Quellcode durchsuchen

修复口语预览页"可以拖动"/"不能滚动"的问题

jimmylee vor 2 Monaten
Ursprung
Commit
d90f35555d

+ 8 - 1
src/views/Editor/Canvas/Operate/index.vue

@@ -10,7 +10,7 @@
     }"
   >
     <component
-      v-if="isSelected"
+      v-if="isSelected && !isLockedInteractiveWidget"
       :is="currentOperateComponent"
       :elementInfo="elementInfo"
       :handlerVisible="!elementInfo.lock && (isActiveGroupElement || !isMultiSelect)"
@@ -103,6 +103,13 @@ const elementIndexListInAnimation = computed(() => {
 
 const rotate = computed(() => 'rotate' in props.elementInfo ? props.elementInfo.rotate : 0)
 const height = computed(() => 'height' in props.elementInfo ? props.elementInfo.height : 0)
+
+// 英语口语预览(FRAME + toolType 77)产品设计上占满整页不可变形,
+// 强制隐藏 resize / rotate 把手,用户仍可选中+删除+开配置面板,但看不到任何"拖动感"。
+const isLockedInteractiveWidget = computed(() => (
+  props.elementInfo.type === ElementTypes.FRAME &&
+  Number((props.elementInfo as any).toolType) === 77
+))
 </script>
 
 <style lang="scss" scoped>

+ 6 - 0
src/views/Editor/Canvas/index.vue

@@ -384,6 +384,12 @@ const throttleScaleCanvas = throttle(scaleCanvas, 100, { leading: true, trailing
 const throttleUpdateSlideIndex = throttle(updateSlideIndex, 300, { leading: true, trailing: false })
 
 const handleMousewheelCanvas = (e: WheelEvent) => {
+  // type 77(英语口语预览)这类可交互 widget:如果 wheel 来自它内部的某个 scroll 容器(比如
+  // 报告的 .report-scroll、对话的 .chat-area),让内部 scroll 接管,canvas 不介入。
+  // 如果不这样做,下面的 preventDefault 会把浏览器默认滚动行为一并抑制,内部永远滚不动。
+  const targetEl = e.target instanceof Element ? e.target : null
+  if (targetEl?.closest('.topic-discussion-preview')) return
+
   e.preventDefault()
 
   // 按住Ctrl键时:缩放画布

+ 57 - 0
src/views/Editor/EnglishSpeaking/composables/useAudioRecorder.ts

@@ -1,5 +1,12 @@
 import { ref, onUnmounted } from 'vue'
 
+/**
+ * 诊断日志说明:本文件的 `console.debug('[recorder] ...')` 用于排查"录不到声音"类问题
+ * (mic track 状态 / AudioContext 状态 / 每 50 个 chunk 的 Float32 峰值 / 结束时整体统计)。
+ * 默认在 Chrome DevTools 不显示——浏览器 Console 的 log level filter 切到 "Verbose"
+ * (或 "All levels")即可看到。Safari / Firefox 同理。
+ */
+
 export function useAudioRecorder() {
   const isRecording = ref(false)
   const permissionState = ref<PermissionState>('prompt')
@@ -18,6 +25,10 @@ export function useAudioRecorder() {
   let silenceCheckTimer: ReturnType<typeof setInterval> | null = null
   let analyser: AnalyserNode | null = null
 
+  // [DEBUG] 跨 start/stop 共享的录音诊断计数器
+  let debugChunkCount = 0
+  let debugPeakFloat = 0
+
   // Check permission state
   async function checkPermission() {
     try {
@@ -83,13 +94,45 @@ export function useAudioRecorder() {
     sampleRate.value = audioContext.sampleRate
     const source = audioContext.createMediaStreamSource(mediaStream)
 
+    // 诊断录音管线是否健康:mic track 状态 + AudioContext 状态
+    console.debug('[recorder] startRecording', {
+      contextState: audioContext.state,
+      contextSampleRate: audioContext.sampleRate,
+      tracks: mediaStream.getAudioTracks().map(t => ({
+        label: t.label,
+        enabled: t.enabled,
+        muted: t.muted,
+        readyState: t.readyState,
+        settings: t.getSettings?.(),
+      })),
+    })
+
+    // 有些浏览器 AudioContext 默认 suspended,必须 resume 才会 tick worklet
+    if (audioContext.state === 'suspended') {
+      await audioContext.resume()
+      console.debug('[recorder] AudioContext resumed →', audioContext.state)
+    }
+
     // 加载 AudioWorklet processor
     await audioContext.audioWorklet.addModule('/pcm-recorder-worklet.js')
     workletNode = new AudioWorkletNode(audioContext, 'pcm-recorder-processor')
 
+    // 重置诊断计数器(很便宜,一直追踪;只有打印受 DevTools Verbose filter 控制)
+    debugChunkCount = 0
+    debugPeakFloat = 0
+
     // 收集 PCM 数据;同时(可选)通过 onChunk 把 int16 字节推给外部(WebSocket 流式上传)
     workletNode.port.onmessage = (e: MessageEvent<Float32Array>) => {
       pcmChunks.push(e.data)
+      debugChunkCount++
+      for (let i = 0; i < e.data.length; i++) {
+        const a = Math.abs(e.data[i])
+        if (a > debugPeakFloat) debugPeakFloat = a
+      }
+      // 每 50 个 chunk 打一次(≈ 128*50/48000 ≈ 133ms 一条,避免刷屏)
+      if (debugChunkCount % 50 === 0) {
+        console.debug(`[recorder] chunks=${debugChunkCount} peak_float=${debugPeakFloat.toFixed(4)}`)
+      }
       const cb = onChunk.value
       if (cb) {
         const int16 = float32ToInt16(e.data)
@@ -120,6 +163,20 @@ export function useAudioRecorder() {
 
     // 合并 PCM chunks 并按实际硬件采样率编码 WAV(后端按 WAV header 解析)
     const sampleRate = audioContext.sampleRate
+
+    // 合并前打一条"整段录音的最终统计"—— peak_float ≈ 0 就是录音管线产零
+    let totalSamples = 0
+    for (const c of pcmChunks) totalSamples += c.length
+    console.debug('[recorder] stopRecording final', {
+      chunks: debugChunkCount,
+      totalSamples,
+      duration: (totalSamples / sampleRate).toFixed(2) + 's',
+      peakFloat: debugPeakFloat.toFixed(6),
+      contextState: audioContext.state,
+      trackMuted: mediaStream?.getAudioTracks()[0]?.muted,
+      trackEnabled: mediaStream?.getAudioTracks()[0]?.enabled,
+    })
+
     const wavBlob = encodeWAV(pcmChunks, sampleRate)
     pcmChunks = []
 

+ 1 - 12
src/views/Editor/EnglishSpeaking/preview/DialogueChatView.vue

@@ -707,19 +707,8 @@ async function handleFinishRecording() {
     const blob = await recorder.stopRecording()
     recorder.onChunk.value = null
     if (ctl) {
-      // 走 WebSocket 流式路径
+      // 走 WebSocket 流式路径。WS 失败时,不自动降级 HTTP —— 让错误泡泡的"重试"按钮走人工路径。
       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)

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

@@ -250,6 +250,12 @@ onUnmounted(() => { speakingStore.setPreviewState('ready') })
   background: #fff;
   position: relative;
   overflow: hidden;
+
+  // flex 嵌套 overflow 生效的前提:flex 子默认 min-height: auto 会被内容撑大,
+  // 让 ready-stage / report-stage / DialogueChatView 根节点能 shrink 到父高度
+  > * {
+    min-height: 0;
+  }
 }
 
 // ─── Ready 阶段 ───

+ 33 - 18
src/views/components/element/FrameElement/index.vue

@@ -12,8 +12,9 @@
       class="rotate-wrapper"
       :style="{ transform: `rotate(${elementInfo.rotate}deg)` }"
     >
-      <div 
-        class="element-content" 
+      <div
+        class="element-content"
+        :class="{ 'is-interactive-widget': isSpeakingTool }"
         v-contextmenu="contextmenus"
         @mousedown="$event => handleSelectElement($event)"
         @touchstart="$event => handleSelectElement($event)"
@@ -29,7 +30,7 @@
         ></video>
         <!-- 英语口语预览(type 77):使用 Vue 组件直接渲染,url 字段即 configId -->
         <TopicDiscussionPreview
-          v-else-if="Number(elementInfo.toolType) === 77"
+          v-else-if="isSpeakingTool"
           :configId="elementInfo.url"
           :style="{ width: '100%', height: '100%' }"
         />
@@ -66,16 +67,21 @@
           allow="camera *; microphone *; display-capture; midi; encrypted-media; fullscreen; geolocation; clipboard-read; clipboard-write; accelerometer; autoplay; gyroscope; payment; picture-in-picture; usb; xr-spatial-tracking;"
         ></iframe>
 
-        <div class="drag-handler top"></div>
-        <div class="drag-handler bottom"></div>
-        <div class="drag-handler left"></div>
-        <div class="drag-handler right"></div>
+        <!-- type 77(英语口语预览)是一个需要内部交互的 Vue 组件:
+             mask / drag-handler 会挡住 wheel 事件导致内部滚不了,
+             所以对 77 不渲染这两层,选中/拖动走外层 .element-content 的 mousedown。 -->
+        <template v-if="!isSpeakingTool">
+          <div class="drag-handler top"></div>
+          <div class="drag-handler bottom"></div>
+          <div class="drag-handler left"></div>
+          <div class="drag-handler right"></div>
 
-        <div class="mask" 
-          v-if="handleElementId !== elementInfo.id"
-          @mousedown="$event => handleSelectElement($event, false)"
-          @touchstart="$event => handleSelectElement($event, false)"
-        ></div>
+          <div class="mask"
+            v-if="handleElementId !== elementInfo.id"
+            @mousedown="$event => handleSelectElement($event, false)"
+            @touchstart="$event => handleSelectElement($event, false)"
+          ></div>
+        </template>
       </div>
     </div>
   </div>
@@ -89,7 +95,7 @@ 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'
-import { ref, watch } from 'vue'
+import { computed, ref, watch } from 'vue'
 
 const props = defineProps({
   elementInfo: {
@@ -109,6 +115,11 @@ const props = defineProps({
 const { handleElementId } = storeToRefs(useMainStore())
 const speakingStore = useSpeakingStore()
 
+// 英语口语预览工具(FRAME + toolType 77):产品上占满整页、不可拖动、不可缩放/旋转。
+// 这个 flag 在 template 里决定是否渲染 mask/drag-handler + interactive-widget class,
+// 在 handleSelectElement 里决定是否禁止 drag + 是否弹配置面板。
+const isSpeakingTool = computed(() => Number(props.elementInfo.toolType) === 77)
+
 const iframeKey = ref(0)
 
 watch(() => props.elementInfo.url, (newUrl, oldUrl) => {
@@ -119,11 +130,11 @@ 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()
-  }
+  // 口语工具:强制 canMove=false 跳过 useSelectElement 的 dragElement 分支,禁止拖动。
+  // 点击后仍弹出/切换配置面板(业务需求)。
+  const finalCanMove = isSpeakingTool.value ? false : canMove
+  props.selectElement(e, props.elementInfo, finalCanMove)
+  if (isSpeakingTool.value) speakingStore.openConfigPanel()
 }
 </script>
 
@@ -139,6 +150,10 @@ const handleSelectElement = (e: MouseEvent | TouchEvent, canMove = true) => {
   overflow: hidden;
   position: absolute;
 }
+// 英语口语预览(type 77)不可拖动,光标维持默认(让内部按钮/输入的 cursor 能生效)
+.element-content.is-interactive-widget {
+  cursor: default;
+}
 .element-content iframe{
   width: 100%;
   height: 100%;