mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-30 16:23:31 +00:00
feat(grok): 吸收调度阈值、配额解析、批量用量与 CLI 身份
从 personal-dev 取优并接线到 feat/grok-complete-integration: - 调度阈值:grok_sched_* 写入、ApplyAccountSchedulingThreshold、 account_scheduling_thresholds 设置与 Settings UI、网关过滤 - 配额头:x-rate-limit 别名、相对秒 reset、tier/entitlement 扩展 - 批量 usage:GetUsageBatch + POST /usage/batch + AccountsView 合并加载 - CLI 身份:xai.cli_identity 统一 pin/UA;service 层 applyGrokCLIHeaders 与 transport 最终改写对齐;media eligibility 模块落地 - UsageCell:月度 cents→USD 与 30d 进度条展示 保留既有 failure taxonomy / sticky / free recovery 等 HEAD cools。
This commit is contained in:
@@ -317,6 +317,19 @@ export async function getUsage(id: number, source?: 'passive' | 'active', force?
|
||||
return data
|
||||
}
|
||||
|
||||
export interface BatchAccountUsageResponse {
|
||||
usage: Record<string, AccountUsageInfo>
|
||||
errors: Record<string, string>
|
||||
}
|
||||
|
||||
export async function getBatchUsage(accountIds: number[], force?: boolean): Promise<BatchAccountUsageResponse> {
|
||||
const { data } = await apiClient.post<BatchAccountUsageResponse>('/admin/accounts/usage/batch', {
|
||||
account_ids: accountIds,
|
||||
force: force === true
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear account rate limit status
|
||||
* @param id - Account ID
|
||||
@@ -986,6 +999,7 @@ export const accountsAPI = {
|
||||
getStats,
|
||||
clearError,
|
||||
getUsage,
|
||||
getBatchUsage,
|
||||
getTodayStats,
|
||||
getBatchTodayStats,
|
||||
clearRateLimit,
|
||||
|
||||
@@ -32,6 +32,38 @@ export type DefaultPlatformQuotasMap = Partial<Record<PlatformType, PlatformQuot
|
||||
|
||||
const PLATFORMS: PlatformType[] = ["anthropic", "openai", "gemini", "antigravity", "grok"]
|
||||
|
||||
export type SchedulingThresholdPlatformType =
|
||||
| "openai"
|
||||
| "anthropic"
|
||||
| "grok"
|
||||
|
||||
export type AccountSchedulingThresholdsMap = Record<SchedulingThresholdPlatformType, number>
|
||||
|
||||
export const SCHEDULING_THRESHOLD_PLATFORMS: SchedulingThresholdPlatformType[] = [
|
||||
"openai",
|
||||
"anthropic",
|
||||
"grok",
|
||||
]
|
||||
|
||||
export function normalizeAccountSchedulingThresholdsMap(
|
||||
input?: Partial<Record<SchedulingThresholdPlatformType, number>> | null,
|
||||
): AccountSchedulingThresholdsMap {
|
||||
const result = {} as AccountSchedulingThresholdsMap
|
||||
for (const platform of SCHEDULING_THRESHOLD_PLATFORMS) {
|
||||
const value = input?.[platform]
|
||||
result[platform] = typeof value === "number" && Number.isFinite(value)
|
||||
? Math.min(100, Math.max(1, Math.trunc(value)))
|
||||
: 100
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function sanitizeAccountSchedulingThresholdsMap(
|
||||
input?: Partial<Record<SchedulingThresholdPlatformType, number>> | null,
|
||||
): AccountSchedulingThresholdsMap {
|
||||
return normalizeAccountSchedulingThresholdsMap(input)
|
||||
}
|
||||
|
||||
/** 归一化为全 4 平台 × 3 窗口(缺失填 null),供模板非空绑定 */
|
||||
export function normalizePlatformQuotasMap(input?: DefaultPlatformQuotasMap | null): DefaultPlatformQuotasMap {
|
||||
const result: DefaultPlatformQuotasMap = {}
|
||||
@@ -559,6 +591,9 @@ export interface SystemSettings {
|
||||
grok_default_text_model: string;
|
||||
grok_cross_client_model_map_enabled: boolean;
|
||||
|
||||
// Per-platform account auto-pause thresholds (100 = disabled)
|
||||
account_scheduling_thresholds: AccountSchedulingThresholdsMap;
|
||||
|
||||
// Identity patch configuration (Claude -> Gemini)
|
||||
enable_identity_patch: boolean;
|
||||
identity_patch_prompt: string;
|
||||
@@ -876,6 +911,7 @@ export interface UpdateSettingsRequest {
|
||||
fallback_model_antigravity?: string;
|
||||
grok_default_text_model?: string;
|
||||
grok_cross_client_model_map_enabled?: boolean;
|
||||
account_scheduling_thresholds?: AccountSchedulingThresholdsMap;
|
||||
enable_identity_patch?: boolean;
|
||||
identity_patch_prompt?: string;
|
||||
ops_monitoring_enabled?: boolean;
|
||||
|
||||
@@ -393,6 +393,29 @@
|
||||
:show-now-when-idle="true"
|
||||
color="indigo"
|
||||
/>
|
||||
<UsageProgressBar
|
||||
v-if="grokMonthlyBillingBar"
|
||||
label="30d"
|
||||
:utilization="grokMonthlyBillingBar.utilization"
|
||||
:resets-at="grokMonthlyBillingBar.resetsAt"
|
||||
:show-now-when-idle="true"
|
||||
color="indigo"
|
||||
/>
|
||||
<div
|
||||
v-if="grokBillingMoneySummary"
|
||||
class="flex flex-wrap items-center gap-1 text-[10px] text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
<span :title="t('admin.accounts.usageWindow.grokMonthlyLimit')">
|
||||
{{ t('admin.accounts.usageWindow.grokUsed') }}
|
||||
{{ grokBillingMoneySummary.used }}/{{ grokBillingMoneySummary.limit }}
|
||||
</span>
|
||||
<span
|
||||
v-if="grokBillingMoneySummary.usedPercent != null"
|
||||
class="rounded bg-gray-100 px-1 py-0.5 dark:bg-gray-800"
|
||||
>
|
||||
{{ grokBillingMoneySummary.usedPercent }}%
|
||||
</span>
|
||||
</div>
|
||||
<UsageProgressBar
|
||||
v-if="!grokWeeklyBillingBar && !grokIsFree && grokRequestQuotaBar"
|
||||
:label="t('admin.accounts.usageWindow.grokRequests')"
|
||||
@@ -652,16 +675,25 @@ const props = withDefaults(
|
||||
todayStats?: WindowStats | null
|
||||
todayStatsLoading?: boolean
|
||||
manualRefreshToken?: number
|
||||
batchedUsage?: AccountUsageInfo | null
|
||||
batchedUsageError?: string | null
|
||||
batchedUsageLoading?: boolean
|
||||
requestBatchedUsage?: ((account: Account, options?: { force?: boolean }) => void) | null
|
||||
}>(),
|
||||
{
|
||||
todayStats: null,
|
||||
todayStatsLoading: false,
|
||||
manualRefreshToken: 0
|
||||
manualRefreshToken: 0,
|
||||
batchedUsage: null,
|
||||
batchedUsageError: null,
|
||||
batchedUsageLoading: false,
|
||||
requestBatchedUsage: null
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
'account-updated': [account: Account]
|
||||
'usage-loaded': [usage: AccountUsageInfo]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -674,6 +706,9 @@ const loading = ref(false)
|
||||
const activeQueryLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const usageInfo = ref<AccountUsageInfo | null>(null)
|
||||
watch(usageInfo, (usage) => {
|
||||
if (usage) emit('usage-loaded', usage)
|
||||
})
|
||||
const suppressOpenAIUsageRefreshUntil = ref(0)
|
||||
const rootRef = ref<HTMLElement | null>(null)
|
||||
const isDesktopViewport = ref(
|
||||
@@ -713,6 +748,8 @@ const shouldFetchUsage = computed(() => {
|
||||
return false
|
||||
})
|
||||
|
||||
const isBatchManaged = computed(() => typeof props.requestBatchedUsage === 'function')
|
||||
|
||||
const showGeminiTodayStats = computed(() => {
|
||||
return props.account.platform === 'gemini' && props.account.type === 'service_account'
|
||||
})
|
||||
@@ -1094,6 +1131,58 @@ const grokWeeklyBillingBar = computed((): GrokQuotaBarInfo | null => {
|
||||
resetsAt: billing.period_end || null
|
||||
}
|
||||
})
|
||||
// Monthly used/limit % from billing probe (used_percent or derived from cents).
|
||||
const grokMonthlyBillingBar = computed((): GrokQuotaBarInfo | null => {
|
||||
const billing = grokBilling.value
|
||||
if (!billing) return null
|
||||
let utilization: number | null = null
|
||||
if (billing.used_percent != null && Number.isFinite(billing.used_percent)) {
|
||||
utilization = billing.used_percent
|
||||
} else if (
|
||||
billing.monthly_limit_cents != null &&
|
||||
billing.monthly_limit_cents > 0 &&
|
||||
billing.used_cents != null
|
||||
) {
|
||||
utilization = (billing.used_cents / billing.monthly_limit_cents) * 100
|
||||
}
|
||||
if (utilization == null) return null
|
||||
// Avoid duplicating the weekly bar when period_type is weekly-only without monthly.
|
||||
if (billing.period_type?.toLowerCase() === 'weekly' && billing.monthly_limit_cents == null) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
utilization: Math.min(100, Math.max(0, utilization)),
|
||||
resetsAt: billing.billing_period_end || billing.period_end || null
|
||||
}
|
||||
})
|
||||
const formatGrokMoneyFromCents = (cents?: number | null) => {
|
||||
if (cents == null || Number.isNaN(cents)) return '0'
|
||||
const dollars = cents / 100
|
||||
if (dollars >= 1000) return formatCompactNumber(dollars)
|
||||
if (dollars >= 100) return dollars.toFixed(0)
|
||||
if (dollars >= 10) return dollars.toFixed(1)
|
||||
return dollars.toFixed(2)
|
||||
}
|
||||
// Absolute monthly used/limit derived from cents fields already on GrokBillingSummary.
|
||||
// (personal-dev has separate prepaid/on_demand fields; HEAD only has cents.)
|
||||
const grokBillingMoneySummary = computed(() => {
|
||||
const billing = grokBilling.value
|
||||
if (!billing) return null
|
||||
const limit = billing.monthly_limit_cents
|
||||
const used = billing.used_cents
|
||||
if ((limit == null || limit <= 0) && (used == null || used <= 0)) return null
|
||||
let usedPercent: number | null = null
|
||||
if (billing.used_percent != null && Number.isFinite(billing.used_percent)) {
|
||||
usedPercent = Math.round(Math.min(100, Math.max(0, billing.used_percent)))
|
||||
} else if (limit != null && limit > 0 && used != null) {
|
||||
usedPercent = Math.round(Math.min(100, Math.max(0, (used / limit) * 100)))
|
||||
}
|
||||
return {
|
||||
used: formatGrokMoneyFromCents(used ?? 0),
|
||||
limit: formatGrokMoneyFromCents(limit ?? 0),
|
||||
usedPercent
|
||||
}
|
||||
})
|
||||
const grokPlanLabelIsFree = (value: string) => value.includes('free') || value.includes('basic')
|
||||
const grokPlanLabelIsPaid = (value: string) => {
|
||||
return value !== '' && !grokPlanLabelIsFree(value) && !value.includes('unknown')
|
||||
@@ -1136,7 +1225,16 @@ const grokFreeTokenBar = computed(() => {
|
||||
})
|
||||
const grokQuotaUnknown = computed(() => {
|
||||
if (props.account.platform !== 'grok') return false
|
||||
if (grokBilling.value || grokFreeTokenBar.value || grokRequestQuotaBar.value || grokTokenQuotaBar.value) return false
|
||||
if (
|
||||
grokBilling.value ||
|
||||
grokBillingMoneySummary.value ||
|
||||
grokMonthlyBillingBar.value ||
|
||||
grokFreeTokenBar.value ||
|
||||
grokRequestQuotaBar.value ||
|
||||
grokTokenQuotaBar.value
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return usageInfo.value?.grok_quota_snapshot_state !== 'observed'
|
||||
})
|
||||
const grokQuotaUnknownLabel = computed(() => {
|
||||
@@ -1273,8 +1371,24 @@ const isAnthropicOAuthOrSetupToken = computed(() => {
|
||||
return props.account.platform === 'anthropic' && (props.account.type === 'oauth' || props.account.type === 'setup-token')
|
||||
})
|
||||
|
||||
const requestParentBatchUsage = (options?: { force?: boolean }) => {
|
||||
if (!isBatchManaged.value || !shouldFetchUsage.value) return
|
||||
props.requestBatchedUsage?.(props.account, options)
|
||||
}
|
||||
|
||||
const syncManagedUsageState = () => {
|
||||
if (!isBatchManaged.value) return
|
||||
usageInfo.value = props.batchedUsage ?? null
|
||||
error.value = props.batchedUsageError ?? null
|
||||
loading.value = props.batchedUsageLoading === true
|
||||
}
|
||||
|
||||
const loadUsage = async (options?: { source?: 'passive' | 'active'; bypassCache?: boolean }) => {
|
||||
if (!shouldFetchUsage.value) return
|
||||
if (isBatchManaged.value) {
|
||||
requestParentBatchUsage({ force: options?.bypassCache === true })
|
||||
return
|
||||
}
|
||||
|
||||
// Check cache
|
||||
if (!options?.bypassCache) {
|
||||
@@ -1529,11 +1643,49 @@ onMounted(() => {
|
||||
}
|
||||
}
|
||||
|
||||
if (isBatchManaged.value) {
|
||||
syncManagedUsageState()
|
||||
requestParentBatchUsage()
|
||||
return
|
||||
}
|
||||
|
||||
if (!shouldAutoLoadUsageOnMount.value) return
|
||||
const source = isAnthropicOAuthOrSetupToken.value ? 'passive' : undefined
|
||||
requestAutoLoad(source)
|
||||
})
|
||||
|
||||
watch(
|
||||
() => [props.batchedUsage, props.batchedUsageError, props.batchedUsageLoading, isBatchManaged.value] as const,
|
||||
() => {
|
||||
syncManagedUsageState()
|
||||
},
|
||||
{ immediate: true, deep: true }
|
||||
)
|
||||
|
||||
watch(isBatchManaged, (managed, wasManaged) => {
|
||||
if (managed && !wasManaged) {
|
||||
syncManagedUsageState()
|
||||
requestParentBatchUsage()
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => [props.account.id, props.account.platform, props.account.type, isBatchManaged.value] as const,
|
||||
([accountID, platform, accountType, managed], [previousAccountID, previousPlatform, previousAccountType]) => {
|
||||
if (
|
||||
accountID === previousAccountID &&
|
||||
platform === previousPlatform &&
|
||||
accountType === previousAccountType
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (!managed || !shouldFetchUsage.value) return
|
||||
syncManagedUsageState()
|
||||
requestParentBatchUsage()
|
||||
},
|
||||
{ flush: 'post' }
|
||||
)
|
||||
|
||||
watch(openAIUsageRefreshKey, (nextKey, prevKey) => {
|
||||
if (!prevKey || nextKey === prevKey) return
|
||||
if (props.account.platform !== 'openai' || props.account.type !== 'oauth') return
|
||||
@@ -1542,6 +1694,11 @@ watch(openAIUsageRefreshKey, (nextKey, prevKey) => {
|
||||
return
|
||||
}
|
||||
|
||||
if (isBatchManaged.value) {
|
||||
requestParentBatchUsage({ force: true })
|
||||
return
|
||||
}
|
||||
|
||||
_usageCache.delete(props.account.id)
|
||||
requestAutoLoad()
|
||||
})
|
||||
@@ -1552,6 +1709,11 @@ watch(
|
||||
if (nextToken === prevToken) return
|
||||
if (!shouldFetchUsage.value) return
|
||||
|
||||
if (isBatchManaged.value) {
|
||||
requestParentBatchUsage({ force: true })
|
||||
return
|
||||
}
|
||||
|
||||
const source = isAnthropicOAuthOrSetupToken.value ? 'passive' : undefined
|
||||
_usageCache.delete(props.account.id)
|
||||
loadUsage({ source, bypassCache: true }).catch((e) => {
|
||||
|
||||
@@ -1357,6 +1357,8 @@ export default {
|
||||
grokTokens: 'Tok',
|
||||
grokFreeQuota24hHint: 'Estimated from local token usage over the rolling 24-hour window ({limit} limit)',
|
||||
grokWeeklyUsage: 'Weekly {percent}%',
|
||||
grokUsed: 'Used $',
|
||||
grokMonthlyLimit: 'Monthly used / limit (USD from billing cents)',
|
||||
grokUnknown: 'Grok quota is unknown until the first upstream response includes xAI rate-limit headers.',
|
||||
grokRetryAfter: 'Retry after {time}',
|
||||
grokProbe: 'Probe',
|
||||
|
||||
@@ -409,7 +409,12 @@ export default {
|
||||
title: 'Gateway Scheduling Settings',
|
||||
description: 'Control API Key scheduling behavior',
|
||||
allowUngroupedKey: 'Allow Ungrouped Key Scheduling',
|
||||
allowUngroupedKeyHint: 'When disabled, API Keys not assigned to any group cannot make requests (403 Forbidden). Keep disabled to ensure all Keys belong to a specific group.'
|
||||
allowUngroupedKeyHint: 'When disabled, API Keys not assigned to any group cannot make requests (403 Forbidden). Keep disabled to ensure all Keys belong to a specific group.',
|
||||
accountSchedulingThresholdsTitle: 'Platform Account Auto-Pause Thresholds',
|
||||
accountSchedulingThresholdsDescription: 'Set per-platform thresholds that automatically pause scheduling for accounts on that platform when their current native usage window reaches the configured percentage.',
|
||||
accountSchedulingThresholdsGlobalHint: 'This is a system-wide global setting and applies to all accounts on that platform.',
|
||||
accountSchedulingThresholdsDisabledHint: 'A value of 100 disables the auto-pause threshold for that platform.',
|
||||
accountSchedulingThresholdsRangeHint: 'Range 1-100, entered as a percentage.'
|
||||
},
|
||||
upstreamBillingProbe: {
|
||||
title: 'Upstream Rate Auto Detection',
|
||||
|
||||
@@ -401,6 +401,8 @@ export default {
|
||||
grokTokens: 'Token',
|
||||
grokFreeQuota24hHint: '按 sub2api 近 24 小时本地 Token 用量估算(上限 {limit})',
|
||||
grokWeeklyUsage: '周额度已用 {percent}%',
|
||||
grokUsed: '已用 $',
|
||||
grokMonthlyLimit: '月度已用/上限(由 billing cents 换算 USD)',
|
||||
grokUnknown: 'Grok 配额需等待首次上游响应返回 xAI rate-limit 头后显示。',
|
||||
grokRetryAfter: '{time} 后重试',
|
||||
grokProbe: '探测',
|
||||
|
||||
@@ -402,7 +402,12 @@ export default {
|
||||
title: '网关调度设置',
|
||||
description: '控制 API Key 的调度行为',
|
||||
allowUngroupedKey: '允许未分组 Key 调度',
|
||||
allowUngroupedKeyHint: '关闭后,未分配到任何分组的 API Key 将无法发起请求(返回 403)。建议保持关闭以确保所有 Key 都归属明确的分组。'
|
||||
allowUngroupedKeyHint: '关闭后,未分配到任何分组的 API Key 将无法发起请求(返回 403)。建议保持关闭以确保所有 Key 都归属明确的分组。',
|
||||
accountSchedulingThresholdsTitle: '平台账号自动停调阈值',
|
||||
accountSchedulingThresholdsDescription: '按平台设置账号自动停调阈值,当账号当前原生使用窗口达到配置百分比时,自动停调该平台账号。',
|
||||
accountSchedulingThresholdsGlobalHint: '这是系统级全局设置,对该平台全部账号生效。',
|
||||
accountSchedulingThresholdsDisabledHint: '100 表示禁用该平台的自动停调阈值。',
|
||||
accountSchedulingThresholdsRangeHint: '范围 1-100,按百分比填写。'
|
||||
},
|
||||
upstreamBillingProbe: {
|
||||
title: '上游倍率自动探测',
|
||||
|
||||
@@ -318,7 +318,12 @@
|
||||
:today-stats="todayStatsByAccountId[String(row.id)] ?? null"
|
||||
:today-stats-loading="todayStatsLoading"
|
||||
:manual-refresh-token="usageManualRefreshToken"
|
||||
:batched-usage="usageBatchByAccountId[String(row.id)] ?? null"
|
||||
:batched-usage-error="usageBatchErrorByAccountId[String(row.id)] ?? null"
|
||||
:batched-usage-loading="usageBatchLoadingByAccountId[String(row.id)] === true"
|
||||
:request-batched-usage="isDesktopViewport ? queueBatchedUsage : null"
|
||||
@account-updated="handleAccountUpdated"
|
||||
@usage-loaded="handleAccountUsageLoaded(row.id, $event)"
|
||||
/>
|
||||
</template>
|
||||
<template #cell-proxy="{ row }">
|
||||
@@ -527,7 +532,7 @@ import { extractApiErrorMessage } from '@/utils/apiError'
|
||||
import { sanitizeUrl } from '@/utils/url'
|
||||
import { getFloatingPanelPosition } from '@/utils/floatingPanel'
|
||||
import { formatMultiplier } from '@/utils/formatters'
|
||||
import type { Account, AccountPlatform, AccountSchedulerGroupScore, AccountType, Proxy as AccountProxy, AdminGroup, WindowStats, ClaudeModel, UpstreamBillingProbeSnapshot } from '@/types'
|
||||
import type { Account, AccountPlatform, AccountSchedulerGroupScore, AccountType, AccountUsageInfo, Proxy as AccountProxy, AdminGroup, WindowStats, ClaudeModel, UpstreamBillingProbeSnapshot } from '@/types'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
@@ -692,6 +697,24 @@ const todayStatsReqSeq = ref(0)
|
||||
const pendingTodayStatsRefresh = ref(false)
|
||||
const usageManualRefreshToken = ref(0)
|
||||
|
||||
const desktopViewportQuery = '(min-width: 768px)'
|
||||
const isDesktopViewport = ref(
|
||||
typeof window === 'undefined' ? true : window.matchMedia(desktopViewportQuery).matches
|
||||
)
|
||||
let desktopViewportMediaQuery: MediaQueryList | null = null
|
||||
let desktopViewportListener: ((event: MediaQueryListEvent) => void) | null = null
|
||||
|
||||
const usageBatchByAccountId = ref<Record<string, AccountUsageInfo | null>>({})
|
||||
const usageBatchErrorByAccountId = ref<Record<string, string | null>>({})
|
||||
const usageBatchLoadingByAccountId = ref<Record<string, boolean>>({})
|
||||
const usageBatchRequestTokenByAccountId = ref<Record<string, number>>({})
|
||||
const usageBatchCache = new Map<number, { data: AccountUsageInfo; ts: number }>()
|
||||
const USAGE_BATCH_CACHE_TTL = 5 * 60 * 1000
|
||||
const pendingUsageBatchIds = new Set<number>()
|
||||
let usageBatchFlushTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let queuedUsageBatchForce = false
|
||||
let usageBatchRequestToken = 0
|
||||
|
||||
const buildDefaultTodayStats = (): WindowStats => ({
|
||||
requests: 0,
|
||||
tokens: 0,
|
||||
@@ -700,6 +723,138 @@ const buildDefaultTodayStats = (): WindowStats => ({
|
||||
user_cost: 0
|
||||
})
|
||||
|
||||
const accountSupportsBatchUsage = (account: Account) => {
|
||||
if (account.platform === 'anthropic') {
|
||||
return account.type === 'oauth' || account.type === 'setup-token'
|
||||
}
|
||||
if (account.platform === 'gemini') return true
|
||||
if (account.platform === 'antigravity') return account.type === 'oauth'
|
||||
if (account.platform === 'openai') return account.type === 'oauth'
|
||||
if (account.platform === 'grok') return account.type === 'oauth'
|
||||
return false
|
||||
}
|
||||
|
||||
const setUsageBatchLoading = (accountID: number, loadingState: boolean) => {
|
||||
usageBatchLoadingByAccountId.value = {
|
||||
...usageBatchLoadingByAccountId.value,
|
||||
[String(accountID)]: loadingState
|
||||
}
|
||||
}
|
||||
|
||||
const setUsageBatchState = (accountID: number, usage: AccountUsageInfo | null, error: string | null) => {
|
||||
const key = String(accountID)
|
||||
usageBatchByAccountId.value = {
|
||||
...usageBatchByAccountId.value,
|
||||
[key]: usage
|
||||
}
|
||||
usageBatchErrorByAccountId.value = {
|
||||
...usageBatchErrorByAccountId.value,
|
||||
[key]: error
|
||||
}
|
||||
}
|
||||
|
||||
const handleAccountUsageLoaded = (accountID: number, usage: AccountUsageInfo) => {
|
||||
if (usageBatchByAccountId.value[String(accountID)] === usage) return
|
||||
setUsageBatchState(accountID, usage, null)
|
||||
}
|
||||
|
||||
const flushQueuedUsageBatch = async () => {
|
||||
usageBatchFlushTimer = null
|
||||
const accountIDs = Array.from(pendingUsageBatchIds)
|
||||
const force = queuedUsageBatchForce
|
||||
pendingUsageBatchIds.clear()
|
||||
queuedUsageBatchForce = false
|
||||
|
||||
if (accountIDs.length === 0) return
|
||||
|
||||
const requestTokensByAccount = accountIDs.reduce<Record<string, number>>((acc, accountID) => {
|
||||
acc[String(accountID)] = usageBatchRequestTokenByAccountId.value[String(accountID)] ?? 0
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
try {
|
||||
const result = await adminAPI.accounts.getBatchUsage(accountIDs, force)
|
||||
|
||||
const usageMap = result.usage ?? {}
|
||||
const errorMap = result.errors ?? {}
|
||||
const now = Date.now()
|
||||
const nextUsage = { ...usageBatchByAccountId.value }
|
||||
const nextErrors = { ...usageBatchErrorByAccountId.value }
|
||||
const nextLoading = { ...usageBatchLoadingByAccountId.value }
|
||||
|
||||
for (const accountID of accountIDs) {
|
||||
const key = String(accountID)
|
||||
if ((usageBatchRequestTokenByAccountId.value[key] ?? 0) !== requestTokensByAccount[key]) {
|
||||
continue
|
||||
}
|
||||
const usage = usageMap[key] ?? null
|
||||
nextUsage[key] = usage
|
||||
nextErrors[key] = errorMap[key] ?? null
|
||||
nextLoading[key] = false
|
||||
if (usage) {
|
||||
usageBatchCache.set(accountID, { data: usage, ts: now })
|
||||
} else {
|
||||
usageBatchCache.delete(accountID)
|
||||
}
|
||||
}
|
||||
|
||||
usageBatchByAccountId.value = nextUsage
|
||||
usageBatchErrorByAccountId.value = nextErrors
|
||||
usageBatchLoadingByAccountId.value = nextLoading
|
||||
} catch (error) {
|
||||
const nextErrors = { ...usageBatchErrorByAccountId.value }
|
||||
const nextLoading = { ...usageBatchLoadingByAccountId.value }
|
||||
for (const accountID of accountIDs) {
|
||||
const key = String(accountID)
|
||||
if ((usageBatchRequestTokenByAccountId.value[key] ?? 0) !== requestTokensByAccount[key]) {
|
||||
continue
|
||||
}
|
||||
nextErrors[key] = 'Failed'
|
||||
nextLoading[key] = false
|
||||
}
|
||||
usageBatchErrorByAccountId.value = nextErrors
|
||||
usageBatchLoadingByAccountId.value = nextLoading
|
||||
console.error('Failed to load account usage batch:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const queueBatchedUsage = (account: Account, options?: { force?: boolean }) => {
|
||||
if (!isDesktopViewport.value) return
|
||||
if (!accountSupportsBatchUsage(account)) return
|
||||
|
||||
const force = options?.force === true
|
||||
const cacheKey = account.id
|
||||
const key = String(cacheKey)
|
||||
|
||||
if (force) {
|
||||
usageBatchCache.delete(cacheKey)
|
||||
} else {
|
||||
const cached = usageBatchCache.get(cacheKey)
|
||||
if (cached && Date.now() - cached.ts < USAGE_BATCH_CACHE_TTL) {
|
||||
setUsageBatchState(cacheKey, cached.data, null)
|
||||
setUsageBatchLoading(cacheKey, false)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
usageBatchErrorByAccountId.value = {
|
||||
...usageBatchErrorByAccountId.value,
|
||||
[key]: null
|
||||
}
|
||||
usageBatchRequestTokenByAccountId.value = {
|
||||
...usageBatchRequestTokenByAccountId.value,
|
||||
[key]: ++usageBatchRequestToken
|
||||
}
|
||||
setUsageBatchLoading(cacheKey, true)
|
||||
pendingUsageBatchIds.add(cacheKey)
|
||||
queuedUsageBatchForce = queuedUsageBatchForce || force
|
||||
|
||||
if (usageBatchFlushTimer !== null) return
|
||||
usageBatchFlushTimer = setTimeout(() => {
|
||||
void flushQueuedUsageBatch()
|
||||
}, 0)
|
||||
}
|
||||
|
||||
const refreshTodayStatsBatch = async () => {
|
||||
// Why this checks both columns:
|
||||
// - today_stats column shows dedicated today's metrics.
|
||||
@@ -1084,6 +1239,22 @@ watch(loading, (isLoading, wasLoading) => {
|
||||
}
|
||||
})
|
||||
|
||||
watch(accounts, (rows) => {
|
||||
const visibleIDs = new Set(rows.map((row) => String(row.id)))
|
||||
usageBatchByAccountId.value = Object.fromEntries(
|
||||
Object.entries(usageBatchByAccountId.value).filter(([key]) => visibleIDs.has(key))
|
||||
)
|
||||
usageBatchErrorByAccountId.value = Object.fromEntries(
|
||||
Object.entries(usageBatchErrorByAccountId.value).filter(([key]) => visibleIDs.has(key))
|
||||
)
|
||||
usageBatchLoadingByAccountId.value = Object.fromEntries(
|
||||
Object.entries(usageBatchLoadingByAccountId.value).filter(([key]) => visibleIDs.has(key))
|
||||
)
|
||||
usageBatchRequestTokenByAccountId.value = Object.fromEntries(
|
||||
Object.entries(usageBatchRequestTokenByAccountId.value).filter(([key]) => visibleIDs.has(key))
|
||||
)
|
||||
})
|
||||
|
||||
watch(upstreamBillingNow, () => {
|
||||
if (sortState.sort_by !== 'upstream_billing_rate' || loading.value) return
|
||||
if (typeof document !== 'undefined' && document.hidden) return
|
||||
@@ -2183,6 +2354,19 @@ const handleClickOutside = (event: MouseEvent) => {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
desktopViewportMediaQuery = window.matchMedia(desktopViewportQuery)
|
||||
isDesktopViewport.value = desktopViewportMediaQuery.matches
|
||||
desktopViewportListener = (event: MediaQueryListEvent) => {
|
||||
isDesktopViewport.value = event.matches
|
||||
}
|
||||
if (typeof desktopViewportMediaQuery.addEventListener === 'function') {
|
||||
desktopViewportMediaQuery.addEventListener('change', desktopViewportListener)
|
||||
} else {
|
||||
desktopViewportMediaQuery.addListener(desktopViewportListener)
|
||||
}
|
||||
}
|
||||
|
||||
load()
|
||||
loadUpstreamBillingProbeGlobalState()
|
||||
try {
|
||||
@@ -2205,9 +2389,23 @@ onMounted(async () => {
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (usageBatchFlushTimer !== null) {
|
||||
clearTimeout(usageBatchFlushTimer)
|
||||
usageBatchFlushTimer = null
|
||||
}
|
||||
pendingUsageBatchIds.clear()
|
||||
window.removeEventListener('scroll', handleScroll, true)
|
||||
window.removeEventListener('resize', handleViewportResize)
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
if (desktopViewportMediaQuery && desktopViewportListener) {
|
||||
if (typeof desktopViewportMediaQuery.removeEventListener === 'function') {
|
||||
desktopViewportMediaQuery.removeEventListener('change', desktopViewportListener)
|
||||
} else {
|
||||
desktopViewportMediaQuery.removeListener(desktopViewportListener)
|
||||
}
|
||||
}
|
||||
desktopViewportListener = null
|
||||
desktopViewportMediaQuery = null
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -4871,6 +4871,78 @@
|
||||
<Toggle v-model="form.allow_ungrouped_key_scheduling" />
|
||||
</div>
|
||||
|
||||
<div class="border-t border-gray-100 pt-4 dark:border-dark-700">
|
||||
<div class="mb-3">
|
||||
<label class="font-medium text-gray-900 dark:text-white">
|
||||
{{
|
||||
t(
|
||||
"admin.settings.scheduling.accountSchedulingThresholdsTitle",
|
||||
)
|
||||
}}
|
||||
</label>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{
|
||||
t(
|
||||
"admin.settings.scheduling.accountSchedulingThresholdsDescription",
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{
|
||||
t(
|
||||
"admin.settings.scheduling.accountSchedulingThresholdsGlobalHint",
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
<p class="mt-0.5 text-xs text-amber-600 dark:text-amber-400">
|
||||
{{
|
||||
t(
|
||||
"admin.settings.scheduling.accountSchedulingThresholdsDisabledHint",
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
<div
|
||||
v-for="platform in schedulingThresholdPlatforms"
|
||||
:key="platform"
|
||||
class="rounded-lg border border-gray-200 p-4 dark:border-dark-700"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<label
|
||||
class="font-mono text-sm font-medium text-gray-900 dark:text-white"
|
||||
>
|
||||
{{ platform }}
|
||||
</label>
|
||||
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{
|
||||
t(
|
||||
"admin.settings.scheduling.accountSchedulingThresholdsRangeHint",
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
class="rounded bg-gray-100 px-2 py-0.5 text-[11px] font-medium text-gray-600 dark:bg-dark-700 dark:text-gray-300"
|
||||
>
|
||||
%
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
v-model.number="form.account_scheduling_thresholds[platform]"
|
||||
type="number"
|
||||
min="1"
|
||||
max="100"
|
||||
step="1"
|
||||
class="input mt-3"
|
||||
:data-testid="`account-scheduling-threshold-${platform}`"
|
||||
placeholder="100"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!form.openai_advanced_scheduler_enabled"
|
||||
class="flex items-center justify-between border-t border-gray-100 pt-5 dark:border-dark-700"
|
||||
@@ -8491,8 +8563,11 @@ import { adminAPI } from "@/api";
|
||||
import {
|
||||
appendAuthSourceDefaultsToUpdateRequest,
|
||||
buildAuthSourceDefaultsState,
|
||||
normalizeAccountSchedulingThresholdsMap,
|
||||
normalizePlatformQuotasMap,
|
||||
sanitizeAccountSchedulingThresholdsMap,
|
||||
sanitizePlatformQuotasMap,
|
||||
SCHEDULING_THRESHOLD_PLATFORMS,
|
||||
defaultWeChatConnectScopesForMode,
|
||||
deriveWeChatConnectStoredMode,
|
||||
normalizeDefaultSubscriptionSettings,
|
||||
@@ -9236,8 +9311,11 @@ type SettingsForm = Omit<
|
||||
openai_advanced_scheduler_weight_session_sticky: string;
|
||||
// 系统全局平台限额 map;form 内始终归一化为全 4 平台对象(模板非空绑定依赖此不变量)
|
||||
default_platform_quotas: DefaultPlatformQuotasMap;
|
||||
account_scheduling_thresholds: ReturnType<typeof normalizeAccountSchedulingThresholdsMap>;
|
||||
};
|
||||
|
||||
const schedulingThresholdPlatforms = SCHEDULING_THRESHOLD_PLATFORMS;
|
||||
|
||||
const form = reactive<SettingsForm>({
|
||||
registration_enabled: true,
|
||||
email_verify_enabled: false,
|
||||
@@ -9260,6 +9338,7 @@ const form = reactive<SettingsForm>({
|
||||
login_agreement_documents: defaultLoginAgreementDocuments(),
|
||||
default_balance: 0,
|
||||
default_platform_quotas: normalizePlatformQuotasMap() as DefaultPlatformQuotasMap,
|
||||
account_scheduling_thresholds: normalizeAccountSchedulingThresholdsMap(),
|
||||
affiliate_rebate_rate: 20,
|
||||
affiliate_rebate_freeze_hours: 0,
|
||||
affiliate_rebate_duration_days: 0,
|
||||
@@ -10512,6 +10591,9 @@ async function loadSettings() {
|
||||
: defaultLoginAgreementDocuments();
|
||||
Object.assign(authSourceDefaults, buildAuthSourceDefaultsState(settings));
|
||||
form.default_platform_quotas = normalizePlatformQuotasMap(settings.default_platform_quotas);
|
||||
form.account_scheduling_thresholds = normalizeAccountSchedulingThresholdsMap(
|
||||
settings.account_scheduling_thresholds,
|
||||
);
|
||||
form.backend_mode_enabled = settings.backend_mode_enabled;
|
||||
form.default_subscriptions = normalizeDefaultSubscriptionSettings(
|
||||
settings.default_subscriptions,
|
||||
@@ -11186,6 +11268,9 @@ async function saveSettings() {
|
||||
}
|
||||
|
||||
payload.default_platform_quotas = sanitizePlatformQuotasMap(form.default_platform_quotas);
|
||||
payload.account_scheduling_thresholds = sanitizeAccountSchedulingThresholdsMap(
|
||||
form.account_scheduling_thresholds,
|
||||
);
|
||||
appendAuthSourceDefaultsToUpdateRequest(payload, authSourceDefaults);
|
||||
|
||||
const updated = await settingsStepUp.run(() =>
|
||||
@@ -11199,6 +11284,9 @@ async function saveSettings() {
|
||||
}
|
||||
Object.assign(authSourceDefaults, buildAuthSourceDefaultsState(updated));
|
||||
form.default_platform_quotas = normalizePlatformQuotasMap(updated.default_platform_quotas);
|
||||
form.account_scheduling_thresholds = normalizeAccountSchedulingThresholdsMap(
|
||||
updated.account_scheduling_thresholds,
|
||||
);
|
||||
registrationEmailSuffixWhitelistTags.value =
|
||||
normalizeRegistrationEmailSuffixDomains(
|
||||
updated.registration_email_suffix_whitelist,
|
||||
|
||||
Reference in New Issue
Block a user