refactor(frontend): streamline quota reset dialog and spacing

Show remaining usage limits and an always-visible reset credit list with per-credit reset actions. Use backend-provided credit titles and quota window labels while preserving confirmation and ambiguous-result recovery.

Display credit expiration in UTC+8 without a timezone suffix. Balance modal title and reset section spacing, and remove inline baseline whitespace from account quota summary cells without changing row heights or the borderless design.

Validation: browser layout and quota popover interaction checks, expiration timezone checks, frontend typecheck, targeted ESLint, and diff whitespace checks passed.

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Zyy
2026-09-10 15:33:43 +08:00
co-authored by Codex
parent b206ee8271
commit 6bc2189e6f
5 changed files with 170 additions and 104 deletions
@@ -239,7 +239,7 @@ onBeforeUnmount(() => {
</slot>
</span>
<div class="min-w-0">
<h2 :id="titleId" class="m-0 text-lg leading-[1.15] font-heavy text-cp-text">
<h2 :id="titleId" class="relative top-px m-0 text-lg leading-[1.15] font-heavy text-cp-text">
{{ title }}
</h2>
<p
@@ -1,28 +1,30 @@
<script setup lang="ts">
import type { Account } from '@/api'
import type { Account, AccountResetCredit } from '@/api'
import { AlertTriangle, RefreshCw, TicketCheck } from '@lucide/vue'
import dayjs from 'dayjs'
import { computed, shallowRef, useId, watch } from 'vue'
import utc from 'dayjs/plugin/utc'
import { computed, shallowRef, 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'
import UsageLimits from './UsageLimits.vue'
const props = defineProps<{
accountId: string
account: Account
}>()
const emit = defineEmits<{
accountUpdated: [account: Account]
}>()
dayjs.extend(utc)
const {
availableCredits,
availableCount,
selectedCreditId,
consumptionCredit,
canRequestConsume,
hasSnapshot,
@@ -37,18 +39,16 @@ const {
cancelConsume,
confirmConsume,
} = useAccountResetCredits({
accountId: () => props.accountId,
accountId: () => props.account.id,
onAccountUpdated: account => emit('accountUpdated', account),
})
const panelOpen = shallowRef(false)
const creditRadioName = `account-reset-credit-${useId()}`
const modalTitle = computed(() => {
if (!showConfirm.value)
return '主动重置额度'
return '额度重置'
return ambiguous.value ? '确认上次重置' : '确认重置额度'
})
const modalDescription = computed(() => (showConfirm.value ? undefined : '选择一张重置卡后继续'))
const triggerLabel = computed(() => {
if (ambiguous.value)
return '查看主动重置卡,有一项操作待确认'
@@ -60,7 +60,17 @@ const triggerLabel = computed(() => {
return hasSnapshot.value ? `查看主动重置卡,最近查询 ${availableCount.value} 张可用` : '查看主动重置卡'
})
const showTriggerCount = computed(() => hasSnapshot.value && availableCount.value > 0)
const confirmCreditTitle = computed(() => consumptionCredit.value?.title || 'Codex 主动重置卡')
const confirmCreditTitle = computed(() => creditTitle(consumptionCredit.value))
const creditItems = computed(() => availableCredits.value.map(credit => ({
id: credit.id,
title: creditTitle(credit),
expiry: expiryLabel(credit.expiresAt),
})))
const countLabel = computed(() => {
if (!hasSnapshot.value)
return loading.value ? '查询中' : '待查询'
return `可用 ${availableCount.value} 次`
})
watch(panelOpen, (isOpen) => {
if (isOpen) {
@@ -75,25 +85,18 @@ function expiryLabel(value: string | null) {
if (!value)
return '有效期由上游决定'
const expiry = dayjs(value)
return expiry.isValid() ? `${expiry.format('YYYY-MM-DD HH:mm')} 到期` : '到期时间未知'
return expiry.isValid() ? `将于 ${expiry.utcOffset(8).format('YYYY-MM-DD HH:mm')} 到期` : '到期时间未知'
}
function creditOptionClasses(creditId: string) {
return [
'w-full min-w-0 rounded-cp px-4 py-3 outline-none transition-colors duration-150 motion-reduce:transition-none',
selectedCreditId.value === creditId
? 'bg-cp-control-item-bg-active'
: 'bg-cp-fill-quaternary hover:bg-cp-bg-text-hover',
]
function creditTitle(credit: AccountResetCredit | undefined) {
return credit?.title?.trim() || '用量重置'
}
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
function handleRequestConsume(creditId: string) {
if (loading.value || consuming.value || ambiguous.value)
return
selectCredit(creditId)
requestConsume()
}
</script>
@@ -116,9 +119,8 @@ async function handleConfirmConsume() {
<BaseModal
v-model="panelOpen"
:title="modalTitle"
:description="modalDescription"
:tone="showConfirm ? 'warning' : 'neutral'"
size="sm"
:size="showConfirm ? 'sm' : 'md'"
:dismissible="!consuming"
>
<div v-if="showConfirm" class="grid gap-3">
@@ -139,43 +141,35 @@ async function handleConfirmConsume() {
</div>
<div v-else class="grid gap-4">
<UsageLimits :windows="account.quota.windows" />
<section v-if="ambiguous" class="flex items-start gap-3 rounded-cp bg-cp-warning-container px-4 py-3.5" role="status">
<AlertTriangle class="mt-0.5 size-4 shrink-0 text-cp-warning-on-container" />
<div class="min-w-0">
<div class="min-w-0 flex-1">
<p class="m-0 text-cp-sm font-heavy text-cp-warning-on-container">
上次操作结果待确认
</p>
<p class="mt-1 mb-0 text-cp-xs leading-normal font-emphasis text-cp-text-secondary">
再确认一次即可。
请继续确认上次重置结果。
</p>
</div>
<BaseButton size="sm" variant="soft" :disabled="loading || consuming || !canRequestConsume" @click="requestConsume">
继续确认
</BaseButton>
</section>
<section class="flex items-center gap-3 rounded-cp bg-cp-fill-quaternary px-4 py-3.5">
<span
class="inline-grid size-9 shrink-0 place-items-center rounded-cp bg-cp-fill-tertiary text-cp-primary-text"
>
<TicketCheck class="size-4" />
</span>
<div class="min-w-0">
<p class="m-0 text-cp-sm font-heavy text-cp-text">
可用重置卡
</p>
<p class="mt-1 mb-0 text-cp-xs leading-none font-emphasis text-cp-text-secondary">
每次操作消费一张
</p>
</div>
<strong class="ml-auto font-mono text-[22px] leading-none font-extrabold text-cp-text">
{{ availableCount }}
<span class="ml-0.5 text-cp-xs font-heavy text-cp-text-quaternary">张</span>
</strong>
</section>
<section class="grid gap-2.5">
<div class="flex min-h-8 items-center justify-between gap-3">
<h3 class="m-0 text-cp-sm font-heavy text-cp-text-quaternary">
选择重置卡
<section class="overflow-hidden rounded-cp bg-cp-fill-quaternary" aria-label="使用限额重置">
<div class="flex items-center gap-3 pt-4 pr-3 pb-1 pl-4">
<h3 class="m-0 min-w-0 flex-1 text-cp-sm font-heavy text-cp-text">
使用限额重置
</h3>
<span
class="shrink-0 rounded-cp-sm px-2 py-1 text-cp-xs leading-none font-heavy tabular-nums"
:class="hasSnapshot && availableCount > 0 ? 'bg-cp-success-container text-cp-success-on-container' : 'bg-cp-fill-tertiary text-cp-text-secondary'"
role="status"
>
{{ countLabel }}
</span>
<BaseIconButton
variant="ghost"
size="sm"
@@ -191,66 +185,66 @@ async function handleConfirmConsume() {
</BaseIconButton>
</div>
<p
v-if="loadError"
class="m-0 rounded-cp bg-cp-error-container px-4 py-3 text-cp-xs leading-normal font-emphasis text-cp-error-on-container"
role="status"
>
{{ loadError }},请刷新重试。
</p>
<div v-else-if="availableCredits.length" class="grid gap-2" role="radiogroup" aria-label="选择要使用的重置卡">
<BaseRadio
v-for="credit in availableCredits"
:key="credit.id"
:model-value="selectedCreditId"
:value="credit.id"
:name="creditRadioName"
:label="creditOptionLabel(credit)"
:disabled="ambiguous || consuming"
:class="creditOptionClasses(credit.id)"
@update:model-value="selectCredit"
<div :aria-busy="loading || consuming">
<p
v-if="loadError"
class="mx-4 mt-0 mb-4 rounded-cp bg-cp-error-container px-4 py-3 text-cp-xs leading-normal font-emphasis text-cp-error-on-container"
role="status"
>
<span class="block min-w-0">
<span class="block truncate text-cp-sm font-heavy text-cp-text">
{{ credit.title || 'Codex 主动重置卡' }}
</span>
<span class="mt-1 block truncate font-mono text-[10px] font-emphasis text-cp-text-quaternary">
{{ expiryLabel(credit.expiresAt) }}
</span>
</span>
</BaseRadio>
</div>
{{ loadError }},请刷新重试。
</p>
<p
v-else-if="loading && !hasSnapshot"
class="m-0 px-4 pt-1 pb-4 text-cp-xs font-emphasis text-cp-text-secondary"
role="status"
>
正在查询可用重置次数…
</p>
<ul v-else-if="creditItems.length" class="m-0 list-none px-0 pt-0 pb-1">
<li
v-for="credit in creditItems"
:key="credit.id"
class="flex items-center gap-4 px-4 py-3"
>
<div class="min-w-0 flex-1">
<p class="m-0 text-cp-sm leading-normal font-heavy wrap-anywhere text-cp-text">
{{ credit.title }}
</p>
<p class="mt-1 mb-0 text-cp-xs leading-normal font-emphasis text-cp-text-secondary">
{{ credit.expiry }}
</p>
</div>
<BaseButton
size="sm"
variant="primary"
:disabled="loading || consuming || ambiguous || availableCount <= 0"
:aria-label="`使用重置:${credit.title},${credit.expiry}`"
@click="handleRequestConsume(credit.id)"
>
使用重置
</BaseButton>
</li>
</ul>
<div v-else class="overflow-hidden rounded-cp bg-cp-fill-quaternary">
<BaseEmpty
v-else
:icon="TicketCheck"
size="sm"
surface="none"
title="当前没有可用重置卡"
title="当前没有可用重置次数"
description="可刷新列表,重新读取上游状态"
/>
</div>
</section>
</div>
<template #footer>
<template v-if="showConfirm">
<BaseButton variant="ghost" :disabled="consuming" @click="cancelConsume">
返回
</BaseButton>
<BaseButton variant="primary" :loading="consuming" @click="handleConfirmConsume">
{{ ambiguous ? '再次确认' : '确认重置' }}
</BaseButton>
</template>
<template v-else>
<BaseButton variant="ghost" :disabled="consuming" @click="panelOpen = false">
关闭
</BaseButton>
<BaseButton variant="primary" :disabled="loading || consuming || !canRequestConsume" @click="requestConsume">
{{ ambiguous ? '继续确认' : '下一步' }}
</BaseButton>
</template>
<template v-if="showConfirm" #footer>
<BaseButton variant="ghost" :disabled="consuming" @click="cancelConsume">
返回
</BaseButton>
<BaseButton variant="primary" :loading="consuming" @click="confirmConsume">
{{ ambiguous ? '再次确认' : '确认重置' }}
</BaseButton>
</template>
</BaseModal>
</template>
@@ -0,0 +1,72 @@
<script setup lang="ts">
import type { AccountQuotaWindow } from '../../constants'
import { computed } from 'vue'
import { orderedPanelQuotaWindows } from '../../constants'
import { quotaWindowPresentation } from '../AccountUsageWindow/presenter'
const props = defineProps<{
windows: AccountQuotaWindow[]
}>()
const items = computed(() => orderedPanelQuotaWindows(props.windows)
.filter(window => window.limitId === 'codex' || !window.limitId)
.map((window) => {
const remaining = window.usedPercent !== null && Number.isFinite(window.usedPercent)
? Math.round((100 - Math.min(100, Math.max(0, window.usedPercent))) * 10) / 10
: null
const presentation = quotaWindowPresentation(window, '0')
return {
key: window.key,
label: window.labelDisplay,
resetAtDisplay: window.resetAtDisplay,
remaining,
remainingDisplay: remaining === null ? '待观测' : `剩余 ${remaining}%`,
barClass: presentation.barClass,
percentTextClass: presentation.percentTextClass,
barStyle: { width: `${remaining ?? 0}%` },
}
}))
</script>
<template>
<section class="overflow-hidden rounded-cp bg-cp-fill-quaternary" aria-label="使用限额">
<div
v-for="item in items"
:key="item.key"
class="grid gap-3 px-4 py-4 sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] sm:items-center sm:gap-5"
>
<div class="min-w-0">
<h3 class="m-0 text-cp-sm font-heavy text-cp-text">
{{ item.label }}
</h3>
<p class="mt-1 mb-0 text-cp-xs leading-normal font-emphasis text-cp-text-secondary">
重置:<span class="font-mono tabular-nums">{{ item.resetAtDisplay }}</span>
</p>
</div>
<div class="flex min-w-0 items-center gap-3">
<div
class="h-1.5 min-w-0 flex-1 overflow-hidden rounded-full bg-cp-border-secondary"
role="progressbar"
:aria-label="`${item.label}剩余额度`"
aria-valuemin="0"
aria-valuemax="100"
:aria-valuenow="item.remaining ?? undefined"
:aria-valuetext="item.remainingDisplay"
>
<div
class="h-full rounded-full transition-[width,background-color] duration-200 motion-reduce:transition-none"
:class="item.barClass"
:style="item.barStyle"
/>
</div>
<span class="shrink-0 text-cp-xs font-heavy tabular-nums" :class="item.percentTextClass">
{{ item.remainingDisplay }}
</span>
</div>
</div>
<p v-if="items.length === 0" class="m-0 px-4 py-4 text-cp-sm font-emphasis text-cp-text-secondary">
额度待观测
</p>
</section>
</template>
@@ -58,7 +58,7 @@ const profileOpen = shallowRef(false)
</BaseIconButton>
<AccountResetCredits
v-if="account.provider === 'openai'"
:account-id="account.id"
:account="account"
@account-updated="emit('accountUpdated', $event)"
/>
<BaseIconButton
@@ -58,7 +58,7 @@ const additionalEntryCount = computed(() => Math.max(summaryEntries.value.length
</div>
<div v-if="recentUsageEntry" class="flex min-w-0 items-end gap-2">
<div class="min-w-0 flex-1">
<div class="flex min-w-0 flex-1">
<AccountQuotaSummaryEntry
:label="recentUsageEntry.label"
:windows="recentUsageEntry.windows"