From bf718ff519cc97c82fb54241d18c2faff0c763a7 Mon Sep 17 00:00:00 2001 From: Magicapple <1540796514@qq.com> Date: Fri, 25 Sep 2026 19:07:01 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(credit-stats):=20=E7=A7=AF=E5=88=86?= =?UTF-8?q?=E7=BB=9F=E8=AE=A1=E4=B8=89=E6=A8=A1=E5=9D=97=E8=B4=A6=E5=8F=B7?= =?UTF-8?q?=E8=81=94=E5=8A=A8=EF=BC=8C=E8=AF=B7=E6=B1=82=E6=98=8E=E7=BB=86?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E6=8E=92=E5=BA=8F=E4=B8=8E=E5=88=86=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 趋势 / 按模型分类 / 积分明细三处的账号选择提升为页面级共享状态, 任一处切换,三处同步(#98 建议一) - 请求用量明细表头「请求时间」「消耗」可点击切换升降序,默认时间倒序(#98 建议二) - 明细超过 100 条时分页展示,显示区间与页码 - 后端每账号明细上限 100 → 1000:官方接口本来就全量扫回, 100 条截不住排查高消耗请求所需的更早记录 closes #98 --- .../src/modules/official_usage.rs | 8 +- src/pages/CreditStatsPage.tsx | 190 ++++++++++++++++-- 2 files changed, 182 insertions(+), 16 deletions(-) diff --git a/crates/wb-switch-core/src/modules/official_usage.rs b/crates/wb-switch-core/src/modules/official_usage.rs index ce7763d3..3c4ad753 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 60e33f75..a316bab7 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, @@ -577,12 +580,15 @@ function buildStackedChart( function TrendChart({ stats, officialUsage, + accountFilter, + onAccountFilterChange, }: { stats: CreditStatistics; officialUsage?: CreditOfficialUsage; + /** 与同页其他模块共享,null = 所有账号汇总 */ + accountFilter: string | null; + onAccountFilterChange: (accountId: string | null) => void; }) { - /** null = 所有账号汇总;本卡片独立,不影响其他卡片 */ - const [accountFilter, setAccountFilter] = useState(null); /** 本卡片独立的时间范围,不影响其他卡片 */ const [range, setRange] = useState("30d"); const official = isOfficialUsageAvailable(officialUsage) ? officialUsage : undefined; @@ -658,7 +664,7 @@ function TrendChart({
@@ -949,11 +955,14 @@ function ModelBreakdownRows({ models }: { models: CreditOfficialUsageModel[] }) function ModelBreakdown({ officialUsage, + accountFilter, + onAccountFilterChange, }: { officialUsage: CreditOfficialUsage; + /** 与同页其他模块共享,null = 所有账号汇总 */ + accountFilter: string | null; + onAccountFilterChange: (accountId: string | null) => void; }) { - /** null = 所有账号汇总;本卡片独立,不影响其他卡片 */ - const [accountFilter, setAccountFilter] = useState(null); /** 本卡片独立的时间范围,不影响其他卡片 */ const [range, setRange] = useState("30d"); const filterAccounts = officialUsage.accounts; @@ -1002,7 +1011,7 @@ function ModelBreakdown({
@@ -1236,6 +1245,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 +1322,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 +1376,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 +1403,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} + + +
+
+ )}
)}
@@ -1401,19 +1552,20 @@ function SelectedAccountDetails({ officialUsage, creditMap, creditLoadingMap, + accountFilter, + onAccountFilterChange, }: { stats: CreditStatistics; officialUsage?: CreditOfficialUsage; creditMap: Record; creditLoadingMap: Record; + /** 与同页其他模块共享;本卡片只允许单账号,未指定时回退到第一个账号 */ + accountFilter: string | null; + onAccountFilterChange: (accountId: string | null) => void; }) { const [detailTab, setDetailTab] = useState<"credits" | "requests">("credits"); const official = isOfficialUsageAvailable(officialUsage) ? officialUsage : undefined; const filterAccounts = official ? official.accounts : stats.accounts; - /** 本卡片仅允许选择单个账号,默认第一个账号 */ - const [accountFilter, setAccountFilter] = useState( - () => filterAccounts[0]?.accountId ?? null, - ); const activeFilterAccount = accountFilter && filterAccounts.some((account) => account.accountId === accountFilter) ? filterAccounts.find((account) => account.accountId === accountFilter) @@ -1453,7 +1605,7 @@ function SelectedAccountDetails({ @@ -1572,6 +1724,8 @@ export default function CreditStatsPage() { const [loading, setLoading] = useState(!cachedStatistics); const [error, setError] = useState(null); const [viewVariant, setViewVariant] = useState("all"); + /** 页面级共享:在任一模块切换账号,趋势 / 模型分类 / 积分明细三处同步联动 */ + const [sharedAccountFilter, setSharedAccountFilter] = useState(null); const load = useCallback( async (refresh = false) => { @@ -1800,11 +1954,15 @@ export default function CreditStatsPage() { {official && ( )} @@ -1817,6 +1975,8 @@ export default function CreditStatsPage() { officialUsage={officialUsage} creditMap={creditMap} creditLoadingMap={creditLoadingMap} + accountFilter={sharedAccountFilter} + onAccountFilterChange={setSharedAccountFilter} /> )} From 0ea870c8b90f97d78a31d96018fac477f7df39c4 Mon Sep 17 00:00:00 2001 From: Magicapple <1540796514@qq.com> Date: Sat, 26 Sep 2026 14:18:59 +0800 Subject: [PATCH 2/2] =?UTF-8?q?revert(credit-stats):=20=E7=A7=BB=E9=99=A4?= =?UTF-8?q?=E4=B8=89=E6=A8=A1=E5=9D=97=E8=B4=A6=E5=8F=B7=E8=81=94=E5=8A=A8?= =?UTF-8?q?=EF=BC=8C=E4=BF=9D=E7=95=99=E6=98=8E=E7=BB=86=E6=8E=92=E5=BA=8F?= =?UTF-8?q?/=E5=88=86=E9=A1=B5=E4=B8=8E=E4=B8=8A=E9=99=90=E8=B0=83?= =?UTF-8?q?=E6=95=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 作者反馈(PR review):三处账号选择分离是有意设计,不是 bug。回退联动 相关改动:TrendChart / ModelBreakdown / SelectedAccountDetails 恢复为各自 内部 state,删除页面级 sharedAccountFilter 及其传参,清理相关注释。 保留 #98 的第二点诉求(与联动无关): - 请求明细表头「请求时间」「消耗」可点击排序,默认时间倒序 - 明细超过 100 条时分页(每页 100),换账号/换排序/重新采集回到第一页 - 后端每账号明细上限 OFFICIAL_USAGE_DETAIL_LIMIT 100 → 1000 tsc --noEmit 与 npm run build 通过。 --- src/pages/CreditStatsPage.tsx | 37 +++++++++++------------------------ 1 file changed, 11 insertions(+), 26 deletions(-) diff --git a/src/pages/CreditStatsPage.tsx b/src/pages/CreditStatsPage.tsx index a316bab7..28a50166 100644 --- a/src/pages/CreditStatsPage.tsx +++ b/src/pages/CreditStatsPage.tsx @@ -580,15 +580,12 @@ function buildStackedChart( function TrendChart({ stats, officialUsage, - accountFilter, - onAccountFilterChange, }: { stats: CreditStatistics; officialUsage?: CreditOfficialUsage; - /** 与同页其他模块共享,null = 所有账号汇总 */ - accountFilter: string | null; - onAccountFilterChange: (accountId: string | null) => void; }) { + /** null = 所有账号汇总;本卡片独立,不影响其他卡片 */ + const [accountFilter, setAccountFilter] = useState(null); /** 本卡片独立的时间范围,不影响其他卡片 */ const [range, setRange] = useState("30d"); const official = isOfficialUsageAvailable(officialUsage) ? officialUsage : undefined; @@ -664,7 +661,7 @@ function TrendChart({
@@ -955,14 +952,11 @@ function ModelBreakdownRows({ models }: { models: CreditOfficialUsageModel[] }) function ModelBreakdown({ officialUsage, - accountFilter, - onAccountFilterChange, }: { officialUsage: CreditOfficialUsage; - /** 与同页其他模块共享,null = 所有账号汇总 */ - accountFilter: string | null; - onAccountFilterChange: (accountId: string | null) => void; }) { + /** null = 所有账号汇总;本卡片独立,不影响其他卡片 */ + const [accountFilter, setAccountFilter] = useState(null); /** 本卡片独立的时间范围,不影响其他卡片 */ const [range, setRange] = useState("30d"); const filterAccounts = officialUsage.accounts; @@ -1011,7 +1005,7 @@ function ModelBreakdown({
@@ -1552,20 +1546,19 @@ function SelectedAccountDetails({ officialUsage, creditMap, creditLoadingMap, - accountFilter, - onAccountFilterChange, }: { stats: CreditStatistics; officialUsage?: CreditOfficialUsage; creditMap: Record; creditLoadingMap: Record; - /** 与同页其他模块共享;本卡片只允许单账号,未指定时回退到第一个账号 */ - accountFilter: string | null; - onAccountFilterChange: (accountId: string | null) => void; }) { const [detailTab, setDetailTab] = useState<"credits" | "requests">("credits"); const official = isOfficialUsageAvailable(officialUsage) ? officialUsage : undefined; const filterAccounts = official ? official.accounts : stats.accounts; + /** 本卡片仅允许选择单个账号,默认第一个账号 */ + const [accountFilter, setAccountFilter] = useState( + () => filterAccounts[0]?.accountId ?? null, + ); const activeFilterAccount = accountFilter && filterAccounts.some((account) => account.accountId === accountFilter) ? filterAccounts.find((account) => account.accountId === accountFilter) @@ -1605,7 +1598,7 @@ function SelectedAccountDetails({ @@ -1724,8 +1717,6 @@ export default function CreditStatsPage() { const [loading, setLoading] = useState(!cachedStatistics); const [error, setError] = useState(null); const [viewVariant, setViewVariant] = useState("all"); - /** 页面级共享:在任一模块切换账号,趋势 / 模型分类 / 积分明细三处同步联动 */ - const [sharedAccountFilter, setSharedAccountFilter] = useState(null); const load = useCallback( async (refresh = false) => { @@ -1954,15 +1945,11 @@ export default function CreditStatsPage() { {official && ( )} @@ -1975,8 +1962,6 @@ export default function CreditStatsPage() { officialUsage={officialUsage} creditMap={creditMap} creditLoadingMap={creditLoadingMap} - accountFilter={sharedAccountFilter} - onAccountFilterChange={setSharedAccountFilter} /> )}