feat(im): 优化语音输入的交互。

im
YunaiV 2026-05-01 09:59:27 +08:00
parent 63c711f9e2
commit 744229a02e
3 changed files with 181 additions and 49 deletions

View File

@ -9,7 +9,7 @@
<div <div
v-if="visible" v-if="visible"
ref="rootRef" ref="rootRef"
class="absolute z-100 w-80 p-2 rounded-md bg-[var(--el-bg-color)] shadow-[0_4px_16px_rgba(0,0,0,0.12)]" class="im-popover-arrow absolute z-100 w-80 p-2 rounded-md bg-[var(--el-bg-color)] shadow-[0_4px_16px_rgba(0,0,0,0.12)]"
@click.stop @click.stop
> >
<el-scrollbar max-height="240px"> <el-scrollbar max-height="240px">
@ -99,3 +99,17 @@ onUnmounted(() => {
}) })
</script> </script>
<style scoped>
/* 底部小三角:指向触发图标,仿微信 PC 气泡指针left 偏移对应表情按钮(工具栏 1st icon */
.im-popover-arrow::after {
content: '';
position: absolute;
top: calc(100% - 1px);
left: 10px;
border-style: solid;
border-width: 6px 6px 0 6px;
border-color: var(--el-bg-color) transparent transparent transparent;
filter: drop-shadow(0 2px 2px rgba(0, 0, 0, 0.08));
}
</style>

View File

@ -71,7 +71,7 @@
<el-tooltip content="语音消息" placement="top"> <el-tooltip content="语音消息" placement="top">
<span <span
class="message-input__tool inline-flex items-center justify-center box-content p-1.5 cursor-pointer rounded transition-colors hover:bg-[var(--el-fill-color)]" class="message-input__tool inline-flex items-center justify-center box-content p-1.5 cursor-pointer rounded transition-colors hover:bg-[var(--el-fill-color)]"
@click="voiceVisible = true" @click.stop="openVoice"
> >
<Icon icon="ant-design:audio-outlined" :size="18" /> <Icon icon="ant-design:audio-outlined" :size="18" />
</span> </span>
@ -113,6 +113,13 @@
class="bottom-full left-3 mb-2" class="bottom-full left-3 mb-2"
@select="insertText" @select="insertText"
/> />
<!-- 语音录制面板与表情面板同处工具栏bottom-full 向上弹出避免离触发的麦克风图标过远 -->
<VoiceRecorder
v-model="voiceVisible"
class="bottom-full left-3 mb-2"
@send="onVoiceSend"
/>
</div> </div>
</div> </div>
@ -127,9 +134,6 @@
@select="onMentionSelect" @select="onMentionSelect"
/> />
<!-- 语音录制对话框 -->
<VoiceRecorder v-model="voiceVisible" @send="onVoiceSend" />
<!-- 隐藏的文件选择器 --> <!-- 隐藏的文件选择器 -->
<input ref="imageInputRef" type="file" accept="image/*" hidden @change="onImagePicked" /> <input ref="imageInputRef" type="file" accept="image/*" hidden @change="onImagePicked" />
<input ref="fileInputRef" type="file" hidden @change="onFilePicked" /> <input ref="fileInputRef" type="file" hidden @change="onFilePicked" />
@ -508,8 +512,12 @@ function onInput() {
// ==================== ==================== // ==================== ====================
const emojiVisible = ref(false) const emojiVisible = ref(false)
/** 切换表情面板;打开时互斥关掉语音面板 */
function toggleEmoji() { function toggleEmoji() {
emojiVisible.value = !emojiVisible.value emojiVisible.value = !emojiVisible.value
if (emojiVisible.value) {
voiceVisible.value = false
}
} }
// ==================== @ ==================== // ==================== @ ====================
@ -768,6 +776,11 @@ async function onFilePicked(e: Event) {
// ==================== ==================== // ==================== ====================
const voiceVisible = ref(false) const voiceVisible = ref(false)
/** 打开语音录制面板;互斥关掉表情面板 */
function openVoice() {
voiceVisible.value = true
emojiVisible.value = false
}
/** VoiceRecorder 录完后回传 blob包成 webm 文件上传,发送 VOICE 消息 */ /** VoiceRecorder 录完后回传 blob包成 webm 文件上传,发送 VOICE 消息 */
async function onVoiceSend(payload: { blob: Blob; duration: number }) { async function onVoiceSend(payload: { blob: Blob; duration: number }) {
const file = new File([payload.blob], `voice-${Date.now()}.webm`, { type: payload.blob.type }) const file = new File([payload.blob], `voice-${Date.now()}.webm`, { type: payload.blob.type })

View File

@ -1,41 +1,68 @@
<template> <template>
<!-- <!--
语音录制对话框 语音录制面板
- 简化实现按下开始录松开结束录超过 maxDuration 自动停止 - 三态机idle 未录 / recording 录制中 / preview 试听阶段
- 只产出 Blob + 时长实际上传/消息封装由调用方处理 - 录制完成后可试听重录或发送
- 需要浏览器支持 MediaRecorderHTTPS localhost - 需浏览器支持 MediaRecorderHTTPS localhost
- idle 状态点击外部会关闭避免录制 / 试听阶段被误关丢内容
--> -->
<el-dialog <div
v-model="visible" v-if="visible"
title="按住空格说话" ref="rootRef"
width="360px" class="im-popover-arrow absolute z-100 w-80 p-4 rounded-md bg-[var(--el-bg-color)] shadow-[0_4px_16px_rgba(0,0,0,0.12)]"
:close-on-click-modal="false" @click.stop
@close="handleCancel"
> >
<div class="flex flex-col items-center gap-4 py-5"> <div class="flex flex-col items-center gap-3">
<div class="text-[32px] font-medium tabular-nums text-[var(--el-text-color-primary)]"> <!-- 计时录制时累加试听阶段定格在最终时长 -->
<div class="text-[28px] font-medium tabular-nums text-[var(--el-text-color-primary)]">
{{ timerText }} {{ timerText }}
</div> </div>
<!-- 状态文案 -->
<div class="text-13px text-[var(--el-text-color-secondary)]"> <div class="text-13px text-[var(--el-text-color-secondary)]">
<span v-if="recording"> Esc </span> <span v-if="status === 'idle'"></span>
<span v-else>""</span> <span v-else-if="status === 'recording'">录制中最长 {{ maxDuration }} </span>
<span v-else></span>
</div> </div>
<!-- idle / recording 阶段脉冲圆点preview 阶段原生音频播放器 -->
<div <div
v-if="status !== 'preview'"
class="w-12 h-12 rounded-full bg-[var(--el-border-color)]" class="w-12 h-12 rounded-full bg-[var(--el-border-color)]"
:class="{ 'im-voice-recorder__pulse bg-[#f56c6c]': recording }" :class="{ 'im-voice-recorder__pulse bg-[#f56c6c]': status === 'recording' }"
></div> ></div>
<audio v-else :src="previewUrl" controls class="w-full"></audio>
</div> </div>
<template #footer> <!-- 操作按钮 -->
<el-button v-if="!recording" @click="handleCancel"></el-button> <div class="flex justify-end gap-2 mt-3">
<el-button v-if="!recording" type="primary" @click="startRecord"></el-button> <template v-if="status === 'idle'">
<el-button v-else type="danger" @click="stopRecord"></el-button> <el-button size="small" @click="handleCancel"></el-button>
</template> <el-button size="small" type="primary" @click="startRecord"></el-button>
</el-dialog> </template>
<template v-else-if="status === 'recording'">
<el-button size="small" @click="handleCancel"></el-button>
<el-button size="small" type="primary" @click="stopRecord"></el-button>
</template>
<template v-else>
<el-button size="small" @click="handleCancel"></el-button>
<el-button size="small" @click="restart"></el-button>
<el-button size="small" type="primary" @click="handleSend"></el-button>
</template>
</div>
</div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { computed, onBeforeUnmount, ref, watch } from 'vue' import {
computed,
onBeforeUnmount,
onMounted,
onUnmounted,
ref,
useTemplateRef,
watch
} from 'vue'
import { useMessage } from '@/hooks/web/useMessage' import { useMessage } from '@/hooks/web/useMessage'
import { formatSeconds } from '@/utils/formatTime' import { formatSeconds } from '@/utils/formatTime'
@ -63,19 +90,50 @@ const visible = computed({
set: (v) => emit('update:modelValue', v) set: (v) => emit('update:modelValue', v)
}) })
const recording = ref(false) const rootRef = useTemplateRef<HTMLDivElement>('rootRef')
/** 录制状态机:未开始 / 录制中 / 完成可试听 */
type Status = 'idle' | 'recording' | 'preview'
const status = ref<Status>('idle')
const duration = ref(0) const duration = ref(0)
const previewUrl = ref('')
let mediaRecorder: MediaRecorder | null = null let mediaRecorder: MediaRecorder | null = null
let audioChunks: Blob[] = [] let audioChunks: Blob[] = []
let mediaStream: MediaStream | null = null let mediaStream: MediaStream | null = null
let timer: ReturnType<typeof setInterval> | null = null let timer: ReturnType<typeof setInterval> | null = null
let recordedBlob: Blob | null = null
/** 取消标记:录制中触发 resetAll 时让异步 'stop' 监听器丢弃数据,不进 preview */
let discarding = false
/** 计时器展示文案mm:ss */
const timerText = computed(() => formatSeconds(duration.value)) const timerText = computed(() => formatSeconds(duration.value))
/** 仅在面板可见时挂全局点击监听;关闭时同步重置录制资源 */
watch(visible, (v) => { watch(visible, (v) => {
if (!v) resetAll() if (v) {
document.addEventListener('click', handleDocumentClick)
} else {
document.removeEventListener('click', handleDocumentClick)
resetAll()
}
}) })
/** 点击面板外部:仅 idle 状态自动关闭,避免录制 / 试听阶段被误关丢数据 */
function handleDocumentClick(e: MouseEvent) {
if (!props.modelValue || !rootRef.value) {
return
}
if (rootRef.value.contains(e.target as Node)) {
return
}
if (status.value !== 'idle') {
return
}
visible.value = false
}
/** 开始录制:申请麦克风 + 启动 MediaRecorder + 启动每秒计时 */
async function startRecord() { async function startRecord() {
if (!navigator.mediaDevices?.getUserMedia) { if (!navigator.mediaDevices?.getUserMedia) {
message.error('当前浏览器不支持录音(需要 HTTPS 或 localhost') message.error('当前浏览器不支持录音(需要 HTTPS 或 localhost')
@ -88,6 +146,7 @@ async function startRecord() {
return return
} }
audioChunks = [] audioChunks = []
discarding = false
mediaRecorder = new MediaRecorder(mediaStream) mediaRecorder = new MediaRecorder(mediaStream)
mediaRecorder.addEventListener('dataavailable', (event: BlobEvent) => { mediaRecorder.addEventListener('dataavailable', (event: BlobEvent) => {
if (event.data.size > 0) { if (event.data.size > 0) {
@ -95,12 +154,16 @@ async function startRecord() {
} }
}) })
mediaRecorder.addEventListener('stop', () => { mediaRecorder.addEventListener('stop', () => {
const blob = new Blob(audioChunks, { type: 'audio/webm' }) // preview
emit('send', { blob, duration: duration.value }) if (discarding) {
visible.value = false return
}
recordedBlob = new Blob(audioChunks, { type: 'audio/webm' })
previewUrl.value = URL.createObjectURL(recordedBlob)
status.value = 'preview'
}) })
mediaRecorder.start() mediaRecorder.start()
recording.value = true status.value = 'recording'
duration.value = 0 duration.value = 0
timer = setInterval(() => { timer = setInterval(() => {
duration.value++ duration.value++
@ -110,56 +173,98 @@ async function startRecord() {
}, 1000) }, 1000)
} }
/** 停止录制:进入 preview 阶段,由用户决定重录或发送 */
function stopRecord() { function stopRecord() {
if (mediaRecorder && mediaRecorder.state !== 'inactive') { if (mediaRecorder && mediaRecorder.state !== 'inactive') {
mediaRecorder.stop() mediaRecorder.stop()
} }
cleanupStream() cleanupStream()
recording.value = false
if (timer) { if (timer) {
clearInterval(timer) clearInterval(timer)
timer = null timer = null
} }
} }
function handleCancel() { /** 重新录制:丢掉当前预览,回到 idle 等待再次点击开始 */
if (recording.value) { function restart() {
// send clearPreview()
mediaRecorder?.removeEventListener('stop', onStopSilently) duration.value = 0
mediaRecorder?.addEventListener('stop', onStopSilently, { once: true }) status.value = 'idle'
if (mediaRecorder && mediaRecorder.state !== 'inactive') { }
mediaRecorder.stop()
} /** 发送:把 blob + 时长上抛父级,由父级负责上传 */
function handleSend() {
if (!recordedBlob) {
return
} }
resetAll() emit('send', { blob: recordedBlob, duration: duration.value })
visible.value = false visible.value = false
} }
function onStopSilently() { /** 取消关闭面板watch 内统一走 resetAll */
// function handleCancel() {
audioChunks = [] visible.value = false
} }
/** 全量重置:录制流 / 计时 / 预览资源全部清掉 */
function resetAll() { function resetAll() {
recording.value = false // mediaRecorder.stop() 'stop' preview
duration.value = 0 discarding = true
audioChunks = [] if (mediaRecorder && mediaRecorder.state !== 'inactive') {
mediaRecorder.stop()
}
cleanupStream() cleanupStream()
if (timer) { if (timer) {
clearInterval(timer) clearInterval(timer)
timer = null timer = null
} }
audioChunks = []
duration.value = 0
status.value = 'idle'
clearPreview()
} }
/** 释放预览音频:撤销 ObjectURL 并清空 blob */
function clearPreview() {
recordedBlob = null
if (previewUrl.value) {
URL.revokeObjectURL(previewUrl.value)
previewUrl.value = ''
}
}
/** 关闭麦克风采集:停止所有 track浏览器顶栏录音指示熄灭 */
function cleanupStream() { function cleanupStream() {
mediaStream?.getTracks().forEach((t) => t.stop()) mediaStream?.getTracks().forEach((t) => t.stop())
mediaStream = null mediaStream = null
} }
onMounted(() => {
if (props.modelValue) {
document.addEventListener('click', handleDocumentClick)
}
})
onBeforeUnmount(resetAll) onBeforeUnmount(resetAll)
onUnmounted(() => {
document.removeEventListener('click', handleDocumentClick)
})
</script> </script>
<style scoped> <style scoped>
/* 底部小三角:指向触发图标,仿微信 PC 气泡指针left 偏移对应语音按钮(工具栏 4th icon */
.im-popover-arrow::after {
content: '';
position: absolute;
top: calc(100% - 1px);
left: 110px;
border-style: solid;
border-width: 6px 6px 0 6px;
border-color: var(--el-bg-color) transparent transparent transparent;
filter: drop-shadow(0 2px 2px rgba(0, 0, 0, 0.08));
}
/* 脉冲呼吸动画keyframes 在 UnoCSS 原子类里不好表达,保留 scoped */ /* 脉冲呼吸动画keyframes 在 UnoCSS 原子类里不好表达,保留 scoped */
.im-voice-recorder__pulse { .im-voice-recorder__pulse {
animation: im-voice-pulse 1s infinite; animation: im-voice-pulse 1s infinite;