mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-10-27 17:03:49 +08:00
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:
1 parent
1f258837f5
commit
557317491b
8 files changed
+491
-393
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user