- 后端移除 Daily Batch 模型,复用 V1 的 (group,op) 进度模型;新增 need_recite 列与独立 v2_study_logs 表(与 V1 study_logs 隔离)
- 前端移植 V1 结构:背诵清单/学习模式不变;品牌隔离 DB_NAME=words-pwa-v2r、token=words_auth_token_v2、Router basename=/v2、SW 与 manifest 指向 /v2
- TestMode 改造:拼写词+需背诵识别词(每组<=8)默写;其余识别词选词性+自测中文比对;末尾最多 5 道句子填空选择题
- 词性/句子选错则换掉错误混淆项并重排,直到全对;去掉 15 分钟限制;全部答对才记录完成(学习日历不变)
- V1 不受影响
example_sentence: Optional[str] = None
picture_id: Optional[int] = None
groupid: Optional[int] = None
+ unit_order: Optional[int] = None
+ memory_type: Optional[str] = None
+ derived_from: Optional[str] = None
+ pos_quiz_answer: Optional[str] = None
+ exam_priority: Optional[str] = None
+ need_recite: Optional[int] = None
class Config:
from_attributes = True
cursor = conn.cursor()
query = """
SELECT group_id, op_type, MAX(COALESCE(log_date, created_at)) as last_date
- FROM study_logs
+ FROM v2_study_logs
WHERE status = 'completed'
GROUP BY group_id, op_type
"""
affix,
example_sentence,
picture_id,
- groupid
+ groupid,
+ unit_order,
+ memory_type,
+ derived_from,
+ pos_quiz_answer,
+ exam_priority,
+ need_recite
FROM words
ORDER BY CAST(groupid AS INTEGER) ASC, ID ASC
""")
affix,
example_sentence,
picture_id,
- groupid
+ groupid,
+ unit_order,
+ memory_type,
+ derived_from,
+ pos_quiz_answer,
+ exam_priority,
+ need_recite
FROM words
WHERE CAST(groupid AS INTEGER) = ?
ORDER BY ID ASC
conn = sqlite3.connect(DATABASE_PATH)
cursor = conn.cursor()
cursor.execute("""
- CREATE TABLE IF NOT EXISTS study_logs (
+ CREATE TABLE IF NOT EXISTS v2_study_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
group_id TEXT,
op_type TEXT,
today_prefix = now[:10]
# 去重: 当天已有同 (group_id, op_type) 的完成记录则跳过,防止前端重复提交产生脏数据
cursor.execute(
- "SELECT id FROM study_logs WHERE group_id=? AND op_type=? AND status='completed' AND COALESCE(log_date, substr(created_at,1,10))=?",
+ "SELECT id FROM v2_study_logs WHERE group_id=? AND op_type=? AND status='completed' AND COALESCE(log_date, substr(created_at,1,10))=?",
(data.group_id, data.op_type, today_prefix)
)
if cursor.fetchone():
conn.close()
return {"status": "skipped", "date": now, "reason": "duplicate same-day record"}
cursor.execute(
- "INSERT INTO study_logs (group_id, op_type, status, created_at, log_date, duration_sec) VALUES (?, ?, ?, ?, ?, ?)",
+ "INSERT INTO v2_study_logs (group_id, op_type, status, created_at, log_date, duration_sec) VALUES (?, ?, ?, ?, ?, ?)",
(data.group_id, data.op_type, 'completed', now, today_prefix, data.duration_sec or 0)
)
conn.commit()
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
-@app.get("/api/calendar/legacy")
+@app.get("/api/calendar")
async def get_calendar():
- # 按日期聚合 study_logs,供学习日历使用;同时计算每日"实际学习时长"(分钟)
+ # 按日期聚合 v2_study_logs,供学习日历使用;同时计算每日"实际学习时长"(分钟)
# 实际学习时长 = 当天各 (group,op) 完成时长(duration_sec)之和, 不含间隔, 即把各次学习时段相加
try:
conn = sqlite3.connect(DATABASE_PATH)
cursor = conn.cursor()
cursor.execute("""
SELECT COALESCE(log_date, substr(created_at,1,10)) as day, op_type, group_id
- FROM study_logs
+ FROM v2_study_logs
WHERE status='completed'
ORDER BY day, op_type, CAST(group_id AS INTEGER)
""")
# 每日实际学习时长(秒) = 各 (group,op) 完成时长之和
cursor.execute("""
SELECT COALESCE(log_date, substr(created_at,1,10)) as day, COALESCE(SUM(duration_sec), 0) as total_sec
- FROM study_logs
+ FROM v2_study_logs
WHERE status='completed'
GROUP BY day
""")
bad += 1
continue
cursor.execute(
- "SELECT 1 FROM study_logs WHERE group_id=? AND op_type=? AND status='completed' LIMIT 1",
+ "SELECT 1 FROM v2_study_logs WHERE group_id=? AND op_type=? AND status='completed' LIMIT 1",
(ev.group_id, op))
if cursor.fetchone():
skipped += 1
log_date = completed_at[:10]
duration_sec = ev.duration_sec or 0
cursor.execute(
- "INSERT OR IGNORE INTO study_logs (group_id, op_type, status, created_at, log_date, duration_sec) "
+ "INSERT OR IGNORE INTO v2_study_logs (group_id, op_type, status, created_at, log_date, duration_sec) "
"VALUES (?, ?, 'completed', ?, ?, ?)",
(ev.group_id, op, completed_at, log_date, duration_sec))
updated += 1
cursor = conn.cursor()
cursor.execute(
"SELECT group_id, op_type, MAX(COALESCE(log_date, created_at)), MAX(duration_sec) "
- "FROM study_logs WHERE status='completed' GROUP BY group_id, op_type"
+ "FROM v2_study_logs WHERE status='completed' GROUP BY group_id, op_type"
)
rows = cursor.fetchall()
conn.close()
raise HTTPException(status_code=401, detail="用户名或密码错误")
-# =====================================================================
-# V2 后端:动态 Daily Batch + word_progress + 事件日志 + 综合题库
-# 仅作用于 bcd_v2.db(独立库),不与 V1 端点冲突。
-# =====================================================================
-import uuid
-import json
-import random
-import re
-from datetime import timedelta
-
-V2_NOTE = "AI生成/待校对"
-QUIZ_PER_DAY = 5
-MAX_NEW_PER_DAY = 12
-MAX_SPELL_PER_DAY = 6
-BUDGET_SEC = 15 * 60
-QUIZ_FIXED_SEC = 5 * 40
-# 粗略 cost(秒):用于预算控制,不展示给学生
-COST = {
- "spell_new": 60, "recognize_new": 21,
- "spell_review": 39, "recognize_review": 12,
-}
-
-# ---------- V2 时间/连接辅助 ----------
-def _china_dt():
- # 用 UTC+8 计算"今天",与前端 localTimestamp() 一致,规避服务器时区差异
- return datetime.datetime.utcnow() + timedelta(hours=8)
-
-def china_date():
- return _china_dt().strftime("%Y-%m-%d")
-
-def china_now_str():
- return _china_dt().strftime("%Y-%m-%d %H:%M:%S")
-
-def _add_days(date_str, n):
- d = datetime.datetime.strptime(date_str[:10], "%Y-%m-%d")
- return (d + timedelta(days=n)).strftime("%Y-%m-%d")
-
-def get_conn():
+def ensure_v2_schema():
+ """启动时确保 V2 表与新列存在(幂等)。
+ - words 增加 need_recite 列并填充:每组取与拼写词同词根的识别词(最易混淆),封顶 8 个,标记需背诵。
+ - 新建独立的 v2_study_logs 表,存放 V2 自己的 (group, op) 完成记录,与 V1 导入的 study_logs 隔离。
+ """
conn = sqlite3.connect(DATABASE_PATH)
conn.row_factory = sqlite3.Row
- return conn
-
-V2_CREATE_SQL = """
-CREATE TABLE IF NOT EXISTS word_progress (
- word_id INTEGER PRIMARY KEY,
- first_learned_at DATETIME,
- last_seen_at DATETIME,
- next_review_at DATETIME,
- memory_type TEXT,
- mastery_level INTEGER DEFAULT 0,
- spell_correct INTEGER DEFAULT 0,
- spell_wrong INTEGER DEFAULT 0,
- recognize_correct INTEGER DEFAULT 0,
- recognize_wrong INTEGER DEFAULT 0,
- quiz_correct INTEGER DEFAULT 0,
- quiz_wrong INTEGER DEFAULT 0,
- updated_at DATETIME
-);
-CREATE TABLE IF NOT EXISTS daily_plans (
- plan_id TEXT PRIMARY KEY,
- plan_date DATE UNIQUE NOT NULL,
- planned_new INTEGER DEFAULT 0,
- planned_spell INTEGER DEFAULT 0,
- planned_recognize INTEGER DEFAULT 0,
- planned_review INTEGER DEFAULT 0,
- planned_quiz INTEGER DEFAULT 5,
- estimated_sec INTEGER DEFAULT 0,
- status TEXT DEFAULT 'planned',
- created_at DATETIME
-);
-CREATE TABLE IF NOT EXISTS daily_plan_items (
- plan_id TEXT NOT NULL,
- item_type TEXT NOT NULL,
- word_id INTEGER,
- question_id TEXT,
- order_no INTEGER,
- payload TEXT,
- PRIMARY KEY (plan_id, order_no)
-);
-CREATE TABLE IF NOT EXISTS learning_events (
- event_id TEXT PRIMARY KEY,
- session_id TEXT NOT NULL,
- plan_id TEXT,
- word_id INTEGER,
- group_id INTEGER,
- stage TEXT NOT NULL,
- question_type TEXT NOT NULL,
- result TEXT NOT NULL,
- attempt_no INTEGER DEFAULT 1,
- duration_ms INTEGER DEFAULT 0,
- created_at DATETIME,
- log_date DATE
-);
-CREATE TABLE IF NOT EXISTS study_sessions (
- session_id TEXT PRIMARY KEY,
- plan_id TEXT,
- log_date DATE NOT NULL,
- started_at DATETIME,
- ended_at DATETIME,
- active_sec INTEGER DEFAULT 0,
- new_sec INTEGER DEFAULT 0,
- review_sec INTEGER DEFAULT 0,
- quiz_sec INTEGER DEFAULT 0,
- completed INTEGER DEFAULT 0,
- updated_at DATETIME
-);
-"""
-
-def ensure_v2_schema():
- """启动时确保 V2 表与新列存在(幂等)。"""
- conn = get_conn()
try:
cols = {r[1] for r in conn.execute("PRAGMA table_info(words)").fetchall()}
alters = [
("derived_from", "ALTER TABLE words ADD COLUMN derived_from TEXT"),
("pos_quiz_answer", "ALTER TABLE words ADD COLUMN pos_quiz_answer TEXT"),
("exam_priority", "ALTER TABLE words ADD COLUMN exam_priority TEXT"),
+ ("need_recite", "ALTER TABLE words ADD COLUMN need_recite INTEGER DEFAULT 0"),
]
for col, sql in alters:
if col not in cols:
conn.execute(sql)
- conn.executescript(V2_CREATE_SQL)
+
+ # 填充 need_recite:同词根识别词优先,每组封顶 8,不足补其余识别词
+ all_words = conn.execute(
+ "SELECT ID, groupid, word_root, memory_type FROM words").fetchall()
+ spell_roots = {(int(w["groupid"]), (w["word_root"] or "").strip())
+ for w in all_words
+ if w["memory_type"] == "spell" and (w["word_root"] or "").strip()}
+ by_group = {}
+ for w in all_words:
+ if w["memory_type"] != "recognize":
+ continue
+ g = int(w["groupid"]); wid = w["ID"]
+ root = (w["word_root"] or "").strip()
+ bucket = "root" if (g, root) in spell_roots else "other"
+ by_group.setdefault(g, {"root": [], "other": []})["root" if bucket == "root" else "other"].append(wid)
+ chosen = []
+ for g in sorted(by_group):
+ sel = (by_group[g]["root"] + by_group[g]["other"])[:8]
+ chosen.extend(sel)
+ conn.execute("UPDATE words SET need_recite=0")
+ if chosen:
+ ph = ",".join("?" * len(chosen))
+ conn.execute("UPDATE words SET need_recite=1 WHERE ID IN (%s)" % ph, chosen)
+
+ # V2 独立的进度表(与 V1 study_logs 隔离)
+ conn.execute("""
+ CREATE TABLE IF NOT EXISTS v2_study_logs (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ group_id TEXT,
+ op_type TEXT,
+ status TEXT,
+ duration_sec INTEGER DEFAULT 0,
+ log_date DATE,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ UNIQUE(group_id, op_type, log_date)
+ )
+ """)
conn.commit()
finally:
conn.close()
async def _v2_startup():
ensure_v2_schema()
-# ---------- 综合题库生成 ----------
-def _distractors(conn, answer_word, n=3):
- grp = conn.execute(
- "SELECT * FROM words WHERE CAST(groupid AS INTEGER)=? AND ID!=?",
- (answer_word["groupid"], answer_word["ID"])).fetchall()
- cands = [dict(w) for w in grp if w["word"]]
- if len(cands) < n:
- extra = conn.execute(
- "SELECT * FROM words WHERE ID!=? ORDER BY RANDOM() LIMIT ?",
- (answer_word["ID"], n - len(cands))).fetchall()
- cands += [dict(w) for w in extra]
- return cands[:n]
-
-def _make_mcq(conn, answer_word, distract_words, prompt, qtype, related_ids):
- opts = [answer_word["word"]]
- for d in distract_words[:3]:
- if d["word"] not in opts:
- opts.append(d["word"])
- while len(opts) < 4:
- extra = conn.execute(
- "SELECT word FROM words WHERE word NOT IN ({}) ORDER BY RANDOM() LIMIT 1".format(
- ",".join("?" * len(opts))), opts).fetchone()
- if extra and extra[0] not in opts:
- opts.append(extra[0])
- else:
- break
- random.shuffle(opts)
- return {
- "type": qtype,
- "prompt": prompt,
- "options": opts,
- "answer": answer_word["word"],
- "answer_index": opts.index(answer_word["word"]),
- "related_word_ids": related_ids,
- "note": V2_NOTE,
- }
-
-def _try_word_form(conn, today_words):
- for base in today_words:
- if base["memory_type"] != "spell":
- continue
- root = (base["word_root"] or "").strip()
- if not root:
- continue
- sibs = conn.execute(
- "SELECT * FROM words WHERE CAST(groupid AS INTEGER)=? AND word_root=? AND memory_type='recognize' AND ID!=?",
- (base["groupid"], root, base["ID"])).fetchall()
- sibs = [dict(s) for s in sibs if (s["word_root"] or "").strip()]
- if sibs:
- ans = random.choice(sibs)
- hint = ans["pos_quiz_answer"] or ans["part_of_speech"] or "派生形式"
- prompt = f"词形变化:「{base['word']}」的{hint}形式是?"
- return _make_mcq(conn, ans, _distractors(conn, ans), prompt, "word_form", [base["ID"], ans["ID"]])
- return None
-
-def _try_pos(conn, today_words):
- for w in today_words:
- if w["memory_type"] == "recognize" and w["pos_quiz_answer"]:
- prompt = f"词性判断:「{w['word']}」的主要词性是?"
- correct = w["pos_quiz_answer"]
- pool = ["n.", "v.", "adj.", "adv.", "prep.", "conj."]
- opts = [correct] + random.sample([p for p in pool if p != correct], 3)
- random.shuffle(opts)
- return {"type": "pos", "prompt": prompt, "options": opts,
- "answer": correct, "answer_index": opts.index(correct),
- "related_word_ids": [w["ID"]], "note": V2_NOTE}
- return None
-
-def _try_confusable(conn, today_words):
- for w in today_words:
- if not w["word"]:
- continue
- pre = w["word"][:3].lower()
- sim = conn.execute(
- "SELECT * FROM words WHERE CAST(groupid AS INTEGER)=? AND ID!=? AND LOWER(word) LIKE ?",
- (w["groupid"], w["ID"], pre + "%")).fetchall()
- sim = [dict(s) for s in sim if s["word"] and s["ID"] != w["ID"]]
- if sim:
- opts = [w["word"]] + [s["word"] for s in sim[:2]]
- for d in _distractors(conn, w):
- if d["word"] not in opts:
- opts.append(d["word"])
- opts = opts[:4]
- while len(opts) < 4:
- opts.append("—")
- random.shuffle(opts)
- prompt = f"易混词:意思是「{w['chinese_meaning']}」的单词是?"
- return {"type": "confusable", "prompt": prompt, "options": opts,
- "answer": w["word"], "answer_index": opts.index(w["word"]),
- "related_word_ids": [w["ID"]], "note": V2_NOTE}
- return None
-
-def _try_context(conn, words):
- for w in words:
- ex = w["example_sentence"]
- if not ex or not w["word"]:
- continue
- if w["word"].lower() not in ex.lower():
- continue
- pattern = re.compile(re.escape(w["word"]), re.IGNORECASE)
- blanked = pattern.sub("_____", ex, count=1)
- if blanked == ex:
- continue
- opts = [w["word"]] + [d["word"] for d in _distractors(conn, w)[:3]]
- random.shuffle(opts)
- prompt = f"语境填空:{blanked}"
- return {"type": "context", "prompt": prompt, "options": opts,
- "answer": w["word"], "answer_index": opts.index(w["word"]),
- "related_word_ids": [w["ID"]], "note": V2_NOTE}
- return None
-
-def _fallback_mcq(conn, word, qtype):
- prompt = f"词义选择:「{word['chinese_meaning']}」对应的英文单词是?"
- return _make_mcq(conn, word, _distractors(conn, word), prompt, qtype, [word["ID"]])
-
-def build_quiz_questions(conn, date_str, today_new_ids, review_ids, words_map):
- today_words = [words_map[i] for i in today_new_ids if i in words_map]
- review_words = [words_map[i] for i in review_ids if i in words_map]
- random.shuffle(today_words)
- random.shuffle(review_words)
- # 兜底词池:已学词 → 任意词
- learned = [dict(r) for r in conn.execute(
- "SELECT w.* FROM words w JOIN word_progress p ON p.word_id=w.ID LIMIT 60").fetchall()]
- random.shuffle(learned)
- any_words = [dict(r) for r in conn.execute("SELECT * FROM words ORDER BY RANDOM() LIMIT 60").fetchall()]
- random.shuffle(any_words)
-
- def pick(preferred, fallback):
- for lst in (preferred, learned, any_words):
- if lst:
- return lst[0]
- return None
-
- questions = []
- builders = [
- ("word_form", lambda: _try_word_form(conn, today_words), today_words),
- ("pos", lambda: _try_pos(conn, today_words), today_words),
- ("confusable", lambda: _try_confusable(conn, today_words), today_words),
- ("context", lambda: _try_context(conn, review_words if review_words else today_words), review_words if review_words else today_words),
- ("context", lambda: _try_context(conn, today_words if today_words else review_words), today_words if today_words else review_words),
- ]
- for qtype, fn, src in builders:
- q = fn()
- if not q:
- w = pick(src, None)
- q = _fallback_mcq(conn, w, qtype) if w else None
- if not q:
- # 最终兜底:任意词
- q = _fallback_mcq(conn, any_words[0], qtype)
- questions.append(q)
- # 冻结 question_id(含日期,天然 14 天内不重复)
- for i, q in enumerate(questions):
- q["question_id"] = f"{date_str}-Q{i + 1}"
- return questions[:QUIZ_PER_DAY]
-
-# ---------- Daily Plan 调度 ----------
-def generate_daily_plan(date_str):
- conn = get_conn()
- try:
- existing = conn.execute("SELECT * FROM daily_plans WHERE plan_date=?", (date_str,)).fetchone()
- if existing:
- return _plan_with_items(conn, date_str)
-
- words = conn.execute(
- "SELECT ID, groupid, memory_type, unit_order FROM words "
- "ORDER BY CAST(groupid AS INTEGER) ASC, COALESCE(unit_order, ID) ASC").fetchall()
- progress = {r["word_id"]: r for r in conn.execute("SELECT * FROM word_progress").fetchall()}
-
- # 到期复习(错词优先)
- due = []
- for w in words:
- p = progress.get(w["ID"])
- if p and p["next_review_at"] and p["next_review_at"] <= date_str:
- wrong = (p["spell_wrong"] or 0) + (p["recognize_wrong"] or 0) + (p["quiz_wrong"] or 0)
- due.append((w, wrong))
- due.sort(key=lambda x: -x[1])
- review_words = [w for w, _ in due]
-
- # 新词(无 first_learned_at)
- new_ids = [w["ID"] for w in words
- if w["ID"] not in progress or not progress[w["ID"]]["first_learned_at"]]
- new_set = set(new_ids)
- new_spell = [w for w in words if w["ID"] in new_set and w["memory_type"] == "spell"]
- new_recognize = [w for w in words if w["ID"] in new_set and w["memory_type"] == "recognize"]
-
- # 预算
- review_sec = 0
- for w in review_words:
- c = COST["spell_review"] if w["memory_type"] == "spell" else COST["recognize_review"]
- p = progress[w["ID"]]
- wrong = (p["spell_wrong"] or 0) + (p["recognize_wrong"] or 0)
- if wrong > 0:
- c = int(c * 1.2)
- review_sec += c
- remaining = BUDGET_SEC - QUIZ_FIXED_SEC - review_sec
- if remaining < 0:
- remaining = 0
-
- chosen_spell, chosen_recognize = [], []
- planned_spell = planned_recognize = 0
- for w in new_spell:
- if remaining <= 0 or planned_spell >= MAX_SPELL_PER_DAY:
- break
- chosen_spell.append(w)
- planned_spell += 1
- remaining -= COST["spell_new"]
- for w in new_recognize:
- if remaining <= 0 or (planned_spell + planned_recognize) >= MAX_NEW_PER_DAY:
- break
- chosen_recognize.append(w)
- planned_recognize += 1
- remaining -= COST["recognize_new"]
-
- planned_new = planned_spell + planned_recognize
- planned_review = len(review_words)
- est_new_sec = planned_spell * COST["spell_new"] + planned_recognize * COST["recognize_new"]
- estimated_sec = QUIZ_FIXED_SEC + review_sec + est_new_sec
-
- plan_id = str(uuid.uuid4())
- now = china_now_str()
- conn.execute(
- "INSERT INTO daily_plans (plan_id, plan_date, planned_new, planned_spell, "
- "planned_recognize, planned_review, planned_quiz, estimated_sec, status, created_at) "
- "VALUES (?,?,?,?,?,?,?,?,?,?)",
- (plan_id, date_str, planned_new, planned_spell, planned_recognize,
- planned_review, QUIZ_PER_DAY, estimated_sec, "planned", now))
-
- order_no = 1
- for w in chosen_spell:
- conn.execute("INSERT INTO daily_plan_items (plan_id, item_type, word_id, order_no) VALUES (?, 'new_spell', ?, ?)",
- (plan_id, w["ID"], order_no)); order_no += 1
- for w in chosen_recognize:
- conn.execute("INSERT INTO daily_plan_items (plan_id, item_type, word_id, order_no) VALUES (?, 'new_recognize', ?, ?)",
- (plan_id, w["ID"], order_no)); order_no += 1
- for w in review_words:
- it = "review_spell" if w["memory_type"] == "spell" else "review_recognize"
- conn.execute("INSERT INTO daily_plan_items (plan_id, item_type, word_id, order_no) VALUES (?, ?, ?, ?)",
- (plan_id, it, w["ID"], order_no)); order_no += 1
-
- words_map = {r["ID"]: dict(r) for r in conn.execute("SELECT * FROM words").fetchall()}
- quiz = build_quiz_questions(conn, date_str,
- [w["ID"] for w in chosen_spell] + [w["ID"] for w in chosen_recognize],
- [w["ID"] for w in review_words], words_map)
- for q in quiz:
- conn.execute(
- "INSERT INTO daily_plan_items (plan_id, item_type, word_id, question_id, order_no, payload) "
- "VALUES (?, 'quiz', NULL, ?, ?, ?)",
- (plan_id, q["question_id"], order_no, json.dumps(q, ensure_ascii=False))); order_no += 1
-
- conn.commit()
- return _plan_with_items(conn, date_str)
- finally:
- conn.close()
-
-def _plan_with_items(conn, date_str):
- plan = conn.execute("SELECT * FROM daily_plans WHERE plan_date=?", (date_str,)).fetchone()
- if not plan:
- return None
- items = conn.execute(
- "SELECT * FROM daily_plan_items WHERE plan_id=? ORDER BY order_no",
- (plan["plan_id"],)).fetchall()
- ids = [it["word_id"] for it in items if it["word_id"]]
- words_map = {}
- if ids:
- place = ",".join("?" * len(ids))
- words_map = {r["ID"]: dict(r) for r in
- conn.execute(f"SELECT * FROM words WHERE ID IN ({place})", ids).fetchall()}
- out_items = []
- for it in items:
- d = {"item_type": it["item_type"], "order_no": it["order_no"],
- "word_id": it["word_id"], "question_id": it["question_id"]}
- if it["word_id"] and it["word_id"] in words_map:
- d["word"] = words_map[it["word_id"]]
- if it["item_type"] == "quiz" and it["payload"]:
- d["question"] = json.loads(it["payload"])
- out_items.append(d)
- result = dict(plan)
- result["items"] = out_items
- result["estimated_min"] = round((result.get("estimated_sec") or 0) / 60)
- return result
-
-# ---------- V2 数据模型 ----------
-class DailyPlanCreate(BaseModel):
- date: Optional[str] = None
-
-class EventItem(BaseModel):
- event_id: str
- plan_id: Optional[str] = None
- word_id: Optional[int] = None
- group_id: Optional[int] = None
- stage: str
- question_type: str
- result: str
- attempt_no: int = 1
- duration_ms: int = 0
- created_at: Optional[str] = None
-
-class EventsBatch(BaseModel):
- session_id: str
- events: List[EventItem]
-
-class SessionUpsert(BaseModel):
- session_id: str
- plan_id: Optional[str] = None
- log_date: Optional[str] = None
- started_at: Optional[str] = None
- ended_at: Optional[str] = None
- active_sec: int = 0
- new_sec: int = 0
- review_sec: int = 0
- quiz_sec: int = 0
- completed: int = 0
-
-# ---------- V2 端点 ----------
-@app.get("/api/v2/home")
-async def v2_home():
- conn = get_conn()
- try:
- wc = conn.execute("SELECT COUNT(*) FROM words").fetchone()[0]
- learned = conn.execute(
- "SELECT COUNT(*) FROM word_progress WHERE first_learned_at IS NOT NULL").fetchone()[0]
- today = china_date()
- plan = conn.execute("SELECT * FROM daily_plans WHERE plan_date=?", (today,)).fetchone()
- plan_info = None
- if plan:
- plan_info = {k: plan[k] for k in
- ["plan_date", "planned_new", "planned_spell", "planned_recognize",
- "planned_review", "planned_quiz", "estimated_sec", "status"]}
- plan_info["estimated_min"] = round((plan_info["estimated_sec"] or 0) / 60)
- return {"words_count": wc, "learned_count": learned, "today": today, "today_plan": plan_info}
- finally:
- conn.close()
-
-@app.get("/api/words/v2/all")
-async def get_words_v2_all():
- try:
- conn = get_conn()
- rows = conn.execute(
- "SELECT ID as id, word, chinese_meaning, part_of_speech, word_root, affix, "
- "example_sentence, picture_id, groupid, unit_order, memory_type, derived_from, "
- "pos_quiz_answer, exam_priority FROM words "
- "ORDER BY CAST(groupid AS INTEGER) ASC, COALESCE(unit_order, ID) ASC").fetchall()
- conn.close()
- return [dict(r) for r in rows]
- except Exception as e:
- raise HTTPException(status_code=500, detail=str(e))
-
-@app.get("/api/words/v2/{group_id}")
-async def get_words_v2_group(group_id: str):
- try:
- conn = get_conn()
- rows = conn.execute(
- "SELECT ID as id, word, chinese_meaning, part_of_speech, word_root, affix, "
- "example_sentence, picture_id, groupid, unit_order, memory_type, derived_from, "
- "pos_quiz_answer, exam_priority FROM words WHERE CAST(groupid AS INTEGER)=? "
- "ORDER BY COALESCE(unit_order, ID) ASC", (group_id,)).fetchall()
- conn.close()
- return [dict(r) for r in rows]
- except Exception as e:
- raise HTTPException(status_code=500, detail=str(e))
-
-@app.get("/api/daily-plan/{date}")
-async def get_daily_plan(date: str):
- conn = get_conn()
- try:
- plan = _plan_with_items(conn, date)
- finally:
- conn.close()
- if not plan:
- raise HTTPException(status_code=404, detail="plan not found")
- return plan
-
-@app.post("/api/daily-plan/create")
-async def create_daily_plan(body: DailyPlanCreate):
- date_str = body.date or china_date()
- plan = generate_daily_plan(date_str)
- return plan
-
-def _apply_event_to_progress(conn, ev):
- wid = ev.word_id
- if not wid:
- return
- stage = ev.stage
- qtype = ev.question_type
- result = ev.result
- now = china_now_str()
- row = conn.execute("SELECT * FROM word_progress WHERE word_id=?", (wid,)).fetchone()
- if not row:
- mt = None
- w = conn.execute("SELECT memory_type FROM words WHERE ID=?", (wid,)).fetchone()
- if w:
- mt = w["memory_type"]
- conn.execute(
- "INSERT INTO word_progress (word_id, first_learned_at, last_seen_at, next_review_at, "
- "memory_type, mastery_level, updated_at) VALUES (?,?,?,?,?,?,?)",
- (wid, now if stage == "new" else None, now, None, mt, 0, now))
- row = conn.execute("SELECT * FROM word_progress WHERE word_id=?", (wid,)).fetchone()
- spell_c = row["spell_correct"] or 0
- spell_w = row["spell_wrong"] or 0
- rec_c = row["recognize_correct"] or 0
- rec_w = row["recognize_wrong"] or 0
- quiz_c = row["quiz_correct"] or 0
- quiz_w = row["quiz_wrong"] or 0
- mastery = row["mastery_level"] or 0
- if stage in ("new", "review"):
- if qtype == "spell":
- if result == "correct":
- spell_c += 1
- mastery = min(mastery + 1, 5)
- else:
- spell_w += 1
- mastery = max(mastery - 1, 0)
- elif qtype in ("pos", "recognize"):
- if result == "correct":
- rec_c += 1
- mastery = min(mastery + 1, 5)
- else:
- rec_w += 1
- mastery = max(mastery - 1, 0)
- elif stage == "quiz":
- if result == "correct":
- quiz_c += 1
- else:
- quiz_w += 1
- wrong = (spell_w + rec_w + quiz_w) > 0
- if wrong:
- nxt = _add_days(china_date(), 1)
- else:
- nxt = _add_days(china_date(), 1 + mastery)
- conn.execute(
- "UPDATE word_progress SET last_seen_at=?, next_review_at=?, mastery_level=?, "
- "spell_correct=?, spell_wrong=?, recognize_correct=?, recognize_wrong=?, "
- "quiz_correct=?, quiz_wrong=?, updated_at=? WHERE word_id=?",
- (now, nxt, mastery, spell_c, spell_w, rec_c, rec_w, quiz_c, quiz_w, now, wid))
-
-@app.post("/api/events/batch")
-async def post_events_batch(body: EventsBatch):
- conn = get_conn()
- try:
- inserted = skipped = 0
- for ev in body.events:
- exists = conn.execute("SELECT 1 FROM learning_events WHERE event_id=?",
- (ev.event_id,)).fetchone()
- if exists:
- skipped += 1
- continue
- gid = ev.group_id
- if gid is None and ev.word_id:
- r = conn.execute("SELECT groupid FROM words WHERE ID=?", (ev.word_id,)).fetchone()
- gid = r[0] if r else None
- created = ev.created_at or china_now_str()
- log_date = created[:10]
- conn.execute(
- "INSERT INTO learning_events (event_id, session_id, plan_id, word_id, group_id, "
- "stage, question_type, result, attempt_no, duration_ms, created_at, log_date) "
- "VALUES (?,?,?,?,?,?,?,?,?,?,?,?)",
- (ev.event_id, body.session_id, ev.plan_id, ev.word_id, gid, ev.stage,
- ev.question_type, ev.result, ev.attempt_no, ev.duration_ms, created, log_date))
- inserted += 1
- _apply_event_to_progress(conn, ev)
- conn.commit()
- return {"status": "success", "inserted": inserted, "skipped": skipped,
- "received": len(body.events)}
- except Exception as e:
- raise HTTPException(status_code=500, detail=str(e))
- finally:
- conn.close()
-
-@app.post("/api/sessions/upsert")
-async def upsert_session(body: SessionUpsert):
- conn = get_conn()
- try:
- log_date = body.log_date or china_date()
- now = china_now_str()
- conn.execute(
- "INSERT INTO study_sessions (session_id, plan_id, log_date, started_at, ended_at, "
- "active_sec, new_sec, review_sec, quiz_sec, completed, updated_at) "
- "VALUES (?,?,?,?,?,?,?,?,?,?,?) "
- "ON CONFLICT(session_id) DO UPDATE SET "
- "plan_id=excluded.plan_id, ended_at=excluded.ended_at, "
- "active_sec=MAX(active_sec, excluded.active_sec), "
- "new_sec=MAX(new_sec, excluded.new_sec), "
- "review_sec=MAX(review_sec, excluded.review_sec), "
- "quiz_sec=MAX(quiz_sec, excluded.quiz_sec), "
- "completed=MAX(completed, excluded.completed), updated_at=excluded.updated_at",
- (body.session_id, body.plan_id, log_date, body.started_at, body.ended_at,
- body.active_sec, body.new_sec, body.review_sec, body.quiz_sec, body.completed, now))
- conn.commit()
- return {"status": "success"}
- except Exception as e:
- raise HTTPException(status_code=500, detail=str(e))
- finally:
- conn.close()
-
-@app.get("/api/progress/words")
-async def get_progress_words():
- conn = get_conn()
- try:
- rows = conn.execute("SELECT * FROM word_progress").fetchall()
- return [dict(r) for r in rows]
- finally:
- conn.close()
-
-@app.get("/api/questions/daily/{date}")
-async def get_questions_daily(date: str):
- conn = get_conn()
- try:
- items = conn.execute(
- "SELECT payload FROM daily_plan_items WHERE plan_id=(SELECT plan_id FROM daily_plans WHERE plan_date=?) "
- "AND item_type='quiz' ORDER BY order_no", (date,)).fetchall()
- if not items:
- raise HTTPException(status_code=404, detail="no quiz for date")
- return [json.loads(it["payload"]) for it in items]
- finally:
- conn.close()
-
-@app.get("/api/calendar")
-async def v2_calendar(month: str = None):
- if not month:
- month = china_date()[:7]
- conn = get_conn()
- try:
- def blank():
- return {"duration_sec": 0, "new_words": 0, "spell_words": 0, "recognize_words": 0,
- "review_words": 0, "quiz_total": 0, "quiz_correct": 0, "groups": set(), "legacy": False}
- days = {}
- for s in conn.execute(
- "SELECT log_date, COALESCE(SUM(active_sec),0) act FROM study_sessions "
- "WHERE log_date LIKE ? GROUP BY log_date", (month + "%",)):
- d = s["log_date"]
- days.setdefault(d, blank())
- days[d]["duration_sec"] = s["act"]
- for e in conn.execute(
- "SELECT log_date, stage, question_type, result, group_id FROM learning_events WHERE log_date LIKE ?",
- (month + "%",)):
- d = e["log_date"]
- dd = days.setdefault(d, blank())
- if e["stage"] == "new":
- dd["new_words"] += 1
- if e["question_type"] == "spell":
- dd["spell_words"] += 1
- else:
- dd["recognize_words"] += 1
- elif e["stage"] == "review":
- dd["review_words"] += 1
- elif e["stage"] == "quiz":
- dd["quiz_total"] += 1
- if e["result"] == "correct":
- dd["quiz_correct"] += 1
- if e["group_id"] is not None:
- dd["groups"].add(str(e["group_id"]))
- # legacy study_logs(V1 历史)合并,保持连续记录可见
- for r in conn.execute(
- "SELECT COALESCE(log_date,substr(created_at,1,10)) day, op_type, group_id "
- "FROM study_logs WHERE status='completed' AND (log_date LIKE ? OR substr(created_at,1,7)=?)",
- (month + "%", month)):
- d = r["day"]
- dd = days.setdefault(d, blank())
- dd["legacy"] = True
- if dd["duration_sec"] == 0:
- dur = conn.execute(
- "SELECT COALESCE(SUM(duration_sec),0) FROM study_logs WHERE status='completed' "
- "AND COALESCE(log_date,substr(created_at,1,10))=?", (d,)).fetchone()[0]
- dd["duration_sec"] = dur
- if r["op_type"] == "study" and r["group_id"] is not None:
- dd["groups"].add(str(r["group_id"]))
- out = []
- for d in sorted(days):
- dd = days[d]
- out.append({
- "date": d,
- "duration_min": round(dd["duration_sec"] / 60),
- "new_words": dd["new_words"],
- "spell_words": dd["spell_words"],
- "recognize_words": dd["recognize_words"],
- "review_words": dd["review_words"],
- "quiz_total": dd["quiz_total"],
- "quiz_correct": dd["quiz_correct"],
- "groups_touched": sorted(dd["groups"], key=lambda x: (len(x), x)),
- "legacy": dd["legacy"],
- })
- return out
- finally:
- conn.close()
-
-@app.get("/api/calendar/{date}")
-async def v2_calendar_day(date: str):
- conn = get_conn()
- try:
- sess = conn.execute("SELECT * FROM study_sessions WHERE log_date=?", (date,)).fetchall()
- evs = conn.execute("SELECT * FROM learning_events WHERE log_date=?", (date,)).fetchall()
- active_sec = sum((s["active_sec"] or 0) for s in sess)
- new_words = sum(1 for e in evs if e["stage"] == "new")
- review_words = sum(1 for e in evs if e["stage"] == "review")
- quiz_total = sum(1 for e in evs if e["stage"] == "quiz")
- quiz_correct = sum(1 for e in evs if e["stage"] == "quiz" and e["result"] == "correct")
- spell_words = sum(1 for e in evs if e["stage"] == "new" and e["question_type"] == "spell")
- recognize_words = sum(1 for e in evs if e["stage"] == "new" and e["question_type"] != "spell")
- groups = sorted({str(e["group_id"]) for e in evs if e["group_id"] is not None})
- # 薄弱点:当天答错的词
- weak = {}
- for e in evs:
- if e["result"] != "correct" and e["word_id"]:
- weak.setdefault(e["word_id"], {"word_id": e["word_id"], "wrong_types": set()})
- weak[e["word_id"]]["wrong_types"].add(e["question_type"])
- weak_ids = list(weak.keys())
- weak_words = {}
- if weak_ids:
- place = ",".join("?" * len(weak_ids))
- weak_words = {r["ID"]: r["word"] for r in
- conn.execute(f"SELECT ID, word FROM words WHERE ID IN ({place})", weak_ids).fetchall()}
- weak_list = []
- for wid, info in weak.items():
- weak_list.append({"word_id": wid, "word": weak_words.get(wid),
- "wrong_types": sorted(info["wrong_types"])})
- return {
- "date": date,
- "duration_min": round(active_sec / 60),
- "new_words": new_words,
- "spell_words": spell_words,
- "recognize_words": recognize_words,
- "review_words": review_words,
- "quiz_total": quiz_total,
- "quiz_correct": quiz_correct,
- "groups_touched": groups,
- "weak_points": weak_list,
- }
- finally:
- conn.close()
-
@app.get("/")
async def root():
return {"message": "English Lexicon API (V2) is running"}
"sizes": "512x512"
}
],
- "start_url": "/",
+ "start_url": "/v2/",
"display": "standalone",
"theme_color": "#2563eb",
"background_color": "#ffffff"
// - navigation (HTML) → network-first, 失败回退 cached index.html
// - 静态资源 (JS/CSS/img/font) → cache-first
-const CACHE_VERSION = 'words-pwa-v2';
+const CACHE_VERSION = 'words-pwa-v2r';
self.addEventListener('install', (event) => {
event.waitUntil(
- caches.open(CACHE_VERSION).then((cache) => cache.addAll(['/index.html', '/manifest.json']))
+ caches.open(CACHE_VERSION).then((cache) => cache.addAll(['/v2/index.html', '/v2/manifest.json']))
);
self.skipWaiting();
});
// 图片与 TTS 音频: cache-first (含跨域, 后端已开 CORS)
// 注意 TTS 现用查询参数形式 /api/tts?w=,pathname 为 /api/tts(无尾斜杠),故不加尾斜杠匹配
- if (path.startsWith('/api/picture') || path.startsWith('/api/tts')) {
+ if (path.startsWith('/v2/api/picture') || path.startsWith('/v2/api/tts')) {
event.respondWith(cacheFirst(req));
return;
}
return res;
} catch (e) {
const cached = await cache.match(req);
- return cached || cache.match('/index.html');
+ return cached || cache.match('/v2/index.html');
}
}
import React, { useState } from 'react';
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import Home from './components/Home';
-import DailyBatch from './components/DailyBatch';
+import StudyMode from './components/StudyMode';
+import TestMode from './components/TestMode';
import GroupView from './components/GroupView';
import StudyCalendar from './components/StudyCalendar';
import SyncNotifications from './components/SyncNotifications';
<SyncNotifications />
<Routes>
<Route path="/" element={<Home />} />
- <Route path="/batch" element={<DailyBatch />} />
+ <Route path="/study/:groupId" element={<StudyMode />} />
+ <Route path="/test/:groupId" element={<TestMode />} />
<Route path="/group/:groupId" element={<GroupView />} />
<Route path="/calendar" element={<StudyCalendar />} />
</Routes>
+++ /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 };
-}
(err) => {
if (err.response && err.response.status === 401) {
clearToken();
- window.location.href = '/v2/';
+ window.location.href = '/';
}
return Promise.reject(err);
}
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, getAllWordProgress } from '../db';
+import { getWordsByGroup } from '../db';
import './GroupView.css';
function GroupView() {
const { groupId } = useParams();
const navigate = useNavigate();
const [words, setWords] = useState([]);
- const [learned, setLearned] = useState({});
useEffect(() => {
- const gid = Number(groupId);
- api.wordsV2Group(gid)
+ api.getWords(groupId)
.then(res => setWords(res.data))
- .catch(async () => {
- const local = await getWordsByGroup(gid);
+ .catch(async (err) => {
+ // 离线或后端不可达: 降级读 IndexedDB (规格 9: 所有组均可离线查看)
+ console.error("获取词汇失败, 降级读离线缓存:", err);
+ const local = await getWordsByGroup(groupId);
setWords(local);
});
- getAllWordProgress().then(list => {
- const m = {};
- list.forEach(p => { if (p.first_learned_at) m[p.word_id] = true; });
- setLearned(m);
- }).catch(() => {});
}, [groupId]);
+ // 组内所有单词共享同一张思维导图,取首词的 picture_id(兼容 picture_id==groupid 约定)
const pictureId = words.length > 0 ? words[0].picture_id : null;
- const learnedCount = words.filter(w => learned[w.id]).length;
if (words.length === 0) {
return (
<div className="group-view-container">
- <div className="group-view-card"><p className="loading">加载中...</p></div>
+ <div className="group-view-card">
+ <p className="loading">加载中...</p>
+ </div>
</div>
);
}
<div className="group-view-card">
<div className="group-view-header">
<h1>Group {groupId} · 词汇预览</h1>
- <p className="group-view-count">共 {words.length} 个单词 · 已学 {learnedCount}
- <br /><span className="gv-hint">只读预览,不记录进度</span></p>
+ <p className="group-view-count">共 {words.length} 个单词(只读预览,不记录进度)</p>
</div>
{pictureId && (
<table className="group-word-table">
<thead>
<tr>
- <th>单词</th><th>词性</th><th>中文释义</th>
- <th>记忆类型</th><th>词根</th><th>例句</th>
+ <th>单词</th>
+ <th>词性</th>
+ <th>中文释义</th>
+ <th>词根</th>
+ <th>词缀</th>
+ <th>例句</th>
</tr>
</thead>
<tbody>
{words.map(w => (
- <tr key={w.id} className={learned[w.id] ? 'gv-learned' : ''}>
+ <tr key={w.id}>
<td className="gw-word">{w.word}</td>
<td className="gw-pos">{w.part_of_speech}</td>
<td className="gw-cn">{w.chinese_meaning}</td>
- <td className="gw-mt">
- <span className={'mt-badge ' + (w.memory_type === 'spell' ? 'mt-spell' : 'mt-recognize')}>
- {w.memory_type === 'spell' ? '拼写' : '识别'}
- </span>
- </td>
<td className="gw-root">{w.word_root}</td>
+ <td className="gw-affix">{w.affix}</td>
<td className="gw-ex">{w.example_sentence}</td>
</tr>
))}
</tbody>
</table>
</div>
-
- <div className="gv-nav">
- <button className="toggle-btn" onClick={() => navigate(`/group/${Number(groupId) - 1}`)}>← 上一组</button>
- <button className="toggle-btn" onClick={() => navigate('/')}>首页</button>
- <button className="toggle-btn" onClick={() => navigate(`/group/${Number(groupId) + 1}`)}>下一组 →</button>
- </div>
</div>
<button className="back-home" onClick={() => navigate('/')}>返回首页</button>
</div>
}
.logout-btn:hover {
background: #eef3fb;
- }
-/* ---- V2 Daily Batch 仪表盘 ---- */
-.batch-dashboard { background:#fff; border:1px solid #e2e8f0; border-radius:14px; padding:18px; margin:16px 0; }
-.batch-headline { display:flex; justify-content:space-between; align-items:baseline; }
-.batch-title { font-size:22px; font-weight:700; color:#0f172a; }
-.batch-est { color:#2563eb; font-weight:600; }
-.batch-note { color:#64748b; font-size:13px; margin:4px 0 16px; }
-.batch-cards { display:flex; gap:12px; flex-wrap:wrap; }
-.batch-card { flex:1; min-width:120px; background:#f8fafc; border:1px solid #e2e8f0; border-radius:12px; padding:14px; text-align:center; }
-.batch-card.new-card { border-top:3px solid #2563eb; }
-.batch-card.review-card { border-top:3px solid #5b7c99; }
-.batch-card.quiz-card { border-top:3px solid #8a9ba8; }
-.bc-label { color:#64748b; font-size:13px; }
-.bc-num { font-size:28px; font-weight:800; color:#0f172a; }
-.bc-unit { font-size:14px; font-weight:500; margin-left:2px; }
-.bc-sub { color:#64748b; font-size:12px; margin-top:4px; }
-.start-batch-btn { display:block; width:100%; margin-top:18px; font-size:17px; padding:14px; }
-.weak-list { text-align:left; margin:10px 0; }
-.weak-why { color:#ef4444; font-size:12px; }
+ }
\ No newline at end of file
import React, { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useOnlineStatus } from '../hooks/useOnlineStatus';
-import { isInitialized, getDailyPlanByDate, putDailyPlan, getWordsCount, getAllWordProgress } from '../db';
-import { triggerAutoSync } from '../sync';
-import { api } from '../api';
+import { getAllWordsFromIDB, getAllProgress, isInitialized } from '../db';
+import { triggerAutoSync, subscribeSyncStatus } from '../sync';
import { logout } from '../auth';
import './Home.css';
-function chinaDate() {
- return new Date(Date.now() + 8 * 3600 * 1000).toISOString().slice(0, 10);
-}
-
function Home() {
- const [plan, setPlan] = useState(null);
- const today = chinaDate();
- const [stats, setStats] = useState({ words_count: 0, learned_count: 0 });
- const [needInit, setNeedInit] = useState(false);
- const [loading, setLoading] = useState(true);
- const [creating, setCreating] = useState(false);
+ const [progressData, setProgressData] = useState([]);
+ const [showAll, setShowAll] = useState(false);
+ const [stats, setStats] = useState({ pictures_count: 0, words_count: 0 });
+ const [needInit, setNeedInit] = useState(false); // 首次离线: 需联网初始化
+ const [blocking, setBlocking] = useState(false); // 同步中且本地有 dirty: 禁用学习入口
const navigate = useNavigate();
const isOnline = useOnlineStatus();
+ // 离线降级: 从 IndexedDB 构造与 /api/home/process 同格式的数据 (emoji 字符串)
+ const buildOfflineData = async () => {
+ const allWords = await getAllWordsFromIDB();
+ const progress = await getAllProgress();
+ const completedByGroup = {};
+ progress.forEach(p => {
+ const g = String(p.group_id);
+ if (!completedByGroup[g]) completedByGroup[g] = {};
+ completedByGroup[g][p.op_type] = p.completed_at;
+ });
+ const allGroups = Array.from(new Set(allWords.map(w => String(w.groupid))))
+ .sort((a, b) => Number(a) - Number(b));
+ let firstUnstudied = null;
+ for (const g of allGroups) {
+ if (!completedByGroup[g] || !completedByGroup[g]['study']) { firstUnstudied = g; break; }
+ }
+ const OPS = ['study', 'test', 'review1', 'review2', 'review3'];
+ const PREV = { test: 'study', review1: 'test', review2: 'review1', review3: 'review2' };
+ const progressData = allGroups.map(g => {
+ const p = completedByGroup[g] || {};
+ const row = { group: `Group ${g}` };
+ for (const op of OPS) {
+ if (p[op]) { row[op] = `✔️ ${p[op].slice(0, 10)}`; continue; }
+ if (op === 'study') { row[op] = (g === firstUnstudied) ? '✅请开始学习' : ''; continue; }
+ if (op === 'test') { row[op] = p['study'] ? '✅请开始测试' : ''; continue; }
+ row[op] = p[PREV[op]] ? '✅请开始复习' : '';
+ }
+ return row;
+ });
+ return {
+ stats: { pictures_count: 0, words_count: allWords.length },
+ progressData,
+ };
+ };
+
+ // 从本地 IndexedDB 渲染首页 (永远本地优先, 不读云端进度)
+ const renderLocal = async () => {
+ const d = await buildOfflineData();
+ setProgressData(d.progressData);
+ setStats(d.stats);
+ return d.progressData.length;
+ };
+
useEffect(() => {
(async () => {
const init = await isInitialized();
+ // 首次离线: 不能进入学习, 提示需联网
if (!isOnline && !init) {
setNeedInit(true);
- setLoading(false);
+ setProgressData([]);
return;
}
setNeedInit(false);
-
- // 今天计划:先本地,再尝试服务端
- let local = await getDailyPlanByDate(today);
- if (!local && isOnline) {
- try {
- const r = await api.dailyPlan(today);
- local = { ...r.data, plan_date: today };
- await putDailyPlan(local);
- } catch (e) {
- local = null; // 404: 尚未生成
- }
- }
- setPlan(local);
-
- // 统计
- if (isOnline) {
- try {
- const h = await api.v2Home();
- setStats({ words_count: h.data.words_count, learned_count: h.data.learned_count });
- } catch (e) {}
- } else {
- const wc = await getWordsCount();
- const wp = await getAllWordProgress();
- const learned = wp.filter(p => p.first_learned_at).length;
- setStats({ words_count: wc, learned_count: learned });
- }
-
- setLoading(false);
+ await renderLocal();
+ // 在线: 后台自动同步 (首次在线会 warmup 词库+进度; 已初始化则合并云端)
if (isOnline) triggerAutoSync();
})();
if (!isOnline) return;
+ // 在线时定时自动合并其他设备的进度
const timer = setInterval(() => triggerAutoSync(), 30000);
return () => clearInterval(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [isOnline, today]);
+ }, [isOnline]);
- const generatePlan = async () => {
- if (!isOnline) { alert('请联网后生成今日计划'); return; }
- setCreating(true);
- try {
- const r = await api.createDailyPlan(today);
- const p = { ...r.data, plan_date: today };
- await putDailyPlan(p);
- setPlan(p);
- } catch (e) {
- alert('生成计划失败:' + ((e && e.message) || e));
- } finally {
- setCreating(false);
- }
+ // 订阅同步状态: 同步完成重渲染本地; 同步中且有 dirty 时禁用学习入口
+ useEffect(() => {
+ return subscribeSyncStatus((s) => {
+ if (s.state === 'synced') renderLocal();
+ setBlocking(s.state === 'syncing' && s.detail && s.detail.dirty > 0);
+ });
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ const filteredData = progressData.filter(item => {
+ if (showAll) return true;
+ const isDone = [item.study, item.test, item.review1, item.review2, item.review3]
+ .every(val => val && val.includes('✔️'));
+ return !isDone;
+ });
+
+ const getStatusClass = (text) => {
+ if (!text) return '';
+ if (text.includes('✔️')) return 'status-done';
+ if (text.includes('✅')) return 'status-start';
+ if (text.includes('⚠️')) return 'status-warning';
+ return '';
};
- const startTask = () => {
- if (!plan) { generatePlan(); return; }
- navigate('/batch');
+ const handleCellClick = (item, type) => {
+ const groupId = item.group.replace('Group ', '');
+ if (type === 'group') {
+ navigate(`/group/${groupId}`);
+ return;
+ }
+ // 同步中且本地有 dirty: 禁止开始新的学习/测试/复习 (先同步完成)
+ if (blocking) {
+ alert('同步完成后再开始新的学习');
+ return;
+ }
+ if (type === 'study' && item.study && item.study.includes('请开始学习')) {
+ navigate(`/study/${groupId}`);
+ }
+ if (type === 'test' && item.test && item.test.includes('请开始测试')) {
+ navigate(`/test/${groupId}?type=test`);
+ }
+ if (type === 'review1' && item.review1 && (item.review1.includes('请开始复习') || item.review1.includes('请尽快复习'))) {
+ navigate(`/test/${groupId}?type=review1`);
+ }
+ if (type === 'review2' && item.review2 && (item.review2.includes('请开始复习') || item.review2.includes('请尽快复习'))) {
+ navigate(`/test/${groupId}?type=review2`);
+ }
+ if (type === 'review3' && item.review3 && (item.review3.includes('请开始复习') || item.review3.includes('请尽快复习'))) {
+ navigate(`/test/${groupId}?type=review3`);
+ }
};
- if (loading) {
- return <div className="main-app"><header className="header"><h1>单词学习</h1></header><p className="stats-info">加载中…</p></div>;
- }
-
- const plannedNew = plan ? plan.planned_new : 0;
- const plannedSpell = plan ? plan.planned_spell : 0;
- const plannedRecognize = plan ? plan.planned_recognize : 0;
- const plannedReview = plan ? plan.planned_review : 0;
- const plannedQuiz = plan ? (plan.planned_quiz || 5) : 5;
- const estMin = plan ? plan.estimated_min : 0;
-
return (
<div className="main-app">
<header className="header">
- <h1>今晚任务</h1>
+ <h1>单词学习进度记录</h1>
<div className="header-right">
<p className="stats-info">
- 词库 {stats.words_count} 词 · 已学 {stats.learned_count}
+ 图片总数: {stats.pictures_count} | 单词总数: {stats.words_count}
</p>
<button className="logout-btn" onClick={() => { logout(); window.location.reload(); }}>退出</button>
</div>
</div>
)}
- <div className="batch-dashboard">
- <div className="batch-headline">
- <span className="batch-title">今晚任务</span>
- <span className="batch-est">预计 {estMin || '--'} 分钟</span>
- </div>
- <p className="batch-note">今日计划生成后不再加码;到 15 分钟自动停止追加。</p>
-
- <div className="batch-cards">
- <div className="batch-card new-card">
- <div className="bc-label">新词</div>
- <div className="bc-num">{plannedNew || '--'}<span className="bc-unit">个</span></div>
- <div className="bc-sub">{plannedSpell} 个拼写 / {plannedRecognize} 个识别</div>
- </div>
- <div className="batch-card review-card">
- <div className="bc-label">到期复习</div>
- <div className="bc-num">{plannedReview || 0}<span className="bc-unit">个</span></div>
- <div className="bc-sub">错词优先 · 未完成自动顺延</div>
- </div>
- <div className="batch-card quiz-card">
- <div className="bc-label">综合训练</div>
- <div className="bc-num">{plannedQuiz}<span className="bc-unit">题</span></div>
- <div className="bc-sub">3 道今日词 / 2 道复习词</div>
- </div>
- </div>
+ {blocking && (
+ <div className="sync-block-tip">同步完成后再开始新的学习</div>
+ )}
- {plan ? (
- <button className="action-btn start-batch-btn" onClick={startTask}>开始今日任务</button>
- ) : (
- <button className="action-btn start-batch-btn" onClick={generatePlan} disabled={creating}>
- {creating ? '生成中…' : '生成今日计划'}
- </button>
- )}
+ <div className="action-bar">
+ <button className="toggle-btn" onClick={() => setShowAll(!showAll)}>
+ {showAll ? "➖ 隐藏已完成组" : "➕ 显示已完成的组"}
+ </button>
+ <button className="toggle-btn" onClick={() => navigate('/calendar')}>
+ 📅 学习日历
+ </button>
</div>
- <div className="action-bar">
- <button className="toggle-btn" onClick={() => navigate('/calendar')}>📅 学习日历</button>
- <button className="toggle-btn" onClick={() => navigate('/group/1')}>📚 词库浏览</button>
+ <div className="table-container">
+ <table className="progress-table">
+ <thead>
+ <tr>
+ <th>组别</th>
+ <th>基础学习</th>
+ <th>单元测试</th>
+ <th>复习 I</th>
+ <th>复习 II</th>
+ <th>复习 III</th>
+ </tr>
+ </thead>
+ <tbody>
+ {filteredData.map((item, index) => (
+ <tr key={index}>
+ <td
+ className="group-name"
+ onClick={() => handleCellClick(item, 'group')}
+ style={{ cursor: 'pointer' }}
+ >
+ {item.group}
+ </td>
+ <td
+ className={getStatusClass(item.study)}
+ onClick={() => handleCellClick(item, 'study')}
+ style={{ cursor: item.study.includes('请开始学习') ? 'pointer' : 'default' }}
+ >
+ {item.study}
+ </td>
+ <td
+ className={getStatusClass(item.test)}
+ onClick={() => handleCellClick(item, 'test')}
+ style={{ cursor: item.test && item.test.includes('请开始') ? 'pointer' : 'default' }}
+ >
+ {item.test}
+ </td>
+ <td
+ className={getStatusClass(item.review1)}
+ onClick={() => handleCellClick(item, 'review1')}
+ style={{ cursor: item.review1 && (item.review1.includes('请开始') || item.review1.includes('请尽快')) ? 'pointer' : 'default' }}
+ >
+ {item.review1}
+ </td>
+ <td
+ className={getStatusClass(item.review2)}
+ onClick={() => handleCellClick(item, 'review2')}
+ style={{ cursor: item.review2 && (item.review2.includes('请开始') || item.review2.includes('请尽快')) ? 'pointer' : 'default' }}
+ >
+ {item.review2}
+ </td>
+ <td
+ className={getStatusClass(item.review3)}
+ onClick={() => handleCellClick(item, 'review3')}
+ style={{ cursor: item.review3 && (item.review3.includes('请开始') || item.review3.includes('请尽快')) ? 'pointer' : 'default' }}
+ >
+ {item.review3}
+ </td>
+ </tr>
+ ))}
+ </tbody>
+ </table>
</div>
<footer className="footer">
- <button className="error-correction-btn" onClick={() => navigate('/calendar')}>查看学习记录</button>
+ <button className="error-correction-btn">进入消错模式</button>
</footer>
</div>
);
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.calendarV2()
+ api.getCalendar()
.then(res => {
const m = {};
res.data.forEach(item => {
- m[item.date] = item;
+ m[item.date] = { activities: item.activities, durationMin: item.duration_min || 0 };
});
setDayMap(m);
})
.catch(async (err) => {
- console.error('日历获取失败:', err);
- setDayMap({});
+ // 离线降级: 从 IndexedDB progress 本地按日聚合 (复用后端 calendar 逻辑)
+ console.error("获取日历失败, 降级读离线缓存:", err);
+ const allProgress = await getAllProgress();
+ const tmp = {};
+ const dayDurSec = {}; // day -> 累计学习秒数(离线按本地 duration_sec 求和, 与后端一致)
+ allProgress.forEach(p => {
+ const day = (p.completed_at || '').slice(0, 10);
+ if (!day) return;
+ if (!tmp[day]) tmp[day] = {};
+ if (!tmp[day][p.op_type]) tmp[day][p.op_type] = [];
+ const g = String(p.group_id);
+ if (!tmp[day][p.op_type].includes(g)) tmp[day][p.op_type].push(g);
+ if (!dayDurSec[day]) dayDurSec[day] = 0;
+ dayDurSec[day] += (p.duration_sec || 0);
+ });
+ const m = {};
+ Object.keys(tmp).forEach(day => {
+ const activities = OP_ORDER.filter(op => tmp[day][op]).map(op => ({ op_type: op, groups: tmp[day][op] }));
+ const totalSec = dayDurSec[day] || 0;
+ const durationMin = totalSec > 0 ? Math.round(totalSec / 60) : 0;
+ m[day] = { activities, durationMin };
+ });
+ setDayMap(m);
});
}, []);
const todayStr = `${today.getFullYear()}-${pad(today.getMonth() + 1)}-${pad(today.getDate())}`;
const currentMonth = `${today.getFullYear()}-${pad(today.getMonth() + 1)}`;
- const recordMonths = Array.from(new Set(Object.keys(dayMap).map(d => d.slice(0, 7)))).sort().reverse();
- const renderMonths = [currentMonth, ...recordMonths.filter(m => m !== currentMonth)];
-
- // 月标题统计(V2 口径:学习天数 + 总时长 + 新词数)
- function monthSummary(monthStr) {
- let days = 0, totalMin = 0, newWords = 0;
- Object.keys(dayMap).forEach(d => {
- if (d.slice(0, 7) === monthStr) {
- const it = dayMap[d];
- const hasData = (it.new_words || it.review_words || it.quiz_total || it.duration_min);
- if (it.legacy && !hasData) { days++; totalMin += it.duration_min || 0; return; }
- if (hasData) { days++; totalMin += it.duration_min || 0; newWords += it.new_words || 0; }
- }
- });
- return { days, totalMin, newWords };
- }
+ // 月份渲染顺序: 当前月 + 有记录月份(去除当前月, 倒序)
+ const recordMonths = Object.keys(dayMap).map(d => d.slice(0, 7));
+ const uniqueRecordMonths = Array.from(new Set(recordMonths)).sort().reverse();
+ const renderMonths = [currentMonth, ...uniqueRecordMonths.filter(m => m !== currentMonth)];
function renderMonth(monthStr) {
const [yStr, mStr] = monthStr.split('-');
const y = parseInt(yStr), m = parseInt(mStr);
- const firstDay = new Date(y, m - 1, 1).getDay();
- const offset = (firstDay + 6) % 7; // 周一起始
+ const firstDay = new Date(y, m - 1, 1).getDay(); // 0=Sun
+ const offset = (firstDay + 6) % 7; // 周一起始的空格数
const daysInMonth = new Date(y, m, 0).getDate();
+ // 统计当月学习的 group 数(op_type='study' 的不同 group)
+ const studiedGroups = new Set();
+ Object.keys(dayMap).forEach(dateStr => {
+ if (dateStr.slice(0, 7) === monthStr) {
+ const info = dayMap[dateStr];
+ if (info && info.activities) info.activities.forEach(a => {
+ if (a.op_type === 'study') a.groups.forEach(g => studiedGroups.add(g));
+ });
+ }
+ });
+ const studiedCount = studiedGroups.size;
const cells = [];
for (let i = 0; i < offset; i++) cells.push(null);
for (let d = 1; d <= daysInMonth; d++) cells.push(d);
while (cells.length % 7 !== 0) cells.push(null);
- const sum = monthSummary(monthStr);
return (
<div className="cal-month" key={monthStr}>
<div className="cal-month-title">
- {y}年 {m}月 · <span className="cal-month-count">学习 {sum.days} 天 · {fmtDur(sum.totalMin)} · 新词 {sum.newWords} 个</span>
+ {y}年 {m}月 · <span className="cal-month-count">(学习了{studiedCount}个group)</span>
</div>
<div className="cal-grid">
{WEEKDAYS.map(w => <div className="cal-weekday" key={w}>{w}</div>)}
{cells.map((d, i) => {
if (d === null) return <div className="cal-day empty" key={i} />;
const dateStr = `${monthStr}-${pad(d)}`;
- const info = dayMap[dateStr];
- const dur = info ? info.duration_min : 0;
- const hasData = info && (info.new_words || info.review_words || info.quiz_total || dur);
- const isActive = !!hasData;
+ const dayInfo = dayMap[dateStr];
+ const acts = dayInfo ? dayInfo.activities : undefined;
+ const durationMin = dayInfo ? dayInfo.durationMin : 0;
+ const isActive = !!acts;
const isToday = dateStr === todayStr;
const cls = ['cal-day'];
if (isActive) cls.push('active');
- if (info && info.legacy) cls.push('legacy');
if (isToday) cls.push('today');
return (
- <div className={cls.join(' ')} key={i} onClick={() => info && hasData ? openDetail(dateStr) : null}>
+ <div className={cls.join(' ')} key={i}>
<div className="cal-day-head">
- {dur > 0 && <span className="cal-duration">{dur}分</span>}
+ {durationMin > 0 && (
+ <span className="cal-duration">{durationMin}分钟</span>
+ )}
<div className="cal-day-num">{d}</div>
</div>
- {info && (
- <div className="cal-batch">
- {info.new_words > 0 && <span className="cb new">新{info.new_words}</span>}
- {info.review_words > 0 && <span className="cb rev">复{info.review_words}</span>}
- {info.quiz_total > 0 && <span className="cb q">题{info.quiz_total}</span>}
+ {acts && acts.map((a, j) => (
+ <div className="cal-act" key={j} style={{ color: OP_COLOR[a.op_type] || '#475569' }}>
+ {OP_LABEL[a.op_type] || a.op_type} G{a.groups.join(',')}
</div>
- )}
+ ))}
</div>
);
})}
);
}
- 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 { getUnsyncedEvents, getUnsyncedSessions, getMeta } from '../db';
+import { getDirtyProgress, getMeta } from '../db';
import { triggerAutoSync, subscribeSyncStatus, getSyncStatus } from '../sync';
import './SyncNotifications.css';
// 轮询 dirty / 初始化状态
useEffect(() => {
const check = async () => {
- const e = await getUnsyncedEvents();
- const s = await getUnsyncedSessions();
- setDirtyCount(e.length + s.length);
+ setDirtyCount((await getDirtyProgress()).length);
setInitialized(!!(await getMeta('lastSyncAt')));
};
check();
color: #64748b;
}
+/* ====================== V2 选词性 / 句子填空 ====================== */
+.prompt-word-en {
+ font-size: 42px;
+ font-weight: bold;
+ color: #1e40af;
+ text-align: center;
+}
+
+.prompt-sentence {
+ font-size: 20px;
+ color: #475569;
+ margin-top: 12px;
+ line-height: 1.6;
+ text-align: center;
+}
+
+.prompt-pos-hint {
+ font-size: 20px;
+ color: #64748b;
+ margin-top: 20px;
+ text-align: center;
+}
+
+.option-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(140px, 1fr));
+ gap: 16px;
+ margin-top: 24px;
+ width: 100%;
+ max-width: 560px;
+}
+
+.option-btn {
+ padding: 16px 20px;
+ font-size: 22px;
+ background: #fff;
+ color: #1e293b;
+ border: 2px solid #cbd5e1;
+ border-radius: 12px;
+ cursor: pointer;
+ transition: all 0.15s;
+}
+
+.option-btn:hover {
+ border-color: #e67e22;
+ background: #fff7ed;
+}
+
+.sentence-area {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ padding: 30px 0;
+}
+
+.sentence-prompt {
+ font-size: 20px;
+ color: #64748b;
+ margin-bottom: 16px;
+}
+
+.sentence-text {
+ font-size: 30px;
+ line-height: 1.8;
+ color: #1e293b;
+ text-align: center;
+ margin-bottom: 20px;
+ max-width: 860px;
+}
+
+.sentence-text .blank {
+ color: #e67e22;
+}
+
+.chinese-recall {
+ margin-top: 16px;
+ padding-top: 14px;
+ border-top: 1px dashed #e2e8f0;
+}
+
+.recall-tip {
+ font-size: 16px;
+ color: #94a3b8;
+ margin-bottom: 10px;
+}
+
+.recall-input {
+ width: 300px;
+ padding: 12px;
+ font-size: 20px;
+ border: 2px solid #ddd;
+ border-radius: 10px;
+ outline: none;
+}
+
+.recall-input:focus {
+ border-color: #e67e22;
+}
+
+.recall-btn {
+ background: #64748b;
+ font-size: 16px;
+ padding: 12px 20px;
+}
+
+.recall-btn:hover {
+ background: #475569;
+}
+
+.recall-result {
+ margin-top: 10px;
+ font-size: 18px;
+ font-weight: bold;
+}
+
+.recall-result.ok {
+ color: #10b981;
+}
+
+.recall-result.bad {
+ color: #dc2626;
+}
+
+/* 手机竖屏:选项与句子自适应 */
+@media (max-width: 480px) {
+ .option-grid {
+ grid-template-columns: 1fr 1fr;
+ gap: 10px;
+ }
+
+ .option-btn {
+ font-size: 18px;
+ padding: 12px;
+ }
+
+ .sentence-text {
+ font-size: 22px;
+ }
+
+ .prompt-word-en {
+ font-size: 30px;
+ }
+
+ .recall-input {
+ width: 100%;
+ font-size: 18px;
+ }
+}
+
/* ====================== 响应式适配 ====================== */
/* 平板竖屏及以下:反馈区上下结构、输入框自适应 */
@media (max-width: 768px) {
--- /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';
+
+// 词性兜底池(当某组词性种类不足时用于补充干扰项)
+const UNIVERSAL_POS = ['n.', 'v.', 'adj.', 'adv.', 'prep.', 'conj.', 'pron.', 'int.'];
+
+function shuffle(arr) {
+ const a = [...arr];
+ for (let i = a.length - 1; i > 0; i--) {
+ const j = Math.floor(Math.random() * (i + 1));
+ [a[i], a[j]] = [a[j], a[i]];
+ }
+ return a;
+}
+
+function escapeRegExp(s) {
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+}
+
+// 在例句中把目标词替换为空格(用于句子填空选择题)。找不到目标词则返回原句。
+function blankWordInSentence(sentence, word) {
+ if (!sentence || !word) return sentence || '';
+ const re = new RegExp('\\b' + escapeRegExp(word) + '\\b', 'ig');
+ return sentence.replace(re, '______');
+}
+
+// 为某词生成 4 个词性选项(含正确项)。avoidSet 中的错误项将被替换掉(需求 5:换掉错误混淆项并重新排序)
+function buildPosOptions(word, posPool, avoidSet) {
+ const correct = (word.part_of_speech || '').trim();
+ let distractors = shuffle(posPool.filter(p => p && p !== correct && !avoidSet.has(p))).slice(0, 3);
+ // 干扰项不足 3 个时,放宽到全池补充(不含正确项、不重复)
+ if (distractors.length < 3) {
+ const backup = shuffle(posPool.filter(p => p && p !== correct && !distractors.includes(p)));
+ for (const p of backup) {
+ if (distractors.length >= 3) break;
+ distractors.push(p);
+ }
+ }
+ return shuffle([correct, ...distractors]);
+}
+
+// 为句子填空生成选项:正确词 + 同组非背诵干扰词。avoidSet 中的错误项将被替换
+function buildSentenceOptions(target, candidateWords, avoidSet) {
+ const correct = target.word;
+ let distractors = shuffle(
+ candidateWords.filter(w => w.word && w.word !== correct && !avoidSet.has(w.word))
+ ).slice(0, 3).map(w => w.word);
+ if (distractors.length < 3) {
+ const backup = shuffle(
+ candidateWords.filter(w => w.word && w.word !== correct && !distractors.includes(w.word))
+ );
+ for (const w of backup) {
+ if (distractors.length >= 3) break;
+ distractors.push(w.word);
+ }
+ }
+ return shuffle([correct, ...distractors]);
+}
+
+// 把中文含义做归一化,用于"自己回忆中文意思比对"的宽松匹配
+function normZh(s) {
+ return (s || '').toLowerCase().replace(/[\s,。、;:?!.,;:?!'"()()]/g, '');
+}
+
+function getCategories(words) {
+ const spell = words.filter(w => (w.memory_type || 'spell') === 'spell');
+ const recognize = words.filter(w => (w.memory_type || '') === 'recognize');
+ const recite = recognize.filter(w => w.need_recite === 1); // 非重点但需背诵 → 默写
+ const other = recognize.filter(w => w.need_recite !== 1 && (w.part_of_speech || '').trim()); // 其余 → 选词性+回忆中文
+ return { spell, recognize, recite, other };
+}
+
+// 句子填空候选:非背诵且例句包含该词(最多 5 个,best-effort 待校对)
+function buildSentenceCandidates(other) {
+ const candidates = other.filter(w => {
+ const s = (w.example_sentence || '');
+ return s && (w.word || '') && s.toLowerCase().includes((w.word || '').toLowerCase());
+ });
+ return shuffle(candidates).slice(0, 5);
+}
+
+function TestMode() {
+ const { groupId } = useParams();
+ const navigate = useNavigate();
+ const [searchParams] = useSearchParams();
+ // 活动类型:test / review1 / review2 / review3(首页点击复习时通过 ?type= 传入;缺省为 test)
+ const activityType = searchParams.get('type') || 'test';
+ const isOnline = useOnlineStatus();
+ // 测试/复习会话开始时间(毫秒时间戳), 用于计算本次学习的实际时长
+ const sessionStartRef = useRef(Date.now());
+ const [words, setWords] = useState([]);
+ const [phase, setPhase] = useState('ready'); // ready, word, word_fb, copy, sentence, sentence_fb, complete
+
+ // 单词环节队列(默写 / 选词性)
+ const [wordQueue, setWordQueue] = useState([]);
+ const [wordIndex, setWordIndex] = useState(0);
+ const [wrongWords, setWrongWords] = useState([]);
+
+ // 句子填空环节队列
+ const [sentenceQueue, setSentenceQueue] = useState([]);
+ const [sentenceIndex, setSentenceIndex] = useState(0);
+ const [wrongSentences, setWrongSentences] = useState([]);
+
+ // 交互状态
+ const [userInput, setUserInput] = useState('');
+ const [copyInput, setCopyInput] = useState('');
+ const [posChoice, setPosChoice] = useState('');
+ const [chineseRecall, setChineseRecall] = useState('');
+ const [compareResult, setCompareResult] = useState(null); // true/false/null
+ const [isCorrect, setIsCorrect] = useState(false);
+ const [sentenceChoice, setSentenceChoice] = useState('');
+
+ const inputRef = useRef(null);
+ const copyRef = useRef(null);
+ const chineseRef = useRef(null);
+ const submittedRef = useRef(false);
+
+ // 构建环节所需的派生数据(存 ref,供重考时重新生成选项)
+ const posPoolRef = useRef([]);
+ const otherRecognizeRef = useRef([]);
+
+ const current = phase === 'sentence' || phase === 'sentence_fb'
+ ? sentenceQueue[sentenceIndex]
+ : wordQueue[wordIndex];
+
+ // 离线时检查可学习范围
+ useEffect(() => {
+ if (isOnline) return;
+ getLearnableGroups().then(groups => {
+ if (!groups.includes(Number(groupId))) {
+ alert('该组不可离线学习(仅未完成组与最后完成组后的5组可离线学习)');
+ navigate('/');
+ }
+ });
+ }, [isOnline, groupId, navigate]);
+
+ // 获取单词数据 (在线走网络, 离线降级读 IndexedDB)
+ useEffect(() => {
+ api.getWords(groupId)
+ .then(res => setWords(res.data))
+ .catch(async () => {
+ const local = await getWordsByGroup(groupId);
+ setWords(local);
+ });
+ }, [groupId]);
+
+ // 清空所有交互输入
+ const resetInputs = useCallback(() => {
+ setUserInput('');
+ setCopyInput('');
+ setPosChoice('');
+ setChineseRecall('');
+ setCompareResult(null);
+ setIsCorrect(false);
+ setSentenceChoice('');
+ }, []);
+
+ // 开始测试:构建三类环节
+ const startTest = useCallback(() => {
+ const { spell, recite, other } = getCategories(words);
+ const posPool = Array.from(new Set([
+ ...words.map(w => (w.part_of_speech || '').trim()).filter(Boolean),
+ ...UNIVERSAL_POS,
+ ]));
+ posPoolRef.current = posPool;
+ otherRecognizeRef.current = other;
+
+ const dictationItems = [...spell, ...recite].map(w => ({ ...w, mode: 'dictation' }));
+ const posItems = other.map(w => ({
+ ...w,
+ mode: 'pos',
+ posOptions: buildPosOptions(w, posPool, new Set()),
+ avoidPos: new Set(),
+ }));
+ const wordQ = shuffle([...dictationItems, ...posItems]);
+
+ const sentCands = buildSentenceCandidates(other);
+ const sentQ = sentCands.map(w => ({
+ id: w.id,
+ target: w,
+ blanked: blankWordInSentence(w.example_sentence, w.word),
+ answer: w.word,
+ options: buildSentenceOptions(w, other, new Set()),
+ avoidWord: new Set(),
+ })).filter(s => s.options.length >= 2);
+
+ setWordQueue(wordQ);
+ setWrongWords([]);
+ setWordIndex(0);
+ setSentenceQueue(sentQ);
+ setWrongSentences([]);
+ setSentenceIndex(0);
+ resetInputs();
+ setPhase('word');
+ }, [words, resetInputs]);
+
+ // 自动聚焦
+ useEffect(() => {
+ if (phase === 'word' && current && current.mode === 'dictation' && inputRef.current) {
+ inputRef.current.focus();
+ }
+ if (phase === 'copy' && copyRef.current) {
+ copyRef.current.focus();
+ }
+ if (phase === 'word' && current && current.mode === 'pos' && chineseRef.current) {
+ // pos 选择后才有中文输入框,这里暂不聚焦
+ }
+ }, [phase, current]);
+
+ // 朗读:通过后端 TTS 代理播放
+ const speak = useCallback((text) => {
+ if (!text) return;
+ const audio = new Audio(api.ttsUrl(text));
+ audio.play().catch(err => console.error("TTS play failed:", err));
+ }, []);
+
+ // 默写提交
+ const checkAnswer = useCallback(() => {
+ if (!current || current.mode !== 'dictation' || !userInput.trim()) return;
+ const correct = (current.word || '').trim().toLowerCase();
+ const answer = userInput.trim().toLowerCase();
+ const ok = answer === correct;
+ setIsCorrect(ok);
+ setPhase('word_fb');
+ speak(current.word);
+ if (!ok) {
+ setWrongWords(prev => prev.find(w => w.id === current.id) ? prev : [...prev, current]);
+ }
+ }, [current, userInput, speak]);
+
+ // 选词性
+ const handlePosSelect = useCallback((opt) => {
+ if (!current || current.mode !== 'pos') return;
+ const correct = (current.part_of_speech || '').trim();
+ const ok = opt === correct;
+ setPosChoice(opt);
+ setIsCorrect(ok);
+ setPhase('word_fb');
+ speak(current.word);
+ if (!ok) {
+ current.avoidPos.add(opt); // 记录错误混淆项,重考时换掉(需求 5)
+ setWrongWords(prev => prev.find(w => w.id === current.id) ? prev : [...prev, current]);
+ }
+ }, [current, speak]);
+
+ // 比对回忆的中文意思(仅自测,不阻塞进度)
+ const compareChinese = useCallback(() => {
+ if (!current) return;
+ const correct = normZh(current.chinese_meaning);
+ const user = normZh(chineseRecall);
+ setCompareResult(!!(user && (correct.includes(user) || user.includes(correct))));
+ }, [current, chineseRecall]);
+
+ // 显示抄写区(默写错误后纠正)
+ const showCopy = useCallback(() => {
+ setCopyInput('');
+ setPhase('copy');
+ }, []);
+
+ // 进入下一词
+ const nextWord = useCallback(() => {
+ if (wordIndex + 1 < wordQueue.length) {
+ setWordIndex(prev => prev + 1);
+ resetInputs();
+ setPhase('word');
+ } else {
+ // 本轮结束
+ if (wrongWords.length > 0) {
+ const rq = shuffle(wrongWords.map(w => w.mode === 'pos'
+ ? { ...w, posOptions: buildPosOptions(w, posPoolRef.current, w.avoidPos) }
+ : { ...w }));
+ setWordQueue(rq);
+ setWrongWords([]);
+ setWordIndex(0);
+ resetInputs();
+ setPhase('word');
+ } else {
+ // 单词环节全部通过 → 进入句子填空环节
+ if (sentenceQueue.length > 0) {
+ setSentenceIndex(0);
+ resetInputs();
+ setPhase('sentence');
+ } else {
+ setPhase('complete');
+ }
+ }
+ }
+ }, [wordIndex, wordQueue.length, wrongWords, sentenceQueue, resetInputs]);
+
+ // 句子填空选择
+ const handleSentenceSelect = useCallback((opt) => {
+ if (!current) return;
+ const ok = opt === current.answer;
+ setSentenceChoice(opt);
+ setIsCorrect(ok);
+ setPhase('sentence_fb');
+ if (!ok) {
+ current.avoidWord.add(opt); // 记录错误混淆项,重考时换掉(需求 5)
+ setWrongSentences(prev => prev.find(w => w.id === current.id) ? prev : [...prev, current]);
+ }
+ }, [current]);
+
+ // 句子环节进入下一题
+ const nextSentence = useCallback(() => {
+ if (sentenceIndex + 1 < sentenceQueue.length) {
+ setSentenceIndex(prev => prev + 1);
+ resetInputs();
+ setPhase('sentence');
+ } else {
+ if (wrongSentences.length > 0) {
+ const rq = shuffle(wrongSentences.map(s => ({
+ ...s,
+ options: buildSentenceOptions(s.target, otherRecognizeRef.current, s.avoidWord),
+ })));
+ setSentenceQueue(rq);
+ setWrongSentences([]);
+ setSentenceIndex(0);
+ resetInputs();
+ setPhase('sentence');
+ } else {
+ setPhase('complete');
+ }
+ }
+ }, [sentenceIndex, sentenceQueue.length, wrongSentences, resetInputs]);
+
+ // 完成:全部环节全对才记录进度(需求 8)
+ const finishTest = useCallback(async () => {
+ if (submittedRef.current) return;
+ submittedRef.current = true;
+ const now = localTimestamp();
+ const durationSec = Math.max(0, Math.round((Date.now() - sessionStartRef.current) / 1000));
+ await putProgress(groupId, activityType, now, true, durationSec);
+ if (isOnline) triggerAutoSync();
+ navigate('/');
+ }, [groupId, navigate, activityType, isOnline]);
+
+ // 键盘事件
+ const handleKeyDown = useCallback((e) => {
+ if (e.key === 'Enter') {
+ if (phase === 'word' && current && current.mode === 'dictation') {
+ checkAnswer();
+ } else if (phase === 'word_fb' && isCorrect) {
+ nextWord();
+ } else if (phase === 'word_fb' && current && current.mode === 'dictation' && !isCorrect) {
+ showCopy();
+ } else if (phase === 'copy' && copyInput.toLowerCase() === (current ? (current.word || '').toLowerCase() : '')) {
+ nextWord();
+ } else if (phase === 'word_fb' && current && current.mode === 'pos') {
+ nextWord();
+ } else if (phase === 'sentence_fb') {
+ nextSentence();
+ }
+ }
+ }, [phase, isCorrect, current, checkAnswer, nextWord, showCopy, copyInput, nextSentence]);
+
+ useEffect(() => {
+ window.addEventListener('keydown', handleKeyDown);
+ return () => window.removeEventListener('keydown', handleKeyDown);
+ }, [handleKeyDown]);
+
+ // 阻止复制/剪切/粘贴/右键,防止作弊
+ const blockClipboard = (e) => e.preventDefault();
+
+ // 默写输入:仅保留 ASCII 可见字符并转小写(屏蔽中文/全角/大写)
+ const sanitize = (s) => s.replace(/[^\x20-\x7E]/g, '').toLowerCase();
+
+ const inputProps = {
+ type: "text",
+ lang: "en",
+ inputMode: "latin",
+ autoComplete: "off",
+ autoCorrect: "off",
+ autoCapitalize: "off",
+ spellCheck: false,
+ onPaste: blockClipboard,
+ onContextMenu: blockClipboard,
+ onCompositionStart: blockClipboard,
+ };
+
+ // 准备阶段
+ if (words.length === 0) return <div className="loading">加载中...</div>;
+
+ if (phase === 'ready') {
+ const { spell, recite, other } = getCategories(words);
+ const dictCount = spell.length + recite.length;
+ const posCount = other.length;
+ const sentCount = buildSentenceCandidates(other).length;
+ return (
+ <div className="test-container" onCopy={blockClipboard} onCut={blockClipboard} onPaste={blockClipboard} onContextMenu={blockClipboard}>
+ <div className="test-card test-start-card">
+ <h1>单元测试 - Group {groupId}</h1>
+ <p className="test-info">共 {words.length} 个单词</p>
+ <p className="test-desc">
+ 拼写/需背诵词({dictCount})默写;其余识别词({posCount})选词性并自测中文;
+ 最后 {sentCount} 道句子填空选择题。全部答对才算完成。
+ </p>
+ <button className="action-btn start-btn" onClick={startTest}>开始测试</button>
+ <button className="back-home" onClick={() => navigate('/')}>返回首页</button>
+ </div>
+ </div>
+ );
+ }
+
+ if (phase === 'complete') {
+ return (
+ <div className="test-container" onCopy={blockClipboard} onCut={blockClipboard} onPaste={blockClipboard} onContextMenu={blockClipboard}>
+ <div className="test-card test-complete-card">
+ <h1>🎉 测试完成!</h1>
+ <p>Group {groupId} 全部通过!</p>
+ <button className="action-btn" onClick={finishTest} disabled={submittedRef.current}>完成</button>
+ </div>
+ </div>
+ );
+ }
+
+ if (!current) return null;
+
+ // 句子填空环节
+ if (phase === 'sentence' || phase === 'sentence_fb') {
+ const isFb = phase === 'sentence_fb';
+ return (
+ <div className="test-container" onCopy={blockClipboard} onCut={blockClipboard} onPaste={blockClipboard} onContextMenu={blockClipboard}>
+ <div className="test-card">
+ <div className="test-header">
+ <span className="test-status">句子填空 - Group {groupId}</span>
+ <span className="test-progress">{sentenceIndex + 1} / {sentenceQueue.length}</span>
+ </div>
+ <div className="test-body">
+ {!isFb && (
+ <div className="sentence-area">
+ <p className="sentence-prompt">选择填入空白处最合适的词:</p>
+ <p className="sentence-text">{current.blanked}</p>
+ <div className="option-grid">
+ {current.options.map((opt, i) => (
+ <button key={i} className="option-btn" onClick={() => handleSentenceSelect(opt)}>
+ {opt}
+ </button>
+ ))}
+ </div>
+ </div>
+ )}
+ {isFb && (
+ <div className="feedback-wrapper">
+ <div className="feedback-main">
+ <div className={`fb-status ${isCorrect ? 'correct' : 'wrong'}`}>
+ {isCorrect ? '✅ 正确!' : `❌ 错误,你的答案: ${sentenceChoice}`}
+ </div>
+ <div className="fb-word">正确答案: {current.answer}</div>
+ <div className="fb-sentence">原句: {current.target.example_sentence}</div>
+ <div className="fb-chinese">中文: {current.target.chinese_meaning}</div>
+ <button className="action-btn" onClick={nextSentence}>下一题 (Enter)</button>
+ </div>
+ </div>
+ )}
+ </div>
+ </div>
+ <button className="back-home" onClick={() => navigate('/')}>返回首页</button>
+ </div>
+ );
+ }
+
+ // 单词环节(默写 / 选词性)
+ const isPos = current.mode === 'pos';
+ return (
+ <div className="test-container" onCopy={blockClipboard} onCut={blockClipboard} onPaste={blockClipboard} onContextMenu={blockClipboard}>
+ <div className="test-card">
+ <div className="test-header">
+ <span className="test-status">
+ {isPos ? '选词性' : '默写'} - Group {groupId}
+ </span>
+ <span className="test-progress">{wordIndex + 1} / {wordQueue.length}</span>
+ </div>
+
+ <div className="test-body">
+ {/* 答题中 */}
+ {phase === 'word' && !isPos && (
+ <div className="testing-area">
+ <div className="prompt-info">
+ <p className="prompt-chinese">{current.chinese_meaning}</p>
+ <p className="prompt-pos">({current.part_of_speech})</p>
+ </div>
+ <div className="input-row">
+ <input
+ ref={inputRef}
+ value={userInput}
+ onChange={(e) => setUserInput(sanitize(e.target.value))}
+ placeholder="请输入英文单词..."
+ className="test-input"
+ {...inputProps}
+ />
+ <button className="action-btn" onClick={checkAnswer}>提交</button>
+ </div>
+ </div>
+ )}
+
+ {phase === 'word' && isPos && (
+ <div className="testing-area">
+ <div className="prompt-info">
+ <p className="prompt-word-en">{current.word}</p>
+ {current.example_sentence && (
+ <p className="prompt-sentence">例句: {current.example_sentence}</p>
+ )}
+ <p className="prompt-pos-hint">请选择该词的词性:</p>
+ </div>
+ <div className="option-grid">
+ {current.posOptions.map((opt, i) => (
+ <button key={i} className="option-btn" onClick={() => handlePosSelect(opt)}>
+ {opt}
+ </button>
+ ))}
+ </div>
+ </div>
+ )}
+
+ {/* 反馈区 */}
+ {(phase === 'word_fb' || phase === 'copy') && (
+ <div className="feedback-wrapper">
+ <div className="feedback-main">
+ <div className={`fb-status ${isCorrect ? 'correct' : 'wrong'}`}>
+ {isCorrect ? '✅ 正确!' : (isPos ? `❌ 词性错误,你的选择: ${posChoice}` : `❌ 错误,你的答案: ${userInput}`)}
+ </div>
+
+ <div className="fb-word">{current.word}</div>
+ <div className="fb-chinese">中文: {current.chinese_meaning}</div>
+ <div className="fb-pos">词性: {current.part_of_speech}</div>
+
+ {current.word_root && (<div className="fb-root">词根: {current.word_root}</div>)}
+ {current.affix && (<div className="fb-affix">组成: {current.affix}</div>)}
+ {current.example_sentence && (<div className="fb-sentence">例句: {current.example_sentence}</div>)}
+
+ {/* 选词性环节:自测回忆中文意思(仅自测,不阻塞) */}
+ {isPos && (
+ <div className="chinese-recall">
+ <p className="recall-tip">自己回忆的中文意思是否一致?(仅自测)</p>
+ <div className="input-row">
+ <input
+ ref={chineseRef}
+ value={chineseRecall}
+ onChange={(e) => setChineseRecall(e.target.value)}
+ placeholder="回忆的中文意思..."
+ className="recall-input"
+ onPaste={blockClipboard}
+ onContextMenu={blockClipboard}
+ />
+ <button className="action-btn recall-btn" onClick={compareChinese}>比对</button>
+ </div>
+ {compareResult !== null && (
+ <div className={`recall-result ${compareResult ? 'ok' : 'bad'}`}>
+ {compareResult ? '✅ 与参考一致/接近' : '❌ 与参考不一致'}
+ </div>
+ )}
+ </div>
+ )}
+
+ {!isCorrect && phase === 'word_fb' && !isPos && (
+ <button className="action-btn copy-trigger" onClick={showCopy}>抄写纠正 (Enter)</button>
+ )}
+
+ {(isCorrect || isPos) && (
+ <button className="action-btn" onClick={nextWord}>下一词 (Enter)</button>
+ )}
+ </div>
+
+ {current.picture_id && (
+ <div className="fb-image">
+ <img src={api.pictureUrl(current.picture_id)} alt="mindmap" />
+ </div>
+ )}
+ </div>
+ )}
+
+ {/* 抄写纠正区(默写错误) */}
+ {phase === 'copy' && (
+ <div className="copy-area">
+ <label className="copy-label">抄写: </label>
+ <input
+ ref={copyRef}
+ value={copyInput}
+ onChange={(e) => setCopyInput(sanitize(e.target.value))}
+ className={`copy-input ${copyInput.toLowerCase() === (current.word || '').toLowerCase() ? 'correct' : ''}`}
+ placeholder="请在此抄写正确单词..."
+ {...inputProps}
+ />
+ <span className={`copy-check ${copyInput.toLowerCase() === (current.word || '').toLowerCase() ? 'ok' : ''}`}>
+ {copyInput.toLowerCase() === (current.word || '').toLowerCase() ? '✅' : '❌'}
+ </span>
+ {copyInput.toLowerCase() === (current.word || '').toLowerCase() && (
+ <button className="action-btn" onClick={nextWord}>下一词 (Enter)</button>
+ )}
+ </div>
+ )}
+ </div>
+ </div>
+ <button className="back-home" onClick={() => navigate('/')}>返回首页</button>
+ </div>
+ );
+}
+
+export default TestMode;
-// IndexedDB 封装 for V2 离线学习(Daily Batch 模型)
-// stores:
-// - words: 全量词库(含 memory_type 等新列), keyPath=id, 索引 groupid
-// - dailyPlans: 当天冻结计划(含 items), keyPath=plan_id, 索引 plan_date
-// - learningEvents: 逐词/逐题事件(append-only), keyPath=event_id, 索引 session_id/plan_id, 带 synced 标记
-// - studySessions: 主动学习时长会话, keyPath=session_id, 带 synced 标记
-// - wordProgress: 当前掌握状态快照(可在离线展示"已学"), keyPath=word_id
-// - meta: 元数据, keyPath=key (lastSyncAt 等)
-
-const DB_NAME = 'words-pwa-v2';
-const DB_VERSION = 2;
+// IndexedDB 封装 for PWA 离线学习
+// 三个 store:
+// - words: 全量词库, keyPath=id, 索引 groupid (供按组查询)
+// - progress: 离线进度, keyPath=`${group_id}:${op_type}`, {key, group_id, op_type, completed_at, dirty}
+// - meta: 元数据, keyPath=key (lastSyncAt, learnableGroups[])
+
+const DB_NAME = 'words-pwa-v2r';
+const DB_VERSION = 1;
let dbPromise = null;
db.createObjectStore('words', { keyPath: 'id' })
.createIndex('groupid', 'groupid', { unique: false });
}
- if (!db.objectStoreNames.contains('dailyPlans')) {
- const s = db.createObjectStore('dailyPlans', { keyPath: 'plan_id' });
- s.createIndex('plan_date', 'plan_date', { unique: false });
- }
- if (!db.objectStoreNames.contains('learningEvents')) {
- const s = db.createObjectStore('learningEvents', { keyPath: 'event_id' });
- s.createIndex('session_id', 'session_id', { unique: false });
- s.createIndex('plan_id', 'plan_id', { unique: false });
- }
- if (!db.objectStoreNames.contains('studySessions')) {
- db.createObjectStore('studySessions', { keyPath: 'session_id' });
- }
- if (!db.objectStoreNames.contains('wordProgress')) {
- db.createObjectStore('wordProgress', { keyPath: 'word_id' });
+ if (!db.objectStoreNames.contains('progress')) {
+ db.createObjectStore('progress', { keyPath: 'key' });
}
if (!db.objectStoreNames.contains('meta')) {
db.createObjectStore('meta', { keyPath: 'key' });
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());
}
-// ---------- dailyPlans store ----------
-export async function putDailyPlan(plan) {
- return singleReq('dailyPlans', 'readwrite', s => s.put(plan));
-}
-
-export async function getDailyPlanByDate(date) {
- return singleReq('dailyPlans', 'readonly', s =>
- s.index('plan_date').getAll(IDBKeyRange.only(date))
- ).then(r => (r && r.length ? r[0] : null));
-}
+// ---------- progress store ----------
-// ---------- learningEvents store ----------
-export async function putLearningEvent(ev) {
- return singleReq('learningEvents', 'readwrite', s => s.put(ev));
+export async function getProgress(group_id, op_type) {
+ return singleReq('progress', 'readonly', s => s.get(`${group_id}:${op_type}`));
}
-export async function getUnsyncedEvents() {
- const all = await singleReq('learningEvents', 'readonly', s => s.getAll());
- return all.filter(e => !e.synced);
+export async function getAllProgress() {
+ return singleReq('progress', 'readonly', s => s.getAll());
}
-export async function markEventsSynced(ids) {
+export async function putProgress(group_id, op_type, completed_at, dirty, duration_sec = 0) {
+ const key = `${group_id}:${op_type}`;
const db = await openDB();
return new Promise((resolve, reject) => {
- const t = db.transaction('learningEvents', 'readwrite');
- const s = t.objectStore('learningEvents');
- ids.forEach(id => {
- const r = s.get(id);
- r.onsuccess = () => {
- if (r.result) { r.result.synced = true; s.put(r.result); }
- };
- });
+ const t = db.transaction('progress', 'readwrite');
+ t.objectStore('progress').put({ key, group_id: String(group_id), op_type, completed_at, dirty, duration_sec: duration_sec || 0 });
t.oncomplete = () => resolve();
t.onerror = () => reject(t.error);
});
}
-// ---------- studySessions store ----------
-export async function putStudySession(session) {
- return singleReq('studySessions', 'readwrite', s => s.put(session));
-}
-
-export async function getUnsyncedSessions() {
- const all = await singleReq('studySessions', 'readonly', s => s.getAll());
- return all.filter(x => !x.synced);
+export async function getDirtyProgress() {
+ const all = await getAllProgress();
+ return all.filter(p => p.dirty);
}
-export async function markSessionsSynced(ids) {
+export async function clearDirty(group_id, op_type) {
+ const key = `${group_id}:${op_type}`;
const db = await openDB();
return new Promise((resolve, reject) => {
- const t = db.transaction('studySessions', 'readwrite');
- const s = t.objectStore('studySessions');
- ids.forEach(id => {
- const r = s.get(id);
- r.onsuccess = () => {
- if (r.result) { r.result.synced = true; s.put(r.result); }
- };
- });
+ const t = db.transaction('progress', 'readwrite');
+ const s = t.objectStore('progress');
+ const req = s.get(key);
+ req.onsuccess = () => {
+ if (req.result) { req.result.dirty = false; s.put(req.result); }
+ };
t.oncomplete = () => resolve();
t.onerror = () => reject(t.error);
});
}
-// ---------- wordProgress store ----------
-export async function putWordProgress(wp) {
- return singleReq('wordProgress', 'readwrite', s => s.put(wp));
-}
-
-export async function getAllWordProgress() {
- return singleReq('wordProgress', 'readonly', s => s.getAll());
+// 删除某条进度(用于服务端已删除记录回写本地, 实现撤销/未执行)
+export async function deleteProgress(group_id, op_type) {
+ const key = `${group_id}:${op_type}`;
+ return singleReq('progress', 'readwrite', s => s.delete(key));
}
// ---------- meta store ----------
+
export async function getMeta(key) {
const r = await singleReq('meta', 'readonly', s => s.get(key));
return r ? r.value : undefined;
});
}
+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'));
}
-// V2 离线优先 + 自动同步 统一入口
-// 同步对象: learningEvents(UUID 幂等) / studySessions(UUID upsert) / dailyPlans / wordProgress
-// 不再使用 (group_id, op_type) 去重;事件以 event_id 天然幂等。
+// 离线优先 + 自动同步 统一入口
+// 数据规则 (布尔并集): (group,op) 任一方完成即完成; 云端删除已确认进度会回写本地(撤销)
+// dirty = "这条本地完成记录尚未确认上传到云端"
+// syncNow: 上传本地 dirty → 拉云端并集合并 → 更新 lastSyncAt → 重算 learnable
+// triggerAutoSync: 幂等并发锁, 供首页/联网恢复/完成学习 调用, 失败时置 failed
-import { api, localTimestamp } from './api';
+import { api } from './api';
import {
- getUnsyncedEvents, markEventsSynced,
- getUnsyncedSessions, markSessionsSynced,
- putWords, setMeta,
- putDailyPlan, putWordProgress,
- getWordsCount,
+ getDirtyProgress, clearDirty, getAllProgress, putProgress, deleteProgress,
+ putWords, setMeta, setLearnableGroups, getAllWordsFromIDB, getWordsCount,
} from './db';
+const OPS = ['study', 'test', 'review1', 'review2', 'review3'];
+
// ---------- 同步状态机 (供 Home / SyncNotifications 共享) ----------
+// state: 'idle' | 'syncing' | 'synced' | 'failed'
+// detail: { phase: 'upload' | 'merge', dirty, message, pushed, pulled }
const listeners = new Set();
let syncStatus = { state: 'idle', detail: null };
// ---------- 核心同步 ----------
export async function syncNow() {
- const result = { pushed: 0, pulled: 0, warmed: 0 };
- if (typeof navigator !== 'undefined' && !navigator.onLine) {
- setSyncStatus('failed', { message: '离线,无法同步' });
- return result;
+ const result = { pushed: 0, pulled: 0, warmed: 0, pushedEvents: [], pulledEvents: [] };
+
+ // 阶段1: 上传本地 dirty (严格按 dirty 推送, 非 dirty 视为已确认)
+ const dirty = await getDirtyProgress();
+ setSyncStatus('syncing', { phase: 'upload', dirty: dirty.length });
+ if (dirty.length > 0) {
+ const events = dirty.map(p => ({
+ group_id: p.group_id,
+ op_type: p.op_type,
+ completed_at: p.completed_at,
+ duration_sec: p.duration_sec || 0,
+ }));
+ const r = await api.syncPush(events);
+ result.pushed = r.data.updated || events.length;
+ result.pushedEvents = events;
+ // 仅清"已确认上传"的 dirty (云端最终存在该 group/op 即清)
+ await Promise.all(dirty.map(p => clearDirty(p.group_id, p.op_type)));
}
- try {
- setSyncStatus('syncing', { phase: 'upload', dirty: 0 });
-
- // 阶段1: 上传本地未同步事件
- const dirtyEvents = await getUnsyncedEvents();
- if (dirtyEvents.length > 0) {
- const payload = dirtyEvents.map(e => ({
- event_id: e.event_id,
- plan_id: e.plan_id,
- word_id: e.word_id,
- group_id: e.group_id,
- stage: e.stage,
- question_type: e.question_type,
- result: e.result,
- attempt_no: e.attempt_no || 1,
- duration_ms: e.duration_ms || 0,
- created_at: e.created_at,
- }));
- const r = await api.eventsBatch(dirtyEvents[0].session_id, payload);
- result.pushed = r.data.inserted || 0;
- await markEventsSynced(dirtyEvents.map(e => e.event_id));
- }
- // 阶段2: 上传未同步会话(主动时长)
- const dirtySessions = await getUnsyncedSessions();
- for (const s of dirtySessions) {
- await api.upsertSession({
- session_id: s.session_id,
- plan_id: s.plan_id,
- log_date: s.log_date,
- started_at: s.started_at,
- ended_at: s.ended_at,
- active_sec: s.active_sec,
- new_sec: s.new_sec,
- review_sec: s.review_sec,
- quiz_sec: s.quiz_sec,
- completed: s.completed,
- });
- result.pushed += 1;
+ // 阶段2: 拉云端进度, 布尔并集合并 (只补不覆盖, 不动本地已有)
+ setSyncStatus('syncing', { phase: 'merge', dirty: 0 });
+ const pullResp = await api.syncPull();
+ const serverEvents = pullResp.data.events || [];
+ const localAll = await getAllProgress();
+ for (const ev of serverEvents) {
+ const key = `${ev.group_id}:${ev.op_type}`;
+ const local = localAll.find(p => p.key === key);
+ if (!local) {
+ // 本地缺失: 直接补入(布尔并集)
+ await putProgress(ev.group_id, ev.op_type, ev.completed_at, false, ev.duration_sec || 0);
+ result.pulled++;
+ result.pulledEvents.push(ev);
+ } else {
+ // 本地已存在: 若云端日期更新(如服务端修正了时区误记),
+ // 用云端日期覆盖本地显示时间, 让首页进度与云端一致(LWW, 只升不降, 按日期比较)
+ const localDate = (local.completed_at || '').slice(0, 10);
+ const serverDate = (ev.completed_at || '').slice(0, 10);
+ if (serverDate > localDate) {
+ await putProgress(ev.group_id, ev.op_type, ev.completed_at, false, ev.duration_sec || 0);
+ result.pulled++;
+ result.pulledEvents.push(ev);
+ }
}
- if (dirtySessions.length > 0) {
- await markSessionsSynced(dirtySessions.map(s => s.session_id));
- }
-
- setSyncStatus('syncing', { phase: 'merge', dirty: 0 });
+ }
- // 阶段3: 首次 warmup 词库 (仅 IDB 空时)
- if ((await getWordsCount()) === 0) {
- const allWords = await api.wordsV2All();
- await putWords(allWords.data);
- result.warmed = allWords.data.length;
+ // 阶段2b: 服务端已删除(且本地已确认/非 dirty)的进度, 本地同步删除。
+ // 仅在成功拉取到非空集合时才执行, 避免服务端异常返回空时误删本地进度;
+ // 仅删非 dirty 记录(本地未推送完成的进度绝不删, 保护离线工作)。
+ if (serverEvents.length > 0) {
+ const serverKeys = new Set(serverEvents.map(e => `${e.group_id}:${e.op_type}`));
+ for (const p of localAll) {
+ if (!p.dirty && !serverKeys.has(p.key)) {
+ await deleteProgress(p.group_id, p.op_type);
+ }
}
+ }
- // 阶段4: 拉取 word_progress 快照(离线展示"已学")
- const wp = await api.progressWords();
- if (wp.data && wp.data.length) {
- for (const p of wp.data) await putWordProgress(p);
- result.pulled = wp.data.length;
- }
+ // 阶段3: 首次 warmup 词库 (仅 IDB 空时, 避免每次重拉 1852 词)
+ if ((await getWordsCount()) === 0) {
+ const allWords = await api.getAllWords();
+ await putWords(allWords.data);
+ result.warmed = allWords.data.length;
+ }
- // 阶段5: 拉取今天计划(若存在)写入本地,离线可继续
- const today = localTimestamp().slice(0, 10);
- try {
- const plan = await api.dailyPlan(today);
- if (plan.data) await putDailyPlan({ ...plan.data, plan_date: today });
- result.pulledPlan = !!plan.data;
- } catch (e) {
- // 404: 今天计划尚未生成,忽略
- }
+ // 阶段4: 更新 lastSyncAt + 重算可学习范围
+ await setMeta('lastSyncAt', new Date().toISOString());
+ await recomputeLearnableGroups();
- await setMeta('lastSyncAt', new Date().toISOString());
- setSyncStatus('synced', { pushed: result.pushed, pulled: result.pulled });
- } catch (e) {
- setSyncStatus('failed', { message: (e && e.message) || String(e) });
- }
+ // 阶段5: 后台预取离线资源(思维导图+语音), 不阻塞同步完成
+ prefetchLearnableAssets().catch(() => {});
+
+ setSyncStatus('synced', { pushed: result.pushed, pulled: result.pulled });
return result;
}
let autoSyncRunning = false;
+// 幂等自动同步: 并发调用只会跑一次; 失败置 failed (本地进度仍保留)
export async function triggerAutoSync() {
if (autoSyncRunning) return;
autoSyncRunning = true;
try {
await syncNow();
+ } catch (e) {
+ setSyncStatus('failed', { message: (e && e.message) || String(e) });
} finally {
autoSyncRunning = false;
}
}
+// 重算可学习范围: 未完全完成的组 + 最后完成组之后的 5 组
+export async function recomputeLearnableGroups() {
+ const allWords = await getAllWordsFromIDB();
+ if (!allWords || allWords.length === 0) {
+ await setLearnableGroups([]);
+ return;
+ }
+ const allGroups = Array.from(new Set(allWords.map(w => Number(w.groupid)))).sort((a, b) => a - b);
+ const progress = await getAllProgress();
+ const completedByGroup = {};
+ progress.forEach(p => {
+ const g = Number(p.group_id);
+ if (!completedByGroup[g]) completedByGroup[g] = new Set();
+ completedByGroup[g].add(p.op_type);
+ });
+ const isComplete = (g) => {
+ const ops = completedByGroup[g];
+ return !!ops && OPS.every(op => ops.has(op));
+ };
+ const learnable = new Set();
+ let maxComplete = -1;
+ allGroups.forEach(g => {
+ if (isComplete(g)) {
+ if (g > maxComplete) maxComplete = g;
+ } else {
+ learnable.add(g);
+ }
+ });
+ // 最后完成组后的 5 组
+ if (maxComplete >= 0) {
+ const idx = allGroups.indexOf(maxComplete);
+ for (let i = 1; i <= 5; i++) {
+ if (idx + i < allGroups.length) learnable.add(allGroups[idx + i]);
+ }
+ }
+ await setLearnableGroups(Array.from(learnable).sort((a, b) => a - b));
+}
+
+// ---------- 预取离线资源(思维导图 + 语音)----------
+// 范围: 未完成组(有记录但未集齐5项) ∪ 未开始组中最前面10组
+// 仅在线执行, 后台非阻塞; 去重直接查 SW 缓存 (caches.match), 不依赖 meta 标记,
+// 避免"SW 未接管时预取未真正落盘却被误标为已完成"导致后续跳过。
+const PREFETCH_CONCURRENCY = 6;
+const PREFETCH_NOT_STARTED_AHEAD = 10;
+const PREFETCH_OPS = ['study', 'test', 'review1', 'review2', 'review3'];
+
+// mode:'no-cors' 使请求键与 <img>/<Audio> 完全一致, 确保 SW cache-first 命中
+function prefetchUrl(url) {
+ return fetch(url, { mode: 'no-cors' }).catch(() => {});
+}
+
+function computePrefetchGroups(allWords, progress) {
+ const allGroups = Array.from(new Set(allWords.map(w => Number(w.groupid)))).sort((a, b) => a - b);
+ const opsByGroup = {};
+ progress.forEach(p => {
+ const g = Number(p.group_id);
+ if (!opsByGroup[g]) opsByGroup[g] = new Set();
+ opsByGroup[g].add(p.op_type);
+ });
+ const isComplete = (g) => !!opsByGroup[g] && PREFETCH_OPS.every(op => opsByGroup[g].has(op));
+ const incomplete = allGroups.filter(g => opsByGroup[g] && !isComplete(g)); // 已开始但未完成
+ const notStarted = allGroups.filter(g => !opsByGroup[g]); // 完全无记录
+ const firstNotStarted = notStarted.slice(0, PREFETCH_NOT_STARTED_AHEAD);
+ return Array.from(new Set([...incomplete, ...firstNotStarted])).sort((a, b) => a - b);
+}
+
+export async function prefetchLearnableAssets() {
+ if (typeof navigator === 'undefined' || !navigator.onLine) return;
+ if (!('caches' in window)) return;
+ const allWords = await getAllWordsFromIDB();
+ if (!allWords || allWords.length === 0) return;
+ const progress = await getAllProgress();
+ const target = computePrefetchGroups(allWords, progress);
+ if (target.length === 0) return;
+
+ const byGroup = {};
+ allWords.forEach(w => {
+ const g = Number(w.groupid);
+ if (!byGroup[g]) byGroup[g] = [];
+ byGroup[g].push(w);
+ });
+
+ // 收集需预取的 URL: 每组首词思维导图 + 每个词语音
+ const urls = [];
+ for (const g of target) {
+ const words = byGroup[g] || [];
+ if (words.length === 0) continue;
+ const picId = words[0].picture_id;
+ if (picId) urls.push(api.pictureUrl(picId));
+ for (const w of words) {
+ if (w.word) urls.push(api.ttsUrl(w.word));
+ }
+ }
+
+ // 准确去重: 只预取 SW 缓存里尚不存在的 URL (避免 meta 误标导致永久跳过)
+ const todo = [];
+ for (const u of urls) {
+ const hit = await caches.match(u).catch(() => undefined);
+ if (!hit) todo.push(u);
+ }
+ if (todo.length === 0) return;
+
+ // 分片并发, 限制同时请求数, 单条失败忽略
+ for (let i = 0; i < todo.length; i += PREFETCH_CONCURRENCY) {
+ const batch = todo.slice(i, i + PREFETCH_CONCURRENCY).map(u => prefetchUrl(u));
+ await Promise.allSettled(batch);
+ }
+}
+
+const OP_LABEL = { study: '学习', test: '测试', review1: '复习I', review2: '复习II', review3: '复习III' };
+
+// 格式化同步结果为可读提示 (保留, 用于失败时/手动诊断)
export function formatSyncResult(r) {
+ const fmt = (e) => `${(e.completed_at || '').slice(0, 10)} Group${e.group_id} ${OP_LABEL[e.op_type] || e.op_type}`;
const lines = [];
- if (r.pushed > 0) lines.push(`上传 ${r.pushed} 条`);
- if (r.pulled > 0) lines.push(`拉取 ${r.pulled} 条进度`);
- if (r.warmed > 0) lines.push(`初始化词库 ${r.warmed} 词`);
- if (r.pushed === 0 && r.pulled === 0 && r.warmed === 0) lines.push('已是最新');
+ if (r.pushed > 0) {
+ lines.push(`推送 ${r.pushed} 条进度:`);
+ r.pushedEvents.forEach(e => lines.push(' ' + fmt(e)));
+ }
+ if (r.pulled > 0) {
+ lines.push(`拉取 ${r.pulled} 条进度:`);
+ r.pulledEvents.forEach(e => lines.push(' ' + fmt(e)));
+ }
+ if (r.pushed === 0 && r.pulled === 0) {
+ lines.push('无新进度,已是最新');
+ }
return '同步完成:\n' + lines.join('\n');
}