mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-10-29 01:23:50 +08:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c6070a8d8 |
No files matched your search
@@ -29,21 +29,9 @@ import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { HttpPacketSyntax } from "@/components/http-code-block";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { api } from "@/lib/api";
|
||||
import type { LLMRecordItem, LLMRecordDetail, LLMTask } from "@/lib/types";
|
||||
@@ -80,7 +68,12 @@ function tryFormatJSON(s: string): string {
|
||||
function CopyButton({ text }: { text: string }) {
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
const timer = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
React.useEffect(() => () => { if (timer.current) clearTimeout(timer.current); }, []);
|
||||
React.useEffect(
|
||||
() => () => {
|
||||
if (timer.current) clearTimeout(timer.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const disabled = !text;
|
||||
const copy = async () => {
|
||||
@@ -95,7 +88,11 @@ function CopyButton({ text }: { text: string }) {
|
||||
ta.style.opacity = "0";
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
try { document.execCommand("copy"); } catch { /* 忽略:不支持则静默 */ }
|
||||
try {
|
||||
document.execCommand("copy");
|
||||
} catch {
|
||||
/* 忽略:不支持则静默 */
|
||||
}
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
setCopied(true);
|
||||
@@ -151,9 +148,7 @@ export default function LLMRecordsPage() {
|
||||
const reqText = showRaw
|
||||
? detail?.raw_request && tryFormatJSON(detail.raw_request)
|
||||
: detail?.request_body && tryFormatJSON(detail.request_body);
|
||||
const respText = showRaw
|
||||
? detail?.raw_response
|
||||
: detail?.response_body && tryFormatJSON(detail.response_body);
|
||||
const respText = showRaw ? detail?.raw_response : detail?.response_body && tryFormatJSON(detail.response_body);
|
||||
|
||||
// Per-task delete (task picker + confirm dialog)
|
||||
const [tasks, setTasks] = React.useState<LLMTask[]>([]);
|
||||
@@ -167,9 +162,13 @@ export default function LLMRecordsPage() {
|
||||
let alive = true;
|
||||
api
|
||||
.settings()
|
||||
.then((s) => { if (alive) setRecEnabled(!!s.llm_record); })
|
||||
.then((s) => {
|
||||
if (alive) setRecEnabled(!!s.llm_record);
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => { alive = false; };
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const toggleRecording = async (on: boolean) => {
|
||||
@@ -201,7 +200,13 @@ export default function LLMRecordsPage() {
|
||||
let alive = true;
|
||||
setLoading(true);
|
||||
api
|
||||
.llmRecords({ model: model || undefined, session: sessionQ || undefined, task: pickedTask || undefined, page, size })
|
||||
.llmRecords({
|
||||
model: model || undefined,
|
||||
session: sessionQ || undefined,
|
||||
task: pickedTask || undefined,
|
||||
page,
|
||||
size,
|
||||
})
|
||||
.then((r) => {
|
||||
if (!alive) return;
|
||||
setRecords(r.records ?? []);
|
||||
@@ -211,9 +216,13 @@ export default function LLMRecordsPage() {
|
||||
.finally(() => alive && setLoading(false));
|
||||
api
|
||||
.llmTasks()
|
||||
.then((r) => { if (alive) setTasks(r.tasks ?? []); })
|
||||
.then((r) => {
|
||||
if (alive) setTasks(r.tasks ?? []);
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => { alive = false; };
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [page, size, sessionQ, model, pickedTask, reloadTick]);
|
||||
|
||||
// Delete every LLM record for the picked task, then refetch.
|
||||
@@ -244,10 +253,16 @@ export default function LLMRecordsPage() {
|
||||
setDetail(null);
|
||||
api
|
||||
.llmRecordDetail(selected.id)
|
||||
.then((d) => { if (alive) setDetail(d); })
|
||||
.then((d) => {
|
||||
if (alive) setDetail(d);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => { if (alive) setDetailLoading(false); });
|
||||
return () => { alive = false; };
|
||||
.finally(() => {
|
||||
if (alive) setDetailLoading(false);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [selected]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / size));
|
||||
@@ -276,12 +291,7 @@ export default function LLMRecordsPage() {
|
||||
className="h-8 pl-8"
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
placeholder="Model"
|
||||
className="h-8 w-48"
|
||||
value={model}
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
/>
|
||||
<Input placeholder="Model" className="h-8 w-48" value={model} onChange={(e) => setModel(e.target.value)} />
|
||||
<Select value={pickedTask} onValueChange={setPickedTask}>
|
||||
<SelectTrigger size="sm" className="w-56">
|
||||
<SelectValue placeholder="选择任务…" />
|
||||
@@ -407,15 +417,10 @@ export default function LLMRecordsPage() {
|
||||
records.map((rec) => (
|
||||
<TableRow
|
||||
key={rec.id}
|
||||
className={cn(
|
||||
"cursor-pointer",
|
||||
selected?.id === rec.id && "bg-accent hover:bg-accent",
|
||||
)}
|
||||
className={cn("cursor-pointer", selected?.id === rec.id && "bg-accent hover:bg-accent")}
|
||||
onClick={() => setSelected(rec)}
|
||||
>
|
||||
<TableCell className="text-xs text-muted-foreground tabular-nums">
|
||||
{fmtTime(rec.ts)}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground tabular-nums">{fmtTime(rec.ts)}</TableCell>
|
||||
<TableCell className="text-xs font-mono text-muted-foreground">
|
||||
{rec.task_id ? `#${rec.task_id}` : "-"}
|
||||
</TableCell>
|
||||
@@ -442,9 +447,13 @@ export default function LLMRecordsPage() {
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{rec.status === "ok" ? (
|
||||
<Badge variant="secondary" className="text-xs text-emerald-600">OK</Badge>
|
||||
<Badge variant="secondary" className="text-xs text-emerald-600">
|
||||
OK
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="destructive" className="text-xs">Error</Badge>
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
Error
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -474,16 +483,18 @@ export default function LLMRecordsPage() {
|
||||
任务 #{selected.task_id}
|
||||
</Badge>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{fmtTime(selected.ts)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">{fmtTime(selected.ts)}</span>
|
||||
<span className={cn("text-xs", selected.latency_ms > 30000 && "text-amber-500")}>
|
||||
{fmtLatency(selected.latency_ms)}
|
||||
</span>
|
||||
{selected.status === "ok" ? (
|
||||
<Badge variant="secondary" className="text-xs text-emerald-600">OK</Badge>
|
||||
<Badge variant="secondary" className="text-xs text-emerald-600">
|
||||
OK
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="destructive" className="text-xs">Error</Badge>
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
Error
|
||||
</Badge>
|
||||
)}
|
||||
{/* 原文视图开关。旧记录没有原文,此时禁用而非静默回退,避免看着像
|
||||
「原文与解析一致」。 */}
|
||||
@@ -497,12 +508,7 @@ export default function LLMRecordsPage() {
|
||||
>
|
||||
原文
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 shrink-0"
|
||||
onClick={() => setSelected(null)}
|
||||
>
|
||||
<Button variant="ghost" size="icon" className="size-7 shrink-0" onClick={() => setSelected(null)}>
|
||||
<XIcon />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -521,7 +527,7 @@ export default function LLMRecordsPage() {
|
||||
</div>
|
||||
) : (
|
||||
<pre className="p-3 font-mono text-xs break-all whitespace-pre-wrap">
|
||||
{reqText || "(空)"}
|
||||
<HttpPacketSyntax raw={reqText || ""} lineNumbers={false} emptyFallback="(空)" />
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
@@ -538,11 +544,13 @@ export default function LLMRecordsPage() {
|
||||
加载…
|
||||
</div>
|
||||
) : (
|
||||
<pre className={cn(
|
||||
"p-3 font-mono text-xs break-all whitespace-pre-wrap",
|
||||
selected.status !== "ok" && "text-red-600 dark:text-red-400",
|
||||
)}>
|
||||
{respText || "(空)"}
|
||||
<pre
|
||||
className={cn(
|
||||
"p-3 font-mono text-xs break-all whitespace-pre-wrap",
|
||||
selected.status !== "ok" && "text-red-600 dark:text-red-400",
|
||||
)}
|
||||
>
|
||||
<HttpPacketSyntax raw={respText || ""} lineNumbers={false} emptyFallback="(空)" />
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -43,7 +43,6 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||
import { SortableHead } from "@/components/ui/sortable-head";
|
||||
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 { useStoredSortPreference } from "@/lib/sort-preference";
|
||||
@@ -774,7 +773,7 @@ export default function TrafficPage() {
|
||||
/>
|
||||
) : null}
|
||||
<Sheet open={selected !== null} onOpenChange={(open) => !open && setSelected(null)}>
|
||||
<SheetContent className="w-full! max-w-none! gap-0 p-0 sm:w-[48rem]! sm:max-w-[48rem]!">
|
||||
<SheetContent className="w-full! max-w-none! gap-0 p-0 sm:w-[72rem]! sm:max-w-[72rem]!">
|
||||
{selected && (
|
||||
<>
|
||||
<SheetHeader className="border-b px-5 py-4">
|
||||
@@ -797,32 +796,24 @@ export default function TrafficPage() {
|
||||
{importing ? "转入中…" : "送入请求重发"}
|
||||
</Button>
|
||||
</SheetHeader>
|
||||
<Tabs defaultValue="request" className="min-h-0 flex-1 gap-0">
|
||||
<TabsList className="mx-5 mt-4 grid w-auto grid-cols-2">
|
||||
<TabsTrigger value="request">请求 Request</TabsTrigger>
|
||||
<TabsTrigger value="response">响应 Response</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="request" className="min-h-0 overflow-auto">
|
||||
{detailLoading ? (
|
||||
<div className="flex items-center gap-2 p-5 text-xs text-muted-foreground">
|
||||
<Loader2Icon className="size-3.5 animate-spin" />
|
||||
加载报文…
|
||||
</div>
|
||||
) : (
|
||||
<HttpCodeBlock raw={requestWithHost(detail?.req ?? "", selected)} />
|
||||
)}
|
||||
</TabsContent>
|
||||
<TabsContent value="response" className="min-h-0 overflow-auto">
|
||||
{detailLoading ? (
|
||||
<div className="flex items-center gap-2 p-5 text-xs text-muted-foreground">
|
||||
<Loader2Icon className="size-3.5 animate-spin" />
|
||||
加载报文…
|
||||
</div>
|
||||
) : (
|
||||
<HttpCodeBlock raw={detail?.resp ?? ""} />
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<div className="grid min-h-0 flex-1 grid-cols-1 gap-2 overflow-hidden p-2 sm:grid-cols-2">
|
||||
{[
|
||||
["request", "请求 Request", requestWithHost(detail?.req ?? "", selected)],
|
||||
["response", "响应 Response", detail?.resp ?? ""],
|
||||
].map(([key, label, raw]) => (
|
||||
<div key={key} className="flex min-h-0 min-w-0 flex-col gap-1 overflow-hidden">
|
||||
<span className="px-1 text-[11px] font-medium text-muted-foreground">{label}</span>
|
||||
{detailLoading ? (
|
||||
<div className="flex min-h-0 flex-1 items-center justify-center gap-2 rounded-md border bg-muted/10 text-xs text-muted-foreground">
|
||||
<Loader2Icon className="size-3.5 animate-spin" />
|
||||
加载报文…
|
||||
</div>
|
||||
) : (
|
||||
<HttpCodeBlock raw={raw} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</SheetContent>
|
||||
|
||||
@@ -11,7 +11,6 @@ import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { api } from "@/lib/api";
|
||||
import { toneClasses } from "@/lib/status";
|
||||
import type { CredentialAccount, CredentialTraffic, CredentialTrafficDetail, TaskIntelligence } from "@/lib/types";
|
||||
@@ -344,26 +343,22 @@ export function CredentialTrafficViewer({
|
||||
{data.reference.side === "request" ? "请求包含匹配凭据" : "响应包含匹配凭据"},不代表认证验证成功。
|
||||
</p>
|
||||
<p className="text-sm">绑定时保存的匹配片段</p>
|
||||
<pre className="max-h-52 overflow-auto whitespace-pre-wrap break-all rounded bg-muted p-3 text-xs">
|
||||
{data.reference.excerpt}
|
||||
</pre>
|
||||
<HttpCodeBlock raw={data.reference.excerpt} />
|
||||
{data.unavailable ? (
|
||||
<Alert>
|
||||
<AlertDescription>原始流量已删除或不可用,仍可查看上方保存的匹配片段。</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<Tabs defaultValue={data.reference.side}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="request">请求</TabsTrigger>
|
||||
<TabsTrigger value="response">响应</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="request">
|
||||
<div className="grid min-w-0 grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div className="min-w-0">
|
||||
<p className="mb-1 px-1 text-sm font-medium">请求 Request</p>
|
||||
<HttpCodeBlock raw={data.req ?? ""} />
|
||||
</TabsContent>
|
||||
<TabsContent value="response">
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="mb-1 px-1 text-sm font-medium">响应 Response</p>
|
||||
<HttpCodeBlock raw={data.resp ?? ""} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
import * as React from "react";
|
||||
|
||||
import { CheckIcon, CopyIcon, WrapTextIcon } from "lucide-react";
|
||||
import { CheckIcon, CopyIcon, SearchIcon, WrapTextIcon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -16,15 +16,55 @@ function statusTone(status: number) {
|
||||
return "text-muted-foreground";
|
||||
}
|
||||
|
||||
const PACKET_COLORS = {
|
||||
method: "font-semibold text-violet-600 dark:text-violet-400",
|
||||
url: "text-sky-600 dark:text-sky-400",
|
||||
dim: "text-muted-foreground",
|
||||
headerName: "text-sky-700 dark:text-sky-300",
|
||||
headerValue: "text-emerald-600 dark:text-emerald-400",
|
||||
jsonKey: "text-sky-700 dark:text-sky-300",
|
||||
jsonString: "text-amber-600 dark:text-amber-400",
|
||||
jsonNumber: "text-fuchsia-600 dark:text-fuchsia-400",
|
||||
jsonBoolean: "text-rose-600 dark:text-rose-400",
|
||||
tag: "text-sky-600 dark:text-sky-400",
|
||||
};
|
||||
|
||||
const PacketHighlightContext = React.createContext("");
|
||||
|
||||
function HighlightedText({ text, className }: { text: string; className?: string }) {
|
||||
const query = React.useContext(PacketHighlightContext).trim();
|
||||
if (!query || !text) return <span className={className}>{text}</span>;
|
||||
const needle = query.toLowerCase();
|
||||
const haystack = text.toLowerCase();
|
||||
const parts: React.ReactNode[] = [];
|
||||
let cursor = 0;
|
||||
let key = 0;
|
||||
for (;;) {
|
||||
const match = haystack.indexOf(needle, cursor);
|
||||
if (match < 0) {
|
||||
parts.push(text.slice(cursor));
|
||||
break;
|
||||
}
|
||||
if (match > cursor) parts.push(text.slice(cursor, match));
|
||||
parts.push(
|
||||
<mark key={key++} className="rounded-[2px] bg-yellow-300 text-black">
|
||||
{text.slice(match, match + needle.length)}
|
||||
</mark>,
|
||||
);
|
||||
cursor = match + needle.length;
|
||||
}
|
||||
return <span className={className}>{parts}</span>;
|
||||
}
|
||||
|
||||
function StartLine({ line }: { line: string }) {
|
||||
const response = /^(HTTP\/\S+)(\s+)(\d{3})(.*)$/.exec(line);
|
||||
if (response) {
|
||||
return (
|
||||
<>
|
||||
<span className="text-muted-foreground">{response[1]}</span>
|
||||
<HighlightedText text={response[1]} className={PACKET_COLORS.dim} />
|
||||
{response[2]}
|
||||
<span className={statusTone(Number(response[3]))}>{response[3]}</span>
|
||||
{response[4]}
|
||||
<HighlightedText text={response[3]} className={statusTone(Number(response[3]))} />
|
||||
<HighlightedText text={response[4]} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -33,26 +73,26 @@ function StartLine({ line }: { line: string }) {
|
||||
if (request) {
|
||||
return (
|
||||
<>
|
||||
<span className="font-semibold text-primary">{request[1]}</span>
|
||||
<HighlightedText text={request[1]} className={PACKET_COLORS.method} />
|
||||
{request[2]}
|
||||
<span className="text-chart-2">{request[3]}</span>
|
||||
<HighlightedText text={request[3]} className={PACKET_COLORS.url} />
|
||||
{request[4]}
|
||||
<span className="text-muted-foreground">{request[5]}</span>
|
||||
<HighlightedText text={request[5]} className={PACKET_COLORS.dim} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return line;
|
||||
return <HighlightedText text={line} />;
|
||||
}
|
||||
|
||||
function HeaderLine({ line }: { line: string }) {
|
||||
const separator = line.indexOf(":");
|
||||
if (separator <= 0) return line;
|
||||
if (separator <= 0) return <HighlightedText text={line} />;
|
||||
return (
|
||||
<>
|
||||
<span className="text-primary">{line.slice(0, separator)}</span>
|
||||
<span className="text-muted-foreground">:</span>
|
||||
<span className="text-chart-2">{line.slice(separator + 1)}</span>
|
||||
<HighlightedText text={line.slice(0, separator)} className={PACKET_COLORS.headerName} />
|
||||
<span className={PACKET_COLORS.dim}>:</span>
|
||||
<HighlightedText text={line.slice(separator + 1)} className={PACKET_COLORS.headerValue} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -63,18 +103,14 @@ function JsonBody({ body }: { body: string }) {
|
||||
let cursor = 0;
|
||||
for (const match of body.matchAll(tokens)) {
|
||||
const index = match.index ?? 0;
|
||||
if (index > cursor) parts.push(body.slice(cursor, index));
|
||||
let className = "text-chart-4";
|
||||
if (match[1]) className = match[2] ? "text-primary" : "text-chart-2";
|
||||
else if (match[3]) className = "text-chart-3";
|
||||
parts.push(
|
||||
<span key={`${index}-${match[0].length}`} className={className}>
|
||||
{match[0]}
|
||||
</span>,
|
||||
);
|
||||
if (index > cursor) parts.push(<HighlightedText key={`plain-${cursor}`} text={body.slice(cursor, index)} />);
|
||||
let className = PACKET_COLORS.jsonNumber;
|
||||
if (match[1]) className = match[2] ? PACKET_COLORS.jsonKey : PACKET_COLORS.jsonString;
|
||||
else if (match[3]) className = PACKET_COLORS.jsonBoolean;
|
||||
parts.push(<HighlightedText key={`${index}-${match[0].length}`} text={match[0]} className={className} />);
|
||||
cursor = index + match[0].length;
|
||||
}
|
||||
if (cursor < body.length) parts.push(body.slice(cursor));
|
||||
if (cursor < body.length) parts.push(<HighlightedText key={`plain-${cursor}`} text={body.slice(cursor)} />);
|
||||
return parts;
|
||||
}
|
||||
|
||||
@@ -84,15 +120,11 @@ function MarkupBody({ body }: { body: string }) {
|
||||
let cursor = 0;
|
||||
for (const match of body.matchAll(tags)) {
|
||||
const index = match.index ?? 0;
|
||||
if (index > cursor) parts.push(body.slice(cursor, index));
|
||||
parts.push(
|
||||
<span key={`${index}-${match[0].length}`} className="text-primary">
|
||||
{match[0]}
|
||||
</span>,
|
||||
);
|
||||
if (index > cursor) parts.push(<HighlightedText key={`plain-${cursor}`} text={body.slice(cursor, index)} />);
|
||||
parts.push(<HighlightedText key={`${index}-${match[0].length}`} text={match[0]} className={PACKET_COLORS.tag} />);
|
||||
cursor = index + match[0].length;
|
||||
}
|
||||
if (cursor < body.length) parts.push(body.slice(cursor));
|
||||
if (cursor < body.length) parts.push(<HighlightedText key={`plain-${cursor}`} text={body.slice(cursor)} />);
|
||||
return parts;
|
||||
}
|
||||
|
||||
@@ -108,7 +140,82 @@ function detectBodyFormat(body: string): BodyFormat {
|
||||
function HighlightedBody({ body, format }: { body: string; format: BodyFormat }) {
|
||||
if (format === "json") return <JsonBody body={body} />;
|
||||
if (format === "markup") return <MarkupBody body={body} />;
|
||||
return body;
|
||||
return <HighlightedText text={body} />;
|
||||
}
|
||||
|
||||
export function HttpPacketSyntax({
|
||||
raw,
|
||||
wrapLines = true,
|
||||
lineNumbers = true,
|
||||
emptyFallback = "(空)",
|
||||
lineClassName = "leading-relaxed",
|
||||
}: {
|
||||
raw: string;
|
||||
wrapLines?: boolean;
|
||||
lineNumbers?: boolean;
|
||||
emptyFallback?: string;
|
||||
lineClassName?: string;
|
||||
}) {
|
||||
const value = raw || emptyFallback;
|
||||
const lines = value.replaceAll("\r\n", "\n").split("\n");
|
||||
const lineItems = React.useMemo(() => {
|
||||
const occurrences = new Map<string, number>();
|
||||
return lines.map((line) => {
|
||||
const occurrence = occurrences.get(line) ?? 0;
|
||||
occurrences.set(line, occurrence + 1);
|
||||
return { key: `${line}\u0000${occurrence}`, line };
|
||||
});
|
||||
}, [value]);
|
||||
const separator = lines.indexOf("");
|
||||
const body = separator >= 0 ? lines.slice(separator + 1).join("\n") : value;
|
||||
const bodyFormat = detectBodyFormat(body);
|
||||
const renderLine = (line: string, index: number) => {
|
||||
if (separator < 0 && bodyFormat !== "plain") return <HighlightedBody body={line} format={bodyFormat} />;
|
||||
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 (
|
||||
<>
|
||||
{lineItems.map((item, index) => (
|
||||
<div
|
||||
key={item.key}
|
||||
data-line={index + 1}
|
||||
className={cn("grid min-w-0", lineClassName, lineNumbers && "grid-cols-[2.25rem_minmax(0,1fr)]")}
|
||||
>
|
||||
{lineNumbers ? (
|
||||
<span className="sticky left-0 self-stretch border-r bg-muted/20 px-1 text-right text-[11px] text-muted-foreground/50">
|
||||
{index + 1}
|
||||
</span>
|
||||
) : null}
|
||||
<code
|
||||
className={cn(
|
||||
"min-h-[1lh] min-w-0 [tab-size:4]",
|
||||
lineNumbers ? "pr-14 pl-2" : "",
|
||||
wrapLines ? "break-words whitespace-pre-wrap" : "whitespace-pre",
|
||||
)}
|
||||
>
|
||||
{renderLine(item.line, index)}
|
||||
</code>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function countMatches(value: string, query: string) {
|
||||
const needle = query.trim().toLowerCase();
|
||||
if (!needle) return 0;
|
||||
const haystack = value.toLowerCase();
|
||||
let count = 0;
|
||||
let cursor = 0;
|
||||
while (cursor <= haystack.length - needle.length) {
|
||||
const index = haystack.indexOf(needle, cursor);
|
||||
if (index < 0) return count;
|
||||
count += 1;
|
||||
cursor = index + needle.length;
|
||||
}
|
||||
}
|
||||
|
||||
// Empty grid rows have no text, so native selection copying drops their newlines.
|
||||
@@ -148,11 +255,10 @@ function selectedPacketText(container: HTMLElement, value: string, lines: string
|
||||
export function HttpCodeBlock({ raw }: { raw: string }) {
|
||||
const [wrapLines, setWrapLines] = React.useState(true);
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
const [query, setQuery] = React.useState("");
|
||||
const value = raw || "(空)";
|
||||
const lines = value.replaceAll("\r\n", "\n").split("\n");
|
||||
const separator = lines.indexOf("");
|
||||
const body = separator >= 0 ? lines.slice(separator + 1).join("\n") : "";
|
||||
const bodyFormat = detectBodyFormat(body);
|
||||
const matches = React.useMemo(() => countMatches(value, query), [value, query]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!copied) return;
|
||||
@@ -169,15 +275,8 @@ export function HttpCodeBlock({ raw }: { raw: string }) {
|
||||
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 (
|
||||
<div className="relative mx-3 my-4 overflow-hidden rounded-md border bg-background shadow-xs">
|
||||
<div className="relative mx-3 my-4 flex min-h-0 flex-col 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>
|
||||
@@ -216,7 +315,7 @@ export function HttpCodeBlock({ raw }: { raw: string }) {
|
||||
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"
|
||||
className="min-h-0 max-h-[calc(100vh-15rem)] flex-1 min-w-0 overflow-auto bg-background py-2 font-mono text-xs outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
|
||||
onCopy={(event) => {
|
||||
const text = selectedPacketText(event.currentTarget, value, lines);
|
||||
if (text === null) return;
|
||||
@@ -234,22 +333,29 @@ export function HttpCodeBlock({ raw }: { raw: string }) {
|
||||
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)]"
|
||||
<PacketHighlightContext.Provider value={query}>
|
||||
<HttpPacketSyntax raw={value} wrapLines={wrapLines} />
|
||||
</PacketHighlightContext.Provider>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 border-t bg-muted/20 px-2 py-1">
|
||||
<SearchIcon className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="搜索报文内容"
|
||||
aria-label="搜索报文内容"
|
||||
className="min-w-0 flex-1 bg-transparent font-mono text-xs outline-none placeholder:text-muted-foreground/60"
|
||||
/>
|
||||
{query.trim() ? (
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 text-xs tabular-nums",
|
||||
matches ? "font-medium text-foreground" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<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>
|
||||
))}
|
||||
{matches} 处命中
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -154,6 +154,7 @@ export function RepeaterEditor({ tab }: { tab: RepeaterTab }) {
|
||||
onChange={changeRequest}
|
||||
disabled={busy}
|
||||
placeholder="POST /path HTTP/1.1"
|
||||
syntaxHighlight
|
||||
/>
|
||||
<div className="flex min-h-[300px] min-w-0 flex-col gap-2 xl:min-h-0">
|
||||
{response?.error ? (
|
||||
@@ -166,6 +167,7 @@ export function RepeaterEditor({ tab }: { tab: RepeaterTab }) {
|
||||
label="响应"
|
||||
value={response ? buildRawResponse(response) : ""}
|
||||
placeholder="发送后显示原始响应"
|
||||
syntaxHighlight
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { CopyButton } from "@/components/copy-button";
|
||||
import { HttpPacketSyntax } from "@/components/http-code-block";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
export function RepeaterTextPane({
|
||||
@@ -12,6 +13,7 @@ export function RepeaterTextPane({
|
||||
onChange,
|
||||
disabled,
|
||||
placeholder,
|
||||
syntaxHighlight = false,
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
@@ -19,8 +21,10 @@ export function RepeaterTextPane({
|
||||
onChange?: (value: string) => void;
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
syntaxHighlight?: boolean;
|
||||
}) {
|
||||
const gutter = React.useRef<HTMLPreElement>(null);
|
||||
const syntax = React.useRef<HTMLDivElement>(null);
|
||||
const lines = React.useMemo(() => value.split(/\r\n|\r|\n/).length, [value]);
|
||||
const numbers = React.useMemo(() => Array.from({ length: lines }, (_, index) => index + 1).join("\n"), [lines]);
|
||||
return (
|
||||
@@ -32,7 +36,7 @@ export function RepeaterTextPane({
|
||||
<span className="text-muted-foreground text-xs tabular-nums">{lines} 行</span>
|
||||
<CopyButton text={value} label={`复制${label}`} className="ml-auto" />
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-1 overflow-hidden rounded-lg border border-input focus-within:border-ring">
|
||||
<div className="relative flex min-h-0 flex-1 overflow-hidden rounded-lg border border-input focus-within:border-ring">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="w-12 shrink-0 overflow-hidden border-r bg-muted/30 text-right text-muted-foreground"
|
||||
@@ -47,6 +51,10 @@ export function RepeaterTextPane({
|
||||
onChange={(event) => onChange?.(event.target.value)}
|
||||
onScroll={(event) => {
|
||||
if (gutter.current) gutter.current.style.transform = `translateY(-${event.currentTarget.scrollTop}px)`;
|
||||
if (syntax.current) {
|
||||
syntax.current.scrollTop = event.currentTarget.scrollTop;
|
||||
syntax.current.scrollLeft = event.currentTarget.scrollLeft;
|
||||
}
|
||||
}}
|
||||
disabled={disabled}
|
||||
readOnly={!onChange}
|
||||
@@ -55,8 +63,28 @@ export function RepeaterTextPane({
|
||||
spellCheck={false}
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
className="field-sizing-fixed h-full max-h-none min-h-0 w-0 flex-1 resize-none rounded-none border-0 px-3 py-2 font-mono text-xs leading-5 focus-visible:ring-0 md:text-xs"
|
||||
className={
|
||||
"field-sizing-fixed relative z-10 h-full max-h-none min-h-0 w-0 flex-1 resize-none rounded-none border-0 px-3 py-2 font-mono text-xs leading-5 focus-visible:ring-0 md:text-xs " +
|
||||
(syntaxHighlight
|
||||
? "bg-transparent text-transparent caret-foreground placeholder:text-muted-foreground selection:bg-primary/20 selection:text-transparent "
|
||||
: "")
|
||||
}
|
||||
/>
|
||||
{syntaxHighlight ? (
|
||||
<div
|
||||
ref={syntax}
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-y-0 right-0 left-12 overflow-hidden px-3 py-2 font-mono text-xs leading-5"
|
||||
>
|
||||
<HttpPacketSyntax
|
||||
raw={value}
|
||||
lineNumbers={false}
|
||||
wrapLines={false}
|
||||
emptyFallback=""
|
||||
lineClassName="leading-5"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -13,7 +13,6 @@ import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { api } from "@/lib/api";
|
||||
import type { FindingTrafficDetail, TrafficDetail } from "@/lib/types";
|
||||
|
||||
@@ -114,17 +113,16 @@ export function TrafficEvidenceViewer({
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
) : detail ? (
|
||||
<Tabs defaultValue="request">
|
||||
<TabsList>
|
||||
<TabsTrigger value="request">请求 Request</TabsTrigger>
|
||||
<TabsTrigger value="response">响应 Response</TabsTrigger>
|
||||
</TabsList>
|
||||
<div className="grid min-w-0 grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
{(["request", "response"] as const).map((side) => (
|
||||
<TabsContent key={side} value={side}>
|
||||
<div key={side} className="flex min-w-0 flex-col gap-2">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
正文 {detail[side].total.toLocaleString()} 字节{detail[side].truncated ? " · 当前为预览" : ""}
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-sm font-medium">{side === "request" ? "请求 Request" : "响应 Response"}</p>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
正文 {detail[side].total.toLocaleString()} 字节{detail[side].truncated ? " · 当前为预览" : ""}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -134,7 +132,7 @@ export function TrafficEvidenceViewer({
|
||||
.catch((e: Error) => toast.error(e.message))
|
||||
}
|
||||
>
|
||||
下载完整{side === "request" ? "请求" : "响应"}正文
|
||||
下载完整正文
|
||||
</Button>
|
||||
</div>
|
||||
<HttpCodeBlock
|
||||
@@ -145,9 +143,9 @@ export function TrafficEvidenceViewer({
|
||||
加载更多正文
|
||||
</Button>
|
||||
) : null}
|
||||
</TabsContent>
|
||||
</div>
|
||||
))}
|
||||
</Tabs>
|
||||
</div>
|
||||
) : (
|
||||
<Skeleton className="h-56 w-full" />
|
||||
)}
|
||||
@@ -193,18 +191,16 @@ export function CapturedTrafficViewer({ id, onClose }: { id: string | null; onCl
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
) : detail ? (
|
||||
<Tabs defaultValue="request">
|
||||
<TabsList>
|
||||
<TabsTrigger value="request">请求 Request</TabsTrigger>
|
||||
<TabsTrigger value="response">响应 Response</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="request">
|
||||
<div className="grid min-w-0 grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div className="min-w-0">
|
||||
<p className="mb-1 px-1 text-sm font-medium">请求 Request</p>
|
||||
<HttpCodeBlock raw={detail.req} />
|
||||
</TabsContent>
|
||||
<TabsContent value="response">
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="mb-1 px-1 text-sm font-medium">响应 Response</p>
|
||||
<HttpCodeBlock raw={detail.resp} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Skeleton className="h-56 w-full" />
|
||||
)}
|
||||
|
||||
Reference in new issue
Block a user