846 lines
47 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 接口**
```typescript
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.tsx`PascalCasevs `credential-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`] |
### 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.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
1. **`components/users/user-form-dialog.tsx`**289 行)—— **首选 1:1 模板**
2. **`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 引用)**
```typescript
// 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**
```tsx
<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 占位 Dialogimport 新组件
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`)。
```typescript
// 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**
```typescript
// 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 模式(命令式 API
**Example**
```typescript
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/api` barrel import `handleApiError`**:那是同名重复定义(`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 1Sonner 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 />`**
```tsx
// 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 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**
1. CONTEXT.md 已锁定**强制输入** access_tokenZod schema `accessToken: z.string().min(1)`)—— 用户每次都要重输;
2. `form.reset({ appId: slot.appId, accessToken: "" })` —— accessToken 永远默认空串;
3. `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 5Dialog 关闭时不 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 6useEffect 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
```typescript
// 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 区域)新增:
```tsx
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 wrapper`user-form-dialog.tsx` / `role-dialog.tsx` | 仓库内并存(前者更早,后者后期) | 本 phase 用后者CONTEXT 锁定) |
| Radix Toast (`hooks/use-toast.ts`) | Sonner`components/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.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.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` 验证 | ✓ | `^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 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 已落地 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 默认 escapeshadcn Input 是受控的;不 dangerouslySetInnerHTML安全 |
## Assumptions Log
| # | Claim | Section | Risk if Wrong |
|---|-------|---------|---------------|
| A1 | Sonner Toaster 全局未挂载 | Common Pitfalls / Pitfall 1 | [VERIFIEDgrep `<Toaster\|<Sonner` 全仓命中 0read `app/layout.tsx`20 行确认无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 | [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 | [VERIFIEDread 两份顶部 30 行,结构与 import 完全一致] |
| A7 | `handleApiError(error: unknown): string``lib/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.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 个现有业务对话框保持一致)
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 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 候选清单中记一笔。
4. **`updated_at` 时间格式**
- What we knowCONTEXT 提了两个选项 —— `toLocaleString('zh-CN')``date-fns`
- What's unclear哪个更符合仓库约定
- Recommendation**`toLocaleString('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 行裸 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.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 date**2026-05-08
**Valid until**2026-06-0730 天,仓库内代码稳定,外部依赖无版本变更)
---
*Phase: 03-dialog-feedback*
*Researcher: gsd-researcher基于 CONTEXT.md 锁定决策 + 仓库 grep + read 全验证)*