From d14ce4d9f4aab75849b6cbd550867543df8824fe Mon Sep 17 00:00:00 2001 From: Autumn-27 <53844290+Autumn-27@users.noreply.github.com> Date: Thu, 20 Aug 2026 11:18:24 -0400 Subject: [PATCH] =?UTF-8?q?feat(ui):=20skill=20=E6=A0=91=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E9=95=BF=E6=96=87=E4=BB=B6=E5=90=8D=E4=B8=8E=E5=88=A0=E9=99=A4?= =?UTF-8?q?=E7=A1=AE=E8=AE=A4=EF=BC=8C=E4=BB=BB=E5=8A=A1=E5=88=97=E8=A1=A8?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E5=A4=9A=E9=80=89=E5=88=A0=E9=99=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Skill 管理: - 长文件名把行撑出侧栏导致操作按钮不可见,约束 ScrollArea viewport 内层并把按钮组改为绝对定位固定在右侧 - Skill / 文件夹 / 文件三处删除补上二次确认对话框 任务列表: - 表格新增多选列(本页全选 + 跨页保留选择) - 批量删除复用单任务的关联数据清理选项,串行执行并显示进度, 结果按成功/失败聚合提示 Claude-Session: https://claude.ai/code/session_01BNN7npEJs4X6LfTtgniMd8 --- web/src/app/(main)/function/tasks/page.tsx | 436 ++++++++++++++++----- web/src/app/(main)/system/skills/page.tsx | 106 ++++- 2 files changed, 430 insertions(+), 112 deletions(-) diff --git a/web/src/app/(main)/function/tasks/page.tsx b/web/src/app/(main)/function/tasks/page.tsx index 46b4ee5..bc60ba0 100644 --- a/web/src/app/(main)/function/tasks/page.tsx +++ b/web/src/app/(main)/function/tasks/page.tsx @@ -172,7 +172,11 @@ function taskDuration(task: Task, nowSec: number): number { return end > start ? end - start : 0; } -function deleteSummary(result: DeleteTaskResult): string { +// deleteDetails takes only the countable part of the result so callers can pass an +// aggregate accumulated over a bulk delete. +type DeleteCounts = Omit; + +function deleteDetails(result: DeleteCounts): string[] { const details: string[] = []; if (result.assets_deleted > 0) details.push(`删除资产 ${result.assets_deleted} 条`); if (result.assets_detached > 0) details.push(`解除共享资产关联 ${result.assets_detached} 条`); @@ -180,6 +184,11 @@ function deleteSummary(result: DeleteTaskResult): string { if (result.files_deleted) details.push("删除任务文件"); if (result.findings_deleted > 0) details.push(`删除漏洞 ${result.findings_deleted} 条`); if (result.llm_records_deleted > 0) details.push(`删除 LLM 请求/响应记录 ${result.llm_records_deleted} 条`); + return details; +} + +function deleteSummary(result: DeleteTaskResult): string { + const details = deleteDetails(result); return details.length > 0 ? `任务已删除(${details.join(",")})` : "任务已删除"; } @@ -232,6 +241,48 @@ export default function TasksPage() { [filtered, page, pageSize], ); + // 多选删除:选择跨翻页/筛选保留,只在任务真的消失(被删或后端不再返回)时收敛。 + const [selectedIds, setSelectedIds] = React.useState>(() => new Set()); + + React.useEffect(() => { + setSelectedIds((prev) => { + if (prev.size === 0) return prev; + const live = new Set(tasks.map((t) => t.id)); + const next = new Set([...prev].filter((id) => live.has(id))); + return next.size === prev.size ? prev : next; + }); + }, [tasks]); + + const toggleSelected = React.useCallback((id: string, checked: boolean) => { + setSelectedIds((prev) => { + const next = new Set(prev); + if (checked) next.add(id); else next.delete(id); + return next; + }); + }, []); + + const pageIds = React.useMemo(() => paginated.map((t) => t.id), [paginated]); + const pageSelectedCount = React.useMemo( + () => pageIds.filter((id) => selectedIds.has(id)).length, + [pageIds, selectedIds], + ); + let headerChecked: boolean | "indeterminate" = false; + if (pageIds.length > 0 && pageSelectedCount === pageIds.length) { + headerChecked = true; + } else if (pageSelectedCount > 0) { + headerChecked = "indeterminate"; + } + + const toggleSelectedPage = React.useCallback((checked: boolean) => { + setSelectedIds((prev) => { + const next = new Set(prev); + for (const id of pageIds) { + if (checked) next.add(id); else next.delete(id); + } + return next; + }); + }, [pageIds]); + // lastRef holds the previous poll's serialized payload: the list is re-fetched every // POLL_MS but usually comes back unchanged, and setTasks on an identical payload would // re-render the whole page for nothing. Bail out when it matches. @@ -284,6 +335,63 @@ export default function TasksPage() { } }, [load]); + // deleteTasks 逐个删除所选任务:后端没有批量接口,且单次删除会连带清理资产/流量/文件, + // 串行执行以免一次性打爆后端;成功的从选中集移除,失败的保留以便重试。 + const deleteTasks = React.useCallback(async ( + ids: string[], + options: DeleteTaskOptions, + onProgress: (done: number) => void, + ) => { + const total: DeleteCounts = { + assets_deleted: 0, + assets_detached: 0, + traffic_deleted: 0, + files_deleted: false, + findings_deleted: 0, + llm_records_deleted: 0, + }; + const deleted: string[] = []; + const failed: { id: string; message: string }[] = []; + const warnings: string[] = []; + + for (const id of ids) { + try { + const r = await api.deleteTask(id, options); + total.assets_deleted += r.assets_deleted; + total.assets_detached += r.assets_detached; + total.traffic_deleted += r.traffic_deleted; + total.files_deleted = total.files_deleted || r.files_deleted; + total.findings_deleted += r.findings_deleted; + total.llm_records_deleted += r.llm_records_deleted; + if (r.cleanup_warning) warnings.push(`#${id}:${r.cleanup_warning}`); + deleted.push(id); + } catch (e) { + failed.push({ id, message: (e as Error).message }); + } + onProgress(deleted.length + failed.length); + } + + if (deleted.length > 0) { + setSelectedIds((prev) => { + const next = new Set(prev); + for (const id of deleted) next.delete(id); + return next; + }); + const details = deleteDetails(total); + const summary = `已删除 ${deleted.length} 个任务` + (details.length > 0 ? `(${details.join(",")})` : ""); + if (warnings.length > 0) { + toast.warning(`${summary};部分外部数据清理未完成:${warnings.join(";")}`); + } else { + toast.success(summary); + } + } + if (failed.length > 0) { + const head = failed.slice(0, 3).map((f) => `#${f.id}(${f.message})`).join(";"); + toast.error(`${failed.length} 个任务删除失败:${head}${failed.length > 3 ? " 等" : ""}`); + } + load(); + }, [load]); + return ( @@ -325,6 +433,15 @@ export default function TasksPage() { {filtered.length}/{tasks.length} 条 + {selectedIds.size > 0 && ( + <> + 已选 {selectedIds.size} 个 + + + + )} @@ -341,6 +458,13 @@ export default function TasksPage() { + + toggleSelectedPage(checked === true)} + aria-label="选择本页全部任务" + /> + ID 描述 目标 @@ -361,6 +485,8 @@ export default function TasksPage() { // 带来的整表重渲染,只让在跑的那几行走时长。 nowSec={task.status === "running" ? nowSec : 0} onDelete={deleteTask} + selected={selectedIds.has(task.id)} + onSelectedChange={toggleSelected} /> ))} @@ -461,13 +587,24 @@ const TaskRow = React.memo(function TaskRow({ task, nowSec, onDelete, + selected, + onSelectedChange, }: { task: Task; nowSec: number; onDelete: (id: string, options: DeleteTaskOptions) => Promise; + selected: boolean; + onSelectedChange: (id: string, checked: boolean) => void; }) { return ( - + + + onSelectedChange(task.id, checked === true)} + aria-label={`选择任务 ${task.id}`} + /> + {task.id} @@ -554,6 +691,117 @@ const deleteOptionKeys: (keyof DeleteTaskOptions)[] = [ "delete_llm_records", ]; +// DeleteOptionFields renders the「同时清理关联数据」checkbox block shared by the +// single-task and bulk delete dialogs. idPrefix keeps the label/input ids unique when +// several dialogs live in the same table. +function DeleteOptionFields({ + idPrefix, + options, + onOptionsChange, + disabled, +}: { + idPrefix: string; + options: DeleteTaskOptions; + onOptionsChange: React.Dispatch>; + disabled: boolean; +}) { + const selectedCount = deleteOptionKeys.filter((key) => options[key]).length; + let allChecked: boolean | "indeterminate" = false; + if (selectedCount === deleteOptionKeys.length) { + allChecked = true; + } else if (selectedCount > 0) { + allChecked = "indeterminate"; + } + + const updateOption = (key: keyof DeleteTaskOptions, checked: boolean) => { + onOptionsChange((current) => ({ ...current, [key]: checked })); + }; + + const updateAllOptions = (checked: boolean) => { + onOptionsChange({ + delete_assets: checked, + delete_traffic: checked, + delete_files: checked, + delete_findings: checked, + delete_llm_records: checked, + }); + }; + + return ( +
+ 同时清理关联数据 + + + updateAllOptions(checked === true)} + /> + + 全部删除 + 选中下方全部关联数据,包括资产、流量、文件、漏洞和 LLM 请求/响应记录。 + + + + updateOption("delete_assets", checked === true)} + /> + + 关联资产 + 删除仅属于该任务的资产;共享资产只解除当前任务关联。 + + + + updateOption("delete_traffic", checked === true)} + /> + + 关联流量 + 按关联资产的精确主机名删除;仍被其他任务引用的共享主机流量会保留。 + + + + updateOption("delete_files", checked === true)} + /> + + 任务文件 + 删除该任务工作目录中的上传文件、命令输出和其他产物。 + + + + updateOption("delete_findings", checked === true)} + /> + + 关联漏洞 + 永久删除该任务产生的独立漏洞记录与漏洞报告。 + + + + updateOption("delete_llm_records", checked === true)} + /> + + LLM 请求/响应记录 + 永久删除该任务录制的 LLM 请求、响应、Token 与错误详情。 + + + +
+ ); +} + function DeleteTaskDialog({ task, onDelete, @@ -565,29 +813,6 @@ function DeleteTaskDialog({ const [deleting, setDeleting] = React.useState(false); const [options, setOptions] = React.useState(emptyDeleteOptions); - const selectedCount = deleteOptionKeys.filter((key) => options[key]).length; - const allSelected = selectedCount === deleteOptionKeys.length; - let allChecked: boolean | "indeterminate" = false; - if (allSelected) { - allChecked = true; - } else if (selectedCount > 0) { - allChecked = "indeterminate"; - } - - const updateOption = (key: keyof DeleteTaskOptions, checked: boolean) => { - setOptions((current) => ({ ...current, [key]: checked })); - }; - - const updateAllOptions = (checked: boolean) => { - setOptions({ - delete_assets: checked, - delete_traffic: checked, - delete_files: checked, - delete_findings: checked, - delete_llm_records: checked, - }); - }; - const handleOpenChange = (next: boolean) => { if (deleting) return; setOpen(next); @@ -621,77 +846,12 @@ function DeleteTaskDialog({ 的执行记录与探索链路将被永久删除。 -
- 同时清理关联数据 - - - updateAllOptions(checked === true)} - /> - - 全部删除 - 选中下方全部关联数据,包括资产、流量、文件、漏洞和 LLM 请求/响应记录。 - - - - updateOption("delete_assets", checked === true)} - /> - - 关联资产 - 删除仅属于该任务的资产;共享资产只解除当前任务关联。 - - - - updateOption("delete_traffic", checked === true)} - /> - - 关联流量 - 按关联资产的精确主机名删除;仍被其他任务引用的共享主机流量会保留。 - - - - updateOption("delete_files", checked === true)} - /> - - 任务文件 - 删除该任务工作目录中的上传文件、命令输出和其他产物。 - - - - updateOption("delete_findings", checked === true)} - /> - - 关联漏洞 - 永久删除该任务产生的独立漏洞记录与漏洞报告。 - - - - updateOption("delete_llm_records", checked === true)} - /> - - LLM 请求/响应记录 - 永久删除该任务录制的 LLM 请求、响应、Token 与错误详情。 - - - -
+ 取消 void, + ) => Promise; +}) { + const [open, setOpen] = React.useState(false); + const [deleting, setDeleting] = React.useState(false); + const [done, setDone] = React.useState(0); + const [options, setOptions] = React.useState(emptyDeleteOptions); + + const handleOpenChange = (next: boolean) => { + if (deleting) return; + setOpen(next); + if (next) { + setOptions(emptyDeleteOptions()); + setDone(0); + } + }; + + const handleDelete = async () => { + setDeleting(true); + setDone(0); + try { + await onDelete(ids, options, setDone); + setOpen(false); + } finally { + setDeleting(false); + } + }; + + return ( + + + + + + + 确认删除所选 {ids.length} 个任务? + + 这些任务的执行记录与探索链路将被永久删除,下方清理选项对所选任务统一生效。 + + +
+ {ids.slice(0, 30).map((id) => ( + #{id} + ))} + {ids.length > 30 && …等 {ids.length} 个} +
+ + + 取消 + { + event.preventDefault(); + void handleDelete(); + }} + > + {deleting && } + {deleting ? `删除中 ${done}/${ids.length}` : `删除 ${ids.length} 个任务`} + + +
+
+ ); +} + // CreateTaskSheet is the 新建任务 drawer. Its form state lives HERE, not in TasksPage: with // 描述/目标 held by the page component every keystroke re-rendered the whole task table // behind the drawer (plus its sticky column and 20 AlertDialog trees), which showed up as diff --git a/web/src/app/(main)/system/skills/page.tsx b/web/src/app/(main)/system/skills/page.tsx index ab6366d..756ef4b 100644 --- a/web/src/app/(main)/system/skills/page.tsx +++ b/web/src/app/(main)/system/skills/page.tsx @@ -14,6 +14,16 @@ import { UploadIcon, } from "lucide-react"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -99,6 +109,12 @@ type Creating = { kind: "file" | "dir"; } | null; +type PendingDelete = + | { kind: "skill"; skill: string } + | { kind: "file"; skill: string; path: string } + | { kind: "dir"; skill: string; path: string } + | null; + // ── Page ────────────────────────────────────────────────────────────────── export default function SkillsPage() { const [skills, setSkills] = React.useState([]); @@ -137,6 +153,10 @@ export default function SkillsPage() { // skill detail — MCP edit state (optimistic, rolls back on error) const [detailMcps, setDetailMcps] = React.useState([]); + // delete confirmation + const [pendingDelete, setPendingDelete] = React.useState(null); + const [deleting, setDeleting] = React.useState(false); + // ── Data ────────────────────────────────────────────────────────────────── const load = React.useCallback(() => { api.agents().then(setAgents).catch(() => {}); @@ -296,6 +316,19 @@ export default function SkillsPage() { } } + async function runPendingDelete() { + const p = pendingDelete; + if (!p) return; + setDeleting(true); + try { + if (p.kind === "skill") await deleteSkill(p.skill); + else await deletePath(p.skill, p.path); + } finally { + setDeleting(false); + setPendingDelete(null); + } + } + async function saveFile() { if (!selected || selected.path === null) return; setSaving(true); @@ -400,7 +433,7 @@ export default function SkillsPage() { return (
toggleExpanded(key)} > @@ -409,8 +442,9 @@ export default function SkillsPage() { ? : } - {node.name} - + {node.name} + {/* Absolute so a long name can never push the actions out of view */} + @@ -442,19 +476,24 @@ export default function SkillsPage() {
setSelected({ skill, path: node.path })} > - {node.name} - + {node.name} +
); }); @@ -496,7 +535,10 @@ export default function SkillsPage() { {uploading ? "上传中…" : "上传压缩包"}
- + {/* Radix viewport wraps children in a display:table div that grows with + content — force it to block so long names truncate instead of + widening the rows past the sidebar. */} +
{skills.map((s) => { const isOpen = expanded.has(s.name); @@ -507,7 +549,7 @@ export default function SkillsPage() { {/* skill 根节点 */}
{ @@ -520,8 +562,11 @@ export default function SkillsPage() { ? : } - {s.name} - + {s.name} + @@ -649,6 +694,35 @@ export default function SkillsPage() {
+ {/* ── 删除二次确认 ── */} + { if (!o) setPendingDelete(null); }}> + + + + {pendingDelete?.kind === "skill" && `删除 Skill「${pendingDelete.skill}」?`} + {pendingDelete?.kind === "dir" && `删除文件夹「${pendingDelete.path}」?`} + {pendingDelete?.kind === "file" && `删除文件「${pendingDelete.path}」?`} + + + {pendingDelete?.kind === "skill" + ? "将删除该 Skill 的全部文件、MCP 关联与可见性配置。此操作不可撤销。" + : pendingDelete?.kind === "dir" + ? "将一并删除该文件夹下的所有文件。此操作不可撤销。" + : "此操作不可撤销。"} + + + + 取消 + { e.preventDefault(); void runPendingDelete(); }} + > + {deleting ? "删除中…" : "删除"} + + + + + {/* ── 新建 Skill 对话框 ── */}