✨ feat(im): 优化语音输入的交互。
parent
63c711f9e2
commit
744229a02e
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -1,41 +1,68 @@
|
|||
<template>
|
||||
<!--
|
||||
语音录制对话框
|
||||
- 简化实现:按下开始录,松开结束录;超过 maxDuration 自动停止
|
||||
- 只产出 Blob + 时长,实际上传/消息封装由调用方处理
|
||||
- 需要浏览器支持 MediaRecorder(HTTPS 或 localhost)
|
||||
语音录制面板
|
||||
- 三态机:idle 未录 / recording 录制中 / preview 试听阶段
|
||||
- 录制完成后可试听、重录或发送
|
||||
- 需浏览器支持 MediaRecorder(HTTPS 或 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>
|
||||
</div>
|
||||
<div
|
||||
class="w-12 h-12 rounded-full bg-[var(--el-border-color)]"
|
||||
:class="{ 'im-voice-recorder__pulse bg-[#f56c6c]': recording }"
|
||||
></div>
|
||||
<span v-if="status === 'idle'">点击下方按钮开始录制</span>
|
||||
<span v-else-if="status === 'recording'">录制中,最长 {{ maxDuration }} 秒</span>
|
||||
<span v-else>录制完成,可试听后发送</span>
|
||||
</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>
|
||||
<!-- 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]': status === 'recording' }"
|
||||
></div>
|
||||
<audio v-else :src="previewUrl" controls class="w-full"></audio>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<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>
|
||||
</el-dialog>
|
||||
<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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue