Files
ARTEX/web/src/app/(main)/function/assets/page.tsx
T
Autumn-27andClaude Opus 4.8 0ef8561411 chore: ARTEX 初始化
ARTEX — AI 自主渗透测试系统(Go 后端 + Next.js 前端,单二进制内嵌)。

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-26 04:27:45 -04:00

1259 lines
49 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import * as React from "react";
import {
BuildingIcon,
GlobeIcon,
NetworkIcon,
LayoutTemplateIcon,
LinkIcon,
SearchIcon,
KeyRoundIcon,
ChevronLeftIcon,
ChevronRightIcon,
RefreshCwIcon,
Trash2Icon,
type LucideIcon,
} from "lucide-react";
import { toast } from "sonner";
import { Card } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Checkbox } from "@/components/ui/checkbox";
import { cn } from "@/lib/utils";
import { api } from "@/lib/api";
import type { Asset, Company } from "@/lib/types";
// ── DSL autocomplete ──────────────────────────────────────────────────────────
const DSL_FIELDS: { name: string; desc: string; ops: { op: string; desc: string }[] }[] = [
{ name: "domain", desc: "域名(根域名/子域名/服务域名)", ops: [{ op: "=", desc: "模糊匹配" }, { op: "==", desc: "精确匹配" }, { op: "!=", desc: "排除" }] },
{ name: "ip", desc: "IPv4/IPv6 地址", ops: [{ op: "=", desc: "模糊匹配" }, { op: "==", desc: "精确匹配" }, { op: "!=", desc: "排除" }] },
{ name: "url", desc: "完整 URL(服务/接口)", ops: [{ op: "=", desc: "模糊匹配" }, { op: "==", desc: "精确匹配" }, { op: "!=", desc: "排除" }] },
{ name: "root_domain", desc: "根域名", ops: [{ op: "=", desc: "模糊匹配" }, { op: "==", desc: "精确匹配" }, { op: "!=", desc: "排除" }] },
{ name: "page_title", desc: "页面标题(HTTP 服务)", ops: [{ op: "=", desc: "模糊匹配" }, { op: "==", desc: "精确匹配" }, { op: "!=", desc: "排除" }] },
{ name: "icp", desc: "ICP 备案号", ops: [{ op: "=", desc: "模糊匹配" }, { op: "==", desc: "精确匹配" }, { op: "!=", desc: "排除" }] },
{ name: "service_name", desc: "服务名称(非 HTTP 服务)", ops: [{ op: "=", desc: "模糊匹配" }, { op: "==", desc: "精确匹配" }, { op: "!=", desc: "排除" }] },
{ name: "app_name", desc: "应用名称", ops: [{ op: "=", desc: "模糊匹配" }, { op: "==", desc: "精确匹配" }, { op: "!=", desc: "排除" }] },
{ name: "method", desc: "HTTP 方法 GET/POST/PUT/…", ops: [{ op: "==", desc: "精确匹配" }, { op: "!=", desc: "排除" }] },
{ name: "service_type", desc: "服务类型:http | other", ops: [{ op: "==", desc: "精确匹配" }, { op: "!=", desc: "排除" }] },
{ name: "record_type", desc: "DNS 解析类型 A/CNAME/MX/…", ops: [{ op: "==", desc: "精确匹配" }, { op: "!=", desc: "排除" }] },
{ name: "technology", desc: "技术指纹(数组字段)", ops: [{ op: "=", desc: "模糊匹配" }, { op: "==", desc: "精确匹配" }, { op: "!=", desc: "排除" }] },
{ name: "port", desc: "端口号(整数)", ops: [{ op: "==", desc: "等于" }, { op: "!=", desc: "不等于" }, { op: ">", desc: "大于" }, { op: ">=", desc: "大于等于" }, { op: "<", desc: "小于" }, { op: "<=", desc: "小于等于" }] },
{ name: "status_code", desc: "HTTP 状态码(整数)", ops: [{ op: "==", desc: "等于" }, { op: "!=", desc: "不等于" }, { op: ">", desc: "大于" }, { op: ">=", desc: "大于等于" }, { op: "<", desc: "小于" }, { op: "<=", desc: "小于等于" }] },
{ name: "company_id", desc: "归属企业 ID(整数)", ops: [{ op: "==", desc: "等于" }] },
{ name: "task_id", desc: "来源任务 ID(整数)", ops: [{ op: "==", desc: "等于" }] },
];
const LOGIC_OPS = [
{ label: "AND", desc: "且(两个条件都满足)" },
{ label: "OR", desc: "或(满足其中之一)" },
];
interface DslSuggestion {
kind: "field" | "operator" | "logic";
label: string;
desc: string;
replaceStart: number;
replaceEnd: number;
insertText: string;
}
function getDslSuggestions(text: string, cursor: number): DslSuggestion[] {
const before = text.slice(0, cursor);
// Current token: non-whitespace, non-paren run ending at cursor
const tokenMatch = before.match(/([^\s()]*$)/);
const currentToken = tokenMatch?.[1] ?? "";
const tokenStart = cursor - currentToken.length;
// Token already contains field+operator → typing a value, no suggestions
if (/^[a-z_]+(==|!=|>=|<=|=|>|<)/.test(currentToken)) return [];
// Complete known field name → suggest operators for that field
const exactField = DSL_FIELDS.find((f) => f.name === currentToken.toLowerCase());
if (exactField) {
return exactField.ops.map(({ op, desc }) => ({
kind: "operator",
label: `${exactField.name}${op}`,
desc,
replaceStart: tokenStart,
replaceEnd: cursor,
insertText: `${exactField.name}${op}`,
}));
}
// Everything before the current token (trimmed)
const beforeToken = before.slice(0, tokenStart).trimEnd();
const afterExpression =
beforeToken.length > 0 &&
!/\b(AND|OR)\s*$/i.test(beforeToken) &&
!beforeToken.endsWith("(");
// Current token is a prefix of AND/OR and follows a complete expression
if (/^(a|an|and|o|or)$/i.test(currentToken) && afterExpression) {
return LOGIC_OPS.filter((l) => l.label.startsWith(currentToken.toUpperCase())).map(
({ label, desc }) => ({
kind: "logic",
label,
desc,
replaceStart: tokenStart,
replaceEnd: cursor,
insertText: `${label} `,
}),
);
}
// No current token, after a complete expression → suggest AND/OR
if (!currentToken && afterExpression) {
return LOGIC_OPS.map(({ label, desc }) => ({
kind: "logic",
label,
desc,
replaceStart: cursor,
replaceEnd: cursor,
insertText: `${label} `,
}));
}
// Default: suggest fields filtered by prefix
const prefix = currentToken.toLowerCase();
return DSL_FIELDS.filter((f) => f.name.startsWith(prefix)).map((f) => ({
kind: "field",
label: f.name,
desc: f.desc,
replaceStart: tokenStart,
replaceEnd: cursor,
insertText: f.name,
}));
}
function applyDslSuggestion(
text: string,
s: DslSuggestion,
): { text: string; cursor: number } {
const newText = text.slice(0, s.replaceStart) + s.insertText + text.slice(s.replaceEnd);
return { text: newText, cursor: s.replaceStart + s.insertText.length };
}
const KIND_STYLE: Record<string, string> = {
field: "text-blue-500 dark:text-blue-400",
operator: "text-amber-500 dark:text-amber-400",
logic: "text-emerald-500 dark:text-emerald-400",
};
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",
HEAD: "bg-purple-100 text-purple-700",
OPTIONS: "bg-slate-100 text-slate-600",
};
function MethodBadge({ method }: { method: string }) {
const m = 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>
);
}
function statusTone(code: number) {
if (code >= 500) return "text-red-500";
if (code >= 400) return "text-amber-500";
if (code >= 300) return "text-blue-500";
if (code >= 200) return "text-emerald-500";
return "text-muted-foreground";
}
const PAGE_SIZES = [25, 50, 100, 200];
const TABS: { key: string; label: string; icon: LucideIcon }[] = [
{ key: "company", label: "企业", icon: BuildingIcon },
{ 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 default function AssetsPage() {
const [rows, setRows] = React.useState<Asset[]>([]);
const [total, setTotal] = React.useState(0);
const [companies, setCompanies] = React.useState<Company[]>([]);
const [counts, setCounts] = React.useState<Record<string, number>>({});
const [tab, setTab] = React.useState("company");
const [query, setQuery] = React.useState("");
const [loaded, setLoaded] = React.useState(false);
const [loading, setLoading] = React.useState(false);
const [page, setPage] = React.useState(0);
const [size, setSize] = React.useState(50);
const [dslError, setDslError] = React.useState("");
const [refreshKey, setRefreshKey] = React.useState(0);
// asset selection & delete
const [selected, setSelected] = React.useState<Set<number>>(new Set());
const [deleteIds, setDeleteIds] = React.useState<number[]>([]);
const [deleteOpen, setDeleteOpen] = React.useState(false);
const [deleting, setDeleting] = React.useState(false);
// company delete
const [companyDeleteTarget, setCompanyDeleteTarget] = React.useState<Company | null>(null);
const [companyDeleteAssets, setCompanyDeleteAssets] = React.useState(false);
const [companyDeleting, setCompanyDeleting] = React.useState(false);
// Companies + per-type counts (tab badges) — loaded on demand, no background polling.
const loadMeta = React.useCallback(() => {
api.companies().then(setCompanies).catch(() => {});
api.assetCounts().then(setCounts).catch(() => {});
}, []);
// Manual refresh: reload counts/companies and re-fetch the current page.
const refresh = React.useCallback(() => {
loadMeta();
setRefreshKey((k) => k + 1);
setSelected(new Set());
}, [loadMeta]);
const toggleSelect = (id: number) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
};
const toggleSelectAll = (ids: number[]) => {
setSelected((prev) => {
const allSelected = ids.every((id) => prev.has(id));
const next = new Set(prev);
if (allSelected) ids.forEach((id) => next.delete(id));
else ids.forEach((id) => next.add(id));
return next;
});
};
const openDelete = (ids: number[]) => {
setDeleteIds(ids);
setDeleteOpen(true);
};
const confirmDelete = async () => {
setDeleting(true);
try {
const res = await api.deleteAssets(deleteIds);
toast.success(`已删除 ${res.deleted} 条资产`);
setSelected(new Set());
refresh();
} catch (e) {
toast.error("删除失败:" + String((e as Error)?.message ?? e));
} finally {
setDeleting(false);
setDeleteOpen(false);
}
};
const confirmDeleteCompany = async () => {
if (!companyDeleteTarget) return;
setCompanyDeleting(true);
try {
const res = await api.deleteCompany(companyDeleteTarget.id, companyDeleteAssets);
const msg = companyDeleteAssets && res.assets_deleted > 0
? `已删除企业,同时删除 ${res.assets_deleted} 条资产`
: "已删除企业";
toast.success(msg);
refresh();
} catch (e) {
toast.error("删除失败:" + String((e as Error)?.message ?? e));
} finally {
setCompanyDeleting(false);
setCompanyDeleteTarget(null);
setCompanyDeleteAssets(false);
}
};
React.useEffect(() => {
loadMeta();
}, [loadMeta]);
// Reset query + page + selection when switching tabs
React.useEffect(() => {
setQuery("");
setDslError("");
setSelected(new Set());
}, [tab]);
React.useEffect(() => setPage(0), [tab, size, query]);
const dslMode = query.trim() !== "";
// Server-side paginated page loader for the active data tab (company tab excluded).
React.useEffect(() => {
if (tab === "company") return;
const dsl = query.trim();
setLoading(true);
const offset = page * size;
const run = async () => {
try {
const r = dsl
? await api.searchAssets(dsl, tab, size, offset)
: await api.assets(tab, size, offset);
setRows(r.assets);
setTotal(r.total);
setDslError("");
} catch (e) {
setDslError(String((e as Error)?.message ?? e));
setRows([]);
setTotal(0);
} finally {
setLoading(false);
setLoaded(true);
}
};
const tid = setTimeout(run, dsl ? 400 : 0);
return () => clearTimeout(tid);
}, [tab, page, size, query, refreshKey]);
const companyById = React.useMemo(() => {
const m = new Map<number, string>();
for (const c of companies) m.set(c.id, c.name);
return m;
}, [companies]);
const companyName = (id?: number) => (id ? (companyById.get(id) ?? "") : "");
const tabCounts: Record<string, number> = { ...counts, company: companies.length };
const totalAssets = Object.values(counts).reduce((a, b) => a + b, 0);
// rows are already the current server-side page.
const tabData = (_type: string): Asset[] => rows;
const slice = <T,>(list: T[]) => list;
const searchBox = (
<TabSearchBox
query={query}
onChange={setQuery}
loading={loading}
error={dslError}
count={dslMode ? total : undefined}
/>
);
return (
<div className="flex h-[calc(100vh-6rem)] min-h-0 flex-col gap-4">
<div className="flex items-center justify-between gap-4">
<div>
<h1 className="text-xl font-semibold tracking-tight">资产</h1>
</div>
<div className="flex items-center gap-3">
<span className="text-sm text-muted-foreground">
共 <span className="tabular-nums">{totalAssets}</span> 项资产
</span>
{selected.size > 0 && (
<Button
variant="destructive"
size="sm"
onClick={() => openDelete(Array.from(selected) as number[])}
>
<Trash2Icon className="size-3.5" /> 删除已选 ({selected.size})
</Button>
)}
<Button variant="outline" size="sm" onClick={refresh} disabled={loading}>
<RefreshCwIcon className={cn("size-4", loading && "animate-spin")} /> 刷新
</Button>
<CompanyDialog onSaved={refresh} />
</div>
</div>
<Tabs value={tab} onValueChange={setTab} className="flex min-h-0 flex-1 flex-col gap-4">
<div className="overflow-x-auto overflow-y-hidden">
<TabsList variant="default">
{TABS.map((t) => (
<TabsTrigger key={t.key} value={t.key}>
<t.icon className="size-3.5" />
{t.label}
<span className="ml-1 tabular-nums text-muted-foreground">{tabCounts[t.key]}</span>
</TabsTrigger>
))}
</TabsList>
</div>
{/* 企业 */}
<TabsContent value="company" className="mt-0 flex min-h-0 flex-1 flex-col">
<Card className="flex min-h-0 flex-1 flex-col overflow-hidden py-0">
<div className="min-h-0 flex-1 overflow-auto">
<Table>
<TableHeader className="sticky top-0 z-10 bg-card">
<TableRow>
<TableHead>企业</TableHead>
<TableHead className="w-24 text-right">资产数</TableHead>
<TableHead>资产范围</TableHead>
<TableHead className="w-36 text-right">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{companies.map((c) => (
<TableRow key={c.id}>
<TableCell>
<div className="flex items-center gap-2">
<CompanyAvatar name={c.name} logo={c.logo} />
<span className="font-medium">{c.name}</span>
</div>
</TableCell>
<TableCell className="text-right tabular-nums text-sm">{c.asset_count}</TableCell>
<TableCell>
{c.scope?.length ? (
<div className="flex flex-wrap gap-1">
{c.scope.map((s, i) => (
<Badge key={i} variant="secondary" className="font-mono text-[11px]">{s.raw}</Badge>
))}
</div>
) : (
<span className="text-xs text-muted-foreground">未设置范围</span>
)}
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1.5">
<EditScopeDialog company={c} onSaved={refresh} />
<AppendScopeDialog company={c} onSaved={refresh} />
<Button
variant="ghost"
size="icon"
className="size-7 text-muted-foreground hover:text-destructive"
onClick={() => { setCompanyDeleteTarget(c); setCompanyDeleteAssets(false); }}
>
<Trash2Icon className="size-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
))}
{companies.length === 0 && (
<TableRow>
<TableCell colSpan={4} className="py-10 text-center text-sm text-muted-foreground">
还没有企业。点击右上角「新增企业」并填写资产范围,系统会自动认领命中的资产。
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</Card>
</TabsContent>
{/* 根域名 */}
<TabsContent value="root_domain" className="mt-0 flex min-h-0 flex-1 flex-col gap-2">
{searchBox}
<AssetCard
cols={["", "域名", "ICP 备案", "归属企业", ""]}
loaded={loaded}
total={total}
page={page}
size={size}
onSize={setSize}
onPage={setPage}
rows={rows}
selected={selected}
onToggleAll={(ids) => toggleSelectAll(ids)}
>
{slice(tabData("root_domain")).map((a) => (
<TableRow key={a.id} className={selected.has(a.id) ? "bg-muted/40" : undefined}>
<TableCell className="w-8 pr-0">
<Checkbox checked={selected.has(a.id)} onCheckedChange={() => toggleSelect(a.id)} />
</TableCell>
<TableCell className="font-mono text-xs font-medium">{a.domain}</TableCell>
<TableCell className="text-xs">{a.icp || "—"}</TableCell>
<TableCell className="text-xs">
{companyName(a.company_id) || <span className="text-muted-foreground">未归属</span>}
</TableCell>
<TableCell className="w-8 pl-0">
<Button
variant="ghost"
size="icon"
className="size-7 text-muted-foreground hover:text-destructive"
onClick={() => openDelete([a.id])}
>
<Trash2Icon className="size-3.5" />
</Button>
</TableCell>
</TableRow>
))}
</AssetCard>
</TabsContent>
{/* IP */}
<TabsContent value="ip" className="mt-0 flex min-h-0 flex-1 flex-col gap-2">
{searchBox}
<AssetCard
cols={["", "IP", "C段", "绑定域名", "开放端口", ""]}
loaded={loaded}
total={total}
page={page}
size={size}
onSize={setSize}
onPage={setPage}
rows={rows}
selected={selected}
onToggleAll={(ids) => toggleSelectAll(ids)}
>
{slice(tabData("ip")).map((a) => (
<TableRow key={a.id} className={selected.has(a.id) ? "bg-muted/40" : undefined}>
<TableCell className="w-8 pr-0">
<Checkbox checked={selected.has(a.id)} onCheckedChange={() => toggleSelect(a.id)} />
</TableCell>
<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>
<TableCell>
<Chips
items={(a.open_ports ?? []).map((p) =>
p.service ? `${p.port}/${p.service}` : String(p.port),
)}
mono
/>
</TableCell>
<TableCell className="w-8 pl-0">
<Button
variant="ghost"
size="icon"
className="size-7 text-muted-foreground hover:text-destructive"
onClick={() => openDelete([a.id])}
>
<Trash2Icon className="size-3.5" />
</Button>
</TableCell>
</TableRow>
))}
</AssetCard>
</TabsContent>
{/* 子域名 */}
<TabsContent value="subdomain" className="mt-0 flex min-h-0 flex-1 flex-col gap-2">
{searchBox}
<AssetCard
cols={["", "域名", "根域名", "解析类型", "解析值", ""]}
loaded={loaded}
total={total}
page={page}
size={size}
onSize={setSize}
onPage={setPage}
rows={rows}
selected={selected}
onToggleAll={(ids) => toggleSelectAll(ids)}
>
{slice(tabData("subdomain")).map((a) => (
<TableRow key={a.id} className={selected.has(a.id) ? "bg-muted/40" : undefined}>
<TableCell className="w-8 pr-0">
<Checkbox checked={selected.has(a.id)} onCheckedChange={() => toggleSelect(a.id)} />
</TableCell>
<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>
<TableCell className="w-8 pl-0">
<Button
variant="ghost"
size="icon"
className="size-7 text-muted-foreground hover:text-destructive"
onClick={() => openDelete([a.id])}
>
<Trash2Icon className="size-3.5" />
</Button>
</TableCell>
</TableRow>
))}
</AssetCard>
</TabsContent>
{/* 服务 */}
<TabsContent value="service" className="mt-0 flex min-h-0 flex-1 flex-col gap-2">
{searchBox}
<AssetCard
cols={["", "服务", "域名", "IP", "端口", "状态码", "标题", "指纹", "认证", ""]}
loaded={loaded}
total={total}
page={page}
size={size}
onSize={setSize}
onPage={setPage}
rows={rows}
selected={selected}
onToggleAll={(ids) => toggleSelectAll(ids)}
>
{slice(tabData("service")).map((a) => {
const isHttp = a.service_type === "http";
const svc = a.service_name || (isHttp ? "http" : "") || a.service_type || "";
let domainCell: React.ReactNode = "—";
if (isHttp && a.url) {
domainCell = (
<a
href={a.url}
target="_blank"
rel="noreferrer"
className="text-blue-600 hover:underline dark:text-blue-400"
>
{a.domain || a.url}
</a>
);
} else if (a.domain) {
domainCell = a.domain;
}
return (
<TableRow key={a.id} className={selected.has(a.id) ? "bg-muted/40" : undefined}>
<TableCell className="w-8 pr-0">
<Checkbox checked={selected.has(a.id)} onCheckedChange={() => toggleSelect(a.id)} />
</TableCell>
<TableCell className="text-xs">
<Badge variant={isHttp ? "default" : "secondary"} className="font-mono text-[10px]">
{svc || "—"}
</Badge>
</TableCell>
<TableCell className="max-w-[14rem] truncate font-mono text-xs" title={a.domain || a.url}>
{domainCell}
</TableCell>
<TableCell className="font-mono text-xs">{a.ip || "—"}</TableCell>
<TableCell className="font-mono text-xs tabular-nums">{a.port || "—"}</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>
) : "—"}
</TableCell>
<TableCell className="max-w-[12rem] truncate text-xs" title={a.page_title}>{a.page_title || "—"}</TableCell>
<TableCell><Chips items={a.technologies ?? []} /></TableCell>
<TableCell>
{(a.auth ?? []).length === 0 ? (
<span className="text-xs text-muted-foreground">—</span>
) : (
(a.auth ?? []).map((authItem, i) => {
const item = authItem as Record<string, string>;
const label = item.type || item.username || "认证";
return (
<span key={i} className="inline-flex items-center gap-1 text-[11px]">
<KeyRoundIcon className="size-3 text-muted-foreground" />
<span className="font-mono">{label}</span>
</span>
);
})
)}
</TableCell>
<TableCell className="w-8 pl-0">
<Button
variant="ghost"
size="icon"
className="size-7 text-muted-foreground hover:text-destructive"
onClick={() => openDelete([a.id])}
>
<Trash2Icon className="size-3.5" />
</Button>
</TableCell>
</TableRow>
);
})}
</AssetCard>
</TabsContent>
{/* 接口 */}
<TabsContent value="endpoint" className="mt-0 flex min-h-0 flex-1 flex-col gap-2">
{searchBox}
<AssetCard
cols={["", "方法", "完整地址", "参数", ""]}
loaded={loaded}
total={total}
page={page}
size={size}
onSize={setSize}
onPage={setPage}
rows={rows}
selected={selected}
onToggleAll={(ids) => toggleSelectAll(ids)}
>
{slice(tabData("endpoint")).map((a) => (
<TableRow key={a.id} className={selected.has(a.id) ? "bg-muted/40" : undefined}>
<TableCell className="w-8 pr-0">
<Checkbox checked={selected.has(a.id)} onCheckedChange={() => toggleSelect(a.id)} />
</TableCell>
<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>
<TableCell>
<Chips
items={(a.params ?? []).map((p) => {
const item = p as Record<string, string>;
return item.name ? `${item.name}(${item.location || "?"})` : "?";
})}
mono
/>
</TableCell>
<TableCell className="w-8 pl-0">
<Button
variant="ghost"
size="icon"
className="size-7 text-muted-foreground hover:text-destructive"
onClick={() => openDelete([a.id])}
>
<Trash2Icon className="size-3.5" />
</Button>
</TableCell>
</TableRow>
))}
</AssetCard>
</TabsContent>
</Tabs>
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认删除</AlertDialogTitle>
<AlertDialogDescription>
将永久删除 <span className="font-semibold tabular-nums">{deleteIds.length}</span> 条资产记录,此操作不可撤销。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleting}>取消</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => { e.preventDefault(); confirmDelete(); }}
disabled={deleting}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{deleting ? "删除中…" : "确认删除"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog open={!!companyDeleteTarget} onOpenChange={(o) => { if (!o) { setCompanyDeleteTarget(null); setCompanyDeleteAssets(false); } }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>删除企业 · {companyDeleteTarget?.name}</AlertDialogTitle>
<AlertDialogDescription asChild>
<div className="space-y-3">
<p>此操作将永久删除该企业及其资产范围配置,不可撤销。</p>
<label className="flex cursor-pointer items-center gap-2.5 rounded-md border p-3 hover:bg-muted/50">
<Checkbox
id="delete-assets-opt"
checked={companyDeleteAssets}
onCheckedChange={(v) => setCompanyDeleteAssets(!!v)}
/>
<span className="text-sm leading-snug">
同时删除该企业下的所有资产
<span className="block text-xs text-muted-foreground">
不勾选则保留资产,仅取消归属关系
</span>
</span>
</label>
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={companyDeleting}>取消</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => { e.preventDefault(); confirmDeleteCompany(); }}
disabled={companyDeleting}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{companyDeleting ? "删除中…" : "确认删除"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
function TabSearchBox({
query,
onChange,
loading,
error,
count,
}: {
query: string;
onChange: (v: string) => void;
loading: boolean;
error: string;
count?: number;
}) {
const inputRef = React.useRef<HTMLInputElement>(null);
const [suggestions, setSuggestions] = React.useState<DslSuggestion[]>([]);
const [selIdx, setSelIdx] = React.useState(0);
const [open, setOpen] = React.useState(false);
const refresh = React.useCallback((val: string, pos: number) => {
const suggs = getDslSuggestions(val, pos);
setSuggestions(suggs);
setSelIdx(0);
setOpen(suggs.length > 0);
}, []);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const val = e.target.value;
onChange(val);
refresh(val, e.target.selectionStart ?? val.length);
};
const apply = React.useCallback(
(s: DslSuggestion) => {
const cursor = inputRef.current?.selectionStart ?? query.length;
// use cursor for logic-kind (insert at cursor), replaceStart/End for others
const adjusted: DslSuggestion =
s.kind === "logic" && !query.slice(s.replaceStart, s.replaceEnd)
? { ...s, replaceStart: cursor, replaceEnd: cursor }
: s;
const { text: newText, cursor: newCursor } = applyDslSuggestion(query, adjusted);
onChange(newText);
requestAnimationFrame(() => {
if (!inputRef.current) return;
inputRef.current.setSelectionRange(newCursor, newCursor);
inputRef.current.focus();
refresh(newText, newCursor);
});
},
[query, onChange, refresh],
);
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (!open || suggestions.length === 0) return;
if (e.key === "ArrowDown") {
e.preventDefault();
setSelIdx((i) => Math.min(i + 1, suggestions.length - 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setSelIdx((i) => Math.max(i - 1, 0));
} else if (e.key === "Tab" || e.key === "Enter") {
const s = suggestions[selIdx];
if (s) { e.preventDefault(); apply(s); }
} else if (e.key === "Escape") {
setOpen(false);
}
};
const cursorPos = () =>
inputRef.current?.selectionStart ?? query.length;
return (
<div className="flex flex-col gap-1">
<div className="relative max-w-lg">
<SearchIcon className="pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
ref={inputRef}
placeholder="DSL 搜索:domain=example AND status_code>=400"
value={query}
onChange={handleChange}
onKeyDown={handleKeyDown}
onFocus={() => refresh(query, cursorPos())}
onClick={() => refresh(query, cursorPos())}
onBlur={() => setTimeout(() => setOpen(false), 120)}
className="h-8 pl-8 font-mono text-xs"
/>
{open && suggestions.length > 0 && (
<div className="absolute top-full left-0 z-50 mt-1 w-max min-w-full max-w-sm rounded-md border bg-popover py-1 shadow-md">
{suggestions.map((s, i) => (
<div
key={i}
className={cn(
"flex cursor-pointer items-center gap-3 px-3 py-1.5",
i === selIdx ? "bg-accent" : "hover:bg-accent/50",
)}
onMouseEnter={() => setSelIdx(i)}
onMouseDown={(e) => { e.preventDefault(); apply(s); }}
>
<span className={cn("shrink-0 font-mono text-xs font-semibold", KIND_STYLE[s.kind])}>
{s.label}
</span>
<span className="text-xs text-muted-foreground">{s.desc}</span>
</div>
))}
</div>
)}
</div>
{query.trim() && !open && (
<p className="pl-1 text-[11px] text-muted-foreground">
{loading
? "搜索中…"
: error
? <span className="text-destructive">{error}</span>
: `找到 ${count ?? 0} 条`}
</p>
)}
</div>
);
}
function AssetCard({
cols,
loaded,
total,
page,
size,
onSize,
onPage,
rows: dataRows,
selected,
onToggleAll,
children,
}: {
cols: string[];
loaded: boolean;
total: number;
page: number;
size: number;
onSize: React.Dispatch<React.SetStateAction<number>>;
onPage: React.Dispatch<React.SetStateAction<number>>;
rows?: Asset[];
selected?: Set<number>;
onToggleAll?: (ids: number[]) => void;
children: React.ReactNode;
}) {
const childRows = 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 + childRows.length;
const pageIds = dataRows?.map((r) => r.id) ?? [];
const allSelected = pageIds.length > 0 && selected != null && pageIds.every((id) => selected.has(id));
const someSelected = selected != null && pageIds.some((id) => selected.has(id));
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">
<Table>
<TableHeader className="sticky top-0 z-10 bg-card">
<TableRow>
{cols.map((c, i) =>
c === "" && i === 0 && onToggleAll ? (
<TableHead key={i} className="w-8 pr-0">
<Checkbox
checked={allSelected ? true : someSelected ? "indeterminate" : false}
onCheckedChange={() => onToggleAll(pageIds)}
/>
</TableHead>
) : (
<TableHead key={c + i}>{c}</TableHead>
)
)}
</TableRow>
</TableHeader>
<TableBody>
{childRows.length > 0 ? childRows : (
<TableRow>
<TableCell colSpan={cols.length} className="py-12 text-center text-sm text-muted-foreground">
{loaded ? "暂无数据。" : "加载中…"}
</TableCell>
</TableRow>
)}
</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>
<SelectContent>
{PAGE_SIZES.map((n) => <SelectItem key={n} value={String(n)}>{n} / 页</SelectItem>)}
</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))}>
<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))}>
<ChevronRightIcon />
</Button>
</div>
)}
</div>
)}
</Card>
);
}
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>;
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>
))}
</div>
);
}
function CompanyAvatar({ name, logo }: { name: string; logo?: string }) {
const initial = (name.trim()[0] ?? "?").toUpperCase();
return (
<Avatar className="size-7 shrink-0">
{logo ? <AvatarImage src={logo} alt={name} /> : null}
<AvatarFallback className="text-xs">{initial}</AvatarFallback>
</Avatar>
);
}
// 新增企业弹窗
function CompanyDialog({ onSaved }: { onSaved: () => void }) {
const [open, setOpen] = React.useState(false);
const [name, setName] = React.useState("");
const [logo, setLogo] = React.useState("");
const [scope, setScope] = React.useState("");
const [busy, setBusy] = React.useState(false);
React.useEffect(() => {
if (!open) return;
setName("");
setLogo("");
setScope("");
}, [open]);
const submit = async () => {
if (!name.trim()) { toast.error("请填写企业名称"); return; }
setBusy(true);
try {
const res = await api.createCompany(name.trim(), logo.trim(), scope);
const added = res.scope_added ?? 0;
const invalid = res.scope_invalid ?? 0;
if (invalid > 0) toast.warning(`已创建企业,添加 ${added} 条范围;${invalid} 行无效`);
else toast.success(`已创建企业,添加 ${added} 条范围`);
setOpen(false);
onSaved();
} catch (e) {
const msg = String((e as Error)?.message ?? e);
if (/:\s*409$/.test(msg)) toast.error("企业已存在,请换个名称");
else toast.error("保存失败:" + msg);
} finally {
setBusy(false);
}
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button size="sm"><BuildingIcon className="size-3.5" /> 新增企业</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>新增企业</DialogTitle>
<DialogDescription>
资产范围是归属的唯一来源:命中范围的资产(现有 + 未来)会被自动归到该企业。一行一条:根域名(含全部子域)、IP、或 CIDR 网段。
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-2">
<div className="grid gap-1.5">
<Label htmlFor="cn-name">企业名称</Label>
<Input id="cn-name" placeholder="如 Acme Corp(名称唯一)" value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div className="grid gap-1.5">
<Label htmlFor="cn-logo">企业图标 URL(可选)</Label>
<div className="flex items-center gap-2">
<Input
id="cn-logo"
placeholder="https://…/logo.png,留空则用名称首字母"
value={logo}
onChange={(e) => setLogo(e.target.value)}
/>
<CompanyAvatar name={name || "?"} logo={logo.trim() || undefined} />
</div>
</div>
<div className="grid gap-1.5">
<Label htmlFor="cn-scope">资产范围(一行一条)</Label>
<Textarea
id="cn-scope"
rows={5}
className="font-mono text-xs"
placeholder={"example.com\n203.0.113.10\n198.51.100.0/24"}
value={scope}
onChange={(e) => setScope(e.target.value)}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)} disabled={busy}>取消</Button>
<Button onClick={submit} disabled={busy || !name.trim()}>{busy ? "保存中…" : "保存"}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// 编辑(覆盖)资产范围弹窗
function EditScopeDialog({ company, onSaved }: { company: Company; onSaved: () => void }) {
const [open, setOpen] = React.useState(false);
const [scope, setScope] = React.useState("");
const [reason, setReason] = React.useState("");
const [busy, setBusy] = React.useState(false);
React.useEffect(() => {
if (!open) return;
setScope(company.scope?.length ? company.scope.map((s) => s.raw).join("\n") : "");
setReason("");
}, [open, company]);
const submit = async () => {
setBusy(true);
try {
const res = await api.updateCompanyScope(company.id, scope, reason);
const errCount = res.invalid ?? 0;
if (errCount > 0) toast.warning(`已保存;${errCount} 行无效`);
else toast.success(`范围已更新,共 ${res.added} 条`);
setOpen(false);
onSaved();
} catch (e) {
toast.error("保存失败:" + String((e as Error)?.message ?? e));
} finally {
setBusy(false);
}
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm" className="h-7">编辑</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>编辑资产范围 · {company.name}</DialogTitle>
<DialogDescription>
编辑后将替换该企业的全部现有范围。一行一条:根域名、IP、或 CIDR 网段。
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-2">
<div className="grid gap-1.5">
<Label htmlFor="es-scope">资产范围(一行一条)</Label>
<Textarea
id="es-scope"
rows={6}
className="font-mono text-xs"
placeholder={"example.com\n203.0.113.10\n198.51.100.0/24"}
value={scope}
onChange={(e) => setScope(e.target.value)}
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="es-reason">归属依据(可选)</Label>
<Input id="es-reason" placeholder="如 证书 / whois / ASN 佐证" value={reason} onChange={(e) => setReason(e.target.value)} />
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)} disabled={busy}>取消</Button>
<Button onClick={submit} disabled={busy}>{busy ? "保存中…" : "覆盖保存"}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// 追加资产范围弹窗
function AppendScopeDialog({ company, onSaved }: { company: Company; onSaved: () => void }) {
const [open, setOpen] = React.useState(false);
const [scope, setScope] = React.useState("");
const [reason, setReason] = React.useState("");
const [busy, setBusy] = React.useState(false);
React.useEffect(() => {
if (!open) return;
setScope("");
setReason("");
}, [open]);
const submit = async () => {
if (!scope.trim()) { toast.error("请填写要追加的范围"); return; }
setBusy(true);
try {
const res = await api.addCompanyScope(company.id, scope, reason);
const errCount = res.invalid ?? 0;
if (errCount > 0) toast.warning(`已保存;${errCount} 行无效`);
else toast.success(`已追加 ${res.added} 条范围`);
setOpen(false);
onSaved();
} catch (e) {
toast.error("保存失败:" + String((e as Error)?.message ?? e));
} finally {
setBusy(false);
}
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm" className="h-7">追加</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>追加资产范围 · {company.name}</DialogTitle>
<DialogDescription>
新填写的范围会追加到现有范围,不影响已有条目。一行一条:根域名、IP、或 CIDR 网段。
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-2">
<div className="grid gap-1.5">
<Label htmlFor="as-scope">新增范围(一行一条)</Label>
<Textarea
id="as-scope"
rows={5}
className="font-mono text-xs"
placeholder={"example.com\n203.0.113.10\n198.51.100.0/24"}
value={scope}
onChange={(e) => setScope(e.target.value)}
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="as-reason">归属依据(可选)</Label>
<Input id="as-reason" placeholder="如 证书 / whois / ASN 佐证" value={reason} onChange={(e) => setReason(e.target.value)} />
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)} disabled={busy}>取消</Button>
<Button onClick={submit} disabled={busy || !scope.trim()}>{busy ? "保存中…" : "追加"}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}