mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-16 14:57:36 +08:00
feat(accounts): show Claude reset credit status on demand
This commit is contained in:
@@ -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 自动用卡阈值(%)',
|
||||
|
||||
Reference in New Issue
Block a user