Add spell practice, memory analytics, auth persistence, and word library UX.

Includes per-word training stats and curves, quiz session auto-save, remember-login,
paginated word list with floating page arrows, and Obsidian-style relationship graph baseline.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
John
2026-06-04 14:57:39 -07:00
parent bd7635986a
commit 68c5efe573
30 changed files with 2560 additions and 63 deletions
+3 -1
View File
@@ -156,7 +156,9 @@ wordloop/
- 用户注册 / 登录(JWT
- 中英互译(本地词典 MVP,可扩展 AI)
- 个人单词库(新词 / 学习中 / 已掌握 / 易错词)
- 个人单词库(新词 / 学习中 / 已掌握 / 易错词,记录进入词库时间
- 记忆曲线与 Obsidian 式单词关系力导向图
- 每日选择题训练
- 拼写练习(中文释义 + 音标,输入英文单词)
- 掌握规则与复习间隔
- 学习统计与设置
+8 -3
View File
@@ -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)
+52
View File
@@ -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
)
"""
)
)
+2
View File
@@ -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")
+3
View File
@@ -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)
+1 -1
View File
@@ -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)
+10
View File
@@ -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)
+25 -1
View File
@@ -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,
+95 -2
View File
@@ -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):
+143
View File
@@ -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()
+355
View File
@@ -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()
+32 -1
View File
@@ -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)
+26
View File
@@ -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"
}
}
}
}
+1
View File
@@ -10,6 +10,7 @@
},
"dependencies": {
"axios": "^1.7.9",
"echarts": "^6.1.0",
"vue": "^3.5.13",
"vue-router": "^4.5.0"
},
+91 -4
View File
@@ -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<TranslateResult>('/translate', { text }),
createWord: (data: Partial<Word>) => request.post<Word>('/words', data),
listWords: (status?: string) =>
request.get<Word[]>('/words', { params: status ? { status } : {} }),
memoryViz: () => request.get<MemoryVisualization>('/words/memory-viz'),
wordMemory: (id: number) => request.get<WordMemoryDetail>(`/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<QuizStats>('/quiz/stats'),
getSettings: () => request.get<Settings>('/settings'),
@@ -0,0 +1,116 @@
<script setup lang="ts">
import * as echarts from 'echarts'
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import type { MemoryCurvePoint, MemoryFutureRiskPoint } from '../api/request'
const props = defineProps<{
curvePoints: MemoryCurvePoint[]
futureRisk: MemoryFutureRiskPoint[]
}>()
const chartRef = ref<HTMLDivElement | null>(null)
let chart: echarts.ECharts | null = null
function render() {
if (!chartRef.value) return
if (!chart) chart = echarts.init(chartRef.value)
const dates = props.curvePoints.map((p) => p.date)
const futureDates = props.futureRisk.map((p) => p.date)
const allDates = [...dates, ...futureDates.slice(1)]
const forgetting = props.curvePoints.map((p) => p.forgetting)
const mastery = props.curvePoints.map((p) => p.mastery)
const riskHist = props.curvePoints.map((p) => p.risk)
const riskFuture = [
...Array(Math.max(0, dates.length - 1)).fill(null),
props.curvePoints.length ? props.curvePoints[props.curvePoints.length - 1].risk : null,
...props.futureRisk.map((p) => p.risk),
]
chart.setOption({
tooltip: { trigger: 'axis' },
legend: {
data: ['遗忘曲线', '熟练曲线', '可能遗忘(历史)', '可能遗忘(预测)'],
bottom: 0,
textStyle: { fontSize: 11 },
},
grid: { left: 48, right: 16, top: 24, bottom: 56 },
xAxis: {
type: 'category',
data: allDates,
axisLabel: { rotate: 35, fontSize: 10 },
},
yAxis: {
type: 'value',
min: 0,
max: 100,
name: '记忆指数 %',
nameTextStyle: { fontSize: 11 },
},
series: [
{
name: '遗忘曲线',
type: 'line',
smooth: true,
data: [...forgetting, ...Array(futureDates.length).fill(null)],
lineStyle: { color: '#ef4444', width: 2 },
itemStyle: { color: '#ef4444' },
areaStyle: { color: 'rgba(239,68,68,0.08)' },
},
{
name: '熟练曲线',
type: 'line',
smooth: true,
data: [...mastery, ...Array(futureDates.length).fill(null)],
lineStyle: { color: '#22c55e', width: 2 },
itemStyle: { color: '#22c55e' },
},
{
name: '可能遗忘(历史)',
type: 'line',
smooth: true,
data: [...riskHist, ...Array(futureDates.length).fill(null)],
lineStyle: { color: '#f59e0b', width: 2, type: 'dashed' },
itemStyle: { color: '#f59e0b' },
},
{
name: '可能遗忘(预测)',
type: 'line',
smooth: true,
data: riskFuture,
lineStyle: { color: '#a855f7', width: 2, type: 'dotted' },
itemStyle: { color: '#a855f7' },
},
],
})
}
function onResize() {
chart?.resize()
}
onMounted(() => {
render()
window.addEventListener('resize', onResize)
})
watch(() => [props.curvePoints, props.futureRisk], render, { deep: true })
onBeforeUnmount(() => {
window.removeEventListener('resize', onResize)
chart?.dispose()
chart = null
})
</script>
<template>
<div ref="chartRef" class="memory-chart" />
</template>
<style scoped>
.memory-chart {
width: 100%;
height: 280px;
}
</style>
+136
View File
@@ -0,0 +1,136 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import type { QuizQuestion } from '../api/request'
const props = defineProps<{
question: QuizQuestion
index: number
total: number
showResult?: boolean
isCorrect?: boolean
submittedAnswer?: string
}>()
const emit = defineEmits<{
submit: [answer: string]
}>()
const input = ref('')
watch(
() => props.index,
() => {
input.value = ''
}
)
function onSubmit() {
const answer = input.value.trim()
if (!answer || props.showResult) return
emit('submit', answer)
}
defineExpose({
getDraft: () => input.value.trim(),
})
</script>
<template>
<div class="card quiz-card">
<div class="quiz-progress">{{ index + 1 }} / {{ total }}</div>
<div class="quiz-type">根据中文与读音拼写英文</div>
<div class="quiz-prompt">{{ question.prompt }}</div>
<div v-if="question.phonetic" class="phonetic">{{ question.phonetic }}</div>
<div v-else class="phonetic muted">暂无音标</div>
<input
v-model="input"
class="spell-input"
type="text"
placeholder="输入英文单词"
autocomplete="off"
autocapitalize="off"
spellcheck="false"
:disabled="showResult"
@keydown.enter="onSubmit"
/>
<button
v-if="!showResult"
class="btn btn-primary submit-btn"
:disabled="!input.trim()"
@click="onSubmit"
>
提交
</button>
<div v-if="showResult" class="result" :class="isCorrect ? 'ok' : 'fail'">
{{ isCorrect ? '拼写正确 ' : '拼写错误 ' }}
<template v-if="!isCorrect">
你的答案{{ submittedAnswer }} · 正确答案{{ question.correct_answer }}
</template>
</div>
</div>
</template>
<style scoped>
.quiz-progress {
font-size: 13px;
color: var(--muted);
margin-bottom: 8px;
}
.quiz-type {
font-size: 12px;
color: var(--primary);
margin-bottom: 8px;
}
.quiz-prompt {
font-size: 28px;
font-weight: 700;
text-align: center;
margin: 20px 0 8px;
}
.phonetic {
text-align: center;
font-size: 18px;
color: var(--primary);
margin-bottom: 20px;
}
.phonetic.muted {
color: var(--muted);
font-size: 14px;
}
.spell-input {
width: 100%;
padding: 14px 16px;
border: 1px solid var(--border);
border-radius: var(--radius);
font-size: 18px;
text-align: center;
box-sizing: border-box;
}
.spell-input:focus {
outline: none;
border-color: var(--primary);
}
.spell-input:disabled {
background: #f3f4f6;
}
.submit-btn {
width: 100%;
margin-top: 12px;
}
.result {
margin-top: 16px;
padding: 12px;
border-radius: var(--radius);
font-size: 14px;
text-align: center;
line-height: 1.5;
}
.result.ok {
background: #d1fae5;
color: #047857;
}
.result.fail {
background: #fee2e2;
color: #b91c1c;
}
</style>
+16 -1
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import type { Word } from '../api/request'
import { formatTrainSeconds } from '../composables/useQuizTimer'
defineProps<{
word: Word
@@ -23,6 +24,12 @@ function enText(word: Word) {
function zhText(word: Word) {
return word.source_lang === 'zh' ? word.source_text : word.target_text
}
function formatEnteredAt(iso: string) {
if (!iso) return '—'
const d = iso.replace('T', ' ').replace('Z', '')
return d.length >= 16 ? d.slice(0, 16) : d.slice(0, 10)
}
</script>
<template>
@@ -35,11 +42,14 @@ function zhText(word: Word) {
<button class="btn btn-danger" @click="$emit('delete', word.id)">删除</button>
</div>
<div class="word-meta">
<span>训练 {{ word.train_count ?? 0 }} </span>
<span>答对 {{ word.correct_count }}</span>
<span>答错 {{ word.wrong_count }}</span>
<span>连续 {{ word.consecutive_correct_count }}</span>
<span>掌握率 {{ word.mastery_score }}%</span>
<span v-if="word.total_train_seconds">用时 {{ formatTrainSeconds(word.total_train_seconds) }}</span>
</div>
<div class="word-entered">进入词库{{ formatEnteredAt(word.entered_at || word.created_at) }}</div>
<div v-if="word.review_due_date" class="word-due">下次复习{{ word.review_due_date }}</div>
</div>
</template>
@@ -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;
}
</style>
+335
View File
@@ -0,0 +1,335 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import type { MemoryGraphLink, MemoryGraphNode } from '../api/request'
const props = defineProps<{
nodes: MemoryGraphNode[]
links: MemoryGraphLink[]
}>()
const emit = defineEmits<{
select: [id: string]
}>()
const canvasRef = ref<HTMLCanvasElement | null>(null)
const selectedId = ref<string | null>(null)
const cursorStyle = ref('default')
interface SimNode extends MemoryGraphNode {
x: number
y: number
vx: number
vy: number
pinned?: boolean
}
let simNodes: SimNode[] = []
let animId = 0
let width = 0
let height = 0
let draggingId: string | null = null
let dragOffsetX = 0
let dragOffsetY = 0
let pointerMoved = false
const statusColor: Record<string, string> = {
new: '#94a3b8',
learning: '#4f6ef7',
mastered: '#22c55e',
weak: '#ef4444',
}
function nodeRadius(n: SimNode) {
return 6 + n.size * 0.25
}
function clampPos(x: number, y: number) {
return {
x: Math.max(24, Math.min(width - 24, x)),
y: Math.max(24, Math.min(height - 24, y)),
}
}
function pointerPos(e: PointerEvent) {
const canvas = canvasRef.value!
const rect = canvas.getBoundingClientRect()
return {
x: e.clientX - rect.left,
y: e.clientY - rect.top,
}
}
function hitNode(x: number, y: number): SimNode | null {
for (let i = simNodes.length - 1; i >= 0; i--) {
const n = simNodes[i]
const r = nodeRadius(n) + 6
if ((x - n.x) ** 2 + (y - n.y) ** 2 <= r * r) return n
}
return null
}
function initSim() {
simNodes = props.nodes.map((n, i) => {
const angle = (i / Math.max(props.nodes.length, 1)) * Math.PI * 2
const r = Math.min(width, height) * 0.28
return {
...n,
x: width / 2 + Math.cos(angle) * r,
y: height / 2 + Math.sin(angle) * r,
vx: 0,
vy: 0,
}
})
}
function tick() {
const centerX = width / 2
const centerY = height / 2
for (let i = 0; i < simNodes.length; i++) {
for (let j = i + 1; j < simNodes.length; j++) {
const a = simNodes[i]
const b = simNodes[j]
if (a.id === draggingId || b.id === draggingId) continue
let dx = a.x - b.x
let dy = a.y - b.y
let dist = Math.sqrt(dx * dx + dy * dy) || 1
const repulse = (12000 / (dist * dist)) * 0.016
dx /= dist
dy /= dist
a.vx += dx * repulse
a.vy += dy * repulse
b.vx -= dx * repulse
b.vy -= dy * repulse
}
}
for (const l of props.links) {
const a = simNodes.find((n) => n.id === l.source)
const b = simNodes.find((n) => n.id === l.target)
if (!a || !b) continue
if (a.id === draggingId) {
const dx = b.x - a.x
const dy = b.y - a.y
const dist = Math.sqrt(dx * dx + dy * dy) || 1
const pull = (dist - 90) * 0.004 * l.strength
b.vx -= (dx / dist) * pull * 0.5
b.vy -= (dy / dist) * pull * 0.5
continue
}
if (b.id === draggingId) {
const dx = b.x - a.x
const dy = b.y - a.y
const dist = Math.sqrt(dx * dx + dy * dy) || 1
const pull = (dist - 90) * 0.004 * l.strength
a.vx += (dx / dist) * pull * 0.5
a.vy += (dy / dist) * pull * 0.5
continue
}
const dx = b.x - a.x
const dy = b.y - a.y
const dist = Math.sqrt(dx * dx + dy * dy) || 1
const pull = (dist - 90) * 0.004 * l.strength
a.vx += (dx / dist) * pull
a.vy += (dy / dist) * pull
b.vx -= (dx / dist) * pull
b.vy -= (dy / dist) * pull
}
for (const n of simNodes) {
if (n.id === draggingId) continue
n.vx += (centerX - n.x) * 0.0008
n.vy += (centerY - n.y) * 0.0008
n.vx *= 0.86
n.vy *= 0.86
n.x += n.vx
n.y += n.vy
const c = clampPos(n.x, n.y)
n.x = c.x
n.y = c.y
}
}
function draw() {
const canvas = canvasRef.value
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
ctx.clearRect(0, 0, width, height)
for (const l of props.links) {
const a = simNodes.find((n) => n.id === l.source)
const b = simNodes.find((n) => n.id === l.target)
if (!a || !b) continue
ctx.beginPath()
ctx.moveTo(a.x, a.y)
ctx.lineTo(b.x, b.y)
ctx.strokeStyle =
l.kind === 'co_review' ? 'rgba(79,110,247,0.35)' : 'rgba(148,163,184,0.25)'
ctx.lineWidth = l.kind === 'co_review' ? 1.5 : 1
ctx.stroke()
}
for (const n of simNodes) {
const r = nodeRadius(n)
const color = statusColor[n.status] || '#64748b'
ctx.beginPath()
ctx.arc(n.x, n.y, r, 0, Math.PI * 2)
ctx.fillStyle = n.id === selectedId.value ? color : color + 'cc'
ctx.fill()
if (n.id === selectedId.value) {
ctx.strokeStyle = '#1e293b'
ctx.lineWidth = 2
ctx.stroke()
}
ctx.fillStyle = '#1e293b'
ctx.font = '10px system-ui'
ctx.textAlign = 'center'
ctx.fillText(n.label, n.x, n.y + r + 11)
}
}
function loop() {
tick()
draw()
animId = requestAnimationFrame(loop)
}
function resize() {
const canvas = canvasRef.value
if (!canvas?.parentElement) return
width = canvas.parentElement.clientWidth
height = 320
canvas.width = width * devicePixelRatio
canvas.height = height * devicePixelRatio
canvas.style.width = `${width}px`
canvas.style.height = `${height}px`
const ctx = canvas.getContext('2d')
if (ctx) ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0)
}
function onPointerDown(e: PointerEvent) {
const { x, y } = pointerPos(e)
const hit = hitNode(x, y)
if (!hit) return
draggingId = hit.id
dragOffsetX = x - hit.x
dragOffsetY = y - hit.y
hit.vx = 0
hit.vy = 0
pointerMoved = false
selectedId.value = hit.id
emit('select', hit.id)
cursorStyle.value = 'grabbing'
canvasRef.value?.setPointerCapture(e.pointerId)
e.preventDefault()
}
function onPointerMove(e: PointerEvent) {
const { x, y } = pointerPos(e)
if (draggingId) {
const n = simNodes.find((node) => node.id === draggingId)
if (n) {
pointerMoved = true
const c = clampPos(x - dragOffsetX, y - dragOffsetY)
n.x = c.x
n.y = c.y
n.vx = 0
n.vy = 0
}
e.preventDefault()
return
}
cursorStyle.value = hitNode(x, y) ? 'grab' : 'default'
}
function onPointerUp(e: PointerEvent) {
if (draggingId) {
const n = simNodes.find((node) => node.id === draggingId)
if (n && !pointerMoved) {
selectedId.value = n.id
emit('select', n.id)
}
draggingId = null
canvasRef.value?.releasePointerCapture(e.pointerId)
}
pointerMoved = false
cursorStyle.value = 'default'
}
function onResize() {
resize()
if (!draggingId) initSim()
}
onMounted(() => {
const canvas = canvasRef.value
if (!canvas) return
resize()
initSim()
loop()
window.addEventListener('resize', onResize)
canvas.addEventListener('pointerdown', onPointerDown)
canvas.addEventListener('pointermove', onPointerMove)
canvas.addEventListener('pointerup', onPointerUp)
canvas.addEventListener('pointercancel', onPointerUp)
})
watch(
() => [props.nodes, props.links],
() => {
resize()
draggingId = null
initSim()
},
{ deep: true }
)
onBeforeUnmount(() => {
cancelAnimationFrame(animId)
window.removeEventListener('resize', onResize)
const canvas = canvasRef.value
if (!canvas) return
canvas.removeEventListener('pointerdown', onPointerDown)
canvas.removeEventListener('pointermove', onPointerMove)
canvas.removeEventListener('pointerup', onPointerUp)
canvas.removeEventListener('pointercancel', onPointerUp)
})
</script>
<template>
<div class="graph-wrap">
<canvas
ref="canvasRef"
class="graph-canvas"
:style="{ cursor: cursorStyle }"
/>
<p class="graph-hint">拖动节点调整位置画布固定· 点击节点查看单词</p>
</div>
</template>
<style scoped>
.graph-wrap {
width: 100%;
border: 1px solid var(--border);
border-radius: var(--radius);
background: #fafbff;
overflow: hidden;
}
.graph-canvas {
display: block;
touch-action: none;
user-select: none;
}
.graph-hint {
font-size: 11px;
color: var(--muted);
padding: 8px 12px;
margin: 0;
border-top: 1px solid var(--border);
}
</style>
+245
View File
@@ -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<T>(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<StoredSession>(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<PendingAnswer[]>(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<void> {
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<void>
}
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) }
}
+26
View File
@@ -0,0 +1,26 @@
import { ref } from 'vue'
const startedAt = ref<number | null>(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} 分钟`
}
+109 -20
View File
@@ -1,7 +1,17 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { onMounted, ref, watch } from 'vue'
import QuizCard from '../components/QuizCard.vue'
import { api, type QuizQuestion } from '../api/request'
import {
clearQuizSession,
loadQuizSession,
submitQuizAnswer,
useQuizAutoSave,
type QuizSessionSnapshot,
} from '../composables/useQuizSession'
import { useQuizTimer } from '../composables/useQuizTimer'
const { startQuestionTimer, consumeDurationSeconds } = useQuizTimer()
const questions = ref<QuizQuestion[]>([])
const currentIndex = ref(0)
@@ -13,18 +23,63 @@ const finished = ref(false)
const sessionCorrect = ref(0)
const sessionWrong = ref(0)
const empty = ref(false)
const restored = ref(false)
const current = () => questions.value[currentIndex.value]
function buildSnapshot(): QuizSessionSnapshot | null {
if (!questions.value.length) return null
return {
questions: questions.value,
currentIndex: currentIndex.value,
sessionCorrect: sessionCorrect.value,
sessionWrong: sessionWrong.value,
finished: finished.value,
selected: selected.value,
showResult: showResult.value,
isCorrect: isCorrect.value,
}
}
const { persist, flushPending } = useQuizAutoSave('daily', buildSnapshot)
function applySnapshot(snap: QuizSessionSnapshot) {
questions.value = snap.questions
currentIndex.value = snap.currentIndex
sessionCorrect.value = snap.sessionCorrect
sessionWrong.value = snap.sessionWrong
finished.value = snap.finished
selected.value = snap.selected ?? ''
showResult.value = snap.showResult ?? false
isCorrect.value = snap.isCorrect ?? false
}
onMounted(async () => {
try {
const { data } = await api.dailyQuiz()
questions.value = data.questions
if (data.questions.length === 0) {
empty.value = true
const saved = loadQuizSession('daily')
if (saved && !saved.finished) {
applySnapshot(saved)
restored.value = true
await flushPending()
} else {
clearQuizSession('daily')
const { data } = await api.dailyQuiz()
questions.value = data.questions
if (data.questions.length === 0) {
empty.value = true
} else {
persist()
}
}
} finally {
loading.value = false
if (current() && !showResult.value) startQuestionTimer()
}
})
watch(currentIndex, () => {
if (!loading.value && !finished.value && current() && !showResult.value) {
startQuestionTimer()
}
})
@@ -34,33 +89,39 @@ async function onSelect(answer: string) {
const q = current()
if (!q) return
try {
const { data } = await api.submitAnswer({
word_id: q.word_id,
question_type: q.question_type,
user_answer: answer,
correct_answer: q.correct_answer,
})
isCorrect.value = data.is_correct
if (data.is_correct) sessionCorrect.value++
else sessionWrong.value++
} catch {
isCorrect.value = answer === q.correct_answer
if (isCorrect.value) sessionCorrect.value++
else sessionWrong.value++
const payload = {
word_id: q.word_id,
question_type: q.question_type,
user_answer: answer,
correct_answer: q.correct_answer,
duration_seconds: consumeDurationSeconds(),
}
const result = await submitQuizAnswer('daily', payload)
if (result.ok && result.is_correct !== undefined) {
isCorrect.value = result.is_correct
} else {
isCorrect.value = answer === q.correct_answer
}
if (isCorrect.value) sessionCorrect.value++
else sessionWrong.value++
showResult.value = true
persist()
}
function nextQuestion() {
if (currentIndex.value >= questions.value.length - 1) {
finished.value = true
clearQuizSession('daily')
return
}
currentIndex.value++
selected.value = ''
showResult.value = false
isCorrect.value = false
startQuestionTimer()
persist()
}
const accuracy = () => {
@@ -71,7 +132,14 @@ const accuracy = () => {
<template>
<div class="page">
<h1 class="page-title">每日训练</h1>
<div class="page-header">
<h1 class="page-title">每日训练</h1>
<router-link to="/spell" class="spell-link">拼写练习 </router-link>
</div>
<p v-if="restored && !loading && !finished && !empty" class="restore-hint">
已恢复上次未完成的训练进度
</p>
<p v-if="loading" style="color: var(--muted)">加载题目...</p>
@@ -114,6 +182,27 @@ const accuracy = () => {
</template>
<style scoped>
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 4px;
}
.page-header .page-title {
margin: 0;
}
.spell-link {
font-size: 14px;
color: var(--primary);
text-decoration: none;
white-space: nowrap;
}
.restore-hint {
font-size: 13px;
color: var(--primary);
margin-bottom: 12px;
}
.summary h2 {
margin-bottom: 12px;
font-size: 20px;
+1
View File
@@ -61,6 +61,7 @@ onMounted(async () => {
<router-link to="/translate" class="btn btn-outline">去翻译</router-link>
<router-link to="/words" class="btn btn-outline">单词库</router-link>
<router-link to="/quiz" class="btn btn-primary">开始每日训练</router-link>
<router-link to="/spell" class="btn btn-outline">拼写练习</router-link>
</div>
</template>
</div>
+37 -5
View File
@@ -1,14 +1,21 @@
<script setup lang="ts">
import { ref } from 'vue'
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { api } from '../api/request'
import { getRememberPreference, getSavedUsername, setAuth } from '../utils/auth'
const router = useRouter()
const username = ref('')
const password = ref('')
const rememberMe = ref(true)
const error = ref('')
const loading = ref(false)
onMounted(() => {
username.value = getSavedUsername()
rememberMe.value = getRememberPreference()
})
async function handleLogin() {
error.value = ''
if (!username.value || !password.value) {
@@ -17,8 +24,8 @@ async function handleLogin() {
}
loading.value = true
try {
const { data } = await api.login(username.value, password.value)
localStorage.setItem('token', data.access_token)
const { data } = await api.login(username.value, password.value, rememberMe.value)
setAuth(data.access_token, rememberMe.value, username.value)
router.push('/')
} catch (e: unknown) {
const err = e as { response?: { data?: { detail?: string } } }
@@ -36,9 +43,20 @@ async function handleLogin() {
<div v-if="error" class="message error">{{ error }}</div>
<div class="card">
<label class="label">用户名</label>
<input v-model="username" class="input" placeholder="请输入用户名" />
<input v-model="username" class="input" placeholder="请输入用户名" autocomplete="username" />
<label class="label">密码</label>
<input v-model="password" type="password" class="input" placeholder="请输入密码" />
<input
v-model="password"
type="password"
class="input"
placeholder="请输入密码"
autocomplete="current-password"
@keydown.enter="handleLogin"
/>
<label class="remember-row">
<input v-model="rememberMe" type="checkbox" class="remember-check" />
<span>记住登录状态关闭浏览器后仍保持登录有效期约 30 </span>
</label>
<button class="btn btn-primary" style="margin-top: 16px" :disabled="loading" @click="handleLogin">
{{ loading ? '登录中...' : '登录' }}
</button>
@@ -68,6 +86,20 @@ async function handleLogin() {
color: var(--muted);
margin: 12px 0 6px;
}
.remember-row {
display: flex;
align-items: flex-start;
gap: 8px;
margin-top: 14px;
font-size: 13px;
color: var(--muted);
cursor: pointer;
line-height: 1.4;
}
.remember-check {
margin-top: 2px;
flex-shrink: 0;
}
.auth-link {
text-align: center;
margin-top: 20px;
+3 -2
View File
@@ -2,6 +2,7 @@
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { api } from '../api/request'
import { setAuth } from '../utils/auth'
const router = useRouter()
const username = ref('')
@@ -27,8 +28,8 @@ async function handleRegister() {
loading.value = true
try {
await api.register(username.value, password.value)
const { data } = await api.login(username.value, password.value)
localStorage.setItem('token', data.access_token)
const { data } = await api.login(username.value, password.value, true)
setAuth(data.access_token, true, username.value)
router.push('/')
} catch (e: unknown) {
const err = e as { response?: { data?: { detail?: string } } }
+2 -1
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { api, type Settings } from '../api/request'
import { clearAuth } from '../utils/auth'
const settings = ref<Settings>({
daily_target: 20,
@@ -30,7 +31,7 @@ async function save() {
}
function logout() {
localStorage.removeItem('token')
clearAuth()
window.location.href = '/login'
}
</script>
+204
View File
@@ -0,0 +1,204 @@
<script setup lang="ts">
import { onMounted, ref, watch } from 'vue'
import SpellCard from '../components/SpellCard.vue'
import { api, type QuizQuestion } from '../api/request'
import {
clearQuizSession,
loadQuizSession,
submitQuizAnswer,
useQuizAutoSave,
type QuizSessionSnapshot,
} from '../composables/useQuizSession'
import { useQuizTimer } from '../composables/useQuizTimer'
const { startQuestionTimer, consumeDurationSeconds } = useQuizTimer()
const questions = ref<QuizQuestion[]>([])
const currentIndex = ref(0)
const submittedAnswer = ref('')
const showResult = ref(false)
const isCorrect = ref(false)
const loading = ref(true)
const finished = ref(false)
const sessionCorrect = ref(0)
const sessionWrong = ref(0)
const empty = ref(false)
const restored = ref(false)
const spellCardRef = ref<InstanceType<typeof SpellCard> | null>(null)
const current = () => questions.value[currentIndex.value]
function buildSnapshot(): QuizSessionSnapshot | null {
if (!questions.value.length) return null
return {
questions: questions.value,
currentIndex: currentIndex.value,
sessionCorrect: sessionCorrect.value,
sessionWrong: sessionWrong.value,
finished: finished.value,
submittedAnswer: submittedAnswer.value,
showResult: showResult.value,
isCorrect: isCorrect.value,
}
}
const { persist, flushPending } = useQuizAutoSave('spell', buildSnapshot, {
getDraftAnswer: () => spellCardRef.value?.getDraft() ?? '',
onDraftSubmit: (answer) => submitAnswer(answer),
})
function applySnapshot(snap: QuizSessionSnapshot) {
questions.value = snap.questions
currentIndex.value = snap.currentIndex
sessionCorrect.value = snap.sessionCorrect
sessionWrong.value = snap.sessionWrong
finished.value = snap.finished
submittedAnswer.value = snap.submittedAnswer ?? ''
showResult.value = snap.showResult ?? false
isCorrect.value = snap.isCorrect ?? false
}
onMounted(async () => {
try {
const saved = loadQuizSession('spell')
if (saved && !saved.finished) {
applySnapshot(saved)
restored.value = true
await flushPending()
} else {
clearQuizSession('spell')
const { data } = await api.spellQuiz()
questions.value = data.questions
if (data.questions.length === 0) {
empty.value = true
} else {
persist()
}
}
} finally {
loading.value = false
if (current() && !showResult.value) startQuestionTimer()
}
})
watch(currentIndex, () => {
if (!loading.value && !finished.value && current() && !showResult.value) {
startQuestionTimer()
}
})
async function submitAnswer(answer: string) {
if (showResult.value) return
submittedAnswer.value = answer
const q = current()
if (!q) return
const payload = {
word_id: q.word_id,
question_type: q.question_type,
user_answer: answer,
correct_answer: q.correct_answer,
duration_seconds: consumeDurationSeconds(),
}
const result = await submitQuizAnswer('spell', payload)
if (result.ok && result.is_correct !== undefined) {
isCorrect.value = result.is_correct
} else {
isCorrect.value = answer.toLowerCase() === q.correct_answer.toLowerCase()
}
if (isCorrect.value) sessionCorrect.value++
else sessionWrong.value++
showResult.value = true
persist()
}
function onSubmit(answer: string) {
return submitAnswer(answer)
}
function nextQuestion() {
if (currentIndex.value >= questions.value.length - 1) {
finished.value = true
clearQuizSession('spell')
return
}
currentIndex.value++
submittedAnswer.value = ''
showResult.value = false
isCorrect.value = false
startQuestionTimer()
persist()
}
const accuracy = () => {
const total = sessionCorrect.value + sessionWrong.value
return total ? Math.round((sessionCorrect.value / total) * 100) : 0
}
</script>
<template>
<div class="page">
<h1 class="page-title">拼写练习</h1>
<p v-if="restored && !loading && !finished && !empty" class="restore-hint">
已恢复上次未完成的训练进度
</p>
<p v-if="loading" style="color: var(--muted)">加载题目...</p>
<div v-else-if="empty" class="card" style="text-align: center">
<p>词库暂无单词请先通过翻译添加单词</p>
<router-link to="/translate" class="btn btn-primary" style="margin-top: 12px; display: inline-block">
去翻译
</router-link>
</div>
<div v-else-if="finished" class="card summary">
<h2>拼写练习完成 🎉</h2>
<p>总题数{{ sessionCorrect + sessionWrong }}</p>
<p>答对{{ sessionCorrect }}</p>
<p>答错{{ sessionWrong }}</p>
<p>正确率{{ accuracy() }}%</p>
<router-link to="/" class="btn btn-primary" style="margin-top: 16px">返回首页</router-link>
</div>
<template v-else-if="current()">
<SpellCard
ref="spellCardRef"
:question="current()!"
:index="currentIndex"
:total="questions.length"
:show-result="showResult"
:is-correct="isCorrect"
:submitted-answer="submittedAnswer"
@submit="onSubmit"
/>
<button
v-if="showResult"
class="btn btn-primary"
style="margin-top: 12px"
@click="nextQuestion"
>
{{ currentIndex >= questions.length - 1 ? '查看结果' : '下一题' }}
</button>
</template>
</div>
</template>
<style scoped>
.restore-hint {
font-size: 13px;
color: var(--primary);
margin-bottom: 12px;
}
.summary h2 {
margin-bottom: 12px;
font-size: 20px;
}
.summary p {
margin: 6px 0;
font-size: 15px;
}
</style>
+421 -20
View File
@@ -1,7 +1,27 @@
<script setup lang="ts">
import { onMounted, ref, watch } from 'vue'
import { computed, defineAsyncComponent, onMounted, ref, watch } from 'vue'
import WordCard from '../components/WordCard.vue'
import { api, type Word } from '../api/request'
const MemoryCurveChart = defineAsyncComponent(
() => import('../components/MemoryCurveChart.vue')
)
const WordGraphCanvas = defineAsyncComponent(
() => import('../components/WordGraphCanvas.vue')
)
import {
api,
type MemoryCurvePoint,
type MemoryVisualization,
type MemoryWordSummary,
type Word,
type WordMemoryDetail,
} from '../api/request'
import { formatTrainSeconds } from '../composables/useQuizTimer'
const viewTabs = [
{ key: 'list', label: '单词列表' },
{ key: 'memory', label: '记忆曲线' },
]
const tabs = [
{ key: '', label: '全部' },
@@ -11,52 +31,433 @@ const tabs = [
{ key: 'weak', label: '易错词' },
]
const PAGE_SIZE = 20
const activeView = ref('list')
const activeTab = ref('')
const words = ref<Word[]>([])
const currentPage = ref(1)
const loading = ref(true)
const vizLoading = ref(false)
const viz = ref<MemoryVisualization | null>(null)
const selectedWord = ref<MemoryWordSummary | null>(null)
const detailExpanded = ref(true)
const wordMemory = ref<WordMemoryDetail | null>(null)
const wordMemoryLoading = ref(false)
const wordCurvePoints = computed((): MemoryCurvePoint[] => {
if (!wordMemory.value?.curve_points.length) return []
return wordMemory.value.curve_points.map((p, i) => ({
day_index: i,
date: p.date,
forgetting: p.forgetting,
mastery: p.mastery,
risk: p.risk,
}))
})
const totalPages = computed(() =>
Math.max(1, Math.ceil(words.value.length / PAGE_SIZE))
)
const paginatedWords = computed(() => {
const start = (currentPage.value - 1) * PAGE_SIZE
return words.value.slice(start, start + PAGE_SIZE)
})
const pageSummary = computed(() => {
if (!words.value.length) return ''
const start = (currentPage.value - 1) * PAGE_SIZE + 1
const end = Math.min(currentPage.value * PAGE_SIZE, words.value.length)
return `${currentPage.value} / ${totalPages.value} 页,显示 ${start}${end},共 ${words.value.length}`
})
function clampPage() {
if (currentPage.value > totalPages.value) {
currentPage.value = totalPages.value
}
if (currentPage.value < 1) {
currentPage.value = 1
}
}
function goToPage(page: number) {
currentPage.value = Math.min(Math.max(1, page), totalPages.value)
}
function goToPageAndScroll(page: number) {
goToPage(page)
window.scrollTo({ top: 0, behavior: 'smooth' })
}
async function loadWords() {
loading.value = true
try {
const { data } = await api.listWords(activeTab.value || undefined)
words.value = data
clampPage()
} finally {
loading.value = false
}
}
async function loadViz() {
vizLoading.value = true
try {
const { data } = await api.memoryViz()
viz.value = data
selectedWord.value = data.words[0] ?? null
detailExpanded.value = true
if (data.words[0]) loadWordMemory(data.words[0].id)
} finally {
vizLoading.value = false
}
}
async function handleDelete(id: number) {
if (!confirm('确定删除这个单词?')) return
await api.deleteWord(id)
await loadWords()
if (activeView.value === 'memory') await loadViz()
}
watch(activeTab, loadWords)
onMounted(loadWords)
async function loadWordMemory(wordId: number) {
wordMemoryLoading.value = true
try {
const { data } = await api.wordMemory(wordId)
wordMemory.value = data
} catch {
wordMemory.value = null
} finally {
wordMemoryLoading.value = false
}
}
function onGraphSelect(id: string) {
const w = viz.value?.words.find((x) => String(x.id) === id)
if (w) {
selectedWord.value = w
detailExpanded.value = true
loadWordMemory(w.id)
}
}
watch(selectedWord, (w) => {
if (w && activeView.value === 'memory') loadWordMemory(w.id)
})
watch(activeTab, () => {
currentPage.value = 1
if (activeView.value === 'list') loadWords()
})
watch(activeView, (v) => {
if (v === 'list') loadWords()
else loadViz()
})
onMounted(() => {
loadWords()
})
</script>
<template>
<div class="page">
<h1 class="page-title">单词库</h1>
<div class="tabs">
<div class="view-tabs">
<button
v-for="t in tabs"
:key="t.key"
:class="['tab', { active: activeTab === t.key }]"
@click="activeTab = t.key"
v-for="v in viewTabs"
:key="v.key"
:class="['view-tab', { active: activeView === v.key }]"
@click="activeView = v.key"
>
{{ t.label }}
{{ v.label }}
</button>
</div>
<p v-if="loading" style="color: var(--muted)">加载中...</p>
<p v-else-if="words.length === 0" style="color: var(--muted); text-align: center">
暂无单词去翻译页添加吧
</p>
<WordCard
v-for="w in words"
:key="w.id"
:word="w"
@delete="handleDelete"
/>
<template v-if="activeView === 'list'">
<div class="tabs">
<button
v-for="t in tabs"
:key="t.key"
:class="['tab', { active: activeTab === t.key }]"
@click="activeTab = t.key"
>
{{ t.label }}
</button>
</div>
<p v-if="loading" style="color: var(--muted)">加载中...</p>
<p v-else-if="words.length === 0" style="color: var(--muted); text-align: center">
暂无单词去翻译页添加吧
</p>
<template v-else>
<Teleport to="body">
<button
v-if="totalPages > 1"
type="button"
class="float-page-arrow prev"
:disabled="currentPage <= 1"
aria-label="上一页"
@click="goToPageAndScroll(currentPage - 1)"
>
</button>
<button
v-if="totalPages > 1"
type="button"
class="float-page-arrow next"
:disabled="currentPage >= totalPages"
aria-label="下一页"
@click="goToPageAndScroll(currentPage + 1)"
>
</button>
</Teleport>
<p class="page-summary">{{ pageSummary }}</p>
<WordCard v-for="w in paginatedWords" :key="w.id" :word="w" @delete="handleDelete" />
<div v-if="totalPages > 1" class="pagination">
<button
type="button"
class="page-btn"
:disabled="currentPage <= 1"
@click="goToPageAndScroll(currentPage - 1)"
>
上一页
</button>
<span class="page-indicator">{{ currentPage }} / {{ totalPages }}</span>
<button
type="button"
class="page-btn"
:disabled="currentPage >= totalPages"
@click="goToPageAndScroll(currentPage + 1)"
>
下一页
</button>
</div>
</template>
</template>
<template v-else>
<p v-if="vizLoading" style="color: var(--muted)">加载记忆数据...</p>
<template v-else-if="viz && viz.graph.nodes.length">
<div class="card chart-card">
<h3 class="section-title">记忆曲线</h3>
<p class="section-desc">
红线遗忘曲线 · 绿线熟练曲线 · 橙虚线可能遗忘(历史) · 紫点线未来预测
</p>
<MemoryCurveChart :curve-points="viz.curve_points" :future-risk="viz.future_risk" />
</div>
<div class="card graph-card">
<h3 class="section-title">单词关系图</h3>
<p class="section-desc">类似 Obsidian 的力导向图展示词与词之间的记忆关联</p>
<WordGraphCanvas
:nodes="viz.graph.nodes"
:links="viz.graph.links"
@select="onGraphSelect"
/>
</div>
<div v-if="selectedWord" class="card word-detail">
<div class="detail-header">
<h3 class="detail-word-title">{{ selectedWord.en }} {{ selectedWord.zh }}</h3>
<button type="button" class="detail-toggle" @click="detailExpanded = !detailExpanded">
{{ detailExpanded ? '收起' : '展开' }}
</button>
</div>
<div v-show="detailExpanded" class="detail-expanded">
<div class="detail-meta">
<span>进入词库{{ selectedWord.entered_at.replace('T', ' ').slice(0, 16) }}</span>
<span>训练 {{ selectedWord.train_count }} · 累计 {{ formatTrainSeconds(selectedWord.total_train_seconds) }}</span>
<span>答对 {{ selectedWord.correct_count }} · 答错 {{ selectedWord.wrong_count }}</span>
<span>掌握率 {{ selectedWord.mastery_score }}%</span>
<span>当前记忆保留 {{ selectedWord.retention_now }}%</span>
<span>7 日后预测 {{ selectedWord.risk_7d }}%</span>
</div>
<p v-if="wordMemoryLoading" class="curve-loading">加载该词记忆曲线...</p>
<template v-else-if="wordCurvePoints.length">
<h4 class="word-curve-title">该单词记忆曲线</h4>
<p class="section-desc">每次训练后更新 · 点与答题记录对应</p>
<MemoryCurveChart
:curve-points="wordCurvePoints"
:future-risk="wordMemory?.future_risk ?? []"
/>
</template>
<p v-else class="curve-loading">暂无训练记录完成每日训练或拼写练习后生成曲线</p>
</div>
</div>
</template>
<p v-else style="color: var(--muted); text-align: center">暂无单词无法生成记忆曲线</p>
</template>
</div>
</template>
<style scoped>
.view-tabs {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
.view-tab {
flex: 1;
padding: 10px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: #fff;
font-size: 14px;
cursor: pointer;
}
.view-tab.active {
border-color: var(--primary);
background: rgba(79, 110, 247, 0.1);
color: var(--primary);
font-weight: 600;
}
.page-summary {
font-size: 13px;
color: var(--muted);
margin: 0 0 12px;
}
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
margin-top: 16px;
padding-top: 12px;
}
.page-btn {
padding: 8px 16px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: #fff;
font-size: 14px;
color: var(--primary);
cursor: pointer;
}
.page-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
color: var(--muted);
}
.page-indicator {
font-size: 14px;
color: var(--muted);
min-width: 72px;
text-align: center;
}
.section-title {
font-size: 16px;
margin: 0 0 6px;
}
.section-desc {
font-size: 12px;
color: var(--muted);
margin: 0 0 12px;
}
.chart-card,
.graph-card {
margin-bottom: 16px;
}
.word-detail {
margin-top: 12px;
}
.detail-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.detail-word-title {
font-size: 17px;
font-weight: 600;
margin: 0;
flex: 1;
min-width: 0;
}
.detail-toggle {
flex-shrink: 0;
padding: 6px 12px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: #fff;
font-size: 13px;
color: var(--primary);
cursor: pointer;
}
.detail-toggle:hover {
background: rgba(79, 110, 247, 0.08);
}
.detail-expanded {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--border);
}
.detail-meta {
display: flex;
flex-direction: column;
gap: 6px;
font-size: 13px;
color: var(--muted);
}
.word-curve-title {
font-size: 14px;
margin: 14px 0 4px;
color: #1e293b;
}
.curve-loading {
font-size: 13px;
color: var(--muted);
margin: 12px 0;
}
</style>
<style>
/* 悬浮翻页箭头挂到 body,避免被页面容器裁剪 */
.float-page-arrow {
position: fixed;
top: calc(50% - 28px);
z-index: 100;
width: 44px;
height: 52px;
border: 1px solid var(--border, #e2e8f0);
border-radius: 12px;
background: rgba(255, 255, 255, 0.92);
box-shadow: 0 4px 16px rgba(15, 23, 42, 0.12);
font-size: 28px;
line-height: 1;
color: var(--primary, #4f6ef7);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
backdrop-filter: blur(6px);
-webkit-tap-highlight-color: transparent;
}
.float-page-arrow.prev {
left: max(6px, env(safe-area-inset-left));
}
.float-page-arrow.next {
right: max(6px, env(safe-area-inset-right));
}
.float-page-arrow:hover:not(:disabled) {
background: #fff;
border-color: var(--primary, #4f6ef7);
}
.float-page-arrow:disabled {
opacity: 0.35;
cursor: not-allowed;
box-shadow: none;
}
@media (min-width: 480px) {
.float-page-arrow.prev {
left: 12px;
}
.float-page-arrow.next {
right: 12px;
}
}
</style>
+3 -1
View File
@@ -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) {
+59
View File
@@ -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()
}