diff --git a/db/intercept.go b/db/intercept.go index b2d4ee6..bc60f9b 100644 --- a/db/intercept.go +++ b/db/intercept.go @@ -3,6 +3,7 @@ package db import ( "database/sql" "encoding/json" + "fmt" "strings" "time" ) @@ -278,6 +279,13 @@ 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) +} + // ListTaskIntercepts returns all intercept_pending rows for a specific task (newest first). func (d *DB) ListTaskIntercepts(taskID string) ([]InterceptApprovalRow, error) { rows, err := d.Query(approvalRowSelect+` WHERE ip.task_id=$1 ORDER BY ip.created_at DESC`, taskID) @@ -295,3 +303,53 @@ func (d *DB) ListTaskIntercepts(taskID string) ([]InterceptApprovalRow, error) { } return out, rows.Err() } + +// 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) listInterceptsPage(taskID string, page, size int) ([]InterceptApprovalRow, int, error) { + if page < 1 { + page = 1 + } + if size <= 0 { + size = 20 + } + if size > 100 { + size = 100 + } + offset := (page - 1) * size + + where := "" + args := []any{} + if taskID != "" { + where = " WHERE ip.task_id=$1" + args = append(args, taskID) + } + var total int + if err := d.QueryRow("SELECT COUNT(*) FROM intercept_pending ip"+where, args...).Scan(&total); err != nil { + return nil, 0, err + } + + limitArg := len(args) + 1 + offsetArg := limitArg + 1 + dataQ := approvalRowSelect + where + + " ORDER BY ip.created_at DESC, ip.id DESC LIMIT $" + fmt.Sprint(limitArg) + + " OFFSET $" + fmt.Sprint(offsetArg) + args = append(args, size, offset) + rows, err := d.Query(dataQ, args...) + if err != nil { + return nil, 0, err + } + defer rows.Close() + out := []InterceptApprovalRow{} + for rows.Next() { + var r InterceptApprovalRow + if err := scanInterceptApprovalRow(rows, &r); err != nil { + return nil, 0, err + } + out = append(out, r) + } + return out, total, rows.Err() +} diff --git a/server/intercept.go b/server/intercept.go index 76a34dc..455a355 100644 --- a/server/intercept.go +++ b/server/intercept.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "net/http" + "net/url" "regexp" "strings" @@ -234,7 +235,21 @@ func (s *Server) interceptListTaskItems(w http.ResponseWriter, r *http.Request) writeErr(w, 400, "bad task id") return } - items, err := pg.ListTaskIntercepts(taskID) + q := r.URL.Query() + if q.Get("page") == "" && q.Get("size") == "" { + items, err := pg.ListTaskIntercepts(taskID) + if err != nil { + writeErr(w, 500, err.Error()) + return + } + if items == nil { + items = []db.InterceptApprovalRow{} + } + writeJSON(w, 200, map[string]any{"items": items, "total": len(items)}) + return + } + page, size := interceptPageParams(q) + items, total, err := pg.ListTaskInterceptsPage(taskID, page, size) if err != nil { writeErr(w, 500, err.Error()) return @@ -242,7 +257,7 @@ func (s *Server) interceptListTaskItems(w http.ResponseWriter, r *http.Request) if items == nil { items = []db.InterceptApprovalRow{} } - writeJSON(w, 200, map[string]any{"items": items}) + writeJSON(w, 200, map[string]any{"items": items, "total": total, "page": page, "page_size": size}) } func (s *Server) interceptHistory(w http.ResponseWriter, r *http.Request) { @@ -250,7 +265,21 @@ func (s *Server) interceptHistory(w http.ResponseWriter, r *http.Request) { if pg == nil { return } - items, err := pg.ListAllIntercepts(200) + q := r.URL.Query() + if q.Get("page") == "" && q.Get("size") == "" { + items, err := pg.ListAllIntercepts(200) + if err != nil { + writeErr(w, 500, err.Error()) + return + } + if items == nil { + items = []db.InterceptApprovalRow{} + } + writeJSON(w, 200, map[string]any{"items": items, "total": len(items)}) + return + } + page, size := interceptPageParams(q) + items, total, err := pg.ListAllInterceptsPage(page, size) if err != nil { writeErr(w, 500, err.Error()) return @@ -258,7 +287,22 @@ func (s *Server) interceptHistory(w http.ResponseWriter, r *http.Request) { if items == nil { items = []db.InterceptApprovalRow{} } - writeJSON(w, 200, map[string]any{"items": items}) + writeJSON(w, 200, map[string]any{"items": items, "total": total, "page": page, "page_size": size}) +} + +func interceptPageParams(q url.Values) (int, int) { + page := atoiDefault(q.Get("page"), 1) + size := atoiDefault(q.Get("size"), 20) + if page < 1 { + page = 1 + } + if size < 1 { + size = 20 + } + if size > 100 { + size = 100 + } + return page, size } func (s *Server) interceptDecide(w http.ResponseWriter, r *http.Request) { diff --git a/web/src/components/approval-records.tsx b/web/src/components/approval-records.tsx index 12d6c18..aa53ddc 100644 --- a/web/src/components/approval-records.tsx +++ b/web/src/components/approval-records.tsx @@ -15,6 +15,7 @@ import { } from "lucide-react"; import { toast } from "sonner"; +import { TablePagination } from "@/components/table-pagination"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -464,6 +465,9 @@ function ApprovalTable({ export function ApprovalRecords({ taskId }: { taskId?: string }) { const [rows, setRows] = React.useState([]); + const [page, setPage] = React.useState(1); + const [pageSize, setPageSize] = React.useState(20); + const [total, setTotal] = React.useState(0); const [loading, setLoading] = React.useState(true); const [refreshing, setRefreshing] = React.useState(false); const [error, setError] = React.useState(""); @@ -472,14 +476,24 @@ export function ApprovalRecords({ taskId }: { taskId?: string }) { const request = React.useRef(0); const decisionLock = React.useRef(false); + // 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. + // biome-ignore lint/correctness/useExhaustiveDependencies: taskId intentionally resets pagination when scope changes. + React.useEffect(() => { + setPage(1); + }, [taskId]); + const load = React.useCallback( async (manual = false) => { const id = ++request.current; if (manual) setRefreshing(true); try { - const items = taskId ? await api.interceptTask(taskId) : await api.interceptHistory(); + const result = taskId + ? await api.interceptTaskPage(taskId, page, pageSize) + : await api.interceptHistoryPage(page, pageSize); if (id !== request.current) return; - setRows(items); + setRows(result.items); + setTotal(result.total); setError(""); if (manual) setRevision((v) => v + 1); } catch (e) { @@ -491,7 +505,7 @@ export function ApprovalRecords({ taskId }: { taskId?: string }) { } } }, - [taskId], + [taskId, page, pageSize], ); React.useEffect(() => { @@ -503,6 +517,11 @@ export function ApprovalRecords({ taskId }: { taskId?: string }) { }; }, [load]); + const changePageSize = (next: number) => { + setPageSize(next); + setPage(1); + }; + const decide: Decide = async (id, decision) => { if (decisionLock.current) return; decisionLock.current = true; @@ -556,9 +575,7 @@ export function ApprovalRecords({ taskId }: { taskId?: string }) { ) : null}
-
- {taskId ? "全部记录" : "最近记录"}({rows.length}) -
+
全部记录({total})
{loading ? (
@@ -580,6 +597,16 @@ export function ApprovalRecords({ taskId }: { taskId?: string }) { {rows.length ? ( ) : null} + {!loading && !error ? ( + + ) : null}
); diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index a5842ba..64fd90e 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -1063,8 +1063,22 @@ export const api = { post<{ ok: boolean }>(`/intercept/pending/${id}/decide`, { decision }), 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( + (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) => + get<{ items: InterceptApprovalRow[]; total?: number }>( + `/intercept/task/${encodeURIComponent(taskId)}?page=${page}&size=${size}`, + ).then((r) => ({ + items: arr(r.items), + total: r.total ?? r.items?.length ?? 0, + })), // ---- intercept tool-config (全局工具拦截范围) ---- interceptGetToolConfig: async (): Promise<{ enabled_tools: string[] }> => { diff --git a/web/src/lib/mock/handler.ts b/web/src/lib/mock/handler.ts index 1bb585b..b957ec5 100644 --- a/web/src/lib/mock/handler.ts +++ b/web/src/lib/mock/handler.ts @@ -2056,7 +2056,7 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re 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); - if (!row || row.status !== "pending") throw new Error("审批已处理或不存在,请刷新记录"); + if (row?.status !== "pending") throw new Error("审批已处理或不存在,请刷新记录"); if (b.decision !== "allowed" && b.decision !== "denied") throw new Error("无效审批动作"); row.status = b.decision; row.decided_at = new Date().toISOString(); @@ -2076,9 +2076,31 @@ 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") return { items: mockInterceptHistory }; - if (seg[0] === "intercept" && seg[1] === "task") - return { items: mockInterceptHistory.filter((r) => r.task_id === seg[2]) }; + 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 }; + 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: filtered.slice(offset, offset + size), + total: filtered.length, + page, + page_size: size, + }; + } if (path === "/intercept/tool-config") return { enabled_tools: ["bash"] }; if (path === "/intercept/judge" && m === "GET") return {