diff --git a/agent/tools_insert.go b/agent/tools_insert.go index fd1a210..91fae61 100644 --- a/agent/tools_insert.go +++ b/agent/tools_insert.go @@ -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 } diff --git a/db/asset_dsl.go b/db/asset_dsl.go index dbe8683..59717ec 100644 --- a/db/asset_dsl.go +++ b/db/asset_dsl.go @@ -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 } diff --git a/db/assets_test.go b/db/assets_test.go index 9bebb97..b942f89 100644 --- a/db/assets_test.go +++ b/db/assets_test.go @@ -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) } diff --git a/server/assets.go b/server/assets.go index 17d2b5a..548783f 100644 --- a/server/assets.go +++ b/server/assets.go @@ -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) diff --git a/web/src/app/(main)/function/assets/page.tsx b/web/src/app/(main)/function/assets/page.tsx index 697543d..e312335 100644 --- a/web/src/app/(main)/function/assets/page.tsx +++ b/web/src/app/(main)/function/assets/page.tsx @@ -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 = { - 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 = { GET: "bg-emerald-100 text-emerald-700", POST: "bg-blue-100 text-blue-700", @@ -554,7 +303,7 @@ export default function AssetsPage() { const slice = (list: T[]) => list; const searchBox = ( - void; - loading: boolean; - error: string; - count?: number; -}) { - const inputRef = React.useRef(null); - const [suggestions, setSuggestions] = React.useState([]); - 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) => { - 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) => { - 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 ( -
-
- - 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 && ( -
- {suggestions.map((s, i) => ( - - ))} -
- )} -
- {query.trim() && !open && ( -

- {loading ? "搜索中…" : error ? {error} : `找到 ${count ?? 0} 条`} -

- )} -
- ); -} - function AssetCard({ cols, loaded, diff --git a/web/src/app/(main)/function/tasks/detail/_tabs/assets-tab.tsx b/web/src/app/(main)/function/tasks/detail/_tabs/assets-tab.tsx index c39ce1d..b449eb5 100644 --- a/web/src/app/(main)/function/tasks/detail/_tabs/assets-tab.tsx +++ b/web/src/app/(main)/function/tasks/detail/_tabs/assets-tab.tsx @@ -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("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(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 = ( + + ); + return (
@@ -436,7 +486,11 @@ export function AssetsTab({ taskId }: { taskId: string }) {
- setTab(value as NewAssetType)} className="min-h-0 flex-1"> + setTab(value as NewAssetType)} + className="flex min-h-0 flex-1 flex-col gap-2" + >
{TABS.map((item) => ( @@ -449,7 +503,9 @@ export function AssetsTab({ taskId }: { taskId: string }) {
- + {searchBox} + + {rows.map((asset) => ( @@ -464,7 +520,7 @@ export function AssetsTab({ taskId }: { taskId: string }) { - + {rows.map((asset) => ( @@ -490,7 +546,7 @@ export function AssetsTab({ taskId }: { taskId: string }) { - + {rows.map((asset) => ( @@ -509,7 +565,7 @@ export function AssetsTab({ taskId }: { taskId: string }) { - + {rows.map((asset) => ( @@ -531,7 +587,7 @@ export function AssetsTab({ taskId }: { taskId: string }) { - + - + {rows.map((asset) => ( diff --git a/web/src/components/asset-dsl-search.tsx b/web/src/components/asset-dsl-search.tsx new file mode 100644 index 0000000..e30b572 --- /dev/null +++ b/web/src/components/asset-dsl-search.tsx @@ -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 = { + 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(null); + const [suggestions, setSuggestions] = React.useState([]); + 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) => { + 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) => { + 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 ( +
+
+ + 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 && ( +
+ {suggestions.map((s, i) => ( + + ))} +
+ )} +
+ {query.trim() && !open && ( +

+ {loading ? "搜索中…" : error ? {error} : `找到 ${count ?? 0} 条`} +

+ )} +
+ ); +} diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 5f3d2c8..f8ae0ac 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -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(`/tasks/${taskId}/assets`, { asset_ids: assetIds,