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

+7
View File
@@ -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
View File
@@ -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
View File
@@ -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
+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>
@@ -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
+7 -4
View File
@@ -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<{
+2
View File
@@ -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;
}