fix(im): 对齐群备注展示并修复 IM 消息管理字典

聊天端:
- 群 API 类型补充 groupRemark 和 silent
- 群列表同步时以接口返回的个人群设置为准,只保留成员缓存
- 会话名写入入口统一使用 getGroupDisplayName,避免群备注被原群名覆盖
- 聊天标题、转发、推荐名片、新建群入口同步群展示名逻辑
- 空群头像且成员未加载时异步预拉群成员,用于合成群头像
- 通讯录和合并消息详情补充滚动容器
- 消息历史日期选择改用 antd Calendar 卡片模式并修正样式

管理端:
- IM 字典常量统一为 im_content_type、im_message_status、im_message_receipt_status
- 私聊 / 群聊消息列表和详情页切换到统一内容类型、消息状态、回执状态字典
- 私聊消息 API 和详情页补充 receiptStatus
- 统计消息类型分布改用内容类型字典
pull/367/head
YunaiV 2026-06-18 08:59:19 -07:00
parent e61d0a5aa2
commit 5a4f8b4e2a
18 changed files with 93 additions and 46 deletions

View File

@ -19,6 +19,8 @@ export namespace ImGroupApi {
createTime?: string; // 创建时间
pinnedMessages?: ImGroupMessageApi.GroupMessageRespVO[]; // 群置顶消息列表(后端关联回填,仅当登录用户是群成员时非空)
joinStatus?: number; // 当前登录用户在该群的成员状态(参见 CommonStatusEnum0 在群 / 1 已退群);历史退群群仍返回,供展示离线消息的群名 / 头像
groupRemark?: string; // 当前登录用户对该群的备注
silent?: boolean; // 当前登录用户是否免打扰
}
/** 群消息置顶 / 取消置顶 Request VO */

View File

@ -14,6 +14,7 @@ export namespace ImManagerPrivateMessageApi {
type: number;
content: string;
status: number;
receiptStatus?: number;
sendTime: Date;
createTime: Date;
}

View File

@ -14,7 +14,7 @@ import { ImContentType, ImConversationType, isGroupConversation } from '../../..
import { getConversationKey } from '../../../utils/conversation'
import { buildDefaultGroupName } from '../../../utils/group'
import { type CardTarget, serializeMessage } from '../../../utils/message'
import { isGroupQuit } from '../../../utils/user'
import { getGroupDisplayName, isGroupQuit } from '../../../utils/user'
import { useMessageSender } from '../../composables/useMessageSender'
import { FacePicker } from '../../pages/conversation/components/input'
import { useConversationStore } from '../../store/conversationStore'
@ -181,7 +181,7 @@ async function handleCreateGroupAndSend() {
const newConversation: Conversation = {
type: ImConversationType.GROUP,
targetId: group.id,
name: group.name || name,
name: getGroupDisplayName(group) || name,
avatar: group.avatar || '',
unreadCount: 0,
lastContent: '',

View File

@ -26,7 +26,7 @@ import {
} from '../../utils/constants'
import { generateClientMessageId, getPrivateMessagePeerId } from '../../utils/message'
import { runMinIdPull } from '../../utils/pull'
import { getFriendDisplayName } from '../../utils/user'
import { getFriendDisplayName, getGroupDisplayName } from '../../utils/user'
import { useConversationStore } from '../store/conversationStore'
import { useFriendStore } from '../store/friendStore'
import { useGroupRequestStore } from '../store/groupRequestStore'
@ -146,7 +146,7 @@ export const useMessagePuller = () => {
return {
type: ImConversationType.GROUP,
targetId: message.groupId,
name: group?.name || String(message.groupId),
name: group ? getGroupDisplayName(group) : String(message.groupId),
avatar: group?.avatar || '',
silent: group?.silent
}

View File

@ -241,7 +241,8 @@ function onRemarkSaved(displayName: string) {
</div>
<!-- 列表主体 FriendRequestList / GroupList / FriendList 三个子组件各自管理折叠 + 过滤本页只透传选中态 -->
<div class="flex-1">
<!-- overflow-y-auto联系人多时本列表可滚动 ResizableAside 不提供滚动对齐 Vue3 el-scrollbar -->
<div class="flex-1 overflow-y-auto">
<FriendRequestList
:requests="friendRequests"
:active-id="selection?.type === 'request' ? selection.request.id : undefined"

View File

@ -11,7 +11,7 @@ import { useConversationStore } from '#/views/im/home/store/conversationStore'
import { useFriendStore } from '#/views/im/home/store/friendStore'
import { useGroupStore } from '#/views/im/home/store/groupStore'
import { ImConversationType } from '#/views/im/utils/constants'
import { getFriendDisplayName } from '#/views/im/utils/user'
import { getFriendDisplayName, getGroupDisplayName } from '#/views/im/utils/user'
import { GroupCreateDialog } from '../../../../components/group'
import { UserAvatar } from '../../../../components/user'
@ -119,7 +119,7 @@ function handleGroupCreated(groupId: number) {
conversationStore.openConversation(
groupId,
ImConversationType.GROUP,
group.name,
getGroupDisplayName(group),
group.avatar || '',
{ silent: !!group.silent }
)

View File

@ -29,7 +29,7 @@ import {
removeQuotePayload,
serializeMessage
} from '#/views/im/utils/message'
import { isGroupQuit } from '#/views/im/utils/user'
import { getGroupDisplayName, isGroupQuit } from '#/views/im/utils/user'
import { FacePicker } from '../../input'
@ -263,7 +263,7 @@ async function handleCreateGroupAndSend() {
const newConversation: Conversation = {
type: ImConversationType.GROUP,
targetId: group.id,
name: group.name || name,
name: getGroupDisplayName(group) || name,
avatar: group.avatar || '',
unreadCount: 0,
lastContent: '',

View File

@ -84,7 +84,8 @@ function handleClose() {
>
以下是 {{ currentPayload.messages.length }} 条消息
</div>
<div class="flex-1">
<!-- overflow-y-auto合并消息多时在定高弹窗内可滚动对齐 Vue3 el-scrollbar -->
<div class="flex-1 overflow-y-auto">
<div
v-for="(item, idx) in currentPayload.messages"
:key="idx"

View File

@ -582,7 +582,11 @@ function locateMessage(messageId: number) {
<div class="px-2 pt-1 pb-2 text-13px font-medium text-[var(--ant-color-text)]">
选择发送日期
</div>
<Calendar v-model:value="datePickerValue" class="im-message-history__calendar" />
<Calendar
v-model:value="datePickerValue"
:fullscreen="false"
class="im-message-history__calendar"
/>
<div class="flex gap-2 justify-end px-2 pt-2">
<Button size="small" @click="datePopoverVisible = false">取消</Button>
<Button size="small" type="primary" @click="onDateConfirm"></Button>
@ -785,18 +789,19 @@ function locateMessage(messageId: number) {
border-radius: 1px;
}
/* :deep 穿透 el-calendar 子组件 DOM默认偏大压一压让它能塞进 320 popover */
.im-message-history__calendar :deep(.el-calendar) {
40px: 36px;
}
.im-message-history__calendar :deep(.el-calendar__header) {
/* :deep 穿透 antd Calendarfullscreen=false 卡片模式)子 DOM压一压塞进 320 popover */
.im-message-history__calendar :deep(.ant-picker-calendar-header) {
padding: 4px 8px;
}
.im-message-history__calendar :deep(.el-calendar-table) {
.im-message-history__calendar :deep(.ant-picker-content) {
font-size: 12px;
}
.im-message-history__calendar :deep(.el-calendar-day) {
height: 36px;
padding: 4px;
.im-message-history__calendar :deep(.ant-picker-cell) {
padding: 1px 0;
}
.im-message-history__calendar :deep(.ant-picker-cell .ant-picker-calendar-date) {
height: 28px;
margin: 0 2px;
padding: 2px 4px 0;
}
</style>

View File

@ -13,7 +13,7 @@ import { getCurrentUserId } from '#/views/im/utils/auth'
import { ImConversationType, ImRtcCallMediaType, ImRtcCallStatus } from '#/views/im/utils/constants'
import { getClientConversationId } from '#/views/im/utils/db'
import { resolveCallEndReasonText } from '#/views/im/utils/message'
import { getMemberDisplayName, isGroupQuit } from '#/views/im/utils/user'
import { getGroupDisplayName, getMemberDisplayName, isGroupQuit } from '#/views/im/utils/user'
import { GroupMuteMemberDialog } from '../../../../components/group'
import {
@ -185,10 +185,11 @@ const groupInfo = computed<
}
const group = groupStore.getGroup(conversation.targetId)
const selfMember = group?.members?.find((member) => member.userId === getCurrentUserId())
const showGroupName = group ? getGroupDisplayName(group) : conversation.name
return {
id: conversation.targetId,
name: group?.name || conversation.name,
showGroupName: group?.name || conversation.name,
showGroupName,
showImage: group?.avatar || conversation.avatar,
notice: group?.notice,
remarkNickName: selfMember?.displayUserName,

View File

@ -10,6 +10,7 @@ import { Button, Dropdown, Input, Menu } from 'ant-design-vue'
import { ImConversationType } from '../../../utils/constants'
import { filterConversationsByKeyword, getConversationKey } from '../../../utils/conversation'
import { StorageKeys } from '../../../utils/db'
import { getGroupDisplayName } from '../../../utils/user'
import { ResizableAside } from '../../components'
import { FriendAddDialog } from '../../components/friend'
import { GroupCreateDialog } from '../../components/group'
@ -124,7 +125,7 @@ function handleGroupCreated(groupId: number) {
conversationStore.openConversation(
groupId,
ImConversationType.GROUP,
group.name,
getGroupDisplayName(group),
group.avatar || '',
{ silent: !!group.silent }
)

View File

@ -223,7 +223,7 @@ export const useGroupStore = defineStore('imGroupStore', {
return
}
const fresh = (list || []).map((group) => convertGroup(group))
// 合并而非全量替换:silent / groupRemark / 成员缓存这些字段不在 ImGroupApi.GroupRespVO 里,得从旧 group 保留
// 合并而非全量替换:成员缓存只在成员列表接口维护,群个人设置以群列表接口为准
const groupMap = new Map(this.groups.map((group) => [group.id, group]))
this.groups = fresh.map((group) => {
const existing = groupMap.get(group.id)
@ -234,8 +234,6 @@ export const useGroupStore = defineStore('imGroupStore', {
...group,
members: existing.members,
memberCount: existing.memberCount ?? group.memberCount,
silent: existing.silent ?? group.silent,
groupRemark: existing.groupRemark,
membersLoaded: existing.membersLoaded,
membersExpired: existing.membersExpired
}
@ -250,6 +248,24 @@ export const useGroupStore = defineStore('imGroupStore', {
})
}
this.saveGroupList()
this.preloadMembersForEmptyAvatarGroups()
},
/** 预加载空群头像的成员列表,供 GroupAvatar 异步合成群头像 */
preloadMembersForEmptyAvatarGroups() {
for (const group of this.groups) {
if (
group.avatar ||
group.joinStatus === CommonStatusEnum.DISABLE ||
(group.membersLoaded && !group.membersExpired && group.members?.length)
) {
continue
}
const force = !!group.membersLoaded && !group.membersExpired && !group.members?.length
this.fetchGroupMemberList(group.id, force).catch((error) => {
console.warn('[IM groupStore] 预加载群头像成员失败', { groupId: group.id }, error)
})
}
},
/** 失效全部群成员缓存 */
@ -908,7 +924,9 @@ function convertGroup(group: ImGroupApi.GroupRespVO): Group {
mutedAll: group.mutedAll,
banned: group.banned,
joinApproval: group.joinApproval,
joinStatus: group.joinStatus
joinStatus: group.joinStatus,
groupRemark: group.groupRemark,
silent: group.silent
}
}

View File

@ -45,7 +45,7 @@ import {
playAudioTip,
resolveCallEndReasonText
} from '../../utils/message'
import { getFriendDisplayName } from '../../utils/user'
import { getFriendDisplayName, getGroupDisplayName } from '../../utils/user'
import { useConversationStore } from './conversationStore'
import { type FriendNotificationPayload, useFriendStore } from './friendStore'
import { useGroupRequestStore } from './groupRequestStore'
@ -781,7 +781,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
{
type: ImConversationType.GROUP,
targetId: websocketMessage.groupId,
name: group?.name || String(websocketMessage.groupId),
name: group ? getGroupDisplayName(group) : String(websocketMessage.groupId),
avatar: group?.avatar || '',
silent: group?.silent
},

View File

@ -35,7 +35,7 @@ export function usePrivateGridFormSchema(): VbenFormSchema[] {
component: 'Select',
componentProps: {
allowClear: true,
options: getDictOptions(DICT_TYPE.IM_MESSAGE_TYPE, 'number'),
options: getDictOptions(DICT_TYPE.IM_CONTENT_TYPE, 'number'),
placeholder: '请选择内容类型',
},
},
@ -84,7 +84,7 @@ export function usePrivateGridColumns(showReadColumns: boolean): VxeTableGridOpt
width: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.IM_MESSAGE_TYPE },
props: { type: DICT_TYPE.IM_CONTENT_TYPE },
},
},
{
@ -101,7 +101,17 @@ export function usePrivateGridColumns(showReadColumns: boolean): VxeTableGridOpt
width: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.IM_PRIVATE_MESSAGE_STATUS },
props: { type: DICT_TYPE.IM_MESSAGE_STATUS },
},
},
{
field: 'receiptStatus',
title: '回执',
width: 110,
cellRender: {
name: 'CellDict',
// 回执状态(私聊 / 群聊共用 im_message_receipt_status与源端「回执」列对齐
props: { type: DICT_TYPE.IM_MESSAGE_RECEIPT_STATUS },
},
},
]
@ -143,7 +153,7 @@ export function useGroupGridFormSchema(): VbenFormSchema[] {
component: 'Select',
componentProps: {
allowClear: true,
options: getDictOptions(DICT_TYPE.IM_MESSAGE_TYPE, 'number'),
options: getDictOptions(DICT_TYPE.IM_CONTENT_TYPE, 'number'),
placeholder: '请选择内容类型',
},
},
@ -192,7 +202,7 @@ export function useGroupGridColumns(showReadColumns: boolean): VxeTableGridOptio
width: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.IM_MESSAGE_TYPE },
props: { type: DICT_TYPE.IM_CONTENT_TYPE },
},
},
{
@ -214,7 +224,7 @@ export function useGroupGridColumns(showReadColumns: boolean): VxeTableGridOptio
width: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.IM_GROUP_MESSAGE_STATUS },
props: { type: DICT_TYPE.IM_MESSAGE_STATUS },
},
},
{
@ -223,7 +233,7 @@ export function useGroupGridColumns(showReadColumns: boolean): VxeTableGridOptio
width: 110,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.IM_GROUP_MESSAGE_RECEIPT_STATUS },
props: { type: DICT_TYPE.IM_MESSAGE_RECEIPT_STATUS },
},
},
]

View File

@ -48,14 +48,14 @@ defineExpose({ open });
{{ formatUserLabel(detail.senderNickname, detail.senderId) }}
</DescriptionsItem>
<DescriptionsItem label="类型">
<DictTag :type="DICT_TYPE.IM_MESSAGE_TYPE" :value="detail.type" />
<DictTag :type="DICT_TYPE.IM_CONTENT_TYPE" :value="detail.type" />
</DescriptionsItem>
<DescriptionsItem label="状态">
<DictTag :type="DICT_TYPE.IM_GROUP_MESSAGE_STATUS" :value="detail.status" />
<DictTag :type="DICT_TYPE.IM_MESSAGE_STATUS" :value="detail.status" />
</DescriptionsItem>
<DescriptionsItem v-if="MESSAGE_GROUP_READ_ENABLED" label="回执" :span="2">
<DictTag
:type="DICT_TYPE.IM_GROUP_MESSAGE_RECEIPT_STATUS"
:type="DICT_TYPE.IM_MESSAGE_RECEIPT_STATUS"
:value="detail.receiptStatus"
/>
</DescriptionsItem>

View File

@ -13,6 +13,7 @@ import {
formatJsonText,
formatUserLabel,
} from '#/views/im/manager/utils/format';
import { MESSAGE_PRIVATE_READ_ENABLED } from '#/views/im/utils/config';
import { MessageContentPreview } from '../..';
@ -44,10 +45,17 @@ defineExpose({ open });
{{ formatUserLabel(detail.receiverNickname, detail.receiverId) }}
</DescriptionsItem>
<DescriptionsItem label="类型">
<DictTag :type="DICT_TYPE.IM_MESSAGE_TYPE" :value="detail.type" />
<DictTag :type="DICT_TYPE.IM_CONTENT_TYPE" :value="detail.type" />
</DescriptionsItem>
<DescriptionsItem label="状态">
<DictTag :type="DICT_TYPE.IM_PRIVATE_MESSAGE_STATUS" :value="detail.status" />
<DictTag :type="DICT_TYPE.IM_MESSAGE_STATUS" :value="detail.status" />
</DescriptionsItem>
<DescriptionsItem v-if="MESSAGE_PRIVATE_READ_ENABLED" label="回执">
<!-- 回执状态私聊 / 群聊共用 im_message_receipt_status与源端私聊详情回执对齐 -->
<DictTag
:type="DICT_TYPE.IM_MESSAGE_RECEIPT_STATUS"
:value="detail.receiptStatus"
/>
</DescriptionsItem>
<DescriptionsItem label="发送时间" :span="2">
{{ formatDateTimeText(detail.sendTime) }}

View File

@ -56,7 +56,7 @@ async function loadData() {
const data = await getMessageTypeDistribution();
const items = data.map((item) => ({
name:
getDictObj(DICT_TYPE.IM_MESSAGE_TYPE, String(item.type))?.label ||
getDictObj(DICT_TYPE.IM_CONTENT_TYPE, String(item.type))?.label ||
`未知(${item.type})`,
value: item.value,
}));

View File

@ -178,17 +178,16 @@ const IOT_DICT = {
/** ========== IM - 即时通讯模块 ========== */
const IM_DICT = {
IM_CHANNEL_MATERIAL_TYPE: 'im_channel_material_type', // IM 频道素材类型
IM_CONTENT_TYPE: 'im_content_type', // IM 消息内容类型
IM_FRIEND_ADD_SOURCE: 'im_friend_add_source', // IM 好友添加来源
IM_FRIEND_REQUEST_HANDLE_RESULT: 'im_friend_request_handle_result', // IM 好友申请处理结果
IM_FRIEND_STATUS: 'im_friend_status', // IM 好友状态
IM_GROUP_ADD_SOURCE: 'im_group_add_source', // IM 加群来源
IM_GROUP_MEMBER_ROLE: 'im_group_member_role', // IM 群成员角色
IM_GROUP_MESSAGE_RECEIPT_STATUS: 'im_group_message_receipt_status', // IM 群消息回执状态
IM_GROUP_MESSAGE_STATUS: 'im_group_message_status', // IM 群聊消息状态
IM_GROUP_REQUEST_HANDLE_RESULT: 'im_group_request_handle_result', // IM 加群申请处理结果
IM_GROUP_STATUS: 'im_group_status', // IM 群状态
IM_MESSAGE_TYPE: 'im_message_type', // IM 消息类型
IM_PRIVATE_MESSAGE_STATUS: 'im_private_message_status', // IM 私聊消息状态
IM_MESSAGE_RECEIPT_STATUS: 'im_message_receipt_status', // IM 消息回执状态(私聊 / 群聊共用)
IM_MESSAGE_STATUS: 'im_message_status', // IM 消息状态(私聊 / 群聊共用)
IM_RTC_CALL_CONVERSATION_TYPE: 'im_rtc_call_conversation_type', // IM 通话会话类型
IM_RTC_CALL_END_REASON: 'im_rtc_call_end_reason', // IM 通话结束原因
IM_RTC_CALL_MEDIA_TYPE: 'im_rtc_call_media_type', // IM 通话媒体类型