@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,459 @@
|
|
|
1
|
+
# 表单与校验规范(uni-manager)
|
|
2
|
+
|
|
3
|
+
> **核心约定**:表单状态用 `react-hook-form`,schema 用 `zod`,**永远不要**用一堆 `useState` 拼表单。**uni-manager 额外约定:危险操作走 `useDangerConfirm`(输入资源完整名称才执行),切租户/区域时表单未保存内容必须二次确认。**
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## 为什么选这个组合(业界事实标准)
|
|
8
|
+
|
|
9
|
+
| 维度 | react-hook-form + zod | 多 `useState` 拼 |
|
|
10
|
+
| ---------- | ------------------------------------ | -------------------------- |
|
|
11
|
+
| 重渲染 | 字段级订阅,只渲染变化的字段 | 任意字段变化整个表单重渲染 |
|
|
12
|
+
| 校验 | schema 驱动,前后端共用 | 散落在 onChange 里,易漏 |
|
|
13
|
+
| 复杂表单 | resolver + watch + array fields 内建 | 自己实现一遍 |
|
|
14
|
+
| TypeScript | 从 schema 自动推导类型 | 手写类型,易漂移 |
|
|
15
|
+
|
|
16
|
+
参考:[React Hook Form](https://react-hook-form.com/) + [Zod](https://zod.dev/) 是 2024+ React 生态默认组合,被 shadcn/ui、Vercel、Linear、Cal.com 等广泛采用。
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## Schema 落点
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
src/services/
|
|
24
|
+
├── instance.ts # 请求函数:listInstances、createInstance
|
|
25
|
+
└── instance.schema.ts # zod schema:CreateInstanceInput、UpdateInstanceInput
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
约定:
|
|
29
|
+
|
|
30
|
+
- **每个 domain 一个 `<domain>.schema.ts`**,与 service 同目录
|
|
31
|
+
- 从 schema 派生类型:`export type CreateInstanceInput = z.infer<typeof CreateInstanceInputSchema>`
|
|
32
|
+
- service 的入参 / 返回类型**优先用** schema 推导,而不是手写 interface
|
|
33
|
+
- **不要**把 schema 写在页面 / 组件文件里 —— schema 是 domain 资产,跨页面复用
|
|
34
|
+
|
|
35
|
+
### 示例
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
// src/services/instance.schema.ts
|
|
39
|
+
import { z } from 'zod';
|
|
40
|
+
|
|
41
|
+
export const CreateInstanceInputSchema = z.object({
|
|
42
|
+
name: z.string().min(2, '名称至少 2 字').max(64),
|
|
43
|
+
cloudProvider: z.enum(['aws', 'aliyun', 'azure']),
|
|
44
|
+
region: z.string(),
|
|
45
|
+
spec: z.object({
|
|
46
|
+
cpu: z.number().int().positive(),
|
|
47
|
+
memoryGb: z.number().int().positive(),
|
|
48
|
+
}),
|
|
49
|
+
tags: z.record(z.string()).optional(),
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
export type CreateInstanceInput = z.infer<typeof CreateInstanceInputSchema>;
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
```ts
|
|
56
|
+
// src/services/instance.ts
|
|
57
|
+
import { http } from '@/lib/http';
|
|
58
|
+
import type { CreateInstanceInput } from './instance.schema';
|
|
59
|
+
import type { Instance } from '@/types/instance';
|
|
60
|
+
|
|
61
|
+
export async function createInstance(
|
|
62
|
+
input: CreateInstanceInput,
|
|
63
|
+
): Promise<Instance> {
|
|
64
|
+
// 不显式拼 tenantId — 走 lib/http.ts interceptor
|
|
65
|
+
const { data } = await http.post('/api/instances', input);
|
|
66
|
+
return data;
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
---
|
|
71
|
+
|
|
72
|
+
## 表单组件结构
|
|
73
|
+
|
|
74
|
+
### 简单表单(单页面专用)
|
|
75
|
+
|
|
76
|
+
放在页面私有目录:
|
|
77
|
+
|
|
78
|
+
```
|
|
79
|
+
src/pages/instances/new/
|
|
80
|
+
├── index.tsx # 路由组件
|
|
81
|
+
└── _components/
|
|
82
|
+
└── CreateInstanceForm.tsx # useForm + UI
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### 跨页面复用表单
|
|
86
|
+
|
|
87
|
+
升到 `src/components/forms/<Name>Form.tsx`:
|
|
88
|
+
|
|
89
|
+
```
|
|
90
|
+
src/components/forms/
|
|
91
|
+
├── TagEditorForm.tsx # 标签编辑(实例 / 桶 / VPC 都用)
|
|
92
|
+
└── CustomerSearchForm.tsx
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### 标准实现模板
|
|
96
|
+
|
|
97
|
+
```tsx
|
|
98
|
+
// src/pages/instances/new/_components/CreateInstanceForm.tsx
|
|
99
|
+
import { useForm } from 'react-hook-form';
|
|
100
|
+
import { zodResolver } from '@hookform/resolvers/zod';
|
|
101
|
+
import { useCreateInstance } from '@/hooks/useCreateInstance';
|
|
102
|
+
import {
|
|
103
|
+
CreateInstanceInputSchema,
|
|
104
|
+
type CreateInstanceInput,
|
|
105
|
+
} from '@/services/instance.schema';
|
|
106
|
+
import { Button, Input, Form, FormField, FormItem } from '@/components/ui';
|
|
107
|
+
|
|
108
|
+
export function CreateInstanceForm() {
|
|
109
|
+
const form = useForm<CreateInstanceInput>({
|
|
110
|
+
resolver: zodResolver(CreateInstanceInputSchema),
|
|
111
|
+
defaultValues: {
|
|
112
|
+
name: '',
|
|
113
|
+
cloudProvider: 'aliyun',
|
|
114
|
+
region: '',
|
|
115
|
+
spec: { cpu: 2, memoryGb: 4 },
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
const mutation = useCreateInstance();
|
|
119
|
+
|
|
120
|
+
return (
|
|
121
|
+
<Form {...form}>
|
|
122
|
+
<form
|
|
123
|
+
onSubmit={form.handleSubmit((values) => mutation.mutate(values))}
|
|
124
|
+
className="space-y-4"
|
|
125
|
+
>
|
|
126
|
+
<FormField
|
|
127
|
+
control={form.control}
|
|
128
|
+
name="name"
|
|
129
|
+
render={({ field }) => (
|
|
130
|
+
<FormItem>
|
|
131
|
+
<Input {...field} />
|
|
132
|
+
</FormItem>
|
|
133
|
+
)}
|
|
134
|
+
/>
|
|
135
|
+
{/* ... 其他字段 */}
|
|
136
|
+
<Button type="submit" disabled={mutation.isPending}>
|
|
137
|
+
{mutation.isPending ? '提交中…' : '创建'}
|
|
138
|
+
</Button>
|
|
139
|
+
</form>
|
|
140
|
+
</Form>
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
---
|
|
146
|
+
|
|
147
|
+
## 校验规则
|
|
148
|
+
|
|
149
|
+
| 场景 | 写在哪 |
|
|
150
|
+
| ------------------------------------------------- | ----------------------------------------------- |
|
|
151
|
+
| 字段格式(邮箱 / 手机号 / URL / 资源命名规则) | zod schema(`z.string().email()`) |
|
|
152
|
+
| 业务规则(库存检查 / 唯一性 / 配额检查) | service 层,服务端返回错误 → hook 暴露 → UI 显示 |
|
|
153
|
+
| 跨字段约束(开始日期 < 结束日期 / cpu/memory 比例) | zod `.refine()` / `.superRefine()` |
|
|
154
|
+
| 异步校验(实例名是否被占用) | `useQuery` + debounce,**不放 schema** |
|
|
155
|
+
|
|
156
|
+
### 反模式
|
|
157
|
+
|
|
158
|
+
- ❌ 在 `onChange` 里手写 `if (value.length > 20) setError(...)`
|
|
159
|
+
- ❌ 校验报错信息硬编码在组件里("请输入正确的实例名") —— 写在 schema 里,UI 只读
|
|
160
|
+
- ❌ 表单状态用 `useState`,然后再加 `useEffect` 同步校验 —— 直接用 `useForm`
|
|
161
|
+
- ❌ schema 在组件文件里就地定义 —— 抽到 `<domain>.schema.ts`
|
|
162
|
+
- ❌ 提交逻辑写在 `<form onSubmit>` 里直接 fetch —— 走 mutation hook
|
|
163
|
+
|
|
164
|
+
---
|
|
165
|
+
|
|
166
|
+
## 表单提交错误如何展示
|
|
167
|
+
|
|
168
|
+
```tsx
|
|
169
|
+
const mutation = useCreateInstance();
|
|
170
|
+
|
|
171
|
+
// 提交后:
|
|
172
|
+
mutation.mutate(values, {
|
|
173
|
+
onError: (err) => {
|
|
174
|
+
// 1. 字段级错误回填 form(后端返回 { field, message }[])
|
|
175
|
+
if (isFieldErrors(err)) {
|
|
176
|
+
err.fields.forEach(({ field, message }) =>
|
|
177
|
+
form.setError(field as keyof CreateInstanceInput, { message }),
|
|
178
|
+
);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
// 2. 通用错误走 toast(全局错误归一化在 lib/http.ts)
|
|
182
|
+
toast.error(err.message);
|
|
183
|
+
},
|
|
184
|
+
});
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
要点:
|
|
188
|
+
|
|
189
|
+
- 字段级错误 → `form.setError`(高亮对应输入框)
|
|
190
|
+
- 通用错误 → `toast`(由 `lib/http.ts` 已归一化的 `Error.message`)
|
|
191
|
+
- **不要**把后端错误对象塞到 form,先归一化
|
|
192
|
+
|
|
193
|
+
---
|
|
194
|
+
|
|
195
|
+
## §9 · 切换租户/区域时未保存内容保护
|
|
196
|
+
|
|
197
|
+
uni-manager 业务页面常见 — 用户填了一半表单,顺手在 um-topbar 切了租户。**必须**用 React Router 的 `useBlocker` 拦截:
|
|
198
|
+
|
|
199
|
+
```tsx
|
|
200
|
+
// 在表单组件内
|
|
201
|
+
import { useBlocker } from 'react-router-dom';
|
|
202
|
+
import { AlertDialog } from '@/components/ui';
|
|
203
|
+
|
|
204
|
+
export function CreateInstanceForm() {
|
|
205
|
+
const form = useForm<CreateInstanceInput>({...});
|
|
206
|
+
const isDirty = form.formState.isDirty;
|
|
207
|
+
|
|
208
|
+
// 拦截路由变化(包括 search params 改变 → 切租户/区域)
|
|
209
|
+
const blocker = useBlocker(({ currentLocation, nextLocation }) =>
|
|
210
|
+
isDirty &&
|
|
211
|
+
(currentLocation.pathname !== nextLocation.pathname ||
|
|
212
|
+
currentLocation.search !== nextLocation.search),
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
return (
|
|
216
|
+
<>
|
|
217
|
+
<Form {...form}>
|
|
218
|
+
{/* 表单内容 */}
|
|
219
|
+
</Form>
|
|
220
|
+
<AlertDialog
|
|
221
|
+
open={blocker.state === 'blocked'}
|
|
222
|
+
title="放弃未保存的内容?"
|
|
223
|
+
description="切换上下文将丢失当前填写。继续操作前请先保存或确认放弃。"
|
|
224
|
+
onConfirm={() => blocker.proceed?.()}
|
|
225
|
+
onCancel={() => blocker.reset?.()}
|
|
226
|
+
confirmLabel="放弃并切换"
|
|
227
|
+
cancelLabel="留下"
|
|
228
|
+
/>
|
|
229
|
+
</>
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
要点:
|
|
235
|
+
|
|
236
|
+
- 仅当 `isDirty` 为 true 时拦截,否则放行
|
|
237
|
+
- AlertDialog 文案明确告知**会丢失什么**
|
|
238
|
+
- 关闭/刷新页面也要拦截:`useBeforeUnload(isDirty ? '...' : null)`
|
|
239
|
+
|
|
240
|
+
---
|
|
241
|
+
|
|
242
|
+
## §10 · 危险操作:`useDangerConfirm`(uni-manager 红线)
|
|
243
|
+
|
|
244
|
+
uni-manager 涉及云资源的删除/释放/销毁/转交租户**禁止**使用普通 `confirm("确定?")` 或简单 `<AlertDialog>`,**必须**走 `useDangerConfirm`:用户输入完整资源名称才能 enable 确认按钮。
|
|
245
|
+
|
|
246
|
+
### Hook 完整实现
|
|
247
|
+
|
|
248
|
+
```tsx
|
|
249
|
+
// src/hooks/useDangerConfirm.ts
|
|
250
|
+
import { useState, useCallback } from 'react';
|
|
251
|
+
|
|
252
|
+
export interface DangerConfirmOptions {
|
|
253
|
+
/** 操作动词:"释放" / "删除" / "销毁" / "转交" */
|
|
254
|
+
action: string;
|
|
255
|
+
/** 资源类型:"实例" / "对象存储桶" / ... */
|
|
256
|
+
resourceType: string;
|
|
257
|
+
/** 资源完整名称或 ID(用户必须原样输入) */
|
|
258
|
+
resourceName: string;
|
|
259
|
+
/** 额外警告(可选) */
|
|
260
|
+
warning?: string;
|
|
261
|
+
/** 实际执行的异步操作 */
|
|
262
|
+
perform: () => Promise<void> | void;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export interface DangerConfirmState {
|
|
266
|
+
/** 打开 dialog */
|
|
267
|
+
open: (opts: DangerConfirmOptions) => void;
|
|
268
|
+
/** 当前 dialog 配置;null 表示未打开 */
|
|
269
|
+
current: DangerConfirmOptions | null;
|
|
270
|
+
/** 关闭(取消) */
|
|
271
|
+
close: () => void;
|
|
272
|
+
/** 用户输入框的值 */
|
|
273
|
+
input: string;
|
|
274
|
+
setInput: (v: string) => void;
|
|
275
|
+
/** 执行(必须 input === resourceName 才有效) */
|
|
276
|
+
confirm: () => Promise<void>;
|
|
277
|
+
/** 是否可以确认 */
|
|
278
|
+
canConfirm: boolean;
|
|
279
|
+
/** 是否正在执行 */
|
|
280
|
+
isPerforming: boolean;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export function useDangerConfirm(): DangerConfirmState {
|
|
284
|
+
const [current, setCurrent] = useState<DangerConfirmOptions | null>(null);
|
|
285
|
+
const [input, setInput] = useState('');
|
|
286
|
+
const [isPerforming, setIsPerforming] = useState(false);
|
|
287
|
+
|
|
288
|
+
const open = useCallback((opts: DangerConfirmOptions) => {
|
|
289
|
+
setCurrent(opts);
|
|
290
|
+
setInput('');
|
|
291
|
+
setIsPerforming(false);
|
|
292
|
+
}, []);
|
|
293
|
+
|
|
294
|
+
const close = useCallback(() => {
|
|
295
|
+
setCurrent(null);
|
|
296
|
+
setInput('');
|
|
297
|
+
setIsPerforming(false);
|
|
298
|
+
}, []);
|
|
299
|
+
|
|
300
|
+
const canConfirm =
|
|
301
|
+
current !== null && input === current.resourceName && !isPerforming;
|
|
302
|
+
|
|
303
|
+
const confirm = useCallback(async () => {
|
|
304
|
+
if (!canConfirm || !current) return;
|
|
305
|
+
setIsPerforming(true);
|
|
306
|
+
try {
|
|
307
|
+
await current.perform();
|
|
308
|
+
close();
|
|
309
|
+
} catch (e) {
|
|
310
|
+
// 失败不关闭 dialog,让用户看到错误后决定下一步
|
|
311
|
+
setIsPerforming(false);
|
|
312
|
+
throw e;
|
|
313
|
+
}
|
|
314
|
+
}, [canConfirm, current, close]);
|
|
315
|
+
|
|
316
|
+
return {
|
|
317
|
+
open,
|
|
318
|
+
current,
|
|
319
|
+
close,
|
|
320
|
+
input,
|
|
321
|
+
setInput,
|
|
322
|
+
confirm,
|
|
323
|
+
canConfirm,
|
|
324
|
+
isPerforming,
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
### 配套 Dialog 组件
|
|
330
|
+
|
|
331
|
+
```tsx
|
|
332
|
+
// src/components/danger/DangerConfirmDialog.tsx
|
|
333
|
+
import { Dialog, DialogContent, Button, Input, Label } from '@/components/ui';
|
|
334
|
+
import { type DangerConfirmState } from '@/hooks/useDangerConfirm';
|
|
335
|
+
|
|
336
|
+
export function DangerConfirmDialog({ state }: { state: DangerConfirmState }) {
|
|
337
|
+
const { current, close, input, setInput, confirm, canConfirm, isPerforming } =
|
|
338
|
+
state;
|
|
339
|
+
if (!current) return null;
|
|
340
|
+
|
|
341
|
+
return (
|
|
342
|
+
<Dialog open onOpenChange={(o) => !o && close()}>
|
|
343
|
+
<DialogContent>
|
|
344
|
+
<h2 className="text-lg font-semibold">
|
|
345
|
+
{current.action}
|
|
346
|
+
{current.resourceType} {current.resourceName}
|
|
347
|
+
</h2>
|
|
348
|
+
<p className="text-muted-foreground mt-2 text-sm">
|
|
349
|
+
此操作不可恢复。{current.warning ?? ''}
|
|
350
|
+
</p>
|
|
351
|
+
<div className="mt-4 space-y-2">
|
|
352
|
+
<Label htmlFor="danger-input">
|
|
353
|
+
请输入 <code>{current.resourceName}</code> 以确认
|
|
354
|
+
</Label>
|
|
355
|
+
<Input
|
|
356
|
+
id="danger-input"
|
|
357
|
+
value={input}
|
|
358
|
+
onChange={(e) => setInput(e.target.value)}
|
|
359
|
+
placeholder={current.resourceName}
|
|
360
|
+
autoComplete="off"
|
|
361
|
+
/>
|
|
362
|
+
</div>
|
|
363
|
+
<div className="mt-6 flex justify-end gap-2">
|
|
364
|
+
<Button variant="outline" onClick={close} disabled={isPerforming}>
|
|
365
|
+
取消
|
|
366
|
+
</Button>
|
|
367
|
+
<Button
|
|
368
|
+
variant="destructive"
|
|
369
|
+
disabled={!canConfirm}
|
|
370
|
+
onClick={confirm}
|
|
371
|
+
>
|
|
372
|
+
{isPerforming ? '执行中…' : `确认${current.action}`}
|
|
373
|
+
</Button>
|
|
374
|
+
</div>
|
|
375
|
+
</DialogContent>
|
|
376
|
+
</Dialog>
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
### 使用范式
|
|
382
|
+
|
|
383
|
+
```tsx
|
|
384
|
+
// 列表页释放实例
|
|
385
|
+
function InstanceListPage() {
|
|
386
|
+
const danger = useDangerConfirm();
|
|
387
|
+
const release = useReleaseInstance();
|
|
388
|
+
|
|
389
|
+
const onClickRelease = (instance: Instance) => {
|
|
390
|
+
danger.open({
|
|
391
|
+
action: '释放',
|
|
392
|
+
resourceType: '实例',
|
|
393
|
+
resourceName: instance.id,
|
|
394
|
+
warning: `${instance.cloudProvider}/${instance.region} 区域的资源将立即释放,数据无法恢复。`,
|
|
395
|
+
perform: async () => {
|
|
396
|
+
await release.mutateAsync(instance.id);
|
|
397
|
+
toast.success(`实例 ${instance.id} 已释放`);
|
|
398
|
+
},
|
|
399
|
+
});
|
|
400
|
+
};
|
|
401
|
+
|
|
402
|
+
return (
|
|
403
|
+
<>
|
|
404
|
+
<Table onRelease={onClickRelease} />
|
|
405
|
+
<DangerConfirmDialog state={danger} />
|
|
406
|
+
</>
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
```
|
|
410
|
+
|
|
411
|
+
### 红线
|
|
412
|
+
|
|
413
|
+
- ❌ 用 `confirm('确定释放?')` —— 浏览器原生 dialog 一律禁用
|
|
414
|
+
- ❌ 用普通 AlertDialog "确定/取消" 二选一(无输入校验)
|
|
415
|
+
- ❌ resourceName 用 ID 缩写、占位符 `xxx`、显示名(必须用**完整不可歧义**的标识,通常是 ID)
|
|
416
|
+
- ❌ 大批量释放(选中 12 个实例)直接弹一个 dialog —— 列出全部 ID + 输入 "DELETE 12" 类似校验
|
|
417
|
+
|
|
418
|
+
---
|
|
419
|
+
|
|
420
|
+
## 与 ui 包的边界
|
|
421
|
+
|
|
422
|
+
`@teamix-evo/ui` 提供 `Form`、`FormField`、`FormItem`、`FormLabel`、`FormMessage`(shadcn 同款,基于 react-hook-form Context)。
|
|
423
|
+
|
|
424
|
+
- **必须用 ui 包提供的 Form 组件**,不要自己 `<form>` + `<label>` 手撸
|
|
425
|
+
- 业务表单只负责:schema + 字段 + 提交 callback,UI 一律走 ui 包
|
|
426
|
+
|
|
427
|
+
uni-manager 工程内**额外**提供(后续抽到 biz-ui/uni-manager):
|
|
428
|
+
|
|
429
|
+
- `useDangerConfirm` Hook
|
|
430
|
+
- `DangerConfirmDialog` 组件
|
|
431
|
+
- `TagEditorForm`(标签编辑跨多种资源)
|
|
432
|
+
- `RegionSelector`(基于 cloudProvider 联动 region 列表)
|
|
433
|
+
|
|
434
|
+
---
|
|
435
|
+
|
|
436
|
+
## 装机指引
|
|
437
|
+
|
|
438
|
+
如果项目里还没装:
|
|
439
|
+
|
|
440
|
+
```bash
|
|
441
|
+
pnpm add react-hook-form zod @hookform/resolvers
|
|
442
|
+
```
|
|
443
|
+
|
|
444
|
+
`@hookform/resolvers` 提供 `zodResolver`,把 zod schema 接入 react-hook-form。
|
|
445
|
+
|
|
446
|
+
---
|
|
447
|
+
|
|
448
|
+
## AI 必须输出的表单日志
|
|
449
|
+
|
|
450
|
+
```
|
|
451
|
+
## 表单改动
|
|
452
|
+
|
|
453
|
+
- src/services/instance.schema.ts: 新增 CreateInstanceInputSchema(zod)、派生类型
|
|
454
|
+
- src/services/instance.ts: createInstance 入参用派生类型(不显式拼 tenantId)
|
|
455
|
+
- src/pages/instances/new/_components/CreateInstanceForm.tsx: useForm + zodResolver + useBlocker(脏态拦截)
|
|
456
|
+
- src/hooks/useCreateInstance.ts: useMutation 包 createInstance,onError 区分字段级 / 通用
|
|
457
|
+
- 危险操作: ✅ 释放实例走 useDangerConfirm(输入 instance.id 校验)
|
|
458
|
+
- 切租户保护: ✅ 表单 isDirty 时拦截 search 变化
|
|
459
|
+
```
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# Reuse-First · 组件 / 工具复用决策流(uni-manager)
|
|
2
|
+
|
|
3
|
+
> 在写**任何**新组件 / Hook / 工具函数之前,AI 必须按本流程跑一遍。"没找到现成的"必须是查询的结论,不能是默认假设。
|
|
4
|
+
>
|
|
5
|
+
> uni-manager 在 OpenTrek 通用流程之上,叠加 **biz-ui/uni-manager** 实物层与"组件兜底铁律"。
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## 决策树
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
收到「写一个 xxx」请求
|
|
13
|
+
│
|
|
14
|
+
├── Step 1: 这是 UI 组件吗?
|
|
15
|
+
│ ├── 是 → 走 §1(查 @teamix-evo/ui 注册表)
|
|
16
|
+
│ └── 否 → 跳 Step 2
|
|
17
|
+
│
|
|
18
|
+
├── Step 2: 这是 uni-manager 专属壳层 / 跨云组件吗?
|
|
19
|
+
│ ├── 是 → 走 §2(查 biz-ui/uni-manager + 概念占位拼装表)
|
|
20
|
+
│ └── 否 → 跳 Step 3
|
|
21
|
+
│
|
|
22
|
+
├── Step 3: 这是业务组件 / 页面骨架吗?
|
|
23
|
+
│ ├── 是 → 走 §3(查 @teamix-evo/biz-ui;页面骨架走 design skill 的 patterns/)
|
|
24
|
+
│ └── 否 → 跳 Step 4
|
|
25
|
+
│
|
|
26
|
+
├── Step 4: 这是工具函数 / Hook 吗?
|
|
27
|
+
│ ├── 是 → 走 §4(grep 本项目 + 查常见库)
|
|
28
|
+
│ └── 否 → 跳 Step 5
|
|
29
|
+
│
|
|
30
|
+
└── Step 5: 全部未命中 → 走 §5(写新代码,但要遵守归位规则)
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## §1 · UI 组件:必须先查 `@teamix-evo/ui` 注册表
|
|
36
|
+
|
|
37
|
+
`@teamix-evo/ui` 是源码注入式 UI 组件库,**89 个条目**(button、input、dialog、data-table、…)。在写新 UI 组件之前:
|
|
38
|
+
|
|
39
|
+
### 查询顺序(从快到慢)
|
|
40
|
+
|
|
41
|
+
1. **MCP `list_components`** —— 一次列全部,扫描是否有同名 / 近义。
|
|
42
|
+
2. **MCP `find_components` query=<关键词>** —— 子串匹配 id / name / description。
|
|
43
|
+
3. **本项目 grep** —— `grep -r "from '@/components/ui'" src/` 看哪些已经装机。
|
|
44
|
+
4. **本项目 `.teamix-evo/ui/manifest.lock.json`** —— 已装机的 ui entry 清单。
|
|
45
|
+
|
|
46
|
+
### 命中后的处理
|
|
47
|
+
|
|
48
|
+
| 情况 | 处理 |
|
|
49
|
+
| ------------------------------- | ------------------------------------------------- |
|
|
50
|
+
| 注册表里有,本项目已装 | 直接 `import` 复用,**不要复制源码** |
|
|
51
|
+
| 注册表里有,本项目未装 | 提示用户跑 `teamix-evo ui add <id>`,装机后再用 |
|
|
52
|
+
| 注册表里有近义但 props 不完全够 | **优先组合**(包一层 wrapper),不要 fork ui 源码 |
|
|
53
|
+
| 注册表里完全没有 | 进 §2 / §5 |
|
|
54
|
+
|
|
55
|
+
### 反模式(禁止)
|
|
56
|
+
|
|
57
|
+
- ❌ 在 `src/components/` 下重写 `Button` / `Input` / `Dialog`(已经在 ui 包里)
|
|
58
|
+
- ❌ 复制 ui 包源码到 `src/components/` 改样式 —— 改样式应当通过 design token 覆盖
|
|
59
|
+
- ❌ 包 `antd` / `mui` / `chakra` 的同类组件 —— teamix-evo 的设计系统是 shadcn-based,混用会破坏 token 链路
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
## §2 · uni-manager 专属壳层 / 跨云组件:查 `biz-ui/uni-manager`
|
|
64
|
+
|
|
65
|
+
### 已实装
|
|
66
|
+
|
|
67
|
+
| 组件 | 包 | 用途 |
|
|
68
|
+
| ------------- | -------------------------------- | --------------------------------------------------------------------------------------- |
|
|
69
|
+
| **um-topbar** | `@teamix-evo/biz-ui/uni-manager` | 全局顶部栏(logo + 租户切换 + 区域 + 用户);**所有 L1/L2 页面必须用,禁止自建**(UM1) |
|
|
70
|
+
|
|
71
|
+
### 概念占位(设计文档已定义,代码层尚未抽实物 — AI 在本项目内组合 ui 原子件实现,并按下表命名)
|
|
72
|
+
|
|
73
|
+
| 概念名 | 推荐组合 | 应该放在哪 |
|
|
74
|
+
| ----------------------- | ----------------------------------------- | ------------------------------------------------------------------ |
|
|
75
|
+
| **CloudBadge** | `Badge` + 云厂商图标 | `src/components/cloud/CloudBadge.tsx` |
|
|
76
|
+
| **RegionBadge** | `Badge` | `src/components/cloud/RegionBadge.tsx` |
|
|
77
|
+
| **ContextSwitcher** | `Select` / `Combobox`(含搜索) | `src/components/cloud/ContextSwitcher.tsx`(如未走 um-topbar) |
|
|
78
|
+
| **BulkActionBar** | `Card`(底部) + `Button` 列 | `src/components/list/BulkActionBar.tsx` |
|
|
79
|
+
| **OperationLog** | `Tabs` + `Timeline` + `Pagination` | `src/components/log/OperationLog.tsx` |
|
|
80
|
+
| **ResourceCard** | `Card` + `DescriptionList` + `CloudBadge` | `src/components/resource/ResourceCard.tsx` |
|
|
81
|
+
| **DangerConfirmDialog** | `AlertDialog` + `Input`(输入资源名) | `src/components/danger/DangerConfirmDialog.tsx`(或走 hook,见下) |
|
|
82
|
+
|
|
83
|
+
### 组件兜底铁律(最高优先)
|
|
84
|
+
|
|
85
|
+
> "找不到现成的实物组件" → **优先用 ui 原子件组合**,并按上表的概念名归位。**禁止**:
|
|
86
|
+
>
|
|
87
|
+
> - ❌ 自撸 `Topbar` / `CustomDialog` / `MyButton` 等基础件
|
|
88
|
+
> - ❌ 直接在页面里散写 `<div className="flex ...">` 拼复合形态(应抽到 `components/`)
|
|
89
|
+
> - ❌ 跨页面重复同样组合 3 次以上还不抽组件
|
|
90
|
+
|
|
91
|
+
### 决策表
|
|
92
|
+
|
|
93
|
+
| 场景 | 选择 |
|
|
94
|
+
| ----------------------------------- | ------------------------------------------------------------------------------ |
|
|
95
|
+
| 全局 topbar / 工作台首屏顶部 | `um-topbar`(biz-ui/uni-manager 实物) |
|
|
96
|
+
| 列表 / 详情页中标识资源云厂商 | `CloudBadge`(本项目内概念组合) |
|
|
97
|
+
| 表单 / 列表跨账号或跨区域上下文切换 | `ContextSwitcher`(在 um-topbar 已就位时勿重复) |
|
|
98
|
+
| 列表多选后底部批量操作栏 | `BulkActionBar` |
|
|
99
|
+
| 资源详情页"操作日志" Tab | `OperationLog` |
|
|
100
|
+
| 危险操作(删除 / 释放 / 销毁) | `DangerConfirmDialog` 或 `useDangerConfirm` hook(见 forms-and-validation.md) |
|
|
101
|
+
|
|
102
|
+
---
|
|
103
|
+
|
|
104
|
+
## §3 · 业务组件:查 `biz-ui`(页面骨架走 design skill 的 patterns/)
|
|
105
|
+
|
|
106
|
+
| 包 | 用途 | 查询入口 |
|
|
107
|
+
| -------------------- | ---------------------------------------- | ------------------------------------------------------ |
|
|
108
|
+
| `@teamix-evo/biz-ui` | 业务化的复合组件(变体感知 + slot 边界) | 本项目 `node_modules/@teamix-evo/biz-ui/manifest.json` |
|
|
109
|
+
|
|
110
|
+
### 页面骨架来源(列表 / 详情 / 表单 / Console Home)
|
|
111
|
+
|
|
112
|
+
> ⚠️ **AI 默认不调用 `@teamix-evo/templates` 包**(见 [ADR 0031](../../../docs/adr/0031-skill-templates-decoupling.md))。页面骨架按下列优先级生成:
|
|
113
|
+
|
|
114
|
+
1. **首选** —— 读 `teamix-evo-design-uni-manager` skill 的 [`patterns/{list|detail|form|dashboard}-page.md`](../teamix-evo-design-uni-manager/patterns/),按 Zone Map 与决策树用 ui 原子件 + biz-ui/uni-manager 实物件(`um-topbar`) + 概念占位拼装表**直接拼装**到 `src/pages/<id>/`
|
|
115
|
+
2. **patterns/ 未覆盖时** —— 按业界流行的云管 / 控制台页面架构(antd Pro / aliyun-console / shadcn Examples 等)与用户描述自由实现,**不要回退到 templates 包**
|
|
116
|
+
3. **`@teamix-evo/templates` 包与 CLI 命令仍保留**,但仅在用户**显式**要求时才走 `teamix-evo templates add`
|
|
117
|
+
|
|
118
|
+
### 决策表
|
|
119
|
+
|
|
120
|
+
| 场景 | 选择 |
|
|
121
|
+
| ---------------------------------------------------- | -------------------------------------------------------------- |
|
|
122
|
+
| 列表页 / 详情页 / 表单页 / Console Home 骨架 | 读 design skill `patterns/*.md` → ui + biz-ui/uni-manager 拼装 |
|
|
123
|
+
| “带筛选的资源表格”、“带审批流的工单卡片” | 优先 `biz-ui` |
|
|
124
|
+
| 一次性的、强领域绑定的组合 | 写在 `src/components/<domain>/` 里,**不要污染 biz-ui** |
|
|
125
|
+
| uni-manager 跨云专属(CloudBadge / OperationLog 等) | 走 §2 概念占位拼装表 |
|
|
126
|
+
| patterns/ 未覆盖的特殊页面 | 按业界流行架构 + 用户描述自由实现,**不调用 templates 包** |
|
|
127
|
+
|
|
128
|
+
---
|
|
129
|
+
|
|
130
|
+
## §4 · 工具函数 / Hook:本项目优先,再查通用库
|
|
131
|
+
|
|
132
|
+
### 顺序
|
|
133
|
+
|
|
134
|
+
1. **本项目 grep**:
|
|
135
|
+
```bash
|
|
136
|
+
grep -rn "function <近义名>" src/utils/ src/lib/ src/hooks/
|
|
137
|
+
```
|
|
138
|
+
2. **`@teamix-evo/*` 是否提供**(少数情况,如格式化、token 工具)
|
|
139
|
+
3. **依赖里的成熟库** —— 例如:
|
|
140
|
+
- 日期 → `date-fns` / `dayjs`(看项目里已经装哪个,不要再装第三个)
|
|
141
|
+
- 数据请求 → `@tanstack/react-query` / `swr`
|
|
142
|
+
- 表单 → `react-hook-form` + `zod`
|
|
143
|
+
- 工具 → `lodash-es`、`es-toolkit`
|
|
144
|
+
4. **uni-manager 推荐内置 Hook**:
|
|
145
|
+
- `useTenant()` —— 读当前租户上下文(`src/contexts/TenantContext.tsx`)
|
|
146
|
+
- `useRegion()` —— 读当前区域
|
|
147
|
+
- `useCloudProvider()` —— 读当前云厂商
|
|
148
|
+
- `useDangerConfirm()` —— 危险操作输入名称确认(封装 AlertDialog)
|
|
149
|
+
5. **完全没有现成** → 进 §5
|
|
150
|
+
|
|
151
|
+
### 反模式
|
|
152
|
+
|
|
153
|
+
- ❌ 写 `formatDate` 时不看项目用的是 dayjs 还是 date-fns,凭直觉新加一个
|
|
154
|
+
- ❌ 同一项目里既装 `lodash` 又装 `lodash-es` 又装 `es-toolkit`
|
|
155
|
+
- ❌ 自己写防抖 / 节流 / 深拷贝(标准库都有)
|
|
156
|
+
- ❌ 在每个组件里 `useContext(TenantContext)` —— 用 `useTenant()` 包装 hook
|
|
157
|
+
- ❌ 自撸"输入文本一致才能确认"的 Dialog —— 用 `useDangerConfirm`
|
|
158
|
+
|
|
159
|
+
---
|
|
160
|
+
|
|
161
|
+
## §5 · 必须新写时的归位规则
|
|
162
|
+
|
|
163
|
+
走到这一步,代码确实需要新写。归位见 [`file-structure.md`](file-structure.md);本节只讲**写新代码时**的最小约束:
|
|
164
|
+
|
|
165
|
+
1. **命名要能让下次复用查得到** —— 起 `InstanceStatusBadge` 而不是 `MyBadge`;`useInstanceList` 而不是 `useList`
|
|
166
|
+
2. **抽象层级别低于一次性页面** —— 跨页面会用的才放 `src/components/`,只在一个页面用的写在 `src/pages/<id>/_components/`
|
|
167
|
+
3. **暴露面尽量窄** —— 默认只 `export` 这次需要的 API,别一次性 `export *`
|
|
168
|
+
4. **写完登记** —— 在 PR 描述里明确“新增 `<path>`,因为 ui / biz-ui / patterns / 本项目均未提供 X 能力”(注:不再以 `templates` 包作为兜底依据,见 [ADR 0031](../../../docs/adr/0031-skill-templates-decoupling.md))
|
|
169
|
+
5. **uni-manager 专属**:跨云 / 跨租户的 component 必须放 `src/components/cloud/` 或 `src/components/<domain>/`,方便后续抽到 biz-ui/uni-manager
|
|
170
|
+
|
|
171
|
+
---
|
|
172
|
+
|
|
173
|
+
## AI 必须输出的决策日志
|
|
174
|
+
|
|
175
|
+
完成代码改动时,AI **必须**在响应里附一段决策日志,例如:
|
|
176
|
+
|
|
177
|
+
```
|
|
178
|
+
## 复用决策
|
|
179
|
+
|
|
180
|
+
- ✅ 复用 `@teamix-evo/biz-ui/uni-manager` 的 `um-topbar`(已装机,UM1 满足)
|
|
181
|
+
- ✅ 复用 `@teamix-evo/ui` 的 `DataTable`、`Button`、`Badge`、`AlertDialog`
|
|
182
|
+
- ✅ 复用本项目 `src/components/cloud/CloudBadge.tsx`(已存在,符合 §2 概念占位)
|
|
183
|
+
- ✅ 复用本项目 hook `useDangerConfirm`(用于"删除实例"二次确认)
|
|
184
|
+
- 🆕 新写 `src/components/cloud/RegionTopologyMap.tsx` —— ui 包提供通用 `ScrollArea`,但区域拓扑图为业务专属,组合一层
|
|
185
|
+
- ✅ 复用 `src/utils/format.ts` 的 `formatBytes`
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
没有这段日志,任务不算完成。
|