diff --git a/src/api/im/group/index.ts b/src/api/im/group/index.ts index 6aefb7fbd..5d76639d8 100644 --- a/src/api/im/group/index.ts +++ b/src/api/im/group/index.ts @@ -16,6 +16,7 @@ export interface ImGroupRespVO { dissolvedTime?: string // 解散时间 createTime?: string // 创建时间 pinnedMessages?: ImGroupMessageRespVO[] // 群置顶消息列表(后端关联回填,仅当登录用户是群成员时非空) + joinStatus?: number // 当前登录用户在该群的成员状态(参见 CommonStatusEnum:0 在群 / 1 已退群);历史退群群仍返回,供展示离线消息的群名 / 头像 } // 群消息置顶 / 取消置顶 Request VO diff --git a/src/views/im/home/components/group/GroupInfo.vue b/src/views/im/home/components/group/GroupInfo.vue index bb3a17508..40bd40993 100644 --- a/src/views/im/home/components/group/GroupInfo.vue +++ b/src/views/im/home/components/group/GroupInfo.vue @@ -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(() => { diff --git a/src/views/im/home/components/user/RecommendCardDialog.vue b/src/views/im/home/components/user/RecommendCardDialog.vue index 5a35af056..abdc04fea 100644 --- a/src/views/im/home/components/user/RecommendCardDialog.vue +++ b/src/views/im/home/components/user/RecommendCardDialog.vue @@ -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( - () => conversationStore.getSortedConversationList +/** 候选会话:从 store 拿排序后的列表(hide 由 Panel 接 hideKeys 过滤);历史退群群不可被推荐选中(选了后端也会拒) */ +const candidateConversations = computed(() => + conversationStore.getSortedConversationList.filter( + (conversation) => + !( + conversation.type === ImConversationType.GROUP && + isGroupQuit(groupStore.getGroup(conversation.targetId)) + ) + ) ) /** 隐藏 key:不能把名片推回名片本身的会话(用户名片避免自推、群名片避免推回该群) */ diff --git a/src/views/im/home/composables/useMuteOverlay.ts b/src/views/im/home/composables/useMuteOverlay.ts index 20e38f424..3a3d89a26 100644 --- a/src/views/im/home/composables/useMuteOverlay.ts +++ b/src/views/im/home/composables/useMuteOverlay.ts @@ -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 { if (!group) { return null } + // 历史退群群:已退群只能查看历史,禁止发送(文本 / 图片 / 文件 / 语音 / 重试共用这一层拦截) + if (isGroupQuit(group)) { + return { text: '你已退出群聊,仅可查看历史消息', icon: 'ant-design:logout-outlined' } + } const myId = getCurrentUserId() // 群封禁:管理后台操作,所有人不可发送 if (group.banned) { diff --git a/src/views/im/home/pages/contact/index.vue b/src/views/im/home/pages/contact/index.vue index 80373c2ea..afc9f36bf 100644 --- a/src/views/im/home/pages/contact/index.vue +++ b/src/views/im/home/pages/contact/index.vue @@ -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(() => friendStore.friendRequest const friends = computed(() => friendStore.getActiveFriendLiteList) const groups = computed(() => - 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 + })) ) /** diff --git a/src/views/im/home/pages/conversation/components/conversation/ConversationGroupSide.vue b/src/views/im/home/pages/conversation/components/conversation/ConversationGroupSide.vue index 5b9fc6d5a..9c21e7d6e 100644 --- a/src/views/im/home/pages/conversation/components/conversation/ConversationGroupSide.vue +++ b/src/views/im/home/pages/conversation/components/conversation/ConversationGroupSide.vue @@ -32,8 +32,9 @@ :group-name="group.name" /> - +
未设置
- + - + - -
+ +
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 稳定) diff --git a/src/views/im/home/pages/conversation/components/message/GroupPinnedMessage.vue b/src/views/im/home/pages/conversation/components/message/GroupPinnedMessage.vue index 030905b7c..bbc379cab 100644 --- a/src/views/im/home/pages/conversation/components/message/GroupPinnedMessage.vue +++ b/src/views/im/home/pages/conversation/components/message/GroupPinnedMessage.vue @@ -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 diff --git a/src/views/im/home/pages/conversation/components/message/MessageItem.vue b/src/views/im/home/pages/conversation/components/message/MessageItem.vue index 833fea500..2e7e429a0 100644 --- a/src/views/im/home/pages/conversation/components/message/MessageItem.vue +++ b/src/views/im/home/pages/conversation/components/message/MessageItem.vue @@ -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 && diff --git a/src/views/im/home/pages/conversation/components/message/MessagePanel.vue b/src/views/im/home/pages/conversation/components/message/MessagePanel.vue index 5f74c57fb..32db1f631 100644 --- a/src/views/im/home/pages/conversation/components/message/MessagePanel.vue +++ b/src/views/im/home/pages/conversation/components/message/MessagePanel.vue @@ -71,7 +71,7 @@
- + @@ -109,7 +109,7 @@ /> @@ -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 diff --git a/src/views/im/home/pages/conversation/components/message/forward/MessageForwardDialog.vue b/src/views/im/home/pages/conversation/components/message/forward/MessageForwardDialog.vue index 8fcf8afd1..67c193070 100644 --- a/src/views/im/home/pages/conversation/components/message/forward/MessageForwardDialog.vue +++ b/src/views/im/home/pages/conversation/components/message/forward/MessageForwardDialog.vue @@ -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(() => conversationStore.getSortedConversationList.filter( - (conversation) => conversation.type !== ImConversationType.CHANNEL + (conversation) => + conversation.type !== ImConversationType.CHANNEL && + // 历史退群群不可被转发选中(选了后端也会拒) + !( + conversation.type === ImConversationType.GROUP && + isGroupQuit(groupStore.getGroup(conversation.targetId)) + ) ) ) diff --git a/src/views/im/home/store/groupStore.ts b/src/views/im/home/store/groupStore.ts index 82dfa780c..6795ec033 100644 --- a/src/views/im/home/store/groupStore.ts +++ b/src/views/im/home/store/groupStore.ts @@ -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 } } diff --git a/src/views/im/home/types/index.ts b/src/views/im/home/types/index.ts index 2c3f8d456..ebb75007a 100644 --- a/src/views/im/home/types/index.ts +++ b/src/views/im/home/types/index.ts @@ -141,6 +141,7 @@ export interface Group { mutedAll?: boolean // 是否全群禁言 banned?: boolean // 是否被管理员封禁 joinApproval?: boolean // 进群是否需群主 / 管理员审批 + joinStatus?: number // 当前登录用户在该群的成员状态(参见 CommonStatusEnum:0 在群 / 1 已退群);历史退群群仍返回,供展示历史消息的群名 / 头像 // ========== 前端扩展字段(user-per-group 维度) ========== silent?: boolean // 是否免打扰。从当前用户的 GroupMember 回填 diff --git a/src/views/im/utils/user.ts b/src/views/im/utils/user.ts index 06b71fae1..3f1a5496e 100644 --- a/src/views/im/utils/user.ts +++ b/src/views/im/utils/user.ts @@ -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 +} + /** * 私聊好友显示名:备注 > 真实昵称 *