feat(assets): 任务测试资产支持 DSL 搜索

任务详情「测试资产」接口/服务多时不好找。复用资产管理页的 DSL
搜索(字段自动补全 + 分页),搜索始终限定在当前任务范围内。

- 抽出共享组件 asset-dsl-search.tsx,资产页与任务页复用,UI/交互一致
- QueryDSL/CountDSL 增加 taskID 参数,taskID>0 时强制 AND $N = ANY(task_ids)
  并补 task 来源信息;即便 DSL 内写 task_id 也无法越出当前任务
- 任务资产 tab 接入搜索框:有 DSL 走 searchTaskAssets,否则走 taskAssets

Closes #107
This commit is contained in:
Autumn-27 committed 2026-09-11 11:59:46 -04:00
1 parent 1f258837f5
commit 557317491b
8 files changed
+491 -393

No files matched your search

+1 -1
View File
@@ -507,7 +507,7 @@ func (t *ToolSet) listAssets() actool.CoreTool {
case len(a.IDs) > 0:
assets, err = t.as.GetByIDs(a.IDs)
case a.DSL != "":
assets, err = t.as.QueryDSL(a.DSL, a.Type, a.Limit, a.Offset)
assets, err = t.as.QueryDSL(a.DSL, a.Type, 0, a.Limit, a.Offset)
default:
return actool.Errorf("未传 id/ids 时 dsl 不能为空:不允许无条件查询全部资产,请提供查询条件"), nil
}
+23 -3
View File
@@ -471,7 +471,8 @@ func ValidateDSL(dsl string) error {
// CountDSL returns the total number of assets matching a DSL expression (and optional
// type), for server-side pagination — same WHERE as QueryDSL, without LIMIT/OFFSET.
func (s *AssetStore) CountDSL(dsl, typ string) (int, error) {
// taskID > 0 scopes the count to assets attached to that task.
func (s *AssetStore) CountDSL(dsl, typ string, taskID int64) (int, error) {
node, err := ParseDSL(dsl)
if err != nil {
return 0, err
@@ -484,6 +485,10 @@ func (s *AssetStore) CountDSL(dsl, typ string) (int, error) {
args = append(args, typ)
where += fmt.Sprintf(" AND type = $%d", len(args))
}
if taskID > 0 {
args = append(args, taskID)
where += fmt.Sprintf(" AND $%d = ANY(task_ids)", len(args))
}
var n int
err = s.db.QueryRow("SELECT count(*) FROM assets WHERE "+where, args...).Scan(&n)
return n, err
@@ -491,7 +496,9 @@ func (s *AssetStore) CountDSL(dsl, typ string) (int, error) {
// QueryDSL executes a DSL query string against the asset store.
// typ is an optional asset type filter applied independently of the DSL expression.
func (s *AssetStore) QueryDSL(dsl, typ string, limit, offset int) ([]*Asset, error) {
// taskID > 0 scopes results to assets attached to that task and hydrates each
// row's per-task source metadata (as QueryByTask does).
func (s *AssetStore) QueryDSL(dsl, typ string, taskID int64, limit, offset int) ([]*Asset, error) {
if limit <= 0 {
limit = 50
}
@@ -510,6 +517,10 @@ func (s *AssetStore) QueryDSL(dsl, typ string, limit, offset int) ([]*Asset, err
args = append(args, typ)
where += fmt.Sprintf(" AND type = $%d", len(args))
}
if taskID > 0 {
args = append(args, taskID)
where += fmt.Sprintf(" AND $%d = ANY(task_ids)", len(args))
}
args = append(args, limit, offset)
q := assetSelectCols + " WHERE " + where +
fmt.Sprintf(" ORDER BY last_seen DESC, id DESC LIMIT $%d OFFSET $%d", len(args)-1, len(args))
@@ -518,5 +529,14 @@ func (s *AssetStore) QueryDSL(dsl, typ string, limit, offset int) ([]*Asset, err
return nil, err
}
defer rows.Close()
return scanAssets(rows)
assets, err := scanAssets(rows)
if err != nil {
return nil, err
}
if taskID > 0 {
if err := s.hydrateTaskAssetSources(taskID, assets); err != nil {
return nil, err
}
}
return assets, nil
}
+1 -1
View File
@@ -943,7 +943,7 @@ func TestAssetPaginationUsesStableIDTieBreaker(t *testing.T) {
t.Fatalf("task pagination order=%v want=%v", got, want)
}
if got := collect(func(limit, offset int) ([]*Asset, error) {
return assets.QueryDSL(marker, "root_domain", limit, offset)
return assets.QueryDSL(marker, "root_domain", 0, limit, offset)
}); !slices.Equal(got, want) {
t.Fatalf("DSL pagination order=%v want=%v", got, want)
}
+5 -2
View File
@@ -346,9 +346,12 @@ func (s *Server) listAssets(w http.ResponseWriter, r *http.Request) {
writeErr(w, http.StatusBadRequest, err.Error())
return
}
total, err = as.CountDSL(dsl, typ)
// task_id scopes the DSL search to a task's assets (the task detail
// "测试资产" search); 0 means the global asset view.
taskID, _ := strconv.ParseInt(q.Get("task_id"), 10, 64)
total, err = as.CountDSL(dsl, typ, taskID)
if err == nil && offset < total {
assets, err = as.QueryDSL(dsl, typ, limit, offset)
assets, err = as.QueryDSL(dsl, typ, taskID, limit, offset)
}
} else {
companyID, _ := strconv.ParseInt(q.Get("company_id"), 10, 64)
+2 -372
View File
@@ -13,12 +13,12 @@ import {
type LucideIcon,
NetworkIcon,
RefreshCwIcon,
SearchIcon,
SmartphoneIcon,
Trash2Icon,
} from "lucide-react";
import { toast } from "sonner";
import { AssetDslSearch } from "@/components/asset-dsl-search";
import { ScopeTextEditor } from "@/components/scope-text-editor";
import {
AlertDialog,
@@ -63,257 +63,6 @@ import { parseCompanyScopeText } from "@/lib/company-scope";
import type { Asset, Company, CompanyScopeRule } from "@/lib/types";
import { cn } from "@/lib/utils";
// ── 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: "bundle_id",
desc: "应用 Bundle ID",
ops: [
{ op: "=", desc: "模糊匹配" },
{ op: "==", desc: "精确匹配" },
{ op: "!=", desc: "排除" },
],
},
{
name: "category",
desc: "应用分类",
ops: [
{ op: "=", desc: "模糊匹配" },
{ op: "==", desc: "精确匹配" },
{ op: "!=", desc: "排除" },
],
},
{
name: "app_icp",
desc: "应用 ICP 备案",
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",
@@ -554,7 +303,7 @@ export default function AssetsPage() {
const slice = <T,>(list: T[]) => list;
const searchBox = (
<TabSearchBox
<AssetDslSearch
query={query}
onChange={setQuery}
loading={loading}
@@ -1061,125 +810,6 @@ export default function AssetsPage() {
);
}
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) => (
<button
type="button"
key={i}
className={cn(
"flex w-full cursor-pointer items-center gap-3 px-3 py-1.5 text-left",
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>
</button>
))}
</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,
@@ -17,6 +17,7 @@ import {
} from "lucide-react";
import { toast } from "sonner";
import { AssetDslSearch } from "@/components/asset-dsl-search";
import { ScopeTextEditor } from "@/components/scope-text-editor";
import {
AlertDialog,
@@ -346,10 +347,15 @@ export function AssetsTab({ taskId }: { taskId: string }) {
const [tab, setTab] = React.useState<NewAssetType>("root_domain");
const [page, setPage] = React.useState(0);
const [size, setSize] = React.useState(50);
const [query, setQuery] = React.useState("");
const [loading, setLoading] = React.useState(false);
const [dslError, setDslError] = React.useState("");
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 assetsRequestRef = React.useRef(0);
const dslMode = query.trim() !== "";
React.useEffect(() => {
setPage(0);
@@ -357,31 +363,65 @@ export function AssetsTab({ taskId }: { taskId: string }) {
setLoaded(false);
}, []);
// Switching type tabs resets the search (DSL fields differ per type; matches
// the global asset view).
// biome-ignore lint/correctness/useExhaustiveDependencies: tab changes intentionally reset tab-local controls.
React.useEffect(() => {
setQuery("");
setDslError("");
}, [tab]);
// Query / tab / page-size changes restart server pagination.
// biome-ignore lint/correctness/useExhaustiveDependencies: these controls intentionally reset server pagination.
React.useEffect(() => setPage(0), [tab, size, query]);
// biome-ignore lint/correctness/useExhaustiveDependencies: refreshKey is an explicit manual-reload trigger.
React.useEffect(() => {
let active = true;
const dsl = query.trim();
const request = ++assetsRequestRef.current;
const load = async () => {
setLoading(true);
try {
// The search always stays within this task's assets: taskAssets and
// searchTaskAssets both pin task_id server-side.
const [current, nextCounts] = await Promise.all([
api.taskAssets(taskId, tab, size, page * size),
dsl
? api.searchTaskAssets(taskId, dsl, tab, size, page * size)
: api.taskAssets(taskId, tab, size, page * size),
api.assetCounts(taskId),
]);
if (!active) return;
if (!active || assetsRequestRef.current !== request) return;
setRows(current.assets);
setTotal(current.total);
setCounts(nextCounts ?? {});
setDslError("");
} catch (reason) {
if (active) toast.error(`加载任务资产失败:${String((reason as Error)?.message ?? reason)}`);
if (!active || assetsRequestRef.current !== request) return;
const message = String((reason as Error)?.message ?? reason);
if (dsl) {
setDslError(message);
setRows([]);
setTotal(0);
} else {
toast.error(`加载任务资产失败:${message}`);
}
} finally {
if (active) setLoaded(true);
if (active && assetsRequestRef.current === request) {
setLoading(false);
setLoaded(true);
}
}
};
void load();
const timer = setInterval(load, 10_000);
// Debounce DSL keystrokes; plain (re)loads and polling run immediately.
const debounce = setTimeout(() => void load(), dsl ? 400 : 0);
const timer = setInterval(() => void load(), 10_000);
return () => {
active = false;
clearTimeout(debounce);
clearInterval(timer);
};
}, [page, refreshKey, size, tab, taskId]);
}, [page, query, refreshKey, size, tab, taskId]);
React.useEffect(() => {
const maxPage = Math.max(0, Math.ceil(total / size) - 1);
@@ -423,6 +463,16 @@ export function AssetsTab({ taskId }: { taskId: string }) {
const commonCardProps = { loaded, onPage: setPage, onSize: setSize, page, size, total };
const totalAll = TABS.reduce((sum, item) => sum + (counts[item.key] ?? 0), 0);
const searchBox = (
<AssetDslSearch
query={query}
onChange={setQuery}
loading={loading}
error={dslError}
count={dslMode ? total : undefined}
/>
);
return (
<div className="flex min-h-0 flex-1 flex-col gap-4">
<div className="flex flex-wrap items-center justify-between gap-3">
@@ -436,7 +486,11 @@ export function AssetsTab({ taskId }: { taskId: string }) {
</Button>
</div>
<Tabs value={tab} onValueChange={(value) => setTab(value as NewAssetType)} className="min-h-0 flex-1">
<Tabs
value={tab}
onValueChange={(value) => setTab(value as NewAssetType)}
className="flex min-h-0 flex-1 flex-col gap-2"
>
<div className="overflow-x-auto overflow-y-hidden">
<TabsList className="w-max">
{TABS.map((item) => (
@@ -449,7 +503,9 @@ export function AssetsTab({ taskId }: { taskId: string }) {
</TabsList>
</div>
<TabsContent value="root_domain" className="mt-2 flex min-h-0 flex-1 flex-col">
{searchBox}
<TabsContent value="root_domain" className="mt-0 flex min-h-0 flex-1 flex-col">
<AssetCard cols={["域名", "ICP 备案", "来源", "操作"]} {...commonCardProps}>
{rows.map((asset) => (
<TableRow key={asset.id}>
@@ -464,7 +520,7 @@ export function AssetsTab({ taskId }: { taskId: string }) {
</AssetCard>
</TabsContent>
<TabsContent value="ip" className="mt-2 flex min-h-0 flex-1 flex-col">
<TabsContent value="ip" className="mt-0 flex min-h-0 flex-1 flex-col">
<AssetCard cols={["IP", "C段", "绑定域名", "开放端口", "来源", "操作"]} {...commonCardProps}>
{rows.map((asset) => (
<TableRow key={asset.id}>
@@ -490,7 +546,7 @@ export function AssetsTab({ taskId }: { taskId: string }) {
</AssetCard>
</TabsContent>
<TabsContent value="subdomain" className="mt-2 flex min-h-0 flex-1 flex-col">
<TabsContent value="subdomain" className="mt-0 flex min-h-0 flex-1 flex-col">
<AssetCard cols={["域名", "根域名", "解析类型", "解析值", "来源", "操作"]} {...commonCardProps}>
{rows.map((asset) => (
<TableRow key={asset.id}>
@@ -509,7 +565,7 @@ export function AssetsTab({ taskId }: { taskId: string }) {
</AssetCard>
</TabsContent>
<TabsContent value="app" className="mt-2 flex min-h-0 flex-1 flex-col">
<TabsContent value="app" className="mt-0 flex min-h-0 flex-1 flex-col">
<AssetCard cols={["应用", "地址", "分类", "标题", "指纹", "来源", "操作"]} {...commonCardProps}>
{rows.map((asset) => (
<TableRow key={asset.id}>
@@ -531,7 +587,7 @@ export function AssetsTab({ taskId }: { taskId: string }) {
</AssetCard>
</TabsContent>
<TabsContent value="service" className="mt-2 flex min-h-0 flex-1 flex-col">
<TabsContent value="service" className="mt-0 flex min-h-0 flex-1 flex-col">
<AssetCard
cols={["地址 / 服务", "状态码", "标题", "响应长度", "指纹", "认证", "来源", "操作"]}
{...commonCardProps}
@@ -590,7 +646,7 @@ export function AssetsTab({ taskId }: { taskId: string }) {
</AssetCard>
</TabsContent>
<TabsContent value="endpoint" className="mt-2 flex min-h-0 flex-1 flex-col">
<TabsContent value="endpoint" className="mt-0 flex min-h-0 flex-1 flex-col">
<AssetCard cols={["方法", "完整地址", "参数", "来源", "操作"]} {...commonCardProps}>
{rows.map((asset) => (
<TableRow key={asset.id}>
+383
View File
@@ -0,0 +1,383 @@
"use client";
import * as React from "react";
import { SearchIcon } from "lucide-react";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
// ── DSL autocomplete ──────────────────────────────────────────────────────────
// Shared by the global asset view (/function/assets) and the per-task 测试资产
// search, so both search boxes behave and look identical.
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: "bundle_id",
desc: "应用 Bundle ID",
ops: [
{ op: "=", desc: "模糊匹配" },
{ op: "==", desc: "精确匹配" },
{ op: "!=", desc: "排除" },
],
},
{
name: "category",
desc: "应用分类",
ops: [
{ op: "=", desc: "模糊匹配" },
{ op: "==", desc: "精确匹配" },
{ op: "!=", desc: "排除" },
],
},
{
name: "app_icp",
desc: "应用 ICP 备案",
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",
};
// AssetDslSearch is the shared DSL search box: a monospace input with a
// field/operator/logic autocomplete popover and a status line ("找到 N 条" /
// error / loading). Used by both the global asset view and the per-task view.
export function AssetDslSearch({
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) => (
<button
type="button"
key={i}
className={cn(
"flex w-full cursor-pointer items-center gap-3 px-3 py-1.5 text-left",
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>
</button>
))}
</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>
);
}
+6
View File
@@ -439,6 +439,12 @@ export const api = {
get<{ count: number; total: number; assets: Asset[] }>(
`/assets?task_id=${encodeURIComponent(taskId)}&type=${encodeURIComponent(type)}&limit=${limit}&offset=${offset}`,
).then((r) => ({ assets: r?.assets ?? [], total: r?.total ?? r?.count ?? 0 })),
// DSL search scoped to a task — the backend forces the task_id filter, so it
// always stays within that task's assets (same DSL grammar as `searchAssets`).
searchTaskAssets: (taskId: string, dsl: string, type = "", limit = 50, offset = 0) =>
get<{ count: number; total: number; assets: Asset[] }>(
`/assets?task_id=${encodeURIComponent(taskId)}&dsl=${encodeURIComponent(dsl)}&type=${encodeURIComponent(type)}&limit=${limit}&offset=${offset}`,
).then((r) => ({ assets: r?.assets ?? [], total: r?.total ?? r?.count ?? 0 })),
attachTaskAssets: (taskId: string, assetIds: number[], sourceSummary: string) =>
post<TaskAssetMutation>(`/tasks/${taskId}/assets`, {
asset_ids: assetIds,