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` | 更新用户 | | `PUT` | `/system/user/:id` | 更新用户 |
| `DELETE` | `/system/user/:id` | 删除用户 | | `DELETE` | `/system/user/:id` | 删除用户 |
系统菜单实体包含 `sort` 字段;菜单树输出按 `meta.order` 优先,其次按 `sort` 升序排列。Admin 菜单管理页面维护 `sort`,不要把普通菜单排序写进隐藏的 route meta。
### Dict ### Dict
| 方法 | 路径 | 说明 | | 方法 | 路径 | 说明 |

View File

@ -7,7 +7,6 @@ UPDATE `admin_menu`
SET `meta` = CASE `name` SET `meta` = CASE `name`
WHEN 'Dashboard' THEN '{"order":-1,"title":"page.dashboard.title"}' WHEN 'Dashboard' THEN '{"order":-1,"title":"page.dashboard.title"}'
WHEN 'Analytics' THEN '{"affixTab":true,"title":"page.dashboard.analytics"}' 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 '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 'SystemRole' THEN '{"icon":"carbon:user-role","title":"system.role.title"}'
WHEN 'SystemRoleCreate' THEN '{"title":"common.create"}' WHEN 'SystemRoleCreate' THEN '{"title":"common.create"}'
@ -34,7 +33,6 @@ END
WHERE `name` IN ( WHERE `name` IN (
'Dashboard', 'Dashboard',
'Analytics', 'Analytics',
'Workspace',
'System', 'System',
'SystemRole', 'SystemRole',
'SystemRoleCreate', 'SystemRoleCreate',

View File

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

View File

@ -210,7 +210,6 @@ INSERT INTO `admin_menu` (`id`, `pid`, `name`, `path`, `component`, `redirect`,
VALUES VALUES
(2041700000000100001, 0, 'Dashboard', '/dashboard', NULL, '/analytics', NULL, 'catalog', '{"order":-1,"title":"page.dashboard.title"}', 1, 0), (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), (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), (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), (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), (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`) INSERT INTO `admin_role` (`id`, `role_code`, `name`, `remark`, `status`)
VALUES VALUES
(2041700000000010001, 'super', '超级管理员', '拥有所有后台权限', 1), (2041700000000010001, 'super', '超级管理员', '拥有所有后台权限', 1),
(2041700000000010002, 'admin', '管理员', '拥有系统管理与工作台权限', 1), (2041700000000010002, 'admin', '管理员', '拥有系统管理与环境总览权限', 1),
(2041700000000010003, 'user', '普通用户', '仅拥有基础查看权限', 1) (2041700000000010003, 'user', '普通用户', '仅拥有基础查看权限', 1)
ON DUPLICATE KEY UPDATE ON DUPLICATE KEY UPDATE
`name` = VALUES(`name`), `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`) INSERT INTO `admin_user` (`id`, `username`, `password`, `real_name`, `avatar`, `dept_id`, `home_path`, `timezone`, `status`)
VALUES VALUES
(2041700000000000001, 'vben', '123456', 'Vben', '', 2041700000000200002, '/workspace', 'Asia/Shanghai', 1), (2041700000000000002, 'kwitsukasa', '123456', 'KwiTsukasa', '', 2041700000000200001, '/analytics', 'Asia/Shanghai', 1)
(2041700000000000002, 'admin', '123456', 'Admin', '', 2041700000000200001, '/workspace', 'Asia/Shanghai', 1),
(2041700000000000003, 'jack', '123456', 'Jack', '', 2041700000000200003, '/analytics', 'Asia/Shanghai', 1)
ON DUPLICATE KEY UPDATE ON DUPLICATE KEY UPDATE
`password` = VALUES(`password`), `username` = VALUES(`username`),
`real_name` = VALUES(`real_name`), `real_name` = VALUES(`real_name`),
`dept_id` = VALUES(`dept_id`), `dept_id` = VALUES(`dept_id`),
`home_path` = VALUES(`home_path`), `home_path` = VALUES(`home_path`),
@ -331,16 +328,12 @@ ON DUPLICATE KEY UPDATE
DELETE FROM `admin_user_role` DELETE FROM `admin_user_role`
WHERE `user_id` IN ( WHERE `user_id` IN (
2041700000000000001, 2041700000000000002
2041700000000000002,
2041700000000000003
); );
INSERT INTO `admin_user_role` (`user_id`, `role_id`) INSERT INTO `admin_user_role` (`user_id`, `role_id`)
VALUES VALUES
(2041700000000000001, 2041700000000010001), (2041700000000000002, 2041700000000010002);
(2041700000000000002, 2041700000000010002),
(2041700000000000003, 2041700000000010003);
DELETE FROM `admin_role_menu` DELETE FROM `admin_role_menu`
WHERE `role_id` IN ( WHERE `role_id` IN (

View File

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

View File

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

View File

@ -65,6 +65,75 @@ describe('AdminMenuService', () => {
expect.arrayContaining(['Blog:Theme:List', 'Blog:Theme:Import']), 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'), join(process.cwd(), 'sql/vben-admin-init.sql'),
'utf8', 'utf8',
); );
const refactorSeedSql = readFileSync(
join(process.cwd(), 'sql/refactor-v3/01-seed-core.sql'),
'utf8',
);
it('adds avatar column for existing databases', () => { it('adds avatar column for existing databases', () => {
expect(avatarSql).toContain("COLUMN_NAME = 'avatar'"); expect(avatarSql).toContain("COLUMN_NAME = 'avatar'");
@ -25,4 +29,17 @@ describe('admin user avatar sql', () => {
); );
expect(initSql).not.toContain('`avatar` = VALUES(`avatar`)'); 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', () => { describe('AdminUserService profile', () => {
const userRepository = { const userRepository = {
create: jest.fn((input) => ({ ...input })),
findOne: jest.fn(), findOne: jest.fn(),
save: jest.fn(), save: jest.fn(),
update: jest.fn(),
}; };
const roleRepository = { const roleRepository = {
find: jest.fn(), find: jest.fn(),
@ -22,10 +24,33 @@ describe('AdminUserService profile', () => {
jest.clearAllMocks(); 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 () => { it('updates current profile fields including uploaded avatar url', async () => {
const user = { const user = {
avatar: '', avatar: '',
homePath: '/workspace', homePath: '/analytics',
id: '2041700000000000001', id: '2041700000000000001',
realName: '旧姓名', realName: '旧姓名',
roles: [], roles: [],
@ -61,11 +86,51 @@ describe('AdminUserService profile', () => {
expect(result).toEqual(user); 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', () => { it('serializes avatar and userId for Admin frontend user store', () => {
expect( expect(
service.serializeUser({ service.serializeUser({
avatar: '/api/minio/download?objectName=avatars%2Favatar.jpg', avatar: '/api/minio/download?objectName=avatars%2Favatar.jpg',
homePath: '/workspace', homePath: '/analytics',
id: '2041700000000000001', id: '2041700000000000001',
realName: '管理员', realName: '管理员',
roles: [], roles: [],
@ -74,7 +139,7 @@ describe('AdminUserService profile', () => {
} as any), } as any),
).toEqual({ ).toEqual({
avatar: '/api/minio/download?objectName=avatars%2Favatar.jpg', avatar: '/api/minio/download?objectName=avatars%2Favatar.jpg',
homePath: '/workspace', homePath: '/analytics',
id: '2041700000000000001', id: '2041700000000000001',
realName: '管理员', realName: '管理员',
roles: [], roles: [],