feat(ui): skill 树修复长文件名与删除确认,任务列表支持多选删除
Skill 管理: - 长文件名把行撑出侧栏导致操作按钮不可见,约束 ScrollArea viewport 内层并把按钮组改为绝对定位固定在右侧 - Skill / 文件夹 / 文件三处删除补上二次确认对话框 任务列表: - 表格新增多选列(本页全选 + 跨页保留选择) - 批量删除复用单任务的关联数据清理选项,串行执行并显示进度, 结果按成功/失败聚合提示 Claude-Session: https://claude.ai/code/session_01BNN7npEJs4X6LfTtgniMd8
This commit is contained in:
1 parent
def7649baa
commit
d14ce4d9f4
2 files changed
+430
-112
No files matched your search
@@ -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<DeleteTaskResult, "deleted" | "cleanup_warning">;
|
||||
|
||||
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<Set<string>>(() => 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 (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col gap-4 px-0 pt-6">
|
||||
@@ -325,6 +433,15 @@ export default function TasksPage() {
|
||||
<span className="text-muted-foreground text-xs tabular-nums">
|
||||
{filtered.length}/{tasks.length} 条
|
||||
</span>
|
||||
{selectedIds.size > 0 && (
|
||||
<>
|
||||
<span className="text-xs tabular-nums">已选 {selectedIds.size} 个</span>
|
||||
<Button size="sm" variant="ghost" onClick={() => setSelectedIds(new Set())}>
|
||||
取消选择
|
||||
</Button>
|
||||
<BulkDeleteTasksDialog ids={[...selectedIds]} onDelete={deleteTasks} />
|
||||
</>
|
||||
)}
|
||||
<ConcurrencySettingsDialog />
|
||||
<CreateTaskSheet tasks={tasks} onCreated={load} />
|
||||
</div>
|
||||
@@ -341,6 +458,13 @@ export default function TasksPage() {
|
||||
<Table className="**:data-[slot='table-cell']:px-4 **:data-[slot='table-head']:px-4">
|
||||
<TableHeader className="[&_tr]:border-t">
|
||||
<TableRow>
|
||||
<TableHead className="w-10">
|
||||
<Checkbox
|
||||
checked={headerChecked}
|
||||
onCheckedChange={(checked) => toggleSelectedPage(checked === true)}
|
||||
aria-label="选择本页全部任务"
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="font-mono">ID</TableHead>
|
||||
<TableHead>描述</TableHead>
|
||||
<TableHead>目标</TableHead>
|
||||
@@ -361,6 +485,8 @@ export default function TasksPage() {
|
||||
// 带来的整表重渲染,只让在跑的那几行走时长。
|
||||
nowSec={task.status === "running" ? nowSec : 0}
|
||||
onDelete={deleteTask}
|
||||
selected={selectedIds.has(task.id)}
|
||||
onSelectedChange={toggleSelected}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
@@ -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<void>;
|
||||
selected: boolean;
|
||||
onSelectedChange: (id: string, checked: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<TableRow className="group border-border/60">
|
||||
<TableRow className="group border-border/60" data-state={selected ? "selected" : undefined}>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={selected}
|
||||
onCheckedChange={(checked) => onSelectedChange(task.id, checked === true)}
|
||||
aria-label={`选择任务 ${task.id}`}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<code className="bg-muted rounded px-1.5 py-0.5 font-mono text-xs">{task.id}</code>
|
||||
</TableCell>
|
||||
@@ -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<React.SetStateAction<DeleteTaskOptions>>;
|
||||
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 (
|
||||
<FieldSet disabled={disabled}>
|
||||
<FieldLegend variant="label">同时清理关联数据</FieldLegend>
|
||||
<FieldGroup className="gap-3">
|
||||
<Field orientation="horizontal">
|
||||
<Checkbox
|
||||
id={`delete-all-${idPrefix}`}
|
||||
checked={allChecked}
|
||||
onCheckedChange={(checked) => updateAllOptions(checked === true)}
|
||||
/>
|
||||
<FieldContent>
|
||||
<FieldLabel htmlFor={`delete-all-${idPrefix}`}>全部删除</FieldLabel>
|
||||
<FieldDescription>选中下方全部关联数据,包括资产、流量、文件、漏洞和 LLM 请求/响应记录。</FieldDescription>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field orientation="horizontal">
|
||||
<Checkbox
|
||||
id={`delete-assets-${idPrefix}`}
|
||||
checked={options.delete_assets}
|
||||
onCheckedChange={(checked) => updateOption("delete_assets", checked === true)}
|
||||
/>
|
||||
<FieldContent>
|
||||
<FieldLabel htmlFor={`delete-assets-${idPrefix}`}>关联资产</FieldLabel>
|
||||
<FieldDescription>删除仅属于该任务的资产;共享资产只解除当前任务关联。</FieldDescription>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field orientation="horizontal">
|
||||
<Checkbox
|
||||
id={`delete-traffic-${idPrefix}`}
|
||||
checked={options.delete_traffic}
|
||||
onCheckedChange={(checked) => updateOption("delete_traffic", checked === true)}
|
||||
/>
|
||||
<FieldContent>
|
||||
<FieldLabel htmlFor={`delete-traffic-${idPrefix}`}>关联流量</FieldLabel>
|
||||
<FieldDescription>按关联资产的精确主机名删除;仍被其他任务引用的共享主机流量会保留。</FieldDescription>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field orientation="horizontal">
|
||||
<Checkbox
|
||||
id={`delete-files-${idPrefix}`}
|
||||
checked={options.delete_files}
|
||||
onCheckedChange={(checked) => updateOption("delete_files", checked === true)}
|
||||
/>
|
||||
<FieldContent>
|
||||
<FieldLabel htmlFor={`delete-files-${idPrefix}`}>任务文件</FieldLabel>
|
||||
<FieldDescription>删除该任务工作目录中的上传文件、命令输出和其他产物。</FieldDescription>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field orientation="horizontal">
|
||||
<Checkbox
|
||||
id={`delete-findings-${idPrefix}`}
|
||||
checked={options.delete_findings}
|
||||
onCheckedChange={(checked) => updateOption("delete_findings", checked === true)}
|
||||
/>
|
||||
<FieldContent>
|
||||
<FieldLabel htmlFor={`delete-findings-${idPrefix}`}>关联漏洞</FieldLabel>
|
||||
<FieldDescription>永久删除该任务产生的独立漏洞记录与漏洞报告。</FieldDescription>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field orientation="horizontal">
|
||||
<Checkbox
|
||||
id={`delete-llm-records-${idPrefix}`}
|
||||
checked={options.delete_llm_records}
|
||||
onCheckedChange={(checked) => updateOption("delete_llm_records", checked === true)}
|
||||
/>
|
||||
<FieldContent>
|
||||
<FieldLabel htmlFor={`delete-llm-records-${idPrefix}`}>LLM 请求/响应记录</FieldLabel>
|
||||
<FieldDescription>永久删除该任务录制的 LLM 请求、响应、Token 与错误详情。</FieldDescription>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</FieldSet>
|
||||
);
|
||||
}
|
||||
|
||||
function DeleteTaskDialog({
|
||||
task,
|
||||
onDelete,
|
||||
@@ -565,29 +813,6 @@ function DeleteTaskDialog({
|
||||
const [deleting, setDeleting] = React.useState(false);
|
||||
const [options, setOptions] = React.useState<DeleteTaskOptions>(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({
|
||||
的执行记录与探索链路将被永久删除。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<FieldSet disabled={deleting}>
|
||||
<FieldLegend variant="label">同时清理关联数据</FieldLegend>
|
||||
<FieldGroup className="gap-3">
|
||||
<Field orientation="horizontal">
|
||||
<Checkbox
|
||||
id={`delete-all-${task.id}`}
|
||||
checked={allChecked}
|
||||
onCheckedChange={(checked) => updateAllOptions(checked === true)}
|
||||
/>
|
||||
<FieldContent>
|
||||
<FieldLabel htmlFor={`delete-all-${task.id}`}>全部删除</FieldLabel>
|
||||
<FieldDescription>选中下方全部关联数据,包括资产、流量、文件、漏洞和 LLM 请求/响应记录。</FieldDescription>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field orientation="horizontal">
|
||||
<Checkbox
|
||||
id={`delete-assets-${task.id}`}
|
||||
checked={options.delete_assets}
|
||||
onCheckedChange={(checked) => updateOption("delete_assets", checked === true)}
|
||||
/>
|
||||
<FieldContent>
|
||||
<FieldLabel htmlFor={`delete-assets-${task.id}`}>关联资产</FieldLabel>
|
||||
<FieldDescription>删除仅属于该任务的资产;共享资产只解除当前任务关联。</FieldDescription>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field orientation="horizontal">
|
||||
<Checkbox
|
||||
id={`delete-traffic-${task.id}`}
|
||||
checked={options.delete_traffic}
|
||||
onCheckedChange={(checked) => updateOption("delete_traffic", checked === true)}
|
||||
/>
|
||||
<FieldContent>
|
||||
<FieldLabel htmlFor={`delete-traffic-${task.id}`}>关联流量</FieldLabel>
|
||||
<FieldDescription>按关联资产的精确主机名删除;仍被其他任务引用的共享主机流量会保留。</FieldDescription>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field orientation="horizontal">
|
||||
<Checkbox
|
||||
id={`delete-files-${task.id}`}
|
||||
checked={options.delete_files}
|
||||
onCheckedChange={(checked) => updateOption("delete_files", checked === true)}
|
||||
/>
|
||||
<FieldContent>
|
||||
<FieldLabel htmlFor={`delete-files-${task.id}`}>任务文件</FieldLabel>
|
||||
<FieldDescription>删除该任务工作目录中的上传文件、命令输出和其他产物。</FieldDescription>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field orientation="horizontal">
|
||||
<Checkbox
|
||||
id={`delete-findings-${task.id}`}
|
||||
checked={options.delete_findings}
|
||||
onCheckedChange={(checked) => updateOption("delete_findings", checked === true)}
|
||||
/>
|
||||
<FieldContent>
|
||||
<FieldLabel htmlFor={`delete-findings-${task.id}`}>关联漏洞</FieldLabel>
|
||||
<FieldDescription>永久删除该任务产生的独立漏洞记录与漏洞报告。</FieldDescription>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field orientation="horizontal">
|
||||
<Checkbox
|
||||
id={`delete-llm-records-${task.id}`}
|
||||
checked={options.delete_llm_records}
|
||||
onCheckedChange={(checked) => updateOption("delete_llm_records", checked === true)}
|
||||
/>
|
||||
<FieldContent>
|
||||
<FieldLabel htmlFor={`delete-llm-records-${task.id}`}>LLM 请求/响应记录</FieldLabel>
|
||||
<FieldDescription>永久删除该任务录制的 LLM 请求、响应、Token 与错误详情。</FieldDescription>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</FieldSet>
|
||||
<DeleteOptionFields
|
||||
idPrefix={task.id}
|
||||
options={options}
|
||||
onOptionsChange={setOptions}
|
||||
disabled={deleting}
|
||||
/>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleting}>取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
@@ -711,6 +871,90 @@ function DeleteTaskDialog({
|
||||
);
|
||||
}
|
||||
|
||||
// BulkDeleteTasksDialog deletes every checked task with one shared set of cleanup options.
|
||||
// The backend has no batch endpoint, so deletion runs one task at a time (see deleteTasks)
|
||||
// and the button shows live progress.
|
||||
function BulkDeleteTasksDialog({
|
||||
ids,
|
||||
onDelete,
|
||||
}: {
|
||||
ids: string[];
|
||||
onDelete: (
|
||||
ids: string[],
|
||||
options: DeleteTaskOptions,
|
||||
onProgress: (done: number) => void,
|
||||
) => Promise<void>;
|
||||
}) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [deleting, setDeleting] = React.useState(false);
|
||||
const [done, setDone] = React.useState(0);
|
||||
const [options, setOptions] = React.useState<DeleteTaskOptions>(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 (
|
||||
<AlertDialog open={open} onOpenChange={handleOpenChange}>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button size="sm" variant="outline">
|
||||
<Trash2Icon className="text-destructive" /> 删除所选 {ids.length}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent className="max-h-[85vh] overflow-y-auto">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除所选 {ids.length} 个任务?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
这些任务的执行记录与探索链路将被永久删除,下方清理选项对所选任务统一生效。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<div className="text-muted-foreground flex flex-wrap gap-1 text-xs">
|
||||
{ids.slice(0, 30).map((id) => (
|
||||
<code key={id} className="bg-muted rounded px-1.5 py-0.5 font-mono">#{id}</code>
|
||||
))}
|
||||
{ids.length > 30 && <span className="self-center">…等 {ids.length} 个</span>}
|
||||
</div>
|
||||
<DeleteOptionFields
|
||||
idPrefix="bulk"
|
||||
options={options}
|
||||
onOptionsChange={setOptions}
|
||||
disabled={deleting}
|
||||
/>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleting}>取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
disabled={deleting}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
void handleDelete();
|
||||
}}
|
||||
>
|
||||
{deleting && <Spinner data-icon="inline-start" />}
|
||||
{deleting ? `删除中 ${done}/${ids.length}` : `删除 ${ids.length} 个任务`}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
@@ -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<SkillItem[]>([]);
|
||||
@@ -137,6 +153,10 @@ export default function SkillsPage() {
|
||||
// skill detail — MCP edit state (optimistic, rolls back on error)
|
||||
const [detailMcps, setDetailMcps] = React.useState<string[]>([]);
|
||||
|
||||
// delete confirmation
|
||||
const [pendingDelete, setPendingDelete] = React.useState<PendingDelete>(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 (
|
||||
<div key={node.path}>
|
||||
<div
|
||||
className="group flex cursor-pointer select-none items-center gap-1 rounded py-0.5 pr-1 text-sm hover:bg-muted"
|
||||
className="group relative flex cursor-pointer select-none items-center gap-1 rounded py-0.5 pr-1 text-sm hover:bg-muted"
|
||||
style={{ paddingLeft: baseIndent }}
|
||||
onClick={() => toggleExpanded(key)}
|
||||
>
|
||||
@@ -409,8 +442,9 @@ export default function SkillsPage() {
|
||||
? <FolderOpenIcon className="size-3.5 shrink-0 text-amber-500" />
|
||||
: <FolderIcon className="size-3.5 shrink-0 text-amber-500" />
|
||||
}
|
||||
<span className="flex-1 truncate">{node.name}</span>
|
||||
<span className="ml-auto hidden shrink-0 items-center gap-0.5 group-hover:flex">
|
||||
<span className="min-w-0 flex-1 truncate" title={node.path}>{node.name}</span>
|
||||
{/* Absolute so a long name can never push the actions out of view */}
|
||||
<span className="absolute inset-y-0 right-1 hidden items-center gap-0.5 rounded bg-muted pl-1 group-hover:flex">
|
||||
<Button size="icon" variant="ghost" className="size-5" title="新建文件"
|
||||
onClick={(e) => { e.stopPropagation(); startCreate(skill, node.path, "file"); }}>
|
||||
<FilePlusIcon className="size-3 text-muted-foreground" />
|
||||
@@ -420,7 +454,7 @@ export default function SkillsPage() {
|
||||
<FolderPlusIcon className="size-3 text-muted-foreground" />
|
||||
</Button>
|
||||
<Button size="icon" variant="ghost" className="size-5" title="删除文件夹"
|
||||
onClick={(e) => { e.stopPropagation(); deletePath(skill, node.path); }}>
|
||||
onClick={(e) => { e.stopPropagation(); setPendingDelete({ kind: "dir", skill, path: node.path }); }}>
|
||||
<Trash2Icon className="size-3 text-destructive" />
|
||||
</Button>
|
||||
</span>
|
||||
@@ -442,19 +476,24 @@ export default function SkillsPage() {
|
||||
<div
|
||||
key={node.path}
|
||||
className={cn(
|
||||
"group flex cursor-pointer select-none items-center gap-1 rounded py-0.5 pr-1 text-sm",
|
||||
"group relative flex cursor-pointer select-none items-center gap-1 rounded py-0.5 pr-1 text-sm",
|
||||
isSelected ? "bg-accent text-accent-foreground" : "hover:bg-muted",
|
||||
)}
|
||||
style={{ paddingLeft: baseIndent + 16 }}
|
||||
onClick={() => setSelected({ skill, path: node.path })}
|
||||
>
|
||||
<FileTextIcon className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="flex-1 truncate font-mono text-xs">{node.name}</span>
|
||||
<Button size="icon" variant="ghost" className="ml-auto hidden size-5 group-hover:flex"
|
||||
title="删除文件"
|
||||
onClick={(e) => { e.stopPropagation(); deletePath(skill, node.path); }}>
|
||||
<Trash2Icon className="size-3 text-destructive" />
|
||||
</Button>
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-xs" title={node.path}>{node.name}</span>
|
||||
<span className={cn(
|
||||
"absolute inset-y-0 right-1 hidden items-center rounded pl-1 group-hover:flex",
|
||||
isSelected ? "bg-accent" : "bg-muted",
|
||||
)}>
|
||||
<Button size="icon" variant="ghost" className="size-5"
|
||||
title="删除文件"
|
||||
onClick={(e) => { e.stopPropagation(); setPendingDelete({ kind: "file", skill, path: node.path }); }}>
|
||||
<Trash2Icon className="size-3 text-destructive" />
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -496,7 +535,10 @@ export default function SkillsPage() {
|
||||
{uploading ? "上传中…" : "上传压缩包"}
|
||||
</Button>
|
||||
</div>
|
||||
<ScrollArea className="flex-1">
|
||||
{/* 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. */}
|
||||
<ScrollArea className="flex-1 [&>[data-slot=scroll-area-viewport]>div]:!block">
|
||||
<div className="p-1">
|
||||
{skills.map((s) => {
|
||||
const isOpen = expanded.has(s.name);
|
||||
@@ -507,7 +549,7 @@ export default function SkillsPage() {
|
||||
{/* skill 根节点 */}
|
||||
<div
|
||||
className={cn(
|
||||
"group flex cursor-pointer select-none items-center gap-1 rounded px-2 py-1 text-sm",
|
||||
"group relative flex cursor-pointer select-none items-center gap-1 rounded px-2 py-1 text-sm",
|
||||
isSkillSelected ? "bg-accent text-accent-foreground" : "hover:bg-muted",
|
||||
)}
|
||||
onClick={() => {
|
||||
@@ -520,8 +562,11 @@ export default function SkillsPage() {
|
||||
? <FolderOpenIcon className="size-3.5 shrink-0 text-blue-500" />
|
||||
: <FolderIcon className="size-3.5 shrink-0 text-blue-500" />
|
||||
}
|
||||
<span className="flex-1 truncate font-semibold">{s.name}</span>
|
||||
<span className="ml-auto hidden shrink-0 items-center gap-0.5 group-hover:flex">
|
||||
<span className="min-w-0 flex-1 truncate font-semibold" title={s.name}>{s.name}</span>
|
||||
<span className={cn(
|
||||
"absolute inset-y-0 right-1 hidden items-center gap-0.5 rounded pl-1 group-hover:flex",
|
||||
isSkillSelected ? "bg-accent" : "bg-muted",
|
||||
)}>
|
||||
<Button size="icon" variant="ghost" className="size-5" title="新建文件"
|
||||
onClick={(e) => { e.stopPropagation(); startCreate(s.name, "", "file"); }}>
|
||||
<FilePlusIcon className="size-3 text-muted-foreground" />
|
||||
@@ -531,7 +576,7 @@ export default function SkillsPage() {
|
||||
<FolderPlusIcon className="size-3 text-muted-foreground" />
|
||||
</Button>
|
||||
<Button size="icon" variant="ghost" className="size-5" title="删除 Skill"
|
||||
onClick={(e) => { e.stopPropagation(); deleteSkill(s.name); }}>
|
||||
onClick={(e) => { e.stopPropagation(); setPendingDelete({ kind: "skill", skill: s.name }); }}>
|
||||
<Trash2Icon className="size-3 text-destructive" />
|
||||
</Button>
|
||||
</span>
|
||||
@@ -649,6 +694,35 @@ export default function SkillsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 删除二次确认 ── */}
|
||||
<AlertDialog open={!!pendingDelete} onOpenChange={(o) => { if (!o) setPendingDelete(null); }}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{pendingDelete?.kind === "skill" && `删除 Skill「${pendingDelete.skill}」?`}
|
||||
{pendingDelete?.kind === "dir" && `删除文件夹「${pendingDelete.path}」?`}
|
||||
{pendingDelete?.kind === "file" && `删除文件「${pendingDelete.path}」?`}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{pendingDelete?.kind === "skill"
|
||||
? "将删除该 Skill 的全部文件、MCP 关联与可见性配置。此操作不可撤销。"
|
||||
: pendingDelete?.kind === "dir"
|
||||
? "将一并删除该文件夹下的所有文件。此操作不可撤销。"
|
||||
: "此操作不可撤销。"}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleting}>取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
disabled={deleting}
|
||||
onClick={(e) => { e.preventDefault(); void runPendingDelete(); }}
|
||||
>
|
||||
{deleting ? "删除中…" : "删除"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* ── 新建 Skill 对话框 ── */}
|
||||
<Sheet open={newOpen} onOpenChange={setNewOpen}>
|
||||
<SheetContent side="right" className="flex w-full flex-col gap-0 data-[side=right]:sm:max-w-xl">
|
||||
|
||||
Reference in new issue
Block a user