@teamix-evo/skills 0.3.0 → 0.4.0
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/manifest.json +61 -45
- package/package.json +2 -2
- package/{skills/teamix-evo-coding-conventions → src/teamix-evo-code-opentrek}/SKILL.md +18 -18
- package/{skills/teamix-evo-coding-conventions → src/teamix-evo-code-opentrek}/checklist.md +2 -2
- package/{skills/teamix-evo-coding-conventions → src/teamix-evo-code-opentrek}/reuse-first.md +25 -17
- package/src/teamix-evo-code-uni-manager/SKILL.md +95 -0
- package/src/teamix-evo-code-uni-manager/api-layering.md +370 -0
- package/src/teamix-evo-code-uni-manager/checklist.md +193 -0
- package/src/teamix-evo-code-uni-manager/error-and-loading.md +389 -0
- package/src/teamix-evo-code-uni-manager/file-structure.md +339 -0
- package/src/teamix-evo-code-uni-manager/forms-and-validation.md +459 -0
- package/src/teamix-evo-code-uni-manager/reuse-first.md +188 -0
- package/src/teamix-evo-code-uni-manager/routing-and-codesplit.md +450 -0
- package/src/teamix-evo-code-uni-manager/testing.md +396 -0
- package/src/teamix-evo-design-opentrek/SKILL.md +71 -0
- package/src/teamix-evo-design-opentrek/boundaries.md +513 -0
- package/src/teamix-evo-design-opentrek/brand.md +154 -0
- package/src/teamix-evo-design-opentrek/checklist.md +83 -0
- package/src/teamix-evo-design-opentrek/components.md +245 -0
- package/src/teamix-evo-design-opentrek/flows.md +51 -0
- package/src/teamix-evo-design-opentrek/foundations.md +271 -0
- package/src/teamix-evo-design-opentrek/generation-flow.md +185 -0
- package/src/teamix-evo-design-opentrek/patterns/dashboard.md +31 -0
- package/src/teamix-evo-design-opentrek/patterns/detail-page.md +202 -0
- package/src/teamix-evo-design-opentrek/patterns/form-page.md +289 -0
- package/src/teamix-evo-design-opentrek/patterns/list-page.md +334 -0
- package/src/teamix-evo-design-opentrek/patterns/page-types.md +154 -0
- package/src/teamix-evo-design-opentrek/philosophy.md +96 -0
- package/src/teamix-evo-design-opentrek/rules/README.md +39 -0
- package/src/teamix-evo-design-opentrek/rules/boundaries.rules.json +391 -0
- package/src/teamix-evo-design-uni-manager/SKILL.md +73 -0
- package/src/teamix-evo-design-uni-manager/boundaries.md +564 -0
- package/src/teamix-evo-design-uni-manager/brand.md +202 -0
- package/src/teamix-evo-design-uni-manager/checklist.md +115 -0
- package/src/teamix-evo-design-uni-manager/components.md +254 -0
- package/src/teamix-evo-design-uni-manager/flows.md +63 -0
- package/src/teamix-evo-design-uni-manager/foundations.md +258 -0
- package/src/teamix-evo-design-uni-manager/generation-flow.md +194 -0
- package/src/teamix-evo-design-uni-manager/patterns/dashboard.md +95 -0
- package/src/teamix-evo-design-uni-manager/patterns/detail-page.md +224 -0
- package/src/teamix-evo-design-uni-manager/patterns/form-page.md +329 -0
- package/src/teamix-evo-design-uni-manager/patterns/list-page.md +356 -0
- package/src/teamix-evo-design-uni-manager/patterns/page-types.md +165 -0
- package/src/teamix-evo-design-uni-manager/philosophy.md +106 -0
- package/src/teamix-evo-design-uni-manager/rules/README.md +49 -0
- package/src/teamix-evo-design-uni-manager/rules/boundaries.rules.json +418 -0
- package/src/teamix-evo-manage/SKILL.md +281 -0
- package/skills/teamix-evo-design-rules/SKILL.md +0 -86
- package/skills/teamix-evo-design-rules/boundaries.md +0 -89
- package/skills/teamix-evo-design-rules/checklist.md +0 -108
- package/skills/teamix-evo-design-rules/generation-flow.md +0 -142
- package/skills/teamix-evo-design-rules/prompts/page-design.md +0 -148
- package/skills/teamix-evo-design-rules-opentrek/SKILL.md +0 -48
- package/skills/teamix-evo-design-rules-opentrek/brand-rules.md +0 -74
- package/skills/teamix-evo-design-rules-uni-manager/SKILL.md +0 -51
- package/skills/teamix-evo-design-rules-uni-manager/ai-scenarios.md +0 -51
- package/skills/teamix-evo-design-rules-uni-manager/command-center.md +0 -108
- package/skills/teamix-evo-design-rules-uni-manager/danger-ops.md +0 -87
- package/skills/teamix-evo-manage/SKILL.md +0 -178
- package/skills/teamix-evo-ui-upgrade/SKILL.md +0 -75
- /package/{skills/teamix-evo-coding-conventions → src/teamix-evo-code-opentrek}/api-layering.md +0 -0
- /package/{skills/teamix-evo-coding-conventions → src/teamix-evo-code-opentrek}/error-and-loading.md +0 -0
- /package/{skills/teamix-evo-coding-conventions → src/teamix-evo-code-opentrek}/file-structure.md +0 -0
- /package/{skills/teamix-evo-coding-conventions → src/teamix-evo-code-opentrek}/forms-and-validation.md +0 -0
- /package/{skills/teamix-evo-coding-conventions → src/teamix-evo-code-opentrek}/routing-and-codesplit.md +0 -0
- /package/{skills/teamix-evo-coding-conventions → src/teamix-evo-code-opentrek}/testing.md +0 -0
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
# 路由与代码分包规范(uni-manager)
|
|
2
|
+
|
|
3
|
+
> **核心约定**:页面级 `React.lazy` 默认分包,鉴权 / 角色守卫集中在 `src/routes/`,404 / 401 / 403 必有兜底。**uni-manager 额外约定:tenantId / regionId / cloudProvider 同步到 URL search params,um-topbar 切换上下文时改写 URL。**
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## 路由库选型
|
|
8
|
+
|
|
9
|
+
teamix-evo uni-manager preset 默认 `react-router-dom@6`(Data Router 模式,业界主流)。
|
|
10
|
+
|
|
11
|
+
| 候选 | 何时选 |
|
|
12
|
+
| -------------------------------- | ------------------------------------ |
|
|
13
|
+
| `react-router-dom@6` Data Router | **默认**。中后台 / SPA / 多页应用 |
|
|
14
|
+
| `@tanstack/react-router` | 类型安全要求极高、复杂 search params |
|
|
15
|
+
| 文件路由(Next.js / Remix) | 不在本 preset 范围 |
|
|
16
|
+
|
|
17
|
+
**一个项目只用一种**。已经用了 react-router 就不要混入 tanstack-router。
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## §1 · 路由文件组织
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
src/routes/
|
|
25
|
+
├── index.tsx # createBrowserRouter 声明(单一来源)
|
|
26
|
+
├── guards.tsx # AuthGuard、RoleGuard、TenantContextGuard
|
|
27
|
+
├── paths.ts # 路径常量集中(避免魔法字符串)
|
|
28
|
+
└── lazy.tsx # 统一的 lazy() 工厂,可选
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
**约定**:
|
|
32
|
+
|
|
33
|
+
- 路由声明**只在** `src/routes/index.tsx`,不要散在多个 layout / page 里
|
|
34
|
+
- 页面文件**默认 default export**,便于 `React.lazy`
|
|
35
|
+
- 嵌套路由用 `children`,layout 用 `<Outlet />`
|
|
36
|
+
|
|
37
|
+
### 标准声明
|
|
38
|
+
|
|
39
|
+
```tsx
|
|
40
|
+
// src/routes/index.tsx
|
|
41
|
+
import { createBrowserRouter } from 'react-router-dom';
|
|
42
|
+
import { lazy } from 'react';
|
|
43
|
+
import { ConsoleLayout } from '@/layouts/ConsoleLayout';
|
|
44
|
+
import { AuthGuard, TenantContextGuard } from './guards';
|
|
45
|
+
import { PageErrorFallback } from '@/components/PageErrorFallback';
|
|
46
|
+
|
|
47
|
+
const DashboardPage = lazy(() => import('@/pages/dashboard'));
|
|
48
|
+
const InstanceListPage = lazy(() => import('@/pages/instances'));
|
|
49
|
+
const InstanceDetailPage = lazy(() => import('@/pages/instances/[id]'));
|
|
50
|
+
const LoginPage = lazy(() => import('@/pages/login'));
|
|
51
|
+
const NotFoundPage = lazy(() => import('@/pages/_not-found'));
|
|
52
|
+
|
|
53
|
+
export const router = createBrowserRouter([
|
|
54
|
+
{
|
|
55
|
+
path: '/login',
|
|
56
|
+
element: <LoginPage />,
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
element: (
|
|
60
|
+
<AuthGuard>
|
|
61
|
+
<TenantContextGuard>
|
|
62
|
+
<ConsoleLayout />
|
|
63
|
+
</TenantContextGuard>
|
|
64
|
+
</AuthGuard>
|
|
65
|
+
),
|
|
66
|
+
errorElement: <PageErrorFallback />,
|
|
67
|
+
children: [
|
|
68
|
+
{ path: '/', element: <DashboardPage /> },
|
|
69
|
+
{ path: '/instances', element: <InstanceListPage /> },
|
|
70
|
+
{ path: '/instances/:id', element: <InstanceDetailPage /> },
|
|
71
|
+
],
|
|
72
|
+
},
|
|
73
|
+
{ path: '*', element: <NotFoundPage /> },
|
|
74
|
+
]);
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
---
|
|
78
|
+
|
|
79
|
+
## §2 · 代码分包(默认开启)
|
|
80
|
+
|
|
81
|
+
### 规则
|
|
82
|
+
|
|
83
|
+
- **每个页面文件**用 `React.lazy()` 引入 → vite 自动 chunk
|
|
84
|
+
- 重型第三方库(图表、富文本、PDF、地图、监控可视化)用动态 import 包一层组件
|
|
85
|
+
- 同一 layout 下的多个 page 自动共享 layout chunk —— 不需要手动配
|
|
86
|
+
|
|
87
|
+
### 不分包的例外
|
|
88
|
+
|
|
89
|
+
- 登录页 / 错误页 / 404 → **不分包**(否则首屏要等 chunk 加载)
|
|
90
|
+
- 主 layout / um-topbar / Sidebar → **不分包**(立即用到)
|
|
91
|
+
- < 5 KB 的小页面 → 分不分都行,默认分
|
|
92
|
+
|
|
93
|
+
### 动态 import 重型库
|
|
94
|
+
|
|
95
|
+
```tsx
|
|
96
|
+
// ❌ 顶部 import:整个图表库进首屏 bundle
|
|
97
|
+
import { LineChart } from 'recharts';
|
|
98
|
+
|
|
99
|
+
// ✅ 用到时再加载
|
|
100
|
+
const LineChart = lazy(() =>
|
|
101
|
+
import('recharts').then((m) => ({ default: m.LineChart })),
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
<Suspense fallback={<ChartSkeleton />}>
|
|
105
|
+
<LineChart data={data} />
|
|
106
|
+
</Suspense>;
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
---
|
|
110
|
+
|
|
111
|
+
## §3 · Suspense 配合
|
|
112
|
+
|
|
113
|
+
每个 lazy 页面**必须**有 Suspense 兜底,否则首次进入会抛错。
|
|
114
|
+
|
|
115
|
+
最佳实践:在 layout / route 配置里挂一次,所有 children 共享:
|
|
116
|
+
|
|
117
|
+
```tsx
|
|
118
|
+
// src/layouts/ConsoleLayout.tsx
|
|
119
|
+
import { Outlet } from 'react-router-dom';
|
|
120
|
+
import { Suspense } from 'react';
|
|
121
|
+
import { Topbar } from 'biz-ui/uni-manager/um-topbar';
|
|
122
|
+
|
|
123
|
+
export function ConsoleLayout() {
|
|
124
|
+
return (
|
|
125
|
+
<div className="flex h-screen flex-col">
|
|
126
|
+
<Topbar />
|
|
127
|
+
<div className="flex flex-1 overflow-hidden">
|
|
128
|
+
<Sidebar />
|
|
129
|
+
<main className="flex-1 overflow-auto">
|
|
130
|
+
<Suspense fallback={<PageSkeleton />}>
|
|
131
|
+
<Outlet />
|
|
132
|
+
</Suspense>
|
|
133
|
+
</main>
|
|
134
|
+
</div>
|
|
135
|
+
</div>
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
---
|
|
141
|
+
|
|
142
|
+
## §4 · 鉴权 / 角色 / 租户上下文守卫
|
|
143
|
+
|
|
144
|
+
### 模式:守卫组件包路由
|
|
145
|
+
|
|
146
|
+
```tsx
|
|
147
|
+
// src/routes/guards.tsx
|
|
148
|
+
import { Navigate, useLocation, useSearchParams } from 'react-router-dom';
|
|
149
|
+
import { useEffect } from 'react';
|
|
150
|
+
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
|
151
|
+
import { useTenant } from '@/hooks/useTenant';
|
|
152
|
+
import { useRegion } from '@/hooks/useRegion';
|
|
153
|
+
|
|
154
|
+
export function AuthGuard({ children }: { children: React.ReactNode }) {
|
|
155
|
+
const { data: user, isPending } = useCurrentUser();
|
|
156
|
+
const location = useLocation();
|
|
157
|
+
|
|
158
|
+
if (isPending) return <PageSkeleton />;
|
|
159
|
+
if (!user) {
|
|
160
|
+
return <Navigate to="/login" state={{ from: location.pathname }} replace />;
|
|
161
|
+
}
|
|
162
|
+
return <>{children}</>;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function RoleGuard({
|
|
166
|
+
roles,
|
|
167
|
+
children,
|
|
168
|
+
}: {
|
|
169
|
+
roles: string[];
|
|
170
|
+
children: React.ReactNode;
|
|
171
|
+
}) {
|
|
172
|
+
const { data: user } = useCurrentUser();
|
|
173
|
+
if (!user || !roles.some((r) => user.roles.includes(r))) {
|
|
174
|
+
return <Navigate to="/_forbidden" replace />;
|
|
175
|
+
}
|
|
176
|
+
return <>{children}</>;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* uni-manager 专用:URL search params 与 active tenant/region 双向同步。
|
|
181
|
+
* - URL 有 ?tenant=t-x → 切到 t-x(若用户有权)
|
|
182
|
+
* - URL 没有但有 active → 写回 URL(分享/刷新可还原)
|
|
183
|
+
*/
|
|
184
|
+
export function TenantContextGuard({
|
|
185
|
+
children,
|
|
186
|
+
}: {
|
|
187
|
+
children: React.ReactNode;
|
|
188
|
+
}) {
|
|
189
|
+
const [searchParams, setSearchParams] = useSearchParams();
|
|
190
|
+
const { tenant, setTenant, isAvailable } = useTenant();
|
|
191
|
+
const { region, setRegion } = useRegion();
|
|
192
|
+
|
|
193
|
+
useEffect(() => {
|
|
194
|
+
const urlTenant = searchParams.get('tenant');
|
|
195
|
+
const urlRegion = searchParams.get('region');
|
|
196
|
+
|
|
197
|
+
// URL → state(优先)
|
|
198
|
+
if (urlTenant && urlTenant !== tenant?.id) {
|
|
199
|
+
if (isAvailable(urlTenant)) {
|
|
200
|
+
setTenant(urlTenant);
|
|
201
|
+
} else {
|
|
202
|
+
// URL 中租户不可用:回退并清掉 URL 参数
|
|
203
|
+
searchParams.delete('tenant');
|
|
204
|
+
setSearchParams(searchParams, { replace: true });
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
if (urlRegion && urlRegion !== region?.id) {
|
|
208
|
+
setRegion(urlRegion);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// state → URL(状态先有,URL 没写)
|
|
212
|
+
const next = new URLSearchParams(searchParams);
|
|
213
|
+
if (tenant && !next.get('tenant')) next.set('tenant', tenant.id);
|
|
214
|
+
if (region && !next.get('region')) next.set('region', region.id);
|
|
215
|
+
if (next.toString() !== searchParams.toString()) {
|
|
216
|
+
setSearchParams(next, { replace: true });
|
|
217
|
+
}
|
|
218
|
+
}, [
|
|
219
|
+
searchParams,
|
|
220
|
+
tenant,
|
|
221
|
+
region,
|
|
222
|
+
setTenant,
|
|
223
|
+
setRegion,
|
|
224
|
+
setSearchParams,
|
|
225
|
+
isAvailable,
|
|
226
|
+
]);
|
|
227
|
+
|
|
228
|
+
if (!tenant) return <PageSkeleton />; // 还没初始化好
|
|
229
|
+
return <>{children}</>;
|
|
230
|
+
}
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
### 反模式
|
|
234
|
+
|
|
235
|
+
- ❌ 每个页面顶部都写 `if (!user) navigate('/login')` —— 抽到守卫
|
|
236
|
+
- ❌ 把角色判断写在 sidebar / button 的渲染逻辑里(只能藏入口,无法防直接访问 URL)—— 路由层判 + UI 层判,**两层都要**
|
|
237
|
+
- ❌ 守卫里直接 fetch 用户信息 —— 走 `useCurrentUser` hook,享受缓存
|
|
238
|
+
- ❌ 切租户时只改 active-context 不改 URL —— 刷新就丢
|
|
239
|
+
|
|
240
|
+
---
|
|
241
|
+
|
|
242
|
+
## §5 · 必须存在的兜底路由
|
|
243
|
+
|
|
244
|
+
每个项目**必须**有这三个页面,放在 `src/pages/_xxx/`(下划线前缀,表示非业务路由):
|
|
245
|
+
|
|
246
|
+
| 路径 | 文件 | 触发场景 |
|
|
247
|
+
| ------------------- | -------------------------------- | --------------------------------- |
|
|
248
|
+
| `*` (404) | `src/pages/_not-found/index.tsx` | 任何未匹配的 URL |
|
|
249
|
+
| `/_forbidden` (403) | `src/pages/_forbidden/index.tsx` | 角色无权访问 / 租户无权访问该资源 |
|
|
250
|
+
| `/_error` (500) | `src/pages/_error/index.tsx` | router `errorElement` 兜底 |
|
|
251
|
+
|
|
252
|
+
未登录(401)由 `AuthGuard` 重定向到 `/login`,**不**单独建页面。
|
|
253
|
+
|
|
254
|
+
---
|
|
255
|
+
|
|
256
|
+
## §6 · search params 与状态同步
|
|
257
|
+
|
|
258
|
+
列表页的筛选 / 分页 / 排序参数**必须**写进 URL,刷新 / 分享 / 浏览器后退都要还原:
|
|
259
|
+
|
|
260
|
+
```tsx
|
|
261
|
+
import { useSearchParams } from 'react-router-dom';
|
|
262
|
+
|
|
263
|
+
function InstanceListPage() {
|
|
264
|
+
const [searchParams, setSearchParams] = useSearchParams();
|
|
265
|
+
const status = searchParams.get('status') ?? 'all';
|
|
266
|
+
const page = Number(searchParams.get('page') ?? 1);
|
|
267
|
+
|
|
268
|
+
const { data } = useInstanceList({ status, page });
|
|
269
|
+
|
|
270
|
+
return (
|
|
271
|
+
<Toolbar
|
|
272
|
+
status={status}
|
|
273
|
+
onStatusChange={(s) =>
|
|
274
|
+
setSearchParams({ status: s, page: '1' }, { replace: true })
|
|
275
|
+
}
|
|
276
|
+
/>
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
复杂场景用 [`nuqs`](https://nuqs.47ng.com/) 做类型化包装,业界常见。
|
|
282
|
+
|
|
283
|
+
反模式:
|
|
284
|
+
|
|
285
|
+
- ❌ 筛选状态只放 `useState` —— 刷新就丢
|
|
286
|
+
- ❌ 分页放 store —— 多 tab 各自分页会互相污染
|
|
287
|
+
|
|
288
|
+
---
|
|
289
|
+
|
|
290
|
+
## §7 · 导航与跳转
|
|
291
|
+
|
|
292
|
+
```tsx
|
|
293
|
+
import { useNavigate, Link } from 'react-router-dom';
|
|
294
|
+
|
|
295
|
+
// 声明式(链接)
|
|
296
|
+
<Link to={`/instances/${instance.id}`}>查看</Link>;
|
|
297
|
+
|
|
298
|
+
// 命令式(mutation success)
|
|
299
|
+
const navigate = useNavigate();
|
|
300
|
+
mutation.mutate(values, {
|
|
301
|
+
onSuccess: (instance) => navigate(`/instances/${instance.id}`),
|
|
302
|
+
});
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
约束:
|
|
306
|
+
|
|
307
|
+
- 静态跳 → `<Link>`(可右键打开新 tab)
|
|
308
|
+
- 动态跳(提交后) → `useNavigate()`
|
|
309
|
+
- **不要** `window.location.href = ...`(整页刷新,丢状态)
|
|
310
|
+
- 外链 → 普通 `<a href target="_blank" rel="noopener noreferrer">`
|
|
311
|
+
|
|
312
|
+
---
|
|
313
|
+
|
|
314
|
+
## §8 · uni-manager 专属:tenant/region/cloud URL 同步
|
|
315
|
+
|
|
316
|
+
### 三套 search params 的标准约定
|
|
317
|
+
|
|
318
|
+
| 参数 | 值 | 由谁写 | 由谁读 |
|
|
319
|
+
| ---------- | --------------------------- | ---------------- | ------------------------------------ |
|
|
320
|
+
| `?tenant=` | tenantId | um-topbar 切换时 | TenantContextGuard / TenantProvider |
|
|
321
|
+
| `?region=` | regionId | um-topbar 切换时 | TenantContextGuard / RegionProvider |
|
|
322
|
+
| `?cloud=` | cloudProvider(aws/aliyun..) | 跨云页面 toolbar | 跨云 service hook(覆盖 active cloud) |
|
|
323
|
+
|
|
324
|
+
### um-topbar 集成示例
|
|
325
|
+
|
|
326
|
+
`biz-ui/uni-manager/um-topbar` 暴露 `onTenantChange` / `onRegionChange` 钩子,业务工程**必须**在这里改写 URL:
|
|
327
|
+
|
|
328
|
+
```tsx
|
|
329
|
+
// src/layouts/ConsoleLayout.tsx
|
|
330
|
+
import { Topbar } from 'biz-ui/uni-manager/um-topbar';
|
|
331
|
+
import { useSearchParams } from 'react-router-dom';
|
|
332
|
+
import { useTenant } from '@/hooks/useTenant';
|
|
333
|
+
import { useRegion } from '@/hooks/useRegion';
|
|
334
|
+
|
|
335
|
+
export function ConsoleLayout() {
|
|
336
|
+
const [searchParams, setSearchParams] = useSearchParams();
|
|
337
|
+
const { tenant, setTenant } = useTenant();
|
|
338
|
+
const { region, setRegion } = useRegion();
|
|
339
|
+
|
|
340
|
+
return (
|
|
341
|
+
<Topbar
|
|
342
|
+
tenant={tenant}
|
|
343
|
+
region={region}
|
|
344
|
+
onTenantChange={(t) => {
|
|
345
|
+
setTenant(t.id); // 1. 写 active-context + invalidate query
|
|
346
|
+
const next = new URLSearchParams(searchParams);
|
|
347
|
+
next.set('tenant', t.id);
|
|
348
|
+
setSearchParams(next); // 2. URL 同步
|
|
349
|
+
}}
|
|
350
|
+
onRegionChange={(r) => {
|
|
351
|
+
setRegion(r.id);
|
|
352
|
+
const next = new URLSearchParams(searchParams);
|
|
353
|
+
next.set('region', r.id);
|
|
354
|
+
setSearchParams(next);
|
|
355
|
+
}}
|
|
356
|
+
/>
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
> 顺序很重要:**先**改 active-context(让正在飞的请求被取消 / 后续请求带新 header),**再**改 URL(否则 URL 改完触发 guard,guard 看到 URL/state 不一致又改一次,产生抖动)。
|
|
362
|
+
|
|
363
|
+
### 跨页面跳转保留上下文
|
|
364
|
+
|
|
365
|
+
业务页面跳转时,URL 中的 `tenant` / `region` 需要继承:
|
|
366
|
+
|
|
367
|
+
```tsx
|
|
368
|
+
// ✅ 用工具函数保留 search
|
|
369
|
+
import { withContextSearch } from '@/lib/url';
|
|
370
|
+
|
|
371
|
+
navigate(withContextSearch(`/instances/${id}`));
|
|
372
|
+
// 等价于 /instances/i-1?tenant=t-x®ion=cn-hangzhou
|
|
373
|
+
|
|
374
|
+
// ❌ 直接拼会丢上下文
|
|
375
|
+
navigate(`/instances/${id}`);
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
```ts
|
|
379
|
+
// src/lib/url.ts
|
|
380
|
+
import { getActiveTenant, getActiveRegion } from '@/lib/active-context';
|
|
381
|
+
|
|
382
|
+
export function withContextSearch(path: string): string {
|
|
383
|
+
const url = new URL(path, window.location.origin);
|
|
384
|
+
const tenant = getActiveTenant();
|
|
385
|
+
const region = getActiveRegion();
|
|
386
|
+
if (tenant && !url.searchParams.has('tenant'))
|
|
387
|
+
url.searchParams.set('tenant', tenant.id);
|
|
388
|
+
if (region && !url.searchParams.has('region'))
|
|
389
|
+
url.searchParams.set('region', region.id);
|
|
390
|
+
return url.pathname + url.search;
|
|
391
|
+
}
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
---
|
|
395
|
+
|
|
396
|
+
## 反模式速查
|
|
397
|
+
|
|
398
|
+
| 反模式 | 为什么禁 | 应该 |
|
|
399
|
+
| ---------------------------------- | ---------------------- | -------------------------------------- |
|
|
400
|
+
| 全部页面顶部 `import` 不 lazy | 首屏 bundle 爆炸 | `React.lazy` 每页 |
|
|
401
|
+
| Lazy 但没 Suspense 兜底 | 进入页面报错 | Layout 挂一次 Suspense |
|
|
402
|
+
| 鉴权写在每个 page 里 | 散乱、易漏 | 守卫组件 + 路由层 |
|
|
403
|
+
| 路由声明分散在多处 | 难维护、易循环 | 唯一在 `src/routes/index.tsx` |
|
|
404
|
+
| 列表筛选状态不进 URL | 刷新 / 分享 / 后退失效 | `useSearchParams` |
|
|
405
|
+
| `window.location.href` 跳转 | 整页刷新 | `useNavigate` / `<Link>` |
|
|
406
|
+
| 路由 path 写魔法字符串 | 改路径漏改 | 抽 `src/routes/paths.ts` 集中 |
|
|
407
|
+
| 自建 Topbar 替代 um-topbar | UM1 红线 | 用 `biz-ui/uni-manager/um-topbar` |
|
|
408
|
+
| 切租户只改 state 不写 URL | 刷新丢上下文 | `TenantContextGuard` + topbar 双向同步 |
|
|
409
|
+
| 切租户先改 URL 再改 active-context | guard 抖动 | 先 setTenant,再 setSearchParams |
|
|
410
|
+
| 跳转时不带 tenant/region search | 跳后丢上下文 | `withContextSearch(path)` 工具函数 |
|
|
411
|
+
|
|
412
|
+
---
|
|
413
|
+
|
|
414
|
+
## 进阶:Loader / Action(react-router v6.4+)
|
|
415
|
+
|
|
416
|
+
如果项目愿意采用 Data Router 的 `loader` / `action` 模式,可把数据预取下沉到路由层:
|
|
417
|
+
|
|
418
|
+
```tsx
|
|
419
|
+
{
|
|
420
|
+
path: '/instances/:id',
|
|
421
|
+
element: <InstanceDetailPage />,
|
|
422
|
+
loader: async ({ params }) => {
|
|
423
|
+
return queryClient.ensureQueryData({
|
|
424
|
+
queryKey: ['instance', getActiveTenant()?.id, params.id],
|
|
425
|
+
queryFn: () => getInstance(params.id!),
|
|
426
|
+
});
|
|
427
|
+
},
|
|
428
|
+
}
|
|
429
|
+
```
|
|
430
|
+
|
|
431
|
+
收益:页面切换时数据并行加载(不是渲染后才发请求),首屏更快。
|
|
432
|
+
|
|
433
|
+
**慎用**:loader 写多了和 hook 形成两套数据流,需团队约定清楚。建议先用 hook + Suspense,熟练后再考虑 loader。
|
|
434
|
+
|
|
435
|
+
---
|
|
436
|
+
|
|
437
|
+
## AI 必须输出的路由日志
|
|
438
|
+
|
|
439
|
+
```
|
|
440
|
+
## 路由 / 分包改动
|
|
441
|
+
|
|
442
|
+
- src/routes/index.tsx: 注册 /instances、/instances/:id(均 React.lazy)
|
|
443
|
+
- src/pages/instances/index.tsx: default export(便于 lazy)
|
|
444
|
+
- 守卫: ✅ 包在 AuthGuard + TenantContextGuard 下
|
|
445
|
+
- Suspense: ✅ ConsoleLayout 已挂一次,新增页面共用
|
|
446
|
+
- search params: ✅ status / page 用 useSearchParams
|
|
447
|
+
- uni-manager 上下文: ✅ tenant / region 同步到 URL,um-topbar 切换改写
|
|
448
|
+
- 跳转保留上下文: ✅ 用 withContextSearch 工具函数
|
|
449
|
+
- 兜底: ✅ * → NotFoundPage、/_forbidden、/_error 已存在
|
|
450
|
+
```
|