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
+117
View File
@@ -0,0 +1,117 @@
from fastapi import APIRouter, Depends
from sqlalchemy import func
from sqlalchemy.orm import Session
from auth import get_current_user
from database import get_db
from models import User, WordBookEntry
from schemas import (
ActiveBookOut,
BookPracticeSettingsOut,
BookPracticeSettingsUpdate,
SetActiveBookRequest,
WordBookDetailOut,
WordBookOut,
WordBookProgressOut,
WordBookUnitOut,
)
from services.book_service import book_service
router = APIRouter(prefix="/api/books", tags=["books"])
def _active_out(db: Session, user: User, book) -> ActiveBookOut:
progress = book_service.book_progress(db, user, book.id)
settings = book_service.practice_settings_dict(db, user, book)
return ActiveBookOut(
book=WordBookOut.model_validate(book),
progress=WordBookProgressOut(**progress),
settings=BookPracticeSettingsOut(**settings),
)
@router.get("", response_model=list[WordBookOut])
def list_books(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
return book_service.list_books(db)
@router.get("/settings", response_model=BookPracticeSettingsOut)
def get_book_practice_settings(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
book = book_service.get_active_book(db, current_user)
if not book:
from fastapi import HTTPException
raise HTTPException(status_code=400, detail="请先在词书设置中选择词书")
data = book_service.practice_settings_dict(db, current_user, book)
return BookPracticeSettingsOut(**data)
@router.patch("/settings", response_model=ActiveBookOut)
def update_book_practice_settings(
data: BookPracticeSettingsUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
book = book_service.get_active_book(db, current_user)
if data.book_id:
book = book_service.activate_book(db, current_user, data.book_id)
elif not book:
from fastapi import HTTPException
raise HTTPException(status_code=400, detail="请先选择词书")
payload = data.model_dump(exclude_unset=True, exclude={"book_id"})
if payload:
book_service.update_practice_settings(db, current_user, book.id, payload)
return _active_out(db, current_user, book)
@router.get("/active", response_model=ActiveBookOut)
def get_active_book(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
book = book_service.get_active_book(db, current_user)
if not book:
return ActiveBookOut(book=None, progress=None, settings=None)
return _active_out(db, current_user, book)
@router.post("/active", response_model=ActiveBookOut)
def set_active_book(
data: SetActiveBookRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
book = book_service.activate_book(db, current_user, data.book_id)
return _active_out(db, current_user, book)
@router.get("/{book_id}", response_model=WordBookDetailOut)
def get_book_detail(
book_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
book = book_service.get_book(db, book_id)
rows = (
db.query(
WordBookEntry.unit,
WordBookEntry.unit_title,
func.count(WordBookEntry.id),
)
.filter(WordBookEntry.book_id == book.id)
.group_by(WordBookEntry.unit, WordBookEntry.unit_title)
.order_by(WordBookEntry.unit.asc())
.all()
)
units = [WordBookUnitOut(unit=r[0], title=r[1], word_count=r[2]) for r in rows]
return WordBookDetailOut(
**WordBookOut.model_validate(book).model_dump(),
units=units,
)
+5 -2
View File
@@ -1,4 +1,6 @@
from fastapi import APIRouter, Depends
from typing import Literal
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from auth import get_current_user
@@ -12,10 +14,11 @@ router = APIRouter(prefix="/api/coach", tags=["coach"])
@router.get("/session", response_model=CoachSessionResponse)
def coach_session(
track: Literal["accumulation", "book"] = Query("accumulation"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
return memory_coach_service.start_session(db, current_user)
return memory_coach_service.start_session(db, current_user, track=track)
@router.post("/turn", response_model=CoachTurnResponse)
+9 -4
View File
@@ -1,4 +1,6 @@
from fastapi import APIRouter, Depends
from typing import Literal, Optional
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from auth import get_current_user
@@ -17,19 +19,21 @@ router = APIRouter(prefix="/api/quiz", tags=["quiz"])
@router.get("/daily", response_model=DailyQuizResponse)
def daily_quiz(
track: Literal["accumulation", "book"] = Query("accumulation"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = quiz_service.get_daily_quiz(db, current_user)
result = quiz_service.get_daily_quiz(db, current_user, track=track)
return DailyQuizResponse(**result)
@router.get("/spell", response_model=DailyQuizResponse)
def spell_quiz(
track: Literal["accumulation", "book"] = Query("accumulation"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = quiz_service.get_spell_quiz(db, current_user)
result = quiz_service.get_spell_quiz(db, current_user, track=track)
return DailyQuizResponse(**result)
@@ -53,7 +57,8 @@ def submit_answer(
@router.get("/stats", response_model=QuizStatsResponse)
def quiz_stats(
track: Literal["accumulation", "book"] = Query("accumulation"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
return quiz_service.get_stats(db, current_user)
return quiz_service.get_stats(db, current_user, track=track)
+4 -2
View File
@@ -34,18 +34,20 @@ def create_word(
@router.get("", response_model=list[WordOut])
def list_words(
status: Optional[str] = None,
book_id: Optional[int] = None,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
return word_service.list_words(db, current_user, status)
return word_service.list_words(db, current_user, status, book_id=book_id)
@router.get("/memory-viz", response_model=MemoryVisualizationResponse)
def memory_visualization(
book_id: int = 0,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
return memory_visual_service.get_visualization(db, current_user)
return memory_visual_service.get_visualization(db, current_user, book_id=book_id)
@router.get("/{word_id}/memory", response_model=WordMemoryDetailResponse)