diff --git a/db/finding_cases.go b/db/finding_cases.go index e469c54..427871c 100644 --- a/db/finding_cases.go +++ b/db/finding_cases.go @@ -123,7 +123,7 @@ func (d *DB) ListFindingCases(filter FindingFilter, page, size int) ([]FindingCa if err := d.QueryRow(base+` SELECT count(*) FROM entities`, args...).Scan(&total); err != nil { return nil, 0, err } - order := `e.created DESC,e.fid DESC` + order := `e.created ` + filter.timeOrder() + `,e.fid DESC` if filter.Sort == "severity" { order = `e.severity_rank DESC,` + order } diff --git a/db/finding_time_sort_test.go b/db/finding_time_sort_test.go new file mode 100644 index 0000000..295cd45 --- /dev/null +++ b/db/finding_time_sort_test.go @@ -0,0 +1,171 @@ +package db + +import ( + "context" + "fmt" + "reflect" + "testing" + "time" +) + +func TestFindingGroupTimeSort(t *testing.T) { + d, task, ids := timeSortFixture(t) + other, err := d.CreateTask("newer low severity task", "goal", nil, 0, 0) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _, _ = d.Exec(`DELETE FROM findings WHERE task_id=$1`, other.ID); _ = d.DeleteTask(other.ID) }) + marker := fmt.Sprintf("time-sort-%d", time.Now().UnixNano()) + if _, err := d.Exec(`UPDATE findings SET vulnclass=$2 WHERE task_id=$1`, task.ID, marker); err != nil { + t.Fatal(err) + } + latest, err := d.AddFinding(other.ID, 0, marker, "latest low", "low", "summary", "evidence", "worker", nil) + if err != nil { + t.Fatal(err) + } + if _, err := d.Exec(`UPDATE findings SET created_at='2026-10-28T12:00:00Z' WHERE id=$1`, latest); err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + field, direction string + want int64 + }{ + {"time", "desc", other.ID}, + {"time", "asc", task.ID}, + {"severity", "desc", task.ID}, + {"severity", "asc", task.ID}, + } { + groups, total, reports, err := d.ListFindingGroups(FindingFilter{VulnClass: marker, Sort: tc.field, TimeDirection: tc.direction}, 1, 1) + if err != nil || total != 2 || reports != len(ids)+1 || len(groups) != 1 || groups[0].TaskID == nil || *groups[0].TaskID != tc.want { + t.Fatalf("%s/%s groups=%+v total=%d reports=%d err=%v", tc.field, tc.direction, groups, total, reports, err) + } + } +} + +func TestFindingTimeDirectionAllowlist(t *testing.T) { + for _, raw := range []string{"", "desc", "invalid", "ASC", "asc; SELECT 1"} { + if got := (FindingFilter{TimeDirection: raw}).timeOrder(); got != "DESC" { + t.Fatalf("timeOrder(%q)=%q, want DESC", raw, got) + } + } + if got := (FindingFilter{TimeDirection: "asc"}).timeOrder(); got != "ASC" { + t.Fatalf("timeOrder(asc)=%q", got) + } +} + +// A newer low-severity record and an older high-severity record make time +// ordering observably different from severity ordering in both directions. +func timeSortFixture(t *testing.T) (*DB, *Task, []int64) { + t.Helper() + d, task, ids := caseTestDB(t) + for i, day := range []int{25, 26, 27, 24} { + if _, err := d.Exec(`UPDATE findings SET created_at=$2::timestamptz WHERE id=$1`, ids[i], fmt.Sprintf("2026-10-%dT12:00:00Z", day)); err != nil { + t.Fatal(err) + } + } + if _, err := d.Exec(`UPDATE findings SET severity='critical' WHERE id=$1`, ids[0]); err != nil { + t.Fatal(err) + } + if _, err := d.Exec(`UPDATE findings SET severity='high' WHERE id=$1`, ids[1]); err != nil { + t.Fatal(err) + } + return d, task, ids +} + +func TestFindingTimeSortPages(t *testing.T) { + d, task, ids := timeSortFixture(t) + for _, tc := range []struct { + name, field, direction string + want []int64 + }{ + {"default newest first", "", "", []int64{ids[2], ids[1], ids[0], ids[3]}}, + {"time descending", "time", "desc", []int64{ids[2], ids[1], ids[0], ids[3]}}, + {"time ascending", "time", "asc", []int64{ids[3], ids[0], ids[1], ids[2]}}, + {"severity descending time", "severity", "desc", []int64{ids[0], ids[1], ids[3], ids[2]}}, + {"severity ascending time", "severity", "asc", []int64{ids[0], ids[3], ids[1], ids[2]}}, + } { + t.Run(tc.name, func(t *testing.T) { + filter := FindingFilter{TaskID: fmt.Sprint(task.ID), Sort: tc.field, TimeDirection: tc.direction} + var got []int64 + for page := 1; page <= 2; page++ { + rows, total, err := d.ListFindingsPage(filter, page, 2) + if err != nil || total != 4 || len(rows) != 2 { + t.Fatalf("page %d: rows=%d total=%d err=%v", page, len(rows), total, err) + } + for _, row := range rows { + got = append(got, row.ID) + } + } + if !reflect.DeepEqual(got, tc.want) { + t.Fatalf("paged order=%v, want %v", got, tc.want) + } + }) + } + if _, err := d.Exec(`UPDATE findings SET created_at='2026-10-27T12:00:00Z' WHERE task_id=$1`, task.ID); err != nil { + t.Fatal(err) + } + for _, direction := range []string{"asc", "desc"} { + rows, _, err := d.ListFindingsPage(FindingFilter{TaskID: fmt.Sprint(task.ID), Sort: "time", TimeDirection: direction}, 1, 4) + if err != nil { + t.Fatal(err) + } + for i, row := range rows { + if row.ID != ids[len(ids)-1-i] { + t.Fatalf("unstable timestamp tie: %+v", rows) + } + } + } +} + +func TestFindingCaseTimeSortPages(t *testing.T) { + d, task, ids := timeSortFixture(t) + caseID, err := d.MergeFindingCase(context.Background(), task.ID, []int64{ids[0], ids[2]}, "same defect", "shared handler", "human") + if err != nil { + t.Fatal(err) + } + latest, err := d.AddFinding(task.ID, 0, "other", "latest low", "low", "summary", "evidence", "worker", nil) + if err != nil { + t.Fatal(err) + } + if _, err := d.Exec(`UPDATE findings SET created_at='2026-10-28T12:00:00Z' WHERE id=$1`, latest); err != nil { + t.Fatal(err) + } + f := func(id int64) string { return fmt.Sprintf("f:%d", id) } + c := fmt.Sprintf("c:%d", caseID) + for _, tc := range []struct { + name, field, direction string + want []string + }{ + {"default newest first", "", "", []string{f(latest), c, f(ids[1]), f(ids[3])}}, + {"time descending", "time", "desc", []string{f(latest), c, f(ids[1]), f(ids[3])}}, + {"time ascending", "time", "asc", []string{f(ids[3]), f(ids[1]), c, f(latest)}}, + {"severity descending time", "severity", "desc", []string{c, f(ids[1]), f(ids[3]), f(latest)}}, + {"severity ascending time", "severity", "asc", []string{c, f(ids[3]), f(ids[1]), f(latest)}}, + } { + t.Run(tc.name, func(t *testing.T) { + filter := FindingFilter{TaskID: fmt.Sprint(task.ID), Sort: tc.field, TimeDirection: tc.direction} + var got []string + for page := 1; page <= 2; page++ { + rows, total, err := d.ListFindingCases(filter, page, 2) + if err != nil || total != 4 || len(rows) != 2 { + t.Fatalf("page %d: rows=%d total=%d err=%v", page, len(rows), total, err) + } + for _, row := range rows { + if row.Case != nil { + got = append(got, fmt.Sprintf("c:%d", row.Case.ID)) + } else { + got = append(got, f(row.FindingID)) + } + } + } + if !reflect.DeepEqual(got, tc.want) { + t.Fatalf("paged order=%v, want %v", got, tc.want) + } + }) + } + // Filtering a folder uses its matching members' timestamps, not merge time. + rows, total, err := d.ListFindingCases(FindingFilter{TaskID: fmt.Sprint(task.ID), Severity: "low", Sort: "time", TimeDirection: "asc"}, 1, 10) + if err != nil || total != 2 || len(rows) != 2 || rows[0].Case == nil || rows[0].Case.ID != caseID || rows[1].FindingID != latest { + t.Fatalf("filtered case order=%+v total=%d err=%v", rows, total, err) + } +} diff --git a/db/findings.go b/db/findings.go index 5906064..7ec541e 100644 --- a/db/findings.go +++ b/db/findings.go @@ -150,15 +150,16 @@ func (d *DB) ListFindings(limit int) ([]*DBFinding, error) { } // FindingFilter narrows a paginated findings query. Empty-string fields mean "no -// filter on that column". Sort is "severity" (severity desc, then newest) or -// anything else (newest first). +// filter on that column". Sort is "severity" (severity desc, then time) or +// anything else (time only). TimeDirection defaults to newest first. type FindingFilter struct { - Severity string // high | medium | low - Status string // pending | false_positive | ignored | resolved - VulnClass string - TaskID string // 任务 id(字符串形式;空/非法 = 不按任务筛选) - Query string // 名称/类型/摘要/证据/报告正文的模糊检索关键词 - Sort string // "severity" | "time" + Severity string // high | medium | low + Status string // pending | false_positive | ignored | resolved + VulnClass string + TaskID string // 任务 id(字符串形式;空/非法 = 不按任务筛选) + Query string // 名称/类型/摘要/证据/报告正文的模糊检索关键词 + Sort string // "severity" | "time" + TimeDirection string // "asc" | "desc"; applies to time, not the severity ranking // AssetScope 是资产树的节点 key(a: / c: / r: / __none__), // 选中一个节点等于选中它的整棵子树。空 = 不按资产筛选。 AssetScope string @@ -169,6 +170,14 @@ type FindingFilter struct { assetMiss bool // 选中的节点在当前筛选下不存在 → 结果恒空 } +// timeOrder only returns SQL keywords from a fixed allowlist. +func (f FindingFilter) timeOrder() string { + if f.TimeDirection == "asc" { + return "ASC" + } + return "DESC" +} + // FindingUnassignedTask is the task filter sentinel for findings whose task is // absent. That includes rows created without a task and rows retained after their // originating task was deleted (the findings FK is ON DELETE SET NULL). @@ -259,10 +268,10 @@ func (d *DB) ListFindingsPage(f FindingFilter, page, pageSize int) ([]*DBFinding return []*DBFinding{}, total, nil } - order := "f.created_at DESC, f.id DESC" + order := "f.created_at " + f.timeOrder() + ", f.id DESC" if f.Sort == "severity" { - // critical > high > medium > low > 其它, then newest first. - order = `CASE f.severity WHEN 'critical' THEN 4 WHEN 'high' THEN 3 WHEN 'medium' THEN 2 WHEN 'low' THEN 1 ELSE 0 END DESC, f.created_at DESC, f.id DESC` + // critical > high > medium > low > 其它, then the requested time direction. + order = `CASE f.severity WHEN 'critical' THEN 4 WHEN 'high' THEN 3 WHEN 'medium' THEN 2 WHEN 'low' THEN 1 ELSE 0 END DESC, ` + order } pageArgs := append(append([]any{}, args...), pageSize, (page-1)*pageSize) q := fmt.Sprintf(` @@ -324,9 +333,9 @@ func (d *DB) ListFindingGroups(f FindingFilter, page, pageSize int) ([]FindingGr return []FindingGroup{}, groupTotal, findingTotal, nil } - order := "MAX(f.created_at) DESC, t.id DESC NULLS LAST" + order := "MAX(f.created_at) " + f.timeOrder() + ", t.id DESC NULLS LAST" if f.Sort == "severity" { - order = `MAX(CASE f.severity WHEN 'critical' THEN 4 WHEN 'high' THEN 3 WHEN 'medium' THEN 2 WHEN 'low' THEN 1 ELSE 0 END) DESC, MAX(f.created_at) DESC, t.id DESC NULLS LAST` + order = `MAX(CASE f.severity WHEN 'critical' THEN 4 WHEN 'high' THEN 3 WHEN 'medium' THEN 2 WHEN 'low' THEN 1 ELSE 0 END) DESC, ` + order } pageArgs := append(append([]any{}, args...), pageSize, (page-1)*pageSize) query := fmt.Sprintf(`SELECT t.id, COALESCE(t.name,''), COALESCE(t.description,''), COALESCE( diff --git a/server/findings_groups.go b/server/findings_groups.go index f7b47f0..b6f4118 100644 --- a/server/findings_groups.go +++ b/server/findings_groups.go @@ -33,10 +33,11 @@ func findingFilterFromQuery(q url.Values) db.FindingFilter { Status: normFilter(q.Get("status")), VulnClass: normFilter(q.Get("vulnclass")), // task_id(独立于会切到「按任务节点」分支的 task 参数):全局表按任务筛选。 - TaskID: normFilter(q.Get("task_id")), - Query: q.Get("q"), - Sort: q.Get("sort"), - AssetScope: strings.TrimSpace(q.Get("asset_scope")), + TaskID: normFilter(q.Get("task_id")), + Query: q.Get("q"), + Sort: q.Get("sort"), + TimeDirection: q.Get("time_direction"), + AssetScope: strings.TrimSpace(q.Get("asset_scope")), } } diff --git a/server/findings_time_sort_test.go b/server/findings_time_sort_test.go new file mode 100644 index 0000000..d083fe7 --- /dev/null +++ b/server/findings_time_sort_test.go @@ -0,0 +1,16 @@ +package server + +import ( + "net/url" + "testing" +) + +func TestFindingFilterTimeDirection(t *testing.T) { + for _, direction := range []string{"", "asc", "desc"} { + q := url.Values{"task_id": {"9"}, "sort": {"time"}, "time_direction": {direction}, "severity": {"high"}, "q": {"keyword"}} + f := findingFilterFromQuery(q) + if f.Sort != "time" || f.TimeDirection != direction || f.TaskID != "9" || f.Severity != "high" || f.Query != "keyword" { + t.Fatalf("filter lost sorting or search state: %+v", f) + } + } +} diff --git a/web/src/app/(main)/function/tasks/detail/_tabs/findings-tab.tsx b/web/src/app/(main)/function/tasks/detail/_tabs/findings-tab.tsx index a434bcc..3ae1876 100644 --- a/web/src/app/(main)/function/tasks/detail/_tabs/findings-tab.tsx +++ b/web/src/app/(main)/function/tasks/detail/_tabs/findings-tab.tsx @@ -13,18 +13,16 @@ import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group"; -import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger } from "@/components/ui/select"; +import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { api } from "@/lib/api"; -import { filterAndSortFindings } from "@/lib/finding-severity"; +import { type FindingSortField, filterAndSortFindings } from "@/lib/finding-severity"; import { useStoredSortPreference } from "@/lib/sort-preference"; import { SEVERITY_FILTER_OPTIONS, statusMeta } from "@/lib/status"; import type { Finding, FindingStatus, Severity } from "@/lib/types"; import { cn } from "@/lib/utils"; -type FindingSortField = "time"; - -const FINDING_SORT_FIELDS: readonly FindingSortField[] = ["time"]; +const FINDING_SORT_FIELDS: readonly FindingSortField[] = ["time", "severity"]; const FINDING_SORT_PREFERENCE_KEY = "artex_task_findings_sort"; function findingLabel(finding: Finding): string { @@ -185,7 +183,7 @@ export function FindingsTab({ taskId }: { taskId: string }) { // Discover inherited sources before filtering; finding-cases owns folder matching. const taskFindings = findings.filter((f) => f.task_id === taskId || f.inherited); - const items = filterAndSortFindings(taskFindings, severity, sortPreference.direction, query); + const items = filterAndSortFindings(taskFindings, severity, sortPreference, query); return (
@@ -204,6 +202,42 @@ export function FindingsTab({ taskId }: { taskId: string }) { ))} + +
{ @@ -233,7 +267,8 @@ export function FindingsTab({ taskId }: { taskId: string }) { task: taskId, severity: severity === "all" ? undefined : severity, query: query || undefined, - sort: "severity", + sort: sortPreference.field, + timeDirection: sortPreference.direction, }} contextTask={taskId} /> @@ -252,7 +287,8 @@ export function FindingsTab({ taskId }: { taskId: string }) { task: source, severity: severity === "all" ? undefined : severity, query: query || undefined, - sort: "severity", + sort: sortPreference.field, + timeDirection: sortPreference.direction, }} contextTask={taskId} readOnly @@ -264,25 +300,17 @@ export function FindingsTab({ taskId }: { taskId: string }) {
- 漏洞(按严重度排序) - +
{items.map((f) => ( diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 9dc4700..bc84b21 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -242,6 +242,7 @@ function findingFilterParams(q: Omit): URLSea if (q.task && q.task !== "all") p.set("task_id", q.task); 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.assetScope) p.set("asset_scope", q.assetScope); return p; } diff --git a/web/src/lib/finding-severity.test.mjs b/web/src/lib/finding-severity.test.mjs index f7dd925..6550640 100644 --- a/web/src/lib/finding-severity.test.mjs +++ b/web/src/lib/finding-severity.test.mjs @@ -21,25 +21,59 @@ const rows = [ finding("5", "high", "2026-10-26T12:00:00Z", { inherited: true, source_task_id: "source" }), ]; +test("time descending sorts across severity levels instead of promoting older critical findings", () => { + assert.deepEqual( + filterAndSortFindings(rows, "all", { field: "time", direction: "desc" }).map((row) => row.id), + ["5", "4", "1", "2", "3"], + ); +}); + +test("time ascending can place an older low-severity finding before a critical finding", () => { + const oldest = finding("6", "low", "2026-10-23T12:00:00Z"); + assert.deepEqual( + filterAndSortFindings([...rows, oldest], "all", { field: "time", direction: "asc" }).map((row) => row.id), + ["6", "3", "2", "5", "4", "1"], + ); +}); + +test("time sorting preserves severity/search filters and inherited records without mutating the snapshot", () => { + const snapshot = Object.freeze(rows.map((row) => Object.freeze({ ...row, evidence: "Shared evidence" }))); + for (const direction of ["asc", "desc"]) { + const result = filterAndSortFindings(snapshot, "high", { field: "time", direction }, " EVIDENCE "); + assert.deepEqual( + result.map((row) => row.id), + direction === "asc" ? ["2", "5"] : ["5", "2"], + ); + assert.equal(result.find((row) => row.id === "5").source_task_id, "source"); + } + assert.deepEqual( + snapshot.map((row) => row.id), + rows.map((row) => row.id), + ); +}); + test("all keeps every report and orders severity before newest time", () => { assert.deepEqual( - filterAndSortFindings(rows, "all", "desc").map((row) => row.id), + filterAndSortFindings(rows, "all", { field: "severity", direction: "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"); + const items = filterAndSortFindings(rows, severity, { field: "severity", direction: "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"); + assert.equal( + filterAndSortFindings(rows, "high", { field: "severity", direction: "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), + filterAndSortFindings(rows, "all", { field: "severity", direction: "asc" }).map((row) => row.id), ["3", "2", "5", "4", "1"], ); }); @@ -48,29 +82,32 @@ 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), + filterAndSortFindings(tied, "all", { field: "severity", 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"), []); + assert.deepEqual(filterAndSortFindings([], "all", { field: "severity", direction: "desc" }), []); + assert.deepEqual(filterAndSortFindings([rows[0]], "critical", { field: "severity", direction: "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.notEqual(filterAndSortFindings(snapshot, "all", { field: "severity", direction: "desc" }), snapshot); + filterAndSortFindings(snapshot, "high", { field: "severity", direction: "asc" }); assert.deepEqual(snapshot, before); }); test("keywords match each searchable field as a trimmed, case-insensitive substring", () => { for (const field of ["name", "vulnclass", "summary", "evidence"]) { const match = finding("match", "high", rows[0].ts, { [field]: "prefix NeEdLe suffix" }); - assert.deepEqual(filterAndSortFindings([rows[0], match], "all", "desc", " nEEdle "), [match]); + assert.deepEqual( + filterAndSortFindings([rows[0], match], "all", { field: "severity", direction: "desc" }, " nEEdle "), + [match], + ); } }); @@ -81,8 +118,11 @@ test("unmatched keywords and missing searchable fields return no matches", () => summary: undefined, evidence: undefined, }); - assert.deepEqual(filterAndSortFindings([...rows, missing], "all", "desc", "needle"), []); - assert.deepEqual(filterAndSortFindings([], "all", "desc", "needle"), []); + assert.deepEqual( + filterAndSortFindings([...rows, missing], "all", { field: "severity", direction: "desc" }, "needle"), + [], + ); + assert.deepEqual(filterAndSortFindings([], "all", { field: "severity", direction: "desc" }, "needle"), []); }); test("undefined, empty and whitespace keywords preserve severity filtering and sorting", () => { @@ -90,8 +130,8 @@ test("undefined, empty and whitespace keywords preserve severity filtering and s for (const severity of ["all", "high"]) { for (const direction of ["asc", "desc"]) { assert.deepEqual( - filterAndSortFindings(rows, severity, direction, query), - filterAndSortFindings(rows, severity, direction), + filterAndSortFindings(rows, severity, { field: "severity", direction }, query), + filterAndSortFindings(rows, severity, { field: "severity", direction }), ); } } @@ -101,12 +141,12 @@ test("undefined, empty and whitespace keywords preserve severity filtering and s test("keywords combine with severity and preserve time ordering for inherited reports", () => { const matches = rows.map((row) => ({ ...row, evidence: "Shared keyword" })); assert.deepEqual( - filterAndSortFindings(matches, "high", "desc", "KEYWORD").map((row) => row.id), + filterAndSortFindings(matches, "high", { field: "severity", direction: "desc" }, "KEYWORD").map((row) => row.id), ["5", "2"], ); assert.deepEqual( - filterAndSortFindings(matches, "high", "asc", "keyword").map((row) => row.id), + filterAndSortFindings(matches, "high", { field: "severity", direction: "asc" }, "keyword").map((row) => row.id), ["2", "5"], ); - assert.deepEqual(filterAndSortFindings(matches, "high", "desc", "absent"), []); + assert.deepEqual(filterAndSortFindings(matches, "high", { field: "severity", direction: "desc" }, "absent"), []); }); diff --git a/web/src/lib/finding-severity.ts b/web/src/lib/finding-severity.ts index 163f20b..304750d 100644 --- a/web/src/lib/finding-severity.ts +++ b/web/src/lib/finding-severity.ts @@ -1,11 +1,14 @@ +import type { SortPreference } from "./sort-preference"; import type { Finding, Severity } from "./types"; +export type FindingSortField = "time" | "severity"; + const SEVERITY_RANK: Record = { critical: 4, high: 3, medium: 2, low: 1 }; export function filterAndSortFindings( findings: readonly Finding[], severity: "all" | Severity, - timeDirection: "asc" | "desc", + sort: SortPreference, query?: string, ): Finding[] { const keyword = query?.trim().toLowerCase() ?? ""; @@ -20,9 +23,9 @@ export function filterAndSortFindings( ) .sort((a, b) => { const severityCompared = (SEVERITY_RANK[b.severity] ?? 0) - (SEVERITY_RANK[a.severity] ?? 0); - if (severityCompared !== 0) return severityCompared; + if (sort.field === "severity" && severityCompared !== 0) return severityCompared; const timeCompared = Date.parse(a.ts) - Date.parse(b.ts); - if (timeCompared !== 0) return timeDirection === "asc" ? timeCompared : -timeCompared; + if (timeCompared !== 0) return sort.direction === "asc" ? timeCompared : -timeCompared; return b.id.localeCompare(a.id, undefined, { numeric: true }); }); } diff --git a/web/src/lib/mock/handler.ts b/web/src/lib/mock/handler.ts index bfdcfa8..c979006 100644 --- a/web/src/lib/mock/handler.ts +++ b/web/src/lib/mock/handler.ts @@ -8,6 +8,7 @@ import { isCompanyScopeKind, normalizeCompanyScopeValue, } from "../company-scope"; +import { filterAndSortFindings } from "../finding-severity"; import type { Activity, ArchiveBatchItem, @@ -1962,11 +1963,13 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re if (path === "/exploration/finding-case-review" && m === "POST") throw new Error("演示模式不执行 Reporter 整理,请在已配置模型的实例中使用"); if (path === "/exploration/finding-cases" && m === "GET") { - const list = mockApplyAssetScope(mockFilterFindings(q), q.get("asset_scope")); - const ranks = { critical: 4, high: 3, medium: 2, low: 1 } as const; - list.sort( - (a, b) => - (q.get("sort") === "severity" ? ranks[b.severity] - ranks[a.severity] : 0) || +new Date(b.ts) - +new Date(a.ts), + const list = filterAndSortFindings( + mockApplyAssetScope(mockFilterFindings(q), q.get("asset_scope")), + "all", + { + field: q.get("sort") === "severity" ? "severity" : "time", + direction: q.get("time_direction") === "asc" ? "asc" : "desc", + }, ); const page = Math.max(1, Number(q.get("page")) || 1); const size = Math.min(100, Math.max(1, Number(q.get("limit")) || 20)); diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 5035cae..0e44355 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -602,6 +602,7 @@ export interface FindingQuery { task?: string; // 任务 id;"all"/空 = 不按任务筛选 query?: string; sort?: "severity" | "time"; + timeDirection?: "asc" | "desc"; // Missing = newest first; secondary to severity when sort="severity". // 资产树节点 key;选中一个节点 = 选中它的整棵子树。空 = 不按资产筛选。 assetScope?: string; }