diff --git a/src/views/im/home/components/user/RecommendCardDialog.vue b/src/views/im/home/components/user/RecommendCardDialog.vue index a9d1ff948..c8c558a8b 100644 --- a/src/views/im/home/components/user/RecommendCardDialog.vue +++ b/src/views/im/home/components/user/RecommendCardDialog.vue @@ -148,7 +148,7 @@ - +
-
@@ -192,7 +193,7 @@ import Icon from '@/components/Icon/src/Icon.vue' import { useMessage } from '@/hooks/web/useMessage' import UserAvatar from './UserAvatar.vue' -import EmojiPicker from '../../pages/conversation/components/input/EmojiPicker.vue' +import FacePicker from '../../pages/conversation/components/input/FacePicker.vue' import { useConversationStore } from '../../store/conversationStore' import { useMessageSender } from '../../composables/useMessageSender' import { ImConversationType, ImMessageType } from '../../../utils/constants' @@ -243,7 +244,7 @@ function resetForm() { emojiVisible.value = false } -/** 选中 emoji:直接拼到留言末尾;EmojiPicker 自身 emit('update:visible', false) 关闭面板 */ +/** 选中 emoji:直接拼到留言末尾;FacePicker 自身 emit('update:visible', false) 关闭面板 */ function handleEmojiSelect(emoji: string) { leaveMessage.value = `${leaveMessage.value}${emoji}` } diff --git a/src/views/im/home/pages/conversation/components/input/FacePicker.vue b/src/views/im/home/pages/conversation/components/input/FacePicker.vue index a38c6f7ab..5ac90414e 100644 --- a/src/views/im/home/pages/conversation/components/input/FacePicker.vue +++ b/src/views/im/home/pages/conversation/components/input/FacePicker.vue @@ -33,6 +33,7 @@
+
+ + +
(message.content) } +function faceOf(message: Message): FaceMessage | null { + return parseMessage(message.content) +} /** 关键字命中文本:文本类返回原文、文件返回文件名(利于按文件名搜)、其他返回占位词 */ function textSnippetOf(message: Message): string { @@ -698,6 +711,8 @@ function textSnippetOf(message: Message): string { return '[视频]' case ImMessageType.CARD: return `[个人名片] ${parseMessage(message.content)?.nickname ?? ''}` + case ImMessageType.FACE: + return buildFacePreviewText(faceOf(message)) case ImMessageType.RECALL: return recallTipOf(message) default: diff --git a/src/views/im/home/pages/conversation/components/message/MessageItem.vue b/src/views/im/home/pages/conversation/components/message/MessageItem.vue index 8b8ab18bd..6fb75782d 100644 --- a/src/views/im/home/pages/conversation/components/message/MessageItem.vue +++ b/src/views/im/home/pages/conversation/components/message/MessageItem.vue @@ -157,6 +157,21 @@ > [视频消息]
+ +
+ +
props.message.type === ImMessageType.FILE) const isVoice = computed(() => props.message.type === ImMessageType.VOICE) const isVideo = computed(() => props.message.type === ImMessageType.VIDEO) const isCard = computed(() => props.message.type === ImMessageType.CARD) +const isFace = computed(() => props.message.type === ImMessageType.FACE) // ==================== 气泡配色 helper ==================== // self / other 两侧 ::before 三角靠 message-bubble--self/other 类承载;本 helper 把 4 处 selfSend ternary @@ -477,6 +497,9 @@ const videoPayload = computed(() => const cardPayload = computed(() => isCard.value ? parseMessage(props.message.content) : null ) +const facePayload = computed(() => + isFace.value ? parseMessage(props.message.content) : null +) /** 名片点击:弹被推荐用户的 UserInfoCard;陌生人名片走「名片」加好友来源 */ function handleCardClick(e: MouseEvent) { @@ -666,6 +689,7 @@ const isAtMe = computed(() => { const MENU_KEYS = { REPLY: 'REPLY', PIN: 'PIN', + ADD_TO_FACE: 'ADD_TO_FACE', MUTE: 'MUTE', UNMUTE: 'UNMUTE', KICK: 'KICK', @@ -714,6 +738,14 @@ async function handleContextMenu(e: MouseEvent) { icon: 'ant-design:pushpin-outlined' }) } + // 「添加到表情」:FACE / IMAGE 消息 + 已落库 + 未撤回;写入个人表情包,对照微信「添加到表情」 + if (canAddToFace.value) { + items.push({ + key: MENU_KEYS.ADD_TO_FACE, + name: '添加到表情', + icon: 'ant-design:smile-outlined' + }) + } // 「禁言 / 解禁 / 移除」:群聊 + 非自己消息 + 我是群主或管理员 if (currentGroup.value && !props.message.selfSend && canManageSender.value) { const senderMember = currentGroup.value.members?.find( @@ -775,6 +807,8 @@ async function handleContextMenu(e: MouseEvent) { handleReply() } else if (item.key === MENU_KEYS.PIN) { await handlePin() + } else if (item.key === MENU_KEYS.ADD_TO_FACE) { + await handleAddToFace() } else if (item.key === MENU_KEYS.MUTE) { handleMute() } else if (item.key === MENU_KEYS.UNMUTE) { @@ -789,6 +823,30 @@ async function handleContextMenu(e: MouseEvent) { }) } +/** 是否可「添加到表情」:FACE / IMAGE 消息 + 已落库 + 未撤回(GIF / 静图都允许) */ +const canAddToFace = computed(() => { + if (isRecall.value || !props.message.id) { + return false + } + return extractAddableFace(props.message) !== null +}) + +/** 添加到个人表情:从 message 抽 url + 尺寸 + name 写入个人表情库;幂等失败时返回 false 走 toast 兜底 */ +async function handleAddToFace() { + const payload = extractAddableFace(props.message) + if (!payload) { + return + } + // TODO @AI:改成 data;更符合预期 + const ok = await faceStore.addFaceUserItem({ + ...payload, + sourceMessageId: props.message.id + }) + if (ok) { + successMessage('已添加到表情') + } +} + /** 当前激活会话对应的群(私聊场景为 undefined) */ const currentGroup = computed(() => { const conversation = conversationStore.activeConversation diff --git a/src/views/im/home/pages/conversation/components/message/ReplyPreview.vue b/src/views/im/home/pages/conversation/components/message/ReplyPreview.vue index 59c0a623d..7f76c6e91 100644 --- a/src/views/im/home/pages/conversation/components/message/ReplyPreview.vue +++ b/src/views/im/home/pages/conversation/components/message/ReplyPreview.vue @@ -65,7 +65,15 @@ - + + + + { /** quote.content 解析一次缓存,让多个 computed 复用,长会话每条引用气泡少一次 JSON.parse */ type AnyQuotePayload = Partial< - TextMessage & ImageMessage & FileMessage & AudioMessage & VideoMessage & CardMessage + TextMessage & ImageMessage & FileMessage & AudioMessage & VideoMessage & CardMessage & FaceMessage > const parsedPayload = computed(() => parseMessage(props.quote.content)) @@ -166,6 +175,7 @@ const isText = computed(() => props.quote.type === ImMessageType.TEXT) const isFile = computed(() => props.quote.type === ImMessageType.FILE) const isVoice = computed(() => props.quote.type === ImMessageType.VOICE) const isCard = computed(() => props.quote.type === ImMessageType.CARD) +const isFace = computed(() => props.quote.type === ImMessageType.FACE) /** 文本超过 MAX_TEXT_PREVIEW_LEN 截断,长内容不撑爆引用块 */ const textPreview = computed(() => { @@ -178,7 +188,7 @@ const textPreview = computed(() => { /** 文件 icon:按扩展名挑色,跟主气泡渲染同源 */ const fileIcon = computed(() => getFileIconInfo(parsedPayload.value?.name)) -/** 缩略图 URL:仅图片 / 视频从 quote.content 直接取,不依赖本地缓存 */ +/** 缩略图 URL:图片 / 视频 / 表情贴图从 quote.content 直接取,不依赖本地缓存 */ const thumbnailUrl = computed(() => { if (isRecalled.value) { return undefined @@ -190,6 +200,9 @@ const thumbnailUrl = computed(() => { if (type === ImMessageType.VIDEO) { return parsedPayload.value?.coverUrl } + if (type === ImMessageType.FACE) { + return parsedPayload.value?.url + } return undefined })