add:语音控制功能-创建前端组件
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package org.nl.wms.system_manage.service.speech;
|
||||
|
||||
public interface SysSpeechService {
|
||||
|
||||
/**
|
||||
* 语音识别服务健康检查
|
||||
*
|
||||
* @return Boolean
|
||||
*/
|
||||
Boolean health();
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
531
nladmin-ui/src/components/VoiceInputDialog/index.vue
Normal file
531
nladmin-ui/src/components/VoiceInputDialog/index.vue
Normal 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>
|
||||
@@ -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',
|
||||
|
||||
@@ -79,6 +79,7 @@ export default {
|
||||
'moreMenu': '更多菜单',
|
||||
'browses': '浏览',
|
||||
'fz': '全屏缩放',
|
||||
'speech': '语音控制',
|
||||
'submit': '提交成功',
|
||||
'add': '新增成功',
|
||||
'edit': '编辑成功',
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user