From 2c31708d1bd2def3d1c12274a4cc898f332fd4c8 Mon Sep 17 00:00:00 2001
From: Zyy
Date: Sat, 22 Aug 2026 09:19:20 +0800
Subject: [PATCH] feat(admin): allow selecting quota reset credits
Co-authored-by: Codex
---
.../src/components/base/BaseModal/index.vue | 11 ++-
frontend/src/components/base/BaseRadio.vue | 64 +++++++++++++
.../components/AccountResetCredits.vue | 93 ++++++++++++-------
.../composables/useAccountResetCredits.ts | 65 ++++++++++---
4 files changed, 185 insertions(+), 48 deletions(-)
create mode 100644 frontend/src/components/base/BaseRadio.vue
diff --git a/frontend/src/components/base/BaseModal/index.vue b/frontend/src/components/base/BaseModal/index.vue
index e46eee3a9..b039f8a96 100644
--- a/frontend/src/components/base/BaseModal/index.vue
+++ b/frontend/src/components/base/BaseModal/index.vue
@@ -203,10 +203,13 @@ onBeforeUnmount(() => {
tabindex="-1"
>
+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({ 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',
+])
+
+
+
+
+
+
+
diff --git a/frontend/src/views/accounts/components/AccountResetCredits.vue b/frontend/src/views/accounts/components/AccountResetCredits.vue
index f163bb509..a9a1e2ba8 100644
--- a/frontend/src/views/accounts/components/AccountResetCredits.vue
+++ b/frontend/src/views/accounts/components/AccountResetCredits.vue
@@ -1,13 +1,14 @@
@@ -115,8 +134,11 @@ function expiryLabel(value: string | null) {
{{ confirmCreditTitle }}
-
- 只消费上游重置卡;完成后系统会回读官方额度,不会直接改写本地重置时间。
+
+ {{ expiryLabel(consumptionCredit.expiresAt) }}
@@ -133,7 +155,7 @@ function expiryLabel(value: string | null) {
上次操作结果待确认
- 再次确认会复用原请求标识,不会创建新的消费操作。
+ 再确认一次即可。
@@ -162,7 +184,7 @@ function expiryLabel(value: string | null) {
- 卡片明细
+ 选择重置卡
-
-
+
-
-
-
+
+
{{ credit.title || 'Codex 主动重置卡' }}
-
-
+
+
{{ expiryLabel(credit.expiresAt) }}
-
-
-
+
+
+
@@ -223,7 +255,7 @@ function expiryLabel(value: string | null) {
返回
- {{ ambiguous ? '确认原操作' : '消费并重置' }}
+ {{ ambiguous ? '再次确认' : '确认重置' }}
@@ -232,13 +264,10 @@ function expiryLabel(value: string | null) {
-
-
-
- {{ ambiguous ? '确认原操作' : '立即重置' }}
+ {{ ambiguous ? '继续确认' : '下一步' }}
diff --git a/frontend/src/views/accounts/composables/useAccountResetCredits.ts b/frontend/src/views/accounts/composables/useAccountResetCredits.ts
index 3853673f2..1f00f701d 100644
--- a/frontend/src/views/accounts/composables/useAccountResetCredits.ts
+++ b/frontend/src/views/accounts/composables/useAccountResetCredits.ts
@@ -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
(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,