feat: show available reset credits
This commit is contained in:
@@ -7,6 +7,7 @@
|
|||||||
- 支持在同一总览页查看多个 Codex 账号或工作区(包括同一邮箱的个人订阅与 Team 工作区)
|
- 支持在同一总览页查看多个 Codex 账号或工作区(包括同一邮箱的个人订阅与 Team 工作区)
|
||||||
- 展示 Codex 账户、套餐、剩余额度和下次重置时间
|
- 展示 Codex 账户、套餐、剩余额度和下次重置时间
|
||||||
- 展示当前重置周期 Token 合计、本周期单日峰值及每日 Token 趋势
|
- 展示当前重置周期 Token 合计、本周期单日峰值及每日 Token 趋势
|
||||||
|
- 登录后有可用重置卡时展示卡片数量和到期时间
|
||||||
- 在本地 SQLite 中保留历史用量和限额快照
|
- 在本地 SQLite 中保留历史用量和限额快照
|
||||||
- 在限额重置前、重置后发送 Telegram 或邮件提醒
|
- 在限额重置前、重置后发送 Telegram 或邮件提醒
|
||||||
- 通过 Telegram 菜单查询当前用量、重置时间、历史概览和账户信息
|
- 通过 Telegram 菜单查询当前用量、重置时间、历史概览和账户信息
|
||||||
|
|||||||
+3
-1
@@ -33,6 +33,7 @@
|
|||||||
usedPercent, windowDurationMinutes, resetsAt, planType:string|null}],
|
usedPercent, windowDurationMinutes, resetsAt, planType:string|null}],
|
||||||
currentCycle?:{limitId, windowType, windowDurationMinutes,
|
currentCycle?:{limitId, windowType, windowDurationMinutes,
|
||||||
startedAt, resetsAt, totalTokens},
|
startedAt, resetsAt, totalTokens},
|
||||||
|
resetCredits?:{availableCount, expiresAt:[Unix seconds]},
|
||||||
summary:{lifetimeTokens?, peakDailyTokens?, longestRunningTurnSec?,
|
summary:{lifetimeTokens?, peakDailyTokens?, longestRunningTurnSec?,
|
||||||
currentStreakDays?, longestStreakDays?, callCount?, inputTokens?, outputTokens?},
|
currentStreakDays?, longestStreakDays?, callCount?, inputTokens?, outputTokens?},
|
||||||
usage:[{date,totalTokens,callCount?,inputTokens?,outputTokens?}],
|
usage:[{date,totalTokens,callCount?,inputTokens?,outputTokens?}],
|
||||||
@@ -40,6 +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 不返回该字段。
|
||||||
|
|
||||||
## 3. 系统、初始化与会话
|
## 3. 系统、初始化与会话
|
||||||
|
|
||||||
@@ -64,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。 |
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -229,6 +230,7 @@ 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
|
||||||
}
|
}
|
||||||
@@ -491,8 +493,10 @@ func (a *App) syncAccount(ctx context.Context, id int64) error {
|
|||||||
var lr struct {
|
var lr struct {
|
||||||
RateLimits *rawLimit `json:"rateLimits"`
|
RateLimits *rawLimit `json:"rateLimits"`
|
||||||
By map[string]rawLimit `json:"rateLimitsByLimitId"`
|
By map[string]rawLimit `json:"rateLimitsByLimitId"`
|
||||||
|
ResetCredits *rawResetCredits `json:"rateLimitResetCredits"`
|
||||||
}
|
}
|
||||||
if e := rt.client.Call(ctx, "account/rateLimits/read", map[string]any{}, &lr); e == nil {
|
if e := rt.client.Call(ctx, "account/rateLimits/read", map[string]any{}, &lr); e == nil {
|
||||||
|
d.ResetCredits = normalizeResetCredits(lr.ResetCredits)
|
||||||
if len(lr.By) > 0 {
|
if len(lr.By) > 0 {
|
||||||
for _, x := range lr.By {
|
for _, x := range lr.By {
|
||||||
d.Limits = append(d.Limits, flattenLimit(x)...)
|
d.Limits = append(d.Limits, flattenLimit(x)...)
|
||||||
@@ -605,6 +609,35 @@ type rawLimit struct {
|
|||||||
Secondary *LimitWindow `json:"secondary"`
|
Secondary *LimitWindow `json:"secondary"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type rawResetCredits struct {
|
||||||
|
AvailableCount int `json:"availableCount"`
|
||||||
|
Credits []rawResetCredit `json:"credits"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type rawResetCredit struct {
|
||||||
|
ExpiresAt *int64 `json:"expiresAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeResetCredits(raw *rawResetCredits) *ResetCreditsSummary {
|
||||||
|
if raw == nil || raw.AvailableCount <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
expiresAt := make([]int64, 0, len(raw.Credits))
|
||||||
|
for _, credit := range raw.Credits {
|
||||||
|
if credit.ExpiresAt != nil && *credit.ExpiresAt > 0 {
|
||||||
|
expiresAt = append(expiresAt, *credit.ExpiresAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(expiresAt, func(i, j int) bool { return expiresAt[i] < expiresAt[j] })
|
||||||
|
uniqueExpiresAt := expiresAt[:0]
|
||||||
|
for _, value := range expiresAt {
|
||||||
|
if len(uniqueExpiresAt) == 0 || uniqueExpiresAt[len(uniqueExpiresAt)-1] != value {
|
||||||
|
uniqueExpiresAt = append(uniqueExpiresAt, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &ResetCreditsSummary{AvailableCount: raw.AvailableCount, ExpiresAt: uniqueExpiresAt}
|
||||||
|
}
|
||||||
|
|
||||||
func currentTokenCycle(limits []LimitBucket, usage []UsagePoint, fetchedAt int64) *TokenCycle {
|
func currentTokenCycle(limits []LimitBucket, usage []UsagePoint, fetchedAt int64) *TokenCycle {
|
||||||
current := LimitBucket{}
|
current := LimitBucket{}
|
||||||
found := false
|
found := false
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"reflect"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -98,6 +99,7 @@ func TestAnonymousOverviewIsReadOnly(t *testing.T) {
|
|||||||
Account: AccountView{Email: &email, PlanType: &plan, Connected: true},
|
Account: AccountView{Email: &email, PlanType: &plan, Connected: true},
|
||||||
Limits: []LimitBucket{},
|
Limits: []LimitBucket{},
|
||||||
Usage: []UsagePoint{},
|
Usage: []UsagePoint{},
|
||||||
|
ResetCredits: &ResetCreditsSummary{AvailableCount: 2, ExpiresAt: []int64{1784246400}},
|
||||||
FetchedAt: time.Now().Unix(),
|
FetchedAt: time.Now().Unix(),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -132,6 +134,9 @@ 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 {
|
||||||
|
t.Fatalf("anonymous dashboard reset credits = %#v; entitlement data was not redacted", 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"} {
|
||||||
recorder := httptest.NewRecorder()
|
recorder := httptest.NewRecorder()
|
||||||
@@ -163,6 +168,9 @@ func TestAnonymousOverviewIsReadOnly(t *testing.T) {
|
|||||||
if privateDashboardBody.Account.Email == nil || *privateDashboardBody.Account.Email != email {
|
if privateDashboardBody.Account.Email == nil || *privateDashboardBody.Account.Email != email {
|
||||||
t.Fatalf("authenticated dashboard email = %v; want %q", privateDashboardBody.Account.Email, email)
|
t.Fatalf("authenticated dashboard email = %v; want %q", privateDashboardBody.Account.Email, email)
|
||||||
}
|
}
|
||||||
|
if privateDashboardBody.ResetCredits == nil || privateDashboardBody.ResetCredits.AvailableCount != 2 {
|
||||||
|
t.Fatalf("authenticated dashboard reset credits = %#v; want two credits", privateDashboardBody.ResetCredits)
|
||||||
|
}
|
||||||
configRecorder := httptest.NewRecorder()
|
configRecorder := httptest.NewRecorder()
|
||||||
configRequest := httptest.NewRequest(http.MethodPut, "/api/v1/settings/general", strings.NewReader(`{"timezone":"UTC","theme":"system","syncMinutes":5,"retentionDays":90,"beforeMinutes":30,"notifyBefore":true,"notifyAfter":true}`))
|
configRequest := httptest.NewRequest(http.MethodPut, "/api/v1/settings/general", strings.NewReader(`{"timezone":"UTC","theme":"system","syncMinutes":5,"retentionDays":90,"beforeMinutes":30,"notifyBefore":true,"notifyAfter":true}`))
|
||||||
configRequest.AddCookie(&http.Cookie{Name: "session", Value: session})
|
configRequest.AddCookie(&http.Cookie{Name: "session", Value: session})
|
||||||
@@ -320,6 +328,29 @@ func TestCurrentTokenCycleUsesLongestWindowAndFiltersDailyUsage(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNormalizeResetCreditsKeepsAvailableCountAndUniqueExpiryTimes(t *testing.T) {
|
||||||
|
earlier := int64(1781654400)
|
||||||
|
later := int64(1784246400)
|
||||||
|
credits := normalizeResetCredits(&rawResetCredits{
|
||||||
|
AvailableCount: 3,
|
||||||
|
Credits: []rawResetCredit{
|
||||||
|
{ExpiresAt: &later},
|
||||||
|
{ExpiresAt: nil},
|
||||||
|
{ExpiresAt: &earlier},
|
||||||
|
{ExpiresAt: &later},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if credits == nil || credits.AvailableCount != 3 {
|
||||||
|
t.Fatalf("reset credits = %#v; want three available credits", credits)
|
||||||
|
}
|
||||||
|
if want := []int64{earlier, later}; !reflect.DeepEqual(credits.ExpiresAt, want) {
|
||||||
|
t.Fatalf("expiry times = %v; want %v", credits.ExpiresAt, want)
|
||||||
|
}
|
||||||
|
if normalizeResetCredits(&rawResetCredits{}) != nil {
|
||||||
|
t.Fatal("zero available credits should be hidden")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCurrentTokenCycleRequiresAValidFutureResetWindow(t *testing.T) {
|
func TestCurrentTokenCycleRequiresAValidFutureResetWindow(t *testing.T) {
|
||||||
cycle := currentTokenCycle(
|
cycle := currentTokenCycle(
|
||||||
[]LimitBucket{{WindowDurationMinutes: 0, ResetsAt: 1}},
|
[]LimitBucket{{WindowDurationMinutes: 0, ResetsAt: 1}},
|
||||||
|
|||||||
@@ -81,6 +81,10 @@ type TokenCycle struct {
|
|||||||
ResetsAt int64 `json:"resetsAt"`
|
ResetsAt int64 `json:"resetsAt"`
|
||||||
TotalTokens int64 `json:"totalTokens"`
|
TotalTokens int64 `json:"totalTokens"`
|
||||||
}
|
}
|
||||||
|
type ResetCreditsSummary struct {
|
||||||
|
AvailableCount int `json:"availableCount"`
|
||||||
|
ExpiresAt []int64 `json:"expiresAt"`
|
||||||
|
}
|
||||||
type Dashboard struct {
|
type Dashboard struct {
|
||||||
AccountID int64 `json:"accountId"`
|
AccountID int64 `json:"accountId"`
|
||||||
DisplayName string `json:"displayName"`
|
DisplayName string `json:"displayName"`
|
||||||
@@ -89,6 +93,7 @@ type Dashboard struct {
|
|||||||
Summary UsageSummary `json:"summary"`
|
Summary UsageSummary `json:"summary"`
|
||||||
Usage []UsagePoint `json:"usage"`
|
Usage []UsagePoint `json:"usage"`
|
||||||
CurrentCycle *TokenCycle `json:"currentCycle,omitempty"`
|
CurrentCycle *TokenCycle `json:"currentCycle,omitempty"`
|
||||||
|
ResetCredits *ResetCreditsSummary `json:"resetCredits,omitempty"`
|
||||||
FetchedAt int64 `json:"fetchedAt"`
|
FetchedAt int64 `json:"fetchedAt"`
|
||||||
Stale bool `json:"stale"`
|
Stale bool `json:"stale"`
|
||||||
LastError string `json:"lastError,omitempty"`
|
LastError string `json:"lastError,omitempty"`
|
||||||
|
|||||||
@@ -23,13 +23,15 @@ account/login/start {type:"chatgptDeviceCode"}
|
|||||||
一次同步依次读取 `account/read`、`account/rateLimits/read` 和 `account/usage/read`:
|
一次同步依次读取 `account/read`、`account/rateLimits/read` 和 `account/usage/read`:
|
||||||
|
|
||||||
- `account/read` 决定连接、邮箱、认证模式和套餐;读取失败会使整次同步失败。
|
- `account/read` 决定连接、邮箱、认证模式和套餐;读取失败会使整次同步失败。
|
||||||
- 限额读取兼容 `rateLimitsByLimitId` 多 bucket 和旧 `rateLimits` 单 bucket,再把 primary/secondary 展平。失败时保留空限额而不令整次同步失败。
|
- 限额读取兼容 `rateLimitsByLimitId` 多 bucket 和旧 `rateLimits` 单 bucket,再把 primary/secondary 展平;同时读取 `rateLimitResetCredits`,只在有可用卡时保留数量和到期时间。失败时保留空限额而不令整次同步失败。
|
||||||
- 套餐未知时,可从所有可分类且一致的限额 bucket 回填;冲突或未知时必须保持 unknown。
|
- 套餐未知时,可从所有可分类且一致的限额 bucket 回填;冲突或未知时必须保持 unknown。
|
||||||
- 用量读取保存 summary 和每日 Token bucket;接口失败时使用空摘要和空历史,不令整次同步失败。同步成功后,以当前仍有效的最长限额窗口作为当前重置周期,按每日 bucket 汇总周期起点至当前日期的 Token,写入 Dashboard 的 `currentCycle`;周期外和未来日期不会计入。
|
- 用量读取保存 summary 和每日 Token bucket;接口失败时使用空摘要和空历史,不令整次同步失败。同步成功后,以当前仍有效的最长限额窗口作为当前重置周期,按每日 bucket 汇总周期起点至当前日期的 Token,写入 Dashboard 的 `currentCycle`;周期外和未来日期不会计入。
|
||||||
- 每次限额同步写入快照、检测用量百分比显著回落,并更新账号元数据及内存 Dashboard。
|
- 每次限额同步写入快照、检测用量百分比显著回落,并更新账号元数据及内存 Dashboard。
|
||||||
|
|
||||||
`account/usage/read` 的 optional 指标和 daily buckets 可能暂未提供。仅 API Key 或 Bedrock 登录不能保证读取 ChatGPT 用量;不得在缺失数据时合成调用次数、输入/输出 Token、价格或账单日期。
|
`account/usage/read` 的 optional 指标和 daily buckets 可能暂未提供。仅 API Key 或 Bedrock 登录不能保证读取 ChatGPT 用量;不得在缺失数据时合成调用次数、输入/输出 Token、价格或账单日期。
|
||||||
|
|
||||||
|
重置卡来自 `account/rateLimits/read` 的 `rateLimitResetCredits`。该信息只保存在内存 Dashboard 中,下一次同步会重新读取;没有可用卡时不返回到 Dashboard。当前项目只展示数量和到期时间,不执行消耗操作。
|
||||||
|
|
||||||
`currentCycle` 的周期长度和重置时间来自 `account/rateLimits/read`,不硬编码为七天;通常会选择 secondary 周窗口。由于 daily bucket 只有日期没有每次请求时间,周期边界所在日期按整日汇总,因此该值是当前周期的日级统计,不是 Credits 或美元估值。
|
`currentCycle` 的周期长度和重置时间来自 `account/rateLimits/read`,不硬编码为七天;通常会选择 secondary 周窗口。由于 daily bucket 只有日期没有每次请求时间,周期边界所在日期按整日汇总,因此该值是当前周期的日级统计,不是 Credits 或美元估值。
|
||||||
|
|
||||||
## 套餐类型
|
## 套餐类型
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ API 类型精确区分后端 `null` 与 optional,并为秘密设置拆分读
|
|||||||
|
|
||||||
## 总览与设置
|
## 总览与设置
|
||||||
|
|
||||||
总览显示账户连接、套餐、每个限额窗口的剩余百分比和重置时间、当前重置周期 Token 合计、本周期单日峰值、其他摘要及每日 Token 图。当前周期 Token 与本周期单日峰值都由后端按 app-server 的有效最长限额窗口和每日 bucket 汇总,不包含周期外或未来日期;详情会显示周期起止时间和“按每日数据汇总”提示。`usedPercent` 展示前限制到 0–100,但原始数据语义不得在 API 类型层改写。缺失摘要显示“暂无”,缺失限额显示明确空状态。
|
总览显示账户连接、套餐、每个限额窗口的剩余百分比和重置时间、当前重置周期 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,程序化切换不得改变页面滚动和布局。账号删除必须保留不可撤销确认,并清理正在显示的设备码和本地账号选择。
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
Settings,
|
Settings,
|
||||||
Sun,
|
Sun,
|
||||||
|
Ticket,
|
||||||
Trash2,
|
Trash2,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
Zap,
|
Zap,
|
||||||
@@ -714,6 +715,21 @@ function AccountDashboardDetails({
|
|||||||
value={dashboard.stale ? "数据可能已过期" : "数据已更新"}
|
value={dashboard.stale ? "数据可能已过期" : "数据已更新"}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{!publicView &&
|
||||||
|
dashboard.resetCredits &&
|
||||||
|
dashboard.resetCredits.availableCount > 0 && (
|
||||||
|
<div className="reset-credits" data-testid="reset-credits">
|
||||||
|
<Ticket />
|
||||||
|
<div className="reset-credits-main">
|
||||||
|
<small>重置卡</small>
|
||||||
|
<b>可用 {dashboard.resetCredits.availableCount} 张</b>
|
||||||
|
</div>
|
||||||
|
<div className="reset-credits-expiry">
|
||||||
|
<small>到期时间</small>
|
||||||
|
<b>{formatResetCreditExpiry(dashboard.resetCredits.expiresAt)}</b>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="limits">
|
<div className="limits">
|
||||||
{dashboard.limits.map((limit, index) => (
|
{dashboard.limits.map((limit, index) => (
|
||||||
<LimitCard
|
<LimitCard
|
||||||
@@ -1646,6 +1662,8 @@ const num = (v?: number | null) =>
|
|||||||
}).format(v);
|
}).format(v);
|
||||||
const cycleTimestamp = (value: number) =>
|
const cycleTimestamp = (value: number) =>
|
||||||
new Date(value * 1000).toLocaleString();
|
new Date(value * 1000).toLocaleString();
|
||||||
|
const formatResetCreditExpiry = (values: number[]) =>
|
||||||
|
values.length ? values.map(cycleTimestamp).join("、") : "服务端未提供";
|
||||||
const cycleDuration = (minutes: number) =>
|
const cycleDuration = (minutes: number) =>
|
||||||
minutes % (24 * 60) === 0
|
minutes % (24 * 60) === 0
|
||||||
? `${minutes / (24 * 60)} 天窗口`
|
? `${minutes / (24 * 60)} 天窗口`
|
||||||
|
|||||||
@@ -499,6 +499,44 @@ header p {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
.reset-credits {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 14px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--accent2) 30%, var(--border));
|
||||||
|
border-radius: 12px;
|
||||||
|
background: color-mix(in srgb, var(--accent2) 7%, var(--panel));
|
||||||
|
}
|
||||||
|
.reset-credits > svg {
|
||||||
|
flex: none;
|
||||||
|
width: 20px;
|
||||||
|
color: var(--accent2);
|
||||||
|
}
|
||||||
|
.reset-credits-main,
|
||||||
|
.reset-credits-expiry {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.reset-credits-main small,
|
||||||
|
.reset-credits-expiry small {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.reset-credits-main b,
|
||||||
|
.reset-credits-expiry b {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.reset-credits-expiry {
|
||||||
|
margin-left: auto;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.reset-credits-expiry b {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
.account-detail-state {
|
.account-detail-state {
|
||||||
display: flex;
|
display: flex;
|
||||||
min-height: 170px;
|
min-height: 170px;
|
||||||
@@ -1041,6 +1079,13 @@ a {
|
|||||||
.account-info-grid {
|
.account-info-grid {
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
}
|
}
|
||||||
|
.reset-credits {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
.reset-credits-expiry {
|
||||||
|
margin-left: 0;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
.account {
|
.account {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
|
|||||||
@@ -47,6 +47,22 @@ describe("API decoders", () => {
|
|||||||
expect(value.currentCycle?.totalTokens).toBe(123_456);
|
expect(value.currentCycle?.totalTokens).toBe(123_456);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("解码可用重置卡及到期时间", () => {
|
||||||
|
const value = decodeDashboard({
|
||||||
|
accountId: 1,
|
||||||
|
displayName: "个人账号",
|
||||||
|
account: { email: null, planType: "plus", connected: true },
|
||||||
|
limits: [],
|
||||||
|
resetCredits: { availableCount: 2, expiresAt: [1_784_246_400] },
|
||||||
|
summary: {},
|
||||||
|
usage: [],
|
||||||
|
fetchedAt: 1_900_000_000,
|
||||||
|
stale: false,
|
||||||
|
});
|
||||||
|
expect(value.resetCredits?.availableCount).toBe(2);
|
||||||
|
expect(value.resetCredits?.expiresAt).toEqual([1_784_246_400]);
|
||||||
|
});
|
||||||
|
|
||||||
it("拒绝未知主题", () => {
|
it("拒绝未知主题", () => {
|
||||||
expect(() =>
|
expect(() =>
|
||||||
decodeGeneral({
|
decodeGeneral({
|
||||||
|
|||||||
@@ -57,6 +57,10 @@ export interface TokenCycle {
|
|||||||
resetsAt: number;
|
resetsAt: number;
|
||||||
totalTokens: number;
|
totalTokens: number;
|
||||||
}
|
}
|
||||||
|
export interface ResetCreditsSummary {
|
||||||
|
availableCount: number;
|
||||||
|
expiresAt: number[];
|
||||||
|
}
|
||||||
export interface Dashboard {
|
export interface Dashboard {
|
||||||
accountId: number;
|
accountId: number;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
@@ -76,6 +80,7 @@ export interface Dashboard {
|
|||||||
usage: Point[];
|
usage: Point[];
|
||||||
fetchedAt: number;
|
fetchedAt: number;
|
||||||
stale: boolean;
|
stale: boolean;
|
||||||
|
resetCredits?: ResetCreditsSummary;
|
||||||
lastError?: string;
|
lastError?: string;
|
||||||
}
|
}
|
||||||
export interface GeneralSettings {
|
export interface GeneralSettings {
|
||||||
@@ -178,6 +183,14 @@ const decodeTokenCycle: Decoder<TokenCycle> = (value) => {
|
|||||||
totalTokens: number(x.totalTokens, "totalTokens"),
|
totalTokens: number(x.totalTokens, "totalTokens"),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
const decodeResetCredits: Decoder<ResetCreditsSummary> = (value) => {
|
||||||
|
const x = record(value, "resetCredits");
|
||||||
|
if (!Array.isArray(x.expiresAt)) throw new Error("expiresAt格式无效");
|
||||||
|
return {
|
||||||
|
availableCount: number(x.availableCount, "availableCount"),
|
||||||
|
expiresAt: x.expiresAt.map((v, index) => number(v, `expiresAt[${index}]`)),
|
||||||
|
};
|
||||||
|
};
|
||||||
export const decodeDashboard: Decoder<Dashboard> = (value) => {
|
export const decodeDashboard: Decoder<Dashboard> = (value) => {
|
||||||
const x = record(value, "Dashboard"),
|
const x = record(value, "Dashboard"),
|
||||||
account = record(x.account, "account"),
|
account = record(x.account, "account"),
|
||||||
@@ -209,6 +222,9 @@ export const decodeDashboard: Decoder<Dashboard> = (value) => {
|
|||||||
...(x.currentCycle == null
|
...(x.currentCycle == null
|
||||||
? {}
|
? {}
|
||||||
: { currentCycle: decodeTokenCycle(x.currentCycle) }),
|
: { currentCycle: decodeTokenCycle(x.currentCycle) }),
|
||||||
|
...(x.resetCredits == null
|
||||||
|
? {}
|
||||||
|
: { resetCredits: decodeResetCredits(x.resetCredits) }),
|
||||||
summary: {
|
summary: {
|
||||||
lifetimeTokens: optionalNumber(summary.lifetimeTokens, "lifetimeTokens"),
|
lifetimeTokens: optionalNumber(summary.lifetimeTokens, "lifetimeTokens"),
|
||||||
peakDailyTokens: optionalNumber(
|
peakDailyTokens: optionalNumber(
|
||||||
|
|||||||
@@ -106,6 +106,10 @@ test("shows every account as a collapsible summary on the overview", async ({
|
|||||||
currentStreakDays: 4,
|
currentStreakDays: 4,
|
||||||
longestRunningTurnSec: 80,
|
longestRunningTurnSec: 80,
|
||||||
},
|
},
|
||||||
|
resetCredits: {
|
||||||
|
availableCount: 2,
|
||||||
|
expiresAt: [1_784_246_400],
|
||||||
|
},
|
||||||
usage: [{ date: "2026-08-13", totalTokens: 3_000 }],
|
usage: [{ date: "2026-08-13", totalTokens: 3_000 }],
|
||||||
fetchedAt: 1_900_000_000,
|
fetchedAt: 1_900_000_000,
|
||||||
stale: false,
|
stale: false,
|
||||||
@@ -175,6 +179,9 @@ test("shows every account as a collapsible summary on the overview", async ({
|
|||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
await expect(first.getByText("本周期 Tokens")).toBeVisible();
|
await expect(first.getByText("本周期 Tokens")).toBeVisible();
|
||||||
await expect(first.getByText("本周期单日峰值")).toBeVisible();
|
await expect(first.getByText("本周期单日峰值")).toBeVisible();
|
||||||
|
await expect(first.getByTestId("reset-credits")).toContainText("重置卡");
|
||||||
|
await expect(first.getByTestId("reset-credits")).toContainText("可用 2 张");
|
||||||
|
await expect(first.getByTestId("reset-credits")).toContainText("到期时间");
|
||||||
await expect(first.getByText("连续使用", { exact: true })).toHaveCount(0);
|
await expect(first.getByText("连续使用", { exact: true })).toHaveCount(0);
|
||||||
await expect(first.getByText("最长任务时长", { exact: true })).toHaveCount(0);
|
await expect(first.getByText("最长任务时长", { exact: true })).toHaveCount(0);
|
||||||
await expect(first.getByText("123.5K", { exact: true })).toBeVisible();
|
await expect(first.getByText("123.5K", { exact: true })).toBeVisible();
|
||||||
@@ -183,6 +190,10 @@ test("shows every account as a collapsible summary on the overview", async ({
|
|||||||
|
|
||||||
await first.getByRole("button", { name: "收起个人账号详情" }).click();
|
await first.getByRole("button", { name: "收起个人账号详情" }).click();
|
||||||
await expect(page.locator(".account-detail")).toHaveCount(0);
|
await expect(page.locator(".account-detail")).toHaveCount(0);
|
||||||
|
|
||||||
|
const second = page.locator('[data-account-id="2"]');
|
||||||
|
await second.getByRole("button", { name: "展开Team 工作区详情" }).click();
|
||||||
|
await expect(second.getByTestId("reset-credits")).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("shows a read-only overview before login", async ({ page }) => {
|
test("shows a read-only overview before login", async ({ page }) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user