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.
Files changed (27) hide show
  1. package/README.md +6 -0
  2. package/generators/micro-react/README.md +1 -1
  3. package/generators/micro-react/index.js +9 -18
  4. package/generators/micro-react/templates/apps/layout/config/config.dev.ts +2 -0
  5. 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
  6. package/generators/micro-react/templates/apps/layout/src/app.tsx +20 -2
  7. package/generators/micro-react/templates/apps/layout/src/common/logger.ts +1 -1
  8. package/generators/micro-react/templates/apps/layout/src/common/menu/parser.ts +36 -0
  9. package/generators/micro-react/templates/apps/layout/src/common/menu/types.ts +6 -0
  10. package/generators/micro-react/templates/apps/layout/src/common/request/index.ts +8 -2
  11. package/generators/micro-react/templates/apps/layout/src/common/request/sso.ts +128 -11
  12. package/generators/micro-react/templates/apps/layout/src/components/MicroAppLoader/index.tsx +6 -2
  13. package/generators/micro-react/templates/apps/layout/src/constants/index.ts +22 -0
  14. package/generators/micro-react/templates/apps/layout/src/hooks/useFilteredMenuItems.ts +29 -0
  15. package/generators/micro-react/templates/apps/layout/src/layouts/components/header/index.less +73 -0
  16. package/generators/micro-react/templates/apps/layout/src/layouts/components/header/index.tsx +51 -4
  17. package/generators/micro-react/templates/apps/layout/src/layouts/components/menu/index.tsx +25 -18
  18. package/generators/micro-react/templates/apps/layout/src/locales/en-US.ts +4 -0
  19. package/generators/micro-react/templates/apps/layout/src/locales/zh-CN.ts +4 -0
  20. package/generators/micro-react/templates/apps/layout/src/services/config/index.ts +1 -0
  21. package/generators/micro-react/templates/packages/common-intl/src/intl.ts +20 -0
  22. package/generators/subapp-react/index.js +10 -19
  23. package/generators/subapp-react/templates/homepage/src/common/logger.ts +1 -1
  24. package/generators/subapp-umd/index.js +6 -0
  25. package/generators/subapp-umd/templates/package.json +1 -1
  26. package/lib/utils.js +222 -0
  27. 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 | `npm view @mico-platform/{ui,theme} version`,前缀 `^`,失败回退 `^1.0.0` |
34
+ | `micoUiVersion` / `themeVersion` | string | `^${micoPackages range resolve 的最新版本}` |
@@ -9,7 +9,7 @@ const {
9
9
  collectFiles,
10
10
  transformDestPath,
11
11
  isTemplateFile,
12
- getPackageVersionsParallel,
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 latest package versions (parallel)...');
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 micoUiVer = versions['@mico-platform/ui'];
178
- const themeVer = versions['@mico-platform/theme'];
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 version:', micoUiVer);
181
- this.logger.verbose('@mico-platform/theme version:', themeVer);
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: `^${micoUiVer}`,
195
- themeVersion: `^${themeVer}`,
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
- await ensureSsoSession();
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 == null || 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 = (): void => {
61
- if (typeof window === 'undefined') return;
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: formatLayoutMessage({ id: 'sso_auth_failure_modal_content', defaultMessage: '自动登录失败,是否重新尝试登录?' }),
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
- console.error('SSO ticket exchange failed', error);
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
- rejectPendingRequests(
187
- error instanceof Error ? error : new Error('SSO 认证失败'),
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;
@@ -115,8 +115,12 @@ const MicroAppLoader: React.FC<MicroAppLoaderProps> = ({
115
115
  current_tenant: currentUser?.current_tenant,
116
116
  allowed_tenants: allowedTenants,
117
117
  };
118
- const { request: _request, ...loggableProps } = props;
119
- console.log('[MicroAppLoader] buildProps', { appName, ...loggableProps });
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
  */