Initial commit: WordLoop 单词学习应用

Vue 前端 + FastAPI 后端,含部署脚本与词典数据。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
John
2026-06-04 14:30:53 -07:00
commit bd7635986a
66 changed files with 5495 additions and 0 deletions
View File
+55
View File
@@ -0,0 +1,55 @@
import random
from typing import Optional
from sqlalchemy.orm import Session
from models import DictionaryEntry
class DictionaryService:
def lookup_en(self, db: Session, text: str) -> Optional[DictionaryEntry]:
key = text.lower().strip()
if not key:
return None
return db.query(DictionaryEntry).filter(DictionaryEntry.lemma_en == key).first()
def lookup_zh(self, db: Session, text: str) -> Optional[DictionaryEntry]:
text = text.strip()
if not text:
return None
entry = db.query(DictionaryEntry).filter(DictionaryEntry.zh == text).first()
if entry:
return entry
return (
db.query(DictionaryEntry)
.filter(DictionaryEntry.zh.contains(text))
.first()
)
def random_zh_values(self, db: Session, exclude: str, limit: int) -> list[str]:
rows = (
db.query(DictionaryEntry.zh)
.filter(DictionaryEntry.zh != exclude)
.order_by(DictionaryEntry.id)
.all()
)
values = [r[0] for r in rows]
random.shuffle(values)
return values[:limit]
def random_en_lemmas(self, db: Session, exclude: str, limit: int) -> list[str]:
rows = (
db.query(DictionaryEntry.lemma_en)
.filter(DictionaryEntry.lemma_en != exclude.lower())
.order_by(DictionaryEntry.id)
.all()
)
values = [r[0] for r in rows]
random.shuffle(values)
return values[:limit]
def count(self, db: Session) -> int:
return db.query(DictionaryEntry).count()
dictionary_service = DictionaryService()
+269
View File
@@ -0,0 +1,269 @@
import random
from datetime import datetime, timedelta, timezone
from typing import Optional
from sqlalchemy import func
from sqlalchemy.orm import Session
from models import QuizRecord, User, UserSettings, Word
from schemas import QuizOption, QuizQuestion, QuizStatsResponse, WordOut
from services.dictionary_service import dictionary_service
from services.word_service import calc_mastery_score, utc_now_iso
def today_str() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%d")
def review_interval_days(consecutive: int) -> int:
"""根据连续答对次数计算下次复习间隔(天)。"""
if consecutive <= 0:
return 0
if consecutive == 1:
return 1
if consecutive == 2:
return 3
if consecutive == 3:
return 7
if consecutive >= 5:
return 15
return 7
class QuizService:
def get_settings(self, db: Session, user: User) -> UserSettings:
settings = db.query(UserSettings).filter(UserSettings.user_id == user.id).first()
if not settings:
settings = UserSettings(user_id=user.id)
db.add(settings)
db.commit()
db.refresh(settings)
return settings
def select_daily_words(self, db: Session, user: User, limit: int) -> list[Word]:
today = today_str()
all_words = db.query(Word).filter(Word.user_id == user.id).all()
weak = [w for w in all_words if w.status == "weak"]
learning = [w for w in all_words if w.status == "learning"]
new = [w for w in all_words if w.status == "new"]
mastered_due = [
w
for w in all_words
if w.status == "mastered"
and w.review_due_date
and w.review_due_date <= today
]
pool: list[Word] = []
for group in (weak, learning, new, mastered_due):
random.shuffle(group)
for w in group:
if w not in pool:
pool.append(w)
if len(pool) >= limit:
return pool[:limit]
return pool[:limit]
def build_question(self, db: Session, word: Word, all_words: list[Word]) -> QuizQuestion:
question_type = random.choice(["en_to_zh", "zh_to_en"])
if question_type == "en_to_zh":
en = word.target_text if word.source_lang == "zh" else word.source_text
zh = word.source_text if word.source_lang == "zh" else word.target_text
prompt = en
correct = zh
distractor_pool = [
(w.source_text if w.source_lang == "zh" else w.target_text)
for w in all_words
if w.id != word.id
]
else:
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
prompt = zh
correct = en
distractor_pool = [
(w.target_text if w.source_lang == "zh" else w.source_text)
for w in all_words
if w.id != word.id
]
distractors = list({d for d in distractor_pool if d != correct})
random.shuffle(distractors)
if len(distractors) < 3:
if question_type == "en_to_zh":
extra = dictionary_service.random_zh_values(db, correct, 10)
else:
extra = dictionary_service.random_en_lemmas(db, correct, 10)
for e in extra:
if e not in distractors:
distractors.append(e)
if len(distractors) >= 3:
break
options_text = [correct] + distractors[:3]
random.shuffle(options_text)
labels = ["A", "B", "C", "D"]
options = [
QuizOption(label=labels[i], text=options_text[i])
for i in range(min(4, len(options_text)))
]
return QuizQuestion(
word_id=word.id,
question_type=question_type,
prompt=prompt,
options=options,
correct_answer=correct,
)
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)
all_words = db.query(Word).filter(Word.user_id == user.id).all()
if len(all_words) < 4:
questions = []
if words:
questions = [self.build_question(db, words[0], all_words)]
else:
questions = [self.build_question(db, w, all_words) for w in words]
return {
"questions": questions,
"total": len(questions),
}
def submit_answer(
self,
db: Session,
user: User,
word_id: int,
question_type: str,
user_answer: str,
correct_answer: str,
) -> 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="单词不存在")
settings = self.get_settings(db, user)
is_correct = user_answer.strip() == correct_answer.strip()
now = utc_now_iso()
today = today_str()
if is_correct:
word.correct_count += 1
word.consecutive_correct_count += 1
if word.status == "new":
word.status = "learning"
if word.consecutive_correct_count >= settings.master_required_count:
word.status = "mastered"
days = review_interval_days(word.consecutive_correct_count)
due = datetime.now(timezone.utc) + timedelta(days=days)
word.review_due_date = due.strftime("%Y-%m-%d")
else:
word.wrong_count += 1
word.consecutive_correct_count = 0
if word.wrong_count >= settings.weak_wrong_threshold:
word.status = "weak"
elif word.status == "new":
word.status = "learning"
word.review_due_date = today
word.mastery_score = calc_mastery_score(word.correct_count, word.wrong_count)
word.last_reviewed_at = now
record = QuizRecord(
user_id=user.id,
word_id=word.id,
question_type=question_type,
user_answer=user_answer,
correct_answer=correct_answer,
is_correct=1 if is_correct else 0,
created_at=now,
)
db.add(record)
db.commit()
db.refresh(word)
return {
"is_correct": is_correct,
"correct_answer": correct_answer,
"word": WordOut.model_validate(word),
}
def get_stats(self, db: Session, user: User) -> QuizStatsResponse:
settings = self.get_settings(db, user)
today = today_str()
total = db.query(Word).filter(Word.user_id == user.id).count()
new_count = db.query(Word).filter(Word.user_id == user.id, Word.status == "new").count()
learning_count = (
db.query(Word).filter(Word.user_id == user.id, Word.status == "learning").count()
)
mastered_count = (
db.query(Word).filter(Word.user_id == user.id, Word.status == "mastered").count()
)
weak_count = db.query(Word).filter(Word.user_id == user.id, Word.status == "weak").count()
today_records = (
db.query(QuizRecord)
.filter(
QuizRecord.user_id == user.id,
func.date(QuizRecord.created_at) == today,
)
.all()
)
# SQLite date compare fallback: filter by prefix
if not today_records:
today_records = [
r
for r in db.query(QuizRecord).filter(QuizRecord.user_id == user.id).all()
if r.created_at.startswith(today)
]
today_quiz_count = len(today_records)
today_correct = sum(1 for r in today_records if r.is_correct == 1)
accuracy = (
round(today_correct / today_quiz_count * 100, 1) if today_quiz_count > 0 else 0.0
)
streak = self._calc_streak(db, user)
return QuizStatsResponse(
total_words=total,
new_count=new_count,
learning_count=learning_count,
mastered_count=mastered_count,
weak_count=weak_count,
today_quiz_count=today_quiz_count,
today_correct_count=today_correct,
today_accuracy=accuracy,
daily_target=settings.daily_target,
today_completed=today_quiz_count,
streak_days=streak,
)
def _calc_streak(self, db: Session, user: User) -> int:
records = (
db.query(QuizRecord)
.filter(QuizRecord.user_id == user.id)
.order_by(QuizRecord.created_at.desc())
.all()
)
days_with_quiz = set()
for r in records:
days_with_quiz.add(r.created_at[:10])
streak = 0
d = datetime.now(timezone.utc).date()
while d.isoformat() in days_with_quiz:
streak += 1
d -= timedelta(days=1)
return streak
quiz_service = QuizService()
+85
View File
@@ -0,0 +1,85 @@
import re
from typing import Optional
from sqlalchemy.orm import Session
from services.dictionary_service import dictionary_service
def contains_chinese(text: str) -> bool:
return bool(re.search(r"[\u4e00-\u9fff]", text))
class TranslationService:
"""翻译服务:优先查离线词典表,未命中时返回占位结果(暂不接入外部 API)。"""
def translate(self, db: Session, text: str) -> dict:
text = text.strip()
if not text:
raise ValueError("输入不能为空")
if contains_chinese(text):
return self._zh_to_en(db, text)
return self._en_to_zh(db, text)
def _en_to_zh(self, db: Session, text: str) -> dict:
entry = dictionary_service.lookup_en(db, text)
if entry:
return self._build_response(
text,
entry.zh,
"en",
"zh",
entry.phonetic,
entry.example_en,
entry.example_cn,
)
return self._placeholder(text, text, "en", "zh")
def _zh_to_en(self, db: Session, text: str) -> dict:
entry = dictionary_service.lookup_zh(db, text)
if entry:
return self._build_response(
text,
entry.lemma_en,
"zh",
"en",
entry.phonetic,
entry.example_en,
entry.example_cn,
)
return self._placeholder(text, f"[{text}]", "zh", "en")
def _build_response(
self,
source: str,
target: str,
source_lang: str,
target_lang: str,
phonetic: Optional[str],
example_en: Optional[str],
example_cn: Optional[str],
) -> dict:
return {
"source_text": source,
"target_text": target,
"source_lang": source_lang,
"target_lang": target_lang,
"phonetic": phonetic,
"example_en": example_en,
"example_cn": example_cn,
}
def _placeholder(self, source: str, target: str, source_lang: str, target_lang: str) -> dict:
return {
"source_text": source,
"target_text": target,
"source_lang": source_lang,
"target_lang": target_lang,
"phonetic": None,
"example_en": f"Example with {target}." if target_lang == "en" else None,
"example_cn": f"包含「{source}」的例句。" if source_lang == "zh" else None,
}
translation_service = TranslationService()
+83
View File
@@ -0,0 +1,83 @@
from datetime import datetime, timezone
from typing import Optional
from fastapi import HTTPException
from sqlalchemy.orm import Session
from models import Word, User
def utc_now_iso() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def calc_mastery_score(correct: int, wrong: int) -> int:
total = correct + wrong
if total == 0:
return 0
return round(correct / total * 100)
class WordService:
def create_word(self, db: Session, user: User, data: dict) -> Word:
existing = (
db.query(Word)
.filter(
Word.user_id == user.id,
Word.source_text == data["source_text"],
Word.target_text == data["target_text"],
)
.first()
)
if existing:
raise HTTPException(status_code=400, detail="该单词已存在")
word = Word(
user_id=user.id,
source_text=data["source_text"],
target_text=data["target_text"],
source_lang=data["source_lang"],
target_lang=data["target_lang"],
phonetic=data.get("phonetic"),
example_en=data.get("example_en"),
example_cn=data.get("example_cn"),
status="new",
correct_count=0,
wrong_count=0,
consecutive_correct_count=0,
mastery_score=0,
review_due_date=datetime.now(timezone.utc).strftime("%Y-%m-%d"),
created_at=utc_now_iso(),
)
db.add(word)
db.commit()
db.refresh(word)
return word
def list_words(self, db: Session, user: User, status: Optional[str] = None) -> list[Word]:
q = db.query(Word).filter(Word.user_id == user.id)
if status:
q = q.filter(Word.status == status)
return q.order_by(Word.created_at.desc()).all()
def get_word(self, db: Session, user: User, word_id: int) -> Word:
word = db.query(Word).filter(Word.id == word_id, Word.user_id == user.id).first()
if not word:
raise HTTPException(status_code=404, detail="单词不存在")
return word
def delete_word(self, db: Session, user: User, word_id: int) -> None:
word = self.get_word(db, user, word_id)
db.delete(word)
db.commit()
def update_word(self, db: Session, user: User, word_id: int, data: dict) -> Word:
word = self.get_word(db, user, word_id)
if "status" in data and data["status"]:
word.status = data["status"]
db.commit()
db.refresh(word)
return word
word_service = WordService()