@teamix-evo/skills 0.2.0 → 0.3.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/README.md +18 -8
- package/_template/SKILL.md.hbs +14 -1
- package/manifest.json +59 -2
- package/package.json +6 -2
- package/skills/teamix-evo-coding-conventions/SKILL.md +92 -0
- package/skills/teamix-evo-coding-conventions/api-layering.md +225 -0
- package/skills/teamix-evo-coding-conventions/checklist.md +173 -0
- package/skills/teamix-evo-coding-conventions/error-and-loading.md +269 -0
- package/skills/teamix-evo-coding-conventions/file-structure.md +273 -0
- package/skills/teamix-evo-coding-conventions/forms-and-validation.md +220 -0
- package/skills/teamix-evo-coding-conventions/reuse-first.md +122 -0
- package/skills/teamix-evo-coding-conventions/routing-and-codesplit.md +298 -0
- package/skills/teamix-evo-coding-conventions/testing.md +313 -0
- package/skills/teamix-evo-design-rules/SKILL.md +86 -0
- package/skills/teamix-evo-design-rules/boundaries.md +89 -0
- package/skills/teamix-evo-design-rules/checklist.md +108 -0
- package/skills/teamix-evo-design-rules/generation-flow.md +142 -0
- package/skills/teamix-evo-design-rules/prompts/page-design.md +148 -0
- package/skills/teamix-evo-design-rules-opentrek/SKILL.md +48 -0
- package/skills/teamix-evo-design-rules-opentrek/brand-rules.md +74 -0
- package/skills/teamix-evo-design-rules-uni-manager/SKILL.md +51 -0
- package/skills/teamix-evo-design-rules-uni-manager/ai-scenarios.md +51 -0
- package/skills/teamix-evo-design-rules-uni-manager/command-center.md +108 -0
- package/skills/teamix-evo-design-rules-uni-manager/danger-ops.md +87 -0
- package/skills/teamix-evo-manage/SKILL.md +80 -40
- package/skills/teamix-evo-ui-upgrade/SKILL.md +75 -0
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
# 路由与代码分包规范
|
|
2
|
+
|
|
3
|
+
> **核心约定**:页面级 `React.lazy` 默认分包,鉴权 / 角色守卫集中在 `src/routes/`,404 / 401 / 403 必有兜底。
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## 路由库选型
|
|
8
|
+
|
|
9
|
+
teamix-evo console preset 默认 `react-router-dom@6`(Data Router 模式,业界主流)。
|
|
10
|
+
|
|
11
|
+
| 候选 | 何时选 |
|
|
12
|
+
| --- | --- |
|
|
13
|
+
| `react-router-dom@6` Data Router | **默认**。中后台 / SPA / 多页应用 |
|
|
14
|
+
| `@tanstack/react-router` | 类型安全要求极高、复杂 search params | (替换需在 ADR 记录)
|
|
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
|
|
27
|
+
└── lazy.tsx # 统一的 lazy() 工厂,可选
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
**约定**:
|
|
31
|
+
|
|
32
|
+
- 路由声明**只在** `src/routes/index.tsx`,不要散在多个 layout / page 里
|
|
33
|
+
- 页面文件**默认 default export**,便于 `React.lazy`
|
|
34
|
+
- 嵌套路由用 `children`,layout 用 `<Outlet />`
|
|
35
|
+
|
|
36
|
+
### 标准声明
|
|
37
|
+
|
|
38
|
+
```tsx
|
|
39
|
+
// src/routes/index.tsx
|
|
40
|
+
import { createBrowserRouter } from 'react-router-dom';
|
|
41
|
+
import { lazy } from 'react';
|
|
42
|
+
import { ConsoleLayout } from '@/layouts/ConsoleLayout';
|
|
43
|
+
import { AuthGuard } from './guards';
|
|
44
|
+
import { PageErrorFallback } from '@/components/PageErrorFallback';
|
|
45
|
+
import { PageSkeleton } from '@/components/PageSkeleton';
|
|
46
|
+
|
|
47
|
+
const DashboardPage = lazy(() => import('@/pages/dashboard'));
|
|
48
|
+
const OrderListPage = lazy(() => import('@/pages/orders'));
|
|
49
|
+
const OrderDetailPage = lazy(() => import('@/pages/orders/[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
|
+
<ConsoleLayout />
|
|
62
|
+
</AuthGuard>
|
|
63
|
+
),
|
|
64
|
+
errorElement: <PageErrorFallback />,
|
|
65
|
+
children: [
|
|
66
|
+
{ path: '/', element: <DashboardPage /> },
|
|
67
|
+
{ path: '/orders', element: <OrderListPage /> },
|
|
68
|
+
{ path: '/orders/:id', element: <OrderDetailPage /> },
|
|
69
|
+
],
|
|
70
|
+
},
|
|
71
|
+
{ path: '*', element: <NotFoundPage /> },
|
|
72
|
+
]);
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
## §2 · 代码分包(默认开启)
|
|
78
|
+
|
|
79
|
+
### 规则
|
|
80
|
+
|
|
81
|
+
- **每个页面文件**用 `React.lazy()` 引入 → vite 自动 chunk
|
|
82
|
+
- 重型第三方库(图表、富文本、PDF)用动态 import 包一层组件,延迟到使用时加载
|
|
83
|
+
- 同一 layout 下的多个 page 自动共享 layout chunk —— 不需要手动配
|
|
84
|
+
|
|
85
|
+
### 不分包的例外
|
|
86
|
+
|
|
87
|
+
- 登录页 / 错误页 / 404 → **不分包**(否则首屏要等 chunk 加载)
|
|
88
|
+
- 主 layout / Header / Sidebar → **不分包**(立即用到)
|
|
89
|
+
- < 5 KB 的小页面 → 分不分都行,默认分
|
|
90
|
+
|
|
91
|
+
### 动态 import 重型库
|
|
92
|
+
|
|
93
|
+
```tsx
|
|
94
|
+
// ❌ 顶部 import:整个图表库进首屏 bundle
|
|
95
|
+
import { LineChart } from 'recharts';
|
|
96
|
+
|
|
97
|
+
// ✅ 用到时再加载
|
|
98
|
+
const LineChart = lazy(() =>
|
|
99
|
+
import('recharts').then((m) => ({ default: m.LineChart })),
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
<Suspense fallback={<ChartSkeleton />}>
|
|
103
|
+
<LineChart data={data} />
|
|
104
|
+
</Suspense>;
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
---
|
|
108
|
+
|
|
109
|
+
## §3 · Suspense 配合
|
|
110
|
+
|
|
111
|
+
每个 lazy 页面**必须**有 Suspense 兜底,否则首次进入会抛错。
|
|
112
|
+
|
|
113
|
+
最佳实践:在 layout / route 配置里挂一次,所有 children 共享:
|
|
114
|
+
|
|
115
|
+
```tsx
|
|
116
|
+
// ConsoleLayout.tsx
|
|
117
|
+
import { Outlet } from 'react-router-dom';
|
|
118
|
+
import { Suspense } from 'react';
|
|
119
|
+
|
|
120
|
+
export function ConsoleLayout() {
|
|
121
|
+
return (
|
|
122
|
+
<div className="flex">
|
|
123
|
+
<Sidebar />
|
|
124
|
+
<main className="flex-1">
|
|
125
|
+
<Suspense fallback={<PageSkeleton />}>
|
|
126
|
+
<Outlet />
|
|
127
|
+
</Suspense>
|
|
128
|
+
</main>
|
|
129
|
+
</div>
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
---
|
|
135
|
+
|
|
136
|
+
## §4 · 鉴权 / 角色守卫
|
|
137
|
+
|
|
138
|
+
### 模式:守卫组件包路由
|
|
139
|
+
|
|
140
|
+
```tsx
|
|
141
|
+
// src/routes/guards.tsx
|
|
142
|
+
import { Navigate, useLocation } from 'react-router-dom';
|
|
143
|
+
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
|
144
|
+
|
|
145
|
+
export function AuthGuard({ children }: { children: React.ReactNode }) {
|
|
146
|
+
const { data: user, isPending } = useCurrentUser();
|
|
147
|
+
const location = useLocation();
|
|
148
|
+
|
|
149
|
+
if (isPending) return <PageSkeleton />;
|
|
150
|
+
if (!user) {
|
|
151
|
+
// 记录原 URL,登录后跳回
|
|
152
|
+
return <Navigate to="/login" state={{ from: location.pathname }} replace />;
|
|
153
|
+
}
|
|
154
|
+
return <>{children}</>;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function RoleGuard({
|
|
158
|
+
roles,
|
|
159
|
+
children,
|
|
160
|
+
}: {
|
|
161
|
+
roles: string[];
|
|
162
|
+
children: React.ReactNode;
|
|
163
|
+
}) {
|
|
164
|
+
const { data: user } = useCurrentUser();
|
|
165
|
+
if (!user || !roles.some((r) => user.roles.includes(r))) {
|
|
166
|
+
return <Navigate to="/_forbidden" replace />;
|
|
167
|
+
}
|
|
168
|
+
return <>{children}</>;
|
|
169
|
+
}
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
### 反模式
|
|
173
|
+
|
|
174
|
+
- ❌ 每个页面顶部都写 `if (!user) navigate('/login')` —— 抽到守卫
|
|
175
|
+
- ❌ 把角色判断写在 sidebar / button 的渲染逻辑里(只能藏入口,无法防直接访问 URL)—— 路由层判 + UI 层判,**两层都要**
|
|
176
|
+
- ❌ 守卫里直接 fetch 用户信息 —— 走 `useCurrentUser` hook,享受缓存
|
|
177
|
+
|
|
178
|
+
---
|
|
179
|
+
|
|
180
|
+
## §5 · 必须存在的兜底路由
|
|
181
|
+
|
|
182
|
+
每个项目**必须**有这三个页面,放在 `src/pages/_xxx/`(下划线前缀,表示非业务路由):
|
|
183
|
+
|
|
184
|
+
| 路径 | 文件 | 触发场景 |
|
|
185
|
+
| --- | --- | --- |
|
|
186
|
+
| `*` (404) | `src/pages/_not-found/index.tsx` | 任何未匹配的 URL |
|
|
187
|
+
| `/_forbidden` (403) | `src/pages/_forbidden/index.tsx` | 角色无权访问 |
|
|
188
|
+
| `/_error` (500) | `src/pages/_error/index.tsx` | router `errorElement` 兜底 |
|
|
189
|
+
|
|
190
|
+
未登录(401)由 `AuthGuard` 重定向到 `/login`,**不**单独建页面。
|
|
191
|
+
|
|
192
|
+
---
|
|
193
|
+
|
|
194
|
+
## §6 · search params 与状态同步
|
|
195
|
+
|
|
196
|
+
列表页的筛选 / 分页 / 排序参数**必须**写进 URL,刷新 / 分享 / 浏览器后退都要还原:
|
|
197
|
+
|
|
198
|
+
```tsx
|
|
199
|
+
import { useSearchParams } from 'react-router-dom';
|
|
200
|
+
|
|
201
|
+
function OrderListPage() {
|
|
202
|
+
const [searchParams, setSearchParams] = useSearchParams();
|
|
203
|
+
const status = searchParams.get('status') ?? 'all';
|
|
204
|
+
const page = Number(searchParams.get('page') ?? 1);
|
|
205
|
+
|
|
206
|
+
const { data } = useOrderList({ status, page });
|
|
207
|
+
|
|
208
|
+
return (
|
|
209
|
+
<Toolbar
|
|
210
|
+
status={status}
|
|
211
|
+
onStatusChange={(s) => setSearchParams({ status: s, page: '1' })}
|
|
212
|
+
/>
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
复杂场景用 [`nuqs`](https://nuqs.47ng.com/) 做类型化包装,业界常见。
|
|
218
|
+
|
|
219
|
+
反模式:
|
|
220
|
+
|
|
221
|
+
- ❌ 筛选状态只放 `useState` —— 刷新就丢
|
|
222
|
+
- ❌ 分页放 store —— 多 tab 各自分页会互相污染
|
|
223
|
+
|
|
224
|
+
---
|
|
225
|
+
|
|
226
|
+
## §7 · 导航与跳转
|
|
227
|
+
|
|
228
|
+
```tsx
|
|
229
|
+
import { useNavigate, Link } from 'react-router-dom';
|
|
230
|
+
|
|
231
|
+
// 声明式(链接)
|
|
232
|
+
<Link to={`/orders/${order.id}`}>查看</Link>
|
|
233
|
+
|
|
234
|
+
// 命令式(mutation success)
|
|
235
|
+
const navigate = useNavigate();
|
|
236
|
+
mutation.mutate(values, {
|
|
237
|
+
onSuccess: (order) => navigate(`/orders/${order.id}`),
|
|
238
|
+
});
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
约束:
|
|
242
|
+
|
|
243
|
+
- 静态跳 → `<Link>`(可右键打开新 tab)
|
|
244
|
+
- 动态跳(提交后) → `useNavigate()`
|
|
245
|
+
- **不要** `window.location.href = ...`(整页刷新,丢状态)
|
|
246
|
+
- 外链 → 普通 `<a href target="_blank" rel="noopener noreferrer">`
|
|
247
|
+
|
|
248
|
+
---
|
|
249
|
+
|
|
250
|
+
## 反模式速查
|
|
251
|
+
|
|
252
|
+
| 反模式 | 为什么禁 | 应该 |
|
|
253
|
+
| --- | --- | --- |
|
|
254
|
+
| 全部页面顶部 `import` 不 lazy | 首屏 bundle 爆炸 | `React.lazy` 每页 |
|
|
255
|
+
| Lazy 但没 Suspense 兜底 | 进入页面报错 | Layout 挂一次 Suspense |
|
|
256
|
+
| 鉴权写在每个 page 里 | 散乱、易漏 | 守卫组件 + 路由层 |
|
|
257
|
+
| 路由声明分散在多处 | 难维护、易循环 | 唯一在 `src/routes/index.tsx` |
|
|
258
|
+
| 列表筛选状态不进 URL | 刷新 / 分享 / 后退失效 | `useSearchParams` |
|
|
259
|
+
| `window.location.href` 跳转 | 整页刷新 | `useNavigate` / `<Link>` |
|
|
260
|
+
| 路由 path 写魔法字符串 | 改路径漏改 | 抽 `src/routes/paths.ts` 集中 |
|
|
261
|
+
|
|
262
|
+
---
|
|
263
|
+
|
|
264
|
+
## 进阶:Loader / Action(react-router v6.4+)
|
|
265
|
+
|
|
266
|
+
如果项目愿意采用 Data Router 的 `loader` / `action` 模式,可把数据预取下沉到路由层:
|
|
267
|
+
|
|
268
|
+
```tsx
|
|
269
|
+
{
|
|
270
|
+
path: '/orders/:id',
|
|
271
|
+
element: <OrderDetailPage />,
|
|
272
|
+
loader: async ({ params }) => {
|
|
273
|
+
return queryClient.ensureQueryData({
|
|
274
|
+
queryKey: ['order', params.id],
|
|
275
|
+
queryFn: () => getOrder(params.id!),
|
|
276
|
+
});
|
|
277
|
+
},
|
|
278
|
+
}
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
收益:页面切换时数据并行加载(不是渲染后才发请求),首屏更快。
|
|
282
|
+
|
|
283
|
+
**慎用**:loader 写多了和 hook 形成两套数据流,需团队约定清楚。建议先用 hook + Suspense,熟练后再考虑 loader。
|
|
284
|
+
|
|
285
|
+
---
|
|
286
|
+
|
|
287
|
+
## AI 必须输出的路由日志
|
|
288
|
+
|
|
289
|
+
```
|
|
290
|
+
## 路由 / 分包改动
|
|
291
|
+
|
|
292
|
+
- src/routes/index.tsx: 注册 /orders、/orders/:id(均 React.lazy)
|
|
293
|
+
- src/pages/orders/index.tsx: default export(便于 lazy)
|
|
294
|
+
- 守卫: ✅ 包在已有 AuthGuard 下
|
|
295
|
+
- Suspense: ✅ ConsoleLayout 已挂一次,新增页面共用
|
|
296
|
+
- search params: ✅ status / page 用 useSearchParams
|
|
297
|
+
- 兜底: ✅ * → NotFoundPage 已存在
|
|
298
|
+
```
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
# 测试规范
|
|
2
|
+
|
|
3
|
+
> **核心约定**:`vitest` + `@testing-library/react` + `msw`,文件就近 `*.test.ts(x)`。**不追求覆盖率数字**,追求"关键路径必有测试 + 纯函数全测"。
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## 选型(业界主流)
|
|
8
|
+
|
|
9
|
+
| 类型 | 工具 | 理由 |
|
|
10
|
+
| --- | --- | --- |
|
|
11
|
+
| 测试 runner | `vitest` | 与 Vite 原生集成,ESM 友好,API 与 Jest 兼容 |
|
|
12
|
+
| 组件测试 | `@testing-library/react` + `@testing-library/user-event` | 测行为不测实现,业界事实标准(Kent C. Dodds) |
|
|
13
|
+
| API mock | `msw`(Mock Service Worker) | 拦截真实 fetch / axios,测试与生产共用 service 层 |
|
|
14
|
+
| 断言 | `vitest` 内置 `expect`(兼容 Jest) | 无需 chai |
|
|
15
|
+
| E2E(可选) | `playwright` | 关键流程冒烟,**不在本 skill 范围** |
|
|
16
|
+
|
|
17
|
+
**不要混用**:已经选 vitest 就不要再加 jest;已经用 msw 就不要再自己 mock axios。
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## §1 · 文件位置
|
|
22
|
+
|
|
23
|
+
测试文件**就近**放在被测文件同目录,`<name>.test.ts(x)`:
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
src/
|
|
27
|
+
├── utils/
|
|
28
|
+
│ ├── format.ts
|
|
29
|
+
│ └── format.test.ts # ← 紧邻
|
|
30
|
+
├── services/
|
|
31
|
+
│ ├── order.ts
|
|
32
|
+
│ └── order.test.ts
|
|
33
|
+
├── hooks/
|
|
34
|
+
│ ├── useOrderList.ts
|
|
35
|
+
│ └── useOrderList.test.ts
|
|
36
|
+
└── components/
|
|
37
|
+
├── OrderStatusBadge.tsx
|
|
38
|
+
└── OrderStatusBadge.test.tsx
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
**不要**集中放在 `tests/` / `__tests__/` 顶层 —— 业界共识已偏向就近,便于维护与移动。
|
|
42
|
+
|
|
43
|
+
测试辅助资源(fixtures、mock handler)放 `src/test/`:
|
|
44
|
+
|
|
45
|
+
```
|
|
46
|
+
src/test/
|
|
47
|
+
├── setup.ts # vitest setup(jest-dom、msw server)
|
|
48
|
+
├── handlers.ts # msw handler 集合
|
|
49
|
+
├── render.tsx # 自定义 render(包 Provider)
|
|
50
|
+
└── fixtures/
|
|
51
|
+
└── order.ts # 测试数据工厂
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
## §2 · 应该写什么测试(优先级)
|
|
57
|
+
|
|
58
|
+
按 ROI 排序:
|
|
59
|
+
|
|
60
|
+
| 优先级 | 类型 | 例子 | 必测? |
|
|
61
|
+
| --- | --- | --- | --- |
|
|
62
|
+
| P0 | 纯函数(`src/utils/`、`src/services/`) | `formatMoney`、`isValidOrder` | ✅ **必测** |
|
|
63
|
+
| P0 | zod schema | `CreateOrderInputSchema.parse(...)` | ✅ **必测** |
|
|
64
|
+
| P0 | 关键业务路径(下单 / 支付 / 鉴权) | E2E-style 组件测 | ✅ **必测** |
|
|
65
|
+
| P1 | Hook(数据 hook + 自定义 UI hook) | `useOrderList`、`useDebounce` | 推荐 |
|
|
66
|
+
| P1 | 复用业务组件(`src/components/`) | `OrderStatusBadge` 各状态渲染 | 推荐 |
|
|
67
|
+
| P2 | 页面组件 | 用 RTL 渲染 + msw mock | 可选 |
|
|
68
|
+
| ❌ | ui 包源码 | 已在 `@teamix-evo/ui` 包内测过 | **不要重复测** |
|
|
69
|
+
| ❌ | 三方库 | `react-router`、`react-query` | **不要测** |
|
|
70
|
+
|
|
71
|
+
**红线**:不要为了凑覆盖率数字测 `Button` 能不能渲染、`React.useState` 能不能用。
|
|
72
|
+
|
|
73
|
+
---
|
|
74
|
+
|
|
75
|
+
## §3 · 标准测试模式
|
|
76
|
+
|
|
77
|
+
### 纯函数
|
|
78
|
+
|
|
79
|
+
```ts
|
|
80
|
+
// src/utils/format.test.ts
|
|
81
|
+
import { describe, it, expect } from 'vitest';
|
|
82
|
+
import { formatMoney } from './format';
|
|
83
|
+
|
|
84
|
+
describe('formatMoney', () => {
|
|
85
|
+
it('保留两位小数', () => {
|
|
86
|
+
expect(formatMoney(1234.5)).toBe('1,234.50');
|
|
87
|
+
});
|
|
88
|
+
it('0 显示 0.00', () => {
|
|
89
|
+
expect(formatMoney(0)).toBe('0.00');
|
|
90
|
+
});
|
|
91
|
+
it('负数前置 -', () => {
|
|
92
|
+
expect(formatMoney(-1.5)).toBe('-1.50');
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
约定:
|
|
98
|
+
|
|
99
|
+
- 一个 `describe` 一个函数
|
|
100
|
+
- 一个 `it` 一个行为(不堆 5 个 expect 测无关行为)
|
|
101
|
+
- 测试名是**行为描述**,不是函数名("保留两位小数" ✓ / "test formatMoney" ❌)
|
|
102
|
+
|
|
103
|
+
### Service 函数(用 msw)
|
|
104
|
+
|
|
105
|
+
```ts
|
|
106
|
+
// src/test/handlers.ts
|
|
107
|
+
import { http, HttpResponse } from 'msw';
|
|
108
|
+
|
|
109
|
+
export const handlers = [
|
|
110
|
+
http.get('/api/orders', () =>
|
|
111
|
+
HttpResponse.json({ list: [{ id: 'o1', status: 'pending' }] }),
|
|
112
|
+
),
|
|
113
|
+
http.post('/api/orders', async ({ request }) => {
|
|
114
|
+
const body = await request.json();
|
|
115
|
+
return HttpResponse.json({ id: 'o-new', ...body });
|
|
116
|
+
}),
|
|
117
|
+
];
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
```ts
|
|
121
|
+
// src/test/setup.ts
|
|
122
|
+
import { setupServer } from 'msw/node';
|
|
123
|
+
import { afterAll, afterEach, beforeAll } from 'vitest';
|
|
124
|
+
import '@testing-library/jest-dom/vitest';
|
|
125
|
+
import { handlers } from './handlers';
|
|
126
|
+
|
|
127
|
+
const server = setupServer(...handlers);
|
|
128
|
+
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
|
|
129
|
+
afterEach(() => server.resetHandlers());
|
|
130
|
+
afterAll(() => server.close());
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
```ts
|
|
134
|
+
// src/services/order.test.ts
|
|
135
|
+
import { describe, it, expect } from 'vitest';
|
|
136
|
+
import { listOrders } from './order';
|
|
137
|
+
|
|
138
|
+
describe('listOrders', () => {
|
|
139
|
+
it('返回订单列表', async () => {
|
|
140
|
+
const orders = await listOrders({ status: 'all' });
|
|
141
|
+
expect(orders).toHaveLength(1);
|
|
142
|
+
expect(orders[0].id).toBe('o1');
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
### Hook
|
|
148
|
+
|
|
149
|
+
```tsx
|
|
150
|
+
// src/hooks/useOrderList.test.tsx
|
|
151
|
+
import { renderHook, waitFor } from '@testing-library/react';
|
|
152
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
153
|
+
import { useOrderList } from './useOrderList';
|
|
154
|
+
|
|
155
|
+
function wrapper({ children }: { children: React.ReactNode }) {
|
|
156
|
+
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
157
|
+
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
it('加载订单列表', async () => {
|
|
161
|
+
const { result } = renderHook(() => useOrderList({ status: 'all' }), { wrapper });
|
|
162
|
+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
|
163
|
+
expect(result.current.data).toHaveLength(1);
|
|
164
|
+
});
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
### 组件(testing-library 范式:测行为不测实现)
|
|
168
|
+
|
|
169
|
+
```tsx
|
|
170
|
+
// src/components/OrderStatusBadge.test.tsx
|
|
171
|
+
import { render, screen } from '@testing-library/react';
|
|
172
|
+
import { OrderStatusBadge } from './OrderStatusBadge';
|
|
173
|
+
|
|
174
|
+
it('pending 状态显示"待处理"', () => {
|
|
175
|
+
render(<OrderStatusBadge status="pending" />);
|
|
176
|
+
expect(screen.getByText('待处理')).toBeInTheDocument();
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('cancelled 状态有 destructive 视觉', () => {
|
|
180
|
+
render(<OrderStatusBadge status="cancelled" />);
|
|
181
|
+
expect(screen.getByText('已取消')).toHaveClass('bg-destructive');
|
|
182
|
+
});
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
### 用户交互(`user-event`)
|
|
186
|
+
|
|
187
|
+
```tsx
|
|
188
|
+
import userEvent from '@testing-library/user-event';
|
|
189
|
+
|
|
190
|
+
it('点击提交按钮触发 onSubmit', async () => {
|
|
191
|
+
const onSubmit = vi.fn();
|
|
192
|
+
render(<CreateOrderForm onSubmit={onSubmit} />);
|
|
193
|
+
|
|
194
|
+
await userEvent.type(screen.getByLabelText('客户'), 'cust-1');
|
|
195
|
+
await userEvent.click(screen.getByRole('button', { name: '提交' }));
|
|
196
|
+
|
|
197
|
+
expect(onSubmit).toHaveBeenCalledWith({ customerId: 'cust-1', items: [] });
|
|
198
|
+
});
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
**不要**用 `fireEvent` —— 它不模拟真实键盘 / 鼠标事件序列,`user-event` 才贴近真实用户。
|
|
202
|
+
|
|
203
|
+
---
|
|
204
|
+
|
|
205
|
+
## §4 · 自定义 render(包 Provider)
|
|
206
|
+
|
|
207
|
+
每次 render 都重复套 QueryClient / Router / Theme 太繁琐,抽到 `src/test/render.tsx`:
|
|
208
|
+
|
|
209
|
+
```tsx
|
|
210
|
+
// src/test/render.tsx
|
|
211
|
+
import { render, type RenderOptions } from '@testing-library/react';
|
|
212
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
213
|
+
import { MemoryRouter } from 'react-router-dom';
|
|
214
|
+
|
|
215
|
+
export function renderWithProviders(
|
|
216
|
+
ui: React.ReactElement,
|
|
217
|
+
{ route = '/', ...options }: RenderOptions & { route?: string } = {},
|
|
218
|
+
) {
|
|
219
|
+
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
220
|
+
return render(
|
|
221
|
+
<QueryClientProvider client={qc}>
|
|
222
|
+
<MemoryRouter initialEntries={[route]}>{ui}</MemoryRouter>
|
|
223
|
+
</QueryClientProvider>,
|
|
224
|
+
options,
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
测试里:
|
|
230
|
+
|
|
231
|
+
```tsx
|
|
232
|
+
import { renderWithProviders } from '@/test/render';
|
|
233
|
+
|
|
234
|
+
renderWithProviders(<OrderListPage />, { route: '/orders?status=pending' });
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
---
|
|
238
|
+
|
|
239
|
+
## §5 · 反模式速查
|
|
240
|
+
|
|
241
|
+
| 反模式 | 为什么禁 | 应该 |
|
|
242
|
+
| --- | --- | --- |
|
|
243
|
+
| 测 `setState` 是否被调用 | 测了实现,重构即崩 | 测渲染结果 / 用户行为 |
|
|
244
|
+
| `getByTestId` 当默认查询 | data-testid 跟可访问性绑不上 | `getByRole` / `getByLabelText` 优先 |
|
|
245
|
+
| 一个 it 里 expect 10 次无关断言 | 失败定位难 | 拆多个 it |
|
|
246
|
+
| mock 整个 hook(`vi.mock('@/hooks/...')`) | 失去集成价值 | msw mock 后端,hook 真跑 |
|
|
247
|
+
| 自己 mock fetch / axios | 与生产代码路径不一致 | 用 msw |
|
|
248
|
+
| 测试里写 `setTimeout(..., 1000)` 等异步 | 慢且不稳定 | `await waitFor` / `findBy*` |
|
|
249
|
+
| 全局 mock console.error 静默 | 错过真实告警 | 让测试输出 console.error,但 setup 里 fail on it |
|
|
250
|
+
| 追求 80% 覆盖率刷数字 | 测了没价值的代码 | 按"优先级表"测,不看覆盖率盲冲 |
|
|
251
|
+
|
|
252
|
+
---
|
|
253
|
+
|
|
254
|
+
## §6 · 装机指引
|
|
255
|
+
|
|
256
|
+
```bash
|
|
257
|
+
pnpm add -D vitest @vitest/ui @testing-library/react @testing-library/user-event @testing-library/jest-dom jsdom msw
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
```ts
|
|
261
|
+
// vite.config.ts
|
|
262
|
+
import { defineConfig } from 'vitest/config';
|
|
263
|
+
|
|
264
|
+
export default defineConfig({
|
|
265
|
+
test: {
|
|
266
|
+
environment: 'jsdom',
|
|
267
|
+
setupFiles: ['./src/test/setup.ts'],
|
|
268
|
+
globals: true, // 可选:免 import describe / it / expect
|
|
269
|
+
},
|
|
270
|
+
});
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
```json
|
|
274
|
+
// package.json scripts
|
|
275
|
+
{
|
|
276
|
+
"test": "vitest",
|
|
277
|
+
"test:run": "vitest run",
|
|
278
|
+
"test:ui": "vitest --ui"
|
|
279
|
+
}
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
`msw` 还需要初始化 worker(用于 dev 环境)或 server(node):
|
|
283
|
+
|
|
284
|
+
```bash
|
|
285
|
+
pnpm exec msw init public/ --save # 浏览器 worker
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
---
|
|
289
|
+
|
|
290
|
+
## §7 · 何时跑测试
|
|
291
|
+
|
|
292
|
+
- **本地开发**:`pnpm test`(watch 模式),改文件即跑相邻 test
|
|
293
|
+
- **commit 前**:pre-commit hook 跑改动文件的 test(可选,慢就关)
|
|
294
|
+
- **CI**:`pnpm test:run`(必须,跑全量)
|
|
295
|
+
- **PR**:CI 红 → 不合并
|
|
296
|
+
|
|
297
|
+
---
|
|
298
|
+
|
|
299
|
+
## AI 必须输出的测试日志
|
|
300
|
+
|
|
301
|
+
新增 / 修改业务代码时,AI 必须在响应里说明测试覆盖情况:
|
|
302
|
+
|
|
303
|
+
```
|
|
304
|
+
## 测试覆盖
|
|
305
|
+
|
|
306
|
+
- 新增 src/utils/calcDiscount.ts → 同目录 calcDiscount.test.ts(5 个 case)
|
|
307
|
+
- 新增 src/services/order.ts:createOrder → order.test.ts(成功 / 422 字段错 / 500)
|
|
308
|
+
- 新增 src/hooks/useCreateOrder.ts → useCreateOrder.test.tsx(success / error)
|
|
309
|
+
- 复用 src/components/OrderStatusBadge.tsx → 已有 test 覆盖,无需补
|
|
310
|
+
- 跳过测: src/pages/orders/new/index.tsx(纯组合,关键逻辑已在 hook / form 层测)
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
如果跳过测试,**必须**写明原因(纯组合 / ui 包已测 / 一次性脚本)。
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: teamix-evo-design-rules
|
|
3
|
+
description: |
|
|
4
|
+
Apply teamix-evo design system rules when AI is asked to generate or review a full UI screen / page (variant-neutral baseline).
|
|
5
|
+
TRIGGER when: user asks to "新建 / 优化 / 重构 一个页面"、"做一个列表页 / 详情页 / 表单页 / 仪表盘"、"create / review / refactor a page、screen、dashboard、template"; intent involves layout structure, page-level information density, or multi-component composition; file write under `src/pages/**` or `src/templates/**`.
|
|
6
|
+
SKIP: single-component edits like "加个按钮"、"改 input 的 label"、"调 card 的 padding"、"add a tooltip" — those go to teamix-evo-coding-conventions; pure tokens / theme override edits — those go to ESLint and `tokens.overrides.css`; pure code refactor with no visual change; teamix-evo lifecycle commands — those go to teamix-evo-manage.
|
|
7
|
+
Coordinates with: teamix-evo-design-rules-<variant> (always layered when same-named variant is installed in `.teamix-evo/design/pack.lock.json`); teamix-evo-coding-conventions (run alongside when the screen also creates new files).
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
# teamix-evo-design-rules
|
|
11
|
+
|
|
12
|
+
This skill is the **AI dispatcher** for teamix-evo page-level UI work. It does **not** restate design rules; it routes the AI to the canonical content under `.teamix-evo/design/**` and to the skill's own behavioral helpers.
|
|
13
|
+
|
|
14
|
+
<!-- teamix-evo:managed:start id="core" -->
|
|
15
|
+
|
|
16
|
+
## How this skill is meant to be used
|
|
17
|
+
|
|
18
|
+
1. **Read the intent** the user gave you. Extract: (a) what page type — list / form / detail / dashboard / empty / review-pass — and (b) what domain entity (order / user / tenant / …).
|
|
19
|
+
2. **Fetch canonical content via MCP** (preferred path — see "Content access" below). Do not write code based on training-data assumptions about how a "list page" should look — read the project's actual rules first.
|
|
20
|
+
3. **Layer the variant overlay** if `.teamix-evo/design/pack.lock.json` shows a `variant` and a `teamix-evo-design-rules-<variant>` skill is also installed. The overlay's TRIGGER fires automatically when the baseline triggers; read it after the canonical patterns file but before generating.
|
|
21
|
+
4. **Run the 6-step generation flow** in [`generation-flow.md`](generation-flow.md). It's the behavioral procedure (intent → shell → IA → components → tokens → checklist).
|
|
22
|
+
5. **Apply the boundaries** in [`boundaries.md`](boundaries.md) — hard NOs (no inline color, no off-system icons, no arbitrary radius).
|
|
23
|
+
6. **Self-check** against [`checklist.md`](checklist.md) before declaring done. Cite each item explicitly: pass / fail / N/A.
|
|
24
|
+
|
|
25
|
+
## Content access — MCP first, file paths as fallback
|
|
26
|
+
|
|
27
|
+
The canonical design content lives in `.teamix-evo/design/**` in the consumer project. There are two equivalent ways to read it. **Prefer MCP tool calls** when available — they return focused slices and work in IDEs that don't expose the project filesystem to AI.
|
|
28
|
+
|
|
29
|
+
| What you need | MCP call (preferred) | File path (fallback) |
|
|
30
|
+
| --- | --- | --- |
|
|
31
|
+
| Principles + one-line definitions | `design_list_principles` | `.teamix-evo/design/philosophy/principles.md` |
|
|
32
|
+
| Tokens (semantic / base / theme.css) | `design_get_tokens` | `.teamix-evo/tokens/*` |
|
|
33
|
+
| Discover available patterns | `design_list_patterns` | `ls .teamix-evo/design/patterns/` |
|
|
34
|
+
| Read one pattern in full | `design_get_pattern { id: "<stem>" }` | `.teamix-evo/design/patterns/<stem>.md` |
|
|
35
|
+
| Brand tone / voice / examples | `design_get_brand` | `.teamix-evo/design/brand/{tone,voice,examples}.md` |
|
|
36
|
+
|
|
37
|
+
If the IDE has no MCP transport (or the `design` group is not loaded), fall back to file-path reads against the same directory tree. The content is identical — only the access path differs.
|
|
38
|
+
|
|
39
|
+
## Dispatch table — page type → canonical content
|
|
40
|
+
|
|
41
|
+
For each page-type intent, fetch **primary** content first, then **secondary** when the task touches that aspect (e.g. a list page with empty state needs `empty` as well).
|
|
42
|
+
|
|
43
|
+
| Page type | Trigger keywords | Primary (read first) | Secondary |
|
|
44
|
+
| --- | --- | --- | --- |
|
|
45
|
+
| **list** | "列表页"、"管理"、"查询"、"批量"、"list page"、"index page"、"CRUD list" | `design_get_pattern { id: "page-types" }` § ListPage | `design_get_pattern { id: "journeys" }`, `design_get_pattern { id: "flows" }` |
|
|
46
|
+
| **form** | "新建"、"创建"、"编辑"、"表单页"、"form"、"create page"、"edit page"、"wizard" | `design_get_pattern { id: "page-types" }` § FormPage | `design_get_tokens` (typography + spacing) |
|
|
47
|
+
| **detail** | "详情页"、"信息页"、"概览"、"detail page"、"resource page"、"profile" | `design_get_pattern { id: "page-types" }` § DetailPage | `design_get_pattern { id: "journeys" }` |
|
|
48
|
+
| **dashboard** | "仪表盘"、"概览大盘"、"监控"、"分析"、"dashboard"、"overview" | `design_get_pattern { id: "page-types" }` § Dashboard | `design_get_tokens` (effects, motion) |
|
|
49
|
+
| **empty** | "空态"、"空状态"、"无数据"、"无结果"、"empty state"、"zero state" | `design_get_pattern { id: "page-types" }` § 设计要点 (空状态行) | `design_get_brand` (voice + examples — 空态文案口吻) |
|
|
50
|
+
| **review-pass** | "审查页面"、"对照规范"、"review this screen"、"check against design rules" | [`checklist.md`](checklist.md) (skill-internal) | [`boundaries.md`](boundaries.md), then walk the relevant page-type row above |
|
|
51
|
+
|
|
52
|
+
If the intent matches multiple rows (e.g. "build a list page with empty state"), fetch **all matched primaries** before generating.
|
|
53
|
+
|
|
54
|
+
If the intent matches no row, default to `list` (CRUD pages dominate B-end work); if even that feels wrong, ask the user instead of guessing.
|
|
55
|
+
|
|
56
|
+
**Discovery first when uncertain**: if you don't know whether a pattern exists for a niche page type, call `design_list_patterns` first to see the full index — it includes variant-specific patterns (e.g. `cloud-page-types`) that may match better than the baseline.
|
|
57
|
+
|
|
58
|
+
## Variant overlay rule
|
|
59
|
+
|
|
60
|
+
If `.teamix-evo/design/pack.lock.json` records a non-default `variant` (e.g. `opentrek`, `uni-manager`), the matching `teamix-evo-design-rules-<variant>` skill auto-triggers alongside this one. Its content **overlays** (overrides on conflict) what's in the dispatch table. Always read the overlay AFTER the baseline canonical content, never instead of it.
|
|
61
|
+
|
|
62
|
+
Variant skills typically point at variant-specific patterns surfaced via `design_list_patterns` with `variantSpecific: true` (e.g. `cloud-page-types`, `cloud-resource-management`). When both a baseline and a variant pattern exist for the same intent, **prefer the variant pattern**. Trust the variant skill's own dispatch.
|
|
63
|
+
|
|
64
|
+
## Behavioral helpers (skill-internal — keep reading these here)
|
|
65
|
+
|
|
66
|
+
| File | Purpose |
|
|
67
|
+
| --- | --- |
|
|
68
|
+
| [`generation-flow.md`](generation-flow.md) | 6-step decision tree (intent → shell → IA → components → tokens → checklist). Behavioral procedure for AI, not design rules. |
|
|
69
|
+
| [`boundaries.md`](boundaries.md) | Hard NO list (refuse / refactor when violated). |
|
|
70
|
+
| [`checklist.md`](checklist.md) | 10-section self-review before declaring "done". |
|
|
71
|
+
| [`prompts/page-design.md`](prompts/page-design.md) | Drop-in prompt template the IDE can prefill the user's intent into. |
|
|
72
|
+
|
|
73
|
+
## Coordination with other skills
|
|
74
|
+
|
|
75
|
+
- [`teamix-evo-coding-conventions`](../teamix-evo-coding-conventions/SKILL.md) — file placement / reuse-first / API layering. Run alongside this skill when the page generation also writes new files; this skill never overrides coding conventions on file placement.
|
|
76
|
+
- `teamix-evo-design-rules-opentrek` — OpenTrek brand-specific overlay (tone / voice / brand color usage).
|
|
77
|
+
- `teamix-evo-design-rules-uni-manager` — Cloud-management overlay (AI scenarios A–G, danger ops, command center, topology).
|
|
78
|
+
- [`teamix-evo-manage`](../teamix-evo-manage/SKILL.md) — lifecycle (`init` / `update` / `uninstall`). Out of scope here.
|
|
79
|
+
|
|
80
|
+
<!-- teamix-evo:managed:end id="core" -->
|
|
81
|
+
|
|
82
|
+
## Project notes
|
|
83
|
+
|
|
84
|
+
> Free-form notes the user can add — CLI update will not overwrite below this section.
|
|
85
|
+
|
|
86
|
+
(empty)
|