mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-31 08:25:35 +00:00
fix: 完善上游 Sub2API 计费倍率探测与账号展示
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user