Ship native iOS app, Wiki/TTS backend, and skeleton loading UX.

Replace the WebView shell with SwiftUI screens, add account-scoped Wiki and TTS APIs with adaptive review and photo scan support, and keep web/iOS pages usable while data loads asynchronously.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-06-08 15:13:59 +08:00
parent db5465c646
commit 05e173b293
115 changed files with 12709 additions and 1212 deletions
+25 -1
View File
@@ -8,6 +8,8 @@ from database import get_db
from models import User
from schemas import (
DailyQuizResponse,
ErrorBankListResponse,
PracticePlanResetResponse,
QuizAnswerRequest,
QuizAnswerResponse,
QuizStatsResponse,
@@ -20,10 +22,11 @@ router = APIRouter(prefix="/api/quiz", tags=["quiz"])
@router.get("/daily", response_model=DailyQuizResponse)
def daily_quiz(
track: Literal["accumulation", "book"] = Query("accumulation"),
pool: Literal["untrained", "errors", "error_bank"] = Query("untrained"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = quiz_service.get_daily_quiz(db, current_user, track=track)
result = quiz_service.get_daily_quiz(db, current_user, track=track, pool=pool)
return DailyQuizResponse(**result)
@@ -51,10 +54,20 @@ def submit_answer(
data.user_answer,
data.correct_answer,
data.duration_seconds or 0,
pool=data.pool,
)
return QuizAnswerResponse(**result)
@router.get("/error-bank", response_model=ErrorBankListResponse)
def error_bank_list(
track: Literal["accumulation", "book"] = Query("accumulation"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
return ErrorBankListResponse(**quiz_service.list_error_bank(db, current_user, track=track))
@router.get("/stats", response_model=QuizStatsResponse)
def quiz_stats(
track: Literal["accumulation", "book"] = Query("accumulation"),
@@ -62,3 +75,14 @@ def quiz_stats(
current_user: User = Depends(get_current_user),
):
return quiz_service.get_stats(db, current_user, track=track)
@router.post("/reset-plan", response_model=PracticePlanResetResponse)
def reset_practice_plan(
track: Literal["accumulation", "book"] = Query("accumulation"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
return PracticePlanResetResponse(
**quiz_service.reset_practice_plan_for_track(db, current_user, track=track)
)
+4
View File
@@ -20,6 +20,7 @@ def get_settings(
daily_target=s.daily_target,
master_required_count=s.master_required_count,
weak_wrong_threshold=s.weak_wrong_threshold,
error_clear_correct_count=s.error_clear_correct_count,
)
@@ -36,10 +37,13 @@ def update_settings(
s.master_required_count = data.master_required_count
if data.weak_wrong_threshold is not None:
s.weak_wrong_threshold = data.weak_wrong_threshold
if data.error_clear_correct_count is not None:
s.error_clear_correct_count = data.error_clear_correct_count
db.commit()
db.refresh(s)
return SettingsOut(
daily_target=s.daily_target,
master_required_count=s.master_required_count,
weak_wrong_threshold=s.weak_wrong_threshold,
error_clear_correct_count=s.error_clear_correct_count,
)
+25
View File
@@ -0,0 +1,25 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import Response
from auth import get_current_user
from models import User
from services.tts_service import TtsServiceError, tts_service
router = APIRouter(prefix="/api", tags=["tts"])
@router.get("/tts/speak")
def speak_word(
text: str = Query(..., min_length=1, max_length=200),
current_user: User = Depends(get_current_user),
):
try:
audio_bytes, content_type = tts_service.synthesize(text)
except TtsServiceError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
return Response(
content=audio_bytes,
media_type=content_type,
headers={"Cache-Control": "private, max-age=86400"},
)
+52
View File
@@ -0,0 +1,52 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from auth import get_current_user
from database import get_db
from models import User
from schemas import WikiGraphOut, WikiSettingsUpdate, WikiStatusOut
from services.wiki_llm_quota_service import WikiLlmQuotaExceeded
from services.wiki_service import wiki_service
router = APIRouter(prefix="/api/wiki", tags=["wiki"])
@router.get("/status", response_model=WikiStatusOut)
def get_wiki_status(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
return wiki_service.status(db, current_user)
@router.get("/graph", response_model=WikiGraphOut)
def get_wiki_graph(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
return wiki_service.graph(db, current_user)
@router.patch("/settings", response_model=WikiStatusOut)
def update_wiki_settings(
data: WikiSettingsUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
return wiki_service.update_settings(
db,
current_user,
enabled=data.enabled,
auto_organize=data.auto_organize,
)
@router.post("/rebuild", response_model=WikiStatusOut)
def rebuild_wiki(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
try:
return wiki_service.rebuild(db, current_user)
except WikiLlmQuotaExceeded as exc:
raise HTTPException(status_code=429, detail=str(exc)) from exc
+72 -6
View File
@@ -1,6 +1,8 @@
from typing import Optional
from typing import Optional, Union
from fastapi import APIRouter, Depends
from pathlib import Path
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from sqlalchemy.orm import Session
from auth import get_current_user
@@ -9,13 +11,20 @@ from models import User
from schemas import (
MemoryTransformerPredictResponse,
MemoryVisualizationResponse,
WordBatchCreateRequest,
WordBatchCreateResponse,
WordCreate,
WordListPageOut,
WordMemoryDetailResponse,
WordOut,
WordScanItem,
WordScanResponse,
WordScanTextRequest,
WordUpdate,
)
from services.memory_transformer import memory_transformer_service
from services.memory_visual_service import memory_visual_service
from services.word_scan_service import word_scan_service
from services.word_service import word_service
router = APIRouter(prefix="/api/words", tags=["words"])
@@ -31,13 +40,70 @@ def create_word(
return word
@router.get("", response_model=list[WordOut])
def list_words(
status: Optional[str] = None,
book_id: Optional[int] = None,
@router.post("/scan-text", response_model=WordScanResponse)
def scan_word_text(
data: WordScanTextRequest,
current_user: User = Depends(get_current_user),
):
items = word_scan_service.parse_scan_text(data.text)
return WordScanResponse(items=[WordScanItem(**item) for item in items])
@router.post("/scan-image", response_model=WordScanResponse)
async def scan_word_image(
file: UploadFile = File(...),
current_user: User = Depends(get_current_user),
):
data = await file.read()
if len(data) > 10 * 1024 * 1024:
raise HTTPException(status_code=400, detail="图片不能超过 10MB")
suffix = Path(file.filename or "scan.png").suffix or ".png"
items = word_scan_service.scan_image(data, suffix=suffix)
return WordScanResponse(items=[WordScanItem(**item) for item in items])
@router.post("/batch", response_model=WordBatchCreateResponse)
def batch_create_words(
data: WordBatchCreateRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = word_service.batch_create_words(
db,
current_user,
[item.model_dump() for item in data.items],
)
return WordBatchCreateResponse(
created=result["created"],
skipped=result["skipped"],
items=result["items"],
)
@router.get("", response_model=Union[list[WordOut], WordListPageOut])
def list_words(
status: Optional[str] = None,
book_id: Optional[int] = None,
page: Optional[int] = None,
page_size: int = 20,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
if page is not None:
items, total = word_service.list_words_page(
db,
current_user,
status=status,
book_id=book_id,
page=page,
page_size=page_size,
)
return WordListPageOut(
items=items,
total=total,
page=max(1, page),
page_size=max(1, min(page_size, 100)),
)
return word_service.list_words(db, current_user, status, book_id=book_id)