|
|
@@ -0,0 +1,162 @@
|
|
|
+# 口语对话结束态全屏 Loading 设计文档
|
|
|
+
|
|
|
+**Date**: 2026-05-07
|
|
|
+**Status**: Draft
|
|
|
+**Touches**: `PPT/src/views/Editor/EnglishSpeaking/preview/DialogueChatView.vue`(仅前端)
|
|
|
+
|
|
|
+## 1. 问题陈述
|
|
|
+
|
|
|
+`DialogueChatView` 触发会话结束后(无论自动 / 手动),都会通过 `fetchReportSafe` 调用 `engine.completeSession()` + `engine.getReport()` 拉报告。轮询最坏 30 × 2s = 60s。期间的 UX 现状:
|
|
|
+
|
|
|
+| 路径 | 触发 | 期间 UI 表现 |
|
|
|
+|---|---|---|
|
|
|
+| **自动完成**(流事件 `event.isComplete === true`) | engine.isComplete watcher → fetchReportSafe | `state === 'finalizing'`:仅在底部 control zone 一行小字 + spinner |
|
|
|
+| **倒计时归零** | `startCountdown` 把 isComplete 置 true → watcher → fetchReportSafe | 同上 |
|
|
|
+| **手动结束**(点"结束并查看报告") | `handleExitConfirm` → fetchReportSafe | **无任何 loading**:弹窗关闭 → 用户盯着原界面 → 父组件突然换页 |
|
|
|
+
|
|
|
+两个问题:
|
|
|
+1. 手动路径完全没有等待反馈(`completeSession()` 在 `fetchReportSafe` 内部执行,所以 `engine.isComplete` 在调用栈里才变 true,期间 `state === 'finalizing'` 的判定 `engine.isComplete && reportFetchInflight` 无法满足,底部条不显示)。
|
|
|
+2. 自动路径的底部一行字过于隐晦——会话已结束,整个聊天区已无可操作内容,却仍占着屏幕。
|
|
|
+
|
|
|
+## 2. 设计目标
|
|
|
+
|
|
|
+- **三条结束路径(自动 / 倒计时 / 手动)UX 完全一致**:触发时立即出现一个占据整个 `DialogueChatView` 的 loading 界面,覆盖至 report 拉到 / 失败为止。
|
|
|
+- **不假装进度**——后端不下发真实进度,前端绝不编造阶段切换。
|
|
|
+- **等待时给用户读真东西**:本次对话的真实数据(轮次、句数、累计时长),把"漫长的 60s"包装成"小成就回顾"。
|
|
|
+- **错误处理沿用现状**——`fetchReportSafe` 已经 catch 并 return null,父组件 `handleDialogueComplete(null)` 已渲染错误态(TopicDiscussionPreview.vue:442-449),loading 不重复实现错误 UI。
|
|
|
+- **顺手清理**底部 control zone 内已不可达的 `state-finalizing` 死代码。
|
|
|
+
|
|
|
+## 3. 触发条件
|
|
|
+
|
|
|
+```vue
|
|
|
+<template>
|
|
|
+ <div class="dialogue-chat-view">
|
|
|
+ <!-- 早分支:finalizing 全屏占位 -->
|
|
|
+ <FinalizingScreen v-if="reportFetchInflight" :stats="finalizingStats" />
|
|
|
+
|
|
|
+ <!-- 否则正常渲染 header / chat / control zone -->
|
|
|
+ <template v-else>
|
|
|
+ <!-- ... 原有结构 ... -->
|
|
|
+ </template>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+```
|
|
|
+
|
|
|
+**触发源唯一为 `reportFetchInflight.value === true`**。该 ref 已存在(DialogueChatView.vue:630),由 `fetchReportSafe` 在 try-finally 里设置。三条结束路径都经过 `fetchReportSafe`,覆盖完整。
|
|
|
+
|
|
|
+**为什么不依赖 `engine.isComplete`**:手动路径下 `completeSession()` 在 `fetchReportSafe` 内部,`isComplete` 与 `reportFetchInflight` 不同步。用单变量触发是消除时序差的最干净做法。
|
|
|
+
|
|
|
+## 4. 视觉设计
|
|
|
+
|
|
|
+整屏垂直居中,背景白(与 `.dialogue-chat-view` 一致):
|
|
|
+
|
|
|
+```
|
|
|
+┌──────────────────────────────────┐
|
|
|
+│ │
|
|
|
+│ │
|
|
|
+│ [ spinner ] │
|
|
|
+│ │
|
|
|
+│ AI 正在为你整理报告... │
|
|
|
+│ │
|
|
|
+│ ┌────────────────────┐ │
|
|
|
+│ │ 3 轮 · 9 句 │ │
|
|
|
+│ │ 累计时长 4:32 │ │
|
|
|
+│ └────────────────────┘ │
|
|
|
+│ │
|
|
|
+│ │
|
|
|
+└──────────────────────────────────┘
|
|
|
+```
|
|
|
+
|
|
|
+- **Spinner**:复用现有 `.spinner` 样式(橙色、`spin` keyframe),尺寸放大到 28-32px。
|
|
|
+- **主文案**:"AI 正在为你整理报告...",字号 14px,颜色 `#374151`。
|
|
|
+- **数据卡片**:浅灰背景(`#f9fafb`)、圆角、内边距 16-20px、字号 12px,`#6b7280`。
|
|
|
+- **不要头像呼吸**(用户明确否决)。
|
|
|
+- **不要假阶段切换**(用户明确否决)。
|
|
|
+
|
|
|
+数据来源(全部前端就地取数,不依赖后端):
|
|
|
+
|
|
|
+| 字段 | 来源 | 备注 |
|
|
|
+|---|---|---|
|
|
|
+| 轮次 | `engine.currentRound.value` | 已显示在 header |
|
|
|
+| 学生句数 | `engine.messages.value.filter(m => m.role === 'student' && m.status !== 'error').length` | 排除失败消息 |
|
|
|
+| 累计时长 | `engine.messages.value.filter(m => m.role === 'student').reduce((s, m) => s + (m.audioDuration ?? 0), 0)` | audioDuration 已写入 student message(见 2026-05-07-audio-duration-and-replay-design.md),单位秒;旧数据 NULL 则按 0 计 |
|
|
|
+
|
|
|
+时长用现有的 `formatSeconds(s)` 渲染(DialogueChatView.vue:694)。
|
|
|
+
|
|
|
+## 5. 集成方式
|
|
|
+
|
|
|
+**方案 X:根级 v-if 替换**——不是浮层。`reportFetchInflight === true` 时整棵原有树(header + 聊天区 + silence-hint + control-zone + 各种 modal)全部不渲染,只渲染 finalizing 屏。
|
|
|
+
|
|
|
+**为什么替换不是浮层**:
|
|
|
+- 此时整个组件的语义就是"等报告"——header 里的轮次指示、三点菜单、聊天历史、麦克风按钮都不应可见也不应可点。
|
|
|
+- 浮层方案要在每个交互入口加 `:disabled="reportFetchInflight"`,比 v-if 早返回更啰嗦。
|
|
|
+- modal(exit-confirm / phoneme-detail)若在弹出时遇到结束触发也会被 v-if 一并隐藏——可接受(结束后没有继续弹这些 modal 的意义)。
|
|
|
+
|
|
|
+**实现形态**:把 finalizing 屏内联在 `DialogueChatView.vue` 的 `<template>` 里,不抽组件——内容简单(spinner + 文案 + 一个数据卡),无复用价值。
|
|
|
+
|
|
|
+## 6. 待清理的死代码
|
|
|
+
|
|
|
+启用 v-if 早分支后,control zone 里的 `'finalizing'` 状态层永远不会被渲染(外层提前替换),属于死代码:
|
|
|
+
|
|
|
+- 删 `state` computed 内 `'finalizing'` 分支:
|
|
|
+ ```ts
|
|
|
+ if (engine.isComplete.value) return reportFetchInflight.value ? 'finalizing' : 'done'
|
|
|
+ ```
|
|
|
+ 改成:
|
|
|
+ ```ts
|
|
|
+ if (engine.isComplete.value) return 'done'
|
|
|
+ ```
|
|
|
+ (`reportFetchInflight === true` 时根级 v-if 已经接管,不再走 state machine。)
|
|
|
+
|
|
|
+- 删 `state` 类型联合中的 `'finalizing'` literal。
|
|
|
+- 删 control zone 里整个 `<div class="state-layer state-center" :style="stateStyle('finalizing')">` 块及其内容。
|
|
|
+
|
|
|
+## 7. 错误处理
|
|
|
+
|
|
|
+**完全不在 finalizing 屏内处理**。`fetchReportSafe` 现有逻辑:
|
|
|
+
|
|
|
+```ts
|
|
|
+async function fetchReportSafe() {
|
|
|
+ reportFetchInflight.value = true
|
|
|
+ try {
|
|
|
+ await engine.completeSession()
|
|
|
+ return await engine.getReport()
|
|
|
+ } catch (err) {
|
|
|
+ console.warn('[speaking] getReport failed:', err)
|
|
|
+ return null
|
|
|
+ } finally {
|
|
|
+ reportFetchInflight.value = false
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+无论成功 / 失败 / 超时,`reportFetchInflight` 都会归 false → finalizing 屏自动消失 → 父组件 `complete` 事件接管。父组件 `handleDialogueComplete(null)` 已经在 `'completed'` 阶段渲染 `reportError` 文案(TopicDiscussionPreview.vue:443-449),覆盖三种失败语义(超时、failed、incomplete)。
|
|
|
+
|
|
|
+**不增加重试按钮**——重试入口在父组件 report-stage 已隐含(用户看到错误后可走 `restart` / 关闭重开),finalizing 屏只负责"等待中"语义。
|
|
|
+
|
|
|
+## 8. 不可取消
|
|
|
+
|
|
|
+finalizing 屏不提供"取消"按钮:
|
|
|
+
|
|
|
+- `completeSession` 已经触发后端状态变更,回退无意义。
|
|
|
+- `getReport` 即使打断,下次进同一 session 仍会拉到(或拉不到)同样的报告。
|
|
|
+- 减少误触离开。
|
|
|
+
|
|
|
+## 9. 范围外
|
|
|
+
|
|
|
+- **后端不动**。
|
|
|
+- **不改 `useDialogueEngine`**——`reportFetchInflight` 是 view-local state,不下沉到 composable。
|
|
|
+- **不改父组件 `TopicDiscussionPreview`**——`complete` 事件契约不变,错误态渲染逻辑不变。
|
|
|
+- **不动现有自动播放 / 沉默检测 / 徽章 watcher**——这些 watcher 在 v-if 早分支生效后仍会订阅,但因聊天区 DOM 不渲染、用户无法触发新消息,自然静默。
|
|
|
+
|
|
|
+## 10. 验收
|
|
|
+
|
|
|
+人工验证三条路径:
|
|
|
+
|
|
|
+| 场景 | 操作 | 期望 |
|
|
|
+|---|---|---|
|
|
|
+| 自动完成 | 完成最后一轮 → 看 finalizing | 立即出现全屏 loading,原 header / 控制条消失;报告拉到后父组件切到报告页 |
|
|
|
+| 倒计时归零 | 等 countdown 到 0 | 同上 |
|
|
|
+| 手动结束 | 点三点 → 结束并查看报告 | 弹窗关闭后**立即**出现全屏 loading(不再是空白等待) |
|
|
|
+| 报告失败 | 模拟 `getReport` reject | finalizing 屏消失,父组件报告页显示错误文案 |
|
|
|
+| 数据卡 0 句场景 | 极端:刚开 session 就立刻结束 | 卡片显示"0 轮 · 0 句 · 0:00",不报错 |
|