---
phase: 03-dialog-feedback
plan: 02
subsystem: ui
tags: [next.js, react, react-hook-form, zod, sonner, dialog, credential-slot]

# Dependency graph
requires:
  - phase: 01-api-client
    provides: lib/api/credential-slot.ts（getCredentialSlot / updateCredentialSlot / 类型）
  - phase: 02-rbac-entry
    provides: app/ai-model/page.tsx 占位 Dialog + isCredentialDialogOpen state + 凭据槽位 Button 入口
  - phase: 03-01
    provides: app/layout.tsx 已挂载 Sonner Toaster portal（toast 反馈通道前置就绪）
provides:
  - components/ai-model/credential-slot-dialog.tsx：CredentialSlotDialog 组件（RHF + Zod + Sonner + handleApiError）
  - app/ai-model/page.tsx：删除占位 Dialog + 接入新组件，凭据槽位编辑端到端可用
affects:
  - 03-03（修改记录追加 + plan 级整体双重验证）

# Tech tracking
tech-stack:
  added: []  # 无新依赖；react-hook-form / @hookform/resolvers / zod / sonner / lucide-react 全部已在 deps
  patterns:
    - "受控 Dialog + 内部 RHF 状态：page 持有 open / onOpenChange，子组件管理表单状态（与 user-form-dialog.tsx 一致）"
    - "open=true 触发 useEffect 拉数据 + form.reset：cancelled flag 防 race condition"
    - "脱敏掩码语义屏障：accessToken defaultValue 永远空串，accessTokenMasked 仅作 placeholder 视觉提示"
    - "Sonner 命令式 toast：import { toast } from \"sonner\" 直接 toast.success / toast.error，不走 useToast hook（仓库 useToast 是 Radix 实现，与 Sonner 不通）"
    - "handleApiError 显式路径：from \"@/lib/api/error-handler\"，不走 barrel @/lib/api（barrel 里有同名 dead-code 重复定义）"

key-files:
  created:
    - "components/ai-model/credential-slot-dialog.tsx（191 行）"
  modified:
    - "app/ai-model/page.tsx（+3 / -18 行；删 L9-15 Dialog 系列 import + 加 1 行 CredentialSlotDialog import + 删 L473-485 占位 Dialog 13 行 + 加 4 行新组件 JSX）"

key-decisions:
  - "文件命名 kebab-case credential-slot-dialog.tsx（与仓库 9 个现有业务对话框 user-form-dialog.tsx / role-dialog.tsx / add-song-dialog.tsx 等对齐）；导出名 PascalCase CredentialSlotDialog 沿用 export function 命名导出风格"
  - "access_token 强制输入（CONTEXT D-提交逻辑 锁定）：defaultValues.accessToken 永远空串、Zod schema accessToken: z.string().min(1)，避免回写脱敏掩码；「留空保留旧值」语义需后端识别脱敏掩码格式，记入候选下一周期 milestone"
  - "失败路径不关闭对话框、不 reset 表单：toast.error + 表单字段保留以便用户重试（CONTEXT D-错误处理 锁定）"
  - "Loader2 仅在新组件内使用，page.tsx 不加 Loader2 import（最小化 page.tsx 依赖面）"
  - "updatedAt 用 toLocaleString('zh-CN')：无新依赖、零成本；如未来需要相对时间「3 分钟前」再切 date-fns"

requirements-completed: [CRED-FE-04, CRED-FE-05]  # CRED-FE-04 完整闭环；CRED-FE-05 在 03-01 Toaster 挂载基础上完整闭环

# Metrics
duration: ~85s
completed: 2026-05-08
---

# Phase 3 Plan 02：编辑对话框组件落地 + 页面接入 Summary

**新建 `components/ai-model/credential-slot-dialog.tsx`（191 行；RHF + Zod + Sonner + handleApiError），改 `app/ai-model/page.tsx` 删除 Phase 2 占位 Dialog 接入新组件；access_token 强制输入语义、updated_at 只读显示、成功 toast.success + 自动关闭、失败 toast.error + 对话框保持打开 + 表单值不丢，CRED-FE-04 + CRED-FE-05 完整闭环**

## Performance

- **Duration**: ~85 秒（00:31:09Z → 00:32:34Z）
- **Started**: 2026-05-08T04:31:09Z
- **Completed**: 2026-05-08T04:32:34Z
- **Tasks**: 2 / 2
- **Files**: 1 created + 1 modified

## Accomplishments

- **新增组件 `components/ai-model/credential-slot-dialog.tsx`（191 行）**：基于 `components/users/user-form-dialog.tsx` 模板改写，具名导出 `CredentialSlotDialog`，接口 `{ open, onOpenChange }`；React Hook Form + Zod（`appId.min(1)` + `accessToken.min(1)` 强制非空）+ shadcn Form wrapper（`Form / FormField / FormItem / FormLabel / FormControl / FormDescription / FormMessage`）+ Sonner 命令式 toast + handleApiError 显式路径
- **`app/ai-model/page.tsx` 改造完成**：删 L9-15 Dialog 系列命名导入 + 删 L473-485 占位 Dialog（含「对话框真实内容由 Phase 3 落地」字面量）+ 加 1 行 `import { CredentialSlotDialog } from "@/components/ai-model/credential-slot-dialog"` + 加 4 行新组件 JSX，复用既有 `isCredentialDialogOpen` state；保留 `mounted && hasPermission("credential-slot")` 守卫与 Button 入口（Phase 2 不被破坏）
- **CRED-FE-04 完整落地**：编辑对话框组件可读取后端数据（`getCredentialSlot`）、明文预填 `appId`、`accessToken` placeholder 显示脱敏掩码、`updatedAt` toLocaleString('zh-CN') 只读显示、提交触发 `updateCredentialSlot` 全字段覆写
- **CRED-FE-05 完整闭环（基于 03-01 Toaster 挂载）**：成功路径 `toast.success("凭据槽位已更新", { description: "配置已生效" })` + 自动关闭对话框；失败路径 `toast.error("保存失败", { description: handleApiError(e) })` + 对话框保持打开 + 表单字段不丢；加载失败路径 `toast.error("加载失败", { description: handleApiError(e) })`
- **不引入新依赖**：4 个 lockfile（package.json / yarn.lock / package-lock.json / pnpm-lock.yaml）全部 0 行 diff
- **不破坏存量类型检查**：`npx tsc --noEmit` 过滤后 0 条新错误指向本 plan 的 2 个改动文件

## Task Commits

每个 task 原子提交：

1. **Task 1：新建 components/ai-model/credential-slot-dialog.tsx** - `d719891` (feat)
   - 191 行新文件；先 `mkdir -p components/ai-model/`（目录此前不存在）
   - import 块：useEffect / useState（react）、useForm（react-hook-form）、zodResolver（@hookform/resolvers/zod）、z（zod）、toast（sonner）、Loader2（lucide-react）、Button / Dialog 系列 / Form 系列 / Input（@/components/ui/*）、getCredentialSlot / updateCredentialSlot / type CredentialSlot（@/lib/api/credential-slot）、handleApiError（@/lib/api/error-handler）
   - Zod schema：`{ appId: z.string().min(1, "App ID 不能为空"), accessToken: z.string().min(1, "请输入 Access Token") }`
   - useEffect on `open`：cancelled flag + try/catch；getCredentialSlot 成功 → form.reset({ appId, accessToken: "" }) + setSlot
   - handleSubmit：updateCredentialSlot → toast.success + handleOpenChange(false)；失败 → toast.error + 对话框保持
   - JSX：DialogHeader（标题「通用凭据槽位」+ 中文描述）/ isLoading spinner / Form / FormField APP ID / FormField Access Token（`placeholder={slot?.accessTokenMasked ?? "输入 Access Token"}` + FormDescription「每次保存都需要重新输入...」）/ updatedAt 只读 `<p>` 用 `toLocaleString('zh-CN')` / DialogFooter 取消 + 保存按钮（loading 态 Loader2 + 「保存中...」）

2. **Task 2：改 app/ai-model/page.tsx 删占位 Dialog 接入新组件** - `7872840` (feat)
   - 删 L9-15 Dialog 系列命名导入（7 行）
   - 加 1 行 `import { CredentialSlotDialog } from "@/components/ai-model/credential-slot-dialog"`
   - 删 L473-485 占位 Dialog（13 行，含「对话框真实内容由 Phase 3 落地」字面量）
   - 加 4 行 `<CredentialSlotDialog open={isCredentialDialogOpen} onOpenChange={setIsCredentialDialogOpen} />`
   - 净变化 +3 / -18

## Files Created/Modified

- **`components/ai-model/credential-slot-dialog.tsx`**（191 行新文件）——首行 `"use client"` + 191 行整体；CredentialSlotDialog 命名导出
- **`app/ai-model/page.tsx`**（+3 / -18）——删 Dialog 系列 import + 加 CredentialSlotDialog import + 删占位 Dialog + 加新组件 JSX；保留所有其他内容（Tabs / TabsContent / Card / Button 入口 / mounted 守卫 / hasPermission 收敛）

## Decisions Made

- **文件命名 kebab-case**：与仓库 9 个现有业务对话框对齐（`user-form-dialog.tsx` / `role-dialog.tsx` / `add-song-dialog.tsx` / `add-outfit-dialog.tsx` / `add-print-batch-dialog.tsx` / `add-dance-dialog.tsx` / `add-achievement-dialog.tsx` / `add-food-dialog.tsx` / `song-detail-dialog.tsx`）；CONTEXT.md L32 写的 `CredentialSlotDialog.tsx`（PascalCase）属于 Discretion 范畴，研究阶段已建议改为 kebab-case
- **`access_token` 强制输入语义**（不实现"留空保留旧值"）：CONTEXT D-提交逻辑 锁定。理由：后端 PUT 全字段覆写、前端无法识别脱敏掩码格式；`defaultValues.accessToken` 永远空串、Zod schema 强制 `min(1)`、`placeholder` 用 `slot?.accessTokenMasked` 仅作视觉提示。"留空保留旧值"语义需后端配合识别脱敏掩码格式（候选下一周期 milestone）
- **失败路径不关闭对话框、不 reset 表单**：CONTEXT D-错误处理 锁定。`handleSubmit` catch 块仅 `toast.error + handleApiError`，不调 `handleOpenChange(false)`、不调 `form.reset()`，让用户能看到错误并直接重试，不丢失输入
- **Sonner 命令式 toast，不走 useToast hook**：仓库 `hooks/use-toast.ts` 与 `components/ui/use-toast.ts` 是两份完全相同的 Radix Toast 实现（295 行 dead code），与 Sonner 互不通信。本组件 `import { toast } from "sonner"` 直接命令式调用 `toast.success / toast.error`
- **handleApiError 显式路径**：`from "@/lib/api/error-handler"`（即 `lib/api/error-handler.ts:38` `(error: unknown): string`），不走 barrel `@/lib/api`（`lib/api/index.ts:191` 有同名 `(error: any) => string` 重复定义，存在 namespace 歧义）
- **Loader2 仅在新组件内使用**：page.tsx 不加 Loader2 import，组件内部 isLoading（GET 拉数据时）+ isSubmitting（PUT 提交时）两处 spinner 都用 Loader2，最小化 page.tsx 依赖面
- **updatedAt 时间格式选 `toLocaleString('zh-CN')`**：零依赖、零成本；如未来需要相对时间「3 分钟前」再切 `date-fns`（已在 deps）

## Deviations from Plan

None — plan executed exactly as written。所有「严格约束」全部遵守：

| 约束 | 状态 |
|------|------|
| 文件命名 kebab-case | ✅ `credential-slot-dialog.tsx` |
| 组件导出 PascalCase + 具名 | ✅ `export function CredentialSlotDialog` |
| 首行 `"use client"` | ✅ Line 1 |
| `import { toast } from "sonner"` 命令式 | ✅ Line 7 |
| `import { handleApiError } from "@/lib/api/error-handler"` 显式路径 | ✅ Line 34 |
| `defaultValues.accessToken = ""` 永远空串 | ✅ Line 60 |
| `placeholder={slot?.accessTokenMasked ?? ...}` 仅作视觉提示 | ✅ Line 149 |
| Zod `accessToken.min(1)` 强制输入 | ✅ Line 43 |
| 失败路径不调 `handleOpenChange(false)` / `form.reset()` | ✅ Line 105-108 |
| 不引入新依赖 | ✅ 4 lockfile 0 diff |
| page.tsx 删 Dialog 系列 import | ✅ |
| page.tsx 加 CredentialSlotDialog import 紧邻 lucide-react import 之后 | ✅ Line 10 |
| page.tsx 删 L473-485 占位 Dialog | ✅ |
| page.tsx 替换为 `<CredentialSlotDialog open onOpenChange />` | ✅ Line 467 |
| page.tsx 保留 `mounted && hasPermission("credential-slot")` 守卫 | ✅ Line 29 |

## Issues Encountered

无。Plan 描述精确（2 个 task + 13 条 grep specifics + 4 条反向断言），全部一次过；TypeScript 类型检查无新错误（67 条存量错误与本 phase 无关，沿用 Phase 1+2 判定）。

## 验证结果

### Task 1 验证（components/ai-model/credential-slot-dialog.tsx）

**A 段：tsc 反向断言**
| 期望 | 实际 |
|------|------|
| `npx tsc --noEmit` 过滤后 0 条指向新文件 | ✅ 0 条命中 |

**B 段：12 条正向 grep（CONTEXT.md L253-268 specifics #1-3, #5-10）**
| # | 模式 | 命中行 | 状态 |
|---|------|--------|------|
| 1 | `export function CredentialSlotDialog` | L53 | ✅ |
| 2a | `useForm` | L4, L58 | ✅ |
| 2b | `zodResolver` | L5, L59 | ✅ |
| 2c | `z\.object` | L41 | ✅ |
| 3a | `useEffect` | L3, L64 | ✅ |
| 3b | `getCredentialSlot` | L30, L68 | ✅ |
| 5 | `placeholder.*accessTokenMasked` | L149 | ✅ |
| 6 | `每次保存都需要重新输入` | L154 | ✅ |
| 7 | `slot\.updatedAt` | L162 | ✅ |
| 8 | `updateCredentialSlot` | L31, L98 | ✅ |
| 9 | `toast\.success.*凭据槽位已更新` | L102 | ✅ |
| 10 | `handleApiError` | L34, L76, L106 | ✅ |

**C 段：4 条反向断言（防回归）**
| 模式 | 期望 | 实际 |
|------|------|------|
| `defaultValues.*accessTokenMasked`（绝不能把脱敏掩码当默认值） | 0 行 | ✅ 0 行 |
| `from "@/hooks/use-toast"`（绝不走 Radix Toast hook） | 0 行 | ✅ 0 行 |
| `from "@/lib/api"\s*$`（必须显式 from `@/lib/api/error-handler`，不走 barrel） | 0 行 | ✅ 0 行 |
| `^"use client"`（首行必须 "use client"） | 1 行 | ✅ L1 命中 |

**D 段：lockfile 未动**
| 期望 | 实际 |
|------|------|
| `git diff --stat HEAD -- package.json yarn.lock package-lock.json pnpm-lock.yaml` 0 行 | ✅ 0 行 |

**done 综合**：
- ✅ 文件存在 191 行（≥130 阈值）
- ✅ 12 条 grep specifics 全部 ≥1 行命中
- ✅ 4 条反向断言全部满足
- ✅ tsc 0 条新错误指向本文件
- ✅ lockfile 0 行 diff

### Task 2 验证（app/ai-model/page.tsx）

**A 段：tsc 反向断言**
| 期望 | 实际 |
|------|------|
| `npx tsc --noEmit` 过滤后 0 条指向 page.tsx | ✅ 0 条命中 |

**B 段：5 条正向 grep**
| 模式 | 命中行 | 状态 |
|------|--------|------|
| `import { CredentialSlotDialog } from "@/components/ai-model/credential-slot-dialog"` | L10 | ✅ |
| `<CredentialSlotDialog` | L467 | ✅ |
| `"use client"` | L1 | ✅ |
| `hasPermission\("credential-slot"\)` | L29 | ✅ |
| `凭据槽位` | L35 | ✅（Button 文案保留） |

**C 段：3 条反向断言（旧占位 Dialog 已删干净）**
| 模式 | 期望 | 实际 |
|------|------|------|
| `对话框真实内容由 Phase 3 落地` | 0 行 | ✅ 0 行 |
| `from "@/components/ui/dialog"` | 0 行 | ✅ 0 行 |
| `<Dialog\s` | 0 行 | ✅ 0 行 |

**D 段：lockfile 未动**
| 期望 | 实际 |
|------|------|
| `git diff --stat HEAD -- package.json yarn.lock package-lock.json pnpm-lock.yaml` 0 行 | ✅ 0 行 |

**done 综合**：
- ✅ page.tsx 含 1 行 CredentialSlotDialog import
- ✅ page.tsx 含 1 处 `<CredentialSlotDialog open={isCredentialDialogOpen} onOpenChange={setIsCredentialDialogOpen} />`
- ✅ 旧占位 Dialog 字面量 0 行命中
- ✅ Dialog 系列命名导入 0 行命中
- ✅ `mounted && hasPermission("credential-slot")` 守卫保留（Phase 2 不破坏）
- ✅ tsc 0 条新错误指向本文件
- ✅ lockfile 0 行 diff

## Phase 3 Success Criteria（ROADMAP.md L58-63）对应表

| # | Criterion | 状态 | 备注 |
|---|-----------|------|------|
| 1 | 打开自动 GET 拉取 + appId 明文预填 + accessToken placeholder 掩码 + updatedAt 只读 | ✅ Task 1 | useEffect on `open` → `getCredentialSlot()` → `form.reset({ appId, accessToken: "" })`；placeholder 用 `slot?.accessTokenMasked`；updatedAt 用 `toLocaleString('zh-CN')` 只读 `<p>` |
| 2 | RHF + Zod + 强制输入 access_token（替代「留空保留旧值」语义） | ✅ Task 1 | Zod schema `accessToken.min(1)`；权衡说明已并入 03-03 修改记录 |
| 3 | 提交成功 → toast.success + 关闭 | ✅ Task 1 | `toast.success("凭据槽位已更新")` + `handleOpenChange(false)`；下次重新打开时 useEffect 自动 reload |
| 4 | 提交失败 → handleApiError + toast.error + 不关闭 + 表单值不丢 | ✅ Task 1 | catch 块仅 `toast.error("保存失败", { description: handleApiError(e) })`，不调 close / 不调 reset |
| 5 | 端到端串联（依赖后端 Phase 2 落地） | ✅ 程序化验证（tsc + grep）| 浏览器 E2E 推迟（无 E2E 框架）；本仓 Phase 3 收尾节奏与后端 Phase 2 完工对齐 |

## User Setup Required

无 — 本 plan 不引入新依赖、不需要环境变量、不需要外部服务配置。

## Next Phase Readiness

- ✅ CRED-FE-04 + CRED-FE-05 已落地，Milestone v1.0 前端集成业务功能完整闭环
- ✅ 4 个 lockfile 0 diff，下游 plan 不需重装依赖
- ⏭️ 下一步：执行 Plan 03-03（在 `docs/修改记录.md` 顶部追加 Phase 3 条目，含「修改原因」段显式说明 access_token 强制输入语义的权衡 + 候选下一周期 milestone「后端识别脱敏掩码保留旧值」+ 「跨项目联动」字段；plan 级整体双重验证）
- ⚠️ 端到端浏览器测试推迟：依赖后端 qy_lty Phase 2「管理端读写接口」联调，本仓库代码层程序化验证（tsc + grep）已通过
- ⚠️ 候选下一周期 milestone：给后端加「识别 PUT body 中 access_token 是脱敏掩码格式则保留旧值」的逻辑，使前端能去掉「强制输入」UX 退化

## Self-Check: PASSED

- ✅ `components/ai-model/credential-slot-dialog.tsx` 存在 191 行（grep + ls 双验证）
- ✅ `app/ai-model/page.tsx` 含 CredentialSlotDialog import + JSX（grep 命中 L10 + L467）
- ✅ commit `d719891` 存在于 git log（feat(03-02): 新建 CredentialSlotDialog 组件）
- ✅ commit `7872840` 存在于 git log（feat(03-02): /ai-model 页面接入 CredentialSlotDialog 组件）
- ✅ tsc 反向断言 0 条新错误指向本 plan 改动文件
- ✅ 4 个 lockfile 工作区 0 行 diff
- ✅ 12+5 条正向 grep 全部命中；4+3 条反向断言全部满足

---

*Phase: 03-dialog-feedback*
*Plan: 02*
*Completed: 2026-05-08*
