docs(03): qy-lty-admin Phase 3 CONTEXT.md(编辑对话框 + 反馈 PRD 快速通道,--skip-ui,含留空保留旧值无法实现的权衡说明)

This commit is contained in:
pmc 2026-05-08 12:05:49 +08:00
parent 3945ab646e
commit 814f49372b

View File

@ -0,0 +1,260 @@
# 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/CredentialSlotDialog.tsx`
- 该路径目录 `components/ai-model/` 可能不存在;需 planner 在 read_first 阶段确认(推测目前没有,若没有则 mkdir
- 沿用 shadcn 组件风格(参考现有 `components/songs/` / `components/outfits/` 等已有业务组件目录的写法)
- **修改**`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 通知
- **Sonner**(项目已用,参考 `components/ui/sonner.tsx``hooks/use-toast.ts`
- 成功文案:"凭据槽位已更新" / 描述:"配置已生效"
- 失败文案title "保存失败" / description: 经 `handleApiError` 映射后的中文消息
- variant失败用 `destructive`(如 Sonner 支持)
### 错误处理
- 复用 `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 调用时提供完整约束)*