generator-mico-cli 0.2.32 → 0.2.34
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/README.md +6 -0
- package/generators/micro-react/README.md +1 -1
- package/generators/micro-react/index.js +9 -18
- package/generators/micro-react/templates/apps/layout/config/config.dev.ts +2 -0
- package/generators/micro-react/templates/apps/layout/docs/feature-/345/244/264/351/203/250/344/270/200/347/272/247/345/257/274/350/210/252/345/270/203/345/261/200.md +166 -0
- package/generators/micro-react/templates/apps/layout/src/app.tsx +20 -2
- package/generators/micro-react/templates/apps/layout/src/common/logger.ts +1 -1
- package/generators/micro-react/templates/apps/layout/src/common/menu/parser.ts +36 -0
- package/generators/micro-react/templates/apps/layout/src/common/menu/types.ts +6 -0
- package/generators/micro-react/templates/apps/layout/src/common/request/index.ts +8 -2
- package/generators/micro-react/templates/apps/layout/src/common/request/sso.ts +128 -11
- package/generators/micro-react/templates/apps/layout/src/components/MicroAppLoader/index.tsx +6 -2
- package/generators/micro-react/templates/apps/layout/src/constants/index.ts +22 -0
- package/generators/micro-react/templates/apps/layout/src/hooks/useFilteredMenuItems.ts +29 -0
- package/generators/micro-react/templates/apps/layout/src/layouts/components/header/index.less +73 -0
- package/generators/micro-react/templates/apps/layout/src/layouts/components/header/index.tsx +51 -4
- package/generators/micro-react/templates/apps/layout/src/layouts/components/menu/index.tsx +25 -18
- package/generators/micro-react/templates/apps/layout/src/locales/en-US.ts +4 -0
- package/generators/micro-react/templates/apps/layout/src/locales/zh-CN.ts +4 -0
- package/generators/micro-react/templates/apps/layout/src/services/config/index.ts +1 -0
- package/generators/micro-react/templates/packages/common-intl/src/intl.ts +20 -0
- package/generators/subapp-react/index.js +10 -19
- package/generators/subapp-react/templates/homepage/src/common/logger.ts +1 -1
- package/generators/subapp-umd/index.js +6 -0
- package/generators/subapp-umd/templates/package.json +1 -1
- package/lib/utils.js +222 -0
- package/package.json +5 -1
package/README.md
CHANGED
|
@@ -117,6 +117,12 @@ mico setup multica-desktop
|
|
|
117
117
|
|
|
118
118
|
所有字段均为可选,未配置时使用默认值或交互式提示。
|
|
119
119
|
|
|
120
|
+
### 组件库版本(`micoPackages`)
|
|
121
|
+
|
|
122
|
+
CLI 根 `package.json` 的 `micoPackages` 是 `@mico-platform/ui` / `@mico-platform/theme` 的**唯一维护点**(semver range,如 `0.x`)。`mico create` 时在该 range 内 resolve 最新版本,写入生成项目为 `^x.y.z`(如 `^0.0.25`);子应用开发环境 CDN 的 `MICO_UI_VERSION` 使用同一 resolved 精确版本。
|
|
123
|
+
|
|
124
|
+
组件库发 patch(仍在 `0.x`)时通常无需发 CLI;切换到 `1.x` 等 major 策略时,修改 `micoPackages` 并发布新 CLI 即可。
|
|
125
|
+
|
|
120
126
|
## Monorepo 项目生成器 (micro-react)
|
|
121
127
|
|
|
122
128
|
创建基于 qiankun 微前端架构的完整 Monorepo 项目:
|
|
@@ -31,4 +31,4 @@ mico create micro-react
|
|
|
31
31
|
| `cdnPrefix` / `cdnPrefixPath` | string | prompt;`cdnPrefixPath` 为空或带尾斜杠 |
|
|
32
32
|
| `author` | string | prompt |
|
|
33
33
|
| `intlTag` | string | prompt |
|
|
34
|
-
| `micoUiVersion` / `themeVersion` | string |
|
|
34
|
+
| `micoUiVersion` / `themeVersion` | string | `^${micoPackages range 内 resolve 的最新版本}` |
|
|
@@ -9,7 +9,7 @@ const {
|
|
|
9
9
|
collectFiles,
|
|
10
10
|
transformDestPath,
|
|
11
11
|
isTemplateFile,
|
|
12
|
-
|
|
12
|
+
getMicoTemplateVersions,
|
|
13
13
|
setupErrorHandlers,
|
|
14
14
|
createLogger,
|
|
15
15
|
loadMicorc,
|
|
@@ -162,23 +162,14 @@ module.exports = class extends Generator {
|
|
|
162
162
|
|
|
163
163
|
// 在 mico_cli 根目录执行 npm view,以使用该目录 .npmrc 中的 Nexus 认证
|
|
164
164
|
const cliRoot = path.resolve(__dirname, '../..');
|
|
165
|
-
this.logger.verbose('Fetching
|
|
166
|
-
|
|
167
|
-
// 并行获取版本
|
|
168
|
-
const versions = await getPackageVersionsParallel(
|
|
169
|
-
[
|
|
170
|
-
{ name: '@mico-platform/ui', fallback: '1.0.0' },
|
|
171
|
-
{ name: '@mico-platform/theme', fallback: '1.0.0' },
|
|
172
|
-
],
|
|
173
|
-
8000,
|
|
174
|
-
cliRoot,
|
|
175
|
-
);
|
|
165
|
+
this.logger.verbose('Fetching mico package versions from manifest...');
|
|
176
166
|
|
|
177
|
-
const
|
|
178
|
-
const
|
|
167
|
+
const micoVersions = await getMicoTemplateVersions(cliRoot, 8000);
|
|
168
|
+
const ui = micoVersions['@mico-platform/ui'];
|
|
169
|
+
const theme = micoVersions['@mico-platform/theme'];
|
|
179
170
|
|
|
180
|
-
this.logger.verbose('@mico-platform/ui
|
|
181
|
-
this.logger.verbose('@mico-platform/theme
|
|
171
|
+
this.logger.verbose('@mico-platform/ui range:', ui.range, 'resolved:', ui.resolved);
|
|
172
|
+
this.logger.verbose('@mico-platform/theme range:', theme.range, 'resolved:', theme.resolved);
|
|
182
173
|
|
|
183
174
|
// 构建 CDN 路径片段:如果有 prefix 则加上斜杠前缀,否则为空
|
|
184
175
|
const cdnPrefixPath = this.cdnPrefix ? `${this.cdnPrefix}/` : '';
|
|
@@ -191,8 +182,8 @@ module.exports = class extends Generator {
|
|
|
191
182
|
appId: this.appId,
|
|
192
183
|
packageScope: this.packageScope,
|
|
193
184
|
author: this.author,
|
|
194
|
-
micoUiVersion: `^${
|
|
195
|
-
themeVersion: `^${
|
|
185
|
+
micoUiVersion: `^${ui.resolved}`,
|
|
186
|
+
themeVersion: `^${theme.resolved}`,
|
|
196
187
|
cdnPrefix: this.cdnPrefix,
|
|
197
188
|
cdnPrefixPath, // 用于拼接路径,已包含尾部斜杠
|
|
198
189
|
intlTag: this.intlTag,
|
|
@@ -25,6 +25,8 @@ const config: ReturnType<typeof defineConfig> = {
|
|
|
25
25
|
logo: '',
|
|
26
26
|
apiBaseUrl: '',
|
|
27
27
|
defaultPath: '',
|
|
28
|
+
// 布局模式, 默认 'side',可选 'mix'
|
|
29
|
+
// layoutMode: 'mix',
|
|
28
30
|
// 免认证路由(跳过 SSO 登录),支持 /* 前缀匹配
|
|
29
31
|
// noAuthRouteList: ['/*'],
|
|
30
32
|
// 免权限校验路由(跳过菜单权限检查),与 mock pages 中 accessControlEnabled 示例一致时可按需配置
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# 头部一级导航布局(layoutMode: mix)
|
|
2
|
+
|
|
3
|
+
> 创建时间:2026-06-15
|
|
4
|
+
|
|
5
|
+
## 功能概述
|
|
6
|
+
|
|
7
|
+
为主布局新增可配置的 **mix 布局模式**:将菜单树的**一级目录**渲染为头部 Tab,对应一级目录下的**子级目录**渲染在侧边栏。切换头部 Tab 时侧边栏跟随变化为对应一级目录的子树。通过 `window.__MICO_CONFIG__.layoutMode` 开关控制,默认 `side`(经典左侧完整目录树),不配置时行为与旧版完全一致。
|
|
8
|
+
|
|
9
|
+
## 技术方案
|
|
10
|
+
|
|
11
|
+
### 技术栈
|
|
12
|
+
|
|
13
|
+
- 框架:React 18 + @umijs/max
|
|
14
|
+
- UI 组件:@mico-platform/ui(`Tabs` 一级导航)
|
|
15
|
+
- 状态管理:完全由路由 `location.pathname` 驱动,**无新增全局状态**
|
|
16
|
+
|
|
17
|
+
### 核心实现
|
|
18
|
+
|
|
19
|
+
1. **配置开关**:`isMixLayout()` 读取 `window.__MICO_CONFIG__.layoutMode`,仅 `'mix'` 时启用新布局。
|
|
20
|
+
2. **数据共用**:抽取 `useFilteredMenuItems()`,统一「权限过滤 + 解析」后的菜单树,供头部一级导航与侧边栏共用,避免重复解析。
|
|
21
|
+
3. **激活一级推导**:`getActiveTopKey(items, pathname)` 复用 `findMenuKeyByPath` 的祖先链 `openKeys[0]` 推导当前激活的一级 key;未命中(403/404、首页、仅在 PAGES 不在 MENUS 的页面)返回 `undefined`,不高亮、不渲染子树。
|
|
22
|
+
4. **头部一级导航**:mix 模式下渲染顶层菜单项为 `Tabs`,高亮跟随路由;点击 Tab 经 `findFirstLeaf` 取该一级下第一个可跳转叶子——`page` 走 `history.push`,`link` 走 `window.open`。
|
|
23
|
+
5. **侧边栏子树**:mix 模式下侧边栏只渲染激活一级的 `children`;该一级**无子目录时隐藏侧边栏**(`--sider-width` 置 0,内容区占满)。
|
|
24
|
+
|
|
25
|
+
### 数据流
|
|
26
|
+
|
|
27
|
+
```
|
|
28
|
+
location.pathname
|
|
29
|
+
│
|
|
30
|
+
├── 头部:getActiveTopKey(全树, pathname) → 高亮对应一级 Tab
|
|
31
|
+
│ 点击 Tab → findFirstLeaf(一级) → page: history.push / link: window.open
|
|
32
|
+
│
|
|
33
|
+
└── 侧边栏:getActiveTopKey(全树, pathname) → 取该一级 children 渲染
|
|
34
|
+
children 为空 → hideSider(--sider-width: 0px,不渲染 Sider)
|
|
35
|
+
|
|
36
|
+
切 Tab = 路由跳转 → pathname 变化 → 头部高亮与侧边栏子树同步刷新
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### 兼容策略
|
|
40
|
+
|
|
41
|
+
```
|
|
42
|
+
isMixLayout() === false(默认 / layoutMode 未配置 / = 'side')
|
|
43
|
+
├── 头部:不渲染一级导航(Logo 保持 margin-right:auto 原布局)
|
|
44
|
+
└── 侧边栏:渲染完整菜单树,hideSider 恒为 false ← 与旧版完全一致
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## 文件清单
|
|
48
|
+
|
|
49
|
+
### 新增文件
|
|
50
|
+
|
|
51
|
+
| 文件路径 | 说明 |
|
|
52
|
+
| --- | --- |
|
|
53
|
+
| `src/hooks/useFilteredMenuItems.ts` | 权限过滤 + 解析后的菜单树 Hook,头部与侧边栏共用 |
|
|
54
|
+
|
|
55
|
+
### 修改文件
|
|
56
|
+
|
|
57
|
+
| 文件路径 | 修改内容 |
|
|
58
|
+
| --- | --- |
|
|
59
|
+
| `src/common/menu/types.ts` | `__MICO_CONFIG__` 新增 `layoutMode?: 'side' \| 'mix'` |
|
|
60
|
+
| `src/constants/index.ts` | 新增 `TLayoutMode`、`getLayoutMode()`、`isMixLayout()` |
|
|
61
|
+
| `src/common/menu/parser.ts` | 新增 `getActiveTopKey()`、`findFirstLeaf()` |
|
|
62
|
+
| `src/layouts/components/header/index.tsx` | mix 模式渲染一级 `Tabs`,按叶子类型分流跳转;mix 时根节点加 `layout-header--mix` 类 |
|
|
63
|
+
| `src/layouts/components/header/index.less` | 一级导航 Tab 样式(对齐设计稿);mix 模式 Logo 列宽对齐侧边栏 |
|
|
64
|
+
| `src/layouts/components/menu/index.tsx` | 改用 `useFilteredMenuItems()`;mix 模式渲染激活一级子树;无子目录时隐藏 Sider |
|
|
65
|
+
| `config/config.dev.ts` | `__MICO_CONFIG__` 增加 `layoutMode` 注释示例 |
|
|
66
|
+
|
|
67
|
+
## API / 组件接口
|
|
68
|
+
|
|
69
|
+
### 配置项
|
|
70
|
+
|
|
71
|
+
```typescript
|
|
72
|
+
interface Window {
|
|
73
|
+
__MICO_CONFIG__?: {
|
|
74
|
+
/**
|
|
75
|
+
* 布局模式
|
|
76
|
+
* - 'side'(默认):经典左侧完整目录树
|
|
77
|
+
* - 'mix':一级目录在头部 Tab,子级目录在侧边栏
|
|
78
|
+
*/
|
|
79
|
+
layoutMode?: 'side' | 'mix';
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### 常量函数(`constants/index.ts`)
|
|
85
|
+
|
|
86
|
+
```typescript
|
|
87
|
+
type TLayoutMode = 'side' | 'mix';
|
|
88
|
+
|
|
89
|
+
/** 读取布局模式,未配置默认 'side' */
|
|
90
|
+
function getLayoutMode(): TLayoutMode;
|
|
91
|
+
|
|
92
|
+
/** 是否为「头部一级 + 侧边二级」混合布局 */
|
|
93
|
+
function isMixLayout(): boolean;
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### 菜单解析函数(`common/menu/parser.ts`)
|
|
97
|
+
|
|
98
|
+
```typescript
|
|
99
|
+
/** 根据当前路径推导激活的一级菜单 key;未命中返回 undefined */
|
|
100
|
+
function getActiveTopKey(
|
|
101
|
+
items: ParsedMenuItem[],
|
|
102
|
+
pathname: string,
|
|
103
|
+
): string | undefined;
|
|
104
|
+
|
|
105
|
+
/** 查找一级菜单项下第一个可跳转的叶子节点(DFS,跳过 group 容器) */
|
|
106
|
+
function findFirstLeaf(item: ParsedMenuItem): ParsedMenuItem | undefined;
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### 共用 Hook(`hooks/useFilteredMenuItems.ts`)
|
|
110
|
+
|
|
111
|
+
```typescript
|
|
112
|
+
/** 权限过滤 + 解析后的菜单树,供侧边栏与头部一级导航共用 */
|
|
113
|
+
function useFilteredMenuItems(): ParsedMenuItem[];
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## 使用示例
|
|
117
|
+
|
|
118
|
+
中台注入的 `window.__MICO_CONFIG__` 中开启 mix 布局:
|
|
119
|
+
|
|
120
|
+
```html
|
|
121
|
+
<script>
|
|
122
|
+
window.__MICO_CONFIG__ = {
|
|
123
|
+
appName: '示例平台',
|
|
124
|
+
layoutMode: 'mix', // 不配置或 'side' 即经典左侧目录树
|
|
125
|
+
};
|
|
126
|
+
</script>
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
或在本地开发 `config/config.dev.ts` 的 `define` 中开启:
|
|
130
|
+
|
|
131
|
+
```typescript
|
|
132
|
+
'window.__MICO_CONFIG__': {
|
|
133
|
+
layoutMode: 'mix',
|
|
134
|
+
},
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
## 设计决策
|
|
138
|
+
|
|
139
|
+
| 决策点 | 选择 | 理由 |
|
|
140
|
+
| --- | --- | --- |
|
|
141
|
+
| 激活态来源 | 完全由 `location.pathname` 推导 | 与 `AppTabs`、`useMenuState` 既有范式一致,零新增全局状态,路由天然驱动头部与侧栏同步 |
|
|
142
|
+
| 数据共用 | 抽取 `useFilteredMenuItems()` | 头部与侧栏共用同一份过滤解析结果,避免重复解析与权限逻辑漂移 |
|
|
143
|
+
| 一级导航组件 | `Tabs`(line 型) | 与设计稿一致,自动继承主题/国际化;与内容区多页签 `AppTabs` 功能不冲突,二者并存 |
|
|
144
|
+
| Tab 点击跳转 | `findFirstLeaf` 按类型分流 | `page` 用 `history.push`,`link` 用 `window.open`,避免外链 URL 被推进路由 |
|
|
145
|
+
| 未命中路由 | `getActiveTopKey` 返回 `undefined` | 403/404、首页等不误高亮、不渲染无关子树 |
|
|
146
|
+
| 无子目录的一级 | 隐藏 Sider + `--sider-width: 0` | 内容区占满,避免空侧栏占位 |
|
|
147
|
+
| Logo 列宽对齐 | 仅 `.layout-header--mix` 生效 | mix 下使一级导航起点与内容区左边界对齐;不影响 side 模式原布局 |
|
|
148
|
+
|
|
149
|
+
## 已知限制与待改进
|
|
150
|
+
|
|
151
|
+
- mix 模式下若某一级目录及其子级**全部为 group 容器**(无 page/link 叶子),点击该 Tab 不会触发跳转(`findFirstLeaf` 返回空)。
|
|
152
|
+
- Logo 列宽按侧边栏展开态(`@sidebar-width`)对齐;侧栏折叠/隐藏时不会同步调整,对齐仅在展开态成立。
|
|
153
|
+
- 头部一级 Tab 暂未处理 `disabled` 态(一级导航不会出现禁用项,故未实现)。
|
|
154
|
+
|
|
155
|
+
## 注意事项
|
|
156
|
+
|
|
157
|
+
- 默认 `side` 模式与旧版行为完全一致,升级零配置。
|
|
158
|
+
- mix 模式不改动路由注册、权限校验、qiankun 加载逻辑,仅影响菜单的「呈现位置」。
|
|
159
|
+
- 头部一级导航与内容区多页签 `AppTabs` 并存、互不冲突。
|
|
160
|
+
- 切换头部 Tab 本质是路由跳转;若当前已在该一级下,点击同一 Tab 不会重复跳转(`Tabs` 仅在 activeTab 变化时触发 `onChange`)。
|
|
161
|
+
|
|
162
|
+
## 相关文档
|
|
163
|
+
|
|
164
|
+
- [路由与菜单解耦](./feature-路由与菜单解耦.md) - PAGES/MENUS 数据源、`getActiveTopKey` 依赖的菜单解析
|
|
165
|
+
- [菜单权限控制](./feature-菜单权限控制.md) - `useFilteredMenuItems` 复用的权限过滤逻辑
|
|
166
|
+
- [主题色切换](./feature-主题色切换.md) - 一级导航 Tab 样式使用的主题变量
|
|
@@ -36,6 +36,7 @@ import {
|
|
|
36
36
|
clearRedirectCount,
|
|
37
37
|
ensureSsoSession,
|
|
38
38
|
handleAuthFailureRedirect,
|
|
39
|
+
SsoNetworkError,
|
|
39
40
|
} from './common/request/sso';
|
|
40
41
|
import { initTheme } from './common/theme';
|
|
41
42
|
import MicroAppLoader from './components/MicroAppLoader';
|
|
@@ -220,7 +221,19 @@ export async function getInitialState(): Promise<{
|
|
|
220
221
|
|
|
221
222
|
// 非免认证路由:走 SSO 流程
|
|
222
223
|
if (!skipAuth) {
|
|
223
|
-
|
|
224
|
+
try {
|
|
225
|
+
await ensureSsoSession();
|
|
226
|
+
} catch (error) {
|
|
227
|
+
// 仅 ticket 换 token 阶段的网络异常会抛出 SsoNetworkError,
|
|
228
|
+
// 在此显式拿到信号并触发带网络异常文案的兜底弹窗。
|
|
229
|
+
if (error instanceof SsoNetworkError) {
|
|
230
|
+
handleAuthFailureRedirect({ isNetworkError: true });
|
|
231
|
+
return {
|
|
232
|
+
fetchUserInfo: fetchUserInfoFn,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
throw error;
|
|
236
|
+
}
|
|
224
237
|
}
|
|
225
238
|
|
|
226
239
|
// 仅在「需认证」路径且本地已有 token 时拉取用户信息(登录态刷新等)。
|
|
@@ -239,7 +252,12 @@ export async function getInitialState(): Promise<{
|
|
|
239
252
|
|
|
240
253
|
// 非免认证路由且没有 token,跳转到 SSO 登录
|
|
241
254
|
if (!skipAuth) {
|
|
242
|
-
handleAuthFailureRedirect();
|
|
255
|
+
const redirected = handleAuthFailureRedirect();
|
|
256
|
+
if (redirected) {
|
|
257
|
+
// 阻断渲染,等待浏览器导航卸载页面,
|
|
258
|
+
// 避免页面组件抢先发起被 401 的请求(如 Home 的业务系统列表)
|
|
259
|
+
await new Promise<never>(() => {});
|
|
260
|
+
}
|
|
243
261
|
// 返回空状态,页面会被重定向
|
|
244
262
|
return {
|
|
245
263
|
fetchUserInfo: fetchUserInfoFn,
|
|
@@ -23,7 +23,7 @@ function readVerboseDebugQueryOnce(): boolean {
|
|
|
23
23
|
}
|
|
24
24
|
try {
|
|
25
25
|
const v = new URLSearchParams(window.location.search).get(DEBUG_LOGS_QUERY_KEY);
|
|
26
|
-
if (v
|
|
26
|
+
if (v === null || v === '') {
|
|
27
27
|
return false;
|
|
28
28
|
}
|
|
29
29
|
const normalized = v.toLowerCase();
|
|
@@ -440,6 +440,42 @@ export const findRouteByPath = (
|
|
|
440
440
|
return exact || bestWildcard?.route;
|
|
441
441
|
};
|
|
442
442
|
|
|
443
|
+
/**
|
|
444
|
+
* 查找一级菜单项下第一个可跳转的叶子节点
|
|
445
|
+
* 深度优先,跳过 group 容器,返回第一个带 path 的 page/link 节点
|
|
446
|
+
*/
|
|
447
|
+
export const findFirstLeaf = (
|
|
448
|
+
item: ParsedMenuItem,
|
|
449
|
+
): ParsedMenuItem | undefined => {
|
|
450
|
+
if (item.type !== 'group' && item.path) {
|
|
451
|
+
return item;
|
|
452
|
+
}
|
|
453
|
+
if (item.children && item.children.length > 0) {
|
|
454
|
+
for (const child of item.children) {
|
|
455
|
+
const found = findFirstLeaf(child);
|
|
456
|
+
if (found) return found;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
return undefined;
|
|
460
|
+
};
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* 根据当前路径推导激活的一级菜单 key
|
|
464
|
+
* - 命中某个菜单项时,取其祖先链顶端(openKeys[0]);自身即一级时取自身 key
|
|
465
|
+
* - 未命中时返回 undefined,不高亮任何一级(如 403/404、首页、仅在 PAGES 不在 MENUS 的页面)
|
|
466
|
+
*/
|
|
467
|
+
export const getActiveTopKey = (
|
|
468
|
+
items: ParsedMenuItem[],
|
|
469
|
+
pathname: string,
|
|
470
|
+
): string | undefined => {
|
|
471
|
+
const result = findMenuKeyByPath(items, pathname);
|
|
472
|
+
if (result) {
|
|
473
|
+
return result.openKeys[0] ?? result.key;
|
|
474
|
+
}
|
|
475
|
+
// 未命中菜单树(如 403/404、首页、仅在 PAGES 不在 MENUS 的页面):不高亮任何一级
|
|
476
|
+
return undefined;
|
|
477
|
+
};
|
|
478
|
+
|
|
443
479
|
/**
|
|
444
480
|
* 根据路径查找对应的菜单项 key
|
|
445
481
|
*/
|
|
@@ -198,6 +198,12 @@ declare global {
|
|
|
198
198
|
>;
|
|
199
199
|
/** 默认重定向路径,访问 "/" 时自动跳转到此路径 */
|
|
200
200
|
defaultPath?: string;
|
|
201
|
+
/**
|
|
202
|
+
* 布局模式
|
|
203
|
+
* - 'side'(默认):经典左侧完整目录树
|
|
204
|
+
* - 'mix':一级目录显示在头部 Tab,对应一级下的子级目录显示在侧边栏
|
|
205
|
+
*/
|
|
206
|
+
layoutMode?: 'side' | 'mix';
|
|
201
207
|
/**
|
|
202
208
|
* 免认证路由列表(跳过 SSO 登录)
|
|
203
209
|
* 支持精确匹配和前缀匹配(以 /* 结尾)
|
|
@@ -36,7 +36,7 @@ import {
|
|
|
36
36
|
} from './interceptors';
|
|
37
37
|
|
|
38
38
|
// SSO 相关
|
|
39
|
-
import { handleAuthFailureRedirect } from './sso';
|
|
39
|
+
import { handleAuthFailureRedirect, isRedirecting } from './sso';
|
|
40
40
|
|
|
41
41
|
// Token 刷新相关
|
|
42
42
|
import {
|
|
@@ -74,6 +74,12 @@ export const request = async <T = unknown>(
|
|
|
74
74
|
url: string,
|
|
75
75
|
options?: UnifiedRequestOptions,
|
|
76
76
|
): Promise<T> => {
|
|
77
|
+
// 正在执行 SSO 硬跳转时,挂起任何在途请求,
|
|
78
|
+
// 避免在浏览器导航卸载前发出被 401 的请求,进而触发重复 toast 与二次跳转
|
|
79
|
+
if (isRedirecting()) {
|
|
80
|
+
return new Promise<T>(() => {});
|
|
81
|
+
}
|
|
82
|
+
|
|
77
83
|
const resolvedUrl = resolveRequestUrl(url, options);
|
|
78
84
|
|
|
79
85
|
const ctx: RequestContext = {
|
|
@@ -169,7 +175,7 @@ export const registerRequestInterceptor = addRequestInterceptor;
|
|
|
169
175
|
export const registerResponseInterceptor = addResponseInterceptor;
|
|
170
176
|
|
|
171
177
|
// 导出认证相关方法
|
|
172
|
-
export { handleAuthFailureRedirect };
|
|
178
|
+
export { handleAuthFailureRedirect, SsoNetworkError } from './sso';
|
|
173
179
|
|
|
174
180
|
// 导出类型
|
|
175
181
|
export type {
|
|
@@ -13,6 +13,7 @@ import { isNoAuthRoute, ROUTES } from '@/constants';
|
|
|
13
13
|
import { captureError } from '@common-web/sentry';
|
|
14
14
|
import { formatLayoutMessage } from '@/common/intl';
|
|
15
15
|
import { Modal } from '@mico-platform/ui';
|
|
16
|
+
import React from 'react';
|
|
16
17
|
import {
|
|
17
18
|
getTicketParam,
|
|
18
19
|
resolveAuthToken,
|
|
@@ -36,6 +37,38 @@ let tokenAcquiredAt: number | null = null;
|
|
|
36
37
|
/** 登录失败弹窗是否已显示,防止多个请求同时失败时重复弹出 */
|
|
37
38
|
let redirectLimitModalShown = false;
|
|
38
39
|
|
|
40
|
+
/** 是否正在执行 SSO 硬跳转,置位后用于阻断渲染与拦截在途请求 */
|
|
41
|
+
let redirecting = false;
|
|
42
|
+
|
|
43
|
+
/** 是否正在执行 SSO 硬跳转 */
|
|
44
|
+
export const isRedirecting = (): boolean => redirecting;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* ticket 换 token 阶段的网络层故障(Failed to fetch / NetworkError 等)。
|
|
48
|
+
* 由 ensureSsoSession() 在 catch 阶段抛出,供调用方识别是否为网络异常,
|
|
49
|
+
* 避免使用模块级 mutable 状态在函数间传递信号。
|
|
50
|
+
*/
|
|
51
|
+
export class SsoNetworkError extends Error {
|
|
52
|
+
constructor(
|
|
53
|
+
message: string,
|
|
54
|
+
public readonly cause?: unknown,
|
|
55
|
+
) {
|
|
56
|
+
super(message);
|
|
57
|
+
this.name = 'SsoNetworkError';
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** 判断错误是否为网络层故障(无法建立连接,而非 HTTP 错误响应) */
|
|
62
|
+
const isNetworkError = (err: Error): boolean => {
|
|
63
|
+
const msg = err.message.toLowerCase();
|
|
64
|
+
return (
|
|
65
|
+
msg.includes('failed to fetch') ||
|
|
66
|
+
msg.includes('networkerror') ||
|
|
67
|
+
msg.includes('network error') ||
|
|
68
|
+
msg.includes('load failed')
|
|
69
|
+
);
|
|
70
|
+
};
|
|
71
|
+
|
|
39
72
|
/**
|
|
40
73
|
* 执行 SSO 重定向跳转,回跳地址中固定携带 redirect_count=1
|
|
41
74
|
*/
|
|
@@ -49,6 +82,9 @@ const performSsoRedirect = (): void => {
|
|
|
49
82
|
redirectUrl.searchParams.delete('ticket');
|
|
50
83
|
const serviceUrl = redirectUrl.toString();
|
|
51
84
|
|
|
85
|
+
// 在赋值 location.href 之前置位,浏览器导航期间用于阻断渲染与拦截在途请求
|
|
86
|
+
redirecting = true;
|
|
87
|
+
|
|
52
88
|
window.location.href = `${
|
|
53
89
|
externalLoginPath ?? ROUTES.LOGIN
|
|
54
90
|
}?service=${encodeURIComponent(serviceUrl)}`;
|
|
@@ -56,21 +92,28 @@ const performSsoRedirect = (): void => {
|
|
|
56
92
|
|
|
57
93
|
/**
|
|
58
94
|
* 处理认证失败后的重定向
|
|
95
|
+
*
|
|
96
|
+
* @param options.isNetworkError 是否由 ticket 换 token 阶段的网络异常触发,
|
|
97
|
+
* true 时弹窗展示 IOA/网络排障指引;其它认证失败入口(401 重试失败、登出等)
|
|
98
|
+
* 不传或传 false,使用默认「自动登录失败」文案。
|
|
59
99
|
*/
|
|
60
|
-
export const handleAuthFailureRedirect = (
|
|
61
|
-
|
|
100
|
+
export const handleAuthFailureRedirect = (
|
|
101
|
+
options: { isNetworkError?: boolean } = {},
|
|
102
|
+
): boolean => {
|
|
103
|
+
const { isNetworkError: isNetworkErr = false } = options;
|
|
104
|
+
if (typeof window === 'undefined') return false;
|
|
62
105
|
|
|
63
106
|
const authCheckPath = resolveAuthCheckPath(window.location.pathname);
|
|
64
107
|
|
|
65
108
|
// 与 getInitialState 一致:用 authCheckPath 判断,避免「/ + defaultPath」首屏仍按 / 误判为免认证
|
|
66
109
|
if (isNoAuthRoute(authCheckPath)) {
|
|
67
110
|
console.log('[SSO] 当前路由在免认证列表中,跳过 SSO 重定向', { authCheckPath });
|
|
68
|
-
return;
|
|
111
|
+
return false;
|
|
69
112
|
}
|
|
70
113
|
|
|
71
114
|
if (isPageAuthFree(authCheckPath)) {
|
|
72
115
|
console.log('[SSO] 页面 accessControlEnabled=false,跳过 SSO 重定向', { authCheckPath });
|
|
73
|
-
return;
|
|
116
|
+
return false;
|
|
74
117
|
}
|
|
75
118
|
|
|
76
119
|
// 从 URL 中获取当前 redirect 登录的次数
|
|
@@ -85,7 +128,7 @@ export const handleAuthFailureRedirect = (): void => {
|
|
|
85
128
|
// 如果 redirect 次数小于 1 次,则执行自动 redirect 跳转登录
|
|
86
129
|
if (redirectCount < 1) {
|
|
87
130
|
performSsoRedirect();
|
|
88
|
-
return;
|
|
131
|
+
return true;
|
|
89
132
|
}
|
|
90
133
|
|
|
91
134
|
// redirect 次数已达上限,停止自动重定向,记录日志并弹窗询问用户是否手动重试
|
|
@@ -105,18 +148,69 @@ export const handleAuthFailureRedirect = (): void => {
|
|
|
105
148
|
? new Date(tokenAcquiredAt).toISOString()
|
|
106
149
|
: null,
|
|
107
150
|
tokenAgeSeconds: tokenAge,
|
|
151
|
+
isNetworkError: isNetworkErr,
|
|
108
152
|
},
|
|
109
153
|
});
|
|
110
154
|
|
|
111
155
|
if (redirectLimitModalShown) {
|
|
112
156
|
console.log('[SSO] 登录失败弹窗已显示,跳过重复弹出');
|
|
113
|
-
return;
|
|
157
|
+
return false;
|
|
114
158
|
}
|
|
115
159
|
|
|
116
160
|
redirectLimitModalShown = true;
|
|
161
|
+
const secondaryStyle: React.CSSProperties = {
|
|
162
|
+
marginTop: 8,
|
|
163
|
+
color: 'var(--color-text-3, #86909c)',
|
|
164
|
+
fontSize: 13,
|
|
165
|
+
};
|
|
166
|
+
const modalContent = isNetworkErr
|
|
167
|
+
? React.createElement(
|
|
168
|
+
'div',
|
|
169
|
+
null,
|
|
170
|
+
React.createElement(
|
|
171
|
+
'div',
|
|
172
|
+
null,
|
|
173
|
+
formatLayoutMessage({
|
|
174
|
+
id: 'sso_auth_failure_network_primary',
|
|
175
|
+
defaultMessage: '网络异常,无法完成自动登录',
|
|
176
|
+
}),
|
|
177
|
+
),
|
|
178
|
+
React.createElement(
|
|
179
|
+
'div',
|
|
180
|
+
{ style: secondaryStyle },
|
|
181
|
+
React.createElement(
|
|
182
|
+
'div',
|
|
183
|
+
null,
|
|
184
|
+
formatLayoutMessage({
|
|
185
|
+
id: 'sso_auth_failure_network_hint_title',
|
|
186
|
+
defaultMessage: '请检查以下情况:',
|
|
187
|
+
}),
|
|
188
|
+
),
|
|
189
|
+
React.createElement(
|
|
190
|
+
'div',
|
|
191
|
+
null,
|
|
192
|
+
formatLayoutMessage({
|
|
193
|
+
id: 'sso_auth_failure_network_hint_ioa',
|
|
194
|
+
defaultMessage: '1. IOA 是否已连接',
|
|
195
|
+
}),
|
|
196
|
+
),
|
|
197
|
+
React.createElement(
|
|
198
|
+
'div',
|
|
199
|
+
null,
|
|
200
|
+
formatLayoutMessage({
|
|
201
|
+
id: 'sso_auth_failure_network_hint_network',
|
|
202
|
+
defaultMessage: '2. 当前网络是否可正常访问',
|
|
203
|
+
}),
|
|
204
|
+
),
|
|
205
|
+
),
|
|
206
|
+
)
|
|
207
|
+
: formatLayoutMessage({
|
|
208
|
+
id: 'sso_auth_failure_modal_content',
|
|
209
|
+
defaultMessage: '自动登录失败,是否重新尝试登录?',
|
|
210
|
+
});
|
|
117
211
|
Modal.confirm({
|
|
118
212
|
title: formatLayoutMessage({ id: 'sso_auth_failure_modal_title', defaultMessage: '登录提示' }),
|
|
119
|
-
content:
|
|
213
|
+
content: modalContent,
|
|
120
214
|
okText: formatLayoutMessage({ id: 'sso_auth_failure_modal_ok', defaultMessage: '重新登录' }),
|
|
121
215
|
cancelText: formatLayoutMessage({ id: 'sso_auth_failure_modal_cancel', defaultMessage: '取消' }),
|
|
122
216
|
onOk: () => {
|
|
@@ -128,6 +222,9 @@ export const handleAuthFailureRedirect = (): void => {
|
|
|
128
222
|
redirectLimitModalShown = false;
|
|
129
223
|
},
|
|
130
224
|
});
|
|
225
|
+
|
|
226
|
+
// 仅弹出 Modal 询问,未执行硬跳转
|
|
227
|
+
return false;
|
|
131
228
|
};
|
|
132
229
|
|
|
133
230
|
/**
|
|
@@ -180,12 +277,32 @@ export const ensureSsoSession = async (): Promise<void> => {
|
|
|
180
277
|
throw new Error('SSO 认证失败');
|
|
181
278
|
}
|
|
182
279
|
} catch (error) {
|
|
183
|
-
|
|
280
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
281
|
+
const networkError = isNetworkError(err);
|
|
282
|
+
console.error('[SSO] ticket 交换失败', err.message, err.stack);
|
|
283
|
+
captureError(err, {
|
|
284
|
+
tags: {
|
|
285
|
+
scene: 'sso_ticket_exchange_failed',
|
|
286
|
+
is_network_error: String(networkError),
|
|
287
|
+
},
|
|
288
|
+
extra: {
|
|
289
|
+
ticketParam,
|
|
290
|
+
message: err.message,
|
|
291
|
+
},
|
|
292
|
+
});
|
|
184
293
|
setFetchingToken(false);
|
|
185
294
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
295
|
+
const propagatedErr = networkError
|
|
296
|
+
? new SsoNetworkError(err.message, err)
|
|
297
|
+
: err;
|
|
298
|
+
rejectPendingRequests(propagatedErr);
|
|
299
|
+
|
|
300
|
+
// 仅在网络异常时向外抛出,让调用方据此切换 UI 文案;
|
|
301
|
+
// 其它失败(如 SSO 业务态失败)保持原有「静默」语义,由调用方
|
|
302
|
+
// 通过 token 是否就绪判断后续走向。
|
|
303
|
+
if (networkError) {
|
|
304
|
+
throw propagatedErr;
|
|
305
|
+
}
|
|
189
306
|
} finally {
|
|
190
307
|
removeParamFromUrl(ticketParam);
|
|
191
308
|
ticketPromise = null;
|
package/generators/micro-react/templates/apps/layout/src/components/MicroAppLoader/index.tsx
CHANGED
|
@@ -115,8 +115,12 @@ const MicroAppLoader: React.FC<MicroAppLoaderProps> = ({
|
|
|
115
115
|
current_tenant: currentUser?.current_tenant,
|
|
116
116
|
allowed_tenants: allowedTenants,
|
|
117
117
|
};
|
|
118
|
-
|
|
119
|
-
|
|
118
|
+
console.log('[MicroAppLoader] buildProps', {
|
|
119
|
+
appName,
|
|
120
|
+
...Object.fromEntries(
|
|
121
|
+
Object.entries(props).filter(([key]) => key !== 'request'),
|
|
122
|
+
),
|
|
123
|
+
});
|
|
120
124
|
return props;
|
|
121
125
|
}, [base, env, routePath, currentUser, appName]);
|
|
122
126
|
|
|
@@ -132,6 +132,28 @@ export const isAuthDisabled = (): boolean => {
|
|
|
132
132
|
return window.__MICO_CONFIG__?.disableAuth === true;
|
|
133
133
|
};
|
|
134
134
|
|
|
135
|
+
/**
|
|
136
|
+
* 布局模式
|
|
137
|
+
* - 'side':经典左侧完整目录树
|
|
138
|
+
* - 'mix':一级目录显示在头部 Tab,对应子级目录显示在侧边栏
|
|
139
|
+
*/
|
|
140
|
+
export type TLayoutMode = 'side' | 'mix';
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* 获取布局模式
|
|
144
|
+
* 读取 window.__MICO_CONFIG__.layoutMode,未配置时默认 'side'(兼容旧版)
|
|
145
|
+
*/
|
|
146
|
+
export const getLayoutMode = (): TLayoutMode => {
|
|
147
|
+
return window.__MICO_CONFIG__?.layoutMode === 'mix' ? 'mix' : 'side';
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* 判断是否为「头部一级 + 侧边二级」混合布局
|
|
152
|
+
*/
|
|
153
|
+
export const isMixLayout = (): boolean => {
|
|
154
|
+
return getLayoutMode() === 'mix';
|
|
155
|
+
};
|
|
156
|
+
|
|
135
157
|
/**
|
|
136
158
|
* 主题相关常量
|
|
137
159
|
*/
|