--- /dev/null
+"""数据库连接与建表。
+
+表结构以 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()
+"""学习山峰 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=["*"],
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)
--- /dev/null
+"""调度引擎与机制回写。
+
+对应设计文档 §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
--- /dev/null
+"""示例数据:一门「雅思词汇」课程 + 进度指针 + 复习队列 + 计划。
+
+预置状态:前 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 (已有课程数据)")
<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>
<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>
+++ /dev/null
-.App {
- min-height: 100vh;
-}
-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
--- /dev/null
+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')),
+}
+++ /dev/null
-/* ========== 首页样式:简单大方 + 高级感 ========== */
-
-.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; }
-}
+++ /dev/null
-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
--- /dev/null
+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>
+ )
+}
--- /dev/null
+import { createContext, useContext } from 'react'
+
+/** 课时浮层与跨页刷新。由 App 提供,各页面消费。 */
+export const LessonCtx = createContext({
+ openLesson: () => {},
+ closeLesson: () => {},
+ refreshKey: 0,
+ refresh: () => {},
+})
+
+export const useLesson = () => useContext(LessonCtx)
+++ /dev/null
-* {
- 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;
-}
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(
--- /dev/null
+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>
+ </>
+ )
+}
--- /dev/null
+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>
+ </>
+ )
+}
--- /dev/null
+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>
+ </>
+ )
+}
--- /dev/null
+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>
+ )}
+ </>
+ )
+}
--- /dev/null
+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>
+ )
+}
--- /dev/null
+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>
+ )}
+ </>
+ )
+}
--- /dev/null
+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>
+ </>
+ )
+}
--- /dev/null
+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
+}
--- /dev/null
+/* 学习山峰 · 浅蓝清新 + 小碎花主题
+ 样式基线来自 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}
+}
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/, ''),
+ },
},
},
})