feat(im): 增加群消息的置顶

im
YunaiV 2026-05-03 12:15:39 +08:00
parent ffb69063b9
commit 01e0e8e37b
9 changed files with 514 additions and 65 deletions

View File

@ -1,4 +1,5 @@
import request from '@/config/axios'
import type { ImGroupMessageRespVO } from '@/api/im/message/group'
// 群 Response VO
export interface ImGroupRespVO {
@ -12,6 +13,14 @@ export interface ImGroupRespVO {
status: number // 群状态0=正常1=已解散)
dissolvedTime?: string // 解散时间
createTime?: string // 创建时间
// TODO @AI不太对返回的就是 ImGroupMessageRespVO 数组
pinnedMessages?: ImGroupMessageRespVO[] // 群置顶消息列表(后端关联回填,仅当登录用户是群成员时非空)
}
// 群消息置顶 / 取消置顶 Request VO
export interface ImGroupMessagePinReqVO {
groupId: number // 群编号
messageId: number // 消息编号
}
// 群创建 Request VO
@ -79,3 +88,13 @@ export const removeGroupAdmin = (data: ImGroupAdminReqVO) => {
export const transferGroupOwner = (data: ImGroupTransferOwnerReqVO) => {
return request.put<boolean>({ url: '/im/group/transfer-owner', data })
}
// 置顶群消息(仅群主 / 管理员可调)
export const pinGroupMessage = (data: ImGroupMessagePinReqVO) => {
return request.put<boolean>({ url: '/im/group/pin-message', data })
}
// 取消置顶群消息(仅群主 / 管理员可调)
export const unpinGroupMessage = (data: ImGroupMessagePinReqVO) => {
return request.put<boolean>({ url: '/im/group/unpin-message', data })
}

View File

@ -237,6 +237,9 @@
<!-- ==================== 查找聊天内容 ==================== -->
<!-- 点击 父组件打开 MessageHistory 弹窗 -->
<div class="im-conversation-group-side__spacer"></div>
<div class="im-conversation-group-side__section">
<div
class="im-conversation-group-side__row im-conversation-group-side__row--clickable"
@ -356,15 +359,16 @@ import { useMessage } from '@/hooks/web/useMessage'
import { useUserStore } from '@/store/modules/user'
import { CommonStatusEnum } from '@/utils/constants'
import { updateGroup, addGroupAdmin, removeGroupAdmin, transferGroupOwner } from '@/api/im/group'
import {
updateGroup,
addGroupAdmin,
removeGroupAdmin,
transferGroupOwner
} from '@/api/im/group'
import { quitGroup, removeGroupMember, updateGroupMember } from '@/api/im/group/member'
import { useConversationStore } from '../../../../store/conversationStore'
import { useGroupStore } from '../../../../store/groupStore'
import {
GROUP_ADMIN_MAX_COUNT,
ImConversationType,
ImGroupMemberRole
} from '@/views/im/utils/constants'
import { GROUP_ADMIN_MAX_COUNT, ImConversationType, ImGroupMemberRole } from '@/views/im/utils/constants'
import GroupMemberGrid from '../../../../components/group/GroupMemberGrid.vue'
import GroupMemberAddDialog from '../../../../components/group/GroupMemberAddDialog.vue'
import GroupMemberSelector, {
@ -462,6 +466,7 @@ const isOwnerOrAdmin = computed(
() => myRole.value === ImGroupMemberRole.OWNER || myRole.value === ImGroupMemberRole.ADMIN
)
// 退 + userId
const visibleMembers = computed(() => {
return props.members

View File

@ -0,0 +1,251 @@
<template>
<!-- 群聊置顶消息仅群聊 + 有置顶时显示悬挂在群聊头部下方左上角不占整行对齐微信 PC -->
<div v-if="pinnedMessages.length > 0" class="im-conversation-group-pinned">
<!-- 顶部胶囊单条点击跳转多条折叠点击展开多条展开点击折叠 -->
<div
class="im-conversation-group-pinned__row im-conversation-group-pinned__row--clickable"
@click="handleTopClick"
>
<Icon icon="ant-design:pushpin-outlined" :size="14" class="im-conversation-group-pinned__icon" />
<span class="im-conversation-group-pinned__sender">{{ getSenderName(latest) }}</span>
<span class="im-conversation-group-pinned__text">{{ getPreview(latest) }}</span>
<!-- 单条移除按钮多条折叠 N 多条展开收起箭头 -->
<span
v-if="pinnedMessages.length === 1 && canManage"
v-loading="removingId === latest.id"
class="im-conversation-group-pinned__remove"
@click.stop="handleRemove(latest)"
>
移除
</span>
<template v-else-if="pinnedMessages.length > 1">
<span class="im-conversation-group-pinned__count"> {{ pinnedMessages.length }} </span>
<Icon
:icon="expanded ? 'ant-design:up-outlined' : 'ant-design:down-outlined'"
:size="11"
class="im-conversation-group-pinned__chevron"
/>
</template>
</div>
<!-- 多条展开浅色面板包裹完整列表每条独立胶囊点击跳转到对应消息位置 -->
<div v-if="pinnedMessages.length > 1 && expanded" class="im-conversation-group-pinned__list">
<div
v-for="msg in pinnedMessages"
:key="msg.id"
class="im-conversation-group-pinned__row im-conversation-group-pinned__row--list im-conversation-group-pinned__row--clickable"
@click="handleLocate(msg)"
>
<Icon icon="ant-design:pushpin-outlined" :size="14" class="im-conversation-group-pinned__icon" />
<span class="im-conversation-group-pinned__sender">{{ getSenderName(msg) }}</span>
<span class="im-conversation-group-pinned__text">{{ getPreview(msg) }}</span>
<span
v-if="canManage"
v-loading="removingId === msg.id"
class="im-conversation-group-pinned__remove"
@click.stop="handleRemove(msg)"
>
移除
</span>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, ref } from 'vue'
import Icon from '@/components/Icon/src/Icon.vue'
import { useMessage } from '@/hooks/web/useMessage'
import { ImConversationType, ImGroupMemberRole } from '@/views/im/utils/constants'
import { unpinGroupMessage as apiUnpinGroupMessage } from '@/api/im/group'
import { getSenderDisplayName } from '@/views/im/utils/user'
import { resolveConversationLastContent } from '@/views/im/utils/conversation'
import { useUserStore } from '@/store/modules/user'
import { useGroupStore } from '../../../../store/groupStore'
import type { Message } from '../../../../types'
defineOptions({ name: 'ImConversationGroupPinned' })
const props = defineProps<{
/** 当前群编号(自行从 groupStore 拿完整 Group跟随响应式 */
groupId: number
}>()
const emit = defineEmits<{
/** 点击置顶消息 → 父级 MessagePanel 滚动定位到原消息位置 */
locate: [messageId: number]
}>()
const userStore = useUserStore()
const groupStore = useGroupStore()
const message = useMessage()
/** 当前群(含 pinnedMessages */
const group = computed(() => groupStore.getGroup(props.groupId))
const expanded = ref(false)
const removingId = ref<number | null>(null)
/** 当前群置顶消息列表(直接走 group.value跟随响应式 */
const pinnedMessages = computed<Message[]>(() => group.value?.pinnedMessages ?? [])
/** 顶部胶囊展示的最新一条即列表最后一条pin 顺序追加) */
const latest = computed(() => pinnedMessages.value[pinnedMessages.value.length - 1])
/** 当前用户是否群主 / 管理员(决定是否显示「移除」入口) */
const canManage = computed(() => {
const myId = Number(userStore.getUser?.id) || 0
const role = group.value?.members?.find((m) => m.userId === myId)?.role
return role === ImGroupMemberRole.OWNER || role === ImGroupMemberRole.ADMIN
})
/** 顶部胶囊点击:单条直接跳转原消息位置;多条切换展开 / 折叠 */
function handleTopClick() {
if (pinnedMessages.value.length === 1) {
handleLocate(latest.value)
return
}
expanded.value = !expanded.value
}
/** 点击置顶消息行 → 触发跳转 + 收起弹出层 */
function handleLocate(msg: Message) {
emit('locate', msg.id)
expanded.value = false
}
/** 置顶消息发送人显示名 */
function getSenderName(msg: Message): string {
return group.value ? getSenderDisplayName(msg.senderId, ImConversationType.GROUP, group.value.id) : ''
}
/** 置顶消息预览文本:复用会话最后一条摘要逻辑([图片] / [文件] / 文本等) */
function getPreview(msg: Message): string {
return group.value ? resolveConversationLastContent(msg, ImConversationType.GROUP, group.value.id) : ''
}
/** 移除置顶:调后端 APIloading 期间禁止重复点;后端广播 GROUP_MESSAGE_UNPIN 由 dispatcher 自动同步本地 */
async function handleRemove(msg: Message) {
if (!group.value || removingId.value !== null) {
return
}
removingId.value = msg.id
try {
await apiUnpinGroupMessage({ groupId: group.value.id, messageId: msg.id })
message.success('已取消置顶')
} finally {
removingId.value = null
}
}
</script>
<style scoped>
/* 容器:左对齐悬浮在 header 下方不占整行relative 让展开列表绝对定位贴顶部胶囊下方 */
.im-conversation-group-pinned {
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: flex-start;
padding: 6px 16px 8px;
background-color: var(--el-fill-color-light);
}
/* + header
margin-top: -1px 让弹出层往上盖住 header bottom 1px 分隔线避免视觉上有横向空隙 */
.im-conversation-group-pinned__list {
position: absolute;
top: 100%;
margin-top: -1px;
left: 6px;
z-index: 10;
display: flex;
flex-direction: column;
gap: 10px;
width: 380px;
padding: 12px;
border-radius: 12px;
background-color: var(--el-bg-color);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12);
}
/* 弹出层箭头:朝上的三角,颜色跟弹出层 background 一致(白色),跟 header 浅灰强对比 */
.im-conversation-group-pinned__list::before {
content: '';
position: absolute;
top: -8px;
left: 184px;
width: 0;
height: 0;
border-left: 8px solid transparent;
border-right: 8px solid transparent;
border-bottom: 8px solid var(--el-bg-color);
filter: drop-shadow(0 -2px 1px rgba(0, 0, 0, 0.04));
}
/* 胶囊基础样式:顶部固定 width弹出层里的胶囊撑满外框宽度 */
.im-conversation-group-pinned__row {
display: flex;
align-items: center;
gap: 6px;
width: 360px;
padding: 6px 12px;
background-color: var(--el-bg-color);
border-radius: 10px;
font-size: 13px;
color: var(--el-text-color-primary);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
}
.im-conversation-group-pinned__row--clickable {
cursor: pointer;
}
.im-conversation-group-pinned__row--clickable:hover {
background-color: var(--el-fill-color-lighter);
}
/* 列表里的胶囊撑满弹出层宽度浅灰背景跟弹出层白色区分hover 反相白色 */
.im-conversation-group-pinned__row--list {
width: 100%;
background-color: var(--el-fill-color-light);
box-shadow: none;
}
.im-conversation-group-pinned__row--list:hover {
background-color: var(--el-bg-color);
}
.im-conversation-group-pinned__icon {
flex-shrink: 0;
color: var(--el-color-warning);
}
.im-conversation-group-pinned__sender {
flex-shrink: 0;
color: var(--el-text-color-secondary);
}
.im-conversation-group-pinned__text {
flex: 1;
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.im-conversation-group-pinned__count {
flex-shrink: 0;
color: var(--el-text-color-secondary);
font-size: 12px;
}
.im-conversation-group-pinned__chevron {
flex-shrink: 0;
color: var(--el-text-color-placeholder);
}
.im-conversation-group-pinned__remove {
flex-shrink: 0;
color: var(--el-color-primary);
cursor: pointer;
font-size: 13px;
}
.im-conversation-group-pinned__remove:hover {
color: var(--el-color-primary-light-3);
}
</style>

View File

@ -228,8 +228,11 @@ import {
ImMessageStatus,
ImGroupReceiptStatus,
ImConversationType,
isGroupNotification
} from '../../../../../utils/constants'
ImGroupMemberRole,
isGroupNotification,
isNormalMessage
} from '@/views/im/utils/constants'
import { pinGroupMessage as apiPinGroupMessage } from '@/api/im/group'
import {
buildQuoteFromMessage,
getQuoteFromMessage,
@ -241,7 +244,7 @@ import {
type FileMessage,
type AudioMessage,
type VideoMessage
} from '../../../../../utils/message'
} from '@/views/im/utils/message'
import { buildRecallTip } from '../../../../../utils/conversation'
import { formatSeconds } from '@/utils/formatTime'
import { formatFileSize } from '@/utils/file'
@ -255,7 +258,7 @@ import {
getSenderDisplayName,
getSenderRealNickname,
resolveGroupNotificationText
} from '../../../../../utils/user'
} from '@/views/im/utils/user'
import { useImUiStore } from '../../../../store/uiStore'
import { useMessageSender } from '../../../../composables/useMessageSender'
import type { Message } from '../../../../types'
@ -264,6 +267,8 @@ import ReplyPreview from './ReplyPreview.vue'
import UserAvatar from '../../../../components/user/UserAvatar.vue'
import type { GroupMemberLite } from '../../../../components/group/GroupMember.vue'
// TODO @AI /Users/yunai/Java/yudao-all-in-im/yudao-ui-admin-vue3/src/views/im/home/pages/conversation/components/conversation/ConversationGroupSide.vue
defineOptions({ name: 'ImMessageItem' })
const props = defineProps<{
@ -283,7 +288,7 @@ const draftStore = useDraftStore()
const uiStore = useImUiStore()
const { recall, sendRaw } = useMessageSender()
// confirm message props.message vue/no-dupe-keys
const { confirm: confirmDialog } = useMessage()
const { confirm: confirmDialog, success: successMessage } = useMessage()
/** 是否已撤回pull / WS 两路都会调 recallMessage 把原消息更新为 type=RECALL渲染只需识别 type */
const isRecall = computed(() => props.message.type === ImMessageType.RECALL)
@ -516,6 +521,7 @@ const isAtMe = computed(() => {
/** 右键菜单 key 常量push 端和分发端从同一处取typo 编译期就能抓 */
const MENU_KEYS = {
REPLY: 'REPLY',
PIN: 'PIN',
RECALL: 'RECALL',
DELETE: 'DELETE'
} as const
@ -552,6 +558,15 @@ async function handleContextMenu(e: MouseEvent) {
icon: 'bxs:quote-alt-left'
})
}
// + + + +
// error toast
if (canPin.value) {
items.push({
key: MENU_KEYS.PIN,
name: '置顶',
icon: 'ant-design:pushpin-outlined'
})
}
// /
// - + id0+ + RECALL
// - / /
@ -583,6 +598,8 @@ async function handleContextMenu(e: MouseEvent) {
uiStore.openContextMenu({ x: e.clientX, y: e.clientY }, items, async (item) => {
if (item.key === MENU_KEYS.REPLY) {
handleReply()
} else if (item.key === MENU_KEYS.PIN) {
await handlePin()
} else if (item.key === MENU_KEYS.RECALL) {
await handleRecall()
} else if (item.key === MENU_KEYS.DELETE) {
@ -591,6 +608,45 @@ async function handleContextMenu(e: MouseEvent) {
})
}
/** 当前激活会话对应的群(私聊场景为 undefined */
const currentGroup = computed(() => {
const conversation = conversationStore.activeConversation
if (!conversation || conversation.type !== ImConversationType.GROUP) {
return undefined
}
return groupStore.getGroup(conversation.targetId)
})
/** 当前用户在该群里的角色;私聊或非群成员 → undefined */
const myGroupRole = computed(() => {
const myId = Number(userStore.getUser?.id) || 0
return currentGroup.value?.members?.find((m) => m.userId === myId)?.role
})
/** 是否允许置顶(已置顶消息不再展示菜单项,由置顶面板的「移除」入口承接):群聊 + 普通消息 + 已落库 + 未撤回 + 群主或管理员 + 未置顶 */
const canPin = computed(
() =>
!!currentGroup.value &&
isNormalMessage(props.message.type) &&
!!props.message.id &&
!isRecall.value &&
(myGroupRole.value === ImGroupMemberRole.OWNER || myGroupRole.value === ImGroupMemberRole.ADMIN) &&
!currentGroup.value.pinnedMessages?.some((m) => m.id === props.message.id)
)
/** 置顶消息:二次确认 → 调后端 pin-message后端广播 GROUP_MESSAGE_PIN本端 dispatcher 拉最新 pinnedMessages */
async function handlePin() {
const group = currentGroup.value
if (!group) {
return
}
try {
await confirmDialog('将在当前群成员的聊天中置顶', '置顶消息', { confirmButtonText: '置顶' })
await apiPinGroupMessage({ groupId: group.id, messageId: props.message.id })
successMessage('已置顶')
} catch {}
}
/** 进入引用模式:把当前消息构造成 QuoteMessage 写入 draftStoreMessageInput 顶部引用条响应式出现 */
function handleReply() {
const conversation = conversationStore.activeConversation

View File

@ -1,61 +1,68 @@
<template>
<div class="flex flex-1 flex-col min-w-0 bg-[var(--el-fill-color-light)]">
<template v-if="conversationStore.activeConversation">
<!-- 顶部会话名群聊带人数+ 右侧功能图标border scoped CSS项目 UnoCSS 不带 border-style preflight -->
<div
class="message-panel__header flex items-center justify-between h-14 px-5 bg-[var(--el-fill-color-light)]"
>
<span class="flex flex-col min-w-0">
<span class="flex items-baseline gap-1.5 min-w-0">
<span
class="overflow-hidden text-base font-medium truncate text-[var(--el-text-color-primary)]"
>
{{ conversationStore.activeConversation?.name || '' }}
<!-- 顶部 header第一行群名 + 右侧图标第二行嵌入置顶气泡仅群聊 + 有置顶border scoped CSS -->
<div class="message-panel__header flex flex-col bg-[var(--el-fill-color-light)]">
<div class="flex items-center justify-between h-14 px-5">
<span class="flex flex-col min-w-0">
<span class="flex items-baseline gap-1.5 min-w-0">
<span
class="overflow-hidden text-base font-medium truncate text-[var(--el-text-color-primary)]"
>
{{ conversationStore.activeConversation?.name || '' }}
</span>
<span
v-if="isGroup && headerMemberCount > 0"
class="flex-shrink-0 text-sm text-[var(--el-text-color-secondary)]"
>
({{ headerMemberCount }})
</span>
</span>
<!-- 副标题备注 群名时展示原群名提示用户当前看到的主名是自己设的备注 -->
<span
v-if="isGroup && headerMemberCount > 0"
class="flex-shrink-0 text-sm text-[var(--el-text-color-secondary)]"
v-if="headerSubtitle"
class="overflow-hidden text-xs truncate text-[var(--el-text-color-secondary)]"
>
({{ headerMemberCount }})
{{ headerSubtitle }}
</span>
</span>
<!-- 副标题备注 群名时展示原群名提示用户当前看到的主名是自己设的备注 -->
<span
v-if="headerSubtitle"
class="overflow-hidden text-xs truncate text-[var(--el-text-color-secondary)]"
>
{{ headerSubtitle }}
</span>
</span>
<div class="flex gap-3 items-center">
<!-- 聊天历史 -->
<el-tooltip content="聊天历史" placement="bottom">
<Icon
icon="ep:chat-dot-round"
:size="20"
class="message-panel__header-icon cursor-pointer"
@click="historyVisible = true"
/>
</el-tooltip>
<!-- 通话入口暂未开放先放占位图标对齐微信 PC -->
<el-tooltip content="通话" placement="bottom">
<Icon
icon="ant-design:phone-outlined"
:size="20"
class="message-panel__header-icon cursor-pointer"
@click="handleCall"
/>
</el-tooltip>
<!-- 信息抽屉入口 -->
<el-tooltip :content="isGroup ? '群聊信息' : '聊天信息'" placement="bottom">
<Icon
icon="ant-design:ellipsis-outlined"
:size="20"
class="message-panel__header-icon cursor-pointer"
@click="toggleSide"
/>
</el-tooltip>
<div class="flex gap-3 items-center">
<!-- 聊天历史 -->
<el-tooltip content="聊天历史" placement="bottom">
<Icon
icon="ep:chat-dot-round"
:size="20"
class="message-panel__header-icon cursor-pointer"
@click="historyVisible = true"
/>
</el-tooltip>
<!-- 通话入口暂未开放先放占位图标对齐微信 PC -->
<el-tooltip content="通话" placement="bottom">
<Icon
icon="ant-design:phone-outlined"
:size="20"
class="message-panel__header-icon cursor-pointer"
@click="handleCall"
/>
</el-tooltip>
<!-- 信息抽屉入口 -->
<el-tooltip :content="isGroup ? '群聊信息' : '聊天信息'" placement="bottom">
<Icon
icon="ant-design:ellipsis-outlined"
:size="20"
class="message-panel__header-icon cursor-pointer"
@click="toggleSide"
/>
</el-tooltip>
</div>
</div>
<!-- 群置顶消息第二行嵌入 header仅群聊 + 有置顶时显示 -->
<ConversationGroupPinned
v-if="isGroup && conversationStore.activeConversation"
:group-id="conversationStore.activeConversation.targetId"
@locate="handleLocate"
/>
</div>
<!-- 中间消息列表 -->
@ -147,6 +154,7 @@ import MessageItem from './MessageItem.vue'
import MessageInput from '../input/MessageInput.vue'
import MessageHistory from './MessageHistory.vue'
import ConversationGroupSide from '../conversation/ConversationGroupSide.vue'
import ConversationGroupPinned from './ConversationGroupPinned.vue'
import ConversationPrivateSide from '../conversation/ConversationPrivateSide.vue'
import type { FriendLite, GroupLite } from '../../../../types'
import type { GroupMemberLite } from '../../../../components/group/GroupMember.vue'
@ -478,6 +486,7 @@ watch(
<style scoped>
/* 顶部分隔线UnoCSS 不带 border-style preflightclass 写法只设色 / 宽不出线,走 scoped 显式 shorthand */
.message-panel__header {
flex-shrink: 0;
border-bottom: 1px solid var(--el-border-color-light);
}

View File

@ -22,7 +22,7 @@ import {
StorageKeys
} from '../../utils/storage'
import { getGroupDisplayName, type GroupNotificationPayload } from '../../utils/user'
import type { Group, GroupMember } from '../types'
import type { Group, GroupMember, Message } from '../types'
/**
* fetchGroupMembers groupId Promise
@ -543,6 +543,12 @@ export const useGroupStore = defineStore('imGroupStore', {
case ImMessageType.GROUP_OWNER_TRANSFER:
this.applyGroupOwnerTransferNotification(groupId, payload)
break
case ImMessageType.GROUP_MESSAGE_PIN:
this.applyGroupMessagePinNotification(groupId, payload)
break
case ImMessageType.GROUP_MESSAGE_UNPIN:
this.applyGroupMessageUnpinNotification(groupId, payload)
break
}
},
@ -629,6 +635,58 @@ export const useGroupStore = defineStore('imGroupStore', {
}
},
/** 群消息置顶:从 payload 直接拿完整消息对象 push 到 pinnedMessages */
applyGroupMessagePinNotification(groupId: number, payload: GroupNotificationPayload) {
if (!payload.message) {
return
}
const group = this.getGroup(groupId)
if (!group) {
return
}
// TODO @AI可以直接使用 payload简化这样设置。。。
const message: Message = {
id: payload.message.id,
clientMessageId: payload.message.clientMessageId || '',
type: payload.message.type,
content: payload.message.content,
status: payload.message.status,
sendTime: new Date(payload.message.sendTime).getTime(),
senderId: payload.message.senderId,
targetId: payload.message.groupId,
selfSend: payload.message.senderId === getCurrentUserId(),
atUserIds: payload.message.atUserIds || [],
receiverUserIds: payload.message.receiverUserIds || [],
receiptStatus: payload.message.receiptStatus,
readCount: payload.message.readCount
}
// 幂等:已存在同 messageId 不重复 push
const existing = group.pinnedMessages || []
if (existing.some((m) => m.id === message.id)) {
return
}
group.pinnedMessages = [...existing, message]
this.saveGroups()
},
/** 群消息取消置顶:按 messageId 从本地置顶列表中移除 */
applyGroupMessageUnpinNotification(groupId: number, payload: GroupNotificationPayload) {
if (!payload.messageId) {
return
}
const group = this.getGroup(groupId)
if (!group?.pinnedMessages?.length) {
return
}
// TODO @AI不要用 next 这样的单词,大家不好理解。可以用 newXXXX 这样。其它地方也看看。
const next = group.pinnedMessages.filter((m) => m.id !== payload.messageId)
if (next.length === group.pinnedMessages.length) {
return
}
group.pinnedMessages = next
this.saveGroups()
},
/** 切账号时仅清 in-memoryIDB 按 userId 分桶天然隔离,回切秒开 */
clear() {
this.groups = []
@ -646,7 +704,28 @@ function convertGroup(vo: ImGroupRespVO): Group {
name: vo.name,
avatar: vo.avatar,
notice: vo.notice,
ownerUserId: vo.ownerUserId
ownerUserId: vo.ownerUserId,
pinnedMessages: vo.pinnedMessages?.map(convertGroupMessageVO)
}
}
/** 后端 ImGroupMessageRespVO -> 前端 Message补 targetId / selfSend / sendTime 等派生字段 */
function convertGroupMessageVO(message: NonNullable<ImGroupRespVO['pinnedMessages']>[number]): Message {
const currentUserId = getCurrentUserId()
return {
id: message.id,
clientMessageId: message.clientMessageId || '',
type: message.type,
content: message.content,
status: message.status,
sendTime: new Date(message.sendTime).getTime(),
senderId: message.senderId,
targetId: message.groupId,
selfSend: !!currentUserId && message.senderId === currentUserId,
atUserIds: message.atUserIds || [],
receiverUserIds: message.receiverUserIds || [],
receiptStatus: message.receiptStatus,
readCount: message.readCount
}
}

View File

@ -113,6 +113,7 @@ export interface Group {
avatar?: string // 群头像
notice?: string // 群公告
ownerUserId?: number // 群主用户编号
pinnedMessages?: Message[] // 群置顶消息列表
// ========== 前端扩展字段user-per-group 维度) ==========
muted?: boolean // 是否免打扰。从当前用户的 GroupMember 回填

View File

@ -34,12 +34,18 @@ export const ImMessageType = {
GROUP_ADMIN_REMOVE: 1518, // 撤销管理员
GROUP_NOTICE_UPDATE: 1519, // 群公告变更
GROUP_NAME_UPDATE: 1520, // 群名变更
GROUP_MEMBER_SETTING_UPDATE: 1530 // 群成员个人设置变更muted / groupRemark 个人多端同步
GROUP_MEMBER_SETTING_UPDATE: 1530, // 群成员个人设置变更muted / groupRemark 个人多端同步
GROUP_MESSAGE_PIN: 1531, // 群消息置顶
GROUP_MESSAGE_UNPIN: 1532 // 群消息取消置顶
} as const
/** 判断是否「群广播事件」:[GROUP_CREATE, GROUP_MEMBER_SETTING_UPDATE) 段位都算GROUP_MEMBER_SETTING_UPDATE 是个人信号不算 */
/** 判断是否「群广播事件」:[GROUP_CREATE, GROUP_MESSAGE_UNPIN] 段位都算,仅 GROUP_MEMBER_SETTING_UPDATE 是个人信号排除 */
export function isGroupNotification(type: number): boolean {
return type >= ImMessageType.GROUP_CREATE && type < ImMessageType.GROUP_MEMBER_SETTING_UPDATE
return (
type >= ImMessageType.GROUP_CREATE
&& type <= ImMessageType.GROUP_MESSAGE_UNPIN
&& type !== ImMessageType.GROUP_MEMBER_SETTING_UPDATE
)
}
/** IM 普通消息类型集合(聊天气泡中显示,并作为会话最后一条摘要) */
@ -98,6 +104,9 @@ export const ImGroupMemberRole = {
/** 群管理员人数上限(对齐后端 GROUP_ADMIN_MAX_COUNT */
export const GROUP_ADMIN_MAX_COUNT = 3
/** 群置顶消息条数上限(对齐后端 GROUP_PIN_MAX_COUNT */
export const GROUP_PIN_MAX_COUNT = 5
/** 每次拉取私聊消息的最大条数(后端上限 1000前端取保守值 100 */
export const PRIVATE_MESSAGE_PULL_SIZE = 100

View File

@ -166,6 +166,22 @@ export type GroupNotificationPayload = {
oldAvatar?: string
newAvatar?: string
displayUserName?: string
messageId?: number
/** PIN 事件携带的完整被置顶消息对象(前端直接 push 进 group.pinnedMessages避免回查群详情 */
message?: {
id: number
clientMessageId?: string
senderId: number
groupId: number
type: number
content: string
status: number
sendTime: string
atUserIds?: number[]
receiverUserIds?: number[]
receiptStatus?: number
readCount?: number
}
}
export function resolveGroupNotificationText(
@ -216,6 +232,10 @@ export function resolveGroupNotificationText(
return `${operatorName} 撤销了 ${memberNames} 的管理员身份`
case ImMessageType.GROUP_OWNER_TRANSFER:
return `${operatorName} 已将群主转让给 ${newOwnerName}`
case ImMessageType.GROUP_MESSAGE_PIN:
return `${operatorName} 置顶了一条消息`
case ImMessageType.GROUP_MESSAGE_UNPIN:
return `${operatorName} 取消了一条置顶消息`
default:
return ''
}