diff --git a/README.md b/README.md index a208d3e..4b9c8c7 100644 --- a/README.md +++ b/README.md @@ -156,7 +156,9 @@ wordloop/ - 用户注册 / 登录(JWT) - 中英互译(本地词典 MVP,可扩展 AI) -- 个人单词库(新词 / 学习中 / 已掌握 / 易错词) +- 个人单词库(新词 / 学习中 / 已掌握 / 易错词,记录进入词库时间) +- 记忆曲线与 Obsidian 式单词关系力导向图 - 每日选择题训练 +- 拼写练习(中文释义 + 音标,输入英文单词) - 掌握规则与复习间隔 - 学习统计与设置 diff --git a/backend/auth.py b/backend/auth.py index 0aa67b5..8b7e064 100644 --- a/backend/auth.py +++ b/backend/auth.py @@ -13,7 +13,11 @@ from models import User SECRET_KEY = os.getenv("WORDLOOP_SECRET_KEY", "wordloop-dev-secret-change-in-production") ALGORITHM = "HS256" -ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7 +# 未勾选「记住登录」:较短有效期;勾选后:长期有效(降低安全级别、减少重复登录) +SESSION_TOKEN_EXPIRE_MINUTES = int(os.getenv("WORDLOOP_SESSION_EXPIRE_MINUTES", str(60 * 24))) +REMEMBER_TOKEN_EXPIRE_MINUTES = int( + os.getenv("WORDLOOP_REMEMBER_EXPIRE_MINUTES", str(60 * 24 * 30)) +) security = HTTPBearer() @@ -26,8 +30,9 @@ def verify_password(plain: str, hashed: str) -> bool: return bcrypt.checkpw(plain.encode("utf-8"), hashed.encode("utf-8")) -def create_access_token(user_id: int, username: str) -> str: - expire = datetime.now(timezone.utc) + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) +def create_access_token(user_id: int, username: str, *, remember: bool = True) -> str: + minutes = REMEMBER_TOKEN_EXPIRE_MINUTES if remember else SESSION_TOKEN_EXPIRE_MINUTES + expire = datetime.now(timezone.utc) + timedelta(minutes=minutes) payload = {"sub": str(user_id), "username": username, "exp": expire} return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM) diff --git a/backend/db_migrate.py b/backend/db_migrate.py new file mode 100644 index 0000000..544a633 --- /dev/null +++ b/backend/db_migrate.py @@ -0,0 +1,52 @@ +"""启动时补齐新增列(已有库无需手工迁移)。""" + +from sqlalchemy import inspect, text + +from database import engine + + +def run_migrations() -> None: + insp = inspect(engine) + with engine.begin() as conn: + if insp.has_table("words"): + cols = {c["name"] for c in insp.get_columns("words")} + if "total_train_seconds" not in cols: + conn.execute( + text( + "ALTER TABLE words ADD COLUMN total_train_seconds " + "INTEGER NOT NULL DEFAULT 0" + ) + ) + if "train_count" not in cols: + conn.execute( + text("ALTER TABLE words ADD COLUMN train_count INTEGER NOT NULL DEFAULT 0") + ) + + if insp.has_table("quiz_records"): + cols = {c["name"] for c in insp.get_columns("quiz_records")} + if "duration_seconds" not in cols: + conn.execute( + text( + "ALTER TABLE quiz_records ADD COLUMN duration_seconds " + "INTEGER NOT NULL DEFAULT 0" + ) + ) + + if insp.has_table("words") and insp.has_table("quiz_records"): + conn.execute( + text( + """ + UPDATE words w SET + train_count = ( + SELECT COUNT(*) FROM quiz_records q WHERE q.word_id = w.id + ), + total_train_seconds = ( + SELECT COALESCE(SUM(duration_seconds), 0) + FROM quiz_records q WHERE q.word_id = w.id + ) + WHERE train_count = 0 AND EXISTS ( + SELECT 1 FROM quiz_records q WHERE q.word_id = w.id + ) + """ + ) + ) diff --git a/backend/main.py b/backend/main.py index e49328e..e1f326f 100644 --- a/backend/main.py +++ b/backend/main.py @@ -2,9 +2,11 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from database import Base, engine +from db_migrate import run_migrations from routers import auth_router, quiz_router, settings_router, translate_router, word_router Base.metadata.create_all(bind=engine) +run_migrations() app = FastAPI(title="WordLoop API", version="1.0.0") diff --git a/backend/models.py b/backend/models.py index cd4e04d..97432d0 100644 --- a/backend/models.py +++ b/backend/models.py @@ -29,6 +29,8 @@ class Word(Base): wrong_count = Column(Integer, nullable=False, default=0) consecutive_correct_count = Column(Integer, nullable=False, default=0) mastery_score = Column(Integer, nullable=False, default=0) + train_count = Column(Integer, nullable=False, default=0) + total_train_seconds = Column(Integer, nullable=False, default=0) review_due_date = Column(String(32), nullable=True) last_reviewed_at = Column(String(32), nullable=True) created_at = Column(String(32), nullable=False) @@ -44,6 +46,7 @@ class QuizRecord(Base): user_answer = Column(String(500), nullable=False) correct_answer = Column(String(500), nullable=False) is_correct = Column(Integer, nullable=False) + duration_seconds = Column(Integer, nullable=False, default=0) created_at = Column(String(32), nullable=False) diff --git a/backend/routers/auth_router.py b/backend/routers/auth_router.py index 30eb10e..e43faf3 100644 --- a/backend/routers/auth_router.py +++ b/backend/routers/auth_router.py @@ -46,7 +46,7 @@ def login(data: UserLogin, db: Session = Depends(get_db)): if not user or not verify_password(data.password, user.password_hash): raise HTTPException(status_code=401, detail="用户名或密码错误") - token = create_access_token(user.id, user.username) + token = create_access_token(user.id, user.username, remember=data.remember) return TokenResponse(access_token=token) diff --git a/backend/routers/quiz_router.py b/backend/routers/quiz_router.py index bc4d09b..4173fb1 100644 --- a/backend/routers/quiz_router.py +++ b/backend/routers/quiz_router.py @@ -24,6 +24,15 @@ def daily_quiz( return DailyQuizResponse(**result) +@router.get("/spell", response_model=DailyQuizResponse) +def spell_quiz( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + result = quiz_service.get_spell_quiz(db, current_user) + return DailyQuizResponse(**result) + + @router.post("/answer", response_model=QuizAnswerResponse) def submit_answer( data: QuizAnswerRequest, @@ -37,6 +46,7 @@ def submit_answer( data.question_type, data.user_answer, data.correct_answer, + data.duration_seconds or 0, ) return QuizAnswerResponse(**result) diff --git a/backend/routers/word_router.py b/backend/routers/word_router.py index e92e24b..06d02f5 100644 --- a/backend/routers/word_router.py +++ b/backend/routers/word_router.py @@ -6,7 +6,14 @@ from sqlalchemy.orm import Session from auth import get_current_user from database import get_db from models import User -from schemas import WordCreate, WordOut, WordUpdate +from schemas import ( + MemoryVisualizationResponse, + WordCreate, + WordMemoryDetailResponse, + WordOut, + WordUpdate, +) +from services.memory_visual_service import memory_visual_service from services.word_service import word_service router = APIRouter(prefix="/api/words", tags=["words"]) @@ -31,6 +38,23 @@ def list_words( return word_service.list_words(db, current_user, status) +@router.get("/memory-viz", response_model=MemoryVisualizationResponse) +def memory_visualization( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + return memory_visual_service.get_visualization(db, current_user) + + +@router.get("/{word_id}/memory", response_model=WordMemoryDetailResponse) +def word_memory( + word_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + return memory_visual_service.get_word_memory(db, current_user, word_id) + + @router.get("/{word_id}", response_model=WordOut) def get_word( word_id: int, diff --git a/backend/schemas.py b/backend/schemas.py index 16e4e21..8c54016 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -1,5 +1,5 @@ from typing import Optional -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, computed_field # Auth @@ -11,6 +11,7 @@ class UserRegister(BaseModel): class UserLogin(BaseModel): username: str password: str + remember: bool = True class TokenResponse(BaseModel): @@ -72,14 +73,104 @@ class WordOut(BaseModel): wrong_count: int consecutive_correct_count: int mastery_score: int + train_count: int = 0 + total_train_seconds: int = 0 review_due_date: Optional[str] = None last_reviewed_at: Optional[str] = None created_at: str + @computed_field # type: ignore[prop-decorator] + @property + def entered_at(self) -> str: + """词库进入时间(与 created_at 一致)。""" + return self.created_at + class Config: from_attributes = True +class MemoryCurvePoint(BaseModel): + day_index: int + date: str + forgetting: float + mastery: float + risk: float + + +class MemoryFutureRiskPoint(BaseModel): + day_offset: int + date: str + risk: float + + +class MemoryGraphNode(BaseModel): + id: str + label: str + zh: str + status: str + mastery: int + entered_at: str + size: int + + +class MemoryGraphLink(BaseModel): + source: str + target: str + kind: str + strength: float + + +class MemoryGraph(BaseModel): + nodes: list[MemoryGraphNode] + links: list[MemoryGraphLink] + + +class MemoryWordSummary(BaseModel): + id: int + en: str + zh: str + status: str + mastery_score: int + correct_count: int = 0 + wrong_count: int = 0 + train_count: int = 0 + total_train_seconds: int = 0 + entered_at: str + retention_now: float + risk_7d: float + + +class WordMemoryCurvePoint(BaseModel): + date: str + datetime: str + forgetting: float + mastery: float + risk: float + wrong_count: int + train_count: int + train_seconds: int + is_correct: Optional[bool] = None + + +class WordMemoryDetailResponse(BaseModel): + word_id: int + en: str + zh: str + correct_count: int + wrong_count: int + train_count: int + total_train_seconds: int + curve_points: list[WordMemoryCurvePoint] + future_risk: list[MemoryFutureRiskPoint] + + +class MemoryVisualizationResponse(BaseModel): + curve_points: list[MemoryCurvePoint] + future_risk: list[MemoryFutureRiskPoint] + words: list[MemoryWordSummary] + graph: MemoryGraph + + # Quiz class QuizOption(BaseModel): label: str @@ -90,8 +181,9 @@ class QuizQuestion(BaseModel): word_id: int question_type: str prompt: str - options: list[QuizOption] + options: list[QuizOption] = [] correct_answer: str + phonetic: Optional[str] = None class DailyQuizResponse(BaseModel): @@ -104,6 +196,7 @@ class QuizAnswerRequest(BaseModel): question_type: str user_answer: str correct_answer: str + duration_seconds: Optional[int] = Field(None, ge=0, le=3600) class QuizAnswerResponse(BaseModel): diff --git a/backend/scripts/seed_user_words.py b/backend/scripts/seed_user_words.py new file mode 100644 index 0000000..c0f6fa1 --- /dev/null +++ b/backend/scripts/seed_user_words.py @@ -0,0 +1,143 @@ +"""向用户词库批量导入单词(测试用)。 + +用法(backend 目录): + python -m scripts.seed_user_words --username john --count 200 + python -m scripts.seed_user_words --username john --count 200 --with-quiz 80 +""" + +from __future__ import annotations + +import argparse +import random +import sys +from datetime import datetime, timedelta, timezone +from pathlib import Path + +BACKEND_DIR = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(BACKEND_DIR)) + +from database import SessionLocal # noqa: E402 +from models import DictionaryEntry, QuizRecord, User, Word # noqa: E402 +from services.word_service import calc_mastery_score, utc_now_iso # noqa: E402 + + +def utc_now_iso_at(dt: datetime) -> str: + return dt.strftime("%Y-%m-%dT%H:%M:%SZ") + + +def seed_words( + db, + user: User, + count: int, + offset: int, +) -> tuple[int, int]: + existing_pairs = { + (w.source_text, w.target_text) + for w in db.query(Word).filter(Word.user_id == user.id).all() + } + entries = ( + db.query(DictionaryEntry) + .order_by(DictionaryEntry.id) + .offset(offset) + .limit(count * 3) + .all() + ) + added = 0 + skipped = 0 + now = utc_now_iso() + + for e in entries: + if added >= count: + break + zh = (e.zh or "").strip() + en = (e.lemma_en or "").strip().lower() + if not zh or not en: + skipped += 1 + continue + if (en, zh) in existing_pairs or (zh, en) in existing_pairs: + skipped += 1 + continue + + word = Word( + user_id=user.id, + source_text=zh, + target_text=en, + source_lang="zh", + target_lang="en", + phonetic=e.phonetic, + example_en=e.example_en, + example_cn=e.example_cn, + status=random.choice(["new", "new", "learning", "mastered", "weak"]), + correct_count=random.randint(0, 8), + wrong_count=random.randint(0, 4), + consecutive_correct_count=random.randint(0, 3), + mastery_score=0, + train_count=0, + total_train_seconds=0, + review_due_date=datetime.now(timezone.utc).strftime("%Y-%m-%d"), + created_at=now, + ) + word.mastery_score = calc_mastery_score(word.correct_count, word.wrong_count) + db.add(word) + existing_pairs.add((zh, en)) + added += 1 + + db.commit() + return added, skipped + + +def seed_quiz_records(db, user: User, max_records: int) -> int: + words = db.query(Word).filter(Word.user_id == user.id).all() + if not words: + return 0 + now = datetime.now(timezone.utc) + created = 0 + for i in range(max_records): + w = random.choice(words) + is_correct = random.random() > 0.35 + t = now - timedelta(days=random.randint(0, 20), hours=random.randint(0, 23)) + record = QuizRecord( + user_id=user.id, + word_id=w.id, + question_type=random.choice(["en_to_zh", "zh_to_en", "spell"]), + user_answer="test", + correct_answer="test", + is_correct=1 if is_correct else 0, + duration_seconds=random.randint(3, 45), + created_at=utc_now_iso_at(t), + ) + db.add(record) + created += 1 + db.commit() + return created + + +def main() -> None: + parser = argparse.ArgumentParser(description="批量导入用户单词") + parser.add_argument("--username", default="john") + parser.add_argument("--count", type=int, default=200) + parser.add_argument("--offset", type=int, default=5000, help="词典偏移,避免总取相同词") + parser.add_argument("--with-quiz", type=int, default=0, help="额外生成 N 条模拟训练记录") + args = parser.parse_args() + + db = SessionLocal() + try: + user = db.query(User).filter(User.username == args.username).first() + if not user: + print(f"用户不存在: {args.username}", file=sys.stderr) + sys.exit(1) + + before = db.query(Word).filter(Word.user_id == user.id).count() + added, skipped = seed_words(db, user, args.count, args.offset) + after = db.query(Word).filter(Word.user_id == user.id).count() + print(f"用户 {args.username}: 原有 {before} 词, 新增 {added}, 跳过 {skipped}, 现有 {after} 词") + + if args.with_quiz > 0: + n = seed_quiz_records(db, user, args.with_quiz) + print(f"已生成模拟训练记录 {n} 条") + finally: + db.close() + + +if __name__ == "__main__": + main() diff --git a/backend/services/memory_visual_service.py b/backend/services/memory_visual_service.py new file mode 100644 index 0000000..98f23ef --- /dev/null +++ b/backend/services/memory_visual_service.py @@ -0,0 +1,355 @@ +import math +from collections import defaultdict +from datetime import datetime, timedelta, timezone +from typing import Optional + +from sqlalchemy.orm import Session + +from models import QuizRecord, User, Word + + +def parse_iso(s: str) -> datetime: + s = s.replace("Z", "+00:00") + try: + return datetime.fromisoformat(s) + except ValueError: + return datetime.strptime(s[:19], "%Y-%m-%dT%H:%M:%S").replace(tzinfo=timezone.utc) + + +def word_en(w: Word) -> str: + return w.target_text if w.source_lang == "zh" else w.source_text + + +def word_zh(w: Word) -> str: + return w.source_text if w.source_lang == "zh" else w.target_text + + +def stability_hours(word: Word) -> float: + """记忆稳定性(小时),随练习增强。""" + base = 24.0 + streak = min(word.consecutive_correct_count, 6) + mastery_factor = 1 + word.mastery_score / 200 + return base * (1.6**streak) * mastery_factor + + +def retention_percent(hours_since: float, stability_h: float) -> float: + """艾宾浩斯型遗忘:R = 100 * e^(-t/S)""" + s = max(stability_h, 6.0) + return max(0.0, min(100.0, 100 * math.exp(-hours_since / s))) + + +def mastery_at_time(word: Word, records: list[QuizRecord], at: datetime) -> float: + correct = 0 + wrong = 0 + for r in records: + if r.word_id != word.id: + continue + if parse_iso(r.created_at) > at: + break + if r.is_correct: + correct += 1 + else: + wrong += 1 + total = correct + wrong + if total == 0: + return float(word.mastery_score) if word.last_reviewed_at else 0.0 + return round(correct / total * 100, 1) + + +class MemoryVisualService: + def get_visualization(self, db: Session, user: User, horizon_days: int = 30) -> dict: + words = db.query(Word).filter(Word.user_id == user.id).all() + records = ( + db.query(QuizRecord) + .filter(QuizRecord.user_id == user.id) + .order_by(QuizRecord.created_at.asc()) + .all() + ) + records_by_word: dict[int, list[QuizRecord]] = defaultdict(list) + for r in records: + records_by_word[r.word_id].append(r) + + now = datetime.now(timezone.utc) + horizon_days = max(7, min(horizon_days, 60)) + + if not words: + return { + "curve_points": [], + "words": [], + "graph": {"nodes": [], "links": []}, + } + + earliest = min(parse_iso(w.created_at) for w in words) + span_days = max( + 1, + int((now - earliest).total_seconds() // 86400) + 1, + ) + horizon = min(horizon_days, span_days) + + curve_points = [] + for d in range(horizon + 1): + t = earliest + timedelta(days=d) + forgetting_vals: list[float] = [] + mastery_vals: list[float] = [] + risk_vals: list[float] = [] + + for w in words: + entered = parse_iso(w.created_at) + if t < entered: + continue + + hours_after_enter = (t - entered).total_seconds() / 3600 + stab = stability_hours(w) + wr = records_by_word.get(w.id, []) + + # 遗忘曲线:自加入词库起的记忆保留率 + reviews_before = sum(1 for r in wr if parse_iso(r.created_at) <= t) + boost = 1 + reviews_before * 0.12 + forgetting_vals.append( + retention_percent(hours_after_enter, stab * boost) + ) + + # 熟练曲线:截至该日的掌握度 + mastery_vals.append(mastery_at_time(w, wr, t)) + + # 可能遗忘:从该日视角若不再复习的预测保留率 + last_at = entered + if w.last_reviewed_at: + lr = parse_iso(w.last_reviewed_at) + if lr <= t: + last_at = lr + hours_since_review = (t - last_at).total_seconds() / 3600 + risk_vals.append(retention_percent(hours_since_review, stab * 0.85)) + + if not forgetting_vals: + continue + + curve_points.append( + { + "day_index": d, + "date": t.strftime("%Y-%m-%d"), + "forgetting": round(sum(forgetting_vals) / len(forgetting_vals), 1), + "mastery": round(sum(mastery_vals) / len(mastery_vals), 1), + "risk": round(sum(risk_vals) / len(risk_vals), 1), + } + ) + + # 今日起未来 14 天风险预测 + future_risk = [] + for fd in range(15): + t = now + timedelta(days=fd) + vals = [] + for w in words: + last_at = parse_iso(w.last_reviewed_at or w.created_at) + hours = (t - last_at).total_seconds() / 3600 + vals.append(retention_percent(hours, stability_hours(w))) + future_risk.append( + { + "day_offset": fd, + "date": t.strftime("%Y-%m-%d"), + "risk": round(sum(vals) / len(vals), 1), + } + ) + + word_summaries = [] + for w in words: + stab_h = stability_hours(w) + last_at = parse_iso(w.last_reviewed_at or w.created_at) + hours_since = (now - last_at).total_seconds() / 3600 + word_summaries.append( + { + "id": w.id, + "en": word_en(w), + "zh": word_zh(w), + "status": w.status, + "mastery_score": w.mastery_score, + "correct_count": w.correct_count, + "wrong_count": w.wrong_count, + "train_count": w.train_count, + "total_train_seconds": w.total_train_seconds, + "entered_at": w.created_at, + "retention_now": round(retention_percent(hours_since, stab_h), 1), + "risk_7d": round( + retention_percent(hours_since + 7 * 24, stab_h), 1 + ), + } + ) + + graph = self._build_graph(words, records) + + return { + "curve_points": curve_points, + "future_risk": future_risk, + "words": word_summaries, + "graph": graph, + } + + def _build_graph(self, words: list[Word], records: list[QuizRecord]) -> dict: + nodes = [] + id_set = set() + for w in words: + id_set.add(w.id) + nodes.append( + { + "id": str(w.id), + "label": word_en(w)[:16], + "zh": word_zh(w)[:8], + "status": w.status, + "mastery": w.mastery_score, + "entered_at": w.created_at, + "size": 8 + min(w.correct_count + w.wrong_count, 20), + } + ) + + links = [] + seen_edges: set[tuple[str, str]] = set() + + def add_link(a: int, b: int, kind: str, strength: float = 0.5) -> None: + if a == b or a not in id_set or b not in id_set: + return + key = (str(min(a, b)), str(max(a, b))) + if key in seen_edges: + return + seen_edges.add(key) + links.append( + { + "source": str(a), + "target": str(b), + "kind": kind, + "strength": strength, + } + ) + + # 同日练习关联 + by_day: dict[str, list[int]] = defaultdict(list) + for r in records: + by_day[r.created_at[:10]].append(r.word_id) + for ids in by_day.values(): + unique = list(set(ids)) + for i in range(len(unique)): + for j in range(i + 1, len(unique)): + add_link(unique[i], unique[j], "co_review", 0.7) + + # 相同学习状态 + by_status: dict[str, list[int]] = defaultdict(list) + for w in words: + by_status[w.status].append(w.id) + for ids in by_status.values(): + for i in range(len(ids)): + for j in range(i + 1, min(i + 4, len(ids))): # 限制边数量 + add_link(ids[i], ids[j], "status", 0.35) + + # 词形相近(英文前缀 / 包含关系) + en_map = {w.id: word_en(w).lower() for w in words} + ids = list(en_map.keys()) + for i in range(len(ids)): + for j in range(i + 1, len(ids)): + a, b = en_map[ids[i]], en_map[ids[j]] + if len(a) >= 3 and len(b) >= 3: + if a[:3] == b[:3] or a in b or b in a: + add_link(ids[i], ids[j], "similar", 0.45) + + return {"nodes": nodes, "links": links[:120]} + + def build_word_curve_points(self, word: Word, records: list[QuizRecord]) -> list[dict]: + entered = parse_iso(word.created_at) + points: list[dict] = [ + { + "date": entered.strftime("%Y-%m-%d"), + "datetime": word.created_at, + "forgetting": 100.0, + "mastery": 0.0, + "risk": 100.0, + "wrong_count": 0, + "train_count": 0, + "train_seconds": 0, + "is_correct": None, + } + ] + if not records: + return points + + correct = 0 + wrong = 0 + total_sec = 0 + last_review = entered + + for i, r in enumerate(records): + t = parse_iso(r.created_at) + if r.is_correct: + correct += 1 + else: + wrong += 1 + total_sec += r.duration_seconds or 0 + + reviews = i + 1 + hours_enter = (t - entered).total_seconds() / 3600 + stab = stability_hours(word) + forgetting = retention_percent(hours_enter, stab * (1 + reviews * 0.12)) + total = correct + wrong + mastery = round(correct / total * 100, 1) if total else 0.0 + hours_since = (t - last_review).total_seconds() / 3600 + risk = retention_percent(hours_since, stab * 0.85) + last_review = t + + points.append( + { + "date": t.strftime("%Y-%m-%d"), + "datetime": r.created_at, + "forgetting": round(forgetting, 1), + "mastery": mastery, + "risk": round(risk, 1), + "wrong_count": wrong, + "train_count": i + 1, + "train_seconds": total_sec, + "is_correct": bool(r.is_correct), + } + ) + + return points + + def build_word_future_risk(self, word: Word) -> list[dict]: + now = datetime.now(timezone.utc) + last_at = parse_iso(word.last_reviewed_at or word.created_at) + hours_since = (now - last_at).total_seconds() / 3600 + stab = stability_hours(word) + future = [] + for fd in range(15): + t = now + timedelta(days=fd) + hours = hours_since + fd * 24 + future.append( + { + "day_offset": fd, + "date": t.strftime("%Y-%m-%d"), + "risk": round(retention_percent(hours, stab), 1), + } + ) + return future + + def get_word_memory(self, db: Session, user: User, word_id: int) -> dict: + word = db.query(Word).filter(Word.id == word_id, Word.user_id == user.id).first() + if not word: + from fastapi import HTTPException + + raise HTTPException(status_code=404, detail="单词不存在") + + records = ( + db.query(QuizRecord) + .filter(QuizRecord.user_id == user.id, QuizRecord.word_id == word.id) + .order_by(QuizRecord.created_at.asc()) + .all() + ) + return { + "word_id": word.id, + "en": word_en(word), + "zh": word_zh(word), + "correct_count": word.correct_count, + "wrong_count": word.wrong_count, + "train_count": word.train_count, + "total_train_seconds": word.total_train_seconds, + "curve_points": self.build_word_curve_points(word, records), + "future_risk": self.build_word_future_risk(word), + } + + +memory_visual_service = MemoryVisualService() diff --git a/backend/services/quiz_service.py b/backend/services/quiz_service.py index 970589f..0c7a475 100644 --- a/backend/services/quiz_service.py +++ b/backend/services/quiz_service.py @@ -118,6 +118,27 @@ class QuizService: correct_answer=correct, ) + def build_spell_question(self, word: Word) -> QuizQuestion: + zh = word.source_text if word.source_lang == "zh" else word.target_text + en = word.target_text if word.source_lang == "zh" else word.source_text + return QuizQuestion( + word_id=word.id, + question_type="spell", + prompt=zh, + phonetic=word.phonetic, + options=[], + correct_answer=en, + ) + + def get_spell_quiz(self, db: Session, user: User) -> dict: + settings = self.get_settings(db, user) + words = self.select_daily_words(db, user, settings.daily_target) + questions = [self.build_spell_question(w) for w in words] + return { + "questions": questions, + "total": len(questions), + } + def get_daily_quiz(self, db: Session, user: User) -> dict: settings = self.get_settings(db, user) words = self.select_daily_words(db, user, settings.daily_target) @@ -143,6 +164,7 @@ class QuizService: question_type: str, user_answer: str, correct_answer: str, + duration_seconds: int = 0, ) -> dict: word = db.query(Word).filter(Word.id == word_id, Word.user_id == user.id).first() if not word: @@ -150,9 +172,15 @@ class QuizService: raise HTTPException(status_code=404, detail="单词不存在") settings = self.get_settings(db, user) - is_correct = user_answer.strip() == correct_answer.strip() + if question_type == "spell": + is_correct = ( + user_answer.strip().lower() == correct_answer.strip().lower() + ) + else: + is_correct = user_answer.strip() == correct_answer.strip() now = utc_now_iso() today = today_str() + duration_seconds = max(0, min(int(duration_seconds or 0), 3600)) if is_correct: word.correct_count += 1 @@ -175,6 +203,8 @@ class QuizService: word.mastery_score = calc_mastery_score(word.correct_count, word.wrong_count) word.last_reviewed_at = now + word.train_count += 1 + word.total_train_seconds += duration_seconds record = QuizRecord( user_id=user.id, @@ -183,6 +213,7 @@ class QuizService: user_answer=user_answer, correct_answer=correct_answer, is_correct=1 if is_correct else 0, + duration_seconds=duration_seconds, created_at=now, ) db.add(record) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index de5b6d8..9a5fdd9 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "dependencies": { "axios": "^1.7.9", + "echarts": "^6.1.0", "vue": "^3.5.13", "vue-router": "^4.5.0" }, @@ -1226,6 +1227,16 @@ "node": ">= 0.4" } }, + "node_modules/echarts": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/echarts/-/echarts-6.1.0.tgz", + "integrity": "sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "2.3.0", + "zrender": "6.1.0" + } + }, "node_modules/entities": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", @@ -1740,6 +1751,12 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" + }, "node_modules/typescript": { "version": "5.6.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", @@ -1888,6 +1905,15 @@ "peerDependencies": { "typescript": ">=5.0.0" } + }, + "node_modules/zrender": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/zrender/-/zrender-6.1.0.tgz", + "integrity": "sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==", + "license": "BSD-3-Clause", + "dependencies": { + "tslib": "2.3.0" + } } } } diff --git a/frontend/package.json b/frontend/package.json index f14d91f..c9b3e44 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,6 +10,7 @@ }, "dependencies": { "axios": "^1.7.9", + "echarts": "^6.1.0", "vue": "^3.5.13", "vue-router": "^4.5.0" }, diff --git a/frontend/src/api/request.ts b/frontend/src/api/request.ts index 9f6a101..f0142a9 100644 --- a/frontend/src/api/request.ts +++ b/frontend/src/api/request.ts @@ -1,4 +1,5 @@ import axios from 'axios' +import { clearAuth, getToken } from '../utils/auth' const request = axios.create({ baseURL: '/api', @@ -6,7 +7,7 @@ const request = axios.create({ }) request.interceptors.request.use((config) => { - const token = localStorage.getItem('token') + const token = getToken() if (token) { config.headers.Authorization = `Bearer ${token}` } @@ -17,7 +18,7 @@ request.interceptors.response.use( (res) => res, (err) => { if (err.response?.status === 401) { - localStorage.removeItem('token') + clearAuth() if (!window.location.pathname.includes('/login')) { window.location.href = '/login' } @@ -42,9 +43,89 @@ export interface Word { wrong_count: number consecutive_correct_count: number mastery_score: number + train_count?: number + total_train_seconds?: number review_due_date?: string last_reviewed_at?: string created_at: string + entered_at: string +} + +export interface MemoryCurvePoint { + day_index: number + date: string + forgetting: number + mastery: number + risk: number +} + +export interface MemoryFutureRiskPoint { + day_offset: number + date: string + risk: number +} + +export interface MemoryGraphNode { + id: string + label: string + zh: string + status: string + mastery: number + entered_at: string + size: number +} + +export interface MemoryGraphLink { + source: string + target: string + kind: string + strength: number +} + +export interface MemoryWordSummary { + id: number + en: string + zh: string + status: string + mastery_score: number + correct_count: number + wrong_count: number + train_count: number + total_train_seconds: number + entered_at: string + retention_now: number + risk_7d: number +} + +export interface WordMemoryCurvePoint { + date: string + datetime: string + forgetting: number + mastery: number + risk: number + wrong_count: number + train_count: number + train_seconds: number + is_correct?: boolean | null +} + +export interface WordMemoryDetail { + word_id: number + en: string + zh: string + correct_count: number + wrong_count: number + train_count: number + total_train_seconds: number + curve_points: WordMemoryCurvePoint[] + future_risk: MemoryFutureRiskPoint[] +} + +export interface MemoryVisualization { + curve_points: MemoryCurvePoint[] + future_risk: MemoryFutureRiskPoint[] + words: MemoryWordSummary[] + graph: { nodes: MemoryGraphNode[]; links: MemoryGraphLink[] } } export interface TranslateResult { @@ -68,6 +149,7 @@ export interface QuizQuestion { prompt: string options: QuizOption[] correct_answer: string + phonetic?: string } export interface QuizStats { @@ -93,21 +175,26 @@ export interface Settings { export const api = { register: (username: string, password: string) => request.post('/auth/register', { username, password }), - login: (username: string, password: string) => - request.post<{ access_token: string }>('/auth/login', { username, password }), + login: (username: string, password: string, remember = true) => + request.post<{ access_token: string }>('/auth/login', { username, password, remember }), me: () => request.get('/auth/me'), translate: (text: string) => request.post('/translate', { text }), createWord: (data: Partial) => request.post('/words', data), listWords: (status?: string) => request.get('/words', { params: status ? { status } : {} }), + memoryViz: () => request.get('/words/memory-viz'), + wordMemory: (id: number) => request.get(`/words/${id}/memory`), deleteWord: (id: number) => request.delete(`/words/${id}`), dailyQuiz: () => request.get<{ questions: QuizQuestion[]; total: number }>('/quiz/daily'), + spellQuiz: () => + request.get<{ questions: QuizQuestion[]; total: number }>('/quiz/spell'), submitAnswer: (data: { word_id: number question_type: string user_answer: string correct_answer: string + duration_seconds?: number }) => request.post('/quiz/answer', data), quizStats: () => request.get('/quiz/stats'), getSettings: () => request.get('/settings'), diff --git a/frontend/src/components/MemoryCurveChart.vue b/frontend/src/components/MemoryCurveChart.vue new file mode 100644 index 0000000..6eb94ca --- /dev/null +++ b/frontend/src/components/MemoryCurveChart.vue @@ -0,0 +1,116 @@ + + + + + diff --git a/frontend/src/components/SpellCard.vue b/frontend/src/components/SpellCard.vue new file mode 100644 index 0000000..f62af47 --- /dev/null +++ b/frontend/src/components/SpellCard.vue @@ -0,0 +1,136 @@ + + + + + diff --git a/frontend/src/components/WordCard.vue b/frontend/src/components/WordCard.vue index 986a9cb..d17d357 100644 --- a/frontend/src/components/WordCard.vue +++ b/frontend/src/components/WordCard.vue @@ -1,5 +1,6 @@ @@ -64,9 +74,14 @@ function zhText(word: Word) { color: var(--muted); margin-top: 10px; } +.word-entered { + font-size: 12px; + color: var(--muted); + margin-top: 8px; +} .word-due { font-size: 12px; color: var(--primary); - margin-top: 6px; + margin-top: 4px; } diff --git a/frontend/src/components/WordGraphCanvas.vue b/frontend/src/components/WordGraphCanvas.vue new file mode 100644 index 0000000..7ea205c --- /dev/null +++ b/frontend/src/components/WordGraphCanvas.vue @@ -0,0 +1,335 @@ + + + + + diff --git a/frontend/src/composables/useQuizSession.ts b/frontend/src/composables/useQuizSession.ts new file mode 100644 index 0000000..c5f752e --- /dev/null +++ b/frontend/src/composables/useQuizSession.ts @@ -0,0 +1,245 @@ +import { onBeforeUnmount, onMounted } from 'vue' +import { onBeforeRouteLeave } from 'vue-router' +import type { QuizQuestion } from '../api/request' +import { getToken } from '../utils/auth' + +export type QuizMode = 'daily' | 'spell' + +export interface QuizSessionSnapshot { + questions: QuizQuestion[] + currentIndex: number + sessionCorrect: number + sessionWrong: number + finished: boolean + selected?: string + showResult?: boolean + isCorrect?: boolean + submittedAnswer?: string +} + +interface StoredSession extends QuizSessionSnapshot { + date: string + updatedAt: number +} + +export interface PendingAnswer { + word_id: number + question_type: string + user_answer: string + correct_answer: string + duration_seconds?: number +} + +function sessionKey(mode: QuizMode) { + return `wordloop_quiz_session_${mode}` +} + +function pendingKey(mode: QuizMode) { + return `wordloop_quiz_pending_${mode}` +} + +function todayStr() { + return new Date().toISOString().slice(0, 10) +} + +function readJson(key: string): T | null { + try { + const raw = localStorage.getItem(key) + if (!raw) return null + return JSON.parse(raw) as T + } catch { + return null + } +} + +function writeJson(key: string, value: unknown) { + try { + localStorage.setItem(key, JSON.stringify(value)) + } catch { + /* quota or private mode */ + } +} + +export function saveQuizSession(mode: QuizMode, snapshot: QuizSessionSnapshot) { + if (!snapshot.questions.length) return + const stored: StoredSession = { + ...snapshot, + date: todayStr(), + updatedAt: Date.now(), + } + writeJson(sessionKey(mode), stored) +} + +export function loadQuizSession(mode: QuizMode): StoredSession | null { + const stored = readJson(sessionKey(mode)) + if (!stored?.questions?.length) return null + if (stored.date !== todayStr()) { + clearQuizSession(mode) + return null + } + return stored +} + +export function clearQuizSession(mode: QuizMode) { + localStorage.removeItem(sessionKey(mode)) + localStorage.removeItem(pendingKey(mode)) +} + +function getPendingList(mode: QuizMode): PendingAnswer[] { + return readJson(pendingKey(mode)) ?? [] +} + +function setPendingList(mode: QuizMode, list: PendingAnswer[]) { + if (list.length === 0) { + localStorage.removeItem(pendingKey(mode)) + } else { + writeJson(pendingKey(mode), list) + } +} + +export function enqueuePendingAnswer(mode: QuizMode, payload: PendingAnswer) { + const list = getPendingList(mode) + const exists = list.some( + (p) => p.word_id === payload.word_id && p.question_type === payload.question_type + ) + if (!exists) { + list.push(payload) + setPendingList(mode, list) + } +} + +function postAnswerKeepalive(payload: PendingAnswer) { + const token = getToken() + if (!token) return + fetch('/api/quiz/answer', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(payload), + keepalive: true, + }).catch(() => {}) +} + +export async function flushPendingAnswers(mode: QuizMode): Promise { + const list = getPendingList(mode) + if (!list.length) return + + const remaining: PendingAnswer[] = [] + for (const item of list) { + try { + const token = getToken() + const res = await fetch('/api/quiz/answer', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + body: JSON.stringify(item), + }) + if (!res.ok) remaining.push(item) + } catch { + remaining.push(item) + } + } + setPendingList(mode, remaining) +} + +export function flushPendingAnswersKeepalive(mode: QuizMode) { + for (const item of getPendingList(mode)) { + postAnswerKeepalive(item) + } + localStorage.removeItem(pendingKey(mode)) +} + +export async function submitQuizAnswer( + mode: QuizMode, + payload: PendingAnswer +): Promise<{ ok: boolean; is_correct?: boolean; correct_answer?: string }> { + const token = getToken() + try { + const res = await fetch('/api/quiz/answer', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + body: JSON.stringify(payload), + }) + if (!res.ok) throw new Error('submit failed') + const data = await res.json() + return { ok: true, is_correct: data.is_correct, correct_answer: data.correct_answer } + } catch { + enqueuePendingAnswer(mode, payload) + return { ok: false } + } +} + +export interface QuizAutoSaveOptions { + /** 拼写题:退出时若有未提交输入则自动提交 */ + getDraftAnswer?: () => string + onDraftSubmit?: (answer: string) => void | Promise +} + +export function useQuizAutoSave( + mode: QuizMode, + getSnapshot: () => QuizSessionSnapshot | null, + options: QuizAutoSaveOptions = {} +) { + const persist = () => { + const snap = getSnapshot() + if (!snap) return + if (snap.finished) { + clearQuizSession(mode) + return + } + saveQuizSession(mode, snap) + } + + const flushDraft = async () => { + const draft = options.getDraftAnswer?.()?.trim() + if (!draft || !options.onDraftSubmit) return + await options.onDraftSubmit(draft) + } + + const onInterrupt = async () => { + await flushDraft() + persist() + await flushPendingAnswers(mode) + } + + const onInterruptSync = () => { + const draft = options.getDraftAnswer?.()?.trim() + if (draft && options.onDraftSubmit) { + void options.onDraftSubmit(draft) + } + persist() + flushPendingAnswersKeepalive(mode) + } + + onMounted(() => { + const handleVisibility = () => { + if (document.visibilityState === 'hidden') void onInterrupt() + } + const handlePageHide = () => onInterruptSync() + const handleBeforeUnload = () => onInterruptSync() + + document.addEventListener('visibilitychange', handleVisibility) + window.addEventListener('pagehide', handlePageHide) + window.addEventListener('beforeunload', handleBeforeUnload) + + onBeforeUnmount(() => { + document.removeEventListener('visibilitychange', handleVisibility) + window.removeEventListener('pagehide', handlePageHide) + window.removeEventListener('beforeunload', handleBeforeUnload) + void onInterrupt() + }) + }) + + onBeforeRouteLeave(async () => { + await onInterrupt() + }) + + return { persist, flushPending: () => flushPendingAnswers(mode) } +} diff --git a/frontend/src/composables/useQuizTimer.ts b/frontend/src/composables/useQuizTimer.ts new file mode 100644 index 0000000..dd95023 --- /dev/null +++ b/frontend/src/composables/useQuizTimer.ts @@ -0,0 +1,26 @@ +import { ref } from 'vue' + +const startedAt = ref(null) + +export function useQuizTimer() { + function startQuestionTimer() { + startedAt.value = Date.now() + } + + function consumeDurationSeconds(): number { + if (startedAt.value == null) return 0 + const sec = Math.round((Date.now() - startedAt.value) / 1000) + startedAt.value = null + if (sec <= 0) return 1 + return Math.min(sec, 3600) + } + + return { startQuestionTimer, consumeDurationSeconds } +} + +export function formatTrainSeconds(total: number): string { + if (total < 60) return `${total} 秒` + const m = Math.floor(total / 60) + const s = total % 60 + return s ? `${m} 分 ${s} 秒` : `${m} 分钟` +} diff --git a/frontend/src/pages/DailyQuiz.vue b/frontend/src/pages/DailyQuiz.vue index 8f8f486..4238a39 100644 --- a/frontend/src/pages/DailyQuiz.vue +++ b/frontend/src/pages/DailyQuiz.vue @@ -1,7 +1,17 @@ diff --git a/frontend/src/pages/SpellQuiz.vue b/frontend/src/pages/SpellQuiz.vue new file mode 100644 index 0000000..9aceb5c --- /dev/null +++ b/frontend/src/pages/SpellQuiz.vue @@ -0,0 +1,204 @@ + + + + + diff --git a/frontend/src/pages/WordLibrary.vue b/frontend/src/pages/WordLibrary.vue index ffbcdc3..78a13ac 100644 --- a/frontend/src/pages/WordLibrary.vue +++ b/frontend/src/pages/WordLibrary.vue @@ -1,7 +1,27 @@ + + + + diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index a1707fd..58d73ec 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -1,4 +1,5 @@ import { createRouter, createWebHistory } from 'vue-router' +import { getToken } from '../utils/auth' const router = createRouter({ history: createWebHistory(), @@ -14,6 +15,7 @@ const router = createRouter({ { path: 'translate', name: 'Translate', component: () => import('../pages/Translate.vue') }, { path: 'words', name: 'WordLibrary', component: () => import('../pages/WordLibrary.vue') }, { path: 'quiz', name: 'DailyQuiz', component: () => import('../pages/DailyQuiz.vue') }, + { path: 'spell', name: 'SpellQuiz', component: () => import('../pages/SpellQuiz.vue') }, { path: 'settings', name: 'Settings', component: () => import('../pages/Settings.vue') }, ], }, @@ -21,7 +23,7 @@ const router = createRouter({ }) router.beforeEach((to, _from, next) => { - const token = localStorage.getItem('token') + const token = getToken() if (to.meta.requiresAuth && !token) { next('/login') } else if ((to.path === '/login' || to.path === '/register') && token) { diff --git a/frontend/src/utils/auth.ts b/frontend/src/utils/auth.ts new file mode 100644 index 0000000..76e8a50 --- /dev/null +++ b/frontend/src/utils/auth.ts @@ -0,0 +1,59 @@ +const TOKEN_KEY = 'token' +const REMEMBER_KEY = 'wordloop_remember' +const USERNAME_KEY = 'wordloop_username' + +/** 未设置过时默认勾选「记住登录」 */ +export function getRememberPreference(): boolean { + const v = localStorage.getItem(REMEMBER_KEY) + if (v === null) return true + return v === '1' +} + +export function setRememberPreference(remember: boolean) { + localStorage.setItem(REMEMBER_KEY, remember ? '1' : '0') +} + +export function getSavedUsername(): string { + return localStorage.getItem(USERNAME_KEY) || '' +} + +function clearTokenStores() { + localStorage.removeItem(TOKEN_KEY) + sessionStorage.removeItem(TOKEN_KEY) +} + +export function getToken(): string | null { + if (getRememberPreference()) { + return localStorage.getItem(TOKEN_KEY) + } + return sessionStorage.getItem(TOKEN_KEY) +} + +export function setAuth(token: string, remember: boolean, username?: string) { + setRememberPreference(remember) + clearTokenStores() + if (remember) { + localStorage.setItem(TOKEN_KEY, token) + } else { + sessionStorage.setItem(TOKEN_KEY, token) + } + if (username) { + localStorage.setItem(USERNAME_KEY, username) + } +} + +/** 退出登录:清除凭证,保留用户名与「记住」偏好供下次登录 */ +export function clearAuth() { + clearTokenStores() +} + +/** 完全清除本地登录相关数据 */ +export function clearAuthAll() { + clearTokenStores() + localStorage.removeItem(REMEMBER_KEY) + localStorage.removeItem(USERNAME_KEY) +} + +export function isLoggedIn(): boolean { + return !!getToken() +}