Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 38 additions & 5 deletions app/skills/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -89,8 +89,9 @@ export default function SkillsPage() {
);

// 删除被引用时(409)弹引用应用清单
const [referencedApps, setReferencedApps] = useState<any[] | null>(null);
const [referencedApps, setReferencedApps] = useState<SkillReference[] | null>(null);
const [deletingSkill, setDeletingSkill] = useState<Skill | null>(null);
const [forceDeleting, setForceDeleting] = useState(false);
const [importOpen, setImportOpen] = useState(false);

const handleDelete = async (skill: Skill) => {
Expand All @@ -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 <div className="flex items-center justify-center h-64">{tc("loading")}</div>;
Expand Down Expand Up @@ -345,7 +370,11 @@ export default function SkillsPage() {
{/* 删除冲突:被应用引用(409) */}
<Dialog
open={referencedApps !== null}
onOpenChange={(open) => !open && setReferencedApps(null)}
onOpenChange={(open) => {
if (!open && !forceDeleting) {
closeDeleteConflict();
}
}}
>
<DialogContent className="max-w-md">
<DialogHeader>
Expand All @@ -358,16 +387,20 @@ export default function SkillsPage() {
</DialogDescription>
</DialogHeader>
<div className="max-h-60 overflow-y-auto space-y-2">
{(referencedApps || []).map((app: any, index: number) => (
{(referencedApps || []).map((app, index) => (
<div key={app.id ?? index} className="p-3 bg-muted rounded-lg border text-sm">
{app.name || app.app_name || String(app)}
</div>
))}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setReferencedApps(null)}>
<Button disabled={forceDeleting} variant="outline" onClick={closeDeleteConflict}>
{tc("close")}
</Button>
<Button disabled={forceDeleting} variant="destructive" onClick={handleForceDelete}>
{forceDeleting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t("unbindAllAndDelete")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
Expand Down
14 changes: 12 additions & 2 deletions hooks/useSkills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down Expand Up @@ -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
Expand All @@ -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"));
Expand Down
3 changes: 2 additions & 1 deletion messages/en/skills.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion messages/zh-CN/skills.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
17 changes: 17 additions & 0 deletions test/skillForceDelete.test.ts
Original file line number Diff line number Diff line change
@@ -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"\]/);
});
Loading