✨ feat(im): 初始化群名片 v0.1:第一次评审
parent
808ad575fc
commit
65d5aacac9
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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) {
|
|||
}
|
||||
}
|
||||
|
||||
/** 构造名片消息 content(JSON 字符串);user 由调用方 narrow 后显式传入避免 non-null 断言 */
|
||||
function buildCardContent(user: User): string {
|
||||
const payload: CardMessage = {
|
||||
userId: user.id!,
|
||||
nickname: user.nickname || '',
|
||||
avatar: user.avatar
|
||||
}
|
||||
/** 构造名片消息 content(JSON 字符串);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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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' })
|
||||
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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_* 系列)的中文文案
|
||||
*
|
||||
|
|
|
|||
Loading…
Reference in New Issue