403 lines
11 KiB
Vue
403 lines
11 KiB
Vue
<template>
|
|
<view class="voice-popup" v-if="visible">
|
|
<view class="voice-mask" @click="hideVoicePanel"></view>
|
|
<view class="voice-input-panel">
|
|
<view class="voice-header">
|
|
<text>{{ $lang('ai-chat.index.voiceInputTitle') }}</text>
|
|
<button class="close-btn" @click="hideVoicePanel">
|
|
<text class="iconfont icon-close"></text>
|
|
</button>
|
|
</view>
|
|
<view class="voice-content">
|
|
<view class="voice-wave" :class="{ recording: voiceInputing }">
|
|
<view
|
|
v-for="i in 8"
|
|
:key="i"
|
|
class="wave-bar"
|
|
:style="waveBarStyle(i)"></view>
|
|
</view>
|
|
<text class="voice-tip">{{ currentTip }}</text>
|
|
<text v-if="voiceInputing" class="voice-duration">{{ formattedDuration }}</text>
|
|
</view>
|
|
<view class="voice-actions">
|
|
<button class="voice-btn" :class="{ 'is-recording': voiceInputing }" @click="toggleVoiceInput">
|
|
<text class="iconfont" :class="voiceInputing ? 'icon-stop' : 'icon-voice'"></text>
|
|
</button>
|
|
<text v-if="voiceInputing" class="cancel-link" @click="cancelVoiceInput">{{ $lang('ai-chat.index.cancel') }}</text>
|
|
</view>
|
|
</view>
|
|
</view>
|
|
</template>
|
|
|
|
<script>
|
|
import { createVoiceRecorder } from '@/common/js/voice-recorder.js'
|
|
/**
|
|
* ai-chat-voice-panel - 语音输入面板组件
|
|
*
|
|
* PR-FRONTEND-VOICE-INPUT (2026-09-24): 真录音 UI。
|
|
* - 接入 common/js/voice-recorder.js,跨 H5 / 微信小程序 / App-Plus / 鸿蒙元服务
|
|
* - 波形振幅由 progress 事件 (level 0..1) 驱动
|
|
* - 录音上限 60s,自动停止
|
|
* - 关闭面板时如果还在录音,先 cancel 释放资源
|
|
*
|
|
* Props:
|
|
* visible: Boolean - 控制显示/隐藏
|
|
*
|
|
* Events:
|
|
* update:visible - 同步关闭状态
|
|
* close - 请求关闭面板
|
|
* audio-ready - 录音完成, 参数 { filePath, blob, duration, mime, size }
|
|
*/
|
|
export default {
|
|
name: 'AiChatVoicePanel',
|
|
|
|
props: {
|
|
visible: {
|
|
type: Boolean,
|
|
default: false
|
|
}
|
|
},
|
|
|
|
data() {
|
|
return {
|
|
voiceInputing: false,
|
|
// 录音中的振幅 0..1;8 根波形的高度按 level 抖动
|
|
voiceLevel: 0,
|
|
// 录音累计时长 (秒,整数)
|
|
voiceDuration: 0,
|
|
_durationTimer: null,
|
|
_voiceRecorder: null,
|
|
_onProgress: null,
|
|
_onError: null,
|
|
// 防止 stop 回调被多次触发 (录音器自己 stop + 用户手动 stop 重叠)
|
|
_resolved: false
|
|
}
|
|
},
|
|
|
|
computed: {
|
|
currentTip() {
|
|
if (this.voiceInputing) {
|
|
return this.$lang('ai-chat.index.recordingTip')
|
|
}
|
|
return this.$lang('ai-chat.index.startRecordTip')
|
|
},
|
|
formattedDuration() {
|
|
const s = Math.max(0, Math.floor(this.voiceDuration))
|
|
return s + '″'
|
|
}
|
|
},
|
|
|
|
watch: {
|
|
visible(val) {
|
|
if (val) {
|
|
// 每次重新打开都重置
|
|
this.voiceInputing = false
|
|
this.voiceLevel = 0
|
|
this.voiceDuration = 0
|
|
this._resolved = false
|
|
} else {
|
|
// 关闭时如果还在录音,丢弃
|
|
this._discardRecorderIfAny()
|
|
}
|
|
}
|
|
},
|
|
|
|
beforeDestroy() {
|
|
this._discardRecorderIfAny()
|
|
},
|
|
|
|
methods: {
|
|
hideVoicePanel() {
|
|
// 关闭时若还在录,默认作废;如需保留产物应先点 stop 按钮
|
|
this._discardRecorderIfAny()
|
|
this.$emit('update:visible', false)
|
|
this.$emit('close')
|
|
},
|
|
|
|
toggleVoiceInput() {
|
|
if (this.voiceInputing) {
|
|
this._finishRecording()
|
|
} else {
|
|
this._beginRecording()
|
|
}
|
|
},
|
|
|
|
cancelVoiceInput() {
|
|
this._discardRecorderIfAny()
|
|
this.hideVoicePanel()
|
|
},
|
|
|
|
waveBarStyle(i) {
|
|
// 每根 bar 基础高度 24rpx + level*振幅 + 不同 bar 的 offset (错相)
|
|
const base = 24
|
|
const offset = ((i * 17) % 32) + 12 // 12..44
|
|
const levelPx = Math.round(this.voiceLevel * 80)
|
|
const h = Math.max(16, base + offset + levelPx) + 'rpx'
|
|
const delay = (i * 0.08) + 's'
|
|
return { height: h, animationDelay: delay }
|
|
},
|
|
|
|
_beginRecording() {
|
|
if (this._voiceRecorder) return
|
|
const rec = createVoiceRecorder({ maxDuration: 60 * 1000 })
|
|
this._voiceRecorder = rec
|
|
this._onProgress = ({ level }) => {
|
|
// 平滑一下,避免波形抖动太狠
|
|
this.voiceLevel = this.voiceLevel * 0.4 + level * 0.6
|
|
}
|
|
this._onError = (err) => {
|
|
console.warn('[voice-panel] recorder error:', err)
|
|
console.error('[voice-panel] recorder error raw:', JSON.stringify(err), 'errMsg=', err && err.errMsg, 'message=', err && err.message)
|
|
uni.showToast({ title: this._voiceErrMsg(err) || this.$lang('ai-chat.index.voiceInputFailed'), icon: 'none' })
|
|
this._discardRecorderIfAny()
|
|
}
|
|
rec.on('progress', this._onProgress)
|
|
rec.on('error', this._onError)
|
|
|
|
rec.start()
|
|
.then(() => {
|
|
this.voiceInputing = true
|
|
this.voiceDuration = 0
|
|
this._startDurationTimer()
|
|
})
|
|
.catch((err) => {
|
|
this._teardownRecorder()
|
|
uni.showToast({ title: this._voiceErrMsg(err) || this.$lang('ai-chat.index.voiceStartFailed'), icon: 'none' })
|
|
})
|
|
},
|
|
|
|
async _finishRecording() {
|
|
if (!this._voiceRecorder || this._resolved) return
|
|
this._resolved = true
|
|
this._stopDurationTimer()
|
|
try {
|
|
const result = await this._voiceRecorder.stop()
|
|
if (!result || !result.filePath) {
|
|
// 录音产物为空,可能是被系统打断
|
|
uni.showToast({ title: this.$lang('ai-chat.index.voiceStopFailed'), icon: 'none' })
|
|
this._teardownRecorder()
|
|
this.voiceInputing = false
|
|
return
|
|
}
|
|
this._teardownRecorder()
|
|
this.voiceInputing = false
|
|
this.$emit('audio-ready', {
|
|
filePath: result.filePath,
|
|
blob: result.blob || null,
|
|
fileName: result.fileName || '',
|
|
duration: result.duration,
|
|
mime: result.mime,
|
|
size: result.size
|
|
})
|
|
this.hideVoicePanel()
|
|
} catch (e) {
|
|
this._teardownRecorder()
|
|
this.voiceInputing = false
|
|
uni.showToast({ title: this._voiceErrMsg(e) || this.$lang('ai-chat.index.voiceInputFailed'), icon: 'none' })
|
|
}
|
|
},
|
|
|
|
_discardRecorderIfAny() {
|
|
if (!this._voiceRecorder) return
|
|
try { this._voiceRecorder.cancel() } catch (e) { /* noop */ }
|
|
this._teardownRecorder()
|
|
this.voiceInputing = false
|
|
},
|
|
|
|
_teardownRecorder() {
|
|
this._stopDurationTimer()
|
|
if (this._voiceRecorder) {
|
|
if (this._onProgress) this._voiceRecorder.off('progress', this._onProgress)
|
|
if (this._onError) this._voiceRecorder.off('error', this._onError)
|
|
}
|
|
this._voiceRecorder = null
|
|
this._onProgress = null
|
|
this._onError = null
|
|
this._resolved = false
|
|
this.voiceLevel = 0
|
|
},
|
|
|
|
// PR-FRONTEND-VOICE-INPUT (2026-09-24): 把 voice-recorder 抛出的 code 翻译成 i18n 文案
|
|
_voiceErrMsg(err) {
|
|
if (!err) return ''
|
|
switch (err.code) {
|
|
case 'NOT_SUPPORTED':
|
|
case 'NO_RECORDER_MANAGER':
|
|
return this.$lang('ai-chat.index.voiceNotSupported')
|
|
case 'H5_NO_MIC_API':
|
|
case 'ALREADY_STARTED':
|
|
return this.$lang('ai-chat.index.voiceStartFailed')
|
|
case 'UNI_RECORD_ERROR':
|
|
return (err && (err.errMsg || err.message)) || this.$lang('ai-chat.index.voiceStopFailed')
|
|
default:
|
|
return err.message || ''
|
|
}
|
|
},
|
|
|
|
_startDurationTimer() {
|
|
this._stopDurationTimer()
|
|
this._durationTimer = setInterval(() => {
|
|
this.voiceDuration += 1
|
|
}, 1000)
|
|
},
|
|
|
|
_stopDurationTimer() {
|
|
if (this._durationTimer) {
|
|
clearInterval(this._durationTimer)
|
|
this._durationTimer = null
|
|
}
|
|
}
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<style lang="scss" scoped>
|
|
|
|
/* 引入图标字体: 由 main.wxss 全局提供, 不再重复 import */
|
|
@import '../styles/ai-chat-global.scss';
|
|
|
|
.voice-popup {
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
z-index: 999;
|
|
|
|
.voice-mask {
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
background-color: rgba(0, 0, 0, 0.4);
|
|
}
|
|
|
|
.voice-input-panel {
|
|
position: absolute;
|
|
top: 50%;
|
|
left: 50%;
|
|
transform: translate(-50%, -50%);
|
|
background-color: $bg-card;
|
|
border-radius: $radius-lg;
|
|
padding: 48rpx;
|
|
width: 60%;
|
|
max-width: 520rpx;
|
|
box-shadow: 0 8rpx 36rpx rgba(0, 0, 0, 0.15);
|
|
|
|
.voice-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 32rpx;
|
|
border-bottom: 2rpx solid #f0f4f8;
|
|
padding-bottom: 16rpx;
|
|
|
|
text {
|
|
font-size: 34rpx;
|
|
font-weight: 600;
|
|
color: $color-text;
|
|
}
|
|
|
|
.close-btn {
|
|
width: 56rpx;
|
|
height: 56rpx;
|
|
border-radius: 50%;
|
|
background-color: $bg-main;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
border: none;
|
|
|
|
.iconfont {
|
|
font-size: 28rpx;
|
|
color: $color-text-light;
|
|
}
|
|
}
|
|
}
|
|
|
|
.voice-content {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
margin-bottom: 32rpx;
|
|
|
|
.voice-wave {
|
|
width: 280rpx;
|
|
height: 160rpx;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
margin-bottom: 24rpx;
|
|
|
|
&.recording .wave-bar {
|
|
animation: wave 1.0s infinite ease-in-out;
|
|
}
|
|
|
|
.wave-bar {
|
|
width: 8rpx;
|
|
margin: 0 4rpx;
|
|
background-color: $color-primary;
|
|
border-radius: 4rpx;
|
|
transition: height 0.12s ease-out;
|
|
animation: none;
|
|
}
|
|
}
|
|
|
|
.voice-tip {
|
|
font-size: 28rpx;
|
|
color: $color-text-light;
|
|
}
|
|
|
|
.voice-duration {
|
|
font-size: 36rpx;
|
|
font-weight: 600;
|
|
color: $color-primary;
|
|
margin-top: 8rpx;
|
|
}
|
|
}
|
|
|
|
.voice-actions {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
|
|
.voice-btn {
|
|
width: 120rpx;
|
|
height: 120rpx;
|
|
border-radius: 50%;
|
|
background-color: $color-primary;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
box-shadow: 0 8rpx 24rpx rgba(108, 142, 191, 0.3);
|
|
border: none;
|
|
transition: transform 0.15s ease-out, background-color 0.15s ease-out;
|
|
|
|
&.is-recording {
|
|
background-color: #e94e4e;
|
|
transform: scale(0.92);
|
|
}
|
|
|
|
.iconfont {
|
|
font-size: 56rpx;
|
|
color: white;
|
|
}
|
|
}
|
|
|
|
.cancel-link {
|
|
margin-top: 20rpx;
|
|
font-size: 26rpx;
|
|
color: $color-text-light;
|
|
padding: 8rpx 16rpx;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@keyframes wave {
|
|
0%, 100% { transform: scaleY(0.6); }
|
|
50% { transform: scaleY(1.15); }
|
|
}
|
|
</style> |