]> acesimba.cloud Git - study-mountion.git/commitdiff
雅思词汇真经课程:3073 词导入 + 五阶段训练 + TTS
authorstudyhill <studyhill@local>
Sat, 29 Aug 2026 09:26:54 +0000 (17:26 +0800)
committerstudyhill <studyhill@local>
Sat, 29 Aug 2026 09:26:54 +0000 (17:26 +0800)
数据:
- 新增 imports/ielts_vocab.py:xlsx(3073 词 / 22 章 / 19 列)按 ceil(n/50)
  切为 74 组;含词性归一化 474 条、缺例句 16 词与无搭配 2173 词标记跳过、
  每组自动选 10 个 is_key
- 删除旧的占位课程,改为真实词库

训练:
- 新增内容类型 vocab_drill_group:学词/拼写/选择/听写/错题重做五阶段,
  错题池清空才允许打卡;复习走缩水版(仅选择 + 听写)
- 新增循环播放器:单词×2 → 逐字母 → 中文 → 例句 → 例句中文 → 各搭配逐一,
  整组无限循环,支持上下词/语速/从第 N 词开始
- 新增 audio.js 单例播放器,修复 StrictMode 下 useEffect 双执行导致的叠音;
  支持分段语速(例句 0.92)与序列 token 取代

TTS:
- 新增 tts.py:单词走有道 type=1 英音,长句与中文走 edge-tts
  (en-GB-SoniaNeural / zh-CN-XiaoxiaoNeural),百度兜底;本地缓存,
  edge 偶发抖动故超时单独放宽到 30s,避免回退到机械音

计划:
- plan_items 加 interval_days,学习计划页支持每天/隔天/每 N 天 + 生效日多选
- auto 类课时不再允许一键打卡,改为打开训练器

其他:
- .gitignore 排除 backend/db/tts_cache 音频缓存目录

16 files changed:
.gitignore
backend/db.py
backend/imports/ielts_vocab.py [new file with mode: 0644]
backend/main.py
backend/requirements.txt
backend/scheduler.py
backend/tts.py [new file with mode: 0644]
frontend/src/api.js
frontend/src/audio.js [new file with mode: 0644]
frontend/src/components/LessonSheet.jsx
frontend/src/components/LoopPlayer.jsx [new file with mode: 0644]
frontend/src/pages/Plan.jsx
frontend/src/pages/Today.jsx
frontend/src/renderers/VocabDrill.jsx [new file with mode: 0644]
frontend/src/renderers/index.jsx
frontend/src/theme.css

index 33b48e52fd8e6f6977a4bdbaaf82e6968196d534..de6841867c38974f46dc893f21bc28b5de9219a9 100644 (file)
@@ -13,3 +13,6 @@ frontend/dist/
 # Database & Logs
 *.db
 backend/db/*.db
+
+# TTS 音频缓存(按需生成,不入库)
+backend/db/tts_cache/
index 663d6c646f65e8b0739c4702133b50af186f1972..7bedbb6833242c7972797c4e341042ed7d9836eb 100644 (file)
@@ -140,6 +140,7 @@ CREATE TABLE IF NOT EXISTS plan_items (
   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',
+  interval_days INTEGER NOT NULL DEFAULT 1,
   UNIQUE(plan_id, course_id)
 );
 
@@ -191,11 +192,23 @@ def _ensure_log_columns(conn):
             conn.execute(f"ALTER TABLE study_logs ADD COLUMN {col} {decl}")
 
 
+# interval_days 是后续加的「每 N 天学一组」,存量库按需补齐
+_PLAN_EXTRA_COLUMNS = (("interval_days", "INTEGER NOT NULL DEFAULT 1"),)
+
+
+def _ensure_plan_columns(conn):
+    existing = {r["name"] for r in conn.execute("PRAGMA table_info(plan_items)")}
+    for col, decl in _PLAN_EXTRA_COLUMNS:
+        if col not in existing:
+            conn.execute(f"ALTER TABLE plan_items 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)
+    _ensure_plan_columns(conn)
     conn.commit()
     conn.close()
diff --git a/backend/imports/ielts_vocab.py b/backend/imports/ielts_vocab.py
new file mode 100644 (file)
index 0000000..83a25b9
--- /dev/null
@@ -0,0 +1,314 @@
+"""雅思词汇真经 xlsx → 74 个课时。
+
+数据源:雅思词汇真经_最终校正版.xlsx · sheet「最终单词表」· 3073 词 · 22 章 · 19 列
+切组规则(不跨章):k = ceil(n / 50),章内均分,余数摊到前几组。
+产出:1 门课程 code=IELTS-VOCAB-01 + 74 个 lessons(content_type=vocab_drill_group)。
+
+用法:
+    python3 imports/ielts_vocab.py                # 用默认 xlsx 路径
+    python3 imports/ielts_vocab.py --xlsx 路径    # 指定词库
+    python3 imports/ielts_vocab.py --dry-run      # 只打印切组结果,不写库
+"""
+
+import argparse
+import json
+import math
+import os
+import re
+import sys
+
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+import db  # noqa: E402
+
+DEFAULT_XLSX = "/home/Codebuddy-web/data/pastes/雅思词汇真经_最终校正版.xlsx"
+SHEET = "最终单词表"
+
+COURSE = {
+    "code": "IELTS-VOCAB-01",
+    "name": "雅思词汇真经",
+    "category": "vocab",
+    "locale": "en",
+    "description": "22 章 3073 词,切分为 74 组;每组五阶段训练(学词/拼写/选择/听写/错题重做)",
+    "icon": "A",
+    "color": "#4a9e7a",
+    "is_open": 1,
+    "allow_skip": 0,
+    "sort_order": 1,
+}
+
+# 列索引(0-based,对应 xlsx 表头顺序)
+C_CHAPTER, C_CHAPTER_TITLE, C_WID, C_WORD, C_PHONETIC, C_POS, C_MEANING = range(7)
+C_EXAMPLE, C_EXAMPLE_ZH = 7, 8
+C_COL_EN = (9, 11, 13)          # 搭配1/2/3
+C_COL_ZH = (10, 12, 14)         # 搭配1/2/3 中文
+
+GROUP_TARGET = 50
+KEY_WORDS_PER_GROUP = 10
+
+# 词性归一化顺序:名词 > 动词 > 形容词 > 副词
+POS_ORDER = {"n.": 0, "v.": 1, "adj.": 2, "adv.": 3}
+# 抽象/学术词后缀,用于挑写作重点词时加权
+ABSTRACT_SUFFIX = re.compile(r"(tion|sion|ment|ness|ity|ism|ance|ence|ship|hood|cy|al)$")
+
+
+# ---------- 读取与清洗 ----------
+
+def _clean(v):
+    if v is None:
+        return None
+    s = str(v).strip()
+    return s or None
+
+
+def normalize_pos(pos):
+    """词性归一化:'v./n.' 与 'n./v.' 统一为 'n./v.',去重后按固定顺序排。"""
+    s = _clean(pos)
+    if not s:
+        return None
+    parts = [p.strip() for p in re.split(r"[/、,;]", s) if p.strip()]
+    norm, seen = [], set()
+    for p in parts:
+        p = p.rstrip(".") + "."
+        if p not in seen:
+            seen.add(p)
+            norm.append(p)
+    norm.sort(key=lambda p: POS_ORDER.get(p, 99))
+    return "/".join(norm)
+
+
+def load_rows(xlsx_path):
+    import openpyxl
+
+    wb = openpyxl.load_workbook(xlsx_path, data_only=True)
+    if SHEET not in wb.sheetnames:
+        raise SystemExit(f"找不到 sheet「{SHEET}」,现有:{wb.sheetnames}")
+    ws = wb[SHEET]
+
+    rows = []
+    for r in ws.iter_rows(min_row=2, values_only=True):
+        if not _clean(r[C_WORD]):
+            continue
+        collocations = []
+        for i, j in zip(C_COL_EN, C_COL_ZH):
+            en, zh = _clean(r[i]), _clean(r[j])
+            if en:
+                collocations.append({"en": en, "zh": zh})
+        rows.append(
+            {
+                "chapter": int(r[C_CHAPTER]),
+                "chapter_title": _clean(r[C_CHAPTER_TITLE]),
+                "wid": _clean(r[C_WID]),
+                "word": _clean(r[C_WORD]),
+                "phonetic": _clean(r[C_PHONETIC]),
+                "pos": normalize_pos(r[C_POS]),
+                "meaning": _clean(r[C_MEANING]),
+                "example": _clean(r[C_EXAMPLE]),
+                "example_zh": _clean(r[C_EXAMPLE_ZH]),
+                "collocations": collocations,
+            }
+        )
+    return rows
+
+
+# ---------- 切组 ----------
+
+def split_sizes(n):
+    """章内均分成 k 组,余数摊到前几组。"""
+    k = math.ceil(n / GROUP_TARGET)
+    base, rem = divmod(n, k)
+    return [base + (1 if i < rem else 0) for i in range(k)]
+
+
+def group_chapters(rows):
+    """返回 [(chapter, chapter_title, group_no, [word...]), ...],lesson_no 全书连续。"""
+    by_chapter = {}
+    for r in rows:
+        by_chapter.setdefault(r["chapter"], {"title": r["chapter_title"], "words": []})
+        by_chapter[r["chapter"]]["words"].append(r)
+
+    groups = []
+    for ch in sorted(by_chapter):
+        data = by_chapter[ch]
+        words = data["words"]
+        offset = 0
+        for g_no, size in enumerate(split_sizes(len(words)), start=1):
+            groups.append((ch, data["title"], g_no, words[offset : offset + size]))
+            offset += size
+    return groups
+
+
+# ---------- is_key 选取 ----------
+
+def key_score(w):
+    """优先有搭配的词 > 学术抽象词 > 词长。"""
+    s = 0
+    if w["collocations"]:
+        s += 100
+    if ABSTRACT_SUFFIX.search(w["word"].lower()):
+        s += 20
+    s += min(len(w["word"]), 15)
+    return s
+
+
+def mark_key_words(words):
+    ranked = sorted(range(len(words)), key=lambda i: -key_score(words[i]))
+    for i in ranked[:KEY_WORDS_PER_GROUP]:
+        words[i]["is_key"] = True
+    return words
+
+
+# ---------- payload ----------
+
+def build_payload(chapter, chapter_title, group_no, words):
+    mark_key_words(words)
+    return {
+        "version": 1,
+        "content_type": "vocab_drill_group",
+        "meta": {
+            "chapter": chapter,
+            "chapter_title": chapter_title,
+            "group_no": group_no,
+            "size": len(words),
+        },
+        "drill_config": {
+            "stages": ["learn", "spell", "choice", "dictation", "error_redo"],
+            "choice_options": 4,
+            "key_words_per_group": KEY_WORDS_PER_GROUP,
+            "pass_rule": "error_pool_empty",
+            # 数据源音标为 OCR 转写,非标准 IPA,前台默认不展示
+            "phonetic_visible": False,
+        },
+        "words": [
+            {
+                "wid": w["wid"],
+                "word": w["word"],
+                "phonetic": w["phonetic"],
+                "phonetic_reliable": False,
+                "pos": w["pos"],
+                "meaning": w["meaning"],
+                "example": w["example"],
+                "example_zh": w["example_zh"],
+                "collocations": w["collocations"],
+                "is_key": w.get("is_key", False),
+                "tts": True,
+            }
+            for w in words
+        ],
+    }
+
+
+def duration_of(size):
+    """一组 40-50 词走完五阶段,按每词约 0.6 分钟估算。"""
+    return max(20, round(size * 0.6))
+
+
+# ---------- 写库 ----------
+
+def purge_course(conn, code):
+    """按 code 删除课程及其全部关联数据(进度/完成/复习/任务/计划条目)。"""
+    row = conn.execute("SELECT id FROM courses WHERE code=?", (code,)).fetchone()
+    if not row:
+        return 0
+    cid = row["id"]
+    conn.execute("DELETE FROM daily_tasks WHERE course_id=?", (cid,))
+    conn.execute("DELETE FROM review_queue WHERE course_id=?", (cid,))
+    conn.execute("DELETE FROM lesson_completions WHERE course_id=?", (cid,))
+    conn.execute("DELETE FROM course_progress WHERE course_id=?", (cid,))
+    conn.execute("DELETE FROM plan_items WHERE course_id=?", (cid,))
+    conn.execute("DELETE FROM lessons WHERE course_id=?", (cid,))
+    conn.execute("DELETE FROM courses WHERE id=?", (cid,))
+    return cid
+
+
+def import_course(rows, dry_run=False):
+    groups = group_chapters(rows)
+    total_words = sum(len(g[3]) for g in groups)
+    print(f"词库:{len(rows)} 词 · {len({g[0] for g in groups})} 章 · 切为 {len(groups)} 组")
+
+    if dry_run:
+        for ch, title, g_no, words in groups:
+            mark_key_words(words)
+            keys = [w["word"] for w in words if w.get("is_key")]
+            print(f"  Ch{ch:<2} {title:<6} G{g_no}  {len(words):>2} 词  key={len(keys)}: {','.join(keys[:4])}…")
+        return
+
+    conn = db.get_conn()
+    try:
+        old = purge_course(conn, COURSE["code"])
+        if old:
+            print(f"已删除旧课程(id={old})及其关联数据")
+        conn.execute("DELETE FROM daily_tasks")  # 今日任务基于旧课程,重建
+
+        ts = db.now()
+        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
+
+        for lesson_no, (ch, title, g_no, words) in enumerate(groups, start=1):
+            payload = build_payload(ch, title, g_no, words)
+            conn.execute(
+                """
+                INSERT INTO lessons (course_id, lesson_no, title, content_type, payload,
+                                     completion_type, duration_min, created_at, updated_at)
+                VALUES (?,?,?,?,?,?,?,?,?)
+                """,
+                (
+                    course_id,
+                    lesson_no,
+                    f"Ch{ch} {title} · G{g_no}",
+                    "vocab_drill_group",
+                    json.dumps(payload, ensure_ascii=False),
+                    "auto",
+                    duration_of(len(words)),
+                    ts,
+                    ts,
+                ),
+            )
+
+        conn.execute(
+            """
+            INSERT INTO course_progress (user_id, course_id, next_lesson_no, completed_count,
+                                         total_lessons, updated_at)
+            VALUES (?,?,1,0,?,?)
+            """,
+            (1, course_id, len(groups), ts),
+        )
+
+        plan = conn.execute(
+            "SELECT id FROM study_plans WHERE user_id=1 AND is_active=1 ORDER BY id LIMIT 1"
+        ).fetchone()
+        if plan:
+            conn.execute(
+                "INSERT OR REPLACE INTO plan_items (plan_id, course_id, daily_new, daily_review) VALUES (?,?,?,?)",
+                (plan["id"], course_id, 1, 1),
+            )
+
+        conn.commit()
+        print(f"已写入:course_id={course_id} · {len(groups)} 课时 · {total_words} 词")
+    finally:
+        conn.close()
+
+
+def main():
+    ap = argparse.ArgumentParser()
+    ap.add_argument("--xlsx", default=DEFAULT_XLSX)
+    ap.add_argument("--dry-run", action="store_true")
+    args = ap.parse_args()
+
+    if not os.path.exists(args.xlsx):
+        raise SystemExit(f"找不到词库文件:{args.xlsx}")
+
+    db.init_db()
+    import_course(load_rows(args.xlsx), dry_run=args.dry_run)
+
+
+if __name__ == "__main__":
+    main()
index cdd99aafed29467ef1e7299b4dd3234f8d4adf6c..16a2e53392eca5294625633fabcb1e319bf06638 100644 (file)
@@ -8,10 +8,12 @@ import json
 
 from fastapi import FastAPI, HTTPException
 from fastapi.middleware.cors import CORSMiddleware
+from fastapi.responses import Response
 from pydantic import BaseModel
 
 import db
 import scheduler
+import tts
 from seed import DEMO_USER_ID
 
 app = FastAPI(title="学习山峰 API", version="0.2.0")
@@ -39,6 +41,8 @@ def on_startup():
 class PlanItemIn(BaseModel):
     daily_new: int = 0
     daily_review: int = 0
+    weekdays: str = None          # '1,2,3,4,5,6,7'
+    interval_days: int = None     # 1=每天,2=隔天
 
 
 class CompleteIn(BaseModel):
@@ -114,7 +118,7 @@ def get_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,
+                   l.lesson_no, l.title, l.content_type, l.completion_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
@@ -159,6 +163,7 @@ def get_today():
                     "task_type": r["task_type"],
                     "status": r["status"],
                     "content_type": r["content_type"],
+                    "completion_type": r["completion_type"],
                     "duration_min": r["duration_min"],
                 }
             )
@@ -337,7 +342,7 @@ def get_plan():
             return {"plan": None, "items": []}
         rows = conn.execute(
             """
-            SELECT pi.course_id, pi.daily_new, pi.daily_review, pi.weekdays,
+            SELECT pi.course_id, pi.daily_new, pi.daily_review, pi.weekdays, pi.interval_days,
                    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
@@ -368,14 +373,31 @@ def update_plan_item(course_id: int, body: PlanItemIn):
         ).fetchone()
         if not plan:
             raise HTTPException(status_code=404, detail="no active plan")
+        # 只更新传入的字段,未传的保持原值
+        sets = ["daily_new=?", "daily_review=?"]
+        vals = [max(0, body.daily_new), max(0, body.daily_review)]
+        if body.weekdays:
+            sets.append("weekdays=?")
+            vals.append(body.weekdays)
+        if body.interval_days is not None:
+            sets.append("interval_days=?")
+            vals.append(max(1, body.interval_days))
+
         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),
+            f"UPDATE plan_items SET {','.join(sets)} WHERE plan_id=? AND course_id=?",
+            vals + [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)),
+                """
+                INSERT INTO plan_items (plan_id, course_id, daily_new, daily_review, weekdays, interval_days)
+                VALUES (?,?,?,?,?,?)
+                """,
+                (
+                    plan["id"], course_id,
+                    max(0, body.daily_new), max(0, body.daily_review),
+                    body.weekdays or "1,2,3,4,5,6,7", max(1, body.interval_days or 1),
+                ),
             )
         conn.commit()
         return {"ok": True}
@@ -432,6 +454,18 @@ def admin_stats():
         conn.close()
 
 
+# ---------- TTS ----------
+
+@app.get("/api/tts")
+def get_tts(text: str = "", lang: str = "en"):
+    """合成语音并缓存。lang: en(单词/例句/字母) / zh(中文含义)。"""
+    try:
+        data = tts.synthesize(text, lang)
+    except tts.TTSError as e:
+        raise HTTPException(status_code=502, detail=str(e))
+    return Response(content=data, media_type="audio/mpeg")
+
+
 # ---------- 其他 ----------
 
 @app.get("/api/me")
index c9b60046e2abd8fb701388afba0cdb85a17d172c..da322d9d64c0756a307e746123b8d801157c62cf 100644 (file)
@@ -1,3 +1,5 @@
 fastapi
 uvicorn
 pydantic
+edge-tts
+openpyxl
index 672ebb11d698ea003450c7fc1fcf48d2fc23380a..1336b63d67a8a2a29e499f0c077ab190556a16be 100644 (file)
@@ -27,7 +27,7 @@ def ensure_daily_tasks(conn, user_id: int, date: str = None) -> int:
     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",
+        "SELECT id, start_date FROM study_plans WHERE user_id=? AND is_active=1 ORDER BY id LIMIT 1",
         (user_id,),
     ).fetchone()
     if not plan:
@@ -35,7 +35,7 @@ def ensure_daily_tasks(conn, user_id: int, date: str = None) -> int:
 
     items = conn.execute(
         """
-        SELECT pi.course_id, pi.daily_new, pi.daily_review, pi.weekdays, c.allow_skip
+        SELECT pi.course_id, pi.daily_new, pi.daily_review, pi.weekdays, pi.interval_days, 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
@@ -48,7 +48,7 @@ def ensure_daily_tasks(conn, user_id: int, date: str = None) -> int:
     for item in items:
         if weekday not in (item["weekdays"] or "").split(","):
             continue
-        created += _gen_new(conn, user_id, date, item)
+        created += _gen_new(conn, user_id, date, item, plan["start_date"])
         created += _gen_review(conn, user_id, date, item)
     conn.commit()
     return created
@@ -66,8 +66,26 @@ def _remaining_quota(conn, user_id, date, course_id, task_type, quota) -> int:
     return max(0, (quota or 0) - used)
 
 
-def _gen_new(conn, user_id, date, item) -> int:
+def _interval_due(start_date, date, interval) -> bool:
+    """每 N 天学一组:按 (date - start_date) % interval 判断当天是否该出新学。
+
+    复习不受此限制——积压的复习本来就该及时清掉。
+    """
+    interval = interval or 1
+    if interval <= 1 or not start_date:
+        return True
+    try:
+        d0 = datetime.date.fromisoformat(start_date)
+        d1 = datetime.date.fromisoformat(date)
+    except ValueError:
+        return True
+    return (d1 - d0).days % interval == 0
+
+
+def _gen_new(conn, user_id, date, item, start_date=None) -> int:
     course_id = item["course_id"]
+    if not _interval_due(start_date, date, item["interval_days"]):
+        return 0
     want = _remaining_quota(conn, user_id, date, course_id, "new", item["daily_new"])
     if want <= 0:
         return 0
diff --git a/backend/tts.py b/backend/tts.py
new file mode 100644 (file)
index 0000000..81aab3a
--- /dev/null
@@ -0,0 +1,146 @@
+"""TTS 代理:有道(英文单词) / edge-tts(长句·中文) / 百度兜底,带本地缓存。
+
+源选择依据 2026-08-29 实测:
+    有道 EN 单词     ✓ 0.24s,词典真人发音,最快
+    有道 EN 长句     ✗ HTTP 500
+    edge-tts 长句/中文 ✓ ~2s,微软神经网络音,自然度明显优于百度
+    百度 EN/ZH       ✓ 合成音偏机械,仅作兜底
+
+策略:单词走有道(快且准);长句与中文走 edge-tts(自然度优先);全部源失败再落百度。
+
+缓存:db/tts_cache/<前缀>_<md5前12位>.mp3,按需生成,重复播放零请求。
+"""
+
+import asyncio
+import hashlib
+import os
+import re
+import urllib.error
+import urllib.parse
+import urllib.request
+
+import db
+
+CACHE_DIR = os.path.join(db.BASE_DIR, "db", "tts_cache")
+os.makedirs(CACHE_DIR, exist_ok=True)
+
+UA = {"User-Agent": "Mozilla/5.0"}
+BAIDU_HEADERS = {"User-Agent": "Mozilla/5.0", "Referer": "https://fanyi.baidu.com/"}
+
+TIMEOUT = 12        # HTTP 源(有道 / 百度)
+EDGE_TIMEOUT = 30   # edge-tts 偶发抖动到 10s+,放宽些避免轻易回退到机械音
+# 超过该长度的英文按长句处理,走 edge-tts(有道对长句返回 500)
+SHORT_EN_MAXLEN = 24
+MIN_AUDIO_BYTES = 500
+
+# 微软神经网络语音:英式女声 Sonia / 中文女声晓晓
+VOICE_EN = "en-GB-SoniaNeural"
+VOICE_ZH = "zh-CN-XiaoxiaoNeural"
+# 有道 type=1 英音 / type=2 美音。与 VOICE_EN 的英音保持一致,避免同一课里英美混读
+YOUDAO_TYPE = 1
+
+
+class TTSError(Exception):
+    pass
+
+
+def _cache_path(text: str, lang: str) -> str:
+    safe = re.sub(r"[^0-9A-Za-z\u4e00-\u9fff]+", "_", text.strip().lower())[:24].strip("_")
+    digest = hashlib.md5(f"{lang}:{text}".encode("utf-8")).hexdigest()[:12]
+    name = f"{safe}_{digest}" if safe else digest
+    return os.path.join(CACHE_DIR, f"{name}.mp3")
+
+
+def _fetch(url: str, headers: dict) -> bytes:
+    req = urllib.request.Request(url, headers=headers)
+    with urllib.request.urlopen(req, timeout=TIMEOUT) as resp:
+        if resp.headers.get("Content-Type", "").startswith("application/json"):
+            raise TTSError("upstream returned json instead of audio")
+        data = resp.read()
+    if len(data) < MIN_AUDIO_BYTES:
+        raise TTSError(f"audio too small ({len(data)} bytes)")
+    return data
+
+
+def _youdao_en(text: str) -> bytes:
+    url = (
+        "https://dict.youdao.com/dictvoice?audio="
+        + urllib.parse.quote(text)
+        + f"&type={YOUDAO_TYPE}"
+    )
+    return _fetch(url, UA)
+
+
+def _edge(text: str, lang: str) -> bytes:
+    """微软 Edge 神经网络 TTS,自然度最好。同步上下文里跑一次事件循环。"""
+    import edge_tts
+
+    voice = VOICE_ZH if lang == "zh" else VOICE_EN
+
+    async def synth() -> bytes:
+        chunks = []
+        async for chunk in edge_tts.Communicate(text, voice).stream():
+            if chunk["type"] == "audio":
+                chunks.append(chunk["data"])
+        data = b"".join(chunks)
+        if len(data) < MIN_AUDIO_BYTES:
+            raise TTSError(f"edge-tts audio too small ({len(data)} bytes)")
+        return data
+
+    return asyncio.run(asyncio.wait_for(synth(), timeout=EDGE_TIMEOUT))
+
+
+def _baidu(text: str, lang: str) -> bytes:
+    lan = "zh" if lang == "zh" else "en"
+    url = (
+        "https://fanyi.baidu.com/gettts?lan="
+        + lan
+        + "&text="
+        + urllib.parse.quote(text)
+        + "&spd=3"
+    )
+    return _fetch(url, BAIDU_HEADERS)
+
+
+def synthesize(text: str, lang: str = "en") -> bytes:
+    """返回 mp3 字节。命中缓存直接读盘,未命中按源优先级合成后写缓存。"""
+    text = (text or "").strip()
+    if not text:
+        raise TTSError("empty text")
+
+    path = _cache_path(text, lang)
+    if os.path.exists(path):
+        with open(path, "rb") as f:
+            return f.read()
+
+    is_short_en = lang == "en" and len(text) <= SHORT_EN_MAXLEN
+    if lang == "zh":
+        # 中文:edge 最自然,百度兜底
+        sources = [(lambda t: _edge(t, "zh")), (lambda t: _baidu(t, "zh"))]
+    elif is_short_en:
+        # 英文单词:有道最快且为词典真人发音,edge 兜底
+        sources = [_youdao_en, (lambda t: _edge(t, "en")), (lambda t: _baidu(t, "en"))]
+    else:
+        # 英文长句:有道会 500,edge 自然度优先,百度兜底
+        sources = [(lambda t: _edge(t, "en")), (lambda t: _baidu(t, "en"))]
+
+    last_err = None
+    data = None
+    for src in sources:
+        try:
+            data = src(text)
+            break
+        except (urllib.error.URLError, TTSError, OSError, asyncio.TimeoutError) as e:
+            last_err = f"{type(e).__name__}: {e}"
+            data = None
+
+    if not data:
+        raise TTSError(f"all TTS sources failed -> {last_err}")
+
+    try:
+        with open(path, "wb") as f:
+            f.write(data)
+    except OSError as e:
+        print(f"TTS cache write failed: {e}")
+
+    return data
index 46a4aad5aae39615451af455775cf2735889e281..cdfe0a81a4046e356de33a12194afa1955175176 100644 (file)
@@ -7,6 +7,10 @@ const http = axios.create({ baseURL: API_BASE })
 
 const unwrap = (p) => p.then((r) => r.data)
 
+/** TTS 音频地址,直接喂给 <audio src> 或 new Audio()。 */
+export const ttsUrl = (text, lang = "en") =>
+  `${API_BASE}/tts?text=${encodeURIComponent(text)}&lang=${lang}`
+
 export const api = {
   me: () => unwrap(http.get('/me')),
 
@@ -21,8 +25,15 @@ export const api = {
   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 })),
+  updatePlanItem: (courseId, { dailyNew, dailyReview, weekdays, intervalDays }) =>
+    unwrap(
+      http.put(`/plan/items/${courseId}`, {
+        daily_new: dailyNew,
+        daily_review: dailyReview,
+        weekdays: weekdays.join(','),
+        interval_days: intervalDays,
+      }),
+    ),
 
   // 后台
   setCourseOpen: (courseId, isOpen) =>
diff --git a/frontend/src/audio.js b/frontend/src/audio.js
new file mode 100644 (file)
index 0000000..83e9682
--- /dev/null
@@ -0,0 +1,77 @@
+import { ttsUrl } from './api'
+
+/**
+ * 全局单例音频播放。
+ *
+ * 必须单例的原因:任何时刻只允许一个音频在播。
+ * 否则会叠音——典型场景是 React StrictMode 在开发模式下把 useEffect 跑两遍,
+ * 同一个词被两个 Audio 实例同时播放,听起来就是明显的回声/重音。
+ *
+ * 序列播放用自增 token 保证「新序列自动取代旧序列」,旧序列在下一个 await 点退出。
+ */
+
+let current = null
+let currentResolve = null
+let seqCounter = 0
+
+
+export function stopAudio() {
+  if (current) {
+    current.pause()
+    current.src = ''
+    current = null
+  }
+  if (currentResolve) {
+    const resolve = currentResolve
+    currentResolve = null
+    resolve('stopped') // 唤醒等待中的 playSeq,让它检查 token 后退出
+  }
+}
+
+/** 播放一段。开始前会停掉上一段,返回 'done' | 'stopped' | 'error'。 */
+export function playOne(text, lang = 'en', rate = 1) {
+  return new Promise((resolve) => {
+    stopAudio()
+
+    const audio = new Audio(ttsUrl(text, lang))
+    audio.playbackRate = rate || 1
+    current = audio
+    currentResolve = resolve
+
+    const done = (reason) => {
+      if (current === audio) current = null
+      if (currentResolve === resolve) currentResolve = null
+      resolve(reason)
+    }
+    audio.onended = () => done('done')
+    audio.onerror = () => done('error')
+    audio.play().catch(() => done('error'))
+  })
+}
+
+/** 例句比单词略慢一档,听清连读与弱读(原本 0.85 偏慢,回调到 0.92)。 */
+export const EXAMPLE_RATE = 0.92
+
+/**
+ * 顺序播放多段。返回是否完整播完(false = 被新序列或 stopAudio 打断)。
+ * items: [{ text, lang, rate? }],item 自带 rate 时优先于整体 rate。
+ */
+export async function playSeq(items, rate = 1) {
+  const mySeq = ++seqCounter
+  for (const it of items) {
+    if (mySeq !== seqCounter) return false
+    const reason = await playOne(it.text, it.lang, it.rate ?? rate)
+    if (reason !== 'done') return false
+  }
+  return mySeq === seqCounter
+}
+
+/** 让循环播放等场景能判断自己是否已被取代。 */
+export function isCurrentSeq(seq) {
+  return seq === seqCounter
+}
+
+/** 实时改变正在播放音频的语速(下一句也沿用)。 */
+export function setAudioRate(rate) {
+  if (current) current.playbackRate = rate
+}
index 70c8b4361e13bdcd0ea7276f24b32702c812097f..3a46dd148ac8cb274969de9d185b4ed1fc70d4ef 100644 (file)
@@ -1,4 +1,4 @@
-import React, { useEffect, useState } from 'react'
+import React, { useCallback, useEffect, useState } from 'react'
 import { api } from '../api'
 import { useLesson } from '../ctx'
 import { resolve, hintOf } from '../renderers'
@@ -6,16 +6,25 @@ import { resolve, hintOf } from '../renderers'
 export default function LessonSheet({ lesson }) {
   const { closeLesson, refresh } = useLesson()
   const [data, setData] = useState(null)
-  // 由渲染器按需控制:manual 恒为 true,duration 类需计时达标
+  // 由渲染器按需控制:manual 恒为 true,auto/duration 类需达标
   const [ready, setReady] = useState(true)
+  // auto 类型回传的成绩明细(如五阶段训练的错题统计)
+  const [detail, setDetail] = useState(null)
+
+  const handleReady = useCallback((isReady, stats) => {
+    setReady(isReady)
+    if (stats) setDetail(stats)
+  }, [])
 
   useEffect(() => {
-    setReady(true)
     setData(null)
+    setDetail(null)
     api
       .lesson(lesson.lesson_id)
       .then((d) => {
         setData(d)
+        // auto/duration 需训练达标后才允许打卡,manual 立即可打
+        setReady((d.completion_type || 'manual') === 'manual')
         api.openLesson(lesson.lesson_id).catch(() => {}) // 记录 open 事件
       })
       .catch(() => setData(null))
@@ -44,6 +53,7 @@ export default function LessonSheet({ lesson }) {
     await api.completeLesson(lesson.lesson_id, {
       task_id: lesson.task_id,
       task_type: lesson.task_type || 'new',
+      detail: detail ? JSON.stringify(detail) : null,
     })
     refresh()
     closeLesson()
@@ -73,11 +83,17 @@ export default function LessonSheet({ lesson }) {
         </div>
 
         <div className="sh-bd">
-          <Component payload={data.payload || {}} onReadyChange={setReady} />
+          <Component
+            payload={data.payload || {}}
+            taskType={lesson.task_type || 'new'}
+            onReadyChange={handleReady}
+          />
         </div>
 
         <div className="sh-ft">
-          <span className="sp">{hintOf(entry, data.payload)}</span>
+          <span className="sp">
+            {ready ? hintOf(entry, data.payload) : '完成训练后可打卡'}
+          </span>
           <button className="btn" onClick={finish} disabled={!ready}>
             完成本课
           </button>
diff --git a/frontend/src/components/LoopPlayer.jsx b/frontend/src/components/LoopPlayer.jsx
new file mode 100644 (file)
index 0000000..429a854
--- /dev/null
@@ -0,0 +1,198 @@
+import React, { useEffect, useRef, useState } from 'react'
+import { EXAMPLE_RATE, playSeq, setAudioRate, stopAudio } from '../audio'
+
+/**
+ * 循环播放(听学模式):整组无限循环,适合躺着只听。
+ *
+ * 默认序列模板(缺例句/搭配的词自动跳过对应段):
+ *   1 单词 ×2 → 2 逐字母 → 3 中文含义+词性 → 4 例句 → 5 例句中文 → 6 搭配 → 7 搭配中文
+ *
+ * 播放统一走 audio.js 的单例播放器,避免切词/重复点击造成叠音。
+ */
+
+const RATES = [0.75, 1.0, 1.25]
+
+const BASE_LABEL = {
+  word: '单词',
+  word2: '单词(第二遍)',
+  letters: '逐字母拼写',
+  zh: '中文含义',
+  example: '例句(慢速)',
+  example_zh: '例句中文',
+}
+
+/** 搭配有多个,序号在标签里体现:搭配1 / 搭配1中文 / 搭配2 … */
+function segLabel(key) {
+  if (BASE_LABEL[key]) return BASE_LABEL[key]
+  const m = key.match(/^colloc(\d+)(_zh)?$/)
+  if (m) return `搭配${m[1]}${m[2] ? '中文' : ''}`
+  return key
+}
+
+function buildQueue(w, baseRate = 1) {
+  if (!w) return []
+  const q = [
+    { key: 'word', text: w.word, lang: 'en' },
+    { key: 'word2', text: w.word, lang: 'en' },
+    { key: 'letters', text: w.word.split('').join('-'), lang: 'en' },
+    { key: 'zh', text: `${w.meaning || ''} ${w.pos || ''}`.trim(), lang: 'zh' },
+  ]
+  if (w.example) {
+    q.push({ key: 'example', text: w.example, lang: 'en', rate: baseRate * EXAMPLE_RATE })
+  }
+  if (w.example_zh) q.push({ key: 'example_zh', text: w.example_zh, lang: 'zh' })
+  // 搭配逐一读出,不再只读第一个
+  ;(w.collocations || []).forEach((c, i) => {
+    if (!c.en) return
+    q.push({ key: `colloc${i + 1}`, text: c.en, lang: 'en' })
+    if (c.zh) q.push({ key: `colloc${i + 1}_zh`, text: c.zh, lang: 'zh' })
+  })
+  return q
+}
+
+export default function LoopPlayer({ words, onExit }) {
+  const [i, setI] = useState(0)
+  const [playing, setPlaying] = useState(true)
+  const [rate, setRate] = useState(1.0)
+  const [seg, setSeg] = useState(null)
+  const [startAt, setStartAt] = useState(1)
+  const [nonce, setNonce] = useState(0) // 递增以重启循环(跳词/重听)
+
+  const idxRef = useRef(0)
+  const rateRef = useRef(1.0)
+  const runRef = useRef(0)
+
+  useEffect(() => {
+    idxRef.current = i
+  }, [i])
+
+  useEffect(() => {
+    rateRef.current = rate
+    setAudioRate(rate) // 正在播放的那一段立刻变速,后续句子沿用新值
+  }, [rate])
+
+  useEffect(() => {
+    if (!playing) {
+      stopAudio()
+      return
+    }
+    const myRun = ++runRef.current
+
+    const run = async () => {
+      while (runRef.current === myRun) {
+        const w = words[idxRef.current]
+        if (!w) break
+        for (const item of buildQueue(w, rateRef.current)) {
+          if (runRef.current !== myRun) return
+          setSeg(item)
+          const finished = await playSeq([item], rateRef.current)
+          if (!finished) return // 被暂停、跳词或新循环取代
+        }
+        if (runRef.current !== myRun) return
+        // 整组播完回到组首,无限循环
+        idxRef.current = (idxRef.current + 1) % words.length
+        setI(idxRef.current)
+      }
+    }
+    run()
+
+    return () => {
+      runRef.current++ // 让旧循环在下一次检查点退出
+      stopAudio()
+    }
+  }, [playing, words, nonce])
+
+  const restart = (nextIdx) => {
+    if (nextIdx !== undefined) {
+      idxRef.current = (nextIdx + words.length) % words.length
+      setI(idxRef.current)
+    }
+    setPlaying(true)
+    setNonce((n) => n + 1)
+  }
+
+  const jump = (delta) => restart(idxRef.current + delta)
+
+  const goTo = (n) => {
+    const clamped = Math.max(1, Math.min(words.length, n))
+    restart(clamped - 1)
+  }
+
+  const w = words[i]
+
+  return (
+    <div>
+      <div className="timer">
+        <span>循环播放</span>
+        <b>
+          {i + 1}/{words.length}
+        </b>
+        <span>· 语速 {rate}×</span>
+        <span style={{ marginLeft: 'auto' }}>{seg ? segLabel(seg.key) : '准备中…'}</span>
+      </div>
+
+      <div className="card pad" style={{ textAlign: 'center', background: '#fbfdff' }}>
+        <div style={{ fontSize: 26, fontWeight: 500, color: 'var(--primary-d)' }}>{w?.word}</div>
+        <div style={{ fontSize: 13, color: 'var(--muted)', marginTop: 2 }}>
+          {w?.pos} {w?.meaning}
+        </div>
+        <div style={{ fontSize: 12.5, color: '#33465a', marginTop: 10, minHeight: 20 }}>
+          {seg?.text}
+        </div>
+      </div>
+
+      <div
+        style={{ display: 'flex', gap: 8, marginTop: 14, flexWrap: 'wrap', alignItems: 'center' }}
+      >
+        <button className="btn ghost sm" onClick={() => jump(-1)}>
+          ⏮ 上词
+        </button>
+        <button className="btn" onClick={() => setPlaying((v) => !v)}>
+          {playing ? '⏸ 暂停' : '▶ 播放'}
+        </button>
+        <button className="btn ghost sm" onClick={() => jump(1)}>
+          ⏭ 下词
+        </button>
+        <button className="btn ghost sm" onClick={() => restart()} title="重听当前词">
+          🔁 重听
+        </button>
+
+        <div className="fld" style={{ marginLeft: 'auto' }}>
+          语速
+          {RATES.map((r) => (
+            <button
+              key={r}
+              className={`btn sm ${rate === r ? '' : 'ghost'}`}
+              onClick={() => setRate(r)}
+            >
+              {r}×
+            </button>
+          ))}
+        </div>
+      </div>
+
+      <div style={{ display: 'flex', gap: 8, marginTop: 12, alignItems: 'center' }}>
+        <div className="fld">
+          从第
+          <input
+            type="number"
+            min="1"
+            max={words.length}
+            value={startAt}
+            onChange={(e) => setStartAt(Number(e.target.value))}
+            onBlur={() => goTo(startAt)}
+            onKeyDown={(e) => e.key === 'Enter' && goTo(startAt)}
+            style={{
+              width: 62, padding: '5px 8px', border: '1px solid var(--line)',
+              borderRadius: 8, fontFamily: 'inherit', fontSize: 12.5,
+            }}
+          />
+          词开始
+        </div>
+        <button className="btn ghost sm" onClick={onExit} style={{ marginLeft: 'auto' }}>
+          退出循环播放
+        </button>
+      </div>
+    </div>
+  )
+}
index 63244fc051f49aa27133aedeecb39f7d0139e29b..fb1649523a34b08032a82645e85df3d6cb2c7af3 100644 (file)
@@ -3,6 +3,13 @@ import { api } from '../api'
 import { useLesson } from '../ctx'
 
 const clamp = (n) => Math.max(0, Math.min(5, n))
+const DAY_LABELS = ['一', '二', '三', '四', '五', '六', '日'] // 索引 0 = 周一
+const INTERVALS = [
+  { v: 1, label: '每天' },
+  { v: 2, label: '隔天' },
+  { v: 3, label: '每 3 天' },
+  { v: 7, label: '每周' },
+]
 
 export default function Plan() {
   const [items, setItems] = useState(null)
@@ -13,7 +20,15 @@ export default function Plan() {
   useEffect(() => {
     api
       .plan()
-      .then((d) => setItems(d.items || []))
+      .then((d) =>
+        setItems(
+          (d.items || []).map((i) => ({
+            ...i,
+            weekdays: (i.weekdays || '1,2,3,4,5,6,7').split(',').map(Number),
+            interval_days: i.interval_days || 1,
+          })),
+        ),
+      )
       .catch(() => setItems([]))
   }, [refreshKey])
 
@@ -21,18 +36,31 @@ export default function Plan() {
 
   if (!items) return <div className="loading">加载中…</div>
 
-  const step = (courseId, key, delta) => {
+  const patch = (courseId, fn) => {
     setHint(false)
-    setItems((prev) =>
-      prev.map((i) =>
-        i.course_id === courseId ? { ...i, [key]: clamp(i[key] + delta) } : i,
-      ),
-    )
+    setItems((prev) => prev.map((i) => (i.course_id === courseId ? fn(i) : i)))
   }
 
+  const step = (courseId, key, delta) =>
+    patch(courseId, (i) => ({ ...i, [key]: clamp(i[key] + delta) }))
+
+  const toggleDay = (courseId, day) =>
+    patch(courseId, (i) => {
+      const on = i.weekdays.includes(day)
+      const days = on ? i.weekdays.filter((d) => d !== day) : [...i.weekdays, day].sort()
+      return { ...i, weekdays: days.length ? days : [day] } // 至少保留一天,否则永不生成任务
+    })
+
   const save = async () => {
     await Promise.all(
-      items.map((i) => api.updatePlanItem(i.course_id, i.daily_new, i.daily_review)),
+      items.map((i) =>
+        api.updatePlanItem(i.course_id, {
+          dailyNew: i.daily_new,
+          dailyReview: i.daily_review,
+          weekdays: i.weekdays,
+          intervalDays: i.interval_days,
+        }),
+      ),
     )
     setHint(true)
     clearTimeout(timer.current)
@@ -48,37 +76,86 @@ export default function Plan() {
       <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' }}>
-                  课程已关闭
+          <div key={i.course_id}>
+            <div className="prow" style={{ borderBottom: 'none', paddingBottom: 6 }}>
+              <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">
+                节奏
+                <select
+                  value={i.interval_days}
+                  onChange={(e) =>
+                    patch(i.course_id, (x) => ({ ...x, interval_days: Number(e.target.value) }))
+                  }
+                >
+                  {INTERVALS.map((o) => (
+                    <option key={o.v} value={o.v}>
+                      {o.label}
+                    </option>
+                  ))}
+                </select>
+                学一组
+              </div>
+              <div className="fld">
+                生效日
+                <span className="days">
+                  {DAY_LABELS.map((label, idx) => {
+                    const day = idx + 1
+                    return (
+                      <button
+                        key={day}
+                        className={i.weekdays.includes(day) ? 'on' : ''}
+                        onClick={() => toggleDay(i.course_id, day)}
+                        title={`周${label}`}
+                      >
+                        {label}
+                      </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
+              className="prow"
+              style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 0 }}
+            >
+              <div style={{ fontSize: 12, color: 'var(--muted-l)' }}>
+                {i.interval_days === 1 ? '每天' : `每 ${i.interval_days} 天`}学{' '}
+                {i.daily_new} 组新内容,
+                {i.weekdays.length === 7
+                  ? '全周无休'
+                  : `仅周${i.weekdays.map((d) => DAY_LABELS[d - 1]).join('')}`}
+                ;复习到期就练,不受节奏限制
+              </div>
             </div>
           </div>
         ))}
@@ -93,8 +170,9 @@ export default function Plan() {
 
       <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 />· 新学:从课程进度指针往后取 N 组,顺序学习不可跳组
+        <br />· 节奏:每 N 天学一组,按「距计划开始日的天数 ÷ N」取模判断当天是否出新学
+        <br />· 复习:从复习队列中取到期最早的 M 组,按 1 / 3 / 7 / 14 / 30 天间隔升级
         <br />· 漏天:新学不补、顺延;复习积压按每天上限截断后顺延
       </div>
     </>
index cd84a47a565d0423d684f41af39447dc7042db6f..b61f213a9ff8a29dfb124038339d1477ecdd1254 100644 (file)
@@ -21,7 +21,13 @@ export default function Today() {
   const allDone = stats.total > 0 && stats.done === stats.total
 
   const tick = async (task) => {
-    if (task.status === 'done' || busy) return
+    if (busy) return
+    // auto/duration 类课时必须走训练达标,不能一键打卡跳过;已完成的点开可重做
+    if (task.completion_type && task.completion_type !== 'manual') {
+      openLesson({ lesson_id: task.lesson_id, task_id: task.id, task_type: task.task_type })
+      return
+    }
+    if (task.status === 'done') return
     setBusy(task.id)
     try {
       await api.completeLesson(task.lesson_id, {
diff --git a/frontend/src/renderers/VocabDrill.jsx b/frontend/src/renderers/VocabDrill.jsx
new file mode 100644 (file)
index 0000000..73ee170
--- /dev/null
@@ -0,0 +1,506 @@
+import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import { EXAMPLE_RATE, playSeq } from '../audio'
+import LoopPlayer from '../components/LoopPlayer'
+
+/**
+ * vocab_drill_group:一组词的五阶段训练。
+ *
+ * ① learn 学词(顺序,自动播 单词→例句→搭配)
+ * ② spell 拼写(乱序,听音写词)
+ * ③ choice 选择(乱序,四选一中文释义)
+ * ④ dictation 听写(仅 is_key 的 10 词,只放音不显示词)
+ * ⑤ error_redo 错题重做(循环直到池空)
+ *
+ * 复习模式(taskType='review')走缩水版:只做 ③ + ④ + ⑤。
+ * 完成判定:错题池清空后 onReadyChange(true, detail),由浮层按钮提交。
+ */
+
+const STAGE_LABELS = ['① 学词', '② 拼写', '③ 选择', '④ 听写', '⑤ 错题重做']
+const FULL_STAGES = ['learn', 'spell', 'choice', 'dictation', 'error_redo']
+const REVIEW_STAGES = ['choice', 'dictation', 'error_redo']
+
+function shuffle(arr, seed = 1) {
+  // 固定种子的洗牌,保证同一课时每次乱序结果一致(重进页面不跳题)
+  const a = [...arr]
+  let s = seed
+  for (let i = a.length - 1; i > 0; i--) {
+    s = (s * 9301 + 49297) % 233280
+    const j = Math.floor((s / 233280) * (i + 1))
+    ;[a[i], a[j]] = [a[j], a[i]]
+  }
+  return a
+}
+
+/** 学词阶段的播放序列:单词 ×2 → 例句(慢一档)→ 各搭配(逐一读出)。 */
+function buildLearnSeq(w, rate = 1) {
+  const seq = [
+    { text: w.word, lang: 'en' },
+    { text: w.word, lang: 'en' },
+  ]
+  if (w.example) {
+    seq.push({ text: w.example, lang: 'en', rate: rate * EXAMPLE_RATE })
+  }
+  ;(w.collocations || []).forEach((c) => {
+    if (c.en) seq.push({ text: c.en, lang: 'en' })
+  })
+  return seq
+}
+
+/** 逐字母比对:对的正常显示,错的标红。 */
+function SpellDiff({ input, answer }) {
+  return (
+    <div style={{ fontSize: 18, letterSpacing: 1, marginTop: 8 }}>
+      {answer.split('').map((ch, i) => {
+        const typed = (input[i] || '').toLowerCase()
+        return (
+          <span
+            key={i}
+            style={{
+              color: typed === ch.toLowerCase() ? 'var(--primary-d)' : '#d9534f',
+              textDecoration: typed === ch.toLowerCase() ? 'none' : 'underline',
+            }}
+          >
+            {ch}
+          </span>
+        )
+      })}
+    </div>
+  )
+}
+
+export default function VocabDrill({ payload, taskType = 'new', onReadyChange }) {
+  const words = useMemo(() => payload.words || [], [payload])
+  const cfg = payload.drill_config || {}
+  const keyWords = useMemo(() => words.filter((w) => w.is_key), [words])
+
+  const stages = taskType === 'review' ? REVIEW_STAGES : FULL_STAGES
+  const [stage, setStage] = useState(stages[0])
+  const [idx, setIdx] = useState(0)
+  const [pool, setPool] = useState([]) // 错题池 [{wid, kind}]
+  const [redoRound, setRedoRound] = useState(0)
+  const [showPhonetic, setShowPhonetic] = useState(!!cfg.phonetic_visible)
+  const [feedback, setFeedback] = useState(null) // {ok, text}
+  const [input, setInput] = useState('')
+  const [options, setOptions] = useState([])
+  const [revealed, setRevealed] = useState(false) // learn 阶段是否已看释义
+  const [looping, setLooping] = useState(false) // 循环播放(听学模式)
+  const startedAt = useRef(Date.now())
+  const inputRef = useRef(null)
+
+  // 乱序题单(固定种子,重进页面顺序不变)
+  const spellOrder = useMemo(() => shuffle(words, 7), [words])
+  const choiceOrder = useMemo(() => shuffle(words, 13), [words])
+  const dictationOrder = useMemo(() => shuffle(keyWords.length ? keyWords : words, 29).slice(0, 10), [keyWords, words])
+
+  const current = useMemo(() => {
+    if (stage === 'learn') return words[idx]
+    if (stage === 'spell') return spellOrder[idx]
+    if (stage === 'choice') return choiceOrder[idx]
+    if (stage === 'dictation') return dictationOrder[idx]
+    return null
+  }, [stage, idx, words, spellOrder, choiceOrder, dictationOrder])
+
+  const total = stage === 'learn' ? words.length
+    : stage === 'spell' ? spellOrder.length
+    : stage === 'choice' ? choiceOrder.length
+    : stage === 'dictation' ? dictationOrder.length
+    : pool.length
+
+  // 进入每题时重置输入态
+  useEffect(() => {
+    setInput('')
+    setFeedback(null)
+    setRevealed(false)
+    if (stage === 'choice' && choiceOrder[idx]) {
+      const target = choiceOrder[idx]
+      const distractors = shuffle(
+        words.filter((w) => w.wid !== target.wid).map((w) => w.meaning),
+        idx + 3,
+      ).slice(0, 3)
+      setOptions(shuffle([target.meaning, ...distractors], idx + 5))
+    }
+  }, [stage, idx, choiceOrder, words])
+
+  // learn 阶段进入新词自动播放序列
+  useEffect(() => {
+    if (stage === 'learn' && words[idx]) {
+      playSeq(buildLearnSeq(words[idx]))
+    }
+  }, [stage, idx, words])
+
+  // dictation 进入即放音(不显示词)
+  useEffect(() => {
+    if (stage === 'dictation' && dictationOrder[idx]) {
+      playSeq([{ text: dictationOrder[idx].word, lang: 'en' }])
+    }
+  }, [stage, idx, dictationOrder])
+
+  // spell 进入即放单词音
+  useEffect(() => {
+    if (stage === 'spell' && spellOrder[idx]) {
+      playSeq([{ text: spellOrder[idx].word, lang: 'en' }])
+    }
+  }, [stage, idx, spellOrder])
+
+  // choice 进入即放单词音
+  useEffect(() => {
+    if (stage === 'choice' && choiceOrder[idx]) {
+      playSeq([{ text: choiceOrder[idx].word, lang: 'en' }])
+    }
+  }, [stage, idx, choiceOrder])
+
+  const addError = useCallback((wid, kind) => {
+    setPool((prev) =>
+      prev.some((e) => e.wid === wid && e.kind === kind) ? prev : [...prev, { wid, kind }],
+    )
+  }, [])
+
+  const nextStage = useCallback(
+    (name) => {
+      const i = stages.indexOf(name)
+      setStage(stages[i + 1] || 'error_redo')
+      setIdx(0)
+    },
+    [stages],
+  )
+
+  const advance = useCallback(() => {
+    if (idx + 1 < total) {
+      setIdx(idx + 1)
+    } else if (stage === 'error_redo') {
+      // 一轮重做完,仍有错题就再来一轮
+      setRedoRound((r) => r + 1)
+      setIdx(0)
+    } else {
+      nextStage(stage)
+    }
+  }, [idx, total, stage, nextStage])
+
+  const finish = useCallback(() => {
+    const detail = {
+      size: words.length,
+      stages_run: stages,
+      redo_rounds: redoRound,
+      error_by_stage: pool.reduce((acc, e) => ({ ...acc, [e.kind]: (acc[e.kind] || 0) + 1 }), {}),
+      seconds: Math.round((Date.now() - startedAt.current) / 1000),
+    }
+    onReadyChange?.(true, detail)
+  }, [pool, redoRound, stages, words.length, onReadyChange])
+
+  // 错题池空 → 判定通过(仅 error_redo 阶段且已跑过至少一轮)
+  useEffect(() => {
+    if (stage === 'error_redo' && pool.length === 0) finish()
+  }, [stage, pool.length, finish])
+
+  const submitSpell = (kind) => {
+    const w = current
+    if (!w) return
+    const ok = input.trim().toLowerCase() === w.word.toLowerCase()
+    if (!ok) addError(w.wid, kind)
+    setFeedback({ ok, text: ok ? '正确' : `正确拼写:${w.word}` })
+  }
+
+  const submitChoice = (picked) => {
+    const w = current
+    if (!w) return
+    const ok = picked === w.meaning
+    if (!ok) addError(w.wid, 'choice')
+    setFeedback({ ok, text: ok ? '正确' : `答案:${w.meaning}` })
+  }
+
+  const submitRedo = () => {
+    const item = pool[idx]
+    if (!item) return
+    const w = words.find((x) => x.wid === item.wid)
+    let ok = false
+    if (item.kind === 'spell' || item.kind === 'dictation') {
+      ok = input.trim().toLowerCase() === w.word.toLowerCase()
+    } else if (item.kind === 'choice') {
+      ok = input === w.meaning
+    }
+    if (ok) {
+      setPool((prev) => prev.filter((e) => !(e.wid === item.wid && e.kind === item.kind)))
+      setFeedback({ ok: true, text: '正确,已移出错题池' })
+    } else {
+      setFeedback({ ok: false, text: `仍不正确,正确内容:${w.word} ${w.meaning}` })
+    }
+  }
+
+  const replay = () => {
+    if (!current) return
+    playSeq(stage === 'learn' ? buildLearnSeq(current) : [{ text: current.word, lang: 'en' }])
+  }
+
+  if (!words.length) return <div className="empty">本课没有词条。</div>
+
+  // ---------- 各阶段渲染 ----------
+
+  const renderStageNav = () => (
+    <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 14 }}>
+      {stages.map((s) => (
+        <span
+          key={s}
+          className={`tag ${s === stage ? '' : ''}`}
+          style={
+            s === stage
+              ? { background: 'var(--primary)', color: '#fff' }
+              : { background: '#eef2f6', color: '#9fb0bd' }
+          }
+        >
+          {STAGE_LABELS[FULL_STAGES.indexOf(s)]}
+        </span>
+      ))}
+      <span style={{ marginLeft: 'auto', fontSize: 12, color: 'var(--muted)' }}>
+        错题池 {pool.length}
+      </span>
+      <button
+        className="btn ghost sm"
+        onClick={() => setShowPhonetic((v) => !v)}
+        title="数据源音标为 OCR 转写,非标准 IPA"
+      >
+        {showPhonetic ? '隐藏音标' : '显示音标'}
+      </button>
+      <button className="btn ghost sm" onClick={() => setLooping((v) => !v)}>
+        {looping ? '返回训练' : '🎧 循环播放'}
+      </button>
+    </div>
+  )
+
+  const renderLearn = () => {
+    const w = current
+    return (
+      <div>
+        <div style={{ fontSize: 12.5, color: 'var(--muted)', marginBottom: 10 }}>
+          第 {idx + 1} / {words.length} 词 · 自动播放 单词 → 例句 → 搭配
+        </div>
+        <div className="card pad" style={{ textAlign: 'center', background: '#fbfdff' }}>
+          <div style={{ fontSize: 28, fontWeight: 500, color: 'var(--primary-d)' }}>{w.word}</div>
+          {showPhonetic && w.phonetic && (
+            <div style={{ fontSize: 13, color: 'var(--muted)' }}>{w.phonetic}</div>
+          )}
+          <div style={{ fontSize: 13, color: 'var(--muted)', marginTop: 2 }}>
+            {w.pos} {w.meaning}
+          </div>
+          {w.example && (
+            <div style={{ fontSize: 13, marginTop: 12, color: '#33465a', lineHeight: 1.8 }}>
+              {w.example}
+              <div style={{ fontSize: 12, color: 'var(--muted)' }}>{w.example_zh}</div>
+            </div>
+          )}
+          {w.collocations?.length > 0 && (
+            <div style={{ marginTop: 10, display: 'flex', gap: 6, flexWrap: 'wrap', justifyContent: 'center' }}>
+              {w.collocations.map((c, i) => (
+                <span className="tag" key={i}>
+                  {c.en} · {c.zh}
+                </span>
+              ))}
+            </div>
+          )}
+        </div>
+      </div>
+    )
+  }
+
+  const renderSpell = (isDictation) => {
+    const w = current
+    return (
+      <div>
+        <div style={{ fontSize: 12.5, color: 'var(--muted)', marginBottom: 10 }}>
+          第 {idx + 1} / {total} 词 · {isDictation ? '只听音,写下完整拼写' : '听音拼写'}
+        </div>
+        {!isDictation && (
+          <div style={{ fontSize: 13, color: 'var(--muted)', marginBottom: 8 }}>
+            {w.pos} {w.meaning}
+          </div>
+        )}
+        <input
+          ref={inputRef}
+          value={input}
+          onChange={(e) => setInput(e.target.value)}
+          onKeyDown={(e) => e.key === 'Enter' && submitSpell(isDictation ? 'dictation' : 'spell')}
+          placeholder="输入拼写后回车"
+          style={{
+            width: '100%', padding: '11px 14px', fontSize: 15,
+            border: '1px solid var(--line)', borderRadius: 10, fontFamily: 'inherit',
+          }}
+        />
+        {feedback && (
+          <div
+            style={{
+              marginTop: 10, padding: '10px 14px', borderRadius: 9, fontSize: 13,
+              background: feedback.ok ? 'var(--green-l)' : 'var(--amber-l)',
+              color: feedback.ok ? 'var(--green)' : 'var(--amber)',
+            }}
+          >
+            {feedback.text}
+            {!feedback.ok && <SpellDiff input={input} answer={w.word} />}
+          </div>
+        )}
+      </div>
+    )
+  }
+
+  const renderChoice = () => {
+    const w = current
+    return (
+      <div>
+        <div style={{ fontSize: 12.5, color: 'var(--muted)', marginBottom: 10 }}>
+          第 {idx + 1} / {choiceOrder.length} 词 · 选出正确中文释义
+        </div>
+        <div style={{ fontSize: 20, fontWeight: 500, color: 'var(--primary-d)', marginBottom: 12 }}>
+          {w.word}
+        </div>
+        <div className="opts">
+          {options.map((o, i) => (
+            <div
+              key={i}
+              className={`opt ${input === o ? 'sel' : ''}`}
+              onClick={() => {
+                setInput(o)
+                submitChoice(o)
+              }}
+            >
+              <span className="k">{String.fromCharCode(65 + i)}</span>
+              {o}
+            </div>
+          ))}
+        </div>
+        {feedback && (
+          <div
+            className="res"
+            style={
+              feedback.ok
+                ? {}
+                : { background: 'var(--amber-l)', color: 'var(--amber)' }
+            }
+          >
+            {feedback.text}
+          </div>
+        )}
+      </div>
+    )
+  }
+
+  const renderRedo = () => {
+    const item = pool[idx]
+    if (!item) {
+      return (
+        <div className="empty">
+          错题已清空,本课通过。
+        </div>
+      )
+    }
+    const w = words.find((x) => x.wid === item.wid)
+    const kindLabel = { spell: '拼写', choice: '选择', dictation: '听写' }[item.kind]
+    return (
+      <div>
+        <div style={{ fontSize: 12.5, color: 'var(--muted)', marginBottom: 10 }}>
+          第 {redoRound + 1} 轮 · 剩余 {pool.length} 题 · 当前:{kindLabel}
+        </div>
+        {item.kind === 'choice' ? (
+          <>
+            <div style={{ fontSize: 20, fontWeight: 500, color: 'var(--primary-d)', marginBottom: 12 }}>
+              {w.word}
+            </div>
+            <div className="opts">
+              {shuffle(
+                [w.meaning, ...shuffle(words.filter((x) => x.wid !== w.wid).map((x) => x.meaning), idx + 1).slice(0, 3)],
+                idx + 2,
+              ).map((o, i) => (
+                <div key={i} className={`opt ${input === o ? 'sel' : ''}`} onClick={() => { setInput(o); }}>
+                  <span className="k">{String.fromCharCode(65 + i)}</span>
+                  {o}
+                </div>
+              ))}
+            </div>
+          </>
+        ) : (
+          <>
+            {item.kind === 'spell' && (
+              <div style={{ fontSize: 13, color: 'var(--muted)', marginBottom: 8 }}>
+                {w.pos} {w.meaning}
+              </div>
+            )}
+            <input
+              value={input}
+              onChange={(e) => setInput(e.target.value)}
+              onKeyDown={(e) => e.key === 'Enter' && submitRedo()}
+              placeholder="输入拼写后回车"
+              style={{
+                width: '100%', padding: '11px 14px', fontSize: 15,
+                border: '1px solid var(--line)', borderRadius: 10, fontFamily: 'inherit',
+              }}
+            />
+          </>
+        )}
+        {feedback && (
+          <div
+            className="res"
+            style={feedback.ok ? {} : { background: 'var(--amber-l)', color: 'var(--amber)' }}
+          >
+            {feedback.text}
+          </div>
+        )}
+      </div>
+    )
+  }
+
+  // 循环播放时整体替换训练界面,训练状态原地保留
+  if (looping) {
+    return (
+      <div>
+        <LoopPlayer words={words} onExit={() => setLooping(false)} />
+      </div>
+    )
+  }
+
+  const body =
+    stage === 'learn' ? renderLearn()
+    : stage === 'spell' ? renderSpell(false)
+    : stage === 'choice' ? renderChoice()
+    : stage === 'dictation' ? renderSpell(true)
+    : renderRedo()
+
+  const isLast = idx + 1 >= total
+  const canSubmit = !feedback && stage !== 'learn' && stage !== 'error_redo'
+  
+  return (
+    <div>
+      {renderStageNav()}
+      {body}
+
+      <div style={{ display: 'flex', gap: 8, marginTop: 16, alignItems: 'center' }}>
+        {stage !== 'error_redo' && (
+          <button className="btn ghost sm" onClick={replay}>
+            ▶ 重听
+          </button>
+        )}
+        <div style={{ marginLeft: 'auto', display: 'flex', gap: 8 }}>
+          {stage === 'error_redo' && pool.length > 0 && (
+            <button className="btn" onClick={submitRedo} disabled={!input}>
+              提交
+            </button>
+          )}
+          {canSubmit && (
+            <button
+              className="btn"
+              onClick={() =>
+                stage === 'choice' ? submitChoice(input) : submitSpell(stage === 'dictation' ? 'dictation' : 'spell')
+              }
+              disabled={!input}
+            >
+              提交
+            </button>
+          )}
+          <button
+            className="btn"
+            onClick={advance}
+            disabled={stage !== 'learn' && !feedback && stage !== 'error_redo'}
+          >
+            {isLast && stage !== 'error_redo' ? '下一阶段' : '下一个'}
+          </button>
+        </div>
+      </div>
+    </div>
+  )
+}
index 06f904fef446656ab7d18aa16accd4f6ea802604..c2473c1d84462e264dea8d5b5add0c937591b3bc 100644 (file)
@@ -2,6 +2,7 @@ import React from 'react'
 import VocabTable from './VocabTable'
 import ReadingArticle from './ReadingArticle'
 import LessonBrief from './LessonBrief'
+import VocabDrill from './VocabDrill'
 
 /**
  * 内容类型注册表(设计文档 02-内容本体规范 §1)。
@@ -9,6 +10,11 @@ import LessonBrief from './LessonBrief'
  * hint 可为字符串或 (payload) => string。
  */
 export const REGISTRY = {
+  vocab_drill_group: {
+    label: '词汇训练',
+    Component: VocabDrill,
+    hint: '走完五阶段训练且错题池清空后即可完成本课',
+  },
   vocab_table: {
     label: '单词表',
     Component: VocabTable,
index ff201d20f02e5c3af987d5002bbc38d1a65f16ce..98b3ac0b95656344ccd14712b46e264b640df60b 100644 (file)
@@ -101,6 +101,19 @@ h2{font-size:15px;font-weight:500;margin-bottom:4px}
 .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)}
+/* 生效日(周几)多选 */
+.days{display:flex;gap:4px}
+.days button{
+  width:26px;height:26px;border-radius:50%;border:1px solid var(--line);background:#fff;
+  cursor:pointer;font-size:12px;color:var(--muted);font-family:inherit;transition:.15s;
+}
+.days button:hover{border-color:var(--primary);color:var(--primary-d)}
+.days button.on{background:var(--primary);border-color:var(--primary);color:#fff}
+select{
+  border:1px solid var(--line);border-radius:8px;padding:5px 9px;font-family:inherit;
+  font-size:12.5px;color:var(--text);background:#fff;cursor:pointer;
+}
+select:focus{outline:none;border-color:var(--primary)}
 .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}