From 8a9d72d3e700722d9c41421235f0800fc8811b97 Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Sun, 1 Mar 2026 22:14:38 +0800 Subject: [PATCH 1/9] =?UTF-8?q?feat:=20=E4=BE=9B=E5=BA=94=E5=95=86?= =?UTF-8?q?=E6=A6=9C=E5=8D=95=E6=94=AF=E6=8C=81=E5=B1=95=E5=BC=80=E6=9F=A5?= =?UTF-8?q?=E7=9C=8B=E6=A8=A1=E5=9E=8B=E6=98=8E=E7=BB=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../_components/leaderboard-table.tsx | 62 +++-- .../_components/leaderboard-view.tsx | 221 ++++++++---------- src/app/api/leaderboard/route.ts | 43 +++- src/lib/redis/leaderboard-cache.ts | 44 +++- src/repository/leaderboard.ts | 160 ++++++++++++- tests/unit/api/leaderboard-route.test.ts | 52 +++++ ...leaderboard-table-expandable-rows.test.tsx | 109 +++++++++ .../leaderboard-provider-metrics.test.ts | 99 ++++++++ 8 files changed, 625 insertions(+), 165 deletions(-) create mode 100644 tests/unit/dashboard/leaderboard-table-expandable-rows.test.tsx diff --git a/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-table.tsx b/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-table.tsx index d161bd537..89dbeb451 100644 --- a/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-table.tsx +++ b/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-table.tsx @@ -41,7 +41,13 @@ interface LeaderboardTableProps { period: LeaderboardPeriod; columns: ColumnDef[]; // 不包含"排名"列,组件会自动添加 getRowKey?: (row: T, index: number) => string | number; - renderExpandedContent?: (row: T, index: number) => React.ReactNode | null; + getSubRows?: (row: T, index: number) => T[] | null | undefined; + getSubRowKey?: ( + subRow: T, + parentRow: T, + parentIndex: number, + subIndex: number + ) => string | number; } export function LeaderboardTable({ @@ -49,7 +55,8 @@ export function LeaderboardTable({ period, columns, getRowKey, - renderExpandedContent, + getSubRows, + getSubRowKey, }: LeaderboardTableProps) { const t = useTranslations("dashboard.leaderboard"); @@ -229,21 +236,19 @@ export function LeaderboardTable({ const rank = index + 1; const isTopThree = rank <= 3; const rowKey = getRowKey ? (getRowKey(row, index) ?? index) : index; - const hasExpandable = renderExpandedContent != null; - const expandedContent = hasExpandable ? renderExpandedContent(row, index) : null; - const isExpanded = expandedRows.has(rowKey); + const subRows = getSubRows ? getSubRows(row, index) : null; + const hasExpandable = (subRows?.length ?? 0) > 0; + const isExpanded = hasExpandable && expandedRows.has(rowKey); return ( toggleRow(rowKey) : undefined - } + className={`${isTopThree ? "bg-muted/50" : ""} ${hasExpandable ? "cursor-pointer" : ""}`} + onClick={hasExpandable ? () => toggleRow(rowKey) : undefined} >
- {hasExpandable && expandedContent ? ( + {hasExpandable ? ( isExpanded ? ( ) : ( @@ -265,16 +270,33 @@ export function LeaderboardTable({ ); })} - {isExpanded && expandedContent && ( - - - {expandedContent} - - - )} + {isExpanded && + (subRows ?? []).map((subRow, subIndex) => { + const rawSubKey = getSubRowKey + ? getSubRowKey(subRow, row, index, subIndex) + : subIndex; + const subKey = `${rowKey}-${String(rawSubKey)}`; + return ( + + +
+
+
+ + {columns.map((col, idx) => { + const shouldBold = getShouldBold(col); + return ( + + {col.cell(subRow, subIndex)} + + ); + })} + + ); + })} ); })} diff --git a/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-view.tsx b/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-view.tsx index ccc13eb22..b1bf72e5c 100644 --- a/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-view.tsx +++ b/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-view.tsx @@ -16,6 +16,7 @@ import type { LeaderboardPeriod, ModelCacheHitStat, ModelLeaderboardEntry, + ModelProviderStat, ProviderCacheHitRateLeaderboardEntry, ProviderLeaderboardEntry, } from "@/repository/leaderboard"; @@ -34,7 +35,9 @@ type ProviderEntry = ProviderLeaderboardEntry & { avgCostPerRequestFormatted?: string | null; avgCostPerMillionTokensFormatted?: string | null; }; +type ProviderRow = ProviderEntry | ModelProviderStat; type ProviderCacheHitRateEntry = ProviderCacheHitRateLeaderboardEntry; +type ProviderCacheHitRateRow = ProviderCacheHitRateEntry | ModelCacheHitStat; type ModelEntry = ModelLeaderboardEntry & { totalCostFormatted?: string }; type AnyEntry = UserEntry | ProviderEntry | ProviderCacheHitRateEntry | ModelEntry; @@ -122,6 +125,9 @@ export function LeaderboardView({ isAdmin }: LeaderboardViewProps) { ) { url += `&providerType=${encodeURIComponent(providerTypeFilter)}`; } + if (scope === "provider") { + url += "&includeModelStats=1"; + } if (scope === "user") { if (userTagFilters.length > 0) { url += `&userTags=${encodeURIComponent(userTagFilters.join(","))}`; @@ -211,108 +217,124 @@ export function LeaderboardView({ isAdmin }: LeaderboardViewProps) { }, ]; - const providerColumns: ColumnDef[] = [ + const providerColumns: ColumnDef[] = [ { header: t("columns.provider"), - cell: (row) => (row as ProviderEntry).providerName, + cell: (row) => { + if ("providerName" in row) return row.providerName; + return ( +
+ {row.model} +
+ ); + }, sortKey: "providerName", - getValue: (row) => (row as ProviderEntry).providerName, + getValue: (row) => ("providerName" in row ? row.providerName : row.model), }, { header: t("columns.requests"), className: "text-right", - cell: (row) => (row as ProviderEntry).totalRequests.toLocaleString(), + cell: (row) => row.totalRequests.toLocaleString(), sortKey: "totalRequests", - getValue: (row) => (row as ProviderEntry).totalRequests, + getValue: (row) => row.totalRequests, }, { header: t("columns.cost"), className: "text-right font-mono", cell: (row) => { - const r = row as ProviderEntry & { totalCostFormatted?: string }; + const r = row as { totalCostFormatted?: string; totalCost: number }; return r.totalCostFormatted ?? r.totalCost; }, sortKey: "totalCost", - getValue: (row) => (row as ProviderEntry).totalCost, + getValue: (row) => row.totalCost, defaultBold: true, }, { header: t("columns.tokens"), className: "text-right", - cell: (row) => formatTokenAmount((row as ProviderEntry).totalTokens), + cell: (row) => formatTokenAmount(row.totalTokens), sortKey: "totalTokens", - getValue: (row) => (row as ProviderEntry).totalTokens, + getValue: (row) => row.totalTokens, }, { header: t("columns.successRate"), className: "text-right", - cell: (row) => `${(Number((row as ProviderEntry).successRate || 0) * 100).toFixed(1)}%`, + cell: (row) => `${(Number(row.successRate || 0) * 100).toFixed(1)}%`, sortKey: "successRate", - getValue: (row) => (row as ProviderEntry).successRate, + getValue: (row) => row.successRate, }, { header: t("columns.avgTtfbMs"), className: "text-right", cell: (row) => { - const val = (row as ProviderEntry).avgTtfbMs; + const val = row.avgTtfbMs; return val && val > 0 ? `${Math.round(val).toLocaleString()} ms` : "-"; }, sortKey: "avgTtfbMs", - getValue: (row) => (row as ProviderEntry).avgTtfbMs ?? 0, + getValue: (row) => row.avgTtfbMs ?? 0, }, { header: t("columns.avgTokensPerSecond"), className: "text-right", cell: (row) => { - const val = (row as ProviderEntry).avgTokensPerSecond; + const val = row.avgTokensPerSecond; return val && val > 0 ? `${val.toFixed(1)} tok/s` : "-"; }, sortKey: "avgTokensPerSecond", - getValue: (row) => (row as ProviderEntry).avgTokensPerSecond ?? 0, + getValue: (row) => row.avgTokensPerSecond ?? 0, }, { header: t("columns.avgCostPerRequest"), className: "text-right font-mono", cell: (row) => { - const r = row as ProviderEntry; - if (r.avgCostPerRequest == null) return "-"; - return r.avgCostPerRequestFormatted ?? r.avgCostPerRequest.toFixed(4); + if (row.avgCostPerRequest == null) return "-"; + const formatted = (row as { avgCostPerRequestFormatted?: string | null }) + .avgCostPerRequestFormatted; + return formatted ?? row.avgCostPerRequest.toFixed(4); }, sortKey: "avgCostPerRequest", - getValue: (row) => (row as ProviderEntry).avgCostPerRequest ?? 0, + getValue: (row) => row.avgCostPerRequest ?? 0, }, { header: t("columns.avgCostPerMillionTokens"), className: "text-right font-mono", cell: (row) => { - const r = row as ProviderEntry; - if (r.avgCostPerMillionTokens == null) return "-"; - return r.avgCostPerMillionTokensFormatted ?? r.avgCostPerMillionTokens.toFixed(2); + if (row.avgCostPerMillionTokens == null) return "-"; + const formatted = (row as { avgCostPerMillionTokensFormatted?: string | null }) + .avgCostPerMillionTokensFormatted; + return formatted ?? row.avgCostPerMillionTokens.toFixed(2); }, sortKey: "avgCostPerMillionTokens", - getValue: (row) => (row as ProviderEntry).avgCostPerMillionTokens ?? 0, + getValue: (row) => row.avgCostPerMillionTokens ?? 0, }, ]; - const providerCacheHitRateColumns: ColumnDef[] = [ + const providerCacheHitRateColumns: ColumnDef[] = [ { header: t("columns.provider"), - cell: (row) => (row as ProviderCacheHitRateEntry).providerName, + cell: (row) => { + if ("providerName" in row) return row.providerName; + return ( +
+ {row.model} +
+ ); + }, sortKey: "providerName", - getValue: (row) => (row as ProviderCacheHitRateEntry).providerName, + getValue: (row) => ("providerName" in row ? row.providerName : row.model), }, { header: t("columns.cacheHitRequests"), className: "text-right", - cell: (row) => (row as ProviderCacheHitRateEntry).totalRequests.toLocaleString(), + cell: (row) => row.totalRequests.toLocaleString(), sortKey: "totalRequests", - getValue: (row) => (row as ProviderCacheHitRateEntry).totalRequests, + getValue: (row) => row.totalRequests, }, { header: t("columns.cacheHitRate"), className: "text-right", cell: (row) => { - const rate = Number((row as ProviderCacheHitRateEntry).cacheHitRate || 0) * 100; + const rate = Number(row.cacheHitRate || 0) * 100; const colorClass = rate >= 85 ? "text-green-600 dark:text-green-400" @@ -322,21 +344,21 @@ export function LeaderboardView({ isAdmin }: LeaderboardViewProps) { return {rate.toFixed(1)}%; }, sortKey: "cacheHitRate", - getValue: (row) => (row as ProviderCacheHitRateEntry).cacheHitRate, + getValue: (row) => row.cacheHitRate, }, { header: t("columns.cacheReadTokens"), className: "text-right", - cell: (row) => formatTokenAmount((row as ProviderCacheHitRateEntry).cacheReadTokens), + cell: (row) => formatTokenAmount(row.cacheReadTokens), sortKey: "cacheReadTokens", - getValue: (row) => (row as ProviderCacheHitRateEntry).cacheReadTokens, + getValue: (row) => row.cacheReadTokens, }, { header: t("columns.totalTokens"), className: "text-right", - cell: (row) => formatTokenAmount((row as ProviderCacheHitRateEntry).totalInputTokens), + cell: (row) => formatTokenAmount(row.totalInputTokens), sortKey: "totalInputTokens", - getValue: (row) => (row as ProviderCacheHitRateEntry).totalInputTokens, + getValue: (row) => row.totalInputTokens, }, ]; @@ -381,30 +403,52 @@ export function LeaderboardView({ isAdmin }: LeaderboardViewProps) { }, ]; - const columns = (() => { - switch (scope) { - case "user": - return userColumns as ColumnDef[]; - case "provider": - return providerColumns as ColumnDef[]; - case "providerCacheHitRate": - return providerCacheHitRateColumns as ColumnDef[]; - case "model": - return modelColumns as ColumnDef[]; + const renderTable = () => { + if (scope === "user") { + return ( + + data={data as UserEntry[]} + period={period} + columns={userColumns} + getRowKey={(row) => row.userId} + /> + ); + } + + if (scope === "provider") { + return ( + + data={data as ProviderEntry[] as ProviderRow[]} + period={period} + columns={providerColumns} + getRowKey={(row) => ("providerId" in row ? row.providerId : row.model)} + getSubRows={(row) => ("modelStats" in row ? row.modelStats : null)} + getSubRowKey={(row) => ("model" in row ? row.model : row.providerId)} + /> + ); } - })(); - - const rowKey = (row: AnyEntry) => { - switch (scope) { - case "user": - return (row as UserEntry).userId; - case "provider": - return (row as ProviderEntry).providerId; - case "providerCacheHitRate": - return (row as ProviderCacheHitRateEntry).providerId; - case "model": - return (row as ModelEntry).model; + + if (scope === "providerCacheHitRate") { + return ( + + data={data as ProviderCacheHitRateEntry[] as ProviderCacheHitRateRow[]} + period={period} + columns={providerCacheHitRateColumns} + getRowKey={(row) => ("providerId" in row ? row.providerId : row.model)} + getSubRows={(row) => ("modelStats" in row ? row.modelStats : null)} + getSubRowKey={(row) => ("model" in row ? row.model : row.providerId)} + /> + ); } + + return ( + + data={data as ModelEntry[]} + period={period} + columns={modelColumns} + getRowKey={(row) => row.model} + /> + ); }; return ( @@ -511,70 +555,7 @@ export function LeaderboardView({ isAdmin }: LeaderboardViewProps) { ) : ( - { - const entry = row as ProviderCacheHitRateEntry & { - modelStats?: ModelCacheHitStat[]; - }; - if (!entry.modelStats || entry.modelStats.length === 0) return null; - return ( -
-
- {t("expandModelStats")} -
- - - - - - - - - - - - {entry.modelStats.map((ms) => { - const rate = (ms.cacheHitRate ?? 0) * 100; - const colorClass = - rate >= 85 - ? "text-green-600 dark:text-green-400" - : rate >= 60 - ? "text-yellow-600 dark:text-yellow-400" - : "text-orange-600 dark:text-orange-400"; - return ( - - - - - - - - ); - })} - -
{t("columns.model")}{t("columns.requests")} - {t("columns.cacheReadTokens")} - {t("columns.totalTokens")}{t("columns.cacheHitRate")}
{ms.model} - {ms.totalRequests.toLocaleString()} - - {formatTokenAmount(ms.cacheReadTokens)} - - {formatTokenAmount(ms.totalInputTokens)} - - {rate.toFixed(1)}% -
-
- ); - } - : undefined - } - /> + renderTable() )}
diff --git a/src/app/api/leaderboard/route.ts b/src/app/api/leaderboard/route.ts index e703a1e71..e727c7385 100644 --- a/src/app/api/leaderboard/route.ts +++ b/src/app/api/leaderboard/route.ts @@ -35,6 +35,7 @@ export const runtime = "nodejs"; * GET /api/leaderboard?period=daily|weekly|monthly|allTime|custom&scope=user|provider|providerCacheHitRate|model * 当 period=custom 时,需要提供 startDate 和 endDate 参数 (YYYY-MM-DD 格式) * 当 scope=providerCacheHitRate 时,可选 providerType=claude|claude-auth|codex|gemini|gemini-cli|openai-compatible + * 当 scope=provider 时,可选 includeModelStats=true|1,返回供应商下各模型的拆分数据 * * 需要认证,普通用户需要 allowGlobalUsageView 权限 * 实时计算 + Redis 乐观缓存(60 秒 TTL) @@ -75,6 +76,7 @@ export async function GET(request: NextRequest) { const startDate = searchParams.get("startDate"); const endDate = searchParams.get("endDate"); const providerTypeParam = searchParams.get("providerType"); + const includeModelStatsParam = searchParams.get("includeModelStats"); const userTagsParam = searchParams.get("userTags"); const userGroupsParam = searchParams.get("userGroups"); @@ -127,6 +129,12 @@ export async function GET(request: NextRequest) { providerType = providerTypeParam; } + const includeModelStats = + scope === "provider" && + (includeModelStatsParam === "1" || + includeModelStatsParam === "true" || + includeModelStatsParam === "yes"); + const parseListParam = (param: string | null): string[] | undefined => { if (!param) return undefined; const items = param @@ -150,7 +158,7 @@ export async function GET(request: NextRequest) { systemSettings.currencyDisplay, scope, dateRange, - { providerType, userTags, userGroups } + { providerType, userTags, userGroups, includeModelStats } ); // 格式化金额字段 @@ -165,6 +173,7 @@ export async function GET(request: NextRequest) { avgCostPerRequest?: number | null; avgCostPerMillionTokens?: number | null; cacheCreationCost?: number; + modelStats?: unknown[]; }; const providerFields = @@ -194,7 +203,36 @@ export async function GET(request: NextRequest) { } : {}; - return { ...base, ...providerFields, ...cacheFields }; + const modelStatsFormatted = + scope === "provider" && Array.isArray(typedEntry.modelStats) + ? typedEntry.modelStats.map((ms) => { + const stat = ms as { + totalCost: number; + avgCostPerRequest: number | null; + avgCostPerMillionTokens: number | null; + } & Record; + + return { + ...stat, + totalCostFormatted: formatCurrency(stat.totalCost, systemSettings.currencyDisplay), + avgCostPerRequestFormatted: + stat.avgCostPerRequest != null + ? formatCurrency(stat.avgCostPerRequest, systemSettings.currencyDisplay) + : null, + avgCostPerMillionTokensFormatted: + stat.avgCostPerMillionTokens != null + ? formatCurrency(stat.avgCostPerMillionTokens, systemSettings.currencyDisplay) + : null, + }; + }) + : undefined; + + return { + ...base, + ...providerFields, + ...cacheFields, + ...(modelStatsFormatted ? { modelStats: modelStatsFormatted } : {}), + }; }); logger.info("Leaderboard API: Access granted", { @@ -205,6 +243,7 @@ export async function GET(request: NextRequest) { scope, dateRange, providerType, + includeModelStats, userTags, userGroups, entriesCount: data.length, diff --git a/src/lib/redis/leaderboard-cache.ts b/src/lib/redis/leaderboard-cache.ts index 5bbdc3251..b740052dc 100644 --- a/src/lib/redis/leaderboard-cache.ts +++ b/src/lib/redis/leaderboard-cache.ts @@ -46,6 +46,7 @@ export interface LeaderboardFilters { providerType?: ProviderType; userTags?: string[]; userGroups?: string[]; + includeModelStats?: boolean; } /** @@ -62,6 +63,8 @@ function buildCacheKey( ): string { const now = new Date(); const providerTypeSuffix = filters?.providerType ? `:providerType:${filters.providerType}` : ""; + const includeModelStatsSuffix = + scope === "provider" && filters?.includeModelStats ? ":includeModelStats" : ""; let userFilterSuffix = ""; if (scope === "user") { @@ -76,22 +79,22 @@ function buildCacheKey( if (period === "custom" && dateRange) { // leaderboard:{scope}:custom:2025-01-01_2025-01-15:USD - return `leaderboard:${scope}:custom:${dateRange.startDate}_${dateRange.endDate}:${currencyDisplay}${providerTypeSuffix}${userFilterSuffix}`; + return `leaderboard:${scope}:custom:${dateRange.startDate}_${dateRange.endDate}:${currencyDisplay}${providerTypeSuffix}${includeModelStatsSuffix}${userFilterSuffix}`; } else if (period === "daily") { // leaderboard:{scope}:daily:2025-01-15:USD const dateStr = formatInTimeZone(now, timezone, "yyyy-MM-dd"); - return `leaderboard:${scope}:daily:${dateStr}:${currencyDisplay}${providerTypeSuffix}${userFilterSuffix}`; + return `leaderboard:${scope}:daily:${dateStr}:${currencyDisplay}${providerTypeSuffix}${includeModelStatsSuffix}${userFilterSuffix}`; } else if (period === "weekly") { // leaderboard:{scope}:weekly:2025-W03:USD (ISO week) const weekStr = formatInTimeZone(now, timezone, "yyyy-'W'ww"); - return `leaderboard:${scope}:weekly:${weekStr}:${currencyDisplay}${providerTypeSuffix}${userFilterSuffix}`; + return `leaderboard:${scope}:weekly:${weekStr}:${currencyDisplay}${providerTypeSuffix}${includeModelStatsSuffix}${userFilterSuffix}`; } else if (period === "monthly") { // leaderboard:{scope}:monthly:2025-01:USD const monthStr = formatInTimeZone(now, timezone, "yyyy-MM"); - return `leaderboard:${scope}:monthly:${monthStr}:${currencyDisplay}${providerTypeSuffix}${userFilterSuffix}`; + return `leaderboard:${scope}:monthly:${monthStr}:${currencyDisplay}${providerTypeSuffix}${includeModelStatsSuffix}${userFilterSuffix}`; } else { // allTime: leaderboard:{scope}:allTime:USD (no date component) - return `leaderboard:${scope}:allTime:${currencyDisplay}${providerTypeSuffix}${userFilterSuffix}`; + return `leaderboard:${scope}:allTime:${currencyDisplay}${providerTypeSuffix}${includeModelStatsSuffix}${userFilterSuffix}`; } } @@ -115,7 +118,11 @@ async function queryDatabase( return await findCustomRangeLeaderboard(dateRange, userFilters); } if (scope === "provider") { - return await findCustomRangeProviderLeaderboard(dateRange, filters?.providerType); + return await findCustomRangeProviderLeaderboard( + dateRange, + filters?.providerType, + filters?.includeModelStats + ); } if (scope === "providerCacheHitRate") { return await findCustomRangeProviderCacheHitRateLeaderboard(dateRange, filters?.providerType); @@ -140,15 +147,30 @@ async function queryDatabase( if (scope === "provider") { switch (period) { case "daily": - return await findDailyProviderLeaderboard(filters?.providerType); + return await findDailyProviderLeaderboard( + filters?.providerType, + filters?.includeModelStats + ); case "weekly": - return await findWeeklyProviderLeaderboard(filters?.providerType); + return await findWeeklyProviderLeaderboard( + filters?.providerType, + filters?.includeModelStats + ); case "monthly": - return await findMonthlyProviderLeaderboard(filters?.providerType); + return await findMonthlyProviderLeaderboard( + filters?.providerType, + filters?.includeModelStats + ); case "allTime": - return await findAllTimeProviderLeaderboard(filters?.providerType); + return await findAllTimeProviderLeaderboard( + filters?.providerType, + filters?.includeModelStats + ); default: - return await findDailyProviderLeaderboard(filters?.providerType); + return await findDailyProviderLeaderboard( + filters?.providerType, + filters?.includeModelStats + ); } } if (scope === "providerCacheHitRate") { diff --git a/src/repository/leaderboard.ts b/src/repository/leaderboard.ts index 451e6c440..c7fb24359 100644 --- a/src/repository/leaderboard.ts +++ b/src/repository/leaderboard.ts @@ -43,6 +43,23 @@ export interface ProviderLeaderboardEntry { avgTokensPerSecond: number; // tok/s(仅统计流式且可计算的请求) avgCostPerRequest: number | null; // totalCost / totalRequests, null when totalRequests === 0 avgCostPerMillionTokens: number | null; // totalCost * 1_000_000 / totalTokens, null when totalTokens === 0 + /** 可选:按模型拆分(仅在 includeModelStats=true 时填充) */ + modelStats?: ModelProviderStat[]; +} + +/** + * 供应商消耗排行榜 - 模型级统计 + */ +export interface ModelProviderStat { + model: string; + totalRequests: number; + totalCost: number; + totalTokens: number; + successRate: number; // 0-1 + avgTtfbMs: number; // 毫秒 + avgTokensPerSecond: number; // tok/s + avgCostPerRequest: number | null; + avgCostPerMillionTokens: number | null; } /** @@ -286,10 +303,17 @@ export async function findCustomRangeLeaderboard( * 使用 SQL AT TIME ZONE 进行时区转换,确保"今日"基于系统时区 */ export async function findDailyProviderLeaderboard( - providerType?: ProviderType + providerType?: ProviderType, + includeModelStats?: boolean ): Promise { const timezone = await resolveSystemTimezone(); - return findProviderLeaderboardWithTimezone("daily", timezone, undefined, providerType); + return findProviderLeaderboardWithTimezone( + "daily", + timezone, + undefined, + providerType, + includeModelStats + ); } /** @@ -297,30 +321,51 @@ export async function findDailyProviderLeaderboard( * 使用 SQL AT TIME ZONE 进行时区转换,确保"本月"基于系统时区 */ export async function findMonthlyProviderLeaderboard( - providerType?: ProviderType + providerType?: ProviderType, + includeModelStats?: boolean ): Promise { const timezone = await resolveSystemTimezone(); - return findProviderLeaderboardWithTimezone("monthly", timezone, undefined, providerType); + return findProviderLeaderboardWithTimezone( + "monthly", + timezone, + undefined, + providerType, + includeModelStats + ); } /** * 查询本周供应商消耗排行榜(不限制数量) */ export async function findWeeklyProviderLeaderboard( - providerType?: ProviderType + providerType?: ProviderType, + includeModelStats?: boolean ): Promise { const timezone = await resolveSystemTimezone(); - return findProviderLeaderboardWithTimezone("weekly", timezone, undefined, providerType); + return findProviderLeaderboardWithTimezone( + "weekly", + timezone, + undefined, + providerType, + includeModelStats + ); } /** * 查询全部时间供应商消耗排行榜(不限制数量) */ export async function findAllTimeProviderLeaderboard( - providerType?: ProviderType + providerType?: ProviderType, + includeModelStats?: boolean ): Promise { const timezone = await resolveSystemTimezone(); - return findProviderLeaderboardWithTimezone("allTime", timezone, undefined, providerType); + return findProviderLeaderboardWithTimezone( + "allTime", + timezone, + undefined, + providerType, + includeModelStats + ); } /** @@ -390,7 +435,8 @@ async function findProviderLeaderboardWithTimezone( period: LeaderboardPeriod, timezone: string, dateRange?: DateRangeParams, - providerType?: ProviderType + providerType?: ProviderType, + includeModelStats?: boolean ): Promise { const whereConditions = [ LEDGER_BILLING_CONDITION, @@ -445,7 +491,7 @@ async function findProviderLeaderboardWithTimezone( .groupBy(usageLedger.finalProviderId, providers.name) .orderBy(desc(sql`sum(${usageLedger.costUsd})`)); - return rankings.map((entry) => { + const baseEntries: ProviderLeaderboardEntry[] = rankings.map((entry) => { const totalCost = parseFloat(entry.totalCost); const totalRequests = entry.totalRequests; const totalTokens = entry.totalTokens; @@ -462,6 +508,89 @@ async function findProviderLeaderboardWithTimezone( avgCostPerMillionTokens: totalTokens > 0 ? (totalCost * 1_000_000) / totalTokens : null, }; }); + + if (!includeModelStats) return baseEntries; + + // Model breakdown per provider + const systemSettings = await getSystemSettings(); + const billingModelSource = systemSettings.billingModelSource; + const modelField = + billingModelSource === "original" + ? sql`COALESCE(${usageLedger.originalModel}, ${usageLedger.model})` + : sql`COALESCE(${usageLedger.model}, ${usageLedger.originalModel})`; + + const modelRows = await db + .select({ + providerId: usageLedger.finalProviderId, + model: modelField, + totalRequests: sql`count(*)::double precision`, + totalCost: sql`COALESCE(sum(${usageLedger.costUsd}), 0)`, + totalTokens: sql`COALESCE( + sum( + ${usageLedger.inputTokens} + + ${usageLedger.outputTokens} + + COALESCE(${usageLedger.cacheCreationInputTokens}, 0) + + COALESCE(${usageLedger.cacheReadInputTokens}, 0) + )::double precision, + 0::double precision + )`, + successRate: sql`COALESCE( + count(CASE WHEN ${usageLedger.isSuccess} THEN 1 END)::double precision + / NULLIF(count(*)::double precision, 0), + 0::double precision + )`, + avgTtfbMs: sql`COALESCE(avg(${usageLedger.ttfbMs})::double precision, 0::double precision)`, + avgTokensPerSecond: sql`COALESCE( + avg( + CASE + WHEN ${usageLedger.outputTokens} > 0 + AND ${usageLedger.durationMs} IS NOT NULL + AND ${usageLedger.ttfbMs} IS NOT NULL + AND ${usageLedger.ttfbMs} < ${usageLedger.durationMs} + AND (${usageLedger.durationMs} - ${usageLedger.ttfbMs}) >= 100 + THEN (${usageLedger.outputTokens}::double precision) + / ((${usageLedger.durationMs} - ${usageLedger.ttfbMs}) / 1000.0) + END + )::double precision, + 0::double precision + )`, + }) + .from(usageLedger) + .innerJoin( + providers, + and(sql`${usageLedger.finalProviderId} = ${providers.id}`, isNull(providers.deletedAt)) + ) + .where( + and(...whereConditions.filter((c): c is NonNullable<(typeof whereConditions)[number]> => !!c)) + ) + .groupBy(usageLedger.finalProviderId, modelField) + .orderBy(desc(sql`sum(${usageLedger.costUsd})`), desc(sql`count(*)`)); + + const modelStatsByProvider = new Map(); + for (const row of modelRows) { + if (!row.model || row.model.trim() === "") continue; + const totalCost = parseFloat(row.totalCost); + const totalRequests = row.totalRequests; + const totalTokens = row.totalTokens; + const stats = modelStatsByProvider.get(row.providerId) ?? []; + stats.push({ + model: row.model, + totalRequests, + totalCost, + totalTokens, + successRate: Math.min(Math.max(row.successRate ?? 0, 0), 1), + avgTtfbMs: row.avgTtfbMs ?? 0, + avgTokensPerSecond: row.avgTokensPerSecond ?? 0, + avgCostPerRequest: totalRequests > 0 ? totalCost / totalRequests : null, + avgCostPerMillionTokens: totalTokens > 0 ? (totalCost * 1_000_000) / totalTokens : null, + }); + modelStatsByProvider.set(row.providerId, stats); + } + + return baseEntries.map((entry) => ({ + ...entry, + modelStats: modelStatsByProvider.get(entry.providerId) ?? [], + })); } /** @@ -595,10 +724,17 @@ async function findProviderCacheHitRateLeaderboardWithTimezone( */ export async function findCustomRangeProviderLeaderboard( dateRange: DateRangeParams, - providerType?: ProviderType + providerType?: ProviderType, + includeModelStats?: boolean ): Promise { const timezone = await resolveSystemTimezone(); - return findProviderLeaderboardWithTimezone("custom", timezone, dateRange, providerType); + return findProviderLeaderboardWithTimezone( + "custom", + timezone, + dateRange, + providerType, + includeModelStats + ); } /** diff --git a/tests/unit/api/leaderboard-route.test.ts b/tests/unit/api/leaderboard-route.test.ts index a4d9385a8..dfd48982c 100644 --- a/tests/unit/api/leaderboard-route.test.ts +++ b/tests/unit/api/leaderboard-route.test.ts @@ -197,5 +197,57 @@ describe("GET /api/leaderboard", () => { expect(entry.modelStats[0]).toHaveProperty("model", "claude-3-opus"); expect(entry.modelStats[0]).toHaveProperty("cacheHitRate", 0.53); }); + + it("passes includeModelStats to cache and formats provider modelStats entries", async () => { + mocks.getSession.mockResolvedValue({ user: { id: 1, name: "u", role: "admin" } }); + mocks.getLeaderboardWithCache.mockResolvedValue([ + { + providerId: 1, + providerName: "test-provider", + totalRequests: 10, + totalCost: 1.5, + totalTokens: 1000, + successRate: 1, + avgTtfbMs: 100, + avgTokensPerSecond: 20, + avgCostPerRequest: 0.15, + avgCostPerMillionTokens: 1500, + modelStats: [ + { + model: "model-a", + totalRequests: 6, + totalCost: 1.0, + totalTokens: 600, + successRate: 1, + avgTtfbMs: 110, + avgTokensPerSecond: 25, + avgCostPerRequest: 0.1667, + avgCostPerMillionTokens: 1666.7, + }, + ], + }, + ]); + + const { GET } = await import("@/app/api/leaderboard/route"); + const url = + "http://localhost/api/leaderboard?scope=provider&period=daily&includeModelStats=1"; + const response = await GET({ nextUrl: new URL(url) } as any); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(mocks.getLeaderboardWithCache).toHaveBeenCalledTimes(1); + + const callArgs = mocks.getLeaderboardWithCache.mock.calls[0]; + const options = callArgs[4]; + expect(options.includeModelStats).toBe(true); + + expect(body).toHaveLength(1); + const entry = body[0]; + expect(entry).toHaveProperty("modelStats"); + expect(entry.modelStats).toHaveLength(1); + expect(entry.modelStats[0]).toHaveProperty("totalCostFormatted"); + expect(entry.modelStats[0]).toHaveProperty("avgCostPerRequestFormatted"); + expect(entry.modelStats[0]).toHaveProperty("avgCostPerMillionTokensFormatted"); + }); }); }); diff --git a/tests/unit/dashboard/leaderboard-table-expandable-rows.test.tsx b/tests/unit/dashboard/leaderboard-table-expandable-rows.test.tsx new file mode 100644 index 000000000..163d0eca1 --- /dev/null +++ b/tests/unit/dashboard/leaderboard-table-expandable-rows.test.tsx @@ -0,0 +1,109 @@ +/** + * @vitest-environment happy-dom + */ +import type { ReactNode } from "react"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + type ColumnDef, + LeaderboardTable, +} from "@/app/[locale]/dashboard/leaderboard/_components/leaderboard-table"; + +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string) => key, +})); + +type ChildRow = { + model: string; + totalRequests: number; +}; + +type ParentRow = { + providerId: number; + providerName: string; + totalRequests: number; + modelStats: ChildRow[]; +}; + +type Row = ParentRow | ChildRow; + +describe("LeaderboardTable expandable rows", () => { + let container: HTMLDivElement | null = null; + let root: ReturnType | null = null; + + function renderSimple(node: ReactNode) { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + act(() => root!.render(node)); + return { container, root }; + } + + afterEach(() => { + if (root) { + act(() => root!.unmount()); + root = null; + } + if (container) { + container.remove(); + container = null; + } + }); + + it("renders sub rows inline (no nested table) and toggles on click", () => { + const data: ParentRow[] = [ + { + providerId: 1, + providerName: "Provider A", + totalRequests: 10, + modelStats: [ + { model: "model-x", totalRequests: 6 }, + { model: "model-y", totalRequests: 4 }, + ], + }, + ]; + + const columns: ColumnDef[] = [ + { + header: "name", + cell: (row) => ("providerName" in row ? row.providerName : row.model), + }, + { + header: "requests", + className: "text-right", + cell: (row) => String(row.totalRequests), + }, + ]; + + const { container } = renderSimple( + + data={data as Row[]} + period="daily" + columns={columns} + getRowKey={(row) => ("providerId" in row ? row.providerId : row.model)} + getSubRows={(row) => ("modelStats" in row ? row.modelStats : null)} + getSubRowKey={(row) => ("model" in row ? row.model : row.providerId)} + /> + ); + + const findCellByText = (text: string) => + Array.from(container.querySelectorAll("td")).find((td) => td.textContent?.trim() === text) ?? + null; + + expect(findCellByText("Provider A")).toBeTruthy(); + expect(findCellByText("model-x")).toBeNull(); + + const providerCell = findCellByText("Provider A")!; + act(() => { + providerCell.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + const modelCell = findCellByText("model-x"); + expect(modelCell).toBeTruthy(); + + const modelRow = modelCell!.closest("tr"); + expect(modelRow).toBeTruthy(); + expect(modelRow!.className).toContain("bg-muted/30"); + }); +}); diff --git a/tests/unit/repository/leaderboard-provider-metrics.test.ts b/tests/unit/repository/leaderboard-provider-metrics.test.ts index a39bb489f..1455961b9 100644 --- a/tests/unit/repository/leaderboard-provider-metrics.test.ts +++ b/tests/unit/repository/leaderboard-provider-metrics.test.ts @@ -236,6 +236,105 @@ describe("Provider Leaderboard Average Cost Metrics", () => { }); }); +describe("Provider Leaderboard Model Breakdown", () => { + beforeEach(() => { + vi.resetModules(); + selectCallIndex = 0; + chainMocks = []; + mockSelect.mockClear(); + mocks.resolveSystemTimezone.mockResolvedValue("UTC"); + mocks.getSystemSettings.mockResolvedValue({ billingModelSource: "redirected" }); + }); + + it("includes modelStats when includeModelStats=true and excludes empty model names", async () => { + chainMocks = [ + createChainMock([ + { + providerId: 1, + providerName: "provider-a", + totalRequests: 100, + totalCost: "10.0", + totalTokens: 1000, + successRate: 0.9, + avgTtfbMs: 200, + avgTokensPerSecond: 50, + }, + { + providerId: 2, + providerName: "provider-b", + totalRequests: 50, + totalCost: "5.0", + totalTokens: 500, + successRate: 0.8, + avgTtfbMs: 300, + avgTokensPerSecond: 40, + }, + ]), + createChainMock([ + { + providerId: 1, + model: "model-a", + totalRequests: 60, + totalCost: "6.0", + totalTokens: 600, + successRate: 0.95, + avgTtfbMs: 120, + avgTokensPerSecond: 55, + }, + { + providerId: 1, + model: "model-b", + totalRequests: 40, + totalCost: "4.0", + totalTokens: 400, + successRate: 0.85, + avgTtfbMs: 180, + avgTokensPerSecond: 45, + }, + { + providerId: 2, + model: "", + totalRequests: 1, + totalCost: "0.1", + totalTokens: 10, + successRate: 0, + avgTtfbMs: 0, + avgTokensPerSecond: 0, + }, + { + providerId: 2, + model: "model-c", + totalRequests: 50, + totalCost: "5.0", + totalTokens: 500, + successRate: 0.8, + avgTtfbMs: 300, + avgTokensPerSecond: 40, + }, + ]), + ]; + + const { findDailyProviderLeaderboard } = await import("@/repository/leaderboard"); + const result = await findDailyProviderLeaderboard(undefined, true); + + expect(result).toHaveLength(2); + + const p1 = result.find((r) => r.providerId === 1); + expect(p1).toBeDefined(); + expect(p1!.modelStats).toBeDefined(); + expect(p1!.modelStats).toHaveLength(2); + expect(p1!.modelStats![0].model).toBe("model-a"); + expect(p1!.modelStats![0].avgCostPerRequest).toBeCloseTo(6.0 / 60); + expect(p1!.modelStats![0].avgCostPerMillionTokens).toBeCloseTo((6.0 * 1_000_000) / 600); + + const p2 = result.find((r) => r.providerId === 2); + expect(p2).toBeDefined(); + // Empty model must be excluded + expect(p2!.modelStats).toHaveLength(1); + expect(p2!.modelStats![0].model).toBe("model-c"); + }); +}); + describe("Provider Cache Hit Rate Model Breakdown", () => { beforeEach(() => { vi.resetModules(); From 296918ffcc18798e905d897657308c953df8bc1b Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Sun, 1 Mar 2026 22:27:09 +0800 Subject: [PATCH 2/9] =?UTF-8?q?refactor:=20=E5=A4=8D=E7=94=A8=E4=BE=9B?= =?UTF-8?q?=E5=BA=94=E5=95=86=E6=A6=9C=E5=8D=95=E8=81=9A=E5=90=88=E8=A1=A8?= =?UTF-8?q?=E8=BE=BE=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../_components/leaderboard-table.tsx | 2 + src/lib/redis/leaderboard-cache.ts | 1 + src/repository/leaderboard.ts | 127 +++++++++--------- 3 files changed, 63 insertions(+), 67 deletions(-) diff --git a/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-table.tsx b/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-table.tsx index 89dbeb451..07e4702be 100644 --- a/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-table.tsx +++ b/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-table.tsx @@ -41,7 +41,9 @@ interface LeaderboardTableProps { period: LeaderboardPeriod; columns: ColumnDef[]; // 不包含"排名"列,组件会自动添加 getRowKey?: (row: T, index: number) => string | number; + /** 返回子行数据(非空且长度 > 0 时,父行展示可展开图标) */ getSubRows?: (row: T, index: number) => T[] | null | undefined; + /** 子行的 React key(默认使用 `${parentKey}-${subIndex}` 组合) */ getSubRowKey?: ( subRow: T, parentRow: T, diff --git a/src/lib/redis/leaderboard-cache.ts b/src/lib/redis/leaderboard-cache.ts index b740052dc..d3774fd59 100644 --- a/src/lib/redis/leaderboard-cache.ts +++ b/src/lib/redis/leaderboard-cache.ts @@ -46,6 +46,7 @@ export interface LeaderboardFilters { providerType?: ProviderType; userTags?: string[]; userGroups?: string[]; + /** 仅 scope=provider 生效:是否包含按模型拆分的数据(ProviderLeaderboardEntry.modelStats) */ includeModelStats?: boolean; } diff --git a/src/repository/leaderboard.ts b/src/repository/leaderboard.ts index c7fb24359..f747e861e 100644 --- a/src/repository/leaderboard.ts +++ b/src/repository/leaderboard.ts @@ -301,6 +301,7 @@ export async function findCustomRangeLeaderboard( /** * 查询今日供应商消耗排行榜(不限制数量) * 使用 SQL AT TIME ZONE 进行时区转换,确保"今日"基于系统时区 + * includeModelStats=true 时会额外返回按模型拆分的统计数据(modelStats) */ export async function findDailyProviderLeaderboard( providerType?: ProviderType, @@ -319,6 +320,7 @@ export async function findDailyProviderLeaderboard( /** * 查询本月供应商消耗排行榜(不限制数量) * 使用 SQL AT TIME ZONE 进行时区转换,确保"本月"基于系统时区 + * includeModelStats=true 时会额外返回按模型拆分的统计数据(modelStats) */ export async function findMonthlyProviderLeaderboard( providerType?: ProviderType, @@ -336,6 +338,7 @@ export async function findMonthlyProviderLeaderboard( /** * 查询本周供应商消耗排行榜(不限制数量) + * includeModelStats=true 时会额外返回按模型拆分的统计数据(modelStats) */ export async function findWeeklyProviderLeaderboard( providerType?: ProviderType, @@ -353,6 +356,7 @@ export async function findWeeklyProviderLeaderboard( /** * 查询全部时间供应商消耗排行榜(不限制数量) + * includeModelStats=true 时会额外返回按模型拆分的统计数据(modelStats) */ export async function findAllTimeProviderLeaderboard( providerType?: ProviderType, @@ -430,6 +434,7 @@ export async function findAllTimeProviderCacheHitRateLeaderboard( /** * 通用供应商排行榜查询函数(使用 SQL AT TIME ZONE 确保时区正确) + * includeModelStats=true 时会额外返回按模型拆分的统计数据(modelStats) */ async function findProviderLeaderboardWithTimezone( period: LeaderboardPeriod, @@ -444,41 +449,53 @@ async function findProviderLeaderboardWithTimezone( providerType ? eq(providers.providerType, providerType) : undefined, ]; + const totalRequestsExpr = sql`count(*)::double precision`; + const totalCostExpr = sql`COALESCE(sum(${usageLedger.costUsd}), 0)`; + const totalTokensExpr = sql`COALESCE( + sum( + ${usageLedger.inputTokens} + + ${usageLedger.outputTokens} + + COALESCE(${usageLedger.cacheCreationInputTokens}, 0) + + COALESCE(${usageLedger.cacheReadInputTokens}, 0) + )::double precision, + 0::double precision + )`; + const successRateExpr = sql`COALESCE( + count(CASE WHEN ${usageLedger.isSuccess} THEN 1 END)::double precision + / NULLIF(count(*)::double precision, 0), + 0::double precision + )`; + const avgTtfbMsExpr = sql`COALESCE(avg(${usageLedger.ttfbMs})::double precision, 0::double precision)`; + const avgTokensPerSecondExpr = sql`COALESCE( + avg( + CASE + WHEN ${usageLedger.outputTokens} > 0 + AND ${usageLedger.durationMs} IS NOT NULL + AND ${usageLedger.ttfbMs} IS NOT NULL + AND ${usageLedger.ttfbMs} < ${usageLedger.durationMs} + AND (${usageLedger.durationMs} - ${usageLedger.ttfbMs}) >= 100 + THEN (${usageLedger.outputTokens}::double precision) + / ((${usageLedger.durationMs} - ${usageLedger.ttfbMs}) / 1000.0) + END + )::double precision, + 0::double precision + )`; + + const computeAvgCosts = (totalCost: number, totalRequests: number, totalTokens: number) => ({ + avgCostPerRequest: totalRequests > 0 ? totalCost / totalRequests : null, + avgCostPerMillionTokens: totalTokens > 0 ? (totalCost * 1_000_000) / totalTokens : null, + }); + const rankings = await db .select({ providerId: usageLedger.finalProviderId, providerName: providers.name, - totalRequests: sql`count(*)::double precision`, - totalCost: sql`COALESCE(sum(${usageLedger.costUsd}), 0)`, - totalTokens: sql`COALESCE( - sum( - ${usageLedger.inputTokens} + - ${usageLedger.outputTokens} + - COALESCE(${usageLedger.cacheCreationInputTokens}, 0) + - COALESCE(${usageLedger.cacheReadInputTokens}, 0) - )::double precision, - 0::double precision - )`, - successRate: sql`COALESCE( - count(CASE WHEN ${usageLedger.isSuccess} THEN 1 END)::double precision - / NULLIF(count(*)::double precision, 0), - 0::double precision - )`, - avgTtfbMs: sql`COALESCE(avg(${usageLedger.ttfbMs})::double precision, 0::double precision)`, - avgTokensPerSecond: sql`COALESCE( - avg( - CASE - WHEN ${usageLedger.outputTokens} > 0 - AND ${usageLedger.durationMs} IS NOT NULL - AND ${usageLedger.ttfbMs} IS NOT NULL - AND ${usageLedger.ttfbMs} < ${usageLedger.durationMs} - AND (${usageLedger.durationMs} - ${usageLedger.ttfbMs}) >= 100 - THEN (${usageLedger.outputTokens}::double precision) - / ((${usageLedger.durationMs} - ${usageLedger.ttfbMs}) / 1000.0) - END - )::double precision, - 0::double precision - )`, + totalRequests: totalRequestsExpr, + totalCost: totalCostExpr, + totalTokens: totalTokensExpr, + successRate: successRateExpr, + avgTtfbMs: avgTtfbMsExpr, + avgTokensPerSecond: avgTokensPerSecondExpr, }) .from(usageLedger) .innerJoin( @@ -495,6 +512,7 @@ async function findProviderLeaderboardWithTimezone( const totalCost = parseFloat(entry.totalCost); const totalRequests = entry.totalRequests; const totalTokens = entry.totalTokens; + const avgCosts = computeAvgCosts(totalCost, totalRequests, totalTokens); return { providerId: entry.providerId, providerName: entry.providerName, @@ -504,8 +522,7 @@ async function findProviderLeaderboardWithTimezone( successRate: entry.successRate ?? 0, avgTtfbMs: entry.avgTtfbMs ?? 0, avgTokensPerSecond: entry.avgTokensPerSecond ?? 0, - avgCostPerRequest: totalRequests > 0 ? totalCost / totalRequests : null, - avgCostPerMillionTokens: totalTokens > 0 ? (totalCost * 1_000_000) / totalTokens : null, + ...avgCosts, }; }); @@ -523,37 +540,12 @@ async function findProviderLeaderboardWithTimezone( .select({ providerId: usageLedger.finalProviderId, model: modelField, - totalRequests: sql`count(*)::double precision`, - totalCost: sql`COALESCE(sum(${usageLedger.costUsd}), 0)`, - totalTokens: sql`COALESCE( - sum( - ${usageLedger.inputTokens} + - ${usageLedger.outputTokens} + - COALESCE(${usageLedger.cacheCreationInputTokens}, 0) + - COALESCE(${usageLedger.cacheReadInputTokens}, 0) - )::double precision, - 0::double precision - )`, - successRate: sql`COALESCE( - count(CASE WHEN ${usageLedger.isSuccess} THEN 1 END)::double precision - / NULLIF(count(*)::double precision, 0), - 0::double precision - )`, - avgTtfbMs: sql`COALESCE(avg(${usageLedger.ttfbMs})::double precision, 0::double precision)`, - avgTokensPerSecond: sql`COALESCE( - avg( - CASE - WHEN ${usageLedger.outputTokens} > 0 - AND ${usageLedger.durationMs} IS NOT NULL - AND ${usageLedger.ttfbMs} IS NOT NULL - AND ${usageLedger.ttfbMs} < ${usageLedger.durationMs} - AND (${usageLedger.durationMs} - ${usageLedger.ttfbMs}) >= 100 - THEN (${usageLedger.outputTokens}::double precision) - / ((${usageLedger.durationMs} - ${usageLedger.ttfbMs}) / 1000.0) - END - )::double precision, - 0::double precision - )`, + totalRequests: totalRequestsExpr, + totalCost: totalCostExpr, + totalTokens: totalTokensExpr, + successRate: successRateExpr, + avgTtfbMs: avgTtfbMsExpr, + avgTokensPerSecond: avgTokensPerSecondExpr, }) .from(usageLedger) .innerJoin( @@ -568,10 +560,11 @@ async function findProviderLeaderboardWithTimezone( const modelStatsByProvider = new Map(); for (const row of modelRows) { - if (!row.model || row.model.trim() === "") continue; + if (!row.model?.trim()) continue; const totalCost = parseFloat(row.totalCost); const totalRequests = row.totalRequests; const totalTokens = row.totalTokens; + const avgCosts = computeAvgCosts(totalCost, totalRequests, totalTokens); const stats = modelStatsByProvider.get(row.providerId) ?? []; stats.push({ model: row.model, @@ -581,8 +574,7 @@ async function findProviderLeaderboardWithTimezone( successRate: Math.min(Math.max(row.successRate ?? 0, 0), 1), avgTtfbMs: row.avgTtfbMs ?? 0, avgTokensPerSecond: row.avgTokensPerSecond ?? 0, - avgCostPerRequest: totalRequests > 0 ? totalCost / totalRequests : null, - avgCostPerMillionTokens: totalTokens > 0 ? (totalCost * 1_000_000) / totalTokens : null, + ...avgCosts, }); modelStatsByProvider.set(row.providerId, stats); } @@ -721,6 +713,7 @@ async function findProviderCacheHitRateLeaderboardWithTimezone( /** * 查询自定义日期范围供应商消耗排行榜 + * includeModelStats=true 时会额外返回按模型拆分的统计数据(modelStats) */ export async function findCustomRangeProviderLeaderboard( dateRange: DateRangeParams, From 90366b28995069a9258b38bc11730cd51ec88a68 Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Sun, 1 Mar 2026 23:13:05 +0800 Subject: [PATCH 3/9] =?UTF-8?q?fix:=20=E6=8C=89=20review=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E6=A6=9C=E5=8D=95=E5=B1=95=E5=BC=80=E7=BB=86=E8=8A=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 展开按钮化(aria-expanded + i18n label),避免仅依赖行点击 - getRowKey 缺失时在排序/数据变化清空展开态,避免错位 - invalidateLeaderboardCache 支持 dateRange/filters 以清理 includeModelStats 缓存 - successRate 统一 clamp 到 [0,1],并收敛前端类型断言 --- .../_components/leaderboard-table.tsx | 35 ++++++--- .../_components/leaderboard-view.tsx | 78 +++++++++---------- src/lib/redis/leaderboard-cache.ts | 9 ++- src/repository/leaderboard.ts | 8 +- ...leaderboard-table-expandable-rows.test.tsx | 9 ++- 5 files changed, 80 insertions(+), 59 deletions(-) diff --git a/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-table.tsx b/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-table.tsx index 07e4702be..cffe8ac8f 100644 --- a/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-table.tsx +++ b/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-table.tsx @@ -11,7 +11,7 @@ import { Trophy, } from "lucide-react"; import { useTranslations } from "next-intl"; -import { Fragment, useMemo, useState } from "react"; +import { Fragment, useEffect, useMemo, useState } from "react"; import { Badge } from "@/components/ui/badge"; import { Card, CardContent } from "@/components/ui/card"; import { @@ -80,6 +80,14 @@ export function LeaderboardTable({ }); }; + // 当调用方未提供稳定 rowKey 时(回退到 index),排序会导致展开态错位;此时在排序/数据变化时清空展开态,至少避免错位展开。 + // biome-ignore lint/correctness/useExhaustiveDependencies: 依赖用于在排序/数据变化时触发清空,避免 index key 造成错位展开 + useEffect(() => { + if (!getRowKey) { + setExpandedRows(new Set()); + } + }, [data, sortKey, sortDirection, getRowKey]); + // 判断列是否需要加粗 const getShouldBold = (col: ColumnDef) => { const isActiveSortColumn = sortKey === col.sortKey && sortDirection !== null; @@ -244,18 +252,25 @@ export function LeaderboardTable({ return ( - toggleRow(rowKey) : undefined} - > +
{hasExpandable ? ( - isExpanded ? ( - - ) : ( - - ) + ) : null} {getRankBadge(rank)}
diff --git a/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-view.tsx b/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-view.tsx index b1bf72e5c..b59c5748e 100644 --- a/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-view.tsx +++ b/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-view.tsx @@ -29,16 +29,23 @@ interface LeaderboardViewProps { } type LeaderboardScope = "user" | "provider" | "providerCacheHitRate" | "model"; -type UserEntry = LeaderboardEntry & { totalCostFormatted?: string }; -type ProviderEntry = ProviderLeaderboardEntry & { +type TotalCostFormattedFields = { totalCostFormatted?: string }; +type ProviderCostFormattedFields = { + // API 额外返回的展示用字段(格式化后的字符串) totalCostFormatted?: string; avgCostPerRequestFormatted?: string | null; avgCostPerMillionTokensFormatted?: string | null; }; -type ProviderRow = ProviderEntry | ModelProviderStat; +type UserEntry = LeaderboardEntry & TotalCostFormattedFields; +type ModelEntry = ModelLeaderboardEntry & TotalCostFormattedFields; +type ModelProviderStatClient = ModelProviderStat & ProviderCostFormattedFields; +type ProviderEntry = Omit & + ProviderCostFormattedFields & { + modelStats?: ModelProviderStatClient[]; + }; +type ProviderRow = ProviderEntry | ModelProviderStatClient; type ProviderCacheHitRateEntry = ProviderCacheHitRateLeaderboardEntry; type ProviderCacheHitRateRow = ProviderCacheHitRateEntry | ModelCacheHitStat; -type ModelEntry = ModelLeaderboardEntry & { totalCostFormatted?: string }; type AnyEntry = UserEntry | ProviderEntry | ProviderCacheHitRateEntry | ModelEntry; const VALID_PERIODS: LeaderboardPeriod[] = ["daily", "weekly", "monthly", "allTime", "custom"]; @@ -186,33 +193,30 @@ export function LeaderboardView({ isAdmin }: LeaderboardViewProps) { const userColumns: ColumnDef[] = [ { header: t("columns.user"), - cell: (row) => (row as UserEntry).userName, + cell: (row) => row.userName, sortKey: "userName", - getValue: (row) => (row as UserEntry).userName, + getValue: (row) => row.userName, }, { header: t("columns.requests"), className: "text-right", - cell: (row) => (row as UserEntry).totalRequests.toLocaleString(), + cell: (row) => row.totalRequests.toLocaleString(), sortKey: "totalRequests", - getValue: (row) => (row as UserEntry).totalRequests, + getValue: (row) => row.totalRequests, }, { header: t("columns.tokens"), className: "text-right", - cell: (row) => formatTokenAmount((row as UserEntry).totalTokens), + cell: (row) => formatTokenAmount(row.totalTokens), sortKey: "totalTokens", - getValue: (row) => (row as UserEntry).totalTokens, + getValue: (row) => row.totalTokens, }, { header: t("columns.consumedAmount"), className: "text-right font-mono", - cell: (row) => { - const r = row as UserEntry & { totalCostFormatted?: string }; - return r.totalCostFormatted ?? r.totalCost; - }, + cell: (row) => row.totalCostFormatted ?? row.totalCost, sortKey: "totalCost", - getValue: (row) => (row as UserEntry).totalCost, + getValue: (row) => row.totalCost, defaultBold: true, }, ]; @@ -241,10 +245,7 @@ export function LeaderboardView({ isAdmin }: LeaderboardViewProps) { { header: t("columns.cost"), className: "text-right font-mono", - cell: (row) => { - const r = row as { totalCostFormatted?: string; totalCost: number }; - return r.totalCostFormatted ?? r.totalCost; - }, + cell: (row) => row.totalCostFormatted ?? row.totalCost, sortKey: "totalCost", getValue: (row) => row.totalCost, defaultBold: true, @@ -288,9 +289,7 @@ export function LeaderboardView({ isAdmin }: LeaderboardViewProps) { className: "text-right font-mono", cell: (row) => { if (row.avgCostPerRequest == null) return "-"; - const formatted = (row as { avgCostPerRequestFormatted?: string | null }) - .avgCostPerRequestFormatted; - return formatted ?? row.avgCostPerRequest.toFixed(4); + return row.avgCostPerRequestFormatted ?? row.avgCostPerRequest.toFixed(4); }, sortKey: "avgCostPerRequest", getValue: (row) => row.avgCostPerRequest ?? 0, @@ -300,9 +299,7 @@ export function LeaderboardView({ isAdmin }: LeaderboardViewProps) { className: "text-right font-mono", cell: (row) => { if (row.avgCostPerMillionTokens == null) return "-"; - const formatted = (row as { avgCostPerMillionTokensFormatted?: string | null }) - .avgCostPerMillionTokensFormatted; - return formatted ?? row.avgCostPerMillionTokens.toFixed(2); + return row.avgCostPerMillionTokensFormatted ?? row.avgCostPerMillionTokens.toFixed(2); }, sortKey: "avgCostPerMillionTokens", getValue: (row) => row.avgCostPerMillionTokens ?? 0, @@ -365,41 +362,38 @@ export function LeaderboardView({ isAdmin }: LeaderboardViewProps) { const modelColumns: ColumnDef[] = [ { header: t("columns.model"), - cell: (row) => {(row as ModelEntry).model}, + cell: (row) => {row.model}, sortKey: "model", - getValue: (row) => (row as ModelEntry).model, + getValue: (row) => row.model, }, { header: t("columns.requests"), className: "text-right", - cell: (row) => (row as ModelEntry).totalRequests.toLocaleString(), + cell: (row) => row.totalRequests.toLocaleString(), sortKey: "totalRequests", - getValue: (row) => (row as ModelEntry).totalRequests, + getValue: (row) => row.totalRequests, }, { header: t("columns.tokens"), className: "text-right", - cell: (row) => formatTokenAmount((row as ModelEntry).totalTokens), + cell: (row) => formatTokenAmount(row.totalTokens), sortKey: "totalTokens", - getValue: (row) => (row as ModelEntry).totalTokens, + getValue: (row) => row.totalTokens, }, { header: t("columns.cost"), className: "text-right font-mono", - cell: (row) => { - const r = row as ModelEntry & { totalCostFormatted?: string }; - return r.totalCostFormatted ?? r.totalCost; - }, + cell: (row) => row.totalCostFormatted ?? row.totalCost, sortKey: "totalCost", - getValue: (row) => (row as ModelEntry).totalCost, + getValue: (row) => row.totalCost, defaultBold: true, }, { header: t("columns.successRate"), className: "text-right", - cell: (row) => `${(Number((row as ModelEntry).successRate || 0) * 100).toFixed(1)}%`, + cell: (row) => `${(Number(row.successRate || 0) * 100).toFixed(1)}%`, sortKey: "successRate", - getValue: (row) => (row as ModelEntry).successRate, + getValue: (row) => row.successRate, }, ]; @@ -418,12 +412,12 @@ export function LeaderboardView({ isAdmin }: LeaderboardViewProps) { if (scope === "provider") { return ( - data={data as ProviderEntry[] as ProviderRow[]} + data={data as ProviderRow[]} period={period} columns={providerColumns} getRowKey={(row) => ("providerId" in row ? row.providerId : row.model)} getSubRows={(row) => ("modelStats" in row ? row.modelStats : null)} - getSubRowKey={(row) => ("model" in row ? row.model : row.providerId)} + getSubRowKey={(subRow) => (subRow as ModelProviderStatClient).model} /> ); } @@ -431,12 +425,12 @@ export function LeaderboardView({ isAdmin }: LeaderboardViewProps) { if (scope === "providerCacheHitRate") { return ( - data={data as ProviderCacheHitRateEntry[] as ProviderCacheHitRateRow[]} + data={data as ProviderCacheHitRateRow[]} period={period} columns={providerCacheHitRateColumns} getRowKey={(row) => ("providerId" in row ? row.providerId : row.model)} getSubRows={(row) => ("modelStats" in row ? row.modelStats : null)} - getSubRowKey={(row) => ("model" in row ? row.model : row.providerId)} + getSubRowKey={(subRow) => (subRow as ModelCacheHitStat).model} /> ); } diff --git a/src/lib/redis/leaderboard-cache.ts b/src/lib/redis/leaderboard-cache.ts index d3774fd59..26abb74ac 100644 --- a/src/lib/redis/leaderboard-cache.ts +++ b/src/lib/redis/leaderboard-cache.ts @@ -321,11 +321,16 @@ export async function getLeaderboardWithCache( * * @param period - 排行榜周期 * @param currencyDisplay - 货币显示单位 + * @param scope - 榜单范围 + * @param dateRange - 自定义日期范围(仅 period=custom 时使用) + * @param filters - 过滤条件(会影响缓存键) */ export async function invalidateLeaderboardCache( period: LeaderboardPeriod, currencyDisplay: string, - scope: LeaderboardScope = "user" + scope: LeaderboardScope = "user", + dateRange?: DateRangeParams, + filters?: LeaderboardFilters ): Promise { const redis = getRedisClient(); if (!redis) { @@ -334,7 +339,7 @@ export async function invalidateLeaderboardCache( // Resolve timezone once per request const timezone = await resolveSystemTimezone(); - const cacheKey = buildCacheKey(period, currencyDisplay, timezone, scope); + const cacheKey = buildCacheKey(period, currencyDisplay, timezone, scope, dateRange, filters); try { await redis.del(cacheKey); diff --git a/src/repository/leaderboard.ts b/src/repository/leaderboard.ts index f747e861e..0931d20d9 100644 --- a/src/repository/leaderboard.ts +++ b/src/repository/leaderboard.ts @@ -8,6 +8,8 @@ import type { ProviderType } from "@/types/provider"; import { LEDGER_BILLING_CONDITION } from "./_shared/ledger-conditions"; import { getSystemSettings } from "./system-config"; +const clampRatio01 = (value: number | null | undefined) => Math.min(Math.max(value ?? 0, 0), 1); + /** * 排行榜条目类型 */ @@ -519,7 +521,7 @@ async function findProviderLeaderboardWithTimezone( totalRequests, totalCost, totalTokens, - successRate: entry.successRate ?? 0, + successRate: clampRatio01(entry.successRate), avgTtfbMs: entry.avgTtfbMs ?? 0, avgTokensPerSecond: entry.avgTokensPerSecond ?? 0, ...avgCosts, @@ -571,7 +573,7 @@ async function findProviderLeaderboardWithTimezone( totalRequests, totalCost, totalTokens, - successRate: Math.min(Math.max(row.successRate ?? 0, 0), 1), + successRate: clampRatio01(row.successRate), avgTtfbMs: row.avgTtfbMs ?? 0, avgTokensPerSecond: row.avgTokensPerSecond ?? 0, ...avgCosts, @@ -833,7 +835,7 @@ async function findModelLeaderboardWithTimezone( totalRequests: entry.totalRequests, totalCost: parseFloat(entry.totalCost), totalTokens: entry.totalTokens, - successRate: entry.successRate ?? 0, + successRate: clampRatio01(entry.successRate), })); } diff --git a/tests/unit/dashboard/leaderboard-table-expandable-rows.test.tsx b/tests/unit/dashboard/leaderboard-table-expandable-rows.test.tsx index 163d0eca1..33e97fa83 100644 --- a/tests/unit/dashboard/leaderboard-table-expandable-rows.test.tsx +++ b/tests/unit/dashboard/leaderboard-table-expandable-rows.test.tsx @@ -94,9 +94,14 @@ describe("LeaderboardTable expandable rows", () => { expect(findCellByText("Provider A")).toBeTruthy(); expect(findCellByText("model-x")).toBeNull(); - const providerCell = findCellByText("Provider A")!; + const expandButton = container.querySelector( + 'button[aria-label="expandModelStats"]' + ) as HTMLButtonElement | null; + expect(expandButton).toBeTruthy(); + expect(expandButton!.getAttribute("aria-expanded")).toBe("false"); + act(() => { - providerCell.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expandButton!.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); const modelCell = findCellByText("model-x"); From f062847349d2874c17b6f51ebdea5b8f2f54c3ec Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Sun, 1 Mar 2026 23:39:27 +0800 Subject: [PATCH 4/9] =?UTF-8?q?refactor:=20=E4=BC=98=E5=8C=96=E6=A6=9C?= =?UTF-8?q?=E5=8D=95=E8=A1=A8=E6=A0=BC=E7=88=B6=E5=AD=90=E8=A1=8C=E7=B1=BB?= =?UTF-8?q?=E5=9E=8B=E5=BB=BA=E6=A8=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LeaderboardTable 支持 Parent/Sub 泛型,避免 data 误导为父子混合 - provider/cacheHitRate 调用改用 providerId 作为父行 key,子行 key 直接用 model - 补充 ProviderLeaderboardEntry.modelStats 的 undefined/[] 语义注释 - 更新可展开行单测 --- .../_components/leaderboard-table.tsx | 21 +++++++------- .../_components/leaderboard-view.tsx | 28 +++++++++---------- src/repository/leaderboard.ts | 6 +++- ...leaderboard-table-expandable-rows.test.tsx | 10 +++---- 4 files changed, 35 insertions(+), 30 deletions(-) diff --git a/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-table.tsx b/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-table.tsx index cffe8ac8f..9113b89d4 100644 --- a/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-table.tsx +++ b/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-table.tsx @@ -36,31 +36,32 @@ export interface ColumnDef { type SortDirection = "asc" | "desc" | null; -interface LeaderboardTableProps { - data: T[]; +interface LeaderboardTableProps { + data: TParent[]; period: LeaderboardPeriod; - columns: ColumnDef[]; // 不包含"排名"列,组件会自动添加 - getRowKey?: (row: T, index: number) => string | number; + columns: ColumnDef[]; // 不包含"排名"列,组件会自动添加 + getRowKey?: (row: TParent, index: number) => string | number; /** 返回子行数据(非空且长度 > 0 时,父行展示可展开图标) */ - getSubRows?: (row: T, index: number) => T[] | null | undefined; + getSubRows?: (row: TParent, index: number) => TSub[] | null | undefined; /** 子行的 React key(默认使用 `${parentKey}-${subIndex}` 组合) */ getSubRowKey?: ( - subRow: T, - parentRow: T, + subRow: TSub, + parentRow: TParent, parentIndex: number, subIndex: number ) => string | number; } -export function LeaderboardTable({ +export function LeaderboardTable({ data, period, columns, getRowKey, getSubRows, getSubRowKey, -}: LeaderboardTableProps) { +}: LeaderboardTableProps) { const t = useTranslations("dashboard.leaderboard"); + type TableRow = TParent | TSub; // 排序状态 const [sortKey, setSortKey] = useState(null); @@ -89,7 +90,7 @@ export function LeaderboardTable({ }, [data, sortKey, sortDirection, getRowKey]); // 判断列是否需要加粗 - const getShouldBold = (col: ColumnDef) => { + const getShouldBold = (col: ColumnDef) => { const isActiveSortColumn = sortKey === col.sortKey && sortDirection !== null; const noSorting = sortKey === null; return isActiveSortColumn || (col.defaultBold && noSorting); diff --git a/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-view.tsx b/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-view.tsx index b59c5748e..553d9b004 100644 --- a/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-view.tsx +++ b/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-view.tsx @@ -43,9 +43,9 @@ type ProviderEntry = Omit & ProviderCostFormattedFields & { modelStats?: ModelProviderStatClient[]; }; -type ProviderRow = ProviderEntry | ModelProviderStatClient; +type ProviderTableRow = ProviderEntry | ModelProviderStatClient; type ProviderCacheHitRateEntry = ProviderCacheHitRateLeaderboardEntry; -type ProviderCacheHitRateRow = ProviderCacheHitRateEntry | ModelCacheHitStat; +type ProviderCacheHitRateTableRow = ProviderCacheHitRateEntry | ModelCacheHitStat; type AnyEntry = UserEntry | ProviderEntry | ProviderCacheHitRateEntry | ModelEntry; const VALID_PERIODS: LeaderboardPeriod[] = ["daily", "weekly", "monthly", "allTime", "custom"]; @@ -221,7 +221,7 @@ export function LeaderboardView({ isAdmin }: LeaderboardViewProps) { }, ]; - const providerColumns: ColumnDef[] = [ + const providerColumns: ColumnDef[] = [ { header: t("columns.provider"), cell: (row) => { @@ -306,7 +306,7 @@ export function LeaderboardView({ isAdmin }: LeaderboardViewProps) { }, ]; - const providerCacheHitRateColumns: ColumnDef[] = [ + const providerCacheHitRateColumns: ColumnDef[] = [ { header: t("columns.provider"), cell: (row) => { @@ -411,26 +411,26 @@ export function LeaderboardView({ isAdmin }: LeaderboardViewProps) { if (scope === "provider") { return ( - - data={data as ProviderRow[]} + + data={data as ProviderEntry[]} period={period} columns={providerColumns} - getRowKey={(row) => ("providerId" in row ? row.providerId : row.model)} - getSubRows={(row) => ("modelStats" in row ? row.modelStats : null)} - getSubRowKey={(subRow) => (subRow as ModelProviderStatClient).model} + getRowKey={(row) => row.providerId} + getSubRows={(row) => row.modelStats} + getSubRowKey={(subRow) => subRow.model} /> ); } if (scope === "providerCacheHitRate") { return ( - - data={data as ProviderCacheHitRateRow[]} + + data={data as ProviderCacheHitRateEntry[]} period={period} columns={providerCacheHitRateColumns} - getRowKey={(row) => ("providerId" in row ? row.providerId : row.model)} - getSubRows={(row) => ("modelStats" in row ? row.modelStats : null)} - getSubRowKey={(subRow) => (subRow as ModelCacheHitStat).model} + getRowKey={(row) => row.providerId} + getSubRows={(row) => row.modelStats} + getSubRowKey={(subRow) => subRow.model} /> ); } diff --git a/src/repository/leaderboard.ts b/src/repository/leaderboard.ts index 0931d20d9..417e4cbaa 100644 --- a/src/repository/leaderboard.ts +++ b/src/repository/leaderboard.ts @@ -45,7 +45,11 @@ export interface ProviderLeaderboardEntry { avgTokensPerSecond: number; // tok/s(仅统计流式且可计算的请求) avgCostPerRequest: number | null; // totalCost / totalRequests, null when totalRequests === 0 avgCostPerMillionTokens: number | null; // totalCost * 1_000_000 / totalTokens, null when totalTokens === 0 - /** 可选:按模型拆分(仅在 includeModelStats=true 时填充) */ + /** + * 可选:按模型拆分 + * - undefined: 未请求 includeModelStats + * - []: 已请求 includeModelStats,但该 provider 下无可用模型统计 + */ modelStats?: ModelProviderStat[]; } diff --git a/tests/unit/dashboard/leaderboard-table-expandable-rows.test.tsx b/tests/unit/dashboard/leaderboard-table-expandable-rows.test.tsx index 33e97fa83..b74e0ac07 100644 --- a/tests/unit/dashboard/leaderboard-table-expandable-rows.test.tsx +++ b/tests/unit/dashboard/leaderboard-table-expandable-rows.test.tsx @@ -77,13 +77,13 @@ describe("LeaderboardTable expandable rows", () => { ]; const { container } = renderSimple( - - data={data as Row[]} + + data={data} period="daily" columns={columns} - getRowKey={(row) => ("providerId" in row ? row.providerId : row.model)} - getSubRows={(row) => ("modelStats" in row ? row.modelStats : null)} - getSubRowKey={(row) => ("model" in row ? row.model : row.providerId)} + getRowKey={(row) => row.providerId} + getSubRows={(row) => row.modelStats} + getSubRowKey={(subRow) => subRow.model} /> ); From f49386f7e696d0653cfd1a48d6b39a5035437303 Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Sun, 1 Mar 2026 23:57:32 +0800 Subject: [PATCH 5/9] =?UTF-8?q?fix:=20=E8=A7=84=E8=8C=83=E5=8C=96=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E5=90=8D=E5=B9=B6=E5=AE=8C=E5=96=84=E5=B1=95=E5=BC=80?= =?UTF-8?q?=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - provider/modelStats 聚合使用 TRIM+NULLIF 规范化模型名,避免空白差异导致重复行 - 提取 renderSubModelLabel,消除模型子行缩进渲染重复 - 单测补充二次点击收起断言(aria-expanded 回落 + 子行隐藏) --- .../_components/leaderboard-view.tsx | 18 ++++++++---------- src/repository/leaderboard.ts | 10 ++++++---- .../leaderboard-table-expandable-rows.test.tsx | 7 +++++++ 3 files changed, 21 insertions(+), 14 deletions(-) diff --git a/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-view.tsx b/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-view.tsx index 553d9b004..dc29ec73f 100644 --- a/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-view.tsx +++ b/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-view.tsx @@ -190,6 +190,12 @@ export function LeaderboardView({ isAdmin }: LeaderboardViewProps) { const skeletonGridStyle = { gridTemplateColumns: `repeat(${skeletonColumns}, minmax(0, 1fr))` }; // 列定义(根据 scope 动态切换) + const renderSubModelLabel = (model: string) => ( +
+ {model} +
+ ); + const userColumns: ColumnDef[] = [ { header: t("columns.user"), @@ -226,11 +232,7 @@ export function LeaderboardView({ isAdmin }: LeaderboardViewProps) { header: t("columns.provider"), cell: (row) => { if ("providerName" in row) return row.providerName; - return ( -
- {row.model} -
- ); + return renderSubModelLabel(row.model); }, sortKey: "providerName", getValue: (row) => ("providerName" in row ? row.providerName : row.model), @@ -311,11 +313,7 @@ export function LeaderboardView({ isAdmin }: LeaderboardViewProps) { header: t("columns.provider"), cell: (row) => { if ("providerName" in row) return row.providerName; - return ( -
- {row.model} -
- ); + return renderSubModelLabel(row.model); }, sortKey: "providerName", getValue: (row) => ("providerName" in row ? row.providerName : row.model), diff --git a/src/repository/leaderboard.ts b/src/repository/leaderboard.ts index 417e4cbaa..51ca26afd 100644 --- a/src/repository/leaderboard.ts +++ b/src/repository/leaderboard.ts @@ -537,10 +537,11 @@ async function findProviderLeaderboardWithTimezone( // Model breakdown per provider const systemSettings = await getSystemSettings(); const billingModelSource = systemSettings.billingModelSource; - const modelField = + const rawModelField = billingModelSource === "original" ? sql`COALESCE(${usageLedger.originalModel}, ${usageLedger.model})` : sql`COALESCE(${usageLedger.model}, ${usageLedger.originalModel})`; + const modelField = sql`NULLIF(TRIM(${rawModelField}), '')`; const modelRows = await db .select({ @@ -566,7 +567,7 @@ async function findProviderLeaderboardWithTimezone( const modelStatsByProvider = new Map(); for (const row of modelRows) { - if (!row.model?.trim()) continue; + if (!row.model) continue; const totalCost = parseFloat(row.totalCost); const totalRequests = row.totalRequests; const totalTokens = row.totalTokens; @@ -656,10 +657,11 @@ async function findProviderCacheHitRateLeaderboardWithTimezone( // Model-level cache hit breakdown per provider const systemSettings = await getSystemSettings(); const billingModelSource = systemSettings.billingModelSource; - const modelField = + const rawModelField = billingModelSource === "original" ? sql`COALESCE(${usageLedger.originalModel}, ${usageLedger.model})` : sql`COALESCE(${usageLedger.model}, ${usageLedger.originalModel})`; + const modelField = sql`NULLIF(TRIM(${rawModelField}), '')`; const modelTotalInput = sql`COALESCE(sum(${totalInputTokensExpr})::double precision, 0::double precision)`; const modelCacheRead = sql`COALESCE(sum(COALESCE(${usageLedger.cacheReadInputTokens}, 0))::double precision, 0::double precision)`; @@ -691,7 +693,7 @@ async function findProviderCacheHitRateLeaderboardWithTimezone( // Group model stats by providerId const modelStatsByProvider = new Map(); for (const row of modelRows) { - if (!row.model || row.model.trim() === "") continue; + if (!row.model) continue; const stats = modelStatsByProvider.get(row.providerId) ?? []; stats.push({ model: row.model, diff --git a/tests/unit/dashboard/leaderboard-table-expandable-rows.test.tsx b/tests/unit/dashboard/leaderboard-table-expandable-rows.test.tsx index b74e0ac07..fdee59243 100644 --- a/tests/unit/dashboard/leaderboard-table-expandable-rows.test.tsx +++ b/tests/unit/dashboard/leaderboard-table-expandable-rows.test.tsx @@ -103,6 +103,7 @@ describe("LeaderboardTable expandable rows", () => { act(() => { expandButton!.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); + expect(expandButton!.getAttribute("aria-expanded")).toBe("true"); const modelCell = findCellByText("model-x"); expect(modelCell).toBeTruthy(); @@ -110,5 +111,11 @@ describe("LeaderboardTable expandable rows", () => { const modelRow = modelCell!.closest("tr"); expect(modelRow).toBeTruthy(); expect(modelRow!.className).toContain("bg-muted/30"); + + act(() => { + expandButton!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + expect(expandButton!.getAttribute("aria-expanded")).toBe("false"); + expect(findCellByText("model-x")).toBeNull(); }); }); From e9e4a7164a301db579eb8fd9859be8ddaeff524d Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Mon, 2 Mar 2026 00:20:09 +0800 Subject: [PATCH 6/9] =?UTF-8?q?refactor:=20=E6=94=B6=E6=95=9B=20clamp=20?= =?UTF-8?q?=E9=80=BB=E8=BE=91=E5=B9=B6=E6=95=B4=E7=90=86=E6=A6=9C=E5=8D=95?= =?UTF-8?q?=E6=B8=B2=E6=9F=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - providerCacheHitRate 的 cacheHitRate 复用 clampRatio01,避免重复实现 - 拆分 render*Table helpers,集中处理 scope 分支与类型断言 --- .../_components/leaderboard-view.tsx | 85 +++++++++---------- src/repository/leaderboard.ts | 4 +- 2 files changed, 44 insertions(+), 45 deletions(-) diff --git a/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-view.tsx b/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-view.tsx index dc29ec73f..66baac510 100644 --- a/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-view.tsx +++ b/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-view.tsx @@ -395,52 +395,51 @@ export function LeaderboardView({ isAdmin }: LeaderboardViewProps) { }, ]; - const renderTable = () => { - if (scope === "user") { - return ( - - data={data as UserEntry[]} - period={period} - columns={userColumns} - getRowKey={(row) => row.userId} - /> - ); - } + const renderUserTable = () => ( + + data={data as UserEntry[]} + period={period} + columns={userColumns} + getRowKey={(row) => row.userId} + /> + ); - if (scope === "provider") { - return ( - - data={data as ProviderEntry[]} - period={period} - columns={providerColumns} - getRowKey={(row) => row.providerId} - getSubRows={(row) => row.modelStats} - getSubRowKey={(subRow) => subRow.model} - /> - ); - } + const renderProviderTable = () => ( + + data={data as ProviderEntry[]} + period={period} + columns={providerColumns} + getRowKey={(row) => row.providerId} + getSubRows={(row) => row.modelStats} + getSubRowKey={(subRow) => subRow.model} + /> + ); - if (scope === "providerCacheHitRate") { - return ( - - data={data as ProviderCacheHitRateEntry[]} - period={period} - columns={providerCacheHitRateColumns} - getRowKey={(row) => row.providerId} - getSubRows={(row) => row.modelStats} - getSubRowKey={(subRow) => subRow.model} - /> - ); - } + const renderProviderCacheHitRateTable = () => ( + + data={data as ProviderCacheHitRateEntry[]} + period={period} + columns={providerCacheHitRateColumns} + getRowKey={(row) => row.providerId} + getSubRows={(row) => row.modelStats} + getSubRowKey={(subRow) => subRow.model} + /> + ); + + const renderModelTable = () => ( + + data={data as ModelEntry[]} + period={period} + columns={modelColumns} + getRowKey={(row) => row.model} + /> + ); - return ( - - data={data as ModelEntry[]} - period={period} - columns={modelColumns} - getRowKey={(row) => row.model} - /> - ); + const renderTable = () => { + if (scope === "user") return renderUserTable(); + if (scope === "provider") return renderProviderTable(); + if (scope === "providerCacheHitRate") return renderProviderCacheHitRateTable(); + return renderModelTable(); }; return ( diff --git a/src/repository/leaderboard.ts b/src/repository/leaderboard.ts index 51ca26afd..38d1a7971 100644 --- a/src/repository/leaderboard.ts +++ b/src/repository/leaderboard.ts @@ -700,7 +700,7 @@ async function findProviderCacheHitRateLeaderboardWithTimezone( totalRequests: row.totalRequests, cacheReadTokens: row.cacheReadTokens, totalInputTokens: row.totalInputTokens, - cacheHitRate: Math.min(Math.max(row.cacheHitRate ?? 0, 0), 1), + cacheHitRate: clampRatio01(row.cacheHitRate), }); modelStatsByProvider.set(row.providerId, stats); } @@ -714,7 +714,7 @@ async function findProviderCacheHitRateLeaderboardWithTimezone( cacheCreationCost: parseFloat(entry.cacheCreationCost), totalInputTokens: entry.totalInputTokens, totalTokens: entry.totalInputTokens, // deprecated, for backward compatibility - cacheHitRate: Math.min(Math.max(entry.cacheHitRate ?? 0, 0), 1), + cacheHitRate: clampRatio01(entry.cacheHitRate), modelStats: modelStatsByProvider.get(entry.providerId) ?? [], })); } From 1df46ba41cebeec2143e26e0bc78a123b9ef96cd Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Mon, 2 Mar 2026 01:55:38 +0800 Subject: [PATCH 7/9] =?UTF-8?q?fix:=20includeModelStats=20=E7=A9=BA?= =?UTF-8?q?=E6=95=B0=E7=BB=84=E4=B9=9F=E4=BF=9D=E7=95=99=20modelStats?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/api/leaderboard/route.ts | 2 +- tests/unit/api/leaderboard-route.test.ts | 37 ++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/src/app/api/leaderboard/route.ts b/src/app/api/leaderboard/route.ts index e727c7385..8fdef012a 100644 --- a/src/app/api/leaderboard/route.ts +++ b/src/app/api/leaderboard/route.ts @@ -231,7 +231,7 @@ export async function GET(request: NextRequest) { ...base, ...providerFields, ...cacheFields, - ...(modelStatsFormatted ? { modelStats: modelStatsFormatted } : {}), + ...(modelStatsFormatted !== undefined ? { modelStats: modelStatsFormatted } : {}), }; }); diff --git a/tests/unit/api/leaderboard-route.test.ts b/tests/unit/api/leaderboard-route.test.ts index dfd48982c..255018760 100644 --- a/tests/unit/api/leaderboard-route.test.ts +++ b/tests/unit/api/leaderboard-route.test.ts @@ -249,5 +249,42 @@ describe("GET /api/leaderboard", () => { expect(entry.modelStats[0]).toHaveProperty("avgCostPerRequestFormatted"); expect(entry.modelStats[0]).toHaveProperty("avgCostPerMillionTokensFormatted"); }); + + it("returns empty modelStats array when includeModelStats is requested but provider has no model data", async () => { + mocks.getSession.mockResolvedValue({ user: { id: 1, name: "u", role: "admin" } }); + mocks.getLeaderboardWithCache.mockResolvedValue([ + { + providerId: 1, + providerName: "empty-models-provider", + totalRequests: 10, + totalCost: 1.0, + totalTokens: 1000, + successRate: 1, + avgTtfbMs: 100, + avgTokensPerSecond: 20, + avgCostPerRequest: 0.1, + avgCostPerMillionTokens: 1000, + modelStats: [], + }, + ]); + + const { GET } = await import("@/app/api/leaderboard/route"); + const url = + "http://localhost/api/leaderboard?scope=provider&period=daily&includeModelStats=1"; + const response = await GET({ nextUrl: new URL(url) } as any); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(mocks.getLeaderboardWithCache).toHaveBeenCalledTimes(1); + + const callArgs = mocks.getLeaderboardWithCache.mock.calls[0]; + const options = callArgs[4]; + expect(options.includeModelStats).toBe(true); + + expect(body).toHaveLength(1); + expect(body[0]).toHaveProperty("modelStats"); + expect(Array.isArray(body[0].modelStats)).toBe(true); + expect(body[0].modelStats).toHaveLength(0); + }); }); }); From 16c0c852551b8df0562430ac90d64678dcbf084e Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Mon, 2 Mar 2026 10:57:25 +0800 Subject: [PATCH 8/9] =?UTF-8?q?fix:=20=E6=A6=9C=E5=8D=95=E8=A1=8C=E6=97=A0?= =?UTF-8?q?=E5=B1=95=E5=BC=80=E6=97=B6=E4=BF=9D=E6=8C=81=E6=8E=92=E5=90=8D?= =?UTF-8?q?=E5=AF=B9=E9=BD=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dashboard/leaderboard/_components/leaderboard-table.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-table.tsx b/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-table.tsx index 9113b89d4..4d47b2a36 100644 --- a/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-table.tsx +++ b/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-table.tsx @@ -272,7 +272,9 @@ export function LeaderboardTable({ )} - ) : null} + ) : ( +
From 8bf905a9b3caa8e07da5b9ba30a486e4c9664c45 Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Mon, 2 Mar 2026 11:49:04 +0800 Subject: [PATCH 9/9] =?UTF-8?q?refactor:=20LeaderboardTable=20cell=20?= =?UTF-8?q?=E5=A2=9E=E5=8A=A0=20isSubRow=20=E8=AF=AD=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../leaderboard/_components/leaderboard-table.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-table.tsx b/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-table.tsx index 4d47b2a36..851cc15cb 100644 --- a/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-table.tsx +++ b/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-table.tsx @@ -28,7 +28,12 @@ import type { LeaderboardPeriod } from "@/repository/leaderboard"; export interface ColumnDef { header: string; className?: string; - cell: (row: T, index: number) => React.ReactNode; + /** + * index 语义: + * - 父行:按当前排序后的全局行序(从 0 开始) + * - 子行:父行内的子行序(从 0 开始) + */ + cell: (row: T, index: number, isSubRow?: boolean) => React.ReactNode; sortKey?: string; // 用于排序的字段名 getValue?: (row: T) => number | string; // 获取排序值的函数 defaultBold?: boolean; // 默认加粗(无排序时显示加粗) @@ -285,7 +290,7 @@ export function LeaderboardTable({ key={idx} className={`${col.className || ""} ${shouldBold ? "font-bold" : ""}`} > - {col.cell(row, index)} + {col.cell(row, index, false)}
); })} @@ -310,7 +315,7 @@ export function LeaderboardTable({ key={idx} className={`${col.className || ""} ${shouldBold ? "font-bold" : ""}`} > - {col.cell(subRow, subIndex)} + {col.cell(subRow, subIndex, true)} ); })}