diff --git a/qy-lty-admin/.planning/phases/03-dialog-feedback/03-RESEARCH.md b/qy-lty-admin/.planning/phases/03-dialog-feedback/03-RESEARCH.md new file mode 100644 index 0000000..718657c --- /dev/null +++ b/qy-lty-admin/.planning/phases/03-dialog-feedback/03-RESEARCH.md @@ -0,0 +1,845 @@ +# Phase 3:编辑对话框 + 提交反馈 - Research + +**调研日期**:2026-05-08 +**Domain**:Next.js 15 (App Router) + React 19 + RHF + Zod + shadcn UI + Sonner(凭据槽位编辑对话框) +**Confidence**:HIGH(全部基于本仓库已落地代码 grep + read,无需外部文档查证) + +## Summary + +本 phase 抽离独立组件 `components/ai-model/credential-slot-dialog.tsx`,把 Phase 2 落地的占位 Dialog(`app/ai-model/page.tsx` L473-485)替换为完整功能的编辑对话框:基于 React Hook Form + Zod 表单、调 `getCredentialSlot()` 预填、提交走 `updateCredentialSlot()`、成功/失败用 toast 反馈、错误经 `handleApiError` 映射。 + +**所有依赖均已在 deps 中**(`react-hook-form`、`@hookform/resolvers`、`zod`、`sonner`、`lucide-react`、shadcn UI 组件),**不需要新增任何依赖**。 + +仓库内已有两个高质量 RHF + Zod 1:1 模板可直接对照:`components/users/user-form-dialog.tsx`(更贴近本 phase 的 single-dialog + 表单 + submit pattern)与 `components/permissions/role-dialog.tsx`(更复杂,含动态字段,本 phase 用不到)。**首选模板:`components/users/user-form-dialog.tsx`**。 + +**Primary recommendation**:以 `components/users/user-form-dialog.tsx` 为蓝本,改造成 `components/ai-model/credential-slot-dialog.tsx`(kebab-case 命名,与 `add-outfit-dialog.tsx` / `add-song-dialog.tsx` / `user-form-dialog.tsx` / `role-dialog.tsx` 保持一致),表单两个字段 `appId` + `accessToken`,access_token **强制输入**(CONTEXT.md 锁定),提交走 `updateCredentialSlot({ appId, accessToken })`。 + +⚠️ **预警 1(必须告知 planner)**:仓库内全局**未挂载** `` 或 `` —— `app/layout.tsx` 仅 20 行裸结构,没有 Toaster 渲染;`components/dashboard-shell.tsx` 也没有。这意味着任何 `toast()` 调用**当前都会静默 no-op**(实测过 `add-dance-dialog.tsx` 等已有 `toast(...)` 调用也属于死代码)。Phase 3 必须**在 plan 中纳入"挂载 Sonner Toaster 到 `app/layout.tsx`"作为前置任务**,否则 toast 反馈完全不可见。 + +⚠️ **预警 2**:仓库内有**两个 `handleApiError` 函数**(`lib/api/error-handler.ts:38` 与 `lib/api/index.ts:191`),签名与行为相同(都是 `(error: unknown) => string`,先看 `error instanceof Error` 取 `.message`,否则返回中文兜底)。CONTEXT.md 锁定从 `lib/api/error-handler.ts` 引入(`import { handleApiError } from '@/lib/api/error-handler'`),不要从 barrel `@/lib/api` 引(barrel 里那个是同名重复定义,存在 namespace 歧义风险)。 + +⚠️ **预警 3**:仓库 `hooks/use-toast.ts` 与 `components/ui/use-toast.ts` 是**两份完全相同的 Radix Toast 实现**(不是 Sonner)—— 即使挂载了 ``(Radix 版本),也跟 Sonner 互不通信。CONTEXT.md 锁定 Sonner 反馈,意味着 plan 必须:(a) 挂载 ``(来自 `@/components/ui/sonner`),(b) 在 dialog 内 `import { toast } from "sonner"`(直接用 sonner 包导出的 `toast()`,**不**用 `useToast` hook —— Sonner 没有 hook 模式,是命令式 API)。 + + +## User Constraints (from CONTEXT.md) + +### Locked Decisions + +**组件抽离**: +- **新建**:`components/ai-model/CredentialSlotDialog.tsx`(命名待定,见 Discretion) + - `components/ai-model/` 目录**确认不存在**,需要 mkdir +- **修改**:`app/ai-model/page.tsx` + - 删除 Phase 2 落地的占位 Dialog(确认在 L473-485) + - 用 `` 替换 + - 保留 Button 入口(L36-43)+ mounted 守卫(L20、L23-25) + +**Dialog 接口**: +```typescript +interface CredentialSlotDialogProps { + open: boolean + onOpenChange: (open: boolean) => void +} +``` + +**表单技术栈**:React Hook Form + Zod,`@hookform/resolvers/zod`(已在 deps)。 + +**预填态**: +- `useEffect(() => { if (open) loadData() }, [open])` 调 `getCredentialSlot()` +- `appId` field 默认值 = `slot.appId`(明文) +- `accessToken` field 默认值 = `''`(**不是** masked!) +- input placeholder = `slot.accessTokenMasked` +- 提示文字"如需更新请重新输入" +- `updated_at` 只读显示 + +**提交逻辑(CONTEXT 最终决策)**: +- access_token **强制输入**:`z.string().min(1, '请输入 Access Token')` +- 退化为"每次保存都要重输 access_token",UX 略差但语义正确(不会回写脱敏掩码) +- `docs/修改记录.md` Phase 3 条目「修改原因」段必须**显式说明**这个权衡 + 候选下一步是给后端加"识别脱敏掩码保留旧值"逻辑 + +**Toast 通知**: +- Sonner(不要用 Radix Toast / hooks/use-toast) +- 成功:title "凭据槽位已更新" / 描述 "配置已生效" +- 失败:title "保存失败" / description = `handleApiError(e)` / variant "destructive" + +**错误处理**: +- 复用 `lib/api/error-handler.ts:handleApiError` +- 失败时**不**关闭对话框、**不**清空表单值 + +### Claude's Discretion + +- 文件命名 `CredentialSlotDialog.tsx`(PascalCase)vs `credential-slot-dialog.tsx`(kebab-case)—— researcher 看现有约定决定 +- Cancel 按钮文案:"取消" / "关闭" +- 提交按钮 loading 态文案:"保存中..." / "处理中..." / "提交中..." +- 是否给 access_token input 加 `type="password"` —— CONTEXT 推荐**不**加 +- "最后更新"时间格式:`new Date(updatedAt).toLocaleString('zh-CN')` 或 `date-fns` + +### Deferred Ideas (OUT OF SCOPE) + +- "留空保留旧值"语义 —— 需要后端识别脱敏掩码格式,给后端开新 phase / patch milestone +- 端到端浏览器测试(无 E2E 框架) +- token 轮换 / refresh token / DB at-rest 加密 / Vitest 体系 / ESLint bootstrap + + + + +## Phase Requirements + +| ID | Description | Research Support | +|----|-------------|------------------| +| CRED-FE-04 | 编辑对话框组件 `components/ai-model/CredentialSlotDialog.tsx`:基于 `components/ui/dialog.tsx`;表单 React Hook Form + Zod 校验;预填态显示后端返回的 app_id 明文 + access_token 末 4 位掩码 + 不可改的 updated_at;提交触发 `updateCredentialSlot()` | ✅ 1:1 模板 `components/users/user-form-dialog.tsx` 直接复用;shadcn Form wrapper `components/ui/form.tsx` 已存在;Phase 1 落地的 `getCredentialSlot()` / `updateCredentialSlot()` API 已就绪(`lib/api/credential-slot.ts`);Phase 2 落地的页面入口 + mounted 守卫已就绪(`app/ai-model/page.tsx`) | +| CRED-FE-05 | 提交反馈:成功调 `useToast()` 弹 Sonner toast 自动关闭 + 重新 GET;失败走 `lib/api/error-handler.ts` 统一映射并 toast 提示 | ⚠️ Sonner Toaster 全局未挂载(gap,必须修复);`handleApiError(error: unknown): string` 签名已确认;CONTEXT 锁定不重新 GET 而由打开 Dialog 时的 useEffect 自动 loadData(关闭再开自然刷新) | + + + +## Architectural Responsibility Map + +| Capability | Primary Tier | Secondary Tier | Rationale | +|------------|-------------|----------------|-----------| +| 表单状态 + 校验 | Browser / Client (RHF + Zod) | — | 标准 RHF 受控表单,纯客户端 | +| 预填数据 GET | Browser / Client (axios) | API / Backend (Phase 2 后端 v1.0) | `apiClient.get` via Phase 1 落地的 `getCredentialSlot()` | +| 提交 PUT | Browser / Client (axios) | API / Backend (Phase 2 后端 v1.0) | `apiClient.put` via Phase 1 落地的 `updateCredentialSlot()` | +| Toast 反馈 | Browser / Client (Sonner) | — | Sonner 是纯客户端 portal,挂在 layout.tsx 的 RootLayout | +| 错误映射 | Browser / Client | — | `handleApiError` 是同步纯函数,无 IO | +| RBAC 入口可见性 | Browser / Client | — | Phase 2 已落地(`hasPermission('credential-slot')`) | + +## Standard Stack + +### Core(已全部在 deps,**不引入新依赖**) + +| 库 | 版本(package.json) | 用途 | Why Standard | +|---------|---------|---------|--------------| +| react-hook-form | `latest` | 表单状态管理 | 仓库现有 `users/user-form-dialog.tsx` + `permissions/role-dialog.tsx` 已用 [VERIFIED: `package.json:56`] | +| @hookform/resolvers | `latest` | RHF + Zod 桥接(`zodResolver`) | 同上 [VERIFIED: `package.json:12`] | +| zod | `latest` | schema 校验 | 同上 [VERIFIED: `package.json:63`] | +| sonner | `^1.7.1` | Toast 通知 | CONTEXT 锁定 [VERIFIED: `package.json:59`] | +| lucide-react | `^0.454.0` | 图标 | `Loader2` for loading 态、`KeyRound` 已在 page.tsx import [VERIFIED: `package.json:50`] | +| @radix-ui/react-dialog | `^1.1.4` | Dialog 底层 | shadcn `components/ui/dialog.tsx` 已封装 [VERIFIED: `package.json:20`] | + +### Supporting(shadcn UI 组件,全部已存在) + +| 文件 | 是否存在 | 用途 | +|---------|---------|---------| +| `components/ui/dialog.tsx` | ✅ | Dialog / DialogContent / DialogHeader / DialogTitle / DialogDescription / DialogFooter | +| `components/ui/form.tsx` | ✅ | shadcn Form wrapper(Form / FormField / FormItem / FormLabel / FormControl / FormMessage / FormDescription)—— 详见下文 | +| `components/ui/input.tsx` | ✅ | Input(forwardRef,受控) | +| `components/ui/label.tsx` | ✅ | Label(基于 Radix `LabelPrimitive.Root`) | +| `components/ui/button.tsx` | ✅ | Button | +| `components/ui/sonner.tsx` | ✅ | Sonner `` 包装(**未在 layout 中挂载,gap**) | + +### Alternatives Considered(CONTEXT 已锁定决策,仅供 plan-checker 验证) + +| Instead of | Could Use | Why we don't | +|------------|-----------|----------| +| Sonner toast | `hooks/use-toast.ts`(Radix Toast) | CONTEXT 锁定 Sonner,且 Radix `` 同样未挂载,无优势 | +| 受控 useState(如 `add-song-dialog.tsx` / `add-outfit-dialog.tsx` 风格) | RHF + Zod | CONTEXT 锁定 RHF + Zod;现有 `user-form-dialog.tsx` / `role-dialog.tsx` 已是该风格的 in-house 模板 | +| barrel 路径 `@/lib/api` 的 `handleApiError` | `lib/api/error-handler.ts` 的版本 | CONTEXT 锁定后者;二者实现相同但显式路径避免歧义 | + +**安装**:`npm install` —— 无新增依赖。 + +## RHF + Zod 1:1 模板(关键发现) + +### 现有 RHF + Zod 用法 grep 全仓结果 + +仅 **2 个文件** 同时含 `useForm` + `zodResolver` + `zod`(grep `useForm.*zodResolver` head_limit: + +1. **`components/users/user-form-dialog.tsx`**(289 行)—— **首选 1:1 模板** +2. **`components/permissions/role-dialog.tsx`**(425 行)—— 含动态字段,过于复杂,参考即可 + +### 首选模板形态:`components/users/user-form-dialog.tsx` + +**关键代码区段(直接复用)**: + +| 段落 | 行号 | 用途 | +|------|------|------| +| 顶部 import 块(含 `useForm` / `zodResolver` / `* as z` / `Form`...`FormMessage` / `Loader2`) | L1-22 | 复用 import 清单 | +| Zod schema 定义 | L25-45 | 改写成 `appId` + `accessToken` 两字段 | +| Component props 类型 | L47-55 | 改写成 `{ open, onOpenChange }` | +| `useForm` 初始化 | L69-80 | `resolver: zodResolver(...)`、`defaultValues` | +| `defaultValues` 改变时 `form.reset()` | L83-94 | 本 phase 不需要外部 defaultValues,但需要 GET 后 reset,**模式相同**(用 useEffect + form.reset) | +| `handleOpenChange` + `form.reset()` | L103-113 | 关闭时清表单 | +| `handleSubmit` 异步 + setIsSubmitting | L115-125 | 复用 try/finally 形态 | +| Form / FormField / FormItem / FormLabel / FormControl / Input / FormMessage 嵌套 | L146-176 | 1:1 复用模式 | +| DialogFooter + 提交按钮 + Loader2 spinner | L264-282 | 复用 | + +**模板核心结构(精简后用于 plan 引用)**: + +```typescript +// from components/users/user-form-dialog.tsx L20-22, 69-80, 146-176 +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import * as z from "zod" + +const formSchema = z.object({ + appId: z.string().min(1, { message: "App ID 不能为空" }), + accessToken: z.string().min(1, { message: "请输入 Access Token" }), +}) + +const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { appId: "", accessToken: "" }, +}) + +// 在 GET 完成后: +form.reset({ appId: slot.appId, accessToken: "" }) + +// JSX: + + + ( + + App ID + + + + + + )} + /> + {/* ...accessToken field with placeholder={slot?.accessTokenMasked} */} + + +``` + +### shadcn Form wrapper 用法(`components/ui/form.tsx` 已存在,179 行) + +**Exported API**(L169-178): + +| 名称 | 来源 | 用途 | +|------|------|------| +| `Form` | re-export of `FormProvider` from `react-hook-form` | 包在 `` 外层,传 `{...form}` | +| `FormField` | 包装 RHF `Controller` + 注入 `FormFieldContext` | 等价于 `` | +| `FormItem` | div + `FormItemContext`(生成唯一 id) | 字段 wrapper(`space-y-2`) | +| `FormLabel` | 基于 `` + 自动 `htmlFor={formItemId}` + error 红色 | 字段 label | +| `FormControl` | Radix `Slot` + 自动注入 `id` / `aria-describedby` / `aria-invalid` | 包在 `` 外层 | +| `FormDescription` | `` 灰色小字 | 字段下方描述(如"如需更新请重新输入"可用) | +| `FormMessage` | `` 红色,自动显示 `error.message` | RHF 错误信息 | + +**用法范式(来自 `components/users/user-form-dialog.tsx` L149-161)**: + +```tsx + ( + + 用户名 + + + + + + )} +/> +``` + +### 本 phase 命名建议(kebab-case) + +仓库 `components/` 下业务子目录的文件**全部使用 kebab-case**: + +``` +components/songs/add-song-dialog.tsx +components/songs/song-detail-dialog.tsx +components/outfits/add-outfit-dialog.tsx +components/outfits/add-print-batch-dialog.tsx +components/users/user-form-dialog.tsx +components/permissions/role-dialog.tsx +components/dances/add-dance-dialog.tsx +components/achievements/add-achievement-dialog.tsx +components/food/add-food-dialog.tsx +``` + +**结论**:本 phase 文件命名 **`components/ai-model/credential-slot-dialog.tsx`(kebab-case)**,导出名 `CredentialSlotDialog`(PascalCase 默认或具名导出,沿用 `user-form-dialog.tsx` 的 `export function UserFormDialog` 写法)。 + +CONTEXT.md L32 写的 `CredentialSlotDialog.tsx` 是 PascalCase 文件名,本研究**强烈建议改为 kebab-case** 以保持仓库一致性。 + +## Architecture Patterns + +### System Architecture Diagram + +``` +┌──────────────────────────────────────────────────────────────────────┐ +│ User clicks "凭据槽位" Button (page.tsx L36-43) │ +└──────────────┬───────────────────────────────────────────────────────┘ + │ onClick → setIsCredentialDialogOpen(true) + ▼ +┌──────────────────────────────────────────────────────────────────────┐ +│ │ +│ ┌──────────────────────────────────────────────────────────────────┐ │ +│ │ useEffect on `open===true` │ │ +│ │ ↓ │ │ +│ │ getCredentialSlot() ◄── Phase 1 落地, lib/api/credential-slot.ts │ │ +│ │ ↓ axios GET /v1/admin/credential-slot/ │ │ +│ │ ↓ 适配 snake_case → camelCase │ │ +│ │ slot: CredentialSlot { appId, accessTokenMasked, updatedAt } │ │ +│ │ ↓ │ │ +│ │ form.reset({ appId: slot.appId, accessToken: '' }) │ │ +│ │ │ │ +│ └──────────────────────────────────────────────────────────────────┘ │ +│ │ +│ User edits → RHF state │ +│ User clicks "保存" → form.handleSubmit(onSubmit) │ +│ ↓ Zod 校验通过(accessToken min(1)) │ +│ ↓ │ +│ updateCredentialSlot({ appId, accessToken }) ◄── Phase 1 落地 │ +│ ↓ axios PUT /v1/admin/credential-slot/ │ +│ ├─ 成功 → toast.success("凭据槽位已更新") + onOpenChange(false) │ +│ └─ 失败 → handleApiError(e) → toast.error(message) │ +│ │ +└──────────────────────────────────────────────────────────────────────┘ + ▲ +┌─────────────────────────────┴────────────────────────────────────────┐ +│ from @/components/ui/sonner ◄─ MUST be mounted in │ +│ app/layout.tsx RootLayout (currently MISSING — gap) │ +└──────────────────────────────────────────────────────────────────────┘ +``` + +### Recommended Project Structure + +``` +components/ +└── ai-model/ # 新建目录(不存在) + └── credential-slot-dialog.tsx # 新建文件,~150 行 +app/ +├── layout.tsx # 修改:挂载 +└── ai-model/ + └── page.tsx # 修改:删 L473-485 占位 Dialog,import 新组件 +docs/ +└── 修改记录.md # 顶部追加 Phase 3 条目 +``` + +### Pattern 1:受控 Dialog + 内部 RHF 状态 + +**What**:Dialog 由 page 持有 `open` / `onOpenChange`,组件内部持有表单状态。 +**When to use**:本 phase 形态(page 级触发 + 子组件管理自身表单)。 +**Example**:见 `components/users/user-form-dialog.tsx` L57-66 props 形态(本 phase 简化为只有 `open` + `onOpenChange`)。 + +```typescript +// Source: components/users/user-form-dialog.tsx L65-67 +export function UserFormDialog({ open, onOpenChange, /* ... */ }) { + const [isSubmitting, setIsSubmitting] = useState(false) + const [dialogOpen, setDialogOpen] = useState(open || false) + // ... +} +``` + +### Pattern 2:`open` 变 true 时拉数据 + reset 表单 + +**What**:useEffect 监听 `open`,open 时 GET → form.reset(预填值)。 +**When to use**:本 phase 必须用(CONTEXT 锁定)。 +**Example**: + +```typescript +// Inspired by user-form-dialog.tsx L83-94 form.reset 模式 +const [slot, setSlot] = useState(null) +const [isLoading, setIsLoading] = useState(false) + +useEffect(() => { + if (!open) return + let cancelled = false + setIsLoading(true) + getCredentialSlot() + .then((data) => { + if (cancelled) return + setSlot(data) + form.reset({ appId: data.appId, accessToken: "" }) + }) + .catch((e) => { + if (cancelled) return + toast.error("加载失败", { description: handleApiError(e) }) + }) + .finally(() => { + if (!cancelled) setIsLoading(false) + }) + return () => { cancelled = true } +}, [open]) +``` + +### Pattern 3:Sonner 命令式 toast + +**What**:直接 `import { toast } from "sonner"` 调 `toast.success(...)` / `toast.error(...)`。 +**Why not `useToast`**:仓库 `hooks/use-toast.ts` 是 Radix Toast 实现,跟 Sonner 互不通信。Sonner 没有 hook 模式(命令式 API)。 +**Example**: + +```typescript +import { toast } from "sonner" + +toast.success("凭据槽位已更新", { description: "配置已生效" }) +toast.error("保存失败", { description: handleApiError(e) }) +``` + +### Anti-Patterns to Avoid + +- **使用 `hooks/use-toast.ts` 的 `useToast()` / `toast()`**:那是 Radix Toast 实现,跟 CONTEXT 锁定的 Sonner 不通信。 +- **从 `@/lib/api` barrel import `handleApiError`**:那是同名重复定义(`lib/api/index.ts:191`),CONTEXT 锁定从 `@/lib/api/error-handler` 引。 +- **PascalCase 文件名**(`CredentialSlotDialog.tsx`):仓库一致 kebab-case,应为 `credential-slot-dialog.tsx`。 +- **把 `slot.accessTokenMasked` 当 `defaultValues.accessToken`**:会回写脱敏掩码(CONTEXT.md 反复强调),永远默认空串。 +- **关闭对话框时不 reset 表单**:会泄漏上次输入的 access_token 到下次打开。 +- **submit 后不调 `onOpenChange(false)`**:dialog 不会关。 +- **失败时关闭对话框 / 清空表单**:违反 CONTEXT.md "失败时不关闭对话框、不清空表单值"。 + +## Don't Hand-Roll + +| 问题 | Don't Build | Use Instead | Why | +|---------|-------------|-------------|-----| +| 表单受控状态 / 字段联动 / 错误信息显示 | 自己写 useState + 手写校验 | RHF + Zod + shadcn Form wrapper | 现成且仓库已用 | +| 异步校验状态 / submitting 状态 | 自己写 try/finally + setIsSubmitting | RHF `formState.isSubmitting` 或 sibling `isSubmitting` useState(仓库 user-form-dialog 风格) | 模板已成熟 | +| Dialog open/close 动画、ESC、焦点陷阱、portal | 自己写 | shadcn `` (`components/ui/dialog.tsx`) | Radix 完整封装 | +| Label + input 关联(`htmlFor`) | 自己手写 id | `` + ``(自动注入 id) | shadcn Form wrapper 已实现 | +| 错误信息红色显示 | 自己写条件 className | `` | shadcn Form wrapper 已实现 | +| 错误码 → 中文消息映射 | 自己写 switch | `handleApiError(e)` from `lib/api/error-handler.ts` | CONTEXT 锁定 | +| Toast 队列 / 动画 / 主题 | 自己写 | Sonner `toast.success/error/info` | 已在 deps | +| API 调用 / token 注入 / 解包 | 自己写 axios | `getCredentialSlot()` / `updateCredentialSlot()` from `lib/api/credential-slot.ts` | Phase 1 落地 | + +**Key insight**:本 phase 几乎所有模式都已在仓库现成,主要工作是**拼装**而非新造。 + +## Common Pitfalls + +### Pitfall 1:Sonner Toaster 全局未挂载 + +**What goes wrong**:调 `toast.success(...)` 后没有任何视觉反馈,用户以为没保存。 +**Why it happens**:`app/layout.tsx`(20 行)没有渲染任何 Toaster 组件;`components/dashboard-shell.tsx` 也没有;全仓库 grep `` 内追加 ``**: + +```tsx +// app/layout.tsx +import { Toaster } from "@/components/ui/sonner" +// ... + + {children} + + +``` + +**Warning signs**:手动测试 toast 后 console 无 error 但屏幕无任何视觉变化 → Toaster 没挂载。 + +### Pitfall 2:误用 hooks/use-toast.ts(Radix Toast) + +**What goes wrong**:`import { useToast } from '@/hooks/use-toast'` 然后 `const { toast } = useToast(); toast({ title, description })` —— 这是 Radix Toast 实现,跟 Sonner 完全不通信,即使两个 Toaster 都挂载也是各管各的。 +**Why it happens**:`hooks/use-toast.ts` 与 `components/ui/use-toast.ts` 是 **shadcn 默认生成的 Radix Toast 模板**(看 L6-9 import 的是 `@/components/ui/toast`,是 Radix 实现);`components/ui/sonner.tsx` 是后来添加的 Sonner 包装,但仓库内**没有任何代码用过它**(grep 全仓 `from "@/components/ui/sonner"` 命中 0)。 +**How to avoid**:本 phase 严格 `import { toast } from "sonner"`(直接用 sonner npm 包),同时必须挂载 `` from `@/components/ui/sonner`。 + +### Pitfall 3:把脱敏掩码 `accessTokenMasked` 当 `defaultValues.accessToken` 回写 + +**What goes wrong**:用户没改 access_token,提交时把 `tk_***1234`(带 `*` 的脱敏字符串)当真值发给后端 PUT,后端按全字段覆写**清空真实 access_token**。 +**Why it happens**:直觉上 GET 返回什么就 reset 什么。 +**How to avoid**: +1. CONTEXT.md 已锁定**强制输入** access_token(Zod schema `accessToken: z.string().min(1)`)—— 用户每次都要重输; +2. `form.reset({ appId: slot.appId, accessToken: "" })` —— accessToken 永远默认空串; +3. `placeholder={slot?.accessTokenMasked}` —— 仅做视觉提示。 + +**Warning signs**:grep `defaultValues.*accessTokenMasked` 命中 → 错。grep `defaultValues.*accessToken: ""` 或 `accessToken: ''` → 对。 + +### Pitfall 4:`updated_at` 用 `new Date()` 在服务端渲染时序不一致 + +**What goes wrong**:Next.js App Router 默认在 server 渲染,`new Date(updatedAt).toLocaleString('zh-CN')` 在 server 与 client 时区可能不一致 → React hydration 警告。 +**Why it happens**:但本组件已 `"use client"`,且在 `useEffect` 后才有 slot 数据(mounted 守卫),这个 pitfall **本 phase 不适用**。但 plan-checker 应注意:组件首行必须 `"use client"`(user-form-dialog.tsx L1 是这么做的)。 +**How to avoid**:组件文件首行 `"use client"`;`updatedAt` 渲染时用 `slot && new Date(slot.updatedAt).toLocaleString('zh-CN')`。 + +### Pitfall 5:Dialog 关闭时不 reset 表单导致 access_token 残留 + +**What goes wrong**:用户输了 access_token 但点取消,下次打开 dialog 时 form 仍记着上次输入。 +**Why it happens**:RHF `useForm` 是组件级状态,dialog 即使关闭组件也没卸载(Dialog 用 portal,不卸载子组件)。 +**How to avoid**:参考 `user-form-dialog.tsx` L110-113,在 `handleOpenChange(false)` 时 `form.reset()`。 + +### Pitfall 6:useEffect open=true 拉数据时如果用户快速关再开 → race condition + +**What goes wrong**:第一次 open 触发的 GET 还没回来,用户已关闭再打开 —— 第二次 GET 也发出,两个 promise 竞态。 +**How to avoid**:useEffect cleanup 用 cancelled flag(见 Pattern 2 的 `let cancelled = false; return () => { cancelled = true }`)。 + +## Code Examples + +### 完整组件骨架(基于模板,简化适配本 phase) + +```typescript +// components/ai-model/credential-slot-dialog.tsx +"use client" + +import { useState, useEffect } from "react" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import * as z from "zod" +import { toast } from "sonner" +import { Loader2 } from "lucide-react" + +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { Input } from "@/components/ui/input" +import { + getCredentialSlot, + updateCredentialSlot, + type CredentialSlot, +} from "@/lib/api/credential-slot" +import { handleApiError } from "@/lib/api/error-handler" + +const formSchema = z.object({ + appId: z.string().min(1, { message: "App ID 不能为空" }), + accessToken: z.string().min(1, { message: "请输入 Access Token" }), +}) + +type FormValues = z.infer + +interface CredentialSlotDialogProps { + open: boolean + onOpenChange: (open: boolean) => void +} + +export function CredentialSlotDialog({ open, onOpenChange }: CredentialSlotDialogProps) { + const [slot, setSlot] = useState(null) + const [isLoading, setIsLoading] = useState(false) + const [isSubmitting, setIsSubmitting] = useState(false) + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { appId: "", accessToken: "" }, + }) + + useEffect(() => { + if (!open) return + let cancelled = false + setIsLoading(true) + getCredentialSlot() + .then((data) => { + if (cancelled) return + setSlot(data) + form.reset({ appId: data.appId, accessToken: "" }) + }) + .catch((e) => { + if (cancelled) return + toast.error("加载失败", { description: handleApiError(e) }) + }) + .finally(() => { + if (!cancelled) setIsLoading(false) + }) + return () => { + cancelled = true + } + }, [open, form]) + + const handleOpenChange = (next: boolean) => { + onOpenChange(next) + if (!next) { + form.reset({ appId: "", accessToken: "" }) + setSlot(null) + } + } + + const handleSubmit = async (values: FormValues) => { + setIsSubmitting(true) + try { + await updateCredentialSlot({ + appId: values.appId, + accessToken: values.accessToken, + }) + toast.success("凭据槽位已更新", { description: "配置已生效" }) + handleOpenChange(false) + } catch (e) { + toast.error("保存失败", { description: handleApiError(e) }) + // 失败时不关闭对话框、不清空表单值 + } finally { + setIsSubmitting(false) + } + } + + return ( + + + + 通用凭据槽位 + + 管理 APP ID 与 Access Token;提交将全字段覆写后端记录。 + + + {isLoading ? ( + + + + ) : ( + + + ( + + APP ID + + + + + + )} + /> + ( + + Access Token + + + + + 每次保存都需要重新输入 Access Token(不会显示原值,避免回写脱敏掩码) + + + + )} + /> + {slot && ( + + 最后更新:{new Date(slot.updatedAt).toLocaleString("zh-CN")} + + )} + + handleOpenChange(false)} + disabled={isSubmitting} + > + 取消 + + + {isSubmitting ? ( + <> + + 保存中... + > + ) : ( + "保存" + )} + + + + + )} + + + ) +} +``` + +### `app/ai-model/page.tsx` 改动定位 + +**Phase 2 占位 Dialog 当前位置**: + +| 项 | 行号 | 内容 | +|----|------|------| +| 占位 Dialog 起始 | L473 | `` | +| `DialogContent` | L477 | | +| `DialogTitle` | L479 | `通用凭据槽位` | +| `DialogDescription` | L480-482 | `对话框真实内容由 Phase 3 落地` | +| 占位 Dialog 结束 | L485 | `` | + +**改动 1**:删除 L473-485(13 行)。 +**改动 2**:替换为 ``。 +**改动 3**:删除 L9-15 的 Dialog 命名导入(`Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle`)—— 不再使用。 +**改动 4**:在 import 块(L1-17 区域)新增: + +```tsx +import { CredentialSlotDialog } from "@/components/ai-model/credential-slot-dialog" +``` + +**保留**: +- L1 `"use client"` —— 不动 +- L20 `useState/useEffect` —— 不动(mounted state 仍需要) +- L21 `isCredentialDialogOpen` state —— 不动 +- L23-25 mounted useEffect —— 不动 +- L16 `KeyRound` 已在 lucide-react import —— 仍在用(Button 图标),不动 +- L35-43 凭据槽位 Button —— 不动 + +**Loader2 import**:是否需要 page.tsx 加? +答:**不需要**。Loader2 仅在新组件 `credential-slot-dialog.tsx` 内使用,page.tsx 不会用到。 + +## State of the Art + +| Old Approach | Current Approach | When Changed | Impact | +|--------------|------------------|--------------|--------| +| 仓库内 useState + 手写校验(`add-song-dialog.tsx` / `add-outfit-dialog.tsx`) | RHF + Zod + shadcn Form wrapper(`user-form-dialog.tsx` / `role-dialog.tsx`) | 仓库内并存(前者更早,后者后期) | 本 phase 用后者(CONTEXT 锁定) | +| Radix Toast (`hooks/use-toast.ts`) | Sonner(`components/ui/sonner.tsx`) | 仓库内并存(Sonner 后加但未使用) | 本 phase 用 Sonner(CONTEXT 锁定) + 必须先挂载 `` | + +**Deprecated/outdated**: +- `hooks/use-toast.ts` + `components/ui/use-toast.ts`:两份完全相同的 Radix Toast 实现(295 行 dead code,本 phase 不动它们但应该在未来 milestone 收敛)。 +- `components/ui/toaster.tsx`:Radix Toast 渲染容器,**也未挂载**,全仓 grep ` 本 phase 是新建组件 + 修改一行 import,**非** rename / refactor / migration。Skip 该 section 大部分内容。 + +| Category | Items Found | Action Required | +|----------|-------------|------------------| +| Stored data | None — 凭据槽位本身的存储是后端 Phase 1 已落地的 DB 单行,前端不持久化任何东西 | None | +| Live service config | None | None | +| OS-registered state | None | None | +| Secrets/env vars | `NEXT_PUBLIC_API_BASE_URL` 仍是凭据 API 的 base URL,本 phase 不改 | None | +| Build artifacts | None — 新增组件文件 + 修改 page.tsx,TS 编译产物会自动更新 | `npx tsc --noEmit` 验证 | + +## Project Constraints (from CLAUDE.md) + +| Constraint | Source | Apply to Phase 3 | +|-----------|--------|--------| +| 中文沟通 / 注释 / commit message / 最终回答 | `CLAUDE.md L98` | 表单文案、toast 文案、注释、commit message 全部中文(已锁定) | +| 不混用包管理器(package-lock.json + pnpm-lock.yaml + yarn.lock 并存) | `CLAUDE.md L99` | 不动 lockfile;不引入新依赖 | +| shadcn 组件可直接修改源码 | `CLAUDE.md L100` | 本 phase 不需要修改 shadcn 组件(form / dialog / input 都已成熟) | +| 修改后**必须**在同一会话追加 `docs/修改记录.md` 顶部 | `CLAUDE.md L72-82` | plan 必须包含"在 docs/修改记录.md 顶部追加 Phase 3 条目"task | +| 跨项目联动需在两端各写一条修改记录互相引用 | `CLAUDE.md L84-88` | 本 phase 是纯前端,**不**触发跨项目联动(CONTEXT.md 已说明端到端测试依赖后端 Phase 2,但前端代码层不阻塞);修改记录条目「跨项目联动」段写「无 / 待评估」 | +| API 契约改动需双端同步 | `CLAUDE.md L65-68` | 本 phase 不改 API 契约(消费 Phase 1 已落地的 contract) | +| 业务代码 / 配置 / package.json 必须记录 | `CLAUDE.md L92-94` | 修改 `app/ai-model/page.tsx` + 新增 `components/ai-model/credential-slot-dialog.tsx` + 修改 `app/layout.tsx` 都必须记 | + +## Environment Availability + +> 本 phase 纯代码改动 + 类型检查,无需外部工具/服务依赖(除 Node.js + npm,已就绪)。 + +| Dependency | Required By | Available | Version | Fallback | +|------------|------------|-----------|---------|----------| +| Node.js + npm | npm install / npx tsc | ✓(仓库已运行 Phase 1 + 2) | — | — | +| TypeScript | `npx tsc --noEmit` 验证 | ✓ | `^5`(package.json devDep) | — | +| react-hook-form | dialog 组件 | ✓ | `latest` | — | +| @hookform/resolvers | zodResolver | ✓ | `latest` | — | +| zod | schema | ✓ | `latest` | — | +| sonner | toast | ✓ | `^1.7.1` | — | +| lucide-react | Loader2 / KeyRound | ✓ | `^0.454.0` | — | +| @radix-ui/react-dialog | Dialog | ✓ | `^1.1.4` | — | +| 后端 qy_lty Phase 2(GET/PUT `/api/v1/admin/credential-slot/`) | 端到端联调 | 🟡 看后端 milestone 状态 | — | 用 mock 或推迟到联调阶段;前端代码层 npx tsc + 单元 grep 验证不阻塞 | + +**Missing dependencies with no fallback**:无。 +**Missing dependencies with fallback**:后端 Phase 2 联调 —— 前端代码层用程序化验证(npx tsc + grep)通过即可,端到端测试推迟。 + +## Security Domain + +> Phase 3 涉及凭据 (Access Token) 编辑表单 —— 必须考虑前端 ASVS。 + +### Applicable ASVS Categories + +| ASVS Category | Applies | Standard Control | +|---------------|---------|-----------------| +| V2 Authentication | yes | Phase 2 已落地 RBAC(`hasPermission('credential-slot')`),但 CONCERNS.md 提示后端独立校验闭环 PERM-06 仍待审计 | +| V3 Session Management | no(本 phase 不动 token 存储) | — | +| V4 Access Control | yes | 入口 Button 受 `hasPermission` 收敛,未授权账户 DOM 中不渲染(Phase 2 已落地)—— 但客户端校验仅 UI 礼貌,真实安全靠后端 | +| V5 Input Validation | yes | Zod schema:`appId.min(1)` + `accessToken.min(1)` —— 仅做"非空"硬要求;其他格式(如 token 长度上下限)后端校验 | +| V6 Cryptography | no(本 phase 不加密 / 不哈希) | — | +| V8 Data Protection | yes | access_token 输入不加 `type="password"`(CONTEXT 选择,运营要看自己输入);不打 console.log;不把真值存 localStorage / sessionStorage / cookie | +| V14 Configuration | no | — | + +### Known Threat Patterns for 本 stack + +| Pattern | STRIDE | Standard Mitigation | +|---------|--------|---------------------| +| 把脱敏掩码当真值回写 | Tampering(污染数据) | CONTEXT 锁定 `accessToken.min(1)` 强制重输;defaultValues.accessToken 永远空串;placeholder 仅做视觉提示 | +| Toast / 表单字段无意中泄露 access_token 真值 | Information Disclosure | toast 描述只写"配置已生效" / 错误用 `handleApiError` 中文映射,**不要** `JSON.stringify(payload)` 进 toast 或 console | +| RBAC 仅前端校验导致绕过 | Elevation of Privilege | CONCERNS.md 已记 PERM-06 候选 milestone(后端独立校验)—— 不在本 phase 范围 | +| API 401 自动跳登录 | Repudiation / Authentication | Phase 0 落地的 axios response interceptor 已处理(清空 token + 重定向 `/login`),本 phase 沿用,无需新增 | +| 表单 XSS(用户在 access_token 输了 `
` 灰色小字 | 字段下方描述(如"如需更新请重新输入"可用) | +| `FormMessage` | `
` 红色,自动显示 `error.message` | RHF 错误信息 | + +**用法范式(来自 `components/users/user-form-dialog.tsx` L149-161)**: + +```tsx + ( + + 用户名 + + + + + + )} +/> +``` + +### 本 phase 命名建议(kebab-case) + +仓库 `components/` 下业务子目录的文件**全部使用 kebab-case**: + +``` +components/songs/add-song-dialog.tsx +components/songs/song-detail-dialog.tsx +components/outfits/add-outfit-dialog.tsx +components/outfits/add-print-batch-dialog.tsx +components/users/user-form-dialog.tsx +components/permissions/role-dialog.tsx +components/dances/add-dance-dialog.tsx +components/achievements/add-achievement-dialog.tsx +components/food/add-food-dialog.tsx +``` + +**结论**:本 phase 文件命名 **`components/ai-model/credential-slot-dialog.tsx`(kebab-case)**,导出名 `CredentialSlotDialog`(PascalCase 默认或具名导出,沿用 `user-form-dialog.tsx` 的 `export function UserFormDialog` 写法)。 + +CONTEXT.md L32 写的 `CredentialSlotDialog.tsx` 是 PascalCase 文件名,本研究**强烈建议改为 kebab-case** 以保持仓库一致性。 + +## Architecture Patterns + +### System Architecture Diagram + +``` +┌──────────────────────────────────────────────────────────────────────┐ +│ User clicks "凭据槽位" Button (page.tsx L36-43) │ +└──────────────┬───────────────────────────────────────────────────────┘ + │ onClick → setIsCredentialDialogOpen(true) + ▼ +┌──────────────────────────────────────────────────────────────────────┐ +│ │ +│ ┌──────────────────────────────────────────────────────────────────┐ │ +│ │ useEffect on `open===true` │ │ +│ │ ↓ │ │ +│ │ getCredentialSlot() ◄── Phase 1 落地, lib/api/credential-slot.ts │ │ +│ │ ↓ axios GET /v1/admin/credential-slot/ │ │ +│ │ ↓ 适配 snake_case → camelCase │ │ +│ │ slot: CredentialSlot { appId, accessTokenMasked, updatedAt } │ │ +│ │ ↓ │ │ +│ │ form.reset({ appId: slot.appId, accessToken: '' }) │ │ +│ │ │ │ +│ └──────────────────────────────────────────────────────────────────┘ │ +│ │ +│ User edits → RHF state │ +│ User clicks "保存" → form.handleSubmit(onSubmit) │ +│ ↓ Zod 校验通过(accessToken min(1)) │ +│ ↓ │ +│ updateCredentialSlot({ appId, accessToken }) ◄── Phase 1 落地 │ +│ ↓ axios PUT /v1/admin/credential-slot/ │ +│ ├─ 成功 → toast.success("凭据槽位已更新") + onOpenChange(false) │ +│ └─ 失败 → handleApiError(e) → toast.error(message) │ +│ │ +└──────────────────────────────────────────────────────────────────────┘ + ▲ +┌─────────────────────────────┴────────────────────────────────────────┐ +│ from @/components/ui/sonner ◄─ MUST be mounted in │ +│ app/layout.tsx RootLayout (currently MISSING — gap) │ +└──────────────────────────────────────────────────────────────────────┘ +``` + +### Recommended Project Structure + +``` +components/ +└── ai-model/ # 新建目录(不存在) + └── credential-slot-dialog.tsx # 新建文件,~150 行 +app/ +├── layout.tsx # 修改:挂载 +└── ai-model/ + └── page.tsx # 修改:删 L473-485 占位 Dialog,import 新组件 +docs/ +└── 修改记录.md # 顶部追加 Phase 3 条目 +``` + +### Pattern 1:受控 Dialog + 内部 RHF 状态 + +**What**:Dialog 由 page 持有 `open` / `onOpenChange`,组件内部持有表单状态。 +**When to use**:本 phase 形态(page 级触发 + 子组件管理自身表单)。 +**Example**:见 `components/users/user-form-dialog.tsx` L57-66 props 形态(本 phase 简化为只有 `open` + `onOpenChange`)。 + +```typescript +// Source: components/users/user-form-dialog.tsx L65-67 +export function UserFormDialog({ open, onOpenChange, /* ... */ }) { + const [isSubmitting, setIsSubmitting] = useState(false) + const [dialogOpen, setDialogOpen] = useState(open || false) + // ... +} +``` + +### Pattern 2:`open` 变 true 时拉数据 + reset 表单 + +**What**:useEffect 监听 `open`,open 时 GET → form.reset(预填值)。 +**When to use**:本 phase 必须用(CONTEXT 锁定)。 +**Example**: + +```typescript +// Inspired by user-form-dialog.tsx L83-94 form.reset 模式 +const [slot, setSlot] = useState(null) +const [isLoading, setIsLoading] = useState(false) + +useEffect(() => { + if (!open) return + let cancelled = false + setIsLoading(true) + getCredentialSlot() + .then((data) => { + if (cancelled) return + setSlot(data) + form.reset({ appId: data.appId, accessToken: "" }) + }) + .catch((e) => { + if (cancelled) return + toast.error("加载失败", { description: handleApiError(e) }) + }) + .finally(() => { + if (!cancelled) setIsLoading(false) + }) + return () => { cancelled = true } +}, [open]) +``` + +### Pattern 3:Sonner 命令式 toast + +**What**:直接 `import { toast } from "sonner"` 调 `toast.success(...)` / `toast.error(...)`。 +**Why not `useToast`**:仓库 `hooks/use-toast.ts` 是 Radix Toast 实现,跟 Sonner 互不通信。Sonner 没有 hook 模式(命令式 API)。 +**Example**: + +```typescript +import { toast } from "sonner" + +toast.success("凭据槽位已更新", { description: "配置已生效" }) +toast.error("保存失败", { description: handleApiError(e) }) +``` + +### Anti-Patterns to Avoid + +- **使用 `hooks/use-toast.ts` 的 `useToast()` / `toast()`**:那是 Radix Toast 实现,跟 CONTEXT 锁定的 Sonner 不通信。 +- **从 `@/lib/api` barrel import `handleApiError`**:那是同名重复定义(`lib/api/index.ts:191`),CONTEXT 锁定从 `@/lib/api/error-handler` 引。 +- **PascalCase 文件名**(`CredentialSlotDialog.tsx`):仓库一致 kebab-case,应为 `credential-slot-dialog.tsx`。 +- **把 `slot.accessTokenMasked` 当 `defaultValues.accessToken`**:会回写脱敏掩码(CONTEXT.md 反复强调),永远默认空串。 +- **关闭对话框时不 reset 表单**:会泄漏上次输入的 access_token 到下次打开。 +- **submit 后不调 `onOpenChange(false)`**:dialog 不会关。 +- **失败时关闭对话框 / 清空表单**:违反 CONTEXT.md "失败时不关闭对话框、不清空表单值"。 + +## Don't Hand-Roll + +| 问题 | Don't Build | Use Instead | Why | +|---------|-------------|-------------|-----| +| 表单受控状态 / 字段联动 / 错误信息显示 | 自己写 useState + 手写校验 | RHF + Zod + shadcn Form wrapper | 现成且仓库已用 | +| 异步校验状态 / submitting 状态 | 自己写 try/finally + setIsSubmitting | RHF `formState.isSubmitting` 或 sibling `isSubmitting` useState(仓库 user-form-dialog 风格) | 模板已成熟 | +| Dialog open/close 动画、ESC、焦点陷阱、portal | 自己写 | shadcn `` (`components/ui/dialog.tsx`) | Radix 完整封装 | +| Label + input 关联(`htmlFor`) | 自己手写 id | `` + ``(自动注入 id) | shadcn Form wrapper 已实现 | +| 错误信息红色显示 | 自己写条件 className | `` | shadcn Form wrapper 已实现 | +| 错误码 → 中文消息映射 | 自己写 switch | `handleApiError(e)` from `lib/api/error-handler.ts` | CONTEXT 锁定 | +| Toast 队列 / 动画 / 主题 | 自己写 | Sonner `toast.success/error/info` | 已在 deps | +| API 调用 / token 注入 / 解包 | 自己写 axios | `getCredentialSlot()` / `updateCredentialSlot()` from `lib/api/credential-slot.ts` | Phase 1 落地 | + +**Key insight**:本 phase 几乎所有模式都已在仓库现成,主要工作是**拼装**而非新造。 + +## Common Pitfalls + +### Pitfall 1:Sonner Toaster 全局未挂载 + +**What goes wrong**:调 `toast.success(...)` 后没有任何视觉反馈,用户以为没保存。 +**Why it happens**:`app/layout.tsx`(20 行)没有渲染任何 Toaster 组件;`components/dashboard-shell.tsx` 也没有;全仓库 grep `` 内追加 ``**: + +```tsx +// app/layout.tsx +import { Toaster } from "@/components/ui/sonner" +// ... + + {children} + + +``` + +**Warning signs**:手动测试 toast 后 console 无 error 但屏幕无任何视觉变化 → Toaster 没挂载。 + +### Pitfall 2:误用 hooks/use-toast.ts(Radix Toast) + +**What goes wrong**:`import { useToast } from '@/hooks/use-toast'` 然后 `const { toast } = useToast(); toast({ title, description })` —— 这是 Radix Toast 实现,跟 Sonner 完全不通信,即使两个 Toaster 都挂载也是各管各的。 +**Why it happens**:`hooks/use-toast.ts` 与 `components/ui/use-toast.ts` 是 **shadcn 默认生成的 Radix Toast 模板**(看 L6-9 import 的是 `@/components/ui/toast`,是 Radix 实现);`components/ui/sonner.tsx` 是后来添加的 Sonner 包装,但仓库内**没有任何代码用过它**(grep 全仓 `from "@/components/ui/sonner"` 命中 0)。 +**How to avoid**:本 phase 严格 `import { toast } from "sonner"`(直接用 sonner npm 包),同时必须挂载 `` from `@/components/ui/sonner`。 + +### Pitfall 3:把脱敏掩码 `accessTokenMasked` 当 `defaultValues.accessToken` 回写 + +**What goes wrong**:用户没改 access_token,提交时把 `tk_***1234`(带 `*` 的脱敏字符串)当真值发给后端 PUT,后端按全字段覆写**清空真实 access_token**。 +**Why it happens**:直觉上 GET 返回什么就 reset 什么。 +**How to avoid**: +1. CONTEXT.md 已锁定**强制输入** access_token(Zod schema `accessToken: z.string().min(1)`)—— 用户每次都要重输; +2. `form.reset({ appId: slot.appId, accessToken: "" })` —— accessToken 永远默认空串; +3. `placeholder={slot?.accessTokenMasked}` —— 仅做视觉提示。 + +**Warning signs**:grep `defaultValues.*accessTokenMasked` 命中 → 错。grep `defaultValues.*accessToken: ""` 或 `accessToken: ''` → 对。 + +### Pitfall 4:`updated_at` 用 `new Date()` 在服务端渲染时序不一致 + +**What goes wrong**:Next.js App Router 默认在 server 渲染,`new Date(updatedAt).toLocaleString('zh-CN')` 在 server 与 client 时区可能不一致 → React hydration 警告。 +**Why it happens**:但本组件已 `"use client"`,且在 `useEffect` 后才有 slot 数据(mounted 守卫),这个 pitfall **本 phase 不适用**。但 plan-checker 应注意:组件首行必须 `"use client"`(user-form-dialog.tsx L1 是这么做的)。 +**How to avoid**:组件文件首行 `"use client"`;`updatedAt` 渲染时用 `slot && new Date(slot.updatedAt).toLocaleString('zh-CN')`。 + +### Pitfall 5:Dialog 关闭时不 reset 表单导致 access_token 残留 + +**What goes wrong**:用户输了 access_token 但点取消,下次打开 dialog 时 form 仍记着上次输入。 +**Why it happens**:RHF `useForm` 是组件级状态,dialog 即使关闭组件也没卸载(Dialog 用 portal,不卸载子组件)。 +**How to avoid**:参考 `user-form-dialog.tsx` L110-113,在 `handleOpenChange(false)` 时 `form.reset()`。 + +### Pitfall 6:useEffect open=true 拉数据时如果用户快速关再开 → race condition + +**What goes wrong**:第一次 open 触发的 GET 还没回来,用户已关闭再打开 —— 第二次 GET 也发出,两个 promise 竞态。 +**How to avoid**:useEffect cleanup 用 cancelled flag(见 Pattern 2 的 `let cancelled = false; return () => { cancelled = true }`)。 + +## Code Examples + +### 完整组件骨架(基于模板,简化适配本 phase) + +```typescript +// components/ai-model/credential-slot-dialog.tsx +"use client" + +import { useState, useEffect } from "react" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import * as z from "zod" +import { toast } from "sonner" +import { Loader2 } from "lucide-react" + +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { Input } from "@/components/ui/input" +import { + getCredentialSlot, + updateCredentialSlot, + type CredentialSlot, +} from "@/lib/api/credential-slot" +import { handleApiError } from "@/lib/api/error-handler" + +const formSchema = z.object({ + appId: z.string().min(1, { message: "App ID 不能为空" }), + accessToken: z.string().min(1, { message: "请输入 Access Token" }), +}) + +type FormValues = z.infer + +interface CredentialSlotDialogProps { + open: boolean + onOpenChange: (open: boolean) => void +} + +export function CredentialSlotDialog({ open, onOpenChange }: CredentialSlotDialogProps) { + const [slot, setSlot] = useState(null) + const [isLoading, setIsLoading] = useState(false) + const [isSubmitting, setIsSubmitting] = useState(false) + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { appId: "", accessToken: "" }, + }) + + useEffect(() => { + if (!open) return + let cancelled = false + setIsLoading(true) + getCredentialSlot() + .then((data) => { + if (cancelled) return + setSlot(data) + form.reset({ appId: data.appId, accessToken: "" }) + }) + .catch((e) => { + if (cancelled) return + toast.error("加载失败", { description: handleApiError(e) }) + }) + .finally(() => { + if (!cancelled) setIsLoading(false) + }) + return () => { + cancelled = true + } + }, [open, form]) + + const handleOpenChange = (next: boolean) => { + onOpenChange(next) + if (!next) { + form.reset({ appId: "", accessToken: "" }) + setSlot(null) + } + } + + const handleSubmit = async (values: FormValues) => { + setIsSubmitting(true) + try { + await updateCredentialSlot({ + appId: values.appId, + accessToken: values.accessToken, + }) + toast.success("凭据槽位已更新", { description: "配置已生效" }) + handleOpenChange(false) + } catch (e) { + toast.error("保存失败", { description: handleApiError(e) }) + // 失败时不关闭对话框、不清空表单值 + } finally { + setIsSubmitting(false) + } + } + + return ( + + + + 通用凭据槽位 + + 管理 APP ID 与 Access Token;提交将全字段覆写后端记录。 + + + {isLoading ? ( + + + + ) : ( + + + ( + + APP ID + + + + + + )} + /> + ( + + Access Token + + + + + 每次保存都需要重新输入 Access Token(不会显示原值,避免回写脱敏掩码) + + + + )} + /> + {slot && ( + + 最后更新:{new Date(slot.updatedAt).toLocaleString("zh-CN")} + + )} + + handleOpenChange(false)} + disabled={isSubmitting} + > + 取消 + + + {isSubmitting ? ( + <> + + 保存中... + > + ) : ( + "保存" + )} + + + +
+ 最后更新:{new Date(slot.updatedAt).toLocaleString("zh-CN")} +