show reset credit expiration

This commit is contained in:
infinityf4p
2026-07-02 13:27:58 +08:00
parent 7dc7cfce1d
commit dfb36e45f1
7 changed files with 442 additions and 4 deletions
@@ -1,9 +1,11 @@
package service
import (
"bytes"
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"log/slog"
"net/http"
@@ -11,6 +13,7 @@ import (
"time"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/imroc/req/v3"
)
// ErrSparkShadowResetNotSupported is returned when ResetCredit is called on a
@@ -23,8 +26,10 @@ var ErrSparkShadowResetNotSupported = infraerrors.New(http.StatusConflict, "SPAR
// Endpoints used by the OpenAI/ChatGPT/Codex quota query and reset feature.
const (
chatGPTUsageURL = "https://chatgpt.com/backend-api/wham/usage"
chatGPTRateLimitCreditsURL = "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits"
chatGPTRateLimitResetURL = "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits/consume"
openaiQuotaUpstreamTimeout = 20 * time.Second
openaiQuotaCodexBeta = "codex-1"
openaiQuotaCodexOriginator = "Codex Desktop"
openaiQuotaCodexLanguageTag = "zh-CN"
openaiQuotaSecFetchSite = "none"
@@ -57,10 +62,17 @@ type OpenAIAdditionalRateLimit struct {
RateLimit *OpenAIRateLimit `json:"rate_limit,omitempty"`
}
// OpenAIRateLimitResetCreditDetail is the sanitized metadata surfaced for one
// available reset credit. Do not add upstream ids or tokens here.
type OpenAIRateLimitResetCreditDetail struct {
ExpiresAt string `json:"expires_at,omitempty"`
}
// OpenAIRateLimitResetCredits captures the "available_count" surfaced for the
// rate_limit_reset_credit grant type, which the reset action consumes.
type OpenAIRateLimitResetCredits struct {
AvailableCount int `json:"available_count"`
AvailableCount int `json:"available_count"`
Credits []OpenAIRateLimitResetCreditDetail `json:"credits,omitempty"`
}
// OpenAIQuotaUsage is the typed projection of /wham/usage we expose to the UI.
@@ -159,9 +171,34 @@ func (s *OpenAIQuotaService) QueryUsage(ctx context.Context, accountID int64) (*
}
payload.FetchedAt = time.Now().Unix()
if payload.RateLimitResetCredits != nil && payload.RateLimitResetCredits.AvailableCount > 0 {
payload.RateLimitResetCredits.Credits = s.queryResetCreditDetails(callCtx, client, accessToken, chatGPTAccountID, fedRAMP, accountID)
}
return &payload, nil
}
func (s *OpenAIQuotaService) queryResetCreditDetails(ctx context.Context, client *req.Client, accessToken, chatGPTAccountID string, fedRAMP bool, accountID int64) []OpenAIRateLimitResetCreditDetail {
resp, err := client.R().
SetContext(ctx).
SetHeaders(buildCodexCommonHeaders(accessToken, chatGPTAccountID, fedRAMP)).
Get(chatGPTRateLimitCreditsURL)
if err != nil {
slog.Warn("openai_quota_reset_credit_details_failed", "account_id", accountID, "error", err)
return nil
}
if !resp.IsSuccessState() {
slog.Warn("openai_quota_reset_credit_details_failed", "account_id", accountID, "status", resp.StatusCode)
return nil
}
credits, err := parseOpenAIRateLimitResetCreditDetails(resp.Bytes())
if err != nil {
slog.Warn("openai_quota_reset_credit_details_parse_failed", "account_id", accountID, "error", err)
return nil
}
return credits
}
// ResetCredit consumes one rate_limit_reset_credit for the given OpenAI account.
// The redeem_request_id is auto-generated (uuid-like) — upstream uses it for
// idempotency. Returns the consumed credit metadata so the UI can refresh.
@@ -306,6 +343,7 @@ func buildCodexCommonHeaders(accessToken, chatGPTAccountID string, fedRAMP bool)
headers := map[string]string{
"authorization": "Bearer " + accessToken,
"chatgpt-account-id": chatGPTAccountID,
"openai-beta": openaiQuotaCodexBeta,
"oai-language": openaiQuotaCodexLanguageTag,
"originator": openaiQuotaCodexOriginator,
"accept": "application/json",
@@ -334,6 +372,65 @@ func generateRedeemRequestID() (string, error) {
return fmt.Sprintf("%s-%s-%s-%s-%s", hexStr[0:8], hexStr[8:12], hexStr[12:16], hexStr[16:20], hexStr[20:]), nil
}
type openAIRateLimitResetCreditDetailPayload struct {
ExpiresAt string `json:"expires_at,omitempty"`
ExpiresAtCamel string `json:"expiresAt,omitempty"`
}
type openAIRateLimitResetCreditDetailsPayload struct {
Credits []openAIRateLimitResetCreditDetailPayload `json:"credits,omitempty"`
RateLimitResetCredits []openAIRateLimitResetCreditDetailPayload `json:"rate_limit_reset_credits,omitempty"`
Items []openAIRateLimitResetCreditDetailPayload `json:"items,omitempty"`
Data []openAIRateLimitResetCreditDetailPayload `json:"data,omitempty"`
}
func parseOpenAIRateLimitResetCreditDetails(body []byte) ([]OpenAIRateLimitResetCreditDetail, error) {
trimmed := bytes.TrimSpace(body)
if len(trimmed) == 0 {
return nil, nil
}
var rawCredits []openAIRateLimitResetCreditDetailPayload
if trimmed[0] == '[' {
if err := json.Unmarshal(trimmed, &rawCredits); err != nil {
return nil, err
}
} else {
var payload openAIRateLimitResetCreditDetailsPayload
if err := json.Unmarshal(trimmed, &payload); err != nil {
return nil, err
}
rawCredits = firstNonEmptyResetCreditPayload(
payload.Credits,
payload.RateLimitResetCredits,
payload.Items,
payload.Data,
)
}
credits := make([]OpenAIRateLimitResetCreditDetail, 0, len(rawCredits))
for _, raw := range rawCredits {
expiresAt := strings.TrimSpace(raw.ExpiresAt)
if expiresAt == "" {
expiresAt = strings.TrimSpace(raw.ExpiresAtCamel)
}
if expiresAt == "" {
continue
}
credits = append(credits, OpenAIRateLimitResetCreditDetail{ExpiresAt: expiresAt})
}
return credits, nil
}
func firstNonEmptyResetCreditPayload(lists ...[]openAIRateLimitResetCreditDetailPayload) []openAIRateLimitResetCreditDetailPayload {
for _, list := range lists {
if len(list) > 0 {
return list
}
}
return nil
}
// buildCodexSparkWindowExtraUpdates extracts Codex Spark usage windows from the
// /wham/usage response body's additional_rate_limits, matching the entry with
// MeteredFeature == "codex_bengalfox". It produces plain codex_* keys (NOT the
@@ -214,6 +214,154 @@ func TestPrepareUpstreamCallShadowResolve(t *testing.T) {
"prepareUpstreamCall should use parent's chatgpt_account_id after shadow resolve")
}
func TestParseOpenAIRateLimitResetCreditDetails_CompatibleContainers(t *testing.T) {
tests := []struct {
name string
body string
want []string
}{
{
name: "credits",
body: `{"credits":[{"id":"secret-id","expires_at":"2026-07-03T04:05:06Z"}]}`,
want: []string{"2026-07-03T04:05:06Z"},
},
{
name: "rate limit reset credits",
body: `{"rate_limit_reset_credits":[{"expiresAt":"2026-07-04T04:05:06Z"}]}`,
want: []string{"2026-07-04T04:05:06Z"},
},
{
name: "items",
body: `{"items":[{"expires_at":"2026-07-05T04:05:06Z"}]}`,
want: []string{"2026-07-05T04:05:06Z"},
},
{
name: "data",
body: `{"data":[{"expires_at":"2026-07-06T04:05:06Z"}]}`,
want: []string{"2026-07-06T04:05:06Z"},
},
{
name: "array",
body: `[{"expires_at":"2026-07-07T04:05:06Z"}]`,
want: []string{"2026-07-07T04:05:06Z"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := parseOpenAIRateLimitResetCreditDetails([]byte(tt.body))
require.NoError(t, err)
require.Len(t, got, len(tt.want))
for i := range tt.want {
require.Equal(t, tt.want[i], got[i].ExpiresAt)
}
encoded, err := json.Marshal(got)
require.NoError(t, err)
require.NotContains(t, string(encoded), "secret-id")
})
}
}
func TestQueryUsageIncludesResetCreditExpirations_EndToEnd(t *testing.T) {
ctx := context.Background()
account := &Account{
ID: 100,
Platform: PlatformOpenAI,
Type: AccountTypeOAuth,
Status: StatusActive,
Credentials: map[string]any{
"chatgpt_account_id": "org-parent123",
},
}
repo := &stubQuotaAccountRepo{accounts: map[int64]*Account{100: account}}
tokenCache := &stubQuotaTokenCache{tokens: map[string]string{
OpenAITokenCacheKey(account): "fake-token",
}}
tokenProvider := NewOpenAITokenProvider(repo, tokenCache, nil)
var capturedBeta string
var detailCalls int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("content-type", "application/json")
switch r.URL.Path {
case "/backend-api/wham/usage":
_ = json.NewEncoder(w).Encode(OpenAIQuotaUsage{
RateLimitResetCredits: &OpenAIRateLimitResetCredits{AvailableCount: 2},
})
case "/backend-api/wham/rate-limit-reset-credits":
detailCalls++
capturedBeta = r.Header.Get("OpenAI-Beta")
require.Equal(t, "org-parent123", r.Header.Get("ChatGPT-Account-ID"))
_, _ = w.Write([]byte(`{"credits":[{"id":"secret-credit-id","expires_at":"2026-07-03T04:05:06Z"},{"expiresAt":"2026-07-04T04:05:06Z"}]}`))
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
svc := NewOpenAIQuotaService(repo, nil, tokenProvider, newQuotaRedirectingFactory(srv))
usage, err := svc.QueryUsage(ctx, 100)
require.NoError(t, err)
require.NotNil(t, usage)
require.NotNil(t, usage.RateLimitResetCredits)
require.Equal(t, 2, usage.RateLimitResetCredits.AvailableCount)
require.Equal(t, 1, detailCalls)
require.Equal(t, openaiQuotaCodexBeta, capturedBeta)
require.Equal(t, []OpenAIRateLimitResetCreditDetail{
{ExpiresAt: "2026-07-03T04:05:06Z"},
{ExpiresAt: "2026-07-04T04:05:06Z"},
}, usage.RateLimitResetCredits.Credits)
encoded, err := json.Marshal(usage)
require.NoError(t, err)
require.NotContains(t, string(encoded), "secret-credit-id")
}
func TestQueryUsageResetCreditDetails401NonFatal(t *testing.T) {
ctx := context.Background()
account := &Account{
ID: 100,
Platform: PlatformOpenAI,
Type: AccountTypeOAuth,
Status: StatusActive,
Credentials: map[string]any{
"chatgpt_account_id": "org-parent123",
},
}
repo := &stubQuotaAccountRepo{accounts: map[int64]*Account{100: account}}
tokenCache := &stubQuotaTokenCache{tokens: map[string]string{
OpenAITokenCacheKey(account): "fake-token",
}}
tokenProvider := NewOpenAITokenProvider(repo, tokenCache, nil)
var detailCalls int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("content-type", "application/json")
switch r.URL.Path {
case "/backend-api/wham/usage":
_ = json.NewEncoder(w).Encode(OpenAIQuotaUsage{
RateLimitResetCredits: &OpenAIRateLimitResetCredits{AvailableCount: 1},
})
case "/backend-api/wham/rate-limit-reset-credits":
detailCalls++
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"error":"unauthorized","id":"secret-error-id"}`))
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
svc := NewOpenAIQuotaService(repo, nil, tokenProvider, newQuotaRedirectingFactory(srv))
usage, err := svc.QueryUsage(ctx, 100)
require.NoError(t, err)
require.NotNil(t, usage)
require.NotNil(t, usage.RateLimitResetCredits)
require.Equal(t, 1, usage.RateLimitResetCredits.AvailableCount)
require.Equal(t, 1, detailCalls)
require.Empty(t, usage.RateLimitResetCredits.Credits)
}
// TestResetCreditGetByIDError_FailsClosed 验证守卫「失败关闭」语义:
// 当守卫的 GetByID 发生瞬时错误时,ResetCredit 必须立即返回该错误,
// 不得旁路进入 prepareUpstreamCall(否则影子账号会借 resolve 路径操作母账号)。
+5
View File
@@ -734,8 +734,13 @@ export interface OpenAIAdditionalRateLimit {
rate_limit?: OpenAIRateLimit | null
}
export interface OpenAIRateLimitResetCreditDetail {
expires_at?: string
}
export interface OpenAIRateLimitResetCredits {
available_count: number
credits?: OpenAIRateLimitResetCreditDetail[]
}
export interface OpenAIQuotaUsage {
@@ -63,6 +63,46 @@
</button>
</div>
<div v-if="primaryResetCreditExpiry" class="space-y-1">
<div class="flex flex-wrap items-center gap-1">
<span
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-gray-800 dark:text-gray-300"
:title="t('admin.accounts.openaiQuotaReset.expiresAtFull', { time: formatResetCreditExpiry(primaryResetCreditExpiry, 'full') })"
>
{{ t('admin.accounts.openaiQuotaReset.expiresAt', { time: formatResetCreditExpiry(primaryResetCreditExpiry, 'short') }) }}
</span>
<button
v-if="hiddenResetCreditCount > 0"
type="button"
data-testid="reset-credit-expiry-toggle"
class="inline-flex items-center rounded-full bg-gray-100 px-1.5 py-0.5 text-[10px] font-medium leading-4 text-gray-600 transition-colors hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
:aria-expanded="showResetCreditDetails"
:aria-label="resetCreditDetailsToggleLabel"
:title="resetCreditDetailsTitle"
@click="toggleResetCreditDetails"
>
+{{ hiddenResetCreditCount }}
</button>
</div>
<div
v-if="showResetCreditDetails && resetCreditExpirations.length > 1"
data-testid="reset-credit-expiry-details"
class="inline-grid max-w-full gap-0.5 rounded border border-gray-200 bg-white px-1.5 py-1 text-[10px] leading-4 text-gray-600 shadow-sm dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300"
>
<span class="sr-only">{{ t('admin.accounts.openaiQuotaReset.expirationDetails') }}</span>
<span
v-for="(expiresAt, index) in resetCreditExpirations"
:key="`${expiresAt}-${index}`"
class="flex min-w-0 items-center gap-1 tabular-nums"
:title="t('admin.accounts.openaiQuotaReset.expiresAtFull', { time: formatResetCreditExpiry(expiresAt, 'full') })"
>
<span class="h-1 w-1 shrink-0 rounded-full bg-gray-400 dark:bg-gray-500" />
<span class="truncate">{{ formatResetCreditExpiry(expiresAt, 'short') }}</span>
</span>
</div>
</div>
<!-- Error / success feedback -->
<div
v-if="error"
@@ -118,14 +158,36 @@ const error = ref<string | null>(null)
const data = ref<OpenAIQuotaUsage | null>(null)
const resetMessage = ref<string | null>(null)
const showResetConfirm = ref(false)
const showResetCreditDetails = ref(false)
// 影子账号的额度查询会 resolve 到母账号,但影子本身不支持重置(后端返回 409);
// 重置必须在母账号上进行。前端据此禁用影子的重置入口(外审 F6)。
const isShadow = computed(() => props.account.parent_account_id != null)
const availableResetCount = computed(() => data.value?.rate_limit_reset_credits?.available_count ?? 0)
const resetCreditExpirations = computed(() =>
(data.value?.rate_limit_reset_credits?.credits ?? [])
.map((credit) => credit.expires_at?.trim() ?? '')
.filter((expiresAt) => expiresAt.length > 0)
.sort(compareResetCreditExpiry)
)
const primaryResetCreditExpiry = computed(() => resetCreditExpirations.value[0] ?? '')
const hiddenResetCreditCount = computed(() => Math.max(resetCreditExpirations.value.length - 1, 0))
const canReset = computed(() => availableResetCount.value > 0 && !isShadow.value)
const resetCreditDetailsTitle = computed(() =>
resetCreditExpirations.value
.map((expiresAt) => formatResetCreditExpiry(expiresAt, 'full'))
.join('\n')
)
const resetCreditDetailsToggleLabel = computed(() => {
if (showResetCreditDetails.value) {
return t('admin.accounts.openaiQuotaReset.collapseExpirations')
}
return t('admin.accounts.openaiQuotaReset.expandExpirations', { count: hiddenResetCreditCount.value })
})
const resetButtonTitle = computed(() => {
if (isShadow.value) return t('admin.accounts.openaiQuotaReset.resetTooltipShadow')
if (!data.value) return t('admin.accounts.openaiQuotaReset.resetTooltipNeedQuery')
@@ -145,6 +207,34 @@ const truncatedError = computed(() => {
return error.value.length > 80 ? `${error.value.slice(0, 80)}…` : error.value
})
const getResetCreditExpiryTime = (value: string): number => {
const time = new Date(value).getTime()
return Number.isNaN(time) ? Number.POSITIVE_INFINITY : time
}
const compareResetCreditExpiry = (a: string, b: string): number => {
const diff = getResetCreditExpiryTime(a) - getResetCreditExpiryTime(b)
if (diff !== 0) return diff
return a.localeCompare(b)
}
const formatResetCreditExpiry = (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)
}
const extractErrorMessage = (e: unknown): string => {
// The project's axios response interceptor (api/client.ts) flattens server
// errors into { status, code, message, reason, ... } and re-rejects them, so
@@ -165,11 +255,17 @@ const extractErrorMessage = (e: unknown): string => {
)
}
const toggleResetCreditDetails = () => {
if (hiddenResetCreditCount.value <= 0) return
showResetCreditDetails.value = !showResetCreditDetails.value
}
const handleQuery = async () => {
if (loading.value) return
loading.value = true
error.value = null
resetMessage.value = null
showResetCreditDetails.value = false
try {
data.value = await queryOpenAIQuota(props.account.id)
} catch (e) {
@@ -224,6 +320,16 @@ watch(
loading.value = false
resetting.value = false
showResetConfirm.value = false
showResetCreditDetails.value = false
}
)
watch(
resetCreditExpirations,
() => {
if (hiddenResetCreditCount.value <= 0) {
showResetCreditDetails.value = false
}
}
)
</script>
@@ -1,13 +1,22 @@
import { describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import OpenAIQuotaResetCell from '../OpenAIQuotaResetCell.vue'
import type { Account } from '@/types'
import { queryOpenAIQuota } from '@/api/admin/accounts'
vi.mock('@/api/admin/accounts', () => ({
queryOpenAIQuota: vi.fn(),
resetOpenAIQuota: vi.fn(),
}))
vi.mock('vue-i18n', async () => {
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
return {
...actual,
useI18n: () => ({ t: (key: string) => key }),
useI18n: () => ({
t: (key: string, params?: Record<string, unknown>) =>
params?.time ? `${key}:${params.time}` : params?.count ? `${key}:${params.count}` : key,
}),
}
})
@@ -44,6 +53,10 @@ function makeAccount(overrides: Partial<Account>): Account {
const resetButton = (wrapper: ReturnType<typeof mount>) =>
wrapper.findAll('button')[1]
beforeEach(() => {
vi.mocked(queryOpenAIQuota).mockReset()
})
describe('OpenAIQuotaResetCell — 外审 F6:影子禁用重置', () => {
it('影子账号(parent_account_id 非空)的 reset 按钮被禁用且提示在母账号重置', () => {
const account = makeAccount({ parent_account_id: 100 })
@@ -64,4 +77,63 @@ describe('OpenAIQuotaResetCell — 外审 F6:影子禁用重置', () => {
expect(btn.attributes('title')).toBe('admin.accounts.openaiQuotaReset.resetTooltipNeedQuery')
wrapper.unmount()
})
it('查询后默认折叠为最早到期时间,点击 +N 展开完整列表', async () => {
vi.mocked(queryOpenAIQuota).mockResolvedValue({
rate_limit_reset_credits: {
available_count: 3,
credits: [
{ expires_at: '2026-07-05T04:05:06Z' },
{ expires_at: '2026-07-03T04:05:06Z' },
{ expires_at: 'not-a-date' },
],
},
fetched_at: 1770000000,
})
const account = makeAccount({ parent_account_id: null })
const wrapper = mount(OpenAIQuotaResetCell, { props: { account } })
await wrapper.findAll('button')[0].trigger('click')
await flushPromises()
expect(queryOpenAIQuota).toHaveBeenCalledWith(1)
expect(wrapper.text()).toContain('admin.accounts.openaiQuotaReset.expiresAt:')
expect(wrapper.text()).toContain('+2')
expect(wrapper.text()).not.toContain('not-a-date')
const toggle = wrapper.find('[data-testid="reset-credit-expiry-toggle"]')
expect(toggle.exists()).toBe(true)
expect(toggle.attributes('aria-expanded')).toBe('false')
await toggle.trigger('click')
expect(toggle.attributes('aria-expanded')).toBe('true')
expect(wrapper.find('[data-testid="reset-credit-expiry-details"]').exists()).toBe(true)
expect(wrapper.text()).toContain('not-a-date')
expect(wrapper.text()).not.toContain('undefined')
wrapper.unmount()
})
it('只有一张重置卡时不显示展开按钮', async () => {
vi.mocked(queryOpenAIQuota).mockResolvedValue({
rate_limit_reset_credits: {
available_count: 1,
credits: [
{ expires_at: '2026-07-03T04:05:06Z' },
],
},
fetched_at: 1770000000,
})
const account = makeAccount({ parent_account_id: null })
const wrapper = mount(OpenAIQuotaResetCell, { props: { account } })
await wrapper.findAll('button')[0].trigger('click')
await flushPromises()
expect(wrapper.find('[data-testid="reset-credit-expiry-toggle"]').exists()).toBe(false)
expect(wrapper.find('[data-testid="reset-credit-expiry-details"]').exists()).toBe(false)
expect(wrapper.text()).toContain('admin.accounts.openaiQuotaReset.expiresAt:')
wrapper.unmount()
})
})
+5
View File
@@ -4251,6 +4251,11 @@ export default {
resetTooltipNeedQuery: 'Click Credits first to load the available count',
resetTooltipNoCredits: 'No reset credits available',
resetTooltipShadow: 'Spark shadow accounts cannot reset credits; reset on the parent account',
expiresAt: 'Expires {time}',
expiresAtFull: 'Reset credit expires at {time}',
expandExpirations: 'Expand the other {count} reset credit expiration(s)',
collapseExpirations: 'Collapse reset credit expirations',
expirationDetails: 'Reset credit expiration details',
noCreditsAvailable: 'No reset credits available',
resetSuccess: 'Reset {windows} window(s)',
confirmTitle: 'Confirm Weekly Limit Reset',
+5
View File
@@ -3488,6 +3488,11 @@ export default {
resetTooltipNeedQuery: '先点击「次数」加载剩余重置次数',
resetTooltipNoCredits: '没有可用的重置次数',
resetTooltipShadow: 'Spark 影子账号不能重置次数;请在母账号上重置',
expiresAt: '到期 {time}',
expiresAtFull: '重置次数到期时间: {time}',
expandExpirations: '展开其余 {count} 张重置次数到期时间',
collapseExpirations: '收起重置次数到期时间',
expirationDetails: '重置次数到期明细',
noCreditsAvailable: '没有可用的重置次数',
resetSuccess: '已重置 {windows} 个窗口',
confirmTitle: '确认重置周限',