From: CodeBuddy Date: Thu, 20 Aug 2026 05:07:46 +0000 (+0800) Subject: V2: Daily Batch 学习模型完整实现 X-Git-Url: http://acesimba.cloud/gitweb/?a=commitdiff_plain;h=7a150327e2dce48535cd45bbedcedc686991883c;p=words.git V2: Daily Batch 学习模型完整实现 - 数据库: init_v2_db.py 导入词库(1852词, spell 560/recognize 1292) + 新列 + 5 张 V2 表(word_progress/daily_plans/daily_plan_items/learning_events/study_sessions) - 后端: 动态 Daily Batch 调度器(预算/成本权重/15分钟上限) + UUID 事件幂等 + 主动时长会话 + 5 类综合题库(best-effort, 标记待校对) + V2 日历聚合(legacy 历史合并) - 前端: Home 改动态仪表盘; 新增 DailyBatch 会话(spell/recognize 新学+复习 + 5 综合题 + 错词队尾重测 + Active Time); V2 日历(时长/新/复/题 + 日详情); GroupView 只读带 memory_type - 同步: UUID 事件/会话/计划 离线优先, 取代 (group,op) 去重 - 隔离: 独立 DB(bcd_v2.db)/端口(1061)/路径(/v2)/IndexedDB(words-pwa-v2)/SW cache(words-pwa-v2)/token - V1 不受影响(独立分支/服务/库) --- diff --git a/backend/init_v2_db.py b/backend/init_v2_db.py new file mode 100644 index 0000000..2610628 --- /dev/null +++ b/backend/init_v2_db.py @@ -0,0 +1,256 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +V2 数据库初始化脚本 +------------------- +从 V1 线上库 (bcd.db) 导入词库与图片作为 V2 知识底表,并搭建 V2 新表结构。 +本脚本只作用于 bcd_v2.db(V2 独立库),不触碰 V1 的 bcd.db。 + +执行: + python3 init_v2_db.py +幂等:已导入词库时跳过导入,但会补齐 schema。 + +V2 模型要点(见改造方案): +- words 增加 subgroupid / unit_order / memory_type / derived_from / pos_quiz_answer / exam_priority +- word_progress / daily_plans / daily_plan_items / learning_events / study_sessions 为 V2 新表 +- study_logs 保留为 legacy(导入历史,仅作展示,不再被 V2 前端写入) +""" +import sqlite3 +import os +import sys + +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +SOURCE_DB = "/home/words/backend/db/bcd.db" # V1 线上库(只读) +TARGET_DB = os.path.join(BASE_DIR, "db", "bcd_v2.db") # V2 独立库 + +OLD_WORD_COLS = ["ID", "picture_id", "word", "chinese_meaning", + "part_of_speech", "word_root", "affix", "example_sentence", "groupid"] + +def log(msg): + print(msg, flush=True) + +def import_base_data(src, tgt): + """把 words / pictures / schema_comments / study_logs 从 V1 导入 V2(legacy)。""" + tgt_wc = tgt.execute("SELECT COUNT(*) FROM words").fetchone()[0] + if tgt_wc > 0: + log(f"[import] words 已有 {tgt_wc} 行,跳过导入。") + return + + # pictures + rows = src.execute("SELECT ID, pictures, enabled FROM pictures").fetchall() + tgt.executemany("INSERT INTO pictures (ID, pictures, enabled) VALUES (?,?,?)", rows) + log(f"[import] pictures: {len(rows)} 行") + + # schema_comments + rows = src.execute( + "SELECT object_type, table_name, column_name, description, updated_at FROM schema_comments" + ).fetchall() + tgt.executemany( + "INSERT INTO schema_comments (object_type, table_name, column_name, description, updated_at) VALUES (?,?,?,?,?)", + rows) + log(f"[import] schema_comments: {len(rows)} 行") + + # study_logs(legacy 历史,仅供展示) + rows = src.execute( + "SELECT id, group_id, op_type, status, created_at, log_date, duration_sec FROM study_logs" + ).fetchall() + tgt.executemany( + "INSERT INTO study_logs (id, group_id, op_type, status, created_at, log_date, duration_sec) VALUES (?,?,?,?,?,?,?)", + rows) + log(f"[import] study_logs (legacy): {len(rows)} 行") + + # words(仅旧列,新列使用默认值/后续填充) + placeholders = ",".join(["?"] * len(OLD_WORD_COLS)) + cols = ",".join(OLD_WORD_COLS) + rows = src.execute(f"SELECT {cols} FROM words ORDER BY ID").fetchall() + tgt.executemany(f"INSERT INTO words ({cols}) VALUES ({placeholders})", rows) + log(f"[import] words: {len(rows)} 行") + + +def alter_words(tgt): + """为 words 增加 V2 新列(幂等)。""" + existing = {r[1] for r in tgt.execute("PRAGMA table_info(words)").fetchall()} + alters = [ + "ALTER TABLE words ADD COLUMN subgroupid TEXT", + "ALTER TABLE words ADD COLUMN unit_order INTEGER", + "ALTER TABLE words ADD COLUMN memory_type TEXT DEFAULT 'spell'", + "ALTER TABLE words ADD COLUMN derived_from TEXT", + "ALTER TABLE words ADD COLUMN pos_quiz_answer TEXT", + "ALTER TABLE words ADD COLUMN exam_priority TEXT", + ] + for sql in alters: + col = sql.split("ADD COLUMN ")[1].split(" ")[0] + if col not in existing: + tgt.execute(sql) + log(f"[alter] 新增列 {col}") + else: + log(f"[alter] 列 {col} 已存在,跳过") + + +def fill_unit_order(tgt): + """单元稳定顺序:按 (groupid, ID) 排。""" + rows = tgt.execute( + "SELECT ID FROM words ORDER BY CAST(groupid AS INTEGER) ASC, ID ASC" + ).fetchall() + tgt.executemany("UPDATE words SET unit_order=? WHERE ID=?", + [(i + 1, r[0]) for i, r in enumerate(rows)]) + log(f"[fill] unit_order 已设置 {len(rows)} 行") + + +def classify_memory_type(tgt): + """ + 按词根家族划分 spell / recognize: + - 同一 (groupid, word_root) 家族中,基词(n./v. 优先,否则最短词)为 spell; + - 其余派生词为 recognize,derived_from 指向基词 ID。 + - 无词根词默认 spell。 + - recognize 且词性唯一(不含 '/')时,pos_quiz_answer = 该词性。 + """ + words = tgt.execute( + "SELECT ID, word, part_of_speech, word_root, groupid FROM words" + ).fetchall() + + families = {} + for w in words: + wid, word, pos, root, gid = w + if root and root.strip(): + families.setdefault((gid, root.strip()), []).append(w) + + updates = [] # (memory_type, derived_from, pos_quiz_answer, wid) + for w in words: + wid, word, pos, root, gid = w + if not (root and root.strip()): + updates.append(("spell", None, None, wid)) + continue + fam = families[(gid, root.strip())] + if len(fam) == 1: + updates.append(("spell", None, None, wid)) + continue + # 选基词:优先 n./v. + base = None + for m in fam: + if m[2] and (m[2].strip().startswith("n.") or m[2].strip().startswith("v.")): + base = m + break + if base is None: + base = min(fam, key=lambda x: len(x[1])) + if wid == base[0]: + updates.append(("spell", None, None, wid)) + else: + pos_ans = pos.strip() if (pos and "/" not in pos) else None + updates.append(("recognize", base[0], pos_ans, wid)) + + tgt.executemany( + "UPDATE words SET memory_type=?, derived_from=?, pos_quiz_answer=? WHERE ID=?", + updates) + spell = sum(1 for u in updates if u[0] == "spell") + recog = sum(1 for u in updates if u[0] == "recognize") + log(f"[classify] memory_type: spell={spell}, recognize={recog}") + + +def create_v2_tables(tgt): + """创建 V2 新表(幂等)。""" + tgt.executescript(""" + CREATE TABLE IF NOT EXISTS word_progress ( + word_id INTEGER PRIMARY KEY, + first_learned_at DATETIME, + last_seen_at DATETIME, + next_review_at DATETIME, + memory_type TEXT, + mastery_level INTEGER DEFAULT 0, + spell_correct INTEGER DEFAULT 0, + spell_wrong INTEGER DEFAULT 0, + recognize_correct INTEGER DEFAULT 0, + recognize_wrong INTEGER DEFAULT 0, + quiz_correct INTEGER DEFAULT 0, + quiz_wrong INTEGER DEFAULT 0, + updated_at DATETIME + ); + + CREATE TABLE IF NOT EXISTS daily_plans ( + plan_id TEXT PRIMARY KEY, + plan_date DATE UNIQUE NOT NULL, + planned_new INTEGER DEFAULT 0, + planned_spell INTEGER DEFAULT 0, + planned_recognize INTEGER DEFAULT 0, + planned_review INTEGER DEFAULT 0, + planned_quiz INTEGER DEFAULT 5, + estimated_sec INTEGER DEFAULT 0, + status TEXT DEFAULT 'planned', + created_at DATETIME + ); + + CREATE TABLE IF NOT EXISTS daily_plan_items ( + plan_id TEXT NOT NULL, + item_type TEXT NOT NULL, + word_id INTEGER, + question_id TEXT, + order_no INTEGER, + payload TEXT, + PRIMARY KEY (plan_id, order_no) + ); + + CREATE TABLE IF NOT EXISTS learning_events ( + event_id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + plan_id TEXT, + word_id INTEGER, + group_id INTEGER, + stage TEXT NOT NULL, + question_type TEXT NOT NULL, + result TEXT NOT NULL, + attempt_no INTEGER DEFAULT 1, + duration_ms INTEGER DEFAULT 0, + created_at DATETIME, + log_date DATE + ); + + CREATE TABLE IF NOT EXISTS study_sessions ( + session_id TEXT PRIMARY KEY, + plan_id TEXT, + log_date DATE NOT NULL, + started_at DATETIME, + ended_at DATETIME, + active_sec INTEGER DEFAULT 0, + new_sec INTEGER DEFAULT 0, + review_sec INTEGER DEFAULT 0, + quiz_sec INTEGER DEFAULT 0, + completed INTEGER DEFAULT 0, + updated_at DATETIME + ); + """) + log("[tables] V2 新表已就绪 (word_progress/daily_plans/daily_plan_items/learning_events/study_sessions)") + + +def mark_legacy(tgt): + """在 schema_comments 中标注 study_logs 为 legacy。""" + tgt.execute( + "INSERT OR REPLACE INTO schema_comments (object_type, table_name, column_name, description, updated_at) " + "VALUES ('table','study_logs',NULL,'V2 legacy:V1 历史进度,仅展示,不再由 V2 前端写入',CURRENT_TIMESTAMP)" + ) + log("[legacy] study_logs 标记为 legacy") + + +def main(): + if not os.path.exists(SOURCE_DB): + log(f"[error] 源库不存在: {SOURCE_DB}") + sys.exit(1) + src = sqlite3.connect(SOURCE_DB) + src.row_factory = sqlite3.Row + tgt = sqlite3.connect(TARGET_DB) + tgt.row_factory = sqlite3.Row + try: + import_base_data(src, tgt) + alter_words(tgt) + fill_unit_order(tgt) + classify_memory_type(tgt) + create_v2_tables(tgt) + mark_legacy(tgt) + tgt.commit() + log("[done] V2 数据库初始化完成。") + finally: + src.close() + tgt.close() + + +if __name__ == "__main__": + main() diff --git a/backend/main.py b/backend/main.py index 346102e..c63efe1 100644 --- a/backend/main.py +++ b/backend/main.py @@ -416,7 +416,7 @@ async def update_progress(data: ProgressUpdate): except Exception as e: raise HTTPException(status_code=500, detail=str(e)) -@app.get("/api/calendar") +@app.get("/api/calendar/legacy") async def get_calendar(): # 按日期聚合 study_logs,供学习日历使用;同时计算每日"实际学习时长"(分钟) # 实际学习时长 = 当天各 (group,op) 完成时长(duration_sec)之和, 不含间隔, 即把各次学习时段相加 @@ -518,10 +518,773 @@ async def auth_login(data: LoginRequest): raise HTTPException(status_code=401, detail="用户名或密码错误") +# ===================================================================== +# V2 后端:动态 Daily Batch + word_progress + 事件日志 + 综合题库 +# 仅作用于 bcd_v2.db(独立库),不与 V1 端点冲突。 +# ===================================================================== +import uuid +import json +import random +import re +from datetime import timedelta + +V2_NOTE = "AI生成/待校对" +QUIZ_PER_DAY = 5 +MAX_NEW_PER_DAY = 12 +MAX_SPELL_PER_DAY = 6 +BUDGET_SEC = 15 * 60 +QUIZ_FIXED_SEC = 5 * 40 +# 粗略 cost(秒):用于预算控制,不展示给学生 +COST = { + "spell_new": 60, "recognize_new": 21, + "spell_review": 39, "recognize_review": 12, +} + +# ---------- V2 时间/连接辅助 ---------- +def _china_dt(): + # 用 UTC+8 计算"今天",与前端 localTimestamp() 一致,规避服务器时区差异 + return datetime.datetime.utcnow() + timedelta(hours=8) + +def china_date(): + return _china_dt().strftime("%Y-%m-%d") + +def china_now_str(): + return _china_dt().strftime("%Y-%m-%d %H:%M:%S") + +def _add_days(date_str, n): + d = datetime.datetime.strptime(date_str[:10], "%Y-%m-%d") + return (d + timedelta(days=n)).strftime("%Y-%m-%d") + +def get_conn(): + conn = sqlite3.connect(DATABASE_PATH) + conn.row_factory = sqlite3.Row + return conn + +V2_CREATE_SQL = """ +CREATE TABLE IF NOT EXISTS word_progress ( + word_id INTEGER PRIMARY KEY, + first_learned_at DATETIME, + last_seen_at DATETIME, + next_review_at DATETIME, + memory_type TEXT, + mastery_level INTEGER DEFAULT 0, + spell_correct INTEGER DEFAULT 0, + spell_wrong INTEGER DEFAULT 0, + recognize_correct INTEGER DEFAULT 0, + recognize_wrong INTEGER DEFAULT 0, + quiz_correct INTEGER DEFAULT 0, + quiz_wrong INTEGER DEFAULT 0, + updated_at DATETIME +); +CREATE TABLE IF NOT EXISTS daily_plans ( + plan_id TEXT PRIMARY KEY, + plan_date DATE UNIQUE NOT NULL, + planned_new INTEGER DEFAULT 0, + planned_spell INTEGER DEFAULT 0, + planned_recognize INTEGER DEFAULT 0, + planned_review INTEGER DEFAULT 0, + planned_quiz INTEGER DEFAULT 5, + estimated_sec INTEGER DEFAULT 0, + status TEXT DEFAULT 'planned', + created_at DATETIME +); +CREATE TABLE IF NOT EXISTS daily_plan_items ( + plan_id TEXT NOT NULL, + item_type TEXT NOT NULL, + word_id INTEGER, + question_id TEXT, + order_no INTEGER, + payload TEXT, + PRIMARY KEY (plan_id, order_no) +); +CREATE TABLE IF NOT EXISTS learning_events ( + event_id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + plan_id TEXT, + word_id INTEGER, + group_id INTEGER, + stage TEXT NOT NULL, + question_type TEXT NOT NULL, + result TEXT NOT NULL, + attempt_no INTEGER DEFAULT 1, + duration_ms INTEGER DEFAULT 0, + created_at DATETIME, + log_date DATE +); +CREATE TABLE IF NOT EXISTS study_sessions ( + session_id TEXT PRIMARY KEY, + plan_id TEXT, + log_date DATE NOT NULL, + started_at DATETIME, + ended_at DATETIME, + active_sec INTEGER DEFAULT 0, + new_sec INTEGER DEFAULT 0, + review_sec INTEGER DEFAULT 0, + quiz_sec INTEGER DEFAULT 0, + completed INTEGER DEFAULT 0, + updated_at DATETIME +); +""" + +def ensure_v2_schema(): + """启动时确保 V2 表与新列存在(幂等)。""" + conn = get_conn() + try: + cols = {r[1] for r in conn.execute("PRAGMA table_info(words)").fetchall()} + alters = [ + ("subgroupid", "ALTER TABLE words ADD COLUMN subgroupid TEXT"), + ("unit_order", "ALTER TABLE words ADD COLUMN unit_order INTEGER"), + ("memory_type", "ALTER TABLE words ADD COLUMN memory_type TEXT DEFAULT 'spell'"), + ("derived_from", "ALTER TABLE words ADD COLUMN derived_from TEXT"), + ("pos_quiz_answer", "ALTER TABLE words ADD COLUMN pos_quiz_answer TEXT"), + ("exam_priority", "ALTER TABLE words ADD COLUMN exam_priority TEXT"), + ] + for col, sql in alters: + if col not in cols: + conn.execute(sql) + conn.executescript(V2_CREATE_SQL) + conn.commit() + finally: + conn.close() + +@app.on_event("startup") +async def _v2_startup(): + ensure_v2_schema() + +# ---------- 综合题库生成 ---------- +def _distractors(conn, answer_word, n=3): + grp = conn.execute( + "SELECT * FROM words WHERE CAST(groupid AS INTEGER)=? AND ID!=?", + (answer_word["groupid"], answer_word["ID"])).fetchall() + cands = [dict(w) for w in grp if w["word"]] + if len(cands) < n: + extra = conn.execute( + "SELECT * FROM words WHERE ID!=? ORDER BY RANDOM() LIMIT ?", + (answer_word["ID"], n - len(cands))).fetchall() + cands += [dict(w) for w in extra] + return cands[:n] + +def _make_mcq(conn, answer_word, distract_words, prompt, qtype, related_ids): + opts = [answer_word["word"]] + for d in distract_words[:3]: + if d["word"] not in opts: + opts.append(d["word"]) + while len(opts) < 4: + extra = conn.execute( + "SELECT word FROM words WHERE word NOT IN ({}) ORDER BY RANDOM() LIMIT 1".format( + ",".join("?" * len(opts))), opts).fetchone() + if extra and extra[0] not in opts: + opts.append(extra[0]) + else: + break + random.shuffle(opts) + return { + "type": qtype, + "prompt": prompt, + "options": opts, + "answer": answer_word["word"], + "answer_index": opts.index(answer_word["word"]), + "related_word_ids": related_ids, + "note": V2_NOTE, + } + +def _try_word_form(conn, today_words): + for base in today_words: + if base["memory_type"] != "spell": + continue + root = (base["word_root"] or "").strip() + if not root: + continue + sibs = conn.execute( + "SELECT * FROM words WHERE CAST(groupid AS INTEGER)=? AND word_root=? AND memory_type='recognize' AND ID!=?", + (base["groupid"], root, base["ID"])).fetchall() + sibs = [dict(s) for s in sibs if (s["word_root"] or "").strip()] + if sibs: + ans = random.choice(sibs) + hint = ans["pos_quiz_answer"] or ans["part_of_speech"] or "派生形式" + prompt = f"词形变化:「{base['word']}」的{hint}形式是?" + return _make_mcq(conn, ans, _distractors(conn, ans), prompt, "word_form", [base["ID"], ans["ID"]]) + return None + +def _try_pos(conn, today_words): + for w in today_words: + if w["memory_type"] == "recognize" and w["pos_quiz_answer"]: + prompt = f"词性判断:「{w['word']}」的主要词性是?" + correct = w["pos_quiz_answer"] + pool = ["n.", "v.", "adj.", "adv.", "prep.", "conj."] + opts = [correct] + random.sample([p for p in pool if p != correct], 3) + random.shuffle(opts) + return {"type": "pos", "prompt": prompt, "options": opts, + "answer": correct, "answer_index": opts.index(correct), + "related_word_ids": [w["ID"]], "note": V2_NOTE} + return None + +def _try_confusable(conn, today_words): + for w in today_words: + if not w["word"]: + continue + pre = w["word"][:3].lower() + sim = conn.execute( + "SELECT * FROM words WHERE CAST(groupid AS INTEGER)=? AND ID!=? AND LOWER(word) LIKE ?", + (w["groupid"], w["ID"], pre + "%")).fetchall() + sim = [dict(s) for s in sim if s["word"] and s["ID"] != w["ID"]] + if sim: + opts = [w["word"]] + [s["word"] for s in sim[:2]] + for d in _distractors(conn, w): + if d["word"] not in opts: + opts.append(d["word"]) + opts = opts[:4] + while len(opts) < 4: + opts.append("—") + random.shuffle(opts) + prompt = f"易混词:意思是「{w['chinese_meaning']}」的单词是?" + return {"type": "confusable", "prompt": prompt, "options": opts, + "answer": w["word"], "answer_index": opts.index(w["word"]), + "related_word_ids": [w["ID"]], "note": V2_NOTE} + return None + +def _try_context(conn, words): + for w in words: + ex = w["example_sentence"] + if not ex or not w["word"]: + continue + if w["word"].lower() not in ex.lower(): + continue + pattern = re.compile(re.escape(w["word"]), re.IGNORECASE) + blanked = pattern.sub("_____", ex, count=1) + if blanked == ex: + continue + opts = [w["word"]] + [d["word"] for d in _distractors(conn, w)[:3]] + random.shuffle(opts) + prompt = f"语境填空:{blanked}" + return {"type": "context", "prompt": prompt, "options": opts, + "answer": w["word"], "answer_index": opts.index(w["word"]), + "related_word_ids": [w["ID"]], "note": V2_NOTE} + return None + +def _fallback_mcq(conn, word, qtype): + prompt = f"词义选择:「{word['chinese_meaning']}」对应的英文单词是?" + return _make_mcq(conn, word, _distractors(conn, word), prompt, qtype, [word["ID"]]) + +def build_quiz_questions(conn, date_str, today_new_ids, review_ids, words_map): + today_words = [words_map[i] for i in today_new_ids if i in words_map] + review_words = [words_map[i] for i in review_ids if i in words_map] + random.shuffle(today_words) + random.shuffle(review_words) + # 兜底词池:已学词 → 任意词 + learned = [dict(r) for r in conn.execute( + "SELECT w.* FROM words w JOIN word_progress p ON p.word_id=w.ID LIMIT 60").fetchall()] + random.shuffle(learned) + any_words = [dict(r) for r in conn.execute("SELECT * FROM words ORDER BY RANDOM() LIMIT 60").fetchall()] + random.shuffle(any_words) + + def pick(preferred, fallback): + for lst in (preferred, learned, any_words): + if lst: + return lst[0] + return None + + questions = [] + builders = [ + ("word_form", lambda: _try_word_form(conn, today_words), today_words), + ("pos", lambda: _try_pos(conn, today_words), today_words), + ("confusable", lambda: _try_confusable(conn, today_words), today_words), + ("context", lambda: _try_context(conn, review_words if review_words else today_words), review_words if review_words else today_words), + ("context", lambda: _try_context(conn, today_words if today_words else review_words), today_words if today_words else review_words), + ] + for qtype, fn, src in builders: + q = fn() + if not q: + w = pick(src, None) + q = _fallback_mcq(conn, w, qtype) if w else None + if not q: + # 最终兜底:任意词 + q = _fallback_mcq(conn, any_words[0], qtype) + questions.append(q) + # 冻结 question_id(含日期,天然 14 天内不重复) + for i, q in enumerate(questions): + q["question_id"] = f"{date_str}-Q{i + 1}" + return questions[:QUIZ_PER_DAY] + +# ---------- Daily Plan 调度 ---------- +def generate_daily_plan(date_str): + conn = get_conn() + try: + existing = conn.execute("SELECT * FROM daily_plans WHERE plan_date=?", (date_str,)).fetchone() + if existing: + return _plan_with_items(conn, date_str) + + words = conn.execute( + "SELECT ID, groupid, memory_type, unit_order FROM words " + "ORDER BY CAST(groupid AS INTEGER) ASC, COALESCE(unit_order, ID) ASC").fetchall() + progress = {r["word_id"]: r for r in conn.execute("SELECT * FROM word_progress").fetchall()} + + # 到期复习(错词优先) + due = [] + for w in words: + p = progress.get(w["ID"]) + if p and p["next_review_at"] and p["next_review_at"] <= date_str: + wrong = (p["spell_wrong"] or 0) + (p["recognize_wrong"] or 0) + (p["quiz_wrong"] or 0) + due.append((w, wrong)) + due.sort(key=lambda x: -x[1]) + review_words = [w for w, _ in due] + + # 新词(无 first_learned_at) + new_ids = [w["ID"] for w in words + if w["ID"] not in progress or not progress[w["ID"]]["first_learned_at"]] + new_set = set(new_ids) + new_spell = [w for w in words if w["ID"] in new_set and w["memory_type"] == "spell"] + new_recognize = [w for w in words if w["ID"] in new_set and w["memory_type"] == "recognize"] + + # 预算 + review_sec = 0 + for w in review_words: + c = COST["spell_review"] if w["memory_type"] == "spell" else COST["recognize_review"] + p = progress[w["ID"]] + wrong = (p["spell_wrong"] or 0) + (p["recognize_wrong"] or 0) + if wrong > 0: + c = int(c * 1.2) + review_sec += c + remaining = BUDGET_SEC - QUIZ_FIXED_SEC - review_sec + if remaining < 0: + remaining = 0 + + chosen_spell, chosen_recognize = [], [] + planned_spell = planned_recognize = 0 + for w in new_spell: + if remaining <= 0 or planned_spell >= MAX_SPELL_PER_DAY: + break + chosen_spell.append(w) + planned_spell += 1 + remaining -= COST["spell_new"] + for w in new_recognize: + if remaining <= 0 or (planned_spell + planned_recognize) >= MAX_NEW_PER_DAY: + break + chosen_recognize.append(w) + planned_recognize += 1 + remaining -= COST["recognize_new"] + + planned_new = planned_spell + planned_recognize + planned_review = len(review_words) + est_new_sec = planned_spell * COST["spell_new"] + planned_recognize * COST["recognize_new"] + estimated_sec = QUIZ_FIXED_SEC + review_sec + est_new_sec + + plan_id = str(uuid.uuid4()) + now = china_now_str() + conn.execute( + "INSERT INTO daily_plans (plan_id, plan_date, planned_new, planned_spell, " + "planned_recognize, planned_review, planned_quiz, estimated_sec, status, created_at) " + "VALUES (?,?,?,?,?,?,?,?,?,?)", + (plan_id, date_str, planned_new, planned_spell, planned_recognize, + planned_review, QUIZ_PER_DAY, estimated_sec, "planned", now)) + + order_no = 1 + for w in chosen_spell: + conn.execute("INSERT INTO daily_plan_items (plan_id, item_type, word_id, order_no) VALUES (?, 'new_spell', ?, ?)", + (plan_id, w["ID"], order_no)); order_no += 1 + for w in chosen_recognize: + conn.execute("INSERT INTO daily_plan_items (plan_id, item_type, word_id, order_no) VALUES (?, 'new_recognize', ?, ?)", + (plan_id, w["ID"], order_no)); order_no += 1 + for w in review_words: + it = "review_spell" if w["memory_type"] == "spell" else "review_recognize" + conn.execute("INSERT INTO daily_plan_items (plan_id, item_type, word_id, order_no) VALUES (?, ?, ?, ?)", + (plan_id, it, w["ID"], order_no)); order_no += 1 + + words_map = {r["ID"]: dict(r) for r in conn.execute("SELECT * FROM words").fetchall()} + quiz = build_quiz_questions(conn, date_str, + [w["ID"] for w in chosen_spell] + [w["ID"] for w in chosen_recognize], + [w["ID"] for w in review_words], words_map) + for q in quiz: + conn.execute( + "INSERT INTO daily_plan_items (plan_id, item_type, word_id, question_id, order_no, payload) " + "VALUES (?, 'quiz', NULL, ?, ?, ?)", + (plan_id, q["question_id"], order_no, json.dumps(q, ensure_ascii=False))); order_no += 1 + + conn.commit() + return _plan_with_items(conn, date_str) + finally: + conn.close() + +def _plan_with_items(conn, date_str): + plan = conn.execute("SELECT * FROM daily_plans WHERE plan_date=?", (date_str,)).fetchone() + if not plan: + return None + items = conn.execute( + "SELECT * FROM daily_plan_items WHERE plan_id=? ORDER BY order_no", + (plan["plan_id"],)).fetchall() + ids = [it["word_id"] for it in items if it["word_id"]] + words_map = {} + if ids: + place = ",".join("?" * len(ids)) + words_map = {r["ID"]: dict(r) for r in + conn.execute(f"SELECT * FROM words WHERE ID IN ({place})", ids).fetchall()} + out_items = [] + for it in items: + d = {"item_type": it["item_type"], "order_no": it["order_no"], + "word_id": it["word_id"], "question_id": it["question_id"]} + if it["word_id"] and it["word_id"] in words_map: + d["word"] = words_map[it["word_id"]] + if it["item_type"] == "quiz" and it["payload"]: + d["question"] = json.loads(it["payload"]) + out_items.append(d) + result = dict(plan) + result["items"] = out_items + result["estimated_min"] = round((result.get("estimated_sec") or 0) / 60) + return result + +# ---------- V2 数据模型 ---------- +class DailyPlanCreate(BaseModel): + date: Optional[str] = None + +class EventItem(BaseModel): + event_id: str + plan_id: Optional[str] = None + word_id: Optional[int] = None + group_id: Optional[int] = None + stage: str + question_type: str + result: str + attempt_no: int = 1 + duration_ms: int = 0 + created_at: Optional[str] = None + +class EventsBatch(BaseModel): + session_id: str + events: List[EventItem] + +class SessionUpsert(BaseModel): + session_id: str + plan_id: Optional[str] = None + log_date: Optional[str] = None + started_at: Optional[str] = None + ended_at: Optional[str] = None + active_sec: int = 0 + new_sec: int = 0 + review_sec: int = 0 + quiz_sec: int = 0 + completed: int = 0 + +# ---------- V2 端点 ---------- +@app.get("/api/v2/home") +async def v2_home(): + conn = get_conn() + try: + wc = conn.execute("SELECT COUNT(*) FROM words").fetchone()[0] + learned = conn.execute( + "SELECT COUNT(*) FROM word_progress WHERE first_learned_at IS NOT NULL").fetchone()[0] + today = china_date() + plan = conn.execute("SELECT * FROM daily_plans WHERE plan_date=?", (today,)).fetchone() + plan_info = None + if plan: + plan_info = {k: plan[k] for k in + ["plan_date", "planned_new", "planned_spell", "planned_recognize", + "planned_review", "planned_quiz", "estimated_sec", "status"]} + plan_info["estimated_min"] = round((plan_info["estimated_sec"] or 0) / 60) + return {"words_count": wc, "learned_count": learned, "today": today, "today_plan": plan_info} + finally: + conn.close() + +@app.get("/api/words/v2/all") +async def get_words_v2_all(): + try: + conn = get_conn() + rows = conn.execute( + "SELECT ID as id, word, chinese_meaning, part_of_speech, word_root, affix, " + "example_sentence, picture_id, groupid, unit_order, memory_type, derived_from, " + "pos_quiz_answer, exam_priority FROM words " + "ORDER BY CAST(groupid AS INTEGER) ASC, COALESCE(unit_order, ID) ASC").fetchall() + conn.close() + return [dict(r) for r in rows] + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@app.get("/api/words/v2/{group_id}") +async def get_words_v2_group(group_id: str): + try: + conn = get_conn() + rows = conn.execute( + "SELECT ID as id, word, chinese_meaning, part_of_speech, word_root, affix, " + "example_sentence, picture_id, groupid, unit_order, memory_type, derived_from, " + "pos_quiz_answer, exam_priority FROM words WHERE CAST(groupid AS INTEGER)=? " + "ORDER BY COALESCE(unit_order, ID) ASC", (group_id,)).fetchall() + conn.close() + return [dict(r) for r in rows] + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@app.get("/api/daily-plan/{date}") +async def get_daily_plan(date: str): + conn = get_conn() + try: + plan = _plan_with_items(conn, date) + finally: + conn.close() + if not plan: + raise HTTPException(status_code=404, detail="plan not found") + return plan + +@app.post("/api/daily-plan/create") +async def create_daily_plan(body: DailyPlanCreate): + date_str = body.date or china_date() + plan = generate_daily_plan(date_str) + return plan + +def _apply_event_to_progress(conn, ev): + wid = ev.word_id + if not wid: + return + stage = ev.stage + qtype = ev.question_type + result = ev.result + now = china_now_str() + row = conn.execute("SELECT * FROM word_progress WHERE word_id=?", (wid,)).fetchone() + if not row: + mt = None + w = conn.execute("SELECT memory_type FROM words WHERE ID=?", (wid,)).fetchone() + if w: + mt = w["memory_type"] + conn.execute( + "INSERT INTO word_progress (word_id, first_learned_at, last_seen_at, next_review_at, " + "memory_type, mastery_level, updated_at) VALUES (?,?,?,?,?,?,?)", + (wid, now if stage == "new" else None, now, None, mt, 0, now)) + row = conn.execute("SELECT * FROM word_progress WHERE word_id=?", (wid,)).fetchone() + spell_c = row["spell_correct"] or 0 + spell_w = row["spell_wrong"] or 0 + rec_c = row["recognize_correct"] or 0 + rec_w = row["recognize_wrong"] or 0 + quiz_c = row["quiz_correct"] or 0 + quiz_w = row["quiz_wrong"] or 0 + mastery = row["mastery_level"] or 0 + if stage in ("new", "review"): + if qtype == "spell": + if result == "correct": + spell_c += 1 + mastery = min(mastery + 1, 5) + else: + spell_w += 1 + mastery = max(mastery - 1, 0) + elif qtype in ("pos", "recognize"): + if result == "correct": + rec_c += 1 + mastery = min(mastery + 1, 5) + else: + rec_w += 1 + mastery = max(mastery - 1, 0) + elif stage == "quiz": + if result == "correct": + quiz_c += 1 + else: + quiz_w += 1 + wrong = (spell_w + rec_w + quiz_w) > 0 + if wrong: + nxt = _add_days(china_date(), 1) + else: + nxt = _add_days(china_date(), 1 + mastery) + conn.execute( + "UPDATE word_progress SET last_seen_at=?, next_review_at=?, mastery_level=?, " + "spell_correct=?, spell_wrong=?, recognize_correct=?, recognize_wrong=?, " + "quiz_correct=?, quiz_wrong=?, updated_at=? WHERE word_id=?", + (now, nxt, mastery, spell_c, spell_w, rec_c, rec_w, quiz_c, quiz_w, now, wid)) + +@app.post("/api/events/batch") +async def post_events_batch(body: EventsBatch): + conn = get_conn() + try: + inserted = skipped = 0 + for ev in body.events: + exists = conn.execute("SELECT 1 FROM learning_events WHERE event_id=?", + (ev.event_id,)).fetchone() + if exists: + skipped += 1 + continue + gid = ev.group_id + if gid is None and ev.word_id: + r = conn.execute("SELECT groupid FROM words WHERE ID=?", (ev.word_id,)).fetchone() + gid = r[0] if r else None + created = ev.created_at or china_now_str() + log_date = created[:10] + conn.execute( + "INSERT INTO learning_events (event_id, session_id, plan_id, word_id, group_id, " + "stage, question_type, result, attempt_no, duration_ms, created_at, log_date) " + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?)", + (ev.event_id, body.session_id, ev.plan_id, ev.word_id, gid, ev.stage, + ev.question_type, ev.result, ev.attempt_no, ev.duration_ms, created, log_date)) + inserted += 1 + _apply_event_to_progress(conn, ev) + conn.commit() + return {"status": "success", "inserted": inserted, "skipped": skipped, + "received": len(body.events)} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + finally: + conn.close() + +@app.post("/api/sessions/upsert") +async def upsert_session(body: SessionUpsert): + conn = get_conn() + try: + log_date = body.log_date or china_date() + now = china_now_str() + conn.execute( + "INSERT INTO study_sessions (session_id, plan_id, log_date, started_at, ended_at, " + "active_sec, new_sec, review_sec, quiz_sec, completed, updated_at) " + "VALUES (?,?,?,?,?,?,?,?,?,?,?) " + "ON CONFLICT(session_id) DO UPDATE SET " + "plan_id=excluded.plan_id, ended_at=excluded.ended_at, " + "active_sec=MAX(active_sec, excluded.active_sec), " + "new_sec=MAX(new_sec, excluded.new_sec), " + "review_sec=MAX(review_sec, excluded.review_sec), " + "quiz_sec=MAX(quiz_sec, excluded.quiz_sec), " + "completed=MAX(completed, excluded.completed), updated_at=excluded.updated_at", + (body.session_id, body.plan_id, log_date, body.started_at, body.ended_at, + body.active_sec, body.new_sec, body.review_sec, body.quiz_sec, body.completed, now)) + conn.commit() + return {"status": "success"} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + finally: + conn.close() + +@app.get("/api/progress/words") +async def get_progress_words(): + conn = get_conn() + try: + rows = conn.execute("SELECT * FROM word_progress").fetchall() + return [dict(r) for r in rows] + finally: + conn.close() + +@app.get("/api/questions/daily/{date}") +async def get_questions_daily(date: str): + conn = get_conn() + try: + items = conn.execute( + "SELECT payload FROM daily_plan_items WHERE plan_id=(SELECT plan_id FROM daily_plans WHERE plan_date=?) " + "AND item_type='quiz' ORDER BY order_no", (date,)).fetchall() + if not items: + raise HTTPException(status_code=404, detail="no quiz for date") + return [json.loads(it["payload"]) for it in items] + finally: + conn.close() + +@app.get("/api/calendar") +async def v2_calendar(month: str = None): + if not month: + month = china_date()[:7] + conn = get_conn() + try: + def blank(): + return {"duration_sec": 0, "new_words": 0, "spell_words": 0, "recognize_words": 0, + "review_words": 0, "quiz_total": 0, "quiz_correct": 0, "groups": set(), "legacy": False} + days = {} + for s in conn.execute( + "SELECT log_date, COALESCE(SUM(active_sec),0) act FROM study_sessions " + "WHERE log_date LIKE ? GROUP BY log_date", (month + "%",)): + d = s["log_date"] + days.setdefault(d, blank()) + days[d]["duration_sec"] = s["act"] + for e in conn.execute( + "SELECT log_date, stage, question_type, result, group_id FROM learning_events WHERE log_date LIKE ?", + (month + "%",)): + d = e["log_date"] + dd = days.setdefault(d, blank()) + if e["stage"] == "new": + dd["new_words"] += 1 + if e["question_type"] == "spell": + dd["spell_words"] += 1 + else: + dd["recognize_words"] += 1 + elif e["stage"] == "review": + dd["review_words"] += 1 + elif e["stage"] == "quiz": + dd["quiz_total"] += 1 + if e["result"] == "correct": + dd["quiz_correct"] += 1 + if e["group_id"] is not None: + dd["groups"].add(str(e["group_id"])) + # legacy study_logs(V1 历史)合并,保持连续记录可见 + for r in conn.execute( + "SELECT COALESCE(log_date,substr(created_at,1,10)) day, op_type, group_id " + "FROM study_logs WHERE status='completed' AND (log_date LIKE ? OR substr(created_at,1,7)=?)", + (month + "%", month)): + d = r["day"] + dd = days.setdefault(d, blank()) + dd["legacy"] = True + if dd["duration_sec"] == 0: + dur = conn.execute( + "SELECT COALESCE(SUM(duration_sec),0) FROM study_logs WHERE status='completed' " + "AND COALESCE(log_date,substr(created_at,1,10))=?", (d,)).fetchone()[0] + dd["duration_sec"] = dur + if r["op_type"] == "study" and r["group_id"] is not None: + dd["groups"].add(str(r["group_id"])) + out = [] + for d in sorted(days): + dd = days[d] + out.append({ + "date": d, + "duration_min": round(dd["duration_sec"] / 60), + "new_words": dd["new_words"], + "spell_words": dd["spell_words"], + "recognize_words": dd["recognize_words"], + "review_words": dd["review_words"], + "quiz_total": dd["quiz_total"], + "quiz_correct": dd["quiz_correct"], + "groups_touched": sorted(dd["groups"], key=lambda x: (len(x), x)), + "legacy": dd["legacy"], + }) + return out + finally: + conn.close() + +@app.get("/api/calendar/{date}") +async def v2_calendar_day(date: str): + conn = get_conn() + try: + sess = conn.execute("SELECT * FROM study_sessions WHERE log_date=?", (date,)).fetchall() + evs = conn.execute("SELECT * FROM learning_events WHERE log_date=?", (date,)).fetchall() + active_sec = sum((s["active_sec"] or 0) for s in sess) + new_words = sum(1 for e in evs if e["stage"] == "new") + review_words = sum(1 for e in evs if e["stage"] == "review") + quiz_total = sum(1 for e in evs if e["stage"] == "quiz") + quiz_correct = sum(1 for e in evs if e["stage"] == "quiz" and e["result"] == "correct") + spell_words = sum(1 for e in evs if e["stage"] == "new" and e["question_type"] == "spell") + recognize_words = sum(1 for e in evs if e["stage"] == "new" and e["question_type"] != "spell") + groups = sorted({str(e["group_id"]) for e in evs if e["group_id"] is not None}) + # 薄弱点:当天答错的词 + weak = {} + for e in evs: + if e["result"] != "correct" and e["word_id"]: + weak.setdefault(e["word_id"], {"word_id": e["word_id"], "wrong_types": set()}) + weak[e["word_id"]]["wrong_types"].add(e["question_type"]) + weak_ids = list(weak.keys()) + weak_words = {} + if weak_ids: + place = ",".join("?" * len(weak_ids)) + weak_words = {r["ID"]: r["word"] for r in + conn.execute(f"SELECT ID, word FROM words WHERE ID IN ({place})", weak_ids).fetchall()} + weak_list = [] + for wid, info in weak.items(): + weak_list.append({"word_id": wid, "word": weak_words.get(wid), + "wrong_types": sorted(info["wrong_types"])}) + return { + "date": date, + "duration_min": round(active_sec / 60), + "new_words": new_words, + "spell_words": spell_words, + "recognize_words": recognize_words, + "review_words": review_words, + "quiz_total": quiz_total, + "quiz_correct": quiz_correct, + "groups_touched": groups, + "weak_points": weak_list, + } + finally: + conn.close() + @app.get("/") async def root(): - return {"message": "English Lexicon API is running"} + return {"message": "English Lexicon API (V2) is running"} if __name__ == "__main__": import uvicorn - uvicorn.run(app, host="0.0.0.0", port=1060) + uvicorn.run(app, host="0.0.0.0", port=1061) diff --git a/frontend/public/sw.js b/frontend/public/sw.js index beef00e..94ba4c5 100644 --- a/frontend/public/sw.js +++ b/frontend/public/sw.js @@ -5,7 +5,7 @@ // - navigation (HTML) → network-first, 失败回退 cached index.html // - 静态资源 (JS/CSS/img/font) → cache-first -const CACHE_VERSION = 'words-pwa-v1'; +const CACHE_VERSION = 'words-pwa-v2'; self.addEventListener('install', (event) => { event.waitUntil( diff --git a/frontend/src/App.js b/frontend/src/App.js index 94a6c4e..c4f62fe 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.js @@ -1,8 +1,7 @@ import React, { useState } from 'react'; import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'; import Home from './components/Home'; -import StudyMode from './components/StudyMode'; -import TestMode from './components/TestMode'; +import DailyBatch from './components/DailyBatch'; import GroupView from './components/GroupView'; import StudyCalendar from './components/StudyCalendar'; import SyncNotifications from './components/SyncNotifications'; @@ -19,8 +18,7 @@ function App() { } /> - } /> - } /> + } /> } /> } /> diff --git a/frontend/src/activeTime.js b/frontend/src/activeTime.js new file mode 100644 index 0000000..7a25dc1 --- /dev/null +++ b/frontend/src/activeTime.js @@ -0,0 +1,87 @@ +// 主动学习时长追踪 (Active Time) +// - 用户有点击/键盘/触摸/输入时累计 active_sec +// - 60 秒无操作 或 页面切到后台/锁屏 时自动暂停 +// - 切回前台/再次操作 时恢复 +// - 记录当前阶段(new/review/quiz)的时间分布 +// 设计目标: 不把锁屏、切后台、闲聊空档算进学习时长。 + +const IDLE_MS = 60 * 1000; + +export function createActiveTimer() { + let activeStart = null; // 当前 active 段起点(ms) + let total = 0; // 累计 active 秒 + let phase = 'new'; + let phaseStart = null; // 当前阶段起点(ms) + const phaseSec = { new: 0, review: 0, quiz: 0 }; + let idleTimer = null; + + const now = () => Date.now(); + + function markActive() { + if (activeStart === null) activeStart = now(); + if (phaseStart === null) phaseStart = now(); + resetIdle(); + } + + function pause() { + if (activeStart !== null) { + total += (now() - activeStart) / 1000; + activeStart = null; + } + if (phaseStart !== null) { + phaseSec[phase] += (now() - phaseStart) / 1000; + phaseStart = null; + } + } + + function setPhase(p) { + if (!phaseSec[p]) phaseSec[p] = 0; + if (phase !== p) { + if (phaseStart !== null) phaseSec[phase] += (now() - phaseStart) / 1000; + phase = p; + phaseStart = activeStart !== null ? now() : null; + } + } + + function resetIdle() { + if (idleTimer) clearTimeout(idleTimer); + idleTimer = setTimeout(pause, IDLE_MS); + } + + function get() { + // 返回当前累计(含未暂停段)的快照 + let t = total; + if (activeStart !== null) t += (now() - activeStart) / 1000; + const ps = { ...phaseSec }; + if (phaseStart !== null) ps[phase] += (now() - phaseStart) / 1000; + return { + active_sec: Math.round(t), + new_sec: Math.round(ps.new), + review_sec: Math.round(ps.review), + quiz_sec: Math.round(ps.quiz), + }; + } + + function destroy() { + if (idleTimer) clearTimeout(idleTimer); + if (typeof document !== 'undefined') { + document.removeEventListener('visibilitychange', onVisibility); + ['click', 'keydown', 'touchstart', 'input'].forEach(ev => + document.removeEventListener(ev, markActive)); + } + } + + function onVisibility() { + if (document.visibilityState !== 'visible') pause(); + else markActive(); + } + + if (typeof document !== 'undefined') { + document.addEventListener('visibilitychange', onVisibility); + ['click', 'keydown', 'touchstart', 'input'].forEach(ev => + document.addEventListener(ev, markActive, { passive: true })); + } + markActive(); + + return { markActive, pause, setPhase, get, destroy }; +} diff --git a/frontend/src/api.js b/frontend/src/api.js index 0f4656b..ddf804d 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -50,6 +50,18 @@ export const api = { syncPush: (events) => http.post('/api/sync/push', { events }), syncPull: () => http.get('/api/sync/pull'), getCalendar: () => http.get('/api/calendar'), + // ---------------- V2 (Daily Batch) ---------------- + v2Home: () => http.get('/api/v2/home'), + wordsV2All: () => http.get('/api/words/v2/all'), + wordsV2Group: (groupId) => http.get(`/api/words/v2/${groupId}`), + dailyPlan: (date) => http.get(`/api/daily-plan/${date}`), + createDailyPlan: (date) => http.post('/api/daily-plan/create', { date }), + eventsBatch: (session_id, events) => http.post('/api/events/batch', { session_id, events }), + upsertSession: (session) => http.post('/api/sessions/upsert', session), + progressWords: () => http.get('/api/progress/words'), + questionsDaily: (date) => http.get(`/api/questions/daily/${date}`), + calendarV2: (month) => http.get('/api/calendar', { params: { month } }), + calendarV2Day: (date) => http.get(`/api/calendar/${date}`), uploadPicture: (formData) => http.post('/api/picture/', formData, { headers: { 'Content-Type': 'multipart/form-data' }, }), diff --git a/frontend/src/components/DailyBatch.js b/frontend/src/components/DailyBatch.js new file mode 100644 index 0000000..d115193 --- /dev/null +++ b/frontend/src/components/DailyBatch.js @@ -0,0 +1,402 @@ +import React, { useState, useEffect, useRef, useCallback } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { api, localTimestamp } from '../api'; +import { useOnlineStatus } from '../hooks/useOnlineStatus'; +import { + getDailyPlanByDate, putDailyPlan, putLearningEvent, putStudySession, + putWordProgress, getAllWordProgress, +} from '../db'; +import { triggerAutoSync } from '../sync'; +import { createActiveTimer } from '../activeTime'; +import './StudyMode.css'; +import './TestMode.css'; + +function uuid() { + if (typeof crypto !== 'undefined' && crypto.randomUUID) return crypto.randomUUID(); + return 'id-' + Date.now() + '-' + Math.random().toString(16).slice(2); +} + +function chinaDate() { + return new Date(Date.now() + 8 * 3600 * 1000).toISOString().slice(0, 10); +} + +const POS_POOL = ['n.', 'v.', 'adj.', 'adv.', 'prep.', 'conj.']; + +const sanitize = (s) => s.replace(/[^\x20-\x7E]/g, '').toLowerCase(); +const blockClipboard = (e) => e.preventDefault(); +const inputProps = { + type: 'text', lang: 'en', inputMode: 'latin', autoComplete: 'off', + autoCorrect: 'off', autoCapitalize: 'off', spellCheck: false, + onPaste: blockClipboard, onContextMenu: blockClipboard, onCompositionStart: blockClipboard, +}; + +export default function DailyBatch() { + const navigate = useNavigate(); + const isOnline = useOnlineStatus(); + const [, setPlan] = useState(null); + const [tasks, setTasks] = useState([]); + const [idx, setIdx] = useState(0); + const [loading, setLoading] = useState(true); + const [done, setDone] = useState(false); + const [weakWords, setWeakWords] = useState([]); + + const sessionIdRef = useRef(uuid()); + const timerRef = useRef(null); + const planRef = useRef(null); + const todayRef = useRef(chinaDate()); + const wrongSetRef = useRef(new Set()); + + useEffect(() => { + (async () => { + const today = todayRef.current; + let p = await getDailyPlanByDate(today); + if (!p) { + if (!isOnline) { + alert('请联网后生成今日计划'); + navigate('/'); + return; + } + try { + const r = await api.dailyPlan(today); + p = { ...r.data, plan_date: today }; + } catch (e) { + const r = await api.createDailyPlan(today); + p = { ...r.data, plan_date: today }; + } + await putDailyPlan(p); + } + planRef.current = p; + setPlan(p); + + const items = p.items || []; + const study = []; + items.forEach((it, i) => { + if (!it.word) return; + if (it.item_type === 'new_spell') + study.push({ kind: 'study', key: 's' + i, word: it.word, stage: 'new', qtype: 'spell', attempt_no: 1 }); + else if (it.item_type === 'new_recognize') + study.push({ kind: 'study', key: 's' + i, word: it.word, stage: 'new', qtype: 'recognize', attempt_no: 1 }); + else if (it.item_type === 'review_spell') + study.push({ kind: 'study', key: 's' + i, word: it.word, stage: 'review', qtype: 'spell', attempt_no: 1 }); + else if (it.item_type === 'review_recognize') + study.push({ kind: 'study', key: 's' + i, word: it.word, stage: 'review', qtype: 'recognize', attempt_no: 1 }); + }); + const quiz = items.filter(it => it.item_type === 'quiz' && it.question) + .map((it, i) => ({ kind: 'quiz', key: 'q' + i, question: it.question })); + + setTasks([...study, ...quiz]); + setLoading(false); + + timerRef.current = createActiveTimer(); + const sess = { + session_id: sessionIdRef.current, plan_id: p.plan_id, log_date: today, + started_at: localTimestamp(), ended_at: null, + active_sec: 0, new_sec: 0, review_sec: 0, quiz_sec: 0, completed: 0, synced: false, + }; + await putStudySession(sess); + if (isOnline) triggerAutoSync(); + })(); + return () => { if (timerRef.current) timerRef.current.destroy(); }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + if (loading) return; + const t = setInterval(() => flushSession(), 20000); + return () => clearInterval(t); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [loading]); + + useEffect(() => { + if (!timerRef.current || done) return; + const tk = tasks[idx]; + if (!tk) return; + if (tk.kind === 'quiz') timerRef.current.setPhase('quiz'); + else if (tk.stage === 'review') timerRef.current.setPhase('review'); + else timerRef.current.setPhase('new'); + }, [idx, tasks, done]); + + const flushSession = useCallback(async () => { + if (!timerRef.current || !planRef.current) return; + const ts = timerRef.current.get(); + await putStudySession({ + session_id: sessionIdRef.current, plan_id: planRef.current.plan_id, log_date: todayRef.current, + started_at: localTimestamp(), ended_at: null, + active_sec: ts.active_sec, new_sec: ts.new_sec, review_sec: ts.review_sec, quiz_sec: ts.quiz_sec, + completed: 0, synced: false, + }); + if (isOnline) triggerAutoSync(); + }, [isOnline]); + + const recordEvent = useCallback(async ({ word_id, group_id, stage, question_type, result, attempt_no, duration_ms }) => { + const ev = { + event_id: uuid(), + session_id: sessionIdRef.current, + plan_id: planRef.current ? planRef.current.plan_id : null, + word_id, group_id, stage, question_type, result, + attempt_no: attempt_no || 1, duration_ms: duration_ms || 0, + created_at: localTimestamp(), log_date: todayRef.current, synced: false, + }; + await putLearningEvent(ev); + if (word_id) { + const all = await getAllWordProgress(); + const cur = all.find(p => p.word_id === word_id) || { + word_id, first_learned_at: null, last_seen_at: null, next_review_at: null, + memory_type: null, mastery_level: 0, + spell_correct: 0, spell_wrong: 0, recognize_correct: 0, recognize_wrong: 0, + quiz_correct: 0, quiz_wrong: 0, + }; + const upd = { ...cur }; + if (stage === 'new' && !upd.first_learned_at) upd.first_learned_at = todayRef.current; + upd.last_seen_at = todayRef.current; + if (stage === 'new' || stage === 'review') { + if (question_type === 'spell') { if (result === 'correct') upd.spell_correct++; else upd.spell_wrong++; } + else { if (result === 'correct') upd.recognize_correct++; else upd.recognize_wrong++; } + } else if (stage === 'quiz') { + if (result === 'correct') upd.quiz_correct++; else upd.quiz_wrong++; + } + await putWordProgress(upd); + } + if (isOnline) triggerAutoSync(); + }, [isOnline]); + + const onStudyResult = useCallback((task, result, duration_ms) => { + recordEvent({ + word_id: task.word.id, group_id: task.word.groupid, stage: task.stage, + question_type: task.qtype, result, attempt_no: task.attempt_no, duration_ms, + }); + if (result !== 'correct') { + if (!wrongSetRef.current.has(task.word.id)) { + wrongSetRef.current.add(task.word.id); + setWeakWords(w => [...w, { word: task.word.word, why: task.qtype === 'spell' ? '拼写' : '识别' }]); + } + setTasks(prev => [...prev, { ...task, key: task.key + '-r' + Date.now(), attempt_no: (task.attempt_no || 1) + 1 }]); + } + setIdx(i => i + 1); + }, [recordEvent]); + + const onQuizResult = useCallback((task, result, duration_ms) => { + recordEvent({ + word_id: null, group_id: null, stage: 'quiz', + question_type: task.question.type, result, attempt_no: 1, duration_ms, + }); + if (result !== 'correct') { + setWeakWords(w => [...w, { word: task.question.answer, why: '综合题' }]); + } + setIdx(i => i + 1); + }, [recordEvent]); + + const finish = useCallback(async () => { + if (timerRef.current) timerRef.current.pause(); + const ts = timerRef.current ? timerRef.current.get() : { active_sec: 0, new_sec: 0, review_sec: 0, quiz_sec: 0 }; + await putStudySession({ + session_id: sessionIdRef.current, plan_id: planRef.current ? planRef.current.plan_id : null, + log_date: todayRef.current, started_at: localTimestamp(), ended_at: localTimestamp(), + active_sec: ts.active_sec, new_sec: ts.new_sec, review_sec: ts.review_sec, quiz_sec: ts.quiz_sec, + completed: 1, synced: false, + }); + if (isOnline) triggerAutoSync(); + setDone(true); + }, [isOnline]); + + if (loading) return
加载今日任务…
; + + 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 1f65d4b..4aa4066 100644 --- a/frontend/src/components/GroupView.css +++ b/frontend/src/components/GroupView.css @@ -132,3 +132,11 @@ 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 317bcb5..87eb1e0 100644 --- a/frontend/src/components/GroupView.js +++ b/frontend/src/components/GroupView.js @@ -1,34 +1,37 @@ import React, { useState, useEffect } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import { api } from '../api'; -import { getWordsByGroup } from '../db'; +import { getWordsByGroup, getAllWordProgress } from '../db'; import './GroupView.css'; function GroupView() { const { groupId } = useParams(); const navigate = useNavigate(); const [words, setWords] = useState([]); + const [learned, setLearned] = useState({}); useEffect(() => { - api.getWords(groupId) + const gid = Number(groupId); + api.wordsV2Group(gid) .then(res => setWords(res.data)) - .catch(async (err) => { - // 离线或后端不可达: 降级读 IndexedDB (规格 9: 所有组均可离线查看) - console.error("获取词汇失败, 降级读离线缓存:", err); - const local = await getWordsByGroup(groupId); + .catch(async () => { + const local = await getWordsByGroup(gid); setWords(local); }); + getAllWordProgress().then(list => { + const m = {}; + list.forEach(p => { if (p.first_learned_at) m[p.word_id] = true; }); + setLearned(m); + }).catch(() => {}); }, [groupId]); - // 组内所有单词共享同一张思维导图,取首词的 picture_id(兼容 picture_id==groupid 约定) const pictureId = words.length > 0 ? words[0].picture_id : null; + const learnedCount = words.filter(w => learned[w.id]).length; if (words.length === 0) { return (
-
-

加载中...

-
+

加载中...

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

Group {groupId} · 词汇预览

-

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

+

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

{pictureId && ( @@ -51,28 +55,34 @@ 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 1a3db68..89b40c9 100644 --- a/frontend/src/components/Home.css +++ b/frontend/src/components/Home.css @@ -206,4 +206,22 @@ } .logout-btn:hover { background: #eef3fb; - } \ No newline at end of file + } +/* ---- V2 Daily Batch 仪表盘 ---- */ +.batch-dashboard { background:#fff; border:1px solid #e2e8f0; border-radius:14px; padding:18px; margin:16px 0; } +.batch-headline { display:flex; justify-content:space-between; align-items:baseline; } +.batch-title { font-size:22px; font-weight:700; color:#0f172a; } +.batch-est { color:#2563eb; font-weight:600; } +.batch-note { color:#64748b; font-size:13px; margin:4px 0 16px; } +.batch-cards { display:flex; gap:12px; flex-wrap:wrap; } +.batch-card { flex:1; min-width:120px; background:#f8fafc; border:1px solid #e2e8f0; border-radius:12px; padding:14px; text-align:center; } +.batch-card.new-card { border-top:3px solid #2563eb; } +.batch-card.review-card { border-top:3px solid #5b7c99; } +.batch-card.quiz-card { border-top:3px solid #8a9ba8; } +.bc-label { color:#64748b; font-size:13px; } +.bc-num { font-size:28px; font-weight:800; color:#0f172a; } +.bc-unit { font-size:14px; font-weight:500; margin-left:2px; } +.bc-sub { color:#64748b; font-size:12px; margin-top:4px; } +.start-batch-btn { display:block; width:100%; margin-top:18px; font-size:17px; padding:14px; } +.weak-list { text-align:left; margin:10px 0; } +.weak-why { color:#ef4444; font-size:12px; } diff --git a/frontend/src/components/Home.js b/frontend/src/components/Home.js index 01f71f8..caab645 100644 --- a/frontend/src/components/Home.js +++ b/frontend/src/components/Home.js @@ -1,143 +1,109 @@ import React, { useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { useOnlineStatus } from '../hooks/useOnlineStatus'; -import { getAllWordsFromIDB, getAllProgress, isInitialized } from '../db'; -import { triggerAutoSync, subscribeSyncStatus } from '../sync'; +import { isInitialized, getDailyPlanByDate, putDailyPlan, getWordsCount, getAllWordProgress } from '../db'; +import { triggerAutoSync } from '../sync'; +import { api } from '../api'; import { logout } from '../auth'; import './Home.css'; +function chinaDate() { + return new Date(Date.now() + 8 * 3600 * 1000).toISOString().slice(0, 10); +} + function Home() { - const [progressData, setProgressData] = useState([]); - const [showAll, setShowAll] = useState(false); - const [stats, setStats] = useState({ pictures_count: 0, words_count: 0 }); - const [needInit, setNeedInit] = useState(false); // 首次离线: 需联网初始化 - const [blocking, setBlocking] = useState(false); // 同步中且本地有 dirty: 禁用学习入口 + const [plan, setPlan] = useState(null); + const today = chinaDate(); + const [stats, setStats] = useState({ words_count: 0, learned_count: 0 }); + const [needInit, setNeedInit] = useState(false); + const [loading, setLoading] = useState(true); + const [creating, setCreating] = useState(false); const navigate = useNavigate(); const isOnline = useOnlineStatus(); - // 离线降级: 从 IndexedDB 构造与 /api/home/process 同格式的数据 (emoji 字符串) - const buildOfflineData = async () => { - const allWords = await getAllWordsFromIDB(); - const progress = await getAllProgress(); - const completedByGroup = {}; - progress.forEach(p => { - const g = String(p.group_id); - if (!completedByGroup[g]) completedByGroup[g] = {}; - completedByGroup[g][p.op_type] = p.completed_at; - }); - const allGroups = Array.from(new Set(allWords.map(w => String(w.groupid)))) - .sort((a, b) => Number(a) - Number(b)); - let firstUnstudied = null; - for (const g of allGroups) { - if (!completedByGroup[g] || !completedByGroup[g]['study']) { firstUnstudied = g; break; } - } - const OPS = ['study', 'test', 'review1', 'review2', 'review3']; - const PREV = { test: 'study', review1: 'test', review2: 'review1', review3: 'review2' }; - const progressData = allGroups.map(g => { - const p = completedByGroup[g] || {}; - const row = { group: `Group ${g}` }; - for (const op of OPS) { - if (p[op]) { row[op] = `✔️ ${p[op].slice(0, 10)}`; continue; } - if (op === 'study') { row[op] = (g === firstUnstudied) ? '✅请开始学习' : ''; continue; } - if (op === 'test') { row[op] = p['study'] ? '✅请开始测试' : ''; continue; } - row[op] = p[PREV[op]] ? '✅请开始复习' : ''; - } - return row; - }); - return { - stats: { pictures_count: 0, words_count: allWords.length }, - progressData, - }; - }; - - // 从本地 IndexedDB 渲染首页 (永远本地优先, 不读云端进度) - const renderLocal = async () => { - const d = await buildOfflineData(); - setProgressData(d.progressData); - setStats(d.stats); - return d.progressData.length; - }; - useEffect(() => { (async () => { const init = await isInitialized(); - // 首次离线: 不能进入学习, 提示需联网 if (!isOnline && !init) { setNeedInit(true); - setProgressData([]); + setLoading(false); return; } setNeedInit(false); - await renderLocal(); - // 在线: 后台自动同步 (首次在线会 warmup 词库+进度; 已初始化则合并云端) + + // 今天计划:先本地,再尝试服务端 + let local = await getDailyPlanByDate(today); + if (!local && isOnline) { + try { + const r = await api.dailyPlan(today); + local = { ...r.data, plan_date: today }; + await putDailyPlan(local); + } catch (e) { + local = null; // 404: 尚未生成 + } + } + setPlan(local); + + // 统计 + if (isOnline) { + try { + const h = await api.v2Home(); + setStats({ words_count: h.data.words_count, learned_count: h.data.learned_count }); + } catch (e) {} + } else { + const wc = await getWordsCount(); + const wp = await getAllWordProgress(); + const learned = wp.filter(p => p.first_learned_at).length; + setStats({ words_count: wc, learned_count: learned }); + } + + setLoading(false); if (isOnline) triggerAutoSync(); })(); if (!isOnline) return; - // 在线时定时自动合并其他设备的进度 const timer = setInterval(() => triggerAutoSync(), 30000); return () => clearInterval(timer); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isOnline]); + }, [isOnline, today]); - // 订阅同步状态: 同步完成重渲染本地; 同步中且有 dirty 时禁用学习入口 - useEffect(() => { - return subscribeSyncStatus((s) => { - if (s.state === 'synced') renderLocal(); - setBlocking(s.state === 'syncing' && s.detail && s.detail.dirty > 0); - }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - const filteredData = progressData.filter(item => { - if (showAll) return true; - const isDone = [item.study, item.test, item.review1, item.review2, item.review3] - .every(val => val && val.includes('✔️')); - return !isDone; - }); - - const getStatusClass = (text) => { - if (!text) return ''; - if (text.includes('✔️')) return 'status-done'; - if (text.includes('✅')) return 'status-start'; - if (text.includes('⚠️')) return 'status-warning'; - return ''; + const generatePlan = async () => { + if (!isOnline) { alert('请联网后生成今日计划'); return; } + setCreating(true); + try { + const r = await api.createDailyPlan(today); + const p = { ...r.data, plan_date: today }; + await putDailyPlan(p); + setPlan(p); + } catch (e) { + alert('生成计划失败:' + ((e && e.message) || e)); + } finally { + setCreating(false); + } }; - const handleCellClick = (item, type) => { - const groupId = item.group.replace('Group ', ''); - if (type === 'group') { - navigate(`/group/${groupId}`); - return; - } - // 同步中且本地有 dirty: 禁止开始新的学习/测试/复习 (先同步完成) - if (blocking) { - alert('同步完成后再开始新的学习'); - return; - } - if (type === 'study' && item.study && item.study.includes('请开始学习')) { - navigate(`/study/${groupId}`); - } - if (type === 'test' && item.test && item.test.includes('请开始测试')) { - navigate(`/test/${groupId}?type=test`); - } - if (type === 'review1' && item.review1 && (item.review1.includes('请开始复习') || item.review1.includes('请尽快复习'))) { - navigate(`/test/${groupId}?type=review1`); - } - if (type === 'review2' && item.review2 && (item.review2.includes('请开始复习') || item.review2.includes('请尽快复习'))) { - navigate(`/test/${groupId}?type=review2`); - } - if (type === 'review3' && item.review3 && (item.review3.includes('请开始复习') || item.review3.includes('请尽快复习'))) { - navigate(`/test/${groupId}?type=review3`); - } + const startTask = () => { + if (!plan) { generatePlan(); return; } + navigate('/batch'); }; + if (loading) { + return

单词学习

加载中…

; + } + + 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.pictures_count} | 单词总数: {stats.words_count} + 词库 {stats.words_count} 词 · 已学 {stats.learned_count}

@@ -149,84 +115,47 @@ function Home() {
)} - {blocking && ( -
同步完成后再开始新的学习
- )} +
+
+ 今晚任务 + 预计 {estMin || '--'} 分钟 +
+

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

-
- - +
+
+
新词
+
{plannedNew || '--'}个
+
{plannedSpell} 个拼写 / {plannedRecognize} 个识别
+
+
+
到期复习
+
{plannedReview || 0}个
+
错词优先 · 未完成自动顺延
+
+
+
综合训练
+
{plannedQuiz}题
+
3 道今日词 / 2 道复习词
+
+
+ + {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 4c9712d..1d6556d 100644 --- a/frontend/src/components/StudyCalendar.css +++ b/frontend/src/components/StudyCalendar.css @@ -165,3 +165,19 @@ 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 dceb13d..c772ae0 100644 --- a/frontend/src/components/StudyCalendar.js +++ b/frontend/src/components/StudyCalendar.js @@ -1,53 +1,35 @@ import React, { useState, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; import { api } from '../api'; -import { getAllProgress } from '../db'; import './StudyCalendar.css'; -const OP_LABEL = { study: '学', test: '测', review1: 'R1', review2: 'R2', review3: 'R3' }; -const OP_COLOR = { study: '#ea580c', test: '#f97316', review1: '#5b7c99', review2: '#6b8e7a', review3: '#8a9ba8' }; const WEEKDAYS = ['一', '二', '三', '四', '五', '六', '日']; -const OP_ORDER = ['study', 'test', 'review1', 'review2', 'review3']; function pad(n) { return String(n).padStart(2, '0'); } +function fmtDur(min) { + if (!min) return ''; + const h = Math.floor(min / 60), m = min % 60; + if (h > 0) return `${h}小时${m}分`; + return `${m}分`; +} function StudyCalendar() { const navigate = useNavigate(); const [dayMap, setDayMap] = useState({}); + const [detail, setDetail] = useState(null); useEffect(() => { - api.getCalendar() + api.calendarV2() .then(res => { const m = {}; res.data.forEach(item => { - m[item.date] = { activities: item.activities, durationMin: item.duration_min || 0 }; + m[item.date] = item; }); setDayMap(m); }) .catch(async (err) => { - // 离线降级: 从 IndexedDB progress 本地按日聚合 (复用后端 calendar 逻辑) - console.error("获取日历失败, 降级读离线缓存:", err); - const allProgress = await getAllProgress(); - const tmp = {}; - const dayDurSec = {}; // day -> 累计学习秒数(离线按本地 duration_sec 求和, 与后端一致) - allProgress.forEach(p => { - const day = (p.completed_at || '').slice(0, 10); - if (!day) return; - if (!tmp[day]) tmp[day] = {}; - if (!tmp[day][p.op_type]) tmp[day][p.op_type] = []; - const g = String(p.group_id); - if (!tmp[day][p.op_type].includes(g)) tmp[day][p.op_type].push(g); - if (!dayDurSec[day]) dayDurSec[day] = 0; - dayDurSec[day] += (p.duration_sec || 0); - }); - const m = {}; - Object.keys(tmp).forEach(day => { - const activities = OP_ORDER.filter(op => tmp[day][op]).map(op => ({ op_type: op, groups: tmp[day][op] })); - const totalSec = dayDurSec[day] || 0; - const durationMin = totalSec > 0 ? Math.round(totalSec / 60) : 0; - m[day] = { activities, durationMin }; - }); - setDayMap(m); + console.error('日历获取失败:', err); + setDayMap({}); }); }, []); @@ -55,64 +37,67 @@ function StudyCalendar() { const todayStr = `${today.getFullYear()}-${pad(today.getMonth() + 1)}-${pad(today.getDate())}`; const currentMonth = `${today.getFullYear()}-${pad(today.getMonth() + 1)}`; - // 月份渲染顺序: 当前月 + 有记录月份(去除当前月, 倒序) - const recordMonths = Object.keys(dayMap).map(d => d.slice(0, 7)); - const uniqueRecordMonths = Array.from(new Set(recordMonths)).sort().reverse(); - const renderMonths = [currentMonth, ...uniqueRecordMonths.filter(m => m !== currentMonth)]; + const recordMonths = Array.from(new Set(Object.keys(dayMap).map(d => d.slice(0, 7)))).sort().reverse(); + const renderMonths = [currentMonth, ...recordMonths.filter(m => m !== currentMonth)]; + + // 月标题统计(V2 口径:学习天数 + 总时长 + 新词数) + function monthSummary(monthStr) { + let days = 0, totalMin = 0, newWords = 0; + Object.keys(dayMap).forEach(d => { + if (d.slice(0, 7) === monthStr) { + const it = dayMap[d]; + const hasData = (it.new_words || it.review_words || it.quiz_total || it.duration_min); + if (it.legacy && !hasData) { days++; totalMin += it.duration_min || 0; return; } + if (hasData) { days++; totalMin += it.duration_min || 0; newWords += it.new_words || 0; } + } + }); + return { days, totalMin, newWords }; + } function renderMonth(monthStr) { const [yStr, mStr] = monthStr.split('-'); const y = parseInt(yStr), m = parseInt(mStr); - const firstDay = new Date(y, m - 1, 1).getDay(); // 0=Sun - const offset = (firstDay + 6) % 7; // 周一起始的空格数 + const firstDay = new Date(y, m - 1, 1).getDay(); + const offset = (firstDay + 6) % 7; // 周一起始 const daysInMonth = new Date(y, m, 0).getDate(); - // 统计当月学习的 group 数(op_type='study' 的不同 group) - const studiedGroups = new Set(); - Object.keys(dayMap).forEach(dateStr => { - if (dateStr.slice(0, 7) === monthStr) { - const info = dayMap[dateStr]; - if (info && info.activities) info.activities.forEach(a => { - if (a.op_type === 'study') a.groups.forEach(g => studiedGroups.add(g)); - }); - } - }); - const studiedCount = studiedGroups.size; const cells = []; for (let i = 0; i < offset; i++) cells.push(null); for (let d = 1; d <= daysInMonth; d++) cells.push(d); while (cells.length % 7 !== 0) cells.push(null); + const sum = monthSummary(monthStr); return (
- {y}年 {m}月 · (学习了{studiedCount}个group) + {y}年 {m}月 · 学习 {sum.days} 天 · {fmtDur(sum.totalMin)} · 新词 {sum.newWords} 个
{WEEKDAYS.map(w =>
{w}
)} {cells.map((d, i) => { if (d === null) return
; const dateStr = `${monthStr}-${pad(d)}`; - const dayInfo = dayMap[dateStr]; - const acts = dayInfo ? dayInfo.activities : undefined; - const durationMin = dayInfo ? dayInfo.durationMin : 0; - const isActive = !!acts; + const info = dayMap[dateStr]; + const dur = info ? info.duration_min : 0; + const hasData = info && (info.new_words || info.review_words || info.quiz_total || dur); + const isActive = !!hasData; const isToday = dateStr === todayStr; const cls = ['cal-day']; if (isActive) cls.push('active'); + if (info && info.legacy) cls.push('legacy'); if (isToday) cls.push('today'); return ( -
+
info && hasData ? openDetail(dateStr) : null}>
- {durationMin > 0 && ( - {durationMin}分钟 - )} + {dur > 0 && {dur}分}
{d}
- {acts && acts.map((a, j) => ( -
- {OP_LABEL[a.op_type] || a.op_type} G{a.groups.join(',')} + {info && ( +
+ {info.new_words > 0 && 新{info.new_words}} + {info.review_words > 0 && 复{info.review_words}} + {info.quiz_total > 0 && 题{info.quiz_total}}
- ))} + )}
); })} @@ -121,12 +106,46 @@ function StudyCalendar() { ); } + async function openDetail(dateStr) { + try { + const r = await api.calendarV2Day(dateStr); + setDetail(r.data); + } catch (e) { + console.error(e); + } + } + return (

📅 学习日历

{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 360a7e4..e2603bf 100644 --- a/frontend/src/components/StudyMode.css +++ b/frontend/src/components/StudyMode.css @@ -253,3 +253,9 @@ input:focus { padding: 16px 12px; } } + +/* ---- V2 选项/综合题 ---- */ +.pos-options, .quiz-options { display:flex; flex-direction:column; gap:10px; margin:12px 0; } +.pos-opt, .quiz-opt { width:100%; text-align:left; } +.quiz-opt.selected { background:#2563eb; color:#fff; } +.quiz-note { color:#94a3b8; font-size:12px; margin-top:8px; } diff --git a/frontend/src/components/StudyMode.js b/frontend/src/components/StudyMode.js deleted file mode 100644 index 52a1de7..0000000 --- a/frontend/src/components/StudyMode.js +++ /dev/null @@ -1,246 +0,0 @@ -import React, { useState, useEffect, useCallback, useRef } from 'react'; -import { useParams, useNavigate } from 'react-router-dom'; -import { api, localTimestamp } from '../api'; -import { useOnlineStatus } from '../hooks/useOnlineStatus'; -import { getWordsByGroup, putProgress, getLearnableGroups } from '../db'; -import { triggerAutoSync } from '../sync'; -import './StudyMode.css'; - -function StudyMode() { - const { groupId } = useParams(); - const navigate = useNavigate(); - const isOnline = useOnlineStatus(); - // 学习会话开始时间(毫秒时间戳), 用于计算本次学习的实际时长 - const sessionStartRef = useRef(Date.now()); - const [words, setWords] = useState([]); - const [currentIndex, setCurrentIndex] = useState(0); - const [phase, setPhase] = useState('learning'); // learning, flashcard, correction - const [userInput, setUserInput] = useState(""); - const [correctionInput, setCorrectionInput] = useState(""); - const [isSpeaking, setIsSpeaking] = useState(false); - - const hasSpokenRef = useRef(null); - const transitioningRef = useRef(false); - const submittedRef = useRef(false); // 防止完成时重复提交进度 - const currentWord = words[currentIndex]; - - // 朗读逻辑:通过后端 TTS 代理(有道美式发音)播放两遍,间隔 500ms - // 朗读期间 isSpeaking=true,阻止进入闪记模式 - const speak = useCallback((text) => { - if (!text) return; - const url = api.ttsUrl(text); - setIsSpeaking(true); - const finish = () => setIsSpeaking(false); - const audio1 = new Audio(url); - audio1.play().catch(err => { - console.error("TTS play failed:", err); - finish(); - }); - audio1.onended = () => { - setTimeout(() => { - const audio2 = new Audio(url); - audio2.play().catch(err => { - console.error("TTS play failed:", err); - finish(); - }); - audio2.onended = finish; - }, 500); - }; - }, []); - - // 离线时检查可学习范围 (规格 7: 仅未完成组 + 最后完成组后5组可离线学习) - useEffect(() => { - if (isOnline) return; // 在线时所有组均可学 - getLearnableGroups().then(groups => { - if (!groups.includes(Number(groupId))) { - alert('该组不可离线学习(仅未完成组与最后完成组后的5组可离线学习)'); - navigate('/'); - } - }); - }, [isOnline, groupId, navigate]); - - // 获取单词数据 (在线走网络, 离线降级读 IndexedDB) - useEffect(() => { - api.getWords(groupId) - .then(res => setWords(res.data)) - .catch(async () => { - const local = await getWordsByGroup(groupId); - setWords(local); - }); - }, [groupId]); - - // 自动朗读逻辑 - useEffect(() => { - if (currentWord && phase === 'learning') { - if (hasSpokenRef.current !== currentWord.id) { - speak(currentWord.word); - hasSpokenRef.current = currentWord.id; - } - } - }, [currentIndex, currentWord, phase, speak]); - - // 全局键盘监听:在学习模式下按回车进入闪记(朗读中禁用) - useEffect(() => { - const handleGlobalKeyDown = (event) => { - if (transitioningRef.current) return; - if (isSpeaking) return; - if (phase === 'learning' && event.key === 'Enter') { - setPhase('flashcard'); - } - }; - window.addEventListener('keydown', handleGlobalKeyDown); - return () => window.removeEventListener('keydown', handleGlobalKeyDown); - }, [phase, isSpeaking]); - - const handleNext = async () => { - transitioningRef.current = true; - if (currentIndex < words.length - 1) { - setCurrentIndex(currentIndex + 1); - setPhase('learning'); - setUserInput(""); - setCorrectionInput(""); - setTimeout(() => { transitioningRef.current = false; }, 300); - } else { - if (submittedRef.current) return; // 防重入(按住Enter等场景) - submittedRef.current = true; - const now = localTimestamp(); - // 本次学习实际时长(秒) = 完成时刻 - 会话开始时刻 - const durationSec = Math.max(0, Math.round((Date.now() - sessionStartRef.current) / 1000)); - // 本地优先: 先写本地 dirty (必定保存, 不丢记录) - await putProgress(groupId, 'study', now, true, durationSec); - // 在线则后台自动同步 (成功清 dirty, 失败保留) - if (isOnline) triggerAutoSync(); - alert("恭喜完成本组学习!"); - navigate('/'); - } - }; - - const checkFlashcard = () => { - if (userInput.toLowerCase().trim() === currentWord.word.toLowerCase().trim()) { - handleNext(); - } else { - setUserInput(""); // 清空闪记输入框,确保回到闪记时为空 - setPhase('correction'); - setCorrectionInput(""); - // 错误时读一遍正确发音提醒 - const audio = new Audio(api.ttsUrl(currentWord.word)); - audio.play().catch(err => console.error("TTS play failed:", err)); - } - }; - - // 抄写正确后回到闪记模式重试 - const retryFlashcard = () => { - setUserInput(""); - setPhase('flashcard'); - }; - - if (!currentWord) return
加载中...
; - - // 阻止复制/剪切/粘贴/右键,防止单词被复制后粘贴作弊 - 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 c273619..d316c44 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 { getDirtyProgress, getMeta } from '../db'; +import { getUnsyncedEvents, getUnsyncedSessions, getMeta } from '../db'; import { triggerAutoSync, subscribeSyncStatus, getSyncStatus } from '../sync'; import './SyncNotifications.css'; @@ -19,7 +19,9 @@ export default function SyncNotifications() { // 轮询 dirty / 初始化状态 useEffect(() => { const check = async () => { - setDirtyCount((await getDirtyProgress()).length); + const e = await getUnsyncedEvents(); + const s = await getUnsyncedSessions(); + setDirtyCount(e.length + s.length); setInitialized(!!(await getMeta('lastSyncAt'))); }; check(); diff --git a/frontend/src/components/TestMode.js b/frontend/src/components/TestMode.js deleted file mode 100644 index f228eba..0000000 --- a/frontend/src/components/TestMode.js +++ /dev/null @@ -1,374 +0,0 @@ -import React, { useState, useEffect, useCallback, useRef } from 'react'; -import { useParams, useNavigate, useSearchParams } from 'react-router-dom'; -import { api, localTimestamp } from '../api'; -import { useOnlineStatus } from '../hooks/useOnlineStatus'; -import { getWordsByGroup, putProgress, getLearnableGroups } from '../db'; -import { triggerAutoSync } from '../sync'; -import './TestMode.css'; - -function TestMode() { - const { groupId } = useParams(); - const navigate = useNavigate(); - const [searchParams] = useSearchParams(); - // 活动类型:test / review1 / review2 / review3(首页点击复习时通过 ?type= 传入;缺省为 test) - const activityType = searchParams.get('type') || 'test'; - const isOnline = useOnlineStatus(); - // 测试/复习会话开始时间(毫秒时间戳), 用于计算本次学习的实际时长 - const sessionStartRef = useRef(Date.now()); - const [words, setWords] = useState([]); - const [queue, setQueue] = useState([]); // 当前测试队列 - const [wrongQueue, setWrongQueue] = useState([]); // 错词重考队列 - const [currentIndex, setCurrentIndex] = useState(0); - const [totalCount, setTotalCount] = useState(0); - const [phase, setPhase] = useState('ready'); // ready, testing, feedback, copy, retest_info, complete - const [userInput, setUserInput] = useState(""); - const [copyInput, setCopyInput] = useState(""); - const [isCorrect, setIsCorrect] = useState(false); - const [isRetesting, setIsRetesting] = useState(false); - - const inputRef = useRef(null); - const copyRef = useRef(null); - const submittedRef = useRef(false); // 防止完成时重复提交进度 - const currentWord = queue[currentIndex]; - - // 离线时检查可学习范围 (规格 7: 仅未完成组 + 最后完成组后5组可离线学习) - useEffect(() => { - if (isOnline) return; - getLearnableGroups().then(groups => { - if (!groups.includes(Number(groupId))) { - alert('该组不可离线学习(仅未完成组与最后完成组后的5组可离线学习)'); - navigate('/'); - } - }); - }, [isOnline, groupId, navigate]); - - // 获取单词数据 (在线走网络, 离线降级读 IndexedDB) - useEffect(() => { - api.getWords(groupId) - .then(res => setWords(res.data)) - .catch(async () => { - const local = await getWordsByGroup(groupId); - setWords(local); - }); - }, [groupId]); - - // 开始测试:随机打乱 - const startTest = useCallback(() => { - const shuffled = [...words].sort(() => Math.random() - 0.5); - setQueue(shuffled); - setWrongQueue([]); - setCurrentIndex(0); - setTotalCount(shuffled.length); - setPhase('testing'); - setUserInput(""); - setCopyInput(""); - setIsRetesting(false); - }, [words]); - - // 自动聚焦输入框 - useEffect(() => { - if (phase === 'testing' && inputRef.current) { - inputRef.current.focus(); - } - if (phase === 'copy' && copyRef.current) { - copyRef.current.focus(); - } - }, [phase, currentIndex]); - - // 朗读:通过后端 TTS 代理(有道美式发音)播放一遍 - const speak = useCallback((text) => { - if (!text) return; - const audio = new Audio(api.ttsUrl(text)); - audio.play().catch(err => console.error("TTS play failed:", err)); - }, []); - - // 提交答案 - const checkAnswer = useCallback(() => { - if (!currentWord || !userInput.trim()) return; - const correct = currentWord.word.trim().toLowerCase(); - const answer = userInput.trim().toLowerCase(); - const ok = answer === correct; - - setIsCorrect(ok); - setPhase('feedback'); - speak(currentWord.word); - - if (!ok) { - setWrongQueue(prev => { - if (!prev.find(w => w.id === currentWord.id)) { - return [...prev, currentWord]; - } - return prev; - }); - } - }, [currentWord, userInput, speak]); - - // 显示抄写区 - const showCopy = useCallback(() => { - setCopyInput(""); - setPhase('copy'); - }, []); - - // 下一个词 - const nextWord = useCallback(() => { - if (currentIndex + 1 < queue.length) { - setCurrentIndex(prev => prev + 1); - setUserInput(""); - setCopyInput(""); - setPhase('testing'); - } else { - // 本轮结束 - if (wrongQueue.length > 0) { - // 有错题,进入重考 - setPhase('retest_info'); - } else { - // 全部通过 - setPhase('complete'); - } - } - }, [currentIndex, queue.length, wrongQueue.length]); - - // 开始重考 - const startRetest = useCallback(() => { - const retestWords = [...wrongQueue].sort(() => Math.random() - 0.5); - setQueue(retestWords); - setWrongQueue([]); - setCurrentIndex(0); - setTotalCount(retestWords.length); - setUserInput(""); - setCopyInput(""); - setPhase('testing'); - setIsRetesting(true); - }, [wrongQueue]); - - // 完成测试,记录进度(op_type 依据活动类型:test / review1 / review2 / review3) - const finishTest = useCallback(async () => { - if (submittedRef.current) return; // 防重入(按住Enter等场景) - submittedRef.current = true; - const now = localTimestamp(); - // 本次测试/复习实际时长(秒) = 完成时刻 - 会话开始时刻 - const durationSec = Math.max(0, Math.round((Date.now() - sessionStartRef.current) / 1000)); - // 本地优先: 先写本地 dirty (必定保存, 不丢记录) - await putProgress(groupId, activityType, now, true, durationSec); - // 在线则后台自动同步 (成功清 dirty, 失败保留) - if (isOnline) triggerAutoSync(); - navigate('/'); - }, [groupId, navigate, activityType, isOnline]); - - // 键盘事件 - const handleKeyDown = useCallback((e) => { - if (e.key === 'Enter') { - if (phase === 'testing') { - checkAnswer(); - } else if (phase === 'feedback' && isCorrect) { - nextWord(); - } else if (phase === 'copy' && copyInput.toLowerCase() === currentWord.word.toLowerCase()) { - nextWord(); - } else if (phase === 'feedback' && !isCorrect) { - showCopy(); - } - } - }, [phase, isCorrect, checkAnswer, nextWord, showCopy, copyInput, currentWord]); - - useEffect(() => { - window.addEventListener('keydown', handleKeyDown); - return () => window.removeEventListener('keydown', handleKeyDown); - }, [handleKeyDown]); - - // 阻止复制/剪切/粘贴/右键,防止单词被复制后粘贴作弊 - const blockClipboard = (e) => e.preventDefault(); - - // 只保留 ASCII 可见字符并转小写,过滤中文/全角/emoji,同时压制首字母自动大写 - // 即使中文输入法开启或系统自动大写,输入框也只显示小写英文 - const sanitize = (s) => s.replace(/[^\x20-\x7E]/g, '').toLowerCase(); - - // 输入框统一属性:强制英文输入、禁用中文输入法/自动大写/自动补全/粘贴 - const inputProps = { - type: "text", - lang: "en", - inputMode: "latin", // 唤起英文/拉丁字母软键盘 - autoComplete: "off", - autoCorrect: "off", - autoCapitalize: "off", // 首字母不大写 - spellCheck: false, - onPaste: blockClipboard, - onContextMenu: blockClipboard, - onCompositionStart: blockClipboard, // 阻止输入法联想 - }; - - // 准备阶段 - if (words.length === 0) return
加载中...
; - - if (phase === 'ready') { - return ( -
-
-

单元测试 - Group {groupId}

-

共 {words.length} 个单词

-

根据中文含义拼写英文单词,拼写错误需要抄写纠正

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

🎉 测试完成!

-

Group {groupId} 全部通过!

- -
-
- ); - } - - if (phase === 'retest_info') { - return ( -
-
-

📝 开始重考

-

还有 {wrongQueue.length} 个错题需要重考

- -
-
- ); - } - - if (!currentWord) return null; - - return ( -
-
- {/* 状态栏 */} -
- - {isRetesting ? '重考' : '测试'} - Group {groupId} - - - {currentIndex + 1} / {totalCount} - -
- - {/* 答题区域 */} -
- {phase === 'testing' && ( -
-
-

{currentWord.chinese_meaning}

-

({currentWord.part_of_speech})

-
-
- setUserInput(sanitize(e.target.value))} - placeholder="请输入英文单词..." - className="test-input" - {...inputProps} - /> - -
-
- )} - - {/* 反馈区域 */} - {(phase === 'feedback' || phase === 'copy') && ( -
-
-
- {isCorrect ? '✅ 正确!' : `❌ 错误,你的答案: ${userInput}`} -
- -
{currentWord.word}
-
中文: {currentWord.chinese_meaning}
-
词性: {currentWord.part_of_speech}
- - {currentWord.word_root && ( -
词根: {currentWord.word_root}
- )} - {currentWord.affix && ( -
组成: {currentWord.affix}
- )} - {currentWord.example_sentence && ( -
例句: {currentWord.example_sentence}
- )} - - {!isCorrect && phase === 'feedback' && ( - - )} - - {isCorrect && ( - - )} -
- - {/* 图片 */} - {currentWord.picture_id && ( -
- mindmap -
- )} -
- )} - - {/* 抄写纠正区域 */} - {phase === 'copy' && ( -
- - setCopyInput(sanitize(e.target.value))} - className={`copy-input ${copyInput.toLowerCase() === currentWord.word.toLowerCase() ? 'correct' : ''}`} - placeholder="请在此抄写正确单词..." - {...inputProps} - /> - - {copyInput.toLowerCase() === currentWord.word.toLowerCase() ? '✅' : '❌'} - - {copyInput.toLowerCase() === currentWord.word.toLowerCase() && ( - - )} -
- )} -
-
- -
- ); -} - -export default TestMode; diff --git a/frontend/src/db.js b/frontend/src/db.js index 163f44d..4d096fd 100644 --- a/frontend/src/db.js +++ b/frontend/src/db.js @@ -1,11 +1,14 @@ -// IndexedDB 封装 for PWA 离线学习 -// 三个 store: -// - words: 全量词库, keyPath=id, 索引 groupid (供按组查询) -// - progress: 离线进度, keyPath=`${group_id}:${op_type}`, {key, group_id, op_type, completed_at, dirty} -// - meta: 元数据, keyPath=key (lastSyncAt, learnableGroups[]) +// IndexedDB 封装 for V2 离线学习(Daily Batch 模型) +// stores: +// - words: 全量词库(含 memory_type 等新列), keyPath=id, 索引 groupid +// - dailyPlans: 当天冻结计划(含 items), keyPath=plan_id, 索引 plan_date +// - learningEvents: 逐词/逐题事件(append-only), keyPath=event_id, 索引 session_id/plan_id, 带 synced 标记 +// - studySessions: 主动学习时长会话, keyPath=session_id, 带 synced 标记 +// - wordProgress: 当前掌握状态快照(可在离线展示"已学"), keyPath=word_id +// - meta: 元数据, keyPath=key (lastSyncAt 等) const DB_NAME = 'words-pwa-v2'; -const DB_VERSION = 1; +const DB_VERSION = 2; let dbPromise = null; @@ -19,8 +22,20 @@ function openDB() { db.createObjectStore('words', { keyPath: 'id' }) .createIndex('groupid', 'groupid', { unique: false }); } - if (!db.objectStoreNames.contains('progress')) { - db.createObjectStore('progress', { keyPath: 'key' }); + if (!db.objectStoreNames.contains('dailyPlans')) { + const s = db.createObjectStore('dailyPlans', { keyPath: 'plan_id' }); + s.createIndex('plan_date', 'plan_date', { unique: false }); + } + if (!db.objectStoreNames.contains('learningEvents')) { + const s = db.createObjectStore('learningEvents', { keyPath: 'event_id' }); + s.createIndex('session_id', 'session_id', { unique: false }); + s.createIndex('plan_id', 'plan_id', { unique: false }); + } + if (!db.objectStoreNames.contains('studySessions')) { + db.createObjectStore('studySessions', { keyPath: 'session_id' }); + } + if (!db.objectStoreNames.contains('wordProgress')) { + db.createObjectStore('wordProgress', { keyPath: 'word_id' }); } if (!db.objectStoreNames.contains('meta')) { db.createObjectStore('meta', { keyPath: 'key' }); @@ -32,7 +47,6 @@ function openDB() { return dbPromise; } -// 通用: 单 store 事务执行, 返回主请求结果 async function singleReq(store, mode, fn) { const db = await openDB(); return new Promise((resolve, reject) => { @@ -44,7 +58,6 @@ async function singleReq(store, mode, fn) { } // ---------- words store ---------- - export async function putWords(wordsArr) { const db = await openDB(); return new Promise((resolve, reject) => { @@ -57,7 +70,6 @@ export async function putWords(wordsArr) { } export async function getWordsByGroup(groupId) { - // groupid 在 IDB 是数字, URL 参数是字符串, 统一转 Number 查索引 return singleReq('words', 'readonly', s => s.index('groupid').getAll(IDBKeyRange.only(Number(groupId))) ); @@ -71,55 +83,79 @@ export async function getAllWordsFromIDB() { return singleReq('words', 'readonly', s => s.getAll()); } -// ---------- progress store ---------- +// ---------- dailyPlans store ---------- +export async function putDailyPlan(plan) { + return singleReq('dailyPlans', 'readwrite', s => s.put(plan)); +} -export async function getProgress(group_id, op_type) { - return singleReq('progress', 'readonly', s => s.get(`${group_id}:${op_type}`)); +export async function getDailyPlanByDate(date) { + return singleReq('dailyPlans', 'readonly', s => + s.index('plan_date').getAll(IDBKeyRange.only(date)) + ).then(r => (r && r.length ? r[0] : null)); } -export async function getAllProgress() { - return singleReq('progress', 'readonly', s => s.getAll()); +// ---------- learningEvents store ---------- +export async function putLearningEvent(ev) { + return singleReq('learningEvents', 'readwrite', s => s.put(ev)); } -export async function putProgress(group_id, op_type, completed_at, dirty, duration_sec = 0) { - const key = `${group_id}:${op_type}`; +export async function getUnsyncedEvents() { + const all = await singleReq('learningEvents', 'readonly', s => s.getAll()); + return all.filter(e => !e.synced); +} + +export async function markEventsSynced(ids) { const db = await openDB(); return new Promise((resolve, reject) => { - const t = db.transaction('progress', 'readwrite'); - t.objectStore('progress').put({ key, group_id: String(group_id), op_type, completed_at, dirty, duration_sec: duration_sec || 0 }); + const t = db.transaction('learningEvents', 'readwrite'); + const s = t.objectStore('learningEvents'); + ids.forEach(id => { + const r = s.get(id); + r.onsuccess = () => { + if (r.result) { r.result.synced = true; s.put(r.result); } + }; + }); t.oncomplete = () => resolve(); t.onerror = () => reject(t.error); }); } -export async function getDirtyProgress() { - const all = await getAllProgress(); - return all.filter(p => p.dirty); +// ---------- studySessions store ---------- +export async function putStudySession(session) { + return singleReq('studySessions', 'readwrite', s => s.put(session)); +} + +export async function getUnsyncedSessions() { + const all = await singleReq('studySessions', 'readonly', s => s.getAll()); + return all.filter(x => !x.synced); } -export async function clearDirty(group_id, op_type) { - const key = `${group_id}:${op_type}`; +export async function markSessionsSynced(ids) { const db = await openDB(); return new Promise((resolve, reject) => { - const t = db.transaction('progress', 'readwrite'); - const s = t.objectStore('progress'); - const req = s.get(key); - req.onsuccess = () => { - if (req.result) { req.result.dirty = false; s.put(req.result); } - }; + const t = db.transaction('studySessions', 'readwrite'); + const s = t.objectStore('studySessions'); + ids.forEach(id => { + const r = s.get(id); + r.onsuccess = () => { + if (r.result) { r.result.synced = true; s.put(r.result); } + }; + }); t.oncomplete = () => resolve(); t.onerror = () => reject(t.error); }); } -// 删除某条进度(用于服务端已删除记录回写本地, 实现撤销/未执行) -export async function deleteProgress(group_id, op_type) { - const key = `${group_id}:${op_type}`; - return singleReq('progress', 'readwrite', s => s.delete(key)); +// ---------- wordProgress store ---------- +export async function putWordProgress(wp) { + return singleReq('wordProgress', 'readwrite', s => s.put(wp)); } -// ---------- meta store ---------- +export async function getAllWordProgress() { + return singleReq('wordProgress', 'readonly', s => s.getAll()); +} +// ---------- meta store ---------- export async function getMeta(key) { const r = await singleReq('meta', 'readonly', s => s.get(key)); return r ? r.value : undefined; @@ -135,16 +171,6 @@ export async function setMeta(key, value) { }); } -export async function getLearnableGroups() { - return (await getMeta('learnableGroups')) || []; -} - -export async function setLearnableGroups(groups) { - return setMeta('learnableGroups', groups); -} - -// 已初始化 = 本地已完成过一次云端初始化 (lastSyncAt 存在) -// 首页/组件用它判断, 而不是"能不能构造出首页表格" export async function isInitialized() { return !!(await getMeta('lastSyncAt')); } diff --git a/frontend/src/sync.js b/frontend/src/sync.js index 7009d23..660397a 100644 --- a/frontend/src/sync.js +++ b/frontend/src/sync.js @@ -1,20 +1,17 @@ -// 离线优先 + 自动同步 统一入口 -// 数据规则 (布尔并集): (group,op) 任一方完成即完成; 云端删除已确认进度会回写本地(撤销) -// dirty = "这条本地完成记录尚未确认上传到云端" -// syncNow: 上传本地 dirty → 拉云端并集合并 → 更新 lastSyncAt → 重算 learnable -// triggerAutoSync: 幂等并发锁, 供首页/联网恢复/完成学习 调用, 失败时置 failed +// V2 离线优先 + 自动同步 统一入口 +// 同步对象: learningEvents(UUID 幂等) / studySessions(UUID upsert) / dailyPlans / wordProgress +// 不再使用 (group_id, op_type) 去重;事件以 event_id 天然幂等。 -import { api } from './api'; +import { api, localTimestamp } from './api'; import { - getDirtyProgress, clearDirty, getAllProgress, putProgress, deleteProgress, - putWords, setMeta, getMeta, setLearnableGroups, getAllWordsFromIDB, getWordsCount, + getUnsyncedEvents, markEventsSynced, + getUnsyncedSessions, markSessionsSynced, + putWords, setMeta, + putDailyPlan, putWordProgress, + getWordsCount, } from './db'; -const OPS = ['study', 'test', 'review1', 'review2', 'review3']; - // ---------- 同步状态机 (供 Home / SyncNotifications 共享) ---------- -// state: 'idle' | 'syncing' | 'synced' | 'failed' -// detail: { phase: 'upload' | 'merge', dirty, message, pushed, pulled } const listeners = new Set(); let syncStatus = { state: 'idle', detail: null }; @@ -34,221 +31,106 @@ function setSyncStatus(state, detail) { // ---------- 核心同步 ---------- export async function syncNow() { - const result = { pushed: 0, pulled: 0, warmed: 0, pushedEvents: [], pulledEvents: [] }; - - // 阶段1: 上传本地 dirty (严格按 dirty 推送, 非 dirty 视为已确认) - const dirty = await getDirtyProgress(); - setSyncStatus('syncing', { phase: 'upload', dirty: dirty.length }); - if (dirty.length > 0) { - const events = dirty.map(p => ({ - group_id: p.group_id, - op_type: p.op_type, - completed_at: p.completed_at, - duration_sec: p.duration_sec || 0, - })); - const r = await api.syncPush(events); - result.pushed = r.data.updated || events.length; - result.pushedEvents = events; - // 仅清"已确认上传"的 dirty (云端最终存在该 group/op 即清) - await Promise.all(dirty.map(p => clearDirty(p.group_id, p.op_type))); + const result = { pushed: 0, pulled: 0, warmed: 0 }; + if (typeof navigator !== 'undefined' && !navigator.onLine) { + setSyncStatus('failed', { message: '离线,无法同步' }); + return result; } - - // 阶段2: 拉云端进度, 布尔并集合并 (只补不覆盖, 不动本地已有) - setSyncStatus('syncing', { phase: 'merge', dirty: 0 }); - const pullResp = await api.syncPull(); - const serverEvents = pullResp.data.events || []; - const localAll = await getAllProgress(); - for (const ev of serverEvents) { - const key = `${ev.group_id}:${ev.op_type}`; - const local = localAll.find(p => p.key === key); - if (!local) { - // 本地缺失: 直接补入(布尔并集) - await putProgress(ev.group_id, ev.op_type, ev.completed_at, false, ev.duration_sec || 0); - result.pulled++; - result.pulledEvents.push(ev); - } else { - // 本地已存在: 若云端日期更新(如服务端修正了时区误记), - // 用云端日期覆盖本地显示时间, 让首页进度与云端一致(LWW, 只升不降, 按日期比较) - const localDate = (local.completed_at || '').slice(0, 10); - const serverDate = (ev.completed_at || '').slice(0, 10); - if (serverDate > localDate) { - await putProgress(ev.group_id, ev.op_type, ev.completed_at, false, ev.duration_sec || 0); - result.pulled++; - result.pulledEvents.push(ev); - } + try { + setSyncStatus('syncing', { phase: 'upload', dirty: 0 }); + + // 阶段1: 上传本地未同步事件 + const dirtyEvents = await getUnsyncedEvents(); + if (dirtyEvents.length > 0) { + const payload = dirtyEvents.map(e => ({ + event_id: e.event_id, + plan_id: e.plan_id, + word_id: e.word_id, + group_id: e.group_id, + stage: e.stage, + question_type: e.question_type, + result: e.result, + attempt_no: e.attempt_no || 1, + duration_ms: e.duration_ms || 0, + created_at: e.created_at, + })); + const r = await api.eventsBatch(dirtyEvents[0].session_id, payload); + result.pushed = r.data.inserted || 0; + await markEventsSynced(dirtyEvents.map(e => e.event_id)); } - } - // 阶段2b: 服务端已删除(且本地已确认/非 dirty)的进度, 本地同步删除。 - // 仅在成功拉取到非空集合时才执行, 避免服务端异常返回空时误删本地进度; - // 仅删非 dirty 记录(本地未推送完成的进度绝不删, 保护离线工作)。 - if (serverEvents.length > 0) { - const serverKeys = new Set(serverEvents.map(e => `${e.group_id}:${e.op_type}`)); - for (const p of localAll) { - if (!p.dirty && !serverKeys.has(p.key)) { - await deleteProgress(p.group_id, p.op_type); - } + // 阶段2: 上传未同步会话(主动时长) + const dirtySessions = await getUnsyncedSessions(); + for (const s of dirtySessions) { + await api.upsertSession({ + session_id: s.session_id, + plan_id: s.plan_id, + log_date: s.log_date, + started_at: s.started_at, + ended_at: s.ended_at, + active_sec: s.active_sec, + new_sec: s.new_sec, + review_sec: s.review_sec, + quiz_sec: s.quiz_sec, + completed: s.completed, + }); + result.pushed += 1; + } + if (dirtySessions.length > 0) { + await markSessionsSynced(dirtySessions.map(s => s.session_id)); } - } - // 阶段3: 首次 warmup 词库 (仅 IDB 空时, 避免每次重拉 1852 词) - if ((await getWordsCount()) === 0) { - const allWords = await api.getAllWords(); - await putWords(allWords.data); - result.warmed = allWords.data.length; - } + setSyncStatus('syncing', { phase: 'merge', dirty: 0 }); - // 阶段4: 更新 lastSyncAt + 重算可学习范围 - await setMeta('lastSyncAt', new Date().toISOString()); - await recomputeLearnableGroups(); + // 阶段3: 首次 warmup 词库 (仅 IDB 空时) + if ((await getWordsCount()) === 0) { + const allWords = await api.wordsV2All(); + await putWords(allWords.data); + result.warmed = allWords.data.length; + } - // 阶段5: 后台预取离线资源(思维导图+语音), 不阻塞同步完成 - prefetchLearnableAssets().catch(() => {}); + // 阶段4: 拉取 word_progress 快照(离线展示"已学") + const wp = await api.progressWords(); + if (wp.data && wp.data.length) { + for (const p of wp.data) await putWordProgress(p); + result.pulled = wp.data.length; + } - setSyncStatus('synced', { pushed: result.pushed, pulled: result.pulled }); + // 阶段5: 拉取今天计划(若存在)写入本地,离线可继续 + const today = localTimestamp().slice(0, 10); + try { + const plan = await api.dailyPlan(today); + if (plan.data) await putDailyPlan({ ...plan.data, plan_date: today }); + result.pulledPlan = !!plan.data; + } catch (e) { + // 404: 今天计划尚未生成,忽略 + } + + await setMeta('lastSyncAt', new Date().toISOString()); + setSyncStatus('synced', { pushed: result.pushed, pulled: result.pulled }); + } catch (e) { + setSyncStatus('failed', { message: (e && e.message) || String(e) }); + } return result; } let autoSyncRunning = false; -// 幂等自动同步: 并发调用只会跑一次; 失败置 failed (本地进度仍保留) export async function triggerAutoSync() { if (autoSyncRunning) return; autoSyncRunning = true; try { await syncNow(); - } catch (e) { - setSyncStatus('failed', { message: (e && e.message) || String(e) }); } finally { autoSyncRunning = false; } } -// 重算可学习范围: 未完全完成的组 + 最后完成组之后的 5 组 -export async function recomputeLearnableGroups() { - const allWords = await getAllWordsFromIDB(); - if (!allWords || allWords.length === 0) { - await setLearnableGroups([]); - return; - } - const allGroups = Array.from(new Set(allWords.map(w => Number(w.groupid)))).sort((a, b) => a - b); - const progress = await getAllProgress(); - const completedByGroup = {}; - progress.forEach(p => { - const g = Number(p.group_id); - if (!completedByGroup[g]) completedByGroup[g] = new Set(); - completedByGroup[g].add(p.op_type); - }); - const isComplete = (g) => { - const ops = completedByGroup[g]; - return !!ops && OPS.every(op => ops.has(op)); - }; - const learnable = new Set(); - let maxComplete = -1; - allGroups.forEach(g => { - if (isComplete(g)) { - if (g > maxComplete) maxComplete = g; - } else { - learnable.add(g); - } - }); - // 最后完成组后的 5 组 - if (maxComplete >= 0) { - const idx = allGroups.indexOf(maxComplete); - for (let i = 1; i <= 5; i++) { - if (idx + i < allGroups.length) learnable.add(allGroups[idx + i]); - } - } - await setLearnableGroups(Array.from(learnable).sort((a, b) => a - b)); -} - -// ---------- 预取离线资源(思维导图 + 语音)---------- -// 范围: 未完成组(有记录但未集齐5项) ∪ 未开始组中最前面10组 -// 仅在线执行, 后台非阻塞; 去重直接查 SW 缓存 (caches.match), 不依赖 meta 标记, -// 避免"SW 未接管时预取未真正落盘却被误标为已完成"导致后续跳过。 -const PREFETCH_CONCURRENCY = 6; -const PREFETCH_NOT_STARTED_AHEAD = 10; -const PREFETCH_OPS = ['study', 'test', 'review1', 'review2', 'review3']; - -// mode:'no-cors' 使请求键与 /