Add word-book practice, iOS app shell, and fix embedded WebView blank screen.

Ship dual-track learning (daily accumulation vs textbook),沪教/商务词书 APIs and UI, native iOS wrapper with bundled H5, and production book import on deploy.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-06-06 21:09:27 +08:00
parent 2fae90b597
commit e76fa586f1
76 changed files with 47488 additions and 445 deletions
+119 -40
View File
@@ -5,8 +5,9 @@ from typing import Optional
from sqlalchemy import func
from sqlalchemy.orm import Session
from models import QuizRecord, User, UserSettings, Word
from models import QuizRecord, User, UserSettings, Word, WordBook
from schemas import QuizOption, QuizQuestion, QuizStatsResponse, WordOut
from services.book_service import ACCUMULATION_BOOK_ID, book_service
from services.dictionary_service import dictionary_service
from services.word_service import calc_mastery_score, utc_now_iso
@@ -40,10 +41,31 @@ class QuizService:
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()
def _track_words(self, db: Session, user: User, book_id: int) -> list[Word]:
return (
db.query(Word)
.filter(Word.user_id == user.id, Word.book_id == book_id, Word.status != "locked")
.all()
)
def select_accumulation_words(self, db: Session, user: User, limit: int) -> list[Word]:
return self._select_adaptive_words(
self._track_words(db, user, ACCUMULATION_BOOK_ID), limit
)
def select_book_words(self, db: Session, user: User, book: WordBook, limit: int) -> list[Word]:
all_words = self._track_words(db, user, book.id)
pool = self._select_adaptive_words(all_words, limit)
if len(pool) < limit and book.learn_mode == "sequential":
need = limit - len(pool)
unlocked = book_service.unlock_more_new_words(db, user, book, need)
if unlocked:
all_words = self._track_words(db, user, book.id)
pool = self._select_adaptive_words(all_words, limit)
return pool[:limit]
def _select_adaptive_words(self, all_words: list[Word], limit: int) -> list[Word]:
today = today_str()
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"]
@@ -65,6 +87,20 @@ class QuizService:
return pool[:limit]
return pool[:limit]
def select_daily_words(
self, db: Session, user: User, limit: int, track: str = "accumulation"
) -> list[Word]:
if track == "book":
book = book_service.get_active_book(db, user)
if not book:
return []
book_service.ensure_user_book_words(db, user, book)
settings = self.get_settings(db, user)
practice = book_service.practice_settings_dict(db, user, book)
return self.select_book_words(db, user, book, practice["daily_target"])
settings = self.get_settings(db, user)
return self.select_accumulation_words(db, user, settings.daily_target)
def build_question(self, db: Session, word: Word, all_words: list[Word]) -> QuizQuestion:
question_type = random.choice(["en_to_zh", "zh_to_en"])
@@ -130,19 +166,42 @@ class QuizService:
correct_answer=en,
)
def get_spell_quiz(self, db: Session, user: User) -> dict:
def _resolve_track(self, db: Session, user: User, track: str) -> tuple[int, int]:
if track == "book":
book = book_service.get_active_book(db, user)
if not book:
from fastapi import HTTPException
raise HTTPException(status_code=400, detail="请先在首页选择词书")
book_service.ensure_user_book_words(db, user, book)
practice = book_service.practice_settings_dict(db, user, book)
return book.id, practice["daily_target"]
settings = self.get_settings(db, user)
words = self.select_daily_words(db, user, settings.daily_target)
return ACCUMULATION_BOOK_ID, settings.daily_target
def get_spell_quiz(self, db: Session, user: User, track: str = "accumulation") -> dict:
book_id, limit = self._resolve_track(db, user, track)
if track == "book":
book = book_service.get_book(db, book_id)
words = self.select_book_words(db, user, book, limit)
else:
words = self.select_accumulation_words(db, user, limit)
questions = [self.build_spell_question(w) for w in words]
return {
"questions": questions,
"total": len(questions),
"track": track,
"book_id": book_id if track == "book" else None,
}
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()
def get_daily_quiz(self, db: Session, user: User, track: str = "accumulation") -> dict:
book_id, limit = self._resolve_track(db, user, track)
if track == "book":
book = book_service.get_book(db, book_id)
words = self.select_book_words(db, user, book, limit)
else:
words = self.select_accumulation_words(db, user, limit)
all_words = self._track_words(db, user, book_id)
if len(all_words) < 4:
questions = []
@@ -154,6 +213,8 @@ class QuizService:
return {
"questions": questions,
"total": len(questions),
"track": track,
"book_id": book_id if track == "book" else None,
}
def submit_answer(
@@ -172,6 +233,10 @@ class QuizService:
raise HTTPException(status_code=404, detail="单词不存在")
settings = self.get_settings(db, user)
book = None
if word.book_id > 0:
book = db.query(WordBook).filter(WordBook.id == word.book_id).first()
rule = book_service.book_settings(db, user, book, settings)
if question_type in ("spell", "memory_coach"):
is_correct = (
user_answer.strip().lower() == correct_answer.strip().lower()
@@ -187,7 +252,7 @@ class QuizService:
word.consecutive_correct_count += 1
if word.status == "new":
word.status = "learning"
if word.consecutive_correct_count >= settings.master_required_count:
if word.consecutive_correct_count >= rule["master_required_count"]:
word.status = "mastered"
days = review_interval_days(word.consecutive_correct_count)
due = datetime.now(timezone.utc) + timedelta(days=days)
@@ -195,7 +260,7 @@ class QuizService:
else:
word.wrong_count += 1
word.consecutive_correct_count = 0
if word.wrong_count >= settings.weak_wrong_threshold:
if word.wrong_count >= rule["weak_wrong_threshold"]:
word.status = "weak"
elif word.status == "new":
word.status = "learning"
@@ -226,35 +291,45 @@ class QuizService:
"word": WordOut.model_validate(word),
}
def get_stats(self, db: Session, user: User) -> QuizStatsResponse:
def get_stats(self, db: Session, user: User, track: str = "accumulation") -> QuizStatsResponse:
settings = self.get_settings(db, user)
today = today_str()
book_id = ACCUMULATION_BOOK_ID
daily_target = settings.daily_target
if track == "book":
active = book_service.get_active_book(db, user)
if not active:
return QuizStatsResponse(
total_words=0,
new_count=0,
learning_count=0,
mastered_count=0,
weak_count=0,
today_quiz_count=0,
today_correct_count=0,
today_accuracy=0.0,
daily_target=15,
today_completed=0,
streak_days=0,
track=track,
)
book_id = active.id
daily_target = book_service.practice_settings_dict(db, user, active)["daily_target"]
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()
word_q = db.query(Word).filter(Word.user_id == user.id, Word.book_id == book_id)
active_words = word_q.filter(Word.status != "locked")
total = active_words.count()
new_count = active_words.filter(Word.status == "new").count()
learning_count = active_words.filter(Word.status == "learning").count()
mastered_count = active_words.filter(Word.status == "mastered").count()
weak_count = active_words.filter(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)
]
track_word_ids = {w.id for w in word_q.all()}
today_records = [
r
for r in db.query(QuizRecord).filter(QuizRecord.user_id == user.id).all()
if r.created_at.startswith(today) and r.word_id in track_word_ids
]
today_quiz_count = len(today_records)
today_correct = sum(1 for r in today_records if r.is_correct == 1)
@@ -262,7 +337,7 @@ class QuizService:
round(today_correct / today_quiz_count * 100, 1) if today_quiz_count > 0 else 0.0
)
streak = self._calc_streak(db, user)
streak = self._calc_streak(db, user, track_word_ids)
return QuizStatsResponse(
total_words=total,
@@ -273,18 +348,22 @@ class QuizService:
today_quiz_count=today_quiz_count,
today_correct_count=today_correct,
today_accuracy=accuracy,
daily_target=settings.daily_target,
daily_target=daily_target,
today_completed=today_quiz_count,
streak_days=streak,
track=track,
book_id=book_id if track == "book" else None,
)
def _calc_streak(self, db: Session, user: User) -> int:
def _calc_streak(self, db: Session, user: User, word_ids: Optional[set[int]] = None) -> int:
records = (
db.query(QuizRecord)
.filter(QuizRecord.user_id == user.id)
.order_by(QuizRecord.created_at.desc())
.all()
)
if word_ids is not None:
records = [r for r in records if r.word_id in word_ids]
days_with_quiz = set()
for r in records:
days_with_quiz.add(r.created_at[:10])