47 KiB
Phase 3:编辑对话框 + 提交反馈 - Research
调研日期:2026-05-08 Domain:Next.js 15 (App Router) + React 19 + RHF + Zod + shadcn UI + Sonner(凭据槽位编辑对话框) Confidence:HIGH(全部基于本仓库已落地代码 grep + read,无需外部文档查证)
Summary
本 phase 抽离独立组件 components/ai-model/credential-slot-dialog.tsx,把 Phase 2 落地的占位 Dialog(app/ai-model/page.tsx L473-485)替换为完整功能的编辑对话框:基于 React Hook Form + Zod 表单、调 getCredentialSlot() 预填、提交走 updateCredentialSlot()、成功/失败用 toast 反馈、错误经 handleApiError 映射。
所有依赖均已在 deps 中(react-hook-form、@hookform/resolvers、zod、sonner、lucide-react、shadcn UI 组件),不需要新增任何依赖。
仓库内已有两个高质量 RHF + Zod 1:1 模板可直接对照:components/users/user-form-dialog.tsx(更贴近本 phase 的 single-dialog + 表单 + submit pattern)与 components/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.tsx(kebab-case 命名,与 add-outfit-dialog.tsx / add-song-dialog.tsx / user-form-dialog.tsx / role-dialog.tsx 保持一致),表单两个字段 appId + accessToken,access_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:38 与 lib/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/api 引(barrel 里那个是同名重复定义,存在 namespace 歧义风险)。
⚠️ 预警 3:仓库 hooks/use-toast.ts 与 components/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()appIdfield 默认值 =slot.appId(明文)accessTokenfield 默认值 =''(不是 masked!)- input placeholder =
slot.accessTokenMasked - 提示文字"如需更新请重新输入"
updated_at只读显示
提交逻辑(CONTEXT 最终决策):
- access_token 强制输入:
z.string().min(1, '请输入 Access Token') - 退化为"每次保存都要重输 access_token",UX 略差但语义正确(不会回写脱敏掩码)
docs/修改记录.mdPhase 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.tsx(PascalCase)vscredential-slot-dialog.tsx(kebab-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.ts);Phase 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] |
Supporting(shadcn UI 组件,全部已存在)
| 文件 | 是否存在 | 用途 |
|---|---|---|
components/ui/dialog.tsx |
✅ | Dialog / DialogContent / DialogHeader / DialogTitle / DialogDescription / DialogFooter |
components/ui/form.tsx |
✅ | shadcn Form wrapper(Form / FormField / FormItem / FormLabel / FormControl / FormMessage / FormDescription)—— 详见下文 |
components/ui/input.tsx |
✅ | Input(forwardRef,受控) |
components/ui/label.tsx |
✅ | Label(基于 Radix LabelPrimitive.Root) |
components/ui/button.tsx |
✅ | Button |
components/ui/sonner.tsx |
✅ | Sonner <Toaster /> 包装(未在 layout 中挂载,gap) |
Alternatives Considered(CONTEXT 已锁定决策,仅供 plan-checker 验证)
| Instead of | Could Use | Why we don't |
|---|---|---|
| Sonner toast | hooks/use-toast.ts(Radix Toast) |
CONTEXT 锁定 Sonner,且 Radix <Toaster /> 同样未挂载,无优势 |
受控 useState(如 add-song-dialog.tsx / add-outfit-dialog.tsx 风格) |
RHF + Zod | CONTEXT 锁定 RHF + Zod;现有 user-form-dialog.tsx / role-dialog.tsx 已是该风格的 in-house 模板 |
barrel 路径 @/lib/api 的 handleApiError |
lib/api/error-handler.ts 的版本 |
CONTEXT 锁定后者;二者实现相同但显式路径避免歧义 |
安装:npm install —— 无新增依赖。
RHF + Zod 1:1 模板(关键发现)
现有 RHF + Zod 用法 grep 全仓结果
仅 2 个文件 同时含 useForm + zodResolver + zod(grep useForm.*zodResolver head_limit:
components/users/user-form-dialog.tsx(289 行)—— 首选 1:1 模板components/permissions/role-dialog.tsx(425 行)—— 含动态字段,过于复杂,参考即可
首选模板形态: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 API(L169-178):
| 名称 | 来源 | 用途 |
|---|---|---|
Form |
re-export of FormProvider from react-hook-form |
包在 <form> 外层,传 {...form} |
FormField |
包装 RHF Controller + 注入 FormFieldContext |
等价于 <Controller name=... control=... render=...> |
FormItem |
div + FormItemContext(生成唯一 id) |
字段 wrapper(space-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.tsx(kebab-case),导出名 CredentialSlotDialog(PascalCase 默认或具名导出,沿用 user-form-dialog.tsx 的 export 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) │
└──────────────────────────────────────────────────────────────────────┘
Recommended Project Structure
components/
└── ai-model/ # 新建目录(不存在)
└── credential-slot-dialog.tsx # 新建文件,~150 行
app/
├── layout.tsx # 修改:挂载 <Toaster />
└── ai-model/
└── page.tsx # 修改:删 L473-485 占位 Dialog,import 新组件
docs/
└── 修改记录.md # 顶部追加 Phase 3 条目
Pattern 1:受控 Dialog + 内部 RHF 状态
What:Dialog 由 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 2:open 变 true 时拉数据 + reset 表单
What:useEffect 监听 open,open 时 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 3:Sonner 命令式 toast
What:直接 import { toast } from "sonner" 调 toast.success(...) / toast.error(...)。
Why not useToast:仓库 hooks/use-toast.ts 是 Radix Toast 实现,跟 Sonner 互不通信。Sonner 没有 hook 模式(命令式 API)。
Example:
import { toast } from "sonner"
toast.success("凭据槽位已更新", { description: "配置已生效" })
toast.error("保存失败", { description: handleApiError(e) })
Anti-Patterns to Avoid
- 使用
hooks/use-toast.ts的useToast()/toast():那是 Radix Toast 实现,跟 CONTEXT 锁定的 Sonner 不通信。 - 从
@/lib/apibarrel importhandleApiError:那是同名重复定义(lib/api/index.ts:191),CONTEXT 锁定从@/lib/api/error-handler引。 - PascalCase 文件名(
CredentialSlotDialog.tsx):仓库一致 kebab-case,应为credential-slot-dialog.tsx。 - 把
slot.accessTokenMasked当defaultValues.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 1:Sonner Toaster 全局未挂载
What goes wrong:调 toast.success(...) 后没有任何视觉反馈,用户以为没保存。
Why it happens:app/layout.tsx(20 行)没有渲染任何 Toaster 组件;components/dashboard-shell.tsx 也没有;全仓库 grep <Toaster|<Sonner 仅命中 components/ui/sonner.tsx 自身定义,无任何调用方。
How to avoid:plan 必须包含一个 task:在 app/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.ts(Radix Toast)
What goes wrong:import { useToast } from '@/hooks/use-toast' 然后 const { toast } = useToast(); toast({ title, description }) —— 这是 Radix Toast 实现,跟 Sonner 完全不通信,即使两个 Toaster 都挂载也是各管各的。
Why it happens:hooks/use-toast.ts 与 components/ui/use-toast.ts 是 shadcn 默认生成的 Radix Toast 模板(看 L6-9 import 的是 @/components/ui/toast,是 Radix 实现);components/ui/sonner.tsx 是后来添加的 Sonner 包装,但仓库内没有任何代码用过它(grep 全仓 from "@/components/ui/sonner" 命中 0)。
How to avoid:本 phase 严格 import { toast } from "sonner"(直接用 sonner npm 包),同时必须挂载 <Toaster /> from @/components/ui/sonner。
Pitfall 3:把脱敏掩码 accessTokenMasked 当 defaultValues.accessToken 回写
What goes wrong:用户没改 access_token,提交时把 tk_***1234(带 * 的脱敏字符串)当真值发给后端 PUT,后端按全字段覆写清空真实 access_token。
Why it happens:直觉上 GET 返回什么就 reset 什么。
How to avoid:
- CONTEXT.md 已锁定强制输入 access_token(Zod schema
accessToken: z.string().min(1))—— 用户每次都要重输; form.reset({ appId: slot.appId, accessToken: "" })—— accessToken 永远默认空串;placeholder={slot?.accessTokenMasked}—— 仅做视觉提示。
Warning signs:grep defaultValues.*accessTokenMasked 命中 → 错。grep defaultValues.*accessToken: "" 或 accessToken: '' → 对。
Pitfall 4:updated_at 用 new Date() 在服务端渲染时序不一致
What goes wrong:Next.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 5:Dialog 关闭时不 reset 表单导致 access_token 残留
What goes wrong:用户输了 access_token 但点取消,下次打开 dialog 时 form 仍记着上次输入。
Why it happens:RHF useForm 是组件级状态,dialog 即使关闭组件也没卸载(Dialog 用 portal,不卸载子组件)。
How to avoid:参考 user-form-dialog.tsx L110-113,在 handleOpenChange(false) 时 form.reset()。
Pitfall 6:useEffect open=true 拉数据时如果用户快速关再开 → race condition
What goes wrong:第一次 open 触发的 GET 还没回来,用户已关闭再打开 —— 第二次 GET 也发出,两个 promise 竞态。
How to avoid:useEffect 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-485(13 行)。
改动 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
isCredentialDialogOpenstate —— 不动 - 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 wrapper(user-form-dialog.tsx / role-dialog.tsx) |
仓库内并存(前者更早,后者后期) | 本 phase 用后者(CONTEXT 锁定) |
Radix Toast (hooks/use-toast.ts) |
Sonner(components/ui/sonner.tsx) |
仓库内并存(Sonner 后加但未使用) | 本 phase 用 Sonner(CONTEXT 锁定) + 必须先挂载 <Toaster /> |
Deprecated/outdated:
hooks/use-toast.ts+components/ui/use-toast.ts:两份完全相同的 Radix Toast 实现(295 行 dead code,本 phase 不动它们但应该在未来 milestone 收敛)。components/ui/toaster.tsx:Radix Toast 渲染容器,也未挂载,全仓 grep<Toaster命中 0。lib/api/index.ts:191的handleApiError:与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.tsx,TS 编译产物会自动更新 | 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 验证 |
✓ | ^5(package.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 2(GET/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 已落地 RBAC(hasPermission('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 schema:appId.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 默认 escape;shadcn Input 是受控的;不 dangerouslySetInnerHTML,安全 |
Assumptions Log
| # | Claim | Section | Risk if Wrong |
|---|---|---|---|
| A1 | Sonner Toaster 全局未挂载 | Common Pitfalls / Pitfall 1 | [VERIFIED:grep <Toaster|<Sonner 全仓命中 0;read app/layout.tsx(20 行)确认无;read components/dashboard-shell.tsx 确认无] —— 不是假设,已验证 |
| A2 | accessToken.min(1) 已是 CONTEXT.md 锁定决策 |
User Constraints | [VERIFIED:CONTEXT.md L150-154] |
| A3 | app/ai-model/page.tsx 占位 Dialog 在 L473-485 |
Code Examples | [VERIFIED:read 全文确认] |
| A4 | components/ai-model/ 目录不存在 |
Recommended Project Structure | [VERIFIED:ls components/ai-model/ 退出码 2,明确"No such file or directory"] |
| A5 | 仓库 components 子目录命名约定为 kebab-case | RHF + Zod 模板 / 命名建议 | [VERIFIED:ls components/songs/ + ls components/outfits/ + 全仓 grep 现有所有业务对话框文件名] |
| A6 | hooks/use-toast.ts 与 components/ui/use-toast.ts 内容相同 |
Common Pitfalls / Pitfall 2 | [VERIFIED:read 两份顶部 30 行,结构与 import 完全一致] |
| A7 | handleApiError(error: unknown): string 是 lib/api/error-handler.ts:38 的签名 |
Standard Stack | [VERIFIED:read L38 确认 export const handleApiError = (error: unknown): string =>] |
| A8 | lib/api/index.ts:191 也有同名 handleApiError 但签名为 (error: any) => string |
Anti-Patterns | [VERIFIED:read L191-196] |
| A9 | Phase 1 落地的 getCredentialSlot() / updateCredentialSlot() 类型签名 |
Code Examples | [VERIFIED:read lib/api/credential-slot.ts 全文] |
| A10 | useToast() 仓库形态是 Radix 实现(非 Sonner / 非混合) |
useToast/toast API | [VERIFIED:read 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
-
PascalCase vs kebab-case 文件命名最终决定
- What we know:仓库现有约定一致 kebab-case
- What's unclear:CONTEXT.md L32 写的是
CredentialSlotDialog.tsx(PascalCase)—— 这是 user 提供给 CONTEXT 时的 PascalCase 写法,但同时在 Discretion 段(L183-184)明确说"researcher 决定" - Recommendation:kebab-case
credential-slot-dialog.tsx(与user-form-dialog.tsx/role-dialog.tsx/add-outfit-dialog.tsx等 9 个现有业务对话框保持一致)
-
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)
-
是否同时挂载 Radix Toast
<Toaster />(来自components/ui/toaster)- What we know:
hooks/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 候选清单中记一笔。
- What we know:
-
updated_at时间格式- What we know:CONTEXT 提了两个选项 ——
toLocaleString('zh-CN')或date-fns - What's unclear:哪个更符合仓库约定?
- Recommendation:
toLocaleString('zh-CN')(无新依赖、零成本;date-fns虽在 deps 但本场景无需复杂格式化;如果将来需要相对时间"3 分钟前"再切 date-fns)
- What we know:CONTEXT 提了两个选项 ——
-
是否做加载态骨架/spinner
- What we know:CONTEXT 没明说
- 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 wrapper(179 行)C:\Users\admin\Desktop\Lila-Server\qy-lty-admin\components\ui\dialog.tsx—— Radix Dialog wrapperC:\Users\admin\Desktop\Lila-Server\qy-lty-admin\components\ui\input.tsx—— shadcn InputC:\Users\admin\Desktop\Lila-Server\qy-lty-admin\components\ui\label.tsx—— shadcn LabelC:\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——handleApiErrorL38C:\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.tsL191 —— 重复 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 行裸 RootLayout(Toaster 未挂)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.mdCRED-FE-04 + CRED-FE-05C:\Users\admin\Desktop\Lila-Server\qy-lty-admin\.planning\ROADMAP.mdPhase 3 success criteriaC:\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 date:2026-05-08 Valid until:2026-06-07(30 天,仓库内代码稳定,外部依赖无版本变更)
Phase: 03-dialog-feedback Researcher: gsd-researcher(基于 CONTEXT.md 锁定决策 + 仓库 grep + read 全验证)