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:
@@ -156,7 +156,9 @@ wordloop/
|
||||
|
||||
- 用户注册 / 登录(JWT)
|
||||
- 中英互译(本地词典 MVP,可扩展 AI)
|
||||
- 个人单词库(新词 / 学习中 / 已掌握 / 易错词)
|
||||
- 个人单词库(新词 / 学习中 / 已掌握 / 易错词,记录进入词库时间)
|
||||
- 记忆曲线与 Obsidian 式单词关系力导向图
|
||||
- 每日选择题训练
|
||||
- 拼写练习(中文释义 + 音标,输入英文单词)
|
||||
- 掌握规则与复习间隔
|
||||
- 学习统计与设置
|
||||
|
||||
+8
-3
@@ -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)
|
||||
|
||||
|
||||
@@ -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,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")
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
Generated
+26
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.7.9",
|
||||
"echarts": "^6.1.0",
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^4.5.0"
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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) }
|
||||
}
|
||||
@@ -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} 分钟`
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 } } }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user