♻️ 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
parent
8fd21da555
commit
bfa267120a
Binary file not shown.
|
|
@ -63,6 +63,7 @@ import { ElScrollbar } from 'element-plus'
|
||||||
|
|
||||||
import Icon from '@/components/Icon/src/Icon.vue'
|
import Icon from '@/components/Icon/src/Icon.vue'
|
||||||
import { useUserStore } from '@/store/modules/user'
|
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 { IM_AT_ALL_NICKNAME, IM_AT_ALL_USER_ID } from '@/views/im/utils/constants'
|
||||||
import ChatGroupMember, { type GroupMemberLite } from '../ChatGroupMember.vue'
|
import ChatGroupMember, { type GroupMemberLite } from '../ChatGroupMember.vue'
|
||||||
|
|
||||||
|
|
@ -119,11 +120,11 @@ const allItem = computed<GroupMemberLite | null>(() => {
|
||||||
/** 真成员:过滤自己 / 退群 / 不匹配关键字;不截断数量,浮层 max-height + el-scrollbar 撑滚动 */
|
/** 真成员:过滤自己 / 退群 / 不匹配关键字;不截断数量,浮层 max-height + el-scrollbar 撑滚动 */
|
||||||
const memberItems = computed<GroupMemberLite[]>(() =>
|
const memberItems = computed<GroupMemberLite[]>(() =>
|
||||||
props.members.filter(
|
props.members.filter(
|
||||||
(m) =>
|
(member) =>
|
||||||
m.userId !== selfUserId.value &&
|
member.userId !== selfUserId.value &&
|
||||||
!m.quit &&
|
member.status !== CommonStatusEnum.DISABLE &&
|
||||||
!!m.showNickName &&
|
!!member.showNickName &&
|
||||||
m.showNickName.startsWith(props.searchText)
|
member.showNickName.startsWith(props.searchText)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -103,7 +103,6 @@ import { computed, onBeforeUnmount, onMounted, ref, useTemplateRef } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
|
|
||||||
import Icon from '@/components/Icon/src/Icon.vue'
|
import Icon from '@/components/Icon/src/Icon.vue'
|
||||||
import { CommonStatusEnum } from '@/utils/constants'
|
|
||||||
import { updateFile } from '@/api/infra/file'
|
import { updateFile } from '@/api/infra/file'
|
||||||
import { useConversationStore } from '@/views/im/home/store/conversationStore'
|
import { useConversationStore } from '@/views/im/home/store/conversationStore'
|
||||||
import { useGroupStore } from '@/views/im/home/store/groupStore'
|
import { useGroupStore } from '@/views/im/home/store/groupStore'
|
||||||
|
|
@ -395,13 +394,12 @@ const groupMembers = computed<GroupMemberLite[]>(() => {
|
||||||
if (!conversation || conversation.type !== ImConversationType.GROUP) {
|
if (!conversation || conversation.type !== ImConversationType.GROUP) {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
// TODO @AI:g 变 group
|
const group = groupStore.getGroup(conversation.targetId)
|
||||||
const g = groupStore.getGroup(conversation.targetId)
|
return (group?.members || []).map((member) => ({
|
||||||
return (g?.members || []).map((m) => ({
|
userId: member.userId,
|
||||||
userId: m.userId,
|
showNickName: member.displayUserName || member.nickname,
|
||||||
showNickName: m.displayUserName || m.nickname,
|
showImage: member.avatar,
|
||||||
showImage: m.avatar,
|
status: member.status
|
||||||
quit: m.status === CommonStatusEnum.DISABLE
|
|
||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
<template>
|
<template>
|
||||||
<!-- TODO @AI:自己发的消息,是不是头像在最右侧; -->
|
|
||||||
<!-- TODO @AI:消息内容的气泡,是不是指向自己。 -->
|
|
||||||
<!-- TODO @AI:@全部、人的消息高亮;在消息内容里 -->
|
<!-- TODO @AI:@全部、人的消息高亮;在消息内容里 -->
|
||||||
<!-- TODO @AI:文件消息,展示的不如微信的好看。可能要优化下; -->
|
<!--
|
||||||
|
布局约定:DOM 顺序永远是「头像在前 / 气泡在后」,对方消息走默认 row(头像顶左),
|
||||||
|
自己消息靠外层 flex-row-reverse 翻视觉(头像顶右、气泡在头像左侧),跟微信对齐。
|
||||||
|
早先双 v-if 头像 + row-reverse 会让自己消息时气泡顶右、头像反而在气泡左边。
|
||||||
|
-->
|
||||||
<!-- 时间分隔线(TIP_TIME=20):居中灰色时间 -->
|
<!-- 时间分隔线(TIP_TIME=20):居中灰色时间 -->
|
||||||
<div
|
<div
|
||||||
v-if="isTipTime"
|
v-if="isTipTime"
|
||||||
|
|
@ -34,12 +36,16 @@
|
||||||
:class="{ 'flex-row-reverse': message.selfSend }"
|
:class="{ 'flex-row-reverse': message.selfSend }"
|
||||||
@contextmenu.prevent="handleContextMenu"
|
@contextmenu.prevent="handleContextMenu"
|
||||||
>
|
>
|
||||||
<!-- 对方消息:头像在左,点头像弹 UserInfoCard -->
|
<!-- 头像:DOM 顺序固定为「头像在前 / 气泡在后」,selfSend 走 flex-row-reverse 翻视觉;
|
||||||
|
点头像弹 UserInfoCard 由 UserAvatar 内部承接 -->
|
||||||
<UserAvatar
|
<UserAvatar
|
||||||
v-if="!message.selfSend"
|
:id="message.selfSend ? userStore.getUser?.id : message.senderId"
|
||||||
:id="message.senderId"
|
:name="
|
||||||
:name="message.senderNickName || String(message.senderId)"
|
message.selfSend
|
||||||
:url="senderAvatar"
|
? userStore.getUser?.nickname
|
||||||
|
: message.senderNickName || String(message.senderId)
|
||||||
|
"
|
||||||
|
:url="message.selfSend ? userStore.getUser?.avatar : senderAvatar"
|
||||||
:size="36"
|
:size="36"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
@ -56,12 +62,13 @@
|
||||||
<!-- 文本消息 -->
|
<!-- 文本消息 -->
|
||||||
<div
|
<div
|
||||||
v-if="isText"
|
v-if="isText"
|
||||||
class="px-3.5 py-2.5 text-sm leading-normal break-words whitespace-pre-wrap rounded-lg"
|
class="relative px-3.5 py-2.5 text-sm leading-normal break-words whitespace-pre-wrap rounded-lg"
|
||||||
:class="
|
:class="[
|
||||||
|
message.selfSend ? 'message-bubble--self' : 'message-bubble--other',
|
||||||
message.selfSend
|
message.selfSend
|
||||||
? 'text-black bg-[#95ec69]'
|
? 'text-black bg-[#95ec69]'
|
||||||
: 'text-[var(--el-text-color-primary)] bg-[var(--el-fill-color-light)]'
|
: 'text-[var(--el-text-color-primary)] bg-[var(--el-fill-color-light)]'
|
||||||
"
|
]"
|
||||||
>
|
>
|
||||||
{{ textContent }}
|
{{ textContent }}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -74,36 +81,43 @@
|
||||||
:preview-teleported="true"
|
:preview-teleported="true"
|
||||||
fit="contain"
|
fit="contain"
|
||||||
/>
|
/>
|
||||||
<!-- 文件消息 -->
|
<!-- 文件消息:对齐微信观感 —— 文件名 + 大小靠左、按扩展名分配的大彩色图标贴右 -->
|
||||||
<div
|
<div
|
||||||
v-else-if="isFile && filePayload"
|
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="relative flex gap-3 items-center min-w-[260px] max-w-[340px] px-3.5 py-3 border rounded cursor-pointer transition-colors"
|
||||||
:class="
|
:class="[
|
||||||
|
message.selfSend ? 'message-bubble--self' : 'message-bubble--other',
|
||||||
message.selfSend
|
message.selfSend
|
||||||
? 'bg-[#95ec69] border-[var(--el-border-color-lighter)]'
|
? 'bg-[#95ec69] border-[var(--el-border-color-lighter)]'
|
||||||
: 'bg-[var(--el-bg-color)] border-[var(--el-border-color-light)] hover:border-[#409eff]'
|
: 'bg-[var(--el-bg-color)] border-[var(--el-border-color-light)] hover:border-[#409eff]'
|
||||||
"
|
]"
|
||||||
@click="handleFileClick"
|
@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="flex-1 min-w-0">
|
||||||
<div
|
<div
|
||||||
class="overflow-hidden text-sm font-medium truncate text-[var(--el-text-color-primary)]"
|
class="overflow-hidden text-sm font-medium truncate text-[var(--el-text-color-primary)]"
|
||||||
>
|
>
|
||||||
{{ filePayload.name }}
|
{{ filePayload.name }}
|
||||||
</div>
|
</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) }}
|
{{ formatFileSize(filePayload.size) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<Icon
|
||||||
|
:icon="fileIconInfo.icon"
|
||||||
|
:color="fileIconInfo.color"
|
||||||
|
:size="40"
|
||||||
|
class="flex-shrink-0"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<!-- 语音消息 -->
|
<!-- 语音消息 -->
|
||||||
<div
|
<div
|
||||||
v-else-if="isVoice && voicePayload"
|
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="relative 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="[
|
||||||
|
message.selfSend ? 'message-bubble--self' : 'message-bubble--other',
|
||||||
|
message.selfSend ? 'bg-[#95ec69]' : 'bg-[var(--el-fill-color-light)]'
|
||||||
|
]"
|
||||||
@click="handleVoiceClick"
|
@click="handleVoiceClick"
|
||||||
>
|
>
|
||||||
<el-icon
|
<el-icon
|
||||||
|
|
@ -191,29 +205,22 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 自己消息:头像在右,点头像也能弹 UserInfoCard -->
|
|
||||||
<UserAvatar
|
|
||||||
v-if="message.selfSend"
|
|
||||||
:id="userStore.getUser?.id"
|
|
||||||
:name="userStore.getUser?.nickname"
|
|
||||||
:url="userStore.getUser?.avatar"
|
|
||||||
:size="36"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { computed, onBeforeUnmount, ref } from 'vue'
|
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 { ElMessageBox } from 'element-plus'
|
||||||
|
|
||||||
|
import Icon from '@/components/Icon/src/Icon.vue'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ImMessageType,
|
ImMessageType,
|
||||||
ImMessageStatus,
|
ImMessageStatus,
|
||||||
ImGroupReceiptStatus,
|
ImGroupReceiptStatus,
|
||||||
ImConversationType
|
ImConversationType
|
||||||
} from '../../../../../utils/constants'
|
} from '../../../../../utils/constants'
|
||||||
import { CommonStatusEnum } from '@/utils/constants'
|
|
||||||
import {
|
import {
|
||||||
parseMessage,
|
parseMessage,
|
||||||
buildRecallTip,
|
buildRecallTip,
|
||||||
|
|
@ -278,7 +285,9 @@ const senderAvatar = computed(() => {
|
||||||
}
|
}
|
||||||
if (conversation.type === ImConversationType.GROUP) {
|
if (conversation.type === ImConversationType.GROUP) {
|
||||||
const group = groupStore.getGroup(conversation.targetId)
|
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 || ''
|
return conversation.avatar || ''
|
||||||
})
|
})
|
||||||
|
|
@ -294,27 +303,27 @@ function formatTipTime(timestamp: number): string {
|
||||||
if (!timestamp) {
|
if (!timestamp) {
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
const d = new Date(timestamp)
|
const messageDate = new Date(timestamp)
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const pad = (n: number) => n.toString().padStart(2, '0')
|
const pad = (value: number) => value.toString().padStart(2, '0')
|
||||||
const hm = `${pad(d.getHours())}:${pad(d.getMinutes())}`
|
const hourMinute = `${pad(messageDate.getHours())}:${pad(messageDate.getMinutes())}`
|
||||||
const sameDay = (a: Date, b: Date) =>
|
const sameDay = (left: Date, right: Date) =>
|
||||||
a.getFullYear() === b.getFullYear() &&
|
left.getFullYear() === right.getFullYear() &&
|
||||||
a.getMonth() === b.getMonth() &&
|
left.getMonth() === right.getMonth() &&
|
||||||
a.getDate() === b.getDate()
|
left.getDate() === right.getDate()
|
||||||
if (sameDay(d, now)) {
|
if (sameDay(messageDate, now)) {
|
||||||
return hm
|
return hourMinute
|
||||||
}
|
}
|
||||||
const yesterday = new Date(now)
|
const yesterday = new Date(now)
|
||||||
yesterday.setDate(now.getDate() - 1)
|
yesterday.setDate(now.getDate() - 1)
|
||||||
if (sameDay(d, yesterday)) {
|
if (sameDay(messageDate, yesterday)) {
|
||||||
return `昨天 ${hm}`
|
return `昨天 ${hourMinute}`
|
||||||
}
|
}
|
||||||
if (now.getTime() - d.getTime() < 7 * 24 * 60 * 60 * 1000) {
|
if (now.getTime() - messageDate.getTime() < 7 * 24 * 60 * 60 * 1000) {
|
||||||
const weeks = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
|
const weekNames = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
|
||||||
return `${weeks[d.getDay()]} ${hm}`
|
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
|
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() {
|
function handleFileClick() {
|
||||||
if (!filePayload.value?.url) {
|
if (!filePayload.value?.url) {
|
||||||
|
|
@ -413,19 +461,23 @@ const showGroupReadStatus = computed(() => {
|
||||||
return status !== ImGroupReceiptStatus.NO_RECEIPT
|
return status !== ImGroupReceiptStatus.NO_RECEIPT
|
||||||
})
|
})
|
||||||
|
|
||||||
// 当前群成员(供 MessageReadStatus 计算未读列表用)
|
/**
|
||||||
|
* 当前群成员(供 MessageReadStatus 计算未读列表用)
|
||||||
|
*
|
||||||
|
* 群成员是按需懒加载到 groupStore(loadGroupMembers),未加载完 group?.members 为 undefined →
|
||||||
|
* 兜底空数组,MessageReadStatus 拿空数组就不渲染未读名单,不会出错
|
||||||
|
*/
|
||||||
const groupMembersForReadStatus = computed<GroupMemberLite[]>(() => {
|
const groupMembersForReadStatus = computed<GroupMemberLite[]>(() => {
|
||||||
const conversation = conversationStore.activeConversation
|
const conversation = conversationStore.activeConversation
|
||||||
if (!conversation || conversation.type !== ImConversationType.GROUP) {
|
if (!conversation || conversation.type !== ImConversationType.GROUP) {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
// TODO @AI:group;注释;
|
const group = groupStore.getGroup(conversation.targetId)
|
||||||
const g = groupStore.getGroup(conversation.targetId)
|
return (group?.members || []).map((member) => ({
|
||||||
return (g?.members || []).map((m) => ({
|
userId: member.userId,
|
||||||
userId: m.userId,
|
showNickName: member.displayUserName || member.nickname,
|
||||||
showNickName: m.displayUserName || m.nickname,
|
showImage: member.avatar,
|
||||||
showImage: m.avatar,
|
status: member.status
|
||||||
quit: m.status === CommonStatusEnum.DISABLE
|
|
||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -450,14 +502,14 @@ async function handleContextMenu(e: MouseEvent) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO @AI:右键菜单;注释;
|
// "删除"对所有消息开放(纯本地清理,无后端影响);"撤回"必须满足 自己发 + 已落库(id≠0)+ 未撤回
|
||||||
const items: Array<{ key: string; name: string; disabled?: boolean }> = [
|
const items: Array<{ key: string; name: string; disabled?: boolean }> = [
|
||||||
{ key: 'DELETE', name: '删除' }
|
{ key: 'DELETE', name: '删除' }
|
||||||
]
|
]
|
||||||
if (props.message.selfSend && !!props.message.id && !isRecall.value) {
|
if (props.message.selfSend && !!props.message.id && !isRecall.value) {
|
||||||
items.push({ key: 'RECALL', name: '撤回' })
|
items.push({ key: 'RECALL', name: '撤回' })
|
||||||
}
|
}
|
||||||
// TODO @AI:右键菜单;注释;
|
// 把菜单渲染交给全局 uiStore(单例,避免每条消息都挂一份菜单 DOM);callback 按 key 分发
|
||||||
uiStore.openContextMenu({ x: e.clientX, y: e.clientY }, items, async (item) => {
|
uiStore.openContextMenu({ x: e.clientX, y: e.clientY }, items, async (item) => {
|
||||||
if (item.key === 'RECALL') {
|
if (item.key === 'RECALL') {
|
||||||
await handleRecall()
|
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() {
|
async function handleRecall() {
|
||||||
try {
|
try {
|
||||||
await ElMessageBox.confirm('确定要撤回这条消息吗?', '撤回消息', { type: 'warning' })
|
await ElMessageBox.confirm('确定要撤回这条消息吗?', '撤回消息', { type: 'warning' })
|
||||||
await recall(props.message)
|
await recall(props.message)
|
||||||
} catch {
|
} catch {
|
||||||
// 用户取消
|
// ElMessageBox 在用户点取消时会 reject,吃掉即可
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -501,7 +558,10 @@ async function handleResend() {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO @AI:注释缺少;
|
/**
|
||||||
|
* 删除消息:本地软删,仅从 conversationStore.messages 移除,不调后端
|
||||||
|
* 区别于"撤回":服务端没动,多端登录时其它客户端 / 群里其他人依然能看到这条
|
||||||
|
*/
|
||||||
function handleDelete() {
|
function handleDelete() {
|
||||||
const conversation = conversationStore.activeConversation
|
const conversation = conversationStore.activeConversation
|
||||||
if (!conversation) {
|
if (!conversation) {
|
||||||
|
|
@ -515,11 +575,32 @@ function handleDelete() {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
/* el-icon 在暗色模式下全局 color 被 .el-icon{color:var(--color)} 干扰;
|
/* 气泡尾巴:小三角伪元素,指向对应头像(对方在左、自己在右),对齐微信观感
|
||||||
这里把 file / voice 图标的 fill 锁死,避免字体色跟随主题变白 */
|
- 用 border 4 边色画三角:透明 3 边 + 实色 1 边,省一张图片
|
||||||
.message-bubble__file-icon :deep(svg) {
|
- 颜色对应气泡背景,留 1px 视觉吃进去;UnoCSS 写不顺手,索性用 scoped CSS */
|
||||||
fill: #409eff !important;
|
.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) {
|
.message-bubble__voice-icon :deep(svg) {
|
||||||
fill: #606266 !important;
|
fill: #606266 !important;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 })
|
|
||||||
}
|
|
||||||
Loading…
Reference in New Issue