mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-10-29 09:33:50 +08:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
76c76eefa9 |
No files matched your search
+6
-17
@@ -24,16 +24,6 @@ const autoDefaultTmpl = `你是 **Auto**,这个渗透测试平台的「操作
|
||||
- 用人话简洁汇报你做了什么、结果如何;只根据工具真实返回作答,不臆造。
|
||||
- 只在授权范围内操作。`
|
||||
|
||||
// assetMapperDefaultTmpl is the read-only assistant behind the topology canvas.
|
||||
const assetMapperDefaultTmpl = `你是授权渗透测试平台的「资产梳理」Agent。你的职责是根据资产库中的真实数据,整理公网、边界服务和内网业务资产之间的关系,并解释用户指定的入站路径。
|
||||
|
||||
工作规则:
|
||||
- 先使用 list_assets 和 list_companies 获取事实,再给出结论;没有证据的关系标记为“待验证”,不要臆造。
|
||||
- 识别域名、子域名、IP、服务、应用和端点之间的关联,说明数据来源和最近更新时间。
|
||||
- 用户询问“从哪里进入内网”时,按“公网入口 → 边界服务 → 目标资产”的顺序回答;当前资产库没有网络流量或防火墙证据时,明确说明这是资产关系推导,不把它描述成已验证的网络通路。
|
||||
- 用简洁中文输出,优先给出资产清单、关系链、可信度和下一步验证建议。
|
||||
- 只读分析,不主动修改资产库,不执行探测。`
|
||||
|
||||
// pentestDefaultTmpl is the built-in "渗透测试" (solo pentest) agent's prompt. Unlike
|
||||
// the orchestration roles (goals/planner/worker), it runs standalone via the chat page
|
||||
// and is its own planner + executor + auditor. Default tools: list_assets / insert_assets
|
||||
@@ -122,12 +112,11 @@ const ReporterDefaultPrompt = `你是一个授权渗透测试系统里的**漏
|
||||
// source while the same string stays as the in-code render fallback.
|
||||
func BuiltinPromptSeeds() map[string]string {
|
||||
return map[string]string{
|
||||
"goals": goalsDefaultTmpl,
|
||||
"planner": plannerDefaultTmpl,
|
||||
"mainagent": mainAgentDefaultTmpl,
|
||||
"worker": workerDefaultTmpl,
|
||||
"auto": autoDefaultTmpl,
|
||||
"asset_mapper": assetMapperDefaultTmpl,
|
||||
"pentest": pentestDefaultTmpl,
|
||||
"goals": goalsDefaultTmpl,
|
||||
"planner": plannerDefaultTmpl,
|
||||
"mainagent": mainAgentDefaultTmpl,
|
||||
"worker": workerDefaultTmpl,
|
||||
"auto": autoDefaultTmpl,
|
||||
"pentest": pentestDefaultTmpl,
|
||||
}
|
||||
}
|
||||
@@ -43,8 +43,6 @@ func builtinToolsByAgent() map[string][]actool.CoreTool {
|
||||
// auto 默认绑漏洞上报 + 资产管理工具,其他域工具可在 UI 按需勾选。
|
||||
// 新库由此 seed 写入;老库由 seedAutoDefaultBindings 迁移。
|
||||
"auto": {ts.addFinding(), ts.insertAssets(), ts.addCompanyScope(), ts.listAssets(), ts.listCompanies()},
|
||||
// asset_mapper 只读资产库,供资产梳理和拓扑解释使用。
|
||||
"asset_mapper": {ts.listAssets(), ts.listCompanies()},
|
||||
// pentest(独立渗透 agent)默认绑:查资产 / 插资产 / 报漏洞 / 查漏洞 / 查企业。
|
||||
// 新库由此 seed 写入;老库由 seedPentestDefaultBindings 迁移。
|
||||
"pentest": {ts.listAssets(), ts.insertAssets(), ts.addFinding(), ts.listFindings(), ts.listCompanies()},
|
||||
@@ -64,7 +62,7 @@ var defaultUnbound = map[string]bool{"goal_met": true}
|
||||
// 多个 agent 都有)合成一条,Agents 取并集;defaultUnbound 里的工具则强制绑定为空。
|
||||
func BuiltinToolSeeds() []ToolSeed {
|
||||
byAgent := builtinToolsByAgent()
|
||||
order := []string{"mainagent", "goals", "planner", "worker", "auto", "asset_mapper", "pentest"}
|
||||
order := []string{"mainagent", "goals", "planner", "worker", "auto", "pentest"}
|
||||
|
||||
type acc struct {
|
||||
tool actool.CoreTool
|
||||
|
||||
@@ -209,8 +209,6 @@ var builtinAgents = []builtinAgent{
|
||||
}, false, nil},
|
||||
// Auto:内置「平台操作」agent。不参与渗透编排循环,经对话页驱动,用工具操作平台。
|
||||
{"auto", "Auto", "assistant", "平台操作助手:用工具管理任务(建/看/暂停/给提示)与资产,并可创建/修改 skill、自定义工具、MCP。", nil, false, nil},
|
||||
// Asset mapper: read-only assistant used by the topology canvas and asset views.
|
||||
{"asset_mapper", "资产梳理", "assistant", "资产梳理助手:汇总公网、边界与内网资产关系,基于真实资产数据解释入站路径,不凭空推断连接。", nil, false, nil},
|
||||
// 渗透测试:内置「独立渗透」agent。经对话页驱动,一人从侦察到收尾走完整条渗透链,自己规划自己执行自己验证。默认开启交互式 shell。
|
||||
{"pentest", "渗透测试", "assistant", "独立渗透 agent:一人从侦察→找攻击面→深入利用→验证→收尾走完整条链,自己规划、自己执行、自己对抗式验证。", nil, true, intp(0)},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function formatClock(value: Date) {
|
||||
const pad = (part: number) => String(part).padStart(2, "0");
|
||||
return `${value.getFullYear()}-${pad(value.getMonth() + 1)}-${pad(value.getDate())} ${pad(value.getHours())}:${pad(value.getMinutes())}:${pad(value.getSeconds())}`;
|
||||
}
|
||||
|
||||
export function LiveClock({ className }: { className?: string }) {
|
||||
const [now, setNow] = React.useState<Date | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
const update = () => setNow(new Date());
|
||||
update();
|
||||
const timer = window.setInterval(update, 1000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<time
|
||||
role="timer"
|
||||
className={cn(
|
||||
"hidden rounded-md border bg-muted/40 px-2 py-1 font-mono text-[11px] text-muted-foreground tabular-nums sm:inline-flex",
|
||||
className,
|
||||
)}
|
||||
dateTime={now?.toISOString()}
|
||||
aria-label="当前时间"
|
||||
title="当前本地时间"
|
||||
>
|
||||
{now ? formatClock(now) : "---- -- -- --:--:--"}
|
||||
</time>
|
||||
);
|
||||
}
|
||||
@@ -10,12 +10,13 @@ import { useCurrentUser } from "@/hooks/use-current-user";
|
||||
import { api } from "@/lib/api";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { EgressIpNavbar, EgressIpProvider } from "./egress-ip-banner";
|
||||
import { LiveClock } from "./live-clock";
|
||||
import { AccountSwitcher } from "./sidebar/account-switcher";
|
||||
import { LayoutControls } from "./sidebar/layout-controls";
|
||||
import { SearchDialog } from "./sidebar/search-dialog";
|
||||
import { ThemeSwitcher } from "./sidebar/theme-switcher";
|
||||
import { UpdateBadge } from "./update-badge";
|
||||
import { EgressIpNavbar, EgressIpProvider } from "./egress-ip-banner";
|
||||
|
||||
// 任务详情页保持原样:它自带头部/Tabs 与内边距,这里不再叠加全局头部和 padding。
|
||||
function isFullBleed(pathname: string) {
|
||||
@@ -77,6 +78,7 @@ function MainContentInner({ children }: { children: ReactNode }) {
|
||||
<EgressIpNavbar />
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<LiveClock />
|
||||
{version && (
|
||||
<span className="font-medium text-muted-foreground text-xs tabular-nums">版本 · {version}</span>
|
||||
)}
|
||||
|
||||
@@ -34,34 +34,6 @@ const FOLD_STEP = 20;
|
||||
|
||||
type Kind = CoverageGraphNode["kind"];
|
||||
|
||||
type Zone = "internet" | "boundary" | "internal";
|
||||
|
||||
const zoneMeta: Record<Zone, { label: string; description: string; className: string }> = {
|
||||
internet: {
|
||||
label: "互联网区",
|
||||
description: "公网域名 / 外部入口",
|
||||
className: "bg-sky-50/70 dark:bg-sky-950/20",
|
||||
},
|
||||
boundary: {
|
||||
label: "边界区",
|
||||
description: "服务 / 网关 / 暴露面",
|
||||
className: "bg-amber-50/70 dark:bg-amber-950/15",
|
||||
},
|
||||
internal: {
|
||||
label: "内网区",
|
||||
description: "业务资产 / 应用 / 端点",
|
||||
className: "bg-emerald-50/65 dark:bg-emerald-950/15",
|
||||
},
|
||||
};
|
||||
|
||||
// 当前后端资产关系以域名、服务和业务资产为主,先用类型推导可视化分区。
|
||||
// 后续接入 VPC/网段/防火墙数据后,可以直接由 API 返回 zone 覆盖这里的推导结果。
|
||||
function zoneOf(node: CoverageGraphNode): Zone {
|
||||
if (node.kind === "root_domain" || node.kind === "subdomain") return "internet";
|
||||
if (node.kind === "service") return "boundary";
|
||||
return "internal";
|
||||
}
|
||||
|
||||
type KindMeta = { label: string; icon: LucideIcon; iconBg: string; hex: string; size: number };
|
||||
|
||||
const kindMeta: Record<Kind, KindMeta> = {
|
||||
@@ -213,11 +185,8 @@ type G6NodeDatum = {
|
||||
inScope: boolean;
|
||||
lbl: string;
|
||||
size: number;
|
||||
active: boolean;
|
||||
};
|
||||
|
||||
type G6EdgeDatum = { source: string; target: string; active: boolean };
|
||||
|
||||
function trunc(s: string, n = 26): string {
|
||||
return s.length > n ? `${s.slice(0, n - 1)}…` : s;
|
||||
}
|
||||
@@ -242,7 +211,7 @@ function graphLabel(n: CoverageGraphNode): string {
|
||||
return n.label;
|
||||
}
|
||||
|
||||
function toG6Nodes(renderNodes: RenderNode[], activeKeys: Set<string>): G6NodeDatum[] {
|
||||
function toG6Nodes(renderNodes: RenderNode[]): G6NodeDatum[] {
|
||||
return renderNodes.map((rn) => {
|
||||
if (rn.fold) {
|
||||
return {
|
||||
@@ -253,7 +222,6 @@ function toG6Nodes(renderNodes: RenderNode[], activeKeys: Set<string>): G6NodeDa
|
||||
inScope: false,
|
||||
lbl: `还有 ${rn.hidden.length} 个${kindMeta[rn.kind].label}`,
|
||||
size: 24,
|
||||
active: activeKeys.has(rn.key),
|
||||
};
|
||||
}
|
||||
return {
|
||||
@@ -264,7 +232,6 @@ function toG6Nodes(renderNodes: RenderNode[], activeKeys: Set<string>): G6NodeDa
|
||||
inScope: rn.node.in_scope,
|
||||
lbl: trunc(graphLabel(rn.node) || kindMeta[rn.kind].label),
|
||||
size: kindMeta[rn.kind].size,
|
||||
active: activeKeys.has(rn.key),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -281,7 +248,6 @@ function nodeFill(d: G6NodeDatum): string {
|
||||
return "#94a3b8";
|
||||
}
|
||||
function nodeStroke(d: G6NodeDatum): string {
|
||||
if (d.active) return "#0f766e";
|
||||
if (d.fold || !d.inScope) return "#94a3b8";
|
||||
if (d.tested) return "#0f766e";
|
||||
return "#64748b";
|
||||
@@ -498,19 +464,16 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera
|
||||
const [expanded, setExpanded] = React.useState<Map<string, number>>(new Map());
|
||||
const [selectedAsset, setSelectedAsset] = React.useState<CoverageGraphNode | null>(null);
|
||||
const [selectedFoldId, setSelectedFoldId] = React.useState<string | null>(null);
|
||||
const [activePath, setActivePath] = React.useState<string[]>([]);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [lastSyncedAt, setLastSyncedAt] = React.useState<Date | null>(null);
|
||||
|
||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||
const graphRef = React.useRef<G6Graph | null>(null);
|
||||
// click 处理需要最新的 key→RenderNode 映射(G6 事件回调闭包外读 ref)。
|
||||
const renderMapRef = React.useRef<Map<string, RenderNode>>(new Map());
|
||||
const gDataRef = React.useRef<{ nodes: G6NodeDatum[]; edges: G6EdgeDatum[] }>({
|
||||
const gDataRef = React.useRef<{ nodes: G6NodeDatum[]; edges: { source: string; target: string }[] }>({
|
||||
nodes: [],
|
||||
edges: [],
|
||||
});
|
||||
const graphDataRef = React.useRef<typeof data>(null);
|
||||
|
||||
const fetchGraph = React.useCallback(() => {
|
||||
setLoading(true);
|
||||
@@ -521,8 +484,6 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera
|
||||
// tested 状态——不显示测试进度、不做已测高亮。
|
||||
const nodes = coverageEnabled ? (g.nodes ?? []) : (g.nodes ?? []).map((n) => ({ ...n, tested: false }));
|
||||
setData({ nodes, edges: g.edges ?? [] });
|
||||
graphDataRef.current = { nodes, edges: g.edges ?? [] };
|
||||
setLastSyncedAt(new Date());
|
||||
})
|
||||
.catch(() => {
|
||||
/* 保留上一次数据 */
|
||||
@@ -545,18 +506,14 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera
|
||||
// 结构签名:只在可见节点/边集合变化时重建图 + 重跑布局,避免无谓抖动。
|
||||
const sig = React.useMemo(
|
||||
() =>
|
||||
`${renderNodes.map((n) => `${n.key}:${n.fold ? "f" : n.node.tested ? "t" : "u"}`).sort().join(",")}|${renderEdges.length}|${activePath.join(",")}`,
|
||||
[activePath, renderNodes, renderEdges],
|
||||
`${renderNodes.map((n) => `${n.key}:${n.fold ? "f" : n.node.tested ? "t" : "u"}`).sort().join(",")}|${renderEdges.length}`,
|
||||
[renderNodes, renderEdges],
|
||||
);
|
||||
|
||||
// 维护 gDataRef + renderMapRef(供事件与图数据应用读取)。
|
||||
gDataRef.current = {
|
||||
nodes: toG6Nodes(renderNodes, new Set(activePath)),
|
||||
edges: renderEdges.map((e) => ({
|
||||
source: e.src,
|
||||
target: e.dst,
|
||||
active: activePath.includes(e.src) && activePath.includes(e.dst),
|
||||
})),
|
||||
nodes: toG6Nodes(renderNodes),
|
||||
edges: renderEdges.map((e) => ({ source: e.src, target: e.dst })),
|
||||
};
|
||||
const rmap = new Map<string, RenderNode>();
|
||||
for (const rn of renderNodes) rmap.set(rn.key, rn);
|
||||
@@ -585,7 +542,7 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera
|
||||
container: containerRef.current,
|
||||
autoResize: true,
|
||||
autoFit: "view",
|
||||
background: "transparent",
|
||||
background: "#f0f2f7",
|
||||
node: {
|
||||
style: {
|
||||
size: (d: unknown) => nd(d).size,
|
||||
@@ -604,18 +561,10 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera
|
||||
labelBackgroundFill: "rgba(255,255,255,0.75)",
|
||||
labelBackgroundRadius: 3,
|
||||
labelPadding: [1, 3],
|
||||
opacity: (d: unknown) =>
|
||||
gDataRef.current.nodes.some((node) => node.active) && !nd(d).active ? 0.28 : 1,
|
||||
},
|
||||
},
|
||||
edge: {
|
||||
style: {
|
||||
stroke: (d: unknown) => ((d as G6EdgeDatum).active ? "#0f766e" : "#cbd5e1"),
|
||||
lineWidth: (d: unknown) => ((d as G6EdgeDatum).active ? 2.5 : 1),
|
||||
opacity: (d: unknown) =>
|
||||
gDataRef.current.edges.some((edge) => edge.active) && !(d as G6EdgeDatum).active ? 0.18 : 1,
|
||||
endArrow: true,
|
||||
},
|
||||
style: { stroke: "#cbd5e1", lineWidth: 1, endArrow: false },
|
||||
},
|
||||
layout: {
|
||||
type: "d3-force",
|
||||
@@ -641,22 +590,8 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera
|
||||
if (!id) return;
|
||||
const rn = renderMapRef.current.get(id);
|
||||
if (!rn) return;
|
||||
if (rn.fold) {
|
||||
setSelectedFoldId(rn.key);
|
||||
return;
|
||||
}
|
||||
setSelectedAsset(rn.node);
|
||||
const parents = new Map<string, string>();
|
||||
for (const edge of graphDataRef.current?.edges ?? []) {
|
||||
if (!parents.has(edge.src)) parents.set(edge.src, edge.dst);
|
||||
}
|
||||
const lineage: string[] = [];
|
||||
let cursor: string | undefined = rn.node.key;
|
||||
while (cursor && !lineage.includes(cursor)) {
|
||||
lineage.push(cursor);
|
||||
cursor = parents.get(cursor);
|
||||
}
|
||||
setActivePath(lineage);
|
||||
if (rn.fold) setSelectedFoldId(rn.key);
|
||||
else setSelectedAsset(rn.node);
|
||||
});
|
||||
graphRef.current = graph;
|
||||
applyData();
|
||||
@@ -701,38 +636,14 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera
|
||||
const inScope = data?.nodes.filter((n) => n.in_scope).length ?? 0;
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full overflow-hidden bg-slate-100/80 dark:bg-slate-950/30">
|
||||
<div className="pointer-events-none absolute inset-0 z-0 grid grid-cols-3">
|
||||
{(Object.keys(zoneMeta) as Zone[]).map((zone, index) => (
|
||||
<div
|
||||
key={zone}
|
||||
className={cn("relative border-r border-slate-200/80 dark:border-slate-800/80", zoneMeta[zone].className, index === 2 && "border-r-0")}
|
||||
>
|
||||
<div className="absolute top-3 left-4">
|
||||
<div className="text-foreground/80 text-sm font-semibold">{zoneMeta[zone].label}</div>
|
||||
<div className="text-muted-foreground mt-0.5 text-[11px]">{zoneMeta[zone].description}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div ref={containerRef} className="relative z-10 h-full w-full pt-12" />
|
||||
<div className="relative h-full w-full">
|
||||
<div ref={containerRef} className="h-full w-full" />
|
||||
|
||||
{/* 图例 + 统计 + 刷新(叠加层) */}
|
||||
<div className="bg-card/95 pointer-events-auto absolute top-3 left-3 z-20 flex max-w-[360px] flex-col gap-2.5 rounded-lg border p-3 text-xs shadow-sm backdrop-blur">
|
||||
<div className="bg-card/95 pointer-events-auto absolute top-3 left-3 flex max-w-[340px] flex-col gap-2.5 rounded-lg border p-3 text-xs shadow-sm backdrop-blur">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="flex size-7 shrink-0 items-center justify-center rounded-md bg-violet-100 text-violet-700 dark:bg-violet-950/60 dark:text-violet-300">
|
||||
<Waypoints className="size-4" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<div className="text-foreground font-medium">资产梳理 Agent</div>
|
||||
<div className="text-muted-foreground truncate text-[11px]">
|
||||
{loading ? "正在更新资产关系…" : lastSyncedAt ? `最近更新 ${lastSyncedAt.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" })}` : "等待首次梳理"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{total > 0 ? (
|
||||
<span className="text-muted-foreground whitespace-nowrap">
|
||||
<span className="text-muted-foreground">
|
||||
范围内 <span className="text-foreground font-semibold tabular-nums">{inScope}</span>
|
||||
{coverageEnabled && (
|
||||
<>
|
||||
@@ -745,9 +656,8 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera
|
||||
) : (
|
||||
<span className="text-muted-foreground">{loading ? "加载中…" : "暂无范围内资产(先锚定任务范围)"}</span>
|
||||
)}
|
||||
<Button variant="outline" size="sm" className="h-7 gap-1.5 px-2" onClick={fetchGraph} title="重新梳理资产">
|
||||
<Button variant="ghost" size="icon" className="size-6" onClick={fetchGraph} title="刷新">
|
||||
<RefreshCw className={cn("size-3.5", loading && "animate-spin")} />
|
||||
<span className="hidden sm:inline">重新梳理</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1.5">
|
||||
@@ -779,49 +689,11 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera
|
||||
<span className="size-3 rounded-full border border-dashed border-neutral-400 bg-neutral-200" /> 范围外
|
||||
</span>
|
||||
</div>
|
||||
<div className="border-border/60 flex flex-wrap gap-x-3 gap-y-1.5 border-t pt-2">
|
||||
{(Object.keys(zoneMeta) as Zone[]).map((zone) => (
|
||||
<span key={zone} className="text-muted-foreground inline-flex items-center gap-1.5">
|
||||
<span className={cn("size-2.5 rounded-full", zone === "internet" && "bg-sky-400", zone === "boundary" && "bg-amber-400", zone === "internal" && "bg-emerald-400")} />
|
||||
{zoneMeta[zone].label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-muted-foreground/80 border-border/60 border-t pt-2 leading-relaxed">
|
||||
点击节点查看详情并高亮关系链;拖拽节点、滚轮缩放。灰色「⋯」是折叠节点,可展开更多资产。
|
||||
力导向布局,可拖拽节点、滚轮缩放;灰色「⋯」是折叠节点,点开可展开更多。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{selectedAsset && activePath.length > 0 && data && (
|
||||
<div className="bg-card/95 pointer-events-auto absolute top-3 right-3 z-20 w-[min(310px,calc(100%-2rem))] rounded-lg border p-3 shadow-sm backdrop-blur">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-foreground text-sm font-semibold">关系路径</div>
|
||||
<div className="text-muted-foreground mt-0.5 text-[11px]">从当前资产向上追溯关联关系</div>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={() => setActivePath([])}>
|
||||
清除高亮
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-col gap-1.5">
|
||||
{[...activePath].reverse().map((key, index, path) => {
|
||||
const node = data.nodes.find((item) => item.key === key);
|
||||
if (!node) return null;
|
||||
return (
|
||||
<React.Fragment key={key}>
|
||||
<div className="flex min-w-0 items-center gap-2 rounded-md bg-muted/60 px-2 py-1.5 text-xs">
|
||||
<span className={cn("size-2 shrink-0 rounded-full", zoneOf(node) === "internet" && "bg-sky-500", zoneOf(node) === "boundary" && "bg-amber-500", zoneOf(node) === "internal" && "bg-emerald-500")} />
|
||||
<span className="text-muted-foreground shrink-0">{zoneMeta[zoneOf(node)].label}</span>
|
||||
<span className="min-w-0 flex-1 truncate font-mono" title={node.label}>{node.label}</span>
|
||||
</div>
|
||||
{index < path.length - 1 && <div className="text-muted-foreground pl-3 text-[10px]">↓</div>}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AssetSheet node={selectedAsset} taskId={taskId} onOpenChange={(o) => !o && setSelectedAsset(null)} />
|
||||
<FoldSheet
|
||||
fold={selectedFold}
|
||||
|
||||
@@ -111,6 +111,70 @@ function HighlightedBody({ body, format }: { body: string; format: BodyFormat })
|
||||
return body;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a packet with the same syntax rules everywhere in the UI.
|
||||
*
|
||||
* `lineNumbers` is disabled by the repeater editor because it already owns a
|
||||
* synchronised gutter. Keeping the tokenisation here prevents packet previews
|
||||
* and editable packet panes from drifting apart visually.
|
||||
*/
|
||||
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 separator = lines.indexOf("");
|
||||
// LLM records and a few provider diagnostics contain a standalone JSON
|
||||
// payload instead of an HTTP head. Treat that as a body too, so those
|
||||
// packet views use the same token colours as captured HTTP traffic.
|
||||
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 (
|
||||
<>
|
||||
{lines.map((line, index) => (
|
||||
<div
|
||||
key={`${index}-${line}`}
|
||||
data-line={index + 1}
|
||||
className={cn("grid min-w-0", lineClassName, lineNumbers && "grid-cols-[1.5rem_minmax(0,1fr)]")}
|
||||
>
|
||||
{lineNumbers ? (
|
||||
<span className="sticky left-0 self-stretch border-r bg-muted/20 px-1 text-right text-muted-foreground/60">
|
||||
{index + 1}
|
||||
</span>
|
||||
) : null}
|
||||
<code
|
||||
className={cn(
|
||||
"min-h-[1lh] min-w-0 [tab-size:4]",
|
||||
lineNumbers ? "pr-16 pl-1.5" : "",
|
||||
wrapLines ? "break-words whitespace-pre-wrap" : "whitespace-pre",
|
||||
)}
|
||||
>
|
||||
{renderLine(line, index)}
|
||||
</code>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Empty grid rows have no text, so native selection copying drops their newlines.
|
||||
// Map the selected DOM boundaries back to the raw packet instead.
|
||||
function selectedPacketText(container: HTMLElement, value: string, lines: string[]): string | null {
|
||||
@@ -150,9 +214,6 @@ export function HttpCodeBlock({ raw }: { raw: string }) {
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
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);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!copied) return;
|
||||
@@ -169,13 +230,6 @@ 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="absolute top-2 right-2 flex items-center gap-0.5 rounded-md border bg-background/95 p-0.5 shadow-xs">
|
||||
@@ -234,22 +288,7 @@ 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)]"
|
||||
>
|
||||
<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>
|
||||
))}
|
||||
<HttpPacketSyntax raw={value} wrapLines={wrapLines} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { SendIcon } from "lucide-react";
|
||||
import { SendIcon, Settings2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
@@ -32,14 +32,40 @@ import { newId, type RepeaterTab } from "./repeater-state";
|
||||
import { useRepeaterStore } from "./repeater-store";
|
||||
import { RepeaterTextPane } from "./repeater-text-pane";
|
||||
|
||||
function prettyResponse(response: NonNullable<RepeaterTab["response"]>) {
|
||||
const raw = buildRawResponse(response);
|
||||
const separator = raw.indexOf("\n\n");
|
||||
if (separator < 0) return raw;
|
||||
const body = raw.slice(separator + 2);
|
||||
try {
|
||||
return `${raw.slice(0, separator)}\n\n${JSON.stringify(JSON.parse(body), null, 2)}`;
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
function responseStatusClass(status: number) {
|
||||
if (status >= 500) return "border-rose-300 text-rose-600";
|
||||
if (status >= 400) return "border-amber-300 text-amber-600";
|
||||
if (status >= 300) return "border-sky-300 text-sky-600";
|
||||
return "border-emerald-300 text-emerald-600";
|
||||
}
|
||||
|
||||
function responseTextFor(response: RepeaterTab["response"], mode: "raw" | "pretty") {
|
||||
if (!response) return "";
|
||||
return mode === "pretty" ? prettyResponse(response) : buildRawResponse(response);
|
||||
}
|
||||
|
||||
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 [responseMode, setResponseMode] = React.useState<"raw" | "pretty">("pretty");
|
||||
const targetOrigin = tab.targetOrigin ?? (tab.rawRequest === undefined ? connectionOrigin(tab.url) : "");
|
||||
const requestText = buildRawRequest(tab);
|
||||
const responseText = responseTextFor(response, responseMode);
|
||||
function changeRequest(rawRequest: string) {
|
||||
try {
|
||||
const { httpVersion: _version, ...parts } = parseRawRequest(rawRequest, targetOrigin);
|
||||
@@ -96,11 +122,15 @@ export function RepeaterEditor({ tab }: { tab: RepeaterTab }) {
|
||||
}
|
||||
|
||||
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>
|
||||
<section aria-label="请求编辑器" className="flex min-h-0 min-w-0 flex-1 flex-col gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2 border-b px-1 pb-2">
|
||||
<span className="text-muted-foreground text-xs">目标</span>
|
||||
<code
|
||||
className="min-w-0 max-w-[min(60vw,44rem)] truncate rounded bg-muted px-2 py-1 font-mono text-xs"
|
||||
title={targetOrigin || tab.url}
|
||||
>
|
||||
{targetOrigin || connectionOrigin(tab.url) || "按 Host 请求头(默认 HTTPS)"}
|
||||
</code>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -110,14 +140,15 @@ export function RepeaterEditor({ tab }: { tab: RepeaterTab }) {
|
||||
setTargetOpen(true);
|
||||
}}
|
||||
>
|
||||
修改目标
|
||||
<Settings2 data-icon="inline-start" />
|
||||
目标设置
|
||||
</Button>
|
||||
<Button disabled={busy} onClick={() => void send()}>
|
||||
<Button className="ml-auto" 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">
|
||||
<FieldSet className="flex-row flex-wrap items-center gap-3 border-b px-1 pb-2 text-xs">
|
||||
<FieldLegend className="sr-only">发送选项</FieldLegend>
|
||||
<FieldLabel className="flex items-center gap-2" htmlFor="repeater-use-proxy">
|
||||
<Checkbox
|
||||
@@ -138,15 +169,19 @@ export function RepeaterEditor({ tab }: { tab: RepeaterTab }) {
|
||||
跳过 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}
|
||||
<div className="ml-auto flex items-center gap-2 text-muted-foreground text-xs">
|
||||
{response.status ? (
|
||||
<Badge variant="outline" className={responseStatusClass(response.status)}>
|
||||
{response.status} {response.status_text}
|
||||
</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">
|
||||
<div className="grid min-h-0 flex-1 auto-rows-[minmax(300px,1fr)] gap-2 overflow-hidden lg:auto-rows-auto lg:grid-cols-2">
|
||||
<RepeaterTextPane
|
||||
id="repeater-request"
|
||||
label="请求"
|
||||
@@ -154,6 +189,12 @@ export function RepeaterEditor({ tab }: { tab: RepeaterTab }) {
|
||||
onChange={changeRequest}
|
||||
disabled={busy}
|
||||
placeholder="POST /path HTTP/1.1"
|
||||
syntaxHighlight
|
||||
panelClassName="bg-background"
|
||||
showClock
|
||||
toolbar={
|
||||
<span className="rounded bg-muted px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">Raw</span>
|
||||
}
|
||||
/>
|
||||
<div className="flex min-h-[300px] min-w-0 flex-col gap-2 xl:min-h-0">
|
||||
{response?.error ? (
|
||||
@@ -164,8 +205,32 @@ export function RepeaterEditor({ tab }: { tab: RepeaterTab }) {
|
||||
<RepeaterTextPane
|
||||
id="repeater-response"
|
||||
label="响应"
|
||||
value={response ? buildRawResponse(response) : ""}
|
||||
value={responseText}
|
||||
placeholder="发送后显示原始响应"
|
||||
syntaxHighlight
|
||||
panelClassName="bg-background"
|
||||
toolbar={
|
||||
<div className="ml-auto flex items-center gap-0.5 rounded border bg-muted/50 p-0.5">
|
||||
<Button
|
||||
type="button"
|
||||
variant={responseMode === "raw" ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
className="h-6 px-1.5 text-[10px]"
|
||||
onClick={() => setResponseMode("raw")}
|
||||
>
|
||||
Raw
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={responseMode === "pretty" ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
className="h-6 px-1.5 text-[10px]"
|
||||
onClick={() => setResponseMode("pretty")}
|
||||
>
|
||||
Pretty
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -32,9 +32,11 @@ type Rename = { kind: "tab" | "group"; id: string; name: string };
|
||||
export function RepeaterNavigator({
|
||||
workspace,
|
||||
onRename,
|
||||
className,
|
||||
}: {
|
||||
workspace: RepeaterWorkspace;
|
||||
onRename: (value: Rename) => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const store = useRepeaterStore();
|
||||
const [filters, setFilters] = React.useState(DEFAULT_FILTERS);
|
||||
@@ -77,7 +79,10 @@ export function RepeaterNavigator({
|
||||
return (
|
||||
<nav
|
||||
aria-label="请求重发分组和标签"
|
||||
className="flex h-[28rem] min-h-0 w-full shrink-0 flex-col gap-3 overflow-y-auto rounded-md border p-3 lg:h-[calc(100dvh-14rem)] lg:min-h-[480px] lg:w-80 xl:h-auto xl:min-h-0"
|
||||
className={cn(
|
||||
"flex h-[28rem] min-h-0 w-full shrink-0 flex-col gap-3 overflow-y-auto rounded-md border p-3 lg:h-[calc(100dvh-14rem)] lg:min-h-[480px] lg:w-80 xl:h-auto xl:min-h-0",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex shrink-0 flex-col gap-2">
|
||||
<Input
|
||||
|
||||
@@ -2,8 +2,13 @@
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { WrapTextIcon } from "lucide-react";
|
||||
|
||||
import { LiveClock } from "@/app/(main)/_components/live-clock";
|
||||
import { CopyButton } from "@/components/copy-button";
|
||||
import { HttpPacketSyntax } from "@/components/http-code-block";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function RepeaterTextPane({
|
||||
id,
|
||||
@@ -12,6 +17,10 @@ export function RepeaterTextPane({
|
||||
onChange,
|
||||
disabled,
|
||||
placeholder,
|
||||
syntaxHighlight = false,
|
||||
toolbar,
|
||||
panelClassName,
|
||||
showClock = false,
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
@@ -19,20 +28,43 @@ export function RepeaterTextPane({
|
||||
onChange?: (value: string) => void;
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
syntaxHighlight?: boolean;
|
||||
toolbar?: React.ReactNode;
|
||||
panelClassName?: string;
|
||||
showClock?: boolean;
|
||||
}) {
|
||||
const gutter = React.useRef<HTMLPreElement>(null);
|
||||
const syntax = React.useRef<HTMLDivElement>(null);
|
||||
const [wrapLines, setWrapLines] = React.useState(true);
|
||||
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">
|
||||
<section
|
||||
className={cn(
|
||||
"flex min-h-[280px] min-w-0 flex-1 flex-col gap-2 rounded-md border bg-background p-2 xl:min-h-0",
|
||||
panelClassName,
|
||||
)}
|
||||
>
|
||||
<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" />
|
||||
{showClock ? <LiveClock className="ml-auto" /> : null}
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-6 items-center gap-1 rounded border px-1.5 text-[10px] text-muted-foreground hover:bg-muted"
|
||||
onClick={() => setWrapLines((value) => !value)}
|
||||
title={wrapLines ? "切换为不换行" : "切换为自动换行"}
|
||||
aria-label={wrapLines ? "切换为不换行" : "切换为自动换行"}
|
||||
>
|
||||
<WrapTextIcon className="size-3" />
|
||||
{wrapLines ? "换行" : "不换行"}
|
||||
</button>
|
||||
{toolbar}
|
||||
<CopyButton text={value} label={`复制${label}`} className={showClock ? "ml-0" : "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 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,17 +79,41 @@ 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}
|
||||
placeholder={placeholder}
|
||||
wrap="off"
|
||||
wrap={wrapLines ? "soft" : "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"
|
||||
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 selection:bg-primary/20 selection:text-transparent placeholder:text-muted-foreground"
|
||||
: "")
|
||||
}
|
||||
/>
|
||||
{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={wrapLines}
|
||||
emptyFallback=""
|
||||
lineClassName="leading-5"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { FolderPlus } from "lucide-react";
|
||||
import { Library, Plus, X } from "lucide-react";
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -27,16 +27,39 @@ import {
|
||||
import { Field, FieldLabel } from "@/components/ui/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { RepeaterEditor } from "./repeater-editor";
|
||||
import { RepeaterNavigator } from "./repeater-navigator";
|
||||
import { useRepeaterStore } from "./repeater-store";
|
||||
|
||||
const METHOD_COLORS: Record<string, string> = {
|
||||
GET: "text-sky-600 dark:text-sky-400",
|
||||
POST: "text-emerald-600 dark:text-emerald-400",
|
||||
PUT: "text-amber-600 dark:text-amber-400",
|
||||
PATCH: "text-amber-600 dark:text-amber-400",
|
||||
DELETE: "text-rose-600 dark:text-rose-400",
|
||||
};
|
||||
|
||||
function methodColor(method: string) {
|
||||
return METHOD_COLORS[method.toUpperCase()] ?? "text-muted-foreground";
|
||||
}
|
||||
|
||||
function statusColor(status: number) {
|
||||
if (status >= 500) return "text-rose-600 dark:text-rose-400";
|
||||
if (status >= 400) return "text-amber-600 dark:text-amber-400";
|
||||
if (status >= 300) return "text-sky-600 dark:text-sky-400";
|
||||
if (status >= 200) return "text-emerald-600 dark:text-emerald-400";
|
||||
return "text-muted-foreground";
|
||||
}
|
||||
|
||||
export function RepeaterWorkspace() {
|
||||
const store = useRepeaterStore();
|
||||
const { workspace, hydrate } = store;
|
||||
const [rename, setRename] = React.useState<{ kind: "tab" | "group"; id: string; name: string } | null>(null);
|
||||
const [libraryOpen, setLibraryOpen] = React.useState(false);
|
||||
React.useEffect(() => {
|
||||
hydrate();
|
||||
}, [hydrate]);
|
||||
@@ -52,16 +75,20 @@ export function RepeaterWorkspace() {
|
||||
}
|
||||
|
||||
return (
|
||||
<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">请求重发</h1>
|
||||
<p className="text-muted-foreground text-sm">编辑、发送并比较请求 · 草稿与近期响应保存在此浏览器</p>
|
||||
<div className="flex min-h-[calc(100dvh-7rem)] min-w-0 flex-col gap-2 xl:h-[calc(100dvh-8rem)] xl:min-h-[600px]">
|
||||
<div className="flex flex-wrap items-center gap-2 border-b pb-2">
|
||||
<div className="min-w-0">
|
||||
<h1 className="font-semibold text-lg tracking-tight">请求重发</h1>
|
||||
<p className="text-muted-foreground text-xs">Repeater · 草稿与历史响应保存在此浏览器</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => store.createGroup()}>
|
||||
<FolderPlus data-icon="inline-start" />
|
||||
新建分组
|
||||
<div className="ml-auto flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => setLibraryOpen(true)}>
|
||||
<Library data-icon="inline-start" />
|
||||
请求库
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => store.createTab()}>
|
||||
<Plus data-icon="inline-start" />
|
||||
新建请求
|
||||
</Button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
@@ -84,40 +111,98 @@ export function RepeaterWorkspace() {
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-4 lg:flex-row">
|
||||
<RepeaterNavigator workspace={workspace} onRename={setRename} />
|
||||
<main className="flex min-h-0 min-w-0 flex-1 flex-col gap-3">
|
||||
{active ? (
|
||||
<>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h2 className="min-w-0 flex-1 truncate font-medium text-sm" title={active.title}>
|
||||
{active.title}
|
||||
</h2>
|
||||
<Field orientation="horizontal" className="w-auto">
|
||||
<FieldLabel htmlFor="repeater-group">移入分组</FieldLabel>
|
||||
<Select value={active.groupId} onValueChange={(id) => store.setTabGroup(active.id, id)}>
|
||||
<SelectTrigger id="repeater-group" className="w-36">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{workspace.groups.map((group) => (
|
||||
<SelectItem key={group.id} value={group.id}>
|
||||
{group.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<div className="flex min-h-10 min-w-0 items-center rounded-md border bg-muted/20 p-1">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto">
|
||||
{workspace.tabs.map((tab) => {
|
||||
const selected = tab.id === workspace.activeTabId;
|
||||
return (
|
||||
<div
|
||||
key={tab.id}
|
||||
className={cn(
|
||||
"group flex min-w-0 max-w-64 shrink-0 items-center rounded-sm border border-transparent",
|
||||
selected && "border-border bg-background shadow-xs",
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="min-w-0 justify-start gap-2 rounded-sm px-2 text-xs"
|
||||
aria-current={selected ? "page" : undefined}
|
||||
onClick={() => store.setActiveTab(tab.id)}
|
||||
onDoubleClick={() => setRename({ kind: "tab", id: tab.id, name: tab.title })}
|
||||
title={`${tab.title}\n${tab.url}`}
|
||||
>
|
||||
<span className={cn("font-mono font-semibold", methodColor(tab.method))}>{tab.method}</span>
|
||||
<span className="truncate">{tab.title}</span>
|
||||
{tab.response?.status ? (
|
||||
<span className={cn("font-mono text-[10px]", statusColor(tab.response.status))}>
|
||||
{tab.response.status}
|
||||
</span>
|
||||
) : null}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="mr-0.5 opacity-60 hover:opacity-100"
|
||||
aria-label={`关闭请求 ${tab.title}`}
|
||||
onClick={() => store.closeTab(tab.id)}
|
||||
>
|
||||
<X />
|
||||
</Button>
|
||||
</div>
|
||||
<RepeaterEditor key={active.id} tab={active} />
|
||||
</>
|
||||
) : (
|
||||
<Button onClick={() => store.createTab()}>新建标签</Button>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Button variant="ghost" size="icon-sm" aria-label="新建请求" onClick={() => store.createTab()}>
|
||||
<Plus />
|
||||
</Button>
|
||||
</div>
|
||||
<main className="flex min-h-0 min-w-0 flex-1 flex-col rounded-md border bg-muted/10 p-2">
|
||||
{active ? (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2 px-1">
|
||||
<span className="truncate font-medium text-sm" title={active.title}>
|
||||
{active.title}
|
||||
</span>
|
||||
<Field orientation="horizontal" className="ml-auto w-auto gap-2">
|
||||
<FieldLabel htmlFor="repeater-group" className="text-muted-foreground text-xs">
|
||||
分组
|
||||
</FieldLabel>
|
||||
<Select value={active.groupId} onValueChange={(id) => store.setTabGroup(active.id, id)}>
|
||||
<SelectTrigger id="repeater-group" className="h-7 w-32 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{workspace.groups.map((group) => (
|
||||
<SelectItem key={group.id} value={group.id}>
|
||||
{group.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
</div>
|
||||
<RepeaterEditor key={active.id} tab={active} />
|
||||
</div>
|
||||
) : (
|
||||
<Button onClick={() => store.createTab()}>新建请求</Button>
|
||||
)}
|
||||
</main>
|
||||
<Sheet open={libraryOpen} onOpenChange={setLibraryOpen}>
|
||||
<SheetContent side="left" className="w-[min(92vw,380px)] max-w-none p-3">
|
||||
<SheetHeader className="px-1 pb-1">
|
||||
<SheetTitle>请求库</SheetTitle>
|
||||
<SheetDescription>搜索、分组和恢复请求历史。</SheetDescription>
|
||||
</SheetHeader>
|
||||
<RepeaterNavigator
|
||||
workspace={workspace}
|
||||
onRename={setRename}
|
||||
className="h-full min-h-0 w-full rounded-md border-0 p-1 lg:h-full lg:min-h-0 lg:w-full xl:h-full"
|
||||
/>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
<Dialog
|
||||
open={rename !== null}
|
||||
onOpenChange={(open) => {
|
||||
|
||||
Reference in new issue
Block a user