Преглед изворни кода

feat: 英语口语 streaming ASR + 配置后端持久化 + 交互重构

- 配置持久化: services/speaking.ts 封装 create/get/update SpeakingConfig;
  FrameElement.url 作为 configId 挂载到 slide 元素;TopicDiscussionPreview
  mount 时 getSpeakingConfig 回灌 speakingStore
- 交互重构: Layer2Speaking 点 RecommendCard 立即 createSpeakingConfig +
  createFrameElement(77)(不再跳配置页);点击画布上 77 型 frame 触发
  speakingStore.openConfigPanel 信号 → CollapsibleToolbar/SpeakingPanel
  联动切到 config 面板
- CanvasTool 新增"重置预览"按钮(previewState 非 ready 时显示)
- DialogueChatView 对齐 enspeak 参考(状态机 idle/recording/stt/ai_thinking/error/done
  + 徽章 + 音素弹窗 + 退出弹窗 + 沉默提示),去掉嵌套 StudentPreview 解决边距问题
- 跨平台录音: useAudioRecorder 去掉硬编码 16k 采样率,按硬件实际采样率
  写入 WAV header(iOS Safari 常见 48kHz)
- 流式 ASR: useAudioRecorder 暴露 onChunk + sampleRate;useDialogueEngine 新增
  beginStudentStream (WebSocket) 和 streamFallback (HTTP 降级);
  DialogueChatView 录音启动即打开 ws、结束 2s 内失败自动降级
- 重播按钮改为真实播放: 学生消息播 audioBlob,AI 消息用 speechSynthesis 复读
- index3.vue handleSave/changeCourse 加 console.trace 便于排查

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
jimmylee пре 2 месеци
родитељ
комит
9a809696e9

+ 14 - 1
src/components/CollapsibleToolbar/index2.vue

@@ -533,11 +533,12 @@
 </template>
 
 <script lang="ts" setup>
-import { ref } from 'vue'
+import { ref, watch } from 'vue'
 import { storeToRefs } from 'pinia'
 import useCreateElement from '@/hooks/useCreateElement'
 import useSlideHandler from '@/hooks/useSlideHandler'
 import { useSlidesStore } from '@/store'
+import { useSpeakingStore } from '@/store/speaking'
 import FileInput from '@/components/FileInput.vue'
 import AiChat from './componets/aiChat.vue'
 import SpeakingPanel from '@/views/Editor/EnglishSpeaking/SpeakingPanel.vue'
@@ -783,6 +784,18 @@ const toggleSubmenu = (menu: string) => {
   }
 }
 
+// 监听"打开口语配置面板"信号 → 自动展开 english 子菜单
+const speakingStore = useSpeakingStore()
+watch(() => speakingStore.openConfigSignal, (val, old) => {
+  if (val !== old) {
+    if (isCollapsed.value) {
+      isCollapsed.value = false
+      emit('toggle', false)
+    }
+    activeSubmenu.value = 'english'
+  }
+})
+
 import titlePage from './page/TitlePage.json'
 import ImagePage from './page/ImagePage.json'
 import ContentPage from './page/ContentPage.json'

+ 49 - 0
src/services/speaking.ts

@@ -0,0 +1,49 @@
+import type { TopicDiscussionConfig } from '@/types/englishSpeaking'
+
+const API_BASE = 'http://localhost:8000/api/speaking/config'
+
+export interface SpeakingConfigRecord {
+  id: string
+  config: TopicDiscussionConfig
+  ownerUid?: string | null
+}
+
+async function parse<T>(res: Response): Promise<T> {
+  if (!res.ok) {
+    const detail = await res.text().catch(() => '')
+    throw new Error(`[${res.status}] ${detail || res.statusText}`)
+  }
+  return res.json() as Promise<T>
+}
+
+/** 新建话题讨论配置 */
+export async function createSpeakingConfig(
+  config: TopicDiscussionConfig,
+  ownerUid?: string | null,
+): Promise<SpeakingConfigRecord> {
+  const res = await fetch(API_BASE, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify({ config, ownerUid: ownerUid ?? null }),
+  })
+  return parse<SpeakingConfigRecord>(res)
+}
+
+/** 读取话题讨论配置 */
+export async function getSpeakingConfig(id: string): Promise<SpeakingConfigRecord> {
+  const res = await fetch(`${API_BASE}/${encodeURIComponent(id)}`)
+  return parse<SpeakingConfigRecord>(res)
+}
+
+/** 更新话题讨论配置 */
+export async function updateSpeakingConfig(
+  id: string,
+  config: TopicDiscussionConfig,
+): Promise<SpeakingConfigRecord> {
+  const res = await fetch(`${API_BASE}/${encodeURIComponent(id)}`, {
+    method: 'PUT',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify({ config }),
+  })
+  return parse<SpeakingConfigRecord>(res)
+}

+ 5 - 0
src/store/speaking.ts

@@ -48,6 +48,8 @@ export const useSpeakingStore = defineStore('speaking', {
     previewState: 'ready' as PreviewDialogueState,
     // 请求重置预览的递增信号(由 CanvasTool 触发 → TopicDiscussionPreview 监听)
     resetSignal: 0,
+    // 请求打开配置面板的递增信号(由画布点击 77 型 frame 触发 → CollapsibleToolbar + SpeakingPanel 监听)
+    openConfigSignal: 0,
   }),
 
   actions: {
@@ -62,6 +64,9 @@ export const useSpeakingStore = defineStore('speaking', {
     requestResetPreview() {
       this.resetSignal += 1
     },
+    openConfigPanel() {
+      this.openConfigSignal += 1
+    },
 
     // 用推荐卡片数据预填充
     prefillFromTask(topic: string, vocabulary: string[], sentences: string[]) {

+ 8 - 1
src/views/Editor/EnglishSpeaking/SpeakingPanel.vue

@@ -65,9 +65,10 @@
 </template>
 
 <script lang="ts" setup>
-import { ref } from 'vue'
+import { ref, watch } from 'vue'
 import { lang } from '@/main'
 import type { PageMode, ExerciseType } from '@/types/englishSpeaking'
+import { useSpeakingStore } from '@/store/speaking'
 import Layer1Home from './layers/Layer1Home.vue'
 import Layer2Speaking from './layers/Layer2Speaking.vue'
 import TopicDiscussionConfig from './configs/TopicDiscussionConfig.vue'
@@ -100,6 +101,12 @@ const goHome = () => {
 const goLayer2 = () => {
   pageMode.value = 'layer2'
 }
+
+// 点击画布上的 77 型 frame 会递增 openConfigSignal → 强制切到配置页
+const speakingStore = useSpeakingStore()
+watch(() => speakingStore.openConfigSignal, (val, old) => {
+  if (val !== old) pageMode.value = 'config'
+})
 </script>
 
 <style lang="scss" scoped>

+ 30 - 12
src/views/Editor/EnglishSpeaking/composables/useAudioRecorder.ts

@@ -1,12 +1,14 @@
 import { ref, onUnmounted } from 'vue'
 
-const TARGET_SAMPLE_RATE = 16000
-
 export function useAudioRecorder() {
   const isRecording = ref(false)
   const permissionState = ref<PermissionState>('prompt')
   const recordingDuration = ref(0)
   const silenceDetected = ref(false)
+  /** 实际硬件采样率,startRecording 后可读;用于 WebSocket 上报给后端 */
+  const sampleRate = ref<number>(0)
+  /** 每收到一块 PCM chunk 时触发(外部可订阅做流式上传) */
+  const onChunk = ref<((pcm16: ArrayBuffer) => void) | null>(null)
 
   let audioContext: AudioContext | null = null
   let mediaStream: MediaStream | null = null
@@ -76,17 +78,23 @@ export function useAudioRecorder() {
       throw err
     }
 
-    // 创建 AudioContext(16kHz 采样率,直接在源头重采样)
-    audioContext = new AudioContext({ sampleRate: TARGET_SAMPLE_RATE })
+    // 创建 AudioContext(不指定 sampleRate,用硬件默认 — iOS Safari 不支持自定义采样率)
+    audioContext = new AudioContext()
+    sampleRate.value = audioContext.sampleRate
     const source = audioContext.createMediaStreamSource(mediaStream)
 
     // 加载 AudioWorklet processor
     await audioContext.audioWorklet.addModule('/pcm-recorder-worklet.js')
     workletNode = new AudioWorkletNode(audioContext, 'pcm-recorder-processor')
 
-    // 收集 PCM 数据
+    // 收集 PCM 数据;同时(可选)通过 onChunk 把 int16 字节推给外部(WebSocket 流式上传)
     workletNode.port.onmessage = (e: MessageEvent<Float32Array>) => {
       pcmChunks.push(e.data)
+      const cb = onChunk.value
+      if (cb) {
+        const int16 = float32ToInt16(e.data)
+        cb(int16.buffer as ArrayBuffer)
+      }
     }
 
     source.connect(workletNode)
@@ -110,8 +118,9 @@ export function useAudioRecorder() {
     workletNode.disconnect()
     workletNode = null
 
-    // 合并 PCM chunks 并编码为 WAV
-    const wavBlob = encodeWAV(pcmChunks, TARGET_SAMPLE_RATE)
+    // 合并 PCM chunks 并按实际硬件采样率编码 WAV(后端按 WAV header 解析)
+    const sampleRate = audioContext.sampleRate
+    const wavBlob = encodeWAV(pcmChunks, sampleRate)
     pcmChunks = []
 
     cleanup()
@@ -143,12 +152,25 @@ export function useAudioRecorder() {
     permissionState,
     recordingDuration,
     silenceDetected,
+    sampleRate,
+    onChunk,
     startRecording,
     stopRecording,
     cleanup,
   }
 }
 
+// ==================== Helpers ====================
+
+function float32ToInt16(float32: Float32Array): Int16Array {
+  const int16 = new Int16Array(float32.length)
+  for (let i = 0; i < float32.length; i++) {
+    const s = Math.max(-1, Math.min(1, float32[i]))
+    int16[i] = s < 0 ? s * 0x8000 : s * 0x7FFF
+  }
+  return int16
+}
+
 // ==================== WAV 编码 ====================
 
 function encodeWAV(chunks: Float32Array[], sampleRate: number): Blob {
@@ -165,11 +187,7 @@ function encodeWAV(chunks: Float32Array[], sampleRate: number): Blob {
   }
 
   // Float32 → Int16
-  const int16 = new Int16Array(pcm.length)
-  for (let i = 0; i < pcm.length; i++) {
-    const s = Math.max(-1, Math.min(1, pcm[i]))
-    int16[i] = s < 0 ? s * 0x8000 : s * 0x7FFF
-  }
+  const int16 = float32ToInt16(pcm)
 
   // 写 WAV header + data
   const numChannels = 1

+ 162 - 0
src/views/Editor/EnglishSpeaking/composables/useDialogueEngine.ts

@@ -253,6 +253,139 @@ export function useDialogueEngine(mode: 'preview' | 'real' = 'preview') {
     currentAbortController = null
   }
 
+  // ==================== Streaming Speak (WebSocket) ====================
+
+  /** 流式开始:立即 push 学生占位消息,返回 controller 供外部推 chunk / 结束 */
+  function beginStudentStream(opts: {
+    sampleRate: number
+    bits?: number
+    channels?: number
+  }) {
+    if (!sessionId.value || isProcessing.value) {
+      return null
+    }
+
+    // 立即占位:录音按完成的那一刻 UI 就已经显示学生泡泡 + AI placeholder
+    const studentMsg: PreviewChatMessage = {
+      id: crypto.randomUUID(),
+      role: 'student',
+      content: '',
+      timestamp: new Date(),
+      status: 'loading',
+    }
+    messages.value.push(studentMsg)
+
+    const aiMsg: PreviewChatMessage = {
+      id: crypto.randomUUID(),
+      role: 'ai',
+      content: '',
+      timestamp: new Date(),
+      status: 'loading',
+    }
+    messages.value.push(aiMsg)
+
+    const wsUrl = buildWsUrl('/speak-stream')
+    const ws = new WebSocket(wsUrl)
+    ws.binaryType = 'arraybuffer'
+
+    let aborted = false
+    let chunkQueue: ArrayBuffer[] = []
+    let open = false
+
+    const finalizeError = (msg: string) => {
+      if (studentMsg.status === 'loading') {
+        studentMsg.status = 'error'
+        studentMsg.error = msg
+      }
+      else if (aiMsg.status === 'loading') {
+        aiMsg.status = 'error'
+        aiMsg.error = msg
+      }
+    }
+
+    ws.onopen = () => {
+      open = true
+      ws.send(JSON.stringify({
+        type: 'start',
+        sessionId: sessionId.value,
+        sampleRate: opts.sampleRate,
+        bits: opts.bits ?? 16,
+        channels: opts.channels ?? 1,
+      }))
+      // flush 队列里攒的 chunk
+      for (const c of chunkQueue) ws.send(c)
+      chunkQueue = []
+    }
+
+    ws.onmessage = (e: MessageEvent) => {
+      try {
+        const data = JSON.parse(e.data)
+        if (data.type === 'transcript') {
+          studentMsg.content = data.text
+          studentMsg.status = 'done'
+        }
+        else if (data.type === 'token') {
+          aiMsg.content += data.content
+        }
+        else if (data.type === 'done') {
+          aiMsg.status = 'done'
+          isComplete.value = !!data.isComplete
+          if (!data.isComplete) currentRound.value++
+          speakTTS(aiMsg.content)
+          ws.close()
+        }
+        else if (data.type === 'error') {
+          finalizeError(friendlyErrorMessage(data.message))
+          ws.close()
+        }
+      } catch { /* ignore */ }
+    }
+
+    ws.onerror = () => {
+      if (!aborted) finalizeError(friendlyErrorMessage('WebSocket error'))
+    }
+    ws.onclose = () => {
+      if (studentMsg.status === 'loading') finalizeError(friendlyErrorMessage('Connection closed'))
+      else if (aiMsg.status === 'loading') finalizeError(friendlyErrorMessage('Connection closed'))
+    }
+
+    const pushChunk = (chunk: ArrayBuffer) => {
+      if (aborted) return
+      if (open && ws.readyState === WebSocket.OPEN) ws.send(chunk)
+      else chunkQueue.push(chunk)
+    }
+
+    const finish = () => {
+      if (aborted) return
+      if (open && ws.readyState === WebSocket.OPEN) {
+        ws.send(JSON.stringify({ type: 'stop' }))
+      } else {
+        // 还没 open 就被要求结束 → 废话不说 直接 close
+        ws.close()
+      }
+    }
+
+    const abortStream = () => {
+      aborted = true
+      try { ws.close() } catch { /* ignore */ }
+      finalizeError('Aborted')
+    }
+
+    // 便于重试:保存关联的学生消息 id
+    return { studentMsgId: studentMsg.id, aiMsgId: aiMsg.id, pushChunk, finish, abortStream }
+  }
+
+  /**
+   * 流式失败时的 HTTP fallback:用完整 audioBlob 走旧 /speak 路径。
+   * 会把 beginStudentStream 已 push 的占位消息回收(避免重复)。
+   */
+  async function streamFallback(audioBlob: Blob, studentMsgId: string, aiMsgId: string) {
+    // 移除占位消息
+    messages.value = messages.value.filter(m => m.id !== studentMsgId && m.id !== aiMsgId)
+    // 走旧流程
+    await sendStudentMessage(audioBlob)
+  }
+
   // ==================== Cleanup ====================
 
   onUnmounted(() => {
@@ -272,6 +405,8 @@ export function useDialogueEngine(mode: 'preview' | 'real' = 'preview') {
 
     initSession,
     sendStudentMessage,
+    beginStudentStream,
+    streamFallback,
     retryMessage,
     regenerateAiMessage,
     getReport,
@@ -279,3 +414,30 @@ export function useDialogueEngine(mode: 'preview' | 'real' = 'preview') {
     cancelTTS,
   }
 }
+
+// ==================== Helpers ====================
+
+/** 把 /api/speaking/dialogue/... 的 HTTP base URL 转成 ws/wss URL */
+function buildWsUrl(path: string): string {
+  const API_BASE = 'http://localhost:8000/api/speaking/dialogue'
+  const wsBase = API_BASE.replace(/^http/, 'ws')
+  return wsBase + path
+}
+
+/** 把后端英文错误转成面向用户的中文友好文案 */
+function friendlyErrorMessage(raw: string | undefined): string {
+  const map: Record<string, string> = {
+    'No speech detected': '没听清,请再说一次',
+    'Session not found': '会话已失效,请刷新页面重新开始',
+    'Session is not active': '会话已结束',
+    'sessionId required': '会话参数缺失',
+    'First message must be start': '协议异常,请刷新重试',
+    'Invalid start payload': '协议异常,请刷新重试',
+    'Internal error': '服务暂时不可用,请稍后重试',
+    'WebSocket error': '网络连接异常',
+    'Connection closed': '连接中断,请重试',
+    'Aborted': '已取消',
+  }
+  if (!raw) return '请求失败,请重试'
+  return map[raw] || raw
+}

+ 32 - 4
src/views/Editor/EnglishSpeaking/configs/TopicDiscussionConfig.vue

@@ -329,9 +329,13 @@
 
 <script lang="ts" setup>
 import { ref, nextTick } from 'vue'
+import { storeToRefs } from 'pinia'
 import { lang } from '@/main'
 import { useSpeakingStore } from '@/store/speaking'
+import { useSlidesStore } from '@/store'
 import useCreateElement from '@/hooks/useCreateElement'
+import { createSpeakingConfig, updateSpeakingConfig } from '@/services/speaking'
+import message from '@/utils/message'
 
 const emit = defineEmits<{
   (e: 'back'): void
@@ -437,11 +441,35 @@ const handleBatchPasteConfirm = () => {
   showBatchPaste.value = false
 }
 
-// 应用配置 → 往画布添加预览元素
+// 应用配置 → 更新画布上已有 77 型 frame 关联的配置
+// 新交互流程下,元素由 Layer2 点卡片即创建;此处"应用"只负责 PUT 更新到后端
 const { createFrameElement } = useCreateElement()
-
-const handleApply = () => {
-  createFrameElement('', 77)
+const { currentSlide } = storeToRefs(useSlidesStore())
+const applying = ref(false)
+
+const handleApply = async () => {
+  if (applying.value) return
+  applying.value = true
+  try {
+    const existing = currentSlide.value?.elements?.find(
+      (el: any) => el.type === 'frame' && Number(el.toolType) === 77
+    ) as { url?: string } | undefined
+
+    if (existing?.url) {
+      await updateSpeakingConfig(existing.url, store.config)
+      message.success('配置已更新')
+    } else {
+      // 兜底:画布上没有 77 型 frame(用户删了或从未创建),此时先 POST + 插入
+      const { id } = await createSpeakingConfig(store.config)
+      createFrameElement(id, 77)
+      message.success('已创建口语工具')
+    }
+  } catch (err: any) {
+    console.error('[speaking] apply failed:', err)
+    message.error(err?.message || '保存配置失败')
+  } finally {
+    applying.value = false
+  }
 }
 </script>
 

+ 36 - 5
src/views/Editor/EnglishSpeaking/layers/Layer2Speaking.vue

@@ -75,9 +75,14 @@
 
 <script lang="ts" setup>
 import { ref, computed } from 'vue'
+import { storeToRefs } from 'pinia'
 import { lang } from '@/main'
 import type { CreationMode, TopicDiscussionTask } from '@/types/englishSpeaking'
 import { useSpeakingStore } from '@/store/speaking'
+import { useSlidesStore } from '@/store'
+import useCreateElement from '@/hooks/useCreateElement'
+import { createSpeakingConfig } from '@/services/speaking'
+import message from '@/utils/message'
 import tasksData from '../data/topicDiscussionTasks.json'
 import CreationModeSwitch from '../components/CreationModeSwitch.vue'
 import RecommendCard from '../components/RecommendCard.vue'
@@ -125,16 +130,42 @@ const filteredTasks = computed(() => {
   return unitTasks.value
 })
 
-// 选择推荐卡片 → 预填充 store → 进入配置页
+// 新交互:点卡片即创建配置 + 插入画布元素;配置页改由"点击画布里的 77 型元素"唤起
+const { createFrameElement } = useCreateElement()
+const { currentSlide } = storeToRefs(useSlidesStore())
+const inserting = ref(false)
+
+async function insertSpeakingToolToCanvas(source: 'select' | 'manual') {
+  if (inserting.value) return
+  // 防御:当前 slide 已有 frame(不管类型)则阻止(createFrameElement 内部也会挡)
+  const hasFrame = currentSlide.value?.elements?.some((el: any) => el.type === 'frame')
+  if (hasFrame) {
+    message.error(lang.ssSlideLearn as string)
+    return
+  }
+  inserting.value = true
+  try {
+    const { id } = await createSpeakingConfig(speakingStore.config)
+    createFrameElement(id, 77)
+    message.success(source === 'select' ? '已添加口语练习到幻灯片' : '已添加空白口语工具,点击画布上的元素进行配置')
+  } catch (err: any) {
+    console.error('[speaking] create failed:', err)
+    message.error(err?.message || '创建口语工具失败')
+  } finally {
+    inserting.value = false
+  }
+}
+
+// 选择推荐卡片 → 预填充 store → 立即创建配置 + 插入元素
 const handleSelectTask = (task: TopicDiscussionTask) => {
   speakingStore.prefillFromTask(task.titleEn, task.vocabulary, task.sentences)
-  emit('openConfig')
+  insertSpeakingToolToCanvas('select')
 }
 
-// 手动创建 → 重置 store → 进入配置页
-const handleManualCreate = (taskTypeId: string) => {
+// 手动创建 → 重置 store → 立即创建配置 + 插入元素
+const handleManualCreate = (_taskTypeId: string) => {
   speakingStore.resetConfig()
-  emit('openConfig')
+  insertSpeakingToolToCanvas('manual')
 }
 </script>
 

+ 91 - 10
src/views/Editor/EnglishSpeaking/preview/DialogueChatView.vue

@@ -569,7 +569,8 @@ const phonemeDetail = ref<{
 const silenceHintText = ref('')
 const showIdleHint = ref(false)
 let idleHintTimer: ReturnType<typeof setTimeout> | null = null
-let playTimer: ReturnType<typeof setTimeout> | null = null
+let currentAudio: HTMLAudioElement | null = null
+let currentAudioUrl: string | null = null
 
 // 总用时计数(独立 UI 计时,与 engine.countdownSeconds 互斥显示)
 const totalSeconds = ref(0)
@@ -664,17 +665,34 @@ function stateStyle(target: string) {
 // Actions
 // ─────────────────────────────────────────────
 
+// 当前流式会话控制器(开始录音时打开 WebSocket,结束时收尾)
+let streamCtl: ReturnType<typeof engine.beginStudentStream> | null = null
+
 async function handleStartRecording() {
   if (!engine.canRecord.value || recorder.isRecording.value) return
   try {
     await recorder.startRecording()
+    // 启动流式会话(立即 push UI 占位消息 + 打开 WebSocket)
+    streamCtl = engine.beginStudentStream({
+      sampleRate: recorder.sampleRate.value,
+      bits: 16,
+      channels: 1,
+    })
+    if (streamCtl) {
+      recorder.onChunk.value = streamCtl.pushChunk
+    }
   } catch (err) {
     console.error('Failed to start recording:', err)
   }
 }
 
 function handleCancelRecording() {
-  // 直接停止,不发送(丢弃本次录音)
+  // 停止录音 + 中止流式会话(丢弃本次录音)
+  recorder.onChunk.value = null
+  if (streamCtl) {
+    streamCtl.abortStream()
+    streamCtl = null
+  }
   if (recorder.isRecording.value) {
     recorder.stopRecording().catch(() => {})
   }
@@ -683,9 +701,29 @@ function handleCancelRecording() {
 
 async function handleFinishRecording() {
   if (!recorder.isRecording.value) return
+  const ctl = streamCtl
+  streamCtl = null
   try {
     const blob = await recorder.stopRecording()
-    await engine.sendStudentMessage(blob)
+    recorder.onChunk.value = null
+    if (ctl) {
+      // 走 WebSocket 流式路径
+      ctl.finish()
+      // HTTP fallback:如果 WS 出错,engine.beginStudentStream 内部会把占位消息标为 error
+      // 这里监听 2 秒看状态,若被标为 error 则降级到 /speak
+      setTimeout(() => {
+        const sMsg = engine.messages.value.find(m => m.id === ctl.studentMsgId)
+        if (sMsg && sMsg.status === 'error') {
+          console.warn('[speak] WS failed, falling back to HTTP /speak')
+          engine.streamFallback(blob, ctl.studentMsgId, ctl.aiMsgId).catch(err => {
+            console.error('HTTP fallback also failed:', err)
+          })
+        }
+      }, 2000)
+    } else {
+      // 没启动流式(异常情况)→ 直接走旧 HTTP 路径
+      await engine.sendStudentMessage(blob)
+    }
   } catch (err) {
     console.error('Recording/send failed:', err)
   }
@@ -703,17 +741,60 @@ function toggleExpand(id: string) {
   expandedMessageId.value = expandedMessageId.value === id ? null : id
 }
 
+function stopCurrentPlayback() {
+  if (currentAudio) {
+    currentAudio.pause()
+    currentAudio = null
+  }
+  if (currentAudioUrl) {
+    URL.revokeObjectURL(currentAudioUrl)
+    currentAudioUrl = null
+  }
+  if (typeof speechSynthesis !== 'undefined') speechSynthesis.cancel()
+  playingMessageId.value = null
+}
+
 function togglePlay(id: string) {
+  // 已在播放 → 停止
   if (playingMessageId.value === id) {
-    playingMessageId.value = null
-    if (playTimer) clearTimeout(playTimer)
+    stopCurrentPlayback()
     return
   }
+  stopCurrentPlayback()
+
+  const msg = engine.messages.value.find(m => m.id === id)
+  if (!msg) return
+
   playingMessageId.value = id
-  if (playTimer) clearTimeout(playTimer)
-  playTimer = setTimeout(() => {
-    if (playingMessageId.value === id) playingMessageId.value = null
-  }, 3000)
+
+  if (msg.role === 'student' && msg.audioBlob) {
+    // 学生消息:播放录音 Blob
+    currentAudioUrl = URL.createObjectURL(msg.audioBlob)
+    currentAudio = new Audio(currentAudioUrl)
+    const clearIfSame = () => {
+      if (playingMessageId.value === id) stopCurrentPlayback()
+    }
+    currentAudio.onended = clearIfSame
+    currentAudio.onerror = clearIfSame
+    currentAudio.play().catch((err) => {
+      console.error('[audio] play failed:', err)
+      clearIfSame()
+    })
+  } else if (msg.role === 'ai' && msg.content && typeof speechSynthesis !== 'undefined') {
+    // AI 消息:用浏览器 TTS 重读
+    const utter = new SpeechSynthesisUtterance(msg.content)
+    utter.lang = 'en-US'
+    utter.rate = 0.9
+    const clearIfSame = () => {
+      if (playingMessageId.value === id) playingMessageId.value = null
+    }
+    utter.onend = clearIfSame
+    utter.onerror = clearIfSame
+    speechSynthesis.speak(utter)
+  } else {
+    // 无可播内容,直接复位
+    playingMessageId.value = null
+  }
 }
 
 function openPhonemeDetail(wa: NonNullable<NonNullable<PreviewChatMessage['evaluation']>['wordAnalysis']>[0]) {
@@ -885,8 +966,8 @@ onMounted(() => {
 onUnmounted(() => {
   if (totalTimer) clearInterval(totalTimer)
   if (idleHintTimer) clearTimeout(idleHintTimer)
-  if (playTimer) clearTimeout(playTimer)
   if (badgeTimer) clearTimeout(badgeTimer)
+  stopCurrentPlayback()
 })
 </script>
 

+ 25 - 5
src/views/Editor/EnglishSpeaking/preview/TopicDiscussionPreview.vue

@@ -12,7 +12,7 @@
 
       <div class="ready-body">
         <span class="topic-emoji">🐼</span>
-        <h3 class="topic-name">{{ topic }}</h3>
+        <h3 class="topic-name">{{ speakingStore.config.topic || topic }}</h3>
       </div>
 
       <div class="ready-footer">
@@ -32,11 +32,11 @@
     <!-- Chatting 阶段:直接渲染 DialogueChatView -->
     <DialogueChatView
       v-else-if="dialogueState === 'chatting'"
-      :topic="topic"
-      :keywords="keywords"
+      :topic="speakingStore.config.topic || topic"
+      :keywords="speakingStore.config.learningGoals.vocabulary.length ? speakingStore.config.learningGoals.vocabulary : keywords"
       :ai-name="mockRole.name"
       :ai-avatar="mockRole.avatar"
-      :total-rounds="totalRounds"
+      :total-rounds="speakingStore.config.practice.rounds || totalRounds"
       :mode="mode"
       @complete="handleDialogueComplete"
     />
@@ -63,6 +63,7 @@
 import { ref, watch, onMounted, onUnmounted } from 'vue'
 import type { OverallEvaluation, PreviewAIRole, PreviewDialogueState } from '@/types/englishSpeaking'
 import { useSpeakingStore } from '@/store/speaking'
+import { getSpeakingConfig } from '@/services/speaking'
 
 import DialogueChatView from './DialogueChatView.vue'
 import OverallReport from './OverallReport.vue'
@@ -73,6 +74,8 @@ interface Props {
   topic?: string
   keywords?: string[]
   totalRounds?: number
+  /** 配置 id(由 FrameElement 从 elementInfo.url 传入) */
+  configId?: string
 }
 
 const props = withDefaults(defineProps<Props>(), {
@@ -80,6 +83,7 @@ const props = withDefaults(defineProps<Props>(), {
   topic: '我最喜欢的动物',
   keywords: () => ['favorite', 'adorable', 'bamboo', 'habitat', 'wildlife', 'endangered'],
   totalRounds: 3,
+  configId: '',
 })
 
 const dialogueState = ref<PreviewDialogueState>('ready')
@@ -207,7 +211,23 @@ watch(
   },
 )
 
-onMounted(() => { speakingStore.setPreviewState(dialogueState.value) })
+// ── 根据 configId 从后端拉回配置注入 store ──
+async function loadConfigFromBackend(id: string) {
+  if (!id) return
+  try {
+    const { config } = await getSpeakingConfig(id)
+    speakingStore.$patch({ config })
+  } catch (err) {
+    console.error('[speaking] load config failed:', err)
+  }
+}
+
+watch(() => props.configId, (id) => { loadConfigFromBackend(id) })
+
+onMounted(() => {
+  speakingStore.setPreviewState(dialogueState.value)
+  loadConfigFromBackend(props.configId)
+})
 onUnmounted(() => { speakingStore.setPreviewState('ready') })
 </script>
 

+ 2 - 0
src/views/Editor/index3.vue

@@ -329,6 +329,7 @@ const handleDelete = () => {
 }
 
 const changeCourse = () => {
+  console.trace('[TRACE] changeCourse fired → parentWindow.save(3)')
   parentWindow.setCouresTitle?.(courseTitle.value)
   parentWindow.save?.(3)
 }
@@ -348,6 +349,7 @@ const handlePreview = () => {
 }
 
 const handleSave = () => {
+  console.trace('[TRACE] handleSave fired → parentWindow.save(1)')
   console.log('保存课程')
   // lastSaveTime.value = new Date().toLocaleString()
   parentWindow.save?.(1)

+ 2 - 1
src/views/components/element/FrameElement/BaseFrameElement.vue

@@ -37,9 +37,10 @@
           controls
           :style="{ width: '100%', height: '100%', objectFit: 'contain' }"
         ></video>
-        <!-- 英语口语预览(type 77):使用 Vue 组件直接渲染 -->
+        <!-- 英语口语预览(type 77):使用 Vue 组件直接渲染,url 字段即 configId -->
         <TopicDiscussionPreview
           v-else-if="Number(elementInfo.toolType) === 77 && !isThumbnail && isVisible"
+          :configId="elementInfo.url"
           :style="{ width: '100%', height: '100%' }"
         />
         <!-- B站视频类型(type 75):使用 iframe -->

+ 8 - 1
src/views/components/element/FrameElement/index.vue

@@ -27,9 +27,10 @@
           controls
           :style="{ width: '100%', height: '100%', objectFit: 'contain' }"
         ></video>
-        <!-- 英语口语预览(type 77):使用 Vue 组件直接渲染 -->
+        <!-- 英语口语预览(type 77):使用 Vue 组件直接渲染,url 字段即 configId -->
         <TopicDiscussionPreview
           v-else-if="Number(elementInfo.toolType) === 77"
+          :configId="elementInfo.url"
           :style="{ width: '100%', height: '100%' }"
         />
         <!-- B站视频类型(type 75):使用 iframe -->
@@ -84,6 +85,7 @@
 import type { PropType } from 'vue'
 import { storeToRefs } from 'pinia'
 import { useMainStore } from '@/store'
+import { useSpeakingStore } from '@/store/speaking'
 import type { PPTFrameElement } from '@/types/slides'
 import TopicDiscussionPreview from '@/views/Editor/EnglishSpeaking/preview/TopicDiscussionPreview.vue'
 import type { ContextmenuItem } from '@/components/Contextmenu/types'
@@ -105,6 +107,7 @@ const props = defineProps({
 })
 
 const { handleElementId } = storeToRefs(useMainStore())
+const speakingStore = useSpeakingStore()
 
 const iframeKey = ref(0)
 
@@ -117,6 +120,10 @@ watch(() => props.elementInfo.url, (newUrl, oldUrl) => {
 const handleSelectElement = (e: MouseEvent | TouchEvent, canMove = true) => {
   e.stopPropagation()
   props.selectElement(e, props.elementInfo, canMove)
+  // 点击口语工具 frame → 弹出/切换到配置面板
+  if (Number(props.elementInfo.toolType) === 77) {
+    speakingStore.openConfigPanel()
+  }
 }
 </script>