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,37 +1,84 @@
1
1
  # 应用 API 契约
2
2
 
3
- Local 与 HTTP 实现 `src/services/contracts.ts` 的同一服务接口。Core 不包含业务协议。
4
-
5
- | 方法 | 路径 | 标准 JSON 响应 |
6
- | --- | --- | --- |
7
- | POST | `/auth/login`,body `{ username, password }` | `{ accessToken, refreshToken?, expiresAt? }` |
8
- | POST | `/auth/refresh`,body `{ refreshToken }` | 同上 |
9
- | GET | `/session/me` | `{ id, username, name }`,均为字符串 |
10
- | GET | `/session/access` | `{ roles: string[], permissions: string[] }` |
11
- | GET | `/menus` | `LumalMenuRecord[]` |
12
- | POST | `/auth/logout` | 204 或 JSON |
13
- | GET | `/system/:resource?page=1&pageSize=10&keyword=` | `{ items: object[], total: number }` |
14
- | POST/PUT/DELETE | `/system/:resource[/:id]` | JSON 或 204 |
15
-
16
- 菜单组件使用 `src/router/views.ts` 的本地白名单,禁止任意远程 import。标准模块资源为
17
- `users`、`roles`、`menus`、`organizations`、`dictionaries/types`、`dictionaries/items`、`config`。
18
- 标准 CRUD 接口按记录管理;配置记录使用 `id/key/value/description`。
19
- 后端仍需检查每项权限,不能依赖前端隐藏按钮。
3
+ 基础契约在 `src/app/services/contracts.ts`,业务入参和解析在各模块 `api.ts/types.ts`。
4
+ Local 与 HTTP 使用同一组页面及端点;Core 不包含业务协议。
5
+ 系统接口仅在选择 system 时调用,个人中心仅 standard / vben-compatible 启用。
6
+
7
+ | 方法 | 路径 | 标准 JSON 响应 |
8
+ | --------------- | ---------------------------------------------------------------------- | -------------------------------------------------------------------- |
9
+ | POST | `/auth/login`,body `{ username, password }` | `{ accessToken, refreshToken?, expiresAt? }` |
10
+ | POST | `/auth/refresh`,body `{ refreshToken }` | 同上 |
11
+ | GET | `/session/me` | `{ id, username, name }`,均为字符串 |
12
+ | GET | `/session/access` | `{ roles: string[], permissions: string[] }` |
13
+ | GET | `/menus` | `LumalMenuRecord[]` |
14
+ | POST | `/auth/logout` | 204 或 JSON |
15
+ | GET | `/system/users`、`/system/roles` | `{ items: User[] \| Role[], total }` |
16
+ | POST/PUT/DELETE | `/system/users[/:id]`、`/system/roles[/:id]` | 对应模块输入 / 记录;删除 204 或 JSON |
17
+ | PATCH | `/system/users/:id/status` | `{ status }` / `User` |
18
+ | PUT | `/system/users/:id/roles` | `{ roles: string[] }` / `User` |
19
+ | PATCH | `/system/users/batch/status` | `{ ids, status }` / `{ items, updated }` |
20
+ | PUT | `/system/users/batch/roles` | `{ ids, roles, mode: 'append' \| 'replace' }` / `{ items, updated }` |
21
+ | POST | `/system/users/:id/reset-password` | `{ temporaryPassword }` |
22
+ | GET | `/system/roles/options`、`/system/organizations/options` | `{ label, value, disabled?, children? }[]` |
23
+ | GET | `/system/permissions/tree` | `{ id, label, permission?, children? }[]` |
24
+ | GET/PUT | `/system/roles/:code/permissions` | PUT `{ permissions: string[] }`;响应 `string[]` |
25
+ | GET | `/system/menus`、`/system/organizations` | 对应树形记录数组,`children`;机构可带 `userCount` |
26
+ | POST/PUT/DELETE | `/system/menus[/:id]`、`/system/organizations[/:id]` | 对应模块输入 / 记录 |
27
+ | GET | `/system/dictionaries/types`、`/system/dictionaries/items?typeCode=` | 对应记录数组 |
28
+ | POST/PUT/DELETE | `/system/dictionaries/types[/:id]`、`/system/dictionaries/items[/:id]` | 对应模块输入 / 记录 |
29
+ | GET | `/system/dictionaries/options/:code` | `{ label, value, color? }[]` |
30
+ | GET/PUT | `/system/config` | `{ appName, colorPrimary, layout, tabbarEnable, transitionEnable }` |
31
+ | POST | `/system/config/restore` | 恢复默认,响应同上 |
32
+ | GET/PUT | `/profile` | GET `User`;PUT `{ nickname, phone }` / `User` |
33
+ | PUT | `/profile/password` | `{ currentPassword, newPassword }` / 204 或 JSON |
34
+
35
+ 用户查询支持 `page/pageSize/keyword/organizationId/roles/status`,角色支持
36
+ `page/pageSize/keyword/status`。分页默认第一页,页号和每页条数必须为正整数。
37
+ `UserInput` 为 `username/nickname/organizationId/roles/phone/status`;响应另有 `id/createdAt`。
38
+ `RoleInput` 为 `code/name/description/status`;响应另有 `id/builtIn`,不接收 permissions。
39
+
40
+ ## 业务约束与权限
41
+
42
+ - 用户操作对应 `system:user:list/create/update/delete/status/assign-roles/reset-password`。
43
+ - 角色对应 `system:role:list/create/update/delete/authorize`;资料和授权分别保存,不宣称两个请求是事务。
44
+ - 授权树来自真实接口,非叶访问权限独立回显;授权已写入但会话重读失败时明确提示部分成功,只重试重读。
45
+ - 菜单、机构、字典的权限前缀分别为 `system:menu`、`system:organization`、`system:dict`,操作是 `list/create/update/delete`。
46
+ - 配置读取需 `system:config:view`,写入需 `system:config:update`;无写权限显示只读表单,不是 key/value 记录列表。
47
+ - 服务端须保护内置管理员和角色、角色引用、机构关联用户和父子关系;批量写入应先校验全部目标。
48
+ - 字典同分类值唯一,删除分类级联处理字典项;修改后清理共享 `dictionaryStore`,业务表单应使用同一 Store。
49
+ - 个人中心由后端确认当前身份;密码必须经后端验证并持久化。接口不存在时展示真实错误,不伪造成功。
50
+
51
+ 完整字段以各模块 types.ts 为准。没有指标接口时工作台不请求或编造业务指标。
20
52
 
21
53
  HTTP 模式必须配置 `VITE_API_BASE_URL`,未配置立即报错,绝不回退本地登录。
22
54
  默认直接消费标准 JSON。包装、字段及权限映射集中在应用 `http-adapter.ts`。
23
55
  需要 Cookie/CSRF、租户或 SSO 时在应用 transport 接入,不将密钥写到 `VITE_*`。
24
56
  默认凭据存于 localStorage,请按安全要求选择内存/HttpOnly Cookie 策略。
25
57
 
58
+ ## 菜单信任边界
59
+
60
+ 菜单组件使用 `src/app/router/views.ts` 汇集的本地白名单,键必须是自有函数加载器;
61
+ 拒绝 constructor、**proto**、prototype 和任意 import URL,递归子菜单同样校验。
62
+ HTTP 菜单替换本地菜单,不静默融合。子路由使用属于父路径的绝对地址;
63
+ 目录默认跳到当前身份可访问的首个子页,无子页时隐藏。路由守卫逐层检查父子权限,
64
+ 远程 ignoreAccess/requireLogin 不能关闭认证或权限。
65
+
66
+ 外链只接受无用户名密码的 HTTP(S) 绝对地址,禁止 javascript/data/vbscript、
67
+ 协议相对地址、反斜线和控制字符。iframe 只能使用 `app/iframe`,且完整来源必须
68
+ 注册在 `VITE_IFRAME_ORIGINS`(逗号分隔),使用独立 sandbox。
69
+
26
70
  ## 接入仓库 Lumal Mock API
27
71
 
28
72
  启动独立的 `lumal-mock-api` 后,设置 `VITE_API_BASE_URL=http://127.0.0.1:5320/api`
29
73
  和 `VITE_API_ADAPTER=lumal-mock`。这是显式选择的演示后端,不是生产默认值。
30
74
  该 adapter 映射 `statusCode/result/resultMsg`、snake_case Token、`records/totalNum`;
31
75
  只选择本预设存在的菜单,其余示例页面不生成。
32
- Mock 的系统配置通过显式 adapter 转换为记录型配置;更新时保留原值类型。
76
+ Mock 的配置字段与上表一致;历史写权限 `system:config:view` 显式映射到应用 update 权限。
77
+ 第三方后端应按真实协议调整 Adapter,不假定均兼容此演示协议。
33
78
 
34
79
  401 GET 自动单飞刷新并最多重放一次;写请求不自动重放,避免重复提交。
35
- 初始化等待用户、权限和菜单全部就绪后注册路由。退出、刷新失败和重新登录清理
36
- 权限、菜单、动态路由、请求缓存、标签和页面缓存;旧会话请求会被取消。
80
+ 初始化等待用户、权限和菜单全部就绪后注册路由。退出、Token 刷新失败和重新登录清理
81
+ 凭据、权限、菜单、动态路由、请求缓存和标签;旧会话请求会被取消。
82
+ 主动重读权限失败时清空旧授权及路由,保留凭据供显式重试。
37
83
  真实接口的 403、校验或网络错误保持可见,不转换成成功响应。
84
+ Local 存储可被浏览器用户修改,仅供开发,不能替代服务端鉴权。
@@ -23,7 +23,7 @@ pnpm preview
23
23
  HTTP 接入前配置 `.env.development`,完整接口见 [API 契约](./API-CONTRACT.md)。
24
24
  Local 模式是明确的开发演示,账号 `admin/guest`,密码 `lumal123`;
25
25
  生产默认禁用,不应将演示模式当作生产认证。
26
- 标准业务模块使用应用服务和原生 CRUD,初始数据为空,可自行新增。
26
+ 系统模块包含有明确关联的开发种子数据;示例数据与系统数据分离。
27
27
 
28
28
  ## 部署
29
29
 
@@ -38,5 +38,22 @@ localStorage Token 示例不是生产安全策略的唯一选择。
38
38
 
39
39
  minimal 没有管理页面与兼容层;standard 的 system/examples 是可选模板层。
40
40
  系统页面的 CRUD 和 Schema 不进入 Core 的业务实现。图表、驾驶舱、富文本、
41
- 国际化与 VXE 高级编辑均不默认安装。新增页面时在 `src/modules` 声明白名单懒加载器,
42
- 并让后端菜单使用相应 component key。服务端菜单改变后需重新加载会话。
41
+ 国际化与 VXE 高级编辑均不默认安装。
42
+
43
+ ```text
44
+ src/app/ 路由、认证、请求、布局接线与异常页
45
+ src/modules/dashboard/ 工作台
46
+ src/modules/profile/ 个人中心(标准预设)
47
+ src/modules/system/ 系统父模块(可选)
48
+ user/ role/ menu/ organization/ dictionary/ config/
49
+ src/modules/examples/ 开发示例(可选)
50
+ src/modules/vben-migration/ 迁移示例(仅兼容预设)
51
+ src/modules/business/ 自有业务模块
52
+ src/shared/ 共享组件、类型与组合式函数
53
+ ```
54
+
55
+ 每个模块通过 `module.ts` 导出 menus/views/permissions,注册表只加载小型元数据,
56
+ 页面使用动态 import。在 business 中新增一个子目录及 module.ts 即可注册,不需要修改中心文件;
57
+ 完整接入示例见该目录 README。业务类型和强类型 API 放在模块内,不写入全局实体配置。
58
+ 系统、开发示例和迁移示例各有真实父路由,权限变化后目录重定向重新计算。
59
+ HTTP 菜单使用已生成页面的 component key,并由后端提供完整菜单;不会静默混入本地菜单。
@@ -1,101 +1,99 @@
1
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 {
6
- findMenuItemByPath,
7
- LumalLayout,
8
- LumalRouterView,
9
- } from '@lumal/core/layout'
10
- import { ElAlert, ElConfigProvider, ElMessage } from 'element-plus'
11
- import zhCn from 'element-plus/es/locale/lang/zh-cn'
12
- import { computed, defineAsyncComponent, onErrorCaptured, reactive, shallowRef, watch } from 'vue'
13
- import { useRoute, useRouter } from 'vue-router'
14
- import { appConfig } from './app.config'
15
- import AppHeaderActions from './components/app/AppHeaderActions.vue'
16
- import { createAdminSidebarMenus, createAdminTabs } from './router'
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';
17
14
  import {
18
15
  adminPreferenceDefaults,
19
16
  adminPreferences,
20
17
  adminResolvedThemeMode,
21
18
  patchAdminPreferences,
22
- } from './services/preferences'
23
- import { adminTabSnapshotStorageKey, currentUser, logout } from './services/session'
19
+ } from './app/services/preferences';
20
+ import { adminTabSnapshotStorageKey, application, currentUser, logout } from './app/services/session';
24
21
 
25
22
  /***********************页面状态*********************/
26
- const AppSettingsDrawer = defineAsyncComponent(() => import('./components/app/AppSettingsDrawer.vue'))
27
- const title = appConfig.title
28
- const settingsVisible = shallowRef(false)
29
- const pageError = shallowRef('')
30
- const routeRefreshKeys = reactive<Record<string, number>>({})
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>>({});
31
28
  onErrorCaptured((error) => {
32
- pageError.value = error.message
33
- return false
34
- })
29
+ pageError.value = error.message;
30
+ return false;
31
+ });
35
32
 
36
33
  /***********************路由状态*********************/
37
- const route = useRoute()
38
- const router = useRouter()
39
- const isPublicLayout = computed(() => route.meta.layout === 'public')
40
- const allMenus = computed(() => createAdminSidebarMenus())
41
- const fixedTabs = computed<LumalLayoutTabItem[]>(() => createAdminTabs())
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());
42
39
  // 标签和视图使用相同的 path/version 身份,刷新只替换当前标签的缓存。
43
40
  function resolveRouteViewKey(path: string): string {
44
- return `${path}:${routeRefreshKeys[path] ?? 0}`
41
+ return `${path}:${routeRefreshKeys[path] ?? 0}`;
45
42
  }
46
- const routeViewKey = computed(() => resolveRouteViewKey(route.path))
47
- const routeViewCache = computed(() => adminPreferences.value.tabbar.enable && adminPreferences.value.tabbar.cache)
43
+ const routeViewKey = computed(() => resolveRouteViewKey(route.path));
44
+ const routeViewCache = computed(() => adminPreferences.value.tabbar.enable && adminPreferences.value.tabbar.cache);
48
45
  const activePath = computed({
49
46
  get: () => route.path,
50
47
  set: (path: string) => {
51
48
  if (path !== route.path) {
52
- void router.push(path)
49
+ void router.push(path);
53
50
  }
54
51
  },
55
- })
56
-
57
- watch([
58
- () => route.meta.title,
59
- () => adminPreferences.value.app.dynamicTitle,
60
- ], () => {
61
- const routeTitle = typeof route.meta.title === 'string' ? route.meta.title.trim() : ''
62
- document.title = adminPreferences.value.app.dynamicTitle && routeTitle
63
- ? `${routeTitle} - ${title}`
64
- : title
65
- }, { immediate: true })
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
+ );
66
62
 
67
63
  /***********************导航事件*********************/
68
64
  function handleMenuSelect(path: string): void {
69
- const item = findMenuItemByPath(allMenus.value, path)
65
+ const item = findMenuItemByPath(allMenus.value, path);
70
66
 
71
- if (item?.externalLink && item.externalTarget !== '_self') {
72
- window.open(item.externalLink, item.externalTarget ?? '_blank', 'noopener,noreferrer')
73
- return
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;
74
72
  }
75
73
 
76
- activePath.value = path
74
+ activePath.value = path;
77
75
  }
78
76
 
79
77
  function handleToggleSidebar(): void {
80
78
  patchAdminPreferences({
81
79
  sidebar: { collapsed: !adminPreferences.value.sidebar.collapsed },
82
- })
80
+ });
83
81
  }
84
82
 
85
83
  function handleTabChange(path: string): void {
86
- activePath.value = path
84
+ activePath.value = path;
87
85
  }
88
86
 
89
87
  function resolveAdminRouteTab(currentRoute: RouteLocationNormalizedLoaded): LumalLayoutTabItem | undefined {
90
88
  if (currentRoute.meta.layout === 'public' || currentRoute.meta.hideInTab === true) {
91
- return undefined
89
+ return undefined;
92
90
  }
93
91
 
94
- const menu = findMenuItemByPath(allMenus.value, currentRoute.path)
95
- const tabTitle = menu?.title ?? (typeof currentRoute.meta.title === 'string' ? currentRoute.meta.title : undefined)
92
+ const menu = findMenuItemByPath(allMenus.value, currentRoute.path);
93
+ const tabTitle = menu?.title ?? (typeof currentRoute.meta.title === 'string' ? currentRoute.meta.title : undefined);
96
94
 
97
95
  if (!tabTitle) {
98
- return undefined
96
+ return undefined;
99
97
  }
100
98
 
101
99
  return {
@@ -103,27 +101,26 @@ function resolveAdminRouteTab(currentRoute: RouteLocationNormalizedLoaded): Luma
103
101
  icon: menu?.icon ?? (typeof currentRoute.meta.icon === 'string' ? currentRoute.meta.icon : undefined),
104
102
  path: currentRoute.path,
105
103
  title: tabTitle,
106
- }
104
+ };
107
105
  }
108
106
 
109
107
  async function handleTabRefresh(path: string): Promise<void> {
110
108
  if (path !== route.path) {
111
- await router.push(path)
109
+ await router.push(path);
112
110
  }
113
111
 
114
- routeRefreshKeys[path] = (routeRefreshKeys[path] ?? 0) + 1
112
+ routeRefreshKeys[path] = (routeRefreshKeys[path] ?? 0) + 1;
115
113
  }
116
114
 
117
115
  function handleTabsChange(tabs: LumalLayoutTabItem[]): void {
118
- const paths = new Set(tabs.map(tab => tab.path))
116
+ const paths = new Set(tabs.map((tab) => tab.path));
119
117
  for (const path of Object.keys(routeRefreshKeys)) {
120
- if (!paths.has(path))
121
- delete routeRefreshKeys[path]
118
+ if (!paths.has(path)) delete routeRefreshKeys[path];
122
119
  }
123
120
  }
124
121
 
125
122
  function handlePreferencesChange(preferences: LumalPreferences): void {
126
- patchAdminPreferences(preferences)
123
+ patchAdminPreferences(preferences);
127
124
  }
128
125
 
129
126
  function toggleTheme(): void {
@@ -131,35 +128,28 @@ function toggleTheme(): void {
131
128
  theme: {
132
129
  mode: adminResolvedThemeMode.value === 'dark' ? 'light' : 'dark',
133
130
  },
134
- })
131
+ });
135
132
  }
136
133
 
137
134
  async function handleLogout(): Promise<void> {
138
135
  try {
139
- await logout()
140
- }
141
- catch (error) {
142
- ElMessage.warning(error instanceof Error ? error.message : '远端退出失败,本地会话已清理')
143
- }
144
- finally {
145
- pageError.value = ''
146
- await router.replace('/login')
136
+ await logout();
137
+ } catch (error) {
138
+ ElMessage.warning(error instanceof Error ? error.message : '远端退出失败,本地会话已清理');
139
+ } finally {
140
+ pageError.value = '';
141
+ await router.replace('/login');
147
142
  }
148
143
  }
149
144
  </script>
150
145
 
151
146
  <template>
152
147
  <ElConfigProvider :locale="zhCn">
153
- <LumalRouterView
154
- v-if="isPublicLayout"
155
- :view-key="routeViewKey"
156
- transition
157
- transition-name="fade"
158
- />
148
+ <LumalRouterView v-if="isPublicLayout" :view-key="routeViewKey" transition transition-name="fade" />
159
149
 
160
150
  <LumalLayout
161
151
  v-else
162
- :key="currentUser?.username"
152
+ :key="`${currentUser?.username}:${application.accessVersion.value}`"
163
153
  v-model:active-tab-path="activePath"
164
154
  :title="title"
165
155
  :menus="allMenus"
@@ -180,6 +170,8 @@ async function handleLogout(): Promise<void> {
180
170
  <AppHeaderActions
181
171
  :resolved-theme-mode="adminResolvedThemeMode"
182
172
  :user-name="currentUser?.name"
173
+ :has-profile="router.hasRoute('Profile')"
174
+ @open-profile="router.push('/profile')"
183
175
  @logout="handleLogout"
184
176
  @open-settings="settingsVisible = true"
185
177
  @toggle-theme="toggleTheme"
@@ -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>
@@ -1,27 +1,27 @@
1
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'
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
6
 
7
7
  /***********************偏好设置抽屉*********************/
8
8
  const props = defineProps<{
9
- defaults: LumalPreferencesDefaults
10
- preferences: LumalPreferences
11
- visible: boolean
12
- }>()
9
+ defaults: LumalPreferencesDefaults;
10
+ preferences: LumalPreferences;
11
+ visible: boolean;
12
+ }>();
13
13
 
14
14
  const emit = defineEmits<{
15
- 'change': [preferences: LumalPreferences]
16
- 'reset': [preferences: LumalPreferences]
17
- 'update:preferences': [preferences: LumalPreferences]
18
- 'update:visible': [visible: boolean]
19
- }>()
15
+ change: [preferences: LumalPreferences];
16
+ reset: [preferences: LumalPreferences];
17
+ 'update:preferences': [preferences: LumalPreferences];
18
+ 'update:visible': [visible: boolean];
19
+ }>();
20
20
 
21
21
  const visible = computed({
22
22
  get: () => props.visible,
23
- set: value => emit('update:visible', value),
24
- })
23
+ set: (value) => emit('update:visible', value),
24
+ });
25
25
  </script>
26
26
 
27
27
  <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
+ }