create-lumal-admin 0.0.5-beta → 0.0.6-beta

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (194) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +39 -10
  3. package/dist/cli.d.ts +9 -3
  4. package/dist/cli.d.ts.map +1 -1
  5. package/dist/cli.js +236 -19
  6. package/dist/cli.js.map +1 -1
  7. package/dist/create.d.ts +10 -1
  8. package/dist/create.d.ts.map +1 -1
  9. package/dist/create.js +18 -5
  10. package/dist/create.js.map +1 -1
  11. package/dist/index.d.ts +2 -1
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +1 -0
  14. package/dist/index.js.map +1 -1
  15. package/dist/presets.d.ts +29 -3
  16. package/dist/presets.d.ts.map +1 -1
  17. package/dist/presets.js +38 -10
  18. package/dist/presets.js.map +1 -1
  19. package/dist/project-cli.d.ts +3 -0
  20. package/dist/project-cli.d.ts.map +1 -0
  21. package/dist/project-cli.js +13 -0
  22. package/dist/project-cli.js.map +1 -0
  23. package/dist/projects.d.ts +21 -0
  24. package/dist/projects.d.ts.map +1 -0
  25. package/dist/projects.js +124 -0
  26. package/dist/projects.js.map +1 -0
  27. package/dist/templates.d.ts +2 -0
  28. package/dist/templates.d.ts.map +1 -1
  29. package/dist/templates.js +42 -13
  30. package/dist/templates.js.map +1 -1
  31. package/dist/types.d.ts +39 -0
  32. package/dist/types.d.ts.map +1 -1
  33. package/package.json +11 -3
  34. package/templates/base/API-CONTRACT.md +67 -20
  35. package/templates/base/README.md +20 -3
  36. package/templates/base/src/App.vue +71 -79
  37. package/templates/base/src/app/components/AppHeaderActions.vue +46 -0
  38. package/templates/base/src/{components/app → app/components}/AppSettingsDrawer.vue +15 -15
  39. package/templates/base/src/app/menu-policy.ts +163 -0
  40. package/templates/base/src/app/module.ts +46 -0
  41. package/templates/base/src/app/router/index.ts +107 -0
  42. package/templates/base/src/app/router/views.ts +12 -0
  43. package/templates/base/src/{services → app/services}/access.ts +1 -1
  44. package/templates/base/src/app/services/contracts.ts +54 -0
  45. package/templates/base/src/app/services/local-contracts.ts +18 -0
  46. package/templates/base/src/{services → app/services}/preferences.ts +8 -8
  47. package/templates/base/src/app/services/session-runtime.ts +186 -0
  48. package/templates/base/src/{services → app/services}/session.ts +4 -4
  49. package/templates/base/src/app/stored-menus.ts +72 -0
  50. package/templates/base/src/app/views/SafeIframeView.vue +34 -0
  51. package/templates/base/src/{views → app/views}/auth/LoginView.vue +29 -34
  52. package/templates/base/src/app/views/error/ForbiddenView.vue +20 -0
  53. package/templates/base/src/{views → app/views}/error/NotFoundView.vue +5 -7
  54. package/templates/base/src/main.ts +21 -22
  55. package/templates/base/src/modules/business/README.md +33 -0
  56. package/templates/base/src/{views → modules}/dashboard/DashboardView.vue +3 -3
  57. package/templates/base/src/modules/dashboard/module.ts +14 -0
  58. package/templates/base/src/shared/api.ts +53 -0
  59. package/templates/base/src/shared/components/BaseEntityDialog.vue +39 -0
  60. package/templates/base/src/shared/composables/use-async-data.ts +35 -0
  61. package/templates/base/src/shared/composables/use-entity-editor.ts +77 -0
  62. package/templates/base/src/shared/status.ts +4 -0
  63. package/templates/base/src/shared/tree.ts +41 -0
  64. package/templates/base/src/styles.scss +1 -6
  65. package/templates/base/tests/menu-policy.test.ts.template +89 -0
  66. package/templates/base/tests/session.test.ts.template +38 -27
  67. package/templates/base/vite.config.ts +4 -4
  68. package/templates/blank/README.md +6 -0
  69. package/templates/blank/src/App.vue +11 -0
  70. package/templates/common/AGENTS.md +13 -0
  71. package/templates/common/_editorconfig +15 -0
  72. package/templates/common/_gitattributes +6 -0
  73. package/templates/{base → common}/_gitignore +1 -0
  74. package/templates/common/_prettierignore +6 -0
  75. package/templates/common/docs/DEVELOPMENT.md +26 -0
  76. package/templates/common/eslint.config.mjs +60 -0
  77. package/templates/common/package.json +39 -0
  78. package/templates/common/prettier.config.mjs +8 -0
  79. package/templates/{base → common}/tsconfig.json +2 -5
  80. package/templates/common/vitest.config.ts +3 -0
  81. package/templates/features/examples/src/modules/examples/CrudExampleView.vue +51 -0
  82. package/templates/features/examples/src/{views → modules}/examples/FormExampleView.vue +15 -8
  83. package/templates/features/examples/src/modules/examples/module.ts +27 -0
  84. package/templates/features/system/src/modules/system/config/ConfigView.vue +104 -0
  85. package/templates/features/system/src/modules/system/config/api.ts +38 -0
  86. package/templates/features/system/src/modules/system/config/routes.ts +21 -0
  87. package/templates/features/system/src/modules/system/dictionary/DictionaryItemView.vue +141 -0
  88. package/templates/features/system/src/modules/system/dictionary/DictionaryTypeView.vue +107 -0
  89. package/templates/features/system/src/modules/system/dictionary/api.ts +61 -0
  90. package/templates/features/system/src/modules/system/dictionary/cache.ts +9 -0
  91. package/templates/features/system/src/modules/system/dictionary/routes.ts +32 -0
  92. package/templates/features/system/src/modules/system/dictionary/types.ts +22 -0
  93. package/templates/features/system/src/modules/system/local.ts +348 -0
  94. package/templates/features/system/src/modules/system/menu/MenuView.vue +133 -0
  95. package/templates/features/system/src/modules/system/menu/api.ts +73 -0
  96. package/templates/features/system/src/modules/system/menu/components/MenuEditorDialog.vue +106 -0
  97. package/templates/features/system/src/modules/system/menu/routes.ts +23 -0
  98. package/templates/features/system/src/modules/system/menu/types.ts +18 -0
  99. package/templates/features/system/src/modules/system/module.ts +22 -0
  100. package/templates/features/system/src/modules/system/organization/OrganizationView.vue +134 -0
  101. package/templates/features/system/src/modules/system/organization/api.ts +38 -0
  102. package/templates/features/system/src/modules/system/organization/routes.ts +23 -0
  103. package/templates/features/system/src/modules/system/organization/types.ts +17 -0
  104. package/templates/features/system/src/modules/system/role/RoleView.vue +90 -0
  105. package/templates/features/system/src/modules/system/role/api.ts +40 -0
  106. package/templates/features/system/src/modules/system/role/components/RoleAuthorizationDialog.vue +110 -0
  107. package/templates/features/system/src/modules/system/role/permission-tree.ts +31 -0
  108. package/templates/features/system/src/modules/system/role/routes.ts +24 -0
  109. package/templates/features/system/src/modules/system/role/types.ts +12 -0
  110. package/templates/features/system/src/modules/system/user/UserView.vue +297 -0
  111. package/templates/features/system/src/modules/system/user/api.ts +45 -0
  112. package/templates/features/system/src/modules/system/user/components/UserOrganizationFilter.vue +61 -0
  113. package/templates/features/system/src/modules/system/user/components/UserRoleDialog.vue +93 -0
  114. package/templates/features/system/src/modules/system/user/routes.ts +27 -0
  115. package/templates/features/system/src/modules/system/user/types.ts +28 -0
  116. package/templates/features/system/tests/system.test.ts.template +266 -0
  117. package/templates/modes/http/src/app/services/data-source.ts +124 -0
  118. package/templates/modes/http/src/app/services/http-adapter.ts +128 -0
  119. package/templates/modes/http/tests/http.test.ts.template +186 -133
  120. package/templates/modes/local/src/app/services/data-source.ts +113 -0
  121. package/templates/modes/local/src/app/services/local-database.ts +67 -0
  122. package/templates/modes/local/tests/local.test.ts.template +117 -77
  123. package/templates/presets/standard/src/modules/dashboard/DashboardView.vue +52 -0
  124. package/templates/presets/standard/src/modules/dashboard/components/DashboardSessionStatus.vue +41 -0
  125. package/templates/presets/standard/src/modules/dashboard/components/DashboardShortcuts.vue +51 -0
  126. package/templates/presets/standard/src/modules/profile/ProfileView.vue +109 -0
  127. package/templates/presets/standard/src/modules/profile/api.ts +49 -0
  128. package/templates/presets/standard/src/modules/profile/components/ProfilePasswordForm.vue +68 -0
  129. package/templates/presets/standard/src/modules/profile/local.ts +40 -0
  130. package/templates/presets/standard/src/modules/profile/module.ts +11 -0
  131. package/templates/presets/standard/tests/profile.test.ts.template +72 -0
  132. package/templates/screen/README.md +6 -0
  133. package/templates/screen/src/App.vue +7 -0
  134. package/templates/screen/src/screen/ScreenEntry.vue +18 -0
  135. package/templates/screen/src/screen/ScreenView.vue +48 -0
  136. package/templates/screen/src/screen/components/ScreenMetrics.vue +40 -0
  137. package/templates/screen-auth/src/screen/ScreenEntry.vue +52 -0
  138. package/templates/screen-auth/src/screen/components/ScreenLoginForm.vue +44 -0
  139. package/templates/screen-auth/src/services/session.ts +61 -0
  140. package/templates/screen-auth/tests/session.test.ts.template +54 -0
  141. package/templates/screen-auth-http/src/services/auth-adapter.ts +14 -0
  142. package/templates/screen-auth-http/tests/auth-adapter.test.ts.template +17 -0
  143. package/templates/screen-auth-local-demo/src/services/auth-adapter.ts +15 -0
  144. package/templates/screen-auth-local-demo/tests/auth-adapter.test.ts.template +18 -0
  145. package/templates/screen-data/src/screen/ScreenView.vue +69 -0
  146. package/templates/screen-data/src/services/metrics-types.ts +4 -0
  147. package/templates/screen-data-http/src/services/metrics.ts +20 -0
  148. package/templates/screen-data-http/src/services/request.ts +3 -0
  149. package/templates/screen-data-http/tests/metrics.test.ts.template +21 -0
  150. package/templates/screen-data-local/src/services/metrics.ts +11 -0
  151. package/templates/screen-data-local/tests/metrics.test.ts.template +9 -0
  152. package/templates/screen-http/API-CONTRACT.md +20 -0
  153. package/templates/screen-http/src/services/http.ts +17 -0
  154. package/templates/screen-request-auth/src/services/request.ts +15 -0
  155. package/templates/screen-router/src/App.vue +7 -0
  156. package/templates/screen-router/src/main.ts +13 -0
  157. package/templates/screen-router/src/router.ts +7 -0
  158. package/templates/screen-router/tests/app.test.ts.template +20 -0
  159. package/templates/screen-viewport/src/screen/ScreenViewport.vue +30 -0
  160. package/templates/screen-visualization-charts/src/screen/components/ScreenVisualization.vue +22 -0
  161. package/templates/screen-visualization-cockpit/src/screen/components/ScreenVisualization.vue +33 -0
  162. package/templates/screen-visualization-cockpit/src/screen/components/ScreenWidget.vue +10 -0
  163. package/templates/screen-visualization-datav/src/screen/components/ScreenVisualization.vue +11 -0
  164. package/templates/vben-compatible/MIGRATION.md +2 -0
  165. package/templates/vben-compatible/src/{views/migration → modules/vben-migration}/VbenFormView.vue +2 -1
  166. package/templates/vben-compatible/src/{views/migration → modules/vben-migration}/VbenUsersView.vue +3 -2
  167. package/templates/vben-compatible/src/modules/vben-migration/components/VbenFormExample.vue +64 -0
  168. package/templates/vben-compatible/src/{components/migration → modules/vben-migration/components}/VbenUsers.vue +73 -64
  169. package/templates/vben-compatible/src/modules/vben-migration/local.ts +68 -0
  170. package/templates/vben-compatible/src/modules/vben-migration/module.ts +42 -0
  171. package/templates/vue/index.html +12 -0
  172. package/templates/vue/src/main.ts +5 -0
  173. package/templates/vue/src/styles.css +19 -0
  174. package/templates/vue/tests/app.test.ts.template +10 -0
  175. package/templates/vue/vite.config.ts +9 -0
  176. package/templates/vue/vitest.config.ts +4 -0
  177. package/templates/base/eslint.config.mjs +0 -8
  178. package/templates/base/src/components/app/AppHeaderActions.vue +0 -34
  179. package/templates/base/src/router/index.ts +0 -86
  180. package/templates/base/src/router/views.ts +0 -16
  181. package/templates/base/src/services/contracts.ts +0 -29
  182. package/templates/base/src/services/session-runtime.ts +0 -137
  183. package/templates/base/src/views/error/ForbiddenView.vue +0 -25
  184. package/templates/base/vitest.config.ts +0 -3
  185. package/templates/features/examples/src/modules/examples.ts +0 -10
  186. package/templates/features/examples/src/views/examples/CrudExampleView.vue +0 -30
  187. package/templates/features/system/src/business/system/entities.ts +0 -93
  188. package/templates/features/system/src/modules/system.ts +0 -28
  189. package/templates/features/system/src/views/system/SystemEntityView.vue +0 -85
  190. package/templates/modes/http/src/services/data-source.ts +0 -120
  191. package/templates/modes/http/src/services/http-adapter.ts +0 -124
  192. package/templates/modes/local/src/services/data-source.ts +0 -176
  193. package/templates/vben-compatible/src/components/migration/VbenFormExample.vue +0 -34
  194. package/templates/vben-compatible/src/modules/vben.ts +0 -17
@@ -0,0 +1,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());
@@ -0,0 +1,54 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import { authenticate, refreshAuth } from '../src/services/auth-adapter';
3
+ import {
4
+ authenticated,
5
+ getSessionSignal,
6
+ getSessionToken,
7
+ refreshSession,
8
+ signIn,
9
+ signOut,
10
+ } from '../src/services/session';
11
+
12
+ vi.mock('../src/services/auth-adapter', () => ({
13
+ authEnabled: true,
14
+ authenticate: vi.fn(),
15
+ refreshAuth: vi.fn(),
16
+ }));
17
+ afterEach(() => {
18
+ signOut();
19
+ vi.resetAllMocks();
20
+ vi.useRealTimers();
21
+ });
22
+
23
+ describe('独立 Screen 会话', () => {
24
+ it('登录、刷新、退出并取消旧请求', async () => {
25
+ vi.mocked(authenticate).mockResolvedValue({ accessToken: 'fixture', refreshToken: 'refresh' });
26
+ vi.mocked(refreshAuth).mockResolvedValue({ accessToken: 'renewed' });
27
+ await signIn('test', 'secret');
28
+ const previous = getSessionSignal();
29
+ await refreshSession();
30
+ expect(getSessionToken()).toBe('renewed');
31
+ signOut();
32
+ expect(previous.aborted).toBe(true);
33
+ expect(authenticated.value).toBe(false);
34
+ });
35
+ it('迟到登录不能恢复已退出的身份', async () => {
36
+ let finish!: (data: { accessToken: string }) => void;
37
+ vi.mocked(authenticate).mockImplementation(() => new Promise((resolve) => (finish = resolve)));
38
+ const pending = signIn('old', 'secret');
39
+ signOut();
40
+ finish({ accessToken: 'late' });
41
+ await expect(pending).rejects.toThrow('会话已切换');
42
+ expect(getSessionToken()).toBeUndefined();
43
+ });
44
+ it('登录失败不回退,过期清除凭据', async () => {
45
+ vi.mocked(authenticate).mockRejectedValueOnce(new Error('401'));
46
+ await expect(signIn('wrong', 'wrong')).rejects.toThrow('401');
47
+ expect(authenticated.value).toBe(false);
48
+ vi.useFakeTimers();
49
+ vi.mocked(authenticate).mockResolvedValue({ accessToken: 'short', expiresAt: Date.now() + 100 });
50
+ await signIn('user', 'secret');
51
+ vi.advanceTimersByTime(101);
52
+ expect(authenticated.value).toBe(false);
53
+ });
54
+ });
@@ -0,0 +1,14 @@
1
+ import { parseAuthSession } from '@lumal/core/auth';
2
+ import { publicRequest } from './http';
3
+
4
+ export const authDemo = false;
5
+ export const authEnabled = true;
6
+
7
+ export async function authenticate(username: string, password: string, signal: AbortSignal) {
8
+ return parseAuthSession(await publicRequest.post('/auth/login', { body: { username, password }, signal }));
9
+ }
10
+
11
+ export async function refreshAuth(refreshToken: string, signal: AbortSignal) {
12
+ if (!refreshToken) throw new Error('会话已过期,请重新登录');
13
+ return parseAuthSession(await publicRequest.post('/auth/refresh', { body: { refreshToken }, signal }));
14
+ }
@@ -0,0 +1,17 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { authenticate } from '../src/services/auth-adapter';
3
+ import { publicRequest } from '../src/services/http';
4
+
5
+ vi.mock('../src/services/http', () => ({ publicRequest: { post: vi.fn() } }));
6
+ describe('接口登录契约', () => {
7
+ it('验证响应,401 不回退演示', async () => {
8
+ vi.mocked(publicRequest.post).mockResolvedValueOnce({ accessToken: 'fixture' });
9
+ await expect(authenticate('user', 'secret', new AbortController().signal)).resolves.toEqual({
10
+ accessToken: 'fixture',
11
+ });
12
+ vi.mocked(publicRequest.post).mockRejectedValueOnce(new Error('Unauthorized'));
13
+ await expect(authenticate('user', 'bad', new AbortController().signal)).rejects.toThrow('Unauthorized');
14
+ vi.mocked(publicRequest.post).mockResolvedValueOnce({ code: 403 });
15
+ await expect(authenticate('user', 'bad', new AbortController().signal)).rejects.toThrow();
16
+ });
17
+ });
@@ -0,0 +1,15 @@
1
+ import type { AuthSessionData } from '@lumal/core/auth';
2
+
3
+ export const authDemo = true;
4
+ export const authEnabled = import.meta.env.DEV;
5
+
6
+ export async function authenticate(username: string, password: string, signal: AbortSignal): Promise<AuthSessionData> {
7
+ signal.throwIfAborted();
8
+ if (!authEnabled) throw new Error('生产环境禁止 Local Demo 登录');
9
+ if (username !== 'demo' || password !== 'demo') throw new Error('演示凭据错误');
10
+ return { accessToken: 'local-demo-only', expiresAt: Date.now() + 3_600_000 };
11
+ }
12
+
13
+ export async function refreshAuth(_refreshToken: string, _signal: AbortSignal): Promise<AuthSessionData> {
14
+ throw new Error('Local Demo 过期后请重新登录');
15
+ }
@@ -0,0 +1,18 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ afterEach(() => {
4
+ vi.unstubAllEnvs();
5
+ vi.resetModules();
6
+ });
7
+ describe('本地 Demo 安全边界', () => {
8
+ it('开发显式认证,错误凭据不成功', async () => {
9
+ const { authenticate } = await import('../src/services/auth-adapter');
10
+ await expect(authenticate('demo', 'demo', new AbortController().signal)).resolves.toHaveProperty('accessToken');
11
+ await expect(authenticate('bad', 'bad', new AbortController().signal)).rejects.toThrow('演示凭据');
12
+ });
13
+ it('生产禁用,即使知道演示凭据', async () => {
14
+ vi.stubEnv('DEV', false);
15
+ const { authenticate } = await import('../src/services/auth-adapter');
16
+ await expect(authenticate('demo', 'demo', new AbortController().signal)).rejects.toThrow('生产环境禁止');
17
+ });
18
+ });
@@ -0,0 +1,69 @@
1
+ <script setup lang="ts">
2
+ import type { ScreenMetric } from '../services/metrics-types';
3
+ import { onBeforeUnmount, onMounted, shallowRef } from 'vue';
4
+ import { loadMetrics } from '../services/metrics';
5
+ import ScreenMetrics from './components/ScreenMetrics.vue';
6
+ // SCREEN_VISUALIZATION_IMPORT
7
+
8
+ /***********************指标加载*********************/
9
+ const metrics = shallowRef<ScreenMetric[]>([]);
10
+ const loading = shallowRef(false);
11
+ const error = shallowRef('');
12
+ let controller: AbortController | undefined;
13
+
14
+ async function reload() {
15
+ controller?.abort();
16
+ const current = new AbortController();
17
+ controller = current;
18
+ loading.value = true;
19
+ error.value = '';
20
+ try {
21
+ const result = await loadMetrics(current.signal);
22
+ if (!current.signal.aborted) metrics.value = result;
23
+ } catch (cause) {
24
+ if (!current.signal.aborted) error.value = cause instanceof Error ? cause.message : '数据加载失败';
25
+ } finally {
26
+ if (!current.signal.aborted) loading.value = false;
27
+ }
28
+ }
29
+
30
+ onMounted(() => void reload());
31
+ onBeforeUnmount(() => controller?.abort());
32
+ </script>
33
+
34
+ <template>
35
+ <main class="screen-view" :aria-busy="loading">
36
+ <header class="screen-view__header">
37
+ <h1>数据总览</h1>
38
+ <button type="button" :disabled="loading" @click="reload">刷新数据</button>
39
+ </header>
40
+ <p v-if="error" role="alert">{{ error }}</p>
41
+ <p v-else-if="loading" role="status">加载中</p>
42
+ <ScreenMetrics :metrics="metrics" />
43
+ <!-- SCREEN_VISUALIZATION -->
44
+ </main>
45
+ </template>
46
+
47
+ <style scoped>
48
+ .screen-view {
49
+ height: 100%;
50
+ min-width: 0;
51
+ min-height: 0;
52
+ flex: 1;
53
+ overflow: auto;
54
+ padding: 32px;
55
+ font-size: max(14px, var(--lumal-viewport-min-font-size, 14px));
56
+ }
57
+ .screen-view__header {
58
+ display: flex;
59
+ flex-wrap: wrap;
60
+ align-items: center;
61
+ justify-content: space-between;
62
+ gap: 16px;
63
+ margin-bottom: 32px;
64
+ }
65
+ .screen-view__header h1 {
66
+ margin: 0;
67
+ font-size: max(28px, var(--lumal-viewport-min-font-size, 14px));
68
+ }
69
+ </style>
@@ -0,0 +1,4 @@
1
+ export interface ScreenMetric {
2
+ label: string;
3
+ value: number;
4
+ }
@@ -0,0 +1,20 @@
1
+ import type { ScreenMetric } from './metrics-types';
2
+ import { request } from './request';
3
+
4
+ export async function loadMetrics(signal?: AbortSignal): Promise<ScreenMetric[]> {
5
+ const data = await request.get<unknown>('/screen/metrics', { signal });
6
+ if (
7
+ !Array.isArray(data) ||
8
+ !data.every(
9
+ (item) =>
10
+ item !== null &&
11
+ typeof item === 'object' &&
12
+ typeof item.label === 'string' &&
13
+ typeof item.value === 'number' &&
14
+ Number.isFinite(item.value),
15
+ )
16
+ ) {
17
+ throw new Error('指标响应不符合 API-CONTRACT.md');
18
+ }
19
+ return data as ScreenMetric[];
20
+ }
@@ -0,0 +1,3 @@
1
+ import { publicRequest } from './http';
2
+
3
+ export const request = publicRequest;
@@ -0,0 +1,21 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { loadMetrics } from '../src/services/metrics';
3
+ import { request } from '../src/services/request';
4
+
5
+ vi.mock('../src/services/request', () => ({ request: { get: vi.fn() } }));
6
+ beforeEach(() => vi.resetAllMocks());
7
+
8
+ describe('接口指标 Adapter', () => {
9
+ it('透传取消并验证响应', async () => {
10
+ vi.mocked(request.get).mockResolvedValue([{ label: '任务', value: 3 }]);
11
+ const signal = new AbortController().signal;
12
+ expect(await loadMetrics(signal)).toEqual([{ label: '任务', value: 3 }]);
13
+ expect(request.get).toHaveBeenCalledWith('/screen/metrics', { signal });
14
+ });
15
+ it('接口失败不能回退静态示例', async () => {
16
+ vi.mocked(request.get).mockRejectedValue(new Error('backend rejected'));
17
+ await expect(loadMetrics()).rejects.toThrow('backend rejected');
18
+ vi.mocked(request.get).mockResolvedValue({ code: 403 });
19
+ await expect(loadMetrics()).rejects.toThrow('指标响应');
20
+ });
21
+ });
@@ -0,0 +1,11 @@
1
+ import type { ScreenMetric } from './metrics-types';
2
+
3
+ /** 明确的本地示例,不会在 HTTP 失败时被调用。 */
4
+ export async function loadMetrics(signal?: AbortSignal): Promise<ScreenMetric[]> {
5
+ signal?.throwIfAborted();
6
+ return [
7
+ { label: '任务总数', value: 128 },
8
+ { label: '已完成', value: 96 },
9
+ { label: '进行中', value: 32 },
10
+ ];
11
+ }
@@ -0,0 +1,9 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { loadMetrics } from '../src/services/metrics';
3
+
4
+ describe('本地指标', () => {
5
+ it('返回明确的示例且尊重取消', async () => {
6
+ expect(await loadMetrics()).toHaveLength(3);
7
+ await expect(loadMetrics(AbortSignal.abort())).rejects.toBeDefined();
8
+ });
9
+ });
@@ -0,0 +1,20 @@
1
+ # Screen HTTP 契约
2
+
3
+ 仅为生成项目的应用 Adapter 约定,不是对任意真实后端的兼容承诺。
4
+ 先配置 `VITE_API_BASE_URL`。无配置明确报错,不使用演示数据兜底。
5
+
6
+ - 选择 HTTP 业务数据时:`GET /screen/metrics` 返回 `[{ label: string, value: number }]`。
7
+ - 选择 HTTP 认证时:`POST /auth/login` 接收 `{ username, password }`,
8
+ 返回 `{ accessToken, refreshToken?, expiresAt? }`。`expiresAt` 是毫秒时间戳。
9
+ - 配置了 refreshToken 时,认证失效通过 `POST /auth/refresh` 接收 `{ refreshToken }`,
10
+ 返回相同会话结构。刷新失败退出,不降级。未提供 refreshToken 时过期直接退出。
11
+ - `Authorization: Bearer <token>` 由 Core 注入。后端仍必须执行权限检查。
12
+ - 默认请求超时 15 秒;取消不会伪装为成功。退出、切换身份会取消旧会话请求。
13
+ - 无认证的 HTTP 数据请求 401 只展示错误,不假定需要登录。
14
+
15
+ `http.ts`、`auth-adapter.ts`、`metrics.ts` 是应用可以修改的 Adapter。
16
+ 后端有 `code/data/msg` 包装时,在客户端的 `onResponse` 显式接入
17
+ `createStandardResponseParser`;字段映射和业务上下文不得写进 Core。
18
+ Token 使用当前标签页的 sessionStorage;按部署安全要求调整,不把演示凭据用于真实后端。
19
+ 登录页只在原路径切换内容,路由不会来回跳转或丢失当前 Screen 路径。
20
+ 服务端 Token 撤销、单点登录和业务 Store 清理由应用按接口契约补充。
@@ -0,0 +1,17 @@
1
+ import { createRequestClient } from '@lumal/core/request';
2
+
3
+ /** 可在这里配置成功码或字段映射;默认协议直接返回 JSON,不猜测业务包装。 */
4
+ export const apiBaseURL = import.meta.env.VITE_API_BASE_URL?.trim() ?? '';
5
+
6
+ export function prepareRequest(init: RequestInit) {
7
+ if (!apiBaseURL) throw new Error('请配置 VITE_API_BASE_URL');
8
+ const signals = [AbortSignal.timeout(15_000)];
9
+ if (init.signal) signals.push(init.signal);
10
+ init.signal = AbortSignal.any(signals);
11
+ }
12
+
13
+ // 登录/刷新用独立客户端,401 不触发已登录会话的退出或递归刷新。
14
+ export const publicRequest = createRequestClient({
15
+ baseURL: apiBaseURL,
16
+ onRequest: ({ init }) => prepareRequest(init),
17
+ });