- 数据库: 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 不受影响(独立分支/服务/库)
--- /dev/null
+#!/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()
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)之和, 不含间隔, 即把各次学习时段相加
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)
// - 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(
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';
<SyncNotifications />
<Routes>
<Route path="/" element={<Home />} />
- <Route path="/study/:groupId" element={<StudyMode />} />
- <Route path="/test/:groupId" element={<TestMode />} />
+ <Route path="/batch" element={<DailyBatch />} />
<Route path="/group/:groupId" element={<GroupView />} />
<Route path="/calendar" element={<StudyCalendar />} />
</Routes>
--- /dev/null
+// 主动学习时长追踪 (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 };
+}
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' },
}),
--- /dev/null
+import React, { useState, useEffect, useRef, useCallback } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { api, localTimestamp } from '../api';
+import { useOnlineStatus } from '../hooks/useOnlineStatus';
+import {
+ getDailyPlanByDate, putDailyPlan, putLearningEvent, putStudySession,
+ putWordProgress, getAllWordProgress,
+} from '../db';
+import { triggerAutoSync } from '../sync';
+import { createActiveTimer } from '../activeTime';
+import './StudyMode.css';
+import './TestMode.css';
+
+function uuid() {
+ if (typeof crypto !== 'undefined' && crypto.randomUUID) return crypto.randomUUID();
+ return 'id-' + Date.now() + '-' + Math.random().toString(16).slice(2);
+}
+
+function chinaDate() {
+ return new Date(Date.now() + 8 * 3600 * 1000).toISOString().slice(0, 10);
+}
+
+const POS_POOL = ['n.', 'v.', 'adj.', 'adv.', 'prep.', 'conj.'];
+
+const sanitize = (s) => s.replace(/[^\x20-\x7E]/g, '').toLowerCase();
+const blockClipboard = (e) => e.preventDefault();
+const inputProps = {
+ type: 'text', lang: 'en', inputMode: 'latin', autoComplete: 'off',
+ autoCorrect: 'off', autoCapitalize: 'off', spellCheck: false,
+ onPaste: blockClipboard, onContextMenu: blockClipboard, onCompositionStart: blockClipboard,
+};
+
+export default function DailyBatch() {
+ const navigate = useNavigate();
+ const isOnline = useOnlineStatus();
+ const [, setPlan] = useState(null);
+ const [tasks, setTasks] = useState([]);
+ const [idx, setIdx] = useState(0);
+ const [loading, setLoading] = useState(true);
+ const [done, setDone] = useState(false);
+ const [weakWords, setWeakWords] = useState([]);
+
+ const sessionIdRef = useRef(uuid());
+ const timerRef = useRef(null);
+ const planRef = useRef(null);
+ const todayRef = useRef(chinaDate());
+ const wrongSetRef = useRef(new Set());
+
+ useEffect(() => {
+ (async () => {
+ const today = todayRef.current;
+ let p = await getDailyPlanByDate(today);
+ if (!p) {
+ if (!isOnline) {
+ alert('请联网后生成今日计划');
+ navigate('/');
+ return;
+ }
+ try {
+ const r = await api.dailyPlan(today);
+ p = { ...r.data, plan_date: today };
+ } catch (e) {
+ const r = await api.createDailyPlan(today);
+ p = { ...r.data, plan_date: today };
+ }
+ await putDailyPlan(p);
+ }
+ planRef.current = p;
+ setPlan(p);
+
+ const items = p.items || [];
+ const study = [];
+ items.forEach((it, i) => {
+ if (!it.word) return;
+ if (it.item_type === 'new_spell')
+ study.push({ kind: 'study', key: 's' + i, word: it.word, stage: 'new', qtype: 'spell', attempt_no: 1 });
+ else if (it.item_type === 'new_recognize')
+ study.push({ kind: 'study', key: 's' + i, word: it.word, stage: 'new', qtype: 'recognize', attempt_no: 1 });
+ else if (it.item_type === 'review_spell')
+ study.push({ kind: 'study', key: 's' + i, word: it.word, stage: 'review', qtype: 'spell', attempt_no: 1 });
+ else if (it.item_type === 'review_recognize')
+ study.push({ kind: 'study', key: 's' + i, word: it.word, stage: 'review', qtype: 'recognize', attempt_no: 1 });
+ });
+ const quiz = items.filter(it => it.item_type === 'quiz' && it.question)
+ .map((it, i) => ({ kind: 'quiz', key: 'q' + i, question: it.question }));
+
+ setTasks([...study, ...quiz]);
+ setLoading(false);
+
+ timerRef.current = createActiveTimer();
+ const sess = {
+ session_id: sessionIdRef.current, plan_id: p.plan_id, log_date: today,
+ started_at: localTimestamp(), ended_at: null,
+ active_sec: 0, new_sec: 0, review_sec: 0, quiz_sec: 0, completed: 0, synced: false,
+ };
+ await putStudySession(sess);
+ if (isOnline) triggerAutoSync();
+ })();
+ return () => { if (timerRef.current) timerRef.current.destroy(); };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ useEffect(() => {
+ if (loading) return;
+ const t = setInterval(() => flushSession(), 20000);
+ return () => clearInterval(t);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [loading]);
+
+ useEffect(() => {
+ if (!timerRef.current || done) return;
+ const tk = tasks[idx];
+ if (!tk) return;
+ if (tk.kind === 'quiz') timerRef.current.setPhase('quiz');
+ else if (tk.stage === 'review') timerRef.current.setPhase('review');
+ else timerRef.current.setPhase('new');
+ }, [idx, tasks, done]);
+
+ const flushSession = useCallback(async () => {
+ if (!timerRef.current || !planRef.current) return;
+ const ts = timerRef.current.get();
+ await putStudySession({
+ session_id: sessionIdRef.current, plan_id: planRef.current.plan_id, log_date: todayRef.current,
+ started_at: localTimestamp(), ended_at: null,
+ active_sec: ts.active_sec, new_sec: ts.new_sec, review_sec: ts.review_sec, quiz_sec: ts.quiz_sec,
+ completed: 0, synced: false,
+ });
+ if (isOnline) triggerAutoSync();
+ }, [isOnline]);
+
+ const recordEvent = useCallback(async ({ word_id, group_id, stage, question_type, result, attempt_no, duration_ms }) => {
+ const ev = {
+ event_id: uuid(),
+ session_id: sessionIdRef.current,
+ plan_id: planRef.current ? planRef.current.plan_id : null,
+ word_id, group_id, stage, question_type, result,
+ attempt_no: attempt_no || 1, duration_ms: duration_ms || 0,
+ created_at: localTimestamp(), log_date: todayRef.current, synced: false,
+ };
+ await putLearningEvent(ev);
+ if (word_id) {
+ const all = await getAllWordProgress();
+ const cur = all.find(p => p.word_id === word_id) || {
+ word_id, first_learned_at: null, last_seen_at: null, next_review_at: null,
+ memory_type: null, mastery_level: 0,
+ spell_correct: 0, spell_wrong: 0, recognize_correct: 0, recognize_wrong: 0,
+ quiz_correct: 0, quiz_wrong: 0,
+ };
+ const upd = { ...cur };
+ if (stage === 'new' && !upd.first_learned_at) upd.first_learned_at = todayRef.current;
+ upd.last_seen_at = todayRef.current;
+ if (stage === 'new' || stage === 'review') {
+ if (question_type === 'spell') { if (result === 'correct') upd.spell_correct++; else upd.spell_wrong++; }
+ else { if (result === 'correct') upd.recognize_correct++; else upd.recognize_wrong++; }
+ } else if (stage === 'quiz') {
+ if (result === 'correct') upd.quiz_correct++; else upd.quiz_wrong++;
+ }
+ await putWordProgress(upd);
+ }
+ if (isOnline) triggerAutoSync();
+ }, [isOnline]);
+
+ const onStudyResult = useCallback((task, result, duration_ms) => {
+ recordEvent({
+ word_id: task.word.id, group_id: task.word.groupid, stage: task.stage,
+ question_type: task.qtype, result, attempt_no: task.attempt_no, duration_ms,
+ });
+ if (result !== 'correct') {
+ if (!wrongSetRef.current.has(task.word.id)) {
+ wrongSetRef.current.add(task.word.id);
+ setWeakWords(w => [...w, { word: task.word.word, why: task.qtype === 'spell' ? '拼写' : '识别' }]);
+ }
+ setTasks(prev => [...prev, { ...task, key: task.key + '-r' + Date.now(), attempt_no: (task.attempt_no || 1) + 1 }]);
+ }
+ setIdx(i => i + 1);
+ }, [recordEvent]);
+
+ const onQuizResult = useCallback((task, result, duration_ms) => {
+ recordEvent({
+ word_id: null, group_id: null, stage: 'quiz',
+ question_type: task.question.type, result, attempt_no: 1, duration_ms,
+ });
+ if (result !== 'correct') {
+ setWeakWords(w => [...w, { word: task.question.answer, why: '综合题' }]);
+ }
+ setIdx(i => i + 1);
+ }, [recordEvent]);
+
+ const finish = useCallback(async () => {
+ if (timerRef.current) timerRef.current.pause();
+ const ts = timerRef.current ? timerRef.current.get() : { active_sec: 0, new_sec: 0, review_sec: 0, quiz_sec: 0 };
+ await putStudySession({
+ session_id: sessionIdRef.current, plan_id: planRef.current ? planRef.current.plan_id : null,
+ log_date: todayRef.current, started_at: localTimestamp(), ended_at: localTimestamp(),
+ active_sec: ts.active_sec, new_sec: ts.new_sec, review_sec: ts.review_sec, quiz_sec: ts.quiz_sec,
+ completed: 1, synced: false,
+ });
+ if (isOnline) triggerAutoSync();
+ setDone(true);
+ }, [isOnline]);
+
+ if (loading) return <div className="study-container"><div className="loading">加载今日任务…</div></div>;
+
+ if (done) {
+ return (
+ <div className="study-container">
+ <div className="study-card">
+ <h1 className="word-text">今日任务完成 🎉</h1>
+ <p className="meaning-row">
+ {weakWords.length > 0
+ ? `今天发现 ${weakWords.length} 个需要加强的点,已自动加入后续复习。`
+ : '今天表现得很好,没有发现薄弱点!'}
+ </p>
+ {weakWords.length > 0 && (
+ <ul className="weak-list">
+ {weakWords.map((w, i) => <li key={i}>{w.word} <span className="weak-why">({w.why})</span></li>)}
+ </ul>
+ )}
+ <button className="action-btn" onClick={() => navigate('/')}>返回首页</button>
+ </div>
+ </div>
+ );
+ }
+
+ const task = tasks[idx];
+ if (!task) { finish(); return <div className="loading">完成中…</div>; }
+
+ const total = tasks.length;
+ const phaseLabel = task.kind === 'quiz' ? '综合训练' : (task.stage === 'review' ? '复习' : '新词');
+
+ return (
+ <div className="study-container"
+ onCopy={blockClipboard} onCut={blockClipboard} onPaste={blockClipboard} onContextMenu={blockClipboard}>
+ <div className="study-card">
+ <div className="progress-tag">{phaseLabel}:{Math.min(idx + 1, total)} / {total}</div>
+ {task.kind === 'study'
+ ? <StudyTask task={task} onResult={(r, d) => onStudyResult(task, r, d)} />
+ : <QuizTask task={task} onResult={(r, d) => onQuizResult(task, r, d)} isLast={idx === total - 1} />}
+ </div>
+ <button className="back-home" onClick={() => { if (window.confirm('退出今日任务?进度已自动保存。')) navigate('/'); }}>返回首页</button>
+ </div>
+ );
+}
+
+// ---------------- 单词学习卡(spell / recognize) ----------------
+function StudyTask({ task, onResult }) {
+ const { word, qtype } = task;
+ const [step, setStep] = useState('prompt');
+ const [userInput, setUserInput] = useState('');
+ const [copyInput, setCopyInput] = useState('');
+ const [isSpeaking, setIsSpeaking] = useState(false);
+ const spokenRef = useRef(false);
+ const startRef = useRef(Date.now());
+
+ const dur = () => Date.now() - startRef.current;
+
+ const speak = useCallback((text) => {
+ if (!text) return;
+ setIsSpeaking(true);
+ const a = new Audio(api.ttsUrl(text));
+ a.play().catch(() => setIsSpeaking(false));
+ a.onended = () => setIsSpeaking(false);
+ }, []);
+
+ useEffect(() => { if (!spokenRef.current && word.word) { speak(word.word); spokenRef.current = true; } }, [word, speak]);
+
+ const correctPos = word.pos_quiz_answer || word.part_of_speech;
+ const hasPos = correctPos && !correctPos.includes('/');
+
+ const checkSpell = () => {
+ if (sanitize(userInput) === word.word.toLowerCase()) onResult('correct', dur());
+ else setStep('copy');
+ };
+ const checkCopy = () => {
+ if (sanitize(copyInput) === word.word.toLowerCase()) onResult('wrong', dur());
+ };
+
+ if (qtype === 'spell') {
+ if (step === 'prompt') {
+ return (
+ <div className="info-area">
+ <h1 className="word-text">?</h1>
+ <div className="meaning-row"><span className="chinese">{word.chinese_meaning}</span><span className="pos">({word.part_of_speech})</span></div>
+ {word.word_root && <p className="root"><strong>词根:</strong> {word.word_root}</p>}
+ {word.example_sentence && <p className="affix"><strong>例句:</strong> {word.example_sentence}</p>}
+ <button className="action-btn" onClick={() => setStep('input')} disabled={isSpeaking}>
+ {isSpeaking ? '朗读中…' : '我来拼写 (Enter)'}
+ </button>
+ </div>
+ );
+ }
+ if (step === 'input') {
+ return (
+ <div className="flashcard-area">
+ <h2 className="flashcard-title">拼写:{word.chinese_meaning}</h2>
+ <p className="flashcard-hint">请写出英文单词</p>
+ <input autoFocus value={userInput} onChange={(e) => setUserInput(sanitize(e.target.value))}
+ onKeyDown={(e) => e.key === 'Enter' && checkSpell()} placeholder="输入英文单词…" {...inputProps} />
+ <button className="action-btn" onClick={checkSpell}>检查 (Enter)</button>
+ </div>
+ );
+ }
+ return (
+ <div className="correction-area">
+ <h2 className="error-tip">回答错误!正确答案:{word.word}</h2>
+ <p>请抄写一遍以解锁:</p>
+ <input autoFocus className={copyInput.toLowerCase() === word.word.toLowerCase() ? 'correct' : 'typing'}
+ value={copyInput} onChange={(e) => setCopyInput(sanitize(e.target.value))}
+ onKeyDown={(e) => { if (e.key === 'Enter' && copyInput.toLowerCase() === word.word.toLowerCase()) checkCopy(); }}
+ placeholder="请在此抄写…" {...inputProps} />
+ {copyInput.toLowerCase() === word.word.toLowerCase() && (
+ <button className="action-btn" onClick={checkCopy}>重新尝试 (Enter)</button>
+ )}
+ </div>
+ );
+ }
+
+ // recognize
+ if (step === 'prompt') {
+ return (
+ <div className="info-area">
+ <h1 className="word-text">{word.word}</h1>
+ <div className="meaning-row"><span className="chinese">{word.chinese_meaning}</span><span className="pos">({word.part_of_speech})</span></div>
+ {word.word_root && <p className="root"><strong>词根:</strong> {word.word_root}</p>}
+ {word.example_sentence && <p className="affix"><strong>例句:</strong> {word.example_sentence}</p>}
+ <button className="action-btn" onClick={() => setStep('choose')}>开始识别 (Enter)</button>
+ </div>
+ );
+ }
+ if (hasPos) {
+ const opts = shuffle([correctPos, ...POS_POOL.filter(p => p !== correctPos)]).slice(0, 4);
+ return (
+ <div className="flashcard-area">
+ <h2 className="flashcard-title">选择「{word.word}」的主要词性</h2>
+ <p className="flashcard-hint">先默想词义,再选择</p>
+ <div className="pos-options">
+ {opts.map((o, i) => (
+ <button key={i} className="action-btn pos-opt" onClick={() => onResult(o === correctPos ? 'correct' : 'wrong', dur())}>{o}</button>
+ ))}
+ </div>
+ </div>
+ );
+ }
+ return (
+ <div className="flashcard-area">
+ <h2 className="flashcard-title">你认识「{word.word}」吗?</h2>
+ <p className="flashcard-hint">意思:{word.chinese_meaning}</p>
+ <button className="action-btn" onClick={() => onResult('correct', dur())}>认识</button>
+ <button className="action-btn" onClick={() => onResult('wrong', dur())}>不认识</button>
+ </div>
+ );
+}
+
+// ---------------- 综合题卡(MCQ) ----------------
+function QuizTask({ task, onResult, isLast }) {
+ const q = task.question;
+ const [selected, setSelected] = useState(null);
+ const [submitted, setSubmitted] = useState(false);
+ const startRef = useRef(Date.now());
+ const dur = () => Date.now() - startRef.current;
+ const correct = selected === q.answer_index;
+
+ const submit = () => { if (selected === null) return; setSubmitted(true); };
+
+ if (!submitted) {
+ return (
+ <div className="flashcard-area">
+ <h2 className="flashcard-title">综合训练</h2>
+ <p className="flashcard-hint">{q.prompt}</p>
+ <div className="quiz-options">
+ {q.options.map((o, i) => (
+ <button key={i} className={'action-btn quiz-opt' + (selected === i ? ' selected' : '')}
+ onClick={() => setSelected(i)}>{o}</button>
+ ))}
+ </div>
+ <button className="action-btn" onClick={submit} disabled={selected === null}>提交</button>
+ <p className="quiz-note">*{q.note}</p>
+ </div>
+ );
+ }
+ return (
+ <div className="feedback-wrapper">
+ <div className="feedback-main">
+ <div className={`fb-status ${correct ? 'correct' : 'wrong'}`}>
+ {correct ? '✅ 正确!' : `❌ 正确答案:${q.answer}`}
+ </div>
+ <button className="action-btn" onClick={() => onResult(correct ? 'correct' : 'wrong', dur())}>
+ {isLast ? '完成 (Enter)' : '下一题 (Enter)'}
+ </button>
+ </div>
+ </div>
+ );
+}
+
+function shuffle(arr) {
+ const a = [...arr];
+ for (let i = a.length - 1; i > 0; i--) {
+ const j = Math.floor(Math.random() * (i + 1));
+ [a[i], a[j]] = [a[j], a[i]];
+ }
+ return a;
+}
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; }
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { api } from '../api';
-import { getWordsByGroup } from '../db';
+import { getWordsByGroup, getAllWordProgress } from '../db';
import './GroupView.css';
function GroupView() {
const { groupId } = useParams();
const navigate = useNavigate();
const [words, setWords] = useState([]);
+ const [learned, setLearned] = useState({});
useEffect(() => {
- api.getWords(groupId)
+ const gid = Number(groupId);
+ api.wordsV2Group(gid)
.then(res => setWords(res.data))
- .catch(async (err) => {
- // 离线或后端不可达: 降级读 IndexedDB (规格 9: 所有组均可离线查看)
- console.error("获取词汇失败, 降级读离线缓存:", err);
- const local = await getWordsByGroup(groupId);
+ .catch(async () => {
+ const local = await getWordsByGroup(gid);
setWords(local);
});
+ getAllWordProgress().then(list => {
+ const m = {};
+ list.forEach(p => { if (p.first_learned_at) m[p.word_id] = true; });
+ setLearned(m);
+ }).catch(() => {});
}, [groupId]);
- // 组内所有单词共享同一张思维导图,取首词的 picture_id(兼容 picture_id==groupid 约定)
const pictureId = words.length > 0 ? words[0].picture_id : null;
+ const learnedCount = words.filter(w => learned[w.id]).length;
if (words.length === 0) {
return (
<div className="group-view-container">
- <div className="group-view-card">
- <p className="loading">加载中...</p>
- </div>
+ <div className="group-view-card"><p className="loading">加载中...</p></div>
</div>
);
}
<div className="group-view-card">
<div className="group-view-header">
<h1>Group {groupId} · 词汇预览</h1>
- <p className="group-view-count">共 {words.length} 个单词(只读预览,不记录进度)</p>
+ <p className="group-view-count">共 {words.length} 个单词 · 已学 {learnedCount}
+ <br /><span className="gv-hint">只读预览,不记录进度</span></p>
</div>
{pictureId && (
<table className="group-word-table">
<thead>
<tr>
- <th>单词</th>
- <th>词性</th>
- <th>中文释义</th>
- <th>词根</th>
- <th>词缀</th>
- <th>例句</th>
+ <th>单词</th><th>词性</th><th>中文释义</th>
+ <th>记忆类型</th><th>词根</th><th>例句</th>
</tr>
</thead>
<tbody>
{words.map(w => (
- <tr key={w.id}>
+ <tr key={w.id} className={learned[w.id] ? 'gv-learned' : ''}>
<td className="gw-word">{w.word}</td>
<td className="gw-pos">{w.part_of_speech}</td>
<td className="gw-cn">{w.chinese_meaning}</td>
+ <td className="gw-mt">
+ <span className={'mt-badge ' + (w.memory_type === 'spell' ? 'mt-spell' : 'mt-recognize')}>
+ {w.memory_type === 'spell' ? '拼写' : '识别'}
+ </span>
+ </td>
<td className="gw-root">{w.word_root}</td>
- <td className="gw-affix">{w.affix}</td>
<td className="gw-ex">{w.example_sentence}</td>
</tr>
))}
</tbody>
</table>
</div>
+
+ <div className="gv-nav">
+ <button className="toggle-btn" onClick={() => navigate(`/group/${Number(groupId) - 1}`)}>← 上一组</button>
+ <button className="toggle-btn" onClick={() => navigate('/')}>首页</button>
+ <button className="toggle-btn" onClick={() => navigate(`/group/${Number(groupId) + 1}`)}>下一组 →</button>
+ </div>
</div>
<button className="back-home" onClick={() => navigate('/')}>返回首页</button>
</div>
}
.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; }
import React, { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useOnlineStatus } from '../hooks/useOnlineStatus';
-import { getAllWordsFromIDB, getAllProgress, isInitialized } from '../db';
-import { triggerAutoSync, subscribeSyncStatus } from '../sync';
+import { isInitialized, getDailyPlanByDate, putDailyPlan, getWordsCount, getAllWordProgress } from '../db';
+import { triggerAutoSync } from '../sync';
+import { api } from '../api';
import { logout } from '../auth';
import './Home.css';
+function chinaDate() {
+ return new Date(Date.now() + 8 * 3600 * 1000).toISOString().slice(0, 10);
+}
+
function Home() {
- const [progressData, setProgressData] = useState([]);
- const [showAll, setShowAll] = useState(false);
- const [stats, setStats] = useState({ pictures_count: 0, words_count: 0 });
- const [needInit, setNeedInit] = useState(false); // 首次离线: 需联网初始化
- const [blocking, setBlocking] = useState(false); // 同步中且本地有 dirty: 禁用学习入口
+ const [plan, setPlan] = useState(null);
+ const today = chinaDate();
+ const [stats, setStats] = useState({ words_count: 0, learned_count: 0 });
+ const [needInit, setNeedInit] = useState(false);
+ const [loading, setLoading] = useState(true);
+ const [creating, setCreating] = useState(false);
const navigate = useNavigate();
const isOnline = useOnlineStatus();
- // 离线降级: 从 IndexedDB 构造与 /api/home/process 同格式的数据 (emoji 字符串)
- const buildOfflineData = async () => {
- const allWords = await getAllWordsFromIDB();
- const progress = await getAllProgress();
- const completedByGroup = {};
- progress.forEach(p => {
- const g = String(p.group_id);
- if (!completedByGroup[g]) completedByGroup[g] = {};
- completedByGroup[g][p.op_type] = p.completed_at;
- });
- const allGroups = Array.from(new Set(allWords.map(w => String(w.groupid))))
- .sort((a, b) => Number(a) - Number(b));
- let firstUnstudied = null;
- for (const g of allGroups) {
- if (!completedByGroup[g] || !completedByGroup[g]['study']) { firstUnstudied = g; break; }
- }
- const OPS = ['study', 'test', 'review1', 'review2', 'review3'];
- const PREV = { test: 'study', review1: 'test', review2: 'review1', review3: 'review2' };
- const progressData = allGroups.map(g => {
- const p = completedByGroup[g] || {};
- const row = { group: `Group ${g}` };
- for (const op of OPS) {
- if (p[op]) { row[op] = `✔️ ${p[op].slice(0, 10)}`; continue; }
- if (op === 'study') { row[op] = (g === firstUnstudied) ? '✅请开始学习' : ''; continue; }
- if (op === 'test') { row[op] = p['study'] ? '✅请开始测试' : ''; continue; }
- row[op] = p[PREV[op]] ? '✅请开始复习' : '';
- }
- return row;
- });
- return {
- stats: { pictures_count: 0, words_count: allWords.length },
- progressData,
- };
- };
-
- // 从本地 IndexedDB 渲染首页 (永远本地优先, 不读云端进度)
- const renderLocal = async () => {
- const d = await buildOfflineData();
- setProgressData(d.progressData);
- setStats(d.stats);
- return d.progressData.length;
- };
-
useEffect(() => {
(async () => {
const init = await isInitialized();
- // 首次离线: 不能进入学习, 提示需联网
if (!isOnline && !init) {
setNeedInit(true);
- setProgressData([]);
+ setLoading(false);
return;
}
setNeedInit(false);
- await renderLocal();
- // 在线: 后台自动同步 (首次在线会 warmup 词库+进度; 已初始化则合并云端)
+
+ // 今天计划:先本地,再尝试服务端
+ let local = await getDailyPlanByDate(today);
+ if (!local && isOnline) {
+ try {
+ const r = await api.dailyPlan(today);
+ local = { ...r.data, plan_date: today };
+ await putDailyPlan(local);
+ } catch (e) {
+ local = null; // 404: 尚未生成
+ }
+ }
+ setPlan(local);
+
+ // 统计
+ if (isOnline) {
+ try {
+ const h = await api.v2Home();
+ setStats({ words_count: h.data.words_count, learned_count: h.data.learned_count });
+ } catch (e) {}
+ } else {
+ const wc = await getWordsCount();
+ const wp = await getAllWordProgress();
+ const learned = wp.filter(p => p.first_learned_at).length;
+ setStats({ words_count: wc, learned_count: learned });
+ }
+
+ setLoading(false);
if (isOnline) triggerAutoSync();
})();
if (!isOnline) return;
- // 在线时定时自动合并其他设备的进度
const timer = setInterval(() => triggerAutoSync(), 30000);
return () => clearInterval(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [isOnline]);
+ }, [isOnline, today]);
- // 订阅同步状态: 同步完成重渲染本地; 同步中且有 dirty 时禁用学习入口
- useEffect(() => {
- return subscribeSyncStatus((s) => {
- if (s.state === 'synced') renderLocal();
- setBlocking(s.state === 'syncing' && s.detail && s.detail.dirty > 0);
- });
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, []);
-
- const filteredData = progressData.filter(item => {
- if (showAll) return true;
- const isDone = [item.study, item.test, item.review1, item.review2, item.review3]
- .every(val => val && val.includes('✔️'));
- return !isDone;
- });
-
- const getStatusClass = (text) => {
- if (!text) return '';
- if (text.includes('✔️')) return 'status-done';
- if (text.includes('✅')) return 'status-start';
- if (text.includes('⚠️')) return 'status-warning';
- return '';
+ const generatePlan = async () => {
+ if (!isOnline) { alert('请联网后生成今日计划'); return; }
+ setCreating(true);
+ try {
+ const r = await api.createDailyPlan(today);
+ const p = { ...r.data, plan_date: today };
+ await putDailyPlan(p);
+ setPlan(p);
+ } catch (e) {
+ alert('生成计划失败:' + ((e && e.message) || e));
+ } finally {
+ setCreating(false);
+ }
};
- const handleCellClick = (item, type) => {
- const groupId = item.group.replace('Group ', '');
- if (type === 'group') {
- navigate(`/group/${groupId}`);
- return;
- }
- // 同步中且本地有 dirty: 禁止开始新的学习/测试/复习 (先同步完成)
- if (blocking) {
- alert('同步完成后再开始新的学习');
- return;
- }
- if (type === 'study' && item.study && item.study.includes('请开始学习')) {
- navigate(`/study/${groupId}`);
- }
- if (type === 'test' && item.test && item.test.includes('请开始测试')) {
- navigate(`/test/${groupId}?type=test`);
- }
- if (type === 'review1' && item.review1 && (item.review1.includes('请开始复习') || item.review1.includes('请尽快复习'))) {
- navigate(`/test/${groupId}?type=review1`);
- }
- if (type === 'review2' && item.review2 && (item.review2.includes('请开始复习') || item.review2.includes('请尽快复习'))) {
- navigate(`/test/${groupId}?type=review2`);
- }
- if (type === 'review3' && item.review3 && (item.review3.includes('请开始复习') || item.review3.includes('请尽快复习'))) {
- navigate(`/test/${groupId}?type=review3`);
- }
+ const startTask = () => {
+ if (!plan) { generatePlan(); return; }
+ navigate('/batch');
};
+ if (loading) {
+ return <div className="main-app"><header className="header"><h1>单词学习</h1></header><p className="stats-info">加载中…</p></div>;
+ }
+
+ const plannedNew = plan ? plan.planned_new : 0;
+ const plannedSpell = plan ? plan.planned_spell : 0;
+ const plannedRecognize = plan ? plan.planned_recognize : 0;
+ const plannedReview = plan ? plan.planned_review : 0;
+ const plannedQuiz = plan ? (plan.planned_quiz || 5) : 5;
+ const estMin = plan ? plan.estimated_min : 0;
+
return (
<div className="main-app">
<header className="header">
- <h1>单词学习进度记录</h1>
+ <h1>今晚任务</h1>
<div className="header-right">
<p className="stats-info">
- 图片总数: {stats.pictures_count} | 单词总数: {stats.words_count}
+ 词库 {stats.words_count} 词 · 已学 {stats.learned_count}
</p>
<button className="logout-btn" onClick={() => { logout(); window.location.reload(); }}>退出</button>
</div>
</div>
)}
- {blocking && (
- <div className="sync-block-tip">同步完成后再开始新的学习</div>
- )}
+ <div className="batch-dashboard">
+ <div className="batch-headline">
+ <span className="batch-title">今晚任务</span>
+ <span className="batch-est">预计 {estMin || '--'} 分钟</span>
+ </div>
+ <p className="batch-note">今日计划生成后不再加码;到 15 分钟自动停止追加。</p>
- <div className="action-bar">
- <button className="toggle-btn" onClick={() => setShowAll(!showAll)}>
- {showAll ? "➖ 隐藏已完成组" : "➕ 显示已完成的组"}
- </button>
- <button className="toggle-btn" onClick={() => navigate('/calendar')}>
- 📅 学习日历
- </button>
+ <div className="batch-cards">
+ <div className="batch-card new-card">
+ <div className="bc-label">新词</div>
+ <div className="bc-num">{plannedNew || '--'}<span className="bc-unit">个</span></div>
+ <div className="bc-sub">{plannedSpell} 个拼写 / {plannedRecognize} 个识别</div>
+ </div>
+ <div className="batch-card review-card">
+ <div className="bc-label">到期复习</div>
+ <div className="bc-num">{plannedReview || 0}<span className="bc-unit">个</span></div>
+ <div className="bc-sub">错词优先 · 未完成自动顺延</div>
+ </div>
+ <div className="batch-card quiz-card">
+ <div className="bc-label">综合训练</div>
+ <div className="bc-num">{plannedQuiz}<span className="bc-unit">题</span></div>
+ <div className="bc-sub">3 道今日词 / 2 道复习词</div>
+ </div>
+ </div>
+
+ {plan ? (
+ <button className="action-btn start-batch-btn" onClick={startTask}>开始今日任务</button>
+ ) : (
+ <button className="action-btn start-batch-btn" onClick={generatePlan} disabled={creating}>
+ {creating ? '生成中…' : '生成今日计划'}
+ </button>
+ )}
</div>
- <div className="table-container">
- <table className="progress-table">
- <thead>
- <tr>
- <th>组别</th>
- <th>基础学习</th>
- <th>单元测试</th>
- <th>复习 I</th>
- <th>复习 II</th>
- <th>复习 III</th>
- </tr>
- </thead>
- <tbody>
- {filteredData.map((item, index) => (
- <tr key={index}>
- <td
- className="group-name"
- onClick={() => handleCellClick(item, 'group')}
- style={{ cursor: 'pointer' }}
- >
- {item.group}
- </td>
- <td
- className={getStatusClass(item.study)}
- onClick={() => handleCellClick(item, 'study')}
- style={{ cursor: item.study.includes('请开始学习') ? 'pointer' : 'default' }}
- >
- {item.study}
- </td>
- <td
- className={getStatusClass(item.test)}
- onClick={() => handleCellClick(item, 'test')}
- style={{ cursor: item.test && item.test.includes('请开始') ? 'pointer' : 'default' }}
- >
- {item.test}
- </td>
- <td
- className={getStatusClass(item.review1)}
- onClick={() => handleCellClick(item, 'review1')}
- style={{ cursor: item.review1 && (item.review1.includes('请开始') || item.review1.includes('请尽快')) ? 'pointer' : 'default' }}
- >
- {item.review1}
- </td>
- <td
- className={getStatusClass(item.review2)}
- onClick={() => handleCellClick(item, 'review2')}
- style={{ cursor: item.review2 && (item.review2.includes('请开始') || item.review2.includes('请尽快')) ? 'pointer' : 'default' }}
- >
- {item.review2}
- </td>
- <td
- className={getStatusClass(item.review3)}
- onClick={() => handleCellClick(item, 'review3')}
- style={{ cursor: item.review3 && (item.review3.includes('请开始') || item.review3.includes('请尽快')) ? 'pointer' : 'default' }}
- >
- {item.review3}
- </td>
- </tr>
- ))}
- </tbody>
- </table>
+ <div className="action-bar">
+ <button className="toggle-btn" onClick={() => navigate('/calendar')}>📅 学习日历</button>
+ <button className="toggle-btn" onClick={() => navigate('/group/1')}>📚 词库浏览</button>
</div>
<footer className="footer">
- <button className="error-correction-btn">进入消错模式</button>
+ <button className="error-correction-btn" onClick={() => navigate('/calendar')}>查看学习记录</button>
</footer>
</div>
);
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; }
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({});
});
}, []);
const todayStr = `${today.getFullYear()}-${pad(today.getMonth() + 1)}-${pad(today.getDate())}`;
const currentMonth = `${today.getFullYear()}-${pad(today.getMonth() + 1)}`;
- // 月份渲染顺序: 当前月 + 有记录月份(去除当前月, 倒序)
- const recordMonths = Object.keys(dayMap).map(d => d.slice(0, 7));
- const uniqueRecordMonths = Array.from(new Set(recordMonths)).sort().reverse();
- const renderMonths = [currentMonth, ...uniqueRecordMonths.filter(m => m !== currentMonth)];
+ const recordMonths = Array.from(new Set(Object.keys(dayMap).map(d => d.slice(0, 7)))).sort().reverse();
+ const renderMonths = [currentMonth, ...recordMonths.filter(m => m !== currentMonth)];
+
+ // 月标题统计(V2 口径:学习天数 + 总时长 + 新词数)
+ function monthSummary(monthStr) {
+ let days = 0, totalMin = 0, newWords = 0;
+ Object.keys(dayMap).forEach(d => {
+ if (d.slice(0, 7) === monthStr) {
+ const it = dayMap[d];
+ const hasData = (it.new_words || it.review_words || it.quiz_total || it.duration_min);
+ if (it.legacy && !hasData) { days++; totalMin += it.duration_min || 0; return; }
+ if (hasData) { days++; totalMin += it.duration_min || 0; newWords += it.new_words || 0; }
+ }
+ });
+ return { days, totalMin, newWords };
+ }
function renderMonth(monthStr) {
const [yStr, mStr] = monthStr.split('-');
const y = parseInt(yStr), m = parseInt(mStr);
- const firstDay = new Date(y, m - 1, 1).getDay(); // 0=Sun
- const offset = (firstDay + 6) % 7; // 周一起始的空格数
+ const firstDay = new Date(y, m - 1, 1).getDay();
+ const offset = (firstDay + 6) % 7; // 周一起始
const daysInMonth = new Date(y, m, 0).getDate();
- // 统计当月学习的 group 数(op_type='study' 的不同 group)
- const studiedGroups = new Set();
- Object.keys(dayMap).forEach(dateStr => {
- if (dateStr.slice(0, 7) === monthStr) {
- const info = dayMap[dateStr];
- if (info && info.activities) info.activities.forEach(a => {
- if (a.op_type === 'study') a.groups.forEach(g => studiedGroups.add(g));
- });
- }
- });
- const studiedCount = studiedGroups.size;
const cells = [];
for (let i = 0; i < offset; i++) cells.push(null);
for (let d = 1; d <= daysInMonth; d++) cells.push(d);
while (cells.length % 7 !== 0) cells.push(null);
+ const sum = monthSummary(monthStr);
return (
<div className="cal-month" key={monthStr}>
<div className="cal-month-title">
- {y}年 {m}月 · <span className="cal-month-count">(学习了{studiedCount}个group)</span>
+ {y}年 {m}月 · <span className="cal-month-count">学习 {sum.days} 天 · {fmtDur(sum.totalMin)} · 新词 {sum.newWords} 个</span>
</div>
<div className="cal-grid">
{WEEKDAYS.map(w => <div className="cal-weekday" key={w}>{w}</div>)}
{cells.map((d, i) => {
if (d === null) return <div className="cal-day empty" key={i} />;
const dateStr = `${monthStr}-${pad(d)}`;
- const dayInfo = dayMap[dateStr];
- const acts = dayInfo ? dayInfo.activities : undefined;
- const durationMin = dayInfo ? dayInfo.durationMin : 0;
- const isActive = !!acts;
+ const info = dayMap[dateStr];
+ const dur = info ? info.duration_min : 0;
+ const hasData = info && (info.new_words || info.review_words || info.quiz_total || dur);
+ const isActive = !!hasData;
const isToday = dateStr === todayStr;
const cls = ['cal-day'];
if (isActive) cls.push('active');
+ if (info && info.legacy) cls.push('legacy');
if (isToday) cls.push('today');
return (
- <div className={cls.join(' ')} key={i}>
+ <div className={cls.join(' ')} key={i} onClick={() => info && hasData ? openDetail(dateStr) : null}>
<div className="cal-day-head">
- {durationMin > 0 && (
- <span className="cal-duration">{durationMin}分钟</span>
- )}
+ {dur > 0 && <span className="cal-duration">{dur}分</span>}
<div className="cal-day-num">{d}</div>
</div>
- {acts && acts.map((a, j) => (
- <div className="cal-act" key={j} style={{ color: OP_COLOR[a.op_type] || '#475569' }}>
- {OP_LABEL[a.op_type] || a.op_type} G{a.groups.join(',')}
+ {info && (
+ <div className="cal-batch">
+ {info.new_words > 0 && <span className="cb new">新{info.new_words}</span>}
+ {info.review_words > 0 && <span className="cb rev">复{info.review_words}</span>}
+ {info.quiz_total > 0 && <span className="cb q">题{info.quiz_total}</span>}
</div>
- ))}
+ )}
</div>
);
})}
);
}
+ async function openDetail(dateStr) {
+ try {
+ const r = await api.calendarV2Day(dateStr);
+ setDetail(r.data);
+ } catch (e) {
+ console.error(e);
+ }
+ }
+
return (
<div className="cal-page">
<div className="cal-container">
<h1 className="cal-title">📅 学习日历</h1>
{renderMonths.map(renderMonth)}
</div>
+
+ {detail && (
+ <div className="cal-detail-mask" onClick={() => setDetail(null)}>
+ <div className="cal-detail" onClick={(e) => e.stopPropagation()}>
+ <h2>{detail.date}</h2>
+ <div className="cd-row"><span>主动学习</span><b>{fmtDur(detail.duration_min)}</b></div>
+ <div className="cd-row"><span>新词 / 拼写 / 识别</span><b>{detail.new_words} · {detail.spell_words} / {detail.recognize_words}</b></div>
+ <div className="cd-row"><span>复习</span><b>{detail.review_words}</b></div>
+ <div className="cd-row"><span>综合题</span><b>{detail.quiz_total}(对 {detail.quiz_correct})</b></div>
+ {detail.groups_touched && detail.groups_touched.length > 0 && (
+ <div className="cd-row"><span>涉及章节</span><b>G{detail.groups_touched.join(' / G')}</b></div>
+ )}
+ {detail.weak_points && detail.weak_points.length > 0 && (
+ <div className="cd-weak">
+ <div className="cd-weak-title">需要加强:</div>
+ {detail.weak_points.map((w, i) => (
+ <div key={i} className="cd-weak-item">{w.word} <span className="weak-why">({w.wrong_types.join('/')})</span></div>
+ ))}
+ </div>
+ )}
+ <button className="action-btn" onClick={() => setDetail(null)}>关闭</button>
+ </div>
+ </div>
+ )}
+
<button className="back-home" onClick={() => navigate('/')}>返回首页</button>
</div>
);
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; }
+++ /dev/null
-import React, { useState, useEffect, useCallback, useRef } from 'react';
-import { useParams, useNavigate } from 'react-router-dom';
-import { api, localTimestamp } from '../api';
-import { useOnlineStatus } from '../hooks/useOnlineStatus';
-import { getWordsByGroup, putProgress, getLearnableGroups } from '../db';
-import { triggerAutoSync } from '../sync';
-import './StudyMode.css';
-
-function StudyMode() {
- const { groupId } = useParams();
- const navigate = useNavigate();
- const isOnline = useOnlineStatus();
- // 学习会话开始时间(毫秒时间戳), 用于计算本次学习的实际时长
- const sessionStartRef = useRef(Date.now());
- const [words, setWords] = useState([]);
- const [currentIndex, setCurrentIndex] = useState(0);
- const [phase, setPhase] = useState('learning'); // learning, flashcard, correction
- const [userInput, setUserInput] = useState("");
- const [correctionInput, setCorrectionInput] = useState("");
- const [isSpeaking, setIsSpeaking] = useState(false);
-
- const hasSpokenRef = useRef(null);
- const transitioningRef = useRef(false);
- const submittedRef = useRef(false); // 防止完成时重复提交进度
- const currentWord = words[currentIndex];
-
- // 朗读逻辑:通过后端 TTS 代理(有道美式发音)播放两遍,间隔 500ms
- // 朗读期间 isSpeaking=true,阻止进入闪记模式
- const speak = useCallback((text) => {
- if (!text) return;
- const url = api.ttsUrl(text);
- setIsSpeaking(true);
- const finish = () => setIsSpeaking(false);
- const audio1 = new Audio(url);
- audio1.play().catch(err => {
- console.error("TTS play failed:", err);
- finish();
- });
- audio1.onended = () => {
- setTimeout(() => {
- const audio2 = new Audio(url);
- audio2.play().catch(err => {
- console.error("TTS play failed:", err);
- finish();
- });
- audio2.onended = finish;
- }, 500);
- };
- }, []);
-
- // 离线时检查可学习范围 (规格 7: 仅未完成组 + 最后完成组后5组可离线学习)
- useEffect(() => {
- if (isOnline) return; // 在线时所有组均可学
- getLearnableGroups().then(groups => {
- if (!groups.includes(Number(groupId))) {
- alert('该组不可离线学习(仅未完成组与最后完成组后的5组可离线学习)');
- navigate('/');
- }
- });
- }, [isOnline, groupId, navigate]);
-
- // 获取单词数据 (在线走网络, 离线降级读 IndexedDB)
- useEffect(() => {
- api.getWords(groupId)
- .then(res => setWords(res.data))
- .catch(async () => {
- const local = await getWordsByGroup(groupId);
- setWords(local);
- });
- }, [groupId]);
-
- // 自动朗读逻辑
- useEffect(() => {
- if (currentWord && phase === 'learning') {
- if (hasSpokenRef.current !== currentWord.id) {
- speak(currentWord.word);
- hasSpokenRef.current = currentWord.id;
- }
- }
- }, [currentIndex, currentWord, phase, speak]);
-
- // 全局键盘监听:在学习模式下按回车进入闪记(朗读中禁用)
- useEffect(() => {
- const handleGlobalKeyDown = (event) => {
- if (transitioningRef.current) return;
- if (isSpeaking) return;
- if (phase === 'learning' && event.key === 'Enter') {
- setPhase('flashcard');
- }
- };
- window.addEventListener('keydown', handleGlobalKeyDown);
- return () => window.removeEventListener('keydown', handleGlobalKeyDown);
- }, [phase, isSpeaking]);
-
- const handleNext = async () => {
- transitioningRef.current = true;
- if (currentIndex < words.length - 1) {
- setCurrentIndex(currentIndex + 1);
- setPhase('learning');
- setUserInput("");
- setCorrectionInput("");
- setTimeout(() => { transitioningRef.current = false; }, 300);
- } else {
- if (submittedRef.current) return; // 防重入(按住Enter等场景)
- submittedRef.current = true;
- const now = localTimestamp();
- // 本次学习实际时长(秒) = 完成时刻 - 会话开始时刻
- const durationSec = Math.max(0, Math.round((Date.now() - sessionStartRef.current) / 1000));
- // 本地优先: 先写本地 dirty (必定保存, 不丢记录)
- await putProgress(groupId, 'study', now, true, durationSec);
- // 在线则后台自动同步 (成功清 dirty, 失败保留)
- if (isOnline) triggerAutoSync();
- alert("恭喜完成本组学习!");
- navigate('/');
- }
- };
-
- const checkFlashcard = () => {
- if (userInput.toLowerCase().trim() === currentWord.word.toLowerCase().trim()) {
- handleNext();
- } else {
- setUserInput(""); // 清空闪记输入框,确保回到闪记时为空
- setPhase('correction');
- setCorrectionInput("");
- // 错误时读一遍正确发音提醒
- const audio = new Audio(api.ttsUrl(currentWord.word));
- audio.play().catch(err => console.error("TTS play failed:", err));
- }
- };
-
- // 抄写正确后回到闪记模式重试
- const retryFlashcard = () => {
- setUserInput("");
- setPhase('flashcard');
- };
-
- if (!currentWord) return <div className="loading">加载中...</div>;
-
- // 阻止复制/剪切/粘贴/右键,防止单词被复制后粘贴作弊
- const blockClipboard = (e) => e.preventDefault();
-
- // 只保留 ASCII 可见字符并转小写,过滤中文/全角/emoji
- // iPad Safari 横屏可能默认中文输入法联想,此过滤保证最终值始终为英文小写
- const sanitize = (s) => s.replace(/[^\x20-\x7E]/g, '').toLowerCase();
-
- // 输入框统一属性:强制英文输入、禁用中文输入法/自动大写/自动补全/粘贴
- const inputProps = {
- type: "text",
- lang: "en",
- inputMode: "latin", // 唤起英文/拉丁字母软键盘
- autoComplete: "off",
- autoCorrect: "off",
- autoCapitalize: "off", // 首字母不大写
- spellCheck: false,
- onPaste: blockClipboard,
- onContextMenu: blockClipboard,
- onCompositionStart: blockClipboard, // 阻止输入法联想
- };
-
- return (
- <div
- className="study-container"
- onCopy={blockClipboard}
- onCut={blockClipboard}
- onPaste={blockClipboard}
- onContextMenu={blockClipboard}
- >
- <div className="study-card">
- {/* 只有在非闪记模式下显示图片 */}
- {phase !== 'flashcard' && (
- <div className="image-section">
- {currentWord.picture_id && (
- <img src={api.pictureUrl(currentWord.picture_id)} alt="mindmap" />
- )}
- </div>
- )}
-
- <div className="content-section">
- <div className="progress-tag">Group {groupId}: {currentIndex + 1} / {words.length}</div>
-
- {phase === 'learning' ? (
- <div className="info-area">
- <h1 className="word-text">{currentWord.word}</h1>
- <div className="meaning-row">
- <span className="chinese">{currentWord.chinese_meaning}</span>
- <span className="pos">({currentWord.part_of_speech})</span>
- </div>
-
- <div className="details-anim">
- {currentWord.word_root && <p className="root"><strong>词根:</strong> {currentWord.word_root}</p>}
- {currentWord.affix && <p className="affix"><strong>组成:</strong> {currentWord.affix}</p>}
- <button
- className="action-btn"
- onClick={() => setPhase('flashcard')}
- disabled={isSpeaking}
- >
- {isSpeaking ? "朗读中,请稍候..." : "进入闪记模式 (Enter)"}
- </button>
- </div>
- </div>
- ) : phase === 'flashcard' ? (
- <div className="flashcard-area">
- <h2 className="flashcard-title">闪记模式:请拼写单词</h2>
- <p className="flashcard-hint">中文含义:{currentWord.chinese_meaning}</p>
- <input
- autoFocus
- value={userInput}
- onChange={(e) => setUserInput(sanitize(e.target.value))}
- onKeyDown={(e) => e.key === 'Enter' && checkFlashcard()}
- placeholder="输入英文单词..."
- {...inputProps}
- />
- <button className="action-btn" onClick={checkFlashcard}>检查 (Enter)</button>
- </div>
- ) : (
- <div className="correction-area">
- <h2 className="error-tip">回答错误!正确答案:{currentWord.word}</h2>
- <p>请抄写一遍以解锁:</p>
- <input
- autoFocus
- className={correctionInput.toLowerCase() === currentWord.word.toLowerCase() ? "correct" : "typing"}
- value={correctionInput}
- onChange={(e) => setCorrectionInput(sanitize(e.target.value))}
- onKeyDown={(e) => {
- if (e.key === 'Enter' && correctionInput.toLowerCase() === currentWord.word.toLowerCase()) {
- retryFlashcard();
- }
- }}
- placeholder="请在此抄写..."
- {...inputProps}
- />
- {correctionInput.toLowerCase() === currentWord.word.toLowerCase() && (
- <button className="action-btn" onClick={retryFlashcard}>
- 重新尝试 (Enter)
- </button>
- )}
- </div>
- )}
- </div>
- </div>
- <button className="back-home" onClick={() => navigate('/')}>返回首页</button>
- </div>
- );
-}
-
-export default StudyMode;
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';
// 轮询 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();
+++ /dev/null
-import React, { useState, useEffect, useCallback, useRef } from 'react';
-import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
-import { api, localTimestamp } from '../api';
-import { useOnlineStatus } from '../hooks/useOnlineStatus';
-import { getWordsByGroup, putProgress, getLearnableGroups } from '../db';
-import { triggerAutoSync } from '../sync';
-import './TestMode.css';
-
-function TestMode() {
- const { groupId } = useParams();
- const navigate = useNavigate();
- const [searchParams] = useSearchParams();
- // 活动类型:test / review1 / review2 / review3(首页点击复习时通过 ?type= 传入;缺省为 test)
- const activityType = searchParams.get('type') || 'test';
- const isOnline = useOnlineStatus();
- // 测试/复习会话开始时间(毫秒时间戳), 用于计算本次学习的实际时长
- const sessionStartRef = useRef(Date.now());
- const [words, setWords] = useState([]);
- const [queue, setQueue] = useState([]); // 当前测试队列
- const [wrongQueue, setWrongQueue] = useState([]); // 错词重考队列
- const [currentIndex, setCurrentIndex] = useState(0);
- const [totalCount, setTotalCount] = useState(0);
- const [phase, setPhase] = useState('ready'); // ready, testing, feedback, copy, retest_info, complete
- const [userInput, setUserInput] = useState("");
- const [copyInput, setCopyInput] = useState("");
- const [isCorrect, setIsCorrect] = useState(false);
- const [isRetesting, setIsRetesting] = useState(false);
-
- const inputRef = useRef(null);
- const copyRef = useRef(null);
- const submittedRef = useRef(false); // 防止完成时重复提交进度
- const currentWord = queue[currentIndex];
-
- // 离线时检查可学习范围 (规格 7: 仅未完成组 + 最后完成组后5组可离线学习)
- useEffect(() => {
- if (isOnline) return;
- getLearnableGroups().then(groups => {
- if (!groups.includes(Number(groupId))) {
- alert('该组不可离线学习(仅未完成组与最后完成组后的5组可离线学习)');
- navigate('/');
- }
- });
- }, [isOnline, groupId, navigate]);
-
- // 获取单词数据 (在线走网络, 离线降级读 IndexedDB)
- useEffect(() => {
- api.getWords(groupId)
- .then(res => setWords(res.data))
- .catch(async () => {
- const local = await getWordsByGroup(groupId);
- setWords(local);
- });
- }, [groupId]);
-
- // 开始测试:随机打乱
- const startTest = useCallback(() => {
- const shuffled = [...words].sort(() => Math.random() - 0.5);
- setQueue(shuffled);
- setWrongQueue([]);
- setCurrentIndex(0);
- setTotalCount(shuffled.length);
- setPhase('testing');
- setUserInput("");
- setCopyInput("");
- setIsRetesting(false);
- }, [words]);
-
- // 自动聚焦输入框
- useEffect(() => {
- if (phase === 'testing' && inputRef.current) {
- inputRef.current.focus();
- }
- if (phase === 'copy' && copyRef.current) {
- copyRef.current.focus();
- }
- }, [phase, currentIndex]);
-
- // 朗读:通过后端 TTS 代理(有道美式发音)播放一遍
- const speak = useCallback((text) => {
- if (!text) return;
- const audio = new Audio(api.ttsUrl(text));
- audio.play().catch(err => console.error("TTS play failed:", err));
- }, []);
-
- // 提交答案
- const checkAnswer = useCallback(() => {
- if (!currentWord || !userInput.trim()) return;
- const correct = currentWord.word.trim().toLowerCase();
- const answer = userInput.trim().toLowerCase();
- const ok = answer === correct;
-
- setIsCorrect(ok);
- setPhase('feedback');
- speak(currentWord.word);
-
- if (!ok) {
- setWrongQueue(prev => {
- if (!prev.find(w => w.id === currentWord.id)) {
- return [...prev, currentWord];
- }
- return prev;
- });
- }
- }, [currentWord, userInput, speak]);
-
- // 显示抄写区
- const showCopy = useCallback(() => {
- setCopyInput("");
- setPhase('copy');
- }, []);
-
- // 下一个词
- const nextWord = useCallback(() => {
- if (currentIndex + 1 < queue.length) {
- setCurrentIndex(prev => prev + 1);
- setUserInput("");
- setCopyInput("");
- setPhase('testing');
- } else {
- // 本轮结束
- if (wrongQueue.length > 0) {
- // 有错题,进入重考
- setPhase('retest_info');
- } else {
- // 全部通过
- setPhase('complete');
- }
- }
- }, [currentIndex, queue.length, wrongQueue.length]);
-
- // 开始重考
- const startRetest = useCallback(() => {
- const retestWords = [...wrongQueue].sort(() => Math.random() - 0.5);
- setQueue(retestWords);
- setWrongQueue([]);
- setCurrentIndex(0);
- setTotalCount(retestWords.length);
- setUserInput("");
- setCopyInput("");
- setPhase('testing');
- setIsRetesting(true);
- }, [wrongQueue]);
-
- // 完成测试,记录进度(op_type 依据活动类型:test / review1 / review2 / review3)
- const finishTest = useCallback(async () => {
- if (submittedRef.current) return; // 防重入(按住Enter等场景)
- submittedRef.current = true;
- const now = localTimestamp();
- // 本次测试/复习实际时长(秒) = 完成时刻 - 会话开始时刻
- const durationSec = Math.max(0, Math.round((Date.now() - sessionStartRef.current) / 1000));
- // 本地优先: 先写本地 dirty (必定保存, 不丢记录)
- await putProgress(groupId, activityType, now, true, durationSec);
- // 在线则后台自动同步 (成功清 dirty, 失败保留)
- if (isOnline) triggerAutoSync();
- navigate('/');
- }, [groupId, navigate, activityType, isOnline]);
-
- // 键盘事件
- const handleKeyDown = useCallback((e) => {
- if (e.key === 'Enter') {
- if (phase === 'testing') {
- checkAnswer();
- } else if (phase === 'feedback' && isCorrect) {
- nextWord();
- } else if (phase === 'copy' && copyInput.toLowerCase() === currentWord.word.toLowerCase()) {
- nextWord();
- } else if (phase === 'feedback' && !isCorrect) {
- showCopy();
- }
- }
- }, [phase, isCorrect, checkAnswer, nextWord, showCopy, copyInput, currentWord]);
-
- useEffect(() => {
- window.addEventListener('keydown', handleKeyDown);
- return () => window.removeEventListener('keydown', handleKeyDown);
- }, [handleKeyDown]);
-
- // 阻止复制/剪切/粘贴/右键,防止单词被复制后粘贴作弊
- const blockClipboard = (e) => e.preventDefault();
-
- // 只保留 ASCII 可见字符并转小写,过滤中文/全角/emoji,同时压制首字母自动大写
- // 即使中文输入法开启或系统自动大写,输入框也只显示小写英文
- const sanitize = (s) => s.replace(/[^\x20-\x7E]/g, '').toLowerCase();
-
- // 输入框统一属性:强制英文输入、禁用中文输入法/自动大写/自动补全/粘贴
- const inputProps = {
- type: "text",
- lang: "en",
- inputMode: "latin", // 唤起英文/拉丁字母软键盘
- autoComplete: "off",
- autoCorrect: "off",
- autoCapitalize: "off", // 首字母不大写
- spellCheck: false,
- onPaste: blockClipboard,
- onContextMenu: blockClipboard,
- onCompositionStart: blockClipboard, // 阻止输入法联想
- };
-
- // 准备阶段
- if (words.length === 0) return <div className="loading">加载中...</div>;
-
- if (phase === 'ready') {
- return (
- <div
- className="test-container"
- onCopy={blockClipboard}
- onCut={blockClipboard}
- onPaste={blockClipboard}
- onContextMenu={blockClipboard}
- >
- <div className="test-card test-start-card">
- <h1>单元测试 - Group {groupId}</h1>
- <p className="test-info">共 {words.length} 个单词</p>
- <p className="test-desc">根据中文含义拼写英文单词,拼写错误需要抄写纠正</p>
- <button className="action-btn start-btn" onClick={startTest}>开始测试</button>
- <button className="back-home" onClick={() => navigate('/')}>返回首页</button>
- </div>
- </div>
- );
- }
-
- if (phase === 'complete') {
- return (
- <div
- className="test-container"
- onCopy={blockClipboard}
- onCut={blockClipboard}
- onPaste={blockClipboard}
- onContextMenu={blockClipboard}
- >
- <div className="test-card test-complete-card">
- <h1>🎉 测试完成!</h1>
- <p>Group {groupId} 全部通过!</p>
- <button className="action-btn" onClick={finishTest} disabled={submittedRef.current}>完成</button>
- </div>
- </div>
- );
- }
-
- if (phase === 'retest_info') {
- return (
- <div
- className="test-container"
- onCopy={blockClipboard}
- onCut={blockClipboard}
- onPaste={blockClipboard}
- onContextMenu={blockClipboard}
- >
- <div className="test-card test-retest-card">
- <h1>📝 开始重考</h1>
- <p>还有 {wrongQueue.length} 个错题需要重考</p>
- <button className="action-btn" onClick={startRetest}>开始重考</button>
- </div>
- </div>
- );
- }
-
- if (!currentWord) return null;
-
- return (
- <div
- className="test-container"
- onCopy={blockClipboard}
- onCut={blockClipboard}
- onPaste={blockClipboard}
- onContextMenu={blockClipboard}
- >
- <div className="test-card">
- {/* 状态栏 */}
- <div className="test-header">
- <span className="test-status">
- {isRetesting ? '重考' : '测试'} - Group {groupId}
- </span>
- <span className="test-progress">
- {currentIndex + 1} / {totalCount}
- </span>
- </div>
-
- {/* 答题区域 */}
- <div className="test-body">
- {phase === 'testing' && (
- <div className="testing-area">
- <div className="prompt-info">
- <p className="prompt-chinese">{currentWord.chinese_meaning}</p>
- <p className="prompt-pos">({currentWord.part_of_speech})</p>
- </div>
- <div className="input-row">
- <input
- ref={inputRef}
- value={userInput}
- onChange={(e) => setUserInput(sanitize(e.target.value))}
- placeholder="请输入英文单词..."
- className="test-input"
- {...inputProps}
- />
- <button className="action-btn" onClick={checkAnswer}>提交</button>
- </div>
- </div>
- )}
-
- {/* 反馈区域 */}
- {(phase === 'feedback' || phase === 'copy') && (
- <div className="feedback-wrapper">
- <div className="feedback-main">
- <div className={`fb-status ${isCorrect ? 'correct' : 'wrong'}`}>
- {isCorrect ? '✅ 正确!' : `❌ 错误,你的答案: ${userInput}`}
- </div>
-
- <div className="fb-word">{currentWord.word}</div>
- <div className="fb-chinese">中文: {currentWord.chinese_meaning}</div>
- <div className="fb-pos">词性: {currentWord.part_of_speech}</div>
-
- {currentWord.word_root && (
- <div className="fb-root">词根: {currentWord.word_root}</div>
- )}
- {currentWord.affix && (
- <div className="fb-affix">组成: {currentWord.affix}</div>
- )}
- {currentWord.example_sentence && (
- <div className="fb-sentence">例句: {currentWord.example_sentence}</div>
- )}
-
- {!isCorrect && phase === 'feedback' && (
- <button className="action-btn copy-trigger" onClick={showCopy}>
- 抄写纠正 (Enter)
- </button>
- )}
-
- {isCorrect && (
- <button className="action-btn" onClick={nextWord}>
- 下一词 (Enter)
- </button>
- )}
- </div>
-
- {/* 图片 */}
- {currentWord.picture_id && (
- <div className="fb-image">
- <img src={api.pictureUrl(currentWord.picture_id)} alt="mindmap" />
- </div>
- )}
- </div>
- )}
-
- {/* 抄写纠正区域 */}
- {phase === 'copy' && (
- <div className="copy-area">
- <label className="copy-label">抄写: </label>
- <input
- ref={copyRef}
- value={copyInput}
- onChange={(e) => setCopyInput(sanitize(e.target.value))}
- className={`copy-input ${copyInput.toLowerCase() === currentWord.word.toLowerCase() ? 'correct' : ''}`}
- placeholder="请在此抄写正确单词..."
- {...inputProps}
- />
- <span className={`copy-check ${copyInput.toLowerCase() === currentWord.word.toLowerCase() ? 'ok' : ''}`}>
- {copyInput.toLowerCase() === currentWord.word.toLowerCase() ? '✅' : '❌'}
- </span>
- {copyInput.toLowerCase() === currentWord.word.toLowerCase() && (
- <button className="action-btn" onClick={nextWord}>
- 下一词 (Enter)
- </button>
- )}
- </div>
- )}
- </div>
- </div>
- <button className="back-home" onClick={() => navigate('/')}>返回首页</button>
- </div>
- );
-}
-
-export default TestMode;
-// 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;
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' });
return dbPromise;
}
-// 通用: 单 store 事务执行, 返回主请求结果
async function singleReq(store, mode, fn) {
const db = await openDB();
return new Promise((resolve, reject) => {
}
// ---------- words store ----------
-
export async function putWords(wordsArr) {
const db = await openDB();
return new Promise((resolve, reject) => {
}
export async function getWordsByGroup(groupId) {
- // groupid 在 IDB 是数字, URL 参数是字符串, 统一转 Number 查索引
return singleReq('words', 'readonly', s =>
s.index('groupid').getAll(IDBKeyRange.only(Number(groupId)))
);
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;
});
}
-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'));
}
-// 离线优先 + 自动同步 统一入口
-// 数据规则 (布尔并集): (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 };
// ---------- 核心同步 ----------
export async function syncNow() {
- const result = { pushed: 0, pulled: 0, warmed: 0, pushedEvents: [], pulledEvents: [] };
-
- // 阶段1: 上传本地 dirty (严格按 dirty 推送, 非 dirty 视为已确认)
- const dirty = await getDirtyProgress();
- setSyncStatus('syncing', { phase: 'upload', dirty: dirty.length });
- if (dirty.length > 0) {
- const events = dirty.map(p => ({
- group_id: p.group_id,
- op_type: p.op_type,
- completed_at: p.completed_at,
- duration_sec: p.duration_sec || 0,
- }));
- const r = await api.syncPush(events);
- result.pushed = r.data.updated || events.length;
- result.pushedEvents = events;
- // 仅清"已确认上传"的 dirty (云端最终存在该 group/op 即清)
- await Promise.all(dirty.map(p => clearDirty(p.group_id, p.op_type)));
+ const result = { pushed: 0, pulled: 0, warmed: 0 };
+ if (typeof navigator !== 'undefined' && !navigator.onLine) {
+ setSyncStatus('failed', { message: '离线,无法同步' });
+ return result;
}
-
- // 阶段2: 拉云端进度, 布尔并集合并 (只补不覆盖, 不动本地已有)
- setSyncStatus('syncing', { phase: 'merge', dirty: 0 });
- const pullResp = await api.syncPull();
- const serverEvents = pullResp.data.events || [];
- const localAll = await getAllProgress();
- for (const ev of serverEvents) {
- const key = `${ev.group_id}:${ev.op_type}`;
- const local = localAll.find(p => p.key === key);
- if (!local) {
- // 本地缺失: 直接补入(布尔并集)
- await putProgress(ev.group_id, ev.op_type, ev.completed_at, false, ev.duration_sec || 0);
- result.pulled++;
- result.pulledEvents.push(ev);
- } else {
- // 本地已存在: 若云端日期更新(如服务端修正了时区误记),
- // 用云端日期覆盖本地显示时间, 让首页进度与云端一致(LWW, 只升不降, 按日期比较)
- const localDate = (local.completed_at || '').slice(0, 10);
- const serverDate = (ev.completed_at || '').slice(0, 10);
- if (serverDate > localDate) {
- await putProgress(ev.group_id, ev.op_type, ev.completed_at, false, ev.duration_sec || 0);
- result.pulled++;
- result.pulledEvents.push(ev);
- }
+ try {
+ setSyncStatus('syncing', { phase: 'upload', dirty: 0 });
+
+ // 阶段1: 上传本地未同步事件
+ const dirtyEvents = await getUnsyncedEvents();
+ if (dirtyEvents.length > 0) {
+ const payload = dirtyEvents.map(e => ({
+ event_id: e.event_id,
+ plan_id: e.plan_id,
+ word_id: e.word_id,
+ group_id: e.group_id,
+ stage: e.stage,
+ question_type: e.question_type,
+ result: e.result,
+ attempt_no: e.attempt_no || 1,
+ duration_ms: e.duration_ms || 0,
+ created_at: e.created_at,
+ }));
+ const r = await api.eventsBatch(dirtyEvents[0].session_id, payload);
+ result.pushed = r.data.inserted || 0;
+ await markEventsSynced(dirtyEvents.map(e => e.event_id));
}
- }
- // 阶段2b: 服务端已删除(且本地已确认/非 dirty)的进度, 本地同步删除。
- // 仅在成功拉取到非空集合时才执行, 避免服务端异常返回空时误删本地进度;
- // 仅删非 dirty 记录(本地未推送完成的进度绝不删, 保护离线工作)。
- if (serverEvents.length > 0) {
- const serverKeys = new Set(serverEvents.map(e => `${e.group_id}:${e.op_type}`));
- for (const p of localAll) {
- if (!p.dirty && !serverKeys.has(p.key)) {
- await deleteProgress(p.group_id, p.op_type);
- }
+ // 阶段2: 上传未同步会话(主动时长)
+ const dirtySessions = await getUnsyncedSessions();
+ for (const s of dirtySessions) {
+ await api.upsertSession({
+ session_id: s.session_id,
+ plan_id: s.plan_id,
+ log_date: s.log_date,
+ started_at: s.started_at,
+ ended_at: s.ended_at,
+ active_sec: s.active_sec,
+ new_sec: s.new_sec,
+ review_sec: s.review_sec,
+ quiz_sec: s.quiz_sec,
+ completed: s.completed,
+ });
+ result.pushed += 1;
+ }
+ if (dirtySessions.length > 0) {
+ await markSessionsSynced(dirtySessions.map(s => s.session_id));
}
- }
- // 阶段3: 首次 warmup 词库 (仅 IDB 空时, 避免每次重拉 1852 词)
- if ((await getWordsCount()) === 0) {
- const allWords = await api.getAllWords();
- await putWords(allWords.data);
- result.warmed = allWords.data.length;
- }
+ setSyncStatus('syncing', { phase: 'merge', dirty: 0 });
- // 阶段4: 更新 lastSyncAt + 重算可学习范围
- await setMeta('lastSyncAt', new Date().toISOString());
- await recomputeLearnableGroups();
+ // 阶段3: 首次 warmup 词库 (仅 IDB 空时)
+ if ((await getWordsCount()) === 0) {
+ const allWords = await api.wordsV2All();
+ await putWords(allWords.data);
+ result.warmed = allWords.data.length;
+ }
- // 阶段5: 后台预取离线资源(思维导图+语音), 不阻塞同步完成
- prefetchLearnableAssets().catch(() => {});
+ // 阶段4: 拉取 word_progress 快照(离线展示"已学")
+ const wp = await api.progressWords();
+ if (wp.data && wp.data.length) {
+ for (const p of wp.data) await putWordProgress(p);
+ result.pulled = wp.data.length;
+ }
- setSyncStatus('synced', { pushed: result.pushed, pulled: result.pulled });
+ // 阶段5: 拉取今天计划(若存在)写入本地,离线可继续
+ const today = localTimestamp().slice(0, 10);
+ try {
+ const plan = await api.dailyPlan(today);
+ if (plan.data) await putDailyPlan({ ...plan.data, plan_date: today });
+ result.pulledPlan = !!plan.data;
+ } catch (e) {
+ // 404: 今天计划尚未生成,忽略
+ }
+
+ await setMeta('lastSyncAt', new Date().toISOString());
+ setSyncStatus('synced', { pushed: result.pushed, pulled: result.pulled });
+ } catch (e) {
+ setSyncStatus('failed', { message: (e && e.message) || String(e) });
+ }
return result;
}
let autoSyncRunning = false;
-// 幂等自动同步: 并发调用只会跑一次; 失败置 failed (本地进度仍保留)
export async function triggerAutoSync() {
if (autoSyncRunning) return;
autoSyncRunning = true;
try {
await syncNow();
- } catch (e) {
- setSyncStatus('failed', { message: (e && e.message) || String(e) });
} finally {
autoSyncRunning = false;
}
}
-// 重算可学习范围: 未完全完成的组 + 最后完成组之后的 5 组
-export async function recomputeLearnableGroups() {
- const allWords = await getAllWordsFromIDB();
- if (!allWords || allWords.length === 0) {
- await setLearnableGroups([]);
- return;
- }
- const allGroups = Array.from(new Set(allWords.map(w => Number(w.groupid)))).sort((a, b) => a - b);
- const progress = await getAllProgress();
- const completedByGroup = {};
- progress.forEach(p => {
- const g = Number(p.group_id);
- if (!completedByGroup[g]) completedByGroup[g] = new Set();
- completedByGroup[g].add(p.op_type);
- });
- const isComplete = (g) => {
- const ops = completedByGroup[g];
- return !!ops && OPS.every(op => ops.has(op));
- };
- const learnable = new Set();
- let maxComplete = -1;
- allGroups.forEach(g => {
- if (isComplete(g)) {
- if (g > maxComplete) maxComplete = g;
- } else {
- learnable.add(g);
- }
- });
- // 最后完成组后的 5 组
- if (maxComplete >= 0) {
- const idx = allGroups.indexOf(maxComplete);
- for (let i = 1; i <= 5; i++) {
- if (idx + i < allGroups.length) learnable.add(allGroups[idx + i]);
- }
- }
- await setLearnableGroups(Array.from(learnable).sort((a, b) => a - b));
-}
-
-// ---------- 预取离线资源(思维导图 + 语音)----------
-// 范围: 未完成组(有记录但未集齐5项) ∪ 未开始组中最前面10组
-// 仅在线执行, 后台非阻塞; 去重直接查 SW 缓存 (caches.match), 不依赖 meta 标记,
-// 避免"SW 未接管时预取未真正落盘却被误标为已完成"导致后续跳过。
-const PREFETCH_CONCURRENCY = 6;
-const PREFETCH_NOT_STARTED_AHEAD = 10;
-const PREFETCH_OPS = ['study', 'test', 'review1', 'review2', 'review3'];
-
-// mode:'no-cors' 使请求键与 <img>/<Audio> 完全一致, 确保 SW cache-first 命中
-function prefetchUrl(url) {
- return fetch(url, { mode: 'no-cors' }).catch(() => {});
-}
-
-function computePrefetchGroups(allWords, progress) {
- const allGroups = Array.from(new Set(allWords.map(w => Number(w.groupid)))).sort((a, b) => a - b);
- const opsByGroup = {};
- progress.forEach(p => {
- const g = Number(p.group_id);
- if (!opsByGroup[g]) opsByGroup[g] = new Set();
- opsByGroup[g].add(p.op_type);
- });
- const isComplete = (g) => !!opsByGroup[g] && PREFETCH_OPS.every(op => opsByGroup[g].has(op));
- const incomplete = allGroups.filter(g => opsByGroup[g] && !isComplete(g)); // 已开始但未完成
- const notStarted = allGroups.filter(g => !opsByGroup[g]); // 完全无记录
- const firstNotStarted = notStarted.slice(0, PREFETCH_NOT_STARTED_AHEAD);
- return Array.from(new Set([...incomplete, ...firstNotStarted])).sort((a, b) => a - b);
-}
-
-export async function prefetchLearnableAssets() {
- if (typeof navigator === 'undefined' || !navigator.onLine) return;
- if (!('caches' in window)) return;
- const allWords = await getAllWordsFromIDB();
- if (!allWords || allWords.length === 0) return;
- const progress = await getAllProgress();
- const target = computePrefetchGroups(allWords, progress);
- if (target.length === 0) return;
-
- const byGroup = {};
- allWords.forEach(w => {
- const g = Number(w.groupid);
- if (!byGroup[g]) byGroup[g] = [];
- byGroup[g].push(w);
- });
-
- // 收集需预取的 URL: 每组首词思维导图 + 每个词语音
- const urls = [];
- for (const g of target) {
- const words = byGroup[g] || [];
- if (words.length === 0) continue;
- const picId = words[0].picture_id;
- if (picId) urls.push(api.pictureUrl(picId));
- for (const w of words) {
- if (w.word) urls.push(api.ttsUrl(w.word));
- }
- }
-
- // 准确去重: 只预取 SW 缓存里尚不存在的 URL (避免 meta 误标导致永久跳过)
- const todo = [];
- for (const u of urls) {
- const hit = await caches.match(u).catch(() => undefined);
- if (!hit) todo.push(u);
- }
- if (todo.length === 0) return;
-
- // 分片并发, 限制同时请求数, 单条失败忽略
- for (let i = 0; i < todo.length; i += PREFETCH_CONCURRENCY) {
- const batch = todo.slice(i, i + PREFETCH_CONCURRENCY).map(u => prefetchUrl(u));
- await Promise.allSettled(batch);
- }
-}
-
-const OP_LABEL = { study: '学习', test: '测试', review1: '复习I', review2: '复习II', review3: '复习III' };
-
-// 格式化同步结果为可读提示 (保留, 用于失败时/手动诊断)
export function formatSyncResult(r) {
- const fmt = (e) => `${(e.completed_at || '').slice(0, 10)} Group${e.group_id} ${OP_LABEL[e.op_type] || e.op_type}`;
const lines = [];
- if (r.pushed > 0) {
- lines.push(`推送 ${r.pushed} 条进度:`);
- r.pushedEvents.forEach(e => lines.push(' ' + fmt(e)));
- }
- if (r.pulled > 0) {
- lines.push(`拉取 ${r.pulled} 条进度:`);
- r.pulledEvents.forEach(e => lines.push(' ' + fmt(e)));
- }
- if (r.pushed === 0 && r.pulled === 0) {
- lines.push('无新进度,已是最新');
- }
+ if (r.pushed > 0) lines.push(`上传 ${r.pushed} 条`);
+ if (r.pulled > 0) lines.push(`拉取 ${r.pulled} 条进度`);
+ if (r.warmed > 0) lines.push(`初始化词库 ${r.warmed} 词`);
+ if (r.pushed === 0 && r.pulled === 0 && r.warmed === 0) lines.push('已是最新');
return '同步完成:\n' + lines.join('\n');
}