diff --git a/app/skills/page.tsx b/app/skills/page.tsx index 48914a3..f2f62c7 100644 --- a/app/skills/page.tsx +++ b/app/skills/page.tsx @@ -31,7 +31,7 @@ import { import { Loader2, Plus, Search, Sparkles, Terminal, Upload } from "lucide-react"; import { useDebounce } from "use-debounce"; import { useCurrentUser } from "@/hooks/useCurrentUser"; -import { useSkills } from "@/hooks/useSkills"; +import { useSkills, type SkillReference } from "@/hooks/useSkills"; import { resolveReviewStatus, reviewStatusBadge } from "@/lib/reviewStatus"; import { canEditSkill } from "@/lib/skillPermissions"; import { filterSkillsByCreator, getSkillCreators } from "@/lib/skillCreatorFilter"; @@ -89,8 +89,9 @@ export default function SkillsPage() { ); // 删除被引用时(409)弹引用应用清单 - const [referencedApps, setReferencedApps] = useState(null); + const [referencedApps, setReferencedApps] = useState(null); const [deletingSkill, setDeletingSkill] = useState(null); + const [forceDeleting, setForceDeleting] = useState(false); const [importOpen, setImportOpen] = useState(false); const handleDelete = async (skill: Skill) => { @@ -102,6 +103,30 @@ export default function SkillsPage() { } }; + const closeDeleteConflict = () => { + setReferencedApps(null); + setDeletingSkill(null); + }; + + // 后端的 force 删除是一个原子操作:先删全部 app_skills 绑定,再删 Skill。 + // 不在浏览器逐条调用解绑接口,否则某一条失败就会留下难以解释的半解绑状态。 + const handleForceDelete = async () => { + if (!deletingSkill) return; + setForceDeleting(true); + try { + const result = await deleteSkill(deletingSkill.id, true); + if (result.ok) { + closeDeleteConflict(); + } else if (result.referencedBy) { + // 若有其他人刚好又绑定了此 Skill,后端会再次给 409;刷新列表后让 + // 管理员看到当前真实的引用,而不是沿用第一次删除时的旧列表。 + setReferencedApps(result.referencedBy); + } + } finally { + setForceDeleting(false); + } + }; + // P5 开放自建:普通用户也可进入 Skill 列表创建自己的 Skill(可见范围由后端裁剪) if (userLoading || !user) { return
{tc("loading")}
; @@ -345,7 +370,11 @@ export default function SkillsPage() { {/* 删除冲突:被应用引用(409) */} !open && setReferencedApps(null)} + onOpenChange={(open) => { + if (!open && !forceDeleting) { + closeDeleteConflict(); + } + }} > @@ -358,16 +387,20 @@ export default function SkillsPage() {
- {(referencedApps || []).map((app: any, index: number) => ( + {(referencedApps || []).map((app, index) => (
{app.name || app.app_name || String(app)}
))}
- +
diff --git a/hooks/useSkills.ts b/hooks/useSkills.ts index cbfccc9..c633754 100644 --- a/hooks/useSkills.ts +++ b/hooks/useSkills.ts @@ -18,6 +18,13 @@ export interface SkillPayload { owner_tenant_id?: number | null; } +/** 删除 Skill 冲突(409)时,后端返回的已绑定数字员工摘要。 */ +export interface SkillReference { + id?: number | string; + name?: string; + app_name?: string; +} + /** 后端列表形状兼容:数组或 {items}/{skills} 包裹 */ function unwrapSkillList(data: unknown): Skill[] { if (Array.isArray(data)) return data as Skill[]; @@ -74,7 +81,7 @@ export const useSkills = (query?: string, filters?: SkillListFilters) => { const deleteSkill = async ( skillId: number, force = false - ): Promise<{ ok: boolean; referencedBy?: any[] }> => { + ): Promise<{ ok: boolean; referencedBy?: SkillReference[] }> => { try { await axios.delete(`/api/v1/skills/${skillId}${force ? "?force=true" : ""}`, { // 409(被应用引用)由调用方弹引用清单,不走全局错误 toast @@ -89,7 +96,10 @@ export const useSkills = (query?: string, filters?: SkillListFilters) => { if (error.response?.status === 409) { const detail = error.response.data?.detail; const referencedBy = Array.isArray(detail) ? detail : detail?.apps || detail?.referenced_by; - return { ok: false, referencedBy: Array.isArray(referencedBy) ? referencedBy : [] }; + return { + ok: false, + referencedBy: Array.isArray(referencedBy) ? (referencedBy as SkillReference[]) : [], + }; } console.error("Delete skill error:", error); toast.error(t("deleteFailed")); diff --git a/messages/en/skills.json b/messages/en/skills.json index 90f273f..cb68a50 100644 --- a/messages/en/skills.json +++ b/messages/en/skills.json @@ -106,7 +106,8 @@ "deleteSuccess": "Skill deleted", "deleteFailed": "Failed to delete skill", "cannotDelete": "Cannot delete skill", - "skillInUse": "Skill \"{name}\" is bound to {count} digital employees. Unbind it first, or it cannot be deleted.", + "skillInUse": "Skill \"{name}\" is bound to {count} digital employees. You can unbind all of them and delete it in one step.", + "unbindAllAndDelete": "Unbind all and delete", "exportSkillMd": "Export SKILL.md", "boundSkills": "Bound Skills ({count})", "bindSkill": "Bind Skill", diff --git a/messages/zh-CN/skills.json b/messages/zh-CN/skills.json index 4ec7c65..e6f4e3b 100644 --- a/messages/zh-CN/skills.json +++ b/messages/zh-CN/skills.json @@ -106,7 +106,8 @@ "deleteSuccess": "Skill 已删除", "deleteFailed": "删除 Skill 失败", "cannotDelete": "无法删除 Skill", - "skillInUse": "Skill「{name}」被 {count} 个数字员工绑定,请先解绑后再删除。", + "skillInUse": "Skill「{name}」被 {count} 个数字员工绑定。你可以一键解除全部绑定后删除。", + "unbindAllAndDelete": "解绑全部并删除", "exportSkillMd": "导出 SKILL.md", "boundSkills": "已绑定 Skill({count})", "bindSkill": "绑定 Skill", diff --git a/test/skillForceDelete.test.ts b/test/skillForceDelete.test.ts new file mode 100644 index 0000000..af1c698 --- /dev/null +++ b/test/skillForceDelete.test.ts @@ -0,0 +1,17 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { test } from "node:test"; + +const PAGE = readFileSync(join(process.cwd(), "app/skills/page.tsx"), "utf8"); +const PROXY = readFileSync(join(process.cwd(), "pages/api/v1/skills/[id]/index.ts"), "utf8"); + +test("删除冲突弹窗提供原子化的解绑全部并删除操作", () => { + assert.match(PAGE, /const handleForceDelete[\s\S]*?deleteSkill\(deletingSkill\.id, true\)/); + assert.match(PAGE, /variant="destructive" onClick=\{handleForceDelete\}/); + assert.match(PAGE, /t\("unbindAllAndDelete"\)/); +}); + +test("Skill 删除代理允许 force 查询参数到达后端", () => { + assert.match(PROXY, /passQuery:\s*\["force"\]/); +});