diff --git a/src/api/im/group/index.ts b/src/api/im/group/index.ts index 705a57f09..a363ab9ae 100644 --- a/src/api/im/group/index.ts +++ b/src/api/im/group/index.ts @@ -27,6 +27,18 @@ export interface ImGroupUpdateReqVO { notice?: string // 群公告 } +// 添加 / 撤销群管理员 Request VO +export interface ImGroupAdminReqVO { + groupId: number // 群编号 + userIds: number[] // 目标用户编号列表 +} + +// 群主转让 Request VO +export interface ImGroupTransferOwnerReqVO { + groupId: number // 群编号 + newOwnerUserId: number // 新群主用户编号 +} + // 获得当前登录用户的群列表 export const getMyGroupList = () => { return request.get({ url: '/im/group/list' }) @@ -51,3 +63,18 @@ export const updateGroup = (data: ImGroupUpdateReqVO) => { export const dissolveGroup = (id: number | string) => { return request.delete({ url: '/im/group/dissolve', params: { id } }) } + +// 添加群管理员(仅群主可调) +export const addGroupAdmin = (data: ImGroupAdminReqVO) => { + return request.put({ url: '/im/group/add-admin', data }) +} + +// 撤销群管理员(仅群主可调) +export const removeGroupAdmin = (data: ImGroupAdminReqVO) => { + return request.put({ url: '/im/group/remove-admin', data }) +} + +// 转让群主(仅老群主可调;旧群主转让后降为普通成员) +export const transferGroupOwner = (data: ImGroupTransferOwnerReqVO) => { + return request.put({ url: '/im/group/transfer-owner', data }) +} diff --git a/src/api/im/group/member/index.ts b/src/api/im/group/member/index.ts index e87e4f124..d3c0f247b 100644 --- a/src/api/im/group/member/index.ts +++ b/src/api/im/group/member/index.ts @@ -6,9 +6,10 @@ export interface ImGroupMemberRespVO { groupId: number // 群编号 userId: number // 用户编号 displayUserName?: string // 组内显示名(群主设置的备注) - displayGroupName?: string // 群显示备注(当前用户对群的备注) + groupRemark?: string // 群备注(当前用户对群的备注) muted?: boolean // 是否免打扰 status?: number // 成员状态(0=在群,1=退群) + role?: number // 成员角色,参见 ImGroupMemberRole 枚举 joinTime?: string // 入群时间 quitTime?: string // 退群时间 createTime?: string // 创建时间 @@ -33,7 +34,7 @@ export interface ImGroupMemberRemoveReqVO { export interface ImGroupMemberUpdateReqVO { groupId: number // 群编号 displayUserName?: string // 群内昵称 - displayGroupName?: string // 群名备注 + groupRemark?: string // 群备注 muted?: boolean // 是否免打扰 } diff --git a/src/api/im/manager/group/index.ts b/src/api/im/manager/group/index.ts index e21878e95..e2beb5f38 100644 --- a/src/api/im/manager/group/index.ts +++ b/src/api/im/manager/group/index.ts @@ -21,9 +21,10 @@ export interface ImManagerGroupMemberVO { nickname?: string avatar?: string displayUserName?: string - displayGroupName?: string + groupRemark?: string muted?: boolean status: number + role?: number // 成员角色,参见 ImGroupMemberRole 枚举 joinTime?: Date quitTime?: Date } diff --git a/src/utils/dict.ts b/src/utils/dict.ts index b65788fde..7cb356818 100644 --- a/src/utils/dict.ts +++ b/src/utils/dict.ts @@ -333,5 +333,6 @@ export enum DICT_TYPE { IM_GROUP_MESSAGE_STATUS = 'im_group_message_status', // IM 群聊消息状态:0=正常 / 2=已撤回 IM_GROUP_MESSAGE_RECEIPT_STATUS = 'im_group_message_receipt_status', // IM 群消息回执状态 IM_FRIEND_STATUS = 'im_friend_status', // IM 好友状态 - IM_GROUP_STATUS = 'im_group_status' // IM 群状态 + IM_GROUP_STATUS = 'im_group_status', // IM 群状态 + IM_GROUP_MEMBER_ROLE = 'im_group_member_role' // IM 群成员角色 } diff --git a/src/views/im/home/components/group/GroupMember.vue b/src/views/im/home/components/group/GroupMember.vue index 988147fd4..d917e8696 100644 --- a/src/views/im/home/components/group/GroupMember.vue +++ b/src/views/im/home/components/group/GroupMember.vue @@ -38,6 +38,7 @@ export interface GroupMemberLite { showName: string // 展示昵称:好友备注 > 用户群备注(displayUserName) > 真实昵称(nickname),给"显示给用户看"的位置用(行内文字、@候选标签等) avatar?: string status?: number + role?: number // 成员角色,仅在群信息抽屉等需要展示角色标签的场景透传;@候选 / 已读列表等场景可不传 } const props = withDefaults( diff --git a/src/views/im/home/components/group/GroupMemberItem.vue b/src/views/im/home/components/group/GroupMemberItem.vue index 1245ffdc0..8ed709776 100644 --- a/src/views/im/home/components/group/GroupMemberItem.vue +++ b/src/views/im/home/components/group/GroupMemberItem.vue @@ -23,6 +23,14 @@ > {{ member.showName }} + + + {{ roleLabel }} + @@ -32,6 +40,8 @@ import { computed } from 'vue' import UserAvatar from '../user/UserAvatar.vue' import type { GroupMemberLite } from './GroupMember.vue' +import { ImGroupMemberRole } from '../../../utils/constants' +import { DICT_TYPE, getDictLabel } from '@/utils/dict' defineOptions({ name: 'ImGroupMemberItem' }) @@ -52,4 +62,21 @@ defineEmits<{ }>() const avatarSize = computed(() => Math.ceil(props.height * 0.75)) + +/** 角色标签文案:普通成员不显示,其余取 im_group_member_role 字典 label */ +// TODO DONE @AI:排除成员,剩余通过字典去 get 下,这样逻辑更统一! +const roleLabel = computed(() => { + if (props.member.role == null || props.member.role === ImGroupMemberRole.NORMAL) { + return '' + } + return getDictLabel(DICT_TYPE.IM_GROUP_MEMBER_ROLE, props.member.role) +}) + +/** 角色标签样式:群主用主色;管理员用次要色 */ +const roleLabelClass = computed(() => { + if (props.member.role === ImGroupMemberRole.OWNER) { + return 'text-[var(--el-color-primary)] bg-[var(--el-color-primary-light-9)]' + } + return 'text-[var(--el-color-info)] bg-[var(--el-fill-color)]' +}) diff --git a/src/views/im/home/pages/contact/GroupDetail.vue b/src/views/im/home/pages/contact/GroupDetail.vue index ddc12e9d5..2be106967 100644 --- a/src/views/im/home/pages/contact/GroupDetail.vue +++ b/src/views/im/home/pages/contact/GroupDetail.vue @@ -85,7 +85,8 @@ function convertGroupMemberLite(member: GroupMember, friend: Friend | undefined) showName: getMemberDisplayName(member, friend), nickname: member.nickname, avatar: member.avatar, - status: member.status + status: member.status, + role: member.role } } diff --git a/src/views/im/home/pages/contact/index.vue b/src/views/im/home/pages/contact/index.vue index 14ce229e6..f2dbb0aa1 100644 --- a/src/views/im/home/pages/contact/index.vue +++ b/src/views/im/home/pages/contact/index.vue @@ -125,8 +125,7 @@ const groups = computed(() => groupStore.groups.map((group: Group) => ({ id: group.id, name: group.name, - // 优先用群备注 displayGroupName,没设置时回落到原群名;避免点"进入群聊"时把已同步的备注会话名刷回原名 - showGroupName: getGroupDisplayName(group), + 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 e4a3417ef..bb0334fe4 100644 --- a/src/views/im/home/pages/conversation/components/conversation/ConversationGroupSide.vue +++ b/src/views/im/home/pages/conversation/components/conversation/ConversationGroupSide.vue @@ -11,7 +11,7 @@
- +
- 取消 - 保存 + 取消 + 保存
@@ -219,9 +220,7 @@ > {{ group.remarkNickName }} - 点击设置 + 点击设置
@@ -236,7 +235,8 @@
- + +
- +
消息免打扰 @@ -264,17 +264,54 @@
+ + + +
- + +
- + + + + + + @@ -300,11 +356,15 @@ import { useMessage } from '@/hooks/web/useMessage' import { useUserStore } from '@/store/modules/user' import { CommonStatusEnum } from '@/utils/constants' -import { updateGroup } 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 { ImConversationType } 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, { @@ -315,14 +375,12 @@ import type { GroupMemberLite } from '../../../../components/group/GroupMember.v defineOptions({ name: 'ImConversationGroupSide' }) -/** 大群默认只展示前 N 个成员,避免抽屉一打开拖到很长 */ -// TODO @AI:需要解释下,为什么是 14 个。因为 4 x 4 - 2,,2 预留给添加和移除 -const MEMBER_PREVIEW_COUNT = 14 +const MEMBER_PREVIEW_COUNT = 14 // 大群默认只展示前 N 个成员(4×4 宫格 − 2 个瓦片预留给"添加 / 移出"按钮) const props = withDefaults( defineProps<{ modelValue?: boolean // 抽屉是否打开(v-model) - group?: GroupLite & { notice?: string; remarkNickName?: string } // 当前群信息(可空:无激活群会话时) + group?: GroupLite & { notice?: string; remarkNickName?: string; groupRemark?: string } // 当前群信息(可空:无激活群会话时) conversation?: Conversation | null // 当前会话:用于读 / 切免打扰、置顶状态 members?: GroupMemberLite[] friends?: FriendLite[] @@ -353,27 +411,19 @@ const visible = computed({ const searchText = ref('') const inviteVisible = ref(false) const removeVisible = ref(false) +const adminVisible = ref(false) +const transferOwnerVisible = ref(false) const showAllMembers = ref(false) const namePopoverVisible = ref(false) const noticePopoverVisible = ref(false) const remarkPopoverVisible = ref(false) -const notePopoverVisible = ref(false) +const groupRemarkPopoverVisible = ref(false) const editName = ref('') const editNotice = ref('') const editRemark = ref('') -const editNote = ref('') +const editGroupRemark = ref('') -// 备注是"仅自己可见"的本地标签,后端没字段;按 groupId 落 localStorage,跟当前群绑定 -const NOTE_STORAGE_PREFIX = 'im:group:note:' -const personalNote = ref('') - -watch( - () => props.group?.id, - (groupId) => { - personalNote.value = groupId ? localStorage.getItem(NOTE_STORAGE_PREFIX + groupId) || '' : '' - }, - { immediate: true } -) +// ==================== 状态同步 watch ==================== // 抽屉关闭时重置临时态:搜索 / 折叠展开 / 还在打开的 popover 都清掉 watch(visible, (v) => { @@ -383,7 +433,7 @@ watch(visible, (v) => { namePopoverVisible.value = false noticePopoverVisible.value = false remarkPopoverVisible.value = false - notePopoverVisible.value = false + groupRemarkPopoverVisible.value = false } }) @@ -397,28 +447,40 @@ watch(noticePopoverVisible, (v) => { watch(remarkPopoverVisible, (v) => { if (v) editRemark.value = props.group?.remarkNickName || '' }) -watch(notePopoverVisible, (v) => { - if (v) editNote.value = personalNote.value +watch(groupRemarkPopoverVisible, (v) => { + if (v) editGroupRemark.value = props.group?.groupRemark || '' }) +// ==================== 角色 / 成员展示 ==================== + const myId = computed(() => Number(userStore.getUser?.id) || 0) const isOwner = computed(() => props.group != null && props.group.ownerId === myId.value) - -// 排除已退群成员 + 关键字过滤 -const visibleMembers = computed(() => - props.members.filter( - (member) => - member.status !== CommonStatusEnum.DISABLE && - (member.showName || '').includes(searchText.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 ) +// 排除已退群成员 + 关键字过滤;按角色排序:群主→管理员→普通成员(同角色按 userId 稳定) +const visibleMembers = computed(() => { + return props.members + .filter( + (member) => + member.status !== CommonStatusEnum.DISABLE && + (member.showName || '').includes(searchText.value) + ) + .sort((a, b) => { + const roleA = a.role ?? ImGroupMemberRole.NORMAL + const roleB = b.role ?? ImGroupMemberRole.NORMAL + return roleA !== roleB ? roleA - roleB : a.userId - b.userId + }) +}) + // 折叠规则:搜索 / 已展开 时不折叠,其余只取前 N 个 const moreMembersHidden = computed( () => - !searchText.value && - !showAllMembers.value && - visibleMembers.value.length > MEMBER_PREVIEW_COUNT + !searchText.value && !showAllMembers.value && visibleMembers.value.length > MEMBER_PREVIEW_COUNT ) const displayMembers = computed(() => moreMembersHidden.value @@ -426,6 +488,8 @@ const displayMembers = computed(() => : visibleMembers.value ) +// ==================== 群信息编辑 ==================== + /** 群主:保存群名(走 /im/group/update) */ async function saveName() { if (!props.group) { @@ -448,19 +512,18 @@ async function saveNotice() { emit('reload') } -/** 备注:仅本地 localStorage 落盘(后端无字段;多端不同步是已知限制) */ -function saveNote() { +/** 任何成员:保存群备注(仅自己可见,会替换会话列表 / 顶部群名展示) */ +async function saveGroupRemark() { if (!props.group) { return } - const value = editNote.value.trim() - if (value) { - localStorage.setItem(NOTE_STORAGE_PREFIX + props.group.id, value) - } else { - localStorage.removeItem(NOTE_STORAGE_PREFIX + props.group.id) - } - personalNote.value = value - notePopoverVisible.value = false + await updateGroupMember({ + groupId: props.group.id, + groupRemark: editGroupRemark.value.trim() + }) + groupRemarkPopoverVisible.value = false + message.success('保存成功') + emit('reload') } /** 任何成员:保存自己在群里的昵称(走 /im/group-member/update) */ @@ -477,6 +540,8 @@ async function saveRemark() { emit('reload') } +// ==================== 开关切换 ==================== + /** * 消息免打扰:本地 conversationStore 立即切;后端 /muted 异步同步,失败回滚本地 * @@ -504,6 +569,8 @@ function onTopChange(value: boolean | string | number) { conversationStore.setTop(props.conversation.type, props.conversation.targetId, !!value) } +// ==================== 退出群聊 ==================== + /** 退出群聊(普通成员入口;群主退出走"解散群"是另一条路径,这里不处理) */ async function handleQuit() { if (!props.group) { @@ -524,7 +591,26 @@ async function handleQuit() { visible.value = false } -/** 移除群成员(仅群主入口)*/ +// ==================== 群主操作 ==================== +// 移除群成员(群主 / 管理员可见)+ 设置群管理员(仅群主)+ 群主管理权转让(仅群主) + +// ---------- 移除群成员 ---------- + +/** 移除群成员的 hideIds:始终隐藏群主;管理员视角额外隐藏其它管理员(管理员不能移出管理员) */ +const removeHideIds = computed(() => { + const hideIds: number[] = [] + if (props.group?.ownerId) { + hideIds.push(props.group.ownerId) + } + if (myRole.value === ImGroupMemberRole.ADMIN) { + props.members + .filter((m) => m.role === ImGroupMemberRole.ADMIN) + .forEach((m) => hideIds.push(m.userId)) + } + return hideIds +}) + +/** 移除群成员入口(群主可移出任意非群主,管理员只能移出普通成员;具体由后端校验) */ async function handleRemoveComplete(members: GroupMemberFlag[]) { if (!props.group || members.length === 0) { return @@ -537,6 +623,72 @@ async function handleRemoveComplete(members: GroupMemberFlag[]) { message.success(`已移除 ${members.length} 位成员`) emit('reload') } + +// ---------- 设置群管理员 ---------- + +/** 当前管理员的 userId 列表,作为 Selector 默认勾选 */ +const adminCheckedIds = computed(() => + props.members + .filter((member) => member.role === ImGroupMemberRole.ADMIN) + .map((member) => member.userId) +) + +/** 设置管理员时隐藏:群主(不能设为管理员) */ +const adminHideIds = computed(() => (props.group?.ownerId ? [props.group.ownerId] : [])) + +/** 群管理员变更:跟当前管理员列表 diff,新增 → addGroupAdmin,撤销 → removeGroupAdmin */ +async function handleAdminUpdate(selected: GroupMemberFlag[]) { + if (!props.group) { + return + } + // 跟当前管理员列表做差集:分别拿到要新增 / 撤销的 userId + const previousIds = adminCheckedIds.value + const previousIdSet = new Set(previousIds) + const selectedIds = selected.map((member) => member.userId) + const selectedIdSet = new Set(selectedIds) + const addedIds = selectedIds.filter((id) => !previousIdSet.has(id)) + const removedIds = previousIds.filter((id) => !selectedIdSet.has(id)) + if (addedIds.length === 0 && removedIds.length === 0) { + return + } + if (addedIds.length > 0) { + await addGroupAdmin({ groupId: props.group.id, userIds: addedIds }) + } + if (removedIds.length > 0) { + await removeGroupAdmin({ groupId: props.group.id, userIds: removedIds }) + } + message.success(`已更新群管理员(新增 ${addedIds.length} 位,撤销 ${removedIds.length} 位)`) + emit('reload') +} + +// ---------- 群主管理权转让 ---------- + +/** 转让群主时隐藏当前用户(不能转让给自己) */ +const transferOwnerHideIds = computed(() => [myId.value]) + +async function handleTransferOwnerComplete(selected: GroupMemberFlag[]) { + if (!props.group || selected.length === 0) { + return + } + const newOwner = selected[0] + // 二次确认:转让后旧群主降为普通成员 + try { + await message.confirm( + `确定将群主转让给 ${newOwner.showName}?转让后你将变为普通成员,无法撤销。`, + '确认转让群主' + ) + } catch { + return + } + // 转让群主 + await transferGroupOwner({ + groupId: props.group.id, + newOwnerUserId: newOwner.userId + }) + // 提示结果 + 刷新数据 + message.success('群主转让成功') + emit('reload') +}