pmc 89cd768765 docs(03-02): 完成「编辑对话框组件落地 + 页面接入」plan
- 新建 .planning/phases/03-dialog-feedback/03-02-SUMMARY.md(Plan 03-02 收尾归档)
- 更新 STATE.md:Phase 3 进度 1/3 → 2/3(67%),milestone 进度 71% → 86%(6/7 plan)
- 更新 ROADMAP.md:Plan 03-02 标记完成(commits d719891 + 7872840)
- 更新 REQUIREMENTS.md:CRED-FE-04 + CRED-FE-05 切到  Done
- 业务功能完整闭环(CredentialSlotDialog 191 行 RHF+Zod+Sonner+handleApiError + page 接入);等待 Plan 03-03 收尾(修改记录追加 + plan 级双重验证)
2026-05-08 12:36:56 +08:00

251 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
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.tsgetCredentialSlot / updateCredentialSlot / 类型)
- phase: 02-rbac-entry
provides: app/ai-model/page.tsx 占位 Dialog + isCredentialDialogOpen state + 凭据槽位 Button 入口
- phase: 03-01
provides: app/layout.tsx 已挂载 Sonner Toaster portaltoast 反馈通道前置就绪)
provides:
- components/ai-model/credential-slot-dialog.tsxCredentialSlotDialog 组件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.resetcancelled flag 防 race condition"
- "脱敏掩码语义屏障accessToken defaultValue 永远空串accessTokenMasked 仅作 placeholder 视觉提示"
- "Sonner 命令式 toastimport { toast } from \"sonner\" 直接 toast.success / toast.error不走 useToast hook仓库 useToast 是 Radix 实现,与 Sonner 不通)"
- "handleApiError 显式路径from \"@/lib/api/error-handler\",不走 barrel @/lib/apibarrel 里有同名 dead-code 重复定义)"
key-files:
created:
- "components/ai-model/credential-slot-dialog.tsx191 行)"
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 个 lockfilepackage.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 / useStatereact、useFormreact-hook-form、zodResolver@hookform/resolvers/zod、zzod、toastsonner、Loader2lucide-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/catchgetCredentialSlot 成功 → form.reset({ appId, accessToken: "" }) + setSlot
- handleSubmitupdateCredentialSlot → toast.success + handleOpenChange(false);失败 → toast.error + 对话框保持
- JSXDialogHeader标题「通用凭据槽位」+ 中文描述)/ 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 占位 Dialog13 行,含「对话框真实内容由 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组件内部 isLoadingGET 拉数据时)+ isSubmittingPUT 提交时)两处 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 条正向 grepCONTEXT.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 CriteriaROADMAP.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 + JSXgrep 命中 L10 + L467
- ✅ commit `d719891` 存在于 git logfeat(03-02): 新建 CredentialSlotDialog 组件)
- ✅ commit `7872840` 存在于 git logfeat(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*