소스 검색

feat: 新增语音识别功能

- 麦克风按钮(长按录音/松手发送/上划取消)
- MediaRecorder 录音 → POST /api/asr → 识别文字自动填入并发送
- 录音浮层(声波动画 + 操作提示)
- 识别结果自动触发发送

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
simonlll 1 주 전
부모
커밋
f9d81c4312
1개의 변경된 파일158개의 추가작업 그리고 4개의 파일을 삭제
  1. 158 4
      src/pages/ai-chat/index.vue

+ 158 - 4
src/pages/ai-chat/index.vue

@@ -117,23 +117,46 @@
117 117
 
118 118
     <!-- 输入区 -->
119 119
     <view class="input-bar">
120
+      <!-- 麦克风按钮 -->
121
+      <view
122
+        :class="['mic-btn', recording ? 'recording' : '']"
123
+        @touchstart.prevent="startRecord"
124
+        @touchend.prevent="stopRecord"
125
+        @touchcancel.prevent="cancelRecord"
126
+      >
127
+        <text class="mic-icon">{{ recording ? '●' : '🎤' }}</text>
128
+      </view>
129
+
120 130
       <input
121 131
         v-model="inputText"
122 132
         class="input"
123
-        placeholder="请输入您的问题..."
124
-        :disabled="loading"
133
+        :placeholder="recording ? '正在录音...' : '请输入您的问题...'"
134
+        :disabled="loading || recording"
125 135
         confirm-type="send"
126 136
         @confirm="sendMessage"
127 137
       />
128
-      <view :class="['send-btn', loading ? 'disabled' : '']" @click="sendMessage">
138
+      <view :class="['send-btn', (loading || recording) ? 'disabled' : '']" @click="sendMessage">
129 139
         <text class="send-text">{{ loading ? '…' : '发送' }}</text>
130 140
       </view>
131 141
     </view>
142
+
143
+    <!-- 录音提示浮层 -->
144
+    <view v-if="recording" class="recording-overlay">
145
+      <view class="recording-inner">
146
+        <text class="recording-icon">🎤</text>
147
+        <text class="recording-tip">松手发送,上滑取消</text>
148
+        <view class="recording-waves">
149
+          <view v-for="i in 5" :key="i" class="wave" :style="{ animationDelay: (i * 0.1) + 's' }" />
150
+        </view>
151
+      </view>
152
+    </view>
132 153
   </view>
133 154
 </template>
134 155
 
135 156
 <script>
136
-const AI_URL = 'https://airport.samsundot.com/api/query-stream'
157
+const AI_BASE = 'http://airport-test.samsundot.com:9015'
158
+const AI_URL  = AI_BASE + '/api/query-stream'
159
+const ASR_URL = AI_BASE + '/api/asr'
137 160
 
138 161
 export default {
139 162
   name: 'AiChat',
@@ -147,9 +170,79 @@ export default {
147 170
       sessionId: 'app_' + Date.now(),
148 171
       history: [],
149 172
       exampleTags: ['各班组查获排名', '打火机查获数量', '旅检一部人员资质', '全站党员人数'],
173
+      // 录音相关
174
+      recording: false,
175
+      mediaRecorder: null,
176
+      audioChunks: [],
177
+      recordCancelled: false,
150 178
     }
151 179
   },
152 180
   methods: {
181
+    // ============ 录音相关 ============
182
+    async startRecord(e) {
183
+      if (this.loading) return
184
+      this.recordCancelled = false
185
+      this.audioChunks = []
186
+
187
+      try {
188
+        const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
189
+        this.mediaRecorder = new MediaRecorder(stream)
190
+        this.mediaRecorder.ondataavailable = (ev) => {
191
+          if (ev.data.size > 0) this.audioChunks.push(ev.data)
192
+        }
193
+        this.mediaRecorder.onstop = () => {
194
+          stream.getTracks().forEach(t => t.stop())
195
+          if (!this.recordCancelled && this.audioChunks.length) {
196
+            this.submitAudio()
197
+          }
198
+        }
199
+        this.mediaRecorder.start()
200
+        this.recording = true
201
+      } catch (err) {
202
+        uni.showToast({ title: '无法访问麦克风', icon: 'none' })
203
+      }
204
+    },
205
+
206
+    stopRecord(e) {
207
+      if (!this.recording || !this.mediaRecorder) return
208
+      this.recording = false
209
+      this.mediaRecorder.stop()
210
+    },
211
+
212
+    cancelRecord(e) {
213
+      if (!this.recording || !this.mediaRecorder) return
214
+      this.recordCancelled = true
215
+      this.recording = false
216
+      this.mediaRecorder.stop()
217
+    },
218
+
219
+    async submitAudio() {
220
+      const mimeType = (this.audioChunks[0] && this.audioChunks[0].type) || 'audio/webm'
221
+      const blob = new Blob(this.audioChunks, { type: mimeType })
222
+      const ext = mimeType.includes('mp4') ? 'm4a' : mimeType.includes('ogg') ? 'ogg' : 'webm'
223
+
224
+      const formData = new FormData()
225
+      formData.append('file', blob, 'voice.' + ext)
226
+
227
+      uni.showLoading({ title: '识别中...' })
228
+      try {
229
+        const resp = await fetch(ASR_URL, { method: 'POST', body: formData })
230
+        const data = await resp.json()
231
+        uni.hideLoading()
232
+        if (data.success && data.text) {
233
+          this.inputText = data.text
234
+          // 自动发送
235
+          this.$nextTick(() => this.sendMessage())
236
+        } else {
237
+          uni.showToast({ title: data.message || '未识别到内容', icon: 'none' })
238
+        }
239
+      } catch (err) {
240
+        uni.hideLoading()
241
+        uni.showToast({ title: '网络异常,请重试', icon: 'none' })
242
+      }
243
+    },
244
+
245
+    // ============ 导航 ============
153 246
     goBack() {
154 247
       uni.navigateBack()
155 248
     },
@@ -545,4 +638,65 @@ $text-sub: #999;
545 638
   &.disabled { background: #ccc; }
546 639
 }
547 640
 .send-text { color: #fff; font-size: 26rpx; }
641
+
642
+/* 麦克风按钮 */
643
+.mic-btn {
644
+  width: 72rpx;
645
+  height: 72rpx;
646
+  border-radius: 50%;
647
+  background: #f0f0f0;
648
+  display: flex;
649
+  align-items: center;
650
+  justify-content: center;
651
+  flex-shrink: 0;
652
+  transition: background 0.2s;
653
+  &.recording {
654
+    background: #ff4d4f;
655
+    animation: pulse 0.8s infinite;
656
+  }
657
+}
658
+.mic-icon { font-size: 32rpx; line-height: 1; }
659
+
660
+@keyframes pulse {
661
+  0%, 100% { transform: scale(1); }
662
+  50% { transform: scale(1.12); }
663
+}
664
+
665
+/* 录音浮层 */
666
+.recording-overlay {
667
+  position: fixed;
668
+  inset: 0;
669
+  background: rgba(0,0,0,.5);
670
+  display: flex;
671
+  align-items: center;
672
+  justify-content: center;
673
+  z-index: 200;
674
+}
675
+.recording-inner {
676
+  background: #fff;
677
+  border-radius: 24rpx;
678
+  padding: 60rpx 80rpx;
679
+  display: flex;
680
+  flex-direction: column;
681
+  align-items: center;
682
+  gap: 20rpx;
683
+}
684
+.recording-icon { font-size: 80rpx; }
685
+.recording-tip { font-size: 26rpx; color: #666; }
686
+.recording-waves {
687
+  display: flex;
688
+  gap: 8rpx;
689
+  align-items: center;
690
+  height: 50rpx;
691
+}
692
+.wave {
693
+  width: 8rpx;
694
+  background: #1890ff;
695
+  border-radius: 4rpx;
696
+  animation: wave 0.8s infinite ease-in-out alternate;
697
+}
698
+@keyframes wave {
699
+  0%  { height: 10rpx; }
700
+  100%{ height: 50rpx; }
701
+}
548 702
 </style>