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,206 @@
1
+ <script setup lang="ts">
2
+ import type { LumalLayoutTabItem } from '@lumal/core/layout';
3
+ import type { LumalPreferences } from '@lumal/core/theme';
4
+ import type { RouteLocationNormalizedLoaded } from 'vue-router';
5
+ import { findMenuItemByPath, LumalLayout, LumalRouterView } from '@lumal/core/layout';
6
+ import { ElAlert, ElConfigProvider, ElMessage } from 'element-plus';
7
+ import zhCn from 'element-plus/es/locale/lang/zh-cn';
8
+ import { computed, defineAsyncComponent, onErrorCaptured, reactive, shallowRef, watch } from 'vue';
9
+ import { useRoute, useRouter } from 'vue-router';
10
+ import { appConfig } from './app.config';
11
+ import AppHeaderActions from './app/components/AppHeaderActions.vue';
12
+ import { safeExternalUrl } from './app/menu-policy';
13
+ import { createAdminSidebarMenus, createAdminTabs } from './app/router';
14
+ import {
15
+ adminPreferenceDefaults,
16
+ adminPreferences,
17
+ adminResolvedThemeMode,
18
+ patchAdminPreferences,
19
+ } from './app/services/preferences';
20
+ import { adminTabSnapshotStorageKey, application, currentUser, logout } from './app/services/session';
21
+
22
+ /***********************页面状态*********************/
23
+ const AppSettingsDrawer = defineAsyncComponent(() => import('./app/components/AppSettingsDrawer.vue'));
24
+ const title = appConfig.title;
25
+ const settingsVisible = shallowRef(false);
26
+ const pageError = shallowRef('');
27
+ const routeRefreshKeys = reactive<Record<string, number>>({});
28
+ onErrorCaptured((error) => {
29
+ pageError.value = error.message;
30
+ return false;
31
+ });
32
+
33
+ /***********************路由状态*********************/
34
+ const route = useRoute();
35
+ const router = useRouter();
36
+ const isPublicLayout = computed(() => route.meta.layout === 'public');
37
+ const allMenus = computed(() => createAdminSidebarMenus());
38
+ const fixedTabs = computed<LumalLayoutTabItem[]>(() => createAdminTabs());
39
+ // 标签和视图使用相同的 path/version 身份,刷新只替换当前标签的缓存。
40
+ function resolveRouteViewKey(path: string): string {
41
+ return `${path}:${routeRefreshKeys[path] ?? 0}`;
42
+ }
43
+ const routeViewKey = computed(() => resolveRouteViewKey(route.path));
44
+ const routeViewCache = computed(() => adminPreferences.value.tabbar.enable && adminPreferences.value.tabbar.cache);
45
+ const activePath = computed({
46
+ get: () => route.path,
47
+ set: (path: string) => {
48
+ if (path !== route.path) {
49
+ void router.push(path);
50
+ }
51
+ },
52
+ });
53
+
54
+ watch(
55
+ [() => route.meta.title, () => adminPreferences.value.app.dynamicTitle],
56
+ () => {
57
+ const routeTitle = typeof route.meta.title === 'string' ? route.meta.title.trim() : '';
58
+ document.title = adminPreferences.value.app.dynamicTitle && routeTitle ? `${routeTitle} - ${title}` : title;
59
+ },
60
+ { immediate: true },
61
+ );
62
+
63
+ /***********************导航事件*********************/
64
+ function handleMenuSelect(path: string): void {
65
+ const item = findMenuItemByPath(allMenus.value, path);
66
+
67
+ if (item?.externalLink) {
68
+ const url = safeExternalUrl(item.externalLink);
69
+ if (item.externalTarget === '_self') window.location.assign(url);
70
+ else window.open(url, '_blank', 'noopener,noreferrer');
71
+ return;
72
+ }
73
+
74
+ activePath.value = path;
75
+ }
76
+
77
+ function handleToggleSidebar(): void {
78
+ patchAdminPreferences({
79
+ sidebar: { collapsed: !adminPreferences.value.sidebar.collapsed },
80
+ });
81
+ }
82
+
83
+ function handleTabChange(path: string): void {
84
+ activePath.value = path;
85
+ }
86
+
87
+ function resolveAdminRouteTab(currentRoute: RouteLocationNormalizedLoaded): LumalLayoutTabItem | undefined {
88
+ if (currentRoute.meta.layout === 'public' || currentRoute.meta.hideInTab === true) {
89
+ return undefined;
90
+ }
91
+
92
+ const menu = findMenuItemByPath(allMenus.value, currentRoute.path);
93
+ const tabTitle = menu?.title ?? (typeof currentRoute.meta.title === 'string' ? currentRoute.meta.title : undefined);
94
+
95
+ if (!tabTitle) {
96
+ return undefined;
97
+ }
98
+
99
+ return {
100
+ closable: !['/403', '/404'].includes(currentRoute.path),
101
+ icon: menu?.icon ?? (typeof currentRoute.meta.icon === 'string' ? currentRoute.meta.icon : undefined),
102
+ path: currentRoute.path,
103
+ title: tabTitle,
104
+ };
105
+ }
106
+
107
+ async function handleTabRefresh(path: string): Promise<void> {
108
+ if (path !== route.path) {
109
+ await router.push(path);
110
+ }
111
+
112
+ routeRefreshKeys[path] = (routeRefreshKeys[path] ?? 0) + 1;
113
+ }
114
+
115
+ function handleTabsChange(tabs: LumalLayoutTabItem[]): void {
116
+ const paths = new Set(tabs.map((tab) => tab.path));
117
+ for (const path of Object.keys(routeRefreshKeys)) {
118
+ if (!paths.has(path)) delete routeRefreshKeys[path];
119
+ }
120
+ }
121
+
122
+ function handlePreferencesChange(preferences: LumalPreferences): void {
123
+ patchAdminPreferences(preferences);
124
+ }
125
+
126
+ function toggleTheme(): void {
127
+ patchAdminPreferences({
128
+ theme: {
129
+ mode: adminResolvedThemeMode.value === 'dark' ? 'light' : 'dark',
130
+ },
131
+ });
132
+ }
133
+
134
+ async function handleLogout(): Promise<void> {
135
+ try {
136
+ await logout();
137
+ } catch (error) {
138
+ ElMessage.warning(error instanceof Error ? error.message : '远端退出失败,本地会话已清理');
139
+ } finally {
140
+ pageError.value = '';
141
+ await router.replace('/login');
142
+ }
143
+ }
144
+ </script>
145
+
146
+ <template>
147
+ <ElConfigProvider :locale="zhCn">
148
+ <LumalRouterView v-if="isPublicLayout" :view-key="routeViewKey" transition transition-name="fade" />
149
+
150
+ <LumalLayout
151
+ v-else
152
+ :key="`${currentUser?.username}:${application.accessVersion.value}`"
153
+ v-model:active-tab-path="activePath"
154
+ :title="title"
155
+ :menus="allMenus"
156
+ :preferences="adminPreferences"
157
+ route-driven
158
+ :fixed-tabs="fixedTabs"
159
+ :route-tab-resolver="resolveAdminRouteTab"
160
+ tab-fallback-path="/dashboard"
161
+ :tab-storage-key="currentUser ? adminTabSnapshotStorageKey : ''"
162
+ :active-menu-path="activePath"
163
+ @menu-select="handleMenuSelect"
164
+ @toggle-sidebar="handleToggleSidebar"
165
+ @tab-change="handleTabChange"
166
+ @tab-refresh="handleTabRefresh"
167
+ @tabs-change="handleTabsChange"
168
+ >
169
+ <template #headerActions>
170
+ <AppHeaderActions
171
+ :resolved-theme-mode="adminResolvedThemeMode"
172
+ :user-name="currentUser?.name"
173
+ :has-profile="router.hasRoute('Profile')"
174
+ @open-profile="router.push('/profile')"
175
+ @logout="handleLogout"
176
+ @open-settings="settingsVisible = true"
177
+ @toggle-theme="toggleTheme"
178
+ />
179
+ </template>
180
+
181
+ <template #default="{ tabs }">
182
+ <ElAlert v-if="pageError" :title="pageError" type="error" role="alert" closable @close="pageError = ''" />
183
+ <LumalRouterView
184
+ :view-key="routeViewKey"
185
+ :cache-keys="tabs.map((tab: LumalLayoutTabItem) => resolveRouteViewKey(tab.path))"
186
+ :progress="adminPreferences.transition.progress"
187
+ :loading="adminPreferences.transition.loading"
188
+ :cache="routeViewCache"
189
+ :cache-max="adminPreferences.tabbar.maxCount"
190
+ :transition="adminPreferences.transition.enable"
191
+ :transition-name="adminPreferences.transition.name"
192
+ />
193
+ </template>
194
+ </LumalLayout>
195
+
196
+ <AppSettingsDrawer
197
+ v-if="settingsVisible"
198
+ v-model:visible="settingsVisible"
199
+ :defaults="adminPreferenceDefaults"
200
+ :preferences="adminPreferences"
201
+ @change="handlePreferencesChange"
202
+ @reset="handlePreferencesChange"
203
+ @update:preferences="handlePreferencesChange"
204
+ />
205
+ </ElConfigProvider>
206
+ </template>
@@ -0,0 +1,46 @@
1
+ <script setup lang="ts">
2
+ import type { ResolvedThemeMode } from '@lumal/core/theme';
3
+ import { LumalIcon } from '@lumal/icons-vue';
4
+ import { ElButton } from 'element-plus';
5
+
6
+ /***********************头部操作契约*********************/
7
+ withDefaults(
8
+ defineProps<{
9
+ resolvedThemeMode: ResolvedThemeMode;
10
+ userName?: string;
11
+ hasProfile?: boolean;
12
+ }>(),
13
+ {
14
+ userName: '管理员',
15
+ },
16
+ );
17
+
18
+ defineEmits<{
19
+ logout: [];
20
+ openSettings: [];
21
+ toggleTheme: [];
22
+ openProfile: [];
23
+ }>();
24
+ </script>
25
+
26
+ <template>
27
+ <div class="lumal-admin-header-actions">
28
+ <ElButton
29
+ text
30
+ :title="resolvedThemeMode === 'dark' ? '切换浅色模式' : '切换深色模式'"
31
+ :aria-label="resolvedThemeMode === 'dark' ? '切换浅色模式' : '切换深色模式'"
32
+ @click="$emit('toggleTheme')"
33
+ >
34
+ <LumalIcon :name="resolvedThemeMode === 'dark' ? 'lumal:sun' : 'lumal:moon'" />
35
+ </ElButton>
36
+ <ElButton text title="偏好设置" aria-label="偏好设置" @click="$emit('openSettings')">
37
+ <LumalIcon name="lumal:settings" />
38
+ </ElButton>
39
+ <ElButton v-if="hasProfile" text :title="userName" aria-label="个人中心" @click="$emit('openProfile')">
40
+ <LumalIcon name="lumal:user" />
41
+ <span class="lumal-admin-header-actions__user">{{ userName }}</span>
42
+ </ElButton>
43
+ <span v-else class="lumal-admin-header-actions__user">{{ userName }}</span>
44
+ <ElButton text aria-label="退出登录" @click="$emit('logout')"> 退出 </ElButton>
45
+ </div>
46
+ </template>
@@ -0,0 +1,37 @@
1
+ <script setup lang="ts">
2
+ import type { LumalPreferences, LumalPreferencesDefaults } from '@lumal/core/theme';
3
+ import { LumalThemeSettingsPanel } from '@lumal/core/theme';
4
+ import { ElDrawer } from 'element-plus';
5
+ import { computed } from 'vue';
6
+
7
+ /***********************偏好设置抽屉*********************/
8
+ const props = defineProps<{
9
+ defaults: LumalPreferencesDefaults;
10
+ preferences: LumalPreferences;
11
+ visible: boolean;
12
+ }>();
13
+
14
+ const emit = defineEmits<{
15
+ change: [preferences: LumalPreferences];
16
+ reset: [preferences: LumalPreferences];
17
+ 'update:preferences': [preferences: LumalPreferences];
18
+ 'update:visible': [visible: boolean];
19
+ }>();
20
+
21
+ const visible = computed({
22
+ get: () => props.visible,
23
+ set: (value) => emit('update:visible', value),
24
+ });
25
+ </script>
26
+
27
+ <template>
28
+ <ElDrawer v-model="visible" title="偏好设置" size="384px" append-to-body>
29
+ <LumalThemeSettingsPanel
30
+ :defaults="defaults"
31
+ :preferences="preferences"
32
+ @change="$emit('change', $event)"
33
+ @reset="$emit('reset', $event)"
34
+ @update:preferences="$emit('update:preferences', $event)"
35
+ />
36
+ </ElDrawer>
37
+ </template>
@@ -0,0 +1,163 @@
1
+ import type { LumalMenuRecord, LumalRouteMeta } from '@lumal/core/router';
2
+ import type { AccessSnapshot } from './services/contracts';
3
+
4
+ const dangerousKeys = new Set(['constructor', '__proto__', 'prototype']);
5
+ const reservedPaths = new Set(['/', '/login', '/403', '/404']);
6
+
7
+ export function object(value: unknown): Record<string, unknown> {
8
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('响应必须是对象');
9
+ return value as Record<string, unknown>;
10
+ }
11
+
12
+ export function stringArray(value: unknown): string[] {
13
+ if (
14
+ !Array.isArray(value) ||
15
+ value.some((item) => typeof item !== 'string' || !item.trim() || item !== item.trim() || dangerousKeys.has(item))
16
+ )
17
+ throw new Error('角色和权限必须是有效的字符串数组');
18
+ return [...new Set(value)] as string[];
19
+ }
20
+
21
+ export function safeExternalUrl(value: unknown): string {
22
+ if (
23
+ typeof value !== 'string' ||
24
+ value !== value.trim() ||
25
+ !/^https?:\/\/[^/\\]/i.test(value) ||
26
+ /[\p{Cc}\s\\]/u.test(value)
27
+ )
28
+ throw new Error('外链仅支持明确的 HTTP(S) 绝对地址');
29
+ const url = new URL(value);
30
+ if (!['http:', 'https:'].includes(url.protocol) || !url.hostname || url.username || url.password)
31
+ throw new Error('外链地址格式不安全');
32
+ return url.href;
33
+ }
34
+
35
+ export function isInternalLocation(value: unknown): value is string {
36
+ if (typeof value !== 'string' || !value.startsWith('/') || value.startsWith('//') || /[\\\p{Cc}\s]/u.test(value))
37
+ return false;
38
+ const path = value.split(/[?#]/)[0]!;
39
+ return !/%|:|\/\/|(?:^|\/)\.\.?(?:\/|$)/.test(path);
40
+ }
41
+
42
+ export function assertRoutePath(value: unknown): asserts value is string {
43
+ if (!isInternalLocation(value) || /[?#]/.test(value) || reservedPaths.has(value))
44
+ throw new Error(`菜单 path 必须是非保留的站内绝对路径:${String(value)}`);
45
+ }
46
+
47
+ export function resolveView(
48
+ views: Record<string, () => Promise<unknown>>,
49
+ key: unknown,
50
+ ): (() => Promise<unknown>) | undefined {
51
+ return typeof key === 'string' &&
52
+ !dangerousKeys.has(key) &&
53
+ Object.hasOwn(views, key) &&
54
+ typeof views[key] === 'function'
55
+ ? views[key]
56
+ : undefined;
57
+ }
58
+
59
+ /** 不直接透传远程 meta;后端数据不能关闭登录检查或绕开权限守卫。 */
60
+ export function validateMenus(value: unknown, views: Record<string, () => Promise<unknown>>): LumalMenuRecord[] {
61
+ const seen = new WeakSet<object>();
62
+ const paths = new Set<string>();
63
+ const names = new Set<string>();
64
+ let count = 0;
65
+ function visit(items: unknown, parent = '', depth = 0): LumalMenuRecord[] {
66
+ if (!Array.isArray(items) || depth > 30) throw new Error('菜单响应必须是有限层级的数组');
67
+ return items.map((item) => {
68
+ const raw = object(item);
69
+ if (++count > 1000 || seen.has(raw)) throw new Error('菜单存在循环或超过数量限制');
70
+ seen.add(raw);
71
+ assertRoutePath(raw.path);
72
+ if (parent && !raw.path.startsWith(`${parent}/`)) throw new Error('子路由必须属于父级绝对路径');
73
+ if (typeof raw.name !== 'string' || !raw.name.trim() || dangerousKeys.has(raw.name))
74
+ throw new Error('菜单 name 必须是安全的非空字符串');
75
+ if (paths.has(raw.path) || names.has(raw.name)) throw new Error('菜单路径或名称重复');
76
+ paths.add(raw.path);
77
+ names.add(raw.name);
78
+ const component = raw.component === '' || raw.component == null ? undefined : raw.component;
79
+ if (component !== undefined && !resolveView(views, component))
80
+ throw new Error(`菜单组件不在白名单中:${String(component)}`);
81
+ const source = raw.meta === undefined ? {} : object(raw.meta);
82
+ const authority =
83
+ source.authority === undefined
84
+ ? []
85
+ : stringArray(typeof source.authority === 'string' ? [source.authority] : source.authority);
86
+ const roles =
87
+ source.roles === undefined ? [] : stringArray(typeof source.roles === 'string' ? [source.roles] : source.roles);
88
+ const meta: LumalRouteMeta = { authority, roles };
89
+ for (const key of ['title', 'icon', 'activeMenu'] as const) {
90
+ if (source[key] !== undefined && typeof source[key] !== 'string') throw new Error(`菜单 ${key} 必须是字符串`);
91
+ if (typeof source[key] === 'string') meta[key] = source[key];
92
+ }
93
+ if (meta.activeMenu && !isInternalLocation(meta.activeMenu)) throw new Error('激活菜单必须是站内路径');
94
+ for (const key of ['hideInMenu', 'hideInBreadcrumb', 'hideInTab', 'keepAlive', 'affixTab', 'topMenu'] as const) {
95
+ if (source[key] !== undefined && typeof source[key] !== 'boolean') throw new Error(`菜单 ${key} 必须是布尔值`);
96
+ if (source[key] !== undefined) meta[key] = source[key];
97
+ }
98
+ if (source.order !== undefined) {
99
+ if (typeof source.order !== 'number' || !Number.isFinite(source.order))
100
+ throw new Error('菜单排序必须是有限数字');
101
+ meta.order = source.order;
102
+ }
103
+ const external = raw.externalLink ?? source.externalLink;
104
+ if (external !== undefined && external !== '') {
105
+ meta.externalLink = safeExternalUrl(external);
106
+ meta.externalTarget = source.externalTarget === '_self' ? '_self' : '_blank';
107
+ }
108
+ // iframe 使用单独的本地组件和构建时来源白名单,不能使用任意页面加载器。
109
+ if (source.iframeSrc !== undefined) {
110
+ const src = safeExternalUrl(source.iframeSrc);
111
+ const allowed = String(import.meta.env.VITE_IFRAME_ORIGINS ?? '')
112
+ .split(',')
113
+ .map((item) => item.trim())
114
+ .filter(Boolean);
115
+ if (component !== 'app/iframe' || !allowed.includes(new URL(src).origin))
116
+ throw new Error('iframe 组件或来源不在独立白名单中');
117
+ meta.iframeSrc = src;
118
+ }
119
+ if (raw.redirect !== undefined && !isInternalLocation(raw.redirect)) throw new Error('菜单重定向必须是站内路径');
120
+ return {
121
+ path: raw.path,
122
+ name: raw.name,
123
+ component: component as string | undefined,
124
+ redirect: typeof raw.redirect === 'string' ? raw.redirect : undefined,
125
+ meta,
126
+ children: raw.children === undefined ? undefined : visit(raw.children, raw.path, depth + 1),
127
+ };
128
+ });
129
+ }
130
+ return visit(value);
131
+ }
132
+
133
+ /** 保留未授权路由供守卫返回 403,目录只重定向到当前身份可访问的后代。 */
134
+ export function withAccessibleRedirects(menus: LumalMenuRecord[], access: AccessSnapshot): LumalMenuRecord[] {
135
+ const can = (menu: LumalMenuRecord) => {
136
+ const permissions = menu.meta?.authority;
137
+ const roles = menu.meta?.roles;
138
+ const required = typeof permissions === 'string' ? [permissions] : (permissions ?? []);
139
+ const roleCodes = typeof roles === 'string' ? [roles] : (roles ?? []);
140
+ return (
141
+ required.every((code) => access.permissions.includes(code)) &&
142
+ (!roleCodes.length || roleCodes.some((role) => access.roles.includes(role)))
143
+ );
144
+ };
145
+ function first(items: LumalMenuRecord[]): string | undefined {
146
+ for (const item of items) {
147
+ if (!can(item) || item.meta?.hideInMenu || item.meta?.externalLink) continue;
148
+ const target = first(item.children ?? []);
149
+ if (target) return target;
150
+ if (item.component && !item.children?.length) return item.path;
151
+ }
152
+ }
153
+ return menus.map((menu) => {
154
+ const children = withAccessibleRedirects(menu.children ?? [], access);
155
+ const directory = !menu.component && !menu.meta?.externalLink;
156
+ return {
157
+ ...menu,
158
+ children,
159
+ redirect: directory ? ((can(menu) ? first(children) : undefined) ?? '/403') : menu.redirect,
160
+ meta: { ...menu.meta, ...(directory && !first(children) ? { hideInMenu: true } : {}) },
161
+ };
162
+ });
163
+ }
@@ -0,0 +1,46 @@
1
+ import type { LumalMenuRecord } from '@lumal/core/router';
2
+
3
+ export interface PermissionNode {
4
+ id: string;
5
+ label: string;
6
+ permission?: string;
7
+ children?: PermissionNode[];
8
+ }
9
+ export interface AppModule {
10
+ menus: LumalMenuRecord[];
11
+ views: Record<string, () => Promise<unknown>>;
12
+ permissions?: PermissionNode[];
13
+ }
14
+
15
+ export function collectPermissions(nodes: readonly PermissionNode[]): string[] {
16
+ return [
17
+ ...new Set(
18
+ nodes.flatMap((node) => [
19
+ ...(node.permission ? [node.permission] : []),
20
+ ...collectPermissions(node.children ?? []),
21
+ ]),
22
+ ),
23
+ ];
24
+ }
25
+
26
+ export function assembleModules(modules: AppModule[]) {
27
+ const views: Record<string, () => Promise<unknown>> = Object.create(null);
28
+ const permissions = modules.flatMap((module) => module.permissions ?? []);
29
+ for (const module of modules) {
30
+ for (const [name, loader] of Object.entries(module.views)) {
31
+ if (
32
+ ['constructor', '__proto__', 'prototype'].includes(name) ||
33
+ Object.hasOwn(views, name) ||
34
+ typeof loader !== 'function'
35
+ )
36
+ throw new Error(`模块组件名称重复或无效:${name}`);
37
+ views[name] = loader;
38
+ }
39
+ }
40
+ return {
41
+ views,
42
+ menus: modules.flatMap((module) => module.menus),
43
+ permissions,
44
+ permissionCodes: collectPermissions(permissions),
45
+ };
46
+ }
@@ -0,0 +1,107 @@
1
+ import type { LumalLayoutTabItem } from '@lumal/core/layout';
2
+ import type { SidebarMenuItem } from '@lumal/core/router';
3
+ import type { RouteRecordRaw, RouterHistory } from 'vue-router';
4
+ import { setupPermissionGuard } from '@lumal/core/permission';
5
+ import { createMenuRouteRuntime } from '@lumal/core/router';
6
+ import { shallowRef } from 'vue';
7
+ import { createRouter, createWebHashHistory, createWebHistory } from 'vue-router';
8
+ import { appConfig } from '../../app.config';
9
+ import { resolveView, validateMenus, withAccessibleRedirects } from '../menu-policy';
10
+ import { application, permissionStore } from '../services/session';
11
+ import { viewComponents } from './views';
12
+
13
+ /***********************路由初始化*********************/
14
+ export const routeError = shallowRef('');
15
+ export function createAdminRouter(history?: RouterHistory) {
16
+ const targetRouter = createRouter({
17
+ history:
18
+ history ??
19
+ (appConfig.routerMode === 'history'
20
+ ? createWebHistory(import.meta.env.BASE_URL)
21
+ : createWebHashHistory(import.meta.env.BASE_URL)),
22
+ routes: [
23
+ {
24
+ path: '/login',
25
+ name: 'Login',
26
+ component: () => import('../views/auth/LoginView.vue'),
27
+ meta: { layout: 'public', requireLogin: false, title: '登录' },
28
+ },
29
+ { path: '/', redirect: '/dashboard' },
30
+ { path: '/403', component: () => import('../views/error/ForbiddenView.vue'), meta: { title: '无权限' } },
31
+ { path: '/404', component: () => import('../views/error/NotFoundView.vue'), meta: { title: '页面不存在' } },
32
+ { path: '/:pathMatch(.*)*', name: 'CatchAll', component: () => import('../views/error/NotFoundView.vue') },
33
+ ],
34
+ });
35
+ const runtime = createMenuRouteRuntime({
36
+ router: {
37
+ addRoute: (route) => targetRouter.addRoute(route as RouteRecordRaw),
38
+ getRoutes: () => targetRouter.getRoutes(),
39
+ hasRoute: (name) => targetRouter.hasRoute(name),
40
+ removeRoute: (name) => targetRouter.removeRoute(name),
41
+ },
42
+ componentResolver: (name) => resolveView(viewComponents, name),
43
+ hasPermission: (values) => permissionStore.hasPermission(values, 'every'),
44
+ hasRole: (values) => permissionStore.hasRole(values, 'some'),
45
+ loadRemoteMenus: async () => {
46
+ await application.initialize();
47
+ return withAccessibleRedirects(validateMenus(application.menus.value, viewComponents), {
48
+ permissions: permissionStore.permissions,
49
+ roles: permissionStore.roles,
50
+ });
51
+ },
52
+ });
53
+ application.onReset((expired) => {
54
+ runtime.resetRemote();
55
+ if (expired && targetRouter.currentRoute.value.path !== '/login')
56
+ void targetRouter.replace({ path: '/login', query: { redirect: targetRouter.currentRoute.value.fullPath } });
57
+ });
58
+ const navigationVersion = shallowRef(0);
59
+ application.onRefresh(async () => {
60
+ await runtime.reload();
61
+ navigationVersion.value += 1;
62
+ await targetRouter.replace(targetRouter.currentRoute.value.fullPath);
63
+ });
64
+
65
+ targetRouter.beforeEach(async (to) => {
66
+ if (!application.isAuthenticated())
67
+ return to.path === '/login' ? true : { path: '/login', query: { redirect: to.fullPath } };
68
+ const rematch = !runtime.remoteLoaded;
69
+ try {
70
+ await runtime.loadRemote();
71
+ routeError.value = '';
72
+ } catch (reason) {
73
+ routeError.value = reason instanceof Error ? reason.message : '菜单加载失败';
74
+ application.reset();
75
+ return { path: '/login', query: { redirect: to.fullPath } };
76
+ }
77
+ if (to.path === '/login' || to.path === '/') return runtime.firstAccessiblePath || '/403';
78
+ if (rematch) return to.fullPath;
79
+ return to.name === 'CatchAll' ? '/404' : true;
80
+ });
81
+ setupPermissionGuard(targetRouter, permissionStore, {
82
+ isAuthenticated: application.isAuthenticated,
83
+ mode: 'every',
84
+ roleMode: 'some',
85
+ requireLoginByDefault: true,
86
+ noAccessRedirect: '/403',
87
+ });
88
+ return { router: targetRouter, runtime, navigationVersion };
89
+ }
90
+
91
+ const { router, runtime, navigationVersion } = createAdminRouter();
92
+ export { router };
93
+ export function createAdminSidebarMenus(): SidebarMenuItem[] {
94
+ void navigationVersion.value;
95
+ // ready 使非响应式菜单运行时在退出和初始化完成后重新计算。
96
+ return application.ready.value ? runtime.sidebarMenus : [];
97
+ }
98
+ export function createAdminTabs(): LumalLayoutTabItem[] {
99
+ return runtime.routes
100
+ .filter(
101
+ (route) =>
102
+ route.meta.affixTab === true &&
103
+ permissionStore.hasPermission(route.meta.permissions, 'every') &&
104
+ permissionStore.hasRole(route.meta.roles, 'some'),
105
+ )
106
+ .map((route) => ({ path: route.path, title: route.meta.title, closable: false }));
107
+ }
@@ -0,0 +1,12 @@
1
+ import type { AppModule } from '../module';
2
+ import { assembleModules } from '../module';
3
+
4
+ // 只加载元数据,页面仍以动态 import 分包;业务子模块无需修改中心注册文件。
5
+ const modules = import.meta.glob<AppModule>(['../../modules/*/module.ts', '../../modules/business/*/module.ts'], {
6
+ eager: true,
7
+ });
8
+ const assembled = assembleModules(Object.values(modules));
9
+ export const localMenus = assembled.menus;
10
+ export const viewComponents = { ...assembled.views, 'app/iframe': () => import('../views/SafeIframeView.vue') };
11
+ export const permissionTree = assembled.permissions;
12
+ export const permissionCodes = assembled.permissionCodes;
@@ -0,0 +1,2 @@
1
+ // 单一权限 Store,由实际会话响应填充,不能持久化演示权限。
2
+ export { permissionStore } from './session';
@@ -0,0 +1,54 @@
1
+ import type { AuthSessionData } from '@lumal/core/auth';
2
+ import type { LumalMenuRecord } from '@lumal/core/router';
3
+
4
+ export interface LoginInput {
5
+ username: string;
6
+ password: string;
7
+ }
8
+ export interface CurrentUser {
9
+ id: string;
10
+ name: string;
11
+ username: string;
12
+ }
13
+ export interface AccessSnapshot {
14
+ permissions: string[];
15
+ roles: string[];
16
+ }
17
+ export type DataRow = Record<string, unknown>;
18
+ export interface ListInput {
19
+ page: number;
20
+ pageSize: number;
21
+ query: DataRow;
22
+ }
23
+ export interface PageResult<T = DataRow> {
24
+ items: T[];
25
+ total: number;
26
+ }
27
+ export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
28
+ export interface BusinessRequest {
29
+ body?: object;
30
+ query?: Record<string, string | number | boolean | undefined>;
31
+ }
32
+ export interface BusinessTransport {
33
+ request: <T>(method: HttpMethod, path: string, input?: BusinessRequest) => Promise<T>;
34
+ }
35
+
36
+ // 应用协议由 Local/HTTP 共同实现,后端包装和字段转换不进入 Core。
37
+ export interface ApplicationService extends BusinessTransport {
38
+ login: (input: LoginInput, signal: AbortSignal) => Promise<AuthSessionData>;
39
+ getUser: (signal: AbortSignal) => Promise<CurrentUser>;
40
+ getAccess: (signal: AbortSignal) => Promise<AccessSnapshot>;
41
+ getMenus: (signal: AbortSignal) => Promise<LumalMenuRecord[]>;
42
+ logout: (accessToken: string) => Promise<void>;
43
+ clearCache: () => void;
44
+ list: (resource: string, input: ListInput) => Promise<PageResult>;
45
+ save: (resource: string, model: DataRow, id?: string) => Promise<unknown>;
46
+ remove: (resource: string, id: string) => Promise<void>;
47
+ }
48
+
49
+ export interface ServiceContext {
50
+ getSession: () => AuthSessionData | null;
51
+ getSignal: () => AbortSignal;
52
+ setSession: (value: AuthSessionData) => void;
53
+ expire: () => Promise<void>;
54
+ }