- 新建 .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 级双重验证)
251 lines
17 KiB
Markdown
251 lines
17 KiB
Markdown
---
|
||
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*
|