feat(tasks): 任务增加可选名称字段

tasks 表新增 name 列(空=未命名,前端回退到描述),端到端打通:
- DB: schema.sql 加列并补旧库 ALTER;tasks.go 的 Task/CreateOptions/CRUD;
  findings.go 分组与任务下拉带出 t.name
- 后端: TaskDTO、内存 Task、createTaskReq 透传 name
- 前端: 类型/API/mock 加 name;任务列表新增名称列与搜索、创建表单加名称输入;
  详情页与漏洞页标题回退 name || description

Claude-Session: https://claude.ai/code/session_019QXSCDmzimwnjntBNCgUD7
This commit is contained in:
Autumn-27 committed 2026-08-23 09:08:05 -04:00
1 parent 070784d43c
commit f63a451b87
12 files changed
+72 -21

No files matched your search

+8 -6
View File
@@ -228,6 +228,7 @@ func (d *DB) ListFindingsPage(f FindingFilter, page, pageSize int) ([]*DBFinding
// deletion; those records intentionally share one "unassigned/deleted" bucket.
type FindingGroup struct {
TaskID *int64 `json:"task_id"`
TaskName string `json:"task_name"` // 可选任务名称;空=未命名
TaskDescription string `json:"task_description"`
TaskStatus string `json:"task_status"`
Count int `json:"count"`
@@ -250,7 +251,7 @@ func (d *DB) ListFindingGroups(f FindingFilter, page, pageSize int) ([]FindingGr
}
where, args := f.where()
grouped := ` FROM findings f LEFT JOIN tasks t ON f.task_id=t.id` + where +
` GROUP BY t.id, t.description, t.status, t.paused, t.queued`
` GROUP BY t.id, t.name, t.description, t.status, t.paused, t.queued`
var groupTotal, findingTotal int
countQuery := `SELECT COUNT(*), COALESCE(SUM(finding_count),0) FROM (` +
@@ -267,7 +268,7 @@ func (d *DB) ListFindingGroups(f FindingFilter, page, pageSize int) ([]FindingGr
order = `MAX(CASE f.severity WHEN 'critical' THEN 4 WHEN 'high' THEN 3 WHEN 'medium' THEN 2 WHEN 'low' THEN 1 ELSE 0 END) DESC, MAX(f.created_at) DESC, t.id DESC NULLS LAST`
}
pageArgs := append(append([]any{}, args...), pageSize, (page-1)*pageSize)
query := fmt.Sprintf(`SELECT t.id, COALESCE(t.description,''), COALESCE(
query := fmt.Sprintf(`SELECT t.id, COALESCE(t.name,''), COALESCE(t.description,''), COALESCE(
CASE
WHEN t.status IN ('done','failed','timeout') THEN t.status
WHEN t.queued THEN 'queued'
@@ -290,7 +291,7 @@ func (d *DB) ListFindingGroups(f FindingFilter, page, pageSize int) ([]FindingGr
for rows.Next() {
var group FindingGroup
var taskID sql.NullInt64
if err := rows.Scan(&taskID, &group.TaskDescription, &group.TaskStatus, &group.Count,
if err := rows.Scan(&taskID, &group.TaskName, &group.TaskDescription, &group.TaskStatus, &group.Count,
&group.Critical, &group.High, &group.Medium, &group.Low, &group.LastFoundAt); err != nil {
return nil, 0, 0, err
}
@@ -477,6 +478,7 @@ type FindingStats struct {
// the id.
type FindingTaskOption struct {
ID int64 `json:"id"`
Name string `json:"name"` // 可选任务名称;空=未命名
Description string `json:"description"`
Count int `json:"count"`
}
@@ -513,11 +515,11 @@ func (d *DB) FindingStats() (*FindingStats, error) {
}
// 任务下拉:有漏洞的任务,带描述(任务删除后为空,前端回退 id)和条数,最新有漏洞的排前。
trows, err := d.Query(`SELECT f.task_id, COALESCE(t.description, ''), COUNT(*)
trows, err := d.Query(`SELECT f.task_id, COALESCE(t.name, ''), COALESCE(t.description, ''), COUNT(*)
FROM findings f
LEFT JOIN tasks t ON f.task_id = t.id
WHERE f.task_id IS NOT NULL
GROUP BY f.task_id, t.description
GROUP BY f.task_id, t.name, t.description
ORDER BY MAX(f.created_at) DESC`)
if err != nil {
return nil, err
@@ -525,7 +527,7 @@ func (d *DB) FindingStats() (*FindingStats, error) {
defer trows.Close()
for trows.Next() {
var opt FindingTaskOption
if err := trows.Scan(&opt.ID, &opt.Description, &opt.Count); err != nil {
if err := trows.Scan(&opt.ID, &opt.Name, &opt.Description, &opt.Count); err != nil {
return nil, err
}
st.Tasks = append(st.Tasks, opt)
+3
View File
@@ -324,6 +324,7 @@ CREATE TABLE IF NOT EXISTS llm_profile_health (
-- =====================================================================
CREATE TABLE IF NOT EXISTS tasks (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL,
goal TEXT NOT NULL,
exploration_id BIGINT NOT NULL UNIQUE
@@ -367,6 +368,8 @@ ALTER TABLE tasks ADD COLUMN IF NOT EXISTS coverage_enabled BOOLEAN NOT NULL DEF
-- exploration that already owns goals/history.
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS queued_at TIMESTAMPTZ;
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS queue_mode TEXT NOT NULL DEFAULT '';
-- 可选的任务名称;补旧库。空串=未命名,前端展示时回退到描述。
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS name TEXT NOT NULL DEFAULT '';
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS active_llm_profile_id BIGINT REFERENCES llm_profiles(id) ON DELETE SET NULL;
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS llm_chain_revision BIGINT NOT NULL DEFAULT 0;
+8 -5
View File
@@ -12,6 +12,7 @@ import (
// Task is a row in the task registry (1:1 with an exploration).
type Task struct {
ID int64 `json:"id"`
Name string `json:"name"` // 可选任务名称;空=未命名
Description string `json:"description"`
Goal string `json:"goal"`
ExplorationID int64 `json:"exploration_id"`
@@ -137,6 +138,7 @@ func (d *DB) CreateTask(description, goal string, llmProfileID *int64, timeoutSe
// TaskCreateOptions contains the task data that must be committed atomically
// with the task/exploration row.
type TaskCreateOptions struct {
Name string // 可选任务名称;空=未命名
SourceTaskIDs []int64
CompanyIDs []int64
LLMProfileIDs []int64
@@ -193,6 +195,7 @@ VALUES ($1, 'fact', $2, 0, 'origin', 'system')`, expID, string(originPayload));
active = &id
}
t := &Task{
Name: opts.Name,
Description: description, Goal: goal, ExplorationID: expID,
LLMProfileID: active, ActiveLLMProfileID: active,
LLMProfileIDs: append([]int64(nil), opts.LLMProfileIDs...),
@@ -202,9 +205,9 @@ VALUES ($1, 'fact', $2, 0, 'origin', 'system')`, expID, string(originPayload));
CoverageEnabled: coverageEnabled,
}
if err := tx.QueryRow(`
INSERT INTO tasks(description, goal, exploration_id, llm_profile_id, active_llm_profile_id, timeout_seconds, plan_heartbeat_seconds, coverage_enabled)
VALUES ($1,$2,$3,$4,$4,$5,$6,$7)
RETURNING id, status, paused, created_at`, description, goal, expID, active, opts.TimeoutSeconds, opts.PlanHeartbeatSeconds, coverageEnabled).Scan(&t.ID, &t.Status, &t.Paused, &t.CreatedAt); err != nil {
INSERT INTO tasks(name, description, goal, exploration_id, llm_profile_id, active_llm_profile_id, timeout_seconds, plan_heartbeat_seconds, coverage_enabled)
VALUES ($1,$2,$3,$4,$5,$5,$6,$7,$8)
RETURNING id, status, paused, created_at`, opts.Name, description, goal, expID, active, opts.TimeoutSeconds, opts.PlanHeartbeatSeconds, coverageEnabled).Scan(&t.ID, &t.Status, &t.Paused, &t.CreatedAt); err != nil {
return nil, err
}
if err := insertTaskRelations(tx, t.ID, opts.SourceTaskIDs); err != nil {
@@ -284,11 +287,11 @@ func insertTaskLLMProfiles(tx *sql.Tx, taskID int64, profileIDs []int64) error {
return nil
}
const taskCols = `id, description, goal, exploration_id, status, paused, queued, queued_at, COALESCE(queue_mode,''), llm_profile_id, active_llm_profile_id, COALESCE(parent_ref,''), created_at, completed_at, COALESCE(timeout_seconds,0), COALESCE(plan_heartbeat_seconds,300), COALESCE(coverage_enabled,true), first_run_at, deadline_at`
const taskCols = `id, COALESCE(name,''), description, goal, exploration_id, status, paused, queued, queued_at, COALESCE(queue_mode,''), llm_profile_id, active_llm_profile_id, COALESCE(parent_ref,''), created_at, completed_at, COALESCE(timeout_seconds,0), COALESCE(plan_heartbeat_seconds,300), COALESCE(coverage_enabled,true), first_run_at, deadline_at`
func scanTask(sc interface{ Scan(...any) error }) (*Task, error) {
var t Task
if err := sc.Scan(&t.ID, &t.Description, &t.Goal, &t.ExplorationID, &t.Status, &t.Paused, &t.Queued, &t.QueuedAt, &t.QueueMode, &t.LLMProfileID, &t.ActiveLLMProfileID, &t.ParentRef, &t.CreatedAt, &t.CompletedAt, &t.TimeoutSeconds, &t.PlanHeartbeatSeconds, &t.CoverageEnabled, &t.FirstRunAt, &t.DeadlineAt); err != nil {
if err := sc.Scan(&t.ID, &t.Name, &t.Description, &t.Goal, &t.ExplorationID, &t.Status, &t.Paused, &t.Queued, &t.QueuedAt, &t.QueueMode, &t.LLMProfileID, &t.ActiveLLMProfileID, &t.ParentRef, &t.CreatedAt, &t.CompletedAt, &t.TimeoutSeconds, &t.PlanHeartbeatSeconds, &t.CoverageEnabled, &t.FirstRunAt, &t.DeadlineAt); err != nil {
return nil, err
}
return &t, nil
+2
View File
@@ -32,6 +32,7 @@ func rawString(raw json.RawMessage) string {
type TaskDTO struct {
ID string `json:"id"`
ExplorationID int64 `json:"exploration_id"`
Name string `json:"name"` // 可选任务名称;空=未命名
Description string `json:"description"`
Goal string `json:"goal"`
Status string `json:"status"` // created | running | paused | done | failed
@@ -83,6 +84,7 @@ func taskDTO(t *Task, status string) TaskDTO {
return TaskDTO{
ID: t.ID,
ExplorationID: t.ExpID,
Name: t.Name,
Description: t.Description,
Goal: t.Goal,
Status: status,
+2
View File
@@ -29,6 +29,7 @@ import (
type Task struct {
ID string `json:"id"`
ExpID int64 `json:"exploration_id"`
Name string `json:"name"` // 可选任务名称;空=未命名
Description string `json:"description"`
Goal string `json:"goal"`
CreatedAt int64 `json:"created_at"`
@@ -687,6 +688,7 @@ func unixNanoOrZero(t *time.Time) int64 {
func taskFromPG(pt *pgdb.Task, store *pgdb.ExplorationStore, ic *intercept.Interceptor) *Task {
return &Task{
ID: strconv.FormatInt(pt.ID, 10), ExpID: pt.ExplorationID,
Name: pt.Name,
Description: pt.Description, Goal: pt.Goal, CreatedAt: pt.CreatedAt.Unix(), Paused: pt.Paused, Queued: pt.Queued,
QueuedAt: unixNanoOrZero(pt.QueuedAt), QueueMode: pt.QueueMode,
CompletedAt: unixOrZero(pt.CompletedAt), Status: pt.Status, ParentRef: pt.ParentRef,
+2
View File
@@ -1299,6 +1299,7 @@ func (s *Server) testLLM(w http.ResponseWriter, r *http.Request) {
}
type createTaskReq struct {
Name string `json:"name,omitempty"` // 可选任务名称;省略/空=未命名
Description string `json:"description"`
Goal string `json:"goal"`
LLMProfileID *int64 `json:"llm_profile_id,omitempty"` // 指定运行本任务的 LLM 配置;省略/null=用激活配置
@@ -1356,6 +1357,7 @@ func (s *Server) createTask(w http.ResponseWriter, r *http.Request) {
}
req.CompanyIDs = companyIDs
t, err := s.m.CreateTaskWithOptions(req.Description, req.Goal, db.TaskCreateOptions{
Name: strings.TrimSpace(req.Name),
SourceTaskIDs: sourceIDs, CompanyIDs: req.CompanyIDs, LLMProfileIDs: req.LLMProfileIDs,
TimeoutSeconds: req.TimeoutSeconds, PlanHeartbeatSeconds: req.PlanHeartbeatSeconds,
CoverageEnabled: req.CoverageEnabled,
@@ -626,7 +626,7 @@ export default function FindingsPage() {
<SelectItem value={UNASSIGNED_TASK}>未关联 / 任务已删除</SelectItem>
{(stats.tasks ?? []).map((t) => {
const id = String(t.id);
const label = t.description || `任务 #${id}(已删除)`;
const label = t.name || t.description || `任务 #${id}(已删除)`;
return (
<SelectItem key={id} value={id}>
<span className="flex w-full items-center gap-2">
@@ -704,7 +704,11 @@ export default function FindingsPage() {
/>
<div className="flex min-w-0 flex-col gap-1">
<CardTitle className="truncate text-sm">
{group.task_id === null ? "未关联 / 任务已删除" : `任务 #${group.task_id}`}
{group.task_id === null
? "未关联 / 任务已删除"
: group.task_name
? `${group.task_name}(任务 #${group.task_id})`
: `任务 #${group.task_id}`}
</CardTitle>
<CardDescription className="truncate" title={group.task_description}>
{group.task_description || "来源任务不可用"}
@@ -314,8 +314,8 @@ function TaskDetailInner() {
<ArrowLeftIcon />
</Link>
</Button>
<h1 className="min-w-0 flex-1 truncate text-sm font-semibold" title={task.description}>
{task.description}
<h1 className="min-w-0 flex-1 truncate text-sm font-semibold" title={task.name || task.description}>
{task.name || task.description}
</h1>
<code className="hidden rounded bg-muted px-1.5 py-0.5 font-mono text-xs text-muted-foreground sm:inline">
{task.id}
+28 -3
View File
@@ -225,7 +225,10 @@ export default function TasksPage() {
if (statusFilter !== "all" && t.status !== statusFilter) return false;
if (!q) return true;
return (
t.description.toLowerCase().includes(q) || t.goal.toLowerCase().includes(q) || t.id.toLowerCase().includes(q)
(t.name ?? "").toLowerCase().includes(q) ||
t.description.toLowerCase().includes(q) ||
t.goal.toLowerCase().includes(q) ||
t.id.toLowerCase().includes(q)
);
});
}, [tasks, query, statusFilter]);
@@ -575,6 +578,7 @@ export default function TasksPage() {
/>
</TableHead>
<TableHead className="font-mono">ID</TableHead>
<TableHead>名称</TableHead>
<TableHead>描述</TableHead>
<TableHead>目标</TableHead>
<TableHead>状态</TableHead>
@@ -741,13 +745,22 @@ const TaskRow = React.memo(function TaskRow({
<Link
href={`/function/tasks/detail?id=${encodeURIComponent(task.id)}`}
className="min-w-0 truncate rounded-sm hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
title={task.description}
title={task.name || task.description}
>
{task.description}
{task.name || <span className="text-muted-foreground">未命名</span>}
</Link>
{task.active && <StarIcon className="size-4 shrink-0 fill-amber-400 text-amber-400" />}
</div>
</TableCell>
<TableCell className="text-muted-foreground max-w-xs">
<Link
href={`/function/tasks/detail?id=${encodeURIComponent(task.id)}`}
className="block truncate rounded-sm hover:text-foreground hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
title={task.description}
>
{task.description}
</Link>
</TableCell>
<TableCell className="text-muted-foreground max-w-xs truncate">{task.goal}</TableCell>
<TableCell>
<StatusBadge domain="task" value={task.status} dot />
@@ -1282,6 +1295,7 @@ function CompanyPicker({
function CreateTaskSheet({ tasks, onCreated }: { tasks: Task[]; onCreated: () => void }) {
const [open, setOpen] = React.useState(false);
const [name, setName] = React.useState("");
const [description, setDescription] = React.useState("");
const [goal, setGoal] = React.useState("");
const [selectedTemplateID, setSelectedTemplateID] = React.useState<number | null>(null);
@@ -1350,6 +1364,7 @@ function CreateTaskSheet({ tasks, onCreated }: { tasks: Task[]; onCreated: () =>
const timeoutSec = Math.max(0, Math.floor(Number(timeoutMin) || 0)) * 60;
const heartbeatSec = Math.max(10, Math.floor(Number(heartbeatMin) || 10)) * 60; // 下限 10min,与后端归一一致
await api.createTask({
name: name.trim(),
description: description.trim(),
goal: goal.trim(),
llmProfileIds: llmProfileIDs.map(Number),
@@ -1361,6 +1376,7 @@ function CreateTaskSheet({ tasks, onCreated }: { tasks: Task[]; onCreated: () =>
coverageEnabled,
});
toast.success("任务已创建");
setName("");
setDescription("");
setGoal("");
setSelectedTemplateID(null);
@@ -1415,6 +1431,15 @@ function CreateTaskSheet({ tasks, onCreated }: { tasks: Task[]; onCreated: () =>
}}
portalContainer={sheetContentRef}
/>
<div className="grid gap-2">
<Label htmlFor="name">名称(可选)</Label>
<Input
id="name"
placeholder="给任务起个便于识别的名字,例如:Acme 官网渗透"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="description">描述</Label>
<Textarea
+2
View File
@@ -180,6 +180,7 @@ export const api = {
tasks: () =>
get<{ tasks: Task[]; active: string }>("/tasks").then((r) => ({ tasks: arr(r.tasks), active: r.active ?? "" })),
createTask: (input: {
name?: string;
description: string;
goal: string;
llmProfileIds?: number[];
@@ -191,6 +192,7 @@ export const api = {
coverageEnabled?: boolean;
}) =>
post<Task>("/tasks", {
name: input.name ?? "",
description: input.description,
goal: input.goal,
llm_profile_ids: input.llmProfileIds ?? [],
+6 -3
View File
@@ -243,6 +243,7 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
const companyIDs = [...((b.company_ids as number[] | undefined) ?? [])];
const created: Task = {
id,
name: String(b.name ?? ""),
description: String(b.description ?? "新任务"),
goal: String(b.goal ?? ""),
status: "created",
@@ -595,14 +596,15 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
if (path === "/exploration/findings/stats") {
const vulnclasses = Array.from(new Set(mockFindings.map((f) => f.vulnclass))).sort();
// 「按任务」下拉:有漏洞的任务 + 描述 + 条数(mock 任务 id 是字符串,直接当 id 用)。
const taskMap = new Map<string, { description: string; count: number }>();
const taskMap = new Map<string, { name: string; description: string; count: number }>();
for (const f of mockFindings) {
if (!f.task_id) continue;
const cur = taskMap.get(f.task_id) ?? { description: f.task_description ?? "", count: 0 };
const owner = mockTasks.find((candidate) => candidate.id === f.task_id);
const cur = taskMap.get(f.task_id) ?? { name: owner?.name ?? "", description: f.task_description ?? "", count: 0 };
cur.count++;
taskMap.set(f.task_id, cur);
}
const tasks = Array.from(taskMap, ([id, v]) => ({ id, description: v.description, count: v.count }));
const tasks = Array.from(taskMap, ([id, v]) => ({ id, name: v.name, description: v.description, count: v.count }));
return {
total: mockFindings.length,
pending: mockFindings.filter((f) => f.status === "pending").length,
@@ -636,6 +638,7 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
const owner = mockTasks.find((candidate) => candidate.id === key);
return {
task_id: key === "__unassigned__" ? null : key,
task_name: owner?.name ?? "",
task_description: owner?.description ?? items[0]?.task_description ?? "",
task_status: owner?.status ?? "",
count: items.length,
+3
View File
@@ -6,6 +6,7 @@ export type EngineMode = "exploring" | "paused" | "stalled" | "idle";
export interface Task {
id: string;
name?: string; // 可选任务名称;空/缺省=未命名,展示时回退到描述
description: string;
goal: string;
status: TaskStatus;
@@ -349,6 +350,7 @@ export interface FindingsPage {
export interface FindingGroup {
task_id: string | number | null;
task_name?: string; // 可选任务名称;空/缺省=未命名
task_description: string;
task_status: string;
count: number;
@@ -390,6 +392,7 @@ export interface FindingStats {
// 前端回退展示 id)及其漏洞条数。
export interface FindingTaskOption {
id: string | number;
name?: string; // 可选任务名称;空/缺省=未命名
description: string;
count: number;
}