✨ feat(im): MessageInput / MentionPicker / ChatPanel 三连修——粘贴文件、切群清空、命名规范

【ChatPanel.vue】
- 加 messageInputKey computed(type-targetId)+ MessageInput :key 绑它,
  切会话强制 unmount + remount editor / mention range / 草稿全归零,
  避免 A 群打了一半的字 / @ token 漏到 B 群被发出去
  (早先用 inline template literal 做 :key,Vue SFC 编译没把表达式接到
  vnode.key 上,hmr / 完整 reload 都看到 key=null;改 computed 后正常)
【MessageInput.vue】
- onPaste 加 clipboardData.items 扫一轮:image/* → uploadAndSendImage,
  其它 file → uploadAndSendFile,纯文本兜底走 nativeExec('insertText');
  截图 / 拖入图片 / 拖入文件不再被默默吞掉
- 抽 uploadAndSendImage / uploadAndSendFile 两个共用函数,
  onImagePicked / onFilePicked 改成薄包装走它们,避免上传逻辑双份
- 删 nativeExec 里的 // eslint-disable-next-line @typescript-eslint/no-deprecated:
  项目当前 @typescript-eslint v7 没有这条规则,加了会让 lint 报"规则不存在",
  反而把 lint 拖红;改用单纯 JSDoc 解释为什么留着 execCommand
- 重命名 mentionPos → mentionPosition(prop / ref 一致),按"变量不缩写"
- 7 个方法补 JSDoc:onSelectionChange / insertText / onPaste / onInput /
  onKeydown / onImagePicked / onFilePicked / onVoiceSend;复杂的
  collectFromEditor 和 handleSend 加分步 1./2./3. 内联注释
- data-empty 改用属性"存在 / 缺失"模拟(template 里 data-empty="",JS 里
  raw 为空就 set ''、否则 delete),CSS 选择器同步改 [data-empty],
  比 [data-empty='true'] 直观
【MentionPicker.vue】
- prop pos → position(不缩写);ref / 内部解构 / 默认值都跟着改
- <el-icon><UserFilled /></el-icon> → <Icon icon="ep:user-filled">:
  用全局 Icon 组件走 Iconify,少一个 EP 图标 import
- scrollToTop / scrollToActive 局部变量 wrap → scrollWrap、
  itemH → itemHeight、activeTop → activeOffsetTop;
  v-for 与 handleSelect 的 (m) → (member)
pull/881/head
YunaiV 2026-04-27 13:57:18 +08:00
parent 678c2d6834
commit cba5c15604
2 changed files with 136 additions and 75 deletions

View File

@ -9,9 +9,9 @@
v-show="visible && showMembers.length > 0"
class="message-input__mention-picker !fixed z-100 w-50 rounded-md bg-[var(--el-bg-color)] shadow-[0_4px_16px_rgba(0,0,0,0.12)]"
:style="{
left: pos.x + 'px',
top: pos.top != null ? pos.top + 'px' : 'auto',
bottom: pos.bottom != null ? pos.bottom + 'px' : 'auto'
left: position.x + 'px',
top: position.top != null ? position.top + 'px' : 'auto',
bottom: position.bottom != null ? position.bottom + 'px' : 'auto'
}"
>
<el-scrollbar ref="scrollRef" max-height="300px">
@ -25,7 +25,7 @@
<div
class="flex items-center justify-center w-[30px] h-[30px] rounded text-white bg-[var(--el-color-primary)] flex-shrink-0"
>
<el-icon :size="18"><UserFilled /></el-icon>
<Icon icon="ep:user-filled" :size="18" />
</div>
<span class="overflow-hidden text-sm truncate text-[var(--el-text-color-regular)]">
{{ allItem.showNickName }}
@ -42,13 +42,13 @@
<!-- 真成员行 -->
<ChatGroupMember
v-for="(m, idx) in memberItems"
:key="m.userId"
:member="m"
v-for="(member, idx) in memberItems"
:key="member.userId"
:member="member"
:height="40"
:active="activeIdx === (allItem ? idx + 1 : idx)"
:clickable="false"
@click.stop="handleSelect(m)"
@click.stop="handleSelect(member)"
/>
</el-scrollbar>
@ -60,8 +60,8 @@
<script lang="ts" setup>
import { computed, ref, useTemplateRef, watch } from 'vue'
import { ElScrollbar } from 'element-plus'
import { UserFilled } from '@element-plus/icons-vue'
import Icon from '@/components/Icon/src/Icon.vue'
import { useUserStore } from '@/store/modules/user'
import { IM_AT_ALL_NICKNAME, IM_AT_ALL_USER_ID } from '@/views/im/utils/constants'
import ChatGroupMember, { type GroupMemberLite } from '../ChatGroupMember.vue'
@ -72,14 +72,14 @@ const props = withDefaults(
defineProps<{
visible: boolean // 是否显示
// 浮层位置:x 横坐标 + top / bottom 二选一(bottom 锚定时 picker 下沿贴 @ 上方)
pos: { x: number; top?: number; bottom?: number }
position: { x: number; top?: number; bottom?: number }
members: GroupMemberLite[] // 当前群的成员列表
searchText?: string // @ 后输入的过滤文本
ownerId?: number // 群主 id,判断是否能展示"所有人"
}>(),
{
searchText: '',
pos: () => ({ x: 0, bottom: 0 })
position: () => ({ x: 0, bottom: 0 })
}
)
@ -151,31 +151,34 @@ watch(
/** el-scrollbar 没暴露 scrollTo,直接拿内部 wrap 调 scrollTop */
function scrollToTop() {
const wrap = scrollRef.value?.$el?.querySelector('.el-scrollbar__wrap') as HTMLElement | null
if (wrap) {
wrap.scrollTop = 0
const scrollWrap = scrollRef.value?.$el?.querySelector(
'.el-scrollbar__wrap'
) as HTMLElement | null
if (scrollWrap) {
scrollWrap.scrollTop = 0
}
}
/** 键盘上下导航时把高亮项滚到可视区:超出底边下推、超出顶边上拉,否则不动 */
// TODO @AI:变量尽量完整!
function scrollToActive() {
const wrap = scrollRef.value?.$el?.querySelector('.el-scrollbar__wrap') as HTMLElement | null
if (!wrap) {
const scrollWrap = scrollRef.value?.$el?.querySelector(
'.el-scrollbar__wrap'
) as HTMLElement | null
if (!scrollWrap) {
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
const itemHeight = 40
const activeOffsetTop = activeIdx.value * itemHeight
if (activeOffsetTop + itemHeight > scrollWrap.scrollTop + scrollWrap.clientHeight) {
scrollWrap.scrollTop = activeOffsetTop + itemHeight - scrollWrap.clientHeight
} else if (activeOffsetTop < scrollWrap.scrollTop) {
scrollWrap.scrollTop = activeOffsetTop
}
}
/** 选中一项:emit 给 MessageInput 落 token,同时关掉浮层 */
function handleSelect(m: GroupMemberLite) {
emit('select', m)
function handleSelect(member: GroupMemberLite) {
emit('select', member)
emit('update:visible', false)
}

View File

@ -77,7 +77,7 @@
<MentionPicker
ref="mentionRef"
v-model:visible="mentionVisible"
:pos="mentionPos"
:position="mentionPosition"
:members="groupMembers"
:search-text="mentionSearchText"
:owner-id="groupOwnerId"
@ -171,7 +171,6 @@ function collectFromEditor(root: HTMLElement): { text: string; atUserIds: number
let text = ''
function walk(node: Node) {
// 1. text 节点
if (node.nodeType === Node.TEXT_NODE) {
text += (node.textContent || '').replace(/​/g, '')
return
@ -181,12 +180,10 @@ function collectFromEditor(root: HTMLElement): { text: string; atUserIds: number
}
const el = node as HTMLElement
const tag = el.tagName.toLowerCase()
// 2. br
if (tag === 'br') {
text += '\n'
return
}
// 3. span[data-id]:mention token
if (tag === 'span' && el.dataset.id) {
text += el.textContent || ''
const id = Number(el.dataset.id)
@ -195,7 +192,6 @@ function collectFromEditor(root: HTMLElement): { text: string; atUserIds: number
}
return
}
// 4. div:行级换行容器
if (tag === 'div') {
if (text && !text.endsWith('\n')) {
text += '\n'
@ -203,10 +199,10 @@ function collectFromEditor(root: HTMLElement): { text: string; atUserIds: number
el.childNodes.forEach(walk)
return
}
// 5. 其他元素:递归
el.childNodes.forEach(walk)
}
// 直接从 root.childNodes 开始,避免把 root 本身也当元素处理(虽然目前没有特殊样式,但以防未来改动)
root.childNodes.forEach(walk)
return {
text: text.trim(),
@ -225,21 +221,18 @@ function collectFromEditor(root: HTMLElement): { text: string; atUserIds: number
* 5. 上送:atUserIds 非空才传,避免发空数组
*/
async function handleSend() {
// 1.
const editor = editorRef.value
if (!canSend.value || !editor) {
return
}
// 2.
const { text, atUserIds } = collectFromEditor(editor)
// 3.
if (!text) {
return
}
// 4.
// 1. 清空 + 同步状态
editor.innerHTML = ''
syncEditorState()
// 5.
// 2. 发送
await send(text, atUserIds.length > 0 ? { atUserIds } : undefined)
}
@ -251,7 +244,22 @@ async function handleSend() {
*/
let savedRange: Range | null = null
// TODO @AI:方法注释、方法内注释;
/**
* 走 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()
@ -299,10 +307,13 @@ onBeforeUnmount(() => {
})
/**
* 把字符串插入光标处。execCommand('insertText') 而非自己拼 DOM,是为了保留浏览器
* 原生 undo 栈(Range API 替代实现会让 Ctrl+Z 失效)
* 把字符串插入光标处(emoji 面板等场景调用)
*
* 1. editor 没挂直接返回
* 2. 焦点回到 editor + 把 savedRange 恢复成当前 selection(emoji 面板偷焦点后还能回原位)
* 3. nativeExec 插文本,保留浏览器原生 undo 栈
* 4. 同步 canSend / placeholder
*/
// TODO @AI:方法注释、方法内注释;
function insertText(str: string) {
const editor = editorRef.value
if (!editor) {
@ -316,21 +327,51 @@ function insertText(str: string) {
sel.addRange(savedRange)
}
}
// TODO @AI:linter 报错
document.execCommand('insertText', false, str)
// 1. nativeExec 插文本,保留浏览器原生 undo 栈
nativeExec('insertText', str)
// 2. 同步 canSend / placeholder
syncEditorState()
}
/** 粘贴:剥掉外部样式 / 脚本,只留 plain text */
/**
* 粘贴处理
*
* 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) {
// TODO @AI:这了有 linter 报错;
for (const item of items) {
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) {
// TODO @AI:linter 报错
document.execCommand('insertText', false, text)
nativeExec('insertText', text)
}
}
// TODO @AI:方法注释、方法内注释;
/** 编辑器内容变化的统一入口:先同步 canSend / placeholder,再判 @ 浮层是否要展开 */
function onInput() {
syncEditorState()
detectAtMention()
@ -353,6 +394,7 @@ const groupMembers = computed<GroupMemberLite[]>(() => {
if (!conversation || conversation.type !== ImConversationType.GROUP) {
return []
}
// TODO @AI:g 变 group
const g = groupStore.getGroup(conversation.targetId)
return (g?.members || []).map((m) => ({
userId: m.userId,
@ -374,7 +416,7 @@ const mentionVisible = ref(false)
const mentionSearchText = ref('')
/** 浮层定位:x 是左边距;top / bottom 二选一—— bottom 锚定(picker 下沿贴 @)是默认,
* 上方放不下时退化为 top 锚定(picker 上沿贴 @ 下方) */
const mentionPos = ref<{ x: number; top?: number; bottom?: number }>({ x: 0, bottom: 0 })
const mentionPosition = ref<{ x: number; top?: number; bottom?: number }>({ x: 0, bottom: 0 })
/** MentionPicker 的容器宽度(与组件里的 w-50 对齐),用于视口右沿回弹;
* 高度不再用常量算位置——bottom 锚定后 picker 内容多寡都不影响下沿位置,自然贴 @ */
@ -436,18 +478,18 @@ function detectAtMention() {
* 无论 1 项还是 N 项 picker 下沿都贴 @);不够则翻到 @ 下方走 top 锚定
*/
function positionMention(node: Node, atOffset: number) {
// 1.
// 1. 计算 @ 字符屏幕坐标 rect
const anchor = document.createRange()
anchor.setStart(node, atOffset)
anchor.collapse(true)
const rect = anchor.getBoundingClientRect()
// 2.
// 2. 横向:picker 左边对齐 @,越过视口右沿则左推;至少留 8px 留白
const left = Math.max(8, Math.min(rect.left, window.innerWidth - MENTION_WIDTH - 8))
// 3.
// 3. 纵向:上方剩余 ≥ MENTION_MIN_FIT_ABOVE 走 bottom 锚定
if (rect.top >= MENTION_MIN_FIT_ABOVE) {
mentionPos.value = { x: left, bottom: window.innerHeight - rect.top + 8 }
mentionPosition.value = { x: left, bottom: window.innerHeight - rect.top + 8 }
} else {
mentionPos.value = { x: left, top: rect.bottom + 8 }
mentionPosition.value = { x: left, top: rect.bottom + 8 }
}
}
@ -496,9 +538,18 @@ function onMentionSelect(member: GroupMemberLite) {
syncEditorState()
}
// TODO @AI:方法注释、方法内注释;
/**
* 键盘事件分发
*
* 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) {
// @ 浮层打开时键盘上下 / Enter / Esc 由浮层消费
// 1. mention 浮层打开时
if (mentionVisible.value) {
if (e.key === 'ArrowUp') {
e.preventDefault()
@ -511,7 +562,6 @@ function onKeydown(e: KeyboardEvent) {
return
}
if (e.key === 'Enter' && !e.isComposing) {
// 有候选才拦 Enter 选中;无候选 fall through 到下面的发送分支,避免按 Enter 没反应
if (mentionRef.value?.hasCandidates()) {
e.preventDefault()
mentionRef.value?.pickActive()
@ -523,14 +573,14 @@ function onKeydown(e: KeyboardEvent) {
return
}
}
// Shift+Enter 强制走 br:浏览器默认会插 div,DOM walk 时拼接更复杂
// 2. Shift+Enter 换行:强制走 br(浏览器默认会插 div,DOM walk 拼接更复杂)
if (e.key === 'Enter' && e.shiftKey && !e.isComposing) {
e.preventDefault()
document.execCommand('insertLineBreak')
nativeExec('insertLineBreak')
syncEditorState()
return
}
// 普通 Enter 发送(IME composition 中除外)
// 3. 普通 Enter 发送(IME composition 期间不触发,避免选词被误发)
if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey && !e.isComposing) {
e.preventDefault()
handleSend()
@ -538,14 +588,8 @@ function onKeydown(e: KeyboardEvent) {
}
// ==================== 图片 / 文件上传 ====================
// TODO @AI:方法注释、方法内注释;
async function onImagePicked(e: Event) {
const input = e.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file) {
return
}
/** 上传并发送 IMAGE 消息;文件选择器和粘贴板都复用这条 */
async function uploadAndSendImage(file: File) {
try {
const form = new FormData()
form.append('file', file)
@ -560,14 +604,8 @@ async function onImagePicked(e: Event) {
}
}
// TODO @AI:方法注释、方法内注释;
async function onFilePicked(e: Event) {
const input = e.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file) {
return
}
/** 上传并发送 FILE 消息;附原始 name / size 让接收端展示文件名和体积 */
async function uploadAndSendFile(file: File) {
try {
const form = new FormData()
form.append('file', file)
@ -585,9 +623,29 @@ async function onFilePicked(e: Event) {
}
}
/** 图片选完即上传 + 发送 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)
// TODO @AI:方法注释、方法内注释;
/** VoiceRecorder 录完后回传 blob,包成 webm 文件上传,发送 VOICE 消息 */
async function onVoiceSend(payload: { blob: Blob; duration: number }) {
try {
const file = new File([payload.blob], `voice-${Date.now()}.webm`, { type: payload.blob.type })