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 @@
-
+
+
+
+
+
(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 @@
-
+
+
+ [表情]
+
+ {{ parsedPayload.name }}
+
+
+
+
{
/** 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
})