Просмотр исходного кода

feat: 供应商榜单支持展开查看模型明细 (#852)

* feat: 供应商榜单支持展开查看模型明细

* refactor: 复用供应商榜单聚合表达式

* fix: 按 review 修复榜单展开细节

- 展开按钮化(aria-expanded + i18n label),避免仅依赖行点击

- getRowKey 缺失时在排序/数据变化清空展开态,避免错位

- invalidateLeaderboardCache 支持 dateRange/filters 以清理 includeModelStats 缓存

- successRate 统一 clamp 到 [0,1],并收敛前端类型断言

* refactor: 优化榜单表格父子行类型建模

- LeaderboardTable 支持 Parent/Sub 泛型,避免 data 误导为父子混合

- provider/cacheHitRate 调用改用 providerId 作为父行 key,子行 key 直接用 model

- 补充 ProviderLeaderboardEntry.modelStats 的 undefined/[] 语义注释

- 更新可展开行单测

* fix: 规范化模型名并完善展开测试

- provider/modelStats 聚合使用 TRIM+NULLIF 规范化模型名,避免空白差异导致重复行

- 提取 renderSubModelLabel,消除模型子行缩进渲染重复

- 单测补充二次点击收起断言(aria-expanded 回落 + 子行隐藏)

* refactor: 收敛 clamp 逻辑并整理榜单渲染

- providerCacheHitRate 的 cacheHitRate 复用 clampRatio01,避免重复实现

- 拆分 render*Table helpers,集中处理 scope 分支与类型断言

* fix: includeModelStats 空数组也保留 modelStats

* fix: 榜单行无展开时保持排名对齐

* refactor: LeaderboardTable cell 增加 isSubRow 语义

---------

Co-authored-by: tesgth032 <[email protected]>
tesgth032 1 месяц назад
Родитель
Сommit
a4e779d81d

+ 85 - 38
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 {
@@ -28,7 +28,12 @@ import type { LeaderboardPeriod } from "@/repository/leaderboard";
 export interface ColumnDef<T> {
   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; // 默认加粗(无排序时显示加粗)
@@ -36,22 +41,32 @@ export interface ColumnDef<T> {
 
 type SortDirection = "asc" | "desc" | null;
 
-interface LeaderboardTableProps<T> {
-  data: T[];
+interface LeaderboardTableProps<TParent, TSub = TParent> {
+  data: TParent[];
   period: LeaderboardPeriod;
-  columns: ColumnDef<T>[]; // 不包含"排名"列,组件会自动添加
-  getRowKey?: (row: T, index: number) => string | number;
-  renderExpandedContent?: (row: T, index: number) => React.ReactNode | null;
+  columns: ColumnDef<TParent | TSub>[]; // 不包含"排名"列,组件会自动添加
+  getRowKey?: (row: TParent, index: number) => string | number;
+  /** 返回子行数据(非空且长度 > 0 时,父行展示可展开图标) */
+  getSubRows?: (row: TParent, index: number) => TSub[] | null | undefined;
+  /** 子行的 React key(默认使用 `${parentKey}-${subIndex}` 组合) */
+  getSubRowKey?: (
+    subRow: TSub,
+    parentRow: TParent,
+    parentIndex: number,
+    subIndex: number
+  ) => string | number;
 }
 
-export function LeaderboardTable<T>({
+export function LeaderboardTable<TParent, TSub = TParent>({
   data,
   period,
   columns,
   getRowKey,
-  renderExpandedContent,
-}: LeaderboardTableProps<T>) {
+  getSubRows,
+  getSubRowKey,
+}: LeaderboardTableProps<TParent, TSub>) {
   const t = useTranslations("dashboard.leaderboard");
+  type TableRow = TParent | TSub;
 
   // 排序状态
   const [sortKey, setSortKey] = useState<string | null>(null);
@@ -71,8 +86,16 @@ export function LeaderboardTable<T>({
     });
   };
 
+  // 当调用方未提供稳定 rowKey 时(回退到 index),排序会导致展开态错位;此时在排序/数据变化时清空展开态,至少避免错位展开。
+  // biome-ignore lint/correctness/useExhaustiveDependencies: 依赖用于在排序/数据变化时触发清空,避免 index key 造成错位展开
+  useEffect(() => {
+    if (!getRowKey) {
+      setExpandedRows(new Set());
+    }
+  }, [data, sortKey, sortDirection, getRowKey]);
+
   // 判断列是否需要加粗
-  const getShouldBold = (col: ColumnDef<T>) => {
+  const getShouldBold = (col: ColumnDef<TableRow>) => {
     const isActiveSortColumn = sortKey === col.sortKey && sortDirection !== null;
     const noSorting = sortKey === null;
     return isActiveSortColumn || (col.defaultBold && noSorting);
@@ -229,27 +252,34 @@ export function LeaderboardTable<T>({
                 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 (
                   <Fragment key={rowKey}>
-                    <TableRow
-                      className={`${isTopThree ? "bg-muted/50" : ""} ${hasExpandable && expandedContent ? "cursor-pointer" : ""}`}
-                      onClick={
-                        hasExpandable && expandedContent ? () => toggleRow(rowKey) : undefined
-                      }
-                    >
+                    <TableRow className={`${isTopThree ? "bg-muted/50" : ""}`}>
                       <TableCell>
                         <div className="flex items-center gap-1">
-                          {hasExpandable && expandedContent ? (
-                            isExpanded ? (
-                              <ChevronDown className="h-4 w-4 text-muted-foreground shrink-0" />
-                            ) : (
-                              <ChevronRight className="h-4 w-4 text-muted-foreground shrink-0" />
-                            )
-                          ) : null}
+                          {hasExpandable ? (
+                            <button
+                              type="button"
+                              className="inline-flex items-center cursor-pointer rounded-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
+                              onClick={() => toggleRow(rowKey)}
+                              aria-expanded={isExpanded}
+                              aria-label={
+                                isExpanded ? t("collapseModelStats") : t("expandModelStats")
+                              }
+                            >
+                              {isExpanded ? (
+                                <ChevronDown className="h-4 w-4 text-muted-foreground shrink-0" />
+                              ) : (
+                                <ChevronRight className="h-4 w-4 text-muted-foreground shrink-0" />
+                              )}
+                            </button>
+                          ) : (
+                            <div className="h-4 w-4" aria-hidden="true" />
+                          )}
                           {getRankBadge(rank)}
                         </div>
                       </TableCell>
@@ -260,21 +290,38 @@ export function LeaderboardTable<T>({
                             key={idx}
                             className={`${col.className || ""} ${shouldBold ? "font-bold" : ""}`}
                           >
-                            {col.cell(row, index)}
+                            {col.cell(row, index, false)}
                           </TableCell>
                         );
                       })}
                     </TableRow>
-                    {isExpanded && expandedContent && (
-                      <TableRow
-                        key={`${rowKey}-expanded`}
-                        className="bg-muted/30 hover:bg-muted/30"
-                      >
-                        <TableCell colSpan={columns.length + 1} className="p-0">
-                          {expandedContent}
-                        </TableCell>
-                      </TableRow>
-                    )}
+                    {isExpanded &&
+                      (subRows ?? []).map((subRow, subIndex) => {
+                        const rawSubKey = getSubRowKey
+                          ? getSubRowKey(subRow, row, index, subIndex)
+                          : subIndex;
+                        const subKey = `${rowKey}-${String(rawSubKey)}`;
+                        return (
+                          <TableRow key={subKey} className="bg-muted/30 hover:bg-muted/30">
+                            <TableCell>
+                              <div className="flex items-center gap-1">
+                                <div className="h-4 w-4" />
+                              </div>
+                            </TableCell>
+                            {columns.map((col, idx) => {
+                              const shouldBold = getShouldBold(col);
+                              return (
+                                <TableCell
+                                  key={idx}
+                                  className={`${col.className || ""} ${shouldBold ? "font-bold" : ""}`}
+                                >
+                                  {col.cell(subRow, subIndex, true)}
+                                </TableCell>
+                              );
+                            })}
+                          </TableRow>
+                        );
+                      })}
                   </Fragment>
                 );
               })}

+ 124 - 152
src/app/[locale]/dashboard/leaderboard/_components/leaderboard-view.tsx

@@ -16,6 +16,7 @@ import type {
   LeaderboardPeriod,
   ModelCacheHitStat,
   ModelLeaderboardEntry,
+  ModelProviderStat,
   ProviderCacheHitRateLeaderboardEntry,
   ProviderLeaderboardEntry,
 } from "@/repository/leaderboard";
@@ -28,14 +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 UserEntry = LeaderboardEntry & TotalCostFormattedFields;
+type ModelEntry = ModelLeaderboardEntry & TotalCostFormattedFields;
+type ModelProviderStatClient = ModelProviderStat & ProviderCostFormattedFields;
+type ProviderEntry = Omit<ProviderLeaderboardEntry, "modelStats"> &
+  ProviderCostFormattedFields & {
+    modelStats?: ModelProviderStatClient[];
+  };
+type ProviderTableRow = ProviderEntry | ModelProviderStatClient;
 type ProviderCacheHitRateEntry = ProviderCacheHitRateLeaderboardEntry;
-type ModelEntry = ModelLeaderboardEntry & { totalCostFormatted?: string };
+type ProviderCacheHitRateTableRow = ProviderCacheHitRateEntry | ModelCacheHitStat;
 type AnyEntry = UserEntry | ProviderEntry | ProviderCacheHitRateEntry | ModelEntry;
 
 const VALID_PERIODS: LeaderboardPeriod[] = ["daily", "weekly", "monthly", "allTime", "custom"];
@@ -122,6 +132,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(","))}`;
@@ -177,142 +190,146 @@ export function LeaderboardView({ isAdmin }: LeaderboardViewProps) {
   const skeletonGridStyle = { gridTemplateColumns: `repeat(${skeletonColumns}, minmax(0, 1fr))` };
 
   // 列定义(根据 scope 动态切换)
+  const renderSubModelLabel = (model: string) => (
+    <div className="pl-6">
+      <span className="font-mono text-sm">{model}</span>
+    </div>
+  );
+
   const userColumns: ColumnDef<UserEntry>[] = [
     {
       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,
     },
   ];
 
-  const providerColumns: ColumnDef<ProviderEntry>[] = [
+  const providerColumns: ColumnDef<ProviderTableRow>[] = [
     {
       header: t("columns.provider"),
-      cell: (row) => (row as ProviderEntry).providerName,
+      cell: (row) => {
+        if ("providerName" in row) return row.providerName;
+        return renderSubModelLabel(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 };
-        return r.totalCostFormatted ?? r.totalCost;
-      },
+      cell: (row) => row.totalCostFormatted ?? row.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 "-";
+        return row.avgCostPerRequestFormatted ?? 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 "-";
+        return row.avgCostPerMillionTokensFormatted ?? row.avgCostPerMillionTokens.toFixed(2);
       },
       sortKey: "avgCostPerMillionTokens",
-      getValue: (row) => (row as ProviderEntry).avgCostPerMillionTokens ?? 0,
+      getValue: (row) => row.avgCostPerMillionTokens ?? 0,
     },
   ];
 
-  const providerCacheHitRateColumns: ColumnDef<ProviderCacheHitRateEntry>[] = [
+  const providerCacheHitRateColumns: ColumnDef<ProviderCacheHitRateTableRow>[] = [
     {
       header: t("columns.provider"),
-      cell: (row) => (row as ProviderCacheHitRateEntry).providerName,
+      cell: (row) => {
+        if ("providerName" in row) return row.providerName;
+        return renderSubModelLabel(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,89 +339,107 @@ export function LeaderboardView({ isAdmin }: LeaderboardViewProps) {
         return <span className={colorClass}>{rate.toFixed(1)}%</span>;
       },
       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,
     },
   ];
 
   const modelColumns: ColumnDef<ModelEntry>[] = [
     {
       header: t("columns.model"),
-      cell: (row) => <span className="font-mono text-sm">{(row as ModelEntry).model}</span>,
+      cell: (row) => <span className="font-mono text-sm">{row.model}</span>,
       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,
     },
   ];
 
-  const columns = (() => {
-    switch (scope) {
-      case "user":
-        return userColumns as ColumnDef<AnyEntry>[];
-      case "provider":
-        return providerColumns as ColumnDef<AnyEntry>[];
-      case "providerCacheHitRate":
-        return providerCacheHitRateColumns as ColumnDef<AnyEntry>[];
-      case "model":
-        return modelColumns as ColumnDef<AnyEntry>[];
-    }
-  })();
-
-  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;
-    }
+  const renderUserTable = () => (
+    <LeaderboardTable<UserEntry>
+      data={data as UserEntry[]}
+      period={period}
+      columns={userColumns}
+      getRowKey={(row) => row.userId}
+    />
+  );
+
+  const renderProviderTable = () => (
+    <LeaderboardTable<ProviderEntry, ModelProviderStatClient>
+      data={data as ProviderEntry[]}
+      period={period}
+      columns={providerColumns}
+      getRowKey={(row) => row.providerId}
+      getSubRows={(row) => row.modelStats}
+      getSubRowKey={(subRow) => subRow.model}
+    />
+  );
+
+  const renderProviderCacheHitRateTable = () => (
+    <LeaderboardTable<ProviderCacheHitRateEntry, ModelCacheHitStat>
+      data={data as ProviderCacheHitRateEntry[]}
+      period={period}
+      columns={providerCacheHitRateColumns}
+      getRowKey={(row) => row.providerId}
+      getSubRows={(row) => row.modelStats}
+      getSubRowKey={(subRow) => subRow.model}
+    />
+  );
+
+  const renderModelTable = () => (
+    <LeaderboardTable<ModelEntry>
+      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 (
@@ -511,70 +546,7 @@ export function LeaderboardView({ isAdmin }: LeaderboardViewProps) {
             </CardContent>
           </Card>
         ) : (
-          <LeaderboardTable
-            data={data}
-            period={period}
-            columns={columns}
-            getRowKey={rowKey}
-            renderExpandedContent={
-              scope === "providerCacheHitRate"
-                ? (row) => {
-                    const entry = row as ProviderCacheHitRateEntry & {
-                      modelStats?: ModelCacheHitStat[];
-                    };
-                    if (!entry.modelStats || entry.modelStats.length === 0) return null;
-                    return (
-                      <div className="px-8 py-3">
-                        <div className="text-xs text-muted-foreground mb-2 font-medium">
-                          {t("expandModelStats")}
-                        </div>
-                        <table className="w-full text-sm">
-                          <thead>
-                            <tr className="text-xs text-muted-foreground border-b">
-                              <th className="text-left py-1 pr-4">{t("columns.model")}</th>
-                              <th className="text-right py-1 pr-4">{t("columns.requests")}</th>
-                              <th className="text-right py-1 pr-4">
-                                {t("columns.cacheReadTokens")}
-                              </th>
-                              <th className="text-right py-1 pr-4">{t("columns.totalTokens")}</th>
-                              <th className="text-right py-1">{t("columns.cacheHitRate")}</th>
-                            </tr>
-                          </thead>
-                          <tbody>
-                            {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 (
-                                <tr key={ms.model} className="border-b last:border-b-0">
-                                  <td className="py-1 pr-4 font-mono">{ms.model}</td>
-                                  <td className="text-right py-1 pr-4">
-                                    {ms.totalRequests.toLocaleString()}
-                                  </td>
-                                  <td className="text-right py-1 pr-4">
-                                    {formatTokenAmount(ms.cacheReadTokens)}
-                                  </td>
-                                  <td className="text-right py-1 pr-4">
-                                    {formatTokenAmount(ms.totalInputTokens)}
-                                  </td>
-                                  <td className={`text-right py-1 ${colorClass}`}>
-                                    {rate.toFixed(1)}%
-                                  </td>
-                                </tr>
-                              );
-                            })}
-                          </tbody>
-                        </table>
-                      </div>
-                    );
-                  }
-                : undefined
-            }
-          />
+          renderTable()
         )}
       </div>
     </div>

+ 41 - 2
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<string, unknown>;
+
+              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 !== undefined ? { 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,

+ 41 - 13
src/lib/redis/leaderboard-cache.ts

@@ -46,6 +46,8 @@ export interface LeaderboardFilters {
   providerType?: ProviderType;
   userTags?: string[];
   userGroups?: string[];
+  /** 仅 scope=provider 生效:是否包含按模型拆分的数据(ProviderLeaderboardEntry.modelStats) */
+  includeModelStats?: boolean;
 }
 
 /**
@@ -62,6 +64,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 +80,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 +119,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 +148,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") {
@@ -298,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<void> {
   const redis = getRedisClient();
   if (!redis) {
@@ -311,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);

+ 188 - 51
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);
+
 /**
  * 排行榜条目类型
  */
@@ -43,6 +45,27 @@ 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
+  /**
+   * 可选:按模型拆分
+   * - undefined: 未请求 includeModelStats
+   * - []: 已请求 includeModelStats,但该 provider 下无可用模型统计
+   */
+  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;
 }
 
 /**
@@ -284,43 +307,75 @@ export async function findCustomRangeLeaderboard(
 /**
  * 查询今日供应商消耗排行榜(不限制数量)
  * 使用 SQL AT TIME ZONE 进行时区转换,确保"今日"基于系统时区
+ * includeModelStats=true 时会额外返回按模型拆分的统计数据(modelStats)
  */
 export async function findDailyProviderLeaderboard(
-  providerType?: ProviderType
+  providerType?: ProviderType,
+  includeModelStats?: boolean
 ): Promise<ProviderLeaderboardEntry[]> {
   const timezone = await resolveSystemTimezone();
-  return findProviderLeaderboardWithTimezone("daily", timezone, undefined, providerType);
+  return findProviderLeaderboardWithTimezone(
+    "daily",
+    timezone,
+    undefined,
+    providerType,
+    includeModelStats
+  );
 }
 
 /**
  * 查询本月供应商消耗排行榜(不限制数量)
  * 使用 SQL AT TIME ZONE 进行时区转换,确保"本月"基于系统时区
+ * includeModelStats=true 时会额外返回按模型拆分的统计数据(modelStats)
  */
 export async function findMonthlyProviderLeaderboard(
-  providerType?: ProviderType
+  providerType?: ProviderType,
+  includeModelStats?: boolean
 ): Promise<ProviderLeaderboardEntry[]> {
   const timezone = await resolveSystemTimezone();
-  return findProviderLeaderboardWithTimezone("monthly", timezone, undefined, providerType);
+  return findProviderLeaderboardWithTimezone(
+    "monthly",
+    timezone,
+    undefined,
+    providerType,
+    includeModelStats
+  );
 }
 
 /**
  * 查询本周供应商消耗排行榜(不限制数量)
+ * includeModelStats=true 时会额外返回按模型拆分的统计数据(modelStats)
  */
 export async function findWeeklyProviderLeaderboard(
-  providerType?: ProviderType
+  providerType?: ProviderType,
+  includeModelStats?: boolean
 ): Promise<ProviderLeaderboardEntry[]> {
   const timezone = await resolveSystemTimezone();
-  return findProviderLeaderboardWithTimezone("weekly", timezone, undefined, providerType);
+  return findProviderLeaderboardWithTimezone(
+    "weekly",
+    timezone,
+    undefined,
+    providerType,
+    includeModelStats
+  );
 }
 
 /**
  * 查询全部时间供应商消耗排行榜(不限制数量)
+ * includeModelStats=true 时会额外返回按模型拆分的统计数据(modelStats)
  */
 export async function findAllTimeProviderLeaderboard(
-  providerType?: ProviderType
+  providerType?: ProviderType,
+  includeModelStats?: boolean
 ): Promise<ProviderLeaderboardEntry[]> {
   const timezone = await resolveSystemTimezone();
-  return findProviderLeaderboardWithTimezone("allTime", timezone, undefined, providerType);
+  return findProviderLeaderboardWithTimezone(
+    "allTime",
+    timezone,
+    undefined,
+    providerType,
+    includeModelStats
+  );
 }
 
 /**
@@ -385,12 +440,14 @@ export async function findAllTimeProviderCacheHitRateLeaderboard(
 
 /**
  * 通用供应商排行榜查询函数(使用 SQL AT TIME ZONE 确保时区正确)
+ * includeModelStats=true 时会额外返回按模型拆分的统计数据(modelStats)
  */
 async function findProviderLeaderboardWithTimezone(
   period: LeaderboardPeriod,
   timezone: string,
   dateRange?: DateRangeParams,
-  providerType?: ProviderType
+  providerType?: ProviderType,
+  includeModelStats?: boolean
 ): Promise<ProviderLeaderboardEntry[]> {
   const whereConditions = [
     LEDGER_BILLING_CONDITION,
@@ -398,41 +455,53 @@ async function findProviderLeaderboardWithTimezone(
     providerType ? eq(providers.providerType, providerType) : undefined,
   ];
 
+  const totalRequestsExpr = sql<number>`count(*)::double precision`;
+  const totalCostExpr = sql<string>`COALESCE(sum(${usageLedger.costUsd}), 0)`;
+  const totalTokensExpr = sql<number>`COALESCE(
+    sum(
+      ${usageLedger.inputTokens} +
+      ${usageLedger.outputTokens} +
+      COALESCE(${usageLedger.cacheCreationInputTokens}, 0) +
+      COALESCE(${usageLedger.cacheReadInputTokens}, 0)
+    )::double precision,
+    0::double precision
+  )`;
+  const successRateExpr = sql<number>`COALESCE(
+    count(CASE WHEN ${usageLedger.isSuccess} THEN 1 END)::double precision
+    / NULLIF(count(*)::double precision, 0),
+    0::double precision
+  )`;
+  const avgTtfbMsExpr = sql<number>`COALESCE(avg(${usageLedger.ttfbMs})::double precision, 0::double precision)`;
+  const avgTokensPerSecondExpr = sql<number>`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<number>`count(*)::double precision`,
-      totalCost: sql<string>`COALESCE(sum(${usageLedger.costUsd}), 0)`,
-      totalTokens: sql<number>`COALESCE(
-        sum(
-          ${usageLedger.inputTokens} +
-          ${usageLedger.outputTokens} +
-          COALESCE(${usageLedger.cacheCreationInputTokens}, 0) +
-          COALESCE(${usageLedger.cacheReadInputTokens}, 0)
-        )::double precision,
-        0::double precision
-      )`,
-      successRate: sql<number>`COALESCE(
-        count(CASE WHEN ${usageLedger.isSuccess} THEN 1 END)::double precision
-        / NULLIF(count(*)::double precision, 0),
-        0::double precision
-      )`,
-      avgTtfbMs: sql<number>`COALESCE(avg(${usageLedger.ttfbMs})::double precision, 0::double precision)`,
-      avgTokensPerSecond: sql<number>`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(
@@ -445,23 +514,82 @@ 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;
+    const avgCosts = computeAvgCosts(totalCost, totalRequests, totalTokens);
     return {
       providerId: entry.providerId,
       providerName: entry.providerName,
       totalRequests,
       totalCost,
       totalTokens,
-      successRate: entry.successRate ?? 0,
+      successRate: clampRatio01(entry.successRate),
       avgTtfbMs: entry.avgTtfbMs ?? 0,
       avgTokensPerSecond: entry.avgTokensPerSecond ?? 0,
-      avgCostPerRequest: totalRequests > 0 ? totalCost / totalRequests : null,
-      avgCostPerMillionTokens: totalTokens > 0 ? (totalCost * 1_000_000) / totalTokens : null,
+      ...avgCosts,
     };
   });
+
+  if (!includeModelStats) return baseEntries;
+
+  // Model breakdown per provider
+  const systemSettings = await getSystemSettings();
+  const billingModelSource = systemSettings.billingModelSource;
+  const rawModelField =
+    billingModelSource === "original"
+      ? sql<string>`COALESCE(${usageLedger.originalModel}, ${usageLedger.model})`
+      : sql<string>`COALESCE(${usageLedger.model}, ${usageLedger.originalModel})`;
+  const modelField = sql<string>`NULLIF(TRIM(${rawModelField}), '')`;
+
+  const modelRows = await db
+    .select({
+      providerId: usageLedger.finalProviderId,
+      model: modelField,
+      totalRequests: totalRequestsExpr,
+      totalCost: totalCostExpr,
+      totalTokens: totalTokensExpr,
+      successRate: successRateExpr,
+      avgTtfbMs: avgTtfbMsExpr,
+      avgTokensPerSecond: avgTokensPerSecondExpr,
+    })
+    .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<number, ModelProviderStat[]>();
+  for (const row of modelRows) {
+    if (!row.model) 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,
+      totalRequests,
+      totalCost,
+      totalTokens,
+      successRate: clampRatio01(row.successRate),
+      avgTtfbMs: row.avgTtfbMs ?? 0,
+      avgTokensPerSecond: row.avgTokensPerSecond ?? 0,
+      ...avgCosts,
+    });
+    modelStatsByProvider.set(row.providerId, stats);
+  }
+
+  return baseEntries.map((entry) => ({
+    ...entry,
+    modelStats: modelStatsByProvider.get(entry.providerId) ?? [],
+  }));
 }
 
 /**
@@ -529,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<string>`COALESCE(${usageLedger.originalModel}, ${usageLedger.model})`
       : sql<string>`COALESCE(${usageLedger.model}, ${usageLedger.originalModel})`;
+  const modelField = sql<string>`NULLIF(TRIM(${rawModelField}), '')`;
 
   const modelTotalInput = sql<number>`COALESCE(sum(${totalInputTokensExpr})::double precision, 0::double precision)`;
   const modelCacheRead = sql<number>`COALESCE(sum(COALESCE(${usageLedger.cacheReadInputTokens}, 0))::double precision, 0::double precision)`;
@@ -564,14 +693,14 @@ async function findProviderCacheHitRateLeaderboardWithTimezone(
   // Group model stats by providerId
   const modelStatsByProvider = new Map<number, ModelCacheHitStat[]>();
   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,
       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);
   }
@@ -585,20 +714,28 @@ 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) ?? [],
   }));
 }
 
 /**
  * 查询自定义日期范围供应商消耗排行榜
+ * includeModelStats=true 时会额外返回按模型拆分的统计数据(modelStats)
  */
 export async function findCustomRangeProviderLeaderboard(
   dateRange: DateRangeParams,
-  providerType?: ProviderType
+  providerType?: ProviderType,
+  includeModelStats?: boolean
 ): Promise<ProviderLeaderboardEntry[]> {
   const timezone = await resolveSystemTimezone();
-  return findProviderLeaderboardWithTimezone("custom", timezone, dateRange, providerType);
+  return findProviderLeaderboardWithTimezone(
+    "custom",
+    timezone,
+    dateRange,
+    providerType,
+    includeModelStats
+  );
 }
 
 /**
@@ -704,7 +841,7 @@ async function findModelLeaderboardWithTimezone(
       totalRequests: entry.totalRequests,
       totalCost: parseFloat(entry.totalCost),
       totalTokens: entry.totalTokens,
-      successRate: entry.successRate ?? 0,
+      successRate: clampRatio01(entry.successRate),
     }));
 }
 

+ 89 - 0
tests/unit/api/leaderboard-route.test.ts

@@ -197,5 +197,94 @@ 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");
+    });
+
+    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);
+    });
   });
 });

+ 121 - 0
tests/unit/dashboard/leaderboard-table-expandable-rows.test.tsx

@@ -0,0 +1,121 @@
+/**
+ * @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<typeof createRoot> | 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<Row>[] = [
+      {
+        header: "name",
+        cell: (row) => ("providerName" in row ? row.providerName : row.model),
+      },
+      {
+        header: "requests",
+        className: "text-right",
+        cell: (row) => String(row.totalRequests),
+      },
+    ];
+
+    const { container } = renderSimple(
+      <LeaderboardTable<ParentRow, ChildRow>
+        data={data}
+        period="daily"
+        columns={columns}
+        getRowKey={(row) => row.providerId}
+        getSubRows={(row) => row.modelStats}
+        getSubRowKey={(subRow) => subRow.model}
+      />
+    );
+
+    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 expandButton = container.querySelector(
+      'button[aria-label="expandModelStats"]'
+    ) as HTMLButtonElement | null;
+    expect(expandButton).toBeTruthy();
+    expect(expandButton!.getAttribute("aria-expanded")).toBe("false");
+
+    act(() => {
+      expandButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+    });
+    expect(expandButton!.getAttribute("aria-expanded")).toBe("true");
+
+    const modelCell = findCellByText("model-x");
+    expect(modelCell).toBeTruthy();
+
+    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();
+  });
+});

+ 99 - 0
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();