fix(openai): harden reset-credit refresh and account recovery

Review follow-ups on the reset-credit caching flow:

- Recover account state BEFORE (and independently of) the reset-credit
  display cache. A failed cache refresh could previously abort the run and
  leave the account rate-limited — the very reason the credit was spent
  (#3672 / #3740). The recovered account row is now returned even when the
  cache refresh fails.
- Run the post-reset bookkeeping on a detached, time-boxed context and give
  the panel reset call a larger timeout. A client abort no longer strands a
  consumed (non-refundable) credit with an unrecovered account, and the
  chained upstream calls can no longer exceed the client timeout and invite a
  retry that spends a second credit.
- Persist the reset-credit snapshot through POST /accounts/:id/quota/refresh
  instead of a side-effecting GET flag, so the write is covered by the audit
  middleware. A rejected snapshot write now degrades to cache_persisted=false
  instead of turning a successful upstream read into a 502 that left the card
  without a credit count and the reset button permanently disabled.
- Reject snapshots whose positive count carries no expiration entries, and
  drop expired credits (clamping the count) when rehydrating, so a stale
  cache can no longer light up the reset button.
- Keep nil quota / rate-limit services nil in the handler's interface fields;
  storing a nil *Service made the "not enabled" guards non-nil.
- Time-box the usage-refresh suppression and reuse handleAccountUpdated so the
  patched row also enters the auto-refresh silent window.
This commit is contained in:
shaw
2026-08-03 14:40:55 +08:00
parent f970bd48c9
commit 54a2bcfd15
14 changed files with 681 additions and 215 deletions
+27 -10
View File
@@ -844,24 +844,41 @@ export interface OpenAIQuotaResetResult {
| 'account_state_refresh_failed'
}
/** Usage payload plus whether the reset-credit snapshot was persisted. */
export interface OpenAIQuotaRefreshResult extends OpenAIQuotaUsage {
cache_persisted: boolean
}
/**
* Query OpenAI/Codex rate-limit usage for an OAuth account.
* Query the upstream quota AND persist the reset-credit snapshot on the account
* so the card can be rehydrated without an upstream round-trip. It is a POST
* because it writes account state (and must therefore be audited).
*
* The read-only `GET /admin/openai/accounts/:id/quota` endpoint still exists for
* API consumers; the panel always wants the snapshot persisted, so it has no
* client binding here.
*/
export async function queryOpenAIQuota(
id: number,
options?: { persistResetCredits?: boolean }
): Promise<OpenAIQuotaUsage> {
const { data } = await apiClient.get<OpenAIQuotaUsage>(`/admin/openai/accounts/${id}/quota`, {
params: options?.persistResetCredits === true ? { persist_reset_credits: true } : undefined
})
export async function refreshOpenAIQuota(id: number): Promise<OpenAIQuotaRefreshResult> {
const { data } = await apiClient.post<OpenAIQuotaRefreshResult>(
`/admin/openai/accounts/${id}/quota/refresh`
)
return data
}
/**
* Consume one rate-limit-reset credit for an OpenAI/Codex OAuth account.
*
* The credit is non-refundable and the endpoint chains an upstream reset with an
* upstream re-query, so it needs a larger budget than the default client
* timeout: aborting locally would report a successful consumption as a failure
* and invite a retry that spends a second credit.
*/
export async function resetOpenAIQuota(id: number): Promise<OpenAIQuotaResetResult> {
const { data } = await apiClient.post<OpenAIQuotaResetResult>(`/admin/openai/accounts/${id}/reset-quota`)
const { data } = await apiClient.post<OpenAIQuotaResetResult>(
`/admin/openai/accounts/${id}/reset-quota`,
undefined,
{ timeout: 90_000 }
)
return data
}
@@ -998,7 +1015,7 @@ export const accountsAPI = {
batchRefresh,
setPrivacy,
revertProxyFallback,
queryOpenAIQuota,
refreshOpenAIQuota,
resetOpenAIQuota,
createSparkShadow,
getUpstreamBillingProbeSettings,
@@ -643,6 +643,8 @@ import OllamaCloudUsageCell from './OllamaCloudUsageCell.vue'
// Module-level cache shared across all AccountUsageCell instances
const _usageCache = new Map<number, { data: AccountUsageInfo; ts: number }>()
const USAGE_CACHE_TTL = 5 * 60 * 1000 // 5 minutes
// How long a quota-reset response may suppress the row-patch usage refetch.
const SUPPRESS_USAGE_REFRESH_WINDOW_MS = 5 * 1000
const props = withDefaults(
defineProps<{
@@ -672,7 +674,7 @@ const loading = ref(false)
const activeQueryLoading = ref(false)
const error = ref<string | null>(null)
const usageInfo = ref<AccountUsageInfo | null>(null)
const suppressNextOpenAIUsageRefresh = ref(false)
const suppressOpenAIUsageRefreshUntil = ref(0)
const rootRef = ref<HTMLElement | null>(null)
const isDesktopViewport = ref(
typeof window === 'undefined' ? true : window.matchMedia(desktopViewportQuery).matches
@@ -1485,7 +1487,9 @@ const quotaTotalBar = computed((): QuotaBarInfo | null => {
const handleQuotaResetAccountUpdated = (account: Account) => {
// The reset response already carries authoritative quota and account data.
// Avoid turning the parent patch into a second automatic /usage request.
suppressNextOpenAIUsageRefresh.value = true
// The suppression is time-boxed so an unhandled emit (parent that ignores
// account-updated) cannot latch it and swallow a later, unrelated refresh.
suppressOpenAIUsageRefreshUntil.value = Date.now() + SUPPRESS_USAGE_REFRESH_WINDOW_MS
emit('account-updated', account)
}
@@ -1533,8 +1537,8 @@ onMounted(() => {
watch(openAIUsageRefreshKey, (nextKey, prevKey) => {
if (!prevKey || nextKey === prevKey) return
if (props.account.platform !== 'openai' || props.account.type !== 'oauth') return
if (suppressNextOpenAIUsageRefresh.value) {
suppressNextOpenAIUsageRefresh.value = false
if (Date.now() < suppressOpenAIUsageRefreshUntil.value) {
suppressOpenAIUsageRefreshUntil.value = 0
return
}
@@ -19,7 +19,7 @@
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 || resetting"
:title="countButtonTitle"
@click="handleQuery({ persistResetCredits: true })"
@click="handleQuery()"
>
<svg
class="h-2.5 w-2.5"
@@ -142,7 +142,7 @@ import { ref, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import type { Account } from '@/types'
import {
queryOpenAIQuota,
refreshOpenAIQuota,
resetOpenAIQuota,
type OpenAIQuotaUsage,
type OpenAIQuotaResetResult
@@ -172,6 +172,11 @@ const resetWarning = ref<string | null>(null)
const showResetConfirm = ref(false)
const showResetCreditDetails = ref(false)
// Rehydrate the card from the persisted snapshot. Credits that already expired
// are dropped and the count is clamped to what remains: the snapshot has no
// freshness signal, so an unfiltered read would offer to consume credits that no
// longer exist. A snapshot claiming credits with no usable expiration left is
// treated as absent, which keeps the reset button gated on a live query.
const readCachedResetCredits = (account: Account): OpenAIQuotaUsage | null => {
const cached = account.extra?.codex_reset_credit_snapshot
if (!cached || typeof cached !== 'object' || Array.isArray(cached)) return null
@@ -182,20 +187,28 @@ const readCachedResetCredits = (account: Account): OpenAIQuotaUsage | null => {
}
if (typeof count !== 'number' || !Number.isFinite(count)) return null
const now = Date.now()
const credits: { expires_at?: string }[] = []
if (Array.isArray(rawCredits)) {
for (const credit of rawCredits) {
if (!credit || typeof credit !== 'object') continue
const expiresAt = (credit as { expires_at?: unknown }).expires_at
if (typeof expiresAt === 'string' && expiresAt.trim() !== '') {
credits.push({ expires_at: expiresAt })
}
if (typeof expiresAt !== 'string' || expiresAt.trim() === '') continue
const expiryTime = new Date(expiresAt).getTime()
// Unparsable timestamps are kept: they are already rendered verbatim and
// dropping them would silently understate the available count.
if (!Number.isNaN(expiryTime) && expiryTime <= now) continue
credits.push({ expires_at: expiresAt })
}
}
const availableCount = Math.min(Math.max(count, 0), credits.length)
// A snapshot that claimed credits but has none left is no longer informative;
// report "unknown" so the operator re-queries instead of trusting it.
if (count > 0 && availableCount <= 0) return null
return {
fetched_at: 0,
rate_limit_reset_credits: {
available_count: count,
available_count: availableCount,
credits
}
}
@@ -209,8 +222,11 @@ data.value = cachedData.value
const isShadow = computed(() => props.account.parent_account_id != null)
const availableResetCount = computed(() => data.value?.rate_limit_reset_credits?.available_count ?? 0)
// Prefer the live payload and fall back to the persisted snapshot only when the
// live state is unknown, so the count and the expirations never come from two
// different generations of the same data.
const resetCreditExpirations = computed(() =>
(cachedData.value?.rate_limit_reset_credits?.credits ?? [])
((data.value ?? cachedData.value)?.rate_limit_reset_credits?.credits ?? [])
.map((credit) => credit.expires_at?.trim() ?? '')
.filter((expiresAt) => expiresAt.length > 0)
.sort(compareResetCreditExpiry)
@@ -304,7 +320,7 @@ const toggleResetCreditDetails = () => {
showResetCreditDetails.value = !showResetCreditDetails.value
}
const handleQuery = async (options?: { persistResetCredits?: boolean }) => {
const handleQuery = async () => {
if (loading.value) return
loading.value = true
error.value = null
@@ -312,11 +328,16 @@ const handleQuery = async (options?: { persistResetCredits?: boolean }) => {
resetWarning.value = null
showResetCreditDetails.value = false
try {
const result = options
? await queryOpenAIQuota(props.account.id, options)
: await queryOpenAIQuota(props.account.id)
const result = await refreshOpenAIQuota(props.account.id)
// The upstream read succeeded even when the snapshot write was rejected, so
// the live count is always adopted. Only the persisted view is left alone,
// which keeps the displayed expirations consistent with what is stored.
data.value = result
if (options?.persistResetCredits) cachedData.value = result
if (result.cache_persisted) {
cachedData.value = result
} else {
resetWarning.value = t('admin.accounts.openaiQuotaReset.refreshCachePersistFailed')
}
} catch (e) {
error.value = extractErrorMessage(e)
} finally {
@@ -346,17 +367,19 @@ const confirmReset = async () => {
resetWarning.value = null
try {
const result: OpenAIQuotaResetResult = await resetOpenAIQuota(props.account.id)
showResetCreditDetails.value = false
if (result.cache_refreshed && result.quota) {
data.value = result.quota
cachedData.value = result.quota
showResetCreditDetails.value = false
} else {
// A credit was consumed but the post-reset count could not be read back.
// Whatever we still hold is one generation stale, so report the count as
// unknown instead of letting a second consumption start from stale data.
data.value = null
}
if (result.account) emit('account-updated', result.account)
if (result.warning_code === 'reset_credit_cache_refresh_failed') {
// The persisted snapshot is intentionally preserved, but it is no longer
// authoritative enough to allow another credit consumption.
data.value = null
resetWarning.value = t('admin.accounts.openaiQuotaReset.resetCacheRefreshFailed')
} else if (result.warning_code === 'account_state_recovery_failed') {
resetWarning.value = t('admin.accounts.openaiQuotaReset.resetAccountRecoveryFailed')
@@ -509,8 +509,8 @@ describe('AccountUsageCell', () => {
await flushPromises()
expect(getUsage).toHaveBeenCalledTimes(2)
expect(wrapper.text()).toContain('5h|0|200')
})
expect(wrapper.text()).toContain('5h|0|200')
})
it('OpenAI 重置响应更新账号行时不会额外拉取 usage', async () => {
getUsage.mockResolvedValue({
@@ -3,10 +3,10 @@ import { flushPromises, mount } from '@vue/test-utils'
import OpenAIQuotaResetCell from '../OpenAIQuotaResetCell.vue'
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
import type { Account } from '@/types'
import { queryOpenAIQuota, resetOpenAIQuota } from '@/api/admin/accounts'
import { refreshOpenAIQuota, resetOpenAIQuota } from '@/api/admin/accounts'
vi.mock('@/api/admin/accounts', () => ({
queryOpenAIQuota: vi.fn(),
refreshOpenAIQuota: vi.fn(),
resetOpenAIQuota: vi.fn(),
}))
@@ -21,6 +21,11 @@ vi.mock('vue-i18n', async () => {
}
})
// 缓存水合会丢弃已过期的重置卡,因此缓存类用例必须使用未来时间。
const FUTURE_EXPIRY_EARLY = '2099-07-03T04:05:06Z'
const FUTURE_EXPIRY_LATE = '2099-07-05T04:05:06Z'
const PAST_EXPIRY = '2020-07-03T04:05:06Z'
function makeAccount(overrides: Partial<Account>): Account {
return {
id: 1,
@@ -55,7 +60,7 @@ const resetButton = (wrapper: ReturnType<typeof mount>) =>
wrapper.findAll('button')[1]
beforeEach(() => {
vi.mocked(queryOpenAIQuota).mockReset()
vi.mocked(refreshOpenAIQuota).mockReset()
vi.mocked(resetOpenAIQuota).mockReset()
})
@@ -87,15 +92,15 @@ describe('OpenAIQuotaResetCell — 外审 F6:影子禁用重置', () => {
codex_reset_credit_snapshot: {
available_count: 2,
credits: [
{ expires_at: '2026-07-05T04:05:06Z' },
{ expires_at: '2026-07-03T04:05:06Z' },
{ expires_at: FUTURE_EXPIRY_LATE },
{ expires_at: FUTURE_EXPIRY_EARLY },
],
},
},
})
const wrapper = mount(OpenAIQuotaResetCell, { props: { account } })
expect(queryOpenAIQuota).not.toHaveBeenCalled()
expect(refreshOpenAIQuota).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('admin.accounts.openaiQuotaReset.count')
expect(wrapper.text()).toContain('admin.accounts.openaiQuotaReset.expiresAt:')
expect(wrapper.text()).toContain('+1')
@@ -103,8 +108,48 @@ describe('OpenAIQuotaResetCell — 外审 F6:影子禁用重置', () => {
wrapper.unmount()
})
it('缓存中的重置卡全部过期时视为未知,不点亮重置入口', () => {
const account = makeAccount({
parent_account_id: null,
extra: {
codex_reset_credit_snapshot: {
available_count: 1,
credits: [{ expires_at: PAST_EXPIRY }],
},
},
})
const wrapper = mount(OpenAIQuotaResetCell, { props: { account } })
expect(wrapper.text()).not.toContain('admin.accounts.openaiQuotaReset.expiresAt:')
const btn = resetButton(wrapper)
expect(btn.attributes('disabled')).toBeDefined()
expect(btn.attributes('title')).toBe('admin.accounts.openaiQuotaReset.resetTooltipNeedQuery')
wrapper.unmount()
})
it('缓存次数向未过期的明细数量收敛', () => {
const account = makeAccount({
parent_account_id: null,
extra: {
codex_reset_credit_snapshot: {
available_count: 3,
credits: [
{ expires_at: PAST_EXPIRY },
{ expires_at: FUTURE_EXPIRY_EARLY },
],
},
},
})
const wrapper = mount(OpenAIQuotaResetCell, { props: { account } })
expect(wrapper.text()).toContain('admin.accounts.openaiQuotaReset.count1')
expect(wrapper.text()).not.toContain('+1')
expect(resetButton(wrapper).attributes('disabled')).toBeUndefined()
wrapper.unmount()
})
it('查询后默认折叠为最早到期时间,点击 +N 展开完整列表', async () => {
vi.mocked(queryOpenAIQuota).mockResolvedValue({
vi.mocked(refreshOpenAIQuota).mockResolvedValue({
rate_limit_reset_credits: {
available_count: 3,
credits: [
@@ -114,6 +159,7 @@ describe('OpenAIQuotaResetCell — 外审 F6:影子禁用重置', () => {
],
},
fetched_at: 1770000000,
cache_persisted: true,
})
const account = makeAccount({ parent_account_id: null })
@@ -122,7 +168,7 @@ describe('OpenAIQuotaResetCell — 外审 F6:影子禁用重置', () => {
await wrapper.findAll('button')[0].trigger('click')
await flushPromises()
expect(queryOpenAIQuota).toHaveBeenCalledWith(1, { persistResetCredits: true })
expect(refreshOpenAIQuota).toHaveBeenCalledWith(1)
expect(wrapper.text()).toContain('admin.accounts.openaiQuotaReset.expiresAt:')
expect(wrapper.text()).toContain('+2')
expect(wrapper.text()).not.toContain('not-a-date')
@@ -140,7 +186,7 @@ describe('OpenAIQuotaResetCell — 外审 F6:影子禁用重置', () => {
})
it('只有一张重置卡时不显示展开按钮', async () => {
vi.mocked(queryOpenAIQuota).mockResolvedValue({
vi.mocked(refreshOpenAIQuota).mockResolvedValue({
rate_limit_reset_credits: {
available_count: 1,
credits: [
@@ -148,6 +194,7 @@ describe('OpenAIQuotaResetCell — 外审 F6:影子禁用重置', () => {
],
},
fetched_at: 1770000000,
cache_persisted: true,
})
const account = makeAccount({ parent_account_id: null })
@@ -162,6 +209,27 @@ describe('OpenAIQuotaResetCell — 外审 F6:影子禁用重置', () => {
wrapper.unmount()
})
// 快照写库被拒绝(上游未返回到期明细)不得吞掉这次成功的上游读取,
// 否则次数永远显示不出来、重置入口被永久禁用。
it('快照持久化失败时仍显示实时次数并给出警告', async () => {
vi.mocked(refreshOpenAIQuota).mockResolvedValue({
rate_limit_reset_credits: { available_count: 2 },
fetched_at: 1770000000,
cache_persisted: false,
})
const account = makeAccount({ parent_account_id: null })
const wrapper = mount(OpenAIQuotaResetCell, { props: { account } })
await wrapper.findAll('button')[0].trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('admin.accounts.openaiQuotaReset.count2')
expect(wrapper.text()).toContain('admin.accounts.openaiQuotaReset.refreshCachePersistFailed')
expect(resetButton(wrapper).attributes('disabled')).toBeUndefined()
wrapper.unmount()
})
it('重置成功后直接使用响应中的最新缓存并回传恢复后的账号', async () => {
const recoveredAccount = makeAccount({
parent_account_id: null,
@@ -187,7 +255,7 @@ describe('OpenAIQuotaResetCell — 外审 F6:影子禁用重置', () => {
extra: {
codex_reset_credit_snapshot: {
available_count: 1,
credits: [{ expires_at: '2026-07-03T04:05:06Z' }],
credits: [{ expires_at: FUTURE_EXPIRY_EARLY }],
},
},
})
@@ -198,27 +266,68 @@ describe('OpenAIQuotaResetCell — 外审 F6:影子禁用重置', () => {
await flushPromises()
expect(resetOpenAIQuota).toHaveBeenCalledWith(1)
expect(queryOpenAIQuota).not.toHaveBeenCalled()
expect(refreshOpenAIQuota).not.toHaveBeenCalled()
expect(wrapper.text()).not.toContain('admin.accounts.openaiQuotaReset.expiresAt:')
expect(wrapper.text()).toContain('admin.accounts.openaiQuotaReset.resetSuccess')
expect(wrapper.emitted('account-updated')).toEqual([[recoveredAccount]])
wrapper.unmount()
})
it('缓存刷新失败时保留旧缓存并显示部分成功警告', async () => {
// 缓存回读失败不影响「账号状态已恢复」这一主目标:恢复后的账号行必须照常回传,
// 否则列表会继续显示已经不存在的限流状态。
it('缓存刷新失败时仍回传恢复后的账号并把次数标为未知', async () => {
const recoveredAccount = makeAccount({
parent_account_id: null,
status: 'active',
rate_limit_reset_at: null,
})
vi.mocked(resetOpenAIQuota).mockResolvedValue({
code: 'success',
windows_reset: 1,
cache_refreshed: false,
account_state_recovered: true,
warning_code: 'reset_credit_cache_refresh_failed',
account: recoveredAccount,
})
const account = makeAccount({
parent_account_id: null,
extra: {
codex_reset_credit_snapshot: {
available_count: 1,
credits: [{ expires_at: FUTURE_EXPIRY_EARLY }],
},
},
})
const wrapper = mount(OpenAIQuotaResetCell, { props: { account } })
await resetButton(wrapper).trigger('click')
wrapper.findComponent(ConfirmDialog).vm.$emit('confirm')
await flushPromises()
expect(refreshOpenAIQuota).not.toHaveBeenCalled()
// 次数未知(隐藏)但仍展示已持久化的到期明细,重置入口保持禁用直到重新查询。
expect(wrapper.text()).not.toContain('admin.accounts.openaiQuotaReset.count1')
expect(wrapper.text()).toContain('admin.accounts.openaiQuotaReset.expiresAt:')
expect(wrapper.text()).toContain('admin.accounts.openaiQuotaReset.resetCacheRefreshFailed')
expect(resetButton(wrapper).attributes('disabled')).toBeDefined()
expect(wrapper.emitted('account-updated')).toEqual([[recoveredAccount]])
wrapper.unmount()
})
it('账号状态恢复失败时停止后续步骤并提示手动恢复', async () => {
vi.mocked(resetOpenAIQuota).mockResolvedValue({
code: 'success',
windows_reset: 1,
cache_refreshed: false,
account_state_recovered: false,
warning_code: 'reset_credit_cache_refresh_failed',
warning_code: 'account_state_recovery_failed',
})
const account = makeAccount({
parent_account_id: null,
extra: {
codex_reset_credit_snapshot: {
available_count: 1,
credits: [{ expires_at: '2026-07-03T04:05:06Z' }],
credits: [{ expires_at: FUTURE_EXPIRY_EARLY }],
},
},
})
@@ -228,48 +337,10 @@ describe('OpenAIQuotaResetCell — 外审 F6:影子禁用重置', () => {
wrapper.findComponent(ConfirmDialog).vm.$emit('confirm')
await flushPromises()
expect(queryOpenAIQuota).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('admin.accounts.openaiQuotaReset.expiresAt:')
expect(wrapper.text()).toContain('admin.accounts.openaiQuotaReset.resetCacheRefreshFailed')
expect(refreshOpenAIQuota).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('admin.accounts.openaiQuotaReset.resetAccountRecoveryFailed')
expect(resetButton(wrapper).attributes('disabled')).toBeDefined()
expect(wrapper.emitted('account-updated')).toBeUndefined()
wrapper.unmount()
})
it('账号恢复失败时使用已刷新的缓存并显示部分成功警告', async () => {
vi.mocked(resetOpenAIQuota).mockResolvedValue({
code: 'success',
windows_reset: 1,
cache_refreshed: true,
account_state_recovered: false,
warning_code: 'account_state_recovery_failed',
quota: {
rate_limit_reset_credits: {
available_count: 0,
credits: [],
},
fetched_at: 1770000000,
},
})
const account = makeAccount({
parent_account_id: null,
extra: {
codex_reset_credit_snapshot: {
available_count: 1,
credits: [{ expires_at: '2026-07-03T04:05:06Z' }],
},
},
})
const wrapper = mount(OpenAIQuotaResetCell, { props: { account } })
await resetButton(wrapper).trigger('click')
wrapper.findComponent(ConfirmDialog).vm.$emit('confirm')
await flushPromises()
expect(queryOpenAIQuota).not.toHaveBeenCalled()
expect(wrapper.text()).not.toContain('admin.accounts.openaiQuotaReset.expiresAt:')
expect(wrapper.text()).toContain('admin.accounts.openaiQuotaReset.resetAccountRecoveryFailed')
expect(wrapper.emitted('account-updated')).toBeUndefined()
wrapper.unmount()
})
})
@@ -1375,9 +1375,10 @@ export default {
expirationDetails: 'Reset credit expiration details',
noCreditsAvailable: 'No reset credits available',
resetSuccess: 'Reset {windows} window(s); credits and account state updated',
resetCacheRefreshFailed: 'The window was reset, but the reset-credit cache refresh failed. Account state was not recovered.',
resetAccountRecoveryFailed: 'The window and reset-credit cache were updated, but account state recovery failed.',
resetAccountRefreshFailed: 'The window, reset-credit cache, and account state were updated, but the latest account display could not be loaded.',
resetCacheRefreshFailed: 'The window was reset and account state recovered, but the reset-credit count could not be read back. Query it again.',
resetAccountRecoveryFailed: 'The window was reset, but account state recovery failed. Recover the account state manually.',
resetAccountRefreshFailed: 'The window, account state, and reset-credit cache were updated, but the latest account display could not be loaded.',
refreshCachePersistFailed: 'Showing the live count, but its expiration details were unavailable, so the cached details were kept.',
confirmTitle: 'Confirm Weekly Limit Reset',
confirmMessage: 'This will consume 1 reset credit to immediately restore the current window ({count} remaining). This action cannot be undone. Continue?'
},
@@ -430,9 +430,10 @@ export default {
expirationDetails: '重置次数到期明细',
noCreditsAvailable: '没有可用的重置次数',
resetSuccess: '已重置 {windows} 个窗口,次数和账号状态已更新',
resetCacheRefreshFailed: '窗口已重置,但重置次数缓存刷新失败,账号状态未恢复。',
resetAccountRecoveryFailed: '窗口和重置次数缓存已更新,但账号状态恢复失败。',
resetAccountRefreshFailed: '窗口、重置次数缓存和账号状态已更新,但无法加载最新账号显示。',
resetCacheRefreshFailed: '窗口已重置、账号状态已恢复,但重置次数未能回读,请重新查询次数。',
resetAccountRecoveryFailed: '窗口已重置,但账号状态恢复失败,请手动恢复账号状态。',
resetAccountRefreshFailed: '窗口、账号状态和重置次数缓存已更新,但无法加载最新账号显示。',
refreshCachePersistFailed: '已显示实时次数,但到期明细获取失败,仍保留原有缓存明细。',
confirmTitle: '确认重置周限',
confirmMessage: '将消耗 1 次重置次数立即恢复当前窗口,剩余 {count} 次。此操作不可撤销,确定继续吗?'
},
+4
View File
@@ -1070,6 +1070,10 @@ export interface Account {
upstream_billing_probe_enabled?: boolean
upstream_billing_rate_sync_enabled?: boolean
upstream_billing_probe?: UpstreamBillingProbeSnapshot
codex_reset_credit_snapshot?: {
available_count?: number
credits?: { expires_at?: string }[]
}
} & Record<string, unknown>)
proxy_id: number | null
proxy_fallback_origin_id?: number | null
+1 -1
View File
@@ -318,7 +318,7 @@
:today-stats="todayStatsByAccountId[String(row.id)] ?? null"
:today-stats-loading="todayStatsLoading"
:manual-refresh-token="usageManualRefreshToken"
@account-updated="patchAccountInList"
@account-updated="handleAccountUpdated"
/>
</template>
<template #cell-proxy="{ row }">