From dfb36e45f1caea9746ec7f8ec92c5b4311816a55 Mon Sep 17 00:00:00 2001 From: infinityf4p <186150610+infinityf4p@users.noreply.github.com> Date: Thu, 2 Jul 2026 13:27:58 +0800 Subject: [PATCH] show reset credit expiration --- .../internal/service/openai_quota_service.go | 99 +++++++++++- .../service/openai_quota_spark_window_test.go | 148 ++++++++++++++++++ frontend/src/api/admin/accounts.ts | 5 + .../account/OpenAIQuotaResetCell.vue | 106 +++++++++++++ .../OpenAIQuotaResetCell.spark_shadow.spec.ts | 78 ++++++++- frontend/src/i18n/locales/en.ts | 5 + frontend/src/i18n/locales/zh.ts | 5 + 7 files changed, 442 insertions(+), 4 deletions(-) diff --git a/backend/internal/service/openai_quota_service.go b/backend/internal/service/openai_quota_service.go index 11f102dc34..337f8c1e8f 100644 --- a/backend/internal/service/openai_quota_service.go +++ b/backend/internal/service/openai_quota_service.go @@ -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 diff --git a/backend/internal/service/openai_quota_spark_window_test.go b/backend/internal/service/openai_quota_spark_window_test.go index ceb46b708d..c56600d3f5 100644 --- a/backend/internal/service/openai_quota_spark_window_test.go +++ b/backend/internal/service/openai_quota_spark_window_test.go @@ -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 路径操作母账号)。 diff --git a/frontend/src/api/admin/accounts.ts b/frontend/src/api/admin/accounts.ts index ae9394a91e..ce7cb694da 100644 --- a/frontend/src/api/admin/accounts.ts +++ b/frontend/src/api/admin/accounts.ts @@ -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 { diff --git a/frontend/src/components/account/OpenAIQuotaResetCell.vue b/frontend/src/components/account/OpenAIQuotaResetCell.vue index 5bf8bb68cb..b7da95cf2d 100644 --- a/frontend/src/components/account/OpenAIQuotaResetCell.vue +++ b/frontend/src/components/account/OpenAIQuotaResetCell.vue @@ -63,6 +63,46 @@ +
+
+ + {{ t('admin.accounts.openaiQuotaReset.expiresAt', { time: formatResetCreditExpiry(primaryResetCreditExpiry, 'short') }) }} + + +
+ +
+ {{ t('admin.accounts.openaiQuotaReset.expirationDetails') }} + + + {{ formatResetCreditExpiry(expiresAt, 'short') }} + +
+
+
(null) const data = ref(null) const resetMessage = ref(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 + } } ) diff --git a/frontend/src/components/account/__tests__/OpenAIQuotaResetCell.spark_shadow.spec.ts b/frontend/src/components/account/__tests__/OpenAIQuotaResetCell.spark_shadow.spec.ts index ffffdbad99..7b5752128b 100644 --- a/frontend/src/components/account/__tests__/OpenAIQuotaResetCell.spark_shadow.spec.ts +++ b/frontend/src/components/account/__tests__/OpenAIQuotaResetCell.spark_shadow.spec.ts @@ -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('vue-i18n') return { ...actual, - useI18n: () => ({ t: (key: string) => key }), + useI18n: () => ({ + t: (key: string, params?: Record) => + params?.time ? `${key}:${params.time}` : params?.count ? `${key}:${params.count}` : key, + }), } }) @@ -44,6 +53,10 @@ function makeAccount(overrides: Partial): Account { const resetButton = (wrapper: ReturnType) => 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() + }) }) diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 3469d3fff2..5f160ab5f7 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -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', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 3fa1e1371d..63fd259409 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -3488,6 +3488,11 @@ export default { resetTooltipNeedQuery: '先点击「次数」加载剩余重置次数', resetTooltipNoCredits: '没有可用的重置次数', resetTooltipShadow: 'Spark 影子账号不能重置次数;请在母账号上重置', + expiresAt: '到期 {time}', + expiresAtFull: '重置次数到期时间: {time}', + expandExpirations: '展开其余 {count} 张重置次数到期时间', + collapseExpirations: '收起重置次数到期时间', + expirationDetails: '重置次数到期明细', noCreditsAvailable: '没有可用的重置次数', resetSuccess: '已重置 {windows} 个窗口', confirmTitle: '确认重置周限',