generator-mico-cli 0.1.29 → 0.2.2

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 (22) hide show
  1. package/generators/micro-react/templates/apps/layout/config/config.dev.ts +2 -1
  2. package/generators/micro-react/templates/apps/layout/config/routes.ts +5 -0
  3. package/generators/micro-react/templates/apps/layout/docs/arch-/350/257/267/346/261/202/346/250/241/345/235/227.md +1 -1
  4. 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
  5. package/generators/micro-react/templates/apps/layout/src/app.tsx +89 -17
  6. package/generators/micro-react/templates/apps/layout/src/common/menu/parser.ts +106 -1
  7. package/generators/micro-react/templates/apps/layout/src/common/menu/types.ts +2 -0
  8. package/generators/micro-react/templates/apps/layout/src/common/request/url-resolver.ts +23 -8
  9. package/generators/micro-react/templates/apps/layout/src/components/MicroAppLoader/container-manager.ts +152 -20
  10. package/generators/micro-react/templates/apps/layout/src/components/MicroAppLoader/index.tsx +100 -20
  11. package/generators/micro-react/templates/apps/layout/src/global.less +5 -1
  12. package/generators/micro-react/templates/apps/layout/src/hooks/useRoutePermissionRefresh.ts +69 -0
  13. package/generators/micro-react/templates/apps/layout/src/layouts/components/menu/index.tsx +11 -3
  14. package/generators/micro-react/templates/apps/layout/src/layouts/index.tsx +62 -8
  15. package/generators/micro-react/templates/apps/layout/src/pages/403/index.tsx +28 -0
  16. package/generators/micro-react/templates/apps/layout/src/pages/User/Login/index.tsx +6 -2
  17. package/generators/micro-react/templates/package.json +5 -0
  18. package/generators/subapp-react/templates/homepage/config/config.prod.development.ts +0 -4
  19. package/generators/subapp-react/templates/homepage/config/config.prod.testing.ts +0 -4
  20. package/generators/subapp-react/templates/homepage/config/config.prod.ts +2 -2
  21. package/generators/subapp-react/templates/homepage/config/config.ts +6 -0
  22. package/package.json +1 -1
@@ -23,6 +23,7 @@ const config: ReturnType<typeof defineConfig> = {
23
23
  window.__MICO_CONFIG__ = {
24
24
  appName: 'Mico Center',
25
25
  apiBaseUrl: '',
26
+ defaultPath: '',
26
27
  };
27
28
  `,
28
29
  },
@@ -47,7 +48,7 @@ const config: ReturnType<typeof defineConfig> = {
47
48
  define: {
48
49
  'process.env.NODE_ENV': 'development',
49
50
  'process.env.APP_ID': 'mibot_dev',
50
- 'process.env.API_BASE_URL': 'https://dashboard-api-test.micoplatform.com',
51
+ 'process.env.API_BASE_URL': '',
51
52
  'process.env.PROXY_SUFFIX': '/',
52
53
  'process.env.LOGIN_ENDPOINT':
53
54
  '',
@@ -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) - 用户数据结构
@@ -1,10 +1,8 @@
1
- import { history, type RequestConfig } from '@umijs/max';
2
- import { prefetchApps } from 'qiankun';
1
+ import { history, Navigate, type RequestConfig } from '@umijs/max';
2
+ import { addGlobalUncaughtErrorHandler, 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
 
@@ -23,6 +21,57 @@ import MicroAppLoader from './components/MicroAppLoader';
23
21
  import { NO_AUTH_ROUTE_LIST } from './constants';
24
22
  import './global.less';
25
23
 
24
+ // ==================== qiankun 全局错误处理 ====================
25
+ // 捕获子应用运行时未捕获的异常,防止页面崩溃
26
+
27
+ /**
28
+ * 注册 qiankun 全局未捕获错误处理器
29
+ * 处理以下场景:
30
+ * 1. 子应用 JS 运行时错误
31
+ * 2. 子应用生命周期钩子抛出的异常
32
+ * 3. 子应用资源加载失败
33
+ */
34
+ if (typeof window !== 'undefined') {
35
+ addGlobalUncaughtErrorHandler((event: Event | string) => {
36
+ // 提取错误信息
37
+ const error =
38
+ event instanceof ErrorEvent
39
+ ? event.error
40
+ : event instanceof PromiseRejectionEvent
41
+ ? event.reason
42
+ : event;
43
+
44
+ const errorMessage =
45
+ error instanceof Error
46
+ ? error.message
47
+ : typeof error === 'string'
48
+ ? error
49
+ : 'Unknown micro-app error';
50
+
51
+ console.error('[qiankun] Global uncaught error:', {
52
+ error,
53
+ message: errorMessage,
54
+ type: event instanceof Event ? event.type : 'unknown',
55
+ });
56
+
57
+ // 检查是否是子应用加载失败(资源 404 等)
58
+ const isLoadError =
59
+ errorMessage.includes('Failed to fetch') ||
60
+ errorMessage.includes('Loading chunk') ||
61
+ errorMessage.includes('load') ||
62
+ errorMessage.includes('Script error');
63
+
64
+ if (isLoadError) {
65
+ console.error(
66
+ '[qiankun] Micro-app resource loading failed. Please check if the micro-app server is running.',
67
+ );
68
+ }
69
+
70
+ // 注意:这里不阻止错误冒泡,让控制台仍能显示原始错误
71
+ // 如果需要阻止,可以 return true
72
+ });
73
+ }
74
+
26
75
  // ==================== 微应用预加载 ====================
27
76
  // 预加载所有微应用资源,避免快速切换时的竞态条件
28
77
  // 当资源已预加载时,loadMicroApp 的异步加载会快速完成,减少容器不存在的错误
@@ -124,21 +173,22 @@ export async function getInitialState(): Promise<{
124
173
  }
125
174
  };
126
175
 
127
- // 如果不是登录页面,执行
128
176
  const { location } = history;
129
- if (!NO_AUTH_ROUTE_LIST.includes(location.pathname)) {
130
- // 先确保 SSO 会话完成(如果 URL 中有 ticket 参数,会换取 token 并存储用户信息)
177
+ const isNoAuthRoute = NO_AUTH_ROUTE_LIST.includes(location.pathname);
178
+
179
+ // 非免认证路由:走 SSO 流程
180
+ if (!isNoAuthRoute) {
131
181
  await ensureSsoSession();
182
+ }
132
183
 
133
- // 有 token 时,获取用户信息
134
- if (getStoredAuthToken()) {
135
- const userInfo = await fetchUserInfoFn();
136
- if (userInfo) {
137
- return {
138
- fetchUserInfo: fetchUserInfoFn,
139
- currentUser: userInfo,
140
- };
141
- }
184
+ // 有 token 就获取用户信息(无论在哪个页面,支持登录后 refresh 场景)
185
+ if (getStoredAuthToken()) {
186
+ const userInfo = await fetchUserInfoFn();
187
+ if (userInfo) {
188
+ return {
189
+ fetchUserInfo: fetchUserInfoFn,
190
+ currentUser: userInfo,
191
+ };
142
192
  }
143
193
  }
144
194
 
@@ -147,6 +197,7 @@ export async function getInitialState(): Promise<{
147
197
  };
148
198
  }
149
199
 
200
+
150
201
  /**
151
202
  * @name request 配置,可以配置错误处理
152
203
  * 它基于 axios 和 ahooks 的 useRequest 提供了一套统一的网络请求和错误处理方案。
@@ -179,7 +230,11 @@ export function patchClientRoutes({ routes }: { routes: UmiRoute[] }) {
179
230
  const menus = getWindowMenus();
180
231
  const dynamicRoutes = extractRoutes(menus);
181
232
 
182
- console.log('[app.tsx] patchClientRoutes:', { menus, dynamicRoutes, routes });
233
+ console.log('[app.tsx] patchClientRoutes:', {
234
+ menus,
235
+ dynamicRoutes,
236
+ routes,
237
+ });
183
238
 
184
239
  // 找到根路由(全局布局)
185
240
  const rootRoute = routes.find((r) => r.path === '/');
@@ -222,6 +277,23 @@ export function patchClientRoutes({ routes }: { routes: UmiRoute[] }) {
222
277
  });
223
278
  }
224
279
 
280
+
281
+ /**
282
+ * @name 路由变化处理
283
+ * @description 处理 defaultPath 重定向:访问 "/" 时自动跳转到配置的默认路径
284
+ * @doc https://umijs.org/docs/api/runtime-config#onroutechange
285
+ */
286
+ export function onRouteChange({
287
+ location,
288
+ }: {
289
+ location: { pathname: string };
290
+ }) {
291
+ const defaultPath = window.__MICO_CONFIG__?.defaultPath;
292
+ if (location.pathname === '/' && defaultPath && defaultPath !== '/') {
293
+ history.replace(defaultPath);
294
+ }
295
+ }
296
+
225
297
  /**
226
298
  * @name qiankun 微前端子应用生命周期
227
299
  * @description 当应用作为 qiankun 子应用运行时,这些生命周期会被调用
@@ -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
 
@@ -155,6 +155,8 @@ declare global {
155
155
  __MICO_CONFIG__?: {
156
156
  appName?: string;
157
157
  apiBaseUrl?: string;
158
+ /** 默认重定向路径,访问 "/" 时自动跳转到此路径 */
159
+ defaultPath?: string;
158
160
  [key: string]: unknown;
159
161
  };
160
162
  __MICO_WORKSPACE__?: WorkspaceConfig | null;
@@ -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
  }