新增IM聊天群管理接口,包括群聊详情、添加成员、移除成员、修改群名称、转让群主、退出群聊及解散群聊功能,提升群聊管理体验。

This commit is contained in:
geht
2026-05-29 10:49:11 +08:00
parent 22814cb1a7
commit e281f7fd92
10 changed files with 1118 additions and 0 deletions

View File

@@ -131,6 +131,9 @@
</button>
<template #overlay>
<a-menu @click="handleSettingsMenuClick">
<!--update-begin---author:cursor ---date:20260529 forIM聊天-OA群聊设置入口------------->
<a-menu-item v-if="isGroupChat" key="groupSettings">群设置</a-menu-item>
<!--update-end---author:cursor ---date:20260529 forIM聊天-OA群聊设置入口----------->
<a-menu-item key="chatSettings">聊天设置</a-menu-item>
</a-menu>
</template>
@@ -214,6 +217,10 @@
<ImChatSettingsModal @register="registerSettingsModal" @saved="onChatSettingsSaved" />
<ImPageListPickModal @register="registerListPickModal" @confirm="handleListRowsSend" />
<ImCreateGroupModal @register="registerCreateGroupModal" @success="handleGroupCreated" />
<!--update-begin---author:cursor ---date:20260529 forIM聊天-OA群设置抽屉------------->
<ImGroupSettingDrawer @register="registerGroupSettingDrawer" @changed="handleGroupSettingChanged" @exited="handleGroupExited" />
<!--update-end---author:cursor ---date:20260529 forIM聊天-OA群设置抽屉----------->
<a-modal :open="previewVisible" :footer="null" width="720px" @cancel="closeImagePreview">
<img alt="图片预览" style="width: 100%" :src="previewImageUrl" />
</a-modal>
@@ -243,6 +250,10 @@
import ImPageListPickModal from './ImPageListPickModal.vue';
import ImBizRecordMessageContent from './ImBizRecordMessageContent.vue';
import ImCreateGroupModal from './ImCreateGroupModal.vue';
//update-begin---author:cursor ---date:20260529 for【IM聊天-OA】群设置抽屉-----------
import ImGroupSettingDrawer from './ImGroupSettingDrawer.vue';
import { useDrawer } from '/@/components/Drawer';
//update-end---author:cursor ---date:20260529 for【IM聊天-OA】群设置抽屉-----------
import { useModal } from '/@/components/Modal';
import { useMessage } from '/@/hooks/web/useMessage';
import { buildImBizRecordPayload, parseImBizRecordPayload, serializeImBizRecordPayload } from './imBizRecordMessage';
@@ -499,6 +510,9 @@
const [registerListPickModal, { openModal: openListPickModal }] = useModal();
const [registerCreateGroupModal, { openModal: openCreateGroupModal }] = useModal();
//update-end---author:xsl ---date:20260528 for【IM聊天-OA】点击功能名气泡选择列表明细发送-----------
//update-begin---author:cursor ---date:20260529 for【IM聊天-OA】群设置抽屉-----------
const [registerGroupSettingDrawer, { openDrawer: openGroupSettingDrawer, closeDrawer: closeGroupSettingDrawer }] = useDrawer();
//update-end---author:cursor ---date:20260529 for【IM聊天-OA】群设置抽屉-----------
const defaultHistoryDays = ref(getImDefaultHistoryDays());
const currentUserId = computed(() => userStore.getUserInfo?.id || '');
const deptLabel = computed(() => {
@@ -715,9 +729,60 @@
function handleSettingsMenuClick({ key }: { key: string }) {
if (key === 'chatSettings') {
openSettingsModal(true, {});
return;
}
//update-begin---author:cursor ---date:20260529 for【IM聊天-OA】打开群设置抽屉-----------
if (key === 'groupSettings') {
if (!activeGroup.value?.conversationId) {
return;
}
openGroupSettingDrawer(true, { conversationId: activeGroup.value.conversationId });
}
//update-end---author:cursor ---date:20260529 for【IM聊天-OA】打开群设置抽屉-----------
}
//update-begin---author:cursor ---date:20260529 for【IM聊天-OA】群设置变更/退出回调-----------
/** 群信息变更(改名/加人/踢人/转让):同步标题、成员数,并刷新群列表 */
async function handleGroupSettingChanged(payload: { conversationId: string; groupName?: string; memberCount?: number }) {
if (!payload?.conversationId) {
return;
}
patchGroup(payload.conversationId, {
groupName: payload.groupName,
memberCount: payload.memberCount,
}, { moveToTop: false });
if (activeGroup.value?.conversationId === payload.conversationId) {
activeGroup.value = {
...activeGroup.value,
groupName: payload.groupName ?? activeGroup.value.groupName,
memberCount: payload.memberCount ?? activeGroup.value.memberCount,
};
}
// 后台静默刷新群列表,保证成员数等准确
await loadGroups(true);
}
/** 退出或解散群聊:从列表移除,若为当前会话则清空聊天区 */
async function handleGroupExited(conversationId: string) {
if (!conversationId) {
return;
}
groupList.value = groupList.value.filter((item) => item.conversationId !== conversationId);
resetGroupUnread(conversationId);
syncAllUnread();
if (activeGroup.value?.conversationId === conversationId) {
activeGroup.value = null;
activeChatType.value = '';
activeConversationId.value = '';
setImActiveConversationId('');
messageList.value = [];
draft.value = '';
clearImActiveSession();
}
closeGroupSettingDrawer();
}
//update-end---author:cursor ---date:20260529 for【IM聊天-OA】群设置变更/退出回调-----------
function onChatSettingsSaved() {
defaultHistoryDays.value = getImDefaultHistoryDays();
if (activeConversationId.value) {

View File

@@ -0,0 +1,164 @@
<!--update-begin---author:cursor ---date:20260529 forIM聊天-OA群设置-添加群成员弹窗----------->
<template>
<BasicModal v-bind="$attrs" title="添加群成员" :width="520" @register="registerModal" @ok="handleSubmit">
<a-form layout="vertical">
<a-form-item label="选择成员" required>
<a-input
v-model:value="memberKeyword"
placeholder="搜索同事"
allow-clear
size="small"
class="member-search"
@pressEnter="loadMembers"
/>
<a-spin :spinning="loading">
<div class="member-list">
<a-checkbox-group v-model:value="checkedMemberIds" class="member-checkbox-group">
<div v-for="item in filteredMembers" :key="item.id" class="member-row">
<a-checkbox :value="item.id">
<div class="member-info">
<a-avatar :size="28" :src="getAvatarUrl(item.avatar)">
{{ (item.realname || item.username || '?').slice(0, 1) }}
</a-avatar>
<span class="member-name">{{ item.realname || item.username }}</span>
</div>
</a-checkbox>
</div>
</a-checkbox-group>
<a-empty v-if="!filteredMembers.length" description="暂无可添加的同事" />
</div>
</a-spin>
</a-form-item>
</a-form>
</BasicModal>
</template>
<script lang="ts" setup>
import { computed, ref } from 'vue';
import { BasicModal, useModalInner } from '/@/components/Modal';
import { useMessage } from '/@/hooks/web/useMessage';
import { getFileAccessHttpUrl } from '/@/utils/common/compUtils';
import { useUserStore } from '/@/store/modules/user';
import { addGroupMembers, fetchDeptMembers } from './im.api';
defineOptions({ name: 'ImGroupAddMemberModal' });
const userStore = useUserStore();
const emit = defineEmits<{
success: [];
}>();
const { createMessage } = useMessage();
const memberKeyword = ref('');
const checkedMemberIds = ref<string[]>([]);
const members = ref<Recordable[]>([]);
const loading = ref(false);
const conversationId = ref('');
// 已在群成员(用于过滤候选)
const existMemberIds = ref<string[]>([]);
const filteredMembers = computed(() => {
const keyword = memberKeyword.value.trim().toLowerCase();
if (!keyword) {
return members.value;
}
return members.value.filter((item) => {
const name = `${item.realname || ''}${item.username || ''}`.toLowerCase();
return name.includes(keyword);
});
});
/** 过滤掉当前用户与已在群成员 */
function resolveSelectableMembers(source: Recordable[]) {
const currentUserId = userStore.getUserInfo?.id || '';
const excludeSet = new Set([currentUserId, ...existMemberIds.value]);
return source.filter((item) => item.id && !excludeSet.has(item.id));
}
const [registerModal, { setModalProps, closeModal }] = useModalInner(
async (data?: { conversationId?: string; existMemberIds?: string[] }) => {
memberKeyword.value = '';
checkedMemberIds.value = [];
conversationId.value = data?.conversationId || '';
existMemberIds.value = data?.existMemberIds || [];
setModalProps({ confirmLoading: false });
members.value = [];
await loadMembers();
},
);
function getAvatarUrl(avatar?: string) {
return avatar ? getFileAccessHttpUrl(avatar) : '';
}
async function loadMembers() {
loading.value = true;
try {
const list = ((await fetchDeptMembers(memberKeyword.value || undefined)) || []) as Recordable[];
members.value = resolveSelectableMembers(list);
} catch {
createMessage.error('加载同事列表失败,请稍后重试');
} finally {
loading.value = false;
}
}
async function handleSubmit() {
if (!conversationId.value) {
return;
}
if (!checkedMemberIds.value.length) {
createMessage.warning('请至少选择1名同事');
return;
}
setModalProps({ confirmLoading: true });
try {
await addGroupMembers({
conversationId: conversationId.value,
memberUserIds: checkedMemberIds.value,
});
createMessage.success('添加成功');
emit('success');
closeModal();
} finally {
setModalProps({ confirmLoading: false });
}
}
</script>
<style lang="less" scoped>
.member-search {
margin-bottom: 8px;
}
.member-list {
max-height: 320px;
overflow-y: auto;
border: 1px solid #f0f0f0;
border-radius: 6px;
padding: 8px;
}
.member-checkbox-group {
display: flex;
flex-direction: column;
gap: 4px;
width: 100%;
}
.member-row {
padding: 4px 0;
}
.member-info {
display: inline-flex;
align-items: center;
gap: 8px;
}
.member-name {
font-size: 13px;
}
</style>
<!--update-end---author:cursor ---date:20260529 forIM聊天-OA群设置-添加群成员弹窗----------->

View File

@@ -0,0 +1,473 @@
<!--update-begin---author:cursor ---date:20260529 forIM聊天-OA群设置抽屉----------->
<template>
<BasicDrawer v-bind="$attrs" title="群设置" :width="360" @register="registerDrawer">
<a-spin :spinning="loading">
<div class="group-setting">
<!-- 群成员宫格 -->
<div class="section">
<div class="section-title">
群成员
<span class="member-count">{{ detail.memberCount || 0 }}</span>
</div>
<div class="member-grid">
<div v-for="m in detail.members" :key="m.userId" class="member-cell">
<div class="member-avatar-wrap">
<a-avatar :size="44" :src="getAvatarUrl(m.avatar)">
{{ (m.realname || m.username || '?').slice(0, 1) }}
</a-avatar>
<span v-if="m.owner" class="owner-tag">群主</span>
<span
v-if="removeMode && !m.owner"
class="remove-badge"
@click="handleRemoveMember(m)"
>
<Icon icon="ant-design:minus-outlined" />
</span>
</div>
<span class="member-name">{{ m.realname || m.username }}</span>
</div>
<!-- 添加成员所有成员可见 -->
<div class="member-cell">
<div class="member-action-btn" @click="handleOpenAddMember">
<Icon icon="ant-design:plus-outlined" />
</div>
<span class="member-name">添加</span>
</div>
<!-- 移除成员仅群主可见 -->
<div v-if="detail.owner" class="member-cell">
<div :class="['member-action-btn', { active: removeMode }]" @click="toggleRemoveMode">
<Icon icon="ant-design:minus-outlined" />
</div>
<span class="member-name">{{ removeMode ? '完成' : '移除' }}</span>
</div>
</div>
</div>
<!-- 群名称 -->
<div class="section">
<div class="setting-row" :class="{ 'is-clickable': detail.owner }" @click="handleEditName">
<span class="row-label">群聊名称</span>
<span class="row-value">
{{ detail.groupName || '未命名群聊' }}
<Icon v-if="detail.owner" icon="ant-design:right-outlined" class="row-arrow" />
</span>
</div>
</div>
<!-- 群主管理 -->
<div v-if="detail.owner" class="section">
<div class="setting-row is-clickable" @click="handleOpenTransfer">
<span class="row-label">转让群主</span>
<Icon icon="ant-design:right-outlined" class="row-arrow" />
</div>
</div>
<!-- 底部操作 -->
<div class="footer-actions">
<a-button v-if="detail.owner" danger block @click="handleDismiss">解散群聊</a-button>
<a-button v-else danger block @click="handleQuit">退出群聊</a-button>
</div>
</div>
</a-spin>
<!-- 添加成员弹窗 -->
<ImGroupAddMemberModal @register="registerAddMemberModal" @success="onMembersChanged" />
<!-- 修改群名称弹窗 -->
<a-modal v-model:open="nameModalVisible" title="修改群名称" :confirm-loading="nameSaving" @ok="submitRename">
<a-input v-model:value="editingName" placeholder="请输入群名称" :maxlength="30" show-count />
</a-modal>
<!-- 转让群主弹窗 -->
<a-modal v-model:open="transferModalVisible" title="转让群主" :confirm-loading="transferSaving" @ok="submitTransfer">
<div class="transfer-tip">选择一名群成员作为新群主转让后你将不再是群主</div>
<a-radio-group v-model:value="transferTargetId" class="transfer-list">
<a-radio v-for="m in transferCandidates" :key="m.userId" :value="m.userId" class="transfer-item">
<a-avatar :size="28" :src="getAvatarUrl(m.avatar)">
{{ (m.realname || m.username || '?').slice(0, 1) }}
</a-avatar>
<span class="transfer-name">{{ m.realname || m.username }}</span>
</a-radio>
</a-radio-group>
<a-empty v-if="!transferCandidates.length" description="暂无其他群成员" />
</a-modal>
</BasicDrawer>
</template>
<script lang="ts" setup>
import { computed, reactive, ref } from 'vue';
import { BasicDrawer, useDrawerInner } from '/@/components/Drawer';
import { useModal } from '/@/components/Modal';
import { Icon } from '/@/components/Icon';
import { useMessage } from '/@/hooks/web/useMessage';
import { getFileAccessHttpUrl } from '/@/utils/common/compUtils';
import {
fetchGroupDetail,
removeGroupMember,
renameGroup,
transferGroupOwner,
quitGroup,
dismissGroup,
type ImGroupDetail,
type ImGroupMember,
} from './im.api';
import ImGroupAddMemberModal from './ImGroupAddMemberModal.vue';
defineOptions({ name: 'ImGroupSettingDrawer' });
const emit = defineEmits<{
/** 群信息变更(改名/加人/踢人/转让),父组件据此刷新会话 */
(e: 'changed', payload: { conversationId: string; groupName?: string; memberCount?: number }): void;
/** 退出或解散群聊,父组件据此移除会话并清理当前会话 */
(e: 'exited', conversationId: string): void;
}>();
const { createMessage, createConfirm } = useMessage();
const loading = ref(false);
const conversationId = ref('');
const detail = reactive<ImGroupDetail>({
conversationId: '',
groupName: '',
ownerId: '',
memberCount: 0,
owner: false,
members: [],
});
const removeMode = ref(false);
// 改名
const nameModalVisible = ref(false);
const editingName = ref('');
const nameSaving = ref(false);
// 转让群主
const transferModalVisible = ref(false);
const transferTargetId = ref('');
const transferSaving = ref(false);
const transferCandidates = computed(() => detail.members.filter((m) => !m.owner));
const [registerAddMemberModal, { openModal: openAddMemberModal }] = useModal();
const [registerDrawer, { setDrawerProps }] = useDrawerInner(async (data?: { conversationId?: string }) => {
removeMode.value = false;
conversationId.value = data?.conversationId || '';
if (!conversationId.value) {
return;
}
setDrawerProps({ loading: false });
await loadDetail();
});
function getAvatarUrl(avatar?: string) {
return avatar ? getFileAccessHttpUrl(avatar) : '';
}
function applyDetail(data: ImGroupDetail) {
detail.conversationId = data.conversationId;
detail.groupName = data.groupName;
detail.ownerId = data.ownerId;
detail.memberCount = data.memberCount;
detail.owner = data.owner;
detail.members = data.members || [];
}
async function loadDetail() {
loading.value = true;
try {
const data = await fetchGroupDetail(conversationId.value);
applyDetail(data);
} finally {
loading.value = false;
}
}
function notifyChanged() {
emit('changed', {
conversationId: conversationId.value,
groupName: detail.groupName,
memberCount: detail.memberCount,
});
}
function handleOpenAddMember() {
openAddMemberModal(true, {
conversationId: conversationId.value,
existMemberIds: detail.members.map((m) => m.userId),
});
}
async function onMembersChanged() {
await loadDetail();
notifyChanged();
}
function toggleRemoveMode() {
removeMode.value = !removeMode.value;
}
function handleRemoveMember(member: ImGroupMember) {
createConfirm({
iconType: 'warning',
title: '移除成员',
content: `确定将「${member.realname || member.username}」移出群聊吗?`,
onOk: async () => {
await removeGroupMember(conversationId.value, member.userId);
createMessage.success('已移除');
await loadDetail();
notifyChanged();
},
});
}
function handleEditName() {
if (!detail.owner) {
return;
}
editingName.value = detail.groupName || '';
nameModalVisible.value = true;
}
async function submitRename() {
const name = editingName.value.trim();
if (!name) {
createMessage.warning('请输入群名称');
return;
}
nameSaving.value = true;
try {
await renameGroup(conversationId.value, name);
detail.groupName = name;
nameModalVisible.value = false;
createMessage.success('修改成功');
notifyChanged();
} finally {
nameSaving.value = false;
}
}
function handleOpenTransfer() {
transferTargetId.value = '';
transferModalVisible.value = true;
}
async function submitTransfer() {
if (!transferTargetId.value) {
createMessage.warning('请选择新群主');
return;
}
transferSaving.value = true;
try {
await transferGroupOwner(conversationId.value, transferTargetId.value);
transferModalVisible.value = false;
createMessage.success('转让成功');
await loadDetail();
notifyChanged();
} finally {
transferSaving.value = false;
}
}
function handleQuit() {
createConfirm({
iconType: 'warning',
title: '退出群聊',
content: '退出后将不再接收该群消息,确定退出吗?',
onOk: async () => {
await quitGroup(conversationId.value);
createMessage.success('已退出群聊');
emit('exited', conversationId.value);
},
});
}
function handleDismiss() {
createConfirm({
iconType: 'warning',
title: '解散群聊',
content: '解散后群聊将被删除且无法恢复,确定解散吗?',
onOk: async () => {
await dismissGroup(conversationId.value);
createMessage.success('群聊已解散');
emit('exited', conversationId.value);
},
});
}
</script>
<style lang="less" scoped>
.group-setting {
padding: 4px 4px 16px;
}
.section {
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid #f0f0f0;
}
.section-title {
font-size: 13px;
color: #888;
margin-bottom: 12px;
.member-count {
color: #aaa;
}
}
.member-grid {
display: flex;
flex-wrap: wrap;
gap: 12px 8px;
}
.member-cell {
width: 56px;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.member-avatar-wrap {
position: relative;
}
.owner-tag {
position: absolute;
left: 50%;
bottom: -4px;
transform: translateX(-50%);
font-size: 10px;
line-height: 14px;
padding: 0 4px;
color: #fff;
background: #fa8c16;
border-radius: 7px;
white-space: nowrap;
}
.remove-badge {
position: absolute;
top: -4px;
right: -4px;
width: 18px;
height: 18px;
display: inline-flex;
align-items: center;
justify-content: center;
background: #ff4d4f;
color: #fff;
border-radius: 50%;
font-size: 12px;
cursor: pointer;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
}
.member-action-btn {
width: 44px;
height: 44px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px dashed #d9d9d9;
border-radius: 6px;
color: #999;
font-size: 18px;
cursor: pointer;
transition: all 0.15s;
&:hover {
border-color: #1677ff;
color: #1677ff;
}
&.active {
border-color: #ff4d4f;
color: #ff4d4f;
}
}
.member-name {
font-size: 12px;
color: #595959;
max-width: 56px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: center;
}
.setting-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 28px;
&.is-clickable {
cursor: pointer;
}
}
.row-label {
font-size: 14px;
color: #262626;
flex-shrink: 0;
}
.row-value {
font-size: 13px;
color: #888;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: inline-flex;
align-items: center;
gap: 4px;
}
.row-arrow {
color: #c0c0c0;
font-size: 12px;
}
.footer-actions {
margin-top: 24px;
}
.transfer-tip {
color: #888;
font-size: 13px;
margin-bottom: 12px;
}
.transfer-list {
display: flex;
flex-direction: column;
gap: 8px;
max-height: 320px;
overflow-y: auto;
width: 100%;
}
.transfer-item {
display: inline-flex;
align-items: center;
:deep(.ant-radio + span) {
display: inline-flex;
align-items: center;
gap: 8px;
}
}
.transfer-name {
font-size: 13px;
}
</style>
<!--update-end---author:cursor ---date:20260529 forIM聊天-OA群设置抽屉----------->

View File

@@ -8,6 +8,15 @@ enum Api {
read = '/sys/im/chat/read',
groups = '/sys/im/chat/groups',
groupCreate = '/sys/im/chat/group/create',
//update-begin---author:cursor ---date:20260529 for【IM聊天-OA】群设置接口-----------
groupDetail = '/sys/im/chat/group/detail',
groupAddMembers = '/sys/im/chat/group/addMembers',
groupRemoveMember = '/sys/im/chat/group/removeMember',
groupRename = '/sys/im/chat/group/rename',
groupTransfer = '/sys/im/chat/group/transfer',
groupQuit = '/sys/im/chat/group/quit',
groupDismiss = '/sys/im/chat/group/dismiss',
//update-end---author:cursor ---date:20260529 for【IM聊天-OA】群设置接口-----------
}
export interface ImGroupConversation {
@@ -57,3 +66,51 @@ export const fetchGroups = () => defHttp.get<ImGroupConversation[]>({ url: Api.g
export const createGroupConversation = (data: { groupName: string; memberUserIds: string[] }) =>
defHttp.post<ImGroupConversation>({ url: Api.groupCreate, data });
//update-begin---author:cursor ---date:20260529 for【IM聊天-OA】群设置接口-----------
export interface ImGroupMember {
userId: string;
realname?: string;
username?: string;
avatar?: string;
owner?: boolean;
}
export interface ImGroupDetail {
conversationId: string;
groupName?: string;
ownerId?: string;
memberCount?: number;
/** 当前登录用户是否群主 */
owner?: boolean;
members: ImGroupMember[];
}
/** 群聊详情(含成员列表) */
export const fetchGroupDetail = (conversationId: string) =>
defHttp.get<ImGroupDetail>({ url: Api.groupDetail, params: { conversationId } });
/** 添加群成员 */
export const addGroupMembers = (data: { conversationId: string; memberUserIds: string[] }) =>
defHttp.post<ImGroupConversation>({ url: Api.groupAddMembers, data });
/** 移除群成员(仅群主) */
export const removeGroupMember = (conversationId: string, memberUserId: string) =>
defHttp.post({ url: Api.groupRemoveMember, params: { conversationId, memberUserId } }, { joinParamsToUrl: true });
/** 修改群名称(仅群主) */
export const renameGroup = (conversationId: string, groupName: string) =>
defHttp.post<ImGroupConversation>({ url: Api.groupRename, params: { conversationId, groupName } }, { joinParamsToUrl: true });
/** 转让群主(仅群主) */
export const transferGroupOwner = (conversationId: string, newOwnerId: string) =>
defHttp.post({ url: Api.groupTransfer, params: { conversationId, newOwnerId } }, { joinParamsToUrl: true });
/** 退出群聊(非群主成员) */
export const quitGroup = (conversationId: string) =>
defHttp.post({ url: Api.groupQuit, params: { conversationId } }, { joinParamsToUrl: true });
/** 解散群聊(仅群主) */
export const dismissGroup = (conversationId: string) =>
defHttp.post({ url: Api.groupDismiss, params: { conversationId } }, { joinParamsToUrl: true });
//update-end---author:cursor ---date:20260529 for【IM聊天-OA】群设置接口-----------