mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 08:06:19 +00:00
feat(admin): allow selecting quota reset credits
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user