mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-10-27 17:03:49 +08:00
feat: improve request replay editor and import captured traffic
This commit is contained in:
1 parent
2f42baf8a1
commit
853a4da57a
10 files changed
+390
-171
No files matched your search
@@ -2,6 +2,8 @@
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import {
|
||||
ArrowDownWideNarrowIcon,
|
||||
ArrowUpNarrowWideIcon,
|
||||
@@ -19,6 +21,8 @@ import { toast } from "sonner";
|
||||
|
||||
import { HttpCodeBlock } from "@/components/http-code-block";
|
||||
import { LinkTrafficDialog } from "@/components/link-traffic-dialog";
|
||||
import { requestFromTraffic } from "@/components/repeater/repeater-http";
|
||||
import { seedFromEvidence } from "@/components/repeater/repeater-store";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -112,7 +116,10 @@ const SORT_STORAGE_KEY = "traffic-sort";
|
||||
const STATUS_BUCKETS = ["2xx", "3xx", "4xx", "5xx"];
|
||||
|
||||
export default function TrafficPage() {
|
||||
const [selectedFlows, setSelectedFlows] = React.useState<Set<string>>(() => new Set());
|
||||
const [selectedFlows, setSelectedFlows] = React.useState<Map<string, TrafficExchange>>(() => new Map());
|
||||
const router = useRouter();
|
||||
const [importing, setImporting] = React.useState(false);
|
||||
const importLock = React.useRef(false);
|
||||
const [linking, setLinking] = React.useState(false);
|
||||
const [page, setPage] = React.useState(0);
|
||||
const [size, setSize] = React.useState(50);
|
||||
@@ -322,6 +329,38 @@ export default function TrafficPage() {
|
||||
};
|
||||
}, [selected]);
|
||||
|
||||
async function openRepeater(flows: TrafficExchange[]) {
|
||||
if (!flows.length || importLock.current) return;
|
||||
importLock.current = true;
|
||||
setImporting(true);
|
||||
let imported = 0;
|
||||
try {
|
||||
// Sequential loading limits pressure when a whole page is selected. A bad
|
||||
// capture does not discard drafts imported successfully before it.
|
||||
for (const flow of flows) {
|
||||
try {
|
||||
const raw = await api.trafficExchange(flow.id);
|
||||
seedFromEvidence(
|
||||
requestFromTraffic(raw.req, flow),
|
||||
{ label: `流量 ${flow.id}` },
|
||||
{ title: `${flow.method} ${flow.host}` },
|
||||
);
|
||||
imported += 1;
|
||||
} catch (error) {
|
||||
toast.error(`${flow.host}:${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
if (imported) {
|
||||
toast.success(`已送入 ${imported} 条请求,可编辑后发送`);
|
||||
setSelected(null);
|
||||
router.push("/function/repeater");
|
||||
}
|
||||
} finally {
|
||||
importLock.current = false;
|
||||
setImporting(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle direction when re-clicking the active column, else sort the new column
|
||||
// newest/largest-first.
|
||||
const toggleSort = (field: SortField) =>
|
||||
@@ -607,8 +646,16 @@ export default function TrafficPage() {
|
||||
<Button variant="outline" size="sm" disabled={selectedFlows.size === 0} onClick={() => setLinking(true)}>
|
||||
关联到漏洞
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={selectedFlows.size === 0 || importing}
|
||||
onClick={() => void openRepeater([...selectedFlows.values()])}
|
||||
>
|
||||
{importing ? "转入中…" : "送入请求重发"}
|
||||
</Button>
|
||||
{selectedFlows.size > 0 ? (
|
||||
<Button variant="ghost" size="sm" onClick={() => setSelectedFlows(new Set())}>
|
||||
<Button variant="ghost" size="sm" onClick={() => setSelectedFlows(new Map())}>
|
||||
清空选择
|
||||
</Button>
|
||||
) : null}
|
||||
@@ -626,9 +673,9 @@ export default function TrafficPage() {
|
||||
checked={exchanges.length > 0 && exchanges.every((e) => selectedFlows.has(e.id))}
|
||||
onCheckedChange={(checked) =>
|
||||
setSelectedFlows((previous) => {
|
||||
const next = new Set(previous);
|
||||
const next = new Map(previous);
|
||||
for (const e of exchanges) {
|
||||
if (checked === true) next.add(e.id);
|
||||
if (checked === true) next.set(e.id, e);
|
||||
else next.delete(e.id);
|
||||
}
|
||||
return next;
|
||||
@@ -681,8 +728,8 @@ export default function TrafficPage() {
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onCheckedChange={(checked) =>
|
||||
setSelectedFlows((previous) => {
|
||||
const next = new Set(previous);
|
||||
if (checked === true) next.add(e.id);
|
||||
const next = new Map(previous);
|
||||
if (checked === true) next.set(e.id, e);
|
||||
else next.delete(e.id);
|
||||
return next;
|
||||
})
|
||||
@@ -721,9 +768,9 @@ export default function TrafficPage() {
|
||||
|
||||
{linking ? (
|
||||
<LinkTrafficDialog
|
||||
trafficIds={[...selectedFlows]}
|
||||
trafficIds={[...selectedFlows.keys()]}
|
||||
onClose={() => setLinking(false)}
|
||||
onBound={() => setSelectedFlows(new Set())}
|
||||
onBound={() => setSelectedFlows(new Map())}
|
||||
/>
|
||||
) : null}
|
||||
<Sheet open={selected !== null} onOpenChange={(open) => !open && setSelected(null)}>
|
||||
@@ -740,6 +787,15 @@ export default function TrafficPage() {
|
||||
</div>
|
||||
<SheetTitle className="break-all font-mono">{selected.host}</SheetTitle>
|
||||
<SheetDescription className="break-all font-mono">{selected.url}</SheetDescription>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-2 w-fit"
|
||||
disabled={importing}
|
||||
onClick={() => void openRepeater([selected])}
|
||||
>
|
||||
{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">
|
||||
|
||||
@@ -138,7 +138,7 @@ export function FindingTrafficPanel({
|
||||
router.push("/function/repeater");
|
||||
}}
|
||||
>
|
||||
打开 Repeater
|
||||
打开请求重发
|
||||
</Button>
|
||||
{!readOnly ? (
|
||||
<Button variant="outline" size="sm" disabled={!data || busy} onClick={() => setAdding(true)}>
|
||||
@@ -201,7 +201,7 @@ export function FindingTrafficPanel({
|
||||
查看报文
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" disabled={repeaterBusy} onClick={() => void seedRepeater(b.id)}>
|
||||
送入 Repeater
|
||||
送入请求重发
|
||||
</Button>
|
||||
{!readOnly ? (
|
||||
<>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { SendIcon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -10,23 +12,40 @@ import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Field, FieldGroup, FieldLabel, FieldLegend, FieldSet } from "@/components/ui/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { api } from "@/lib/api";
|
||||
|
||||
import { buildRawResponse, getHostHeader, parseHeadersText, setHostHeader } from "./repeater-http";
|
||||
import {
|
||||
buildRawResponse,
|
||||
getHostHeader,
|
||||
joinRequestText,
|
||||
parseHeadersText,
|
||||
setHostHeader,
|
||||
splitRequestText,
|
||||
} from "./repeater-http";
|
||||
import { newId, type RepeaterTab } from "./repeater-state";
|
||||
import { useRepeaterStore } from "./repeater-store";
|
||||
import { RepeaterTextPane } from "./repeater-text-pane";
|
||||
|
||||
const METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"];
|
||||
|
||||
export function RepeaterEditor({ tab }: { tab: RepeaterTab }) {
|
||||
const { method, url, headersText, body, useProxy, insecure, response, history } = tab;
|
||||
const { method, url, headersText, body, useProxy, insecure, response } = tab;
|
||||
const updateTab = useRepeaterStore((state) => state.updateTab);
|
||||
const busy = useRepeaterStore((state) => state.inflight[tab.id] !== undefined);
|
||||
const setMethod = (method: string) => updateTab(tab.id, { method });
|
||||
const setURL = (url: string) => updateTab(tab.id, { url });
|
||||
const setHeadersText = (headersText: string) => updateTab(tab.id, { headersText });
|
||||
const setBody = (body: string) => updateTab(tab.id, { body });
|
||||
const [request, setRequest] = React.useState(() => ({ headersText, body, text: joinRequestText(headersText, body) }));
|
||||
// Keep partial edits (including a single trailing newline) intact while syncing
|
||||
// changes made by the Host control or by restoring a history entry.
|
||||
if (request.headersText !== headersText || request.body !== body) {
|
||||
setRequest({ headersText, body, text: joinRequestText(headersText, body) });
|
||||
}
|
||||
function changeRequest(text: string) {
|
||||
const parts = splitRequestText(text);
|
||||
setRequest({ ...parts, text });
|
||||
updateTab(tab.id, parts);
|
||||
}
|
||||
const setUseProxy = (useProxy: boolean) => updateTab(tab.id, { useProxy });
|
||||
const setInsecure = (insecure: boolean) => updateTab(tab.id, { insecure });
|
||||
async function send() {
|
||||
@@ -148,87 +167,27 @@ export function RepeaterEditor({ tab }: { tab: RepeaterTab }) {
|
||||
</div>
|
||||
) : null}
|
||||
</FieldSet>
|
||||
<div className="grid min-h-0 flex-1 auto-rows-max gap-3 overflow-y-auto xl:auto-rows-auto xl:grid-cols-[140px_minmax(0,1fr)_minmax(0,1fr)]">
|
||||
<div className="flex min-h-0 flex-col gap-2 overflow-hidden rounded-md border p-2">
|
||||
<div className="font-medium text-muted-foreground text-xs">标签历史</div>
|
||||
<div className="flex max-h-40 min-h-0 flex-1 flex-col gap-1 overflow-y-auto xl:max-h-none">
|
||||
{history.length === 0 ? (
|
||||
<p className="text-muted-foreground text-xs">发送后会出现在这里</p>
|
||||
) : (
|
||||
history.map((entry) => (
|
||||
<button
|
||||
key={entry.id}
|
||||
type="button"
|
||||
disabled={busy}
|
||||
className="block w-full rounded-md px-2 py-1.5 text-left hover:bg-muted"
|
||||
onClick={() => {
|
||||
updateTab(tab.id, {
|
||||
method: entry.method,
|
||||
url: entry.url,
|
||||
headersText: entry.headersText,
|
||||
body: entry.body,
|
||||
useProxy: entry.useProxy,
|
||||
insecure: entry.insecure,
|
||||
response: entry.response,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<div className="truncate font-mono text-xs">
|
||||
{entry.method} {entry.response.status || "ERR"}
|
||||
</div>
|
||||
<div className="truncate text-[11px] text-muted-foreground">{entry.url}</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex min-h-[300px] flex-col gap-2 overflow-hidden xl:min-h-0">
|
||||
<div className="font-medium text-xs">请求</div>
|
||||
<FieldGroup className="min-h-0 flex-1 gap-2">
|
||||
<Field className="min-h-0 flex-1 gap-1">
|
||||
<FieldLabel htmlFor="repeater-headers" className="text-muted-foreground text-xs">
|
||||
请求头
|
||||
</FieldLabel>
|
||||
<Textarea
|
||||
className="min-h-[120px] flex-1 font-mono text-xs"
|
||||
disabled={busy}
|
||||
id="repeater-headers"
|
||||
value={headersText}
|
||||
onChange={(event) => setHeadersText(event.target.value)}
|
||||
placeholder="Header: value"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</Field>
|
||||
<Field className="min-h-0 flex-1 gap-1">
|
||||
<FieldLabel htmlFor="repeater-body" className="text-muted-foreground text-xs">
|
||||
请求体
|
||||
</FieldLabel>
|
||||
<Textarea
|
||||
className="min-h-[120px] flex-1 font-mono text-xs"
|
||||
disabled={busy}
|
||||
id="repeater-body"
|
||||
value={body}
|
||||
onChange={(event) => setBody(event.target.value)}
|
||||
placeholder="请求正文(可选)"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</div>
|
||||
<div className="flex min-h-[300px] flex-col gap-2 overflow-hidden xl:min-h-0">
|
||||
<div className="font-medium text-xs">响应</div>
|
||||
<div className="grid min-h-0 flex-1 auto-rows-[minmax(300px,1fr)] gap-3 overflow-y-auto xl:auto-rows-auto xl:grid-cols-2">
|
||||
<RepeaterTextPane
|
||||
id="repeater-request"
|
||||
label="请求"
|
||||
value={request.text}
|
||||
onChange={changeRequest}
|
||||
disabled={busy}
|
||||
placeholder="Header: value(请求头与正文用空行分隔)"
|
||||
/>
|
||||
<div className="flex min-h-[300px] min-w-0 flex-col gap-2 xl:min-h-0">
|
||||
{response?.error ? (
|
||||
<Alert variant={response.status ? "default" : "destructive"}>
|
||||
<AlertDescription className="break-all font-mono text-xs">{response.error}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
<div className="min-h-[240px] flex-1 overflow-auto rounded-md border">
|
||||
{response ? (
|
||||
<pre className="whitespace-pre-wrap break-words p-3 font-mono text-xs">{buildRawResponse(response)}</pre>
|
||||
) : (
|
||||
<p className="p-3 text-muted-foreground text-xs">发送后显示原始响应</p>
|
||||
)}
|
||||
</div>
|
||||
<RepeaterTextPane
|
||||
id="repeater-response"
|
||||
label="响应"
|
||||
value={response ? buildRawResponse(response) : ""}
|
||||
placeholder="发送后显示原始响应"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
import type { RepeaterTab } from "./repeater-state";
|
||||
import { useRepeaterStore } from "./repeater-store";
|
||||
|
||||
export function RepeaterHistory({ tab }: { tab?: RepeaterTab }) {
|
||||
const updateTab = useRepeaterStore((state) => state.updateTab);
|
||||
const busy = useRepeaterStore((state) => (tab ? state.inflight[tab.id] !== undefined : false));
|
||||
return (
|
||||
<section aria-label="标签历史" className="flex min-h-20 flex-1 flex-col gap-2 overflow-hidden border-t pt-3">
|
||||
<div className="flex items-center justify-between px-1 text-muted-foreground text-xs">
|
||||
<h3 className="font-medium">标签历史</h3>
|
||||
<span>{tab?.history.length ?? 0} 条</span>
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-1 overflow-y-auto">
|
||||
{!tab?.history.length ? (
|
||||
<p className="px-1 text-muted-foreground text-xs">发送后会出现在这里</p>
|
||||
) : (
|
||||
tab.history.map((entry) => (
|
||||
<Button
|
||||
key={entry.id}
|
||||
variant="ghost"
|
||||
disabled={busy}
|
||||
className="h-auto w-full shrink-0 flex-col items-start gap-1 px-2 py-2 text-left"
|
||||
onClick={() =>
|
||||
updateTab(tab.id, {
|
||||
method: entry.method,
|
||||
url: entry.url,
|
||||
headersText: entry.headersText,
|
||||
body: entry.body,
|
||||
useProxy: entry.useProxy,
|
||||
insecure: entry.insecure,
|
||||
response: entry.response,
|
||||
})
|
||||
}
|
||||
title={entry.url}
|
||||
>
|
||||
<span className="flex w-full items-center justify-between gap-2 font-mono text-xs">
|
||||
<span>
|
||||
{entry.method} {entry.response.status || "ERR"}
|
||||
</span>
|
||||
<time className="text-muted-foreground" dateTime={entry.at}>
|
||||
{new Date(entry.at).toLocaleTimeString("zh-CN", { hour12: false })}
|
||||
</time>
|
||||
</span>
|
||||
<span className="w-full truncate text-muted-foreground text-xs">{entry.url}</span>
|
||||
</Button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -76,3 +76,41 @@ export function setHostHeader(headersText: string, host: string): string {
|
||||
if (first < 0 && value) result.unshift(`Host: ${value}`);
|
||||
return result.join("\n");
|
||||
}
|
||||
|
||||
// Split only the HTTP separator; preserve all body bytes represented by the text.
|
||||
export function splitRequestText(text: string): { headersText: string; body: string } {
|
||||
const separator = /\r?\n\r?\n/.exec(text);
|
||||
if (!separator) return { headersText: text, body: "" };
|
||||
return { headersText: text.slice(0, separator.index), body: text.slice(separator.index + separator[0].length) };
|
||||
}
|
||||
|
||||
export function joinRequestText(headersText: string, body: string): string {
|
||||
if (!headersText && !body) return "";
|
||||
return `${headersText.replace(/(?:\r?\n)+$/, "")}\n\n${body}`;
|
||||
}
|
||||
|
||||
export function requestFromTraffic(
|
||||
raw: string,
|
||||
exchange: { method: string; url: string; host: string },
|
||||
): RepeaterInitial {
|
||||
const firstLine = /^[^\r\n]+(?:\r?\n|$)/.exec(raw);
|
||||
if (!firstLine || !/^[A-Z]+\s+\S+\s+HTTP\/\d(?:\.\d)?\r?\n?$/i.test(firstLine[0])) {
|
||||
throw new Error("请求报文不完整,无法送入请求重发");
|
||||
}
|
||||
const request = splitRequestText(raw.slice(firstLine[0].length));
|
||||
if (/…\[truncated\] @blob sha256:[a-f0-9]{64}/.test(request.body)) {
|
||||
throw new Error("请求正文为截断预览,请下载完整正文后再导入");
|
||||
}
|
||||
let host = exchange.host;
|
||||
try {
|
||||
host = new URL(exchange.url).host || host;
|
||||
} catch {
|
||||
/* Legacy URL uses the indexed host. */
|
||||
}
|
||||
return {
|
||||
method: exchange.method,
|
||||
url: exchange.url,
|
||||
headersText: getHostHeader(request.headersText) ? request.headersText : setHostHeader(request.headersText, host),
|
||||
body: request.body,
|
||||
};
|
||||
}
|
||||
@@ -21,6 +21,9 @@ const {
|
||||
truncateResponse,
|
||||
} = require("./repeater-state.ts");
|
||||
const {
|
||||
joinRequestText,
|
||||
splitRequestText,
|
||||
requestFromTraffic,
|
||||
buildRawResponse,
|
||||
getHostHeader,
|
||||
headersFromReqHead,
|
||||
@@ -393,3 +396,44 @@ test("secure origins retain native UUID generation", (t) => {
|
||||
assert.equal(newId(), "native-uuid");
|
||||
assert.equal(native.mock.callCount(), 1);
|
||||
});
|
||||
|
||||
test("combined request editing preserves body newlines and splits only the first HTTP separator", () => {
|
||||
const body = '\n{\r\n "a": 1\r\n}\n\n';
|
||||
const text = joinRequestText("Host: example.test\nContent-Type: application/json", body);
|
||||
assert.deepEqual(splitRequestText(text), { headersText: "Host: example.test\nContent-Type: application/json", body });
|
||||
assert.deepEqual(splitRequestText("Host: example.test\n"), { headersText: "Host: example.test\n", body: "" });
|
||||
assert.deepEqual(splitRequestText("Host: example.test\r\n\r\nhello\r\n\r\nworld"), {
|
||||
headersText: "Host: example.test",
|
||||
body: "hello\r\n\r\nworld",
|
||||
});
|
||||
assert.deepEqual(splitRequestText(joinRequestText("", "body only")), { headersText: "", body: "body only" });
|
||||
assert.equal(joinRequestText("", ""), "");
|
||||
});
|
||||
|
||||
test("traffic import uses captured metadata, preserves Host overrides and the full body", () => {
|
||||
const flow = { method: "POST", url: "https://example.test:8443/upload?q=1", host: "example.test" };
|
||||
const raw = "POST /upload?q=1 HTTP/1.1\r\nHost: override.test\r\nCookie: a=b\r\n\r\nfirst\r\n\r\nlast\n";
|
||||
assert.deepEqual(requestFromTraffic(raw, flow), {
|
||||
method: "POST",
|
||||
url: flow.url,
|
||||
headersText: "Host: override.test\r\nCookie: a=b",
|
||||
body: "first\r\n\r\nlast\n",
|
||||
});
|
||||
assert.equal(
|
||||
requestFromTraffic("POST /upload HTTP/1.1\nContent-Type: text/plain\n\nhello", flow).headersText,
|
||||
"Host: example.test:8443\nContent-Type: text/plain",
|
||||
);
|
||||
});
|
||||
|
||||
test("traffic import rejects failed loads and blob preview markers rather than sending corrupted bodies", () => {
|
||||
const flow = { method: "POST", url: "https://example.test/", host: "example.test" };
|
||||
assert.throws(() => requestFromTraffic("(无法加载报文)", flow), /不完整/);
|
||||
assert.throws(
|
||||
() =>
|
||||
requestFromTraffic(
|
||||
`POST / HTTP/1.1\nHost: example.test\n\npreview\n…[truncated] @blob sha256:${"a".repeat(64)} (len=9999)`,
|
||||
flow,
|
||||
),
|
||||
/截断预览/,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { CopyButton } from "@/components/copy-button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
export function RepeaterTextPane({
|
||||
id,
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
placeholder,
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
value: string;
|
||||
onChange?: (value: string) => void;
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
const gutter = React.useRef<HTMLPreElement>(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 (
|
||||
<div className="flex min-h-[280px] min-w-0 flex-1 flex-col gap-2 xl:min-h-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<label htmlFor={id} className="font-medium text-xs">
|
||||
{label}
|
||||
</label>
|
||||
<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
|
||||
aria-hidden="true"
|
||||
className="w-12 shrink-0 overflow-hidden border-r bg-muted/30 text-right text-muted-foreground"
|
||||
>
|
||||
<pre ref={gutter} className="select-none px-2 py-2 font-mono text-xs leading-5">
|
||||
{numbers}
|
||||
</pre>
|
||||
</div>
|
||||
<Textarea
|
||||
id={id}
|
||||
value={value}
|
||||
onChange={(event) => onChange?.(event.target.value)}
|
||||
onScroll={(event) => {
|
||||
if (gutter.current) gutter.current.style.transform = `translateY(-${event.currentTarget.scrollTop}px)`;
|
||||
}}
|
||||
disabled={disabled}
|
||||
readOnly={!onChange}
|
||||
placeholder={placeholder}
|
||||
wrap="off"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -31,6 +31,7 @@ import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { RepeaterEditor } from "./repeater-editor";
|
||||
import { RepeaterHistory } from "./repeater-history";
|
||||
import { useRepeaterStore } from "./repeater-store";
|
||||
|
||||
export function RepeaterWorkspace() {
|
||||
@@ -52,10 +53,10 @@ export function RepeaterWorkspace() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100dvh-7rem)] min-h-[600px] min-w-0 flex-col gap-4 md:h-[calc(100dvh-8rem)]">
|
||||
<div className="flex min-h-[calc(100dvh-7rem)] min-w-0 flex-col gap-4 xl:h-[calc(100dvh-8rem)] xl:min-h-[600px]">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="font-semibold text-xl">Repeater · 请求重放</h1>
|
||||
<h1 className="font-semibold text-xl">请求重发</h1>
|
||||
<p className="text-muted-foreground text-sm">编辑、发送并比较请求 · 草稿与近期响应保存在此浏览器</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
@@ -71,7 +72,7 @@ export function RepeaterWorkspace() {
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>清空 Repeater 工作区?</AlertDialogTitle>
|
||||
<AlertDialogTitle>清空请求重发工作区?</AlertDialogTitle>
|
||||
<AlertDialogDescription>所有分组、标签、请求草稿和历史响应都会被删除。</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
@@ -86,84 +87,87 @@ export function RepeaterWorkspace() {
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-4 lg:flex-row">
|
||||
<nav
|
||||
aria-label="Repeater 分组和标签"
|
||||
className="flex max-h-48 shrink-0 flex-col gap-3 overflow-y-auto rounded-md border p-2 lg:max-h-none lg:w-56"
|
||||
aria-label="请求重发分组和标签"
|
||||
className="flex max-h-64 min-h-0 shrink-0 flex-col gap-3 overflow-hidden rounded-md border p-2 lg:max-h-none lg:w-56"
|
||||
>
|
||||
{workspace.groups.map((group) => (
|
||||
<div key={group.id} className="flex flex-col gap-1">
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`${group.collapsed ? "展开" : "折叠"}分组 ${group.name}`}
|
||||
onClick={() => store.toggleGroupCollapsed(group.id)}
|
||||
>
|
||||
{group.collapsed ? <ChevronRight /> : <ChevronDown />}
|
||||
</Button>
|
||||
<span className="min-w-0 flex-1 truncate font-medium text-sm" title={group.name}>
|
||||
{group.name}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`重命名分组 ${group.name}`}
|
||||
onClick={() => setRename({ kind: "group", id: group.id, name: group.name })}
|
||||
>
|
||||
<Pencil />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`新建标签 · ${group.name}`}
|
||||
title="新建标签"
|
||||
onClick={() => store.createTab({}, { groupId: group.id })}
|
||||
>
|
||||
<Plus />
|
||||
</Button>
|
||||
<div className="max-h-32 shrink-0 overflow-y-auto lg:max-h-[45%]">
|
||||
{workspace.groups.map((group) => (
|
||||
<div key={group.id} className="flex flex-col gap-1">
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`${group.collapsed ? "展开" : "折叠"}分组 ${group.name}`}
|
||||
onClick={() => store.toggleGroupCollapsed(group.id)}
|
||||
>
|
||||
{group.collapsed ? <ChevronRight /> : <ChevronDown />}
|
||||
</Button>
|
||||
<span className="min-w-0 flex-1 truncate font-medium text-sm" title={group.name}>
|
||||
{group.name}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`重命名分组 ${group.name}`}
|
||||
onClick={() => setRename({ kind: "group", id: group.id, name: group.name })}
|
||||
>
|
||||
<Pencil />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`新建标签 · ${group.name}`}
|
||||
title="新建标签"
|
||||
onClick={() => store.createTab({}, { groupId: group.id })}
|
||||
>
|
||||
<Plus />
|
||||
</Button>
|
||||
</div>
|
||||
{!group.collapsed
|
||||
? workspace.tabs
|
||||
.filter((tab) => tab.groupId === group.id)
|
||||
.map((tab) => (
|
||||
<div
|
||||
key={tab.id}
|
||||
className={cn(
|
||||
"flex min-w-0 items-center gap-1 rounded-md",
|
||||
active?.id === tab.id && "bg-accent",
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-current={active?.id === tab.id ? "page" : undefined}
|
||||
className="min-w-0 flex-1 justify-start"
|
||||
onClick={() => store.setActiveTab(tab.id)}
|
||||
onDoubleClick={() => setRename({ kind: "tab", id: tab.id, name: tab.title })}
|
||||
title={`${tab.title}\n${tab.url}`}
|
||||
>
|
||||
<span className="truncate">{tab.title}</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`重命名标签 ${tab.title}`}
|
||||
onClick={() => setRename({ kind: "tab", id: tab.id, name: tab.title })}
|
||||
>
|
||||
<Pencil />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`关闭标签 ${tab.title}`}
|
||||
onClick={() => store.closeTab(tab.id)}
|
||||
>
|
||||
<X />
|
||||
</Button>
|
||||
</div>
|
||||
))
|
||||
: null}
|
||||
</div>
|
||||
{!group.collapsed
|
||||
? workspace.tabs
|
||||
.filter((tab) => tab.groupId === group.id)
|
||||
.map((tab) => (
|
||||
<div
|
||||
key={tab.id}
|
||||
className={cn(
|
||||
"flex min-w-0 items-center gap-1 rounded-md",
|
||||
active?.id === tab.id && "bg-accent",
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-current={active?.id === tab.id ? "page" : undefined}
|
||||
className="min-w-0 flex-1 justify-start"
|
||||
onClick={() => store.setActiveTab(tab.id)}
|
||||
onDoubleClick={() => setRename({ kind: "tab", id: tab.id, name: tab.title })}
|
||||
title={`${tab.title}\n${tab.url}`}
|
||||
>
|
||||
<span className="truncate">{tab.title}</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`重命名标签 ${tab.title}`}
|
||||
onClick={() => setRename({ kind: "tab", id: tab.id, name: tab.title })}
|
||||
>
|
||||
<Pencil />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`关闭标签 ${tab.title}`}
|
||||
onClick={() => store.closeTab(tab.id)}
|
||||
>
|
||||
<X />
|
||||
</Button>
|
||||
</div>
|
||||
))
|
||||
: null}
|
||||
</div>
|
||||
))}
|
||||
))}
|
||||
</div>
|
||||
<RepeaterHistory tab={active} />
|
||||
</nav>
|
||||
<main className="flex min-h-0 min-w-0 flex-1 flex-col gap-3">
|
||||
{active ? (
|
||||
|
||||
@@ -101,7 +101,7 @@ export function TrafficEvidenceViewer({
|
||||
</div>
|
||||
{detail ? (
|
||||
<Button variant="outline" size="sm" onClick={openRepeater}>
|
||||
送入 Repeater
|
||||
送入请求重发
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -74,7 +74,7 @@ export const sidebarItems: NavGroup[] = [
|
||||
{ id: "schedules", title: "计划任务", url: "/function/schedules", icon: CalendarClock },
|
||||
{ id: "findings", title: "发现", url: "/function/findings", icon: Bug },
|
||||
{ id: "traffic", title: "流量", url: "/function/traffic", icon: Activity },
|
||||
{ id: "repeater", title: "Repeater · 请求重放", url: "/function/repeater", icon: Repeat },
|
||||
{ id: "repeater", title: "请求重发", url: "/function/repeater", icon: Repeat },
|
||||
{ id: "commands", title: "工具执行", url: "/function/commands", icon: Terminal },
|
||||
{ id: "llm-records", title: "LLM 录制", url: "/function/llm-records", icon: Radio },
|
||||
{ id: "assets", title: "资产", url: "/function/assets", icon: Network },
|
||||
|
||||
Reference in new issue
Block a user