mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-10-27 17:03:49 +08:00
修改缩紧
This commit is contained in:
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} />
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user