mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-10-26 01:34:51 +08:00
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.
This commit is contained in:
1 parent
e167956f95
commit
5261b7e3dc
6 files changed
+404
-206
No files matched your search
@@ -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 <div className={usageTable.skeleton} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={usageTable.scroller}>
|
||||
<table
|
||||
className={cn(usageTable.table, "min-w-[560px]")}
|
||||
aria-label={t("usage.modelStats")}
|
||||
>
|
||||
<thead>
|
||||
<tr className={usageTable.headRow}>
|
||||
<th className={usageTable.th}>{t("usage.model")}</th>
|
||||
<th className={usageTable.thEnd}>{t("usage.requests")}</th>
|
||||
<th className={usageTable.thEnd}>{t("usage.tokens")}</th>
|
||||
<th className={usageTable.thEnd}>{t("usage.totalCost")}</th>
|
||||
<th className={usageTable.thEnd}>{t("usage.avgCost")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className={usageTable.empty}>
|
||||
{t("usage.noData")}
|
||||
</td>
|
||||
<div className="flex flex-col">
|
||||
<div className={usageTable.scroller}>
|
||||
<table
|
||||
className={cn(usageTable.table, "min-w-[560px]")}
|
||||
aria-label={t("usage.modelStats")}
|
||||
>
|
||||
<thead>
|
||||
<tr className={usageTable.headRow}>
|
||||
<th className={usageTable.th}>{t("usage.model")}</th>
|
||||
<th className={usageTable.thEnd}>{t("usage.requests")}</th>
|
||||
<th className={usageTable.thEnd}>{t("usage.tokens")}</th>
|
||||
<th className={usageTable.thEnd}>{t("usage.totalCost")}</th>
|
||||
<th className={usageTable.thEnd}>{t("usage.avgCost")}</th>
|
||||
</tr>
|
||||
) : (
|
||||
rows.map((stat) => (
|
||||
<tr key={stat.model} className={usageTable.row}>
|
||||
<td className={cn(usageTable.td, usageTable.mono)}>
|
||||
<span
|
||||
className="block max-w-[320px] truncate"
|
||||
title={stat.model}
|
||||
>
|
||||
{stat.model}
|
||||
</span>
|
||||
</td>
|
||||
<td className={usageTable.tdEnd}>
|
||||
{fmtInt(stat.requestCount, locale)}
|
||||
</td>
|
||||
<td
|
||||
className={usageTable.tdEnd}
|
||||
title={fmtInt(stat.totalTokens, locale)}
|
||||
>
|
||||
{formatTokensCompact(stat.totalTokens, locale)}
|
||||
</td>
|
||||
<td
|
||||
className={cn(usageTable.tdEnd, "font-medium")}
|
||||
title={fmtUsd(stat.totalCost, 6)}
|
||||
>
|
||||
{fmtUsd(stat.totalCost, 2)}
|
||||
</td>
|
||||
<td
|
||||
className={cn(usageTable.tdEnd, "text-fg-2")}
|
||||
title={fmtUsd(stat.avgCostPerRequest, 6)}
|
||||
>
|
||||
{fmtUsd(stat.avgCostPerRequest, 4)}
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className={usageTable.empty}>
|
||||
{t("usage.noData")}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
pagination.pageRows.map((stat) => (
|
||||
<tr key={stat.model} className={usageTable.row}>
|
||||
<td className={cn(usageTable.td, usageTable.mono)}>
|
||||
<span
|
||||
className="block max-w-[320px] truncate"
|
||||
title={stat.model}
|
||||
>
|
||||
{stat.model}
|
||||
</span>
|
||||
</td>
|
||||
<td className={usageTable.tdEnd}>
|
||||
{fmtInt(stat.requestCount, locale)}
|
||||
</td>
|
||||
<td
|
||||
className={usageTable.tdEnd}
|
||||
title={fmtInt(stat.totalTokens, locale)}
|
||||
>
|
||||
{formatTokensCompact(stat.totalTokens, locale)}
|
||||
</td>
|
||||
<td
|
||||
className={cn(usageTable.tdEnd, "font-medium")}
|
||||
title={fmtUsd(stat.totalCost, 6)}
|
||||
>
|
||||
{fmtUsd(stat.totalCost, 2)}
|
||||
</td>
|
||||
<td
|
||||
className={cn(usageTable.tdEnd, "text-fg-2")}
|
||||
title={fmtUsd(stat.avgCostPerRequest, 6)}
|
||||
>
|
||||
{fmtUsd(stat.avgCostPerRequest, 4)}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<TablePagination
|
||||
page={pagination.page}
|
||||
totalPages={pagination.totalPages}
|
||||
total={pagination.total}
|
||||
onPageChange={pagination.setPage}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="flex flex-col gap-3 pt-3">
|
||||
@@ -403,7 +405,7 @@ export function PricingConfigPanel() {
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
rows.map((model) => {
|
||||
pagination.pageRows.map((model) => {
|
||||
const name = model.displayName || model.modelId;
|
||||
return (
|
||||
<tr
|
||||
@@ -479,6 +481,14 @@ export function PricingConfigPanel() {
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && !error && (
|
||||
<TablePagination
|
||||
page={pagination.page}
|
||||
totalPages={pagination.totalPages}
|
||||
total={pagination.total}
|
||||
onPageChange={pagination.setPage}
|
||||
/>
|
||||
)}
|
||||
|
||||
{editingModel && (
|
||||
<PricingEditModal
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useProviderStats } from "@/lib/query/usage";
|
||||
import { TablePagination, useClientPagination } from "./TablePagination";
|
||||
import { HelpTip } from "@/components/ui/help-tip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
@@ -65,105 +66,117 @@ export function ProviderStatsTable({
|
||||
() => [...(stats ?? [])].sort((a, b) => b.requestCount - a.requestCount),
|
||||
[stats],
|
||||
);
|
||||
const pagination = useClientPagination(
|
||||
rows,
|
||||
JSON.stringify([range, appType, providerName, model]),
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return <div className={usageTable.skeleton} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={usageTable.scroller}>
|
||||
<table
|
||||
className={cn(usageTable.table, "min-w-[620px]")}
|
||||
aria-label={t("usage.providerStats")}
|
||||
>
|
||||
<thead>
|
||||
<tr className={usageTable.headRow}>
|
||||
<th className={usageTable.th}>{t("usage.provider")}</th>
|
||||
<th className={usageTable.thEnd}>{t("usage.requests")}</th>
|
||||
<th className={usageTable.thEnd}>{t("usage.tokens")}</th>
|
||||
<th className={usageTable.thEnd}>{t("usage.cost")}</th>
|
||||
<th className={usageTable.thEnd}>{t("usage.successRate")}</th>
|
||||
<th className={usageTable.thEnd}>
|
||||
<span className="inline-flex items-center gap-0.5">
|
||||
{t("usage.speed")}
|
||||
<HelpTip title={t("usage.speedSumHelpTitle")} align="end">
|
||||
{t("usage.speedSumHelp")}
|
||||
</HelpTip>
|
||||
</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className={usageTable.empty}>
|
||||
{t("usage.noData")}
|
||||
</td>
|
||||
<div className="flex flex-col">
|
||||
<div className={usageTable.scroller}>
|
||||
<table
|
||||
className={cn(usageTable.table, "min-w-[620px]")}
|
||||
aria-label={t("usage.providerStats")}
|
||||
>
|
||||
<thead>
|
||||
<tr className={usageTable.headRow}>
|
||||
<th className={usageTable.th}>{t("usage.provider")}</th>
|
||||
<th className={usageTable.thEnd}>{t("usage.requests")}</th>
|
||||
<th className={usageTable.thEnd}>{t("usage.tokens")}</th>
|
||||
<th className={usageTable.thEnd}>{t("usage.cost")}</th>
|
||||
<th className={usageTable.thEnd}>{t("usage.successRate")}</th>
|
||||
<th className={usageTable.thEnd}>
|
||||
<span className="inline-flex items-center gap-0.5">
|
||||
{t("usage.speed")}
|
||||
<HelpTip title={t("usage.speedSumHelpTitle")} align="end">
|
||||
{t("usage.speedSumHelp")}
|
||||
</HelpTip>
|
||||
</span>
|
||||
</th>
|
||||
</tr>
|
||||
) : (
|
||||
rows.map((stat) => {
|
||||
const exactSpeed = getProviderSpeed(stat);
|
||||
const estimatedSpeed =
|
||||
exactSpeed == null ? getProviderEstimatedSpeed(stat) : null;
|
||||
const speed = exactSpeed ?? estimatedSpeed;
|
||||
return (
|
||||
<tr
|
||||
key={`${stat.providerId}:${stat.providerName}`}
|
||||
className={usageTable.row}
|
||||
>
|
||||
<td className={usageTable.td}>
|
||||
<span
|
||||
className="block max-w-[260px] truncate"
|
||||
title={stat.providerName}
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className={usageTable.empty}>
|
||||
{t("usage.noData")}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
pagination.pageRows.map((stat) => {
|
||||
const exactSpeed = getProviderSpeed(stat);
|
||||
const estimatedSpeed =
|
||||
exactSpeed == null ? getProviderEstimatedSpeed(stat) : null;
|
||||
const speed = exactSpeed ?? estimatedSpeed;
|
||||
return (
|
||||
<tr
|
||||
key={`${stat.providerId}:${stat.providerName}`}
|
||||
className={usageTable.row}
|
||||
>
|
||||
<td className={usageTable.td}>
|
||||
<span
|
||||
className="block max-w-[260px] truncate"
|
||||
title={stat.providerName}
|
||||
>
|
||||
{stat.providerName}
|
||||
</span>
|
||||
</td>
|
||||
<td className={usageTable.tdEnd}>
|
||||
{fmtInt(stat.requestCount, locale)}
|
||||
</td>
|
||||
<td
|
||||
className={usageTable.tdEnd}
|
||||
title={fmtInt(stat.totalTokens, locale)}
|
||||
>
|
||||
{stat.providerName}
|
||||
</span>
|
||||
</td>
|
||||
<td className={usageTable.tdEnd}>
|
||||
{fmtInt(stat.requestCount, locale)}
|
||||
</td>
|
||||
<td
|
||||
className={usageTable.tdEnd}
|
||||
title={fmtInt(stat.totalTokens, locale)}
|
||||
>
|
||||
{formatTokensCompact(stat.totalTokens, locale)}
|
||||
</td>
|
||||
<td
|
||||
className={cn(usageTable.tdEnd, "font-medium")}
|
||||
title={fmtUsd(stat.totalCost, 6)}
|
||||
>
|
||||
{fmtUsd(stat.totalCost, 2)}
|
||||
</td>
|
||||
<td className={usageTable.tdEnd}>
|
||||
{stat.successRate.toFixed(
|
||||
stat.successRate >= 99.95 ? 0 : 1,
|
||||
)}
|
||||
%
|
||||
</td>
|
||||
<td
|
||||
className={cn(
|
||||
usageTable.tdEnd,
|
||||
speed == null && usageTable.muted,
|
||||
)}
|
||||
>
|
||||
{speed == null ? (
|
||||
"—"
|
||||
) : (
|
||||
<>
|
||||
{estimatedSpeed != null && "≈"}
|
||||
{speed}
|
||||
<span className="ms-0.5 text-badge font-normal text-fg-3">
|
||||
tok/s
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
{formatTokensCompact(stat.totalTokens, locale)}
|
||||
</td>
|
||||
<td
|
||||
className={cn(usageTable.tdEnd, "font-medium")}
|
||||
title={fmtUsd(stat.totalCost, 6)}
|
||||
>
|
||||
{fmtUsd(stat.totalCost, 2)}
|
||||
</td>
|
||||
<td className={usageTable.tdEnd}>
|
||||
{stat.successRate.toFixed(
|
||||
stat.successRate >= 99.95 ? 0 : 1,
|
||||
)}
|
||||
%
|
||||
</td>
|
||||
<td
|
||||
className={cn(
|
||||
usageTable.tdEnd,
|
||||
speed == null && usageTable.muted,
|
||||
)}
|
||||
>
|
||||
{speed == null ? (
|
||||
"—"
|
||||
) : (
|
||||
<>
|
||||
{estimatedSpeed != null && "≈"}
|
||||
{speed}
|
||||
<span className="ms-0.5 text-badge font-normal text-fg-3">
|
||||
tok/s
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<TablePagination
|
||||
page={pagination.page}
|
||||
totalPages={pagination.totalPages}
|
||||
total={pagination.total}
|
||||
onPageChange={pagination.setPage}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<string | null>(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({
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="flex h-10 items-center gap-2 text-caption text-fg-3">
|
||||
<span className="tabular-nums">
|
||||
{t("usage.totalRecords", { total })}
|
||||
</span>
|
||||
<div className="flex-1" />
|
||||
<HoverTip content={t("usage.prevPage")}>
|
||||
<button
|
||||
type="button"
|
||||
className="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"
|
||||
aria-label={t("usage.prevPage")}
|
||||
disabled={page === 0}
|
||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</button>
|
||||
</HoverTip>
|
||||
<span className="flex items-center gap-1 tabular-nums text-fg-2">
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
aria-label={t("usage.pageInputPlaceholder")}
|
||||
className="h-6 w-9 rounded-[4px] border border-transparent bg-transparent text-center text-caption text-fg-1 transition-[border-color,box-shadow] hover:border-border-strong focus:border-ring focus:bg-surface focus:outline-none focus:ring-[3px] focus:ring-ring/20"
|
||||
value={pageDraft ?? String(page + 1)}
|
||||
onChange={(event) => setPageDraft(event.target.value)}
|
||||
onFocus={(event) => event.target.select()}
|
||||
onBlur={commitPageDraft}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") commitPageDraft();
|
||||
if (event.key === "Escape") setPageDraft(null);
|
||||
}}
|
||||
/>
|
||||
<span>/ {fmtInt(totalPages, locale)}</span>
|
||||
</span>
|
||||
<HoverTip content={t("usage.nextPage")}>
|
||||
<button
|
||||
type="button"
|
||||
className="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"
|
||||
aria-label={t("usage.nextPage")}
|
||||
disabled={page >= totalPages - 1}
|
||||
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</button>
|
||||
</HoverTip>
|
||||
</div>
|
||||
<TablePagination
|
||||
page={page}
|
||||
totalPages={totalPages}
|
||||
total={total}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<string | null>(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 (
|
||||
<div className="flex h-10 items-center gap-2 text-caption text-fg-3">
|
||||
<span className="tabular-nums">{t("usage.totalRecords", { total })}</span>
|
||||
<div className="flex-1" />
|
||||
<HoverTip content={t("usage.prevPage")}>
|
||||
<button
|
||||
type="button"
|
||||
className={NAV_BUTTON}
|
||||
aria-label={t("usage.prevPage")}
|
||||
disabled={page === 0}
|
||||
onClick={() => onPageChange(Math.max(0, page - 1))}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</button>
|
||||
</HoverTip>
|
||||
<span className="flex items-center gap-1 tabular-nums text-fg-2">
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
aria-label={t("usage.pageInputPlaceholder")}
|
||||
className="h-6 w-9 rounded-[4px] border border-transparent bg-transparent text-center text-caption text-fg-1 transition-[border-color,box-shadow] hover:border-border-strong focus:border-ring focus:bg-surface focus:outline-none focus:ring-[3px] focus:ring-ring/20"
|
||||
value={pageDraft ?? String(page + 1)}
|
||||
onChange={(event) => setPageDraft(event.target.value)}
|
||||
onFocus={(event) => event.target.select()}
|
||||
onBlur={commitPageDraft}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") commitPageDraft();
|
||||
if (event.key === "Escape") setPageDraft(null);
|
||||
}}
|
||||
/>
|
||||
<span>/ {fmtInt(totalPages, locale)}</span>
|
||||
</span>
|
||||
<HoverTip content={t("usage.nextPage")}>
|
||||
<button
|
||||
type="button"
|
||||
className={NAV_BUTTON}
|
||||
aria-label={t("usage.nextPage")}
|
||||
disabled={page >= totalPages - 1}
|
||||
onClick={() => onPageChange(Math.min(totalPages - 1, page + 1))}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</button>
|
||||
</HoverTip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const TABLE_PAGE_SIZE = 20;
|
||||
|
||||
/**
|
||||
* 前端分页:数据一次性拿到、在客户端切页的表格(供应商、模型、定价)用。
|
||||
* `resetKey` 变了(换时间范围 / 筛选)回到第一页;自动刷新只换数据不换 key,
|
||||
* 停在当前页,数据变少时把页码收回到最后一页。
|
||||
*/
|
||||
export function useClientPagination<T>(
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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<string, unknown>) =>
|
||||
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 (
|
||||
<div>
|
||||
<button type="button" onClick={() => setFilter("b")}>
|
||||
change-filter
|
||||
</button>
|
||||
<ul>
|
||||
{pagination.pageRows.map((row) => (
|
||||
<li key={row}>{row}</li>
|
||||
))}
|
||||
</ul>
|
||||
<TablePagination
|
||||
page={pagination.page}
|
||||
totalPages={pagination.totalPages}
|
||||
total={pagination.total}
|
||||
onPageChange={pagination.setPage}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
describe("useClientPagination + TablePagination", () => {
|
||||
it("pages rows 20 at a time and resets when the filter changes", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Harness count={45} />);
|
||||
|
||||
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(<Harness count={45} />);
|
||||
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(<Harness count={25} />);
|
||||
expect(screen.getByText("row-21")).toBeInTheDocument();
|
||||
expect(screen.getByText("/ 2")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user