feat(assets): add editable company and task scopes
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user