|
|
@@ -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。
|