create-lumal-admin 0.0.4-beta → 0.0.6-beta
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +25 -0
- package/README.md +98 -12
- package/dist/cli.d.ts +18 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +308 -28
- package/dist/cli.js.map +1 -1
- package/dist/create.d.ts +11 -1
- package/dist/create.d.ts.map +1 -1
- package/dist/create.js +98 -2
- package/dist/create.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/presets.d.ts +41 -0
- package/dist/presets.d.ts.map +1 -0
- package/dist/presets.js +58 -0
- package/dist/presets.js.map +1 -0
- package/dist/project-cli.d.ts +3 -0
- package/dist/project-cli.d.ts.map +1 -0
- package/dist/project-cli.js +13 -0
- package/dist/project-cli.js.map +1 -0
- package/dist/projects.d.ts +21 -0
- package/dist/projects.d.ts.map +1 -0
- package/dist/projects.js +124 -0
- package/dist/projects.js.map +1 -0
- package/dist/scaffold.d.ts +2 -11
- package/dist/scaffold.d.ts.map +1 -1
- package/dist/scaffold.js +2 -1602
- package/dist/scaffold.js.map +1 -1
- package/dist/templates.d.ts +7 -0
- package/dist/templates.d.ts.map +1 -0
- package/dist/templates.js +91 -0
- package/dist/templates.js.map +1 -0
- package/dist/types.d.ts +64 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +13 -4
- package/templates/base/.env.example +4 -0
- package/templates/base/API-CONTRACT.md +84 -0
- package/templates/base/README.md +59 -0
- package/templates/base/index.html +12 -0
- package/templates/base/package.json +38 -0
- package/templates/base/src/App.vue +206 -0
- package/templates/base/src/app/components/AppHeaderActions.vue +46 -0
- package/templates/base/src/app/components/AppSettingsDrawer.vue +37 -0
- package/templates/base/src/app/menu-policy.ts +163 -0
- package/templates/base/src/app/module.ts +46 -0
- package/templates/base/src/app/router/index.ts +107 -0
- package/templates/base/src/app/router/views.ts +12 -0
- package/templates/base/src/app/services/access.ts +2 -0
- package/templates/base/src/app/services/contracts.ts +54 -0
- package/templates/base/src/app/services/local-contracts.ts +18 -0
- package/templates/base/src/app/services/preferences.ts +41 -0
- package/templates/base/src/app/services/session-runtime.ts +186 -0
- package/templates/base/src/app/services/session.ts +14 -0
- package/templates/base/src/app/stored-menus.ts +72 -0
- package/templates/base/src/app/views/SafeIframeView.vue +34 -0
- package/templates/base/src/app/views/auth/LoginView.vue +75 -0
- package/templates/base/src/app/views/error/ForbiddenView.vue +20 -0
- package/templates/base/src/app/views/error/NotFoundView.vue +16 -0
- package/templates/base/src/main.ts +40 -0
- package/templates/base/src/modules/business/README.md +33 -0
- package/templates/base/src/modules/dashboard/DashboardView.vue +27 -0
- package/templates/base/src/modules/dashboard/module.ts +14 -0
- package/templates/base/src/shared/api.ts +53 -0
- package/templates/base/src/shared/components/BaseEntityDialog.vue +39 -0
- package/templates/base/src/shared/composables/use-async-data.ts +35 -0
- package/templates/base/src/shared/composables/use-entity-editor.ts +77 -0
- package/templates/base/src/shared/status.ts +4 -0
- package/templates/base/src/shared/tree.ts +41 -0
- package/templates/base/src/styles.scss +173 -0
- package/templates/base/tests/menu-policy.test.ts.template +89 -0
- package/templates/base/tests/session.test.ts.template +45 -0
- package/templates/base/vite.config.ts +54 -0
- package/templates/blank/README.md +6 -0
- package/templates/blank/src/App.vue +11 -0
- package/templates/common/AGENTS.md +13 -0
- package/templates/common/_editorconfig +15 -0
- package/templates/common/_gitattributes +6 -0
- package/templates/common/_gitignore +7 -0
- package/templates/common/_prettierignore +6 -0
- package/templates/common/docs/DEVELOPMENT.md +26 -0
- package/templates/common/eslint.config.mjs +60 -0
- package/templates/common/package.json +39 -0
- package/templates/common/prettier.config.mjs +8 -0
- package/templates/common/tsconfig.json +19 -0
- package/templates/common/vitest.config.ts +3 -0
- package/templates/features/examples/src/modules/examples/CrudExampleView.vue +51 -0
- package/templates/features/examples/src/modules/examples/FormExampleView.vue +32 -0
- package/templates/features/examples/src/modules/examples/module.ts +27 -0
- package/templates/features/system/src/modules/system/config/ConfigView.vue +104 -0
- package/templates/features/system/src/modules/system/config/api.ts +38 -0
- package/templates/features/system/src/modules/system/config/routes.ts +21 -0
- package/templates/features/system/src/modules/system/dictionary/DictionaryItemView.vue +141 -0
- package/templates/features/system/src/modules/system/dictionary/DictionaryTypeView.vue +107 -0
- package/templates/features/system/src/modules/system/dictionary/api.ts +61 -0
- package/templates/features/system/src/modules/system/dictionary/cache.ts +9 -0
- package/templates/features/system/src/modules/system/dictionary/routes.ts +32 -0
- package/templates/features/system/src/modules/system/dictionary/types.ts +22 -0
- package/templates/features/system/src/modules/system/local.ts +348 -0
- package/templates/features/system/src/modules/system/menu/MenuView.vue +133 -0
- package/templates/features/system/src/modules/system/menu/api.ts +73 -0
- package/templates/features/system/src/modules/system/menu/components/MenuEditorDialog.vue +106 -0
- package/templates/features/system/src/modules/system/menu/routes.ts +23 -0
- package/templates/features/system/src/modules/system/menu/types.ts +18 -0
- package/templates/features/system/src/modules/system/module.ts +22 -0
- package/templates/features/system/src/modules/system/organization/OrganizationView.vue +134 -0
- package/templates/features/system/src/modules/system/organization/api.ts +38 -0
- package/templates/features/system/src/modules/system/organization/routes.ts +23 -0
- package/templates/features/system/src/modules/system/organization/types.ts +17 -0
- package/templates/features/system/src/modules/system/role/RoleView.vue +90 -0
- package/templates/features/system/src/modules/system/role/api.ts +40 -0
- package/templates/features/system/src/modules/system/role/components/RoleAuthorizationDialog.vue +110 -0
- package/templates/features/system/src/modules/system/role/permission-tree.ts +31 -0
- package/templates/features/system/src/modules/system/role/routes.ts +24 -0
- package/templates/features/system/src/modules/system/role/types.ts +12 -0
- package/templates/features/system/src/modules/system/user/UserView.vue +297 -0
- package/templates/features/system/src/modules/system/user/api.ts +45 -0
- package/templates/features/system/src/modules/system/user/components/UserOrganizationFilter.vue +61 -0
- package/templates/features/system/src/modules/system/user/components/UserRoleDialog.vue +93 -0
- package/templates/features/system/src/modules/system/user/routes.ts +27 -0
- package/templates/features/system/src/modules/system/user/types.ts +28 -0
- package/templates/features/system/tests/system.test.ts.template +266 -0
- package/templates/modes/http/src/app/services/data-source.ts +124 -0
- package/templates/modes/http/src/app/services/http-adapter.ts +128 -0
- package/templates/modes/http/tests/http.test.ts.template +218 -0
- package/templates/modes/local/src/app/services/data-source.ts +113 -0
- package/templates/modes/local/src/app/services/local-database.ts +67 -0
- package/templates/modes/local/tests/local.test.ts.template +132 -0
- package/templates/presets/standard/src/modules/dashboard/DashboardView.vue +52 -0
- package/templates/presets/standard/src/modules/dashboard/components/DashboardSessionStatus.vue +41 -0
- package/templates/presets/standard/src/modules/dashboard/components/DashboardShortcuts.vue +51 -0
- package/templates/presets/standard/src/modules/profile/ProfileView.vue +109 -0
- package/templates/presets/standard/src/modules/profile/api.ts +49 -0
- package/templates/presets/standard/src/modules/profile/components/ProfilePasswordForm.vue +68 -0
- package/templates/presets/standard/src/modules/profile/local.ts +40 -0
- package/templates/presets/standard/src/modules/profile/module.ts +11 -0
- package/templates/presets/standard/tests/profile.test.ts.template +72 -0
- package/templates/screen/README.md +6 -0
- package/templates/screen/src/App.vue +7 -0
- package/templates/screen/src/screen/ScreenEntry.vue +18 -0
- package/templates/screen/src/screen/ScreenView.vue +48 -0
- package/templates/screen/src/screen/components/ScreenMetrics.vue +40 -0
- package/templates/screen-auth/src/screen/ScreenEntry.vue +52 -0
- package/templates/screen-auth/src/screen/components/ScreenLoginForm.vue +44 -0
- package/templates/screen-auth/src/services/session.ts +61 -0
- package/templates/screen-auth/tests/session.test.ts.template +54 -0
- package/templates/screen-auth-http/src/services/auth-adapter.ts +14 -0
- package/templates/screen-auth-http/tests/auth-adapter.test.ts.template +17 -0
- package/templates/screen-auth-local-demo/src/services/auth-adapter.ts +15 -0
- package/templates/screen-auth-local-demo/tests/auth-adapter.test.ts.template +18 -0
- package/templates/screen-data/src/screen/ScreenView.vue +69 -0
- package/templates/screen-data/src/services/metrics-types.ts +4 -0
- package/templates/screen-data-http/src/services/metrics.ts +20 -0
- package/templates/screen-data-http/src/services/request.ts +3 -0
- package/templates/screen-data-http/tests/metrics.test.ts.template +21 -0
- package/templates/screen-data-local/src/services/metrics.ts +11 -0
- package/templates/screen-data-local/tests/metrics.test.ts.template +9 -0
- package/templates/screen-http/API-CONTRACT.md +20 -0
- package/templates/screen-http/src/services/http.ts +17 -0
- package/templates/screen-request-auth/src/services/request.ts +15 -0
- package/templates/screen-router/src/App.vue +7 -0
- package/templates/screen-router/src/main.ts +13 -0
- package/templates/screen-router/src/router.ts +7 -0
- package/templates/screen-router/tests/app.test.ts.template +20 -0
- package/templates/screen-viewport/src/screen/ScreenViewport.vue +30 -0
- package/templates/screen-visualization-charts/src/screen/components/ScreenVisualization.vue +22 -0
- package/templates/screen-visualization-cockpit/src/screen/components/ScreenVisualization.vue +33 -0
- package/templates/screen-visualization-cockpit/src/screen/components/ScreenWidget.vue +10 -0
- package/templates/screen-visualization-datav/src/screen/components/ScreenVisualization.vue +11 -0
- package/templates/vben-compatible/MIGRATION.md +30 -0
- package/templates/vben-compatible/src/modules/vben-migration/VbenFormView.vue +11 -0
- package/templates/vben-compatible/src/modules/vben-migration/VbenUsersView.vue +12 -0
- package/templates/vben-compatible/src/modules/vben-migration/components/VbenFormExample.vue +64 -0
- package/templates/vben-compatible/src/modules/vben-migration/components/VbenUsers.vue +144 -0
- package/templates/vben-compatible/src/modules/vben-migration/local.ts +68 -0
- package/templates/vben-compatible/src/modules/vben-migration/module.ts +42 -0
- package/templates/vue/index.html +12 -0
- package/templates/vue/src/main.ts +5 -0
- package/templates/vue/src/styles.css +19 -0
- package/templates/vue/tests/app.test.ts.template +10 -0
- package/templates/vue/vite.config.ts +9 -0
- package/templates/vue/vitest.config.ts +4 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { AppModule } from '../../../app/module';
|
|
2
|
+
|
|
3
|
+
export const menus: AppModule['menus'] = [
|
|
4
|
+
{
|
|
5
|
+
path: '/system/users',
|
|
6
|
+
name: 'SystemUsers',
|
|
7
|
+
component: 'system/users',
|
|
8
|
+
meta: { title: '用户管理', icon: 'lumal:user', authority: ['system:user:list'], keepAlive: true, order: 1 },
|
|
9
|
+
},
|
|
10
|
+
];
|
|
11
|
+
export const views: AppModule['views'] = { 'system/users': () => import('./UserView.vue') };
|
|
12
|
+
// 只声明真实存在的操作,不根据菜单前缀生成伪权限。
|
|
13
|
+
export const permissions: AppModule['permissions'] = [
|
|
14
|
+
{
|
|
15
|
+
id: 'system-user',
|
|
16
|
+
label: '用户管理',
|
|
17
|
+
children: [
|
|
18
|
+
{ id: 'system:user:list', label: '查询用户', permission: 'system:user:list' },
|
|
19
|
+
{ id: 'system:user:create', label: '新增用户', permission: 'system:user:create' },
|
|
20
|
+
{ id: 'system:user:update', label: '修改用户', permission: 'system:user:update' },
|
|
21
|
+
{ id: 'system:user:delete', label: '删除用户', permission: 'system:user:delete' },
|
|
22
|
+
{ id: 'system:user:status', label: '启用与停用', permission: 'system:user:status' },
|
|
23
|
+
{ id: 'system:user:assign-roles', label: '分配角色', permission: 'system:user:assign-roles' },
|
|
24
|
+
{ id: 'system:user:reset-password', label: '重置密码', permission: 'system:user:reset-password' },
|
|
25
|
+
],
|
|
26
|
+
},
|
|
27
|
+
];
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { Query, Status } from '../../../shared/api';
|
|
2
|
+
|
|
3
|
+
export interface User {
|
|
4
|
+
id: string;
|
|
5
|
+
username: string;
|
|
6
|
+
nickname: string;
|
|
7
|
+
organizationId: string;
|
|
8
|
+
roles: string[];
|
|
9
|
+
phone: string;
|
|
10
|
+
status: Status;
|
|
11
|
+
createdAt: string;
|
|
12
|
+
}
|
|
13
|
+
export interface UserInput {
|
|
14
|
+
username: string;
|
|
15
|
+
nickname: string;
|
|
16
|
+
organizationId: string;
|
|
17
|
+
roles: string[];
|
|
18
|
+
phone: string;
|
|
19
|
+
status: Status;
|
|
20
|
+
}
|
|
21
|
+
export interface UserQuery extends Query {
|
|
22
|
+
organizationId?: string;
|
|
23
|
+
roles?: string;
|
|
24
|
+
}
|
|
25
|
+
export interface UserBatchResult {
|
|
26
|
+
items: User[];
|
|
27
|
+
updated: number;
|
|
28
|
+
}
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import type { BusinessTransport, DataRow, ListInput, PageResult } from '../src/app/services/contracts';
|
|
2
|
+
import type { LocalDatabase } from '../src/app/services/local-contracts';
|
|
3
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { permissionCodes, viewComponents } from '../src/app/router/views';
|
|
5
|
+
import { buildStoredMenus } from '../src/app/stored-menus';
|
|
6
|
+
import { createConfigService } from '../src/modules/system/config/api';
|
|
7
|
+
import { createDictionaryService } from '../src/modules/system/dictionary/api';
|
|
8
|
+
import { createLocalHandler } from '../src/modules/system/local';
|
|
9
|
+
import { createMenuService } from '../src/modules/system/menu/api';
|
|
10
|
+
import { createOrganizationService } from '../src/modules/system/organization/api';
|
|
11
|
+
import { createRoleService } from '../src/modules/system/role/api';
|
|
12
|
+
import { parsePermissionTree } from '../src/modules/system/role/permission-tree';
|
|
13
|
+
import { createUserService, userInput } from '../src/modules/system/user/api';
|
|
14
|
+
|
|
15
|
+
let transport: BusinessTransport & { list: (resource: string, input: ListInput) => Promise<PageResult> };
|
|
16
|
+
let allowed = new Set<string>();
|
|
17
|
+
let records: Map<string, DataRow[]>;
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
records = new Map([
|
|
20
|
+
[
|
|
21
|
+
'users',
|
|
22
|
+
[
|
|
23
|
+
{
|
|
24
|
+
id: 'admin',
|
|
25
|
+
username: 'admin',
|
|
26
|
+
nickname: '管理员',
|
|
27
|
+
organizationId: 'root',
|
|
28
|
+
roles: ['admin'],
|
|
29
|
+
status: 'enabled',
|
|
30
|
+
password: 'lumal123',
|
|
31
|
+
},
|
|
32
|
+
],
|
|
33
|
+
],
|
|
34
|
+
[
|
|
35
|
+
'roles',
|
|
36
|
+
[{ id: 'admin', code: 'admin', name: '管理员', builtIn: true, status: 'enabled', permissions: permissionCodes }],
|
|
37
|
+
],
|
|
38
|
+
]);
|
|
39
|
+
allowed = new Set(permissionCodes);
|
|
40
|
+
const db: LocalDatabase = {
|
|
41
|
+
read: <T extends DataRow>(key: string, fallback?: () => T[]): T[] =>
|
|
42
|
+
structuredClone(records.get(key) ?? fallback?.() ?? []) as T[],
|
|
43
|
+
write: (key, rows) => {
|
|
44
|
+
records.set(key, structuredClone(rows));
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
const handler = createLocalHandler({
|
|
48
|
+
db,
|
|
49
|
+
user: () => ({ id: 'admin', username: 'admin', name: '管理员' }),
|
|
50
|
+
assertPermission: (code) => {
|
|
51
|
+
if (!allowed.has(code)) throw new Error(`无操作权限:${code}`);
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
const request: BusinessTransport['request'] = async <T>(
|
|
55
|
+
method: Parameters<BusinessTransport['request']>[0],
|
|
56
|
+
path: string,
|
|
57
|
+
input = {},
|
|
58
|
+
) => (await handler.request(method, path, input)) as T;
|
|
59
|
+
transport = {
|
|
60
|
+
request,
|
|
61
|
+
list: (resource, input) =>
|
|
62
|
+
request('GET', `/system/${resource}`, {
|
|
63
|
+
query: { page: input.page, pageSize: input.pageSize, ...input.query } as Record<string, string | number>,
|
|
64
|
+
}),
|
|
65
|
+
};
|
|
66
|
+
});
|
|
67
|
+
const newUser = (username = 'new-user') =>
|
|
68
|
+
userInput({ username, nickname: '新增用户', organizationId: 'root', roles: ['admin'], status: 'enabled' });
|
|
69
|
+
|
|
70
|
+
describe('用户业务约束', () => {
|
|
71
|
+
it('独立 CRUD、机构筛选、角色与状态筛选', async () => {
|
|
72
|
+
const users = createUserService(transport);
|
|
73
|
+
const row = await users.create(newUser());
|
|
74
|
+
expect(row).not.toHaveProperty('password');
|
|
75
|
+
await users.update(row.id, { ...newUser(), nickname: '已修改' });
|
|
76
|
+
expect(
|
|
77
|
+
(
|
|
78
|
+
await users.list({
|
|
79
|
+
page: 1,
|
|
80
|
+
pageSize: 10,
|
|
81
|
+
query: { organizationId: 'root', roles: 'admin', status: 'enabled' },
|
|
82
|
+
})
|
|
83
|
+
).items,
|
|
84
|
+
).toHaveLength(2);
|
|
85
|
+
await users.setStatus(row.id, 'disabled');
|
|
86
|
+
expect((await users.list({ page: 1, pageSize: 10, query: { status: 'disabled' } })).items[0]?.nickname).toBe(
|
|
87
|
+
'已修改',
|
|
88
|
+
);
|
|
89
|
+
await users.remove(row.id);
|
|
90
|
+
expect((await users.list({ page: 1, pageSize: 10, query: { keyword: 'new-user' } })).total).toBe(0);
|
|
91
|
+
});
|
|
92
|
+
it('批量先校验再写入,管理员与关联保护', async () => {
|
|
93
|
+
const users = createUserService(transport);
|
|
94
|
+
const row = await users.create(newUser());
|
|
95
|
+
await expect(users.batchStatus([row.id, 'admin'], 'disabled')).rejects.toThrow('内置管理员');
|
|
96
|
+
expect(records.get('users')?.find((item) => item.id === row.id)?.status).toBe('enabled');
|
|
97
|
+
await expect(users.remove('admin')).rejects.toThrow('内置管理员');
|
|
98
|
+
await expect(users.assignRoles('admin', [])).rejects.toThrow();
|
|
99
|
+
await expect(users.create({ ...newUser('missing-org'), organizationId: 'missing' })).rejects.toThrow('机构');
|
|
100
|
+
const result = await users.resetPassword(row.id);
|
|
101
|
+
expect(result.temporaryPassword.length).toBeGreaterThanOrEqual(8);
|
|
102
|
+
expect(records.get('users')?.find((item) => item.id === row.id)?.password).toBe(result.temporaryPassword);
|
|
103
|
+
});
|
|
104
|
+
it('资料更新不能绕过专用状态和角色权限', async () => {
|
|
105
|
+
const users = createUserService(transport);
|
|
106
|
+
const row = await users.create(newUser());
|
|
107
|
+
allowed.delete('system:user:status');
|
|
108
|
+
await expect(users.update(row.id, { ...newUser(), status: 'disabled' })).rejects.toThrow('无操作权限');
|
|
109
|
+
allowed.delete('system:user:create');
|
|
110
|
+
await expect(users.create(newUser('blocked'))).rejects.toThrow('无操作权限');
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
describe('角色资料与授权是两个独立业务流程', () => {
|
|
114
|
+
it('非叶访问权限独立回显,并拒绝循环、重复和异常权限树', () => {
|
|
115
|
+
const tree = parsePermissionTree([
|
|
116
|
+
{
|
|
117
|
+
id: 'page',
|
|
118
|
+
label: '用户',
|
|
119
|
+
permission: 'system:user:list',
|
|
120
|
+
children: [{ id: 'create', label: '新增', permission: 'system:user:create' }],
|
|
121
|
+
},
|
|
122
|
+
]);
|
|
123
|
+
expect(tree[0]?.permission).toBeUndefined();
|
|
124
|
+
expect(tree[0]?.children?.map((node) => node.permission)).toEqual(['system:user:list', 'system:user:create']);
|
|
125
|
+
expect(() =>
|
|
126
|
+
parsePermissionTree([
|
|
127
|
+
{ id: 'same', label: '1' },
|
|
128
|
+
{ id: 'same', label: '2' },
|
|
129
|
+
]),
|
|
130
|
+
).toThrow('重复');
|
|
131
|
+
expect(() => parsePermissionTree([{ id: 'bad', label: '1', children: {} }])).toThrow('数组');
|
|
132
|
+
const cycle = { id: 'cycle', label: '循环', children: [] as unknown[] };
|
|
133
|
+
cycle.children.push(cycle);
|
|
134
|
+
expect(() => parsePermissionTree([cycle])).toThrow('循环');
|
|
135
|
+
});
|
|
136
|
+
it('真实权限树、授权回显、未知授权拒绝、引用和内置保护', async () => {
|
|
137
|
+
const roles = createRoleService(transport);
|
|
138
|
+
const role = await roles.create({ code: 'reader', name: '只读角色', description: '', status: 'enabled' });
|
|
139
|
+
expect((await roles.tree()).length).toBeGreaterThan(0);
|
|
140
|
+
expect(await roles.authorize('reader', ['system:user:list'])).toEqual(['system:user:list']);
|
|
141
|
+
expect(await roles.permissions('reader')).toEqual(['system:user:list']);
|
|
142
|
+
await expect(roles.authorize('reader', ['not-registered'])).rejects.toThrow('未注册');
|
|
143
|
+
await expect(roles.authorize('admin', [])).rejects.toThrow('内置管理员');
|
|
144
|
+
await expect(roles.remove('admin')).rejects.toThrow('内置');
|
|
145
|
+
const user = await createUserService(transport).create({ ...newUser(), roles: ['reader'] });
|
|
146
|
+
await expect(roles.remove(role.id)).rejects.toThrow('引用');
|
|
147
|
+
await createUserService(transport).remove(user.id);
|
|
148
|
+
await roles.remove(role.id);
|
|
149
|
+
});
|
|
150
|
+
it('资料已写入而授权失败时不会回滚或声称原子更新', async () => {
|
|
151
|
+
const roles = createRoleService(transport);
|
|
152
|
+
const role = await roles.create({ code: 'reader', name: '只读角色', description: '', status: 'enabled' });
|
|
153
|
+
await roles.update(role.id, { ...role, name: '已保存资料' });
|
|
154
|
+
allowed.delete('system:role:authorize');
|
|
155
|
+
await expect(roles.authorize(role.code, ['system:user:list'])).rejects.toThrow('无操作权限');
|
|
156
|
+
expect((await roles.list({ page: 1, pageSize: 10, query: { keyword: 'reader' } })).items[0]?.name).toBe(
|
|
157
|
+
'已保存资料',
|
|
158
|
+
);
|
|
159
|
+
allowed.add('system:role:authorize');
|
|
160
|
+
expect(await roles.authorize(role.code, ['system:user:list'])).toEqual(['system:user:list']);
|
|
161
|
+
await expect(
|
|
162
|
+
transport.request('PUT', `/system/roles/${role.id}`, { body: { ...role, permissions: [] } }),
|
|
163
|
+
).rejects.toThrow('分别保存');
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
describe('菜单、机构、字典和配置', () => {
|
|
167
|
+
it('目录、菜单、按钮树,路径、白名单、循环与层级约束', async () => {
|
|
168
|
+
const api = createMenuService(transport);
|
|
169
|
+
const directory = await api.create({
|
|
170
|
+
parentId: '',
|
|
171
|
+
type: 'directory',
|
|
172
|
+
path: '/custom',
|
|
173
|
+
name: 'Custom',
|
|
174
|
+
title: '扩展目录',
|
|
175
|
+
component: '',
|
|
176
|
+
icon: '',
|
|
177
|
+
permissions: [],
|
|
178
|
+
order: 0,
|
|
179
|
+
hidden: false,
|
|
180
|
+
externalLink: '',
|
|
181
|
+
});
|
|
182
|
+
const page = await api.create({
|
|
183
|
+
...directory,
|
|
184
|
+
parentId: directory.id,
|
|
185
|
+
type: 'menu',
|
|
186
|
+
path: '/custom/page',
|
|
187
|
+
name: 'CustomPage',
|
|
188
|
+
title: '扩展页',
|
|
189
|
+
component: 'system/users',
|
|
190
|
+
});
|
|
191
|
+
await api.create({
|
|
192
|
+
...page,
|
|
193
|
+
parentId: page.id,
|
|
194
|
+
type: 'button',
|
|
195
|
+
path: '',
|
|
196
|
+
name: '',
|
|
197
|
+
title: '操作',
|
|
198
|
+
component: '',
|
|
199
|
+
permissions: ['system:user:create'],
|
|
200
|
+
});
|
|
201
|
+
expect((await api.list())[0]?.children?.[0]?.children?.[0]?.type).toBe('button');
|
|
202
|
+
await expect(api.update(directory.id, { ...directory, parentId: page.id })).rejects.toThrow('循环');
|
|
203
|
+
await expect(api.create({ ...page, component: 'constructor', name: 'Bad', path: '/custom/bad' })).rejects.toThrow(
|
|
204
|
+
'白名单',
|
|
205
|
+
);
|
|
206
|
+
await expect(api.create({ ...page, name: 'BadPath', path: '/other' })).rejects.toThrow('父级');
|
|
207
|
+
const nav = buildStoredMenus(records.get('menus')!, viewComponents);
|
|
208
|
+
expect(nav[0]?.children).toHaveLength(1);
|
|
209
|
+
expect(nav[0]?.children?.[0]?.children).toEqual([]);
|
|
210
|
+
await expect(api.remove(directory.id)).rejects.toThrow('子节点');
|
|
211
|
+
});
|
|
212
|
+
it('机构新增、更新、循环和关联用户删除保护', async () => {
|
|
213
|
+
const api = createOrganizationService(transport);
|
|
214
|
+
const org = await api.create({
|
|
215
|
+
name: '研发部',
|
|
216
|
+
code: 'dev',
|
|
217
|
+
parentId: 'root',
|
|
218
|
+
leader: '',
|
|
219
|
+
phone: '',
|
|
220
|
+
email: '',
|
|
221
|
+
order: 1,
|
|
222
|
+
status: 'enabled',
|
|
223
|
+
});
|
|
224
|
+
await api.update(org.id, { ...org, name: '研发中心' });
|
|
225
|
+
const root = (await api.list())[0]!;
|
|
226
|
+
await expect(api.update('root', { ...root, parentId: org.id })).rejects.toThrow('循环');
|
|
227
|
+
const users = createUserService(transport);
|
|
228
|
+
const user = await users.create({ ...newUser(), organizationId: org.id });
|
|
229
|
+
await expect(api.remove(org.id)).rejects.toThrow('关联用户');
|
|
230
|
+
expect((await users.list({ page: 1, pageSize: 10, query: { organizationId: 'root' } })).items).toHaveLength(2);
|
|
231
|
+
await users.remove(user.id);
|
|
232
|
+
await api.remove(org.id);
|
|
233
|
+
});
|
|
234
|
+
it('字典关联、值唯一、级联删除及缓存失效', async () => {
|
|
235
|
+
const invalidate = vi.fn();
|
|
236
|
+
const api = createDictionaryService(transport, invalidate);
|
|
237
|
+
const type = await api.createType({ code: 'approval', name: '审批', description: '', status: 'enabled' });
|
|
238
|
+
const input = {
|
|
239
|
+
typeCode: 'approval',
|
|
240
|
+
label: '通过',
|
|
241
|
+
value: 'approved',
|
|
242
|
+
order: 1,
|
|
243
|
+
color: '#15803d',
|
|
244
|
+
status: 'enabled' as const,
|
|
245
|
+
};
|
|
246
|
+
const item = await api.createItem(input);
|
|
247
|
+
await api.updateItem(item.id, { ...input, label: '已通过' });
|
|
248
|
+
expect((await api.items('approval'))[0]?.label).toBe('已通过');
|
|
249
|
+
expect(await api.items('other')).toEqual([]);
|
|
250
|
+
await expect(api.createItem(input)).rejects.toThrow('重复');
|
|
251
|
+
await api.removeType(type.id);
|
|
252
|
+
expect(await api.items('approval')).toEqual([]);
|
|
253
|
+
expect(invalidate).toHaveBeenCalledTimes(4);
|
|
254
|
+
});
|
|
255
|
+
it('配置验证、独立保存与权限拒绝', async () => {
|
|
256
|
+
const api = createConfigService(transport);
|
|
257
|
+
const initial = await api.get();
|
|
258
|
+
expect((await api.save({ ...initial, appName: '测试应用' })).appName).toBe('测试应用');
|
|
259
|
+
await expect(api.save({ ...initial, colorPrimary: 'javascript:bad' })).rejects.toThrow('无效');
|
|
260
|
+
expect((await api.get()).appName).toBe('测试应用');
|
|
261
|
+
await api.restore();
|
|
262
|
+
expect((await api.get()).appName).toBe(initial.appName);
|
|
263
|
+
allowed.delete('system:config:update');
|
|
264
|
+
await expect(api.save(initial)).rejects.toThrow('无操作权限');
|
|
265
|
+
});
|
|
266
|
+
});
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import type { ApplicationService, ServiceContext } from './contracts';
|
|
2
|
+
import { createRequestClient, RequestError } from '@lumal/core/request';
|
|
3
|
+
import { object } from '../menu-policy';
|
|
4
|
+
import { createHttpAdapter } from './http-adapter';
|
|
5
|
+
|
|
6
|
+
export const demoAccount: { username: string; password: string } | null = null;
|
|
7
|
+
|
|
8
|
+
export function createHttpBackend(
|
|
9
|
+
context: ServiceContext,
|
|
10
|
+
baseURL: string,
|
|
11
|
+
adapterName = 'standard',
|
|
12
|
+
): ApplicationService {
|
|
13
|
+
const adapter = createHttpAdapter(adapterName);
|
|
14
|
+
function exposeHttpMessage({ error }: { error: unknown }): void {
|
|
15
|
+
if (error instanceof RequestError) {
|
|
16
|
+
const message = adapter.errorMessage(error.data);
|
|
17
|
+
if (message) error.message = message; // 保留 status/kind/data,仅适配后端明确定义的错误文案。
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
const publicRequest = createRequestClient({
|
|
21
|
+
baseURL,
|
|
22
|
+
onResponseError: exposeHttpMessage,
|
|
23
|
+
onRequest() {
|
|
24
|
+
if (!baseURL.trim()) throw new Error('未配置 VITE_API_BASE_URL,请先接入真实后端');
|
|
25
|
+
},
|
|
26
|
+
onResponse: <T>({ data, init }: { data: unknown; init: RequestInit }) => {
|
|
27
|
+
init.signal?.throwIfAborted();
|
|
28
|
+
return adapter.parseResponse(data) as T;
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
const request = createRequestClient({
|
|
32
|
+
baseURL,
|
|
33
|
+
onResponseError: exposeHttpMessage,
|
|
34
|
+
duplicateSubmit: true,
|
|
35
|
+
getToken: () => context.getSession()?.accessToken,
|
|
36
|
+
onRequest({ init }) {
|
|
37
|
+
if (!baseURL.trim()) throw new Error('未配置 VITE_API_BASE_URL,请先接入真实后端');
|
|
38
|
+
init.signal = init.signal ? AbortSignal.any([init.signal, context.getSignal()]) : context.getSignal();
|
|
39
|
+
const token = context.getSession()?.accessToken;
|
|
40
|
+
if (!token || new Headers(init.headers).get('Authorization') !== `Bearer ${token}`)
|
|
41
|
+
throw new RequestError('会话已切换', { kind: 'cancelled' });
|
|
42
|
+
},
|
|
43
|
+
onResponse: <T>({ data, init }: { data: unknown; init: RequestInit }) => {
|
|
44
|
+
init.signal?.throwIfAborted();
|
|
45
|
+
return adapter.parseResponse(data) as T;
|
|
46
|
+
},
|
|
47
|
+
authRefresh: {
|
|
48
|
+
async refresh({ init }) {
|
|
49
|
+
const refreshToken = context.getSession()?.refreshToken;
|
|
50
|
+
if (!refreshToken) throw new Error('缺少刷新凭据');
|
|
51
|
+
const result = await publicRequest.post('/auth/refresh', {
|
|
52
|
+
body: { refreshToken },
|
|
53
|
+
signal: init.signal ?? undefined,
|
|
54
|
+
});
|
|
55
|
+
init.signal?.throwIfAborted();
|
|
56
|
+
if (context.getSession()?.refreshToken !== refreshToken) throw new DOMException('会话已切换', 'AbortError');
|
|
57
|
+
context.setSession(adapter.parseSession(result));
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
onSessionExpired: context.expire,
|
|
61
|
+
});
|
|
62
|
+
return {
|
|
63
|
+
request: (method, path, input = {}) =>
|
|
64
|
+
request.request(path, {
|
|
65
|
+
method,
|
|
66
|
+
...input,
|
|
67
|
+
// 业务端点仅接受 JSON 对象;在请求边界校验,不能直接透传任意 object。
|
|
68
|
+
body: input.body === undefined ? undefined : object(input.body),
|
|
69
|
+
}),
|
|
70
|
+
login: async (input, signal) =>
|
|
71
|
+
adapter.parseSession(await publicRequest.post('/auth/login', { body: { ...input }, signal })),
|
|
72
|
+
getUser: async (signal) => adapter.parseUser(await request.get('/session/me', { signal })),
|
|
73
|
+
getAccess: async (signal) => adapter.parseAccess(await request.get('/session/access', { signal })),
|
|
74
|
+
getMenus: async (signal) => adapter.parseMenus(await request.get(adapter.menusPath, { signal })),
|
|
75
|
+
logout: async (accessToken) => {
|
|
76
|
+
await publicRequest.post('/auth/logout', { headers: { Authorization: `Bearer ${accessToken}` } });
|
|
77
|
+
},
|
|
78
|
+
clearCache() {
|
|
79
|
+
publicRequest.clearCache();
|
|
80
|
+
request.clearCache();
|
|
81
|
+
},
|
|
82
|
+
async list(resource, input) {
|
|
83
|
+
const result = await request.get(`/system/${resource}`, {
|
|
84
|
+
query: { page: input.page, pageSize: input.pageSize, ...input.query } as Record<string, string | number>,
|
|
85
|
+
});
|
|
86
|
+
// Mock 的树形 / 字典接口不分页,显式在应用 adapter 边界转换。
|
|
87
|
+
if (adapterName === 'lumal-mock' && Array.isArray(result)) {
|
|
88
|
+
const flatten = (rows: Record<string, unknown>[]): Record<string, unknown>[] =>
|
|
89
|
+
rows.flatMap((row) => [
|
|
90
|
+
{ ...row, children: undefined },
|
|
91
|
+
...(Array.isArray(row.children) ? flatten(row.children) : []),
|
|
92
|
+
]);
|
|
93
|
+
const items = flatten(result);
|
|
94
|
+
const keyword = String(input.query.keyword ?? '').trim();
|
|
95
|
+
const filtered = items.filter(
|
|
96
|
+
(row) => !keyword || Object.values(row).some((value) => String(value).includes(keyword)),
|
|
97
|
+
);
|
|
98
|
+
return {
|
|
99
|
+
items: filtered.slice((input.page - 1) * input.pageSize, input.page * input.pageSize),
|
|
100
|
+
total: filtered.length,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
return adapter.parsePage(result);
|
|
104
|
+
},
|
|
105
|
+
async save(resource, model, id) {
|
|
106
|
+
if (resource === 'roles' && Object.hasOwn(model, 'permissions'))
|
|
107
|
+
throw new Error('角色资料与授权必须分别调用,请使用角色授权服务');
|
|
108
|
+
return id
|
|
109
|
+
? await request.put(`/system/${resource}/${encodeURIComponent(id)}`, { body: model })
|
|
110
|
+
: await request.post(`/system/${resource}`, { body: model });
|
|
111
|
+
},
|
|
112
|
+
remove: async (resource, id) => {
|
|
113
|
+
await request.delete(`/system/${resource}/${encodeURIComponent(id)}`);
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function createBackend(context: ServiceContext): ApplicationService {
|
|
119
|
+
return createHttpBackend(
|
|
120
|
+
context,
|
|
121
|
+
import.meta.env.VITE_API_BASE_URL ?? '',
|
|
122
|
+
import.meta.env.VITE_API_ADAPTER || 'standard',
|
|
123
|
+
);
|
|
124
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import type { AuthSessionData } from '@lumal/core/auth';
|
|
2
|
+
import type { LumalMenuRecord } from '@lumal/core/router';
|
|
3
|
+
import type { AccessSnapshot, CurrentUser, PageResult } from './contracts';
|
|
4
|
+
import { parseAuthSession } from '@lumal/core/auth';
|
|
5
|
+
import { createStandardResponseParser, parsePageResult } from '@lumal/core/request';
|
|
6
|
+
import { object, resolveView, safeExternalUrl, stringArray, validateMenus } from '../menu-policy';
|
|
7
|
+
import { localMenus, viewComponents } from '../router/views';
|
|
8
|
+
|
|
9
|
+
export interface HttpAdapter {
|
|
10
|
+
parseResponse: (data: unknown) => unknown;
|
|
11
|
+
parseSession: (data: unknown) => AuthSessionData;
|
|
12
|
+
parseUser: (data: unknown) => CurrentUser;
|
|
13
|
+
parseAccess: (data: unknown) => AccessSnapshot;
|
|
14
|
+
parseMenus: (data: unknown) => LumalMenuRecord[];
|
|
15
|
+
parsePage: (data: unknown) => PageResult;
|
|
16
|
+
errorMessage: (data: unknown) => string | undefined;
|
|
17
|
+
menusPath: string;
|
|
18
|
+
}
|
|
19
|
+
function errorMessage(value: unknown, key = 'message'): string | undefined {
|
|
20
|
+
if (!value || typeof value !== 'object' || !Object.hasOwn(value, key)) return;
|
|
21
|
+
const message = (value as Record<string, unknown>)[key];
|
|
22
|
+
return typeof message === 'string' && message.trim() ? message.trim().slice(0, 500) : undefined;
|
|
23
|
+
}
|
|
24
|
+
function parseUser(value: unknown): CurrentUser {
|
|
25
|
+
const raw = object(value);
|
|
26
|
+
if (!['id', 'name', 'username'].every((key) => typeof raw[key] === 'string' && raw[key]))
|
|
27
|
+
throw new Error('当前用户缺少 id/name/username');
|
|
28
|
+
return { id: String(raw.id), name: String(raw.name), username: String(raw.username) };
|
|
29
|
+
}
|
|
30
|
+
function parseAccess(value: unknown): AccessSnapshot {
|
|
31
|
+
const raw = object(value);
|
|
32
|
+
return { roles: stringArray(raw.roles), permissions: stringArray(raw.permissions) };
|
|
33
|
+
}
|
|
34
|
+
const parseMenus = (value: unknown) => validateMenus(value, viewComponents);
|
|
35
|
+
function flatten(menus: LumalMenuRecord[]): LumalMenuRecord[] {
|
|
36
|
+
return menus.flatMap((menu) => [menu, ...flatten(menu.children ?? [])]);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function createHttpAdapter(name = 'standard'): HttpAdapter {
|
|
40
|
+
const standard: HttpAdapter = {
|
|
41
|
+
parseResponse: (value) => value,
|
|
42
|
+
parseSession: parseAuthSession,
|
|
43
|
+
parseUser,
|
|
44
|
+
parseAccess,
|
|
45
|
+
parseMenus,
|
|
46
|
+
parsePage: (value) => (Array.isArray(value) ? { items: value, total: value.length } : parsePageResult(value)),
|
|
47
|
+
errorMessage,
|
|
48
|
+
menusPath: '/menus',
|
|
49
|
+
};
|
|
50
|
+
if (name === 'standard') return standard;
|
|
51
|
+
if (name !== 'lumal-mock') throw new Error(`未知 HTTP adapter:${name}`);
|
|
52
|
+
const unwrap = createStandardResponseParser({
|
|
53
|
+
fieldNames: { code: 'statusCode', data: 'result', message: 'resultMsg' },
|
|
54
|
+
successCodes: ['0000'],
|
|
55
|
+
sessionExpiredCodes: ['AUTH_EXPIRED'],
|
|
56
|
+
});
|
|
57
|
+
const aliases: Record<string, string> = {
|
|
58
|
+
'system/user': 'system/users',
|
|
59
|
+
'system/role': 'system/roles',
|
|
60
|
+
'system/menu': 'system/menus',
|
|
61
|
+
'system/organization': 'system/organizations',
|
|
62
|
+
'system/dict': 'system/dictionaries/types',
|
|
63
|
+
'system/dict-item': 'system/dictionaries/items',
|
|
64
|
+
};
|
|
65
|
+
const supported = new Map(
|
|
66
|
+
flatten(localMenus)
|
|
67
|
+
.filter((menu) => menu.component)
|
|
68
|
+
.map((menu) => [menu.component!, menu]),
|
|
69
|
+
);
|
|
70
|
+
return {
|
|
71
|
+
...standard,
|
|
72
|
+
parseResponse: (data) => unwrap({ data } as Parameters<typeof unwrap>[0]),
|
|
73
|
+
errorMessage: (value) => errorMessage(value, 'resultMsg'),
|
|
74
|
+
parseSession: (value) =>
|
|
75
|
+
parseAuthSession(value, {
|
|
76
|
+
fieldNames: { accessToken: 'access_token', refreshToken: 'refresh_token', expiresAt: 'expire_time' },
|
|
77
|
+
}),
|
|
78
|
+
parsePage: (value) =>
|
|
79
|
+
Array.isArray(value)
|
|
80
|
+
? { items: value, total: value.length }
|
|
81
|
+
: parsePageResult(value, { fieldNames: { items: 'records', total: 'totalNum' } }),
|
|
82
|
+
menusPath: '/menu',
|
|
83
|
+
parseAccess(value) {
|
|
84
|
+
const access = parseAccess(value);
|
|
85
|
+
// Mock API 实际以 config:view 保护写接口,显式映射到应用的 update 权限。
|
|
86
|
+
return {
|
|
87
|
+
...access,
|
|
88
|
+
permissions: access.permissions.includes('system:config:view')
|
|
89
|
+
? [...new Set([...access.permissions, 'system:config:update'])]
|
|
90
|
+
: access.permissions,
|
|
91
|
+
};
|
|
92
|
+
},
|
|
93
|
+
parseMenus(value) {
|
|
94
|
+
const visit = (items: unknown): LumalMenuRecord[] => {
|
|
95
|
+
if (!Array.isArray(items)) throw new Error('Mock 菜单响应必须是数组');
|
|
96
|
+
return items.flatMap((item) => {
|
|
97
|
+
const raw = object(item);
|
|
98
|
+
const key = String(raw.viewPath ?? '');
|
|
99
|
+
if (['constructor', '__proto__', 'prototype'].includes(key)) throw new Error('菜单组件不在白名单中');
|
|
100
|
+
const component = Object.hasOwn(aliases, key) ? aliases[key]! : key;
|
|
101
|
+
const selected = supported.get(component);
|
|
102
|
+
const children = raw.nodes === undefined ? [] : visit(raw.nodes);
|
|
103
|
+
if (key && !selected && !children.length) return []; // 明确的 Mock adapter 只接入当前预设已生成的页面。
|
|
104
|
+
if (selected && !resolveView(viewComponents, component)) throw new Error('菜单组件不在白名单中');
|
|
105
|
+
if (!selected && !children.length) return [];
|
|
106
|
+
const sourceMeta = raw.meta === undefined ? {} : object(raw.meta);
|
|
107
|
+
return [
|
|
108
|
+
{
|
|
109
|
+
path: selected?.path ?? String(raw.routePath),
|
|
110
|
+
name: selected?.name ?? String(raw.routeName),
|
|
111
|
+
component: selected?.component,
|
|
112
|
+
meta: {
|
|
113
|
+
...selected?.meta,
|
|
114
|
+
...sourceMeta,
|
|
115
|
+
title: String(raw.menuName ?? selected?.meta?.title ?? ''),
|
|
116
|
+
authority: stringArray(raw.authCode ?? []),
|
|
117
|
+
order: Number(raw.sortNo ?? selected?.meta?.order ?? 0),
|
|
118
|
+
...(raw.url ? { externalLink: safeExternalUrl(raw.url) } : {}),
|
|
119
|
+
},
|
|
120
|
+
children,
|
|
121
|
+
},
|
|
122
|
+
];
|
|
123
|
+
});
|
|
124
|
+
};
|
|
125
|
+
return parseMenus(visit(value));
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
}
|