From a61df5e7822d4317638f1ef68110aa78c64c0161 Mon Sep 17 00:00:00 2001 From: CodeBuddy Date: Fri, 21 Aug 2026 07:00:43 +0800 Subject: [PATCH] =?utf8?q?refactor(v2):=20=E5=9B=9E=E6=BB=9A=E4=B8=BA=20V1?= =?utf8?q?=20=E7=BB=93=E6=9E=84=E5=B9=B6=E6=94=B9=E9=80=A0=E6=B5=8B?= =?utf8?q?=E8=AF=95/=E5=A4=8D=E4=B9=A0=E6=A8=A1=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit - 后端移除 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 不受影响 --- backend/main.py | 827 ++----------------- frontend/public/manifest.json | 2 +- frontend/public/sw.js | 8 +- frontend/src/App.js | 6 +- frontend/src/activeTime.js | 87 -- frontend/src/api.js | 14 +- frontend/src/components/DailyBatch.js | 402 --------- frontend/src/components/GroupView.css | 8 - frontend/src/components/GroupView.js | 48 +- frontend/src/components/Home.css | 20 +- frontend/src/components/Home.js | 285 ++++--- frontend/src/components/StudyCalendar.css | 16 - frontend/src/components/StudyCalendar.js | 137 ++- frontend/src/components/StudyMode.css | 6 - frontend/src/components/StudyMode.js | 246 ++++++ frontend/src/components/SyncNotifications.js | 6 +- frontend/src/components/TestMode.css | 149 ++++ frontend/src/components/TestMode.js | 604 ++++++++++++++ frontend/src/db.js | 122 ++- frontend/src/sync.js | 288 +++++-- 20 files changed, 1593 insertions(+), 1688 deletions(-) delete mode 100644 frontend/src/activeTime.js delete mode 100644 frontend/src/components/DailyBatch.js create mode 100644 frontend/src/components/StudyMode.js create mode 100644 frontend/src/components/TestMode.js diff --git a/backend/main.py b/backend/main.py index c63efe1..57f074d 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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"} diff --git a/frontend/public/manifest.json b/frontend/public/manifest.json index 6bdc64b..56a8d58 100644 --- a/frontend/public/manifest.json +++ b/frontend/public/manifest.json @@ -18,7 +18,7 @@ "sizes": "512x512" } ], - "start_url": "/", + "start_url": "/v2/", "display": "standalone", "theme_color": "#2563eb", "background_color": "#ffffff" diff --git a/frontend/public/sw.js b/frontend/public/sw.js index 94ba4c5..79b2d24 100644 --- a/frontend/public/sw.js +++ b/frontend/public/sw.js @@ -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'); } } diff --git a/frontend/src/App.js b/frontend/src/App.js index c4f62fe..94a6c4e 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.js @@ -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() { } /> - } /> + } /> + } /> } /> } /> diff --git a/frontend/src/activeTime.js b/frontend/src/activeTime.js deleted file mode 100644 index 7a25dc1..0000000 --- a/frontend/src/activeTime.js +++ /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 }; -} diff --git a/frontend/src/api.js b/frontend/src/api.js index ddf804d..911d1dd 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -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 index d115193..0000000 --- a/frontend/src/components/DailyBatch.js +++ /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
加载今日任务…
; - - if (done) { - return ( -
-
-

今日任务完成 🎉

-

- {weakWords.length > 0 - ? `今天发现 ${weakWords.length} 个需要加强的点,已自动加入后续复习。` - : '今天表现得很好,没有发现薄弱点!'} -

- {weakWords.length > 0 && ( -
    - {weakWords.map((w, i) =>
  • {w.word} ({w.why})
  • )} -
- )} - -
-
- ); - } - - const task = tasks[idx]; - if (!task) { finish(); return
完成中…
; } - - const total = tasks.length; - const phaseLabel = task.kind === 'quiz' ? '综合训练' : (task.stage === 'review' ? '复习' : '新词'); - - return ( -
-
-
{phaseLabel}:{Math.min(idx + 1, total)} / {total}
- {task.kind === 'study' - ? onStudyResult(task, r, d)} /> - : onQuizResult(task, r, d)} isLast={idx === total - 1} />} -
- -
- ); -} - -// ---------------- 单词学习卡(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 ( -
-

?

-
{word.chinese_meaning}({word.part_of_speech})
- {word.word_root &&

词根: {word.word_root}

} - {word.example_sentence &&

例句: {word.example_sentence}

} - -
- ); - } - if (step === 'input') { - return ( -
-

拼写:{word.chinese_meaning}

-

请写出英文单词

- setUserInput(sanitize(e.target.value))} - onKeyDown={(e) => e.key === 'Enter' && checkSpell()} placeholder="输入英文单词…" {...inputProps} /> - -
- ); - } - return ( -
-

回答错误!正确答案:{word.word}

-

请抄写一遍以解锁:

- setCopyInput(sanitize(e.target.value))} - onKeyDown={(e) => { if (e.key === 'Enter' && copyInput.toLowerCase() === word.word.toLowerCase()) checkCopy(); }} - placeholder="请在此抄写…" {...inputProps} /> - {copyInput.toLowerCase() === word.word.toLowerCase() && ( - - )} -
- ); - } - - // recognize - if (step === 'prompt') { - return ( -
-

{word.word}

-
{word.chinese_meaning}({word.part_of_speech})
- {word.word_root &&

词根: {word.word_root}

} - {word.example_sentence &&

例句: {word.example_sentence}

} - -
- ); - } - if (hasPos) { - const opts = shuffle([correctPos, ...POS_POOL.filter(p => p !== correctPos)]).slice(0, 4); - return ( -
-

选择「{word.word}」的主要词性

-

先默想词义,再选择

-
- {opts.map((o, i) => ( - - ))} -
-
- ); - } - return ( -
-

你认识「{word.word}」吗?

-

意思:{word.chinese_meaning}

- - -
- ); -} - -// ---------------- 综合题卡(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 ( -
-

综合训练

-

{q.prompt}

-
- {q.options.map((o, i) => ( - - ))} -
- -

*{q.note}

-
- ); - } - return ( -
-
-
- {correct ? '✅ 正确!' : `❌ 正确答案:${q.answer}`} -
- -
-
- ); -} - -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; -} diff --git a/frontend/src/components/GroupView.css b/frontend/src/components/GroupView.css index 4aa4066..1f65d4b 100644 --- a/frontend/src/components/GroupView.css +++ b/frontend/src/components/GroupView.css @@ -132,11 +132,3 @@ 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; } diff --git a/frontend/src/components/GroupView.js b/frontend/src/components/GroupView.js index 87eb1e0..317bcb5 100644 --- a/frontend/src/components/GroupView.js +++ b/frontend/src/components/GroupView.js @@ -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 (
-

加载中...

+
+

加载中...

+
); } @@ -41,8 +38,7 @@ function GroupView() {

Group {groupId} · 词汇预览

-

共 {words.length} 个单词 · 已学 {learnedCount} -
只读预览,不记录进度

+

共 {words.length} 个单词(只读预览,不记录进度)

{pictureId && ( @@ -55,34 +51,28 @@ function GroupView() { - - + + + + + + {words.map(w => ( - + - + ))}
单词词性中文释义记忆类型词根例句单词词性中文释义词根词缀例句
{w.word} {w.part_of_speech} {w.chinese_meaning} - - {w.memory_type === 'spell' ? '拼写' : '识别'} - - {w.word_root}{w.affix} {w.example_sentence}
- -
- - - -
diff --git a/frontend/src/components/Home.css b/frontend/src/components/Home.css index 89b40c9..1a3db68 100644 --- a/frontend/src/components/Home.css +++ b/frontend/src/components/Home.css @@ -206,22 +206,4 @@ } .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 diff --git a/frontend/src/components/Home.js b/frontend/src/components/Home.js index caab645..01f71f8 100644 --- a/frontend/src/components/Home.js +++ b/frontend/src/components/Home.js @@ -1,109 +1,143 @@ 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

单词学习

加载中…

; - } - - 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 (
-

今晚任务

+

单词学习进度记录

- 词库 {stats.words_count} 词 · 已学 {stats.learned_count} + 图片总数: {stats.pictures_count} | 单词总数: {stats.words_count}

@@ -115,47 +149,84 @@ function Home() {
)} -
-
- 今晚任务 - 预计 {estMin || '--'} 分钟 -
-

今日计划生成后不再加码;到 15 分钟自动停止追加。

- -
-
-
新词
-
{plannedNew || '--'}个
-
{plannedSpell} 个拼写 / {plannedRecognize} 个识别
-
-
-
到期复习
-
{plannedReview || 0}个
-
错词优先 · 未完成自动顺延
-
-
-
综合训练
-
{plannedQuiz}题
-
3 道今日词 / 2 道复习词
-
-
+ {blocking && ( +
同步完成后再开始新的学习
+ )} - {plan ? ( - - ) : ( - - )} +
+ +
-
- - +
+ + + + + + + + + + + + + {filteredData.map((item, index) => ( + + + + + + + + + ))} + +
组别基础学习单元测试复习 I复习 II复习 III
handleCellClick(item, 'group')} + style={{ cursor: 'pointer' }} + > + {item.group} + handleCellClick(item, 'study')} + style={{ cursor: item.study.includes('请开始学习') ? 'pointer' : 'default' }} + > + {item.study} + handleCellClick(item, 'test')} + style={{ cursor: item.test && item.test.includes('请开始') ? 'pointer' : 'default' }} + > + {item.test} + handleCellClick(item, 'review1')} + style={{ cursor: item.review1 && (item.review1.includes('请开始') || item.review1.includes('请尽快')) ? 'pointer' : 'default' }} + > + {item.review1} + handleCellClick(item, 'review2')} + style={{ cursor: item.review2 && (item.review2.includes('请开始') || item.review2.includes('请尽快')) ? 'pointer' : 'default' }} + > + {item.review2} + handleCellClick(item, 'review3')} + style={{ cursor: item.review3 && (item.review3.includes('请开始') || item.review3.includes('请尽快')) ? 'pointer' : 'default' }} + > + {item.review3} +
- +
); diff --git a/frontend/src/components/StudyCalendar.css b/frontend/src/components/StudyCalendar.css index 1d6556d..4c9712d 100644 --- a/frontend/src/components/StudyCalendar.css +++ b/frontend/src/components/StudyCalendar.css @@ -165,19 +165,3 @@ 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; } diff --git a/frontend/src/components/StudyCalendar.js b/frontend/src/components/StudyCalendar.js index c772ae0..dceb13d 100644 --- a/frontend/src/components/StudyCalendar.js +++ b/frontend/src/components/StudyCalendar.js @@ -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 (
- {y}年 {m}月 · 学习 {sum.days} 天 · {fmtDur(sum.totalMin)} · 新词 {sum.newWords} 个 + {y}年 {m}月 · (学习了{studiedCount}个group)
{WEEKDAYS.map(w =>
{w}
)} {cells.map((d, i) => { if (d === null) return
; 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 ( -
info && hasData ? openDetail(dateStr) : null}> +
- {dur > 0 && {dur}分} + {durationMin > 0 && ( + {durationMin}分钟 + )}
{d}
- {info && ( -
- {info.new_words > 0 && 新{info.new_words}} - {info.review_words > 0 && 复{info.review_words}} - {info.quiz_total > 0 && 题{info.quiz_total}} + {acts && acts.map((a, j) => ( +
+ {OP_LABEL[a.op_type] || a.op_type} G{a.groups.join(',')}
- )} + ))}
); })} @@ -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 (

📅 学习日历

{renderMonths.map(renderMonth)}
- - {detail && ( -
setDetail(null)}> -
e.stopPropagation()}> -

{detail.date}

-
主动学习{fmtDur(detail.duration_min)}
-
新词 / 拼写 / 识别{detail.new_words} · {detail.spell_words} / {detail.recognize_words}
-
复习{detail.review_words}
-
综合题{detail.quiz_total}(对 {detail.quiz_correct})
- {detail.groups_touched && detail.groups_touched.length > 0 && ( -
涉及章节G{detail.groups_touched.join(' / G')}
- )} - {detail.weak_points && detail.weak_points.length > 0 && ( -
-
需要加强:
- {detail.weak_points.map((w, i) => ( -
{w.word} ({w.wrong_types.join('/')})
- ))} -
- )} - -
-
- )} -
); diff --git a/frontend/src/components/StudyMode.css b/frontend/src/components/StudyMode.css index e2603bf..360a7e4 100644 --- a/frontend/src/components/StudyMode.css +++ b/frontend/src/components/StudyMode.css @@ -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 index 0000000..52a1de7 --- /dev/null +++ b/frontend/src/components/StudyMode.js @@ -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
加载中...
; + + // 阻止复制/剪切/粘贴/右键,防止单词被复制后粘贴作弊 + 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 ( +
+
+ {/* 只有在非闪记模式下显示图片 */} + {phase !== 'flashcard' && ( +
+ {currentWord.picture_id && ( + mindmap + )} +
+ )} + +
+
Group {groupId}: {currentIndex + 1} / {words.length}
+ + {phase === 'learning' ? ( +
+

{currentWord.word}

+
+ {currentWord.chinese_meaning} + ({currentWord.part_of_speech}) +
+ +
+ {currentWord.word_root &&

词根: {currentWord.word_root}

} + {currentWord.affix &&

组成: {currentWord.affix}

} + +
+
+ ) : phase === 'flashcard' ? ( +
+

闪记模式:请拼写单词

+

中文含义:{currentWord.chinese_meaning}

+ setUserInput(sanitize(e.target.value))} + onKeyDown={(e) => e.key === 'Enter' && checkFlashcard()} + placeholder="输入英文单词..." + {...inputProps} + /> + +
+ ) : ( +
+

回答错误!正确答案:{currentWord.word}

+

请抄写一遍以解锁:

+ setCorrectionInput(sanitize(e.target.value))} + onKeyDown={(e) => { + if (e.key === 'Enter' && correctionInput.toLowerCase() === currentWord.word.toLowerCase()) { + retryFlashcard(); + } + }} + placeholder="请在此抄写..." + {...inputProps} + /> + {correctionInput.toLowerCase() === currentWord.word.toLowerCase() && ( + + )} +
+ )} +
+
+ +
+ ); +} + +export default StudyMode; diff --git a/frontend/src/components/SyncNotifications.js b/frontend/src/components/SyncNotifications.js index d316c44..c273619 100644 --- a/frontend/src/components/SyncNotifications.js +++ b/frontend/src/components/SyncNotifications.js @@ -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(); diff --git a/frontend/src/components/TestMode.css b/frontend/src/components/TestMode.css index 55fdd8c..05177c5 100644 --- a/frontend/src/components/TestMode.css +++ b/frontend/src/components/TestMode.css @@ -330,6 +330,155 @@ 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 index 0000000..5864b51 --- /dev/null +++ b/frontend/src/components/TestMode.js @@ -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
加载中...
; + + 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 ( +
+
+

单元测试 - Group {groupId}

+

共 {words.length} 个单词

+

+ 拼写/需背诵词({dictCount})默写;其余识别词({posCount})选词性并自测中文; + 最后 {sentCount} 道句子填空选择题。全部答对才算完成。 +

+ + +
+
+ ); + } + + if (phase === 'complete') { + return ( +
+
+

🎉 测试完成!

+

Group {groupId} 全部通过!

+ +
+
+ ); + } + + if (!current) return null; + + // 句子填空环节 + if (phase === 'sentence' || phase === 'sentence_fb') { + const isFb = phase === 'sentence_fb'; + return ( +
+
+
+ 句子填空 - Group {groupId} + {sentenceIndex + 1} / {sentenceQueue.length} +
+
+ {!isFb && ( +
+

选择填入空白处最合适的词:

+

{current.blanked}

+
+ {current.options.map((opt, i) => ( + + ))} +
+
+ )} + {isFb && ( +
+
+
+ {isCorrect ? '✅ 正确!' : `❌ 错误,你的答案: ${sentenceChoice}`} +
+
正确答案: {current.answer}
+
原句: {current.target.example_sentence}
+
中文: {current.target.chinese_meaning}
+ +
+
+ )} +
+
+ +
+ ); + } + + // 单词环节(默写 / 选词性) + const isPos = current.mode === 'pos'; + return ( +
+
+
+ + {isPos ? '选词性' : '默写'} - Group {groupId} + + {wordIndex + 1} / {wordQueue.length} +
+ +
+ {/* 答题中 */} + {phase === 'word' && !isPos && ( +
+
+

{current.chinese_meaning}

+

({current.part_of_speech})

+
+
+ setUserInput(sanitize(e.target.value))} + placeholder="请输入英文单词..." + className="test-input" + {...inputProps} + /> + +
+
+ )} + + {phase === 'word' && isPos && ( +
+
+

{current.word}

+ {current.example_sentence && ( +

例句: {current.example_sentence}

+ )} +

请选择该词的词性:

+
+
+ {current.posOptions.map((opt, i) => ( + + ))} +
+
+ )} + + {/* 反馈区 */} + {(phase === 'word_fb' || phase === 'copy') && ( +
+
+
+ {isCorrect ? '✅ 正确!' : (isPos ? `❌ 词性错误,你的选择: ${posChoice}` : `❌ 错误,你的答案: ${userInput}`)} +
+ +
{current.word}
+
中文: {current.chinese_meaning}
+
词性: {current.part_of_speech}
+ + {current.word_root && (
词根: {current.word_root}
)} + {current.affix && (
组成: {current.affix}
)} + {current.example_sentence && (
例句: {current.example_sentence}
)} + + {/* 选词性环节:自测回忆中文意思(仅自测,不阻塞) */} + {isPos && ( +
+

自己回忆的中文意思是否一致?(仅自测)

+
+ setChineseRecall(e.target.value)} + placeholder="回忆的中文意思..." + className="recall-input" + onPaste={blockClipboard} + onContextMenu={blockClipboard} + /> + +
+ {compareResult !== null && ( +
+ {compareResult ? '✅ 与参考一致/接近' : '❌ 与参考不一致'} +
+ )} +
+ )} + + {!isCorrect && phase === 'word_fb' && !isPos && ( + + )} + + {(isCorrect || isPos) && ( + + )} +
+ + {current.picture_id && ( +
+ mindmap +
+ )} +
+ )} + + {/* 抄写纠正区(默写错误) */} + {phase === 'copy' && ( +
+ + setCopyInput(sanitize(e.target.value))} + className={`copy-input ${copyInput.toLowerCase() === (current.word || '').toLowerCase() ? 'correct' : ''}`} + placeholder="请在此抄写正确单词..." + {...inputProps} + /> + + {copyInput.toLowerCase() === (current.word || '').toLowerCase() ? '✅' : '❌'} + + {copyInput.toLowerCase() === (current.word || '').toLowerCase() && ( + + )} +
+ )} +
+
+ +
+ ); +} + +export default TestMode; diff --git a/frontend/src/db.js b/frontend/src/db.js index 4d096fd..c36fd9f 100644 --- a/frontend/src/db.js +++ b/frontend/src/db.js @@ -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')); } diff --git a/frontend/src/sync.js b/frontend/src/sync.js index 660397a..e845979 100644 --- a/frontend/src/sync.js +++ b/frontend/src/sync.js @@ -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' 使请求键与 /