状态: WIP,讨论中 日期: 2026-04-21 相关系统:
- 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) - 口语后端
英文口语功能(toolType=77)以 Vue 组件形式直接写在 PPT 项目里,不走 iframe 嵌入路线,这与 PPT 现有其他工具组件(选择题 toolType=45、问答 toolType=15、AI 工具 toolType=72 等)的架构模式不同。
本文档梳理英文口语功能要与 PPT 内其他工具组件行为一致,需要补充和改造的内容。
Rendering:
src 指向外部 URL(如 https://pbl.cocorobo.cn/work#workPageNew)courseid、userid、stage、task、tool答题阶段:
提交作业(单次触发):
Student/index.vue 遍历当前 slide 的所有 iframeiframe.contentWindow.submitWork(slideIndex) 拿到答题 JSONapi.submitWork() → POST addCourseWorks_workPage{ type: 'homework_submitted', courseid, slideIndex, userid }addCourseWorks_workPage 存储结构:
uid, cid, stage=0, task=slideIndex, tool=0
atool = "45"(选择) / "15"(问答) / "72"(AI)
type = "8" / "3" / "20"
content = encodeURIComponent(JSON.stringify(答题JSON))
content: { testJson: [{ teststitle, checkList, userAnswer }] }content: { answerQ, answer, fileList }content: S3 文件 URL(iframe 内 exposed_outputs 序列化后上传)取回展示:
select_courseWorks_workPageData 返回 content → 解析 JSON → 填回题目组件显示Rendering:
TopicDiscussionPreview 渲染(直接在 DOM,不是 iframe)BaseFrameElement.vue 根据 toolType === 77 判断后挂载答题阶段:
POST /api/speaking/dialogue/speakdialogue_message (音频 URL + 转录文本)pronunciation_evaluation (评分,后台异步填充)dialogue_session (总体状态,当 current_round > total_rounds 或 expires_at 到期时自动 status='completed')提交作业:
addCourseWorks_workPageStudent/index.vue 的 submitWork 循环只遍历 iframe,找不到 toolType=77 的 Vue 组件结果页:
GET /report?sessionId=xxx 轮询(最多 15 次,2s 间隔),等所有发音评估完成父页面感知:
/speak 请求必须尽快返回 AI 响应,评分不能阻塞对话pronunciation_evaluation.status: pending → completed 追踪cococlass-english-speaking-api 只管口语部分,不承担 workPage 作业提交口语后端是口语数据的 source of truth - 音频、转录、评分、摘要都存在口语后端,不复制到 addCourseWorks_workPage。
"完成" 和 "提交" 在口语场景里天然分开,与其他工具不同:
采用 模型 Z(双触发):
自动路径(主流程):
isComplete = true → 自动调 submitWork手动路径(fallback):
提交 ≠ 评分完成 - 两者进一步解耦:
addCourseWorks_workPageaddCourseWorks_workPage 中 atool=77 的 content 字段存:
{
"sessionId": "uuid-xxx",
"totalRounds": 5,
"completedRounds": 5,
"completedAt": "2026-04-21T10:00:00Z"
}
注:是否追加评分摘要(overallScore 等)待讨论。
放弃对 GET /report 的轮询,改用 SSE 流式推送:
GET /report/stream?sessionId=xxxpronunciation_evaluation 从 pending → completed 时推一条 evaluation_ready 事件done 事件atool=77 的 work 记录 → 解析 content.sessionIdGET /report?sessionId=xxx 拿完整数据渲染前置:两个独立的后端数据流
pronunciation_evaluation):每次 /speak 结束时后端自动派发 background task,不依赖会话是否结束,自驱完成dialogue_session.summary):首次调 /report 时,若"所有评分完成 + 无 summary"则调 LLM 生成,依赖有人来查报告四种触发路径的处理
| 情形 | 谁触发 isComplete |
后端 session.status 谁负责置 'completed' |
|---|---|---|
| 轮次用完 | /speak done 事件(后端自检) |
/speak 后端自检(现有逻辑) |
| 时间到 + 学生正在说 | /speak done 事件(后端自检) |
/speak 后端自检(现有逻辑) |
| 时间到 + 学生沉默 | 前端 countdown 到 0 | /report 被调用时懒结束(新加逻辑) |
| 关页面 | 无 | 无人管(数据废弃,不影响其他人) |
| 学生主动放弃 | 点"结束对话"按钮(待定是否加) | POST /session/{id}/end(待定是否加) |
关键策略:/report 懒结束
后端 GET /report 处理开始时加一段:
if session.expires_at and now > session.expires_at and session.status == 'active':
session.status = 'completed'
session.completed_at = now
# commit 后再继续原有报告生成逻辑
isComplete=true → 走结果页 → 订阅 SSE(必调 /report 或 /report/stream)→ 后端懒结束/speak 已经处理,无变化关页面场景(可接受的弃置)
session.status 永远 'active'、summary 永远 null ⚠️addCourseWorks_workPage 无记录 → 教师侧看不到 → 没人会去调 /report → 没影响背景:其他工具走 iframe,Student/index.vue 通过 iframe.contentWindow.submitWork() 主动拉答题 JSON;英文口语是直挂 Vue 组件,这条路径不通。
决策:组件自己在 isComplete 变为 true 时提交,不让父层扫描。父层通过 provide 提供"广播+提交"能力,组件通过 inject 使用。
"一致性" 拆成三层看
| 层 | 是否需要一致 | 说明 |
|---|---|---|
| L1 后端契约 | 必须 | 同样调 addCourseWorks_workPage,同样走 Yjs homework_submitted 广播 —— 教师侧无感知差异 |
| L2 学生 UX | 不必 | 选择题有"提交"按钮给"做完了"的反馈;口语的反馈是结果页(评分/总结),不需要再点一次按钮 |
| L3 代码架构 | 不必 | iframe.contentWindow.submitWork() 是 iframe 模式的产物,Vue 组件没有 contentWindow,强行模仿反而别扭 |
组件自提交只偏离 L3,对 L1/L2 无影响 —— 教师侧和 Yjs 协同逻辑完全复用既有机制。
接口约定
Student/index.vue 通过 provide 暴露:
provide('homeworkContext', {
courseid,
userid,
slideIndex,
submitAndBroadcast: async (atool: string, type: string, content: any) => {
await api.submitWork({ uid, cid, stage:'0', task:slideIndex, tool:'0', atool, type, content })
sendMessage({ type: 'homework_submitted', courseid, slideIndex, userid })
}
})
DialogueChatView.vue(或 TopicDiscussionPreview)通过 inject 使用:
const ctx = inject<HomeworkContext | null>('homeworkContext', null)
watch(isComplete, async (done) => {
if (!done) return
if (props.mode === 'preview') return // 编辑器预览不提交
if (!ctx) return // 非 Student 场景(老师端预览/草稿)不提交
await ctx.submitAndBroadcast('77', '77', {
sessionId: sessionId.value,
totalRounds,
completedRounds: currentRound.value,
completedAt: new Date().toISOString(),
})
})
全局"提交作业"按钮在 toolType=77 场景
Student/index.vue 的 handleHomeworkSubmit 现在只遍历 iframe;当前 slide 全是 toolType=77(没有 iframe)时:
混合场景(slide 同时有 iframe 工具 + 口语组件):iframe 部分走原流程,口语部分由组件自提交处理,互不干扰。
| 改动 | 位置 |
|---|---|
新增 SSE 接口 GET /report/stream?sessionId=xxx |
app/api/dialogue.py |
| 实现评估完成时推事件(内部事件总线或 DB 轮询触发) | app/service/speaking/dialogue_service.py |
GET /report 入口加懒结束逻辑:expires_at < now AND status='active' 时置 completed |
app/service/speaking/dialogue_service.py lines 206-280 |
(可选)POST /session/{id}/end 用于学生主动放弃 |
app/api/dialogue.py |
| 改动 | 位置 |
|---|---|
Student/index.vue 通过 provide('homeworkContext', ...) 暴露 { courseid, userid, slideIndex, submitAndBroadcast } |
src/views/Student/index.vue |
Student/index.vue 的 handleHomeworkSubmit:全为 toolType=77 时走 toast 提示分支,不阻断混合场景 |
src/views/Student/index.vue (lines ~1749-1867) |
TopicDiscussionPreview / DialogueChatView.vue 通过 inject('homeworkContext', null) + watch(isComplete) 自提交 |
src/views/Editor/EnglishSpeaking/preview/*.vue |
DialogueChatView.vue 的 complete 事件携带 sessionId |
src/views/Editor/EnglishSpeaking/preview/DialogueChatView.vue |
TopicDiscussionPreview 结果页改用 SSE,移除轮询 |
src/views/Editor/EnglishSpeaking/preview/TopicDiscussionPreview.vue |
llmService.ts 增加 streamReport(sessionId): AsyncGenerator<SSEEvent> |
src/views/Editor/EnglishSpeaking/services/llmService.ts |
| 改动 | 位置 |
|---|---|
select_courseWorks_workPageData 返回 atool=77 时解析 content |
src/components/pages/workPage/index.vue 或新建口语查看组件 |
新增"口语详情"视图,调口语 API /report?sessionId=xxx 渲染 |
新组件 |
| 成绩列表添加 atool=77 类型展示(完成状态 + 简要信息) | 教师作业查看入口 |
┌─────────────────┐ ┌──────────────────────┐
│ 学生 (PPT) │ │ pbl-teacher-table │
└────────┬────────┘ └──────────┬───────────┘
│ │
│ 对话中 (每轮) │
│ POST /speak │
├────────► 口语后端 │
│ (后端自动存 message + async eval)
│ │
│ 对话结束 │
│ │
├── submitWork(atool=77, │
│ content={sessionId,...}) │
│ → addCourseWorks_workPage│
│ │
├── Yjs 广播 homework_submitted────► ✅ 看到"已完成"
│ │
│ │
│ 结果页 │
│ GET /report/stream │
│ (SSE 增量推评分) │
├────────► 口语后端 │
│ │
│ │ 教师点详情
│ │ GET /report?sessionId=xxx
│ 口语后端 ◄──────────┤
│ │
POST /session/{id}/endaddCourseWorks_workPage.content 是否需要冗余存评分摘要(overallScore 等)?当前倾向:不存,保持单一数据源mode='preview')是否走同一套流程?还是完全跳过提交?homework_submitted 广播的兼容性:教师侧已有机制接收,直接复用即可atool=77 对应的 type 字段取值(延用"77"还是另起一档,需与后端/教师端对齐)'homeworkContext' vs Symbol vs Pinia store 封装)addCourseWorks_workPage 提交逻辑:pbl-teacher-table/src/components/pages/workPage/index.vue lines 359-411submitWork 循环(iframe 扫描):PPT/src/views/Student/index.vue lines 1749-1867PPT/src/views/Student/index.vue lines 3054-3093/speak 流程:cococlass-english-speaking-api/app/service/speaking/dialogue_service.py lines 95-204/report 流程:cococlass-english-speaking-api/app/service/speaking/dialogue_service.py lines 206-280cococlass-english-speaking-api/app/service/speaking/dialogue_service.py lines 321-352