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

View File

@ -1,41 +1,68 @@
<template>
<!--
语音录制对话框
- 简化实现按下开始录松开结束录超过 maxDuration 自动停止
- 只产出 Blob + 时长实际上传/消息封装由调用方处理
- 需要浏览器支持 MediaRecorderHTTPS localhost
语音录制面板
- 三态机idle 未录 / recording 录制中 / preview 试听阶段
- 录制完成后可试听重录或发送
- 需浏览器支持 MediaRecorderHTTPS localhost
- idle 状态点击外部会关闭避免录制 / 试听阶段被误关丢内容
-->
<el-dialog
v-model="visible"
title="按住空格说话"
width="360px"
:close-on-click-modal="false"
@close="handleCancel"
<div
v-if="visible"
ref="rootRef"
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)]"
@click.stop
>
<div class="flex flex-col items-center gap-4 py-5">
<div class="text-[32px] font-medium tabular-nums text-[var(--el-text-color-primary)]">
<div class="flex flex-col items-center gap-3">
<!-- 计时录制时累加试听阶段定格在最终时长 -->
<div class="text-[28px] font-medium tabular-nums text-[var(--el-text-color-primary)]">
{{ timerText }}
</div>
<!-- 状态文案 -->
<div class="text-13px text-[var(--el-text-color-secondary)]">
<span v-if="recording"> Esc </span>
<span v-else>""</span>
<span v-if="status === 'idle'"></span>
<span v-else-if="status === 'recording'">录制中最长 {{ maxDuration }} </span>
<span v-else></span>
</div>
<!-- idle / recording 阶段脉冲圆点preview 阶段原生音频播放器 -->
<div
v-if="status !== 'preview'"
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>
<audio v-else :src="previewUrl" controls class="w-full"></audio>
</div>
<template #footer>
<el-button v-if="!recording" @click="handleCancel"></el-button>
<el-button v-if="!recording" type="primary" @click="startRecord"></el-button>
<el-button v-else type="danger" @click="stopRecord"></el-button>
</template>
</el-dialog>
<!-- 操作按钮 -->
<div class="flex justify-end gap-2 mt-3">
<template v-if="status === 'idle'">
<el-button size="small" @click="handleCancel"></el-button>
<el-button size="small" type="primary" @click="startRecord"></el-button>
</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>
<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 { formatSeconds } from '@/utils/formatTime'
@ -63,19 +90,50 @@ const visible = computed({
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 previewUrl = ref('')
let mediaRecorder: MediaRecorder | null = null
let audioChunks: Blob[] = []
let mediaStream: MediaStream | 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))
/** 仅在面板可见时挂全局点击监听;关闭时同步重置录制资源 */
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() {
if (!navigator.mediaDevices?.getUserMedia) {
message.error('当前浏览器不支持录音(需要 HTTPS 或 localhost')
@ -88,6 +146,7 @@ async function startRecord() {
return
}
audioChunks = []
discarding = false
mediaRecorder = new MediaRecorder(mediaStream)
mediaRecorder.addEventListener('dataavailable', (event: BlobEvent) => {
if (event.data.size > 0) {
@ -95,12 +154,16 @@ async function startRecord() {
}
})
mediaRecorder.addEventListener('stop', () => {
const blob = new Blob(audioChunks, { type: 'audio/webm' })
emit('send', { blob, duration: duration.value })
visible.value = false
// preview
if (discarding) {
return
}
recordedBlob = new Blob(audioChunks, { type: 'audio/webm' })
previewUrl.value = URL.createObjectURL(recordedBlob)
status.value = 'preview'
})
mediaRecorder.start()
recording.value = true
status.value = 'recording'
duration.value = 0
timer = setInterval(() => {
duration.value++
@ -110,56 +173,98 @@ async function startRecord() {
}, 1000)
}
/** 停止录制:进入 preview 阶段,由用户决定重录或发送 */
function stopRecord() {
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
mediaRecorder.stop()
}
cleanupStream()
recording.value = false
if (timer) {
clearInterval(timer)
timer = null
}
}
function handleCancel() {
if (recording.value) {
// send
mediaRecorder?.removeEventListener('stop', onStopSilently)
mediaRecorder?.addEventListener('stop', onStopSilently, { once: true })
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
mediaRecorder.stop()
}
/** 重新录制:丢掉当前预览,回到 idle 等待再次点击开始 */
function restart() {
clearPreview()
duration.value = 0
status.value = 'idle'
}
/** 发送:把 blob + 时长上抛父级,由父级负责上传 */
function handleSend() {
if (!recordedBlob) {
return
}
resetAll()
emit('send', { blob: recordedBlob, duration: duration.value })
visible.value = false
}
function onStopSilently() {
//
audioChunks = []
/** 取消关闭面板watch 内统一走 resetAll */
function handleCancel() {
visible.value = false
}
/** 全量重置:录制流 / 计时 / 预览资源全部清掉 */
function resetAll() {
recording.value = false
duration.value = 0
audioChunks = []
// mediaRecorder.stop() 'stop' preview
discarding = true
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
mediaRecorder.stop()
}
cleanupStream()
if (timer) {
clearInterval(timer)
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() {
mediaStream?.getTracks().forEach((t) => t.stop())
mediaStream = null
}
onMounted(() => {
if (props.modelValue) {
document.addEventListener('click', handleDocumentClick)
}
})
onBeforeUnmount(resetAll)
onUnmounted(() => {
document.removeEventListener('click', handleDocumentClick)
})
</script>
<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 */
.im-voice-recorder__pulse {
animation: im-voice-pulse 1s infinite;