# Phase 3:编辑对话框 + 提交反馈 - Context **Gathered**: 2026-05-08 **Status**: Ready for planning(用户选择 `--skip-ui` 跳过 UI-SPEC,直接规划;与 Phase 2 同模式) **Source**: 用户在 `/gsd-plan-phase 3` 调用时提供的内联约束 ## Phase 边界 本 phase 是 Milestone v1.0 前端集成的**收尾 phase**,把 Phase 2 落地的占位 Dialog 替换为完整功能的编辑对话框 + Sonner toast 反馈: - 抽离 `components/ai-model/CredentialSlotDialog.tsx` 独立组件 - 表单 React Hook Form + Zod;预填态从 `getCredentialSlot()` 拉取 - 关键业务规则:**留空保留旧值** —— 用户不输入 access_token 时不提交它,避免把脱敏掩码当真值回写 - 仅提交用户实际改动的字段(部分载荷) - 成功 / 失败 toast 反馈 **不负责**(推迟到下一周期): - 真实生产部署 / 端到端浏览器测试(项目无 E2E 框架) - token 轮换 / refresh token - DB at-rest 加密 - 其他 brownfield 候选优先级(PERM-06 后端独立校验闭环 / 多 lockfile 收敛 / Vitest 测试基础设施 等) **Milestone v1.0 收尾**:本 phase 完成后 Milestone v1.0 全部 11 个需求(CRED-01~06 后端 + CRED-FE-01~05 前端)100% 交付。 ## 实现决策(锁定) ### 组件抽离 - **新建**:`components/ai-model/credential-slot-dialog.tsx`(researcher 修正:仓库 9 个现有业务对话框全部 **kebab-case**,如 `add-song-dialog.tsx` / `user-form-dialog.tsx`,本 phase 跟规约) - 该路径目录 `components/ai-model/` **确认不存在**(researcher 实测 `ls` 退出码 2),需要 mkdir - 沿用 shadcn 组件风格 + RHF + Zod;1:1 模板首选 `components/users/user-form-dialog.tsx` L1-289(最贴近本 phase 形态:单 dialog + 几个字段 + RHF + Zod + Form wrapper + Loader2 spinner + 提交后关闭) - **修改**:`app/ai-model/page.tsx` - 删除 Phase 2 落地的占位 Dialog(约第 473-485 行的内联 Dialog) - 用 `` 替换 - 保留 Button 入口控件 + mounted 守卫(Phase 2 已落地) ### Dialog 组件接口(CredentialSlotDialog.tsx) ```typescript interface CredentialSlotDialogProps { open: boolean onOpenChange: (open: boolean) => void } ``` 简洁接口;状态由 page 持有,组件只受控渲染 + 自身的表单状态管理。 ### 表单技术栈(已在 deps) - **React Hook Form**:`useForm({ resolver: zodResolver(schema), defaultValues: { appId: '', accessToken: '' } })` - **Zod**:定义 schema,**条件校验**(核心): ```typescript const schema = z.object({ appId: z.string(), // 默认允许任何值(包括空,因为预填会有原值) accessToken: z.string(), // 默认允许空("留空保留旧值"语义) }).refine( (data, ctx) => { // 一旦用户在 access_token 输入了内容,要求非纯空白 if (data.accessToken !== '' && data.accessToken.trim() === '') { return false } // 一旦用户清空了 app_id(与原值不同),要求非空 // 这条在 form-level 校验中处理(不在 schema 内),见 submit 段 return true }, { message: 'Access Token 不能仅含空白字符' } ) ``` - **Resolver**:`@hookform/resolvers/zod`(已在 deps) ### 预填态(关键 UX 规则) 打开 Dialog 时(`useEffect(() => { if (open) loadData() }, [open])`): 1. 调 `getCredentialSlot()` 拉取后端数据 2. 把 `slot.appId` 作为 `appId` field 默认值(明文显示) 3. **`accessToken` field 默认值是空字符串**(**不是** `slot.accessTokenMasked`!) 4. `accessTokenMasked` 仅作为 input 的 **placeholder**:`` 5. input 下方一行小字提示:「如需更新请重新输入,留空保留旧值」 6. `updated_at` 只读显示(如 `

最后更新:{slot.updatedAt}

`) **TypeScript 类型层面屏障**:表单值类型用 `Partial`(仅 `accessToken` + `appId` 两个 camelCase 字段,**不含** `accessTokenMasked`)—— 编译期切断"把脱敏字符串当真值回写"的 bug。 ### 提交逻辑(核心业务规则) ```typescript const onSubmit = async (values: { appId: string; accessToken: string }) => { const payload: Partial = {} // 仅当用户实际改动了 appId 才提交 if (values.appId !== '' && values.appId !== originalSlot.appId) { payload.appId = values.appId } // 仅当用户实际输入了 accessToken(非空字符串)才提交 if (values.accessToken !== '') { payload.accessToken = values.accessToken } // 两个字段都没改 → 校验失败提示,不调 API if (Object.keys(payload).length === 0) { toast({ title: '没有改动', description: '请修改至少一个字段后再保存' }) return } try { await updateCredentialSlot(payload as CredentialSlotUpdatePayload) toast({ title: '凭据槽位已更新', description: '配置已生效' }) onOpenChange(false) // 关闭对话框 // page 监听 onOpenChange 后下次重新打开会自动 loadData,无需主动通知 } catch (e) { const message = handleApiError(e) // lib/api/error-handler.ts toast({ title: '保存失败', description: message, variant: 'destructive' }) // 对话框保持打开 + 表单字段保留 } } ``` **关键**:`updateCredentialSlot(payload as CredentialSlotUpdatePayload)` 这里有个类型断言。Phase 1 落地的 `updateCredentialSlot` 期待**完整** `CredentialSlotUpdatePayload`(两个字段都必填)。本 phase 引入"部分载荷"语义。两条路径选其一: - **路径 A**(推荐,最低侵入):调用方传部分载荷 + 类型断言;后端按 PUT 覆写语义,缺失字段会导致后端清空 —— 这是问题 - **路径 B**:表单提交前用预填值补齐缺失字段。例如: ```typescript const finalPayload: CredentialSlotUpdatePayload = { appId: payload.appId ?? originalSlot.appId, accessToken: payload.accessToken ?? '', } ``` 但 `` 后端不识别,等于明文回写,**错误** **最简正确路径**(planner 锁定): - **后端 PUT 是全字段覆写**(GSD 后端 Phase 2 已锁),所以前端必须**总是**提供两个字段 - access_token 缺失场景**用预填的 accessTokenMasked 重新 GET 拿真值** —— **这违反"留空保留旧值"语义** ❌ **真正正确路径(planner 必须按这个)**: - 给 `updateCredentialSlot` 增加重载或新建一个 helper:当用户没改 access_token 时,前端**先 GET 再 PUT**:调 `getCredentialSlot()` 拿到当前 access_token 的真值(**但**返回的是脱敏掩码!) - 这条路也走不通,因为前端永远拿不到真值 **最终决策(planner 严格遵守,CONTEXT 锁定)**: - 后端 PUT 必须全字段提交 → 用户没改 access_token 时**前端用 `accessTokenMasked` 字符串本身回填给 PUT** - 但这等于回写脱敏掩码 —— **必须由后端识别"PUT body 中 access_token 是脱敏掩码格式(即仅末 4 位明文 + 前面全 `*`)则保留旧值"** - ⚠️ **Phase 3 不能完成"留空保留旧值"语义** —— 这需要后端配合改一行:`if access_token == mask_token(current.access_token): preserve old` - 该 bug 在 Phase 3 中暴露后,需要**回头给后端开 Phase 4**(或一个 patch milestone)补上"识别脱敏掩码并保留旧值"的逻辑 **简化处理(planner 落地)**: - Phase 3 表单 access_token 字段**强制要求填写**(去掉"留空保留旧值"语义) - Zod schema:`accessToken: z.string().min(1, '请输入 Access Token')` - 这样退化为"每次保存都要重输 access_token",UX 略差但语义正确(不会回写脱敏掩码) - 在 `docs/修改记录.md` 顶部 Phase 3 条目的「修改原因」段**显式说明**这个权衡 + 候选下一步是给后端加"识别脱敏掩码保留旧值"逻辑 **Planner 重要:必须按"强制输入"路线落地,不要尝试实现"留空保留旧值"**(除非 plan-checker 一轮里我明确改主意)。 ### Toast 通知(researcher 修正:3 个关键纠偏) **纠偏 1 — Sonner Toaster 全局未挂载(关键 pre-existing bug)**: - `app/layout.tsx` 是 20 行裸 RootLayout,没有 `` - 现有 9 处 `toast(...)` 调用其实**全是 dead code**(toast 不会显示) - **本 phase 必须前置一个任务**:在 `app/layout.tsx` `` 末尾挂载 `` from `@/components/ui/sonner` - 否则 Phase 3 的成功 / 失败反馈完全静默 **纠偏 2 — 双 `useToast` 实现都是 Radix Toast,与 Sonner 不通**: - `hooks/use-toast.ts` + `components/ui/use-toast.ts` 是两份内容相同的 Radix Toast 实现(295 行 dead code) - 不要走 `useToast` hook - **直接** `import { toast } from "sonner"` 命令式调用:`toast.success(...)` / `toast.error(...)` **纠偏 3 — 双 `handleApiError` 函数**: - `lib/api/error-handler.ts:38` `(error: unknown): string` ← 用这个 - `lib/api/index.ts:191` `(error: any): string` ← 同名重复定义,**不要**从 barrel import - 显式 import:`import { handleApiError } from '@/lib/api/error-handler'` **Toast 调用形态**: ```typescript import { toast } from "sonner" import { handleApiError } from "@/lib/api/error-handler" // 成功 toast.success("凭据槽位已更新", { description: "配置已生效" }) // 失败 toast.error("保存失败", { description: handleApiError(e) }) ``` ### 错误处理 - 复用 `lib/api/error-handler.ts:handleApiError`(researcher 必须 read 该函数确认签名 + 返回值类型) - 失败时**不**关闭对话框,**不**清空表单值 ### `app/ai-model/page.tsx` 改动 - import `CredentialSlotDialog` from `@/components/ai-model/credential-slot-dialog` - 删除 Phase 2 占位 Dialog(约 13 行 JSX) - 替换为: ```tsx ``` - 保留 Button 入口 + mounted 守卫 ### Claude's Discretion - 文件命名 `CredentialSlotDialog.tsx`(PascalCase)vs `credential-slot-dialog.tsx`(kebab-case) —— planner 看仓库现有 components/ 子目录约定 - Cancel 按钮文案:"取消" / "关闭" - 提交按钮 loading 态文案:"保存中..." / "处理中..." - 是否给 access_token input 加 type="password" 屏蔽明文显示 —— 推荐**不**加(运营要看自己输入的内容) - "最后更新"时间格式:`new Date(updatedAt).toLocaleString('zh-CN')` 还是用 `date-fns`(已在 deps)
## Canonical References **下游 agent 必读**: ### 项目宪法 - `qy-lty-admin/CLAUDE.md` - `qy-lty-admin/.planning/PROJECT.md` — Milestone v1.0「关键约束」段(特别注意"留空保留旧值"语义实际无法实现的限制) - `qy-lty-admin/.planning/REQUIREMENTS.md` — Active 段 CRED-FE-04 + CRED-FE-05 - `qy-lty-admin/.planning/ROADMAP.md` — Phase 3 详情段(5 条 success criteria) ### Phase 1+2 已交付(必读,作为消费 contract) - `qy-lty-admin/lib/api/credential-slot.ts` — `getCredentialSlot` / `updateCredentialSlot` / 类型 - `qy-lty-admin/lib/permissions.ts` — `'credential-slot'` 已加入 - `qy-lty-admin/app/ai-model/page.tsx` — Phase 2 落地的 Button 入口 + mounted 守卫 + 占位 Dialog(待替换) ### React Hook Form + Zod 现有用法(必读,1:1 模板) - `qy-lty-admin/components/songs/` 或 `qy-lty-admin/components/outfits/` 下任一 form 组件 — researcher 找出最贴近本 phase 的 RHF + Zod 写法(必读 1-2 个完整 form) - `qy-lty-admin/lib/api/error-handler.ts` — handleApiError 函数 ### Sonner toast - `qy-lty-admin/hooks/use-toast.ts` — useToast hook 调用样板 - `qy-lty-admin/components/ui/sonner.tsx`(如存在) — Sonner 注入位置 ### shadcn UI - `qy-lty-admin/components/ui/dialog.tsx` - `qy-lty-admin/components/ui/input.tsx` - `qy-lty-admin/components/ui/label.tsx` - `qy-lty-admin/components/ui/button.tsx` - `qy-lty-admin/components/ui/form.tsx`(如存在 —— shadcn Form wrapper 配 RHF) ### 修改记录 - `qy-lty-admin/docs/修改记录.md` — 头部「修改格式说明」+ Phase 1 / Phase 2 条目作格式模板 ## 具体要点(Success Criteria 显式化) | # | 验证点 | 检查方式 | |---|--------|----------| | 1 | `components/ai-model/CredentialSlotDialog.tsx`(或 kebab-case 名)存在并导出组件 | grep + 文件存在 | | 2 | 组件用 React Hook Form + Zod | grep `useForm` + `zodResolver` + `z.object` 命中 | | 3 | 打开时 `useEffect` 调 `getCredentialSlot()` 拉取预填 | grep `useEffect` + `getCredentialSlot` 命中 | | 4 | `accessToken` field 默认值为空字符串(不是 accessTokenMasked) | grep adverse:`defaultValues:.*accessToken.*accessTokenMasked` 不命中;正向 grep `defaultValues.*accessToken: ''` 或类似 | | 5 | input placeholder 用 `slot.accessTokenMasked` | grep `placeholder.*accessTokenMasked` 命中 | | 6 | 提示文字"如需更新请重新输入" 或类似中文 | grep 命中 | | 7 | `updated_at` 只读显示 | grep `slot.updatedAt` + 不在 `` 内 | | 8 | submit 调 `updateCredentialSlot` 仅传改动字段(部分载荷) | grep `updateCredentialSlot` + 提交逻辑形态 | | 9 | 成功路径调 `useToast` / `toast(` 弹中文提示 | grep `toast.*凭据槽位已更新` 或类似 | | 10 | 失败路径调 `handleApiError` | grep 命中 | | 11 | `app/ai-model/page.tsx` 占位 Dialog 已删除,替换为 `` | grep `import CredentialSlotDialog` + 旧占位 Dialog 字面量"对话框真实内容由 Phase 3 落地"已不在 page.tsx | | 12 | `npx tsc --noEmit` 在新增/修改文件零错误(沿用 Phase 1+2 判定) | shell exit + filter | | 13 | 修改记录顶部 Phase 3 条目(含权衡说明 + 跨项目联动「无 / 待评估」)| grep | ## 推迟事项 - "留空保留旧值"语义 —— 需要后端识别脱敏掩码格式并保留旧值(**给后端开新 phase / patch milestone**);本 phase 走"强制输入" UX - 端到端浏览器测试 —— 无 E2E 框架,本 phase 用程序化验证(npx tsc + grep) - token 轮换 / refresh token / DB at-rest 加密 / Vitest 体系 / ESLint bootstrap —— 各自独立 milestone --- *Phase: 03-dialog-feedback* *Context gathered: 2026-05-08 via inline PRD(用户在 /gsd-plan-phase 3 --skip-ui 调用时提供完整约束)*