Просмотр исходного кода

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>
jimmylee 1 день назад
Родитель
Сommit
0f9b72fc75
1 измененных файлов с 499 добавлено и 424 удалено
  1. 499 424
      src/views/Editor/EnglishSpeaking/preview/DialogueChatView.vue

+ 499 - 424
src/views/Editor/EnglishSpeaking/preview/DialogueChatView.vue

@@ -1,55 +1,168 @@
 <template>
   <div class="dialogue-chat-view">
-    <!-- ── HEADER ── -->
-    <div class="chat-header">
-      <div class="header-left">
-        <div
-          class="ai-avatar"
-          :class="{ breathing: state === 'idle' || state === 'ai_thinking' }"
-        >{{ aiAvatar }}</div>
-        <span class="ai-name">{{ aiName }}</span>
-
-        <span v-if="state === 'idle' && showIdleHint" class="idle-hint fade-in">
-          在等你的回答...
-        </span>
-        <span
-          v-else-if="state !== 'idle' && state !== 'ai_thinking'"
-          class="online-dot"
-          title="在线"
-        />
+    <!-- ── 结束态全屏 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>
-
-      <div class="header-right">
-        <span class="round-indicator">{{ currentRound }} / {{ totalRounds }} 轮</span>
-        <span v-if="engine.countdownSeconds.value != null" class="total-time">
-          {{ formatSeconds(engine.countdownSeconds.value) }}
-        </span>
-        <button class="icon-btn" title="更多操作" @click="showExitConfirm = true">
-          <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
-            stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
-            <circle cx="12" cy="12" r="1" /><circle cx="19" cy="12" r="1" /><circle cx="5" cy="12" r="1" />
-          </svg>
-        </button>
+      <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>
 
-    <!-- 麦克风权限引导 -->
-    <div v-if="recorder.permissionState.value === 'denied'" class="permission-banner">
-      <span class="permission-icon">🎤</span>
-      <span>麦克风权限已被拒绝,请在浏览器设置中开启后刷新页面</span>
-    </div>
+    <template v-else>
+      <!-- ── HEADER ── -->
+      <div class="chat-header">
+        <div class="header-left">
+          <div
+            class="ai-avatar"
+            :class="{ breathing: state === 'idle' || state === 'ai_thinking' }"
+          >{{ aiAvatar }}</div>
+          <span class="ai-name">{{ aiName }}</span>
+
+          <span v-if="state === 'idle' && showIdleHint" class="idle-hint fade-in">
+            在等你的回答...
+          </span>
+          <span
+            v-else-if="state !== 'idle' && state !== 'ai_thinking'"
+            class="online-dot"
+            title="在线"
+          />
+        </div>
+
+        <div class="header-right">
+          <span class="round-indicator">{{ currentRound }} / {{ totalRounds }} 轮</span>
+          <span v-if="engine.countdownSeconds.value != null" class="total-time">
+            {{ formatSeconds(engine.countdownSeconds.value) }}
+          </span>
+          <button class="icon-btn" title="更多操作" @click="showExitConfirm = true">
+            <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
+              stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+              <circle cx="12" cy="12" r="1" /><circle cx="19" cy="12" r="1" /><circle cx="5" cy="12" r="1" />
+            </svg>
+          </button>
+        </div>
+      </div>
+
+      <!-- 麦克风权限引导 -->
+      <div v-if="recorder.permissionState.value === 'denied'" class="permission-banner">
+        <span class="permission-icon">🎤</span>
+        <span>麦克风权限已被拒绝,请在浏览器设置中开启后刷新页面</span>
+      </div>
+
+      <!-- ── CHAT AREA ── -->
+      <div ref="chatContainerRef" class="chat-area">
+        <template v-for="message in engine.messages.value" :key="message.id">
+          <!-- AI 消息 -->
+          <div v-if="message.role === 'ai'" class="msg-row msg-ai fade-in">
+            <div class="avatar-sm">{{ aiAvatar }}</div>
+            <div class="msg-col">
+              <!-- 音频条 -->
+              <div v-if="message.content || message.status === 'done'" class="voice-bar voice-ai">
+                <button
+                  class="play-btn play-ai"
+                  :class="{ 'play-btn-error': player.errorId.value === message.id }"
+                  @click="togglePlay(message.id)"
+                >
+                  <svg
+                    v-if="player.loadingId.value === message.id"
+                    class="play-spinner"
+                    width="12" height="12" 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>
+                  <svg
+                    v-else-if="player.playingId.value === message.id"
+                    width="12" height="12" viewBox="0 0 24 24" fill="currentColor"
+                  >
+                    <rect x="6" y="4" width="4" height="16" /><rect x="14" y="4" width="4" height="16" />
+                  </svg>
+                  <svg
+                    v-else-if="player.errorId.value === message.id"
+                    width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
+                    stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
+                  >
+                    <path d="M12 9v4" />
+                    <path d="M12 17h.01" />
+                    <path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
+                  </svg>
+                  <svg
+                    v-else
+                    width="12" height="12" viewBox="0 0 24 24" fill="currentColor"
+                  >
+                    <polygon points="5 3 19 12 5 21 5 3" />
+                  </svg>
+                </button>
+                <div class="wave-bar-group">
+                  <div
+                    v-for="i in 14"
+                    :key="i"
+                    class="wave-bar wave-ai"
+                    :style="{ height: `${Math.abs(Math.sin(i * 0.7)) * 8 + 3}px` }"
+                  />
+                </div>
+                <span
+                  v-if="player.errorId.value === message.id"
+                  class="play-error-hint"
+                >点击重试</span>
+              </div>
+
+              <!-- 英文文本 -->
+              <div v-if="showEnglishText && message.content" class="bubble bubble-ai">
+                {{ message.content }}
+              </div>
+
+              <!-- 流式加载中 -->
+              <div v-if="message.status === 'loading' && !message.content" class="typing-bubble">
+                <span class="typing-dot" style="animation-delay: 0ms" />
+                <span class="typing-dot" style="animation-delay: 150ms" />
+                <span class="typing-dot" style="animation-delay: 300ms" />
+              </div>
 
-    <!-- ── CHAT AREA ── -->
-    <div ref="chatContainerRef" class="chat-area">
-      <template v-for="message in engine.messages.value" :key="message.id">
-        <!-- AI 消息 -->
-        <div v-if="message.role === 'ai'" class="msg-row msg-ai fade-in">
-          <div class="avatar-sm">{{ aiAvatar }}</div>
-          <div class="msg-col">
-            <!-- 音频条 -->
-            <div v-if="message.content || message.status === 'done'" class="voice-bar voice-ai">
+              <!-- AI 错误 -->
+              <div v-if="message.status === 'error'" class="error-card">
+                <span class="error-text">{{ message.error || '生成失败' }}</span>
+                <button
+                  v-if="hasRetryButton(message)"
+                  class="retry-btn"
+                  :disabled="engine.greetingInflight.value"
+                  @click="handleRetry(message)"
+                >{{ retryButtonLabel(message) }}</button>
+              </div>
+            </div>
+          </div>
+
+          <!-- 学生消息 -->
+          <div v-else class="msg-row msg-student fade-in">
+            <!-- 音频条(橙色) -->
+            <div v-if="message.content || message.status !== 'loading'" class="voice-bar voice-student">
+              <span
+                v-if="player.errorId.value === message.id"
+                class="play-error-hint play-error-hint-student"
+              >点击重试</span>
+              <span
+                v-else
+                class="voice-duration voice-duration-student"
+              >{{ formatDuration(message.audioDuration) }}</span>
+              <div class="wave-bar-group">
+                <div
+                  v-for="i in 14"
+                  :key="i"
+                  class="wave-bar wave-student"
+                  :style="{ height: `${Math.abs(Math.sin(i * 0.7)) * 8 + 3}px` }"
+                />
+              </div>
               <button
-                class="play-btn play-ai"
+                class="play-btn play-student"
                 :class="{ 'play-btn-error': player.errorId.value === message.id }"
                 @click="togglePlay(message.id)"
               >
@@ -83,451 +196,358 @@
                   <polygon points="5 3 19 12 5 21 5 3" />
                 </svg>
               </button>
-              <div class="wave-bar-group">
-                <div
-                  v-for="i in 14"
-                  :key="i"
-                  class="wave-bar wave-ai"
-                  :style="{ height: `${Math.abs(Math.sin(i * 0.7)) * 8 + 3}px` }"
-                />
-              </div>
-              <span
-                v-if="player.errorId.value === message.id"
-                class="play-error-hint"
-              >点击重试</span>
             </div>
 
-            <!-- 英文文本 -->
-            <div v-if="showEnglishText && message.content" class="bubble bubble-ai">
-              {{ message.content }}
-            </div>
-
-            <!-- 流式加载中 -->
-            <div v-if="message.status === 'loading' && !message.content" class="typing-bubble">
+            <!-- 学生 STT 加载中(与 AI typing-bubble 对称,避免按完成后 student 侧空白) -->
+            <div v-if="message.status === 'loading' && !message.content" class="typing-bubble typing-bubble-student">
               <span class="typing-dot" style="animation-delay: 0ms" />
               <span class="typing-dot" style="animation-delay: 150ms" />
               <span class="typing-dot" style="animation-delay: 300ms" />
             </div>
 
-            <!-- AI 错误 -->
+            <!-- 英文识别文本(带高亮) -->
+            <div v-if="showEnglishText && message.content" class="bubble bubble-student">
+              <template v-if="message.evaluation?.wordAnalysis">
+                <template v-for="(word, idx) in message.content.split(' ')" :key="idx">
+                  <span
+                    v-if="getWordAnalysis(message, word)?.status === 'improvable'"
+                    class="improvable-word"
+                    @click="openPhonemeDetail(getWordAnalysis(message, word)!)"
+                  >{{ word }}</span>
+                  <span v-else>{{ word }}</span>
+                  {{ ' ' }}
+                </template>
+              </template>
+              <template v-else>{{ message.content }}</template>
+            </div>
+
+            <!-- 学生错误 -->
             <div v-if="message.status === 'error'" class="error-card">
-              <span class="error-text">{{ message.error || '生成失败' }}</span>
+              <span class="error-text">{{ message.error || '发送失败' }}</span>
               <button
                 v-if="hasRetryButton(message)"
                 class="retry-btn"
-                :disabled="engine.greetingInflight.value"
                 @click="handleRetry(message)"
               >{{ retryButtonLabel(message) }}</button>
+              <button
+                v-if="hasRerecordButton(message)"
+                class="rerecord-btn"
+                @click="handleRerecord(message)"
+              >重录</button>
             </div>
-          </div>
-        </div>
-
-        <!-- 学生消息 -->
-        <div v-else class="msg-row msg-student fade-in">
-          <!-- 音频条(橙色) -->
-          <div v-if="message.content || message.status !== 'loading'" class="voice-bar voice-student">
-            <span
-              v-if="player.errorId.value === message.id"
-              class="play-error-hint play-error-hint-student"
-            >点击重试</span>
-            <span
-              v-else
-              class="voice-duration voice-duration-student"
-            >{{ formatDuration(message.audioDuration) }}</span>
-            <div class="wave-bar-group">
-              <div
-                v-for="i in 14"
-                :key="i"
-                class="wave-bar wave-student"
-                :style="{ height: `${Math.abs(Math.sin(i * 0.7)) * 8 + 3}px` }"
-              />
-            </div>
-            <button
-              class="play-btn play-student"
-              :class="{ 'play-btn-error': player.errorId.value === message.id }"
-              @click="togglePlay(message.id)"
-            >
-              <svg
-                v-if="player.loadingId.value === message.id"
-                class="play-spinner"
-                width="12" height="12" 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>
-              <svg
-                v-else-if="player.playingId.value === message.id"
-                width="12" height="12" viewBox="0 0 24 24" fill="currentColor"
-              >
-                <rect x="6" y="4" width="4" height="16" /><rect x="14" y="4" width="4" height="16" />
-              </svg>
-              <svg
-                v-else-if="player.errorId.value === message.id"
-                width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
-                stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
-              >
-                <path d="M12 9v4" />
-                <path d="M12 17h.01" />
-                <path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
-              </svg>
-              <svg
-                v-else
-                width="12" height="12" viewBox="0 0 24 24" fill="currentColor"
-              >
-                <polygon points="5 3 19 12 5 21 5 3" />
-              </svg>
-            </button>
-          </div>
-
-          <!-- 学生 STT 加载中(与 AI typing-bubble 对称,避免按完成后 student 侧空白) -->
-          <div v-if="message.status === 'loading' && !message.content" class="typing-bubble typing-bubble-student">
-            <span class="typing-dot" style="animation-delay: 0ms" />
-            <span class="typing-dot" style="animation-delay: 150ms" />
-            <span class="typing-dot" style="animation-delay: 300ms" />
-          </div>
-
-          <!-- 英文识别文本(带高亮) -->
-          <div v-if="showEnglishText && message.content" class="bubble bubble-student">
-            <template v-if="message.evaluation?.wordAnalysis">
-              <template v-for="(word, idx) in message.content.split(' ')" :key="idx">
-                <span
-                  v-if="getWordAnalysis(message, word)?.status === 'improvable'"
-                  class="improvable-word"
-                  @click="openPhonemeDetail(getWordAnalysis(message, word)!)"
-                >{{ word }}</span>
-                <span v-else>{{ word }}</span>
-                {{ ' ' }}
-              </template>
-            </template>
-            <template v-else>{{ message.content }}</template>
-          </div>
-
-          <!-- 学生错误 -->
-          <div v-if="message.status === 'error'" class="error-card">
-            <span class="error-text">{{ message.error || '发送失败' }}</span>
-            <button
-              v-if="hasRetryButton(message)"
-              class="retry-btn"
-              @click="handleRetry(message)"
-            >{{ retryButtonLabel(message) }}</button>
-            <button
-              v-if="hasRerecordButton(message)"
-              class="rerecord-btn"
-              @click="handleRerecord(message)"
-            >重录</button>
-          </div>
 
-          <!-- L1 评分卡 -->
-          <div v-if="message.evaluation" class="eval-card">
-            <div class="eval-l1">
-              <div class="dim-row">
-                <span class="dim-label">准确</span>
-                <DimBadge :level="message.evaluation.dimensions.accuracy" />
-                <span class="dim-sep">|</span>
-                <span class="dim-label">流畅</span>
-                <DimBadge :level="message.evaluation.dimensions.fluency" />
-                <span class="dim-sep">|</span>
-                <span class="dim-label">完整</span>
-                <DimBadge :level="message.evaluation.dimensions.completeness" />
-                <span class="dim-sep">|</span>
-                <span class="dim-label">节奏</span>
-                <DimBadge :level="message.evaluation.dimensions.rhythm" />
-              </div>
-              <div class="sugg-row">
-                <p class="sugg-text">
-                  <span class="sugg-icon">💡</span>
-                  <span class="truncate">{{ message.evaluation.suggestion }}</span>
-                </p>
-                <button class="detail-toggle" @click="toggleExpand(message.id)">
-                  {{ expandedMessageId === message.id ? '收起' : '详情' }}
-                  <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor"
-                    stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
-                    :class="{ 'chev-up': expandedMessageId === message.id }">
-                    <polyline points="6 9 12 15 18 9" />
-                  </svg>
-                </button>
+            <!-- L1 评分卡 -->
+            <div v-if="message.evaluation" class="eval-card">
+              <div class="eval-l1">
+                <div class="dim-row">
+                  <span class="dim-label">准确</span>
+                  <DimBadge :level="message.evaluation.dimensions.accuracy" />
+                  <span class="dim-sep">|</span>
+                  <span class="dim-label">流畅</span>
+                  <DimBadge :level="message.evaluation.dimensions.fluency" />
+                  <span class="dim-sep">|</span>
+                  <span class="dim-label">完整</span>
+                  <DimBadge :level="message.evaluation.dimensions.completeness" />
+                  <span class="dim-sep">|</span>
+                  <span class="dim-label">节奏</span>
+                  <DimBadge :level="message.evaluation.dimensions.rhythm" />
+                </div>
+                <div class="sugg-row">
+                  <p class="sugg-text">
+                    <span class="sugg-icon">💡</span>
+                    <span class="truncate">{{ message.evaluation.suggestion }}</span>
+                  </p>
+                  <button class="detail-toggle" @click="toggleExpand(message.id)">
+                    {{ expandedMessageId === message.id ? '收起' : '详情' }}
+                    <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor"
+                      stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
+                      :class="{ 'chev-up': expandedMessageId === message.id }">
+                      <polyline points="6 9 12 15 18 9" />
+                    </svg>
+                  </button>
+                </div>
               </div>
-            </div>
 
-            <div v-if="expandedMessageId === message.id" class="eval-l2 fade-in">
-              <div v-if="message.evaluation.betterExpression" class="better-exp">
-                <p class="detail-label"><span>✨</span> Better expression:</p>
-                <p class="detail-content">{{ message.evaluation.betterExpression }}</p>
-              </div>
-              <div v-if="message.evaluation.suggestedWords?.length" class="suggested-words">
-                <p class="detail-label"><span>🎯</span> Try these words:</p>
-                <div class="word-tags">
-                  <span v-for="(w, i) in message.evaluation.suggestedWords" :key="i" class="word-tag">{{ w }}</span>
+              <div v-if="expandedMessageId === message.id" class="eval-l2 fade-in">
+                <div v-if="message.evaluation.betterExpression" class="better-exp">
+                  <p class="detail-label"><span>✨</span> Better expression:</p>
+                  <p class="detail-content">{{ message.evaluation.betterExpression }}</p>
+                </div>
+                <div v-if="message.evaluation.suggestedWords?.length" class="suggested-words">
+                  <p class="detail-label"><span>🎯</span> Try these words:</p>
+                  <div class="word-tags">
+                    <span v-for="(w, i) in message.evaluation.suggestedWords" :key="i" class="word-tag">{{ w }}</span>
+                  </div>
                 </div>
               </div>
             </div>
           </div>
-        </div>
-      </template>
+        </template>
 
-    </div>
-
-    <!-- 沉默提示浮层 -->
-    <div v-if="state === 'recording' && silenceHintText" class="silence-hint-wrap">
-      <div class="silence-hint fade-in">
-        <span class="silence-icon">💡</span>
-        <p class="silence-text">{{ silenceHintText }}</p>
-        <button class="silence-close" @click="silenceHintText = ''">
-          <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
-            stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
-            <line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
-          </svg>
-        </button>
       </div>
-    </div>
 
-    <!-- ── CONTROL ZONE ── -->
-    <div class="control-zone">
-      <!-- 进度条(仅录音时可见) -->
-      <div class="progress-wrap">
-        <div
-          class="progress-track"
-          :style="{ background: state === 'recording' ? '#f3f4f6' : 'transparent' }"
-        >
-          <div
-            class="progress-fill"
-            :class="{ 'near-limit': isNearLimit }"
-            :style="{
-              width: state === 'recording' ? `${progressPct}%` : '0%',
-              opacity: state === 'recording' ? 1 : 0,
-            }"
-          />
-        </div>
-      </div>
-
-      <!-- 状态叠放区 -->
-      <div class="state-stack">
-        <!-- idle -->
-        <div
-          class="state-layer state-idle"
-          :style="stateStyle('idle')"
-        >
-          <button class="hint-btn" @click="openTaskHint">
-            <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
+      <!-- 沉默提示浮层 -->
+      <div v-if="state === 'recording' && silenceHintText" class="silence-hint-wrap">
+        <div class="silence-hint fade-in">
+          <span class="silence-icon">💡</span>
+          <p class="silence-text">{{ silenceHintText }}</p>
+          <button class="silence-close" @click="silenceHintText = ''">
+            <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
               stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
-              <path d="M9 18h6" /><path d="M10 22h4" />
-              <path d="M15.09 14c.18-.98.65-1.74 1.41-2.5A4.65 4.65 0 0 0 18 8 6 6 0 0 0 6 8c0 1 .23 2.23 1.5 3.5A4.61 4.61 0 0 1 8.91 14" />
+              <line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
             </svg>
-            提示
           </button>
-          <button
-            class="mic-btn"
-            :disabled="!engine.canRecord.value"
-            @click="handleStartRecording"
+        </div>
+      </div>
+
+      <!-- ── CONTROL ZONE ── -->
+      <div class="control-zone">
+        <!-- 进度条(仅录音时可见) -->
+        <div class="progress-wrap">
+          <div
+            class="progress-track"
+            :style="{ background: state === 'recording' ? '#f3f4f6' : 'transparent' }"
           >
-            <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
-              stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
-              <path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z" />
-              <path d="M19 10v2a7 7 0 0 1-14 0v-2" />
-              <line x1="12" y1="19" x2="12" y2="23" />
-              <line x1="8" y1="23" x2="16" y2="23" />
-            </svg>
-            开始录音
-          </button>
+            <div
+              class="progress-fill"
+              :class="{ 'near-limit': isNearLimit }"
+              :style="{
+                width: state === 'recording' ? `${progressPct}%` : '0%',
+                opacity: state === 'recording' ? 1 : 0,
+              }"
+            />
+          </div>
         </div>
 
-        <!-- starting -->
-        <div class="state-layer state-starting" :style="stateStyle('starting')">
-          <div class="record-capsule">
-            <button class="cancel-btn" @click="handleCancelStarting">
-              <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
+        <!-- 状态叠放区 -->
+        <div class="state-stack">
+          <!-- idle -->
+          <div
+            class="state-layer state-idle"
+            :style="stateStyle('idle')"
+          >
+            <button class="hint-btn" @click="openTaskHint">
+              <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
                 stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
-                <line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
+                <path d="M9 18h6" /><path d="M10 22h4" />
+                <path d="M15.09 14c.18-.98.65-1.74 1.41-2.5A4.65 4.65 0 0 0 18 8 6 6 0 0 0 6 8c0 1 .23 2.23 1.5 3.5A4.61 4.61 0 0 1 8.91 14" />
               </svg>
-              取消
+              提示
             </button>
-            <div class="record-meter">
-              <svg class="spinner" width="14" height="14" 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="record-time">准备录音中...</span>
-            </div>
-            <button class="finish-btn" disabled>
-              <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
+            <button
+              class="mic-btn"
+              :disabled="!engine.canRecord.value"
+              @click="handleStartRecording"
+            >
+              <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
                 stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
-                <path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
-                <polyline points="22 4 12 14.01 9 11.01" />
+                <path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z" />
+                <path d="M19 10v2a7 7 0 0 1-14 0v-2" />
+                <line x1="12" y1="19" x2="12" y2="23" />
+                <line x1="8" y1="23" x2="16" y2="23" />
               </svg>
-              完成
+              开始录音
             </button>
           </div>
-        </div>
 
-        <!-- recording -->
-        <div class="state-layer state-recording" :style="stateStyle('recording')">
-          <div class="record-capsule">
-            <button class="cancel-btn" @click="handleCancelRecording">
-              <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
-                stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
-                <line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
-              </svg>
-              取消
-            </button>
-            <div class="record-meter">
-              <div class="animated-wave">
-                <div
-                  v-for="i in 7"
-                  :key="i"
-                  class="aw-bar"
-                  :class="{ 'near-limit': isNearLimit }"
-                  :style="{
-                    height: `${Math.abs(Math.sin(i * 0.9)) * 9 + 3}px`,
-                    animationDelay: `${(i - 1) * 0.1}s`,
-                  }"
-                />
+          <!-- starting -->
+          <div class="state-layer state-starting" :style="stateStyle('starting')">
+            <div class="record-capsule">
+              <button class="cancel-btn" @click="handleCancelStarting">
+                <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
+                  stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+                  <line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
+                </svg>
+                取消
+              </button>
+              <div class="record-meter">
+                <svg class="spinner" width="14" height="14" 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="record-time">准备录音中...</span>
               </div>
-              <span class="record-time" :class="{ 'near-limit': isNearLimit }">
-                {{ formatSeconds(recorder.recordingDuration.value) }}
-              </span>
-              <span class="record-time-max">/ {{ formatSeconds(MAX_RECORDING_SECONDS) }}</span>
+              <button class="finish-btn" disabled>
+                <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
+                  stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+                  <path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
+                  <polyline points="22 4 12 14.01 9 11.01" />
+                </svg>
+                完成
+              </button>
             </div>
-            <button class="finish-btn" @click="handleFinishRecording">
-              <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
-                stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
-                <path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
-                <polyline points="22 4 12 14.01 9 11.01" />
-              </svg>
-              完成
-            </button>
           </div>
-        </div>
 
-        <!-- stt -->
-        <div class="state-layer state-center" :style="stateStyle('stt')">
-          <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>
-
-        <!-- ai_thinking -->
-        <div class="state-layer state-center" :style="stateStyle('ai_thinking')">
-          <div class="mini-avatar">{{ aiAvatar }}</div>
-          <span class="center-text">{{ aiName }} 正在回复...</span>
-        </div>
+          <!-- recording -->
+          <div class="state-layer state-recording" :style="stateStyle('recording')">
+            <div class="record-capsule">
+              <button class="cancel-btn" @click="handleCancelRecording">
+                <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
+                  stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+                  <line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
+                </svg>
+                取消
+              </button>
+              <div class="record-meter">
+                <div class="animated-wave">
+                  <div
+                    v-for="i in 7"
+                    :key="i"
+                    class="aw-bar"
+                    :class="{ 'near-limit': isNearLimit }"
+                    :style="{
+                      height: `${Math.abs(Math.sin(i * 0.9)) * 9 + 3}px`,
+                      animationDelay: `${(i - 1) * 0.1}s`,
+                    }"
+                  />
+                </div>
+                <span class="record-time" :class="{ 'near-limit': isNearLimit }">
+                  {{ formatSeconds(recorder.recordingDuration.value) }}
+                </span>
+                <span class="record-time-max">/ {{ formatSeconds(MAX_RECORDING_SECONDS) }}</span>
+              </div>
+              <button class="finish-btn" @click="handleFinishRecording">
+                <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
+                  stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+                  <path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
+                  <polyline points="22 4 12 14.01 9 11.01" />
+                </svg>
+                完成
+              </button>
+            </div>
+          </div>
 
-        <!-- error -->
-        <div class="state-layer state-error" :style="stateStyle('error')">
-          <div class="error-info">
-            <span class="warn-icon">⚠️</span>
-            <span class="warn-text">{{ lastErrorText }}</span>
+          <!-- stt -->
+          <div class="state-layer state-center" :style="stateStyle('stt')">
+            <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>
-          <button
-            v-if="lastErroredMessage && hasRetryButton(lastErroredMessage)"
-            class="retry-pill"
-            @click="handleRetry(lastErroredMessage)"
-          >{{ retryButtonLabel(lastErroredMessage) }}</button>
-        </div>
 
-        <!-- 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>
-      </div>
-    </div>
+          <!-- ai_thinking -->
+          <div class="state-layer state-center" :style="stateStyle('ai_thinking')">
+            <div class="mini-avatar">{{ aiAvatar }}</div>
+            <span class="center-text">{{ aiName }} 正在回复...</span>
+          </div>
 
-    <!-- ─────── OVERLAYS ─────── -->
-
-    <!-- 任务提示弹窗 -->
-    <TaskHintModal
-      :visible="showHintModal"
-      :loading="taskHintLoading"
-      :error="taskHintError"
-      :hint="taskHint"
-      :ai-name="aiName"
-      @close="showHintModal = false"
-      @retry="loadTaskHint"
-    />
-
-    <!-- 音素详情弹窗 -->
-    <div v-if="phonemeDetail" class="modal-mask" @click.self="phonemeDetail = null">
-      <div class="modal phoneme-modal scale-in">
-        <div class="modal-head">
-          <div>
-            <h3 class="phoneme-word">{{ phonemeDetail.word }}</h3>
-            <p class="phoneme-sub">发音详情</p>
+          <!-- error -->
+          <div class="state-layer state-error" :style="stateStyle('error')">
+            <div class="error-info">
+              <span class="warn-icon">⚠️</span>
+              <span class="warn-text">{{ lastErrorText }}</span>
+            </div>
+            <button
+              v-if="lastErroredMessage && hasRetryButton(lastErroredMessage)"
+              class="retry-pill"
+              @click="handleRetry(lastErroredMessage)"
+            >{{ retryButtonLabel(lastErroredMessage) }}</button>
           </div>
-          <button class="close-btn" @click="phonemeDetail = null">
-            <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
-              stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
-              <line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
+
+          <!-- 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>
-          </button>
+            <span class="center-text">正在生成你的本次对话报告...</span>
+          </div>
         </div>
+      </div>
 
-        <div class="phoneme-body">
-          <div class="pho-card pho-user">
+      <!-- ─────── OVERLAYS ─────── -->
+
+      <!-- 任务提示弹窗 -->
+      <TaskHintModal
+        :visible="showHintModal"
+        :loading="taskHintLoading"
+        :error="taskHintError"
+        :hint="taskHint"
+        :ai-name="aiName"
+        @close="showHintModal = false"
+        @retry="loadTaskHint"
+      />
+
+      <!-- 音素详情弹窗 -->
+      <div v-if="phonemeDetail" class="modal-mask" @click.self="phonemeDetail = null">
+        <div class="modal phoneme-modal scale-in">
+          <div class="modal-head">
             <div>
-              <p class="pho-label">你的发音</p>
-              <p class="pho-value">{{ phonemeDetail.userPronunciation }}</p>
+              <h3 class="phoneme-word">{{ phonemeDetail.word }}</h3>
+              <p class="phoneme-sub">发音详情</p>
             </div>
-            <button class="pho-play pho-play-user">
-              <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3" /></svg>
+            <button class="close-btn" @click="phonemeDetail = null">
+              <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
+                stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+                <line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
+              </svg>
             </button>
           </div>
-          <div class="pho-card pho-standard">
-            <div>
-              <p class="pho-label">标准发音</p>
-              <p class="pho-value pho-value-green">{{ phonemeDetail.standardPronunciation }}</p>
+
+          <div class="phoneme-body">
+            <div class="pho-card pho-user">
+              <div>
+                <p class="pho-label">你的发音</p>
+                <p class="pho-value">{{ phonemeDetail.userPronunciation }}</p>
+              </div>
+              <button class="pho-play pho-play-user">
+                <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3" /></svg>
+              </button>
             </div>
-            <button class="pho-play pho-play-standard">
-              <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3" /></svg>
+            <div class="pho-card pho-standard">
+              <div>
+                <p class="pho-label">标准发音</p>
+                <p class="pho-value pho-value-green">{{ phonemeDetail.standardPronunciation }}</p>
+              </div>
+              <button class="pho-play pho-play-standard">
+                <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3" /></svg>
+              </button>
+            </div>
+            <div v-if="phonemeDetail.tip" class="pho-card pho-tip">
+              <p class="pho-label">小提示</p>
+              <p class="pho-tip-text">{{ phonemeDetail.tip }}</p>
+            </div>
+
+            <button class="pho-practice-btn" @click="practiceThisWord">
+              针对这个词重练一次
             </button>
           </div>
-          <div v-if="phonemeDetail.tip" class="pho-card pho-tip">
-            <p class="pho-label">小提示</p>
-            <p class="pho-tip-text">{{ phonemeDetail.tip }}</p>
-          </div>
-
-          <button class="pho-practice-btn" @click="practiceThisWord">
-            针对这个词重练一次
-          </button>
         </div>
       </div>
-    </div>
 
-    <!-- 退出/重开确认弹窗 -->
-    <div v-if="showExitConfirm" class="modal-mask" @click.self="showExitConfirm = false">
-      <div class="modal exit-modal scale-in">
-        <div class="modal-head">
-          <h3 class="modal-title">选择操作</h3>
-          <button class="close-btn" @click="showExitConfirm = false">
-            <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
-              stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
-              <line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
-            </svg>
-          </button>
-        </div>
-        <p class="exit-hint">请选择你的操作:</p>
-        <div class="exit-actions">
-          <button class="exit-secondary" @click="showExitConfirm = false">继续练习</button>
-          <button class="exit-secondary" @click="handleRestart">重新开始</button>
-          <button class="exit-primary" @click="handleExitConfirm">结束并查看报告</button>
+      <!-- 退出/重开确认弹窗 -->
+      <div v-if="showExitConfirm" class="modal-mask" @click.self="showExitConfirm = false">
+        <div class="modal exit-modal scale-in">
+          <div class="modal-head">
+            <h3 class="modal-title">选择操作</h3>
+            <button class="close-btn" @click="showExitConfirm = false">
+              <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
+                stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+                <line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
+              </svg>
+            </button>
+          </div>
+          <p class="exit-hint">请选择你的操作:</p>
+          <div class="exit-actions">
+            <button class="exit-secondary" @click="showExitConfirm = false">继续练习</button>
+            <button class="exit-secondary" @click="handleRestart">重新开始</button>
+            <button class="exit-primary" @click="handleExitConfirm">结束并查看报告</button>
+          </div>
         </div>
       </div>
-    </div>
 
-    <!-- 徽章弹窗 -->
-    <div v-if="showBadge" class="badge-popup scale-in">
-      <div class="badge-card">
-        <span class="badge-icon">{{ showBadge.icon }}</span>
-        <div>
-          <p class="badge-name">{{ showBadge.name }}</p>
-          <p class="badge-desc">{{ showBadge.description }}</p>
+      <!-- 徽章弹窗 -->
+      <div v-if="showBadge" class="badge-popup scale-in">
+        <div class="badge-card">
+          <span class="badge-icon">{{ showBadge.icon }}</span>
+          <div>
+            <p class="badge-name">{{ showBadge.name }}</p>
+            <p class="badge-desc">{{ showBadge.description }}</p>
+          </div>
         </div>
       </div>
-    </div>
+    </template>
   </div>
 </template>
 
@@ -672,6 +692,20 @@ const lastErrorText = computed(() => {
   return last?.error || '请求异常,请稍后再试'
 })
 
+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)),
+  }
+})
+
 // ─────────────────────────────────────────────
 // Sub-Component: DimBadge
 // ─────────────────────────────────────────────
@@ -1866,4 +1900,45 @@ onUnmounted(() => {
   to   { opacity: 1; transform: scale(1); }
 }
 .scale-in { animation: scale-in-frames 0.22s ease-out; }
+
+// ─────────────────────────────────────────────
+// 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; }
 </style>