From 1872d44b58b0a1d800e7aba125460ddef184f9be Mon Sep 17 00:00:00 2001 From: William Drake Date: Tue, 29 Sep 2026 11:41:33 -0400 Subject: [PATCH] feat(accounts): add confirmed Claude reset button next to reset count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Co-Authored-By: Claude Opus 5.5 (1M context) --- frontend/src/api/admin/claudeResetCredits.ts | 20 ++ .../components/account/AccountUsageCell.vue | 2 +- .../account/ClaudeResetCreditsCell.vue | 175 +++++++++++++++++- .../__tests__/ClaudeResetCreditsCell.spec.ts | 112 ++++++++++- .../src/i18n/locales/en/admin/accounts.ts | 20 +- .../src/i18n/locales/zh/admin/accounts.ts | 20 +- 6 files changed, 337 insertions(+), 12 deletions(-) diff --git a/frontend/src/api/admin/claudeResetCredits.ts b/frontend/src/api/admin/claudeResetCredits.ts index f7011d05cc..d23360a6e2 100644 --- a/frontend/src/api/admin/claudeResetCredits.ts +++ b/frontend/src/api/admin/claudeResetCredits.ts @@ -25,3 +25,23 @@ export async function getClaudeResetCredits(id: number): Promise(`/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 { + const { data } = await apiClient.post(`/admin/accounts/${id}/claude/reset-credits/redeem`, undefined, { + headers: { 'Idempotency-Key': idempotencyKey } + }) + return data +} diff --git a/frontend/src/components/account/AccountUsageCell.vue b/frontend/src/components/account/AccountUsageCell.vue index 68977610ed..eb47cf8074 100644 --- a/frontend/src/components/account/AccountUsageCell.vue +++ b/frontend/src/components/account/AccountUsageCell.vue @@ -88,7 +88,7 @@ while usage is still loading survives the usage response. The local query button shares its row once usage data exists. --> - + @@ -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(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 diff --git a/frontend/src/components/account/__tests__/ClaudeResetCreditsCell.spec.ts b/frontend/src/components/account/__tests__/ClaudeResetCreditsCell.spec.ts index 81aca5a479..b9b7b383ca 100644 --- a/frontend/src/components/account/__tests__/ClaudeResetCreditsCell.spec.ts +++ b/frontend/src/components/account/__tests__/ClaudeResetCreditsCell.spec.ts @@ -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: `
{{ message }} + +
` + } +})) 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) => wrapper.find('[data-testid="claude-reset-count"]') +const redeemButton = (wrapper: ReturnType) => 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) { + 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 () => { diff --git a/frontend/src/i18n/locales/en/admin/accounts.ts b/frontend/src/i18n/locales/en/admin/accounts.ts index 943db3cc1f..c8b656de5a 100644 --- a/frontend/src/i18n/locales/en/admin/accounts.ts +++ b/frontend/src/i18n/locales/en/admin/accounts.ts @@ -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', diff --git a/frontend/src/i18n/locales/zh/admin/accounts.ts b/frontend/src/i18n/locales/zh/admin/accounts.ts index 06a07e8fa3..c1df709fd4 100644 --- a/frontend/src/i18n/locales/zh/admin/accounts.ts +++ b/frontend/src/i18n/locales/zh/admin/accounts.ts @@ -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: '自动使用重置卡',