Merge pull request #7684 from william-drakemond/feat/claude-reset-credits-ui

feat(claude): 查询 Claude 重置次数(只读),UI 复用 Codex 次数按钮 / Read-only Claude reset credit count, reusing Codex count UI
This commit is contained in:
Wesley Liddick
2026-09-29 15:50:53 +08:00
committed by GitHub
14 changed files with 691 additions and 13 deletions
+2 -1
View File
@@ -288,7 +288,8 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
auditLogHandler := admin.NewAuditLogHandler(auditLogService, totpService)
upstreamBillingProbeService := service.ProvideUpstreamBillingProbeService(accountRepository, accountTestService, settingService, leaderLockCache, db)
openCodeGoUsageService := service.ProvideOpenCodeGoUsageService(accountRepository, httpUpstream, settingService, leaderLockCache, db)
adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, adminAnnouncementHandler, dataManagementHandler, backupHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, grokOAuthHandler, cnProviderHandler, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler, errorPassthroughHandler, tlsFingerprintProfileHandler, pluginHandler, adminAPIKeyHandler, scheduledTestHandler, channelHandler, channelMonitorHandler, channelMonitorRequestTemplateHandler, contentModerationHandler, promptAdminHandler, paymentHandler, affiliateHandler, complianceHandler, auditLogHandler, upstreamBillingProbeService, ollamaCloudUsageService, openCodeGoUsageService)
claudeResetCreditService := service.NewClaudeResetCreditService(accountRepository, claudeTokenProvider, proxyRepository, settingService)
adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, adminAnnouncementHandler, dataManagementHandler, backupHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, grokOAuthHandler, cnProviderHandler, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler, errorPassthroughHandler, tlsFingerprintProfileHandler, pluginHandler, adminAPIKeyHandler, scheduledTestHandler, channelHandler, channelMonitorHandler, channelMonitorRequestTemplateHandler, contentModerationHandler, promptAdminHandler, paymentHandler, affiliateHandler, complianceHandler, auditLogHandler, upstreamBillingProbeService, ollamaCloudUsageService, openCodeGoUsageService, claudeResetCreditService)
usageRecordWorkerPool := service.NewUsageRecordWorkerPool(configConfig)
userMsgQueueCache := repository.NewUserMsgQueueCache(redisClient)
userMessageQueueService := service.ProvideUserMessageQueueService(userMsgQueueCache, rpmCache, configConfig)
@@ -48,6 +48,7 @@ func NewOAuthHandler(oauthService *service.OAuthService) *OAuthHandler {
// AccountHandler handles admin account management
type AccountHandler struct {
claudeResetCredits claudeResetReader
adminService service.AdminService
oauthService *service.OAuthService
openaiOAuthService *service.OpenAIOAuthService
@@ -0,0 +1,34 @@
package admin
import (
"context"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"strconv"
)
type claudeResetReader interface {
Query(context.Context, int64) (*service.ClaudeResetCredits, error)
}
func (h *AccountHandler) SetClaudeResetCreditService(s *service.ClaudeResetCreditService) {
h.claudeResetCredits = s
}
func (h *AccountHandler) ClaudeResetCredits(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil || id <= 0 {
response.BadRequest(c, "Invalid account ID")
return
}
if h.claudeResetCredits == nil {
response.Error(c, 503, "Claude reset service unavailable")
return
}
status, err := h.claudeResetCredits.Query(c.Request.Context(), id)
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, status)
}
+2
View File
@@ -50,10 +50,12 @@ func ProvideAdminHandlers(
upstreamBillingProbe *service.UpstreamBillingProbeService,
ollamaCloudUsage *service.OllamaCloudUsageService,
opencodeGoUsage *service.OpenCodeGoUsageService,
claudeResetCredits *service.ClaudeResetCreditService,
) *AdminHandlers {
accountHandler.SetUpstreamBillingProbeService(upstreamBillingProbe)
accountHandler.SetOllamaCloudUsageService(ollamaCloudUsage)
accountHandler.SetOpenCodeGoUsageService(opencodeGoUsage)
accountHandler.SetClaudeResetCreditService(claudeResetCredits)
return &AdminHandlers{
Dashboard: dashboardHandler,
User: userHandler,
+1
View File
@@ -367,6 +367,7 @@ func registerAccountRoutes(admin *gin.RouterGroup, h *handler.Handlers, stepUpAu
accounts.GET("/opencode-go-usage/settings", h.Admin.Account.GetOpenCodeGoUsageSettings)
accounts.PUT("/opencode-go-usage/settings", h.Admin.Account.UpdateOpenCodeGoUsageSettings)
accounts.GET("/:id", h.Admin.Account.GetByID)
accounts.GET("/:id/claude/reset-credits", h.Admin.Account.ClaudeResetCredits)
accounts.POST("", h.Admin.Account.Create)
accounts.POST("/:id/duplicate", h.Admin.Account.Duplicate)
accounts.POST("/check-mixed-channel", h.Admin.Account.CheckMixedChannel)
@@ -0,0 +1,204 @@
package service
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"regexp"
"strings"
"time"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/Wei-Shaw/sub2api/internal/pkg/httpclient"
)
var claudeResetGrantIDPattern = regexp.MustCompile(`^[a-z0-9_-]{1,40}$`)
const claudeResetUsageURL = "https://api.anthropic.com/api/oauth/usage?cedar_ember=1&skip_spend=1"
// ClaudeResetCredit is deliberately free of upstream grant and organization IDs.
type ClaudeResetCredit struct {
Label string `json:"label"`
ResetsLeft int `json:"resets_left"`
StartsAt *time.Time `json:"starts_at,omitempty"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
Clears []string `json:"clears"`
PercentUsed map[string]float64 `json:"percent_used"`
Blocking []string `json:"blocking"`
UseRequiresLimit bool `json:"use_requires_limit"`
Redeemable bool `json:"redeemable"`
}
type ClaudeResetCredits struct {
Eligible bool `json:"eligible"`
AvailableCount int `json:"available_count"`
Credits []ClaudeResetCredit `json:"credits"`
CooldownUntil *time.Time `json:"cooldown_until,omitempty"`
WeeklyResetsAt *time.Time `json:"weekly_resets_at,omitempty"`
FetchedAt time.Time `json:"fetched_at"`
}
type claudeResetGrant struct {
ID string `json:"id"`
Label string `json:"label"`
ResetsLeft int `json:"resets_left"`
StartsAt *time.Time `json:"starts_at"`
EndsAt *time.Time `json:"ends_at"`
Clears []string `json:"clears"`
Paused bool `json:"paused"`
UsableNow bool `json:"usable_now"`
UseRequiresLimit *bool `json:"use_requires_limit"`
PercentUsed map[string]float64 `json:"percent_used"`
Blocking []string `json:"blocking"`
}
type claudeResetBlock struct {
Eligible bool `json:"eligible"`
AtLimit bool `json:"at_limit"`
Grants []claudeResetGrant `json:"grants"`
NextGrantID string `json:"next_grant_id"`
CooldownUntil *time.Time `json:"cooldown_until"`
WeeklyResetsAt *time.Time `json:"weekly_resets_at"`
}
type claudeResetAccounts interface {
GetByID(context.Context, int64) (*Account, error)
}
type claudeResetTokens interface {
GetAccessToken(context.Context, *Account) (string, error)
}
type ClaudeResetCreditService struct {
accounts claudeResetAccounts
tokens claudeResetTokens
proxies ProxyRepository
settings *SettingService
do func(*http.Request, string) (*http.Response, error)
now func() time.Time
}
func NewClaudeResetCreditService(accounts AccountRepository, tokens *ClaudeTokenProvider, proxies ProxyRepository, settings *SettingService) *ClaudeResetCreditService {
s := &ClaudeResetCreditService{accounts: accounts, tokens: tokens, proxies: proxies, settings: settings, now: time.Now}
s.do = func(req *http.Request, proxy string) (*http.Response, error) {
client, err := httpclient.GetClient(httpclient.Options{ProxyURL: proxy, Timeout: 25 * time.Second, ValidateResolvedIP: true})
if err != nil {
return nil, err
}
// Never forward OAuth credentials across redirects, even to another public host.
isolated := *client
isolated.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
return isolated.Do(req)
}
return s
}
func (s *ClaudeResetCreditService) account(ctx context.Context, id int64) (*Account, string, string, error) {
a, err := s.accounts.GetByID(ctx, id)
if err != nil {
return nil, "", "", err
}
if a == nil || a.Platform != PlatformAnthropic || a.Type != AccountTypeOAuth {
return nil, "", "", infraerrors.BadRequest("CLAUDE_RESET_OAUTH_REQUIRED", "Claude OAuth account required")
}
profile := false
for _, scope := range strings.Fields(a.GetCredential("scope")) {
if scope == "user:profile" {
profile = true
}
}
if !profile {
return nil, "", "", infraerrors.BadRequest("CLAUDE_RESET_PROFILE_SCOPE_REQUIRED", "user:profile scope required")
}
proxy := ""
if a.ProxyID != nil {
p, e := s.proxies.GetByID(ctx, *a.ProxyID)
if e != nil || p == nil {
return nil, "", "", infraerrors.ServiceUnavailable("CLAUDE_RESET_PROXY_UNAVAILABLE", "account proxy unavailable")
}
proxy = p.URL()
}
token, err := s.tokens.GetAccessToken(ctx, a)
if err != nil || strings.TrimSpace(token) == "" {
return nil, "", "", infraerrors.ServiceUnavailable("CLAUDE_RESET_TOKEN_UNAVAILABLE", "OAuth token unavailable")
}
return a, token, proxy, nil
}
func (s *ClaudeResetCreditService) headers(ctx context.Context, req *http.Request, token string) {
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/json")
req.Header.Set("anthropic-beta", "oauth-2025-04-20")
req.Header.Set("x-app", "cli")
req.Header.Set("User-Agent", "claude-cli/"+s.settings.GetClaudeCodeClientVersion(ctx)+" (external, cli)")
}
func (s *ClaudeResetCreditService) query(ctx context.Context, id int64) (*ClaudeResetCredits, error) {
_, token, proxy, err := s.account(ctx, id)
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, claudeResetUsageURL, nil)
if err != nil {
return nil, err
}
s.headers(ctx, req, token)
resp, err := s.do(req, proxy)
if err != nil {
return nil, infraerrors.ServiceUnavailable("CLAUDE_RESET_QUERY_FAILED", "reset status request failed")
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return nil, infraerrors.New(http.StatusBadGateway, "CLAUDE_RESET_QUERY_FAILED", fmt.Sprintf("reset status upstream HTTP %d", resp.StatusCode))
}
var envelope map[string]json.RawMessage
if err = json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&envelope); err != nil || envelope == nil {
return nil, infraerrors.New(http.StatusBadGateway, "CLAUDE_RESET_STATUS_INVALID", "invalid reset status")
}
if _, ok := envelope["error"]; ok {
return nil, infraerrors.New(http.StatusBadGateway, "CLAUDE_RESET_STATUS_INVALID", "invalid reset status")
}
var block *claudeResetBlock
raw, present := envelope["cedar_ember"]
if present && string(raw) != "null" {
if err = json.Unmarshal(raw, &block); err != nil || block == nil || block.Grants == nil {
return nil, infraerrors.New(http.StatusBadGateway, "CLAUDE_RESET_STATUS_INVALID", "invalid reset grants")
}
}
return projectClaudeResetCredits(block, s.now()), nil
}
func (s *ClaudeResetCreditService) Query(ctx context.Context, id int64) (*ClaudeResetCredits, error) {
return s.query(ctx, id)
}
func projectClaudeResetCredits(b *claudeResetBlock, now time.Time) *ClaudeResetCredits {
r := &ClaudeResetCredits{Credits: []ClaudeResetCredit{}, FetchedAt: now.UTC()}
if b == nil {
return r
}
r.Eligible = b.Eligible
if b.CooldownUntil != nil && now.Before(*b.CooldownUntil) {
r.CooldownUntil = b.CooldownUntil
}
r.WeeklyResetsAt = b.WeeklyResetsAt
for _, g := range b.Grants {
if !claudeResetGrantIDPattern.MatchString(g.ID) || len(g.Clears) == 0 || g.ResetsLeft <= 0 || g.Paused || (g.StartsAt != nil && now.Before(*g.StartsAt)) || (g.EndsAt != nil && !now.Before(*g.EndsAt)) {
continue
}
requires := g.UseRequiresLimit == nil || *g.UseRequiresLimit
usable := b.Eligible && g.UsableNow && g.ID == b.NextGrantID && (!requires || b.AtLimit) && len(g.Blocking) == 0 && (b.CooldownUntil == nil || !now.Before(*b.CooldownUntil))
used := map[string]float64{}
for k, v := range g.PercentUsed {
if v >= 0 && v <= 100 {
used[k] = v
}
}
r.Credits = append(r.Credits, ClaudeResetCredit{Label: g.Label, ResetsLeft: g.ResetsLeft, StartsAt: g.StartsAt, ExpiresAt: g.EndsAt, Clears: g.Clears, PercentUsed: used, Blocking: g.Blocking, UseRequiresLimit: requires, Redeemable: usable})
if usable {
r.AvailableCount += g.ResetsLeft
}
}
return r
}
@@ -0,0 +1,114 @@
package service
import (
"context"
"encoding/json"
"github.com/stretchr/testify/require"
"io"
"net/http"
"strings"
"testing"
"time"
)
type resetAccountStub struct{ account *Account }
func (s resetAccountStub) GetByID(context.Context, int64) (*Account, error) { return s.account, nil }
type resetTokenStub struct{}
func (resetTokenStub) GetAccessToken(context.Context, *Account) (string, error) {
return "synthetic-token", nil
}
func TestClaudeResetStatusNativeContract(t *testing.T) {
now := time.Date(2026, 9, 25, 0, 0, 0, 0, time.UTC)
s := &ClaudeResetCreditService{accounts: resetAccountStub{&Account{ID: 1, Platform: PlatformAnthropic, Type: AccountTypeOAuth, Credentials: map[string]any{"scope": "user:profile user:inference"}}}, tokens: resetTokenStub{}, now: func() time.Time { return now }}
s.do = func(r *http.Request, p string) (*http.Response, error) {
require.Equal(t, claudeResetUsageURL, r.URL.String())
require.Equal(t, "GET", r.Method)
require.Equal(t, "Bearer synthetic-token", r.Header.Get("Authorization"))
require.Contains(t, r.Header.Get("User-Agent"), "claude-cli/")
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(`{"cedar_ember":{"eligible":true,"at_limit":false,"next_grant_id":"launch","grants":[{"id":"launch","resets_left":2,"usable_now":true,"use_requires_limit":false,"ends_at":"2026-10-22T00:00:00Z","clears":["five_hour"],"percent_used":{"five_hour":65},"blocking":[]},{"id":"later","clears":["five_hour"],"resets_left":1,"usable_now":true,"use_requires_limit":false}]}}`))}, nil
}
r, e := s.Query(context.Background(), 1)
require.NoError(t, e)
require.Equal(t, 2, r.AvailableCount)
require.True(t, r.Credits[0].Redeemable)
require.False(t, r.Credits[1].Redeemable)
b, e := json.Marshal(r)
require.NoError(t, e)
require.NotContains(t, string(b), `"id"`)
require.NotContains(t, string(b), "launch")
require.NotContains(t, string(b), "synthetic-token")
require.NotContains(t, string(b), "selection_token")
}
func TestClaudeResetPastCooldownIsCleared(t *testing.T) {
now := time.Now()
past := now.Add(-time.Minute)
no := false
g := claudeResetGrant{ID: "grant", ResetsLeft: 1, UsableNow: true, UseRequiresLimit: &no, Clears: []string{"five_hour"}}
r := projectClaudeResetCredits(&claudeResetBlock{Eligible: true, NextGrantID: g.ID, CooldownUntil: &past, Grants: []claudeResetGrant{g}}, now)
require.Nil(t, r.CooldownUntil)
require.Equal(t, 1, r.AvailableCount)
future := now.Add(time.Hour)
r = projectClaudeResetCredits(&claudeResetBlock{Eligible: true, NextGrantID: g.ID, CooldownUntil: &future, Grants: []claudeResetGrant{g}}, now)
require.Equal(t, &future, r.CooldownUntil)
}
func TestClaudeResetEligibilityFailClosed(t *testing.T) {
now := time.Now()
past := now.Add(-time.Minute)
future := now.Add(time.Hour)
no := false
base := claudeResetGrant{ID: "grant", ResetsLeft: 1, UsableNow: true, UseRequiresLimit: &no, Clears: []string{"five_hour"}}
for _, name := range []string{"paused", "expired", "future", "cooldown", "requires-limit", "blocking", "ineligible", "spent", "not-next"} {
t.Run(name, func(t *testing.T) {
g := base
b := &claudeResetBlock{Eligible: true, NextGrantID: g.ID}
switch name {
case "paused":
g.Paused = true
case "expired":
g.EndsAt = &past
case "future":
g.StartsAt = &future
case "cooldown":
b.CooldownUntil = &future
case "requires-limit":
g.UseRequiresLimit = nil
case "blocking":
g.Blocking = []string{"seven_day"}
case "ineligible":
b.Eligible = false
case "spent":
g.ResetsLeft = 0
case "not-next":
b.NextGrantID = "other"
}
b.Grants = []claudeResetGrant{g}
r := projectClaudeResetCredits(b, now)
require.Zero(t, r.AvailableCount)
})
}
}
func TestClaudeResetStatusRejectsMissingScopeBeforeNetwork(t *testing.T) {
s := &ClaudeResetCreditService{accounts: resetAccountStub{&Account{Platform: PlatformAnthropic, Type: AccountTypeOAuth}}, do: func(*http.Request, string) (*http.Response, error) { t.Fatal("network called"); return nil, nil }}
_, e := s.Query(context.Background(), 1)
require.Error(t, e)
}
func TestClaudeResetMalformedAndAbsent(t *testing.T) {
for _, body := range []string{`{}`, `{"five_hour":{},"cedar_ember":null}`, `{"cedar_ember":{"eligible":true}}`, `{"error":{"message":"private upstream data"}}`, `not json`} {
t.Run(body, func(t *testing.T) {
s := &ClaudeResetCreditService{accounts: resetAccountStub{&Account{Platform: PlatformAnthropic, Type: AccountTypeOAuth, Credentials: map[string]any{"scope": "user:profile"}}}, tokens: resetTokenStub{}, now: time.Now, do: func(*http.Request, string) (*http.Response, error) {
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body))}, nil
}}
r, e := s.Query(context.Background(), 1)
if body == `{}` || strings.Contains(body, `"cedar_ember":null`) {
require.NoError(t, e)
require.Empty(t, r.Credits)
} else {
require.Error(t, e)
require.NotContains(t, e.Error(), "private upstream data")
}
})
}
}
+1
View File
@@ -880,6 +880,7 @@ var ProviderSet = wire.NewSet(
ProvideBatchImageWorkerRuntime,
wire.Bind(new(AccountRuntimeBlocker), new(*OpenAIGatewayService)),
NewOAuthService,
NewClaudeResetCreditService,
ProvideOpenAIOAuthService,
ProvideGrokOAuthService,
wire.Bind(new(GrokOAuthTokenService), new(*GrokOAuthService)),
@@ -0,0 +1,27 @@
import { apiClient } from '../client'
export interface ClaudeResetCredit {
label: string
resets_left: number
starts_at?: string
expires_at?: string
clears: string[]
percent_used: Record<string, number>
blocking: string[]
use_requires_limit: boolean
redeemable: boolean
}
export interface ClaudeResetCredits {
eligible: boolean
available_count: number
credits: ClaudeResetCredit[]
cooldown_until?: string
weekly_resets_at?: string
fetched_at: string
}
export async function getClaudeResetCredits(id: number): Promise<ClaudeResetCredits> {
const { data } = await apiClient.get<ClaudeResetCredits>(`/admin/accounts/${id}/claude/reset-credits`)
return data
}
@@ -77,8 +77,19 @@
color="amber"
/>
<!-- Passive sampling label + active query button -->
<div class="flex items-center gap-1.5 mt-0.5">
</div>
<!-- No data yet -->
<div v-else class="space-y-1">
<div class="text-xs text-gray-400">-</div>
</div>
<!--
One stable instance for every usage state, so a reset-credit query started
while usage is still loading survives the usage response. The local query
button shares its row once usage data exists.
-->
<ClaudeResetCreditsCell :account="account" class="mt-1">
<template v-if="usageInfo" #pre-actions>
<span
v-if="usageInfo.source === 'passive'"
class="text-[9px] text-gray-400 dark:text-gray-500 italic"
@@ -87,7 +98,7 @@
</span>
<button
type="button"
class="inline-flex items-center gap-0.5 rounded px-1.5 py-0.5 text-[9px] font-medium text-blue-600 hover:bg-blue-50 dark:text-blue-400 dark:hover:bg-blue-900/30 transition-colors"
class="inline-flex items-center gap-0.5 rounded px-1.5 py-0.5 text-[10px] font-medium text-blue-600 hover:bg-blue-50 dark:text-blue-400 dark:hover:bg-blue-900/30 transition-colors disabled:cursor-not-allowed disabled:opacity-50"
:disabled="activeQueryLoading"
@click="loadActiveUsage"
>
@@ -107,13 +118,8 @@
</svg>
{{ t('admin.accounts.usageWindow.activeQuery') }}
</button>
</div>
</div>
<!-- No data yet -->
<div v-else class="space-y-1">
<div class="text-xs text-gray-400">-</div>
</div>
</template>
</ClaudeResetCreditsCell>
</template>
<!-- OpenAI OAuth accounts: single source from /usage API -->
@@ -677,6 +683,7 @@ import { enqueueUsageRequest } from '@/utils/usageLoadQueue'
import { formatCompactNumber } from '@/utils/format'
import UsageProgressBar from './UsageProgressBar.vue'
import AccountQuotaInfo from './AccountQuotaInfo.vue'
import ClaudeResetCreditsCell from './ClaudeResetCreditsCell.vue'
import OpenAIQuotaResetCell from './OpenAIQuotaResetCell.vue'
import GrokQuotaProbeCell from './GrokQuotaProbeCell.vue'
import CNProviderQuotaCell from './CNProviderQuotaCell.vue'
@@ -0,0 +1,148 @@
<template>
<div class="space-y-1">
<!--
Same action row layout as OpenAIQuotaResetCell: the parent's local
"查询" button is passed in via #pre-actions so related buttons share one
row. The reset count only shows for Anthropic OAuth accounts; the slot always
renders. This cell is read-only — it never redeems a reset.
-->
<div class="flex flex-wrap items-center gap-1.5">
<slot name="pre-actions" />
<button
v-if="visible"
type="button"
data-testid="claude-reset-count"
class="inline-flex items-center gap-0.5 rounded px-1.5 py-0.5 text-[10px] font-medium text-blue-600 transition-colors hover:bg-blue-50 disabled:cursor-not-allowed disabled:opacity-50 dark:text-blue-400 dark:hover:bg-blue-900/30"
:disabled="loading"
:title="countButtonTitle"
@click="refresh"
>
<svg
class="h-2.5 w-2.5"
:class="{ 'animate-spin': loading }"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
{{ t('admin.accounts.claudeResetCredits.count') }}<span v-if="status" class="ml-0.5 tabular-nums">{{ totalResets }}</span>
</button>
</div>
<div v-if="visible && status && (primaryCredit || !status.eligible || cooldownActive)" class="flex flex-wrap items-center gap-1">
<span
v-if="primaryCredit?.expires_at"
data-testid="claude-reset-expiry"
class="inline-flex max-w-full items-center rounded bg-gray-100 px-1.5 py-0.5 text-[10px] leading-4 text-gray-600 tabular-nums dark:bg-dark-800 dark:text-gray-300"
:title="creditTitle"
>
{{ t('admin.accounts.claudeResetCredits.expiresAt', { time: formatTime(primaryCredit.expires_at, 'short') }) }}
</span>
<span
v-if="status.eligible && totalResets > 0 && status.available_count === 0"
data-testid="claude-reset-not-usable"
class="text-[10px] text-gray-500 dark:text-gray-400"
>
{{ t('admin.accounts.claudeResetCredits.notUsableNow') }}
</span>
<span v-if="!status.eligible" class="text-[10px] text-amber-600 dark:text-amber-400">
{{ t('admin.accounts.claudeResetCredits.ineligible') }}
</span>
<span v-if="cooldownActive" data-testid="claude-reset-cooldown" class="text-[10px] text-amber-600 dark:text-amber-400">
{{ t('admin.accounts.claudeResetCredits.cooldown', { time: formatTime(status.cooldown_until!, 'short') }) }}
</span>
</div>
<div v-if="visible && error" role="alert" class="text-[10px] text-red-600 dark:text-red-400">
{{ t('admin.accounts.claudeResetCredits.error') }}
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import type { Account } from '@/types'
import { getClaudeResetCredits, type ClaudeResetCredits } from '@/api/admin/claudeResetCredits'
const props = defineProps<{ account: Account }>()
const { t } = useI18n()
const status = ref<ClaudeResetCredits | null>(null)
const loading = ref(false)
const error = ref(false)
let generation = 0
const visible = computed(() => props.account.platform === 'anthropic' && props.account.type === 'oauth')
watch(() => [props.account.id, props.account.platform, props.account.type], () => { generation++; status.value = null; loading.value = false; error.value = false })
// 与 OpenAIQuotaResetCell 的到期时间格式保持一致
const formatTime = (value: string, style: 'short' | 'full'): string => {
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
const options: Intl.DateTimeFormatOptions = { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }
if (style === 'full') options.year = 'numeric'
return new Intl.DateTimeFormat(undefined, options).format(date)
}
// 与 Codex 的「次数」一致:显示持有的剩余次数,而不是此刻可兑换的次数
const totalResets = computed(() => (status.value?.credits ?? []).reduce((sum, c) => sum + c.resets_left, 0))
// 优先展示下一张会被使用的券(仅它可能 redeemable),否则取最早到期的一张
const primaryCredit = computed(() => {
const credits = status.value?.credits ?? []
return credits.find(c => c.redeemable) ?? [...credits].sort((a, b) => expiryMs(a.expires_at) - expiryMs(b.expires_at))[0] ?? null
})
// 缺失或无法解析的到期时间排在最后
function expiryMs(value?: string): number {
const ms = value ? new Date(value).getTime() : NaN
return Number.isNaN(ms) ? Number.POSITIVE_INFINITY : ms
}
// 仅在冷却时间仍在未来时提示(后端已清理过期值,这里防御性再判断一次)
const cooldownActive = computed(() => {
const until = status.value?.cooldown_until
return !!until && new Date(until).getTime() > Date.now()
})
const creditTitle = computed(() => {
const credit = primaryCredit.value
if (!credit) return ''
const lines = [credit.label]
if (credit.expires_at) lines.push(t('admin.accounts.claudeResetCredits.expiresAtFull', { time: formatTime(credit.expires_at, 'full') }))
if (credit.clears.length) lines.push(t('admin.accounts.claudeResetCredits.clears', { windows: credit.clears.join(', ') }))
if (credit.use_requires_limit) lines.push(t('admin.accounts.claudeResetCredits.requiresLimit'))
return lines.join('\n')
})
const countButtonTitle = computed(() => {
if (!status.value) return t('admin.accounts.claudeResetCredits.countTooltipLoad')
return [
t('admin.accounts.claudeResetCredits.countTooltipRefresh'),
t('admin.accounts.claudeResetCredits.fetched', { time: formatTime(status.value.fetched_at, 'full') })
].join('\n')
})
async function refresh() {
if (loading.value) return
const current = ++generation
loading.value = true
error.value = false
try {
const result = await getClaudeResetCredits(props.account.id)
if (current === generation) status.value = result
} catch {
if (current === generation) { error.value = true; status.value = null }
} finally {
if (current === generation) loading.value = false
}
}
</script>
@@ -0,0 +1,110 @@
import { mount, flushPromises } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import ClaudeResetCreditsCell from '../ClaudeResetCreditsCell.vue'
import type { Account } from '@/types'
const getCredits = vi.hoisted(() => vi.fn())
vi.mock('@/api/admin/claudeResetCredits', () => ({ getClaudeResetCredits: getCredits }))
vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (key: string) => key }) }))
const account = { id: 1, platform: 'anthropic', type: 'oauth' } as Account
const credit = {
label: 'Launch reset', resets_left: 1,
expires_at: '2026-10-22T16:00:00Z', clears: ['five_hour', 'seven_day'],
percent_used: {}, blocking: [], use_requires_limit: false, redeemable: true
}
const snapshot = { eligible: true, available_count: 1, credits: [credit], fetched_at: '2026-09-25T00:00:00Z' }
const countButton = (wrapper: ReturnType<typeof mount>) => wrapper.find('[data-testid="claude-reset-count"]')
describe('Claude reset credit status', () => {
beforeEach(() => getCredits.mockReset())
it('queries only on explicit request and shows count and expiry', async () => {
getCredits.mockResolvedValue(snapshot)
const wrapper = mount(ClaudeResetCreditsCell, { props: { account } })
expect(getCredits).not.toHaveBeenCalled()
expect(countButton(wrapper).text()).toBe('admin.accounts.claudeResetCredits.count')
await countButton(wrapper).trigger('click')
await flushPromises()
expect(getCredits).toHaveBeenCalledWith(1)
expect(countButton(wrapper).text()).toBe('admin.accounts.claudeResetCredits.count1')
const expiry = wrapper.get('[data-testid="claude-reset-expiry"]')
expect(expiry.attributes('title')).toContain('Launch reset')
})
it('counts held resets and prefers the next redeemable grant', async () => {
const later = { ...credit, label: 'Later', expires_at: '2026-12-01T00:00:00Z', redeemable: false }
const next = { ...credit, label: 'Next', expires_at: '2026-11-01T00:00:00Z', redeemable: true }
getCredits.mockResolvedValue({ ...snapshot, available_count: 1, credits: [later, next] })
const wrapper = mount(ClaudeResetCreditsCell, { props: { account } })
await countButton(wrapper).trigger('click')
await flushPromises()
expect(countButton(wrapper).text()).toBe('admin.accounts.claudeResetCredits.count2')
expect(wrapper.get('[data-testid="claude-reset-expiry"]').attributes('title')).toContain('Next')
expect(wrapper.find('[data-testid="claude-reset-not-usable"]').exists()).toBe(false)
})
it('flags held resets that cannot be used yet', async () => {
const waiting = { ...credit, use_requires_limit: true, redeemable: false }
getCredits.mockResolvedValue({ ...snapshot, available_count: 0, credits: [waiting] })
const wrapper = mount(ClaudeResetCreditsCell, { props: { account } })
await countButton(wrapper).trigger('click')
await flushPromises()
expect(countButton(wrapper).text()).toBe('admin.accounts.claudeResetCredits.count1')
expect(wrapper.find('[data-testid="claude-reset-not-usable"]').exists()).toBe(true)
})
it('offers no redeem action', () => {
const wrapper = mount(ClaudeResetCreditsCell, { props: { account } })
expect(wrapper.findAll('button')).toHaveLength(1)
})
it('keeps slot content for setup tokens and discards responses after account changes', async () => {
let resolve!: (value: typeof snapshot) => void
getCredits.mockReturnValue(new Promise(r => { resolve = r }))
const wrapper = mount(ClaudeResetCreditsCell, {
props: { account },
slots: { 'pre-actions': '<button data-testid="local-query">q</button>' }
})
await countButton(wrapper).trigger('click')
await wrapper.setProps({ account: { ...account, id: 2, type: 'setup-token' } })
resolve(snapshot)
await flushPromises()
expect(countButton(wrapper).exists()).toBe(false)
expect(wrapper.find('[data-testid="local-query"]').exists()).toBe(true)
expect(wrapper.find('[data-testid="claude-reset-expiry"]').exists()).toBe(false)
})
it('falls back to the earliest parsed expiry across offsets, invalid last', async () => {
const a = { ...credit, label: 'Tokyo', expires_at: '2026-11-01T08:00:00+09:00', redeemable: false }
const b = { ...credit, label: 'Chicago', expires_at: '2026-10-31T20:00:00-05:00', redeemable: false }
const bad = { ...credit, label: 'Bad', expires_at: 'not-a-date', redeemable: false }
const none = { ...credit, label: 'None', expires_at: undefined, redeemable: false }
getCredits.mockResolvedValue({ ...snapshot, available_count: 0, credits: [none, bad, b, a] })
const wrapper = mount(ClaudeResetCreditsCell, { props: { account } })
await countButton(wrapper).trigger('click')
await flushPromises()
expect(wrapper.get('[data-testid="claude-reset-expiry"]').attributes('title')).toContain('Tokyo')
expect(wrapper.get('[data-testid="claude-reset-expiry"]').attributes('title')).not.toContain('Chicago')
})
it('hides a cooldown hint that is already in the past', async () => {
getCredits.mockResolvedValue({ ...snapshot, cooldown_until: '2000-01-01T00:00:00Z' })
const wrapper = mount(ClaudeResetCreditsCell, { props: { account } })
await countButton(wrapper).trigger('click')
await flushPromises()
expect(wrapper.find('[data-testid="claude-reset-cooldown"]').exists()).toBe(false)
})
it('discards in-flight responses when the same account changes type', async () => {
let resolve!: (value: typeof snapshot) => void
getCredits.mockReturnValue(new Promise(r => { resolve = r }))
const wrapper = mount(ClaudeResetCreditsCell, { props: { account } })
await countButton(wrapper).trigger('click')
await wrapper.setProps({ account: { ...account, type: 'setup-token' } })
await wrapper.setProps({ account: { ...account } })
resolve(snapshot)
await flushPromises()
expect(countButton(wrapper).text()).toBe('admin.accounts.claudeResetCredits.count')
expect(countButton(wrapper).attributes('disabled')).toBeUndefined()
expect(wrapper.find('[data-testid="claude-reset-expiry"]').exists()).toBe(false)
})
})
+15 -1
View File
@@ -926,7 +926,21 @@ export default {
autoPause5hDisabled: 'Disable 5h auto-pause',
autoPause7dDisabled: 'Disable 7d auto-pause',
autoPauseDisabledHint: 'When enabled, this account is never auto-paused (even if a global default threshold is configured).',
autoResetCredit: {
claudeResetCredits: {
count: 'Resets',
countTooltipLoad: 'Check remaining Claude resets (read-only, never consumes one)',
countTooltipRefresh: 'Refresh remaining Claude resets (read-only, never consumes one)',
fetched: 'Checked at {time}',
error: 'Could not check reset credits',
ineligible: 'This account cannot use resets right now',
cooldown: 'Cooldown until {time}',
expiresAt: 'Expires {time}',
expiresAtFull: 'Reset credit expires at: {time}',
clears: 'Clears windows: {windows}',
notUsableNow: 'Not usable now',
requiresLimit: 'Usable only after hitting a limit'
},
autoResetCredit: {
title: 'Automatically use reset credits',
hint: 'Uses the earliest-expiring available credit only when actual usage reaches a threshold. Off by default; the account remains paused if no credit is available or reset fails.',
threshold5h: '5h auto-reset threshold (%)',
+15 -1
View File
@@ -1027,7 +1027,21 @@ export default {
autoPause5hDisabled: '禁用 5h 自动暂停',
autoPause7dDisabled: '禁用 7d 自动暂停',
autoPauseDisabledHint: '开启后该账号永不进入自动暂停(即使全局默认阈值已配置)。',
autoResetCredit: {
claudeResetCredits: {
count: '次数',
countTooltipLoad: '点击查询 Claude 剩余重置次数(只读,不会消耗)',
countTooltipRefresh: '点击刷新 Claude 剩余重置次数(只读,不会消耗)',
fetched: '查询时间:{time}',
error: '无法查询重置次数',
ineligible: '此账号当前不可使用重置',
cooldown: '冷却至 {time}',
expiresAt: '到期 {time}',
expiresAtFull: '重置次数到期时间:{time}',
clears: '可清除窗口:{windows}',
notUsableNow: '暂不可用',
requiresLimit: '需达到限额后才能使用'
},
autoResetCredit: {
title: '自动使用重置卡',
hint: '仅在实际用量达到阈值时使用最早到期的可用卡;默认关闭。无卡或失败时账号保持暂停。',
threshold5h: '5h 自动用卡阈值(%)',