create-lumal-admin 0.0.5-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 (194) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +39 -10
  3. package/dist/cli.d.ts +9 -3
  4. package/dist/cli.d.ts.map +1 -1
  5. package/dist/cli.js +236 -19
  6. package/dist/cli.js.map +1 -1
  7. package/dist/create.d.ts +10 -1
  8. package/dist/create.d.ts.map +1 -1
  9. package/dist/create.js +18 -5
  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 +29 -3
  16. package/dist/presets.d.ts.map +1 -1
  17. package/dist/presets.js +38 -10
  18. package/dist/presets.js.map +1 -1
  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/templates.d.ts +2 -0
  28. package/dist/templates.d.ts.map +1 -1
  29. package/dist/templates.js +42 -13
  30. package/dist/templates.js.map +1 -1
  31. package/dist/types.d.ts +39 -0
  32. package/dist/types.d.ts.map +1 -1
  33. package/package.json +11 -3
  34. package/templates/base/API-CONTRACT.md +67 -20
  35. package/templates/base/README.md +20 -3
  36. package/templates/base/src/App.vue +71 -79
  37. package/templates/base/src/app/components/AppHeaderActions.vue +46 -0
  38. package/templates/base/src/{components/app → app/components}/AppSettingsDrawer.vue +15 -15
  39. package/templates/base/src/app/menu-policy.ts +163 -0
  40. package/templates/base/src/app/module.ts +46 -0
  41. package/templates/base/src/app/router/index.ts +107 -0
  42. package/templates/base/src/app/router/views.ts +12 -0
  43. package/templates/base/src/{services → app/services}/access.ts +1 -1
  44. package/templates/base/src/app/services/contracts.ts +54 -0
  45. package/templates/base/src/app/services/local-contracts.ts +18 -0
  46. package/templates/base/src/{services → app/services}/preferences.ts +8 -8
  47. package/templates/base/src/app/services/session-runtime.ts +186 -0
  48. package/templates/base/src/{services → app/services}/session.ts +4 -4
  49. package/templates/base/src/app/stored-menus.ts +72 -0
  50. package/templates/base/src/app/views/SafeIframeView.vue +34 -0
  51. package/templates/base/src/{views → app/views}/auth/LoginView.vue +29 -34
  52. package/templates/base/src/app/views/error/ForbiddenView.vue +20 -0
  53. package/templates/base/src/{views → app/views}/error/NotFoundView.vue +5 -7
  54. package/templates/base/src/main.ts +21 -22
  55. package/templates/base/src/modules/business/README.md +33 -0
  56. package/templates/base/src/{views → modules}/dashboard/DashboardView.vue +3 -3
  57. package/templates/base/src/modules/dashboard/module.ts +14 -0
  58. package/templates/base/src/shared/api.ts +53 -0
  59. package/templates/base/src/shared/components/BaseEntityDialog.vue +39 -0
  60. package/templates/base/src/shared/composables/use-async-data.ts +35 -0
  61. package/templates/base/src/shared/composables/use-entity-editor.ts +77 -0
  62. package/templates/base/src/shared/status.ts +4 -0
  63. package/templates/base/src/shared/tree.ts +41 -0
  64. package/templates/base/src/styles.scss +1 -6
  65. package/templates/base/tests/menu-policy.test.ts.template +89 -0
  66. package/templates/base/tests/session.test.ts.template +38 -27
  67. package/templates/base/vite.config.ts +4 -4
  68. package/templates/blank/README.md +6 -0
  69. package/templates/blank/src/App.vue +11 -0
  70. package/templates/common/AGENTS.md +13 -0
  71. package/templates/common/_editorconfig +15 -0
  72. package/templates/common/_gitattributes +6 -0
  73. package/templates/{base → common}/_gitignore +1 -0
  74. package/templates/common/_prettierignore +6 -0
  75. package/templates/common/docs/DEVELOPMENT.md +26 -0
  76. package/templates/common/eslint.config.mjs +60 -0
  77. package/templates/common/package.json +39 -0
  78. package/templates/common/prettier.config.mjs +8 -0
  79. package/templates/{base → common}/tsconfig.json +2 -5
  80. package/templates/common/vitest.config.ts +3 -0
  81. package/templates/features/examples/src/modules/examples/CrudExampleView.vue +51 -0
  82. package/templates/features/examples/src/{views → modules}/examples/FormExampleView.vue +15 -8
  83. package/templates/features/examples/src/modules/examples/module.ts +27 -0
  84. package/templates/features/system/src/modules/system/config/ConfigView.vue +104 -0
  85. package/templates/features/system/src/modules/system/config/api.ts +38 -0
  86. package/templates/features/system/src/modules/system/config/routes.ts +21 -0
  87. package/templates/features/system/src/modules/system/dictionary/DictionaryItemView.vue +141 -0
  88. package/templates/features/system/src/modules/system/dictionary/DictionaryTypeView.vue +107 -0
  89. package/templates/features/system/src/modules/system/dictionary/api.ts +61 -0
  90. package/templates/features/system/src/modules/system/dictionary/cache.ts +9 -0
  91. package/templates/features/system/src/modules/system/dictionary/routes.ts +32 -0
  92. package/templates/features/system/src/modules/system/dictionary/types.ts +22 -0
  93. package/templates/features/system/src/modules/system/local.ts +348 -0
  94. package/templates/features/system/src/modules/system/menu/MenuView.vue +133 -0
  95. package/templates/features/system/src/modules/system/menu/api.ts +73 -0
  96. package/templates/features/system/src/modules/system/menu/components/MenuEditorDialog.vue +106 -0
  97. package/templates/features/system/src/modules/system/menu/routes.ts +23 -0
  98. package/templates/features/system/src/modules/system/menu/types.ts +18 -0
  99. package/templates/features/system/src/modules/system/module.ts +22 -0
  100. package/templates/features/system/src/modules/system/organization/OrganizationView.vue +134 -0
  101. package/templates/features/system/src/modules/system/organization/api.ts +38 -0
  102. package/templates/features/system/src/modules/system/organization/routes.ts +23 -0
  103. package/templates/features/system/src/modules/system/organization/types.ts +17 -0
  104. package/templates/features/system/src/modules/system/role/RoleView.vue +90 -0
  105. package/templates/features/system/src/modules/system/role/api.ts +40 -0
  106. package/templates/features/system/src/modules/system/role/components/RoleAuthorizationDialog.vue +110 -0
  107. package/templates/features/system/src/modules/system/role/permission-tree.ts +31 -0
  108. package/templates/features/system/src/modules/system/role/routes.ts +24 -0
  109. package/templates/features/system/src/modules/system/role/types.ts +12 -0
  110. package/templates/features/system/src/modules/system/user/UserView.vue +297 -0
  111. package/templates/features/system/src/modules/system/user/api.ts +45 -0
  112. package/templates/features/system/src/modules/system/user/components/UserOrganizationFilter.vue +61 -0
  113. package/templates/features/system/src/modules/system/user/components/UserRoleDialog.vue +93 -0
  114. package/templates/features/system/src/modules/system/user/routes.ts +27 -0
  115. package/templates/features/system/src/modules/system/user/types.ts +28 -0
  116. package/templates/features/system/tests/system.test.ts.template +266 -0
  117. package/templates/modes/http/src/app/services/data-source.ts +124 -0
  118. package/templates/modes/http/src/app/services/http-adapter.ts +128 -0
  119. package/templates/modes/http/tests/http.test.ts.template +186 -133
  120. package/templates/modes/local/src/app/services/data-source.ts +113 -0
  121. package/templates/modes/local/src/app/services/local-database.ts +67 -0
  122. package/templates/modes/local/tests/local.test.ts.template +117 -77
  123. package/templates/presets/standard/src/modules/dashboard/DashboardView.vue +52 -0
  124. package/templates/presets/standard/src/modules/dashboard/components/DashboardSessionStatus.vue +41 -0
  125. package/templates/presets/standard/src/modules/dashboard/components/DashboardShortcuts.vue +51 -0
  126. package/templates/presets/standard/src/modules/profile/ProfileView.vue +109 -0
  127. package/templates/presets/standard/src/modules/profile/api.ts +49 -0
  128. package/templates/presets/standard/src/modules/profile/components/ProfilePasswordForm.vue +68 -0
  129. package/templates/presets/standard/src/modules/profile/local.ts +40 -0
  130. package/templates/presets/standard/src/modules/profile/module.ts +11 -0
  131. package/templates/presets/standard/tests/profile.test.ts.template +72 -0
  132. package/templates/screen/README.md +6 -0
  133. package/templates/screen/src/App.vue +7 -0
  134. package/templates/screen/src/screen/ScreenEntry.vue +18 -0
  135. package/templates/screen/src/screen/ScreenView.vue +48 -0
  136. package/templates/screen/src/screen/components/ScreenMetrics.vue +40 -0
  137. package/templates/screen-auth/src/screen/ScreenEntry.vue +52 -0
  138. package/templates/screen-auth/src/screen/components/ScreenLoginForm.vue +44 -0
  139. package/templates/screen-auth/src/services/session.ts +61 -0
  140. package/templates/screen-auth/tests/session.test.ts.template +54 -0
  141. package/templates/screen-auth-http/src/services/auth-adapter.ts +14 -0
  142. package/templates/screen-auth-http/tests/auth-adapter.test.ts.template +17 -0
  143. package/templates/screen-auth-local-demo/src/services/auth-adapter.ts +15 -0
  144. package/templates/screen-auth-local-demo/tests/auth-adapter.test.ts.template +18 -0
  145. package/templates/screen-data/src/screen/ScreenView.vue +69 -0
  146. package/templates/screen-data/src/services/metrics-types.ts +4 -0
  147. package/templates/screen-data-http/src/services/metrics.ts +20 -0
  148. package/templates/screen-data-http/src/services/request.ts +3 -0
  149. package/templates/screen-data-http/tests/metrics.test.ts.template +21 -0
  150. package/templates/screen-data-local/src/services/metrics.ts +11 -0
  151. package/templates/screen-data-local/tests/metrics.test.ts.template +9 -0
  152. package/templates/screen-http/API-CONTRACT.md +20 -0
  153. package/templates/screen-http/src/services/http.ts +17 -0
  154. package/templates/screen-request-auth/src/services/request.ts +15 -0
  155. package/templates/screen-router/src/App.vue +7 -0
  156. package/templates/screen-router/src/main.ts +13 -0
  157. package/templates/screen-router/src/router.ts +7 -0
  158. package/templates/screen-router/tests/app.test.ts.template +20 -0
  159. package/templates/screen-viewport/src/screen/ScreenViewport.vue +30 -0
  160. package/templates/screen-visualization-charts/src/screen/components/ScreenVisualization.vue +22 -0
  161. package/templates/screen-visualization-cockpit/src/screen/components/ScreenVisualization.vue +33 -0
  162. package/templates/screen-visualization-cockpit/src/screen/components/ScreenWidget.vue +10 -0
  163. package/templates/screen-visualization-datav/src/screen/components/ScreenVisualization.vue +11 -0
  164. package/templates/vben-compatible/MIGRATION.md +2 -0
  165. package/templates/vben-compatible/src/{views/migration → modules/vben-migration}/VbenFormView.vue +2 -1
  166. package/templates/vben-compatible/src/{views/migration → modules/vben-migration}/VbenUsersView.vue +3 -2
  167. package/templates/vben-compatible/src/modules/vben-migration/components/VbenFormExample.vue +64 -0
  168. package/templates/vben-compatible/src/{components/migration → modules/vben-migration/components}/VbenUsers.vue +73 -64
  169. package/templates/vben-compatible/src/modules/vben-migration/local.ts +68 -0
  170. package/templates/vben-compatible/src/modules/vben-migration/module.ts +42 -0
  171. package/templates/vue/index.html +12 -0
  172. package/templates/vue/src/main.ts +5 -0
  173. package/templates/vue/src/styles.css +19 -0
  174. package/templates/vue/tests/app.test.ts.template +10 -0
  175. package/templates/vue/vite.config.ts +9 -0
  176. package/templates/vue/vitest.config.ts +4 -0
  177. package/templates/base/eslint.config.mjs +0 -8
  178. package/templates/base/src/components/app/AppHeaderActions.vue +0 -34
  179. package/templates/base/src/router/index.ts +0 -86
  180. package/templates/base/src/router/views.ts +0 -16
  181. package/templates/base/src/services/contracts.ts +0 -29
  182. package/templates/base/src/services/session-runtime.ts +0 -137
  183. package/templates/base/src/views/error/ForbiddenView.vue +0 -25
  184. package/templates/base/vitest.config.ts +0 -3
  185. package/templates/features/examples/src/modules/examples.ts +0 -10
  186. package/templates/features/examples/src/views/examples/CrudExampleView.vue +0 -30
  187. package/templates/features/system/src/business/system/entities.ts +0 -93
  188. package/templates/features/system/src/modules/system.ts +0 -28
  189. package/templates/features/system/src/views/system/SystemEntityView.vue +0 -85
  190. package/templates/modes/http/src/services/data-source.ts +0 -120
  191. package/templates/modes/http/src/services/http-adapter.ts +0 -124
  192. package/templates/modes/local/src/services/data-source.ts +0 -176
  193. package/templates/vben-compatible/src/components/migration/VbenFormExample.vue +0 -34
  194. package/templates/vben-compatible/src/modules/vben.ts +0 -17
@@ -0,0 +1,348 @@
1
+ import type { DataRow } from '../../app/services/contracts';
2
+ import type { LocalBusinessContext, LocalHandler } from '../../app/services/local-contracts';
3
+ import { object, stringArray } from '../../app/menu-policy';
4
+ import { permissionCodes, permissionTree, viewComponents } from '../../app/router/views';
5
+ import { buildStoredMenus } from '../../app/stored-menus';
6
+ import { assertHierarchy, buildTree } from '../../shared/tree';
7
+ import { configInput } from './config/api';
8
+ import { dictionaryItemInput, dictionaryTypeInput } from './dictionary/api';
9
+ import { menuInput } from './menu/api';
10
+ import { organizationInput } from './organization/api';
11
+ import { roleInput } from './role/api';
12
+ import { userInput } from './user/api';
13
+
14
+ const defaultConfig = {
15
+ appName: 'Lumal Admin',
16
+ colorPrimary: '#1677ff',
17
+ layout: 'mixed-nav',
18
+ tabbarEnable: true,
19
+ transitionEnable: true,
20
+ };
21
+
22
+ /** 本地业务模型独立于页面,使用与 HTTP 相同的业务端点和关联约束。 */
23
+ export function createLocalHandler({ db, user, assertPermission }: LocalBusinessContext): LocalHandler {
24
+ const initial: Record<string, DataRow[]> = {
25
+ organizations: [
26
+ {
27
+ id: 'root',
28
+ parentId: '',
29
+ name: '总部',
30
+ code: 'root',
31
+ leader: '管理员',
32
+ phone: '',
33
+ email: '',
34
+ status: 'enabled',
35
+ order: 0,
36
+ },
37
+ ],
38
+ 'dictionaries/types': [
39
+ { id: 'status', name: '状态', code: 'status', description: '开发演示状态', status: 'enabled' },
40
+ ],
41
+ 'dictionaries/items': [
42
+ {
43
+ id: 'enabled',
44
+ typeCode: 'status',
45
+ label: '启用',
46
+ value: 'enabled',
47
+ color: '#15803d',
48
+ order: 1,
49
+ status: 'enabled',
50
+ },
51
+ {
52
+ id: 'disabled',
53
+ typeCode: 'status',
54
+ label: '停用',
55
+ value: 'disabled',
56
+ color: '#64748b',
57
+ order: 2,
58
+ status: 'enabled',
59
+ },
60
+ ],
61
+ };
62
+ const read = (resource: string) => db.read(resource, () => initial[resource] ?? []);
63
+ const cleanUser = ({ password: _password, ...row }: DataRow) => row;
64
+ const hierarchy = (rows: DataRow[]) =>
65
+ rows.map((row): DataRow & { id: string; parentId: string } => ({
66
+ ...row,
67
+ id: String(row.id),
68
+ parentId: String(row.parentId ?? ''),
69
+ }));
70
+ function requireRecord(rows: DataRow[], id: string): DataRow {
71
+ const record = rows.find((row) => row.id === id);
72
+ if (!record) throw new Error('记录不存在');
73
+ return record;
74
+ }
75
+ function validateUser(model: DataRow, id?: string): DataRow {
76
+ const input = userInput(model);
77
+ if (input.nickname.length > 32 || (input.phone && !/^1\d{10}$/.test(input.phone)) || !input.roles.length)
78
+ throw new Error('用户资料无效');
79
+ if (read('users').some((row) => row.id !== id && row.username === input.username)) throw new Error('用户名重复');
80
+ if (
81
+ input.organizationId &&
82
+ !read('organizations').some((row) => row.id === input.organizationId && row.status === 'enabled')
83
+ )
84
+ throw new Error('所属机构不存在或已停用');
85
+ if (input.roles.some((code) => !read('roles').some((row) => row.code === code && row.status === 'enabled')))
86
+ throw new Error('角色不存在或已停用');
87
+ const previous = id ? requireRecord(read('users'), id) : undefined;
88
+ if (
89
+ previous?.username === 'admin' &&
90
+ (input.username !== 'admin' || input.status !== 'enabled' || !input.roles.includes('admin'))
91
+ )
92
+ throw new Error('不能停用或移除内置管理员角色');
93
+ if (previous && JSON.stringify(previous.roles) !== JSON.stringify(input.roles))
94
+ assertPermission('system:user:assign-roles');
95
+ if (previous && previous.status !== input.status) assertPermission('system:user:status');
96
+ return {
97
+ password: 'lumal123',
98
+ ...previous,
99
+ ...input,
100
+ id: id ?? crypto.randomUUID(),
101
+ createdAt: previous?.createdAt ?? new Date().toISOString(),
102
+ };
103
+ }
104
+ function saveRows(resource: string, record: DataRow, id?: string) {
105
+ const rows = read(resource);
106
+ db.write(resource, id ? rows.map((row) => (row.id === id ? record : row)) : [...rows, record]);
107
+ return resource === 'users' ? cleanUser(record) : record;
108
+ }
109
+ function validate(resource: string, body: DataRow, id?: string): DataRow {
110
+ if (resource === 'users') return validateUser(body, id);
111
+ const rows = read(resource);
112
+ const previous = id ? requireRecord(rows, id) : undefined;
113
+ let input: DataRow;
114
+ if (resource === 'roles') {
115
+ if (Object.hasOwn(body, 'permissions')) throw new Error('角色资料与授权必须分别保存');
116
+ input = { ...roleInput(body), builtIn: previous?.builtIn ?? false, permissions: previous?.permissions ?? [] };
117
+ if (previous && input.code !== previous.code) throw new Error('角色编码不允许修改');
118
+ if (previous?.builtIn && input.status !== 'enabled') throw new Error('不能停用内置角色');
119
+ } else if (resource === 'organizations') {
120
+ input = { ...organizationInput(body) };
121
+ } else if (resource === 'menus') {
122
+ input = { ...menuInput(body) };
123
+ } else if (resource === 'dictionaries/types') {
124
+ input = { ...dictionaryTypeInput(body) };
125
+ if (previous && input.code !== previous.code) throw new Error('字典编码不允许修改');
126
+ } else {
127
+ input = { ...dictionaryItemInput(body) };
128
+ if (!read('dictionaries/types').some((row) => row.code === input.typeCode)) throw new Error('字典分类不存在');
129
+ if (rows.some((row) => row.id !== id && row.typeCode === input.typeCode && row.value === input.value))
130
+ throw new Error('同分类字典值重复');
131
+ }
132
+ const unique = ['roles', 'organizations', 'dictionaries/types'].includes(resource) ? 'code' : undefined;
133
+ if (unique && rows.some((row) => row.id !== id && row[unique] === input[unique])) throw new Error('编码重复');
134
+ const record: DataRow = { ...previous, ...input, id: id ?? crypto.randomUUID() };
135
+ const next = id ? rows.map((row) => (row.id === id ? record : row)) : [...rows, record];
136
+ if (resource === 'organizations') assertHierarchy(hierarchy(next));
137
+ if (resource === 'menus') {
138
+ assertHierarchy(hierarchy(next));
139
+ const active = next.filter((row) => row.type !== 'button');
140
+ if (
141
+ new Set(active.map((row) => row.path)).size !== active.length ||
142
+ new Set(active.map((row) => row.name)).size !== active.length
143
+ )
144
+ throw new Error('菜单路径或路由名重复');
145
+ buildStoredMenus(next, viewComponents);
146
+ }
147
+ return record;
148
+ }
149
+ function remove(resource: string, id: string): void {
150
+ const rows = read(resource);
151
+ const record = requireRecord(rows, id);
152
+ if ((resource === 'users' && record.username === 'admin') || (resource === 'roles' && record.builtIn))
153
+ throw new Error('不能删除内置管理员或内置角色');
154
+ if (resource === 'roles' && read('users').some((row) => stringArray(row.roles).includes(String(record.code))))
155
+ throw new Error('角色仍被用户引用');
156
+ if (['menus', 'organizations'].includes(resource) && rows.some((row) => row.parentId === id))
157
+ throw new Error('请先删除子节点');
158
+ if (resource === 'organizations' && read('users').some((row) => row.organizationId === id))
159
+ throw new Error('机构仍有关联用户');
160
+ if (resource === 'dictionaries/types')
161
+ db.write(
162
+ 'dictionaries/items',
163
+ read('dictionaries/items').filter((row) => row.typeCode !== record.code),
164
+ );
165
+ db.write(
166
+ resource,
167
+ rows.filter((row) => row.id !== id),
168
+ );
169
+ }
170
+ return {
171
+ handles: (path) => path.startsWith('/system/'),
172
+ async request(method, path, request) {
173
+ user();
174
+ const assertMethod = (...allowed: string[]) => {
175
+ if (!allowed.includes(method)) throw new Error(`此接口不支持 ${method} 操作`);
176
+ };
177
+ const body = request.body === undefined ? {} : object(request.body);
178
+ if (path === '/system/roles/options') {
179
+ assertMethod('GET');
180
+ assertPermission('system:user:list');
181
+ return read('roles').map((row) => ({ label: row.name, value: row.code, disabled: row.status !== 'enabled' }));
182
+ }
183
+ if (path === '/system/organizations/options') {
184
+ assertMethod('GET');
185
+ assertPermission('system:user:list');
186
+ const convert = (items: (DataRow & { id: string; children?: DataRow[] })[]): unknown[] =>
187
+ items.map((row) => ({
188
+ label: row.name,
189
+ value: row.id,
190
+ disabled: row.status !== 'enabled',
191
+ children: convert((row.children ?? []).map((child) => ({ ...child, id: String(child.id) }))),
192
+ }));
193
+ return convert(buildTree(hierarchy(read('organizations'))));
194
+ }
195
+ if (path === '/system/permissions/tree') {
196
+ assertMethod('GET');
197
+ assertPermission('system:role:authorize');
198
+ return structuredClone(permissionTree);
199
+ }
200
+ const grant = path.match(/^\/system\/roles\/([^/]+)\/permissions$/);
201
+ if (grant) {
202
+ assertMethod('GET', 'PUT');
203
+ assertPermission('system:role:authorize');
204
+ const code = decodeURIComponent(grant[1]!);
205
+ const roles = read('roles');
206
+ const role = roles.find((row) => row.code === code);
207
+ if (!role) throw new Error('角色不存在');
208
+ if (method === 'GET') return stringArray(role.permissions ?? []);
209
+ const permissions = stringArray(body.permissions);
210
+ if (permissions.some((code) => !permissionCodes.includes(code))) throw new Error('授权包含未注册权限');
211
+ if (role.code === 'admin' && !permissionCodes.every((code) => permissions.includes(code)))
212
+ throw new Error('不能移除内置管理员权限');
213
+ db.write(
214
+ 'roles',
215
+ roles.map((row) => (row.id === role.id ? { ...row, permissions } : row)),
216
+ );
217
+ return permissions;
218
+ }
219
+ const batch = path.match(/^\/system\/users\/batch\/(status|roles)$/);
220
+ const special = path.match(/^\/system\/users\/([^/]+)\/(status|roles|reset-password)$/);
221
+ if (batch || special) {
222
+ const action = batch?.[1] ?? special?.[2];
223
+ assertMethod(action === 'status' ? 'PATCH' : action === 'roles' ? 'PUT' : 'POST');
224
+ assertPermission(`system:user:${action === 'roles' ? 'assign-roles' : action}`);
225
+ const all = read('users');
226
+ const ids = batch ? stringArray(body.ids) : [decodeURIComponent(special![1]!)];
227
+ if (!ids.length) throw new Error('请先选择用户');
228
+ const selected = ids.map((id) => requireRecord(all, id));
229
+ if (action === 'reset-password') {
230
+ const temporaryPassword = crypto.randomUUID().replaceAll('-', '').slice(0, 16);
231
+ db.write(
232
+ 'users',
233
+ all.map((row) => (ids.includes(String(row.id)) ? { ...row, password: temporaryPassword } : row)),
234
+ );
235
+ return { temporaryPassword };
236
+ }
237
+ if (batch && action === 'roles' && !['append', 'replace'].includes(String(body.mode)))
238
+ throw new Error('角色分配方式无效');
239
+ const changed = selected.map((row) => {
240
+ const roles = stringArray(body.roles ?? []);
241
+ return validateUser(
242
+ {
243
+ ...row,
244
+ ...(action === 'status'
245
+ ? { status: body.status }
246
+ : {
247
+ roles:
248
+ batch && body.mode === 'append' ? [...new Set([...stringArray(row.roles), ...roles])] : roles,
249
+ }),
250
+ },
251
+ String(row.id),
252
+ );
253
+ });
254
+ db.write(
255
+ 'users',
256
+ all.map((row) => changed.find((item) => item.id === row.id) ?? row),
257
+ );
258
+ return batch ? { items: changed.map(cleanUser), updated: changed.length } : cleanUser(changed[0]!);
259
+ }
260
+ if (path.startsWith('/system/dictionaries/options/')) {
261
+ assertMethod('GET');
262
+ const code = decodeURIComponent(path.split('/').at(-1)!);
263
+ if (!read('dictionaries/types').some((row) => row.code === code && row.status === 'enabled')) return [];
264
+ return read('dictionaries/items')
265
+ .filter((row) => row.typeCode === code && row.status === 'enabled')
266
+ .sort((a, b) => Number(a.order) - Number(b.order))
267
+ .map((row) => ({ label: row.label, value: row.value, color: row.color }));
268
+ }
269
+ if (path === '/system/config' || path === '/system/config/restore') {
270
+ assertMethod(...(path.endsWith('/restore') ? ['POST'] : ['GET', 'PUT']));
271
+ assertPermission(`system:config:${method === 'GET' ? 'view' : 'update'}`);
272
+ const config =
273
+ method === 'GET'
274
+ ? db.read('config', () => [{ ...defaultConfig }])[0]!
275
+ : path.endsWith('/restore')
276
+ ? defaultConfig
277
+ : configInput(body);
278
+ if (method !== 'GET') db.write('config', [{ ...config }]);
279
+ return config;
280
+ }
281
+ const match = path.match(
282
+ /^\/system\/(users|roles|menus|organizations|dictionaries\/(?:types|items))(?:\/([^/]+))?$/,
283
+ );
284
+ if (!match) throw new Error(`本地不支持此接口:${method} ${path}`);
285
+ const resource = match[1]!;
286
+ const id = match[2] ? decodeURIComponent(match[2]) : undefined;
287
+ assertMethod(...(id ? ['PUT', 'DELETE'] : ['GET', 'POST']));
288
+ const prefix = {
289
+ users: 'user',
290
+ roles: 'role',
291
+ menus: 'menu',
292
+ organizations: 'organization',
293
+ 'dictionaries/types': 'dict',
294
+ 'dictionaries/items': 'dict',
295
+ }[resource]!;
296
+ assertPermission(
297
+ `system:${prefix}:${method === 'GET' ? 'list' : method === 'POST' ? 'create' : method === 'DELETE' ? 'delete' : 'update'}`,
298
+ );
299
+ if (method === 'GET') {
300
+ const query = request.query ?? {};
301
+ let result = read(resource);
302
+ if (query.keyword)
303
+ result = result.filter((row) =>
304
+ Object.values(row).some((value) => String(value).includes(String(query.keyword))),
305
+ );
306
+ if (query.status) result = result.filter((row) => row.status === query.status);
307
+ if (query.typeCode) result = result.filter((row) => row.typeCode === query.typeCode);
308
+ if (query.roles) result = result.filter((row) => stringArray(row.roles).includes(String(query.roles)));
309
+ if (query.organizationId) {
310
+ const organizations = hierarchy(read('organizations'));
311
+ const descendants = new Set([String(query.organizationId)]);
312
+ for (let size = -1; size !== descendants.size;) {
313
+ size = descendants.size;
314
+ organizations.filter((row) => descendants.has(row.parentId)).forEach((row) => descendants.add(row.id));
315
+ }
316
+ result = result.filter((row) => descendants.has(String(row.organizationId)));
317
+ }
318
+ if (resource === 'menus' || resource === 'organizations')
319
+ return buildTree(
320
+ hierarchy(result).map((row) => ({
321
+ ...row,
322
+ ...(resource === 'organizations'
323
+ ? { userCount: read('users').filter((user) => user.organizationId === row.id).length }
324
+ : {}),
325
+ })),
326
+ );
327
+ if (resource.startsWith('dictionaries/'))
328
+ return result.sort((a, b) => Number(a.order ?? 0) - Number(b.order ?? 0));
329
+ const page = Number(query.page ?? 1);
330
+ const size = Number(query.pageSize ?? 10);
331
+ if (!Number.isSafeInteger(page) || page < 1 || !Number.isSafeInteger(size) || size < 1 || size > 1000)
332
+ throw new Error('分页参数无效');
333
+ return {
334
+ items: result
335
+ .slice((page - 1) * size, page * size)
336
+ .map((row) => (resource === 'users' ? cleanUser(row) : row)),
337
+ total: result.length,
338
+ };
339
+ }
340
+ if (method === 'DELETE') {
341
+ remove(resource, id ?? '');
342
+ return;
343
+ }
344
+ const record = validate(resource, body, id);
345
+ return saveRows(resource, record, id);
346
+ },
347
+ };
348
+ }
@@ -0,0 +1,133 @@
1
+ <script setup lang="ts">
2
+ import type { SchemaTableColumn } from '@lumal/core/components';
3
+ import type { Menu } from './types';
4
+ import { LumalPage, LumalSchemaTable } from '@lumal/core/components';
5
+ import { LumalIcon } from '@lumal/icons-vue';
6
+ import { ElAlert, ElButton, ElInput, ElMessage } from 'element-plus';
7
+ import { computed, shallowRef } from 'vue';
8
+ import { application, permissionStore, service } from '../../../app/services/session';
9
+ import { useAsyncData } from '../../../shared/composables/use-async-data';
10
+ import { useEntityEditor } from '../../../shared/composables/use-entity-editor';
11
+ import { flattenTree } from '../../../shared/tree';
12
+ import { createMenuService, menuInput, parseMenu } from './api';
13
+ import MenuEditorDialog from './components/MenuEditorDialog.vue';
14
+
15
+ /***********************菜单树与独立刷新*********************/
16
+ const api = createMenuService(service);
17
+ const state = useAsyncData(api.list, [] as Menu[]);
18
+ const keyword = shallowRef('');
19
+ const refreshing = shallowRef(false);
20
+ const refreshError = shallowRef('');
21
+ const can = (action: string) => permissionStore.hasPermission(`system:menu:${action}`);
22
+ const records = computed(() => flattenTree(state.data.value));
23
+ function filterTree(items: Menu[]): Menu[] {
24
+ return items.flatMap((item) => {
25
+ const children = filterTree(item.children ?? []);
26
+ return !keyword.value || item.title.includes(keyword.value) || children.length ? [{ ...item, children }] : [];
27
+ });
28
+ }
29
+ const displayed = computed(() => filterTree(state.data.value));
30
+ const columns: SchemaTableColumn<Menu>[] = [
31
+ { field: 'title', label: '标题', minWidth: 210 },
32
+ {
33
+ field: 'type',
34
+ label: '类型',
35
+ width: 100,
36
+ formatter: (value) =>
37
+ ({ directory: '目录', menu: '菜单', button: '按钮', external: '外链', embedded: '内嵌' })[String(value)] ?? value,
38
+ },
39
+ { field: 'path', label: '路径', minWidth: 170 },
40
+ { field: 'component', label: '组件', minWidth: 170 },
41
+ { field: 'order', label: '排序', width: 80 },
42
+ { field: 'hidden', label: '显示', width: 80, formatter: (value) => (value ? '隐藏' : '显示') },
43
+ ];
44
+ const editor = useEntityEditor<Menu, ReturnType<typeof menuInput>>({
45
+ create: api.create,
46
+ update: api.update,
47
+ remove: api.remove,
48
+ toInput: menuInput,
49
+ reload: state.load,
50
+ });
51
+ async function refreshMenus(): Promise<void> {
52
+ refreshing.value = true;
53
+ refreshError.value = '';
54
+ try {
55
+ await application.refreshAccess();
56
+ ElMessage.success('导航与权限已重新加载');
57
+ } catch (reason) {
58
+ refreshError.value = reason instanceof Error ? reason.message : '导航刷新失败';
59
+ } finally {
60
+ refreshing.value = false;
61
+ }
62
+ }
63
+ function create(parent?: Menu): void {
64
+ editor.openCreate({
65
+ parentId: parent?.id ?? '',
66
+ type: parent?.type === 'menu' ? 'button' : 'menu',
67
+ hidden: false,
68
+ order: 0,
69
+ permissions: [],
70
+ });
71
+ }
72
+ </script>
73
+
74
+ <template>
75
+ <LumalPage class="lumal-admin-page" title="菜单管理" no-padding>
76
+ <ElAlert
77
+ v-if="editor.error.value || refreshError"
78
+ :title="editor.error.value || refreshError"
79
+ type="error"
80
+ :closable="false"
81
+ />
82
+ <LumalSchemaTable
83
+ :rows="displayed"
84
+ :columns="columns"
85
+ :loading="state.loading.value"
86
+ :error="state.error.value"
87
+ default-expand-all
88
+ :action-width="240"
89
+ @retry="state.reload"
90
+ >
91
+ <template #toolbar>
92
+ <ElInput
93
+ v-model="keyword"
94
+ placeholder="搜索菜单标题"
95
+ aria-label="搜索菜单"
96
+ clearable
97
+ style="max-width: 260px"
98
+ />
99
+ <ElButton v-if="can('create')" type="primary" @click="create()"> 新增节点 </ElButton>
100
+ </template>
101
+ <template #toolbar-tools>
102
+ <ElButton :loading="refreshing" @click="refreshMenus"> 刷新导航 </ElButton>
103
+ <ElButton title="重新加载" aria-label="重新加载" @click="state.reload">
104
+ <LumalIcon name="lumal:refresh" />
105
+ </ElButton>
106
+ </template>
107
+ <template #actions="{ row }">
108
+ <ElButton v-if="can('create') && ['directory', 'menu'].includes(row.type)" link @click="create(parseMenu(row))">
109
+ 新增子节点
110
+ </ElButton>
111
+ <ElButton v-if="can('update')" link @click="editor.openEdit(parseMenu(row))"> 编辑 </ElButton>
112
+ <ElButton
113
+ v-if="can('delete')"
114
+ link
115
+ type="danger"
116
+ :disabled="editor.saving.value"
117
+ @click="editor.remove(parseMenu(row))"
118
+ >
119
+ 删除
120
+ </ElButton>
121
+ </template>
122
+ </LumalSchemaTable>
123
+ <MenuEditorDialog
124
+ v-model:visible="editor.visible.value"
125
+ v-model:model="editor.model.value"
126
+ :records="records"
127
+ :editing-id="editor.editing.value?.id"
128
+ :saving="editor.saving.value"
129
+ :error="editor.error.value"
130
+ @submit="editor.submit"
131
+ />
132
+ </LumalPage>
133
+ </template>
@@ -0,0 +1,73 @@
1
+ import type { SchemaFormModel } from '@lumal/core/components';
2
+ import type { BusinessTransport } from '../../../app/services/contracts';
3
+ import type { Menu, MenuInput, MenuType } from './types';
4
+ import { assertRoutePath, resolveView, safeExternalUrl } from '../../../app/menu-policy';
5
+ import { viewComponents } from '../../../app/router/views';
6
+ import { integer, object, rows, stringArray, text } from '../../../shared/api';
7
+
8
+ export function menuInput(model: SchemaFormModel): MenuInput {
9
+ const type = text(model.type, '菜单类型', true) as MenuType;
10
+ if (!['directory', 'menu', 'button', 'external', 'embedded'].includes(type)) throw new Error('菜单类型无效');
11
+ const path = type === 'button' ? '' : text(model.path, '路径', true);
12
+ if (type !== 'button') assertRoutePath(path);
13
+ const component = type === 'menu' ? text(model.component, '组件', true) : type === 'embedded' ? 'app/iframe' : '';
14
+ if (component && !resolveView(viewComponents, component)) throw new Error('菜单组件不在白名单中');
15
+ const externalLink = ['external', 'embedded'].includes(type) ? safeExternalUrl(model.externalLink) : '';
16
+ if (type === 'embedded') {
17
+ const origins = String(import.meta.env.VITE_IFRAME_ORIGINS ?? '')
18
+ .split(',')
19
+ .map((item) => item.trim());
20
+ if (!origins.includes(new URL(externalLink).origin)) throw new Error('iframe 来源不在独立白名单中');
21
+ }
22
+ const permissions =
23
+ typeof model.permissions === 'string'
24
+ ? stringArray(
25
+ model.permissions
26
+ .split(',')
27
+ .map((item) => item.trim())
28
+ .filter(Boolean),
29
+ )
30
+ : stringArray(model.permissions ?? []);
31
+ if (type === 'button' && !permissions.length) throw new Error('按钮必须配置权限标识');
32
+ return {
33
+ type,
34
+ path,
35
+ component,
36
+ externalLink,
37
+ permissions,
38
+ name: type === 'button' ? '' : text(model.name, '路由名', true),
39
+ parentId: text(model.parentId, '父节点'),
40
+ title: text(model.title, '标题', true),
41
+ icon: text(model.icon, '图标'),
42
+ order: integer(model.order ?? 0),
43
+ hidden: model.hidden === true,
44
+ };
45
+ }
46
+ export function parseMenu(value: unknown): Menu {
47
+ const raw = object(value);
48
+ // 远端业务菜单包含未生成页面时,仍允许管理其资料;路由下发另经严格白名单验证。
49
+ return {
50
+ id: text(raw.id, '菜单 ID', true),
51
+ title: text(raw.title, '标题', true),
52
+ parentId: text(raw.parentId, '父节点'),
53
+ type: raw.type as MenuType,
54
+ path: text(raw.path, '路径'),
55
+ name: text(raw.name, '路由名'),
56
+ component: text(raw.component, '组件'),
57
+ icon: text(raw.icon, '图标'),
58
+ order: integer(raw.order ?? 0),
59
+ hidden: raw.hidden === true,
60
+ permissions: stringArray(raw.permissions ?? (raw.permission ? [raw.permission] : [])),
61
+ externalLink: text(raw.externalLink, '外链'),
62
+ children: raw.children === undefined ? undefined : rows(raw.children, parseMenu),
63
+ };
64
+ }
65
+ export function createMenuService(transport: BusinessTransport) {
66
+ return {
67
+ list: async () => rows(await transport.request('GET', '/system/menus'), parseMenu),
68
+ create: async (input: MenuInput) => parseMenu(await transport.request('POST', '/system/menus', { body: input })),
69
+ update: async (id: string, input: MenuInput) =>
70
+ parseMenu(await transport.request('PUT', `/system/menus/${encodeURIComponent(id)}`, { body: input })),
71
+ remove: (id: string) => transport.request<void>('DELETE', `/system/menus/${encodeURIComponent(id)}`),
72
+ };
73
+ }
@@ -0,0 +1,106 @@
1
+ <script setup lang="ts">
2
+ import type { SchemaFormItem, SchemaFormModel } from '@lumal/core/components';
3
+ import type { Menu } from '../types';
4
+ import { LumalIconPicker } from '@lumal/icons-vue';
5
+ import { computed } from 'vue';
6
+ import { viewComponents } from '../../../../app/router/views';
7
+ import BaseEntityDialog from '../../../../shared/components/BaseEntityDialog.vue';
8
+ import { excludedDescendants } from '../../../../shared/tree';
9
+
10
+ /***********************节点类型约束与父级选择*********************/
11
+ const props = defineProps<{ records: Menu[]; editingId?: string; saving: boolean; error: string }>();
12
+ defineEmits<{ submit: [model: SchemaFormModel] }>();
13
+ const visible = defineModel<boolean>('visible', { required: true });
14
+ const model = defineModel<SchemaFormModel>('model', { required: true });
15
+ const schemas = computed<SchemaFormItem[]>(() => {
16
+ const excluded = excludedDescendants(props.records, props.editingId);
17
+ const parents = props.records.filter(
18
+ (item) =>
19
+ !excluded.has(item.id) &&
20
+ (model.value.type === 'button' ? ['menu', 'directory'].includes(item.type) : item.type === 'directory'),
21
+ );
22
+ return [
23
+ {
24
+ field: 'type',
25
+ label: '节点类型',
26
+ component: 'select',
27
+ required: true,
28
+ defaultValue: 'menu',
29
+ options: [
30
+ { label: '目录', value: 'directory' },
31
+ { label: '菜单', value: 'menu' },
32
+ { label: '按钮', value: 'button' },
33
+ { label: '外链', value: 'external' },
34
+ { label: '内嵌页面', value: 'embedded' },
35
+ ],
36
+ },
37
+ {
38
+ field: 'parentId',
39
+ label: '父节点',
40
+ component: 'select',
41
+ options: [{ label: '无', value: '' }, ...parents.map((item) => ({ label: item.title, value: item.id }))],
42
+ },
43
+ { field: 'title', label: '标题', required: true },
44
+ { field: 'name', label: '路由名', required: true, hidden: ({ model }) => model.type === 'button' },
45
+ { field: 'path', label: '路径', required: true, hidden: ({ model }) => model.type === 'button' },
46
+ {
47
+ field: 'component',
48
+ label: '页面组件',
49
+ component: 'select',
50
+ required: true,
51
+ options: Object.keys(viewComponents)
52
+ .filter((key) => key !== 'app/iframe')
53
+ .map((key) => ({ label: key, value: key })),
54
+ hidden: ({ model }) => model.type !== 'menu',
55
+ },
56
+ {
57
+ field: 'externalLink',
58
+ label: 'HTTP(S) 地址',
59
+ required: true,
60
+ hidden: ({ model }) => !['external', 'embedded'].includes(String(model.type)),
61
+ },
62
+ {
63
+ field: 'permissions',
64
+ label: '权限标识',
65
+ component: 'input',
66
+ placeholder: '多个权限以英文逗号分隔',
67
+ required: ({ model }) => model.type === 'button',
68
+ },
69
+ { field: 'icon', label: '图标', hidden: ({ model }) => model.type === 'button' },
70
+ { field: 'order', label: '排序', component: 'number', defaultValue: 0 },
71
+ {
72
+ field: 'hidden',
73
+ label: '隐藏',
74
+ component: 'switch',
75
+ defaultValue: false,
76
+ hidden: ({ model }) => model.type === 'button',
77
+ },
78
+ ];
79
+ });
80
+ const formModel = computed<SchemaFormModel>({
81
+ get: () => ({
82
+ ...model.value,
83
+ permissions: Array.isArray(model.value.permissions) ? model.value.permissions.join(',') : model.value.permissions,
84
+ }),
85
+ set: (value) => {
86
+ model.value = value;
87
+ },
88
+ });
89
+ </script>
90
+
91
+ <template>
92
+ <BaseEntityDialog
93
+ v-model:visible="visible"
94
+ v-model:model="formModel"
95
+ :title="editingId ? '编辑节点' : '新增节点'"
96
+ :schemas="schemas"
97
+ :editing="!!editingId"
98
+ :saving="saving"
99
+ :error="error"
100
+ @submit="$emit('submit', $event)"
101
+ >
102
+ <template #field-icon="{ value }">
103
+ <LumalIconPicker :model-value="String(value ?? '')" @update:model-value="model = { ...model, icon: $event }" />
104
+ </template>
105
+ </BaseEntityDialog>
106
+ </template>