✨ feat(im): 优化语音输入的交互。
parent
63c711f9e2
commit
744229a02e
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 })
|
||||||
|
|
|
||||||
|
|
@ -1,41 +1,68 @@
|
||||||
<template>
|
<template>
|
||||||
<!--
|
<!--
|
||||||
语音录制对话框
|
语音录制面板
|
||||||
- 简化实现:按下开始录,松开结束录;超过 maxDuration 自动停止
|
- 三态机:idle 未录 / recording 录制中 / preview 试听阶段
|
||||||
- 只产出 Blob + 时长,实际上传/消息封装由调用方处理
|
- 录制完成后可试听、重录或发送
|
||||||
- 需要浏览器支持 MediaRecorder(HTTPS 或 localhost)
|
- 需浏览器支持 MediaRecorder(HTTPS 或 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;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue