284 lines
14 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编辑对话框 + 提交反馈 - 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 + Zod1: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`PascalCasevs `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 调用时提供完整约束)*