# Database & Logs
*.db
backend/db/*.db
+
+# TTS 音频缓存(按需生成,不入库)
+backend/db/tts_cache/
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)
);
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()
--- /dev/null
+"""雅思词汇真经 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()
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")
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):
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
"task_type": r["task_type"],
"status": r["status"],
"content_type": r["content_type"],
+ "completion_type": r["completion_type"],
"duration_min": r["duration_min"],
}
)
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
).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}
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")
fastapi
uvicorn
pydantic
+edge-tts
+openpyxl
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:
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
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
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
--- /dev/null
+"""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
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')),
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) =>
--- /dev/null
+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
+}
-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'
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))
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()
</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>
--- /dev/null
+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>
+ )
+}
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)
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])
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)
<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>
))}
<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>
</>
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, {
--- /dev/null
+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>
+ )
+}
import VocabTable from './VocabTable'
import ReadingArticle from './ReadingArticle'
import LessonBrief from './LessonBrief'
+import VocabDrill from './VocabDrill'
/**
* 内容类型注册表(设计文档 02-内容本体规范 §1)。
* hint 可为字符串或 (payload) => string。
*/
export const REGISTRY = {
+ vocab_drill_group: {
+ label: '词汇训练',
+ Component: VocabDrill,
+ hint: '走完五阶段训练且错题池清空后即可完成本课',
+ },
vocab_table: {
label: '单词表',
Component: VocabTable,
.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}