Files
ARTEX-chixuweihu/web/src/app/(main)/function/tasks/page.tsx
T
2026-08-25 16:48:20 +08:00

2717 lines
101 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import * as React from "react";
import Link from "next/link";
import {
DndContext,
type DragEndEvent,
DragOverlay,
type DragStartEvent,
KeyboardSensor,
PointerSensor,
useDraggable,
useDroppable,
useSensor,
useSensors,
} from "@dnd-kit/core";
import {
ArrowDownIcon,
ArrowUpDownIcon,
ArrowUpIcon,
ChevronRightIcon,
EyeIcon,
FolderInputIcon,
GripVerticalIcon,
Loader2Icon,
PaperclipIcon,
PauseIcon,
PencilIcon,
PinIcon,
PinOffIcon,
PlayIcon,
PlusIcon,
SaveIcon,
SearchIcon,
SlidersHorizontalIcon,
StarIcon,
TagsIcon,
Trash2Icon,
XIcon,
} from "lucide-react";
import { toast } from "sonner";
import { StatusBadge } from "@/components/status-badge";
import { TablePagination } from "@/components/table-pagination";
import { TaskLLMProfileChain } from "@/components/task-llm-profile-chain";
import { TaskTemplateControls } from "@/components/task-template-controls";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import {
Combobox,
ComboboxChip,
ComboboxChips,
ComboboxChipsInput,
ComboboxContent,
ComboboxEmpty,
ComboboxItem,
ComboboxList,
ComboboxValue,
} from "@/components/ui/combobox";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@/components/ui/empty";
import {
Field,
FieldContent,
FieldDescription,
FieldGroup,
FieldLabel,
FieldLegend,
FieldSet,
} from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { Item, ItemActions, ItemContent, ItemDescription, ItemGroup, ItemMedia, ItemTitle } from "@/components/ui/item";
import { Label } from "@/components/ui/label";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet";
import { Spinner } from "@/components/ui/spinner";
import { Switch } from "@/components/ui/switch";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Textarea } from "@/components/ui/textarea";
import { api } from "@/lib/api";
import { type SortDirection, useStoredSortPreference } from "@/lib/sort-preference";
import type {
ChatAttachment,
Company,
DeleteTaskOptions,
DeleteTaskResult,
LLMProfile,
Task,
TaskCategory,
TaskStatus,
} from "@/lib/types";
import { cn } from "@/lib/utils";
// fmtBytes renders a human file size for the upload manifest.
function fmtBytes(n: number): string {
if (n >= 1 << 20) return `${(n / (1 << 20)).toFixed(1)} MB`;
if (n >= 1 << 10) return `${(n / (1 << 10)).toFixed(1)} KB`;
return `${n} B`;
}
// UPLOAD_MARKER labels the auto-appended block of uploaded-file paths inside the task
// description, so re-uploads append under the same block instead of adding a new header.
const UPLOAD_MARKER = "【上传文件(绝对路径)】";
// appendUploads folds newly-uploaded files' ABSOLUTE paths into the description as a
// Read/Bash-friendly manifest — the worker opens them by path. Keeps one marked block:
// first upload adds the header, later uploads append bullets under it.
function appendUploads(desc: string, atts: ChatAttachment[]): string {
const bullets = atts.map((a) => `- ${a.abs ?? a.path}(${fmtBytes(a.size)})`).join("\n");
if (desc.includes(UPLOAD_MARKER)) {
return `${desc.replace(/\s*$/, "")}\n${bullets}\n`;
}
const head = desc.trim() ? `${desc.replace(/\s*$/, "")}\n\n` : "";
return `${head}${UPLOAD_MARKER} worker 可用 Read/Bash 按路径打开:\n${bullets}\n`;
}
// POLL_MS is the task-list refresh interval. Task state moves on the server (planner /
// worker), so the list has to be pulled; 10s is plenty for status / 进度 / token 变化.
const POLL_MS = 10_000;
const MAX_SOURCE_TASKS = 8;
// fmtTokens renders a compact token count (1234 → 1.2k, 2_000_000 → 2M).
function fmtTokens(n: number): string {
if (n >= 1_000_000) return (n / 1_000_000).toFixed(n >= 10_000_000 ? 0 : 1) + "M";
if (n >= 1000) return (n / 1000).toFixed(n >= 10000 ? 0 : 1) + "k";
return String(n);
}
// fmtDuration renders a run duration in seconds as a compact human string.
function fmtDuration(sec: number): string {
if (sec <= 0) return "—";
const d = Math.floor(sec / 86400);
const h = Math.floor((sec % 86400) / 3600);
const m = Math.floor((sec % 3600) / 60);
const s = sec % 60;
if (d > 0) return `${d}d ${h}h`;
if (h > 0) return `${h}h ${m}m`;
if (m > 0) return `${m}m ${s}s`;
return `${s}s`;
}
// taskDuration is the task's run duration in seconds: created → now while running,
// created → completion for a finished task, else created → last activity. 0 when it
// never ran (no activity yet).
function taskDuration(task: Task, nowSec: number): number {
const start = task.created_unix ?? 0;
if (!start) return 0;
const end =
task.status === "running"
? nowSec
: task.completed_unix && task.completed_unix > 0
? task.completed_unix
: (task.last_activity_unix ?? 0);
return end > start ? end - start : 0;
}
// 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} 条`);
if (result.traffic_deleted > 0) details.push(`删除流量 ${result.traffic_deleted} 条`);
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(",")})` : "任务已删除";
}
// fmtDateTime renders a unix-seconds timestamp as a compact local date-time
// (MM-DD HH:mm), or "—" when unset.
function fmtDateTime(unix?: number): string {
if (!unix || unix <= 0) return "—";
const d = new Date(unix * 1000);
const p = (n: number) => String(n).padStart(2, "0");
return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
}
const STATUS_OPTIONS: { value: TaskStatus; label: string }[] = [
{ value: "created", label: "已创建" },
{ value: "queued", label: "排队中" },
{ value: "running", label: "运行中" },
{ value: "paused", label: "已暂停" },
{ value: "done", label: "已完成" },
{ value: "failed", label: "失败" },
{ value: "timeout", label: "已超时" },
];
// Select 不接受空字符串 value,所以「无分类」在筛选器、新建表单和批量移动里
// 统一用这个哨兵值,提交时再翻译成后端的 null。
const UNCATEGORIZED_VALUE = "uncategorized";
// 可暂停 = 非终态且未暂停,与后端 applyTaskControlWithCause 的门控一致
// (done/failed/timeout 为终态);paused 才可继续。行内按钮与批量控制共用此判断,
// 两边不会出现一个可点、另一个不可点的分歧。
const PAUSABLE_STATUSES = new Set<TaskStatus>(["created", "queued", "running"]);
function taskControlAction(status: TaskStatus): "pause" | "resume" | null {
if (status === "paused") return "resume";
if (PAUSABLE_STATUSES.has(status)) return "pause";
return null;
}
type TaskSortField = "id" | "created" | "duration" | "status";
const TASK_SORT_FIELDS: readonly TaskSortField[] = ["id", "created", "duration", "status"];
const TASK_SORT_PREFERENCE_KEY = "artex_task_list_sort";
const TASK_STATUS_RANK = new Map(STATUS_OPTIONS.map((option, index) => [option.value, index]));
function taskIsPinned(task: Task): boolean {
return task.pinned ?? Boolean(task.pinned_at);
}
function compareTaskIDs(left: string, right: string): number {
return left.localeCompare(right, undefined, { numeric: true, sensitivity: "base" });
}
function taskCreatedUnix(task: Task): number {
if (task.created_unix) return task.created_unix;
const parsed = Date.parse(task.created_at);
return Number.isNaN(parsed) ? 0 : Math.floor(parsed / 1000);
}
function compareTasks(left: Task, right: Task, field: TaskSortField, direction: SortDirection, nowSec: number): number {
const leftPinned = taskIsPinned(left);
const rightPinned = taskIsPinned(right);
if (leftPinned !== rightPinned) return leftPinned ? -1 : 1;
if (leftPinned && left.pinned_at !== right.pinned_at) {
return (right.pinned_at ?? "").localeCompare(left.pinned_at ?? "");
}
let compared = 0;
switch (field) {
case "created":
compared = taskCreatedUnix(left) - taskCreatedUnix(right);
break;
case "duration":
compared = taskDuration(left, nowSec) - taskDuration(right, nowSec);
break;
case "status":
compared = (TASK_STATUS_RANK.get(left.status) ?? 0) - (TASK_STATUS_RANK.get(right.status) ?? 0);
break;
default:
compared = compareTaskIDs(left.id, right.id);
}
if (compared !== 0) return direction === "asc" ? compared : -compared;
return -compareTaskIDs(left.id, right.id);
}
export default function TasksPage() {
const [tasks, setTasks] = React.useState<Task[]>([]);
const [categories, setCategories] = React.useState<TaskCategory[]>([]);
const [query, setQuery] = React.useState("");
const [statusFilter, setStatusFilter] = React.useState<TaskStatus | "all">("all");
const [categoryFilter, setCategoryFilter] = React.useState("all");
const [sortPreference, setSortPreference] = useStoredSortPreference(
TASK_SORT_PREFERENCE_KEY,
TASK_SORT_FIELDS,
"id",
"desc",
);
const { field: sortField, direction: sortDirection } = sortPreference;
const [page, setPage] = React.useState(1);
const [pageSize, setPageSize] = React.useState(20);
const [nowSec, setNowSec] = React.useState(() => Math.floor(Date.now() / 1000));
const [batchControlling, setBatchControlling] = React.useState<"pause" | "resume" | null>(null);
const [movingCategory, setMovingCategory] = React.useState(false);
const filtered = React.useMemo(() => {
const q = query.trim().toLowerCase();
return tasks.filter((t) => {
if (statusFilter !== "all" && t.status !== statusFilter) return false;
if (categoryFilter === UNCATEGORIZED_VALUE && t.category_id != null) return false;
if (
categoryFilter !== "all" &&
categoryFilter !== UNCATEGORIZED_VALUE &&
String(t.category_id) !== categoryFilter
)
return false;
if (!q) return true;
return (
(t.name ?? "").toLowerCase().includes(q) ||
(t.category_name ?? "").toLowerCase().includes(q) ||
t.description.toLowerCase().includes(q) ||
t.goal.toLowerCase().includes(q) ||
t.id.toLowerCase().includes(q)
);
});
}, [tasks, query, statusFilter, categoryFilter]);
const sortNowSec = sortField === "duration" ? nowSec : 0;
const ordered = React.useMemo(() => {
return [...filtered].sort((left, right) => compareTasks(left, right, sortField, sortDirection, sortNowSec));
}, [filtered, sortDirection, sortField, sortNowSec]);
const sortTasksBy = React.useCallback(
(field: TaskSortField) => {
setSortPreference((current) => {
if (current.field !== field) return { field, direction: "desc" };
return { field, direction: current.direction === "asc" ? "desc" : "asc" };
});
},
[setSortPreference],
);
// reset to page 1 whenever filters or ordering change
// biome-ignore lint/correctness/useExhaustiveDependencies: both filters intentionally reset pagination.
React.useEffect(() => {
setPage(1);
}, [query, statusFilter, categoryFilter, sortField, sortDirection]);
const paginated = React.useMemo(
() => ordered.slice((page - 1) * pageSize, page * pageSize),
[ordered, 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.
const lastRef = React.useRef<string>("");
const load = React.useCallback(() => {
api
.tasks()
.then((r) => {
const next = r.tasks.map((t) => (t.id === r.active ? { ...t, active: true } : t));
const sig = JSON.stringify(next);
if (sig === lastRef.current) return;
lastRef.current = sig;
setTasks(next);
})
.catch(() => {
// Polling is best-effort; the next interval retries automatically.
});
}, []);
const loadCategories = React.useCallback(() => {
api
.taskCategories()
.then(setCategories)
.catch(() => {
// Category management remains retryable without blocking the task list.
});
}, []);
React.useEffect(() => {
load();
loadCategories();
const i = setInterval(load, POLL_MS);
return () => clearInterval(i);
}, [load, loadCategories]);
const refreshCategoriesAndTasks = React.useCallback(() => {
lastRef.current = "";
loadCategories();
load();
}, [load, loadCategories]);
const applyTaskCategoryMove = React.useCallback(
(taskID: string, category: TaskCategory | null) => {
setTasks((current) => {
const next = current.map((task) =>
task.id === taskID ? { ...task, category_id: category?.id, category_name: category?.name } : task,
);
lastRef.current = JSON.stringify(next);
return next;
});
loadCategories();
},
[loadCategories],
);
React.useEffect(() => {
if (categoryFilter === "all" || categoryFilter === UNCATEGORIZED_VALUE) return;
if (!categories.some((category) => String(category.id) === categoryFilter)) setCategoryFilter("all");
}, [categories, categoryFilter]);
// 只在确有 running 任务时才每秒 tick——其余情况「运行时长」是静态值,空转的 tick 会白白
// 重渲染整张表。
const hasRunning = React.useMemo(() => tasks.some((t) => t.status === "running"), [tasks]);
// tick every second so running tasks' 运行时长 counts up live.
React.useEffect(() => {
if (!hasRunning) return;
setNowSec(Math.floor(Date.now() / 1000));
const i = setInterval(() => setNowSec(Math.floor(Date.now() / 1000)), 1000);
return () => clearInterval(i);
}, [hasRunning]);
const deleteTask = React.useCallback(
async (id: string, options: DeleteTaskOptions) => {
try {
const result = await api.deleteTask(id, options);
if (result.cleanup_warning) {
toast.warning(`${deleteSummary(result)};部分外部数据清理未完成:${result.cleanup_warning}`);
} else {
toast.success(deleteSummary(result));
}
load();
} catch (e) {
toast.error("删除失败:" + (e as Error).message);
throw e;
}
},
[load],
);
// controlTask 是行内暂停/继续:批量走 controlTasksBatch,单行走单任务接口,省去
// 「先勾选再点批量」。清空 lastRef 让下一次轮询即使负载相同也照单接收,否则状态
// 回写会被去重挡掉、按钮看起来没反应。
const controlTask = React.useCallback(
async (id: string, action: "pause" | "resume") => {
try {
const result = await api.controlTask(id, action);
toast.success(
action === "pause" ? `任务 #${id} 已暂停` : `任务 #${id} 已继续${result.queued ? ",已进入队列" : ""}`,
);
} catch (e) {
toast.error(`${action === "pause" ? "暂停" : "继续"}失败:${(e as Error).message}`);
} finally {
// 无论成败都刷新:失败多半是状态已变化,重新拉取才能让按钮回到正确形态。
lastRef.current = "";
load();
}
},
[load],
);
const renameTask = React.useCallback(
async (task: Task, name: string) => {
try {
await api.renameTask(task.id, name);
toast.success(`任务 #${task.id} 已重命名`);
lastRef.current = "";
load();
} catch (error) {
toast.error(`重命名失败:${(error as Error).message}`);
throw error;
}
},
[load],
);
const toggleTaskPinned = React.useCallback(
async (task: Task) => {
const pinned = taskIsPinned(task);
try {
await api.pinTask(task.id, !pinned);
toast.success(pinned ? `任务 #${task.id} 已取消置顶` : `任务 #${task.id} 已置顶`);
lastRef.current = "";
load();
} catch (error) {
toast.error(`${pinned ? "取消置顶" : "置顶"}失败:${(error as Error).message}`);
throw error;
}
},
[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],
);
const selectedTasks = React.useMemo(() => tasks.filter((task) => selectedIds.has(task.id)), [tasks, selectedIds]);
const pausableTaskIDs = React.useMemo(
() => selectedTasks.filter((task) => taskControlAction(task.status) === "pause").map((task) => task.id),
[selectedTasks],
);
const resumableTaskIDs = React.useMemo(
() => selectedTasks.filter((task) => taskControlAction(task.status) === "resume").map((task) => task.id),
[selectedTasks],
);
const controlSelectedTasks = React.useCallback(
async (action: "pause" | "resume", ids: string[]) => {
if (ids.length === 0 || batchControlling) return;
if (ids.length > 100) {
toast.error("一次最多控制 100 个任务");
return;
}
setBatchControlling(action);
try {
const result = await api.controlTasksBatch(ids, action);
const succeeded = result.items.filter((item) => item.ok);
const failed = result.items.filter((item) => !item.ok);
if (succeeded.length > 0) {
toast.success(
action === "pause"
? `已暂停 ${succeeded.length} 个任务`
: `已继续 ${succeeded.length} 个任务${succeeded.some((item) => item.queued) ? ",部分任务已进入队列" : ""}`,
);
}
if (failed.length > 0) {
const details = failed
.slice(0, 3)
.map((item) => `#${item.id}(${item.error || "状态已变化"})`)
.join(";");
toast.error(`${failed.length} 个任务操作失败:${details}${failed.length > 3 ? " 等" : ""}`);
}
lastRef.current = "";
load();
} catch (error) {
toast.error(`${action === "pause" ? "批量暂停" : "批量继续"}失败:${(error as Error).message}`);
} finally {
setBatchControlling(null);
}
},
[batchControlling, load],
);
// 后端把整批分类写入放在一个事务里,所以失败项只可能是勾选后又被删掉的任务。
const moveSelectedTasksCategory = React.useCallback(
async (categoryID?: number) => {
const ids = [...selectedIds];
if (ids.length === 0 || movingCategory) return;
if (ids.length > 100) {
toast.error("一次最多修改 100 个任务的分类");
return;
}
setMovingCategory(true);
try {
const result = await api.updateTasksCategory(ids, categoryID);
const succeeded = result.items.filter((item) => item.ok);
const failed = result.items.filter((item) => !item.ok);
const target = result.category?.name ?? "未分类";
if (succeeded.length > 0) {
toast.success(`已将 ${succeeded.length} 个任务移动到「${target}」`);
setSelectedIds(new Set());
}
if (failed.length > 0) {
const details = failed
.slice(0, 3)
.map((item) => `#${item.id}(${item.error || "任务已不存在"})`)
.join(";");
toast.error(`${failed.length} 个任务未能移动:${details}${failed.length > 3 ? " 等" : ""}`);
}
refreshCategoriesAndTasks();
} catch (error) {
toast.error(`修改分类失败:${(error as Error).message}`);
} finally {
setMovingCategory(false);
}
},
[movingCategory, refreshCategoriesAndTasks, selectedIds],
);
return (
<Card>
<CardContent className="flex flex-col gap-4 px-0 pt-6">
<div className="flex flex-wrap items-center gap-2 px-4 lg:px-6">
<div className="relative w-full sm:max-w-xs">
<SearchIcon className="text-muted-foreground pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2" />
<Input
placeholder="搜索描述 / 目标 / ID"
value={query}
onChange={(e) => setQuery(e.target.value)}
className="pl-8"
/>
{query && (
<button
type="button"
onClick={() => setQuery("")}
aria-label="清除搜索"
className="text-muted-foreground hover:text-foreground absolute top-1/2 right-2 -translate-y-1/2"
>
<XIcon className="size-4" />
</button>
)}
</div>
<Select value={statusFilter} onValueChange={(v) => setStatusFilter(v as TaskStatus | "all")}>
<SelectTrigger className="w-36">
<SelectValue placeholder="状态" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="all">全部状态</SelectItem>
{STATUS_OPTIONS.map((s) => (
<SelectItem key={s.value} value={s.value}>
{s.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<Select value={categoryFilter} onValueChange={setCategoryFilter}>
<SelectTrigger className="w-40">
<SelectValue placeholder="任务分类" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="all">全部分类</SelectItem>
<SelectItem value={UNCATEGORIZED_VALUE}>未分类</SelectItem>
{categories.map((category) => (
<SelectItem key={category.id} value={String(category.id)}>
{category.name}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<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>
{pausableTaskIDs.length > 0 && (
<Button
size="sm"
variant="outline"
disabled={batchControlling !== null}
onClick={() => void controlSelectedTasks("pause", pausableTaskIDs)}
>
{batchControlling === "pause" ? (
<Spinner data-icon="inline-start" />
) : (
<PauseIcon data-icon="inline-start" />
)}
暂停 {pausableTaskIDs.length}
</Button>
)}
{resumableTaskIDs.length > 0 && (
<Button
size="sm"
variant="outline"
disabled={batchControlling !== null}
onClick={() => void controlSelectedTasks("resume", resumableTaskIDs)}
>
{batchControlling === "resume" ? (
<Spinner data-icon="inline-start" />
) : (
<PlayIcon data-icon="inline-start" />
)}
继续 {resumableTaskIDs.length}
</Button>
)}
<MoveTasksCategoryDialog
categories={categories}
count={selectedIds.size}
moving={movingCategory}
onMove={moveSelectedTasksCategory}
/>
<BulkDeleteTasksDialog ids={[...selectedIds]} onDelete={deleteTasks} />
</>
)}
<ConcurrencySettingsDialog />
<CategoryManagementSheet
categories={categories}
tasks={tasks}
onChanged={refreshCategoriesAndTasks}
onTaskMoved={applyTaskCategoryMove}
/>
<CreateTaskSheet
tasks={tasks}
categories={categories}
onCreated={refreshCategoriesAndTasks}
onCategoriesChanged={refreshCategoriesAndTasks}
/>
</div>
{tasks.length === 0 ? (
<div className="text-muted-foreground mx-4 flex items-center justify-center rounded-lg border border-dashed py-20 text-sm lg:mx-6">
暂无任务,点击右上角「新建任务」开始。
</div>
) : filtered.length === 0 ? (
<div className="text-muted-foreground mx-4 flex items-center justify-center rounded-lg border border-dashed py-20 text-sm lg:mx-6">
没有匹配的任务。
</div>
) : (
<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>
<SortableTaskHead
field="id"
label="ID"
activeField={sortField}
direction={sortDirection}
className="font-mono"
onSort={sortTasksBy}
/>
<TableHead>名称</TableHead>
<TableHead>描述</TableHead>
<TableHead>目标</TableHead>
<SortableTaskHead
field="status"
label="状态"
activeField={sortField}
direction={sortDirection}
onSort={sortTasksBy}
/>
<TableHead className="text-center">目标进度</TableHead>
<SortableTaskHead
field="created"
label="创建时间"
activeField={sortField}
direction={sortDirection}
align="right"
onSort={sortTasksBy}
/>
<SortableTaskHead
field="duration"
label="运行时长"
activeField={sortField}
direction={sortDirection}
align="right"
onSort={sortTasksBy}
/>
<TableHead className="text-right">Token</TableHead>
<TableHead className="sticky right-0 z-10 bg-card text-right shadow-[-1px_0_0_0_hsl(var(--border))]">
操作
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{paginated.map((task) => (
<TaskRow
key={task.id}
task={task}
// running 任务才吃 nowSec;其余行传 0 —— props 不变,memo 就能拦下每秒 tick
// 带来的整表重渲染,只让在跑的那几行走时长。
nowSec={task.status === "running" ? nowSec : 0}
onDelete={deleteTask}
onControl={controlTask}
onRename={renameTask}
onTogglePinned={toggleTaskPinned}
selected={selectedIds.has(task.id)}
onSelectedChange={toggleSelected}
/>
))}
</TableBody>
</Table>
)}
<TablePagination
page={page}
pageSize={pageSize}
total={filtered.length}
onPageChange={setPage}
onPageSizeChange={(nextPageSize) => {
setPageSize(nextPageSize);
setPage(1);
}}
/>
</CardContent>
</Card>
);
}
function taskSortIcon(active: boolean, direction: SortDirection) {
if (!active) {
return <ArrowUpDownIcon className="size-3.5 opacity-40 transition-opacity group-hover/sort:opacity-100" />;
}
if (direction === "asc") return <ArrowUpIcon className="size-3.5" />;
return <ArrowDownIcon className="size-3.5" />;
}
function SortableTaskHead({
field,
label,
activeField,
direction,
align = "left",
className,
onSort,
}: {
field: TaskSortField;
label: string;
activeField: TaskSortField;
direction: SortDirection;
align?: "left" | "right";
className?: string;
onSort: (field: TaskSortField) => void;
}) {
const active = activeField === field;
let ariaSort: React.AriaAttributes["aria-sort"] = "none";
if (active) ariaSort = direction === "asc" ? "ascending" : "descending";
let actionLabel = `按${label}倒序排序`;
if (active) actionLabel = `${label}当前${direction === "asc" ? "正序" : "倒序"},点击切换排序方向`;
return (
<TableHead className={className} aria-sort={ariaSort}>
<button
type="button"
className={cn(
"group/sort inline-flex h-full w-full items-center gap-1 outline-none focus-visible:underline",
align === "right" && "justify-end",
)}
aria-label={actionLabel}
onClick={() => onSort(field)}
>
<span>{label}</span>
{taskSortIcon(active, direction)}
</button>
</TableHead>
);
}
function ConcurrencySettingsDialog() {
const [open, setOpen] = React.useState(false);
const [enabled, setEnabled] = React.useState(false);
const [limit, setLimit] = React.useState("5");
const [loading, setLoading] = React.useState(false);
const [saving, setSaving] = React.useState(false);
React.useEffect(() => {
if (!open) return;
setLoading(true);
api
.settings()
.then((settings) => {
setEnabled(!!settings.task_concurrency_enabled);
setLimit(String(settings.task_concurrency_limit ?? 5));
})
.catch(() => {
// Keep the dialog usable with defaults; reopening retries the request.
})
.finally(() => setLoading(false));
}, [open]);
async function save() {
const nextLimit = Math.max(1, Math.floor(Number(limit) || 5));
setSaving(true);
try {
await api.setSettings({ task_concurrency_enabled: enabled, task_concurrency_limit: nextLimit });
toast.success(enabled ? `已开启并发限制:最多同时运行 ${nextLimit} 个任务` : "已关闭任务并发限制");
setOpen(false);
} catch (error) {
toast.error(`保存失败:${(error as Error).message}`);
} finally {
setSaving(false);
}
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button size="sm" variant="outline" className="ml-auto" aria-label="任务并发设置">
<SlidersHorizontalIcon /> 并发设置
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>任务并发限制</DialogTitle>
<DialogDescription>
限制同时运行的任务数量。达到上限后,新任务会按创建顺序排队并在空位出现时自动启动。
</DialogDescription>
</DialogHeader>
<div className="grid gap-5 py-2">
<div className="flex items-center justify-between gap-4">
<div className="grid gap-1">
<Label htmlFor="task-concurrency-enabled">开启任务并发限制</Label>
<span className="text-muted-foreground text-xs">默认关闭</span>
</div>
<Switch id="task-concurrency-enabled" checked={enabled} onCheckedChange={setEnabled} disabled={loading} />
</div>
{enabled && (
<div className="grid gap-2">
<Label htmlFor="task-concurrency-limit">同时运行上限</Label>
<Input
id="task-concurrency-limit"
type="number"
min={1}
className="w-32"
value={limit}
onChange={(event) => setLimit(event.target.value)}
disabled={loading}
/>
</div>
)}
</div>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline">取消</Button>
</DialogClose>
<Button onClick={save} disabled={loading || saving}>
{saving && <Loader2Icon className="animate-spin" />} 保存
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// TaskRow renders one row of the task table. Memoized so the per-second 运行时长 tick and
// the POLL_MS list refresh only re-render the rows whose data actually moved — a table page
// is 20 rows × (StatusBadge + Link + a Radix AlertDialog tree), far too heavy to rebuild
// wholesale on every parent render.
const TaskRow = React.memo(function TaskRow({
task,
nowSec,
onDelete,
onControl,
onRename,
onTogglePinned,
selected,
onSelectedChange,
}: {
task: Task;
nowSec: number;
onDelete: (id: string, options: DeleteTaskOptions) => Promise<void>;
onControl: (id: string, action: "pause" | "resume") => Promise<void>;
onRename: (task: Task, name: string) => Promise<void>;
onTogglePinned: (task: Task) => Promise<void>;
selected: boolean;
onSelectedChange: (id: string, checked: boolean) => void;
}) {
return (
<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>
<TableCell className="font-medium">
<div className="flex max-w-xs items-center gap-2">
<TaskNameEditor task={task} onRename={onRename} />
{task.active && <StarIcon className="size-4 shrink-0 fill-amber-400 text-amber-400" />}
{taskIsPinned(task) && <PinIcon className="text-primary size-4 shrink-0" aria-label="已置顶" />}
</div>
</TableCell>
<TableCell className="text-muted-foreground max-w-xs">
<Link
href={`/function/tasks/detail?id=${encodeURIComponent(task.id)}`}
className="block truncate rounded-sm hover:text-foreground hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
title={task.description}
>
{task.description}
</Link>
</TableCell>
<TableCell className="text-muted-foreground max-w-xs truncate">{task.goal}</TableCell>
<TableCell>
<StatusBadge domain="task" value={task.status} dot />
</TableCell>
<TableCell className="text-muted-foreground text-center text-xs tabular-nums">
{typeof task.goals_total === "number" && task.goals_total > 0 ? (
`${task.goals_met}/${task.goals_total}`
) : (
<span className="text-muted-foreground">—</span>
)}
</TableCell>
<TableCell className="text-muted-foreground text-right text-xs whitespace-nowrap tabular-nums">
{fmtDateTime(task.created_unix)}
</TableCell>
<TableCell className="text-right text-xs whitespace-nowrap tabular-nums">
{(() => {
const secs = taskDuration(task, nowSec);
if (secs <= 0) return <span className="text-muted-foreground">—</span>;
return (
<span className={task.status === "running" ? "text-foreground" : "text-muted-foreground"}>
{fmtDuration(secs)}
</span>
);
})()}
</TableCell>
<TableCell
className="text-right text-xs whitespace-nowrap tabular-nums"
title={
task.tokens
? `输入 ${task.tokens.input_tokens} · 缓存 ${task.tokens.cache_read_tokens} · 输出 ${task.tokens.output_tokens}`
: undefined
}
>
{task.tokens ? (
<span className="text-muted-foreground">
入 <span className="text-foreground">{fmtTokens(task.tokens.input_tokens)}</span>
{" · 缓 "}
<span className="text-foreground">{fmtTokens(task.tokens.cache_read_tokens)}</span>
{" · 出 "}
<span className="text-foreground">{fmtTokens(task.tokens.output_tokens)}</span>
</span>
) : (
"—"
)}
</TableCell>
<TableCell className="sticky right-0 z-10 bg-card text-right shadow-[-1px_0_0_0_hsl(var(--border))] group-hover:bg-muted/50">
<div className="flex items-center justify-end gap-0.5">
<Button size="icon" variant="ghost" asChild aria-label="查看任务详情" title="查看任务详情">
<Link href={`/function/tasks/detail?id=${encodeURIComponent(task.id)}`}>
<EyeIcon />
</Link>
</Button>
<TaskControlButton task={task} onControl={onControl} />
<TaskPinAction task={task} onTogglePinned={onTogglePinned} />
<DeleteTaskDialog task={task} onDelete={onDelete} />
</div>
</TableCell>
</TableRow>
);
});
function TaskNameEditor({
task,
onRename,
}: {
task: Task;
onRename: (task: Task, name: string) => Promise<void>;
}) {
const [editing, setEditing] = React.useState(false);
const [name, setName] = React.useState(task.name ?? "");
const [saving, setSaving] = React.useState(false);
const inputRef = React.useRef<HTMLInputElement>(null);
const cancelOnBlurRef = React.useRef(false);
React.useEffect(() => {
if (!editing) setName(task.name ?? "");
}, [editing, task.name]);
async function finishEditing() {
if (cancelOnBlurRef.current) {
cancelOnBlurRef.current = false;
setName(task.name ?? "");
setEditing(false);
return;
}
const next = name.trim();
if (!next || next === task.name?.trim()) {
setName(task.name ?? "");
setEditing(false);
return;
}
setSaving(true);
try {
await onRename(task, next);
setEditing(false);
} catch {
requestAnimationFrame(() => inputRef.current?.focus());
} finally {
setSaving(false);
}
}
if (editing) {
return (
<div className="flex min-w-0 items-center gap-1">
<Input
ref={inputRef}
value={name}
maxLength={200}
autoFocus
disabled={saving}
aria-label={`任务 #${task.id} 名称`}
className="h-7 min-w-28 max-w-48 px-2 font-medium"
onFocus={(event) => event.currentTarget.select()}
onChange={(event) => setName(event.target.value)}
onBlur={() => void finishEditing()}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
event.currentTarget.blur();
} else if (event.key === "Escape") {
event.preventDefault();
cancelOnBlurRef.current = true;
event.currentTarget.blur();
}
}}
/>
{saving && <Spinner className="shrink-0" />}
</div>
);
}
return (
<div className="flex min-w-0 items-center gap-1">
<Link
href={`/function/tasks/detail?id=${encodeURIComponent(task.id)}`}
className="min-w-0 truncate rounded-sm hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
title={task.name?.trim() ? task.name : task.description}
>
{task.name?.trim() ? task.name : <span className="text-muted-foreground">未命名</span>}
</Link>
<Button
type="button"
variant="ghost"
size="icon-xs"
className="shrink-0 opacity-100 transition-opacity [@media(hover:hover)]:opacity-0 [@media(hover:hover)]:group-focus-within:opacity-100 [@media(hover:hover)]:group-hover:opacity-100"
aria-label={`重命名任务 #${task.id}`}
title="重命名"
onClick={() => {
setName(task.name ?? "");
setEditing(true);
}}
>
<PencilIcon />
</Button>
</div>
);
}
function taskPinIcon(pinning: boolean, pinned: boolean) {
if (pinning) return <Spinner />;
if (pinned) return <PinOffIcon />;
return <PinIcon />;
}
function TaskPinAction({
task,
onTogglePinned,
}: {
task: Task;
onTogglePinned: (task: Task) => Promise<void>;
}) {
const [pinning, setPinning] = React.useState(false);
const pinned = taskIsPinned(task);
return (
<Button
type="button"
variant="ghost"
size="icon"
disabled={pinning}
aria-label={pinned ? `取消置顶任务 #${task.id}` : `置顶任务 #${task.id}`}
title={pinned ? "取消置顶" : "置顶"}
onClick={async () => {
setPinning(true);
try {
await onTogglePinned(task);
} finally {
setPinning(false);
}
}}
>
{taskPinIcon(pinning, pinned)}
</Button>
);
}
// 三态图标分开写而非嵌套三元:仓库 Biome 基线禁 noNestedTernary。
function taskControlIcon(pending: boolean, action: "pause" | "resume" | null) {
if (pending) return <Loader2Icon className="animate-spin" />;
if (action === "resume") return <PlayIcon />;
return <PauseIcon />;
}
// TaskControlButton 是行内的暂停/继续开关。终态任务渲染为 disabled 而不是隐藏,
// 这样各行操作列宽度一致,按钮位置不会随状态跳动。
function TaskControlButton({
task,
onControl,
}: {
task: Task;
onControl: (id: string, action: "pause" | "resume") => Promise<void>;
}) {
const [pending, setPending] = React.useState(false);
const action = taskControlAction(task.status);
const label = action === "resume" ? "继续任务" : "暂停任务";
return (
<Button
size="icon"
variant="ghost"
aria-label={label}
title={action ? label : "该状态不可暂停或继续"}
disabled={!action || pending}
onClick={async () => {
if (!action) return;
setPending(true);
try {
await onControl(task.id, action);
} finally {
setPending(false);
}
}}
>
{taskControlIcon(pending, action)}
</Button>
);
}
const emptyDeleteOptions = (): DeleteTaskOptions => ({
delete_assets: false,
delete_traffic: false,
delete_files: false,
delete_findings: false,
delete_llm_records: false,
});
const deleteOptionKeys: (keyof DeleteTaskOptions)[] = [
"delete_assets",
"delete_traffic",
"delete_files",
"delete_findings",
"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,
}: {
task: Task;
onDelete: (id: string, options: DeleteTaskOptions) => Promise<void>;
}) {
const [open, setOpen] = React.useState(false);
const [deleting, setDeleting] = React.useState(false);
const [options, setOptions] = React.useState<DeleteTaskOptions>(emptyDeleteOptions);
const handleOpenChange = (next: boolean) => {
if (deleting) return;
setOpen(next);
if (next) setOptions(emptyDeleteOptions());
};
const handleDelete = async () => {
setDeleting(true);
try {
await onDelete(task.id, options);
setOpen(false);
} finally {
setDeleting(false);
}
};
return (
<AlertDialog open={open} onOpenChange={handleOpenChange}>
<AlertDialogTrigger asChild>
<Button size="icon" variant="outline" aria-label="删除任务">
<Trash2Icon className="text-destructive" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认删除任务 #{task.id}?</AlertDialogTitle>
<AlertDialogDescription className="break-words">
{task.description ? (
<>
「
<span className="break-all">
{task.description.length > 80 ? `${task.description.slice(0, 80)}…` : task.description}
</span>
」
</>
) : (
"该任务"
)}
的执行记录与探索链路将被永久删除。
</AlertDialogDescription>
</AlertDialogHeader>
<DeleteOptionFields idPrefix={task.id} 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 ? "删除中" : "删除"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
// 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.
// MoveTasksCategoryDialog confirms a target before applying, so a mis-click on a
// large selection cannot silently re-file every task. UNCATEGORIZED_VALUE stands
// in for "no category" because Select rejects an empty string value.
function MoveTasksCategoryDialog({
categories,
count,
moving,
onMove,
}: {
categories: TaskCategory[];
count: number;
moving: boolean;
onMove: (categoryID?: number) => Promise<void>;
}) {
const [open, setOpen] = React.useState(false);
const [target, setTarget] = React.useState(UNCATEGORIZED_VALUE);
const handleOpenChange = (next: boolean) => {
if (moving) return;
setOpen(next);
if (next) setTarget(UNCATEGORIZED_VALUE);
};
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
<Button size="sm" variant="outline">
<FolderInputIcon data-icon="inline-start" /> 修改分类
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>修改所选 {count} 个任务的分类</DialogTitle>
<DialogDescription>目标分类对所选任务统一生效;选「未分类」会把它们移出当前分类。</DialogDescription>
</DialogHeader>
<Field>
<FieldLabel htmlFor="bulk-category">目标分类</FieldLabel>
<Select value={target} onValueChange={setTarget} disabled={moving}>
<SelectTrigger id="bulk-category" className="w-full">
<SelectValue placeholder="选择分类" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value={UNCATEGORIZED_VALUE}>未分类</SelectItem>
{categories.map((category) => (
<SelectItem key={category.id} value={String(category.id)}>
{category.name}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
{categories.length === 0 && <FieldDescription>还没有任何分类,先用「分类管理」创建一个。</FieldDescription>}
</Field>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline" disabled={moving}>
取消
</Button>
</DialogClose>
<Button
disabled={moving}
onClick={() => {
void onMove(target === UNCATEGORIZED_VALUE ? undefined : Number(target)).then(() => setOpen(false));
}}
>
{moving && <Spinner data-icon="inline-start" />}
移动
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
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
// input lag. Now typing only re-renders the drawer.
function SourceTaskPicker({
tasks,
value,
onValueChange,
portalContainer,
}: {
tasks: Task[];
value: string[];
onValueChange: (value: string[]) => void;
portalContainer?: React.RefObject<HTMLElement | null>;
}) {
const tasksByID = React.useMemo(() => new Map(tasks.map((task) => [task.id, task])), [tasks]);
const taskIDs = React.useMemo(() => tasks.map((task) => task.id), [tasks]);
const atLimit = value.length >= MAX_SOURCE_TASKS;
const handleValueChange = (next: string[]) => {
onValueChange(next.slice(0, MAX_SOURCE_TASKS));
};
return (
<Combobox
items={taskIDs}
itemToStringValue={(taskID) => {
const task = tasksByID.get(taskID);
return task ? `${task.id} ${task.description} ${task.goal}` : taskID;
}}
multiple
value={value}
onValueChange={handleValueChange}
>
<ComboboxChips>
<ComboboxValue>
{value.map((taskID) => (
<ComboboxChip key={taskID}>#{taskID}</ComboboxChip>
))}
</ComboboxValue>
<ComboboxChipsInput
id="source-tasks"
placeholder={atLimit ? `最多关联 ${MAX_SOURCE_TASKS} 个任务` : "搜索任务 ID、描述或目标"}
disabled={atLimit}
/>
</ComboboxChips>
<ComboboxContent portalContainer={portalContainer}>
<ComboboxEmpty>没有匹配的任务</ComboboxEmpty>
<ComboboxList>
{(taskID) => {
const task = tasksByID.get(taskID);
return (
<ComboboxItem key={taskID} value={taskID} disabled={atLimit && !value.includes(taskID)}>
<div className="flex min-w-0 flex-1 items-center gap-2">
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">
#{taskID} · {task?.description ?? "未知任务"}
</p>
{task?.goal && <p className="text-muted-foreground truncate text-xs">{task.goal}</p>}
</div>
{task && <StatusBadge domain="task" value={task.status} />}
</div>
</ComboboxItem>
);
}}
</ComboboxList>
</ComboboxContent>
</Combobox>
);
}
// CategoryPicker 是新建任务里的单选分类选择器:可搜索已有分类;输入库里没有的名称后
// 回车(或点下拉里的「创建」)即时新建分类并选中,选中项以可移除的 tag 展示。分类是
// 全局资源,这里即时创建与「分类管理」里手动新建等价。只允许一个分类。
function CategoryPicker({
categories,
value,
onValueChange,
onCategoryCreated,
portalContainer,
}: {
categories: TaskCategory[];
value?: number;
onValueChange: (categoryID?: number) => void;
onCategoryCreated: () => void;
portalContainer?: React.RefObject<HTMLElement | null>;
}) {
const [inputValue, setInputValue] = React.useState("");
const [creating, setCreating] = React.useState(false);
// 新建的分类要等父层重新拉取才回流到 categories,先本地留一份,避免选中的 chip 和
// 下拉在这段窗口里显示成「未知分类」。
const [localExtra, setLocalExtra] = React.useState<TaskCategory[]>([]);
const allCategories = React.useMemo(() => {
const byID = new Map<number, TaskCategory>();
for (const category of categories) byID.set(category.id, category);
for (const category of localExtra) if (!byID.has(category.id)) byID.set(category.id, category);
return [...byID.values()];
}, [categories, localExtra]);
const byID = React.useMemo(() => new Map(allCategories.map((c) => [String(c.id), c])), [allCategories]);
const categoryIDs = React.useMemo(() => allCategories.map((c) => String(c.id)), [allCategories]);
const selectedIDs = value != null ? [String(value)] : [];
const trimmed = inputValue.trim();
const lower = trimmed.toLowerCase();
// 与 base-ui 默认子串过滤保持一致,用来判断「有没有相关分类」。
const matchCount = trimmed
? allCategories.filter((c) => c.name.toLowerCase().includes(lower)).length
: allCategories.length;
const createAndSelect = async () => {
if (!trimmed || creating) return;
// 精确同名已存在则直接选中,不重复创建。
const existing = allCategories.find((c) => c.name.toLowerCase() === lower);
if (existing) {
onValueChange(existing.id);
setInputValue("");
return;
}
setCreating(true);
try {
const created = await api.createTaskCategory(trimmed);
setLocalExtra((prev) => [...prev, created]);
onValueChange(created.id);
setInputValue("");
onCategoryCreated();
toast.success(`已创建分类「${created.name}」`);
} catch (e) {
toast.error(`创建分类失败:${(e as Error).message}`);
} finally {
setCreating(false);
}
};
return (
<Combobox
items={categoryIDs}
itemToStringValue={(id) => byID.get(id)?.name ?? id}
multiple
value={selectedIDs}
onValueChange={(next: string[]) => {
// 单选:取最新选中的一个;移除 chip(清空)则回到未分类。
const last = next[next.length - 1];
onValueChange(last ? Number(last) : undefined);
setInputValue("");
}}
inputValue={inputValue}
onInputValueChange={setInputValue}
>
<ComboboxChips>
<ComboboxValue>
{selectedIDs.map((id) => (
<ComboboxChip key={id}>{byID.get(id)?.name ?? "未知分类"}</ComboboxChip>
))}
</ComboboxValue>
<ComboboxChipsInput
id="task-category"
placeholder={selectedIDs.length ? "" : "搜索分类,或输入新名称后回车创建"}
onKeyDown={(e) => {
// 完全无匹配时回车 = 创建;有匹配项时保留 base-ui 的「回车选中高亮项」。
if (e.key === "Enter" && matchCount === 0 && trimmed) {
e.preventDefault();
void createAndSelect();
}
}}
/>
</ComboboxChips>
<ComboboxContent portalContainer={portalContainer}>
<ComboboxList>
{(id: string) => (
<ComboboxItem key={id} value={id}>
{byID.get(id)?.name ?? id}
</ComboboxItem>
)}
</ComboboxList>
{matchCount === 0 &&
(trimmed ? (
<button
type="button"
disabled={creating}
onClick={() => void createAndSelect()}
className="flex w-full items-center gap-2 px-2 py-2 text-left text-sm hover:bg-accent hover:text-accent-foreground disabled:opacity-50"
>
{creating ? <Spinner className="size-4" /> : <PlusIcon className="size-4" />}
创建分类「{trimmed}」
</button>
) : (
<div className="px-2 py-2 text-sm text-muted-foreground">输入名称以搜索或创建分类</div>
))}
</ComboboxContent>
</Combobox>
);
}
const COMPANY_SCOPE_LABELS: Record<string, string> = {
domain: "域名",
ip: "IP",
cidr: "CIDR",
icp: "ICP",
keyword: "关键词",
};
function companyScopeSummary(company: Company): string {
const rows = company.scope ?? [];
if (rows.length === 0) return "未配置资产范围";
const preview = rows.slice(0, 3).map((row) => {
const value = row.raw || row.value || row.domain || row.net || "";
return `${COMPANY_SCOPE_LABELS[row.kind] ?? row.kind}:${value}`;
});
return `${preview.join(" · ")}${rows.length > preview.length ? ` · 另 ${rows.length - preview.length} 条` : ""}`;
}
function CompanyPicker({
companies,
value,
onValueChange,
portalContainer,
}: {
companies: Company[];
value: number[];
onValueChange: (value: number[]) => void;
portalContainer?: React.RefObject<HTMLElement | null>;
}) {
const companiesByID = React.useMemo(
() => new Map(companies.map((company) => [String(company.id), company])),
[companies],
);
const companyIDs = React.useMemo(() => companies.map((company) => String(company.id)), [companies]);
const selectedIDs = React.useMemo(() => value.map(String), [value]);
return (
<Combobox
items={companyIDs}
itemToStringValue={(companyID) => {
const company = companiesByID.get(companyID);
return company ? `${company.name} ${companyScopeSummary(company)}` : companyID;
}}
multiple
value={selectedIDs}
onValueChange={(next) => onValueChange(next.map(Number).filter(Number.isFinite))}
>
<ComboboxChips>
<ComboboxValue>
{selectedIDs.map((companyID) => (
<ComboboxChip key={companyID}>{companiesByID.get(companyID)?.name ?? `企业 #${companyID}`}</ComboboxChip>
))}
</ComboboxValue>
<ComboboxChipsInput id="task-companies" placeholder="搜索企业名称或资产范围" />
</ComboboxChips>
<ComboboxContent portalContainer={portalContainer}>
<ComboboxEmpty>没有匹配的企业</ComboboxEmpty>
<ComboboxList>
{(companyID) => {
const company = companiesByID.get(companyID);
return (
<ComboboxItem key={companyID} value={companyID}>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<div className="flex min-w-0 items-center gap-2">
<span className="min-w-0 flex-1 truncate font-medium">{company?.name ?? `企业 #${companyID}`}</span>
<span className="text-muted-foreground shrink-0 text-xs tabular-nums">
{company?.asset_count ?? 0} 个资产
</span>
</div>
{company && (
<span className="text-muted-foreground truncate text-xs" title={companyScopeSummary(company)}>
{companyScopeSummary(company)}
</span>
)}
</div>
</ComboboxItem>
);
}}
</ComboboxList>
</ComboboxContent>
</Combobox>
);
}
type CategoryManagementView = number | "uncategorized" | "new";
function CategoryDropTarget({
value,
name,
count,
selected,
disabled,
onSelect,
}: {
value: string;
name: string;
count: number;
selected: boolean;
disabled: boolean;
onSelect: () => void;
}) {
const { isOver, setNodeRef } = useDroppable({ id: `category:${value}`, disabled });
return (
<button
ref={setNodeRef}
type="button"
className={cn(
"min-w-0 rounded-md border border-transparent px-2.5 py-2 text-left transition-colors",
selected ? "bg-accent text-accent-foreground" : "hover:bg-accent/50",
isOver && "border-primary bg-primary/10 text-foreground",
)}
onClick={onSelect}
>
<span className="block truncate font-medium text-sm">{name}</span>
<span className="block truncate text-muted-foreground text-xs">{isOver ? "松开以移动" : `${count} 个任务`}</span>
</button>
);
}
function DraggableCategoryTask({ task, disabled, moving }: { task: Task; disabled: boolean; moving: boolean }) {
const { attributes, isDragging, listeners, setNodeRef } = useDraggable({
id: `task:${task.id}`,
disabled,
});
return (
<Item ref={setNodeRef} variant="outline" size="sm" className={cn(isDragging && "opacity-40")}>
<ItemMedia className="group-has-data-[slot=item-description]/item:self-center group-has-data-[slot=item-description]/item:translate-y-0">
{moving ? (
<Spinner />
) : (
<Button
type="button"
size="icon-xs"
variant="ghost"
className="touch-none cursor-grab active:cursor-grabbing"
disabled={disabled}
{...listeners}
{...attributes}
aria-label={`拖动任务 #${task.id}`}
title="拖动任务"
>
<GripVerticalIcon />
</Button>
)}
</ItemMedia>
<ItemContent className="min-w-0">
<ItemTitle className="w-full min-w-0">
<Link href={`/function/tasks/detail?id=${encodeURIComponent(task.id)}`} className="truncate hover:underline">
{task.name?.trim() || task.description || `任务 #${task.id}`}
</Link>
</ItemTitle>
<ItemDescription className="line-clamp-1">
#{task.id} · {task.description}
</ItemDescription>
</ItemContent>
<ItemActions>
<StatusBadge domain="task" value={task.status} />
</ItemActions>
</Item>
);
}
function CategoryTaskDragPreview({ task }: { task: Task }) {
return (
<Item variant="outline" size="sm" className="w-80 bg-background shadow-lg">
<ItemMedia className="group-has-data-[slot=item-description]/item:self-center group-has-data-[slot=item-description]/item:translate-y-0">
<GripVerticalIcon className="size-4 text-muted-foreground" />
</ItemMedia>
<ItemContent className="min-w-0">
<ItemTitle className="w-full min-w-0 truncate">
{task.name?.trim() || task.description || `任务 #${task.id}`}
</ItemTitle>
<ItemDescription className="line-clamp-1">#{task.id}</ItemDescription>
</ItemContent>
</Item>
);
}
function CategoryManagementSheet({
categories,
tasks,
onChanged,
onTaskMoved,
}: {
categories: TaskCategory[];
tasks: Task[];
onChanged: () => void;
onTaskMoved: (taskID: string, category: TaskCategory | null) => void;
}) {
const [open, setOpen] = React.useState(false);
const [selectedView, setSelectedView] = React.useState<CategoryManagementView>("new");
const [draftName, setDraftName] = React.useState("");
const [saving, setSaving] = React.useState(false);
const [deleteOpen, setDeleteOpen] = React.useState(false);
const [deleting, setDeleting] = React.useState(false);
const [movingTaskID, setMovingTaskID] = React.useState<string | null>(null);
const [activeTaskID, setActiveTaskID] = React.useState<string | null>(null);
const wasOpen = React.useRef(false);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
useSensor(KeyboardSensor),
);
React.useEffect(() => {
if (open && !wasOpen.current) {
const first = categories[0];
if (first) {
setSelectedView(first.id);
setDraftName(first.name);
} else if (tasks.some((task) => task.category_id == null)) {
setSelectedView("uncategorized");
setDraftName("");
} else {
setSelectedView("new");
setDraftName("");
}
}
wasOpen.current = open;
}, [categories, open, tasks]);
const selectedCategory = React.useMemo(
() =>
typeof selectedView === "number" ? (categories.find((category) => category.id === selectedView) ?? null) : null,
[categories, selectedView],
);
const uncategorizedCount = React.useMemo(() => tasks.filter((task) => task.category_id == null).length, [tasks]);
const visibleTasks = React.useMemo(() => {
if (selectedView === "uncategorized") return tasks.filter((task) => task.category_id == null);
if (typeof selectedView === "number") return tasks.filter((task) => task.category_id === selectedView);
return [];
}, [selectedView, tasks]);
const activeTask = React.useMemo(() => tasks.find((task) => task.id === activeTaskID) ?? null, [activeTaskID, tasks]);
const selectCategory = (category: TaskCategory) => {
setSelectedView(category.id);
setDraftName(category.name);
};
const selectUncategorized = () => {
setSelectedView("uncategorized");
setDraftName("");
};
const startNew = () => {
setSelectedView("new");
setDraftName("");
};
async function saveCategory() {
const name = draftName.trim();
if (!name || saving || selectedView === "uncategorized") return;
setSaving(true);
try {
if (selectedView === "new") {
const created = await api.createTaskCategory(name);
setSelectedView(created.id);
setDraftName(created.name);
toast.success("分类已创建");
} else {
const updated = await api.renameTaskCategory(selectedView, name);
setDraftName(updated.name);
toast.success("分类已更新");
}
onChanged();
} catch (error) {
toast.error(`${selectedView === "new" ? "创建" : "更新"}分类失败:${(error as Error).message}`);
} finally {
setSaving(false);
}
}
async function deleteCategory() {
if (!selectedCategory || deleting) return;
const deletedID = selectedCategory.id;
setDeleting(true);
try {
await api.deleteTaskCategory(deletedID);
const next = categories.find((category) => category.id !== deletedID);
if (next) selectCategory(next);
else selectUncategorized();
toast.success("分类已删除,关联任务已移入未分类");
setDeleteOpen(false);
onChanged();
} catch (error) {
toast.error(`删除分类失败:${(error as Error).message}`);
} finally {
setDeleting(false);
}
}
async function moveTask(task: Task, destination: string) {
if (movingTaskID) return;
const category =
destination === "uncategorized" ? null : (categories.find((item) => item.id === Number(destination)) ?? null);
if (destination !== "uncategorized" && !category) {
toast.error("目标分类不存在,请刷新后重试");
return;
}
if (task.category_id === category?.id || (task.category_id == null && category == null)) return;
setMovingTaskID(task.id);
try {
await api.updateTaskCategory(task.id, category?.id);
onTaskMoved(task.id, category);
toast.success(`任务 #${task.id} 已移至「${category?.name ?? "未分类"}」`);
} catch (error) {
toast.error(`移动任务失败:${(error as Error).message}`);
} finally {
setMovingTaskID(null);
}
}
function handleDragStart(event: DragStartEvent) {
const id = String(event.active.id);
setActiveTaskID(id.startsWith("task:") ? id.slice("task:".length) : null);
}
function handleDragEnd(event: DragEndEvent) {
const activeID = String(event.active.id);
const taskID = activeID.startsWith("task:") ? activeID.slice("task:".length) : null;
setActiveTaskID(null);
if (!taskID || !event.over) return;
const destination = String(event.over.id);
if (!destination.startsWith("category:")) return;
const task = tasks.find((item) => item.id === taskID);
if (!task) return;
void moveTask(task, destination.slice("category:".length));
}
const saveLabel = saving ? "保存中" : selectedView === "new" ? "创建分类" : "保存修改";
return (
<>
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<Button size="sm" variant="outline">
<TagsIcon data-icon="inline-start" />
分类管理
</Button>
</SheetTrigger>
<SheetContent className="grid h-full w-full! max-w-none! grid-rows-[auto_minmax(0,1fr)_auto] gap-0 overflow-hidden p-0 sm:w-[48rem]! sm:max-w-[48rem]!">
<SheetHeader className="border-b px-6 py-5">
<SheetTitle>任务分类管理</SheetTitle>
<SheetDescription>分类用于任务筛选与归档;修改不会影响任务执行,删除后任务会移入未分类。</SheetDescription>
</SheetHeader>
<DndContext
sensors={sensors}
onDragStart={handleDragStart}
onDragCancel={() => setActiveTaskID(null)}
onDragEnd={handleDragEnd}
>
<div className="grid min-h-0 overflow-y-auto lg:grid-cols-[15rem_minmax(0,1fr)] lg:overflow-hidden">
<div className="flex min-h-0 flex-col border-b p-3 lg:border-r lg:border-b-0">
<Button type="button" variant="outline" className="w-full" onClick={startNew}>
<PlusIcon data-icon="inline-start" />
新建分类
</Button>
<ScrollArea className="mt-2 max-h-44 lg:max-h-none lg:flex-1">
<div className="flex flex-col gap-1 pr-2">
<CategoryDropTarget
value="uncategorized"
name="未分类"
count={uncategorizedCount}
selected={selectedView === "uncategorized"}
disabled={movingTaskID != null}
onSelect={selectUncategorized}
/>
{categories.map((category) => (
<CategoryDropTarget
key={category.id}
value={String(category.id)}
name={category.name}
count={category.task_count}
selected={selectedView === category.id}
disabled={movingTaskID != null}
onSelect={() => selectCategory(category)}
/>
))}
</div>
</ScrollArea>
</div>
<ScrollArea className="min-h-0">
<FieldGroup className="p-6">
{selectedView !== "uncategorized" && (
<Field>
<FieldLabel htmlFor="task-category-name">分类名称</FieldLabel>
<Input
id="task-category-name"
value={draftName}
onChange={(event) => setDraftName(event.target.value)}
placeholder="例如:外网评估"
maxLength={80}
onKeyDown={(event) => {
if (event.key === "Enter") void saveCategory();
}}
/>
<FieldDescription>
{selectedCategory
? `当前有 ${selectedCategory.task_count} 个任务使用该分类。重命名后会同步更新任务列表。`
: "创建后可在新建任务和任务列表筛选中使用。"}
</FieldDescription>
</Field>
)}
{selectedView !== "new" && (
<Field>
<div className="flex flex-wrap items-end justify-between gap-2">
<div className="flex min-w-0 flex-col gap-1">
<FieldLabel>{selectedCategory ? "分类任务" : "未分类任务"}</FieldLabel>
<FieldDescription>
{selectedCategory
? `该分类包含 ${visibleTasks.length} 个任务。`
: `当前有 ${visibleTasks.length} 个任务尚未分类。`}
</FieldDescription>
</div>
</div>
{visibleTasks.length === 0 ? (
<Empty className="min-h-36 border">
<EmptyHeader>
<EmptyTitle>{selectedCategory ? "该分类暂无任务" : "暂无未分类任务"}</EmptyTitle>
<EmptyDescription>此处将在任务归入后显示内容。</EmptyDescription>
</EmptyHeader>
</Empty>
) : (
<ItemGroup className="gap-2">
{visibleTasks.map((task) => (
<DraggableCategoryTask
key={task.id}
task={task}
moving={movingTaskID === task.id}
disabled={movingTaskID != null}
/>
))}
</ItemGroup>
)}
</Field>
)}
</FieldGroup>
</ScrollArea>
</div>
<DragOverlay>{activeTask ? <CategoryTaskDragPreview task={activeTask} /> : null}</DragOverlay>
</DndContext>
<SheetFooter className="border-t px-6 py-4 sm:flex-row sm:items-center">
{selectedCategory && (
<Button
type="button"
variant="destructive"
className="sm:mr-auto"
disabled={saving || deleting}
onClick={() => setDeleteOpen(true)}
>
<Trash2Icon data-icon="inline-start" />
删除分类
</Button>
)}
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
关闭
</Button>
{selectedView !== "uncategorized" && (
<Button
type="button"
disabled={!draftName.trim() || saving || deleting}
onClick={() => void saveCategory()}
>
{saving ? <Spinner data-icon="inline-start" /> : <SaveIcon data-icon="inline-start" />}
{saveLabel}
</Button>
)}
</SheetFooter>
</SheetContent>
</Sheet>
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>删除分类「{selectedCategory?.name || "未命名分类"}」?</AlertDialogTitle>
<AlertDialogDescription>
分类删除后,其中 {selectedCategory?.task_count ?? 0} 个任务会自动移入“未分类”,任务数据不会被删除。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleting}>取消</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
disabled={deleting}
onClick={(event) => {
event.preventDefault();
void deleteCategory();
}}
>
{deleting && <Spinner data-icon="inline-start" />}
{deleting ? "删除中" : "删除"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
function CreateTaskSheet({
tasks,
categories,
onCreated,
onCategoriesChanged,
}: {
tasks: Task[];
categories: TaskCategory[];
onCreated: () => void;
onCategoriesChanged: () => void;
}) {
const [open, setOpen] = React.useState(false);
const [name, setName] = React.useState("");
const [categoryID, setCategoryID] = React.useState<number | undefined>(undefined);
const [description, setDescription] = React.useState("");
const [goal, setGoal] = React.useState("");
const [selectedTemplateID, setSelectedTemplateID] = React.useState<number | null>(null);
const [profiles, setProfiles] = React.useState<LLMProfile[]>([]);
const [companies, setCompanies] = React.useState<Company[]>([]);
const [sourceTaskIDs, setSourceTaskIDs] = React.useState<string[]>([]);
const [companyIDs, setCompanyIDs] = React.useState<number[]>([]);
const [llmProfileIDs, setLLMProfileIDs] = React.useState<string[]>([]);
const [creating, setCreating] = React.useState(false);
const [timeoutMin, setTimeoutMin] = React.useState(""); // 任务级超时(分钟);空/0 = 不限时
const [heartbeatMin, setHeartbeatMin] = React.useState("10"); // planner 心跳(分钟);默认10,下限10(与后端一致)
const [seedFirstIntent, setSeedFirstIntent] = React.useState(false); // 创建时下发种子意图,worker 免等首轮 planner 直接开跑;默认关闭,走标准先规划再执行
const [coverageEnabled, setCoverageEnabled] = React.useState(true); // 资产覆盖度功能;默认开。关闭=不计算/展示覆盖度、不累积范围、隐藏范围类工具(company 关联不受影响)
// 方式1 文件上传:建任务前把文件暂存到 drafts/<draftId>/uploads/,拿回绝对路径追加进描述。
const [uploading, setUploading] = React.useState(false);
const [uploadCount, setUploadCount] = React.useState(0);
const draftIdRef = React.useRef<string>("");
const fileInputRef = React.useRef<HTMLInputElement>(null);
const sheetContentRef = React.useRef<HTMLDivElement>(null);
// load LLM profiles once for the create-task profile picker.
React.useEffect(() => {
api
.llmProfiles()
.then(setProfiles)
.catch(() => setProfiles([]));
api
.companies()
.then(setCompanies)
.catch(() => setCompanies([]));
}, []);
// pickFiles uploads the chosen files into this draft's staging dir and appends their
// absolute paths to the description; the task's agents open them by path via Read/Bash.
async function pickFiles(files: FileList | null) {
if (!files || files.length === 0) return;
// crypto.randomUUID 仅在安全上下文可用(https/localhost);经 IP+http 访问时降级。
if (!draftIdRef.current) {
draftIdRef.current =
globalThis.crypto?.randomUUID?.() ?? `d${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`;
}
setUploading(true);
try {
const r = await api.chatUpload("staging", draftIdRef.current, Array.from(files));
setDescription((prev) => appendUploads(prev, r.attachments));
setUploadCount((n) => n + r.attachments.length);
} catch (e) {
toast.error("上传失败:" + (e as Error).message);
} finally {
setUploading(false);
if (fileInputRef.current) fileInputRef.current.value = ""; // allow re-picking the same file
}
}
async function createTask() {
if (!description.trim() || !goal.trim()) {
toast.error("请填写描述与目标");
return;
}
if (sourceTaskIDs.length > MAX_SOURCE_TASKS) {
toast.error(`最多关联 ${MAX_SOURCE_TASKS} 个来源任务`);
return;
}
setCreating(true);
try {
const timeoutSec = Math.max(0, Math.floor(Number(timeoutMin) || 0)) * 60;
const heartbeatSec = Math.max(10, Math.floor(Number(heartbeatMin) || 10)) * 60; // 下限 10min,与后端归一一致
await api.createTask({
name: name.trim(),
categoryId: categoryID,
description: description.trim(),
goal: goal.trim(),
llmProfileIds: llmProfileIDs.map(Number),
sourceTaskIds: sourceTaskIDs,
companyIds: companyIDs,
timeoutSeconds: timeoutSec,
seedFirstIntent,
planHeartbeatSeconds: heartbeatSec,
coverageEnabled,
});
toast.success("任务已创建");
setName("");
setCategoryID(undefined);
setDescription("");
setGoal("");
setSelectedTemplateID(null);
setSourceTaskIDs([]);
setCompanyIDs([]);
setLLMProfileIDs([]);
setTimeoutMin("");
setHeartbeatMin("10");
setSeedFirstIntent(false);
setCoverageEnabled(true);
setUploadCount(0);
draftIdRef.current = "";
setOpen(false);
onCreated();
} catch (e) {
toast.error("创建失败:" + (e as Error).message);
} finally {
setCreating(false);
}
}
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<Button size="sm">
<PlusIcon /> 新建任务
</Button>
</SheetTrigger>
{/* 45vw 宽的右侧抽屉:整屏高度可滚动,长表单不再受弹窗高度限制。窄屏退化为全宽。
内容为 flex 列:头/脚固定,中间字段区 flex-1 独立滚动。 */}
<SheetContent
ref={sheetContentRef}
side="right"
className="w-full! max-w-none! gap-0 p-0 sm:w-[45vw]! sm:max-w-[45vw]!"
>
<SheetHeader className="border-b p-6">
<SheetTitle>新建任务</SheetTitle>
<SheetDescription>填写测试对象与目标,高级参数可按需展开。</SheetDescription>
</SheetHeader>
<div className="flex-1 overflow-y-auto p-6">
<div className="grid gap-5">
<TaskTemplateControls
description={description}
goal={goal}
selectedTemplateID={selectedTemplateID}
onSelectedTemplateIDChange={setSelectedTemplateID}
onApply={(template) => {
setDescription(template.description);
setGoal(template.goal);
setUploadCount(0);
}}
portalContainer={sheetContentRef}
/>
<div className="grid gap-2">
<Label htmlFor="name">名称(可选)</Label>
<Input
id="name"
placeholder="给任务起个便于识别的名字,例如:Acme 官网渗透"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<Field>
<FieldLabel htmlFor="task-category">任务分类</FieldLabel>
<CategoryPicker
categories={categories}
value={categoryID}
onValueChange={setCategoryID}
onCategoryCreated={onCategoriesChanged}
portalContainer={sheetContentRef}
/>
<FieldDescription>可选,单个分类;用于任务列表筛选和归档,不影响 Agent 执行。</FieldDescription>
</Field>
<div className="grid gap-2">
<Label htmlFor="description">描述</Label>
<Textarea
id="description"
className="min-h-32"
placeholder="测试对象与背景,例如:测试 example.com 这个站点"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
{/* 上传文件(可多选):暂存到 drafts/,把绝对路径追加进上方描述,worker 据此 Read/Bash 打开。 */}
<div className="flex flex-wrap items-center gap-2">
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={(e) => void pickFiles(e.target.files)}
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
>
{uploading ? <Loader2Icon className="animate-spin" /> : <PaperclipIcon />}
上传文件
</Button>
<span className="text-muted-foreground text-xs">
{uploadCount > 0
? `已上传 ${uploadCount} 个文件,绝对路径已追加到描述末尾(可编辑)`
: "可多选;上传后把文件的绝对路径追加到描述,供 worker 用 Read/Bash 打开"}
</span>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="goal">目标</Label>
<Textarea
id="goal"
className="min-h-32"
placeholder="要达成什么,例如:拿下后台管理权限、获取服务器权限"
value={goal}
onChange={(e) => setGoal(e.target.value)}
/>
</div>
<Field>
<FieldLabel htmlFor="source-tasks">关联任务</FieldLabel>
<SourceTaskPicker
tasks={tasks}
value={sourceTaskIDs}
onValueChange={setSourceTaskIDs}
portalContainer={sheetContentRef}
/>
<FieldDescription>
最多关联 {MAX_SOURCE_TASKS}{" "}
个任务。实时只读继承所选任务的持久化黑板、资产范围及相关流量;新任务写入独立黑板。
</FieldDescription>
</Field>
<Field>
<FieldLabel htmlFor="task-companies">关联企业资产范围</FieldLabel>
<CompanyPicker
companies={companies}
value={companyIDs}
onValueChange={setCompanyIDs}
portalContainer={sheetContentRef}
/>
<FieldDescription>
将所选企业的域名、IP、CIDR、ICP 和企业关键词作为 Agent
可选范围上下文;不会自动生成意图或强制改变执行目标。
</FieldDescription>
</Field>
<Field>
<FieldLabel htmlFor="llm-profiles">LLM 配置链</FieldLabel>
<TaskLLMProfileChain
profiles={profiles}
value={llmProfileIDs}
onValueChange={setLLMProfileIDs}
inputId="llm-profiles"
portalContainer={sheetContentRef}
/>
<FieldDescription>按列表顺序故障转移;第一项为当前配置,仅在明确额度不足时切换下一项。</FieldDescription>
</Field>
{/* 高级参数默认折叠:超时/心跳/首个意图,展开才占空间,常用路径保持清爽。 */}
<Collapsible>
<CollapsibleTrigger className="group flex w-full items-center gap-2 border-t pt-4 text-sm font-medium">
<ChevronRightIcon className="text-muted-foreground size-4 transition-transform group-data-[state=open]:rotate-90" />
高级设置
<span className="text-muted-foreground ml-auto text-xs font-normal">超时 · 心跳 · 首个意图</span>
</CollapsibleTrigger>
<CollapsibleContent className="grid gap-5 pt-5">
<div className="grid gap-2">
<Label htmlFor="timeout-min">任务超时(分钟,可选)</Label>
<Input
id="timeout-min"
type="number"
min={0}
className="w-40"
placeholder="留空 = 不限时"
value={timeoutMin}
onChange={(e) => setTimeoutMin(e.target.value)}
/>
<p className="text-muted-foreground text-xs">
到点后触发优雅收尾(各 agent 写回 + planner 终局判定),任务进入 timeout 终态。
</p>
</div>
<div className="grid gap-2">
<Label htmlFor="heartbeat-min">planner 心跳(分钟)</Label>
<Input
id="heartbeat-min"
type="number"
min={10}
className="w-40"
placeholder="默认 10"
value={heartbeatMin}
onChange={(e) => setHeartbeatMin(e.target.value)}
/>
<p className="text-muted-foreground text-xs">
距上轮规划结束/任务开始满该时长且期间无触发,自动触发一轮规划(兜底卡死 + 唤醒去监督在跑的
worker)。下限 10 分钟。
</p>
</div>
<div className="grid gap-2">
<label htmlFor="seed-first-intent" className="flex items-center gap-2 text-sm">
<Checkbox
id="seed-first-intent"
checked={seedFirstIntent}
onCheckedChange={(v) => setSeedFirstIntent(!!v)}
/>
直接下发首个意图(描述+目标)
</label>
<p className="text-muted-foreground text-xs">
开启后创建即把「描述+目标」作为一条意图下发,worker 免等首轮规划直接开跑,跑完再由 planner
接手判定/补充。CTF 等常一个 work 直接解决的场景推荐开启;关闭则走标准的先规划再执行。
</p>
</div>
<div className="grid gap-2">
<label htmlFor="coverage-enabled" className="flex items-center gap-2 text-sm">
<Checkbox
id="coverage-enabled"
checked={coverageEnabled}
onCheckedChange={(v) => setCoverageEnabled(!!v)}
/>
资产覆盖度功能
</label>
<p className="text-muted-foreground text-xs">
默认开启:计算并展示测试覆盖度、态势图显示测试进度、自动累积测试范围。关闭后不再计算/展示覆盖度,
态势图仅展示资产不显示进度,agent 也不再获得范围类工具。关闭不影响「关联企业资产范围」。
</p>
</div>
</CollapsibleContent>
</Collapsible>
</div>
</div>
<SheetFooter className="flex-row justify-end gap-2 border-t p-4">
<SheetClose asChild>
<Button variant="outline">取消</Button>
</SheetClose>
<Button onClick={createTask} disabled={creating || uploading}>
{creating && <Spinner data-icon="inline-start" />}
{creating ? "创建中" : "创建"}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}