feat(im): 新增 MentionPicker.vue、MessageInput.vue、VoiceRecorder.vue 三个组件,vibe~

im
YunaiV 2026-04-27 09:20:10 +08:00
parent 6add0b0600
commit 45a530e8c7
3 changed files with 739 additions and 0 deletions

View File

@ -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>

View File

@ -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>

View File

@ -0,0 +1,177 @@
<template>
<!--
语音录制对话框
- 简化实现按下开始录松开结束录超过 maxDuration 自动停止
- 只产出 Blob + 时长实际上传/消息封装由调用方处理
- 需要浏览器支持 MediaRecorderHTTPS 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>