# 英文口语 - PPT 组件集成设计 > 状态:决策收敛,待实现 > 日期:2026-04-22 > 相关系统: > - PPT (`/Users/buoy/Development/gitrepo/PPT`) — 幻灯片编辑器,嵌在父页面 iframe 中 > - pbl-teacher-table (`/Users/buoy/Development/gitrepo/pbl-teacher-table`) — 父页面,课程/作业管理 > - cococlass-english-speaking-api (`/Users/buoy/Development/gitrepo/cococlass-english-speaking-api`) — 口语后端(FastAPI) > - enspeak (`/Users/buoy/Development/gitrepo/enspeak`) — 原型项目,UI/交互参照基准 --- ## 1. 背景 英文口语功能以 Vue 组件(`toolType === 77`,`atool='77'`)直挂在 PPT 项目里,不走 iframe 路径。这与 PPT 其他工具组件(选择题 45 / 问答 15 / AI 工具 72 / 截图 73)的 iframe 架构不同,因此提交、感知、展示各环节都需要在 Vue 组件体系下重新接上。 本文档固化与 pbl-teacher-table / cococlass-english-speaking-api 协同的设计契约。 --- ## 2. 现状对比 ### 2.1 PPT 其他工具组件的标准流程 - **渲染**:iframe,`src` 指向外部 URL,URL 注入 `courseid / userid / stage / task / tool` - **答题**:学生在 iframe 内答题,答案只在 iframe 内存 - **提交**:学生点"提交" → `Student/index.vue` 遍历 slide 的 iframe,通过 `iframe.contentWindow.submitWork(slideIndex)` 拉答题 JSON - **存储**:`POST addCourseWorks_workPage`(`stage=0 / task=slideIndex / tool=0 / atool='15'|'45'|'72'...`) - **感知**:Yjs 广播 `{ type:'homework_submitted', courseid, slideIndex, userid }`,教师侧幂等消费(调 `getWork(true)` 刷新) - **回显**:`select_courseWorks_workPageData` 返回 content → 解析 → 渲染 ### 2.2 英文口语的差异 - **渲染**:Vue 组件 `TopicDiscussionPreview`,由 `BaseFrameElement.vue` 根据 `toolType === 77` 挂载(无 iframe,无 `contentWindow`) - **答题**:多轮语音对话,每轮经 `POST /api/speaking/dialogue/speak`(SSE 流) - **后端持久化**:`DialogueSession` / `DialogueMessage` / `PronunciationEvaluation`(音频 URL + 转录 + 评分) - **结果**:`GET /api/speaking/dialogue/report?sessionId=xxx` 返回完整报告(含 LLM summary) - **评分异步**:`asyncio.create_task` fire-and-forget,`status: pending → completed | failed` --- ## 3. 核心约束 1. **数据单一所有权**:音频 / 转录 / 评分 / summary 只存在口语后端。`addCourseWorks_workPage` 只存**指针**(sessionId + 进度摘要),不复制内容。 2. **对话流畅性优先**:`/speak` 不能等评分;评分后台跑,对话途中**不显示**评分。 3. **教师侧直接访问口语后端**:pbl-teacher-table 通过 sessionId 调 `/report`;CORS 由后端配置解决。 4. **与既有 homework_submitted 广播兼容**:口语的 workPage 写入后同样发 Yjs 广播,教师侧无需新增分支(`homework_submitted` 消费是幂等的,都走 `getWork(true)`)。 5. **UI 全盘还原 enspeak 原型**:所有可视元素(布局、配色、动效、状态机文案、消息气泡、录音按钮、倒计时、空/错/过渡态、结果页四维分、summary 展示等)**严格对齐** `/Users/buoy/Development/gitrepo/enspeak`。实现方式是**React TSX → Vue 3 移植**:保留行为与视觉 1:1,语法层面由 hooks→composables、JSX→template、useState→ref/reactive、useEffect→watch/watchEffect/onMounted;不得为"Vue 更自然"而改动交互。映射表见 §5.4。 --- ## 4. 数据契约 ### 4.1 `addCourseWorks_workPage` payload ``` uid = userid cid = courseid stage = '0' task = String(slideIndex) tool = '0' atool = '77' type = '21' content = JSON.stringify({ sessionId: "uuid-xxx", totalRounds: 3, completedRounds: 2, isComplete: false, lastUpdatedAt: "2026-04-22T10:15:30Z" }) ``` - **每轮对话结束都写一次**(支持 B1 resume:刷新后能判断 in-progress) - **重做直接覆盖**(`addCourseWorks_workPage` 原生是 upsert 语义,无需历史数组) ### 4.2 后端接口(cococlass-english-speaking-api) | 方法 | 路径 | 作用 | |---|---|---| | POST | `/api/speaking/dialogue/session` | 创建 session,返回 sessionId + AI 开场白 | | POST | `/api/speaking/dialogue/speak` | 提交一轮录音,SSE 流式响应 | | GET | `/api/speaking/dialogue/report?sessionId=xxx` | 拉完整报告(含历史 + 评分 + summary) | ### 4.3 SSE 事件类型(`/speak`) | event | data | 触发 | |---|---|---| | `transcript` | `{ text, round }` | ASR 完成后 | | `token` | `{ content }` | LLM 流式输出每个 token(**最后一轮不发**) | | `done` | `{ isComplete, nextRound }` | 本轮所有 DB 操作完成 | | `error` | `{ message }` | 异常兜底(DB 已 rollback) | ### 4.4 /report 返回结构(取关键字段) ```ts { sessionId: string topic: string status: 'evaluating' | 'ready' // evaluating = 还有评分在跑 rounds: Array<{ round: number role: 'ai' | 'student' content: string audioUrl: string | null evaluation?: { // 仅 role='student' 且评分有结果 status: 'pending' | 'completed' | 'failed' accuracyScore: number fluencyScore: number completenessScore: number prosodyScore: number wordAnalysis: unknown } }> summary: string | null // 全部评分完成(含 failed)后生成 } ``` --- ## 5. 组件边界与注入 ### 5.1 分层 ``` TopicDiscussionPreview.vue ← runner context 注入层(provide SPEAKING_RUNNER_KEY) └─ DialogueChatView.vue ← 纯 UI 状态机 + SSE 消费(inject) ``` - `DialogueChatView` 不直接调 `submitWork`、不广播 Yjs、不知道 PPT 外壳的存在 - `TopicDiscussionPreview`(或更外层 wrapper)负责把 PPT 运行时上下文(`courseId / userId / slideIndex / mode`)和"提交能力"注入下去 ### 5.2 Inject key 与 context ```ts // src/views/Editor/EnglishSpeaking/types.ts (新增) export const SPEAKING_RUNNER_KEY = Symbol('english-speaking-runner') export interface RoundSnapshot { sessionId: string round: number transcript: string audioUrl: string totalRounds: number } export interface SessionSnapshot { sessionId: string totalRounds: number completedRounds: number isComplete: boolean } export interface SpeakingRunnerContext { userId: string courseId: string slideIndex: number mode: 'preview' | 'student' | 'teacher' onRoundFinished: (snap: RoundSnapshot) => Promise onSessionComplete: (snap: SessionSnapshot) => Promise initialSessionId?: string // B1 resume 时由 parent 读 workPage 后塞入 } ``` - `mode==='preview'`:编辑器预览,**不** submitWork,不调真后端(走 mock / 直通) - `mode==='student'`:Student/index.vue 场景,`onRoundFinished` / `onSessionComplete` 里实际调 `submitWork` + Yjs 广播 - `mode==='teacher'`:教师只读回放,不新建 session ### 5.3 DialogueChatView Props & Emits **Props**(参照 enspeak `DialogueChatView.tsx:200-214`): ```ts defineProps<{ topic: string keywords?: string[] totalRounds?: number // 默认 3 timeLimitSeconds?: number // 未传 = 无倒计时(正计时);传 = 倒计时 aiName?: string aiAvatar?: string showEnglishText?: boolean showChineseText?: boolean showTaskHint?: boolean showSilenceHint?: boolean mode: 'preview' | 'student' | 'teacher' }>() ``` **Emits**: ```ts defineEmits<{ (e: 'session-start', sessionId: string): void (e: 'round-finished', payload: RoundSnapshot): void (e: 'complete', payload: SessionSnapshot): void (e: 'error', payload: { code: ErrorCode; message?: string }): void }>() type ErrorCode = | 'asr_failed' // /speak error 事件 | 'network_error' // SSE 连接中断 / 请求失败 | 'session_expired' // 409 Session is not active | 'permission_denied' // 麦克风权限拒绝 | 'unknown' ``` - **无 "role / identity / personality / speakingStyle / speed" 字段**(产品明确不需要) - **无"结束对话"按钮**(MVP 不允许学生主动放弃) ### 5.4 UI 还原基准(enspeak → Vue 映射) > 全部 UI 从 enspeak 原型移植,**视觉与交互 1:1**。以下是源文件与目标 Vue 文件的映射。所有 UI 细节(文案、颜色、动效时长、气泡样式、按钮状态切换、空态、提示 modal、倒计时样式、结果页四维分卡片、summary 排版等)以 enspeak 为准,不自行发挥。 | 功能区域 | enspeak 源(React TSX) | PPT 目标(Vue) | |---|---|---| | 对话主界面(状态机 + 录音按钮 + 消息气泡 + 倒计时显示 + 过渡动画) | `enspeak/src/components/dialogue/DialogueChatView.tsx` (47KB,主干) | `src/views/Editor/EnglishSpeaking/preview/DialogueChatView.vue` | | 话题预览(配置面板进入对话前的 landing) | `enspeak/src/components/dialogue/DialoguePreview.tsx` | `src/views/Editor/EnglishSpeaking/preview/TopicDiscussionPreview.vue`(入口部分) | | 详细报告页(每轮录音 / transcript / 四维分 / word analysis) | `enspeak/src/components/dialogue/DetailedReport.tsx` (17KB) | `src/views/Editor/EnglishSpeaking/preview/DetailedReport.vue` | | 总览报告页(四维分汇总 + summary + 总体评价) | `enspeak/src/components/dialogue/OverallReport.tsx` (8KB) | `src/views/Editor/EnglishSpeaking/preview/OverallReport.vue` | | 结果入口(控制详细/总览切换) | `enspeak/src/components/dialogue/DialogueResult.tsx` | 合入 `TopicDiscussionPreview.vue` 的完成态分支或新建 `DialogueResult.vue` | | 提示弹窗(沉默提示 / 帮助等) | `enspeak/src/components/dialogue/HintDialog.tsx` | `src/views/Editor/EnglishSpeaking/preview/HintDialog.vue` | | 实时反馈条(录音中的声纹/能量指示) | `enspeak/src/components/dialogue/RealtimeFeedback.tsx` | `src/views/Editor/EnglishSpeaking/preview/RealtimeFeedback.vue` | | 话题讨论父容器(上下文包装层) | `enspeak/src/components/preview/TopicDiscussionPreview.tsx` | `src/views/Editor/EnglishSpeaking/preview/TopicDiscussionPreview.vue`(包装部分) | **移植规则(TSX → Vue)**: | React 原语 | Vue 对应 | |---|---| | `useState` | `ref` / `reactive` | | `useRef` | `ref`(DOM 引用) / `shallowRef`(持有对象引用如 MediaRecorder) | | `useEffect(..., [deps])` | `watch(deps, cb)` / `watchEffect` / `onMounted` + `onBeforeUnmount` | | `useMemo` | `computed` | | `useCallback` | 普通函数即可(Vue 不需要引用稳定性) | | Props with optional callbacks | `defineEmits` + 父组件 listener | | 条件 render `{cond && }` | `v-if` / `v-show`(动画场景慎选 `v-show`) | | `className={clsx(...)}` | `:class="[...]"` | | Tailwind classes | 保留 class 名不动(Tailwind 在 PPT 项目若可用直接沿用;若不可用,按 class 名对照抽 CSS vars) | **不移植项**(enspeak 独有,PPT 不需要): - 角色选择器 `RoleSelector.tsx`、话题选择器 `TopicSelector.tsx`(PPT 编辑器侧已有自己的配置面板) - 自定义角色/话题 modal `CustomRoleModal.tsx` / `CustomTopicModal.tsx`(同上) - `DialogueChat.tsx`(enspeak 的路由容器,PPT 不需要路由层) **可能的对齐差异**(实现时标注在代码注释): - 字体:enspeak 可能用 Inter / 系统字体,PPT 沿用项目字体栈 - 尺寸:enspeak 是独立页面(全屏),PPT 是 slide 内嵌组件,需按 `elementInfo` 的 width/height 缩放;保持相对比例不变 - 主题色:enspeak 有自己的色板,如与 PPT 冲突以 PPT 为准(最小改动) --- ## 6. 会话生命周期 ### 6.1 B1 — 刷新 / 返回时恢复(允许 resume) 挂载时,Parent wrapper 按 workPage 状态分三支: ``` 读 workPage.content ├─ 无记录 / content 为空 → 新建 session (POST /session) ├─ isComplete = true → 直接渲染报告 (GET /report) └─ isComplete = false + sessionId → 恢复模式 └─ GET /report 拿已有 rounds └─ DialogueChatView 初始化 history + 定位 currentRound └─ 用户点"开始录音"继续 ``` **降级**: - `/speak` 返回 409 Session is not active(后端超时/已 completed)→ 降级为"展示最终报告"或"新建一次"(看 `/report` 的 status) - `/report` 返回 404(极少见)→ 直接新建 ### 6.2 B2 — 重做(重新进入已完成页面) - 点"重做" → 新建 v2,**即刻覆盖 workPage**(第 1 轮结束时写入 `{sessionId:v2, isComplete:false, completedRounds:1}`) - **只保留最新一份**,不维护历史数组 - v1 原始数据仍在后端 `DialogueSession` 表(通过 uuid 可查),但前端**无恢复入口** ### 6.3 B3 — 时长限制(倒计时) - 老师侧可配置 `timeLimitSeconds`(slide 属性,传递给 `/session` 的 `durationSeconds`),UI 对齐 enspeak `DialogueChatView.tsx:200 / 292-315` - `timeLimitSeconds` 未设置 = 正计时(仅轮数限制) - `timeLimitSeconds` 有值 = 倒计时 - **到期处理(α 方案)**:倒计时到 0 → **禁用录音按钮** → 若正在录音则**取消录音,不调 /speak** → 触发 `onSessionComplete` 走报告页 **不变量**: 1. 倒计时以后端 `expires_at` 为真实依据;前端显示是客户端时钟(允许偏差但不超过几秒) 2. 重新可见时如已过期,立即跳报告页 3. **不可能真正"暂停"倒计时**(`expires_at` 是绝对时间戳) ### 6.4 B4 — 对话中 PPT 翻页(组件不卸载) `ScreenSlideList.vue:27` 使用 `v-show`(非 `v-if`),翻页后 DialogueChatView 仍挂载。行为(γ+ 方案): - 翻走 → 倒计时**继续跑**(反映真实 wall-clock) - 翻走时若正在录音 → **取消 in-flight 录音**(禁止上传到 /speak) - 翻回 → 重新同步后端状态:若已过期,跳报告页;未过期,可以继续下一轮,UI 显示"已消耗 XX 秒" **硬规则**: 1. 不可见时必须取消 in-flight 录音 2. 倒计时以 `expires_at` 为准;前端过期检测基于剩余时间 ≤ 0 --- ## 7. 错误态 ### 7.1 A1 — ASR 失败 - 前端缓存 MediaRecorder Blob(录音结束后不释放) - 收到 `error` 事件 → toast "识别失败" + 显示**「重试」按钮** - 点重试 → 用同一份 blob 再 POST `/speak`(sessionId 相同,round 未前进) - 2 次都失败 → 按钮变「重新录制」,清 blob 回录音态 **为什么幂等**:`/speak` 失败走 `rollback`(`dialogue_service.py:203`),`student_msg` / `evaluation` 都未落库;S3 key `{sessionId}/round_{round}.webm` 重传覆盖,无脏数据。 ### 7.2 A2 — Azure 发音评分失败(后台) **后端改动**(`_evaluate_pronunciation`):加 1 次重试 ```python for attempt in range(2): try: result = await self.assessor.assess(...) # set completed fields, break break except Exception as e: if attempt == 0: logger.warning(f"Eval attempt 1 failed, retrying: {e}") await asyncio.sleep(1) continue evaluation.status = "failed" evaluation.error_message = str(e) ``` **结果页渲染规则**: | status | UI | |---|---| | `completed` | 展示四项分数 | | `failed` | 整个评分块**空**(不显示占位/错误文字) | | `pending` | 继续轮询 | **Summary**:后端 `_generate_summary` 仅把 `status == 'completed'` 的评分喂给 LLM(`dialogue_service.py:295`),failed 轮自动被跳过,无需额外改。 ### 7.3 A3 — "结束录音"并发点击 前端状态机(不依赖后端幂等): ``` idle → recording → [点击 finish] → submitting → [SSE done] → idle ↑ 期间所有点击无效 ``` - `recording` → 收到 finish 点击:立即置 `submitting`,按钮禁用 - `submitting` 期间任何点击丢弃 - `done` 事件到达才切回 `idle` / 转 report ### 7.4 A4 — 网络中断 | 场景 | 处理 | |---|---| | `/session` 创建断网 | toast + 手动重试;未进入对话 UI | | `/speak` SSE 流中断 | toast "网络异常,请刷新重试" → 用户手动刷新 → 走 B1 resume,从服务端真相重建 | | `/report` 轮询断 | 指数退避自动重试(见 §8) | | idle 时断网 | 无感,下次操作才发现 | **不自动重试 `/speak`**:后端 commit 在 LLM stream 之后(`dialogue_service.py:185`),自动重试可能造成重复轮次。让刷新走 resume,状态由服务端真相决定。 --- ## 8. 报告轮询策略 结束后调 `GET /report`,若 `status === 'evaluating'` 则进入轮询。 **退避序列**(总上限 ~30s): ``` 1s, 1s, 1s, 2s, 3s, 5s, 5s, 5s, 5s (cumulative: 1,2,3,5,8,13,18,23,28s) ``` **UI 时间阈值文案**: | 累计等待 | 文案 | |---|---| | 0-15s | "正在生成报告..." | | 15-30s | "评分耗时较长,请稍候..." | | 30-60s | "仍在处理,建议稍后刷新查看" | | > 60s | toast "评分未能完成,请稍后刷新;音频和对话内容已保存" | **最终失败 fallback**:显示当前已拿到的部分数据(pending 的评分块留空),不阻塞用户阅读历史对话。 --- ## 9. 时序 ### 9.1 E1 — 最后一轮特殊处理(后端改动) **产品决策**:第 N 轮(N = totalRounds)以学生回应作为结束,**不触发 AI 回复**。 **后端改动**(`dialogue_service.py::speak`,约 line 160-175): ```python is_last_round = (current_round >= session.total_rounds) # 评估记录、transcript yield、student_msg 写入保持不变 if not is_last_round: # ⑥ 构建 LLM prompt # ⑦ LLM 流式 (yield token) # ⑧ 写入 AI 消息 ... # ⑨ 推进轮次 session.current_round += 1 is_complete = session.current_round > session.total_rounds ... ``` **SSE 流形态对比**: - 普通轮:`transcript → token* → done` - 最后一轮:`transcript → done`(无 token) **前端状态机**:监听 SSE 时,收到 `done` 前未收到任何 `token` → "最后一轮"路径:保留学生 transcript 气泡,不渲染"AI 正在说",1.5s 过渡(显示"正在生成报告...")→ 进入结果页 + 启动轮询。 **single-round session**(`total_rounds = 1`):AI 开场白(`create_session` 时已生成)→ 学生说一轮 → 无 AI 回复 → 直接结束。自洽。 ### 9.2 E2 — 回滚不变量 | 副作用 | 异常回滚行为 | |---|---| | S3 音频文件 | **不回滚**(可能遗留孤儿文件;重试同 key 覆盖,无害) | | `student_msg` / `evaluation`(flushed 未 commit) | session 关闭自动丢弃 ✓ | | `ai_msg` / round 递增 / session status | 未 commit 即异常,rollback 清理 ✓ | **硬不变量**:每轮要么**完整成功**(全部写入 + commit),要么**完全没发生**(从 `/report` 看不到这一轮)。 **SSE 中断但后端已 commit 的情况**(网络断在 `yield done` 之前但 commit 已完成):前端看"连接断"但真相"这轮已完成"。处理方式:刷新页面走 B1 resume(α),`/report` 返回真相即可重建正确 UI。不做 SSE 自动重连。 --- ## 10. 后端改动清单(cococlass-english-speaking-api) | 改动 | 位置 | 必须? | |---|---|---| | `_evaluate_pronunciation` 加 1 次重试 | `app/service/speaking/dialogue_service.py:321-352` | ✅ 必须 | | `speak` 最后一轮跳过 LLM + AI msg | `app/service/speaking/dialogue_service.py:160-175` | ✅ 必须 | | CORS 允许 pbl-teacher-table 域名访问 `/report` | FastAPI middleware 配置 | ✅ 必须 | | S3 ACL 确认 `public-read` | 已确认,保持现状 | — | --- ## 11. 前端改动清单(PPT) | 改动 | 位置 | |---|---| | 定义 `SPEAKING_RUNNER_KEY` + `SpeakingRunnerContext` 类型 | `src/views/Editor/EnglishSpeaking/types.ts`(新) | | `TopicDiscussionPreview.vue` 作为 runner context 注入层:读 workPage → 决定 B1 resume / 新建 / 查看;provide `SPEAKING_RUNNER_KEY` | `src/views/Editor/EnglishSpeaking/preview/TopicDiscussionPreview.vue` | | `DialogueChatView.vue` 重写为纯 UI + SSE 消费:inject runner context;Props/Emits 见 §5.3;状态机 §7.3;倒计时 §6.3;B4 `isVisible` 处理 §6.4。**UI 严格对齐 enspeak**(见 §5.4 映射) | `src/views/Editor/EnglishSpeaking/preview/DialogueChatView.vue` | | `Student/index.vue` 通过 `provide(SPEAKING_RUNNER_KEY, ...)` 暴露 runner context | `src/views/Student/index.vue` | | `Student/index.vue::handleHomeworkSubmit` 处理全 toolType=77 slide:toast "对话结束后自动提交" | `src/views/Student/index.vue` (~line 1749-1867) | | 详细报告页 / 总览报告页:按 §4.4 数据结构渲染 + 轮询逻辑 §8;**UI 严格对齐 enspeak**(见 §5.4 映射) | `src/views/Editor/EnglishSpeaking/preview/DetailedReport.vue` / `OverallReport.vue` | | 提示弹窗、实时反馈条等辅助 UI 按 §5.4 映射移植 | `src/views/Editor/EnglishSpeaking/preview/HintDialog.vue` / `RealtimeFeedback.vue` | | `types/englishSpeaking.ts`:加 `timeLimitSeconds?: number`;清理 role.identity / personality / speakingStyle / speed 等不需要的字段 | `src/types/englishSpeaking.ts` | ### 11.1 WIP 代码处置(开发起步前先做) 上一次提交(`2ce4e49 chore: WIP checkpoint before english-speaking redesign`)里保留了一批探索性代码,基于已被推翻的旧设计(老 inject key `'homeworkContext'`、老 role 字段、SSE `/report/stream` 等)。新会话开始实现前应做**显式处置**,避免误把 WIP 当成可用基础。 | 文件 | 状态 | 建议 | |---|---|---| | `src/types/englishSpeaking.ts` | 含已废弃字段(`Role.identity` / `Role.personality` / `PreviewAIRole.speakingStyle` / `PreviewAIRole.speed`) | 按 §11 表的"清理" + 加 `timeLimitSeconds` 项执行;读 §5.3 Props 对齐 | | `src/views/Editor/EnglishSpeaking/composables/useAudioRecorder.ts` | 录音封装,基础设施类 | **通读 + 评估复用**:录音本身与设计无耦合,若实现干净可保留;若依赖老类型则重写 | | `src/views/Editor/EnglishSpeaking/composables/useDialogueEngine.ts` | 引用老类型 `PreviewChatMessage / SessionConfig / DialogueReport` + 老 `MockDialogueAPI/RealDialogueAPI` 抽象 | **重写**。新设计里对话引擎的状态由 `DialogueChatView` + `SpeakingRunnerContext` 承载,不需要独立 engine composable(或 composable 要按新 inject 模式重构) | | `src/views/Editor/EnglishSpeaking/services/llmService.ts` | `parseSSEStream` 逻辑本身可能可复用;API_BASE 硬编码需改 env | **SSE 解析保留 / 其余重写**。新设计没有 `report/stream`,只有 `/session` + `/speak` + `/report` 三个 endpoint | | `src/views/Editor/EnglishSpeaking/preview/DialogueChatView.vue` | 基于旧 Props(有 role 字段)+ 旧状态机 | **重写**(§11 主表已标明)。按 §5.4 从 enspeak TSX 1:1 移植 | | `src/views/Editor/EnglishSpeaking/preview/TopicDiscussionPreview.vue` | 基于旧流程(无 B1 resume / 无 runner context) | **重写**。按 §5.1 / §6.1 承担 runner context 注入 + B1 分支路由 | | `public/pcm-recorder-worklet.js` | Audio worklet,基础设施类 | 若 enspeak 没对应 worklet(enspeak 用浏览器原生 MediaRecorder),按 §5.4 移植时对齐选择;目前文件保留 | | `vite.config.ts` 改动 | 应该是为了 worklet / 路径别名 | 先看 diff 决定是否保留 | **推荐动作**:新会话开始时先 `git diff 500179d HEAD -- src/` 回顾 WIP 改动;把"直接重写"的文件先 `git checkout 500179d -- ` 恢复到 checkpoint 前状态(干净起点),再按 §11 主表从零实现。保留性工具(worklet / recorder)先不动,实现到对应环节再决定。 ### 11.2 teacher-table 侧 | 改动 | 位置 | |---|---| | 识别 atool='77' 的 workPage content(含 sessionId) | `pbl-teacher-table/src/components/pages/workPage/index.vue` | | "口语详情"视图:直接调 `GET /api/speaking/dialogue/report?sessionId=xxx` 渲染 | 新组件 | | 复用现有 `homework_submitted` 消费(幂等的 `getWork(true)`)— 无需新增 | — | --- ## 12. 已知缺陷 / Known Limitations 1. **评分任务进程重启会丢失**:`asyncio.create_task(self._evaluate_pronunciation(...))` 是 fire-and-forget。若 uvicorn 进程在评分完成前重启 / 崩溃,那一轮评分会永远 `status='pending'`,`/report` 不会生成 summary。MVP 接受;未来应改为持久化任务队列(Celery / RQ / DB polling)。 2. **客户端时钟偏差**:倒计时显示以前端时钟为准。客户端时钟偏慢时,UI 可能显示"还剩 3s"但后端已过期;此时发起的 `/speak` 会得到 `done + isComplete:true`(录音被丢弃)。后端 `expires_at` 是唯一真相。 3. **workPage 只保留最新一份**:学生重做后原作业在前端不可见(后端表里仍有记录,通过 sessionId 可查)。老师看到的始终是"最近一次"。 4. **SSE 无自动重连**:网络中断只能刷新页面走 B1 resume。 5. **音频孤儿文件**:`/speak` 失败时 S3 文件可能已上传,不自动清理;同 key 会被重试覆盖,实际无害。 --- ## 13. 参考 - `addCourseWorks_workPage` 提交模式:`pbl-teacher-table/src/components/pages/workPage/index.vue:327, 394-408` - `submitWork` 包装 + aichat S3 快照模式:`PPT/src/views/Student/index.vue:1665-1849` - `homework_submitted` Yjs 广播 + 幂等消费:`PPT/src/views/Student/index.vue:2196, 3116-3124` - 口语后端 `/speak` 主流程:`cococlass-english-speaking-api/app/service/speaking/dialogue_service.py:95-204` - 口语后端 `/report` 主流程:`cococlass-english-speaking-api/app/service/speaking/dialogue_service.py:206-280` - 后台评分:`cococlass-english-speaking-api/app/service/speaking/dialogue_service.py:321-352` - enspeak 倒计时模式:`enspeak/src/components/dialogue/DialogueChatView.tsx:200, 292-315` - enspeak 结果页参考:`enspeak/src/components/dialogue/DialogueResult.tsx` - PPT slide 渲染(v-show 行为):`PPT/src/views/Screen/ScreenSlideList.vue:27` - 轮询方案详解:`/Users/buoy/Development/gitrepo/interview/network/polling.md` --- ## 14. 本次未讨论 / 待实现阶段处理 - 老师侧"口语详情"视图:UI 按 §5.4 从 enspeak `DetailedReport.tsx` + `OverallReport.tsx` 移植(布局是否需要压缩为嵌入式等实现阶段决定) - 编辑器 `mode='preview'` 下的 mock 数据/直通策略 - 单 slide 上多个口语组件的处理(理论上可能,产品价值存疑,MVP 暂不支持) - `showEnglishText` / `showChineseText` / `showTaskHint` / `showSilenceHint` 几个展示开关的默认值由产品决定 - 生产部署的 `API_BASE` 配置(`llmService.ts:3` 当前是 `http://localhost:8000`,需改环境变量) - B4 `isVisible` 的具体传递链路(ScreenSlide 已经把 `is-visible` 传给子组件,DialogueChatView 接收方式到实现阶段确认) - Tailwind 在 PPT 项目的可用性(enspeak 使用 Tailwind;若 PPT 不开启,需把 class 转为 scoped CSS,保留样式值)