feat(grok): 账单绝对金额、调度阈值 UI、搜索/Voice 计费与 /v1/web_search

- BillingSummary 输出 prepaid/monthly_used/on_demand 绝对金额,并映射官方 7d/30d 进度条
- 账号编辑页支持 credentials.account_scheduling_threshold 覆盖;Settings 文案细化
- groups.search_price_per_1k + 管理端/缓存全链路;SearchCount/AudioUsage 请求级计费
- Voice TTS/STT/Realtime 成功路径 RecordUsage;独立 /v1/web_search 原生 Grok 搜索
This commit is contained in:
IanShaw027
2026-08-07 15:52:55 +08:00
parent 99ad01f6de
commit 79df1647d4
46 changed files with 1665 additions and 77 deletions
@@ -365,7 +365,7 @@
{{ grokEntitlementLabel }}
</span>
</div>
<div v-if="grokLocalUsage" class="mb-0.5 flex items-center">
<div v-if="showGrokStandaloneLocalStats && grokLocalUsage" class="mb-0.5 flex items-center">
<div class="flex items-center gap-1.5 text-[9px] text-gray-500 dark:text-gray-400">
<span class="rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-800">
{{ formatWindowRequests(grokLocalUsage) }} req
@@ -405,6 +405,13 @@
v-if="grokBillingMoneySummary"
class="flex flex-wrap items-center gap-1 text-[10px] text-gray-500 dark:text-gray-400"
>
<span
v-if="grokBillingMoneySummary.prepaid != null"
class="rounded bg-emerald-50 px-1 py-0.5 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300"
:title="t('admin.accounts.usageWindow.grokPrepaid')"
>
{{ t('admin.accounts.usageWindow.grokPrepaid') }} ${{ grokBillingMoneySummary.prepaid }}
</span>
<span :title="t('admin.accounts.usageWindow.grokMonthlyLimit')">
{{ t('admin.accounts.usageWindow.grokUsed') }}
{{ grokBillingMoneySummary.used }}/{{ grokBillingMoneySummary.limit }}
@@ -415,6 +422,14 @@
>
{{ grokBillingMoneySummary.usedPercent }}%
</span>
<span
v-if="grokBillingMoneySummary.showOverage"
class="rounded bg-amber-50 px-1 py-0.5 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300"
:title="t('admin.accounts.usageWindow.grokOverage')"
>
{{ t('admin.accounts.usageWindow.grokOverageShort') }}
{{ grokBillingMoneySummary.onDemandUsed }}/{{ grokBillingMoneySummary.onDemandCap }}
</span>
</div>
<UsageProgressBar
v-if="!grokWeeklyBillingBar && !grokIsFree && grokRequestQuotaBar"
@@ -1155,22 +1170,42 @@ const grokMonthlyBillingBar = computed((): GrokQuotaBarInfo | null => {
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)
const formatGrokMoney = (value?: number | null) => {
if (value == null || Number.isNaN(value)) return '0'
if (value >= 1000) return formatCompactNumber(value)
if (value >= 100) return value.toFixed(0)
if (value >= 10) return value.toFixed(1)
return value.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.)
// Absolute money: prefer prepaid/monthly_used fields; fall back to cents/100.
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
const prepaid =
billing.prepaid_balance != null
? billing.prepaid_balance
: null
const used =
billing.monthly_used != null
? billing.monthly_used
: billing.used_cents != null
? billing.used_cents / 100
: null
const limit =
billing.monthly_limit != null
? billing.monthly_limit
: billing.monthly_limit_cents != null
? billing.monthly_limit_cents / 100
: null
const onDemandUsed = billing.on_demand_used ?? null
const onDemandCap = billing.on_demand_cap ?? null
const hasAny =
(prepaid ?? 0) > 0 ||
(used ?? 0) > 0 ||
(limit ?? 0) > 0 ||
(onDemandCap ?? 0) > 0 ||
(onDemandUsed ?? 0) > 0
if (!hasAny) 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)))
@@ -1178,8 +1213,12 @@ const grokBillingMoneySummary = computed(() => {
usedPercent = Math.round(Math.min(100, Math.max(0, (used / limit) * 100)))
}
return {
used: formatGrokMoneyFromCents(used ?? 0),
limit: formatGrokMoneyFromCents(limit ?? 0),
prepaid: prepaid != null ? formatGrokMoney(prepaid) : null,
used: formatGrokMoney(used ?? 0),
limit: formatGrokMoney(limit ?? 0),
onDemandUsed: formatGrokMoney(onDemandUsed ?? 0),
onDemandCap: formatGrokMoney(onDemandCap ?? 0),
showOverage: (onDemandCap ?? 0) > 0 || (onDemandUsed ?? 0) > 0,
usedPercent
}
})
@@ -1208,6 +1247,10 @@ const grokIsFree = computed(() => {
return billing != null
})
const grokFreeQuotaUsage = computed(() => usageInfo.value?.grok_local_usage_24h || null)
const showGrokStandaloneLocalStats = computed(() => {
// Official 7d/30d bars already embed window_stats — avoid duplicate local row.
return !usageInfo.value?.seven_day && !usageInfo.value?.thirty_day
})
const grokLocalUsage = computed(() => {
if (grokIsFree.value) return grokFreeQuotaUsage.value
return props.todayStats ||
@@ -1375,6 +1375,40 @@
</div>
</div>
<div
v-if="supportsAccountSchedulingThresholdOverride"
class="border-t border-gray-200 pt-4 dark:border-dark-600"
data-testid="account-scheduling-threshold-section"
>
<div class="mb-3 flex items-center justify-between">
<div>
<label class="input-label mb-0">{{ t('admin.accounts.accountSchedulingThresholdOverride') }}</label>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.accountSchedulingThresholdOverrideHint') }}
</p>
</div>
<input
v-model="accountSchedulingThresholdOverrideEnabled"
data-testid="account-scheduling-threshold-override-enabled"
type="checkbox"
class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/>
</div>
<div v-if="accountSchedulingThresholdOverrideEnabled">
<label class="input-label">{{ t('admin.accounts.accountSchedulingThresholdOverrideValue') }}</label>
<input
v-model.number="accountSchedulingThresholdOverrideValue"
data-testid="account-scheduling-threshold-override-value"
type="number"
min="1"
max="100"
class="input"
/>
<p class="input-hint">{{ t('admin.accounts.accountSchedulingThresholdOverrideDisabledHint') }}</p>
</div>
</div>
<!-- Intercept Warmup Requests (Anthropic/Antigravity) -->
<div
v-if="account?.platform === 'anthropic' || account?.platform === 'antigravity'"
@@ -2867,6 +2901,12 @@ const antigravityWhitelistModels = ref<string[]>([])
const antigravityModelMappings = ref<ModelMapping[]>([])
const isSyncingAntigravityUpstream = ref(false)
const tempUnschedEnabled = ref(false)
const accountSchedulingThresholdOverrideEnabled = ref(false)
const accountSchedulingThresholdOverrideValue = ref(100)
const ACCOUNT_SCHEDULING_THRESHOLD_CREDENTIAL_KEY = 'account_scheduling_threshold'
const supportsAccountSchedulingThresholdOverride = computed(() =>
supportsAccountSchedulingThresholdOverridePlatform(props.account?.platform)
)
const tempUnschedRules = ref<TempUnschedRuleForm[]>([])
const getModelMappingKey = createStableObjectKeyResolver<ModelMapping>('edit-model-mapping')
const getOpenAICompactModelMappingKey = createStableObjectKeyResolver<ModelMapping>('edit-openai-compact-model-mapping')
@@ -3512,6 +3552,7 @@ const syncFormFromAccount = (newAccount: Account | null) => {
loadQuotaControlSettings(newAccount)
loadTempUnschedRules(credentials)
loadAccountSchedulingThresholdOverride(newAccount.platform, credentials)
// Load header override state (anthropic/openai apikey + grok apikey/oauth)
headerOverrideEnabled.value = false
@@ -3874,6 +3915,67 @@ const applyTempUnschedConfig = (credentials: Record<string, unknown>) => {
return true
}
const supportsAccountSchedulingThresholdOverridePlatform = (platform: Account['platform'] | undefined) =>
platform === 'openai' || platform === 'anthropic' || platform === 'grok'
const normalizeAccountSchedulingThresholdOverride = (value: unknown): number | null => {
if (value === null || value === undefined || value === '') {
return null
}
const numeric = Number(value)
if (!Number.isFinite(numeric)) {
return null
}
const integer = Math.trunc(numeric)
if (integer < 1 || integer > 100) {
return null
}
return integer
}
const clampAccountSchedulingThresholdOverride = (value: unknown): number =>
Math.min(100, Math.max(1, Math.trunc(Number(value) || 100)))
const loadAccountSchedulingThresholdOverride = (
platform: Account['platform'] | undefined,
credentials: Record<string, unknown> | undefined
) => {
if (!supportsAccountSchedulingThresholdOverridePlatform(platform)) {
accountSchedulingThresholdOverrideEnabled.value = false
accountSchedulingThresholdOverrideValue.value = 100
return
}
const value = normalizeAccountSchedulingThresholdOverride(
credentials?.[ACCOUNT_SCHEDULING_THRESHOLD_CREDENTIAL_KEY]
)
accountSchedulingThresholdOverrideEnabled.value = value !== null
accountSchedulingThresholdOverrideValue.value = value ?? 100
}
const applyAccountSchedulingThresholdOverridePatch = (
credentials: Record<string, unknown>,
currentCredentials: Record<string, unknown>,
platform: Account['platform'] | undefined = props.account?.platform
) => {
if (!supportsAccountSchedulingThresholdOverridePlatform(platform)) {
return
}
const current = normalizeAccountSchedulingThresholdOverride(
currentCredentials[ACCOUNT_SCHEDULING_THRESHOLD_CREDENTIAL_KEY]
)
if (!accountSchedulingThresholdOverrideEnabled.value) {
if (current !== null) {
credentials[ACCOUNT_SCHEDULING_THRESHOLD_CREDENTIAL_KEY] = null
}
return
}
const next = clampAccountSchedulingThresholdOverride(accountSchedulingThresholdOverrideValue.value)
if (current !== next) {
credentials[ACCOUNT_SCHEDULING_THRESHOLD_CREDENTIAL_KEY] = next
}
}
function loadTempUnschedRules(credentials?: Record<string, unknown>) {
tempUnschedEnabled.value = credentials?.temp_unschedulable_enabled === true
const rawRules = credentials?.temp_unschedulable_rules
@@ -4233,6 +4335,7 @@ const handleSubmit = async () => {
// Add intercept warmup requests setting
applyInterceptWarmup(newCredentials, interceptWarmupRequests.value, 'edit')
applyAccountSchedulingThresholdOverridePatch(newCredentials, currentCredentials)
if (!applyTempUnschedConfig(newCredentials)) {
return
}
@@ -4251,6 +4354,7 @@ const handleSubmit = async () => {
// Add intercept warmup requests setting
applyInterceptWarmup(newCredentials, interceptWarmupRequests.value, 'edit')
applyAccountSchedulingThresholdOverridePatch(newCredentials, currentCredentials)
if (!applyTempUnschedConfig(newCredentials)) {
return
}
@@ -4299,6 +4403,7 @@ const handleSubmit = async () => {
}
applyInterceptWarmup(newCredentials, interceptWarmupRequests.value, 'edit')
applyAccountSchedulingThresholdOverridePatch(newCredentials, currentCredentials)
if (!applyTempUnschedConfig(newCredentials)) {
return
}
@@ -4356,6 +4461,7 @@ const handleSubmit = async () => {
}
applyInterceptWarmup(newCredentials, interceptWarmupRequests.value, 'edit')
applyAccountSchedulingThresholdOverridePatch(newCredentials, currentCredentials)
if (!applyTempUnschedConfig(newCredentials)) {
return
}
@@ -4367,6 +4473,7 @@ const handleSubmit = async () => {
const newCredentials: Record<string, unknown> = { ...currentCredentials }
applyInterceptWarmup(newCredentials, interceptWarmupRequests.value, 'edit')
applyAccountSchedulingThresholdOverridePatch(newCredentials, currentCredentials)
if (!applyTempUnschedConfig(newCredentials)) {
return
}