Merge pull request #79 from RuoJi6/codex/finding-date-filter

新增漏洞发现时间范围筛选
This commit is contained in:
RuoJi6 authored and GitHub committed 2026-10-29 08:11:56 -07:00
commit 1f6028d10a
6 files changed
+192 -7

No files matched your search

+14
View File
@@ -160,6 +160,8 @@ type FindingFilter struct {
Query string // 名称/类型/摘要/证据/报告正文的模糊检索关键词
Sort string // "severity" | "time"
TimeDirection string // "asc" | "desc"; applies to time, not the severity ranking
From string // RFC3339 timestamp, inclusive
To string // RFC3339 timestamp, inclusive
// AssetScope 是资产树的节点 key(a:<id> / c:<id> / r:<domain> / __none__),
// 选中一个节点等于选中它的整棵子树。空 = 不按资产筛选。
AssetScope string
@@ -199,6 +201,18 @@ func (f FindingFilter) where() (string, []any) {
add("severity", f.Severity)
add("status", f.Status)
add("vulnclass", f.VulnClass)
if from := strings.TrimSpace(f.From); from != "" {
if parsed, err := time.Parse(time.RFC3339, from); err == nil {
args = append(args, parsed)
conds = append(conds, fmt.Sprintf("f.created_at >= $%d", len(args)))
}
}
if to := strings.TrimSpace(f.To); to != "" {
if parsed, err := time.Parse(time.RFC3339, to); err == nil {
args = append(args, parsed)
conds = append(conds, fmt.Sprintf("f.created_at <= $%d", len(args)))
}
}
// task_id 是 bigint 列,按整数比较(不能走上面的文本 add);空/非法值忽略。
if f.TaskID == FindingUnassignedTask {
conds = append(conds, "(f.task_id IS NULL OR t.id IS NULL)")
+2
View File
@@ -37,6 +37,8 @@ func findingFilterFromQuery(q url.Values) db.FindingFilter {
Query: q.Get("q"),
Sort: q.Get("sort"),
TimeDirection: q.Get("time_direction"),
From: strings.TrimSpace(q.Get("from")),
To: strings.TrimSpace(q.Get("to")),
AssetScope: strings.TrimSpace(q.Get("asset_scope")),
}
}
+35 -7
View File
@@ -18,6 +18,7 @@ import {
import { toast } from "sonner";
import { FindingCaseList } from "@/components/finding-case-list";
import { FindingDateFilter, type FindingDateRange } from "@/components/finding-date-filter";
import { FindingRetestDialog } from "@/components/finding-retest-dialog";
import { StatusBadge } from "@/components/status-badge";
import { TablePagination } from "@/components/table-pagination";
@@ -142,6 +143,7 @@ export default function FindingsPage() {
const [sort, setSort] = React.useState<"severity" | "time">("severity");
const [search, setSearch] = React.useState("");
const [query, setQuery] = React.useState("");
const [dateRange, setDateRange] = React.useState<FindingDateRange>({});
const [expanded, setExpanded] = React.useState<string | null>(null);
const [flat, setFlat] = React.useState<FlatFindingsState>(EMPTY_FLAT_STATE);
const [flatPage, setFlatPage] = React.useState(1);
@@ -167,7 +169,16 @@ export default function FindingsPage() {
const retestRefreshVersion = React.useRef(0);
const [deepenDescription, setDeepenDescription] = React.useState("");
const [deepening, setDeepening] = React.useState(false);
const filterFingerprint = JSON.stringify([severity, status, vulnclass, task, sort, query]);
const filterFingerprint = JSON.stringify([
severity,
status,
vulnclass,
task,
sort,
query,
dateRange.from,
dateRange.to,
]);
const activeFilterFingerprint = React.useRef(filterFingerprint);
activeFilterFingerprint.current = filterFingerprint;
@@ -310,6 +321,7 @@ export default function FindingsPage() {
task,
query,
sort,
...dateRange,
assetScope: view === "asset" ? (assetScope ?? undefined) : undefined,
},
ids: [...selectedIds],
@@ -374,7 +386,19 @@ export default function FindingsPage() {
// Polling keeps the last successful snapshot visible.
setFlat((current) => ({ ...current, loading: false }));
}
}, [activeAssetScope, filterFingerprint, flatPage, flatPageSize, severity, status, vulnclass, task, query, sort]);
}, [
activeAssetScope,
dateRange,
filterFingerprint,
flatPage,
flatPageSize,
severity,
status,
vulnclass,
task,
query,
sort,
]);
// loadAssetTree 取整棵资产树。树不随选中节点变化(否则选一下就塌成一条链),
// 所以这里不带 assetScope。
@@ -384,7 +408,7 @@ export default function FindingsPage() {
const request = ++assetTreeRequest.current;
setAssetTree((current) => ({ ...current, loading: true }));
try {
const result = await api.findingAssetTree({ severity, status, vulnclass, task, query, sort });
const result = await api.findingAssetTree({ severity, status, vulnclass, task, query, sort, ...dateRange });
if (request !== assetTreeRequest.current || activeFilterFingerprint.current !== requestFilter) return;
setAssetTree({
nodes: result.nodes ?? [],
@@ -399,7 +423,7 @@ export default function FindingsPage() {
setAssetTree((current) => ({ ...current, loading: false }));
toast.error(`资产树加载失败:${(e as Error).message}`);
}
}, [filterFingerprint, severity, status, vulnclass, task, query, sort]);
}, [dateRange, filterFingerprint, severity, status, vulnclass, task, query, sort]);
const refreshGroups = React.useCallback(async () => {
const requestFilter = filterFingerprint;
@@ -415,6 +439,7 @@ export default function FindingsPage() {
task,
query,
sort,
...dateRange,
});
if (request !== groupsRequest.current || activeFilterFingerprint.current !== requestFilter) return;
setGroups(result.items);
@@ -423,7 +448,7 @@ export default function FindingsPage() {
} catch {
// Polling keeps the last successful snapshot visible.
}
}, [filterFingerprint, page, pageSize, severity, status, vulnclass, task, query, sort]);
}, [dateRange, filterFingerprint, page, pageSize, severity, status, vulnclass, task, query, sort]);
const loadGroup = React.useCallback(
async (key: string, groupPage: number, groupPageSize: number) => {
@@ -454,6 +479,7 @@ export default function FindingsPage() {
task: key,
query,
sort,
...dateRange,
},
true,
);
@@ -488,7 +514,7 @@ export default function FindingsPage() {
}));
}
},
[filterFingerprint, severity, status, vulnclass, query, sort],
[dateRange, filterFingerprint, severity, status, vulnclass, query, sort],
);
React.useEffect(() => {
@@ -980,6 +1006,8 @@ export default function FindingsPage() {
</SelectContent>
</Select>
<FindingDateFilter value={dateRange} onChange={setDateRange} />
<Select value={sort} onValueChange={(v) => setSort(v as "severity" | "time")}>
<SelectTrigger size="sm" className="w-36">
<SelectValue />
@@ -1077,7 +1105,7 @@ export default function FindingsPage() {
{view === "cases" && (
<FindingCaseList
onTotal={setCaseTotal}
query={{ severity, status, vulnclass, task, query, sort }}
query={{ severity, status, vulnclass, task, query, sort, ...dateRange }}
selectedIds={selectedIds}
onSelect={toggleSelected}
/>
+137
View File
@@ -0,0 +1,137 @@
"use client";
import * as React from "react";
import { format, parseISO } from "date-fns";
import { zhCN } from "date-fns/locale/zh-CN";
import { CalendarDaysIcon, RotateCcwIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { TimeSelect } from "@/components/time-select";
export interface FindingDateRange {
from?: string;
to?: string;
}
function localIso(date: string, time: string) {
if (!date) return undefined;
const value = new Date(`${date}T${time || "00:00"}:00`);
return Number.isNaN(value.getTime()) ? undefined : value.toISOString();
}
function displayDate(iso?: string) {
if (!iso) return "";
const date = new Date(iso);
return Number.isNaN(date.getTime()) ? "" : date.toLocaleDateString("zh-CN");
}
function DateField({ value, onChange, label }: { value: string; onChange: (value: string) => void; label: string }) {
const selected = value ? parseISO(value) : undefined;
return (
<Popover>
<PopoverTrigger asChild>
<Button type="button" variant="outline" size="sm" className="h-10 justify-between px-3 font-normal" aria-label={label}>
<span className={selected ? "text-foreground" : "text-muted-foreground"}>
{selected ? format(selected, "yyyy年M月d日") : "年 / 月 / 日"}
</span>
<CalendarDaysIcon className="size-4 text-muted-foreground" aria-hidden="true" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
locale={zhCN}
selected={selected}
defaultMonth={selected}
onSelect={(date) => date && onChange(format(date, "yyyy-MM-dd"))}
/>
</PopoverContent>
</Popover>
);
}
export function FindingDateFilter({
value,
onChange,
}: {
value: FindingDateRange;
onChange: (value: FindingDateRange) => void;
}) {
const [open, setOpen] = React.useState(false);
const [fromDate, setFromDate] = React.useState("");
const [fromTime, setFromTime] = React.useState("00:00");
const [toDate, setToDate] = React.useState("");
const [toTime, setToTime] = React.useState("23:59");
const active = Boolean(value.from ?? value.to);
const apply = (next: { fromDate?: string; fromTime?: string; toDate?: string; toTime?: string }) => {
const nextFromDate = next.fromDate ?? fromDate;
const nextFromTime = next.fromTime ?? fromTime;
const nextToDate = next.toDate ?? toDate;
const nextToTime = next.toTime ?? toTime;
if (next.fromDate !== undefined) setFromDate(next.fromDate);
if (next.fromTime !== undefined) setFromTime(next.fromTime);
if (next.toDate !== undefined) setToDate(next.toDate);
if (next.toTime !== undefined) setToTime(next.toTime);
onChange({ from: localIso(nextFromDate, nextFromTime), to: localIso(nextToDate, nextToTime) });
};
const clear = () => {
setFromDate("");
setToDate("");
setFromTime("00:00");
setToTime("23:59");
onChange({});
};
const label = active
? `${displayDate(value.from) || "开始不限"} – ${displayDate(value.to) || "结束不限"}`
: "时间筛选";
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
size="sm"
variant={active ? "secondary" : "outline"}
className="max-w-64 justify-start gap-2 font-normal"
aria-label="按时间筛选"
>
<CalendarDaysIcon aria-hidden="true" />
<span className="truncate">{label}</span>
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="w-80 p-4">
<div className="mb-3">
<p className="font-medium">按发现时间筛选</p>
<p className="text-muted-foreground text-xs">选择年月日和时分,范围包含起止时刻。</p>
</div>
<div className="space-y-3">
<div className="grid gap-1.5 text-xs font-medium">
<span>开始时间</span>
<div className="grid grid-cols-[1fr_8.5rem] gap-2">
<DateField value={fromDate} onChange={(value) => apply({ fromDate: value })} label="开始日期" />
<TimeSelect value={fromTime} onValueChange={(value) => apply({ fromTime: value })} aria-label="开始时分" />
</div>
</div>
<div className="grid gap-1.5 text-xs font-medium">
<span>结束时间</span>
<div className="grid grid-cols-[1fr_8.5rem] gap-2">
<DateField value={toDate} onChange={(value) => apply({ toDate: value })} label="结束日期" />
<TimeSelect value={toTime} onValueChange={(value) => apply({ toTime: value })} aria-label="结束时分" />
</div>
</div>
</div>
{active && (
<Button variant="ghost" size="sm" className="mt-3 w-full" onClick={clear}>
<RotateCcwIcon />
清除时间筛选
</Button>
)}
</PopoverContent>
</Popover>
);
}
+2
View File
@@ -243,6 +243,8 @@ function findingFilterParams(q: Omit<FindingQuery, "page" | "pageSize">): URLSea
if (q.query?.trim()) p.set("q", q.query.trim());
if (q.sort) p.set("sort", q.sort);
if (q.timeDirection) p.set("time_direction", q.timeDirection);
if (q.from) p.set("from", q.from);
if (q.to) p.set("to", q.to);
if (q.assetScope) p.set("asset_scope", q.assetScope);
return p;
}
+2
View File
@@ -608,6 +608,8 @@ export interface FindingQuery {
query?: string;
sort?: "severity" | "time";
timeDirection?: "asc" | "desc"; // Missing = newest first; secondary to severity when sort="severity".
from?: string; // ISO timestamp, inclusive
to?: string; // ISO timestamp, inclusive
// 资产树节点 key;选中一个节点 = 选中它的整棵子树。空 = 不按资产筛选。
assetScope?: string;
}