1027 lines
39 KiB
Vue
1027 lines
39 KiB
Vue
<template>
|
||
<!--
|
||
外层底色与消息流(bg-color-page)保持一致,让"消息 → 输入"无色差过渡;
|
||
padding 给内层白卡片呼吸空间,卡片自带边框就够区分输入区,不再需要一条 border-t
|
||
-->
|
||
<div class="relative bg-[var(--el-bg-color-page)] px-3 pt-2 pb-3">
|
||
<!--
|
||
内层白色圆角卡片 = editor + 工具栏;border + rounded 模拟微信"输入框"边界,
|
||
避免之前"无框 Web 输入"的散开感;border 走 scoped CSS(UnoCSS 不带 border-style preflight)
|
||
-->
|
||
<div class="message-input__card relative flex flex-col bg-[var(--el-bg-color)] rounded-lg">
|
||
<!--
|
||
输入区在上:contenteditable div(取代 textarea,对齐微信 PC:输入区在上,操作在下)
|
||
- 让 @ 浮层能拿到真实光标 rect(textarea 拿不到)
|
||
- 让 @ 成员以 <span data-id> token 节点存在,删 token 即删 id,避免 stale atUserIds
|
||
- placeholder 通过 data-empty + ::before 模拟(contenteditable 没有原生 placeholder)
|
||
-->
|
||
<div
|
||
ref="editorRef"
|
||
class="message-input__editor"
|
||
contenteditable="true"
|
||
data-placeholder="按 Enter 发送,Shift+Enter 换行"
|
||
data-empty=""
|
||
role="textbox"
|
||
@keydown="onKeydown"
|
||
@input="onInput"
|
||
@scroll.passive="onEditorScroll"
|
||
@paste.prevent="onPaste"
|
||
></div>
|
||
|
||
<!--
|
||
底部工具栏:左侧操作图标 + 右侧发送按钮(对齐微信 PC:操作图标统一放底部)
|
||
- relative 给 EmojiPicker 提供 absolute 锚点,picker 用 bottom-full 向上弹出
|
||
- 图标统一 30×30 点击区(18px icon + p-1.5),gap-1 让间距贴合微信观感
|
||
- border-t 在编辑区与工具栏之间画一条与 card 边框同色的细线(scoped CSS 避绕 UnoCSS preflight 缺失)
|
||
-->
|
||
<div
|
||
class="message-input__toolbar relative flex items-center justify-between gap-2 px-3 py-2"
|
||
>
|
||
<div class="flex items-center gap-1">
|
||
<!--
|
||
所有 icon 统一走 Iconify(ant-design outlined 系列):
|
||
- 视觉风格更接近微信 PC(线性、圆角,比 Element Plus 内置的更轻量)
|
||
- 笑脸 / 图片 / 文件夹 / 麦克风 同源,避免一个走 ep 一个走 antd 视觉割裂
|
||
- 外层 span 复用 .message-input__tool 的 padding / hover 样式,scoped CSS 的 :deep(svg) 仍能命中
|
||
-->
|
||
<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.stop="toggleEmoji"
|
||
>
|
||
<Icon icon="ant-design:smile-outlined" :size="18" />
|
||
</span>
|
||
</el-tooltip>
|
||
<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="imageInputRef?.click()"
|
||
>
|
||
<Icon icon="ant-design:picture-outlined" :size="18" />
|
||
</span>
|
||
</el-tooltip>
|
||
<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="fileInputRef?.click()"
|
||
>
|
||
<Icon icon="ant-design:folder-outlined" :size="18" />
|
||
</span>
|
||
</el-tooltip>
|
||
<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"
|
||
>
|
||
<Icon icon="ant-design:audio-outlined" :size="18" />
|
||
</span>
|
||
</el-tooltip>
|
||
<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="videoInputRef?.click()"
|
||
>
|
||
<Icon icon="ant-design:video-camera-outlined" :size="18" />
|
||
</span>
|
||
</el-tooltip>
|
||
</div>
|
||
|
||
<!-- 群聊:发送按钮 + ▼ 下拉菜单(点主按钮普通发送 / 点 ▼ 选「发送回执消息」),对齐微信 PC -->
|
||
<el-dropdown
|
||
v-if="isGroup"
|
||
split-button
|
||
type="primary"
|
||
:disabled="!canSend"
|
||
@click="handleSend()"
|
||
@command="handleSendCommand"
|
||
>
|
||
发 送
|
||
<template #dropdown>
|
||
<el-dropdown-menu>
|
||
<el-dropdown-item command="receipt">发送回执消息</el-dropdown-item>
|
||
</el-dropdown-menu>
|
||
</template>
|
||
</el-dropdown>
|
||
<!-- 私聊:普通发送按钮(私聊没有群回执概念) -->
|
||
<el-button v-else type="primary" :disabled="!canSend" @click="handleSend()">
|
||
发 送
|
||
</el-button>
|
||
|
||
<!-- 表情面板:bottom-full 让 picker 下沿贴工具栏顶部,向上弹出(对齐工具栏左侧首图标) -->
|
||
<EmojiPicker
|
||
v-model:visible="emojiVisible"
|
||
class="bottom-full left-3 mb-2"
|
||
@select="insertText"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- @ 选择浮层:群聊才启用 -->
|
||
<MentionPicker
|
||
ref="mentionRef"
|
||
v-model:visible="mentionVisible"
|
||
:position="mentionPosition"
|
||
: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" />
|
||
<input ref="videoInputRef" type="file" accept="video/*" hidden @change="onVideoPicked" />
|
||
</div>
|
||
</template>
|
||
|
||
<script lang="ts" setup>
|
||
import { computed, onBeforeUnmount, onMounted, ref, useTemplateRef, watch } from 'vue'
|
||
|
||
import Icon from '@/components/Icon/src/Icon.vue'
|
||
import { updateFile } from '@/api/infra/file'
|
||
import { useConversationStore } from '@/views/im/home/store/conversationStore'
|
||
import { useGroupStore } from '@/views/im/home/store/groupStore'
|
||
import { useFriendStore } from '@/views/im/home/store/friendStore'
|
||
import { useDraftStore } from '@/views/im/home/store/draftStore'
|
||
import { getMemberDisplayName } from '@/views/im/utils/user'
|
||
import { useMessageSender } from '@/views/im/home/composables/useMessageSender'
|
||
import { getConversationKey } from '@/views/im/utils/conversation'
|
||
import { ImConversationType, ImMessageType } from '@/views/im/utils/constants'
|
||
import {
|
||
serializeMessage,
|
||
type ImageMessage,
|
||
type FileMessage,
|
||
type AudioMessage,
|
||
type VideoMessage
|
||
} from '@/views/im/utils/message'
|
||
|
||
import EmojiPicker from './EmojiPicker.vue'
|
||
import MentionPicker from './MentionPicker.vue'
|
||
import VoiceRecorder from './VoiceRecorder.vue'
|
||
import type { GroupMemberLite } from '../../../../components/group/GroupMember.vue'
|
||
|
||
defineOptions({ name: 'ImMessageInput' })
|
||
|
||
const conversationStore = useConversationStore()
|
||
const groupStore = useGroupStore()
|
||
const friendStore = useFriendStore()
|
||
const draftStore = useDraftStore()
|
||
const { send, sendRaw } = useMessageSender()
|
||
|
||
const editorRef = useTemplateRef<HTMLDivElement>('editorRef')
|
||
const imageInputRef = useTemplateRef<HTMLInputElement>('imageInputRef')
|
||
const fileInputRef = useTemplateRef<HTMLInputElement>('fileInputRef')
|
||
const videoInputRef = useTemplateRef<HTMLInputElement>('videoInputRef')
|
||
const mentionRef = useTemplateRef<InstanceType<typeof MentionPicker>>('mentionRef')
|
||
|
||
// ==================== 文本 / 发送 ====================
|
||
const canSend = ref(false) // editor 是否有可发送内容;contenteditable 没 v-model,靠 input 事件主动同步
|
||
|
||
/** 维护 canSend + data-empty(撑起 placeholder);不写草稿,restoreDraftToEditor 复用避免回流 */
|
||
function applyEditorUiState(editor: HTMLDivElement) {
|
||
const raw = editor.textContent || ''
|
||
// canSend 按 trim 后判断(空格 / 换行不算可发送内容)
|
||
canSend.value = !!raw.trim() && !!conversationStore.activeConversation
|
||
// data-empty 按原始内容判断:用户敲一个空格也要让 placeholder 隐藏,避免视觉叠加
|
||
// 用属性"存在 / 缺失"而非 'true'/'false' 字符串:CSS [data-empty]::before 命中即可,
|
||
// 比 [data-empty='true'] 直观;浏览器删空后留 <br> → :empty 不命中,所以必须 JS 维护
|
||
if (raw) {
|
||
delete editor.dataset.empty
|
||
} else {
|
||
editor.dataset.empty = ''
|
||
}
|
||
}
|
||
|
||
/** 用户编辑入口的统一收尾:UI 状态同步 + 草稿写回 store(列表立即出 [草稿] 前缀) */
|
||
function syncEditorState() {
|
||
const editor = editorRef.value
|
||
if (!editor) {
|
||
return
|
||
}
|
||
applyEditorUiState(editor)
|
||
syncDraftToStore(editor)
|
||
}
|
||
|
||
/** 把 editor 当前内容写到 draftStore;plain 由 collectFromEditor 拿,与发送时同源避免列表与实发不一致 */
|
||
function syncDraftToStore(editor: HTMLDivElement) {
|
||
const conversation = conversationStore.activeConversation
|
||
if (!conversation) {
|
||
return
|
||
}
|
||
// collectFromEditor 已 trim,plain 为空时 store 内部按 clearDraft 处理
|
||
const { text } = collectFromEditor(editor)
|
||
draftStore.setDraft(conversation, { html: editor.innerHTML, plain: text })
|
||
}
|
||
|
||
/** 切会话时把 store 里的草稿还原到 editor;只更 UI 不回写草稿,避免 store→editor→store 回流 */
|
||
function restoreDraftToEditor() {
|
||
const editor = editorRef.value
|
||
if (!editor) {
|
||
return
|
||
}
|
||
const conversation = conversationStore.activeConversation
|
||
const draft = conversation ? draftStore.getDraft(conversation) : undefined
|
||
editor.innerHTML = draft?.html || ''
|
||
applyEditorUiState(editor)
|
||
// 把光标移到末尾,让用户接着输入;空内容直接 focus 即可
|
||
if (draft?.html) {
|
||
placeCaretAtEnd(editor)
|
||
}
|
||
}
|
||
|
||
/** 把光标放到 contenteditable 元素的末尾——切回有草稿的会话时光标自然落在尾部,对齐微信 */
|
||
function placeCaretAtEnd(el: HTMLElement) {
|
||
const range = document.createRange()
|
||
range.selectNodeContents(el)
|
||
range.collapse(false)
|
||
const sel = window.getSelection()
|
||
if (!sel) {
|
||
return
|
||
}
|
||
sel.removeAllRanges()
|
||
sel.addRange(range)
|
||
}
|
||
|
||
/**
|
||
* 走 DOM 把 editor 内容拼回 plain text + atUserIds
|
||
*
|
||
* 节点分支:
|
||
* 1. text 节点:直接拼 textContent;过程中滤掉 ZWSP(token 首位锚点用,不进发送内容)
|
||
* 2. br:拼 \n(Shift+Enter 走 execCommand('insertLineBreak') 产物)
|
||
* 3. span[data-id]:拼 token 显示文本 + 把 dataset.id 收到 atUserIds(不递归 span 内部)
|
||
* 4. div:浏览器在 contenteditable 里默认换行容器,前置 \n 后递归子节点
|
||
* 5. 其他元素:透传,递归子节点
|
||
*
|
||
* atUserIds 走 Set 去重:用户 @ 张三两次时 atUserIds 只出现一次;trim 末尾空白
|
||
*/
|
||
function collectFromEditor(root: HTMLElement): { text: string; atUserIds: number[] } {
|
||
const userIds: number[] = []
|
||
let text = ''
|
||
|
||
function walk(node: Node) {
|
||
if (node.nodeType === Node.TEXT_NODE) {
|
||
text += (node.textContent || '').replace(//g, '')
|
||
return
|
||
}
|
||
if (node.nodeType !== Node.ELEMENT_NODE) {
|
||
return
|
||
}
|
||
const el = node as HTMLElement
|
||
const tag = el.tagName.toLowerCase()
|
||
if (tag === 'br') {
|
||
text += '\n'
|
||
return
|
||
}
|
||
if (tag === 'span' && el.dataset.id) {
|
||
text += el.textContent || ''
|
||
const id = Number(el.dataset.id)
|
||
if (!Number.isNaN(id)) {
|
||
userIds.push(id)
|
||
}
|
||
return
|
||
}
|
||
if (tag === 'div') {
|
||
if (text && !text.endsWith('\n')) {
|
||
text += '\n'
|
||
}
|
||
el.childNodes.forEach(walk)
|
||
return
|
||
}
|
||
el.childNodes.forEach(walk)
|
||
}
|
||
|
||
// 直接从 root.childNodes 开始,避免把 root 本身也当元素处理(虽然目前没有特殊样式,但以防未来改动)
|
||
root.childNodes.forEach(walk)
|
||
return {
|
||
text: text.trim(),
|
||
atUserIds: [...new Set(userIds)]
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 发送:从 DOM 收 text + atUserIds → 清空编辑器 → 走 useMessageSender.send
|
||
*
|
||
* 1. 防御:canSend 是 false(trim 后空 / 没激活会话)或 editor 没 mount → 直接 return
|
||
* 2. 收集:DOM walk 拿到要发送的文本 + atUserIds
|
||
* 3. 二次防御:collectFromEditor 走 trim,可能比 syncEditorState 更严格(例如全 ZWSP),仍空就 return
|
||
* 4. 清空 + 同步状态:先清 innerHTML 再 syncEditorState 让 placeholder / canSend 一起回归
|
||
* (顺序很重要:先清后 sync,否则 sync 看到旧内容会误判)
|
||
* 5. 上送:atUserIds 非空才传,避免发空数组
|
||
*/
|
||
async function handleSend(options?: { receipt?: boolean }) {
|
||
const editor = editorRef.value
|
||
if (!canSend.value || !editor) {
|
||
return
|
||
}
|
||
const { text, atUserIds } = collectFromEditor(editor)
|
||
if (!text) {
|
||
return
|
||
}
|
||
// 1. 清空 editor + 当前会话草稿;syncEditorState 后 plain 已为空,store 内部会自动清,
|
||
// 但显式 clearDraft 能立即同步、不依赖 debounce 时序,列表上的 [草稿] 立即消失
|
||
editor.innerHTML = ''
|
||
if (conversationStore.activeConversation) {
|
||
draftStore.clearDraft(conversationStore.activeConversation)
|
||
}
|
||
syncEditorState()
|
||
// 2. 发送
|
||
await send(text, {
|
||
atUserIds: atUserIds.length > 0 ? atUserIds : undefined,
|
||
receipt: options?.receipt
|
||
})
|
||
}
|
||
|
||
/** 发送按钮 dropdown 菜单回调:选"发送回执消息"时这一次带 receipt=true,每次独立决定 */
|
||
function handleSendCommand(command: string) {
|
||
if (command === 'receipt') {
|
||
handleSend({ receipt: true })
|
||
}
|
||
}
|
||
|
||
// ==================== 选区 / 插入 ====================
|
||
/**
|
||
* 上次落在 editor 内的 selection(焦点被表情面板等夺走时用来回到原插入点)
|
||
*
|
||
* 监听 document.selectionchange 比 editor.@blur 更可靠:blur 时 selection 已经移走
|
||
*/
|
||
let savedRange: Range | null = null
|
||
|
||
/**
|
||
* 走 native execCommand,保留浏览器原生 undo 栈
|
||
*
|
||
* execCommand 在 lib.dom 里被标 @deprecated(IDE 显示删除线),但 'insertText' /
|
||
* 'insertLineBreak' 没有等价的 W3C 标准替代——Range/Selection 自己拼 DOM 会让 Ctrl+Z 失效。
|
||
* 集中到这里调用,调用点不必散落 ts-expect-error;不引入 eslint disable(项目当前
|
||
* @typescript-eslint v7 没有 no-deprecated 规则,加了也无效,反而让 lint 报"规则不存在")
|
||
*/
|
||
function nativeExec(command: 'insertText' | 'insertLineBreak', value?: string) {
|
||
document.execCommand(command, false, value)
|
||
}
|
||
|
||
/**
|
||
* document selectionchange 监听:把落在 editor 内的 selection 缓存到 savedRange,
|
||
* insertText 在焦点被偷走后用它把光标恢复到原插入点
|
||
*/
|
||
function onSelectionChange() {
|
||
const editor = editorRef.value
|
||
const sel = window.getSelection()
|
||
if (!editor || !sel || sel.rangeCount === 0) {
|
||
return
|
||
}
|
||
const range = sel.getRangeAt(0)
|
||
if (editor.contains(range.startContainer)) {
|
||
savedRange = range.cloneRange()
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 点击 editor / picker 外部时关掉浮层,避免输入 @keyword 后用户点别处浮层不消失
|
||
*
|
||
* 用 mousedown 而非 click:click 在某些浏览器里 picker 元素消失后回不到原 target,会被吞掉
|
||
*/
|
||
function onDocMousedown(e: MouseEvent) {
|
||
if (!mentionVisible.value) {
|
||
return
|
||
}
|
||
const target = e.target as Node | null
|
||
if (!target) {
|
||
return
|
||
}
|
||
if (editorRef.value?.contains(target)) {
|
||
return
|
||
}
|
||
// picker 是 fixed 定位的兄弟节点,不在 editor 子树里;用类名定位
|
||
const pickerEl = document.querySelector('.message-input__mention-picker')
|
||
if (pickerEl?.contains(target)) {
|
||
return
|
||
}
|
||
closeMention()
|
||
}
|
||
|
||
onMounted(() => {
|
||
document.addEventListener('selectionchange', onSelectionChange)
|
||
document.addEventListener('mousedown', onDocMousedown)
|
||
restoreDraftToEditor()
|
||
})
|
||
|
||
onBeforeUnmount(() => {
|
||
document.removeEventListener('selectionchange', onSelectionChange)
|
||
document.removeEventListener('mousedown', onDocMousedown)
|
||
})
|
||
|
||
/**
|
||
* 切会话时还原对方的草稿到 editor
|
||
*
|
||
* 同步关 @ / 表情 / 语音浮层并清 savedRange:
|
||
* - mentionRange / savedRange 旧引用还指向上一会话的 DOM 节点,不清下次插 token 会落错位置
|
||
* - 语音录制弹窗保留时,录完触发的 onVoiceSend 会读当前 activeConversation,把语音发到新会话
|
||
*/
|
||
watch(
|
||
() =>
|
||
conversationStore.activeConversation
|
||
? getConversationKey(conversationStore.activeConversation)
|
||
: null,
|
||
() => {
|
||
closeMention()
|
||
emojiVisible.value = false
|
||
voiceVisible.value = false
|
||
savedRange = null
|
||
restoreDraftToEditor()
|
||
}
|
||
)
|
||
|
||
/**
|
||
* 把字符串插入光标处(emoji 面板等场景调用)
|
||
*
|
||
* 1. editor 没挂直接返回
|
||
* 2. 焦点回到 editor + 把 savedRange 恢复成当前 selection(emoji 面板偷焦点后还能回原位)
|
||
* 3. nativeExec 插文本,保留浏览器原生 undo 栈
|
||
* 4. 同步 canSend / placeholder
|
||
*/
|
||
function insertText(str: string) {
|
||
const editor = editorRef.value
|
||
if (!editor) {
|
||
return
|
||
}
|
||
editor.focus()
|
||
if (savedRange) {
|
||
const sel = window.getSelection()
|
||
if (sel) {
|
||
sel.removeAllRanges()
|
||
sel.addRange(savedRange)
|
||
}
|
||
}
|
||
// 1. nativeExec 插文本,保留浏览器原生 undo 栈
|
||
nativeExec('insertText', str)
|
||
// 2. 同步 canSend / placeholder
|
||
syncEditorState()
|
||
}
|
||
|
||
/**
|
||
* 粘贴处理
|
||
*
|
||
* 1. 优先扫 clipboardData.items 找文件类型条目(截图、拖入的图片 / 文件等)
|
||
* - image/* → 走 IMAGE 上传发送
|
||
* - 其它 file → 走 FILE 上传发送
|
||
* - 一次粘贴只处理第一个文件,避免一次粘贴发出多条消息
|
||
* 2. 没文件再走 plain text:剥掉外部样式 / 脚本,避免外站 inline style 污染 editor
|
||
* (contenteditable 默认粘贴会带 HTML,所以模板上 @paste.prevent 拦截)
|
||
*/
|
||
function onPaste(e: ClipboardEvent) {
|
||
// 1. 优先扫 clipboardData.items 找文件类型条目(截图、拖入的图片 / 文件等)
|
||
const items = e.clipboardData?.items
|
||
if (items?.length) {
|
||
for (let i = 0; i < items.length; i++) {
|
||
const item = items[i]
|
||
if (item.kind !== 'file') {
|
||
continue
|
||
}
|
||
const file = item.getAsFile()
|
||
if (!file) {
|
||
continue
|
||
}
|
||
if (item.type.startsWith('image/')) {
|
||
void uploadAndSendImage(file)
|
||
} else {
|
||
void uploadAndSendFile(file)
|
||
}
|
||
return
|
||
}
|
||
}
|
||
// 2. 没文件再走 plain text:剥掉外部样式 / 脚本,避免外站 inline style 污染 editor
|
||
const text = e.clipboardData?.getData('text/plain') || ''
|
||
if (text) {
|
||
nativeExec('insertText', text)
|
||
// @paste.prevent 阻断了浏览器默认 input 事件,需手动同步草稿 / canSend,与 insertText() 路径一致
|
||
syncEditorState()
|
||
}
|
||
}
|
||
|
||
/** 编辑器内容变化的统一入口:先同步 canSend / placeholder,再判 @ 浮层是否要展开 */
|
||
function onInput() {
|
||
syncEditorState()
|
||
detectAtMention()
|
||
}
|
||
|
||
// ==================== 表情 ====================
|
||
const emojiVisible = ref(false)
|
||
function toggleEmoji() {
|
||
emojiVisible.value = !emojiVisible.value
|
||
}
|
||
|
||
// ==================== @ 成员选择(群聊) ====================
|
||
const isGroup = computed(
|
||
() => conversationStore.activeConversation?.type === ImConversationType.GROUP
|
||
)
|
||
|
||
/** 从 groupStore 读当前激活群的成员(切会话时由 MessagePanel 预拉) */
|
||
const groupMembers = computed<GroupMemberLite[]>(() => {
|
||
const conversation = conversationStore.activeConversation
|
||
if (!conversation || conversation.type !== ImConversationType.GROUP) {
|
||
return []
|
||
}
|
||
const group = groupStore.getGroup(conversation.targetId)
|
||
return (group?.members || []).map((member) => {
|
||
const friend = friendStore.getFriend(member.userId)
|
||
return {
|
||
userId: member.userId,
|
||
showName: getMemberDisplayName(member, friend),
|
||
nickname: member.nickname,
|
||
avatar: member.avatar,
|
||
status: member.status
|
||
}
|
||
})
|
||
})
|
||
|
||
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('')
|
||
/** 浮层定位:x 是左边距;top / bottom 二选一—— bottom 锚定(picker 下沿贴 @)是默认,
|
||
* 上方放不下时退化为 top 锚定(picker 上沿贴 @ 下方) */
|
||
const mentionPosition = ref<{ x: number; top?: number; bottom?: number }>({ x: 0, bottom: 0 })
|
||
|
||
/** MentionPicker 的容器宽度(与组件里的 w-50 对齐),用于视口右沿回弹;
|
||
* 高度不再用常量算位置——bottom 锚定后 picker 内容多寡都不影响下沿位置,自然贴 @ */
|
||
const MENTION_WIDTH = 200
|
||
/** 上方剩余空间至少这么多才放上方,否则翻到下方(避免 picker 被视口顶 / 顶部 chat header 切掉) */
|
||
const MENTION_MIN_FIT_ABOVE = 120
|
||
|
||
/** 当前 @ 关键词在 editor 里的范围;onMentionSelect 用它定位删除 + 插入 token */
|
||
let mentionRange: Range | null = null
|
||
|
||
/** 关闭浮层 + 清掉 range,避免上次残留的 range 被下一次 onMentionSelect 误用 */
|
||
function closeMention() {
|
||
mentionVisible.value = false
|
||
mentionRange = null
|
||
}
|
||
|
||
/** 在光标当前文本节点里向前找 @keyword,命中则展开浮层 */
|
||
function detectAtMention() {
|
||
if (!isGroup.value) {
|
||
closeMention()
|
||
return
|
||
}
|
||
const sel = window.getSelection()
|
||
if (!sel || sel.rangeCount === 0) {
|
||
closeMention()
|
||
return
|
||
}
|
||
const range = sel.getRangeAt(0)
|
||
if (!range.collapsed || range.startContainer.nodeType !== Node.TEXT_NODE) {
|
||
closeMention()
|
||
return
|
||
}
|
||
const node = range.startContainer
|
||
const offset = range.startOffset
|
||
const before = (node.textContent || '').slice(0, offset)
|
||
// 直接找最近的 @:不限制前置字符,对齐微信"中文紧贴 @ 也能联想"(你好@张三、,@张三 都触发);
|
||
// 代价是 email-like "test@example.com" 也会触发,但聊天输入里粘 email 的概率低,
|
||
// 且用户按 Esc 即可关浮层;兜底由 [^\s@] 保证一旦输入空格 / 第二个 @ 就停下
|
||
const match = before.match(/@([^\s@]*)$/)
|
||
if (!match) {
|
||
closeMention()
|
||
return
|
||
}
|
||
const atOffset = offset - match[1].length - 1
|
||
mentionRange = document.createRange()
|
||
mentionRange.setStart(node, atOffset)
|
||
mentionRange.setEnd(node, offset)
|
||
mentionSearchText.value = match[1]
|
||
// 锚定在 @ 符号本身,而非当前 caret——否则用户每多敲一个字浮层就跟着右移("飘")
|
||
positionMention(node, atOffset)
|
||
mentionVisible.value = true
|
||
}
|
||
|
||
/**
|
||
* 浮层位置:默认 bottom 锚定(picker 下沿贴 @ 上方 8px),上方不够才翻成 top 锚定
|
||
*
|
||
* 1. 计算 @ 字符屏幕坐标 rect
|
||
* 2. 横向:picker 左边对齐 @,越过视口右沿则左推;至少留 8px 留白
|
||
* 3. 纵向:上方剩余 ≥ MENTION_MIN_FIT_ABOVE 走 bottom 锚定(不依赖 picker 实际高度,
|
||
* 无论 1 项还是 N 项 picker 下沿都贴 @);不够则翻到 @ 下方走 top 锚定
|
||
*/
|
||
function positionMention(node: Node, atOffset: number) {
|
||
// 1. 计算 @ 字符屏幕坐标 rect
|
||
const anchor = document.createRange()
|
||
anchor.setStart(node, atOffset)
|
||
anchor.collapse(true)
|
||
const rect = anchor.getBoundingClientRect()
|
||
// 2. 横向:picker 左边对齐 @,越过视口右沿则左推;至少留 8px 留白
|
||
const left = Math.max(8, Math.min(rect.left, window.innerWidth - MENTION_WIDTH - 8))
|
||
// 3. 纵向:上方剩余 ≥ MENTION_MIN_FIT_ABOVE 走 bottom 锚定
|
||
if (rect.top >= MENTION_MIN_FIT_ABOVE) {
|
||
mentionPosition.value = { x: left, bottom: window.innerHeight - rect.top + 8 }
|
||
} else {
|
||
mentionPosition.value = { x: left, top: rect.bottom + 8 }
|
||
}
|
||
}
|
||
|
||
/** editor 内部滚动时同步浮层位置(多行 + 触发滚动条场景) */
|
||
function onEditorScroll() {
|
||
if (!mentionVisible.value || !mentionRange) {
|
||
return
|
||
}
|
||
positionMention(mentionRange.startContainer, mentionRange.startOffset)
|
||
}
|
||
|
||
function onMentionSelect(member: GroupMemberLite) {
|
||
const editor = editorRef.value
|
||
if (!editor || !mentionRange) {
|
||
return
|
||
}
|
||
// 删 @keyword,插入 contenteditable=false 的 token:
|
||
// 删除时整段消除 + 不会被光标拆穿;data-id 是后续 collectFromEditor 收 atUserIds 的钩子
|
||
mentionRange.deleteContents()
|
||
const span = document.createElement('span')
|
||
span.className = 'mention-token'
|
||
span.dataset.id = String(member.userId)
|
||
span.contentEditable = 'false'
|
||
span.textContent = `@${member.showName}`
|
||
mentionRange.insertNode(span)
|
||
// token 在 editor 首位时,contenteditable=false 边缘会让光标无法挪到 token 前
|
||
// 补一个零宽空格 当锚点;DOM walk 时会被滤掉,不进入发送内容
|
||
const prev = span.previousSibling
|
||
if (!prev || (prev.nodeType === Node.TEXT_NODE && !prev.textContent)) {
|
||
span.parentNode?.insertBefore(document.createTextNode(''), span)
|
||
}
|
||
// 在 token 后补一个 NBSP,让光标可以继续输入;NBSP 比普通空格更稳,避免被浏览器折叠
|
||
const space = document.createTextNode(' ')
|
||
span.parentNode?.insertBefore(space, span.nextSibling)
|
||
// 光标移到 NBSP 之后
|
||
const sel = window.getSelection()
|
||
if (sel) {
|
||
const newRange = document.createRange()
|
||
newRange.setStartAfter(space)
|
||
newRange.collapse(true)
|
||
sel.removeAllRanges()
|
||
sel.addRange(newRange)
|
||
}
|
||
closeMention()
|
||
editor.focus()
|
||
syncEditorState()
|
||
}
|
||
|
||
/**
|
||
* 键盘事件分发
|
||
*
|
||
* 1. mention 浮层打开时
|
||
* 1.1 ↑/↓ 移动高亮
|
||
* 1.2 Enter 有候选 → 选中;无候选 fall through 到下面的发送分支,避免按 Enter 没反应
|
||
* 1.3 Esc 关浮层
|
||
* 2. Shift+Enter 换行:强制走 br(浏览器默认会插 div,DOM walk 拼接更复杂)
|
||
* 3. 普通 Enter 发送(IME composition 期间不触发,避免选词被误发)
|
||
*/
|
||
function onKeydown(e: KeyboardEvent) {
|
||
// 1. mention 浮层打开时
|
||
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.isComposing) {
|
||
if (mentionRef.value?.hasCandidates()) {
|
||
e.preventDefault()
|
||
mentionRef.value?.pickActive()
|
||
return
|
||
}
|
||
}
|
||
if (e.key === 'Escape') {
|
||
closeMention()
|
||
return
|
||
}
|
||
}
|
||
// 2. Shift+Enter 换行:强制走 br(浏览器默认会插 div,DOM walk 拼接更复杂)
|
||
if (e.key === 'Enter' && e.shiftKey && !e.isComposing) {
|
||
e.preventDefault()
|
||
nativeExec('insertLineBreak')
|
||
syncEditorState()
|
||
return
|
||
}
|
||
// 3. 普通 Enter 发送(IME composition 期间不触发,避免选词被误发)
|
||
if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey && !e.isComposing) {
|
||
e.preventDefault()
|
||
handleSend()
|
||
}
|
||
}
|
||
|
||
// ==================== 图片 / 文件上传 ====================
|
||
/** 上传并发送 IMAGE 消息;文件选择器和粘贴板都复用这条 */
|
||
async function uploadAndSendImage(file: File) {
|
||
const form = new FormData()
|
||
form.append('file', file)
|
||
const url = ((await updateFile(form)) as { data?: string })?.data
|
||
if (!url) {
|
||
return
|
||
}
|
||
await sendRaw(ImMessageType.IMAGE, serializeMessage<ImageMessage>({ url }))
|
||
}
|
||
|
||
/** 上传并发送 FILE 消息;附原始 name / size 让接收端展示文件名和体积 */
|
||
async function uploadAndSendFile(file: File) {
|
||
const form = new FormData()
|
||
form.append('file', file)
|
||
const url = ((await updateFile(form)) as { data?: string })?.data
|
||
if (!url) {
|
||
return
|
||
}
|
||
await sendRaw(
|
||
ImMessageType.FILE,
|
||
serializeMessage<FileMessage>({ url, name: file.name, size: file.size })
|
||
)
|
||
}
|
||
|
||
/** 图片选完即上传 + 发送 IMAGE 消息(不放入 editor,整体走 sendRaw) */
|
||
async function onImagePicked(e: Event) {
|
||
const input = e.target as HTMLInputElement
|
||
const file = input.files?.[0]
|
||
input.value = ''
|
||
if (file) {
|
||
await uploadAndSendImage(file)
|
||
}
|
||
}
|
||
|
||
/** 文件选完即上传 + 发送 FILE 消息(携带原始 name / size 元数据) */
|
||
async function onFilePicked(e: Event) {
|
||
const input = e.target as HTMLInputElement
|
||
const file = input.files?.[0]
|
||
input.value = ''
|
||
if (file) {
|
||
await uploadAndSendFile(file)
|
||
}
|
||
}
|
||
|
||
// ==================== 语音 ====================
|
||
const voiceVisible = ref(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 })
|
||
const form = new FormData()
|
||
form.append('file', file)
|
||
// request.upload 返回完整 axios response(不是 res.data,跟 get/post/put 不一样),URL 在 .data 里取
|
||
const url = ((await updateFile(form)) as { data?: string })?.data
|
||
if (!url) {
|
||
return
|
||
}
|
||
await sendRaw(
|
||
ImMessageType.VOICE,
|
||
serializeMessage<AudioMessage>({ url, duration: payload.duration })
|
||
)
|
||
}
|
||
|
||
// ==================== 视频 ====================
|
||
type VideoProbe = {
|
||
duration?: number
|
||
width?: number
|
||
height?: number
|
||
cover?: Blob
|
||
}
|
||
|
||
const VIDEO_COVER_MAX_DIM = 720 // 封面最长边 cap:聊天列表里的视频封面没必要原视频分辨率,4K 原尺寸 jpeg 1-3MB 太浪费
|
||
|
||
/**
|
||
* 加载视频本地预览,一次性拿到 metadata(duration / 宽高)+ 首帧封面 blob
|
||
*
|
||
* 一个 video 元素串两件事是为了避免重复 decode:metadata 解完后直接 seek 首帧再截图。
|
||
* 截图失败不抛异常,只让 cover 缺失,保证主流程仍能上传视频本体。
|
||
*
|
||
* finally 里显式断引用是因为:仅 revokeObjectURL 不足以让 video decoder 立即释放,
|
||
* 部分浏览器版本上 4K 视频解码 buffer 可滞留数十 MB 几秒到十几秒,连发几条会累计放大。
|
||
*/
|
||
async function probeVideoFile(file: File): Promise<VideoProbe> {
|
||
// 1. 准备离屏 video
|
||
// 1.1 muted + preload=metadata:只下载文件头,不预加载整条流
|
||
const objectUrl = URL.createObjectURL(file)
|
||
const video = document.createElement('video')
|
||
video.preload = 'metadata'
|
||
video.muted = true
|
||
video.src = objectUrl
|
||
try {
|
||
// 1.2 等 metadata 加载:解出 duration / 宽高才有 seek + 截图的依据
|
||
await new Promise<void>((resolve, reject) => {
|
||
video.onloadedmetadata = () => resolve()
|
||
video.onerror = () => reject(new Error('video metadata load error'))
|
||
})
|
||
// 1.3 抽元信息:duration 偶有 NaN(极少数损坏文件),软处理为 undefined
|
||
const meta = {
|
||
duration: Number.isFinite(video.duration) ? Math.round(video.duration) : undefined,
|
||
width: video.videoWidth || undefined,
|
||
height: video.videoHeight || undefined
|
||
}
|
||
|
||
// 2. 截首帧封面(独立 try:失败仅降级 cover 为空,不影响 meta)
|
||
let cover: Blob | undefined
|
||
try {
|
||
// 2.1 算 seek 时间:0.1s 避开常见的纯黑首帧;时长 < 0.2s 的极短视频退化为 0
|
||
const seekTo = video.duration > 0.2 ? 0.1 : 0
|
||
// 2.2 seek + 3s 超时:currentTime 设为当前值(譬如已经是 0 的极短视频)
|
||
// 部分浏览器不触发 onseeked,promise 会一直 pending 卡死整条链路
|
||
await new Promise<void>((resolve, reject) => {
|
||
const timer = setTimeout(() => reject(new Error('video seek timeout')), 3000)
|
||
video.onseeked = () => {
|
||
clearTimeout(timer)
|
||
resolve()
|
||
}
|
||
video.onerror = () => {
|
||
clearTimeout(timer)
|
||
reject(new Error('video seek error'))
|
||
}
|
||
video.currentTime = seekTo
|
||
})
|
||
// 2.3 离屏 canvas 等比缩放:长边 cap 720(VIDEO_COVER_MAX_DIM)
|
||
const canvas = document.createElement('canvas')
|
||
const ratio = Math.min(1, VIDEO_COVER_MAX_DIM / Math.max(video.videoWidth, video.videoHeight))
|
||
canvas.width = Math.round(video.videoWidth * ratio)
|
||
canvas.height = Math.round(video.videoHeight * ratio)
|
||
const ctx = canvas.getContext('2d')
|
||
if (ctx && canvas.width && canvas.height) {
|
||
// 2.4 当前帧绘到 canvas → toBlob 拿 jpeg;0.8 质量是聊天封面常用甜点
|
||
ctx.drawImage(video, 0, 0, canvas.width, canvas.height)
|
||
cover =
|
||
(await new Promise<Blob | null>((resolve) =>
|
||
canvas.toBlob((b) => resolve(b), 'image/jpeg', 0.8)
|
||
)) ?? undefined
|
||
// 2.5 提前释放 canvas backing store(4K 原尺寸 33MB),别等 GC
|
||
canvas.width = 0
|
||
canvas.height = 0
|
||
}
|
||
} catch (e) {
|
||
console.warn('[IM] 视频封面截取失败', e)
|
||
}
|
||
return { ...meta, cover }
|
||
} finally {
|
||
// 3. 显式释放 video 资源
|
||
// 3.1 revoke 本地 objectUrl
|
||
URL.revokeObjectURL(objectUrl)
|
||
// 3.2 解绑事件 + 触发 unload,让 decoder buffer 立即释放(不然可滞留数十 MB 数秒)
|
||
video.onloadedmetadata = null
|
||
video.onseeked = null
|
||
video.onerror = null
|
||
video.removeAttribute('src')
|
||
video.load()
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 上传并发送 VIDEO 消息
|
||
*
|
||
* 1. probe 与视频上传同步起跑;封面上传等 probe 出 cover 后与视频上传竞速
|
||
* (probe 解码 + 封面上传通常被视频上传时长完全遮蔽,体感节省几百 ms 起步)
|
||
* 2. 视频本体上传必须成功,拿不到 url 就直接 return
|
||
* 3. 封面是锦上添花:上传失败仅日志,coverUrl 留空,气泡 <video> 自带黑底播放按钮兜底
|
||
*
|
||
* 视频链路耗时长(probe + 双上传),上传期间用户切会话则放弃发送,
|
||
* 否则会落到错误的会话里;切走再切回来不算变化(key 仍相等)。
|
||
*/
|
||
async function uploadAndSendVideo(file: File) {
|
||
// 1. 锁定起始会话 key
|
||
// 1.1 上传期间用户切走则不发到错误目标;切走再切回来 key 仍相等,不算变化
|
||
const startConversation = conversationStore.activeConversation
|
||
if (!startConversation) {
|
||
return
|
||
}
|
||
const startKey = getConversationKey(startConversation)
|
||
|
||
// 2. 三路并行起跑(probe 与两条上传无依赖,封面上传等 probe 出 cover 后立即接力)
|
||
// 2.1 视频本体上传:立即 catch 兜底为 url=undefined,由 step 3.2 拿不到 url 时放弃;同时让 promise 不再 floating
|
||
const videoForm = new FormData()
|
||
videoForm.append('file', file)
|
||
const videoUploadPromise = (updateFile(videoForm) as Promise<{ data?: string }>).catch((e) => {
|
||
console.warn('[IM] 视频本体上传失败', e)
|
||
return { data: undefined as string | undefined }
|
||
})
|
||
// 2.2 probe 拿元信息 + 封面 blob:解码失败降级为空 probe,不阻断视频上传
|
||
const probePromise = probeVideoFile(file).catch((e): VideoProbe => {
|
||
console.warn('[IM] 视频元信息加载失败,降级为仅 url + size', e)
|
||
return {}
|
||
})
|
||
// 2.3 封面上传:等 probe.cover 出来后接力起跑,与视频上传竞速;失败降级 coverUrl 为空
|
||
const coverUploadPromise = probePromise.then(async (probe) => {
|
||
if (!probe.cover) {
|
||
return { probe, coverUrl: undefined as string | undefined }
|
||
}
|
||
try {
|
||
const coverForm = new FormData()
|
||
coverForm.append(
|
||
'file',
|
||
new File([probe.cover], `cover-${Date.now()}.jpg`, { type: 'image/jpeg' })
|
||
)
|
||
const coverUrl = ((await updateFile(coverForm)) as { data?: string })?.data || undefined
|
||
return { probe, coverUrl }
|
||
} catch (e) {
|
||
console.warn('[IM] 视频封面上传失败', e)
|
||
return { probe, coverUrl: undefined as string | undefined }
|
||
}
|
||
})
|
||
|
||
// 3. 收口校验
|
||
// 3.1 等两条上传链路汇合
|
||
const [videoRes, { probe, coverUrl }] = await Promise.all([
|
||
videoUploadPromise,
|
||
coverUploadPromise
|
||
])
|
||
// 3.2 视频本体没 url 直接放弃(封面也不再有意义)
|
||
const url = videoRes?.data
|
||
if (!url) {
|
||
return
|
||
}
|
||
// 3.3 校验会话仍是发送时锁定的那个,否则放弃;视频链路耗时长,这个窗口很实际
|
||
const currentConversation = conversationStore.activeConversation
|
||
if (!currentConversation || getConversationKey(currentConversation) !== startKey) {
|
||
console.warn('[IM] 视频上传期间切换了会话,放弃发送', { startKey })
|
||
return
|
||
}
|
||
|
||
// 4. 拼 VideoMessage payload 走通用 sendRaw(与图片 / 文件 / 语音同链路)
|
||
await sendRaw(
|
||
ImMessageType.VIDEO,
|
||
serializeMessage<VideoMessage>({
|
||
url,
|
||
coverUrl,
|
||
duration: probe.duration,
|
||
width: probe.width,
|
||
height: probe.height,
|
||
size: file.size
|
||
})
|
||
)
|
||
}
|
||
|
||
/** 视频选完即上传 + 发送 VIDEO 消息(不放入 editor,整体走 sendRaw) */
|
||
async function onVideoPicked(e: Event) {
|
||
const input = e.target as HTMLInputElement
|
||
const file = input.files?.[0]
|
||
input.value = ''
|
||
if (file) {
|
||
await uploadAndSendVideo(file)
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
/* 输入框卡片外框 + 编辑区与工具栏之间的分隔线:UnoCSS 不带 border-style preflight,
|
||
border-* 类只设色 / 宽不出线,统一走 scoped 显式 shorthand 兜底 */
|
||
.message-input__card {
|
||
border: 1px solid var(--el-border-color-lighter);
|
||
}
|
||
.message-input__toolbar {
|
||
border-top: 1px solid var(--el-border-color-lighter);
|
||
}
|
||
|
||
/* 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;
|
||
}
|
||
|
||
/* 输入区在上、工具栏在下时,编辑区视觉上承担"主体",min-height / padding 都比早期版本撑大,
|
||
贴近微信 PC 的"大输入框"观感;max-height 限内部滚动,避免聊天列表被挤太短 */
|
||
.message-input__editor {
|
||
position: relative;
|
||
min-height: 120px;
|
||
max-height: 200px;
|
||
overflow-y: auto;
|
||
padding: 14px 16px;
|
||
font-size: 14px;
|
||
line-height: 1.5;
|
||
outline: none;
|
||
white-space: pre-wrap;
|
||
word-break: break-word;
|
||
}
|
||
|
||
/* 用 data-empty 而非 :empty:浏览器在删空后会留下 <br>,:empty 不命中;data-empty 由 syncEditorState 维护 */
|
||
.message-input__editor[data-empty]::before {
|
||
content: attr(data-placeholder);
|
||
color: var(--el-text-color-placeholder);
|
||
pointer-events: none;
|
||
position: absolute;
|
||
}
|
||
|
||
/* @ token 走主色高亮;contenteditable=false 让 backspace 整段删而不是逐字符 */
|
||
.message-input__editor :deep(.mention-token) {
|
||
color: var(--el-color-primary);
|
||
}
|
||
</style>
|