From 814f49372bbf20b49c09226de9a8ed77ce15d58d Mon Sep 17 00:00:00 2001 From: pmc <740076875@qq.com> Date: Fri, 8 May 2026 12:05:49 +0800 Subject: [PATCH] =?UTF-8?q?docs(03):=20qy-lty-admin=20Phase=203=20CONTEXT.?= =?UTF-8?q?md=EF=BC=88=E7=BC=96=E8=BE=91=E5=AF=B9=E8=AF=9D=E6=A1=86=20+=20?= =?UTF-8?q?=E5=8F=8D=E9=A6=88=20PRD=20=E5=BF=AB=E9=80=9F=E9=80=9A=E9=81=93?= =?UTF-8?q?=EF=BC=8C--skip-ui=EF=BC=8C=E5=90=AB=E7=95=99=E7=A9=BA=E4=BF=9D?= =?UTF-8?q?=E7=95=99=E6=97=A7=E5=80=BC=E6=97=A0=E6=B3=95=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=E7=9A=84=E6=9D=83=E8=A1=A1=E8=AF=B4=E6=98=8E=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../phases/03-dialog-feedback/03-CONTEXT.md | 260 ++++++++++++++++++ 1 file changed, 260 insertions(+) create mode 100644 qy-lty-admin/.planning/phases/03-dialog-feedback/03-CONTEXT.md diff --git a/qy-lty-admin/.planning/phases/03-dialog-feedback/03-CONTEXT.md b/qy-lty-admin/.planning/phases/03-dialog-feedback/03-CONTEXT.md new file mode 100644 index 0000000..f2b3d07 --- /dev/null +++ b/qy-lty-admin/.planning/phases/03-dialog-feedback/03-CONTEXT.md @@ -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` 调用时提供的内联约束 + + +## 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% 交付。 + + + + +## 实现决策(锁定) + +### 组件抽离 + +- **新建**:`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) + - 用 `` 替换 + - 保留 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**:`` +5. input 下方一行小字提示:「如需更新请重新输入,留空保留旧值」 +6. `updated_at` 只读显示(如 `

最后更新:{slot.updatedAt}

`) + +**TypeScript 类型层面屏障**:表单值类型用 `Partial`(仅 `accessToken` + `appId` 两个 camelCase 字段,**不含** `accessTokenMasked`)—— 编译期切断"把脱敏字符串当真值回写"的 bug。 + +### 提交逻辑(核心业务规则) + +```typescript +const onSubmit = async (values: { appId: string; accessToken: string }) => { + const payload: Partial = {} + + // 仅当用户实际改动了 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 ?? '', + } + ``` + 但 `` 后端不识别,等于明文回写,**错误** + +**最简正确路径**(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 + + ``` +- 保留 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) + +
+ + +## 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 条目作格式模板 + + + + +## 具体要点(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` + 不在 `` 内 | +| 8 | submit 调 `updateCredentialSlot` 仅传改动字段(部分载荷) | grep `updateCredentialSlot` + 提交逻辑形态 | +| 9 | 成功路径调 `useToast` / `toast(` 弹中文提示 | grep `toast.*凭据槽位已更新` 或类似 | +| 10 | 失败路径调 `handleApiError` | grep 命中 | +| 11 | `app/ai-model/page.tsx` 占位 Dialog 已删除,替换为 `` | grep `import CredentialSlotDialog` + 旧占位 Dialog 字面量"对话框真实内容由 Phase 3 落地"已不在 page.tsx | +| 12 | `npx tsc --noEmit` 在新增/修改文件零错误(沿用 Phase 1+2 判定) | shell exit + filter | +| 13 | 修改记录顶部 Phase 3 条目(含权衡说明 + 跨项目联动「无 / 待评估」)| grep | + + + + +## 推迟事项 + +- "留空保留旧值"语义 —— 需要后端识别脱敏掩码格式并保留旧值(**给后端开新 phase / patch milestone**);本 phase 走"强制输入" UX +- 端到端浏览器测试 —— 无 E2E 框架,本 phase 用程序化验证(npx tsc + grep) +- token 轮换 / refresh token / DB at-rest 加密 / Vitest 体系 / ESLint bootstrap —— 各自独立 milestone + + + +--- + +*Phase: 03-dialog-feedback* +*Context gathered: 2026-05-08 via inline PRD(用户在 /gsd-plan-phase 3 --skip-ui 调用时提供完整约束)*