javascript
复制代码
<template>
<transition name="modal-fade">
<div v-if="isOpen" class="modal-overlay" @click.self="handleOverlayClick">
<div class="modal-container">
<div class="modal-header">
<h2>语音输入</h2>
<button class="close-button" @click="closeModal">×</button>
</div>
<div class="modal-body">
<div class="status-indicator">
<div v-if="isListening" class="mic-animation">
<div class="mic-icon">
<svg viewBox="0 0 24 24">
<path
d="M12,2A3,3 0 0,1 15,5V11A3,3 0 0,1 12,14A3,3 0 0,1 9,11V5A3,3 0 0,1 12,2M19,11C19,14.53 16.39,17.44 13,17.93V21H11V17.93C7.61,17.44 5,14.53 5,11H7A5,5 0 0,0 12,16A5,5 0 0,0 17,11H19Z" />
</svg>
</div>
<div class="sound-wave">
<div class="wave"></div>
<div class="wave"></div>
<div class="wave"></div>
</div>
</div>
<div v-else class="mic-ready">
<svg viewBox="0 0 24 24">
<path
d="M12,2A3,3 0 0,1 15,5V11A3,3 0 0,1 12,14A3,3 0 0,1 9,11V5A3,3 0 0,1 12,2M19,11C19,14.53 16.39,17.44 13,17.93V21H11V17.93C7.61,17.44 5,14.53 5,11H7A5,5 0 0,0 12,16A5,5 0 0,0 17,11H19Z" />
</svg>
</div>
<p class="status-text">{{ statusText }}</p>
<div v-if="recognitionError" class="error-message">
<svg viewBox="0 0 24 24" class="error-icon">
<path
d="M12,2C17.53,2 22,6.47 22,12C22,17.53 17.53,22 12,22C6.47,22 2,17.53 2,12C2,6.47 6.47,2 12,2M15.59,7L12,10.59L8.41,7L7,8.41L10.59,12L7,15.59L8.41,17L12,13.41L15.59,17L17,15.59L13.41,12L17,8.41L15.59,7Z" />
</svg>
<span>{{ friendlyErrorMessage }}</span>
</div>
</div>
<div class="result-container">
<div class="result-content" :class="{ 'has-result': transcript }">
{{ transcript || '请点击"开始录音"按钮并说话...' }}
</div>
</div>
</div>
<div class="modal-footer">
<button @click="toggleRecognition" class="control-button" :class="{ 'listening': isListening }"
:disabled="!isBrowserSupported">
{{ isListening ? '停止录音' : '开始录音' }}
</button>
<button @click="confirmResult" class="confirm-button" :disabled="!transcript">
使用内容
</button>
</div>
</div>
</div>
</transition>
</template>
<script setup>
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue';
const props = defineProps({
isOpen: {
type: Boolean,
required: true
},
lang: {
type: String,
default: 'zh-CN'
}
});
const emit = defineEmits(['close', 'confirm']);
const recognition = ref(null);
const isListening = ref(false);
const transcript = ref('');
const recognitionError = ref(null);
const isBrowserSupported = ref(true);
// 错误代码到友好提示的映射
const errorMessageMap = {
'no-speech': '未检测到语音,请靠近麦克风说话',
'audio-capture': '无法访问麦克风,请检查麦克风是否已连接',
'not-allowed': '麦克风权限被拒绝,请允许浏览器使用麦克风',
'aborted': '语音识别已中止',
'network': '网络连接问题,请检查网络后重试',
'not-supported': '您的浏览器不支持语音识别功能',
'service-not-allowed': '语音识别服务不可用',
'bad-grammar': '识别过程中出现语法错误',
'language-not-supported': '不支持当前语言设置',
'default': '语音识别出现问题,请刷新页面后重试'
};
// 计算属性:生成友好的错误提示
const friendlyErrorMessage = computed(() => {
if (!recognitionError.value) return '';
return errorMessageMap[recognitionError.value] || errorMessageMap['default'];
});
const statusText = computed(() => {
if (recognitionError.value) {
return '语音识别遇到问题';
}
return isListening.value ? '正在聆听中...请说话' : '点击"开始录音"按钮开始说话';
});
// 检查浏览器支持情况
const checkBrowserSupport = () => {
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
isBrowserSupported.value = !!SpeechRecognition;
if (!isBrowserSupported.value) {
recognitionError.value = 'not-supported';
}
return isBrowserSupported.value;
};
// 初始化语音识别
const initRecognition = () => {
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
recognition.value = new SpeechRecognition();
recognition.value.continuous = true;
recognition.value.interimResults = true;
recognition.value.lang = props.lang;
recognition.value.onstart = () => {
isListening.value = true;
recognitionError.value = null;
console.log('语音识别已启动');
};
recognition.value.onend = () => {
isListening.value = false;
console.log('语音识别已结束');
};
recognition.value.onresult = (event) => {
let interimTranscript = '';
let finalTranscript = '';
for (let i = event.resultIndex; i < event.results.length; i++) {
const result = event.results[i];
const transcript = result[0].transcript;
if (result.isFinal) {
finalTranscript += transcript;
} else {
interimTranscript += transcript;
}
}
transcript.value = finalTranscript || interimTranscript;
console.log('识别结果:', transcript.value);
};
recognition.value.onerror = (event) => {
recognitionError.value = event.error;
isListening.value = false;
console.error('语音识别错误:', event.error);
// 特定错误自动重新尝试
if (['network', 'service-not-allowed'].includes(event.error)) {
setTimeout(() => {
if (props.isOpen && !isListening.value) {
startRecognition();
}
}, 1500);
}
};
};
// 检查麦克风权限
const checkMicrophonePermission = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
stream.getTracks().forEach(track => track.stop());
return true;
} catch (error) {
console.error('麦克风权限被拒绝:', error);
recognitionError.value = 'not-allowed';
return false;
}
};
// 开始语音识别
const startRecognition = async () => {
if (!recognition.value) {
initRecognition();
}
const hasPermission = await checkMicrophonePermission();
if (!hasPermission) return;
transcript.value = '';
recognitionError.value = null;
try {
recognition.value.start();
} catch (error) {
console.error('启动语音识别失败:', error);
recognitionError.value = 'service-not-allowed';
}
};
// 停止语音识别
const stopRecognition = () => {
if (!recognition.value) return;
try {
recognition.value.stop();
} catch (error) {
console.error('停止语音识别失败:', error);
}
};
// 切换录音状态
const toggleRecognition = () => {
if (!isBrowserSupported.value) return;
if (isListening.value) {
stopRecognition();
} else {
startRecognition();
}
};
// 关闭模态框
const closeModal = () => {
if (isListening.value) {
stopRecognition();
}
emit('close');
};
// 确认使用结果
const confirmResult = () => {
emit('confirm', transcript.value);
closeModal();
};
// 点击遮罩层
const handleOverlayClick = (event) => {
if (event.target === event.currentTarget) {
closeModal();
}
};
// 监听语言变化
watch(() => props.lang, (newLang) => {
if (recognition.value) {
recognition.value.lang = newLang;
}
});
// 组件挂载时初始化
onMounted(() => {
checkBrowserSupport();
if (isBrowserSupported.value) {
initRecognition();
}
});
// 组件卸载前清理
onBeforeUnmount(() => {
if (recognition.value) {
recognition.value.onend = null;
recognition.value.onresult = null;
recognition.value.onerror = null;
if (isListening.value) {
recognition.value.abort();
}
}
});
</script>
<style scoped>
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal-container {
background-color: white;
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
width: 90%;
max-width: 500px;
max-height: 90vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.modal-header {
padding: 16px 24px;
border-bottom: 1px solid #eee;
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-header h2 {
margin: 0;
font-size: 1.25rem;
color: #333;
}
.close-button {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: #666;
padding: 0;
line-height: 1;
outline: none;
}
.modal-body {
padding: 24px;
flex: 1;
overflow-y: auto;
}
.status-indicator {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 24px;
}
.mic-animation {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 8px;
}
.mic-icon svg,
.mic-ready svg {
width: 36px;
height: 36px;
fill: #4a6cf7;
}
.sound-wave {
display: flex;
align-items: center;
gap: 4px;
height: 36px;
}
.wave {
width: 6px;
height: 16px;
background-color: #4a6cf7;
border-radius: 3px;
animation: wave 1.2s infinite ease-in-out;
}
.wave:nth-child(1) {
animation-delay: -0.6s;
}
.wave:nth-child(2) {
animation-delay: -0.3s;
}
.wave:nth-child(3) {
animation-delay: 0s;
}
@keyframes wave {
0%,
60%,
100% {
transform: scaleY(0.4);
}
30% {
transform: scaleY(1);
}
}
.mic-ready svg {
opacity: 0.7;
}
.status-text {
margin: 0;
color: #666;
font-size: 0.9rem;
text-align: center;
}
.result-container {
background-color: #f8f9fa;
border-radius: 8px;
padding: 16px;
min-height: 120px;
}
.result-content {
color: #666;
font-size: 0.95rem;
line-height: 1.5;
}
.result-content.has-result {
color: #333;
}
.modal-footer {
padding: 16px 24px;
border-top: 1px solid #eee;
display: flex;
justify-content: flex-end;
gap: 12px;
}
.control-button {
padding: 8px 16px;
background-color: #f0f2f5;
border: none;
outline: none;
border-radius: 6px;
color: #333;
cursor: pointer;
font-weight: 500;
transition: all 0.2s;
}
.control-button.listening {
background-color: #ffebee;
color: #f44336;
}
.control-button:hover {
background-color: #e4e6eb;
}
.confirm-button {
padding: 8px 16px;
background-color: #4a6cf7;
border: none;
outline: none;
border-radius: 6px;
color: white;
cursor: pointer;
font-weight: 500;
transition: background-color 0.2s;
}
.confirm-button:hover {
background-color: #3a5bd9;
}
.confirm-button:disabled {
background-color: #cccccc;
cursor: not-allowed;
}
.modal-fade-enter-active,
.modal-fade-leave-active {
transition: opacity 0.3s ease;
}
.modal-fade-enter-from,
.modal-fade-leave-to {
opacity: 0;
}
.error-message {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
margin-top: 12px;
padding: 8px 12px;
background-color: #ffebee;
border-radius: 6px;
color: #d32f2f;
font-size: 0.9rem;
}
.error-icon {
width: 18px;
height: 18px;
fill: #d32f2f;
}
.control-button:disabled {
background-color: #e0e0e0;
color: #9e9e9e;
cursor: not-allowed;
}
.status-text {
font-weight: 500;
color: #333;
margin-bottom: 4px;
}
</style>