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,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') };
@@ -0,0 +1,72 @@
1
+ import type { BusinessTransport, DataRow } from '../src/app/services/contracts';
2
+ import { beforeEach, describe, expect, it } from 'vitest';
3
+ import { createProfileService, passwordInput, profileInput } from '../src/modules/profile/api';
4
+ import { createLocalHandler } from '../src/modules/profile/local';
5
+
6
+ let users: DataRow[];
7
+ let api: ReturnType<typeof createProfileService>;
8
+ beforeEach(() => {
9
+ users = [
10
+ {
11
+ id: 'admin',
12
+ username: 'admin',
13
+ nickname: '管理员',
14
+ phone: '',
15
+ roles: ['admin'],
16
+ status: 'enabled',
17
+ password: 'lumal123',
18
+ },
19
+ ];
20
+ const handler = createLocalHandler({
21
+ db: {
22
+ read: <T extends DataRow>() => structuredClone(users) as T[],
23
+ write: (_key, rows) => {
24
+ users = structuredClone(rows);
25
+ },
26
+ },
27
+ user: () => ({ id: 'admin', username: 'admin', name: '管理员' }),
28
+ assertPermission: () => {},
29
+ });
30
+ const transport: BusinessTransport = {
31
+ request: async <T>(method: Parameters<BusinessTransport['request']>[0], path: string, input = {}) =>
32
+ (await handler.request(method, path, input)) as T,
33
+ };
34
+ api = createProfileService(transport);
35
+ });
36
+ describe('个人资料与密码业务契约', () => {
37
+ it('读取与修改允许字段,不泄露密码或允许修改身份与角色', async () => {
38
+ expect(await api.get()).not.toHaveProperty('password');
39
+ const saved = await api.save(
40
+ profileInput({ nickname: '已修改', phone: '13800138000', username: 'injected', roles: [] }),
41
+ );
42
+ expect(saved.nickname).toBe('已修改');
43
+ expect(saved.username).toBe('admin');
44
+ expect(saved.roles).toEqual(['admin']);
45
+ expect(users[0]?.password).toBe('lumal123');
46
+ expect(() => profileInput({ nickname: '', phone: 'bad' })).toThrow();
47
+ });
48
+ it('错误密码不写入;真实保存后新密码才生效', async () => {
49
+ await expect(api.changePassword({ currentPassword: 'wrong', newPassword: 'new-password' })).rejects.toThrow(
50
+ '当前密码',
51
+ );
52
+ expect(users[0]?.password).toBe('lumal123');
53
+ expect(() =>
54
+ passwordInput({ currentPassword: 'lumal123', newPassword: 'new-password', confirmPassword: 'other' }),
55
+ ).toThrow('两次');
56
+ await api.changePassword(
57
+ passwordInput({ currentPassword: 'lumal123', newPassword: 'new-password', confirmPassword: 'new-password' }),
58
+ );
59
+ expect(users[0]?.password).toBe('new-password');
60
+ });
61
+ it('hTTP 能力未接入时向页面传播失败,不伪造保存结果', async () => {
62
+ const http = createProfileService({
63
+ request: async () => {
64
+ throw new Error('HTTP 404:未配置个人资料接口');
65
+ },
66
+ });
67
+ await expect(http.get()).rejects.toThrow('HTTP 404');
68
+ await expect(http.changePassword({ currentPassword: 'old', newPassword: 'new-password' })).rejects.toThrow(
69
+ 'HTTP 404',
70
+ );
71
+ });
72
+ });
@@ -0,0 +1,6 @@
1
+ # Screen
2
+
3
+ 独立大屏,不包含 Admin 菜单、标签和系统管理。默认纯静态、无 API、无认证。
4
+
5
+ 首次运行 `pnpm install`,开发使用 `pnpm dev`,提交前执行 `pnpm check`。
6
+ 工程规范见 [开发约定](docs/DEVELOPMENT.md)。
@@ -0,0 +1,7 @@
1
+ <script setup lang="ts">
2
+ import ScreenEntry from './screen/ScreenEntry.vue';
3
+ </script>
4
+
5
+ <template>
6
+ <ScreenEntry />
7
+ </template>
@@ -0,0 +1,18 @@
1
+ <script setup lang="ts">
2
+ import ScreenView from './ScreenView.vue';
3
+ </script>
4
+
5
+ <template>
6
+ <div class="screen-entry">
7
+ <ScreenView />
8
+ </div>
9
+ </template>
10
+
11
+ <style scoped>
12
+ .screen-entry {
13
+ display: flex;
14
+ flex-direction: column;
15
+ height: 100dvh;
16
+ min-height: 0;
17
+ }
18
+ </style>
@@ -0,0 +1,48 @@
1
+ <script setup lang="ts">
2
+ import ScreenMetrics from './components/ScreenMetrics.vue';
3
+ // SCREEN_VISUALIZATION_IMPORT
4
+
5
+ /***********************静态演示数据*********************/
6
+ const metrics = [
7
+ { label: '任务总数', value: 128 },
8
+ { label: '已完成', value: 96 },
9
+ { label: '进行中', value: 32 },
10
+ ];
11
+ </script>
12
+
13
+ <template>
14
+ <main class="screen-view">
15
+ <header class="screen-view__header">
16
+ <h1>数据总览</h1>
17
+ <span>静态示例</span>
18
+ </header>
19
+ <ScreenMetrics :metrics="metrics" />
20
+ <!-- SCREEN_VISUALIZATION -->
21
+ </main>
22
+ </template>
23
+
24
+ <style scoped>
25
+ .screen-view {
26
+ height: 100%;
27
+ min-width: 0;
28
+ min-height: 0;
29
+ flex: 1;
30
+ overflow: auto;
31
+ padding: 32px;
32
+ font-size: max(14px, var(--lumal-viewport-min-font-size, 14px));
33
+ }
34
+
35
+ .screen-view__header {
36
+ display: flex;
37
+ flex-wrap: wrap;
38
+ align-items: center;
39
+ justify-content: space-between;
40
+ gap: 16px;
41
+ margin-bottom: 32px;
42
+ }
43
+
44
+ .screen-view__header h1 {
45
+ margin: 0;
46
+ font-size: max(28px, var(--lumal-viewport-min-font-size, 14px));
47
+ }
48
+ </style>
@@ -0,0 +1,40 @@
1
+ <script setup lang="ts">
2
+ /***********************指标输入*********************/
3
+ defineProps<{ metrics: { label: string; value: number }[] }>();
4
+ </script>
5
+
6
+ <template>
7
+ <dl class="screen-metrics">
8
+ <div v-for="metric in metrics" :key="metric.label" class="screen-metrics__item">
9
+ <dt>{{ metric.label }}</dt>
10
+ <dd>{{ metric.value.toLocaleString() }}</dd>
11
+ </div>
12
+ </dl>
13
+ </template>
14
+
15
+ <style scoped>
16
+ .screen-metrics {
17
+ display: grid;
18
+ grid-template-columns: repeat(auto-fit, minmax(min(100%, 240px), 1fr));
19
+ gap: 24px;
20
+ margin: 0;
21
+ }
22
+
23
+ .screen-metrics__item {
24
+ padding: 24px;
25
+ border: 1px solid #d8e0e6;
26
+ border-top: 3px solid #118c74;
27
+ border-radius: 4px;
28
+ background: #fff;
29
+ }
30
+
31
+ .screen-metrics__item dt {
32
+ color: #53616f;
33
+ }
34
+
35
+ .screen-metrics__item dd {
36
+ margin: 12px 0 0;
37
+ font-size: max(36px, var(--lumal-viewport-min-font-size, 14px));
38
+ font-weight: 600;
39
+ }
40
+ </style>
@@ -0,0 +1,52 @@
1
+ <script setup lang="ts">
2
+ import { shallowRef } from 'vue';
3
+ import { authDemo, authEnabled } from '../services/auth-adapter';
4
+ import { authenticated, signIn, signOut } from '../services/session';
5
+ import ScreenLoginForm from './components/ScreenLoginForm.vue';
6
+ import ScreenView from './ScreenView.vue';
7
+
8
+ /***********************认证门禁*********************/
9
+ const loading = shallowRef(false);
10
+ const error = shallowRef('');
11
+ async function login(username: string, password: string) {
12
+ loading.value = true;
13
+ error.value = '';
14
+ try {
15
+ await signIn(username, password);
16
+ } catch (cause) {
17
+ error.value = cause instanceof Error ? cause.message : '登录失败';
18
+ } finally {
19
+ loading.value = false;
20
+ }
21
+ }
22
+ </script>
23
+
24
+ <template>
25
+ <div v-if="authenticated" class="screen-entry">
26
+ <div class="screen-session">
27
+ <span v-if="authDemo">Local Demo</span>
28
+ <button type="button" @click="signOut">退出登录</button>
29
+ </div>
30
+ <ScreenView />
31
+ </div>
32
+ <main v-else>
33
+ <ScreenLoginForm :loading="loading" :error="error" :enabled="authEnabled" :demo="authDemo" @submit="login" />
34
+ </main>
35
+ </template>
36
+
37
+ <style scoped>
38
+ .screen-entry {
39
+ display: flex;
40
+ flex-direction: column;
41
+ height: 100dvh;
42
+ min-height: 0;
43
+ }
44
+ .screen-session {
45
+ flex: none;
46
+ display: flex;
47
+ align-items: center;
48
+ justify-content: flex-end;
49
+ gap: 16px;
50
+ padding: 12px 32px;
51
+ }
52
+ </style>
@@ -0,0 +1,44 @@
1
+ <script setup lang="ts">
2
+ import { shallowRef } from 'vue';
3
+
4
+ /***********************登录输入*********************/
5
+ defineProps<{ loading: boolean; error: string; enabled: boolean; demo: boolean }>();
6
+ const emit = defineEmits<{ submit: [username: string, password: string] }>();
7
+ const username = shallowRef('');
8
+ const password = shallowRef('');
9
+ </script>
10
+
11
+ <template>
12
+ <form class="screen-login" @submit.prevent="emit('submit', username, password)">
13
+ <h1>登录</h1>
14
+ <p v-if="demo" role="status">Local Demo 认证,仅限开发:demo / demo</p>
15
+ <p v-if="!enabled" role="alert">生产环境禁止 Local Demo 登录</p>
16
+ <p v-if="error" role="alert">{{ error }}</p>
17
+ <label>用户名<input v-model="username" name="username" autocomplete="username" required /></label>
18
+ <label
19
+ >密码<input v-model="password" name="password" type="password" autocomplete="current-password" required
20
+ /></label>
21
+ <button type="submit" :disabled="loading || !enabled">{{ loading ? '登录中' : '登录' }}</button>
22
+ </form>
23
+ </template>
24
+
25
+ <style scoped>
26
+ .screen-login {
27
+ display: grid;
28
+ gap: 20px;
29
+ width: min(360px, calc(100% - 40px));
30
+ margin: 12vh auto;
31
+ }
32
+ .screen-login label {
33
+ display: grid;
34
+ gap: 8px;
35
+ }
36
+ .screen-login input {
37
+ box-sizing: border-box;
38
+ width: 100%;
39
+ min-height: 40px;
40
+ }
41
+ .screen-login button {
42
+ min-height: 40px;
43
+ }
44
+ </style>
@@ -0,0 +1,61 @@
1
+ import type { AuthSessionData } from '@lumal/core/auth';
2
+ import { createAuthSession } from '@lumal/core/auth';
3
+ import { shallowRef } from 'vue';
4
+ import { authEnabled, authenticate, refreshAuth } from './auth-adapter';
5
+
6
+ /***********************独立会话*********************/
7
+ const session = createAuthSession({
8
+ storage: typeof sessionStorage === 'undefined' ? undefined : sessionStorage,
9
+ tokenKey: 'screen:session',
10
+ });
11
+ if (!authEnabled) session.clearToken();
12
+ const saved = session.getSession();
13
+ if (saved?.expiresAt !== undefined && saved.expiresAt <= Date.now()) session.clearToken();
14
+ export const authenticated = shallowRef(session.isAuthenticated());
15
+ let controller = new AbortController();
16
+ let generation = 0;
17
+ let expiry: ReturnType<typeof setTimeout> | undefined;
18
+ export const getSessionToken = () => session.getToken() || undefined;
19
+ export const getSessionSignal = () => controller.signal;
20
+ export const getSessionScope = () => String(generation);
21
+
22
+ function scheduleExpiry() {
23
+ clearTimeout(expiry);
24
+ const expiresAt = session.getSession()?.expiresAt;
25
+ if (expiresAt === undefined) return;
26
+ const remaining = expiresAt - Date.now();
27
+ if (remaining <= 0) signOut();
28
+ else expiry = setTimeout(scheduleExpiry, Math.min(remaining, 2_147_483_647));
29
+ }
30
+
31
+ export function signOut() {
32
+ generation += 1;
33
+ controller.abort();
34
+ controller = new AbortController();
35
+ clearTimeout(expiry);
36
+ session.clearToken();
37
+ authenticated.value = false;
38
+ }
39
+
40
+ function applySession(data: AuthSessionData, expected: number) {
41
+ if (generation !== expected) throw new Error('会话已切换');
42
+ session.setSession(data);
43
+ authenticated.value = true;
44
+ scheduleExpiry();
45
+ }
46
+
47
+ export async function signIn(username: string, password: string) {
48
+ signOut();
49
+ const expected = generation;
50
+ const data = await authenticate(username, password, controller.signal);
51
+ applySession(data, expected);
52
+ }
53
+
54
+ export async function refreshSession() {
55
+ const expected = generation;
56
+ const data = await refreshAuth(session.getRefreshToken(), controller.signal);
57
+ applySession(data, expected);
58
+ }
59
+
60
+ scheduleExpiry();
61
+ if (import.meta.hot) import.meta.hot.dispose(() => signOut());