feat(assets): add editable company and task scopes

This commit is contained in:
pphua committed 2026-08-24 19:29:14 +08:00
1 parent 4392b4fee1
commit 31dc9e7446
20 files changed
+4186 -783

No files matched your search

File diff suppressed because it is too large. Load diff
@@ -1,56 +1,102 @@
"use client";
import * as React from "react";
import {
GlobeIcon,
NetworkIcon,
LayoutTemplateIcon,
LinkIcon,
KeyRoundIcon,
ChevronLeftIcon,
ChevronRightIcon,
GlobeIcon,
KeyRoundIcon,
LayoutTemplateIcon,
LinkIcon,
type LucideIcon,
NetworkIcon,
PlusIcon,
SmartphoneIcon,
Trash2Icon,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { toast } from "sonner";
import { ScopeTextEditor } from "@/components/scope-text-editor";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Tabs, TabsContent } from "@/components/ui/tabs";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Card, CardContent, CardFooter } from "@/components/ui/card";
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { Spinner } from "@/components/ui/spinner";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { api } from "@/lib/api";
import type { Asset } from "@/lib/types";
import { parseCompanyScopeText } from "@/lib/company-scope";
import { taskAssetSourceLabel } from "@/lib/task-assets";
import type { Asset, NewAssetType } from "@/lib/types";
import { cn } from "@/lib/utils";
const PAGE_SIZES = [25, 50, 100, 200];
const METHOD_COLOR: Record<string, string> = {
GET: "bg-emerald-100 text-emerald-700",
POST: "bg-blue-100 text-blue-700",
PUT: "bg-amber-100 text-amber-700",
PATCH: "bg-orange-100 text-orange-700",
DELETE: "bg-red-100 text-red-700",
GET: "bg-emerald-100 text-emerald-700",
HEAD: "bg-purple-100 text-purple-700",
OPTIONS: "bg-slate-100 text-slate-600",
PATCH: "bg-orange-100 text-orange-700",
POST: "bg-blue-100 text-blue-700",
PUT: "bg-amber-100 text-amber-700",
};
const TABS: { key: NewAssetType; label: string; icon: LucideIcon }[] = [
{ key: "root_domain", label: "根域名", icon: GlobeIcon },
{ key: "ip", label: "IP", icon: NetworkIcon },
{ key: "subdomain", label: "子域名", icon: GlobeIcon },
{ key: "app", label: "应用", icon: SmartphoneIcon },
{ key: "service", label: "服务", icon: LayoutTemplateIcon },
{ key: "endpoint", label: "接口", icon: LinkIcon },
];
function firstText(values: Array<string | undefined>, fallback: string): string {
return values.find((value) => value?.trim()) ?? fallback;
}
function assetLabel(asset: Asset): string {
switch (asset.type) {
case "root_domain":
case "subdomain":
return firstText([asset.domain], `#${asset.id}`);
case "ip":
return firstText([asset.ip], `#${asset.id}`);
case "app":
return firstText([asset.app_name, asset.url], `#${asset.id}`);
case "service": {
const host = firstText([asset.ip, asset.domain], "");
const address = [host, asset.port].filter((value) => value !== undefined && value !== "").join(":");
return firstText([asset.url, address, asset.service_name], `#${asset.id}`);
}
case "endpoint":
return firstText([[asset.method, asset.url].filter(Boolean).join(" ")], `#${asset.id}`);
}
}
function MethodBadge({ method }: { method: string }) {
const m = method.toUpperCase();
const normalized = method.toUpperCase();
return (
<span className={cn("inline-block rounded px-1.5 py-0.5 font-mono text-[10px] font-semibold leading-none", METHOD_COLOR[m] ?? "bg-muted text-muted-foreground")}>
{m || "—"}
<span
className={cn(
"inline-block rounded px-1.5 py-0.5 font-mono font-semibold text-[10px] leading-none",
METHOD_COLOR[normalized] ?? "bg-muted text-muted-foreground",
)}
>
{normalized || "—"}
</span>
);
}
@@ -63,259 +109,470 @@ function statusTone(code: number) {
return "text-muted-foreground";
}
function fmtBytes(n?: number | null) {
if (!n || !Number.isFinite(n) || n <= 0) return "—";
const u = ["B", "KB", "MB"];
const i = Math.min(Math.floor(Math.log(n) / Math.log(1024)), u.length - 1);
return `${i === 0 ? n : (n / 1024 ** i).toFixed(1)} ${u[i]}`;
function fmtBytes(value?: number | null) {
if (!value || !Number.isFinite(value) || value <= 0) return "—";
const units = ["B", "KB", "MB"];
const index = Math.min(Math.floor(Math.log(value) / Math.log(1024)), units.length - 1);
return `${index === 0 ? value : (value / 1024 ** index).toFixed(1)} ${units[index]}`;
}
function Chips({ items, mono }: { items: string[]; mono?: boolean }) {
const clean = items.filter(Boolean);
if (clean.length === 0) return <span className="text-xs text-muted-foreground">—</span>;
if (clean.length === 0) return <span className="text-muted-foreground text-xs">—</span>;
return (
<div className="flex flex-wrap gap-1">
{clean.map((s, i) => (
<Badge key={i} variant="outline" className={cn("text-[10px]", mono && "font-mono")}>{s}</Badge>
{clean.map((item) => (
<Badge key={item} variant="outline" className={cn(mono && "font-mono")}>
{item}
</Badge>
))}
</div>
);
}
function SourceCell({ asset }: { asset: Asset }) {
const source = firstText([asset.task_source], "legacy");
const summary = firstText([asset.task_source_summary], "由历史任务资产关联迁移,暂无更详细来源说明");
return (
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="outline" className="max-w-28 shrink-0 font-normal">
<span className="truncate">{taskAssetSourceLabel(source)}</span>
</Badge>
</TooltipTrigger>
<TooltipContent side="left" align="start" className="max-w-sm">
<div className="flex min-w-0 flex-col gap-1">
<span className="font-medium">{taskAssetSourceLabel(source)}</span>
<span className="[overflow-wrap:anywhere]">{summary}</span>
{asset.task_source_node_id ? (
<span className="font-mono opacity-80">来源节点 #{asset.task_source_node_id}</span>
) : null}
</div>
</TooltipContent>
</Tooltip>
);
}
function AssetCard({
cols, total, page, size, onSize, onPage, children,
children,
cols,
loaded,
onPage,
onSize,
page,
size,
total,
}: {
children: React.ReactNode;
cols: string[];
total: number;
loaded: boolean;
onPage: (page: number) => void;
onSize: (size: number) => void;
page: number;
size: number;
onSize: (v: number) => void;
onPage: (fn: (p: number) => number) => void;
children: React.ReactNode;
total: number;
}) {
const rows = React.Children.toArray(children);
const pageCount = Math.max(1, Math.ceil(total / size));
const start = total === 0 ? 0 : page * size + 1;
const end = page * size + rows.length;
const end = Math.min(total, page * size + rows.length);
let tableRows: React.ReactNode;
if (!loaded) {
tableRows = (
<TableRow>
<TableCell colSpan={cols.length} className="py-14 text-center">
<Spinner className="mx-auto" />
</TableCell>
</TableRow>
);
} else if (rows.length > 0) {
tableRows = rows;
} else {
tableRows = (
<TableRow>
<TableCell colSpan={cols.length} className="py-10 text-center text-muted-foreground text-sm">
当前分类暂无测试资产
</TableCell>
</TableRow>
);
}
return (
<Card className="flex min-h-0 flex-1 flex-col overflow-hidden py-0">
<div className="min-h-0 flex-1 overflow-auto scrollbar-thin scrollbar-track-transparent">
<Card className="flex min-h-0 flex-1 flex-col py-0">
<CardContent className="scrollbar-thin scrollbar-track-transparent min-h-0 flex-1 overflow-auto p-0">
<Table>
<TableHeader className="sticky top-0 z-10 bg-card">
<TableRow>
{cols.map((c) => <TableHead key={c}>{c}</TableHead>)}
{cols.map((column) => (
<TableHead key={column}>{column}</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{rows.length > 0 ? rows : (
<TableRow>
<TableCell colSpan={cols.length} className="py-12 text-center text-sm text-muted-foreground">
暂无数据
</TableCell>
</TableRow>
)}
</TableBody>
<TableBody>{tableRows}</TableBody>
</Table>
</div>
{total > 0 && (
<div className="flex shrink-0 items-center gap-2 border-t px-3 py-1.5 text-xs text-muted-foreground">
<Select value={String(size)} onValueChange={(v) => onSize(Number(v))}>
<SelectTrigger size="sm" className="h-7 w-24"><SelectValue /></SelectTrigger>
</CardContent>
{total > 0 ? (
<CardFooter className="gap-2 px-3 py-1.5 text-muted-foreground text-xs">
<Select
value={String(size)}
onValueChange={(value) => {
onSize(Number(value));
onPage(0);
}}
>
<SelectTrigger size="sm" className="w-24">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PAGE_SIZES.map((n) => <SelectItem key={n} value={String(n)}>{n} / 页</SelectItem>)}
<SelectGroup>
{PAGE_SIZES.map((value) => (
<SelectItem key={value} value={String(value)}>
{value} / 页
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<span className="tabular-nums">{start}–{end} / {total}</span>
{pageCount > 1 && (
<div className="flex items-center gap-2">
<Button variant="outline" size="icon" className="size-7" disabled={page <= 0} onClick={() => onPage((p) => Math.max(0, p - 1))}>
<span className="tabular-nums">
{start}–{end} / {total}
</span>
{pageCount > 1 ? (
<div className="ml-auto flex items-center gap-2">
<Button
variant="outline"
size="icon-sm"
disabled={page <= 0}
onClick={() => onPage(Math.max(0, page - 1))}
aria-label="上一页"
>
<ChevronLeftIcon />
</Button>
<span className="tabular-nums">{page + 1} / {pageCount}</span>
<Button variant="outline" size="icon" className="size-7" disabled={page + 1 >= pageCount} onClick={() => onPage((p) => Math.min(pageCount - 1, p + 1))}>
<span className="tabular-nums">
{page + 1} / {pageCount}
</span>
<Button
variant="outline"
size="icon-sm"
disabled={page + 1 >= pageCount}
onClick={() => onPage(Math.min(pageCount - 1, page + 1))}
aria-label="下一页"
>
<ChevronRightIcon />
</Button>
</div>
)}
</div>
)}
) : null}
</CardFooter>
) : null}
</Card>
);
}
const TABS: { key: string; label: string; icon: LucideIcon }[] = [
{ key: "root_domain", label: "根域名", icon: GlobeIcon },
{ key: "ip", label: "IP", icon: NetworkIcon },
{ key: "subdomain", label: "子域名", icon: GlobeIcon },
{ key: "service", label: "服务", icon: LayoutTemplateIcon },
{ key: "endpoint", label: "接口", icon: LinkIcon },
];
export function AssetsTab({ taskId }: { taskId: string }) {
const [assets, setAssets] = React.useState<Asset[]>([]);
const [loaded, setLoaded] = React.useState(false);
const [tab, setTab] = React.useState("root_domain");
const [page, setPage] = React.useState(0);
const [size, setSize] = React.useState(50);
React.useEffect(() => setPage(0), [tab, size]);
function AddTaskAssetsSheet({
onAttached,
onOpenChange,
open,
taskId,
}: {
onAttached: () => void;
onOpenChange: (open: boolean) => void;
open: boolean;
taskId: string;
}) {
const [scopeText, setScopeText] = React.useState("");
const [saving, setSaving] = React.useState(false);
const parsedScope = React.useMemo(() => parseCompanyScopeText(scopeText), [scopeText]);
React.useEffect(() => {
let alive = true;
const load = async () => {
try {
const results = await Promise.all(
TABS.map((t) => api.taskAssets(taskId, t.key)),
);
if (!alive) return;
setAssets(results.flat());
setLoaded(true);
} catch {
if (alive) setLoaded(true);
}
};
load();
const timer = setInterval(load, 10_000);
return () => { alive = false; clearInterval(timer); };
}, [taskId]);
if (!open) return;
setScopeText("");
}, [open]);
const byType = React.useMemo(() => {
const m: Record<string, Asset[]> = { root_domain: [], ip: [], subdomain: [], service: [], endpoint: [] };
for (const a of assets) {
if (a.type in m) m[a.type].push(a);
const attach = async () => {
if (parsedScope.rules.length === 0 || parsedScope.errors.length > 0) return;
setSaving(true);
try {
const result = await api.registerTaskAssetScopes(taskId, parsedScope.rules);
const assetSummary = result.assets_linked + result.assets_existing;
toast.success(`已登记 ${result.requested} 条范围,关联 ${assetSummary} 项域名/IP 资产`);
onAttached();
onOpenChange(false);
} catch (reason) {
toast.error(`新增失败:${String((reason as Error)?.message ?? reason)}`);
} finally {
setSaving(false);
}
return m;
}, [assets]);
const counts: Record<string, number> = {
root_domain: byType.root_domain.length,
ip: byType.ip.length,
subdomain: byType.subdomain.length,
service: byType.service.length,
endpoint: byType.endpoint.length,
};
const totalAll = assets.length;
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="w-full sm:max-w-xl">
<SheetHeader>
<SheetTitle>新增测试资产</SheetTitle>
<SheetDescription>
直接填写测试范围。域名和 IP 会创建或复用全局资产;CIDR、ICP 和关键词作为 Agent 范围上下文。
</SheetDescription>
</SheetHeader>
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto px-4">
<ScopeTextEditor
id="task-asset-scope"
value={scopeText}
onValueChange={setScopeText}
parsed={parsedScope}
label="测试资产与范围"
description="每行一条,自动识别域名、IP、CIDR、ICP 备案和关键词。"
/>
</div>
<SheetFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>
取消
</Button>
<Button
onClick={() => void attach()}
disabled={saving || parsedScope.rules.length === 0 || parsedScope.errors.length > 0}
>
{saving ? <Spinner data-icon="inline-start" /> : <PlusIcon data-icon="inline-start" />}
登记 {parsedScope.rules.length > 0 ? parsedScope.rules.length : ""} 条
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
if (!loaded) {
return <div className="py-20 text-center text-sm text-muted-foreground">加载中…</div>;
}
if (totalAll === 0) {
return (
<div className="py-20 text-center text-sm text-muted-foreground">
该任务暂无涉及资产
<p className="mt-1 text-xs">资产在任务执行过程中由 agent 发现并记录</p>
</div>
);
}
export function AssetsTab({ taskId }: { taskId: string }) {
const [rows, setRows] = React.useState<Asset[]>([]);
const [total, setTotal] = React.useState(0);
const [counts, setCounts] = React.useState<Record<string, number>>({});
const [loaded, setLoaded] = React.useState(false);
const [tab, setTab] = React.useState<NewAssetType>("root_domain");
const [page, setPage] = React.useState(0);
const [size, setSize] = React.useState(50);
const [refreshKey, setRefreshKey] = React.useState(0);
const [addOpen, setAddOpen] = React.useState(false);
const [removeTarget, setRemoveTarget] = React.useState<Asset | null>(null);
const [removing, setRemoving] = React.useState(false);
const cur = byType[tab] ?? [];
const slice = cur.slice(page * size, page * size + size);
React.useEffect(() => {
setPage(0);
setRows([]);
setLoaded(false);
}, []);
React.useEffect(() => {
let active = true;
const load = async () => {
try {
const [current, nextCounts] = await Promise.all([
api.taskAssets(taskId, tab, size, page * size),
api.assetCounts(taskId),
]);
if (!active) return;
setRows(current.assets);
setTotal(current.total);
setCounts(nextCounts ?? {});
} catch (reason) {
if (active) toast.error(`加载任务资产失败:${String((reason as Error)?.message ?? reason)}`);
} finally {
if (active) setLoaded(true);
}
};
void load();
const timer = setInterval(load, 10_000);
return () => {
active = false;
clearInterval(timer);
};
}, [page, refreshKey, size, tab, taskId]);
React.useEffect(() => {
const maxPage = Math.max(0, Math.ceil(total / size) - 1);
if (page > maxPage) setPage(maxPage);
}, [page, size, total]);
const refresh = React.useCallback(() => {
setLoaded(false);
setRefreshKey((current) => current + 1);
}, []);
const remove = async () => {
if (!removeTarget) return;
setRemoving(true);
try {
await api.detachTaskAsset(taskId, removeTarget.id);
toast.success(`已将 ${assetLabel(removeTarget)} 移出当前任务`);
setRemoveTarget(null);
refresh();
} catch (reason) {
toast.error(`移出失败:${String((reason as Error)?.message ?? reason)}`);
} finally {
setRemoving(false);
}
};
const removeButton = (asset: Asset) => (
<Button
variant="ghost"
size="icon-sm"
onClick={() => setRemoveTarget(asset)}
aria-label={`将资产 ${assetLabel(asset)} 移出任务`}
title="移出任务"
>
<Trash2Icon />
</Button>
);
const commonCardProps = { loaded, onPage: setPage, onSize: setSize, page, size, total };
const totalAll = TABS.reduce((sum, item) => sum + (counts[item.key] ?? 0), 0);
return (
<Tabs orientation="vertical" value={tab} onValueChange={setTab} className="items-start gap-4">
{/* left nav */}
<div className="w-36 shrink-0 rounded-lg border bg-card p-1 shadow-sm">
{TABS.map((t) => (
<button
key={t.key}
onClick={() => setTab(t.key)}
className={cn(
"flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-sm transition-colors",
tab === t.key
? "bg-accent text-accent-foreground font-medium"
: "text-muted-foreground hover:bg-accent/60 hover:text-foreground",
)}
>
<t.icon className="size-3.5 shrink-0" />
<span className="flex-1 text-left">{t.label}</span>
<span className="tabular-nums text-xs opacity-70">{counts[t.key] ?? 0}</span>
</button>
))}
<div className="flex min-h-0 flex-1 flex-col gap-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="min-w-0">
<h2 className="font-medium text-sm">测试资产</h2>
<p className="text-muted-foreground text-xs">当前任务共关联 {totalAll} 项资产</p>
</div>
<Button size="sm" onClick={() => setAddOpen(true)}>
<PlusIcon data-icon="inline-start" />
新增测试资产
</Button>
</div>
{/* right content */}
<div className="min-w-0 flex-1 flex flex-col gap-4">
<Tabs value={tab} onValueChange={(value) => setTab(value as NewAssetType)} className="min-h-0 flex-1">
<div className="overflow-x-auto overflow-y-hidden">
<TabsList className="w-max">
{TABS.map((item) => (
<TabsTrigger key={item.key} value={item.key}>
<item.icon data-icon="inline-start" />
{item.label}
<span className="text-muted-foreground tabular-nums">{counts[item.key] ?? 0}</span>
</TabsTrigger>
))}
</TabsList>
</div>
<TabsContent value="root_domain" className="mt-0 flex min-h-0 flex-1 flex-col">
<AssetCard cols={["域名", "ICP 备案"]} total={cur.length} page={page} size={size} onSize={setSize} onPage={setPage}>
{slice.map((a) => (
<TableRow key={a.id}>
<TableCell className="font-mono text-xs font-medium">{a.domain}</TableCell>
<TableCell className="text-xs">{a.icp || "—"}</TableCell>
<TabsContent value="root_domain" className="mt-2 flex min-h-0 flex-1 flex-col">
<AssetCard cols={["域名", "ICP 备案", "来源", "操作"]} {...commonCardProps}>
{rows.map((asset) => (
<TableRow key={asset.id}>
<TableCell className="font-medium font-mono text-xs">{asset.domain}</TableCell>
<TableCell className="text-xs">{asset.icp || "—"}</TableCell>
<TableCell>
<SourceCell asset={asset} />
</TableCell>
<TableCell className="w-16">{removeButton(asset)}</TableCell>
</TableRow>
))}
</AssetCard>
</TabsContent>
<TabsContent value="ip" className="mt-0 flex min-h-0 flex-1 flex-col">
<AssetCard cols={["IP", "C段", "绑定域名", "开放端口"]} total={cur.length} page={page} size={size} onSize={setSize} onPage={setPage}>
{slice.map((a) => (
<TableRow key={a.id}>
<TableCell className="font-mono text-xs font-medium">{a.ip}</TableCell>
<TableCell className="font-mono text-xs">{a.c_segment || "—"}</TableCell>
<TableCell><Chips items={a.bound_domains ?? []} mono /></TableCell>
<TabsContent value="ip" className="mt-2 flex min-h-0 flex-1 flex-col">
<AssetCard cols={["IP", "C段", "绑定域名", "开放端口", "来源", "操作"]} {...commonCardProps}>
{rows.map((asset) => (
<TableRow key={asset.id}>
<TableCell className="font-medium font-mono text-xs">{asset.ip}</TableCell>
<TableCell className="font-mono text-xs">{asset.c_segment || "—"}</TableCell>
<TableCell>
<Chips items={asset.bound_domains ?? []} mono />
</TableCell>
<TableCell>
<Chips
items={(a.open_ports ?? []).map((p) =>
p.service ? `${p.port}/${p.service}` : String(p.port),
items={(asset.open_ports ?? []).map((item) =>
item.service ? `${item.port}/${item.service}` : String(item.port),
)}
mono
/>
</TableCell>
<TableCell>
<SourceCell asset={asset} />
</TableCell>
<TableCell className="w-16">{removeButton(asset)}</TableCell>
</TableRow>
))}
</AssetCard>
</TabsContent>
<TabsContent value="subdomain" className="mt-0 flex min-h-0 flex-1 flex-col">
<AssetCard cols={["域名", "根域名", "解析类型", "解析值"]} total={cur.length} page={page} size={size} onSize={setSize} onPage={setPage}>
{slice.map((a) => (
<TableRow key={a.id}>
<TableCell className="font-mono text-xs font-medium">{a.domain}</TableCell>
<TableCell className="font-mono text-xs">{a.root_domain || "—"}</TableCell>
<TableCell className="text-xs">{a.record_type || "—"}</TableCell>
<TableCell className="max-w-xs truncate font-mono text-xs">{(Array.isArray(a.record_value) ? a.record_value.join(", ") : a.record_value) || "—"}</TableCell>
<TabsContent value="subdomain" className="mt-2 flex min-h-0 flex-1 flex-col">
<AssetCard cols={["域名", "根域名", "解析类型", "解析值", "来源", "操作"]} {...commonCardProps}>
{rows.map((asset) => (
<TableRow key={asset.id}>
<TableCell className="font-medium font-mono text-xs">{asset.domain}</TableCell>
<TableCell className="font-mono text-xs">{asset.root_domain || "—"}</TableCell>
<TableCell className="text-xs">{asset.record_type || "—"}</TableCell>
<TableCell className="max-w-xs truncate font-mono text-xs">
{(Array.isArray(asset.record_value) ? asset.record_value.join(", ") : asset.record_value) || "—"}
</TableCell>
<TableCell>
<SourceCell asset={asset} />
</TableCell>
<TableCell className="w-16">{removeButton(asset)}</TableCell>
</TableRow>
))}
</AssetCard>
</TabsContent>
<TabsContent value="service" className="mt-0 flex min-h-0 flex-1 flex-col">
<TabsContent value="app" className="mt-2 flex min-h-0 flex-1 flex-col">
<AssetCard cols={["应用", "地址", "分类", "标题", "指纹", "来源", "操作"]} {...commonCardProps}>
{rows.map((asset) => (
<TableRow key={asset.id}>
<TableCell className="max-w-48 truncate font-medium text-xs">{asset.app_name || "—"}</TableCell>
<TableCell className="max-w-xs truncate font-mono text-xs" title={asset.url}>
{asset.url || "—"}
</TableCell>
<TableCell className="text-xs">{asset.category || "—"}</TableCell>
<TableCell className="max-w-48 truncate text-xs">{asset.page_title || "—"}</TableCell>
<TableCell>
<Chips items={asset.technologies ?? []} />
</TableCell>
<TableCell>
<SourceCell asset={asset} />
</TableCell>
<TableCell className="w-16">{removeButton(asset)}</TableCell>
</TableRow>
))}
</AssetCard>
</TabsContent>
<TabsContent value="service" className="mt-2 flex min-h-0 flex-1 flex-col">
<AssetCard
cols={["地址 / 服务", "状态码", "标题", "响应长度", "指纹", "认证"]}
total={cur.length}
page={page}
size={size}
onSize={setSize}
onPage={setPage}
cols={["地址 / 服务", "状态码", "标题", "响应长度", "指纹", "认证", "来源", "操作"]}
{...commonCardProps}
>
{slice.map((a) => {
const isHttp = a.service_type === "http";
const addr = isHttp
? (a.url || "")
: (a.service_name || [a.ip || a.domain, a.port].filter(Boolean).join(":"));
{rows.map((asset) => {
const isHttp = asset.service_type === "http";
const address = isHttp
? asset.url || ""
: asset.service_name || [asset.ip || asset.domain, asset.port].filter(Boolean).join(":");
return (
<TableRow key={a.id}>
<TableCell className="max-w-xs truncate font-mono text-xs" title={addr}>{addr || "—"}</TableCell>
<TableCell>
{a.status_code != null ? (
<span className={cn("font-mono text-xs font-semibold tabular-nums", statusTone(a.status_code))}>
{a.status_code}
</span>
) : "—"}
<TableRow key={asset.id}>
<TableCell className="max-w-xs truncate font-mono text-xs" title={address}>
{address || "—"}
</TableCell>
<TableCell className="max-w-[12rem] truncate text-xs">{a.page_title || "—"}</TableCell>
<TableCell className="text-xs tabular-nums">{fmtBytes(a.content_length)}</TableCell>
<TableCell><Chips items={a.technologies ?? []} /></TableCell>
<TableCell>
{(a.auth ?? []).length === 0 ? (
<span className="text-xs text-muted-foreground">—</span>
{asset.status_code != null ? (
<span
className={cn("font-mono font-semibold text-xs tabular-nums", statusTone(asset.status_code))}
>
{asset.status_code}
</span>
) : (
(a.auth ?? []).map((authItem, i) => {
"—"
)}
</TableCell>
<TableCell className="max-w-48 truncate text-xs">{asset.page_title || "—"}</TableCell>
<TableCell className="text-xs tabular-nums">{fmtBytes(asset.content_length)}</TableCell>
<TableCell>
<Chips items={asset.technologies ?? []} />
</TableCell>
<TableCell>
{(asset.auth ?? []).length === 0 ? (
<span className="text-muted-foreground text-xs">—</span>
) : (
(asset.auth ?? []).map((authItem) => {
const item = authItem as Record<string, string>;
return (
<span key={i} className="inline-flex items-center gap-1 text-[11px]">
<span
key={`${asset.id}-${item.type ?? ""}-${item.username ?? ""}-${JSON.stringify(item)}`}
className="inline-flex items-center gap-1 text-[11px]"
>
<KeyRoundIcon className="size-3 text-muted-foreground" />
<span className="font-mono">{item.type || item.username || "认证"}</span>
</span>
@@ -323,33 +580,72 @@ export function AssetsTab({ taskId }: { taskId: string }) {
})
)}
</TableCell>
<TableCell>
<SourceCell asset={asset} />
</TableCell>
<TableCell className="w-16">{removeButton(asset)}</TableCell>
</TableRow>
);
})}
</AssetCard>
</TabsContent>
<TabsContent value="endpoint" className="mt-0 flex min-h-0 flex-1 flex-col">
<AssetCard cols={["方法", "完整地址", "参数"]} total={cur.length} page={page} size={size} onSize={setSize} onPage={setPage}>
{slice.map((a) => (
<TableRow key={a.id}>
<TableCell className="w-16"><MethodBadge method={a.method || ""} /></TableCell>
<TableCell className="max-w-sm truncate font-mono text-xs" title={a.url}>{a.url || "—"}</TableCell>
<TabsContent value="endpoint" className="mt-2 flex min-h-0 flex-1 flex-col">
<AssetCard cols={["方法", "完整地址", "参数", "来源", "操作"]} {...commonCardProps}>
{rows.map((asset) => (
<TableRow key={asset.id}>
<TableCell className="w-16">
<MethodBadge method={asset.method || ""} />
</TableCell>
<TableCell className="max-w-sm truncate font-mono text-xs" title={asset.url}>
{asset.url || "—"}
</TableCell>
<TableCell>
<Chips
items={(a.params ?? []).map((p) => {
const item = p as Record<string, string>;
return item.name ? `${item.name}(${item.location || "?"})` : "?";
items={(asset.params ?? []).map((param) => {
const item = param as Record<string, string>;
return item.name ? `${item.name}(${item.location || item.in || "?"})` : "?";
})}
mono
/>
</TableCell>
<TableCell>
<SourceCell asset={asset} />
</TableCell>
<TableCell className="w-16">{removeButton(asset)}</TableCell>
</TableRow>
))}
</AssetCard>
</TabsContent>
</Tabs>
</div>
</Tabs>
<AddTaskAssetsSheet onAttached={refresh} onOpenChange={setAddOpen} open={addOpen} taskId={taskId} />
<AlertDialog open={Boolean(removeTarget)} onOpenChange={(open) => !open && !removing && setRemoveTarget(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>移出当前任务?</AlertDialogTitle>
<AlertDialogDescription className="[overflow-wrap:anywhere]">
{removeTarget ? `将“${assetLabel(removeTarget)}”从当前任务的测试资产中移出。` : ""}
全局资产、关联流量和历史黑板锚点会继续保留。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={removing}>取消</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
disabled={removing}
onClick={(event) => {
event.preventDefault();
void remove();
}}
>
{removing ? <Spinner data-icon="inline-start" /> : <Trash2Icon data-icon="inline-start" />}
{removing ? "移出中" : "确认移出"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
+78
View File
@@ -0,0 +1,78 @@
"use client";
import * as React from "react";
import { Badge } from "@/components/ui/badge";
import { Field, FieldDescription, FieldError, FieldLabel } from "@/components/ui/field";
import { Textarea } from "@/components/ui/textarea";
import type { ParsedCompanyScopeText } from "@/lib/company-scope";
import type { CompanyScopeKind } from "@/lib/types";
const SCOPE_KIND_LABELS: Record<CompanyScopeKind, string> = {
domain: "域名",
ip: "IP",
cidr: "CIDR",
icp: "ICP",
keyword: "关键词",
};
export function ScopeTextEditor({
id,
value,
onValueChange,
parsed,
label = "资产范围",
description = "每行一条,自动识别域名、IP、CIDR、ICP 备案和企业关键词。",
}: {
id: string;
value: string;
onValueChange: (value: string) => void;
parsed: ParsedCompanyScopeText;
label?: string;
description?: string;
}) {
const counts = React.useMemo(() => {
const result = new Map<CompanyScopeKind, number>();
for (const rule of parsed.rules) result.set(rule.kind, (result.get(rule.kind) ?? 0) + 1);
return result;
}, [parsed.rules]);
return (
<Field data-invalid={parsed.errors.length > 0}>
<FieldLabel htmlFor={id}>{label}</FieldLabel>
<FieldDescription>{description}</FieldDescription>
<Textarea
id={id}
rows={8}
value={value}
aria-invalid={parsed.errors.length > 0}
placeholder={"example.com\n203.0.113.10\n198.51.100.0/24\n京ICP备12345678号-1\n企业名称关键词"}
className="min-h-36 resize-y font-mono text-sm"
onChange={(event) => onValueChange(event.target.value)}
/>
{parsed.rules.length > 0 && (
<div className="flex flex-wrap items-center gap-1.5 text-muted-foreground text-xs">
<span>已识别 {parsed.rules.length} 条</span>
{Object.entries(SCOPE_KIND_LABELS).map(([kind, kindLabel]) => {
const count = counts.get(kind as CompanyScopeKind) ?? 0;
return count > 0 ? (
<Badge key={kind} variant="secondary" className="font-mono tabular-nums">
{kindLabel} {count}
</Badge>
) : null;
})}
</div>
)}
{parsed.errors.length > 0 && (
<FieldError>
{parsed.errors.slice(0, 5).map((item) => (
<span key={`${item.line}-${item.error}`} className="block">
第 {item.line} 行:{item.error}
</span>
))}
{parsed.errors.length > 5 && <span className="block">另有 {parsed.errors.length - 5} 行错误</span>}
</FieldError>
)}
</Field>
);
}
+212
View File
@@ -0,0 +1,212 @@
import type { CompanyScopeKind, CompanyScopeRule } from "@/lib/types";
export const MAX_COMPANY_SCOPE_RULES = 256;
export const MAX_COMPANY_SCOPE_VALUE_LENGTH = 1024;
export type CompanyScopeTextIssue = {
line: number;
rule?: CompanyScopeRule;
error?: string;
};
export type ParsedCompanyScopeText = {
rules: CompanyScopeRule[];
errors: Array<{ line: number; error: string }>;
};
export const COMPANY_SCOPE_KINDS = new Set<CompanyScopeKind>(["domain", "ip", "cidr", "icp", "keyword"]);
export function isCompanyScopeKind(value: unknown): value is CompanyScopeKind {
return typeof value === "string" && COMPANY_SCOPE_KINDS.has(value as CompanyScopeKind);
}
function isIPv4(value: string): boolean {
const parts = value.split(".");
return (
parts.length === 4 && parts.every((part) => (part === "0" || /^[1-9]\d{0,2}$/.test(part)) && Number(part) <= 255)
);
}
function isIPv6(value: string): boolean {
if (!value.includes(":") || /[^0-9a-f:.]/i.test(value) || value.includes(":::")) return false;
const halves = value.split("::");
if (halves.length > 2) return false;
const countSegments = (half: string): number | null => {
if (!half) return 0;
const segments = half.split(":");
let count = 0;
for (const [index, segment] of segments.entries()) {
if (segment.includes(".")) {
if (index !== segments.length - 1 || !isIPv4(segment)) return null;
count += 2;
} else {
if (!/^[0-9a-f]{1,4}$/i.test(segment)) return null;
count++;
}
}
return count;
};
const left = countSegments(halves[0]);
const right = countSegments(halves[1] ?? "");
if (left === null || right === null) return false;
return halves.length === 2 ? left + right < 8 : left === 8;
}
function ipVersion(value: string): 4 | 6 | null {
if (isIPv4(value)) return 4;
if (isIPv6(value)) return 6;
return null;
}
function looksLikeIPAddress(value: string): boolean {
const trimmed = value.trim();
if (trimmed.includes("://") || /\s/.test(trimmed)) return false;
if ((trimmed.match(/:/g) ?? []).length >= 2) {
const parts = trimmed.split(":");
let validSegments = 0;
for (const part of parts) {
if (!part) {
if (validSegments > 0 || trimmed.startsWith("::")) return true;
continue;
}
if (part.length > 4 || !/^[0-9a-f]+$/i.test(part)) return false;
validSegments++;
if (validSegments >= 2) return true;
}
return false;
}
return trimmed.includes(".") && /^[0-9.]+$/.test(trimmed);
}
function domainHostname(value: string): string | null {
try {
let candidate = value;
if (value.startsWith("//")) candidate = `http:${value}`;
else if (!value.includes("://")) candidate = `http://${value}`;
const url = new URL(candidate);
return (
url.hostname
.replace(/^\[|\]$/g, "")
.replace(/\.$/, "")
.toLowerCase() || null
);
} catch {
return null;
}
}
export function companyScopeRuleError(rule: CompanyScopeRule): string {
const value = rule.value.trim();
if (!value) return "请填写范围值";
if (Array.from(value).length > MAX_COMPANY_SCOPE_VALUE_LENGTH) {
return `最多 ${MAX_COMPANY_SCOPE_VALUE_LENGTH} 个字符`;
}
if (rule.kind === "domain") {
if (/\s/.test(value)) return "请输入不含空格的有效域名或 URL";
const hostname = domainHostname(value);
if (!hostname || ipVersion(hostname) !== null) return "请输入有效域名或 URL";
const labels = hostname.split(".");
if (
hostname.length > 253 ||
labels.length < 2 ||
labels.some(
(label) =>
!label || label.length > 63 || label.startsWith("-") || label.endsWith("-") || !/^[a-z0-9-]+$/i.test(label),
)
) {
return "请输入有效域名或 URL";
}
}
if (rule.kind === "ip" && ipVersion(value) === null) return "请输入有效 IP";
if (rule.kind === "cidr") {
const separator = value.lastIndexOf("/");
if (separator <= 0) return "请输入 CIDR 网段";
const address = value.slice(0, separator);
const prefixText = value.slice(separator + 1);
const version = ipVersion(address);
if (version === null || !/^\d+$/.test(prefixText)) return "请输入 CIDR 网段";
const prefix = Number(prefixText);
if (version === 4 && (prefix < 16 || prefix > 32)) return "IPv4 网段前缀需为 /16 至 /32";
if (version === 6 && (prefix < 32 || prefix > 128)) return "IPv6 网段前缀需为 /32 至 /128";
}
return "";
}
export function classifyCompanyScopeLine(
raw: string,
line: number,
preservedRule?: CompanyScopeRule,
): CompanyScopeTextIssue {
const value = raw.trim();
if (Array.from(value).length > MAX_COMPANY_SCOPE_VALUE_LENGTH) {
return { line, error: `最多 ${MAX_COMPANY_SCOPE_VALUE_LENGTH} 个字符` };
}
if (preservedRule) {
const rule = { kind: preservedRule.kind, value };
const error = companyScopeRuleError(rule);
return error ? { line, error } : { line, rule };
}
const separator = value.lastIndexOf("/");
if (
separator > 0 &&
(ipVersion(value.slice(0, separator)) !== null || looksLikeIPAddress(value.slice(0, separator)))
) {
const rule: CompanyScopeRule = { kind: "cidr", value };
const error = companyScopeRuleError(rule);
return error ? { line, error } : { line, rule };
}
if (ipVersion(value) !== null) return { line, rule: { kind: "ip", value } };
if (looksLikeIPAddress(value)) return { line, error: "请输入有效 IP" };
const looksLikeDomain = value.includes("://") || (!/\s/.test(value) && value.includes("."));
if (looksLikeDomain) {
const hostname = domainHostname(value);
if (hostname && ipVersion(hostname) !== null) return { line, rule: { kind: "ip", value: hostname } };
const rule: CompanyScopeRule = { kind: "domain", value };
const error = companyScopeRuleError(rule);
return error ? { line, error } : { line, rule };
}
if (/icp|备案/i.test(value)) return { line, rule: { kind: "icp", value } };
return { line, rule: { kind: "keyword", value } };
}
export function parseCompanyScopeText(
value: string,
options: { preservedRules?: CompanyScopeRule[]; maxRules?: number } = {},
): ParsedCompanyScopeText {
const nonEmpty = value
.split(/\r?\n/)
.map((raw, index) => ({ raw, line: index + 1 }))
.filter(({ raw }) => raw.trim());
const preserved = new Map<string, CompanyScopeRule[]>();
for (const rule of options.preservedRules ?? []) {
const key = rule.value.trim();
preserved.set(key, [...(preserved.get(key) ?? []), rule]);
}
const issues = nonEmpty.map(({ raw, line }) => {
const key = raw.trim();
const queue = preserved.get(key);
const preservedRule = queue?.shift();
return classifyCompanyScopeLine(raw, line, preservedRule);
});
const maxRules = options.maxRules ?? MAX_COMPANY_SCOPE_RULES;
if (nonEmpty.length > maxRules) {
issues.push({
line: nonEmpty[Math.max(0, maxRules)]?.line ?? 1,
error: `最多允许 ${maxRules} 条范围,当前 ${nonEmpty.length} 条`,
});
}
return {
rules: issues.flatMap((item) => (item.rule ? [item.rule] : [])),
errors: issues.flatMap((item) => (item.error ? [{ line: item.line, error: item.error }] : [])),
};
}
export function normalizeCompanyScopeValue(rule: CompanyScopeRule): string {
const value = rule.value.trim();
if (rule.kind === "domain") return domainHostname(value) ?? value.toLowerCase();
if (rule.kind === "icp") return value.toLowerCase().replace(/\s/gu, "");
if (rule.kind === "keyword") return value.toLowerCase().split(/\s+/u).filter(Boolean).join(" ");
return value.toLowerCase();
}
+28
View File
@@ -0,0 +1,28 @@
import type { NewAssetType } from "@/lib/types";
const ASSET_TYPE_LABELS: Record<NewAssetType, string> = {
app: "应用",
endpoint: "接口",
ip: "IP",
root_domain: "根域名",
service: "服务",
subdomain: "子域名",
};
const TASK_ASSET_SOURCE_LABELS: Record<string, string> = {
agent: "Agent 发现",
anchor: "黑板锚点",
api: "资产 API",
legacy: "历史关联",
manual: "人工加入",
system: "系统关联",
task: "任务初始化",
};
export function taskAssetTypeLabel(type: NewAssetType): string {
return ASSET_TYPE_LABELS[type];
}
export function taskAssetSourceLabel(source: string): string {
return TASK_ASSET_SOURCE_LABELS[source] ?? source;
}