feat(@vben/web-antd): 项目成员管理改 Drawer + 分页 + 增量

从 Modal 多选改为 Drawer 分页表,更接近"成员管理"语义:
- 原 assign-user-form.vue 重写为 Drawer + Vxe 分页表
- 新增 add-user-modal.vue 子弹窗用于添加用户(过滤已是成员)
- 每行一个"移除"popConfirm 按钮,调 removeProjectUser 单删
- 顶部 keyword 搜索,按 username/nickname/mobile 模糊
- 底部提示:超管不在此列表(后端已过滤)
- data.ts 新增 useProjectMemberGridColumns
- api 新增 getProjectUserPage / addProjectUsers / removeProjectUser
- project/index.vue 接入点改 useVbenDrawer

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
lzh
2026-04-23 15:48:43 +08:00
parent b15b6b4f4d
commit 09538b03cb
5 changed files with 342 additions and 78 deletions

View File

@@ -1,3 +1,7 @@
import type { PageParam, PageResult } from '@vben/request';
import type { SystemUserApi } from '#/api/system/user';
import { requestClient } from '#/api/request';
export namespace SystemUserProjectApi {
@@ -9,6 +13,14 @@ export namespace SystemUserProjectApi {
projectId: number;
userIds: number[];
}
export interface AddProjectUsersReq {
projectId: number;
userIds: number[];
}
export interface ProjectUserPageReq extends PageParam {
projectId: number;
keyword?: string;
}
}
/** 给用户覆盖式分配项目 */
@@ -44,3 +56,26 @@ export function getUserIdsByProjectId(projectId: number) {
`/system/user-project/list-user-ids-by-project?projectId=${projectId}`,
);
}
/** 分页查询项目成员(已自动过滤超级管理员) */
export function getProjectUserPage(params: SystemUserProjectApi.ProjectUserPageReq) {
return requestClient.get<PageResult<SystemUserApi.User>>(
'/system/user-project/project-user-page',
{ params },
);
}
/** 增量给项目添加成员 */
export function addProjectUsers(data: SystemUserProjectApi.AddProjectUsersReq) {
return requestClient.post<boolean>(
'/system/user-project/add-project-users',
data,
);
}
/** 从项目中移除单个成员 */
export function removeProjectUser(projectId: number, userId: number) {
return requestClient.delete<boolean>(
`/system/user-project/remove-project-user?projectId=${projectId}&userId=${userId}`,
);
}

View File

@@ -84,7 +84,53 @@ export function useFormSchema(): VbenFormSchema[] {
];
}
/** 管理成员的表单(项目 → 多用户) */
/** 项目成员管理抽屉的表格列 */
export function useProjectMemberGridColumns(): VxeTableGridOptions['columns'] {
return [
{
field: 'id',
title: '用户编号',
minWidth: 90,
},
{
field: 'username',
title: '用户名',
minWidth: 120,
},
{
field: 'nickname',
title: '昵称',
minWidth: 120,
},
{
field: 'mobile',
title: '手机号',
minWidth: 120,
},
{
field: 'deptId',
title: '部门编号',
minWidth: 90,
},
{
field: 'status',
title: '状态',
minWidth: 90,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.COMMON_STATUS },
},
},
{
title: '操作',
width: 100,
fixed: 'right',
slots: { default: 'actions' },
},
];
}
/** 管理成员的表单(项目 → 多用户)— 保留给未来"覆盖写入"场景,当前 UI 已改抽屉 */
export function useAssignUserFormSchema(): VbenFormSchema[] {
return [
{

View File

@@ -4,7 +4,7 @@ import type { SystemProjectApi } from '#/api/system/project';
import { ref } from 'vue';
import { confirm, Page, useVbenModal } from '@vben/common-ui';
import { confirm, Page, useVbenDrawer, useVbenModal } from '@vben/common-ui';
import { isEmpty } from '@vben/utils';
import { message } from 'ant-design-vue';
@@ -14,7 +14,7 @@ import { deleteProject, getProjectPage } from '#/api/system/project';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import AssignUserForm from './modules/assign-user-form.vue';
import AssignUserDrawer from './modules/assign-user-form.vue';
import Form from './modules/form.vue';
const [FormModal, formModalApi] = useVbenModal({
@@ -22,8 +22,8 @@ const [FormModal, formModalApi] = useVbenModal({
destroyOnClose: true,
});
const [AssignUserModal, assignUserModalApi] = useVbenModal({
connectedComponent: AssignUserForm,
const [MemberDrawer, memberDrawerApi] = useVbenDrawer({
connectedComponent: AssignUserDrawer,
destroyOnClose: true,
});
@@ -44,7 +44,7 @@ function handleEdit(row: SystemProjectApi.Project) {
/** 管理成员 */
function handleAssignUser(row: SystemProjectApi.Project) {
assignUserModalApi.setData(row).open();
memberDrawerApi.setData(row).open();
}
/** 删除项目 */
@@ -125,7 +125,7 @@ const [Grid, gridApi] = useVbenVxeGrid({
<template>
<Page auto-content-height>
<FormModal @success="handleRefresh" />
<AssignUserModal @success="handleRefresh" />
<MemberDrawer @success="handleRefresh" />
<Grid table-title="项目列表">
<template #toolbar-tools>
<TableAction

View File

@@ -0,0 +1,106 @@
<script lang="ts" setup>
import type { SystemUserApi } from '#/api/system/user';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { message, Select, Spin } from 'ant-design-vue';
import { getSimpleUserList } from '#/api/system/user';
import { addProjectUsers } from '#/api/system/user-project';
import { $t } from '#/locales';
interface AddUserModalData {
projectId: number;
projectName?: string;
excludedUserIds: number[];
}
const emit = defineEmits(['success']);
const allUsers = ref<SystemUserApi.User[]>([]);
const selectedUserIds = ref<number[]>([]);
const loading = ref(false);
const currentData = ref<AddUserModalData | null>(null);
/** 可选项:全量用户 - 已是成员的用户 */
const selectOptions = computed(() => {
const excluded = new Set(currentData.value?.excludedUserIds ?? []);
return allUsers.value
.filter((u) => u.id && !excluded.has(u.id))
.map((u) => ({
value: u.id,
label: `${u.nickname || u.username}${u.username}`,
}));
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
if (!currentData.value?.projectId) return;
if (selectedUserIds.value.length === 0) {
message.warning('请至少选择一个用户');
return;
}
modalApi.lock();
try {
await addProjectUsers({
projectId: currentData.value.projectId,
userIds: selectedUserIds.value,
});
message.success($t('ui.actionMessage.operationSuccess'));
await modalApi.close();
emit('success');
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
allUsers.value = [];
selectedUserIds.value = [];
currentData.value = null;
return;
}
const data = modalApi.getData<AddUserModalData>();
if (!data) return;
currentData.value = data;
loading.value = true;
try {
allUsers.value = await getSimpleUserList();
} finally {
loading.value = false;
}
},
});
</script>
<template>
<Modal
:title="
currentData?.projectName
? `添加用户到项目 - ${currentData.projectName}`
: '添加用户'
"
class="w-[560px]"
>
<Spin :spinning="loading">
<div class="px-4 py-2">
<div class="mb-2 text-sm">选择要加入项目的用户已在项目中的用户会自动过滤</div>
<Select
v-model:value="selectedUserIds"
mode="multiple"
placeholder="请选择用户"
:options="selectOptions"
:filter-option="
(input: string, option: any) =>
option.label.toLowerCase().includes(input.toLowerCase())
"
show-search
allow-clear
class="w-full"
/>
</div>
</Spin>
</Modal>
</template>

View File

@@ -1,98 +1,175 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { SystemProjectApi } from '#/api/system/project';
import type { SystemUserApi } from '#/api/system/user';
import { useVbenModal } from '@vben/common-ui';
import { ref } from 'vue';
import { message, Modal as AntModal } from 'ant-design-vue';
import { useVbenDrawer, useVbenModal } from '@vben/common-ui';
import { useVbenForm } from '#/adapter/form';
import { Button, Input, message } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
assignProjectUsers,
getProjectUserPage,
getUserIdsByProjectId,
removeProjectUser,
} from '#/api/system/user-project';
import { $t } from '#/locales';
import { useAssignUserFormSchema } from '../data';
import AddUserModal from './add-user-modal.vue';
import { useProjectMemberGridColumns } from '../data';
const emit = defineEmits(['success']);
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
formItemClass: 'col-span-2',
labelWidth: 80,
},
layout: 'horizontal',
schema: useAssignUserFormSchema(),
showDefaultActions: false,
/** 当前打开抽屉的项目 */
const currentProject = ref<SystemProjectApi.Project | null>(null);
/** 搜索关键字 */
const keyword = ref<string>('');
/** 添加用户子弹窗 */
const [AddUserModalCmp, addUserModalApi] = useVbenModal({
connectedComponent: AddUserModal,
destroyOnClose: true,
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
const values = await formApi.getValues();
const userIds: number[] = values.userIds ?? [];
/** 移除单个成员 */
async function handleRemove(row: SystemUserApi.User) {
if (!currentProject.value?.id || !row.id) return;
const hideLoading = message.loading({
content: `正在从项目中移除 ${row.nickname || row.username}...`,
duration: 0,
});
try {
await removeProjectUser(currentProject.value.id, row.id);
message.success($t('ui.actionMessage.operationSuccess'));
gridApi.query();
} finally {
hideLoading();
}
}
// 空集二次确认:清空该项目所有成员是高危操作
if (userIds.length === 0) {
const confirmed = await new Promise<boolean>((resolve) => {
AntModal.confirm({
title: '确认清空项目成员?',
content: `即将清空项目【${values.name}】的所有成员,保存后除超管外所有用户都将无法访问该项目。确认继续?`,
okText: '确认清空',
okType: 'danger',
cancelText: '取消',
onOk: () => resolve(true),
onCancel: () => resolve(false),
});
});
if (!confirmed) {
return;
}
}
/** 打开添加用户弹窗 */
async function handleOpenAddUser() {
if (!currentProject.value?.id) return;
// 把当前已是成员的 userIds 传给子 Modal 用于过滤
const excludedIds = await getUserIdsByProjectId(currentProject.value.id);
addUserModalApi
.setData({
projectId: currentProject.value.id,
projectName: currentProject.value.name,
excludedUserIds: excludedIds,
})
.open();
}
modalApi.lock();
try {
await assignProjectUsers({
projectId: values.id,
userIds,
});
await modalApi.close();
emit('success');
message.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
/** 子弹窗保存后刷新表格 */
function handleAddUserSuccess() {
gridApi.query();
emit('success');
}
const [Grid, gridApi] = useVbenVxeGrid({
gridOptions: {
columns: useProjectMemberGridColumns(),
height: 'auto',
keepSource: true,
pagerConfig: {
pageSize: 10,
},
proxyConfig: {
ajax: {
query: async ({ page }) => {
if (!currentProject.value?.id) {
return { items: [], total: 0 };
}
return await getProjectUserPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
projectId: currentProject.value.id,
keyword: keyword.value || undefined,
});
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: true,
},
} as VxeTableGridOptions<SystemUserApi.User>,
});
function handleSearch() {
gridApi.query();
}
const [Drawer, drawerApi] = useVbenDrawer({
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
currentProject.value = null;
keyword.value = '';
return;
}
const data = modalApi.getData<SystemProjectApi.Project>();
if (!data || !data.id) {
return;
}
modalApi.lock();
try {
const userIds = await getUserIdsByProjectId(data.id);
await formApi.setValues({
...data,
userIds,
});
} finally {
modalApi.unlock();
}
const data = drawerApi.getData<SystemProjectApi.Project>();
if (!data || !data.id) return;
currentProject.value = data;
// 等 currentProject 赋值后再 query
gridApi.query();
},
});
</script>
<template>
<Modal title="管理成员">
<Form class="mx-4" />
</Modal>
<Drawer
class="w-[800px]"
:title="currentProject ? `项目成员管理 - ${currentProject.name}` : '项目成员管理'"
:show-cancel-button="false"
:show-confirm-button="false"
>
<AddUserModalCmp @success="handleAddUserSuccess" />
<div class="mb-3 flex items-center gap-2">
<Input
v-model:value="keyword"
placeholder="搜索用户名 / 昵称 / 手机号"
allow-clear
class="w-64"
@press-enter="handleSearch"
/>
<Button type="primary" @click="handleSearch">搜索</Button>
<Button type="primary" @click="handleOpenAddUser">
<template #icon>
<span class="iconify ant-design--plus-outlined" />
</template>
添加用户
</Button>
</div>
<Grid>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: '移除',
type: 'link',
danger: true,
icon: ACTION_ICON.DELETE,
auth: ['system:project:assign-user'],
popConfirm: {
title: `确认将 ${row.nickname || row.username} 从项目中移除?`,
confirm: handleRemove.bind(null, row),
},
},
]"
/>
</template>
</Grid>
<div class="mt-3 text-xs text-gray-500">
提示超级管理员通过角色天然拥有所有项目权限不会出现在此列表中
</div>
</Drawer>
</template>