浏览代码

feat(web): refactor input box layout and improve recording error handling

- Move input box to top of chat container for kiosk devices in spread mode
- Add conditional rendering to display input box at top for kiosk or bottom for other devices
- Restructure kiosk-chat container with flexbox to properly order input and chat sections
- Convert startRecording to async function with improved error handling and user feedback
- Add specific error messages for different microphone permission scenarios (NotAllowedError, NotFoundError, NotReadableError)
- Refactor scanAvailableAudioDevices to request microphone permissions before device enumeration
- Improve initRecord function with better error handling and validation
- Add console logging for debugging recording initialization and device detection
- Reset recording state flags when opening recording dialog to prevent state inconsistencies
- Enhance user experience with more descriptive error messages for microphone access issues
sy 1 月之前
父节点
当前提交
7b227c1164
共有 2 个文件被更改,包括 132 次插入55 次删除
  1. 40 9
      web/src/components/SsInputBox.vue
  2. 92 46
      web/src/components/SsRecording.vue

+ 40 - 9
web/src/components/SsInputBox.vue

@@ -262,6 +262,31 @@ defineExpose({
 
 <template>
   <div class="chat-container" :class="{spread: global.spread, 'kiosk-chat': isKioskDevice && global.spread}">
+    <!-- 立式一体机模式:输入框在顶部 -->
+    <div v-if="isKioskDevice && global.spread" class="input-box input-box-top" :class="{spread: global.spread}">
+      <div class="input-container hover-scale scale101">
+        <el-input ref="textareaRef" v-model="message" class="input-container-input" placeholder="请输入你想咨询的问题..."
+                  :type="global.spread ? 'textarea' : 'text'"
+                  :autosize="{ minRows: 1, maxRows: 3 }"
+                  @keyup.enter="sendMessage"
+                  maxlength="200"
+                  @input="calculateLines"
+                  @change="calculateLines"
+        />
+        <div class="input-buttons">
+          <div class="input-buttons-limit">{{ message.length }}/200</div>
+          <div class="input-buttons-right">
+            <ss-recording @set-message="setMessage"/>
+            <div class="buttons-separate"></div>
+            <el-button class="send-button" v-if="global.inReply" circle @click="stopInReply">
+              <div class="stop-block"></div>
+            </el-button>
+            <el-button class="send-button" v-else :icon="Top" circle @click="sendMessage"/>
+          </div>
+        </div>
+      </div>
+    </div>
+    
     <el-scrollbar ref="scrollbarRef" class="ss-chat" @scroll="handleScroll">
       <div class="ss-chat-container">
         <template v-for="(item, idx) in replyList">
@@ -273,7 +298,9 @@ defineExpose({
       <div ref="bottomRef"></div>
       <div style="height: 1rem"></div>
     </el-scrollbar>
-    <div class="input-box" :class="{spread: global.spread}">
+    
+    <!-- 非立式一体机模式:输入框在底部 -->
+    <div v-if="!(isKioskDevice && global.spread)" class="input-box" :class="{spread: global.spread}">
       <div class="input-container hover-scale scale101">
         <el-input ref="textareaRef" v-model="message" class="input-container-input" placeholder="请输入你想咨询的问题..."
                   :type="global.spread ? 'textarea' : 'text'"
@@ -560,19 +587,23 @@ defineExpose({
       }
     }
 
-    /* 立式一体机对话模式:输入框固定在对话区部 */
+    /* 立式一体机对话模式:输入框固定在对话区部 */
     &.kiosk-chat {
+      display: flex;
+      flex-direction: column;
+      
+      .input-box-top {
+        order: 1;
+        flex-shrink: 0;
+        margin-top: 2rem;
+        margin-bottom: 1.5rem;
+      }
+      
       .ss-chat {
+        order: 2;
         flex: 1;
         height: auto !important;
       }
-
-      .input-box {
-        flex-shrink: 0;
-        margin-bottom: 2rem;
-        position: relative;
-        /* 确保输入框在 Y≤1400px 范围内(100px 顶部导航 + 最多 1300px 对话区) */
-      }
     }
   }
 }

+ 92 - 46
web/src/components/SsRecording.vue

@@ -44,24 +44,35 @@ const stopCountDown = () => {
 }
 
 // 开始录音
-const startRecording = () => {
+const startRecording = async () => {
+  if (!recordEsm) {
+    ElMessage.error('录音插件未初始化')
+    cancelRecording()
+    return
+  }
+
   try {
-    recordEsm.startRecording({deviceId: deviceId.value}).then(() => {
-      isRecording.value = true
-      isProcessing.value = true
-      useTime.value = 0
-      startCountDown()
-    }).catch(() => {
-      ElMessage.warning('未找到或未授权录音设备')
-      availableAudioDevicesFlag.value = false
-      cancelRecording()
-    })
-  } catch (err) {
-    console.error(err)
-    ElMessage.error('录音错误')
-    recordingDialog.value = false
-    isProcessing.value = false
-    recordingDialog.value = false
+    await recordEsm.startRecording({deviceId: deviceId.value})
+    isRecording.value = true
+    isProcessing.value = true
+    useTime.value = 0
+    startCountDown()
+    console.log('录音已启动')
+  } catch (err: any) {
+    console.error('录音启动失败:', err)
+    availableAudioDevicesFlag.value = false
+    
+    // 根据错误类型提供更友好的提示
+    if (err.name === 'NotAllowedError') {
+      ElMessage.warning('麦克风权限被拒绝,请在浏览器设置中允许访问麦克风')
+    } else if (err.name === 'NotFoundError') {
+      ElMessage.warning('未找到可用的麦克风设备')
+    } else if (err.name === 'NotReadableError') {
+      ElMessage.warning('麦克风被其他应用占用,请关闭其他使用麦克风的程序')
+    } else {
+      ElMessage.warning(`无法启动录音: ${err.message || '未知错误'}`)
+    }
+    cancelRecording()
   }
 }
 
@@ -82,7 +93,10 @@ const cancelRecording = () => {
 }
 
 const openRecordingDialog = () => {
+  console.log('打开录音对话框')
   isActiveCancel.value = false
+  isRecording.value = false
+  isProcessing.value = false
   recordingDialog.value = true
   nextTick(() => {
     initRecord(() => startRecording());
@@ -95,39 +109,69 @@ const setMessage = (message: string): void => {
   emit('setMessage', message)
 }
 
-const scanAvailableAudioDevices = (success: Function | null = null, fail: Function | null = null) => {
-  if (deviceId.value != '') {
-    if (success) success()
-  } else {
-    RecordPlugin.getAvailableAudioDevices().then((devices) => {
-      if (devices.length <= 0) {
-        availableAudioDevicesFlag.value = false
-        if (fail) fail()
-      } else {
-        devices.forEach((device) => {
-          if (deviceId.value == '') deviceId.value = device.deviceId
-        })
-        if (success) success();
+const scanAvailableAudioDevices = async (success: Function | null = null, fail: Function | null = null) => {
+  try {
+    // 先请求麦克风权限,这样才能获取到设备列表
+    const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
+    
+    // 获取权限后立即停止流,避免占用设备
+    stream.getTracks().forEach(track => track.stop())
+    
+    // 现在可以获取设备列表了
+    const devices = await RecordPlugin.getAvailableAudioDevices()
+    
+    if (devices.length <= 0) {
+      availableAudioDevicesFlag.value = false
+      ElMessage.error('未找到可用的音频设备,请检查麦克风连接')
+      if (fail) fail()
+    } else {
+      // 选择第一个可用设备
+      if (deviceId.value === '') {
+        deviceId.value = devices[0].deviceId
       }
-    })
+      console.log('找到音频设备:', devices.length, '个')
+      if (success) success()
+    }
+  } catch (err: any) {
+    console.error('获取音频设备失败:', err)
+    availableAudioDevicesFlag.value = false
+    
+    // 根据错误类型提供更友好的提示
+    if (err.name === 'NotAllowedError') {
+      ElMessage.error('麦克风权限被拒绝,请在浏览器设置中允许访问麦克风')
+    } else if (err.name === 'NotFoundError') {
+      ElMessage.error('未找到可用的麦克风设备,请检查设备连接')
+    } else {
+      ElMessage.error('无法访问麦克风,请检查浏览器权限设置')
+    }
+    
+    if (fail) fail()
   }
 }
 
-const initRecord = (callback: Function) => {
-  if (waveformRef.value) {
+const initRecord = async (callback: Function) => {
+  if (!waveformRef.value) {
+    cancelRecording()
+    ElMessage.warning('初始化失败')
+    return
+  }
+
+  try {
     wavesurfer = WaveSurfer.create({
       container: waveformRef.value,
       waveColor: '#5A92F8',
       progressColor: 'transparent',
     })
+    
     recordEsm = wavesurfer.registerPlugin(
-        RecordPlugin.create({
-          renderRecordedAudio: false,
-          scrollingWaveform: false,
-          continuousWaveform: false,
-          continuousWaveformDuration: 30
-        }),
+      RecordPlugin.create({
+        renderRecordedAudio: false,
+        scrollingWaveform: false,
+        continuousWaveform: false,
+        continuousWaveformDuration: 30
+      }),
     )
+    
     recordEsm.on('record-end', async (audioBlob: any) => {
       try {
         stopCountDown();
@@ -150,13 +194,15 @@ const initRecord = (callback: Function) => {
         recordingDialog.value = false
       }
     })
-    scanAvailableAudioDevices(callback, () => {
-      ElMessage.error('未找到音频设备')
+    
+    // 扫描设备并启动录音
+    await scanAvailableAudioDevices(callback, () => {
       cancelRecording()
     })
-  } else {
+  } catch (err) {
+    console.error('初始化录音插件失败:', err)
+    ElMessage.error('录音功能初始化失败')
     cancelRecording()
-    ElMessage.warning('初始化失败')
   }
 }
 
@@ -184,14 +230,14 @@ onUnmounted(() => {
         :close-on-press-escape="false"
     >
       <div v-if="isProcessing" class="waveformRef-title">录音将在{{ countDown }}秒后自动结束</div>
-      <div v-else class="waveformRef-title">正则扫描麦克风设备</div>
+      <div v-else class="waveformRef-title">正在检测麦克风设备...</div>
       <div ref="waveformRef" class="waveformRef"></div>
       <template #footer>
         <el-button round @click="cancelRecording">取消</el-button>
-        <el-button type="primary" round class="stop-recording" @click="stopRecording">
+        <el-button type="primary" round class="stop-recording" :disabled="!isRecording" @click="stopRecording">
           <el-image v-if="isRecording" class="record-icon" fit="contain" src="/images/recording-stop.png"/>
           <el-image v-else class="record-icon loading" fit="contain" src="/images/recording-loading.png"/>
-          {{ isRecording ? '我说完了' : '音频处理中' }}
+          {{ isRecording ? '我说完了' : '正在准备...' }}
         </el-button>
       </template>
     </el-dialog>