14 KiB
Phase 3:编辑对话框 + 提交反馈 - Context
Gathered: 2026-05-08
Status: Ready for planning(用户选择 --skip-ui 跳过 UI-SPEC,直接规划;与 Phase 2 同模式)
Source: 用户在 /gsd-plan-phase 3 调用时提供的内联约束
本 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-0106 后端 + CRED-FE-0105 前端)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.tsxL1-289(最贴近本 phase 形态:单 dialog + 几个字段 + RHF + Zod + Form wrapper + Loader2 spinner + 提交后关闭)
- 该路径目录
- 修改:
app/ai-model/page.tsx- 删除 Phase 2 落地的占位 Dialog(约第 473-485 行的内联 Dialog)
- 用
<CredentialSlotDialog open={isCredentialDialogOpen} onOpenChange={setIsCredentialDialogOpen} />替换 - 保留 Button 入口控件 + mounted 守卫(Phase 2 已落地)
Dialog 组件接口(CredentialSlotDialog.tsx)
interface CredentialSlotDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
}
简洁接口;状态由 page 持有,组件只受控渲染 + 自身的表单状态管理。
表单技术栈(已在 deps)
- React Hook Form:
useForm({ resolver: zodResolver(schema), defaultValues: { appId: '', accessToken: '' } }) - Zod:定义 schema,条件校验(核心):
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])):
- 调
getCredentialSlot()拉取后端数据 - 把
slot.appId作为appIdfield 默认值(明文显示) accessTokenfield 默认值是空字符串(不是slot.accessTokenMasked!)accessTokenMasked仅作为 input 的 placeholder:<Input placeholder={slot.accessTokenMasked} />- input 下方一行小字提示:「如需更新请重新输入,留空保留旧值」
updated_at只读显示(如<p className="text-sm text-muted">最后更新:{slot.updatedAt}</p>)
TypeScript 类型层面屏障:表单值类型用 Partial<CredentialSlotUpdatePayload>(仅 accessToken + appId 两个 camelCase 字段,不含 accessTokenMasked)—— 编译期切断"把脱敏字符串当真值回写"的 bug。
提交逻辑(核心业务规则)
const onSubmit = async (values: { appId: string; accessToken: string }) => {
const payload: Partial<CredentialSlotUpdatePayload> = {}
// 仅当用户实际改动了 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:表单提交前用预填值补齐缺失字段。例如:
但const finalPayload: CredentialSlotUpdatePayload = { appId: payload.appId ?? originalSlot.appId, accessToken: payload.accessToken ?? '<KEEP-OLD-MARKER>', }<KEEP-OLD-MARKER>后端不识别,等于明文回写,错误
最简正确路径(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,没有<Toaster />- 现有 9 处
toast(...)调用其实全是 dead code(toast 不会显示) - 本 phase 必须前置一个任务:在
app/layout.tsx<body>末尾挂载<Toaster />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)- 不要走
useToasthook - 直接
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 调用形态:
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
CredentialSlotDialogfrom@/components/ai-model/credential-slot-dialog - 删除 Phase 2 占位 Dialog(约 13 行 JSX)
- 替换为:
<CredentialSlotDialog open={isCredentialDialogOpen} onOpenChange={setIsCredentialDialogOpen} /> - 保留 Button 入口 + mounted 守卫
Claude's Discretion
- 文件命名
CredentialSlotDialog.tsx(PascalCase)vscredential-slot-dialog.tsx(kebab-case) —— planner 看仓库现有 components/ 子目录约定 - Cancel 按钮文案:"取消" / "关闭"
- 提交按钮 loading 态文案:"保存中..." / "处理中..."
- 是否给 access_token input 加 type="password" 屏蔽明文显示 —— 推荐不加(运营要看自己输入的内容)
- "最后更新"时间格式:
new Date(updatedAt).toLocaleString('zh-CN')还是用date-fns(已在 deps)
<canonical_refs>
Canonical References
下游 agent 必读:
项目宪法
qy-lty-admin/CLAUDE.mdqy-lty-admin/.planning/PROJECT.md— Milestone v1.0「关键约束」段(特别注意"留空保留旧值"语义实际无法实现的限制)qy-lty-admin/.planning/REQUIREMENTS.md— Active 段 CRED-FE-04 + CRED-FE-05qy-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.tsxqy-lty-admin/components/ui/input.tsxqy-lty-admin/components/ui/label.tsxqy-lty-admin/components/ui/button.tsxqy-lty-admin/components/ui/form.tsx(如存在 —— shadcn Form wrapper 配 RHF)
修改记录
qy-lty-admin/docs/修改记录.md— 头部「修改格式说明」+ Phase 1 / Phase 2 条目作格式模板
</canonical_refs>
## 具体要点(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 + 不在 <input> 内 |
| 8 | submit 调 updateCredentialSlot 仅传改动字段(部分载荷) |
grep updateCredentialSlot + 提交逻辑形态 |
| 9 | 成功路径调 useToast / toast( 弹中文提示 |
grep toast.*凭据槽位已更新 或类似 |
| 10 | 失败路径调 handleApiError |
grep 命中 |
| 11 | app/ai-model/page.tsx 占位 Dialog 已删除,替换为 <CredentialSlotDialog .../> |
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 调用时提供完整约束)