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.
Files changed (185) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +98 -12
  3. package/dist/cli.d.ts +18 -1
  4. package/dist/cli.d.ts.map +1 -1
  5. package/dist/cli.js +308 -28
  6. package/dist/cli.js.map +1 -1
  7. package/dist/create.d.ts +11 -1
  8. package/dist/create.d.ts.map +1 -1
  9. package/dist/create.js +98 -2
  10. package/dist/create.js.map +1 -1
  11. package/dist/index.d.ts +2 -1
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +1 -0
  14. package/dist/index.js.map +1 -1
  15. package/dist/presets.d.ts +41 -0
  16. package/dist/presets.d.ts.map +1 -0
  17. package/dist/presets.js +58 -0
  18. package/dist/presets.js.map +1 -0
  19. package/dist/project-cli.d.ts +3 -0
  20. package/dist/project-cli.d.ts.map +1 -0
  21. package/dist/project-cli.js +13 -0
  22. package/dist/project-cli.js.map +1 -0
  23. package/dist/projects.d.ts +21 -0
  24. package/dist/projects.d.ts.map +1 -0
  25. package/dist/projects.js +124 -0
  26. package/dist/projects.js.map +1 -0
  27. package/dist/scaffold.d.ts +2 -11
  28. package/dist/scaffold.d.ts.map +1 -1
  29. package/dist/scaffold.js +2 -1602
  30. package/dist/scaffold.js.map +1 -1
  31. package/dist/templates.d.ts +7 -0
  32. package/dist/templates.d.ts.map +1 -0
  33. package/dist/templates.js +91 -0
  34. package/dist/templates.js.map +1 -0
  35. package/dist/types.d.ts +64 -0
  36. package/dist/types.d.ts.map +1 -0
  37. package/dist/types.js +2 -0
  38. package/dist/types.js.map +1 -0
  39. package/package.json +13 -4
  40. package/templates/base/.env.example +4 -0
  41. package/templates/base/API-CONTRACT.md +84 -0
  42. package/templates/base/README.md +59 -0
  43. package/templates/base/index.html +12 -0
  44. package/templates/base/package.json +38 -0
  45. package/templates/base/src/App.vue +206 -0
  46. package/templates/base/src/app/components/AppHeaderActions.vue +46 -0
  47. package/templates/base/src/app/components/AppSettingsDrawer.vue +37 -0
  48. package/templates/base/src/app/menu-policy.ts +163 -0
  49. package/templates/base/src/app/module.ts +46 -0
  50. package/templates/base/src/app/router/index.ts +107 -0
  51. package/templates/base/src/app/router/views.ts +12 -0
  52. package/templates/base/src/app/services/access.ts +2 -0
  53. package/templates/base/src/app/services/contracts.ts +54 -0
  54. package/templates/base/src/app/services/local-contracts.ts +18 -0
  55. package/templates/base/src/app/services/preferences.ts +41 -0
  56. package/templates/base/src/app/services/session-runtime.ts +186 -0
  57. package/templates/base/src/app/services/session.ts +14 -0
  58. package/templates/base/src/app/stored-menus.ts +72 -0
  59. package/templates/base/src/app/views/SafeIframeView.vue +34 -0
  60. package/templates/base/src/app/views/auth/LoginView.vue +75 -0
  61. package/templates/base/src/app/views/error/ForbiddenView.vue +20 -0
  62. package/templates/base/src/app/views/error/NotFoundView.vue +16 -0
  63. package/templates/base/src/main.ts +40 -0
  64. package/templates/base/src/modules/business/README.md +33 -0
  65. package/templates/base/src/modules/dashboard/DashboardView.vue +27 -0
  66. package/templates/base/src/modules/dashboard/module.ts +14 -0
  67. package/templates/base/src/shared/api.ts +53 -0
  68. package/templates/base/src/shared/components/BaseEntityDialog.vue +39 -0
  69. package/templates/base/src/shared/composables/use-async-data.ts +35 -0
  70. package/templates/base/src/shared/composables/use-entity-editor.ts +77 -0
  71. package/templates/base/src/shared/status.ts +4 -0
  72. package/templates/base/src/shared/tree.ts +41 -0
  73. package/templates/base/src/styles.scss +173 -0
  74. package/templates/base/tests/menu-policy.test.ts.template +89 -0
  75. package/templates/base/tests/session.test.ts.template +45 -0
  76. package/templates/base/vite.config.ts +54 -0
  77. package/templates/blank/README.md +6 -0
  78. package/templates/blank/src/App.vue +11 -0
  79. package/templates/common/AGENTS.md +13 -0
  80. package/templates/common/_editorconfig +15 -0
  81. package/templates/common/_gitattributes +6 -0
  82. package/templates/common/_gitignore +7 -0
  83. package/templates/common/_prettierignore +6 -0
  84. package/templates/common/docs/DEVELOPMENT.md +26 -0
  85. package/templates/common/eslint.config.mjs +60 -0
  86. package/templates/common/package.json +39 -0
  87. package/templates/common/prettier.config.mjs +8 -0
  88. package/templates/common/tsconfig.json +19 -0
  89. package/templates/common/vitest.config.ts +3 -0
  90. package/templates/features/examples/src/modules/examples/CrudExampleView.vue +51 -0
  91. package/templates/features/examples/src/modules/examples/FormExampleView.vue +32 -0
  92. package/templates/features/examples/src/modules/examples/module.ts +27 -0
  93. package/templates/features/system/src/modules/system/config/ConfigView.vue +104 -0
  94. package/templates/features/system/src/modules/system/config/api.ts +38 -0
  95. package/templates/features/system/src/modules/system/config/routes.ts +21 -0
  96. package/templates/features/system/src/modules/system/dictionary/DictionaryItemView.vue +141 -0
  97. package/templates/features/system/src/modules/system/dictionary/DictionaryTypeView.vue +107 -0
  98. package/templates/features/system/src/modules/system/dictionary/api.ts +61 -0
  99. package/templates/features/system/src/modules/system/dictionary/cache.ts +9 -0
  100. package/templates/features/system/src/modules/system/dictionary/routes.ts +32 -0
  101. package/templates/features/system/src/modules/system/dictionary/types.ts +22 -0
  102. package/templates/features/system/src/modules/system/local.ts +348 -0
  103. package/templates/features/system/src/modules/system/menu/MenuView.vue +133 -0
  104. package/templates/features/system/src/modules/system/menu/api.ts +73 -0
  105. package/templates/features/system/src/modules/system/menu/components/MenuEditorDialog.vue +106 -0
  106. package/templates/features/system/src/modules/system/menu/routes.ts +23 -0
  107. package/templates/features/system/src/modules/system/menu/types.ts +18 -0
  108. package/templates/features/system/src/modules/system/module.ts +22 -0
  109. package/templates/features/system/src/modules/system/organization/OrganizationView.vue +134 -0
  110. package/templates/features/system/src/modules/system/organization/api.ts +38 -0
  111. package/templates/features/system/src/modules/system/organization/routes.ts +23 -0
  112. package/templates/features/system/src/modules/system/organization/types.ts +17 -0
  113. package/templates/features/system/src/modules/system/role/RoleView.vue +90 -0
  114. package/templates/features/system/src/modules/system/role/api.ts +40 -0
  115. package/templates/features/system/src/modules/system/role/components/RoleAuthorizationDialog.vue +110 -0
  116. package/templates/features/system/src/modules/system/role/permission-tree.ts +31 -0
  117. package/templates/features/system/src/modules/system/role/routes.ts +24 -0
  118. package/templates/features/system/src/modules/system/role/types.ts +12 -0
  119. package/templates/features/system/src/modules/system/user/UserView.vue +297 -0
  120. package/templates/features/system/src/modules/system/user/api.ts +45 -0
  121. package/templates/features/system/src/modules/system/user/components/UserOrganizationFilter.vue +61 -0
  122. package/templates/features/system/src/modules/system/user/components/UserRoleDialog.vue +93 -0
  123. package/templates/features/system/src/modules/system/user/routes.ts +27 -0
  124. package/templates/features/system/src/modules/system/user/types.ts +28 -0
  125. package/templates/features/system/tests/system.test.ts.template +266 -0
  126. package/templates/modes/http/src/app/services/data-source.ts +124 -0
  127. package/templates/modes/http/src/app/services/http-adapter.ts +128 -0
  128. package/templates/modes/http/tests/http.test.ts.template +218 -0
  129. package/templates/modes/local/src/app/services/data-source.ts +113 -0
  130. package/templates/modes/local/src/app/services/local-database.ts +67 -0
  131. package/templates/modes/local/tests/local.test.ts.template +132 -0
  132. package/templates/presets/standard/src/modules/dashboard/DashboardView.vue +52 -0
  133. package/templates/presets/standard/src/modules/dashboard/components/DashboardSessionStatus.vue +41 -0
  134. package/templates/presets/standard/src/modules/dashboard/components/DashboardShortcuts.vue +51 -0
  135. package/templates/presets/standard/src/modules/profile/ProfileView.vue +109 -0
  136. package/templates/presets/standard/src/modules/profile/api.ts +49 -0
  137. package/templates/presets/standard/src/modules/profile/components/ProfilePasswordForm.vue +68 -0
  138. package/templates/presets/standard/src/modules/profile/local.ts +40 -0
  139. package/templates/presets/standard/src/modules/profile/module.ts +11 -0
  140. package/templates/presets/standard/tests/profile.test.ts.template +72 -0
  141. package/templates/screen/README.md +6 -0
  142. package/templates/screen/src/App.vue +7 -0
  143. package/templates/screen/src/screen/ScreenEntry.vue +18 -0
  144. package/templates/screen/src/screen/ScreenView.vue +48 -0
  145. package/templates/screen/src/screen/components/ScreenMetrics.vue +40 -0
  146. package/templates/screen-auth/src/screen/ScreenEntry.vue +52 -0
  147. package/templates/screen-auth/src/screen/components/ScreenLoginForm.vue +44 -0
  148. package/templates/screen-auth/src/services/session.ts +61 -0
  149. package/templates/screen-auth/tests/session.test.ts.template +54 -0
  150. package/templates/screen-auth-http/src/services/auth-adapter.ts +14 -0
  151. package/templates/screen-auth-http/tests/auth-adapter.test.ts.template +17 -0
  152. package/templates/screen-auth-local-demo/src/services/auth-adapter.ts +15 -0
  153. package/templates/screen-auth-local-demo/tests/auth-adapter.test.ts.template +18 -0
  154. package/templates/screen-data/src/screen/ScreenView.vue +69 -0
  155. package/templates/screen-data/src/services/metrics-types.ts +4 -0
  156. package/templates/screen-data-http/src/services/metrics.ts +20 -0
  157. package/templates/screen-data-http/src/services/request.ts +3 -0
  158. package/templates/screen-data-http/tests/metrics.test.ts.template +21 -0
  159. package/templates/screen-data-local/src/services/metrics.ts +11 -0
  160. package/templates/screen-data-local/tests/metrics.test.ts.template +9 -0
  161. package/templates/screen-http/API-CONTRACT.md +20 -0
  162. package/templates/screen-http/src/services/http.ts +17 -0
  163. package/templates/screen-request-auth/src/services/request.ts +15 -0
  164. package/templates/screen-router/src/App.vue +7 -0
  165. package/templates/screen-router/src/main.ts +13 -0
  166. package/templates/screen-router/src/router.ts +7 -0
  167. package/templates/screen-router/tests/app.test.ts.template +20 -0
  168. package/templates/screen-viewport/src/screen/ScreenViewport.vue +30 -0
  169. package/templates/screen-visualization-charts/src/screen/components/ScreenVisualization.vue +22 -0
  170. package/templates/screen-visualization-cockpit/src/screen/components/ScreenVisualization.vue +33 -0
  171. package/templates/screen-visualization-cockpit/src/screen/components/ScreenWidget.vue +10 -0
  172. package/templates/screen-visualization-datav/src/screen/components/ScreenVisualization.vue +11 -0
  173. package/templates/vben-compatible/MIGRATION.md +30 -0
  174. package/templates/vben-compatible/src/modules/vben-migration/VbenFormView.vue +11 -0
  175. package/templates/vben-compatible/src/modules/vben-migration/VbenUsersView.vue +12 -0
  176. package/templates/vben-compatible/src/modules/vben-migration/components/VbenFormExample.vue +64 -0
  177. package/templates/vben-compatible/src/modules/vben-migration/components/VbenUsers.vue +144 -0
  178. package/templates/vben-compatible/src/modules/vben-migration/local.ts +68 -0
  179. package/templates/vben-compatible/src/modules/vben-migration/module.ts +42 -0
  180. package/templates/vue/index.html +12 -0
  181. package/templates/vue/src/main.ts +5 -0
  182. package/templates/vue/src/styles.css +19 -0
  183. package/templates/vue/tests/app.test.ts.template +10 -0
  184. package/templates/vue/vite.config.ts +9 -0
  185. 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
+ }