47 KiB
Raw Blame History

Phase 3编辑对话框 + 提交反馈 - Research

调研日期2026-05-08 DomainNext.js 15 (App Router) + React 19 + RHF + Zod + shadcn UI + Sonner凭据槽位编辑对话框 ConfidenceHIGH全部基于本仓库已落地代码 grep + read无需外部文档查证

Summary

本 phase 抽离独立组件 components/ai-model/credential-slot-dialog.tsx,把 Phase 2 落地的占位 Dialogapp/ai-model/page.tsx L473-485替换为完整功能的编辑对话框基于 React Hook Form + Zod 表单、调 getCredentialSlot() 预填、提交走 updateCredentialSlot()、成功/失败用 toast 反馈、错误经 handleApiError 映射。

所有依赖均已在 deps 中react-hook-form@hookform/resolverszodsonnerlucide-react、shadcn UI 组件),不需要新增任何依赖

仓库内已有两个高质量 RHF + Zod 1:1 模板可直接对照:components/users/user-form-dialog.tsx(更贴近本 phase 的 single-dialog + 表单 + submit patterncomponents/permissions/role-dialog.tsx(更复杂,含动态字段,本 phase 用不到)。首选模板:components/users/user-form-dialog.tsx

Primary recommendation:以 components/users/user-form-dialog.tsx 为蓝本,改造成 components/ai-model/credential-slot-dialog.tsxkebab-case 命名,与 add-outfit-dialog.tsx / add-song-dialog.tsx / user-form-dialog.tsx / role-dialog.tsx 保持一致),表单两个字段 appId + accessTokenaccess_token 强制输入CONTEXT.md 锁定),提交走 updateCredentialSlot({ appId, accessToken })

⚠️ 预警 1必须告知 planner:仓库内全局未挂载 <Toaster /><Sonner Toaster /> —— app/layout.tsx 仅 20 行裸结构,没有 Toaster 渲染;components/dashboard-shell.tsx 也没有。这意味着任何 toast() 调用当前都会静默 no-op(实测过 add-dance-dialog.tsx 等已有 toast(...) 调用也属于死代码。Phase 3 必须在 plan 中纳入"挂载 Sonner Toaster 到 app/layout.tsx"作为前置任务,否则 toast 反馈完全不可见。

⚠️ 预警 2:仓库内有两个 handleApiError 函数lib/api/error-handler.ts:38lib/api/index.ts:191),签名与行为相同(都是 (error: unknown) => string,先看 error instanceof Error.message否则返回中文兜底。CONTEXT.md 锁定从 lib/api/error-handler.ts 引入(import { handleApiError } from '@/lib/api/error-handler'),不要从 barrel @/lib/apibarrel 里那个是同名重复定义,存在 namespace 歧义风险)。

⚠️ 预警 3:仓库 hooks/use-toast.tscomponents/ui/use-toast.ts两份完全相同的 Radix Toast 实现(不是 Sonner—— 即使挂载了 <Toaster />Radix 版本),也跟 Sonner 互不通信。CONTEXT.md 锁定 Sonner 反馈,意味着 plan 必须:(a) 挂载 <Toaster />(来自 @/components/ui/sonner(b) 在 dialog 内 import { toast } from "sonner"(直接用 sonner 包导出的 toast()useToast hook —— Sonner 没有 hook 模式,是命令式 API

<user_constraints>

User Constraints (from CONTEXT.md)

Locked Decisions

组件抽离

  • 新建components/ai-model/CredentialSlotDialog.tsx(命名待定,见 Discretion
    • components/ai-model/ 目录确认不存在,需要 mkdir
  • 修改app/ai-model/page.tsx
    • 删除 Phase 2 落地的占位 Dialog确认在 L473-485
    • <CredentialSlotDialog open={isCredentialDialogOpen} onOpenChange={setIsCredentialDialogOpen} /> 替换
    • 保留 Button 入口L36-43+ mounted 守卫L20、L23-25

Dialog 接口

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

表单技术栈React Hook Form + Zod@hookform/resolvers/zod(已在 deps

预填态

  • useEffect(() => { if (open) loadData() }, [open])getCredentialSlot()
  • appId field 默认值 = slot.appId(明文)
  • accessToken field 默认值 = ''不是 masked
  • input placeholder = slot.accessTokenMasked
  • 提示文字"如需更新请重新输入"
  • updated_at 只读显示

提交逻辑CONTEXT 最终决策)

  • access_token 强制输入z.string().min(1, '请输入 Access Token')
  • 退化为"每次保存都要重输 access_token"UX 略差但语义正确(不会回写脱敏掩码)
  • docs/修改记录.md Phase 3 条目「修改原因」段必须显式说明这个权衡 + 候选下一步是给后端加"识别脱敏掩码保留旧值"逻辑

Toast 通知

  • Sonner不要用 Radix Toast / hooks/use-toast
  • 成功title "凭据槽位已更新" / 描述 "配置已生效"
  • 失败title "保存失败" / description = handleApiError(e) / variant "destructive"

错误处理

  • 复用 lib/api/error-handler.ts:handleApiError
  • 失败时关闭对话框、清空表单值

Claude's Discretion

  • 文件命名 CredentialSlotDialog.tsxPascalCasevs credential-slot-dialog.tsxkebab-case—— researcher 看现有约定决定
  • Cancel 按钮文案:"取消" / "关闭"
  • 提交按钮 loading 态文案:"保存中..." / "处理中..." / "提交中..."
  • 是否给 access_token input 加 type="password" —— CONTEXT 推荐
  • "最后更新"时间格式:new Date(updatedAt).toLocaleString('zh-CN')date-fns

Deferred Ideas (OUT OF SCOPE)

  • "留空保留旧值"语义 —— 需要后端识别脱敏掩码格式,给后端开新 phase / patch milestone
  • 端到端浏览器测试(无 E2E 框架)
  • token 轮换 / refresh token / DB at-rest 加密 / Vitest 体系 / ESLint bootstrap

</user_constraints>

<phase_requirements>

Phase Requirements

ID Description Research Support
CRED-FE-04 编辑对话框组件 components/ai-model/CredentialSlotDialog.tsx:基于 components/ui/dialog.tsx;表单 React Hook Form + Zod 校验;预填态显示后端返回的 app_id 明文 + access_token 末 4 位掩码 + 不可改的 updated_at提交触发 updateCredentialSlot() 1:1 模板 components/users/user-form-dialog.tsx 直接复用shadcn Form wrapper components/ui/form.tsx 已存在Phase 1 落地的 getCredentialSlot() / updateCredentialSlot() API 已就绪(lib/api/credential-slot.tsPhase 2 落地的页面入口 + mounted 守卫已就绪(app/ai-model/page.tsx
CRED-FE-05 提交反馈:成功调 useToast() 弹 Sonner toast 自动关闭 + 重新 GET失败走 lib/api/error-handler.ts 统一映射并 toast 提示 ⚠️ Sonner Toaster 全局未挂载gap必须修复handleApiError(error: unknown): string 签名已确认CONTEXT 锁定不重新 GET 而由打开 Dialog 时的 useEffect 自动 loadData关闭再开自然刷新

</phase_requirements>

Architectural Responsibility Map

Capability Primary Tier Secondary Tier Rationale
表单状态 + 校验 Browser / Client (RHF + Zod) 标准 RHF 受控表单,纯客户端
预填数据 GET Browser / Client (axios) API / Backend (Phase 2 后端 v1.0) apiClient.get via Phase 1 落地的 getCredentialSlot()
提交 PUT Browser / Client (axios) API / Backend (Phase 2 后端 v1.0) apiClient.put via Phase 1 落地的 updateCredentialSlot()
Toast 反馈 Browser / Client (Sonner) Sonner 是纯客户端 portal挂在 layout.tsx 的 RootLayout
错误映射 Browser / Client handleApiError 是同步纯函数,无 IO
RBAC 入口可见性 Browser / Client Phase 2 已落地(hasPermission('credential-slot')

Standard Stack

Core已全部在 deps不引入新依赖

版本package.json 用途 Why Standard
react-hook-form latest 表单状态管理 仓库现有 users/user-form-dialog.tsx + permissions/role-dialog.tsx 已用 [VERIFIED: package.json:56]
@hookform/resolvers latest RHF + Zod 桥接(zodResolver 同上 [VERIFIED: package.json:12]
zod latest schema 校验 同上 [VERIFIED: package.json:63]
sonner ^1.7.1 Toast 通知 CONTEXT 锁定 [VERIFIED: package.json:59]
lucide-react ^0.454.0 图标 Loader2 for loading 态、KeyRound 已在 page.tsx import [VERIFIED: package.json:50]
@radix-ui/react-dialog ^1.1.4 Dialog 底层 shadcn components/ui/dialog.tsx 已封装 [VERIFIED: package.json:20]

Supportingshadcn UI 组件,全部已存在)

文件 是否存在 用途
components/ui/dialog.tsx Dialog / DialogContent / DialogHeader / DialogTitle / DialogDescription / DialogFooter
components/ui/form.tsx shadcn Form wrapperForm / FormField / FormItem / FormLabel / FormControl / FormMessage / FormDescription—— 详见下文
components/ui/input.tsx InputforwardRef受控
components/ui/label.tsx Label基于 Radix LabelPrimitive.Root
components/ui/button.tsx Button
components/ui/sonner.tsx Sonner <Toaster /> 包装(未在 layout 中挂载gap

Alternatives ConsideredCONTEXT 已锁定决策,仅供 plan-checker 验证)

Instead of Could Use Why we don't
Sonner toast hooks/use-toast.tsRadix Toast CONTEXT 锁定 Sonner且 Radix <Toaster /> 同样未挂载,无优势
受控 useStateadd-song-dialog.tsx / add-outfit-dialog.tsx 风格) RHF + Zod CONTEXT 锁定 RHF + Zod现有 user-form-dialog.tsx / role-dialog.tsx 已是该风格的 in-house 模板
barrel 路径 @/lib/apihandleApiError lib/api/error-handler.ts 的版本 CONTEXT 锁定后者;二者实现相同但显式路径避免歧义

安装npm install —— 无新增依赖。

RHF + Zod 1:1 模板(关键发现)

现有 RHF + Zod 用法 grep 全仓结果

2 个文件 同时含 useForm + zodResolver + zodgrep useForm.*zodResolver head_limit

  1. components/users/user-form-dialog.tsx289 行)—— 首选 1:1 模板
  2. components/permissions/role-dialog.tsx425 行)—— 含动态字段,过于复杂,参考即可

首选模板形态:components/users/user-form-dialog.tsx

关键代码区段(直接复用)

段落 行号 用途
顶部 import 块(含 useForm / zodResolver / * as z / Form...FormMessage / Loader2 L1-22 复用 import 清单
Zod schema 定义 L25-45 改写成 appId + accessToken 两字段
Component props 类型 L47-55 改写成 { open, onOpenChange }
useForm 初始化 L69-80 resolver: zodResolver(...)defaultValues
defaultValues 改变时 form.reset() L83-94 本 phase 不需要外部 defaultValues但需要 GET 后 reset模式相同(用 useEffect + form.reset
handleOpenChange + form.reset() L103-113 关闭时清表单
handleSubmit 异步 + setIsSubmitting L115-125 复用 try/finally 形态
Form / FormField / FormItem / FormLabel / FormControl / Input / FormMessage 嵌套 L146-176 1:1 复用模式
DialogFooter + 提交按钮 + Loader2 spinner L264-282 复用

模板核心结构(精简后用于 plan 引用)

// from components/users/user-form-dialog.tsx L20-22, 69-80, 146-176
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import * as z from "zod"

const formSchema = z.object({
  appId: z.string().min(1, { message: "App ID 不能为空" }),
  accessToken: z.string().min(1, { message: "请输入 Access Token" }),
})

const form = useForm<z.infer<typeof formSchema>>({
  resolver: zodResolver(formSchema),
  defaultValues: { appId: "", accessToken: "" },
})

// 在 GET 完成后:
form.reset({ appId: slot.appId, accessToken: "" })

// JSX
<Form {...form}>
  <form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4 py-4">
    <FormField
      control={form.control}
      name="appId"
      render={({ field }) => (
        <FormItem>
          <FormLabel>App ID</FormLabel>
          <FormControl>
            <Input placeholder="输入 App ID" {...field} />
          </FormControl>
          <FormMessage />
        </FormItem>
      )}
    />
    {/* ...accessToken field with placeholder={slot?.accessTokenMasked} */}
  </form>
</Form>

shadcn Form wrapper 用法(components/ui/form.tsx 已存在179 行)

Exported APIL169-178

名称 来源 用途
Form re-export of FormProvider from react-hook-form 包在 <form> 外层,传 {...form}
FormField 包装 RHF Controller + 注入 FormFieldContext 等价于 <Controller name=... control=... render=...>
FormItem div + FormItemContext(生成唯一 id 字段 wrapperspace-y-2
FormLabel 基于 <Label> + 自动 htmlFor={formItemId} + error 红色 字段 label
FormControl Radix Slot + 自动注入 id / aria-describedby / aria-invalid 包在 <Input /> 外层
FormDescription <p> 灰色小字 字段下方描述(如"如需更新请重新输入"可用)
FormMessage <p> 红色,自动显示 error.message RHF 错误信息

用法范式(来自 components/users/user-form-dialog.tsx L149-161

<FormField
  control={form.control}
  name="name"
  render={({ field }) => (
    <FormItem>
      <FormLabel>用户名</FormLabel>
      <FormControl>
        <Input placeholder="输入用户名" {...field} />
      </FormControl>
      <FormMessage />
    </FormItem>
  )}
/>

本 phase 命名建议kebab-case

仓库 components/ 下业务子目录的文件全部使用 kebab-case

components/songs/add-song-dialog.tsx
components/songs/song-detail-dialog.tsx
components/outfits/add-outfit-dialog.tsx
components/outfits/add-print-batch-dialog.tsx
components/users/user-form-dialog.tsx
components/permissions/role-dialog.tsx
components/dances/add-dance-dialog.tsx
components/achievements/add-achievement-dialog.tsx
components/food/add-food-dialog.tsx

结论:本 phase 文件命名 components/ai-model/credential-slot-dialog.tsxkebab-case,导出名 CredentialSlotDialogPascalCase 默认或具名导出,沿用 user-form-dialog.tsxexport function UserFormDialog 写法)。

CONTEXT.md L32 写的 CredentialSlotDialog.tsx 是 PascalCase 文件名,本研究强烈建议改为 kebab-case 以保持仓库一致性。

Architecture Patterns

System Architecture Diagram

┌──────────────────────────────────────────────────────────────────────┐
│ User clicks "凭据槽位" Button (page.tsx L36-43)                       │
└──────────────┬───────────────────────────────────────────────────────┘
               │ onClick → setIsCredentialDialogOpen(true)
               ▼
┌──────────────────────────────────────────────────────────────────────┐
│ <CredentialSlotDialog open onOpenChange />                           │
│ ┌──────────────────────────────────────────────────────────────────┐ │
│ │ useEffect on `open===true`                                        │ │
│ │   ↓                                                               │ │
│ │ getCredentialSlot()  ◄── Phase 1 落地, lib/api/credential-slot.ts │ │
│ │   ↓ axios GET /v1/admin/credential-slot/                          │ │
│ │   ↓ 适配 snake_case → camelCase                                   │ │
│ │ slot: CredentialSlot { appId, accessTokenMasked, updatedAt }      │ │
│ │   ↓                                                               │ │
│ │ form.reset({ appId: slot.appId, accessToken: '' })                │ │
│ │ <Input placeholder={slot.accessTokenMasked} />                    │ │
│ └──────────────────────────────────────────────────────────────────┘ │
│                                                                      │
│ User edits → RHF state                                               │
│ User clicks "保存" → form.handleSubmit(onSubmit)                     │
│   ↓ Zod 校验通过accessToken min(1)                                │
│   ↓                                                                  │
│ updateCredentialSlot({ appId, accessToken })  ◄── Phase 1 落地        │
│   ↓ axios PUT /v1/admin/credential-slot/                             │
│   ├─ 成功 → toast.success("凭据槽位已更新") + onOpenChange(false)     │
│   └─ 失败 → handleApiError(e) → toast.error(message)                 │
│                                                                      │
└──────────────────────────────────────────────────────────────────────┘
                              ▲
┌─────────────────────────────┴────────────────────────────────────────┐
│ <Toaster /> from @/components/ui/sonner ◄─ MUST be mounted in        │
│   app/layout.tsx RootLayout (currently MISSING — gap)                │
└──────────────────────────────────────────────────────────────────────┘
components/
└── ai-model/                              # 新建目录(不存在)
    └── credential-slot-dialog.tsx         # 新建文件,~150 行
app/
├── layout.tsx                             # 修改:挂载 <Toaster />
└── ai-model/
    └── page.tsx                           # 修改:删 L473-485 占位 Dialogimport 新组件
docs/
└── 修改记录.md                            # 顶部追加 Phase 3 条目

Pattern 1受控 Dialog + 内部 RHF 状态

WhatDialog 由 page 持有 open / onOpenChange,组件内部持有表单状态。 When to use:本 phase 形态page 级触发 + 子组件管理自身表单)。 Example:见 components/users/user-form-dialog.tsx L57-66 props 形态(本 phase 简化为只有 open + onOpenChange)。

// Source: components/users/user-form-dialog.tsx L65-67
export function UserFormDialog({ open, onOpenChange, /* ... */ }) {
  const [isSubmitting, setIsSubmitting] = useState(false)
  const [dialogOpen, setDialogOpen] = useState(open || false)
  // ...
}

Pattern 2open 变 true 时拉数据 + reset 表单

WhatuseEffect 监听 openopen 时 GET → form.reset(预填值)。 When to use:本 phase 必须用CONTEXT 锁定)。 Example

// Inspired by user-form-dialog.tsx L83-94 form.reset 模式
const [slot, setSlot] = useState<CredentialSlot | null>(null)
const [isLoading, setIsLoading] = useState(false)

useEffect(() => {
  if (!open) return
  let cancelled = false
  setIsLoading(true)
  getCredentialSlot()
    .then((data) => {
      if (cancelled) return
      setSlot(data)
      form.reset({ appId: data.appId, accessToken: "" })
    })
    .catch((e) => {
      if (cancelled) return
      toast.error("加载失败", { description: handleApiError(e) })
    })
    .finally(() => {
      if (!cancelled) setIsLoading(false)
    })
  return () => { cancelled = true }
}, [open])

Pattern 3Sonner 命令式 toast

What:直接 import { toast } from "sonner"toast.success(...) / toast.error(...)Why not useToast:仓库 hooks/use-toast.ts 是 Radix Toast 实现,跟 Sonner 互不通信。Sonner 没有 hook 模式(命令式 APIExample

import { toast } from "sonner"

toast.success("凭据槽位已更新", { description: "配置已生效" })
toast.error("保存失败", { description: handleApiError(e) })

Anti-Patterns to Avoid

  • 使用 hooks/use-toast.tsuseToast() / toast():那是 Radix Toast 实现,跟 CONTEXT 锁定的 Sonner 不通信。
  • @/lib/api barrel import handleApiError:那是同名重复定义(lib/api/index.ts:191CONTEXT 锁定从 @/lib/api/error-handler 引。
  • PascalCase 文件名CredentialSlotDialog.tsx):仓库一致 kebab-case应为 credential-slot-dialog.tsx
  • slot.accessTokenMaskeddefaultValues.accessToken会回写脱敏掩码CONTEXT.md 反复强调),永远默认空串。
  • 关闭对话框时不 reset 表单:会泄漏上次输入的 access_token 到下次打开。
  • submit 后不调 onOpenChange(false)dialog 不会关。
  • 失败时关闭对话框 / 清空表单:违反 CONTEXT.md "失败时不关闭对话框、不清空表单值"。

Don't Hand-Roll

问题 Don't Build Use Instead Why
表单受控状态 / 字段联动 / 错误信息显示 自己写 useState + 手写校验 RHF + Zod + shadcn Form wrapper 现成且仓库已用
异步校验状态 / submitting 状态 自己写 try/finally + setIsSubmitting RHF formState.isSubmitting 或 sibling isSubmitting useState仓库 user-form-dialog 风格) 模板已成熟
Dialog open/close 动画、ESC、焦点陷阱、portal 自己写 shadcn <Dialog> (components/ui/dialog.tsx) Radix 完整封装
Label + input 关联(htmlFor 自己手写 id <FormLabel> + <FormControl>(自动注入 id shadcn Form wrapper 已实现
错误信息红色显示 自己写条件 className <FormMessage /> shadcn Form wrapper 已实现
错误码 → 中文消息映射 自己写 switch handleApiError(e) from lib/api/error-handler.ts CONTEXT 锁定
Toast 队列 / 动画 / 主题 自己写 Sonner toast.success/error/info 已在 deps
API 调用 / token 注入 / 解包 自己写 axios getCredentialSlot() / updateCredentialSlot() from lib/api/credential-slot.ts Phase 1 落地

Key insight:本 phase 几乎所有模式都已在仓库现成,主要工作是拼装而非新造。

Common Pitfalls

Pitfall 1Sonner Toaster 全局未挂载

What goes wrong:调 toast.success(...) 后没有任何视觉反馈,用户以为没保存。 Why it happensapp/layout.tsx20 行)没有渲染任何 Toaster 组件;components/dashboard-shell.tsx 也没有;全仓库 grep <Toaster|<Sonner 仅命中 components/ui/sonner.tsx 自身定义,无任何调用方。 How to avoidplan 必须包含一个 taskapp/layout.tsx <body> 内追加 <Toaster />

// app/layout.tsx
import { Toaster } from "@/components/ui/sonner"
// ...
<body>
  {children}
  <Toaster />
</body>

Warning signs:手动测试 toast 后 console 无 error 但屏幕无任何视觉变化 → Toaster 没挂载。

Pitfall 2误用 hooks/use-toast.tsRadix Toast

What goes wrongimport { useToast } from '@/hooks/use-toast' 然后 const { toast } = useToast(); toast({ title, description }) —— 这是 Radix Toast 实现,跟 Sonner 完全不通信,即使两个 Toaster 都挂载也是各管各的。 Why it happenshooks/use-toast.tscomponents/ui/use-toast.tsshadcn 默认生成的 Radix Toast 模板(看 L6-9 import 的是 @/components/ui/toast,是 Radix 实现);components/ui/sonner.tsx 是后来添加的 Sonner 包装,但仓库内没有任何代码用过它grep 全仓 from "@/components/ui/sonner" 命中 0How to avoid:本 phase 严格 import { toast } from "sonner"(直接用 sonner npm 包),同时必须挂载 <Toaster /> from @/components/ui/sonner

Pitfall 3把脱敏掩码 accessTokenMaskeddefaultValues.accessToken 回写

What goes wrong:用户没改 access_token提交时把 tk_***1234(带 * 的脱敏字符串)当真值发给后端 PUT后端按全字段覆写清空真实 access_tokenWhy it happens:直觉上 GET 返回什么就 reset 什么。 How to avoid

  1. CONTEXT.md 已锁定强制输入 access_tokenZod schema accessToken: z.string().min(1))—— 用户每次都要重输;
  2. form.reset({ appId: slot.appId, accessToken: "" }) —— accessToken 永远默认空串;
  3. placeholder={slot?.accessTokenMasked} —— 仅做视觉提示。

Warning signsgrep defaultValues.*accessTokenMasked 命中 → 错。grep defaultValues.*accessToken: ""accessToken: '' → 对。

Pitfall 4updated_atnew Date() 在服务端渲染时序不一致

What goes wrongNext.js App Router 默认在 server 渲染,new Date(updatedAt).toLocaleString('zh-CN') 在 server 与 client 时区可能不一致 → React hydration 警告。 Why it happens:但本组件已 "use client",且在 useEffect 后才有 slot 数据mounted 守卫),这个 pitfall 本 phase 不适用。但 plan-checker 应注意:组件首行必须 "use client"user-form-dialog.tsx L1 是这么做的)。 How to avoid:组件文件首行 "use client"updatedAt 渲染时用 slot && new Date(slot.updatedAt).toLocaleString('zh-CN')

Pitfall 5Dialog 关闭时不 reset 表单导致 access_token 残留

What goes wrong:用户输了 access_token 但点取消,下次打开 dialog 时 form 仍记着上次输入。 Why it happensRHF useForm 是组件级状态dialog 即使关闭组件也没卸载Dialog 用 portal不卸载子组件How to avoid:参考 user-form-dialog.tsx L110-113handleOpenChange(false)form.reset()

Pitfall 6useEffect open=true 拉数据时如果用户快速关再开 → race condition

What goes wrong:第一次 open 触发的 GET 还没回来,用户已关闭再打开 —— 第二次 GET 也发出,两个 promise 竞态。 How to avoiduseEffect cleanup 用 cancelled flag见 Pattern 2 的 let cancelled = false; return () => { cancelled = true })。

Code Examples

完整组件骨架(基于模板,简化适配本 phase

// components/ai-model/credential-slot-dialog.tsx
"use client"

import { useState, useEffect } from "react"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import * as z from "zod"
import { toast } from "sonner"
import { Loader2 } from "lucide-react"

import { Button } from "@/components/ui/button"
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
} from "@/components/ui/dialog"
import {
  Form,
  FormControl,
  FormDescription,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from "@/components/ui/form"
import { Input } from "@/components/ui/input"
import {
  getCredentialSlot,
  updateCredentialSlot,
  type CredentialSlot,
} from "@/lib/api/credential-slot"
import { handleApiError } from "@/lib/api/error-handler"

const formSchema = z.object({
  appId: z.string().min(1, { message: "App ID 不能为空" }),
  accessToken: z.string().min(1, { message: "请输入 Access Token" }),
})

type FormValues = z.infer<typeof formSchema>

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

export function CredentialSlotDialog({ open, onOpenChange }: CredentialSlotDialogProps) {
  const [slot, setSlot] = useState<CredentialSlot | null>(null)
  const [isLoading, setIsLoading] = useState(false)
  const [isSubmitting, setIsSubmitting] = useState(false)

  const form = useForm<FormValues>({
    resolver: zodResolver(formSchema),
    defaultValues: { appId: "", accessToken: "" },
  })

  useEffect(() => {
    if (!open) return
    let cancelled = false
    setIsLoading(true)
    getCredentialSlot()
      .then((data) => {
        if (cancelled) return
        setSlot(data)
        form.reset({ appId: data.appId, accessToken: "" })
      })
      .catch((e) => {
        if (cancelled) return
        toast.error("加载失败", { description: handleApiError(e) })
      })
      .finally(() => {
        if (!cancelled) setIsLoading(false)
      })
    return () => {
      cancelled = true
    }
  }, [open, form])

  const handleOpenChange = (next: boolean) => {
    onOpenChange(next)
    if (!next) {
      form.reset({ appId: "", accessToken: "" })
      setSlot(null)
    }
  }

  const handleSubmit = async (values: FormValues) => {
    setIsSubmitting(true)
    try {
      await updateCredentialSlot({
        appId: values.appId,
        accessToken: values.accessToken,
      })
      toast.success("凭据槽位已更新", { description: "配置已生效" })
      handleOpenChange(false)
    } catch (e) {
      toast.error("保存失败", { description: handleApiError(e) })
      // 失败时不关闭对话框、不清空表单值
    } finally {
      setIsSubmitting(false)
    }
  }

  return (
    <Dialog open={open} onOpenChange={handleOpenChange}>
      <DialogContent className="sm:max-w-[500px]">
        <DialogHeader>
          <DialogTitle>通用凭据槽位</DialogTitle>
          <DialogDescription>
            管理 APP ID  Access Token;提交将全字段覆写后端记录。
          </DialogDescription>
        </DialogHeader>
        {isLoading ? (
          <div className="flex items-center justify-center py-8">
            <Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
          </div>
        ) : (
          <Form {...form}>
            <form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4 py-2">
              <FormField
                control={form.control}
                name="appId"
                render={({ field }) => (
                  <FormItem>
                    <FormLabel>APP ID</FormLabel>
                    <FormControl>
                      <Input placeholder="输入 APP ID" {...field} />
                    </FormControl>
                    <FormMessage />
                  </FormItem>
                )}
              />
              <FormField
                control={form.control}
                name="accessToken"
                render={({ field }) => (
                  <FormItem>
                    <FormLabel>Access Token</FormLabel>
                    <FormControl>
                      <Input
                        placeholder={slot?.accessTokenMasked ?? "输入 Access Token"}
                        {...field}
                      />
                    </FormControl>
                    <FormDescription>
                      每次保存都需要重新输入 Access Token(不会显示原值,避免回写脱敏掩码)
                    </FormDescription>
                    <FormMessage />
                  </FormItem>
                )}
              />
              {slot && (
                <p className="text-sm text-muted-foreground">
                  最后更新:{new Date(slot.updatedAt).toLocaleString("zh-CN")}
                </p>
              )}
              <DialogFooter>
                <Button
                  type="button"
                  variant="outline"
                  onClick={() => handleOpenChange(false)}
                  disabled={isSubmitting}
                >
                  取消
                </Button>
                <Button type="submit" disabled={isSubmitting}>
                  {isSubmitting ? (
                    <>
                      <Loader2 className="mr-2 h-4 w-4 animate-spin" />
                      保存中...
                    </>
                  ) : (
                    "保存"
                  )}
                </Button>
              </DialogFooter>
            </form>
          </Form>
        )}
      </DialogContent>
    </Dialog>
  )
}

app/ai-model/page.tsx 改动定位

Phase 2 占位 Dialog 当前位置

行号 内容
占位 Dialog 起始 L473 <Dialog open={isCredentialDialogOpen} onOpenChange={setIsCredentialDialogOpen}>
DialogContent L477
DialogTitle L479 通用凭据槽位
DialogDescription L480-482 对话框真实内容由 Phase 3 落地
占位 Dialog 结束 L485 </Dialog>

改动 1:删除 L473-48513 行)。 改动 2:替换为 <CredentialSlotDialog open={isCredentialDialogOpen} onOpenChange={setIsCredentialDialogOpen} />改动 3:删除 L9-15 的 Dialog 命名导入(Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle)—— 不再使用。 改动 4:在 import 块L1-17 区域)新增:

import { CredentialSlotDialog } from "@/components/ai-model/credential-slot-dialog"

保留

  • L1 "use client" —— 不动
  • L20 useState/useEffect —— 不动mounted state 仍需要)
  • L21 isCredentialDialogOpen state —— 不动
  • L23-25 mounted useEffect —— 不动
  • L16 KeyRound 已在 lucide-react import —— 仍在用Button 图标),不动
  • L35-43 凭据槽位 Button —— 不动

Loader2 import:是否需要 page.tsx 加? 答:不需要。Loader2 仅在新组件 credential-slot-dialog.tsx 内使用page.tsx 不会用到。

State of the Art

Old Approach Current Approach When Changed Impact
仓库内 useState + 手写校验(add-song-dialog.tsx / add-outfit-dialog.tsx RHF + Zod + shadcn Form wrapperuser-form-dialog.tsx / role-dialog.tsx 仓库内并存(前者更早,后者后期) 本 phase 用后者CONTEXT 锁定)
Radix Toast (hooks/use-toast.ts) Sonnercomponents/ui/sonner.tsx 仓库内并存Sonner 后加但未使用) 本 phase 用 SonnerCONTEXT 锁定) + 必须先挂载 <Toaster />

Deprecated/outdated

  • hooks/use-toast.ts + components/ui/use-toast.ts:两份完全相同的 Radix Toast 实现295 行 dead code本 phase 不动它们但应该在未来 milestone 收敛)。
  • components/ui/toaster.tsxRadix Toast 渲染容器,也未挂载,全仓 grep <Toaster 命中 0。
  • lib/api/index.ts:191handleApiError:与 lib/api/error-handler.ts:38 重复定义,未来应收敛。

Runtime State Inventory

本 phase 是新建组件 + 修改一行 import rename / refactor / migration。Skip 该 section 大部分内容。

Category Items Found Action Required
Stored data None — 凭据槽位本身的存储是后端 Phase 1 已落地的 DB 单行,前端不持久化任何东西 None
Live service config None None
OS-registered state None None
Secrets/env vars NEXT_PUBLIC_API_BASE_URL 仍是凭据 API 的 base URL本 phase 不改 None
Build artifacts None — 新增组件文件 + 修改 page.tsxTS 编译产物会自动更新 npx tsc --noEmit 验证

Project Constraints (from CLAUDE.md)

Constraint Source Apply to Phase 3
中文沟通 / 注释 / commit message / 最终回答 CLAUDE.md L98 表单文案、toast 文案、注释、commit message 全部中文(已锁定)
不混用包管理器package-lock.json + pnpm-lock.yaml + yarn.lock 并存) CLAUDE.md L99 不动 lockfile不引入新依赖
shadcn 组件可直接修改源码 CLAUDE.md L100 本 phase 不需要修改 shadcn 组件form / dialog / input 都已成熟)
修改后必须在同一会话追加 docs/修改记录.md 顶部 CLAUDE.md L72-82 plan 必须包含"在 docs/修改记录.md 顶部追加 Phase 3 条目"task
跨项目联动需在两端各写一条修改记录互相引用 CLAUDE.md L84-88 本 phase 是纯前端,触发跨项目联动CONTEXT.md 已说明端到端测试依赖后端 Phase 2但前端代码层不阻塞修改记录条目「跨项目联动」段写「无 / 待评估」
API 契约改动需双端同步 CLAUDE.md L65-68 本 phase 不改 API 契约(消费 Phase 1 已落地的 contract
业务代码 / 配置 / package.json 必须记录 CLAUDE.md L92-94 修改 app/ai-model/page.tsx + 新增 components/ai-model/credential-slot-dialog.tsx + 修改 app/layout.tsx 都必须记

Environment Availability

本 phase 纯代码改动 + 类型检查,无需外部工具/服务依赖(除 Node.js + npm已就绪

Dependency Required By Available Version Fallback
Node.js + npm npm install / npx tsc ✓(仓库已运行 Phase 1 + 2
TypeScript npx tsc --noEmit 验证 ^5package.json devDep
react-hook-form dialog 组件 latest
@hookform/resolvers zodResolver latest
zod schema latest
sonner toast ^1.7.1
lucide-react Loader2 / KeyRound ^0.454.0
@radix-ui/react-dialog Dialog ^1.1.4
后端 qy_lty Phase 2GET/PUT /api/v1/admin/credential-slot/ 端到端联调 🟡 看后端 milestone 状态 用 mock 或推迟到联调阶段;前端代码层 npx tsc + 单元 grep 验证不阻塞

Missing dependencies with no fallback:无。 Missing dependencies with fallback:后端 Phase 2 联调 —— 前端代码层用程序化验证npx tsc + grep通过即可端到端测试推迟。

Security Domain

Phase 3 涉及凭据 (Access Token) 编辑表单 —— 必须考虑前端 ASVS。

Applicable ASVS Categories

ASVS Category Applies Standard Control
V2 Authentication yes Phase 2 已落地 RBAChasPermission('credential-slot')),但 CONCERNS.md 提示后端独立校验闭环 PERM-06 仍待审计
V3 Session Management no本 phase 不动 token 存储)
V4 Access Control yes 入口 Button 受 hasPermission 收敛,未授权账户 DOM 中不渲染Phase 2 已落地)—— 但客户端校验仅 UI 礼貌,真实安全靠后端
V5 Input Validation yes Zod schemaappId.min(1) + accessToken.min(1) —— 仅做"非空"硬要求;其他格式(如 token 长度上下限)后端校验
V6 Cryptography no本 phase 不加密 / 不哈希)
V8 Data Protection yes access_token 输入不加 type="password"CONTEXT 选择,运营要看自己输入);不打 console.log不把真值存 localStorage / sessionStorage / cookie
V14 Configuration no

Known Threat Patterns for 本 stack

Pattern STRIDE Standard Mitigation
把脱敏掩码当真值回写 Tampering污染数据 CONTEXT 锁定 accessToken.min(1) 强制重输defaultValues.accessToken 永远空串placeholder 仅做视觉提示
Toast / 表单字段无意中泄露 access_token 真值 Information Disclosure toast 描述只写"配置已生效" / 错误用 handleApiError 中文映射,不要 JSON.stringify(payload) 进 toast 或 console
RBAC 仅前端校验导致绕过 Elevation of Privilege CONCERNS.md 已记 PERM-06 候选 milestone后端独立校验—— 不在本 phase 范围
API 401 自动跳登录 Repudiation / Authentication Phase 0 落地的 axios response interceptor 已处理(清空 token + 重定向 /login),本 phase 沿用,无需新增
表单 XSS用户在 access_token 输了 <script> Tampering React 默认 escapeshadcn Input 是受控的;不 dangerouslySetInnerHTML安全

Assumptions Log

# Claim Section Risk if Wrong
A1 Sonner Toaster 全局未挂载 Common Pitfalls / Pitfall 1 [VERIFIEDgrep <Toaster|<Sonner 全仓命中 0read app/layout.tsx20 行确认无read components/dashboard-shell.tsx 确认无] —— 不是假设,已验证
A2 accessToken.min(1) 已是 CONTEXT.md 锁定决策 User Constraints [VERIFIEDCONTEXT.md L150-154]
A3 app/ai-model/page.tsx 占位 Dialog 在 L473-485 Code Examples [VERIFIEDread 全文确认]
A4 components/ai-model/ 目录不存在 Recommended Project Structure [VERIFIEDls components/ai-model/ 退出码 2明确"No such file or directory"]
A5 仓库 components 子目录命名约定为 kebab-case RHF + Zod 模板 / 命名建议 [VERIFIEDls components/songs/ + ls components/outfits/ + 全仓 grep 现有所有业务对话框文件名]
A6 hooks/use-toast.tscomponents/ui/use-toast.ts 内容相同 Common Pitfalls / Pitfall 2 [VERIFIEDread 两份顶部 30 行,结构与 import 完全一致]
A7 handleApiError(error: unknown): stringlib/api/error-handler.ts:38 的签名 Standard Stack [VERIFIEDread L38 确认 export const handleApiError = (error: unknown): string =>]
A8 lib/api/index.ts:191 也有同名 handleApiError 但签名为 (error: any) => string Anti-Patterns [VERIFIEDread L191-196]
A9 Phase 1 落地的 getCredentialSlot() / updateCredentialSlot() 类型签名 Code Examples [VERIFIEDread lib/api/credential-slot.ts 全文]
A10 useToast() 仓库形态是 Radix 实现(非 Sonner / 非混合) useToast/toast API [VERIFIEDread hooks/use-toast.ts 全文 195 行 —— import type { ToastActionElement, ToastProps } from "@/components/ui/toast" L6-9 明确是 Radix Toast wrapper无 sonner import]

结论:所有关键发现都已通过 grep + read 验证,没有 ASSUMED 类断言

Open Questions

  1. PascalCase vs kebab-case 文件命名最终决定

    • What we know仓库现有约定一致 kebab-case
    • What's unclearCONTEXT.md L32 写的是 CredentialSlotDialog.tsxPascalCase—— 这是 user 提供给 CONTEXT 时的 PascalCase 写法,但同时在 Discretion 段L183-184明确说"researcher 决定"
    • Recommendationkebab-case credential-slot-dialog.tsx(与 user-form-dialog.tsx / role-dialog.tsx / add-outfit-dialog.tsx 等 9 个现有业务对话框保持一致)
  2. Sonner Toaster 挂载位置

    • What we know必须挂载否则 toast 不显示
    • What's unclear挂载到 app/layout.tsx <body> 内还是 components/dashboard-shell.tsx 内?
    • Recommendation挂在 app/layout.tsx <body> 末尾(最高层、覆盖所有路由;与 next-themes ThemeProvider 同级 —— 但本仓 layout.tsx 当前就 20 行裸结构没用 ThemeProvider可只加 Toaster
  3. 是否同时挂载 Radix Toast <Toaster />(来自 components/ui/toaster

    • What we knowhooks/use-toast.ts 是 Radix Toast 实现且已被 add-dance-dialog.tsx 等使用grep 命中 9 文件),但其 Toaster 也未挂载 → 这些 toast 调用本身就是 dead code
    • What's unclear本 phase 是只挂 Sonner还是顺手把 Radix Toaster 也挂上修复其他 dead code
    • Recommendation只挂 Sonner。修复 Radix Toast dead code 是范围外CONTEXT 明确锁定本 phase 用 Sonner。这个发现可在 CONCERNS.md 候选清单中记一笔。
  4. updated_at 时间格式

    • What we knowCONTEXT 提了两个选项 —— toLocaleString('zh-CN')date-fns
    • What's unclear哪个更符合仓库约定
    • RecommendationtoLocaleString('zh-CN')(无新依赖、零成本;date-fns 虽在 deps 但本场景无需复杂格式化;如果将来需要相对时间"3 分钟前"再切 date-fns
  5. 是否做加载态骨架/spinner

    • What we knowCONTEXT 没明说
    • Recommendation简单 <Loader2 className="animate-spin" /> 居中显示(已在 Code Examples 中给出);不做骨架屏(过度工程)

Sources

Primary (HIGH confidence — 全部仓库内 grep + read)

  • C:\Users\admin\Desktop\Lila-Server\qy-lty-admin\components\users\user-form-dialog.tsx —— RHF + Zod 首选 1:1 模板289 行)
  • C:\Users\admin\Desktop\Lila-Server\qy-lty-admin\components\permissions\role-dialog.tsx —— RHF + Zod 第二模板425 行,含动态字段)
  • C:\Users\admin\Desktop\Lila-Server\qy-lty-admin\components\ui\form.tsx —— shadcn Form wrapper179 行)
  • C:\Users\admin\Desktop\Lila-Server\qy-lty-admin\components\ui\dialog.tsx —— Radix Dialog wrapper
  • C:\Users\admin\Desktop\Lila-Server\qy-lty-admin\components\ui\input.tsx —— shadcn Input
  • C:\Users\admin\Desktop\Lila-Server\qy-lty-admin\components\ui\label.tsx —— shadcn Label
  • C:\Users\admin\Desktop\Lila-Server\qy-lty-admin\components\ui\sonner.tsx —— Sonner Toaster wrapper未挂载
  • C:\Users\admin\Desktop\Lila-Server\qy-lty-admin\hooks\use-toast.ts —— Radix Toast hook不用仅作识别参考
  • C:\Users\admin\Desktop\Lila-Server\qy-lty-admin\lib\api\error-handler.ts —— handleApiError L38
  • C:\Users\admin\Desktop\Lila-Server\qy-lty-admin\lib\api\credential-slot.ts —— Phase 1 落地的 API 客户端
  • C:\Users\admin\Desktop\Lila-Server\qy-lty-admin\lib\api\index.ts L191 —— 重复 handleApiError 定义
  • C:\Users\admin\Desktop\Lila-Server\qy-lty-admin\app\ai-model\page.tsx —— Phase 2 落地的页面(占位 Dialog L473-485
  • C:\Users\admin\Desktop\Lila-Server\qy-lty-admin\app\layout.tsx —— 20 行裸 RootLayoutToaster 未挂
  • C:\Users\admin\Desktop\Lila-Server\qy-lty-admin\package.json —— deps 版本验证
  • C:\Users\admin\Desktop\Lila-Server\qy-lty-admin\.planning\phases\03-dialog-feedback\03-CONTEXT.md —— 锁定决策来源
  • C:\Users\admin\Desktop\Lila-Server\qy-lty-admin\.planning\REQUIREMENTS.md CRED-FE-04 + CRED-FE-05
  • C:\Users\admin\Desktop\Lila-Server\qy-lty-admin\.planning\ROADMAP.md Phase 3 success criteria
  • C:\Users\admin\Desktop\Lila-Server\qy-lty-admin\CLAUDE.md 项目宪法

Secondary (MEDIUM confidence)

  • 无 —— 全部基于本仓库代码,无外部源

Tertiary (LOW confidence)

Metadata

Confidence breakdown:

  • Standard stack: HIGH —— 全部 deps 在 package.json 已验证版本
  • Architecture: HIGH —— 1:1 模板 user-form-dialog.tsx 已存在并验证可读
  • Pitfalls: HIGH —— Toaster 未挂载、双 use-toast、双 handleApiError 都是 grep + read 验证的硬事实
  • Toast / Sonner gap: HIGH这是 critical finding —— planner 必须 surface

Research date2026-05-08 Valid until2026-06-0730 天,仓库内代码稳定,外部依赖无版本变更)


Phase: 03-dialog-feedback Researcher: gsd-researcher基于 CONTEXT.md 锁定决策 + 仓库 grep + read 全验证)