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

+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,