feat: 为审批记录增加翻页 (#115)
This commit is contained in:
1 parent
5db71f7c53
commit
71fa919599
5 files changed
+179
-14
No files matched your search
@@ -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()
|
||||
}
|
||||
+48
-4
@@ -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) {
|
||||
|
||||
@@ -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<InterceptApprovalRow[]>([]);
|
||||
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 }) {
|
||||
</section>
|
||||
) : null}
|
||||
<section className="overflow-hidden rounded-xl border">
|
||||
<div className="border-b px-4 py-3 font-medium text-sm">
|
||||
{taskId ? "全部记录" : "最近记录"}({rows.length})
|
||||
</div>
|
||||
<div className="border-b px-4 py-3 font-medium text-sm">全部记录({total})</div>
|
||||
{loading ? (
|
||||
<div className="flex flex-col gap-3 p-4">
|
||||
<Skeleton className="h-10 w-full" />
|
||||
@@ -580,6 +597,16 @@ export function ApprovalRecords({ taskId }: { taskId?: string }) {
|
||||
{rows.length ? (
|
||||
<ApprovalTable rows={rows} busy={deciding} decide={decide} revision={revision} label="审批记录列表" />
|
||||
) : null}
|
||||
{!loading && !error ? (
|
||||
<TablePagination
|
||||
page={page}
|
||||
pageSize={pageSize}
|
||||
total={total}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={changePageSize}
|
||||
pageSizeOptions={[10, 20, 50]}
|
||||
/>
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1063,8 +1063,22 @@ export const api = {
|
||||
post<{ ok: boolean }>(`/intercept/pending/${id}/decide`, { decision }),
|
||||
interceptDetail: (id: number) => get<InterceptDetail>(`/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[] }> => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user