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
13 changes: 11 additions & 2 deletions src/app/api/token-rank/leaderboard/route.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,25 @@
import { NextRequest, NextResponse } from "next/server";
import { getTokenRankLeaderboard } from "@/lib/tokenRankStore";
import { getTokenRankLeaderboard, normalizeTokenRankRange } from "@/lib/tokenRankStore";

export const runtime = "nodejs";

export async function GET(request: NextRequest) {
const params = request.nextUrl.searchParams;
const metric = params.get("metric");
const requestedRange = params.get("range") ?? "today";
const range = normalizeTokenRankRange(requestedRange);

if (!range) {
return NextResponse.json(
{ status: 400, message: `不支持的榜单时间范围:${requestedRange}` },
{ status: 400 },
);
}

return NextResponse.json(
await getTokenRankLeaderboard({
board: params.get("board") ?? "total",
range: params.get("range") ?? "today",
range,
metric: metric === "norm" || metric === "cost" ? metric : "total",
}),
);
Expand Down
61 changes: 52 additions & 9 deletions src/app/token-rank/TokenRankClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,10 @@ function metricScore(entry: RankedEntry, metric: MetricKey) {
return entry.displayScore;
}

function leaderboardRequestKey(board: string, range: string, metric: MetricKey) {
return `${board}:${range}:${metric}`;
}

function buildRankedEntries(
entries: TokenRankEntry[],
board: string,
Expand Down Expand Up @@ -299,6 +303,7 @@ function LeaderboardView({
setActiveMetric,
setActiveTab,
isRefreshing,
selectionState,
}: {
data: TokenRankData;
activeBoard: string;
Expand All @@ -309,6 +314,7 @@ function LeaderboardView({
setActiveMetric: (value: MetricKey) => void;
setActiveTab: (value: TabKey) => void;
isRefreshing: boolean;
selectionState: "ready" | "loading" | "error";
}) {
const rankedEntries = useMemo(
() => buildRankedEntries(data.entries, activeBoard, activeMetric),
Expand All @@ -323,6 +329,7 @@ function LeaderboardView({
const myRank = rankedEntries.find((entry) => entry.userId === data.mySummary.userId);
const rangeLabel = data.ranges.find((range) => range.key === activeRange)?.label ?? "今天";
const boardLabel = data.boards.find((board) => board.key === activeBoard)?.label ?? "总榜";
const selectionReady = selectionState === "ready";

return (
<div className="space-y-5">
Expand Down Expand Up @@ -360,11 +367,21 @@ function LeaderboardView({
<div className="mt-5 grid grid-cols-2 gap-3">
<StatTile label="参与人数" value={`${data.totalMembers}`} tone="text-accent" />
<StatTile label="上榜工具" value={`${data.boards.length - 1}`} tone="text-success" />
<StatTile label="总消耗" value={formatTokens(totalTokens)} tone="text-purple" />
<StatTile label="预估费用" value={formatUsd(totalCost)} tone="text-pink" />
<StatTile
label="总消耗"
value={selectionReady ? formatTokens(totalTokens) : "--"}
tone="text-purple"
/>
<StatTile
label="预估费用"
value={selectionReady ? formatUsd(totalCost) : "--"}
tone="text-pink"
/>
</div>
<p className="mt-4 text-xs leading-6 text-foreground-muted">
最近同步 {data.updatedAt} · 每 {data.syncIntervalMinutes} 分钟更新一次{isRefreshing ? " · 正在刷新" : ""}。
{selectionReady ? `最近同步 ${data.updatedAt}` : `正在加载${rangeLabel}${boardLabel}`}
{selectionReady ? ` · 每 ${data.syncIntervalMinutes} 分钟更新一次` : ""}
{isRefreshing ? " · 正在刷新" : ""}。
</p>
</aside>
</section>
Expand Down Expand Up @@ -414,7 +431,7 @@ function LeaderboardView({
</div>
</section>

{myRank ? (
{selectionReady && myRank ? (
<section className="rounded-[16px] border border-accent/20 bg-accent/[0.07] p-4">
<div className="flex flex-wrap items-center gap-3">
<span className="text-sm font-bold text-foreground">我的{rangeLabel}{boardLabel}排名</span>
Expand All @@ -435,16 +452,27 @@ function LeaderboardView({
{rangeLabel}{boardLabel} Top 20
</h2>
<p className="text-sm text-foreground-muted">
全员累计 {activeMetric === "cost"
{selectionReady ? `全员累计 ${activeMetric === "cost"
? `≈${formatUsd(totalCost)}`
: `${formatTokens(activeMetric === "norm" ? totalNorm : totalTokens)} tokens`}
: `${formatTokens(activeMetric === "norm" ? totalNorm : totalTokens)} tokens`}` : "正在加载真实数据"}
</p>
<p className="w-full text-sm text-foreground-muted">
点击昵称、头像或“查看主页”可查看完整统计。
</p>
</div>

{rankedEntries.length === 0 ? (
{!selectionReady ? (
<div className="glass-card p-8 text-center">
<h3 className="text-xl font-bold text-foreground">
{selectionState === "error" ? "榜单加载失败" : "正在加载榜单"}
</h3>
<p className="mx-auto mt-3 max-w-2xl text-sm leading-7 text-foreground-muted">
{selectionState === "error"
? "没有显示其他日期的旧数据。请稍后重试。"
: `正在获取${rangeLabel}${boardLabel}的真实统计。`}
</p>
</div>
) : rankedEntries.length === 0 ? (
<div className="glass-card p-8 text-center">
<h3 className="text-xl font-bold text-foreground">暂无真实上榜数据</h3>
<p className="mx-auto mt-3 max-w-2xl text-sm leading-7 text-foreground-muted">
Expand Down Expand Up @@ -1042,12 +1070,21 @@ export function TokenRankClient({ data }: { data: TokenRankData }) {
const [activeMetric, setActiveMetric] = useState<MetricKey>("total");
const [leaderboardData, setLeaderboardData] = useState(data);
const [isRefreshing, setIsRefreshing] = useState(false);
const [loadedRequestKey, setLoadedRequestKey] = useState(
leaderboardRequestKey("total", "today", "total"),
);
const [failedRequestKey, setFailedRequestKey] = useState<string | null>(null);
const requestKey = leaderboardRequestKey(activeBoard, activeRange, activeMetric);
const selectionState = loadedRequestKey === requestKey
? "ready"
: failedRequestKey === requestKey ? "error" : "loading";

useEffect(() => {
const controller = new AbortController();

async function refreshLeaderboard() {
setIsRefreshing(true);
setFailedRequestKey(null);
try {
const params = new URLSearchParams({
board: activeBoard,
Expand All @@ -1065,7 +1102,10 @@ export function TokenRankClient({ data }: { data: TokenRankData }) {
aggregate?: TokenRankData["aggregate"];
};

if (!response.ok || body.status !== 0 || !Array.isArray(body.entries)) return;
if (!response.ok || body.status !== 0 || !Array.isArray(body.entries)) {
setFailedRequestKey(requestKey);
return;
}

setLeaderboardData((current) => ({
...current,
Expand All @@ -1074,8 +1114,10 @@ export function TokenRankClient({ data }: { data: TokenRankData }) {
updatedAt: body.updatedAt ?? current.updatedAt,
aggregate: body.aggregate ?? current.aggregate,
}));
setLoadedRequestKey(requestKey);
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") return;
setFailedRequestKey(requestKey);
} finally {
if (!controller.signal.aborted) setIsRefreshing(false);
}
Expand All @@ -1088,7 +1130,7 @@ export function TokenRankClient({ data }: { data: TokenRankData }) {
controller.abort();
window.clearInterval(timer);
};
}, [activeBoard, activeMetric, activeRange, data.syncIntervalMinutes]);
}, [activeBoard, activeMetric, activeRange, data.syncIntervalMinutes, requestKey]);

return (
<section className="space-y-5">
Expand Down Expand Up @@ -1120,6 +1162,7 @@ export function TokenRankClient({ data }: { data: TokenRankData }) {
setActiveMetric={setActiveMetric}
setActiveTab={setActiveTab}
isRefreshing={isRefreshing}
selectionState={selectionState}
/>
) : null}
{activeTab === "connect" ? <ConnectView data={data} /> : null}
Expand Down
21 changes: 19 additions & 2 deletions src/lib/tokenRankStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -340,7 +340,24 @@ function addDays(date: string, offset: number) {
return new Date(time).toISOString().slice(0, 10);
}

function dateRange(range: string) {
export type TokenRankRangeKey = "today" | "yesterday" | "day-before" | "3d" | "7d" | "30d";

export function normalizeTokenRankRange(value: string): TokenRankRangeKey | null {
if (value === "day-before-yesterday") return "day-before";
if (
value === "today" ||
value === "yesterday" ||
value === "day-before" ||
value === "3d" ||
value === "7d" ||
value === "30d"
) {
return value;
}
return null;
}

function dateRange(range: TokenRankRangeKey) {
const today = beijingDate();
if (range === "yesterday") return { start: addDays(today, -1), end: addDays(today, -1) };
if (range === "day-before") return { start: addDays(today, -2), end: addDays(today, -2) };
Expand Down Expand Up @@ -1048,7 +1065,7 @@ export async function getTokenRankLeaderboard(params: {
}) {
const store = await readStore();
const board = params.board || "total";
const range = params.range || "today";
const range = normalizeTokenRankRange(params.range || "today") ?? "today";
const metric = params.metric || "total";
const { start, end } = dateRange(range);
const records = store.records.filter((record) => record.date >= start && record.date <= end);
Expand Down
64 changes: 64 additions & 0 deletions tests/token-rank-store.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -773,6 +773,70 @@ test("leaderboard aggregate covers every member and keeps total separate from no
assert.notEqual(board.aggregate.total, board.aggregate.norm);
});

test("leaderboard range aliases resolve to the correct Beijing calendar day", async (t) => {
const { store } = await setupStore(t);
const today = beijingDate();
const yesterday = addDays(today, -1);
const dayBefore = addDays(today, -2);
const { token, user } = await store.createTokenRankUser({ name: "range-alias" });

await store.appendTokenRankUsage(token, {
deviceId: DEVICE_A,
clientVersion: "0.1.0",
records: [
{
date: today,
tool: "claude-code",
model: "today-model",
inputTokens: 10,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
totalTokens: 10,
},
{
date: yesterday,
tool: "claude-code",
model: "yesterday-model",
inputTokens: 20,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
totalTokens: 20,
},
{
date: dayBefore,
tool: "claude-code",
model: "day-before-model",
inputTokens: 30,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
totalTokens: 30,
},
],
});

const todayBoard = await store.getTokenRankLeaderboard({ range: "today" });
const yesterdayBoard = await store.getTokenRankLeaderboard({ range: "yesterday" });
const canonical = await store.getTokenRankLeaderboard({ range: "day-before" });
const alias = await store.getTokenRankLeaderboard({ range: "day-before-yesterday" });
const score = (board) => board.entries.find((entry) => entry.userId === user.userId)?.score;

assert.equal(score(todayBoard), 10);
assert.equal(score(yesterdayBoard), 20);
assert.equal(score(canonical), 30);
assert.equal(score(alias), 30);
assert.equal(alias.range, "day-before");
});

test("leaderboard range normalization rejects unknown API ranges", async (t) => {
const { store } = await setupStore(t);
assert.equal(store.normalizeTokenRankRange("day-before-yesterday"), "day-before");
assert.equal(store.normalizeTokenRankRange("before-yesterday"), null);
assert.equal(store.normalizeTokenRankRange("invalid"), null);
});

test("public profiles expose active legacy identities with complete sanitized aggregates", async (t) => {
const { store, storePath } = await setupStore(t);
const { token, user } = await store.createTokenRankUser({ name: "public-profile", role: "测试角色" });
Expand Down
Loading