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
@@ -1,165 +1,218 @@
1
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/services/data-source'
7
- import { createApplicationSession } from '../src/services/session-runtime'
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
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 baseURL = ''
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 = '';
16
18
  const server = createServer(async (request, response) => {
17
- const path = new URL(request.url!, 'http://localhost').pathname
18
- calls.set(path, (calls.get(path) ?? 0) + 1)
19
- let body = ''
20
- for await (const part of request)
21
- body += String(part)
22
- const input = body ? JSON.parse(body) as Record<string, string> : {}
23
- const token = request.headers.authorization?.replace('Bearer ', '') ?? ''
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 ', '') ?? '';
24
25
  function send(data: unknown, status = 200) {
25
- response.writeHead(status, { 'content-type': 'application/json' })
26
- response.end(JSON.stringify(data))
26
+ response.writeHead(status, { 'content-type': 'application/json' });
27
+ response.end(JSON.stringify(data));
27
28
  }
28
29
  function issue(username: string) {
29
- const accessToken = crypto.randomUUID()
30
- const refreshToken = crypto.randomUUID()
31
- tokens.set(accessToken, username)
32
- refreshTokens.set(refreshToken, username)
33
- return { accessToken, refreshToken }
30
+ const accessToken = crypto.randomUUID();
31
+ const refreshToken = crypto.randomUUID();
32
+ tokens.set(accessToken, username);
33
+ refreshTokens.set(refreshToken, username);
34
+ return { accessToken, refreshToken };
34
35
  }
35
36
  if (path === '/auth/login') {
36
- if (input.password !== 'valid')
37
- return send({ message: '登录失败' }, 401)
38
- return send(issue(input.username!))
37
+ if (input.password !== 'valid') return send({ message: '登录失败' }, 401);
38
+ return send(issue(input.username!));
39
39
  }
40
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)
41
+ await delay(40);
42
+ const username = refreshTokens.get(input.refreshToken!);
43
+ return !failRefresh && username ? send(issue(username)) : send({ message: '刷新失败' }, 401);
44
44
  }
45
- const username = tokens.get(token)
46
- if (!username || expired.has(token))
47
- return send({ message: '已过期' }, 401)
48
- if (path === '/session/me')
49
- return send({ id: username, name: username, username })
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 });
50
48
  if (path === '/session/access')
51
- return send({ roles: [username], permissions: [`${username}:read`] })
49
+ return failAccess
50
+ ? send({ message: '权限读取失败' }, 503)
51
+ : send({ roles: [username], permissions: [`${username}:read`] });
52
52
  if (path === '/menus')
53
- return send([{ path: '/deep', name: 'Deep', component: 'dashboard/index', meta: { title: username } }])
53
+ return send(
54
+ menuPayload ?? [{ path: '/deep', name: 'Deep', component: 'dashboard/index', meta: { title: username } }],
55
+ );
54
56
  if (path === '/auth/logout') {
55
- tokens.delete(token)
56
- return send({}, failLogout ? 503 : 200)
57
+ tokens.delete(token);
58
+ return send({}, failLogout ? 503 : 200);
57
59
  }
58
- if (path.startsWith('/system/'))
59
- return send({ items: [{ id: '1', owner: username }], total: 1 })
60
- send({}, 404)
61
- })
60
+ if (path.startsWith('/system/')) return send({ items: [{ id: '1', owner: username }], total: 1 });
61
+ send({}, 404);
62
+ });
62
63
  beforeAll(async () => {
63
- await new Promise<void>(done => server.listen(0, '127.0.0.1', done))
64
- baseURL = `http://127.0.0.1:${(server.address() as AddressInfo).port}`
65
- })
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
+ });
66
67
  beforeEach(() => {
67
- calls.clear()
68
- tokens.clear()
69
- refreshTokens.clear()
70
- expired.clear()
71
- failRefresh = false
72
- failLogout = false
73
- })
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
+ });
74
77
  afterAll(async () => {
75
- server.closeAllConnections()
76
- await new Promise<void>((done, reject) => server.close(error => error ? reject(error) : done()))
77
- })
78
+ server.closeAllConnections();
79
+ await new Promise<void>((done, reject) => server.close((error) => (error ? reject(error) : done())));
80
+ });
78
81
  function memoryStorage(): Storage {
79
- const values = new Map<string, string>()
82
+ const values = new Map<string, string>();
80
83
  return {
81
- getItem: key => values.get(key) ?? null,
82
- setItem: (key, value) => { values.set(key, value) },
83
- removeItem: (key) => { values.delete(key) },
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
+ },
84
91
  clear: () => values.clear(),
85
- key: index => [...values.keys()][index] ?? null,
86
- get length() { return values.size },
87
- }
92
+ key: (index) => [...values.keys()][index] ?? null,
93
+ get length() {
94
+ return values.size;
95
+ },
96
+ };
88
97
  }
89
98
  function application(url = baseURL) {
90
- const storage = memoryStorage()
91
- return createApplicationSession(context => createHttpBackend(context, url), storage, storage)
99
+ const storage = memoryStorage();
100
+ return createApplicationSession((context) => createHttpBackend(context, url), storage, storage);
92
101
  }
93
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
+ });
94
141
  it('配置缺失明确失败,不产生演示会话', async () => {
95
- const app = application('')
96
- await expect(app.login({ username: 'alice', password: 'valid' })).rejects.toThrow('VITE_API_BASE_URL')
97
- expect(app.isAuthenticated()).toBe(false)
98
- expect(calls.size).toBe(0)
99
- })
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
+ });
100
147
  it('登录失败、成功、用户权限菜单与真实 CRUD 请求', async () => {
101
- const app = application()
102
- await expect(app.login({ username: 'alice', password: 'wrong' })).rejects.toThrow()
103
- expect(app.currentUser.value).toBeNull()
104
- await app.login({ username: 'alice', password: 'valid' })
105
- expect(app.ready.value).toBe(true)
106
- expect(app.currentUser.value?.username).toBe('alice')
107
- expect(app.permissionStore.permissions).toEqual(['alice:read'])
108
- expect(app.permissionStore.roles).toEqual(['alice'])
109
- expect(app.menus.value[0]?.path).toBe('/deep')
110
- expect(await app.service.list('users', { page: 1, pageSize: 10, query: {} })).toEqual({ items: [{ id: '1', owner: 'alice' }], total: 1 })
111
- await Promise.all([app.initialize(), app.initialize()])
112
- expect(calls.get('/session/me')).toBe(1)
113
- })
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
+ });
114
164
  it('401 并发请求只刷新一次,重放使用新凭据', async () => {
115
- const app = application()
116
- await app.login({ username: 'alice', password: 'valid' })
117
- expired.add(app.session.getToken()!)
118
- const signal = new AbortController().signal
119
- const [user, access, menus] = await Promise.all([app.service.getUser(signal), app.service.getAccess(signal), app.service.getMenus(signal)])
120
- expect(user.username).toBe('alice')
121
- expect(access.permissions).toEqual(['alice:read'])
122
- expect(menus[0]?.path).toBe('/deep')
123
- expect(calls.get('/auth/refresh')).toBe(1)
124
- expect(calls.get('/session/me')).toBe(3)
125
- })
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
+ });
126
180
  it('刷新失败清理凭据、权限、菜单、标签和动态状态回调', async () => {
127
- const app = application()
128
- await app.login({ username: 'alice', password: 'valid' })
129
- let resets = 0
181
+ const app = application();
182
+ await app.login({ username: 'alice', password: 'valid' });
183
+ let resets = 0;
130
184
  app.onReset(() => {
131
- resets += 1
132
- })
133
- expired.add(app.session.getToken()!)
134
- failRefresh = true
135
- await expect(app.service.getUser(new AbortController().signal)).rejects.toThrow()
136
- expect(app.isAuthenticated()).toBe(false)
137
- expect(app.currentUser.value).toBeNull()
138
- expect(app.menus.value).toEqual([])
139
- expect(app.permissionStore.permissions).toEqual([])
140
- expect(resets).toBe(1)
141
- })
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
+ });
142
196
  it('退出接口失败不保留本地身份', async () => {
143
- const app = application()
144
- await app.login({ username: 'alice', password: 'valid' })
145
- failLogout = true
146
- await expect(app.logout()).rejects.toThrow()
147
- expect(app.isAuthenticated()).toBe(false)
148
- expect(app.permissionStore.roles).toEqual([])
149
- expect(calls.get('/auth/logout')).toBe(1)
150
- })
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
+ });
151
205
  it('刷新在途时切换账号,旧响应不能污染新用户', async () => {
152
- const app = application()
153
- await app.login({ username: 'alice', password: 'valid' })
154
- expired.add(app.session.getToken()!)
155
- const old = app.service.getUser(new AbortController().signal).catch(error => error)
156
- while (!calls.get('/auth/refresh'))
157
- await delay(1)
158
- await app.login({ username: 'bob', password: 'valid' })
159
- await old
160
- await delay(50)
161
- expect(app.currentUser.value?.username).toBe('bob')
162
- expect(app.permissionStore.permissions).toEqual(['bob:read'])
163
- expect(app.ready.value).toBe(true)
164
- })
165
- })
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
+ }