diff --git a/.claude/Claude.md b/.claude/Claude.md index ed66e9b..0b2a6be 100644 --- a/.claude/Claude.md +++ b/.claude/Claude.md @@ -101,10 +101,10 @@ speakeasy/ | user_facts | V0.3 | LLM 提取的跨会话事实记忆 | | pronunciation_cards | V0.4 | FSRS 管理的发音练习卡片 | | subtitle_sources | V0.4 | B站字幕缓存 | -| vocabulary | V0.5 | 翻译生词本收藏(软删除,无 FSRS)(V0.8 起 explain 接口发起即自动入库,explanation_json 由解读完成回填) | +| vocabulary | V0.5 | 翻译生词本收藏(软删除)(V0.8 起 explain 自动入库;source_type='article' + series 字段区分来源系列) | | ask_threads | V0.7 | 跨页面通用追问 thread(scope + ref_type/ref_id) | | ask_messages | V0.7 | 追问 thread 的消息列表 | -| bbc_eaw_episodes | V0.7.x | BBC English at Work 67 集语料(共享,不绑 user_id),见 `docs/datasets/bbc_eaw.md` | +| article_episodes | V0.7.x | 文章系列语料(共享,不绑 user_id),series 字段区分 bbc_eaw/voa 等(原 bbc_eaw_episodes,Issue #42 重命名) | ### API 接口清单 @@ -140,6 +140,12 @@ speakeasy/ | `/articles/explain` | POST | V0.8 | 单次词/句解读,发起即自动写入 vocabulary | | `/articles/tts` | POST | V0.8 | 文章 TTS 语音合成 | | `/vocabulary` | GET | V0.8 | 生词本独立页(支持 source_type 过滤) | +| `/articles/episodes` | GET | #42 | 文章系列列表(原 /bbc-eaw/episodes,支持 series 过滤) | +| `/articles/episodes/{slug}` | GET | #42 | 单集详情(原 /bbc-eaw/episodes/{slug}) | +| `/articles/episodes/{slug}/start` | POST | #42 | 开始学习(原 /bbc-eaw/{slug}/start) | +| `/articles/episodes/{slug}/review` | GET | #42 | 文章复习题(原 /bbc-eaw/{slug}/review) | +| `/articles/episodes/{slug}/rate` | POST | #42 | FSRS 评分(原 /bbc-eaw/{slug}/rate) | +| `/articles/due` | GET | #42 | 到期文章列表(原 /bbc-eaw/due) | | `/ask/threads` | POST | V0.7 | 通用追问:新建 thread + 首轮问答 | | `/ask/threads/{id}/messages` | POST | V0.7 | 追问某 thread(带历史) | | `/ask/threads` | GET | V0.7 | 列出 thread(按 scope/ref_type/ref_id 过滤) | diff --git a/app/models/db.py b/app/models/db.py index ee56c56..4cae023 100644 --- a/app/models/db.py +++ b/app/models/db.py @@ -255,6 +255,9 @@ class ArticleEpisode(Base): updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) +BbcEawEpisode = ArticleEpisode # 向后兼容别名 + + # ── V0.10 BBC 文章级 SRS ────────────────────────────────── # 每个 (user, slug) 一张 FSRS 卡,文章作为复习单元 # 题目按 slug 共享,懒加载首次复习时由 LLM 生成 diff --git a/app/routers/articles.py b/app/routers/articles.py index 69f473b..cc13164 100644 --- a/app/routers/articles.py +++ b/app/routers/articles.py @@ -81,8 +81,9 @@ class ExplainRequest(BaseModel): context: Optional[str] = "" # word 模式下可提供所在句子 refresh: bool = False # V0.11 #8 · 跳过缓存强制重新生成 # V0.8 自动入库:可选,传了就在解读入口自动写 vocabulary - source_type: Optional[str] = None # 'bbc_eaw' | 'translate' | ... - source_ref: Optional[str] = None # 例: BBC episode slug + source_type: Optional[str] = None # 'article' | 'translate' | ... + source_ref: Optional[str] = None # 例: episode slug + series: Optional[str] = None # bbc_eaw | voa | ... (when source_type='article') item_type: Optional[str] = None # 'word' | 'phrase' | 'sentence',缺省由 kind 推断 @@ -346,6 +347,7 @@ def _auto_save_vocab( source_type: Optional[str], source_ref: Optional[str], item_type: str, + series: Optional[str] = None, ) -> bool: """解读发起时占位写入 vocabulary(explanation_json=None)。 @@ -369,10 +371,11 @@ def _auto_save_vocab( source_type=source_type, source_ref=source_ref, explanation_json=None, + series=series, ) logger.info( - "vocab_auto_save_attempt user_id=%s text=%s source_type=%s source_ref=%s item_type=%s", - user_id, text[:40], source_type, source_ref, item_type, + "vocab_auto_save_attempt user_id=%s text=%s source_type=%s series=%s source_ref=%s item_type=%s", + user_id, text[:40], source_type, series, source_ref, item_type, ) return True except Exception as e: @@ -395,7 +398,7 @@ async def practice_explain( _auto_save_vocab( user_id=user_id, text=req.text, context=req.context, source_type=req.source_type, source_ref=req.source_ref, - item_type=item_type, + item_type=item_type, series=req.series, ) try: @@ -433,6 +436,7 @@ class QuickPhoneticRequest(BaseModel): source_type: Optional[str] = None source_ref: Optional[str] = None item_type: Optional[str] = None # 前端推断后传入,避免与后续 explain 不一致 + series: Optional[str] = None # bbc_eaw | voa | ... @router.post("/explain/phonetic") @@ -449,7 +453,7 @@ async def practice_explain_phonetic( _auto_save_vocab( user_id=user_id, text=req.text, context=None, source_type=req.source_type, source_ref=req.source_ref, - item_type=item_type, + item_type=item_type, series=req.series, ) return {"phonetic": quick_phonetic(req.text)} @@ -461,6 +465,7 @@ class StreamExplainRequest(BaseModel): source_type: Optional[str] = None source_ref: Optional[str] = None item_type: Optional[str] = None + series: Optional[str] = None # bbc_eaw | voa | ... context: Optional[str] = "" # 句子级 context(如所在段落) @@ -485,7 +490,7 @@ async def practice_explain_stream( _auto_save_vocab( user_id=user_id, text=req.text, context=req.context, source_type=req.source_type, source_ref=req.source_ref, - item_type=item_type, + item_type=item_type, series=req.series, ) async def event_stream(): diff --git a/app/routers/bbc_eaw.py b/app/routers/bbc_eaw.py index 1b596ff..32a7418 100644 --- a/app/routers/bbc_eaw.py +++ b/app/routers/bbc_eaw.py @@ -1,4 +1,7 @@ -"""BBC English at Work 公共语料 API · 共享只读,不绑 user_id""" +"""文章系列公共语料 API · 共享只读,不绑 user_id + +路由前缀:/articles/episodes(由 main.py 注册) +""" import json from typing import Optional @@ -6,26 +9,30 @@ from fastapi import APIRouter, HTTPException, Query from sqlalchemy.orm import Session as OrmSession -from app.models.db import BbcEawEpisode, engine +from app.models.db import ArticleEpisode, engine router = APIRouter() -@router.get("/bbc-eaw/episodes") +@router.get("/articles/episodes") async def list_episodes( limit: int = Query(100, ge=1, le=200), topic: Optional[str] = None, + series: Optional[str] = None, ): - """列出 EAW 集 · 公共数据,无需登录""" + """列出文章集 · 公共数据,无需登录""" with OrmSession(engine) as s: - q = s.query(BbcEawEpisode).order_by(BbcEawEpisode.id.asc()) + q = s.query(ArticleEpisode).order_by(ArticleEpisode.id.asc()) if topic: - q = q.filter(BbcEawEpisode.topic == topic) + q = q.filter(ArticleEpisode.topic == topic) + if series: + q = q.filter(ArticleEpisode.series == series) rows = q.limit(limit).all() return { "items": [ { "slug": r.slug, + "series": r.series, "title": r.title, "episode_id": r.episode_id, "topic": r.topic, @@ -39,19 +46,17 @@ async def list_episodes( } -@router.get("/bbc-eaw/episodes/{slug}") +@router.get("/articles/episodes/{slug}") async def get_episode(slug: str): """取单集详情 · 把 transcript 转成 PracticePlayer 期待的 segments 形态""" with OrmSession(engine) as s: - row = s.query(BbcEawEpisode).filter_by(slug=slug).first() + row = s.query(ArticleEpisode).filter_by(slug=slug).first() if not row: raise HTTPException(404, "episode not found") transcript = json.loads(row.transcript_json or "[]") phrases = json.loads(row.phrases_json or "[]") - # transcript: [{speaker, text}] → segments: [{content, speaker}] - # PracticePlayer 用 seg.content 做主键,无时间戳就用顺序 idx 做兜底 segments = [ { "content": item.get("text", ""), @@ -68,7 +73,8 @@ async def get_episode(slug: str): "title": row.title, "topic": row.topic, "description": row.description, - "source_type": "bbc_eaw", + "series": row.series, + "source_type": "article", "segments": segments, "phrases": phrases, "listening_question": row.listening_question, diff --git a/app/routers/bbc_review.py b/app/routers/bbc_review.py index bfcf896..0edea9a 100644 --- a/app/routers/bbc_review.py +++ b/app/routers/bbc_review.py @@ -1,4 +1,7 @@ -"""BBC 文章级 SRS 路由 — V0.10""" +"""文章级 SRS 路由 — V0.10 + +路由前缀:/articles/episodes(由 main.py 注册) +""" from typing import List, Optional from fastapi import APIRouter, Depends, HTTPException @@ -25,7 +28,7 @@ class RateRequest(BaseModel): wrong_segments: Optional[List[WrongSegment]] = None -@router.post("/bbc-eaw/{slug}/start", status_code=201) +@router.post("/articles/episodes/{slug}/start", status_code=201) async def start(slug: str, user_id: str = Depends(get_current_user_id)): try: return bbc_article_srs.start_studying(user_id, slug) @@ -33,7 +36,7 @@ async def start(slug: str, user_id: str = Depends(get_current_user_id)): raise HTTPException(404, str(e)) -@router.get("/bbc-eaw/{slug}/review") +@router.get("/articles/episodes/{slug}/review") async def review(slug: str, user_id: str = Depends(get_current_user_id)): """取该文章的复习题;未生成则懒加载首次生成。""" try: @@ -45,7 +48,7 @@ async def review(slug: str, user_id: str = Depends(get_current_user_id)): return {"slug": slug, "questions": questions, "count": len(questions)} -@router.post("/bbc-eaw/{slug}/rate") +@router.post("/articles/episodes/{slug}/rate") async def rate( slug: str, req: RateRequest, @@ -60,7 +63,7 @@ async def rate( raise HTTPException(404, str(e)) -@router.get("/bbc-eaw/due") +@router.get("/articles/due") async def due(user_id: str = Depends(get_current_user_id)): items = bbc_article_srs.list_due_articles(user_id) return {"items": items, "count": len(items)} diff --git a/app/routers/vocab.py b/app/routers/vocab.py index d4af64f..42dcd7e 100644 --- a/app/routers/vocab.py +++ b/app/routers/vocab.py @@ -32,9 +32,10 @@ class VocabCreate(BaseModel): direction: str = "en2zh" context: Optional[str] = None item_type: str = "sentence" # word | phrase | sentence - source_type: str = "translate" # translate | bbc_eaw | practice | chat + source_type: str = "translate" # translate | article | practice | chat source_ref: Optional[str] = None explanation_json: Optional[str] = None + series: Optional[str] = None # bbc_eaw | voa | ... (when source_type='article') class VocabRate(BaseModel): @@ -54,6 +55,7 @@ async def create_vocab(req: VocabCreate, user_id: str = Depends(get_current_user source_type=req.source_type, source_ref=req.source_ref, explanation_json=req.explanation_json, + series=req.series, ) except ValueError as e: raise HTTPException(400, str(e)) diff --git a/app/services/bbc_article_srs.py b/app/services/bbc_article_srs.py index fec5f97..da3784c 100644 --- a/app/services/bbc_article_srs.py +++ b/app/services/bbc_article_srs.py @@ -1,7 +1,7 @@ """BBC 文章级 SRS 服务 — V0.10 每个 (user_id, slug) 一张 FSRS 卡。题目按 slug 共享缓存。 -答错的句子会落入 Vocabulary(item_type=sentence、source=bbc_eaw)。 +答错的句子会落入 Vocabulary(item_type=sentence、source_type=article、series=bbc_eaw)。 """ import json from datetime import datetime @@ -13,7 +13,7 @@ engine, BbcArticleCard, BbcArticleQuestion, - BbcEawEpisode, + ArticleEpisode, ) from app.services.fsrs_utils import ( create_card, @@ -28,7 +28,7 @@ logger = get_logger("bbc_article_srs") -def _card_to_dict(c: BbcArticleCard, episode: Optional[BbcEawEpisode] = None) -> Dict: +def _card_to_dict(c: BbcArticleCard, episode: Optional[ArticleEpisode] = None) -> Dict: return { "id": c.id, "user_id": c.user_id, @@ -59,7 +59,7 @@ def _question_to_dict(q: BbcArticleQuestion) -> Dict: def start_studying(user_id: str, slug: str) -> Dict: """首次学习 / 已有则返回当前卡。""" with OrmSession(engine) as s: - episode = s.query(BbcEawEpisode).filter_by(slug=slug).first() + episode = s.query(ArticleEpisode).filter_by(slug=slug).first() if not episode: raise LookupError(f"BBC episode not found: {slug}") @@ -160,7 +160,7 @@ def rate_card( s.commit() s.refresh(card) - episode = s.query(BbcEawEpisode).filter_by(slug=slug).first() + episode = s.query(ArticleEpisode).filter_by(slug=slug).first() result = _card_to_dict(card, episode) # 答错联动:在事务外做(vocab_service 自管 session) @@ -186,7 +186,7 @@ def list_due_articles(user_id: str) -> List[Dict]: slugs = {c.slug for c in cards} episodes = { e.slug: e - for e in s.query(BbcEawEpisode).filter(BbcEawEpisode.slug.in_(slugs)).all() + for e in s.query(ArticleEpisode).filter(ArticleEpisode.slug.in_(slugs)).all() } result = [] for c in cards: diff --git a/app/services/bbc_eaw_seeder.py b/app/services/bbc_eaw_seeder.py index 2fa6fe2..dfcd60e 100644 --- a/app/services/bbc_eaw_seeder.py +++ b/app/services/bbc_eaw_seeder.py @@ -1,4 +1,4 @@ -"""Seed bbc_eaw_episodes from data/bbc_eaw/parsed/*.json. +"""Seed article_episodes from data/bbc_eaw/parsed/*.json. Idempotent upsert by `slug`. Used by: - scripts/bbc_eaw_seed.py (CLI / one-shot) @@ -14,7 +14,7 @@ from sqlalchemy.orm import Session as OrmSession -from app.models.db import BbcEawEpisode, engine +from app.models.db import ArticleEpisode, engine log = logging.getLogger(__name__) @@ -55,12 +55,12 @@ def seed(parsed_dir: Path = DEFAULT_PARSED_DIR, for f in files: parsed = json.loads(f.read_text(encoding="utf-8")) slug = parsed["slug"] - row = sess.query(BbcEawEpisode).filter_by(slug=slug).one_or_none() + row = sess.query(ArticleEpisode).filter_by(slug=slug).one_or_none() fields = _row_fields(parsed, Path(raw_dir)) if row is None: inserted += 1 if not dry_run: - sess.add(BbcEawEpisode(**fields)) + sess.add(ArticleEpisode(**fields)) else: updated += 1 if not dry_run: diff --git a/app/services/bbc_question_gen.py b/app/services/bbc_question_gen.py index ce1a903..9a63e71 100644 --- a/app/services/bbc_question_gen.py +++ b/app/services/bbc_question_gen.py @@ -18,7 +18,7 @@ from sqlalchemy.orm import Session as OrmSession -from app.models.db import engine, BbcEawEpisode +from app.models.db import engine, ArticleEpisode from app.prompts.bbc_questions import BBC_QUESTION_GEN_PROMPT from app.logger import get_logger @@ -27,9 +27,9 @@ VALID_QTYPES = {"cloze", "back_translate", "recall_prompt"} -def _load_episode(slug: str) -> Optional[BbcEawEpisode]: +def _load_episode(slug: str) -> Optional[ArticleEpisode]: with OrmSession(engine) as s: - return s.query(BbcEawEpisode).filter_by(slug=slug).first() + return s.query(ArticleEpisode).filter_by(slug=slug).first() def _build_cloze(transcript: List[Dict], phrases: List[str]) -> Optional[Dict]: @@ -120,7 +120,7 @@ def _validate_question(q: Dict) -> Optional[Dict]: } -async def _generate_via_llm(episode: BbcEawEpisode) -> List[Dict]: +async def _generate_via_llm(episode: ArticleEpisode) -> List[Dict]: """LLM 出题;任何异常向上抛,由 generate_questions 捕获后回退。""" from app.services.model_client import get_client # 延迟导入以便测试时无 LLM 也能用 @@ -158,7 +158,7 @@ async def _generate_via_llm(episode: BbcEawEpisode) -> List[Dict]: return validated[:6] -def _generate_deterministic(episode: BbcEawEpisode) -> List[Dict]: +def _generate_deterministic(episode: ArticleEpisode) -> List[Dict]: transcript = json.loads(episode.transcript_json or "[]") phrases = json.loads(episode.phrases_json or "[]") transcript = [t for t in transcript if (t.get("text") or "").strip()] diff --git a/app/services/model_client.py b/app/services/model_client.py index f822361..08ade8b 100644 --- a/app/services/model_client.py +++ b/app/services/model_client.py @@ -1,3 +1,4 @@ +from __future__ import annotations import os import time from typing import AsyncGenerator, Optional diff --git a/frontend/src/components/ExplanationModal.vue b/frontend/src/components/ExplanationModal.vue index 761d1b0..ee0795e 100644 --- a/frontend/src/components/ExplanationModal.vue +++ b/frontend/src/components/ExplanationModal.vue @@ -204,10 +204,11 @@ const _refStr = (v) => (v == null || v === '' ? null : String(v)) function deriveVocabSource() { // 来源由父组件透传到 target.context;下面的 fallback 兼容老调用 const ctx = props.target?.context || {} - let source_type, raw + let source_type, raw, series = null if (ctx.vocab_source_type) { source_type = ctx.vocab_source_type raw = ctx.vocab_source_ref + series = ctx.vocab_series || null } else if (ctx.source === 'chat') { source_type = 'chat' raw = ctx.session_id @@ -219,7 +220,7 @@ function deriveVocabSource() { source_type = 'translate' raw = null } - return { source_type, source_ref: _refStr(raw) } + return { source_type, source_ref: _refStr(raw), series } } const data = ref({ @@ -272,7 +273,7 @@ function reset() { } async function fetchWord(refresh = false) { - const { source_type, source_ref } = deriveVocabSource() + const { source_type, source_ref, series } = deriveVocabSource() // 1. IPA 快拿(本地秒出 · 不阻塞主请求) try { const p = await authFetchJson(`${API.ARTICLES}/explain/phonetic`, { @@ -280,6 +281,7 @@ async function fetchWord(refresh = false) { source_type: source_type || null, source_ref: source_ref || null, item_type: inferredItemType.value, + series: series || null, }) data.value.phonetic = p.phonetic || '' } catch { @@ -297,6 +299,7 @@ async function fetchWord(refresh = false) { source_type: source_type || null, source_ref: source_ref || null, item_type: inferredItemType.value, + series: series || null, }) const exp = resp.explanation || {} Object.assign(data.value, { @@ -339,7 +342,7 @@ function _scheduleUpdate(field, value) { async function fetchSentence(refresh = false) { loading.value = true - const { source_type, source_ref } = deriveVocabSource() + const { source_type, source_ref, series } = deriveVocabSource() try { await sseStream( `${API.ARTICLES}/explain/stream`, @@ -349,6 +352,7 @@ async function fetchSentence(refresh = false) { source_type: source_type || null, source_ref: source_ref || null, item_type: inferredItemType.value, + series: series || null, context: props.target.sentence || '', }, { diff --git a/frontend/src/components/translate/TopBar.vue b/frontend/src/components/translate/TopBar.vue new file mode 100644 index 0000000..bb4cbe5 --- /dev/null +++ b/frontend/src/components/translate/TopBar.vue @@ -0,0 +1,144 @@ + + + + + diff --git a/frontend/src/components/translate/TranslateInput.vue b/frontend/src/components/translate/TranslateInput.vue new file mode 100644 index 0000000..62d7ab8 --- /dev/null +++ b/frontend/src/components/translate/TranslateInput.vue @@ -0,0 +1,122 @@ + + + + + diff --git a/frontend/src/components/translate/TranslateResult.vue b/frontend/src/components/translate/TranslateResult.vue new file mode 100644 index 0000000..9bbbd03 --- /dev/null +++ b/frontend/src/components/translate/TranslateResult.vue @@ -0,0 +1,188 @@ + + + + + diff --git a/frontend/src/components/translate/TranslateSidebar.vue b/frontend/src/components/translate/TranslateSidebar.vue new file mode 100644 index 0000000..1c50aef --- /dev/null +++ b/frontend/src/components/translate/TranslateSidebar.vue @@ -0,0 +1,228 @@ + + + + + diff --git a/frontend/src/composables/usePractice.js b/frontend/src/composables/usePractice.js index 901d83c..018d9ba 100644 --- a/frontend/src/composables/usePractice.js +++ b/frontend/src/composables/usePractice.js @@ -82,7 +82,7 @@ export function usePractice() { * BBC English at Work 公共语料 · 列表 */ async function listEawEpisodes(limit = 100) { - const resp = await authFetch(`/bbc-eaw/episodes?limit=${limit}`) + const resp = await authFetch(`/articles/episodes?limit=${limit}`) if (!resp.ok) throw new Error('加载 BBC EAW 失败') return resp.json() } @@ -91,7 +91,7 @@ export function usePractice() { * BBC English at Work 单集 · 转 segments 与 user 字幕同形态 */ async function getEawEpisode(slug) { - const resp = await authFetch(`/bbc-eaw/episodes/${encodeURIComponent(slug)}`) + const resp = await authFetch(`/articles/episodes/${encodeURIComponent(slug)}`) if (!resp.ok) throw new Error('加载 EAW 单集失败') return resp.json() } diff --git a/frontend/src/views/Articles.vue b/frontend/src/views/Articles.vue index 6cc8a6f..6f08042 100644 --- a/frontend/src/views/Articles.vue +++ b/frontend/src/views/Articles.vue @@ -44,7 +44,7 @@ const hasSource = computed( ) const isBbcSource = computed(() => { const s = store.currentSource - return !!s && (s.source_type === 'bbc_eaw' || s.type === 'bbc_eaw') + return !!s && (s.source_type === 'article' || s.series === 'bbc_eaw' || s.source_type === 'bbc_eaw' || s.type === 'bbc_eaw') }) function showToast(text, type = 'info', duration = 2500) { @@ -189,12 +189,12 @@ function onExplain(payload) { if (payload.sentence) target.sentence = payload.sentence } const src = store.currentSource || {} - const isBbc = src.source_type === 'bbc_eaw' || src.type === 'bbc_eaw' + const isBbc = src.source_type === 'article' || src.series === 'bbc_eaw' || src.source_type === 'bbc_eaw' || src.type === 'bbc_eaw' // SubtitleSource.id 是 Integer,必须强转 string 后再塞入; // 后端 source_ref: Optional[str],数字会被 Pydantic 拒收。 const refStr = (v) => (v == null || v === '' ? null : String(v)) const vocabRef = isBbc - ? { vocab_source_type: 'bbc_eaw', vocab_source_ref: refStr(src.slug) || refStr(src.id) } + ? { vocab_source_type: 'article', vocab_source_ref: refStr(src.slug) || refStr(src.id), vocab_series: 'bbc_eaw' } : { vocab_source_type: 'practice', vocab_source_ref: refStr(src.id) || refStr(src.source_id) } target.context = { source: 'practice', diff --git a/frontend/src/views/BbcArticleReview.vue b/frontend/src/views/BbcArticleReview.vue index 1cb8d9e..34824a6 100644 --- a/frontend/src/views/BbcArticleReview.vue +++ b/frontend/src/views/BbcArticleReview.vue @@ -3,10 +3,10 @@ * BBC 文章复习 · V0.10 * * 流程: - * 1. start (POST /bbc-eaw/:slug/start) 确保卡存在 - * 2. review (GET /bbc-eaw/:slug/review) 取题 + * 1. start (POST /articles/episodes/:slug/start) 确保卡存在 + * 2. review (GET /articles/episodes/:slug/review) 取题 * 3. 用户做完所有题 → 评分弹窗(Again/Hard/Good/Easy) - * 4. POST /bbc-eaw/:slug/rate · wrong_segments 联动入生词本 + * 4. POST /articles/episodes/:slug/rate · wrong_segments 联动入生词本 */ import { ref, computed, onMounted } from 'vue' import { useRoute, useRouter } from 'vue-router' @@ -46,17 +46,17 @@ async function bootstrap() { loading.value = true error.value = '' try { - const articleResp = await authFetch(`${API.BASE}/bbc-eaw/episodes/${slug.value}`) + const articleResp = await authFetch(`${API.BASE}/articles/episodes/${slug.value}`) if (!articleResp.ok) throw new Error('加载文章失败') article.value = await articleResp.json() card.value = await authFetchJson( - `${API.BASE}/bbc-eaw/${slug.value}/start`, + `${API.BASE}/articles/episodes/${slug.value}/start`, {}, { method: 'POST' }, ) - const reviewResp = await authFetch(`${API.BASE}/bbc-eaw/${slug.value}/review`) + const reviewResp = await authFetch(`${API.BASE}/articles/episodes/${slug.value}/review`) if (!reviewResp.ok) { const detail = (await reviewResp.json().catch(() => ({}))).detail throw new Error(detail || '题目加载失败') @@ -124,7 +124,7 @@ function buildWrongPayload() { async function submitRating(rating) { submitting.value = true try { - const result = await authFetchJson(`${API.BASE}/bbc-eaw/${slug.value}/rate`, { + const result = await authFetchJson(`${API.BASE}/articles/episodes/${slug.value}/rate`, { rating, wrong_segments: buildWrongPayload(), }) diff --git a/frontend/src/views/Memory.vue b/frontend/src/views/Memory.vue index 3f0b00f..09e603b 100644 --- a/frontend/src/views/Memory.vue +++ b/frontend/src/views/Memory.vue @@ -26,10 +26,14 @@ const vocabExpandedId = ref(null) const SOURCE_LABEL = { translate: '解读', - bbc_eaw: 'BBC', + article: '文章', practice: '练习', chat: '对话', } +const SERIES_LABEL = { + bbc_eaw: 'BBC', + voa: 'VOA', +} const TYPE_LABEL = { word: '词', phrase: '短语', sentence: '句' } @@ -273,7 +277,7 @@ watch(vocabFilter, () => loadVocab())
📍 {{ v.context }}
- 来源:{{ SOURCE_LABEL[v.source_type] || v.source_type }} + 来源:{{ v.series ? (SERIES_LABEL[v.series] || v.series) : (SOURCE_LABEL[v.source_type] || v.source_type) }} {{ v.created_at.slice(0, 10) }}
diff --git a/frontend/src/views/ReviewHome.vue b/frontend/src/views/ReviewHome.vue index 2c8d0cd..8485d8e 100644 --- a/frontend/src/views/ReviewHome.vue +++ b/frontend/src/views/ReviewHome.vue @@ -3,7 +3,7 @@ * Review Dashboard · V0.10 * * 「今日待复习」统一入口: - * - BBC 文章 (GET /bbc-eaw/due) + * - BBC 文章 (GET /articles/due) * - 生词条目 (GET /vocab?due=true) * * 文章 → /bbc-review/:slug @@ -42,7 +42,7 @@ async function loadAll() { error.value = '' try { const [a, v, p] = await Promise.all([ - authFetch(`${API.BASE}/bbc-eaw/due`).then((r) => (r.ok ? r.json() : { items: [] })), + authFetch(`${API.BASE}/articles/due`).then((r) => (r.ok ? r.json() : { items: [] })), authFetch(`${API.VOCAB}?due=true&limit=200`).then((r) => (r.ok ? r.json() : { items: [] })), authFetch(`${API.POLISH}/due`).then((r) => (r.ok ? r.json() : { items: [] })), ]) diff --git a/frontend/src/views/Translate.vue b/frontend/src/views/Translate.vue index a66762d..c4666c5 100644 --- a/frontend/src/views/Translate.vue +++ b/frontend/src/views/Translate.vue @@ -1,56 +1,203 @@