diff --git a/crates/wb-switch-core/src/modules/official_usage.rs b/crates/wb-switch-core/src/modules/official_usage.rs index ce7763d..3c4ad75 100644 --- a/crates/wb-switch-core/src/modules/official_usage.rs +++ b/crates/wb-switch-core/src/modules/official_usage.rs @@ -37,7 +37,13 @@ fn official_usage_url_for(account: &Value) -> &'static str { } } pub const OFFICIAL_USAGE_PAGE_SIZE: usize = 3_000; -pub const OFFICIAL_USAGE_DETAIL_LIMIT: usize = 100; +/// 每个账号落进 `requests` 明细的条数上限。 +/// +/// 官方接口已全量扫回(见 [`OFFICIAL_USAGE_PAGE_SIZE`] 的分页扫描),这里只是 +/// 「明细对外暴露多少条」的闸门:定得太低,用户排查高消耗请求时看不到更早的 +/// 记录;不设上限,重度账号会把明细撑到几万条,缓存文件与前端渲染一起变慢。 +/// 1000 条配合前端分页与按消耗排序,足够覆盖排查场景,同时保持有界。 +pub const OFFICIAL_USAGE_DETAIL_LIMIT: usize = 1_000; /// 连续扫描的轮数上限:每轮最多 [`OFFICIAL_USAGE_PAGE_SIZE`] 条,100 轮足够覆盖 /// 单账号单窗口的任何真实量级,同时兜住「服务端异常回同样一页」的死循环。 const OFFICIAL_USAGE_MAX_ROUNDS: usize = 100; diff --git a/src/pages/CreditStatsPage.tsx b/src/pages/CreditStatsPage.tsx index 60e33f7..28a5016 100644 --- a/src/pages/CreditStatsPage.tsx +++ b/src/pages/CreditStatsPage.tsx @@ -1,9 +1,12 @@ import { useCallback, useEffect, useMemo, useState, type ComponentProps } from "react"; import { Bar, BarChart, CartesianGrid, Rectangle, XAxis, YAxis } from "recharts"; import { + ArrowUpDown, CalendarDays, CalendarRange, Check, + ChevronLeft, + ChevronRight, CircleAlert, CircleCheck, Loader2, @@ -1236,6 +1239,76 @@ function OfficialRequestRow({ ); } +/** 请求用量明细每页条数;明细由后端一次性返回,分页只控制单页渲染量 */ +const REQUEST_PAGE_SIZE = 100; + +/** 请求用量明细的排序维度:请求时间(默认倒序)与消耗 */ +type RequestSortKey = "time" | "credit"; +type SortDirection = "desc" | "asc"; + +/** 明细行的时间戳:requestTime 是「YYYY-MM-DD HH:mm:ss」文本,按字典序即时间序 */ +function requestSortValue(request: CreditOfficialUsageRequest, key: RequestSortKey): number | string { + if (key === "credit") return request.credit ?? 0; + return request.requestTime ?? ""; +} + +function compareRequests( + left: CreditOfficialUsageRequest, + right: CreditOfficialUsageRequest, + key: RequestSortKey, + direction: SortDirection, +): number { + const leftValue = requestSortValue(left, key); + const rightValue = requestSortValue(right, key); + let order: number; + if (typeof leftValue === "number" && typeof rightValue === "number") { + order = leftValue - rightValue; + } else { + order = String(leftValue).localeCompare(String(rightValue)); + } + if (order === 0) { + // 同值时用请求 ID 兜底,保证排序稳定(数组排序在 V8 中稳定,但仍显式保证) + order = (left.requestId ?? "").localeCompare(right.requestId ?? ""); + } + return direction === "desc" ? -order : order; +} + +function SortableHeader({ + label, + sortKey, + activeKey, + direction, + onSort, + align = "left", +}: { + label: string; + sortKey: RequestSortKey; + activeKey: RequestSortKey; + direction: SortDirection; + onSort: (key: RequestSortKey) => void; + align?: "left" | "right"; +}) { + const active = activeKey === sortKey; + return ( + + + + ); +} + function OfficialUsageBreakdown({ officialUsage, accountId, @@ -1243,7 +1316,25 @@ function OfficialUsageBreakdown({ officialUsage?: CreditOfficialUsage; accountId: string | null; }) { + const [sortKey, setSortKey] = useState("time"); + const [sortDirection, setSortDirection] = useState("desc"); + const [page, setPage] = useState(0); const officialAvailable = isOfficialUsageAvailable(officialUsage); + + // 换账号或换排序都回到第一页;重新采集后数据整体更换,同样重置 + useEffect(() => { + setPage(0); + }, [accountId, sortKey, sortDirection, officialUsage?.collectedAt]); + + const toggleSort = (key: RequestSortKey) => { + if (key === sortKey) { + setSortDirection((current) => (current === "desc" ? "asc" : "desc")); + return; + } + setSortKey(key); + // 新列默认从「最大」开始看:消耗列先看最高消耗,时间列看最近请求 + setSortDirection("desc"); + }; const account = accountId ? officialAccountFor(officialUsage, accountId) : undefined; if (!officialAvailable || !officialUsage) { @@ -1279,13 +1370,21 @@ function OfficialUsageBreakdown({ : officialUsage.accounts.some((item) => item.detailTruncated); const showAccount = !account; + const sortedRequests = [...requests].sort((left, right) => + compareRequests(left, right, sortKey, sortDirection), + ); + const pageCount = Math.max(1, Math.ceil(sortedRequests.length / REQUEST_PAGE_SIZE)); + const safePage = Math.min(Math.max(page, 0), pageCount - 1); + const pageStart = safePage * REQUEST_PAGE_SIZE; + const pageRows = sortedRequests.slice(pageStart, pageStart + REQUEST_PAGE_SIZE); + return (
{detailTruncated && (
- 仅展示最近 {officialUsage.detailLimitPerAccount} 条请求明细;合计使用官方返回的全部 {formatCredits(totalRequests)} 条请求。 + 最多展示每账号最近 {officialUsage.detailLimitPerAccount} 条请求明细;合计使用官方返回的全部 {formatCredits(totalRequests)} 条请求。
)} @@ -1298,16 +1397,29 @@ function OfficialUsageBreakdown({ - + {showAccount && } - + - {requests.map((request) => ( + {pageRows.map((request) => (
请求时间账号消耗模型 客户端 请求 ID
+ {sortedRequests.length > REQUEST_PAGE_SIZE && ( +
+ + 第 {pageStart + 1}–{Math.min(pageStart + REQUEST_PAGE_SIZE, sortedRequests.length)} 条,共{" "} + {sortedRequests.length} 条 + +
+ + + {safePage + 1} / {pageCount} + + +
+
+ )}
)}