feat(admin): allow selecting quota reset credits

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Zyy
2026-08-22 09:19:20 +08:00
co-authored by Codex
parent 80f338ce01
commit 2c31708d1b
4 changed files with 185 additions and 48 deletions
@@ -203,10 +203,13 @@ onBeforeUnmount(() => {
tabindex="-1"
>
<header
class="grid shrink-0 items-start gap-3 p-4 pb-0 sm:gap-4 sm:p-6 sm:pb-0"
:class="tone === 'neutral'
? 'grid-cols-[minmax(0,1fr)_28px]'
: 'grid-cols-[auto_minmax(0,1fr)_28px]'"
class="grid shrink-0 gap-3 p-4 pb-0 sm:gap-4 sm:p-6 sm:pb-0"
:class="[
tone === 'neutral'
? 'grid-cols-[minmax(0,1fr)_28px]'
: 'grid-cols-[auto_minmax(0,1fr)_28px]',
description ? 'items-start' : 'items-center',
]"
>
<span
v-if="tone !== 'neutral'"
@@ -0,0 +1,64 @@
<script setup lang="ts">
import { computed } from 'vue'
const props = withDefaults(
defineProps<{
value: string
name: string
label: string
disabled?: boolean
showLabel?: boolean
}>(),
{
disabled: false,
showLabel: false,
},
)
defineSlots<{
default?: () => unknown
}>()
const model = defineModel<string>({ required: true })
const checked = computed(() => model.value === props.value)
const indicatorClasses = computed(() => [
'relative inline-grid size-4 shrink-0 place-items-center rounded-full transition-colors duration-150 motion-reduce:transition-none',
props.disabled
? 'bg-cp-disabled text-cp-disabled-icon'
: checked.value
? 'bg-cp-accent text-cp-accent-on'
: 'bg-cp-muted text-transparent group-hover:bg-cp-default-active',
])
</script>
<template>
<!-- The native radio is nested so the full label remains one accessible click target. -->
<!-- eslint-disable-next-line vue-a11y/label-has-for -->
<label
class="group relative inline-flex min-h-4 min-w-4 items-center gap-2.5"
:class="disabled ? 'cursor-not-allowed opacity-55' : 'cursor-pointer'"
>
<input
v-model="model"
type="radio"
class="peer sr-only"
:name="name"
:value="value"
:aria-label="label"
:disabled="disabled"
>
<span
:class="indicatorClasses"
class="peer-focus-visible:ring-2 peer-focus-visible:ring-cp-accent-border peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-cp-surface"
aria-hidden="true"
>
<span class="size-1.5 rounded-full bg-current" />
</span>
<span v-if="$slots.default" class="min-w-0 flex-1">
<slot />
</span>
<span v-else-if="showLabel" class="text-[13px] leading-none font-emphasis">
{{ label }}
</span>
</label>
</template>
@@ -1,13 +1,14 @@
<script setup lang="ts">
import type { Account } from '@/api'
import { AlertTriangle, RefreshCw, RotateCcw, TicketCheck } from '@lucide/vue'
import { AlertTriangle, RefreshCw, TicketCheck } from '@lucide/vue'
import dayjs from 'dayjs'
import { computed, shallowRef, watch } from 'vue'
import { computed, shallowRef, useId, watch } from 'vue'
import BaseButton from '@/components/base/BaseButton.vue'
import BaseEmpty from '@/components/base/BaseEmpty.vue'
import BaseIconButton from '@/components/base/BaseIconButton.vue'
import BaseModal from '@/components/base/BaseModal/index.vue'
import BaseRadio from '@/components/base/BaseRadio.vue'
import { useAccountResetCredits } from '../composables/useAccountResetCredits'
const props = defineProps<{
@@ -21,6 +22,9 @@ const emit = defineEmits<{
const {
availableCredits,
availableCount,
selectedCreditId,
consumptionCredit,
canRequestConsume,
hasSnapshot,
loading,
consuming,
@@ -28,6 +32,7 @@ const {
ambiguous,
showConfirm,
loadCredits,
selectCredit,
requestConsume,
cancelConsume,
confirmConsume,
@@ -37,14 +42,15 @@ const {
})
const panelOpen = shallowRef(false)
const confirmTitle = computed(() => ambiguous.value ? '确认上次重置结果?' : '立即重置额度?')
const confirmDescription = computed(() => ambiguous.value
? '上次请求的结果不确定。本次会复用同一个请求标识,只用于确认或完成原操作。'
: '这会消费一张上游主动重置卡。操作完成后将立即重新读取额度窗口。')
const modalTitle = computed(() => showConfirm.value ? confirmTitle.value : '主动重置额度')
const modalDescription = computed(() => showConfirm.value
? confirmDescription.value
: '查看可用重置卡,并按需提前开启新的 Codex 额度窗口。')
const creditRadioName = `account-reset-credit-${useId()}`
const modalTitle = computed(() => {
if (!showConfirm.value)
return '主动重置额度'
return ambiguous.value ? '确认上次重置' : '确认重置额度'
})
const modalDescription = computed(() =>
showConfirm.value ? undefined : '选择一张重置卡后继续',
)
const triggerLabel = computed(() => {
if (ambiguous.value)
return '查看主动重置卡,有一项操作待确认'
@@ -59,7 +65,7 @@ const triggerLabel = computed(() => {
})
const showTriggerCount = computed(() => hasSnapshot.value && availableCount.value > 0)
const confirmCreditTitle = computed(() =>
availableCredits.value[0]?.title || 'Codex 主动重置卡',
consumptionCredit.value?.title || 'Codex 主动重置卡',
)
watch(panelOpen, (isOpen) => {
@@ -77,6 +83,19 @@ function expiryLabel(value: string | null) {
const expiry = dayjs(value)
return expiry.isValid() ? `${expiry.format('YYYY-MM-DD HH:mm')} 到期` : '到期时间未知'
}
function creditOptionClasses(creditId: string) {
return [
'w-full min-w-0 rounded-cp-control px-4 py-3 outline-none transition-colors duration-150 motion-reduce:transition-none',
selectedCreditId.value === creditId
? 'bg-cp-info-bg'
: 'bg-cp-subtle hover:bg-cp-default-hover',
]
}
function creditOptionLabel(credit: { title: string | null, expiresAt: string | null }) {
return `${credit.title || 'Codex 主动重置卡'},${expiryLabel(credit.expiresAt)}`
}
</script>
<template>
@@ -115,8 +134,11 @@ function expiryLabel(value: string | null) {
<p class="mt-1.5 mb-0 text-[14px] leading-snug font-heavy text-cp-primary">
{{ confirmCreditTitle }}
</p>
<p class="mt-2 mb-0 text-[12px] leading-[1.55] font-emphasis text-cp-secondary">
只消费上游重置卡;完成后系统会回读官方额度,不会直接改写本地重置时间。
<p
v-if="consumptionCredit"
class="mt-1 mb-0 font-mono text-[10px] leading-normal font-emphasis text-cp-muted-text"
>
{{ expiryLabel(consumptionCredit.expiresAt) }}
</p>
</section>
</div>
@@ -133,7 +155,7 @@ function expiryLabel(value: string | null) {
上次操作结果待确认
</p>
<p class="mt-1 mb-0 text-[11px] leading-normal font-emphasis text-cp-secondary">
再次确认会复用原请求标识,不会创建新的消费操作。
再确认一次即可。
</p>
</div>
</section>
@@ -162,7 +184,7 @@ function expiryLabel(value: string | null) {
<section class="grid gap-2.5">
<div class="flex min-h-8 items-center justify-between gap-3">
<h3 class="m-0 text-[12px] font-heavy text-cp-muted-text">
卡片明细
选择重置卡
</h3>
<BaseIconButton
variant="ghost"
@@ -187,22 +209,32 @@ function expiryLabel(value: string | null) {
{{ loadError }},请刷新重试。
</p>
<div v-else-if="availableCredits.length" class="grid gap-2">
<article
<div
v-else-if="availableCredits.length"
class="grid gap-2"
role="radiogroup"
aria-label="选择要使用的重置卡"
>
<BaseRadio
v-for="credit in availableCredits"
:key="credit.id"
class="flex min-w-0 items-center gap-3 rounded-cp-control bg-cp-subtle px-4 py-3"
:model-value="selectedCreditId"
:value="credit.id"
:name="creditRadioName"
:label="creditOptionLabel(credit)"
:disabled="ambiguous || consuming"
:class="creditOptionClasses(credit.id)"
@update:model-value="selectCredit"
>
<span class="size-1.5 shrink-0 rounded-full bg-cp-info" aria-hidden="true" />
<div class="min-w-0">
<p class="m-0 truncate text-[12px] font-heavy text-cp-primary">
<span class="block min-w-0">
<span class="block truncate text-[12px] font-heavy text-cp-primary">
{{ credit.title || 'Codex 主动重置卡' }}
</p>
<p class="mt-1 mb-0 truncate font-mono text-[10px] font-emphasis text-cp-muted-text">
</span>
<span class="mt-1 block truncate font-mono text-[10px] font-emphasis text-cp-muted-text">
{{ expiryLabel(credit.expiresAt) }}
</p>
</div>
</article>
</span>
</span>
</BaseRadio>
</div>
<div v-else class="overflow-hidden rounded-cp-control bg-cp-subtle">
@@ -223,7 +255,7 @@ function expiryLabel(value: string | null) {
返回
</BaseButton>
<BaseButton variant="primary" :loading="consuming" @click="confirmConsume">
{{ ambiguous ? '确认原操作' : '消费并重置' }}
{{ ambiguous ? '再次确认' : '确认重置' }}
</BaseButton>
</template>
<template v-else>
@@ -232,13 +264,10 @@ function expiryLabel(value: string | null) {
</BaseButton>
<BaseButton
variant="primary"
:disabled="loading || consuming || (!ambiguous && availableCount <= 0)"
:disabled="loading || consuming || !canRequestConsume"
@click="requestConsume"
>
<template #icon>
<RotateCcw class="size-3.5" />
</template>
{{ ambiguous ? '确认原操作' : '立即重置' }}
{{ ambiguous ? '继续确认' : '下一步' }}
</BaseButton>
</template>
</template>
@@ -12,7 +12,8 @@ import { errorMessage } from '@/utils/async'
interface PendingResetCreditOperation {
accountId: string
creditId?: string
creditId: string
credit: AccountResetCredit
redeemRequestId: string
hasTransportFailure: boolean
}
@@ -36,13 +37,37 @@ export function useAccountResetCredits(options: {
const consuming = shallowRef(false)
const loadError = shallowRef('')
const showConfirm = shallowRef(false)
const selectedCreditId = shallowRef('')
const pendingOperation = shallowRef<PendingResetCreditOperation | null>(null)
let loadSequence = 0
const availableCredits = computed(() =>
credits.value.filter(credit => credit.status === 'available'),
)
const selectedCredit = computed(() =>
availableCredits.value.find(credit => credit.id === selectedCreditId.value),
)
const consumptionCredit = computed(() =>
pendingOperation.value?.credit ?? selectedCredit.value,
)
const ambiguous = computed(() => pendingOperation.value?.hasTransportFailure === true)
const canRequestConsume = computed(() =>
ambiguous.value || (availableCount.value > 0 && selectedCredit.value !== undefined),
)
function reconcileSelectedCredit(accountId: string) {
if (accountId !== options.accountId())
return
const operation = pendingOperation.value
if (operation?.accountId === accountId) {
selectedCreditId.value = operation.creditId
return
}
if (!selectedCredit.value)
selectedCreditId.value = ''
}
function applySnapshot(accountId: string, snapshot: ResetCreditsSnapshot) {
snapshotsByAccountId.set(accountId, snapshot)
@@ -51,6 +76,7 @@ export function useAccountResetCredits(options: {
credits.value = snapshot.credits
availableCount.value = snapshot.availableCount
hasSnapshot.value = true
reconcileSelectedCredit(accountId)
}
function restoreSnapshot(accountId: string) {
@@ -58,15 +84,22 @@ export function useAccountResetCredits(options: {
credits.value = snapshot?.credits ?? []
availableCount.value = snapshot?.availableCount ?? 0
hasSnapshot.value = snapshot !== undefined
reconcileSelectedCredit(accountId)
}
function selectCredit(creditId: string) {
if (pendingOperation.value || consuming.value)
return
if (!availableCredits.value.some(credit => credit.id === creditId))
return
selectedCreditId.value = creditId
}
function applyConfirmedConsumption(operation: PendingResetCreditOperation) {
const snapshot = snapshotsByAccountId.get(operation.accountId)
if (!snapshot)
return
const creditIndex = operation.creditId
? snapshot.credits.findIndex(credit => credit.id === operation.creditId)
: snapshot.credits.findIndex(credit => credit.status === 'available')
const creditIndex = snapshot.credits.findIndex(credit => credit.id === operation.creditId)
const nextCredits = creditIndex < 0
? snapshot.credits
: snapshot.credits.filter((_, index) => index !== creditIndex)
@@ -107,9 +140,13 @@ export function useAccountResetCredits(options: {
if (!existing || existing.accountId !== accountId) {
if (availableCount.value <= 0)
return
const credit = selectedCredit.value
if (!credit)
return
pendingOperation.value = {
accountId,
creditId: availableCredits.value[0]?.id,
creditId: credit.id,
credit,
redeemRequestId: globalThis.crypto.randomUUID(),
hasTransportFailure: false,
}
@@ -145,21 +182,20 @@ export function useAccountResetCredits(options: {
return
}
toast.success(
result.code === 'already_redeemed'
? '已确认上次操作完成'
: '额度已重置',
)
const successMessage = result.code === 'already_redeemed'
? '上次重置已完成'
: '额度已重置'
applyConfirmedConsumption(operation)
await loadCredits()
try {
const quota = await refreshAccountQuota({ accountId: operation.accountId })
if (operation.accountId === options.accountId())
options.onAccountUpdated(quota.account)
toast.success(successMessage)
}
catch (error: unknown) {
toast.warning(
`重置卡已消费,额度快照刷新失败:${errorMessage(error, '请手动刷新额度')}`,
`${successMessage},但最新额度加载失败:${errorMessage(error, '请手动刷新额度')}`,
{ duration: 5000 },
)
}
@@ -188,10 +224,11 @@ export function useAccountResetCredits(options: {
options.accountId,
(accountId) => {
loadSequence += 1
pendingOperation.value = null
selectedCreditId.value = ''
restoreSnapshot(accountId)
loading.value = false
loadError.value = ''
pendingOperation.value = null
showConfirm.value = false
},
{ immediate: true },
@@ -201,6 +238,9 @@ export function useAccountResetCredits(options: {
credits,
availableCredits,
availableCount,
selectedCreditId,
consumptionCredit,
canRequestConsume,
hasSnapshot,
loading,
consuming,
@@ -208,6 +248,7 @@ export function useAccountResetCredits(options: {
ambiguous,
showConfirm,
loadCredits,
selectCredit,
requestConsume,
cancelConsume,
confirmConsume,