|
|
@@ -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>
|