Преглед изворни кода

docs(speaking): finalizing fullscreen loading impl plan

Three-task plan for the design committed in dc64bb0:
1. Add finalizing screen (computed + template + styles).
2. Drop dead state-finalizing layer in control zone.
3. Manual verification of three completion paths.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
jimmylee пре 1 дан
родитељ
комит
846beb0160

+ 355 - 0
docs/superpowers/plans/2026-05-07-dialogue-finalizing-fullscreen-loading.md

@@ -0,0 +1,355 @@
+# 口语对话结束态全屏 Loading 实施计划
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** 让 `DialogueChatView` 在结束态(自动完成 / 倒计时归零 / 手动结束)期间出现一个占据整个组件的 loading 屏,显示真实数据回顾,并清理底部 control zone 中已不可达的 `state-finalizing` 死代码。
+
+**Architecture:** 在 `<template>` 根级用 `v-if="reportFetchInflight"` 早分支替换整棵原有树,渲染一个 spinner + 文案 + 真实数据卡片的居中布局;其余 header / chat / control / modal 全部包进 `<template v-else>`。`reportFetchInflight` 已存在于 view-local state,三条结束路径都经过 `fetchReportSafe`,覆盖完整。
+
+**Tech Stack:** Vue 3 `<script setup>` / TypeScript / Vite / SCSS scoped
+
+**Spec:** `docs/superpowers/specs/2026-05-07-dialogue-finalizing-fullscreen-loading-design.md`
+
+**Repo:** `PPT`(前端)— `/Users/buoy/Development/gitrepo/PPT`(当前 working dir)
+
+> **唯一改动文件**:`src/views/Editor/EnglishSpeaking/preview/DialogueChatView.vue`
+
+> **测试方式**:本仓 Vue 组件没有单测基础设施(参考前一份 `2026-05-07-audio-duration-and-replay.md` 同样走人工验证),用 `pnpm dev` / `npm run dev` 起开发服 + 浏览器验证三条路径。
+
+---
+
+## Task 1: 加 finalizing 屏(computed + template + styles)
+
+**Files:**
+- Modify: `src/views/Editor/EnglishSpeaking/preview/DialogueChatView.vue`
+
+**目标**:根级 v-if 出现 finalizing 屏;旧 control zone 里的 `state-finalizing` 暂时保留(Task 2 再清理)。这一步先确保 UI 先生效、不引入空白期。
+
+- [ ] **Step 1.1: 在 `<script setup>` 加 `finalizingStats` computed**
+
+定位到 `lastErrorText` computed 紧接其后(约 DialogueChatView.vue:673 后),插入:
+
+```ts
+const finalizingStats = computed(() => {
+  const studentMsgs = engine.messages.value.filter(m => m.role === 'student')
+  const sentenceCount = studentMsgs.filter(m => m.status !== 'error').length
+  const totalDurationSec = studentMsgs.reduce(
+    (sum, m) => sum + (m.audioDuration ?? 0),
+    0,
+  )
+  return {
+    rounds: engine.currentRound.value,
+    sentences: sentenceCount,
+    durationText: formatSeconds(Math.round(totalDurationSec)),
+  }
+})
+```
+
+> 字段说明:
+> - `rounds` 用 `engine.currentRound`,与 header 显示一致。结束时该值即"已完成轮数"(engine 在 isComplete=true 时不再 ++,见 useDialogueEngine.ts:158-160 / 249-250)。
+> - `sentences` 排除失败消息——失败的录音不算"说出过的一句"。
+> - `durationText` 累加 `audioDuration`(前一份 spec 已让后端把真实秒数下发到学生消息),缺失则按 0 计;`Math.round` 后过 `formatSeconds` 得 `M:SS`。
+
+- [ ] **Step 1.2: 在 `<template>` 根级加 v-if 早分支并把原有内容包进 v-else**
+
+定位到 `<template>` 顶部(DialogueChatView.vue:1)。原结构:
+
+```vue
+<template>
+  <div class="dialogue-chat-view">
+    <!-- ── HEADER ── -->
+    <div class="chat-header">
+      ...
+```
+
+改为(在 `<div class="dialogue-chat-view">` 内最前面插入 finalizing 屏,然后用 `<template v-else>` 包裹所有原有兄弟节点直到 `</div>` 之前):
+
+```vue
+<template>
+  <div class="dialogue-chat-view">
+    <!-- ── 结束态全屏 loading ── -->
+    <div v-if="reportFetchInflight" class="finalizing-screen">
+      <div class="finalizing-spinner">
+        <svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor"
+          stroke-width="2" stroke-linecap="round">
+          <path d="M21 12a9 9 0 1 1-6.219-8.56" />
+        </svg>
+      </div>
+      <p class="finalizing-title">AI 正在为你整理报告...</p>
+      <div class="finalizing-stats-card">
+        <span class="finalizing-stat">{{ finalizingStats.rounds }} 轮</span>
+        <span class="finalizing-stats-sep">·</span>
+        <span class="finalizing-stat">{{ finalizingStats.sentences }} 句</span>
+        <span class="finalizing-stats-sep">·</span>
+        <span class="finalizing-stat">累计 {{ finalizingStats.durationText }}</span>
+      </div>
+    </div>
+
+    <template v-else>
+      <!-- ── HEADER ── -->
+      <div class="chat-header">
+        ...(其余所有原有内容保持不变,直到 badge 弹窗结束)...
+      </div>
+    </template>
+  </div>
+</template>
+```
+
+> **重要**:`<template v-else>` 必须包裹所有原本作为 `.dialogue-chat-view` 直接子节点的元素:`.chat-header`、`.permission-banner` (v-if)、`.chat-area`、`.silence-hint-wrap` (v-if)、`.control-zone`、`<TaskHintModal>`、`.modal-mask` 各处 (v-if)、`.badge-popup` (v-if)。即从原 DialogueChatView.vue:4 (`<!-- ── HEADER ── -->`) 一直到 DialogueChatView.vue:530 (`</div>` 闭合 badge-popup) 整段都进 `<template v-else>`。
+>
+> `<template v-else>` 不会引入额外 DOM 节点,外层 `.dialogue-chat-view` 的 flex 布局不变。
+
+- [ ] **Step 1.3: 在 `<style lang="scss" scoped>` 末尾加 finalizing 屏样式**
+
+定位到 `<style>` 段最末尾(DialogueChatView.vue:1868 `.scale-in { ... }` 之后),追加:
+
+```scss
+// ─────────────────────────────────────────────
+// Finalizing screen (full-view loading while fetching report)
+// ─────────────────────────────────────────────
+.finalizing-screen {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  gap: 16px;
+  padding: 24px;
+  background: #fff;
+}
+.finalizing-spinner {
+  color: #fb923c;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  svg { animation: spin 1s linear infinite; }
+}
+.finalizing-title {
+  font-size: 14px;
+  color: #374151;
+  margin: 0;
+  font-weight: 500;
+}
+.finalizing-stats-card {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  padding: 12px 18px;
+  background: #f9fafb;
+  border: 1px solid #f3f4f6;
+  border-radius: 12px;
+  font-size: 12px;
+  color: #6b7280;
+  font-variant-numeric: tabular-nums;
+}
+.finalizing-stat { white-space: nowrap; }
+.finalizing-stats-sep { color: #d1d5db; }
+```
+
+> `spin` keyframes 已经在 DialogueChatView.vue:1676 定义(被 `.spinner` 复用),这里直接引用同名动画。
+
+- [ ] **Step 1.4: 起开发服并验证 finalizing 屏会显示**
+
+Run: `npm run dev`(或 `pnpm dev`)。打开浏览器进 EnglishSpeaking → 创建 session → 进入对话。
+
+**临时手动触发**:浏览器 DevTools → Vue DevTools → 找到 `DialogueChatView` 组件 → 把 `reportFetchInflight` 手动改成 `true`(或在 Console `$vm._.setupState.reportFetchInflight.value = true`)。
+
+Expected:
+- 整个 chat view 区域被替换成居中 loading 屏
+- spinner 在转
+- 文案 "AI 正在为你整理报告..." 显示
+- 数据卡片显示 "0 轮 · 0 句 · 累计 0:00"(未开始练习时)
+- header / 聊天区 / 底部控制条 全部消失
+- 把 `reportFetchInflight` 改回 `false` → 恢复原界面
+
+如果 `<template v-else>` 包裹错了节点,常见症状是部分原有元素漏在 finalizing 屏外面同时显示。
+
+- [ ] **Step 1.5: Commit**
+
+```bash
+git add src/views/Editor/EnglishSpeaking/preview/DialogueChatView.vue
+git commit -m "$(cat <<'EOF'
+feat(speaking): full-view finalizing loading in DialogueChatView
+
+Replace the entire chat view with a centered loading screen while the
+report is being fetched, keyed off reportFetchInflight. Shows live stats
+(rounds / sentence count / accumulated student-audio duration) read
+straight from engine state — no fake progress phases.
+
+Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
+EOF
+)"
+```
+
+---
+
+## Task 2: 清理 control zone 内 `state-finalizing` 死代码
+
+**Files:**
+- Modify: `src/views/Editor/EnglishSpeaking/preview/DialogueChatView.vue`
+
+**目标**:根级 v-if 已经接管 finalizing 时刻的整棵树,原 control zone 里的 `state-finalizing` 状态层永远走不到。一并清掉 `state` computed 里的 `'finalizing'` 分支。
+
+- [ ] **Step 2.1: 修改 `state` computed**
+
+定位到 DialogueChatView.vue:646-664。原代码:
+
+```ts
+const state = computed<
+  'idle' | 'starting' | 'recording' | 'stt' | 'ai_thinking' | 'finalizing' | 'error' | 'done'
+>(() => {
+  if (isStarting.value) return 'starting'
+  if (recorder.isRecording.value) return 'recording'
+  if (engine.isComplete.value) return reportFetchInflight.value ? 'finalizing' : 'done'
+
+  const msgs = engine.messages.value
+  const last = msgs[msgs.length - 1]
+
+  if (last?.status === 'error') return 'error'
+
+  // 学生消息 loading 且无 content → 正在 STT
+  if (last?.role === 'student' && last.status === 'loading' && !last.content) return 'stt'
+
+  if (engine.isProcessing.value) return 'ai_thinking'
+
+  return 'idle'
+})
+```
+
+改为:
+
+```ts
+const state = computed<
+  'idle' | 'starting' | 'recording' | 'stt' | 'ai_thinking' | 'error' | 'done'
+>(() => {
+  if (isStarting.value) return 'starting'
+  if (recorder.isRecording.value) return 'recording'
+  if (engine.isComplete.value) return 'done'
+
+  const msgs = engine.messages.value
+  const last = msgs[msgs.length - 1]
+
+  if (last?.status === 'error') return 'error'
+
+  // 学生消息 loading 且无 content → 正在 STT
+  if (last?.role === 'student' && last.status === 'loading' && !last.content) return 'stt'
+
+  if (engine.isProcessing.value) return 'ai_thinking'
+
+  return 'idle'
+})
+```
+
+> 改动:
+> 1. 联合类型移除 `'finalizing'`
+> 2. `engine.isComplete.value` 分支不再三元,直接 `'done'`(`reportFetchInflight === true` 时根级 v-if 提前替换,state machine 走不到这里)
+
+- [ ] **Step 2.2: 删除 control zone 里的 `state-layer state-finalizing` 块**
+
+定位到 DialogueChatView.vue:429-436(在 error 状态层之后、`</div>` 闭合 `.state-stack` 之前):
+
+```vue
+<!-- finalizing -->
+<div class="state-layer state-center" :style="stateStyle('finalizing')">
+  <svg class="spinner" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
+    stroke-width="2" stroke-linecap="round">
+    <path d="M21 12a9 9 0 1 1-6.219-8.56" />
+  </svg>
+  <span class="center-text">正在生成你的本次对话报告...</span>
+</div>
+```
+
+整段(包括前面注释 `<!-- finalizing -->`)删除。
+
+- [ ] **Step 2.3: TS 类型检查**
+
+Run: `npm run type-check`
+
+Expected: 通过。`stateStyle('finalizing')` 不再被调用,`state.value === 'finalizing'` 也不再出现,TypeScript 应能确认无 dangling reference。
+
+> 若类型检查报 `Argument of type '"finalizing"' is not assignable to parameter of type 'string'`,说明漏删了某处对 `'finalizing'` 的引用——全局搜 `'finalizing'` 字面量定位。
+
+- [ ] **Step 2.4: 浏览器再验证一次**
+
+Run: 开发服已开。手动触发 `reportFetchInflight = true`(同 Step 1.4 方法)→ 看 finalizing 屏。手动改回 false → 看其它状态:idle 显示提示+录音按钮、recording 显示录音胶囊、ai_thinking 显示"Amy 正在回复..."。
+
+Expected: 各状态正常切换,没有任何状态层显示残缺或样式错乱(因为只删了 `'finalizing'` 这一层,其余 state-layer 与 stateStyle 调用未动)。
+
+- [ ] **Step 2.5: Commit**
+
+```bash
+git add src/views/Editor/EnglishSpeaking/preview/DialogueChatView.vue
+git commit -m "$(cat <<'EOF'
+refactor(speaking): drop dead state-finalizing layer in DialogueChatView
+
+The control-zone finalizing strip is now unreachable — root-level v-if
+on reportFetchInflight replaces the entire view first. Remove the
+'finalizing' literal from the state union and delete the dead DOM.
+
+Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
+EOF
+)"
+```
+
+---
+
+## Task 3: 三条结束路径人工验证
+
+**Files:** 无修改,仅验证。
+
+- [ ] **Step 3.1: 自动完成路径(最后一轮 AI 收到 isComplete 事件)**
+
+操作:在浏览器进入对话,把 `totalRounds` 跑到底(默认 3 轮,每轮一来一回)。最后一轮学生消息发出后等 AI 回复完成。
+
+Expected:
+- 最后一轮 AI 消息流式 done 后,**立即**整个 view 替换为 finalizing 屏
+- 数据卡显示真实数字:`3 轮 · ~3 句 · 累计 X:XX`(X 为学生录音真实累计秒数;若该 session 学生消息没有 audioDuration,则 `0:00`)
+- spinner 持续转
+- 父组件 `TopicDiscussionPreview` 拿到 report 后切到报告页(finalizing 屏自动消失)
+
+- [ ] **Step 3.2: 手动结束路径(点"结束并查看报告")**
+
+操作:进入对话进行至少 1 轮,点 header 右上三点 → "结束并查看报告"。
+
+Expected:
+- 弹窗关闭后**立即**出现 finalizing 屏(修复了原本"弹窗关闭后空白等待"的 UX 漏洞)
+- 数据卡显示 `1 轮 · 1 句 · 累计 X:XX` 或类似真实数据
+- 报告拉到后切到报告页
+
+> 重要回归点:原本手动路径下 finalizing 状态层判定 `engine.isComplete && reportFetchInflight` 同时成立,但 `completeSession` 在 `fetchReportSafe` 内才执行,导致这条路径 0 反馈。改完后必须立刻有反馈。
+
+- [ ] **Step 3.3: 倒计时归零路径**
+
+操作:找一个 `expiresAt` 已临近或可调短的 session(或 DevTools 改 `engine.countdownSeconds.value` 加速)。等到 0。
+
+Expected:
+- countdown 到 0 → engine 把 `isComplete = true`(useDialogueEngine.ts:303) → watcher 调 `fetchReportSafe` → finalizing 屏出现
+- 表现与 Step 3.1 一致
+
+> 若环境难以模拟过期,可暂以 Step 3.1 + 3.2 覆盖;倒计时与自动完成走的是同一 watcher 路径(DialogueChatView.vue:1025-1030),逻辑等价。
+
+- [ ] **Step 3.4: 报告失败 fallback(可选)**
+
+操作:Network tab 把 `getReport` 端点 block 掉,再触发任一结束路径。
+
+Expected:
+- finalizing 屏显示约 60s(30 × 2s 轮询超时)
+- 之后 `fetchReportSafe` catch 返回 null → `reportFetchInflight = false` → finalizing 屏消失
+- 父组件切到报告页并显示 `reportError` 文案"报告生成超时或获取失败,请稍后重试。"(TopicDiscussionPreview.vue:446)
+
+- [ ] **Step 3.5: 0 句边角场景(可选)**
+
+操作:进入对话立即点结束(一句话都不说)。
+
+Expected:
+- finalizing 屏显示 `1 轮 · 0 句 · 累计 0:00`(或 `0 轮`,取决于 currentRound 初值)
+- 不报错、不崩
+- 父组件接收到 null report → 显示"本次练习没有足够的有效回答生成报告"
+
+- [ ] **Step 3.6: 收尾确认 git 状态**
+
+Run: `git log --oneline -5`
+
+Expected: 看到两个新 commit(Task 1 / Task 2),都在 `feat/english-speaking-2026-0507` 分支上,工作树干净。
+
+无新代码提交在此 task。