14 KiB
Raw Blame History

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-0106 后端 + CRED-FE-0105 前端100% 交付。

## 实现决策(锁定)

组件抽离

  • 新建components/ai-model/credential-slot-dialog.tsxresearcher 修正:仓库 9 个现有业务对话框全部 kebab-case,如 add-song-dialog.tsx / user-form-dialog.tsx,本 phase 跟规约)
    • 该路径目录 components/ai-model/ 确认不存在researcher 实测 ls 退出码 2需要 mkdir
    • 沿用 shadcn 组件风格 + RHF + Zod1: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
    • <CredentialSlotDialog open={isCredentialDialogOpen} onOpenChange={setIsCredentialDialogOpen} /> 替换
    • 保留 Button 入口控件 + mounted 守卫Phase 2 已落地)

Dialog 组件接口CredentialSlotDialog.tsx

interface CredentialSlotDialogProps {
  open: boolean
  onOpenChange: (open: boolean) => void
}

简洁接口;状态由 page 持有,组件只受控渲染 + 自身的表单状态管理。

表单技术栈(已在 deps

  • React Hook FormuseForm({ 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])

  1. getCredentialSlot() 拉取后端数据
  2. slot.appId 作为 appId field 默认值(明文显示)
  3. accessToken field 默认值是空字符串不是 slot.accessTokenMasked
  4. accessTokenMasked 仅作为 input 的 placeholder<Input placeholder={slot.accessTokenMasked} />
  5. input 下方一行小字提示:「如需更新请重新输入,留空保留旧值」
  6. 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 schemaaccessToken: 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 codetoast 不会显示)
  • 本 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
  • 不要走 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
  • 显式 importimport { 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:handleApiErrorresearcher 必须 read 该函数确认签名 + 返回值类型)
  • 失败时关闭对话框,清空表单值

app/ai-model/page.tsx 改动

  • import CredentialSlotDialog from @/components/ai-model/credential-slot-dialog
  • 删除 Phase 2 占位 Dialog约 13 行 JSX
  • 替换为:
    <CredentialSlotDialog
      open={isCredentialDialogOpen}
      onOpenChange={setIsCredentialDialogOpen}
    />
    
  • 保留 Button 入口 + mounted 守卫

Claude's Discretion

  • 文件命名 CredentialSlotDialog.tsxPascalCasevs credential-slot-dialog.tsxkebab-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.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.tsgetCredentialSlot / 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 条目作格式模板

</canonical_refs>

## 具体要点Success Criteria 显式化)
# 验证点 检查方式
1 components/ai-model/CredentialSlotDialog.tsx(或 kebab-case 名)存在并导出组件 grep + 文件存在
2 组件用 React Hook Form + Zod grep useForm + zodResolver + z.object 命中
3 打开时 useEffectgetCredentialSlot() 拉取预填 grep useEffect + getCredentialSlot 命中
4 accessToken field 默认值为空字符串(不是 accessTokenMasked grep adversedefaultValues:.*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 调用时提供完整约束)