feat: show reset credits publicly

This commit is contained in:
2026-08-22 10:06:48 +08:00
parent 6a711c1fff
commit 54e20f7b8c
7 changed files with 24 additions and 21 deletions
+1 -1
View File
@@ -7,7 +7,7 @@
- 支持在同一总览页查看多个 Codex 账号或工作区(包括同一邮箱的个人订阅与 Team 工作区) - 支持在同一总览页查看多个 Codex 账号或工作区(包括同一邮箱的个人订阅与 Team 工作区)
- 展示 Codex 账户、套餐、剩余额度和下次重置时间 - 展示 Codex 账户、套餐、剩余额度和下次重置时间
- 展示当前重置周期 Token 合计、本周期单日峰值及每日 Token 趋势 - 展示当前重置周期 Token 合计、本周期单日峰值及每日 Token 趋势
- 登录后有可用重置卡时展示卡片数量和到期时间 - 有可用重置卡时展示卡片数量和到期时间
- 在本地 SQLite 中保留历史用量和限额快照 - 在本地 SQLite 中保留历史用量和限额快照
- 在限额重置前、重置后发送 Telegram 或邮件提醒 - 在限额重置前、重置后发送 Telegram 或邮件提醒
- 通过 Telegram 菜单查询当前用量、重置时间、历史概览和账户信息 - 通过 Telegram 菜单查询当前用量、重置时间、历史概览和账户信息
+2 -2
View File
@@ -41,7 +41,7 @@
``` ```
时间字段为 Unix 秒。`currentCycle` 是按当前仍有效的最长限额窗口计算的当前重置周期;`totalTokens` 由 `account/usage/read` 的每日 Token bucket 汇总,受每日粒度影响,无法精确切分周期起止日期内的单日数据。`summary.peakDailyTokens` 是同一当前重置周期内每日 Token bucket 的最大值,不再直接采用 app-server 未限定口径的峰值摘要;没有有效窗口或周期内没有每日数据时为 `null`。app-server 未提供的其他摘要字段可以是 `null`;列表应返回数组而非 `null`。 时间字段为 Unix 秒。`currentCycle` 是按当前仍有效的最长限额窗口计算的当前重置周期;`totalTokens` 由 `account/usage/read` 的每日 Token bucket 汇总,受每日粒度影响,无法精确切分周期起止日期内的单日数据。`summary.peakDailyTokens` 是同一当前重置周期内每日 Token bucket 的最大值,不再直接采用 app-server 未限定口径的峰值摘要;没有有效窗口或周期内没有每日数据时为 `null`。app-server 未提供的其他摘要字段可以是 `null`;列表应返回数组而非 `null`。
`resetCredits` 仅在 app-server 返回可用重置卡且 `availableCount > 0` 时出现;`expiresAt` 是已返回卡片的去重到期时间列表,服务端未提供卡片详情时可以为空。匿名公开 Dashboard 不返回该字段。 `resetCredits` 仅在 app-server 返回可用重置卡且 `availableCount > 0` 时出现;`expiresAt` 是已返回卡片的去重到期时间列表,服务端未提供卡片详情时可以为空。公开账号的匿名 Dashboard 也会返回该字段;邮箱、认证方式和内部错误字段仍会隐藏。
## 3. 系统、初始化与会话 ## 3. 系统、初始化与会话
@@ -66,7 +66,7 @@
| `POST /api/v1/accounts/{id}/login/device` | 启动并初始化 app-server,调用 `account/login/start` 的 `chatgptDeviceCode` 流程;返回含 `verificationUrl`、`userCode` 和 `loginId` 的结果。 | | `POST /api/v1/accounts/{id}/login/device` | 启动并初始化 app-server,调用 `account/login/start` 的 `chatgptDeviceCode` 流程;返回含 `verificationUrl`、`userCode` 和 `loginId` 的结果。 |
| `POST /api/v1/accounts/{id}/logout` | 调用 `account/logout` 并将连接状态置为 false;返回 `200 {ok:true}`。 | | `POST /api/v1/accounts/{id}/logout` | 调用 `account/logout` 并将连接状态置为 false;返回 `200 {ok:true}`。 |
| `POST /api/v1/accounts/{id}/sync` | 同步指定账号;成功 `200 {ok:true}`,上游失败 502。 | | `POST /api/v1/accounts/{id}/sync` | 同步指定账号;成功 `200 {ok:true}`,上游失败 502。 |
| `GET`/`HEAD /api/v1/dashboard?accountId={id}` | 初始化后匿名可读公开账号,返回内存中的 `Dashboard`;匿名访问未公开账号返回 404,匿名响应隐藏邮箱、认证方式、重置卡和内部错误字段。登录后可读取全部账号。省略或无效的零值 ID 使用账号 1,前端使用 `GET`。 | | `GET`/`HEAD /api/v1/dashboard?accountId={id}` | 初始化后匿名可读公开账号,返回内存中的 `Dashboard`;匿名访问未公开账号返回 404,匿名响应隐藏邮箱、认证方式和内部错误字段,但保留重置卡信息。登录后可读取全部账号。省略或无效的零值 ID 使用账号 1,前端使用 `GET`。 |
| `任意非读方法 /api/v1/dashboard?accountId={id}` | 要求 session;非 `GET`/`HEAD` 还需来源头。保持兼容的读取行为,省略或无效的零值 ID 使用账号 1。 | | `任意非读方法 /api/v1/dashboard?accountId={id}` | 要求 session;非 `GET`/`HEAD` 还需来源头。保持兼容的读取行为,省略或无效的零值 ID 使用账号 1。 |
| `POST /api/v1/sync?accountId={id}` | 旧兼容入口,同步指定账号;省略或零值 ID 使用账号 1。 | | `POST /api/v1/sync?accountId={id}` | 旧兼容入口,同步指定账号;省略或零值 ID 使用账号 1。 |
-1
View File
@@ -230,7 +230,6 @@ func (a *App) dashboardAPI(w http.ResponseWriter, r *http.Request) {
func publicDashboard(d Dashboard) Dashboard { func publicDashboard(d Dashboard) Dashboard {
d.Account.Email = nil d.Account.Email = nil
d.Account.AuthMode = nil d.Account.AuthMode = nil
d.ResetCredits = nil
d.LastError = "" d.LastError = ""
return d return d
} }
+2 -2
View File
@@ -134,8 +134,8 @@ func TestAnonymousOverviewIsReadOnly(t *testing.T) {
if publicDashboardBody.Account.Email != nil || publicDashboardBody.Account.AuthMode != nil { if publicDashboardBody.Account.Email != nil || publicDashboardBody.Account.AuthMode != nil {
t.Fatalf("anonymous dashboard account = %#v; identity fields were not redacted", publicDashboardBody.Account) t.Fatalf("anonymous dashboard account = %#v; identity fields were not redacted", publicDashboardBody.Account)
} }
if publicDashboardBody.ResetCredits != nil { if publicDashboardBody.ResetCredits == nil || publicDashboardBody.ResetCredits.AvailableCount != 2 {
t.Fatalf("anonymous dashboard reset credits = %#v; entitlement data was not redacted", publicDashboardBody.ResetCredits) t.Fatalf("anonymous dashboard reset credits = %#v; want two credits", publicDashboardBody.ResetCredits)
} }
for _, path := range []string{"/api/v1/settings/general", "/api/v1/accounts", "/api/v1/accounts/1/sync"} { for _, path := range []string{"/api/v1/settings/general", "/api/v1/accounts", "/api/v1/accounts/1/sync"} {
+1 -1
View File
@@ -18,7 +18,7 @@ API 类型精确区分后端 `null` 与 optional,并为秘密设置拆分读
## 总览与设置 ## 总览与设置
总览显示账户连接、套餐、每个限额窗口的剩余百分比和重置时间、当前重置周期 Token 合计、本周期单日峰值、其他摘要及每日 Token 图;登录后账号有可用重置卡时,详情额外显示卡片数量和到期时间。当前周期 Token 与本周期单日峰值都由后端按 app-server 的有效最长限额窗口和每日 bucket 汇总,不包含周期外或未来日期;详情会显示周期起止时间和“按每日数据汇总”提示。`usedPercent` 展示前限制到 0–100,但原始数据语义不得在 API 类型层改写。缺失摘要显示“暂无”,缺失限额显示明确空状态。匿名公开 Dashboard 不显示重置卡信息。 总览显示账户连接、套餐、每个限额窗口的剩余百分比和重置时间、当前重置周期 Token 合计、本周期单日峰值、其他摘要及每日 Token 图;账号有可用重置卡时,详情额外显示卡片数量和到期时间,公开账号的匿名总览也会显示。当前周期 Token 与本周期单日峰值都由后端按 app-server 的有效最长限额窗口和每日 bucket 汇总,不包含周期外或未来日期;详情会显示周期起止时间和“按每日数据汇总”提示。`usedPercent` 展示前限制到 0–100,但原始数据语义不得在 API 类型层改写。缺失摘要显示“暂无”,缺失限额显示明确空状态。匿名公开 Dashboard 仍隐藏邮箱、认证方式和内部错误。
设置页四个 tab 全部保持挂载,以保留未提交表单状态;非活动 panel 使用 `aria-hidden` 和 `inert` 隔离。tab 支持方向键、Home 和 End,程序化切换不得改变页面滚动和布局。账号删除必须保留不可撤销确认,并清理正在显示的设备码和本地账号选择。 设置页四个 tab 全部保持挂载,以保留未提交表单状态;非活动 panel 使用 `aria-hidden` 和 `inert` 隔离。tab 支持方向键、Home 和 End,程序化切换不得改变页面滚动和布局。账号删除必须保留不可撤销确认,并清理正在显示的设备码和本地账号选择。
+1 -3
View File
@@ -715,9 +715,7 @@ function AccountDashboardDetails({
value={dashboard.stale ? "数据可能已过期" : "数据已更新"} value={dashboard.stale ? "数据可能已过期" : "数据已更新"}
/> />
</div> </div>
{!publicView && {dashboard.resetCredits && dashboard.resetCredits.availableCount > 0 && (
dashboard.resetCredits &&
dashboard.resetCredits.availableCount > 0 && (
<div className="reset-credits" data-testid="reset-credits"> <div className="reset-credits" data-testid="reset-credits">
<Ticket /> <Ticket />
<div className="reset-credits-main"> <div className="reset-credits-main">
+6
View File
@@ -217,6 +217,10 @@ test("shows a read-only overview before login", async ({ page }) => {
connected: true, connected: true,
}, },
limits: [], limits: [],
resetCredits: {
availableCount: 2,
expiresAt: [1_784_246_400],
},
summary: {}, summary: {},
usage: [], usage: [],
fetchedAt: 1_900_000_000, fetchedAt: 1_900_000_000,
@@ -235,6 +239,8 @@ test("shows a read-only overview before login", async ({ page }) => {
const account = page.locator('[data-account-id="1"]'); const account = page.locator('[data-account-id="1"]');
await account.getByRole("button", { name: "展开默认账号详情" }).click(); await account.getByRole("button", { name: "展开默认账号详情" }).click();
await expect(account.getByText("登录后查看")).toBeVisible(); await expect(account.getByText("登录后查看")).toBeVisible();
await expect(account.getByTestId("reset-credits")).toContainText("可用 2 张");
await expect(account.getByTestId("reset-credits")).toContainText("到期时间");
await expect(account.locator(".account-detail .refresh")).toHaveCount(0); await expect(account.locator(".account-detail .refresh")).toHaveCount(0);
await page.goto("/settings"); await page.goto("/settings");