From 42adb484deec290bf970b8b977ce52fefdb69618 Mon Sep 17 00:00:00 2001 From: studyhill Date: Sat, 29 Aug 2026 09:45:21 +0800 Subject: [PATCH] =?utf8?q?=E6=8C=89=20v1.1=20=E8=AE=BE=E8=AE=A1=E9=87=8D?= =?utf8?q?=E5=BB=BA=E4=B8=BA=E5=A4=9A=E8=AF=BE=E7=A8=8B=E5=AD=A6=E4=B9=A0?= =?utf8?q?=E8=BF=BD=E8=B8=AA=E7=B3=BB=E7=BB=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit 后端: - 新增 db.py:八张表 DDL(内容层/进度层/计划层/执行层), study_logs 扩展 course_id/lesson_id/action,旧 tasks 表冻结 - 新增 scheduler.py:幂等调度引擎 + 完成回写链 + 复习队列升降级; 每日配额按当天已生成任务数截断,修复完成后补位突破配额的问题 - 新增 seed.py:雅思词汇示例数据(8 课 48 词,含进度指针与复习队列) - main.py 重写为 11 个接口;端口改用 1063(1061 已被 words-v2 占用) 前端: - 重构为四页:今日 / 课程 / 学习计划 / 课程管理 - 新增课时浮层与内容类型注册表(vocab_table/reading_article/lesson_brief, 未知类型兜底到 lesson_brief) - 移植原型浅蓝小碎花主题;favicon 换浅绿清新风,标签页标题改 Mountion - 顶栏移除用户昵称展示 - vite base 改 /study/ 并加 allowedHosts,配合 nginx 反代到 443 - 删除废弃的 Home 组件与旧样式 --- backend/db.py | 201 +++++++++ backend/main.py | 504 ++++++++++++++++++---- backend/scheduler.py | 266 ++++++++++++ backend/seed.py | 230 ++++++++++ frontend/index.html | 4 +- frontend/public/favicon.svg | 14 +- frontend/src/App.css | 3 - frontend/src/App.jsx | 70 ++- frontend/src/api.js | 31 ++ frontend/src/components/Home.css | 278 ------------ frontend/src/components/Home.jsx | 93 ---- frontend/src/components/LessonSheet.jsx | 88 ++++ frontend/src/ctx.js | 11 + frontend/src/index.css | 32 -- frontend/src/main.jsx | 2 +- frontend/src/pages/Admin.jsx | 99 +++++ frontend/src/pages/Courses.jsx | 98 +++++ frontend/src/pages/Plan.jsx | 102 +++++ frontend/src/pages/Today.jsx | 138 ++++++ frontend/src/renderers/LessonBrief.jsx | 47 ++ frontend/src/renderers/ReadingArticle.jsx | 55 +++ frontend/src/renderers/VocabTable.jsx | 41 ++ frontend/src/renderers/index.jsx | 37 ++ frontend/src/theme.css | 192 +++++++++ frontend/vite.config.js | 15 +- 25 files changed, 2143 insertions(+), 508 deletions(-) create mode 100644 backend/db.py create mode 100644 backend/scheduler.py create mode 100644 backend/seed.py delete mode 100644 frontend/src/App.css create mode 100644 frontend/src/api.js delete mode 100644 frontend/src/components/Home.css delete mode 100644 frontend/src/components/Home.jsx create mode 100644 frontend/src/components/LessonSheet.jsx create mode 100644 frontend/src/ctx.js delete mode 100644 frontend/src/index.css create mode 100644 frontend/src/pages/Admin.jsx create mode 100644 frontend/src/pages/Courses.jsx create mode 100644 frontend/src/pages/Plan.jsx create mode 100644 frontend/src/pages/Today.jsx create mode 100644 frontend/src/renderers/LessonBrief.jsx create mode 100644 frontend/src/renderers/ReadingArticle.jsx create mode 100644 frontend/src/renderers/VocabTable.jsx create mode 100644 frontend/src/renderers/index.jsx create mode 100644 frontend/src/theme.css diff --git a/backend/db.py b/backend/db.py new file mode 100644 index 0000000..663d6c6 --- /dev/null +++ b/backend/db.py @@ -0,0 +1,201 @@ +"""数据库连接与建表。 + +表结构以 design-package/schema.sql 为准,分三层: + 内容层 courses / lessons + 进度层 course_progress / lesson_completions + 计划层 study_plans / plan_items / review_queue + 执行层 daily_tasks / study_logs +旧 tasks 表冻结归档,仅保留建表语句以兼容存量库,不再承担业务职责。 +""" + +import datetime +import os +import sqlite3 + +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +DATABASE_PATH = os.path.join(BASE_DIR, "db", "studyhill.db") + + +def get_conn(): + conn = sqlite3.connect(DATABASE_PATH) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA foreign_keys = ON") + return conn + + +def today() -> str: + """本地日期 YYYY-MM-DD,daily_tasks / review_queue 的日期口径。""" + return datetime.date.today().isoformat() + + +def now() -> str: + return datetime.datetime.now().isoformat(timespec="seconds") + + +# ---------- 沿用旧表(users / tasks / study_logs)---------- +# study_logs 在此直接带上扩展列,使全新库无需再走 ALTER 分支。 +LEGACY_SCHEMA = """ +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + nickname TEXT NOT NULL, + avatar_url TEXT, + wx_openid TEXT UNIQUE, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +-- 冻结归档:职责已由 daily_tasks 接管 +CREATE TABLE IF NOT EXISTS tasks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + description TEXT, + owner_id INTEGER, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (owner_id) REFERENCES users(id) +); + +CREATE TABLE IF NOT EXISTS study_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER, + task_id INTEGER, + op_type TEXT, + status TEXT, + course_id INTEGER, + lesson_id INTEGER, + action TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); +""" + +# ---------- 内容层 / 进度层 / 计划层 / 执行层 ---------- +SCHEMA = """ +CREATE TABLE IF NOT EXISTS courses ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + code TEXT UNIQUE NOT NULL, + name TEXT NOT NULL, + category TEXT NOT NULL, + locale TEXT NOT NULL DEFAULT 'en', + description TEXT, + icon TEXT, + color TEXT NOT NULL DEFAULT '#4a90c8', + is_open INTEGER NOT NULL DEFAULT 1, + allow_skip INTEGER NOT NULL DEFAULT 0, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS lessons ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + course_id INTEGER NOT NULL REFERENCES courses(id), + lesson_no INTEGER NOT NULL, + title TEXT NOT NULL, + content_type TEXT NOT NULL, + payload TEXT NOT NULL, + launch_uri TEXT, + completion_type TEXT NOT NULL DEFAULT 'manual', + completion_threshold REAL, + duration_min INTEGER, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE(course_id, lesson_no) +); + +CREATE TABLE IF NOT EXISTS course_progress ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id), + course_id INTEGER NOT NULL REFERENCES courses(id), + next_lesson_no INTEGER NOT NULL DEFAULT 1, + completed_count INTEGER NOT NULL DEFAULT 0, + total_lessons INTEGER NOT NULL DEFAULT 0, + finished_at TEXT, + updated_at TEXT NOT NULL, + UNIQUE(user_id, course_id) +); + +CREATE TABLE IF NOT EXISTS lesson_completions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id), + lesson_id INTEGER NOT NULL REFERENCES lessons(id), + course_id INTEGER NOT NULL, + score REAL, + detail TEXT, + completed_at TEXT NOT NULL, + UNIQUE(user_id, lesson_id) +); + +CREATE TABLE IF NOT EXISTS study_plans ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id), + name TEXT NOT NULL DEFAULT '我的计划', + is_active INTEGER NOT NULL DEFAULT 1, + start_date TEXT NOT NULL, + end_date TEXT, + created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS plan_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + plan_id INTEGER NOT NULL REFERENCES study_plans(id), + course_id INTEGER NOT NULL REFERENCES courses(id), + daily_new INTEGER NOT NULL DEFAULT 1, + daily_review INTEGER NOT NULL DEFAULT 1, + weekdays TEXT NOT NULL DEFAULT '1,2,3,4,5,6,7', + UNIQUE(plan_id, course_id) +); + +CREATE TABLE IF NOT EXISTS review_queue ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id), + lesson_id INTEGER NOT NULL REFERENCES lessons(id), + course_id INTEGER NOT NULL, + stage INTEGER NOT NULL DEFAULT 0, + due_date TEXT NOT NULL, + last_result TEXT, + updated_at TEXT NOT NULL, + UNIQUE(user_id, lesson_id) +); + +CREATE TABLE IF NOT EXISTS daily_tasks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id), + task_date TEXT NOT NULL, + course_id INTEGER NOT NULL, + lesson_id INTEGER NOT NULL, + task_type TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + generated_at TEXT NOT NULL, + done_at TEXT, + UNIQUE(user_id, task_date, lesson_id, task_type) +); + +CREATE INDEX IF NOT EXISTS idx_lessons_course ON lessons(course_id, lesson_no); +CREATE INDEX IF NOT EXISTS idx_progress_user ON course_progress(user_id); +CREATE INDEX IF NOT EXISTS idx_completions_user_lesson ON lesson_completions(user_id, lesson_id); +CREATE INDEX IF NOT EXISTS idx_review_due ON review_queue(user_id, due_date); +CREATE INDEX IF NOT EXISTS idx_tasks_user_date ON daily_tasks(user_id, task_date); +CREATE INDEX IF NOT EXISTS idx_logs_user_time ON study_logs(user_id, created_at); +""" + +# 存量库的 study_logs 缺这三列,按需补齐 +_LOG_EXTRA_COLUMNS = ( + ("course_id", "INTEGER"), + ("lesson_id", "INTEGER"), + ("action", "TEXT"), +) + + +def _ensure_log_columns(conn): + existing = {r["name"] for r in conn.execute("PRAGMA table_info(study_logs)")} + for col, decl in _LOG_EXTRA_COLUMNS: + if col not in existing: + conn.execute(f"ALTER TABLE study_logs ADD COLUMN {col} {decl}") + + +def init_db(): + os.makedirs(os.path.join(BASE_DIR, "db"), exist_ok=True) + conn = get_conn() + conn.executescript(LEGACY_SCHEMA) + conn.executescript(SCHEMA) + _ensure_log_columns(conn) + conn.commit() + conn.close() diff --git a/backend/main.py b/backend/main.py index 8c2742c..cdd99aa 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,14 +1,21 @@ +"""学习山峰 API · 多课程学习追踪。 + +用户体系尚未接入,当前固定使用 demo 用户(users.id=1)。 +""" + +import datetime +import json + from fastapi import FastAPI, HTTPException from fastapi.middleware.cors import CORSMiddleware from pydantic import BaseModel -from typing import Optional -import sqlite3 -import os -import datetime -app = FastAPI(title="学习山峰 API", version="0.1.0") +import db +import scheduler +from seed import DEMO_USER_ID + +app = FastAPI(title="学习山峰 API", version="0.2.0") -# 允许 CORS(开发期) app.add_middleware( CORSMiddleware, allow_origins=["*"], @@ -17,106 +24,435 @@ app.add_middleware( allow_headers=["*"], ) -# --- 路径自动修复逻辑(仿 Words) --- -BASE_DIR = os.path.dirname(os.path.abspath(__file__)) -DATABASE_PATH = os.path.join(BASE_DIR, "db", "studyhill.db") +WEEKDAY_CN = ["星期一", "星期二", "星期三", "星期四", "星期五", "星期六", "星期日"] -def get_conn(): - conn = sqlite3.connect(DATABASE_PATH) - conn.row_factory = sqlite3.Row - return conn +# ---------- 启动 ---------- +@app.on_event("startup") +def on_startup(): + db.init_db() -def init_db(): - """首次启动时建表,并写入示例数据,保证首页有内容可展示。""" - os.makedirs(os.path.join(BASE_DIR, "db"), exist_ok=True) - conn = get_conn() - cur = conn.cursor() - cur.executescript( - """ - CREATE TABLE IF NOT EXISTS users ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - nickname TEXT NOT NULL, - avatar_url TEXT, - wx_openid TEXT UNIQUE, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP - ); - - CREATE TABLE IF NOT EXISTS tasks ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - title TEXT NOT NULL, - description TEXT, - owner_id INTEGER, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (owner_id) REFERENCES users(id) - ); - - CREATE TABLE IF NOT EXISTS study_logs ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER, - task_id INTEGER, - op_type TEXT, - status TEXT, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (user_id) REFERENCES users(id), - FOREIGN KEY (task_id) REFERENCES tasks(id) - ); + +# ---------- 模型 ---------- + +class PlanItemIn(BaseModel): + daily_new: int = 0 + daily_review: int = 0 + + +class CompleteIn(BaseModel): + task_id: int = None + task_type: str = "new" # new / review + result: str = "good" # good / again(仅 review 有效) + score: float = None + detail: str = None + + +class CoursePatch(BaseModel): + is_open: int = None + + +# ---------- 工具 ---------- + +def _conn(): + return db.get_conn() + + +def _lesson_out(row): + d = dict(row) + try: + d["payload"] = json.loads(row["payload"] or "{}") + except json.JSONDecodeError: + d["payload"] = {} + return d + + +def _date_label(d: datetime.date) -> str: + return f"{d.month}月{d.day}日 · {WEEKDAY_CN[d.weekday()]}" + + +def _streak_days(conn, user_id) -> int: + rows = conn.execute( + "SELECT DISTINCT substr(completed_at,1,10) AS d FROM lesson_completions WHERE user_id=? ORDER BY d DESC", + (user_id,), + ).fetchall() + days = {r["d"] for r in rows} + cur = datetime.date.fromisoformat(db.today()) + if cur.isoformat() not in days: # 今天还没学,不打断连续记录 + cur -= datetime.timedelta(days=1) + streak = 0 + while cur.isoformat() in days: + streak += 1 + cur -= datetime.timedelta(days=1) + return streak + + +def _week_rate(conn, user_id) -> int: + """本周(周一起)完成率,expired 不计入分母。""" + d = datetime.date.fromisoformat(db.today()) + monday = (d - datetime.timedelta(days=d.weekday())).isoformat() + row = conn.execute( """ - ) - # 仅在空库时塞一点示例数据,方便首页展示 - cur.execute("SELECT COUNT(*) FROM users") - if cur.fetchone()[0] == 0: - cur.execute( - "INSERT INTO users (nickname) VALUES (?)", ("山峰访客",) - ) - cur.execute( - "INSERT INTO tasks (title, description, owner_id) VALUES (?, ?, ?)", - ("第一座山峰", "完成初始学习任务,熟悉学习山峰的节奏", 1), + SELECT SUM(status='done') AS done, SUM(status IN ('pending','done')) AS total + FROM daily_tasks WHERE user_id=? AND task_date>=? AND task_date<=? + """, + (user_id, monday, d.isoformat()), + ).fetchone() + done, total = row["done"] or 0, row["total"] or 0 + return round(done / total * 100) if total else 0 + + +# ---------- 今日 ---------- + +@app.get("/api/today") +def get_today(): + conn = _conn() + try: + scheduler.ensure_daily_tasks(conn, DEMO_USER_ID) # 幂等 + date = db.today() + rows = conn.execute( + """ + SELECT dt.id, dt.course_id, dt.lesson_id, dt.task_type, dt.status, + l.lesson_no, l.title, l.content_type, l.duration_min, + c.name AS course_name, c.category, c.color, c.icon + FROM daily_tasks dt + JOIN lessons l ON l.id = dt.lesson_id + JOIN courses c ON c.id = dt.course_id + WHERE dt.user_id=? AND dt.task_date=? AND dt.status IN ('pending','done') + ORDER BY c.sort_order, c.id, dt.task_type DESC, l.lesson_no + """, + (DEMO_USER_ID, date), + ).fetchall() + + groups, order = {}, [] + for r in rows: + cid = r["course_id"] + if cid not in groups: + prog = conn.execute( + "SELECT completed_count, total_lessons FROM course_progress WHERE user_id=? AND course_id=?", + (DEMO_USER_ID, cid), + ).fetchone() + groups[cid] = { + "course_id": cid, + "course_name": r["course_name"], + "category": r["category"], + "color": r["color"], + "icon": r["icon"], + "completed": prog["completed_count"] if prog else 0, + "total": prog["total_lessons"] if prog else 0, + "done": 0, + "count": 0, + "tasks": [], + } + order.append(cid) + g = groups[cid] + g["count"] += 1 + if r["status"] == "done": + g["done"] += 1 + g["tasks"].append( + { + "id": r["id"], + "lesson_id": r["lesson_id"], + "lesson_no": r["lesson_no"], + "title": r["title"], + "task_type": r["task_type"], + "status": r["status"], + "content_type": r["content_type"], + "duration_min": r["duration_min"], + } + ) + + total_tasks = sum(g["count"] for g in groups.values()) + done_tasks = sum(g["done"] for g in groups.values()) + review_pending = conn.execute( + """ + SELECT COUNT(*) FROM review_queue rq + JOIN courses c ON c.id = rq.course_id + WHERE rq.user_id=? AND rq.due_date<=? AND c.is_open=1 + """, + (DEMO_USER_ID, date), + ).fetchone()[0] + open_courses = conn.execute("SELECT COUNT(*) FROM courses WHERE is_open=1").fetchone()[0] + + return { + "date": date, + "date_label": _date_label(datetime.date.fromisoformat(date)), + "stats": { + "total": total_tasks, + "done": done_tasks, + "percent": round(done_tasks / total_tasks * 100) if total_tasks else 0, + }, + "open_courses": open_courses, + "review_pending": review_pending, + "streak_days": _streak_days(conn, DEMO_USER_ID), + "week_rate": _week_rate(conn, DEMO_USER_ID), + "groups": [groups[c] for c in order], + "closed_courses": [ + {"id": r["id"], "name": r["name"]} + for r in conn.execute( + "SELECT id, name FROM courses WHERE is_open=0 ORDER BY sort_order, id" + ).fetchall() + ], + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + finally: + conn.close() + + +# ---------- 课程 ---------- + +@app.get("/api/courses") +def list_courses(): + conn = _conn() + try: + rows = conn.execute( + """ + SELECT c.*, + COALESCE(cp.completed_count,0) AS completed, + COALESCE(cp.next_lesson_no,1) AS next_lesson_no, + COALESCE(cp.total_lessons, + (SELECT COUNT(*) FROM lessons l WHERE l.course_id=c.id)) AS total + FROM courses c + LEFT JOIN course_progress cp ON cp.course_id=c.id AND cp.user_id=? + ORDER BY c.sort_order, c.id + """, + (DEMO_USER_ID,), + ).fetchall() + return { + "courses": [ + { + "id": r["id"], + "code": r["code"], + "name": r["name"], + "category": r["category"], + "locale": r["locale"], + "description": r["description"], + "icon": r["icon"], + "color": r["color"], + "is_open": r["is_open"], + "allow_skip": r["allow_skip"], + "completed": r["completed"], + "total": r["total"], + "next_lesson_no": r["next_lesson_no"], + } + for r in rows + ] + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + finally: + conn.close() + + +@app.get("/api/courses/{course_id}/lessons") +def list_lessons(course_id: int): + conn = _conn() + try: + rows = conn.execute( + "SELECT * FROM lessons WHERE course_id=? ORDER BY lesson_no", (course_id,) + ).fetchall() + return {"lessons": [_lesson_out(r) for r in rows]} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + finally: + conn.close() + + +# ---------- 课时 ---------- + +@app.get("/api/lessons/{lesson_id}") +def get_lesson(lesson_id: int): + conn = _conn() + try: + row = conn.execute( + """ + SELECT l.*, c.name AS course_name, c.color + FROM lessons l JOIN courses c ON c.id=l.course_id + WHERE l.id=? + """, + (lesson_id,), + ).fetchone() + if not row: + raise HTTPException(status_code=404, detail="lesson not found") + out = _lesson_out(row) + out["content_type"] = out["content_type"] or "lesson_brief" + return out + finally: + conn.close() + + +@app.post("/api/lessons/{lesson_id}/open") +def open_lesson(lesson_id: int): + conn = _conn() + try: + scheduler.log_open(conn, DEMO_USER_ID, lesson_id) + return {"ok": True} + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + finally: + conn.close() + + +@app.post("/api/lessons/{lesson_id}/complete") +def complete_lesson(lesson_id: int, body: CompleteIn): + conn = _conn() + try: + scheduler.complete_lesson( + conn, DEMO_USER_ID, lesson_id, + task_type=body.task_type, result=body.result, + score=body.score, detail=body.detail, ) - cur.execute( - "INSERT INTO tasks (title, description, owner_id) VALUES (?, ?, ?)", - ("晨间复习", "每日清晨回顾昨日所学", 1), + cur = conn.execute( + """ + UPDATE daily_tasks SET status='done', done_at=? + WHERE user_id=? AND lesson_id=? AND task_type=? AND task_date=? AND status='pending' + """, + (db.now(), DEMO_USER_ID, lesson_id, body.task_type, db.today()), ) - conn.commit() - conn.close() + conn.commit() + return {"ok": True, "tasks_done": cur.rowcount} + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + finally: + conn.close() -# --- 数据模型 --- -class HomeData(BaseModel): - users_count: int - tasks_count: int - logs_count: int +# ---------- 学习计划 ---------- +@app.get("/api/plan") +def get_plan(): + conn = _conn() + try: + plan = conn.execute( + "SELECT * FROM study_plans WHERE user_id=? AND is_active=1 ORDER BY id LIMIT 1", + (DEMO_USER_ID,), + ).fetchone() + if not plan: + return {"plan": None, "items": []} + rows = conn.execute( + """ + SELECT pi.course_id, pi.daily_new, pi.daily_review, pi.weekdays, + c.name AS course_name, c.color, c.icon, c.is_open + FROM plan_items pi JOIN courses c ON c.id=pi.course_id + WHERE pi.plan_id=? ORDER BY c.sort_order, c.id + """, + (plan["id"],), + ).fetchall() + return { + "plan": { + "id": plan["id"], + "name": plan["name"], + "start_date": plan["start_date"], + }, + "items": [dict(r) for r in rows], + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + finally: + conn.close() + + +@app.put("/api/plan/items/{course_id}") +def update_plan_item(course_id: int, body: PlanItemIn): + conn = _conn() + try: + plan = conn.execute( + "SELECT id FROM study_plans WHERE user_id=? AND is_active=1 ORDER BY id LIMIT 1", + (DEMO_USER_ID,), + ).fetchone() + if not plan: + raise HTTPException(status_code=404, detail="no active plan") + cur = conn.execute( + "UPDATE plan_items SET daily_new=?, daily_review=? WHERE plan_id=? AND course_id=?", + (max(0, body.daily_new), max(0, body.daily_review), plan["id"], course_id), + ) + if cur.rowcount == 0: + conn.execute( + "INSERT INTO plan_items (plan_id, course_id, daily_new, daily_review) VALUES (?,?,?,?)", + (plan["id"], course_id, max(0, body.daily_new), max(0, body.daily_review)), + ) + conn.commit() + return {"ok": True} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + finally: + conn.close() -# 启动即建表,保证无论何种方式启动都有库结构 -init_db() +# ---------- 后台管理 ---------- -# --- 路由 --- -@app.get("/api/home", response_model=HomeData) -async def get_home_data(): +@app.patch("/api/admin/courses/{course_id}") +def patch_course(course_id: int, body: CoursePatch): + conn = _conn() try: - conn = get_conn() - cur = conn.cursor() - cur.execute("SELECT COUNT(*) FROM users") - u = cur.fetchone()[0] - cur.execute("SELECT COUNT(*) FROM tasks") - t = cur.fetchone()[0] - cur.execute("SELECT COUNT(*) FROM study_logs") - l = cur.fetchone()[0] + row = conn.execute("SELECT id FROM courses WHERE id=?", (course_id,)).fetchone() + if not row: + raise HTTPException(status_code=404, detail="course not found") + if body.is_open is not None: + conn.execute( + "UPDATE courses SET is_open=?, updated_at=? WHERE id=?", + (1 if body.is_open else 0, db.now(), course_id), + ) + if not body.is_open: + # 关闭:当天未完成任务标 expired,不计入完成率 + scheduler.expire_course_tasks(conn, DEMO_USER_ID, course_id) + conn.commit() + return {"ok": True, "is_open": body.is_open} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + finally: conn.close() - return HomeData(users_count=u, tasks_count=t, logs_count=l) + + +@app.get("/api/admin/stats") +def admin_stats(): + conn = _conn() + try: + row = conn.execute( + """ + SELECT + (SELECT COUNT(*) FROM courses) AS courses, + (SELECT COUNT(*) FROM lessons) AS lessons, + (SELECT COUNT(*) FROM lesson_completions WHERE user_id=?) AS completions, + (SELECT COUNT(*) FROM review_queue WHERE user_id=?) AS in_review, + (SELECT COUNT(*) FROM study_logs WHERE user_id=?) AS logs + """, + (DEMO_USER_ID, DEMO_USER_ID, DEMO_USER_ID), + ).fetchone() + return dict(row) except Exception as e: raise HTTPException(status_code=500, detail=str(e)) + finally: + conn.close() + + +# ---------- 其他 ---------- + +@app.get("/api/me") +def me(): + conn = _conn() + try: + row = conn.execute( + "SELECT id, nickname FROM users WHERE id=?", (DEMO_USER_ID,) + ).fetchone() + return dict(row) if row else {"id": DEMO_USER_ID, "nickname": "访客"} + finally: + conn.close() @app.get("/") -async def root(): - return {"message": "学习山峰 API is running"} +def root(): + return {"message": "学习山峰 API is running", "version": app.version} if __name__ == "__main__": import uvicorn - uvicorn.run(app, host="0.0.0.0", port=1061) + # 1061 已被 words-v2 长期占用,学习山峰改用 1063 + uvicorn.run(app, host="127.0.0.1", port=1063) diff --git a/backend/scheduler.py b/backend/scheduler.py new file mode 100644 index 0000000..672ebb1 --- /dev/null +++ b/backend/scheduler.py @@ -0,0 +1,266 @@ +"""调度引擎与机制回写。 + +对应设计文档 §5:每日任务生成(幂等)、完成回写链、复习队列升降级。 +唯二对外入口是 ensure_daily_tasks() 与 complete_lesson()。 +""" + +import datetime + +import db + +# 简化 SM-2 五档间隔(天),stage 0..4 +REVIEW_INTERVALS = [1, 3, 7, 14, 30] + + +def next_due(stage: int, from_date: str = None) -> str: + """按 stage 推算下次复习日期。stage 超出档位时取最后一档。""" + base = datetime.date.fromisoformat(from_date or db.today()) + idx = min(max(stage, 0), len(REVIEW_INTERVALS) - 1) + return (base + datetime.timedelta(days=REVIEW_INTERVALS[idx])).isoformat() + + +# ---------- 每日任务生成 ---------- + +def ensure_daily_tasks(conn, user_id: int, date: str = None) -> int: + """幂等生成当日任务,返回新增条数。重复触发不会产生重复任务。""" + date = date or db.today() + weekday = str(datetime.date.fromisoformat(date).isoweekday()) + + plan = conn.execute( + "SELECT id FROM study_plans WHERE user_id=? AND is_active=1 ORDER BY id LIMIT 1", + (user_id,), + ).fetchone() + if not plan: + return 0 + + items = conn.execute( + """ + SELECT pi.course_id, pi.daily_new, pi.daily_review, pi.weekdays, c.allow_skip + FROM plan_items pi + JOIN courses c ON c.id = pi.course_id + WHERE pi.plan_id=? AND c.is_open=1 + ORDER BY c.sort_order, c.id + """, + (plan["id"],), + ).fetchall() + + created = 0 + for item in items: + if weekday not in (item["weekdays"] or "").split(","): + continue + created += _gen_new(conn, user_id, date, item) + created += _gen_review(conn, user_id, date, item) + conn.commit() + return created + + +def _remaining_quota(conn, user_id, date, course_id, task_type, quota) -> int: + """当天该类型已生成的任务(含已完成)占用配额,保证每天上限不被突破。 + + 否则完成一课后再次调度会拿下一课补位,导致当日实际任务数超过计划配额。 + """ + used = conn.execute( + "SELECT COUNT(*) FROM daily_tasks WHERE user_id=? AND task_date=? AND course_id=? AND task_type=?", + (user_id, date, course_id, task_type), + ).fetchone()[0] + return max(0, (quota or 0) - used) + + +def _gen_new(conn, user_id, date, item) -> int: + course_id = item["course_id"] + want = _remaining_quota(conn, user_id, date, course_id, "new", item["daily_new"]) + if want <= 0: + return 0 + prog = conn.execute( + "SELECT next_lesson_no FROM course_progress WHERE user_id=? AND course_id=?", + (user_id, course_id), + ).fetchone() + start = prog["next_lesson_no"] if prog else 1 + + rows = conn.execute( + "SELECT id, lesson_no FROM lessons WHERE course_id=? AND lesson_no>=? ORDER BY lesson_no", + (course_id, start), + ).fetchall() + + # 顺序约束:allow_skip=0 的课程只取从指针起的连续段 + picked, expected = [], start + for r in rows: + if r["lesson_no"] != expected and not item["allow_skip"]: + break + picked.append(r) + expected = r["lesson_no"] + 1 + if len(picked) >= want: + break + + return sum( + _insert_task(conn, user_id, date, course_id, r["id"], "new") for r in picked + ) + + +def _gen_review(conn, user_id, date, item) -> int: + want = _remaining_quota( + conn, user_id, date, item["course_id"], "review", item["daily_review"] + ) + if want <= 0: + return 0 + rows = conn.execute( + """ + SELECT lesson_id FROM review_queue + WHERE user_id=? AND course_id=? AND due_date<=? + ORDER BY due_date ASC, id ASC + LIMIT ? + """, + (user_id, item["course_id"], date, want), + ).fetchall() + return sum( + _insert_task(conn, user_id, date, item["course_id"], r["lesson_id"], "review") + for r in rows + ) + + +def _insert_task(conn, user_id, date, course_id, lesson_id, task_type) -> int: + cur = conn.execute( + """ + INSERT OR IGNORE INTO daily_tasks + (user_id, task_date, course_id, lesson_id, task_type, status, generated_at) + VALUES (?,?,?,?,?,'pending',?) + """, + (user_id, date, course_id, lesson_id, task_type, db.now()), + ) + return cur.rowcount + + +# ---------- 完成回写 ---------- + +def complete_lesson(conn, user_id, lesson_id, task_type="new", result="good", + score=None, detail=None): + """完成一课:双写 completions + logs,按任务类型推进进度或复习队列。 + + task_type='new' → 推进 next_lesson_no,复习入队(stage=0,明天) + task_type='review' → 复习升降级(good 升档 / again 归零,满 5 轮出队) + """ + lesson = conn.execute("SELECT * FROM lessons WHERE id=?", (lesson_id,)).fetchone() + if not lesson: + raise ValueError(f"lesson {lesson_id} not found") + + ts = db.now() + conn.execute( + """ + INSERT OR IGNORE INTO lesson_completions + (user_id, lesson_id, course_id, score, detail, completed_at) + VALUES (?,?,?,?,?,?) + """, + (user_id, lesson_id, lesson["course_id"], score, detail, ts), + ) + conn.execute( + """ + INSERT INTO study_logs (user_id, course_id, lesson_id, action, op_type, status, created_at) + VALUES (?,?,?,?,?,?,?) + """, + (user_id, lesson["course_id"], lesson_id, "complete", task_type, "done", ts), + ) + + if task_type == "review": + _promote_review(conn, user_id, lesson, result) + else: + _advance_progress(conn, user_id, lesson) + _enqueue_review(conn, user_id, lesson) + conn.commit() + + +def log_open(conn, user_id, lesson_id): + lesson = conn.execute("SELECT id, course_id FROM lessons WHERE id=?", (lesson_id,)).fetchone() + if not lesson: + raise ValueError(f"lesson {lesson_id} not found") + conn.execute( + """ + INSERT INTO study_logs (user_id, course_id, lesson_id, action, op_type, created_at) + VALUES (?,?,?,?,?,?) + """, + (user_id, lesson["course_id"], lesson_id, "open", "open", db.now()), + ) + conn.commit() + + +def _advance_progress(conn, user_id, lesson): + course_id = lesson["course_id"] + ts = db.now() + conn.execute( + """ + INSERT OR IGNORE INTO course_progress + (user_id, course_id, next_lesson_no, completed_count, total_lessons, updated_at) + VALUES (?,?,1,0,0,?) + """, + (user_id, course_id, ts), + ) + row = conn.execute( + "SELECT * FROM course_progress WHERE user_id=? AND course_id=?", + (user_id, course_id), + ).fetchone() + + total = conn.execute("SELECT COUNT(*) FROM lessons WHERE course_id=?", (course_id,)).fetchone()[0] + max_no = conn.execute( + "SELECT COALESCE(MAX(lesson_no),0) FROM lessons WHERE course_id=?", (course_id,) + ).fetchone()[0] + # completed_count 是指针缓存,始终由完成记录重算 + completed = conn.execute( + "SELECT COUNT(*) FROM lesson_completions WHERE user_id=? AND course_id=?", + (user_id, course_id), + ).fetchone()[0] + + nxt = max(row["next_lesson_no"], lesson["lesson_no"] + 1) + finished = ts if (total > 0 and nxt > max_no) else None + conn.execute( + """ + UPDATE course_progress + SET next_lesson_no=?, completed_count=?, total_lessons=?, + finished_at=COALESCE(?, finished_at), updated_at=? + WHERE user_id=? AND course_id=? + """, + (nxt, completed, total, finished, ts, user_id, course_id), + ) + + +def _enqueue_review(conn, user_id, lesson): + conn.execute( + """ + INSERT INTO review_queue (user_id, lesson_id, course_id, stage, due_date, last_result, updated_at) + VALUES (?,?,?,0,?,NULL,?) + ON CONFLICT(user_id, lesson_id) DO UPDATE SET + stage=0, due_date=excluded.due_date, last_result=NULL, updated_at=excluded.updated_at + """, + (user_id, lesson["id"], lesson["course_id"], next_due(0), db.now()), + ) + + +def _promote_review(conn, user_id, lesson, result="good"): + row = conn.execute( + "SELECT * FROM review_queue WHERE user_id=? AND lesson_id=?", + (user_id, lesson["id"]), + ).fetchone() + if not row: + return + if result == "again": + stage = 0 + else: + stage = row["stage"] + 1 + if stage >= len(REVIEW_INTERVALS): # 满 5 轮视为长记住,移出队列 + conn.execute("DELETE FROM review_queue WHERE id=?", (row["id"],)) + return + conn.execute( + "UPDATE review_queue SET stage=?, due_date=?, last_result=?, updated_at=? WHERE id=?", + (stage, next_due(stage), result, db.now(), row["id"]), + ) + + +def expire_course_tasks(conn, user_id, course_id): + """课程关闭:当天该课程未完成任务标 expired,不计入完成率。""" + cur = conn.execute( + """ + UPDATE daily_tasks SET status='expired' + WHERE user_id=? AND course_id=? AND task_date=? AND status='pending' + """, + (user_id, course_id, db.today()), + ) + conn.commit() + return cur.rowcount diff --git a/backend/seed.py b/backend/seed.py new file mode 100644 index 0000000..4ce4d03 --- /dev/null +++ b/backend/seed.py @@ -0,0 +1,230 @@ +"""示例数据:一门「雅思词汇」课程 + 进度指针 + 复习队列 + 计划。 + +预置状态:前 3 课已完成(next_lesson_no=4),复习队列含到期项, +计划为每天新学 1 课 + 复习 1 课 —— 使「今日」页首次打开即有任务。 +""" + +import datetime +import json + +import db + +DEMO_USER_ID = 1 +DEMO_NICKNAME = "小阿姨" + +COURSE = { + "code": "IELTS-VOCAB-01", + "name": "雅思词汇", + "category": "vocab", + "locale": "en", + "description": "按 List 推进,每课 6 个高频词,逐词翻面打卡", + "icon": "A", + "color": "#4a90c8", + "is_open": 1, + "allow_skip": 0, + "sort_order": 1, +} + +# (课时标题, [(word, phonetic, meaning, example), ...]) +WORD_LISTS = [ + ("List 1 · 高频动词 A–B", [ + ("abandon", "/əˈbændən/", "v. 放弃;抛弃", "The plan was abandoned for lack of funding."), + ("beneficial", "/ˌbenɪˈfɪʃl/", "adj. 有益的;有利的", "Regular exercise is beneficial to health."), + ("capacity", "/kəˈpæsəti/", "n. 容量;能力", "The hall has a seating capacity of 500."), + ("decline", "/dɪˈklaɪn/", "v./n. 下降;婉拒", "Birth rates have declined steadily since 2010."), + ("emphasize", "/ˈemfəsaɪz/", "v. 强调;着重", "The report emphasizes the need for reform."), + ("fluctuate", "/ˈflʌktʃueɪt/", "v. 波动;起伏", "Prices fluctuated between $20 and $30."), + ]), + ("List 2 · 高频动词 G–M", [ + ("generate", "/ˈdʒenəreɪt/", "v. 产生;生成", "Solar panels generate electricity from sunlight."), + ("hierarchy", "/ˈhaɪərɑːki/", "n. 等级制度;层级", "The company has a relatively flat hierarchy."), + ("implement", "/ˈɪmplɪment/", "v. 实施;执行", "The policy was implemented last April."), + ("justify", "/ˈdʒʌstɪfaɪ/", "v. 证明…正当;为…辩护", "Nothing can justify such behaviour."), + ("leverage", "/ˈliːvərɪdʒ/", "v./n. 利用;杠杆作用", "They leveraged social media to reach customers."), + ("maintain", "/meɪnˈteɪn/", "v. 维持;保养;主张", "It is costly to maintain the old system."), + ]), + ("List 3 · 高频动词 N–S", [ + ("negotiate", "/nɪˈɡəʊʃieɪt/", "v. 谈判;协商", "Unions negotiated a 3% pay rise."), + ("obstacle", "/ˈɒbstəkl/", "n. 障碍;阻碍", "Lack of capital is the main obstacle."), + ("perceive", "/pəˈsiːv/", "v. 察觉;认为", "Risk is perceived differently across cultures."), + ("quantify", "/ˈkwɒntɪfaɪ/", "v. 量化;确定数量", "It is hard to quantify the damage."), + ("reinforce", "/ˌriːɪnˈfɔːs/", "v. 加强;强化", "The findings reinforce earlier research."), + ("sustain", "/səˈsteɪn/", "v. 维持;支撑", "The sector cannot sustain current growth."), + ]), + ("List 4 · 高频词 T–Y", [ + ("tackle", "/ˈtækl/", "v. 应对;解决", "Governments must tackle climate change."), + ("ultimate", "/ˈʌltɪmət/", "adj. 最终的;根本的", "The ultimate goal is zero emissions."), + ("volatile", "/ˈvɒlətaɪl/", "adj. 易变的;不稳定的", "Food prices remain volatile."), + ("welfare", "/ˈwelfeə(r)/", "n. 福利;福祉", "Public spending on welfare has risen."), + ("yield", "/jiːld/", "v./n. 产生;产量", "The new process yields 20% more output."), + ("accumulate", "/əˈkjuːmjəleɪt/", "v. 积累;积聚", "Debt accumulated over a decade."), + ]), + ("List 5 · 学术形容词", [ + ("adequate", "/ˈædɪkwət/", "adj. 足够的;适当的", "The sample size was adequate."), + ("coherent", "/kəʊˈhɪərənt/", "adj. 连贯的;有条理的", "She presented a coherent argument."), + ("deteriorate", "/dɪˈtɪəriəreɪt/", "v. 恶化;退化", "Air quality has deteriorated sharply."), + ("eligible", "/ˈelɪdʒəbl/", "adj. 有资格的;符合条件的", "Only residents are eligible to apply."), + ("facilitate", "/fəˈsɪlɪteɪt/", "v. 促进;使便利", "The fund facilitates access to training."), + ("gradual", "/ˈɡrædʒuəl/", "adj. 逐渐的;渐进的", "There was a gradual shift in attitudes."), + ]), + ("List 6 · 学术动词", [ + ("hinder", "/ˈhɪndə(r)/", "v. 阻碍;妨碍", "Bureaucracy hinders innovation."), + ("incentive", "/ɪnˈsentɪv/", "n. 激励;动机", "Tax breaks act as an incentive."), + ("latitude", "/ˈlætɪtjuːd/", "n. 纬度;自由度", "Managers have wide latitude in hiring."), + ("mitigate", "/ˈmɪtɪɡeɪt/", "v. 减轻;缓解", "Planting trees mitigates urban heat."), + ("notorious", "/nəʊˈtɔːriəs/", "adj. 臭名昭著的", "The area is notorious for traffic jams."), + ("optimum", "/ˈɒptɪməm/", "adj. 最佳的", "30°C is the optimum temperature."), + ]), + ("List 7 · 抽象名词", [ + ("paradigm", "/ˈpærədaɪm/", "n. 范式;典范", "It marks a paradigm shift in teaching."), + ("phenomenon", "/fəˈnɒmɪnən/", "n. 现象", "Urban sprawl is a global phenomenon."), + ("prevalent", "/ˈprevələnt/", "adj. 普遍的;流行的", "Such views are prevalent among youth."), + ("rigorous", "/ˈrɪɡərəs/", "adj. 严谨的;严格的", "The study followed a rigorous method."), + ("scenario", "/səˈnɑːriəʊ/", "n. 情景;设想", "Consider the worst-case scenario."), + ("threshold", "/ˈθreʃhəʊld/", "n. 门槛;临界点", "Income above the tax threshold."), + ]), + ("List 8 · 进阶词汇", [ + ("unprecedented", "/ʌnˈpresɪdentɪd/", "adj. 前所未有的", "The pace of change is unprecedented."), + ("urbanisation", "/ˌɜːbənaɪˈzeɪʃn/", "n. 城市化", "Rapid urbanisation strains services."), + ("vacant", "/ˈveɪkənt/", "adj. 空缺的;空着的", "Few flats remain vacant."), + ("abundant", "/əˈbʌndənt/", "adj. 丰富的;充裕的", "The region has abundant rainfall."), + ("diverse", "/daɪˈvɜːs/", "adj. 多样的;不同的", "The student body is culturally diverse."), + ("viable", "/ˈvaɪəbl/", "adj. 可行的", "Solar power is now commercially viable."), + ]), +] + +# 预置完成:前 3 课已完成,指针停在第 4 课 +COMPLETED_LESSONS = 3 +# 复习队列预置:[lesson_no, stage, 相对今天的偏移天数] +REVIEW_SEED = [(1, 1, -1), (2, 0, 0), (3, 0, 1)] + + +def _payload(words) -> str: + return json.dumps( + { + "version": 1, + "content_type": "vocab_table", + "meta": {"estimated_min": 15, "difficulty": 2}, + "words": [ + {"word": w, "phonetic": p, "meaning": m, "example": e} + for w, p, m, e in words + ], + }, + ensure_ascii=False, + ) + + +def _demo_user(conn): + """保证 demo 用户存在。旧脚手架已建过 id=1 时,顺带把昵称统一成 demo 名。""" + conn.execute( + "INSERT OR IGNORE INTO users (id, nickname) VALUES (?,?)", + (DEMO_USER_ID, DEMO_NICKNAME), + ) + conn.execute( + "UPDATE users SET nickname=? WHERE id=?", (DEMO_NICKNAME, DEMO_USER_ID) + ) + return DEMO_USER_ID + + +def seed(force: bool = False) -> bool: + """写入示例数据。库中已有课程时跳过(force=True 则先清空课程相关数据)。""" + conn = db.get_conn() + if force: + conn.executescript( + """ + DELETE FROM daily_tasks; + DELETE FROM review_queue; + DELETE FROM lesson_completions; + DELETE FROM course_progress; + DELETE FROM plan_items; + DELETE FROM study_plans; + DELETE FROM lessons; + DELETE FROM courses; + """ + ) + exists = conn.execute("SELECT COUNT(*) FROM courses").fetchone()[0] + if exists: + conn.close() + return False + + ts = db.now() + user_id = _demo_user(conn) + cur = conn.execute( + """ + INSERT INTO courses (code, name, category, locale, description, icon, color, + is_open, allow_skip, sort_order, created_at, updated_at) + VALUES (:code, :name, :category, :locale, :description, :icon, :color, + :is_open, :allow_skip, :sort_order, :created_at, :updated_at) + """, + {**COURSE, "created_at": ts, "updated_at": ts}, + ) + course_id = cur.lastrowid + + lesson_ids = {} + for no, (title, words) in enumerate(WORD_LISTS, start=1): + c = conn.execute( + """ + INSERT INTO lessons (course_id, lesson_no, title, content_type, payload, + completion_type, duration_min, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?) + """, + (course_id, no, title, "vocab_table", _payload(words), "manual", 15, ts, ts), + ) + lesson_ids[no] = c.lastrowid + + total = len(WORD_LISTS) + conn.execute( + """ + INSERT INTO course_progress (user_id, course_id, next_lesson_no, completed_count, + total_lessons, updated_at) + VALUES (?,?,?,?,?,?) + """, + (user_id, course_id, COMPLETED_LESSONS + 1, COMPLETED_LESSONS, total, ts), + ) + + today_d = datetime.date.fromisoformat(db.today()) + for no in range(1, COMPLETED_LESSONS + 1): + done_at = (today_d - datetime.timedelta(days=COMPLETED_LESSONS - no + 1)).isoformat() + "T21:00:00" + conn.execute( + """ + INSERT INTO lesson_completions (user_id, lesson_id, course_id, completed_at) + VALUES (?,?,?,?) + """, + (user_id, lesson_ids[no], course_id, done_at), + ) + + for no, stage, offset in REVIEW_SEED: + due = (today_d + datetime.timedelta(days=offset)).isoformat() + conn.execute( + """ + INSERT INTO review_queue (user_id, lesson_id, course_id, stage, due_date, updated_at) + VALUES (?,?,?,?,?,?) + """, + (user_id, lesson_ids[no], course_id, stage, due, ts), + ) + + cur = conn.execute( + """ + INSERT INTO study_plans (user_id, name, is_active, start_date, created_at) + VALUES (?,?,?,?,?) + """, + (user_id, "我的计划", 1, db.today(), ts), + ) + conn.execute( + """ + INSERT INTO plan_items (plan_id, course_id, daily_new, daily_review) + VALUES (?,?,?,?) + """, + (cur.lastrowid, course_id, 1, 1), + ) + + conn.commit() + conn.close() + return True + + +if __name__ == "__main__": + import sys + + db.init_db() + print("seeded" if seed(force="--force" in sys.argv) else "skipped (已有课程数据)") diff --git a/frontend/index.html b/frontend/index.html index 845ad62..82565a5 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,9 +4,9 @@ - + - 学习山峰 + Mountion diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg index 2349cb1..d669843 100644 --- a/frontend/public/favicon.svg +++ b/frontend/public/favicon.svg @@ -1,5 +1,13 @@ - - - + + + + + + + + + + + diff --git a/frontend/src/App.css b/frontend/src/App.css deleted file mode 100644 index 915ba97..0000000 --- a/frontend/src/App.css +++ /dev/null @@ -1,3 +0,0 @@ -.App { - min-height: 100vh; -} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index ce1a6ae..b7aca74 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,15 +1,65 @@ -import React from 'react' -import { BrowserRouter as Router, Routes, Route } from 'react-router-dom' -import Home from './components/Home' +import React, { useState } from 'react' +import { BrowserRouter as Router, Routes, Route, NavLink } from 'react-router-dom' +import { LessonCtx } from './ctx' +import Today from './pages/Today' +import Courses from './pages/Courses' +import Plan from './pages/Plan' +import Admin from './pages/Admin' +import LessonSheet from './components/LessonSheet' + +const NAV = [ + { to: '/', label: '今日', end: true }, + { to: '/courses', label: '课程' }, + { to: '/plan', label: '学习计划' }, + { to: '/admin', label: '课程管理' }, +] + +export default function App() { + const [lesson, setLesson] = useState(null) + // 完成任务后自增,通知各页重新拉数据 + const [refreshKey, setRefreshKey] = useState(0) + + const value = { + openLesson: (payload) => setLesson(payload), + closeLesson: () => setLesson(null), + refreshKey, + refresh: () => setRefreshKey((k) => k + 1), + } -function App() { return ( - - - } /> - + // basename 跟随 vite base(线上挂在 /study/ 下),否则路径匹配不上导致白屏 + + +
+
+
+ + + + + 学习山峰 +
+ +
+
+ +
+ + } /> + } /> + } /> + } /> + +
+ + {lesson && } +
) } - -export default App diff --git a/frontend/src/api.js b/frontend/src/api.js new file mode 100644 index 0000000..46a4aad --- /dev/null +++ b/frontend/src/api.js @@ -0,0 +1,31 @@ +import axios from 'axios' + +// 线上经 nginx 挂在 /study/ 下,接口前缀需一致;开发期由 vite proxy 去掉 /study 后转发 +const API_BASE = '/study/api' + +const http = axios.create({ baseURL: API_BASE }) + +const unwrap = (p) => p.then((r) => r.data) + +export const api = { + me: () => unwrap(http.get('/me')), + + // 今日:后端会在此幂等触发调度引擎 + today: () => unwrap(http.get('/today')), + + courses: () => unwrap(http.get('/courses')), + courseLessons: (courseId) => unwrap(http.get(`/courses/${courseId}/lessons`)), + + lesson: (lessonId) => unwrap(http.get(`/lessons/${lessonId}`)), + openLesson: (lessonId) => unwrap(http.post(`/lessons/${lessonId}/open`)), + completeLesson: (lessonId, body) => unwrap(http.post(`/lessons/${lessonId}/complete`, body)), + + plan: () => unwrap(http.get('/plan')), + updatePlanItem: (courseId, dailyNew, dailyReview) => + unwrap(http.put(`/plan/items/${courseId}`, { daily_new: dailyNew, daily_review: dailyReview })), + + // 后台 + setCourseOpen: (courseId, isOpen) => + unwrap(http.patch(`/admin/courses/${courseId}`, { is_open: isOpen ? 1 : 0 })), + stats: () => unwrap(http.get('/admin/stats')), +} diff --git a/frontend/src/components/Home.css b/frontend/src/components/Home.css deleted file mode 100644 index 9caca76..0000000 --- a/frontend/src/components/Home.css +++ /dev/null @@ -1,278 +0,0 @@ -/* ========== 首页样式:简单大方 + 高级感 ========== */ - -.home { - max-width: 960px; - margin: 0 auto; - padding: 0 24px 64px; -} - -/* ---------- 顶栏 ---------- */ -.topbar { - display: flex; - align-items: center; - justify-content: space-between; - padding: 24px 4px; - border-bottom: 1px solid var(--border); -} - -.brand { - display: flex; - align-items: center; - gap: 10px; -} - -.brand-mark { - display: inline-flex; - align-items: center; - justify-content: center; - width: 32px; - height: 32px; - border-radius: 8px; - background: var(--ink); - color: var(--gold); - font-weight: 700; - font-size: 16px; - letter-spacing: 0; -} - -.brand-name { - font-size: 17px; - font-weight: 600; - letter-spacing: 0.02em; - color: var(--ink); -} - -.topnav { - display: flex; - gap: 28px; -} - -.topnav a { - font-size: 14px; - color: var(--ink-muted); - text-decoration: none; - transition: color 0.2s ease; -} - -.topnav a:hover, -.topnav a.active { - color: var(--ink); -} - -/* ---------- 欢迎区 ---------- */ -.hero { - position: relative; - padding: 72px 0 56px; - text-align: center; -} - -.hero-accent { - width: 48px; - height: 3px; - margin: 0 auto 28px; - background: var(--gold); - border-radius: 2px; -} - -.hero-title { - font-size: 40px; - font-weight: 700; - letter-spacing: -0.02em; - color: var(--ink); - margin-bottom: 18px; -} - -.hero-subtitle { - max-width: 560px; - margin: 0 auto 36px; - font-size: 16px; - line-height: 1.8; - color: var(--ink-soft); -} - -.hero-actions { - display: flex; - gap: 14px; - justify-content: center; -} - -/* ---------- 按钮 ---------- */ -.btn { - padding: 11px 26px; - border-radius: 10px; - border: 1px solid transparent; - font-size: 14px; - font-weight: 500; - cursor: pointer; - transition: transform 0.15s ease, box-shadow 0.2s ease, background 0.2s ease; - font-family: inherit; -} - -.btn-primary { - background: var(--ink); - color: #fff; -} - -.btn-primary:hover { - background: #1e293b; - transform: translateY(-1px); - box-shadow: 0 6px 18px rgba(15, 23, 42, 0.18); -} - -.btn-ghost { - background: transparent; - color: var(--ink-soft); - border-color: var(--border); -} - -.btn-ghost:hover { - color: var(--ink); - border-color: var(--ink-muted); - background: var(--surface); -} - -/* ---------- 数据卡片 ---------- */ -.stats { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 20px; - margin-bottom: 40px; -} - -.stat-card { - background: var(--surface); - border: 1px solid var(--border); - border-radius: 14px; - padding: 28px 24px; - box-shadow: var(--shadow-sm); - position: relative; - overflow: hidden; - transition: box-shadow 0.2s ease, transform 0.15s ease; -} - -.stat-card::before { - content: ''; - position: absolute; - top: 0; - left: 0; - right: 0; - height: 2px; - background: var(--gold); - opacity: 0.85; -} - -.stat-card:hover { - transform: translateY(-2px); - box-shadow: var(--shadow-md); -} - -.stat-value { - font-size: 34px; - font-weight: 700; - color: var(--ink); - letter-spacing: -0.02em; - line-height: 1.1; - margin-bottom: 8px; -} - -.stat-label { - font-size: 13px; - color: var(--ink-muted); - letter-spacing: 0.04em; -} - -.skeleton { - display: inline-block; - width: 48px; - height: 28px; - border-radius: 6px; - background: linear-gradient(90deg, var(--bg-soft) 25%, var(--bg) 37%, var(--bg-soft) 63%); - background-size: 400% 100%; - animation: shimmer 1.4s ease infinite; - vertical-align: middle; -} - -@keyframes shimmer { - 0% { background-position: 100% 0; } - 100% { background-position: 0 0; } -} - -/* ---------- 面板 ---------- */ -.panel { - background: var(--surface); - border: 1px solid var(--border); - border-radius: 14px; - box-shadow: var(--shadow-sm); - overflow: hidden; -} - -.panel-head { - display: flex; - align-items: baseline; - justify-content: space-between; - padding: 20px 24px; - border-bottom: 1px solid var(--border); -} - -.panel-title { - font-size: 17px; - font-weight: 600; - color: var(--ink); - letter-spacing: -0.01em; -} - -.panel-hint { - font-size: 12px; - color: var(--ink-muted); -} - -.panel-empty { - display: flex; - flex-direction: column; - align-items: center; - padding: 56px 24px; - text-align: center; -} - -.empty-mark { - font-size: 40px; - opacity: 0.5; - margin-bottom: 14px; -} - -.panel-empty p { - font-size: 15px; - color: var(--ink-soft); - margin-bottom: 6px; -} - -.panel-empty span { - font-size: 13px; - color: var(--ink-muted); -} - -/* ---------- 页脚 ---------- */ -.footer { - margin-top: 56px; - padding-top: 24px; - border-top: 1px solid var(--border); - text-align: center; - font-size: 12px; - color: var(--ink-muted); - letter-spacing: 0.04em; -} - -/* ---------- 响应式 ---------- */ -@media (max-width: 640px) { - .home { padding: 0 16px 48px; } - - .topnav { gap: 18px; } - - .hero { padding: 48px 0 40px; } - .hero-title { font-size: 30px; } - .hero-subtitle { font-size: 15px; } - - .stats { grid-template-columns: 1fr; gap: 12px; } - .stat-card { padding: 22px 20px; } - .stat-value { font-size: 28px; } -} diff --git a/frontend/src/components/Home.jsx b/frontend/src/components/Home.jsx deleted file mode 100644 index 3bee0ee..0000000 --- a/frontend/src/components/Home.jsx +++ /dev/null @@ -1,93 +0,0 @@ -import React, { useEffect, useState } from 'react' -import axios from 'axios' -import './Home.css' - -// 开发期走 vite 代理(见 vite.config.js),生产期同源部署即可 -const API_BASE = '' - -function Home() { - const [stats, setStats] = useState({ users_count: 0, tasks_count: 0, logs_count: 0 }) - const [loading, setLoading] = useState(true) - - const fetchData = async () => { - try { - const res = await axios.get(`${API_BASE}/api/home`) - setStats(res.data) - } catch (err) { - console.error('获取首页数据失败:', err) - } finally { - setLoading(false) - } - } - - useEffect(() => { - fetchData() - }, []) - - const cards = [ - { key: 'users_count', label: '学习者', value: stats.users_count }, - { key: 'tasks_count', label: '学习任务', value: stats.tasks_count }, - { key: 'logs_count', label: '学习记录', value: stats.logs_count }, - ] - - return ( -
- {/* 顶栏 */} -
-
- - 学习山峰 -
- -
- - {/* 欢迎区 */} -
-
-

欢迎来到学习山峰

-

- 一座属于学习者的山峰 —— 在这里,多人协作、多任务并行,把每一次攀登都留下印记。 -

-
- - -
-
- - {/* 数据卡片 */} -
- {cards.map((c) => ( -
-
- {loading ? : c.value} -
-
{c.label}
-
- ))} -
- - {/* 进度区(占位,后续接入任务进度) */} -
-
-

学习进度

- 多人多任务进度即将上线 -
-
- -

暂无进度数据

- 创建你的第一个学习任务,开启攀登之旅。 -
-
- -
- © {new Date().getFullYear()} 学习山峰 · Study Mountion -
-
- ) -} - -export default Home diff --git a/frontend/src/components/LessonSheet.jsx b/frontend/src/components/LessonSheet.jsx new file mode 100644 index 0000000..70c8b43 --- /dev/null +++ b/frontend/src/components/LessonSheet.jsx @@ -0,0 +1,88 @@ +import React, { useEffect, useState } from 'react' +import { api } from '../api' +import { useLesson } from '../ctx' +import { resolve, hintOf } from '../renderers' + +export default function LessonSheet({ lesson }) { + const { closeLesson, refresh } = useLesson() + const [data, setData] = useState(null) + // 由渲染器按需控制:manual 恒为 true,duration 类需计时达标 + const [ready, setReady] = useState(true) + + useEffect(() => { + setReady(true) + setData(null) + api + .lesson(lesson.lesson_id) + .then((d) => { + setData(d) + api.openLesson(lesson.lesson_id).catch(() => {}) // 记录 open 事件 + }) + .catch(() => setData(null)) + }, [lesson.lesson_id]) + + useEffect(() => { + const onKey = (e) => e.key === 'Escape' && closeLesson() + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, [closeLesson]) + + if (!data) { + return ( +
+
+
加载中…
+
+
+ ) + } + + const entry = resolve(data.content_type) + const { Component } = entry + + const finish = async () => { + await api.completeLesson(lesson.lesson_id, { + task_id: lesson.task_id, + task_type: lesson.task_type || 'new', + }) + refresh() + closeLesson() + } + + return ( +
{ + if (e.currentTarget === e.target) closeLesson() + }} + > +
+
+ +
+
+ {data.course_name} · {data.title} +
+
+ {data.content_type} · {entry.label} +
+
+ +
+ +
+ +
+ +
+ {hintOf(entry, data.payload)} + +
+
+
+ ) +} diff --git a/frontend/src/ctx.js b/frontend/src/ctx.js new file mode 100644 index 0000000..b451c2f --- /dev/null +++ b/frontend/src/ctx.js @@ -0,0 +1,11 @@ +import { createContext, useContext } from 'react' + +/** 课时浮层与跨页刷新。由 App 提供,各页面消费。 */ +export const LessonCtx = createContext({ + openLesson: () => {}, + closeLesson: () => {}, + refreshKey: 0, + refresh: () => {}, +}) + +export const useLesson = () => useContext(LessonCtx) diff --git a/frontend/src/index.css b/frontend/src/index.css deleted file mode 100644 index 7a8815e..0000000 --- a/frontend/src/index.css +++ /dev/null @@ -1,32 +0,0 @@ -* { - margin: 0; - padding: 0; - box-sizing: border-box; -} - -:root { - /* 高级感配色:深夜蓝 + 暖金点缀 + 中性灰 */ - --bg: #f7f8fa; - --bg-soft: #eef1f5; - --surface: #ffffff; - --ink: #0f172a; - --ink-soft: #475569; - --ink-muted: #94a3b8; - --accent: #1e3a5f; - --accent-soft: #3b5778; - --gold: #b08d57; - --border: #e6e8ec; - --shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.04); - --shadow-md: 0 8px 30px rgba(15, 23, 42, 0.06); -} - -body { - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', - 'Hiragino Sans GB', 'Microsoft YaHei', Roboto, Helvetica, Arial, sans-serif; - background-color: var(--bg); - color: var(--ink); - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - min-height: 100vh; - line-height: 1.6; -} diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx index 995053a..6de7ce2 100644 --- a/frontend/src/main.jsx +++ b/frontend/src/main.jsx @@ -1,6 +1,6 @@ import React from 'react' import ReactDOM from 'react-dom/client' -import './index.css' +import './theme.css' import App from './App' ReactDOM.createRoot(document.getElementById('root')).render( diff --git a/frontend/src/pages/Admin.jsx b/frontend/src/pages/Admin.jsx new file mode 100644 index 0000000..f861dbc --- /dev/null +++ b/frontend/src/pages/Admin.jsx @@ -0,0 +1,99 @@ +import React, { useEffect, useState } from 'react' +import { api } from '../api' +import { useLesson } from '../ctx' + +export default function Admin() { + const [courses, setCourses] = useState(null) + // courseId -> { count, content_type, noToId } + const [meta, setMeta] = useState({}) + const [stats, setStats] = useState(null) + const { openLesson, refreshKey, refresh } = useLesson() + + useEffect(() => { + let alive = true + api + .courses() + .then(async (d) => { + const list = d.courses + const pairs = await Promise.all( + list.map((c) => + api + .courseLessons(c.id) + .then((r) => [ + c.id, + { + count: r.lessons.length, + content_type: r.lessons[0]?.content_type || '—', + noToId: Object.fromEntries(r.lessons.map((l) => [l.lesson_no, l.id])), + }, + ]) + .catch(() => [c.id, { count: 0, content_type: '—', noToId: {} }]), + ), + ) + if (!alive) return + setCourses(list) + setMeta(Object.fromEntries(pairs)) + }) + .catch(() => alive && setCourses([])) + api.stats().then(setStats).catch(() => {}) + return () => { + alive = false + } + }, [refreshKey]) + + if (!courses) return
加载中…
+ + const toggle = async (c) => { + await api.setCourseOpen(c.id, !c.is_open) + refresh() + } + + const preview = (c) => { + const lessonId = meta[c.id]?.noToId?.[c.next_lesson_no] + if (lessonId) openLesson({ lesson_id: lessonId, task_type: 'new' }) + } + + return ( + <> +

课程管理

+
+ 后台维护 · 开启/关闭课程开关,关闭后不生成任务、不提醒 +
+ +
+ {courses.length === 0 &&
还没有课程。
} + {courses.map((c) => ( +
+
+ {c.name} +
+ {c.category} · 已发布 {meta[c.id]?.count ?? 0} 课 · 学员进度 {c.completed} 课 · + 内容类型 {meta[c.id]?.content_type} +
+
+ + + {c.is_open ? '已开启' : '已关闭'} + +
toggle(c)} /> +
+ ))} +
+ + {stats && ( +
+ 课程 {stats.courses} · 课时 {stats.lessons} · 完成记录 {stats.completions} · 复习队列{' '} + {stats.in_review} · 学习日志 {stats.logs} +
+ )} + +
+ 关闭课程会发生什么 +
· 次日不再生成新学与复习任务,当天未完成的任务标记为已过期,不计入完成率 +
· 课程进度指针冻结,重新开启后从原地继续,历史学习日志全部保留 +
+ + ) +} diff --git a/frontend/src/pages/Courses.jsx b/frontend/src/pages/Courses.jsx new file mode 100644 index 0000000..9dd59b5 --- /dev/null +++ b/frontend/src/pages/Courses.jsx @@ -0,0 +1,98 @@ +import React, { useEffect, useState } from 'react' +import { api } from '../api' +import { useLesson } from '../ctx' + +const LOCALE_CN = { ja: '日语', 'zh-classical': '文言文' } + +export default function Courses() { + const [courses, setCourses] = useState(null) + // courseId -> { lesson_no: lesson_id },供「点击进入最新一课」使用 + const [lessonMap, setLessonMap] = useState({}) + const { openLesson, refreshKey } = useLesson() + + useEffect(() => { + let alive = true + api + .courses() + .then(async (d) => { + const list = d.courses + const pairs = await Promise.all( + list.map((c) => + api + .courseLessons(c.id) + .then((r) => [c.id, Object.fromEntries(r.lessons.map((l) => [l.lesson_no, l.id]))]) + .catch(() => [c.id, {}]), + ), + ) + if (!alive) return + setCourses(list) + setLessonMap(Object.fromEntries(pairs)) + }) + .catch(() => alive && setCourses([])) + return () => { + alive = false + } + }, [refreshKey]) + + if (!courses) return
加载中…
+ + const openCount = courses.filter((c) => c.is_open).length + + const enter = (c) => { + const lessonId = lessonMap[c.id]?.[c.next_lesson_no] + if (lessonId) openLesson({ lesson_id: lessonId, task_type: 'new' }) + } + + return ( + <> +

我的课程

+
+ 共 {courses.length} 门,进行中 {openCount} 门 · 点击卡片进入最新一课 +
+
+ {courses.map((c) => ( +
enter(c)}> +
+
+ {c.icon} +
+
+

{c.name}

+
+ {c.category} + {LOCALE_CN[c.locale] ? ` · ${LOCALE_CN[c.locale]}` : ''} ·{' '} + {c.is_open ? '进行中' : '已关闭'} +
+
+
+
+ {c.is_open + ? '按计划推进中,完成一课自动进入复习队列' + : '课程已关闭,进度冻结,可在管理页重新开启'} +
+
+ + {c.completed} / {c.total} 课 + + {c.is_open ? ( + 已开启 + ) : ( + + 已关闭 + + )} +
+
+ +
+
+ ))} +
+ + ) +} diff --git a/frontend/src/pages/Plan.jsx b/frontend/src/pages/Plan.jsx new file mode 100644 index 0000000..63244fc --- /dev/null +++ b/frontend/src/pages/Plan.jsx @@ -0,0 +1,102 @@ +import React, { useEffect, useRef, useState } from 'react' +import { api } from '../api' +import { useLesson } from '../ctx' + +const clamp = (n) => Math.max(0, Math.min(5, n)) + +export default function Plan() { + const [items, setItems] = useState(null) + const [hint, setHint] = useState(false) + const timer = useRef(null) + const { refreshKey, refresh } = useLesson() + + useEffect(() => { + api + .plan() + .then((d) => setItems(d.items || [])) + .catch(() => setItems([])) + }, [refreshKey]) + + useEffect(() => () => clearTimeout(timer.current), []) + + if (!items) return
加载中…
+ + const step = (courseId, key, delta) => { + setHint(false) + setItems((prev) => + prev.map((i) => + i.course_id === courseId ? { ...i, [key]: clamp(i[key] + delta) } : i, + ), + ) + } + + const save = async () => { + await Promise.all( + items.map((i) => api.updatePlanItem(i.course_id, i.daily_new, i.daily_review)), + ) + setHint(true) + clearTimeout(timer.current) + timer.current = setTimeout(() => setHint(false), 2400) + refresh() + } + + return ( + <> +

学习计划

+
每天生成多少新学与复习任务 · 关闭的课程不参与生成
+ +
+ {items.length === 0 &&
还没有计划条目。
} + {items.map((i) => ( +
+
+ + {i.course_name} +
+
+ 每天新学 + + + {i.daily_new} + + + 课 +
+
+ 每天复习 + + + {i.daily_review} + + + 课 +
+
+ {i.is_open ? ( + 生效中 + ) : ( + + 课程已关闭 + + )} +
+
+ ))} +
+ +
+ + 已保存,明天起按新计划生成任务 +
+ +
+ 规则说明 +
· 新学:从课程进度指针往后取 N 课,顺序学习不可跳课 +
· 复习:从复习队列中取到期最早的 M 课,按 1 / 3 / 7 / 14 / 30 天间隔升级 +
· 漏天:新学不补、顺延;复习积压按每天上限截断后顺延 +
+ + ) +} diff --git a/frontend/src/pages/Today.jsx b/frontend/src/pages/Today.jsx new file mode 100644 index 0000000..cd84a47 --- /dev/null +++ b/frontend/src/pages/Today.jsx @@ -0,0 +1,138 @@ +import React, { useEffect, useState } from 'react' +import { api } from '../api' +import { useLesson } from '../ctx' + +const R = 42 +const CIRC = 2 * Math.PI * R + +export default function Today() { + const [data, setData] = useState(null) + const [busy, setBusy] = useState(null) + const { openLesson, refreshKey, refresh } = useLesson() + + const load = () => api.today().then(setData).catch(() => setData(null)) + useEffect(() => { + load() + }, [refreshKey]) + + if (!data) return
加载中…
+ + const { stats, groups, closed_courses } = data + const allDone = stats.total > 0 && stats.done === stats.total + + const tick = async (task) => { + if (task.status === 'done' || busy) return + setBusy(task.id) + try { + await api.completeLesson(task.lesson_id, { + task_id: task.id, + task_type: task.task_type, + }) + await load() + refresh() + } catch (e) { + console.error('打卡失败:', e) + } finally { + setBusy(null) + } + } + + return ( + <> +
+
+ + + + +
+ {stats.done}/{stats.total} + 今日任务 +
+
+
+

{data.date_label}

+

+ {allDone + ? '今天的任务全部完成,去休息吧' + : `${data.open_courses} 门课程进行中 · 计划生成于今天首次打开时`} +

+
+ 连续学习 {data.streak_days} 天 + 待复习 {data.review_pending} 课 + 本周完成率 {data.week_rate}% +
+
+
+ + {groups.length === 0 && ( +
今天没有任务。去「学习计划」给课程设置每天的学习量。
+ )} + + {groups.map((g) => ( +
+
+ + {g.course_name} + {g.category} + + {g.done}/{g.count} · 课程总进度 {g.completed}/{g.total} + +
+
+ {g.tasks.map((t) => ( +
+
tick(t)}> + {t.status === 'done' ? '✓' : ''} +
+
+
{t.title}
+
+ {t.task_type === 'review' ? ( + 复习 + ) : ( + 新学 + )}{' '} + · 第 {t.lesson_no} 课 · 预计 {t.duration_min} 分钟 +
+
+ +
+ ))} +
+
+
+ +
+
+
+ ))} + + {closed_courses.length > 0 && ( +
+ {closed_courses.map((c) => ( + + {c.name} + + ))} + 已关闭,暂不生成任务、不提醒,进度已冻结保留。 +
+ )} + + ) +} diff --git a/frontend/src/renderers/LessonBrief.jsx b/frontend/src/renderers/LessonBrief.jsx new file mode 100644 index 0000000..0805944 --- /dev/null +++ b/frontend/src/renderers/LessonBrief.jsx @@ -0,0 +1,47 @@ +import React from 'react' + +/** lesson_brief:通用讲义兜底类型,任何形态未做专属渲染时都先落到这里。 */ +export default function LessonBrief({ payload }) { + const blocks = payload.blocks || [] + + return ( +
+ {payload.title && ( +

+ {payload.title} +

+ )} + + {blocks.length === 0 && ( +

{payload.body || '(本课暂无内容,可直接在后台补充讲义)'}

+ )} + + {blocks.map((b, i) => { + if (b.type === 'heading') { + return ( +

+ {b.text} +

+ ) + } + if (b.type === 'list') { + return ( +
    + {(b.items || []).map((it, j) => ( +
  • {it}
  • + ))} +
+ ) + } + if (b.type === 'image') { + return ( +

+ {b.caption +

+ ) + } + return

{b.text}

+ })} +
+ ) +} diff --git a/frontend/src/renderers/ReadingArticle.jsx b/frontend/src/renderers/ReadingArticle.jsx new file mode 100644 index 0000000..43e37b6 --- /dev/null +++ b/frontend/src/renderers/ReadingArticle.jsx @@ -0,0 +1,55 @@ +import React, { useEffect, useState } from 'react' + +const mmss = (s) => + `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}` + +/** reading_article:阅读计时,满 duration 分钟后可打卡。 */ +export default function ReadingArticle({ payload, onReadyChange }) { + const [sec, setSec] = useState(0) + const targetSec = (payload.min || 0) * 60 + + useEffect(() => { + const t = setInterval(() => setSec((s) => s + 1), 1000) + return () => clearInterval(t) + }, []) + + useEffect(() => { + onReadyChange?.(sec >= targetSec) + }, [sec, targetSec]) + + const glossary = payload.glossary || [] + + return ( + <> +
+ 已阅读 + {mmss(sec)} + · 目标 {payload.min || 0} 分钟 + {glossary.length} 个生词标注 +
+ +
+ {payload.title && ( +

+ {payload.title} +

+ )} + {/* 正文由后台发布(含 生词标注),内容源可信 */} + {(payload.body || []).map((p, i) => ( +

+ ))} +

+ + {glossary.length > 0 && ( +
+
生词表
+ {glossary.map((g, i) => ( +
+ {g} +
+ ))} +
+ )} + + ) +} diff --git a/frontend/src/renderers/VocabTable.jsx b/frontend/src/renderers/VocabTable.jsx new file mode 100644 index 0000000..a8d7525 --- /dev/null +++ b/frontend/src/renderers/VocabTable.jsx @@ -0,0 +1,41 @@ +import React, { useState } from 'react' + +/** vocab_table:单词表,逐词翻面后手动打卡。 */ +export default function VocabTable({ payload }) { + const [flipped, setFlipped] = useState({}) + const words = payload.words || [] + + if (!words.length) return
本课暂无词条。
+ + return ( + <> +
+ 共 {words.length} 个词条 · 点击卡片翻面 +
+
+ {words.map((w, i) => ( +
+
setFlipped((f) => ({ ...f, [i]: !f[i] }))} + > +
+
+ {w.word} + {w.phonetic} +
+ {i + 1}/{words.length} +
+
+
+ {w.meaning} + {w.example} +
+
+
+
+ ))} +
+ + ) +} diff --git a/frontend/src/renderers/index.jsx b/frontend/src/renderers/index.jsx new file mode 100644 index 0000000..06f904f --- /dev/null +++ b/frontend/src/renderers/index.jsx @@ -0,0 +1,37 @@ +import React from 'react' +import VocabTable from './VocabTable' +import ReadingArticle from './ReadingArticle' +import LessonBrief from './LessonBrief' + +/** + * 内容类型注册表(设计文档 02-内容本体规范 §1)。 + * 新增一种课程形态 = 在此登记一次;之后同类内容发布 = 纯数据录入。 + * hint 可为字符串或 (payload) => string。 + */ +export const REGISTRY = { + vocab_table: { + label: '单词表', + Component: VocabTable, + hint: '点击卡片翻面查看释义 · 全部过一遍后完成本课', + }, + reading_article: { + label: '阅读', + Component: ReadingArticle, + hint: (p) => `阅读计时满 ${p.min || 0} 分钟后可打卡`, + }, + lesson_brief: { + label: '讲义', + Component: LessonBrief, + hint: '阅读完毕后完成本课', + }, +} + +/** 未知类型兜底到 lesson_brief,保证前台永远不会遇到打不开的课。 */ +export function resolve(contentType) { + return REGISTRY[contentType] || REGISTRY.lesson_brief +} + +export function hintOf(entry, payload) { + const h = entry.hint + return typeof h === 'function' ? h(payload || {}) : h +} diff --git a/frontend/src/theme.css b/frontend/src/theme.css new file mode 100644 index 0000000..ff201d2 --- /dev/null +++ b/frontend/src/theme.css @@ -0,0 +1,192 @@ +/* 学习山峰 · 浅蓝清新 + 小碎花主题 + 样式基线来自 design-package/ui-prototype.html,保持与主色/底纹一致。 */ + +:root{ + --bg:#f3f9fd; --surface:#ffffff; --line:#dceaf6; --line-soft:#eaf3fa; + --primary:#4a90c8; --primary-d:#3a76a8; --primary-l:#e8f3fc; + --text:#2b3a47; --muted:#8298ab; --muted-l:#a9bccb; + --green:#4a9e7a; --green-l:#e6f5ef; + --pink:#f0b6cd; --pink-l:#fdeef4; + --amber:#dd9b3c; --amber-l:#fdf3e3; + --radius:16px; --shadow:0 2px 10px rgba(74,144,200,.08); +} +*{box-sizing:border-box;margin:0;padding:0} +body{ + font-family:-apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei",sans-serif; + color:var(--text); font-size:14px; line-height:1.6; + background-color:var(--bg); + background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='90' height='90'%3E%3Cg fill='%23b9d6ec' fill-opacity='.40'%3E%3Cg transform='translate(22,22)'%3E%3Ccircle cx='0' cy='0' r='1.7'/%3E%3Ccircle cx='4' cy='0' r='2.4'/%3E%3Ccircle cx='1.2' cy='3.8' r='2.4'/%3E%3Ccircle cx='-3.2' cy='2.4' r='2.4'/%3E%3Ccircle cx='-3.2' cy='-2.4' r='2.4'/%3E%3Ccircle cx='1.2' cy='-3.8' r='2.4'/%3E%3C/g%3E%3Cg transform='translate(67,67)'%3E%3Ccircle cx='0' cy='0' r='1.7'/%3E%3Ccircle cx='4' cy='0' r='2.4'/%3E%3Ccircle cx='1.2' cy='3.8' r='2.4'/%3E%3Ccircle cx='-3.2' cy='2.4' r='2.4'/%3E%3Ccircle cx='-3.2' cy='-2.4' r='2.4'/%3E%3Ccircle cx='1.2' cy='-3.8' r='2.4'/%3E%3C/g%3E%3Cg transform='translate(78,18)'%3E%3Ccircle cx='0' cy='0' r='1.4'/%3E%3Ccircle cx='3.2' cy='0' r='1.9'/%3E%3Ccircle cx='1' cy='3' r='1.9'/%3E%3Ccircle cx='-2.6' cy='1.9' r='1.9'/%3E%3Ccircle cx='-2.6' cy='-1.9' r='1.9'/%3E%3Ccircle cx='1' cy='-3' r='1.9'/%3E%3C/g%3E%3Cg transform='translate(14,72)'%3E%3Ccircle cx='0' cy='0' r='1.4'/%3E%3Ccircle cx='3.2' cy='0' r='1.9'/%3E%3Ccircle cx='1' cy='3' r='1.9'/%3E%3Ccircle cx='-2.6' cy='1.9' r='1.9'/%3E%3Ccircle cx='-2.6' cy='-1.9' r='1.9'/%3E%3Ccircle cx='1' cy='-3' r='1.9'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E"); + min-height:100vh; +} +.wrap{max-width:1080px;margin:0 auto;padding:0 20px 60px} + +/* 顶栏 */ +header{ + background:rgba(255,255,255,.86);backdrop-filter:blur(6px); + border-bottom:1px solid var(--line);position:sticky;top:0;z-index:20; +} +.hd{max-width:1080px;margin:0 auto;padding:14px 20px;display:flex;align-items:center;gap:16px;flex-wrap:wrap} +.logo{display:flex;align-items:center;gap:9px;font-size:16px;font-weight:500} +.logo .peak{width:26px;height:26px;flex:none} +nav{display:flex;gap:4px;margin-left:auto;flex-wrap:wrap} +nav a{ + border:none;background:transparent;padding:7px 15px;border-radius:20px;cursor:pointer; + font-size:14px;color:var(--muted);font-family:inherit;transition:.18s;text-decoration:none; +} +nav a:hover{background:var(--primary-l);color:var(--primary-d)} +nav a.on{background:var(--primary);color:#fff} +.user{display:flex;align-items:center;gap:8px;color:var(--muted);font-size:13px} +.avatar{width:30px;height:30px;border-radius:50%;background:var(--pink-l);color:var(--pink);display:flex;align-items:center;justify-content:center;font-size:13px} + +/* 通用卡片 */ +.card{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow)} +.pad{padding:18px 20px} +h2{font-size:15px;font-weight:500;margin-bottom:4px} +.sub{color:var(--muted);font-size:12.5px;margin-bottom:14px} +.tag{display:inline-block;font-size:11.5px;padding:2px 9px;border-radius:11px;background:var(--primary-l);color:var(--primary-d)} +.tag.g{background:var(--green-l);color:var(--green)} +.tag.p{background:var(--pink-l);color:#c9749a} +.tag.a{background:var(--amber-l);color:var(--amber)} + +/* 今日 */ +.hero{display:flex;align-items:center;gap:22px;margin-bottom:18px} +.ring{position:relative;width:96px;height:96px;flex:none} +.ring svg{transform:rotate(-90deg)} +.ring .val{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center} +.ring .val b{font-size:22px;font-weight:500;color:var(--primary-d)} +.ring .val span{font-size:11px;color:var(--muted)} +.hero .meta h1{font-size:17px;font-weight:500} +.hero .meta p{color:var(--muted);font-size:13px;margin-top:3px} + +.taskgroup{margin-bottom:16px} +.tg-hd{display:flex;align-items:center;gap:10px;padding:13px 18px;border-bottom:1px solid var(--line-soft)} +.dot{width:9px;height:9px;border-radius:50%;flex:none} +.tg-hd .nm{font-weight:500} +.tg-hd .pg{margin-left:auto;font-size:12.5px;color:var(--muted)} +.tasks{display:flex;flex-direction:column} +.task{display:flex;align-items:center;gap:13px;padding:13px 18px;border-bottom:1px solid var(--line-soft)} +.task:last-child{border-bottom:none} +.tick{width:24px;height:24px;border-radius:50%;border:1.5px solid var(--line);flex:none;display:flex;align-items:center;justify-content:center;color:#fff;font-size:13px;transition:.2s;cursor:pointer;background:transparent} +.task.done .tick{background:var(--green);border-color:var(--green)} +.task.done .t-title{color:var(--muted);text-decoration:line-through} +.t-main{flex:1;min-width:0} +.t-title{font-size:14px} +.t-meta{font-size:12px;color:var(--muted);margin-top:1px} +.btn{border:none;border-radius:9px;padding:7px 14px;font-size:13px;cursor:pointer;font-family:inherit;transition:.18s;background:var(--primary);color:#fff} +.btn:hover{background:var(--primary-d)} +.btn.ghost{background:var(--primary-l);color:var(--primary-d)} +.btn.ghost:hover{background:#d8eaf8} +.btn.sm{padding:5px 11px;font-size:12.5px} +.btn:disabled{background:#e6eef5;color:var(--muted-l);cursor:default} +.bar{height:5px;border-radius:3px;background:var(--line-soft);overflow:hidden;margin-top:6px} +.bar i{display:block;height:100%;background:var(--primary);border-radius:3px;transition:width .4s} + +/* 课程页 */ +.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(250px,1fr));gap:14px} +.ccard{padding:16px 17px;display:flex;flex-direction:column;gap:10px;transition:.2s;cursor:pointer} +.ccard:hover{border-color:var(--primary);transform:translateY(-2px)} +.ccard .top{display:flex;align-items:center;gap:10px} +.cicon{width:38px;height:38px;border-radius:11px;display:flex;align-items:center;justify-content:center;font-size:17px;flex:none} +.ccard h3{font-size:14.5px;font-weight:500} +.ccard .desc{font-size:12.5px;color:var(--muted);min-height:34px} +.ccard .foot{display:flex;align-items:center;gap:8px;margin-top:2px} +.ccard .foot .pg{font-size:12px;color:var(--muted);margin-right:auto} + +/* 计划页 */ +.prow{display:flex;align-items:center;gap:14px;padding:14px 18px;border-bottom:1px solid var(--line-soft);flex-wrap:wrap} +.prow:last-child{border-bottom:none} +.prow .nm{width:150px;flex:none;font-weight:500;display:flex;align-items:center;gap:8px} +.stepper{display:flex;align-items:center;gap:8px} +.stepper button{width:26px;height:26px;border-radius:50%;border:1px solid var(--line);background:#fff;cursor:pointer;color:var(--primary-d);font-size:15px;line-height:1;font-family:inherit} +.stepper button:hover{background:var(--primary-l)} +.stepper .n{min-width:22px;text-align:center;font-variant-numeric:tabular-nums} +.fld{display:flex;align-items:center;gap:8px;font-size:12.5px;color:var(--muted)} +.savebar{display:flex;align-items:center;gap:12px;margin-top:14px} +.hint{font-size:12px;color:var(--green);opacity:0;transition:.3s} +.hint.on{opacity:1} + +/* 管理页 */ +.mrow{display:flex;align-items:center;gap:14px;padding:15px 18px;border-bottom:1px solid var(--line-soft)} +.mrow:last-child{border-bottom:none} +.mrow .nm{flex:1;min-width:0} +.mrow .nm b{font-weight:500} +.mrow .nm div{font-size:12px;color:var(--muted)} +.sw{width:44px;height:24px;border-radius:12px;background:#dde7ef;position:relative;cursor:pointer;transition:.2s;flex:none} +.sw::after{content:"";position:absolute;top:2px;left:2px;width:20px;height:20px;border-radius:50%;background:#fff;box-shadow:0 1px 3px rgba(0,0,0,.18);transition:.2s} +.sw.on{background:var(--primary)} +.sw.on::after{left:22px} +.mrow.off .nm b{color:var(--muted-l)} + +/* 课时层 */ +.overlay{position:fixed;inset:0;background:rgba(43,58,71,.34);backdrop-filter:blur(2px);z-index:50;display:none;align-items:flex-start;justify-content:center;padding:34px 20px;overflow:auto} +.overlay.on{display:flex} +.sheet{width:100%;max-width:720px;background:var(--surface);border-radius:18px;box-shadow:0 12px 40px rgba(43,58,71,.2);overflow:hidden;animation:up .22s ease} +@keyframes up{from{opacity:0;transform:translateY(14px)}to{opacity:1;transform:none}} +.sh-hd{padding:16px 22px;border-bottom:1px solid var(--line);display:flex;align-items:center;gap:12px;background:linear-gradient(180deg,#f7fbfe,#fff)} +.sh-hd .t{font-size:15px;font-weight:500} +.sh-hd .s{font-size:12px;color:var(--muted)} +.x{margin-left:auto;border:none;background:var(--primary-l);color:var(--primary-d);width:28px;height:28px;border-radius:50%;cursor:pointer;font-size:15px;line-height:1;font-family:inherit} +.sh-bd{padding:20px 22px;max-height:66vh;overflow:auto} +.sh-ft{padding:14px 22px;border-top:1px solid var(--line);display:flex;align-items:center;gap:10px;background:#fbfdff} +.sh-ft .sp{margin-right:auto;font-size:12.5px;color:var(--muted)} + +/* 单词卡 */ +.words{display:grid;grid-template-columns:repeat(2,1fr);gap:11px} +.flip{perspective:700px;height:104px;cursor:pointer} +.flip .in{position:relative;width:100%;height:100%;transition:transform .45s;transform-style:preserve-3d} +.flip.f .in{transform:rotateY(180deg)} +.fc{position:absolute;inset:0;backface-visibility:hidden;border:1px solid var(--line);border-radius:12px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:3px;background:#fbfdff;padding:8px;text-align:center} +.fc b{font-size:16px;font-weight:500;color:var(--primary-d)} +.fc span{font-size:11.5px;color:var(--muted)} +.fc.back{transform:rotateY(180deg);background:var(--primary-l);border-color:#cfe5f7} +.fc.back b{font-size:14px} +.fc.back span{font-size:11.5px;color:var(--primary-d)} +.widx{font-size:11.5px;color:var(--muted-l);margin-top:4px} + +/* 阅读 */ +.article{font-size:14.5px;line-height:1.95;color:#33465a} +.article p{margin-bottom:12px} +.article em{background:var(--pink-l);font-style:normal;border-radius:3px;padding:0 3px;cursor:help} +.timer{display:flex;align-items:center;gap:9px;padding:10px 14px;background:var(--primary-l);border-radius:11px;color:var(--primary-d);font-size:13px;margin-bottom:15px} +.timer b{font-variant-numeric:tabular-nums;font-size:15px} + +/* 作文 */ +.promptbox{background:var(--amber-l);border-left:3px solid var(--amber);border-radius:8px;padding:13px 15px;margin-bottom:15px} +.promptbox h4{font-size:13px;color:var(--amber);margin-bottom:5px;font-weight:500} +textarea{width:100%;min-height:170px;border:1px solid var(--line);border-radius:11px;padding:13px 15px;font-family:inherit;font-size:14px;line-height:1.8;resize:vertical;color:var(--text)} +textarea:focus{outline:none;border-color:var(--primary)} +.grading{display:flex;align-items:center;gap:10px;color:var(--muted);font-size:13px;padding:22px 0} +.spin{width:15px;height:15px;border:2px solid var(--line);border-top-color:var(--primary);border-radius:50%;animation:sp .7s linear infinite} +@keyframes sp{to{transform:rotate(360deg)}} +.report{border:1px solid var(--line);border-radius:13px;overflow:hidden;margin-top:14px} +.rp-hd{display:flex;align-items:center;gap:13px;padding:14px 17px;background:var(--primary-l)} +.rp-hd .score{font-size:26px;font-weight:500;color:var(--primary-d);line-height:1} +.rp-hd .lb{font-size:12px;color:var(--muted)} +.rp-bd{padding:14px 17px} +.item{margin-bottom:13px} +.item:last-child{margin-bottom:0} +.item .h{display:flex;align-items:center;gap:8px;font-size:13px;margin-bottom:4px} +.item .h b{margin-left:auto;font-variant-numeric:tabular-nums;color:var(--primary-d)} +.item .c{font-size:12.5px;color:var(--muted)} + +/* 听力 */ +audio{width:100%;margin-bottom:15px} +.qbox{border:1px solid var(--line);border-radius:11px;padding:14px 16px;margin-bottom:11px} +.qbox .q{font-size:13.5px;margin-bottom:9px} +.qbox .q b{color:var(--primary-d);margin-right:5px} +.opts{display:flex;flex-direction:column;gap:6px} +.opt{display:flex;align-items:center;gap:8px;padding:7px 11px;border:1px solid var(--line);border-radius:8px;cursor:pointer;font-size:13px;transition:.15s} +.opt:hover{border-color:var(--primary);background:#f8fcff} +.opt.sel{border-color:var(--primary);background:var(--primary-l)} +.opt .k{width:18px;height:18px;border-radius:50%;border:1.5px solid var(--line);flex:none;display:flex;align-items:center;justify-content:center;font-size:11px;color:#fff} +.opt.sel .k{background:var(--primary);border-color:var(--primary)} +.res{margin-top:11px;padding:10px 14px;border-radius:9px;font-size:13px;background:var(--green-l);color:var(--green)} + +/* 骨架期补充:加载/空态 */ +.loading,.empty{padding:26px 20px;text-align:center;color:var(--muted);font-size:13px} + +@media(max-width:640px){ + .words{grid-template-columns:1fr} + .prow .nm{width:100%} + .hero{gap:14px} +} diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 449ba51..0bcf8bd 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -1,18 +1,29 @@ import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' -// 后端默认跑在 1061 端口(仿 Words 的 1060,错开一位避免冲突) +// 后端跑在 1063 端口(1060/1061/1062 已被 words 系列占用) +// 线上经 nginx 挂在 https://words.acesimba.cloud/study/ 下,故 base 需带 /study/ 前缀 export default defineConfig({ plugins: [react()], + base: '/study/', server: { host: true, // 绑 0.0.0.0,允许公网访问 port: 2341, strictPort: true, + // 经 nginx 反代时 Host 为 words.acesimba.cloud,需加入白名单否则 vite 返回 403 + allowedHosts: ['words.acesimba.cloud'], proxy: { + // 本地直连 dev server 时用 '/api': { - target: 'http://localhost:1061', + target: 'http://localhost:1063', changeOrigin: true, }, + // 经 nginx /study/ 前缀访问时,前端发出的 /study/api/* 需去掉前缀再转发 + '/study/api': { + target: 'http://localhost:1063', + changeOrigin: true, + rewrite: (p) => p.replace(/^\/study/, ''), + }, }, }, }) -- 2.43.0