add:增加tts测试功能

This commit is contained in:
zhangzq
2026-04-15 13:04:00 +08:00
parent ff0185ffd4
commit 9c8358c8ff
18 changed files with 1123 additions and 2 deletions

View File

@@ -0,0 +1,18 @@
import request from '@/utils/request'
export function voiceAsr(formData) {
return request({
url: '/api/wms/voice/asr',
method: 'post',
data: formData,
headers: { 'Content-Type': 'multipart/form-data' }
})
}
export function voiceConfirm(data) {
return request({
url: '/api/wms/voice/confirm',
method: 'post',
data
})
}

View File

@@ -23,6 +23,11 @@
</el-tooltip>-->
</template>
<el-tooltip content="语音助手" effect="dark" placement="bottom">
<el-button class="right-menu-item hover-effect voice-btn" type="text" icon="el-icon-microphone" @click="toggleVoicePanel">
语音
</el-button>
</el-tooltip>
<img :src="Avatar" class="user-avatar">
<el-dropdown class="avatar-container right-menu-item hover-effect" trigger="hover">
<div class="avatar-wrapper">
@@ -57,6 +62,37 @@
</el-dropdown-menu>
</el-dropdown>
</div>
<transition name="el-zoom-in-bottom">
<div v-if="voicePanelVisible" class="voice-float">
<div class="voice-header">
<div class="title">
<i :class="['status-dot', wsStatus]" />
语音交互
<span v-if="connectError" class="error-text">{{ connectError }}</span>
</div>
<div class="actions">
<el-button size="mini" type="text" @click="resetVoice">重置</el-button>
<el-button size="mini" type="text" @click="toggleVoicePanel">关闭</el-button>
</div>
</div>
<div ref="voiceScroll" class="voice-body">
<div v-for="(msg, idx) in messages" :key="idx" :class="['msg', msg.role]">
<div class="meta">{{ msg.role === 'user' ? '我' : '系统' }} · {{ msg.time }}</div>
<div class="bubble">{{ msg.content }}</div>
<div v-if="msg.role==='system' && msg.textRaw" class="bubble-raw">{{ msg.textRaw }}</div>
<div v-if="msg.role==='system' && msg.textRaw && !msg.confirmed" class="confirm-box">
<el-button size="mini" type="primary" @click="confirmText(msg.textRaw)">语音确认</el-button>
</div>
</div>
<div v-if="messages.length === 0" class="empty">请点击下方按住说话开始交互</div>
</div>
<div class="voice-footer">
<el-button :type="recording ? 'danger' : 'primary'" circle class="mic" icon="el-icon-microphone" @mousedown.native.prevent="startRecording" @mouseup.native.prevent="stopRecording" @mouseleave.native.prevent="stopRecording" />
<div class="hint">按住说话 · 松开发送</div>
</div>
</div>
</transition>
</div>
</template>
@@ -73,6 +109,7 @@ import Search from '@/components/HeaderSearch'
import Avatar from '@/assets/images/avatar.png'
import NoticeIcon from '@/views/system/notice/NoticeIcon.vue'
import NoticeIconReader from '@/views/system/notice/NoticeIconReader.vue'
import { voiceAsr, voiceConfirm } from '@/api/voice'
export default {
components: {
@@ -90,12 +127,23 @@ export default {
return {
Avatar: Avatar,
dialogVisible: false,
language: '简体中文'
language: '简体中文',
voicePanelVisible: false,
wsStatus: 'http',
connectError: '',
messages: [],
recording: false,
mediaRecorder: null,
audioChunks: [],
pendingSend: false
}
},
created() {
this.initLang()
},
beforeDestroy() {
this.teardownVoice()
},
computed: {
...mapGetters([
'sidebar',
@@ -162,6 +210,99 @@ export default {
this.$store.dispatch('LogOut').then(() => {
location.reload()
})
},
toggleVoicePanel() {
this.voicePanelVisible = !this.voicePanelVisible
if (!this.voicePanelVisible) {
this.teardownVoice()
}
},
resetVoice() {
this.messages = []
this.pendingSend = false
this.audioChunks = []
this.recording = false
},
teardownVoice() {
if (this.mediaRecorder) {
this.mediaRecorder.stop()
this.mediaRecorder = null
}
this.recording = false
this.pendingSend = false
this.audioChunks = []
},
startRecording() {
if (this.recording) return
navigator.mediaDevices.getUserMedia({ audio: true }).then((stream) => {
this.audioChunks = []
this.mediaRecorder = new MediaRecorder(stream)
this.mediaRecorder.ondataavailable = (e) => {
if (e.data.size > 0) this.audioChunks.push(e.data)
}
this.mediaRecorder.onstop = () => {
const blob = new Blob(this.audioChunks, { type: 'audio/webm' })
this.sendAudio(blob)
stream.getTracks().forEach(t => t.stop())
this.recording = false
}
this.mediaRecorder.start()
this.recording = true
}).catch(() => {
this.$message.error('无法获取麦克风权限')
})
},
stopRecording() {
if (!this.recording || !this.mediaRecorder) return
this.mediaRecorder.stop()
},
sendAudio(blob) {
this.pendingSend = true
this.appendMessage('user', '上传语音中...')
const formData = new FormData()
formData.append('audio', blob, 'voice.webm')
voiceAsr(formData).then(res => {
const text = res.data && res.data ? res.data.text : ''
this.markUserDone('语音已上传')
if (text) {
this.appendMessage('system', text, text)
} else {
this.appendMessage('system', '未识别到有效文本', '')
}
}).catch(err => {
console.error(err)
this.markUserDone('上传失败')
this.appendMessage('system', 'ASR 调用失败', '')
})
},
confirmText(text) {
this.appendMessage('user', `语音确认: ${text}`)
voiceConfirm({ text }).then(() => {
this.appendMessage('system', '已提交任务', text)
}).catch(err => {
console.error(err)
this.appendMessage('system', '任务提交失败', text)
})
},
appendMessage(role, content, textRaw = '') {
const now = new Date()
const time = `${now.getHours().toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')}:${now.getSeconds().toString().padStart(2, '0')}`
this.messages.push({ role, content, time, textRaw })
this.$nextTick(() => {
if (this.$refs.voiceScroll) {
this.$refs.voiceScroll.scrollTop = this.$refs.voiceScroll.scrollHeight
}
})
},
markUserDone(doneText) {
if (!this.pendingSend) return
for (let i = this.messages.length - 1; i >= 0; i -= 1) {
if (this.messages[i].role === 'user' && this.messages[i].content.includes('上传语音')) {
this.messages[i].content = doneText
this.pendingSend = false
break
}
}
}
}
}
@@ -228,6 +369,10 @@ export default {
}
}
}
.voice-btn {
font-weight: 600;
color: #0c8bff;
}
.user-avatar {
cursor: pointer;
width: 40px;
@@ -249,4 +394,118 @@ export default {
}
}
}
.voice-float {
position: fixed;
right: 24px;
bottom: 24px;
width: 360px;
background: #0e1a2b;
color: #f6f8fb;
border-radius: 14px;
box-shadow: 0 10px 30px rgba(0,0,0,0.25);
overflow: hidden;
display: flex;
flex-direction: column;
backdrop-filter: blur(6px);
border: 1px solid rgba(255,255,255,0.08);
.voice-header {
padding: 10px 12px;
display: flex;
align-items: center;
justify-content: space-between;
background: linear-gradient(135deg, #102544, #0e1a2b);
.title {
font-weight: 700;
display: flex;
align-items: center;
gap: 8px;
}
.actions {
display: flex;
gap: 4px;
}
.status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: #38bdf8;
}
.error-text {
color: #f87171;
font-size: 12px;
}
}
.voice-body {
flex: 1;
max-height: 320px;
overflow-y: auto;
padding: 10px 12px;
background: linear-gradient(180deg, #0e1a2b 0%, #0b1320 100%);
}
.msg {
margin-bottom: 10px;
.meta {
font-size: 12px;
color: #9ca3af;
margin-bottom: 4px;
}
.bubble {
display: inline-block;
padding: 10px 12px;
border-radius: 10px;
line-height: 1.4;
max-width: 92%;
}
.bubble-raw {
margin-top: 6px;
font-size: 12px;
color: #cbd5e1;
opacity: 0.8;
}
.confirm-box {
margin-top: 6px;
}
&.user .bubble {
background: #133b70;
color: #dbeafe;
}
&.system .bubble {
background: #111827;
border: 1px solid rgba(255,255,255,0.08);
color: #e5e7eb;
}
}
.empty {
text-align: center;
color: #9ca3af;
font-size: 14px;
padding: 30px 10px;
}
.voice-footer {
padding: 12px;
display: flex;
align-items: center;
gap: 12px;
border-top: 1px solid rgba(255,255,255,0.06);
background: #0f1c2f;
.mic {
width: 48px;
height: 48px;
font-size: 20px;
box-shadow: 0 10px 20px rgba(0,0,0,0.25);
}
.hint {
color: #9ca3af;
font-size: 14px;
}
}
}
</style>