From a91c0a72230851a5d787e5d699a3c77fb2ebb4e1 Mon Sep 17 00:00:00 2001 From: ji ruo <79234113+RuoJi6@users.noreply.github.com> Date: Wed, 16 Sep 2026 17:24:15 +0800 Subject: [PATCH] feat: filter approval records by status and decision source --- db/intercept.go | 45 +++++--- db/intercept_filter_test.go | 141 ++++++++++++++++++++++++ server/intercept.go | 33 +++++- server/intercept_filter_test.go | 105 ++++++++++++++++++ web/src/components/approval-records.tsx | 107 ++++++++++++++++-- web/src/lib/api.ts | 16 ++- web/src/lib/mock/handler.ts | 26 ++--- web/src/lib/types.ts | 5 + 8 files changed, 431 insertions(+), 47 deletions(-) create mode 100644 db/intercept_filter_test.go create mode 100644 server/intercept_filter_test.go diff --git a/db/intercept.go b/db/intercept.go index bc60f9b..5b0770e 100644 --- a/db/intercept.go +++ b/db/intercept.go @@ -225,8 +225,13 @@ func scanInterceptApprovalRow(rows interface{ Scan(...any) error }, r *Intercept ) } +// Keep legacy rows without decision_source consistent with their displayed source. +const approvalDecisionSource = `COALESCE(NULLIF(ip.decision_source,''), CASE + WHEN ip.rule_id IS NOT NULL THEN 'rule' + WHEN ip.reason LIKE '[模型]%' THEN 'model' ELSE 'unknown' END)` + const approvalRowColumns = `ip.id, ip.rule_id, ip.conversation_id, ip.task_id, ip.agent_name, - ip.tool_name, ip.tool_input, ip.status, ip.reason, ip.decided_at, ip.created_at, ip.decision_source, + ip.tool_name, ip.tool_input, ip.status, ip.reason, ip.decided_at, ip.created_at, ` + approvalDecisionSource + `, COALESCE(c.title,'') AS conv_title, COALESCE(c.agent_key,'') AS conv_agent_key, COALESCE(ir.name,'') AS rule_name` @@ -279,11 +284,16 @@ func (d *DB) ListAllIntercepts(limit int) ([]InterceptApprovalRow, error) { return out, rows.Err() } -// ListAllInterceptsPage returns one 1-based page of approval records and the -// total number of records. Records are ordered newest first, matching the -// legacy capped list returned by ListAllIntercepts. -func (d *DB) ListAllInterceptsPage(page, size int) ([]InterceptApprovalRow, int, error) { - return d.listInterceptsPage("", page, size) +// InterceptApprovalFilter combines exact status and decision-source filters. +// Empty fields include all values. +type InterceptApprovalFilter struct { + Status string + DecisionSource string +} + +// ListAllInterceptsPage returns one 1-based page and the total matching count. +func (d *DB) ListAllInterceptsPage(page, size int, filter InterceptApprovalFilter) ([]InterceptApprovalRow, int, error) { + return d.listInterceptsPage("", page, size, filter) } // ListTaskIntercepts returns all intercept_pending rows for a specific task (newest first). @@ -305,11 +315,11 @@ func (d *DB) ListTaskIntercepts(taskID string) ([]InterceptApprovalRow, error) { } // ListTaskInterceptsPage is the paginated variant of ListTaskIntercepts. -func (d *DB) ListTaskInterceptsPage(taskID string, page, size int) ([]InterceptApprovalRow, int, error) { - return d.listInterceptsPage(taskID, page, size) +func (d *DB) ListTaskInterceptsPage(taskID string, page, size int, filter InterceptApprovalFilter) ([]InterceptApprovalRow, int, error) { + return d.listInterceptsPage(taskID, page, size, filter) } -func (d *DB) listInterceptsPage(taskID string, page, size int) ([]InterceptApprovalRow, int, error) { +func (d *DB) listInterceptsPage(taskID string, page, size int, filter InterceptApprovalFilter) ([]InterceptApprovalRow, int, error) { if page < 1 { page = 1 } @@ -321,11 +331,20 @@ func (d *DB) listInterceptsPage(taskID string, page, size int) ([]InterceptAppro } offset := (page - 1) * size - where := "" + conditions := []string{} args := []any{} - if taskID != "" { - where = " WHERE ip.task_id=$1" - args = append(args, taskID) + add := func(column, value string) { + if value != "" { + args = append(args, value) + conditions = append(conditions, column+"=$"+fmt.Sprint(len(args))) + } + } + add("ip.task_id", taskID) + add("ip.status", filter.Status) + add(approvalDecisionSource, filter.DecisionSource) + where := "" + if len(conditions) > 0 { + where = " WHERE " + strings.Join(conditions, " AND ") } var total int if err := d.QueryRow("SELECT COUNT(*) FROM intercept_pending ip"+where, args...).Scan(&total); err != nil { diff --git a/db/intercept_filter_test.go b/db/intercept_filter_test.go new file mode 100644 index 0000000..b34126d --- /dev/null +++ b/db/intercept_filter_test.go @@ -0,0 +1,141 @@ +package db + +import ( + "slices" + "testing" +) + +func TestInterceptApprovalFilters(t *testing.T) { + d, err := Open(testDSN(t)) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = d.Close() }) + scope := "approval-filter-" + t.Name() + t.Cleanup(func() { _, _ = d.Exec(`DELETE FROM intercept_pending WHERE task_id IN ($1,$2)`, scope, scope+"-other") }) + rule, err := d.CreateInterceptRule("filter fixture", "tool_name", "string", "Bash", "deny", "fixture", 1, true, false, 0, "deny") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _, _ = d.Exec(`DELETE FROM intercept_rules WHERE id=$1`, rule.ID) }) + filter := InterceptApprovalFilter{Status: "denied", DecisionSource: "model"} + _, baseline, err := d.ListAllInterceptsPage(1, 20, filter) + if err != nil { + t.Fatal(err) + } + expected := []int64{} + for _, task := range []string{scope, scope + "-other"} { + copies := 3 + if task != scope { + copies = 1 + } + for _, status := range []string{"pending", "allowed", "denied", "timeout"} { + for _, source := range []string{"model", "rule", "unknown"} { + for i := 0; i < copies; i++ { + var ruleID int64 + reason := "legacy reason" + if source == "rule" { + ruleID = rule.ID + } + if source == "model" { + reason = "[模型] fixture" + } + id, err := d.CreateDecidedIntercept(ruleID, 0, task, "test", "Bash", []byte(`{"command":"fixture"}`), status, reason) + if err != nil { + t.Fatal(err) + } + // Legacy source fields and tied timestamps must filter/page consistently. + if i == 0 { + if _, err = d.Exec(`UPDATE intercept_pending SET decision_source='' WHERE id=$1`, id); err != nil { + t.Fatal(err) + } + } + if task == scope && status == "denied" && source == "model" { + expected = append(expected, id) + } + } + } + } + } + if _, err := d.Exec(`UPDATE intercept_pending SET created_at='2099-01-01' WHERE task_id IN ($1,$2)`, scope, scope+"-other"); err != nil { + t.Fatal(err) + } + cases := []struct { + name string + filter InterceptApprovalFilter + want int + }{ + {"all", InterceptApprovalFilter{}, 36}, + } + for _, s := range []string{"pending", "allowed", "denied", "timeout"} { + cases = append(cases, struct { + name string + filter InterceptApprovalFilter + want int + }{s, InterceptApprovalFilter{Status: s}, 9}) + } + for _, s := range []string{"model", "rule", "unknown"} { + cases = append(cases, struct { + name string + filter InterceptApprovalFilter + want int + }{s, InterceptApprovalFilter{DecisionSource: s}, 12}) + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + rows, total, err := d.ListTaskInterceptsPage(scope, 1, 100, tc.filter) + if err != nil || total != tc.want || len(rows) != tc.want { + t.Fatalf("count=%d rows=%d err=%v", total, len(rows), err) + } + for _, r := range rows { + if r.TaskID == nil || *r.TaskID != scope || tc.filter.Status != "" && r.Status != tc.filter.Status || tc.filter.DecisionSource != "" && r.DecisionSource != tc.filter.DecisionSource { + t.Fatalf("nonmatching row: %+v", r) + } + } + }) + } + slices.Reverse(expected) + var seen []int64 + for page := 1; page <= 2; page++ { + rows, total, err := d.ListTaskInterceptsPage(scope, page, 2, filter) + if err != nil || total != 3 { + t.Fatalf("page %d total=%d err=%v", page, total, err) + } + for _, r := range rows { + seen = append(seen, r.ID) + } + } + if !slices.Equal(seen, expected) { + t.Fatalf("unstable combined-filter pagination: got %v want %v", seen, expected) + } + rows, total, err := d.ListTaskInterceptsPage(scope, 3, 2, filter) + if err != nil || len(rows) != 0 || total != 3 { + t.Fatalf("empty page count=%d rows=%d err=%v", total, len(rows), err) + } + _, total, err = d.ListAllInterceptsPage(1, 100, filter) + if err != nil || total != baseline+4 { + t.Fatalf("global filter count=%d want=%d err=%v", total, baseline+4, err) + } + // Values must remain SQL parameters even for direct store callers. + rows, total, err = d.ListTaskInterceptsPage(scope, 1, 20, InterceptApprovalFilter{Status: "denied' OR 1=1 --"}) + if err != nil || total != 0 || len(rows) != 0 { + t.Fatalf("invalid value escaped filter: %d %v", total, err) + } + // A rule's persisted source survives deletion of its associated rule. + _, err = d.Exec(`UPDATE intercept_pending SET decision_source='rule' WHERE task_id=$1 AND rule_id=$2`, scope, rule.ID) + if err != nil { + t.Fatal(err) + } + if err = d.DeleteInterceptRule(rule.ID); err != nil { + t.Fatal(err) + } + rows, total, err = d.ListTaskInterceptsPage(scope, 1, 100, InterceptApprovalFilter{DecisionSource: "rule"}) + if err != nil || total != 12 { + t.Fatalf("deleted-rule source count=%d err=%v", total, err) + } + for _, r := range rows { + if r.RuleID != nil || r.DecisionSource != "rule" { + t.Fatal("deleted rule lost its source") + } + } +} diff --git a/server/intercept.go b/server/intercept.go index 32376f2..fc89740 100644 --- a/server/intercept.go +++ b/server/intercept.go @@ -248,7 +248,12 @@ func (s *Server) interceptListTaskItems(w http.ResponseWriter, r *http.Request) return } q := r.URL.Query() - if q.Get("page") == "" && q.Get("size") == "" { + filter, err := interceptFilterParams(q) + if err != nil { + writeErr(w, 400, err.Error()) + return + } + if q.Get("page") == "" && q.Get("size") == "" && filter == (db.InterceptApprovalFilter{}) { items, err := pg.ListTaskIntercepts(taskID) if err != nil { writeErr(w, 500, err.Error()) @@ -261,7 +266,7 @@ func (s *Server) interceptListTaskItems(w http.ResponseWriter, r *http.Request) return } page, size := interceptPageParams(q) - items, total, err := pg.ListTaskInterceptsPage(taskID, page, size) + items, total, err := pg.ListTaskInterceptsPage(taskID, page, size, filter) if err != nil { writeErr(w, 500, err.Error()) return @@ -278,7 +283,12 @@ func (s *Server) interceptHistory(w http.ResponseWriter, r *http.Request) { return } q := r.URL.Query() - if q.Get("page") == "" && q.Get("size") == "" { + filter, err := interceptFilterParams(q) + if err != nil { + writeErr(w, 400, err.Error()) + return + } + if q.Get("page") == "" && q.Get("size") == "" && filter == (db.InterceptApprovalFilter{}) { items, err := pg.ListAllIntercepts(200) if err != nil { writeErr(w, 500, err.Error()) @@ -291,7 +301,7 @@ func (s *Server) interceptHistory(w http.ResponseWriter, r *http.Request) { return } page, size := interceptPageParams(q) - items, total, err := pg.ListAllInterceptsPage(page, size) + items, total, err := pg.ListAllInterceptsPage(page, size, filter) if err != nil { writeErr(w, 500, err.Error()) return @@ -302,6 +312,21 @@ func (s *Server) interceptHistory(w http.ResponseWriter, r *http.Request) { writeJSON(w, 200, map[string]any{"items": items, "total": total, "page": page, "page_size": size}) } +func interceptFilterParams(q url.Values) (db.InterceptApprovalFilter, error) { + filter := db.InterceptApprovalFilter{Status: q.Get("status"), DecisionSource: q.Get("decision_source")} + switch filter.Status { + case "", "pending", "allowed", "denied", "timeout": + default: + return filter, fmt.Errorf("status 必须是 pending、allowed、denied 或 timeout") + } + switch filter.DecisionSource { + case "", "model", "rule", "unknown": + default: + return filter, fmt.Errorf("decision_source 必须是 model、rule 或 unknown") + } + return filter, nil +} + func interceptPageParams(q url.Values) (int, int) { page := atoiDefault(q.Get("page"), 1) size := atoiDefault(q.Get("size"), 20) diff --git a/server/intercept_filter_test.go b/server/intercept_filter_test.go new file mode 100644 index 0000000..6f106c3 --- /dev/null +++ b/server/intercept_filter_test.go @@ -0,0 +1,105 @@ +package server + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/Autumn-27/artex/db" + "github.com/Autumn-27/artex/intercept" +) + +func TestInterceptFilterHTTP(t *testing.T) { + dsn, _, err := db.DSN() + if err != nil { + t.Skip("no test database configured") + } + d, err := db.Open(dsn) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = d.Close() }) + m := &Manager{pg: d, interceptor: intercept.New(d)} + s := &Server{m: m, jwtKey: []byte("approval-filter-test-key")} + h := s.Handler() + token, err := signJWT(s.jwtKey) + if err != nil { + t.Fatal(err) + } + scope := "approval-http-filter" + t.Cleanup(func() { _, _ = d.Exec(`DELETE FROM intercept_pending WHERE task_id IN ($1,$2)`, scope, scope+"-other") }) + _, baseline, err := d.ListAllInterceptsPage(1, 20, db.InterceptApprovalFilter{Status: "denied", DecisionSource: "model"}) + if err != nil { + t.Fatal(err) + } + for _, state := range []string{"pending", "allowed", "denied", "timeout"} { + if _, err := d.CreateDecidedIntercept(0, 0, scope, "test", "Bash", []byte(`{}`), state, "[模型] fixture"); err != nil { + t.Fatal(err) + } + } + if _, err := d.CreateDecidedIntercept(0, 0, scope+"-other", "test", "Bash", []byte(`{}`), "denied", "[模型] fixture"); err != nil { + t.Fatal(err) + } + do := func(path string, auth bool) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodGet, path, nil) + if auth { + req.Header.Set("Authorization", "Bearer "+token) + } + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + return rec + } + for _, base := range []string{"/api/intercept/history", "/api/intercept/task/" + scope} { + if rec := do(base+"?status=denied&decision_source=model", false); rec.Code != 401 { + t.Fatalf("unprotected list: %d", rec.Code) + } + for _, query := range []string{"?status=invalid", "?decision_source=invalid", "?page=1&status=denied%27+OR+1%3D1--", "?size=20&decision_source=MODEL"} { + if rec := do(base+query, true); rec.Code != 400 { + t.Fatalf("invalid filter %s: %d %s", query, rec.Code, rec.Body.String()) + } + } + // Filtering without explicit page parameters must still apply the filters. + rec := do(base+"?status=denied&decision_source=model", true) + var body struct { + Items []db.InterceptApprovalRow `json:"items"` + Total int `json:"total"` + Page int `json:"page"` + } + if rec.Code != 200 || json.Unmarshal(rec.Body.Bytes(), &body) != nil { + t.Fatalf("list: %d %s", rec.Code, rec.Body.String()) + } + want := baseline + 2 + if base != "/api/intercept/history" { + want = 1 + } + if body.Total != want || body.Page != 1 { + t.Fatalf("filter total=%d want=%d page=%d", body.Total, want, body.Page) + } + for _, r := range body.Items { + if r.Status != "denied" || r.DecisionSource != "model" { + t.Fatalf("nonmatching item: %+v", r) + } + } + } + for _, tc := range []struct { + query string + total, rows, page int + }{ + {"", 4, 4, 0}, + {"?page=2&size=2&decision_source=model", 4, 2, 2}, + {"?page=2&size=1&status=denied&decision_source=model", 1, 0, 2}, + {"?status=denied&decision_source=unknown", 0, 0, 1}, + {"?page=0&size=0&status=pending", 1, 1, 1}, + } { + rec := do("/api/intercept/task/"+scope+tc.query, true) + var body struct { + Items []db.InterceptApprovalRow `json:"items"` + Total int `json:"total"` + Page int `json:"page"` + } + if rec.Code != 200 || json.Unmarshal(rec.Body.Bytes(), &body) != nil || body.Total != tc.total || len(body.Items) != tc.rows || body.Page != tc.page { + t.Fatalf("%s: %d %s", tc.query, rec.Code, rec.Body.String()) + } + } +} diff --git a/web/src/components/approval-records.tsx b/web/src/components/approval-records.tsx index c4b2d34..08c87e8 100644 --- a/web/src/components/approval-records.tsx +++ b/web/src/components/approval-records.tsx @@ -21,10 +21,18 @@ import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty"; +import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"; +import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Skeleton } from "@/components/ui/skeleton"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { api } from "@/lib/api"; -import type { InterceptApprovalRow, InterceptAudit, InterceptDetail, InterceptReviewInput } from "@/lib/types"; +import type { + InterceptApprovalFilter, + InterceptApprovalRow, + InterceptAudit, + InterceptDetail, + InterceptReviewInput, +} from "@/lib/types"; import { cn } from "@/lib/utils"; function fmtTime(value?: string) { @@ -643,6 +651,7 @@ export function ApprovalRecords({ taskId }: { taskId?: string }) { const [page, setPage] = React.useState(1); const [pageSize, setPageSize] = React.useState(20); const [total, setTotal] = React.useState(0); + const [filter, setFilter] = React.useState({}); const [loading, setLoading] = React.useState(true); const [refreshing, setRefreshing] = React.useState(false); const [error, setError] = React.useState(""); @@ -650,6 +659,8 @@ export function ApprovalRecords({ taskId }: { taskId?: string }) { const [revision, setRevision] = React.useState(0); const request = React.useRef(0); const decisionLock = React.useRef(false); + const filterID = React.useId(); + const filtered = Boolean(filter.status || filter.decision_source); // A task can stay mounted while the user switches between task details. // Reset the cursor so the new scope always starts at its newest records. @@ -664,8 +675,8 @@ export function ApprovalRecords({ taskId }: { taskId?: string }) { if (manual) setRefreshing(true); try { const result = taskId - ? await api.interceptTaskPage(taskId, page, pageSize) - : await api.interceptHistoryPage(page, pageSize); + ? await api.interceptTaskPage(taskId, page, pageSize, filter) + : await api.interceptHistoryPage(page, pageSize, filter); if (id !== request.current) return; setRows(result.items); setTotal(result.total); @@ -680,8 +691,10 @@ export function ApprovalRecords({ taskId }: { taskId?: string }) { } } }, - [taskId, page, pageSize], + [taskId, page, pageSize, filter], ); + const latestLoad = React.useRef(load); + latestLoad.current = load; React.useEffect(() => { void load(); @@ -697,6 +710,17 @@ export function ApprovalRecords({ taskId }: { taskId?: string }) { setPage(1); }; + const changeFilter = (next: InterceptApprovalFilter) => { + // An in-flight response for the old filter must not repopulate the table. + request.current++; + setFilter(next); + setPage(1); + setRows([]); + setTotal(0); + setError(""); + setLoading(true); + }; + const decide: Decide = async (id, decision) => { if (decisionLock.current) return; decisionLock.current = true; @@ -705,14 +729,13 @@ export function ApprovalRecords({ taskId }: { taskId?: string }) { await api.interceptDecide(id, decision); // Invalidate a list request started before this decision. request.current++; - setRows((prev) => - prev.map((row) => (row.id === id ? { ...row, status: decision, decided_at: new Date().toISOString() } : row)), - ); setRevision((v) => v + 1); toast.success(decision === "allowed" ? "已允许执行" : "已拒绝执行"); + // Re-fetch counts and rows: a decided item may no longer match the filter. + await latestLoad.current(true); } catch (e) { toast.error((e as Error).message); - await load(true); + await latestLoad.current(true); } finally { decisionLock.current = false; setDeciding(false); @@ -735,6 +758,62 @@ export function ApprovalRecords({ taskId }: { taskId?: string }) {

展开记录查看工具请求和审批裁决,以及当时的上下文与执行结果。

+ + + 审批状态 + + + + 判定来源 + + + {filtered ? ( + + ) : null} + {error ? ( 记录加载失败:{error}。请点击刷新重试。 @@ -750,7 +829,9 @@ export function ApprovalRecords({ taskId }: { taskId?: string }) { ) : null}
-
全部记录({total})
+
+ {filtered ? "筛选结果" : "全部记录"}({total}) +
{loading ? (
@@ -764,8 +845,12 @@ export function ApprovalRecords({ taskId }: { taskId?: string }) { - 暂无审批记录 - 规则或模型作出审批决定后,记录将显示在这里。 + {filtered ? "没有符合筛选条件的审批记录" : "暂无审批记录"} + + {filtered + ? "请调整审批状态或判定来源,或清除筛选查看全部记录。" + : "规则或模型作出审批决定后,记录将显示在这里。"} + ) : null} diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 0eaf5f7..331f91b 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -42,6 +42,7 @@ import type { FindingTraffic, FindingTrafficDetail, IntentAsset, + InterceptApprovalFilter, InterceptApprovalRow, InterceptDetail, InterceptPending, @@ -204,6 +205,13 @@ function findingFilterParams(q: Omit): URLSea return p; } +function interceptPageQuery(page: number, size: number, filter: InterceptApprovalFilter) { + const query = new URLSearchParams({ page: String(page), size: String(size) }); + if (filter.status) query.set("status", filter.status); + if (filter.decision_source) query.set("decision_source", filter.decision_source); + return query.toString(); +} + export const api = { // 后端应用版本号(release 时由 ldflags 注入,默认 "dev")。 health: () => get<{ ok: boolean; service: string; version: string }>("/health"), @@ -1065,8 +1073,8 @@ export const api = { get(`/intercept/history/${id}/execution${conversationId ? `?conversation=${conversationId}` : ""}`), interceptDetail: (id: number) => get(`/intercept/history/${id}`), interceptHistory: () => get<{ items: InterceptApprovalRow[] }>("/intercept/history").then((r) => arr(r.items)), - interceptHistoryPage: (page = 1, size = 20) => - get<{ items: InterceptApprovalRow[]; total?: number }>(`/intercept/history?page=${page}&size=${size}`).then( + 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, @@ -1074,9 +1082,9 @@ export const api = { ), interceptTask: (taskId: string) => get<{ items: InterceptApprovalRow[] }>(`/intercept/task/${taskId}`).then((r) => arr(r.items)), - interceptTaskPage: (taskId: string, page = 1, size = 20) => + interceptTaskPage: (taskId: string, page = 1, size = 20, filter: InterceptApprovalFilter = {}) => get<{ items: InterceptApprovalRow[]; total?: number }>( - `/intercept/task/${encodeURIComponent(taskId)}?page=${page}&size=${size}`, + `/intercept/task/${encodeURIComponent(taskId)}?${interceptPageQuery(page, size, filter)}`, ).then((r) => ({ items: arr(r.items), total: r.total ?? r.items?.length ?? 0, diff --git a/web/src/lib/mock/handler.ts b/web/src/lib/mock/handler.ts index b957ec5..f00bc03 100644 --- a/web/src/lib/mock/handler.ts +++ b/web/src/lib/mock/handler.ts @@ -2076,21 +2076,17 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re } if (seg[0] === "intercept" && seg[1] === "pending" && seg.length === 3 && m === "GET") return mockInterceptPending.find((p) => p.id === Number(seg[2])) ?? null; - if (path === "/intercept/history") { - if (!q.has("page") && !q.has("size")) return { items: mockInterceptHistory, total: mockInterceptHistory.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; - return { - items: mockInterceptHistory.slice(offset, offset + size), - total: mockInterceptHistory.length, - page, - page_size: size, - }; - } - if (seg[0] === "intercept" && seg[1] === "task") { - const filtered = mockInterceptHistory.filter((r) => r.task_id === seg[2]); - if (!q.has("page") && !q.has("size")) return { items: filtered, total: filtered.length }; + if (path === "/intercept/history" || (seg[0] === "intercept" && seg[1] === "task")) { + const status = q.get("status") || ""; + const decisionSource = q.get("decision_source") || ""; + 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); + }); + 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; diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 111660f..03c319b 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -1195,6 +1195,11 @@ export interface JudgeConfig { ask_timeout_action: "allow" | "deny"; // 审批超时后的默认动作 } +export interface InterceptApprovalFilter { + status?: InterceptPending["status"]; + decision_source?: "rule" | "model" | "unknown"; +} + // InterceptApprovalRow enriches InterceptPending with conversation/task and rule context. export interface InterceptApprovalRow extends InterceptPending { conv_title: string; // "" if no linked conversation