feat(web): 任务发现 tab 支持严重度筛选与排序

对齐全局发现页:共用 severity chip、Case 列表 sort=severity,
原始上报列表前端按严重度过滤并排序。
This commit is contained in:
RuoJi6 committed 2026-10-26 09:14:28 +08:00
1 parent 620ca5d96c
commit 0ecaea2fb2
5 files changed
+134 -26

No files matched your search

+2 -10
View File
@@ -42,7 +42,7 @@ import { Textarea } from "@/components/ui/textarea";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { api } from "@/lib/api";
import { getLocalStorageValue, setLocalStorageValue } from "@/lib/local-storage.client";
import { statusMeta } from "@/lib/status";
import { SEVERITY_FILTER_OPTIONS, statusMeta } from "@/lib/status";
import type {
ActiveFindingRetest,
Finding,
@@ -918,15 +918,7 @@ export default function FindingsPage() {
size="sm"
spacing={0}
>
{(
[
["all", "全部"],
["critical", "严重"],
["high", "高危"],
["medium", "中危"],
["low", "低危"],
] as const
).map(([val, label]) => (
{SEVERITY_FILTER_OPTIONS.map(([val, label]) => (
<ToggleGroupItem key={val} value={val} aria-label={`按${label}等级筛选`}>
{label}
</ToggleGroupItem>
@@ -12,10 +12,12 @@ import { StatusBadge } from "@/components/status-badge";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger } from "@/components/ui/select";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { api } from "@/lib/api";
import { filterAndSortFindings } from "@/lib/finding-severity";
import { useStoredSortPreference } from "@/lib/sort-preference";
import { statusMeta } from "@/lib/status";
import type { Finding, FindingStatus } from "@/lib/types";
import { SEVERITY_FILTER_OPTIONS, statusMeta } from "@/lib/status";
import type { Finding, FindingStatus, Severity } from "@/lib/types";
import { cn } from "@/lib/utils";
type FindingSortField = "time";
@@ -140,6 +142,7 @@ function Row({
export function FindingsTab({ taskId }: { taskId: string }) {
const [findings, setFindings] = React.useState<Finding[]>([]);
const [severity, setSeverity] = React.useState<"all" | Severity>("all");
const [sortPreference, setSortPreference] = useStoredSortPreference(
FINDING_SORT_PREFERENCE_KEY,
FINDING_SORT_FIELDS,
@@ -180,20 +183,32 @@ export function FindingsTab({ taskId }: { taskId: string }) {
}
}, []);
const items = findings
.filter((f) => f.task_id === taskId || f.inherited)
.sort((a, b) => {
const compared = Date.parse(a.ts) - Date.parse(b.ts);
if (compared !== 0) return sortPreference.direction === "asc" ? compared : -compared;
return b.id.localeCompare(a.id, undefined, { numeric: true });
});
// Discover inherited sources before severity filtering; finding-cases owns folder matching.
const taskFindings = findings.filter((f) => f.task_id === taskId || f.inherited);
const items = filterAndSortFindings(taskFindings, severity, sortPreference.direction);
return (
<div className="flex flex-col gap-4">
<FindingCaseList query={{ task: taskId, sort: "time" }} />
<ToggleGroup
type="single"
value={severity}
onValueChange={(value) => value && setSeverity(value as "all" | Severity)}
variant="outline"
size="sm"
spacing={0}
>
{SEVERITY_FILTER_OPTIONS.map(([val, label]) => (
<ToggleGroupItem key={val} value={val} aria-label={`按${label}等级筛选`}>
{label}
</ToggleGroupItem>
))}
</ToggleGroup>
<FindingCaseList
query={{ task: taskId, severity: severity === "all" ? undefined : severity, sort: "severity" }}
/>
{[
...new Set(
items
taskFindings
.filter((f) => f.inherited)
.map((f) => f.source_task_id)
.filter((id): id is string => !!id),
@@ -201,7 +216,11 @@ export function FindingsTab({ taskId }: { taskId: string }) {
].map((source) => (
<div key={source}>
<p className="mb-2 text-sm text-muted-foreground">继承任务 #{source} · 只读</p>
<FindingCaseList query={{ task: source, sort: "time" }} contextTask={taskId} readOnly />
<FindingCaseList
query={{ task: source, severity: severity === "all" ? undefined : severity, sort: "severity" }}
contextTask={taskId}
readOnly
/>
</div>
))}
<details>
@@ -209,11 +228,11 @@ export function FindingsTab({ taskId }: { taskId: string }) {
<Card className="overflow-hidden py-0">
<CardContent className="px-0">
<div className="flex items-center border-b px-4 py-2 text-xs text-muted-foreground">
<span className="min-w-0 flex-1">漏洞</span>
<span className="min-w-0 flex-1">漏洞(按严重度排序)</span>
<button
type="button"
className="inline-flex items-center gap-1 outline-none focus-visible:underline"
aria-label={`发现时间当前${sortPreference.direction === "asc" ? "正序" : "倒序"},点击切换排序方向`}
aria-label={`同等级发现时间当前${sortPreference.direction === "asc" ? "正序" : "倒序"},点击切换排序方向`}
onClick={() =>
setSortPreference((current) => ({
field: "time",
@@ -221,7 +240,7 @@ export function FindingsTab({ taskId }: { taskId: string }) {
}))
}
>
<span>发现时间</span>
<span>同等级发现时间</span>
{sortPreference.direction === "asc" ? (
<ArrowUpIcon className="size-3.5" />
) : (
@@ -233,7 +252,9 @@ export function FindingsTab({ taskId }: { taskId: string }) {
<Row key={f.id} f={f} contextTaskId={taskId} onStatus={onStatus} />
))}
{items.length === 0 && (
<p className="px-4 py-8 text-center text-sm text-muted-foreground">本任务及直接关联任务暂无确认发现。</p>
<p className="px-4 py-8 text-center text-sm text-muted-foreground">
{severity === "all" ? "本任务及直接关联任务暂无确认发现。" : "当前风险等级暂无原始上报。"}
</p>
)}
</CardContent>
</Card>
+68
View File
@@ -0,0 +1,68 @@
import { filterAndSortFindings } from "./finding-severity.ts";
import assert from "node:assert/strict";
import test from "node:test";
const finding = (id, severity, ts, extra = {}) => ({
id,
severity,
ts,
vulnclass: "XSS",
status: "confirmed",
summary: id,
evidence: "",
...extra,
});
const rows = [
finding("1", "low", "2026-10-26T12:00:00Z"),
finding("2", "high", "2026-10-25T12:00:00Z"),
finding("3", "critical", "2026-10-24T12:00:00Z"),
finding("4", "medium", "2026-10-26T12:00:00Z"),
finding("5", "high", "2026-10-26T12:00:00Z", { inherited: true, source_task_id: "source" }),
];
test("all keeps every report and orders severity before newest time", () => {
assert.deepEqual(
filterAndSortFindings(rows, "all", "desc").map((row) => row.id),
["3", "5", "2", "4", "1"],
);
});
test("each severity filters reports including inherited reports", () => {
for (const severity of ["critical", "high", "medium", "low"]) {
const items = filterAndSortFindings(rows, severity, "desc");
assert.equal(items.length, severity === "high" ? 2 : 1);
assert.ok(items.every((row) => row.severity === severity));
}
assert.equal(filterAndSortFindings(rows, "high", "desc")[0].source_task_id, "source");
});
test("ascending time changes only the order within a severity", () => {
assert.deepEqual(
filterAndSortFindings(rows, "all", "asc").map((row) => row.id),
["3", "2", "5", "4", "1"],
);
});
test("matching severity and timestamp use descending numeric IDs", () => {
const tied = [finding("2", "high", rows[0].ts), finding("10", "high", rows[0].ts)];
for (const direction of ["asc", "desc"]) {
assert.deepEqual(
filterAndSortFindings(tied, "all", direction).map((row) => row.id),
["10", "2"],
);
}
});
test("empty input and a severity without matches return an empty list", () => {
assert.deepEqual(filterAndSortFindings([], "all", "desc"), []);
assert.deepEqual(filterAndSortFindings([rows[0]], "critical", "desc"), []);
});
test("filtering and sorting preserve the polling snapshot", () => {
const snapshot = Object.freeze(rows.map((row) => Object.freeze({ ...row })));
const before = [...snapshot];
assert.notEqual(filterAndSortFindings(snapshot, "all", "desc"), snapshot);
filterAndSortFindings(snapshot, "high", "asc");
assert.deepEqual(snapshot, before);
});
+19
View File
@@ -0,0 +1,19 @@
import type { Finding, Severity } from "./types";
const SEVERITY_RANK: Record<Severity, number> = { critical: 4, high: 3, medium: 2, low: 1 };
export function filterAndSortFindings(
findings: readonly Finding[],
severity: "all" | Severity,
timeDirection: "asc" | "desc",
): Finding[] {
return findings
.filter((finding) => severity === "all" || finding.severity === severity)
.sort((a, b) => {
const severityCompared = (SEVERITY_RANK[b.severity] ?? 0) - (SEVERITY_RANK[a.severity] ?? 0);
if (severityCompared !== 0) return severityCompared;
const timeCompared = Date.parse(a.ts) - Date.parse(b.ts);
if (timeCompared !== 0) return timeDirection === "asc" ? timeCompared : -timeCompared;
return b.id.localeCompare(a.id, undefined, { numeric: true });
});
}
+8
View File
@@ -63,6 +63,14 @@ const severity: Record<string, StatusMeta> = {
low: { label: "低危", tone: "slate" },
};
export const SEVERITY_FILTER_OPTIONS = [
["all", "全部"],
["critical", severity.critical.label],
["high", severity.high.label],
["medium", severity.medium.label],
["low", severity.low.label],
] as const;
const finding: Record<string, StatusMeta> = {
pending: { label: "待处理", tone: "amber" },
in_progress: { label: "处理中", tone: "blue" },