feat(accounts): add confirmed Claude reset button next to reset count

Orange 「重置」 button mirrors the Codex cell: enabled only after a query
shows a redeemable credit, danger confirm, one idempotency key per
confirmation reused on retry, outcome-specific messages, then refreshes
credits and the usage row. Ported from upstream PR #7591.

Co-authored-by: korkin25 <eugeny.kuzakov@gmail.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
William Drake
2026-09-29 11:41:33 -04:00
co-authored by korkin25 Claude Opus 5.5
parent 196b47757a
commit 1872d44b58
6 changed files with 337 additions and 12 deletions
@@ -25,3 +25,23 @@ export async function getClaudeResetCredits(id: number): Promise<ClaudeResetCred
const { data } = await apiClient.get<ClaudeResetCredits>(`/admin/accounts/${id}/claude/reset-credits`)
return data
}
export type ClaudeResetOutcomeKind = 'reset' | 'already_used' | 'not_limited' | 'cooldown' | 'ineligible' | 'unknown'
export interface ClaudeResetOutcome {
outcome: ClaudeResetOutcomeKind
reason?: string
cleared?: string[]
cooldown_until?: string
credits?: ClaudeResetCredits
replayed: boolean
}
// Consumes one reset credit (irreversible). The server picks the grant; the key
// identifies one operator confirmation and must be reused when retrying it.
export async function redeemClaudeResetCredit(id: number, idempotencyKey: string): Promise<ClaudeResetOutcome> {
const { data } = await apiClient.post<ClaudeResetOutcome>(`/admin/accounts/${id}/claude/reset-credits/redeem`, undefined, {
headers: { 'Idempotency-Key': idempotencyKey }
})
return data
}
@@ -88,7 +88,7 @@
while usage is still loading survives the usage response. The local query
button shares its row once usage data exists.
-->
<ClaudeResetCreditsCell :account="account" class="mt-1">
<ClaudeResetCreditsCell :account="account" class="mt-1" @redeemed="loadActiveUsage">
<template v-if="usageInfo" #pre-actions>
<span
v-if="usageInfo.source === 'passive'"
@@ -4,7 +4,9 @@
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.
renders. The count button is read-only; only the orange reset button,
after a query shows a redeemable credit and the operator confirms,
consumes one reset.
-->
<div class="flex flex-wrap items-center gap-1.5">
<slot name="pre-actions" />
@@ -14,7 +16,7 @@
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"
:disabled="loading || redeeming"
:title="countButtonTitle"
@click="refresh"
>
@@ -34,6 +36,32 @@
</svg>
{{ t('admin.accounts.claudeResetCredits.count') }}<span v-if="status" class="ml-0.5 tabular-nums">{{ totalResets }}</span>
</button>
<button
v-if="visible"
type="button"
data-testid="claude-reset-redeem"
class="inline-flex items-center gap-0.5 rounded px-1.5 py-0.5 text-[10px] font-medium text-orange-600 transition-colors hover:bg-orange-50 disabled:cursor-not-allowed disabled:opacity-50 dark:text-orange-400 dark:hover:bg-orange-900/30"
:disabled="redeeming || loading || !canRedeem"
:title="redeemButtonTitle"
@click="openRedeemConfirm"
>
<svg
class="h-2.5 w-2.5"
:class="{ 'animate-spin': redeeming }"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M20 12a8 8 0 11-2.343-5.657L20 8m0 0V4m0 4h-4"
/>
</svg>
{{ t('admin.accounts.claudeResetCredits.reset') }}
</button>
</div>
<div v-if="visible && status && (primaryCredit || !status.eligible || cooldownActive)" class="flex flex-wrap items-center gap-1">
@@ -63,6 +91,28 @@
<div v-if="visible && error" role="alert" class="text-[10px] text-red-600 dark:text-red-400">
{{ t('admin.accounts.claudeResetCredits.error') }}
</div>
<div
v-if="visible && redeemFeedback"
data-testid="claude-reset-feedback"
:role="redeemFeedback.kind === 'success' ? 'status' : 'alert'"
class="text-[10px]"
:class="feedbackClass"
:title="redeemFeedback.text"
>
{{ redeemFeedback.text }}
</div>
<ConfirmDialog
v-if="visible"
:show="showRedeemConfirm"
:title="t('admin.accounts.claudeResetCredits.confirmTitle')"
:message="confirmMessage"
:confirm-text="t('admin.accounts.claudeResetCredits.reset')"
:cancel-text="t('common.cancel')"
danger
@confirm="confirmRedeem"
@cancel="showRedeemConfirm = false"
/>
</div>
</template>
@@ -70,18 +120,41 @@
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import type { Account } from '@/types'
import { getClaudeResetCredits, type ClaudeResetCredits } from '@/api/admin/claudeResetCredits'
import {
getClaudeResetCredits,
redeemClaudeResetCredit,
type ClaudeResetCredits,
type ClaudeResetOutcome
} from '@/api/admin/claudeResetCredits'
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
const props = defineProps<{ account: Account }>()
// Fired after a redemption attempt so the parent can refresh the usage row.
const emit = defineEmits<{ redeemed: [outcome: ClaudeResetOutcome] }>()
const { t } = useI18n()
const status = ref<ClaudeResetCredits | null>(null)
const loading = ref(false)
const error = ref(false)
const redeeming = ref(false)
const showRedeemConfirm = ref(false)
const redeemFeedback = ref<{ kind: 'success' | 'warning' | 'error'; text: string } | null>(null)
// One key per operator confirmation. It survives a failed request so retrying the
// same confirmation replays server-side instead of claiming a second reset.
let pendingKey: string | null = null
let generation = 0
const visible = computed(() => props.account.platform === 'anthropic' && props.account.type === 'oauth')
watch(() => [props.account.id, props.account.platform, props.account.type], () => { generation++; status.value = null; loading.value = false; error.value = false })
watch(() => [props.account.id, props.account.platform, props.account.type], () => {
generation++
status.value = null
loading.value = false
error.value = false
redeeming.value = false
showRedeemConfirm.value = false
redeemFeedback.value = null
pendingKey = null
})
// 与 OpenAIQuotaResetCell 的到期时间格式保持一致
const formatTime = (value: string, style: 'short' | 'full'): string => {
@@ -131,8 +204,100 @@ const countButtonTitle = computed(() => {
].join('\n')
})
const canRedeem = computed(() => (status.value?.available_count ?? 0) > 0)
const redeemButtonTitle = computed(() => {
if (!status.value) return t('admin.accounts.claudeResetCredits.resetTooltipNeedQuery')
if (!canRedeem.value) return t('admin.accounts.claudeResetCredits.resetTooltipNone')
return t('admin.accounts.claudeResetCredits.resetTooltipReady')
})
const confirmMessage = computed(() => {
const next = status.value?.credits.find(c => c.redeemable)
return t('admin.accounts.claudeResetCredits.confirmMessage', {
windows: next?.clears.join(', ') || '—',
count: totalResets.value
})
})
const feedbackClass = computed(() => {
switch (redeemFeedback.value?.kind) {
case 'success': return 'text-emerald-600 dark:text-emerald-400'
case 'warning': return 'text-amber-600 dark:text-amber-400'
default: return 'text-red-600 dark:text-red-400'
}
})
function newOperationKey(accountID: number): string {
const id = globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`
return `claude-reset-${accountID}-${id}`
}
function openRedeemConfirm() {
if (redeeming.value || loading.value || !canRedeem.value) return
showRedeemConfirm.value = true
}
function outcomeFeedback(result: ClaudeResetOutcome): { kind: 'success' | 'warning' | 'error'; text: string } {
const key = 'admin.accounts.claudeResetCredits.outcome'
switch (result.outcome) {
case 'reset':
return { kind: 'success', text: t(`${key}.reset`, { windows: result.cleared?.join(', ') || '—' }) }
case 'already_used':
return { kind: 'warning', text: t(`${key}.alreadyUsed`) }
case 'cooldown':
return {
kind: 'warning',
text: result.cooldown_until
? t(`${key}.cooldownUntil`, { time: formatTime(result.cooldown_until, 'short') })
: t(`${key}.cooldown`)
}
case 'not_limited':
return { kind: 'warning', text: t(`${key}.notLimited`) }
case 'ineligible':
return { kind: 'error', text: t(`${key}.ineligible`) }
default:
return { kind: 'warning', text: t(`${key}.unknown`) }
}
}
function errorText(e: unknown): string {
const err = e as { reason?: string; message?: string }
switch (err?.reason) {
case 'CLAUDE_RESET_UNRESOLVED': return t('admin.accounts.claudeResetCredits.outcome.unknown')
case 'CLAUDE_RESET_BUSY': return t('admin.accounts.claudeResetCredits.outcome.busy')
case 'CLAUDE_RESET_NOT_AVAILABLE': return t('admin.accounts.claudeResetCredits.outcome.notAvailable')
default: return err?.message || t('admin.accounts.claudeResetCredits.outcome.failed')
}
}
async function confirmRedeem() {
showRedeemConfirm.value = false
if (redeeming.value || loading.value || !canRedeem.value) return
const accountID = props.account.id
const current = generation
pendingKey ??= newOperationKey(accountID)
redeeming.value = true
redeemFeedback.value = null
try {
const result = await redeemClaudeResetCredit(accountID, pendingKey)
if (current !== generation) return
// A definite server answer ends this confirmation; the next one gets a new key.
pendingKey = null
redeemFeedback.value = outcomeFeedback(result)
redeeming.value = false
emit('redeemed', result)
await refresh()
} catch (e) {
if (current !== generation) return
redeemFeedback.value = { kind: 'error', text: errorText(e) }
} finally {
if (current === generation) redeeming.value = false
}
}
async function refresh() {
if (loading.value) return
if (loading.value || redeeming.value) return
const current = ++generation
loading.value = true
error.value = false
@@ -3,8 +3,19 @@ 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 }))
const redeem = vi.hoisted(() => vi.fn())
vi.mock('@/api/admin/claudeResetCredits', () => ({ getClaudeResetCredits: getCredits, redeemClaudeResetCredit: redeem }))
vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (key: string) => key }) }))
// Minimal stand-in exposing the dialog's show state and confirm/cancel events.
vi.mock('@/components/common/ConfirmDialog.vue', () => ({
default: {
props: { show: Boolean, title: String, message: String, confirmText: String, cancelText: String, danger: Boolean },
emits: ['confirm', 'cancel'],
template: `<div v-if="show" data-testid="confirm-dialog" :data-danger="String(danger)">{{ message }}
<button data-testid="confirm-ok" @click="$emit('confirm')">ok</button>
<button data-testid="confirm-cancel" @click="$emit('cancel')">cancel</button></div>`
}
}))
const account = { id: 1, platform: 'anthropic', type: 'oauth' } as Account
const credit = {
label: 'Launch reset', resets_left: 1,
@@ -13,9 +24,22 @@ const credit = {
}
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"]')
const redeemButton = (wrapper: ReturnType<typeof mount>) => wrapper.get('[data-testid="claude-reset-redeem"]')
async function queried(result: unknown = snapshot) {
getCredits.mockResolvedValue(result)
const wrapper = mount(ClaudeResetCreditsCell, { props: { account } })
await countButton(wrapper).trigger('click')
await flushPromises()
return wrapper
}
async function confirmReset(wrapper: ReturnType<typeof mount>) {
await redeemButton(wrapper).trigger('click')
await wrapper.get('[data-testid="confirm-ok"]').trigger('click')
await flushPromises()
}
describe('Claude reset credit status', () => {
beforeEach(() => getCredits.mockReset())
beforeEach(() => { getCredits.mockReset(); redeem.mockReset() })
it('queries only on explicit request and shows count and expiry', async () => {
getCredits.mockResolvedValue(snapshot)
@@ -52,9 +76,89 @@ describe('Claude reset credit status', () => {
expect(wrapper.find('[data-testid="claude-reset-not-usable"]').exists()).toBe(true)
})
it('offers no redeem action', () => {
it('disables reset until a query shows a redeemable credit', async () => {
const wrapper = mount(ClaudeResetCreditsCell, { props: { account } })
expect(wrapper.findAll('button')).toHaveLength(1)
expect(redeemButton(wrapper).attributes('disabled')).toBeDefined()
await redeemButton(wrapper).trigger('click')
expect(wrapper.find('[data-testid="confirm-dialog"]').exists()).toBe(false)
const waiting = await queried({ ...snapshot, available_count: 0, credits: [{ ...credit, redeemable: false }] })
expect(redeemButton(waiting).attributes('disabled')).toBeDefined()
let resolve!: (value: typeof snapshot) => void
getCredits.mockReturnValue(new Promise(r => { resolve = r }))
const loading = mount(ClaudeResetCreditsCell, { props: { account } })
await countButton(loading).trigger('click')
expect(redeemButton(loading).attributes('disabled')).toBeDefined()
resolve(snapshot)
await flushPromises()
expect(redeemButton(loading).attributes('disabled')).toBeUndefined()
expect(redeem).not.toHaveBeenCalled()
})
it('never redeems without confirmation, and the count button never redeems', async () => {
const wrapper = await queried()
await countButton(wrapper).trigger('click')
await flushPromises()
await redeemButton(wrapper).trigger('click')
const dialog = wrapper.get('[data-testid="confirm-dialog"]')
expect(dialog.attributes('data-danger')).toBe('true')
expect(dialog.text()).toContain('admin.accounts.claudeResetCredits.confirmMessage')
await wrapper.get('[data-testid="confirm-cancel"]').trigger('click')
await flushPromises()
expect(wrapper.find('[data-testid="confirm-dialog"]').exists()).toBe(false)
expect(redeem).not.toHaveBeenCalled()
})
it('redeems after confirmation, reports success, refreshes and notifies the parent', async () => {
const wrapper = await queried()
redeem.mockResolvedValue({ outcome: 'reset', cleared: ['five_hour'], replayed: false })
getCredits.mockResolvedValue({ ...snapshot, available_count: 0, credits: [] })
await confirmReset(wrapper)
expect(redeem).toHaveBeenCalledTimes(1)
expect(redeem.mock.calls[0][0]).toBe(1)
expect(redeem.mock.calls[0][1]).toMatch(/^claude-reset-1-/)
expect(wrapper.get('[data-testid="claude-reset-feedback"]').text()).toBe('admin.accounts.claudeResetCredits.outcome.reset')
expect(getCredits).toHaveBeenCalledTimes(2)
expect(wrapper.emitted('redeemed')).toHaveLength(1)
expect(redeemButton(wrapper).attributes('disabled')).toBeDefined()
})
it('reuses the key when retrying a failed confirmation and rotates it after an answer', async () => {
const wrapper = await queried()
redeem.mockRejectedValueOnce({ status: 0, message: 'Network Error' })
await confirmReset(wrapper)
expect(wrapper.get('[data-testid="claude-reset-feedback"]').text()).toBe('Network Error')
redeem.mockResolvedValueOnce({ outcome: 'not_limited', replayed: false })
await confirmReset(wrapper)
expect(redeem.mock.calls[1][1]).toBe(redeem.mock.calls[0][1])
redeem.mockResolvedValueOnce({ outcome: 'not_limited', replayed: false })
await confirmReset(wrapper)
expect(redeem.mock.calls[2][1]).not.toBe(redeem.mock.calls[0][1])
})
it.each([
['already_used', 'alreadyUsed'],
['cooldown', 'cooldown'],
['not_limited', 'notLimited'],
['ineligible', 'ineligible'],
['unknown', 'unknown']
])('maps outcome %s to a clear message', async (outcome, key) => {
const wrapper = await queried()
redeem.mockResolvedValue({ outcome, replayed: false })
await confirmReset(wrapper)
expect(wrapper.get('[data-testid="claude-reset-feedback"]').text()).toBe(`admin.accounts.claudeResetCredits.outcome.${key}`)
expect(getCredits).toHaveBeenCalledTimes(2)
})
it('maps server refusals to clear messages', async () => {
const wrapper = await queried()
redeem.mockRejectedValueOnce({ status: 409, reason: 'CLAUDE_RESET_UNRESOLVED', message: 'x' })
await confirmReset(wrapper)
expect(wrapper.get('[data-testid="claude-reset-feedback"]').text()).toBe('admin.accounts.claudeResetCredits.outcome.unknown')
redeem.mockRejectedValueOnce({ status: 409, reason: 'CLAUDE_RESET_BUSY', message: 'x' })
await confirmReset(wrapper)
expect(wrapper.get('[data-testid="claude-reset-feedback"]').text()).toBe('admin.accounts.claudeResetCredits.outcome.busy')
})
it('keeps slot content for setup tokens and discards responses after account changes', async () => {
+19 -1
View File
@@ -938,7 +938,25 @@ export default {
expiresAtFull: 'Reset credit expires at: {time}',
clears: 'Clears windows: {windows}',
notUsableNow: 'Not usable now',
requiresLimit: 'Usable only after hitting a limit'
requiresLimit: 'Usable only after hitting a limit',
reset: 'Reset',
resetTooltipNeedQuery: 'Check the count first; reset is available once a usable credit is found',
resetTooltipNone: 'No reset can be used right now',
resetTooltipReady: 'Consume 1 reset to clear limit windows (asks for confirmation)',
confirmTitle: 'Use a Claude reset',
confirmMessage: 'This immediately consumes 1 reset and cannot be undone. Windows cleared: {windows}. Resets held: {count}. Continue?',
outcome: {
reset: 'Reset applied; cleared: {windows}',
alreadyUsed: 'This reset was already used; refreshing to confirm',
cooldown: 'Resets are cooling down; try again later',
cooldownUntil: 'Resets are cooling down until {time}',
notLimited: 'Not at a limit, so nothing was reset and no credit was used',
ineligible: 'This account cannot use resets right now',
unknown: 'Result unconfirmed; further redemption is blocked for now. Check again later',
busy: 'Another reset is in progress; try again later',
notAvailable: 'No reset can be used right now; no credit was used',
failed: 'Reset request failed'
}
},
autoResetCredit: {
title: 'Automatically use reset credits',
+19 -1
View File
@@ -1039,7 +1039,25 @@ export default {
expiresAtFull: '重置次数到期时间:{time}',
clears: '可清除窗口:{windows}',
notUsableNow: '暂不可用',
requiresLimit: '需达到限额后才能使用'
requiresLimit: '需达到限额后才能使用',
reset: '重置',
resetTooltipNeedQuery: '请先点「次数」查询;查询到可用的重置后才能使用',
resetTooltipNone: '当前没有可立即使用的重置',
resetTooltipReady: '消耗 1 次重置,清除限额窗口(需确认)',
confirmTitle: '使用 Claude 重置',
confirmMessage: '将立即消耗 1 次重置,此操作不可撤销。将清除窗口:{windows}。当前持有 {count} 次。确定继续?',
outcome: {
reset: '重置成功,已清除:{windows}',
alreadyUsed: '该重置已被使用,正在刷新确认',
cooldown: '重置处于冷却中,请稍后再试',
cooldownUntil: '重置处于冷却中,冷却至 {time}',
notLimited: '当前未达到限额,无需重置,未消耗次数',
ineligible: '此账号当前不可使用重置',
unknown: '结果未确认,已阻止再次兑换,请稍后查询',
busy: '另一个重置正在进行中,请稍后再试',
notAvailable: '当前没有可立即使用的重置,未消耗次数',
failed: '重置请求失败'
}
},
autoResetCredit: {
title: '自动使用重置卡',