# 口语对话结束态全屏 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 ``` **触发源唯一为 `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` 的 `