diff --git a/src/views/im/home/composables/useMessageSender.ts b/src/views/im/home/composables/useMessageSender.ts
index 2b0981682..0fc019d46 100644
--- a/src/views/im/home/composables/useMessageSender.ts
+++ b/src/views/im/home/composables/useMessageSender.ts
@@ -18,7 +18,7 @@ import {
type TextMessage
} from '../../utils/message'
import { ImMessageType, ImMessageStatus, ImConversationType } from '../../utils/constants'
-import type { Message } from '../types'
+import type { Conversation, Message } from '../types'
import { useUserStore } from '@/store/modules/user'
/** 非文本消息的扩展选项(通用) */
@@ -26,6 +26,13 @@ interface SendExtOptions {
atUserIds?: number[] // 群聊 @ 的用户编号列表
receipt?: boolean // 是否需要群回执(默认 false)
targetId?: number // 覆盖默认的 targetId
+ /**
+ * 显式指定目标会话(转发 / 名片推荐场景)
+ *
+ * 不传时默认取 conversationStore.activeConversation;传入时按本值发送 + 乐观更新到对应会话,
+ * 不要求该会话当前是激活状态(适合发给「非当前会话」的多个目标)
+ */
+ conversation?: Conversation
/** 被引用消息(可选):写进 content.quote 用于乐观渲染,服务端按 quote.messageId 反查重算覆盖 */
quote?: QuoteMessage
/**
@@ -78,8 +85,8 @@ export const useMessageSender = () => {
* 2. type / content 由调用方构造
*/
const sendRaw = async (type: number, content: string, options?: SendExtOptions) => {
- // 1. 参数校验:必须有激活会话和目标 id
- const conversation = conversationStore.activeConversation
+ // 1. 参数校验:优先用显式传入的 conversation(转发场景),否则取激活会话
+ const conversation = options?.conversation ?? conversationStore.activeConversation
if (!conversation) {
return
}
diff --git a/src/views/im/home/pages/conversation/components/message/MessageHistory.vue b/src/views/im/home/pages/conversation/components/message/MessageHistory.vue
index c165dfa4c..dbd5a2ff2 100644
--- a/src/views/im/home/pages/conversation/components/message/MessageHistory.vue
+++ b/src/views/im/home/pages/conversation/components/message/MessageHistory.vue
@@ -252,6 +252,15 @@
[视频]
+
+
+
+ 个人名片:{{ cardOf(message)?.nickname || '' }}
+
+
(message.content)
}
+function cardOf(message: Message): CardMessage | null {
+ return parseMessage
(message.content)
+}
/** 关键字命中文本:文本类返回原文、文件返回文件名(利于按文件名搜)、其他返回占位词 */
function textSnippetOf(message: Message): string {
@@ -683,6 +696,8 @@ function textSnippetOf(message: Message): string {
return '[语音]'
case ImMessageType.VIDEO:
return '[视频]'
+ case ImMessageType.CARD:
+ return `[个人名片] ${parseMessage(message.content)?.nickname ?? ''}`
case ImMessageType.RECALL:
return recallTipOf(message)
default:
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 bbdfd484f..59c0a623d 100644
--- a/src/views/im/home/pages/conversation/components/message/ReplyPreview.vue
+++ b/src/views/im/home/pages/conversation/components/message/ReplyPreview.vue
@@ -57,6 +57,14 @@
+
+
+
+
+ 个人名片:{{ parsedPayload?.nickname || '' }}
+
+
+
{
/** quote.content 解析一次缓存,让多个 computed 复用,长会话每条引用气泡少一次 JSON.parse */
type AnyQuotePayload = Partial<
- TextMessage & ImageMessage & FileMessage & AudioMessage & VideoMessage
+ TextMessage & ImageMessage & FileMessage & AudioMessage & VideoMessage & CardMessage
>
const parsedPayload = computed(() => parseMessage(props.quote.content))
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)
/** 文本超过 MAX_TEXT_PREVIEW_LEN 截断,长内容不撑爆引用块 */
const textPreview = computed(() => {
diff --git a/src/views/im/manager/message/MessageContentPreview.vue b/src/views/im/manager/message/MessageContentPreview.vue
index 4ee21d44d..c255f0680 100644
--- a/src/views/im/manager/message/MessageContentPreview.vue
+++ b/src/views/im/manager/message/MessageContentPreview.vue
@@ -56,6 +56,12 @@
+
+
+
+ 个人名片:{{ cardPayload.nickname }}
+
+
props.type === ImMessageType.IMAGE)
const isFile = computed(() => props.type === ImMessageType.FILE)
const isVoice = computed(() => props.type === ImMessageType.VOICE)
const isVideo = computed(() => props.type === ImMessageType.VIDEO)
+const isCard = computed(() => props.type === ImMessageType.CARD)
/** 文本内容:从 TextMessage payload 取 .content */
const textContent = computed(
@@ -154,6 +162,9 @@ const voicePayload = computed(() =>
const videoPayload = computed(() =>
isVideo.value ? parseMessage(props.content || '') : null
)
+const cardPayload = computed(() =>
+ isCard.value ? parseMessage(props.content || '') : null
+)
/** 点击视频封面:在新标签打开视频 url(不在管理后台内嵌播放,避免列表里多个 video 同时占资源) */
function openVideo() {