feat(accounts): show Claude reset credit status on demand

This commit is contained in:
korkin25
2026-09-28 18:04:13 -04:00
committed by William Drake
parent 6a8f921650
commit f8984b8a93
6 changed files with 118 additions and 2 deletions
@@ -0,0 +1,28 @@
import { apiClient } from '../client'
export interface ClaudeResetCredit {
selection_token: string
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
}
@@ -114,6 +114,7 @@
<div v-else class="space-y-1">
<div class="text-xs text-gray-400">-</div>
</div>
<ClaudeResetCreditsCell :account="account" />
</template>
<!-- OpenAI OAuth accounts: single source from /usage API -->
@@ -677,6 +678,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,52 @@
<template>
<div v-if="account.platform === 'anthropic' && account.type === 'oauth'" class="space-y-1 text-xs">
<button type="button" class="text-blue-600 disabled:opacity-50" :disabled="loading" @click="refresh">
{{ t('admin.accounts.claudeResetCredits.query') }}
</button>
<p v-if="error" role="alert">{{ t('admin.accounts.claudeResetCredits.error') }}</p>
<template v-if="status">
<p>{{ t('admin.accounts.claudeResetCredits.count', { count: status.available_count }) }}</p>
<p v-if="!status.eligible">{{ t('admin.accounts.claudeResetCredits.ineligible') }}</p>
<p v-if="status.cooldown_until">{{ t('admin.accounts.claudeResetCredits.cooldown', { time: status.cooldown_until }) }}</p>
<p v-if="status.weekly_resets_at">{{ t('admin.accounts.claudeResetCredits.weekly', { time: status.weekly_resets_at }) }}</p>
<div v-for="credit in status.credits" :key="credit.selection_token" class="rounded border p-1">
<p>{{ credit.label }} · {{ credit.resets_left }}</p>
<p v-if="credit.expires_at">{{ t('admin.accounts.claudeResetCredits.expires', { time: credit.expires_at }) }}</p>
<p>{{ t('admin.accounts.claudeResetCredits.clears', { windows: credit.clears.join(', ') }) }}</p>
<p v-for="(percent, window) in credit.percent_used" :key="window">{{ window }}: {{ percent }}%</p>
<p v-if="credit.blocking.length">{{ credit.blocking.join(', ') }}</p>
</div>
<p class="text-gray-500">{{ t('admin.accounts.claudeResetCredits.fetched', { time: status.fetched_at }) }}</p>
</template>
</div>
</template>
<script setup lang="ts">
import { 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
watch(() => props.account.id, () => { generation++; status.value = null; loading.value = false; error.value = false })
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,32 @@
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 snapshot = { eligible: true, available_count: 1, credits: [], fetched_at: '2026-09-25T00:00:00Z' }
describe('Claude reset credit status', () => {
beforeEach(() => getCredits.mockReset())
it('queries only on explicit request and renders fetched data', async () => {
getCredits.mockResolvedValue(snapshot)
const wrapper = mount(ClaudeResetCreditsCell, { props: { account } })
expect(getCredits).not.toHaveBeenCalled()
await wrapper.get('button').trigger('click')
await flushPromises()
expect(getCredits).toHaveBeenCalledWith(1)
expect(wrapper.text()).toContain('claudeResetCredits.count')
})
it('hides 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 } })
await wrapper.get('button').trigger('click')
await wrapper.setProps({ account: { ...account, id: 2, type: 'setup-token' } })
resolve(snapshot)
await flushPromises()
expect(wrapper.find('button').exists()).toBe(false)
expect(wrapper.text()).not.toContain('claudeResetCredits.count')
})
})
@@ -925,7 +925,8 @@ 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: {"query": "Claude reset credits", "error": "Could not refresh reset credits. No reset was performed.", "count": "Available resets: {count}", "ineligible": "This account is not eligible.", "cooldown": "Cooldown until {time}", "weekly": "Weekly window resets at {time}", "expires": "Expires at {time}", "clears": "Clears: {windows}", "fetched": "Checked at {time}"},
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 (%)',
@@ -1026,7 +1026,8 @@ export default {
autoPause5hDisabled: '禁用 5h 自动暂停',
autoPause7dDisabled: '禁用 7d 自动暂停',
autoPauseDisabledHint: '开启后该账号永不进入自动暂停(即使全局默认阈值已配置)。',
autoResetCredit: {
claudeResetCredits: {"query": "Claude 重置额度", "error": "无法刷新重置额度,未执行重置。", "count": "可用重置次数:{count}", "ineligible": "此账号不符合使用条件。", "cooldown": "冷却截止:{time}", "weekly": "每周窗口重置时间:{time}", "expires": "到期时间:{time}", "clears": "清除窗口:{windows}", "fetched": "查询时间:{time}"},
autoResetCredit: {
title: '自动使用重置卡',
hint: '仅在实际用量达到阈值时使用最早到期的可用卡;默认关闭。无卡或失败时账号保持暂停。',
threshold5h: '5h 自动用卡阈值(%)',