feat: show current-cycle token totals
This commit is contained in:
@@ -6,7 +6,7 @@
|
||||
|
||||
- 支持在同一总览页查看多个 Codex 账号或工作区(包括同一邮箱的个人订阅与 Team 工作区)
|
||||
- 展示 Codex 账户、套餐、剩余额度和下次重置时间
|
||||
- 展示累计 Token、单日峰值、连续使用天数及每日 Token 趋势
|
||||
- 展示当前重置周期 Token 合计、单日峰值、连续使用天数及每日 Token 趋势
|
||||
- 在本地 SQLite 中保留历史用量和限额快照
|
||||
- 在限额重置前、重置后发送 Telegram 或邮件提醒
|
||||
- 通过 Telegram 菜单查询当前用量、重置时间、历史概览和账户信息
|
||||
@@ -169,10 +169,10 @@ Codex Helper 展示的是 Codex app-server 实际返回的数据。当前接口
|
||||
|
||||
- ChatGPT/Codex 账户和套餐类型
|
||||
- 限额窗口使用百分比、窗口长度和重置时间
|
||||
- 累计 Token、单日峰值、连续使用天数、最长任务时长等摘要
|
||||
- 当前重置周期 Token 合计、单日峰值、连续使用天数、最长任务时长等摘要
|
||||
- 每日总 Token 桶
|
||||
|
||||
当前接口不提供调用次数、输入 Token、输出 Token、订阅价格或账单续期日,因此总览使用“最长任务时长”作为替代摘要指标。部分摘要或每日数据也可能因账户或服务端暂未返回而显示为“暂无”。根据 OpenAI 官方文档,`account/usage/read` 需要 Codex 服务支持的身份认证;仅 API Key 或 Bedrock 登录不能读取这些 ChatGPT 用量数据。
|
||||
当前总览的 Token 合计按 app-server 返回的当前有效最长限额窗口汇总每日 Token bucket,通常对应 secondary 周窗口;周期边界所在日期按整日统计。系统不根据 Token 推算 Credits、美元价值或订阅价格。部分摘要或每日数据也可能因账户或服务端暂未返回而显示为“暂无”。根据 OpenAI 官方文档,`account/usage/read` 需要 Codex 服务支持的身份认证;仅 API Key 或 Bedrock 登录不能读取这些 ChatGPT 用量数据。
|
||||
|
||||
## 常见问题
|
||||
|
||||
|
||||
+3
-1
@@ -31,13 +31,15 @@
|
||||
account:{email:string|null, authMode:string|null, planType:string|null, connected:bool},
|
||||
limits:[{limitId, limitName:string|null, windowType,
|
||||
usedPercent, windowDurationMinutes, resetsAt, planType:string|null}],
|
||||
currentCycle?:{limitId, windowType, windowDurationMinutes,
|
||||
startedAt, resetsAt, totalTokens},
|
||||
summary:{lifetimeTokens?, peakDailyTokens?, longestRunningTurnSec?,
|
||||
currentStreakDays?, longestStreakDays?, callCount?, inputTokens?, outputTokens?},
|
||||
usage:[{date,totalTokens,callCount?,inputTokens?,outputTokens?}],
|
||||
fetchedAt, stale, lastError?}
|
||||
```
|
||||
|
||||
时间字段为 Unix 秒。app-server 未提供的摘要字段可以是 `null`;列表应返回数组而非 `null`。
|
||||
时间字段为 Unix 秒。`currentCycle` 是按当前仍有效的最长限额窗口计算的当前重置周期;`totalTokens` 由 `account/usage/read` 的每日 Token bucket 汇总,受每日粒度影响,无法精确切分周期起止日期内的单日数据。没有有效窗口或用量接口失败时省略该字段。app-server 未提供的摘要字段可以是 `null`;列表应返回数组而非 `null`。
|
||||
|
||||
## 3. 系统、初始化与会话
|
||||
|
||||
|
||||
@@ -460,6 +460,7 @@ func (a *App) syncAccount(ctx context.Context, id int64) error {
|
||||
d.Usage = append(d.Usage, p)
|
||||
_, _ = a.store.DB.Exec("INSERT INTO daily_usage(account_id,date,total_tokens,fetched_at) VALUES(?,?,?,?) ON CONFLICT(account_id,date) DO UPDATE SET total_tokens=excluded.total_tokens,fetched_at=excluded.fetched_at", id, x.StartDate, x.Tokens, d.FetchedAt)
|
||||
}
|
||||
d.CurrentCycle = currentTokenCycle(d.Limits, d.Usage, d.FetchedAt)
|
||||
}
|
||||
resetDetected, e := a.storeLimitSnapshots(d)
|
||||
if e != nil {
|
||||
@@ -530,6 +531,58 @@ type rawLimit struct {
|
||||
Secondary *LimitWindow `json:"secondary"`
|
||||
}
|
||||
|
||||
func currentTokenCycle(limits []LimitBucket, usage []UsagePoint, fetchedAt int64) *TokenCycle {
|
||||
current := LimitBucket{}
|
||||
found := false
|
||||
for _, limit := range limits {
|
||||
if limit.WindowDurationMinutes <= 0 || limit.ResetsAt <= fetchedAt {
|
||||
continue
|
||||
}
|
||||
if !found || betterTokenCycleLimit(limit, current) {
|
||||
current = limit
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return nil
|
||||
}
|
||||
|
||||
startedAt := current.ResetsAt - int64(current.WindowDurationMinutes)*60
|
||||
startDate := time.Unix(startedAt, 0).UTC().Format("2006-01-02")
|
||||
endDate := time.Unix(fetchedAt, 0).UTC().Format("2006-01-02")
|
||||
var total int64
|
||||
for _, point := range usage {
|
||||
if point.Date < startDate || point.Date > endDate {
|
||||
continue
|
||||
}
|
||||
if _, err := time.Parse("2006-01-02", point.Date); err != nil {
|
||||
continue
|
||||
}
|
||||
total += point.TotalTokens
|
||||
}
|
||||
return &TokenCycle{
|
||||
LimitID: current.LimitID,
|
||||
WindowType: current.WindowType,
|
||||
WindowDurationMinutes: current.WindowDurationMinutes,
|
||||
StartedAt: startedAt,
|
||||
ResetsAt: current.ResetsAt,
|
||||
TotalTokens: total,
|
||||
}
|
||||
}
|
||||
|
||||
func betterTokenCycleLimit(candidate, current LimitBucket) bool {
|
||||
if candidate.WindowDurationMinutes != current.WindowDurationMinutes {
|
||||
return candidate.WindowDurationMinutes > current.WindowDurationMinutes
|
||||
}
|
||||
if candidate.WindowType != current.WindowType {
|
||||
return candidate.WindowType == "secondary"
|
||||
}
|
||||
if candidate.LimitID != current.LimitID {
|
||||
return candidate.LimitID < current.LimitID
|
||||
}
|
||||
return candidate.ResetsAt > current.ResetsAt
|
||||
}
|
||||
|
||||
func flattenLimit(x rawLimit) []LimitBucket {
|
||||
out := []LimitBucket{}
|
||||
if x.Primary != nil {
|
||||
|
||||
@@ -71,6 +71,72 @@ func TestDashboardSerializesNilListsAsEmptyArrays(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrentTokenCycleUsesLongestWindowAndFiltersDailyUsage(t *testing.T) {
|
||||
now := time.Date(2026, time.August, 14, 12, 0, 0, 0, time.UTC)
|
||||
reset := time.Date(2026, time.August, 15, 0, 0, 0, 0, time.UTC)
|
||||
cycle := currentTokenCycle(
|
||||
[]LimitBucket{
|
||||
{
|
||||
LimitID: "codex",
|
||||
WindowType: "primary",
|
||||
WindowDurationMinutes: 300,
|
||||
ResetsAt: now.Add(5 * time.Hour).Unix(),
|
||||
},
|
||||
{
|
||||
LimitID: "codex",
|
||||
WindowType: "secondary",
|
||||
WindowDurationMinutes: 7 * 24 * 60,
|
||||
ResetsAt: reset.Unix(),
|
||||
},
|
||||
},
|
||||
[]UsagePoint{
|
||||
{Date: "2026-08-07", TotalTokens: 50},
|
||||
{Date: "2026-08-08", TotalTokens: 100},
|
||||
{Date: "2026-08-10", TotalTokens: 200},
|
||||
{Date: "2026-08-14", TotalTokens: 300},
|
||||
{Date: "2026-08-15", TotalTokens: 400},
|
||||
},
|
||||
now.Unix(),
|
||||
)
|
||||
if cycle == nil {
|
||||
t.Fatal("currentTokenCycle() returned nil")
|
||||
}
|
||||
if cycle.WindowType != "secondary" || cycle.WindowDurationMinutes != 7*24*60 {
|
||||
t.Fatalf("cycle window = %#v; want the seven-day secondary window", cycle)
|
||||
}
|
||||
if cycle.StartedAt != time.Date(2026, time.August, 8, 0, 0, 0, 0, time.UTC).Unix() {
|
||||
t.Fatalf("cycle start = %d; want 2026-08-08", cycle.StartedAt)
|
||||
}
|
||||
if cycle.ResetsAt != reset.Unix() || cycle.TotalTokens != 600 {
|
||||
t.Fatalf("cycle = %#v; want reset %d and 600 tokens", cycle, reset.Unix())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrentTokenCycleRequiresAValidFutureResetWindow(t *testing.T) {
|
||||
cycle := currentTokenCycle(
|
||||
[]LimitBucket{{WindowDurationMinutes: 0, ResetsAt: 1}},
|
||||
[]UsagePoint{{Date: "2026-08-14", TotalTokens: 100}},
|
||||
time.Date(2026, time.August, 14, 12, 0, 0, 0, time.UTC).Unix(),
|
||||
)
|
||||
if cycle != nil {
|
||||
t.Fatalf("currentTokenCycle() = %#v; want nil", cycle)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlattenLimitReadsAppServerWindowDuration(t *testing.T) {
|
||||
var limit rawLimit
|
||||
if err := json.Unmarshal([]byte(`{
|
||||
"limitId":"codex",
|
||||
"primary":{"usedPercent":20,"windowDurationMins":10080,"resetsAt":1787197043}
|
||||
}`), &limit); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
flattened := flattenLimit(limit)
|
||||
if len(flattened) != 1 || flattened[0].WindowDurationMinutes != 10080 {
|
||||
t.Fatalf("flattened limit = %#v; want a 10080-minute window", flattened)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeCodexClient struct {
|
||||
mu sync.Mutex
|
||||
connected bool
|
||||
|
||||
@@ -41,7 +41,7 @@ type AccountView struct {
|
||||
}
|
||||
type LimitWindow struct {
|
||||
UsedPercent float64 `json:"usedPercent"`
|
||||
WindowDurationMins int `json:"windowDurationMinutes"`
|
||||
WindowDurationMins int `json:"windowDurationMins"`
|
||||
ResetsAt int64 `json:"resetsAt"`
|
||||
}
|
||||
type LimitBucket struct {
|
||||
@@ -70,6 +70,14 @@ type UsagePoint struct {
|
||||
InputTokens *int64 `json:"inputTokens"`
|
||||
OutputTokens *int64 `json:"outputTokens"`
|
||||
}
|
||||
type TokenCycle struct {
|
||||
LimitID string `json:"limitId"`
|
||||
WindowType string `json:"windowType"`
|
||||
WindowDurationMinutes int `json:"windowDurationMinutes"`
|
||||
StartedAt int64 `json:"startedAt"`
|
||||
ResetsAt int64 `json:"resetsAt"`
|
||||
TotalTokens int64 `json:"totalTokens"`
|
||||
}
|
||||
type Dashboard struct {
|
||||
AccountID int64 `json:"accountId"`
|
||||
DisplayName string `json:"displayName"`
|
||||
@@ -77,6 +85,7 @@ type Dashboard struct {
|
||||
Limits []LimitBucket `json:"limits"`
|
||||
Summary UsageSummary `json:"summary"`
|
||||
Usage []UsagePoint `json:"usage"`
|
||||
CurrentCycle *TokenCycle `json:"currentCycle,omitempty"`
|
||||
FetchedAt int64 `json:"fetchedAt"`
|
||||
Stale bool `json:"stale"`
|
||||
LastError string `json:"lastError,omitempty"`
|
||||
|
||||
@@ -25,11 +25,13 @@ account/login/start {type:"chatgptDeviceCode"}
|
||||
- `account/read` 决定连接、邮箱、认证模式和套餐;读取失败会使整次同步失败。
|
||||
- 限额读取兼容 `rateLimitsByLimitId` 多 bucket 和旧 `rateLimits` 单 bucket,再把 primary/secondary 展平。失败时保留空限额而不令整次同步失败。
|
||||
- 套餐未知时,可从所有可分类且一致的限额 bucket 回填;冲突或未知时必须保持 unknown。
|
||||
- 用量读取保存 summary 和每日 Token bucket;接口失败时使用空摘要和空历史,不令整次同步失败。
|
||||
- 用量读取保存 summary 和每日 Token bucket;接口失败时使用空摘要和空历史,不令整次同步失败。同步成功后,以当前仍有效的最长限额窗口作为当前重置周期,按每日 bucket 汇总周期起点至当前日期的 Token,写入 Dashboard 的 `currentCycle`;周期外和未来日期不会计入。
|
||||
- 每次限额同步写入快照、检测用量百分比显著回落,并更新账号元数据及内存 Dashboard。
|
||||
|
||||
`account/usage/read` 的 optional 指标和 daily buckets 可能暂未提供。仅 API Key 或 Bedrock 登录不能保证读取 ChatGPT 用量;不得在缺失数据时合成调用次数、输入/输出 Token、价格或账单日期。
|
||||
|
||||
`currentCycle` 的周期长度和重置时间来自 `account/rateLimits/read`,不硬编码为七天;通常会选择 secondary 周窗口。由于 daily bucket 只有日期没有每次请求时间,周期边界所在日期按整日汇总,因此该值是当前周期的日级统计,不是 Credits 或美元估值。
|
||||
|
||||
## 套餐类型
|
||||
|
||||
用户期望类型仅为连接后的校验提示:`any`、`personal`、`team`。当前 personal 包括 free/go/plus/pro/prolite;team 包括 team/business 及两种 self-serve business 标识。未知新套餐必须显示 unknown,不能静默当作某一类型。相同邮箱和相同已知实际类型的多个账号标记 `possibleDuplicate`,但不会自动合并或删除。
|
||||
|
||||
@@ -18,7 +18,7 @@ API 类型精确区分后端 `null` 与 optional,并为秘密设置拆分读
|
||||
|
||||
## 总览与设置
|
||||
|
||||
总览显示账户连接、套餐、每个限额窗口的剩余百分比和重置时间、四项摘要及每日 Token 图。`usedPercent` 展示前限制到 0–100,但原始数据语义不得在 API 类型层改写。缺失摘要显示“暂无”,缺失限额显示明确空状态。
|
||||
总览显示账户连接、套餐、每个限额窗口的剩余百分比和重置时间、当前重置周期 Token 合计、其他摘要及每日 Token 图。当前周期 Token 由后端按 app-server 的有效最长限额窗口和每日 bucket 汇总,不包含周期外或未来日期;详情会显示周期起止时间和“按每日数据汇总”提示。`usedPercent` 展示前限制到 0–100,但原始数据语义不得在 API 类型层改写。缺失摘要显示“暂无”,缺失限额显示明确空状态。
|
||||
|
||||
设置页四个 tab 全部保持挂载,以保留未提交表单状态;非活动 panel 使用 `aria-hidden` 和 `inert` 隔离。tab 支持方向键、Home 和 End,程序化切换不得改变页面滚动和布局。账号删除必须保留不可撤销确认,并清理正在显示的设备码和本地账号选择。
|
||||
|
||||
|
||||
+27
-3
@@ -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) => {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user