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)
im
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 里被标 @deprecatedIDE 显示删除线 '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 恢复成当前 selectionemoji 面板偷焦点后还能回原位
* 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 @AIlinter
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 @AIlinter
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 @AIg 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浏览器默认会插 divDOM 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 divDOM walk
// 2. Shift+Enter br divDOM 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 })