2026-05-07-dialogue-finalizing-fullscreen-loading-design.md 8.6 KB

口语对话结束态全屏 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. 触发条件

<template>
  <div class="dialogue-chat-view">
    <!-- 早分支:finalizing 全屏占位 -->
    <FinalizingScreen v-if="reportFetchInflight" :stats="finalizingStats" />

    <!-- 否则正常渲染 header / chat / control zone -->
    <template v-else>
      <!-- ... 原有结构 ... -->
    </template>
  </div>
</template>

触发源唯一为 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 的 <template> 里,不抽组件——内容简单(spinner + 文案 + 一个数据卡),无复用价值。

6. 待清理的死代码

启用 v-if 早分支后,control zone 里的 'finalizing' 状态层永远不会被渲染(外层提前替换),属于死代码:

  • 删 state computed 内 'finalizing' 分支:

    if (engine.isComplete.value) return reportFetchInflight.value ? 'finalizing' : 'done'
    

    改成:

    if (engine.isComplete.value) return 'done'
    

    (reportFetchInflight === true 时根级 v-if 已经接管,不再走 state machine。)

  • 删 state 类型联合中的 'finalizing' literal。

  • 删 control zone 里整个 <div class="state-layer state-center" :style="stateStyle('finalizing')"> 块及其内容。

7. 错误处理

完全不在 finalizing 屏内处理。fetchReportSafe 现有逻辑:

async function fetchReportSafe() {
  reportFetchInflight.value = true
  try {
    await engine.completeSession()
    return await engine.getReport()
  } catch (err) {
    console.warn('[speaking] getReport failed:', err)
    return null
  } finally {
    reportFetchInflight.value = false
  }
}

无论成功 / 失败 / 超时,reportFetchInflight 都会归 false → finalizing 屏自动消失 → 父组件 complete 事件接管。父组件 handleDialogueComplete(null) 已经在 'completed' 阶段渲染 reportError 文案(TopicDiscussionPreview.vue:443-449),覆盖三种失败语义(超时、failed、incomplete)。

不增加重试按钮——重试入口在父组件 report-stage 已隐含(用户看到错误后可走 restart / 关闭重开),finalizing 屏只负责"等待中"语义。

8. 不可取消

finalizing 屏不提供"取消"按钮:

  • completeSession 已经触发后端状态变更,回退无意义。
  • getReport 即使打断,下次进同一 session 仍会拉到(或拉不到)同样的报告。
  • 减少误触离开。

9. 范围外

  • 后端不动。
  • 不改 useDialogueEngine——reportFetchInflight 是 view-local state,不下沉到 composable。
  • 不改父组件 TopicDiscussionPreview——complete 事件契约不变,错误态渲染逻辑不变。
  • 不动现有自动播放 / 沉默检测 / 徽章 watcher——这些 watcher 在 v-if 早分支生效后仍会订阅,但因聊天区 DOM 不渲染、用户无法触发新消息,自然静默。

10. 验收

人工验证三条路径:

场景 操作 期望
自动完成 完成最后一轮 → 看 finalizing 立即出现全屏 loading,原 header / 控制条消失;报告拉到后父组件切到报告页
倒计时归零 等 countdown 到 0 同上
手动结束 点三点 → 结束并查看报告 弹窗关闭后立即出现全屏 loading(不再是空白等待)
报告失败 模拟 getReport reject finalizing 屏消失,父组件报告页显示错误文案
数据卡 0 句场景 极端:刚开 session 就立刻结束 卡片显示"0 轮 · 0 句 · 0:00",不报错