feat: 发现页支持按任务筛选/行内看报告与改名称类别严重度;任务列表输入去卡顿

- 发现列表新增「按任务」下拉(有漏洞的任务+条数),task_id 下推 SQL 筛选
- 展开行懒加载并渲染详细报告(Markdown),修 table 溢出:table-fixed +
  whitespace-normal + break-words + pre 换行
- 展开行前置「名称/类别/严重等级」可编辑并保存:patchFinding 增 name/vulnclass,
  DB 抽 setFindingCol 同步节点 payload
- 详情页状态区加「漏洞 ID」,列表证据行去掉裸 id
- 任务新建表单拆出独立组件消除输入卡顿;轮询 3s→10s+去重,时长 tick 仅 running 时跑
This commit is contained in:
Autumn-27 committed 2026-08-18 13:27:46 -04:00
1 parent 24a94cf8e5
commit d9474814b5
6 files changed
+719 -378

No files matched your search

+76 -20
View File
@@ -4,6 +4,7 @@ import (
"database/sql"
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
)
@@ -141,6 +142,7 @@ type FindingFilter struct {
Severity string // high | medium | low
Status string // pending | false_positive | ignored | resolved
VulnClass string
TaskID string // 任务 id(字符串形式;空/非法 = 不按任务筛选)
Sort string // "severity" | "time"
}
@@ -159,6 +161,11 @@ func (f FindingFilter) where() (string, []any) {
add("severity", f.Severity)
add("status", f.Status)
add("vulnclass", f.VulnClass)
// task_id 是 bigint 列,按整数比较(不能走上面的文本 add);空/非法值忽略。
if tid, err := strconv.ParseInt(f.TaskID, 10, 64); err == nil && tid > 0 {
args = append(args, tid)
conds = append(conds, fmt.Sprintf("f.task_id = $%d", len(args)))
}
if len(conds) == 0 {
return "", args
}
@@ -206,19 +213,30 @@ func (d *DB) ListFindingsPage(f FindingFilter, page, pageSize int) ([]*DBFinding
// and vuln-class filter — computed server-side so it stays exact regardless of
// pagination.
type FindingStats struct {
Total int `json:"total"`
Pending int `json:"pending"`
Critical int `json:"critical"`
High int `json:"high"`
Medium int `json:"medium"`
Low int `json:"low"`
VulnClasses []string `json:"vulnclasses"`
Total int `json:"total"`
Pending int `json:"pending"`
Critical int `json:"critical"`
High int `json:"high"`
Medium int `json:"medium"`
Low int `json:"low"`
VulnClasses []string `json:"vulnclasses"`
Tasks []FindingTaskOption `json:"tasks"` // 有漏洞的任务(供「按任务」下拉)
}
// FindingTaskOption is one entry in the 发现 page's 任务 filter: a task that has at
// least one finding, with its description and finding count. Description is empty when
// the task has since been deleted (finding rows persist), so the frontend falls back to
// the id.
type FindingTaskOption struct {
ID int64 `json:"id"`
Description string `json:"description"`
Count int `json:"count"`
}
// FindingStats returns whole-table counts (by severity + pending) and the sorted
// set of distinct vuln classes.
func (d *DB) FindingStats() (*FindingStats, error) {
st := &FindingStats{VulnClasses: []string{}}
st := &FindingStats{VulnClasses: []string{}, Tasks: []FindingTaskOption{}}
err := d.QueryRow(`SELECT
COUNT(*),
COUNT(*) FILTER (WHERE status = 'pending'),
@@ -242,7 +260,29 @@ func (d *DB) FindingStats() (*FindingStats, error) {
}
st.VulnClasses = append(st.VulnClasses, vc)
}
return st, rows.Err()
if err := rows.Err(); err != nil {
return nil, err
}
// 任务下拉:有漏洞的任务,带描述(任务删除后为空,前端回退 id)和条数,最新有漏洞的排前。
trows, err := d.Query(`SELECT f.task_id, 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
ORDER BY MAX(f.created_at) DESC`)
if err != nil {
return nil, err
}
defer trows.Close()
for trows.Next() {
var opt FindingTaskOption
if err := trows.Scan(&opt.ID, &opt.Description, &opt.Count); err != nil {
return nil, err
}
st.Tasks = append(st.Tasks, opt)
}
return st, trows.Err()
}
// GetFinding returns a single finding row (with task_description joined and the
@@ -288,13 +328,15 @@ func (d *DB) SetFindingReportByNodeID(nodeID int64, report string) (int64, error
return res.RowsAffected()
}
// SetFindingSeverity updates one finding's severity in the standalone table AND
// keeps the originating exploration node's payload in sync (the per-task view
// reads severity from the node payload, not this table). Returns rows affected
// (0 when no finding has that id). The node sync is best-effort.
func (d *DB) SetFindingSeverity(id int64, severity string) (int64, error) {
// setFindingCol updates one text column on the standalone finding row AND mirrors
// the new value into the originating exploration node's payload under jsonKey, so the
// per-task 发现 Tab (which reads the node payload, not this table) stays in sync.
// Returns rows affected (0 when no finding has that id); the node sync is best-effort.
// col and jsonKey MUST be trusted constants (they are interpolated into SQL) — never
// pass user input.
func (d *DB) setFindingCol(id int64, col, jsonKey, val string) (int64, error) {
var nodeID *int64
err := d.QueryRow(`UPDATE findings SET severity=$1 WHERE id=$2 RETURNING node_id`, severity, id).Scan(&nodeID)
err := d.QueryRow(`UPDATE findings SET `+col+`=$1 WHERE id=$2 RETURNING node_id`, val, id).Scan(&nodeID)
if err == sql.ErrNoRows {
return 0, nil
}
@@ -302,16 +344,30 @@ func (d *DB) SetFindingSeverity(id int64, severity string) (int64, error) {
return 0, err
}
if nodeID != nil {
// jsonb_set the {severity} key so the finding node (read by the per-task
// 发现 Tab) shows the same level. Ignore errors — the standalone row is the
// source of truth for the global 发现 page.
_, _ = d.Exec(`UPDATE exploration_nodes
SET payload = jsonb_set(payload, '{severity}', to_jsonb($1::text))
WHERE id = $2`, severity, *nodeID)
SET payload = jsonb_set(payload, '{`+jsonKey+`}', to_jsonb($1::text))
WHERE id = $2`, val, *nodeID)
}
return 1, nil
}
// SetFindingSeverity updates one finding's severity (+ node payload sync). Returns
// rows affected (0 when no finding has that id).
func (d *DB) SetFindingSeverity(id int64, severity string) (int64, error) {
return d.setFindingCol(id, "severity", "severity", severity)
}
// SetFindingName updates one finding's 漏洞名称 (+ node payload sync). Empty name is
// allowed — the frontend falls back to the vuln class for display.
func (d *DB) SetFindingName(id int64, name string) (int64, error) {
return d.setFindingCol(id, "name", "name", name)
}
// SetFindingVulnClass updates one finding's 漏洞类别 (+ node payload sync).
func (d *DB) SetFindingVulnClass(id int64, vulnclass string) (int64, error) {
return d.setFindingCol(id, "vulnclass", "vulnclass", vulnclass)
}
// FindingMeta is the standalone-row data (id, triage state, anchored assets) the
// per-task view grafts onto its exploration-node findings.
type FindingMeta struct {
+28 -4
View File
@@ -1537,15 +1537,17 @@ func (s *Server) patchFinding(w http.ResponseWriter, r *http.Request) {
return
}
var body struct {
Status *string `json:"status"`
Severity *string `json:"severity"`
Status *string `json:"status"`
Severity *string `json:"severity"`
Name *string `json:"name"`
VulnClass *string `json:"vulnclass"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeErr(w, 400, "bad json: "+err.Error())
return
}
if body.Status == nil && body.Severity == nil {
writeErr(w, 400, "nothing to update: provide status and/or severity")
if body.Status == nil && body.Severity == nil && body.Name == nil && body.VulnClass == nil {
writeErr(w, 400, "nothing to update: provide status/severity/name/vulnclass")
return
}
if body.Status != nil {
@@ -1578,6 +1580,28 @@ func (s *Server) patchFinding(w http.ResponseWriter, r *http.Request) {
return
}
}
if body.Name != nil {
n, err := s.m.pg.SetFindingName(id, strings.TrimSpace(*body.Name))
if err != nil {
writeErr(w, 500, err.Error())
return
}
if n == 0 {
writeErr(w, 404, "finding not found")
return
}
}
if body.VulnClass != nil {
n, err := s.m.pg.SetFindingVulnClass(id, strings.TrimSpace(*body.VulnClass))
if err != nil {
writeErr(w, 500, err.Error())
return
}
if n == 0 {
writeErr(w, 404, "finding not found")
return
}
}
f, err := s.m.pg.GetFinding(id)
if err != nil {
writeErr(w, 500, err.Error())
+198 -7
View File
@@ -6,9 +6,14 @@ import {
ChevronRightIcon,
ShieldAlertIcon,
ArrowUpRightIcon,
FileTextIcon,
} from "lucide-react";
import { StatusBadge } from "@/components/status-badge";
import { Markdown } from "@/components/markdown";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Card,
CardContent,
@@ -38,6 +43,8 @@ import { toast } from "sonner";
import { statusMeta } from "@/lib/status";
import type { Finding, FindingStats, FindingStatus, Severity } from "@/lib/types";
const SEVERITIES: Severity[] = ["critical", "high", "medium", "low"];
const FINDING_STATUSES: FindingStatus[] = [
"pending",
"in_progress",
@@ -57,6 +64,7 @@ const EMPTY_STATS: FindingStats = {
medium: 0,
low: 0,
vulnclasses: [],
tasks: [],
};
function fmtTime(ts: string) {
@@ -72,6 +80,7 @@ export default function FindingsPage() {
const [severity, setSeverity] = React.useState<"all" | Severity>("all");
const [status, setStatus] = React.useState<"all" | FindingStatus>("all");
const [vulnclass, setVulnclass] = React.useState<string>("all");
const [task, setTask] = React.useState<string>("all");
const [sort, setSort] = React.useState<"severity" | "time">("severity");
const [expanded, setExpanded] = React.useState<string | null>(null);
const [findings, setFindings] = React.useState<Finding[]>([]);
@@ -83,7 +92,7 @@ export default function FindingsPage() {
// reset to page 1 whenever filters change
React.useEffect(() => {
setPage(1);
}, [severity, status, vulnclass, sort]);
}, [severity, status, vulnclass, task, sort]);
// Server-driven list: paging + filtering + sorting all happen in SQL. Polls the
// current page every 5s, and refetches immediately when any query input changes.
@@ -91,7 +100,7 @@ export default function FindingsPage() {
let alive = true;
const load = () => {
api
.findingsPage({ page, pageSize, severity, status, vulnclass, sort })
.findingsPage({ page, pageSize, severity, status, vulnclass, task, sort })
.then((res) => {
if (!alive) return;
setFindings(res.items);
@@ -105,7 +114,7 @@ export default function FindingsPage() {
alive = false;
clearInterval(t);
};
}, [page, pageSize, severity, status, vulnclass, sort]);
}, [page, pageSize, severity, status, vulnclass, task, sort]);
// Whole-table aggregates (stat cards + vuln-class options) — independent of the
// current page, so they stay exact.
@@ -154,6 +163,74 @@ export default function FindingsPage() {
[status],
);
// 行内展开的详细报告缓存,按行 key(f.id)存。report 是大段 Markdown,列表查询不带它,
// 故展开时才按 finding_id 单独拉取一次;done 且文本为空 = 该漏洞暂无报告。
const [reports, setReports] = React.useState<
Record<string, { status: "loading" | "done" | "error"; text: string }>
>({});
// 行内可编辑缓冲:当前展开行的名称/类别/严重等级,展开时用该行数据初始化,收起清空。
// 单行展开,故一份缓冲即可。
const [edit, setEdit] = React.useState<
{ name: string; vulnclass: string; severity: Severity } | null
>(null);
const [saving, setSaving] = React.useState(false);
// toggle 展开/收起一行;新展开时初始化编辑缓冲,并(尚未取过时)按 finding_id 拉一次报告缓存。
const toggleRow = React.useCallback(
(f: Finding) => {
const willOpen = expanded !== f.id;
setExpanded(willOpen ? f.id : null);
if (!willOpen) {
setEdit(null);
return;
}
setEdit({ name: f.name ?? "", vulnclass: f.vulnclass ?? "", severity: f.severity });
if (!f.finding_id || reports[f.id]) return;
const fid = f.finding_id;
const key = f.id;
setReports((r) => ({ ...r, [key]: { status: "loading", text: "" } }));
api
.getFinding(fid)
.then((full) =>
setReports((r) => ({ ...r, [key]: { status: "done", text: full.report ?? "" } })),
)
.catch(() =>
setReports((r) => ({ ...r, [key]: { status: "error", text: "" } })),
);
},
[expanded, reports],
);
// saveEdit 保存当前展开行的名称/类别/严重等级,回写本地列表并刷新统计(类别下拉/严重计数可能变)。
const saveEdit = React.useCallback(
async (f: Finding) => {
if (!f.finding_id || !edit) return;
setSaving(true);
try {
const updated = await api.updateFinding(f.finding_id, {
name: edit.name.trim(),
vulnclass: edit.vulnclass.trim(),
severity: edit.severity,
});
setFindings((cur) =>
cur.map((x) =>
x.id === f.id
? { ...x, name: updated.name, vulnclass: updated.vulnclass, severity: updated.severity }
: x,
),
);
toast.success("已保存");
api.findingStats().then(setStats).catch(() => {});
} catch (e) {
toast.error("保存失败:" + (e as Error).message);
} finally {
setSaving(false);
}
},
[edit],
);
const statCards: { label: string; value: number; tone?: string }[] = [
{ label: "发现总数", value: stats.total },
{ label: "待处理", value: stats.pending, tone: "text-amber-500" },
@@ -241,6 +318,31 @@ export default function FindingsPage() {
</SelectContent>
</Select>
<Select value={task} onValueChange={setTask}>
<SelectTrigger size="sm" className="w-48">
<SelectValue placeholder="任务" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">全部任务</SelectItem>
{(stats.tasks ?? []).map((t) => {
const id = String(t.id);
const label = t.description || `任务 #${id}(已删除)`;
return (
<SelectItem key={id} value={id}>
<span className="flex w-full items-center gap-2">
<span className="max-w-[14rem] truncate" title={label}>
{label}
</span>
<span className="text-muted-foreground tabular-nums">
{t.count}
</span>
</span>
</SelectItem>
);
})}
</SelectContent>
</Select>
<Select
value={sort}
onValueChange={(v) => setSort(v as "severity" | "time")}
@@ -261,7 +363,9 @@ export default function FindingsPage() {
<Card className="py-0">
<CardContent className="px-0">
<Table>
{/* table-fixed:列宽由表头锁定,展开行那个 colSpan 单元格再宽也只能在固定宽度内
换行/内部滚动,不会把整张表撑出横向滚动条。 */}
<Table className="table-fixed">
<TableHeader>
<TableRow>
<TableHead className="w-8" />
@@ -280,7 +384,7 @@ export default function FindingsPage() {
<React.Fragment key={f.id}>
<TableRow
className="cursor-pointer"
onClick={() => setExpanded(open ? null : f.id)}
onClick={() => toggleRow(f)}
>
<TableCell>
<ChevronRightIcon
@@ -389,11 +493,61 @@ export default function FindingsPage() {
</TableRow>
{open && (
<TableRow className="hover:bg-transparent">
<TableCell colSpan={7} className="bg-muted/30">
{/* whitespace-normal 覆盖 TableCell 默认的 nowrap,否则展开区文字
被强制单行、直接溢出单元格。 */}
<TableCell colSpan={7} className="bg-muted/30 whitespace-normal">
<div className="flex flex-col gap-2 px-2 py-1">
{/* 行内编辑:名称/类别/严重等级,可改并保存(仅独立 finding 行)。 */}
{f.finding_id && edit && (
<div className="flex flex-wrap items-end gap-3 rounded-md border bg-background px-3 py-2.5">
<div className="flex min-w-[12rem] flex-1 flex-col gap-1">
<Label className="text-xs text-muted-foreground">漏洞名称</Label>
<Input
value={edit.name}
onChange={(e) =>
setEdit((s) => (s ? { ...s, name: e.target.value } : s))
}
placeholder="可读标题,留空回退类别"
/>
</div>
<div className="flex min-w-[10rem] flex-col gap-1">
<Label className="text-xs text-muted-foreground">类别</Label>
<Input
value={edit.vulnclass}
onChange={(e) =>
setEdit((s) => (s ? { ...s, vulnclass: e.target.value } : s))
}
placeholder="如 SQL Injection"
/>
</div>
<div className="flex flex-col gap-1">
<Label className="text-xs text-muted-foreground">严重等级</Label>
<Select
value={edit.severity}
onValueChange={(v) =>
setEdit((s) => (s ? { ...s, severity: v as Severity } : s))
}
>
<SelectTrigger size="sm" className="w-28">
<SelectValue />
</SelectTrigger>
<SelectContent>
{SEVERITIES.map((sv) => (
<SelectItem key={sv} value={sv}>
{statusMeta("severity", sv).label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button size="sm" disabled={saving} onClick={() => saveEdit(f)}>
{saving ? "保存中…" : "保存"}
</Button>
</div>
)}
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<ShieldAlertIcon className="size-3.5" />
证据 · {f.id}
证据
{f.vulnclass && (
<span>
· 类型:
@@ -425,6 +579,43 @@ export default function FindingsPage() {
<pre className="overflow-x-auto rounded-md bg-muted px-3 py-2 font-mono text-xs whitespace-pre-wrap">
{f.evidence}
</pre>
{/* 详细报告(Markdown):展开时按 finding_id 懒加载,免进详情页即可查看。 */}
{f.finding_id && (
<div className="flex flex-col gap-1.5">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<FileTextIcon className="size-3.5" />
详细报告
</div>
{(() => {
const rep = reports[f.id];
if (!rep || rep.status === "loading")
return (
<p className="text-xs text-muted-foreground">加载中…</p>
);
if (rep.status === "error")
return (
<p className="text-xs text-muted-foreground">
报告加载失败。
</p>
);
if (!rep.text.trim())
return (
<p className="text-xs text-muted-foreground">
暂无详细报告。
</p>
);
return (
// break-words 会继承到段落/列表,pre 另加
// whitespace-pre-wrap 让代码块也换行——否则长代码行/长 URL
// 会撑宽 colSpan 单元格,把整张表挤出横向滚动条。
<div className="min-w-0 break-words rounded-md border bg-background px-3 py-2 [&_pre]:whitespace-pre-wrap">
<Markdown text={rep.text} />
</div>
);
})()}
</div>
)}
</div>
</TableCell>
</TableRow>
+402 -347
View File
@@ -93,6 +93,10 @@ function appendUploads(desc: string, atts: ChatAttachment[]): string {
// ACTIVE_PROFILE is the sentinel Select value for "use the global active profile".
const ACTIVE_PROFILE = "__active__";
// POLL_MS is the task-list refresh interval. Task state moves on the server (planner /
// worker), so the list has to be pulled; 10s is plenty for status / 进度 / token 变化.
const POLL_MS = 10_000;
// fmtTokens renders a compact token count (1234 → 1.2k, 2_000_000 → 2M).
function fmtTokens(n: number): string {
if (n >= 1_000_000) return (n / 1_000_000).toFixed(n >= 10_000_000 ? 0 : 1) + "M";
@@ -148,19 +152,6 @@ const STATUS_OPTIONS: { value: TaskStatus; label: string }[] = [
export default function TasksPage() {
const [tasks, setTasks] = React.useState<Task[]>([]);
const [description, setDescription] = React.useState("");
const [goal, setGoal] = React.useState("");
const [profiles, setProfiles] = React.useState<LLMProfile[]>([]);
const [llmProfile, setLlmProfile] = React.useState<string>(ACTIVE_PROFILE); // sentinel = active
const [timeoutMin, setTimeoutMin] = React.useState(""); // 任务级超时(分钟);空/0 = 不限时
const [heartbeatMin, setHeartbeatMin] = React.useState("10"); // planner 心跳(分钟);默认10,下限10(与后端一致)
const [seedFirstIntent, setSeedFirstIntent] = React.useState(false); // 创建时下发种子意图,worker 免等首轮 planner 直接开跑;默认关闭,走标准先规划再执行
const [open, setOpen] = React.useState(false);
// 方式1 文件上传:建任务前把文件暂存到 drafts/<draftId>/uploads/,拿回绝对路径追加进描述。
const [uploading, setUploading] = React.useState(false);
const [uploadCount, setUploadCount] = React.useState(0);
const draftIdRef = React.useRef<string>("");
const fileInputRef = React.useRef<HTMLInputElement>(null);
const [query, setQuery] = React.useState("");
const [statusFilter, setStatusFilter] = React.useState<TaskStatus | "all">("all");
const [page, setPage] = React.useState(1);
@@ -188,31 +179,274 @@ export default function TasksPage() {
[filtered, page, pageSize],
);
// lastRef holds the previous poll's serialized payload: the list is re-fetched every
// POLL_MS but usually comes back unchanged, and setTasks on an identical payload would
// re-render the whole page for nothing. Bail out when it matches.
const lastRef = React.useRef<string>("");
const load = React.useCallback(() => {
api.tasks()
.then((r) => {
setTasks(r.tasks.map((t) => (t.id === r.active ? { ...t, active: true } : t)));
const next = r.tasks.map((t) => (t.id === r.active ? { ...t, active: true } : t));
const sig = JSON.stringify(next);
if (sig === lastRef.current) return;
lastRef.current = sig;
setTasks(next);
})
.catch(() => {});
}, []);
React.useEffect(() => {
load();
const i = setInterval(load, 3000);
const i = setInterval(load, POLL_MS);
return () => clearInterval(i);
}, [load]);
// 只在确有 running 任务时才每秒 tick——其余情况「运行时长」是静态值,空转的 tick 会白白
// 重渲染整张表。
const hasRunning = React.useMemo(() => tasks.some((t) => t.status === "running"), [tasks]);
// tick every second so running tasks' 运行时长 counts up live.
React.useEffect(() => {
if (!hasRunning) return;
setNowSec(Math.floor(Date.now() / 1000));
const i = setInterval(() => setNowSec(Math.floor(Date.now() / 1000)), 1000);
return () => clearInterval(i);
}, [hasRunning]);
const deleteTask = React.useCallback(async (id: string) => {
try {
await api.deleteTask(id);
toast.success("任务已删除(全局资产图保留)");
load();
} catch (e) {
toast.error("删除失败:" + (e as Error).message);
}
}, [load]);
return (
<Card>
<CardContent className="flex flex-col gap-4 px-0 pt-6">
<div className="flex flex-wrap items-center gap-2 px-4 lg:px-6">
<div className="relative w-full sm:max-w-xs">
<SearchIcon className="text-muted-foreground pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2" />
<Input
placeholder="搜索描述 / 目标 / ID"
value={query}
onChange={(e) => setQuery(e.target.value)}
className="pl-8"
/>
{query && (
<button
type="button"
onClick={() => setQuery("")}
aria-label="清除搜索"
className="text-muted-foreground hover:text-foreground absolute top-1/2 right-2 -translate-y-1/2"
>
<XIcon className="size-4" />
</button>
)}
</div>
<Select value={statusFilter} onValueChange={(v) => setStatusFilter(v as TaskStatus | "all")}>
<SelectTrigger className="w-36">
<SelectValue placeholder="状态" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">全部状态</SelectItem>
{STATUS_OPTIONS.map((s) => (
<SelectItem key={s.value} value={s.value}>
{s.label}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-muted-foreground text-xs tabular-nums">
{filtered.length}/{tasks.length} 条
</span>
<CreateTaskSheet onCreated={load} />
</div>
{tasks.length === 0 ? (
<div className="text-muted-foreground mx-4 flex items-center justify-center rounded-lg border border-dashed py-20 text-sm lg:mx-6">
暂无任务,点击右上角「新建任务」开始。
</div>
) : filtered.length === 0 ? (
<div className="text-muted-foreground mx-4 flex items-center justify-center rounded-lg border border-dashed py-20 text-sm lg:mx-6">
没有匹配的任务。
</div>
) : (
<Table className="**:data-[slot='table-cell']:px-4 **:data-[slot='table-head']:px-4">
<TableHeader className="[&_tr]:border-t">
<TableRow>
<TableHead className="font-mono">ID</TableHead>
<TableHead>描述</TableHead>
<TableHead>目标</TableHead>
<TableHead>状态</TableHead>
<TableHead className="text-center">目标进度</TableHead>
<TableHead className="text-right">创建时间</TableHead>
<TableHead className="text-right">运行时长</TableHead>
<TableHead className="text-right">Token</TableHead>
<TableHead className="sticky right-0 z-10 bg-card text-right shadow-[-1px_0_0_0_hsl(var(--border))]">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{paginated.map((task) => (
<TaskRow
key={task.id}
task={task}
// running 任务才吃 nowSec;其余行传 0 —— props 不变,memo 就能拦下每秒 tick
// 带来的整表重渲染,只让在跑的那几行走时长。
nowSec={task.status === "running" ? nowSec : 0}
onDelete={deleteTask}
/>
))}
</TableBody>
</Table>
)}
<TablePagination
page={page}
pageSize={pageSize}
total={filtered.length}
onPageChange={setPage}
onPageSizeChange={setPageSize}
/>
</CardContent>
</Card>
);
}
// TaskRow renders one row of the task table. Memoized so the per-second 运行时长 tick and
// the POLL_MS list refresh only re-render the rows whose data actually moved — a table page
// is 20 rows × (StatusBadge + Link + a Radix AlertDialog tree), far too heavy to rebuild
// wholesale on every parent render.
const TaskRow = React.memo(function TaskRow({
task,
nowSec,
onDelete,
}: {
task: Task;
nowSec: number;
onDelete: (id: string) => void;
}) {
return (
<TableRow className="group border-border/60">
<TableCell>
<code className="bg-muted rounded px-1.5 py-0.5 font-mono text-xs">{task.id}</code>
</TableCell>
<TableCell className="font-medium">
<div className="flex max-w-xs items-center gap-2">
<span className="truncate" title={task.description}>
{task.description}
</span>
{task.active && (
<StarIcon className="size-4 shrink-0 fill-amber-400 text-amber-400" />
)}
</div>
</TableCell>
<TableCell className="text-muted-foreground max-w-xs truncate">{task.goal}</TableCell>
<TableCell>
<StatusBadge domain="task" value={task.status} dot />
</TableCell>
<TableCell className="text-muted-foreground text-center text-xs tabular-nums">
{typeof task.goals_total === "number" && task.goals_total > 0
? `${task.goals_met}/${task.goals_total}`
: <span className="text-muted-foreground">—</span>}
</TableCell>
<TableCell className="text-muted-foreground text-right text-xs whitespace-nowrap tabular-nums">
{fmtDateTime(task.created_unix)}
</TableCell>
<TableCell className="text-right text-xs whitespace-nowrap tabular-nums">
{(() => {
const secs = taskDuration(task, nowSec);
if (secs <= 0) return <span className="text-muted-foreground">—</span>;
return (
<span className={task.status === "running" ? "text-foreground" : "text-muted-foreground"}>
{fmtDuration(secs)}
</span>
);
})()}
</TableCell>
<TableCell
className="text-right text-xs whitespace-nowrap tabular-nums"
title={
task.tokens
? `输入 ${task.tokens.input_tokens} · 缓存 ${task.tokens.cache_read_tokens} · 输出 ${task.tokens.output_tokens}`
: undefined
}
>
{task.tokens ? (
<span className="text-muted-foreground">
入 <span className="text-foreground">{fmtTokens(task.tokens.input_tokens)}</span>
{" · 缓 "}
<span className="text-foreground">{fmtTokens(task.tokens.cache_read_tokens)}</span>
{" · 出 "}
<span className="text-foreground">{fmtTokens(task.tokens.output_tokens)}</span>
</span>
) : (
"—"
)}
</TableCell>
<TableCell className="sticky right-0 z-10 bg-card text-right shadow-[-1px_0_0_0_hsl(var(--border))] group-hover:bg-muted/50">
<div className="flex items-center justify-end gap-2">
<Button asChild size="sm">
<Link href={`/function/tasks/detail?id=${task.id}`}>
进入 <ArrowRightIcon />
</Link>
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button size="icon" variant="outline" aria-label="删除任务">
<Trash2Icon className="text-destructive" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认删除任务 #{task.id}?</AlertDialogTitle>
<AlertDialogDescription>
{task.description
? `「${task.description.length > 80 ? task.description.slice(0, 80) + "…" : task.description}」`
: "该任务"}
的执行记录、会话与产物将被删除,此操作不可撤销(全局资产图保留)。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction onClick={() => onDelete(task.id)}>
删除
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</TableCell>
</TableRow>
);
});
// CreateTaskSheet is the 新建任务 drawer. Its form state lives HERE, not in TasksPage: with
// 描述/目标 held by the page component every keystroke re-rendered the whole task table
// behind the drawer (plus its sticky column and 20 AlertDialog trees), which showed up as
// input lag. Now typing only re-renders the drawer.
function CreateTaskSheet({ onCreated }: { onCreated: () => void }) {
const [open, setOpen] = React.useState(false);
const [description, setDescription] = React.useState("");
const [goal, setGoal] = React.useState("");
const [profiles, setProfiles] = React.useState<LLMProfile[]>([]);
const [llmProfile, setLlmProfile] = React.useState<string>(ACTIVE_PROFILE); // sentinel = active
const [timeoutMin, setTimeoutMin] = React.useState(""); // 任务级超时(分钟);空/0 = 不限时
const [heartbeatMin, setHeartbeatMin] = React.useState("10"); // planner 心跳(分钟);默认10,下限10(与后端一致)
const [seedFirstIntent, setSeedFirstIntent] = React.useState(false); // 创建时下发种子意图,worker 免等首轮 planner 直接开跑;默认关闭,走标准先规划再执行
// 方式1 文件上传:建任务前把文件暂存到 drafts/<draftId>/uploads/,拿回绝对路径追加进描述。
const [uploading, setUploading] = React.useState(false);
const [uploadCount, setUploadCount] = React.useState(0);
const draftIdRef = React.useRef<string>("");
const fileInputRef = React.useRef<HTMLInputElement>(null);
// load LLM profiles once for the create-task profile picker.
React.useEffect(() => {
api.llmProfiles().then(setProfiles).catch(() => setProfiles([]));
}, []);
// tick every second so running tasks' 运行时长 counts up live.
React.useEffect(() => {
const i = setInterval(() => setNowSec(Math.floor(Date.now() / 1000)), 1000);
return () => clearInterval(i);
}, []);
// pickFiles uploads the chosen files into this draft's staging dir and appends their
// absolute paths to the description; the task's agents open them by path via Read/Bash.
async function pickFiles(files: FileList | null) {
@@ -256,342 +490,163 @@ export default function TasksPage() {
setUploadCount(0);
draftIdRef.current = "";
setOpen(false);
load();
onCreated();
} catch (e) {
toast.error("创建失败:" + (e as Error).message);
}
}
async function deleteTask(id: string) {
try {
await api.deleteTask(id);
toast.success("任务已删除(全局资产图保留)");
load();
} catch (e) {
toast.error("删除失败:" + (e as Error).message);
}
}
return (
<Card>
<CardContent className="flex flex-col gap-4 px-0 pt-6">
<div className="flex flex-wrap items-center gap-2 px-4 lg:px-6">
<div className="relative w-full sm:max-w-xs">
<SearchIcon className="text-muted-foreground pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2" />
<Input
placeholder="搜索描述 / 目标 / ID"
value={query}
onChange={(e) => setQuery(e.target.value)}
className="pl-8"
/>
{query && (
<button
type="button"
onClick={() => setQuery("")}
aria-label="清除搜索"
className="text-muted-foreground hover:text-foreground absolute top-1/2 right-2 -translate-y-1/2"
>
<XIcon className="size-4" />
</button>
)}
</div>
<Select value={statusFilter} onValueChange={(v) => setStatusFilter(v as TaskStatus | "all")}>
<SelectTrigger className="w-36">
<SelectValue placeholder="状态" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">全部状态</SelectItem>
{STATUS_OPTIONS.map((s) => (
<SelectItem key={s.value} value={s.value}>
{s.label}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-muted-foreground text-xs tabular-nums">
{filtered.length}/{tasks.length} 条
</span>
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<Button size="sm" className="ml-auto">
<PlusIcon /> 新建任务
</Button>
</SheetTrigger>
{/* 45vw 宽的右侧抽屉:整屏高度可滚动,长表单不再受弹窗高度限制。窄屏退化为全宽。
内容为 flex 列:头/脚固定,中间字段区 flex-1 独立滚动。 */}
<SheetContent
side="right"
className="w-full! max-w-none! gap-0 p-0 sm:w-[45vw]! sm:max-w-[45vw]!"
>
<SheetHeader className="border-b p-6">
<SheetTitle>新建任务</SheetTitle>
<SheetDescription>填写测试对象与目标,高级参数可按需展开。</SheetDescription>
</SheetHeader>
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<Button size="sm" className="ml-auto">
<PlusIcon /> 新建任务
</Button>
</SheetTrigger>
{/* 45vw 宽的右侧抽屉:整屏高度可滚动,长表单不再受弹窗高度限制。窄屏退化为全宽。
内容为 flex 列:头/脚固定,中间字段区 flex-1 独立滚动。 */}
<SheetContent
side="right"
className="w-full! max-w-none! gap-0 p-0 sm:w-[45vw]! sm:max-w-[45vw]!"
>
<SheetHeader className="border-b p-6">
<SheetTitle>新建任务</SheetTitle>
<SheetDescription>填写测试对象与目标,高级参数可按需展开。</SheetDescription>
</SheetHeader>
<div className="flex-1 overflow-y-auto p-6">
<div className="grid gap-5">
<div className="grid gap-2">
<Label htmlFor="description">描述</Label>
<Textarea
id="description"
className="min-h-32"
placeholder="测试对象与背景,例如:测试 example.com 这个站点"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
{/* 上传文件(可多选):暂存到 drafts/,把绝对路径追加进上方描述,worker 据此 Read/Bash 打开。 */}
<div className="flex flex-wrap items-center gap-2">
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={(e) => void pickFiles(e.target.files)}
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
>
{uploading ? (
<Loader2Icon className="animate-spin" />
) : (
<PaperclipIcon />
)}
上传文件
</Button>
<span className="text-muted-foreground text-xs">
{uploadCount > 0
? `已上传 ${uploadCount} 个文件,绝对路径已追加到描述末尾(可编辑)`
: "可多选;上传后把文件的绝对路径追加到描述,供 worker 用 Read/Bash 打开"}
</span>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="goal">目标</Label>
<Textarea
id="goal"
className="min-h-32"
placeholder="要达成什么,例如:拿下后台管理权限、获取服务器权限"
value={goal}
onChange={(e) => setGoal(e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="llm-profile">LLM 配置</Label>
<Select value={llmProfile} onValueChange={setLlmProfile}>
<SelectTrigger id="llm-profile">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={ACTIVE_PROFILE}>使用激活配置(默认)</SelectItem>
{profiles.map((p) => (
<SelectItem key={p.id} value={String(p.id)}>
{p.name}
{p.is_default ? "(激活)" : ""} · {p.model}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-muted-foreground text-xs">
本任务的 planner/worker 使用所选配置运行;对话仍用激活配置。
</p>
</div>
{/* 高级参数默认折叠:超时/心跳/首个意图,展开才占空间,常用路径保持清爽。 */}
<Collapsible>
<CollapsibleTrigger className="group flex w-full items-center gap-2 border-t pt-4 text-sm font-medium">
<ChevronRightIcon className="text-muted-foreground size-4 transition-transform group-data-[state=open]:rotate-90" />
高级设置
<span className="text-muted-foreground ml-auto text-xs font-normal">
超时 · 心跳 · 首个意图
</span>
</CollapsibleTrigger>
<CollapsibleContent className="grid gap-5 pt-5">
<div className="grid gap-2">
<Label htmlFor="timeout-min">任务超时(分钟,可选)</Label>
<Input
id="timeout-min"
type="number"
min={0}
className="w-40"
placeholder="留空 = 不限时"
value={timeoutMin}
onChange={(e) => setTimeoutMin(e.target.value)}
/>
<p className="text-muted-foreground text-xs">
到点后触发优雅收尾(各 agent 写回 + planner 终局判定),任务进入 timeout 终态。
</p>
</div>
<div className="grid gap-2">
<Label htmlFor="heartbeat-min">planner 心跳(分钟)</Label>
<Input
id="heartbeat-min"
type="number"
min={10}
className="w-40"
placeholder="默认 10"
value={heartbeatMin}
onChange={(e) => setHeartbeatMin(e.target.value)}
/>
<p className="text-muted-foreground text-xs">
距上轮规划结束/任务开始满该时长且期间无触发,自动触发一轮规划(兜底卡死 + 唤醒去监督在跑的 worker)。下限 10 分钟。
</p>
</div>
<div className="grid gap-2">
<label className="flex items-center gap-2 text-sm">
<Checkbox checked={seedFirstIntent} onCheckedChange={(v) => setSeedFirstIntent(!!v)} />
直接下发首个意图(描述+目标)
</label>
<p className="text-muted-foreground text-xs">
开启后创建即把「描述+目标」作为一条意图下发,worker 免等首轮规划直接开跑,跑完再由 planner 接手判定/补充。CTF 等常一个 work 直接解决的场景推荐开启;关闭则走标准的先规划再执行。
</p>
</div>
</CollapsibleContent>
</Collapsible>
<div className="flex-1 overflow-y-auto p-6">
<div className="grid gap-5">
<div className="grid gap-2">
<Label htmlFor="description">描述</Label>
<Textarea
id="description"
className="min-h-32"
placeholder="测试对象与背景,例如:测试 example.com 这个站点"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
{/* 上传文件(可多选):暂存到 drafts/,把绝对路径追加进上方描述,worker 据此 Read/Bash 打开。 */}
<div className="flex flex-wrap items-center gap-2">
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={(e) => void pickFiles(e.target.files)}
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
>
{uploading ? (
<Loader2Icon className="animate-spin" />
) : (
<PaperclipIcon />
)}
上传文件
</Button>
<span className="text-muted-foreground text-xs">
{uploadCount > 0
? `已上传 ${uploadCount} 个文件,绝对路径已追加到描述末尾(可编辑)`
: "可多选;上传后把文件的绝对路径追加到描述,供 worker 用 Read/Bash 打开"}
</span>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="goal">目标</Label>
<Textarea
id="goal"
className="min-h-32"
placeholder="要达成什么,例如:拿下后台管理权限、获取服务器权限"
value={goal}
onChange={(e) => setGoal(e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="llm-profile">LLM 配置</Label>
<Select value={llmProfile} onValueChange={setLlmProfile}>
<SelectTrigger id="llm-profile">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={ACTIVE_PROFILE}>使用激活配置(默认)</SelectItem>
{profiles.map((p) => (
<SelectItem key={p.id} value={String(p.id)}>
{p.name}
{p.is_default ? "(激活)" : ""} · {p.model}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-muted-foreground text-xs">
本任务的 planner/worker 使用所选配置运行;对话仍用激活配置。
</p>
</div>
<SheetFooter className="flex-row justify-end gap-2 border-t p-4">
<SheetClose asChild>
<Button variant="outline">取消</Button>
</SheetClose>
<Button onClick={createTask}>创建</Button>
</SheetFooter>
</SheetContent>
</Sheet>
</div>
{/* 高级参数默认折叠:超时/心跳/首个意图,展开才占空间,常用路径保持清爽。 */}
<Collapsible>
<CollapsibleTrigger className="group flex w-full items-center gap-2 border-t pt-4 text-sm font-medium">
<ChevronRightIcon className="text-muted-foreground size-4 transition-transform group-data-[state=open]:rotate-90" />
高级设置
<span className="text-muted-foreground ml-auto text-xs font-normal">
超时 · 心跳 · 首个意图
</span>
</CollapsibleTrigger>
<CollapsibleContent className="grid gap-5 pt-5">
<div className="grid gap-2">
<Label htmlFor="timeout-min">任务超时(分钟,可选)</Label>
<Input
id="timeout-min"
type="number"
min={0}
className="w-40"
placeholder="留空 = 不限时"
value={timeoutMin}
onChange={(e) => setTimeoutMin(e.target.value)}
/>
<p className="text-muted-foreground text-xs">
到点后触发优雅收尾(各 agent 写回 + planner 终局判定),任务进入 timeout 终态。
</p>
</div>
<div className="grid gap-2">
<Label htmlFor="heartbeat-min">planner 心跳(分钟)</Label>
<Input
id="heartbeat-min"
type="number"
min={10}
className="w-40"
placeholder="默认 10"
value={heartbeatMin}
onChange={(e) => setHeartbeatMin(e.target.value)}
/>
<p className="text-muted-foreground text-xs">
距上轮规划结束/任务开始满该时长且期间无触发,自动触发一轮规划(兜底卡死 + 唤醒去监督在跑的 worker)。下限 10 分钟。
</p>
</div>
<div className="grid gap-2">
<label className="flex items-center gap-2 text-sm">
<Checkbox checked={seedFirstIntent} onCheckedChange={(v) => setSeedFirstIntent(!!v)} />
直接下发首个意图(描述+目标)
</label>
<p className="text-muted-foreground text-xs">
开启后创建即把「描述+目标」作为一条意图下发,worker 免等首轮规划直接开跑,跑完再由 planner 接手判定/补充。CTF 等常一个 work 直接解决的场景推荐开启;关闭则走标准的先规划再执行。
</p>
</div>
</CollapsibleContent>
</Collapsible>
</div>
</div>
{tasks.length === 0 ? (
<div className="text-muted-foreground mx-4 flex items-center justify-center rounded-lg border border-dashed py-20 text-sm lg:mx-6">
暂无任务,点击右上角「新建任务」开始。
</div>
) : filtered.length === 0 ? (
<div className="text-muted-foreground mx-4 flex items-center justify-center rounded-lg border border-dashed py-20 text-sm lg:mx-6">
没有匹配的任务。
</div>
) : (
<Table className="**:data-[slot='table-cell']:px-4 **:data-[slot='table-head']:px-4">
<TableHeader className="[&_tr]:border-t">
<TableRow>
<TableHead className="font-mono">ID</TableHead>
<TableHead>描述</TableHead>
<TableHead>目标</TableHead>
<TableHead>状态</TableHead>
<TableHead className="text-center">目标进度</TableHead>
<TableHead className="text-right">创建时间</TableHead>
<TableHead className="text-right">运行时长</TableHead>
<TableHead className="text-right">Token</TableHead>
<TableHead className="sticky right-0 z-10 bg-card text-right shadow-[-1px_0_0_0_hsl(var(--border))]">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{paginated.map((task) => (
<TableRow key={task.id} className="group border-border/60">
<TableCell>
<code className="bg-muted rounded px-1.5 py-0.5 font-mono text-xs">{task.id}</code>
</TableCell>
<TableCell className="font-medium">
<div className="flex max-w-xs items-center gap-2">
<span className="truncate" title={task.description}>
{task.description}
</span>
{task.active && (
<StarIcon className="size-4 shrink-0 fill-amber-400 text-amber-400" />
)}
</div>
</TableCell>
<TableCell className="text-muted-foreground max-w-xs truncate">{task.goal}</TableCell>
<TableCell>
<StatusBadge domain="task" value={task.status} dot />
</TableCell>
<TableCell className="text-muted-foreground text-center text-xs tabular-nums">
{typeof task.goals_total === "number" && task.goals_total > 0
? `${task.goals_met}/${task.goals_total}`
: <span className="text-muted-foreground">—</span>}
</TableCell>
<TableCell className="text-muted-foreground text-right text-xs whitespace-nowrap tabular-nums">
{fmtDateTime(task.created_unix)}
</TableCell>
<TableCell className="text-right text-xs whitespace-nowrap tabular-nums">
{(() => {
const secs = taskDuration(task, nowSec);
if (secs <= 0) return <span className="text-muted-foreground">—</span>;
return (
<span className={task.status === "running" ? "text-foreground" : "text-muted-foreground"}>
{fmtDuration(secs)}
</span>
);
})()}
</TableCell>
<TableCell
className="text-right text-xs whitespace-nowrap tabular-nums"
title={
task.tokens
? `输入 ${task.tokens.input_tokens} · 缓存 ${task.tokens.cache_read_tokens} · 输出 ${task.tokens.output_tokens}`
: undefined
}
>
{task.tokens ? (
<span className="text-muted-foreground">
入 <span className="text-foreground">{fmtTokens(task.tokens.input_tokens)}</span>
{" · 缓 "}
<span className="text-foreground">{fmtTokens(task.tokens.cache_read_tokens)}</span>
{" · 出 "}
<span className="text-foreground">{fmtTokens(task.tokens.output_tokens)}</span>
</span>
) : (
"—"
)}
</TableCell>
<TableCell className="sticky right-0 z-10 bg-card text-right shadow-[-1px_0_0_0_hsl(var(--border))] group-hover:bg-muted/50">
<div className="flex items-center justify-end gap-2">
<Button asChild size="sm">
<Link href={`/function/tasks/detail?id=${task.id}`}>
进入 <ArrowRightIcon />
</Link>
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button size="icon" variant="outline" aria-label="删除任务">
<Trash2Icon className="text-destructive" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认删除任务 #{task.id}?</AlertDialogTitle>
<AlertDialogDescription>
{task.description
? `「${task.description.length > 80 ? task.description.slice(0, 80) + "…" : task.description}」`
: "该任务"}
的执行记录、会话与产物将被删除,此操作不可撤销(全局资产图保留)。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction onClick={() => deleteTask(task.id)}>
删除
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
<TablePagination
page={page}
pageSize={pageSize}
total={filtered.length}
onPageChange={setPage}
onPageSizeChange={setPageSize}
/>
</CardContent>
</Card>
<SheetFooter className="flex-row justify-end gap-2 border-t p-4">
<SheetClose asChild>
<Button variant="outline">取消</Button>
</SheetClose>
<Button onClick={createTask}>创建</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
+5
View File
@@ -254,6 +254,11 @@ export const api = {
patch<Finding>(`/exploration/findings/${id}`, { status }),
setFindingSeverity: (id: string, severity: Severity) =>
patch<Finding>(`/exploration/findings/${id}`, { severity }),
// 一次保存漏洞的名称/类别/严重等级(发现列表行内编辑用),只传出现的字段。
updateFinding: (
id: string,
fields: { name?: string; vulnclass?: string; severity?: Severity; status?: FindingStatus },
) => patch<Finding>(`/exploration/findings/${id}`, fields),
intents: (task?: string) => get<TaskNode[]>(`/exploration/intents${tq(task)}`).then(arr),
tokenStats: (task?: string) =>
get<{ workers: TokenUsage[]; total: TokenTotal }>(`/exploration/tokens${tq(task)}`).then((r) => ({
+10
View File
@@ -292,6 +292,15 @@ export interface FindingStats {
medium: number;
low: number;
vulnclasses: string[];
tasks: FindingTaskOption[];
}
// FindingTaskOption 是发现页「按任务」筛选下拉的一项:有漏洞的任务(描述为空表示任务已删除,
// 前端回退展示 id)及其漏洞条数。
export interface FindingTaskOption {
id: number;
description: string;
count: number;
}
// FindingQuery 是发现列表分页/筛选/排序参数。
@@ -301,6 +310,7 @@ export interface FindingQuery {
severity?: "all" | Severity;
status?: "all" | FindingStatus;
vulnclass?: string;
task?: string; // 任务 id;"all"/空 = 不按任务筛选
sort?: "severity" | "time";
}