]> acesimba.cloud Git - words.git/commitdiff
refactor(v2): 回滚为 V1 结构并改造测试/复习模式
authorCodeBuddy <codebuddy@local>
Thu, 20 Aug 2026 23:00:43 +0000 (07:00 +0800)
committerCodeBuddy <codebuddy@local>
Thu, 20 Aug 2026 23:00:43 +0000 (07:00 +0800)
- 后端移除 Daily Batch 模型,复用 V1 的 (group,op) 进度模型;新增 need_recite 列与独立 v2_study_logs 表(与 V1 study_logs 隔离)
- 前端移植 V1 结构:背诵清单/学习模式不变;品牌隔离 DB_NAME=words-pwa-v2r、token=words_auth_token_v2、Router basename=/v2、SW 与 manifest 指向 /v2
- TestMode 改造:拼写词+需背诵识别词(每组<=8)默写;其余识别词选词性+自测中文比对;末尾最多 5 道句子填空选择题
- 词性/句子选错则换掉错误混淆项并重排,直到全对;去掉 15 分钟限制;全部答对才记录完成(学习日历不变)
- V1 不受影响

20 files changed:
backend/main.py
frontend/public/manifest.json
frontend/public/sw.js
frontend/src/App.js
frontend/src/activeTime.js [deleted file]
frontend/src/api.js
frontend/src/components/DailyBatch.js [deleted file]
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 [new file with mode: 0644]
frontend/src/components/SyncNotifications.js
frontend/src/components/TestMode.css
frontend/src/components/TestMode.js [new file with mode: 0644]
frontend/src/db.js
frontend/src/sync.js

index c63efe1ac1a212faa3d6f4ff2475de31bd45354f..57f074d53b603bd02665778d1c2c5d803019a538 100644 (file)
@@ -110,6 +110,12 @@ class WordData(BaseModel):
     example_sentence: Optional[str] = None
     picture_id: Optional[int] = None
     groupid: Optional[int] = None
+    unit_order: Optional[int] = None
+    memory_type: Optional[str] = None
+    derived_from: Optional[str] = None
+    pos_quiz_answer: Optional[str] = None
+    exam_priority: Optional[str] = None
+    need_recite: Optional[int] = None
 
     class Config:
         from_attributes = True
@@ -166,7 +172,7 @@ def get_progress_from_db():
         cursor = conn.cursor()
         query = """
             SELECT group_id, op_type, MAX(COALESCE(log_date, created_at)) as last_date 
-            FROM study_logs 
+            FROM v2_study_logs 
             WHERE status = 'completed'
             GROUP BY group_id, op_type
         """
@@ -264,7 +270,13 @@ async def get_all_words():
                 affix,
                 example_sentence,
                 picture_id,
-                groupid
+                groupid,
+                unit_order,
+                memory_type,
+                derived_from,
+                pos_quiz_answer,
+                exam_priority,
+                need_recite
             FROM words
             ORDER BY CAST(groupid AS INTEGER) ASC, ID ASC
         """)
@@ -291,7 +303,13 @@ async def get_words_by_group(group_id: str):
                 affix,
                 example_sentence,
                 picture_id,
-                groupid
+                groupid,
+                unit_order,
+                memory_type,
+                derived_from,
+                pos_quiz_answer,
+                exam_priority,
+                need_recite
             FROM words
             WHERE CAST(groupid AS INTEGER) = ?
             ORDER BY ID ASC
@@ -388,7 +406,7 @@ async def update_progress(data: ProgressUpdate):
         conn = sqlite3.connect(DATABASE_PATH)
         cursor = conn.cursor()
         cursor.execute("""
-            CREATE TABLE IF NOT EXISTS study_logs (
+            CREATE TABLE IF NOT EXISTS v2_study_logs (
                 id INTEGER PRIMARY KEY AUTOINCREMENT,
                 group_id TEXT,
                 op_type TEXT,
@@ -400,14 +418,14 @@ async def update_progress(data: ProgressUpdate):
         today_prefix = now[:10]
         # 去重: 当天已有同 (group_id, op_type) 的完成记录则跳过,防止前端重复提交产生脏数据
         cursor.execute(
-            "SELECT id FROM study_logs WHERE group_id=? AND op_type=? AND status='completed' AND COALESCE(log_date, substr(created_at,1,10))=?",
+            "SELECT id FROM v2_study_logs WHERE group_id=? AND op_type=? AND status='completed' AND COALESCE(log_date, substr(created_at,1,10))=?",
             (data.group_id, data.op_type, today_prefix)
         )
         if cursor.fetchone():
             conn.close()
             return {"status": "skipped", "date": now, "reason": "duplicate same-day record"}
         cursor.execute(
-            "INSERT INTO study_logs (group_id, op_type, status, created_at, log_date, duration_sec) VALUES (?, ?, ?, ?, ?, ?)",
+            "INSERT INTO v2_study_logs (group_id, op_type, status, created_at, log_date, duration_sec) VALUES (?, ?, ?, ?, ?, ?)",
             (data.group_id, data.op_type, 'completed', now, today_prefix, data.duration_sec or 0)
         )
         conn.commit()
@@ -416,16 +434,16 @@ async def update_progress(data: ProgressUpdate):
     except Exception as e:
         raise HTTPException(status_code=500, detail=str(e))
 
-@app.get("/api/calendar/legacy")
+@app.get("/api/calendar")
 async def get_calendar():
-    # 按日期聚合 study_logs,供学习日历使用;同时计算每日"实际学习时长"(分钟)
+    # 按日期聚合 v2_study_logs,供学习日历使用;同时计算每日"实际学习时长"(分钟)
     # 实际学习时长 = 当天各 (group,op) 完成时长(duration_sec)之和, 不含间隔, 即把各次学习时段相加
     try:
         conn = sqlite3.connect(DATABASE_PATH)
         cursor = conn.cursor()
         cursor.execute("""
             SELECT COALESCE(log_date, substr(created_at,1,10)) as day, op_type, group_id
-            FROM study_logs
+            FROM v2_study_logs
             WHERE status='completed'
             ORDER BY day, op_type, CAST(group_id AS INTEGER)
         """)
@@ -435,7 +453,7 @@ async def get_calendar():
         # 每日实际学习时长(秒) = 各 (group,op) 完成时长之和
         cursor.execute("""
             SELECT COALESCE(log_date, substr(created_at,1,10)) as day, COALESCE(SUM(duration_sec), 0) as total_sec
-            FROM study_logs
+            FROM v2_study_logs
             WHERE status='completed'
             GROUP BY day
         """)
@@ -472,7 +490,7 @@ async def sync_push(data: SyncPushRequest):
                 bad += 1
                 continue
             cursor.execute(
-                "SELECT 1 FROM study_logs WHERE group_id=? AND op_type=? AND status='completed' LIMIT 1",
+                "SELECT 1 FROM v2_study_logs WHERE group_id=? AND op_type=? AND status='completed' LIMIT 1",
                 (ev.group_id, op))
             if cursor.fetchone():
                 skipped += 1
@@ -481,7 +499,7 @@ async def sync_push(data: SyncPushRequest):
             log_date = completed_at[:10]
             duration_sec = ev.duration_sec or 0
             cursor.execute(
-                "INSERT OR IGNORE INTO study_logs (group_id, op_type, status, created_at, log_date, duration_sec) "
+                "INSERT OR IGNORE INTO v2_study_logs (group_id, op_type, status, created_at, log_date, duration_sec) "
                 "VALUES (?, ?, 'completed', ?, ?, ?)",
                 (ev.group_id, op, completed_at, log_date, duration_sec))
             updated += 1
@@ -500,7 +518,7 @@ async def sync_pull():
         cursor = conn.cursor()
         cursor.execute(
             "SELECT group_id, op_type, MAX(COALESCE(log_date, created_at)), MAX(duration_sec) "
-            "FROM study_logs WHERE status='completed' GROUP BY group_id, op_type"
+            "FROM v2_study_logs WHERE status='completed' GROUP BY group_id, op_type"
         )
         rows = cursor.fetchall()
         conn.close()
@@ -518,117 +536,13 @@ 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():
+def ensure_v2_schema():
+    """启动时确保 V2 表与新列存在(幂等)。
+    - words 增加 need_recite 列并填充:每组取与拼写词同词根的识别词(最易混淆),封顶 8 个,标记需背诵。
+    - 新建独立的 v2_study_logs 表,存放 V2 自己的 (group, op) 完成记录,与 V1 导入的 study_logs 隔离。
+    """
     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 = [
@@ -638,11 +552,48 @@ def ensure_v2_schema():
             ("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"),
+            ("need_recite", "ALTER TABLE words ADD COLUMN need_recite INTEGER DEFAULT 0"),
         ]
         for col, sql in alters:
             if col not in cols:
                 conn.execute(sql)
-        conn.executescript(V2_CREATE_SQL)
+
+        # 填充 need_recite:同词根识别词优先,每组封顶 8,不足补其余识别词
+        all_words = conn.execute(
+            "SELECT ID, groupid, word_root, memory_type FROM words").fetchall()
+        spell_roots = {(int(w["groupid"]), (w["word_root"] or "").strip())
+                       for w in all_words
+                       if w["memory_type"] == "spell" and (w["word_root"] or "").strip()}
+        by_group = {}
+        for w in all_words:
+            if w["memory_type"] != "recognize":
+                continue
+            g = int(w["groupid"]); wid = w["ID"]
+            root = (w["word_root"] or "").strip()
+            bucket = "root" if (g, root) in spell_roots else "other"
+            by_group.setdefault(g, {"root": [], "other": []})["root" if bucket == "root" else "other"].append(wid)
+        chosen = []
+        for g in sorted(by_group):
+            sel = (by_group[g]["root"] + by_group[g]["other"])[:8]
+            chosen.extend(sel)
+        conn.execute("UPDATE words SET need_recite=0")
+        if chosen:
+            ph = ",".join("?" * len(chosen))
+            conn.execute("UPDATE words SET need_recite=1 WHERE ID IN (%s)" % ph, chosen)
+
+        # V2 独立的进度表(与 V1 study_logs 隔离)
+        conn.execute("""
+            CREATE TABLE IF NOT EXISTS v2_study_logs (
+                id INTEGER PRIMARY KEY AUTOINCREMENT,
+                group_id TEXT,
+                op_type TEXT,
+                status TEXT,
+                duration_sec INTEGER DEFAULT 0,
+                log_date DATE,
+                created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+                UNIQUE(group_id, op_type, log_date)
+            )
+        """)
         conn.commit()
     finally:
         conn.close()
@@ -651,636 +602,6 @@ def ensure_v2_schema():
 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 (V2) is running"}
index 6bdc64bc887aefc3ad2b60a18605b56f7ebac359..56a8d5890dbc7fa43adea0a1ddd5edaa56945465 100644 (file)
@@ -18,7 +18,7 @@
       "sizes": "512x512"
     }
   ],
-  "start_url": "/",
+  "start_url": "/v2/",
   "display": "standalone",
   "theme_color": "#2563eb",
   "background_color": "#ffffff"
index 94ba4c563e3d73f41c7d8137e7ec35a0e4c3210c..79b2d245097315f66594b08fa73b72240b7ae4b8 100644 (file)
@@ -5,11 +5,11 @@
 //   - navigation (HTML)         → network-first, 失败回退 cached index.html
 //   - 静态资源 (JS/CSS/img/font) → cache-first
 
-const CACHE_VERSION = 'words-pwa-v2';
+const CACHE_VERSION = 'words-pwa-v2r';
 
 self.addEventListener('install', (event) => {
     event.waitUntil(
-        caches.open(CACHE_VERSION).then((cache) => cache.addAll(['/index.html', '/manifest.json']))
+        caches.open(CACHE_VERSION).then((cache) => cache.addAll(['/v2/index.html', '/v2/manifest.json']))
     );
     self.skipWaiting();
 });
@@ -32,7 +32,7 @@ self.addEventListener('fetch', (event) => {
 
     // 图片与 TTS 音频: cache-first (含跨域, 后端已开 CORS)
     // 注意 TTS 现用查询参数形式 /api/tts?w=,pathname 为 /api/tts(无尾斜杠),故不加尾斜杠匹配
-    if (path.startsWith('/api/picture') || path.startsWith('/api/tts')) {
+    if (path.startsWith('/v2/api/picture') || path.startsWith('/v2/api/tts')) {
         event.respondWith(cacheFirst(req));
         return;
     }
@@ -76,6 +76,6 @@ async function networkFirst(req) {
         return res;
     } catch (e) {
         const cached = await cache.match(req);
-        return cached || cache.match('/index.html');
+        return cached || cache.match('/v2/index.html');
     }
 }
index c4f62fe705707b77f268bf7a6a53dfb40829c21d..94a6c4eaa2321c65d0051c7bc7ffec39c79edcb2 100644 (file)
@@ -1,7 +1,8 @@
 import React, { useState } from 'react';
 import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
 import Home from './components/Home';
-import DailyBatch from './components/DailyBatch';
+import StudyMode from './components/StudyMode';
+import TestMode from './components/TestMode';
 import GroupView from './components/GroupView';
 import StudyCalendar from './components/StudyCalendar';
 import SyncNotifications from './components/SyncNotifications';
@@ -18,7 +19,8 @@ function App() {
                     <SyncNotifications />
                     <Routes>
                         <Route path="/" element={<Home />} />
-                        <Route path="/batch" element={<DailyBatch />} />
+                        <Route path="/study/:groupId" element={<StudyMode />} />
+                        <Route path="/test/:groupId" element={<TestMode />} />
                         <Route path="/group/:groupId" element={<GroupView />} />
                         <Route path="/calendar" element={<StudyCalendar />} />
                     </Routes>
diff --git a/frontend/src/activeTime.js b/frontend/src/activeTime.js
deleted file mode 100644 (file)
index 7a25dc1..0000000
+++ /dev/null
@@ -1,87 +0,0 @@
-// 主动学习时长追踪 (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 ddf804dc2e607904275fbc81275dc2a51e8f6fe9..911d1dd67fa11fce7c082f3d33617d835af073de 100644 (file)
@@ -24,7 +24,7 @@ http.interceptors.response.use(
     (err) => {
         if (err.response && err.response.status === 401) {
             clearToken();
-            window.location.href = '/v2/';
+            window.location.href = '/';
         }
         return Promise.reject(err);
     }
@@ -50,18 +50,6 @@ 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
deleted file mode 100644 (file)
index d115193..0000000
+++ /dev/null
@@ -1,402 +0,0 @@
-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 4aa4066974baf90e4137733e4650fdc2be33490f..1f65d4b0bc4277f76673fda5a3d05a9a9c00836f 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 87eb1e0ffe87b234b28d2318169906fe1acf4979..317bcb5058fd35b06337c5bc0ea00f384cb46d0f 100644 (file)
@@ -1,37 +1,34 @@
 import React, { useState, useEffect } from 'react';
 import { useParams, useNavigate } from 'react-router-dom';
 import { api } from '../api';
-import { getWordsByGroup, getAllWordProgress } from '../db';
+import { getWordsByGroup } from '../db';
 import './GroupView.css';
 
 function GroupView() {
     const { groupId } = useParams();
     const navigate = useNavigate();
     const [words, setWords] = useState([]);
-    const [learned, setLearned] = useState({});
 
     useEffect(() => {
-        const gid = Number(groupId);
-        api.wordsV2Group(gid)
+        api.getWords(groupId)
             .then(res => setWords(res.data))
-            .catch(async () => {
-                const local = await getWordsByGroup(gid);
+            .catch(async (err) => {
+                // 离线或后端不可达: 降级读 IndexedDB (规格 9: 所有组均可离线查看)
+                console.error("获取词汇失败, 降级读离线缓存:", err);
+                const local = await getWordsByGroup(groupId);
                 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>
         );
     }
@@ -41,8 +38,7 @@ function GroupView() {
             <div className="group-view-card">
                 <div className="group-view-header">
                     <h1>Group {groupId} · 词汇预览</h1>
-                    <p className="group-view-count">共 {words.length} 个单词 · 已学 {learnedCount}
-                        <br /><span className="gv-hint">只读预览,不记录进度</span></p>
+                    <p className="group-view-count">共 {words.length} 个单词(只读预览,不记录进度)</p>
                 </div>
 
                 {pictureId && (
@@ -55,34 +51,28 @@ 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} className={learned[w.id] ? 'gv-learned' : ''}>
+                                <tr key={w.id}>
                                     <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 89b40c904ddd5eb734d59b7f056ec0020842c762..1a3db682d9a2c73d35ad2f2b2137576faf274abb 100644 (file)
   }
   .logout-btn:hover {
     background: #eef3fb;
-  }
-/* ---- 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; }
+  }
\ No newline at end of file
index caab64581c51581f3197128108ee718194633fb4..01f71f8990f43263cf2f9e455282c207886ef239 100644 (file)
 import React, { useEffect, useState } from 'react';
 import { useNavigate } from 'react-router-dom';
 import { useOnlineStatus } from '../hooks/useOnlineStatus';
-import { isInitialized, getDailyPlanByDate, putDailyPlan, getWordsCount, getAllWordProgress } from '../db';
-import { triggerAutoSync } from '../sync';
-import { api } from '../api';
+import { getAllWordsFromIDB, getAllProgress, isInitialized } from '../db';
+import { triggerAutoSync, subscribeSyncStatus } from '../sync';
 import { logout } from '../auth';
 import './Home.css';
 
-function chinaDate() {
-    return new Date(Date.now() + 8 * 3600 * 1000).toISOString().slice(0, 10);
-}
-
 function Home() {
-    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 [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 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);
-                setLoading(false);
+                setProgressData([]);
                 return;
             }
             setNeedInit(false);
-
-            // 今天计划:先本地,再尝试服务端
-            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);
+            await renderLocal();
+            // 在线: 后台自动同步 (首次在线会 warmup 词库+进度; 已初始化则合并云端)
             if (isOnline) triggerAutoSync();
         })();
         if (!isOnline) return;
+        // 在线时定时自动合并其他设备的进度
         const timer = setInterval(() => triggerAutoSync(), 30000);
         return () => clearInterval(timer);
         // eslint-disable-next-line react-hooks/exhaustive-deps
-    }, [isOnline, today]);
+    }, [isOnline]);
 
-    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);
-        }
+    // 订阅同步状态: 同步完成重渲染本地; 同步中且有 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 startTask = () => {
-        if (!plan) { generatePlan(); return; }
-        navigate('/batch');
+    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`);
+        }
     };
 
-    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.words_count} 词 · 已学 {stats.learned_count}
+                        图片总数: {stats.pictures_count} | 单词总数: {stats.words_count}
                     </p>
                     <button className="logout-btn" onClick={() => { logout(); window.location.reload(); }}>退出</button>
                 </div>
@@ -115,47 +149,84 @@ function Home() {
                 </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="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>
+            {blocking && (
+                <div className="sync-block-tip">同步完成后再开始新的学习</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 className="action-bar">
+                <button className="toggle-btn" onClick={() => setShowAll(!showAll)}>
+                    {showAll ? "➖ 隐藏已完成组" : "➕ 显示已完成的组"}
+                </button>
+                <button className="toggle-btn" onClick={() => navigate('/calendar')}>
+                    📅 学习日历
+                </button>
             </div>
 
-            <div className="action-bar">
-                <button className="toggle-btn" onClick={() => navigate('/calendar')}>📅 学习日历</button>
-                <button className="toggle-btn" onClick={() => navigate('/group/1')}>📚 词库浏览</button>
+            <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>
 
             <footer className="footer">
-                <button className="error-correction-btn" onClick={() => navigate('/calendar')}>查看学习记录</button>
+                <button className="error-correction-btn">进入消错模式</button>
             </footer>
         </div>
     );
index 1d6556dc97fef60b28362e98acfd5654e7d49d9a..4c9712d659b21f90e12de265b4be72f58bb058c3 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 c772ae06cf3a9dd56248b7c3244020f42454903b..dceb13dba2a2ec04a3dbc3bdf05ea4e9b3a5ba3b 100644 (file)
@@ -1,35 +1,53 @@
 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.calendarV2()
+        api.getCalendar()
             .then(res => {
                 const m = {};
                 res.data.forEach(item => {
-                    m[item.date] = item;
+                    m[item.date] = { activities: item.activities, durationMin: item.duration_min || 0 };
                 });
                 setDayMap(m);
             })
             .catch(async (err) => {
-                console.error('日历获取失败:', err);
-                setDayMap({});
+                // 离线降级: 从 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);
             });
     }, []);
 
@@ -37,67 +55,64 @@ function StudyCalendar() {
     const todayStr = `${today.getFullYear()}-${pad(today.getMonth() + 1)}-${pad(today.getDate())}`;
     const currentMonth = `${today.getFullYear()}-${pad(today.getMonth() + 1)}`;
 
-    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 };
-    }
+    // 月份渲染顺序: 当前月 + 有记录月份(去除当前月, 倒序)
+    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)];
 
     function renderMonth(monthStr) {
         const [yStr, mStr] = monthStr.split('-');
         const y = parseInt(yStr), m = parseInt(mStr);
-        const firstDay = new Date(y, m - 1, 1).getDay();
-        const offset = (firstDay + 6) % 7; // 周一起始
+        const firstDay = new Date(y, m - 1, 1).getDay();      // 0=Sun
+        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">学习 {sum.days} 天 · {fmtDur(sum.totalMin)} · 新词 {sum.newWords} 个</span>
+                    {y}年 {m}月 · <span className="cal-month-count">(学习了{studiedCount}个group)</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 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 dayInfo = dayMap[dateStr];
+                        const acts = dayInfo ? dayInfo.activities : undefined;
+                        const durationMin = dayInfo ? dayInfo.durationMin : 0;
+                        const isActive = !!acts;
                         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} onClick={() => info && hasData ? openDetail(dateStr) : null}>
+                            <div className={cls.join(' ')} key={i}>
                                 <div className="cal-day-head">
-                                    {dur > 0 && <span className="cal-duration">{dur}分</span>}
+                                    {durationMin > 0 && (
+                                        <span className="cal-duration">{durationMin}分钟</span>
+                                    )}
                                     <div className="cal-day-num">{d}</div>
                                 </div>
-                                {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>}
+                                {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(',')}
                                     </div>
-                                )}
+                                ))}
                             </div>
                         );
                     })}
@@ -106,46 +121,12 @@ 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 e2603bfc7edd815e464ba881a75f1bcf50118735..360a7e4b216552fa45b6c2f0433c4775473baf24 100644 (file)
@@ -253,9 +253,3 @@ 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
new file mode 100644 (file)
index 0000000..52a1de7
--- /dev/null
@@ -0,0 +1,246 @@
+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 d316c44726ea669178cfdc6b1d448898d810c73e..c273619823038bce2ab34c8bf5345aac4e4ba80d 100644 (file)
@@ -1,6 +1,6 @@
 import React, { useState, useEffect, useRef } from 'react';
 import { useOnlineStatus } from '../hooks/useOnlineStatus';
-import { getUnsyncedEvents, getUnsyncedSessions, getMeta } from '../db';
+import { getDirtyProgress, getMeta } from '../db';
 import { triggerAutoSync, subscribeSyncStatus, getSyncStatus } from '../sync';
 import './SyncNotifications.css';
 
@@ -19,9 +19,7 @@ export default function SyncNotifications() {
     // 轮询 dirty / 初始化状态
     useEffect(() => {
         const check = async () => {
-            const e = await getUnsyncedEvents();
-            const s = await getUnsyncedSessions();
-            setDirtyCount(e.length + s.length);
+            setDirtyCount((await getDirtyProgress()).length);
             setInitialized(!!(await getMeta('lastSyncAt')));
         };
         check();
index 55fdd8c39ca4260f9e375f3c643d22ad160a838d..05177c5a5a0efb0c25d74fe4afbd2c74d2851740 100644 (file)
     color: #64748b;
 }
 
+/* ====================== V2 选词性 / 句子填空 ====================== */
+.prompt-word-en {
+    font-size: 42px;
+    font-weight: bold;
+    color: #1e40af;
+    text-align: center;
+}
+
+.prompt-sentence {
+    font-size: 20px;
+    color: #475569;
+    margin-top: 12px;
+    line-height: 1.6;
+    text-align: center;
+}
+
+.prompt-pos-hint {
+    font-size: 20px;
+    color: #64748b;
+    margin-top: 20px;
+    text-align: center;
+}
+
+.option-grid {
+    display: grid;
+    grid-template-columns: repeat(2, minmax(140px, 1fr));
+    gap: 16px;
+    margin-top: 24px;
+    width: 100%;
+    max-width: 560px;
+}
+
+.option-btn {
+    padding: 16px 20px;
+    font-size: 22px;
+    background: #fff;
+    color: #1e293b;
+    border: 2px solid #cbd5e1;
+    border-radius: 12px;
+    cursor: pointer;
+    transition: all 0.15s;
+}
+
+.option-btn:hover {
+    border-color: #e67e22;
+    background: #fff7ed;
+}
+
+.sentence-area {
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    padding: 30px 0;
+}
+
+.sentence-prompt {
+    font-size: 20px;
+    color: #64748b;
+    margin-bottom: 16px;
+}
+
+.sentence-text {
+    font-size: 30px;
+    line-height: 1.8;
+    color: #1e293b;
+    text-align: center;
+    margin-bottom: 20px;
+    max-width: 860px;
+}
+
+.sentence-text .blank {
+    color: #e67e22;
+}
+
+.chinese-recall {
+    margin-top: 16px;
+    padding-top: 14px;
+    border-top: 1px dashed #e2e8f0;
+}
+
+.recall-tip {
+    font-size: 16px;
+    color: #94a3b8;
+    margin-bottom: 10px;
+}
+
+.recall-input {
+    width: 300px;
+    padding: 12px;
+    font-size: 20px;
+    border: 2px solid #ddd;
+    border-radius: 10px;
+    outline: none;
+}
+
+.recall-input:focus {
+    border-color: #e67e22;
+}
+
+.recall-btn {
+    background: #64748b;
+    font-size: 16px;
+    padding: 12px 20px;
+}
+
+.recall-btn:hover {
+    background: #475569;
+}
+
+.recall-result {
+    margin-top: 10px;
+    font-size: 18px;
+    font-weight: bold;
+}
+
+.recall-result.ok {
+    color: #10b981;
+}
+
+.recall-result.bad {
+    color: #dc2626;
+}
+
+/* 手机竖屏:选项与句子自适应 */
+@media (max-width: 480px) {
+    .option-grid {
+        grid-template-columns: 1fr 1fr;
+        gap: 10px;
+    }
+
+    .option-btn {
+        font-size: 18px;
+        padding: 12px;
+    }
+
+    .sentence-text {
+        font-size: 22px;
+    }
+
+    .prompt-word-en {
+        font-size: 30px;
+    }
+
+    .recall-input {
+        width: 100%;
+        font-size: 18px;
+    }
+}
+
 /* ====================== 响应式适配 ====================== */
 /* 平板竖屏及以下:反馈区上下结构、输入框自适应 */
 @media (max-width: 768px) {
diff --git a/frontend/src/components/TestMode.js b/frontend/src/components/TestMode.js
new file mode 100644 (file)
index 0000000..5864b51
--- /dev/null
@@ -0,0 +1,604 @@
+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';
+
+// 词性兜底池(当某组词性种类不足时用于补充干扰项)
+const UNIVERSAL_POS = ['n.', 'v.', 'adj.', 'adv.', 'prep.', 'conj.', 'pron.', 'int.'];
+
+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;
+}
+
+function escapeRegExp(s) {
+    return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+}
+
+// 在例句中把目标词替换为空格(用于句子填空选择题)。找不到目标词则返回原句。
+function blankWordInSentence(sentence, word) {
+    if (!sentence || !word) return sentence || '';
+    const re = new RegExp('\\b' + escapeRegExp(word) + '\\b', 'ig');
+    return sentence.replace(re, '______');
+}
+
+// 为某词生成 4 个词性选项(含正确项)。avoidSet 中的错误项将被替换掉(需求 5:换掉错误混淆项并重新排序)
+function buildPosOptions(word, posPool, avoidSet) {
+    const correct = (word.part_of_speech || '').trim();
+    let distractors = shuffle(posPool.filter(p => p && p !== correct && !avoidSet.has(p))).slice(0, 3);
+    // 干扰项不足 3 个时,放宽到全池补充(不含正确项、不重复)
+    if (distractors.length < 3) {
+        const backup = shuffle(posPool.filter(p => p && p !== correct && !distractors.includes(p)));
+        for (const p of backup) {
+            if (distractors.length >= 3) break;
+            distractors.push(p);
+        }
+    }
+    return shuffle([correct, ...distractors]);
+}
+
+// 为句子填空生成选项:正确词 + 同组非背诵干扰词。avoidSet 中的错误项将被替换
+function buildSentenceOptions(target, candidateWords, avoidSet) {
+    const correct = target.word;
+    let distractors = shuffle(
+        candidateWords.filter(w => w.word && w.word !== correct && !avoidSet.has(w.word))
+    ).slice(0, 3).map(w => w.word);
+    if (distractors.length < 3) {
+        const backup = shuffle(
+            candidateWords.filter(w => w.word && w.word !== correct && !distractors.includes(w.word))
+        );
+        for (const w of backup) {
+            if (distractors.length >= 3) break;
+            distractors.push(w.word);
+        }
+    }
+    return shuffle([correct, ...distractors]);
+}
+
+// 把中文含义做归一化,用于"自己回忆中文意思比对"的宽松匹配
+function normZh(s) {
+    return (s || '').toLowerCase().replace(/[\s,。、;:?!.,;:?!'"()()]/g, '');
+}
+
+function getCategories(words) {
+    const spell = words.filter(w => (w.memory_type || 'spell') === 'spell');
+    const recognize = words.filter(w => (w.memory_type || '') === 'recognize');
+    const recite = recognize.filter(w => w.need_recite === 1);            // 非重点但需背诵 → 默写
+    const other = recognize.filter(w => w.need_recite !== 1 && (w.part_of_speech || '').trim()); // 其余 → 选词性+回忆中文
+    return { spell, recognize, recite, other };
+}
+
+// 句子填空候选:非背诵且例句包含该词(最多 5 个,best-effort 待校对)
+function buildSentenceCandidates(other) {
+    const candidates = other.filter(w => {
+        const s = (w.example_sentence || '');
+        return s && (w.word || '') && s.toLowerCase().includes((w.word || '').toLowerCase());
+    });
+    return shuffle(candidates).slice(0, 5);
+}
+
+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 [phase, setPhase] = useState('ready'); // ready, word, word_fb, copy, sentence, sentence_fb, complete
+
+    // 单词环节队列(默写 / 选词性)
+    const [wordQueue, setWordQueue] = useState([]);
+    const [wordIndex, setWordIndex] = useState(0);
+    const [wrongWords, setWrongWords] = useState([]);
+
+    // 句子填空环节队列
+    const [sentenceQueue, setSentenceQueue] = useState([]);
+    const [sentenceIndex, setSentenceIndex] = useState(0);
+    const [wrongSentences, setWrongSentences] = useState([]);
+
+    // 交互状态
+    const [userInput, setUserInput] = useState('');
+    const [copyInput, setCopyInput] = useState('');
+    const [posChoice, setPosChoice] = useState('');
+    const [chineseRecall, setChineseRecall] = useState('');
+    const [compareResult, setCompareResult] = useState(null); // true/false/null
+    const [isCorrect, setIsCorrect] = useState(false);
+    const [sentenceChoice, setSentenceChoice] = useState('');
+
+    const inputRef = useRef(null);
+    const copyRef = useRef(null);
+    const chineseRef = useRef(null);
+    const submittedRef = useRef(false);
+
+    // 构建环节所需的派生数据(存 ref,供重考时重新生成选项)
+    const posPoolRef = useRef([]);
+    const otherRecognizeRef = useRef([]);
+
+    const current = phase === 'sentence' || phase === 'sentence_fb'
+        ? sentenceQueue[sentenceIndex]
+        : wordQueue[wordIndex];
+
+    // 离线时检查可学习范围
+    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 resetInputs = useCallback(() => {
+        setUserInput('');
+        setCopyInput('');
+        setPosChoice('');
+        setChineseRecall('');
+        setCompareResult(null);
+        setIsCorrect(false);
+        setSentenceChoice('');
+    }, []);
+
+    // 开始测试:构建三类环节
+    const startTest = useCallback(() => {
+        const { spell, recite, other } = getCategories(words);
+        const posPool = Array.from(new Set([
+            ...words.map(w => (w.part_of_speech || '').trim()).filter(Boolean),
+            ...UNIVERSAL_POS,
+        ]));
+        posPoolRef.current = posPool;
+        otherRecognizeRef.current = other;
+
+        const dictationItems = [...spell, ...recite].map(w => ({ ...w, mode: 'dictation' }));
+        const posItems = other.map(w => ({
+            ...w,
+            mode: 'pos',
+            posOptions: buildPosOptions(w, posPool, new Set()),
+            avoidPos: new Set(),
+        }));
+        const wordQ = shuffle([...dictationItems, ...posItems]);
+
+        const sentCands = buildSentenceCandidates(other);
+        const sentQ = sentCands.map(w => ({
+            id: w.id,
+            target: w,
+            blanked: blankWordInSentence(w.example_sentence, w.word),
+            answer: w.word,
+            options: buildSentenceOptions(w, other, new Set()),
+            avoidWord: new Set(),
+        })).filter(s => s.options.length >= 2);
+
+        setWordQueue(wordQ);
+        setWrongWords([]);
+        setWordIndex(0);
+        setSentenceQueue(sentQ);
+        setWrongSentences([]);
+        setSentenceIndex(0);
+        resetInputs();
+        setPhase('word');
+    }, [words, resetInputs]);
+
+    // 自动聚焦
+    useEffect(() => {
+        if (phase === 'word' && current && current.mode === 'dictation' && inputRef.current) {
+            inputRef.current.focus();
+        }
+        if (phase === 'copy' && copyRef.current) {
+            copyRef.current.focus();
+        }
+        if (phase === 'word' && current && current.mode === 'pos' && chineseRef.current) {
+            // pos 选择后才有中文输入框,这里暂不聚焦
+        }
+    }, [phase, current]);
+
+    // 朗读:通过后端 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 (!current || current.mode !== 'dictation' || !userInput.trim()) return;
+        const correct = (current.word || '').trim().toLowerCase();
+        const answer = userInput.trim().toLowerCase();
+        const ok = answer === correct;
+        setIsCorrect(ok);
+        setPhase('word_fb');
+        speak(current.word);
+        if (!ok) {
+            setWrongWords(prev => prev.find(w => w.id === current.id) ? prev : [...prev, current]);
+        }
+    }, [current, userInput, speak]);
+
+    // 选词性
+    const handlePosSelect = useCallback((opt) => {
+        if (!current || current.mode !== 'pos') return;
+        const correct = (current.part_of_speech || '').trim();
+        const ok = opt === correct;
+        setPosChoice(opt);
+        setIsCorrect(ok);
+        setPhase('word_fb');
+        speak(current.word);
+        if (!ok) {
+            current.avoidPos.add(opt); // 记录错误混淆项,重考时换掉(需求 5)
+            setWrongWords(prev => prev.find(w => w.id === current.id) ? prev : [...prev, current]);
+        }
+    }, [current, speak]);
+
+    // 比对回忆的中文意思(仅自测,不阻塞进度)
+    const compareChinese = useCallback(() => {
+        if (!current) return;
+        const correct = normZh(current.chinese_meaning);
+        const user = normZh(chineseRecall);
+        setCompareResult(!!(user && (correct.includes(user) || user.includes(correct))));
+    }, [current, chineseRecall]);
+
+    // 显示抄写区(默写错误后纠正)
+    const showCopy = useCallback(() => {
+        setCopyInput('');
+        setPhase('copy');
+    }, []);
+
+    // 进入下一词
+    const nextWord = useCallback(() => {
+        if (wordIndex + 1 < wordQueue.length) {
+            setWordIndex(prev => prev + 1);
+            resetInputs();
+            setPhase('word');
+        } else {
+            // 本轮结束
+            if (wrongWords.length > 0) {
+                const rq = shuffle(wrongWords.map(w => w.mode === 'pos'
+                    ? { ...w, posOptions: buildPosOptions(w, posPoolRef.current, w.avoidPos) }
+                    : { ...w }));
+                setWordQueue(rq);
+                setWrongWords([]);
+                setWordIndex(0);
+                resetInputs();
+                setPhase('word');
+            } else {
+                // 单词环节全部通过 → 进入句子填空环节
+                if (sentenceQueue.length > 0) {
+                    setSentenceIndex(0);
+                    resetInputs();
+                    setPhase('sentence');
+                } else {
+                    setPhase('complete');
+                }
+            }
+        }
+    }, [wordIndex, wordQueue.length, wrongWords, sentenceQueue, resetInputs]);
+
+    // 句子填空选择
+    const handleSentenceSelect = useCallback((opt) => {
+        if (!current) return;
+        const ok = opt === current.answer;
+        setSentenceChoice(opt);
+        setIsCorrect(ok);
+        setPhase('sentence_fb');
+        if (!ok) {
+            current.avoidWord.add(opt); // 记录错误混淆项,重考时换掉(需求 5)
+            setWrongSentences(prev => prev.find(w => w.id === current.id) ? prev : [...prev, current]);
+        }
+    }, [current]);
+
+    // 句子环节进入下一题
+    const nextSentence = useCallback(() => {
+        if (sentenceIndex + 1 < sentenceQueue.length) {
+            setSentenceIndex(prev => prev + 1);
+            resetInputs();
+            setPhase('sentence');
+        } else {
+            if (wrongSentences.length > 0) {
+                const rq = shuffle(wrongSentences.map(s => ({
+                    ...s,
+                    options: buildSentenceOptions(s.target, otherRecognizeRef.current, s.avoidWord),
+                })));
+                setSentenceQueue(rq);
+                setWrongSentences([]);
+                setSentenceIndex(0);
+                resetInputs();
+                setPhase('sentence');
+            } else {
+                setPhase('complete');
+            }
+        }
+    }, [sentenceIndex, sentenceQueue.length, wrongSentences, resetInputs]);
+
+    // 完成:全部环节全对才记录进度(需求 8)
+    const finishTest = useCallback(async () => {
+        if (submittedRef.current) return;
+        submittedRef.current = true;
+        const now = localTimestamp();
+        const durationSec = Math.max(0, Math.round((Date.now() - sessionStartRef.current) / 1000));
+        await putProgress(groupId, activityType, now, true, durationSec);
+        if (isOnline) triggerAutoSync();
+        navigate('/');
+    }, [groupId, navigate, activityType, isOnline]);
+
+    // 键盘事件
+    const handleKeyDown = useCallback((e) => {
+        if (e.key === 'Enter') {
+            if (phase === 'word' && current && current.mode === 'dictation') {
+                checkAnswer();
+            } else if (phase === 'word_fb' && isCorrect) {
+                nextWord();
+            } else if (phase === 'word_fb' && current && current.mode === 'dictation' && !isCorrect) {
+                showCopy();
+            } else if (phase === 'copy' && copyInput.toLowerCase() === (current ? (current.word || '').toLowerCase() : '')) {
+                nextWord();
+            } else if (phase === 'word_fb' && current && current.mode === 'pos') {
+                nextWord();
+            } else if (phase === 'sentence_fb') {
+                nextSentence();
+            }
+        }
+    }, [phase, isCorrect, current, checkAnswer, nextWord, showCopy, copyInput, nextSentence]);
+
+    useEffect(() => {
+        window.addEventListener('keydown', handleKeyDown);
+        return () => window.removeEventListener('keydown', handleKeyDown);
+    }, [handleKeyDown]);
+
+    // 阻止复制/剪切/粘贴/右键,防止作弊
+    const blockClipboard = (e) => e.preventDefault();
+
+    // 默写输入:仅保留 ASCII 可见字符并转小写(屏蔽中文/全角/大写)
+    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') {
+        const { spell, recite, other } = getCategories(words);
+        const dictCount = spell.length + recite.length;
+        const posCount = other.length;
+        const sentCount = buildSentenceCandidates(other).length;
+        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">
+                        拼写/需背诵词({dictCount})默写;其余识别词({posCount})选词性并自测中文;
+                        最后 {sentCount} 道句子填空选择题。全部答对才算完成。
+                    </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 (!current) return null;
+
+    // 句子填空环节
+    if (phase === 'sentence' || phase === 'sentence_fb') {
+        const isFb = phase === 'sentence_fb';
+        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">句子填空 - Group {groupId}</span>
+                        <span className="test-progress">{sentenceIndex + 1} / {sentenceQueue.length}</span>
+                    </div>
+                    <div className="test-body">
+                        {!isFb && (
+                            <div className="sentence-area">
+                                <p className="sentence-prompt">选择填入空白处最合适的词:</p>
+                                <p className="sentence-text">{current.blanked}</p>
+                                <div className="option-grid">
+                                    {current.options.map((opt, i) => (
+                                        <button key={i} className="option-btn" onClick={() => handleSentenceSelect(opt)}>
+                                            {opt}
+                                        </button>
+                                    ))}
+                                </div>
+                            </div>
+                        )}
+                        {isFb && (
+                            <div className="feedback-wrapper">
+                                <div className="feedback-main">
+                                    <div className={`fb-status ${isCorrect ? 'correct' : 'wrong'}`}>
+                                        {isCorrect ? '✅ 正确!' : `❌ 错误,你的答案: ${sentenceChoice}`}
+                                    </div>
+                                    <div className="fb-word">正确答案: {current.answer}</div>
+                                    <div className="fb-sentence">原句: {current.target.example_sentence}</div>
+                                    <div className="fb-chinese">中文: {current.target.chinese_meaning}</div>
+                                    <button className="action-btn" onClick={nextSentence}>下一题 (Enter)</button>
+                                </div>
+                            </div>
+                        )}
+                    </div>
+                </div>
+                <button className="back-home" onClick={() => navigate('/')}>返回首页</button>
+            </div>
+        );
+    }
+
+    // 单词环节(默写 / 选词性)
+    const isPos = current.mode === 'pos';
+    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">
+                        {isPos ? '选词性' : '默写'} - Group {groupId}
+                    </span>
+                    <span className="test-progress">{wordIndex + 1} / {wordQueue.length}</span>
+                </div>
+
+                <div className="test-body">
+                    {/* 答题中 */}
+                    {phase === 'word' && !isPos && (
+                        <div className="testing-area">
+                            <div className="prompt-info">
+                                <p className="prompt-chinese">{current.chinese_meaning}</p>
+                                <p className="prompt-pos">({current.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 === 'word' && isPos && (
+                        <div className="testing-area">
+                            <div className="prompt-info">
+                                <p className="prompt-word-en">{current.word}</p>
+                                {current.example_sentence && (
+                                    <p className="prompt-sentence">例句: {current.example_sentence}</p>
+                                )}
+                                <p className="prompt-pos-hint">请选择该词的词性:</p>
+                            </div>
+                            <div className="option-grid">
+                                {current.posOptions.map((opt, i) => (
+                                    <button key={i} className="option-btn" onClick={() => handlePosSelect(opt)}>
+                                        {opt}
+                                    </button>
+                                ))}
+                            </div>
+                        </div>
+                    )}
+
+                    {/* 反馈区 */}
+                    {(phase === 'word_fb' || phase === 'copy') && (
+                        <div className="feedback-wrapper">
+                            <div className="feedback-main">
+                                <div className={`fb-status ${isCorrect ? 'correct' : 'wrong'}`}>
+                                    {isCorrect ? '✅ 正确!' : (isPos ? `❌ 词性错误,你的选择: ${posChoice}` : `❌ 错误,你的答案: ${userInput}`)}
+                                </div>
+
+                                <div className="fb-word">{current.word}</div>
+                                <div className="fb-chinese">中文: {current.chinese_meaning}</div>
+                                <div className="fb-pos">词性: {current.part_of_speech}</div>
+
+                                {current.word_root && (<div className="fb-root">词根: {current.word_root}</div>)}
+                                {current.affix && (<div className="fb-affix">组成: {current.affix}</div>)}
+                                {current.example_sentence && (<div className="fb-sentence">例句: {current.example_sentence}</div>)}
+
+                                {/* 选词性环节:自测回忆中文意思(仅自测,不阻塞) */}
+                                {isPos && (
+                                    <div className="chinese-recall">
+                                        <p className="recall-tip">自己回忆的中文意思是否一致?(仅自测)</p>
+                                        <div className="input-row">
+                                            <input
+                                                ref={chineseRef}
+                                                value={chineseRecall}
+                                                onChange={(e) => setChineseRecall(e.target.value)}
+                                                placeholder="回忆的中文意思..."
+                                                className="recall-input"
+                                                onPaste={blockClipboard}
+                                                onContextMenu={blockClipboard}
+                                            />
+                                            <button className="action-btn recall-btn" onClick={compareChinese}>比对</button>
+                                        </div>
+                                        {compareResult !== null && (
+                                            <div className={`recall-result ${compareResult ? 'ok' : 'bad'}`}>
+                                                {compareResult ? '✅ 与参考一致/接近' : '❌ 与参考不一致'}
+                                            </div>
+                                        )}
+                                    </div>
+                                )}
+
+                                {!isCorrect && phase === 'word_fb' && !isPos && (
+                                    <button className="action-btn copy-trigger" onClick={showCopy}>抄写纠正 (Enter)</button>
+                                )}
+
+                                {(isCorrect || isPos) && (
+                                    <button className="action-btn" onClick={nextWord}>下一词 (Enter)</button>
+                                )}
+                            </div>
+
+                            {current.picture_id && (
+                                <div className="fb-image">
+                                    <img src={api.pictureUrl(current.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() === (current.word || '').toLowerCase() ? 'correct' : ''}`}
+                                placeholder="请在此抄写正确单词..."
+                                {...inputProps}
+                            />
+                            <span className={`copy-check ${copyInput.toLowerCase() === (current.word || '').toLowerCase() ? 'ok' : ''}`}>
+                                {copyInput.toLowerCase() === (current.word || '').toLowerCase() ? '✅' : '❌'}
+                            </span>
+                            {copyInput.toLowerCase() === (current.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 4d096fd751d548b40f9ecb358647bb88ced70a7d..c36fd9f9ee879f6fc61f867ff86059ff3200af28 100644 (file)
@@ -1,14 +1,11 @@
-// 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 = 2;
+// 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[])
+
+const DB_NAME = 'words-pwa-v2r';
+const DB_VERSION = 1;
 
 let dbPromise = null;
 
@@ -22,20 +19,8 @@ function openDB() {
                 db.createObjectStore('words', { keyPath: 'id' })
                   .createIndex('groupid', 'groupid', { unique: false });
             }
-            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('progress')) {
+                db.createObjectStore('progress', { keyPath: 'key' });
             }
             if (!db.objectStoreNames.contains('meta')) {
                 db.createObjectStore('meta', { keyPath: 'key' });
@@ -47,6 +32,7 @@ function openDB() {
     return dbPromise;
 }
 
+// 通用: 单 store 事务执行, 返回主请求结果
 async function singleReq(store, mode, fn) {
     const db = await openDB();
     return new Promise((resolve, reject) => {
@@ -58,6 +44,7 @@ async function singleReq(store, mode, fn) {
 }
 
 // ---------- words store ----------
+
 export async function putWords(wordsArr) {
     const db = await openDB();
     return new Promise((resolve, reject) => {
@@ -70,6 +57,7 @@ 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)))
     );
@@ -83,79 +71,55 @@ export async function getAllWordsFromIDB() {
     return singleReq('words', 'readonly', s => s.getAll());
 }
 
-// ---------- dailyPlans store ----------
-export async function putDailyPlan(plan) {
-    return singleReq('dailyPlans', 'readwrite', s => s.put(plan));
-}
-
-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));
-}
+// ---------- progress store ----------
 
-// ---------- learningEvents store ----------
-export async function putLearningEvent(ev) {
-    return singleReq('learningEvents', 'readwrite', s => s.put(ev));
+export async function getProgress(group_id, op_type) {
+    return singleReq('progress', 'readonly', s => s.get(`${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 getAllProgress() {
+    return singleReq('progress', 'readonly', s => s.getAll());
 }
 
-export async function markEventsSynced(ids) {
+export async function putProgress(group_id, op_type, completed_at, dirty, duration_sec = 0) {
+    const key = `${group_id}:${op_type}`;
     const db = await openDB();
     return new Promise((resolve, reject) => {
-        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); }
-            };
-        });
+        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 });
         t.oncomplete = () => resolve();
         t.onerror = () => reject(t.error);
     });
 }
 
-// ---------- 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 getDirtyProgress() {
+    const all = await getAllProgress();
+    return all.filter(p => p.dirty);
 }
 
-export async function markSessionsSynced(ids) {
+export async function clearDirty(group_id, op_type) {
+    const key = `${group_id}:${op_type}`;
     const db = await openDB();
     return new Promise((resolve, reject) => {
-        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); }
-            };
-        });
+        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); }
+        };
         t.oncomplete = () => resolve();
         t.onerror = () => reject(t.error);
     });
 }
 
-// ---------- wordProgress store ----------
-export async function putWordProgress(wp) {
-    return singleReq('wordProgress', 'readwrite', s => s.put(wp));
-}
-
-export async function getAllWordProgress() {
-    return singleReq('wordProgress', 'readonly', s => s.getAll());
+// 删除某条进度(用于服务端已删除记录回写本地, 实现撤销/未执行)
+export async function deleteProgress(group_id, op_type) {
+    const key = `${group_id}:${op_type}`;
+    return singleReq('progress', 'readwrite', s => s.delete(key));
 }
 
 // ---------- meta store ----------
+
 export async function getMeta(key) {
     const r = await singleReq('meta', 'readonly', s => s.get(key));
     return r ? r.value : undefined;
@@ -171,6 +135,16 @@ 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 660397ac4c0db3a3e497232793776028693a861f..e8459795a547a74098d36d73cde33dc37596419b 100644 (file)
@@ -1,17 +1,20 @@
-// V2 离线优先 + 自动同步 统一入口
-// 同步对象: learningEvents(UUID 幂等) / studySessions(UUID upsert) / dailyPlans / wordProgress
-// 不再使用 (group_id, op_type) 去重;事件以 event_id 天然幂等。
+// 离线优先 + 自动同步 统一入口
+// 数据规则 (布尔并集): (group,op) 任一方完成即完成; 云端删除已确认进度会回写本地(撤销)
+// dirty = "这条本地完成记录尚未确认上传到云端"
+// syncNow: 上传本地 dirty → 拉云端并集合并 → 更新 lastSyncAt → 重算 learnable
+// triggerAutoSync: 幂等并发锁, 供首页/联网恢复/完成学习 调用, 失败时置 failed
 
-import { api, localTimestamp } from './api';
+import { api } from './api';
 import {
-    getUnsyncedEvents, markEventsSynced,
-    getUnsyncedSessions, markSessionsSynced,
-    putWords, setMeta,
-    putDailyPlan, putWordProgress,
-    getWordsCount,
+    getDirtyProgress, clearDirty, getAllProgress, putProgress, deleteProgress,
+    putWords, setMeta, setLearnableGroups, getAllWordsFromIDB, 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 };
 
@@ -31,106 +34,221 @@ function setSyncStatus(state, detail) {
 
 // ---------- 核心同步 ----------
 export async function syncNow() {
-    const result = { pushed: 0, pulled: 0, warmed: 0 };
-    if (typeof navigator !== 'undefined' && !navigator.onLine) {
-        setSyncStatus('failed', { message: '离线,无法同步' });
-        return result;
+    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)));
     }
-    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));
-        }
 
-        // 阶段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;
+    // 阶段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);
+            }
         }
-        if (dirtySessions.length > 0) {
-            await markSessionsSynced(dirtySessions.map(s => s.session_id));
-        }
-
-        setSyncStatus('syncing', { phase: 'merge', dirty: 0 });
+    }
 
-        // 阶段3: 首次 warmup 词库 (仅 IDB 空时)
-        if ((await getWordsCount()) === 0) {
-            const allWords = await api.wordsV2All();
-            await putWords(allWords.data);
-            result.warmed = allWords.data.length;
+    // 阶段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);
+            }
         }
+    }
 
-        // 阶段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;
-        }
+    // 阶段3: 首次 warmup 词库 (仅 IDB 空时, 避免每次重拉 1852 词)
+    if ((await getWordsCount()) === 0) {
+        const allWords = await api.getAllWords();
+        await putWords(allWords.data);
+        result.warmed = allWords.data.length;
+    }
 
-        // 阶段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: 今天计划尚未生成,忽略
-        }
+    // 阶段4: 更新 lastSyncAt + 重算可学习范围
+    await setMeta('lastSyncAt', new Date().toISOString());
+    await recomputeLearnableGroups();
 
-        await setMeta('lastSyncAt', new Date().toISOString());
-        setSyncStatus('synced', { pushed: result.pushed, pulled: result.pulled });
-    } catch (e) {
-        setSyncStatus('failed', { message: (e && e.message) || String(e) });
-    }
+    // 阶段5: 后台预取离线资源(思维导图+语音), 不阻塞同步完成
+    prefetchLearnableAssets().catch(() => {});
+
+    setSyncStatus('synced', { pushed: result.pushed, pulled: result.pulled });
     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} 条`);
-    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('已是最新');
+    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('无新进度,已是最新');
+    }
     return '同步完成:\n' + lines.join('\n');
 }