]> acesimba.cloud Git - words.git/commitdiff
V2: Daily Batch 学习模型完整实现
authorCodeBuddy <codebuddy@local>
Thu, 20 Aug 2026 05:07:46 +0000 (13:07 +0800)
committerCodeBuddy <codebuddy@local>
Thu, 20 Aug 2026 05:07:46 +0000 (13:07 +0800)
- 数据库: init_v2_db.py 导入词库(1852词, spell 560/recognize 1292) + 新列 + 5 张 V2 表(word_progress/daily_plans/daily_plan_items/learning_events/study_sessions)
- 后端: 动态 Daily Batch 调度器(预算/成本权重/15分钟上限) + UUID 事件幂等 + 主动时长会话 + 5 类综合题库(best-effort, 标记待校对) + V2 日历聚合(legacy 历史合并)
- 前端: Home 改动态仪表盘; 新增 DailyBatch 会话(spell/recognize 新学+复习 + 5 综合题 + 错词队尾重测 + Active Time); V2 日历(时长/新/复/题 + 日详情); GroupView 只读带 memory_type
- 同步: UUID 事件/会话/计划 离线优先, 取代 (group,op) 去重
- 隔离: 独立 DB(bcd_v2.db)/端口(1061)/路径(/v2)/IndexedDB(words-pwa-v2)/SW cache(words-pwa-v2)/token
- V1 不受影响(独立分支/服务/库)

19 files changed:
backend/init_v2_db.py [new file with mode: 0644]
backend/main.py
frontend/public/sw.js
frontend/src/App.js
frontend/src/activeTime.js [new file with mode: 0644]
frontend/src/api.js
frontend/src/components/DailyBatch.js [new file with mode: 0644]
frontend/src/components/GroupView.css
frontend/src/components/GroupView.js
frontend/src/components/Home.css
frontend/src/components/Home.js
frontend/src/components/StudyCalendar.css
frontend/src/components/StudyCalendar.js
frontend/src/components/StudyMode.css
frontend/src/components/StudyMode.js [deleted file]
frontend/src/components/SyncNotifications.js
frontend/src/components/TestMode.js [deleted file]
frontend/src/db.js
frontend/src/sync.js

diff --git a/backend/init_v2_db.py b/backend/init_v2_db.py
new file mode 100644 (file)
index 0000000..2610628
--- /dev/null
@@ -0,0 +1,256 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+V2 数据库初始化脚本
+-------------------
+从 V1 线上库 (bcd.db) 导入词库与图片作为 V2 知识底表,并搭建 V2 新表结构。
+本脚本只作用于 bcd_v2.db(V2 独立库),不触碰 V1 的 bcd.db。
+
+执行:
+    python3 init_v2_db.py
+幂等:已导入词库时跳过导入,但会补齐 schema。
+
+V2 模型要点(见改造方案):
+- words 增加 subgroupid / unit_order / memory_type / derived_from / pos_quiz_answer / exam_priority
+- word_progress / daily_plans / daily_plan_items / learning_events / study_sessions 为 V2 新表
+- study_logs 保留为 legacy(导入历史,仅作展示,不再被 V2 前端写入)
+"""
+import sqlite3
+import os
+import sys
+
+BASE_DIR = os.path.dirname(os.path.abspath(__file__))
+SOURCE_DB = "/home/words/backend/db/bcd.db"          # V1 线上库(只读)
+TARGET_DB = os.path.join(BASE_DIR, "db", "bcd_v2.db")  # V2 独立库
+
+OLD_WORD_COLS = ["ID", "picture_id", "word", "chinese_meaning",
+                 "part_of_speech", "word_root", "affix", "example_sentence", "groupid"]
+
+def log(msg):
+    print(msg, flush=True)
+
+def import_base_data(src, tgt):
+    """把 words / pictures / schema_comments / study_logs 从 V1 导入 V2(legacy)。"""
+    tgt_wc = tgt.execute("SELECT COUNT(*) FROM words").fetchone()[0]
+    if tgt_wc > 0:
+        log(f"[import] words 已有 {tgt_wc} 行,跳过导入。")
+        return
+
+    # pictures
+    rows = src.execute("SELECT ID, pictures, enabled FROM pictures").fetchall()
+    tgt.executemany("INSERT INTO pictures (ID, pictures, enabled) VALUES (?,?,?)", rows)
+    log(f"[import] pictures: {len(rows)} 行")
+
+    # schema_comments
+    rows = src.execute(
+        "SELECT object_type, table_name, column_name, description, updated_at FROM schema_comments"
+    ).fetchall()
+    tgt.executemany(
+        "INSERT INTO schema_comments (object_type, table_name, column_name, description, updated_at) VALUES (?,?,?,?,?)",
+        rows)
+    log(f"[import] schema_comments: {len(rows)} 行")
+
+    # study_logs(legacy 历史,仅供展示)
+    rows = src.execute(
+        "SELECT id, group_id, op_type, status, created_at, log_date, duration_sec FROM study_logs"
+    ).fetchall()
+    tgt.executemany(
+        "INSERT INTO study_logs (id, group_id, op_type, status, created_at, log_date, duration_sec) VALUES (?,?,?,?,?,?,?)",
+        rows)
+    log(f"[import] study_logs (legacy): {len(rows)} 行")
+
+    # words(仅旧列,新列使用默认值/后续填充)
+    placeholders = ",".join(["?"] * len(OLD_WORD_COLS))
+    cols = ",".join(OLD_WORD_COLS)
+    rows = src.execute(f"SELECT {cols} FROM words ORDER BY ID").fetchall()
+    tgt.executemany(f"INSERT INTO words ({cols}) VALUES ({placeholders})", rows)
+    log(f"[import] words: {len(rows)} 行")
+
+
+def alter_words(tgt):
+    """为 words 增加 V2 新列(幂等)。"""
+    existing = {r[1] for r in tgt.execute("PRAGMA table_info(words)").fetchall()}
+    alters = [
+        "ALTER TABLE words ADD COLUMN subgroupid TEXT",
+        "ALTER TABLE words ADD COLUMN unit_order INTEGER",
+        "ALTER TABLE words ADD COLUMN memory_type TEXT DEFAULT 'spell'",
+        "ALTER TABLE words ADD COLUMN derived_from TEXT",
+        "ALTER TABLE words ADD COLUMN pos_quiz_answer TEXT",
+        "ALTER TABLE words ADD COLUMN exam_priority TEXT",
+    ]
+    for sql in alters:
+        col = sql.split("ADD COLUMN ")[1].split(" ")[0]
+        if col not in existing:
+            tgt.execute(sql)
+            log(f"[alter] 新增列 {col}")
+        else:
+            log(f"[alter] 列 {col} 已存在,跳过")
+
+
+def fill_unit_order(tgt):
+    """单元稳定顺序:按 (groupid, ID) 排。"""
+    rows = tgt.execute(
+        "SELECT ID FROM words ORDER BY CAST(groupid AS INTEGER) ASC, ID ASC"
+    ).fetchall()
+    tgt.executemany("UPDATE words SET unit_order=? WHERE ID=?",
+                    [(i + 1, r[0]) for i, r in enumerate(rows)])
+    log(f"[fill] unit_order 已设置 {len(rows)} 行")
+
+
+def classify_memory_type(tgt):
+    """
+    按词根家族划分 spell / recognize:
+    - 同一 (groupid, word_root) 家族中,基词(n./v. 优先,否则最短词)为 spell;
+    - 其余派生词为 recognize,derived_from 指向基词 ID。
+    - 无词根词默认 spell。
+    - recognize 且词性唯一(不含 '/')时,pos_quiz_answer = 该词性。
+    """
+    words = tgt.execute(
+        "SELECT ID, word, part_of_speech, word_root, groupid FROM words"
+    ).fetchall()
+
+    families = {}
+    for w in words:
+        wid, word, pos, root, gid = w
+        if root and root.strip():
+            families.setdefault((gid, root.strip()), []).append(w)
+
+    updates = []  # (memory_type, derived_from, pos_quiz_answer, wid)
+    for w in words:
+        wid, word, pos, root, gid = w
+        if not (root and root.strip()):
+            updates.append(("spell", None, None, wid))
+            continue
+        fam = families[(gid, root.strip())]
+        if len(fam) == 1:
+            updates.append(("spell", None, None, wid))
+            continue
+        # 选基词:优先 n./v.
+        base = None
+        for m in fam:
+            if m[2] and (m[2].strip().startswith("n.") or m[2].strip().startswith("v.")):
+                base = m
+                break
+        if base is None:
+            base = min(fam, key=lambda x: len(x[1]))
+        if wid == base[0]:
+            updates.append(("spell", None, None, wid))
+        else:
+            pos_ans = pos.strip() if (pos and "/" not in pos) else None
+            updates.append(("recognize", base[0], pos_ans, wid))
+
+    tgt.executemany(
+        "UPDATE words SET memory_type=?, derived_from=?, pos_quiz_answer=? WHERE ID=?",
+        updates)
+    spell = sum(1 for u in updates if u[0] == "spell")
+    recog = sum(1 for u in updates if u[0] == "recognize")
+    log(f"[classify] memory_type: spell={spell}, recognize={recog}")
+
+
+def create_v2_tables(tgt):
+    """创建 V2 新表(幂等)。"""
+    tgt.executescript("""
+    CREATE TABLE IF NOT EXISTS word_progress (
+        word_id INTEGER PRIMARY KEY,
+        first_learned_at DATETIME,
+        last_seen_at DATETIME,
+        next_review_at DATETIME,
+        memory_type TEXT,
+        mastery_level INTEGER DEFAULT 0,
+        spell_correct INTEGER DEFAULT 0,
+        spell_wrong INTEGER DEFAULT 0,
+        recognize_correct INTEGER DEFAULT 0,
+        recognize_wrong INTEGER DEFAULT 0,
+        quiz_correct INTEGER DEFAULT 0,
+        quiz_wrong INTEGER DEFAULT 0,
+        updated_at DATETIME
+    );
+
+    CREATE TABLE IF NOT EXISTS daily_plans (
+        plan_id TEXT PRIMARY KEY,
+        plan_date DATE UNIQUE NOT NULL,
+        planned_new INTEGER DEFAULT 0,
+        planned_spell INTEGER DEFAULT 0,
+        planned_recognize INTEGER DEFAULT 0,
+        planned_review INTEGER DEFAULT 0,
+        planned_quiz INTEGER DEFAULT 5,
+        estimated_sec INTEGER DEFAULT 0,
+        status TEXT DEFAULT 'planned',
+        created_at DATETIME
+    );
+
+    CREATE TABLE IF NOT EXISTS daily_plan_items (
+        plan_id TEXT NOT NULL,
+        item_type TEXT NOT NULL,
+        word_id INTEGER,
+        question_id TEXT,
+        order_no INTEGER,
+        payload TEXT,
+        PRIMARY KEY (plan_id, order_no)
+    );
+
+    CREATE TABLE IF NOT EXISTS learning_events (
+        event_id TEXT PRIMARY KEY,
+        session_id TEXT NOT NULL,
+        plan_id TEXT,
+        word_id INTEGER,
+        group_id INTEGER,
+        stage TEXT NOT NULL,
+        question_type TEXT NOT NULL,
+        result TEXT NOT NULL,
+        attempt_no INTEGER DEFAULT 1,
+        duration_ms INTEGER DEFAULT 0,
+        created_at DATETIME,
+        log_date DATE
+    );
+
+    CREATE TABLE IF NOT EXISTS study_sessions (
+        session_id TEXT PRIMARY KEY,
+        plan_id TEXT,
+        log_date DATE NOT NULL,
+        started_at DATETIME,
+        ended_at DATETIME,
+        active_sec INTEGER DEFAULT 0,
+        new_sec INTEGER DEFAULT 0,
+        review_sec INTEGER DEFAULT 0,
+        quiz_sec INTEGER DEFAULT 0,
+        completed INTEGER DEFAULT 0,
+        updated_at DATETIME
+    );
+    """)
+    log("[tables] V2 新表已就绪 (word_progress/daily_plans/daily_plan_items/learning_events/study_sessions)")
+
+
+def mark_legacy(tgt):
+    """在 schema_comments 中标注 study_logs 为 legacy。"""
+    tgt.execute(
+        "INSERT OR REPLACE INTO schema_comments (object_type, table_name, column_name, description, updated_at) "
+        "VALUES ('table','study_logs',NULL,'V2 legacy:V1 历史进度,仅展示,不再由 V2 前端写入',CURRENT_TIMESTAMP)"
+    )
+    log("[legacy] study_logs 标记为 legacy")
+
+
+def main():
+    if not os.path.exists(SOURCE_DB):
+        log(f"[error] 源库不存在: {SOURCE_DB}")
+        sys.exit(1)
+    src = sqlite3.connect(SOURCE_DB)
+    src.row_factory = sqlite3.Row
+    tgt = sqlite3.connect(TARGET_DB)
+    tgt.row_factory = sqlite3.Row
+    try:
+        import_base_data(src, tgt)
+        alter_words(tgt)
+        fill_unit_order(tgt)
+        classify_memory_type(tgt)
+        create_v2_tables(tgt)
+        mark_legacy(tgt)
+        tgt.commit()
+        log("[done] V2 数据库初始化完成。")
+    finally:
+        src.close()
+        tgt.close()
+
+
+if __name__ == "__main__":
+    main()
index 346102ecd63e37d5d945a9194fd44926d4a2998e..c63efe1ac1a212faa3d6f4ff2475de31bd45354f 100644 (file)
@@ -416,7 +416,7 @@ async def update_progress(data: ProgressUpdate):
     except Exception as e:
         raise HTTPException(status_code=500, detail=str(e))
 
-@app.get("/api/calendar")
+@app.get("/api/calendar/legacy")
 async def get_calendar():
     # 按日期聚合 study_logs,供学习日历使用;同时计算每日"实际学习时长"(分钟)
     # 实际学习时长 = 当天各 (group,op) 完成时长(duration_sec)之和, 不含间隔, 即把各次学习时段相加
@@ -518,10 +518,773 @@ async def auth_login(data: LoginRequest):
     raise HTTPException(status_code=401, detail="用户名或密码错误")
 
 
+# =====================================================================
+#  V2 后端:动态 Daily Batch + word_progress + 事件日志 + 综合题库
+#  仅作用于 bcd_v2.db(独立库),不与 V1 端点冲突。
+# =====================================================================
+import uuid
+import json
+import random
+import re
+from datetime import timedelta
+
+V2_NOTE = "AI生成/待校对"
+QUIZ_PER_DAY = 5
+MAX_NEW_PER_DAY = 12
+MAX_SPELL_PER_DAY = 6
+BUDGET_SEC = 15 * 60
+QUIZ_FIXED_SEC = 5 * 40
+# 粗略 cost(秒):用于预算控制,不展示给学生
+COST = {
+    "spell_new": 60, "recognize_new": 21,
+    "spell_review": 39, "recognize_review": 12,
+}
+
+# ---------- V2 时间/连接辅助 ----------
+def _china_dt():
+    # 用 UTC+8 计算"今天",与前端 localTimestamp() 一致,规避服务器时区差异
+    return datetime.datetime.utcnow() + timedelta(hours=8)
+
+def china_date():
+    return _china_dt().strftime("%Y-%m-%d")
+
+def china_now_str():
+    return _china_dt().strftime("%Y-%m-%d %H:%M:%S")
+
+def _add_days(date_str, n):
+    d = datetime.datetime.strptime(date_str[:10], "%Y-%m-%d")
+    return (d + timedelta(days=n)).strftime("%Y-%m-%d")
+
+def get_conn():
+    conn = sqlite3.connect(DATABASE_PATH)
+    conn.row_factory = sqlite3.Row
+    return conn
+
+V2_CREATE_SQL = """
+CREATE TABLE IF NOT EXISTS word_progress (
+    word_id INTEGER PRIMARY KEY,
+    first_learned_at DATETIME,
+    last_seen_at DATETIME,
+    next_review_at DATETIME,
+    memory_type TEXT,
+    mastery_level INTEGER DEFAULT 0,
+    spell_correct INTEGER DEFAULT 0,
+    spell_wrong INTEGER DEFAULT 0,
+    recognize_correct INTEGER DEFAULT 0,
+    recognize_wrong INTEGER DEFAULT 0,
+    quiz_correct INTEGER DEFAULT 0,
+    quiz_wrong INTEGER DEFAULT 0,
+    updated_at DATETIME
+);
+CREATE TABLE IF NOT EXISTS daily_plans (
+    plan_id TEXT PRIMARY KEY,
+    plan_date DATE UNIQUE NOT NULL,
+    planned_new INTEGER DEFAULT 0,
+    planned_spell INTEGER DEFAULT 0,
+    planned_recognize INTEGER DEFAULT 0,
+    planned_review INTEGER DEFAULT 0,
+    planned_quiz INTEGER DEFAULT 5,
+    estimated_sec INTEGER DEFAULT 0,
+    status TEXT DEFAULT 'planned',
+    created_at DATETIME
+);
+CREATE TABLE IF NOT EXISTS daily_plan_items (
+    plan_id TEXT NOT NULL,
+    item_type TEXT NOT NULL,
+    word_id INTEGER,
+    question_id TEXT,
+    order_no INTEGER,
+    payload TEXT,
+    PRIMARY KEY (plan_id, order_no)
+);
+CREATE TABLE IF NOT EXISTS learning_events (
+    event_id TEXT PRIMARY KEY,
+    session_id TEXT NOT NULL,
+    plan_id TEXT,
+    word_id INTEGER,
+    group_id INTEGER,
+    stage TEXT NOT NULL,
+    question_type TEXT NOT NULL,
+    result TEXT NOT NULL,
+    attempt_no INTEGER DEFAULT 1,
+    duration_ms INTEGER DEFAULT 0,
+    created_at DATETIME,
+    log_date DATE
+);
+CREATE TABLE IF NOT EXISTS study_sessions (
+    session_id TEXT PRIMARY KEY,
+    plan_id TEXT,
+    log_date DATE NOT NULL,
+    started_at DATETIME,
+    ended_at DATETIME,
+    active_sec INTEGER DEFAULT 0,
+    new_sec INTEGER DEFAULT 0,
+    review_sec INTEGER DEFAULT 0,
+    quiz_sec INTEGER DEFAULT 0,
+    completed INTEGER DEFAULT 0,
+    updated_at DATETIME
+);
+"""
+
+def ensure_v2_schema():
+    """启动时确保 V2 表与新列存在(幂等)。"""
+    conn = get_conn()
+    try:
+        cols = {r[1] for r in conn.execute("PRAGMA table_info(words)").fetchall()}
+        alters = [
+            ("subgroupid", "ALTER TABLE words ADD COLUMN subgroupid TEXT"),
+            ("unit_order", "ALTER TABLE words ADD COLUMN unit_order INTEGER"),
+            ("memory_type", "ALTER TABLE words ADD COLUMN memory_type TEXT DEFAULT 'spell'"),
+            ("derived_from", "ALTER TABLE words ADD COLUMN derived_from TEXT"),
+            ("pos_quiz_answer", "ALTER TABLE words ADD COLUMN pos_quiz_answer TEXT"),
+            ("exam_priority", "ALTER TABLE words ADD COLUMN exam_priority TEXT"),
+        ]
+        for col, sql in alters:
+            if col not in cols:
+                conn.execute(sql)
+        conn.executescript(V2_CREATE_SQL)
+        conn.commit()
+    finally:
+        conn.close()
+
+@app.on_event("startup")
+async def _v2_startup():
+    ensure_v2_schema()
+
+# ---------- 综合题库生成 ----------
+def _distractors(conn, answer_word, n=3):
+    grp = conn.execute(
+        "SELECT * FROM words WHERE CAST(groupid AS INTEGER)=? AND ID!=?",
+        (answer_word["groupid"], answer_word["ID"])).fetchall()
+    cands = [dict(w) for w in grp if w["word"]]
+    if len(cands) < n:
+        extra = conn.execute(
+            "SELECT * FROM words WHERE ID!=? ORDER BY RANDOM() LIMIT ?",
+            (answer_word["ID"], n - len(cands))).fetchall()
+        cands += [dict(w) for w in extra]
+    return cands[:n]
+
+def _make_mcq(conn, answer_word, distract_words, prompt, qtype, related_ids):
+    opts = [answer_word["word"]]
+    for d in distract_words[:3]:
+        if d["word"] not in opts:
+            opts.append(d["word"])
+    while len(opts) < 4:
+        extra = conn.execute(
+            "SELECT word FROM words WHERE word NOT IN ({}) ORDER BY RANDOM() LIMIT 1".format(
+                ",".join("?" * len(opts))), opts).fetchone()
+        if extra and extra[0] not in opts:
+            opts.append(extra[0])
+        else:
+            break
+    random.shuffle(opts)
+    return {
+        "type": qtype,
+        "prompt": prompt,
+        "options": opts,
+        "answer": answer_word["word"],
+        "answer_index": opts.index(answer_word["word"]),
+        "related_word_ids": related_ids,
+        "note": V2_NOTE,
+    }
+
+def _try_word_form(conn, today_words):
+    for base in today_words:
+        if base["memory_type"] != "spell":
+            continue
+        root = (base["word_root"] or "").strip()
+        if not root:
+            continue
+        sibs = conn.execute(
+            "SELECT * FROM words WHERE CAST(groupid AS INTEGER)=? AND word_root=? AND memory_type='recognize' AND ID!=?",
+            (base["groupid"], root, base["ID"])).fetchall()
+        sibs = [dict(s) for s in sibs if (s["word_root"] or "").strip()]
+        if sibs:
+            ans = random.choice(sibs)
+            hint = ans["pos_quiz_answer"] or ans["part_of_speech"] or "派生形式"
+            prompt = f"词形变化:「{base['word']}」的{hint}形式是?"
+            return _make_mcq(conn, ans, _distractors(conn, ans), prompt, "word_form", [base["ID"], ans["ID"]])
+    return None
+
+def _try_pos(conn, today_words):
+    for w in today_words:
+        if w["memory_type"] == "recognize" and w["pos_quiz_answer"]:
+            prompt = f"词性判断:「{w['word']}」的主要词性是?"
+            correct = w["pos_quiz_answer"]
+            pool = ["n.", "v.", "adj.", "adv.", "prep.", "conj."]
+            opts = [correct] + random.sample([p for p in pool if p != correct], 3)
+            random.shuffle(opts)
+            return {"type": "pos", "prompt": prompt, "options": opts,
+                    "answer": correct, "answer_index": opts.index(correct),
+                    "related_word_ids": [w["ID"]], "note": V2_NOTE}
+    return None
+
+def _try_confusable(conn, today_words):
+    for w in today_words:
+        if not w["word"]:
+            continue
+        pre = w["word"][:3].lower()
+        sim = conn.execute(
+            "SELECT * FROM words WHERE CAST(groupid AS INTEGER)=? AND ID!=? AND LOWER(word) LIKE ?",
+            (w["groupid"], w["ID"], pre + "%")).fetchall()
+        sim = [dict(s) for s in sim if s["word"] and s["ID"] != w["ID"]]
+        if sim:
+            opts = [w["word"]] + [s["word"] for s in sim[:2]]
+            for d in _distractors(conn, w):
+                if d["word"] not in opts:
+                    opts.append(d["word"])
+            opts = opts[:4]
+            while len(opts) < 4:
+                opts.append("—")
+            random.shuffle(opts)
+            prompt = f"易混词:意思是「{w['chinese_meaning']}」的单词是?"
+            return {"type": "confusable", "prompt": prompt, "options": opts,
+                    "answer": w["word"], "answer_index": opts.index(w["word"]),
+                    "related_word_ids": [w["ID"]], "note": V2_NOTE}
+    return None
+
+def _try_context(conn, words):
+    for w in words:
+        ex = w["example_sentence"]
+        if not ex or not w["word"]:
+            continue
+        if w["word"].lower() not in ex.lower():
+            continue
+        pattern = re.compile(re.escape(w["word"]), re.IGNORECASE)
+        blanked = pattern.sub("_____", ex, count=1)
+        if blanked == ex:
+            continue
+        opts = [w["word"]] + [d["word"] for d in _distractors(conn, w)[:3]]
+        random.shuffle(opts)
+        prompt = f"语境填空:{blanked}"
+        return {"type": "context", "prompt": prompt, "options": opts,
+                "answer": w["word"], "answer_index": opts.index(w["word"]),
+                "related_word_ids": [w["ID"]], "note": V2_NOTE}
+    return None
+
+def _fallback_mcq(conn, word, qtype):
+    prompt = f"词义选择:「{word['chinese_meaning']}」对应的英文单词是?"
+    return _make_mcq(conn, word, _distractors(conn, word), prompt, qtype, [word["ID"]])
+
+def build_quiz_questions(conn, date_str, today_new_ids, review_ids, words_map):
+    today_words = [words_map[i] for i in today_new_ids if i in words_map]
+    review_words = [words_map[i] for i in review_ids if i in words_map]
+    random.shuffle(today_words)
+    random.shuffle(review_words)
+    # 兜底词池:已学词 → 任意词
+    learned = [dict(r) for r in conn.execute(
+        "SELECT w.* FROM words w JOIN word_progress p ON p.word_id=w.ID LIMIT 60").fetchall()]
+    random.shuffle(learned)
+    any_words = [dict(r) for r in conn.execute("SELECT * FROM words ORDER BY RANDOM() LIMIT 60").fetchall()]
+    random.shuffle(any_words)
+
+    def pick(preferred, fallback):
+        for lst in (preferred, learned, any_words):
+            if lst:
+                return lst[0]
+        return None
+
+    questions = []
+    builders = [
+        ("word_form", lambda: _try_word_form(conn, today_words), today_words),
+        ("pos", lambda: _try_pos(conn, today_words), today_words),
+        ("confusable", lambda: _try_confusable(conn, today_words), today_words),
+        ("context", lambda: _try_context(conn, review_words if review_words else today_words), review_words if review_words else today_words),
+        ("context", lambda: _try_context(conn, today_words if today_words else review_words), today_words if today_words else review_words),
+    ]
+    for qtype, fn, src in builders:
+        q = fn()
+        if not q:
+            w = pick(src, None)
+            q = _fallback_mcq(conn, w, qtype) if w else None
+        if not q:
+            # 最终兜底:任意词
+            q = _fallback_mcq(conn, any_words[0], qtype)
+        questions.append(q)
+    # 冻结 question_id(含日期,天然 14 天内不重复)
+    for i, q in enumerate(questions):
+        q["question_id"] = f"{date_str}-Q{i + 1}"
+    return questions[:QUIZ_PER_DAY]
+
+# ---------- Daily Plan 调度 ----------
+def generate_daily_plan(date_str):
+    conn = get_conn()
+    try:
+        existing = conn.execute("SELECT * FROM daily_plans WHERE plan_date=?", (date_str,)).fetchone()
+        if existing:
+            return _plan_with_items(conn, date_str)
+
+        words = conn.execute(
+            "SELECT ID, groupid, memory_type, unit_order FROM words "
+            "ORDER BY CAST(groupid AS INTEGER) ASC, COALESCE(unit_order, ID) ASC").fetchall()
+        progress = {r["word_id"]: r for r in conn.execute("SELECT * FROM word_progress").fetchall()}
+
+        # 到期复习(错词优先)
+        due = []
+        for w in words:
+            p = progress.get(w["ID"])
+            if p and p["next_review_at"] and p["next_review_at"] <= date_str:
+                wrong = (p["spell_wrong"] or 0) + (p["recognize_wrong"] or 0) + (p["quiz_wrong"] or 0)
+                due.append((w, wrong))
+        due.sort(key=lambda x: -x[1])
+        review_words = [w for w, _ in due]
+
+        # 新词(无 first_learned_at)
+        new_ids = [w["ID"] for w in words
+                   if w["ID"] not in progress or not progress[w["ID"]]["first_learned_at"]]
+        new_set = set(new_ids)
+        new_spell = [w for w in words if w["ID"] in new_set and w["memory_type"] == "spell"]
+        new_recognize = [w for w in words if w["ID"] in new_set and w["memory_type"] == "recognize"]
+
+        # 预算
+        review_sec = 0
+        for w in review_words:
+            c = COST["spell_review"] if w["memory_type"] == "spell" else COST["recognize_review"]
+            p = progress[w["ID"]]
+            wrong = (p["spell_wrong"] or 0) + (p["recognize_wrong"] or 0)
+            if wrong > 0:
+                c = int(c * 1.2)
+            review_sec += c
+        remaining = BUDGET_SEC - QUIZ_FIXED_SEC - review_sec
+        if remaining < 0:
+            remaining = 0
+
+        chosen_spell, chosen_recognize = [], []
+        planned_spell = planned_recognize = 0
+        for w in new_spell:
+            if remaining <= 0 or planned_spell >= MAX_SPELL_PER_DAY:
+                break
+            chosen_spell.append(w)
+            planned_spell += 1
+            remaining -= COST["spell_new"]
+        for w in new_recognize:
+            if remaining <= 0 or (planned_spell + planned_recognize) >= MAX_NEW_PER_DAY:
+                break
+            chosen_recognize.append(w)
+            planned_recognize += 1
+            remaining -= COST["recognize_new"]
+
+        planned_new = planned_spell + planned_recognize
+        planned_review = len(review_words)
+        est_new_sec = planned_spell * COST["spell_new"] + planned_recognize * COST["recognize_new"]
+        estimated_sec = QUIZ_FIXED_SEC + review_sec + est_new_sec
+
+        plan_id = str(uuid.uuid4())
+        now = china_now_str()
+        conn.execute(
+            "INSERT INTO daily_plans (plan_id, plan_date, planned_new, planned_spell, "
+            "planned_recognize, planned_review, planned_quiz, estimated_sec, status, created_at) "
+            "VALUES (?,?,?,?,?,?,?,?,?,?)",
+            (plan_id, date_str, planned_new, planned_spell, planned_recognize,
+             planned_review, QUIZ_PER_DAY, estimated_sec, "planned", now))
+
+        order_no = 1
+        for w in chosen_spell:
+            conn.execute("INSERT INTO daily_plan_items (plan_id, item_type, word_id, order_no) VALUES (?, 'new_spell', ?, ?)",
+                         (plan_id, w["ID"], order_no)); order_no += 1
+        for w in chosen_recognize:
+            conn.execute("INSERT INTO daily_plan_items (plan_id, item_type, word_id, order_no) VALUES (?, 'new_recognize', ?, ?)",
+                         (plan_id, w["ID"], order_no)); order_no += 1
+        for w in review_words:
+            it = "review_spell" if w["memory_type"] == "spell" else "review_recognize"
+            conn.execute("INSERT INTO daily_plan_items (plan_id, item_type, word_id, order_no) VALUES (?, ?, ?, ?)",
+                         (plan_id, it, w["ID"], order_no)); order_no += 1
+
+        words_map = {r["ID"]: dict(r) for r in conn.execute("SELECT * FROM words").fetchall()}
+        quiz = build_quiz_questions(conn, date_str,
+                                    [w["ID"] for w in chosen_spell] + [w["ID"] for w in chosen_recognize],
+                                    [w["ID"] for w in review_words], words_map)
+        for q in quiz:
+            conn.execute(
+                "INSERT INTO daily_plan_items (plan_id, item_type, word_id, question_id, order_no, payload) "
+                "VALUES (?, 'quiz', NULL, ?, ?, ?)",
+                (plan_id, q["question_id"], order_no, json.dumps(q, ensure_ascii=False))); order_no += 1
+
+        conn.commit()
+        return _plan_with_items(conn, date_str)
+    finally:
+        conn.close()
+
+def _plan_with_items(conn, date_str):
+    plan = conn.execute("SELECT * FROM daily_plans WHERE plan_date=?", (date_str,)).fetchone()
+    if not plan:
+        return None
+    items = conn.execute(
+        "SELECT * FROM daily_plan_items WHERE plan_id=? ORDER BY order_no",
+        (plan["plan_id"],)).fetchall()
+    ids = [it["word_id"] for it in items if it["word_id"]]
+    words_map = {}
+    if ids:
+        place = ",".join("?" * len(ids))
+        words_map = {r["ID"]: dict(r) for r in
+                     conn.execute(f"SELECT * FROM words WHERE ID IN ({place})", ids).fetchall()}
+    out_items = []
+    for it in items:
+        d = {"item_type": it["item_type"], "order_no": it["order_no"],
+             "word_id": it["word_id"], "question_id": it["question_id"]}
+        if it["word_id"] and it["word_id"] in words_map:
+            d["word"] = words_map[it["word_id"]]
+        if it["item_type"] == "quiz" and it["payload"]:
+            d["question"] = json.loads(it["payload"])
+        out_items.append(d)
+    result = dict(plan)
+    result["items"] = out_items
+    result["estimated_min"] = round((result.get("estimated_sec") or 0) / 60)
+    return result
+
+# ---------- V2 数据模型 ----------
+class DailyPlanCreate(BaseModel):
+    date: Optional[str] = None
+
+class EventItem(BaseModel):
+    event_id: str
+    plan_id: Optional[str] = None
+    word_id: Optional[int] = None
+    group_id: Optional[int] = None
+    stage: str
+    question_type: str
+    result: str
+    attempt_no: int = 1
+    duration_ms: int = 0
+    created_at: Optional[str] = None
+
+class EventsBatch(BaseModel):
+    session_id: str
+    events: List[EventItem]
+
+class SessionUpsert(BaseModel):
+    session_id: str
+    plan_id: Optional[str] = None
+    log_date: Optional[str] = None
+    started_at: Optional[str] = None
+    ended_at: Optional[str] = None
+    active_sec: int = 0
+    new_sec: int = 0
+    review_sec: int = 0
+    quiz_sec: int = 0
+    completed: int = 0
+
+# ---------- V2 端点 ----------
+@app.get("/api/v2/home")
+async def v2_home():
+    conn = get_conn()
+    try:
+        wc = conn.execute("SELECT COUNT(*) FROM words").fetchone()[0]
+        learned = conn.execute(
+            "SELECT COUNT(*) FROM word_progress WHERE first_learned_at IS NOT NULL").fetchone()[0]
+        today = china_date()
+        plan = conn.execute("SELECT * FROM daily_plans WHERE plan_date=?", (today,)).fetchone()
+        plan_info = None
+        if plan:
+            plan_info = {k: plan[k] for k in
+                         ["plan_date", "planned_new", "planned_spell", "planned_recognize",
+                          "planned_review", "planned_quiz", "estimated_sec", "status"]}
+            plan_info["estimated_min"] = round((plan_info["estimated_sec"] or 0) / 60)
+        return {"words_count": wc, "learned_count": learned, "today": today, "today_plan": plan_info}
+    finally:
+        conn.close()
+
+@app.get("/api/words/v2/all")
+async def get_words_v2_all():
+    try:
+        conn = get_conn()
+        rows = conn.execute(
+            "SELECT ID as id, word, chinese_meaning, part_of_speech, word_root, affix, "
+            "example_sentence, picture_id, groupid, unit_order, memory_type, derived_from, "
+            "pos_quiz_answer, exam_priority FROM words "
+            "ORDER BY CAST(groupid AS INTEGER) ASC, COALESCE(unit_order, ID) ASC").fetchall()
+        conn.close()
+        return [dict(r) for r in rows]
+    except Exception as e:
+        raise HTTPException(status_code=500, detail=str(e))
+
+@app.get("/api/words/v2/{group_id}")
+async def get_words_v2_group(group_id: str):
+    try:
+        conn = get_conn()
+        rows = conn.execute(
+            "SELECT ID as id, word, chinese_meaning, part_of_speech, word_root, affix, "
+            "example_sentence, picture_id, groupid, unit_order, memory_type, derived_from, "
+            "pos_quiz_answer, exam_priority FROM words WHERE CAST(groupid AS INTEGER)=? "
+            "ORDER BY COALESCE(unit_order, ID) ASC", (group_id,)).fetchall()
+        conn.close()
+        return [dict(r) for r in rows]
+    except Exception as e:
+        raise HTTPException(status_code=500, detail=str(e))
+
+@app.get("/api/daily-plan/{date}")
+async def get_daily_plan(date: str):
+    conn = get_conn()
+    try:
+        plan = _plan_with_items(conn, date)
+    finally:
+        conn.close()
+    if not plan:
+        raise HTTPException(status_code=404, detail="plan not found")
+    return plan
+
+@app.post("/api/daily-plan/create")
+async def create_daily_plan(body: DailyPlanCreate):
+    date_str = body.date or china_date()
+    plan = generate_daily_plan(date_str)
+    return plan
+
+def _apply_event_to_progress(conn, ev):
+    wid = ev.word_id
+    if not wid:
+        return
+    stage = ev.stage
+    qtype = ev.question_type
+    result = ev.result
+    now = china_now_str()
+    row = conn.execute("SELECT * FROM word_progress WHERE word_id=?", (wid,)).fetchone()
+    if not row:
+        mt = None
+        w = conn.execute("SELECT memory_type FROM words WHERE ID=?", (wid,)).fetchone()
+        if w:
+            mt = w["memory_type"]
+        conn.execute(
+            "INSERT INTO word_progress (word_id, first_learned_at, last_seen_at, next_review_at, "
+            "memory_type, mastery_level, updated_at) VALUES (?,?,?,?,?,?,?)",
+            (wid, now if stage == "new" else None, now, None, mt, 0, now))
+        row = conn.execute("SELECT * FROM word_progress WHERE word_id=?", (wid,)).fetchone()
+    spell_c = row["spell_correct"] or 0
+    spell_w = row["spell_wrong"] or 0
+    rec_c = row["recognize_correct"] or 0
+    rec_w = row["recognize_wrong"] or 0
+    quiz_c = row["quiz_correct"] or 0
+    quiz_w = row["quiz_wrong"] or 0
+    mastery = row["mastery_level"] or 0
+    if stage in ("new", "review"):
+        if qtype == "spell":
+            if result == "correct":
+                spell_c += 1
+                mastery = min(mastery + 1, 5)
+            else:
+                spell_w += 1
+                mastery = max(mastery - 1, 0)
+        elif qtype in ("pos", "recognize"):
+            if result == "correct":
+                rec_c += 1
+                mastery = min(mastery + 1, 5)
+            else:
+                rec_w += 1
+                mastery = max(mastery - 1, 0)
+    elif stage == "quiz":
+        if result == "correct":
+            quiz_c += 1
+        else:
+            quiz_w += 1
+    wrong = (spell_w + rec_w + quiz_w) > 0
+    if wrong:
+        nxt = _add_days(china_date(), 1)
+    else:
+        nxt = _add_days(china_date(), 1 + mastery)
+    conn.execute(
+        "UPDATE word_progress SET last_seen_at=?, next_review_at=?, mastery_level=?, "
+        "spell_correct=?, spell_wrong=?, recognize_correct=?, recognize_wrong=?, "
+        "quiz_correct=?, quiz_wrong=?, updated_at=? WHERE word_id=?",
+        (now, nxt, mastery, spell_c, spell_w, rec_c, rec_w, quiz_c, quiz_w, now, wid))
+
+@app.post("/api/events/batch")
+async def post_events_batch(body: EventsBatch):
+    conn = get_conn()
+    try:
+        inserted = skipped = 0
+        for ev in body.events:
+            exists = conn.execute("SELECT 1 FROM learning_events WHERE event_id=?",
+                                  (ev.event_id,)).fetchone()
+            if exists:
+                skipped += 1
+                continue
+            gid = ev.group_id
+            if gid is None and ev.word_id:
+                r = conn.execute("SELECT groupid FROM words WHERE ID=?", (ev.word_id,)).fetchone()
+                gid = r[0] if r else None
+            created = ev.created_at or china_now_str()
+            log_date = created[:10]
+            conn.execute(
+                "INSERT INTO learning_events (event_id, session_id, plan_id, word_id, group_id, "
+                "stage, question_type, result, attempt_no, duration_ms, created_at, log_date) "
+                "VALUES (?,?,?,?,?,?,?,?,?,?,?,?)",
+                (ev.event_id, body.session_id, ev.plan_id, ev.word_id, gid, ev.stage,
+                 ev.question_type, ev.result, ev.attempt_no, ev.duration_ms, created, log_date))
+            inserted += 1
+            _apply_event_to_progress(conn, ev)
+        conn.commit()
+        return {"status": "success", "inserted": inserted, "skipped": skipped,
+                "received": len(body.events)}
+    except Exception as e:
+        raise HTTPException(status_code=500, detail=str(e))
+    finally:
+        conn.close()
+
+@app.post("/api/sessions/upsert")
+async def upsert_session(body: SessionUpsert):
+    conn = get_conn()
+    try:
+        log_date = body.log_date or china_date()
+        now = china_now_str()
+        conn.execute(
+            "INSERT INTO study_sessions (session_id, plan_id, log_date, started_at, ended_at, "
+            "active_sec, new_sec, review_sec, quiz_sec, completed, updated_at) "
+            "VALUES (?,?,?,?,?,?,?,?,?,?,?) "
+            "ON CONFLICT(session_id) DO UPDATE SET "
+            "plan_id=excluded.plan_id, ended_at=excluded.ended_at, "
+            "active_sec=MAX(active_sec, excluded.active_sec), "
+            "new_sec=MAX(new_sec, excluded.new_sec), "
+            "review_sec=MAX(review_sec, excluded.review_sec), "
+            "quiz_sec=MAX(quiz_sec, excluded.quiz_sec), "
+            "completed=MAX(completed, excluded.completed), updated_at=excluded.updated_at",
+            (body.session_id, body.plan_id, log_date, body.started_at, body.ended_at,
+             body.active_sec, body.new_sec, body.review_sec, body.quiz_sec, body.completed, now))
+        conn.commit()
+        return {"status": "success"}
+    except Exception as e:
+        raise HTTPException(status_code=500, detail=str(e))
+    finally:
+        conn.close()
+
+@app.get("/api/progress/words")
+async def get_progress_words():
+    conn = get_conn()
+    try:
+        rows = conn.execute("SELECT * FROM word_progress").fetchall()
+        return [dict(r) for r in rows]
+    finally:
+        conn.close()
+
+@app.get("/api/questions/daily/{date}")
+async def get_questions_daily(date: str):
+    conn = get_conn()
+    try:
+        items = conn.execute(
+            "SELECT payload FROM daily_plan_items WHERE plan_id=(SELECT plan_id FROM daily_plans WHERE plan_date=?) "
+            "AND item_type='quiz' ORDER BY order_no", (date,)).fetchall()
+        if not items:
+            raise HTTPException(status_code=404, detail="no quiz for date")
+        return [json.loads(it["payload"]) for it in items]
+    finally:
+        conn.close()
+
+@app.get("/api/calendar")
+async def v2_calendar(month: str = None):
+    if not month:
+        month = china_date()[:7]
+    conn = get_conn()
+    try:
+        def blank():
+            return {"duration_sec": 0, "new_words": 0, "spell_words": 0, "recognize_words": 0,
+                    "review_words": 0, "quiz_total": 0, "quiz_correct": 0, "groups": set(), "legacy": False}
+        days = {}
+        for s in conn.execute(
+                "SELECT log_date, COALESCE(SUM(active_sec),0) act FROM study_sessions "
+                "WHERE log_date LIKE ? GROUP BY log_date", (month + "%",)):
+            d = s["log_date"]
+            days.setdefault(d, blank())
+            days[d]["duration_sec"] = s["act"]
+        for e in conn.execute(
+                "SELECT log_date, stage, question_type, result, group_id FROM learning_events WHERE log_date LIKE ?",
+                (month + "%",)):
+            d = e["log_date"]
+            dd = days.setdefault(d, blank())
+            if e["stage"] == "new":
+                dd["new_words"] += 1
+                if e["question_type"] == "spell":
+                    dd["spell_words"] += 1
+                else:
+                    dd["recognize_words"] += 1
+            elif e["stage"] == "review":
+                dd["review_words"] += 1
+            elif e["stage"] == "quiz":
+                dd["quiz_total"] += 1
+                if e["result"] == "correct":
+                    dd["quiz_correct"] += 1
+            if e["group_id"] is not None:
+                dd["groups"].add(str(e["group_id"]))
+        # legacy study_logs(V1 历史)合并,保持连续记录可见
+        for r in conn.execute(
+                "SELECT COALESCE(log_date,substr(created_at,1,10)) day, op_type, group_id "
+                "FROM study_logs WHERE status='completed' AND (log_date LIKE ? OR substr(created_at,1,7)=?)",
+                (month + "%", month)):
+            d = r["day"]
+            dd = days.setdefault(d, blank())
+            dd["legacy"] = True
+            if dd["duration_sec"] == 0:
+                dur = conn.execute(
+                    "SELECT COALESCE(SUM(duration_sec),0) FROM study_logs WHERE status='completed' "
+                    "AND COALESCE(log_date,substr(created_at,1,10))=?", (d,)).fetchone()[0]
+                dd["duration_sec"] = dur
+            if r["op_type"] == "study" and r["group_id"] is not None:
+                dd["groups"].add(str(r["group_id"]))
+        out = []
+        for d in sorted(days):
+            dd = days[d]
+            out.append({
+                "date": d,
+                "duration_min": round(dd["duration_sec"] / 60),
+                "new_words": dd["new_words"],
+                "spell_words": dd["spell_words"],
+                "recognize_words": dd["recognize_words"],
+                "review_words": dd["review_words"],
+                "quiz_total": dd["quiz_total"],
+                "quiz_correct": dd["quiz_correct"],
+                "groups_touched": sorted(dd["groups"], key=lambda x: (len(x), x)),
+                "legacy": dd["legacy"],
+            })
+        return out
+    finally:
+        conn.close()
+
+@app.get("/api/calendar/{date}")
+async def v2_calendar_day(date: str):
+    conn = get_conn()
+    try:
+        sess = conn.execute("SELECT * FROM study_sessions WHERE log_date=?", (date,)).fetchall()
+        evs = conn.execute("SELECT * FROM learning_events WHERE log_date=?", (date,)).fetchall()
+        active_sec = sum((s["active_sec"] or 0) for s in sess)
+        new_words = sum(1 for e in evs if e["stage"] == "new")
+        review_words = sum(1 for e in evs if e["stage"] == "review")
+        quiz_total = sum(1 for e in evs if e["stage"] == "quiz")
+        quiz_correct = sum(1 for e in evs if e["stage"] == "quiz" and e["result"] == "correct")
+        spell_words = sum(1 for e in evs if e["stage"] == "new" and e["question_type"] == "spell")
+        recognize_words = sum(1 for e in evs if e["stage"] == "new" and e["question_type"] != "spell")
+        groups = sorted({str(e["group_id"]) for e in evs if e["group_id"] is not None})
+        # 薄弱点:当天答错的词
+        weak = {}
+        for e in evs:
+            if e["result"] != "correct" and e["word_id"]:
+                weak.setdefault(e["word_id"], {"word_id": e["word_id"], "wrong_types": set()})
+                weak[e["word_id"]]["wrong_types"].add(e["question_type"])
+        weak_ids = list(weak.keys())
+        weak_words = {}
+        if weak_ids:
+            place = ",".join("?" * len(weak_ids))
+            weak_words = {r["ID"]: r["word"] for r in
+                          conn.execute(f"SELECT ID, word FROM words WHERE ID IN ({place})", weak_ids).fetchall()}
+        weak_list = []
+        for wid, info in weak.items():
+            weak_list.append({"word_id": wid, "word": weak_words.get(wid),
+                              "wrong_types": sorted(info["wrong_types"])})
+        return {
+            "date": date,
+            "duration_min": round(active_sec / 60),
+            "new_words": new_words,
+            "spell_words": spell_words,
+            "recognize_words": recognize_words,
+            "review_words": review_words,
+            "quiz_total": quiz_total,
+            "quiz_correct": quiz_correct,
+            "groups_touched": groups,
+            "weak_points": weak_list,
+        }
+    finally:
+        conn.close()
+
 @app.get("/")
 async def root():
-    return {"message": "English Lexicon API is running"}
+    return {"message": "English Lexicon API (V2) is running"}
 
 if __name__ == "__main__":
     import uvicorn
-    uvicorn.run(app, host="0.0.0.0", port=1060)
+    uvicorn.run(app, host="0.0.0.0", port=1061)
index beef00e2edc166ac81c6d462ba3c6b1ad282f1ab..94ba4c563e3d73f41c7d8137e7ec35a0e4c3210c 100644 (file)
@@ -5,7 +5,7 @@
 //   - navigation (HTML)         → network-first, 失败回退 cached index.html
 //   - 静态资源 (JS/CSS/img/font) → cache-first
 
-const CACHE_VERSION = 'words-pwa-v1';
+const CACHE_VERSION = 'words-pwa-v2';
 
 self.addEventListener('install', (event) => {
     event.waitUntil(
index 94a6c4eaa2321c65d0051c7bc7ffec39c79edcb2..c4f62fe705707b77f268bf7a6a53dfb40829c21d 100644 (file)
@@ -1,8 +1,7 @@
 import React, { useState } from 'react';
 import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
 import Home from './components/Home';
-import StudyMode from './components/StudyMode';
-import TestMode from './components/TestMode';
+import DailyBatch from './components/DailyBatch';
 import GroupView from './components/GroupView';
 import StudyCalendar from './components/StudyCalendar';
 import SyncNotifications from './components/SyncNotifications';
@@ -19,8 +18,7 @@ function App() {
                     <SyncNotifications />
                     <Routes>
                         <Route path="/" element={<Home />} />
-                        <Route path="/study/:groupId" element={<StudyMode />} />
-                        <Route path="/test/:groupId" element={<TestMode />} />
+                        <Route path="/batch" element={<DailyBatch />} />
                         <Route path="/group/:groupId" element={<GroupView />} />
                         <Route path="/calendar" element={<StudyCalendar />} />
                     </Routes>
diff --git a/frontend/src/activeTime.js b/frontend/src/activeTime.js
new file mode 100644 (file)
index 0000000..7a25dc1
--- /dev/null
@@ -0,0 +1,87 @@
+// 主动学习时长追踪 (Active Time)
+// - 用户有点击/键盘/触摸/输入时累计 active_sec
+// - 60 秒无操作 或 页面切到后台/锁屏 时自动暂停
+// - 切回前台/再次操作 时恢复
+// - 记录当前阶段(new/review/quiz)的时间分布
+// 设计目标: 不把锁屏、切后台、闲聊空档算进学习时长。
+
+const IDLE_MS = 60 * 1000;
+
+export function createActiveTimer() {
+    let activeStart = null;     // 当前 active 段起点(ms)
+    let total = 0;              // 累计 active 秒
+    let phase = 'new';
+    let phaseStart = null;      // 当前阶段起点(ms)
+    const phaseSec = { new: 0, review: 0, quiz: 0 };
+    let idleTimer = null;
+
+    const now = () => Date.now();
+
+    function markActive() {
+        if (activeStart === null) activeStart = now();
+        if (phaseStart === null) phaseStart = now();
+        resetIdle();
+    }
+
+    function pause() {
+        if (activeStart !== null) {
+            total += (now() - activeStart) / 1000;
+            activeStart = null;
+        }
+        if (phaseStart !== null) {
+            phaseSec[phase] += (now() - phaseStart) / 1000;
+            phaseStart = null;
+        }
+    }
+
+    function setPhase(p) {
+        if (!phaseSec[p]) phaseSec[p] = 0;
+        if (phase !== p) {
+            if (phaseStart !== null) phaseSec[phase] += (now() - phaseStart) / 1000;
+            phase = p;
+            phaseStart = activeStart !== null ? now() : null;
+        }
+    }
+
+    function resetIdle() {
+        if (idleTimer) clearTimeout(idleTimer);
+        idleTimer = setTimeout(pause, IDLE_MS);
+    }
+
+    function get() {
+        // 返回当前累计(含未暂停段)的快照
+        let t = total;
+        if (activeStart !== null) t += (now() - activeStart) / 1000;
+        const ps = { ...phaseSec };
+        if (phaseStart !== null) ps[phase] += (now() - phaseStart) / 1000;
+        return {
+            active_sec: Math.round(t),
+            new_sec: Math.round(ps.new),
+            review_sec: Math.round(ps.review),
+            quiz_sec: Math.round(ps.quiz),
+        };
+    }
+
+    function destroy() {
+        if (idleTimer) clearTimeout(idleTimer);
+        if (typeof document !== 'undefined') {
+            document.removeEventListener('visibilitychange', onVisibility);
+            ['click', 'keydown', 'touchstart', 'input'].forEach(ev =>
+                document.removeEventListener(ev, markActive));
+        }
+    }
+
+    function onVisibility() {
+        if (document.visibilityState !== 'visible') pause();
+        else markActive();
+    }
+
+    if (typeof document !== 'undefined') {
+        document.addEventListener('visibilitychange', onVisibility);
+        ['click', 'keydown', 'touchstart', 'input'].forEach(ev =>
+            document.addEventListener(ev, markActive, { passive: true }));
+    }
+    markActive();
+
+    return { markActive, pause, setPhase, get, destroy };
+}
index 0f4656b2b3307091dd5fc8c8ae24a6d330da3594..ddf804dc2e607904275fbc81275dc2a51e8f6fe9 100644 (file)
@@ -50,6 +50,18 @@ export const api = {
     syncPush: (events) => http.post('/api/sync/push', { events }),
     syncPull: () => http.get('/api/sync/pull'),
     getCalendar: () => http.get('/api/calendar'),
+    // ---------------- V2 (Daily Batch) ----------------
+    v2Home: () => http.get('/api/v2/home'),
+    wordsV2All: () => http.get('/api/words/v2/all'),
+    wordsV2Group: (groupId) => http.get(`/api/words/v2/${groupId}`),
+    dailyPlan: (date) => http.get(`/api/daily-plan/${date}`),
+    createDailyPlan: (date) => http.post('/api/daily-plan/create', { date }),
+    eventsBatch: (session_id, events) => http.post('/api/events/batch', { session_id, events }),
+    upsertSession: (session) => http.post('/api/sessions/upsert', session),
+    progressWords: () => http.get('/api/progress/words'),
+    questionsDaily: (date) => http.get(`/api/questions/daily/${date}`),
+    calendarV2: (month) => http.get('/api/calendar', { params: { month } }),
+    calendarV2Day: (date) => http.get(`/api/calendar/${date}`),
     uploadPicture: (formData) => http.post('/api/picture/', formData, {
         headers: { 'Content-Type': 'multipart/form-data' },
     }),
diff --git a/frontend/src/components/DailyBatch.js b/frontend/src/components/DailyBatch.js
new file mode 100644 (file)
index 0000000..d115193
--- /dev/null
@@ -0,0 +1,402 @@
+import React, { useState, useEffect, useRef, useCallback } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { api, localTimestamp } from '../api';
+import { useOnlineStatus } from '../hooks/useOnlineStatus';
+import {
+    getDailyPlanByDate, putDailyPlan, putLearningEvent, putStudySession,
+    putWordProgress, getAllWordProgress,
+} from '../db';
+import { triggerAutoSync } from '../sync';
+import { createActiveTimer } from '../activeTime';
+import './StudyMode.css';
+import './TestMode.css';
+
+function uuid() {
+    if (typeof crypto !== 'undefined' && crypto.randomUUID) return crypto.randomUUID();
+    return 'id-' + Date.now() + '-' + Math.random().toString(16).slice(2);
+}
+
+function chinaDate() {
+    return new Date(Date.now() + 8 * 3600 * 1000).toISOString().slice(0, 10);
+}
+
+const POS_POOL = ['n.', 'v.', 'adj.', 'adv.', 'prep.', 'conj.'];
+
+const sanitize = (s) => s.replace(/[^\x20-\x7E]/g, '').toLowerCase();
+const blockClipboard = (e) => e.preventDefault();
+const inputProps = {
+    type: 'text', lang: 'en', inputMode: 'latin', autoComplete: 'off',
+    autoCorrect: 'off', autoCapitalize: 'off', spellCheck: false,
+    onPaste: blockClipboard, onContextMenu: blockClipboard, onCompositionStart: blockClipboard,
+};
+
+export default function DailyBatch() {
+    const navigate = useNavigate();
+    const isOnline = useOnlineStatus();
+    const [, setPlan] = useState(null);
+    const [tasks, setTasks] = useState([]);
+    const [idx, setIdx] = useState(0);
+    const [loading, setLoading] = useState(true);
+    const [done, setDone] = useState(false);
+    const [weakWords, setWeakWords] = useState([]);
+
+    const sessionIdRef = useRef(uuid());
+    const timerRef = useRef(null);
+    const planRef = useRef(null);
+    const todayRef = useRef(chinaDate());
+    const wrongSetRef = useRef(new Set());
+
+    useEffect(() => {
+        (async () => {
+            const today = todayRef.current;
+            let p = await getDailyPlanByDate(today);
+            if (!p) {
+                if (!isOnline) {
+                    alert('请联网后生成今日计划');
+                    navigate('/');
+                    return;
+                }
+                try {
+                    const r = await api.dailyPlan(today);
+                    p = { ...r.data, plan_date: today };
+                } catch (e) {
+                    const r = await api.createDailyPlan(today);
+                    p = { ...r.data, plan_date: today };
+                }
+                await putDailyPlan(p);
+            }
+            planRef.current = p;
+            setPlan(p);
+
+            const items = p.items || [];
+            const study = [];
+            items.forEach((it, i) => {
+                if (!it.word) return;
+                if (it.item_type === 'new_spell')
+                    study.push({ kind: 'study', key: 's' + i, word: it.word, stage: 'new', qtype: 'spell', attempt_no: 1 });
+                else if (it.item_type === 'new_recognize')
+                    study.push({ kind: 'study', key: 's' + i, word: it.word, stage: 'new', qtype: 'recognize', attempt_no: 1 });
+                else if (it.item_type === 'review_spell')
+                    study.push({ kind: 'study', key: 's' + i, word: it.word, stage: 'review', qtype: 'spell', attempt_no: 1 });
+                else if (it.item_type === 'review_recognize')
+                    study.push({ kind: 'study', key: 's' + i, word: it.word, stage: 'review', qtype: 'recognize', attempt_no: 1 });
+            });
+            const quiz = items.filter(it => it.item_type === 'quiz' && it.question)
+                .map((it, i) => ({ kind: 'quiz', key: 'q' + i, question: it.question }));
+
+            setTasks([...study, ...quiz]);
+            setLoading(false);
+
+            timerRef.current = createActiveTimer();
+            const sess = {
+                session_id: sessionIdRef.current, plan_id: p.plan_id, log_date: today,
+                started_at: localTimestamp(), ended_at: null,
+                active_sec: 0, new_sec: 0, review_sec: 0, quiz_sec: 0, completed: 0, synced: false,
+            };
+            await putStudySession(sess);
+            if (isOnline) triggerAutoSync();
+        })();
+        return () => { if (timerRef.current) timerRef.current.destroy(); };
+        // eslint-disable-next-line react-hooks/exhaustive-deps
+    }, []);
+
+    useEffect(() => {
+        if (loading) return;
+        const t = setInterval(() => flushSession(), 20000);
+        return () => clearInterval(t);
+        // eslint-disable-next-line react-hooks/exhaustive-deps
+    }, [loading]);
+
+    useEffect(() => {
+        if (!timerRef.current || done) return;
+        const tk = tasks[idx];
+        if (!tk) return;
+        if (tk.kind === 'quiz') timerRef.current.setPhase('quiz');
+        else if (tk.stage === 'review') timerRef.current.setPhase('review');
+        else timerRef.current.setPhase('new');
+    }, [idx, tasks, done]);
+
+    const flushSession = useCallback(async () => {
+        if (!timerRef.current || !planRef.current) return;
+        const ts = timerRef.current.get();
+        await putStudySession({
+            session_id: sessionIdRef.current, plan_id: planRef.current.plan_id, log_date: todayRef.current,
+            started_at: localTimestamp(), ended_at: null,
+            active_sec: ts.active_sec, new_sec: ts.new_sec, review_sec: ts.review_sec, quiz_sec: ts.quiz_sec,
+            completed: 0, synced: false,
+        });
+        if (isOnline) triggerAutoSync();
+    }, [isOnline]);
+
+    const recordEvent = useCallback(async ({ word_id, group_id, stage, question_type, result, attempt_no, duration_ms }) => {
+        const ev = {
+            event_id: uuid(),
+            session_id: sessionIdRef.current,
+            plan_id: planRef.current ? planRef.current.plan_id : null,
+            word_id, group_id, stage, question_type, result,
+            attempt_no: attempt_no || 1, duration_ms: duration_ms || 0,
+            created_at: localTimestamp(), log_date: todayRef.current, synced: false,
+        };
+        await putLearningEvent(ev);
+        if (word_id) {
+            const all = await getAllWordProgress();
+            const cur = all.find(p => p.word_id === word_id) || {
+                word_id, first_learned_at: null, last_seen_at: null, next_review_at: null,
+                memory_type: null, mastery_level: 0,
+                spell_correct: 0, spell_wrong: 0, recognize_correct: 0, recognize_wrong: 0,
+                quiz_correct: 0, quiz_wrong: 0,
+            };
+            const upd = { ...cur };
+            if (stage === 'new' && !upd.first_learned_at) upd.first_learned_at = todayRef.current;
+            upd.last_seen_at = todayRef.current;
+            if (stage === 'new' || stage === 'review') {
+                if (question_type === 'spell') { if (result === 'correct') upd.spell_correct++; else upd.spell_wrong++; }
+                else { if (result === 'correct') upd.recognize_correct++; else upd.recognize_wrong++; }
+            } else if (stage === 'quiz') {
+                if (result === 'correct') upd.quiz_correct++; else upd.quiz_wrong++;
+            }
+            await putWordProgress(upd);
+        }
+        if (isOnline) triggerAutoSync();
+    }, [isOnline]);
+
+    const onStudyResult = useCallback((task, result, duration_ms) => {
+        recordEvent({
+            word_id: task.word.id, group_id: task.word.groupid, stage: task.stage,
+            question_type: task.qtype, result, attempt_no: task.attempt_no, duration_ms,
+        });
+        if (result !== 'correct') {
+            if (!wrongSetRef.current.has(task.word.id)) {
+                wrongSetRef.current.add(task.word.id);
+                setWeakWords(w => [...w, { word: task.word.word, why: task.qtype === 'spell' ? '拼写' : '识别' }]);
+            }
+            setTasks(prev => [...prev, { ...task, key: task.key + '-r' + Date.now(), attempt_no: (task.attempt_no || 1) + 1 }]);
+        }
+        setIdx(i => i + 1);
+    }, [recordEvent]);
+
+    const onQuizResult = useCallback((task, result, duration_ms) => {
+        recordEvent({
+            word_id: null, group_id: null, stage: 'quiz',
+            question_type: task.question.type, result, attempt_no: 1, duration_ms,
+        });
+        if (result !== 'correct') {
+            setWeakWords(w => [...w, { word: task.question.answer, why: '综合题' }]);
+        }
+        setIdx(i => i + 1);
+    }, [recordEvent]);
+
+    const finish = useCallback(async () => {
+        if (timerRef.current) timerRef.current.pause();
+        const ts = timerRef.current ? timerRef.current.get() : { active_sec: 0, new_sec: 0, review_sec: 0, quiz_sec: 0 };
+        await putStudySession({
+            session_id: sessionIdRef.current, plan_id: planRef.current ? planRef.current.plan_id : null,
+            log_date: todayRef.current, started_at: localTimestamp(), ended_at: localTimestamp(),
+            active_sec: ts.active_sec, new_sec: ts.new_sec, review_sec: ts.review_sec, quiz_sec: ts.quiz_sec,
+            completed: 1, synced: false,
+        });
+        if (isOnline) triggerAutoSync();
+        setDone(true);
+    }, [isOnline]);
+
+    if (loading) return <div className="study-container"><div className="loading">加载今日任务…</div></div>;
+
+    if (done) {
+        return (
+            <div className="study-container">
+                <div className="study-card">
+                    <h1 className="word-text">今日任务完成 🎉</h1>
+                    <p className="meaning-row">
+                        {weakWords.length > 0
+                            ? `今天发现 ${weakWords.length} 个需要加强的点,已自动加入后续复习。`
+                            : '今天表现得很好,没有发现薄弱点!'}
+                    </p>
+                    {weakWords.length > 0 && (
+                        <ul className="weak-list">
+                            {weakWords.map((w, i) => <li key={i}>{w.word} <span className="weak-why">({w.why})</span></li>)}
+                        </ul>
+                    )}
+                    <button className="action-btn" onClick={() => navigate('/')}>返回首页</button>
+                </div>
+            </div>
+        );
+    }
+
+    const task = tasks[idx];
+    if (!task) { finish(); return <div className="loading">完成中…</div>; }
+
+    const total = tasks.length;
+    const phaseLabel = task.kind === 'quiz' ? '综合训练' : (task.stage === 'review' ? '复习' : '新词');
+
+    return (
+        <div className="study-container"
+             onCopy={blockClipboard} onCut={blockClipboard} onPaste={blockClipboard} onContextMenu={blockClipboard}>
+            <div className="study-card">
+                <div className="progress-tag">{phaseLabel}:{Math.min(idx + 1, total)} / {total}</div>
+                {task.kind === 'study'
+                    ? <StudyTask task={task} onResult={(r, d) => onStudyResult(task, r, d)} />
+                    : <QuizTask task={task} onResult={(r, d) => onQuizResult(task, r, d)} isLast={idx === total - 1} />}
+            </div>
+            <button className="back-home" onClick={() => { if (window.confirm('退出今日任务?进度已自动保存。')) navigate('/'); }}>返回首页</button>
+        </div>
+    );
+}
+
+// ---------------- 单词学习卡(spell / recognize) ----------------
+function StudyTask({ task, onResult }) {
+    const { word, qtype } = task;
+    const [step, setStep] = useState('prompt');
+    const [userInput, setUserInput] = useState('');
+    const [copyInput, setCopyInput] = useState('');
+    const [isSpeaking, setIsSpeaking] = useState(false);
+    const spokenRef = useRef(false);
+    const startRef = useRef(Date.now());
+
+    const dur = () => Date.now() - startRef.current;
+
+    const speak = useCallback((text) => {
+        if (!text) return;
+        setIsSpeaking(true);
+        const a = new Audio(api.ttsUrl(text));
+        a.play().catch(() => setIsSpeaking(false));
+        a.onended = () => setIsSpeaking(false);
+    }, []);
+
+    useEffect(() => { if (!spokenRef.current && word.word) { speak(word.word); spokenRef.current = true; } }, [word, speak]);
+
+    const correctPos = word.pos_quiz_answer || word.part_of_speech;
+    const hasPos = correctPos && !correctPos.includes('/');
+
+    const checkSpell = () => {
+        if (sanitize(userInput) === word.word.toLowerCase()) onResult('correct', dur());
+        else setStep('copy');
+    };
+    const checkCopy = () => {
+        if (sanitize(copyInput) === word.word.toLowerCase()) onResult('wrong', dur());
+    };
+
+    if (qtype === 'spell') {
+        if (step === 'prompt') {
+            return (
+                <div className="info-area">
+                    <h1 className="word-text">?</h1>
+                    <div className="meaning-row"><span className="chinese">{word.chinese_meaning}</span><span className="pos">({word.part_of_speech})</span></div>
+                    {word.word_root && <p className="root"><strong>词根:</strong> {word.word_root}</p>}
+                    {word.example_sentence && <p className="affix"><strong>例句:</strong> {word.example_sentence}</p>}
+                    <button className="action-btn" onClick={() => setStep('input')} disabled={isSpeaking}>
+                        {isSpeaking ? '朗读中…' : '我来拼写 (Enter)'}
+                    </button>
+                </div>
+            );
+        }
+        if (step === 'input') {
+            return (
+                <div className="flashcard-area">
+                    <h2 className="flashcard-title">拼写:{word.chinese_meaning}</h2>
+                    <p className="flashcard-hint">请写出英文单词</p>
+                    <input autoFocus value={userInput} onChange={(e) => setUserInput(sanitize(e.target.value))}
+                        onKeyDown={(e) => e.key === 'Enter' && checkSpell()} placeholder="输入英文单词…" {...inputProps} />
+                    <button className="action-btn" onClick={checkSpell}>检查 (Enter)</button>
+                </div>
+            );
+        }
+        return (
+            <div className="correction-area">
+                <h2 className="error-tip">回答错误!正确答案:{word.word}</h2>
+                <p>请抄写一遍以解锁:</p>
+                <input autoFocus className={copyInput.toLowerCase() === word.word.toLowerCase() ? 'correct' : 'typing'}
+                    value={copyInput} onChange={(e) => setCopyInput(sanitize(e.target.value))}
+                    onKeyDown={(e) => { if (e.key === 'Enter' && copyInput.toLowerCase() === word.word.toLowerCase()) checkCopy(); }}
+                    placeholder="请在此抄写…" {...inputProps} />
+                {copyInput.toLowerCase() === word.word.toLowerCase() && (
+                    <button className="action-btn" onClick={checkCopy}>重新尝试 (Enter)</button>
+                )}
+            </div>
+        );
+    }
+
+    // recognize
+    if (step === 'prompt') {
+        return (
+            <div className="info-area">
+                <h1 className="word-text">{word.word}</h1>
+                <div className="meaning-row"><span className="chinese">{word.chinese_meaning}</span><span className="pos">({word.part_of_speech})</span></div>
+                {word.word_root && <p className="root"><strong>词根:</strong> {word.word_root}</p>}
+                {word.example_sentence && <p className="affix"><strong>例句:</strong> {word.example_sentence}</p>}
+                <button className="action-btn" onClick={() => setStep('choose')}>开始识别 (Enter)</button>
+            </div>
+        );
+    }
+    if (hasPos) {
+        const opts = shuffle([correctPos, ...POS_POOL.filter(p => p !== correctPos)]).slice(0, 4);
+        return (
+            <div className="flashcard-area">
+                <h2 className="flashcard-title">选择「{word.word}」的主要词性</h2>
+                <p className="flashcard-hint">先默想词义,再选择</p>
+                <div className="pos-options">
+                    {opts.map((o, i) => (
+                        <button key={i} className="action-btn pos-opt" onClick={() => onResult(o === correctPos ? 'correct' : 'wrong', dur())}>{o}</button>
+                    ))}
+                </div>
+            </div>
+        );
+    }
+    return (
+        <div className="flashcard-area">
+            <h2 className="flashcard-title">你认识「{word.word}」吗?</h2>
+            <p className="flashcard-hint">意思:{word.chinese_meaning}</p>
+            <button className="action-btn" onClick={() => onResult('correct', dur())}>认识</button>
+            <button className="action-btn" onClick={() => onResult('wrong', dur())}>不认识</button>
+        </div>
+    );
+}
+
+// ---------------- 综合题卡(MCQ) ----------------
+function QuizTask({ task, onResult, isLast }) {
+    const q = task.question;
+    const [selected, setSelected] = useState(null);
+    const [submitted, setSubmitted] = useState(false);
+    const startRef = useRef(Date.now());
+    const dur = () => Date.now() - startRef.current;
+    const correct = selected === q.answer_index;
+
+    const submit = () => { if (selected === null) return; setSubmitted(true); };
+
+    if (!submitted) {
+        return (
+            <div className="flashcard-area">
+                <h2 className="flashcard-title">综合训练</h2>
+                <p className="flashcard-hint">{q.prompt}</p>
+                <div className="quiz-options">
+                    {q.options.map((o, i) => (
+                        <button key={i} className={'action-btn quiz-opt' + (selected === i ? ' selected' : '')}
+                            onClick={() => setSelected(i)}>{o}</button>
+                    ))}
+                </div>
+                <button className="action-btn" onClick={submit} disabled={selected === null}>提交</button>
+                <p className="quiz-note">*{q.note}</p>
+            </div>
+        );
+    }
+    return (
+        <div className="feedback-wrapper">
+            <div className="feedback-main">
+                <div className={`fb-status ${correct ? 'correct' : 'wrong'}`}>
+                    {correct ? '✅ 正确!' : `❌ 正确答案:${q.answer}`}
+                </div>
+                <button className="action-btn" onClick={() => onResult(correct ? 'correct' : 'wrong', dur())}>
+                    {isLast ? '完成 (Enter)' : '下一题 (Enter)'}
+                </button>
+            </div>
+        </div>
+    );
+}
+
+function shuffle(arr) {
+    const a = [...arr];
+    for (let i = a.length - 1; i > 0; i--) {
+        const j = Math.floor(Math.random() * (i + 1));
+        [a[i], a[j]] = [a[j], a[i]];
+    }
+    return a;
+}
index 1f65d4b0bc4277f76673fda5a3d05a9a9c00836f..4aa4066974baf90e4137733e4650fdc2be33490f 100644 (file)
         font-size: 13px;
     }
 }
+
+/* ---- V2 记忆类型标识 ---- */
+.mt-badge { font-size:11px; padding:2px 6px; border-radius:6px; }
+.mt-spell { background:#dbeafe; color:#1d4ed8; }
+.mt-recognize { background:#dcfce7; color:#15803d; }
+.gv-learned { background:#f0fdf4; }
+.gv-hint { color:#94a3b8; font-size:12px; }
+.gv-nav { display:flex; gap:10px; justify-content:center; margin-top:16px; }
index 317bcb5058fd35b06337c5bc0ea00f384cb46d0f..87eb1e0ffe87b234b28d2318169906fe1acf4979 100644 (file)
@@ -1,34 +1,37 @@
 import React, { useState, useEffect } from 'react';
 import { useParams, useNavigate } from 'react-router-dom';
 import { api } from '../api';
-import { getWordsByGroup } from '../db';
+import { getWordsByGroup, getAllWordProgress } from '../db';
 import './GroupView.css';
 
 function GroupView() {
     const { groupId } = useParams();
     const navigate = useNavigate();
     const [words, setWords] = useState([]);
+    const [learned, setLearned] = useState({});
 
     useEffect(() => {
-        api.getWords(groupId)
+        const gid = Number(groupId);
+        api.wordsV2Group(gid)
             .then(res => setWords(res.data))
-            .catch(async (err) => {
-                // 离线或后端不可达: 降级读 IndexedDB (规格 9: 所有组均可离线查看)
-                console.error("获取词汇失败, 降级读离线缓存:", err);
-                const local = await getWordsByGroup(groupId);
+            .catch(async () => {
+                const local = await getWordsByGroup(gid);
                 setWords(local);
             });
+        getAllWordProgress().then(list => {
+            const m = {};
+            list.forEach(p => { if (p.first_learned_at) m[p.word_id] = true; });
+            setLearned(m);
+        }).catch(() => {});
     }, [groupId]);
 
-    // 组内所有单词共享同一张思维导图,取首词的 picture_id(兼容 picture_id==groupid 约定)
     const pictureId = words.length > 0 ? words[0].picture_id : null;
+    const learnedCount = words.filter(w => learned[w.id]).length;
 
     if (words.length === 0) {
         return (
             <div className="group-view-container">
-                <div className="group-view-card">
-                    <p className="loading">加载中...</p>
-                </div>
+                <div className="group-view-card"><p className="loading">加载中...</p></div>
             </div>
         );
     }
@@ -38,7 +41,8 @@ function GroupView() {
             <div className="group-view-card">
                 <div className="group-view-header">
                     <h1>Group {groupId} · 词汇预览</h1>
-                    <p className="group-view-count">共 {words.length} 个单词(只读预览,不记录进度)</p>
+                    <p className="group-view-count">共 {words.length} 个单词 · 已学 {learnedCount}
+                        <br /><span className="gv-hint">只读预览,不记录进度</span></p>
                 </div>
 
                 {pictureId && (
@@ -51,28 +55,34 @@ function GroupView() {
                     <table className="group-word-table">
                         <thead>
                             <tr>
-                                <th>单词</th>
-                                <th>词性</th>
-                                <th>中文释义</th>
-                                <th>词根</th>
-                                <th>词缀</th>
-                                <th>例句</th>
+                                <th>单词</th><th>词性</th><th>中文释义</th>
+                                <th>记忆类型</th><th>词根</th><th>例句</th>
                             </tr>
                         </thead>
                         <tbody>
                             {words.map(w => (
-                                <tr key={w.id}>
+                                <tr key={w.id} className={learned[w.id] ? 'gv-learned' : ''}>
                                     <td className="gw-word">{w.word}</td>
                                     <td className="gw-pos">{w.part_of_speech}</td>
                                     <td className="gw-cn">{w.chinese_meaning}</td>
+                                    <td className="gw-mt">
+                                        <span className={'mt-badge ' + (w.memory_type === 'spell' ? 'mt-spell' : 'mt-recognize')}>
+                                            {w.memory_type === 'spell' ? '拼写' : '识别'}
+                                        </span>
+                                    </td>
                                     <td className="gw-root">{w.word_root}</td>
-                                    <td className="gw-affix">{w.affix}</td>
                                     <td className="gw-ex">{w.example_sentence}</td>
                                 </tr>
                             ))}
                         </tbody>
                     </table>
                 </div>
+
+                <div className="gv-nav">
+                    <button className="toggle-btn" onClick={() => navigate(`/group/${Number(groupId) - 1}`)}>← 上一组</button>
+                    <button className="toggle-btn" onClick={() => navigate('/')}>首页</button>
+                    <button className="toggle-btn" onClick={() => navigate(`/group/${Number(groupId) + 1}`)}>下一组 →</button>
+                </div>
             </div>
             <button className="back-home" onClick={() => navigate('/')}>返回首页</button>
         </div>
index 1a3db682d9a2c73d35ad2f2b2137576faf274abb..89b40c904ddd5eb734d59b7f056ec0020842c762 100644 (file)
   }
   .logout-btn:hover {
     background: #eef3fb;
-  }
\ No newline at end of file
+  }
+/* ---- V2 Daily Batch 仪表盘 ---- */
+.batch-dashboard { background:#fff; border:1px solid #e2e8f0; border-radius:14px; padding:18px; margin:16px 0; }
+.batch-headline { display:flex; justify-content:space-between; align-items:baseline; }
+.batch-title { font-size:22px; font-weight:700; color:#0f172a; }
+.batch-est { color:#2563eb; font-weight:600; }
+.batch-note { color:#64748b; font-size:13px; margin:4px 0 16px; }
+.batch-cards { display:flex; gap:12px; flex-wrap:wrap; }
+.batch-card { flex:1; min-width:120px; background:#f8fafc; border:1px solid #e2e8f0; border-radius:12px; padding:14px; text-align:center; }
+.batch-card.new-card { border-top:3px solid #2563eb; }
+.batch-card.review-card { border-top:3px solid #5b7c99; }
+.batch-card.quiz-card { border-top:3px solid #8a9ba8; }
+.bc-label { color:#64748b; font-size:13px; }
+.bc-num { font-size:28px; font-weight:800; color:#0f172a; }
+.bc-unit { font-size:14px; font-weight:500; margin-left:2px; }
+.bc-sub { color:#64748b; font-size:12px; margin-top:4px; }
+.start-batch-btn { display:block; width:100%; margin-top:18px; font-size:17px; padding:14px; }
+.weak-list { text-align:left; margin:10px 0; }
+.weak-why { color:#ef4444; font-size:12px; }
index 01f71f8990f43263cf2f9e455282c207886ef239..caab64581c51581f3197128108ee718194633fb4 100644 (file)
 import React, { useEffect, useState } from 'react';
 import { useNavigate } from 'react-router-dom';
 import { useOnlineStatus } from '../hooks/useOnlineStatus';
-import { getAllWordsFromIDB, getAllProgress, isInitialized } from '../db';
-import { triggerAutoSync, subscribeSyncStatus } from '../sync';
+import { isInitialized, getDailyPlanByDate, putDailyPlan, getWordsCount, getAllWordProgress } from '../db';
+import { triggerAutoSync } from '../sync';
+import { api } from '../api';
 import { logout } from '../auth';
 import './Home.css';
 
+function chinaDate() {
+    return new Date(Date.now() + 8 * 3600 * 1000).toISOString().slice(0, 10);
+}
+
 function Home() {
-    const [progressData, setProgressData] = useState([]);
-    const [showAll, setShowAll] = useState(false);
-    const [stats, setStats] = useState({ pictures_count: 0, words_count: 0 });
-    const [needInit, setNeedInit] = useState(false);   // 首次离线: 需联网初始化
-    const [blocking, setBlocking] = useState(false);   // 同步中且本地有 dirty: 禁用学习入口
+    const [plan, setPlan] = useState(null);
+    const today = chinaDate();
+    const [stats, setStats] = useState({ words_count: 0, learned_count: 0 });
+    const [needInit, setNeedInit] = useState(false);
+    const [loading, setLoading] = useState(true);
+    const [creating, setCreating] = useState(false);
     const navigate = useNavigate();
     const isOnline = useOnlineStatus();
 
-    // 离线降级: 从 IndexedDB 构造与 /api/home/process 同格式的数据 (emoji 字符串)
-    const buildOfflineData = async () => {
-        const allWords = await getAllWordsFromIDB();
-        const progress = await getAllProgress();
-        const completedByGroup = {};
-        progress.forEach(p => {
-            const g = String(p.group_id);
-            if (!completedByGroup[g]) completedByGroup[g] = {};
-            completedByGroup[g][p.op_type] = p.completed_at;
-        });
-        const allGroups = Array.from(new Set(allWords.map(w => String(w.groupid))))
-            .sort((a, b) => Number(a) - Number(b));
-        let firstUnstudied = null;
-        for (const g of allGroups) {
-            if (!completedByGroup[g] || !completedByGroup[g]['study']) { firstUnstudied = g; break; }
-        }
-        const OPS = ['study', 'test', 'review1', 'review2', 'review3'];
-        const PREV = { test: 'study', review1: 'test', review2: 'review1', review3: 'review2' };
-        const progressData = allGroups.map(g => {
-            const p = completedByGroup[g] || {};
-            const row = { group: `Group ${g}` };
-            for (const op of OPS) {
-                if (p[op]) { row[op] = `✔️ ${p[op].slice(0, 10)}`; continue; }
-                if (op === 'study') { row[op] = (g === firstUnstudied) ? '✅请开始学习' : ''; continue; }
-                if (op === 'test') { row[op] = p['study'] ? '✅请开始测试' : ''; continue; }
-                row[op] = p[PREV[op]] ? '✅请开始复习' : '';
-            }
-            return row;
-        });
-        return {
-            stats: { pictures_count: 0, words_count: allWords.length },
-            progressData,
-        };
-    };
-
-    // 从本地 IndexedDB 渲染首页 (永远本地优先, 不读云端进度)
-    const renderLocal = async () => {
-        const d = await buildOfflineData();
-        setProgressData(d.progressData);
-        setStats(d.stats);
-        return d.progressData.length;
-    };
-
     useEffect(() => {
         (async () => {
             const init = await isInitialized();
-            // 首次离线: 不能进入学习, 提示需联网
             if (!isOnline && !init) {
                 setNeedInit(true);
-                setProgressData([]);
+                setLoading(false);
                 return;
             }
             setNeedInit(false);
-            await renderLocal();
-            // 在线: 后台自动同步 (首次在线会 warmup 词库+进度; 已初始化则合并云端)
+
+            // 今天计划:先本地,再尝试服务端
+            let local = await getDailyPlanByDate(today);
+            if (!local && isOnline) {
+                try {
+                    const r = await api.dailyPlan(today);
+                    local = { ...r.data, plan_date: today };
+                    await putDailyPlan(local);
+                } catch (e) {
+                    local = null; // 404: 尚未生成
+                }
+            }
+            setPlan(local);
+
+            // 统计
+            if (isOnline) {
+                try {
+                    const h = await api.v2Home();
+                    setStats({ words_count: h.data.words_count, learned_count: h.data.learned_count });
+                } catch (e) {}
+            } else {
+                const wc = await getWordsCount();
+                const wp = await getAllWordProgress();
+                const learned = wp.filter(p => p.first_learned_at).length;
+                setStats({ words_count: wc, learned_count: learned });
+            }
+
+            setLoading(false);
             if (isOnline) triggerAutoSync();
         })();
         if (!isOnline) return;
-        // 在线时定时自动合并其他设备的进度
         const timer = setInterval(() => triggerAutoSync(), 30000);
         return () => clearInterval(timer);
         // eslint-disable-next-line react-hooks/exhaustive-deps
-    }, [isOnline]);
+    }, [isOnline, today]);
 
-    // 订阅同步状态: 同步完成重渲染本地; 同步中且有 dirty 时禁用学习入口
-    useEffect(() => {
-        return subscribeSyncStatus((s) => {
-            if (s.state === 'synced') renderLocal();
-            setBlocking(s.state === 'syncing' && s.detail && s.detail.dirty > 0);
-        });
-        // eslint-disable-next-line react-hooks/exhaustive-deps
-    }, []);
-
-    const filteredData = progressData.filter(item => {
-        if (showAll) return true;
-        const isDone = [item.study, item.test, item.review1, item.review2, item.review3]
-            .every(val => val && val.includes('✔️'));
-        return !isDone;
-    });
-
-    const getStatusClass = (text) => {
-        if (!text) return '';
-        if (text.includes('✔️')) return 'status-done';
-        if (text.includes('✅')) return 'status-start';
-        if (text.includes('⚠️')) return 'status-warning';
-        return '';
+    const generatePlan = async () => {
+        if (!isOnline) { alert('请联网后生成今日计划'); return; }
+        setCreating(true);
+        try {
+            const r = await api.createDailyPlan(today);
+            const p = { ...r.data, plan_date: today };
+            await putDailyPlan(p);
+            setPlan(p);
+        } catch (e) {
+            alert('生成计划失败:' + ((e && e.message) || e));
+        } finally {
+            setCreating(false);
+        }
     };
 
-    const handleCellClick = (item, type) => {
-        const groupId = item.group.replace('Group ', '');
-        if (type === 'group') {
-            navigate(`/group/${groupId}`);
-            return;
-        }
-        // 同步中且本地有 dirty: 禁止开始新的学习/测试/复习 (先同步完成)
-        if (blocking) {
-            alert('同步完成后再开始新的学习');
-            return;
-        }
-        if (type === 'study' && item.study && item.study.includes('请开始学习')) {
-            navigate(`/study/${groupId}`);
-        }
-        if (type === 'test' && item.test && item.test.includes('请开始测试')) {
-            navigate(`/test/${groupId}?type=test`);
-        }
-        if (type === 'review1' && item.review1 && (item.review1.includes('请开始复习') || item.review1.includes('请尽快复习'))) {
-            navigate(`/test/${groupId}?type=review1`);
-        }
-        if (type === 'review2' && item.review2 && (item.review2.includes('请开始复习') || item.review2.includes('请尽快复习'))) {
-            navigate(`/test/${groupId}?type=review2`);
-        }
-        if (type === 'review3' && item.review3 && (item.review3.includes('请开始复习') || item.review3.includes('请尽快复习'))) {
-            navigate(`/test/${groupId}?type=review3`);
-        }
+    const startTask = () => {
+        if (!plan) { generatePlan(); return; }
+        navigate('/batch');
     };
 
+    if (loading) {
+        return <div className="main-app"><header className="header"><h1>单词学习</h1></header><p className="stats-info">加载中…</p></div>;
+    }
+
+    const plannedNew = plan ? plan.planned_new : 0;
+    const plannedSpell = plan ? plan.planned_spell : 0;
+    const plannedRecognize = plan ? plan.planned_recognize : 0;
+    const plannedReview = plan ? plan.planned_review : 0;
+    const plannedQuiz = plan ? (plan.planned_quiz || 5) : 5;
+    const estMin = plan ? plan.estimated_min : 0;
+
     return (
         <div className="main-app">
             <header className="header">
-                <h1>单词学习进度记录</h1>
+                <h1>今晚任务</h1>
                 <div className="header-right">
                     <p className="stats-info">
-                        图片总数: {stats.pictures_count} | 单词总数: {stats.words_count}
+                        词库 {stats.words_count} 词 · 已学 {stats.learned_count}
                     </p>
                     <button className="logout-btn" onClick={() => { logout(); window.location.reload(); }}>退出</button>
                 </div>
@@ -149,84 +115,47 @@ function Home() {
                 </div>
             )}
 
-            {blocking && (
-                <div className="sync-block-tip">同步完成后再开始新的学习</div>
-            )}
+            <div className="batch-dashboard">
+                <div className="batch-headline">
+                    <span className="batch-title">今晚任务</span>
+                    <span className="batch-est">预计 {estMin || '--'} 分钟</span>
+                </div>
+                <p className="batch-note">今日计划生成后不再加码;到 15 分钟自动停止追加。</p>
 
-            <div className="action-bar">
-                <button className="toggle-btn" onClick={() => setShowAll(!showAll)}>
-                    {showAll ? "➖ 隐藏已完成组" : "➕ 显示已完成的组"}
-                </button>
-                <button className="toggle-btn" onClick={() => navigate('/calendar')}>
-                    📅 学习日历
-                </button>
+                <div className="batch-cards">
+                    <div className="batch-card new-card">
+                        <div className="bc-label">新词</div>
+                        <div className="bc-num">{plannedNew || '--'}<span className="bc-unit">个</span></div>
+                        <div className="bc-sub">{plannedSpell} 个拼写 / {plannedRecognize} 个识别</div>
+                    </div>
+                    <div className="batch-card review-card">
+                        <div className="bc-label">到期复习</div>
+                        <div className="bc-num">{plannedReview || 0}<span className="bc-unit">个</span></div>
+                        <div className="bc-sub">错词优先 · 未完成自动顺延</div>
+                    </div>
+                    <div className="batch-card quiz-card">
+                        <div className="bc-label">综合训练</div>
+                        <div className="bc-num">{plannedQuiz}<span className="bc-unit">题</span></div>
+                        <div className="bc-sub">3 道今日词 / 2 道复习词</div>
+                    </div>
+                </div>
+
+                {plan ? (
+                    <button className="action-btn start-batch-btn" onClick={startTask}>开始今日任务</button>
+                ) : (
+                    <button className="action-btn start-batch-btn" onClick={generatePlan} disabled={creating}>
+                        {creating ? '生成中…' : '生成今日计划'}
+                    </button>
+                )}
             </div>
 
-            <div className="table-container">
-                <table className="progress-table">
-                    <thead>
-                        <tr>
-                            <th>组别</th>
-                            <th>基础学习</th>
-                            <th>单元测试</th>
-                            <th>复习 I</th>
-                            <th>复习 II</th>
-                            <th>复习 III</th>
-                        </tr>
-                    </thead>
-                    <tbody>
-                        {filteredData.map((item, index) => (
-                            <tr key={index}>
-                                <td
-                                    className="group-name"
-                                    onClick={() => handleCellClick(item, 'group')}
-                                    style={{ cursor: 'pointer' }}
-                                >
-                                    {item.group}
-                                </td>
-                                <td 
-                                    className={getStatusClass(item.study)}
-                                    onClick={() => handleCellClick(item, 'study')}
-                                    style={{ cursor: item.study.includes('请开始学习') ? 'pointer' : 'default' }}
-                                >
-                                    {item.study}
-                                </td>
-                                <td
-                                    className={getStatusClass(item.test)}
-                                    onClick={() => handleCellClick(item, 'test')}
-                                    style={{ cursor: item.test && item.test.includes('请开始') ? 'pointer' : 'default' }}
-                                >
-                                    {item.test}
-                                </td>
-                                <td
-                                    className={getStatusClass(item.review1)}
-                                    onClick={() => handleCellClick(item, 'review1')}
-                                    style={{ cursor: item.review1 && (item.review1.includes('请开始') || item.review1.includes('请尽快')) ? 'pointer' : 'default' }}
-                                >
-                                    {item.review1}
-                                </td>
-                                <td
-                                    className={getStatusClass(item.review2)}
-                                    onClick={() => handleCellClick(item, 'review2')}
-                                    style={{ cursor: item.review2 && (item.review2.includes('请开始') || item.review2.includes('请尽快')) ? 'pointer' : 'default' }}
-                                >
-                                    {item.review2}
-                                </td>
-                                <td
-                                    className={getStatusClass(item.review3)}
-                                    onClick={() => handleCellClick(item, 'review3')}
-                                    style={{ cursor: item.review3 && (item.review3.includes('请开始') || item.review3.includes('请尽快')) ? 'pointer' : 'default' }}
-                                >
-                                    {item.review3}
-                                </td>
-                            </tr>
-                        ))}
-                    </tbody>
-                </table>
+            <div className="action-bar">
+                <button className="toggle-btn" onClick={() => navigate('/calendar')}>📅 学习日历</button>
+                <button className="toggle-btn" onClick={() => navigate('/group/1')}>📚 词库浏览</button>
             </div>
 
             <footer className="footer">
-                <button className="error-correction-btn">进入消错模式</button>
+                <button className="error-correction-btn" onClick={() => navigate('/calendar')}>查看学习记录</button>
             </footer>
         </div>
     );
index 4c9712d659b21f90e12de265b4be72f58bb058c3..1d6556dc97fef60b28362e98acfd5654e7d49d9a 100644 (file)
         font-size: 20px;
     }
 }
+
+/* ---- V2 日历格子/详情 ---- */
+.cal-batch { display:flex; gap:3px; flex-wrap:wrap; margin-top:2px; }
+.cb { font-size:10px; padding:1px 4px; border-radius:6px; color:#fff; }
+.cb.new { background:#2563eb; }
+.cb.rev { background:#5b7c99; }
+.cb.q { background:#8a9ba8; }
+.cal-day.legacy { opacity:0.7; }
+.cal-day.legacy .cal-day-num { color:#94a3b8; }
+.cal-detail-mask { position:fixed; inset:0; background:rgba(0,0,0,0.4); display:flex; align-items:center; justify-content:center; z-index:50; }
+.cal-detail { background:#fff; border-radius:14px; padding:22px; width:90%; max-width:420px; }
+.cal-detail h2 { margin-top:0; }
+.cd-row { display:flex; justify-content:space-between; padding:6px 0; border-bottom:1px solid #f1f5f9; }
+.cd-weak { margin-top:10px; }
+.cd-weak-title { font-weight:600; margin-bottom:6px; }
+.cd-weak-item { color:#ef4444; font-size:14px; }
index dceb13dba2a2ec04a3dbc3bdf05ea4e9b3a5ba3b..c772ae06cf3a9dd56248b7c3244020f42454903b 100644 (file)
@@ -1,53 +1,35 @@
 import React, { useState, useEffect } from 'react';
 import { useNavigate } from 'react-router-dom';
 import { api } from '../api';
-import { getAllProgress } from '../db';
 import './StudyCalendar.css';
 
-const OP_LABEL = { study: '学', test: '测', review1: 'R1', review2: 'R2', review3: 'R3' };
-const OP_COLOR = { study: '#ea580c', test: '#f97316', review1: '#5b7c99', review2: '#6b8e7a', review3: '#8a9ba8' };
 const WEEKDAYS = ['一', '二', '三', '四', '五', '六', '日'];
-const OP_ORDER = ['study', 'test', 'review1', 'review2', 'review3'];
 
 function pad(n) { return String(n).padStart(2, '0'); }
+function fmtDur(min) {
+    if (!min) return '';
+    const h = Math.floor(min / 60), m = min % 60;
+    if (h > 0) return `${h}小时${m}分`;
+    return `${m}分`;
+}
 
 function StudyCalendar() {
     const navigate = useNavigate();
     const [dayMap, setDayMap] = useState({});
+    const [detail, setDetail] = useState(null);
 
     useEffect(() => {
-        api.getCalendar()
+        api.calendarV2()
             .then(res => {
                 const m = {};
                 res.data.forEach(item => {
-                    m[item.date] = { activities: item.activities, durationMin: item.duration_min || 0 };
+                    m[item.date] = item;
                 });
                 setDayMap(m);
             })
             .catch(async (err) => {
-                // 离线降级: 从 IndexedDB progress 本地按日聚合 (复用后端 calendar 逻辑)
-                console.error("获取日历失败, 降级读离线缓存:", err);
-                const allProgress = await getAllProgress();
-                const tmp = {};
-                const dayDurSec = {};   // day -> 累计学习秒数(离线按本地 duration_sec 求和, 与后端一致)
-                allProgress.forEach(p => {
-                    const day = (p.completed_at || '').slice(0, 10);
-                    if (!day) return;
-                    if (!tmp[day]) tmp[day] = {};
-                    if (!tmp[day][p.op_type]) tmp[day][p.op_type] = [];
-                    const g = String(p.group_id);
-                    if (!tmp[day][p.op_type].includes(g)) tmp[day][p.op_type].push(g);
-                    if (!dayDurSec[day]) dayDurSec[day] = 0;
-                    dayDurSec[day] += (p.duration_sec || 0);
-                });
-                const m = {};
-                Object.keys(tmp).forEach(day => {
-                    const activities = OP_ORDER.filter(op => tmp[day][op]).map(op => ({ op_type: op, groups: tmp[day][op] }));
-                    const totalSec = dayDurSec[day] || 0;
-                    const durationMin = totalSec > 0 ? Math.round(totalSec / 60) : 0;
-                    m[day] = { activities, durationMin };
-                });
-                setDayMap(m);
+                console.error('日历获取失败:', err);
+                setDayMap({});
             });
     }, []);
 
@@ -55,64 +37,67 @@ function StudyCalendar() {
     const todayStr = `${today.getFullYear()}-${pad(today.getMonth() + 1)}-${pad(today.getDate())}`;
     const currentMonth = `${today.getFullYear()}-${pad(today.getMonth() + 1)}`;
 
-    // 月份渲染顺序: 当前月 + 有记录月份(去除当前月, 倒序)
-    const recordMonths = Object.keys(dayMap).map(d => d.slice(0, 7));
-    const uniqueRecordMonths = Array.from(new Set(recordMonths)).sort().reverse();
-    const renderMonths = [currentMonth, ...uniqueRecordMonths.filter(m => m !== currentMonth)];
+    const recordMonths = Array.from(new Set(Object.keys(dayMap).map(d => d.slice(0, 7)))).sort().reverse();
+    const renderMonths = [currentMonth, ...recordMonths.filter(m => m !== currentMonth)];
+
+    // 月标题统计(V2 口径:学习天数 + 总时长 + 新词数)
+    function monthSummary(monthStr) {
+        let days = 0, totalMin = 0, newWords = 0;
+        Object.keys(dayMap).forEach(d => {
+            if (d.slice(0, 7) === monthStr) {
+                const it = dayMap[d];
+                const hasData = (it.new_words || it.review_words || it.quiz_total || it.duration_min);
+                if (it.legacy && !hasData) { days++; totalMin += it.duration_min || 0; return; }
+                if (hasData) { days++; totalMin += it.duration_min || 0; newWords += it.new_words || 0; }
+            }
+        });
+        return { days, totalMin, newWords };
+    }
 
     function renderMonth(monthStr) {
         const [yStr, mStr] = monthStr.split('-');
         const y = parseInt(yStr), m = parseInt(mStr);
-        const firstDay = new Date(y, m - 1, 1).getDay();      // 0=Sun
-        const offset = (firstDay + 6) % 7;                      // 周一起始的空格数
+        const firstDay = new Date(y, m - 1, 1).getDay();
+        const offset = (firstDay + 6) % 7; // 周一起始
         const daysInMonth = new Date(y, m, 0).getDate();
-        // 统计当月学习的 group 数(op_type='study' 的不同 group)
-        const studiedGroups = new Set();
-        Object.keys(dayMap).forEach(dateStr => {
-            if (dateStr.slice(0, 7) === monthStr) {
-                const info = dayMap[dateStr];
-                if (info && info.activities) info.activities.forEach(a => {
-                    if (a.op_type === 'study') a.groups.forEach(g => studiedGroups.add(g));
-                });
-            }
-        });
-        const studiedCount = studiedGroups.size;
         const cells = [];
         for (let i = 0; i < offset; i++) cells.push(null);
         for (let d = 1; d <= daysInMonth; d++) cells.push(d);
         while (cells.length % 7 !== 0) cells.push(null);
 
+        const sum = monthSummary(monthStr);
         return (
             <div className="cal-month" key={monthStr}>
                 <div className="cal-month-title">
-                    {y}年 {m}月 · <span className="cal-month-count">(学习了{studiedCount}个group)</span>
+                    {y}年 {m}月 · <span className="cal-month-count">学习 {sum.days} 天 · {fmtDur(sum.totalMin)} · 新词 {sum.newWords} 个</span>
                 </div>
                 <div className="cal-grid">
                     {WEEKDAYS.map(w => <div className="cal-weekday" key={w}>{w}</div>)}
                     {cells.map((d, i) => {
                         if (d === null) return <div className="cal-day empty" key={i} />;
                         const dateStr = `${monthStr}-${pad(d)}`;
-                        const dayInfo = dayMap[dateStr];
-                        const acts = dayInfo ? dayInfo.activities : undefined;
-                        const durationMin = dayInfo ? dayInfo.durationMin : 0;
-                        const isActive = !!acts;
+                        const info = dayMap[dateStr];
+                        const dur = info ? info.duration_min : 0;
+                        const hasData = info && (info.new_words || info.review_words || info.quiz_total || dur);
+                        const isActive = !!hasData;
                         const isToday = dateStr === todayStr;
                         const cls = ['cal-day'];
                         if (isActive) cls.push('active');
+                        if (info && info.legacy) cls.push('legacy');
                         if (isToday) cls.push('today');
                         return (
-                            <div className={cls.join(' ')} key={i}>
+                            <div className={cls.join(' ')} key={i} onClick={() => info && hasData ? openDetail(dateStr) : null}>
                                 <div className="cal-day-head">
-                                    {durationMin > 0 && (
-                                        <span className="cal-duration">{durationMin}分钟</span>
-                                    )}
+                                    {dur > 0 && <span className="cal-duration">{dur}分</span>}
                                     <div className="cal-day-num">{d}</div>
                                 </div>
-                                {acts && acts.map((a, j) => (
-                                    <div className="cal-act" key={j} style={{ color: OP_COLOR[a.op_type] || '#475569' }}>
-                                        {OP_LABEL[a.op_type] || a.op_type} G{a.groups.join(',')}
+                                {info && (
+                                    <div className="cal-batch">
+                                        {info.new_words > 0 && <span className="cb new">新{info.new_words}</span>}
+                                        {info.review_words > 0 && <span className="cb rev">复{info.review_words}</span>}
+                                        {info.quiz_total > 0 && <span className="cb q">题{info.quiz_total}</span>}
                                     </div>
-                                ))}
+                                )}
                             </div>
                         );
                     })}
@@ -121,12 +106,46 @@ function StudyCalendar() {
         );
     }
 
+    async function openDetail(dateStr) {
+        try {
+            const r = await api.calendarV2Day(dateStr);
+            setDetail(r.data);
+        } catch (e) {
+            console.error(e);
+        }
+    }
+
     return (
         <div className="cal-page">
             <div className="cal-container">
                 <h1 className="cal-title">📅 学习日历</h1>
                 {renderMonths.map(renderMonth)}
             </div>
+
+            {detail && (
+                <div className="cal-detail-mask" onClick={() => setDetail(null)}>
+                    <div className="cal-detail" onClick={(e) => e.stopPropagation()}>
+                        <h2>{detail.date}</h2>
+                        <div className="cd-row"><span>主动学习</span><b>{fmtDur(detail.duration_min)}</b></div>
+                        <div className="cd-row"><span>新词 / 拼写 / 识别</span><b>{detail.new_words} · {detail.spell_words} / {detail.recognize_words}</b></div>
+                        <div className="cd-row"><span>复习</span><b>{detail.review_words}</b></div>
+                        <div className="cd-row"><span>综合题</span><b>{detail.quiz_total}(对 {detail.quiz_correct})</b></div>
+                        {detail.groups_touched && detail.groups_touched.length > 0 && (
+                            <div className="cd-row"><span>涉及章节</span><b>G{detail.groups_touched.join(' / G')}</b></div>
+                        )}
+                        {detail.weak_points && detail.weak_points.length > 0 && (
+                            <div className="cd-weak">
+                                <div className="cd-weak-title">需要加强:</div>
+                                {detail.weak_points.map((w, i) => (
+                                    <div key={i} className="cd-weak-item">{w.word} <span className="weak-why">({w.wrong_types.join('/')})</span></div>
+                                ))}
+                            </div>
+                        )}
+                        <button className="action-btn" onClick={() => setDetail(null)}>关闭</button>
+                    </div>
+                </div>
+            )}
+
             <button className="back-home" onClick={() => navigate('/')}>返回首页</button>
         </div>
     );
index 360a7e4b216552fa45b6c2f0433c4775473baf24..e2603bfc7edd815e464ba881a75f1bcf50118735 100644 (file)
@@ -253,3 +253,9 @@ input:focus {
         padding: 16px 12px;
     }
 }
+
+/* ---- V2 选项/综合题 ---- */
+.pos-options, .quiz-options { display:flex; flex-direction:column; gap:10px; margin:12px 0; }
+.pos-opt, .quiz-opt { width:100%; text-align:left; }
+.quiz-opt.selected { background:#2563eb; color:#fff; }
+.quiz-note { color:#94a3b8; font-size:12px; margin-top:8px; }
diff --git a/frontend/src/components/StudyMode.js b/frontend/src/components/StudyMode.js
deleted file mode 100644 (file)
index 52a1de7..0000000
+++ /dev/null
@@ -1,246 +0,0 @@
-import React, { useState, useEffect, useCallback, useRef } from 'react';
-import { useParams, useNavigate } from 'react-router-dom';
-import { api, localTimestamp } from '../api';
-import { useOnlineStatus } from '../hooks/useOnlineStatus';
-import { getWordsByGroup, putProgress, getLearnableGroups } from '../db';
-import { triggerAutoSync } from '../sync';
-import './StudyMode.css';
-
-function StudyMode() {
-    const { groupId } = useParams();
-    const navigate = useNavigate();
-    const isOnline = useOnlineStatus();
-    // 学习会话开始时间(毫秒时间戳), 用于计算本次学习的实际时长
-    const sessionStartRef = useRef(Date.now());
-    const [words, setWords] = useState([]);
-    const [currentIndex, setCurrentIndex] = useState(0);
-    const [phase, setPhase] = useState('learning'); // learning, flashcard, correction
-    const [userInput, setUserInput] = useState("");
-    const [correctionInput, setCorrectionInput] = useState("");
-    const [isSpeaking, setIsSpeaking] = useState(false);
-
-    const hasSpokenRef = useRef(null);
-    const transitioningRef = useRef(false);
-    const submittedRef = useRef(false);  // 防止完成时重复提交进度
-    const currentWord = words[currentIndex];
-
-    // 朗读逻辑:通过后端 TTS 代理(有道美式发音)播放两遍,间隔 500ms
-    // 朗读期间 isSpeaking=true,阻止进入闪记模式
-    const speak = useCallback((text) => {
-        if (!text) return;
-        const url = api.ttsUrl(text);
-        setIsSpeaking(true);
-        const finish = () => setIsSpeaking(false);
-        const audio1 = new Audio(url);
-        audio1.play().catch(err => {
-            console.error("TTS play failed:", err);
-            finish();
-        });
-        audio1.onended = () => {
-            setTimeout(() => {
-                const audio2 = new Audio(url);
-                audio2.play().catch(err => {
-                    console.error("TTS play failed:", err);
-                    finish();
-                });
-                audio2.onended = finish;
-            }, 500);
-        };
-    }, []);
-
-    // 离线时检查可学习范围 (规格 7: 仅未完成组 + 最后完成组后5组可离线学习)
-    useEffect(() => {
-        if (isOnline) return;  // 在线时所有组均可学
-        getLearnableGroups().then(groups => {
-            if (!groups.includes(Number(groupId))) {
-                alert('该组不可离线学习(仅未完成组与最后完成组后的5组可离线学习)');
-                navigate('/');
-            }
-        });
-    }, [isOnline, groupId, navigate]);
-
-    // 获取单词数据 (在线走网络, 离线降级读 IndexedDB)
-    useEffect(() => {
-        api.getWords(groupId)
-            .then(res => setWords(res.data))
-            .catch(async () => {
-                const local = await getWordsByGroup(groupId);
-                setWords(local);
-            });
-    }, [groupId]);
-
-    // 自动朗读逻辑
-    useEffect(() => {
-        if (currentWord && phase === 'learning') {
-            if (hasSpokenRef.current !== currentWord.id) {
-                speak(currentWord.word);
-                hasSpokenRef.current = currentWord.id;
-            }
-        }
-    }, [currentIndex, currentWord, phase, speak]);
-
-    // 全局键盘监听:在学习模式下按回车进入闪记(朗读中禁用)
-    useEffect(() => {
-        const handleGlobalKeyDown = (event) => {
-            if (transitioningRef.current) return;
-            if (isSpeaking) return;
-            if (phase === 'learning' && event.key === 'Enter') {
-                setPhase('flashcard');
-            }
-        };
-        window.addEventListener('keydown', handleGlobalKeyDown);
-        return () => window.removeEventListener('keydown', handleGlobalKeyDown);
-    }, [phase, isSpeaking]);
-
-    const handleNext = async () => {
-        transitioningRef.current = true;
-        if (currentIndex < words.length - 1) {
-            setCurrentIndex(currentIndex + 1);
-            setPhase('learning');
-            setUserInput("");
-            setCorrectionInput("");
-            setTimeout(() => { transitioningRef.current = false; }, 300);
-        } else {
-            if (submittedRef.current) return;   // 防重入(按住Enter等场景)
-            submittedRef.current = true;
-            const now = localTimestamp();
-            // 本次学习实际时长(秒) = 完成时刻 - 会话开始时刻
-            const durationSec = Math.max(0, Math.round((Date.now() - sessionStartRef.current) / 1000));
-            // 本地优先: 先写本地 dirty (必定保存, 不丢记录)
-            await putProgress(groupId, 'study', now, true, durationSec);
-            // 在线则后台自动同步 (成功清 dirty, 失败保留)
-            if (isOnline) triggerAutoSync();
-            alert("恭喜完成本组学习!");
-            navigate('/');
-        }
-    };
-
-    const checkFlashcard = () => {
-        if (userInput.toLowerCase().trim() === currentWord.word.toLowerCase().trim()) {
-            handleNext();
-        } else {
-            setUserInput("");           // 清空闪记输入框,确保回到闪记时为空
-            setPhase('correction');
-            setCorrectionInput("");
-            // 错误时读一遍正确发音提醒
-            const audio = new Audio(api.ttsUrl(currentWord.word));
-            audio.play().catch(err => console.error("TTS play failed:", err));
-        }
-    };
-
-    // 抄写正确后回到闪记模式重试
-    const retryFlashcard = () => {
-        setUserInput("");
-        setPhase('flashcard');
-    };
-
-    if (!currentWord) return <div className="loading">加载中...</div>;
-
-    // 阻止复制/剪切/粘贴/右键,防止单词被复制后粘贴作弊
-    const blockClipboard = (e) => e.preventDefault();
-
-    // 只保留 ASCII 可见字符并转小写,过滤中文/全角/emoji
-    // iPad Safari 横屏可能默认中文输入法联想,此过滤保证最终值始终为英文小写
-    const sanitize = (s) => s.replace(/[^\x20-\x7E]/g, '').toLowerCase();
-
-    // 输入框统一属性:强制英文输入、禁用中文输入法/自动大写/自动补全/粘贴
-    const inputProps = {
-        type: "text",
-        lang: "en",
-        inputMode: "latin",          // 唤起英文/拉丁字母软键盘
-        autoComplete: "off",
-        autoCorrect: "off",
-        autoCapitalize: "off",       // 首字母不大写
-        spellCheck: false,
-        onPaste: blockClipboard,
-        onContextMenu: blockClipboard,
-        onCompositionStart: blockClipboard, // 阻止输入法联想
-    };
-
-    return (
-        <div
-            className="study-container"
-            onCopy={blockClipboard}
-            onCut={blockClipboard}
-            onPaste={blockClipboard}
-            onContextMenu={blockClipboard}
-        >
-            <div className="study-card">
-                {/* 只有在非闪记模式下显示图片 */}
-                {phase !== 'flashcard' && (
-                    <div className="image-section">
-                        {currentWord.picture_id && (
-                            <img src={api.pictureUrl(currentWord.picture_id)} alt="mindmap" />
-                        )}
-                    </div>
-                )}
-
-                <div className="content-section">
-                    <div className="progress-tag">Group {groupId}: {currentIndex + 1} / {words.length}</div>
-                    
-                    {phase === 'learning' ? (
-                        <div className="info-area">
-                            <h1 className="word-text">{currentWord.word}</h1>
-                            <div className="meaning-row">
-                                <span className="chinese">{currentWord.chinese_meaning}</span>
-                                <span className="pos">({currentWord.part_of_speech})</span>
-                            </div>
-                            
-                            <div className="details-anim">
-                                {currentWord.word_root && <p className="root"><strong>词根:</strong> {currentWord.word_root}</p>}
-                                {currentWord.affix && <p className="affix"><strong>组成:</strong> {currentWord.affix}</p>}
-                                <button
-                                    className="action-btn"
-                                    onClick={() => setPhase('flashcard')}
-                                    disabled={isSpeaking}
-                                >
-                                    {isSpeaking ? "朗读中,请稍候..." : "进入闪记模式 (Enter)"}
-                                </button>
-                            </div>
-                        </div>
-                    ) : phase === 'flashcard' ? (
-                        <div className="flashcard-area">
-                            <h2 className="flashcard-title">闪记模式:请拼写单词</h2>
-                            <p className="flashcard-hint">中文含义:{currentWord.chinese_meaning}</p>
-                            <input
-                                autoFocus
-                                value={userInput}
-                                onChange={(e) => setUserInput(sanitize(e.target.value))}
-                                onKeyDown={(e) => e.key === 'Enter' && checkFlashcard()}
-                                placeholder="输入英文单词..."
-                                {...inputProps}
-                            />
-                            <button className="action-btn" onClick={checkFlashcard}>检查 (Enter)</button>
-                        </div>
-                    ) : (
-                        <div className="correction-area">
-                            <h2 className="error-tip">回答错误!正确答案:{currentWord.word}</h2>
-                            <p>请抄写一遍以解锁:</p>
-                            <input
-                                autoFocus
-                                className={correctionInput.toLowerCase() === currentWord.word.toLowerCase() ? "correct" : "typing"}
-                                value={correctionInput}
-                                onChange={(e) => setCorrectionInput(sanitize(e.target.value))}
-                                onKeyDown={(e) => {
-                                    if (e.key === 'Enter' && correctionInput.toLowerCase() === currentWord.word.toLowerCase()) {
-                                        retryFlashcard();
-                                    }
-                                }}
-                                placeholder="请在此抄写..."
-                                {...inputProps}
-                            />
-                            {correctionInput.toLowerCase() === currentWord.word.toLowerCase() && (
-                                <button className="action-btn" onClick={retryFlashcard}>
-                                    重新尝试 (Enter)
-                                </button>
-                            )}
-                        </div>
-                    )}
-                </div>
-            </div>
-            <button className="back-home" onClick={() => navigate('/')}>返回首页</button>
-        </div>
-    );
-}
-
-export default StudyMode;
index c273619823038bce2ab34c8bf5345aac4e4ba80d..d316c44726ea669178cfdc6b1d448898d810c73e 100644 (file)
@@ -1,6 +1,6 @@
 import React, { useState, useEffect, useRef } from 'react';
 import { useOnlineStatus } from '../hooks/useOnlineStatus';
-import { getDirtyProgress, getMeta } from '../db';
+import { getUnsyncedEvents, getUnsyncedSessions, getMeta } from '../db';
 import { triggerAutoSync, subscribeSyncStatus, getSyncStatus } from '../sync';
 import './SyncNotifications.css';
 
@@ -19,7 +19,9 @@ export default function SyncNotifications() {
     // 轮询 dirty / 初始化状态
     useEffect(() => {
         const check = async () => {
-            setDirtyCount((await getDirtyProgress()).length);
+            const e = await getUnsyncedEvents();
+            const s = await getUnsyncedSessions();
+            setDirtyCount(e.length + s.length);
             setInitialized(!!(await getMeta('lastSyncAt')));
         };
         check();
diff --git a/frontend/src/components/TestMode.js b/frontend/src/components/TestMode.js
deleted file mode 100644 (file)
index f228eba..0000000
+++ /dev/null
@@ -1,374 +0,0 @@
-import React, { useState, useEffect, useCallback, useRef } from 'react';
-import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
-import { api, localTimestamp } from '../api';
-import { useOnlineStatus } from '../hooks/useOnlineStatus';
-import { getWordsByGroup, putProgress, getLearnableGroups } from '../db';
-import { triggerAutoSync } from '../sync';
-import './TestMode.css';
-
-function TestMode() {
-    const { groupId } = useParams();
-    const navigate = useNavigate();
-    const [searchParams] = useSearchParams();
-    // 活动类型:test / review1 / review2 / review3(首页点击复习时通过 ?type= 传入;缺省为 test)
-    const activityType = searchParams.get('type') || 'test';
-    const isOnline = useOnlineStatus();
-    // 测试/复习会话开始时间(毫秒时间戳), 用于计算本次学习的实际时长
-    const sessionStartRef = useRef(Date.now());
-    const [words, setWords] = useState([]);
-    const [queue, setQueue] = useState([]);        // 当前测试队列
-    const [wrongQueue, setWrongQueue] = useState([]); // 错词重考队列
-    const [currentIndex, setCurrentIndex] = useState(0);
-    const [totalCount, setTotalCount] = useState(0);
-    const [phase, setPhase] = useState('ready');     // ready, testing, feedback, copy, retest_info, complete
-    const [userInput, setUserInput] = useState("");
-    const [copyInput, setCopyInput] = useState("");
-    const [isCorrect, setIsCorrect] = useState(false);
-    const [isRetesting, setIsRetesting] = useState(false);
-
-    const inputRef = useRef(null);
-    const copyRef = useRef(null);
-    const submittedRef = useRef(false);  // 防止完成时重复提交进度
-    const currentWord = queue[currentIndex];
-
-    // 离线时检查可学习范围 (规格 7: 仅未完成组 + 最后完成组后5组可离线学习)
-    useEffect(() => {
-        if (isOnline) return;
-        getLearnableGroups().then(groups => {
-            if (!groups.includes(Number(groupId))) {
-                alert('该组不可离线学习(仅未完成组与最后完成组后的5组可离线学习)');
-                navigate('/');
-            }
-        });
-    }, [isOnline, groupId, navigate]);
-
-    // 获取单词数据 (在线走网络, 离线降级读 IndexedDB)
-    useEffect(() => {
-        api.getWords(groupId)
-            .then(res => setWords(res.data))
-            .catch(async () => {
-                const local = await getWordsByGroup(groupId);
-                setWords(local);
-            });
-    }, [groupId]);
-
-    // 开始测试:随机打乱
-    const startTest = useCallback(() => {
-        const shuffled = [...words].sort(() => Math.random() - 0.5);
-        setQueue(shuffled);
-        setWrongQueue([]);
-        setCurrentIndex(0);
-        setTotalCount(shuffled.length);
-        setPhase('testing');
-        setUserInput("");
-        setCopyInput("");
-        setIsRetesting(false);
-    }, [words]);
-
-    // 自动聚焦输入框
-    useEffect(() => {
-        if (phase === 'testing' && inputRef.current) {
-            inputRef.current.focus();
-        }
-        if (phase === 'copy' && copyRef.current) {
-            copyRef.current.focus();
-        }
-    }, [phase, currentIndex]);
-
-    // 朗读:通过后端 TTS 代理(有道美式发音)播放一遍
-    const speak = useCallback((text) => {
-        if (!text) return;
-        const audio = new Audio(api.ttsUrl(text));
-        audio.play().catch(err => console.error("TTS play failed:", err));
-    }, []);
-
-    // 提交答案
-    const checkAnswer = useCallback(() => {
-        if (!currentWord || !userInput.trim()) return;
-        const correct = currentWord.word.trim().toLowerCase();
-        const answer = userInput.trim().toLowerCase();
-        const ok = answer === correct;
-
-        setIsCorrect(ok);
-        setPhase('feedback');
-        speak(currentWord.word);
-
-        if (!ok) {
-            setWrongQueue(prev => {
-                if (!prev.find(w => w.id === currentWord.id)) {
-                    return [...prev, currentWord];
-                }
-                return prev;
-            });
-        }
-    }, [currentWord, userInput, speak]);
-
-    // 显示抄写区
-    const showCopy = useCallback(() => {
-        setCopyInput("");
-        setPhase('copy');
-    }, []);
-
-    // 下一个词
-    const nextWord = useCallback(() => {
-        if (currentIndex + 1 < queue.length) {
-            setCurrentIndex(prev => prev + 1);
-            setUserInput("");
-            setCopyInput("");
-            setPhase('testing');
-        } else {
-            // 本轮结束
-            if (wrongQueue.length > 0) {
-                // 有错题,进入重考
-                setPhase('retest_info');
-            } else {
-                // 全部通过
-                setPhase('complete');
-            }
-        }
-    }, [currentIndex, queue.length, wrongQueue.length]);
-
-    // 开始重考
-    const startRetest = useCallback(() => {
-        const retestWords = [...wrongQueue].sort(() => Math.random() - 0.5);
-        setQueue(retestWords);
-        setWrongQueue([]);
-        setCurrentIndex(0);
-        setTotalCount(retestWords.length);
-        setUserInput("");
-        setCopyInput("");
-        setPhase('testing');
-        setIsRetesting(true);
-    }, [wrongQueue]);
-
-    // 完成测试,记录进度(op_type 依据活动类型:test / review1 / review2 / review3)
-    const finishTest = useCallback(async () => {
-        if (submittedRef.current) return;   // 防重入(按住Enter等场景)
-        submittedRef.current = true;
-        const now = localTimestamp();
-        // 本次测试/复习实际时长(秒) = 完成时刻 - 会话开始时刻
-        const durationSec = Math.max(0, Math.round((Date.now() - sessionStartRef.current) / 1000));
-        // 本地优先: 先写本地 dirty (必定保存, 不丢记录)
-        await putProgress(groupId, activityType, now, true, durationSec);
-        // 在线则后台自动同步 (成功清 dirty, 失败保留)
-        if (isOnline) triggerAutoSync();
-        navigate('/');
-    }, [groupId, navigate, activityType, isOnline]);
-
-    // 键盘事件
-    const handleKeyDown = useCallback((e) => {
-        if (e.key === 'Enter') {
-            if (phase === 'testing') {
-                checkAnswer();
-            } else if (phase === 'feedback' && isCorrect) {
-                nextWord();
-            } else if (phase === 'copy' && copyInput.toLowerCase() === currentWord.word.toLowerCase()) {
-                nextWord();
-            } else if (phase === 'feedback' && !isCorrect) {
-                showCopy();
-            }
-        }
-    }, [phase, isCorrect, checkAnswer, nextWord, showCopy, copyInput, currentWord]);
-
-    useEffect(() => {
-        window.addEventListener('keydown', handleKeyDown);
-        return () => window.removeEventListener('keydown', handleKeyDown);
-    }, [handleKeyDown]);
-
-    // 阻止复制/剪切/粘贴/右键,防止单词被复制后粘贴作弊
-    const blockClipboard = (e) => e.preventDefault();
-
-    // 只保留 ASCII 可见字符并转小写,过滤中文/全角/emoji,同时压制首字母自动大写
-    // 即使中文输入法开启或系统自动大写,输入框也只显示小写英文
-    const sanitize = (s) => s.replace(/[^\x20-\x7E]/g, '').toLowerCase();
-
-    // 输入框统一属性:强制英文输入、禁用中文输入法/自动大写/自动补全/粘贴
-    const inputProps = {
-        type: "text",
-        lang: "en",
-        inputMode: "latin",          // 唤起英文/拉丁字母软键盘
-        autoComplete: "off",
-        autoCorrect: "off",
-        autoCapitalize: "off",       // 首字母不大写
-        spellCheck: false,
-        onPaste: blockClipboard,
-        onContextMenu: blockClipboard,
-        onCompositionStart: blockClipboard, // 阻止输入法联想
-    };
-
-    // 准备阶段
-    if (words.length === 0) return <div className="loading">加载中...</div>;
-
-    if (phase === 'ready') {
-        return (
-            <div
-                className="test-container"
-                onCopy={blockClipboard}
-                onCut={blockClipboard}
-                onPaste={blockClipboard}
-                onContextMenu={blockClipboard}
-            >
-                <div className="test-card test-start-card">
-                    <h1>单元测试 - Group {groupId}</h1>
-                    <p className="test-info">共 {words.length} 个单词</p>
-                    <p className="test-desc">根据中文含义拼写英文单词,拼写错误需要抄写纠正</p>
-                    <button className="action-btn start-btn" onClick={startTest}>开始测试</button>
-                    <button className="back-home" onClick={() => navigate('/')}>返回首页</button>
-                </div>
-            </div>
-        );
-    }
-
-    if (phase === 'complete') {
-        return (
-            <div
-                className="test-container"
-                onCopy={blockClipboard}
-                onCut={blockClipboard}
-                onPaste={blockClipboard}
-                onContextMenu={blockClipboard}
-            >
-                <div className="test-card test-complete-card">
-                    <h1>🎉 测试完成!</h1>
-                    <p>Group {groupId} 全部通过!</p>
-                    <button className="action-btn" onClick={finishTest} disabled={submittedRef.current}>完成</button>
-                </div>
-            </div>
-        );
-    }
-
-    if (phase === 'retest_info') {
-        return (
-            <div
-                className="test-container"
-                onCopy={blockClipboard}
-                onCut={blockClipboard}
-                onPaste={blockClipboard}
-                onContextMenu={blockClipboard}
-            >
-                <div className="test-card test-retest-card">
-                    <h1>📝 开始重考</h1>
-                    <p>还有 {wrongQueue.length} 个错题需要重考</p>
-                    <button className="action-btn" onClick={startRetest}>开始重考</button>
-                </div>
-            </div>
-        );
-    }
-
-    if (!currentWord) return null;
-
-    return (
-        <div
-            className="test-container"
-            onCopy={blockClipboard}
-            onCut={blockClipboard}
-            onPaste={blockClipboard}
-            onContextMenu={blockClipboard}
-        >
-            <div className="test-card">
-                {/* 状态栏 */}
-                <div className="test-header">
-                    <span className="test-status">
-                        {isRetesting ? '重考' : '测试'} - Group {groupId}
-                    </span>
-                    <span className="test-progress">
-                        {currentIndex + 1} / {totalCount}
-                    </span>
-                </div>
-
-                {/* 答题区域 */}
-                <div className="test-body">
-                    {phase === 'testing' && (
-                        <div className="testing-area">
-                            <div className="prompt-info">
-                                <p className="prompt-chinese">{currentWord.chinese_meaning}</p>
-                                <p className="prompt-pos">({currentWord.part_of_speech})</p>
-                            </div>
-                            <div className="input-row">
-                                <input
-                                    ref={inputRef}
-                                    value={userInput}
-                                    onChange={(e) => setUserInput(sanitize(e.target.value))}
-                                    placeholder="请输入英文单词..."
-                                    className="test-input"
-                                    {...inputProps}
-                                />
-                                <button className="action-btn" onClick={checkAnswer}>提交</button>
-                            </div>
-                        </div>
-                    )}
-
-                    {/* 反馈区域 */}
-                    {(phase === 'feedback' || phase === 'copy') && (
-                        <div className="feedback-wrapper">
-                            <div className="feedback-main">
-                                <div className={`fb-status ${isCorrect ? 'correct' : 'wrong'}`}>
-                                    {isCorrect ? '✅ 正确!' : `❌ 错误,你的答案: ${userInput}`}
-                                </div>
-
-                                <div className="fb-word">{currentWord.word}</div>
-                                <div className="fb-chinese">中文: {currentWord.chinese_meaning}</div>
-                                <div className="fb-pos">词性: {currentWord.part_of_speech}</div>
-
-                                {currentWord.word_root && (
-                                    <div className="fb-root">词根: {currentWord.word_root}</div>
-                                )}
-                                {currentWord.affix && (
-                                    <div className="fb-affix">组成: {currentWord.affix}</div>
-                                )}
-                                {currentWord.example_sentence && (
-                                    <div className="fb-sentence">例句: {currentWord.example_sentence}</div>
-                                )}
-
-                                {!isCorrect && phase === 'feedback' && (
-                                    <button className="action-btn copy-trigger" onClick={showCopy}>
-                                        抄写纠正 (Enter)
-                                    </button>
-                                )}
-
-                                {isCorrect && (
-                                    <button className="action-btn" onClick={nextWord}>
-                                        下一词 (Enter)
-                                    </button>
-                                )}
-                            </div>
-
-                            {/* 图片 */}
-                            {currentWord.picture_id && (
-                                <div className="fb-image">
-                                    <img src={api.pictureUrl(currentWord.picture_id)} alt="mindmap" />
-                                </div>
-                            )}
-                        </div>
-                    )}
-
-                    {/* 抄写纠正区域 */}
-                    {phase === 'copy' && (
-                        <div className="copy-area">
-                            <label className="copy-label">抄写: </label>
-                            <input
-                                ref={copyRef}
-                                value={copyInput}
-                                onChange={(e) => setCopyInput(sanitize(e.target.value))}
-                                className={`copy-input ${copyInput.toLowerCase() === currentWord.word.toLowerCase() ? 'correct' : ''}`}
-                                placeholder="请在此抄写正确单词..."
-                                {...inputProps}
-                            />
-                            <span className={`copy-check ${copyInput.toLowerCase() === currentWord.word.toLowerCase() ? 'ok' : ''}`}>
-                                {copyInput.toLowerCase() === currentWord.word.toLowerCase() ? '✅' : '❌'}
-                            </span>
-                            {copyInput.toLowerCase() === currentWord.word.toLowerCase() && (
-                                <button className="action-btn" onClick={nextWord}>
-                                    下一词 (Enter)
-                                </button>
-                            )}
-                        </div>
-                    )}
-                </div>
-            </div>
-            <button className="back-home" onClick={() => navigate('/')}>返回首页</button>
-        </div>
-    );
-}
-
-export default TestMode;
index 163f44d55b03864a42e924f7c6dd7e4db925e798..4d096fd751d548b40f9ecb358647bb88ced70a7d 100644 (file)
@@ -1,11 +1,14 @@
-// IndexedDB 封装 for PWA 离线学习
-// 三个 store:
-//   - words:    全量词库, keyPath=id, 索引 groupid (供按组查询)
-//   - progress: 离线进度, keyPath=`${group_id}:${op_type}`, {key, group_id, op_type, completed_at, dirty}
-//   - meta:     元数据, keyPath=key (lastSyncAt, learnableGroups[])
+// IndexedDB 封装 for V2 离线学习(Daily Batch 模型)
+// stores:
+//   - words:          全量词库(含 memory_type 等新列), keyPath=id, 索引 groupid
+//   - dailyPlans:     当天冻结计划(含 items), keyPath=plan_id, 索引 plan_date
+//   - learningEvents: 逐词/逐题事件(append-only), keyPath=event_id, 索引 session_id/plan_id, 带 synced 标记
+//   - studySessions:  主动学习时长会话, keyPath=session_id, 带 synced 标记
+//   - wordProgress:   当前掌握状态快照(可在离线展示"已学"), keyPath=word_id
+//   - meta:           元数据, keyPath=key (lastSyncAt 等)
 
 const DB_NAME = 'words-pwa-v2';
-const DB_VERSION = 1;
+const DB_VERSION = 2;
 
 let dbPromise = null;
 
@@ -19,8 +22,20 @@ function openDB() {
                 db.createObjectStore('words', { keyPath: 'id' })
                   .createIndex('groupid', 'groupid', { unique: false });
             }
-            if (!db.objectStoreNames.contains('progress')) {
-                db.createObjectStore('progress', { keyPath: 'key' });
+            if (!db.objectStoreNames.contains('dailyPlans')) {
+                const s = db.createObjectStore('dailyPlans', { keyPath: 'plan_id' });
+                s.createIndex('plan_date', 'plan_date', { unique: false });
+            }
+            if (!db.objectStoreNames.contains('learningEvents')) {
+                const s = db.createObjectStore('learningEvents', { keyPath: 'event_id' });
+                s.createIndex('session_id', 'session_id', { unique: false });
+                s.createIndex('plan_id', 'plan_id', { unique: false });
+            }
+            if (!db.objectStoreNames.contains('studySessions')) {
+                db.createObjectStore('studySessions', { keyPath: 'session_id' });
+            }
+            if (!db.objectStoreNames.contains('wordProgress')) {
+                db.createObjectStore('wordProgress', { keyPath: 'word_id' });
             }
             if (!db.objectStoreNames.contains('meta')) {
                 db.createObjectStore('meta', { keyPath: 'key' });
@@ -32,7 +47,6 @@ function openDB() {
     return dbPromise;
 }
 
-// 通用: 单 store 事务执行, 返回主请求结果
 async function singleReq(store, mode, fn) {
     const db = await openDB();
     return new Promise((resolve, reject) => {
@@ -44,7 +58,6 @@ async function singleReq(store, mode, fn) {
 }
 
 // ---------- words store ----------
-
 export async function putWords(wordsArr) {
     const db = await openDB();
     return new Promise((resolve, reject) => {
@@ -57,7 +70,6 @@ export async function putWords(wordsArr) {
 }
 
 export async function getWordsByGroup(groupId) {
-    // groupid 在 IDB 是数字, URL 参数是字符串, 统一转 Number 查索引
     return singleReq('words', 'readonly', s =>
         s.index('groupid').getAll(IDBKeyRange.only(Number(groupId)))
     );
@@ -71,55 +83,79 @@ export async function getAllWordsFromIDB() {
     return singleReq('words', 'readonly', s => s.getAll());
 }
 
-// ---------- progress store ----------
+// ---------- dailyPlans store ----------
+export async function putDailyPlan(plan) {
+    return singleReq('dailyPlans', 'readwrite', s => s.put(plan));
+}
 
-export async function getProgress(group_id, op_type) {
-    return singleReq('progress', 'readonly', s => s.get(`${group_id}:${op_type}`));
+export async function getDailyPlanByDate(date) {
+    return singleReq('dailyPlans', 'readonly', s =>
+        s.index('plan_date').getAll(IDBKeyRange.only(date))
+    ).then(r => (r && r.length ? r[0] : null));
 }
 
-export async function getAllProgress() {
-    return singleReq('progress', 'readonly', s => s.getAll());
+// ---------- learningEvents store ----------
+export async function putLearningEvent(ev) {
+    return singleReq('learningEvents', 'readwrite', s => s.put(ev));
 }
 
-export async function putProgress(group_id, op_type, completed_at, dirty, duration_sec = 0) {
-    const key = `${group_id}:${op_type}`;
+export async function getUnsyncedEvents() {
+    const all = await singleReq('learningEvents', 'readonly', s => s.getAll());
+    return all.filter(e => !e.synced);
+}
+
+export async function markEventsSynced(ids) {
     const db = await openDB();
     return new Promise((resolve, reject) => {
-        const t = db.transaction('progress', 'readwrite');
-        t.objectStore('progress').put({ key, group_id: String(group_id), op_type, completed_at, dirty, duration_sec: duration_sec || 0 });
+        const t = db.transaction('learningEvents', 'readwrite');
+        const s = t.objectStore('learningEvents');
+        ids.forEach(id => {
+            const r = s.get(id);
+            r.onsuccess = () => {
+                if (r.result) { r.result.synced = true; s.put(r.result); }
+            };
+        });
         t.oncomplete = () => resolve();
         t.onerror = () => reject(t.error);
     });
 }
 
-export async function getDirtyProgress() {
-    const all = await getAllProgress();
-    return all.filter(p => p.dirty);
+// ---------- studySessions store ----------
+export async function putStudySession(session) {
+    return singleReq('studySessions', 'readwrite', s => s.put(session));
+}
+
+export async function getUnsyncedSessions() {
+    const all = await singleReq('studySessions', 'readonly', s => s.getAll());
+    return all.filter(x => !x.synced);
 }
 
-export async function clearDirty(group_id, op_type) {
-    const key = `${group_id}:${op_type}`;
+export async function markSessionsSynced(ids) {
     const db = await openDB();
     return new Promise((resolve, reject) => {
-        const t = db.transaction('progress', 'readwrite');
-        const s = t.objectStore('progress');
-        const req = s.get(key);
-        req.onsuccess = () => {
-            if (req.result) { req.result.dirty = false; s.put(req.result); }
-        };
+        const t = db.transaction('studySessions', 'readwrite');
+        const s = t.objectStore('studySessions');
+        ids.forEach(id => {
+            const r = s.get(id);
+            r.onsuccess = () => {
+                if (r.result) { r.result.synced = true; s.put(r.result); }
+            };
+        });
         t.oncomplete = () => resolve();
         t.onerror = () => reject(t.error);
     });
 }
 
-// 删除某条进度(用于服务端已删除记录回写本地, 实现撤销/未执行)
-export async function deleteProgress(group_id, op_type) {
-    const key = `${group_id}:${op_type}`;
-    return singleReq('progress', 'readwrite', s => s.delete(key));
+// ---------- wordProgress store ----------
+export async function putWordProgress(wp) {
+    return singleReq('wordProgress', 'readwrite', s => s.put(wp));
 }
 
-// ---------- meta store ----------
+export async function getAllWordProgress() {
+    return singleReq('wordProgress', 'readonly', s => s.getAll());
+}
 
+// ---------- meta store ----------
 export async function getMeta(key) {
     const r = await singleReq('meta', 'readonly', s => s.get(key));
     return r ? r.value : undefined;
@@ -135,16 +171,6 @@ export async function setMeta(key, value) {
     });
 }
 
-export async function getLearnableGroups() {
-    return (await getMeta('learnableGroups')) || [];
-}
-
-export async function setLearnableGroups(groups) {
-    return setMeta('learnableGroups', groups);
-}
-
-// 已初始化 = 本地已完成过一次云端初始化 (lastSyncAt 存在)
-// 首页/组件用它判断, 而不是"能不能构造出首页表格"
 export async function isInitialized() {
     return !!(await getMeta('lastSyncAt'));
 }
index 7009d2340cb1fa8396abd750161245acc4b017c2..660397ac4c0db3a3e497232793776028693a861f 100644 (file)
@@ -1,20 +1,17 @@
-// 离线优先 + 自动同步 统一入口
-// 数据规则 (布尔并集): (group,op) 任一方完成即完成; 云端删除已确认进度会回写本地(撤销)
-// dirty = "这条本地完成记录尚未确认上传到云端"
-// syncNow: 上传本地 dirty → 拉云端并集合并 → 更新 lastSyncAt → 重算 learnable
-// triggerAutoSync: 幂等并发锁, 供首页/联网恢复/完成学习 调用, 失败时置 failed
+// V2 离线优先 + 自动同步 统一入口
+// 同步对象: learningEvents(UUID 幂等) / studySessions(UUID upsert) / dailyPlans / wordProgress
+// 不再使用 (group_id, op_type) 去重;事件以 event_id 天然幂等。
 
-import { api } from './api';
+import { api, localTimestamp } from './api';
 import {
-    getDirtyProgress, clearDirty, getAllProgress, putProgress, deleteProgress,
-    putWords, setMeta, getMeta, setLearnableGroups, getAllWordsFromIDB, getWordsCount,
+    getUnsyncedEvents, markEventsSynced,
+    getUnsyncedSessions, markSessionsSynced,
+    putWords, setMeta,
+    putDailyPlan, putWordProgress,
+    getWordsCount,
 } from './db';
 
-const OPS = ['study', 'test', 'review1', 'review2', 'review3'];
-
 // ---------- 同步状态机 (供 Home / SyncNotifications 共享) ----------
-// state: 'idle' | 'syncing' | 'synced' | 'failed'
-// detail: { phase: 'upload' | 'merge', dirty, message, pushed, pulled }
 const listeners = new Set();
 let syncStatus = { state: 'idle', detail: null };
 
@@ -34,221 +31,106 @@ function setSyncStatus(state, detail) {
 
 // ---------- 核心同步 ----------
 export async function syncNow() {
-    const result = { pushed: 0, pulled: 0, warmed: 0, pushedEvents: [], pulledEvents: [] };
-
-    // 阶段1: 上传本地 dirty (严格按 dirty 推送, 非 dirty 视为已确认)
-    const dirty = await getDirtyProgress();
-    setSyncStatus('syncing', { phase: 'upload', dirty: dirty.length });
-    if (dirty.length > 0) {
-        const events = dirty.map(p => ({
-            group_id: p.group_id,
-            op_type: p.op_type,
-            completed_at: p.completed_at,
-            duration_sec: p.duration_sec || 0,
-        }));
-        const r = await api.syncPush(events);
-        result.pushed = r.data.updated || events.length;
-        result.pushedEvents = events;
-        // 仅清"已确认上传"的 dirty (云端最终存在该 group/op 即清)
-        await Promise.all(dirty.map(p => clearDirty(p.group_id, p.op_type)));
+    const result = { pushed: 0, pulled: 0, warmed: 0 };
+    if (typeof navigator !== 'undefined' && !navigator.onLine) {
+        setSyncStatus('failed', { message: '离线,无法同步' });
+        return result;
     }
-
-    // 阶段2: 拉云端进度, 布尔并集合并 (只补不覆盖, 不动本地已有)
-    setSyncStatus('syncing', { phase: 'merge', dirty: 0 });
-    const pullResp = await api.syncPull();
-    const serverEvents = pullResp.data.events || [];
-    const localAll = await getAllProgress();
-    for (const ev of serverEvents) {
-        const key = `${ev.group_id}:${ev.op_type}`;
-        const local = localAll.find(p => p.key === key);
-        if (!local) {
-            // 本地缺失: 直接补入(布尔并集)
-            await putProgress(ev.group_id, ev.op_type, ev.completed_at, false, ev.duration_sec || 0);
-            result.pulled++;
-            result.pulledEvents.push(ev);
-        } else {
-            // 本地已存在: 若云端日期更新(如服务端修正了时区误记),
-            // 用云端日期覆盖本地显示时间, 让首页进度与云端一致(LWW, 只升不降, 按日期比较)
-            const localDate = (local.completed_at || '').slice(0, 10);
-            const serverDate = (ev.completed_at || '').slice(0, 10);
-            if (serverDate > localDate) {
-                await putProgress(ev.group_id, ev.op_type, ev.completed_at, false, ev.duration_sec || 0);
-                result.pulled++;
-                result.pulledEvents.push(ev);
-            }
+    try {
+        setSyncStatus('syncing', { phase: 'upload', dirty: 0 });
+
+        // 阶段1: 上传本地未同步事件
+        const dirtyEvents = await getUnsyncedEvents();
+        if (dirtyEvents.length > 0) {
+            const payload = dirtyEvents.map(e => ({
+                event_id: e.event_id,
+                plan_id: e.plan_id,
+                word_id: e.word_id,
+                group_id: e.group_id,
+                stage: e.stage,
+                question_type: e.question_type,
+                result: e.result,
+                attempt_no: e.attempt_no || 1,
+                duration_ms: e.duration_ms || 0,
+                created_at: e.created_at,
+            }));
+            const r = await api.eventsBatch(dirtyEvents[0].session_id, payload);
+            result.pushed = r.data.inserted || 0;
+            await markEventsSynced(dirtyEvents.map(e => e.event_id));
         }
-    }
 
-    // 阶段2b: 服务端已删除(且本地已确认/非 dirty)的进度, 本地同步删除。
-    // 仅在成功拉取到非空集合时才执行, 避免服务端异常返回空时误删本地进度;
-    // 仅删非 dirty 记录(本地未推送完成的进度绝不删, 保护离线工作)。
-    if (serverEvents.length > 0) {
-        const serverKeys = new Set(serverEvents.map(e => `${e.group_id}:${e.op_type}`));
-        for (const p of localAll) {
-            if (!p.dirty && !serverKeys.has(p.key)) {
-                await deleteProgress(p.group_id, p.op_type);
-            }
+        // 阶段2: 上传未同步会话(主动时长)
+        const dirtySessions = await getUnsyncedSessions();
+        for (const s of dirtySessions) {
+            await api.upsertSession({
+                session_id: s.session_id,
+                plan_id: s.plan_id,
+                log_date: s.log_date,
+                started_at: s.started_at,
+                ended_at: s.ended_at,
+                active_sec: s.active_sec,
+                new_sec: s.new_sec,
+                review_sec: s.review_sec,
+                quiz_sec: s.quiz_sec,
+                completed: s.completed,
+            });
+            result.pushed += 1;
+        }
+        if (dirtySessions.length > 0) {
+            await markSessionsSynced(dirtySessions.map(s => s.session_id));
         }
-    }
 
-    // 阶段3: 首次 warmup 词库 (仅 IDB 空时, 避免每次重拉 1852 词)
-    if ((await getWordsCount()) === 0) {
-        const allWords = await api.getAllWords();
-        await putWords(allWords.data);
-        result.warmed = allWords.data.length;
-    }
+        setSyncStatus('syncing', { phase: 'merge', dirty: 0 });
 
-    // 阶段4: 更新 lastSyncAt + 重算可学习范围
-    await setMeta('lastSyncAt', new Date().toISOString());
-    await recomputeLearnableGroups();
+        // 阶段3: 首次 warmup 词库 (仅 IDB 空时)
+        if ((await getWordsCount()) === 0) {
+            const allWords = await api.wordsV2All();
+            await putWords(allWords.data);
+            result.warmed = allWords.data.length;
+        }
 
-    // 阶段5: 后台预取离线资源(思维导图+语音), 不阻塞同步完成
-    prefetchLearnableAssets().catch(() => {});
+        // 阶段4: 拉取 word_progress 快照(离线展示"已学")
+        const wp = await api.progressWords();
+        if (wp.data && wp.data.length) {
+            for (const p of wp.data) await putWordProgress(p);
+            result.pulled = wp.data.length;
+        }
 
-    setSyncStatus('synced', { pushed: result.pushed, pulled: result.pulled });
+        // 阶段5: 拉取今天计划(若存在)写入本地,离线可继续
+        const today = localTimestamp().slice(0, 10);
+        try {
+            const plan = await api.dailyPlan(today);
+            if (plan.data) await putDailyPlan({ ...plan.data, plan_date: today });
+            result.pulledPlan = !!plan.data;
+        } catch (e) {
+            // 404: 今天计划尚未生成,忽略
+        }
+
+        await setMeta('lastSyncAt', new Date().toISOString());
+        setSyncStatus('synced', { pushed: result.pushed, pulled: result.pulled });
+    } catch (e) {
+        setSyncStatus('failed', { message: (e && e.message) || String(e) });
+    }
     return result;
 }
 
 let autoSyncRunning = false;
 
-// 幂等自动同步: 并发调用只会跑一次; 失败置 failed (本地进度仍保留)
 export async function triggerAutoSync() {
     if (autoSyncRunning) return;
     autoSyncRunning = true;
     try {
         await syncNow();
-    } catch (e) {
-        setSyncStatus('failed', { message: (e && e.message) || String(e) });
     } finally {
         autoSyncRunning = false;
     }
 }
 
-// 重算可学习范围: 未完全完成的组 + 最后完成组之后的 5 组
-export async function recomputeLearnableGroups() {
-    const allWords = await getAllWordsFromIDB();
-    if (!allWords || allWords.length === 0) {
-        await setLearnableGroups([]);
-        return;
-    }
-    const allGroups = Array.from(new Set(allWords.map(w => Number(w.groupid)))).sort((a, b) => a - b);
-    const progress = await getAllProgress();
-    const completedByGroup = {};
-    progress.forEach(p => {
-        const g = Number(p.group_id);
-        if (!completedByGroup[g]) completedByGroup[g] = new Set();
-        completedByGroup[g].add(p.op_type);
-    });
-    const isComplete = (g) => {
-        const ops = completedByGroup[g];
-        return !!ops && OPS.every(op => ops.has(op));
-    };
-    const learnable = new Set();
-    let maxComplete = -1;
-    allGroups.forEach(g => {
-        if (isComplete(g)) {
-            if (g > maxComplete) maxComplete = g;
-        } else {
-            learnable.add(g);
-        }
-    });
-    // 最后完成组后的 5 组
-    if (maxComplete >= 0) {
-        const idx = allGroups.indexOf(maxComplete);
-        for (let i = 1; i <= 5; i++) {
-            if (idx + i < allGroups.length) learnable.add(allGroups[idx + i]);
-        }
-    }
-    await setLearnableGroups(Array.from(learnable).sort((a, b) => a - b));
-}
-
-// ---------- 预取离线资源(思维导图 + 语音)----------
-// 范围: 未完成组(有记录但未集齐5项) ∪ 未开始组中最前面10组
-// 仅在线执行, 后台非阻塞; 去重直接查 SW 缓存 (caches.match), 不依赖 meta 标记,
-// 避免"SW 未接管时预取未真正落盘却被误标为已完成"导致后续跳过。
-const PREFETCH_CONCURRENCY = 6;
-const PREFETCH_NOT_STARTED_AHEAD = 10;
-const PREFETCH_OPS = ['study', 'test', 'review1', 'review2', 'review3'];
-
-// mode:'no-cors' 使请求键与 <img>/<Audio> 完全一致, 确保 SW cache-first 命中
-function prefetchUrl(url) {
-    return fetch(url, { mode: 'no-cors' }).catch(() => {});
-}
-
-function computePrefetchGroups(allWords, progress) {
-    const allGroups = Array.from(new Set(allWords.map(w => Number(w.groupid)))).sort((a, b) => a - b);
-    const opsByGroup = {};
-    progress.forEach(p => {
-        const g = Number(p.group_id);
-        if (!opsByGroup[g]) opsByGroup[g] = new Set();
-        opsByGroup[g].add(p.op_type);
-    });
-    const isComplete = (g) => !!opsByGroup[g] && PREFETCH_OPS.every(op => opsByGroup[g].has(op));
-    const incomplete = allGroups.filter(g => opsByGroup[g] && !isComplete(g));   // 已开始但未完成
-    const notStarted = allGroups.filter(g => !opsByGroup[g]);                     // 完全无记录
-    const firstNotStarted = notStarted.slice(0, PREFETCH_NOT_STARTED_AHEAD);
-    return Array.from(new Set([...incomplete, ...firstNotStarted])).sort((a, b) => a - b);
-}
-
-export async function prefetchLearnableAssets() {
-    if (typeof navigator === 'undefined' || !navigator.onLine) return;
-    if (!('caches' in window)) return;
-    const allWords = await getAllWordsFromIDB();
-    if (!allWords || allWords.length === 0) return;
-    const progress = await getAllProgress();
-    const target = computePrefetchGroups(allWords, progress);
-    if (target.length === 0) return;
-
-    const byGroup = {};
-    allWords.forEach(w => {
-        const g = Number(w.groupid);
-        if (!byGroup[g]) byGroup[g] = [];
-        byGroup[g].push(w);
-    });
-
-    // 收集需预取的 URL: 每组首词思维导图 + 每个词语音
-    const urls = [];
-    for (const g of target) {
-        const words = byGroup[g] || [];
-        if (words.length === 0) continue;
-        const picId = words[0].picture_id;
-        if (picId) urls.push(api.pictureUrl(picId));
-        for (const w of words) {
-            if (w.word) urls.push(api.ttsUrl(w.word));
-        }
-    }
-
-    // 准确去重: 只预取 SW 缓存里尚不存在的 URL (避免 meta 误标导致永久跳过)
-    const todo = [];
-    for (const u of urls) {
-        const hit = await caches.match(u).catch(() => undefined);
-        if (!hit) todo.push(u);
-    }
-    if (todo.length === 0) return;
-
-    // 分片并发, 限制同时请求数, 单条失败忽略
-    for (let i = 0; i < todo.length; i += PREFETCH_CONCURRENCY) {
-        const batch = todo.slice(i, i + PREFETCH_CONCURRENCY).map(u => prefetchUrl(u));
-        await Promise.allSettled(batch);
-    }
-}
-
-const OP_LABEL = { study: '学习', test: '测试', review1: '复习I', review2: '复习II', review3: '复习III' };
-
-// 格式化同步结果为可读提示 (保留, 用于失败时/手动诊断)
 export function formatSyncResult(r) {
-    const fmt = (e) => `${(e.completed_at || '').slice(0, 10)} Group${e.group_id} ${OP_LABEL[e.op_type] || e.op_type}`;
     const lines = [];
-    if (r.pushed > 0) {
-        lines.push(`推送 ${r.pushed} 条进度:`);
-        r.pushedEvents.forEach(e => lines.push('  ' + fmt(e)));
-    }
-    if (r.pulled > 0) {
-        lines.push(`拉取 ${r.pulled} 条进度:`);
-        r.pulledEvents.forEach(e => lines.push('  ' + fmt(e)));
-    }
-    if (r.pushed === 0 && r.pulled === 0) {
-        lines.push('无新进度,已是最新');
-    }
+    if (r.pushed > 0) lines.push(`上传 ${r.pushed} 条`);
+    if (r.pulled > 0) lines.push(`拉取 ${r.pulled} 条进度`);
+    if (r.warmed > 0) lines.push(`初始化词库 ${r.warmed} 词`);
+    if (r.pushed === 0 && r.pulled === 0 && r.warmed === 0) lines.push('已是最新');
     return '同步完成:\n' + lines.join('\n');
 }