修改缩紧

This commit is contained in:
pphua committed 2026-08-25 16:48:20 +08:00
1 parent 86eb67ea0a
commit 3675bd815c
4 files changed
+347 -143

No files matched your search

@@ -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({
<StatusBadge domain="severity" value={f.severity} dot />
<div className="flex min-w-0 flex-1 flex-col gap-1">
<div className="flex min-w-0 items-center gap-2">
<span className="truncate font-medium">
{f.name || f.vulnclass || "未分类"}
</span>
<span className="truncate font-medium">{findingLabel(f)}</span>
{f.inherited && f.source_task_id && (
<Badge variant="outline" className="shrink-0">
来源 #{f.source_task_id} · 只读
@@ -147,6 +160,12 @@ function Row({
export function FindingsTab({ taskId }: { taskId: string }) {
const [findings, setFindings] = React.useState<Finding[]>([]);
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 (
<Card className="overflow-hidden py-0">
<CardContent className="px-0">
<div className="flex items-center border-b px-4 py-2 text-xs text-muted-foreground">
<span className="min-w-0 flex-1">漏洞</span>
<button
type="button"
className="inline-flex items-center gap-1 outline-none focus-visible:underline"
aria-label={`发现时间当前${sortPreference.direction === "asc" ? "正序" : "倒序"},点击切换排序方向`}
onClick={() =>
setSortPreference((current) => ({
field: "time",
direction: current.direction === "asc" ? "desc" : "asc",
}))
}
>
<span>发现时间</span>
{sortPreference.direction === "asc" ? (
<ArrowUpIcon className="size-3.5" />
) : (
<ArrowDownIcon className="size-3.5" />
)}
</button>
</div>
{items.map((f) => (
<Row key={f.id} f={f} contextTaskId={taskId} onStatus={onStatus} />
))}
+137 -113
View File
@@ -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<TaskStatus | "all">("all");
const [categoryFilter, setCategoryFilter] = React.useState("all");
const [sortField, setSortField] = React.useState<TaskSortField>("id");
const [sortDirection, setSortDirection] = React.useState<SortDirection>("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({
</TableCell>
<TableCell className="font-medium">
<div className="flex max-w-xs items-center gap-2">
<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>
<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>
@@ -1139,7 +1139,7 @@ const TaskRow = React.memo(function TaskRow({
</Link>
</Button>
<TaskControlButton task={task} onControl={onControl} />
<TaskMetadataActions task={task} onRename={onRename} onTogglePinned={onTogglePinned} />
<TaskPinAction task={task} onTogglePinned={onTogglePinned} />
<DeleteTaskDialog task={task} onDelete={onDelete} />
</div>
</TableCell>
@@ -1147,117 +1147,141 @@ const TaskRow = React.memo(function TaskRow({
);
});
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 TaskMetadataActions({
function TaskPinAction({
task,
onRename,
onTogglePinned,
}: {
task: Task;
onRename: (task: Task, name: string) => Promise<void>;
onTogglePinned: (task: Task) => Promise<void>;
}) {
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 (
<>
<Button
type="button"
variant="ghost"
size="icon"
aria-label={`重命名任务 #${task.id}`}
title="重命名"
onClick={() => setRenameOpen(true)}
>
<PencilIcon />
</Button>
<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>
<Dialog open={renameOpen} onOpenChange={setRenameOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>重命名任务 #{task.id}</DialogTitle>
<DialogDescription>新名称只用于列表和详情展示,不会改变任务描述、目标或执行状态。</DialogDescription>
</DialogHeader>
<FieldGroup>
<Field>
<FieldLabel htmlFor={`task-rename-${task.id}`}>任务名称</FieldLabel>
<Input
id={`task-rename-${task.id}`}
value={name}
maxLength={200}
autoFocus
onChange={(event) => setName(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
void saveName();
}
}}
/>
</Field>
</FieldGroup>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline" disabled={saving}>
取消
</Button>
</DialogClose>
<Button disabled={saving || !name.trim()} onClick={() => void saveName()}>
{saving && <Spinner data-icon="inline-start" />}
保存
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
<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>
);
}
+109 -19
View File
@@ -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 <JsonBody body={body} />;
if (trimmed.startsWith("<")) return <MarkupBody body={body} />;
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 <JsonBody body={body} />;
if (format === "markup") return <MarkupBody body={body} />;
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 <StartLine line={line} />;
if (separator < 0 || index < separator) return <HeaderLine line={line} />;
if (index === separator) return null;
return <HighlightedBody body={line} format={bodyFormat} />;
};
return (
<pre className="min-w-full whitespace-pre p-5 font-mono text-xs leading-relaxed">
<code>
{headerLines.map((line, index) => (
<React.Fragment key={`${index}-${line}`}>
{index === 0 ? <StartLine line={line} /> : <HeaderLine line={line} />}
{index < headerLines.length - 1 ? "\n" : null}
</React.Fragment>
<div className="relative mx-3 my-4 overflow-hidden rounded-md border bg-background shadow-xs">
<div className="absolute top-2 right-2 flex items-center gap-0.5 rounded-md border bg-background/95 p-0.5 shadow-xs">
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={wrapLines ? "关闭自动换行" : "开启自动换行"}
aria-pressed={wrapLines}
onClick={() => setWrapLines((current) => !current)}
>
<WrapTextIcon />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{wrapLines ? "关闭自动换行" : "开启自动换行"}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={copied ? "已复制报文" : "复制报文"}
onClick={() => void copyPacket()}
>
{copied ? <CheckIcon /> : <CopyIcon />}
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{copied ? "已复制" : "复制报文"}</TooltipContent>
</Tooltip>
</div>
{/* biome-ignore lint/a11y/useSemanticElements: textarea cannot preserve line numbers and syntax-highlighting markup. */}
<div
role="textbox"
aria-label="HTTP 报文代码"
aria-multiline="true"
aria-readonly="true"
tabIndex={0}
className="max-h-[calc(100vh-15rem)] min-w-0 overflow-auto bg-background py-3 font-mono text-xs outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
onKeyDown={(event) => {
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) => (
<div
key={`${index}-${line}`}
data-line={index + 1}
className="grid min-w-0 grid-cols-[1.5rem_minmax(0,1fr)] leading-relaxed before:sticky before:left-0 before:self-stretch before:border-r before:bg-muted/20 before:px-1 before:text-right before:text-muted-foreground/60 before:content-[attr(data-line)]"
>
<code
className={cn(
"min-h-[1lh] min-w-0 pr-16 pl-1.5 [tab-size:4]",
wrapLines ? "break-words whitespace-pre-wrap" : "whitespace-pre",
)}
>
{renderLine(line, index)}
</code>
</div>
))}
{separator >= 0 && (
<>
{"\n\n"}
<HighlightedBody body={body} />
</>
)}
</code>
</pre>
</div>
</div>
);
}
+47
View File
@@ -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 extends string> {
field: Field;
direction: SortDirection;
}
export function useStoredSortPreference<Field extends string>(
key: string,
fields: readonly Field[],
defaultField: Field,
defaultDirection: SortDirection,
) {
const [preference, setPreference] = React.useState<SortPreference<Field>>({
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<SortPreference<string>>;
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;
}