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

@@ -0,0 +1,103 @@
"use client";
import * as React from "react";
import { PlusIcon, Trash2Icon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select";
import type { AssetInterceptKind, AssetInterceptRuleInput } from "@/lib/types";
// 用 NativeSelect(原生 <select>)而非 shadcn Select:这个编辑器会用在 Sheet 抽屉内,
// shadcn Select 的下拉 portal 到 body、点击外部会触发抽屉的「点击外部关闭」误关;原生下拉无此问题。
export const ASSET_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" },
];
// AssetInterceptRulesEditor 是「拦截/允许规则」的受控多行编辑区(拦截block/允许allow +
// 类型 + 匹配内容 + 备注),不自带持久化——由父组件决定何时提交。
export function AssetInterceptRulesEditor({
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 = ASSET_INTERCEPT_KIND_OPTIONS.find((o) => o.value === r.kind)?.placeholder ?? "";
return (
// biome-ignore lint/suspicious/noArrayIndexKey: 行无稳定 id,按索引受控即可
<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 })}
>
{ASSET_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>
);
}
+88 -10
View File
@@ -5,6 +5,7 @@ import * as React from "react";
import { LibraryIcon, PlusIcon, SaveIcon, Settings2Icon, Trash2Icon } from "lucide-react";
import { toast } from "sonner";
import { AssetInterceptRulesEditor } from "@/components/asset-intercept-rules-editor";
import {
AlertDialog,
AlertDialogAction,
@@ -26,37 +27,51 @@ import {
} from "@/components/ui/combobox";
import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { Spinner } from "@/components/ui/spinner";
import { Textarea } from "@/components/ui/textarea";
import { api } from "@/lib/api";
import type { TaskTemplate } from "@/lib/types";
import type { AssetInterceptRuleInput, TaskCategory, TaskTemplate } from "@/lib/types";
import { cn } from "@/lib/utils";
interface TemplateDraft {
name: string;
description: string;
goal: string;
categoryID: number | null;
interceptRules: AssetInterceptRuleInput[];
}
// TemplateSeed 是「另存为模板」时从创建表单带入的初值。
type TemplateSeed = Pick<TemplateDraft, "description" | "goal" | "categoryID" | "interceptRules">;
interface TaskTemplateManagerProps {
open: boolean;
onOpenChange: (open: boolean) => void;
templates: TaskTemplate[];
seed: Pick<TemplateDraft, "description" | "goal"> | null;
seed: TemplateSeed | null;
onCreated: (template: TaskTemplate) => void;
onUpdated: (template: TaskTemplate) => void;
onDeleted: (id: number) => void;
}
const emptyDraft = (): TemplateDraft => ({ name: "", description: "", goal: "" });
const emptyDraft = (): TemplateDraft => ({
name: "",
description: "",
goal: "",
categoryID: null,
interceptRules: [],
});
function templateDraft(template: TaskTemplate): TemplateDraft {
return {
name: template.name,
description: template.description,
goal: template.goal,
categoryID: template.category_id ?? null,
interceptRules: template.intercept_rules ?? [],
};
}
@@ -74,13 +89,28 @@ function TaskTemplateManager({
const [saving, setSaving] = React.useState(false);
const [deleteOpen, setDeleteOpen] = React.useState(false);
const [deleting, setDeleting] = React.useState(false);
const [categories, setCategories] = React.useState<TaskCategory[]>([]);
const wasOpen = React.useRef(false);
React.useEffect(() => {
if (!open) return;
api
.taskCategories()
.then(setCategories)
.catch(() => setCategories([]));
}, [open]);
React.useEffect(() => {
if (open && !wasOpen.current) {
if (seed) {
setSelectedID(null);
setDraft({ name: "", description: seed.description, goal: seed.goal });
setDraft({
name: "",
description: seed.description,
goal: seed.goal,
categoryID: seed.categoryID,
interceptRules: seed.interceptRules,
});
} else if (templates[0]) {
setSelectedID(templates[0].id);
setDraft(templateDraft(templates[0]));
@@ -102,15 +132,23 @@ function TaskTemplateManager({
setDraft(emptyDraft());
};
const updateDraft = (field: keyof TemplateDraft, value: string) => {
const updateDraft = (field: "name" | "description" | "goal", value: string) => {
setDraft((current) => ({ ...current, [field]: value }));
};
const patchDraft = (patch: Partial<TemplateDraft>) => {
setDraft((current) => ({ ...current, ...patch }));
};
async function save() {
const input = {
name: draft.name.trim(),
description: draft.description.trim(),
goal: draft.goal.trim(),
category_id: draft.categoryID,
intercept_rules: draft.interceptRules
.map((r) => ({ ...r, pattern: r.pattern.trim() }))
.filter((r) => r.pattern !== ""),
};
if (!input.name || !input.description || !input.goal) {
toast.error("请填写模板名称、描述和目标");
@@ -169,7 +207,7 @@ function TaskTemplateManager({
<SheetContent className="grid h-full w-full! max-w-none! grid-rows-[auto_minmax(0,1fr)_auto] gap-0 overflow-hidden p-0 sm:w-[48rem]! sm:max-w-[48rem]!">
<SheetHeader className="border-b px-6 py-5">
<SheetTitle>任务模板管理</SheetTitle>
<SheetDescription>模板只保存描述与目标;修改不会影响已经创建的任务。</SheetDescription>
<SheetDescription>模板保存描述、目标、分类与任务级拦截/允许规则;修改不会影响已经创建的任务。</SheetDescription>
</SheetHeader>
<div className="grid min-h-0 overflow-y-auto lg:grid-cols-[15rem_minmax(0,1fr)] lg:overflow-hidden">
<div className="flex min-h-0 flex-col border-b p-3 lg:border-r lg:border-b-0">
@@ -231,6 +269,35 @@ function TaskTemplateManager({
onChange={(event) => updateDraft("goal", event.target.value)}
/>
</Field>
<Field>
<FieldLabel htmlFor="task-template-category">任务分类</FieldLabel>
<NativeSelect
id="task-template-category"
className="w-full"
value={draft.categoryID == null ? "" : String(draft.categoryID)}
onChange={(event) =>
patchDraft({ categoryID: event.target.value === "" ? null : Number(event.target.value) })
}
>
<NativeSelectOption value="">未分类</NativeSelectOption>
{categories.map((c) => (
<NativeSelectOption key={c.id} value={String(c.id)}>
{c.name}
</NativeSelectOption>
))}
</NativeSelect>
<FieldDescription>应用模板时预填此分类(可再改)。</FieldDescription>
</Field>
<Field>
<FieldLabel>任务级拦截 / 允许规则</FieldLabel>
<AssetInterceptRulesEditor
value={draft.interceptRules}
onChange={(rules) => patchDraft({ interceptRules: rules })}
/>
<FieldDescription>
应用模板时预填这些任务级规则(拦截/允许,仅对新任务生效,不进全局)。
</FieldDescription>
</Field>
</FieldGroup>
</ScrollArea>
</div>
@@ -280,6 +347,8 @@ function TaskTemplateManager({
interface TaskTemplateControlsProps {
description: string;
goal: string;
categoryID?: number;
interceptRules?: AssetInterceptRuleInput[];
selectedTemplateID: number | null;
onSelectedTemplateIDChange: (id: number | null) => void;
onApply: (template: TaskTemplate) => void;
@@ -289,6 +358,8 @@ interface TaskTemplateControlsProps {
export function TaskTemplateControls({
description,
goal,
categoryID,
interceptRules,
selectedTemplateID,
onSelectedTemplateIDChange,
onApply,
@@ -299,7 +370,7 @@ export function TaskTemplateControls({
const [templateInputValue, setTemplateInputValue] = React.useState("");
const [pendingTemplate, setPendingTemplate] = React.useState<TaskTemplate | null>(null);
const [managerOpen, setManagerOpen] = React.useState(false);
const [managerSeed, setManagerSeed] = React.useState<Pick<TemplateDraft, "description" | "goal"> | null>(null);
const [managerSeed, setManagerSeed] = React.useState<TemplateSeed | null>(null);
const loadTemplates = React.useCallback(async () => {
setLoading(true);
@@ -348,7 +419,7 @@ export function TaskTemplateControls({
applyTemplate(template);
};
const openManager = (seed: Pick<TemplateDraft, "description" | "goal"> | null) => {
const openManager = (seed: TemplateSeed | null) => {
setManagerSeed(seed);
setManagerOpen(true);
};
@@ -380,7 +451,14 @@ export function TaskTemplateControls({
variant="ghost"
size="sm"
disabled={!description.trim() || !goal.trim()}
onClick={() => openManager({ description, goal })}
onClick={() =>
openManager({
description,
goal,
categoryID: categoryID ?? null,
interceptRules: interceptRules ?? [],
})
}
>
<SaveIcon data-icon="inline-start" />
另存为模板
@@ -420,7 +498,7 @@ export function TaskTemplateControls({
</ComboboxList>
</ComboboxContent>
</Combobox>
<FieldDescription>选择后会复制模板的描述和目标,不与模板保持关联。</FieldDescription>
<FieldDescription>选择后会复制模板的描述、目标、分类与任务级规则,不与模板保持关联。</FieldDescription>
</Field>
<AlertDialog