@ai0x0/utils 0.0.32 → 0.0.33
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/.agents/skills/ai0x0-utils/backend/SKILL.md +366 -0
- package/.agents/skills/antd/SKILL.md +255 -0
- package/.agents/skills/cloudflare/SKILL.md +132 -0
- package/.agents/skills/nextjs/SKILL.md +252 -0
- package/README.md +34 -0
- package/eslint-rules/no-hardcoded-style.js +103 -42
- package/eslint-rules/require-section-divider.js +30 -11
- package/eslint-rules/require-use-form.js +12 -5
- package/package.json +2 -1
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: ai0x0-utils/backend
|
|
3
|
+
description: Scaffold Next.js App Router CRUD endpoints and drizzle-orm(pg) tables with `@ai0x0/utils`. Use when a project needs standard list/get/create/update/delete routes backed by drizzle-orm + zod + next-rest-framework, auto-injected base fields (id / creatorId / createdAt…), paginated list queries with joins, or ready-to-use select/insert/update Zod schemas. Pair with the `create-next-rest-framework-api` skill — this skill replaces hand-written `routeOperation(...).input(...).outputs(...).handler(...)` chains with a declarative factory API exposed inside `next-rest-framework`'s `route({...})`.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# @ai0x0/utils Backend
|
|
7
|
+
|
|
8
|
+
Declarative CRUD factory on top of `next-rest-framework` v6, `drizzle-orm/pg-core`, `drizzle-zod`, and `zod` v4. Each factory returns a `RouteOperationDefinition<Method>` so it plugs directly into `route({ … })` from next-rest-framework.
|
|
9
|
+
|
|
10
|
+
## Related Project Skills
|
|
11
|
+
|
|
12
|
+
This backend factory skill pairs with the packaged project skills:
|
|
13
|
+
|
|
14
|
+
- `nextjs` — route placement, App Router boundaries, OpenAPI generation, file splitting.
|
|
15
|
+
- `antd` — frontend forms, ProTable/ModalForm, request flow, token styling.
|
|
16
|
+
- `cloudflare` — OpenNext, Workers, Hyperdrive, Neon, deploy/runtime checks.
|
|
17
|
+
|
|
18
|
+
When a CRUD change spans backend and frontend, use this skill for factories and schemas, then apply `nextjs` for routing/client generation and `antd` for the admin page.
|
|
19
|
+
|
|
20
|
+
## Workflow
|
|
21
|
+
|
|
22
|
+
1. Create a shared options module (once per project) that pre-binds `{ db, getSession }` to every operation factory (see "Project Setup" below). Route files never import `db`/`getSession` directly.
|
|
23
|
+
2. For each domain entity, place a `table.ts` next to an `index.ts`:
|
|
24
|
+
- `table.ts` — call `createTableSchema({ name, columns })` and re-export `table` only.
|
|
25
|
+
- `index.ts` — compose the zod schemas (`insertXxxSchema`, `selectXxxSchema`, `updateXxxSchema`, `queryXxxSchema`, `queryListXxxSchema`, `queryListSelectXxxSchema`). Extend with `.merge(z.object(...))` when the business exposes non-column fields (tags, computed columns).
|
|
26
|
+
3. Author the CRUD `route.ts` by wrapping the pre-bound operations inside next-rest-framework's `route({...})`. Name each operation key descriptively — it is the OpenAPI `operationId`.
|
|
27
|
+
4. Drop down to raw `routeOperation(...)` (via the `create-next-rest-framework-api` skill) when the endpoint is non-CRUD (auth, webhooks, streaming, multi-status) or needs custom `TypedNextResponse` unions.
|
|
28
|
+
5. Reuse action-level helpers (`createGetAction`, `createGetListAction`, etc.) inside server actions, cron jobs, queue consumers, or the raw-`routeOperation` handlers — same API, no request wrapping.
|
|
29
|
+
|
|
30
|
+
## ESLint-Aware Defaults
|
|
31
|
+
|
|
32
|
+
The bundled ESLint preset treats every custom rule as an error. Backend examples should follow these defaults:
|
|
33
|
+
|
|
34
|
+
- Use `async/await`; do not use `.then()`.
|
|
35
|
+
- Use meaningful variable names; avoid single-letter names except `_`, loop counters, and generic type parameters.
|
|
36
|
+
- Use braces for every control-flow block because `curly` is enabled as `error`.
|
|
37
|
+
- Keep route files thin. Put reusable logic in `utils/actions/<resource>.ts` instead of exporting helpers from `route.ts`.
|
|
38
|
+
- For files with 150+ lines, add the three-line `===` section divider blocks described in the `nextjs` skill.
|
|
39
|
+
- Avoid `try/catch` in route handlers; use `onError` or the factory default error handling.
|
|
40
|
+
|
|
41
|
+
## Project Setup
|
|
42
|
+
|
|
43
|
+
Create three files once and reuse everywhere:
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
// app/(backend)/db/schemas/_helper.ts — re-export + lock import path
|
|
47
|
+
export {
|
|
48
|
+
createTableSchema,
|
|
49
|
+
queryListSchema,
|
|
50
|
+
listBodySchema,
|
|
51
|
+
} from "@ai0x0/utils/lib/backend/schemas";
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
```ts
|
|
55
|
+
// app/(backend)/utils/actions/_helper.ts — action-level helpers
|
|
56
|
+
export {
|
|
57
|
+
createDeleteAction,
|
|
58
|
+
createGetAction,
|
|
59
|
+
createGetListAction,
|
|
60
|
+
createPostAction,
|
|
61
|
+
createPutAction,
|
|
62
|
+
} from "@ai0x0/utils/lib/backend/actions";
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
```ts
|
|
66
|
+
// app/(backend)/utils/route-operation/index.ts — pre-bind db + getSession
|
|
67
|
+
import {
|
|
68
|
+
createGetOperation,
|
|
69
|
+
createPutOperation,
|
|
70
|
+
createPostOperation,
|
|
71
|
+
createDeleteOperation,
|
|
72
|
+
createGetListOperation,
|
|
73
|
+
} from "@ai0x0/utils/lib/backend";
|
|
74
|
+
import { db } from "@backend/db";
|
|
75
|
+
import getSession from "@backend/utils/get-session";
|
|
76
|
+
|
|
77
|
+
export const postOperation = createPostOperation({ db, getSession });
|
|
78
|
+
export const deleteOperation = createDeleteOperation({ db, getSession });
|
|
79
|
+
export const putOperation = createPutOperation({ db, getSession });
|
|
80
|
+
export const getOperation = createGetOperation({ db, getSession });
|
|
81
|
+
export const getListOperation = createGetListOperation({ db, getSession });
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
`getSession(req)` **must** return `{ userId?: string } | undefined` (a Promise is fine). Throwing rejects the request; returning `undefined` disables ownership checks for that call.
|
|
85
|
+
|
|
86
|
+
## Factory Catalog
|
|
87
|
+
|
|
88
|
+
| Export | Purpose | Required options | Notable optional options |
|
|
89
|
+
| ------------------- | -------------------------- | ------------------------------------ | ------------------------------------------------------------------------------------------- |
|
|
90
|
+
| `createTableSchema` | pg `Table` + 5 zod schemas | `name`, `columns` | `refineSchema`, `extraConfig` |
|
|
91
|
+
| `getOperation` | GET one by filters | `table`, `bodySchema`, `querySchema` | `setParams`, `relations`, `jsonArrayFields`, `byCreator`, `onSuccess`, `onError`, `summary` |
|
|
92
|
+
| `getListOperation` | GET paginated list | `table`, `bodySchema`, `querySchema` | `setParams`, `relations`, `jsonArrayFields`, `onSuccess`, `onError`, `summary` |
|
|
93
|
+
| `postOperation` | POST create | `table`, `bodySchema` | `outputBodySchema`, `setBody`, `onSuccess`, `onError`, `summary` |
|
|
94
|
+
| `putOperation` | PUT update | `table`, `bodySchema` | `outputBodySchema`, `setBody`, `byCreator`, `onSuccess`, `onError`, `summary` |
|
|
95
|
+
| `deleteOperation` | DELETE | `table` | `byCreator`, `onSuccess`, `onError`, `summary` |
|
|
96
|
+
|
|
97
|
+
## Canonical Examples
|
|
98
|
+
|
|
99
|
+
### 1. Table + schemas (`table.ts` + `index.ts`)
|
|
100
|
+
|
|
101
|
+
```ts
|
|
102
|
+
// app/(backend)/db/schemas/agent/table.ts
|
|
103
|
+
import { jsonb, text } from "drizzle-orm/pg-core";
|
|
104
|
+
import { createTableSchema } from "@backend/db/schemas/_helper";
|
|
105
|
+
|
|
106
|
+
export const agentColumns = {
|
|
107
|
+
name: text("name"),
|
|
108
|
+
modelId: text("model_id"),
|
|
109
|
+
tags: jsonb("tags"),
|
|
110
|
+
mcpIds: jsonb("mcp_ids"),
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
export const { table: agents } = createTableSchema({
|
|
114
|
+
name: "agents",
|
|
115
|
+
columns: agentColumns,
|
|
116
|
+
});
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
```ts
|
|
120
|
+
// app/(backend)/db/schemas/agent/index.ts
|
|
121
|
+
import { createSelectSchema } from "drizzle-zod";
|
|
122
|
+
import { z } from "zod";
|
|
123
|
+
import {
|
|
124
|
+
createInsertBodySchema,
|
|
125
|
+
createUpdateBodySchema,
|
|
126
|
+
queryListSchema,
|
|
127
|
+
} from "@backend/db/schemas";
|
|
128
|
+
import { agents } from "./table";
|
|
129
|
+
|
|
130
|
+
export { agents, agentColumns } from "./table";
|
|
131
|
+
|
|
132
|
+
export const insertAgentSchema = createInsertBodySchema(agents).merge(
|
|
133
|
+
z.object({
|
|
134
|
+
mcpIds: z.array(z.string()).optional(),
|
|
135
|
+
tags: z.array(z.string()).optional(),
|
|
136
|
+
}),
|
|
137
|
+
);
|
|
138
|
+
export const selectAgentSchema = createSelectSchema(agents).merge(
|
|
139
|
+
z.object({
|
|
140
|
+
mcpIds: z.array(z.string()).optional(),
|
|
141
|
+
tags: z.array(z.string()).optional(),
|
|
142
|
+
}),
|
|
143
|
+
);
|
|
144
|
+
export const updateAgentSchema = createUpdateBodySchema(agents).merge(
|
|
145
|
+
z.object({
|
|
146
|
+
mcpIds: z.array(z.string()).optional(),
|
|
147
|
+
tags: z.array(z.string()).optional(),
|
|
148
|
+
}),
|
|
149
|
+
);
|
|
150
|
+
export const queryAgentSchema = insertAgentSchema.partial();
|
|
151
|
+
export const queryListAgentSchema = queryListSchema(
|
|
152
|
+
selectAgentSchema.partial().merge(
|
|
153
|
+
z.object({ tags: z.string().optional() }), // 列表里 jsonArray 以逗号分隔字符串传入
|
|
154
|
+
),
|
|
155
|
+
);
|
|
156
|
+
export const queryListSelectAgentSchema = selectAgentSchema;
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Note the two-layer shape: `queryListSchema(...)` input is the **partial of the select schema** (optionally merged with extra querystring-only fields); `bodySchema` in `getListOperation` stays as the full select schema (`queryListSelectAgentSchema`).
|
|
160
|
+
|
|
161
|
+
### 2. Detail CRUD route
|
|
162
|
+
|
|
163
|
+
```ts
|
|
164
|
+
// app/(backend)/api/agent/route.ts
|
|
165
|
+
import { route } from "next-rest-framework";
|
|
166
|
+
import {
|
|
167
|
+
agents,
|
|
168
|
+
insertAgentSchema,
|
|
169
|
+
queryAgentSchema,
|
|
170
|
+
selectAgentSchema,
|
|
171
|
+
updateAgentSchema,
|
|
172
|
+
} from "@backend/db/schemas";
|
|
173
|
+
import {
|
|
174
|
+
deleteOperation,
|
|
175
|
+
getOperation,
|
|
176
|
+
postOperation,
|
|
177
|
+
putOperation,
|
|
178
|
+
} from "@backend/utils/route-operation";
|
|
179
|
+
import getSession from "@backend/utils/get-session";
|
|
180
|
+
import { seedAgentData } from "@backend/utils/actions";
|
|
181
|
+
|
|
182
|
+
export const { POST, GET, DELETE, PUT } = route({
|
|
183
|
+
getAgent: getOperation({
|
|
184
|
+
querySchema: queryAgentSchema,
|
|
185
|
+
bodySchema: selectAgentSchema,
|
|
186
|
+
table: agents,
|
|
187
|
+
summary: "助手详情",
|
|
188
|
+
setParams: async (req) => {
|
|
189
|
+
const { userId } = (await getSession(req)) || {};
|
|
190
|
+
await seedAgentData(userId as string);
|
|
191
|
+
return {};
|
|
192
|
+
},
|
|
193
|
+
}),
|
|
194
|
+
postAgent: postOperation({
|
|
195
|
+
bodySchema: insertAgentSchema,
|
|
196
|
+
table: agents,
|
|
197
|
+
summary: "添加助手",
|
|
198
|
+
setBody: async () => ({ tags: ["my"] }),
|
|
199
|
+
}),
|
|
200
|
+
putAgent: putOperation({
|
|
201
|
+
bodySchema: updateAgentSchema,
|
|
202
|
+
table: agents,
|
|
203
|
+
summary: "编辑助手",
|
|
204
|
+
}),
|
|
205
|
+
deleteAgent: deleteOperation({
|
|
206
|
+
table: agents,
|
|
207
|
+
summary: "删除助手",
|
|
208
|
+
}),
|
|
209
|
+
});
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
### 3. List route with relations + `onSuccess` tree building
|
|
213
|
+
|
|
214
|
+
```ts
|
|
215
|
+
// app/(backend)/api/channel/list/route.ts
|
|
216
|
+
import { route } from "next-rest-framework";
|
|
217
|
+
import { eq, sql } from "drizzle-orm";
|
|
218
|
+
import {
|
|
219
|
+
channels,
|
|
220
|
+
models,
|
|
221
|
+
queryListChannelSchema,
|
|
222
|
+
queryListSelectChannelSchema,
|
|
223
|
+
} from "@backend/db/schemas";
|
|
224
|
+
import { getListOperation } from "@backend/utils/route-operation";
|
|
225
|
+
import getSession from "@backend/utils/get-session";
|
|
226
|
+
import { seedChannelData } from "@backend/utils/actions/channel";
|
|
227
|
+
|
|
228
|
+
export const { GET } = route({
|
|
229
|
+
getChannelList: getListOperation({
|
|
230
|
+
querySchema: queryListChannelSchema,
|
|
231
|
+
bodySchema: queryListSelectChannelSchema,
|
|
232
|
+
table: channels,
|
|
233
|
+
summary: "获取渠道列表",
|
|
234
|
+
relations: [
|
|
235
|
+
{
|
|
236
|
+
table: models,
|
|
237
|
+
sql: eq(models.channelId, channels.id),
|
|
238
|
+
select: {
|
|
239
|
+
models: sql`(
|
|
240
|
+
SELECT array_agg(DISTINCT jsonb_strip_nulls(to_jsonb(models.*)))
|
|
241
|
+
FROM ${models}
|
|
242
|
+
WHERE ${models.channelId} = ${channels.id}
|
|
243
|
+
)`,
|
|
244
|
+
},
|
|
245
|
+
},
|
|
246
|
+
],
|
|
247
|
+
setParams: async (req) => {
|
|
248
|
+
const { userId } = (await getSession(req)) || {};
|
|
249
|
+
await seedChannelData(userId as string);
|
|
250
|
+
return {};
|
|
251
|
+
},
|
|
252
|
+
onSuccess: async (result) => {
|
|
253
|
+
// 内部字段过滤 / 映射 / 树化都写在这里
|
|
254
|
+
return result;
|
|
255
|
+
},
|
|
256
|
+
}),
|
|
257
|
+
});
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
For list routes that consume `jsonb` arrays as comma-separated querystring values, add `jsonArrayFields: ["tags"]`.
|
|
261
|
+
|
|
262
|
+
### 4. Escape hatch — drop to raw `routeOperation` but reuse actions
|
|
263
|
+
|
|
264
|
+
When the endpoint returns multiple status shapes (e.g. 200 success + 401 unauthorized), do **not** use the CRUD factories. Call `routeOperation` directly and reuse the action helpers for the database side:
|
|
265
|
+
|
|
266
|
+
```ts
|
|
267
|
+
// app/(backend)/api/user/me/route.ts
|
|
268
|
+
import { TypedNextResponse, route, routeOperation } from "next-rest-framework";
|
|
269
|
+
import { eq } from "drizzle-orm";
|
|
270
|
+
import { z } from "zod";
|
|
271
|
+
import { db } from "@backend/db";
|
|
272
|
+
import { members, selectUserSchema, teams, users } from "@backend/db/schemas";
|
|
273
|
+
import { createGetAction } from "@backend/utils/actions";
|
|
274
|
+
import getSession from "@backend/utils/get-session";
|
|
275
|
+
|
|
276
|
+
export const { GET } = route({
|
|
277
|
+
getUserMe: routeOperation({
|
|
278
|
+
method: "GET",
|
|
279
|
+
openApiOperation: { tags: ["users"], summary: "我的信息" },
|
|
280
|
+
})
|
|
281
|
+
.outputs([
|
|
282
|
+
{ status: 200, contentType: "application/json", body: selectUserSchema },
|
|
283
|
+
{
|
|
284
|
+
status: 401,
|
|
285
|
+
contentType: "application/json",
|
|
286
|
+
body: z.object({ message: z.string() }),
|
|
287
|
+
},
|
|
288
|
+
])
|
|
289
|
+
.handler(async (req) => {
|
|
290
|
+
const { userId } = (await getSession(req)) || {};
|
|
291
|
+
const user = await createGetAction({
|
|
292
|
+
db,
|
|
293
|
+
table: users,
|
|
294
|
+
bodySchema: selectUserSchema,
|
|
295
|
+
relations: [
|
|
296
|
+
{ table: members, sql: eq(members.userId, users.id) },
|
|
297
|
+
{
|
|
298
|
+
groupBy: true,
|
|
299
|
+
table: teams,
|
|
300
|
+
select: { team: teams },
|
|
301
|
+
sql: eq(teams.id, members.teamId),
|
|
302
|
+
},
|
|
303
|
+
],
|
|
304
|
+
})({ id: userId });
|
|
305
|
+
if (!user) {
|
|
306
|
+
return TypedNextResponse.json(
|
|
307
|
+
{ message: "用户不存在" },
|
|
308
|
+
{ status: 401 },
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
return TypedNextResponse.json(user, { status: 200 });
|
|
312
|
+
}),
|
|
313
|
+
});
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
## Built-in Conventions
|
|
317
|
+
|
|
318
|
+
- **Base fields** auto-added by `createTableSchema`: `id` (uuid, pk, default random) / `creatorId` / `editorId` / `accessedAt` / `createdAt` / `updatedAt`.
|
|
319
|
+
- **Ownership check**: with `byCreator: true` (default on PUT/DELETE/GET), PUT/DELETE pre-fetch the row filtered by `creatorId = session.userId` and throw `"未找到…对象,或没有权限"` if missing. Set `byCreator: false` to skip (e.g. admin endpoints).
|
|
320
|
+
- **Auto injection**: POST writes `creatorId = session.userId`; PUT writes `editorId = session.userId`; GET/GET_LIST append `creatorId = session.userId` to the filter when `byCreator` is on.
|
|
321
|
+
- **Date coercion**: any body field ending in `At` is passed through `dayjs(value).toDate()` before write — accept ISO strings from clients.
|
|
322
|
+
- **List filtering** (all querystring values are strings):
|
|
323
|
+
- `key=a,b,c` → `IN (...)` when key ends with `Id`; otherwise `OR ILIKE %a% OR ILIKE %b%`
|
|
324
|
+
- `key=foo` where key ends with `Id` → `eq`; otherwise `ILIKE %foo%`
|
|
325
|
+
- `${anything}AtFrom` / `${anything}AtTo` → `gte` / `lte` against the matching `${anything}At` column
|
|
326
|
+
- `jsonArrayFields: ["tags"]` → `EXISTS (SELECT 1 FROM jsonb_array_elements_text(col) t WHERE t LIKE %v%)`
|
|
327
|
+
- **Pagination / sort**: `current` (default `"1"`), `pageSize` (default `"10"`), `orderBy` (default `createdAt`), `orderDir` (`asc|desc`, default `desc`).
|
|
328
|
+
- **Relations**: `relations: [{ table, sql, select, groupBy? }]` drives `leftJoin` + optional `groupBy(root.id, relation.id)`; `select` merges into the final `SELECT` projection and can contain raw `sql\`…\`` correlated sub-queries.
|
|
329
|
+
|
|
330
|
+
## Guardrails
|
|
331
|
+
|
|
332
|
+
- Always pass `insertXxxSchema` to POST, `updateXxxSchema` (has `.required({ id: true })`) to PUT, `selectXxxSchema` to GET body, `queryListSchema(selectXxxSchema.partial())` to GET_LIST query.
|
|
333
|
+
- Query-string schemas stay `z.string()` — numbers / arrays from querystrings must stay strings and be parsed inside `onSuccess` or `setParams`.
|
|
334
|
+
- Do not reuse `insertSchema` for PUT — missing `id` silently dispatches an update without a where clause.
|
|
335
|
+
- `bodySchema` on list/get is the **response item** schema, not the query shape. Query shape goes in `querySchema`.
|
|
336
|
+
- Do not inline `{ db, getSession }` into individual route files — always import the pre-bound `postOperation` / `getListOperation` / … from `@backend/utils/route-operation` so session/DB swaps are one-file changes.
|
|
337
|
+
- `jsonb` columns declared with `jsonb(...)` carry no zod info — extend the matching insert/select schema with `.merge(z.object({ field: z.array(z.string()).optional() }))` to keep typing accurate.
|
|
338
|
+
- Swallowing errors: an unhandled throw in a handler propagates to next-rest-framework as a 500. Provide `onError` for any operation exposed to untrusted clients.
|
|
339
|
+
- For non-CRUD behavior (middleware chains, streaming, multipart, `TypedNextResponse` unions, RPC) stop using these factories and switch to the `create-next-rest-framework-api` skill.
|
|
340
|
+
|
|
341
|
+
## Escape Hatches
|
|
342
|
+
|
|
343
|
+
- Extra server-side filters — `setParams(req) => Promise<Record<string, unknown>>` on GET/GET_LIST; returned keys are merged into the filter.
|
|
344
|
+
- Extra columns on write — `setBody(req) => Promise<Partial<infer<IB>>>` on POST/PUT; returned fields are merged into the body before validation runs against `bodySchema`.
|
|
345
|
+
- Post-processing output — `onSuccess(data) => Promise<data>`; on GET_LIST the payload is `{ data, total }`.
|
|
346
|
+
- Error rewriting — `onError(err) => Promise<Response | undefined>`. Return `undefined` to rethrow.
|
|
347
|
+
|
|
348
|
+
## Imports
|
|
349
|
+
|
|
350
|
+
```
|
|
351
|
+
@ai0x0/utils/lib/backend // actions + route-operation + schemas (CJS, used by Next.js server bundle)
|
|
352
|
+
@ai0x0/utils/lib/backend/actions
|
|
353
|
+
@ai0x0/utils/lib/backend/route-operation
|
|
354
|
+
@ai0x0/utils/lib/backend/schemas
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
ESM consumers can substitute `/es/backend/...` instead of `/lib/backend/...` when Next.js resolves the `exports` map that way; prefer `/lib/backend` in Next.js projects because the server bundle is CommonJS.
|
|
358
|
+
|
|
359
|
+
## Done Criteria
|
|
360
|
+
|
|
361
|
+
- Route files contain zero hand-written `{ db, getSession }` wiring; all operations come from `@backend/utils/route-operation`.
|
|
362
|
+
- Each domain has a `table.ts` (columns + `createTableSchema`) and an `index.ts` (five zod schemas + re-exports).
|
|
363
|
+
- Every list endpoint accepts `current / pageSize / orderBy / orderDir / *AtFrom / *AtTo` and — where applicable — `jsonArrayFields` is declared.
|
|
364
|
+
- `byCreator` is explicitly set when the endpoint is admin-facing or otherwise crosses ownership boundaries.
|
|
365
|
+
- Non-CRUD endpoints are implemented via raw `routeOperation` + action helpers, not by forcing a CRUD factory.
|
|
366
|
+
- `next-rest-framework validate` / `generate` (from the partner skill) still pass — the factory values are plain `RouteOperationDefinition` and participate in OpenAPI generation.
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: antd
|
|
3
|
+
description: Ant Design / Ant Design Pro 前端页面、表单、ProTable、ModalForm、主题 token、布局组件和 ahooks 数据流约定。新增或修改业务 UI、表单、搜索筛选、配置面板、管理后台 CRUD 页面时使用。
|
|
4
|
+
metadata:
|
|
5
|
+
short-description: Ant Design 表单、页面与主题约定
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Ant Design 开发约定
|
|
9
|
+
|
|
10
|
+
适用于基于 antd、Ant Design Pro、ProComponents、antd-style、ahooks 的业务前端。
|
|
11
|
+
|
|
12
|
+
## 客户端组件与 API
|
|
13
|
+
|
|
14
|
+
- 需要交互或状态的组件首行加 `"use client"`。
|
|
15
|
+
- API 调用统一走 `@frontend/apis`,类型来自 `@frontend/apis/generator`。
|
|
16
|
+
- 生成产物不要手改;接口或 schema 变更后重新生成 OpenAPI client。
|
|
17
|
+
- 全局状态用 `unstated-next` 的 `data-store.tsx`。
|
|
18
|
+
- 弹出类 UI 用 `app/(frontend)/app.tsx` 暴露的 `app`,避免 antd 静态方法告警。
|
|
19
|
+
|
|
20
|
+
## 表单核心规则
|
|
21
|
+
|
|
22
|
+
所有表单组件必须使用 `Form.useForm()`、ahooks/rc-field-form 的 `useForm` 或 ProComponents 内建 form 管理状态。
|
|
23
|
+
|
|
24
|
+
禁止在表单场景中使用:
|
|
25
|
+
|
|
26
|
+
- `useState` 直接绑定多个字段的 `value` + `onChange`
|
|
27
|
+
- 裸 `useRef` 读取输入值
|
|
28
|
+
- 手动拼接对象后提交
|
|
29
|
+
|
|
30
|
+
适用范围:
|
|
31
|
+
|
|
32
|
+
- 数据录入表单
|
|
33
|
+
- 搜索 / 筛选面板
|
|
34
|
+
- 聊天输入框里的多字段输入
|
|
35
|
+
- 配置弹窗 / Drawer
|
|
36
|
+
- 任意包含两个及以上输入控件的组件
|
|
37
|
+
|
|
38
|
+
例外:
|
|
39
|
+
|
|
40
|
+
- 纯展示文本
|
|
41
|
+
- 单一开关 / 单选且无需校验
|
|
42
|
+
- 第三方组件已经内部封装 Form
|
|
43
|
+
|
|
44
|
+
ESLint 会把“同一文件里存在受控输入 `value` + `onChange`,同时使用 `useState` / `useSetState`”识别为违规表单状态管理。遇到多字段输入时,直接上 Form,不要先写本地 state 再迁移。
|
|
45
|
+
|
|
46
|
+
## 表单与请求
|
|
47
|
+
|
|
48
|
+
表单提交配合 `useRequest`,不要在 `onFinish` 里裸写 async/await 和 try/catch。
|
|
49
|
+
|
|
50
|
+
```tsx
|
|
51
|
+
const { run: submit } = useRequest(
|
|
52
|
+
async (values) => {
|
|
53
|
+
await apis.postSomething({ body: values });
|
|
54
|
+
},
|
|
55
|
+
{ manual: true },
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
<Form form={form} onFinish={submit} />;
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
辅助说明文字使用 `Form.Item` 的 `help`,不要拼进 `label` 字符串。
|
|
62
|
+
|
|
63
|
+
强制表单约定:
|
|
64
|
+
|
|
65
|
+
- `<Form>` 必须用 `onFinish` 提交。
|
|
66
|
+
- 有 `name` 的 `<Form.Item>` 必须写 `rules`,`noStyle` 例外。
|
|
67
|
+
- Modal + Form 组合禁止用 `Modal.onOk` 提交,改用 `Form.onFinish`。
|
|
68
|
+
- 禁止 `form.validateFields()` + 手动提交的模式。
|
|
69
|
+
- 请求错误由拦截器统一处理,禁止在 `useRequest` 回调里 `message.error` / `app?.message.error`。
|
|
70
|
+
|
|
71
|
+
## ProComponents CRUD 页面
|
|
72
|
+
|
|
73
|
+
管理页优先使用 `ProTable` + `ModalForm`。
|
|
74
|
+
|
|
75
|
+
```tsx
|
|
76
|
+
"use client";
|
|
77
|
+
|
|
78
|
+
import type { ActionType, ProColumns } from "@ant-design/pro-components";
|
|
79
|
+
import { ModalForm, ProFormText, ProTable } from "@ant-design/pro-components";
|
|
80
|
+
import apis from "@frontend/apis";
|
|
81
|
+
|
|
82
|
+
const columns: ProColumns<Row>[] = [
|
|
83
|
+
{ title: "名称", dataIndex: "name" },
|
|
84
|
+
{
|
|
85
|
+
title: "创建时间",
|
|
86
|
+
dataIndex: "createdAt",
|
|
87
|
+
valueType: "dateRange",
|
|
88
|
+
search: {
|
|
89
|
+
transform: (value) => ({
|
|
90
|
+
createdAtFrom: value[0],
|
|
91
|
+
createdAtTo: value[1],
|
|
92
|
+
}),
|
|
93
|
+
},
|
|
94
|
+
editable: false,
|
|
95
|
+
},
|
|
96
|
+
];
|
|
97
|
+
|
|
98
|
+
const Page = () => (
|
|
99
|
+
<ProTable
|
|
100
|
+
columns={columns}
|
|
101
|
+
request={async (query) => {
|
|
102
|
+
const { data } = await apis.getKeyList(query);
|
|
103
|
+
return { data: data.data, success: true, total: data.total };
|
|
104
|
+
}}
|
|
105
|
+
/>
|
|
106
|
+
);
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
约定:
|
|
110
|
+
|
|
111
|
+
- `ProTable request` 必须返回 `{ data, success, total }`,否则分页不生效。
|
|
112
|
+
- 时间范围列用 `valueType: "dateRange"` + `search.transform` 映射 `<field>From/<field>To`。
|
|
113
|
+
- 图片表单列用 `renderFormItem` 接入上传组件。
|
|
114
|
+
- 删除走 `Popconfirm`。
|
|
115
|
+
- 提交成功后调用 `action?.reload()`。
|
|
116
|
+
|
|
117
|
+
业务表单优先 ProComponents:
|
|
118
|
+
|
|
119
|
+
- 禁止直接用 antd `<Modal>`,使用 `ModalForm`。
|
|
120
|
+
- 禁止直接用非 `noStyle` 的 `<Form.Item>`,使用 `ProFormText` / `ProFormSelect` / `ProFormItem` 等。
|
|
121
|
+
- `ProForm*` 字段组件必须设置 `rules`;容器类 `ProForm`、`ProFormGroup`、`ProFormList`、`ProFormDependency` 例外。
|
|
122
|
+
- 禁止 `<Button htmlType="submit">`,通过 ProForm 的 `submitter` 配置提交按钮。
|
|
123
|
+
|
|
124
|
+
## 布局与文本
|
|
125
|
+
|
|
126
|
+
业务页面禁止用 `<div>` / `<span>` / `<p>` / `<h1-6>` 承载布局或文本,优先使用 antd 组件。
|
|
127
|
+
|
|
128
|
+
- 布局容器 -> `Flex`
|
|
129
|
+
- 纯文本 -> `Typography.Text` / `Typography.Paragraph` / `Typography.Title`
|
|
130
|
+
- 次级、危险、警告、成功文本 -> `Text type="secondary|danger|warning|success"`
|
|
131
|
+
- Badge、Tag、Avatar、Image、Button 等使用 antd 对应组件
|
|
132
|
+
|
|
133
|
+
允许例外:
|
|
134
|
+
|
|
135
|
+
- 框架或第三方组件必须的结构标签。
|
|
136
|
+
- 纯 Portal / ref 挂载点。
|
|
137
|
+
- SVG 内部元素。
|
|
138
|
+
- antd 没有对等组件的语义标签,如必要的外链 `<a target>`。
|
|
139
|
+
- 禁止 antd `<Space>` / `<Space.Compact>`,使用 `<Flex>`。
|
|
140
|
+
|
|
141
|
+
Space 到 Flex 的常见替换:
|
|
142
|
+
|
|
143
|
+
- `<Space direction="vertical">` -> `<Flex vertical>`
|
|
144
|
+
- `<Space size={token.paddingSM}>` -> `<Flex gap={token.paddingSM}>`
|
|
145
|
+
- `<Space wrap>` -> `<Flex wrap="wrap">`
|
|
146
|
+
- `<Space.Compact>` -> 用 `<Flex>` 和 token 自行实现紧凑排列
|
|
147
|
+
|
|
148
|
+
提交前在改动范围内检查:
|
|
149
|
+
|
|
150
|
+
```bash
|
|
151
|
+
rg -n "<(div|span|p|h[1-6])[ >]"
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
## 主题与样式
|
|
155
|
+
|
|
156
|
+
- 使用 antd 6 + `antd-style`。
|
|
157
|
+
- 顶层用 `@ant-design/nextjs-registry` 的 `AntdRegistry` 包裹,不再写 `unstable_setRender` 或自定义 `StyleRegistry`。
|
|
158
|
+
- 主题色从全局 env/store 读取,开启 `cssVar: {}`。
|
|
159
|
+
- 中文语言包统一在 theme 入口设置 `ConfigProvider locale={zhCN}` 和 `dayjs.locale("zh-cn")`。
|
|
160
|
+
|
|
161
|
+
所有前端样式必须走 antd token,禁止硬编码视觉值:
|
|
162
|
+
|
|
163
|
+
- 颜色:`#fff`、`rgb(...)`、`rgba(...)`、`hsl(...)`
|
|
164
|
+
- 间距/字号/圆角:`12px`、`8px 16px`
|
|
165
|
+
- 阴影:`0 2px 8px rgba(...)`
|
|
166
|
+
|
|
167
|
+
推荐:
|
|
168
|
+
|
|
169
|
+
```tsx
|
|
170
|
+
import { createStyles } from "antd-style";
|
|
171
|
+
|
|
172
|
+
const useStyles = createStyles(({ token, css }) => ({
|
|
173
|
+
card: css`
|
|
174
|
+
background: ${token.colorBgContainer};
|
|
175
|
+
color: ${token.colorText};
|
|
176
|
+
border: 1px solid ${token.colorBorderSecondary};
|
|
177
|
+
border-radius: ${token.borderRadiusLG}px;
|
|
178
|
+
padding: ${token.paddingLG}px;
|
|
179
|
+
box-shadow: ${token.boxShadowTertiary};
|
|
180
|
+
`,
|
|
181
|
+
}));
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
允许例外:
|
|
185
|
+
|
|
186
|
+
- `0`。
|
|
187
|
+
- CSS 功能性关键字,如 `auto`、`none`、`hidden`、`pointer`、`center`、`flex`、`grid`、`relative`。
|
|
188
|
+
- 条件表达式、变量、函数调用、`calc()` / `min()` / `max()` / `clamp()`。
|
|
189
|
+
- `css`` 中通过 `${token.xxx}` 插值的值。
|
|
190
|
+
- `100%`、`100vh`、`100vw` 等布局尺寸。
|
|
191
|
+
- 第三方库强制要求的字面量,但要用中文注释说明原因。
|
|
192
|
+
|
|
193
|
+
检查硬编码视觉值:
|
|
194
|
+
|
|
195
|
+
```bash
|
|
196
|
+
rg -n "#[0-9a-fA-F]{3,8}\\b|rgba?\\(|hsla?\\(|[0-9]+(px|rem|em)\\b"
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
## ahooks 数据流
|
|
200
|
+
|
|
201
|
+
业务组件里请求、定时器、防抖、节流、生命周期、布尔状态等优先用 ahooks。
|
|
202
|
+
|
|
203
|
+
| 需求 | 避免 | 推荐 |
|
|
204
|
+
| ------------------ | ---------------------------------- | --------------------------------- |
|
|
205
|
+
| 请求 / 加载 / 错误 | `useState + useEffect + try/catch` | `useRequest` |
|
|
206
|
+
| 防抖 / 节流 | 自写 `setTimeout` | `useDebounceFn` / `useThrottleFn` |
|
|
207
|
+
| 挂载 / 卸载 | 手写 mounted 状态 | `useMount` / `useUnmount` |
|
|
208
|
+
| 上一个值 | 自写 ref 比较 | `usePrevious` |
|
|
209
|
+
| 定时器 | `setInterval` + cleanup | `useInterval` / `useTimeout` |
|
|
210
|
+
| URL 状态 | 手写 router/searchParams | `useUrlState` |
|
|
211
|
+
| 布尔开关 | 多个 setter | `useBoolean` / `useToggle` |
|
|
212
|
+
|
|
213
|
+
业务页面 / 组件不要直接写请求型 `useEffect`。如果确实需要底层 `useEffect`,在函数上方用中文注释说明为什么 ahooks 覆盖不了。
|
|
214
|
+
|
|
215
|
+
按钮请求标准链路:`manual: true` + `runAsync` + `await` + `refresh`,不要自己维护 loading、try/catch 和 toast。
|
|
216
|
+
|
|
217
|
+
`useRequest` 解构时禁止使用 `run` 或 `run: alias`:
|
|
218
|
+
|
|
219
|
+
```tsx
|
|
220
|
+
const { runAsync: submit } = useRequest(save, { manual: true });
|
|
221
|
+
|
|
222
|
+
await submit(values);
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
连续多个 `setState({ ... })` 要合并成一次:
|
|
226
|
+
|
|
227
|
+
```tsx
|
|
228
|
+
setState({ open: false, current: undefined });
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
## 类型与枚举
|
|
232
|
+
|
|
233
|
+
- 不要手写 `"a" | "b" | "c"` 重复 OpenAPI enum。
|
|
234
|
+
- `Segmented`、`Menu`、Tabs 的 value/key 使用 generator 导出的 `XxxEnum`。
|
|
235
|
+
- props、state、request body 使用 generator 导出的类型。
|
|
236
|
+
- 对象字面量符合接口时用 `satisfies`,不要 `as T`。
|
|
237
|
+
|
|
238
|
+
## ESLint 规则对应
|
|
239
|
+
|
|
240
|
+
- `require-use-form`:受控表单字段必须交给 Form 管理。
|
|
241
|
+
- `require-form-convention`:Form/onFinish/rules/Modal 提交/错误提示约定。
|
|
242
|
+
- `require-pro-components`:ModalForm、ProForm 字段和 submitter 约定。
|
|
243
|
+
- `no-hardcoded-style`:token 样式约定。
|
|
244
|
+
- `no-antd-space`:Flex 替代 Space。
|
|
245
|
+
- `no-use-request-run`:`runAsync` 替代 `run`。
|
|
246
|
+
- `no-consecutive-setstate`:合并连续 setState。
|
|
247
|
+
|
|
248
|
+
## 前端自检
|
|
249
|
+
|
|
250
|
+
- 新增/修改 UI 后跑 `pnpm exec tsc` 和 `pnpm exec eslint`。
|
|
251
|
+
- 表单场景确认由 Form 管理状态。
|
|
252
|
+
- ProTable request 确认返回 `total`。
|
|
253
|
+
- 业务文本和布局确认使用 antd 组件。
|
|
254
|
+
- 样式确认使用 token。
|
|
255
|
+
- 涉及真实交互时在浏览器点一次受影响页面,确认 Console 无 error/warning。
|