mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-05 17:07:46 +08:00
增加 Codex 重置次数功能
This commit is contained in:
@@ -278,6 +278,48 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="getCodexResetCreditsDisplay(key)"
|
||||
class="mt-3 border-t border-border/60 pt-2"
|
||||
>
|
||||
<div class="flex flex-wrap items-center gap-x-1 gap-y-1 text-[10px] leading-4 text-muted-foreground">
|
||||
<button
|
||||
v-if="canConsumeCodexResetCredit(key)"
|
||||
type="button"
|
||||
class="font-medium text-primary underline-offset-2 transition-colors hover:text-primary/80 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 disabled:pointer-events-none disabled:opacity-60"
|
||||
:disabled="consumingCodexResetCreditKeyId === key.id"
|
||||
@click="handleConsumeCodexResetCredit(key)"
|
||||
>
|
||||
{{ consumingCodexResetCreditKeyId === key.id ? legacyT('重置中...') : legacyT('点击以进行重置') }}
|
||||
</button>
|
||||
<span
|
||||
v-else
|
||||
class="font-medium"
|
||||
>
|
||||
{{ legacyT('点击以进行重置') }}
|
||||
</span>
|
||||
<span>{{ formatCodexResetCreditCount(key) }}</span>
|
||||
<template v-if="getVisibleCodexResetCreditItems(key).length > 0">
|
||||
<span aria-hidden="true">|</span>
|
||||
<span>{{ legacyT('临近过期') }}</span>
|
||||
<template
|
||||
v-for="(item, itemIndex) in getVisibleCodexResetCreditItems(key)"
|
||||
:key="item.id || `${item.displayKey}-${item.expiresAt}`"
|
||||
>
|
||||
<span
|
||||
:title="item.title"
|
||||
class="tabular-nums"
|
||||
>
|
||||
{{ item.displayKey }} {{ formatCodexResetCreditDays(item.remainingSeconds) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="itemIndex < getVisibleCodexResetCreditItems(key).length - 1"
|
||||
aria-hidden="true"
|
||||
>·</span>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Antigravity 上游额度摘要(按家族分组展示关键配额) -->
|
||||
<div
|
||||
@@ -916,6 +958,7 @@ import {
|
||||
exportKey,
|
||||
refreshProviderOAuth,
|
||||
refreshProviderQuota,
|
||||
consumeCodexResetCredit,
|
||||
clearOAuthInvalid,
|
||||
type ProviderEndpoint,
|
||||
type EndpointAPIKey,
|
||||
@@ -931,6 +974,7 @@ import type {
|
||||
GrokUpstreamMetadata,
|
||||
KiroUpstreamMetadata,
|
||||
WindsurfUpstreamMetadata,
|
||||
QuotaResetCreditsSnapshot,
|
||||
QuotaStatusSnapshot,
|
||||
QuotaWindowSnapshot,
|
||||
} from '@/api/endpoints/types'
|
||||
@@ -961,6 +1005,12 @@ import {
|
||||
getOAuthStatusTitle as resolveOAuthStatusTitle,
|
||||
} from '@/utils/providerKeyStatus'
|
||||
import { getGeminiCliAccountCreditsText } from '@/utils/providerKeyQuota'
|
||||
import {
|
||||
formatCodexResetCreditCount as formatCodexResetCreditCountLabel,
|
||||
formatCodexResetCreditDays,
|
||||
getCodexResetCreditAvailableCount as getCodexResetCreditAvailableCountFromSnapshot,
|
||||
getVisibleCodexResetCreditItems as getVisibleCodexResetCreditItemsFromSnapshot,
|
||||
} from './codex-reset-credit-display'
|
||||
|
||||
// 扩展端点类型,包含密钥列表
|
||||
interface ProviderEndpointWithKeys extends ProviderEndpoint {
|
||||
@@ -1060,6 +1110,9 @@ const refreshingOAuthKeyId = ref<string | null>(null)
|
||||
// OAuth 失效清除状态
|
||||
const clearingOAuthInvalidKeyId = ref<string | null>(null)
|
||||
|
||||
// Codex reset credit 消费状态
|
||||
const consumingCodexResetCreditKeyId = ref<string | null>(null)
|
||||
|
||||
// 限额刷新状态(Codex / Antigravity)
|
||||
const refreshingQuota = ref(false)
|
||||
|
||||
@@ -1616,6 +1669,75 @@ async function handleClearOAuthInvalid(key: EndpointAPIKey) {
|
||||
}
|
||||
}
|
||||
|
||||
function createCodexResetCreditIdempotencyKey(): string {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID()
|
||||
}
|
||||
throw new Error('浏览器不支持 crypto.randomUUID,无法生成安全幂等 ID')
|
||||
}
|
||||
|
||||
function codexResetCreditOutcomeFeedback(
|
||||
result: Awaited<ReturnType<typeof consumeCodexResetCredit>>,
|
||||
): { tone: 'success' | 'warning'; message: string } {
|
||||
switch (result.outcome) {
|
||||
case 'reset':
|
||||
return { tone: 'success', message: '已使用 Codex 重置机会,并刷新账号配额' }
|
||||
case 'already_redeemed':
|
||||
return { tone: 'success', message: '本次重置请求已处理,账号配额已刷新' }
|
||||
case 'nothing_to_reset':
|
||||
return { tone: 'warning', message: '当前没有需要重置的 Codex 额度窗口' }
|
||||
case 'no_credit':
|
||||
return { tone: 'warning', message: '当前没有可用的 Codex 重置机会' }
|
||||
default:
|
||||
return { tone: 'warning', message: '重置请求已返回,但结果类型未知,请查看最新账号配额' }
|
||||
}
|
||||
}
|
||||
|
||||
async function handleConsumeCodexResetCredit(key: EndpointAPIKey) {
|
||||
if (!canConsumeCodexResetCredit(key)) return
|
||||
|
||||
const earliest = getVisibleCodexResetCreditItems(key)[0]
|
||||
const detailMessage = earliest
|
||||
? `\n当前最早过期项:${earliest.displayKey},约 ${formatCodexResetCreditDays(earliest.remainingSeconds)} 后过期。`
|
||||
: ''
|
||||
const confirmed = await confirm({
|
||||
title: legacyT('确认使用 Codex 重置机会'),
|
||||
message: `${legacyT('将消耗 1 次 Codex 重置机会。操作完成后会重新刷新账号配额状态。')}${detailMessage}`,
|
||||
confirmText: legacyT('确认重置'),
|
||||
cancelText: legacyT('取消'),
|
||||
variant: 'warning',
|
||||
})
|
||||
if (!confirmed) return
|
||||
|
||||
consumingCodexResetCreditKeyId.value = key.id
|
||||
try {
|
||||
const idempotencyKey = createCodexResetCreditIdempotencyKey()
|
||||
const result = await consumeCodexResetCredit(key.id, {
|
||||
idempotency_key: idempotencyKey,
|
||||
})
|
||||
applyQuotaResults([{
|
||||
key_id: result.key_id,
|
||||
status: result.refresh_status === 'success' ? 'success' : result.status,
|
||||
metadata: result.metadata,
|
||||
quota_snapshot: result.quota_snapshot,
|
||||
}])
|
||||
|
||||
const feedback = codexResetCreditOutcomeFeedback(result)
|
||||
if (feedback.tone === 'success') {
|
||||
showSuccess(legacyT(feedback.message))
|
||||
} else {
|
||||
showWarning(legacyT(feedback.message))
|
||||
}
|
||||
if (result.refresh_status === 'failed') {
|
||||
showWarning(legacyT(result.refresh_error || '重置请求已处理,但最新配额刷新失败'))
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
showError(localizedApiError(err, 'Codex 重置机会使用失败'), legacyT('错误'))
|
||||
} finally {
|
||||
consumingCodexResetCreditKeyId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// Codex / Gemini CLI / Antigravity / Kiro / Windsurf / ChatGPT Web:打开抽屉后自动后台刷新(配额缓存缺失/过期,或 Token 即将过期时触发)
|
||||
const AUTO_QUOTA_REFRESH_STALE_SECONDS = 5 * 60
|
||||
// 与后端 OAuth 懒刷新阈值对齐:到期前 2 分钟内视为需要刷新
|
||||
@@ -1764,6 +1886,7 @@ function getCodexQuotaDisplayFromMetadata(metadata: CodexUpstreamMetadata | null
|
||||
]
|
||||
numberFields.forEach(field => copyCodexNumberField(display, metadata, field))
|
||||
if (metadata.has_credits !== undefined) display.has_credits = metadata.has_credits
|
||||
if (metadata.reset_credits) display.reset_credits = metadata.reset_credits
|
||||
|
||||
return Object.keys(display).length > 0 ? display : null
|
||||
}
|
||||
@@ -1819,6 +1942,7 @@ function getCodexQuotaDisplayFromSnapshot(quota: QuotaStatusSnapshot | null | un
|
||||
if (typeof sparkSecondaryWindow?.window_minutes === 'number') {
|
||||
display.spark_secondary_window_minutes = sparkSecondaryWindow.window_minutes
|
||||
}
|
||||
if (quota.reset_credits) display.reset_credits = quota.reset_credits
|
||||
|
||||
return Object.keys(display).length > 0 ? display : null
|
||||
}
|
||||
@@ -1837,6 +1961,11 @@ function codexDisplayHasUsage(display: CodexUpstreamMetadata | null | undefined)
|
||||
)
|
||||
}
|
||||
|
||||
function codexDisplayHasResetCredits(display: CodexUpstreamMetadata | null | undefined): boolean {
|
||||
const count = display?.reset_credits?.available_count
|
||||
return typeof count === 'number' && Number.isFinite(count)
|
||||
}
|
||||
|
||||
function getCodexQuotaDisplay(key: EndpointAPIKey): CodexUpstreamMetadata | null {
|
||||
const snapshotDisplay = getCodexQuotaDisplayFromSnapshot(getQuotaSnapshotForProvider(key, 'codex'))
|
||||
const metadataDisplay = getCodexQuotaDisplayFromMetadata(key.upstream_metadata?.codex)
|
||||
@@ -1863,6 +1992,7 @@ function hasCodexQuotaDisplayData(key: EndpointAPIKey): boolean {
|
||||
|| codex.secondary_used_percent !== undefined
|
||||
|| codex.spark_primary_used_percent !== undefined
|
||||
|| codex.spark_secondary_used_percent !== undefined
|
||||
|| codexDisplayHasResetCredits(codex)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1874,6 +2004,29 @@ function hasCodexSparkQuotaDisplayData(key: EndpointAPIKey): boolean {
|
||||
)
|
||||
}
|
||||
|
||||
function getCodexResetCreditsDisplay(key: EndpointAPIKey): QuotaResetCreditsSnapshot | null {
|
||||
return getCodexQuotaDisplay(key)?.reset_credits ?? null
|
||||
}
|
||||
|
||||
function getCodexResetCreditAvailableCount(key: EndpointAPIKey): number | null {
|
||||
return getCodexResetCreditAvailableCountFromSnapshot(getCodexResetCreditsDisplay(key))
|
||||
}
|
||||
|
||||
function formatCodexResetCreditCount(key: EndpointAPIKey): string {
|
||||
return formatCodexResetCreditCountLabel(getCodexResetCreditAvailableCount(key))
|
||||
}
|
||||
|
||||
function getVisibleCodexResetCreditItems(key: EndpointAPIKey) {
|
||||
return getVisibleCodexResetCreditItemsFromSnapshot(getCodexResetCreditsDisplay(key))
|
||||
}
|
||||
|
||||
function canConsumeCodexResetCredit(key: EndpointAPIKey): boolean {
|
||||
return provider.value?.provider_type === 'codex'
|
||||
&& getCodexResetCreditAvailableCount(key) !== null
|
||||
&& (getCodexResetCreditAvailableCount(key) ?? 0) > 0
|
||||
&& !consumingCodexResetCreditKeyId.value
|
||||
}
|
||||
|
||||
function getKiroQuotaDisplay(key: EndpointAPIKey): KiroUpstreamMetadata | null {
|
||||
const quota = getQuotaSnapshotForProvider(key, 'kiro')
|
||||
if (!quota) return null
|
||||
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
formatCodexResetCreditCount,
|
||||
formatCodexResetCreditDays,
|
||||
getCodexResetCreditAvailableCount,
|
||||
getVisibleCodexResetCreditItems,
|
||||
} from '@/features/providers/components/codex-reset-credit-display'
|
||||
import type { QuotaResetCreditsSnapshot } from '@/api/endpoints/types'
|
||||
|
||||
describe('codex reset credit display helpers', () => {
|
||||
it('keeps zero available credits displayable but non-positive detail items hidden', () => {
|
||||
const snapshot: QuotaResetCreditsSnapshot = {
|
||||
available_count: 0,
|
||||
updated_at: 1_700_000_000,
|
||||
credits: [
|
||||
{
|
||||
id: 'expired-1111',
|
||||
display_key: 'expired',
|
||||
status: 'available',
|
||||
expires_at: 1_699_999_999,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
expect(getCodexResetCreditAvailableCount(snapshot)).toBe(0)
|
||||
expect(formatCodexResetCreditCount(0)).toBe('共 0 次机会')
|
||||
expect(getVisibleCodexResetCreditItems(snapshot, 1_700_000_000)).toEqual([])
|
||||
})
|
||||
|
||||
it('sorts available detail items by remaining time and limits visible items to five', () => {
|
||||
const snapshot: QuotaResetCreditsSnapshot = {
|
||||
available_count: 7,
|
||||
updated_at: 1_700_000_000,
|
||||
credits: [
|
||||
{ id: 'sixth-0000', status: 'available', expires_at: 1_700_060_000 },
|
||||
{ id: 'spent-0000', status: 'redeemed', expires_at: 1_700_001_000 },
|
||||
{ id: 'fifth-0000', status: 'active', expires_at: 1_700_050_000 },
|
||||
{ id: 'third-0000', status: 'available', remaining_seconds: 30_000 },
|
||||
{ id: 'missing-expiry-0000', status: 'available' },
|
||||
{ id: 'first-0000', status: 'available', expires_at: 1_700_010_000 },
|
||||
{ id: 'second-0000', status: 'available', expires_at: 1_700_020_000 },
|
||||
{ id: 'fourth-0000', display_key: 'displayed', status: 'available', expires_at: 1_700_040_000 },
|
||||
],
|
||||
}
|
||||
|
||||
const items = getVisibleCodexResetCreditItems(snapshot, 1_700_000_000)
|
||||
|
||||
expect(items.map(item => item.displayKey)).toEqual([
|
||||
'first',
|
||||
'second',
|
||||
'third',
|
||||
'displayed',
|
||||
'fifth',
|
||||
])
|
||||
expect(items.map(item => item.remainingSeconds)).toEqual([
|
||||
10_000,
|
||||
20_000,
|
||||
30_000,
|
||||
40_000,
|
||||
50_000,
|
||||
])
|
||||
})
|
||||
|
||||
it('formats reset credit remaining days with a one-day minimum', () => {
|
||||
expect(formatCodexResetCreditDays(1)).toBe('1天')
|
||||
expect(formatCodexResetCreditDays(86_401)).toBe('2天')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,86 @@
|
||||
import type {
|
||||
QuotaResetCreditSnapshot,
|
||||
QuotaResetCreditsSnapshot,
|
||||
} from '@/api/endpoints/types'
|
||||
|
||||
export interface CodexResetCreditDisplayItem {
|
||||
id?: string | null
|
||||
displayKey: string
|
||||
expiresAt?: number | null
|
||||
remainingSeconds: number
|
||||
title: string
|
||||
}
|
||||
|
||||
export function getCodexResetCreditAvailableCount(
|
||||
snapshot: QuotaResetCreditsSnapshot | null | undefined,
|
||||
): number | null {
|
||||
const count = snapshot?.available_count
|
||||
return typeof count === 'number' && Number.isFinite(count) && count >= 0 ? count : null
|
||||
}
|
||||
|
||||
export function formatCodexResetCreditCount(count: number | null | undefined): string {
|
||||
return `共 ${count ?? 0} 次机会`
|
||||
}
|
||||
|
||||
function codexResetCreditDisplayKey(item: QuotaResetCreditSnapshot): string | null {
|
||||
const explicit = item.display_key?.trim()
|
||||
if (explicit) return explicit
|
||||
const id = item.id?.trim()
|
||||
if (!id) return null
|
||||
return id.split('-')[0]?.trim() || null
|
||||
}
|
||||
|
||||
function codexResetCreditRemainingSeconds(
|
||||
item: QuotaResetCreditSnapshot,
|
||||
snapshot: QuotaResetCreditsSnapshot,
|
||||
nowUnixSecs: number,
|
||||
): number | null {
|
||||
if (typeof item.expires_at === 'number' && Number.isFinite(item.expires_at)) {
|
||||
return Math.max(item.expires_at - nowUnixSecs, 0)
|
||||
}
|
||||
if (typeof item.remaining_seconds === 'number' && Number.isFinite(item.remaining_seconds)) {
|
||||
const updatedAt = snapshot.updated_at
|
||||
const elapsed = typeof updatedAt === 'number' && Number.isFinite(updatedAt)
|
||||
? Math.max(nowUnixSecs - updatedAt, 0)
|
||||
: 0
|
||||
return Math.max(item.remaining_seconds - elapsed, 0)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function codexResetCreditStatusIsDisplayable(item: QuotaResetCreditSnapshot): boolean {
|
||||
const status = item.status?.trim().toLowerCase()
|
||||
return !status || status === 'available' || status === 'active'
|
||||
}
|
||||
|
||||
export function getVisibleCodexResetCreditItems(
|
||||
snapshot: QuotaResetCreditsSnapshot | null | undefined,
|
||||
nowUnixSecs = Math.floor(Date.now() / 1000),
|
||||
limit = 5,
|
||||
): CodexResetCreditDisplayItem[] {
|
||||
const credits = snapshot?.credits
|
||||
if (!snapshot || !Array.isArray(credits)) return []
|
||||
|
||||
return credits
|
||||
.map((item) => {
|
||||
if (!codexResetCreditStatusIsDisplayable(item)) return null
|
||||
const displayKey = codexResetCreditDisplayKey(item)
|
||||
const remainingSeconds = codexResetCreditRemainingSeconds(item, snapshot, nowUnixSecs)
|
||||
if (!displayKey || remainingSeconds === null || remainingSeconds <= 0) return null
|
||||
return {
|
||||
id: item.id,
|
||||
displayKey,
|
||||
expiresAt: item.expires_at,
|
||||
remainingSeconds,
|
||||
title: item.id ? `${item.id}` : displayKey,
|
||||
} satisfies CodexResetCreditDisplayItem
|
||||
})
|
||||
.filter((item): item is CodexResetCreditDisplayItem => item !== null)
|
||||
.sort((a, b) => a.remainingSeconds - b.remainingSeconds)
|
||||
.slice(0, limit)
|
||||
}
|
||||
|
||||
export function formatCodexResetCreditDays(remainingSeconds: number): string {
|
||||
const days = Math.max(1, Math.ceil(remainingSeconds / 86_400))
|
||||
return `${days}天`
|
||||
}
|
||||
Reference in New Issue
Block a user