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,218 @@
1
+ // @vitest-environment node
2
+ import type { AddressInfo } from 'node:net';
3
+ import { createServer } from 'node:http';
4
+ import { setTimeout as delay } from 'node:timers/promises';
5
+ import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
6
+ import { createHttpBackend } from '../src/app/services/data-source';
7
+ import { createApplicationSession } from '../src/app/services/session-runtime';
8
+
9
+ const tokens = new Map<string, string>();
10
+ const refreshTokens = new Map<string, string>();
11
+ const expired = new Set<string>();
12
+ const calls = new Map<string, number>();
13
+ let failRefresh = false;
14
+ let failLogout = false;
15
+ let failAccess = false;
16
+ let menuPayload: unknown;
17
+ let baseURL = '';
18
+ const server = createServer(async (request, response) => {
19
+ const path = new URL(request.url!, 'http://localhost').pathname;
20
+ calls.set(path, (calls.get(path) ?? 0) + 1);
21
+ let body = '';
22
+ for await (const part of request) body += String(part);
23
+ const input = body ? (JSON.parse(body) as Record<string, string>) : {};
24
+ const token = request.headers.authorization?.replace('Bearer ', '') ?? '';
25
+ function send(data: unknown, status = 200) {
26
+ response.writeHead(status, { 'content-type': 'application/json' });
27
+ response.end(JSON.stringify(data));
28
+ }
29
+ function issue(username: string) {
30
+ const accessToken = crypto.randomUUID();
31
+ const refreshToken = crypto.randomUUID();
32
+ tokens.set(accessToken, username);
33
+ refreshTokens.set(refreshToken, username);
34
+ return { accessToken, refreshToken };
35
+ }
36
+ if (path === '/auth/login') {
37
+ if (input.password !== 'valid') return send({ message: '登录失败' }, 401);
38
+ return send(issue(input.username!));
39
+ }
40
+ if (path === '/auth/refresh') {
41
+ await delay(40);
42
+ const username = refreshTokens.get(input.refreshToken!);
43
+ return !failRefresh && username ? send(issue(username)) : send({ message: '刷新失败' }, 401);
44
+ }
45
+ const username = tokens.get(token);
46
+ if (!username || expired.has(token)) return send({ message: '已过期' }, 401);
47
+ if (path === '/session/me') return send({ id: username, name: username, username });
48
+ if (path === '/session/access')
49
+ return failAccess
50
+ ? send({ message: '权限读取失败' }, 503)
51
+ : send({ roles: [username], permissions: [`${username}:read`] });
52
+ if (path === '/menus')
53
+ return send(
54
+ menuPayload ?? [{ path: '/deep', name: 'Deep', component: 'dashboard/index', meta: { title: username } }],
55
+ );
56
+ if (path === '/auth/logout') {
57
+ tokens.delete(token);
58
+ return send({}, failLogout ? 503 : 200);
59
+ }
60
+ if (path.startsWith('/system/')) return send({ items: [{ id: '1', owner: username }], total: 1 });
61
+ send({}, 404);
62
+ });
63
+ beforeAll(async () => {
64
+ await new Promise<void>((done) => server.listen(0, '127.0.0.1', done));
65
+ baseURL = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
66
+ });
67
+ beforeEach(() => {
68
+ calls.clear();
69
+ tokens.clear();
70
+ refreshTokens.clear();
71
+ expired.clear();
72
+ failRefresh = false;
73
+ failLogout = false;
74
+ failAccess = false;
75
+ menuPayload = undefined;
76
+ });
77
+ afterAll(async () => {
78
+ server.closeAllConnections();
79
+ await new Promise<void>((done, reject) => server.close((error) => (error ? reject(error) : done())));
80
+ });
81
+ function memoryStorage(): Storage {
82
+ const values = new Map<string, string>();
83
+ return {
84
+ getItem: (key) => values.get(key) ?? null,
85
+ setItem: (key, value) => {
86
+ values.set(key, value);
87
+ },
88
+ removeItem: (key) => {
89
+ values.delete(key);
90
+ },
91
+ clear: () => values.clear(),
92
+ key: (index) => [...values.keys()][index] ?? null,
93
+ get length() {
94
+ return values.size;
95
+ },
96
+ };
97
+ }
98
+ function application(url = baseURL) {
99
+ const storage = memoryStorage();
100
+ return createApplicationSession((context) => createHttpBackend(context, url), storage, storage);
101
+ }
102
+ describe('真实 HTTP 应用服务', () => {
103
+ it('权限重读失败清理旧授权,保留凭据以供显式重试且并发刷新单飞', async () => {
104
+ const app = application();
105
+ await app.login({ username: 'alice', password: 'valid' });
106
+ const token = app.session.getToken();
107
+ failAccess = true;
108
+ const failed = app.refreshAccess();
109
+ expect(app.refreshAccess()).toBe(failed);
110
+ await expect(failed).rejects.toThrow('权限读取失败');
111
+ expect(app.permissionStore.permissions).toEqual([]);
112
+ expect(app.menus.value).toEqual([]);
113
+ expect(app.ready.value).toBe(false);
114
+ expect(app.session.getToken()).toBe(token);
115
+ failAccess = false;
116
+ await app.refreshAccess();
117
+ expect(app.ready.value).toBe(true);
118
+ expect(app.permissionStore.permissions).toEqual(['alice:read']);
119
+ });
120
+ it.each([
121
+ { component: 'constructor' },
122
+ { component: '__proto__' },
123
+ { component: 'not-registered' },
124
+ { meta: { externalLink: 'javascript:alert(1)' } },
125
+ { meta: { externalLink: '//evil.invalid' } },
126
+ { meta: { authority: [7] } },
127
+ ])('真实 HTTP 下发的恶意子菜单拒绝初始化 %j', async (invalid) => {
128
+ menuPayload = [
129
+ {
130
+ path: '/remote',
131
+ name: 'Remote',
132
+ children: [{ path: '/remote/page', name: 'RemotePage', component: 'dashboard/index', ...invalid }],
133
+ },
134
+ ];
135
+ const app = application();
136
+ await expect(app.login({ username: 'alice', password: 'valid' })).rejects.toThrow();
137
+ expect(app.ready.value).toBe(false);
138
+ expect(app.menus.value).toEqual([]);
139
+ expect(app.isAuthenticated()).toBe(false);
140
+ });
141
+ it('配置缺失明确失败,不产生演示会话', async () => {
142
+ const app = application('');
143
+ await expect(app.login({ username: 'alice', password: 'valid' })).rejects.toThrow('VITE_API_BASE_URL');
144
+ expect(app.isAuthenticated()).toBe(false);
145
+ expect(calls.size).toBe(0);
146
+ });
147
+ it('登录失败、成功、用户权限菜单与真实 CRUD 请求', async () => {
148
+ const app = application();
149
+ await expect(app.login({ username: 'alice', password: 'wrong' })).rejects.toThrow('登录失败');
150
+ expect(app.currentUser.value).toBeNull();
151
+ await app.login({ username: 'alice', password: 'valid' });
152
+ expect(app.ready.value).toBe(true);
153
+ expect(app.currentUser.value?.username).toBe('alice');
154
+ expect(app.permissionStore.permissions).toEqual(['alice:read']);
155
+ expect(app.permissionStore.roles).toEqual(['alice']);
156
+ expect(app.menus.value[0]?.path).toBe('/deep');
157
+ expect(await app.service.list('users', { page: 1, pageSize: 10, query: {} })).toEqual({
158
+ items: [{ id: '1', owner: 'alice' }],
159
+ total: 1,
160
+ });
161
+ await Promise.all([app.initialize(), app.initialize()]);
162
+ expect(calls.get('/session/me')).toBe(1);
163
+ });
164
+ it('401 并发请求只刷新一次,重放使用新凭据', async () => {
165
+ const app = application();
166
+ await app.login({ username: 'alice', password: 'valid' });
167
+ expired.add(app.session.getToken()!);
168
+ const signal = new AbortController().signal;
169
+ const [user, access, menus] = await Promise.all([
170
+ app.service.getUser(signal),
171
+ app.service.getAccess(signal),
172
+ app.service.getMenus(signal),
173
+ ]);
174
+ expect(user.username).toBe('alice');
175
+ expect(access.permissions).toEqual(['alice:read']);
176
+ expect(menus[0]?.path).toBe('/deep');
177
+ expect(calls.get('/auth/refresh')).toBe(1);
178
+ expect(calls.get('/session/me')).toBe(3);
179
+ });
180
+ it('刷新失败清理凭据、权限、菜单、标签和动态状态回调', async () => {
181
+ const app = application();
182
+ await app.login({ username: 'alice', password: 'valid' });
183
+ let resets = 0;
184
+ app.onReset(() => {
185
+ resets += 1;
186
+ });
187
+ expired.add(app.session.getToken()!);
188
+ failRefresh = true;
189
+ await expect(app.service.getUser(new AbortController().signal)).rejects.toThrow();
190
+ expect(app.isAuthenticated()).toBe(false);
191
+ expect(app.currentUser.value).toBeNull();
192
+ expect(app.menus.value).toEqual([]);
193
+ expect(app.permissionStore.permissions).toEqual([]);
194
+ expect(resets).toBe(1);
195
+ });
196
+ it('退出接口失败不保留本地身份', async () => {
197
+ const app = application();
198
+ await app.login({ username: 'alice', password: 'valid' });
199
+ failLogout = true;
200
+ await expect(app.logout()).rejects.toThrow();
201
+ expect(app.isAuthenticated()).toBe(false);
202
+ expect(app.permissionStore.roles).toEqual([]);
203
+ expect(calls.get('/auth/logout')).toBe(1);
204
+ });
205
+ it('刷新在途时切换账号,旧响应不能污染新用户', async () => {
206
+ const app = application();
207
+ await app.login({ username: 'alice', password: 'valid' });
208
+ expired.add(app.session.getToken()!);
209
+ const old = app.service.getUser(new AbortController().signal).catch((error) => error);
210
+ while (!calls.get('/auth/refresh')) await delay(1);
211
+ await app.login({ username: 'bob', password: 'valid' });
212
+ await old;
213
+ await delay(50);
214
+ expect(app.currentUser.value?.username).toBe('bob');
215
+ expect(app.permissionStore.permissions).toEqual(['bob:read']);
216
+ expect(app.ready.value).toBe(true);
217
+ });
218
+ });
@@ -0,0 +1,113 @@
1
+ import type {
2
+ AccessSnapshot,
3
+ ApplicationService,
4
+ BusinessRequest,
5
+ CurrentUser,
6
+ DataRow,
7
+ HttpMethod,
8
+ ServiceContext,
9
+ } from './contracts';
10
+ import type { LocalModule } from './local-contracts';
11
+ import { object, stringArray } from '../menu-policy';
12
+ import { permissionCodes, viewComponents } from '../router/views';
13
+ import { buildStoredMenus } from '../stored-menus';
14
+ import { createLocalDatabase } from './local-database';
15
+
16
+ export const demoAccount = import.meta.env.DEV ? { username: 'admin', password: 'lumal123' } : null;
17
+ const localModules = import.meta.glob<LocalModule>(['../../modules/*/local.ts', '../../modules/business/*/local.ts']);
18
+
19
+ export function createBackend(context: ServiceContext): ApplicationService {
20
+ const db = createLocalDatabase();
21
+ let ownedToken = '';
22
+ let handlers: Promise<ReturnType<LocalModule['createLocalHandler']>[]> | undefined;
23
+ function assertDemo(): void {
24
+ if (import.meta.env.PROD && import.meta.env.VITE_ENABLE_LOCAL_DEMO !== 'true')
25
+ throw new Error('生产环境已禁用本地演示,请使用 HTTP 模式或显式启用演示');
26
+ context.getSignal().throwIfAborted();
27
+ }
28
+ function account(): DataRow {
29
+ assertDemo();
30
+ const token = context.getSession()?.accessToken ?? '';
31
+ const raw = sessionStorage.getItem(`demo:${token}`);
32
+ if (!raw) throw new Error('本地演示会话已失效');
33
+ const identity = object(JSON.parse(raw));
34
+ const user = db.read('users').find((row) => row.id === identity.id);
35
+ if (!user || user.status !== 'enabled') throw new Error('本地账号已停用或删除,请重新登录');
36
+ ownedToken = token;
37
+ return user;
38
+ }
39
+ function user(): CurrentUser {
40
+ const current = account();
41
+ return { id: String(current.id), username: String(current.username), name: String(current.nickname) };
42
+ }
43
+ function access(): AccessSnapshot {
44
+ const current = account();
45
+ const assigned = stringArray(current.roles);
46
+ const roles = db.read('roles').filter((row) => assigned.includes(String(row.code)) && row.status === 'enabled');
47
+ return {
48
+ roles: roles.map((row) => String(row.code)),
49
+ permissions: [...new Set(roles.flatMap((row) => stringArray(row.permissions)))].filter((code) =>
50
+ permissionCodes.includes(code),
51
+ ),
52
+ };
53
+ }
54
+ function assertPermission(code: string): void {
55
+ if (!access().permissions.includes(code)) throw new Error(`无操作权限:${code}`);
56
+ }
57
+ async function businessRequest<T>(method: HttpMethod, path: string, input: BusinessRequest = {}): Promise<T> {
58
+ user();
59
+ const signal = context.getSignal();
60
+ handlers ??= Promise.all(
61
+ Object.values(localModules).map(async (load) =>
62
+ (await load()).createLocalHandler({ db, user, assertPermission }),
63
+ ),
64
+ );
65
+ const handler = (await handlers).find((item) => item.handles(path));
66
+ signal.throwIfAborted();
67
+ if (handler) return (await handler.request(method, path, input)) as T;
68
+ throw new Error(`本地数据源不支持此业务接口:${method} ${path}`);
69
+ }
70
+ return {
71
+ request: businessRequest,
72
+ async login(input, signal) {
73
+ assertDemo();
74
+ signal.throwIfAborted();
75
+ const found = db.read('users').find((row) => row.username === input.username);
76
+ if (!found || found.status !== 'enabled' || input.password !== found.password)
77
+ throw new Error('账号或密码不正确');
78
+ ownedToken = crypto.randomUUID();
79
+ sessionStorage.setItem(`demo:${ownedToken}`, JSON.stringify({ id: found.id }));
80
+ return { accessToken: ownedToken };
81
+ },
82
+ getUser: async (signal) => {
83
+ signal.throwIfAborted();
84
+ return user();
85
+ },
86
+ getAccess: async (signal) => {
87
+ signal.throwIfAborted();
88
+ return access();
89
+ },
90
+ getMenus: async (signal) => {
91
+ signal.throwIfAborted();
92
+ user();
93
+ return buildStoredMenus(db.read('menus'), viewComponents);
94
+ },
95
+ logout: async (token) => {
96
+ sessionStorage.removeItem(`demo:${token}`);
97
+ },
98
+ clearCache() {
99
+ if (ownedToken) sessionStorage.removeItem(`demo:${ownedToken}`);
100
+ ownedToken = '';
101
+ handlers = undefined;
102
+ },
103
+ list: (resource, input) =>
104
+ businessRequest('GET', `/system/${resource}`, {
105
+ query: { page: input.page, pageSize: input.pageSize, ...input.query } as BusinessRequest['query'],
106
+ }),
107
+ save: (resource, model, id) =>
108
+ businessRequest(id ? 'PUT' : 'POST', `/system/${resource}${id ? `/${encodeURIComponent(id)}` : ''}`, {
109
+ body: model,
110
+ }),
111
+ remove: (resource, id) => businessRequest('DELETE', `/system/${resource}/${encodeURIComponent(id)}`),
112
+ };
113
+ }
@@ -0,0 +1,67 @@
1
+ import type { DataRow } from './contracts';
2
+ import type { LocalDatabase } from './local-contracts';
3
+ import { localMenus, permissionCodes } from '../router/views';
4
+ import { seedMenuRows } from '../stored-menus';
5
+
6
+ // 仅开发用途。存储和校验不能替代生产后端的认证、授权与密码散列。
7
+ export function createLocalDatabase(): LocalDatabase {
8
+ const initial: Record<string, DataRow[]> = {
9
+ users: [
10
+ {
11
+ id: 'admin',
12
+ username: 'admin',
13
+ nickname: '管理员',
14
+ organizationId: 'root',
15
+ roles: ['admin'],
16
+ status: 'enabled',
17
+ phone: '',
18
+ createdAt: '',
19
+ password: 'lumal123',
20
+ },
21
+ {
22
+ id: 'guest',
23
+ username: 'guest',
24
+ nickname: '访客',
25
+ organizationId: 'root',
26
+ roles: ['guest'],
27
+ status: 'enabled',
28
+ phone: '',
29
+ createdAt: '',
30
+ password: 'lumal123',
31
+ },
32
+ ],
33
+ roles: [
34
+ {
35
+ id: 'admin',
36
+ code: 'admin',
37
+ name: '管理员',
38
+ description: '内置管理员',
39
+ builtIn: true,
40
+ status: 'enabled',
41
+ permissions: permissionCodes,
42
+ },
43
+ {
44
+ id: 'guest',
45
+ code: 'guest',
46
+ name: '访客',
47
+ description: '默认访客',
48
+ builtIn: true,
49
+ status: 'enabled',
50
+ permissions: [],
51
+ },
52
+ ],
53
+ menus: seedMenuRows(localMenus),
54
+ };
55
+ return {
56
+ read<T extends DataRow>(resource: string, fallback?: () => T[]): T[] {
57
+ const raw = localStorage.getItem(`demo:system:${resource}`);
58
+ const value: unknown = raw ? JSON.parse(raw) : structuredClone(initial[resource] ?? fallback?.() ?? []);
59
+ if (!Array.isArray(value) || value.some((row) => !row || typeof row !== 'object' || Array.isArray(row)))
60
+ throw new Error(`本地演示数据损坏:${resource}`);
61
+ return value as T[];
62
+ },
63
+ write(resource, rows) {
64
+ localStorage.setItem(`demo:system:${resource}`, JSON.stringify(rows));
65
+ },
66
+ };
67
+ }
@@ -0,0 +1,132 @@
1
+ import type { LumalMenuRecord } from '@lumal/core/router';
2
+ // @vitest-environment node
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4
+ import { localMenus } from '../src/app/router/views';
5
+ import { createBackend } from '../src/app/services/data-source';
6
+ import { createApplicationSession } from '../src/app/services/session-runtime';
7
+
8
+ function storage(): Storage {
9
+ const values = new Map<string, string>();
10
+ return {
11
+ getItem: (key) => values.get(key) ?? null,
12
+ setItem: (key, value) => {
13
+ values.set(key, value);
14
+ },
15
+ removeItem: (key) => {
16
+ values.delete(key);
17
+ },
18
+ clear: () => values.clear(),
19
+ key: (index) => [...values.keys()][index] ?? null,
20
+ get length() {
21
+ return values.size;
22
+ },
23
+ };
24
+ }
25
+ beforeEach(() => {
26
+ vi.unstubAllEnvs();
27
+ vi.stubGlobal('localStorage', storage());
28
+ vi.stubGlobal('sessionStorage', storage());
29
+ });
30
+ afterEach(() => {
31
+ vi.unstubAllGlobals();
32
+ vi.unstubAllEnvs();
33
+ });
34
+ function application() {
35
+ return createApplicationSession(createBackend, localStorage, sessionStorage);
36
+ }
37
+ describe('本地应用服务', () => {
38
+ it('认证、动态菜单和重新初始化使用同一服务契约', async () => {
39
+ const app = application();
40
+ await expect(app.login({ username: 'admin', password: 'wrong' })).rejects.toThrow('账号或密码');
41
+ await app.login({ username: 'admin', password: 'lumal123' });
42
+ expect(app.currentUser.value?.username).toBe('admin');
43
+ expect(app.menus.value.some((menu) => menu.path === '/dashboard')).toBe(true);
44
+ const token = app.session.getToken();
45
+ const restored = application();
46
+ await restored.initialize();
47
+ expect(restored.session.getToken()).toBe(token);
48
+ expect(restored.ready.value).toBe(true);
49
+ await restored.logout();
50
+ expect(sessionStorage.getItem(`demo:${token}`)).toBeNull();
51
+ expect(restored.permissionStore.roles).toEqual([]);
52
+ });
53
+ it('生产默认拒绝演示账号,显式启用仅用于演示站点', async () => {
54
+ vi.stubEnv('PROD', true);
55
+ vi.stubEnv('VITE_ENABLE_LOCAL_DEMO', '');
56
+ await expect(application().login({ username: 'admin', password: 'lumal123' })).rejects.toThrow('生产环境');
57
+ vi.stubEnv('VITE_ENABLE_LOCAL_DEMO', 'true');
58
+ await expect(application().login({ username: 'admin', password: 'lumal123' })).resolves.toBeUndefined();
59
+ });
60
+ it('管理能力按预设启用,访客无写权限', async () => {
61
+ const app = application();
62
+ await app.login({ username: 'admin', password: 'lumal123' });
63
+ const flatten = (menus: LumalMenuRecord[]): LumalMenuRecord[] =>
64
+ menus.flatMap((menu) => [menu, ...flatten(menu.children ?? [])]);
65
+ if (flatten(localMenus).some((menu) => menu.component === 'system/users')) {
66
+ await app.service.save('users', { username: 'new-user', nickname: '新增', roles: ['guest'], status: 'enabled' });
67
+ const result = await app.service.list('users', { page: 1, pageSize: 10, query: { keyword: 'new-user' } });
68
+ expect(result.total).toBe(1);
69
+ await app.service.remove('users', String(result.items[0]?.id));
70
+ await expect(app.service.remove('users', 'admin')).rejects.toThrow('内置管理员');
71
+ await expect(
72
+ app.service.save('users', { username: 'admin', nickname: '重复', roles: ['guest'] }),
73
+ ).rejects.toThrow('重复');
74
+ await expect(
75
+ app.service.save('roles', { code: 'admin', name: '管理员', status: 'disabled' }, 'admin'),
76
+ ).rejects.toThrow('内置角色');
77
+ await app.service.save('roles', { code: 'reader', name: '只读角色', status: 'enabled' });
78
+ await app.service.request('PUT', '/system/roles/reader/permissions', {
79
+ body: { permissions: ['system:user:list'] },
80
+ });
81
+ await app.service.save('users', {
82
+ username: 'reader',
83
+ nickname: '只读用户',
84
+ roles: ['reader'],
85
+ status: 'enabled',
86
+ });
87
+ await app.service.save('dictionaries/items', { typeCode: 'status', label: '待审核', value: 'pending' });
88
+ expect(await app.service.request('GET', '/system/dictionaries/items', { query: { typeCode: 'other' } })).toEqual(
89
+ [],
90
+ );
91
+ await app.service.save('menus', { title: '目录', path: '/group', name: 'Group', type: 'directory' });
92
+ const group = (await app.service.request<Record<string, unknown>[]>('GET', '/system/menus')).find(
93
+ (row) => row.name === 'Group',
94
+ )!;
95
+ await expect(app.service.save('menus', { ...group, parentId: group.id }, String(group.id))).rejects.toThrow(
96
+ '循环',
97
+ );
98
+ await expect(
99
+ app.service.save('menus', {
100
+ title: '恶意',
101
+ path: '/bad',
102
+ name: 'Bad',
103
+ type: 'menu',
104
+ component: 'https://evil.invalid/a.js',
105
+ }),
106
+ ).rejects.toThrow('白名单');
107
+ await app.login({ username: 'reader', password: 'lumal123' });
108
+ expect(app.permissionStore.permissions).toEqual(['system:user:list']);
109
+ await expect(app.service.list('users', { page: 1, pageSize: 10, query: {} })).resolves.toHaveProperty('total');
110
+ await expect(app.service.save('users', { username: 'denied' })).rejects.toThrow('无操作权限');
111
+ } else if (flatten(localMenus).some((menu) => menu.component === 'migration/users')) {
112
+ expect(app.permissionStore.permissions).toContain('system:user:create');
113
+ await app.service.save('users', {
114
+ username: 'migration-only',
115
+ nickname: '迁移用户',
116
+ roles: ['guest'],
117
+ status: 'enabled',
118
+ });
119
+ const result = await app.service.list('users', { page: 1, pageSize: 10, query: { keyword: 'migration-only' } });
120
+ expect(result.total).toBe(1);
121
+ await app.service.remove('users', String(result.items[0]?.id));
122
+ expect(
123
+ (await app.service.list('users', { page: 1, pageSize: 10, query: { keyword: 'migration-only' } })).total,
124
+ ).toBe(0);
125
+ } else {
126
+ await expect(app.service.list('users', { page: 1, pageSize: 10, query: {} })).rejects.toThrow('不支持');
127
+ }
128
+ await app.login({ username: 'guest', password: 'lumal123' });
129
+ expect(app.permissionStore.permissions).toEqual([]);
130
+ await expect(app.service.save('users', { username: 'denied' })).rejects.toThrow();
131
+ });
132
+ });
@@ -0,0 +1,52 @@
1
+ <script setup lang="ts">
2
+ import { LumalPage } from '@lumal/core/components';
3
+ import { ElEmpty } from 'element-plus';
4
+ import { currentUser } from '../../app/services/session';
5
+ import DashboardSessionStatus from './components/DashboardSessionStatus.vue';
6
+ import DashboardShortcuts from './components/DashboardShortcuts.vue';
7
+
8
+ /***********************工作台装配*********************/
9
+ const today = new Intl.DateTimeFormat('zh-CN', { dateStyle: 'full' }).format(new Date());
10
+ </script>
11
+
12
+ <template>
13
+ <LumalPage class="lumal-admin-page" title="工作台">
14
+ <header class="dashboard-welcome">
15
+ <div>
16
+ <h2>你好,{{ currentUser?.name }}</h2>
17
+ <p>{{ today }}</p>
18
+ </div>
19
+ </header>
20
+ <DashboardShortcuts />
21
+ <DashboardSessionStatus />
22
+ <section class="dashboard-section">
23
+ <h3>业务动态</h3>
24
+ <ElEmpty description="暂无业务动态" :image-size="72" />
25
+ </section>
26
+ </LumalPage>
27
+ </template>
28
+
29
+ <style scoped>
30
+ .dashboard-welcome {
31
+ display: flex;
32
+ align-items: center;
33
+ justify-content: space-between;
34
+ padding: 8px 0 24px;
35
+ }
36
+ .dashboard-welcome h2 {
37
+ margin: 0 0 8px;
38
+ font-size: 22px;
39
+ }
40
+ .dashboard-welcome p {
41
+ margin: 0;
42
+ color: var(--el-text-color-secondary);
43
+ }
44
+ .dashboard-section {
45
+ padding-top: 24px;
46
+ border-top: 1px solid var(--el-border-color-lighter);
47
+ }
48
+ .dashboard-section h3 {
49
+ margin: 0;
50
+ font-size: 16px;
51
+ }
52
+ </style>
@@ -0,0 +1,41 @@
1
+ <script setup lang="ts">
2
+ import { ElDescriptions, ElDescriptionsItem, ElTag } from 'element-plus';
3
+ import { appConfig } from '../../../app.config';
4
+ import { permissionStore } from '../../../app/services/session';
5
+
6
+ /***********************真实应用与会话状态*********************/
7
+ const source = appConfig.apiMode === 'local' ? '本地开发演示' : 'HTTP API';
8
+ </script>
9
+
10
+ <template>
11
+ <section class="session-status">
12
+ <h3>应用状态</h3>
13
+ <ElDescriptions :column="1" border>
14
+ <ElDescriptionsItem label="数据源">
15
+ <ElTag :type="appConfig.apiMode === 'local' ? 'warning' : 'success'">
16
+ {{ source }}
17
+ </ElTag>
18
+ </ElDescriptionsItem>
19
+ <ElDescriptionsItem label="当前角色">
20
+ {{ permissionStore.roles.join('、') || '无' }}
21
+ </ElDescriptionsItem>
22
+ <ElDescriptionsItem label="可用操作权限">
23
+ {{ permissionStore.permissions.length }}
24
+ </ElDescriptionsItem>
25
+ <ElDescriptionsItem label="启用模块">
26
+ {{ appConfig.features.join('、') || '无可选模块' }}
27
+ </ElDescriptionsItem>
28
+ </ElDescriptions>
29
+ </section>
30
+ </template>
31
+
32
+ <style scoped>
33
+ .session-status {
34
+ padding: 24px 0;
35
+ border-top: 1px solid var(--el-border-color-lighter);
36
+ }
37
+ .session-status h3 {
38
+ margin: 0 0 16px;
39
+ font-size: 16px;
40
+ }
41
+ </style>
@@ -0,0 +1,51 @@
1
+ <script setup lang="ts">
2
+ import type { SidebarMenuItem } from '@lumal/core/router';
3
+ import { LumalIcon } from '@lumal/icons-vue';
4
+ import { ElButton, ElEmpty } from 'element-plus';
5
+ import { computed } from 'vue';
6
+ import { useRouter } from 'vue-router';
7
+ import { createAdminSidebarMenus } from '../../../app/router';
8
+
9
+ /***********************当前权限下的快捷入口*********************/
10
+ const router = useRouter();
11
+ function flatten(items: SidebarMenuItem[]): SidebarMenuItem[] {
12
+ return items.flatMap((item) => (item.children?.length ? flatten(item.children) : [item]));
13
+ }
14
+ const shortcuts = computed(() =>
15
+ flatten(createAdminSidebarMenus())
16
+ .filter((item) => item.path !== '/dashboard' && !item.externalLink)
17
+ .slice(0, 6),
18
+ );
19
+ </script>
20
+
21
+ <template>
22
+ <section class="dashboard-shortcuts">
23
+ <h3>常用入口</h3>
24
+ <div v-if="shortcuts.length" class="shortcut-list">
25
+ <ElButton v-for="item in shortcuts" :key="item.path" @click="router.push(item.path)">
26
+ <LumalIcon :name="item.icon || 'lumal:grid'" />{{ item.title }}
27
+ </ElButton>
28
+ </div>
29
+ <ElEmpty v-else description="暂无可用入口" :image-size="48" />
30
+ </section>
31
+ </template>
32
+
33
+ <style scoped>
34
+ .dashboard-shortcuts {
35
+ padding-bottom: 24px;
36
+ }
37
+ .dashboard-shortcuts h3 {
38
+ margin: 0 0 16px;
39
+ font-size: 16px;
40
+ }
41
+ .shortcut-list {
42
+ display: flex;
43
+ flex-wrap: wrap;
44
+ gap: 12px;
45
+ }
46
+ .shortcut-list :deep(.el-button) {
47
+ min-height: 40px;
48
+ margin: 0;
49
+ gap: 8px;
50
+ }
51
+ </style>