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:
IanShaw027
2026-08-07 14:53:18 +08:00
parent 0316994c52
commit 7c62382d04
55 changed files with 3542 additions and 162 deletions
+14
View File
@@ -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,
+36
View File
@@ -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: '上游倍率自动探测',
+199 -1
View File
@@ -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>
+88
View File
@@ -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,