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,92 +1,132 @@
1
+ import type { LumalMenuRecord } from '@lumal/core/router';
1
2
  // @vitest-environment node
2
- import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
3
- import { localMenus } from '../src/router/views'
4
- import { createBackend } from '../src/services/data-source'
5
- import { createApplicationSession } from '../src/services/session-runtime'
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';
6
7
 
7
8
  function storage(): Storage {
8
- const values = new Map<string, string>()
9
+ const values = new Map<string, string>();
9
10
  return {
10
- getItem: key => values.get(key) ?? null,
11
- setItem: (key, value) => { values.set(key, value) },
12
- removeItem: (key) => { values.delete(key) },
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
+ },
13
18
  clear: () => values.clear(),
14
- key: index => [...values.keys()][index] ?? null,
15
- get length() { return values.size },
16
- }
19
+ key: (index) => [...values.keys()][index] ?? null,
20
+ get length() {
21
+ return values.size;
22
+ },
23
+ };
17
24
  }
18
25
  beforeEach(() => {
19
- vi.unstubAllEnvs()
20
- vi.stubGlobal('localStorage', storage())
21
- vi.stubGlobal('sessionStorage', storage())
22
- })
26
+ vi.unstubAllEnvs();
27
+ vi.stubGlobal('localStorage', storage());
28
+ vi.stubGlobal('sessionStorage', storage());
29
+ });
23
30
  afterEach(() => {
24
- vi.unstubAllGlobals()
25
- vi.unstubAllEnvs()
26
- })
31
+ vi.unstubAllGlobals();
32
+ vi.unstubAllEnvs();
33
+ });
27
34
  function application() {
28
- return createApplicationSession(createBackend, localStorage, sessionStorage)
35
+ return createApplicationSession(createBackend, localStorage, sessionStorage);
29
36
  }
30
37
  describe('本地应用服务', () => {
31
38
  it('认证、动态菜单和重新初始化使用同一服务契约', async () => {
32
- const app = application()
33
- await expect(app.login({ username: 'admin', password: 'wrong' })).rejects.toThrow('账号或密码')
34
- await app.login({ username: 'admin', password: 'lumal123' })
35
- expect(app.currentUser.value?.username).toBe('admin')
36
- expect(app.menus.value.some(menu => menu.path === '/dashboard')).toBe(true)
37
- const token = app.session.getToken()
38
- const restored = application()
39
- await restored.initialize()
40
- expect(restored.session.getToken()).toBe(token)
41
- expect(restored.ready.value).toBe(true)
42
- await restored.logout()
43
- expect(sessionStorage.getItem(`demo:${token}`)).toBeNull()
44
- expect(restored.permissionStore.roles).toEqual([])
45
- })
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
+ });
46
53
  it('生产默认拒绝演示账号,显式启用仅用于演示站点', async () => {
47
- vi.stubEnv('PROD', true)
48
- vi.stubEnv('VITE_ENABLE_LOCAL_DEMO', '')
49
- await expect(application().login({ username: 'admin', password: 'lumal123' })).rejects.toThrow('生产环境')
50
- vi.stubEnv('VITE_ENABLE_LOCAL_DEMO', 'true')
51
- await expect(application().login({ username: 'admin', password: 'lumal123' })).resolves.toBeUndefined()
52
- })
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
+ });
53
60
  it('管理能力按预设启用,访客无写权限', async () => {
54
- const app = application()
55
- await app.login({ username: 'admin', password: 'lumal123' })
56
- if (localMenus.some(menu => menu.component === 'system/users')) {
57
- await app.service.save('users', { username: 'new-user', nickname: '新增', roles: ['guest'], status: 'enabled' })
58
- const result = await app.service.list('users', { page: 1, pageSize: 10, query: { keyword: 'new-user' } })
59
- expect(result.total).toBe(1)
60
- await app.service.remove('users', String(result.items[0]?.id))
61
- await expect(app.service.remove('users', 'admin')).rejects.toThrow('内置管理员')
62
- await expect(app.service.save('users', { username: 'admin' })).rejects.toThrow('重复')
63
- await expect(app.service.save('roles', { code: 'admin', status: 'disabled', permissions: [] }, 'admin')).rejects.toThrow('内置管理员')
64
- await app.service.save('roles', { code: 'reader', name: '只读角色', status: 'enabled', permissions: ['system:user:list'] })
65
- await app.service.save('users', { username: 'reader', roles: ['reader'], status: 'enabled' })
66
- await app.service.save('dictionaries/items', { typeCode: 'status', label: '启用', value: 'enabled' })
67
- expect((await app.service.list('dictionaries/items', { page: 1, pageSize: 10, query: { typeCode: 'other' } })).total).toBe(0)
68
- await app.service.save('menus', { title: '目录', path: '/group', name: 'Group', type: 'directory' })
69
- const group = (await app.service.list('menus', { page: 1, pageSize: 10, query: { keyword: 'Group' } })).items[0]!
70
- await expect(app.service.save('menus', { ...group, parentId: group.id }, String(group.id))).rejects.toThrow('循环')
71
- await expect(app.service.save('menus', { title: '恶意', path: '/bad', name: 'Bad', type: 'menu', component: 'https://evil.invalid/a.js' })).rejects.toThrow('白名单')
72
- await app.login({ username: 'reader', password: 'lumal123' })
73
- expect(app.permissionStore.permissions).toEqual(['system:user:list'])
74
- await expect(app.service.list('users', { page: 1, pageSize: 10, query: {} })).resolves.toHaveProperty('total')
75
- await expect(app.service.save('users', { username: 'denied' })).rejects.toThrow('无操作权限')
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('不支持');
76
127
  }
77
- else if (localMenus.some(menu => menu.component === 'migration/users')) {
78
- expect(app.permissionStore.permissions).toContain('system:user:create')
79
- await app.service.save('users', { username: 'migration-only', nickname: '迁移用户', roles: ['guest'], status: 'enabled' })
80
- const result = await app.service.list('users', { page: 1, pageSize: 10, query: { keyword: 'migration-only' } })
81
- expect(result.total).toBe(1)
82
- await app.service.remove('users', String(result.items[0]?.id))
83
- expect((await app.service.list('users', { page: 1, pageSize: 10, query: { keyword: 'migration-only' } })).total).toBe(0)
84
- }
85
- else {
86
- await expect(app.service.list('users', { page: 1, pageSize: 10, query: {} })).rejects.toThrow('无操作权限')
87
- }
88
- await app.login({ username: 'guest', password: 'lumal123' })
89
- expect(app.permissionStore.permissions).toEqual([])
90
- await expect(app.service.save('users', { username: 'denied' })).rejects.toThrow('无操作权限')
91
- })
92
- })
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>
@@ -0,0 +1,109 @@
1
+ <script setup lang="ts">
2
+ import type { SchemaFormItem, SchemaFormModel } from '@lumal/core/components';
3
+ import { LumalPage, LumalSchemaForm } from '@lumal/core/components';
4
+ import { ElAlert, ElButton, ElMessage, ElTabPane, ElTabs } from 'element-plus';
5
+ import { shallowRef, watch } from 'vue';
6
+ import { application, service } from '../../app/services/session';
7
+ import { useAsyncData } from '../../shared/composables/use-async-data';
8
+ import { createProfileService, profileInput } from './api';
9
+ import ProfilePasswordForm from './components/ProfilePasswordForm.vue';
10
+
11
+ /***********************个人资料读取与保存*********************/
12
+ const api = createProfileService(service);
13
+ const state = useAsyncData(api.get, null);
14
+ const model = shallowRef<SchemaFormModel>({});
15
+ const saving = shallowRef(false);
16
+ const needsSessionRefresh = shallowRef(false);
17
+ const failure = shallowRef('');
18
+ const success = shallowRef('');
19
+ watch(state.data, (value) => {
20
+ if (value) model.value = { ...value };
21
+ });
22
+ const schemas: SchemaFormItem[] = [
23
+ { field: 'username', label: '用户名', readonly: true },
24
+ {
25
+ field: 'roles',
26
+ label: '角色',
27
+ readonly: true,
28
+ formatter: (value) => (Array.isArray(value) ? value.join('、') : ''),
29
+ },
30
+ { field: 'nickname', label: '昵称', required: true, rules: [{ max: 32, message: '昵称最多 32 个字符' }] },
31
+ { field: 'phone', label: '手机号', rules: [{ pattern: /^1\d{10}$/, message: '手机号格式错误' }] },
32
+ ];
33
+ async function save(values: SchemaFormModel): Promise<void> {
34
+ if (saving.value || needsSessionRefresh.value) return;
35
+ saving.value = true;
36
+ failure.value = '';
37
+ success.value = '';
38
+ let persisted = false;
39
+ try {
40
+ model.value = { ...(await api.save(profileInput(values))) };
41
+ persisted = true;
42
+ needsSessionRefresh.value = true;
43
+ await application.refreshAccess();
44
+ needsSessionRefresh.value = false;
45
+ success.value = '个人资料已更新';
46
+ ElMessage.success('个人资料已更新');
47
+ } catch (reason) {
48
+ failure.value = `${persisted ? '资料已保存,但会话刷新失败,请重新读取:' : ''}${reason instanceof Error ? reason.message : '保存失败'}`;
49
+ } finally {
50
+ saving.value = false;
51
+ }
52
+ }
53
+
54
+ /***********************部分成功后的只读重试*********************/
55
+ async function reload(): Promise<void> {
56
+ if (saving.value || state.loading.value) return;
57
+ saving.value = true;
58
+ failure.value = '';
59
+ try {
60
+ // 已保存的资料不重复提交;恢复会话授权后再读取服务端资料。
61
+ if (needsSessionRefresh.value) {
62
+ await application.refreshAccess();
63
+ needsSessionRefresh.value = false;
64
+ }
65
+ await state.load();
66
+ } catch (reason) {
67
+ failure.value = `${needsSessionRefresh.value ? '资料已保存,但会话刷新失败,请重试:' : ''}${reason instanceof Error ? reason.message : '读取失败'}`;
68
+ } finally {
69
+ saving.value = false;
70
+ }
71
+ }
72
+ </script>
73
+
74
+ <template>
75
+ <LumalPage class="lumal-admin-page" title="个人中心">
76
+ <ElAlert v-if="state.error.value || failure" :title="state.error.value || failure" type="error" :closable="false">
77
+ <ElButton link :loading="saving" @click="reload">
78
+ {{ needsSessionRefresh ? '重试会话刷新' : '重新读取' }}
79
+ </ElButton>
80
+ </ElAlert>
81
+ <ElAlert v-if="success" :title="success" type="success" :closable="false" />
82
+ <ElTabs class="profile-tabs">
83
+ <ElTabPane label="个人资料">
84
+ <LumalSchemaForm
85
+ v-if="state.data.value"
86
+ v-model="model"
87
+ :schemas="schemas"
88
+ :columns="1"
89
+ mode="edit"
90
+ show-actions
91
+ :disabled="needsSessionRefresh"
92
+ :submit-disabled="needsSessionRefresh"
93
+ :submit-loading="saving"
94
+ submit-text="保存资料"
95
+ @submit="save"
96
+ />
97
+ </ElTabPane>
98
+ <ElTabPane label="修改密码" lazy>
99
+ <ProfilePasswordForm :api="api" />
100
+ </ElTabPane>
101
+ </ElTabs>
102
+ </LumalPage>
103
+ </template>
104
+
105
+ <style scoped>
106
+ .profile-tabs {
107
+ max-width: 720px;
108
+ }
109
+ </style>
@@ -0,0 +1,49 @@
1
+ import type { SchemaFormModel } from '@lumal/core/components';
2
+ import type { BusinessTransport } from '../../app/services/contracts';
3
+ import { object, stringArray, text } from '../../shared/api';
4
+
5
+ export interface Profile {
6
+ id: string;
7
+ username: string;
8
+ nickname: string;
9
+ phone: string;
10
+ roles: string[];
11
+ }
12
+ export interface ProfileInput {
13
+ nickname: string;
14
+ phone: string;
15
+ }
16
+ export interface PasswordInput {
17
+ currentPassword: string;
18
+ newPassword: string;
19
+ }
20
+ export function profileInput(model: SchemaFormModel): ProfileInput {
21
+ const nickname = text(model.nickname, '昵称', true);
22
+ const phone = text(model.phone, '手机号');
23
+ if (nickname.length > 32 || (phone && !/^1\d{10}$/.test(phone)))
24
+ throw new Error('昵称最多 32 个字符,手机号为 11 位数字');
25
+ return { nickname, phone };
26
+ }
27
+ export function passwordInput(model: SchemaFormModel): PasswordInput {
28
+ const currentPassword = text(model.currentPassword, '当前密码', true);
29
+ const newPassword = text(model.newPassword, '新密码', true);
30
+ if (newPassword.length < 8 || newPassword.length > 32 || newPassword !== model.confirmPassword)
31
+ throw new Error('新密码为 8 到 32 位,且两次输入必须相同');
32
+ return { currentPassword, newPassword };
33
+ }
34
+ function parseProfile(value: unknown): Profile {
35
+ const raw = object(value);
36
+ return {
37
+ ...profileInput(raw),
38
+ id: text(raw.id, '用户 ID', true),
39
+ username: text(raw.username, '用户名', true),
40
+ roles: stringArray(raw.roles),
41
+ };
42
+ }
43
+ export function createProfileService(transport: BusinessTransport) {
44
+ return {
45
+ get: async () => parseProfile(await transport.request('GET', '/profile')),
46
+ save: async (input: ProfileInput) => parseProfile(await transport.request('PUT', '/profile', { body: input })),
47
+ changePassword: (input: PasswordInput) => transport.request<void>('PUT', '/profile/password', { body: input }),
48
+ };
49
+ }
@@ -0,0 +1,68 @@
1
+ <script setup lang="ts">
2
+ import type { SchemaFormItem, SchemaFormModel } from '@lumal/core/components';
3
+ import type { createProfileService } from '../api';
4
+ import { LumalSchemaForm } from '@lumal/core/components';
5
+ import { ElAlert } from 'element-plus';
6
+ import { shallowRef } from 'vue';
7
+ import { passwordInput } from '../api';
8
+
9
+ /***********************真实密码更新接口*********************/
10
+ const props = defineProps<{ api: ReturnType<typeof createProfileService> }>();
11
+ const model = shallowRef<SchemaFormModel>({});
12
+ const saving = shallowRef(false);
13
+ const failure = shallowRef('');
14
+ const success = shallowRef('');
15
+ const schemas: SchemaFormItem[] = [
16
+ {
17
+ field: 'currentPassword',
18
+ label: '当前密码',
19
+ required: true,
20
+ component: 'input',
21
+ componentProps: { type: 'password', showPassword: true, autocomplete: 'current-password' },
22
+ },
23
+ {
24
+ field: 'newPassword',
25
+ label: '新密码',
26
+ required: true,
27
+ component: 'input',
28
+ rules: [{ min: 8, max: 32, message: '密码长度为 8 到 32 位' }],
29
+ componentProps: { type: 'password', showPassword: true, autocomplete: 'new-password' },
30
+ },
31
+ {
32
+ field: 'confirmPassword',
33
+ label: '确认新密码',
34
+ required: true,
35
+ component: 'input',
36
+ componentProps: { type: 'password', showPassword: true, autocomplete: 'new-password' },
37
+ },
38
+ ];
39
+ async function submit(values: SchemaFormModel): Promise<void> {
40
+ if (saving.value) return;
41
+ saving.value = true;
42
+ failure.value = '';
43
+ success.value = '';
44
+ try {
45
+ await props.api.changePassword(passwordInput(values));
46
+ model.value = {};
47
+ success.value = '密码已通过接口更新';
48
+ } catch (reason) {
49
+ failure.value = reason instanceof Error ? reason.message : '密码更新失败';
50
+ } finally {
51
+ saving.value = false;
52
+ }
53
+ }
54
+ </script>
55
+
56
+ <template>
57
+ <ElAlert v-if="failure" :title="failure" type="error" role="alert" :closable="false" />
58
+ <ElAlert v-if="success" :title="success" type="success" :closable="false" />
59
+ <LumalSchemaForm
60
+ v-model="model"
61
+ :schemas="schemas"
62
+ :columns="1"
63
+ show-actions
64
+ submit-text="更新密码"
65
+ :submit-loading="saving"
66
+ @submit="submit"
67
+ />
68
+ </template>
@@ -0,0 +1,40 @@
1
+ import type { DataRow } from '../../app/services/contracts';
2
+ import type { LocalBusinessContext, LocalHandler } from '../../app/services/local-contracts';
3
+ import { object } from '../../app/menu-policy';
4
+ import { profileInput } from './api';
5
+
6
+ export function createLocalHandler({ db, user }: LocalBusinessContext): LocalHandler {
7
+ return {
8
+ handles: (path) => path === '/profile' || path === '/profile/password',
9
+ async request(method, path, input) {
10
+ const current = db.read('users').find((row) => row.id === user().id);
11
+ if (!current) throw new Error('当前用户不存在');
12
+ const clean = ({ password: _password, ...profile }: DataRow) => profile;
13
+ if (path === '/profile' && method === 'GET') return clean(current);
14
+ if (path === '/profile' && method === 'PUT') {
15
+ const next = { ...current, ...profileInput(object(input.body)) };
16
+ db.write(
17
+ 'users',
18
+ db.read('users').map((row) => (row.id === current.id ? next : row)),
19
+ );
20
+ return clean(next);
21
+ }
22
+ if (path === '/profile/password' && method === 'PUT') {
23
+ const body = object(input.body);
24
+ if (
25
+ body.currentPassword !== current.password ||
26
+ typeof body.newPassword !== 'string' ||
27
+ body.newPassword.length < 8 ||
28
+ body.newPassword.length > 32
29
+ )
30
+ throw new Error('当前密码错误或新密码长度不符合要求');
31
+ db.write(
32
+ 'users',
33
+ db.read('users').map((row) => (row.id === current.id ? { ...row, password: body.newPassword } : row)),
34
+ );
35
+ return;
36
+ }
37
+ throw new Error('不支持的个人资料操作');
38
+ },
39
+ };
40
+ }
@@ -0,0 +1,11 @@
1
+ import type { AppModule } from '../../app/module';
2
+
3
+ export const menus: AppModule['menus'] = [
4
+ {
5
+ path: '/profile',
6
+ name: 'Profile',
7
+ component: 'profile/index',
8
+ meta: { title: '个人中心', icon: 'lumal:user', hideInMenu: true, keepAlive: false },
9
+ },
10
+ ];
11
+ export const views: AppModule['views'] = { 'profile/index': () => import('./ProfileView.vue') };