From ebd9357ceb20b29ca97285ca38a394e20edfb1dd Mon Sep 17 00:00:00 2001 From: Owlbay Date: Sun, 4 Oct 2026 11:40:33 +0800 Subject: [PATCH] feat(usage): paginate the request log, provider, model and pricing tables Extract the request-log pager into a shared TablePagination and add useClientPagination for tables that load all rows at once. Pages hold 20 rows, reset when the range or filters change, and clamp when a refresh returns fewer rows. Reapplies c932436dc, reverted in 6d077f2a1. --- src/components/usage/ModelStatsTable.tsx | 123 ++++++------ src/components/usage/PricingConfigPanel.tsx | 12 +- src/components/usage/ProviderStatsTable.tsx | 195 +++++++++++--------- src/components/usage/RequestLogTable.tsx | 66 +------ src/components/usage/TablePagination.tsx | 126 +++++++++++++ tests/components/TablePagination.test.tsx | 88 +++++++++ 6 files changed, 404 insertions(+), 206 deletions(-) create mode 100644 src/components/usage/TablePagination.tsx create mode 100644 tests/components/TablePagination.test.tsx diff --git a/src/components/usage/ModelStatsTable.tsx b/src/components/usage/ModelStatsTable.tsx index 9eca65436..435adf63e 100644 --- a/src/components/usage/ModelStatsTable.tsx +++ b/src/components/usage/ModelStatsTable.tsx @@ -1,6 +1,7 @@ import { useMemo } from "react"; import { useTranslation } from "react-i18next"; import { useModelStats } from "@/lib/query/usage"; +import { TablePagination, useClientPagination } from "./TablePagination"; import { cn } from "@/lib/utils"; import { fmtInt, @@ -41,70 +42,82 @@ export function ModelStatsTable({ () => [...(stats ?? [])].sort((a, b) => b.requestCount - a.requestCount), [stats], ); + const pagination = useClientPagination( + rows, + JSON.stringify([range, appType, providerName, model]), + ); if (isLoading) { return
; } return ( -
- - - - - - - - - - - - {rows.length === 0 ? ( - - +
+
+
{t("usage.model")}{t("usage.requests")}{t("usage.tokens")}{t("usage.totalCost")}{t("usage.avgCost")}
- {t("usage.noData")} -
+ + + + + + + - ) : ( - rows.map((stat) => ( - - - - - - + + {rows.length === 0 ? ( + + - )) - )} - -
{t("usage.model")}{t("usage.requests")}{t("usage.tokens")}{t("usage.totalCost")}{t("usage.avgCost")}
- - {stat.model} - - - {fmtInt(stat.requestCount, locale)} - - {formatTokensCompact(stat.totalTokens, locale)} - - {fmtUsd(stat.totalCost, 2)} - - {fmtUsd(stat.avgCostPerRequest, 4)} +
+ {t("usage.noData")}
+ ) : ( + pagination.pageRows.map((stat) => ( + + + + {stat.model} + + + + {fmtInt(stat.requestCount, locale)} + + + {formatTokensCompact(stat.totalTokens, locale)} + + + {fmtUsd(stat.totalCost, 2)} + + + {fmtUsd(stat.avgCostPerRequest, 4)} + + + )) + )} + + +
+
); } diff --git a/src/components/usage/PricingConfigPanel.tsx b/src/components/usage/PricingConfigPanel.tsx index d08fc0d69..ea410bc90 100644 --- a/src/components/usage/PricingConfigPanel.tsx +++ b/src/components/usage/PricingConfigPanel.tsx @@ -24,6 +24,7 @@ import { import { ConfirmDialog } from "@/components/ConfirmDialog"; import { AppGlyph, APP_DISPLAY_NAME } from "@/components/shell/AppGlyph"; import { useModelPricing, useDeleteModelPricing } from "@/lib/query/usage"; +import { TablePagination, useClientPagination } from "./TablePagination"; import { proxyApi } from "@/lib/api/proxy"; import type { ModelPricing } from "@/types/usage"; import { cn } from "@/lib/utils"; @@ -329,6 +330,7 @@ export function PricingConfigPanel() { }; const rows = pricing ?? []; + const pagination = useClientPagination(rows); return (
@@ -403,7 +405,7 @@ export function PricingConfigPanel() { ) : ( - rows.map((model) => { + pagination.pageRows.map((model) => { const name = model.displayName || model.modelId; return (
)} + {!isLoading && !error && ( + + )} {editingModel && ( [...(stats ?? [])].sort((a, b) => b.requestCount - a.requestCount), [stats], ); + const pagination = useClientPagination( + rows, + JSON.stringify([range, appType, providerName, model]), + ); if (isLoading) { return
; } return ( -
- - - - - - - - - - - - - {rows.length === 0 ? ( - - +
+
+
{t("usage.provider")}{t("usage.requests")}{t("usage.tokens")}{t("usage.cost")}{t("usage.successRate")} - - {t("usage.speed")} - - {t("usage.speedSumHelp")} - - -
- {t("usage.noData")} -
+ + + + + + + + - ) : ( - rows.map((stat) => { - const exactSpeed = getProviderSpeed(stat); - const estimatedSpeed = - exactSpeed == null ? getProviderEstimatedSpeed(stat) : null; - const speed = exactSpeed ?? estimatedSpeed; - return ( - - + {rows.length === 0 ? ( + + + + ) : ( + pagination.pageRows.map((stat) => { + const exactSpeed = getProviderSpeed(stat); + const estimatedSpeed = + exactSpeed == null ? getProviderEstimatedSpeed(stat) : null; + const speed = exactSpeed ?? estimatedSpeed; + return ( + + + + - - - - - - - ); - }) - )} - -
{t("usage.provider")}{t("usage.requests")}{t("usage.tokens")}{t("usage.cost")}{t("usage.successRate")} + + {t("usage.speed")} + + {t("usage.speedSumHelp")} + + +
- +
+ {t("usage.noData")} +
+ + {stat.providerName} + + + {fmtInt(stat.requestCount, locale)} + - {stat.providerName} - - - {fmtInt(stat.requestCount, locale)} - - {formatTokensCompact(stat.totalTokens, locale)} - - {fmtUsd(stat.totalCost, 2)} - - {stat.successRate.toFixed( - stat.successRate >= 99.95 ? 0 : 1, - )} - % - - {speed == null ? ( - "—" - ) : ( - <> - {estimatedSpeed != null && "≈"} - {speed} - - tok/s - - - )} -
+ {formatTokensCompact(stat.totalTokens, locale)} + + + {fmtUsd(stat.totalCost, 2)} + + + {stat.successRate.toFixed( + stat.successRate >= 99.95 ? 0 : 1, + )} + % + + + {speed == null ? ( + "—" + ) : ( + <> + {estimatedSpeed != null && "≈"} + {speed} + + tok/s + + + )} + + + ); + }) + )} + + +
+
); } diff --git a/src/components/usage/RequestLogTable.tsx b/src/components/usage/RequestLogTable.tsx index 24ed759c8..71d1febba 100644 --- a/src/components/usage/RequestLogTable.tsx +++ b/src/components/usage/RequestLogTable.tsx @@ -1,9 +1,8 @@ import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; -import { ChevronLeft, ChevronRight } from "lucide-react"; import { useRequestLogs } from "@/lib/query/usage"; +import { TablePagination } from "./TablePagination"; import { HelpTip } from "@/components/ui/help-tip"; -import { HoverTip } from "@/components/ui/hover-tip"; import { AppGlyph, APP_DISPLAY_NAME } from "@/components/shell/AppGlyph"; import type { AppId } from "@/lib/api"; import { @@ -111,7 +110,6 @@ export function RequestLogTable({ }: RequestLogTableProps) { const { t, i18n } = useTranslation(); const [page, setPage] = useState(0); - const [pageDraft, setPageDraft] = useState(null); const pageSize = 20; const effectiveFilters: LogFilters = { @@ -140,7 +138,6 @@ export function RequestLogTable({ useEffect(() => { setPage(0); - setPageDraft(null); }, [ dashboardAppType, providerName, @@ -151,16 +148,6 @@ export function RequestLogTable({ range.preset, ]); - const commitPageDraft = () => { - if (pageDraft == null) return; - const trimmed = pageDraft.trim(); - setPageDraft(null); - if (!/^\d+$/.test(trimmed)) return; - const parsed = Number(trimmed); - if (parsed < 1 || parsed > totalPages) return; - setPage(parsed - 1); - }; - const language = i18n.resolvedLanguage || i18n.language || "en"; const locale = getLocaleFromLanguage(language); // 「今天」按本地时区判断;每次渲染(含自动刷新)取一次当前时间 @@ -364,51 +351,12 @@ export function RequestLogTable({ -
- - {t("usage.totalRecords", { total })} - -
- - - - - setPageDraft(event.target.value)} - onFocus={(event) => event.target.select()} - onBlur={commitPageDraft} - onKeyDown={(event) => { - if (event.key === "Enter") commitPageDraft(); - if (event.key === "Escape") setPageDraft(null); - }} - /> - / {fmtInt(totalPages, locale)} - - - - -
+
); } diff --git a/src/components/usage/TablePagination.tsx b/src/components/usage/TablePagination.tsx new file mode 100644 index 000000000..36f95b9e5 --- /dev/null +++ b/src/components/usage/TablePagination.tsx @@ -0,0 +1,126 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { ChevronLeft, ChevronRight } from "lucide-react"; +import { HoverTip } from "@/components/ui/hover-tip"; +import { fmtInt, getLocaleFromLanguage } from "./format"; + +interface TablePaginationProps { + /** 从 0 开始的页码 */ + page: number; + totalPages: number; + /** 总条数,显示在左侧 */ + total: number; + onPageChange: (page: number) => void; +} + +const NAV_BUTTON = + "inline-flex h-7 w-7 items-center justify-center rounded-control text-fg-2 transition-colors hover:bg-subtle hover:text-fg-1 disabled:pointer-events-none disabled:opacity-45"; + +/** 用量页表格共用的翻页条:总数、上一页、可输入的页码、下一页。 */ +export function TablePagination({ + page, + totalPages, + total, + onPageChange, +}: TablePaginationProps) { + const { t, i18n } = useTranslation(); + const locale = getLocaleFromLanguage( + i18n.resolvedLanguage || i18n.language || "en", + ); + const [pageDraft, setPageDraft] = useState(null); + + // 页码从外部变了(翻页、筛选重置)时丢掉没提交的输入 + useEffect(() => { + setPageDraft(null); + }, [page]); + + const commitPageDraft = () => { + if (pageDraft == null) return; + const trimmed = pageDraft.trim(); + setPageDraft(null); + if (!/^\d+$/.test(trimmed)) return; + const parsed = Number(trimmed); + if (parsed < 1 || parsed > totalPages) return; + onPageChange(parsed - 1); + }; + + return ( +
+ {t("usage.totalRecords", { total })} +
+ + + + + setPageDraft(event.target.value)} + onFocus={(event) => event.target.select()} + onBlur={commitPageDraft} + onKeyDown={(event) => { + if (event.key === "Enter") commitPageDraft(); + if (event.key === "Escape") setPageDraft(null); + }} + /> + / {fmtInt(totalPages, locale)} + + + + +
+ ); +} + +export const TABLE_PAGE_SIZE = 20; + +/** + * 前端分页:数据一次性拿到、在客户端切页的表格(供应商、模型、定价)用。 + * `resetKey` 变了(换时间范围 / 筛选)回到第一页;自动刷新只换数据不换 key, + * 停在当前页,数据变少时把页码收回到最后一页。 + */ +export function useClientPagination( + rows: readonly T[], + resetKey: string = "", + pageSize: number = TABLE_PAGE_SIZE, +) { + const [page, setPage] = useState(0); + const totalPages = Math.max(1, Math.ceil(rows.length / pageSize)); + + useEffect(() => { + setPage(0); + }, [resetKey]); + + const currentPage = Math.min(page, totalPages - 1); + const pageRows = rows.slice( + currentPage * pageSize, + (currentPage + 1) * pageSize, + ); + + return { + page: currentPage, + setPage, + totalPages, + total: rows.length, + pageRows, + }; +} diff --git a/tests/components/TablePagination.test.tsx b/tests/components/TablePagination.test.tsx new file mode 100644 index 000000000..f4b306fd9 --- /dev/null +++ b/tests/components/TablePagination.test.tsx @@ -0,0 +1,88 @@ +import { useState } from "react"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { + TablePagination, + useClientPagination, +} from "@/components/usage/TablePagination"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string, options?: Record) => + options && "total" in options ? `${key}:${options.total}` : key, + i18n: { resolvedLanguage: "en", language: "en" }, + }), +})); + +function Harness({ count }: { count: number }) { + const [filter, setFilter] = useState("a"); + const rows = Array.from({ length: count }, (_, i) => `row-${i + 1}`); + const pagination = useClientPagination(rows, filter); + return ( +
+ +
    + {pagination.pageRows.map((row) => ( +
  • {row}
  • + ))} +
+ +
+ ); +} + +describe("useClientPagination + TablePagination", () => { + it("pages rows 20 at a time and resets when the filter changes", async () => { + const user = userEvent.setup(); + render(); + + expect(screen.getByText("usage.totalRecords:45")).toBeInTheDocument(); + expect(screen.getAllByRole("listitem")).toHaveLength(20); + expect(screen.getByText("/ 3")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "usage.prevPage" }), + ).toBeDisabled(); + + await user.click(screen.getByRole("button", { name: "usage.nextPage" })); + expect(screen.getByText("row-21")).toBeInTheDocument(); + + // 输入页码跳到最后一页:只剩 5 行 + const input = screen.getByRole("textbox", { + name: "usage.pageInputPlaceholder", + }); + await user.clear(input); + await user.type(input, "3{Enter}"); + expect(screen.getAllByRole("listitem")).toHaveLength(5); + expect( + screen.getByRole("button", { name: "usage.nextPage" }), + ).toBeDisabled(); + + // 超出范围的页码不生效 + await user.clear(input); + await user.type(input, "9{Enter}"); + expect(screen.getByText("row-41")).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "change-filter" })); + expect(screen.getByText("row-1")).toBeInTheDocument(); + }); + + it("clamps to the last page when rows shrink", async () => { + const user = userEvent.setup(); + const { rerender } = render(); + await user.click(screen.getByRole("button", { name: "usage.nextPage" })); + await user.click(screen.getByRole("button", { name: "usage.nextPage" })); + expect(screen.getByText("row-41")).toBeInTheDocument(); + + rerender(); + expect(screen.getByText("row-21")).toBeInTheDocument(); + expect(screen.getByText("/ 2")).toBeInTheDocument(); + }); +});