Compare commits

..
5 Commits
Author SHA1 Message Date
ZyyandCodex 941c4d57fa chore: bump version to 3.2.16
Co-authored-by: Codex <noreply@openai.com>
2026-08-22 09:28:34 +08:00
ZyyandCodex cf046ad9a4 fix(admin): close quota reset modal on success
Co-authored-by: Codex <noreply@openai.com>
2026-08-22 09:25:05 +08:00
ZyyandCodex 7699a954ac fix(admin): format account counts with separators
Co-authored-by: Codex <noreply@openai.com>
2026-08-22 09:21:11 +08:00
ZyyandCodex 2c31708d1b feat(admin): allow selecting quota reset credits
Co-authored-by: Codex <noreply@openai.com>
2026-08-22 09:19:20 +08:00
ZyyandCodex 80f338ce01 fix(admin): show raw account counts
Co-authored-by: Codex <noreply@openai.com>
2026-08-21 23:12:54 +08:00
9 changed files with 230 additions and 72 deletions
@@ -729,21 +729,19 @@ pub(crate) fn dashboard_view(
last_used: relative_time(credential.last_used_at, range.end),
});
}
let unavailable_accounts = provider_accounts
.total
.saturating_sub(provider_accounts.normal);
DashboardDataView {
cards: DashboardCardsView {
credentials: DashboardCredentialsCardView {
total: format_compact_number(provider_accounts.total),
total: provider_accounts.total.to_string(),
total_value: provider_accounts.total,
available: format_compact_number(provider_accounts.normal),
available: provider_accounts.normal.to_string(),
available_value: provider_accounts.normal,
unavailable: format_compact_number(
provider_accounts
.total
.saturating_sub(provider_accounts.normal),
),
unavailable_value: provider_accounts
.total
.saturating_sub(provider_accounts.normal),
unavailable: unavailable_accounts.to_string(),
unavailable_value: unavailable_accounts,
},
traffic: DashboardTrafficCardView {
today_requests: format_compact_number(today.request_count),
@@ -221,9 +221,9 @@ async fn dashboard_summary_should_include_quota_exhaustion_in_unavailable_headli
attempts: AttemptMetrics::default(),
totals: Default::default(),
provider_accounts: AccountPoolMetrics {
total: 807,
normal: 15,
quota_exhausted: 785,
total: 1002,
normal: 943,
quota_exhausted: 52,
rate_limited: 0,
disabled: 3,
error: 4,
@@ -259,9 +259,21 @@ async fn dashboard_summary_should_include_quota_exhaustion_in_unavailable_headli
&value["data"]["cards"]["credentials"]["unavailableValue"],
),
(
&serde_json::json!(807),
&serde_json::json!(15),
&serde_json::json!(792)
&serde_json::json!(1002),
&serde_json::json!(943),
&serde_json::json!(59)
),
);
assert_eq!(
(
&value["data"]["cards"]["credentials"]["total"],
&value["data"]["cards"]["credentials"]["available"],
&value["data"]["cards"]["credentials"]["unavailable"],
),
(
&serde_json::json!("1002"),
&serde_json::json!("943"),
&serde_json::json!("59")
),
);
}
@@ -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,24 @@ 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)}`
}
async function handleConfirmConsume() {
if (await confirmConsume())
panelOpen.value = false
}
</script>
<template>
@@ -115,8 +139,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 +160,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 +189,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 +214,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">
@@ -222,8 +259,8 @@ function expiryLabel(value: string | null) {
<BaseButton variant="ghost" :disabled="consuming" @click="cancelConsume">
返回
</BaseButton>
<BaseButton variant="primary" :loading="consuming" @click="confirmConsume">
{{ ambiguous ? '确认原操作' : '消费并重置' }}
<BaseButton variant="primary" :loading="consuming" @click="handleConfirmConsume">
{{ ambiguous ? '再次确认' : '确认重置' }}
</BaseButton>
</template>
<template v-else>
@@ -232,13 +269,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,
}
@@ -123,10 +160,10 @@ export function useAccountResetCredits(options: {
pendingOperation.value = null
}
async function confirmConsume() {
async function confirmConsume(): Promise<boolean> {
const operation = pendingOperation.value
if (!operation || consuming.value)
return
return false
consuming.value = true
try {
@@ -142,27 +179,27 @@ export function useAccountResetCredits(options: {
if (!confirmed) {
toast.error(resetResultMessage(result.code))
await loadCredits()
return
return false
}
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 },
)
}
return true
}
catch (error: unknown) {
showConfirm.value = false
@@ -178,6 +215,7 @@ export function useAccountResetCredits(options: {
toast.error(errorMessage(error, '额度重置失败'))
await loadCredits()
}
return false
}
finally {
consuming.value = false
@@ -188,10 +226,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 +240,9 @@ export function useAccountResetCredits(options: {
credits,
availableCredits,
availableCount,
selectedCreditId,
consumptionCredit,
canRequestConsume,
hasSnapshot,
loading,
consuming,
@@ -208,6 +250,7 @@ export function useAccountResetCredits(options: {
ambiguous,
showConfirm,
loadCredits,
selectCredit,
requestConsume,
cancelConsume,
confirmConsume,
+1 -1
View File
@@ -194,7 +194,7 @@ function windowRoleOrder(role: AccountQuotaWindow['role']) {
function quotaLimitLabel(window: AccountQuotaWindow) {
if (window.limitId === 'codex')
return '主额度'
return '通用额度'
return window.limitName
}
@@ -7,7 +7,7 @@ import { computed, onMounted, shallowRef } from 'vue'
import { getDashboardSummary, getDashboardTrend } from '@/api'
import { withMinimumDuration } from '@/utils/async'
import { formatDateTime } from '@/utils/date'
import { formatCompactNumber } from '@/utils/number'
import { formatCompactNumber, formatInteger } from '@/utils/number'
import { normalizeUsageRecord } from '@/views/usage/utils/records'
export function useDashboard() {
@@ -263,7 +263,7 @@ function metricCards(
title: '账号',
value: credentials.total,
valueRaw: credentials.totalValue,
valueFormatter: formatCompactNumber,
valueFormatter: formatAccountCount,
icon: Users,
tone: 'normal',
details: [
@@ -337,6 +337,10 @@ function metricCards(
]
}
function formatAccountCount(value: number) {
return formatInteger(Math.max(0, Math.round(value)))
}
function sparkline(values: number[], tone: MetricTone) {
return values.some(value => value > 0) ? { values, tone } : undefined
}
+1 -1
View File
@@ -1 +1 @@
version: "3.2.15"
version: "3.2.16"