✨ feat(im): 新增 MentionPicker.vue、MessageInput.vue、VoiceRecorder.vue 三个组件,vibe~
parent
6add0b0600
commit
45a530e8c7
|
|
@ -0,0 +1,155 @@
|
|||
<template>
|
||||
<!--
|
||||
@ 成员选择浮层
|
||||
- 父组件通过 v-model:visible 控制显隐,searchText 过滤
|
||||
- 父组件通过 ref 调 moveUp / moveDown / pickActive 实现键盘上下 + Enter 选中
|
||||
- @select 发射被选中的成员
|
||||
-->
|
||||
<el-scrollbar
|
||||
v-show="visible && showMembers.length > 0"
|
||||
ref="scrollRef"
|
||||
class="fixed z-100 w-50 h-75 rounded-md bg-[var(--el-bg-color)] shadow-[0_4px_16px_rgba(0,0,0,0.12)]"
|
||||
:style="{ left: pos.x + 'px', top: pos.y + 'px' }"
|
||||
>
|
||||
<ChatGroupMember
|
||||
v-for="(m, idx) in showMembers"
|
||||
:key="m.userId"
|
||||
:member="m"
|
||||
:height="40"
|
||||
:active="activeIdx === idx"
|
||||
:clickable="false"
|
||||
@click.stop="handleSelect(m)"
|
||||
/>
|
||||
</el-scrollbar>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref, useTemplateRef, watch } from 'vue'
|
||||
import { ElScrollbar } from 'element-plus'
|
||||
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import ChatGroupMember, { type GroupMemberLite } from '../ChatGroupMember.vue'
|
||||
|
||||
defineOptions({ name: 'ImMentionPicker' })
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
visible: boolean // 是否显示
|
||||
pos: { x: number; y: number } // 浮层位置(一般贴在 @ 符号上方)
|
||||
members: GroupMemberLite[] // 当前群的成员列表
|
||||
searchText?: string // @ 后输入的过滤文本
|
||||
ownerId?: number // 群主 id,判断是否能展示"全体成员"
|
||||
}>(),
|
||||
{
|
||||
searchText: '',
|
||||
pos: () => ({ x: 0, y: 0 })
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:visible': [value: boolean]
|
||||
select: [member: GroupMemberLite]
|
||||
}>()
|
||||
|
||||
const userStore = useUserStore()
|
||||
const scrollRef = useTemplateRef<InstanceType<typeof ElScrollbar>>('scrollRef')
|
||||
const activeIdx = ref(0)
|
||||
|
||||
/** 当前登录用户 id(群成员过滤掉自己) */
|
||||
const selfUserId = computed(() => Number(userStore.getUser?.id) || 0)
|
||||
|
||||
/** 是否群主(只有群主能 @ 全体成员) */
|
||||
const isOwner = computed(() => {
|
||||
return props.ownerId != null && props.ownerId === selfUserId.value
|
||||
})
|
||||
|
||||
/** 过滤后的显示列表(最多 100 条) */
|
||||
const showMembers = computed<GroupMemberLite[]>(() => {
|
||||
const result: GroupMemberLite[] = []
|
||||
const keyword = props.searchText
|
||||
// 群主 + 关键字是"全体成员"前缀时,插入虚拟"全体成员"条目
|
||||
const allTag = '全体成员'
|
||||
if (isOwner.value && allTag.startsWith(keyword)) {
|
||||
result.push({ userId: -1, showNickName: allTag })
|
||||
}
|
||||
for (const m of props.members) {
|
||||
if (result.length > 100) {
|
||||
break
|
||||
}
|
||||
if (m.userId === selfUserId.value) {
|
||||
continue
|
||||
}
|
||||
if (m.quit) {
|
||||
continue
|
||||
}
|
||||
if (m.showNickName && m.showNickName.startsWith(keyword)) {
|
||||
result.push(m)
|
||||
}
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
watch(showMembers, (list) => {
|
||||
activeIdx.value = list.length > 0 ? 0 : -1
|
||||
scrollToTop()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
(v) => {
|
||||
if (v) {
|
||||
activeIdx.value = showMembers.value.length > 0 ? 0 : -1
|
||||
scrollToTop()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
function scrollToTop() {
|
||||
const wrap = scrollRef.value?.$el?.querySelector('.el-scrollbar__wrap') as HTMLElement | null
|
||||
if (wrap) {
|
||||
wrap.scrollTop = 0
|
||||
}
|
||||
}
|
||||
|
||||
function scrollToActive() {
|
||||
const wrap = scrollRef.value?.$el?.querySelector('.el-scrollbar__wrap') as HTMLElement | null
|
||||
if (!wrap) {
|
||||
return
|
||||
}
|
||||
const itemH = 40
|
||||
const activeTop = activeIdx.value * itemH
|
||||
if (activeTop + itemH > wrap.scrollTop + wrap.clientHeight) {
|
||||
wrap.scrollTop = activeTop + itemH - wrap.clientHeight
|
||||
} else if (activeTop < wrap.scrollTop) {
|
||||
wrap.scrollTop = activeTop
|
||||
}
|
||||
}
|
||||
|
||||
function handleSelect(m: GroupMemberLite) {
|
||||
emit('select', m)
|
||||
emit('update:visible', false)
|
||||
}
|
||||
|
||||
// 暴露给父组件的键盘导航方法
|
||||
defineExpose({
|
||||
moveUp() {
|
||||
if (activeIdx.value > 0) {
|
||||
activeIdx.value--
|
||||
scrollToActive()
|
||||
}
|
||||
},
|
||||
moveDown() {
|
||||
if (activeIdx.value < showMembers.value.length - 1) {
|
||||
activeIdx.value++
|
||||
scrollToActive()
|
||||
}
|
||||
},
|
||||
pickActive() {
|
||||
if (activeIdx.value >= 0 && showMembers.value[activeIdx.value]) {
|
||||
handleSelect(showMembers.value[activeIdx.value])
|
||||
}
|
||||
},
|
||||
hasCandidates: () => showMembers.value.length > 0
|
||||
})
|
||||
</script>
|
||||
|
||||
|
|
@ -0,0 +1,407 @@
|
|||
<template>
|
||||
<div
|
||||
class="relative flex flex-col bg-[var(--el-bg-color)] border-t border-[var(--el-border-color-lighter)]"
|
||||
>
|
||||
<!-- 顶部工具栏:表情/图片/文件/语音/历史 -->
|
||||
<div class="relative flex items-center gap-2 h-9 px-3">
|
||||
<!--
|
||||
注意:el-icon 默认 box-sizing:border-box + width:1em,所以这里显式 box-content 才能让 p-1.5
|
||||
不把 1em 的图标挤瘪;hover 态走 UnoCSS 的 hover: 前缀
|
||||
-->
|
||||
<el-tooltip content="表情" placement="top">
|
||||
<el-icon
|
||||
class="message-input__tool box-content p-1.5 cursor-pointer rounded transition-colors hover:bg-[var(--el-fill-color)]"
|
||||
@click.stop="toggleEmoji"
|
||||
>
|
||||
<Sunny />
|
||||
</el-icon>
|
||||
</el-tooltip>
|
||||
<el-tooltip content="发送图片" placement="top">
|
||||
<el-icon
|
||||
class="message-input__tool box-content p-1.5 cursor-pointer rounded transition-colors hover:bg-[var(--el-fill-color)]"
|
||||
@click="imageInputRef?.click()"
|
||||
>
|
||||
<Picture />
|
||||
</el-icon>
|
||||
</el-tooltip>
|
||||
<el-tooltip content="发送文件" placement="top">
|
||||
<el-icon
|
||||
class="message-input__tool box-content p-1.5 cursor-pointer rounded transition-colors hover:bg-[var(--el-fill-color)]"
|
||||
@click="fileInputRef?.click()"
|
||||
>
|
||||
<Paperclip />
|
||||
</el-icon>
|
||||
</el-tooltip>
|
||||
<el-tooltip content="语音消息" placement="top">
|
||||
<el-icon
|
||||
class="message-input__tool box-content p-1.5 cursor-pointer rounded transition-colors hover:bg-[var(--el-fill-color)]"
|
||||
@click="voiceVisible = true"
|
||||
>
|
||||
<Microphone />
|
||||
</el-icon>
|
||||
</el-tooltip>
|
||||
<el-tooltip content="历史消息" placement="top">
|
||||
<el-icon
|
||||
class="message-input__tool box-content p-1.5 cursor-pointer rounded transition-colors hover:bg-[var(--el-fill-color)]"
|
||||
@click="$emit('openHistory')"
|
||||
>
|
||||
<Tickets />
|
||||
</el-icon>
|
||||
</el-tooltip>
|
||||
|
||||
<!-- 浮层:表情面板;绝对定位到工具栏左上方 -->
|
||||
<EmojiPicker
|
||||
v-model:visible="emojiVisible"
|
||||
class="bottom-9 left-3"
|
||||
@select="insertText"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 输入区 -->
|
||||
<el-input
|
||||
ref="inputRef"
|
||||
v-model="text"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
resize="none"
|
||||
placeholder="按 Enter 发送,Shift+Enter 换行"
|
||||
class="message-input__textarea"
|
||||
@keydown="onKeydown"
|
||||
@input="onInput"
|
||||
/>
|
||||
|
||||
<!-- 发送按钮 -->
|
||||
<div class="flex justify-end px-3 pt-1.5 pb-2.5">
|
||||
<el-button type="primary" :disabled="!canSend" @click="handleSend">发 送</el-button>
|
||||
</div>
|
||||
|
||||
<!-- @ 选择浮层:群聊才启用 -->
|
||||
<MentionPicker
|
||||
ref="mentionRef"
|
||||
v-model:visible="mentionVisible"
|
||||
:pos="mentionPos"
|
||||
:members="groupMembers"
|
||||
:search-text="mentionSearchText"
|
||||
:owner-id="groupOwnerId"
|
||||
@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" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, nextTick, ref, useTemplateRef } from 'vue'
|
||||
import { Sunny, Picture, Paperclip, Microphone, Tickets } from '@element-plus/icons-vue'
|
||||
import { ElInput, ElMessage } from 'element-plus'
|
||||
|
||||
import { CommonStatusEnum } from '@/utils/constants'
|
||||
import { updateFile } from '@/api/infra/file'
|
||||
import { useConversationStore } from '@/views/im/home/store/conversationStore'
|
||||
import { useGroupStore } from '@/views/im/home/store/groupStore'
|
||||
import { useMessageSender } from '@/views/im/home/composables/useMessageSender'
|
||||
import { ImConversationType, ImMessageType } from '@/views/im/utils/constants'
|
||||
import {
|
||||
serializeMessage,
|
||||
type ImageMessage,
|
||||
type FileMessage,
|
||||
type AudioMessage
|
||||
} from '@/views/im/utils/message'
|
||||
|
||||
import EmojiPicker from './EmojiPicker.vue'
|
||||
import MentionPicker from './MentionPicker.vue'
|
||||
import VoiceRecorder from './VoiceRecorder.vue'
|
||||
import type { GroupMemberLite } from '../ChatGroupMember.vue'
|
||||
|
||||
defineOptions({ name: 'ImMessageInput' })
|
||||
|
||||
defineEmits<{
|
||||
openHistory: [] // 打开历史消息抽屉(由 ChatPanel 或 MessagePage 承接)
|
||||
}>()
|
||||
|
||||
const conversationStore = useConversationStore()
|
||||
const groupStore = useGroupStore()
|
||||
const { send, sendRaw } = useMessageSender()
|
||||
|
||||
const inputRef = useTemplateRef<InstanceType<typeof ElInput>>('inputRef')
|
||||
const imageInputRef = useTemplateRef<HTMLInputElement>('imageInputRef')
|
||||
const fileInputRef = useTemplateRef<HTMLInputElement>('fileInputRef')
|
||||
const mentionRef = useTemplateRef<InstanceType<typeof MentionPicker>>('mentionRef')
|
||||
|
||||
// ==================== 文本 / 发送 ====================
|
||||
const text = ref('')
|
||||
const canSend = computed(() => !!text.value.trim() && !!conversationStore.activeConversation)
|
||||
|
||||
/**
|
||||
* 提交时从文本里实际存在的 @ 段重新收集 atUserIds
|
||||
* - 用户先 @ 后又把 "@张三 " 整段删掉时,旧 push 模型仍会带上张三的 id(与文本不一致)
|
||||
* - "@全体成员" 走虚拟 userId=-1,对齐 MentionPicker 里的 allTag 约定
|
||||
* - 同名成员碰撞时第一条匹配胜出(textarea 没有不可编辑 token,也只能这么做)
|
||||
*/
|
||||
function collectAtUserIds(): number[] {
|
||||
if (!isGroup.value) {
|
||||
return []
|
||||
}
|
||||
const userIds = new Set<number>()
|
||||
const regex = /@([^\s@]+)/g
|
||||
let match: RegExpExecArray | null
|
||||
while ((match = regex.exec(text.value)) !== null) {
|
||||
const name = match[1]
|
||||
if (name === '全体成员') {
|
||||
userIds.add(-1)
|
||||
continue
|
||||
}
|
||||
const member = groupMembers.value.find((m) => m.showNickName === name)
|
||||
if (member?.userId != null) {
|
||||
userIds.add(member.userId)
|
||||
}
|
||||
}
|
||||
return Array.from(userIds)
|
||||
}
|
||||
|
||||
async function handleSend() {
|
||||
if (!canSend.value) {
|
||||
return
|
||||
}
|
||||
const atUserIds = collectAtUserIds()
|
||||
const txt = text.value
|
||||
text.value = ''
|
||||
await send(txt, atUserIds.length > 0 ? { atUserIds } : undefined)
|
||||
}
|
||||
|
||||
function insertText(str: string) {
|
||||
const ta = getTextarea()
|
||||
if (!ta) {
|
||||
text.value += str
|
||||
return
|
||||
}
|
||||
const start = ta.selectionStart ?? text.value.length
|
||||
const end = ta.selectionEnd ?? text.value.length
|
||||
text.value = text.value.slice(0, start) + str + text.value.slice(end)
|
||||
nextTick(() => {
|
||||
ta.focus()
|
||||
const caret = start + str.length
|
||||
ta.setSelectionRange(caret, caret)
|
||||
})
|
||||
}
|
||||
|
||||
function getTextarea(): HTMLTextAreaElement | null {
|
||||
// ElInput 在 type=textarea 时内部是 <textarea>,需要在 DOM 上查找
|
||||
return (inputRef.value?.$el?.querySelector('textarea') as HTMLTextAreaElement) || null
|
||||
}
|
||||
|
||||
// ==================== 表情 ====================
|
||||
const emojiVisible = ref(false)
|
||||
function toggleEmoji() {
|
||||
emojiVisible.value = !emojiVisible.value
|
||||
}
|
||||
|
||||
// ==================== @ 成员选择(群聊) ====================
|
||||
const isGroup = computed(
|
||||
() => conversationStore.activeConversation?.type === ImConversationType.GROUP
|
||||
)
|
||||
|
||||
/** 从 groupStore 读当前激活群的成员(切会话时由 ChatPanel 预拉) */
|
||||
const groupMembers = computed<GroupMemberLite[]>(() => {
|
||||
const conversation = conversationStore.activeConversation
|
||||
if (!conversation || conversation.type !== ImConversationType.GROUP) {
|
||||
return []
|
||||
}
|
||||
const g = groupStore.getGroup(conversation.targetId)
|
||||
return (g?.members || []).map((m) => ({
|
||||
userId: m.userId,
|
||||
showNickName: m.displayUserName || m.nickname,
|
||||
showImage: m.avatar,
|
||||
quit: m.status === CommonStatusEnum.DISABLE
|
||||
}))
|
||||
})
|
||||
const groupOwnerId = computed<number | undefined>(() => {
|
||||
const conversation = conversationStore.activeConversation
|
||||
if (!conversation || conversation.type !== ImConversationType.GROUP) {
|
||||
return undefined
|
||||
}
|
||||
return groupStore.getGroup(conversation.targetId)?.ownerUserId
|
||||
})
|
||||
|
||||
const mentionVisible = ref(false)
|
||||
const mentionSearchText = ref('')
|
||||
const mentionPos = ref({ x: 0, y: 0 })
|
||||
|
||||
/** 当前输入里 @ 符号的起始光标位置,用于选中成员后做替换 */
|
||||
let atStartPos = -1
|
||||
|
||||
function onInput() {
|
||||
if (!isGroup.value) {
|
||||
return
|
||||
}
|
||||
const ta = getTextarea()
|
||||
if (!ta) {
|
||||
return
|
||||
}
|
||||
const caret = ta.selectionStart ?? 0
|
||||
const before = text.value.slice(0, caret)
|
||||
// 找最近的 @ 且 @ 后没有空格(允许汉字、字母、数字)
|
||||
const match = before.match(/@([^\s@]*)$/)
|
||||
if (match) {
|
||||
atStartPos = caret - match[0].length
|
||||
mentionSearchText.value = match[1]
|
||||
// 贴在输入框左上方
|
||||
const rect = ta.getBoundingClientRect()
|
||||
mentionPos.value = { x: rect.left + 20, y: rect.top - 10 }
|
||||
mentionVisible.value = true
|
||||
} else {
|
||||
mentionVisible.value = false
|
||||
atStartPos = -1
|
||||
}
|
||||
}
|
||||
|
||||
function onMentionSelect(member: GroupMemberLite) {
|
||||
const ta = getTextarea()
|
||||
if (!ta || atStartPos < 0) {
|
||||
return
|
||||
}
|
||||
const caret = ta.selectionStart ?? atStartPos
|
||||
const insert = `@${member.showNickName} `
|
||||
text.value = text.value.slice(0, atStartPos) + insert + text.value.slice(caret)
|
||||
// userId 不再 push 到本地状态,由 collectAtUserIds 在 send 时从 text 里重新收集
|
||||
nextTick(() => {
|
||||
ta.focus()
|
||||
const newCaret = atStartPos + insert.length
|
||||
ta.setSelectionRange(newCaret, newCaret)
|
||||
})
|
||||
mentionVisible.value = false
|
||||
atStartPos = -1
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
// @ 浮层打开时,键盘上下 + Enter 由浮层消费
|
||||
if (mentionVisible.value) {
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
mentionRef.value?.moveUp()
|
||||
return
|
||||
}
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
mentionRef.value?.moveDown()
|
||||
return
|
||||
}
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
if (mentionRef.value?.hasCandidates()) {
|
||||
mentionRef.value?.pickActive()
|
||||
return
|
||||
}
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
mentionVisible.value = false
|
||||
return
|
||||
}
|
||||
}
|
||||
// 普通 Enter → 发送;Shift+Enter / Ctrl+Enter → 换行
|
||||
if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey && !e.isComposing) {
|
||||
e.preventDefault()
|
||||
handleSend()
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 图片 / 文件上传 ====================
|
||||
async function onImagePicked(e: Event) {
|
||||
const input = e.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
input.value = ''
|
||||
if (!file) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
const url = (await updateFile(form)) as unknown as string
|
||||
if (!url) {
|
||||
return
|
||||
}
|
||||
await sendRaw(ImMessageType.IMAGE, serializeMessage<ImageMessage>({ url }))
|
||||
} catch (err) {
|
||||
console.error('[IM] 图片上传失败:', err)
|
||||
ElMessage.error('图片上传失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function onFilePicked(e: Event) {
|
||||
const input = e.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
input.value = ''
|
||||
if (!file) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
const url = (await updateFile(form)) as unknown as string
|
||||
if (!url) {
|
||||
return
|
||||
}
|
||||
await sendRaw(
|
||||
ImMessageType.FILE,
|
||||
serializeMessage<FileMessage>({ url, name: file.name, size: file.size })
|
||||
)
|
||||
} catch (err) {
|
||||
console.error('[IM] 文件上传失败:', err)
|
||||
ElMessage.error('文件上传失败')
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 语音 ====================
|
||||
const voiceVisible = ref(false)
|
||||
async function onVoiceSend(payload: { blob: Blob; duration: number }) {
|
||||
try {
|
||||
const file = new File([payload.blob], `voice-${Date.now()}.webm`, { type: payload.blob.type })
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
const url = (await updateFile(form)) as unknown as string
|
||||
if (!url) {
|
||||
return
|
||||
}
|
||||
await sendRaw(
|
||||
ImMessageType.VOICE,
|
||||
serializeMessage<AudioMessage>({ url, duration: payload.duration })
|
||||
)
|
||||
} catch (err) {
|
||||
console.error('[IM] 语音上传失败:', err)
|
||||
ElMessage.error('语音上传失败')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* el-icon 全局规则 .el-icon{color:var(--color,inherit); font-size:inherit; width:1em; height:1em}
|
||||
会盖过 UnoCSS 原子类;用字面选择器 + !important 兜底。
|
||||
颜色取 Element Plus 主题变量,暗色自动切到浅灰 */
|
||||
.message-input__tool,
|
||||
.message-input__tool:deep(svg) {
|
||||
font-size: 18px !important;
|
||||
color: var(--el-text-color-regular) !important;
|
||||
fill: currentColor !important;
|
||||
}
|
||||
.message-input__tool:hover,
|
||||
.message-input__tool:hover:deep(svg) {
|
||||
color: var(--el-color-primary) !important;
|
||||
}
|
||||
|
||||
/* el-textarea 是 ElInput 内部的 textarea,需要 :deep() 去掉默认边框 / 圆角 */
|
||||
.message-input__textarea :deep(.el-textarea__inner) {
|
||||
padding: 8px 12px;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.message-input__textarea :deep(.el-textarea__inner):focus {
|
||||
box-shadow: none;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,177 @@
|
|||
<template>
|
||||
<!--
|
||||
语音录制对话框
|
||||
- 简化实现:按下开始录,松开结束录;超过 maxDuration 自动停止
|
||||
- 只产出 Blob + 时长,实际上传/消息封装由调用方处理
|
||||
- 需要浏览器支持 MediaRecorder(HTTPS 或 localhost)
|
||||
-->
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
title="按住空格说话"
|
||||
width="360px"
|
||||
:close-on-click-modal="false"
|
||||
@close="handleCancel"
|
||||
>
|
||||
<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)]">
|
||||
{{ 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>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { formatSeconds } from '@/utils/formatTime'
|
||||
|
||||
defineOptions({ name: 'ImVoiceRecorder' })
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: boolean // 是否显示
|
||||
maxDuration?: number // 最长录制秒数
|
||||
}>(),
|
||||
{
|
||||
maxDuration: 60
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
send: [payload: { blob: Blob; duration: number }] // 录制完成:返回录音 Blob 和时长(秒)
|
||||
}>()
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (v) => emit('update:modelValue', v)
|
||||
})
|
||||
|
||||
const recording = ref(false)
|
||||
const duration = ref(0)
|
||||
let mediaRecorder: MediaRecorder | null = null
|
||||
let audioChunks: Blob[] = []
|
||||
let mediaStream: MediaStream | null = null
|
||||
let timer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const timerText = computed(() => formatSeconds(duration.value))
|
||||
|
||||
watch(visible, (v) => {
|
||||
if (!v) resetAll()
|
||||
})
|
||||
|
||||
async function startRecord() {
|
||||
if (!navigator.mediaDevices?.getUserMedia) {
|
||||
ElMessage.error('当前浏览器不支持录音(需要 HTTPS 或 localhost)')
|
||||
return
|
||||
}
|
||||
try {
|
||||
mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
||||
} catch (e) {
|
||||
ElMessage.error('无法获取麦克风权限')
|
||||
return
|
||||
}
|
||||
audioChunks = []
|
||||
mediaRecorder = new MediaRecorder(mediaStream)
|
||||
mediaRecorder.addEventListener('dataavailable', (event: BlobEvent) => {
|
||||
if (event.data.size > 0) {
|
||||
audioChunks.push(event.data)
|
||||
}
|
||||
})
|
||||
mediaRecorder.addEventListener('stop', () => {
|
||||
const blob = new Blob(audioChunks, { type: 'audio/webm' })
|
||||
emit('send', { blob, duration: duration.value })
|
||||
visible.value = false
|
||||
})
|
||||
mediaRecorder.start()
|
||||
recording.value = true
|
||||
duration.value = 0
|
||||
timer = setInterval(() => {
|
||||
duration.value++
|
||||
if (duration.value >= props.maxDuration) {
|
||||
stopRecord()
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
resetAll()
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
function onStopSilently() {
|
||||
// 用户取消:丢弃已录数据,不发送
|
||||
audioChunks = []
|
||||
}
|
||||
|
||||
function resetAll() {
|
||||
recording.value = false
|
||||
duration.value = 0
|
||||
audioChunks = []
|
||||
cleanupStream()
|
||||
if (timer) {
|
||||
clearInterval(timer)
|
||||
timer = null
|
||||
}
|
||||
}
|
||||
|
||||
function cleanupStream() {
|
||||
mediaStream?.getTracks().forEach((t) => t.stop())
|
||||
mediaStream = null
|
||||
}
|
||||
|
||||
onBeforeUnmount(resetAll)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 脉冲呼吸动画:keyframes 在 UnoCSS 原子类里不好表达,保留 scoped */
|
||||
.im-voice-recorder__pulse {
|
||||
animation: im-voice-pulse 1s infinite;
|
||||
}
|
||||
|
||||
@keyframes im-voice-pulse {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(245, 108, 108, 0.6);
|
||||
}
|
||||
70% {
|
||||
box-shadow: 0 0 0 20px rgba(245, 108, 108, 0);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(245, 108, 108, 0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Loading…
Reference in New Issue