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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (194) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +39 -10
  3. package/dist/cli.d.ts +9 -3
  4. package/dist/cli.d.ts.map +1 -1
  5. package/dist/cli.js +236 -19
  6. package/dist/cli.js.map +1 -1
  7. package/dist/create.d.ts +10 -1
  8. package/dist/create.d.ts.map +1 -1
  9. package/dist/create.js +18 -5
  10. package/dist/create.js.map +1 -1
  11. package/dist/index.d.ts +2 -1
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +1 -0
  14. package/dist/index.js.map +1 -1
  15. package/dist/presets.d.ts +29 -3
  16. package/dist/presets.d.ts.map +1 -1
  17. package/dist/presets.js +38 -10
  18. package/dist/presets.js.map +1 -1
  19. package/dist/project-cli.d.ts +3 -0
  20. package/dist/project-cli.d.ts.map +1 -0
  21. package/dist/project-cli.js +13 -0
  22. package/dist/project-cli.js.map +1 -0
  23. package/dist/projects.d.ts +21 -0
  24. package/dist/projects.d.ts.map +1 -0
  25. package/dist/projects.js +124 -0
  26. package/dist/projects.js.map +1 -0
  27. package/dist/templates.d.ts +2 -0
  28. package/dist/templates.d.ts.map +1 -1
  29. package/dist/templates.js +42 -13
  30. package/dist/templates.js.map +1 -1
  31. package/dist/types.d.ts +39 -0
  32. package/dist/types.d.ts.map +1 -1
  33. package/package.json +11 -3
  34. package/templates/base/API-CONTRACT.md +67 -20
  35. package/templates/base/README.md +20 -3
  36. package/templates/base/src/App.vue +71 -79
  37. package/templates/base/src/app/components/AppHeaderActions.vue +46 -0
  38. package/templates/base/src/{components/app → app/components}/AppSettingsDrawer.vue +15 -15
  39. package/templates/base/src/app/menu-policy.ts +163 -0
  40. package/templates/base/src/app/module.ts +46 -0
  41. package/templates/base/src/app/router/index.ts +107 -0
  42. package/templates/base/src/app/router/views.ts +12 -0
  43. package/templates/base/src/{services → app/services}/access.ts +1 -1
  44. package/templates/base/src/app/services/contracts.ts +54 -0
  45. package/templates/base/src/app/services/local-contracts.ts +18 -0
  46. package/templates/base/src/{services → app/services}/preferences.ts +8 -8
  47. package/templates/base/src/app/services/session-runtime.ts +186 -0
  48. package/templates/base/src/{services → app/services}/session.ts +4 -4
  49. package/templates/base/src/app/stored-menus.ts +72 -0
  50. package/templates/base/src/app/views/SafeIframeView.vue +34 -0
  51. package/templates/base/src/{views → app/views}/auth/LoginView.vue +29 -34
  52. package/templates/base/src/app/views/error/ForbiddenView.vue +20 -0
  53. package/templates/base/src/{views → app/views}/error/NotFoundView.vue +5 -7
  54. package/templates/base/src/main.ts +21 -22
  55. package/templates/base/src/modules/business/README.md +33 -0
  56. package/templates/base/src/{views → modules}/dashboard/DashboardView.vue +3 -3
  57. package/templates/base/src/modules/dashboard/module.ts +14 -0
  58. package/templates/base/src/shared/api.ts +53 -0
  59. package/templates/base/src/shared/components/BaseEntityDialog.vue +39 -0
  60. package/templates/base/src/shared/composables/use-async-data.ts +35 -0
  61. package/templates/base/src/shared/composables/use-entity-editor.ts +77 -0
  62. package/templates/base/src/shared/status.ts +4 -0
  63. package/templates/base/src/shared/tree.ts +41 -0
  64. package/templates/base/src/styles.scss +1 -6
  65. package/templates/base/tests/menu-policy.test.ts.template +89 -0
  66. package/templates/base/tests/session.test.ts.template +38 -27
  67. package/templates/base/vite.config.ts +4 -4
  68. package/templates/blank/README.md +6 -0
  69. package/templates/blank/src/App.vue +11 -0
  70. package/templates/common/AGENTS.md +13 -0
  71. package/templates/common/_editorconfig +15 -0
  72. package/templates/common/_gitattributes +6 -0
  73. package/templates/{base → common}/_gitignore +1 -0
  74. package/templates/common/_prettierignore +6 -0
  75. package/templates/common/docs/DEVELOPMENT.md +26 -0
  76. package/templates/common/eslint.config.mjs +60 -0
  77. package/templates/common/package.json +39 -0
  78. package/templates/common/prettier.config.mjs +8 -0
  79. package/templates/{base → common}/tsconfig.json +2 -5
  80. package/templates/common/vitest.config.ts +3 -0
  81. package/templates/features/examples/src/modules/examples/CrudExampleView.vue +51 -0
  82. package/templates/features/examples/src/{views → modules}/examples/FormExampleView.vue +15 -8
  83. package/templates/features/examples/src/modules/examples/module.ts +27 -0
  84. package/templates/features/system/src/modules/system/config/ConfigView.vue +104 -0
  85. package/templates/features/system/src/modules/system/config/api.ts +38 -0
  86. package/templates/features/system/src/modules/system/config/routes.ts +21 -0
  87. package/templates/features/system/src/modules/system/dictionary/DictionaryItemView.vue +141 -0
  88. package/templates/features/system/src/modules/system/dictionary/DictionaryTypeView.vue +107 -0
  89. package/templates/features/system/src/modules/system/dictionary/api.ts +61 -0
  90. package/templates/features/system/src/modules/system/dictionary/cache.ts +9 -0
  91. package/templates/features/system/src/modules/system/dictionary/routes.ts +32 -0
  92. package/templates/features/system/src/modules/system/dictionary/types.ts +22 -0
  93. package/templates/features/system/src/modules/system/local.ts +348 -0
  94. package/templates/features/system/src/modules/system/menu/MenuView.vue +133 -0
  95. package/templates/features/system/src/modules/system/menu/api.ts +73 -0
  96. package/templates/features/system/src/modules/system/menu/components/MenuEditorDialog.vue +106 -0
  97. package/templates/features/system/src/modules/system/menu/routes.ts +23 -0
  98. package/templates/features/system/src/modules/system/menu/types.ts +18 -0
  99. package/templates/features/system/src/modules/system/module.ts +22 -0
  100. package/templates/features/system/src/modules/system/organization/OrganizationView.vue +134 -0
  101. package/templates/features/system/src/modules/system/organization/api.ts +38 -0
  102. package/templates/features/system/src/modules/system/organization/routes.ts +23 -0
  103. package/templates/features/system/src/modules/system/organization/types.ts +17 -0
  104. package/templates/features/system/src/modules/system/role/RoleView.vue +90 -0
  105. package/templates/features/system/src/modules/system/role/api.ts +40 -0
  106. package/templates/features/system/src/modules/system/role/components/RoleAuthorizationDialog.vue +110 -0
  107. package/templates/features/system/src/modules/system/role/permission-tree.ts +31 -0
  108. package/templates/features/system/src/modules/system/role/routes.ts +24 -0
  109. package/templates/features/system/src/modules/system/role/types.ts +12 -0
  110. package/templates/features/system/src/modules/system/user/UserView.vue +297 -0
  111. package/templates/features/system/src/modules/system/user/api.ts +45 -0
  112. package/templates/features/system/src/modules/system/user/components/UserOrganizationFilter.vue +61 -0
  113. package/templates/features/system/src/modules/system/user/components/UserRoleDialog.vue +93 -0
  114. package/templates/features/system/src/modules/system/user/routes.ts +27 -0
  115. package/templates/features/system/src/modules/system/user/types.ts +28 -0
  116. package/templates/features/system/tests/system.test.ts.template +266 -0
  117. package/templates/modes/http/src/app/services/data-source.ts +124 -0
  118. package/templates/modes/http/src/app/services/http-adapter.ts +128 -0
  119. package/templates/modes/http/tests/http.test.ts.template +186 -133
  120. package/templates/modes/local/src/app/services/data-source.ts +113 -0
  121. package/templates/modes/local/src/app/services/local-database.ts +67 -0
  122. package/templates/modes/local/tests/local.test.ts.template +117 -77
  123. package/templates/presets/standard/src/modules/dashboard/DashboardView.vue +52 -0
  124. package/templates/presets/standard/src/modules/dashboard/components/DashboardSessionStatus.vue +41 -0
  125. package/templates/presets/standard/src/modules/dashboard/components/DashboardShortcuts.vue +51 -0
  126. package/templates/presets/standard/src/modules/profile/ProfileView.vue +109 -0
  127. package/templates/presets/standard/src/modules/profile/api.ts +49 -0
  128. package/templates/presets/standard/src/modules/profile/components/ProfilePasswordForm.vue +68 -0
  129. package/templates/presets/standard/src/modules/profile/local.ts +40 -0
  130. package/templates/presets/standard/src/modules/profile/module.ts +11 -0
  131. package/templates/presets/standard/tests/profile.test.ts.template +72 -0
  132. package/templates/screen/README.md +6 -0
  133. package/templates/screen/src/App.vue +7 -0
  134. package/templates/screen/src/screen/ScreenEntry.vue +18 -0
  135. package/templates/screen/src/screen/ScreenView.vue +48 -0
  136. package/templates/screen/src/screen/components/ScreenMetrics.vue +40 -0
  137. package/templates/screen-auth/src/screen/ScreenEntry.vue +52 -0
  138. package/templates/screen-auth/src/screen/components/ScreenLoginForm.vue +44 -0
  139. package/templates/screen-auth/src/services/session.ts +61 -0
  140. package/templates/screen-auth/tests/session.test.ts.template +54 -0
  141. package/templates/screen-auth-http/src/services/auth-adapter.ts +14 -0
  142. package/templates/screen-auth-http/tests/auth-adapter.test.ts.template +17 -0
  143. package/templates/screen-auth-local-demo/src/services/auth-adapter.ts +15 -0
  144. package/templates/screen-auth-local-demo/tests/auth-adapter.test.ts.template +18 -0
  145. package/templates/screen-data/src/screen/ScreenView.vue +69 -0
  146. package/templates/screen-data/src/services/metrics-types.ts +4 -0
  147. package/templates/screen-data-http/src/services/metrics.ts +20 -0
  148. package/templates/screen-data-http/src/services/request.ts +3 -0
  149. package/templates/screen-data-http/tests/metrics.test.ts.template +21 -0
  150. package/templates/screen-data-local/src/services/metrics.ts +11 -0
  151. package/templates/screen-data-local/tests/metrics.test.ts.template +9 -0
  152. package/templates/screen-http/API-CONTRACT.md +20 -0
  153. package/templates/screen-http/src/services/http.ts +17 -0
  154. package/templates/screen-request-auth/src/services/request.ts +15 -0
  155. package/templates/screen-router/src/App.vue +7 -0
  156. package/templates/screen-router/src/main.ts +13 -0
  157. package/templates/screen-router/src/router.ts +7 -0
  158. package/templates/screen-router/tests/app.test.ts.template +20 -0
  159. package/templates/screen-viewport/src/screen/ScreenViewport.vue +30 -0
  160. package/templates/screen-visualization-charts/src/screen/components/ScreenVisualization.vue +22 -0
  161. package/templates/screen-visualization-cockpit/src/screen/components/ScreenVisualization.vue +33 -0
  162. package/templates/screen-visualization-cockpit/src/screen/components/ScreenWidget.vue +10 -0
  163. package/templates/screen-visualization-datav/src/screen/components/ScreenVisualization.vue +11 -0
  164. package/templates/vben-compatible/MIGRATION.md +2 -0
  165. package/templates/vben-compatible/src/{views/migration → modules/vben-migration}/VbenFormView.vue +2 -1
  166. package/templates/vben-compatible/src/{views/migration → modules/vben-migration}/VbenUsersView.vue +3 -2
  167. package/templates/vben-compatible/src/modules/vben-migration/components/VbenFormExample.vue +64 -0
  168. package/templates/vben-compatible/src/{components/migration → modules/vben-migration/components}/VbenUsers.vue +73 -64
  169. package/templates/vben-compatible/src/modules/vben-migration/local.ts +68 -0
  170. package/templates/vben-compatible/src/modules/vben-migration/module.ts +42 -0
  171. package/templates/vue/index.html +12 -0
  172. package/templates/vue/src/main.ts +5 -0
  173. package/templates/vue/src/styles.css +19 -0
  174. package/templates/vue/tests/app.test.ts.template +10 -0
  175. package/templates/vue/vite.config.ts +9 -0
  176. package/templates/vue/vitest.config.ts +4 -0
  177. package/templates/base/eslint.config.mjs +0 -8
  178. package/templates/base/src/components/app/AppHeaderActions.vue +0 -34
  179. package/templates/base/src/router/index.ts +0 -86
  180. package/templates/base/src/router/views.ts +0 -16
  181. package/templates/base/src/services/contracts.ts +0 -29
  182. package/templates/base/src/services/session-runtime.ts +0 -137
  183. package/templates/base/src/views/error/ForbiddenView.vue +0 -25
  184. package/templates/base/vitest.config.ts +0 -3
  185. package/templates/features/examples/src/modules/examples.ts +0 -10
  186. package/templates/features/examples/src/views/examples/CrudExampleView.vue +0 -30
  187. package/templates/features/system/src/business/system/entities.ts +0 -93
  188. package/templates/features/system/src/modules/system.ts +0 -28
  189. package/templates/features/system/src/views/system/SystemEntityView.vue +0 -85
  190. package/templates/modes/http/src/services/data-source.ts +0 -120
  191. package/templates/modes/http/src/services/http-adapter.ts +0 -124
  192. package/templates/modes/local/src/services/data-source.ts +0 -176
  193. package/templates/vben-compatible/src/components/migration/VbenFormExample.vue +0 -34
  194. package/templates/vben-compatible/src/modules/vben.ts +0 -17
@@ -1,48 +1,47 @@
1
1
  <script setup lang="ts">
2
- import type { FormInstance } from 'element-plus'
3
- import { ElButton, ElForm, ElFormItem, ElInput } from 'element-plus'
4
- import { reactive, shallowRef, useTemplateRef } from 'vue'
5
- import { useRoute, useRouter } from 'vue-router'
6
- import { routeError } from '../../router'
7
- import { demoAccount } from '../../services/data-source'
8
- import { application, login } from '../../services/session'
2
+ import type { FormInstance } from 'element-plus';
3
+ import { ElButton, ElForm, ElFormItem, ElInput } from 'element-plus';
4
+ import { reactive, shallowRef, useTemplateRef } from 'vue';
5
+ import { useRoute, useRouter } from 'vue-router';
6
+ import { isInternalLocation } from '../../menu-policy';
7
+ import { routeError } from '../../router';
8
+ import { demoAccount } from '../../services/data-source';
9
+ import { application, login } from '../../services/session';
9
10
 
10
11
  /***********************登录状态*********************/
11
- const route = useRoute()
12
- const router = useRouter()
13
- const formRef = useTemplateRef<FormInstance>('formRef')
14
- const loading = shallowRef(false)
15
- const errorMessage = shallowRef('')
16
- const form = reactive({ username: demoAccount?.username ?? '', password: demoAccount?.password ?? '' })
12
+ const route = useRoute();
13
+ const router = useRouter();
14
+ const formRef = useTemplateRef<FormInstance>('formRef');
15
+ const loading = shallowRef(false);
16
+ const errorMessage = shallowRef('');
17
+ const form = reactive({ username: demoAccount?.username ?? '', password: demoAccount?.password ?? '' });
17
18
 
18
19
  /***********************登录提交*********************/
19
20
  function resolveRedirect(): string {
20
- const redirect = Array.isArray(route.query.redirect) ? route.query.redirect[0] : route.query.redirect
21
- return redirect && redirect.startsWith('/') && !redirect.startsWith('//') && !redirect.startsWith('/login') ? redirect : '/dashboard'
21
+ const redirect = Array.isArray(route.query.redirect) ? route.query.redirect[0] : route.query.redirect;
22
+ return redirect && isInternalLocation(redirect) && !redirect.startsWith('/login') ? redirect : '/dashboard';
22
23
  }
23
24
 
24
25
  async function handleSubmit(): Promise<void> {
25
26
  if (loading.value) {
26
- return
27
+ return;
27
28
  }
28
29
 
29
- loading.value = true
30
- errorMessage.value = ''
30
+ loading.value = true;
31
+ errorMessage.value = '';
31
32
 
32
33
  try {
33
- const valid = await formRef.value?.validate()
34
+ const valid = await formRef.value?.validate();
34
35
  if (valid === false) {
35
- return
36
+ return;
36
37
  }
37
38
 
38
- await login(form)
39
- await router.replace(resolveRedirect())
40
- }
41
- catch (error) {
42
- errorMessage.value = error instanceof Error ? error.message : '登录失败'
43
- }
44
- finally {
45
- loading.value = false
39
+ await login(form);
40
+ await router.replace(resolveRedirect());
41
+ } catch (error) {
42
+ errorMessage.value = error instanceof Error ? error.message : '登录失败';
43
+ } finally {
44
+ loading.value = false;
46
45
  }
47
46
  }
48
47
  </script>
@@ -50,9 +49,7 @@ async function handleSubmit(): Promise<void> {
50
49
  <template>
51
50
  <main class="lumal-admin-login">
52
51
  <section class="lumal-admin-login__panel">
53
- <div class="lumal-admin-login__brand" aria-hidden="true">
54
- L
55
- </div>
52
+ <div class="lumal-admin-login__brand" aria-hidden="true">L</div>
56
53
  <h1>Lumal Admin</h1>
57
54
  <p>轻量后台基座</p>
58
55
 
@@ -66,9 +63,7 @@ async function handleSubmit(): Promise<void> {
66
63
  <p v-if="errorMessage" class="lumal-admin-login__error" role="alert">
67
64
  {{ errorMessage }}
68
65
  </p>
69
- <ElButton native-type="submit" type="primary" :loading="loading">
70
- 登录
71
- </ElButton>
66
+ <ElButton native-type="submit" type="primary" :loading="loading"> 登录 </ElButton>
72
67
  </ElForm>
73
68
 
74
69
  <p v-if="routeError || application.error.value" role="alert">
@@ -0,0 +1,20 @@
1
+ <script setup lang="ts">
2
+ import { LumalPage } from '@lumal/core/components';
3
+ import { ElButton } from 'element-plus';
4
+ import { useRouter } from 'vue-router';
5
+
6
+ /***********************路由操作*********************/
7
+ const router = useRouter();
8
+
9
+ function handleBackDashboardClick(): void {
10
+ void router.push('/dashboard');
11
+ }
12
+ </script>
13
+
14
+ <template>
15
+ <main class="lumal-admin-page">
16
+ <LumalPage title="无权限" description="当前账号没有访问该页面所需的权限。">
17
+ <ElButton type="primary" @click="handleBackDashboardClick"> 返回工作台 </ElButton>
18
+ </LumalPage>
19
+ </main>
20
+ </template>
@@ -1,18 +1,16 @@
1
1
  <script setup lang="ts">
2
- import { LumalPage } from '@lumal/core/components'
3
- import { ElButton } from 'element-plus'
4
- import { useRouter } from 'vue-router'
2
+ import { LumalPage } from '@lumal/core/components';
3
+ import { ElButton } from 'element-plus';
4
+ import { useRouter } from 'vue-router';
5
5
 
6
6
  /***********************异常页导航*********************/
7
- const router = useRouter()
7
+ const router = useRouter();
8
8
  </script>
9
9
 
10
10
  <template>
11
11
  <main class="lumal-admin-page">
12
12
  <LumalPage title="页面不存在" description="请检查地址,或返回工作台继续操作。">
13
- <ElButton type="primary" @click="router.push('/dashboard')">
14
- 返回工作台
15
- </ElButton>
13
+ <ElButton type="primary" @click="router.push('/dashboard')"> 返回工作台 </ElButton>
16
14
  </LumalPage>
17
15
  </main>
18
16
  </template>
@@ -1,16 +1,15 @@
1
- import type { IconDefinition } from '@lumal/icons'
2
- import { registerAuthorityDirectives } from '@lumal/core/directives'
3
- import { createLumalAdmin } from '@lumal/core/minimal'
4
- import App from './App.vue'
5
- import { router } from './router'
6
- import { permissionStore } from './services/access'
7
- import '@lumal/core/theme-chalk/index.scss'
8
- import '@lumal/core/style.css'
9
- import '@lumal/icons-vue/style.css'
10
- import 'element-plus/theme-chalk/el-alert.css'
11
- import 'element-plus/theme-chalk/el-date-picker.css'
12
- import 'element-plus/theme-chalk/dark/css-vars.css'
13
- import './styles.scss'
1
+ import type { IconDefinition } from '@lumal/icons';
2
+ import { registerAuthorityDirectives } from '@lumal/core/directives';
3
+ import { createLumalAdmin } from '@lumal/core/minimal';
4
+ import App from './App.vue';
5
+ import { router } from './app/router';
6
+ import { permissionStore } from './app/services/access';
7
+ import '@lumal/core/theme-chalk/index.scss';
8
+ import '@lumal/core/style.css';
9
+ import '@lumal/icons-vue/style.css';
10
+ import 'element-plus/theme-chalk/el-alert.css';
11
+ import 'element-plus/theme-chalk/dark/css-vars.css';
12
+ import './styles.scss';
14
13
 
15
14
  /***********************本地图标定义*********************/
16
15
  const localIcons: IconDefinition[] = [
@@ -18,9 +17,10 @@ const localIcons: IconDefinition[] = [
18
17
  key: 'app:dashboard',
19
18
  label: '控制台',
20
19
  source: 'local-svg',
21
- svgText: '<svg viewBox="0 0 24 24"><path fill="currentColor" d="M3 13h8V3H3v10Zm0 8h8v-6H3v6Zm10 0h8V11h-8v10Zm0-18v6h8V3h-8Z"/></svg>',
20
+ svgText:
21
+ '<svg viewBox="0 0 24 24"><path fill="currentColor" d="M3 13h8V3H3v10Zm0 8h8v-6H3v6Zm10 0h8V11h-8v10Zm0-18v6h8V3h-8Z"/></svg>',
22
22
  },
23
- ]
23
+ ];
24
24
 
25
25
  /***********************应用启动*********************/
26
26
  const framework = createLumalAdmin({
@@ -30,12 +30,11 @@ const framework = createLumalAdmin({
30
30
  icons: {
31
31
  localSvg: localIcons,
32
32
  },
33
- })
33
+ });
34
34
 
35
- registerAuthorityDirectives(framework.app, permissionStore)
35
+ registerAuthorityDirectives(framework.app, permissionStore);
36
36
  void framework.mount('#app').catch((error: unknown) => {
37
- console.error('应用启动失败', error)
38
- const element = document.getElementById('app')
39
- if (element)
40
- element.textContent = '应用启动失败,请检查配置与控制台。'
41
- })
37
+ console.error('应用启动失败', error);
38
+ const element = document.getElementById('app');
39
+ if (element) element.textContent = '应用启动失败,请检查配置与控制台。';
40
+ });
@@ -0,0 +1,33 @@
1
+ # 业务模块
2
+
3
+ 在此目录新增 `orders/module.ts`,导出 `menus`、`views` 和可选 `permissions`。
4
+ 应用仅自动加载模块元数据,页面使用 `() => import('./OrderView.vue')`。
5
+ 业务类型与 API 放在同一模块内,不要向系统管理或 Core 添加业务依赖。
6
+
7
+ ```ts
8
+ import type { AppModule } from '../../../app/module';
9
+
10
+ export const menus: AppModule['menus'] = [
11
+ {
12
+ path: '/orders',
13
+ name: 'Orders',
14
+ component: 'business/orders',
15
+ meta: { title: '订单', authority: ['orders:list'] },
16
+ },
17
+ ];
18
+ export const views: AppModule['views'] = {
19
+ 'business/orders': () => import('./OrderView.vue'),
20
+ };
21
+ export const permissions: AppModule['permissions'] = [
22
+ { id: 'orders:list', label: '订单列表', permission: 'orders:list' },
23
+ ];
24
+ ```
25
+
26
+ 后端 `/menus` 必须返回此组件白名单键;HTTP 不会把本地路由悄悄融合到远端菜单中。
27
+ 本地开发的权限来自显式注册表,生产权限始终由后端检查。
28
+
29
+ 业务 API、输入/响应类型与页面放在 `orders/` 内,通过 `BusinessTransport` 发送请求。
30
+ Local 模式可在同目录提供 `local.ts`,导出满足 `LocalModule` 的 `createLocalHandler`:
31
+ `handles(path)` 仅匹配本模块端点,`request(method, path, input)` 实现开发行为。
32
+ 处理器通过上下文的 `db/user/assertPermission` 读写演示数据、确认身份和检查权限。
33
+ 路由元数据和 Local 处理器均自动发现,无需改写中心注册文件;HTTP 仍需真实后端实现同一端点。
@@ -1,9 +1,9 @@
1
1
  <script setup lang="ts">
2
- import { ElDescriptions, ElDescriptionsItem } from 'element-plus'
3
- import { currentUser, permissionStore } from '../../services/session'
2
+ import { ElDescriptions, ElDescriptionsItem } from 'element-plus';
3
+ import { currentUser, permissionStore } from '../../app/services/session';
4
4
 
5
5
  /***********************会话摘要*********************/
6
- const startedAt = new Date().toLocaleDateString()
6
+ const startedAt = new Date().toLocaleDateString();
7
7
  </script>
8
8
 
9
9
  <template>
@@ -0,0 +1,14 @@
1
+ import type { AppModule } from '../../app/module';
2
+
3
+ export const menus: AppModule['menus'] = [
4
+ {
5
+ path: '/dashboard',
6
+ name: 'Dashboard',
7
+ component: 'dashboard/index',
8
+ meta: { title: '工作台', icon: 'app:dashboard', affixTab: true, keepAlive: true, order: 0 },
9
+ },
10
+ ];
11
+ export const views: AppModule['views'] = {
12
+ 'dashboard/index': () => import('./DashboardView.vue'),
13
+ 'app/iframe': () => import('../../app/views/SafeIframeView.vue'),
14
+ };
@@ -0,0 +1,53 @@
1
+ import type { SchemaFormModel } from '@lumal/core/components';
2
+ import { object, stringArray } from '../app/menu-policy';
3
+
4
+ export type Status = 'enabled' | 'disabled';
5
+ export interface Option {
6
+ label: string;
7
+ value: string;
8
+ disabled?: boolean;
9
+ children?: Option[];
10
+ }
11
+ export interface Query {
12
+ keyword?: string;
13
+ status?: Status | '';
14
+ }
15
+ export interface PageInput<Q = Query> {
16
+ page: number;
17
+ pageSize: number;
18
+ query: Q;
19
+ }
20
+ export { object, stringArray };
21
+
22
+ export function text(value: unknown, label: string, required = false): string {
23
+ if (value == null && !required) return '';
24
+ if (typeof value !== 'string' || (required && !value.trim()))
25
+ throw new Error(`${label}必须是${required ? '非空' : ''}字符串`);
26
+ return value.trim();
27
+ }
28
+ export function status(value: unknown): Status {
29
+ if (value !== 'enabled' && value !== 'disabled') throw new Error('状态仅支持 enabled / disabled');
30
+ return value;
31
+ }
32
+ export function integer(value: unknown): number {
33
+ if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) throw new Error('排序必须是非负整数');
34
+ return value;
35
+ }
36
+ export function rows<T>(value: unknown, parse: (item: unknown) => T): T[] {
37
+ if (!Array.isArray(value)) throw new Error('列表响应必须是数组');
38
+ return value.map(parse);
39
+ }
40
+ export function options(value: unknown): Option[] {
41
+ return rows(value, (item) => {
42
+ const raw = object(item);
43
+ return {
44
+ label: text(raw.label, '选项名称', true),
45
+ value: text(raw.value, '选项值', true),
46
+ disabled: raw.disabled === true,
47
+ children: raw.children === undefined ? undefined : options(raw.children),
48
+ };
49
+ });
50
+ }
51
+ export function formStatus(model: SchemaFormModel): Status {
52
+ return status(model.status ?? 'enabled');
53
+ }
@@ -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
+ }
@@ -1,12 +1,7 @@
1
1
  :root {
2
2
  color: var(--el-text-color-primary);
3
3
  background: var(--el-bg-color-page);
4
- font-family:
5
- Inter,
6
- "Segoe UI",
7
- "Microsoft YaHei",
8
- Arial,
9
- sans-serif;
4
+ font-family: Inter, 'Segoe UI', 'Microsoft YaHei', Arial, sans-serif;
10
5
  }
11
6
 
12
7
  body {
@@ -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
+ });