# 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`（PascalCase）vs `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`] |

### 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：

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 占位 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`）。

```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 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**：

```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 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 />`**：

```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.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**：
1. CONTEXT.md 已锁定**强制输入** access_token（Zod 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 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）

```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-485（13 行）。
**改动 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 用 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

1. **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 个现有业务对话框保持一致）

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 know：CONTEXT 提了两个选项 —— `toLocaleString('zh-CN')` 或 `date-fns`
   - What's unclear：哪个更符合仓库约定？
   - Recommendation：**`toLocaleString('zh-CN')`**（无新依赖、零成本；`date-fns` 虽在 deps 但本场景无需复杂格式化；如果将来需要相对时间"3 分钟前"再切 date-fns）

5. **是否做加载态骨架/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 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-07（30 天，仓库内代码稳定，外部依赖无版本变更）

---

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