generator-mico-cli 0.1.29 → 0.2.1

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.
@@ -47,7 +47,7 @@ const config: ReturnType<typeof defineConfig> = {
47
47
  define: {
48
48
  'process.env.NODE_ENV': 'development',
49
49
  'process.env.APP_ID': 'mibot_dev',
50
- 'process.env.API_BASE_URL': 'https://dashboard-api-test.micoplatform.com',
50
+ 'process.env.API_BASE_URL': '',
51
51
  'process.env.PROXY_SUFFIX': '/',
52
52
  'process.env.LOGIN_ENDPOINT':
53
53
  '',
@@ -15,4 +15,9 @@ export default [
15
15
  component: './Home',
16
16
  name: '首页',
17
17
  },
18
+ {
19
+ path: '/403',
20
+ component: './403',
21
+ name: '无权限',
22
+ },
18
23
  ];
@@ -4,7 +4,7 @@
4
4
 
5
5
  ## 功能概述
6
6
 
7
- HTTP 请求层模块化架构,将原 599 行单文件拆分为 6 个职责单一的模块,遵循 SRP 原则。
7
+ HTTP 请求层模块化架构,将原 599 行单文件拆分为 7 个文件(1 个入口 + 6 个功能模块),遵循 SRP 原则。
8
8
 
9
9
  ## 模块结构
10
10
 
@@ -0,0 +1,175 @@
1
+ # 菜单权限控制
2
+
3
+ > 创建时间:2026-01-24
4
+
5
+ ## 功能概述
6
+
7
+ 基于用户信息中的 `side_menus` 字段实现菜单和路由的白名单权限控制。非超级用户只能看到和访问 `side_menus` 中配置的菜单项,访问无权限路由时显示 403 页面。
8
+
9
+ ## 技术方案
10
+
11
+ ### 技术栈
12
+
13
+ - 框架:React 18 + @umijs/max
14
+ - UI 组件:Arco Design (Result 组件)
15
+ - 状态管理:Umi initialState
16
+
17
+ ### 核心实现
18
+
19
+ 1. 用户信息接口返回 `side_menus` 字段(白名单)
20
+ 2. `filterMenuItems` 根据白名单过滤菜单项
21
+ 3. Layout 组件对比"所有路由"和"有权限路由"判断 403
22
+ 4. 无权限时在 Layout 内直接渲染 403 组件(无跳转)
23
+
24
+ ### 权限判断逻辑
25
+
26
+ ```
27
+ 用户访问 /some-path
28
+
29
+ 是否是超级用户?
30
+ ├── 是 → 允许访问所有菜单
31
+ └── 否 → 检查 side_menus 白名单
32
+ ├── 精确匹配:menuPath === side_menus[i]
33
+ ├── 前缀匹配:side_menus[i].startsWith(menuPath + '.')
34
+ └── 都不匹配 → 禁止访问,显示 403
35
+ ```
36
+
37
+ ## 文件清单
38
+
39
+ ### 新增文件
40
+
41
+ | 文件路径 | 说明 |
42
+ |----------|------|
43
+ | `src/pages/403/index.tsx` | 403 无权限页面组件 |
44
+
45
+ ### 修改文件
46
+
47
+ | 文件路径 | 修改内容 |
48
+ |----------|----------|
49
+ | `src/common/menu/parser.ts` | 新增 `filterMenuItems`、`isMenuAllowed` 等权限过滤函数 |
50
+ | `src/layouts/index.tsx` | 新增 `isForbidden` 判断和 403 渲染逻辑 |
51
+ | `src/layouts/components/menu/index.tsx` | 集成菜单过滤 |
52
+ | `config/routes.ts` | 新增 `/403` 路由 |
53
+
54
+ ## API / 组件接口
55
+
56
+ ### MenuFilterOptions
57
+
58
+ ```typescript
59
+ interface MenuFilterOptions {
60
+ /** 是否是超级用户 */
61
+ isSuperuser?: boolean | number;
62
+ /** 允许访问的菜单路径列表(白名单) */
63
+ sideMenus?: string[];
64
+ }
65
+ ```
66
+
67
+ ### filterMenuItems
68
+
69
+ ```typescript
70
+ /**
71
+ * 根据权限过滤菜单项(白名单逻辑)
72
+ */
73
+ function filterMenuItems(
74
+ items: MenuItem[],
75
+ options?: MenuFilterOptions,
76
+ parentPath?: string,
77
+ ): MenuItem[];
78
+ ```
79
+
80
+ **参数说明**:
81
+
82
+ | 参数 | 类型 | 必填 | 说明 |
83
+ |------|------|------|------|
84
+ | items | MenuItem[] | 是 | 原始菜单数据 |
85
+ | options | MenuFilterOptions | 否 | 过滤选项 |
86
+ | parentPath | string | 否 | 父级路径(递归用) |
87
+
88
+ ### isRouteAllowed
89
+
90
+ ```typescript
91
+ /**
92
+ * 检查路由是否允许访问
93
+ */
94
+ function isRouteAllowed(
95
+ menuPath: string,
96
+ options?: MenuFilterOptions,
97
+ ): boolean;
98
+ ```
99
+
100
+ ### 用户信息相关字段
101
+
102
+ ```typescript
103
+ interface IUserInfo {
104
+ is_superuser: boolean | number;
105
+ /** 允许访问的菜单路径列表 */
106
+ side_menus: string[];
107
+ /** 缺失的操作权限(用于按钮级别控制,非菜单) */
108
+ miss_permissions: string[];
109
+ }
110
+ ```
111
+
112
+ ## 使用示例
113
+
114
+ ### 用户信息接口返回
115
+
116
+ ```json
117
+ {
118
+ "is_superuser": false,
119
+ "side_menus": ["列队管理.配置队列", "工作台"],
120
+ "miss_permissions": ["列队管理.配置队列.查看"]
121
+ }
122
+ ```
123
+
124
+ ### 菜单过滤结果
125
+
126
+ ```
127
+ 原始菜单:
128
+ ├── 工作台 ✅ 精确匹配 "工作台"
129
+ ├── 列队管理 ✅ 前缀匹配 "列队管理.配置队列"
130
+ │ └── 配置队列 ✅ 精确匹配 "列队管理.配置队列"
131
+ ├── 质量管理 ❌ 不在白名单
132
+ │ └── 抽样检查 ❌ 不在白名单
133
+ └── 权限管理 ❌ 硬编码禁止(非超级用户)
134
+ ```
135
+
136
+ ### Layout 中的权限判断
137
+
138
+ ```tsx
139
+ // layouts/index.tsx
140
+ const isForbidden = useMemo(() => {
141
+ if (currentRoute) return false; // 在有权限的路由中找到了
142
+ const routeInAll = findRouteByPath(allRoutes, location.pathname);
143
+ return !!routeInAll; // 在所有路由中存在但无权限
144
+ }, [currentRoute, allRoutes, location.pathname]);
145
+
146
+ const renderContent = () => {
147
+ if (isForbidden) {
148
+ return <ForbiddenPage />;
149
+ }
150
+ // ... 正常渲染
151
+ };
152
+ ```
153
+
154
+ ## 设计决策
155
+
156
+ | 决策点 | 选择 | 理由 |
157
+ |--------|------|------|
158
+ | 权限模型 | 白名单 (`side_menus`) | 后端返回的 `side_menus` 是允许列表,比黑名单更安全 |
159
+ | 403 处理 | 原地渲染组件 | 保持 URL 不变,用户体验更好,便于分享链接 |
160
+ | 父级菜单显示 | 前缀匹配 | 子菜单有权限时,父级菜单需要作为容器显示 |
161
+ | 超级用户 | 跳过所有检查 | 管理员需要完整访问权限 |
162
+ | 权限管理菜单 | 硬编码禁止 | 非超级用户不应看到权限管理入口 |
163
+
164
+ ## 注意事项
165
+
166
+ - `side_menus` 为空时,非超级用户没有任何菜单权限
167
+ - `side_menus` 格式为菜单路径,如 `"列队管理.配置队列"`
168
+ - `miss_permissions` 用于按钮级别权限控制,不影响菜单显示
169
+ - 403 页面在 Layout 内渲染,不会触发路由跳转
170
+ - 调试时可在控制台搜索 `isForbidden check` 查看权限判断日志
171
+
172
+ ## 相关文档
173
+
174
+ - [微前端模式](./feature-微前端模式.md) - 路由和菜单解析
175
+ - [用户信息接口](./feature-用户信息接口.md) - 用户数据结构
@@ -3,8 +3,6 @@ import { prefetchApps } from 'qiankun';
3
3
  import { errorConfig } from './requestErrorConfig';
4
4
  // 解决「React19 中无法使用 Message/Notification」的问题。 @see https://github.com/arco-design/arco-design/issues/2900#issuecomment-2796571653
5
5
  import * as arco from '@arco-design/web-react';
6
- import '@arco-design/web-react/dist/css/arco.css';
7
- import '@arco-design/web-react/es/_util/react-19-adapter';
8
6
  import React from 'react';
9
7
  import ReactDOM from 'react-dom/client';
10
8
 
@@ -124,21 +122,22 @@ export async function getInitialState(): Promise<{
124
122
  }
125
123
  };
126
124
 
127
- // 如果不是登录页面,执行
128
125
  const { location } = history;
129
- if (!NO_AUTH_ROUTE_LIST.includes(location.pathname)) {
130
- // 先确保 SSO 会话完成(如果 URL 中有 ticket 参数,会换取 token 并存储用户信息)
126
+ const isNoAuthRoute = NO_AUTH_ROUTE_LIST.includes(location.pathname);
127
+
128
+ // 非免认证路由:走 SSO 流程
129
+ if (!isNoAuthRoute) {
131
130
  await ensureSsoSession();
131
+ }
132
132
 
133
- // 有 token 时,获取用户信息
134
- if (getStoredAuthToken()) {
135
- const userInfo = await fetchUserInfoFn();
136
- if (userInfo) {
137
- return {
138
- fetchUserInfo: fetchUserInfoFn,
139
- currentUser: userInfo,
140
- };
141
- }
133
+ // 有 token 就获取用户信息(无论在哪个页面,支持登录后 refresh 场景)
134
+ if (getStoredAuthToken()) {
135
+ const userInfo = await fetchUserInfoFn();
136
+ if (userInfo) {
137
+ return {
138
+ fetchUserInfo: fetchUserInfoFn,
139
+ currentUser: userInfo,
140
+ };
142
141
  }
143
142
  }
144
143
 
@@ -147,6 +146,7 @@ export async function getInitialState(): Promise<{
147
146
  };
148
147
  }
149
148
 
149
+
150
150
  /**
151
151
  * @name request 配置,可以配置错误处理
152
152
  * 它基于 axios 和 ahooks 的 useRequest 提供了一套统一的网络请求和错误处理方案。
@@ -179,7 +179,11 @@ export function patchClientRoutes({ routes }: { routes: UmiRoute[] }) {
179
179
  const menus = getWindowMenus();
180
180
  const dynamicRoutes = extractRoutes(menus);
181
181
 
182
- console.log('[app.tsx] patchClientRoutes:', { menus, dynamicRoutes, routes });
182
+ console.log('[app.tsx] patchClientRoutes:', {
183
+ menus,
184
+ dynamicRoutes,
185
+ routes,
186
+ });
183
187
 
184
188
  // 找到根路由(全局布局)
185
189
  const rootRoute = routes.find((r) => r.path === '/');
@@ -9,6 +9,108 @@ export const getWindowMenus = (): MenuItem[] => {
9
9
  return Array.isArray(menus) ? menus : [];
10
10
  };
11
11
 
12
+ export interface MenuFilterOptions {
13
+ /** 是否是超级用户 */
14
+ isSuperuser?: boolean | number;
15
+ /** 允许访问的菜单路径列表(白名单) */
16
+ sideMenus?: string[];
17
+ }
18
+
19
+ const isSuperuserUser = (value?: boolean | number): boolean => {
20
+ return value === true || value === 1;
21
+ };
22
+
23
+ export const buildMenuPath = (parentPath: string, name: string): string => {
24
+ return parentPath ? `${parentPath}.${name}` : name;
25
+ };
26
+
27
+ /**
28
+ * 检查菜单路径是否允许访问(白名单逻辑)
29
+ * - 超级用户可以访问所有菜单
30
+ * - 非超级用户不能访问"权限管理"
31
+ * - 菜单路径在 sideMenus 中,或是 sideMenus 中某项的前缀(父级菜单)
32
+ */
33
+ const isMenuAllowed = (
34
+ menuPath: string,
35
+ options: MenuFilterOptions,
36
+ ): boolean => {
37
+ // 超级用户可以访问所有菜单
38
+ if (isSuperuserUser(options.isSuperuser)) return true;
39
+
40
+ // 非超级用户不能访问"权限管理"
41
+ if (menuPath === '权限管理') return false;
42
+
43
+ const sideMenus = options.sideMenus || [];
44
+
45
+ // 如果没有配置 sideMenus,非超级用户没有任何菜单权限
46
+ if (sideMenus.length === 0) return false;
47
+
48
+ // 检查是否在白名单中(精确匹配或前缀匹配)
49
+ return sideMenus.some((allowedPath) => {
50
+ // 精确匹配:菜单路径完全等于白名单中的路径
51
+ if (menuPath === allowedPath) return true;
52
+ // 前缀匹配:白名单路径以菜单路径开头(说明菜单是父级)
53
+ if (allowedPath.startsWith(menuPath + '.')) return true;
54
+ return false;
55
+ });
56
+ };
57
+
58
+ export const isRouteAllowed = (
59
+ menuPath: string,
60
+ options: MenuFilterOptions = {},
61
+ ): boolean => {
62
+ return isMenuAllowed(menuPath, options);
63
+ };
64
+
65
+ /**
66
+ * 根据权限过滤菜单项(白名单逻辑)
67
+ */
68
+ export const filterMenuItems = (
69
+ items: MenuItem[],
70
+ options: MenuFilterOptions = {},
71
+ parentPath = '',
72
+ ): MenuItem[] => {
73
+ return items
74
+ .filter((item) => item.enabled)
75
+ .map((item) => {
76
+ const menuPath = buildMenuPath(parentPath, item.name);
77
+ const isAllowed = isMenuAllowed(menuPath, options);
78
+
79
+ // 递归处理子菜单
80
+ const nextChildren = item.children?.length
81
+ ? filterMenuItems(item.children, options, menuPath)
82
+ : [];
83
+
84
+ // 分组类型:如果没有子菜单,不显示
85
+ if (item.type === 'group' && nextChildren.length === 0) {
86
+ return null;
87
+ }
88
+
89
+ // 当前菜单不允许访问
90
+ if (!isAllowed) {
91
+ // 但如果有允许的子菜单,仍需显示当前菜单作为容器
92
+ if (nextChildren.length > 0) {
93
+ return { ...item, children: nextChildren };
94
+ }
95
+ return null;
96
+ }
97
+
98
+ // 当前菜单允许访问
99
+ const hasChildren = (item.children?.length || 0) > 0;
100
+ if (!hasChildren) {
101
+ return item;
102
+ }
103
+
104
+ // 有子菜单但过滤后为空,不显示
105
+ if (nextChildren.length === 0) {
106
+ return null;
107
+ }
108
+
109
+ return { ...item, children: nextChildren };
110
+ })
111
+ .filter((item): item is MenuItem => Boolean(item));
112
+ };
113
+
12
114
  /**
13
115
  * 判断页面的加载类型
14
116
  * 有 htmlUrl 或 jsUrls 时使用 qiankun 微应用加载
@@ -40,10 +142,13 @@ const getEntry = (page: MenuItem['page']): string | undefined => {
40
142
  export const extractRoutes = (
41
143
  items: MenuItem[],
42
144
  routes: ParsedRoute[] = [],
145
+ parentPath = '',
43
146
  ): ParsedRoute[] => {
44
147
  for (const item of items) {
45
148
  if (!item.enabled) continue;
46
149
 
150
+ const menuPath = buildMenuPath(parentPath, item.name);
151
+
47
152
  if (item.type === 'page' && item.page && item.page.enabled) {
48
153
  const loadType = getLoadType(item.page);
49
154
  routes.push({
@@ -57,7 +162,7 @@ export const extractRoutes = (
57
162
  }
58
163
 
59
164
  if (item.children && item.children.length > 0) {
60
- extractRoutes(item.children, routes);
165
+ extractRoutes(item.children, routes, menuPath);
61
166
  }
62
167
  }
63
168
 
@@ -2,7 +2,7 @@
2
2
  * URL 解析与拼接工具
3
3
  */
4
4
 
5
- import { resolveProxySuffix } from './config';
5
+ import { resolveApiBaseUrl, resolveProxySuffix } from './config';
6
6
  import type { UnifiedRequestOptions } from './types';
7
7
 
8
8
  /**
@@ -33,8 +33,12 @@ const joinProxyAndPath = (proxySuffix: string, path: string): string => {
33
33
  * 配置优先级:
34
34
  * 1. rawUrl: true → 直接返回原始 url
35
35
  * 2. 绝对 URL → 直接返回
36
- * 3. skipProxy: true → 返回相对路径,不拼接代理前缀
37
- * 4. 其他情况 → 拼接 proxySuffix + url
36
+ * 3. skipProxy: true → 拼接 apiBaseUrl + url(跳过 proxySuffix)
37
+ * 4. 其他情况 → 拼接 apiBaseUrl + proxySuffix + url
38
+ *
39
+ * 示例(testing 环境,apiBaseUrl = https://dashboard-api-test.micoplatform.com):
40
+ * - 默认: /api/user/info → https://dashboard-api-test.micoplatform.com/proxy/audit_svr/api/user/info
41
+ * - skipProxy: /api/user/info → https://dashboard-api-test.micoplatform.com/api/user/info
38
42
  */
39
43
  export const resolveRequestUrl = (
40
44
  url: string,
@@ -50,13 +54,24 @@ export const resolveRequestUrl = (
50
54
  return url;
51
55
  }
52
56
 
53
- // skipProxy: true 时不拼接代理前缀
54
- if (options?.skipProxy) {
55
- return url;
57
+ // 拼接 apiBaseUrl + proxySuffix + url
58
+ const apiBaseUrl = resolveApiBaseUrl();
59
+ // skipProxy: true 时不拼接 proxySuffix
60
+ const proxySuffix = options?.skipProxy
61
+ ? ''
62
+ : resolveProxySuffix(options?.proxySuffix);
63
+
64
+ if (apiBaseUrl) {
65
+ // 有 apiBaseUrl 时,拼接完整的绝对路径
66
+ const pathPart = proxySuffix ? joinProxyAndPath(proxySuffix, url) : url;
67
+ const normalizedBase = apiBaseUrl.endsWith('/')
68
+ ? apiBaseUrl.slice(0, -1)
69
+ : apiBaseUrl;
70
+ const normalizedPath = pathPart.startsWith('/') ? pathPart : `/${pathPart}`;
71
+ return `${normalizedBase}${normalizedPath}`;
56
72
  }
57
73
 
58
- // 拼接 proxySuffix
59
- const proxySuffix = resolveProxySuffix(options?.proxySuffix);
74
+ // 没有 apiBaseUrl 时,仅拼接 proxySuffix(相对路径)
60
75
  if (proxySuffix) {
61
76
  return joinProxyAndPath(proxySuffix, url);
62
77
  }
@@ -115,23 +115,60 @@ const MicroAppLoader: React.FC<MicroAppLoaderProps> = ({
115
115
  container,
116
116
  props: buildPropsRef.current(),
117
117
  },
118
- { sandbox: { loose: true } },
118
+ {
119
+ sandbox: { loose: true },
120
+ // 自定义 fetch,包装原生 fetch 以便在网络层面捕获错误
121
+ fetch: async (url, options) => {
122
+ try {
123
+ const response = await window.fetch(url, options);
124
+ if (!response.ok) {
125
+ throw new Error(
126
+ `HTTP ${response.status}: ${response.statusText}`,
127
+ );
128
+ }
129
+ return response;
130
+ } catch (err) {
131
+ // 将网络错误转换为更友好的错误信息
132
+ const message =
133
+ err instanceof Error ? err.message : 'Network error';
134
+ throw new Error(`加载资源失败 (${url}): ${message}`);
135
+ }
136
+ },
137
+ },
119
138
  );
120
139
 
121
- // 立即给 loadPromise 添加错误处理器,防止 unhandled rejection
122
- // 虽然提前检查了服务可达性,但 qiankun 可能因其他原因失败(CORS、HTML 解析等)
123
- microApp.loadPromise.catch((err) => {
124
- console.error(`[MicroAppLoader] ${appName} loadPromise rejected:`, err);
125
- });
126
-
127
140
  setMicroApp(appName, microApp);
128
141
 
142
+ // 关键:必须同时处理 loadPromise 和 bootstrapPromise/mountPromise 的错误
143
+ // qiankun 内部会产生多个 Promise 链,任何一个未处理的 rejection 都会导致页面崩溃
144
+ const handlePromiseError = (err: unknown) => {
145
+ if (!isCancelled) {
146
+ const message =
147
+ err instanceof Error ? err.message : 'Unknown error';
148
+ setError(message);
149
+ setLoading(false);
150
+ }
151
+ };
152
+
153
+ // 为所有 qiankun 返回的 Promise 添加错误处理器
154
+ microApp.loadPromise.catch(handlePromiseError);
155
+ microApp.bootstrapPromise.catch(handlePromiseError);
156
+ microApp.mountPromise.catch(handlePromiseError);
157
+
158
+ if (isCancelled) {
159
+ unmountApp(appName);
160
+ return;
161
+ }
162
+
163
+ // 等待加载完成,这里的错误会被上层 catch 捕获
164
+ await microApp.loadPromise;
165
+
129
166
  if (isCancelled) {
130
167
  unmountApp(appName);
131
168
  return;
132
169
  }
133
170
 
134
- // mountPromise 内部会等待 loadPromise,错误会传递到 catch 块
171
+ // 等待挂载完成
135
172
  await microApp.mountPromise;
136
173
 
137
174
  if (!isCancelled) {
@@ -140,7 +177,12 @@ const MicroAppLoader: React.FC<MicroAppLoaderProps> = ({
140
177
  }
141
178
  } catch (err) {
142
179
  if (!isCancelled) {
143
- setError(err instanceof Error ? err.message : 'Unknown error');
180
+ const message = err instanceof Error ? err.message : 'Unknown error';
181
+ // 提取更友好的错误信息
182
+ const friendlyMessage = message.includes('Failed to fetch')
183
+ ? '无法连接到子应用服务,请检查子应用是否已启动'
184
+ : message;
185
+ setError(friendlyMessage);
144
186
  setLoading(false);
145
187
  }
146
188
  }
@@ -1,5 +1,9 @@
1
1
  // ==================== 全局样式 ====================
2
- // 导入共享主题变量(CSS Variables + Less Variables)
2
+ // 先导入 Arco Design CSS(确保它在项目样式之前)
3
+ @import (css) '~@arco-design/web-react/dist/css/arco.css';
4
+
5
+ // 再导入共享主题变量(CSS Variables + Less Variables)
6
+ // 这样项目的变量定义会在 Arco 之后,可以正确覆盖
3
7
  @import '<%= packageScope %>/shared-styles';
4
8
 
5
9
  * {
@@ -1,10 +1,11 @@
1
1
  import type { ParsedMenuItem } from '@/common/menu';
2
- import { getWindowMenus, parseMenuItems } from '@/common/menu';
2
+ import { filterMenuItems, getWindowMenus, parseMenuItems } from '@/common/menu';
3
3
  import IconFont from '@/components/IconFont';
4
4
  import { useMenuState } from '@/hooks/useMenuState';
5
5
  import { useTheme } from '@/hooks/useTheme';
6
6
  import { Layout, Menu } from '@arco-design/web-react';
7
7
  import * as Icons from '@arco-design/web-react/icon';
8
+ import { useModel } from '@umijs/max';
8
9
  import React, { useEffect, useMemo, useRef } from 'react';
9
10
  import './index.less';
10
11
 
@@ -103,11 +104,18 @@ const LayoutMenu: React.FC<LayoutMenuProps> = () => {
103
104
  const siderRef = useRef<HTMLDivElement>(null);
104
105
  const { isDark } = useTheme();
105
106
 
107
+ const { initialState } = useModel('@@initialState');
108
+ const currentUser = initialState?.currentUser;
109
+
106
110
  // Parse menu data
107
111
  const menuItems = useMemo(() => {
108
112
  const menus = getWindowMenus();
109
- return parseMenuItems(menus);
110
- }, []);
113
+ const filteredMenus = filterMenuItems(menus, {
114
+ isSuperuser: currentUser?.is_superuser,
115
+ sideMenus: (currentUser?.side_menus || []) as string[],
116
+ });
117
+ return parseMenuItems(filteredMenus);
118
+ }, [currentUser?.is_superuser, currentUser?.side_menus]);
111
119
 
112
120
  // 使用菜单状态 Hook
113
121
  const {
@@ -1,10 +1,11 @@
1
1
  import { layoutLogger } from '@/common/logger';
2
- import { extractRoutes, findRouteByPath, getWindowMenus } from '@/common/menu';
2
+ import { extractRoutes, filterMenuItems, findRouteByPath, getWindowMenus } from '@/common/menu';
3
3
  import AppTabs from '@/components/AppTabs';
4
4
  import MicroAppLoader from '@/components/MicroAppLoader';
5
5
  import { NO_AUTH_ROUTE_LIST } from '@/constants';
6
+ import ForbiddenPage from '@/pages/403';
6
7
  import { Layout, Spin } from '@arco-design/web-react';
7
- import { Outlet, useLocation } from '@umijs/max';
8
+ import { Outlet, useLocation, useModel } from '@umijs/max';
8
9
  import React, { Suspense, useMemo } from 'react';
9
10
  import LayoutHeader from './components/header';
10
11
  import LayoutMenu from './components/menu';
@@ -38,17 +39,55 @@ const getAppNameFromEntry = (entry: string): string => {
38
39
  */
39
40
  const BasicLayout: React.FC = () => {
40
41
  const location = useLocation();
42
+ const { initialState } = useModel('@@initialState');
43
+ const currentUser = initialState?.currentUser;
41
44
 
42
- // 解析路由配置
43
- const routes = useMemo(() => {
45
+ const filterOptions = useMemo(
46
+ () => ({
47
+ isSuperuser: currentUser?.is_superuser,
48
+ sideMenus: (currentUser?.side_menus || []) as string[],
49
+ }),
50
+ [currentUser?.is_superuser, currentUser?.side_menus],
51
+ );
52
+ // 所有路由(不过滤权限,用于判断路径是否存在)
53
+ const allRoutes = useMemo(() => {
44
54
  const menus = getWindowMenus();
45
55
  return extractRoutes(menus);
46
56
  }, []);
47
57
 
48
- // 查找当前路由配置
58
+ // 有权限的路由
59
+ const allowedRoutes = useMemo(() => {
60
+ const menus = getWindowMenus();
61
+ const filteredMenus = filterMenuItems(menus, filterOptions);
62
+ return extractRoutes(filteredMenus);
63
+ }, [filterOptions]);
64
+
65
+ // 查找当前路由配置(优先从有权限的路由中查找)
49
66
  const currentRoute = useMemo(() => {
50
- return findRouteByPath(routes, location.pathname);
51
- }, [routes, location.pathname]);
67
+ return findRouteByPath(allowedRoutes, location.pathname);
68
+ }, [allowedRoutes, location.pathname]);
69
+
70
+ // 判断是否是动态路由但无权限
71
+ const isForbidden = useMemo(() => {
72
+ // 如果在有权限的路由中找到了,说明有权限
73
+ if (currentRoute) return false;
74
+ // 如果在所有路由中也找不到,说明不是动态路由,交给 Umi 处理
75
+ const routeInAll = findRouteByPath(allRoutes, location.pathname);
76
+ // 在所有路由中存在,但在有权限的路由中不存在 → 无权限
77
+ const forbidden = !!routeInAll;
78
+
79
+ layoutLogger.log('isForbidden check:', {
80
+ pathname: location.pathname,
81
+ currentRoute,
82
+ routeInAll,
83
+ forbidden,
84
+ allRoutesCount: allRoutes.length,
85
+ allowedRoutesCount: allowedRoutes.length,
86
+ filterOptions,
87
+ });
88
+
89
+ return forbidden;
90
+ }, [currentRoute, allRoutes, allowedRoutes, location.pathname, filterOptions]);
52
91
 
53
92
  // 判断是否需要显示布局
54
93
  const showLayout = !NO_AUTH_ROUTE_LIST.includes(location.pathname);
@@ -58,9 +97,14 @@ const BasicLayout: React.FC = () => {
58
97
  layoutLogger.log('renderContent:', {
59
98
  pathname: location.pathname,
60
99
  currentRoute,
61
- routes,
100
+ isForbidden,
62
101
  });
63
102
 
103
+ // 无权限,显示 403
104
+ if (isForbidden) {
105
+ return <ForbiddenPage />;
106
+ }
107
+
64
108
  // 如果有匹配的动态路由配置且需要加载微应用
65
109
  if (currentRoute?.loadType === 'microapp' && currentRoute.entry) {
66
110
  layoutLogger.log('Loading microapp:', currentRoute);
@@ -0,0 +1,28 @@
1
+ import { Button, Result } from '@arco-design/web-react';
2
+ import { history } from '@umijs/max';
3
+ import React from 'react';
4
+
5
+ const ForbiddenPage: React.FC = () => {
6
+ return (
7
+ <div style={{
8
+ display: 'flex',
9
+ justifyContent: 'center',
10
+ alignItems: 'center',
11
+ height: '100%',
12
+ minHeight: 400,
13
+ }}>
14
+ <Result
15
+ status="403"
16
+ title="无权限访问"
17
+ subTitle="抱歉,您没有权限访问此页面"
18
+ extra={
19
+ <Button type="primary" onClick={() => history.push('/')}>
20
+ 返回首页
21
+ </Button>
22
+ }
23
+ />
24
+ </div>
25
+ );
26
+ };
27
+
28
+ export default ForbiddenPage;
@@ -1,6 +1,6 @@
1
1
  import { Button, Form, Input, Message, Space, Typography } from '@arco-design/web-react';
2
2
  import { IconLock, IconUser } from '@arco-design/web-react/icon';
3
- import { useLocation, useNavigate } from '@umijs/max';
3
+ import { useLocation, useModel,useNavigate } from '@umijs/max';
4
4
  import React, { useCallback, useMemo, useState } from 'react';
5
5
  import { loginStaff } from '@/services/auth';
6
6
  import { maybePersistTokens } from '@/common/auth/auth-manager';
@@ -18,6 +18,8 @@ const LoginPage: React.FC = () => {
18
18
  const navigate = useNavigate();
19
19
  const location = useLocation();
20
20
  const [form] = Form.useForm<LoginFormValues>();
21
+ const { refresh } = useModel('@@initialState');
22
+
21
23
 
22
24
  const redirectPath = useMemo(() => {
23
25
  const params = new URLSearchParams(location.search);
@@ -39,6 +41,8 @@ const LoginPage: React.FC = () => {
39
41
  if (response?.code === 200) {
40
42
  maybePersistTokens(response);
41
43
  Message.success('登录成功');
44
+ // 刷新 initialState,获取用户信息
45
+ await refresh();
42
46
  navigate(redirectPath, { replace: true });
43
47
  return;
44
48
  }
@@ -50,7 +54,7 @@ const LoginPage: React.FC = () => {
50
54
  setLoading(false);
51
55
  }
52
56
  },
53
- [navigate, redirectPath],
57
+ [navigate, redirectPath, refresh],
54
58
  );
55
59
 
56
60
  return (
@@ -4,8 +4,8 @@ import { defineConfig } from '@umijs/max';
4
4
  const { CDN_PUBLIC_PATH } = process.env;
5
5
 
6
6
  const PUBLIC_PATH: string = CDN_PUBLIC_PATH
7
- ? `${CDN_PUBLIC_PATH.replace(/\/?$/, '/') }homepage/`
8
- : '/homepage/';
7
+ ? `${CDN_PUBLIC_PATH.replace(/\/?$/, '/') }<%= appName %>/`
8
+ : '/<%= appName %>/';
9
9
 
10
10
  const config: ReturnType<typeof defineConfig> = {
11
11
  // 生产环境:将所有代码打包到一个文件
@@ -30,6 +30,12 @@ const config: ReturnType<typeof defineConfig> = {
30
30
 
31
31
  publicPath: PUBLIC_PATH,
32
32
 
33
+ /**
34
+ * @name 运行时公共路径
35
+ * @description qiankun 微应用运行时动态设置 publicPath
36
+ */
37
+ runtimePublicPath: {},
38
+
33
39
  /**
34
40
  * @name 路由配置
35
41
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "generator-mico-cli",
3
- "version": "0.1.29",
3
+ "version": "0.2.1",
4
4
  "description": "Yeoman generator for Mico CLI projects",
5
5
  "keywords": [
6
6
  "yeoman-generator",