mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-10-28 09:13:49 +08:00
219 lines
8.2 KiB
TypeScript
219 lines
8.2 KiB
TypeScript
"use client";
|
||
|
||
import * as React from "react";
|
||
|
||
import { SendIcon } from "lucide-react";
|
||
import { toast } from "sonner";
|
||
|
||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||
import { Badge } from "@/components/ui/badge";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Checkbox } from "@/components/ui/checkbox";
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogDescription,
|
||
DialogFooter,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
} from "@/components/ui/dialog";
|
||
import { Field, FieldLabel, FieldLegend, FieldSet } from "@/components/ui/field";
|
||
import { Input } from "@/components/ui/input";
|
||
import { api } from "@/lib/api";
|
||
|
||
import {
|
||
buildRawRequest,
|
||
buildRawResponse,
|
||
connectionOrigin,
|
||
parseHeadersText,
|
||
parseRawRequest,
|
||
} from "./repeater-http";
|
||
import { newId, type RepeaterTab } from "./repeater-state";
|
||
import { useRepeaterStore } from "./repeater-store";
|
||
import { RepeaterTextPane } from "./repeater-text-pane";
|
||
|
||
export function RepeaterEditor({ tab }: { tab: RepeaterTab }) {
|
||
const { useProxy, insecure, response } = tab;
|
||
const updateTab = useRepeaterStore((state) => state.updateTab);
|
||
const busy = useRepeaterStore((state) => state.inflight[tab.id] !== undefined);
|
||
const [targetOpen, setTargetOpen] = React.useState(false);
|
||
const [targetDraft, setTargetDraft] = React.useState("");
|
||
const targetOrigin = tab.targetOrigin ?? (tab.rawRequest === undefined ? connectionOrigin(tab.url) : "");
|
||
const requestText = buildRawRequest(tab);
|
||
function changeRequest(rawRequest: string) {
|
||
try {
|
||
const { httpVersion: _version, ...parts } = parseRawRequest(rawRequest, targetOrigin);
|
||
updateTab(tab.id, { ...parts, rawRequest, targetOrigin });
|
||
} catch {
|
||
// Preserve incomplete first lines and headers as drafts without rewriting
|
||
// the text or moving the caret. Validate only when the user sends.
|
||
updateTab(tab.id, { rawRequest, targetOrigin });
|
||
}
|
||
}
|
||
const setUseProxy = (useProxy: boolean) => updateTab(tab.id, { useProxy });
|
||
const setInsecure = (insecure: boolean) => updateTab(tab.id, { insecure });
|
||
async function send() {
|
||
const store = useRepeaterStore.getState();
|
||
if (store.inflight[tab.id] !== undefined) return;
|
||
let parsed: ReturnType<typeof parseRawRequest>;
|
||
try {
|
||
parsed = parseRawRequest(requestText, targetOrigin);
|
||
} catch (error) {
|
||
toast.error((error as Error).message);
|
||
return;
|
||
}
|
||
const { method, url: target, headersText, body, httpVersion } = parsed;
|
||
const requestGeneration = store.beginSend(tab.id);
|
||
if (requestGeneration === undefined) return;
|
||
try {
|
||
const result = await api.repeaterSend({
|
||
method,
|
||
http_version: httpVersion,
|
||
url: target,
|
||
headers: parseHeadersText(headersText),
|
||
body,
|
||
use_recording_proxy: useProxy,
|
||
insecure_skip_verify: insecure,
|
||
});
|
||
store.completeSend(tab.id, requestGeneration, {
|
||
id: newId(),
|
||
method,
|
||
url: target,
|
||
headersText,
|
||
body,
|
||
rawRequest: requestText,
|
||
targetOrigin,
|
||
useProxy,
|
||
insecure,
|
||
response: result,
|
||
at: new Date().toISOString(),
|
||
});
|
||
} catch (error) {
|
||
if (store.isCurrentSend(tab.id, requestGeneration)) toast.error((error as Error).message);
|
||
} finally {
|
||
store.clearInflight(tab.id, requestGeneration);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<section aria-label="请求编辑器" className="flex min-h-0 min-w-0 flex-1 flex-col gap-3">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<span className="min-w-0 flex-1 truncate text-muted-foreground text-xs" title={targetOrigin || tab.url}>
|
||
发送目标:{targetOrigin || connectionOrigin(tab.url) || "按 Host 请求头(默认 HTTPS)"}
|
||
</span>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
disabled={busy}
|
||
onClick={() => {
|
||
setTargetDraft(targetOrigin || connectionOrigin(tab.url));
|
||
setTargetOpen(true);
|
||
}}
|
||
>
|
||
修改目标
|
||
</Button>
|
||
<Button disabled={busy} onClick={() => void send()}>
|
||
<SendIcon data-icon="inline-start" />
|
||
{busy ? "发送中…" : "发送"}
|
||
</Button>
|
||
</div>
|
||
<FieldSet className="flex-row flex-wrap items-center gap-4 text-sm">
|
||
<FieldLegend className="sr-only">发送选项</FieldLegend>
|
||
<FieldLabel className="flex items-center gap-2" htmlFor="repeater-use-proxy">
|
||
<Checkbox
|
||
id="repeater-use-proxy"
|
||
disabled={busy}
|
||
checked={useProxy}
|
||
onCheckedChange={(value) => setUseProxy(value === true)}
|
||
/>
|
||
经流量代理
|
||
</FieldLabel>
|
||
<FieldLabel className="flex items-center gap-2" htmlFor="repeater-insecure">
|
||
<Checkbox
|
||
id="repeater-insecure"
|
||
disabled={busy}
|
||
checked={insecure}
|
||
onCheckedChange={(value) => setInsecure(value === true)}
|
||
/>
|
||
跳过 TLS 校验
|
||
</FieldLabel>
|
||
{response ? (
|
||
<div className="flex items-center gap-2 text-muted-foreground text-xs">
|
||
{response.status ? <Badge variant="secondary">{response.status_text || response.status}</Badge> : null}
|
||
<span>{response.elapsed_ms} ms</span>
|
||
<span>{response.body_bytes.toLocaleString()} 字节</span>
|
||
{response.body_truncated ? <Badge variant="outline">正文已截断</Badge> : null}
|
||
</div>
|
||
) : null}
|
||
</FieldSet>
|
||
<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={requestText}
|
||
onChange={changeRequest}
|
||
disabled={busy}
|
||
placeholder="POST /path HTTP/1.1"
|
||
/>
|
||
<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}
|
||
<RepeaterTextPane
|
||
id="repeater-response"
|
||
label="响应"
|
||
value={response ? buildRawResponse(response) : ""}
|
||
placeholder="发送后显示原始响应"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<Dialog open={targetOpen} onOpenChange={setTargetOpen}>
|
||
<DialogContent>
|
||
<DialogHeader>
|
||
<DialogTitle>修改发送目标</DialogTitle>
|
||
<DialogDescription>
|
||
设置连接地址和 HTTP/HTTPS。方法、路径和 Host 均在左侧报文中编辑;Host 可以与连接目标不同。
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
<form
|
||
className="flex flex-col gap-4"
|
||
onSubmit={(event) => {
|
||
event.preventDefault();
|
||
const origin = targetDraft.trim() ? connectionOrigin(targetDraft.trim()) : "";
|
||
if (targetDraft.trim() && !origin) {
|
||
toast.error("请填写 http:// 或 https:// 开头的地址");
|
||
return;
|
||
}
|
||
try {
|
||
const { httpVersion: _version, ...parts } = parseRawRequest(requestText, origin);
|
||
updateTab(tab.id, { ...parts, rawRequest: requestText, targetOrigin: origin });
|
||
} catch {
|
||
updateTab(tab.id, { rawRequest: requestText, targetOrigin: origin });
|
||
}
|
||
setTargetOpen(false);
|
||
}}
|
||
>
|
||
<Field>
|
||
<FieldLabel htmlFor="repeater-target">发送目标</FieldLabel>
|
||
<Input
|
||
id="repeater-target"
|
||
placeholder="https://example.com:443,留空按 Host 连接"
|
||
value={targetDraft}
|
||
onChange={(event) => setTargetDraft(event.target.value)}
|
||
/>
|
||
</Field>
|
||
<DialogFooter>
|
||
<Button type="button" variant="outline" onClick={() => setTargetOpen(false)}>
|
||
取消
|
||
</Button>
|
||
<Button type="submit">保存目标</Button>
|
||
</DialogFooter>
|
||
</form>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</section>
|
||
);
|
||
}
|