feat(web): 任务详情页新增探索链路播报板
按创建时间分页平铺展示探索节点,作为探索链路图的时间轴视图, 复用同一份 exploration_nodes 数据,不影响链路图。 - 后端 NodesPage/NodesByIDs/EdgesTouching:分页下推 SQL, 支持 kind/state 过滤、payload/origin 搜索、正倒序, 并补出当页节点的相邻边与对端节点 - 新增 GET /api/exploration/nodes(仅本任务节点,不含继承) - 播报板:时间轴分行、按天分组、类型快筛、搜索防抖、 自动刷新、直播位未读提示、展开看上下游与 payload - 补 mock,NEXT_PUBLIC_MOCK=1 下可无后端预览
This commit is contained in:
1 parent
13ce2ea6e3
commit
c03e008d7b
7 files changed
+1053
-37
No files matched your search
@@ -0,0 +1,639 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import {
|
||||
ArrowDownIcon,
|
||||
ArrowUpIcon,
|
||||
ArrowUpToLineIcon,
|
||||
BugIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
CompassIcon,
|
||||
FlagIcon,
|
||||
FlaskConicalIcon,
|
||||
LayersIcon,
|
||||
LightbulbIcon,
|
||||
type LucideIcon,
|
||||
PauseIcon,
|
||||
PlayIcon,
|
||||
SearchIcon,
|
||||
TargetIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
import { StatusBadge } from "@/components/status-badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardFooter } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { api } from "@/lib/api";
|
||||
import { type Tone, toneClasses, toneDot } from "@/lib/status";
|
||||
import type { Edge, ExploreKind, TaskNode } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const PAGE_SIZES = [20, 50, 100];
|
||||
const POLL_MS = 8000;
|
||||
|
||||
type KindMeta = { label: string; icon: LucideIcon; dot: string; chip: string };
|
||||
|
||||
// 播报板自己的展示元数据。刻意不复用探索链路图那份:图是拓扑视角(节点卡片、连线配色),
|
||||
// 播报是流水视角(时间轴行),两边的信息密度和配色需求不同,各自演进更省事。
|
||||
const KIND_META: Record<string, KindMeta> = {
|
||||
begin: {
|
||||
label: "起点",
|
||||
icon: FlagIcon,
|
||||
dot: "bg-slate-500",
|
||||
chip: "bg-slate-500/15 text-slate-600 dark:text-slate-300",
|
||||
},
|
||||
task: {
|
||||
label: "根任务",
|
||||
icon: FlagIcon,
|
||||
dot: "bg-slate-500",
|
||||
chip: "bg-slate-500/15 text-slate-600 dark:text-slate-300",
|
||||
},
|
||||
goal: {
|
||||
label: "目标",
|
||||
icon: TargetIcon,
|
||||
dot: "bg-emerald-500",
|
||||
chip: "bg-emerald-500/15 text-emerald-600 dark:text-emerald-400",
|
||||
},
|
||||
intent: {
|
||||
label: "意图",
|
||||
icon: CompassIcon,
|
||||
dot: "bg-blue-500",
|
||||
chip: "bg-blue-500/15 text-blue-600 dark:text-blue-400",
|
||||
},
|
||||
fact: {
|
||||
label: "事实",
|
||||
icon: FlaskConicalIcon,
|
||||
dot: "bg-amber-500",
|
||||
chip: "bg-amber-500/15 text-amber-600 dark:text-amber-400",
|
||||
},
|
||||
finding: {
|
||||
label: "漏洞",
|
||||
icon: BugIcon,
|
||||
dot: "bg-rose-500",
|
||||
chip: "bg-rose-500/15 text-rose-600 dark:text-rose-400",
|
||||
},
|
||||
hint: {
|
||||
label: "提示",
|
||||
icon: LightbulbIcon,
|
||||
dot: "bg-violet-500",
|
||||
chip: "bg-violet-500/15 text-violet-600 dark:text-violet-400",
|
||||
},
|
||||
digest: {
|
||||
label: "压缩",
|
||||
icon: LayersIcon,
|
||||
dot: "bg-teal-500",
|
||||
chip: "bg-teal-500/15 text-teal-600 dark:text-teal-400",
|
||||
},
|
||||
};
|
||||
|
||||
// 可筛选的类型。起点(fact/state=origin)不单独列,它跟着「事实」一起过滤。
|
||||
const FILTER_KINDS: ExploreKind[] = ["goal", "intent", "fact", "finding", "hint", "digest"];
|
||||
|
||||
const REL_LABEL: Record<string, string> = {
|
||||
spawns: "派生",
|
||||
derived_from: "意图链",
|
||||
yields: "产出",
|
||||
proves: "证明",
|
||||
covers: "压缩",
|
||||
};
|
||||
|
||||
// goal / intent 的状态语义由全局 status 表提供(StatusBadge);其余类型的状态只在
|
||||
// 图和播报里出现,这里补一份。
|
||||
const STATE_META: Record<string, Record<string, { label: string; tone: Tone }>> = {
|
||||
fact: {
|
||||
origin: { label: "起点", tone: "slate" },
|
||||
confirmed: { label: "已确认", tone: "green" },
|
||||
dismissed: { label: "已否定", tone: "slate" },
|
||||
},
|
||||
finding: {
|
||||
confirmed: { label: "已确认", tone: "red" },
|
||||
dismissed: { label: "已排除", tone: "slate" },
|
||||
},
|
||||
hint: {
|
||||
active: { label: "待采纳", tone: "violet" },
|
||||
consumed: { label: "已采纳", tone: "slate" },
|
||||
},
|
||||
digest: {
|
||||
active: { label: "生效中", tone: "green" },
|
||||
superseded: { label: "已替代", tone: "slate" },
|
||||
},
|
||||
};
|
||||
|
||||
// 任务根是 state=origin 的 fact,播报里读作「起点」。
|
||||
function viewKind(n: TaskNode): string {
|
||||
return n.type === "fact" && n.state === "origin" ? "begin" : n.type;
|
||||
}
|
||||
|
||||
const SUMMARY_FIELDS: Record<string, string[]> = {
|
||||
begin: ["summary", "description"],
|
||||
task: ["summary", "description"],
|
||||
goal: ["text"],
|
||||
intent: ["summary"],
|
||||
fact: ["summary"],
|
||||
finding: ["name", "summary"],
|
||||
hint: ["text", "summary"],
|
||||
digest: ["body", "summary"],
|
||||
};
|
||||
|
||||
function summaryOf(n: TaskNode): string {
|
||||
const raw = n.payload ?? "";
|
||||
if (!raw.trim()) return "";
|
||||
for (const field of SUMMARY_FIELDS[viewKind(n)] ?? []) {
|
||||
try {
|
||||
const obj: unknown = JSON.parse(raw);
|
||||
if (obj && typeof obj === "object") {
|
||||
const v = (obj as Record<string, unknown>)[field];
|
||||
if (typeof v === "string" && v.trim()) return v;
|
||||
}
|
||||
} catch {
|
||||
return raw; // 非 JSON payload:原样播报
|
||||
}
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
function prettyPayload(raw?: string): string {
|
||||
if (!raw?.trim()) return "(无 payload)";
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(raw), null, 2);
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
function relTime(ts: number, now: number): string {
|
||||
if (!now || !ts) return "";
|
||||
const sec = Math.max(0, (now - ts) / 1000);
|
||||
if (sec < 60) return "刚刚";
|
||||
if (sec < 3600) return `${Math.floor(sec / 60)} 分钟前`;
|
||||
if (sec < 86400) return `${Math.floor(sec / 3600)} 小时前`;
|
||||
return `${Math.floor(sec / 86400)} 天前`;
|
||||
}
|
||||
|
||||
const dayFmt = new Intl.DateTimeFormat("zh-CN", { month: "long", day: "numeric", weekday: "short" });
|
||||
const clockFmt = new Intl.DateTimeFormat("zh-CN", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
|
||||
function NodeStateBadge({ node }: { node: TaskNode }) {
|
||||
if (node.type === "goal") return <StatusBadge domain="goal" value={node.state} dot />;
|
||||
if (node.type === "intent") return <StatusBadge domain="intent" value={node.state} dot />;
|
||||
const meta = STATE_META[node.type]?.[node.state];
|
||||
if (!meta) return null;
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs font-medium whitespace-nowrap",
|
||||
toneClasses[meta.tone],
|
||||
)}
|
||||
>
|
||||
<span className={cn("size-1.5 rounded-full", toneDot[meta.tone])} />
|
||||
{meta.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function KindChip({ kind }: { kind: string }) {
|
||||
const meta = KIND_META[kind] ?? KIND_META.fact;
|
||||
return (
|
||||
<span className={cn("rounded px-1.5 py-0.5 text-xs font-medium whitespace-nowrap", meta.chip)}>{meta.label}</span>
|
||||
);
|
||||
}
|
||||
|
||||
// 一条播报涉及的上下游:上游 = 指向本节点的边,下游 = 本节点指出去的边。
|
||||
function RelatedList({
|
||||
title,
|
||||
rows,
|
||||
refs,
|
||||
}: {
|
||||
title: string;
|
||||
rows: Array<{ rel: string; id: string }>;
|
||||
refs: Record<string, TaskNode>;
|
||||
}) {
|
||||
if (rows.length === 0) return null;
|
||||
return (
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-1.5 text-xs font-medium text-muted-foreground">{title}</div>
|
||||
<ul className="flex flex-col gap-1.5">
|
||||
{rows.map((row) => {
|
||||
const node = refs[row.id];
|
||||
return (
|
||||
<li key={`${row.rel}-${row.id}`} className="flex min-w-0 items-center gap-2 text-xs">
|
||||
<span className="shrink-0 rounded bg-muted px-1.5 py-0.5 text-muted-foreground">
|
||||
{REL_LABEL[row.rel] ?? row.rel}
|
||||
</span>
|
||||
{node ? (
|
||||
<>
|
||||
<KindChip kind={viewKind(node)} />
|
||||
<span className="truncate" title={summaryOf(node)}>
|
||||
{summaryOf(node) || `节点 #${node.id}`}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-muted-foreground">节点 #{row.id}</span>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BroadcastRow({
|
||||
node,
|
||||
edges,
|
||||
refs,
|
||||
now,
|
||||
fresh,
|
||||
}: {
|
||||
node: TaskNode;
|
||||
edges: Edge[];
|
||||
refs: Record<string, TaskNode>;
|
||||
now: number;
|
||||
fresh: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const kind = viewKind(node);
|
||||
const meta = KIND_META[kind] ?? KIND_META.fact;
|
||||
const Icon = meta.icon;
|
||||
const ts = Date.parse(node.ts);
|
||||
const summary = summaryOf(node);
|
||||
const upstream = edges.filter((e) => e.dst === node.id).map((e) => ({ rel: e.rel, id: e.src }));
|
||||
const downstream = edges.filter((e) => e.src === node.id).map((e) => ({ rel: e.rel, id: e.dst }));
|
||||
|
||||
return (
|
||||
<div className={cn("relative grid grid-cols-[4.5rem_1.75rem_1fr] gap-x-2", fresh && "bg-primary/5")}>
|
||||
{/* 时间列 */}
|
||||
<div className="py-3 text-right text-xs text-muted-foreground tabular-nums">
|
||||
<div>{Number.isNaN(ts) ? "--:--:--" : clockFmt.format(ts)}</div>
|
||||
<div className="text-[11px] opacity-70">{relTime(ts, now)}</div>
|
||||
</div>
|
||||
|
||||
{/* 时间轴:竖线 + 类型圆点 */}
|
||||
<div className="relative flex justify-center">
|
||||
<span className="absolute inset-y-0 w-px bg-border" />
|
||||
<span
|
||||
className={cn(
|
||||
"relative mt-3.5 flex size-6 items-center justify-center rounded-full text-white ring-4 ring-background",
|
||||
meta.dot,
|
||||
)}
|
||||
>
|
||||
<Icon className="size-3.5" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 内容列 */}
|
||||
<div className="min-w-0 border-b py-3 pr-1 last:border-b-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="flex w-full min-w-0 items-start gap-2 text-left"
|
||||
>
|
||||
<ChevronRightIcon
|
||||
className={cn("mt-0.5 size-3.5 shrink-0 text-muted-foreground transition-transform", open && "rotate-90")}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
|
||||
<KindChip kind={kind} />
|
||||
<NodeStateBadge node={node} />
|
||||
{node.priority > 0 && (kind === "goal" || kind === "intent") && (
|
||||
<span className="rounded bg-muted px-1.5 py-0.5 text-xs text-muted-foreground">P{node.priority}</span>
|
||||
)}
|
||||
{fresh && (
|
||||
<span className="rounded bg-primary px-1.5 py-0.5 text-[10px] font-semibold text-primary-foreground">
|
||||
新
|
||||
</span>
|
||||
)}
|
||||
<span className="ml-auto shrink-0 text-xs text-muted-foreground">{node.origin || "system"}</span>
|
||||
</div>
|
||||
<p className={cn("mt-1 text-sm", !open && "line-clamp-2")}>{summary || `节点 #${node.id}`}</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="mt-2 ml-5 flex flex-col gap-3 rounded-md border bg-muted/30 p-3">
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||
<span>
|
||||
节点 <code className="font-mono">#{node.id}</code>
|
||||
</span>
|
||||
<span>类型 {meta.label}</span>
|
||||
<span>来源 {node.origin || "system"}</span>
|
||||
<span>{Number.isNaN(ts) ? node.ts : new Date(ts).toLocaleString("zh-CN")}</span>
|
||||
</div>
|
||||
{(upstream.length > 0 || downstream.length > 0) && (
|
||||
<div className="flex flex-col gap-3 sm:flex-row">
|
||||
<RelatedList title="上游 · 由此而来" rows={upstream} refs={refs} />
|
||||
<RelatedList title="下游 · 由此产生" rows={downstream} refs={refs} />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<div className="mb-1.5 text-xs font-medium text-muted-foreground">payload</div>
|
||||
<pre className="max-h-64 overflow-auto rounded-md border bg-background p-3 font-mono text-xs whitespace-pre-wrap">
|
||||
{prettyPayload(node.payload)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BroadcastTab({ taskId }: { taskId: string }) {
|
||||
const [kinds, setKinds] = React.useState<ExploreKind[]>([]);
|
||||
const [queryInput, setQueryInput] = React.useState("");
|
||||
const [query, setQuery] = React.useState("");
|
||||
const [order, setOrder] = React.useState<"asc" | "desc">("desc");
|
||||
const [page, setPage] = React.useState(1);
|
||||
const [size, setSize] = React.useState(20);
|
||||
const [live, setLive] = React.useState(true);
|
||||
|
||||
const [items, setItems] = React.useState<TaskNode[]>([]);
|
||||
const [edges, setEdges] = React.useState<Edge[]>([]);
|
||||
const [refs, setRefs] = React.useState<Record<string, TaskNode>>({});
|
||||
const [total, setTotal] = React.useState(0);
|
||||
const [loaded, setLoaded] = React.useState(false);
|
||||
const [freshIDs, setFreshIDs] = React.useState<Set<string>>(new Set());
|
||||
const [pending, setPending] = React.useState(0);
|
||||
const [now, setNow] = React.useState(0);
|
||||
|
||||
const seenRef = React.useRef<Set<string>>(new Set());
|
||||
const baselineRef = React.useRef<number | null>(null);
|
||||
const streamRef = React.useRef("");
|
||||
// 只有「最新在前的第 1 页」才是真正的直播位;其余位置轮询只更新未读计数,
|
||||
// 不动列表,免得翻页/展开时内容在脚下变。
|
||||
const atLive = page === 1 && order === "desc";
|
||||
|
||||
React.useEffect(() => {
|
||||
setNow(Date.now());
|
||||
const t = setInterval(() => setNow(Date.now()), 30000);
|
||||
return () => clearInterval(t);
|
||||
}, []);
|
||||
|
||||
// 输入防抖:打字停 300ms 才真正查询,并回到第一页。
|
||||
React.useEffect(() => {
|
||||
const t = setTimeout(() => {
|
||||
setQuery(queryInput);
|
||||
setPage(1);
|
||||
}, 300);
|
||||
return () => clearTimeout(t);
|
||||
}, [queryInput]);
|
||||
|
||||
React.useEffect(() => {
|
||||
let alive = true;
|
||||
let rendered = false; // 本次查询是否已经把内容渲染出来过
|
||||
// 换任务/筛选/排序 = 换了一条播报流:清掉「新」标记和未读基线。翻页不算换流,
|
||||
// 否则回到最新时就没有未读计数可算了。
|
||||
const stream = `${taskId}|${kinds.join(",")}|${query}|${order}`;
|
||||
if (streamRef.current !== stream) {
|
||||
streamRef.current = stream;
|
||||
seenRef.current = new Set();
|
||||
baselineRef.current = null;
|
||||
setPending(0);
|
||||
}
|
||||
const load = () =>
|
||||
api
|
||||
.explorationNodes(taskId, { page, size, kinds, q: query, order })
|
||||
.then((r) => {
|
||||
if (!alive) return;
|
||||
// 直播位每轮都刷新;其它位置只渲染第一次,之后轮询仅更新未读计数。
|
||||
if (atLive || !rendered) {
|
||||
rendered = true;
|
||||
setItems(r.items);
|
||||
setEdges(r.edges);
|
||||
setRefs(r.refs);
|
||||
}
|
||||
setTotal(r.total);
|
||||
if (atLive) {
|
||||
const seen = seenRef.current;
|
||||
setFreshIDs(seen.size === 0 ? new Set() : new Set(r.items.filter((n) => !seen.has(n.id)).map((n) => n.id)));
|
||||
seenRef.current = new Set(r.items.map((n) => n.id));
|
||||
baselineRef.current = r.total;
|
||||
setPending(0);
|
||||
} else {
|
||||
const base = baselineRef.current;
|
||||
setPending(base === null ? 0 : Math.max(0, r.total - base));
|
||||
}
|
||||
setLoaded(true);
|
||||
})
|
||||
.catch(() => {
|
||||
// 轮询是尽力而为:保留上一次成功的播报内容,下一轮自动重试。
|
||||
});
|
||||
void load();
|
||||
if (!live) {
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}
|
||||
const timer = setInterval(load, POLL_MS);
|
||||
return () => {
|
||||
alive = false;
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, [taskId, page, size, kinds, query, order, live, atLive]);
|
||||
|
||||
const toggleKind = (kind: ExploreKind) => {
|
||||
setKinds((cur) => (cur.includes(kind) ? cur.filter((k) => k !== kind) : [...cur, kind]));
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const backToLive = () => {
|
||||
setPage(1);
|
||||
setOrder("desc");
|
||||
setPending(0);
|
||||
};
|
||||
|
||||
const pageCount = Math.max(1, Math.ceil(total / size));
|
||||
const start = total === 0 ? 0 : (page - 1) * size + 1;
|
||||
const end = (page - 1) * size + items.length;
|
||||
|
||||
// 换任务或筛选后条数变少时,把越界的页码收回来。
|
||||
React.useEffect(() => {
|
||||
if (page > pageCount) setPage(pageCount);
|
||||
}, [page, pageCount]);
|
||||
|
||||
// 按天分组:播报流按日期断行,长任务翻页时还能认出「这是哪天的事」。
|
||||
const groups: Array<{ day: string; rows: TaskNode[] }> = [];
|
||||
for (const node of items) {
|
||||
const ts = Date.parse(node.ts);
|
||||
const day = Number.isNaN(ts) ? "未知日期" : dayFmt.format(ts);
|
||||
const last = groups[groups.length - 1];
|
||||
if (last && last.day === day) last.rows.push(node);
|
||||
else groups.push({ day, rows: [node] });
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden py-0">
|
||||
{/* 工具条 */}
|
||||
<div className="flex flex-wrap items-center gap-2 border-b px-4 py-2.5">
|
||||
<div className="relative w-full sm:w-64">
|
||||
<SearchIcon className="absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={queryInput}
|
||||
onChange={(e) => setQueryInput(e.target.value)}
|
||||
placeholder="搜索播报内容 / 来源"
|
||||
className="h-8 pl-8"
|
||||
aria-label="搜索播报"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
{FILTER_KINDS.map((kind) => {
|
||||
const meta = KIND_META[kind];
|
||||
const active = kinds.includes(kind);
|
||||
return (
|
||||
<button
|
||||
key={kind}
|
||||
type="button"
|
||||
onClick={() => toggleKind(kind)}
|
||||
aria-pressed={active}
|
||||
className={cn(
|
||||
"rounded-md border px-2 py-0.5 text-xs font-medium transition-colors",
|
||||
active ? meta.chip : "border-transparent text-muted-foreground hover:bg-accent",
|
||||
)}
|
||||
>
|
||||
{meta.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{kinds.length > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={() => {
|
||||
setKinds([]);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
清除
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={() => {
|
||||
setOrder((o) => (o === "desc" ? "asc" : "desc"));
|
||||
setPage(1);
|
||||
}}
|
||||
aria-label={order === "desc" ? "当前最新在前,点击改为最早在前" : "当前最早在前,点击改为最新在前"}
|
||||
>
|
||||
{order === "desc" ? <ArrowDownIcon /> : <ArrowUpIcon />}
|
||||
{order === "desc" ? "最新在前" : "最早在前"}
|
||||
</Button>
|
||||
<Button
|
||||
variant={live ? "outline" : "secondary"}
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={() => setLive((v) => !v)}
|
||||
aria-label={live ? "暂停自动刷新" : "恢复自动刷新"}
|
||||
>
|
||||
{live ? <PauseIcon /> : <PlayIcon />}
|
||||
{live ? "自动刷新" : "已暂停"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 离开直播位时的未读提示 */}
|
||||
{!atLive && pending > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={backToLive}
|
||||
className="flex w-full items-center justify-center gap-1.5 border-b bg-primary/10 py-1.5 text-xs font-medium text-primary hover:bg-primary/15"
|
||||
>
|
||||
<ArrowUpToLineIcon className="size-3.5" />
|
||||
{pending > 99 ? "99+" : pending} 条新播报 · 回到最新
|
||||
</button>
|
||||
)}
|
||||
|
||||
<CardContent className="px-4 py-0">
|
||||
{!loaded ? (
|
||||
<div className="flex flex-col gap-3 py-4">
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<Skeleton key={i} className="h-12 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<p className="py-10 text-center text-sm text-muted-foreground">
|
||||
{query || kinds.length > 0 ? "没有符合条件的播报。" : "这个任务还没有产生探索节点。"}
|
||||
</p>
|
||||
) : (
|
||||
groups.map((group) => (
|
||||
<div key={group.day}>
|
||||
<div className="py-2 pl-[6.25rem] text-xs font-medium text-muted-foreground">{group.day}</div>
|
||||
{group.rows.map((node) => (
|
||||
<BroadcastRow
|
||||
key={node.id}
|
||||
node={node}
|
||||
edges={edges}
|
||||
refs={refs}
|
||||
now={now}
|
||||
fresh={freshIDs.has(node.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="flex flex-wrap items-center gap-2 border-t px-4 py-2.5 text-xs text-muted-foreground">
|
||||
<Select
|
||||
value={String(size)}
|
||||
onValueChange={(v) => {
|
||||
setSize(Number(v));
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger size="sm" className="h-7 w-24">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{PAGE_SIZES.map((n) => (
|
||||
<SelectItem key={n} value={String(n)}>
|
||||
{n} / 页
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="tabular-nums">
|
||||
{start}–{end} / {total}
|
||||
</span>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
aria-label="上一页"
|
||||
>
|
||||
<ChevronLeftIcon />
|
||||
</Button>
|
||||
<span className="tabular-nums">
|
||||
{page} / {pageCount}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
disabled={page >= pageCount}
|
||||
onClick={() => setPage((p) => Math.min(pageCount, p + 1))}
|
||||
aria-label="下一页"
|
||||
>
|
||||
<ChevronRightIcon />
|
||||
</Button>
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -40,6 +40,7 @@ import { api } from "@/lib/api";
|
||||
import type { LLMProfile, Task } from "@/lib/types";
|
||||
|
||||
import { AssetsTab } from "./_tabs/assets-tab";
|
||||
import { BroadcastTab } from "./_tabs/broadcast-tab";
|
||||
import { CoverageGraphTab } from "./_tabs/coverage-graph-tab";
|
||||
import { FindingsTab } from "./_tabs/findings-tab";
|
||||
import { GraphTab } from "./_tabs/graph-tab";
|
||||
@@ -53,6 +54,7 @@ const TABS = [
|
||||
{ value: "sessions", label: "会话" },
|
||||
{ value: "overview", label: "总览" },
|
||||
{ value: "graph", label: "探索链路" },
|
||||
{ value: "broadcast", label: "播报板" },
|
||||
{ value: "findings", label: "发现" },
|
||||
{ value: "retests", label: "复测" },
|
||||
{ value: "assets", label: "测试资产" },
|
||||
@@ -424,6 +426,9 @@ function TaskDetailInner() {
|
||||
<TabsContent value="graph" className="mt-0">
|
||||
<GraphTab taskId={id} />
|
||||
</TabsContent>
|
||||
<TabsContent value="broadcast" className="mt-0">
|
||||
<BroadcastTab taskId={id} />
|
||||
</TabsContent>
|
||||
<TabsContent value="findings" className="mt-0">
|
||||
<FindingsTab taskId={id} />
|
||||
</TabsContent>
|
||||
|
||||
+34
-8
@@ -31,6 +31,8 @@ import type {
|
||||
DeleteTaskResult,
|
||||
Edge,
|
||||
EvidenceBodyPreview,
|
||||
ExplorationNodePage,
|
||||
ExplorationNodeQuery,
|
||||
Finding,
|
||||
FindingAssetTree,
|
||||
FindingDeepenResponse,
|
||||
@@ -655,6 +657,25 @@ export const api = {
|
||||
.then(arr)
|
||||
.catch(() => [] as ConvTokenSummary[]),
|
||||
explorationGraph: (task?: string) => get<{ nodes: TaskNode[]; edges: Edge[] }>(`/exploration/graph${tq(task)}`),
|
||||
// 播报板:服务端按创建顺序分页的探索节点(默认最新在前)。
|
||||
explorationNodes: (task: string, query: ExplorationNodeQuery = {}) => {
|
||||
const q = new URLSearchParams();
|
||||
if (task) q.set("task", task);
|
||||
q.set("page", String(query.page ?? 1));
|
||||
q.set("size", String(query.size ?? 20));
|
||||
if (query.kinds?.length) q.set("kind", query.kinds.join(","));
|
||||
if (query.states?.length) q.set("state", query.states.join(","));
|
||||
if (query.q?.trim()) q.set("q", query.q.trim());
|
||||
if (query.order === "asc") q.set("order", "asc");
|
||||
return get<ExplorationNodePage>(`/exploration/nodes?${q.toString()}`).then((r) => ({
|
||||
items: arr(r.items),
|
||||
total: r.total ?? 0,
|
||||
page: r.page ?? 1,
|
||||
size: r.size ?? query.size ?? 20,
|
||||
edges: arr(r.edges),
|
||||
refs: r.refs ?? {},
|
||||
}));
|
||||
},
|
||||
activity: (task?: string, opts?: { intent?: string; since?: number; limit?: number }) => {
|
||||
const q = new URLSearchParams();
|
||||
if (task) q.set("task", task);
|
||||
@@ -742,7 +763,10 @@ export const api = {
|
||||
return r.text();
|
||||
},
|
||||
chatMentions: (kind: string, query: string, signal?: AbortSignal, cursor = "") =>
|
||||
http<{ items: ChatMention[]; next_cursor?: string }>(`/chat/mentions?${new URLSearchParams({ kind, q: query, cursor })}`, { signal }),
|
||||
http<{ items: ChatMention[]; next_cursor?: string }>(
|
||||
`/chat/mentions?${new URLSearchParams({ kind, q: query, cursor })}`,
|
||||
{ signal },
|
||||
),
|
||||
chat: (message: string, task?: string, attachments?: ChatAttachment[], seg?: number) =>
|
||||
post<{ reply: string; mode: string }>(`/chat${tq(task)}`, { message, attachments, seg }),
|
||||
chatStatus: (taskId: string) => get<{ running: boolean }>(`/tasks/${taskId}/chat/status`),
|
||||
@@ -1073,16 +1097,18 @@ export const api = {
|
||||
interceptDecide: (id: number, decision: "allowed" | "denied") =>
|
||||
post<{ ok: boolean }>(`/intercept/pending/${id}/decide`, { decision }),
|
||||
interceptExecution: (id: number, conversationId?: number) =>
|
||||
get<import("@/lib/types").InterceptExecution>(`/intercept/history/${id}/execution${conversationId ? `?conversation=${conversationId}` : ""}`),
|
||||
get<import("@/lib/types").InterceptExecution>(
|
||||
`/intercept/history/${id}/execution${conversationId ? `?conversation=${conversationId}` : ""}`,
|
||||
),
|
||||
interceptDetail: (id: number) => get<InterceptDetail>(`/intercept/history/${id}`),
|
||||
interceptHistory: () => get<{ items: InterceptApprovalRow[] }>("/intercept/history").then((r) => arr(r.items)),
|
||||
interceptHistoryPage: (page = 1, size = 20, filter: InterceptApprovalFilter = {}) =>
|
||||
get<{ items: InterceptApprovalRow[]; total?: number }>(`/intercept/history?${interceptPageQuery(page, size, filter)}`).then(
|
||||
(r) => ({
|
||||
items: arr(r.items),
|
||||
total: r.total ?? r.items?.length ?? 0,
|
||||
}),
|
||||
),
|
||||
get<{ items: InterceptApprovalRow[]; total?: number }>(
|
||||
`/intercept/history?${interceptPageQuery(page, size, filter)}`,
|
||||
).then((r) => ({
|
||||
items: arr(r.items),
|
||||
total: r.total ?? r.items?.length ?? 0,
|
||||
})),
|
||||
interceptTask: (taskId: string) =>
|
||||
get<{ items: InterceptApprovalRow[] }>(`/intercept/task/${taskId}`).then((r) => arr(r.items)),
|
||||
interceptTaskPage: (taskId: string, page = 1, size = 20, filter: InterceptApprovalFilter = {}) =>
|
||||
|
||||
+147
-29
@@ -21,8 +21,6 @@ import type {
|
||||
FindingTraffic,
|
||||
FindingTrafficBinding,
|
||||
IntentAsset,
|
||||
TrafficEvidenceRole,
|
||||
TrafficEvidenceSnapshot,
|
||||
ScopeRow,
|
||||
Task,
|
||||
TaskArchive,
|
||||
@@ -32,6 +30,8 @@ import type {
|
||||
TaskLLMResolution,
|
||||
TaskScopeRow,
|
||||
TaskTemplate,
|
||||
TrafficEvidenceRole,
|
||||
TrafficEvidenceSnapshot,
|
||||
} from "../types";
|
||||
import * as D from "./data";
|
||||
|
||||
@@ -179,10 +179,16 @@ function advanceMockRetests() {
|
||||
retest.status = "completed";
|
||||
retest.verdict = "inconclusive";
|
||||
retest.summary = "演示环境未执行真实验证,无法确认漏洞当前状态。";
|
||||
retest.evidence = "### 演示记录\n\n已关联原漏洞。此环境未连接真实 Agent,也未向目标发送请求;请在实际部署中执行复测。";
|
||||
retest.evidence =
|
||||
"### 演示记录\n\n已关联原漏洞。此环境未连接真实 Agent,也未向目标发送请求;请在实际部署中执行复测。";
|
||||
retest.finished_at = new Date().toISOString();
|
||||
mockRetestMessages[retest.conversation_id].push({
|
||||
seq: 3, worker: "retester", ts: retest.finished_at, kind: "text", summary: retest.summary, detail: retest.evidence,
|
||||
seq: 3,
|
||||
worker: "retester",
|
||||
ts: retest.finished_at,
|
||||
kind: "text",
|
||||
summary: retest.summary,
|
||||
detail: retest.evidence,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1010,14 +1016,42 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
|
||||
const kind = q.get("kind") ?? "";
|
||||
const query = (q.get("q") ?? "").trim().toLowerCase();
|
||||
const candidates = [
|
||||
...D.findings.map((finding, index) => ({ kind: "finding", id: index + 1, label: finding.name || finding.vulnclass, description: `${finding.severity} · ${finding.summary}` })),
|
||||
...D.findings.map((finding, index) => ({
|
||||
kind: "finding",
|
||||
id: index + 1,
|
||||
label: finding.name || finding.vulnclass,
|
||||
description: `${finding.severity} · ${finding.summary}`,
|
||||
})),
|
||||
...D.companies.map((company) => ({ kind: "company", id: company.id, label: company.name, description: "企业" })),
|
||||
...D.assets.map((asset) => ({ kind: asset.type, id: asset.id, label: asset.type === "endpoint" ? `${asset.method || "GET"} ${asset.url}` : asset.app_name || asset.url || asset.domain || asset.ip || asset.bundle_id || `资产 #${asset.id}`, description: [asset.type, asset.page_title, asset.service_name, asset.bundle_id, asset.ip].filter(Boolean).join(" · ") })),
|
||||
...D.assets.map((asset) => ({
|
||||
kind: asset.type,
|
||||
id: asset.id,
|
||||
label:
|
||||
asset.type === "endpoint"
|
||||
? `${asset.method || "GET"} ${asset.url}`
|
||||
: asset.app_name || asset.url || asset.domain || asset.ip || asset.bundle_id || `资产 #${asset.id}`,
|
||||
description: [asset.type, asset.page_title, asset.service_name, asset.bundle_id, asset.ip]
|
||||
.filter(Boolean)
|
||||
.join(" · "),
|
||||
})),
|
||||
];
|
||||
const filtered = candidates.filter((item) => (!kind || kind === item.kind || (kind === "asset" && item.kind !== "finding" && item.kind !== "company")) && (!query || String(item.id) === query || `${item.label} ${item.description}`.toLowerCase().includes(query)))
|
||||
.sort((a, b) => Number(String(b.id) === query) - Number(String(a.id) === query) || b.id - a.id || a.kind.localeCompare(b.kind));
|
||||
const filtered = candidates
|
||||
.filter(
|
||||
(item) =>
|
||||
(!kind || kind === item.kind || (kind === "asset" && item.kind !== "finding" && item.kind !== "company")) &&
|
||||
(!query || String(item.id) === query || `${item.label} ${item.description}`.toLowerCase().includes(query)),
|
||||
)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
Number(String(b.id) === query) - Number(String(a.id) === query) ||
|
||||
b.id - a.id ||
|
||||
a.kind.localeCompare(b.kind),
|
||||
);
|
||||
const offset = Math.max(0, Number(q.get("cursor")) || 0);
|
||||
return { items: filtered.slice(offset, offset + 20), next_cursor: offset + 20 < filtered.length ? String(offset + 20) : undefined };
|
||||
return {
|
||||
items: filtered.slice(offset, offset + 20),
|
||||
next_cursor: offset + 20 < filtered.length ? String(offset + 20) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// ── auth:让 demo 直接进主界面 ──
|
||||
@@ -1872,9 +1906,16 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
|
||||
};
|
||||
}
|
||||
if (path === "/exploration/findings/retests/active" && m === "GET") {
|
||||
return { retests: mockRetests
|
||||
.filter((item) => ["pending", "running"].includes(item.status) && item.conversation_id != null)
|
||||
.map((item) => ({ id: item.id, finding_id: D.findings[item.finding_id - 1].id, conversation_id: item.conversation_id, status: item.status })) };
|
||||
return {
|
||||
retests: mockRetests
|
||||
.filter((item) => ["pending", "running"].includes(item.status) && item.conversation_id != null)
|
||||
.map((item) => ({
|
||||
id: item.id,
|
||||
finding_id: D.findings[item.finding_id - 1].id,
|
||||
conversation_id: item.conversation_id,
|
||||
status: item.status,
|
||||
})),
|
||||
};
|
||||
}
|
||||
if (seg[0] === "exploration" && seg[1] === "findings" && seg[3] === "retests") {
|
||||
const finding = mockFindings.find((item) => item.id === seg[2]);
|
||||
@@ -1882,20 +1923,52 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
|
||||
const findingID = D.findings.findIndex((item) => item.id === finding.id) + 1;
|
||||
if (m === "GET") return { retests: structuredClone(mockRetests.filter((item) => item.finding_id === findingID)) };
|
||||
if (m === "POST") {
|
||||
const existing = mockRetests.find((item) => item.finding_id === findingID && ["pending", "running"].includes(item.status));
|
||||
const existing = mockRetests.find(
|
||||
(item) => item.finding_id === findingID && ["pending", "running"].includes(item.status),
|
||||
);
|
||||
if (existing) return { retest: structuredClone(existing), created: false };
|
||||
const now = new Date().toISOString();
|
||||
const conversationID = mockConversations.reduce((max, item) => Math.max(max, item.id), 0) + 1;
|
||||
mockConversations.unshift({ id: conversationID, agent_key: "retester", title: `复测 #${finding.id} · ${finding.name || finding.vulnclass}`, pinned: false, created_at: now, updated_at: now });
|
||||
mockConversations.unshift({
|
||||
id: conversationID,
|
||||
agent_key: "retester",
|
||||
title: `复测 #${finding.id} · ${finding.name || finding.vulnclass}`,
|
||||
pinned: false,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
});
|
||||
const retest: FindingRetest = {
|
||||
id: mockRetests.length + 1, finding_id: findingID, conversation_id: conversationID,
|
||||
status: "running", verdict: "", notes: String(b.notes ?? ""), summary: "", evidence: "",
|
||||
error: "", created_at: now, started_at: now, finished_at: null,
|
||||
id: mockRetests.length + 1,
|
||||
finding_id: findingID,
|
||||
conversation_id: conversationID,
|
||||
status: "running",
|
||||
verdict: "",
|
||||
notes: String(b.notes ?? ""),
|
||||
summary: "",
|
||||
evidence: "",
|
||||
error: "",
|
||||
created_at: now,
|
||||
started_at: now,
|
||||
finished_at: null,
|
||||
};
|
||||
mockRetests.unshift(retest);
|
||||
mockRetestMessages[conversationID] = [
|
||||
{ seq: 1, worker: "retester", ts: now, kind: "user", summary: `请复测漏洞 #${finding.id}`, detail: retest.notes },
|
||||
{ seq: 2, worker: "retester", ts: now, kind: "text", summary: "演示复测进行中(未向目标发送请求)", detail: "演示复测进行中(未向目标发送请求)" },
|
||||
{
|
||||
seq: 1,
|
||||
worker: "retester",
|
||||
ts: now,
|
||||
kind: "user",
|
||||
summary: `请复测漏洞 #${finding.id}`,
|
||||
detail: retest.notes,
|
||||
},
|
||||
{
|
||||
seq: 2,
|
||||
worker: "retester",
|
||||
ts: now,
|
||||
kind: "text",
|
||||
summary: "演示复测进行中(未向目标发送请求)",
|
||||
detail: "演示复测进行中(未向目标发送请求)",
|
||||
},
|
||||
];
|
||||
return { retest: structuredClone(retest), created: true };
|
||||
}
|
||||
@@ -2070,6 +2143,37 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
|
||||
};
|
||||
}
|
||||
if (path === "/exploration/graph") return D.explorationGraph;
|
||||
// 播报板:和后端 /exploration/nodes 同语义 —— 按创建顺序(mock 里用 ts + id)分页,
|
||||
// 并带上这一页涉及的边与边另一端的节点。
|
||||
if (path === "/exploration/nodes") {
|
||||
const all = D.explorationGraph.nodes;
|
||||
const kinds = new Set((q.get("kind") ?? "").split(",").filter(Boolean));
|
||||
const states = new Set((q.get("state") ?? "").split(",").filter(Boolean));
|
||||
const needle = (q.get("q") ?? "").trim().toLowerCase();
|
||||
const asc = q.get("order") === "asc";
|
||||
const page = Math.max(1, Number(q.get("page") ?? 1));
|
||||
const size = Math.min(200, Math.max(1, Number(q.get("size") ?? 20)));
|
||||
const rank = (id: string) => all.findIndex((n) => n.id === id);
|
||||
const matched = all
|
||||
.filter((n) => (kinds.size === 0 || kinds.has(n.type)) && (states.size === 0 || states.has(n.state)))
|
||||
.filter((n) => !needle || `${n.payload ?? ""} ${n.origin}`.toLowerCase().includes(needle))
|
||||
.sort((a, b) => {
|
||||
const d = Date.parse(a.ts) - Date.parse(b.ts) || rank(a.id) - rank(b.id);
|
||||
return asc ? d : -d;
|
||||
});
|
||||
const items = matched.slice((page - 1) * size, page * size);
|
||||
const onPage = new Set(items.map((n) => n.id));
|
||||
const edges = D.explorationGraph.edges.filter((e) => onPage.has(e.src) || onPage.has(e.dst));
|
||||
const refs: Record<string, (typeof all)[number]> = {};
|
||||
for (const e of edges) {
|
||||
for (const id of [e.src, e.dst]) {
|
||||
if (onPage.has(id) || refs[id]) continue;
|
||||
const node = all.find((n) => n.id === id);
|
||||
if (node) refs[id] = node;
|
||||
}
|
||||
}
|
||||
return { items, total: matched.length, page, size, edges, refs };
|
||||
}
|
||||
if (path === "/exploration/activity" && seg.length === 2) {
|
||||
const since = Number(q.get("since") ?? 0);
|
||||
const limit = Math.max(1, Number(q.get("limit") ?? 300));
|
||||
@@ -2160,10 +2264,16 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
|
||||
// ── conversations ──
|
||||
if (path === "/conversations" && m === "GET") {
|
||||
sortMockConversations();
|
||||
return { conversations: structuredClone(mockConversations.map((conversation) => ({
|
||||
...conversation,
|
||||
running: mockRetests.some((item) => item.conversation_id === conversation.id && ["pending", "running"].includes(item.status)),
|
||||
}))) };
|
||||
return {
|
||||
conversations: structuredClone(
|
||||
mockConversations.map((conversation) => ({
|
||||
...conversation,
|
||||
running: mockRetests.some(
|
||||
(item) => item.conversation_id === conversation.id && ["pending", "running"].includes(item.status),
|
||||
),
|
||||
})),
|
||||
),
|
||||
};
|
||||
}
|
||||
if (path === "/conversations" && m === "POST") {
|
||||
const now = new Date().toISOString();
|
||||
@@ -2216,7 +2326,9 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
|
||||
}
|
||||
if (seg[0] === "conversations" && seg[2] === "messages" && seg.length === 3 && m === "GET") {
|
||||
const items = mockRetestMessages[Number(seg[1])] ?? D.conversationMessages[Number(seg[1])] ?? [];
|
||||
const running = mockRetests.some((item) => item.conversation_id === Number(seg[1]) && ["pending", "running"].includes(item.status));
|
||||
const running = mockRetests.some(
|
||||
(item) => item.conversation_id === Number(seg[1]) && ["pending", "running"].includes(item.status),
|
||||
);
|
||||
return { items, cursor: items.length ? items[items.length - 1].seq : 0, running };
|
||||
}
|
||||
if (seg[0] === "conversations" && seg[2] === "messages" && seg.length === 4) {
|
||||
@@ -2265,7 +2377,8 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
|
||||
if (path === "/intercept/rules" && m === "GET") return { rules: D.interceptRules };
|
||||
if (seg[0] === "intercept" && seg[1] === "rules" && seg[3] === "toggle")
|
||||
return { ok: true, enabled: b.enabled ?? true };
|
||||
if (path === "/intercept/pending" && m === "GET") return { pending: mockInterceptHistory.filter((r) => r.status === "pending") };
|
||||
if (path === "/intercept/pending" && m === "GET")
|
||||
return { pending: mockInterceptHistory.filter((r) => r.status === "pending") };
|
||||
if (seg[0] === "intercept" && seg[1] === "pending" && seg[3] === "decide") {
|
||||
const id = Number(seg[2]);
|
||||
const row = mockInterceptHistory.find((r) => r.id === id) ?? mockInterceptPending.find((r) => r.id === id);
|
||||
@@ -2295,11 +2408,16 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
|
||||
if (status && !["pending", "allowed", "denied", "timeout"].includes(status)) throw new Error("无效审批状态");
|
||||
if (decisionSource && !["model", "rule", "unknown"].includes(decisionSource)) throw new Error("无效判定来源");
|
||||
const filtered = mockInterceptHistory.filter((row) => {
|
||||
const source = row.decision_source || (row.rule_id ? "rule" : row.reason?.startsWith("[模型]") ? "model" : "unknown");
|
||||
return (seg[1] !== "task" || row.task_id === decodeURIComponent(seg[2])) &&
|
||||
(!status || row.status === status) && (!decisionSource || source === decisionSource);
|
||||
const source =
|
||||
row.decision_source || (row.rule_id ? "rule" : row.reason?.startsWith("[模型]") ? "model" : "unknown");
|
||||
return (
|
||||
(seg[1] !== "task" || row.task_id === decodeURIComponent(seg[2])) &&
|
||||
(!status || row.status === status) &&
|
||||
(!decisionSource || source === decisionSource)
|
||||
);
|
||||
});
|
||||
if (!q.has("page") && !q.has("size") && !status && !decisionSource) return { items: filtered, total: filtered.length };
|
||||
if (!q.has("page") && !q.has("size") && !status && !decisionSource)
|
||||
return { items: filtered, total: filtered.length };
|
||||
const page = Math.max(1, Number(q.get("page")) || 1);
|
||||
const size = Math.min(100, Math.max(1, Number(q.get("size")) || 20));
|
||||
const offset = (page - 1) * size;
|
||||
|
||||
@@ -420,6 +420,26 @@ export interface TaskNode {
|
||||
inherited?: boolean;
|
||||
}
|
||||
|
||||
// 播报板一页:按创建顺序分页的节点 + 这一页涉及的边 + 边另一端的节点(refs,按 id 索引),
|
||||
// 这样每条播报都能说清「从哪来、产出了什么」,而不用把整张图拉下来。
|
||||
export interface ExplorationNodePage {
|
||||
items: TaskNode[];
|
||||
total: number;
|
||||
page: number;
|
||||
size: number;
|
||||
edges: Edge[];
|
||||
refs: Record<string, TaskNode>;
|
||||
}
|
||||
|
||||
export interface ExplorationNodeQuery {
|
||||
page?: number;
|
||||
size?: number;
|
||||
kinds?: ExploreKind[];
|
||||
states?: string[];
|
||||
q?: string;
|
||||
order?: "asc" | "desc";
|
||||
}
|
||||
|
||||
// 目标管理卡片用的目标(后端已把 payload 拆成 text/vulnclass)。
|
||||
export interface TaskGoal {
|
||||
id: string;
|
||||
|
||||
Reference in new issue
Block a user