28 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 03-dialog-feedback | 02 | execute | 2 |
|
|
true |
|
|
- 新建
components/ai-model/credential-slot-dialog.tsx(kebab-case 命名,与仓库 9 个现有业务对话框一致),导出CredentialSlotDialog组件,基于 React Hook Form + Zod + shadcn Form wrapper + Sonner - 修改
app/ai-model/page.tsx:删 L9-15 不再用的 Dialog 系列 import、新增CredentialSlotDialogimport、删 L473-485 占位 Dialog、替换为<CredentialSlotDialog open onOpenChange />
Purpose:让授权运营能查看脱敏的当前凭据、安全提交新值,且成功 / 失败两条路径都有清晰的中文 toast 反馈;表单语义按 CONTEXT D-提交逻辑 锁定为「access_token 强制输入」(每次保存都要重输;不实现「留空保留旧值」,避免回写脱敏掩码 —— 该语义需后端配合识别脱敏掩码格式,已记入候选下一周期 milestone)。
Output:1 个新组件文件 ~150 行 + page.tsx 局部 4 处改动。
<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_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.tsxFrom lib/api/credential-slot.ts:
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):
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):
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:
export const Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter
From components/ui/form.tsx:
export const Form, FormField, FormItem, FormLabel, FormControl, FormDescription, FormMessage
From react-hook-form + @hookform/resolvers/zod + zod(已在 deps):
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:
hasPermissionimport(保留) - L20-25:mounted state + isCredentialDialogOpen state + useEffect mounted 守卫(保留)
- L35-43:「凭据槽位」Button 入口(保留)
- L473-485:占位 Dialog(本 plan 删除并替换)
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>
先 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 锁定决策):
"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"—— 不要 importuseToastfrom@/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 不动) cd C:\Users\admin\Desktop\Lila-Server\qy-lty-admin
- `components/ai-model/credential-slot-dialog.tsx` 存在且 ≥130 行 - 12 条 grep specifics(spec #1-3, #5-10)全部 ≥1 行命中 - 4 条反向断言(accessTokenMasked 不在 defaultValues / 不 import use-toast / 不走 barrel handleApiError / 首行 "use client")全部满足 - `npx tsc --noEmit` 过滤后 0 条新错误指向本文件 - lockfile 0 行 diff# 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 条 specifics(CONTEXT.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 行
- L1:`"use client"`(保留)
- L9-15:Dialog 系列命名导入(**本 task 删除整段**):
```tsx
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
```
- L16:lucide-react 图标 import(保留 `KeyRound` 等)
- L17:`hasPermission` import(保留)
- L20-25:mounted 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> 精确改动 4 处:
改动 1:删除 L9-15 Dialog 系列命名导入整段(占位 Dialog 删后 page 不再直接使用 Dialog primitive)
删前(当前):
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"
删后:
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:
import { CredentialSlotDialog } from "@/components/ai-model/credential-slot-dialog"
放在 import { Brain, ... } from "lucide-react" 之后、import { hasPermission } from "@/lib/permissions" 之前 —— 与同目录的业务组件 import 紧邻,逻辑成组。
最终该段(删除 + 新增之后)应为:
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 />
删前(当前):
<Dialog
open={isCredentialDialogOpen}
onOpenChange={setIsCredentialDialogOpen}
>
<DialogContent>
<DialogHeader>
<DialogTitle>通用凭据槽位</DialogTitle>
<DialogDescription>
对话框真实内容由 Phase 3 落地
</DialogDescription>
</DialogHeader>
</DialogContent>
</Dialog>
</DashboardShell>
删后:
<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 import(Loader2 仅在新组件
credential-slot-dialog.tsx内用) -
不改 Button 入口文案 / 图标 / variant
-
不改 isCredentialDialogOpen state 名称
-
替换占位 Dialog 时保留前后的缩进与换行,确保 JSX 树形对齐
</DashboardShell>cd C:\Users\admin\Desktop\Lila-Server\qy-lty-admin
- `app/ai-model/page.tsx` 含 1 行 `import { CredentialSlotDialog } from "@/components/ai-model/credential-slot-dialog"` - `app/ai-model/page.tsx` 含 1 处 `` - 旧占位 Dialog(含「对话框真实内容由 Phase 3 落地」字面量)已 0 行命中 - Dialog 系列命名导入已 0 行命中 - `mounted && hasPermission("credential-slot")` 守卫保留(Phase 2 不被破坏) - `npx tsc --noEmit` 过滤后 0 条新错误指向本文件 - lockfile 0 行 diff# 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 行
- 类型检查:
npx tsc --noEmitexit 非 0(67 条存量错误),但Select-String过滤credential-slot-dialog.tsx+app/ai-model/page.tsx命中 0 条新错误 - 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)
- 不引入新依赖:4 个 lockfile 0 行 diff
- 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 框架)
<success_criteria>
components/ai-model/credential-slot-dialog.tsx存在 ≥130 行components/ai-model/目录已创建- 12 条正向 grep(Task 1 specifics #1-10)全部命中
- 4 条反向断言(Task 1)全部满足
app/ai-model/page.tsx含CredentialSlotDialogimport + JSX 元素app/ai-model/page.tsx不再含旧占位 Dialog 字面量「对话框真实内容由 Phase 3 落地」app/ai-model/page.tsx不再含from "@/components/ui/dialog"importmounted && hasPermission("credential-slot")守卫保留(Phase 2 不破坏)npx tsc --noEmit过滤后 0 条新错误指向 2 个改动文件- 4 个 lockfile 0 行 diff </success_criteria>