feat: realistic face avatar + voice input + ASR endpoint

This commit is contained in:
yuzhiran
2026-06-12 15:32:04 +08:00
parent 6fe84b6ef8
commit 8191cf4b41
26 changed files with 1934 additions and 228 deletions
+56 -2
View File
@@ -53,11 +53,14 @@
</scroll-view>
<view class="input-bar" v-if="!isComplete">
<view class="mic-btn" :class="{ recording: isRecording }" @touchstart="startRecord" @touchend="stopRecord" @touchcancel="stopRecord">
<text class="mic-icon">🎤</text>
</view>
<view class="input-box">
<textarea class="input-area" v-model="inputText" placeholder="输入你的回答..." :auto-height="true" :maxlength="2000" :disabled="aiLoading" @confirm="sendAnswer" />
</view>
<view class="send-btn" :class="{ disabled: !inputText.trim() || aiLoading }" @click="sendAnswer">
<text class="send-icon"></text>
<view class="send-btn" :class="{ disabled: (!inputText.trim() && !isRecording) || aiLoading }" @click="sendAnswer">
<text class="send-icon">{{ isRecording ? '◉' : '➤' }}</text>
</view>
</view>
@@ -88,6 +91,8 @@ const aiSpeechText = ref('')
const aiAudioUrl = ref('')
const isSpeaking = ref(false)
const dhRef = ref(null)
const isRecording = ref(false)
let recorder = null
let timerSeconds = 0
let timerInterval = null
@@ -227,6 +232,43 @@ const confirmExit = () => {
success: (r) => { if (r.confirm) uni.navigateBack() },
})
}
function startRecord() {
if (aiLoading.value || isComplete.value) return
isRecording.value = true
recorder = uni.getRecorderManager()
recorder.onStart(() => {})
recorder.onError(() => { isRecording.value = false })
recorder.start({ format: 'mp3' })
uni.vibrateShort({ type: 'medium' })
}
function stopRecord() {
if (!recorder || !isRecording.value) return
isRecording.value = false
recorder.stop()
recorder.onStop(async (res) => {
if (!res.tempFilePath) return
const audioPath = res.tempFilePath
try {
const uploadRes = await uni.uploadFile({
url: api('/asr/recognize'),
filePath: audioPath,
name: 'audio',
header: { 'Authorization': `Bearer ${token.value}` },
})
if (uploadRes.statusCode === 200 && uploadRes.data) {
const data = typeof uploadRes.data === 'string' ? JSON.parse(uploadRes.data) : uploadRes.data
if (data.text) {
inputText.value = data.text
uni.vibrateShort({ type: 'light' })
return
}
}
} catch {}
uni.showToast({ title: '语音识别失败,请手动输入', icon: 'none' })
})
}
</script>
<style scoped>
@@ -300,6 +342,18 @@ const confirmExit = () => {
}
.input-box { flex: 1; background: var(--color-bg); border-radius: var(--radius-md); padding: 12rpx 20rpx; }
.input-area { width: 100%; font-size: 26rpx; color: var(--color-text); max-height: 160rpx; line-height: 1.5; }
.mic-btn {
width: 64rpx; height: 64rpx; border-radius: 50%; background: #F3F4F6;
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
transition: all 0.2s;
}
.mic-btn:active { transform: scale(0.9); }
.mic-btn.recording { background: #FEE2E2; animation: mic-pulse 1s infinite; }
.mic-icon { font-size: 28rpx; }
@keyframes mic-pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4); }
50% { box-shadow: 0 0 0 16rpx rgba(239, 68, 68, 0); }
}
.send-btn {
width: 80rpx; height: 80rpx; background: linear-gradient(135deg, var(--color-gradient-start), var(--color-gradient-mid));
border-radius: 50%; display: flex; align-items: center; justify-content: center; flex-shrink: 0;