feat(im): 初始化群名片 v0.1:第一次评审

im
YunaiV 2026-05-07 13:07:56 +08:00
parent 808ad575fc
commit 65d5aacac9
11 changed files with 277 additions and 108 deletions

View File

@ -0,0 +1,67 @@
<template>
<!--
名片消息气泡 / 名片预览卡240px用户名片 + 群名片通用
- 头像 + 名字 + 群成员数副标题仅群名片+ 底部分隔条群名片 / 个人名片
- 用户名片把 :id 传给 UserAvatar 让点击 avatar UserInfoCard群名片不传 id
- 整卡 click 由调用方监听@click组件不内嵌业务逻辑
-->
<div
class="flex flex-col w-[240px] rounded-md overflow-hidden bg-[var(--el-bg-color)] border border-[var(--el-border-color-lighter)]"
:class="{ 'cursor-pointer': clickable }"
>
<div class="flex gap-2.5 items-center px-3 py-2.5">
<UserAvatar
:id="isUser ? card.targetId : undefined"
:url="card.avatar"
:name="card.name"
:size="40"
:clickable="false"
/>
<div class="flex flex-col flex-1 min-w-0">
<div class="text-sm font-medium truncate text-[var(--el-text-color-primary)]">
{{ card.name }}
</div>
<div
v-if="!isUser && card.memberCount"
class="text-12px truncate text-[var(--el-text-color-placeholder)]"
>
{{ card.memberCount }} 人群聊
</div>
</div>
</div>
<div
class="px-3 py-1 text-12px border-t text-[var(--el-text-color-placeholder)] border-[var(--el-border-color-lighter)] bg-[var(--el-fill-color-lighter)]"
>
{{ labelInfo.label }}
</div>
</div>
</template>
<script lang="ts" setup>
import { computed } from 'vue'
import UserAvatar from '../user/UserAvatar.vue'
import { isPrivateConversation } from '@/views/im/utils/constants'
import {
getCardLabelInfo,
type CardMessage,
type CardTarget
} from '@/views/im/utils/message'
defineOptions({ name: 'ImCardBubble' })
const props = withDefaults(
defineProps<{
/** 名片数据CardMessage接收侧消息体或 CardTarget发送侧预览共用结构 */
card: CardMessage | CardTarget
/** 是否显示 cursor: pointer调用方负责绑 @click 监听 */
clickable?: boolean
}>(),
{ clickable: false }
)
/** 是否用户名片:决定 UserAvatar 是否带 id 触发 UserInfoCard */
const isUser = computed(() => isPrivateConversation(props.card.targetType))
/** 名片标签信息 */
const labelInfo = computed(() => getCardLabelInfo(props.card))
</script>

View File

@ -0,0 +1,28 @@
<template>
<!-- 名片单行 inline label[icon] 群名片 / 个人名片xx列表摘要 / 引用预览 / 后台预览复用 -->
<span class="inline-flex gap-1.5 items-center">
<Icon :icon="labelInfo.icon" :size="iconSize" />
<span>{{ labelInfo.label }}{{ card?.name || '' }}</span>
</span>
</template>
<script lang="ts" setup>
import { computed } from 'vue'
import Icon from '@/components/Icon/src/Icon.vue'
import { getCardLabelInfo } from '@/views/im/utils/message'
defineOptions({ name: 'ImCardLineLabel' })
const props = withDefaults(
defineProps<{
/** 名片数据;只读 targetType / name 派生标签 + 显示,结构性类型兼容 CardMessage / 引用预览的 partial */
card: { targetType?: number; name?: string } | null | undefined
iconSize?: number
}>(),
{ iconSize: 14 }
)
/** 标签 + 图标按 targetType 二分;兜底「个人名片」避免 null 时 UI 空白 */
const labelInfo = computed(() => getCardLabelInfo(props.card))
</script>

View File

@ -1,6 +1,6 @@
<template>
<!--
他推荐给朋友个人名片转发弹窗对齐微信 PC 双栏布局
名片推荐给朋友用户 / 群通用对齐微信 PC 双栏布局
- 左栏搜索 + 最近聊天列表圆形单/多选指示
- 右栏已选预览每行可移除+ 名片预览卡 + 留言 + 取消/发送
- 选中时按 1 个走发送多个走分别发送(n)文案与微信一致
@ -8,7 +8,7 @@
-->
<el-dialog
v-model="visible"
title="把他推荐给朋友"
:title="dialogTitle"
width="720px"
:close-on-click-modal="false"
class="im-recommend-dialog"
@ -123,30 +123,7 @@
<div
class="flex flex-col gap-3 px-4 py-3 flex-shrink-0 border-t border-[var(--el-border-color-lighter)]"
>
<!-- 名片预览和聊天里的名片气泡同源浅卡片 + 个人名片分隔条 -->
<div
class="flex flex-col w-full rounded-md overflow-hidden bg-[var(--el-bg-color)] border border-[var(--el-border-color-lighter)]"
>
<div class="flex gap-2.5 items-center px-3 py-2.5">
<UserAvatar
:id="user?.id"
:url="user?.avatar"
:name="user?.nickname"
:size="36"
:clickable="false"
/>
<div
class="flex-1 min-w-0 text-sm font-medium truncate text-[var(--el-text-color-primary)]"
>
{{ user?.nickname }}
</div>
</div>
<div
class="px-3 py-1 text-12px border-t text-[var(--el-text-color-placeholder)] border-[var(--el-border-color-lighter)] bg-[var(--el-fill-color-lighter)]"
>
个人名片
</div>
</div>
<CardBubble v-if="target" :card="target" />
<!-- 留言单行右侧表情按钮触发 FacePicker(mode=emoji)所选 emoji 直接拼接到输入末尾 -->
<div class="relative">
@ -192,21 +169,22 @@ import { computed, ref } from 'vue'
import Icon from '@/components/Icon/src/Icon.vue'
import { useMessage } from '@/hooks/web/useMessage'
import CardBubble from '@/views/im/home/components/card/CardBubble.vue'
import UserAvatar from './UserAvatar.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'
import { ImMessageType, isGroupConversation } from '../../../utils/constants'
import { filterConversationsByKeyword, getConversationKey } from '../../../utils/conversation'
import { serializeMessage, type CardMessage } from '../../../utils/message'
import type { Conversation, User } from '../../types'
import { serializeMessage, type CardMessage, type CardTarget } from '../../../utils/message'
import type { Conversation } from '../../types'
defineOptions({ name: 'ImRecommendCardDialog' })
const props = defineProps<{
modelValue: boolean
/** 被推荐的用户名片:触发方传入;为 null 时不渲染(弹窗也不应被打开) */
user: User | null
/** 被推荐的名片对象(用户 / 群通用);为 null 时不渲染(弹窗也不应被打开) */
target: CardTarget | null
}>()
const emit = defineEmits<{
@ -223,6 +201,11 @@ const visible = computed({
set: (value) => emit('update:modelValue', value)
})
/** 弹窗标题:群名片走「把这个群推荐给朋友」,否则「把他推荐给朋友」 */
const dialogTitle = computed(() =>
isGroupConversation(props.target?.targetType) ? '把这个群推荐给朋友' : '把他推荐给朋友'
)
const keyword = ref('')
const leaveMessage = ref('')
const sending = ref(false)
@ -249,11 +232,14 @@ function handleEmojiSelect(emoji: string) {
leaveMessage.value = `${leaveMessage.value}${emoji}`
}
/** 候选会话:私聊「推荐给本人」过滤掉避免无意义自推 */
/** 候选会话:过滤掉名片对象本身的会话(同 type + 同 id用户名片避免自推、群名片避免推回该群 */
const candidateConversations = computed<Conversation[]>(() => {
const recommendId = props.user?.id
const target = props.target
if (!target) {
return conversationStore.getSortedConversations
}
return conversationStore.getSortedConversations.filter(
(c) => !(recommendId && c.type === ImConversationType.PRIVATE && c.targetId === recommendId)
(c) => !(c.type === target.targetType && c.targetId === target.targetId)
)
})
@ -284,13 +270,9 @@ function handleToggle(conversation: Conversation) {
}
}
/** 构造名片消息 contentJSON 字符串user 由调用方 narrow 后显式传入避免 non-null 断言 */
function buildCardContent(user: User): string {
const payload: CardMessage = {
userId: user.id!,
nickname: user.nickname || '',
avatar: user.avatar
}
/** 构造名片消息 contentJSON 字符串CardTarget 字段已与 CardMessage 对齐spread 即可 */
function buildCardContent(target: CardTarget): string {
const payload: CardMessage = { ...target }
return serializeMessage(payload)
}
@ -301,25 +283,25 @@ function buildCardContent(user: User): string {
* 失败的消息以 FAILED 状态留在对应会话气泡里可右键重试
*/
async function handleSend() {
const user = props.user
if (!user?.id || selectedKeys.value.length === 0) {
const target = props.target
if (!target?.targetId || selectedKeys.value.length === 0) {
return
}
const targets = selectedConversations.value
const cardContent = buildCardContent(user)
const cardContent = buildCardContent(target)
const leaveText = leaveMessage.value.trim()
sending.value = true
try {
const tasks = targets.map(async (target) => {
const cardOk = await sendRaw(ImMessageType.CARD, cardContent, { conversation: target })
const tasks = targets.map(async (conversation) => {
const cardOk = await sendRaw(ImMessageType.CARD, cardContent, { conversation })
if (!cardOk) {
return { target, ok: false }
return { conversation, ok: false }
}
const ok = leaveText ? await send(leaveText, { conversation: target }) : true
return { target, ok }
const ok = leaveText ? await send(leaveText, { conversation }) : true
return { conversation, ok }
})
const results = await Promise.all(tasks)
const failedNames = results.filter((r) => !r.ok).map((r) => r.target.name || '未命名会话')
const failedNames = results.filter((r) => !r.ok).map((r) => r.conversation.name || '未命名会话')
if (failedNames.length === 0) {
message.success('已转发')
} else if (failedNames.length === targets.length) {

View File

@ -179,7 +179,7 @@
/>
<!-- 把他推荐给朋友弹窗 friend 态下出现入口 -->
<RecommendCardDialog v-model="recommendVisible" :user="full" />
<RecommendCardDialog v-model="recommendVisible" :target="recommendTarget" />
</div>
</template>
@ -195,6 +195,7 @@ import RecommendCardDialog from './RecommendCardDialog.vue'
import { getSimpleUser, type UserVO } from '@/api/system/user'
import { useFriendStore } from '../../store/friendStore'
import { ImFriendAddSource } from '../../../utils/constants'
import { toUserCardTarget } from '../../../utils/message'
import { getGenderColor, getGenderIcon } from '../../../utils/user'
import { DICT_TYPE, getDictLabel } from '@/utils/dict'
import { formatDate } from '@/utils/formatTime'
@ -353,6 +354,8 @@ const presetUserForAdd = ref<UserVO | null>(null)
/** 把他推荐给朋友:弹 RecommendCardDialog 选目标会话 */
const recommendVisible = ref(false) //
/** 推荐名片源对象用户名片targetType = PRIVATE从 full 派生 */
const recommendTarget = computed(() => toUserCardTarget(full.value))
function handleRecommend() {
if (!props.user?.id) {
return

View File

@ -253,6 +253,19 @@
class="im-conversation-group-side__chevron"
/>
</div>
<!-- 分享群名片 RecommendCardDialog把当前群作为名片消息推荐给其他会话 -->
<div
v-if="group"
class="im-conversation-group-side__row im-conversation-group-side__row--clickable"
@click="recommendCardVisible = true"
>
<span class="im-conversation-group-side__label">分享群名片</span>
<Icon
icon="ant-design:right-outlined"
:size="11"
class="im-conversation-group-side__chevron"
/>
</div>
</div>
<div class="im-conversation-group-side__spacer"></div>
@ -395,6 +408,9 @@
<!-- 进群申请列表仅当开启审批 + 当前用户是 owner / admin 时入口可见 -->
<GroupRequestListDialog v-model="requestListVisible" :group-id="group?.id" />
<!-- 分享群名片把当前群作为名片消息推荐给其他会话 -->
<RecommendCardDialog v-model="recommendCardVisible" :target="recommendCardTarget" />
</el-drawer>
</template>
@ -427,6 +443,8 @@ import GroupMemberSelector, {
type GroupMemberFlag
} from '../../../../components/group/GroupMemberSelector.vue'
import GroupRequestListDialog from '../../../../components/group/GroupRequestListDialog.vue'
import RecommendCardDialog from '../../../../components/user/RecommendCardDialog.vue'
import { toGroupCardTarget } from '@/views/im/utils/message'
import type { Conversation, FriendLite, GroupLite } from '../../../../types'
import type { GroupMemberLite } from '../../../../components/group/GroupMember.vue'
@ -471,6 +489,10 @@ const removeVisible = ref(false)
const adminVisible = ref(false)
const transferOwnerVisible = ref(false)
const requestListVisible = ref(false)
/** 分享群名片弹窗显隐:「分享群名片」入口控制 */
const recommendCardVisible = ref(false)
/** 群名片源对象targetType = GROUP含成员数快照 */
const recommendCardTarget = computed(() => toGroupCardTarget(props.group))
const showAllMembers = ref(false)
const namePopoverVisible = ref(false)
const noticePopoverVisible = ref(false)

View File

@ -252,14 +252,12 @@
[视频]
</div>
<!-- 名片人形 icon + 个人名片昵称 -->
<div
<!-- 名片 -->
<CardLineLabel
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>
:card="cardOf(message)"
class="text-sm text-[var(--el-text-color-secondary)]"
/>
<!-- 表情贴图直接渲染图片对照微信观感 -->
<img
@ -709,8 +707,10 @@ function textSnippetOf(message: Message): string {
return '[语音]'
case ImMessageType.VIDEO:
return '[视频]'
case ImMessageType.CARD:
return `[个人名片] ${parseMessage<CardMessage>(message.content)?.nickname ?? ''}`
case ImMessageType.CARD: {
const card = parseMessage<CardMessage>(message.content)
return `[${getCardLabelInfo(card).label}] ${card?.name ?? ''}`
}
case ImMessageType.FACE:
return buildFacePreviewText(faceOf(message))
case ImMessageType.RECALL:

View File

@ -57,13 +57,8 @@
</span>
</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>
<!-- 名片 -->
<CardLineLabel v-else-if="isCard" :card="parsedPayload" class="im-reply-preview__text min-w-0" />
<!-- 表情贴图缩略图 + name name 仅显示 [表情] -->
<template v-else-if="isFace">
@ -102,6 +97,7 @@ import { formatFileSize } from '@/utils/file'
import { useConversationStore } from '../../../../store/conversationStore'
import { getSenderDisplayName } from '@/views/im/utils/user'
import { ImMessageType } from '@/views/im/utils/constants'
import CardLineLabel from '@/views/im/home/components/card/CardLineLabel.vue'
import {
parseMessage,
getFileIconInfo,

View File

@ -8,8 +8,8 @@
:inline="true"
label-width="80px"
>
<el-form-item label="群编号" prop="groupId">
<el-input v-model="queryParams.groupId" placeholder="请输入群编号" clearable class="!w-240px" />
<el-form-item label="群" prop="groupId">
<GroupSelect v-model="queryParams.groupId" placeholder="请选择群" class="!w-240px" />
</el-form-item>
<el-form-item label="申请人" prop="userId">
<UserSelectV2 v-model="queryParams.userId" placeholder="请选择申请人" class="!w-240px" />
@ -141,6 +141,7 @@ import { dateFormatter } from '@/utils/formatTime'
import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
import * as ManagerGroupRequestApi from '@/api/im/manager/group/request'
import UserSelectV2 from '@/views/system/user/components/UserSelectV2.vue'
import GroupSelect from '@/views/im/manager/group/components/GroupSelect.vue'
defineOptions({ name: 'ImGroupRequest' })

View File

@ -56,11 +56,8 @@
</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>
<!-- 名片 -->
<CardLineLabel v-else-if="isCard && cardPayload" :card="cardPayload" :icon-size="16" />
<!-- 表情贴图缩略图 + 表情名无名字时仅 [表情] -->
<span v-else-if="isFace && facePayload" class="inline-flex gap-1.5 items-center">
@ -121,9 +118,11 @@ import { formatFileSize } from '@/utils/file'
import { formatSeconds } from '@/utils/formatTime'
import {
ImMessageType,
MERGE_FORWARD_PREVIEW_LINES,
isFriendChatTip,
isGroupNotification
} from '@/views/im/utils/constants'
import CardLineLabel from '@/views/im/home/components/card/CardLineLabel.vue'
import {
parseMessage,
type ImageMessage,
@ -132,13 +131,14 @@ import {
type VideoMessage,
type TextMessage,
type CardMessage,
type FaceMessage
type FaceMessage,
type MergeMessage
} from '@/views/im/utils/message'
import {
resolveFriendNotificationText,
resolveGroupNotificationText
} from '@/views/im/utils/user'
import { buildFacePreviewText } from '@/views/im/utils/conversation'
import { buildFacePreviewText, summarizeMessageContent } from '@/views/im/utils/conversation'
defineOptions({ name: 'ImMessageContentPreview' })
@ -159,6 +159,7 @@ const isVoice = computed(() => props.type === ImMessageType.VOICE)
const isVideo = computed(() => props.type === ImMessageType.VIDEO)
const isCard = computed(() => props.type === ImMessageType.CARD)
const isFace = computed(() => props.type === ImMessageType.FACE)
const isMerge = computed(() => props.type === ImMessageType.MERGE)
/** 文本内容:从 TextMessage payload 取 .content */
const textContent = computed(
@ -183,6 +184,19 @@ const cardPayload = computed(() =>
const facePayload = computed(() =>
isFace.value ? parseMessage<FaceMessage>(props.content || '') : null
)
const mergePayload = computed(() =>
isMerge.value ? parseMessage<MergeMessage>(props.content || '') : null
)
/** 合并转发预览行:取前 N 条派生「{昵称}{摘要}」 */
const mergePreviewLines = computed(() => {
if (!mergePayload.value) {
return []
}
return mergePayload.value.messages
.slice(0, MERGE_FORWARD_PREVIEW_LINES)
.map((item) => `${item.senderNickname}${summarizeMessageContent(item)}`)
})
/** 点击视频封面:在新标签打开视频 url不在管理后台内嵌播放避免列表里多个 video 同时占资源) */
function openVideo() {

View File

@ -7,7 +7,14 @@
// ====================================================================
import { ImMessageType, isFriendChatTip, isGroupNotification } from './constants'
import { parseMessage, type FaceMessage, type TextMessage } from './message'
import {
getCardLabelInfo,
parseMessage,
type CardMessage,
type FaceMessage,
type FileMessage,
type TextMessage
} from './message'
import {
getSenderDisplayName,
resolveFriendNotificationText,
@ -60,6 +67,43 @@ export function buildRecallTip(
return `${senderDisplayName || '对方'} 撤回了一条消息`
}
/**
* conversation
*
* - withFileName=true
* - RECALL / 广 /
*/
export function summarizeMessageContent(
message: Pick<Message, 'type' | 'content'>,
opts?: { withFileName?: boolean }
): string {
switch (message.type) {
case ImMessageType.TEXT:
return parseMessage<TextMessage>(message.content)?.content ?? ''
case ImMessageType.IMAGE:
return '[图片]'
case ImMessageType.VOICE:
return '[语音]'
case ImMessageType.VIDEO:
return '[视频]'
case ImMessageType.FILE: {
if (opts?.withFileName) {
const file = parseMessage<FileMessage>(message.content)
return file?.name ? `[文件] ${file.name}` : '[文件]'
}
return '[文件]'
}
case ImMessageType.CARD:
return `[${getCardLabelInfo(parseMessage<CardMessage>(message.content)).label}]`
case ImMessageType.FACE:
return buildFacePreviewText(parseMessage<FaceMessage>(message.content))
case ImMessageType.MERGE:
return '[聊天记录]'
default:
return ''
}
}
/** 会话列表最后一条摘要RECALL 走 buildRecallTip + fallbackName其它按消息类型派生 */
export function resolveConversationLastContent(
message: Message,
@ -67,20 +111,7 @@ export function resolveConversationLastContent(
conversationTargetId: number,
fallbackName?: string
): string {
switch (message.type) {
case ImMessageType.IMAGE:
return '[图片]'
case ImMessageType.FILE:
return '[文件]'
case ImMessageType.VOICE:
return '[语音]'
case ImMessageType.VIDEO:
return '[视频]'
case ImMessageType.CARD:
return '[个人名片]'
case ImMessageType.FACE:
return buildFacePreviewText(parseMessage<FaceMessage>(message.content))
case ImMessageType.RECALL:
if (message.type === ImMessageType.RECALL) {
return buildRecallTip(
message.senderId,
message.selfSend,
@ -88,15 +119,12 @@ export function resolveConversationLastContent(
conversationTargetId,
fallbackName
)
case ImMessageType.TEXT:
return parseMessage<TextMessage>(message.content)?.content ?? ''
default:
}
if (isFriendChatTip(message.type)) {
return resolveFriendNotificationText(message)
}
if (isGroupNotification(message.type)) {
return resolveGroupNotificationText(message)
}
return parseMessage<TextMessage>(message.content)?.content ?? ''
}
return summarizeMessageContent(message)
}

View File

@ -148,6 +148,34 @@ export function getSenderRealNickname(
return String(senderId)
}
// TODO @AI这个方法还需要么之前是哪个调用的可能要看看。
/**
* conversation group.members / friend / userStore
*
* - userStore.avatar
* - friend.avatar
* - member.avatar friend.avatar
* - UserAvatar
*/
export function getSenderAvatar(
senderId: number,
conversationType: number,
conversationTargetId: number
): string {
const userStore = useUserStore()
if (senderId === getCurrentUserId()) {
return userStore.getUser?.avatar || ''
}
if (conversationType === ImConversationType.GROUP) {
const group = useGroupStore().getGroup(conversationTargetId)
const member = group?.members?.find((m) => m.userId === senderId)
if (member?.avatar) {
return member.avatar
}
}
return useFriendStore().getFriend(senderId)?.avatar || ''
}
/**
* 广GROUP_*
*