✨ 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