♻️ 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) =>
im
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 @AIg 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 计算未读列表用
*
* 群成员是按需懒加载到 groupStoreloadGroupMembers未加载完 group?.members undefined
* 兜底空数组MessageReadStatus 拿空数组就不渲染未读名单不会出错
*/
const groupMembersForReadStatus = computed<GroupMemberLite[]>(() => {
const conversation = conversationStore.activeConversation
if (!conversation || conversation.type !== ImConversationType.GROUP) {
return []
}
// TODO @AIgroup
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
// """" + id0+
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 DOMcallback 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 改成 RECALLUI 自动切到"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 })
}