feat: show current-cycle token totals

This commit is contained in:
2026-08-14 11:07:46 +08:00
parent 1ec9001387
commit bf37999833
12 changed files with 254 additions and 19 deletions
+27 -3
View File
@@ -687,6 +687,11 @@ function AccountQuickSummary({
<span>
{remaining == null ? "限额暂无" : `最低 ${Math.round(remaining)}% 剩余`}
</span>
<span>
{dashboard.currentCycle == null
? "本周期 Token 暂无"
: `本周期 ${num(dashboard.currentCycle.totalTokens)} Tokens`}
</span>
<span>{reset ? `最近重置 ${relative(reset)}` : "暂无重置时间"}</span>
</span>
);
@@ -776,11 +781,22 @@ function AccountDashboardDetails({
该连接暂无限额数据,请确认登录后刷新。
</div>
)}
{dashboard.currentCycle && (
<div className="cycle-note">
<Clock />
<span>
本周期:{cycleTimestamp(dashboard.currentCycle.startedAt)} 至{" "}
{cycleTimestamp(dashboard.currentCycle.resetsAt)}(
{cycleDuration(dashboard.currentCycle.windowDurationMinutes)}
,按每日数据汇总)
</span>
</div>
)}
<div className="stats">
<Stat
icon={<Coins />}
label="累计 Tokens"
v={num(dashboard.summary.lifetimeTokens)}
label="本周期 Tokens"
v={num(dashboard.currentCycle?.totalTokens)}
/>
<Stat
icon={<TrendingUp />}
@@ -1664,7 +1680,7 @@ function Header({
const num = (v?: number | null) =>
v == null
? "暂无"
: new Intl.NumberFormat("zh-CN", {
: new Intl.NumberFormat("en", {
notation: "compact",
maximumFractionDigits: 1,
}).format(v);
@@ -1676,6 +1692,14 @@ const duration = (v?: number | null) =>
: v < 3600
? `${Math.floor(v / 60)} 分 ${v % 60} 秒`
: `${(v / 3600).toFixed(1)} 小时`;
const cycleTimestamp = (value: number) =>
new Date(value * 1000).toLocaleString();
const cycleDuration = (minutes: number) =>
minutes % (24 * 60) === 0
? `${minutes / (24 * 60)} 天窗口`
: minutes % 60 === 0
? `${minutes / 60} 小时窗口`
: `${minutes} 分钟窗口`;
const remainingPercent = (limit: Limit) =>
100 - Math.min(100, Math.max(0, limit.usedPercent));
const limitLabel = (limit: Limit) => {
+18
View File
@@ -432,6 +432,24 @@ header p {
border-radius: 10px;
background: color-mix(in srgb, var(--danger) 8%, var(--panel));
}
.cycle-note {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 12px 14px;
border: 1px solid color-mix(in srgb, var(--accent) 28%, var(--border));
border-radius: 10px;
background: color-mix(in srgb, var(--accent) 7%, var(--panel));
color: var(--muted);
font-size: 13px;
line-height: 1.5;
}
.cycle-note svg {
flex: none;
width: 16px;
margin-top: 2px;
color: var(--accent);
}
.account-info-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
+23
View File
@@ -24,6 +24,29 @@ describe("API decoders", () => {
expect(value.account.email).toBeNull();
});
it("解码当前重置周期 Token 合计", () => {
const value = decodeDashboard({
accountId: 1,
displayName: "个人账号",
account: { email: "user@example.com", planType: "plus", connected: true },
limits: [],
currentCycle: {
limitId: "codex",
windowType: "secondary",
windowDurationMinutes: 10080,
startedAt: 1_900_000_000,
resetsAt: 1_900_604_800,
totalTokens: 123_456,
},
summary: {},
usage: [],
fetchedAt: 1_900_000_000,
stale: false,
});
expect(value.currentCycle?.windowType).toBe("secondary");
expect(value.currentCycle?.totalTokens).toBe(123_456);
});
it("拒绝未知主题", () => {
expect(() =>
decodeGeneral({
+26
View File
@@ -48,6 +48,14 @@ export interface Point {
date: string;
totalTokens: number;
}
export interface TokenCycle {
limitId: string;
windowType: string;
windowDurationMinutes: number;
startedAt: number;
resetsAt: number;
totalTokens: number;
}
export interface Dashboard {
accountId: number;
displayName: string;
@@ -57,6 +65,7 @@ export interface Dashboard {
connected: boolean;
};
limits: Limit[];
currentCycle?: TokenCycle;
summary: {
lifetimeTokens?: number | null;
peakDailyTokens?: number | null;
@@ -153,6 +162,20 @@ export const decodeAccounts: Decoder<Account[]> = (value) => {
if (!Array.isArray(value)) throw new Error("账号列表格式无效");
return value.map(decodeAccount);
};
const decodeTokenCycle: Decoder<TokenCycle> = (value) => {
const x = record(value, "currentCycle");
return {
limitId: string(x.limitId, "limitId"),
windowType: string(x.windowType, "windowType"),
windowDurationMinutes: number(
x.windowDurationMinutes,
"windowDurationMinutes",
),
startedAt: number(x.startedAt, "startedAt"),
resetsAt: number(x.resetsAt, "resetsAt"),
totalTokens: number(x.totalTokens, "totalTokens"),
};
};
export const decodeDashboard: Decoder<Dashboard> = (value) => {
const x = record(value, "Dashboard"),
account = record(x.account, "account"),
@@ -181,6 +204,9 @@ export const decodeDashboard: Decoder<Dashboard> = (value) => {
resetsAt: number(l.resetsAt, "resetsAt"),
};
}),
...(x.currentCycle == null
? {}
: { currentCycle: decodeTokenCycle(x.currentCycle) }),
summary: {
lifetimeTokens: optionalNumber(summary.lifetimeTokens, "lifetimeTokens"),
peakDailyTokens: optionalNumber(
+12
View File
@@ -91,6 +91,14 @@ test("shows every account as a collapsible summary on the overview", async ({
resetsAt: 1_900_000_000,
},
],
currentCycle: {
limitId: "codex",
windowType: "secondary",
windowDurationMinutes: 10_080,
startedAt: 1_899_999_000,
resetsAt: 1_900_604_800,
totalTokens: 123_456,
},
summary: {
lifetimeTokens: 12_345,
peakDailyTokens: 3_000,
@@ -151,6 +159,7 @@ test("shows every account as a collapsible summary on the overview", async ({
await expect(page.locator(".account-overview")).toHaveCount(2);
await expect.poll(() => requestedDashboards.size).toBe(2);
await expect(page.getByText("最低 65% 剩余")).toBeVisible();
await expect(page.getByText("本周期 123.5K Tokens")).toBeVisible();
await expect(page.getByText("限额暂无")).toBeVisible();
await expect(page.locator(".account-detail")).toHaveCount(0);
await expect(page.locator(".account-select")).toHaveCount(0);
@@ -162,6 +171,9 @@ test("shows every account as a collapsible summary on the overview", async ({
await expect(
first.getByRole("heading", { name: "每日 Token 趋势" }),
).toBeVisible();
await expect(first.getByText("本周期 Tokens")).toBeVisible();
await expect(first.getByText("123.5K", { exact: true })).toBeVisible();
await expect(first.getByText("按每日数据汇总")).toBeVisible();
await expect(page.locator(".account-detail")).toHaveCount(1);
await first.getByRole("button", { name: "收起个人账号详情" }).click();