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.
- package/generators/micro-react/templates/apps/layout/config/config.dev.ts +1 -1
- package/generators/micro-react/templates/apps/layout/config/routes.ts +5 -0
- package/generators/micro-react/templates/apps/layout/docs/arch-/350/257/267/346/261/202/346/250/241/345/235/227.md +1 -1
- package/generators/micro-react/templates/apps/layout/docs/feature-/350/217/234/345/215/225/346/235/203/351/231/220/346/216/247/345/210/266.md +175 -0
- package/generators/micro-react/templates/apps/layout/src/app.tsx +19 -15
- package/generators/micro-react/templates/apps/layout/src/common/menu/parser.ts +106 -1
- package/generators/micro-react/templates/apps/layout/src/common/request/url-resolver.ts +23 -8
- package/generators/micro-react/templates/apps/layout/src/components/MicroAppLoader/index.tsx +51 -9
- package/generators/micro-react/templates/apps/layout/src/global.less +5 -1
- package/generators/micro-react/templates/apps/layout/src/layouts/components/menu/index.tsx +11 -3
- package/generators/micro-react/templates/apps/layout/src/layouts/index.tsx +52 -8
- package/generators/micro-react/templates/apps/layout/src/pages/403/index.tsx +28 -0
- package/generators/micro-react/templates/apps/layout/src/pages/User/Login/index.tsx +6 -2
- package/generators/subapp-react/templates/homepage/config/config.prod.ts +2 -2
- package/generators/subapp-react/templates/homepage/config/config.ts +6 -0
- package/package.json +1 -1
|
@@ -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': '
|
|
50
|
+
'process.env.API_BASE_URL': '',
|
|
51
51
|
'process.env.PROXY_SUFFIX': '/',
|
|
52
52
|
'process.env.LOGIN_ENDPOINT':
|
|
53
53
|
'',
|
|
@@ -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
|
-
|
|
130
|
-
|
|
126
|
+
const isNoAuthRoute = NO_AUTH_ROUTE_LIST.includes(location.pathname);
|
|
127
|
+
|
|
128
|
+
// 非免认证路由:走 SSO 流程
|
|
129
|
+
if (!isNoAuthRoute) {
|
|
131
130
|
await ensureSsoSession();
|
|
131
|
+
}
|
|
132
132
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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:', {
|
|
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
|
-
//
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
//
|
|
59
|
-
const proxySuffix = resolveProxySuffix(options?.proxySuffix);
|
|
74
|
+
// 没有 apiBaseUrl 时,仅拼接 proxySuffix(相对路径)
|
|
60
75
|
if (proxySuffix) {
|
|
61
76
|
return joinProxyAndPath(proxySuffix, url);
|
|
62
77
|
}
|
package/generators/micro-react/templates/apps/layout/src/components/MicroAppLoader/index.tsx
CHANGED
|
@@ -115,23 +115,60 @@ const MicroAppLoader: React.FC<MicroAppLoaderProps> = ({
|
|
|
115
115
|
container,
|
|
116
116
|
props: buildPropsRef.current(),
|
|
117
117
|
},
|
|
118
|
-
{
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
51
|
-
}, [
|
|
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
|
-
|
|
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(/\/?$/, '/') }
|
|
8
|
-
: '
|
|
7
|
+
? `${CDN_PUBLIC_PATH.replace(/\/?$/, '/') }<%= appName %>/`
|
|
8
|
+
: '/<%= appName %>/';
|
|
9
9
|
|
|
10
10
|
const config: ReturnType<typeof defineConfig> = {
|
|
11
11
|
// 生产环境:将所有代码打包到一个文件
|