Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions messages/en/dashboard.json
Original file line number Diff line number Diff line change
Expand Up @@ -425,6 +425,7 @@
"users": "User Rankings",
"keys": "Key Rankings",
"userRanking": "User Rankings",
"userCacheHitRateRanking": "User Cache Hit Rate",
"providerRanking": "Provider Rankings",
"providerCacheHitRateRanking": "Provider Cache Hit Rate",
"modelRanking": "Model Rankings",
Expand Down
1 change: 1 addition & 0 deletions messages/ja/dashboard.json
Original file line number Diff line number Diff line change
Expand Up @@ -425,6 +425,7 @@
"users": "ユーザー ランキング",
"keys": "キー ランキング",
"userRanking": "ユーザーランキング",
"userCacheHitRateRanking": "ユーザーキャッシュ命中率",
"providerRanking": "プロバイダーランキング",
"providerCacheHitRateRanking": "プロバイダーキャッシュ命中率",
"modelRanking": "モデルランキング",
Expand Down
1 change: 1 addition & 0 deletions messages/ru/dashboard.json
Original file line number Diff line number Diff line change
Expand Up @@ -425,6 +425,7 @@
"users": "Рейтинг пользователей",
"keys": "Рейтинг ключей",
"userRanking": "Рейтинг пользователей",
"userCacheHitRateRanking": "Рейтинг пользователей по попаданиям в кэш",
"providerRanking": "Рейтинг поставщиков",
"providerCacheHitRateRanking": "Рейтинг по попаданиям в кэш",
"modelRanking": "Рейтинг моделей",
Expand Down
1 change: 1 addition & 0 deletions messages/zh-CN/dashboard.json
Original file line number Diff line number Diff line change
Expand Up @@ -425,6 +425,7 @@
"users": "用户排行",
"keys": "密钥排行",
"userRanking": "用户排行",
"userCacheHitRateRanking": "用户缓存命中率排行",
"providerRanking": "供应商排行",
"providerCacheHitRateRanking": "供应商缓存命中率排行",
"modelRanking": "模型排行",
Expand Down
1 change: 1 addition & 0 deletions messages/zh-TW/dashboard.json
Original file line number Diff line number Diff line change
Expand Up @@ -425,6 +425,7 @@
"users": "使用者排名",
"keys": "密鑰排名",
"userRanking": "使用者排名",
"userCacheHitRateRanking": "使用者快取命中率排行",
"providerRanking": "供應商排名",
"providerCacheHitRateRanking": "供應商快取命中率排行",
"modelRanking": "模型排名",
Expand Down
142 changes: 128 additions & 14 deletions src/app/[locale]/dashboard/leaderboard/_components/leaderboard-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import type {
ModelProviderStat,
ProviderCacheHitRateLeaderboardEntry,
ProviderLeaderboardEntry,
UserCacheHitModelStat,
UserCacheHitRateLeaderboardEntry,
UserModelStat,
} from "@/repository/leaderboard";
import type { ProviderType } from "@/types/provider";
Expand All @@ -30,7 +32,7 @@ interface LeaderboardViewProps {
isAdmin: boolean;
}

type LeaderboardScope = "user" | "provider" | "providerCacheHitRate" | "model";
type LeaderboardScope = "user" | "userCacheHitRate" | "provider" | "providerCacheHitRate" | "model";
type TotalCostFormattedFields = { totalCostFormatted?: string };
type ProviderCostFormattedFields = {
// API 额外返回的展示用字段(格式化后的字符串)
Expand All @@ -51,9 +53,20 @@ type ProviderEntry = Omit<ProviderLeaderboardEntry, "modelStats"> &
modelStats?: ModelProviderStatClient[];
};
type ProviderTableRow = ProviderEntry | ModelProviderStatClient;
type UserCacheHitModelStatClient = UserCacheHitModelStat;
type UserCacheHitRateEntry = Omit<UserCacheHitRateLeaderboardEntry, "modelStats"> &
TotalCostFormattedFields & {
modelStats?: UserCacheHitModelStatClient[];
};
type UserCacheHitRateTableRow = UserCacheHitRateEntry | UserCacheHitModelStatClient;
type ProviderCacheHitRateEntry = ProviderCacheHitRateLeaderboardEntry;
type ProviderCacheHitRateTableRow = ProviderCacheHitRateEntry | ModelCacheHitStat;
type AnyEntry = UserEntry | ProviderEntry | ProviderCacheHitRateEntry | ModelEntry;
type AnyEntry =
| UserEntry
| UserCacheHitRateEntry
| ProviderEntry
| ProviderCacheHitRateEntry
| ModelEntry;

const VALID_PERIODS: LeaderboardPeriod[] = ["daily", "weekly", "monthly", "allTime", "custom"];

Expand All @@ -63,7 +76,10 @@ export function LeaderboardView({ isAdmin }: LeaderboardViewProps) {

const urlScope = searchParams.get("scope") as LeaderboardScope | null;
const initialScope: LeaderboardScope =
(urlScope === "provider" || urlScope === "providerCacheHitRate" || urlScope === "model") &&
(urlScope === "provider" ||
urlScope === "providerCacheHitRate" ||
urlScope === "userCacheHitRate" ||
urlScope === "model") &&
isAdmin
? urlScope
: "user";
Expand Down Expand Up @@ -104,6 +120,7 @@ export function LeaderboardView({ isAdmin }: LeaderboardViewProps) {
const urlScopeParam = searchParams.get("scope") as LeaderboardScope | null;
const normalizedScope: LeaderboardScope =
(urlScopeParam === "provider" ||
urlScopeParam === "userCacheHitRate" ||
urlScopeParam === "providerCacheHitRate" ||
urlScopeParam === "model") &&
isAdmin
Expand Down Expand Up @@ -142,10 +159,10 @@ export function LeaderboardView({ isAdmin }: LeaderboardViewProps) {
if (scope === "provider") {
url += "&includeModelStats=1";
}
if (scope === "user" && isAdmin) {
if ((scope === "user" || scope === "userCacheHitRate") && isAdmin) {
url += "&includeUserModelStats=1";
}
if (scope === "user") {
if (scope === "user" || scope === "userCacheHitRate") {
if (userTagFilters.length > 0) {
url += `&userTags=${encodeURIComponent(userTagFilters.join(","))}`;
}
Expand Down Expand Up @@ -190,13 +207,15 @@ export function LeaderboardView({ isAdmin }: LeaderboardViewProps) {
const skeletonColumns =
scope === "user"
? 5
: scope === "provider"
? 10
: scope === "providerCacheHitRate"
? 8
: scope === "model"
? 6
: 5;
: scope === "userCacheHitRate"
? 6
: scope === "provider"
? 10
: scope === "providerCacheHitRate"
? 8
: scope === "model"
? 6
: 5;
const skeletonGridStyle = { gridTemplateColumns: `repeat(${skeletonColumns}, minmax(0, 1fr))` };

// 列定义(根据 scope 动态切换)
Expand Down Expand Up @@ -382,6 +401,80 @@ export function LeaderboardView({ isAdmin }: LeaderboardViewProps) {
},
];

const userCacheHitRateColumns: ColumnDef<UserCacheHitRateTableRow>[] = [
{
header: t("columns.user"),
cell: (row) => {
if ("userName" in row) {
return isAdmin ? (
<Link
href={`/dashboard/leaderboard/user/${row.userId}`}
className="hover:text-muted-foreground transition-colors"
data-testid={`leaderboard-user-cache-link-${row.userId}`}
>
{row.userName}
</Link>
) : (
row.userName
);
}
return renderSubModelLabel(row.model ?? t("columns.unknownModel"));
},
sortKey: "userName",
getValue: (row) => ("userName" in row ? row.userName : (row.model ?? "")),
},
{
header: t("columns.cacheHitRequests"),
className: "text-right",
cell: (row) => row.totalRequests.toLocaleString(),
sortKey: "totalRequests",
getValue: (row) => row.totalRequests,
},
{
header: t("columns.cacheHitRate"),
className: "text-right",
cell: (row) => {
const rate = Number(row.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 <span className={colorClass}>{rate.toFixed(1)}%</span>;
},
sortKey: "cacheHitRate",
getValue: (row) => row.cacheHitRate,
},
{
header: t("columns.cacheReadTokens"),
className: "text-right",
cell: (row) => formatTokenAmount(row.cacheReadTokens),
sortKey: "cacheReadTokens",
getValue: (row) => row.cacheReadTokens,
},
{
header: t("columns.totalTokens"),
className: "text-right",
cell: (row) => formatTokenAmount(row.totalInputTokens),
sortKey: "totalInputTokens",
getValue: (row) => row.totalInputTokens,
},
{
header: t("columns.consumedAmount"),
className: "text-right font-mono",
cell: (row) => {
if ("userName" in row) {
return row.totalCostFormatted ?? row.totalCost;
}
return <span className="text-muted-foreground">-</span>;
},
sortKey: "totalCost",
getValue: (row) => ("userName" in row ? row.totalCost : 0),
defaultBold: true,
},
];

const modelColumns: ColumnDef<ModelEntry>[] = [
{
header: t("columns.model"),
Expand Down Expand Up @@ -457,6 +550,21 @@ export function LeaderboardView({ isAdmin }: LeaderboardViewProps) {
/>
);

const renderUserCacheHitRateTable = () => (
<LeaderboardTable<UserCacheHitRateEntry, UserCacheHitModelStat>
data={data as UserCacheHitRateEntry[]}
period={period}
columns={userCacheHitRateColumns}
getRowKey={(row) => row.userId}
{...(isAdmin
? {
getSubRows: (row) => row.modelStats,
getSubRowKey: (subRow) => subRow.model ?? "__null__",
}
: {})}
/>
);

const renderModelTable = () => (
<LeaderboardTable<ModelEntry>
data={data as ModelEntry[]}
Expand All @@ -468,6 +576,7 @@ export function LeaderboardView({ isAdmin }: LeaderboardViewProps) {

const renderTable = () => {
if (scope === "user") return renderUserTable();
if (scope === "userCacheHitRate") return renderUserCacheHitRateTable();
if (scope === "provider") return renderProviderTable();
if (scope === "providerCacheHitRate") return renderProviderCacheHitRateTable();
return renderModelTable();
Expand All @@ -478,8 +587,13 @@ export function LeaderboardView({ isAdmin }: LeaderboardViewProps) {
{/* Scope toggle */}
<div className="flex flex-wrap gap-4 items-center mb-4">
<Tabs value={scope} onValueChange={(v) => setScope(v as LeaderboardScope)}>
<TabsList className={isAdmin ? "grid grid-cols-4" : ""}>
<TabsList className={isAdmin ? "grid grid-cols-5" : ""}>
<TabsTrigger value="user">{t("tabs.userRanking")}</TabsTrigger>
{isAdmin && (
<TabsTrigger value="userCacheHitRate">
{t("tabs.userCacheHitRateRanking")}
</TabsTrigger>
)}
{isAdmin && <TabsTrigger value="provider">{t("tabs.providerRanking")}</TabsTrigger>}
{isAdmin && (
<TabsTrigger value="providerCacheHitRate">
Expand All @@ -499,7 +613,7 @@ export function LeaderboardView({ isAdmin }: LeaderboardViewProps) {
) : null}
</div>

{scope === "user" && isAdmin && (
{(scope === "user" || scope === "userCacheHitRate") && isAdmin && (
<div className="flex flex-wrap gap-4 mb-4">
<div className="flex-1 min-w-[200px] max-w-[300px]">
<TagInput
Expand Down
25 changes: 18 additions & 7 deletions src/app/api/leaderboard/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,9 @@ export const runtime = "nodejs";

/**
* 获取排行榜数据
* GET /api/leaderboard?period=daily|weekly|monthly|allTime|custom&scope=user|provider|providerCacheHitRate|model
* GET /api/leaderboard?period=daily|weekly|monthly|allTime|custom&scope=user|userCacheHitRate|provider|providerCacheHitRate|model
* 当 period=custom 时,需要提供 startDate 和 endDate 参数 (YYYY-MM-DD 格式)
* 当 scope=userCacheHitRate 时,可选 includeUserModelStats=true|1,返回用户下各模型的缓存命中率拆分数据
* 当 scope=providerCacheHitRate 时,可选 providerType=claude|claude-auth|codex|gemini|gemini-cli|openai-compatible
* 当 scope=provider 时,可选 includeModelStats=true|1,返回供应商下各模型的拆分数据
*
Expand Down Expand Up @@ -90,12 +91,16 @@ export async function GET(request: NextRequest) {

if (
scope !== "user" &&
scope !== "userCacheHitRate" &&
scope !== "provider" &&
scope !== "providerCacheHitRate" &&
scope !== "model"
) {
return NextResponse.json(
{ error: "参数 scope 必须是 'user'、'provider'、'providerCacheHitRate' 或 'model'" },
{
error:
"参数 scope 必须是 'user'、'userCacheHitRate'、'provider'、'providerCacheHitRate' 或 'model'",
},
{ status: 400 }
);
}
Expand Down Expand Up @@ -137,7 +142,7 @@ export async function GET(request: NextRequest) {
includeModelStatsParam === "yes");

const includeUserModelStats =
scope === "user" &&
(scope === "user" || scope === "userCacheHitRate") &&
(includeUserModelStatsParam === "1" ||
includeUserModelStatsParam === "true" ||
includeUserModelStatsParam === "yes");
Expand All @@ -161,7 +166,7 @@ export async function GET(request: NextRequest) {

let userTags: string[] | undefined;
let userGroups: string[] | undefined;
if (scope === "user") {
if (scope === "user" || scope === "userCacheHitRate") {
userTags = parseListParam(userTagsParam);
userGroups = parseListParam(userGroupsParam);
}
Expand Down Expand Up @@ -247,15 +252,21 @@ export async function GET(request: NextRequest) {
: undefined;

const userModelStatsFormatted =
scope === "user" && Array.isArray(typedEntry.modelStats)
(scope === "user" || scope === "userCacheHitRate") && Array.isArray(typedEntry.modelStats)
? typedEntry.modelStats.map((ms) => {
const stat = ms as {
totalCost: number;
model: string | null;
} & Record<string, unknown>;
return {
...stat,
totalCostFormatted: formatCurrency(stat.totalCost, systemSettings.currencyDisplay),
...("totalCost" in stat && typeof stat.totalCost === "number"
? {
totalCostFormatted: formatCurrency(
stat.totalCost,
systemSettings.currencyDisplay
),
}
: {}),
};
})
: undefined;
Expand Down
Loading
Loading