场景很简单就是当用户长按按钮说话之后可以将识别到的语音转为文字效果图如下长按转换文字断句动态拼接分隔符识别的文字由浮层展示光标在哪语音插入到哪支持中途编辑清除文本和复制文本长按按钮有动画效果需要解决的核心问题recognition.start() 不是立即开始录音用户如果刚按下按钮就开始说话很可能语音没有被录到因此需要做一个延迟的处理。result.isFinal 这个方法只有在断句之前才进行一次拼接就会给用户延迟显示文本的情况我们需要 “边说边出字”一起来看完整代码template div classvoice-wrapper div classinput-container textarea reftextareaRef v-modeldisplayText classresult-box placeholder语音识别内容会显示在这里可直接编辑/textarea tiny-icon-copy v-if!copied classinput-icon copy-icon clickhandleCopy / tiny-icon-yes v-else classinput-icon copy-icon / tiny-icon-clear v-if!deleted classinput-icon clear-icon clickhandleClear / tiny-icon-yes v-else classinput-icon clear-icon / /div div classbtn-container div v-iftempText classfloat-temp-text {{ tempText }} /div button idrecordBtn :class{ recording: isRecording } mousedownstartRecord touchstart.preventstartRecord mouseup.stopstopRecord touchend.stopstopRecord tiny-icon-mic classmic-icon / /button /div /div /template script setup import { ref, nextTick, onMounted } from vue import { IconMic, IconCopySolid, IconRichTextDeleteTable, IconYes } from opentiny/vue-icon const TinyIconMic IconMic() const TinyIconYes IconYes() const TinyIconCopy IconCopySolid() const TinyIconClear IconRichTextDeleteTable() const textareaRef ref(null) const isRecording ref(false) const isPressing ref(false) const displayText ref() const tempText ref() const copied ref(false) const deleted ref(false) let copyTimer null let deleteTimer null let lastProcessedIndex 0 const Recognition window.SpeechRecognition || window.webkitSpeechRecognition const recognition new Recognition() recognition.lang zh-CN recognition.continuous true recognition.interimResults true recognition.onresult (e) { let finalChunk let interimChunk for (let i lastProcessedIndex; i e.results.length; i) { const result e.results[i] if (!result.isFinal) continue const transcript result[0].transcript.trim() if (!transcript) continue finalChunk transcript lastProcessedIndex i 1 } const lastResult e.results[e.results.length - 1] if (lastResult !lastResult.isFinal) { interimChunk lastResult[0].transcript.trim() } if (finalChunk) { tempText.value insertAtCursor(finalChunk) } tempText.value interimChunk } recognition.onaudiostart () { if (isPressing.value) isRecording.value true else recognition.stop() } recognition.onend () { if (isPressing.value) setTimeout(() safeStart(), 50) } function insertAtCursor(text) { const el textareaRef.value if (!el) return const start el.selectionStart const end el.selectionEnd const val displayText.value const before val.substring(0, start) const after val.substring(end) const isNewParagraph start 0 const lastChar before.slice(-1) const isAlreadyPunctuation /[。、]/g.test(lastChar) const needComma before !isNewParagraph !isAlreadyPunctuation const insert needComma ? text : text displayText.value before insert after nextTick(() { const newPos start insert.length el.setSelectionRange(newPos, newPos) }) } function safeStart() { try { recognition.start() } catch {} } function startRecord(e) { e?.preventDefault() if (isPressing.value) return lastProcessedIndex 0 isPressing.value true safeStart() } function stopRecord() { isPressing.value false if (isRecording.value) { recognition.stop() isRecording.value false setTimeout(() tempText.value , 400) } } const handleCopy async () { try { await navigator.clipboard.writeText(displayText.value) copied.value true clearTimeout(copyTimer) copyTimer setTimeout(() copied.value false, 2000) } catch {} } function handleClear() { deleted.value true clearTimeout(deleteTimer) deleteTimer setTimeout(() deleted.value false, 2000) displayText.value } onMounted(() { try { recognition.start(); recognition.stop() } catch {} }) /script style scoped .voice-wrapper { position: fixed; top: 0; left: 0; right: 0; bottom: 0; display: flex; flex-direction: column; background: #fff; box-sizing: border-box; } .input-container { position: relative; flex: 1; padding: 15px; display: flex; flex-direction: column; min-height: 0; } .result-box { flex: 1; width: 100%; padding: 12px; font-size: 16px; line-height: 1.6; border: 1px solid #eee; border-radius: 8px; resize: none; outline: none; box-sizing: border-box; overflow-y: auto; } .btn-container { position: relative; padding: 20px 0; display: flex; justify-content: center; align-items: center; border-top: 1px solid #f5f5f5; background: #fff; } .float-temp-text { position: absolute; bottom: 100px; left: 50%; transform: translateX(-50%); background: rgba(0, 0, 0, 0.6); color: #fff; padding: 6px 12px; border-radius: 6px; font-size: 14px; white-space: nowrap; pointer-events: none; z-index: 10; max-width: 80%; overflow: hidden; text-overflow: ellipsis; } #recordBtn { position: relative; width: 68px; height: 68px; background: #42b983; border: none; border-radius: 50%; display: flex; align-items: center; justify-content: center; cursor: pointer; user-select: none; } #recordBtn.recording { background: #2f9e6e; transform: scale(1.08); box-shadow: 0 0 0 6px rgba(66, 185, 131, 0.25); } #recordBtn.recording::after { content: ; position: absolute; inset: 0; border-radius: 50%; background: rgba(66, 185, 131, 0.35); animation: pulse 1.5s infinite; } keyframes pulse { 0% { transform: scale(1); opacity: 0.6; } 100% { transform: scale(1.6); opacity: 0; } } #recordBtn:active { background: #359469; } .input-icon { position: absolute; font-size: 24px; bottom: 25px; fill: #323233; cursor: pointer; } .copy-icon { right: 60px; } .clear-icon { right: 25px; } .mic-icon { fill: #fff; font-size: 30px; } /style