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起开发服 + 浏览器验证三条路径。
Files:
src/views/Editor/EnglishSpeaking/preview/DialogueChatView.vue目标:根级 v-if 出现 finalizing 屏;旧 control zone 里的 state-finalizing 暂时保留(Task 2 再清理)。这一步先确保 UI 先生效、不引入空白期。
<script setup> 加 finalizingStats computed定位到 lastErrorText computed 紧接其后(约 DialogueChatView.vue:673 后),插入:
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。
<template> 根级加 v-if 早分支并把原有内容包进 v-else定位到 <template> 顶部(DialogueChatView.vue:1)。原结构:
<template>
<div class="dialogue-chat-view">
<!-- ── HEADER ── -->
<div class="chat-header">
...
改为(在 <div class="dialogue-chat-view"> 内最前面插入 finalizing 屏,然后用 <template v-else> 包裹所有原有兄弟节点直到 </div> 之前):
<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 布局不变。
<style lang="scss" scoped> 末尾加 finalizing 屏样式定位到 <style> 段最末尾(DialogueChatView.vue:1868 .scale-in { ... } 之后),追加:
// ─────────────────────────────────────────────
// 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; }
spinkeyframes 已经在 DialogueChatView.vue:1676 定义(被.spinner复用),这里直接引用同名动画。
Run: npm run dev(或 pnpm dev)。打开浏览器进 EnglishSpeaking → 创建 session → 进入对话。
临时手动触发:浏览器 DevTools → Vue DevTools → 找到 DialogueChatView 组件 → 把 reportFetchInflight 手动改成 true(或在 Console $vm._.setupState.reportFetchInflight.value = true)。
Expected:
reportFetchInflight 改回 false → 恢复原界面如果 <template v-else> 包裹错了节点,常见症状是部分原有元素漏在 finalizing 屏外面同时显示。
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
)"
state-finalizing 死代码Files:
src/views/Editor/EnglishSpeaking/preview/DialogueChatView.vue目标:根级 v-if 已经接管 finalizing 时刻的整棵树,原 control zone 里的 state-finalizing 状态层永远走不到。一并清掉 state computed 里的 'finalizing' 分支。
state computed定位到 DialogueChatView.vue:646-664。原代码:
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'
})
改为:
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'
})
改动:
- 联合类型移除
'finalizing'engine.isComplete.value分支不再三元,直接'done'(reportFetchInflight === true时根级 v-if 提前替换,state machine 走不到这里)
state-layer state-finalizing 块定位到 DialogueChatView.vue:429-436(在 error 状态层之后、</div> 闭合 .state-stack 之前):
<!-- 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 -->)删除。
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'字面量定位。
Run: 开发服已开。手动触发 reportFetchInflight = true(同 Step 1.4 方法)→ 看 finalizing 屏。手动改回 false → 看其它状态:idle 显示提示+录音按钮、recording 显示录音胶囊、ai_thinking 显示"Amy 正在回复..."。
Expected: 各状态正常切换,没有任何状态层显示残缺或样式错乱(因为只删了 'finalizing' 这一层,其余 state-layer 与 stateStyle 调用未动)。
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
)"
Files: 无修改,仅验证。
操作:在浏览器进入对话,把 totalRounds 跑到底(默认 3 轮,每轮一来一回)。最后一轮学生消息发出后等 AI 回复完成。
Expected:
3 轮 · ~3 句 · 累计 X:XX(X 为学生录音真实累计秒数;若该 session 学生消息没有 audioDuration,则 0:00)父组件 TopicDiscussionPreview 拿到 report 后切到报告页(finalizing 屏自动消失)
[ ] Step 3.2: 手动结束路径(点"结束并查看报告")
操作:进入对话进行至少 1 轮,点 header 右上三点 → "结束并查看报告"。
Expected:
1 轮 · 1 句 · 累计 X:XX 或类似真实数据重要回归点:原本手动路径下 finalizing 状态层判定
engine.isComplete && reportFetchInflight同时成立,但completeSession在fetchReportSafe内才执行,导致这条路径 0 反馈。改完后必须立刻有反馈。
操作:找一个 expiresAt 已临近或可调短的 session(或 DevTools 改 engine.countdownSeconds.value 加速)。等到 0。
Expected:
isComplete = true(useDialogueEngine.ts:303) → watcher 调 fetchReportSafe → finalizing 屏出现若环境难以模拟过期,可暂以 Step 3.1 + 3.2 覆盖;倒计时与自动完成走的是同一 watcher 路径(DialogueChatView.vue:1025-1030),逻辑等价。
操作:Network tab 把 getReport 端点 block 掉,再触发任一结束路径。
Expected:
fetchReportSafe catch 返回 null → reportFetchInflight = false → finalizing 屏消失父组件切到报告页并显示 reportError 文案"报告生成超时或获取失败,请稍后重试。"(TopicDiscussionPreview.vue:446)
[ ] Step 3.5: 0 句边角场景(可选)
操作:进入对话立即点结束(一句话都不说)。
Expected:
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。