Merge pull request #411 from buniakinazach-dev/feat/codex-spark-quota

新增 Codex Spark 额度展示
This commit is contained in:
fawney19
2026-05-10 09:25:44 +08:00
committed by GitHub
9 changed files with 405 additions and 19 deletions

View File

@@ -315,6 +315,16 @@ export interface CodexUpstreamMetadata {
secondary_reset_after_seconds?: number // 5H限额重置剩余秒数兼容字段
secondary_reset_at?: number // 5H限额重置时间Unix 时间戳)
secondary_window_minutes?: number // 5H限额窗口大小分钟
spark_primary_used_percent?: number // Spark 5H限额窗口使用百分比
spark_primary_reset_seconds?: number // Spark 5H限额重置剩余秒数
spark_primary_reset_after_seconds?: number // Spark 5H限额重置剩余秒数兼容字段
spark_primary_reset_at?: number // Spark 5H限额重置时间Unix 时间戳)
spark_primary_window_minutes?: number // Spark 5H限额窗口大小分钟
spark_secondary_used_percent?: number // Spark 周限额窗口使用百分比
spark_secondary_reset_seconds?: number // Spark 周限额重置剩余秒数
spark_secondary_reset_after_seconds?: number // Spark 周限额重置剩余秒数(兼容字段)
spark_secondary_reset_at?: number // Spark 周限额重置时间Unix 时间戳)
spark_secondary_window_minutes?: number // Spark 周限额窗口大小(分钟)
has_credits?: boolean // 是否有积分
credits_balance?: number // 积分余额
}

View File

@@ -29,10 +29,12 @@ describe('quota selectors', () => {
it('detects only depleted weekly segments', () => {
expect(hasNoWeeklyLimit('周剩余 0.0%5天后重置 | 5H剩余 93.0%2小时后重置')).toBe(true)
expect(hasNoWeeklyLimit('周剩余 45.0%5天后重置 | 5H剩余 0.0%2小时后重置')).toBe(false)
expect(hasNoWeeklyLimit('周剩余 45.0% | Spark周剩余 0.0%')).toBe(false)
})
it('detects only depleted 5h segments', () => {
expect(hasNoFiveHourLimit('周剩余 45.0%5天后重置 | 5H剩余 0.0%2小时后重置')).toBe(true)
expect(hasNoFiveHourLimit('周剩余 0.0%5天后重置 | 5H剩余 93.0%2小时后重置')).toBe(false)
expect(hasNoFiveHourLimit('5H剩余 93.0% | Spark5H剩余 0.0%')).toBe(false)
})
})

View File

@@ -38,14 +38,20 @@ export function isDepletedQuotaSegment(segment: string): boolean {
return false
}
function isSparkQuotaSegment(segment: string): boolean {
return /spark/.test(segment)
}
export function hasNoFiveHourLimit(accountQuota: string | null | undefined): boolean {
return getQuotaSegments(accountQuota)
.filter((segment) => !isSparkQuotaSegment(segment))
.filter((segment) => /5h|5小时/.test(segment))
.some(isDepletedQuotaSegment)
}
export function hasNoWeeklyLimit(accountQuota: string | null | undefined): boolean {
return getQuotaSegments(accountQuota)
.filter((segment) => !isSparkQuotaSegment(segment))
.filter((segment) => /周|weekly|week/.test(segment))
.some(isDepletedQuotaSegment)
}

View File

@@ -576,7 +576,7 @@
</span>
</div>
</div>
<!-- 限额并排显示Team/Plus/Enterprise 账号 2列, Free 账号 1列 -->
<!-- 普通 Codex 限额并排显示Team/Plus/Enterprise 账号 2列, Free 账号 1列 -->
<div
class="grid gap-3"
:class="isCodexTeamPlan(key) ? 'grid-cols-2' : 'grid-cols-1'"
@@ -653,6 +653,89 @@
</div>
</div>
</div>
<!-- Spark 限额独立一行展示,避免与普通 Codex 周/5H 混淆 -->
<div
v-if="hasCodexSparkQuotaDisplayData(key)"
class="mt-3 border-t border-border/60 pt-2"
>
<div class="mb-1 text-[10px] text-muted-foreground">GPT-5.3 Codex Spark</div>
<div class="grid gap-3 grid-cols-2">
<div v-if="getCodexQuotaDisplay(key)?.spark_secondary_used_percent !== undefined">
<div class="flex items-center justify-between text-[10px] mb-0.5">
<span class="text-muted-foreground">Spark 周</span>
<span :class="getQuotaRemainingClass(getCodexQuotaDisplay(key)?.spark_secondary_used_percent || 0)">
{{ (100 - (getCodexQuotaDisplay(key)?.spark_secondary_used_percent || 0)).toFixed(1) }}%
</span>
</div>
<div class="relative w-full h-1.5 bg-border rounded-full overflow-hidden">
<div
class="absolute left-0 top-0 h-full transition-all duration-300"
:class="getQuotaRemainingBarColor(getCodexQuotaDisplay(key)?.spark_secondary_used_percent || 0)"
:style="{ width: `${Math.max(100 - (getCodexQuotaDisplay(key)?.spark_secondary_used_percent || 0), 0)}%` }"
/>
</div>
<div
v-if="shouldStartCodexResetCountdown(getCodexQuotaDisplay(key)?.spark_secondary_used_percent || 0)"
class="text-[9px] mt-0.5 tabular-nums"
:class="getResetCountdownClass(
getCodexQuotaDisplay(key)?.spark_secondary_reset_at,
getCodexQuotaDisplay(key)?.spark_secondary_reset_seconds,
getCodexQuotaDisplay(key)?.updated_at,
getCodexQuotaDisplay(key)?.spark_secondary_used_percent
)"
>
<template v-if="getCodexQuotaDisplay(key)?.spark_secondary_reset_at || getCodexQuotaDisplay(key)?.spark_secondary_reset_seconds">
{{ getResetCountdownText(
getCodexQuotaDisplay(key)?.spark_secondary_reset_at,
getCodexQuotaDisplay(key)?.spark_secondary_reset_seconds,
getCodexQuotaDisplay(key)?.updated_at,
getCodexQuotaDisplay(key)?.spark_secondary_used_percent
) }}
</template>
<template v-else>
已重置
</template>
</div>
</div>
<div v-if="getCodexQuotaDisplay(key)?.spark_primary_used_percent !== undefined">
<div class="flex items-center justify-between text-[10px] mb-0.5">
<span class="text-muted-foreground">Spark 5H</span>
<span :class="getQuotaRemainingClass(getCodexQuotaDisplay(key)?.spark_primary_used_percent || 0)">
{{ (100 - (getCodexQuotaDisplay(key)?.spark_primary_used_percent || 0)).toFixed(1) }}%
</span>
</div>
<div class="relative w-full h-1.5 bg-border rounded-full overflow-hidden">
<div
class="absolute left-0 top-0 h-full transition-all duration-300"
:class="getQuotaRemainingBarColor(getCodexQuotaDisplay(key)?.spark_primary_used_percent || 0)"
:style="{ width: `${Math.max(100 - (getCodexQuotaDisplay(key)?.spark_primary_used_percent || 0), 0)}%` }"
/>
</div>
<div
v-if="shouldStartCodexResetCountdown(getCodexQuotaDisplay(key)?.spark_primary_used_percent || 0)"
class="text-[9px] mt-0.5 tabular-nums"
:class="getResetCountdownClass(
getCodexQuotaDisplay(key)?.spark_primary_reset_at,
getCodexQuotaDisplay(key)?.spark_primary_reset_seconds,
getCodexQuotaDisplay(key)?.updated_at,
getCodexQuotaDisplay(key)?.spark_primary_used_percent
)"
>
<template v-if="getCodexQuotaDisplay(key)?.spark_primary_reset_at || getCodexQuotaDisplay(key)?.spark_primary_reset_seconds">
{{ getResetCountdownText(
getCodexQuotaDisplay(key)?.spark_primary_reset_at,
getCodexQuotaDisplay(key)?.spark_primary_reset_seconds,
getCodexQuotaDisplay(key)?.updated_at,
getCodexQuotaDisplay(key)?.spark_primary_used_percent
) }}
</template>
<template v-else>
已重置
</template>
</div>
</div>
</div>
</div>
</div>
<!-- Antigravity 上游额度摘要(按家族分组展示关键配额) -->
<div
@@ -1997,6 +2080,28 @@ function getCodexQuotaDisplay(key: EndpointAPIKey): CodexUpstreamMetadata | null
display.secondary_window_minutes = secondaryWindow.window_minutes
}
const sparkPrimaryWindow = getQuotaWindow(quota, 'spark_5h')
const sparkPrimaryUsedPercent = getQuotaWindowUsedPercent(sparkPrimaryWindow)
if (sparkPrimaryUsedPercent !== undefined) display.spark_primary_used_percent = sparkPrimaryUsedPercent
const sparkPrimaryResetAt = getQuotaWindowResetAt(sparkPrimaryWindow)
if (sparkPrimaryResetAt !== undefined) display.spark_primary_reset_at = sparkPrimaryResetAt
const sparkPrimaryResetSeconds = getQuotaWindowResetSeconds(sparkPrimaryWindow)
if (sparkPrimaryResetSeconds !== undefined) display.spark_primary_reset_seconds = sparkPrimaryResetSeconds
if (typeof sparkPrimaryWindow?.window_minutes === 'number') {
display.spark_primary_window_minutes = sparkPrimaryWindow.window_minutes
}
const sparkSecondaryWindow = getQuotaWindow(quota, 'spark_weekly')
const sparkSecondaryUsedPercent = getQuotaWindowUsedPercent(sparkSecondaryWindow)
if (sparkSecondaryUsedPercent !== undefined) display.spark_secondary_used_percent = sparkSecondaryUsedPercent
const sparkSecondaryResetAt = getQuotaWindowResetAt(sparkSecondaryWindow)
if (sparkSecondaryResetAt !== undefined) display.spark_secondary_reset_at = sparkSecondaryResetAt
const sparkSecondaryResetSeconds = getQuotaWindowResetSeconds(sparkSecondaryWindow)
if (sparkSecondaryResetSeconds !== undefined) display.spark_secondary_reset_seconds = sparkSecondaryResetSeconds
if (typeof sparkSecondaryWindow?.window_minutes === 'number') {
display.spark_secondary_window_minutes = sparkSecondaryWindow.window_minutes
}
return Object.keys(display).length > 0 ? display : null
}
@@ -2005,6 +2110,16 @@ function hasCodexQuotaDisplayData(key: EndpointAPIKey): boolean {
return !!codex && (
codex.primary_used_percent !== undefined
|| codex.secondary_used_percent !== undefined
|| codex.spark_primary_used_percent !== undefined
|| codex.spark_secondary_used_percent !== undefined
)
}
function hasCodexSparkQuotaDisplayData(key: EndpointAPIKey): boolean {
const codex = getCodexQuotaDisplay(key)
return !!codex && (
codex.spark_primary_used_percent !== undefined
|| codex.spark_secondary_used_percent !== undefined
)
}

View File

@@ -0,0 +1,42 @@
import { describe, expect, it } from 'vitest'
import { getQuotaDisplayText } from '../providerKeyQuota'
describe('providerKeyQuota', () => {
it('includes Codex Spark quota windows in display text', () => {
expect(getQuotaDisplayText({
status_snapshot: {
oauth: {
code: 'valid',
},
account: {
code: 'ok',
blocked: false,
},
quota: {
provider_type: 'codex',
code: 'ok',
exhausted: false,
windows: [
{
code: 'weekly',
remaining_ratio: 0.9,
},
{
code: '5h',
remaining_ratio: 0.8,
},
{
code: 'spark_5h',
remaining_ratio: 0.6,
},
{
code: 'spark_weekly',
remaining_ratio: 0.95,
},
],
},
},
}, 'codex')).toBe('周剩余 90.0% | 5H剩余 80.0% | Spark5H剩余 60.0% | Spark周剩余 95.0%')
})
})

View File

@@ -96,7 +96,12 @@ function formatQuotaValue(value: number | null | undefined): string {
function getCodexQuotaText(quota: QuotaStatusSnapshot): string | null {
const parts: string[] = []
for (const [label, code] of [['周', 'weekly'], ['5H', '5h']] as const) {
for (const [label, code] of [
['周', 'weekly'],
['5H', '5h'],
['Spark5H', 'spark_5h'],
['Spark周', 'spark_weekly'],
] as const) {
const remainingPercent = getQuotaWindowRemainingPercent(getQuotaWindow(quota, code))
if (remainingPercent == null) continue
parts.push(`${label}剩余 ${formatPercent(remainingPercent)}`)

View File

@@ -549,7 +549,7 @@
class="max-w-[208px] space-y-2"
>
<div
v-for="(item, idx) in quotaProgressMap[key.key_id].slice(0, 2)"
v-for="(item, idx) in quotaProgressMap[key.key_id]"
:key="`${key.key_id}-quota-${idx}`"
class="flex flex-col gap-1 min-w-[140px] max-w-[208px]"
>
@@ -2064,6 +2064,27 @@ function refreshOverviewInBackground(): void {
void loadOverview()
}
function applyQuotaRefreshResultToCurrentPage(result: Awaited<ReturnType<typeof refreshProviderQuota>>): void {
const successfulResults = Array.isArray(result.results)
? result.results.filter((item) => item.status === 'success' && item.quota_snapshot)
: []
if (successfulResults.length === 0) return
const quotaByKeyId = new Map(successfulResults.map((item) => [item.key_id, item.quota_snapshot!]))
keyPage.value.keys = keyPage.value.keys.map((key) => {
const quotaSnapshot = quotaByKeyId.get(key.key_id)
if (!quotaSnapshot) return key
return {
...key,
quota_updated_at: quotaSnapshot.updated_at ?? quotaSnapshot.observed_at ?? key.quota_updated_at ?? null,
status_snapshot: {
...(key.status_snapshot ?? {}),
quota: quotaSnapshot,
},
}
})
}
function normalizeQuotaUpdatedAt(raw: number | null | undefined): number | null {
const value = Number(raw ?? 0)
if (!Number.isFinite(value) || value <= 0) return null
@@ -2131,6 +2152,7 @@ async function refreshCurrentPageQuotaInBackground(
refreshingCurrentPageQuota.value = true
try {
const result = await refreshProviderQuota(providerId, quotaStats.eligibleIds)
applyQuotaRefreshResultToCurrentPage(result)
const successCount = Number(result.success || 0)
const failedCount = Number(result.failed || 0)
const skippedCount = Math.max(quotaStats.total - quotaStats.eligibleIds.length, 0)
@@ -3118,6 +3140,8 @@ function getAccountAlertTitle(key: PoolKeyDetail): string {
function normalizeQuotaLabel(label: string): string {
const normalized = label.trim()
if (!normalized) return '额度'
if (/spark\s*5h/i.test(normalized) || normalized.includes('Spark5H')) return 'Spark5H'
if (/spark/i.test(normalized) && normalized.includes('周')) return 'Spark周'
if (normalized.includes('5H')) return '5H'
if (normalized.includes('周')) return '周'
if (normalized.includes('最低剩余')) return '最低'
@@ -3128,13 +3152,15 @@ function normalizeQuotaLabel(label: string): string {
function getQuotaProgressLabel(label: string): string {
if (label === '5H') return '5H'
if (label === '周') return '周'
if (label === 'Spark5H') return 'Spark5H'
if (label === 'Spark周') return 'Spark周'
if (label === '最低') return '最低'
if (label === '剩余') return '剩余'
return label
}
function getQuotaProgressCountdown(item: QuotaProgressItem) {
if (item.label !== '5H' && item.label !== '周') return null
if (!['5H', '周', 'Spark5H', 'Spark周'].includes(item.label)) return null
if (item.resetAtSeconds == null && item.resetSeconds == null) return null
return getCodexResetCountdown(
item.resetAtSeconds,
@@ -3180,9 +3206,11 @@ function getQuotaFallbackText(key: PoolKeyDetail): string | null {
function getQuotaLabelOrder(label: string): number {
if (label === '5H') return 0
if (label === '周') return 1
if (label === '剩余') return 2
if (label === '最低') return 3
if (label === '生图') return 4
if (label === 'Spark5H') return 2
if (label === 'Spark周') return 3
if (label === '剩余') return 4
if (label === '最低') return 5
if (label === '生图') return 6
return 10
}
@@ -3296,7 +3324,12 @@ function buildQuotaProgressItemsFromSnapshot(key: PoolKeyDetail): QuotaProgressI
if (providerType === 'codex') {
const items: QuotaProgressItem[] = []
for (const [label, code] of [['5H', '5h'], ['周', 'weekly']] as const) {
for (const [label, code] of [
['5H', '5h'],
['周', 'weekly'],
['Spark5H', 'spark_5h'],
['Spark周', 'spark_weekly'],
] as const) {
const window = getQuotaSnapshotWindow(quota, code)
const remainingPercent = getQuotaWindowRemainingPercent(window)
if (remainingPercent == null) continue
@@ -3405,10 +3438,17 @@ function resolveCodexQuotaCountdown(
key: PoolKeyDetail,
label: string
): Pick<QuotaProgressItem, 'resetAtSeconds' | 'resetSeconds' | 'updatedAtSeconds'> | null {
if (label !== '5H' && label !== '周') return null
const codexWindowCodeByLabel: Record<string, string> = {
'5H': '5h',
'周': 'weekly',
Spark5H: 'spark_5h',
Spark周: 'spark_weekly',
}
const windowCode = codexWindowCodeByLabel[label]
if (!windowCode) return null
const codexSnapshot = getCodexQuotaSnapshot(key)
const snapshotWindow = getQuotaSnapshotWindow(codexSnapshot, label === '周' ? 'weekly' : '5h')
const snapshotWindow = getQuotaSnapshotWindow(codexSnapshot, windowCode)
if (!snapshotWindow) return null
const resetAtSeconds = normalizeUnixSeconds(snapshotWindow.reset_at ?? null)