jimmylee 1 месяц назад
Родитель
Сommit
a442d71e88

+ 23 - 23
openspec/changes/topic-discussion-preview/tasks.md

@@ -1,46 +1,46 @@
 ## 1. 类型定义
 
-- [ ] 1.1 在 `src/types/englishSpeaking.ts` 中追加预览相关类型:`SentenceEvaluation`、`ScoreLevel`、`OverallEvaluation`、`DialogueStatistics`、`AIRole`(预览用)、`ChatMessage`、`BadgeAchievement`
+- [x] 1.1 在 `src/types/englishSpeaking.ts` 中追加预览相关类型:`SentenceEvaluation`、`ScoreLevel`、`OverallEvaluation`、`DialogueStatistics`、`AIRole`(预览用)、`ChatMessage`、`BadgeAchievement`
 
 ## 2. StudentPreview 容器组件
 
-- [ ] 2.1 创建 `src/views/Editor/EnglishSpeaking/preview/StudentPreview.vue`,实现 16:9 PPT 页面容器布局(标题区 slot、内容区 default slot、操作区 slot),使用 scoped SCSS
+- [x] 2.1 创建 `src/views/Editor/EnglishSpeaking/preview/StudentPreview.vue`,实现 16:9 PPT 页面容器布局(标题区 slot、内容区 default slot、操作区 slot),使用 scoped SCSS
 
 ## 3. DialogueChatView 对话组件
 
-- [ ] 3.1 创建 `src/views/Editor/EnglishSpeaking/preview/DialogueChatView.vue` 基础骨架:顶部状态栏、消息列表区、底部录音控制区
-- [ ] 3.2 实现消息渲染:AI 语音条(白色左对齐)、学生语音条(橙色右对齐)、英文文本、图标使用 SVG 内联
-- [ ] 3.3 实现录音交互:点击录音按钮切换状态、模拟生成学生回复和评估数据、AI 自动回复
-- [ ] 3.4 实现 L1 即时反馈:四维度评估(准确/流畅/完整/节奏)+ 一句话建议
-- [ ] 3.5 实现 L2 展开详情:better expression + suggested words
-- [ ] 3.6 实现单词高亮:可发音改进单词标记波浪下划线,点击弹出 L3 音素详情弹窗
-- [ ] 3.7 实现提示弹窗:任务提示、句子提示(关键词高亮)、词汇提示(音标+释义)
-- [ ] 3.8 实现徽章动画:流畅达人 / 发音专家 / 完美一轮,右上角弹出 2.5s 消失
+- [x] 3.1 创建 `src/views/Editor/EnglishSpeaking/preview/DialogueChatView.vue` 基础骨架:顶部状态栏、消息列表区、底部录音控制区
+- [x] 3.2 实现消息渲染:AI 语音条(白色左对齐)、学生语音条(橙色右对齐)、英文文本、图标使用 SVG 内联
+- [x] 3.3 实现录音交互:点击录音按钮切换状态、模拟生成学生回复和评估数据、AI 自动回复
+- [x] 3.4 实现 L1 即时反馈:四维度评估(准确/流畅/完整/节奏)+ 一句话建议
+- [x] 3.5 实现 L2 展开详情:better expression + suggested words
+- [x] 3.6 实现单词高亮:可发音改进单词标记波浪下划线,点击弹出 L3 音素详情弹窗
+- [x] 3.7 实现提示弹窗:任务提示、句子提示(关键词高亮)、词汇提示(音标+释义)
+- [x] 3.8 实现徽章动画:流畅达人 / 发音专家 / 完美一轮,右上角弹出 2.5s 消失
 
 ## 4. OverallReport 整体报告组件
 
-- [ ] 4.1 创建 `src/views/Editor/EnglishSpeaking/preview/OverallReport.vue`:综合评分展示(数字/字母/仅评语三种模式)、评分等级标签
-- [ ] 4.2 实现四维能力分析横向柱状图(流利度/互动性/词汇量/语法)
-- [ ] 4.3 实现 AI 点评区(角色头像 + 评语)、亮点列表、建议列表、统计数据
-- [ ] 4.4 实现操作按钮:"再来一次" + "完成"
+- [x] 4.1 创建 `src/views/Editor/EnglishSpeaking/preview/OverallReport.vue`:综合评分展示(数字/字母/仅评语三种模式)、评分等级标签
+- [x] 4.2 实现四维能力分析横向柱状图(流利度/互动性/词汇量/语法)
+- [x] 4.3 实现 AI 点评区(角色头像 + 评语)、亮点列表、建议列表、统计数据
+- [x] 4.4 实现操作按钮:"再来一次" + "完成"
 
 ## 5. DetailedReport 详细报告组件
 
-- [ ] 5.1 创建 `src/views/Editor/EnglishSpeaking/preview/DetailedReport.vue`:按轮次分组的对话卡片列表
-- [ ] 5.2 实现 SentenceCard 子组件:语音条 + 文本 + 可展开的发音评分和反馈详情
-- [ ] 5.3 实现展开/收起全部切换
+- [x] 5.1 创建 `src/views/Editor/EnglishSpeaking/preview/DetailedReport.vue`:按轮次分组的对话卡片列表
+- [x] 5.2 实现 SentenceCard 子组件:语音条 + 文本 + 可展开的发音评分和反馈详情
+- [x] 5.3 实现展开/收起全部切换
 
 ## 6. TopicDiscussionPreview 主预览组件
 
-- [ ] 6.1 创建 `src/views/Editor/EnglishSpeaking/preview/TopicDiscussionPreview.vue`:管理三阶段状态(ready → chatting → completed)
-- [ ] 6.2 实现 ready 阶段:StudentPreview 容器 + 话题图标 + "开始对话"按钮
-- [ ] 6.3 实现 chatting 阶段:DialogueChatView + "重置预览"按钮
-- [ ] 6.4 实现 completed 阶段:OverallReport + DetailedReport + "重置预览"按钮
-- [ ] 6.5 添加模拟评估数据(mockOverallEvaluation)
+- [x] 6.1 创建 `src/views/Editor/EnglishSpeaking/preview/TopicDiscussionPreview.vue`:管理三阶段状态(ready → chatting → completed)
+- [x] 6.2 实现 ready 阶段:StudentPreview 容器 + 话题图标 + "开始对话"按钮
+- [x] 6.3 实现 chatting 阶段:DialogueChatView + "重置预览"按钮
+- [x] 6.4 实现 completed 阶段:OverallReport + DetailedReport + "重置预览"按钮
+- [x] 6.5 添加模拟评估数据(mockOverallEvaluation)
 
 ## 7. 画布集成
 
-- [ ] 7.1 修改 `BaseFrameElement.vue`:在 v-if 链中新增 `toolType === 77` 分支,渲染 `<TopicDiscussionPreview>` 组件替代 iframe
+- [x] 7.1 修改 `BaseFrameElement.vue`:在 v-if 链中新增 `toolType === 77` 分支,渲染 `<TopicDiscussionPreview>` 组件替代 iframe
 
 ## 8. 验证
 

+ 123 - 0
src/types/englishSpeaking.ts

@@ -108,3 +108,126 @@ export type PageMode = 'layer1' | 'layer2' | 'config'
 
 // 练习类型
 export type ExerciseType = 'speaking' | 'listening' | 'reading' | 'writing'
+
+// ==================== 预览组件类型定义 ====================
+
+// 评分等级
+export type ScoreLevel = 'excellent' | 'good' | 'fair' | 'needsWork'
+
+// 评分展示方式(复用配置侧的 ScoreMode)
+export type ScoreDisplayMode = ScoreMode
+
+// 对话统计数据
+export interface DialogueStatistics {
+  totalRounds: number
+  averageScore: number
+  highestScore: number
+  highestRound: number
+  grammarErrors: number
+  excellentExpressions: number
+  totalDuration: number // 秒
+}
+
+// 下次挑战推荐
+export interface NextChallenge {
+  difficulty?: string
+  unlockedTopic?: string
+  suggestedMode?: string
+}
+
+// 单句评价
+export interface SentenceEvaluation {
+  id: string
+  round: number
+  role: 'ai' | 'student'
+  content: string
+  contentZh?: string
+  audioUrl?: string
+  audioDuration?: number
+  score?: number
+  pronunciation?: {
+    accuracy: number
+    intonation: number
+    stress: number
+    fluency: number
+  }
+  feedback?: {
+    highlights: string[]
+    corrections: {
+      original: string
+      corrected: string
+      explanation: string
+    }[]
+    suggestions: string[]
+  }
+}
+
+// 整体评价报告
+export interface OverallEvaluation {
+  overallScore: number
+  scoreLevel: ScoreLevel
+  percentile: number
+  dimensions: {
+    fluency: number
+    interaction: number
+    vocabulary: number
+    grammar: number
+  }
+  aiComment: string
+  highlights: string[]
+  improvements: string[]
+  nextChallenge: NextChallenge
+  statistics: DialogueStatistics
+  sentenceEvaluations: SentenceEvaluation[]
+}
+
+// 预览用 AI 角色(扩展配置侧的 Role)
+export interface PreviewAIRole {
+  id: string
+  name: string
+  avatar: string
+  identity: string
+  personality: string
+  speakingStyle: 'formal' | 'casual' | 'playful'
+  speed: 'slow' | 'normal' | 'fast'
+  isCustom: boolean
+  isRecommended?: boolean
+}
+
+// 对话消息
+export interface PreviewChatMessage {
+  id: string
+  role: 'ai' | 'student'
+  content: string
+  timestamp: Date
+  evaluation?: {
+    dimensions: {
+      accuracy: 'excellent' | 'good' | 'improve'
+      fluency: 'excellent' | 'good' | 'improve'
+      completeness: 'excellent' | 'good' | 'improve'
+      rhythm: 'excellent' | 'good' | 'improve'
+    }
+    suggestion: string
+    betterExpression?: string
+    suggestedWords?: string[]
+    wordAnalysis?: {
+      word: string
+      status: 'correct' | 'improvable'
+      userPronunciation?: string
+      standardPronunciation?: string
+      tip?: string
+    }[]
+  }
+}
+
+// 徽章成就
+export interface BadgeAchievement {
+  id: string
+  name: string
+  nameEn: string
+  icon: string
+  description: string
+}
+
+// 预览对话状态
+export type PreviewDialogueState = 'ready' | 'chatting' | 'completed'

+ 5 - 5
src/views/Editor/EnglishSpeaking/configs/TopicDiscussionConfig.vue

@@ -331,6 +331,7 @@
 import { ref, nextTick } from 'vue'
 import { lang } from '@/main'
 import { useSpeakingStore } from '@/store/speaking'
+import useCreateElement from '@/hooks/useCreateElement'
 
 const emit = defineEmits<{
   (e: 'back'): void
@@ -436,12 +437,11 @@ const handleBatchPasteConfirm = () => {
   showBatchPaste.value = false
 }
 
-// 应用配置 → 通知父应用
+// 应用配置 → 往画布添加预览元素
+const { createFrameElement } = useCreateElement()
+
 const handleApply = () => {
-  const parentWindow = (window as any).parent
-  if (parentWindow && parentWindow.addTool) {
-    parentWindow.addTool(77)
-  }
+  createFrameElement('', 77)
 }
 </script>
 

+ 322 - 0
src/views/Editor/EnglishSpeaking/preview/DetailedReport.vue

@@ -0,0 +1,322 @@
+<template>
+  <div class="detailed-report">
+    <div class="report-header">
+      <h3 class="report-title">详细报告</h3>
+      <button class="toggle-all-btn" @click="toggleAll">
+        {{ allExpanded ? '收起全部' : '展开全部' }}
+        <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" :class="{ 'chevron-up': allExpanded }">
+          <polyline points="6 9 12 15 18 9" />
+        </svg>
+      </button>
+    </div>
+
+    <!-- 按轮次分组 -->
+    <div
+      v-for="(roundData, roundIndex) in groupedByRound"
+      :key="roundIndex"
+      class="round-group"
+    >
+      <div class="round-label">Round {{ roundIndex + 1 }}</div>
+      <div class="round-cards">
+        <div
+          v-for="sentence in roundData"
+          :key="sentence.id"
+          class="sentence-card"
+          :class="{ 'card-student': sentence.role === 'student', 'card-expanded': expandedIds.has(sentence.id) }"
+          @click="sentence.role === 'student' && sentence.score !== undefined && toggleExpand(sentence.id)"
+        >
+          <!-- 卡片主体 -->
+          <div class="card-body" :class="{ 'body-student': sentence.role === 'student' }">
+            <div class="card-left">
+              <!-- 语音条 -->
+              <div class="card-voice-bar" :class="sentence.role === 'ai' ? 'bar-ai' : 'bar-student'">
+                <button class="card-play-btn" :class="sentence.role === 'ai' ? 'play-ai' : 'play-student'" @click.stop>
+                  <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+                    <polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
+                    <path d="M15.54 8.46a5 5 0 0 1 0 7.07" />
+                  </svg>
+                </button>
+                <div class="card-waveform">
+                  <div
+                    v-for="i in 8"
+                    :key="i"
+                    class="card-wave-bar"
+                    :class="sentence.role === 'ai' ? 'cw-ai' : 'cw-student'"
+                    :style="{ height: `${Math.sin(i * 0.6) * 4 + 3}px` }"
+                  />
+                </div>
+                <span class="card-duration" :class="sentence.role === 'ai' ? 'cd-ai' : 'cd-student'">
+                  {{ formatDuration(sentence.audioDuration || 3) }}
+                </span>
+              </div>
+
+              <!-- 文本 -->
+              <p class="card-text">{{ sentence.content }}</p>
+              <p v-if="sentence.contentZh" class="card-text-zh">{{ sentence.contentZh }}</p>
+            </div>
+
+            <!-- 评分标签 -->
+            <div v-if="sentence.role === 'student' && sentence.score !== undefined" class="card-right">
+              <span class="card-score" :class="getScoreClass(sentence.score)">{{ sentence.score }}</span>
+              <svg v-if="sentence.role === 'student'" class="expand-icon" :class="{ 'expanded': expandedIds.has(sentence.id) }" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#9ca3af" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+                <polyline points="6 9 12 15 18 9" />
+              </svg>
+            </div>
+          </div>
+
+          <!-- 展开详情 -->
+          <div v-if="sentence.role === 'student' && expandedIds.has(sentence.id)" class="card-detail">
+            <!-- 发音评分 -->
+            <div v-if="sentence.pronunciation" class="pron-scores">
+              <div class="pron-row">
+                <span class="pron-label">准确度</span>
+                <div class="pron-track"><div class="pron-fill" :style="{ width: `${sentence.pronunciation.accuracy}%` }" /></div>
+                <span class="pron-value">{{ sentence.pronunciation.accuracy }}</span>
+              </div>
+              <div class="pron-row">
+                <span class="pron-label">语调</span>
+                <div class="pron-track"><div class="pron-fill" :style="{ width: `${sentence.pronunciation.intonation}%` }" /></div>
+                <span class="pron-value">{{ sentence.pronunciation.intonation }}</span>
+              </div>
+              <div class="pron-row">
+                <span class="pron-label">重音</span>
+                <div class="pron-track"><div class="pron-fill" :style="{ width: `${sentence.pronunciation.stress}%` }" /></div>
+                <span class="pron-value">{{ sentence.pronunciation.stress }}</span>
+              </div>
+              <div class="pron-row">
+                <span class="pron-label">流畅度</span>
+                <div class="pron-track"><div class="pron-fill" :style="{ width: `${sentence.pronunciation.fluency}%` }" /></div>
+                <span class="pron-value">{{ sentence.pronunciation.fluency }}</span>
+              </div>
+            </div>
+
+            <!-- 反馈信息 -->
+            <div v-if="sentence.feedback" class="feedback-section">
+              <div v-if="sentence.feedback.highlights.length" class="feedback-block">
+                <div class="feedback-block-label"><span class="fb-good">✓</span> 亮点</div>
+                <ul class="feedback-list">
+                  <li v-for="(h, i) in sentence.feedback.highlights" :key="i">{{ h }}</li>
+                </ul>
+              </div>
+              <div v-if="sentence.feedback.corrections.length" class="feedback-block">
+                <div class="feedback-block-label"><span class="fb-fix">✎</span> 改正</div>
+                <div v-for="(c, i) in sentence.feedback.corrections" :key="i" class="correction-item">
+                  <div class="correction-row">
+                    <span class="correction-original">{{ c.original }}</span>
+                    <span class="correction-arrow">→</span>
+                    <span class="correction-corrected">{{ c.corrected }}</span>
+                  </div>
+                  <p class="correction-explanation">{{ c.explanation }}</p>
+                </div>
+              </div>
+              <div v-if="sentence.feedback.suggestions.length" class="feedback-block">
+                <div class="feedback-block-label"><span class="fb-suggest">💡</span> 建议</div>
+                <ul class="feedback-list">
+                  <li v-for="(s, i) in sentence.feedback.suggestions" :key="i">{{ s }}</li>
+                </ul>
+              </div>
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { ref, computed } from 'vue'
+import type { SentenceEvaluation } from '@/types/englishSpeaking'
+
+interface Props {
+  sentenceEvaluations: SentenceEvaluation[]
+}
+
+const props = defineProps<Props>()
+
+const expandedIds = ref(new Set<string>())
+const allExpanded = ref(false)
+
+const groupedByRound = computed(() => {
+  const groups: SentenceEvaluation[][] = []
+  for (const s of props.sentenceEvaluations) {
+    const idx = s.round - 1
+    if (!groups[idx]) groups[idx] = []
+    groups[idx].push(s)
+  }
+  return groups
+})
+
+function toggleExpand(id: string) {
+  if (expandedIds.value.has(id)) {
+    expandedIds.value.delete(id)
+  } else {
+    expandedIds.value.add(id)
+  }
+}
+
+function toggleAll() {
+  if (allExpanded.value) {
+    expandedIds.value.clear()
+  } else {
+    props.sentenceEvaluations
+      .filter(s => s.role === 'student' && s.score !== undefined)
+      .forEach(s => expandedIds.value.add(s.id))
+  }
+  allExpanded.value = !allExpanded.value
+}
+
+function formatDuration(seconds: number) {
+  const mins = Math.floor(seconds / 60)
+  const secs = seconds % 60
+  return `${mins}:${String(secs).padStart(2, '0')}`
+}
+
+function getScoreClass(score: number) {
+  if (score >= 90) return 'score-excellent'
+  if (score >= 80) return 'score-good'
+  if (score >= 70) return 'score-fair'
+  return 'score-low'
+}
+</script>
+
+<style lang="scss" scoped>
+.detailed-report {
+  max-width: 448px;
+  margin: 0 auto;
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+}
+
+.report-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+}
+.report-title { font-size: 14px; font-weight: 600; color: #111827; margin: 0; }
+.toggle-all-btn {
+  font-size: 11px;
+  color: #f97316;
+  background: none;
+  border: none;
+  cursor: pointer;
+  display: flex;
+  align-items: center;
+  gap: 2px;
+  &:hover { color: #ea580c; }
+  svg { transition: transform 0.2s; }
+  .chevron-up { transform: rotate(180deg); }
+}
+
+.round-group { display: flex; flex-direction: column; gap: 6px; }
+.round-label { font-size: 10px; font-weight: 500; color: #9ca3af; text-transform: uppercase; letter-spacing: 0.5px; }
+
+.round-cards { display: flex; flex-direction: column; gap: 6px; }
+
+.sentence-card {
+  background: #fff;
+  border-radius: 10px;
+  border: 1px solid #f3f4f6;
+  overflow: hidden;
+  transition: border-color 0.2s;
+}
+.card-student { cursor: pointer; &:hover { border-color: #fed7aa; } }
+.card-expanded { border-color: rgba(249,115,22,0.3); }
+
+.card-body {
+  padding: 10px 12px;
+  display: flex;
+  align-items: flex-start;
+  justify-content: space-between;
+  gap: 8px;
+}
+.card-left { flex: 1; min-width: 0; }
+
+// 卡片语音条
+.card-voice-bar {
+  display: inline-flex;
+  align-items: center;
+  gap: 4px;
+  padding: 4px 8px;
+  border-radius: 8px;
+  margin-bottom: 4px;
+}
+.bar-ai { background: #fff; border: 1px solid #f3f4f6; }
+.bar-student { background: #f97316; }
+
+.card-play-btn {
+  padding: 2px;
+  border-radius: 50%;
+  border: none;
+  cursor: pointer;
+}
+.play-ai { background: rgba(249,115,22,0.1); color: #f97316; }
+.play-student { background: rgba(255,255,255,0.2); color: #fff; }
+
+.card-waveform { display: flex; align-items: center; gap: 1px; }
+.card-wave-bar { width: 1.5px; border-radius: 999px; }
+.cw-ai { background: rgba(249,115,22,0.4); }
+.cw-student { background: rgba(255,255,255,0.5); }
+
+.card-duration { font-size: 9px; }
+.cd-ai { color: #9ca3af; }
+.cd-student { color: rgba(255,255,255,0.8); }
+
+.card-text { font-size: 12px; color: #1f2937; margin: 0; line-height: 1.5; }
+.card-text-zh { font-size: 10px; color: #9ca3af; margin: 2px 0 0; }
+
+.card-right {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 4px;
+}
+.card-score {
+  font-size: 12px;
+  font-weight: 600;
+  padding: 2px 8px;
+  border-radius: 999px;
+}
+.score-excellent { background: #dcfce7; color: #16a34a; }
+.score-good { background: #dbeafe; color: #2563eb; }
+.score-fair { background: #fef3c7; color: #d97706; }
+.score-low { background: #fee2e2; color: #ef4444; }
+
+.expand-icon {
+  transition: transform 0.2s;
+  &.expanded { transform: rotate(180deg); }
+}
+
+// 展开详情
+.card-detail {
+  padding: 10px 12px;
+  border-top: 1px solid #f9fafb;
+  background: #fafbfc;
+}
+
+.pron-scores { display: flex; flex-direction: column; gap: 8px; margin-bottom: 10px; }
+.pron-row { display: flex; align-items: center; gap: 8px; }
+.pron-label { font-size: 10px; color: #6b7280; width: 36px; flex-shrink: 0; }
+.pron-track { flex: 1; height: 4px; background: #e5e7eb; border-radius: 999px; overflow: hidden; }
+.pron-fill { height: 100%; background: #f97316; border-radius: 999px; transition: width 0.5s; }
+.pron-value { font-size: 10px; font-weight: 500; color: #f97316; width: 20px; text-align: right; }
+
+.feedback-section { display: flex; flex-direction: column; gap: 10px; }
+.feedback-block { margin-bottom: 2px; }
+.feedback-block-label { font-size: 10px; color: #6b7280; display: flex; align-items: center; gap: 4px; margin-bottom: 4px; }
+.fb-good { color: #22c55e; }
+.fb-fix { color: #3b82f6; }
+.fb-suggest { color: #f59e0b; }
+.feedback-list {
+  margin: 0;
+  padding-left: 16px;
+  li { font-size: 10px; color: #4b5563; line-height: 1.6; }
+}
+
+.correction-item { margin-bottom: 6px; }
+.correction-row { display: flex; align-items: center; gap: 4px; font-size: 11px; }
+.correction-original { color: #ef4444; text-decoration: line-through; }
+.correction-arrow { color: #9ca3af; }
+.correction-corrected { color: #22c55e; font-weight: 500; }
+.correction-explanation { font-size: 10px; color: #6b7280; margin: 2px 0 0; }
+</style>

+ 1040 - 0
src/views/Editor/EnglishSpeaking/preview/DialogueChatView.vue

@@ -0,0 +1,1040 @@
+<template>
+  <div class="dialogue-chat-view">
+    <!-- 顶部状态栏 -->
+    <div class="status-bar">
+      <div class="status-left">
+        <div class="ai-avatar-small">{{ aiAvatar }}</div>
+        <span class="ai-name">{{ aiName }}</span>
+        <span class="online-dot"></span>
+      </div>
+      <div class="status-right">
+        <span v-if="isRecording" class="recording-duration">{{ recordingDuration }}s</span>
+        <span class="round-indicator">{{ currentRound }}/{{ totalRounds }}</span>
+      </div>
+    </div>
+
+    <!-- 对话消息区 -->
+    <div ref="chatContainerRef" class="chat-messages">
+      <div
+        v-for="message in messages"
+        :key="message.id"
+        class="message-row"
+        :class="{ 'message-student': message.role === 'student' }"
+      >
+        <div class="message-content" :class="{ 'student-content': message.role === 'student' }">
+          <!-- 语音条 -->
+          <div class="voice-bar" :class="message.role === 'ai' ? 'voice-ai' : 'voice-student'">
+            <button class="play-btn" :class="message.role === 'ai' ? 'play-ai' : 'play-student'">
+              <!-- VolumeIcon SVG -->
+              <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+                <polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
+                <path d="M15.54 8.46a5 5 0 0 1 0 7.07" />
+              </svg>
+            </button>
+            <div class="waveform">
+              <div
+                v-for="i in 12"
+                :key="i"
+                class="wave-bar"
+                :class="message.role === 'ai' ? 'wave-ai' : 'wave-student'"
+                :style="{ height: `${Math.sin(i * 0.5) * 6 + 4}px` }"
+              />
+            </div>
+            <span class="duration" :class="message.role === 'ai' ? 'duration-ai' : 'duration-student'">0:03</span>
+          </div>
+
+          <!-- 英文文本(带单词高亮) -->
+          <div class="text-bubble" :class="message.role === 'ai' ? 'text-ai' : 'text-student'">
+            <template v-if="message.role === 'student' && 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="showPhonemeDetail(getWordAnalysis(message, word)!)"
+                >{{ word }}</span>
+                <span v-else>{{ word }}</span>
+                {{ ' ' }}
+              </template>
+            </template>
+            <template v-else>{{ message.content }}</template>
+          </div>
+
+          <!-- L1 即时反馈 -->
+          <div v-if="message.role === 'student' && message.evaluation" class="feedback-card">
+            <div class="feedback-l1">
+              <div class="dimensions-row">
+                <span class="dim-label">准确</span>
+                <span :class="getDimClass(message.evaluation.dimensions.accuracy)">{{ getDimIcon(message.evaluation.dimensions.accuracy) }}</span>
+                <span class="dim-sep">|</span>
+                <span class="dim-label">流畅</span>
+                <span :class="getDimClass(message.evaluation.dimensions.fluency)">{{ getDimIcon(message.evaluation.dimensions.fluency) }}</span>
+                <span class="dim-sep">|</span>
+                <span class="dim-label">完整</span>
+                <span :class="getDimClass(message.evaluation.dimensions.completeness)">{{ getDimIcon(message.evaluation.dimensions.completeness) }}</span>
+                <span class="dim-sep">|</span>
+                <span class="dim-label">节奏</span>
+                <span :class="getDimClass(message.evaluation.dimensions.rhythm)">{{ getDimIcon(message.evaluation.dimensions.rhythm) }}</span>
+              </div>
+              <div class="suggestion-row">
+                <p class="suggestion-text">
+                  <span class="suggestion-icon">💡</span>
+                  {{ message.evaluation.suggestion }}
+                </p>
+                <button class="detail-toggle" @click="toggleExpand(message.id)">
+                  {{ expandedMessageId === message.id ? '收起' : '详情' }}
+                  <!-- ChevronDown SVG -->
+                  <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" :class="{ 'chevron-up': expandedMessageId === message.id }">
+                    <polyline points="6 9 12 15 18 9" />
+                  </svg>
+                </button>
+              </div>
+            </div>
+
+            <!-- L2 展开详情 -->
+            <div v-if="expandedMessageId === message.id" class="feedback-l2">
+              <div v-if="message.evaluation.betterExpression" class="better-expression">
+                <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="(word, i) in message.evaluation.suggestedWords" :key="i" class="word-tag">{{ word }}</span>
+                </div>
+              </div>
+            </div>
+          </div>
+        </div>
+      </div>
+
+      <!-- AI 正在输入 -->
+      <div v-if="isWaiting" class="message-row">
+        <div class="typing-indicator">
+          <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>
+    </div>
+
+    <!-- 录音时的沉默提示 -->
+    <div v-if="isRecording && silenceHint" class="silence-hint">
+      <div class="silence-hint-card">
+        <p class="silence-hint-text">
+          <span class="hint-icon">💡</span>
+          {{ silenceHint }}
+        </p>
+      </div>
+    </div>
+
+    <!-- 底部录音控制区 -->
+    <div class="recording-controls">
+      <div class="controls-inner">
+        <!-- 提示按钮 -->
+        <button
+          class="side-btn"
+          :disabled="isWaiting || isRecording"
+          @click="showSmartHint = true"
+        >
+          <!-- LightbulbIcon SVG -->
+          <svg width="14" height="14" 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" />
+          </svg>
+          <span>提示</span>
+        </button>
+
+        <!-- 录音按钮 -->
+        <div class="record-group">
+          <button
+            class="record-btn"
+            :class="{ recording: isRecording, waiting: isWaiting }"
+            :disabled="isWaiting"
+            @click="handleToggleRecording"
+          >
+            <!-- MicIcon SVG -->
+            <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="white" 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="record-status">
+            <div v-if="isRecording" class="pulse-bars">
+              <div v-for="i in 4" :key="i" class="pulse-bar" :style="{ height: `${Math.sin((i) * 0.8) * 6 + 6}px`, animationDelay: `${i * 0.15}s` }" />
+            </div>
+            <span class="record-label" :class="{ 'label-recording': isRecording, 'label-waiting': isWaiting }">
+              {{ isRecording ? '录音中' : isWaiting ? '等待中...' : '点击说话' }}
+            </span>
+          </div>
+        </div>
+
+        <!-- 重录按钮 -->
+        <button class="side-btn" :disabled="isWaiting || isRecording">
+          <!-- RefreshIcon SVG -->
+          <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+            <polyline points="23 4 23 10 17 10" /><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10" />
+          </svg>
+          <span>重录</span>
+        </button>
+      </div>
+    </div>
+
+    <!-- 提示弹窗 -->
+    <div v-if="showSmartHint" class="modal-overlay" @click.self="showSmartHint = false">
+      <div class="hint-modal">
+        <div class="hint-header">
+          <h3 class="hint-title">
+            <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#f97316" 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" />
+            </svg>
+            提示
+          </h3>
+          <button class="close-btn" @click="showSmartHint = false">
+            <svg width="16" height="16" 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="hint-section">
+          <div class="hint-section-label">任务提示</div>
+          <div class="hint-task-box">
+            <p>{{ aiName }} 刚刚问你最喜欢的动物是什么,你可以告诉他你喜欢的动物以及原因。</p>
+          </div>
+        </div>
+
+        <!-- 句子提示 -->
+        <div class="hint-section">
+          <div class="hint-section-label">句子提示</div>
+          <div class="hint-sentences">
+            <div v-for="(hint, index) in sentenceHints" :key="index" class="hint-sentence-card">
+              <div class="hint-sentence-body">
+                <div class="hint-sentence-main">
+                  <p class="hint-en">{{ hint.en }}</p>
+                  <p class="hint-zh">{{ hint.zh }}</p>
+                  <div class="hint-keywords">
+                    <span v-for="(part, i) in hint.keyParts" :key="i" class="hint-keyword">{{ part }}</span>
+                  </div>
+                </div>
+                <button class="hint-play-btn" title="朗读句子">
+                  <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+                    <polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
+                    <path d="M15.54 8.46a5 5 0 0 1 0 7.07" />
+                  </svg>
+                </button>
+              </div>
+            </div>
+          </div>
+        </div>
+
+        <!-- 词汇提示 -->
+        <div class="hint-section">
+          <div class="hint-section-label">词汇提示</div>
+          <div class="hint-vocab-list">
+            <div v-for="(vocab, index) in vocabHints" :key="index" class="hint-vocab-item">
+              <div class="vocab-info">
+                <span class="vocab-word">{{ vocab.word }}</span>
+                <span class="vocab-phonetic">{{ vocab.phonetic }}</span>
+                <span class="vocab-meaning">{{ vocab.meaning }}</span>
+              </div>
+              <button class="vocab-play-btn" title="朗读单词">
+                <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+                  <polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
+                  <path d="M15.54 8.46a5 5 0 0 1 0 7.07" />
+                </svg>
+              </button>
+            </div>
+          </div>
+        </div>
+
+        <p class="hint-footer">试着用自己的话说更好哦!</p>
+      </div>
+    </div>
+
+    <!-- L3 音素详情弹窗 -->
+    <div v-if="phonemeDetailData" class="modal-overlay" @click.self="phonemeDetailData = null">
+      <div class="phoneme-modal">
+        <div class="hint-header">
+          <h3 class="hint-title">发音详情</h3>
+          <button class="close-btn" @click="phonemeDetailData = null">
+            <svg width="16" height="16" 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="phoneme-word">{{ phonemeDetailData.word }}</div>
+
+        <div class="phoneme-cards">
+          <div class="phoneme-card phoneme-user">
+            <div class="phoneme-card-inner">
+              <div>
+                <p class="phoneme-card-label">🎧 你的发音</p>
+                <p class="phoneme-card-value">{{ phonemeDetailData.userPronunciation }}</p>
+              </div>
+              <button class="phoneme-play-btn phoneme-play-user">
+                <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3" /></svg>
+              </button>
+            </div>
+          </div>
+          <div class="phoneme-card phoneme-standard">
+            <div class="phoneme-card-inner">
+              <div>
+                <p class="phoneme-card-label">🎯 标准发音</p>
+                <p class="phoneme-card-value phoneme-value-green">{{ phonemeDetailData.standardPronunciation }}</p>
+              </div>
+              <button class="phoneme-play-btn phoneme-play-standard">
+                <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3" /></svg>
+              </button>
+            </div>
+          </div>
+          <div v-if="phonemeDetailData.tip" class="phoneme-card phoneme-tip">
+            <p class="phoneme-card-label">💡 小提示</p>
+            <p class="phoneme-tip-text">{{ phonemeDetailData.tip }}</p>
+          </div>
+        </div>
+      </div>
+    </div>
+
+    <!-- 徽章动画 -->
+    <div v-if="showBadge" class="badge-popup">
+      <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>
+
+<script lang="ts" setup>
+import { ref, watch, onMounted, onUnmounted, nextTick } from 'vue'
+import type { PreviewChatMessage, BadgeAchievement } from '@/types/englishSpeaking'
+
+interface Props {
+  topic?: string
+  keywords?: string[]
+  aiName?: string
+  aiAvatar?: string
+  totalRounds?: number
+}
+
+const props = withDefaults(defineProps<Props>(), {
+  topic: '我最喜欢的动物',
+  keywords: () => ['animal', 'zoo', 'cute', 'favorite'],
+  aiName: 'Tom',
+  aiAvatar: '😊',
+  totalRounds: 3,
+})
+
+const emit = defineEmits<{
+  complete: []
+}>()
+
+const messages = ref<PreviewChatMessage[]>([])
+const currentRound = ref(1)
+const isRecording = ref(false)
+const isWaiting = ref(false)
+const showSmartHint = ref(false)
+const chatContainerRef = ref<HTMLDivElement>()
+
+const silenceHint = ref<string | null>(null)
+const recordingDuration = ref(0)
+let silenceTimer: ReturnType<typeof setTimeout> | null = null
+let recordingTimer: ReturnType<typeof setInterval> | null = null
+
+const expandedMessageId = ref<string | null>(null)
+
+const phonemeDetailData = ref<{
+  word: string
+  userPronunciation: string
+  standardPronunciation: string
+  tip: string
+} | null>(null)
+
+const showBadge = ref<BadgeAchievement | null>(null)
+const consecutiveFluent = ref(0)
+const consecutiveAccurate = ref(0)
+
+// 徽章配置
+const BADGE_CONFIG: Record<string, BadgeAchievement> = {
+  smooth_talker: { id: 'smooth_talker', name: '流畅达人', nameEn: 'Smooth Talker', icon: '💬', description: '连续3句流畅度优秀' },
+  pronunciation_pro: { id: 'pronunciation_pro', name: '发音专家', nameEn: 'Pronunciation Pro', icon: '🎯', description: '连续5句发音准确' },
+  perfect_round: { id: 'perfect_round', name: '完美一轮', nameEn: 'Perfect Round', icon: '⭐', description: '单轮四维度全优' },
+}
+
+// 提示数据
+const TRAVEL_HINTS = [
+  "You could say: I like pandas because they are cute!",
+  "You could say: My favorite animal is the elephant.",
+  "You could say: I went to the zoo last weekend.",
+]
+
+const sentenceHints = [
+  { en: 'I like pandas best because they are very cute.', zh: '我最喜欢大熊猫,因为它们非常可爱。', keyParts: ['I like', 'best', 'because'] },
+  { en: "My favorite animal is the elephant. It's really smart!", zh: '我最喜欢的动物是大象。它真的很聪明!', keyParts: ['My favorite', 'is', 'really'] },
+  { en: 'I enjoy watching animals at the zoo with my family.', zh: '我喜欢和家人一起在动物园看动物。', keyParts: ['I enjoy', 'watching', 'with'] },
+]
+
+const vocabHints = [
+  { word: 'favorite', phonetic: '/ˈfeɪvərɪt/', meaning: '最喜欢的' },
+  { word: 'adorable', phonetic: '/əˈdɔːrəbl/', meaning: '可爱的' },
+  { word: 'bamboo', phonetic: '/bæmˈbuː/', meaning: '竹子' },
+  { word: 'habitat', phonetic: '/ˈhæbɪtæt/', meaning: '栖息地' },
+  { word: 'wildlife', phonetic: '/ˈwaɪldlaɪf/', meaning: '野生动物' },
+  { word: 'endangered', phonetic: '/ɪnˈdeɪndʒərd/', meaning: '濒危的' },
+]
+
+// 对话脚本
+const dialogueScript = [
+  { ai: "Hi! What's your favorite animal?", student: 'I like pandas. They are very cute!' },
+  { ai: 'Pandas are adorable! Have you seen them at the zoo?', student: 'Yes, I went to the zoo last month.' },
+  { ai: "That's great! What do pandas like to eat?", student: 'They like to eat bamboo.' },
+]
+
+// 生成模拟评估数据
+function generateMockEvaluation(content: string): PreviewChatMessage['evaluation'] {
+  const dimensions = ['excellent', 'good', 'improve'] as const
+  const randomDim = () => dimensions[Math.floor(Math.random() * 2)]
+
+  const words = content.split(' ').filter(w => w.length > 3)
+  const wordAnalysis = words.slice(0, 3).map((word, i) => ({
+    word: word.replace(/[.,!?]/g, ''),
+    status: (i === 1 ? 'improvable' : 'correct') as 'correct' | 'improvable',
+    userPronunciation: i === 1 ? '/traˈvel/' : undefined,
+    standardPronunciation: i === 1 ? '/ˈtrævəl/' : undefined,
+    tip: i === 1 ? '重音在第一音节' : undefined,
+  }))
+
+  return {
+    dimensions: { accuracy: randomDim(), fluency: randomDim(), completeness: randomDim(), rhythm: randomDim() },
+    suggestion: 'Try: "I\'d like to..." instead of "I want to..."',
+    betterExpression: "I'd like to visit the zoo. It's one of the most famous places!",
+    suggestedWords: ['adorable', 'bamboo', 'habitat'],
+    wordAnalysis,
+  }
+}
+
+// 检查并触发徽章
+function checkAndTriggerBadge(evaluation: PreviewChatMessage['evaluation']) {
+  if (!evaluation) return
+
+  if (evaluation.dimensions.fluency === 'excellent') {
+    consecutiveFluent.value++
+    if (consecutiveFluent.value === 3) {
+      showBadge.value = BADGE_CONFIG.smooth_talker
+      setTimeout(() => { showBadge.value = null }, 2500)
+    }
+  } else {
+    consecutiveFluent.value = 0
+  }
+
+  if (evaluation.dimensions.accuracy === 'excellent') {
+    consecutiveAccurate.value++
+    if (consecutiveAccurate.value === 5) {
+      showBadge.value = BADGE_CONFIG.pronunciation_pro
+      setTimeout(() => { showBadge.value = null }, 2500)
+    }
+  } else {
+    consecutiveAccurate.value = 0
+  }
+
+  const dims = evaluation.dimensions
+  if (dims.accuracy === 'excellent' && dims.fluency === 'excellent' && dims.completeness === 'excellent' && dims.rhythm === 'excellent') {
+    setTimeout(() => {
+      showBadge.value = BADGE_CONFIG.perfect_round
+      setTimeout(() => { showBadge.value = null }, 2500)
+    }, 500)
+  }
+}
+
+// 录音计时与沉默检测
+watch(isRecording, (recording) => {
+  if (recording) {
+    recordingTimer = setInterval(() => { recordingDuration.value++ }, 1000)
+    silenceTimer = setTimeout(() => {
+      silenceHint.value = TRAVEL_HINTS[Math.floor(Math.random() * TRAVEL_HINTS.length)]
+      setTimeout(() => { silenceHint.value = null }, 3000)
+    }, 5000)
+  } else {
+    recordingDuration.value = 0
+    silenceHint.value = null
+    if (silenceTimer) { clearTimeout(silenceTimer); silenceTimer = null }
+    if (recordingTimer) { clearInterval(recordingTimer); recordingTimer = null }
+  }
+})
+
+// 初始化 AI 第一条消息
+onMounted(() => {
+  if (messages.value.length === 0) {
+    messages.value.push({
+      id: crypto.randomUUID(),
+      role: 'ai',
+      content: dialogueScript[0].ai,
+      timestamp: new Date(),
+    })
+  }
+})
+
+onUnmounted(() => {
+  if (silenceTimer) clearTimeout(silenceTimer)
+  if (recordingTimer) clearInterval(recordingTimer)
+})
+
+// 滚动到底部
+watch(() => messages.value.length, () => {
+  nextTick(() => {
+    if (chatContainerRef.value) {
+      chatContainerRef.value.scrollTop = chatContainerRef.value.scrollHeight
+    }
+  })
+})
+
+// 录音切换
+function handleToggleRecording() {
+  if (isWaiting.value) return
+
+  if (isRecording.value) {
+    isRecording.value = false
+    isWaiting.value = true
+
+    const scriptIndex = Math.min(currentRound.value - 1, dialogueScript.length - 1)
+    const studentResponse = dialogueScript[scriptIndex].student
+    const evaluation = generateMockEvaluation(studentResponse)
+
+    messages.value.push({
+      id: crypto.randomUUID(),
+      role: 'student',
+      content: studentResponse,
+      timestamp: new Date(),
+      evaluation,
+    })
+    checkAndTriggerBadge(evaluation)
+
+    setTimeout(() => {
+      isWaiting.value = false
+      if (currentRound.value < props.totalRounds && scriptIndex + 1 < dialogueScript.length) {
+        messages.value.push({
+          id: crypto.randomUUID(),
+          role: 'ai',
+          content: dialogueScript[scriptIndex + 1].ai,
+          timestamp: new Date(),
+        })
+        currentRound.value++
+      } else {
+        emit('complete')
+      }
+    }, 1200)
+  } else {
+    isRecording.value = true
+  }
+}
+
+// 工具函数
+function toggleExpand(id: string) {
+  expandedMessageId.value = expandedMessageId.value === id ? null : id
+}
+
+function getDimIcon(level: string) {
+  if (level === 'excellent') return '✓✓'
+  if (level === 'good') return '✓'
+  return '△'
+}
+
+function getDimClass(level: string) {
+  if (level === 'excellent') return 'dim-excellent'
+  if (level === 'good') return 'dim-good'
+  return 'dim-improve'
+}
+
+function getWordAnalysis(message: PreviewChatMessage, word: string) {
+  const cleanWord = word.replace(/[.,!?]/g, '')
+  return message.evaluation?.wordAnalysis?.find(w => w.word === cleanWord)
+}
+
+function showPhonemeDetail(analysis: NonNullable<NonNullable<PreviewChatMessage['evaluation']>['wordAnalysis']>[0]) {
+  phonemeDetailData.value = {
+    word: analysis.word,
+    userPronunciation: analysis.userPronunciation || '',
+    standardPronunciation: analysis.standardPronunciation || '',
+    tip: analysis.tip || '',
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.dialogue-chat-view {
+  width: 100%;
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+  position: relative;
+}
+
+// 顶部状态栏
+.status-bar {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 4px 12px;
+  border-bottom: 1px solid #f3f4f6;
+}
+.status-left {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+}
+.ai-avatar-small {
+  width: 20px; height: 20px;
+  border-radius: 50%;
+  background: #fff7ed;
+  border: 1px solid rgba(249,115,22,0.2);
+  display: flex; align-items: center; justify-content: center;
+  font-size: 10px;
+}
+.ai-name { font-size: 10px; font-weight: 500; color: #111827; }
+.online-dot { width: 4px; height: 4px; background: #22c55e; border-radius: 50%; }
+.status-right { display: flex; align-items: center; gap: 8px; font-size: 10px; color: #9ca3af; }
+.recording-duration { color: #ef4444; font-weight: 500; }
+.round-indicator { color: #f97316; font-weight: 500; }
+
+// 消息区域
+.chat-messages {
+  flex: 1;
+  overflow-y: auto;
+  padding: 12px 16px;
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+  background: #fafbfc;
+}
+.message-row { display: flex; justify-content: flex-start; }
+.message-student { justify-content: flex-end; }
+.message-content { max-width: 85%; }
+.student-content { text-align: right; }
+
+// 语音条
+.voice-bar {
+  display: inline-flex;
+  align-items: center;
+  gap: 8px;
+  padding: 6px 10px;
+  border-radius: 12px;
+  box-shadow: 0 1px 3px rgba(0,0,0,0.08);
+}
+.voice-ai { background: #fff; border: 1px solid #f3f4f6; border-top-left-radius: 4px; }
+.voice-student { background: #f97316; border-top-right-radius: 4px; }
+
+.play-btn {
+  padding: 4px; border-radius: 50%; border: none; cursor: pointer; transition: background 0.2s;
+}
+.play-ai { background: rgba(249,115,22,0.1); color: #f97316; }
+.play-ai:hover { background: rgba(249,115,22,0.2); }
+.play-student { background: rgba(255,255,255,0.2); color: #fff; }
+.play-student:hover { background: rgba(255,255,255,0.3); }
+
+.waveform { display: flex; align-items: center; gap: 1px; }
+.wave-bar { width: 2px; border-radius: 999px; }
+.wave-ai { background: rgba(249,115,22,0.4); }
+.wave-student { background: rgba(255,255,255,0.5); }
+
+.duration { font-size: 10px; }
+.duration-ai { color: #9ca3af; }
+.duration-student { color: rgba(255,255,255,0.8); }
+
+// 文本气泡
+.text-bubble {
+  margin-top: 6px;
+  padding: 6px 12px;
+  border-radius: 8px;
+  font-size: 12px;
+  line-height: 1.6;
+}
+.text-ai { background: #fff; color: #374151; border: 1px solid #f3f4f6; }
+.text-student { background: #fff8f0; color: #1f2937; border: 1px solid #fed7aa; }
+
+.improvable-word {
+  color: #d97706;
+  text-decoration: underline wavy #fbbf24;
+  cursor: pointer;
+  padding: 0 2px;
+  border-radius: 2px;
+  &:hover { background: #fef3c7; }
+}
+
+// 即时反馈
+.feedback-card {
+  margin-top: 8px;
+  background: #fff;
+  border-radius: 8px;
+  border: 1px solid #f3f4f6;
+  box-shadow: 0 1px 3px rgba(0,0,0,0.05);
+  overflow: hidden;
+  text-align: left;
+}
+.feedback-l1 { padding: 8px 12px; }
+.dimensions-row {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  font-size: 10px;
+}
+.dim-label { color: #6b7280; }
+.dim-sep { color: #d1d5db; }
+.dim-excellent { color: #16a34a; }
+.dim-good { color: #22c55e; }
+.dim-improve { color: #f59e0b; }
+
+.suggestion-row {
+  margin-top: 6px;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+}
+.suggestion-text {
+  font-size: 10px;
+  color: #4b5563;
+  display: flex;
+  align-items: center;
+  gap: 4px;
+  margin: 0;
+}
+.suggestion-icon { color: #f59e0b; }
+.detail-toggle {
+  font-size: 10px;
+  color: #f97316;
+  background: transparent;
+  border: none;
+  cursor: pointer;
+  display: flex;
+  align-items: center;
+  gap: 2px;
+  &:hover { color: #ea580c; }
+  svg { transition: transform 0.2s; }
+  .chevron-up { transform: rotate(180deg); }
+}
+
+// L2 展开
+.feedback-l2 {
+  padding: 8px 12px;
+  border-top: 1px solid #f9fafb;
+  background: #fafbfc;
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+}
+.detail-label {
+  font-size: 10px;
+  color: #9ca3af;
+  margin: 0 0 4px;
+  display: flex;
+  align-items: center;
+  gap: 4px;
+}
+.detail-content {
+  font-size: 12px;
+  color: #374151;
+  background: #fff;
+  padding: 6px 8px;
+  border-radius: 4px;
+  border: 1px solid #f3f4f6;
+  margin: 0;
+}
+.word-tags { display: flex; flex-wrap: wrap; gap: 4px; }
+.word-tag {
+  padding: 2px 8px;
+  background: #fff7ed;
+  color: #ea580c;
+  font-size: 10px;
+  border-radius: 999px;
+  border: 1px solid #fed7aa;
+}
+
+// 等待指示器
+.typing-indicator {
+  padding: 8px 12px;
+  background: #fff;
+  border: 1px solid #f3f4f6;
+  border-radius: 12px;
+  border-top-left-radius: 4px;
+  box-shadow: 0 1px 3px rgba(0,0,0,0.05);
+  display: flex;
+  align-items: center;
+  gap: 4px;
+}
+.typing-dot {
+  width: 6px; height: 6px;
+  background: rgba(249,115,22,0.6);
+  border-radius: 50%;
+  animation: bounce 1s ease-in-out infinite;
+}
+@keyframes bounce {
+  0%, 100% { transform: translateY(0); }
+  50% { transform: translateY(-4px); }
+}
+
+// 沉默提示
+.silence-hint {
+  position: absolute;
+  bottom: 112px;
+  left: 16px; right: 16px;
+}
+.silence-hint-card {
+  background: rgba(255,255,255,0.95);
+  backdrop-filter: blur(8px);
+  border-radius: 8px;
+  border: 1px solid #fed7aa;
+  box-shadow: 0 4px 12px rgba(0,0,0,0.1);
+  padding: 8px 12px;
+}
+.silence-hint-text {
+  font-size: 12px;
+  color: #4b5563;
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  margin: 0;
+}
+.hint-icon { color: #f97316; }
+
+// 底部录音控制
+.recording-controls {
+  padding: 10px 16px;
+  background: #fff;
+  border-top: 1px solid #f3f4f6;
+}
+.controls-inner {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  gap: 12px;
+}
+.side-btn {
+  display: flex;
+  align-items: center;
+  gap: 4px;
+  padding: 6px 10px;
+  border-radius: 999px;
+  background: #f9fafb;
+  border: 1px solid #e5e7eb;
+  color: #4b5563;
+  font-size: 11px;
+  font-weight: 500;
+  cursor: pointer;
+  transition: all 0.2s;
+  &:hover { background: #f3f4f6; border-color: #d1d5db; }
+  &:disabled { opacity: 0.4; cursor: not-allowed; }
+}
+
+.record-group { display: flex; align-items: center; gap: 10px; }
+.record-btn {
+  width: 40px; height: 40px;
+  border-radius: 50%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  border: none;
+  cursor: pointer;
+  transition: background 0.2s;
+  background: #f97316;
+  &:hover { background: #ea580c; }
+  &.recording { background: #ef4444; }
+  &.waiting { background: #d1d5db; cursor: not-allowed; }
+}
+
+.record-status { display: flex; align-items: center; gap: 6px; }
+.pulse-bars { display: flex; align-items: center; gap: 2px; }
+.pulse-bar {
+  width: 2px;
+  background: #ef4444;
+  border-radius: 999px;
+  animation: pulse 1s ease-in-out infinite;
+}
+@keyframes pulse {
+  0%, 100% { opacity: 0.6; }
+  50% { opacity: 1; }
+}
+.record-label { font-size: 11px; font-weight: 500; color: #4b5563; }
+.label-recording { color: #ef4444; }
+.label-waiting { color: #9ca3af; }
+
+// 弹窗通用
+.modal-overlay {
+  position: fixed;
+  inset: 0;
+  background: rgba(0,0,0,0.4);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  z-index: 50;
+}
+.hint-modal {
+  background: #fff;
+  border-radius: 16px;
+  width: 100%;
+  max-width: 448px;
+  margin: 0 16px;
+  padding: 20px;
+  max-height: 80vh;
+  overflow-y: auto;
+}
+.hint-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 16px;
+}
+.hint-title {
+  font-size: 14px;
+  font-weight: 600;
+  color: #111827;
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  margin: 0;
+}
+.close-btn {
+  width: 28px; height: 28px;
+  border-radius: 8px;
+  background: #f3f4f6;
+  border: none;
+  cursor: pointer;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: #6b7280;
+  &:hover { background: #e5e7eb; }
+}
+
+// 提示弹窗内容
+.hint-section { margin-bottom: 16px; }
+.hint-section-label { font-size: 12px; color: #9ca3af; margin-bottom: 8px; }
+.hint-task-box {
+  padding: 12px;
+  background: #fff7ed;
+  border-radius: 12px;
+  border: 1px solid rgba(249,115,22,0.2);
+  p { font-size: 14px; color: #374151; line-height: 1.6; margin: 0; }
+}
+
+.hint-sentences { display: flex; flex-direction: column; gap: 8px; }
+.hint-sentence-card {
+  padding: 12px;
+  background: #fafbfc;
+  border-radius: 12px;
+  border: 1px solid #f3f4f6;
+  transition: border-color 0.2s;
+  &:hover { border-color: rgba(249,115,22,0.3); }
+}
+.hint-sentence-body { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; }
+.hint-sentence-main { flex: 1; }
+.hint-en { font-size: 14px; color: #1f2937; margin: 0 0 4px; }
+.hint-zh { font-size: 12px; color: #6b7280; margin: 0; }
+.hint-keywords { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 8px; }
+.hint-keyword {
+  padding: 2px 6px;
+  background: rgba(249,115,22,0.1);
+  color: #f97316;
+  font-size: 10px;
+  border-radius: 4px;
+}
+.hint-play-btn {
+  width: 32px; height: 32px;
+  border-radius: 8px;
+  background: #fff7ed;
+  border: 1px solid rgba(249,115,22,0.2);
+  color: #f97316;
+  display: flex; align-items: center; justify-content: center;
+  flex-shrink: 0;
+  cursor: pointer;
+  &:hover { background: #fed7aa; }
+}
+
+.hint-vocab-list { display: flex; flex-direction: column; gap: 8px; }
+.hint-vocab-item {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 10px;
+  background: #f9fafb;
+  border: 1px solid #e5e7eb;
+  border-radius: 8px;
+}
+.vocab-info { display: flex; align-items: center; gap: 8px; }
+.vocab-word { font-weight: 500; font-size: 14px; color: #1f2937; }
+.vocab-phonetic { font-size: 12px; color: #9ca3af; }
+.vocab-meaning { font-size: 12px; color: #6b7280; }
+.vocab-play-btn {
+  width: 24px; height: 24px;
+  border-radius: 6px;
+  background: #fff;
+  border: 1px solid #e5e7eb;
+  color: #6b7280;
+  display: flex; align-items: center; justify-content: center;
+  cursor: pointer;
+  &:hover { color: #f97316; border-color: rgba(249,115,22,0.3); }
+}
+
+.hint-footer { text-align: center; font-size: 12px; color: #9ca3af; margin-top: 20px; }
+
+// 音素弹窗
+.phoneme-modal {
+  background: #fff;
+  border-radius: 16px;
+  width: 100%;
+  max-width: 384px;
+  margin: 0 16px;
+  padding: 16px;
+  box-shadow: 0 20px 60px rgba(0,0,0,0.15);
+}
+.phoneme-word { text-align: center; font-size: 24px; font-weight: 600; color: #111827; margin-bottom: 16px; }
+.phoneme-cards { display: flex; flex-direction: column; gap: 12px; }
+.phoneme-card { padding: 12px; border-radius: 12px; }
+.phoneme-card-inner { display: flex; align-items: center; justify-content: space-between; }
+.phoneme-user { background: #fffbeb; border: 1px solid #fde68a; }
+.phoneme-standard { background: #f0fdf4; border: 1px solid #bbf7d0; }
+.phoneme-tip { background: #eff6ff; border: 1px solid #bfdbfe; }
+.phoneme-card-label { font-size: 10px; margin: 0 0 4px; }
+.phoneme-user .phoneme-card-label { color: #d97706; }
+.phoneme-standard .phoneme-card-label { color: #16a34a; }
+.phoneme-tip .phoneme-card-label { color: #2563eb; }
+.phoneme-card-value { font-size: 14px; font-family: monospace; color: #92400e; margin: 0; }
+.phoneme-value-green { color: #166534; }
+.phoneme-tip-text { font-size: 12px; color: #1e40af; margin: 0; }
+
+.phoneme-play-btn {
+  width: 32px; height: 32px;
+  border-radius: 8px;
+  border: 1px solid;
+  display: flex; align-items: center; justify-content: center;
+  cursor: pointer;
+}
+.phoneme-play-user { background: #fef3c7; border-color: #fde68a; color: #d97706; &:hover { background: #fde68a; } }
+.phoneme-play-standard { background: #dcfce7; border-color: #bbf7d0; color: #16a34a; &:hover { background: #bbf7d0; } }
+
+// 徽章弹出
+.badge-popup {
+  position: fixed;
+  top: 16px;
+  right: 16px;
+  z-index: 50;
+}
+.badge-card {
+  background: linear-gradient(to right, #f97316, #f59e0b);
+  border-radius: 16px;
+  padding: 12px 16px;
+  box-shadow: 0 8px 24px rgba(249,115,22,0.3);
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  animation: bounce 1s ease-in-out infinite;
+}
+.badge-icon { font-size: 30px; }
+.badge-name { font-size: 14px; font-weight: 600; color: #fff; margin: 0; }
+.badge-desc { font-size: 10px; color: rgba(255,255,255,0.8); margin: 0; }
+</style>

+ 328 - 0
src/views/Editor/EnglishSpeaking/preview/OverallReport.vue

@@ -0,0 +1,328 @@
+<template>
+  <div class="overall-report">
+    <!-- 完成提示 -->
+    <div class="completion-header">
+      <div class="completion-emoji">🎉</div>
+      <h2 class="completion-title">对话完成!</h2>
+      <p class="completion-subtitle">
+        与 {{ role?.name || 'AI' }} 对话 {{ evaluation.statistics.totalRounds }} 轮 · {{ formatDuration(evaluation.statistics.totalDuration) }}
+      </p>
+    </div>
+
+    <!-- 综合评分区域 -->
+    <div v-if="scoreDisplayMode !== 'comment_only'" class="score-section">
+      <div class="score-header">
+        <div>
+          <div class="score-label">综合评分</div>
+          <!-- 数字模式 -->
+          <div v-if="scoreDisplayMode === 'numeric'" class="score-display">
+            <span class="score-number">{{ evaluation.overallScore }}</span>
+            <span class="score-unit">分</span>
+          </div>
+          <!-- 字母模式 -->
+          <div v-else class="score-display">
+            <span class="score-letter" :style="{ color: letterColorMap[scoreToLetter(evaluation.overallScore)] }">
+              {{ scoreToLetter(evaluation.overallScore) }}
+            </span>
+            <span class="score-unit">级</span>
+          </div>
+        </div>
+        <div class="score-meta">
+          <div class="level-badge" :style="{ backgroundColor: levelConfig.bgColor, color: levelConfig.color }">
+            {{ levelConfig.label }}
+          </div>
+          <div class="percentile-text">
+            超过 <span class="percentile-value">{{ evaluation.percentile }}%</span> 同学
+          </div>
+        </div>
+      </div>
+
+      <!-- 四维能力分析 -->
+      <div class="ability-section">
+        <div class="ability-label">能力分析</div>
+        <div class="ability-bars">
+          <div v-for="dim in visibleDimensions" :key="dim.key" class="ability-row">
+            <span class="ability-name">{{ dim.label }}</span>
+            <div class="ability-track">
+              <div class="ability-fill" :style="{ width: `${getDimValue(dim.key)}%` }" />
+            </div>
+            <span class="ability-value">
+              {{ scoreDisplayMode === 'letter' ? scoreToLetter(getDimValue(dim.key)) : getDimValue(dim.key) }}
+            </span>
+          </div>
+        </div>
+      </div>
+    </div>
+
+    <!-- AI 点评 -->
+    <div class="ai-comment-section">
+      <div class="ai-comment-inner">
+        <div class="ai-avatar-circle">{{ role?.avatar || '👦' }}</div>
+        <div class="ai-comment-body">
+          <div class="ai-comment-name">{{ role?.name || 'AI' }} 说</div>
+          <p class="ai-comment-text">{{ evaluation.aiComment }}</p>
+        </div>
+      </div>
+    </div>
+
+    <!-- 亮点与建议 -->
+    <div class="highlights-grid">
+      <div class="highlight-card">
+        <div class="highlight-header"><span class="highlight-icon-good">✓</span> 亮点</div>
+        <div class="highlight-list">
+          <div v-for="(h, i) in evaluation.highlights.slice(0, 3)" :key="i" class="highlight-item">• {{ h }}</div>
+        </div>
+      </div>
+      <div class="highlight-card">
+        <div class="highlight-header"><span class="highlight-icon-improve">→</span> 建议</div>
+        <div class="highlight-list">
+          <div v-for="(h, i) in evaluation.improvements.slice(0, 3)" :key="i" class="highlight-item">• {{ h }}</div>
+        </div>
+      </div>
+    </div>
+
+    <!-- 统计数据 -->
+    <div class="stats-section">
+      <div class="stats-grid" :class="{ 'stats-narrow': scoreDisplayMode === 'comment_only' }">
+        <template v-if="scoreDisplayMode !== 'comment_only'">
+          <div class="stat-cell">
+            <div class="stat-value">
+              {{ scoreDisplayMode === 'letter' ? scoreToLetter(evaluation.statistics.averageScore) : evaluation.statistics.averageScore }}
+            </div>
+            <div class="stat-label">{{ scoreDisplayMode === 'letter' ? '平均等级' : '平均分' }}</div>
+          </div>
+          <div class="stat-cell">
+            <div class="stat-value stat-green">
+              {{ scoreDisplayMode === 'letter' ? scoreToLetter(evaluation.statistics.highestScore) : evaluation.statistics.highestScore }}
+            </div>
+            <div class="stat-label">{{ scoreDisplayMode === 'letter' ? '最高等级' : '最高分' }}</div>
+          </div>
+        </template>
+        <div class="stat-cell">
+          <div class="stat-value stat-blue">{{ evaluation.statistics.excellentExpressions }}</div>
+          <div class="stat-label">优秀表达</div>
+        </div>
+        <div class="stat-cell">
+          <div class="stat-value stat-amber">{{ evaluation.statistics.grammarErrors }}</div>
+          <div class="stat-label">语法错误</div>
+        </div>
+      </div>
+    </div>
+
+    <!-- 操作按钮 -->
+    <div class="action-buttons">
+      <button class="btn-secondary" @click="$emit('restart')">
+        <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+          <polyline points="23 4 23 10 17 10" /><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10" />
+        </svg>
+        再来一次
+      </button>
+      <button class="btn-primary" @click="$emit('complete')">
+        <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+          <polyline points="20 6 9 17 4 12" />
+        </svg>
+        完成
+      </button>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { computed } from 'vue'
+import type { OverallEvaluation, ScoreLevel, ScoreDisplayMode, PreviewAIRole } from '@/types/englishSpeaking'
+
+interface Props {
+  evaluation: OverallEvaluation
+  role: PreviewAIRole | null
+  scoreDisplayMode?: ScoreDisplayMode
+}
+
+const props = withDefaults(defineProps<Props>(), {
+  scoreDisplayMode: 'numeric',
+})
+
+defineEmits<{
+  restart: []
+  complete: []
+}>()
+
+const scoreLevelConfig: Record<ScoreLevel, { label: string; color: string; bgColor: string }> = {
+  excellent: { label: '优秀', color: '#22c55e', bgColor: '#dcfce7' },
+  good: { label: '良好', color: '#3b82f6', bgColor: '#dbeafe' },
+  fair: { label: '不错', color: '#f59e0b', bgColor: '#fef3c7' },
+  needsWork: { label: '继续加油', color: '#ef4444', bgColor: '#fee2e2' },
+}
+
+const letterColorMap: Record<string, string> = {
+  A: '#22c55e', B: '#3b82f6', C: '#f59e0b', D: '#f97316', E: '#ef4444',
+}
+
+const dimensionConfig = [
+  { label: '准确度', key: 'grammar' },
+  { label: '语调', key: 'interaction' },
+  { label: '重音', key: 'vocabulary' },
+  { label: '流畅度', key: 'fluency' },
+]
+
+const levelConfig = computed(() => scoreLevelConfig[props.evaluation.scoreLevel])
+const visibleDimensions = computed(() => dimensionConfig)
+
+function scoreToLetter(score: number): string {
+  if (score >= 90) return 'A'
+  if (score >= 80) return 'B'
+  if (score >= 70) return 'C'
+  if (score >= 60) return 'D'
+  return 'E'
+}
+
+function formatDuration(seconds: number) {
+  const mins = Math.floor(seconds / 60)
+  const secs = seconds % 60
+  return `${mins}分${secs}秒`
+}
+
+function getDimValue(key: string): number {
+  return (props.evaluation.dimensions as Record<string, number>)[key] || 0
+}
+</script>
+
+<style lang="scss" scoped>
+.overall-report {
+  max-width: 448px;
+  margin: 0 auto;
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+}
+
+.completion-header { text-align: center; }
+.completion-emoji { font-size: 30px; margin-bottom: 4px; }
+.completion-title { font-size: 16px; font-weight: 600; color: #111827; margin: 0; }
+.completion-subtitle { font-size: 12px; color: #6b7280; margin: 2px 0 0; }
+
+// 评分区域
+.score-section {
+  background: #fff;
+  border-radius: 12px;
+  border: 1px solid #f3f4f6;
+  overflow: hidden;
+}
+.score-header {
+  padding: 12px 16px;
+  background: linear-gradient(to right, #fff7ed, #f6f8ff);
+  border-bottom: 1px solid #f3f4f6;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+}
+.score-label { font-size: 12px; color: #6b7280; margin-bottom: 4px; }
+.score-display { display: flex; align-items: baseline; gap: 4px; }
+.score-number { font-size: 32px; font-weight: 700; color: #f97316; }
+.score-letter { font-size: 32px; font-weight: 700; }
+.score-unit { font-size: 14px; color: #9ca3af; }
+.score-meta { text-align: right; }
+.level-badge {
+  display: inline-block;
+  padding: 4px 10px;
+  border-radius: 999px;
+  font-size: 12px;
+  font-weight: 500;
+}
+.percentile-text { font-size: 10px; color: #9ca3af; margin-top: 4px; }
+.percentile-value { color: #f97316; font-weight: 500; }
+
+.ability-section { padding: 12px 16px; }
+.ability-label { font-size: 12px; color: #6b7280; margin-bottom: 8px; }
+.ability-bars { display: flex; flex-direction: column; gap: 10px; }
+.ability-row { display: flex; align-items: center; gap: 12px; }
+.ability-name { font-size: 12px; color: #4b5563; width: 40px; flex-shrink: 0; }
+.ability-track { flex: 1; height: 8px; background: #f3f4f6; border-radius: 999px; overflow: hidden; }
+.ability-fill { height: 100%; background: #f97316; border-radius: 999px; transition: width 0.5s; }
+.ability-value { font-size: 12px; font-weight: 500; color: #f97316; width: 32px; text-align: right; }
+
+// AI 点评
+.ai-comment-section {
+  background: #fff;
+  border-radius: 12px;
+  border: 1px solid #f3f4f6;
+  padding: 12px;
+}
+.ai-comment-inner { display: flex; gap: 10px; }
+.ai-avatar-circle {
+  width: 32px; height: 32px;
+  border-radius: 50%;
+  background: #fff7ed;
+  display: flex; align-items: center; justify-content: center;
+  font-size: 16px;
+  flex-shrink: 0;
+}
+.ai-comment-body { flex: 1; min-width: 0; }
+.ai-comment-name { font-size: 12px; color: #9ca3af; margin-bottom: 4px; }
+.ai-comment-text { font-size: 12px; color: #374151; line-height: 1.6; margin: 0; }
+
+// 亮点与建议
+.highlights-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
+.highlight-card {
+  background: #fff;
+  border-radius: 12px;
+  border: 1px solid #f3f4f6;
+  padding: 10px;
+}
+.highlight-header { font-size: 12px; color: #6b7280; margin-bottom: 6px; display: flex; align-items: center; gap: 4px; }
+.highlight-icon-good { color: #22c55e; }
+.highlight-icon-improve { color: #f59e0b; }
+.highlight-list { display: flex; flex-direction: column; gap: 4px; }
+.highlight-item { font-size: 10px; color: #4b5563; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+
+// 统计数据
+.stats-section {
+  background: #fff;
+  border-radius: 12px;
+  border: 1px solid #f3f4f6;
+  padding: 10px;
+}
+.stats-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; text-align: center; }
+.stats-narrow { grid-template-columns: repeat(2, 1fr); }
+.stat-value { font-size: 16px; font-weight: 600; color: #1f2937; }
+.stat-green { color: #22c55e; }
+.stat-blue { color: #3b82f6; }
+.stat-amber { color: #f59e0b; }
+.stat-label { font-size: 9px; color: #9ca3af; }
+
+// 按钮
+.action-buttons {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  gap: 12px;
+  padding-top: 4px;
+}
+.btn-secondary {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  padding: 8px 16px;
+  border-radius: 8px;
+  font-size: 12px;
+  font-weight: 500;
+  color: #4b5563;
+  background: #f3f4f6;
+  border: none;
+  cursor: pointer;
+  &:hover { background: #e5e7eb; }
+}
+.btn-primary {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  padding: 8px 16px;
+  border-radius: 8px;
+  font-size: 12px;
+  font-weight: 500;
+  color: #fff;
+  background: #f97316;
+  border: none;
+  cursor: pointer;
+  &:hover { background: #ea580c; }
+}
+</style>

+ 155 - 0
src/views/Editor/EnglishSpeaking/preview/StudentPreview.vue

@@ -0,0 +1,155 @@
+<template>
+  <div class="student-preview" :class="{ 'fullscreen': fullscreen }">
+    <!-- PPT 预览模式:16:9 比例容器 -->
+    <div v-if="!fullscreen" class="preview-wrapper">
+      <div class="preview-card">
+        <!-- 标题区 -->
+        <div v-if="title || subtitle || progress" class="title-area">
+          <h1 v-if="title" class="preview-title">
+            <span v-if="titleIcon" class="title-icon">{{ titleIcon }}</span>
+            {{ title }}
+          </h1>
+          <p v-if="subtitle" class="preview-subtitle">{{ subtitle }}</p>
+          <p v-if="progress" class="preview-progress">{{ progress }}</p>
+        </div>
+
+        <!-- 内容区 -->
+        <div class="content-area">
+          <slot />
+        </div>
+
+        <!-- 操作区 -->
+        <div v-if="$slots.action" class="action-area">
+          <slot name="action" />
+        </div>
+      </div>
+    </div>
+
+    <!-- 全屏模式 -->
+    <div v-else class="fullscreen-wrapper">
+      <div v-if="title || subtitle || progress" class="title-area">
+        <h1 v-if="title" class="preview-title">
+          <span v-if="titleIcon" class="title-icon">{{ titleIcon }}</span>
+          {{ title }}
+        </h1>
+        <p v-if="subtitle" class="preview-subtitle">{{ subtitle }}</p>
+        <p v-if="progress" class="preview-progress">{{ progress }}</p>
+      </div>
+
+      <div class="content-area fullscreen-content">
+        <slot />
+      </div>
+
+      <div v-if="$slots.action" class="action-area">
+        <slot name="action" />
+      </div>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+interface Props {
+  title?: string
+  subtitle?: string
+  titleIcon?: string
+  progress?: string
+  fullscreen?: boolean
+}
+
+withDefaults(defineProps<Props>(), {
+  fullscreen: false,
+})
+</script>
+
+<style lang="scss" scoped>
+.student-preview {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  background: #fff;
+  overflow: hidden;
+  border-radius: 16px;
+}
+
+.preview-wrapper {
+  flex: 1;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 24px;
+  background: #f9fafb;
+}
+
+.preview-card {
+  width: 100%;
+  max-width: 900px;
+  aspect-ratio: 16 / 9;
+  background: #fff;
+  border-radius: 12px;
+  box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
+  overflow: hidden;
+  display: flex;
+  flex-direction: column;
+}
+
+.fullscreen-wrapper {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  background: #fff;
+  overflow: hidden;
+}
+
+.title-area {
+  padding: 24px 32px 16px;
+  text-align: center;
+  border-bottom: 1px solid #f9fafb;
+}
+
+.preview-title {
+  font-size: 20px;
+  font-weight: 600;
+  color: #111827;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  gap: 8px;
+  margin: 0;
+}
+
+.title-icon {
+  font-size: 24px;
+}
+
+.preview-subtitle {
+  font-size: 14px;
+  color: #9ca3af;
+  margin: 4px 0 0;
+}
+
+.preview-progress {
+  font-size: 12px;
+  color: #9ca3af;
+  margin: 8px 0 0;
+}
+
+.content-area {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  padding: 24px 32px;
+  overflow: auto;
+}
+
+.fullscreen-content {
+  overflow: auto;
+}
+
+.action-area {
+  padding: 16px 32px;
+  border-top: 1px solid #f9fafb;
+  background: #fafbfc;
+}
+</style>

+ 310 - 0
src/views/Editor/EnglishSpeaking/preview/TopicDiscussionPreview.vue

@@ -0,0 +1,310 @@
+<template>
+  <div class="topic-discussion-preview">
+    <!-- Ready 阶段 -->
+    <StudentPreview
+      v-if="dialogueState === 'ready'"
+      title="Talk About Animals"
+      subtitle="Topic Discussion"
+      titleIcon="🗣️"
+    >
+      <div class="ready-content">
+        <div class="topic-icon-large">🐼</div>
+        <h2 class="topic-name">我最喜欢的动物</h2>
+        <p class="topic-desc">和 AI 伙伴 {{ mockRole.name }} 一起练习关于动物的英语对话</p>
+        <div class="topic-meta">
+          <span class="meta-item">🔄 {{ totalRounds }} 轮对话</span>
+          <span class="meta-item">⏱️ 约 {{ totalRounds * 2 }} 分钟</span>
+        </div>
+        <div class="vocab-preview">
+          <span v-for="word in previewVocab" :key="word" class="vocab-chip">{{ word }}</span>
+        </div>
+      </div>
+      <template #action>
+        <div class="action-center">
+          <button class="start-btn" @click="startDialogue">
+            <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+              <polygon points="5 3 19 12 5 21 5 3" />
+            </svg>
+            开始对话
+          </button>
+        </div>
+      </template>
+    </StudentPreview>
+
+    <!-- Chatting 阶段 -->
+    <StudentPreview
+      v-else-if="dialogueState === 'chatting'"
+      :title="`与 ${mockRole.name} 对话中`"
+      titleIcon="💬"
+      :progress="`Round ${currentRound}/${totalRounds}`"
+      :fullscreen="true"
+    >
+      <DialogueChatView
+        :topic="'我最喜欢的动物'"
+        :ai-name="mockRole.name"
+        :ai-avatar="mockRole.avatar"
+        :total-rounds="totalRounds"
+        @complete="handleDialogueComplete"
+      />
+    </StudentPreview>
+
+    <!-- Completed 阶段 -->
+    <StudentPreview
+      v-else
+      title="学习报告"
+      titleIcon="📊"
+      :fullscreen="true"
+    >
+      <div class="report-container">
+        <OverallReport
+          :evaluation="mockEvaluation"
+          :role="mockRole"
+          :scoreDisplayMode="'numeric'"
+          @restart="resetPreview"
+          @complete="resetPreview"
+        />
+        <div class="report-divider" />
+        <DetailedReport
+          :sentenceEvaluations="mockEvaluation.sentenceEvaluations"
+        />
+      </div>
+    </StudentPreview>
+
+    <!-- 重置按钮 -->
+    <button v-if="dialogueState !== 'ready'" class="reset-btn" @click="resetPreview">
+      <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+        <polyline points="23 4 23 10 17 10" /><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10" />
+      </svg>
+      重置预览
+    </button>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { ref } from 'vue'
+import type { OverallEvaluation, PreviewAIRole, PreviewDialogueState } from '@/types/englishSpeaking'
+
+import StudentPreview from './StudentPreview.vue'
+import DialogueChatView from './DialogueChatView.vue'
+import OverallReport from './OverallReport.vue'
+import DetailedReport from './DetailedReport.vue'
+
+const dialogueState = ref<PreviewDialogueState>('ready')
+const currentRound = ref(1)
+const totalRounds = 3
+
+const previewVocab = ['favorite', 'adorable', 'bamboo', 'habitat', 'wildlife', 'endangered']
+
+const mockRole: PreviewAIRole = {
+  id: 'tom',
+  name: 'Tom',
+  avatar: '😊',
+  identity: 'Friendly Teacher',
+  personality: 'Patient and encouraging',
+  speakingStyle: 'casual',
+  speed: 'normal',
+  isCustom: false,
+  isRecommended: true,
+}
+
+const mockEvaluation: OverallEvaluation = {
+  overallScore: 85,
+  scoreLevel: 'good',
+  percentile: 78,
+  dimensions: {
+    fluency: 82,
+    interaction: 88,
+    vocabulary: 76,
+    grammar: 90,
+  },
+  aiComment: 'Great job! You showed good understanding of the topic. Your pronunciation was clear and your responses were relevant. Keep practicing to improve your fluency and try using more advanced vocabulary!',
+  highlights: [
+    'Clear pronunciation of key words',
+    'Good use of complete sentences',
+    'Natural conversation flow',
+  ],
+  improvements: [
+    'Try using more descriptive adjectives',
+    'Practice linking words for smoother speech',
+    'Expand vocabulary range for the topic',
+  ],
+  nextChallenge: {
+    difficulty: 'Medium',
+    unlockedTopic: 'My Dream Job',
+    suggestedMode: 'Free Talk',
+  },
+  statistics: {
+    totalRounds: 3,
+    averageScore: 83,
+    highestScore: 92,
+    highestRound: 2,
+    grammarErrors: 2,
+    excellentExpressions: 4,
+    totalDuration: 180,
+  },
+  sentenceEvaluations: [
+    {
+      id: 'se-1', round: 1, role: 'ai',
+      content: "Hi! What's your favorite animal?",
+      audioDuration: 3,
+    },
+    {
+      id: 'se-2', round: 1, role: 'student',
+      content: 'I like pandas. They are very cute!',
+      audioDuration: 4, score: 88,
+      pronunciation: { accuracy: 90, intonation: 85, stress: 82, fluency: 88 },
+      feedback: {
+        highlights: ['Good pronunciation of "pandas"', 'Natural intonation'],
+        corrections: [],
+        suggestions: ['Try: "I really love pandas because they\'re incredibly cute!"'],
+      },
+    },
+    {
+      id: 'se-3', round: 2, role: 'ai',
+      content: 'Pandas are adorable! Have you seen them at the zoo?',
+      audioDuration: 4,
+    },
+    {
+      id: 'se-4', round: 2, role: 'student',
+      content: 'Yes, I went to the zoo last month.',
+      audioDuration: 3, score: 92,
+      pronunciation: { accuracy: 95, intonation: 90, stress: 88, fluency: 92 },
+      feedback: {
+        highlights: ['Excellent fluency', 'Clear past tense usage'],
+        corrections: [],
+        suggestions: ['Add more details: "I visited the Beijing Zoo last month and saw giant pandas there."'],
+      },
+    },
+    {
+      id: 'se-5', round: 3, role: 'ai',
+      content: "That's great! What do pandas like to eat?",
+      audioDuration: 3,
+    },
+    {
+      id: 'se-6', round: 3, role: 'student',
+      content: 'They like to eat bamboo.',
+      audioDuration: 3, score: 78,
+      pronunciation: { accuracy: 80, intonation: 75, stress: 76, fluency: 82 },
+      feedback: {
+        highlights: ['Correct answer'],
+        corrections: [{ original: 'like to eat', corrected: 'mainly feed on', explanation: '"feed on" is more natural for describing animal diets' }],
+        suggestions: ['Expand: "Pandas mainly feed on bamboo, but they also eat fruits and vegetables."'],
+      },
+    },
+  ],
+}
+
+function startDialogue() {
+  dialogueState.value = 'chatting'
+}
+
+function handleDialogueComplete() {
+  dialogueState.value = 'completed'
+}
+
+function resetPreview() {
+  dialogueState.value = 'ready'
+  currentRound.value = 1
+}
+</script>
+
+<style lang="scss" scoped>
+.topic-discussion-preview {
+  width: 100%;
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+  background: #f9fafb;
+  position: relative;
+  overflow: hidden;
+}
+
+// Ready 阶段
+.ready-content {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  text-align: center;
+  gap: 8px;
+}
+.topic-icon-large { font-size: 48px; }
+.topic-name { font-size: 18px; font-weight: 600; color: #111827; margin: 0; }
+.topic-desc { font-size: 13px; color: #6b7280; margin: 0; }
+.topic-meta {
+  display: flex;
+  align-items: center;
+  gap: 16px;
+  margin-top: 4px;
+}
+.meta-item { font-size: 12px; color: #9ca3af; }
+
+.vocab-preview {
+  display: flex;
+  flex-wrap: wrap;
+  justify-content: center;
+  gap: 6px;
+  margin-top: 8px;
+}
+.vocab-chip {
+  padding: 4px 10px;
+  background: #fff7ed;
+  color: #ea580c;
+  font-size: 11px;
+  border-radius: 999px;
+  border: 1px solid #fed7aa;
+}
+
+// 开始按钮
+.action-center { display: flex; justify-content: center; }
+.start-btn {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  padding: 10px 28px;
+  background: #f97316;
+  color: #fff;
+  border: none;
+  border-radius: 999px;
+  font-size: 14px;
+  font-weight: 500;
+  cursor: pointer;
+  transition: background 0.2s;
+  box-shadow: 0 4px 12px rgba(249, 115, 22, 0.3);
+  &:hover { background: #ea580c; }
+}
+
+// 报告容器
+.report-container {
+  width: 100%;
+  padding: 16px;
+  overflow-y: auto;
+  display: flex;
+  flex-direction: column;
+  gap: 24px;
+}
+.report-divider {
+  height: 1px;
+  background: #e5e7eb;
+  margin: 0 16px;
+}
+
+// 重置按钮
+.reset-btn {
+  position: absolute;
+  top: 8px;
+  right: 8px;
+  display: flex;
+  align-items: center;
+  gap: 4px;
+  padding: 4px 10px;
+  border-radius: 999px;
+  background: rgba(255,255,255,0.9);
+  backdrop-filter: blur(4px);
+  border: 1px solid #e5e7eb;
+  color: #6b7280;
+  font-size: 10px;
+  cursor: pointer;
+  z-index: 10;
+  &:hover { background: #f3f4f6; color: #374151; }
+}
+</style>

+ 11 - 5
src/views/components/element/FrameElement/BaseFrameElement.vue

@@ -37,6 +37,11 @@
           controls
           :style="{ width: '100%', height: '100%', objectFit: 'contain' }"
         ></video>
+        <!-- 英语口语预览(type 77):使用 Vue 组件直接渲染 -->
+        <TopicDiscussionPreview
+          v-else-if="Number(elementInfo.toolType) === 77 && !isThumbnail && isVisible"
+          :style="{ width: '100%', height: '100%' }"
+        />
         <!-- B站视频类型(type 75):使用 iframe -->
         <iframe
           v-else-if="elementInfo.toolType === 75 && !isThumbnail && isVisible"
@@ -96,11 +101,12 @@
   </div>
 </template>
   
-<script lang="ts" setup>
-import type { PropType } from 'vue'
-import type { PPTFrameElement } from '@/types/slides'
-import { lang } from '@/main'
-import { ref, watch, nextTick } from 'vue'
+  <script lang="ts" setup>
+import type { PropType } from "vue";
+import type { PPTFrameElement } from "@/types/slides";
+import { lang } from "@/main";
+import TopicDiscussionPreview from "@/views/Editor/EnglishSpeaking/preview/TopicDiscussionPreview.vue";
+import { ref, watch, nextTick } from "vue";
 import { computed } from 'vue'
 
 const props = defineProps({

+ 6 - 0
src/views/components/element/FrameElement/index.vue

@@ -27,6 +27,11 @@
           controls
           :style="{ width: '100%', height: '100%', objectFit: 'contain' }"
         ></video>
+        <!-- 英语口语预览(type 77):使用 Vue 组件直接渲染 -->
+        <TopicDiscussionPreview
+          v-else-if="Number(elementInfo.toolType) === 77"
+          :style="{ width: '100%', height: '100%' }"
+        />
         <!-- B站视频类型(type 75):使用 iframe -->
         <iframe 
           v-else-if="elementInfo.toolType === 75"
@@ -80,6 +85,7 @@ import type { PropType } from 'vue'
 import { storeToRefs } from 'pinia'
 import { useMainStore } from '@/store'
 import type { PPTFrameElement } from '@/types/slides'
+import TopicDiscussionPreview from '@/views/Editor/EnglishSpeaking/preview/TopicDiscussionPreview.vue'
 import type { ContextmenuItem } from '@/components/Contextmenu/types'
 import { ref, watch } from 'vue'