diff --git a/web/src/app/(main)/function/tasks/detail/_tabs/findings-tab.tsx b/web/src/app/(main)/function/tasks/detail/_tabs/findings-tab.tsx index 07d9449..4a0dc59 100644 --- a/web/src/app/(main)/function/tasks/detail/_tabs/findings-tab.tsx +++ b/web/src/app/(main)/function/tasks/detail/_tabs/findings-tab.tsx @@ -1,12 +1,15 @@ "use client"; import * as React from "react"; + import Link from "next/link"; -import { ArrowUpRightIcon, ChevronRightIcon } from "lucide-react"; -import { cn } from "@/lib/utils"; -import { Card, CardContent } from "@/components/ui/card"; -import { Badge } from "@/components/ui/badge"; + +import { ArrowDownIcon, ArrowUpIcon, ArrowUpRightIcon, ChevronRightIcon } from "lucide-react"; +import { toast } from "sonner"; + import { StatusBadge } from "@/components/status-badge"; +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent } from "@/components/ui/card"; import { Select, SelectContent, @@ -15,9 +18,21 @@ import { SelectTrigger, } from "@/components/ui/select"; import { api } from "@/lib/api"; -import { toast } from "sonner"; +import { useStoredSortPreference } from "@/lib/sort-preference"; import { statusMeta } from "@/lib/status"; import type { Finding, FindingStatus } from "@/lib/types"; +import { cn } from "@/lib/utils"; + +type FindingSortField = "time"; + +const FINDING_SORT_FIELDS: readonly FindingSortField[] = ["time"]; +const FINDING_SORT_PREFERENCE_KEY = "artex_task_findings_sort"; + +function findingLabel(finding: Finding): string { + if (finding.name?.trim()) return finding.name; + if (finding.vulnclass?.trim()) return finding.vulnclass; + return "未分类"; +} const FINDING_STATUSES: FindingStatus[] = [ "pending", @@ -57,9 +72,7 @@ function Row({
- - {f.name || f.vulnclass || "未分类"} - + {findingLabel(f)} {f.inherited && f.source_task_id && ( 来源 #{f.source_task_id} · 只读 @@ -147,6 +160,12 @@ function Row({ export function FindingsTab({ taskId }: { taskId: string }) { const [findings, setFindings] = React.useState([]); + const [sortPreference, setSortPreference] = useStoredSortPreference( + FINDING_SORT_PREFERENCE_KEY, + FINDING_SORT_FIELDS, + "time", + "desc", + ); React.useEffect(() => { let active = true; @@ -156,7 +175,9 @@ export function FindingsTab({ taskId }: { taskId: string }) { .then((fs) => { if (active) setFindings(fs); }) - .catch(() => {}); + .catch(() => { + // Keep the last successful snapshot during transient refresh failures. + }); }; load(); const t = setInterval(load, 3000); @@ -189,13 +210,35 @@ export function FindingsTab({ taskId }: { taskId: string }) { const items = findings .filter((f) => f.task_id === taskId || f.inherited) .sort((a, b) => { - const order = { critical: 0, high: 1, medium: 2, low: 3 }; - return order[a.severity] - order[b.severity]; + const compared = Date.parse(a.ts) - Date.parse(b.ts); + if (compared !== 0) return sortPreference.direction === "asc" ? compared : -compared; + return b.id.localeCompare(a.id, undefined, { numeric: true }); }); return ( +
+ 漏洞 + +
{items.map((f) => ( ))} diff --git a/web/src/app/(main)/function/tasks/page.tsx b/web/src/app/(main)/function/tasks/page.tsx index 86f349a..f7050e2 100644 --- a/web/src/app/(main)/function/tasks/page.tsx +++ b/web/src/app/(main)/function/tasks/page.tsx @@ -112,6 +112,7 @@ 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, @@ -242,7 +243,9 @@ function taskControlAction(status: TaskStatus): "pause" | "resume" | null { } type TaskSortField = "id" | "created" | "duration" | "status"; -type SortDirection = "asc" | "desc"; + +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])); @@ -292,8 +295,13 @@ export default function TasksPage() { const [query, setQuery] = React.useState(""); const [statusFilter, setStatusFilter] = React.useState("all"); const [categoryFilter, setCategoryFilter] = React.useState("all"); - const [sortField, setSortField] = React.useState("id"); - const [sortDirection, setSortDirection] = React.useState("desc"); + 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)); @@ -329,14 +337,12 @@ export default function TasksPage() { const sortTasksBy = React.useCallback( (field: TaskSortField) => { - if (sortField === field) { - setSortDirection((current) => (current === "asc" ? "desc" : "asc")); - return; - } - setSortField(field); - setSortDirection("desc"); + setSortPreference((current) => { + if (current.field !== field) return { field, direction: "desc" }; + return { field, direction: current.direction === "asc" ? "desc" : "asc" }; + }); }, - [sortField], + [setSortPreference], ); // reset to page 1 whenever filters or ordering change @@ -1066,13 +1072,7 @@ const TaskRow = React.memo(function TaskRow({
- - {task.name?.trim() ? task.name : 未命名} - + {task.active && } {taskIsPinned(task) && }
@@ -1139,7 +1139,7 @@ const TaskRow = React.memo(function TaskRow({ - +
@@ -1147,117 +1147,141 @@ const TaskRow = React.memo(function TaskRow({ ); }); +function TaskNameEditor({ + task, + onRename, +}: { + task: Task; + onRename: (task: Task, name: string) => Promise; +}) { + const [editing, setEditing] = React.useState(false); + const [name, setName] = React.useState(task.name ?? ""); + const [saving, setSaving] = React.useState(false); + const inputRef = React.useRef(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 ( +
+ 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 && } +
+ ); + } + + return ( +
+ + {task.name?.trim() ? task.name : 未命名} + + +
+ ); +} + function taskPinIcon(pinning: boolean, pinned: boolean) { if (pinning) return ; if (pinned) return ; return ; } -function TaskMetadataActions({ +function TaskPinAction({ task, - onRename, onTogglePinned, }: { task: Task; - onRename: (task: Task, name: string) => Promise; onTogglePinned: (task: Task) => Promise; }) { - const [renameOpen, setRenameOpen] = React.useState(false); - const [name, setName] = React.useState(task.name ?? ""); - const [saving, setSaving] = React.useState(false); const [pinning, setPinning] = React.useState(false); const pinned = taskIsPinned(task); - React.useEffect(() => { - if (renameOpen) setName(task.name ?? ""); - }, [renameOpen, task.name]); - - async function saveName() { - const next = name.trim(); - if (!next) return; - if (next === task.name?.trim()) { - setRenameOpen(false); - return; - } - setSaving(true); - try { - await onRename(task, next); - setRenameOpen(false); - } catch { - // The parent already reports the API error; keep the dialog open for retry. - } finally { - setSaving(false); - } - } - return ( - <> - - - - - - 重命名任务 #{task.id} - 新名称只用于列表和详情展示,不会改变任务描述、目标或执行状态。 - - - - 任务名称 - setName(event.target.value)} - onKeyDown={(event) => { - if (event.key === "Enter") { - event.preventDefault(); - void saveName(); - } - }} - /> - - - - - - - - - - - + ); } diff --git a/web/src/app/(main)/function/traffic/page.tsx b/web/src/app/(main)/function/traffic/page.tsx index 9ccf7d0..b7623ff 100644 --- a/web/src/app/(main)/function/traffic/page.tsx +++ b/web/src/app/(main)/function/traffic/page.tsx @@ -3,14 +3,18 @@ import * as React from "react"; import { + CheckIcon, ChevronLeftIcon, ChevronRightIcon, + CopyIcon, ListChecksIcon, Loader2Icon, RadioTowerIcon, SearchIcon, Trash2Icon, + WrapTextIcon, } from "lucide-react"; +import { toast } from "sonner"; import { AlertDialog, @@ -32,6 +36,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@ import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { api } from "@/lib/api"; import type { TrafficDetail, TrafficExchange, TrafficHost, TrafficResp } from "@/lib/types"; import { cn } from "@/lib/utils"; @@ -163,37 +168,122 @@ function MarkupBody({ body }: { body: string }) { return parts; } -function HighlightedBody({ body }: { body: string }) { +type BodyFormat = "json" | "markup" | "plain"; + +function detectBodyFormat(body: string): BodyFormat { const trimmed = body.trimStart(); - if (trimmed.startsWith("{") || trimmed.startsWith("[")) return ; - if (trimmed.startsWith("<")) return ; + if (trimmed.startsWith("{") || trimmed.startsWith("[")) return "json"; + if (trimmed.startsWith("<")) return "markup"; + return "plain"; +} + +function HighlightedBody({ body, format }: { body: string; format: BodyFormat }) { + if (format === "json") return ; + if (format === "markup") return ; return body; } function HttpCodeBlock({ raw }: { raw: string }) { + const [wrapLines, setWrapLines] = React.useState(true); + const [copied, setCopied] = React.useState(false); const value = raw || "(空)"; const lines = value.replaceAll("\r\n", "\n").split("\n"); const separator = lines.indexOf(""); - const headerLines = separator >= 0 ? lines.slice(0, separator) : lines; const body = separator >= 0 ? lines.slice(separator + 1).join("\n") : ""; + const bodyFormat = detectBodyFormat(body); + + React.useEffect(() => { + if (!copied) return; + const timer = window.setTimeout(() => setCopied(false), 1500); + return () => window.clearTimeout(timer); + }, [copied]); + + const copyPacket = async () => { + try { + await navigator.clipboard.writeText(value); + setCopied(true); + } catch { + toast.error("复制失败,请使用 Ctrl/Cmd+A 后复制"); + } + }; + + const renderLine = (line: string, index: number) => { + if (index === 0) return ; + if (separator < 0 || index < separator) return ; + if (index === separator) return null; + return ; + }; return ( -
-      
-        {headerLines.map((line, index) => (
-          
-            {index === 0 ?  : }
-            {index < headerLines.length - 1 ? "\n" : null}
-          
+    
+
+ + + + + {wrapLines ? "关闭自动换行" : "开启自动换行"} + + + + + + {copied ? "已复制" : "复制报文"} + +
+ {/* biome-ignore lint/a11y/useSemanticElements: textarea cannot preserve line numbers and syntax-highlighting markup. */} +
{ + if (!(event.ctrlKey || event.metaKey) || event.key.toLowerCase() !== "a") return; + event.preventDefault(); + const selection = window.getSelection(); + if (!selection) return; + const range = document.createRange(); + range.selectNodeContents(event.currentTarget); + selection.removeAllRanges(); + selection.addRange(range); + }} + > + {lines.map((line, index) => ( +
+ + {renderLine(line, index)} + +
))} - {separator >= 0 && ( - <> - {"\n\n"} - - - )} -
-
+
+ ); } diff --git a/web/src/lib/sort-preference.ts b/web/src/lib/sort-preference.ts new file mode 100644 index 0000000..20342f4 --- /dev/null +++ b/web/src/lib/sort-preference.ts @@ -0,0 +1,47 @@ +"use client"; + +import * as React from "react"; + +import { getLocalStorageValue, setLocalStorageValue } from "@/lib/local-storage.client"; + +export type SortDirection = "asc" | "desc"; + +export interface SortPreference { + field: Field; + direction: SortDirection; +} + +export function useStoredSortPreference( + key: string, + fields: readonly Field[], + defaultField: Field, + defaultDirection: SortDirection, +) { + const [preference, setPreference] = React.useState>({ + field: defaultField, + direction: defaultDirection, + }); + const [hydrated, setHydrated] = React.useState(false); + + React.useEffect(() => { + const raw = getLocalStorageValue(key); + if (raw) { + try { + const parsed = JSON.parse(raw) as Partial>; + const field = fields.find((candidate) => candidate === parsed.field); + const direction = parsed.direction === "asc" || parsed.direction === "desc" ? parsed.direction : null; + if (field && direction) setPreference({ field, direction }); + } catch { + // Ignore malformed or legacy preferences and retain the current default. + } + } + setHydrated(true); + }, [fields, key]); + + React.useEffect(() => { + if (!hydrated) return; + setLocalStorageValue(key, JSON.stringify(preference)); + }, [hydrated, key, preference]); + + return [preference, setPreference] as const; +}