# Phase 3：编辑对话框 + 提交反馈 - Context

**Gathered**: 2026-05-08
**Status**: Ready for planning（用户选择 `--skip-ui` 跳过 UI-SPEC，直接规划；与 Phase 2 同模式）
**Source**: 用户在 `/gsd-plan-phase 3` 调用时提供的内联约束

<domain>
## Phase 边界

本 phase 是 Milestone v1.0 前端集成的**收尾 phase**，把 Phase 2 落地的占位 Dialog 替换为完整功能的编辑对话框 + Sonner toast 反馈：
- 抽离 `components/ai-model/CredentialSlotDialog.tsx` 独立组件
- 表单 React Hook Form + Zod；预填态从 `getCredentialSlot()` 拉取
- 关键业务规则：**留空保留旧值** —— 用户不输入 access_token 时不提交它，避免把脱敏掩码当真值回写
- 仅提交用户实际改动的字段（部分载荷）
- 成功 / 失败 toast 反馈

**不负责**（推迟到下一周期）：
- 真实生产部署 / 端到端浏览器测试（项目无 E2E 框架）
- token 轮换 / refresh token
- DB at-rest 加密
- 其他 brownfield 候选优先级（PERM-06 后端独立校验闭环 / 多 lockfile 收敛 / Vitest 测试基础设施 等）

**Milestone v1.0 收尾**：本 phase 完成后 Milestone v1.0 全部 11 个需求（CRED-01~06 后端 + CRED-FE-01~05 前端）100% 交付。

</domain>

<decisions>
## 实现决策（锁定）

### 组件抽离

- **新建**：`components/ai-model/credential-slot-dialog.tsx`（researcher 修正：仓库 9 个现有业务对话框全部 **kebab-case**，如 `add-song-dialog.tsx` / `user-form-dialog.tsx`，本 phase 跟规约）
  - 该路径目录 `components/ai-model/` **确认不存在**（researcher 实测 `ls` 退出码 2），需要 mkdir
  - 沿用 shadcn 组件风格 + RHF + Zod；1:1 模板首选 `components/users/user-form-dialog.tsx` L1-289（最贴近本 phase 形态：单 dialog + 几个字段 + RHF + Zod + Form wrapper + Loader2 spinner + 提交后关闭）
- **修改**：`app/ai-model/page.tsx`
  - 删除 Phase 2 落地的占位 Dialog（约第 473-485 行的内联 Dialog）
  - 用 `<CredentialSlotDialog open={isCredentialDialogOpen} onOpenChange={setIsCredentialDialogOpen} />` 替换
  - 保留 Button 入口控件 + mounted 守卫（Phase 2 已落地）

### Dialog 组件接口（CredentialSlotDialog.tsx）

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

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

### 表单技术栈（已在 deps）

- **React Hook Form**：`useForm({ resolver: zodResolver(schema), defaultValues: { appId: '', accessToken: '' } })`
- **Zod**：定义 schema，**条件校验**（核心）：
  ```typescript
  const schema = z.object({
    appId: z.string(),  // 默认允许任何值（包括空，因为预填会有原值）
    accessToken: z.string(),  // 默认允许空（"留空保留旧值"语义）
  }).refine(
    (data, ctx) => {
      // 一旦用户在 access_token 输入了内容，要求非纯空白
      if (data.accessToken !== '' && data.accessToken.trim() === '') {
        return false
      }
      // 一旦用户清空了 app_id（与原值不同），要求非空
      // 这条在 form-level 校验中处理（不在 schema 内），见 submit 段
      return true
    },
    { message: 'Access Token 不能仅含空白字符' }
  )
  ```
- **Resolver**：`@hookform/resolvers/zod`（已在 deps）

### 预填态（关键 UX 规则）

打开 Dialog 时（`useEffect(() => { if (open) loadData() }, [open])`）：

1. 调 `getCredentialSlot()` 拉取后端数据
2. 把 `slot.appId` 作为 `appId` field 默认值（明文显示）
3. **`accessToken` field 默认值是空字符串**（**不是** `slot.accessTokenMasked`！）
4. `accessTokenMasked` 仅作为 input 的 **placeholder**：`<Input placeholder={slot.accessTokenMasked} />`
5. input 下方一行小字提示：「如需更新请重新输入，留空保留旧值」
6. `updated_at` 只读显示（如 `<p className="text-sm text-muted">最后更新：{slot.updatedAt}</p>`）

**TypeScript 类型层面屏障**：表单值类型用 `Partial<CredentialSlotUpdatePayload>`（仅 `accessToken` + `appId` 两个 camelCase 字段，**不含** `accessTokenMasked`）—— 编译期切断"把脱敏字符串当真值回写"的 bug。

### 提交逻辑（核心业务规则）

```typescript
const onSubmit = async (values: { appId: string; accessToken: string }) => {
  const payload: Partial<CredentialSlotUpdatePayload> = {}

  // 仅当用户实际改动了 appId 才提交
  if (values.appId !== '' && values.appId !== originalSlot.appId) {
    payload.appId = values.appId
  }

  // 仅当用户实际输入了 accessToken（非空字符串）才提交
  if (values.accessToken !== '') {
    payload.accessToken = values.accessToken
  }

  // 两个字段都没改 → 校验失败提示，不调 API
  if (Object.keys(payload).length === 0) {
    toast({ title: '没有改动', description: '请修改至少一个字段后再保存' })
    return
  }

  try {
    await updateCredentialSlot(payload as CredentialSlotUpdatePayload)
    toast({ title: '凭据槽位已更新', description: '配置已生效' })
    onOpenChange(false)  // 关闭对话框
    // page 监听 onOpenChange 后下次重新打开会自动 loadData，无需主动通知
  } catch (e) {
    const message = handleApiError(e)  // lib/api/error-handler.ts
    toast({ title: '保存失败', description: message, variant: 'destructive' })
    // 对话框保持打开 + 表单字段保留
  }
}
```

**关键**：`updateCredentialSlot(payload as CredentialSlotUpdatePayload)` 这里有个类型断言。Phase 1 落地的 `updateCredentialSlot` 期待**完整** `CredentialSlotUpdatePayload`（两个字段都必填）。本 phase 引入"部分载荷"语义。两条路径选其一：

- **路径 A**（推荐，最低侵入）：调用方传部分载荷 + 类型断言；后端按 PUT 覆写语义，缺失字段会导致后端清空 —— 这是问题
- **路径 B**：表单提交前用预填值补齐缺失字段。例如：
  ```typescript
  const finalPayload: CredentialSlotUpdatePayload = {
    appId: payload.appId ?? originalSlot.appId,
    accessToken: payload.accessToken ?? '<KEEP-OLD-MARKER>',
  }
  ```
  但 `<KEEP-OLD-MARKER>` 后端不识别，等于明文回写，**错误**

**最简正确路径**（planner 锁定）：
- **后端 PUT 是全字段覆写**（GSD 后端 Phase 2 已锁），所以前端必须**总是**提供两个字段
- access_token 缺失场景**用预填的 accessTokenMasked 重新 GET 拿真值** —— **这违反"留空保留旧值"语义** ❌

**真正正确路径（planner 必须按这个）**：
- 给 `updateCredentialSlot` 增加重载或新建一个 helper：当用户没改 access_token 时，前端**先 GET 再 PUT**：调 `getCredentialSlot()` 拿到当前 access_token 的真值（**但**返回的是脱敏掩码！）
- 这条路也走不通，因为前端永远拿不到真值

**最终决策（planner 严格遵守，CONTEXT 锁定）**：
- 后端 PUT 必须全字段提交 → 用户没改 access_token 时**前端用 `accessTokenMasked` 字符串本身回填给 PUT**
- 但这等于回写脱敏掩码 —— **必须由后端识别"PUT body 中 access_token 是脱敏掩码格式（即仅末 4 位明文 + 前面全 `*`）则保留旧值"**
- ⚠️ **Phase 3 不能完成"留空保留旧值"语义** —— 这需要后端配合改一行：`if access_token == mask_token(current.access_token): preserve old`
- 该 bug 在 Phase 3 中暴露后，需要**回头给后端开 Phase 4**（或一个 patch milestone）补上"识别脱敏掩码并保留旧值"的逻辑

**简化处理（planner 落地）**：
- Phase 3 表单 access_token 字段**强制要求填写**（去掉"留空保留旧值"语义）
- Zod schema：`accessToken: z.string().min(1, '请输入 Access Token')`
- 这样退化为"每次保存都要重输 access_token"，UX 略差但语义正确（不会回写脱敏掩码）
- 在 `docs/修改记录.md` 顶部 Phase 3 条目的「修改原因」段**显式说明**这个权衡 + 候选下一步是给后端加"识别脱敏掩码保留旧值"逻辑

**Planner 重要：必须按"强制输入"路线落地，不要尝试实现"留空保留旧值"**（除非 plan-checker 一轮里我明确改主意）。

### Toast 通知（researcher 修正：3 个关键纠偏）

**纠偏 1 — Sonner Toaster 全局未挂载（关键 pre-existing bug）**：
- `app/layout.tsx` 是 20 行裸 RootLayout，没有 `<Toaster />`
- 现有 9 处 `toast(...)` 调用其实**全是 dead code**（toast 不会显示）
- **本 phase 必须前置一个任务**：在 `app/layout.tsx` `<body>` 末尾挂载 `<Toaster />` from `@/components/ui/sonner`
- 否则 Phase 3 的成功 / 失败反馈完全静默

**纠偏 2 — 双 `useToast` 实现都是 Radix Toast，与 Sonner 不通**：
- `hooks/use-toast.ts` + `components/ui/use-toast.ts` 是两份内容相同的 Radix Toast 实现（295 行 dead code）
- 不要走 `useToast` hook
- **直接** `import { toast } from "sonner"` 命令式调用：`toast.success(...)` / `toast.error(...)`

**纠偏 3 — 双 `handleApiError` 函数**：
- `lib/api/error-handler.ts:38` `(error: unknown): string` ← 用这个
- `lib/api/index.ts:191` `(error: any): string` ← 同名重复定义，**不要**从 barrel import
- 显式 import：`import { handleApiError } from '@/lib/api/error-handler'`

**Toast 调用形态**：
```typescript
import { toast } from "sonner"
import { handleApiError } from "@/lib/api/error-handler"

// 成功
toast.success("凭据槽位已更新", { description: "配置已生效" })

// 失败
toast.error("保存失败", { description: handleApiError(e) })
```

### 错误处理

- 复用 `lib/api/error-handler.ts:handleApiError`（researcher 必须 read 该函数确认签名 + 返回值类型）
- 失败时**不**关闭对话框，**不**清空表单值

### `app/ai-model/page.tsx` 改动

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

### Claude's Discretion

- 文件命名 `CredentialSlotDialog.tsx`（PascalCase）vs `credential-slot-dialog.tsx`（kebab-case） —— planner 看仓库现有 components/ 子目录约定
- Cancel 按钮文案："取消" / "关闭"
- 提交按钮 loading 态文案："保存中..." / "处理中..."
- 是否给 access_token input 加 type="password" 屏蔽明文显示 —— 推荐**不**加（运营要看自己输入的内容）
- "最后更新"时间格式：`new Date(updatedAt).toLocaleString('zh-CN')` 还是用 `date-fns`（已在 deps）

</decisions>

<canonical_refs>
## Canonical References

**下游 agent 必读**：

### 项目宪法
- `qy-lty-admin/CLAUDE.md`
- `qy-lty-admin/.planning/PROJECT.md` — Milestone v1.0「关键约束」段（特别注意"留空保留旧值"语义实际无法实现的限制）
- `qy-lty-admin/.planning/REQUIREMENTS.md` — Active 段 CRED-FE-04 + CRED-FE-05
- `qy-lty-admin/.planning/ROADMAP.md` — Phase 3 详情段（5 条 success criteria）

### Phase 1+2 已交付（必读，作为消费 contract）
- `qy-lty-admin/lib/api/credential-slot.ts` — `getCredentialSlot` / `updateCredentialSlot` / 类型
- `qy-lty-admin/lib/permissions.ts` — `'credential-slot'` 已加入
- `qy-lty-admin/app/ai-model/page.tsx` — Phase 2 落地的 Button 入口 + mounted 守卫 + 占位 Dialog（待替换）

### React Hook Form + Zod 现有用法（必读，1:1 模板）
- `qy-lty-admin/components/songs/` 或 `qy-lty-admin/components/outfits/` 下任一 form 组件 — researcher 找出最贴近本 phase 的 RHF + Zod 写法（必读 1-2 个完整 form）
- `qy-lty-admin/lib/api/error-handler.ts` — handleApiError 函数

### Sonner toast
- `qy-lty-admin/hooks/use-toast.ts` — useToast hook 调用样板
- `qy-lty-admin/components/ui/sonner.tsx`（如存在） — Sonner 注入位置

### shadcn UI
- `qy-lty-admin/components/ui/dialog.tsx`
- `qy-lty-admin/components/ui/input.tsx`
- `qy-lty-admin/components/ui/label.tsx`
- `qy-lty-admin/components/ui/button.tsx`
- `qy-lty-admin/components/ui/form.tsx`（如存在 —— shadcn Form wrapper 配 RHF）

### 修改记录
- `qy-lty-admin/docs/修改记录.md` — 头部「修改格式说明」+ Phase 1 / Phase 2 条目作格式模板

</canonical_refs>

<specifics>
## 具体要点（Success Criteria 显式化）

| # | 验证点 | 检查方式 |
|---|--------|----------|
| 1 | `components/ai-model/CredentialSlotDialog.tsx`（或 kebab-case 名）存在并导出组件 | grep + 文件存在 |
| 2 | 组件用 React Hook Form + Zod | grep `useForm` + `zodResolver` + `z.object` 命中 |
| 3 | 打开时 `useEffect` 调 `getCredentialSlot()` 拉取预填 | grep `useEffect` + `getCredentialSlot` 命中 |
| 4 | `accessToken` field 默认值为空字符串（不是 accessTokenMasked） | grep adverse：`defaultValues:.*accessToken.*accessTokenMasked` 不命中；正向 grep `defaultValues.*accessToken: ''` 或类似 |
| 5 | input placeholder 用 `slot.accessTokenMasked` | grep `placeholder.*accessTokenMasked` 命中 |
| 6 | 提示文字"如需更新请重新输入" 或类似中文 | grep 命中 |
| 7 | `updated_at` 只读显示 | grep `slot.updatedAt` + 不在 `<input>` 内 |
| 8 | submit 调 `updateCredentialSlot` 仅传改动字段（部分载荷） | grep `updateCredentialSlot` + 提交逻辑形态 |
| 9 | 成功路径调 `useToast` / `toast(` 弹中文提示 | grep `toast.*凭据槽位已更新` 或类似 |
| 10 | 失败路径调 `handleApiError` | grep 命中 |
| 11 | `app/ai-model/page.tsx` 占位 Dialog 已删除，替换为 `<CredentialSlotDialog .../>` | grep `import CredentialSlotDialog` + 旧占位 Dialog 字面量"对话框真实内容由 Phase 3 落地"已不在 page.tsx |
| 12 | `npx tsc --noEmit` 在新增/修改文件零错误（沿用 Phase 1+2 判定） | shell exit + filter |
| 13 | 修改记录顶部 Phase 3 条目（含权衡说明 + 跨项目联动「无 / 待评估」）| grep |

</specifics>

<deferred>
## 推迟事项

- "留空保留旧值"语义 —— 需要后端识别脱敏掩码格式并保留旧值（**给后端开新 phase / patch milestone**）；本 phase 走"强制输入" UX
- 端到端浏览器测试 —— 无 E2E 框架，本 phase 用程序化验证（npx tsc + grep）
- token 轮换 / refresh token / DB at-rest 加密 / Vitest 体系 / ESLint bootstrap —— 各自独立 milestone

</deferred>

---

*Phase: 03-dialog-feedback*
*Context gathered: 2026-05-08 via inline PRD（用户在 /gsd-plan-phase 3 --skip-ui 调用时提供完整约束）*
