fix(frontend): 优化移动端弹窗并完善提供商配额刷新

This commit is contained in:
ZheFox
2026-07-16 15:21:11 +08:00
parent 71fcb9c168
commit 1333efdad5
13 changed files with 200 additions and 152 deletions
@@ -993,10 +993,6 @@ import type {
} from '@/api/endpoints/types'
import { formatApiFormatShort } from '@/api/endpoints/types/api-format'
import { isOAuthAccountProviderType, isKeyManagedProviderType } from '../utils/providerTypeUtils'
import {
isProviderQuotaAutoRefreshCoolingDown,
markProviderQuotaAutoRefreshAttempt,
} from '../utils/quotaAutoRefreshCooldown'
import { getOAuthOrgBadge } from '@/utils/oauthIdentity'
import { getOAuthRefreshFeedback } from '@/utils/oauthRefreshFeedback'
import { formatCompactNumber } from '@/utils/format'
@@ -1022,6 +1018,7 @@ import {
formatCodexResetCreditDays,
getCodexResetCreditAvailableCount as getCodexResetCreditAvailableCountFromSnapshot,
getVisibleCodexResetCreditItems as getVisibleCodexResetCreditItemsFromSnapshot,
mergeCodexQuotaDisplays,
} from './codex-reset-credit-display'
// 扩展端点类型,包含密钥列表
@@ -1600,7 +1597,7 @@ async function handleRefreshOAuth(key: EndpointAPIKey) {
}
// Antigravity:token 刷新后可能完成了账号激活,触发配额获取
// (不 emit('refresh'),避免触发全局 provider 余额刷新)
void autoRefreshQuotaInBackground({ ignoreCooldown: true })
void autoRefreshQuotaInBackground()
} catch (err: unknown) {
showError(localizedApiError(err, 'Token 刷新失败'), legacyT('错误'))
} finally {
@@ -1951,20 +1948,6 @@ function getCodexQuotaDisplayFromSnapshot(quota: QuotaStatusSnapshot | null | un
return Object.keys(display).length > 0 ? display : null
}
function getCodexQuotaDisplayUpdatedAt(display: CodexUpstreamMetadata | null | undefined): number | null {
const updatedAt = Number(display?.updated_at)
return Number.isFinite(updatedAt) ? updatedAt : null
}
function codexDisplayHasUsage(display: CodexUpstreamMetadata | null | undefined): boolean {
return !!display && (
display.primary_used_percent !== undefined
|| display.secondary_used_percent !== undefined
|| display.spark_primary_used_percent !== undefined
|| display.spark_secondary_used_percent !== undefined
)
}
function codexDisplayHasResetCredits(display: CodexUpstreamMetadata | null | undefined): boolean {
const count = display?.reset_credits?.available_count
return typeof count === 'number' && Number.isFinite(count)
@@ -1973,20 +1956,7 @@ function codexDisplayHasResetCredits(display: CodexUpstreamMetadata | null | und
function getCodexQuotaDisplay(key: EndpointAPIKey): CodexUpstreamMetadata | null {
const snapshotDisplay = getCodexQuotaDisplayFromSnapshot(getQuotaSnapshotForProvider(key, 'codex'))
const metadataDisplay = getCodexQuotaDisplayFromMetadata(key.upstream_metadata?.codex)
if (!snapshotDisplay) return metadataDisplay
if (!metadataDisplay) return snapshotDisplay
if (!codexDisplayHasUsage(snapshotDisplay) && codexDisplayHasUsage(metadataDisplay)) {
return metadataDisplay
}
const snapshotUpdatedAt = getCodexQuotaDisplayUpdatedAt(snapshotDisplay)
const metadataUpdatedAt = getCodexQuotaDisplayUpdatedAt(metadataDisplay)
if (metadataUpdatedAt !== null && (snapshotUpdatedAt === null || metadataUpdatedAt > snapshotUpdatedAt)) {
return metadataDisplay
}
return snapshotDisplay
return mergeCodexQuotaDisplays(snapshotDisplay, metadataDisplay)
}
function hasCodexQuotaDisplayData(key: EndpointAPIKey): boolean {
@@ -2714,7 +2684,7 @@ function applyQuotaResults(
}
// 通用的自动刷新配额函数(支持 Codex、Gemini CLI、Antigravity、Kiro、Windsurf 和 ChatGPT Web)
async function autoRefreshQuotaInBackground(options: { ignoreCooldown?: boolean } = {}) {
async function autoRefreshQuotaInBackground() {
const providerId = props.providerId
if (!providerId) return
if (refreshingQuota.value) return
@@ -2740,7 +2710,6 @@ async function autoRefreshQuotaInBackground(options: { ignoreCooldown?: boolean
shouldRefresh = shouldAutoRefreshChatGPTWebQuota()
}
if (!shouldRefresh) return
if (!options.ignoreCooldown && isProviderQuotaAutoRefreshCoolingDown(providerId)) return
let hadCachedQuota = false
if (providerType === 'codex') {
@@ -2760,7 +2729,6 @@ async function autoRefreshQuotaInBackground(options: { ignoreCooldown?: boolean
}
refreshingQuota.value = true
markProviderQuotaAutoRefreshAttempt(providerId)
try {
const result = await refreshProviderQuota(providerId)
const applied = applyQuotaResults(result.results)
@@ -2804,7 +2772,7 @@ async function handleKeyChanged() {
await Promise.all([loadEndpoints(), loadMappingPreview()])
emit('refresh')
// 添加/修改 key 后自动获取已支持 provider 的配额(新 key 的 upstream_metadata 为空)
void autoRefreshQuotaInBackground({ ignoreCooldown: true })
void autoRefreshQuotaInBackground()
}
// 切换密钥启用状态
@@ -28,7 +28,7 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { computed, onMounted } from 'vue'
import {
Select,
SelectTrigger,
@@ -67,8 +67,10 @@ const nodeOptions = computed(() => {
/** 供父组件调用:启用代理时懒加载节点列表 */
function ensureLoaded() {
proxyNodesStore.ensureLoaded()
return proxyNodesStore.ensureLoaded()
}
onMounted(ensureLoaded)
defineExpose({ ensureLoaded })
</script>
@@ -0,0 +1,52 @@
import { describe, expect, it, vi } from 'vitest'
import { createApp, defineComponent, h } from 'vue'
import ProxyNodeSelect from '@/features/providers/components/ProxyNodeSelect.vue'
import { createI18n } from '@/i18n'
const proxyNodesStore = vi.hoisted(() => ({
loading: false,
nodes: [],
onlineNodes: [],
ensureLoaded: vi.fn(() => Promise.resolve()),
}))
vi.mock('@/stores/proxy-nodes', () => ({
useProxyNodesStore: () => proxyNodesStore,
}))
vi.mock('@/components/ui', async () => {
const { defineComponent, h } = await import('vue')
const passthrough = (name: string) => defineComponent({
name,
setup(_, { slots }) {
return () => h('div', slots.default?.())
},
})
return {
Select: passthrough('SelectStub'),
SelectTrigger: passthrough('SelectTriggerStub'),
SelectValue: passthrough('SelectValueStub'),
SelectContent: passthrough('SelectContentStub'),
SelectItem: passthrough('SelectItemStub'),
}
})
describe('ProxyNodeSelect', () => {
it('loads proxy nodes when mounted', () => {
const root = document.createElement('div')
const app = createApp(defineComponent({
setup() {
return () => h(ProxyNodeSelect, { modelValue: '' })
},
}))
app.use(createI18n())
app.mount(root)
expect(proxyNodesStore.ensureLoaded).toHaveBeenCalledTimes(1)
app.unmount()
})
})
@@ -5,10 +5,40 @@ import {
formatCodexResetCreditDays,
getCodexResetCreditAvailableCount,
getVisibleCodexResetCreditItems,
mergeCodexQuotaDisplays,
} from '@/features/providers/components/codex-reset-credit-display'
import type { QuotaResetCreditsSnapshot } from '@/api/endpoints/types'
describe('codex reset credit display helpers', () => {
it('keeps reset credits and usage windows when snapshot sources are partially populated', () => {
const merged = mergeCodexQuotaDisplays(
{
updated_at: 1_700_000_100,
primary_used_percent: 25,
reset_credits: {
available_count: 2,
},
},
{
updated_at: 1_700_000_000,
secondary_used_percent: 40,
reset_credits: {
credits: [{ id: 'credit-1', expires_at: 1_700_086_400 }],
},
},
)
expect(merged).toMatchObject({
updated_at: 1_700_000_100,
primary_used_percent: 25,
secondary_used_percent: 40,
reset_credits: {
available_count: 2,
credits: [{ id: 'credit-1', expires_at: 1_700_086_400 }],
},
})
})
it('keeps zero available credits displayable but non-positive detail items hidden', () => {
const snapshot: QuotaResetCreditsSnapshot = {
available_count: 0,
@@ -1,8 +1,41 @@
import type {
CodexUpstreamMetadata,
QuotaResetCreditSnapshot,
QuotaResetCreditsSnapshot,
} from '@/api/endpoints/types'
function codexQuotaUpdatedAt(display: CodexUpstreamMetadata | null | undefined): number | null {
const updatedAt = Number(display?.updated_at)
return Number.isFinite(updatedAt) ? updatedAt : null
}
export function mergeCodexQuotaDisplays(
snapshotDisplay: CodexUpstreamMetadata | null | undefined,
metadataDisplay: CodexUpstreamMetadata | null | undefined,
): CodexUpstreamMetadata | null {
if (!snapshotDisplay) return metadataDisplay ?? null
if (!metadataDisplay) return snapshotDisplay
const snapshotUpdatedAt = codexQuotaUpdatedAt(snapshotDisplay)
const metadataUpdatedAt = codexQuotaUpdatedAt(metadataDisplay)
const metadataIsNewer = metadataUpdatedAt !== null
&& (snapshotUpdatedAt === null || metadataUpdatedAt > snapshotUpdatedAt)
const preferred = metadataIsNewer ? metadataDisplay : snapshotDisplay
const fallback = metadataIsNewer ? snapshotDisplay : metadataDisplay
const resetCredits = preferred.reset_credits || fallback.reset_credits
? {
...fallback.reset_credits,
...preferred.reset_credits,
}
: undefined
return {
...fallback,
...preferred,
...(resetCredits ? { reset_credits: resetCredits } : {}),
}
}
export interface CodexResetCreditDisplayItem {
id?: string | null
displayKey: string