mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-21 04:04:34 +00:00
feat(accounts): align Claude reset credit query with Codex reset count button
The Claude reset credit query rendered as a bare "Claude 重置额度" link, which reads like a redeem action, and dumped raw ISO timestamps and window keys. Render it like OpenAIQuotaResetCell instead: a read-only "次数 N" button sharing one row with the local query button via a #pre-actions slot, plus an expiry chip whose tooltip carries the grant label, cleared windows and limit requirement. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
48970f61b4
commit
421da3f0d4
@@ -77,44 +77,46 @@
|
||||
color="amber"
|
||||
/>
|
||||
|
||||
<!-- Passive sampling label + active query button -->
|
||||
<div class="flex items-center gap-1.5 mt-0.5">
|
||||
<span
|
||||
v-if="usageInfo.source === 'passive'"
|
||||
class="text-[9px] text-gray-400 dark:text-gray-500 italic"
|
||||
>
|
||||
{{ t('admin.accounts.usageWindow.passiveSampled') }}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-0.5 rounded px-1.5 py-0.5 text-[9px] font-medium text-blue-600 hover:bg-blue-50 dark:text-blue-400 dark:hover:bg-blue-900/30 transition-colors"
|
||||
:disabled="activeQueryLoading"
|
||||
@click="loadActiveUsage"
|
||||
>
|
||||
<svg
|
||||
class="h-2.5 w-2.5"
|
||||
:class="{ 'animate-spin': activeQueryLoading }"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
<!-- Passive sampling label + active query button, sharing one row with the reset count -->
|
||||
<ClaudeResetCreditsCell :account="account" class="mt-0.5">
|
||||
<template #pre-actions>
|
||||
<span
|
||||
v-if="usageInfo.source === 'passive'"
|
||||
class="text-[9px] text-gray-400 dark:text-gray-500 italic"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
</svg>
|
||||
{{ t('admin.accounts.usageWindow.activeQuery') }}
|
||||
</button>
|
||||
</div>
|
||||
{{ t('admin.accounts.usageWindow.passiveSampled') }}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-0.5 rounded px-1.5 py-0.5 text-[10px] font-medium text-blue-600 hover:bg-blue-50 dark:text-blue-400 dark:hover:bg-blue-900/30 transition-colors disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="activeQueryLoading"
|
||||
@click="loadActiveUsage"
|
||||
>
|
||||
<svg
|
||||
class="h-2.5 w-2.5"
|
||||
:class="{ 'animate-spin': activeQueryLoading }"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
</svg>
|
||||
{{ t('admin.accounts.usageWindow.activeQuery') }}
|
||||
</button>
|
||||
</template>
|
||||
</ClaudeResetCreditsCell>
|
||||
</div>
|
||||
|
||||
<!-- No data yet -->
|
||||
<div v-else class="space-y-1">
|
||||
<div class="text-xs text-gray-400">-</div>
|
||||
</div>
|
||||
<ClaudeResetCreditsCell :account="account" />
|
||||
<ClaudeResetCreditsCell v-if="!usageInfo && account.type === 'oauth'" :account="account" class="mt-1" />
|
||||
</template>
|
||||
|
||||
<!-- OpenAI OAuth accounts: single source from /usage API -->
|
||||
|
||||
@@ -1,28 +1,66 @@
|
||||
<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 class="space-y-1">
|
||||
<!--
|
||||
Same action row layout as OpenAIQuotaResetCell: the parent's local
|
||||
"查询" button is passed in via #pre-actions so related buttons share one
|
||||
row. The reset count only shows for Anthropic OAuth accounts; the slot always
|
||||
renders. This cell is read-only — it never redeems a reset.
|
||||
-->
|
||||
<div class="flex flex-wrap items-center gap-1.5">
|
||||
<slot name="pre-actions" />
|
||||
|
||||
<button
|
||||
v-if="visible"
|
||||
type="button"
|
||||
data-testid="claude-reset-count"
|
||||
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"
|
||||
:title="countButtonTitle"
|
||||
@click="refresh"
|
||||
>
|
||||
<svg
|
||||
class="h-2.5 w-2.5"
|
||||
:class="{ 'animate-spin': loading }"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
</svg>
|
||||
{{ t('admin.accounts.claudeResetCredits.count') }}<span v-if="status" class="ml-0.5 tabular-nums">{{ status.available_count }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="visible && status && (primaryCredit || !status.eligible || status.cooldown_until)" class="flex flex-wrap items-center gap-1">
|
||||
<span
|
||||
v-if="primaryCredit?.expires_at"
|
||||
data-testid="claude-reset-expiry"
|
||||
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-dark-800 dark:text-gray-300"
|
||||
:title="creditTitle"
|
||||
>
|
||||
{{ t('admin.accounts.claudeResetCredits.expiresAt', { time: formatTime(primaryCredit.expires_at, 'short') }) }}
|
||||
</span>
|
||||
<span v-if="!status.eligible" class="text-[10px] text-amber-600 dark:text-amber-400">
|
||||
{{ t('admin.accounts.claudeResetCredits.ineligible') }}
|
||||
</span>
|
||||
<span v-if="status.cooldown_until" class="text-[10px] text-amber-600 dark:text-amber-400">
|
||||
{{ t('admin.accounts.claudeResetCredits.cooldown', { time: formatTime(status.cooldown_until, 'short') }) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="visible && error" role="alert" class="text-[10px] text-red-600 dark:text-red-400">
|
||||
{{ t('admin.accounts.claudeResetCredits.error') }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { Account } from '@/types'
|
||||
import { getClaudeResetCredits, type ClaudeResetCredits } from '@/api/admin/claudeResetCredits'
|
||||
@@ -33,8 +71,41 @@ const status = ref<ClaudeResetCredits | null>(null)
|
||||
const loading = ref(false)
|
||||
const error = ref(false)
|
||||
let generation = 0
|
||||
|
||||
const visible = computed(() => props.account.platform === 'anthropic' && props.account.type === 'oauth')
|
||||
|
||||
watch(() => props.account.id, () => { generation++; status.value = null; loading.value = false; error.value = false })
|
||||
|
||||
// 与 OpenAIQuotaResetCell 的到期时间格式保持一致
|
||||
const formatTime = (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)
|
||||
}
|
||||
|
||||
// 上游按 next_grant_id 排序,第一张即下一张会被使用的券
|
||||
const primaryCredit = computed(() => status.value?.credits[0] ?? null)
|
||||
|
||||
const creditTitle = computed(() => {
|
||||
const credit = primaryCredit.value
|
||||
if (!credit) return ''
|
||||
const lines = [credit.label]
|
||||
if (credit.expires_at) lines.push(t('admin.accounts.claudeResetCredits.expiresAtFull', { time: formatTime(credit.expires_at, 'full') }))
|
||||
if (credit.clears.length) lines.push(t('admin.accounts.claudeResetCredits.clears', { windows: credit.clears.join(', ') }))
|
||||
if (credit.use_requires_limit) lines.push(t('admin.accounts.claudeResetCredits.requiresLimit'))
|
||||
return lines.join('\n')
|
||||
})
|
||||
|
||||
const countButtonTitle = computed(() => {
|
||||
if (!status.value) return t('admin.accounts.claudeResetCredits.countTooltipLoad')
|
||||
return [
|
||||
t('admin.accounts.claudeResetCredits.countTooltipRefresh'),
|
||||
t('admin.accounts.claudeResetCredits.fetched', { time: formatTime(status.value.fetched_at, 'full') })
|
||||
].join('\n')
|
||||
})
|
||||
|
||||
async function refresh() {
|
||||
if (loading.value) return
|
||||
const current = ++generation
|
||||
|
||||
@@ -6,27 +6,48 @@ 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' }
|
||||
const credit = {
|
||||
selection_token: 'tok', label: 'Launch reset', resets_left: 1,
|
||||
expires_at: '2026-10-22T16:00:00Z', clears: ['five_hour', 'seven_day'],
|
||||
percent_used: {}, blocking: [], use_requires_limit: false, redeemable: true
|
||||
}
|
||||
const snapshot = { eligible: true, available_count: 1, credits: [credit], fetched_at: '2026-09-25T00:00:00Z' }
|
||||
const countButton = (wrapper: ReturnType<typeof mount>) => wrapper.find('[data-testid="claude-reset-count"]')
|
||||
|
||||
describe('Claude reset credit status', () => {
|
||||
beforeEach(() => getCredits.mockReset())
|
||||
it('queries only on explicit request and renders fetched data', async () => {
|
||||
|
||||
it('queries only on explicit request and shows count and expiry', async () => {
|
||||
getCredits.mockResolvedValue(snapshot)
|
||||
const wrapper = mount(ClaudeResetCreditsCell, { props: { account } })
|
||||
expect(getCredits).not.toHaveBeenCalled()
|
||||
await wrapper.get('button').trigger('click')
|
||||
expect(countButton(wrapper).text()).toBe('admin.accounts.claudeResetCredits.count')
|
||||
await countButton(wrapper).trigger('click')
|
||||
await flushPromises()
|
||||
expect(getCredits).toHaveBeenCalledWith(1)
|
||||
expect(wrapper.text()).toContain('claudeResetCredits.count')
|
||||
expect(countButton(wrapper).text()).toBe('admin.accounts.claudeResetCredits.count1')
|
||||
const expiry = wrapper.get('[data-testid="claude-reset-expiry"]')
|
||||
expect(expiry.attributes('title')).toContain('Launch reset')
|
||||
})
|
||||
it('hides setup tokens and discards responses after account changes', async () => {
|
||||
|
||||
it('offers no redeem action', () => {
|
||||
const wrapper = mount(ClaudeResetCreditsCell, { props: { account } })
|
||||
expect(wrapper.findAll('button')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('keeps slot content for 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')
|
||||
const wrapper = mount(ClaudeResetCreditsCell, {
|
||||
props: { account },
|
||||
slots: { 'pre-actions': '<button data-testid="local-query">q</button>' }
|
||||
})
|
||||
await countButton(wrapper).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')
|
||||
expect(countButton(wrapper).exists()).toBe(false)
|
||||
expect(wrapper.find('[data-testid="local-query"]').exists()).toBe(true)
|
||||
expect(wrapper.find('[data-testid="claude-reset-expiry"]').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -925,7 +925,19 @@ 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).',
|
||||
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}"},
|
||||
claudeResetCredits: {
|
||||
count: 'Resets',
|
||||
countTooltipLoad: 'Check remaining Claude resets (read-only, never consumes one)',
|
||||
countTooltipRefresh: 'Refresh remaining Claude resets (read-only, never consumes one)',
|
||||
fetched: 'Checked at {time}',
|
||||
error: 'Could not check reset credits',
|
||||
ineligible: 'This account cannot use resets right now',
|
||||
cooldown: 'Cooldown until {time}',
|
||||
expiresAt: 'Expires {time}',
|
||||
expiresAtFull: 'Reset credit expires at: {time}',
|
||||
clears: 'Clears windows: {windows}',
|
||||
requiresLimit: 'Usable only after hitting a limit'
|
||||
},
|
||||
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.',
|
||||
|
||||
@@ -1026,7 +1026,19 @@ export default {
|
||||
autoPause5hDisabled: '禁用 5h 自动暂停',
|
||||
autoPause7dDisabled: '禁用 7d 自动暂停',
|
||||
autoPauseDisabledHint: '开启后该账号永不进入自动暂停(即使全局默认阈值已配置)。',
|
||||
claudeResetCredits: {"query": "Claude 重置额度", "error": "无法刷新重置额度,未执行重置。", "count": "可用重置次数:{count}", "ineligible": "此账号不符合使用条件。", "cooldown": "冷却截止:{time}", "weekly": "每周窗口重置时间:{time}", "expires": "到期时间:{time}", "clears": "清除窗口:{windows}", "fetched": "查询时间:{time}"},
|
||||
claudeResetCredits: {
|
||||
count: '次数',
|
||||
countTooltipLoad: '点击查询 Claude 剩余重置次数(只读,不会消耗)',
|
||||
countTooltipRefresh: '点击刷新 Claude 剩余重置次数(只读,不会消耗)',
|
||||
fetched: '查询时间:{time}',
|
||||
error: '无法查询重置次数',
|
||||
ineligible: '此账号当前不可使用重置',
|
||||
cooldown: '冷却至 {time}',
|
||||
expiresAt: '到期 {time}',
|
||||
expiresAtFull: '重置次数到期时间:{time}',
|
||||
clears: '可清除窗口:{windows}',
|
||||
requiresLimit: '需达到限额后才能使用'
|
||||
},
|
||||
autoResetCredit: {
|
||||
title: '自动使用重置卡',
|
||||
hint: '仅在实际用量达到阈值时使用最早到期的可用卡;默认关闭。无卡或失败时账号保持暂停。',
|
||||
|
||||
Reference in New Issue
Block a user