From 3575b9091e02046f1768c73714f47f5b3a21a434 Mon Sep 17 00:00:00 2001 From: ji ruo <79234113+RuoJi6@users.noreply.github.com> Date: Fri, 30 Oct 2026 11:25:36 +0800 Subject: [PATCH 1/3] feat: add task list column visibility settings --- web/src/app/(main)/function/tasks/page.tsx | 1338 ++++++++++++++++---- 1 file changed, 1082 insertions(+), 256 deletions(-) diff --git a/web/src/app/(main)/function/tasks/page.tsx b/web/src/app/(main)/function/tasks/page.tsx index df88c10..152eb6e 100644 --- a/web/src/app/(main)/function/tasks/page.tsx +++ b/web/src/app/(main)/function/tasks/page.tsx @@ -36,6 +36,7 @@ import { SaveIcon, SearchIcon, SlidersHorizontalIcon, + TablePropertiesIcon, StarIcon, TagsIcon, Trash2Icon, @@ -67,7 +68,11 @@ import { Badge } from "@/components/ui/badge"; 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 { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; import { Combobox, ComboboxChip, @@ -89,7 +94,12 @@ import { DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; -import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@/components/ui/empty"; +import { + Empty, + EmptyDescription, + EmptyHeader, + EmptyTitle, +} from "@/components/ui/empty"; import { Field, FieldContent, @@ -100,11 +110,26 @@ import { FieldSet, } from "@/components/ui/field"; import { Input } from "@/components/ui/input"; -import { Item, ItemActions, ItemContent, ItemDescription, ItemGroup, ItemMedia, ItemTitle } from "@/components/ui/item"; +import { + Item, + ItemActions, + ItemContent, + ItemDescription, + ItemGroup, + ItemMedia, + ItemTitle, +} from "@/components/ui/item"; import { Label } from "@/components/ui/label"; import { Progress } from "@/components/ui/progress"; import { ScrollArea } from "@/components/ui/scroll-area"; -import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { Sheet, SheetClose, @@ -117,13 +142,30 @@ import { } 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 { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Textarea } from "@/components/ui/textarea"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; import { api, type ScheduleInput } from "@/lib/api"; -import { getLocalStorageValue, setLocalStorageValue } from "@/lib/local-storage.client"; -import { type SortDirection, useStoredSortPreference } from "@/lib/sort-preference"; +import { + getLocalStorageValue, + setLocalStorageValue, +} from "@/lib/local-storage.client"; +import { + type SortDirection, + useStoredSortPreference, +} from "@/lib/sort-preference"; import type { AssetInterceptRuleInput, ChatAttachment, @@ -137,7 +179,11 @@ import type { TaskCategory, TaskStatus, } from "@/lib/types"; -import { MAX_TASK_REMARK_RUNES, MAX_TASK_WORKER_COUNT, parseWorkerCountInput } from "@/lib/types"; +import { + MAX_TASK_REMARK_RUNES, + MAX_TASK_WORKER_COUNT, + parseWorkerCountInput, +} from "@/lib/types"; import { cn } from "@/lib/utils"; // fmtBytes renders a human file size for the upload manifest. @@ -155,7 +201,9 @@ const UPLOAD_MARKER = "【上传文件(绝对路径)】"; // 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"); + 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`; } @@ -170,7 +218,8 @@ 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 >= 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); } @@ -193,7 +242,12 @@ function fmtDuration(sec: number): string { function taskDuration(task: Task, nowSec: number): number { const active = Math.max(0, Math.floor(task.active_run_seconds ?? 0)); const segmentStart = task.run_segment_started_unix ?? 0; - if (task.status === "running" && !task.paused && !task.queued && segmentStart > 0) { + if ( + task.status === "running" && + !task.paused && + !task.queued && + segmentStart > 0 + ) { return active + Math.max(0, Math.floor(nowSec - segmentStart)); } return active; @@ -205,18 +259,25 @@ type DeleteCounts = Omit; function deleteDetails(result: DeleteCounts): string[] { const details: string[] = []; - if (result.assets_deleted > 0) details.push(`删除资产 ${result.assets_deleted} 条`); - if (result.assets_detached > 0) details.push(`解除共享资产关联 ${result.assets_detached} 条`); - if (result.traffic_deleted > 0) details.push(`删除流量 ${result.traffic_deleted} 条`); + 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} 条`); + 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(",")})` : "任务已删除"; + return details.length > 0 + ? `任务已删除(${details.join(",")})` + : "任务已删除"; } // fmtDateTime renders a unix-seconds timestamp as a compact local date-time @@ -246,7 +307,12 @@ const UNCATEGORIZED_VALUE = "uncategorized"; // (done/failed/timeout 为终态);paused 才可继续。行内按钮与批量控制共用此判断, // 两边不会出现一个可点、另一个不可点的分歧。 const PAUSABLE_STATUSES = new Set(["created", "queued", "running"]); -const ARCHIVABLE_STATUSES = new Set(["paused", "done", "failed", "timeout"]); +const ARCHIVABLE_STATUSES = new Set([ + "paused", + "done", + "failed", + "timeout", +]); function taskControlAction(status: TaskStatus): "pause" | "resume" | null { if (status === "paused") return "resume"; @@ -256,18 +322,59 @@ function taskControlAction(status: TaskStatus): "pause" | "resume" | null { type TaskSortField = "id" | "created" | "duration" | "status"; -const TASK_SORT_FIELDS: readonly 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_FILTER_PREFERENCE_KEY = "artex_task_list_filters"; +const TASK_COLUMNS_PREFERENCE_KEY = "artex_task_list_columns"; +type TaskColumnKey = + | "id" + | "name" + | "remark" + | "description" + | "goal" + | "status" + | "progress" + | "findings" + | "inFlight" + | "created" + | "duration" + | "tokens"; +const TASK_COLUMNS: readonly { key: TaskColumnKey; label: string }[] = [ + ["id", "ID"], + ["name", "名称"], + ["remark", "备注"], + ["description", "描述"], + ["goal", "目标"], + ["status", "状态"], + ["progress", "目标进度"], + ["findings", "漏洞"], + ["inFlight", "运行中 Worker"], + ["created", "创建时间"], + ["duration", "运行时长"], + ["tokens", "Token"], +].map(([key, label]) => ({ key: key as TaskColumnKey, label })); +const DEFAULT_TASK_COLUMNS = Object.fromEntries( + TASK_COLUMNS.map(({ key }) => [key, true]), +) as Record; -const TASK_STATUS_RANK = new Map(STATUS_OPTIONS.map((option, index) => [option.value, index])); +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" }); + return left.localeCompare(right, undefined, { + numeric: true, + sensitivity: "base", + }); } function taskCreatedUnix(task: Task): number { @@ -276,7 +383,13 @@ function taskCreatedUnix(task: Task): number { return Number.isNaN(parsed) ? 0 : Math.floor(parsed / 1000); } -function compareTasks(left: Task, right: Task, field: TaskSortField, direction: SortDirection, nowSec: number): number { +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; @@ -293,7 +406,9 @@ function compareTasks(left: Task, right: Task, field: TaskSortField, direction: 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); + compared = + (TASK_STATUS_RANK.get(left.status) ?? 0) - + (TASK_STATUS_RANK.get(right.status) ?? 0); break; default: compared = compareTaskIDs(left.id, right.id); @@ -308,7 +423,9 @@ export default function TasksPage() { const [categories, setCategories] = React.useState([]); const [categoriesLoaded, setCategoriesLoaded] = React.useState(false); const [query, setQuery] = React.useState(""); - const [statusFilter, setStatusFilter] = React.useState("all"); + const [statusFilter, setStatusFilter] = React.useState( + "all", + ); const [categoryFilter, setCategoryFilter] = React.useState("all"); const [filtersHydrated, setFiltersHydrated] = React.useState(false); const [sortPreference, setSortPreference] = useStoredSortPreference( @@ -320,21 +437,59 @@ export default function TasksPage() { 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 [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 [visibleColumns, setVisibleColumns] = + React.useState>(DEFAULT_TASK_COLUMNS); + React.useEffect(() => { + const raw = getLocalStorageValue(TASK_COLUMNS_PREFERENCE_KEY); + if (!raw) return; + try { + const parsed = JSON.parse(raw) as Record; + setVisibleColumns( + (current) => + Object.fromEntries( + TASK_COLUMNS.map(({ key }) => [ + key, + typeof parsed[key] === "boolean" ? parsed[key] : current[key], + ]), + ) as Record, + ); + } catch { + // Ignore malformed saved preferences. + } + }, []); + React.useEffect(() => { + setLocalStorageValue( + TASK_COLUMNS_PREFERENCE_KEY, + JSON.stringify(visibleColumns), + ); + }, [visibleColumns]); React.useEffect(() => { const raw = getLocalStorageValue(TASK_FILTER_PREFERENCE_KEY); if (raw) { try { - const parsed = JSON.parse(raw) as { status?: unknown; category?: unknown }; - if (parsed.status === "all" || STATUS_OPTIONS.some((option) => option.value === parsed.status)) { + const parsed = JSON.parse(raw) as { + status?: unknown; + category?: unknown; + }; + if ( + parsed.status === "all" || + STATUS_OPTIONS.some((option) => option.value === parsed.status) + ) { setStatusFilter(parsed.status as TaskStatus | "all"); } if ( typeof parsed.category === "string" && - (parsed.category === "all" || parsed.category === UNCATEGORIZED_VALUE || /^\d+$/.test(parsed.category)) + (parsed.category === "all" || + parsed.category === UNCATEGORIZED_VALUE || + /^\d+$/.test(parsed.category)) ) { setCategoryFilter(parsed.category); } @@ -357,7 +512,8 @@ export default function TasksPage() { 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 === UNCATEGORIZED_VALUE && t.category_id != null) + return false; if ( categoryFilter !== "all" && categoryFilter !== UNCATEGORIZED_VALUE && @@ -377,14 +533,19 @@ export default function TasksPage() { const sortNowSec = sortField === "duration" ? nowSec : 0; const ordered = React.useMemo(() => { - return [...filtered].sort((left, right) => compareTasks(left, right, sortField, sortDirection, sortNowSec)); + 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" }; + return { + field, + direction: current.direction === "asc" ? "desc" : "asc", + }; }); }, [setSortPreference], @@ -402,7 +563,9 @@ export default function TasksPage() { ); // 多选删除:选择跨翻页/筛选保留,只在任务真的消失(被删或后端不再返回)时收敛。 - const [selectedIds, setSelectedIds] = React.useState>(() => new Set()); + const [selectedIds, setSelectedIds] = React.useState>( + () => new Set(), + ); React.useEffect(() => { setSelectedIds((prev) => { @@ -457,7 +620,9 @@ export default function TasksPage() { api .tasks() .then((r) => { - const next = r.tasks.map((t) => (t.id === r.active ? { ...t, active: true } : t)); + 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; @@ -497,7 +662,13 @@ export default function TasksPage() { (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, + task.id === taskID + ? { + ...task, + category_id: category?.id, + category_name: category?.name, + } + : task, ); lastRef.current = JSON.stringify(next); return next; @@ -509,13 +680,18 @@ export default function TasksPage() { React.useEffect(() => { if (!categoriesLoaded) return; - if (categoryFilter === "all" || categoryFilter === UNCATEGORIZED_VALUE) return; - if (!categories.some((category) => String(category.id) === categoryFilter)) setCategoryFilter("all"); + if (categoryFilter === "all" || categoryFilter === UNCATEGORIZED_VALUE) + return; + if (!categories.some((category) => String(category.id) === categoryFilter)) + setCategoryFilter("all"); }, [categories, categoriesLoaded, categoryFilter]); // 只在确有 running 任务时才每秒 tick——其余情况「运行时长」是静态值,空转的 tick 会白白 // 重渲染整张表。 - const hasRunning = React.useMemo(() => tasks.some((t) => t.status === "running"), [tasks]); + const hasRunning = React.useMemo( + () => tasks.some((t) => t.status === "running"), + [tasks], + ); // tick every second so running tasks' 运行时长 counts up live. React.useEffect(() => { @@ -530,7 +706,9 @@ export default function TasksPage() { try { const result = await api.deleteTask(id, options); if (result.cleanup_warning) { - toast.warning(`${deleteSummary(result)};部分外部数据清理未完成:${result.cleanup_warning}`); + toast.warning( + `${deleteSummary(result)};部分外部数据清理未完成:${result.cleanup_warning}`, + ); } else { toast.success(deleteSummary(result)); } @@ -551,10 +729,14 @@ export default function TasksPage() { try { const result = await api.controlTask(id, action); toast.success( - action === "pause" ? `任务 #${id} 已暂停` : `任务 #${id} 已继续${result.queued ? ",已进入队列" : ""}`, + action === "pause" + ? `任务 #${id} 已暂停` + : `任务 #${id} 已继续${result.queued ? ",已进入队列" : ""}`, ); } catch (e) { - toast.error(`${action === "pause" ? "暂停" : "继续"}失败:${(e as Error).message}`); + toast.error( + `${action === "pause" ? "暂停" : "继续"}失败:${(e as Error).message}`, + ); } finally { // 无论成败都刷新:失败多半是状态已变化,重新拉取才能让按钮回到正确形态。 lastRef.current = ""; @@ -568,7 +750,9 @@ export default function TasksPage() { async (task: Task, remark: string) => { const updated = await api.updateTask(task.id, { remark }); setTasks((current) => - current.map((entry) => entry.id === task.id ? { ...entry, remark: updated.remark } : entry), + current.map((entry) => + entry.id === task.id ? { ...entry, remark: updated.remark } : entry, + ), ); lastRef.current = ""; load(); @@ -596,11 +780,15 @@ export default function TasksPage() { const pinned = taskIsPinned(task); try { await api.pinTask(task.id, !pinned); - toast.success(pinned ? `任务 #${task.id} 已取消置顶` : `任务 #${task.id} 已置顶`); + toast.success( + pinned ? `任务 #${task.id} 已取消置顶` : `任务 #${task.id} 已置顶`, + ); lastRef.current = ""; load(); } catch (error) { - toast.error(`${pinned ? "取消置顶" : "置顶"}失败:${(error as Error).message}`); + toast.error( + `${pinned ? "取消置顶" : "置顶"}失败:${(error as Error).message}`, + ); throw error; } }, @@ -626,7 +814,11 @@ export default function TasksPage() { // deleteTasks 逐个删除所选任务:后端没有批量接口,且单次删除会连带清理资产/流量/文件, // 串行执行以免一次性打爆后端;成功的从选中集移除,失败的保留以便重试。 const deleteTasks = React.useCallback( - async (ids: string[], options: DeleteTaskOptions, onProgress: (done: number) => void) => { + async ( + ids: string[], + options: DeleteTaskOptions, + onProgress: (done: number) => void, + ) => { const total: DeleteCounts = { assets_deleted: 0, assets_detached: 0, @@ -663,9 +855,13 @@ export default function TasksPage() { return next; }); const details = deleteDetails(total); - const summary = `已删除 ${deleted.length} 个任务` + (details.length > 0 ? `(${details.join(",")})` : ""); + const summary = + `已删除 ${deleted.length} 个任务` + + (details.length > 0 ? `(${details.join(",")})` : ""); if (warnings.length > 0) { - toast.warning(`${summary};部分外部数据清理未完成:${warnings.join(";")}`); + toast.warning( + `${summary};部分外部数据清理未完成:${warnings.join(";")}`, + ); } else { toast.success(summary); } @@ -675,24 +871,38 @@ export default function TasksPage() { .slice(0, 3) .map((f) => `#${f.id}(${f.message})`) .join(";"); - toast.error(`${failed.length} 个任务删除失败:${head}${failed.length > 3 ? " 等" : ""}`); + toast.error( + `${failed.length} 个任务删除失败:${head}${failed.length > 3 ? " 等" : ""}`, + ); } load(); }, [load], ); - const selectedTasks = React.useMemo(() => tasks.filter((task) => selectedIds.has(task.id)), [tasks, selectedIds]); + 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 + .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 + .filter((task) => taskControlAction(task.status) === "resume") + .map((task) => task.id), [selectedTasks], ); const archivableTaskIDs = React.useMemo( - () => selectedTasks.filter((task) => ARCHIVABLE_STATUSES.has(task.status) && !task.queued).map((task) => task.id), + () => + selectedTasks + .filter((task) => ARCHIVABLE_STATUSES.has(task.status) && !task.queued) + .map((task) => task.id), [selectedTasks], ); @@ -701,7 +911,8 @@ export default function TasksPage() { const result = await api.archiveTasks(archivableTaskIDs); const succeeded = result.items.filter((item) => item.ok); const failed = result.items.filter((item) => !item.ok); - if (succeeded.length > 0) toast.success(`已将 ${succeeded.length} 个任务加入归档队列`); + if (succeeded.length > 0) + toast.success(`已将 ${succeeded.length} 个任务加入归档队列`); if (failed.length > 0) { toast.error( `${failed.length} 个任务无法归档:${failed @@ -740,12 +951,16 @@ export default function TasksPage() { .slice(0, 3) .map((item) => `#${item.id}(${item.error || "状态已变化"})`) .join(";"); - toast.error(`${failed.length} 个任务操作失败:${details}${failed.length > 3 ? " 等" : ""}`); + toast.error( + `${failed.length} 个任务操作失败:${details}${failed.length > 3 ? " 等" : ""}`, + ); } lastRef.current = ""; load(); } catch (error) { - toast.error(`${action === "pause" ? "批量暂停" : "批量继续"}失败:${(error as Error).message}`); + toast.error( + `${action === "pause" ? "批量暂停" : "批量继续"}失败:${(error as Error).message}`, + ); } finally { setBatchControlling(null); } @@ -777,7 +992,9 @@ export default function TasksPage() { .slice(0, 3) .map((item) => `#${item.id}(${item.error || "任务已不存在"})`) .join(";"); - toast.error(`${failed.length} 个任务未能移动:${details}${failed.length > 3 ? " 等" : ""}`); + toast.error( + `${failed.length} 个任务未能移动:${details}${failed.length > 3 ? " 等" : ""}`, + ); } refreshCategoriesAndTasks(); } catch (error) { @@ -818,7 +1035,10 @@ export default function TasksPage() { )} - setStatusFilter(v as TaskStatus | "all")} + > @@ -854,8 +1074,14 @@ export default function TasksPage() { {selectedIds.size > 0 && ( <> - 已选 {selectedIds.size} 个 - {pausableTaskIDs.length > 0 && ( @@ -863,7 +1089,9 @@ export default function TasksPage() { size="sm" variant="outline" disabled={batchControlling !== null} - onClick={() => void controlSelectedTasks("pause", pausableTaskIDs)} + onClick={() => + void controlSelectedTasks("pause", pausableTaskIDs) + } > {batchControlling === "pause" ? ( @@ -878,7 +1106,9 @@ export default function TasksPage() { size="sm" variant="outline" disabled={batchControlling !== null} - onClick={() => void controlSelectedTasks("resume", resumableTaskIDs)} + onClick={() => + void controlSelectedTasks("resume", resumableTaskIDs) + } > {batchControlling === "resume" ? ( @@ -906,9 +1136,16 @@ export default function TasksPage() { moving={movingCategory} onMove={moveSelectedTasksCategory} /> - + )} + toggleSelectedPage(checked === true)} + onCheckedChange={(checked) => + toggleSelectedPage(checked === true) + } aria-label="选择本页全部任务" /> @@ -949,27 +1188,62 @@ export default function TasksPage() { activeField={sortField} direction={sortDirection} className="font-mono" + hidden={!visibleColumns.id} onSort={sortTasksBy} /> - 名称 - 备注 - 描述 - 目标 + + 名称 + + + 备注 + + + 描述 + + + 目标 +