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,39 @@
1
+ <script setup lang="ts">
2
+ import type { SchemaFormItem, SchemaFormModel } from '@lumal/core/components';
3
+ import { LumalSchemaForm } from '@lumal/core/components';
4
+ import { ElAlert, ElDialog } from 'element-plus';
5
+
6
+ /***********************编辑弹窗契约*********************/
7
+ defineProps<{ title: string; schemas: SchemaFormItem[]; editing?: boolean; saving?: boolean; error?: string }>();
8
+ defineEmits<{ submit: [model: SchemaFormModel] }>();
9
+ const visible = defineModel<boolean>('visible', { required: true });
10
+ const model = defineModel<SchemaFormModel>('model', { required: true });
11
+ </script>
12
+
13
+ <template>
14
+ <ElDialog
15
+ v-model="visible"
16
+ :title="title"
17
+ width="min(760px, 94vw)"
18
+ append-to-body
19
+ destroy-on-close
20
+ :close-on-click-modal="!saving"
21
+ :close-on-press-escape="!saving"
22
+ >
23
+ <ElAlert v-if="error" :title="error" type="error" :closable="false" role="alert" />
24
+ <LumalSchemaForm
25
+ v-model="model"
26
+ :mode="editing ? 'edit' : 'create'"
27
+ :schemas="schemas"
28
+ :columns="2"
29
+ :submit-loading="saving"
30
+ show-actions
31
+ submit-text="保存"
32
+ @submit="$emit('submit', $event)"
33
+ >
34
+ <template v-for="(_, name) in $slots" #[name]="scope">
35
+ <slot :name="name" v-bind="scope || {}" />
36
+ </template>
37
+ </LumalSchemaForm>
38
+ </ElDialog>
39
+ </template>
@@ -0,0 +1,35 @@
1
+ import { onActivated, onDeactivated, onMounted, onScopeDispose, shallowRef } from 'vue';
2
+
3
+ export function useAsyncData<T>(fetcher: () => Promise<T>, initial: T) {
4
+ const data = shallowRef(initial);
5
+ const loading = shallowRef(false);
6
+ const error = shallowRef('');
7
+ let generation = 0;
8
+ let reactivating = false;
9
+ async function load(): Promise<void> {
10
+ const version = ++generation;
11
+ loading.value = true;
12
+ error.value = '';
13
+ try {
14
+ const next = await fetcher();
15
+ if (version === generation) data.value = next;
16
+ } catch (reason) {
17
+ if (version === generation) error.value = reason instanceof Error ? reason.message : '加载失败';
18
+ throw reason;
19
+ } finally {
20
+ if (version === generation) loading.value = false;
21
+ }
22
+ }
23
+ const reload = () => load().catch(() => {}); // 错误已保留供页面显示和重试。
24
+ onMounted(reload);
25
+ onDeactivated(() => {
26
+ reactivating = true;
27
+ });
28
+ onActivated(() => {
29
+ if (reactivating) void reload();
30
+ });
31
+ onScopeDispose(() => {
32
+ generation += 1;
33
+ });
34
+ return { data, loading, error, load, reload };
35
+ }
@@ -0,0 +1,77 @@
1
+ import type { SchemaFormModel } from '@lumal/core/components';
2
+ import { ElMessage, ElMessageBox } from 'element-plus';
3
+ import { shallowRef } from 'vue';
4
+
5
+ interface EditorOptions<T extends { id: string }, Input> {
6
+ create: (input: Input) => Promise<unknown>;
7
+ update: (id: string, input: Input) => Promise<unknown>;
8
+ remove: (id: string) => Promise<unknown>;
9
+ toInput: (model: SchemaFormModel) => Input;
10
+ reload: () => Promise<void>;
11
+ removeMessage?: (record: T) => string;
12
+ }
13
+ export function useEntityEditor<T extends { id: string }, Input>(options: EditorOptions<T, Input>) {
14
+ const visible = shallowRef(false);
15
+ const model = shallowRef<SchemaFormModel>({});
16
+ const editing = shallowRef<T>();
17
+ const saving = shallowRef(false);
18
+ const error = shallowRef('');
19
+ function openCreate(initial: SchemaFormModel = {}): void {
20
+ editing.value = undefined;
21
+ model.value = structuredClone(initial);
22
+ error.value = '';
23
+ visible.value = true;
24
+ }
25
+ function openEdit(record: T): void {
26
+ editing.value = record;
27
+ model.value = structuredClone(record) as SchemaFormModel;
28
+ error.value = '';
29
+ visible.value = true;
30
+ }
31
+ async function submit(values: SchemaFormModel): Promise<void> {
32
+ if (saving.value) return;
33
+ saving.value = true;
34
+ error.value = '';
35
+ let persisted = false;
36
+ try {
37
+ const input = options.toInput(values);
38
+ if (editing.value) await options.update(editing.value.id, input);
39
+ else await options.create(input);
40
+ persisted = true;
41
+ visible.value = false;
42
+ await options.reload();
43
+ ElMessage.success('保存成功');
44
+ } catch (reason) {
45
+ const message = reason instanceof Error ? reason.message : '操作失败';
46
+ error.value = persisted ? `保存已完成,但刷新失败:${message}。请重新加载,不要重复提交。` : message;
47
+ } finally {
48
+ saving.value = false;
49
+ }
50
+ }
51
+ async function remove(record: T): Promise<void> {
52
+ if (saving.value) return;
53
+ try {
54
+ await ElMessageBox.confirm(options.removeMessage?.(record) ?? '确定删除该记录?', '删除确认', {
55
+ confirmButtonText: '删除',
56
+ cancelButtonText: '取消',
57
+ type: 'warning',
58
+ });
59
+ } catch {
60
+ return;
61
+ }
62
+ saving.value = true;
63
+ error.value = '';
64
+ let persisted = false;
65
+ try {
66
+ await options.remove(record.id);
67
+ persisted = true;
68
+ await options.reload();
69
+ ElMessage.success('删除成功');
70
+ } catch (reason) {
71
+ error.value = `${persisted ? '删除已完成,但刷新失败:' : ''}${reason instanceof Error ? reason.message : '删除失败'}`;
72
+ } finally {
73
+ saving.value = false;
74
+ }
75
+ }
76
+ return { visible, model, editing, saving, error, openCreate, openEdit, submit, remove };
77
+ }
@@ -0,0 +1,4 @@
1
+ export const statusOptions = [
2
+ { label: '启用', value: 'enabled' },
3
+ { label: '停用', value: 'disabled' },
4
+ ];
@@ -0,0 +1,41 @@
1
+ export interface TreeItem {
2
+ id: string;
3
+ parentId: string;
4
+ }
5
+
6
+ export function assertHierarchy<T extends TreeItem>(items: T[]): void {
7
+ const byId = new Map(items.map((item) => [item.id, item]));
8
+ if (byId.size !== items.length) throw new Error('树节点 ID 重复');
9
+ for (const item of items) {
10
+ const visited = new Set([item.id]);
11
+ let parent = item.parentId;
12
+ while (parent) {
13
+ if (visited.has(parent)) throw new Error('父子关系形成循环');
14
+ const found = byId.get(parent);
15
+ if (!found) throw new Error('父节点不存在');
16
+ visited.add(parent);
17
+ parent = found.parentId;
18
+ }
19
+ }
20
+ }
21
+ export function buildTree<T extends TreeItem>(
22
+ items: T[],
23
+ parentId = '',
24
+ ): (T & { children: (T & { children?: T[] })[] })[] {
25
+ return items
26
+ .filter((item) => item.parentId === parentId)
27
+ .map((item) => ({ ...item, children: buildTree(items, item.id) }));
28
+ }
29
+ export function flattenTree<T extends { children?: T[] }>(items: T[]): T[] {
30
+ return items.flatMap((item) => [item, ...flattenTree(item.children ?? [])]);
31
+ }
32
+ export function excludedDescendants<T extends TreeItem>(items: T[], id?: string): Set<string> {
33
+ const excluded = new Set<string>();
34
+ function visit(current?: string): void {
35
+ if (!current || excluded.has(current)) return;
36
+ excluded.add(current);
37
+ items.filter((item) => item.parentId === current).forEach((item) => visit(item.id));
38
+ }
39
+ visit(id);
40
+ return excluded;
41
+ }
@@ -0,0 +1,173 @@
1
+ :root {
2
+ color: var(--el-text-color-primary);
3
+ background: var(--el-bg-color-page);
4
+ font-family: Inter, 'Segoe UI', 'Microsoft YaHei', Arial, sans-serif;
5
+ }
6
+
7
+ body {
8
+ margin: 0;
9
+ min-width: 320px;
10
+ overflow-x: hidden;
11
+ }
12
+
13
+ .lumal-admin-home {
14
+ display: grid;
15
+ gap: var(--lumal-page-gutter, 20px);
16
+ box-sizing: border-box;
17
+ padding: var(--lumal-page-gutter, 20px);
18
+ }
19
+
20
+ .lumal-admin-home__metrics {
21
+ display: grid;
22
+ grid-template-columns: repeat(4, minmax(0, 1fr));
23
+ gap: 16px;
24
+ }
25
+
26
+ .lumal-admin-home__metric {
27
+ display: grid;
28
+ min-width: 0;
29
+ gap: 8px;
30
+ padding: 18px;
31
+ border: 1px solid var(--el-border-color-lighter);
32
+ border-radius: var(--lumal-radius-base, 8px);
33
+ background: var(--el-bg-color);
34
+ }
35
+
36
+ .lumal-admin-home__metric strong {
37
+ color: var(--el-color-primary);
38
+ font-size: 28px;
39
+ }
40
+
41
+ .lumal-admin-home__metric small {
42
+ color: var(--el-text-color-secondary);
43
+ }
44
+
45
+ .lumal-admin-page {
46
+ box-sizing: border-box;
47
+ padding: var(--lumal-page-gutter, 20px);
48
+ }
49
+
50
+ .lumal-admin-page__list {
51
+ display: grid;
52
+ gap: 10px;
53
+ margin: 0;
54
+ padding-left: 18px;
55
+ }
56
+
57
+ .lumal-admin-page__item {
58
+ color: var(--el-text-color-regular);
59
+ font-size: 14px;
60
+ line-height: 1.6;
61
+ }
62
+
63
+ .lumal-admin-header-actions {
64
+ display: flex;
65
+ min-width: 0;
66
+ align-items: center;
67
+ gap: 4px;
68
+ }
69
+
70
+ .lumal-admin-header-actions .el-button {
71
+ min-width: 44px;
72
+ min-height: 44px;
73
+ margin: 0;
74
+ }
75
+
76
+ .lumal-admin-header-actions__user {
77
+ max-width: 120px;
78
+ overflow: hidden;
79
+ color: var(--el-text-color-regular);
80
+ font-size: 13px;
81
+ text-overflow: ellipsis;
82
+ white-space: nowrap;
83
+ }
84
+
85
+ .lumal-admin-login {
86
+ display: grid;
87
+ min-height: 100dvh;
88
+ place-items: center;
89
+ padding: 24px;
90
+ background: var(--el-bg-color-page);
91
+ }
92
+
93
+ .lumal-admin-login__panel {
94
+ box-sizing: border-box;
95
+ width: min(400px, 100%);
96
+ padding: 32px;
97
+ border: 1px solid var(--el-border-color-lighter);
98
+ border-radius: var(--lumal-radius-large, 12px);
99
+ background: var(--el-bg-color);
100
+ box-shadow: var(--lumal-shadow-dialog);
101
+ }
102
+
103
+ .lumal-admin-login__brand {
104
+ display: grid;
105
+ width: 48px;
106
+ height: 48px;
107
+ place-items: center;
108
+ border-radius: var(--lumal-radius-base, 8px);
109
+ color: var(--el-color-white);
110
+ background: var(--el-color-primary);
111
+ font-size: 24px;
112
+ font-weight: 800;
113
+ }
114
+
115
+ .lumal-admin-login h1 {
116
+ margin: 20px 0 6px;
117
+ }
118
+
119
+ .lumal-admin-login p,
120
+ .lumal-admin-login small {
121
+ color: var(--el-text-color-secondary);
122
+ }
123
+
124
+ .lumal-admin-login .el-form {
125
+ margin: 24px 0 16px;
126
+ }
127
+
128
+ .lumal-admin-login .el-button {
129
+ width: 100%;
130
+ min-height: 44px;
131
+ }
132
+
133
+ .lumal-admin-login__error {
134
+ color: var(--el-color-danger) !important;
135
+ font-size: 13px;
136
+ }
137
+
138
+ @media (max-width: 900px) {
139
+ .lumal-admin-home__metrics {
140
+ grid-template-columns: repeat(2, minmax(0, 1fr));
141
+ }
142
+ }
143
+
144
+ @media (max-width: 640px) {
145
+ .lumal-admin-home,
146
+ .lumal-admin-page {
147
+ padding: 12px;
148
+ }
149
+
150
+ .lumal-admin-home__metrics {
151
+ grid-template-columns: minmax(0, 1fr);
152
+ }
153
+
154
+ .lumal-admin-header-actions__user {
155
+ display: none;
156
+ }
157
+
158
+ .lumal-admin-login {
159
+ padding: 16px;
160
+ }
161
+
162
+ .lumal-admin-login__panel {
163
+ padding: 24px 20px;
164
+ }
165
+ }
166
+
167
+ @media (prefers-reduced-motion: reduce) {
168
+ *,
169
+ *::before,
170
+ *::after {
171
+ scroll-behavior: auto !important;
172
+ }
173
+ }
@@ -0,0 +1,89 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ isInternalLocation,
4
+ resolveView,
5
+ safeExternalUrl,
6
+ validateMenus,
7
+ withAccessibleRedirects,
8
+ } from '../src/app/menu-policy';
9
+ import { assembleModules, collectPermissions } from '../src/app/module';
10
+
11
+ const views = { page: async () => ({}), 'app/iframe': async () => ({}) };
12
+ const page = (path = '/page') => ({ path, name: path, component: 'page', meta: {} });
13
+ describe('远程菜单信任边界', () => {
14
+ it.each(['constructor', '__proto__', 'prototype', 'missing', 'https://evil.invalid/a.js'])(
15
+ '拒绝非白名单组件 %s',
16
+ (component) => {
17
+ expect(() => validateMenus([{ ...page(), component }], views)).toThrow('白名单');
18
+ expect(() =>
19
+ validateMenus([{ path: '/group', name: 'group', children: [{ ...page('/group/page'), component }] }], views),
20
+ ).toThrow('白名单');
21
+ },
22
+ );
23
+ it('仅接受自有函数加载器', () => {
24
+ const inherited = Object.create({ page: async () => ({}) });
25
+ expect(resolveView(inherited, 'page')).toBeUndefined();
26
+ expect(resolveView({ page: false } as unknown as typeof views, 'page')).toBeUndefined();
27
+ expect(resolveView(views, 'page')).toBe(views.page);
28
+ });
29
+ it.each([
30
+ 'javascript:alert(1)',
31
+ 'data:text/html,bad',
32
+ 'vbscript:x',
33
+ '//evil.invalid',
34
+ 'ftp://evil.invalid',
35
+ 'https://user:pass@evil.invalid',
36
+ 'https://evil.invalid\\test',
37
+ ' https://evil.invalid',
38
+ 'https://evil.invalid/\nfoo',
39
+ ])('拒绝危险外链 %s', (url) => {
40
+ expect(() => safeExternalUrl(url)).toThrow();
41
+ expect(() => validateMenus([{ ...page(), meta: { externalLink: url } }], views)).toThrow();
42
+ });
43
+ it('站内地址、iframe 来源和重定向有独立约束', () => {
44
+ expect(safeExternalUrl('https://example.com/path')).toBe('https://example.com/path');
45
+ expect(isInternalLocation('/page?q=1')).toBe(true);
46
+ for (const path of ['//host', '/a/../b', '/%2fhost', '/a\\b', 'https://example.com'])
47
+ expect(isInternalLocation(path)).toBe(false);
48
+ expect(() => validateMenus([{ ...page(), redirect: '//evil.invalid' }], views)).toThrow();
49
+ expect(() => validateMenus([{ ...page(), meta: { iframeSrc: 'https://evil.invalid' } }], views)).toThrow('iframe');
50
+ });
51
+ it('拒绝异常权限、重复路由和循环结构,远程 ignoreAccess 不能取消权限', () => {
52
+ expect(() => validateMenus([{ ...page(), meta: { authority: [7] } }], views)).toThrow();
53
+ expect(() => validateMenus([page(), page()], views)).toThrow('重复');
54
+ const node = { path: '/loop', name: 'Loop', children: [] as unknown[] };
55
+ node.children.push(node);
56
+ expect(() => validateMenus([node], views)).toThrow('循环');
57
+ const result = validateMenus(
58
+ [{ ...page(), meta: { authority: ['read'], ignoreAccess: true, requireLogin: false } }],
59
+ views,
60
+ );
61
+ expect(result[0]?.meta?.authority).toEqual(['read']);
62
+ expect(result[0]?.meta).not.toHaveProperty('requireLogin');
63
+ });
64
+ });
65
+ describe('模块权限与目录重定向', () => {
66
+ it('递归提取真实操作,不机械生成权限', () => {
67
+ const permissions = [{ id: 'group', label: '分组', children: [{ id: 'read', label: '读取', permission: 'read' }] }];
68
+ expect(collectPermissions(permissions)).toEqual(['read']);
69
+ expect(assembleModules([{ menus: [], views, permissions }]).permissionCodes).toEqual(['read']);
70
+ });
71
+ it('父目录跳到首个有权限的子页,无可访问子页时隐藏', () => {
72
+ const menus = [
73
+ {
74
+ path: '/group',
75
+ name: 'Group',
76
+ children: [
77
+ { ...page('/group/a'), meta: { authority: ['a'] } },
78
+ { ...page('/group/b'), meta: { authority: ['b'] } },
79
+ ],
80
+ },
81
+ ];
82
+ expect(withAccessibleRedirects(menus, { roles: [], permissions: ['b'] })[0]?.redirect).toBe('/group/b');
83
+ expect(withAccessibleRedirects(menus, { roles: [], permissions: [] })[0]?.meta?.hideInMenu).toBe(true);
84
+ expect(
85
+ withAccessibleRedirects([{ ...menus[0]!, meta: { authority: ['parent'] } }], { roles: [], permissions: ['b'] })[0]
86
+ ?.redirect,
87
+ ).toBe('/403');
88
+ });
89
+ });
@@ -0,0 +1,45 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { createApplicationSession } from '../src/app/services/session-runtime';
3
+
4
+ describe('应用会话', () => {
5
+ it('统一清理认证、权限、菜单与标签', () => {
6
+ const values = new Map<string, string>();
7
+ const storage = {
8
+ getItem: (key: string) => values.get(key) ?? null,
9
+ setItem: (key: string, value: string) => {
10
+ values.set(key, value);
11
+ },
12
+ removeItem: (key: string) => {
13
+ values.delete(key);
14
+ },
15
+ clear: () => values.clear(),
16
+ key: (index: number) => [...values.keys()][index] ?? null,
17
+ get length() {
18
+ return values.size;
19
+ },
20
+ };
21
+ const app = createApplicationSession(
22
+ () => ({
23
+ login: async () => ({ accessToken: 'test-only' }),
24
+ getUser: async () => ({ id: '1', username: 'test', name: 'Test' }),
25
+ getAccess: async () => ({ roles: ['admin'], permissions: ['read'] }),
26
+ getMenus: async () => [],
27
+ logout: async () => {},
28
+ clearCache: () => {},
29
+ request: async <T>() => undefined as T,
30
+ list: async () => ({ items: [], total: 0 }),
31
+ save: async () => {},
32
+ remove: async () => {},
33
+ }),
34
+ storage,
35
+ storage,
36
+ );
37
+ app.session.setSession({ accessToken: 'test-only' });
38
+ app.permissionStore.setPermissions(['read']);
39
+ storage.setItem(app.tabStorageKey, 'old-tabs');
40
+ app.reset();
41
+ expect(app.isAuthenticated()).toBe(false);
42
+ expect(app.permissionStore.permissions).toEqual([]);
43
+ expect(storage.getItem(app.tabStorageKey)).toBeNull();
44
+ });
45
+ });
@@ -0,0 +1,54 @@
1
+ import { fileURLToPath, URL } from 'node:url';
2
+ import vue from '@vitejs/plugin-vue';
3
+ import { defineConfig } from 'vite';
4
+
5
+ /***********************应用开发配置*********************/
6
+ export default defineConfig({
7
+ plugins: [vue()],
8
+ // manifest 用于核对路由懒加载和初始资源体积。
9
+ build: {
10
+ manifest: true,
11
+ chunkSizeWarningLimit: 500,
12
+ rolldownOptions: {
13
+ output: {
14
+ codeSplitting: {
15
+ groups: [
16
+ {
17
+ entriesAware: true,
18
+ maxSize: 420 * 1024,
19
+ name: 'vendor-element-plus',
20
+ priority: 40,
21
+ test: /node_modules[\\/]element-plus[\\/]/,
22
+ },
23
+ {
24
+ entriesAware: true,
25
+ maxSize: 420 * 1024,
26
+ name: 'vendor-vue',
27
+ priority: 30,
28
+ test: /node_modules[\\/](?:@vue|vue|vue-router)[\\/]/,
29
+ },
30
+ {
31
+ entriesAware: true,
32
+ maxSize: 420 * 1024,
33
+ name: 'vendor-lumal',
34
+ priority: 20,
35
+ test: /node_modules[\\/]@lumal[\\/]/,
36
+ },
37
+ {
38
+ entriesAware: true,
39
+ maxSize: 420 * 1024,
40
+ name: 'vendor',
41
+ priority: 10,
42
+ test: /node_modules[\\/]/,
43
+ },
44
+ ],
45
+ },
46
+ },
47
+ },
48
+ },
49
+ resolve: {
50
+ alias: {
51
+ '@': fileURLToPath(new URL('./src', import.meta.url)),
52
+ },
53
+ },
54
+ });
@@ -0,0 +1,6 @@
1
+ # Vue 项目
2
+
3
+ Vue 3 + TypeScript + Vite。运行时仅依赖 Vue,无登录、路由、Store 或后台框架。
4
+
5
+ 首次运行 `pnpm install`,开发使用 `pnpm dev`,提交前执行 `pnpm check`。
6
+ 工程规范见 [开发约定](docs/DEVELOPMENT.md)。
@@ -0,0 +1,11 @@
1
+ <template>
2
+ <main class="app">
3
+ <h1>Vue</h1>
4
+ </main>
5
+ </template>
6
+
7
+ <style scoped>
8
+ .app {
9
+ padding: 24px;
10
+ }
11
+ </style>
@@ -0,0 +1,13 @@
1
+ # 项目开发指令
2
+
3
+ 先阅读 [开发约定](docs/DEVELOPMENT.md),再修改代码。
4
+
5
+ - 使用 Vue 3、Composition API、`<script setup lang="ts">` 和明确的 TypeScript 类型。
6
+ - 组合式逻辑按 `/***********************说明*********************/` 分块,模板引用使用 `useTemplateRef`。
7
+ - 只使用依赖包的公开 API,不导入 Lumal Monorepo 私有源码。
8
+ - 组件负责呈现,Composable 负责复用状态与生命周期,API Adapter 负责后端字段转换。
9
+ - 路由负责页面装配,Store 只保存跨页面状态;业务规则留在业务模块。
10
+ - 前端认证、菜单和按钮权限不能替代服务端鉴权。
11
+ - 新增页面、修改接口或公共行为时补充对应测试,禁止将接口失败伪装成演示成功。
12
+ - 不添加未使用的重依赖,不批量改写无关文件,不提交凭据、私有数据或构建产物。
13
+ - 完成前执行 `pnpm check`,分别报告通过、失败和未执行的检查。
@@ -0,0 +1,15 @@
1
+ root = true
2
+
3
+ [*]
4
+ charset = utf-8
5
+ end_of_line = lf
6
+ indent_style = space
7
+ indent_size = 2
8
+ insert_final_newline = true
9
+ trim_trailing_whitespace = true
10
+
11
+ [*.md]
12
+ trim_trailing_whitespace = false
13
+
14
+ [*.{yml,yaml}]
15
+ indent_size = 2
@@ -0,0 +1,6 @@
1
+ * text=auto eol=lf
2
+ *.png binary
3
+ *.jpg binary
4
+ *.jpeg binary
5
+ *.webp binary
6
+ *.woff2 binary
@@ -0,0 +1,7 @@
1
+ node_modules/
2
+ dist/
3
+ coverage/
4
+ .env.local
5
+ .env.*.local
6
+ *.log
7
+ .pnpm-store/
@@ -0,0 +1,6 @@
1
+ node_modules/
2
+ dist/
3
+ coverage/
4
+ .pnpm-store/
5
+ pnpm-lock.yaml
6
+ **/*.d.ts
@@ -0,0 +1,26 @@
1
+ # 开发约定
2
+
3
+ ## 通用
4
+
5
+ 组件使用 PascalCase 文件名,目录、普通 TypeScript 文件使用 kebab-case。
6
+ 页面负责组合功能组件;组件通过 props/emits 通信,真正跨页面的状态才放入 Store。
7
+ 异步副作用必须处理加载、失败、取消和卸载,不用假成功替代后端错误。
8
+ 公共逻辑优先使用稳定生态或已安装包的公开 API,禁止导入 `packages/*/src`。
9
+ 业务响应在 API Adapter 中转成稳定类型,校验与业务规则不散落到模板。
10
+
11
+ ESLint 负责质量与依赖约束,Prettier 负责格式。不要同时开启其他格式化插件。
12
+ `src/shared` 不反向依赖业务模块;存在 admin/screen 双域时两者互不依赖。
13
+ 只有当前项目存在相应目录时才应用这些边界,不要求 Blank 建立业务架构。
14
+
15
+ ## 测试与检查
16
+
17
+ - 新增页面:覆盖关键交互、加载失败和权限边界。
18
+ - 修改接口:验证响应转换、错误、取消和会话切换,不只验证成功路径。
19
+ - 修改公共能力:增加契约回归测试,同时检查已有消费者。
20
+ - 布局、图表、Canvas、地图或点击坐标变化:必要时运行真实浏览器检查,DOM 模拟不能证明布局正确。
21
+
22
+ 统一命令:`pnpm lint`、`pnpm format:check`、`pnpm typecheck`、`pnpm test`、`pnpm build`。
23
+ 提交前运行 `pnpm check`。初次安装生成 lockfile,之后使用 `pnpm install --frozen-lockfile`。
24
+ `pnpm format` 应可重复运行且不产生新变化;依赖、产物与声明文件不参与格式化。
25
+
26
+ <!-- PROJECT_CONVENTIONS -->