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
@@ -616,9 +616,16 @@ CREATE TABLE IF NOT EXISTS task_templates (
|
||||
nkey TEXT NOT NULL UNIQUE,
|
||||
description TEXT NOT NULL,
|
||||
goal TEXT NOT NULL,
|
||||
-- 预设的任务分类;分类删除时置空(与 tasks.category_id 一致,不阻断)。
|
||||
category_id BIGINT REFERENCES task_categories(id) ON DELETE SET NULL,
|
||||
-- 预设的任务级拦截/允许规则快照(AssetInterceptRuleInput 数组);应用模板时灌进新任务。
|
||||
intercept_rules JSONB NOT NULL DEFAULT '[]',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
-- 补旧库(已发版,加列带 IF NOT EXISTS)。
|
||||
ALTER TABLE task_templates ADD COLUMN IF NOT EXISTS category_id BIGINT REFERENCES task_categories(id) ON DELETE SET NULL;
|
||||
ALTER TABLE task_templates ADD COLUMN IF NOT EXISTS intercept_rules JSONB NOT NULL DEFAULT '[]';
|
||||
CREATE INDEX IF NOT EXISTS idx_task_templates_updated ON task_templates(updated_at DESC, id DESC);
|
||||
DROP TRIGGER IF EXISTS trg_task_templates_upd ON task_templates;
|
||||
CREATE TRIGGER trg_task_templates_upd BEFORE UPDATE ON task_templates
|
||||
|
||||
+68
-23
@@ -2,6 +2,7 @@ package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
@@ -22,37 +23,69 @@ var (
|
||||
ErrTaskTemplateNotFound = errors.New("task template not found")
|
||||
)
|
||||
|
||||
// TaskTemplate is a reusable task description and goal preset.
|
||||
// TaskTemplate is a reusable task preset (description/goal + optional category
|
||||
// and task-level intercept/allow rules).
|
||||
type TaskTemplate struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
NKey string `json:"-"`
|
||||
Description string `json:"description"`
|
||||
Goal string `json:"goal"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
NKey string `json:"-"`
|
||||
Description string `json:"description"`
|
||||
Goal string `json:"goal"`
|
||||
CategoryID *int64 `json:"category_id"`
|
||||
InterceptRules []TaskInterceptRuleInput `json:"intercept_rules"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// TaskTemplateInput is the create/update payload after normalization.
|
||||
type TaskTemplateInput struct {
|
||||
Name string
|
||||
Description string
|
||||
Goal string
|
||||
Name string
|
||||
Description string
|
||||
Goal string
|
||||
CategoryID *int64
|
||||
InterceptRules []TaskInterceptRuleInput
|
||||
}
|
||||
|
||||
// TaskTemplatePatch changes only fields whose pointers are non-nil.
|
||||
// TaskTemplatePatch changes only fields flagged as set. Name/Description/Goal use
|
||||
// non-nil pointers; CategoryID/InterceptRules use explicit Set flags (so a nil
|
||||
// CategoryID can mean "clear" when SetCategoryID is true).
|
||||
type TaskTemplatePatch struct {
|
||||
Name *string
|
||||
Description *string
|
||||
Goal *string
|
||||
Name *string
|
||||
Description *string
|
||||
Goal *string
|
||||
CategoryID *int64
|
||||
SetCategoryID bool
|
||||
InterceptRules []TaskInterceptRuleInput
|
||||
SetInterceptRules bool
|
||||
}
|
||||
|
||||
const taskTemplateCols = `id, name, nkey, description, goal, created_at, updated_at`
|
||||
const taskTemplateCols = `id, name, nkey, description, goal, category_id, intercept_rules, created_at, updated_at`
|
||||
|
||||
func scanTaskTemplate(row interface{ Scan(...any) error }) (TaskTemplate, error) {
|
||||
var t TaskTemplate
|
||||
err := row.Scan(&t.ID, &t.Name, &t.NKey, &t.Description, &t.Goal, &t.CreatedAt, &t.UpdatedAt)
|
||||
return t, err
|
||||
var rulesRaw []byte
|
||||
if err := row.Scan(&t.ID, &t.Name, &t.NKey, &t.Description, &t.Goal, &t.CategoryID, &rulesRaw, &t.CreatedAt, &t.UpdatedAt); err != nil {
|
||||
return t, err
|
||||
}
|
||||
t.InterceptRules = []TaskInterceptRuleInput{}
|
||||
if len(rulesRaw) > 0 {
|
||||
if err := json.Unmarshal(rulesRaw, &t.InterceptRules); err != nil {
|
||||
return t, err
|
||||
}
|
||||
if t.InterceptRules == nil {
|
||||
t.InterceptRules = []TaskInterceptRuleInput{}
|
||||
}
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// marshalTemplateRules serializes a template's rule snapshot to JSONB text,
|
||||
// always producing a JSON array (never null).
|
||||
func marshalTemplateRules(rules []TaskInterceptRuleInput) ([]byte, error) {
|
||||
if rules == nil {
|
||||
rules = []TaskInterceptRuleInput{}
|
||||
}
|
||||
return json.Marshal(rules)
|
||||
}
|
||||
|
||||
// taskTemplateName normalizes display whitespace while preserving the user's case.
|
||||
@@ -83,7 +116,7 @@ func normalizeTaskTemplateInput(in TaskTemplateInput) (TaskTemplateInput, string
|
||||
}
|
||||
|
||||
func normalizeTaskTemplatePatch(patch TaskTemplatePatch) (TaskTemplatePatch, *string, error) {
|
||||
if patch.Name == nil && patch.Description == nil && patch.Goal == nil {
|
||||
if patch.Name == nil && patch.Description == nil && patch.Goal == nil && !patch.SetCategoryID && !patch.SetInterceptRules {
|
||||
return patch, nil, fmt.Errorf("%w: no fields supplied", ErrTaskTemplateInvalid)
|
||||
}
|
||||
var nkey *string
|
||||
@@ -133,11 +166,15 @@ func (d *DB) CreateTaskTemplate(in TaskTemplateInput) (*TaskTemplate, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rulesJSON, err := marshalTemplateRules(in.InterceptRules)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t, err := scanTaskTemplate(d.QueryRow(`
|
||||
INSERT INTO task_templates(name, nkey, description, goal)
|
||||
VALUES ($1,$2,$3,$4)
|
||||
INSERT INTO task_templates(name, nkey, description, goal, category_id, intercept_rules)
|
||||
VALUES ($1,$2,$3,$4,$5,$6)
|
||||
ON CONFLICT (nkey) DO NOTHING
|
||||
RETURNING `+taskTemplateCols, in.Name, nkey, in.Description, in.Goal))
|
||||
RETURNING `+taskTemplateCols, in.Name, nkey, in.Description, in.Goal, in.CategoryID, rulesJSON))
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, ErrTaskTemplateNameConflict
|
||||
}
|
||||
@@ -196,17 +233,25 @@ func (d *DB) PatchTaskTemplate(id int64, patch TaskTemplatePatch) (*TaskTemplate
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rulesJSON, err := marshalTemplateRules(patch.InterceptRules)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t, err := scanTaskTemplate(d.QueryRow(`UPDATE task_templates
|
||||
SET name=CASE WHEN $2 THEN $3::text ELSE name END,
|
||||
nkey=CASE WHEN $2 THEN $4::text ELSE nkey END,
|
||||
description=CASE WHEN $5 THEN $6::text ELSE description END,
|
||||
goal=CASE WHEN $7 THEN $8::text ELSE goal END
|
||||
goal=CASE WHEN $7 THEN $8::text ELSE goal END,
|
||||
category_id=CASE WHEN $9 THEN $10::bigint ELSE category_id END,
|
||||
intercept_rules=CASE WHEN $11 THEN $12::jsonb ELSE intercept_rules END
|
||||
WHERE id=$1
|
||||
RETURNING `+taskTemplateCols,
|
||||
id,
|
||||
patch.Name != nil, patch.Name, nkey,
|
||||
patch.Description != nil, patch.Description,
|
||||
patch.Goal != nil, patch.Goal,
|
||||
patch.SetCategoryID, patch.CategoryID,
|
||||
patch.SetInterceptRules, rulesJSON,
|
||||
))
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, ErrTaskTemplateNotFound
|
||||
|
||||
+61
-17
@@ -1,8 +1,10 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
@@ -14,24 +16,44 @@ const maxTaskTemplateRequestBytes = 512 << 10
|
||||
|
||||
// taskTemplateRequest uses pointers so PATCH can distinguish omitted fields from
|
||||
// explicit empty values. Empty values are still rejected by the DB validator.
|
||||
// category_id / intercept_rules presence is detected via the raw key set (see
|
||||
// decodeTaskTemplateRequest) so a PATCH can clear category to null.
|
||||
type taskTemplateRequest struct {
|
||||
Name *string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
Goal *string `json:"goal"`
|
||||
Name *string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
Goal *string `json:"goal"`
|
||||
CategoryID *int64 `json:"category_id"`
|
||||
InterceptRules []taskInterceptRuleReq `json:"intercept_rules"`
|
||||
}
|
||||
|
||||
func decodeTaskTemplateRequest(w http.ResponseWriter, r *http.Request, req *taskTemplateRequest) bool {
|
||||
// decodeTaskTemplateRequest decodes the body into req and returns the set of
|
||||
// top-level keys present in the JSON (for PATCH presence detection).
|
||||
func decodeTaskTemplateRequest(w http.ResponseWriter, r *http.Request, req *taskTemplateRequest) (map[string]struct{}, bool) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxTaskTemplateRequestBytes)
|
||||
if err := decode(r, req); err != nil {
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
var tooLarge *http.MaxBytesError
|
||||
if errors.As(err, &tooLarge) {
|
||||
writeErr(w, http.StatusRequestEntityTooLarge, "请求正文过大")
|
||||
} else {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
}
|
||||
return false
|
||||
return nil, false
|
||||
}
|
||||
return true
|
||||
var raw map[string]json.RawMessage
|
||||
if err := json.Unmarshal(body, &raw); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
return nil, false
|
||||
}
|
||||
if err := json.Unmarshal(body, req); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
return nil, false
|
||||
}
|
||||
present := make(map[string]struct{}, len(raw))
|
||||
for k := range raw {
|
||||
present[k] = struct{}{}
|
||||
}
|
||||
return present, true
|
||||
}
|
||||
|
||||
func validateTaskTemplateRequest(req taskTemplateRequest) error {
|
||||
@@ -91,17 +113,24 @@ func (s *Server) pgCreateTaskTemplate(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
var req taskTemplateRequest
|
||||
if !decodeTaskTemplateRequest(w, r, &req) {
|
||||
if _, ok := decodeTaskTemplateRequest(w, r, &req); !ok {
|
||||
return
|
||||
}
|
||||
if err := validateTaskTemplateRequest(req); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
rules, err := buildTaskInterceptRules(req.InterceptRules)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "拦截/允许规则无效:"+err.Error())
|
||||
return
|
||||
}
|
||||
template, err := pg.CreateTaskTemplate(db.TaskTemplateInput{
|
||||
Name: stringValue(req.Name),
|
||||
Description: stringValue(req.Description),
|
||||
Goal: stringValue(req.Goal),
|
||||
Name: stringValue(req.Name),
|
||||
Description: stringValue(req.Description),
|
||||
Goal: stringValue(req.Goal),
|
||||
CategoryID: req.CategoryID,
|
||||
InterceptRules: rules,
|
||||
})
|
||||
if err != nil {
|
||||
writeTaskTemplateErr(w, err)
|
||||
@@ -121,20 +150,35 @@ func (s *Server) pgUpdateTaskTemplate(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
var req taskTemplateRequest
|
||||
if !decodeTaskTemplateRequest(w, r, &req) {
|
||||
present, ok := decodeTaskTemplateRequest(w, r, &req)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := validateTaskTemplateRequest(req); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if req.Name == nil && req.Description == nil && req.Goal == nil {
|
||||
writeErr(w, http.StatusBadRequest, "至少需要提供 name、description 或 goal")
|
||||
_, catPresent := present["category_id"]
|
||||
_, rulesPresent := present["intercept_rules"]
|
||||
if req.Name == nil && req.Description == nil && req.Goal == nil && !catPresent && !rulesPresent {
|
||||
writeErr(w, http.StatusBadRequest, "至少需要提供 name、description、goal、category_id 或 intercept_rules")
|
||||
return
|
||||
}
|
||||
template, err := pg.PatchTaskTemplate(id, db.TaskTemplatePatch{
|
||||
Name: req.Name, Description: req.Description, Goal: req.Goal,
|
||||
})
|
||||
patch := db.TaskTemplatePatch{Name: req.Name, Description: req.Description, Goal: req.Goal}
|
||||
if catPresent {
|
||||
patch.SetCategoryID = true
|
||||
patch.CategoryID = req.CategoryID
|
||||
}
|
||||
if rulesPresent {
|
||||
rules, err := buildTaskInterceptRules(req.InterceptRules)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "拦截/允许规则无效:"+err.Error())
|
||||
return
|
||||
}
|
||||
patch.SetInterceptRules = true
|
||||
patch.InterceptRules = rules
|
||||
}
|
||||
template, err := pg.PatchTaskTemplate(id, patch)
|
||||
if err != nil {
|
||||
writeTaskTemplateErr(w, err)
|
||||
return
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
+7
-4
@@ -276,10 +276,13 @@ export const api = {
|
||||
category_id: categoryId ?? null,
|
||||
}),
|
||||
taskTemplates: () => get<{ templates: TaskTemplate[] }>("/task-templates").then((r) => arr(r.templates)),
|
||||
createTaskTemplate: (input: Pick<TaskTemplate, "name" | "description" | "goal">) =>
|
||||
post<TaskTemplate>("/task-templates", input),
|
||||
updateTaskTemplate: (id: number, input: Partial<Pick<TaskTemplate, "name" | "description" | "goal">>) =>
|
||||
patch<TaskTemplate>(`/task-templates/${id}`, input),
|
||||
createTaskTemplate: (
|
||||
input: Pick<TaskTemplate, "name" | "description" | "goal" | "category_id" | "intercept_rules">,
|
||||
) => post<TaskTemplate>("/task-templates", input),
|
||||
updateTaskTemplate: (
|
||||
id: number,
|
||||
input: Partial<Pick<TaskTemplate, "name" | "description" | "goal" | "category_id" | "intercept_rules">>,
|
||||
) => patch<TaskTemplate>(`/task-templates/${id}`, input),
|
||||
deleteTaskTemplate: (id: number) => del<{ deleted: number }>(`/task-templates/${id}`),
|
||||
updateTaskLLMProfiles: (id: string, llmProfileIds: number[], activeLLMProfileId?: number) =>
|
||||
put<{
|
||||
|
||||
@@ -53,6 +53,8 @@ export interface TaskTemplate {
|
||||
name: string;
|
||||
description: string;
|
||||
goal: string;
|
||||
category_id?: number | null; // 预设分类;null/缺省=无
|
||||
intercept_rules?: AssetInterceptRuleInput[]; // 预设的任务级拦截/允许规则
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user