feat(im): 增加群角色(管理员)

im
YunaiV 2026-05-02 14:31:42 +08:00
parent 3146f64edc
commit fa27c27831
16 changed files with 394 additions and 107 deletions

View File

@ -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 })
}

View File

@ -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 // 是否免打扰
}

View File

@ -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
}

View File

@ -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 群成员角色
}

View File

@ -38,6 +38,7 @@ export interface GroupMemberLite {
showName: string // > displayUserName > nickname""@
avatar?: string
status?: number
role?: number // 成员角色,仅在群信息抽屉等需要展示角色标签的场景透传;@候选 / 已读列表等场景可不传
}
const props = withDefaults(

View File

@ -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>

View File

@ -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>

View File

@ -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

View File

@ -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>

View File

@ -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
}
})
})

View File

@ -731,7 +731,7 @@ export const useConversationStore = defineStore('imConversationStore', {
*
* / Conversation
* - name = friend.nicknameavatar = friend.avatar
* - name = group.name displayGroupNameavatar = group.avatar
* - name = group.name groupRemarkavatar = group.avatar
*/
updateConversation(
type: number,

View File

@ -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 和 IDBgroupRemark 变化要顺带刷会话名
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 @AInewMembers 更合适?
// TODO @AIm 是不是改成 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-memoryIDB 按 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
}
}

View File

@ -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 命中时为 truefetchGroupMember 单成员补齐不置位,避免 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 计算)

View File

@ -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 }">

View File

@ -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

View File

@ -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
}
/**