add:语音控制功能-创建前端组件

This commit is contained in:
Afly
2026-04-15 08:54:14 +08:00
parent ff0185ffd4
commit f9ca8583fb
8 changed files with 646 additions and 4 deletions

View File

@@ -0,0 +1,52 @@
package org.nl.wms.system_manage.controller.speech;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* @author Zhao Ya Fei
* @date 2026-04-14
* 根据语音输入执行任务
*/
@Slf4j
@RestController
@RequestMapping("/speech")
public class SysSpeechController {
/**
* 健康检查
* @return
*/
@GetMapping("/health")
public ResponseEntity<Object> health(){
return ResponseEntity.noContent().build();
}
/**
* 语音转文字
* @return
*/
@PostMapping("/asr")
public ResponseEntity<Object> asr(){
return ResponseEntity.noContent().build();
}
/**
* 文字转语音
* @return
*/
@PostMapping("/tts")
public ResponseEntity<Object> tts(){
return ResponseEntity.noContent().build();
}
@PostMapping("/confirm")
public ResponseEntity<Object> confirm(){
return ResponseEntity.noContent().build();
}
}

View File

@@ -0,0 +1,11 @@
package org.nl.wms.system_manage.service.speech;
public interface SysSpeechService {
/**
* 语音识别服务健康检查
*
* @return Boolean
*/
Boolean health();
}

View File

@@ -0,0 +1,11 @@
package org.nl.wms.system_manage.service.speech.enums;
/**
* @author Zhao Ya Fei
* @date 2026-04-14
* @desc 语音识别URL
*/
public class SpeechUrlConstant {
}

View File

@@ -0,0 +1,20 @@
package org.nl.wms.system_manage.service.speech.impl;
import lombok.extern.slf4j.Slf4j;
import org.nl.wms.system_manage.service.speech.SysSpeechService;
import org.springframework.stereotype.Service;
/**
* @author Zhao Ya Fei
* @date 2026-04-14
* 语音识别服务实现类
*/
@Slf4j
@Service
public class SysSpeechServiceImpl implements SysSpeechService {
@Override
public Boolean health() {
return null;
}
}

View File

@@ -0,0 +1,531 @@
<template>
<el-dialog
:visible.sync="visible"
:show-close="false"
:close-on-click-modal="false"
width="400px"
custom-class="voice-input-dialog"
:modal="false"
:before-close="handleClose"
>
<div class="voice-input-container">
<!-- 关闭按钮 -->
<div class="close-btn" @click="handleClose">
<i class="el-icon-close" />
</div>
<!-- 标题 -->
<div class="dialog-header">
<span class="title">语音输入</span>
</div>
<!-- 录音按钮区域 - 只在未识别成功时显示 -->
<div v-if="!showTranscript" class="record-section">
<div
class="record-btn"
:class="{ 'recording': isRecording, 'disabled': isTranscribing }"
:style="{ 'pointer-events': isTranscribing ? 'none' : 'auto' }"
@mousedown="startRecording"
@mouseup="stopRecording"
@mouseleave="isRecording && stopRecording()"
@touchstart.prevent="startRecording"
@touchend.prevent="stopRecording"
>
<i :class="isRecording ? 'el-icon-microphone recording-icon' : 'el-icon-microphone'" />
<span class="record-text">{{ isRecording ? '录音中...' : '按住说话' }}</span>
</div>
<p class="recording-tip" :class="{ 'visible': isRecording }">松开后结束录音</p>
<!-- 识别中提示 - 固定位置用opacity控制显示不占位变化 -->
<div class="transcribing-status" :class="{ 'visible': isTranscribing }">
<i class="el-icon-loading" /> 正在识别中...
</div>
</div>
<!-- 语音转文字结果区域 - 识别成功后才显示 -->
<div v-if="showTranscript" class="transcript-section">
<el-input
v-model="transcript"
type="textarea"
:rows="3"
placeholder="语音转文字结果..."
/>
<div class="transcript-actions">
<el-button type="primary" size="small" @click="confirmInput">确认</el-button>
<el-button size="small" @click="reRecognize">重新识别</el-button>
</div>
</div>
<!-- 处理结果展示区域 - 只在确认后显示 -->
<div v-if="isConfirmed && result" class="result-section">
<div class="result-header">
<i class="el-icon-success" />
<span>处理结果</span>
</div>
<div class="result-content">
{{ result }}
</div>
</div>
</div>
</el-dialog>
</template>
<script>
export default {
name: 'VoiceInputDialog',
data() {
return {
visible: false,
isRecording: false,
isTranscribing: false,
showTranscript: false, // 是否显示识别结果文本框
transcript: '',
result: '',
isConfirmed: false,
recognition: null,
recordStartTime: 0,
minRecordTime: 500, // 最小录音时间500ms
isStopping: false // 防止重复停止
}
},
mounted() {
this.initSpeechRecognition()
},
beforeDestroy() {
this.stopRecording()
if (this.recognition) {
try {
this.recognition.stop()
} catch (e) {
// ignore
}
}
},
methods: {
// 初始化语音识别
initSpeechRecognition() {
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition
if (SpeechRecognition) {
this.recognition = new SpeechRecognition()
this.recognition.continuous = false
this.recognition.interimResults = true
this.recognition.lang = 'zh-CN'
this.recognition.onresult = (event) => {
let finalTranscript = ''
let interimTranscript = ''
for (let i = event.resultIndex; i < event.results.length; i++) {
const transcript = event.results[i][0].transcript
if (event.results[i].isFinal) {
finalTranscript += transcript
} else {
interimTranscript += transcript
}
}
if (finalTranscript) {
this.transcript = finalTranscript
this.isTranscribing = false
// 识别成功后显示文本框
this.showTranscript = true
} else if (interimTranscript) {
this.transcript = interimTranscript
}
}
this.recognition.onerror = (event) => {
console.error('语音识别错误:', event.error)
this.isTranscribing = false
this.isRecording = false
if (event.error !== 'aborted') {
this.$message.error('语音识别失败,请重试')
}
}
this.recognition.onend = () => {
// 如果识别结束但没有结果
if (this.isTranscribing && !this.transcript) {
this.isTranscribing = false
this.$message.warning('未能识别到语音,请重试')
}
}
}
},
// 检查浏览器是否支持语音识别
checkBrowserSupport() {
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition
if (!SpeechRecognition) {
return { supported: false, message: '您的浏览器不支持语音识别功能请使用Chrome、Edge或Safari浏览器' }
}
return { supported: true }
},
// 请求麦克风权限
async requestMicrophonePermission() {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
stream.getTracks().forEach(track => track.stop())
return { granted: true }
} catch (err) {
console.error('麦克风权限请求失败:', err)
let message = '无法获取麦克风权限'
if (err.name === 'NotAllowedError') {
message = '麦克风权限被拒绝,请在浏览器设置中允许使用麦克风'
} else if (err.name === 'NotFoundError') {
message = '未检测到麦克风设备'
} else if (err.name === 'NotSupportedError') {
message = '当前环境不支持录音功能'
}
return { granted: false, message }
}
},
// 显示对话框(带权限检查)
async show() {
const supportCheck = this.checkBrowserSupport()
if (!supportCheck.supported) {
this.$message.warning(supportCheck.message)
return
}
const permissionCheck = await this.requestMicrophonePermission()
if (!permissionCheck.granted) {
this.$message.warning(permissionCheck.message)
return
}
this.visible = true
this.reset()
},
// 开始录音
startRecording() {
if (this.isTranscribing) {
this.$message.warning('正在识别中,请稍候...')
return
}
if (!this.recognition) {
this.$message.warning('您的浏览器不支持语音识别功能')
return
}
if (this.isStopping) return
this.recordStartTime = Date.now()
this.isRecording = true
this.transcript = ''
this.result = ''
this.isConfirmed = false
try {
this.recognition.start()
} catch (e) {
console.error('启动语音识别失败:', e)
this.isRecording = false
this.recordStartTime = 0
}
},
// 停止录音
stopRecording() {
if (!this.isRecording) return
const recordDuration = Date.now() - this.recordStartTime
if (recordDuration < this.minRecordTime) {
this.isRecording = false
this.recordStartTime = 0
if (this.recognition) {
try {
this.recognition.abort()
} catch (e) {
// ignore
}
}
this.$message.warning('录音时间太短,请长按说话')
return
}
this.isRecording = false
this.isTranscribing = true
if (this.recognition) {
try {
this.recognition.stop()
} catch (e) {
console.error('停止语音识别失败:', e)
this.isTranscribing = false
}
}
this.recordStartTime = 0
},
// 确认输入
confirmInput() {
if (!this.transcript.trim()) {
this.$message.warning('请先输入或录制语音')
return
}
this.isConfirmed = true
this.processVoiceInput(this.transcript)
},
// 重新识别 - 恢复初始状态
reRecognize() {
this.showTranscript = false
this.transcript = ''
this.result = ''
this.isTranscribing = false
this.isConfirmed = false
},
// 处理语音输入
processVoiceInput(text) {
this.result = `已处理: "${text}"`
},
// 关闭对话框
handleClose() {
this.stopRecording()
this.visible = false
},
// 重置状态
reset() {
this.isRecording = false
this.isTranscribing = false
this.showTranscript = false
this.transcript = ''
this.result = ''
this.isConfirmed = false
this.recordStartTime = 0
}
}
}
</script>
<style lang="scss" scoped>
.voice-input-container {
padding: 10px 20px 20px;
position: relative;
.close-btn {
position: absolute;
top: 10px;
right: 10px;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
border-radius: 4px;
transition: all 0.3s;
&:hover {
background: rgba(0, 0, 0, 0.05);
color: #409EFF;
}
i {
font-size: 18px;
color: #909399;
&:hover {
color: #409EFF;
}
}
}
.dialog-header {
text-align: center;
margin-bottom: 20px;
padding-top: 10px;
.title {
font-size: 18px;
font-weight: 600;
color: #303133;
}
}
.record-section {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 20px;
min-height: 160px;
.record-btn {
width: 100px;
height: 100px;
border-radius: 50%;
background: linear-gradient(135deg, #409EFF 0%, #66b1ff 100%);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s;
box-shadow: 0 4px 12px rgba(64, 158, 255, 0.3);
user-select: none;
flex-shrink: 0;
&:hover {
transform: scale(1.05);
box-shadow: 0 6px 16px rgba(64, 158, 255, 0.4);
}
&:active {
transform: scale(0.95);
}
&.recording {
background: linear-gradient(135deg, #F56C6C 0%, #f78989 100%);
box-shadow: 0 4px 12px rgba(245, 108, 108, 0.3);
animation: pulse 1.5s infinite;
}
&.disabled {
opacity: 0.5;
cursor: not-allowed;
background: linear-gradient(135deg, #909399 0%, #a6a9ad 100%);
}
i {
font-size: 32px;
color: #fff;
margin-bottom: 5px;
&.recording-icon {
animation: shake 0.5s infinite;
}
}
.record-text {
font-size: 12px;
color: #fff;
}
}
.recording-tip {
margin-top: 10px;
font-size: 12px;
color: #F56C6C;
height: 20px;
line-height: 20px;
visibility: hidden;
opacity: 0;
transition: opacity 0.2s;
&.visible {
visibility: visible;
opacity: 1;
}
}
.transcribing-status {
margin-top: 15px;
text-align: center;
color: #909399;
font-size: 14px;
height: 20px; /* 固定高度 */
line-height: 20px;
visibility: hidden;
opacity: 0;
transition: opacity 0.2s;
i {
margin-right: 5px;
}
&.visible {
visibility: visible;
opacity: 1;
}
}
}
.transcript-section {
margin-bottom: 20px;
.transcript-actions {
margin-top: 15px;
text-align: center;
.el-button {
margin: 0 10px;
}
}
}
.result-section {
background: #f0f9eb;
border: 1px solid #c2e7b0;
border-radius: 8px;
padding: 15px;
margin-top: 15px;
.result-header {
display: flex;
align-items: center;
margin-bottom: 10px;
color: #67C23A;
font-weight: 600;
i {
margin-right: 8px;
font-size: 18px;
}
}
.result-content {
color: #606266;
font-size: 14px;
line-height: 1.6;
word-break: break-all;
}
}
}
@keyframes pulse {
0% {
box-shadow: 0 0 0 0 rgba(245, 108, 108, 0.4);
}
70% {
box-shadow: 0 0 0 20px rgba(245, 108, 108, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(245, 108, 108, 0);
}
}
@keyframes shake {
0%, 100% { transform: rotate(0deg); }
25% { transform: rotate(-5deg); }
75% { transform: rotate(5deg); }
}
</style>
<style lang="scss">
.voice-input-dialog {
position: fixed !important;
right: 20px !important;
bottom: 20px !important;
top: auto !important;
left: auto !important;
margin: 0 !important;
.el-dialog__header {
display: none;
}
.el-dialog__body {
padding: 0;
}
}
</style>

View File

@@ -79,6 +79,7 @@ export default {
'moreMenu': 'MoreMenu',
'browses': 'browse',
'fz': 'Full screen zoom',
'speech': 'Voice Control',
'submit': 'Submit Success',
'add': 'Add Success',
'edit': 'Edit Success',

View File

@@ -79,6 +79,7 @@ export default {
'moreMenu': '更多菜单',
'browses': '浏览',
'fz': '全屏缩放',
'speech': '语音控制',
'submit': '提交成功',
'add': '新增成功',
'edit': '编辑成功',

View File

@@ -6,6 +6,13 @@
<div class="right-menu">
<template v-if="device!=='mobile'">
<!-- 语音输入按钮 -->
<el-tooltip :content="$t('common.speech')" effect="dark" placement="bottom">
<div class="right-menu-item hover-effect voice-btn" @click="openVoiceInput">
<i class="el-icon-microphone" />
</div>
</el-tooltip>
<search id="header-search" class="right-menu-item" />
<!-- <el-tooltip content="项目文档" effect="dark" placement="bottom">
@@ -15,10 +22,13 @@
<el-tooltip :content="$t('common.fz')" effect="dark" placement="bottom">
<screenfull id="screenfull" class="right-menu-item hover-effect" />
</el-tooltip>
<notice-icon class="right-menu-item" />
<notice-icon-reader ref="noticeIconReader" />
<notice-icon class="right-menu-item" />
<notice-icon-reader ref="noticeIconReader" />
<voice-input-dialog ref="voiceInputDialog" />
<!-- <el-tooltip content="布局设置" effect="dark" placement="bottom">
<!-- <el-tooltip content="布局设置" effect="dark" placement="bottom">
<size-select id="size-select" class="right-menu-item hover-effect" />
</el-tooltip>-->
@@ -73,6 +83,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 VoiceInputDialog from '@/components/VoiceInputDialog'
export default {
components: {
@@ -84,7 +95,8 @@ export default {
SizeSelect,
Search,
Doc,
TopNav
TopNav,
VoiceInputDialog
},
data() {
return {
@@ -162,6 +174,9 @@ export default {
this.$store.dispatch('LogOut').then(() => {
location.reload()
})
},
openVoiceInput() {
this.$refs.voiceInputDialog && this.$refs.voiceInputDialog.show()
}
}
}