feat(im): 优化名片消息类型 v0.1:补充缺失的名片展示

im
YunaiV 2026-05-06 08:21:52 +08:00
parent f3de29f95f
commit 3836467481
4 changed files with 49 additions and 6 deletions

View File

@ -18,7 +18,7 @@ import {
type TextMessage type TextMessage
} from '../../utils/message' } from '../../utils/message'
import { ImMessageType, ImMessageStatus, ImConversationType } from '../../utils/constants' import { ImMessageType, ImMessageStatus, ImConversationType } from '../../utils/constants'
import type { Message } from '../types' import type { Conversation, Message } from '../types'
import { useUserStore } from '@/store/modules/user' import { useUserStore } from '@/store/modules/user'
/** 非文本消息的扩展选项(通用) */ /** 非文本消息的扩展选项(通用) */
@ -26,6 +26,13 @@ interface SendExtOptions {
atUserIds?: number[] // 群聊 @ 的用户编号列表 atUserIds?: number[] // 群聊 @ 的用户编号列表
receipt?: boolean // 是否需要群回执(默认 false receipt?: boolean // 是否需要群回执(默认 false
targetId?: number // 覆盖默认的 targetId targetId?: number // 覆盖默认的 targetId
/**
* /
*
* conversationStore.activeConversation +
*
*/
conversation?: Conversation
/** 被引用消息(可选):写进 content.quote 用于乐观渲染,服务端按 quote.messageId 反查重算覆盖 */ /** 被引用消息(可选):写进 content.quote 用于乐观渲染,服务端按 quote.messageId 反查重算覆盖 */
quote?: QuoteMessage quote?: QuoteMessage
/** /**
@ -78,8 +85,8 @@ export const useMessageSender = () => {
* 2. type / content * 2. type / content
*/ */
const sendRaw = async (type: number, content: string, options?: SendExtOptions) => { const sendRaw = async (type: number, content: string, options?: SendExtOptions) => {
// 1. 参数校验:必须有激活会话和目标 id // 1. 参数校验:优先用显式传入的 conversation转发场景否则取激活会话
const conversation = conversationStore.activeConversation const conversation = options?.conversation ?? conversationStore.activeConversation
if (!conversation) { if (!conversation) {
return return
} }

View File

@ -252,6 +252,15 @@
[视频] [视频]
</div> </div>
<!-- 名片人形 icon + 个人名片昵称 -->
<div
v-else-if="message.type === ImMessageType.CARD"
class="inline-flex gap-1.5 items-center text-sm text-[var(--el-text-color-secondary)]"
>
<Icon icon="ant-design:user-outlined" :size="14" />
<span>个人名片{{ cardOf(message)?.nickname || '' }}</span>
</div>
<!-- 撤回 --> <!-- 撤回 -->
<div <div
v-else-if="message.type === ImMessageType.RECALL" v-else-if="message.type === ImMessageType.RECALL"
@ -329,7 +338,8 @@ import {
type TextMessage, type TextMessage,
type ImageMessage, type ImageMessage,
type FileMessage, type FileMessage,
type AudioMessage type AudioMessage,
type CardMessage
} from '@/views/im/utils/message' } from '@/views/im/utils/message'
import type { Message } from '@/views/im/home/types' import type { Message } from '@/views/im/home/types'
import UserAvatar from '../../../../components/user/UserAvatar.vue' import UserAvatar from '../../../../components/user/UserAvatar.vue'
@ -666,6 +676,9 @@ function fileOf(message: Message): FileMessage | null {
function audioOf(message: Message): AudioMessage | null { function audioOf(message: Message): AudioMessage | null {
return parseMessage<AudioMessage>(message.content) return parseMessage<AudioMessage>(message.content)
} }
function cardOf(message: Message): CardMessage | null {
return parseMessage<CardMessage>(message.content)
}
/** 关键字命中文本:文本类返回原文、文件返回文件名(利于按文件名搜)、其他返回占位词 */ /** 关键字命中文本:文本类返回原文、文件返回文件名(利于按文件名搜)、其他返回占位词 */
function textSnippetOf(message: Message): string { function textSnippetOf(message: Message): string {
@ -683,6 +696,8 @@ function textSnippetOf(message: Message): string {
return '[语音]' return '[语音]'
case ImMessageType.VIDEO: case ImMessageType.VIDEO:
return '[视频]' return '[视频]'
case ImMessageType.CARD:
return `[个人名片] ${parseMessage<CardMessage>(message.content)?.nickname ?? ''}`
case ImMessageType.RECALL: case ImMessageType.RECALL:
return recallTipOf(message) return recallTipOf(message)
default: default:

View File

@ -57,6 +57,14 @@
</span> </span>
</template> </template>
<!-- 名片人形 icon + 个人名片昵称 -->
<template v-else-if="isCard">
<Icon icon="ant-design:user-outlined" :size="14" class="flex-shrink-0" />
<span class="im-reply-preview__text min-w-0">
个人名片{{ parsedPayload?.nickname || '' }}
</span>
</template>
<!-- 图片 / 视频缩略图 --> <!-- 图片 / 视频缩略图 -->
<img <img
v-if="thumbnailUrl" v-if="thumbnailUrl"
@ -90,6 +98,7 @@ import {
parseMessage, parseMessage,
getFileIconInfo, getFileIconInfo,
type AudioMessage, type AudioMessage,
type CardMessage,
type FileMessage, type FileMessage,
type ImageMessage, type ImageMessage,
type TextMessage, type TextMessage,
@ -149,13 +158,14 @@ const senderName = computed(() => {
/** quote.content 解析一次缓存,让多个 computed 复用,长会话每条引用气泡少一次 JSON.parse */ /** quote.content 解析一次缓存,让多个 computed 复用,长会话每条引用气泡少一次 JSON.parse */
type AnyQuotePayload = Partial< type AnyQuotePayload = Partial<
TextMessage & ImageMessage & FileMessage & AudioMessage & VideoMessage TextMessage & ImageMessage & FileMessage & AudioMessage & VideoMessage & CardMessage
> >
const parsedPayload = computed(() => parseMessage<AnyQuotePayload>(props.quote.content)) const parsedPayload = computed(() => parseMessage<AnyQuotePayload>(props.quote.content))
const isText = computed(() => props.quote.type === ImMessageType.TEXT) const isText = computed(() => props.quote.type === ImMessageType.TEXT)
const isFile = computed(() => props.quote.type === ImMessageType.FILE) const isFile = computed(() => props.quote.type === ImMessageType.FILE)
const isVoice = computed(() => props.quote.type === ImMessageType.VOICE) const isVoice = computed(() => props.quote.type === ImMessageType.VOICE)
const isCard = computed(() => props.quote.type === ImMessageType.CARD)
/** 文本超过 MAX_TEXT_PREVIEW_LEN 截断,长内容不撑爆引用块 */ /** 文本超过 MAX_TEXT_PREVIEW_LEN 截断,长内容不撑爆引用块 */
const textPreview = computed(() => { const textPreview = computed(() => {

View File

@ -56,6 +56,12 @@
</span> </span>
</span> </span>
<!-- 名片人形 icon + 昵称管理后台单行预览不渲染头像图片 -->
<span v-else-if="isCard && cardPayload" class="inline-flex gap-1.5 items-center">
<Icon icon="ant-design:user-outlined" :size="16" color="#606266" />
<span>个人名片{{ cardPayload.nickname }}</span>
</span>
<!-- 控制类消息撤回 / 已读 / 回执 --> <!-- 控制类消息撤回 / 已读 / 回执 -->
<span <span
v-else-if="props.type === ImMessageType.RECALL" v-else-if="props.type === ImMessageType.RECALL"
@ -112,7 +118,8 @@ import {
type FileMessage, type FileMessage,
type AudioMessage, type AudioMessage,
type VideoMessage, type VideoMessage,
type TextMessage type TextMessage,
type CardMessage
} from '@/views/im/utils/message' } from '@/views/im/utils/message'
import { import {
resolveFriendNotificationText, resolveFriendNotificationText,
@ -136,6 +143,7 @@ const isImage = computed(() => props.type === ImMessageType.IMAGE)
const isFile = computed(() => props.type === ImMessageType.FILE) const isFile = computed(() => props.type === ImMessageType.FILE)
const isVoice = computed(() => props.type === ImMessageType.VOICE) const isVoice = computed(() => props.type === ImMessageType.VOICE)
const isVideo = computed(() => props.type === ImMessageType.VIDEO) const isVideo = computed(() => props.type === ImMessageType.VIDEO)
const isCard = computed(() => props.type === ImMessageType.CARD)
/** 文本内容:从 TextMessage payload 取 .content */ /** 文本内容:从 TextMessage payload 取 .content */
const textContent = computed( const textContent = computed(
@ -154,6 +162,9 @@ const voicePayload = computed(() =>
const videoPayload = computed(() => const videoPayload = computed(() =>
isVideo.value ? parseMessage<VideoMessage>(props.content || '') : null isVideo.value ? parseMessage<VideoMessage>(props.content || '') : null
) )
const cardPayload = computed(() =>
isCard.value ? parseMessage<CardMessage>(props.content || '') : null
)
/** 点击视频封面:在新标签打开视频 url不在管理后台内嵌播放避免列表里多个 video 同时占资源) */ /** 点击视频封面:在新标签打开视频 url不在管理后台内嵌播放避免列表里多个 video 同时占资源) */
function openVideo() { function openVideo() {