docs(03): qy-lty-admin Phase 3 PLAN ×3(03-01 Toaster 挂载 / 03-02 对话框组件 + page 替换 / 03-03 修改记录 + 双重验证),plan-checker 一遍过

This commit is contained in:
pmc 2026-05-08 12:24:41 +08:00
parent 1068c77075
commit b27be2508f
4 changed files with 1214 additions and 2 deletions

View File

@ -61,7 +61,10 @@
3. 提交成功路径:`updateCredentialSlot()` 返回成功后,调用 `useToast()` 弹出 Sonner 成功 toast中文文案如"凭据槽位已更新"),对话框自动关闭,再次打开时数据被重新拉取并展示新的 `updated_at`
4. 提交失败路径:后端返回非成功响应或网络异常时,错误经由 `lib/api/error-handler.ts` 统一映射为可读中文消息后通过 toast 提示;对话框保持打开、表单字段保留用户输入、不丢失编辑态
5. 端到端串联(依赖 qy_lty 后端 Phase 2 落地):以"超级管理员"账户登录 → 进入 `/ai-model` → 点击凭据槽位入口 → 输入一组真实 APP ID + Access Token → 提交 → 看到成功 toast → 关闭后重新打开对话框,`access_token` 仅显示新值末 4 位、`updated_at` 已刷新
**Plans**: TBD
**Plans**: 3 plans
- [ ] 03-01-PLAN.md — 在 app/layout.tsx 挂载 Sonner Toaster修复仓库 pre-existing dead code解锁 toast 反馈)
- [ ] 03-02-PLAN.md — 新建 components/ai-model/credential-slot-dialog.tsxRHF + Zod + Sonner + handleApiError+ 改 app/ai-model/page.tsx删占位 Dialog + 接入新组件)
- [ ] 03-03-PLAN.md — docs/修改记录.md 顶部追加 Phase 3 条目(含 access_token 强制输入权衡说明 + 候选下一周期 milestone 锚点)+ plan 级双重验证tsc 反向断言 + 13 条 grep specifics + lockfile diff
**UI hint**: yes
## Progress
@ -73,9 +76,10 @@ Phase 按数值顺序执行1 → 2 → 3如出现紧急插入记为 1.1
|-------|----------------|--------|-----------|
| 1. 凭据槽位 API 客户端 | 2/2 | ✅ Complete | 2026-05-08 |
| 2. RBAC 收敛 + AI 模型页入口 | 2/2 | ✅ Complete | 2026-05-08 |
| 3. 编辑对话框 + 提交反馈 | 0/TBD | Not started | - |
| 3. 编辑对话框 + 提交反馈 | 0/3 | Planning complete | - |
---
*生成时间2026-05-07Milestone v1.0「通用凭据槽位前端集成」启动;与 qy_lty 后端 v1.0 并行,端到端验收依赖后端 Phase 2 落地*
*2026-05-08 更新Phase 2 全部交付Plan 02-01 + Plan 02-02 共 2/2 完成commit 2be1f1d 修改记录追加 + plan 级双重验证Milestone 进度 2/3 phase67%),等待 /gsd-plan-phase 3 启动 Phase 3*
*2026-05-08 更新Phase 3 plan 规划完成3 plan 串行03-01 挂载 Sonner Toaster → 03-02 新组件 + page 接入 → 03-03 修改记录追加 + 双重验证);等待 /gsd-execute-phase 3 启动执行*

View File

@ -0,0 +1,203 @@
---
phase: 03-dialog-feedback
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- app/layout.tsx
autonomous: true
requirements:
- CRED-FE-05
must_haves:
truths:
- "调用 toast.success(...) / toast.error(...) 后屏幕能看到 Sonner 通知"
- "Toaster 在所有路由下均可用(挂在 RootLayout 顶层)"
- "不破坏现有 RootLayout 渲染children 仍正常显示)"
artifacts:
- path: "app/layout.tsx"
provides: "RootLayout 注入 <Toaster /> portal"
contains: "Toaster"
key_links:
- from: "app/layout.tsx"
to: "@/components/ui/sonner"
via: "import { Toaster }"
pattern: "from \"@/components/ui/sonner\""
---
<objective>
`app/layout.tsx``<body>` 内挂载 Sonner `<Toaster />`,让全局 `toast.success(...)` / `toast.error(...)` 命令式调用真正能在屏幕上显示。
**Purpose**:仓库 9 处 `toast(...)` 调用当前全部是 dead code`components/ui/sonner.tsx` 定义了 Toaster 包装但**从未挂载**),不挂载 Phase 3 的成功 / 失败反馈完全静默;这是 Phase 3 业务功能跑通的硬前置。
**Output**:修改后的 `app/layout.tsx`,新增 1 行 import + 1 个 JSX 元素挂载点。
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/03-dialog-feedback/03-CONTEXT.md
@.planning/phases/03-dialog-feedback/03-RESEARCH.md
@CLAUDE.md
@app/layout.tsx
@components/ui/sonner.tsx
<interfaces>
<!-- Sonner Toaster 包装组件签名(从 components/ui/sonner.tsx 提取) -->
<!-- 直接 import 即可使用,无 props 也能渲染(已封装 next-themes 主题适配)。 -->
From components/ui/sonner.tsx:
```typescript
type ToasterProps = React.ComponentProps<typeof Sonner>
const Toaster: ({ ...props }: ToasterProps) => JSX.Element
export { Toaster }
```
调用形态:`<Toaster />`(无 props 即可theme 内部已读 next-themes context本仓库未挂 ThemeProvider会回退到默认 "system",无错误)
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1在 RootLayout 挂载 Sonner Toaster</name>
<files>app/layout.tsx</files>
<read_first>
必读 `app/layout.tsx` 全文(仅 20 行)确认当前结构。当前内容:
```tsx
import type { Metadata } from 'next'
import './globals.css'
export const metadata: Metadata = {
title: 'v0 App',
description: 'Created with v0',
generator: 'v0.dev',
}
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode
}>) {
return (
<html lang="en">
<body>{children}</body>
</html>
)
}
```
</read_first>
<action>
**精确改动 2 处**
**改动 1**:在 `import './globals.css'` 之后追加 1 行 import
```tsx
import { Toaster } from '@/components/ui/sonner'
```
**改动 2**:把 `<body>{children}</body>` 改为 `<body>{children}<Toaster /></body>`(即在 `{children}` 之后、`</body>` 之前插入 `<Toaster />`)。
**最终全文(应该是这样)**
```tsx
import type { Metadata } from 'next'
import './globals.css'
import { Toaster } from '@/components/ui/sonner'
export const metadata: Metadata = {
title: 'v0 App',
description: 'Created with v0',
generator: 'v0.dev',
}
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode
}>) {
return (
<html lang="en">
<body>
{children}
<Toaster />
</body>
</html>
)
}
```
**严格约束**
- **不**挂 Radix Toast `<Toaster />`(来自 `@/components/ui/toaster`)—— 那是另一套实现,与 Sonner 不通信;本 phase 锁定 SonnerCONTEXT D-Toast 决策)
- **不**新增 ThemeProvider / next-themes 包装 —— `components/ui/sonner.tsx:9``useTheme()` fallback 到 `"system"`,无 ThemeProvider 也能跑(本仓库目前确实没挂 ThemeProvider
- **不**改 `metadata` / `html lang` / `globals.css` import 顺序
- **不**给 RootLayout 加 `"use client"` —— `<Toaster />` 自身就是 client component`components/ui/sonner.tsx:1` 顶部已 `"use client"`React Server Component 可以直接渲染 client child无需 RootLayout 自己 client 化
- 这是本 phase 唯一改动 `app/layout.tsx` 的 task**不**在此挂任何其他 provider
</action>
<verify>
<automated>
# Windows PowerShell项目不含 .eslintrc*lint 跳过沿用 Phase 1+2 判定)
cd C:\Users\admin\Desktop\Lila-Server\qy-lty-admin
# A 段tsc 整体 + 反向断言(必须 0 条指向 app/layout.tsx
npx tsc --noEmit 2>&1 | Select-String -Pattern 'app/layout\.tsx|app\\layout\.tsx'
# 期望无任何输出0 条新错误指向本文件)
# B 段grep 验证 import + Toaster 元素都已落地PowerShell Select-String
Select-String -Path 'app/layout.tsx' -Pattern 'from "@/components/ui/sonner"'
# 期望1 行命中 import 行
Select-String -Path 'app/layout.tsx' -Pattern '<Toaster\s*/>'
# 期望1 行命中 <Toaster /> 标签
# C 段lockfile 未动(不引入新依赖)—— Sonner 已在 deps^1.7.1
git diff --stat HEAD -- package.json yarn.lock package-lock.json pnpm-lock.yaml
# 期望0 行(无 diff
</automated>
</verify>
<done>
- `app/layout.tsx` 包含 `import { Toaster } from "@/components/ui/sonner"` 一行
- `<body>``{children}` 之后渲染 `<Toaster />`
- `npx tsc --noEmit` 输出过滤 `app/layout.tsx`**0 条新错误**67 条存量错误与本 task 无关)
- 4 个 manifest+lockfile 在 git diff 中 0 行 diff不引入新依赖
</done>
</task>
</tasks>
<verification>
**Phase 3 Plan 1 整体验证**Plan 内已涵盖,此处汇总):
1. **类型检查**`npx tsc --noEmit` exit 非 067 条存量错误,与本 phase 无关),但 `Select-String` 过滤 `app/layout.tsx` 命中 0 行
2. **挂载位置正确**grep `<Toaster />` 命中且位于 `<body>` 内、`{children}` 之后(不是 `<head>` 内、不在 children 之前)
3. **不动 lockfile**`git diff --stat HEAD -- package.json *.lock` 输出 0 行
4. **lint 跳过**:项目无 `.eslintrc*` / `eslint-config-next`,沿用 Phase 1+2 判定(不阻塞)
**关键失败模式**(如果出现,回头修):
- 如果挂在 `<html>` 之外或 `<head>` 内 → 渲染失败 → 修
- 如果误用 `import { Toaster } from "@/components/ui/toaster"`Radix Toast→ 与 Sonner toast() 不通信 → 修
- 如果给 layout.tsx 加了 `"use client"` → 改回 RSC无必要
</verification>
<success_criteria>
- [ ] `app/layout.tsx` import 块包含 `from "@/components/ui/sonner"`
- [ ] `app/layout.tsx` `<body>` 内含 `<Toaster />` 元素
- [ ] `npx tsc --noEmit` 过滤后 0 条新错误指向 `app/layout.tsx`
- [ ] 4 个 lockfile 在 git diff 中 0 行 diff
- [ ] Plan 03-02 的 toast 调用上线后能在屏幕显示(本 plan 单独无法跑通端到端,由 03-02 联动验证)
</success_criteria>
<output>
完成后创建 `.planning/phases/03-dialog-feedback/03-01-SUMMARY.md`,按 `$HOME/.claude/get-shit-done/templates/summary.md` 格式记录:
- 改动文件清单1 个文件)
- import + JSX 两处具体行号
- tsc 过滤结果 + lockfile diff 结果
- 阻塞 / 非阻塞结论
- 下一步:执行 Plan 03-02
</output>

View File

@ -0,0 +1,633 @@
---
phase: 03-dialog-feedback
plan: 02
type: execute
wave: 2
depends_on:
- "03-01"
files_modified:
- components/ai-model/credential-slot-dialog.tsx
- app/ai-model/page.tsx
autonomous: true
requirements:
- CRED-FE-04
- CRED-FE-05
must_haves:
truths:
- "授权运营点击「凭据槽位」按钮能打开真实编辑对话框(不再是占位空 Dialog"
- "对话框打开时通过 getCredentialSlot() 拉取后端数据appId 字段以明文预填、accessToken 字段为空、placeholder 显示脱敏掩码"
- "updated_at 字段以中文格式只读显示toLocaleString('zh-CN')"
- "用户必须重新输入 access_token 才能提交(强制输入语义;防止把脱敏掩码当真值回写)"
- "提交成功 → Sonner toast.success(\"凭据槽位已更新\") + 自动关闭对话框"
- "提交失败 → 经 handleApiError 映射 + Sonner toast.error 提示,对话框保持打开 + 表单字段不丢"
- "失败重试时再次打开对话框会重新调 getCredentialSlot 拉最新数据"
artifacts:
- path: "components/ai-model/credential-slot-dialog.tsx"
provides: "CredentialSlotDialog 组件 (RHF + Zod + Sonner + handleApiError)"
exports: ["CredentialSlotDialog"]
contains: "useForm"
min_lines: 130
- path: "app/ai-model/page.tsx"
provides: "import 新组件 + 删除 Phase 2 占位 Dialog (L473-485) + 删除不再使用的 Dialog 系列 import (L9-15)"
contains: "CredentialSlotDialog"
key_links:
- from: "components/ai-model/credential-slot-dialog.tsx"
to: "lib/api/credential-slot.ts"
via: "import { getCredentialSlot, updateCredentialSlot, type CredentialSlot }"
pattern: "from \"@/lib/api/credential-slot\""
- from: "components/ai-model/credential-slot-dialog.tsx"
to: "lib/api/error-handler.ts"
via: "import { handleApiError }"
pattern: "from \"@/lib/api/error-handler\""
- from: "components/ai-model/credential-slot-dialog.tsx"
to: "sonner npm package"
via: "import { toast } from \"sonner\""
pattern: "from \"sonner\""
- from: "app/ai-model/page.tsx"
to: "components/ai-model/credential-slot-dialog.tsx"
via: "import { CredentialSlotDialog }"
pattern: "from \"@/components/ai-model/credential-slot-dialog\""
---
<objective>
落地 Phase 3 核心实现:
1. **新建** `components/ai-model/credential-slot-dialog.tsx`kebab-case 命名,与仓库 9 个现有业务对话框一致),导出 `CredentialSlotDialog` 组件,基于 React Hook Form + Zod + shadcn Form wrapper + Sonner
2. **修改** `app/ai-model/page.tsx`:删 L9-15 不再用的 Dialog 系列 import、新增 `CredentialSlotDialog` import、删 L473-485 占位 Dialog、替换为 `<CredentialSlotDialog open onOpenChange />`
**Purpose**:让授权运营能查看脱敏的当前凭据、安全提交新值,且成功 / 失败两条路径都有清晰的中文 toast 反馈;表单语义按 CONTEXT D-提交逻辑 锁定为「access_token 强制输入」(每次保存都要重输;不实现「留空保留旧值」,避免回写脱敏掩码 —— 该语义需后端配合识别脱敏掩码格式,已记入候选下一周期 milestone
**Output**1 个新组件文件 ~150 行 + page.tsx 局部 4 处改动。
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/03-dialog-feedback/03-CONTEXT.md
@.planning/phases/03-dialog-feedback/03-RESEARCH.md
@.planning/phases/03-dialog-feedback/03-01-SUMMARY.md
@CLAUDE.md
@components/users/user-form-dialog.tsx
@components/ui/dialog.tsx
@components/ui/form.tsx
@components/ui/sonner.tsx
@lib/api/credential-slot.ts
@lib/api/error-handler.ts
@app/ai-model/page.tsx
<interfaces>
<!-- Phase 1 已落地的 API client 类型 + 函数(直接消费) -->
From lib/api/credential-slot.ts:
```typescript
export interface CredentialSlot {
appId: string
accessTokenMasked: string // 后端返回的脱敏字符串(前端绝不能当真值回写)
updatedAt: string // ISO 8601
}
export interface CredentialSlotUpdatePayload {
appId: string
accessToken: string // 明文(提交后将完整覆写后端记录)
}
export const getCredentialSlot: () => Promise<CredentialSlot>
export const updateCredentialSlot: (payload: CredentialSlotUpdatePayload) => Promise<CredentialSlot>
```
From lib/api/error-handler.ts (line 38)
```typescript
export const handleApiError = (error: unknown): string
// 实现error instanceof Error → error.message否则 → "发生未知错误,请重试"
```
**注意**lib/api/index.ts:191 也有同名 dead-code 重复定义;本 phase **必须**显式 `from "@/lib/api/error-handler"`**不要**走 barrel。
From sonner npm package已在 deps `^1.7.1`
```typescript
import { toast } from "sonner"
toast.success(title: string, options?: { description?: string }): void
toast.error(title: string, options?: { description?: string }): void
```
**注意**:仓库 `hooks/use-toast.ts` 是 Radix Toast 实现,与 Sonner 不通;**不要**走 useToast hook。
From components/ui/dialog.tsx
```typescript
export const Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter
```
From components/ui/form.tsx
```typescript
export const Form, FormField, FormItem, FormLabel, FormControl, FormDescription, FormMessage
```
From react-hook-form + @hookform/resolvers/zod + zod已在 deps
```typescript
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import * as z from "zod"
```
Phase 2 已落地的 page.tsx 上下文(行号引用):
- L1`"use client"`(保留)
- L9-15`Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle` 命名导入(**本 plan 删除**,因占位 Dialog 删后 page 不再直接用 Dialog primitive
- L16`KeyRound` 等 lucide-react 图标 import保留
- L17`hasPermission` import保留
- L20-25mounted state + isCredentialDialogOpen state + useEffect mounted 守卫(保留)
- L35-43「凭据槽位」Button 入口(保留)
- L473-485占位 Dialog**本 plan 删除并替换**
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1新建 components/ai-model/credential-slot-dialog.tsxCRED-FE-04 + CRED-FE-05 主体)</name>
<files>components/ai-model/credential-slot-dialog.tsx</files>
<read_first>
必读 4 个 canonical references已在 context @ 块声明,再次明确):
1. `components/users/user-form-dialog.tsx` L1-289 —— RHF + Zod + shadcn Form 1:1 模板(**确认 import 块结构 + useForm + handleSubmit + form.reset 关闭模式**
2. `lib/api/credential-slot.ts` 全文 —— 确认 `getCredentialSlot` 返回 `Promise<CredentialSlot>``updateCredentialSlot` 接受 `CredentialSlotUpdatePayload`、字段名 `appId` / `accessTokenMasked` / `updatedAt`
3. `lib/api/error-handler.ts` L38-43 —— 确认 `handleApiError(error: unknown): string` 签名
4. `components/ui/form.tsx` L169-178 —— 确认 Form / FormField / FormItem / FormLabel / FormControl / FormDescription / FormMessage 都已导出
并确认 `components/ai-model/` 目录不存在,**创建文件时需要 mkdir**Write 工具会自动创建父目录)。
</read_first>
<action>
**先 mkdir** `components/ai-model/`PowerShell`New-Item -ItemType Directory -Force -Path 'components/ai-model'`)—— 如果用 Write tool 创建文件,父目录通常自动创建,但本仓在 Windows / git bash 混用环境下若不行需显式 mkdir。
**新建文件 `components/ai-model/credential-slot-dialog.tsx`,逐字写入以下完整内容**~150 行;改写自 `components/users/user-form-dialog.tsx` 模板,已对齐 CONTEXT 锁定决策):
```tsx
"use client"
import { useEffect, useState } 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"
// ───── Zod schema ──────────────────────────────────────────────────────
// access_token 强制输入CONTEXT D-提交逻辑 锁定):
// - 后端 PUT 是全字段覆写语义;前端无法识别脱敏掩码格式
// - 「留空保留旧值」需后端配合识别掩码格式,已记入候选下一周期 milestone
// - 本 phase 退化为「每次保存都要重输 access_token」—— UX 略差但语义正确
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: "" },
})
// open=true 时拉数据 + reset 表单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)
// 关闭时清表单 + 清 slot避免下次打开残留上次输入
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) {
// 失败时不关闭对话框、不清空表单值CONTEXT D-错误处理 锁定)
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>
)
}
```
**严格约束(绝不偏离)**
- 文件命名 **kebab-case** `credential-slot-dialog.tsx`(与 `user-form-dialog.tsx` / `add-song-dialog.tsx` 等 9 个对齐CONTEXT D-命名 + RESEARCH 决议)
- 组件导出名 **PascalCase** `CredentialSlotDialog`(具名导出,沿用 `user-form-dialog.tsx:57``export function UserFormDialog` 写法)
- 顶部首行 **必须** `"use client"`(含 `useState` / `useEffect` / RHF
- toast 走 **`import { toast } from "sonner"`** —— **不要** import `useToast` from `@/hooks/use-toast`Radix Toast与 Sonner 不通)
- handleApiError 走 **`from "@/lib/api/error-handler"`** —— **不要** from `@/lib/api`barrel 里有 dead-code 同名重复定义)
- `defaultValues.accessToken` **必须为空字符串 `""`** —— **不要**写 `slot?.accessTokenMasked`(会回写脱敏掩码,违反 D-提交逻辑)
- `placeholder``slot?.accessTokenMasked ?? "输入 Access Token"`(仅作视觉提示)
- 失败路径 **不**调用 `handleOpenChange(false)`、**不**调 `form.reset()` —— 保持对话框打开 + 表单值不丢
- 不引入新依赖sonner / lucide-react / @hookform/resolvers / zod / react-hook-form / shadcn UI 全部已在 deps
- 不修改 shadcn 原子组件Dialog / Form / Input / Button 不动)
</action>
<verify>
<automated>
cd C:\Users\admin\Desktop\Lila-Server\qy-lty-admin
# A 段tsc 反向断言(必须 0 条指向新文件)
npx tsc --noEmit 2>&1 | Select-String -Pattern 'components/ai-model/credential-slot-dialog\.tsx|components\\ai-model\\credential-slot-dialog\.tsx'
# 期望:无任何输出
# B 段grep 13 条 specificsCONTEXT.md L253-268 表)—— 全部命中
Select-String -Path 'components/ai-model/credential-slot-dialog.tsx' -Pattern 'export function CredentialSlotDialog' # spec #1
Select-String -Path 'components/ai-model/credential-slot-dialog.tsx' -Pattern 'useForm' # spec #2a
Select-String -Path 'components/ai-model/credential-slot-dialog.tsx' -Pattern 'zodResolver' # spec #2b
Select-String -Path 'components/ai-model/credential-slot-dialog.tsx' -Pattern 'z\.object' # spec #2c
Select-String -Path 'components/ai-model/credential-slot-dialog.tsx' -Pattern 'useEffect' # spec #3a
Select-String -Path 'components/ai-model/credential-slot-dialog.tsx' -Pattern 'getCredentialSlot' # spec #3b
Select-String -Path 'components/ai-model/credential-slot-dialog.tsx' -Pattern 'placeholder.*accessTokenMasked' # spec #5
Select-String -Path 'components/ai-model/credential-slot-dialog.tsx' -Pattern '每次保存都需要重新输入' # spec #6
Select-String -Path 'components/ai-model/credential-slot-dialog.tsx' -Pattern 'slot\.updatedAt' # spec #7
Select-String -Path 'components/ai-model/credential-slot-dialog.tsx' -Pattern 'updateCredentialSlot' # spec #8
Select-String -Path 'components/ai-model/credential-slot-dialog.tsx' -Pattern 'toast\.success.*凭据槽位已更新' # spec #9
Select-String -Path 'components/ai-model/credential-slot-dialog.tsx' -Pattern 'handleApiError' # spec #10
# C 段:反向断言(绝不能命中 —— 防回归)
Select-String -Path 'components/ai-model/credential-slot-dialog.tsx' -Pattern 'defaultValues.*accessTokenMasked'
# 期望0 行(绝不能把脱敏掩码当默认值)
Select-String -Path 'components/ai-model/credential-slot-dialog.tsx' -Pattern 'from\s+"@/hooks/use-toast"|from\s+''@/hooks/use-toast'''
# 期望0 行(绝不走 Radix Toast hook
Select-String -Path 'components/ai-model/credential-slot-dialog.tsx' -Pattern 'from\s+"@/lib/api"\s*$'
# 期望0 行(必须显式 from "@/lib/api/error-handler",不走 barrel
Select-String -Path 'components/ai-model/credential-slot-dialog.tsx' -Pattern '^"use client"'
# 期望1 行(首行必须 "use client"
# D 段lockfile 未动
git diff --stat HEAD -- package.json yarn.lock package-lock.json pnpm-lock.yaml
# 期望0 行
</automated>
</verify>
<done>
- `components/ai-model/credential-slot-dialog.tsx` 存在且 ≥130 行
- 12 条 grep specificsspec #1-3, #5-10全部 ≥1 行命中
- 4 条反向断言accessTokenMasked 不在 defaultValues / 不 import use-toast / 不走 barrel handleApiError / 首行 "use client")全部满足
- `npx tsc --noEmit` 过滤后 0 条新错误指向本文件
- lockfile 0 行 diff
</done>
</task>
<task type="auto">
<name>Task 2改 app/ai-model/page.tsx 删占位 Dialog 并接入 CredentialSlotDialog</name>
<files>app/ai-model/page.tsx</files>
<read_first>
必读 `app/ai-model/page.tsx` 全文确认改动定位。当前结构关键行号:
- L1`"use client"`(保留)
- L9-15Dialog 系列命名导入(**本 task 删除整段**
```tsx
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
```
- L16lucide-react 图标 import保留 `KeyRound` 等)
- L17`hasPermission` import保留
- L20-25mounted state + isCredentialDialogOpen state + useEffect保留
- L35-43「凭据槽位」Button 入口(保留)
- L473-485占位 Dialog**本 task 删除整段并替换**
```tsx
<Dialog
open={isCredentialDialogOpen}
onOpenChange={setIsCredentialDialogOpen}
>
<DialogContent>
<DialogHeader>
<DialogTitle>通用凭据槽位</DialogTitle>
<DialogDescription>
对话框真实内容由 Phase 3 落地
</DialogDescription>
</DialogHeader>
</DialogContent>
</Dialog>
```
Tabs / TabsContent / Card 等其余内容L18 / L26-46 间未列段 / L46-471**逐字不动**。
</read_first>
<action>
**精确改动 4 处**
**改动 1删除 L9-15** Dialog 系列命名导入整段(占位 Dialog 删后 page 不再直接使用 Dialog primitive
删前(当前):
```tsx
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Brain, Mic, Database, Plus, Sparkles, Edit, Play, Sliders, User, KeyRound } from "lucide-react"
```
删后:
```tsx
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Brain, Mic, Database, Plus, Sparkles, Edit, Play, Sliders, User, KeyRound } from "lucide-react"
```
**改动 2在 lucide-react import 之后追加 1 行新 import**
```tsx
import { CredentialSlotDialog } from "@/components/ai-model/credential-slot-dialog"
```
放在 `import { Brain, ... } from "lucide-react"` 之后、`import { hasPermission } from "@/lib/permissions"` 之前 —— 与同目录的业务组件 import 紧邻,逻辑成组。
最终该段(删除 + 新增之后)应为:
```tsx
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Brain, Mic, Database, Plus, Sparkles, Edit, Play, Sliders, User, KeyRound } from "lucide-react"
import { CredentialSlotDialog } from "@/components/ai-model/credential-slot-dialog"
import { hasPermission } from "@/lib/permissions"
```
**改动 3删除 L473-485占位 Dialog 整 13 行)+ 替换为 1 行 `<CredentialSlotDialog />`**
删前(当前):
```tsx
<Dialog
open={isCredentialDialogOpen}
onOpenChange={setIsCredentialDialogOpen}
>
<DialogContent>
<DialogHeader>
<DialogTitle>通用凭据槽位</DialogTitle>
<DialogDescription>
对话框真实内容由 Phase 3 落地
</DialogDescription>
</DialogHeader>
</DialogContent>
</Dialog>
</DashboardShell>
```
删后:
```tsx
<CredentialSlotDialog
open={isCredentialDialogOpen}
onOpenChange={setIsCredentialDialogOpen}
/>
</DashboardShell>
```
**改动 4保留所有其他内容**line 1 `"use client"` / line 17 `hasPermission` import / L19-25 默认导出函数 + state + mounted useEffect / L26-46 DashboardHeader 含 Button 入口 / L47-471 Tabs 内容)—— **逐字不动**
**严格约束**
- **不**改 mounted 守卫的形态(沿用 Phase 2 已落地的 `mounted && hasPermission("credential-slot")`
- **不**给 page.tsx 加 Loader2 importLoader2 仅在新组件 `credential-slot-dialog.tsx` 内用)
- **不**改 Button 入口文案 / 图标 / variant
- **不**改 isCredentialDialogOpen state 名称
- 替换占位 Dialog 时**保留**前后的缩进与换行,确保 JSX 树形对齐 `</DashboardShell>`
</action>
<verify>
<automated>
cd C:\Users\admin\Desktop\Lila-Server\qy-lty-admin
# A 段tsc 反向断言(必须 0 条指向 page.tsx
npx tsc --noEmit 2>&1 | Select-String -Pattern 'app/ai-model/page\.tsx|app\\ai-model\\page\.tsx'
# 期望:无任何输出
# B 段:正向 grep —— 改动应该都在
Select-String -Path 'app/ai-model/page.tsx' -Pattern 'import \{ CredentialSlotDialog \} from "@/components/ai-model/credential-slot-dialog"' # spec #11a
# 期望1 行命中
Select-String -Path 'app/ai-model/page.tsx' -Pattern '<CredentialSlotDialog' # spec #11b
# 期望1 行命中
Select-String -Path 'app/ai-model/page.tsx' -Pattern '"use client"'
# 期望1 行(首行)
Select-String -Path 'app/ai-model/page.tsx' -Pattern 'hasPermission\("credential-slot"\)'
# 期望1 行Phase 2 已落地,本 plan 不应删)
Select-String -Path 'app/ai-model/page.tsx' -Pattern '凭据槽位'
# 期望≥1 行Button 文案 + DialogTitle 现已搬到子组件page 仍保留 Button 文案)
# C 段:反向断言(绝不能命中 —— 旧占位 Dialog 已删干净)
Select-String -Path 'app/ai-model/page.tsx' -Pattern '对话框真实内容由 Phase 3 落地' # spec #11c
# 期望0 行
Select-String -Path 'app/ai-model/page.tsx' -Pattern 'from "@/components/ui/dialog"'
# 期望0 行Dialog 系列 import 已整段删除)
Select-String -Path 'app/ai-model/page.tsx' -Pattern '<Dialog\s'
# 期望0 行page 内不再直接使用 Dialog primitive
# D 段lockfile 未动
git diff --stat HEAD -- package.json yarn.lock package-lock.json pnpm-lock.yaml
# 期望0 行
</automated>
</verify>
<done>
- `app/ai-model/page.tsx` 含 1 行 `import { CredentialSlotDialog } from "@/components/ai-model/credential-slot-dialog"`
- `app/ai-model/page.tsx` 含 1 处 `<CredentialSlotDialog open={isCredentialDialogOpen} onOpenChange={setIsCredentialDialogOpen} />`
- 旧占位 Dialog含「对话框真实内容由 Phase 3 落地」字面量)已 0 行命中
- Dialog 系列命名导入已 0 行命中
- `mounted && hasPermission("credential-slot")` 守卫保留Phase 2 不被破坏)
- `npx tsc --noEmit` 过滤后 0 条新错误指向本文件
- lockfile 0 行 diff
</done>
</task>
</tasks>
<verification>
**Plan 03-02 整体串联验证**Plan 内已涵盖,此处汇总):
1. **类型检查**`npx tsc --noEmit` exit 非 067 条存量错误),但 `Select-String` 过滤 `credential-slot-dialog.tsx` + `app/ai-model/page.tsx` 命中 **0 条**新错误
2. **13 条 grep specifics**CONTEXT.md L253-268 表)全部命中:
- #1 文件存在 + 导出 ✓Task 1
- #2 RHF + Zod ✓Task 1
- #3 useEffect + getCredentialSlot ✓Task 1
- #4 反向defaultValues 不含 accessTokenMasked ✓Task 1
- #5 placeholder 用 accessTokenMasked ✓Task 1
- #6 「如需更新...」中文提示 ✓Task 1等价表述「每次保存都需要重新输入...」)
- #7 updatedAt 只读显示 ✓Task 1
- #8 updateCredentialSlot 调用 ✓Task 1
- #9 toast.success 中文 ✓Task 1
- #10 handleApiError ✓Task 1
- #11 page.tsx 占位删除 + import 新组件 ✓Task 2
- #12 tsc 过滤 0 条 ✓Task 1+2
- #13 修改记录条目(推迟到 Plan 03-03
3. **不引入新依赖**4 个 lockfile 0 行 diff
4. **lint 跳过**:项目无 ESLint infra沿用 Phase 1+2 判定
**Phase 3 success criteria 对应**ROADMAP.md L58-63
- #1 打开自动 GET 拉取 + appId 明文预填 + accessToken placeholder 掩码 + updatedAt 只读 ✓ Task 1
- #2 RHF + Zod ✓ + 强制输入 access_token替代「留空保留旧值」语义Plan 03-03 修改记录写权衡说明)✓ Task 1
- #3 提交成功 → toast.success + 关闭 ✓ Task 1重新打开时 useEffect 自动 reload无需主动刷新
- #4 提交失败 → handleApiError + toast.error + 不关闭对话框 + 表单值不丢 ✓ Task 1
- #5 端到端串联依赖后端 Phase 2 落地 —— 程序化验证tsc + grep通过即可浏览器 E2E 推迟(无 E2E 框架)
</verification>
<success_criteria>
- [ ] `components/ai-model/credential-slot-dialog.tsx` 存在 ≥130 行
- [ ] `components/ai-model/` 目录已创建
- [ ] 12 条正向 grepTask 1 specifics #1-10全部命中
- [ ] 4 条反向断言Task 1全部满足
- [ ] `app/ai-model/page.tsx``CredentialSlotDialog` import + JSX 元素
- [ ] `app/ai-model/page.tsx` 不再含旧占位 Dialog 字面量「对话框真实内容由 Phase 3 落地」
- [ ] `app/ai-model/page.tsx` 不再含 `from "@/components/ui/dialog"` import
- [ ] `mounted && hasPermission("credential-slot")` 守卫保留Phase 2 不破坏)
- [ ] `npx tsc --noEmit` 过滤后 0 条新错误指向 2 个改动文件
- [ ] 4 个 lockfile 0 行 diff
</success_criteria>
<output>
完成后创建 `.planning/phases/03-dialog-feedback/03-02-SUMMARY.md`,按 `$HOME/.claude/get-shit-done/templates/summary.md` 格式记录:
- 改动文件清单2 个:新建 + 修改)
- 12 条正向 grep + 4 条反向断言全表结果
- tsc 过滤结果0 条新错误)
- lockfile diff0 行)
- Phase 3 ROADMAP success criteria 对应表(#1-#5 状态:本 plan 完成 #1-#4 + #5 程序化验证;浏览器端到端推迟到后端 Phase 2 联调)
- 下一步:执行 Plan 03-03修改记录追加 + plan 级双重验证)
</output>

View File

@ -0,0 +1,372 @@
---
phase: 03-dialog-feedback
plan: 03
type: execute
wave: 3
depends_on:
- "03-01"
- "03-02"
files_modified:
- docs/修改记录.md
autonomous: true
requirements:
- CRED-FE-04
- CRED-FE-05
must_haves:
truths:
- "docs/修改记录.md 顶部存在 [2026-05-08] Phase 3 条目(在 Phase 2 条目之上)"
- "条目按 CLAUDE.md L72-82 格式包含「文件路径 / 修改类型 / 修改内容 / 修改原因」四段"
- "条目「修改内容」段明确列出 3 个改动文件app/layout.tsx + components/ai-model/credential-slot-dialog.tsx + app/ai-model/page.tsx"
- "条目「修改原因」段显式说明 access_token 强制输入的权衡 + 候选下一周期 milestone识别脱敏掩码保留旧值"
- "条目「跨项目联动」段写「无 — Phase 3 是前端 UI 收尾access_token 强制输入语义为 Phase 1+2 已建立的前后端互引commit 46d72b8的延续'留空保留旧值' 语义需后端识别脱敏掩码格式 + 保留旧值,已记入候选下一周期 milestone不属于 v1.0 范畴)」"
- "条目「服务端联动」字段同上「跨项目联动」"
- "Plan 级双重验证tsc 整体反向断言 + grep 13 条 specifics 全表 + lockfile diff 0 行)全部通过"
artifacts:
- path: "docs/修改记录.md"
provides: "Phase 3 修改记录条目(在 Phase 2 [2026-05-08] 条目上方)"
contains: "[2026-05-08] Phase 3"
key_links:
- from: "docs/修改记录.md Phase 3 条目"
to: "Phase 2 [2026-05-08] 条目"
via: "在其上方追加CLAUDE.md「最新在最前」规则"
pattern: "Phase 3.*\\n.*Phase 2"
---
<objective>
Phase 3 收尾 plan
1. 在 `docs/修改记录.md` 顶部追加 [2026-05-08] Phase 3 条目(在 Phase 2 条目上方),按 CLAUDE.md L72-82 格式 + Phase 2 条目作为同期模板
2. 跑 plan 级双重验证tsc 反向断言 + 13 条 grep specifics 全表 + lockfile diff 0 行 + lint 跳过沿用 Phase 1+2 判定
**Purpose**CLAUDE.md L70-94 强制每次代码改动同会话追加修改记录(自动执行),且 Phase 3 引入了一个**业务语义权衡**access_token 强制输入 vs 「留空保留旧值」),必须显式记入「修改原因」便于未来开后端 patch milestone 时反查。
**Output**`docs/修改记录.md` 顶部 +1 个完整条目plan 级整体验证报告。
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/03-dialog-feedback/03-CONTEXT.md
@.planning/phases/03-dialog-feedback/03-01-SUMMARY.md
@.planning/phases/03-dialog-feedback/03-02-SUMMARY.md
@CLAUDE.md
@docs/修改记录.md
</context>
<tasks>
<task type="auto">
<name>Task 1docs/修改记录.md 顶部追加 Phase 3 条目</name>
<files>docs/修改记录.md</files>
<read_first>
必读 `docs/修改记录.md` 头部:
- L1-22「修改格式说明」段4 字段格式:文件路径 / 修改类型 / 修改内容 / 修改原因)
- L24-26「修改历史」标题 + 「<!-- 新的修改记录添加在此处下方,最新的在最前面 -->」标记
- L28-58上一条 [2026-05-08] Phase 2 条目(**作为格式模板**,特别注意「跨项目联动」与「服务端联动」两段写法)
确认插入位置L26标记注释之后、L28Phase 2 条目)之前。
</read_first>
<action>
**逐字插入以下条目**到 `docs/修改记录.md` 的 L26 注释 `<!-- 新的修改记录添加在此处下方,最新的在最前面 -->` 之后、L28 `### [2026-05-08] Phase 2前端...` 之前。
**完整条目(直接复制粘贴)**
```markdown
### [2026-05-08] Phase 3前端凭据槽位编辑对话框 + 提交反馈
配套服务端 Phase本 phase **不**触达服务端;与服务端 v1.0 Phase 2「管理端读写接口」commit `46d72b8` 既有契约保持兼容GET 脱敏掩码 + PUT 全字段覆写语义不变)
覆盖前端需求CRED-FE-04、CRED-FE-05
- **文件路径**
- `app/layout.tsx`(修改)
- `components/ai-model/credential-slot-dialog.tsx`(新增)
- `app/ai-model/page.tsx`(修改)
- **修改类型**: 修改 + 新增(前端 UI 收尾;纯前端,无新依赖、不动 lockfile、不触达服务端
- **修改内容**:
- `app/layout.tsx`(修复仓库 pre-existing 死代码 bug
- 顶部新增 `import { Toaster } from "@/components/ui/sonner"`
- `<body>{children}</body>``{children}` 之后追加 `<Toaster />`
- 修复仓库内 9 处 `toast(...)` 调用全部静默的 dead-code 状态(`components/ui/sonner.tsx` 早就存在 Toaster 包装但**从未在 RootLayout 挂载**
- `components/ai-model/credential-slot-dialog.tsx`**新建** ~150 行):
- 顶部 `"use client"` 指令;具名导出 `CredentialSlotDialog`
- 文件命名 **kebab-case**(与仓库 9 个现有业务对话框 `user-form-dialog.tsx` / `add-song-dialog.tsx` 等对齐)
- 表单技术栈React Hook Form + Zod + shadcn Form wrapper1:1 模板自 `components/users/user-form-dialog.tsx`
- Zod schema`appId` + `accessToken``min(1)`access_token **强制输入**,见「修改原因」段权衡说明)
- 受控接口:`{ open: boolean; onOpenChange: (open: boolean) => void }`
- 打开时 `useEffect``getCredentialSlot()` 拉取 → `form.reset({ appId: data.appId, accessToken: "" })` —— **accessToken 永远默认空串**,绝不回填脱敏掩码
- `<Input placeholder={slot?.accessTokenMasked} />` —— 仅作视觉提示
- `<FormDescription>每次保存都需要重新输入 Access Token不会显示原值避免回写脱敏掩码</FormDescription>`
- `updatedAt``new Date(slot.updatedAt).toLocaleString("zh-CN")` 中文只读显示
- 提交成功:`toast.success("凭据槽位已更新", { description: "配置已生效" })` + `handleOpenChange(false)`
- 提交失败:`toast.error("保存失败", { description: handleApiError(e) })` + 对话框保持打开 + 表单值不丢
- 关闭时 `form.reset({ appId: "", accessToken: "" })` + `setSlot(null)` —— 避免下次打开残留上次输入
- useEffect cleanup `cancelled` flag 防止快速开关导致的 race condition
- **关键 import 决策**(避免仓库内同名 dead code
- `import { toast } from "sonner"` —— **不**走 `@/hooks/use-toast`Radix Toast 实现,与 Sonner 不通信)
- `import { handleApiError } from "@/lib/api/error-handler"` —— **不**走 barrel `@/lib/api`barrel 内有同名重复定义)
- `app/ai-model/page.tsx`
- 删除 L9-15 Dialog 系列命名导入整段(`Dialog / DialogContent / DialogDescription / DialogHeader / DialogTitle`)—— 占位 Dialog 删除后 page 不再直接使用 Dialog primitive
- 在 lucide-react import 之后新增 `import { CredentialSlotDialog } from "@/components/ai-model/credential-slot-dialog"`
- 删除 L473-485 占位 Dialog`<DialogTitle>通用凭据槽位</DialogTitle>` + `<DialogDescription>对话框真实内容由 Phase 3 落地</DialogDescription>`
- 替换为 `<CredentialSlotDialog open={isCredentialDialogOpen} onOpenChange={setIsCredentialDialogOpen} />`
- 保留 L1 `"use client"` / L20-25 mounted state + isCredentialDialogOpen state + useEffect 守卫 / L35-43「凭据槽位」Button 入口(含 `mounted && hasPermission("credential-slot")` 守卫)
- Tabs / TabsContent / Card 等其余内容L18-471逐字不动
- **修改原因**:
- 收尾 Milestone v1.0「通用凭据槽位前端集成」:让授权运营能查看脱敏的当前凭据、安全提交新值,且成功 / 失败两条路径都有清晰的中文 toast 反馈
- 修复 `app/layout.tsx` 的 pre-existing dead code仓库 `components/ui/sonner.tsx` 早已存在 Sonner Toaster 包装但**从未挂载到 RootLayout**,导致仓库内 9 处既有 `toast(...)` 调用全部静默;本 phase 顺手修复(否则 Phase 3 反馈不可见)
- 拆出独立组件 `credential-slot-dialog.tsx` 而非把表单内联进 page.tsx(a) 与仓库 9 个现有业务对话框抽离风格一致;(b) 让 page.tsx 保持简洁,不掺业务表单状态;(c) 关闭对话框时组件级 form.reset 隔离干净
- **业务语义权衡(重要 — 候选下一周期 milestone 锚点)**:本 phase Zod schema 把 `accessToken: z.string().min(1)` —— **强制每次重输** access_token**不实现** ROADMAP success criteria #2 中提到的「留空保留旧值」语义。原因:
- 后端 PUT 是全字段覆写语义qy_lty 后端 v1.0 Phase 2 已锁定 commit `46d72b8`
- 后端 GET 返回的 access_token 字段是脱敏掩码(末 4 位明文 + 前缀 `*`),前端永远拿不到真值
- 「留空保留旧值」需后端配合识别 PUT body 中 access_token 的脱敏掩码格式并保留旧值(后端逻辑:`if access_token == mask_token(current.access_token): preserve old`
- 该后端识别逻辑不在 Milestone v1.0 范畴,**已记入候选下一周期 milestone**(参见 PROJECT.md / REQUIREMENTS.md 候选清单 + STATE.md 风险段)
- 当前实现退化为「每次保存都要重输 access_token」—— UX 略差但语义正确(永远不会回写脱敏掩码导致后端清空真实凭据)
- 沿用 Phase 1 已建立的「`accessTokenMasked` vs `accessToken` 类型层屏障」(前者是脱敏字符串、后者是明文)—— TS 编译期会拦截「把脱敏字符串赋给 accessToken 字段」的 bug 路径
- Sonner`components/ui/sonner.tsx`+ `lib/api/error-handler.ts:handleApiError` 是 CONTEXT D-Toast / D-错误处理 锁定的双依赖;不引入新依赖、不混合 Radix Toast hook、不走 barrel 同名重复定义,避免仓库内 dead code 串扰
- **跨项目联动**: 无 — Phase 3 是前端 UI 收尾access_token 强制输入语义为 Phase 1+2 已建立的前后端互引commit `46d72b8`)的延续;'留空保留旧值' 语义需后端识别脱敏掩码格式 + 保留旧值,已记入候选下一周期 milestone不属于 v1.0 范畴)
- **服务端联动**: 同上「跨项目联动」字段;后端 commit `46d72b8` 已建立互引闭环,本 phase 无需再次互引;未来若启动「识别脱敏掩码保留旧值」的后端 patch milestone届时双端各写新一轮互引条目
```
**严格约束**
- 插入位置:在 L26 注释之后、L28 Phase 2 条目之前CLAUDE.md L72「最新在最前面」规则
- 不动 L1-22 的「修改格式说明」段
- 不修改 L28 之后已有的任何条目Phase 2 / Phase 1 / [2026-05-07] 锁定契约)
- 「修改原因」段必须包含「access_token 强制输入」「留空保留旧值」「候选下一周期 milestone」三个关键短语方便未来 grep 反查
- 「跨项目联动」字段值**逐字使用** CONTEXT.md / 上下文锁定的中文文本(注意标点、引号、破折号 `—`
- 不引入跨项目互引条目(本 phase 不触达后端)
</action>
<verify>
<automated>
cd C:\Users\admin\Desktop\Lila-Server\qy-lty-admin
# A 段:条目存在性 + 标题正确
Select-String -Path 'docs/修改记录.md' -Pattern '### \[2026-05-08\] Phase 3'
# 期望1 行命中
# B 段插入位置正确Phase 3 出现在 Phase 2 之上)
$content = Get-Content 'docs/修改记录.md' -Raw
$phase3Pos = $content.IndexOf('### [2026-05-08] Phase 3')
$phase2Pos = $content.IndexOf('### [2026-05-08] Phase 2')
if ($phase3Pos -ge 0 -and $phase2Pos -ge 0 -and $phase3Pos -lt $phase2Pos) { Write-Host 'OK: Phase 3 在 Phase 2 上方' } else { Write-Host 'FAIL' }
# 期望:'OK: Phase 3 在 Phase 2 上方'
# C 段4 段格式齐全CLAUDE.md L72-82
Select-String -Path 'docs/修改记录.md' -Pattern '^- \*\*文件路径\*\*' | Where-Object { $_.LineNumber -lt 60 }
# 期望:在 Phase 3 段内(前 60 行≥1 行命中
Select-String -Path 'docs/修改记录.md' -Pattern '^- \*\*修改类型\*\*' | Where-Object { $_.LineNumber -lt 60 }
# 期望≥1 行命中
Select-String -Path 'docs/修改记录.md' -Pattern '^- \*\*修改内容\*\*' | Where-Object { $_.LineNumber -lt 60 }
# 期望≥1 行命中
Select-String -Path 'docs/修改记录.md' -Pattern '^- \*\*修改原因\*\*' | Where-Object { $_.LineNumber -lt 60 }
# 期望≥1 行命中
# D 段3 个改动文件全列
Select-String -Path 'docs/修改记录.md' -Pattern '`app/layout\.tsx`'
Select-String -Path 'docs/修改记录.md' -Pattern '`components/ai-model/credential-slot-dialog\.tsx`'
Select-String -Path 'docs/修改记录.md' -Pattern '`app/ai-model/page\.tsx`.*修改' | Where-Object { $_.LineNumber -lt 80 }
# E 段:业务权衡关键短语
Select-String -Path 'docs/修改记录.md' -Pattern '强制每次重输|强制输入'
# 期望≥1 行
Select-String -Path 'docs/修改记录.md' -Pattern '留空保留旧值'
# 期望≥1 行
Select-String -Path 'docs/修改记录.md' -Pattern '候选下一周期 milestone|候选下一周期'
# 期望≥1 行
Select-String -Path 'docs/修改记录.md' -Pattern '识别脱敏掩码'
# 期望≥1 行
# F 段:「跨项目联动」+「服务端联动」字段都在
Select-String -Path 'docs/修改记录.md' -Pattern '\*\*跨项目联动\*\*' | Where-Object { $_.LineNumber -lt 60 }
Select-String -Path 'docs/修改记录.md' -Pattern '\*\*服务端联动\*\*' | Where-Object { $_.LineNumber -lt 60 }
# G 段CRED-FE-04 + CRED-FE-05 显式列出
Select-String -Path 'docs/修改记录.md' -Pattern 'CRED-FE-04.*CRED-FE-05|CRED-FE-04、CRED-FE-05'
# 期望≥1 行
</automated>
</verify>
<done>
- `docs/修改记录.md` 顶部存在 `### [2026-05-08] Phase 3` 条目
- Phase 3 条目位于 Phase 2 条目上方IndexOf 比较通过)
- 4 段格式(文件路径 / 修改类型 / 修改内容 / 修改原因)全齐
- 3 个改动文件app/layout.tsx / credential-slot-dialog.tsx / app/ai-model/page.tsx全列
- 业务权衡关键短语(强制输入 / 留空保留旧值 / 候选下一周期 milestone / 识别脱敏掩码)全部 ≥1 行命中
- 「跨项目联动」+「服务端联动」字段都在
- CRED-FE-04 + CRED-FE-05 显式列出
</done>
</task>
<task type="auto">
<name>Task 2Plan 级整体双重验证(沿用 Phase 1+2 模式)</name>
<files></files>
<read_first>
回顾 STATE.md L84-85 Plan 02-02 落地说明,本 task 沿用相同验证模式A 段 tsc 反向断言 + B 段 14 条 grep 全表 + C 段 4 个 lockfile diff + D 段 lint 跳过判定)。
</read_first>
<action>
**执行 4 段验证脚本,逐段记录结果到 SUMMARY.md**
**A 段tsc 整体 + 反向断言(必须 0 条指向本 phase 改动文件)**
```powershell
cd C:\Users\admin\Desktop\Lila-Server\qy-lty-admin
# 整体(含 67 条存量错误,与本 phase 无关)
$tscOutput = npx tsc --noEmit 2>&1
$totalErrors = ($tscOutput | Select-String -Pattern '\.tsx?\(\d+,\d+\):' -AllMatches).Matches.Count
Write-Host "tsc 整体错误总数: $totalErrors应在 60-70 之间,与 Phase 1+2 持平)"
# 反向断言:本 phase 3 个改动文件应 0 条命中
$tscOutput | Select-String -Pattern 'app/layout\.tsx|app\\layout\.tsx|components/ai-model/credential-slot-dialog\.tsx|components\\ai-model\\credential-slot-dialog\.tsx|app/ai-model/page\.tsx|app\\ai-model\\page\.tsx'
# 期望:无任何输出
```
**B 段13 条 grep specifics 全表CONTEXT.md L253-268 表)**
```powershell
# 编号沿用 CONTEXT.md L253-268 表
$file = 'components/ai-model/credential-slot-dialog.tsx'
# spec #1 文件存在 + 导出
Select-String -Path $file -Pattern 'export function CredentialSlotDialog'
# spec #2 RHF + Zod
Select-String -Path $file -Pattern 'useForm'
Select-String -Path $file -Pattern 'zodResolver'
Select-String -Path $file -Pattern 'z\.object'
# spec #3 useEffect 调 getCredentialSlot
Select-String -Path $file -Pattern 'useEffect'
Select-String -Path $file -Pattern 'getCredentialSlot'
# spec #4 反向accessToken 默认空串、不是 accessTokenMasked
Select-String -Path $file -Pattern 'defaultValues.*accessTokenMasked'
# 期望0 行
Select-String -Path $file -Pattern "accessToken: \`"\`""
# 期望≥1 行("accessToken": ""
# spec #5 placeholder 用 accessTokenMasked
Select-String -Path $file -Pattern 'placeholder.*accessTokenMasked'
# spec #6 提示文字
Select-String -Path $file -Pattern '每次保存都需要重新输入'
# spec #7 updatedAt 只读显示
Select-String -Path $file -Pattern 'slot\.updatedAt'
# spec #8 submit 调 updateCredentialSlot
Select-String -Path $file -Pattern 'updateCredentialSlot'
# spec #9 toast.success 中文
Select-String -Path $file -Pattern 'toast\.success.*凭据槽位已更新'
# spec #10 handleApiError
Select-String -Path $file -Pattern 'handleApiError'
# spec #11 page.tsx 占位删除 + 替换
Select-String -Path 'app/ai-model/page.tsx' -Pattern 'import \{ CredentialSlotDialog \} from "@/components/ai-model/credential-slot-dialog"'
Select-String -Path 'app/ai-model/page.tsx' -Pattern '<CredentialSlotDialog'
Select-String -Path 'app/ai-model/page.tsx' -Pattern '对话框真实内容由 Phase 3 落地'
# 期望0 行(已删干净)
# spec #12 tsc 过滤A 段已覆盖)
# spec #13 修改记录条目Task 1 已覆盖)
# Layout Toaster 挂载Plan 03-01 落地)
Select-String -Path 'app/layout.tsx' -Pattern 'from "@/components/ui/sonner"'
Select-String -Path 'app/layout.tsx' -Pattern '<Toaster\s*/>'
# 反向(防回归):不走 Radix Toast hook + 不走 barrel handleApiError
Select-String -Path $file -Pattern 'from\s+"@/hooks/use-toast"'
# 期望0 行
Select-String -Path $file -Pattern '^"use client"'
# 期望1 行(首行)
```
**C 段4 个 manifest+lockfile 在工作区 + HEAD~3 比较均 0 行 diff**
```powershell
# 工作区 diff应已被 git add 且 commit 后查 HEAD
git diff --stat HEAD -- package.json yarn.lock package-lock.json pnpm-lock.yaml
# 期望0 行
# 跨 Phase 3 三个 plan 的累计 diffHEAD~3 = 03-01 之前)
# 注意:父仓库 Lila-Server 视角,路径需含子目录前缀
git diff --stat HEAD~3 HEAD -- 'qy-lty-admin/package.json' 'qy-lty-admin/yarn.lock' 'qy-lty-admin/package-lock.json' 'qy-lty-admin/pnpm-lock.yaml'
# 期望0 行Phase 3 全程不引入新依赖)
```
**D 段lint 跳过判定(沿用 Phase 1+2**
仓库 `package.json``"lint": "next lint"` 在执行时若无 `.eslintrc*` / `eslint-config-next` 会触发交互式 prompt非自动通过本 phase 沿用 Phase 1 + Phase 2 已建立的判定(参见 STATE.md L83-85
- 不主动跑 `npm run lint`
- 在 SUMMARY.md「lint 状态」段写:「无 .eslintrc* / eslint-config-next → next lint 进入 interactive prompt 不可自动判定 → 沿用 Phase 1+2 判定不阻塞ESLint bootstrap 留待候选 #3 milestone」
**记录验证报告**:把 A/B/C/D 四段全部输出 + 解读写到 `.planning/phases/03-dialog-feedback/03-03-SUMMARY.md`「Plan 级双重验证」段。
</action>
<verify>
<automated>
cd C:\Users\admin\Desktop\Lila-Server\qy-lty-admin
# 整 phase 工作区在 commit 后净状态:本 task 不改任何代码 / lockfile仅查询验证
# 验证 SUMMARY.md 已生成
Test-Path '.planning/phases/03-dialog-feedback/03-03-SUMMARY.md'
# 期望True
# SUMMARY 内含 4 段标题
Select-String -Path '.planning/phases/03-dialog-feedback/03-03-SUMMARY.md' -Pattern 'A 段|B 段|C 段|D 段'
# 期望4 段都命中
</automated>
</verify>
<done>
- A 段tsc 整体错误数 60-70 之间(沿用 67 条存量水位) + 反向断言对 3 个改动文件layout.tsx / credential-slot-dialog.tsx / page.tsx输出 0 行
- B 段13 条 grep specifics 全表跑过;正向 ≥1 行命中、反向 0 行命中全部满足Layout Toaster 挂载验证通过
- C 段:工作区 + HEAD~3 比较均 0 行 lockfile diff
- D 段lint 跳过判定文字化记入 SUMMARY
- SUMMARY.md 已生成并包含 A/B/C/D 段完整报告
</done>
</task>
</tasks>
<verification>
**Phase 3 整体收尾验证**(本 plan 已涵盖,此处汇总 Milestone v1.0 收尾态):
1. **修改记录闭环**CLAUDE.md L70-94 强制规则满足 —— Phase 1 / Phase 2 / Phase 3 三条条目按时间倒序排列在 docs/修改记录.md 顶部
2. **5 条 ROADMAP success criteria 对应**ROADMAP.md L58-63
- #1 打开自动 GET + appId 明文 + accessToken 掩码 placeholder + updatedAt 只读 ✓ Plan 03-02 Task 1
- #2 RHF + Zod 校验 + 不回写脱敏掩码(强制输入语义;权衡说明已写入修改记录「修改原因」)✓ Plan 03-02 Task 1 + Plan 03-03 Task 1
- #3 提交成功 toast.success + 自动关闭 + 重新打开自动 reload ✓ Plan 03-02 Task 1
- #4 提交失败 handleApiError 映射 + toast.error + 对话框保持 + 表单不丢 ✓ Plan 03-02 Task 1
- #5 端到端串联(依赖后端 Phase 2commit 46d72b8 已落地)—— 程序化验证通过;浏览器 E2E 推迟(无 E2E 框架CONTEXT 已声明)
3. **不引入新依赖**4 个 lockfile 在 HEAD~3..HEAD 范围 0 行 diff
4. **不破坏 Phase 1+2**Phase 1 落地的 `lib/api/credential-slot.ts` + `lib/api/index.ts` 未改 / Phase 2 落地的 `lib/permissions.ts` + `app/ai-model/page.tsx` Button 入口与 mounted 守卫未改
**Milestone v1.0 收尾态**:本 plan 完成后Milestone v1.0「通用凭据槽位前端集成」全部 5 条前端需求CRED-FE-01~05100% 交付;后端 v1.0CRED-01~06已于 commit 46d72b8 收尾。Milestone 进度 100%3/3 phase
</verification>
<success_criteria>
- [ ] `docs/修改记录.md` 顶部含 [2026-05-08] Phase 3 条目(位于 Phase 2 之上)
- [ ] 条目 4 字段格式齐全 + 3 改动文件全列 + 4 个关键权衡短语全命中
- [ ] 「跨项目联动」+「服务端联动」字段含 CONTEXT 锁定文本
- [ ] CRED-FE-04 + CRED-FE-05 显式列出
- [ ] A 段 tsc 反向断言 0 行layout.tsx / credential-slot-dialog.tsx / page.tsx
- [ ] B 段 13 条 grep specifics 全表通过
- [ ] C 段 lockfile 工作区 + HEAD~3 双比较均 0 行 diff
- [ ] D 段 lint 跳过判定文字化记入 SUMMARY
- [ ] `.planning/phases/03-dialog-feedback/03-03-SUMMARY.md` 已生成并含 4 段完整报告
- [ ] Milestone v1.0 5 条 success criteria 全部确认通过(#1-#4 完整 / #5 程序化验证通过 + 浏览器 E2E 已声明推迟)
</success_criteria>
<output>
完成后创建 `.planning/phases/03-dialog-feedback/03-03-SUMMARY.md`,按 `$HOME/.claude/get-shit-done/templates/summary.md` 格式记录:
- 改动文件清单1 个docs/修改记录.md
- 修改记录条目预览(含 4 段 + 跨项目联动 + 服务端联动)
- 「Plan 级双重验证」段A/B/C/D 四段输出 + 解读
- 「Milestone v1.0 收尾确认」段5 条 success criteria 状态表
- 「下一步」段:
- Milestone v1.0 已 100% 交付,可执行 `/gsd-retrospective` 总结本 milestone
- 候选下一周期 milestone已记入清单
1. 后端「识别脱敏掩码保留旧值」patch解锁 ROADMAP success criteria #2 完整语义)
2. PERM-06 后端独立校验闭环
3. ESLint bootstrap候选 #3
4. 其他 brownfield 候选优先级
</output>