2026-05-07-dialogue-finalizing-fullscreen-loading.md 15 KB

口语对话结束态全屏 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 后),插入:

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)),
  }
})

字段说明:

  • roundsengine.currentRound,与 header 显示一致。结束时该值即"已完成轮数"(engine 在 isComplete=true 时不再 ++,见 useDialogueEngine.ts:158-160 / 249-250)。
  • sentences 排除失败消息——失败的录音不算"说出过的一句"。
  • durationText 累加 audioDuration(前一份 spec 已让后端把真实秒数下发到学生消息),缺失则按 0 计;Math.round 后过 formatSecondsM:SS
  • Step 1.2: 在 <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 布局不变。

  • Step 1.3: 在 <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; }

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
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。原代码:

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'
})

改动:

  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 之前):

<!-- 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
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 同时成立,但 completeSessionfetchReportSafe 内才执行,导致这条路径 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。