Merge origin/main into main

Integrate upstream updates while preserving the local analytics dashboards and schema-only migration changes.

Combine user account analysis with upstream user/group usage statistics in separate tabs, retain all migration versions, and keep the deleted audit document removed.

Validation: gateway all-target cargo check, frontend type check and 57 focused tests, 48 migration tests, schema composition checks, and diff whitespace checks.
This commit is contained in:
elky
2026-10-02 11:57:18 +08:00
343 changed files with 27929 additions and 2549 deletions
+62 -5
View File
@@ -1071,6 +1071,7 @@ import { useConfirm } from '@/composables/useConfirm'
import { useRouteQuery } from '@/composables/useRouteQuery'
import { useBatchSelection } from '@/composables/useBatchSelection'
import { useI18n } from '@/i18n'
import type { MessageKey } from '@/i18n/messages'
import { parseApiError } from '@/utils/errorParser'
import {
getPoolOverview,
@@ -1395,6 +1396,8 @@ async function loadOverview(options: { cacheTtlMs?: number, silent?: boolean } =
async function handleSchedulingSaved(updatedProvider: ProviderWithEndpointsSummary) {
if (!selectedProviderId.value || updatedProvider.id !== selectedProviderId.value) return
// 保存前发出的详情读取不得覆盖这次保存返回的新配置。
providerDataRequestId += 1
// 优先回写保存接口返回值,避免弹窗立即重开时读到旧配置。
if (selectedProviderData.value) {
Object.assign(selectedProviderData.value, updatedProvider)
@@ -1403,7 +1406,7 @@ async function handleSchedulingSaved(updatedProvider: ProviderWithEndpointsSumma
}
showSchedulingDialog.value = false
showAdvancedDialog.value = false
await loadOverview({ silent: true })
await Promise.all([loadKeys({ silent: true }), loadOverview({ silent: true })])
}
// --- Provider Selection ---
@@ -1694,12 +1697,14 @@ watch(showAdaptiveHotPoolMetricsButton, (enabled) => {
const showAccountQuotaColumn = computed(() => {
return selectedProviderType.value === 'codex'
|| selectedProviderType.value === 'claude_code'
|| selectedProviderType.value === 'gemini_cli'
|| selectedProviderType.value === 'kiro'
|| selectedProviderType.value === 'windsurf'
|| selectedProviderType.value === 'antigravity'
|| selectedProviderType.value === 'grok'
|| selectedProviderType.value === 'chatgpt_web'
|| selectedProviderType.value === 'xai'
})
const desktopColumnWidths = computed(() => {
@@ -2143,12 +2148,14 @@ function getPoolKeyAccountStatsMetrics(key: PoolKeyDetail): PoolStatsMetric[] {
const quotaRefreshSupported = computed(() => {
return selectedProviderType.value === 'codex'
|| selectedProviderType.value === 'claude_code'
|| selectedProviderType.value === 'kiro'
|| selectedProviderType.value === 'gemini_cli'
|| selectedProviderType.value === 'windsurf'
|| selectedProviderType.value === 'antigravity'
|| selectedProviderType.value === 'grok'
|| selectedProviderType.value === 'chatgpt_web'
|| selectedProviderType.value === 'xai'
})
function canResetCycleStats(_key: PoolKeyDetail): boolean {
@@ -2267,7 +2274,19 @@ function getPendingCodexResetCreditIdempotencyKey(key: PoolKeyDetail): string |
: readPendingCodexResetCreditIdempotencyKey(key.key_id, generation)
}
function getClaudeCodeResetCredits(key: PoolKeyDetail) {
if (getQuotaSnapshotProviderType(key) !== 'claude_code') return null
return key.status_snapshot?.quota?.reset_credits
?? key.upstream_metadata?.claude_code?.reset_credits
?? null
}
function getCodexResetCreditCountText(key: PoolKeyDetail): string | null {
const claudeCredits = getClaudeCodeResetCredits(key)
if (claudeCredits) {
const claudeCount = getCodexResetCreditAvailableCount(claudeCredits)
return claudeCount === null ? null : formatCodexResetCreditCount(claudeCount)
}
const count = getCodexResetCreditAvailableCount(getCodexResetCredits(key))
return count === null && !getPendingCodexResetCreditIdempotencyKey(key)
? null
@@ -2275,7 +2294,11 @@ function getCodexResetCreditCountText(key: PoolKeyDetail): string | null {
}
function getCodexResetCreditItemTexts(key: PoolKeyDetail): string[] {
return getVisibleCodexResetCreditItems(getCodexResetCredits(key), undefined, 3)
return getVisibleCodexResetCreditItems(
getClaudeCodeResetCredits(key) ?? getCodexResetCredits(key),
undefined,
3,
)
.map(item => `${item.displayKey} ${formatCodexResetCreditExpiresAt(item.expiresAt)}`)
}
@@ -3480,8 +3503,10 @@ function normalizeQuotaLabel(label: string): string {
if (/spark/i.test(normalized) && normalized.includes('周')) return 'Spark周'
if (normalized.includes('5H')) return '5H'
if (normalized.includes('周')) return '周'
if (normalized.includes('月')) return '月'
if (normalized.includes('最低剩余')) return '最低'
if (normalized === '剩余' || normalized.includes('剩余')) return '剩余'
if (normalized === '额度') return '额度'
return normalized
}
@@ -3490,6 +3515,9 @@ function getQuotaProgressLabel(label: string): string {
if (label === '5H') return '5H'
if (label === '周') return '周'
if (label === '月') return '月'
if (label === '周额度') return '周'
if (label === '月额度') return '月'
if (label === '额度') return '额度'
if (label === 'Spark5H') return 'Spark5H'
if (label === 'Spark周') return 'Spark周'
if (label === '最低') return '最低'
@@ -3498,7 +3526,7 @@ function getQuotaProgressLabel(label: string): string {
}
function getQuotaProgressCountdown(item: QuotaProgressItem) {
const staticResetLabels = ['日', '5H', '周', '月', 'Spark5H', 'Spark周', 'Spark月', 'Auto', 'Fast', 'Expert', 'Heavy', 'Grok 4.3', '生图']
const staticResetLabels = ['日', '5H', '周', '月', '周额度', '月额度', '额度', 'Spark5H', 'Spark周', 'Spark月', 'Auto', 'Fast', 'Expert', 'Heavy', 'Grok 4.3', '生图']
if (!item.allowDynamicReset && !staticResetLabels.includes(item.label)) return null
if (item.resetAtSeconds == null && item.resetSeconds == null) return null
return getCodexResetCountdown(
@@ -3567,6 +3595,7 @@ function getQuotaLabelOrder(label: string): number {
if (label === 'Prompt') return 12
if (label === 'Flex') return 13
if (label === '剩余') return 14
if (label === '额度') return 14
if (label === '最低') return 15
if (label === '生图') return 16
if (label === '速率') return 17
@@ -3758,7 +3787,7 @@ function buildQuotaProgressItemsFromSnapshot(key: PoolKeyDetail): QuotaProgressI
.filter((item): item is QuotaProgressItem => item != null)
}
if (providerType === 'kiro') {
if (providerType === 'kiro' || providerType === 'xai') {
const quotaResetAtSeconds = getQuotaSnapshotResetAtSeconds(quota)
const quotaResetSeconds = getQuotaSnapshotResetSeconds(quota)
const window = getQuotaSnapshotWindow(quota, 'usage')
@@ -3772,12 +3801,13 @@ function buildQuotaProgressItemsFromSnapshot(key: PoolKeyDetail): QuotaProgressI
: undefined
return [{
label: '剩余',
label: normalizeQuotaLabel(String(window?.label || '').trim() || '剩余'),
remainingPercent,
detail,
resetAtSeconds: normalizeUnixSeconds(window?.reset_at ?? quotaResetAtSeconds ?? null),
resetSeconds: normalizeRemainingSeconds(window?.reset_seconds ?? quotaResetSeconds ?? null),
updatedAtSeconds: getQuotaSnapshotUpdatedAtSeconds(quota),
allowDynamicReset: true,
}]
}
@@ -3891,6 +3921,33 @@ function buildQuotaProgressItemsFromSnapshot(key: PoolKeyDetail): QuotaProgressI
.filter((item): item is QuotaProgressItem => item != null)
}
if (providerType === 'claude_code') {
const quotaResetAtSeconds = getQuotaSnapshotResetAtSeconds(quota)
const quotaResetSeconds = getQuotaSnapshotResetSeconds(quota)
const windowPresentations: Record<string, { labelKey: MessageKey, sortOrder: number }> = {
'5h': { labelKey: 'poolQuota.claudeCode.window5h', sortOrder: 0 },
weekly: { labelKey: 'poolQuota.claudeCode.weekly', sortOrder: 1 },
weekly_sonnet: { labelKey: 'poolQuota.claudeCode.weeklySonnet', sortOrder: 2 },
weekly_fable: { labelKey: 'poolQuota.claudeCode.weeklyFable', sortOrder: 3 },
}
return (quota.windows ?? [])
.map((window): QuotaProgressItem | null => {
const remainingPercent = getQuotaWindowRemainingPercent(window)
if (remainingPercent == null) return null
const presentation = windowPresentations[String(window.code || '')]
return {
label: t(presentation?.labelKey ?? 'poolQuota.claudeCode.unknownWindow'),
sortOrder: presentation?.sortOrder ?? 9,
remainingPercent,
resetAtSeconds: normalizeUnixSeconds(window.reset_at ?? quotaResetAtSeconds ?? null),
resetSeconds: normalizeRemainingSeconds(window.reset_seconds ?? quotaResetSeconds ?? null),
updatedAtSeconds: getQuotaSnapshotUpdatedAtSeconds(quota),
allowDynamicReset: true,
}
})
.filter((item): item is QuotaProgressItem => item != null)
}
if (providerType === 'gemini_cli') {
const windows = getQuotaSnapshotWindowsByScope(quota, 'model')
if (windows.length === 0) return []
+206 -186
View File
@@ -1,203 +1,221 @@
<template>
<main class="space-y-5 px-4 pb-8 sm:px-6 lg:px-0">
<OverviewToolbar
:title="t('用户分析', 'User analysis')"
:range="range"
:preset="relativePreset"
:show-range="false"
:refresh-active="autoRefresh"
:refresh-title="refreshTitle"
presets-only
@update:range="setRange"
@refresh="toggleAutoRefresh"
>
<Button
variant="outline"
size="sm"
:disabled="exporting"
@click="exportCsv('users', requestQuery)"
<Tabs v-model="activeTab">
<TabsList class="mb-4">
<TabsTrigger value="accounts">
{{ t('用户账目', 'User accounts') }}
</TabsTrigger>
<TabsTrigger value="usage">
{{ t('使用统计', 'Usage statistics') }}
</TabsTrigger>
</TabsList>
<TabsContent
value="accounts"
class="space-y-5"
>
<Download class="mr-2 h-4 w-4" />{{ t('导出用户报表', 'Export user report') }}
</Button>
</OverviewToolbar>
<OverviewStatus
:error="error || exportError"
@retry="refreshAll"
/>
<UserFinanceSummary
:summary="data?.data.summary"
:finance="data?.data.finance_summary"
:user-count="data?.data.summary?.user_count"
:active-user-count="data?.data.summary?.active_user_count"
/>
<UserReports :revision="revision" />
<TableCard
:title="t('用户账目', 'User accounts')"
:description="t('消费与到账按所选时间统计,余额为当前值', 'Consumption and credits follow the selected period; balances are current')"
class="relative min-w-0"
data-user-accounts
>
<template #actions>
<form
class="flex w-full items-center gap-2 md:w-auto"
@submit.prevent="patch({ search: search.trim() || undefined, offset: undefined })"
<OverviewToolbar
:title="t('用户分析', 'User analysis')"
:range="range"
:preset="relativePreset"
:show-range="false"
:refresh-active="autoRefresh"
:refresh-title="refreshTitle"
presets-only
@update:range="setRange"
@refresh="toggleAutoRefresh"
>
<div class="relative min-w-0 flex-1 md:w-48 md:flex-none">
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground z-10 pointer-events-none" />
<Input
v-model="search"
type="search"
:placeholder="t('搜索用户', 'Search users')"
:aria-label="t('搜索用户', 'Search users')"
class="h-8 w-full text-xs border-border/60 pl-8"
/>
</div>
<Button
type="submit"
size="sm"
variant="outline"
class="h-8 text-xs"
size="sm"
:disabled="exporting"
@click="exportCsv('users', requestQuery)"
>
{{ t('查询', 'Search') }}
<Download class="mr-2 h-4 w-4" />{{ t('导出用户报表', 'Export user report') }}
</Button>
</form>
</template>
<p
v-if="query.search"
class="border-b border-border/60 px-4 py-3 text-xs text-muted-foreground sm:px-6"
>
{{ t('上方汇总与账目仅包含匹配的用户', 'The summary and accounts include matching users only') }}
</p>
<div
class="min-w-0"
:aria-busy="loading"
>
<Table class="min-w-[1040px]">
<TableHeader>
<TableRow class="border-b border-border/60 hover:bg-transparent">
<TableHead class="h-12 font-semibold">
{{ t('用户', 'User') }}
</TableHead>
<SortableTableHead
v-for="column in columns"
:key="column.key"
class="h-12 font-semibold text-right"
:column-key="column.key"
:active-key="requestQuery.sort"
:direction="requestQuery.order"
default-direction="desc"
align="right"
@sort="sort"
>
{{ column.label }}
</SortableTableHead>
<TableHead class="h-12 font-semibold text-right">
{{ t('充值', 'Recharges') }}
</TableHead>
<TableHead class="h-12 font-semibold text-right">
{{ t('余额', 'Balance') }}
</TableHead>
<TableHead class="h-12 font-semibold text-right">
{{ t('最近使用', 'Last used') }}
</TableHead>
<TableHead class="h-12 font-semibold text-right">
{{ t('记录', 'Records') }}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow
v-for="user in data?.data.items || []"
:key="user.user_id"
class="border-b border-border/40 hover:bg-muted/30 transition-colors h-[72px]"
>
<TableCell class="max-w-64 py-4 text-xs">
<button
type="button"
class="break-words hover:text-primary hover:underline"
@click="accountUser = user"
>
{{ user.username }}
</button>
<span
v-if="!user.is_active"
class="ml-2 text-xs text-muted-foreground"
>{{ t('停用', 'Disabled') }}</span>
<p class="mt-0.5 break-words text-xs text-muted-foreground">
{{ user.email }}
</p>
</TableCell>
<TableCell
class="whitespace-nowrap py-4 text-right text-xs font-medium tabular-nums"
:title="user.billable_amount?.value ?? ''"
>
{{ money(user.billable_amount) }}
</TableCell>
<TableCell class="py-4 text-right text-xs tabular-nums">
{{ count(user.request_count) }}
</TableCell>
<TableCell class="py-4 text-right text-xs tabular-nums">
{{ count(user.total_tokens) }}
</TableCell>
<TableCell class="whitespace-nowrap py-4 text-right text-xs tabular-nums">
{{ money(user.finance?.recharge_amount) }}
</TableCell>
<TableCell class="whitespace-nowrap py-4 text-right text-xs tabular-nums">
{{ money(user.finance?.wallet_balance) }}
</TableCell>
<TableCell class="whitespace-nowrap py-4 text-right text-xs text-muted-foreground">
{{ timestamp(user.last_used_at, range.timezone) }}
</TableCell>
<TableCell class="whitespace-nowrap py-4 text-right text-xs">
<Button
type="button"
variant="ghost"
size="icon"
:title="t('查看', 'View') + ' ' + user.username + ' ' + t('账目', 'account')"
:aria-label="t('查看', 'View') + ' ' + user.username + ' ' + t('账目', 'account')"
@click="accountUser = user"
>
<ReceiptText class="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
<TableRow v-if="!data?.data.items.length">
<TableCell
colspan="8"
class="py-12 text-center text-muted-foreground"
>
{{ loading ? t('加载中', 'Loading') : error ? t('用户账目暂不可用', 'User accounts unavailable') : t('没有符合条件的用户', 'No matching users') }}
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
<template #pagination>
<Pagination
v-if="data"
:total="data.data.total"
:current="Math.floor(requestQuery.offset / requestQuery.limit) + 1"
:page-size="requestQuery.limit"
:page-size-options="[25, 50, 100]"
@update:current="changePage"
@update:page-size="changePageSize"
</OverviewToolbar>
<OverviewStatus
:error="error || exportError"
@retry="refreshAll"
/>
</template>
</TableCard>
<UserAccountDrawer
v-if="accountUser"
:key="accountUser.user_id"
:user="accountUser"
:timezone="range.timezone"
@close="accountUser = null"
/>
<UserFinanceSummary
:summary="data?.data.summary"
:finance="data?.data.finance_summary"
:user-count="data?.data.summary?.user_count"
:active-user-count="data?.data.summary?.active_user_count"
/>
<UserReports :revision="revision" />
<TableCard
:title="t('用户账目', 'User accounts')"
:description="t('消费与到账按所选时间统计,余额为当前值', 'Consumption and credits follow the selected period; balances are current')"
class="relative min-w-0"
data-user-accounts
>
<template #actions>
<form
class="flex w-full items-center gap-2 md:w-auto"
@submit.prevent="patch({ search: search.trim() || undefined, offset: undefined })"
>
<div class="relative min-w-0 flex-1 md:w-48 md:flex-none">
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground z-10 pointer-events-none" />
<Input
v-model="search"
type="search"
:placeholder="t('搜索用户', 'Search users')"
:aria-label="t('搜索用户', 'Search users')"
class="h-8 w-full text-xs border-border/60 pl-8"
/>
</div>
<Button
type="submit"
size="sm"
variant="outline"
class="h-8 text-xs"
>
{{ t('查询', 'Search') }}
</Button>
</form>
</template>
<p
v-if="query.search"
class="border-b border-border/60 px-4 py-3 text-xs text-muted-foreground sm:px-6"
>
{{ t('上方汇总与账目仅包含匹配的用户', 'The summary and accounts include matching users only') }}
</p>
<div
class="min-w-0"
:aria-busy="loading"
>
<Table class="min-w-[1040px]">
<TableHeader>
<TableRow class="border-b border-border/60 hover:bg-transparent">
<TableHead class="h-12 font-semibold">
{{ t('用户', 'User') }}
</TableHead>
<SortableTableHead
v-for="column in columns"
:key="column.key"
class="h-12 font-semibold text-right"
:column-key="column.key"
:active-key="requestQuery.sort"
:direction="requestQuery.order"
default-direction="desc"
align="right"
@sort="sort"
>
{{ column.label }}
</SortableTableHead>
<TableHead class="h-12 font-semibold text-right">
{{ t('充值', 'Recharges') }}
</TableHead>
<TableHead class="h-12 font-semibold text-right">
{{ t('余额', 'Balance') }}
</TableHead>
<TableHead class="h-12 font-semibold text-right">
{{ t('最近使用', 'Last used') }}
</TableHead>
<TableHead class="h-12 font-semibold text-right">
{{ t('记录', 'Records') }}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow
v-for="user in data?.data.items || []"
:key="user.user_id"
class="border-b border-border/40 hover:bg-muted/30 transition-colors h-[72px]"
>
<TableCell class="max-w-64 py-4 text-xs">
<button
type="button"
class="break-words hover:text-primary hover:underline"
@click="accountUser = user"
>
{{ user.username }}
</button>
<span
v-if="!user.is_active"
class="ml-2 text-xs text-muted-foreground"
>{{ t('停用', 'Disabled') }}</span>
<p class="mt-0.5 break-words text-xs text-muted-foreground">
{{ user.email }}
</p>
</TableCell>
<TableCell
class="whitespace-nowrap py-4 text-right text-xs font-medium tabular-nums"
:title="user.billable_amount?.value ?? ''"
>
{{ money(user.billable_amount) }}
</TableCell>
<TableCell class="py-4 text-right text-xs tabular-nums">
{{ count(user.request_count) }}
</TableCell>
<TableCell class="py-4 text-right text-xs tabular-nums">
{{ count(user.total_tokens) }}
</TableCell>
<TableCell class="whitespace-nowrap py-4 text-right text-xs tabular-nums">
{{ money(user.finance?.recharge_amount) }}
</TableCell>
<TableCell class="whitespace-nowrap py-4 text-right text-xs tabular-nums">
{{ money(user.finance?.wallet_balance) }}
</TableCell>
<TableCell class="whitespace-nowrap py-4 text-right text-xs text-muted-foreground">
{{ timestamp(user.last_used_at, range.timezone) }}
</TableCell>
<TableCell class="whitespace-nowrap py-4 text-right text-xs">
<Button
type="button"
variant="ghost"
size="icon"
:title="t('查看', 'View') + ' ' + user.username + ' ' + t('账目', 'account')"
:aria-label="t('查看', 'View') + ' ' + user.username + ' ' + t('账目', 'account')"
@click="accountUser = user"
>
<ReceiptText class="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
<TableRow v-if="!data?.data.items.length">
<TableCell
colspan="8"
class="py-12 text-center text-muted-foreground"
>
{{ loading ? t('加载中', 'Loading') : error ? t('用户账目暂不可用', 'User accounts unavailable') : t('没有符合条件的用户', 'No matching users') }}
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
<template #pagination>
<Pagination
v-if="data"
:total="data.data.total"
:current="Math.floor(requestQuery.offset / requestQuery.limit) + 1"
:page-size="requestQuery.limit"
:page-size-options="[25, 50, 100]"
@update:current="changePage"
@update:page-size="changePageSize"
/>
</template>
</TableCard>
<UserAccountDrawer
v-if="accountUser"
:key="accountUser.user_id"
:user="accountUser"
:timezone="range.timezone"
@close="accountUser = null"
/>
</TabsContent>
<TabsContent value="usage">
<UserUsageStats v-if="activeTab === 'usage'" />
</TabsContent>
</Tabs>
</main>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { computed, defineAsyncComponent, ref, watch } from 'vue'
import { Download, Search, ReceiptText } from 'lucide-vue-next'
import { Button, Input, Pagination, SortableTableHead, Table, TableBody, TableCard, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui'
import { Button, Input, Pagination, SortableTableHead, Table, TableBody, TableCard, TableCell, TableHead, TableHeader, TableRow, Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui'
import { overviewApi } from '@/api/overview'
import OverviewToolbar from '@/features/overview/components/OverviewToolbar.vue'
import OverviewStatus from '@/features/overview/components/OverviewStatus.vue'
@@ -210,6 +228,8 @@ import { useOverviewRequest } from '@/features/overview/useOverviewRequest'
import { useOverviewExport } from '@/features/overview/useOverviewExport'
import { useOverviewI18n } from '@/features/overview/i18n'
import { count, money, timestamp } from '@/features/overview/format'
const UserUsageStats = defineAsyncComponent(() => import('@/features/overview/users/UserUsageStats.vue'))
const activeTab = ref('accounts')
const { t } = useOverviewI18n()
const { query, range, relativePreset, refreshRange, setRange, patch } = useOverviewQuery('today', { rolling: true })
const { revision, autoRefresh, refreshTitle, refreshAll, toggleAutoRefresh } = useUserAnalysisRefresh(refreshRange)
@@ -340,12 +340,24 @@ vi.mock('@/components/ui/refresh-button.vue', async () => {
})
vi.mock('@/features/pool/components/PoolSchedulingDialog.vue', async () => {
const { defineComponent } = await import('vue')
const { defineComponent, h } = await import('vue')
return {
default: defineComponent({
name: 'PoolSchedulingDialogStub',
setup() {
return () => null
props: {
providerId: { type: String, required: true },
currentConfig: { type: Object, default: null },
},
emits: ['saved'],
setup(props, { emit }) {
return () => h('button', {
'data-testid': 'save-pool-scheduling',
'data-reserve': String(props.currentConfig?.reserve_minimum_quota ?? false),
onClick: () => emit('saved', {
id: props.providerId,
pool_advanced: { ...props.currentConfig, reserve_minimum_quota: false },
}),
}, '保存调度测试')
},
}),
}
@@ -1278,4 +1290,80 @@ describe('PoolManagement Codex cycle stats mode', () => {
expect(disabledRoot.querySelector('[data-testid="pool-demand-metrics-button"]')).toBeNull()
})
it('reloads account scheduling state immediately after disabling minimum quota reserve', async () => {
const key = createPoolKey('codex', {
scheduling_status: 'blocked',
scheduling_reason: 'account_quota_exhausted',
scheduling_label: '额度耗尽',
})
endpointMocks.getPoolOverview.mockResolvedValue({ items: [createOverview('codex')] })
endpointMocks.listPoolKeys.mockResolvedValue(createKeyPage(key))
endpointMocks.getProvider.mockResolvedValue(createProvider('codex', {
pool_advanced: { reserve_minimum_quota: true },
}))
const root = mountPoolManagement()
await settle()
expect(root.querySelector('tbody')?.textContent).toContain('额度耗尽')
endpointMocks.listPoolKeys.mockClear()
endpointMocks.listPoolKeys.mockResolvedValue(createKeyPage({
...key,
scheduling_status: 'available',
scheduling_reason: 'available',
scheduling_label: '可用',
}))
root.querySelector<HTMLButtonElement>('[data-testid="save-pool-scheduling"]')?.click()
await settle()
expect(endpointMocks.listPoolKeys).toHaveBeenCalledWith(
'codex-provider', expect.anything(), { cacheTtlMs: 0 },
)
expect(root.querySelector('tbody')?.textContent).not.toContain('额度耗尽')
expect(root.querySelector('tbody')?.textContent).toContain('可用')
})
it('shows 17% remaining without marking a schedulable account exhausted when reserve is enabled', async () => {
endpointMocks.getPoolOverview.mockResolvedValue({ items: [createOverview('codex')] })
endpointMocks.getProvider.mockResolvedValue(createProvider('codex', {
pool_advanced: { reserve_minimum_quota: true },
}))
endpointMocks.listPoolKeys.mockResolvedValue(createKeyPage(createPoolKey('codex', {
scheduling_status: 'available',
scheduling_reason: 'available',
scheduling_label: '可用',
status_snapshot: {
account: { code: 'ok', blocked: false },
quota: {
code: 'ok',
exhausted: false,
provider_type: 'codex',
windows: [{ code: '5h', used_ratio: 0.83, remaining_ratio: 0.17 }],
},
oauth: { code: 'none' },
},
})))
const root = mountPoolManagement()
await settle()
expect(root.querySelector('[data-testid="pool-quota-meter-text"]')?.textContent).toBe('17.0%')
expect(root.querySelector('tbody')?.textContent).toContain('可用')
expect(root.querySelector('tbody')?.textContent).not.toContain('额度耗尽')
})
it('does not replace saved scheduling settings with an older provider detail response', async () => {
let resolveOldProvider!: (provider: ReturnType<typeof createProvider>) => void
endpointMocks.getProvider.mockReturnValue(new Promise((resolve) => { resolveOldProvider = resolve }))
endpointMocks.getPoolOverview.mockResolvedValue({ items: [createOverview('codex')] })
endpointMocks.listPoolKeys.mockResolvedValue(createKeyPage(createPoolKey()))
const root = mountPoolManagement()
await settle()
root.querySelector<HTMLButtonElement>('[data-testid="save-pool-scheduling"]')?.click()
await settle()
resolveOldProvider(createProvider('codex', { pool_advanced: { reserve_minimum_quota: true } }))
await settle()
expect(root.querySelector('[data-testid="save-pool-scheduling"]')?.getAttribute('data-reserve')).toBe('false')
})
})
+12
View File
@@ -527,6 +527,8 @@ const filteredRecords = computed(() => {
records = records.filter(record => hasUsageFallback(record))
} else if (filterStatus.value === 'has_retry') {
records = records.filter(record => record.has_retry === true)
} else if (filterStatus.value === 'has_skipped_candidate') {
records = records.filter(record => record.has_skipped_candidate === true)
}
}
@@ -735,6 +737,10 @@ async function pollActiveRequests() {
record.actual_service_tier = typeof update.actual_service_tier === 'string' && update.actual_service_tier.trim()
? update.actual_service_tier
: null
// 活跃接口返回的是当前最终候选快照,服务端空值要清除旧响应模型。
record.response_model = typeof update.response_model === 'string' && update.response_model.trim()
? update.response_model
: null
// 管理员接口返回额外字段
// 只有当返回的 provider 不是 pending/unknown/unknow 时才更新,避免覆盖已有的正确值
if ('provider' in update && typeof update.provider === 'string') {
@@ -1307,6 +1313,7 @@ function handleDetailRequestState(update: {
reasoningEffort?: string | null
serviceTier?: string | null
actualServiceTier?: string | null
responseModel?: string | null
imageProgress?: ImageProgress | null
errorMessage?: string | null
updatedAt?: string | null
@@ -1449,6 +1456,11 @@ function handleDetailRequestState(update: {
? update.actualServiceTier
: null
}
if ('responseModel' in update) {
record.response_model = typeof update.responseModel === 'string'
? update.responseModel
: null
}
if ('imageProgress' in update) {
const nextProgress = update.imageProgress ?? null
if (!sameImageProgress(record.image_progress, nextProgress)) {