♻️ refactor(im): MessageItem 头像顶右 + MentionPicker/MessageInput 命名清理

【MessageItem.vue】
- 头像合一:双 v-if 头像(左/右)收成单一 <UserAvatar>,DOM 顺序固定为
  [头像, 气泡],selfSend 靠外层 flex-row-reverse 翻视觉 → 头像顶右、气泡在
  头像左侧。早先双 v-if + row-reverse 让自己消息时气泡顶右、头像反而被
  挤在 bubble 左边,跟微信观感不对齐
- 5 处脚本 TODO 注释补齐:groupMembersForReadStatus / handleContextMenu /
  handleRecall / handleDelete,解释 WHY 而不是 WHAT
- formatTipTime 局部变量按"不缩写"展开:d → messageDate / n → value /
  hm → hourMinute / (a,b) → (left,right) / weeks → weekNames
- senderAvatar / groupMembersForReadStatus 回调参数 m → member、g → group

【MessageInput.vue】
- groupMembers producer 局部变量 g → group、(m) => → (member) =>

【MentionPicker.vue】
- memberItems 过滤回调 (m) => → (member) =>
pull/881/head
YunaiV 2026-04-27 21:48:34 +08:00
parent 8fd21da555
commit bfa267120a
6 changed files with 157 additions and 107 deletions

Binary file not shown.

View File

@ -63,6 +63,7 @@ import { ElScrollbar } from 'element-plus'
import Icon from '@/components/Icon/src/Icon.vue'
import { useUserStore } from '@/store/modules/user'
import { CommonStatusEnum } from '@/utils/constants'
import { IM_AT_ALL_NICKNAME, IM_AT_ALL_USER_ID } from '@/views/im/utils/constants'
import ChatGroupMember, { type GroupMemberLite } from '../ChatGroupMember.vue'
@ -119,11 +120,11 @@ const allItem = computed<GroupMemberLite | null>(() => {
/** 真成员:过滤自己 / 退群 / 不匹配关键字;不截断数量,浮层 max-height + el-scrollbar 撑滚动 */
const memberItems = computed<GroupMemberLite[]>(() =>
props.members.filter(
(m) =>
m.userId !== selfUserId.value &&
!m.quit &&
!!m.showNickName &&
m.showNickName.startsWith(props.searchText)
(member) =>
member.userId !== selfUserId.value &&
member.status !== CommonStatusEnum.DISABLE &&
!!member.showNickName &&
member.showNickName.startsWith(props.searchText)
)
)

View File

@ -103,7 +103,6 @@ import { computed, onBeforeUnmount, onMounted, ref, useTemplateRef } from 'vue'
import { ElMessage } from 'element-plus'
import Icon from '@/components/Icon/src/Icon.vue'
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'
@ -395,13 +394,12 @@ 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,
showNickName: m.displayUserName || m.nickname,
showImage: m.avatar,
quit: m.status === CommonStatusEnum.DISABLE
const group = groupStore.getGroup(conversation.targetId)
return (group?.members || []).map((member) => ({
userId: member.userId,
showNickName: member.displayUserName || member.nickname,
showImage: member.avatar,
status: member.status
}))
})

View File

@ -1,8 +1,10 @@
<template>
<!-- TODO @AI:自己发的消息,是不是头像在最右侧; -->
<!-- TODO @AI:消息内容的气泡,是不是指向自己。 -->
<!-- TODO @AI:@全部、人的消息高亮;在消息内容里 -->
<!-- TODO @AI:文件消息,展示的不如微信的好看。可能要优化下; -->
<!--
布局约定:DOM 顺序永远是「头像在前 / 气泡在后」,对方消息走默认 row(头像顶左),
自己消息靠外层 flex-row-reverse 翻视觉(头像顶右、气泡在头像左侧),跟微信对齐。
早先双 v-if 头像 + row-reverse 会让自己消息时气泡顶右、头像反而在气泡左边。
-->
<!-- 时间分隔线(TIP_TIME=20):居中灰色时间 -->
<div
v-if="isTipTime"
@ -34,12 +36,16 @@
:class="{ 'flex-row-reverse': message.selfSend }"
@contextmenu.prevent="handleContextMenu"
>
<!-- 对方消息:头像在左,点头像弹 UserInfoCard -->
<!-- 头像:DOM 顺序固定为「头像在前 / 气泡在后」,selfSend 走 flex-row-reverse 翻视觉;
点头像弹 UserInfoCard 由 UserAvatar 内部承接 -->
<UserAvatar
v-if="!message.selfSend"
:id="message.senderId"
:name="message.senderNickName || String(message.senderId)"
:url="senderAvatar"
:id="message.selfSend ? userStore.getUser?.id : message.senderId"
:name="
message.selfSend
? userStore.getUser?.nickname
: message.senderNickName || String(message.senderId)
"
:url="message.selfSend ? userStore.getUser?.avatar : senderAvatar"
:size="36"
/>
@ -56,12 +62,13 @@
<!-- 文本消息 -->
<div
v-if="isText"
class="px-3.5 py-2.5 text-sm leading-normal break-words whitespace-pre-wrap rounded-lg"
:class="
class="relative px-3.5 py-2.5 text-sm leading-normal break-words whitespace-pre-wrap rounded-lg"
:class="[
message.selfSend ? 'message-bubble--self' : 'message-bubble--other',
message.selfSend
? 'text-black bg-[#95ec69]'
: 'text-[var(--el-text-color-primary)] bg-[var(--el-fill-color-light)]'
"
]"
>
{{ textContent }}
</div>
@ -74,36 +81,43 @@
:preview-teleported="true"
fit="contain"
/>
<!-- 文件消息 -->
<!-- 文件消息:对齐微信观感 —— 文件名 + 大小靠左、按扩展名分配的大彩色图标贴右 -->
<div
v-else-if="isFile && filePayload"
class="flex gap-2.5 items-center min-w-[220px] max-w-[320px] px-3.5 py-2.5 border rounded cursor-pointer transition-colors"
:class="
class="relative flex gap-3 items-center min-w-[260px] max-w-[340px] px-3.5 py-3 border rounded cursor-pointer transition-colors"
:class="[
message.selfSend ? 'message-bubble--self' : 'message-bubble--other',
message.selfSend
? 'bg-[#95ec69] border-[var(--el-border-color-lighter)]'
: 'bg-[var(--el-bg-color)] border-[var(--el-border-color-light)] hover:border-[#409eff]'
"
]"
@click="handleFileClick"
>
<el-icon class="message-bubble__file-icon flex-shrink-0 !text-[32px]">
<Document />
</el-icon>
<div class="flex-1 min-w-0">
<div
class="overflow-hidden text-sm font-medium truncate text-[var(--el-text-color-primary)]"
>
{{ filePayload.name }}
</div>
<div class="mt-0.5 text-12px text-[var(--el-text-color-secondary)]">
<div class="mt-1 text-12px text-[var(--el-text-color-secondary)]">
{{ formatFileSize(filePayload.size) }}
</div>
</div>
<Icon
:icon="fileIconInfo.icon"
:color="fileIconInfo.color"
:size="40"
class="flex-shrink-0"
/>
</div>
<!-- 语音消息 -->
<div
v-else-if="isVoice && voicePayload"
class="flex gap-2 items-center min-w-[120px] px-3.5 py-2.5 rounded-lg cursor-pointer"
:class="message.selfSend ? 'bg-[#95ec69]' : 'bg-[var(--el-fill-color-light)]'"
class="relative flex gap-2 items-center min-w-[120px] px-3.5 py-2.5 rounded-lg cursor-pointer"
:class="[
message.selfSend ? 'message-bubble--self' : 'message-bubble--other',
message.selfSend ? 'bg-[#95ec69]' : 'bg-[var(--el-fill-color-light)]'
]"
@click="handleVoiceClick"
>
<el-icon
@ -191,29 +205,22 @@
</div>
</div>
<!-- 自己消息:头像在右,点头像也能弹 UserInfoCard -->
<UserAvatar
v-if="message.selfSend"
:id="userStore.getUser?.id"
:name="userStore.getUser?.nickname"
:url="userStore.getUser?.avatar"
:size="36"
/>
</div>
</template>
<script lang="ts" setup>
import { computed, onBeforeUnmount, ref } from 'vue'
import { Loading, WarningFilled, Document, Microphone } from '@element-plus/icons-vue'
import { Loading, WarningFilled, Microphone } from '@element-plus/icons-vue'
import { ElMessageBox } from 'element-plus'
import Icon from '@/components/Icon/src/Icon.vue'
import {
ImMessageType,
ImMessageStatus,
ImGroupReceiptStatus,
ImConversationType
} from '../../../../../utils/constants'
import { CommonStatusEnum } from '@/utils/constants'
import {
parseMessage,
buildRecallTip,
@ -278,7 +285,9 @@ const senderAvatar = computed(() => {
}
if (conversation.type === ImConversationType.GROUP) {
const group = groupStore.getGroup(conversation.targetId)
return group?.members?.find((m) => m.userId === props.message.senderId)?.avatar || ''
return (
group?.members?.find((member) => member.userId === props.message.senderId)?.avatar || ''
)
}
return conversation.avatar || ''
})
@ -294,27 +303,27 @@ function formatTipTime(timestamp: number): string {
if (!timestamp) {
return ''
}
const d = new Date(timestamp)
const messageDate = new Date(timestamp)
const now = new Date()
const pad = (n: number) => n.toString().padStart(2, '0')
const hm = `${pad(d.getHours())}:${pad(d.getMinutes())}`
const sameDay = (a: Date, b: Date) =>
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate()
if (sameDay(d, now)) {
return hm
const pad = (value: number) => value.toString().padStart(2, '0')
const hourMinute = `${pad(messageDate.getHours())}:${pad(messageDate.getMinutes())}`
const sameDay = (left: Date, right: Date) =>
left.getFullYear() === right.getFullYear() &&
left.getMonth() === right.getMonth() &&
left.getDate() === right.getDate()
if (sameDay(messageDate, now)) {
return hourMinute
}
const yesterday = new Date(now)
yesterday.setDate(now.getDate() - 1)
if (sameDay(d, yesterday)) {
return `昨天 ${hm}`
if (sameDay(messageDate, yesterday)) {
return `昨天 ${hourMinute}`
}
if (now.getTime() - d.getTime() < 7 * 24 * 60 * 60 * 1000) {
const weeks = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
return `${weeks[d.getDay()]} ${hm}`
if (now.getTime() - messageDate.getTime() < 7 * 24 * 60 * 60 * 1000) {
const weekNames = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
return `${weekNames[messageDate.getDay()]} ${hourMinute}`
}
return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${hm}`
return `${pad(messageDate.getMonth() + 1)}-${pad(messageDate.getDate())} ${hourMinute}`
}
/** 文本内容 */
@ -335,6 +344,45 @@ const videoPayload = computed(() =>
isVideo.value ? parseMessage<VideoMessage>(props.message.content) : null
)
/**
* 文件类型图标 + 配色(按扩展名分发)
*
* 对齐微信观感:PDF 红 / Word 蓝 / Excel 绿 / PPT 橘 / 压缩包 黄 / 媒体 紫 / 文本 灰,
* 其它走通用 file-filled。后续多了类型在这里加 case,不动模板
*/
const fileIconInfo = computed<{ icon: string; color: string }>(() => {
const name = filePayload.value?.name || ''
const ext = name.split('.').pop()?.toLowerCase() || ''
if (ext === 'pdf') {
return { icon: 'ant-design:file-pdf-filled', color: '#ed5757' }
}
if (['doc', 'docx'].includes(ext)) {
return { icon: 'ant-design:file-word-filled', color: '#2b7cd3' }
}
if (['xls', 'xlsx'].includes(ext)) {
return { icon: 'ant-design:file-excel-filled', color: '#1f7244' }
}
if (['ppt', 'pptx'].includes(ext)) {
return { icon: 'ant-design:file-ppt-filled', color: '#d24726' }
}
if (['zip', 'rar', '7z', 'tar', 'gz'].includes(ext)) {
return { icon: 'ant-design:file-zip-filled', color: '#f0ad4e' }
}
if (['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg'].includes(ext)) {
return { icon: 'ant-design:file-image-filled', color: '#9c27b0' }
}
if (['mp4', 'mov', 'avi', 'mkv', 'wmv', 'flv'].includes(ext)) {
return { icon: 'ant-design:video-camera-filled', color: '#9c27b0' }
}
if (['mp3', 'wav', 'ogg', 'flac', 'aac'].includes(ext)) {
return { icon: 'ant-design:audio-filled', color: '#9c27b0' }
}
if (['txt', 'md', 'log', 'json', 'xml'].includes(ext)) {
return { icon: 'ant-design:file-text-filled', color: '#909399' }
}
return { icon: 'ant-design:file-filled', color: '#909399' }
})
/** 文件点击 → 新窗口下载 */
function handleFileClick() {
if (!filePayload.value?.url) {
@ -413,19 +461,23 @@ const showGroupReadStatus = computed(() => {
return status !== ImGroupReceiptStatus.NO_RECEIPT
})
// 当前群成员(供 MessageReadStatus 计算未读列表用)
/**
* 当前群成员(供 MessageReadStatus 计算未读列表用)
*
* 群成员是按需懒加载到 groupStore(loadGroupMembers),未加载完 group?.members 为 undefined →
* 兜底空数组,MessageReadStatus 拿空数组就不渲染未读名单,不会出错
*/
const groupMembersForReadStatus = computed<GroupMemberLite[]>(() => {
const conversation = conversationStore.activeConversation
if (!conversation || conversation.type !== ImConversationType.GROUP) {
return []
}
// TODO @AI:group;注释;
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 group = groupStore.getGroup(conversation.targetId)
return (group?.members || []).map((member) => ({
userId: member.userId,
showNickName: member.displayUserName || member.nickname,
showImage: member.avatar,
status: member.status
}))
})
@ -450,14 +502,14 @@ async function handleContextMenu(e: MouseEvent) {
return
}
// TODO @AI:右键菜单;注释;
// "删除"对所有消息开放(纯本地清理,无后端影响);"撤回"必须满足 自己发 + 已落库(id≠0)+ 未撤回
const items: Array<{ key: string; name: string; disabled?: boolean }> = [
{ key: 'DELETE', name: '删除' }
]
if (props.message.selfSend && !!props.message.id && !isRecall.value) {
items.push({ key: 'RECALL', name: '撤回' })
}
// TODO @AI:右键菜单;注释;
// 把菜单渲染交给全局 uiStore(单例,避免每条消息都挂一份菜单 DOM);callback 按 key 分发
uiStore.openContextMenu({ x: e.clientX, y: e.clientY }, items, async (item) => {
if (item.key === 'RECALL') {
await handleRecall()
@ -467,13 +519,18 @@ async function handleContextMenu(e: MouseEvent) {
})
}
// TODO @AI:注释缺少;
/**
* 撤回消息:弹确认框 → 调 useMessageSender.recall → 后端通过 WS RECALL 事件推回,
* websocketStore 把对应 message 的 type 改成 RECALL,UI 自动切到"XX 撤回了一条消息"
*
* 不做乐观撤回:失败 / 超时 / 后端拒绝时本端状态可能与服务端漂移,统一让 WS 回推最稳
*/
async function handleRecall() {
try {
await ElMessageBox.confirm('确定要撤回这条消息吗?', '撤回消息', { type: 'warning' })
await recall(props.message)
} catch {
// 用户取消
// ElMessageBox 在用户点取消时会 reject,吃掉即可
}
}
@ -501,7 +558,10 @@ async function handleResend() {
})
}
// TODO @AI:注释缺少;
/**
* 删除消息:本地软删,仅从 conversationStore.messages 移除,不调后端
* 区别于"撤回":服务端没动,多端登录时其它客户端 / 群里其他人依然能看到这条
*/
function handleDelete() {
const conversation = conversationStore.activeConversation
if (!conversation) {
@ -515,11 +575,32 @@ function handleDelete() {
</script>
<style scoped>
/* el-icon 在暗色模式下全局 color 被 .el-icon{color:var(--color)} 干扰;
这里把 file / voice 图标的 fill 锁死,避免字体色跟随主题变白 */
.message-bubble__file-icon :deep(svg) {
fill: #409eff !important;
/* 气泡尾巴:小三角伪元素,指向对应头像(对方在左、自己在右),对齐微信观感
- 用 border 4 边色画三角:透明 3 边 + 实色 1 边,省一张图片
- 颜色对应气泡背景,留 1px 视觉吃进去;UnoCSS 写不顺手,索性用 scoped CSS */
.message-bubble--other::before,
.message-bubble--self::before {
content: '';
position: absolute;
top: 12px;
width: 0;
height: 0;
border-style: solid;
}
.message-bubble--other::before {
left: -5px;
border-width: 5px 6px 5px 0;
border-color: transparent var(--el-fill-color-light) transparent transparent;
}
.message-bubble--self::before {
right: -5px;
border-width: 5px 0 5px 6px;
border-color: transparent transparent transparent #95ec69;
}
/* el-icon 在暗色模式下全局 color 被 .el-icon{color:var(--color)} 干扰;
这里把 voice 图标的 fill 锁死,避免字体色跟随主题变白;
file 图标已迁到 Iconify 按扩展名走彩色,不在这里强制 */
.message-bubble__voice-icon :deep(svg) {
fill: #606266 !important;
}

View File

@ -1,30 +0,0 @@
/** 生成客户端消息 ID(时间戳 + UUID) */
export const generateClientMessageId = (): string => {
const timestamp = Date.now().toString()
const randomPart = 'xxxx-xxxx-4xxx-yxxx-xxxx'.replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0
const v = c === 'x' ? r : (r & 0x3) | 0x8
return v.toString(16)
})
return `${timestamp}-${randomPart}`
}
/** 生成存储 key(对齐 boxim key 命名 chats-{userId}) */
export const buildMetaKey = (userId: string): string => {
return `chats-${userId}`
}
/** 解析文本消息 content JSON */
export const parseTextContent = (content: string): string => {
try {
const parsed = JSON.parse(content)
return parsed.content || ''
} catch {
return content
}
}
/** 序列化文本消息 content JSON */
export const serializeTextContent = (text: string): string => {
return JSON.stringify({ content: text })
}