add:增加tts测试功能
This commit is contained in:
18
nladmin-ui/src/api/voice.js
Normal file
18
nladmin-ui/src/api/voice.js
Normal 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
|
||||
})
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user