fix: 完善上游 Sub2API 计费倍率探测与账号展示

This commit is contained in:
Tian Lee
2026-07-17 20:59:29 +08:00
committed by yardbirds0
parent bc50c9d013
commit d2585097f3
30 changed files with 1311 additions and 183 deletions
+65 -76
View File
@@ -132,41 +132,6 @@
<span class="flex-1 text-left">{{ t('admin.tlsFingerprintProfiles.title') }}</span>
</button>
<div class="my-2 border-t border-gray-100 dark:border-gray-700"></div>
<div class="space-y-2 px-3 py-2">
<div class="flex items-center justify-between gap-3">
<span class="text-sm font-medium text-gray-700 dark:text-gray-200">
{{ t('admin.accounts.upstreamBilling.autoProbeSettings') }}
</span>
<Toggle
v-model="upstreamBillingProbeSettings.enabled"
:aria-label="t('admin.accounts.upstreamBilling.autoProbeSettings')"
/>
</div>
<div class="flex items-center gap-2">
<label class="flex-1 text-xs text-gray-500 dark:text-gray-400" for="upstream-billing-probe-interval">
{{ t('admin.accounts.upstreamBilling.intervalMinutes') }}
</label>
<input
id="upstream-billing-probe-interval"
v-model.number="upstreamBillingProbeSettings.interval_minutes"
type="number"
min="5"
max="1440"
class="input h-8 w-20 px-2 text-sm"
/>
<button
type="button"
class="btn btn-secondary h-8 px-2"
:disabled="upstreamBillingSettingsLoading || upstreamBillingSettingsSaving"
:title="t('common.save')"
@click="saveUpstreamBillingProbeSettings"
>
<Icon name="check" size="sm" />
</button>
</div>
</div>
<div class="my-2 border-t border-gray-100 dark:border-gray-700"></div>
<div class="px-2 py-2">
<div class="flex items-center justify-between gap-3">
@@ -375,15 +340,17 @@
</span>
</template>
<template #header-upstream_billing_rate="{ column }">
<div class="flex items-center">
<div class="flex items-center gap-1">
<span>{{ column.label }}</span>
<HelpTooltip :content="t('admin.accounts.upstreamBilling.trustWarning')" width-class="w-80" />
<span @click.stop>
<HelpTooltip :content="t('admin.accounts.upstreamBilling.trustWarning')" width-class="w-80" />
</span>
</div>
</template>
<template #cell-upstream_billing_rate="{ row }">
<UpstreamBillingRateCell
:account="row"
:interval-minutes="upstreamBillingProbeSettings.interval_minutes"
:global-probe-enabled="upstreamBillingProbeGloballyEnabled"
:now="upstreamBillingNow"
:probing="probingUpstreamBilling.has(row.id)"
@probe="handleProbeUpstreamBilling(row)"
@@ -512,7 +479,6 @@ import AppLayout from '@/components/layout/AppLayout.vue'
import TablePageLayout from '@/components/layout/TablePageLayout.vue'
import DataTable from '@/components/common/DataTable.vue'
import HelpTooltip from '@/components/common/HelpTooltip.vue'
import Toggle from '@/components/common/Toggle.vue'
import Pagination from '@/components/common/Pagination.vue'
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
import { CreateAccountModal, EditAccountModal, BulkEditAccountModal, SyncFromCrsModal, TempUnschedStatusModal } from '@/components/account'
@@ -541,7 +507,7 @@ import { formatDateTime, formatRelativeTime } from '@/utils/format'
import { proxyExpiryBadgeClass, proxyExpiryLabelKey } from '@/utils/proxyExpiry'
import { extractApiErrorMessage } from '@/utils/apiError'
import { sanitizeUrl } from '@/utils/url'
import type { Account, AccountPlatform, AccountSchedulerGroupScore, AccountType, Proxy as AccountProxy, AdminGroup, WindowStats, ClaudeModel, UpstreamBillingProbeSettings, UpstreamBillingProbeSnapshot } from '@/types'
import type { Account, AccountPlatform, AccountSchedulerGroupScore, AccountType, Proxy as AccountProxy, AdminGroup, WindowStats, ClaudeModel, UpstreamBillingProbeSnapshot } from '@/types'
const { t } = useI18n()
const appStore = useAppStore()
@@ -619,14 +585,10 @@ const scheduleModelOptions = ref<SelectOption[]>([])
const togglingSchedulable = ref<number | null>(null)
const menu = reactive<{show:boolean, acc:Account|null, pos:{top:number, left:number}|null}>({ show: false, acc: null, pos: null })
const exportingData = ref(false)
const upstreamBillingProbeSettings = reactive<UpstreamBillingProbeSettings>({
enabled: true,
interval_minutes: 30
})
const upstreamBillingSettingsLoading = ref(false)
const upstreamBillingSettingsSaving = ref(false)
const probingUpstreamBilling = reactive(new Set<number>())
const upstreamBillingProbeGloballyEnabled = ref<boolean | undefined>(undefined)
const upstreamBillingNow = ref(Date.now())
let lastUpstreamBillingSortRefreshMinute = -1
useIntervalFn(() => { upstreamBillingNow.value = Date.now() }, 60_000)
// Account tools dropdown
@@ -653,6 +615,7 @@ const ACCOUNT_SORTABLE_KEYS = new Set([
'schedulable',
'priority',
'rate_multiplier',
'upstream_billing_rate',
'last_used_at',
'created_at',
'expires_at'
@@ -968,8 +931,15 @@ const resetAutoRefreshCache = () => {
const isFirstLoad = ref(true)
function markUpstreamBillingSortRefresh() {
if (sortState.sort_by === 'upstream_billing_rate') {
lastUpstreamBillingSortRefreshMinute = Math.floor(Date.now() / 60_000)
}
}
const load = async () => {
const requestParams = params as any
markUpstreamBillingSortRefresh()
syncAccountListDerivedParams()
hasPendingListSync.value = false
resetAutoRefreshCache()
@@ -986,6 +956,7 @@ const load = async () => {
}
const reload = async () => {
markUpstreamBillingSortRefresh()
syncAccountListDerivedParams()
hasPendingListSync.value = false
resetAutoRefreshCache()
@@ -994,6 +965,19 @@ const reload = async () => {
await refreshTodayStatsBatch()
}
const refreshUpstreamBillingSortedList = async (force = false) => {
if (sortState.sort_by !== 'upstream_billing_rate') return
const minute = Math.floor(upstreamBillingNow.value / 60_000)
if (!force && lastUpstreamBillingSortRefreshMinute === minute) return
lastUpstreamBillingSortRefreshMinute = minute
try {
await reload()
} catch (error) {
console.error('Failed to refresh upstream billing sort:', error)
}
}
const debouncedReload = () => {
syncAccountListDerivedParams()
hasPendingListSync.value = false
@@ -1033,6 +1017,9 @@ const handleSort = (key: string, order: AccountSortOrder) => {
}
watch(loading, (isLoading, wasLoading) => {
if (wasLoading && !isLoading) {
upstreamBillingNow.value = Date.now()
}
if (wasLoading && !isLoading && pendingTodayStatsRefresh.value) {
pendingTodayStatsRefresh.value = false
refreshTodayStatsBatch().catch((error) => {
@@ -1041,6 +1028,12 @@ watch(loading, (isLoading, wasLoading) => {
}
})
watch(upstreamBillingNow, () => {
if (sortState.sort_by !== 'upstream_billing_rate' || loading.value) return
if (typeof document !== 'undefined' && document.hidden) return
void refreshUpstreamBillingSortedList()
})
const isAnyModalOpen = computed(() => {
return (
showCreate.value ||
@@ -1152,7 +1145,9 @@ const refreshAccountsIncrementally = async () => {
pagination.pages = result.data.pages || 0
mergeAccountsIncrementally(result.data.items || [])
hasPendingListSync.value = false
markUpstreamBillingSortRefresh()
}
upstreamBillingNow.value = Date.now()
await refreshTodayStatsBatch()
} catch (error) {
@@ -1163,11 +1158,20 @@ const refreshAccountsIncrementally = async () => {
}
const handleManualRefresh = async () => {
await load()
await Promise.all([load(), loadUpstreamBillingProbeGlobalState()])
// Force usage cells to refetch /usage on explicit user refresh.
usageManualRefreshToken.value += 1
}
const loadUpstreamBillingProbeGlobalState = async () => {
try {
const settings = await adminAPI.accounts.getUpstreamBillingProbeSettings()
upstreamBillingProbeGloballyEnabled.value = settings.enabled
} catch (error) {
console.error('Failed to load upstream billing probe settings:', error)
}
}
const closeAccountToolsDropdown = () => {
showAccountToolsDropdown.value = false
}
@@ -1197,31 +1201,6 @@ const openTLSFingerprintProfiles = () => {
showTLSFingerprintProfiles.value = true
}
const loadUpstreamBillingProbeSettings = async () => {
upstreamBillingSettingsLoading.value = true
try {
Object.assign(upstreamBillingProbeSettings, await adminAPI.accounts.getUpstreamBillingProbeSettings())
} catch (error) {
console.error('Failed to load upstream billing probe settings:', error)
} finally {
upstreamBillingSettingsLoading.value = false
}
}
const saveUpstreamBillingProbeSettings = async () => {
upstreamBillingSettingsSaving.value = true
try {
const saved = await adminAPI.accounts.updateUpstreamBillingProbeSettings({ ...upstreamBillingProbeSettings })
Object.assign(upstreamBillingProbeSettings, saved)
appStore.showSuccess(t('admin.accounts.upstreamBilling.settingsSaved'))
} catch (error) {
console.error('Failed to save upstream billing probe settings:', error)
appStore.showError(extractApiErrorMessage(error, t('admin.accounts.upstreamBilling.settingsFailed')))
} finally {
upstreamBillingSettingsSaving.value = false
}
}
const syncPendingListChanges = async () => {
hasPendingListSync.value = false
await load()
@@ -1397,7 +1376,7 @@ const allColumns = computed(() => {
{ key: 'priority', label: t('admin.accounts.columns.priority'), sortable: true },
{ key: 'scheduler_score', label: t('admin.accounts.columns.schedulerScore'), sortable: false },
{ key: 'rate_multiplier', label: t('admin.accounts.columns.billingRateMultiplier'), sortable: true },
{ key: 'upstream_billing_rate', label: t('admin.accounts.columns.upstreamBillingRate'), sortable: false },
{ key: 'upstream_billing_rate', label: t('admin.accounts.columns.upstreamBillingRate'), sortable: true },
{ key: 'last_used_at', label: t('admin.accounts.columns.lastUsed'), sortable: true },
{ key: 'created_at', label: t('admin.accounts.columns.createdAt'), sortable: true },
{ key: 'expires_at', label: t('admin.accounts.columns.expiresAt'), sortable: true },
@@ -1521,9 +1500,14 @@ const handleBulkProbeUpstreamBilling = async () => {
accountIDs.forEach(id => probingUpstreamBilling.add(id))
try {
const results = await adminAPI.accounts.probeUpstreamBillingBatch(accountIDs)
let patched = false
results.forEach(result => {
if (result.snapshot) patchUpstreamBillingSnapshot(result.account_id, result.snapshot)
if (result.snapshot) {
patchUpstreamBillingSnapshot(result.account_id, result.snapshot)
patched = true
}
})
if (patched) await refreshUpstreamBillingSortedList(true)
const failed = results.filter(result => result.error).length
if (failed > 0) {
appStore.showError(t('admin.accounts.upstreamBilling.batchPartial', { success: results.length - failed, failed }))
@@ -1789,6 +1773,8 @@ const patchAccountInList = (updatedAccount: Account) => {
const patchUpstreamBillingSnapshot = (accountID: number, snapshot: UpstreamBillingProbeSnapshot) => {
const account = accounts.value.find(item => item.id === accountID)
if (!account) return
markUpstreamBillingSortRefresh()
upstreamBillingNow.value = Date.now()
patchAccountInList({
...account,
extra: { ...account.extra, upstream_billing_probe: snapshot }
@@ -1799,7 +1785,10 @@ const handleProbeUpstreamBilling = async (account: Account) => {
probingUpstreamBilling.add(account.id)
try {
const result = await adminAPI.accounts.probeUpstreamBilling(account.id)
if (result.snapshot) patchUpstreamBillingSnapshot(account.id, result.snapshot)
if (result.snapshot) {
patchUpstreamBillingSnapshot(account.id, result.snapshot)
await refreshUpstreamBillingSortedList(true)
}
} catch (error) {
console.error('Failed to probe upstream billing:', error)
appStore.showError(extractApiErrorMessage(error, t('admin.accounts.upstreamBilling.probeFailed')))
@@ -2062,7 +2051,7 @@ const handleClickOutside = (event: MouseEvent) => {
onMounted(async () => {
load()
loadUpstreamBillingProbeSettings()
loadUpstreamBillingProbeGlobalState()
try {
const [p, g] = await Promise.all([adminAPI.proxies.getAll(), adminAPI.groups.getAll()])
proxies.value = p
+129
View File
@@ -4028,6 +4028,92 @@
</div>
</div>
<!-- Upstream Billing Probe Settings -->
<div class="card" data-testid="upstream-billing-probe-settings">
<div
class="border-b border-gray-100 px-6 py-4 dark:border-dark-700"
>
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
{{ t("admin.settings.upstreamBillingProbe.title") }}
</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{ t("admin.settings.upstreamBillingProbe.description") }}
</p>
</div>
<div class="space-y-5 p-6">
<div
v-if="upstreamBillingProbeLoading"
class="flex items-center gap-2 text-gray-500"
>
<div
class="h-4 w-4 animate-spin rounded-full border-b-2 border-primary-600"
></div>
{{ t("common.loading") }}
</div>
<template v-else>
<div class="flex items-center justify-between gap-4">
<div>
<label class="font-medium text-gray-900 dark:text-white">
{{ t("admin.settings.upstreamBillingProbe.enabled") }}
</label>
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ t("admin.settings.upstreamBillingProbe.enabledHint") }}
</p>
</div>
<Toggle
v-model="upstreamBillingProbeForm.enabled"
:aria-label="t('admin.settings.upstreamBillingProbe.enabled')"
data-testid="upstream-billing-probe-enabled"
/>
</div>
<div
v-if="upstreamBillingProbeForm.enabled"
class="border-t border-gray-100 pt-4 dark:border-dark-700"
>
<label
class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
for="upstream-billing-probe-interval"
>
{{ t("admin.settings.upstreamBillingProbe.intervalMinutes") }}
</label>
<input
id="upstream-billing-probe-interval"
v-model.number="upstreamBillingProbeForm.interval_minutes"
type="number"
min="5"
max="1440"
class="input w-32"
data-testid="upstream-billing-probe-interval"
@keydown.enter.prevent="saveUpstreamBillingProbeSettings"
/>
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
{{ t("admin.settings.upstreamBillingProbe.intervalHint") }}
</p>
</div>
<div
class="flex justify-end border-t border-gray-100 pt-4 dark:border-dark-700"
>
<button
type="button"
class="btn btn-primary btn-sm"
:disabled="upstreamBillingProbeSaving"
data-testid="upstream-billing-probe-save"
@click="saveUpstreamBillingProbeSettings"
>
{{
upstreamBillingProbeSaving
? t("common.saving")
: t("common.save")
}}
</button>
</div>
</template>
</div>
</div>
<!-- Gateway Scheduling Settings -->
<div class="card">
<div
@@ -7652,6 +7738,14 @@ const adminApiKeyOperating = ref(false);
const newAdminApiKey = ref("");
const subscriptionGroups = ref<AdminGroup[]>([]);
// Upstream billing probe state
const upstreamBillingProbeLoading = ref(true);
const upstreamBillingProbeSaving = ref(false);
const upstreamBillingProbeForm = reactive({
enabled: true,
interval_minutes: 30,
});
// Overload Cooldown (529) 状态
const overloadCooldownLoading = ref(true);
const overloadCooldownSaving = ref(false);
@@ -10058,6 +10152,40 @@ function copyNewKey() {
});
}
async function loadUpstreamBillingProbeSettings() {
upstreamBillingProbeLoading.value = true;
try {
Object.assign(
upstreamBillingProbeForm,
await adminAPI.accounts.getUpstreamBillingProbeSettings(),
);
} catch (_error: unknown) {
// Keep defaults when this optional setting cannot be loaded.
} finally {
upstreamBillingProbeLoading.value = false;
}
}
async function saveUpstreamBillingProbeSettings() {
upstreamBillingProbeSaving.value = true;
try {
const updated = await adminAPI.accounts.updateUpstreamBillingProbeSettings({
...upstreamBillingProbeForm,
});
Object.assign(upstreamBillingProbeForm, updated);
appStore.showSuccess(t("admin.settings.upstreamBillingProbe.saved"));
} catch (error: unknown) {
appStore.showError(
extractApiErrorMessage(
error,
t("admin.settings.upstreamBillingProbe.saveFailed"),
),
);
} finally {
upstreamBillingProbeSaving.value = false;
}
}
// Overload Cooldown 方法
async function loadOverloadCooldownSettings() {
overloadCooldownLoading.value = true;
@@ -10754,6 +10882,7 @@ onMounted(() => {
loadSettings();
loadSubscriptionGroups();
loadAdminApiKey();
loadUpstreamBillingProbeSettings();
loadOverloadCooldownSettings();
loadRateLimit429CooldownSettings();
loadStreamTimeoutSettings();
@@ -7,6 +7,7 @@ const {
listAccounts,
listWithEtag,
getBatchTodayStats,
getUpstreamBillingProbeSettings,
getAllProxies,
getAllGroups,
probeUpstreamBillingBatch
@@ -14,6 +15,7 @@ const {
listAccounts: vi.fn(),
listWithEtag: vi.fn(),
getBatchTodayStats: vi.fn(),
getUpstreamBillingProbeSettings: vi.fn(),
getAllProxies: vi.fn(),
getAllGroups: vi.fn(),
probeUpstreamBillingBatch: vi.fn()
@@ -25,7 +27,7 @@ vi.mock('@/api/admin', () => ({
list: listAccounts,
listWithEtag,
getBatchTodayStats,
getUpstreamBillingProbeSettings: vi.fn().mockResolvedValue({ enabled: true, interval_minutes: 30 }),
getUpstreamBillingProbeSettings,
delete: vi.fn(),
batchClearError: vi.fn(),
batchRefresh: vi.fn(),
@@ -106,6 +108,7 @@ describe('admin AccountsView bulk edit scope', () => {
listAccounts.mockReset()
listWithEtag.mockReset()
getBatchTodayStats.mockReset()
getUpstreamBillingProbeSettings.mockReset()
getAllProxies.mockReset()
getAllGroups.mockReset()
probeUpstreamBillingBatch.mockReset()
@@ -123,6 +126,7 @@ describe('admin AccountsView bulk edit scope', () => {
data: null
})
getBatchTodayStats.mockResolvedValue({ stats: {} })
getUpstreamBillingProbeSettings.mockResolvedValue({ enabled: true, interval_minutes: 30 })
getAllProxies.mockResolvedValue([])
getAllGroups.mockResolvedValue([])
probeUpstreamBillingBatch.mockResolvedValue([])
@@ -242,6 +246,75 @@ describe('admin AccountsView bulk edit scope', () => {
})
})
it('passes the loaded global probe state to every upstream billing cell', async () => {
listAccounts.mockResolvedValue({
items: [
{
id: 1,
name: 'upstream',
platform: 'openai',
type: 'apikey',
status: 'active',
schedulable: true,
created_at: '2026-07-13T00:00:00Z',
updated_at: '2026-07-13T00:00:00Z'
}
],
total: 1,
page: 1,
page_size: 20,
pages: 1
})
getUpstreamBillingProbeSettings.mockResolvedValue({ enabled: false, interval_minutes: 30 })
const wrapper = mount(AccountsView, {
global: {
stubs: {
AppLayout: { template: '<div><slot /></div>' },
TablePageLayout: { template: '<div><slot name="table" /></div>' },
DataTable: {
props: ['data'],
template: '<div><div v-for="row in data" :key="row.id"><slot name="cell-upstream_billing_rate" :row="row" /></div></div>'
},
UpstreamBillingRateCell: {
props: ['globalProbeEnabled'],
template: '<span data-test="upstream-billing-cell" :data-global-enabled="String(globalProbeEnabled)"></span>'
},
Pagination: true,
ConfirmDialog: true,
AccountTableActions: true,
AccountTableFilters: true,
AccountBulkActionsBar: true,
AccountActionMenu: true,
ImportDataModal: true,
ReAuthAccountModal: true,
AccountTestModal: true,
AccountStatsModal: true,
ScheduledTestsPanel: true,
SyncFromCrsModal: true,
TempUnschedStatusModal: true,
ErrorPassthroughRulesModal: true,
TLSFingerprintProfilesModal: true,
CreateAccountModal: true,
EditAccountModal: true,
BulkEditAccountModal: true,
PlatformTypeBadge: true,
AccountCapacityCell: true,
AccountStatusIndicator: true,
AccountTodayStatsCell: true,
AccountGroupsCell: true,
AccountUsageCell: true,
Icon: true
}
}
})
await flushPromises()
expect(getUpstreamBillingProbeSettings).toHaveBeenCalledTimes(1)
expect(wrapper.get('[data-test="upstream-billing-cell"]').attributes('data-global-enabled')).toBe('false')
})
it('submits selected account IDs from every page for backend eligibility checks', async () => {
const account = (id: number) => ({
id,
@@ -302,4 +375,75 @@ describe('admin AccountsView bulk edit scope', () => {
expect(probeUpstreamBillingBatch).toHaveBeenCalledWith([7, 11])
})
it('reloads the server-sorted list after a batch probe changes a snapshot', async () => {
localStorage.setItem('account-table-sort', JSON.stringify({ key: 'upstream_billing_rate', order: 'asc' }))
const account = (id: number) => ({
id,
name: `account-${id}`,
platform: 'openai',
type: 'apikey',
status: 'active',
schedulable: true,
created_at: '2026-07-13T00:00:00Z',
updated_at: '2026-07-13T00:00:00Z'
})
listAccounts
.mockResolvedValueOnce({ items: [account(7)], total: 1, page: 1, page_size: 20, pages: 1 })
.mockResolvedValueOnce({ items: [account(7)], total: 1, page: 1, page_size: 20, pages: 1 })
probeUpstreamBillingBatch.mockResolvedValue([
{
account_id: 7,
snapshot: {
status: 'ok',
data: { effective_rate_multiplier: 0.5 },
last_attempt_at: '2026-07-13T00:00:00Z',
next_probe_at: '2026-07-13T00:30:00Z'
}
}
])
const wrapper = mount(AccountsView, {
global: {
stubs: {
AppLayout: { template: '<div><slot /></div>' },
TablePageLayout: { template: '<div><slot name="table" /></div>' },
DataTable: DataTableStub,
AccountBulkActionsBar: AccountBulkActionsBarStub,
AccountTableActions: true,
AccountTableFilters: true,
AccountActionMenu: true,
Pagination: true,
ConfirmDialog: true,
ImportDataModal: true,
ReAuthAccountModal: true,
AccountTestModal: true,
AccountStatsModal: true,
ScheduledTestsPanel: true,
SyncFromCrsModal: true,
TempUnschedStatusModal: true,
ErrorPassthroughRulesModal: true,
TLSFingerprintProfilesModal: true,
CreateAccountModal: true,
EditAccountModal: true,
BulkEditAccountModal: BulkEditAccountModalStub,
PlatformTypeBadge: true,
AccountCapacityCell: true,
AccountStatusIndicator: true,
AccountTodayStatsCell: true,
AccountGroupsCell: true,
AccountUsageCell: true,
Icon: true
}
}
})
await flushPromises()
await wrapper.get('[data-test="select-row"] input').trigger('change')
await wrapper.get('[data-test="probe-upstream-billing"]').trigger('click')
await flushPromises()
expect(probeUpstreamBillingBatch).toHaveBeenCalledWith([7])
expect(listAccounts).toHaveBeenCalledTimes(2)
})
})
@@ -176,5 +176,7 @@ describe('admin AccountsView usage windows hint', () => {
expect(wrapper.findAll('[data-test="usage-windows-hint"]').some(node =>
node.text() === 'admin.accounts.upstreamBilling.trustWarning'
)).toBe(true)
const columns = wrapper.getComponent(DataTableStub).props('columns') as Array<{ key: string; sortable: boolean }>
expect(columns.find(column => column.key === 'upstream_billing_rate')?.sortable).toBe(true)
})
})
@@ -16,6 +16,8 @@ const {
getStreamTimeoutSettings,
getRectifierSettings,
getBetaPolicySettings,
getUpstreamBillingProbeSettings,
updateUpstreamBillingProbeSettings,
getGroups,
listProxies,
getProviders,
@@ -38,6 +40,11 @@ const {
getStreamTimeoutSettings: vi.fn(),
getRectifierSettings: vi.fn(),
getBetaPolicySettings: vi.fn(),
getUpstreamBillingProbeSettings: vi.fn().mockResolvedValue({
enabled: true,
interval_minutes: 30,
}),
updateUpstreamBillingProbeSettings: vi.fn().mockImplementation(async (payload) => payload),
getGroups: vi.fn(),
listProxies: vi.fn(),
getProviders: vi.fn(),
@@ -67,6 +74,10 @@ vi.mock("@/api", () => ({
getRectifierSettings,
getBetaPolicySettings,
},
accounts: {
getUpstreamBillingProbeSettings,
updateUpstreamBillingProbeSettings,
},
groups: {
getAll: getGroups,
},
@@ -184,6 +195,14 @@ vi.mock("vue-i18n", async () => {
"admin.settings.openaiExperimentalScheduler.upstreamCostWeight": "计费倍率",
"admin.settings.openaiExperimentalScheduler.previousResponseWeight": "previous_response 粘性",
"admin.settings.openaiExperimentalScheduler.sessionStickyWeight": "session_hash 粘性",
"admin.settings.upstreamBillingProbe.title": "上游倍率自动探测",
"admin.settings.upstreamBillingProbe.description": "定期获取 OpenAI API Key 所连接上游 Sub2API 站点声明的计费倍率。",
"admin.settings.upstreamBillingProbe.enabled": "启用全局自动探测",
"admin.settings.upstreamBillingProbe.enabledHint": "开启后,仅对账号自身已启用自动检测的账号执行定时探测。",
"admin.settings.upstreamBillingProbe.intervalMinutes": "探测周期(分钟)",
"admin.settings.upstreamBillingProbe.intervalHint": "范围 5–1440 分钟。",
"admin.settings.upstreamBillingProbe.saved": "上游倍率自动探测设置已保存",
"admin.settings.upstreamBillingProbe.saveFailed": "保存上游倍率自动探测设置失败",
"admin.settings.site.uploadImage": "上传图片",
"admin.settings.site.remove": "移除",
"admin.settings.platformQuota.platform": "平台",
@@ -517,6 +536,16 @@ async function openSecurityTab(wrapper: ReturnType<typeof mountView>) {
await flushPromises();
}
async function openGatewayTab(wrapper: ReturnType<typeof mountView>) {
const gatewayTabButton = wrapper
.findAll("button")
.find((node) => node.text().includes("admin.settings.tabs.gateway"));
expect(gatewayTabButton).toBeDefined();
await gatewayTabButton?.trigger("click");
await flushPromises();
}
async function openUsersTab(wrapper: ReturnType<typeof mountView>) {
const usersTabButton = wrapper
.findAll("button")
@@ -540,6 +569,8 @@ describe("admin SettingsView payment visible method controls", () => {
getStreamTimeoutSettings.mockReset();
getRectifierSettings.mockReset();
getBetaPolicySettings.mockReset();
getUpstreamBillingProbeSettings.mockReset();
updateUpstreamBillingProbeSettings.mockReset();
getGroups.mockReset();
listProxies.mockReset();
getProviders.mockReset();
@@ -595,6 +626,11 @@ describe("admin SettingsView payment visible method controls", () => {
getBetaPolicySettings.mockResolvedValue({
rules: [],
});
getUpstreamBillingProbeSettings.mockResolvedValue({
enabled: true,
interval_minutes: 30,
});
updateUpstreamBillingProbeSettings.mockImplementation(async (payload) => payload);
getGroups.mockResolvedValue([]);
listProxies.mockResolvedValue({
items: [],
@@ -848,6 +884,38 @@ describe("admin SettingsView payment visible method controls", () => {
expect(wrapper.text()).not.toContain("OpenAI 高级调度器");
});
it("loads and saves upstream billing probe settings from the gateway tab", async () => {
getUpstreamBillingProbeSettings.mockResolvedValueOnce({
enabled: false,
interval_minutes: 45,
});
const wrapper = mountView();
await flushPromises();
await openGatewayTab(wrapper);
const card = wrapper.get('[data-testid="upstream-billing-probe-settings"]');
expect(card.isVisible()).toBe(true);
expect(card.text()).toContain("上游倍率自动探测");
expect(
(card.get('[data-testid="upstream-billing-probe-enabled"]').element as HTMLInputElement)
.checked,
).toBe(false);
expect(card.find('[data-testid="upstream-billing-probe-interval"]').exists()).toBe(false);
await card.get('[data-testid="upstream-billing-probe-enabled"]').setValue(true);
await card.get('[data-testid="upstream-billing-probe-interval"]').setValue(60);
await card.get('[data-testid="upstream-billing-probe-save"]').trigger("click");
await flushPromises();
expect(updateUpstreamBillingProbeSettings).toHaveBeenCalledWith({
enabled: true,
interval_minutes: 60,
});
expect(showSuccess).toHaveBeenCalledWith("上游倍率自动探测设置已保存");
});
it("places and explains rate controls for both scheduling modes", async () => {
const wrapper = mountView();