feat: 完善 IM 群历史消息拉取与历史群前端门控

- 后端群列表返回历史群成员状态 joinStatus,用于区分当前群和历史退群群
- 群消息拉取支持基于 receiver_user_ids 快照过滤可见消息
- 补充群消息 pull、群成员候选、私聊 pull 相关索引与 SQL 脚本
- 前端接入 joinStatus,并封装历史退群群判断
- 历史退群群禁发、隐藏群操作入口,并从通讯录、转发、推荐名片候选中排除
- 保留历史群会话展示能力,用于查看退群前历史消息
pull/884/MERGE
YunaiV 2026-06-14 02:01:09 +08:00
parent 89a49cf19c
commit 8c796950f9
13 changed files with 108 additions and 33 deletions

View File

@ -16,6 +16,7 @@ export interface ImGroupRespVO {
dissolvedTime?: string // 解散时间
createTime?: string // 创建时间
pinnedMessages?: ImGroupMessageRespVO[] // 群置顶消息列表(后端关联回填,仅当登录用户是群成员时非空)
joinStatus?: number // 当前登录用户在该群的成员状态(参见 CommonStatusEnum0 在群 / 1 已退群);历史退群群仍返回,供展示离线消息的群名 / 头像
}
// 群消息置顶 / 取消置顶 Request VO

View File

@ -53,7 +53,7 @@ import { getCurrentUserId } from '@/utils/auth'
import { CommonStatusEnum } from '@/utils/constants'
import { useFriendStore } from '../../store/friendStore'
import { useGroupStore } from '../../store/groupStore'
import { getMemberDisplayName } from '../../../utils/user'
import { getMemberDisplayName, isGroupQuit } from '../../../utils/user'
import type { Friend, GroupLite, GroupMember } from '../../types'
import type { GroupMemberLite } from './GroupMember.vue'
@ -89,6 +89,10 @@ const isMember = computed(() => {
if (!cached) {
return false
}
// 退 false
if (isGroupQuit(cached)) {
return false
}
if (cached.membersLoaded && cached.members) {
const myId = getCurrentUserId()
return cached.members.some(
@ -97,8 +101,13 @@ const isMember = computed(() => {
}
return true
})
/** 是否未加群:有 id 但 isMember 不成立;对比 isMember 用于动作区按钮分支 */
const isStranger = computed(() => !!props.group?.id && !isMember.value)
/** 历史退群群:只读,动作区两个按钮都不渲染(既不「进入群聊」也不「加入群聊」) */
const isQuitGroup = computed(() => {
const id = props.group?.id
return id != null && isGroupQuit(groupStore.getGroup(id))
})
/** 是否未加群:有 id、非成员、且非历史退群群只有真·陌生人才给「加入群聊」 */
const isStranger = computed(() => !!props.group?.id && !isMember.value && !isQuitGroup.value)
/** 成员数文案member 优先用本地拉到的列表长度stranger 用 props.group.memberCount 卡片快照 */
const memberCountText = computed(() => {

View File

@ -119,6 +119,7 @@ import { ImConversationType, ImMessageType, isGroupConversation } from '../../..
import { getConversationKey } from '../../../utils/conversation'
import { buildDefaultGroupName } from '../../../utils/group'
import { serializeMessage, type CardTarget } from '../../../utils/message'
import { isGroupQuit } from '../../../utils/user'
import type { Conversation, FriendLite } from '../../types'
defineOptions({ name: 'ImRecommendCardDialog' })
@ -162,9 +163,15 @@ const headerTitle = computed(() => {
return isGroupConversation(target.value?.targetType) ? '把这个群推荐给朋友' : '把他推荐给朋友'
})
/** 候选会话:从 store 拿排序后的列表hide 由 Panel 接 hideKeys 过滤) */
const candidateConversations = computed<Conversation[]>(
() => conversationStore.getSortedConversationList
/** 候选会话:从 store 拿排序后的列表hide 由 Panel 接 hideKeys 过滤);历史退群群不可被推荐选中(选了后端也会拒) */
const candidateConversations = computed<Conversation[]>(() =>
conversationStore.getSortedConversationList.filter(
(conversation) =>
!(
conversation.type === ImConversationType.GROUP &&
isGroupQuit(groupStore.getGroup(conversation.targetId))
)
)
)
/** 隐藏 key不能把名片推回名片本身的会话用户名片避免自推、群名片避免推回该群 */

View File

@ -4,6 +4,7 @@ import { getCurrentUserId } from '@/utils/auth'
import { useConversationStore } from '../store/conversationStore'
import { useGroupStore } from '../store/groupStore'
import { ImConversationType, ImGroupMemberRole } from '../../utils/constants'
import { isGroupQuit } from '../../utils/user'
export type MuteOverlayInfo = { text: string; icon: string }
@ -61,6 +62,10 @@ export function useMuteOverlay(): ComputedRef<MuteOverlayInfo | null> {
if (!group) {
return null
}
// 历史退群群:已退群只能查看历史,禁止发送(文本 / 图片 / 文件 / 语音 / 重试共用这一层拦截)
if (isGroupQuit(group)) {
return { text: '你已退出群聊,仅可查看历史消息', icon: 'ant-design:logout-outlined' }
}
const myId = getCurrentUserId()
// 群封禁:管理后台操作,所有人不可发送
if (group.banned) {

View File

@ -101,7 +101,7 @@ import GroupDetail from './GroupDetail.vue'
import { useConversationStore } from '../../store/conversationStore'
import { useFriendStore } from '../../store/friendStore'
import { useGroupStore } from '../../store/groupStore'
import { getFriendDisplayName, getGroupDisplayName } from '../../../utils/user'
import { getFriendDisplayName, getGroupDisplayName, isGroupQuit } from '../../../utils/user'
import type { FriendLite, FriendRequest, Group, GroupLite, User } from '../../types'
import { ImConversationType } from '../../../utils/constants'
import { StorageKeys } from '../../../utils/db'
@ -139,14 +139,17 @@ const friendRequests = computed<FriendRequest[]>(() => friendStore.friendRequest
const friends = computed<FriendLite[]>(() => friendStore.getActiveFriendLiteList)
const groups = computed<GroupLite[]>(() =>
groupStore.groups.map((group: Group) => ({
id: group.id,
name: group.name,
showGroupName: getGroupDisplayName(group), // groupRemark""
showImage: group.avatar,
showImageThumb: group.avatar,
memberCount: group.memberCount
}))
// 退 store /
groupStore.groups
.filter((group: Group) => !isGroupQuit(group))
.map((group: Group) => ({
id: group.id,
name: group.name,
showGroupName: getGroupDisplayName(group), // groupRemark""
showImage: group.avatar,
showImageThumb: group.avatar,
memberCount: group.memberCount
}))
)
/**

View File

@ -32,8 +32,9 @@
:group-name="group.name"
/>
<!-- 添加任何成员都能邀请 -->
<!-- 添加任何成员都能邀请历史退群群隐藏 -->
<div
v-if="!isQuitGroup"
class="im-conversation-group-side__tile-wrap flex flex-col items-center w-[66px] cursor-pointer"
title="邀请好友入群"
@click="handleOpenInvite"
@ -164,8 +165,9 @@
<span v-else class="text-13px text-[var(--el-text-color-placeholder)] leading-[1.6]">未设置</span>
</div>
<!-- 备注仅自己可见保存后会替换会话列表 / 顶部群名展示 -->
<!-- 备注仅自己可见保存后会替换会话列表 / 顶部群名展示历史退群群隐藏改备注走 updateGroupMember已退群会被后端拒 -->
<el-popover
v-if="!isQuitGroup"
v-model:visible="groupRemarkPopoverVisible"
trigger="click"
placement="left-start"
@ -203,8 +205,9 @@
</div>
</el-popover>
<!-- 我在本群的昵称任何成员都能改自己的 -->
<!-- 我在本群的昵称任何成员都能改自己的历史退群群隐藏 updateGroupMember已退群会被后端拒 -->
<el-popover
v-if="!isQuitGroup"
v-model:visible="remarkPopoverVisible"
trigger="click"
placement="left-start"
@ -344,8 +347,11 @@
</template>
</div>
<!-- ==================== 底部退出 / 解散群聊 ==================== -->
<div class="flex-shrink-0 px-4 pt-[14px] pb-[18px] bg-[var(--el-bg-color)] border-t border-t-solid border-[var(--el-border-color-lighter)]">
<!-- ==================== 底部退出 / 解散群聊历史退群群隐藏已退群无需再退 ==================== -->
<div
v-if="!isQuitGroup"
class="flex-shrink-0 px-4 pt-[14px] pb-[18px] bg-[var(--el-bg-color)] border-t border-t-solid border-[var(--el-border-color-lighter)]"
>
<!-- 群主解散群聊 -->
<el-button
v-if="isOwner"
@ -410,6 +416,7 @@ import GroupOwnerTransferDialog from '../../../../components/group/GroupOwnerTra
import GroupRequestListDialog from '../../../../components/group/GroupRequestListDialog.vue'
import RecommendCardDialog from '../../../../components/user/RecommendCardDialog.vue'
import { toGroupCardTarget } from '@/views/im/utils/message'
import { isGroupQuit } from '@/views/im/utils/user'
import type { Conversation, GroupLite } from '../../../../types'
import type { GroupMemberLite } from '../../../../components/group/GroupMember.vue'
@ -461,12 +468,21 @@ function handleOpenInvite() {
}
const myId = computed(() => getCurrentUserId())
const isOwner = computed(() => props.group != null && props.group.ownerId === myId.value)
/** 历史退群群:禁所有群操作入口(邀请 / 移出 / 改资料 / 禁言 / 审批 / 退出等只保留展示props.group 是 GroupLite 无 joinStatus回 store 取全量 */
const isQuitGroup = computed(() => {
const id = props.group?.id
return id != null && isGroupQuit(groupStore.getGroup(id))
})
const isOwner = computed(
() => !isQuitGroup.value && props.group != null && props.group.ownerId === myId.value
)
/** 当前用户在群里的角色(来自 props.members 的 me 行);用于判定是否可移出他人 */
const myRole = computed(() => props.members.find((m) => m.userId === myId.value)?.role)
/** 群主或管理员:在抽屉里有 "移出群成员" 入口 */
/** 群主或管理员:在抽屉里有 "移出群成员" 入口;历史退群群一律视为无权限 */
const isOwnerOrAdmin = computed(
() => myRole.value === ImGroupMemberRole.OWNER || myRole.value === ImGroupMemberRole.ADMIN
() =>
!isQuitGroup.value &&
(myRole.value === ImGroupMemberRole.OWNER || myRole.value === ImGroupMemberRole.ADMIN)
)
// 退 + userId

View File

@ -79,7 +79,7 @@ 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 { getSenderDisplayName, isGroupQuit } from '@/views/im/utils/user'
import { resolveConversationLastContent } from '@/views/im/utils/conversation'
import { getCurrentUserId } from '@/utils/auth'
import { useGroupStore } from '../../../../store/groupStore'
@ -123,6 +123,10 @@ const latest = computed(() => pinnedMessages.value[pinnedMessages.value.length -
/** 当前用户是否群主 / 管理员(决定是否显示「移除」入口) */
const canManage = computed(() => {
// 退
if (isGroupQuit(group.value)) {
return false
}
const myId = getCurrentUserId()
const role = group.value?.members?.find((m) => m.userId === myId)?.role
return role === ImGroupMemberRole.OWNER || role === ImGroupMemberRole.ADMIN

View File

@ -241,7 +241,8 @@ import {
getMemberDisplayName,
getMentionCandidates,
getSenderDisplayName,
getSenderRealNickname
getSenderRealNickname,
isGroupQuit
} from '@/views/im/utils/user'
import {
resolveFriendNotificationSegments,
@ -811,7 +812,8 @@ const myGroupRole = computed(() => {
/** 是否可管理该消息发送人:我的角色高于目标角色(群主 > 管理员 > 普通成员);目标角色未知时不展示 */
const canManageSender = computed(() => {
if (!currentGroup.value || !myGroupRole.value) {
// 退 /
if (!currentGroup.value || isGroupQuit(currentGroup.value) || !myGroupRole.value) {
return false
}
const senderMember = currentGroup.value.members?.find((m) => m.userId === props.message.senderId)
@ -856,6 +858,7 @@ function handleMultiSelectClick(e: MouseEvent) {
const canPin = computed(
() =>
!!currentGroup.value &&
!isGroupQuit(currentGroup.value) &&
isNormalMessage(props.message.type) &&
!!props.message.id &&
!isRecall.value &&

View File

@ -71,7 +71,7 @@
</div>
</div>
</el-popover>
<el-tooltip v-else content="通话" placement="bottom">
<el-tooltip v-else-if="!isQuitGroup" content="通话" placement="bottom">
<Icon
icon="ant-design:phone-outlined"
:size="20"
@ -97,7 +97,7 @@
<!-- 群通话胶囊条仅群聊 + 该群有活跃通话时显示点击展开看成员 + 加入按钮 -->
<RtcGroupCallBanner
v-if="isGroup && conversationStore.activeConversation"
v-if="isGroup && !isQuitGroup && conversationStore.activeConversation"
:group-id="conversationStore.activeConversation.targetId"
/>
@ -109,7 +109,7 @@
/>
<!-- 群顶部待处理加群申请横幅仅群聊 + owner / admin + count > 0 时显示 -->
<GroupRequestPending
v-if="isGroup && conversationStore.activeConversation"
v-if="isGroup && !isQuitGroup && conversationStore.activeConversation"
:group-id="conversationStore.activeConversation.targetId"
/>
<!-- 私聊对方不再是有效好友我删了对方 / 从未加过单边设计下被对方删除本端 friendStore 不更新故不会触发胶囊嵌在 header 跟群置顶同级点击弹 UserInfoCard -->
@ -231,7 +231,7 @@ import { useMessage } from '@/hooks/web/useMessage'
import { useConversationStore } from '../../../../store/conversationStore'
import { useFriendStore } from '../../../../store/friendStore'
import { useImUiStore } from '../../../../store/uiStore'
import { getMemberDisplayName } from '@/views/im/utils/user'
import { getMemberDisplayName, isGroupQuit } from '@/views/im/utils/user'
import { useGroupStore } from '../../../../store/groupStore'
import MessageItem from './MessageItem.vue'
import MessageInput from '../input/MessageInput.vue'
@ -321,6 +321,15 @@ const isChannel = computed(
() => conversationStore.activeConversation?.type === ImConversationType.CHANNEL
)
/** 当前激活会话是否历史退群群:禁群通话、隐藏群申请横幅等操作入口;聊天历史、群名头像照常展示 */
const isQuitGroup = computed(() => {
const conversation = conversationStore.activeConversation
return (
conversation?.type === ImConversationType.GROUP &&
isGroupQuit(groupStore.getGroup(conversation.targetId))
)
})
/** 私聊会话且对端不是有效好友(本端 friend 记录缺失或 DISABLE单边删除语义下「被对方删除」不触发本端横幅 */
const showNotFriendBanner = computed(() => {
const conversation = conversationStore.activeConversation

View File

@ -154,6 +154,7 @@ import FacePicker from '../../input/FacePicker.vue'
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 { isGroupQuit } from '@/views/im/utils/user'
import { useMessageSender } from '@/views/im/home/composables/useMessageSender'
import { useMessageMultiSelect } from '@/views/im/home/composables/useMessageMultiSelect'
import {
@ -228,7 +229,13 @@ const confirmButtonText = computed(() =>
/** 候选会话:从 store 拿排序后的列表(转发回原会话也允许,与微信一致);公众号 / 频道单向消息不接受转发,从候选里剔除 */
const candidateConversations = computed<Conversation[]>(() =>
conversationStore.getSortedConversationList.filter(
(conversation) => conversation.type !== ImConversationType.CHANNEL
(conversation) =>
conversation.type !== ImConversationType.CHANNEL &&
// 退
!(
conversation.type === ImConversationType.GROUP &&
isGroupQuit(groupStore.getGroup(conversation.targetId))
)
)
)

View File

@ -804,7 +804,8 @@ function convertGroup(group: ImGroupRespVO): Group {
pinnedMessages: group.pinnedMessages?.map(convertGroupMessageVO),
mutedAll: group.mutedAll,
banned: group.banned,
joinApproval: group.joinApproval
joinApproval: group.joinApproval,
joinStatus: group.joinStatus
}
}

View File

@ -141,6 +141,7 @@ export interface Group {
mutedAll?: boolean // 是否全群禁言
banned?: boolean // 是否被管理员封禁
joinApproval?: boolean // 进群是否需群主 / 管理员审批
joinStatus?: number // 当前登录用户在该群的成员状态(参见 CommonStatusEnum0 在群 / 1 已退群);历史退群群仍返回,供展示历史消息的群名 / 头像
// ========== 前端扩展字段user-per-group 维度) ==========
silent?: boolean // 是否免打扰。从当前用户的 GroupMember 回填

View File

@ -12,7 +12,7 @@
import { countBy } from 'lodash-es'
import { useUserStore } from '@/store/modules/user'
import { SystemUserSexEnum } from '@/utils/constants'
import { CommonStatusEnum, SystemUserSexEnum } from '@/utils/constants'
import {
ImConversationType,
ImFriendAddSource,
@ -31,6 +31,15 @@ import type { Conversation, Friend, Group, User } from '../home/types'
// MessageBubble 的 textSegments 才不会跟着无谓重算
const EMPTY_MENTIONS: MentionCandidate[] = []
/**
* 退joinStatus DISABLE退 /
*
* /im/group/list 退 / /
*/
export function isGroupQuit(group?: Group | null): boolean {
return group?.joinStatus === CommonStatusEnum.DISABLE
}
/**
* >
*