feat: 新增用户管理页面

This commit is contained in:
sunlei 2026-06-03 16:06:45 +08:00
parent aec8444121
commit 768bde6e14
9 changed files with 702 additions and 0 deletions

View File

@ -63,6 +63,10 @@ const SUPPORTED_ADMIN_MENU_NAMES = new Set([
'SystemRoleCreate',
'SystemRoleDelete',
'SystemRoleEdit',
'SystemUser',
'SystemUserCreate',
'SystemUserDelete',
'SystemUserEdit',
]);
export function isSupportedAdminMenuName(name?: null | string | symbol) {

View File

@ -1,3 +1,4 @@
export * from './dept';
export * from './menu';
export * from './role';
export * from './user';

View File

@ -0,0 +1,74 @@
import type { Recordable } from '@vben/types';
import { requestClient } from '#/api/request';
export namespace SystemUserApi {
export interface SystemUser {
[key: string]: any;
createTime?: string;
dept?: null | {
id: string;
name: string;
};
deptId?: null | string;
deptName?: string;
homePath: string;
id: string;
password?: string;
realName: string;
roleIds: string[];
roleNames: string[];
roles?: Array<{
id: string;
name: string;
roleCode: string;
status: 0 | 1;
}>;
status: 0 | 1;
timezone: string;
updateTime?: string;
username: string;
}
export type SystemUserInput = Partial<Omit<SystemUser, 'id' | 'roles'>> & {
roleIds?: string[];
};
}
/**
*
* @param params
*/
async function getUserList(params: Recordable<any>) {
return requestClient.get<Array<SystemUserApi.SystemUser>>(
'/system/user/list',
{ params },
);
}
/**
*
* @param data
*/
async function createUser(data: SystemUserApi.SystemUserInput) {
return requestClient.post('/system/user', data);
}
/**
*
* @param id ID
* @param data
*/
async function updateUser(id: string, data: SystemUserApi.SystemUserInput) {
return requestClient.put(`/system/user/${id}`, data);
}
/**
*
* @param id ID
*/
async function deleteUser(id: string) {
return requestClient.delete(`/system/user/${id}`);
}
export { createUser, deleteUser, getUserList, updateUser };

View File

@ -1,5 +1,22 @@
{
"title": "System Management",
"user": {
"allDept": "All",
"createTime": "Create Time",
"dept": "Department",
"deptTree": "Department Tree",
"homePath": "Home Path",
"list": "User List",
"name": "User",
"password": "Password",
"passwordPlaceholder": "Leave blank when editing to keep the current password; blank new users default to 123456",
"realName": "Real Name",
"roles": "Roles",
"status": "Status",
"timezone": "Timezone",
"title": "User Management",
"username": "Username"
},
"dept": {
"name": "Department",
"title": "Department Management",

View File

@ -1,5 +1,22 @@
{
"title": "系统管理",
"user": {
"allDept": "全部",
"createTime": "创建时间",
"dept": "所属部门",
"deptTree": "部门树",
"homePath": "首页路径",
"list": "用户列表",
"name": "用户",
"password": "密码",
"passwordPlaceholder": "编辑时留空则不修改密码;新增留空默认 123456",
"realName": "真实姓名",
"roles": "角色",
"status": "状态",
"timezone": "时区",
"title": "用户管理",
"username": "用户名"
},
"dept": {
"list": "部门列表",
"createTime": "创建时间",

View File

@ -12,6 +12,15 @@ const routes: RouteRecordRaw[] = [
name: 'System',
path: '/system',
children: [
{
path: '/system/user',
name: 'SystemUser',
meta: {
icon: 'mdi:account',
title: $t('system.user.title'),
},
component: () => import('#/views/system/user/list.vue'),
},
{
path: '/system/role',
name: 'SystemRole',

View File

@ -0,0 +1,150 @@
import type { VbenFormSchema } from '#/adapter/form';
import { z } from '#/adapter/form';
import { getDeptList } from '#/api/system/dept';
import { getRoleList } from '#/api/system/role';
import { $t } from '#/locales';
const statusOptions = [
{ label: $t('common.enabled'), value: 1 },
{ label: $t('common.disabled'), value: 0 },
];
async function getRoleOptions() {
const res = await getRoleList({
page: 1,
pageSize: 1000,
status: 1,
});
const items = (res as any)?.items || [];
return items.map((role: any) => ({
label: role.name,
value: role.id,
}));
}
export function useFormSchema(): VbenFormSchema[] {
return [
{
component: 'Input',
fieldName: 'username',
label: $t('system.user.username'),
rules: z
.string()
.min(2, $t('ui.formRules.minLength', [$t('system.user.username'), 2]))
.max(
30,
$t('ui.formRules.maxLength', [$t('system.user.username'), 30]),
),
},
{
component: 'Input',
componentProps: {
placeholder: $t('system.user.passwordPlaceholder'),
},
fieldName: 'password',
label: $t('system.user.password'),
},
{
component: 'Input',
fieldName: 'realName',
label: $t('system.user.realName'),
rules: z
.string()
.min(2, $t('ui.formRules.minLength', [$t('system.user.realName'), 2]))
.max(
30,
$t('ui.formRules.maxLength', [$t('system.user.realName'), 30]),
),
},
{
component: 'ApiSelect',
componentProps: {
api: getRoleOptions,
mode: 'multiple',
},
fieldName: 'roleIds',
label: $t('system.user.roles'),
rules: 'required',
},
{
component: 'ApiTreeSelect',
componentProps: {
allowClear: true,
api: getDeptList,
childrenField: 'children',
labelField: 'name',
valueField: 'id',
},
fieldName: 'deptId',
label: $t('system.user.dept'),
},
{
component: 'Input',
componentProps: {
placeholder: '/workspace',
},
defaultValue: '/workspace',
fieldName: 'homePath',
label: $t('system.user.homePath'),
},
{
component: 'Input',
componentProps: {
placeholder: 'Asia/Shanghai',
},
defaultValue: 'Asia/Shanghai',
fieldName: 'timezone',
label: $t('system.user.timezone'),
},
{
component: 'RadioGroup',
componentProps: {
buttonStyle: 'solid',
options: statusOptions,
optionType: 'button',
},
defaultValue: 1,
fieldName: 'status',
label: $t('system.user.status'),
},
];
}
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
component: 'Input',
fieldName: 'username',
label: $t('system.user.username'),
},
{
component: 'Input',
fieldName: 'realName',
label: $t('system.user.realName'),
},
{
component: 'Select',
componentProps: {
allowClear: true,
options: statusOptions,
},
fieldName: 'status',
label: $t('system.user.status'),
},
{
component: 'ApiSelect',
componentProps: {
allowClear: true,
api: getRoleOptions,
},
fieldName: 'roleId',
label: $t('system.user.roles'),
},
{
component: 'RangePicker',
fieldName: 'createTime',
label: $t('system.user.createTime'),
},
];
}

View File

@ -0,0 +1,353 @@
<script lang="ts" setup>
import type { TableColumnType } from 'antdv-next';
import type { DataNode } from 'antdv-next/dist/tree/index';
import type { SystemUserApi } from '#/api';
import type { SystemDeptApi } from '#/api/system/dept';
import type {
KtTableApi,
KtTableButton,
KtTableContext,
KtTableRowAction,
} from '#/components/ktTable';
import { computed, h, onMounted, ref } from 'vue';
import { useAccess } from '@vben/access';
import { Page, useVbenDrawer } from '@vben/common-ui';
import { Plus } from '@vben/icons';
import { Button, message, Spin, Switch, Tag, Tree } from 'antdv-next';
import { deleteUser, getUserList, updateUser } from '#/api';
import { getDeptList } from '#/api/system/dept';
import { KtTable, useKtTable } from '#/components/ktTable';
import { $t } from '#/locales';
import { useGridFormSchema } from './data';
import Form from './modules/form.vue';
const [FormDrawer, formDrawerApi] = useVbenDrawer({
connectedComponent: Form,
destroyOnClose: true,
});
const { hasAccessByCodes } = useAccess();
const deptTree = ref<SystemDeptApi.SystemDept[]>([]);
const deptLoading = ref(false);
const selectedDeptId = ref<string>();
const selectedDeptKeys = computed(() =>
selectedDeptId.value ? [selectedDeptId.value] : [],
);
const deptTreeData = computed<DataNode[]>(() => mapDeptTree(deptTree.value));
function hasPermission(code: string) {
return hasAccessByCodes([code]);
}
const columns: Array<TableColumnType<SystemUserApi.SystemUser>> = [
{
dataIndex: 'username',
fixed: 'left',
key: 'username',
title: $t('system.user.username'),
width: 180,
},
{
dataIndex: 'realName',
key: 'realName',
title: $t('system.user.realName'),
width: 160,
},
{
dataIndex: 'roleNames',
key: 'roleNames',
title: $t('system.user.roles'),
width: 220,
},
{
dataIndex: 'deptName',
key: 'deptName',
title: $t('system.user.dept'),
width: 160,
},
{
align: 'center',
dataIndex: 'status',
key: 'status',
title: $t('system.user.status'),
width: 100,
},
{
dataIndex: 'homePath',
key: 'homePath',
title: $t('system.user.homePath'),
width: 160,
},
{
dataIndex: 'timezone',
key: 'timezone',
title: $t('system.user.timezone'),
width: 180,
},
{
dataIndex: 'createTime',
key: 'createTime',
title: $t('system.user.createTime'),
width: 200,
},
];
const api: KtTableApi<SystemUserApi.SystemUser> = {
list: async (params) => {
const { pageNo, pageSize, ...formValues } = params;
return await getUserList({
page: pageNo,
pageSize,
...(selectedDeptId.value ? { deptId: selectedDeptId.value } : {}),
...formValues,
});
},
};
const buttons: Array<KtTableButton<SystemUserApi.SystemUser>> = [
{
icon: () => h(Plus, { class: 'kt-table__button-icon' }),
key: 'create',
label: $t('ui.actionTitle.create', [$t('system.user.name')]),
onClick: onCreate,
permissionCodes: ['System:User:Create'],
type: 'primary',
},
];
const rowActions: Array<KtTableRowAction<SystemUserApi.SystemUser>> = [
{
key: 'edit',
label: $t('common.edit'),
onClick: onEdit,
permissionCodes: ['System:User:Edit'],
},
{
confirm: (row) => `确认删除「${row.username}」吗?`,
danger: true,
disabled: (row) => row.username === 'admin',
key: 'delete',
label: $t('common.delete'),
onClick: onDelete,
permissionCodes: ['System:User:Delete'],
},
];
const [registerTable, tableApi] = useKtTable<SystemUserApi.SystemUser>({
api,
buttons,
columns,
formOptions: {
fieldMappingTime: [['createTime', ['startTime', 'endTime']]],
schema: useGridFormSchema(),
},
rowActions,
tableTitle: $t('system.user.list'),
});
onMounted(() => {
loadDeptTree();
});
async function loadDeptTree() {
deptLoading.value = true;
try {
deptTree.value = await getDeptList();
} finally {
deptLoading.value = false;
}
}
function onDeptSelect(keys: Array<number | string>) {
selectedDeptId.value = keys.length > 0 ? String(keys[0]) : undefined;
tableApi.reload();
}
function clearDeptFilter() {
selectedDeptId.value = undefined;
tableApi.reload();
}
function mapDeptTree(depts: SystemDeptApi.SystemDept[]): DataNode[] {
return depts.map((dept) => ({
children: dept.children ? mapDeptTree(dept.children) : undefined,
key: dept.id,
title: dept.name,
}));
}
async function onStatusSwitchChange(
checked: boolean | number | string,
row: SystemUserApi.SystemUser,
) {
const nextStatus = Number(checked) as SystemUserApi.SystemUser['status'];
if (nextStatus === row.status) return;
await updateUser(row.id, { status: nextStatus });
await tableApi.reload();
}
function onEdit(row: SystemUserApi.SystemUser) {
formDrawerApi.setData(row).open();
}
async function onDelete(
row: SystemUserApi.SystemUser,
context?: KtTableContext<SystemUserApi.SystemUser>,
) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.username]),
duration: 0,
key: 'action_process_msg',
});
try {
await deleteUser(row.id);
message.success({
content: $t('ui.actionMessage.deleteSuccess', [row.username]),
key: 'action_process_msg',
});
await (context || tableApi).reload();
} catch {
hideLoading();
}
}
function onRefresh() {
tableApi.reload();
}
function onCreate() {
formDrawerApi.setData({ deptId: selectedDeptId.value }).open();
}
</script>
<template>
<Page auto-content-height>
<FormDrawer @success="onRefresh" />
<div class="system-user-page">
<aside class="system-user-page__dept">
<div class="system-user-page__dept-header">
<span class="system-user-page__dept-title">
{{ $t('system.user.deptTree') }}
</span>
<Button size="small" type="link" @click="clearDeptFilter">
{{ $t('system.user.allDept') }}
</Button>
</div>
<Spin :spinning="deptLoading">
<Tree
block-node
:selected-keys="selectedDeptKeys"
:tree-data="deptTreeData"
default-expand-all
@select="onDeptSelect"
/>
</Spin>
</aside>
<main class="system-user-page__table">
<KtTable @register="registerTable">
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'roleNames'">
<div class="system-user-list__roles">
<Tag
v-for="roleName in record.roleNames || []"
:key="roleName"
color="processing"
>
{{ roleName }}
</Tag>
</div>
</template>
<template v-if="column.key === 'deptName'">
{{ record.deptName || '-' }}
</template>
<template v-if="column.key === 'status'">
<Switch
v-if="record && hasPermission('System:User:Edit')"
:checked="record.status"
:checked-value="1"
:un-checked-value="0"
@change="(checked) => onStatusSwitchChange(checked, record)"
/>
<Tag v-else :color="record.status === 1 ? 'success' : 'default'">
{{
record.status === 1
? $t('common.enabled')
: $t('common.disabled')
}}
</Tag>
</template>
</template>
</KtTable>
</main>
</div>
</Page>
</template>
<style lang="scss" scoped>
.system-user-page {
display: flex;
gap: 12px;
height: 100%;
min-height: 0;
&__dept {
display: flex;
flex: 0 0 240px;
flex-direction: column;
min-height: 0;
padding: 12px;
overflow: hidden;
background: hsl(var(--card));
border: 1px solid hsl(var(--border));
border-radius: 6px;
}
&__dept-header {
display: flex;
align-items: center;
justify-content: space-between;
padding-bottom: 10px;
margin-bottom: 10px;
border-bottom: 1px solid hsl(var(--border));
}
&__dept-title {
font-size: 14px;
font-weight: 600;
color: hsl(var(--foreground));
}
&__table {
min-width: 0;
min-height: 0;
flex: 1;
}
:deep(.ant-spin-nested-loading),
:deep(.ant-spin-container) {
min-height: 0;
flex: 1;
}
:deep(.ant-spin-container) {
overflow: auto;
}
}
.system-user-list {
&__roles {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
}
</style>

View File

@ -0,0 +1,77 @@
<script lang="ts" setup>
import type { SystemUserApi } from '#/api/system/user';
import { computed, ref } from 'vue';
import { useVbenDrawer } from '@vben/common-ui';
import { useVbenForm } from '#/adapter/form';
import { createUser, updateUser } from '#/api/system/user';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<SystemUserApi.SystemUser>();
const id = ref<string>();
const [Form, formApi] = useVbenForm({
schema: useFormSchema(),
showDefaultActions: false,
});
const [Drawer, drawerApi] = useVbenDrawer({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) return;
const values = await formApi.getValues();
if (id.value && !values.password) {
delete values.password;
}
drawerApi.lock();
try {
await (id.value ? updateUser(id.value, values) : createUser(values));
emit('success');
drawerApi.close();
} finally {
drawerApi.lock(false);
}
},
onOpenChange(isOpen) {
if (!isOpen) return;
const data = drawerApi.getData<SystemUserApi.SystemUser>();
formData.value = data || undefined;
id.value = data?.id;
formApi.resetForm();
formApi.setValues({
...data,
homePath: data?.homePath || '/workspace',
password: '',
status: data?.status ?? 1,
timezone: data?.timezone || 'Asia/Shanghai',
});
},
});
const getDrawerTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', [$t('system.user.name')])
: $t('ui.actionTitle.create', [$t('system.user.name')]);
});
</script>
<template>
<Drawer :title="getDrawerTitle">
<Form class="system-user-form" />
</Drawer>
</template>
<style lang="scss" scoped>
.system-user-form {
padding: 0 8px;
}
</style>