feat: 对齐管理员菜单排序与默认首页

This commit is contained in:
sunlei 2026-06-29 17:01:20 +08:00
parent 828a321bdb
commit 7056663fe0
9 changed files with 184 additions and 30 deletions

2
API.md
View File

@ -149,6 +149,8 @@ QQBot 插件 worker 队列依赖 Redis。K8s 生产清单提供内部 Redis Serv
| `PUT` | `/system/user/:id` | 更新用户 |
| `DELETE` | `/system/user/:id` | 删除用户 |
系统菜单实体包含 `sort` 字段;菜单树输出按 `meta.order` 优先,其次按 `sort` 升序排列。Admin 菜单管理页面维护 `sort`,不要把普通菜单排序写进隐藏的 route meta。
### Dict
| 方法 | 路径 | 说明 |

View File

@ -7,7 +7,6 @@ UPDATE `admin_menu`
SET `meta` = CASE `name`
WHEN 'Dashboard' THEN '{"order":-1,"title":"page.dashboard.title"}'
WHEN 'Analytics' THEN '{"affixTab":true,"title":"page.dashboard.analytics"}'
WHEN 'Workspace' THEN '{"icon":"carbon:workspace","title":"page.dashboard.workspace"}'
WHEN 'System' THEN '{"badge":"new","badgeType":"normal","badgeVariants":"primary","icon":"carbon:settings","order":9997,"title":"system.title"}'
WHEN 'SystemRole' THEN '{"icon":"carbon:user-role","title":"system.role.title"}'
WHEN 'SystemRoleCreate' THEN '{"title":"common.create"}'
@ -34,7 +33,6 @@ END
WHERE `name` IN (
'Dashboard',
'Analytics',
'Workspace',
'System',
'SystemRole',
'SystemRoleCreate',

View File

@ -47,16 +47,16 @@ INSERT INTO admin_user (
status
) VALUES (
2041700000000000002,
'admin',
'kwitsukasa',
'123456',
'Admin',
'KwiTsukasa',
'',
2041700000000200001,
'/workspace',
'/analytics',
'Asia/Shanghai',
1
) ON DUPLICATE KEY UPDATE
password = VALUES(password),
username = VALUES(username),
real_name = VALUES(real_name),
avatar = VALUES(avatar),
dept_id = VALUES(dept_id),
@ -84,7 +84,7 @@ INSERT INTO admin_menu (
'Dashboard',
'/dashboard',
NULL,
'/workspace',
'/analytics',
NULL,
'catalog',
'{"order":-1,"title":"page.dashboard.title"}',
@ -92,15 +92,15 @@ INSERT INTO admin_menu (
0
),
(
2041700000000100102,
2041700000000100101,
2041700000000100001,
'Workspace',
'/workspace',
'/dashboard/workspace/index',
'Analytics',
'/analytics',
'/dashboard/analytics/index',
NULL,
NULL,
'menu',
'{"icon":"carbon:workspace","title":"page.dashboard.workspace"}',
'{"affixTab":true,"title":"page.dashboard.analytics"}',
1,
0
),

View File

@ -210,7 +210,6 @@ INSERT INTO `admin_menu` (`id`, `pid`, `name`, `path`, `component`, `redirect`,
VALUES
(2041700000000100001, 0, 'Dashboard', '/dashboard', NULL, '/analytics', NULL, 'catalog', '{"order":-1,"title":"page.dashboard.title"}', 1, 0),
(2041700000000100101, 2041700000000100001, 'Analytics', '/analytics', '/dashboard/analytics/index', NULL, NULL, 'menu', '{"affixTab":true,"title":"page.dashboard.analytics"}', 1, 0),
(2041700000000100102, 2041700000000100001, 'Workspace', '/workspace', '/dashboard/workspace/index', NULL, NULL, 'menu', '{"icon":"carbon:workspace","title":"page.dashboard.workspace"}', 1, 0),
(2041700000000100002, 0, 'System', '/system', NULL, NULL, NULL, 'catalog', '{"badge":"new","badgeType":"normal","badgeVariants":"primary","icon":"carbon:settings","order":9997,"title":"system.title"}', 1, 9997),
(2041700000000100199, 2041700000000100002, 'SystemUser', '/system/user', '/system/user/list', NULL, 'System:User:List', 'menu', '{"icon":"carbon:user-profile","title":"system.user.title"}', 1, 0),
(2041700000000120191, 2041700000000100199, 'SystemUserCreate', NULL, NULL, NULL, 'System:User:Create', 'button', '{"title":"common.create"}', 1, 0),
@ -277,7 +276,7 @@ ON DUPLICATE KEY UPDATE
INSERT INTO `admin_role` (`id`, `role_code`, `name`, `remark`, `status`)
VALUES
(2041700000000010001, 'super', '超级管理员', '拥有所有后台权限', 1),
(2041700000000010002, 'admin', '管理员', '拥有系统管理与工作台权限', 1),
(2041700000000010002, 'admin', '管理员', '拥有系统管理与环境总览权限', 1),
(2041700000000010003, 'user', '普通用户', '仅拥有基础查看权限', 1)
ON DUPLICATE KEY UPDATE
`name` = VALUES(`name`),
@ -287,11 +286,9 @@ ON DUPLICATE KEY UPDATE
INSERT INTO `admin_user` (`id`, `username`, `password`, `real_name`, `avatar`, `dept_id`, `home_path`, `timezone`, `status`)
VALUES
(2041700000000000001, 'vben', '123456', 'Vben', '', 2041700000000200002, '/workspace', 'Asia/Shanghai', 1),
(2041700000000000002, 'admin', '123456', 'Admin', '', 2041700000000200001, '/workspace', 'Asia/Shanghai', 1),
(2041700000000000003, 'jack', '123456', 'Jack', '', 2041700000000200003, '/analytics', 'Asia/Shanghai', 1)
(2041700000000000002, 'kwitsukasa', '123456', 'KwiTsukasa', '', 2041700000000200001, '/analytics', 'Asia/Shanghai', 1)
ON DUPLICATE KEY UPDATE
`password` = VALUES(`password`),
`username` = VALUES(`username`),
`real_name` = VALUES(`real_name`),
`dept_id` = VALUES(`dept_id`),
`home_path` = VALUES(`home_path`),
@ -331,16 +328,12 @@ ON DUPLICATE KEY UPDATE
DELETE FROM `admin_user_role`
WHERE `user_id` IN (
2041700000000000001,
2041700000000000002,
2041700000000000003
2041700000000000002
);
INSERT INTO `admin_user_role` (`user_id`, `role_id`)
VALUES
(2041700000000000001, 2041700000000010001),
(2041700000000000002, 2041700000000010002),
(2041700000000000003, 2041700000000010003);
(2041700000000000002, 2041700000000010002);
DELETE FROM `admin_role_menu`
WHERE `role_id` IN (

View File

@ -210,6 +210,7 @@ export class AdminMenuService {
pid: data.pid || '0',
redirect: data.redirect || null,
status: data.status ?? 1,
sort: Number(data.sort ?? 0),
type: data.type || 'menu',
};
if (includeEmptyMeta || Object.keys(meta).length > 0) {
@ -321,6 +322,7 @@ export class AdminMenuService {
pid: menu.pid || '0',
redirect: menu.redirect,
status: menu.status,
sort: menu.sort,
type: menu.type,
} as any;

View File

@ -10,6 +10,9 @@ import type {
AdminUserListQuery,
} from '../../contract/admin.types';
const BUILTIN_ADMIN_USER_ID = '2041700000000000002';
const DEFAULT_ADMIN_HOME_PATH = '/analytics';
@Injectable()
export class AdminUserService {
/**
@ -97,7 +100,7 @@ export class AdminUserService {
const user = this.userRepository.create({
deptId: data.deptId || null,
homePath: data.homePath || '/workspace',
homePath: data.homePath || DEFAULT_ADMIN_HOME_PATH,
password: data.password || '123456',
realName: data.realName,
status: data.status ?? 1,
@ -156,7 +159,11 @@ export class AdminUserService {
if (id === currentUserId) {
throwVbenError('不能删除当前登录用户', HttpStatus.BAD_REQUEST);
}
if (user.username === 'admin') {
if (
user.id === BUILTIN_ADMIN_USER_ID ||
user.username === 'admin' ||
user.username === 'kwitsukasa'
) {
throwVbenError('不能删除内置管理员账号', HttpStatus.BAD_REQUEST);
}
@ -183,7 +190,8 @@ export class AdminUserService {
user.realName = realName;
}
if (data.homePath !== undefined) {
user.homePath = String(data.homePath || '').trim() || '/workspace';
user.homePath =
String(data.homePath || '').trim() || DEFAULT_ADMIN_HOME_PATH;
}
if (data.avatar !== undefined) {
user.avatar = String(data.avatar || '').trim();

View File

@ -65,6 +65,75 @@ describe('AdminMenuService', () => {
expect.arrayContaining(['Blog:Theme:List', 'Blog:Theme:Import']),
);
});
it('returns and orders menus by the persisted sort field', async () => {
const service = new AdminMenuService({
find: jest.fn(async () => [
createMenu({
id: '2',
meta: { title: 'Second' },
name: 'Second',
path: '/second',
sort: 20,
type: 'menu',
}),
createMenu({
id: '1',
meta: { title: 'First' },
name: 'First',
path: '/first',
sort: 10,
type: 'menu',
}),
]),
} as any);
const user = {
roles: [
{
isDeleted: false,
roleCode: 'super',
status: 1,
},
],
} as AdminUser;
await expect(service.getRouteMenus(user)).resolves.toEqual([
expect.objectContaining({ name: 'First', sort: 10 }),
expect.objectContaining({ name: 'Second', sort: 20 }),
]);
});
it('persists menu sort values from create and update inputs', async () => {
const repository = {
create: jest.fn((input) => input),
save: jest.fn(),
update: jest.fn(),
};
const service = new AdminMenuService(repository as any);
await service.createMenu({
name: 'SortedMenu',
path: '/sorted',
sort: 8,
type: 'menu',
} as any);
await service.updateMenu('menu-1', {
name: 'SortedMenu',
path: '/sorted',
sort: 9,
type: 'menu',
} as any);
expect(repository.create).toHaveBeenCalledWith(
expect.objectContaining({ sort: 8 }),
);
expect(repository.update).toHaveBeenCalledWith(
{
id: 'menu-1',
},
expect.objectContaining({ sort: 9 }),
);
});
});
/**

View File

@ -10,6 +10,10 @@ describe('admin user avatar sql', () => {
join(process.cwd(), 'sql/vben-admin-init.sql'),
'utf8',
);
const refactorSeedSql = readFileSync(
join(process.cwd(), 'sql/refactor-v3/01-seed-core.sql'),
'utf8',
);
it('adds avatar column for existing databases', () => {
expect(avatarSql).toContain("COLUMN_NAME = 'avatar'");
@ -25,4 +29,17 @@ describe('admin user avatar sql', () => {
);
expect(initSql).not.toContain('`avatar` = VALUES(`avatar`)');
});
it('seeds the renamed administrator and removes the fake workspace menu', () => {
expect(initSql).toContain("'kwitsukasa', '123456', 'KwiTsukasa'");
expect(initSql).toContain("'/analytics', 'Asia/Shanghai'");
expect(initSql).toContain("'Analytics', '/analytics'");
expect(initSql).not.toContain("'vben', '123456'");
expect(initSql).not.toContain("'jack', '123456'");
expect(initSql).not.toContain("'Workspace'");
expect(initSql).not.toContain("'/workspace'");
expect(initSql).toContain('`username` = VALUES(`username`)');
expect(initSql).not.toContain('`password` = VALUES(`password`)');
expect(refactorSeedSql).not.toContain('password = VALUES(password)');
});
});

View File

@ -2,8 +2,10 @@ import { AdminUserService } from '../../src/modules/admin/identity/user/admin-us
describe('AdminUserService profile', () => {
const userRepository = {
create: jest.fn((input) => ({ ...input })),
findOne: jest.fn(),
save: jest.fn(),
update: jest.fn(),
};
const roleRepository = {
find: jest.fn(),
@ -22,10 +24,33 @@ describe('AdminUserService profile', () => {
jest.clearAllMocks();
});
it('uses environment overview as the default home path for created users', async () => {
userRepository.findOne.mockResolvedValue(null);
roleRepository.find.mockResolvedValue([]);
await service.createUser({
realName: '新用户',
roleIds: [],
username: 'new-user',
});
expect(userRepository.create).toHaveBeenCalledWith(
expect.objectContaining({
homePath: '/analytics',
username: 'new-user',
}),
);
expect(userRepository.save).toHaveBeenCalledWith(
expect.objectContaining({
homePath: '/analytics',
}),
);
});
it('updates current profile fields including uploaded avatar url', async () => {
const user = {
avatar: '',
homePath: '/workspace',
homePath: '/analytics',
id: '2041700000000000001',
realName: '旧姓名',
roles: [],
@ -61,11 +86,51 @@ describe('AdminUserService profile', () => {
expect(result).toEqual(user);
});
it('falls back to environment overview when current profile clears home path', async () => {
const user = {
avatar: '',
homePath: '/profile',
id: '2041700000000000002',
realName: 'KwiTsukasa',
roles: [],
timezone: 'Asia/Shanghai',
username: 'kwitsukasa',
};
userRepository.findOne.mockResolvedValue(user);
await service.updateCurrentProfile(user.id, {
homePath: '',
});
expect(userRepository.save).toHaveBeenCalledWith(
expect.objectContaining({
homePath: '/analytics',
}),
);
});
it('prevents deleting the renamed built-in administrator account', async () => {
userRepository.findOne.mockResolvedValue({
id: '2041700000000000002',
isDeleted: false,
username: 'kwitsukasa',
});
await expect(service.deleteUser('2041700000000000002')).rejects.toMatchObject(
{
response: expect.objectContaining({
msg: '不能删除内置管理员账号',
}),
},
);
expect(userRepository.update).not.toHaveBeenCalled();
});
it('serializes avatar and userId for Admin frontend user store', () => {
expect(
service.serializeUser({
avatar: '/api/minio/download?objectName=avatars%2Favatar.jpg',
homePath: '/workspace',
homePath: '/analytics',
id: '2041700000000000001',
realName: '管理员',
roles: [],
@ -74,7 +139,7 @@ describe('AdminUserService profile', () => {
} as any),
).toEqual({
avatar: '/api/minio/download?objectName=avatars%2Favatar.jpg',
homePath: '/workspace',
homePath: '/analytics',
id: '2041700000000000001',
realName: '管理员',
roles: [],