feat: 任务模板支持预设分类与任务级拦截/允许规则

- db: task_templates 加 category_id(FK task_categories, ON DELETE SET NULL)
  与 intercept_rules JSONB(规则快照);带幂等 ALTER 补旧库。
- db/task_templates.go: 用 JSONB 存取规则;PATCH 用 Set 标志区分改/清。
- server: 模板请求体加 category_id + intercept_rules,复用 buildTaskInterceptRules
  校验;PATCH 靠原始 key presence 判断是否更新(可清分类为 null)。
- web: 抽共享组件 asset-intercept-rules-editor(NativeSelect 版,避免 Sheet 内
  下拉 portal 误关抽屉),创建表单与模板管理器共用;模板管理器新增分类下拉与
  规则编辑区,另存为模板带当前分类/规则;应用模板回填分类与规则。
This commit is contained in:
Autumn-27 committed 2026-09-19 09:27:03 -04:00
1 parent d5359e1f04
commit 777730e255
8 files changed
+342 -143

No files matched your search

+6 -89
View File
@@ -44,6 +44,7 @@ import {
} from "lucide-react";
import { toast } from "sonner";
import { AssetInterceptRulesEditor } from "@/components/asset-intercept-rules-editor";
import { StatusBadge } from "@/components/status-badge";
import { TablePagination } from "@/components/table-pagination";
import { TaskLLMProfileChain } from "@/components/task-llm-profile-chain";
@@ -98,7 +99,6 @@ import {
import { Input } from "@/components/ui/input";
import { Item, ItemActions, ItemContent, ItemDescription, ItemGroup, ItemMedia, ItemTitle } from "@/components/ui/item";
import { Label } from "@/components/ui/label";
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select";
import { Progress } from "@/components/ui/progress";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
@@ -122,7 +122,6 @@ import { api } from "@/lib/api";
import { getLocalStorageValue, setLocalStorageValue } from "@/lib/local-storage.client";
import { type SortDirection, useStoredSortPreference } from "@/lib/sort-preference";
import type {
AssetInterceptKind,
AssetInterceptRuleInput,
ChatAttachment,
Company,
@@ -3035,92 +3034,6 @@ function CategoryManagementSheet({
);
}
const TASK_INTERCEPT_KIND_OPTIONS: { value: AssetInterceptKind; label: string; placeholder: string }[] = [
{ value: "exact_domain", label: "域名(全等)", placeholder: "example.gov.cn" },
{ value: "exact_ip", label: "IP(全等)", placeholder: "203.0.113.10" },
{ value: "exact_url", label: "URL(全等)", placeholder: "https://example.com/login" },
{ value: "fuzzy_domain", label: "域名(模糊)", placeholder: ".gov.cn" },
{ value: "fuzzy_ip", label: "IP(模糊)", placeholder: "203.0.113." },
{ value: "fuzzy_url", label: "URL(模糊)", placeholder: "/admin" },
{ value: "cidr", label: "CIDR 网段", placeholder: "192.168.0.0/16" },
];
// TaskInterceptRulesField 是创建任务时录入「任务级资产拦截规则」的多行编辑区。
// 规则只对本任务生效、不进全局表;与全局规则合并后在 agent 执行时匹配。
function TaskInterceptRulesField({
value,
onChange,
}: {
value: AssetInterceptRuleInput[];
onChange: (v: AssetInterceptRuleInput[]) => void;
}) {
function update(i: number, patch: Partial<AssetInterceptRuleInput>) {
onChange(value.map((r, idx) => (idx === i ? { ...r, ...patch } : r)));
}
function remove(i: number) {
onChange(value.filter((_, idx) => idx !== i));
}
function add() {
onChange([...value, { action: "block", kind: "fuzzy_domain", pattern: "", note: "", enabled: true }]);
}
return (
<div className="grid gap-2">
{value.map((r, i) => {
const ph = TASK_INTERCEPT_KIND_OPTIONS.find((o) => o.value === r.kind)?.placeholder ?? "";
return (
<div key={i} className="flex items-center gap-2">
<NativeSelect
size="sm"
className="w-[84px] shrink-0"
value={r.action}
onChange={(e) => update(i, { action: e.target.value as "block" | "allow" })}
>
<NativeSelectOption value="block">拦截</NativeSelectOption>
<NativeSelectOption value="allow">允许</NativeSelectOption>
</NativeSelect>
<NativeSelect
size="sm"
className="w-[120px] shrink-0"
value={r.kind}
onChange={(e) => update(i, { kind: e.target.value as AssetInterceptKind })}
>
{TASK_INTERCEPT_KIND_OPTIONS.map((o) => (
<NativeSelectOption key={o.value} value={o.value}>
{o.label}
</NativeSelectOption>
))}
</NativeSelect>
<Input
className="flex-1"
placeholder={ph}
value={r.pattern}
onChange={(e) => update(i, { pattern: e.target.value })}
/>
<Input
className="w-[120px] shrink-0"
placeholder="备注(可选)"
value={r.note}
onChange={(e) => update(i, { note: e.target.value })}
/>
<Button
type="button"
size="icon"
variant="ghost"
className="text-destructive hover:text-destructive size-8 shrink-0"
onClick={() => remove(i)}
>
<Trash2Icon className="size-4" />
</Button>
</div>
);
})}
<Button type="button" size="sm" variant="outline" className="w-fit" onClick={add}>
<PlusIcon className="size-4" /> 添加一条
</Button>
</div>
);
}
function CreateTaskSheet({
tasks,
categories,
@@ -3268,11 +3181,15 @@ function CreateTaskSheet({
<TaskTemplateControls
description={description}
goal={goal}
categoryID={categoryID}
interceptRules={interceptRules}
selectedTemplateID={selectedTemplateID}
onSelectedTemplateIDChange={setSelectedTemplateID}
onApply={(template) => {
setDescription(template.description);
setGoal(template.goal);
setCategoryID(template.category_id ?? undefined);
setInterceptRules(template.intercept_rules ?? []);
setUploadCount(0);
}}
portalContainer={sheetContentRef}
@@ -3370,7 +3287,7 @@ function CreateTaskSheet({
</Field>
<Field>
<FieldLabel htmlFor="task-intercept-rules">任务级资产拦截 / 允许规则(可选)</FieldLabel>
<TaskInterceptRulesField value={interceptRules} onChange={setInterceptRules} />
<AssetInterceptRulesEditor value={interceptRules} onChange={setInterceptRules} />
<FieldDescription>
仅对本任务生效,不写入全局规则。判定顺序:先按「拦截」规则(含全局)匹配,命中即禁止测试;未命中且本任务配置了「允许」规则时,须命中某条允许规则才放行,否则同样不允许测试;未配置任何允许规则则不启用白名单。
</FieldDescription>