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:
William Drake
2026-09-28 18:06:23 -04:00
co-authored by Claude Opus 5.5
parent 48970f61b4
commit 421da3f0d4
5 changed files with 180 additions and 62 deletions
@@ -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)
})
})
+13 -1
View File
@@ -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.',
+13 -1
View File
@@ -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: '仅在实际用量达到阈值时使用最早到期的可用卡;默认关闭。无卡或失败时账号保持暂停。',