Browse Source

docs(speaking): finalizing fullscreen loading design

Unify finalizing UX across three completion paths (auto / countdown /
manual exit) by replacing the chat view with a centered loading screen
keyed off reportFetchInflight, fixing the manual-exit gap where users
saw no feedback while the report was being fetched.
jimmylee 1 day ago
parent
commit
5694079a45

+ 162 - 0
docs/superpowers/specs/2026-05-07-dialogue-finalizing-fullscreen-loading-design.md

@@ -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",不报错 |