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:
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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user