✨ feat(im): 增加群角色(管理员)
parent
3146f64edc
commit
fa27c27831
|
|
@ -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<ImGroupRespVO[]>({ url: '/im/group/list' })
|
||||
|
|
@ -51,3 +63,18 @@ export const updateGroup = (data: ImGroupUpdateReqVO) => {
|
|||
export const dissolveGroup = (id: number | string) => {
|
||||
return request.delete<boolean>({ url: '/im/group/dissolve', params: { id } })
|
||||
}
|
||||
|
||||
// 添加群管理员(仅群主可调)
|
||||
export const addGroupAdmin = (data: ImGroupAdminReqVO) => {
|
||||
return request.put<boolean>({ url: '/im/group/add-admin', data })
|
||||
}
|
||||
|
||||
// 撤销群管理员(仅群主可调)
|
||||
export const removeGroupAdmin = (data: ImGroupAdminReqVO) => {
|
||||
return request.put<boolean>({ url: '/im/group/remove-admin', data })
|
||||
}
|
||||
|
||||
// 转让群主(仅老群主可调;旧群主转让后降为普通成员)
|
||||
export const transferGroupOwner = (data: ImGroupTransferOwnerReqVO) => {
|
||||
return request.put<boolean>({ url: '/im/group/transfer-owner', data })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 // 是否免打扰
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 群成员角色
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ export interface GroupMemberLite {
|
|||
showName: string // 展示昵称:好友备注 > 用户群备注(displayUserName) > 真实昵称(nickname),给"显示给用户看"的位置用(行内文字、@候选标签等)
|
||||
avatar?: string
|
||||
status?: number
|
||||
role?: number // 成员角色,仅在群信息抽屉等需要展示角色标签的场景透传;@候选 / 已读列表等场景可不传
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
|
|
|
|||
|
|
@ -23,6 +23,14 @@
|
|||
>
|
||||
{{ member.showName }}
|
||||
</div>
|
||||
<!-- 角色标签:群主 / 管理员;普通成员不显示。仅在传入 member.role 时生效 -->
|
||||
<span
|
||||
v-if="roleLabel"
|
||||
class="px-1.5 py-px rounded text-xs whitespace-nowrap"
|
||||
:class="roleLabelClass"
|
||||
>
|
||||
{{ roleLabel }}
|
||||
</span>
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -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)]'
|
||||
})
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -125,8 +125,7 @@ const groups = computed<GroupLite[]>(() =>
|
|||
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
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
<div v-if="group" class="im-conversation-group-side flex flex-col h-full">
|
||||
<!-- 上部:可滚动内容区 -->
|
||||
<div class="im-conversation-group-side__scroll flex-1 overflow-y-auto">
|
||||
<!-- 群成员区 -->
|
||||
<!-- ==================== 群成员区 ==================== -->
|
||||
<div class="im-conversation-group-side__section im-conversation-group-side__members">
|
||||
<el-input v-model="searchText" placeholder="搜索群成员" clearable>
|
||||
<template #prefix>
|
||||
|
|
@ -43,9 +43,9 @@
|
|||
<div class="im-conversation-group-side__tile-label">添加</div>
|
||||
</div>
|
||||
|
||||
<!-- 移出(仅群主) -->
|
||||
<!-- 移出(群主或管理员;管理员只能移出普通成员,由后端校验) -->
|
||||
<div
|
||||
v-if="isOwner"
|
||||
v-if="isOwnerOrAdmin"
|
||||
class="im-conversation-group-side__tile-wrap"
|
||||
title="移出群成员"
|
||||
@click="removeVisible = true"
|
||||
|
|
@ -70,7 +70,8 @@
|
|||
|
||||
<div class="im-conversation-group-side__spacer"></div>
|
||||
|
||||
<!-- 群信息:label 在上、value 在下,纵向堆叠(对齐微信 PC 设计);只有 "群公告" 因为内容长加 > chevron -->
|
||||
<!-- ==================== 群信息 ==================== -->
|
||||
<!-- label 在上、value 在下,纵向堆叠(对齐微信 PC 设计);只有 "群公告" 因为内容长加 > chevron -->
|
||||
<div class="im-conversation-group-side__section">
|
||||
<!-- 群聊名称(群主可改) -->
|
||||
<el-popover
|
||||
|
|
@ -162,9 +163,9 @@
|
|||
<span v-else class="im-conversation-group-side__value-placeholder">未设置</span>
|
||||
</div>
|
||||
|
||||
<!-- 备注(仅自己可见的群备注;后端没字段,落 localStorage 做"个人记忆"用) -->
|
||||
<!-- 备注(仅自己可见;保存后会替换会话列表 / 顶部群名展示) -->
|
||||
<el-popover
|
||||
v-model:visible="notePopoverVisible"
|
||||
v-model:visible="groupRemarkPopoverVisible"
|
||||
trigger="click"
|
||||
placement="left-start"
|
||||
:width="280"
|
||||
|
|
@ -175,28 +176,28 @@
|
|||
>
|
||||
<span class="im-conversation-group-side__label">备注</span>
|
||||
<span
|
||||
v-if="personalNote"
|
||||
v-if="group.groupRemark"
|
||||
class="im-conversation-group-side__value im-conversation-group-side__value--clamp"
|
||||
>
|
||||
{{ personalNote }}
|
||||
{{ group.groupRemark }}
|
||||
</span>
|
||||
<span v-else class="im-conversation-group-side__value-placeholder">
|
||||
群聊的备注仅自己可见
|
||||
</span>
|
||||
<span v-else class="im-conversation-group-side__value-placeholder"
|
||||
>群聊的备注仅自己可见</span
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
<div class="flex flex-col gap-2">
|
||||
<el-input
|
||||
v-model="editNote"
|
||||
v-model="editGroupRemark"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
maxlength="200"
|
||||
maxlength="64"
|
||||
show-word-limit
|
||||
placeholder="仅自己可见"
|
||||
/>
|
||||
<div class="flex justify-end gap-2">
|
||||
<el-button size="small" @click="notePopoverVisible = false">取消</el-button>
|
||||
<el-button size="small" type="primary" @click="saveNote">保存</el-button>
|
||||
<el-button size="small" @click="groupRemarkPopoverVisible = false">取消</el-button>
|
||||
<el-button size="small" type="primary" @click="saveGroupRemark">保存</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-popover>
|
||||
|
|
@ -219,9 +220,7 @@
|
|||
>
|
||||
{{ group.remarkNickName }}
|
||||
</span>
|
||||
<span v-else class="im-conversation-group-side__value-placeholder"
|
||||
>点击设置</span
|
||||
>
|
||||
<span v-else class="im-conversation-group-side__value-placeholder">点击设置</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="flex flex-col gap-2">
|
||||
|
|
@ -236,7 +235,8 @@
|
|||
|
||||
<div class="im-conversation-group-side__spacer"></div>
|
||||
|
||||
<!-- 查找聊天内容(点击 → 父组件打开 MessageHistory 弹窗) -->
|
||||
<!-- ==================== 查找聊天内容 ==================== -->
|
||||
<!-- 点击 → 父组件打开 MessageHistory 弹窗 -->
|
||||
<div class="im-conversation-group-side__section">
|
||||
<div
|
||||
class="im-conversation-group-side__row im-conversation-group-side__row--clickable"
|
||||
|
|
@ -253,7 +253,7 @@
|
|||
|
||||
<div class="im-conversation-group-side__spacer"></div>
|
||||
|
||||
<!-- 开关项 -->
|
||||
<!-- ==================== 开关项 ==================== -->
|
||||
<div class="im-conversation-group-side__section">
|
||||
<div class="im-conversation-group-side__row">
|
||||
<span class="im-conversation-group-side__label">消息免打扰</span>
|
||||
|
|
@ -264,17 +264,54 @@
|
|||
<el-switch :model-value="!!conversation?.top" @change="onTopChange" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ==================== 群主操作 ==================== -->
|
||||
<!-- 仅群主可见,含管理员设置 + 群主管理权转让 -->
|
||||
<template v-if="isOwner">
|
||||
<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"
|
||||
@click="adminVisible = true"
|
||||
>
|
||||
<span class="im-conversation-group-side__label">群管理员</span>
|
||||
<Icon
|
||||
icon="ant-design:right-outlined"
|
||||
:size="11"
|
||||
class="im-conversation-group-side__chevron"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="im-conversation-group-side__row im-conversation-group-side__row--clickable"
|
||||
@click="transferOwnerVisible = true"
|
||||
>
|
||||
<span class="im-conversation-group-side__label">群主管理权转让</span>
|
||||
<Icon
|
||||
icon="ant-design:right-outlined"
|
||||
:size="11"
|
||||
class="im-conversation-group-side__chevron"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 底部:退出群聊(仅非群主入口;群主退出走"解散群"另起一条路径,这里不处理) -->
|
||||
<!-- ==================== 底部:退出群聊 ==================== -->
|
||||
<!-- 仅非群主入口;群主退出走"解散群"另起一条路径,这里不处理 -->
|
||||
<div v-if="!isOwner" class="im-conversation-group-side__footer">
|
||||
<el-button class="im-conversation-group-side__quit-btn" type="danger" plain @click="handleQuit">
|
||||
<el-button
|
||||
class="im-conversation-group-side__quit-btn"
|
||||
type="danger"
|
||||
plain
|
||||
@click="handleQuit"
|
||||
>
|
||||
退出群聊
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 子对话框:邀请新成员 / 选成员移除 -->
|
||||
<!-- ==================== 子对话框 ==================== -->
|
||||
<!-- 邀请新成员 / 选成员移除 -->
|
||||
<GroupMemberAddDialog
|
||||
v-model="inviteVisible"
|
||||
:group-id="group?.id"
|
||||
|
|
@ -286,10 +323,29 @@
|
|||
v-model="removeVisible"
|
||||
title="选择成员进行移除"
|
||||
:members="members"
|
||||
:hide-ids="group?.ownerId ? [group.ownerId] : []"
|
||||
:hide-ids="removeHideIds"
|
||||
:max-size="50"
|
||||
@complete="handleRemoveComplete"
|
||||
/>
|
||||
|
||||
<!-- 群主操作:管理员设置(一个弹窗合并增 / 删,提交时 diff)+ 群主管理权转让 -->
|
||||
<GroupMemberSelector
|
||||
v-model="adminVisible"
|
||||
title="设置群管理员"
|
||||
:members="members"
|
||||
:checked-ids="adminCheckedIds"
|
||||
:hide-ids="adminHideIds"
|
||||
:max-size="GROUP_ADMIN_MAX_COUNT"
|
||||
@complete="handleAdminUpdate"
|
||||
/>
|
||||
<GroupMemberSelector
|
||||
v-model="transferOwnerVisible"
|
||||
title="选择新群主"
|
||||
:members="members"
|
||||
:hide-ids="transferOwnerHideIds"
|
||||
:max-size="1"
|
||||
@complete="handleTransferOwnerComplete"
|
||||
/>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
|
|
@ -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')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
|
|
|||
|
|
@ -5,17 +5,26 @@
|
|||
<div
|
||||
class="message-panel__header flex items-center justify-between h-14 px-5 bg-[var(--el-fill-color-light)]"
|
||||
>
|
||||
<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 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>
|
||||
<div class="flex gap-3 items-center">
|
||||
|
|
@ -170,6 +179,13 @@ const headerMemberCount = computed(() => {
|
|||
return group?.memberCount ?? group?.members?.length ?? 0
|
||||
})
|
||||
|
||||
/** 顶部副标题:仅当群备注 ≠ 原群名时显示原群名(对齐微信 PC 双行 header) */
|
||||
const headerSubtitle = computed(() => {
|
||||
const remark = groupInfo.value?.groupRemark
|
||||
const name = groupInfo.value?.name
|
||||
return remark && name && remark !== name ? name : ''
|
||||
})
|
||||
|
||||
const BOTTOM_THRESHOLD = 80 // "是否停留在底部"的阈值:距离底部 < 80px 视为底部
|
||||
const showJumpToBottom = ref(false) // 当前是否已不在底部(显示"回到底部"按钮)
|
||||
const newMessageCount = ref(0) // 不在底部期间累计的新消息数
|
||||
|
|
@ -182,7 +198,13 @@ const newMessageCount = ref(0) // 不在底部期间累计的新消息数
|
|||
* 必须等 store 就位才有值(这些字段在 conversation 里没有)
|
||||
*/
|
||||
const groupInfo = computed<
|
||||
(GroupLite & { notice?: string; remarkNickName?: string; ownerId?: number }) | undefined
|
||||
| (GroupLite & {
|
||||
notice?: string
|
||||
remarkNickName?: string
|
||||
groupRemark?: string
|
||||
ownerId?: number
|
||||
})
|
||||
| undefined
|
||||
>(() => {
|
||||
const conversation = conversationStore.activeConversation
|
||||
if (!conversation || conversation.type !== ImConversationType.GROUP) {
|
||||
|
|
@ -195,6 +217,7 @@ const groupInfo = computed<
|
|||
showGroupName: group?.name || conversation.name,
|
||||
showImage: group?.avatar || conversation.avatar,
|
||||
notice: group?.notice,
|
||||
groupRemark: group?.groupRemark,
|
||||
ownerId: group?.ownerUserId,
|
||||
memberCount: group?.memberCount
|
||||
}
|
||||
|
|
@ -215,7 +238,8 @@ const groupMembers = computed<GroupMemberLite[]>(() => {
|
|||
showName: getMemberDisplayName(member, friend),
|
||||
nickname: member.nickname,
|
||||
avatar: member.avatar,
|
||||
status: member.status
|
||||
status: member.status,
|
||||
role: member.role
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -731,7 +731,7 @@ export const useConversationStore = defineStore('imConversationStore', {
|
|||
*
|
||||
* 调用方负责把好友 / 群的信息整理成 Conversation 视角的字段:
|
||||
* - 私聊:name = friend.nickname;avatar = friend.avatar
|
||||
* - 群聊:name = group.name(或叠加 displayGroupName);avatar = group.avatar
|
||||
* - 群聊:name = group.name(或叠加 groupRemark);avatar = group.avatar
|
||||
*/
|
||||
updateConversation(
|
||||
type: number,
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import {
|
|||
type ImGroupMemberRespVO
|
||||
} from '@/api/im/group/member'
|
||||
import { useConversationStore } from './conversationStore'
|
||||
import { ImConversationType } from '../../utils/constants'
|
||||
import { ImConversationType, ImGroupMemberRole } from '../../utils/constants'
|
||||
import {
|
||||
getCurrentUserId,
|
||||
imStorage,
|
||||
|
|
@ -170,7 +170,7 @@ export const useGroupStore = defineStore('imGroupStore', {
|
|||
// 拉取当前登录用户加入的所有群(不带成员;成员按需再走 fetchGroupMembers)
|
||||
const list = await apiGetMyGroupList()
|
||||
const fresh = (list || []).map(convertGroup)
|
||||
// 合并而非全量替换:保留 user-per-group 字段(muted / displayGroupName)+ 成员缓存——这些字段不在 ImGroupRespVO 里,全量替换会把它们冲掉
|
||||
// 合并而非全量替换:muted / groupRemark / 成员缓存这些字段不在 ImGroupRespVO 里,得从旧 group 保留
|
||||
const groupMap = new Map(this.groups.map((group) => [group.id, group]))
|
||||
this.groups = fresh.map((group) => {
|
||||
const existing = groupMap.get(group.id)
|
||||
|
|
@ -182,7 +182,7 @@ export const useGroupStore = defineStore('imGroupStore', {
|
|||
members: existing.members,
|
||||
memberCount: existing.memberCount ?? group.memberCount,
|
||||
muted: existing.muted ?? group.muted,
|
||||
displayGroupName: existing.displayGroupName,
|
||||
groupRemark: existing.groupRemark,
|
||||
membersLoaded: existing.membersLoaded
|
||||
}
|
||||
})
|
||||
|
|
@ -230,8 +230,7 @@ export const useGroupStore = defineStore('imGroupStore', {
|
|||
return inflight
|
||||
}
|
||||
const promise = (async () => {
|
||||
// 拉接口 + 单 pass 转换:同时捕获 me 的原始 VO,给下面回填 user-per-group 字段(muted / displayGroupName)用
|
||||
// convertGroupMember 不带 muted / displayGroupName(已搬到 Group 上),所以从 raw VO 里捞
|
||||
// 拉接口 + 单 pass 转换:同时捕获 me 的原始 VO,给下面回填 user-per-group 字段(muted / groupRemark)用
|
||||
const list = await apiGetGroupMemberList(groupId)
|
||||
let meRaw: ImGroupMemberRespVO | undefined
|
||||
const members = (list || []).map((member) => {
|
||||
|
|
@ -241,7 +240,7 @@ export const useGroupStore = defineStore('imGroupStore', {
|
|||
return convertGroupMember(member, groupId)
|
||||
})
|
||||
const muted = !!meRaw?.muted
|
||||
const displayGroupName = meRaw?.displayGroupName || ''
|
||||
const groupRemark = meRaw?.groupRemark || ''
|
||||
|
||||
// 必须 await 之后重新 getGroup,避免 fetchGroups 已并发写入真实 group 的 race
|
||||
const group = this.getGroup(groupId)
|
||||
|
|
@ -256,18 +255,17 @@ export const useGroupStore = defineStore('imGroupStore', {
|
|||
members,
|
||||
memberCount: members.length,
|
||||
muted,
|
||||
displayGroupName,
|
||||
groupRemark,
|
||||
membersLoaded: true
|
||||
})
|
||||
} else {
|
||||
group.members = members
|
||||
group.memberCount = members.length
|
||||
group.membersLoaded = true
|
||||
// muted / displayGroupName 任一变化就同步到 conversation + 触发 saveGroups;
|
||||
// 后续,displayGroupName 变化要刷会话名("我对该群的备注"是会话列表的展示名)
|
||||
if (group.muted !== muted || group.displayGroupName !== displayGroupName) {
|
||||
// muted / groupRemark 任一变化才同步到 conversation 和 IDB;groupRemark 变化要顺带刷会话名
|
||||
if (group.muted !== muted || group.groupRemark !== groupRemark) {
|
||||
group.muted = muted
|
||||
group.displayGroupName = displayGroupName
|
||||
group.groupRemark = groupRemark
|
||||
groupFieldsChanged = true
|
||||
const conversationStore = useConversationStore()
|
||||
conversationStore.updateConversation(ImConversationType.GROUP, groupId, {
|
||||
|
|
@ -294,7 +292,7 @@ export const useGroupStore = defineStore('imGroupStore', {
|
|||
/**
|
||||
* 按 (groupId, memberUserId) 单成员补齐——deriveLastSenderDisplayName 兜底场景用
|
||||
*
|
||||
* 跟 fetchGroupMembers 区别:只拉这一个成员,不动 me 的 muted / displayGroupName(不是 me 的话拿不到);
|
||||
* 跟 fetchGroupMembers 区别:只拉这一个成员,不动 me 的 muted / groupRemark(不是 me 的话拿不到);
|
||||
* 命中时把成员 upsert 进 group.members 数组并落 IDB,让后续渲染能用 displayUserName
|
||||
*/
|
||||
fetchGroupMember(groupId: number, memberUserId: number): Promise<GroupMember | null> {
|
||||
|
|
@ -395,6 +393,44 @@ export const useGroupStore = defineStore('imGroupStore', {
|
|||
this.saveGroups()
|
||||
},
|
||||
|
||||
/** 批量更新群成员角色;本地不命中则忽略,等 fetchGroupMembers 兜底 */
|
||||
updateMembersRole(groupId: number, userIds: number[], role: number) {
|
||||
const group = this.getGroup(groupId)
|
||||
if (!group?.members?.length) {
|
||||
return
|
||||
}
|
||||
// TODO @AI:计算是否更新?【优化注释】
|
||||
const idSet = new Set(userIds)
|
||||
let changed = false
|
||||
// TODO @AI:newMembers 更合适?
|
||||
// TODO @AI:m 是不是改成 member;
|
||||
const next = group.members.map((m) => {
|
||||
if (!idSet.has(m.userId) || m.role === role) {
|
||||
return m
|
||||
}
|
||||
changed = true
|
||||
return { ...m, role }
|
||||
})
|
||||
// TODO @AI:有更新则进行替换,补充下注释【优化注释】
|
||||
if (changed) {
|
||||
group.members = next
|
||||
}
|
||||
},
|
||||
|
||||
/** 群主转让:群表 ownerUserId 改为新值;旧群主 role → NORMAL;新群主 role → OWNER */
|
||||
transferOwner(groupId: number, oldOwnerId: number, newOwnerId: number) {
|
||||
const group = this.getGroup(groupId)
|
||||
if (!group) {
|
||||
return
|
||||
}
|
||||
if (group.ownerUserId !== newOwnerId) {
|
||||
group.ownerUserId = newOwnerId
|
||||
}
|
||||
this.updateMembersRole(groupId, [oldOwnerId], ImGroupMemberRole.NORMAL)
|
||||
this.updateMembersRole(groupId, [newOwnerId], ImGroupMemberRole.OWNER)
|
||||
this.saveGroups()
|
||||
},
|
||||
|
||||
/** 切账号时仅清 in-memory,IDB 按 userId 分桶天然隔离,回切秒开 */
|
||||
clear() {
|
||||
this.groups = []
|
||||
|
|
@ -424,7 +460,8 @@ function convertGroupMember(member: ImGroupMemberRespVO, groupId: number): Group
|
|||
nickname: member.nickname || String(member.userId),
|
||||
avatar: member.avatar,
|
||||
displayUserName: member.displayUserName,
|
||||
status: member.status
|
||||
status: member.status,
|
||||
role: member.role
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -115,8 +115,8 @@ export interface Group {
|
|||
ownerUserId?: number // 群主用户编号
|
||||
|
||||
// ========== 前端扩展字段(user-per-group 维度) ==========
|
||||
muted?: boolean // 是否免打扰。从当前用户的 GroupMember 回填(当前用户对该群的自定义名)
|
||||
displayGroupName?: string // 群显示备注。从当前用户的 GroupMember 回填(当前用户对该群的自定义名)
|
||||
muted?: boolean // 是否免打扰。从当前用户的 GroupMember 回填
|
||||
groupRemark?: string // 群备注。从当前用户的 GroupMember 回填(当前用户对该群的自定义名)
|
||||
members?: GroupMember[] // 群成员缓存(按需懒加载)
|
||||
membersLoaded?: boolean // members 是否"完整加载"——只有整群 loadGroupMembers / fetchGroupMembers 命中时为 true;fetchGroupMember 单成员补齐不置位,避免 fetchGroupMembers(force=false) 命中缓存时误判整群已加载
|
||||
memberCount?: number // 成员总数
|
||||
|
|
@ -132,6 +132,7 @@ export interface GroupMember {
|
|||
nickname: string // 用户昵称
|
||||
displayUserName?: string // 该成员在群内自定义昵称(每个 member 一份;不与 nickname 合并,由消费方按需取舍)
|
||||
status?: number // 在群 / 退群状态,对齐 CommonStatusEnum
|
||||
role?: number // 成员角色,参见 ImGroupMemberRole 枚举:1=群主 2=管理员 3=普通成员
|
||||
|
||||
// ========== 前端扩展字段 ==========
|
||||
isOwner?: boolean // 是否群主(前端从 Group.ownerUserId 计算)
|
||||
|
|
|
|||
|
|
@ -40,6 +40,11 @@
|
|||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="用户编号" prop="userId" width="100" align="center" />
|
||||
<el-table-column label="角色" prop="role" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<dict-tag :type="DICT_TYPE.IM_GROUP_MEMBER_ROLE" :value="row.role" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="昵称" prop="nickname" min-width="120" show-overflow-tooltip />
|
||||
<el-table-column
|
||||
label="组内显示名"
|
||||
|
|
@ -50,12 +55,12 @@
|
|||
<template #default="{ row }">{{ row.displayUserName || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
label="群显示备注"
|
||||
prop="displayGroupName"
|
||||
label="群备注"
|
||||
prop="groupRemark"
|
||||
min-width="120"
|
||||
show-overflow-tooltip
|
||||
>
|
||||
<template #default="{ row }">{{ row.displayGroupName || '-' }}</template>
|
||||
<template #default="{ row }">{{ row.groupRemark || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="免打扰" prop="muted" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
|
|
|
|||
|
|
@ -65,6 +65,16 @@ export const ImGroupReceiptStatus = {
|
|||
DONE: 2 // 已完成
|
||||
} as const
|
||||
|
||||
/** 群成员角色(对齐后端 ImGroupMemberRoleEnum) */
|
||||
export const ImGroupMemberRole = {
|
||||
OWNER: 1, // 群主
|
||||
ADMIN: 2, // 管理员
|
||||
NORMAL: 3 // 普通成员
|
||||
} as const
|
||||
|
||||
/** 群管理员人数上限(对齐后端 GROUP_ADMIN_MAX_COUNT) */
|
||||
export const GROUP_ADMIN_MAX_COUNT = 3
|
||||
|
||||
/** 每次拉取私聊消息的最大条数(后端上限 1000,前端取保守值 100) */
|
||||
export const PRIVATE_MESSAGE_PULL_SIZE = 100
|
||||
|
||||
|
|
|
|||
|
|
@ -39,9 +39,9 @@ export function getMemberDisplayName(
|
|||
return friend?.displayName || member.displayUserName || member.nickname
|
||||
}
|
||||
|
||||
/** 群显示名:当前用户对该群的备注(displayGroupName) > 群名(name) */
|
||||
export function getGroupDisplayName(group: Pick<Group, 'name' | 'displayGroupName'>): string {
|
||||
return group.displayGroupName || group.name
|
||||
/** 群显示名:当前用户对该群的备注(groupRemark) > 群名(name) */
|
||||
export function getGroupDisplayName(group: Pick<Group, 'name' | 'groupRemark'>): string {
|
||||
return group.groupRemark || group.name
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Reference in New Issue