]> acesimba.cloud Git - study-mountion.git/commitdiff
按 v1.1 设计重建为多课程学习追踪系统
authorstudyhill <studyhill@local>
Sat, 29 Aug 2026 01:45:21 +0000 (09:45 +0800)
committerstudyhill <studyhill@local>
Sat, 29 Aug 2026 01:45:21 +0000 (09:45 +0800)
后端:
- 新增 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 组件与旧样式

25 files changed:
backend/db.py [new file with mode: 0644]
backend/main.py
backend/scheduler.py [new file with mode: 0644]
backend/seed.py [new file with mode: 0644]
frontend/index.html
frontend/public/favicon.svg
frontend/src/App.css [deleted file]
frontend/src/App.jsx
frontend/src/api.js [new file with mode: 0644]
frontend/src/components/Home.css [deleted file]
frontend/src/components/Home.jsx [deleted file]
frontend/src/components/LessonSheet.jsx [new file with mode: 0644]
frontend/src/ctx.js [new file with mode: 0644]
frontend/src/index.css [deleted file]
frontend/src/main.jsx
frontend/src/pages/Admin.jsx [new file with mode: 0644]
frontend/src/pages/Courses.jsx [new file with mode: 0644]
frontend/src/pages/Plan.jsx [new file with mode: 0644]
frontend/src/pages/Today.jsx [new file with mode: 0644]
frontend/src/renderers/LessonBrief.jsx [new file with mode: 0644]
frontend/src/renderers/ReadingArticle.jsx [new file with mode: 0644]
frontend/src/renderers/VocabTable.jsx [new file with mode: 0644]
frontend/src/renderers/index.jsx [new file with mode: 0644]
frontend/src/theme.css [new file with mode: 0644]
frontend/vite.config.js

diff --git a/backend/db.py b/backend/db.py
new file mode 100644 (file)
index 0000000..663d6c6
--- /dev/null
@@ -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()
index 8c2742ca319049d28cff13dd6b4f72c4e356ed8f..cdd99aafed29467ef1e7299b4dd3234f8d4adf6c 100644 (file)
@@ -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 (file)
index 0000000..672ebb1
--- /dev/null
@@ -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 (file)
index 0000000..4ce4d03
--- /dev/null
@@ -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 (已有课程数据)")
index 845ad6295d48c84c0c2ad478ab9236481929a689..82565a51e4610cd7265ebd3df00724a42cd89cf6 100644 (file)
@@ -4,9 +4,9 @@
     <meta charset="UTF-8" />
     <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
     <meta name="viewport" content="width=device-width, initial-scale=1" />
-    <meta name="theme-color" content="#0f172a" />
+    <meta name="theme-color" content="#eaf7f0" />
     <meta name="description" content="学习山峰 — 多人多任务学习系统" />
-    <title>学习山峰</title>
+    <title>Mountion</title>
   </head>
   <body>
     <noscript>需要启用 JavaScript 才能运行学习山峰。</noscript>
index 2349cb134393b09f39a8ab6cf3ff5229083aaf28..d669843c11d9fc17071199dd40b8f2a877a6cbbd 100644 (file)
@@ -1,5 +1,13 @@
 <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
-  <rect width="64" height="64" rx="14" fill="#0f172a"/>
-  <path d="M10 46 L24 26 L34 38 L44 22 L54 46 Z" fill="#b08d57"/>
-  <circle cx="46" cy="18" r="4" fill="#f7f8fa"/>
+  <defs>
+    <linearGradient id="sky" x1="32" y1="0" x2="32" y2="64" gradientUnits="userSpaceOnUse">
+      <stop stop-color="#f2fbf6"/>
+      <stop offset="1" stop-color="#cfeee1"/>
+    </linearGradient>
+  </defs>
+  <rect width="64" height="64" rx="14" fill="url(#sky)"/>
+  <circle cx="47" cy="17" r="5" fill="#ffd98a"/>
+  <path d="M8 47 L23 26 L31 37 L39 23 L57 47 Z" fill="#79cca6"/>
+  <path d="M39 23 L47 34 L57 47 L39 47 Z" fill="#a9e0c7"/>
+  <path d="M23 26 L28.5 34 L17.5 34 Z" fill="#eaf8f1"/>
 </svg>
diff --git a/frontend/src/App.css b/frontend/src/App.css
deleted file mode 100644 (file)
index 915ba97..0000000
+++ /dev/null
@@ -1,3 +0,0 @@
-.App {
-  min-height: 100vh;
-}
index ce1a6ae575125b7862f3367e43c23c5f8da6ea07..b7aca746741c88c34e5b3b1abe49583844179d21 100644 (file)
@@ -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 (
-    <Router>
-      <Routes>
-        <Route path="/" element={<Home />} />
-      </Routes>
+    // basename 跟随 vite base(线上挂在 /study/ 下),否则路径匹配不上导致白屏
+    <Router basename={import.meta.env.BASE_URL}>
+      <LessonCtx.Provider value={value}>
+        <header>
+          <div className="hd">
+            <div className="logo">
+              <svg className="peak" viewBox="0 0 24 24" fill="none">
+                <path d="M2 19h20L15 8l-3.5 5L9 10l-7 9z" fill="#4a90c8" />
+                <path d="M17 12.5l3 4.5h-6l3-4.5z" fill="#f0b6cd" />
+              </svg>
+              学习山峰
+            </div>
+            <nav>
+              {NAV.map((n) => (
+                <NavLink key={n.to} to={n.to} end={n.end} className={({ isActive }) => (isActive ? 'on' : '')}>
+                  {n.label}
+                </NavLink>
+              ))}
+            </nav>
+          </div>
+        </header>
+
+        <div className="wrap">
+          <Routes>
+            <Route path="/" element={<Today />} />
+            <Route path="/courses" element={<Courses />} />
+            <Route path="/plan" element={<Plan />} />
+            <Route path="/admin" element={<Admin />} />
+          </Routes>
+        </div>
+
+        {lesson && <LessonSheet lesson={lesson} />}
+      </LessonCtx.Provider>
     </Router>
   )
 }
-
-export default App
diff --git a/frontend/src/api.js b/frontend/src/api.js
new file mode 100644 (file)
index 0000000..46a4aad
--- /dev/null
@@ -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 (file)
index 9caca76..0000000
+++ /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 (file)
index 3bee0ee..0000000
+++ /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 (
-    <div className="home">
-      {/* 顶栏 */}
-      <header className="topbar">
-        <div className="brand">
-          <span className="brand-mark" aria-hidden="true">山</span>
-          <span className="brand-name">学习山峰</span>
-        </div>
-        <nav className="topnav">
-          <a href="/" className="active">首页</a>
-          <a href="/">任务</a>
-          <a href="/">关于</a>
-        </nav>
-      </header>
-
-      {/* 欢迎区 */}
-      <section className="hero">
-        <div className="hero-accent" />
-        <h1 className="hero-title">欢迎来到学习山峰</h1>
-        <p className="hero-subtitle">
-          一座属于学习者的山峰 —— 在这里,多人协作、多任务并行,把每一次攀登都留下印记。
-        </p>
-        <div className="hero-actions">
-          <button className="btn btn-primary">开始学习</button>
-          <button className="btn btn-ghost">了解更多</button>
-        </div>
-      </section>
-
-      {/* 数据卡片 */}
-      <section className="stats">
-        {cards.map((c) => (
-          <div className="stat-card" key={c.key}>
-            <div className="stat-value">
-              {loading ? <span className="skeleton" /> : c.value}
-            </div>
-            <div className="stat-label">{c.label}</div>
-          </div>
-        ))}
-      </section>
-
-      {/* 进度区(占位,后续接入任务进度) */}
-      <section className="panel">
-        <div className="panel-head">
-          <h2 className="panel-title">学习进度</h2>
-          <span className="panel-hint">多人多任务进度即将上线</span>
-        </div>
-        <div className="panel-empty">
-          <div className="empty-mark" aria-hidden="true">⛰</div>
-          <p>暂无进度数据</p>
-          <span>创建你的第一个学习任务,开启攀登之旅。</span>
-        </div>
-      </section>
-
-      <footer className="footer">
-        <span>© {new Date().getFullYear()} 学习山峰 · Study Mountion</span>
-      </footer>
-    </div>
-  )
-}
-
-export default Home
diff --git a/frontend/src/components/LessonSheet.jsx b/frontend/src/components/LessonSheet.jsx
new file mode 100644 (file)
index 0000000..70c8b43
--- /dev/null
@@ -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 (
+      <div className="overlay on">
+        <div className="sheet">
+          <div className="loading">加载中…</div>
+        </div>
+      </div>
+    )
+  }
+
+  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 (
+    <div
+      className="overlay on"
+      onClick={(e) => {
+        if (e.currentTarget === e.target) closeLesson()
+      }}
+    >
+      <div className="sheet">
+        <div className="sh-hd">
+          <span className="dot" style={{ background: data.color }} />
+          <div>
+            <div className="t">
+              {data.course_name} · {data.title}
+            </div>
+            <div className="s">
+              {data.content_type} · {entry.label}
+            </div>
+          </div>
+          <button className="x" onClick={closeLesson}>
+            ×
+          </button>
+        </div>
+
+        <div className="sh-bd">
+          <Component payload={data.payload || {}} onReadyChange={setReady} />
+        </div>
+
+        <div className="sh-ft">
+          <span className="sp">{hintOf(entry, data.payload)}</span>
+          <button className="btn" onClick={finish} disabled={!ready}>
+            完成本课
+          </button>
+        </div>
+      </div>
+    </div>
+  )
+}
diff --git a/frontend/src/ctx.js b/frontend/src/ctx.js
new file mode 100644 (file)
index 0000000..b451c2f
--- /dev/null
@@ -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 (file)
index 7a8815e..0000000
+++ /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;
-}
index 995053a0f79d2ba2d179316de8bd40ef4f05f2bc..6de7ce2256569231315b3d596244b0bca9cd68b3 100644 (file)
@@ -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 (file)
index 0000000..f861dbc
--- /dev/null
@@ -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 <div className="loading">加载中…</div>
+
+  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 (
+    <>
+      <h2 style={{ margin: '18px 0 3px' }}>课程管理</h2>
+      <div className="sub">
+        后台维护 · 开启/关闭课程开关,关闭后不生成任务、不提醒
+      </div>
+
+      <div className="card">
+        {courses.length === 0 && <div className="empty">还没有课程。</div>}
+        {courses.map((c) => (
+          <div className={`mrow ${c.is_open ? '' : 'off'}`} key={c.id}>
+            <div className="nm">
+              <b>{c.name}</b>
+              <div>
+                {c.category} · 已发布 {meta[c.id]?.count ?? 0} 课 · 学员进度 {c.completed} 课 ·
+                内容类型 {meta[c.id]?.content_type}
+              </div>
+            </div>
+            <button className="btn ghost sm" onClick={() => preview(c)}>
+              预览课时
+            </button>
+            <span style={{ fontSize: 12.5, color: 'var(--muted)' }}>
+              {c.is_open ? '已开启' : '已关闭'}
+            </span>
+            <div className={`sw ${c.is_open ? 'on' : ''}`} onClick={() => toggle(c)} />
+          </div>
+        ))}
+      </div>
+
+      {stats && (
+        <div className="card pad" style={{ marginTop: 14, fontSize: 12.5, color: 'var(--muted)' }}>
+          课程 {stats.courses} · 课时 {stats.lessons} · 完成记录 {stats.completions} · 复习队列{' '}
+          {stats.in_review} · 学习日志 {stats.logs}
+        </div>
+      )}
+
+      <div className="card pad" style={{ marginTop: 14, fontSize: 12.5, color: 'var(--muted)' }}>
+        <b style={{ color: 'var(--text)', fontWeight: 500 }}>关闭课程会发生什么</b>
+        <br />· 次日不再生成新学与复习任务,当天未完成的任务标记为已过期,不计入完成率
+        <br />· 课程进度指针冻结,重新开启后从原地继续,历史学习日志全部保留
+      </div>
+    </>
+  )
+}
diff --git a/frontend/src/pages/Courses.jsx b/frontend/src/pages/Courses.jsx
new file mode 100644 (file)
index 0000000..9dd59b5
--- /dev/null
@@ -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 <div className="loading">加载中…</div>
+
+  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 (
+    <>
+      <h2 style={{ margin: '18px 0 3px' }}>我的课程</h2>
+      <div className="sub">
+        共 {courses.length} 门,进行中 {openCount} 门 · 点击卡片进入最新一课
+      </div>
+      <div className="grid">
+        {courses.map((c) => (
+          <div className="card ccard" key={c.id} onClick={() => enter(c)}>
+            <div className="top">
+              <div className="cicon" style={{ background: `${c.color}1a`, color: c.color }}>
+                {c.icon}
+              </div>
+              <div>
+                <h3>{c.name}</h3>
+                <div style={{ fontSize: 11.5, color: 'var(--muted)' }}>
+                  {c.category}
+                  {LOCALE_CN[c.locale] ? ` · ${LOCALE_CN[c.locale]}` : ''} ·{' '}
+                  {c.is_open ? '进行中' : '已关闭'}
+                </div>
+              </div>
+            </div>
+            <div className="desc">
+              {c.is_open
+                ? '按计划推进中,完成一课自动进入复习队列'
+                : '课程已关闭,进度冻结,可在管理页重新开启'}
+            </div>
+            <div className="foot">
+              <span className="pg">
+                {c.completed} / {c.total} 课
+              </span>
+              {c.is_open ? (
+                <span className="tag g">已开启</span>
+              ) : (
+                <span className="tag" style={{ background: '#eef2f6', color: '#9fb0bd' }}>
+                  已关闭
+                </span>
+              )}
+            </div>
+            <div className="bar">
+              <i
+                style={{
+                  width: `${c.total ? Math.round((c.completed / c.total) * 100) : 0}%`,
+                  background: c.color,
+                }}
+              />
+            </div>
+          </div>
+        ))}
+      </div>
+    </>
+  )
+}
diff --git a/frontend/src/pages/Plan.jsx b/frontend/src/pages/Plan.jsx
new file mode 100644 (file)
index 0000000..63244fc
--- /dev/null
@@ -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 <div className="loading">加载中…</div>
+
+  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 (
+    <>
+      <h2 style={{ margin: '18px 0 3px' }}>学习计划</h2>
+      <div className="sub">每天生成多少新学与复习任务 · 关闭的课程不参与生成</div>
+
+      <div className="card">
+        {items.length === 0 && <div className="empty">还没有计划条目。</div>}
+        {items.map((i) => (
+          <div className="prow" key={i.course_id}>
+            <div className="nm">
+              <span className="dot" style={{ background: i.color }} />
+              {i.course_name}
+            </div>
+            <div className="fld">
+              每天新学
+              <span className="stepper">
+                <button onClick={() => step(i.course_id, 'daily_new', -1)}>−</button>
+                <span className="n">{i.daily_new}</span>
+                <button onClick={() => step(i.course_id, 'daily_new', 1)}>+</button>
+              </span>
+              课
+            </div>
+            <div className="fld">
+              每天复习
+              <span className="stepper">
+                <button onClick={() => step(i.course_id, 'daily_review', -1)}>−</button>
+                <span className="n">{i.daily_review}</span>
+                <button onClick={() => step(i.course_id, 'daily_review', 1)}>+</button>
+              </span>
+              课
+            </div>
+            <div className="fld" style={{ marginLeft: 'auto' }}>
+              {i.is_open ? (
+                <span className="tag g">生效中</span>
+              ) : (
+                <span className="tag" style={{ background: '#eef2f6', color: '#9fb0bd' }}>
+                  课程已关闭
+                </span>
+              )}
+            </div>
+          </div>
+        ))}
+      </div>
+
+      <div className="savebar">
+        <button className="btn" onClick={save}>
+          保存计划
+        </button>
+        <span className={`hint ${hint ? 'on' : ''}`}>已保存,明天起按新计划生成任务</span>
+      </div>
+
+      <div className="card pad" style={{ marginTop: 14, fontSize: 12.5, color: 'var(--muted)' }}>
+        <b style={{ color: 'var(--text)', fontWeight: 500 }}>规则说明</b>
+        <br />· 新学:从课程进度指针往后取 N 课,顺序学习不可跳课
+        <br />· 复习:从复习队列中取到期最早的 M 课,按 1 / 3 / 7 / 14 / 30 天间隔升级
+        <br />· 漏天:新学不补、顺延;复习积压按每天上限截断后顺延
+      </div>
+    </>
+  )
+}
diff --git a/frontend/src/pages/Today.jsx b/frontend/src/pages/Today.jsx
new file mode 100644 (file)
index 0000000..cd84a47
--- /dev/null
@@ -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 <div className="loading">加载中…</div>
+
+  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 (
+    <>
+      <div className="card pad hero">
+        <div className="ring">
+          <svg width="96" height="96">
+            <circle cx="48" cy="48" r={R} fill="none" stroke="#eaf3fa" strokeWidth="9" />
+            <circle
+              cx="48" cy="48" r={R} fill="none" stroke="#4a90c8" strokeWidth="9" strokeLinecap="round"
+              strokeDasharray={CIRC} strokeDashoffset={CIRC * (1 - stats.percent / 100)}
+              style={{ transition: 'stroke-dashoffset .5s' }}
+            />
+          </svg>
+          <div className="val">
+            <b>{stats.done}/{stats.total}</b>
+            <span>今日任务</span>
+          </div>
+        </div>
+        <div className="meta">
+          <h1>{data.date_label}</h1>
+          <p>
+            {allDone
+              ? '今天的任务全部完成,去休息吧'
+              : `${data.open_courses} 门课程进行中 · 计划生成于今天首次打开时`}
+          </p>
+          <div style={{ marginTop: 9, display: 'flex', gap: 7, flexWrap: 'wrap' }}>
+            <span className="tag">连续学习 {data.streak_days} 天</span>
+            <span className="tag p">待复习 {data.review_pending} 课</span>
+            <span className="tag g">本周完成率 {data.week_rate}%</span>
+          </div>
+        </div>
+      </div>
+
+      {groups.length === 0 && (
+        <div className="card empty">今天没有任务。去「学习计划」给课程设置每天的学习量。</div>
+      )}
+
+      {groups.map((g) => (
+        <div className="card taskgroup" key={g.course_id}>
+          <div className="tg-hd">
+            <span className="dot" style={{ background: g.color }} />
+            <span className="nm">{g.course_name}</span>
+            <span className="tag">{g.category}</span>
+            <span className="pg">
+              {g.done}/{g.count} · 课程总进度 {g.completed}/{g.total}
+            </span>
+          </div>
+          <div className="tasks">
+            {g.tasks.map((t) => (
+              <div className={`task ${t.status === 'done' ? 'done' : ''}`} key={t.id}>
+                <div className="tick" onClick={() => tick(t)}>
+                  {t.status === 'done' ? '✓' : ''}
+                </div>
+                <div className="t-main">
+                  <div className="t-title">{t.title}</div>
+                  <div className="t-meta">
+                    {t.task_type === 'review' ? (
+                      <span className="tag a" style={{ padding: '1px 7px' }}>复习</span>
+                    ) : (
+                      <span className="tag g" style={{ padding: '1px 7px' }}>新学</span>
+                    )}{' '}
+                    · 第 {t.lesson_no} 课 · 预计 {t.duration_min} 分钟
+                  </div>
+                </div>
+                <button
+                  className={`btn sm ${t.status === 'done' ? 'ghost' : ''}`}
+                  onClick={() =>
+                    openLesson({ lesson_id: t.lesson_id, task_id: t.id, task_type: t.task_type })
+                  }
+                >
+                  {t.status === 'done' ? '再看一次' : '打开课程'}
+                </button>
+              </div>
+            ))}
+          </div>
+          <div style={{ padding: '11px 18px' }}>
+            <div className="bar">
+              <i style={{ width: `${g.total ? Math.round((g.completed / g.total) * 100) : 0}%` }} />
+            </div>
+          </div>
+        </div>
+      ))}
+
+      {closed_courses.length > 0 && (
+        <div className="card pad" style={{ color: 'var(--muted)', fontSize: 12.5 }}>
+          {closed_courses.map((c) => (
+            <span
+              className="tag"
+              key={c.id}
+              style={{ background: '#eef2f6', color: '#9fb0bd', marginRight: 6 }}
+            >
+              {c.name}
+            </span>
+          ))}
+          已关闭,暂不生成任务、不提醒,进度已冻结保留。
+        </div>
+      )}
+    </>
+  )
+}
diff --git a/frontend/src/renderers/LessonBrief.jsx b/frontend/src/renderers/LessonBrief.jsx
new file mode 100644 (file)
index 0000000..0805944
--- /dev/null
@@ -0,0 +1,47 @@
+import React from 'react'
+
+/** lesson_brief:通用讲义兜底类型,任何形态未做专属渲染时都先落到这里。 */
+export default function LessonBrief({ payload }) {
+  const blocks = payload.blocks || []
+
+  return (
+    <div className="article">
+      {payload.title && (
+        <h3 style={{ marginBottom: 12, color: 'var(--primary-d)', fontWeight: 500 }}>
+          {payload.title}
+        </h3>
+      )}
+
+      {blocks.length === 0 && (
+        <p>{payload.body || '(本课暂无内容,可直接在后台补充讲义)'}</p>
+      )}
+
+      {blocks.map((b, i) => {
+        if (b.type === 'heading') {
+          return (
+            <h4 key={i} style={{ margin: '14px 0 6px', color: 'var(--primary-d)', fontWeight: 500 }}>
+              {b.text}
+            </h4>
+          )
+        }
+        if (b.type === 'list') {
+          return (
+            <ul key={i} style={{ margin: '0 0 12px 20px' }}>
+              {(b.items || []).map((it, j) => (
+                <li key={j}>{it}</li>
+              ))}
+            </ul>
+          )
+        }
+        if (b.type === 'image') {
+          return (
+            <p key={i}>
+              <img src={b.uri} alt={b.caption || ''} style={{ maxWidth: '100%', borderRadius: 8 }} />
+            </p>
+          )
+        }
+        return <p key={i}>{b.text}</p>
+      })}
+    </div>
+  )
+}
diff --git a/frontend/src/renderers/ReadingArticle.jsx b/frontend/src/renderers/ReadingArticle.jsx
new file mode 100644 (file)
index 0000000..43e37b6
--- /dev/null
@@ -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 (
+    <>
+      <div className="timer">
+        <span>已阅读</span>
+        <b>{mmss(sec)}</b>
+        <span>· 目标 {payload.min || 0} 分钟</span>
+        <span style={{ marginLeft: 'auto' }}>{glossary.length} 个生词标注</span>
+      </div>
+
+      <div className="article">
+        {payload.title && (
+          <h3 style={{ marginBottom: 12, color: 'var(--primary-d)', fontWeight: 500 }}>
+            {payload.title}
+          </h3>
+        )}
+        {/* 正文由后台发布(含 <em> 生词标注),内容源可信 */}
+        {(payload.body || []).map((p, i) => (
+          <p key={i} dangerouslySetInnerHTML={{ __html: p }} />
+        ))}
+      </div>
+
+      {glossary.length > 0 && (
+        <div className="card pad" style={{ marginTop: 15, background: '#fbfdff' }}>
+          <div style={{ fontSize: 12.5, color: 'var(--muted)', marginBottom: 6 }}>生词表</div>
+          {glossary.map((g, i) => (
+            <div style={{ fontSize: 13, padding: '2px 0' }} key={i}>
+              {g}
+            </div>
+          ))}
+        </div>
+      )}
+    </>
+  )
+}
diff --git a/frontend/src/renderers/VocabTable.jsx b/frontend/src/renderers/VocabTable.jsx
new file mode 100644 (file)
index 0000000..a8d7525
--- /dev/null
@@ -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 <div className="empty">本课暂无词条。</div>
+
+  return (
+    <>
+      <div style={{ fontSize: 12.5, color: 'var(--muted)', marginBottom: 12 }}>
+        共 {words.length} 个词条 · 点击卡片翻面
+      </div>
+      <div className="words">
+        {words.map((w, i) => (
+          <div key={i}>
+            <div
+              className={`flip ${flipped[i] ? 'f' : ''}`}
+              onClick={() => setFlipped((f) => ({ ...f, [i]: !f[i] }))}
+            >
+              <div className="in">
+                <div className="fc">
+                  <b>{w.word}</b>
+                  <span>{w.phonetic}</span>
+                  <div className="widx">
+                    {i + 1}/{words.length}
+                  </div>
+                </div>
+                <div className="fc back">
+                  <b>{w.meaning}</b>
+                  <span>{w.example}</span>
+                </div>
+              </div>
+            </div>
+          </div>
+        ))}
+      </div>
+    </>
+  )
+}
diff --git a/frontend/src/renderers/index.jsx b/frontend/src/renderers/index.jsx
new file mode 100644 (file)
index 0000000..06f904f
--- /dev/null
@@ -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 (file)
index 0000000..ff201d2
--- /dev/null
@@ -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}
+}
index 449ba5166314f96d5bb1d7ccedebc585ba5f6a8e..0bcf8bd07239fd9055eca9c03392f4f23116d37f 100644 (file)
@@ -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/, ''),
+      },
     },
   },
 })