From c21a16af5c3f61a1127b1ecb38446b9d4dbb215d Mon Sep 17 00:00:00 2001 From: pmc <740076875@qq.com> Date: Fri, 8 May 2026 12:12:33 +0800 Subject: [PATCH] =?UTF-8?q?docs(phase-3):=20=E8=B0=83=E7=A0=94=20Phase=203?= =?UTF-8?q?=20=E7=BC=96=E8=BE=91=E5=AF=B9=E8=AF=9D=E6=A1=86=20+=20?= =?UTF-8?q?=E6=8F=90=E4=BA=A4=E5=8F=8D=E9=A6=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../phases/03-dialog-feedback/03-RESEARCH.md | 845 ++++++++++++++++++ 1 file changed, 845 insertions(+) create mode 100644 qy-lty-admin/.planning/phases/03-dialog-feedback/03-RESEARCH.md 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` | 基于 `