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:
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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"},
|
||||
)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user