@lark-apaas/coding-steering 0.1.73 → 0.1.75

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/package.json CHANGED
@@ -1,14 +1,11 @@
1
1
  {
2
2
  "name": "@lark-apaas/coding-steering",
3
- "version": "0.1.73",
3
+ "version": "0.1.75",
4
4
  "description": "Stack-specific steering content for miaoda-coding templates",
5
5
  "type": "module",
6
6
  "files": [
7
7
  "steering"
8
8
  ],
9
- "scripts": {
10
- "lint:md": "markdownlint 'steering/**/*.md' --ignore 'steering/**/skills/**' --ignore 'steering/**/skills_common/**' --ignore 'steering/**/skills_local/**'"
11
- },
12
9
  "devDependencies": {
13
10
  "markdownlint-cli": "^0.47.0"
14
11
  },
@@ -20,5 +17,8 @@
20
17
  "miaoda",
21
18
  "coding-steering"
22
19
  ],
23
- "license": "MIT"
24
- }
20
+ "license": "MIT",
21
+ "scripts": {
22
+ "lint:md": "markdownlint 'steering/**/*.md' --ignore 'steering/**/skills/**' --ignore 'steering/**/skills_common/**' --ignore 'steering/**/skills_local/**'"
23
+ }
24
+ }
@@ -71,9 +71,7 @@ client/
71
71
  ├── pages/ # 页面
72
72
  ├── components/
73
73
  │ ├── ui/ # shadcn/ui,使用前读 ui/README.md(Use the components for functionality, but heavily style them.)
74
- {% if projectMeta['flags']['supportBusinessUser'] %}
75
74
  │ ├── business-ui/ # 用户/部门选择与展示,必须使用(禁止用 avatar 等自行实现),先读 README.md
76
- {% endif %}
77
75
  │ └── Layout.tsx # 布局
78
76
  ├── hooks/
79
77
  └── utils/
@@ -118,10 +116,16 @@ shared/
118
116
  - **import 类型前必须确认实际导出**:先读取文件确认类型名称存在且拼写一致,禁止臆造类型名
119
117
  - 属性统一 camelCase(禁止 snake_case),server 端 schema 的 snake_case 不应泄漏到接口类型
120
118
  - shared 目录是前后端共享文件,**禁止反向引用** `@server/*`、`@client/*` 等路径别名
121
- - **请求 DTO 中的 shared 字符串联合别名**:`shared/api.interface.ts` 是前后端契约权威。带 `class-validator` 的 NestJS 请求 DTO 字段若对应 shared 字符串字面量联合 `type` alias,不把 imported alias 直接写在 DTO 属性上;改用等价 inline literal union + `@IsString()` + `@IsIn([...])`,DTO class 仍 `implements` shared 请求接口。
122
- - ❌ `type!: ExpressionElementType`
123
- - ✅ `@IsString()` + `@IsIn(['tile', 'operator'])` + `type!: 'tile' | 'operator'`
124
- - **机械核对**:先数 shared alias 字面量个数 N,inline union 和 `@IsIn` 数组都必须恰好 N 个,且每个字面量完全一致;数量或名称不等就是错,不能只写“不得窄于”。
119
+ - **固定取值在 shared 定义一次**:状态、类型等固定字符串取值,在 `shared/api.interface.ts` 用 `as const` 数组定义,同时导出联合类型、类型守卫和中文标签;前后端都从这里导入,不另写取值数组或守卫。
120
+ ```typescript
121
+ export const ITEM_STATUSES = ['pending', 'done'] as const;
122
+ export type ItemStatus = (typeof ITEM_STATUSES)[number];
123
+ export const isItemStatus = (v: string): v is ItemStatus =>
124
+ (ITEM_STATUSES as readonly string[]).includes(v);
125
+ export const ITEM_STATUS_LABELS: Record<ItemStatus, string> = { pending: '待处理', done: '已完成' };
126
+ ```
127
+ - 后端把数据库读出的字符串收窄成联合类型时,用 shared 的守卫
128
+ - 带 `class-validator` 的请求 DTO:`@IsString()` + `@IsIn(ITEM_STATUSES)` 引用 shared 数组;字段类型写等价的内联字面量联合(`status!: 'pending' | 'done'`),不直接写 shared 别名(`status!: ItemStatus` 会被 lint 报错)。DTO 内联联合与 shared 取值保持一致,DTO class 仍 `implements` shared 请求接口
125
129
  - 边界:shared 真实 `enum` 用 `@IsEnum(EnumName)`;嵌套 DTO 用 `@ValidateNested()` + `@Type(() => XxxDto)`;普通对象字段用 `@IsObject()`;Date 转换按 `@Type(() => Date)` 处理。禁止用 `@Type(() => String)` 只为绕过 lint。
126
130
 
127
131
  ## 代码质量约束
@@ -285,6 +289,7 @@ shared/
285
289
  - `client/src/app.tsx`:顶部追加 `import UsersPage from './pages/users';`,在 `<Routes>` 内加 `<Route index element={<UsersPage />} />`(或其他业务首页组件作为 index 路由),并加 `<Route path="users" element={<UsersPage />} />`
286
290
  - `client/src/api/index.ts`:每行 `export * as <namespace> from './<name>';`(用命名空间导出避免跨模块 export 名冲突)
287
291
  - 预填完成后才派发业务模块任务;聚合文件引用的符号(`UsersModule` / `UsersPage` / `./users`)在任务产出前暂时悬空,任务完成后自然对齐
292
+ - 派发后端 / 数据库相关业务模块任务时,任务说明必须要求 Worker 在编码前重新读取 `server/database/schema.ts`,并以当前 schema 暴露的字段名、TypeScript 类型、必填 / 可选性为准处理 DTO、service 校验和写库逻辑;禁止凭记忆或需求文本猜字段。刚执行过 DDL 时,先确认 codegen 已刷新,再读取 `schema.ts`
288
293
 
289
294
  **业务模块任务的工作边界**:
290
295
 
@@ -551,7 +556,22 @@ await db.select().from(users).where(eq(users.adminUser, userId));
551
556
  - 前端 API 调用的 HTTP method 和 path **必须**与后端 Controller 装饰器完全匹配(`GET≠POST` → 405)
552
557
  - 后端 Service 返回值结构**必须**与 `shared/api.interface.ts` 中定义的响应类型完全匹配(禁止后端直接返回数组而 shared 定义为 `{items: T[]}`)
553
558
  5. **路由注册验证**:创建新 Controller 后必须用接口测试工具验证路由是否生效。**遇到 404 排查路径**:① 检查 `@Controller(...)` 是否以 `api/` 或 `openapi/` 开头 ② 检查 Module 是否在 `app.module.ts` 注册 ③ 检查静态路由是否在动态路由 `/:id` 之前
554
- 6. **@Query/@Param 类型转换**:默认 string,必须在 controller 层手动转换(如 `parseInt(limit, 10)`)
559
+ 6. **请求参数绑定与校验**:
560
+ - 列表 / 筛选接口用 `@Query() query: XxxQueryDto` 绑定,不在 controller 里逐个 `parseInt`。查询参数原始值都是字符串:数字字段加 `@Type(() => Number)`;布尔、数组和可选筛选字段用 `@Transform` 处理。传给 `value` 的值已被全局隐式转换和 `@Type` 改写(如 `'false'`→`true`、`''`→`0`),判断原始字符串时读 `obj[key]`:布尔按 `obj[key]` 把 `'true'`/`'false'` 转成布尔;可选筛选在 `obj[key] === ''` 时返回 `undefined`、否则返回 `value`(保留 `@Type` 的转换结果),不处理时前端「全部」发出的空串会返回 400 或被当成 0;数组把单个 `value` 包成数组
561
+ ```typescript
562
+ @IsOptional() @Type(() => Number) @IsInt() @Min(1)
563
+ page?: number;
564
+ @IsOptional() @Transform(({ obj, key, value }) => (obj[key] === '' ? undefined : value)) @Type(() => Number) @IsNumber()
565
+ minAmount?: number;
566
+ @IsOptional() @Transform(({ obj, key, value }) => (obj[key] === '' ? undefined : value)) @IsString() @IsIn(ITEM_STATUSES)
567
+ status?: 'pending' | 'done';
568
+ @IsOptional() @Transform(({ obj, key }) => (obj[key] === 'true' ? true : obj[key] === 'false' ? false : obj[key])) @IsBoolean()
569
+ mine?: boolean;
570
+ @IsOptional() @Transform(({ value }) => (Array.isArray(value) ? value : [value])) @IsArray() @IsString({ each: true })
571
+ tags?: string[];
572
+ ```
573
+ - 前端筛选选「全部」时不传该参数。Select 用占位值(如 `'all'`)表示「全部」时,发请求前去掉该参数:shadcn Select 不允许空串作选项值,`'all'` 原样发出会被 `@IsIn` 校验拒绝并返回 400
574
+ - `@Param` 单值默认 string,需要数字时在 controller 层转换(如 `Number(id)`)
555
575
  7. **写接口鉴权**:POST/PUT/PATCH/DELETE 不一刀切。个人/后台/依赖当前用户的写入加 `@NeedLogin()`;未登录访客表单/报名/反馈可公开,但每张被写入的 RLS 表都要有 `FOR INSERT TO anon WITH CHECK (true)` 或等价 Drizzle `withCheck`,并用未登录 HTTP POST 验证。`FOR SELECT TO anon` 只读、`FOR INSERT TO authenticated` 只登录态;公开只读页的增删改仍按登录态处理。
556
576
  ```typescript
557
577
  import { NeedLogin } from "@lark-apaas/fullstack-nestjs-core";
@@ -564,6 +584,12 @@ await db.select().from(users).where(eq(users.adminUser, userId));
564
584
  @Post("public-submit")
565
585
  async submitPublic(@Body() dto) { ... }
566
586
  ```
587
+ 整个 controller 都需要登录时,在类上写一次 `@NeedLogin()`;同一 controller 里有公开路由时,拆成两个 controller,或保留方法级写法:
588
+ ```typescript
589
+ @Controller("api/items")
590
+ @NeedLogin()
591
+ export class ItemsController { ... }
592
+ ```
567
593
  8. **OpenAPI 文档同步**:改动 `*.openapi.controller.ts` 或其引用的 interface / service 返回值 / schema 字段时,加载 `openapi-guide` skill,同步更新 `docs/openapi.json`
568
594
  9. **服务端查用户信息(CRITICAL)**:先区分当前用户 `userId`、应用 `appId` 与人员 ID,再确认请求上下文是否已有非空 `appId`,最后选择查询能力和响应字段语义。仅有 `userId` 或给定人员 ID 都不能推导出 `appId`;只有已具备非空 `appId` 时才用 `AuthNPaasService.listUsersByIds`,并保留批量字段映射、平台错误与查无此人的区别。匿名入口无法证明该前置条件时,主体读取保持独立,返回已有稳定 ID,展示名 / 头像建模为可空或可选;明确要求匿名真实姓名但无受支持 `appId` 来源时报告能力阻塞。禁止凭空补身份头、裸调平台、持久化姓名副本、service 广域 `try/catch` 或固定“未知用户”降级。动手前加载 `contacts-service` skill 第六节
569
595
 
@@ -687,15 +713,8 @@ async createArticle(@Req() req: Request, @Body() dto: CreateArticleDto) {
687
713
  - **图表**: ReactECharts,**开发前必须调用 `/charts-skill`**
688
714
  - **图标**: Lucide React(唯一图标库,禁止 Emoji 和其他图标库)
689
715
  - **表格/表单/图表**: 见下方"组件 Skill 召回规则",开发前必须先调用对应 Skill
690
- {% if projectMeta['flags']['supportBusinessUser'] %}
691
716
  - **用户**: 用户信息展示/选择必须用 `business-ui` 组件(阅读 README.md),禁止直接展示 userId
692
- {% endif %}
693
- {% if projectMeta['flags']['supportTiptapAndStreamdown'] %}
694
- - **富文本**: `business-ui/tiptap-editor`(阅读 README.md)
695
- - **Markdown 渲染**: `components/ui/streamdown`(内置 prose 排版)
696
- {% else %}
697
717
  - **Markdown 渲染**: `components/ui/markdown`(react-markdown + remark-gfm,内置 prose 排版)
698
- {% endif %}
699
718
 
700
719
  ## API 请求
701
720
 
@@ -715,6 +734,7 @@ import { axiosForBackend } from '@lark-apaas/client-toolkit/utils/getAxiosForBac
715
734
  ### 后端异常响应与前端错误提示
716
735
  - 后端异常结构为 `{ "error": { "code": "CONFLICT", "message": "具体错误信息", "fieldErrors": { "字段": ["错误"] } } }`,顶层没有 `message`。
717
736
  - 前端展示接口错误(catch、`toast.error`、页面错误态)读 `error.response?.data?.error?.message`,取不到再用第一条 `fieldErrors` 文案或调用方的兜底文案,不用 axios 的 `error.message`;403 按 authz-guide 统一处理,不在业务 catch 里判断。
737
+ - 多个文件都要读错误文案时,写成一个函数放在共享目录,各处导入,不在每个文件里重写。
718
738
 
719
739
  ## 日志与错误处理
720
740
 
@@ -727,15 +747,8 @@ import { axiosForBackend } from '@lark-apaas/client-toolkit/utils/getAxiosForBac
727
747
  | 组件 | 来源 | 用途 |
728
748
  |------|------|------|
729
749
  | Table | `@lark-apaas/client-toolkit/antd-table` | 数据表格,**先调 `/table-skill`** |
730
- {% if projectMeta['flags']['supportBusinessUser'] %}
731
750
  | UserSelect/UserDisplay/UserProfile/DepartmentSelect | `business-ui/*` | 用户/部门选择展示 |
732
- {% endif %}
733
- {% if projectMeta['flags']['supportTiptapAndStreamdown'] %}
734
- | TiptapEditorComplete | `business-ui/tiptap-editor` | 富文本编辑器 |
735
- | Streamdown | `components/ui/streamdown` | Markdown/流式渲染 |
736
- {% else %}
737
751
  | Markdown | `components/ui/markdown` | Markdown 渲染(react-markdown + remark-gfm) |
738
- {% endif %}
739
752
 
740
753
  ### 组件 Skill 召回规则(强制执行)
741
754
 
@@ -824,7 +837,17 @@ import { axiosForBackend } from '@lark-apaas/client-toolkit/utils/getAxiosForBac
824
837
  ## 页面与路由
825
838
 
826
839
  - **新增页面流程**:判断是否需要隔离布局 → 创建 `pages/` 下组件 → `app.tsx` 配置路由 → 更新导航
827
- - **数据获取**:页面级数据获取在页面组件顶层处理,通过 props 向下传递
840
+ - **数据获取**:页面级数据获取在页面组件顶层处理,通过 props 向下传递。读写后端数据写成自定义 hook;`package.json` 已有 `@tanstack/react-query` 时,hook 内用 `@tanstack/react-query` 的 `useQuery` / `useMutation`(`AppContainer` 已提供 QueryClient),用返回的 `isLoading` / `isPending` / `error` 表示状态,不手写 loading/error state。**每个 `useMutation` 都写 `retry: false`**(平台默认写操作失败会重发一次,造成重复提交);写成功后在 `onSuccess` 里用 `queryClient.invalidateQueries` 刷新相关查询:
841
+ ```typescript
842
+ export const useCreateItem = () => {
843
+ const queryClient = useQueryClient();
844
+ return useMutation({
845
+ mutationFn: (body: CreateItemRequest) => axiosForBackend.post('/api/items', body).then((r) => r.data),
846
+ retry: false,
847
+ onSuccess: () => queryClient.invalidateQueries({ queryKey: ['items'] }),
848
+ });
849
+ };
850
+ ```
828
851
  - **禁止 React.lazy**:严禁异步加载路由组件
829
852
  - **页面跳转**:必须用 `NavLink`/`Link`/`useNavigate`,**严禁 `window.location.href`**
830
853
  - **分享链接/二维码**(CRITICAL):应用部署后 URL 与本地路由不同,**必须**用 `resolveAppUrl` 转换:
@@ -856,7 +879,8 @@ import { axiosForBackend } from '@lark-apaas/client-toolkit/utils/getAxiosForBac
856
879
  - **为可能 undefined 的数据提供 fallback UI**
857
880
 
858
881
  ```jsx
859
- if (loading) return <div>加载中...</div>;
882
+ const { data, isLoading } = useItemDetail(id); // 自定义 hook,内部用 useQuery
883
+ if (isLoading) return <div>加载中...</div>;
860
884
  if (!data) return <div>暂无数据</div>;
861
885
  return <h1>{data?.title || '未知标题'}</h1>;
862
886
  ```
@@ -875,9 +899,7 @@ return <h1>{data?.title || '未知标题'}</h1>;
875
899
  ## 图片规范
876
900
 
877
901
  - **展示**:必须用 `@client/src/components/ui/image` 组件(非原生 `<img>`),响应式设 `sizes`,固定宽设 `width`
878
- {% if projectMeta['flags']['supportBusinessUser'] %}
879
902
  - 用户头像用 business-ui 组件
880
- {% endif %}
881
903
 
882
904
  ### 图片资源优先级
883
905
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: react-hook-best-practices
3
- description: "在编写 React Hooks 时遇到闭包陷阱、冗余 Effect、派生状态管理、useEffect 死循环(含「Maximum update depth exceeded」/「循环渲染」/「无限循环」/「页面卡死」等症状)等问题时使用。涵盖 React 19 的 use()、useActionState、useOptimistic 等新特性,以及依赖数组管理、事件处理 vs Effect 等现代模式。React Hooks best practices for stale closures, redundant Effects, derived state, and infinite loop / Maximum update depth diagnosis."
3
+ description: "在编写 React Hooks 时遇到闭包陷阱、冗余 Effect、派生状态管理、useEffect 死循环(含「Maximum update depth exceeded」/「循环渲染」/「无限循环」/「页面卡死」等症状)等问题时使用。涵盖后端数据读写用 react-query(useQuery / useMutation)、React 19 的 useOptimistic 等新特性,以及依赖数组管理、事件处理 vs Effect 等现代模式。React Hooks best practices for stale closures, redundant Effects, derived state, and infinite loop / Maximum update depth diagnosis."
4
4
  steering: true
5
5
  steering-topic: react_hook_best_practices
6
6
  match-template-name: nestjs-react-fullstack
@@ -10,7 +10,7 @@ match-template-name: nestjs-react-fullstack
10
10
 
11
11
  ## ⚡️ 核心原则 (TL;DR)
12
12
 
13
- 1. **优先 React 19 新特性**: 用 `use()` 读取异步数据,用 `useActionState` 管理表单,替代繁琐的 `useEffect` + `useState`。
13
+ 1. **后端数据用 react-query**: `package.json` 已有 `@tanstack/react-query` 时(全栈模板默认自带),读写后端接口在自定义 hook 内用 `useQuery` / `useMutation`(写操作 `retry: false`,见 coding-guide「数据获取」),替代 `useEffect` + `useState` 手写 loading/error;本文件下面的 react-query 规则和示例都以此为前提。没有该依赖时,不直接生成 `@tanstack/react-query` import,沿用项目已有的数据获取写法。
14
14
  2. **拒绝冗余 State**: 能计算得到的变量(派生状态),绝不存入 State,直接计算 or `useMemo`。
15
15
  3. **事件驱动 > Effects**: 用户交互(点击、提交)产生的逻辑写在事件处理函数中,`useEffect` 仅用于同步外部系统(订阅、DOM)。
16
16
  4. **依赖诚实**: `useEffect/useCallback/useMemo` 的依赖数组必须包含所有引用的响应式变量,禁止欺骗 Linter。
@@ -22,11 +22,11 @@ match-template-name: nestjs-react-fullstack
22
22
  | ❌ 错误模式 | ✅ 正确做法 | 说明 |
23
23
  | --------------------------------- | --------------------------------- | ----------------------------------------------------------------------------------------------------- |
24
24
  | **依赖数组撒谎** | **诚实的依赖数组** | 漏写依赖会导致闭包陷阱(读到旧值)。若不希望频繁触发,用 `useRef` 或拆分逻辑。 |
25
- | **useEffect 获取数据** | **use() / useActionState** | React 19 中,数据获取应配合 Suspense 或 Action,而非手动管理 loading/error。 |
25
+ | **useEffect 获取数据** | **useQuery / useMutation** | 后端数据由 react-query 管理 loading/error 和缓存,不手动维护。 |
26
26
  | **useCallback 依赖 loading** | **Functional Updates / 移除依赖** | 若 `useCallback` 修改 `loading` 又依赖 `loading`,会导致引用不稳定,触发 `useEffect` 循环或双重请求。 |
27
27
  | **useEffect 同步 Props 到 State** | **派生状态 / key** | 直接在 Render 中计算;若需重置状态,给组件加 `key` prop。 |
28
28
  | **useEffect 回调直接 async** | **内部定义 async 函数** | `useEffect(async () => ...)` 返回 Promise 会破坏清理机制。 |
29
- | **手动管理 Form Loading** | **useActionState** | 自动处理 pending/error,减少样板代码。 |
29
+ | **手动管理 Form Loading** | **useMutation** | 提交到后端用 `useMutation`(写 `retry: false`)的 `isPending` / `error`,减少样板代码。 |
30
30
  | **遗漏副作用清理** | **返回清理函数** | 监听器、定时器、订阅必须在 `return () => ...` 中清除。 |
31
31
  | **Context value 引用不稳定** | **ref 持值 + 稳定 getter** | `<Provider value={useMemo(() => ({ inst }), [inst])}>` 中若 `inst` 是会更新的实例,每帧 identity 翻 → 所有 consumer 重渲染 → 子组件 ref-callback 链触发 setState → 死循环。改用 `ref.current = inst` + 一次性 `useMemo(() => ({ get: () => ref.current }), [])`。 |
32
32
  | **selector / hook 返回 `.bind()` 或新对象字面量** | **直接传引用,不 bind** | `useStore({ selector })` / `useContextSelector` 类用 shallow equality bailout。`selector: ctx => ({ fn: ctx.fn.bind(ctx) })` 每帧产生新函数 → 所有 consumer 重渲染 → setRef 链路放大成死循环。直接传 `ctx.fn`,consumer 自己访问。 |
@@ -36,18 +36,17 @@ match-template-name: nestjs-react-fullstack
36
36
 
37
37
  #### Safe Destructuring (安全解构)
38
38
 
39
- **禁止**对可能为空的 Hook 返回值(如 `useActionState` 的 state 或 `use` 读取的异步数据)直接进行深层解构。
39
+ **禁止**对可能为空的 Hook 返回值(如 `useQuery` 的 `data`)直接进行深层解构。
40
40
 
41
41
  ```typescript
42
42
  // ❌ Anti-pattern
43
- const [state, action] = useActionState(updateUser, null);
44
43
  const {
45
44
  data: { name },
46
- } = state; // 如果 state 初始为 null,直接崩溃
45
+ } = useUser(id); // data 加载完成前是 undefined,直接崩溃
47
46
 
48
47
  // ✅ Best Practice
49
- const [state, action] = useActionState(updateUser, null);
50
- const name = state?.data?.name ?? "Guest";
48
+ const { data } = useUser(id);
49
+ const name = data?.name ?? "Guest";
51
50
  ```
52
51
 
53
52
  ---
@@ -56,8 +55,8 @@ const name = state?.data?.name ?? "Guest";
56
55
 
57
56
  | Hook | 场景 | 示例模式 |
58
57
  | ------------------ | ---------------------- | -------------------------------------------------------------- |
59
- | **use(Promise)** | **读取异步数据** | `const data = use(fetchData(id))` (需配合 `<Suspense>`) |
60
- | **useActionState** | **表单提交/Mutation** | `const [state, action, isPending] = useActionState(fn, null)` |
58
+ | **use(Promise)** | **读取已有 Promise** | `const v = use(promise)` (需配合 `<Suspense>`;后端接口数据用 `useQuery`) |
59
+ | **useActionState** | **本地表单状态** | `const [state, action, isPending] = useActionState(fn, null)`(提交到后端用 `useMutation`,写 `retry: false`) |
61
60
  | **useOptimistic** | **乐观 UI 更新** | `const [optTodos, addOpt] = useOptimistic(todos, reducer)` |
62
61
  | **useFormStatus** | **子组件获取表单状态** | `const { pending } = useFormStatus()` (仅限 `<form>` 内部组件) |
63
62
  | **useTransition** | **非阻塞 UI 更新** | `startTransition(() => setFilter(value))` |
@@ -75,12 +74,15 @@ useEffect(() => {
75
74
  if (loading) return <Spinner />;
76
75
  ```
77
76
 
78
- **🟢 新模式 (React 19+)**:
77
+ **🟢 推荐 (react-query)**:
79
78
 
80
79
  ```typescript
81
- // 结合 Suspense 使用
82
- const data = use(fetchUserPromise(id)); // 支持条件调用!
83
- return <div>{data.name}</div>;
80
+ // 自定义 hook 内用 useQuery
81
+ const useUser = (id: string) =>
82
+ useQuery({ queryKey: ['user', id], queryFn: () => axiosForBackend.get(`/api/user/${id}`).then((r) => r.data) });
83
+ const { data, isLoading } = useUser(id);
84
+ if (isLoading) return <Spinner />;
85
+ return <div>{data?.name}</div>;
84
86
  ```
85
87
 
86
88
  ---
@@ -100,7 +102,7 @@ return <div>{data.name}</div>;
100
102
  `useEffect` 是用来**同步** React 之外的东西(非 React 组件状态)的。
101
103
 
102
104
  - **用户点击加载数据?** -> ❌ Effect -> ✅ Event Handler (`onClick`)
103
- - **表单提交?** -> ❌ Effect -> ✅ `useActionState` / Event Handler
105
+ - **表单提交?** -> ❌ Effect -> ✅ 提交到后端用 `useMutation`(`retry: false`)/ Event Handler;纯本地表单状态才用 `useActionState`
104
106
  - **WebSocket 连接?** -> ✅ `useEffect` (因为要保持连接同步)
105
107
  - **DOM 元素测量?** -> ✅ `useLayoutEffect` / `useEffect`
106
108
 
@@ -116,4 +118,4 @@ return <div>{data.name}</div>;
116
118
  - [ ] **竞态处理**: 异步 Effect 是否处理了组件卸载或 id 变更的情况?(如 `ignore` 标志)。
117
119
  - [ ] **autosave/防抖保存竞态**: ① 组件卸载时禁止强制保存当前 state(卸载触发的保存常用未初始化/空值覆盖已有数据);② id/资源切换时必须用 `key` 或 effect 重置 state,避免上一资源的脏 state 串写;③ **修一个模块的 autosave 竞态后,必须 grep 同构模块(其他 autosave/防抖保存)全部横展同一修复**——单模块局部修补是数据反复丢失复发的主因。
118
120
  - [ ] **引用稳定**: `Context` value 或自定义 Hook 返回的对象,是否做了 `useMemo` 缓存?
119
- - [ ] **React 19 升级**: 是否还在手动写 `loading` state?能否用 `useActionState` 或 `Suspense` 替代?
121
+ - [ ] **加载状态**: 后端数据是否还在手动写 `loading` state?改用 `useQuery` / `useMutation` 返回的状态。
@@ -92,10 +92,16 @@ shared/
92
92
  - **import 类型前必须确认实际导出**:先读取文件确认类型名称存在且拼写一致,禁止臆造类型名
93
93
  - 属性统一 camelCase(禁止 snake_case),server 端 schema 的 snake_case 不应泄漏到接口类型
94
94
  - shared 目录是前后端共享文件,**禁止反向引用** `@server/*`、`@client/*` 等路径别名
95
- - **请求 DTO 中的 shared 字符串联合别名**:`shared/api.interface.ts` 是前后端契约权威。带 `class-validator` 的 NestJS 请求 DTO 字段若对应 shared 字符串字面量联合 `type` alias,不把 imported alias 直接写在 DTO 属性上;改用等价 inline literal union + `@IsString()` + `@IsIn([...])`,DTO class 仍 `implements` shared 请求接口。
96
- - ❌ `type!: ExpressionElementType`
97
- - ✅ `@IsString()` + `@IsIn(['tile', 'operator'])` + `type!: 'tile' | 'operator'`
98
- - **机械核对**:先数 shared alias 字面量个数 N,inline union 和 `@IsIn` 数组都必须恰好 N 个,且每个字面量完全一致;数量或名称不等就是错,不能只写“不得窄于”。
95
+ - **固定取值在 shared 定义一次**:状态、类型等固定字符串取值,在 `shared/api.interface.ts` 用 `as const` 数组定义,同时导出联合类型、类型守卫和中文标签;前后端都从这里导入,不另写取值数组或守卫。
96
+ ```typescript
97
+ export const ITEM_STATUSES = ['pending', 'done'] as const;
98
+ export type ItemStatus = (typeof ITEM_STATUSES)[number];
99
+ export const isItemStatus = (v: string): v is ItemStatus =>
100
+ (ITEM_STATUSES as readonly string[]).includes(v);
101
+ export const ITEM_STATUS_LABELS: Record<ItemStatus, string> = { pending: '待处理', done: '已完成' };
102
+ ```
103
+ - 后端把数据库读出的字符串收窄成联合类型时,用 shared 的守卫
104
+ - 带 `class-validator` 的请求 DTO:`@IsString()` + `@IsIn(ITEM_STATUSES)` 引用 shared 数组;字段类型写等价的内联字面量联合(`status!: 'pending' | 'done'`),不直接写 shared 别名(`status!: ItemStatus` 会被 lint 报错)。DTO 内联联合与 shared 取值保持一致,DTO class 仍 `implements` shared 请求接口
99
105
  - 边界:shared 真实 `enum` 用 `@IsEnum(EnumName)`;嵌套 DTO 用 `@ValidateNested()` + `@Type(() => XxxDto)`;普通对象字段用 `@IsObject()`;Date 转换按 `@Type(() => Date)` 处理。禁止用 `@Type(() => String)` 只为绕过 lint。
100
106
 
101
107
  ## 代码质量约束
@@ -368,7 +374,22 @@ await db.select().from(users).where(eq(users.adminUser, userId));
368
374
  - 前端 API 调用的 HTTP method 和 path **必须**与后端 Controller 装饰器完全匹配(`GET≠POST` → 405)
369
375
  - 后端 Service 返回值结构**必须**与 `shared/api.interface.ts` 中定义的响应类型完全匹配(禁止后端直接返回数组而 shared 定义为 `{items: T[]}`)
370
376
  5. **路由注册验证**:创建新 Controller 后必须用接口测试工具验证路由是否生效。**遇到 404 排查路径**:① 检查 `@Controller(...)` 是否以 `api/` 或 `openapi/` 开头 ② 检查 Module 是否在 `app.module.ts` 注册 ③ 检查静态路由是否在动态路由 `/:id` 之前
371
- 6. **@Query/@Param 类型转换**:默认 string,必须在 controller 层手动转换(如 `parseInt(limit, 10)`)
377
+ 6. **请求参数绑定与校验**:
378
+ - 列表 / 筛选接口用 `@Query() query: XxxQueryDto` 绑定,不在 controller 里逐个 `parseInt`。查询参数原始值都是字符串:数字字段加 `@Type(() => Number)`;布尔、数组和可选筛选字段用 `@Transform` 处理。传给 `value` 的值已被全局隐式转换和 `@Type` 改写(如 `'false'`→`true`、`''`→`0`),判断原始字符串时读 `obj[key]`:布尔按 `obj[key]` 把 `'true'`/`'false'` 转成布尔;可选筛选在 `obj[key] === ''` 时返回 `undefined`、否则返回 `value`(保留 `@Type` 的转换结果),不处理时前端「全部」发出的空串会返回 400 或被当成 0;数组把单个 `value` 包成数组
379
+ ```typescript
380
+ @IsOptional() @Type(() => Number) @IsInt() @Min(1)
381
+ page?: number;
382
+ @IsOptional() @Transform(({ obj, key, value }) => (obj[key] === '' ? undefined : value)) @Type(() => Number) @IsNumber()
383
+ minAmount?: number;
384
+ @IsOptional() @Transform(({ obj, key, value }) => (obj[key] === '' ? undefined : value)) @IsString() @IsIn(ITEM_STATUSES)
385
+ status?: 'pending' | 'done';
386
+ @IsOptional() @Transform(({ obj, key }) => (obj[key] === 'true' ? true : obj[key] === 'false' ? false : obj[key])) @IsBoolean()
387
+ mine?: boolean;
388
+ @IsOptional() @Transform(({ value }) => (Array.isArray(value) ? value : [value])) @IsArray() @IsString({ each: true })
389
+ tags?: string[];
390
+ ```
391
+ - 前端筛选选「全部」时不传该参数。Select 用占位值(如 `'all'`)表示「全部」时,发请求前去掉该参数:shadcn Select 不允许空串作选项值,`'all'` 原样发出会被 `@IsIn` 校验拒绝并返回 400
392
+ - `@Param` 单值默认 string,需要数字时在 controller 层转换(如 `Number(id)`)
372
393
  7. **写操作加 @NeedLogin**:POST/PUT/PATCH/DELETE 接口显式加 `@NeedLogin()` 装饰器:
373
394
 
374
395
  ```typescript
@@ -377,6 +398,12 @@ await db.select().from(users).where(eq(users.adminUser, userId));
377
398
  @Post()
378
399
  async createItem(@Req() req, @Body() dto) { ... }
379
400
  ```
401
+ 整个 controller 都需要登录时,在类上写一次 `@NeedLogin()`;同一 controller 里有公开路由时,拆成两个 controller,或保留方法级写法:
402
+ ```typescript
403
+ @Controller("api/items")
404
+ @NeedLogin()
405
+ export class ItemsController { ... }
406
+ ```
380
407
 
381
408
  8. **OpenAPI 文档同步**:改动 `*.openapi.controller.ts` 或其引用的 interface / service 返回值 / schema 字段时,加载 `openapi-guide` skill,同步更新 `docs/openapi.json`
382
409
 
@@ -534,6 +561,7 @@ import { axiosForBackend } from '@lark-apaas/client-toolkit/utils/getAxiosForBac
534
561
  ### 后端异常响应与前端错误提示
535
562
  - 后端异常结构为 `{ "error": { "code": "CONFLICT", "message": "具体错误信息", "fieldErrors": { "字段": ["错误"] } } }`,顶层没有 `message`。
536
563
  - 前端展示接口错误(catch、`toast.error`、页面错误态)读 `error.response?.data?.error?.message`,取不到再用第一条 `fieldErrors` 文案或调用方的兜底文案,不用 axios 的 `error.message`;403 按 authz-guide 统一处理,不在业务 catch 里判断。
564
+ - 多个文件都要读错误文案时,写成一个函数放在共享目录,各处导入,不在每个文件里重写。
537
565
 
538
566
  ## 日志与错误处理
539
567
 
@@ -633,7 +661,17 @@ import { axiosForBackend } from '@lark-apaas/client-toolkit/utils/getAxiosForBac
633
661
  ## 页面与路由
634
662
 
635
663
  - **新增页面流程**:判断是否需要隔离布局 → 创建 `pages/` 下组件 → `app.tsx` 配置路由 → 更新导航
636
- - **数据获取**:页面级数据获取在页面组件顶层处理,通过 props 向下传递
664
+ - **数据获取**:页面级数据获取在页面组件顶层处理,通过 props 向下传递。读写后端数据写成自定义 hook;`package.json` 已有 `@tanstack/react-query` 时,hook 内用 `@tanstack/react-query` 的 `useQuery` / `useMutation`(`AppContainer` 已提供 QueryClient),用返回的 `isLoading` / `isPending` / `error` 表示状态,不手写 loading/error state。**每个 `useMutation` 都写 `retry: false`**(平台默认写操作失败会重发一次,造成重复提交);写成功后在 `onSuccess` 里用 `queryClient.invalidateQueries` 刷新相关查询:
665
+ ```typescript
666
+ export const useCreateItem = () => {
667
+ const queryClient = useQueryClient();
668
+ return useMutation({
669
+ mutationFn: (body: CreateItemRequest) => axiosForBackend.post('/api/items', body).then((r) => r.data),
670
+ retry: false,
671
+ onSuccess: () => queryClient.invalidateQueries({ queryKey: ['items'] }),
672
+ });
673
+ };
674
+ ```
637
675
  - **禁止 React.lazy**:严禁异步加载路由组件
638
676
  - **页面跳转**:必须用 `NavLink`/`Link`/`useNavigate`,**严禁 `window.location.href`**
639
677
  - **分享链接/二维码**(CRITICAL):应用部署后 URL 与本地路由不同,**必须**用 `resolveAppUrl` 转换:
@@ -667,7 +705,8 @@ import { axiosForBackend } from '@lark-apaas/client-toolkit/utils/getAxiosForBac
667
705
  - **为可能 undefined 的数据提供 fallback UI**
668
706
 
669
707
  ```jsx
670
- if (loading) return <div>加载中...</div>;
708
+ const { data, isLoading } = useItemDetail(id); // 自定义 hook,内部用 useQuery
709
+ if (isLoading) return <div>加载中...</div>;
671
710
  if (!data) return <div>暂无数据</div>;
672
711
  return <h1>{data?.title || '未知标题'}</h1>;
673
712
  ```