feat(admin): 添加临时不可调度功能

当账号触发特定错误码和关键词匹配时,自动临时禁用调度:

后端:
- 新增 TempUnschedCache Redis 缓存层
- RateLimitService 支持规则匹配和状态管理
- 添加 GET/DELETE /accounts/:id/temp-unschedulable API
- 数据库迁移添加 temp_unschedulable_until/reason 字段

前端:
- 账号状态指示器显示临时不可调度状态
- 新增 TempUnschedStatusModal 详情弹窗
- 创建/编辑账号时支持配置规则和预设模板
- 完整的中英文国际化支持
This commit is contained in:
ianshaw
2026-01-03 06:34:00 -08:00
parent acb718d355
commit 09da6904f5
18 changed files with 1829 additions and 199 deletions

View File

@@ -12,7 +12,8 @@ import type {
AccountUsageInfo,
WindowStats,
ClaudeModel,
AccountUsageStatsResponse
AccountUsageStatsResponse,
TempUnschedulableStatus
} from '@/types'
/**
@@ -170,6 +171,30 @@ export async function clearRateLimit(id: number): Promise<{ message: string }> {
return data
}
/**
* Get temporary unschedulable status
* @param id - Account ID
* @returns Status with detail state if active
*/
export async function getTempUnschedulableStatus(id: number): Promise<TempUnschedulableStatus> {
const { data } = await apiClient.get<TempUnschedulableStatus>(
`/admin/accounts/${id}/temp-unschedulable`
)
return data
}
/**
* Reset temporary unschedulable status
* @param id - Account ID
* @returns Success confirmation
*/
export async function resetTempUnschedulable(id: number): Promise<{ message: string }> {
const { data } = await apiClient.delete<{ message: string }>(
`/admin/accounts/${id}/temp-unschedulable`
)
return data
}
/**
* Generate OAuth authorization URL
* @param endpoint - API endpoint path
@@ -332,6 +357,8 @@ export const accountsAPI = {
getUsage,
getTodayStats,
clearRateLimit,
getTempUnschedulableStatus,
resetTempUnschedulable,
setSchedulable,
getAvailableModels,
generateAuthUrl,

View File

@@ -1,7 +1,16 @@
<template>
<div class="flex items-center gap-2">
<!-- Main Status Badge -->
<span :class="['badge text-xs', statusClass]">
<button
v-if="isTempUnschedulable"
type="button"
:class="['badge text-xs', statusClass, 'cursor-pointer']"
:title="t('admin.accounts.tempUnschedulable.viewDetails')"
@click="handleTempUnschedClick"
>
{{ statusText }}
</button>
<span v-else :class="['badge text-xs', statusClass]">
{{ statusText }}
</span>
@@ -83,26 +92,25 @@
></div>
</div>
</div>
<!-- Tier Indicator -->
<span
v-if="tierDisplay"
class="inline-flex items-center rounded bg-blue-100 px-1.5 py-0.5 text-xs font-medium text-blue-700 dark:bg-blue-900/30 dark:text-blue-400"
>
{{ tierDisplay }}
</span>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { Account } from '@/types'
import { formatTime } from '@/utils/format'
const { t } = useI18n()
const props = defineProps<{
account: Account
}>()
const emit = defineEmits<{
(e: 'show-temp-unsched', account: Account): void
}>()
// Computed: is rate limited (429)
const isRateLimited = computed(() => {
if (!props.account.rate_limit_reset_at) return false
@@ -115,6 +123,12 @@ const isOverloaded = computed(() => {
return new Date(props.account.overload_until) > new Date()
})
// Computed: is temp unschedulable
const isTempUnschedulable = computed(() => {
if (!props.account.temp_unschedulable_until) return false
return new Date(props.account.temp_unschedulable_until) > new Date()
})
// Computed: has error status
const hasError = computed(() => {
return props.account.status === 'error'
@@ -122,6 +136,12 @@ const hasError = computed(() => {
// Computed: status badge class
const statusClass = computed(() => {
if (hasError.value) {
return 'badge-danger'
}
if (isTempUnschedulable.value) {
return 'badge-warning'
}
if (!props.account.schedulable || isRateLimited.value || isOverloaded.value) {
return 'badge-gray'
}
@@ -139,32 +159,24 @@ const statusClass = computed(() => {
// Computed: status text
const statusText = computed(() => {
if (hasError.value) {
return t('common.error')
}
if (isTempUnschedulable.value) {
return t('admin.accounts.status.tempUnschedulable')
}
if (!props.account.schedulable) {
return 'Paused'
return t('admin.accounts.status.paused')
}
if (isRateLimited.value || isOverloaded.value) {
return 'Limited'
return t('admin.accounts.status.limited')
}
return props.account.status
})
// Computed: tier display
const tierDisplay = computed(() => {
const credentials = props.account.credentials as Record<string, any> | undefined
const tierId = credentials?.tier_id
if (!tierId || tierId === 'unknown') return null
const tierMap: Record<string, string> = {
'free': 'Free',
'payg': 'Pay-as-you-go',
'pay-as-you-go': 'Pay-as-you-go',
'enterprise': 'Enterprise',
'LEGACY': 'Legacy',
'PRO': 'Pro',
'ULTRA': 'Ultra'
}
return tierMap[tierId] || tierId
})
const handleTempUnschedClick = () => {
if (!isTempUnschedulable.value) return
emit('show-temp-unsched', props.account)
}
</script>

View File

@@ -1065,7 +1065,7 @@
<!-- Manual input -->
<div class="flex items-center gap-2">
<input
v-model="customErrorCodeInput"
v-model.number="customErrorCodeInput"
type="number"
min="100"
max="599"
@@ -1304,6 +1304,175 @@
</div>
</div>
<!-- Temp Unschedulable Rules -->
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
<div class="mb-3 flex items-center justify-between">
<div>
<label class="input-label mb-0">{{ t('admin.accounts.tempUnschedulable.title') }}</label>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.tempUnschedulable.hint') }}
</p>
</div>
<button
type="button"
@click="tempUnschedEnabled = !tempUnschedEnabled"
:class="[
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
tempUnschedEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
]"
>
<span
:class="[
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
tempUnschedEnabled ? 'translate-x-5' : 'translate-x-0'
]"
/>
</button>
</div>
<div v-if="tempUnschedEnabled" class="space-y-3">
<div class="rounded-lg bg-blue-50 p-3 dark:bg-blue-900/20">
<p class="text-xs text-blue-700 dark:text-blue-400">
<svg
class="mr-1 inline h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
{{ t('admin.accounts.tempUnschedulable.notice') }}
</p>
</div>
<div class="flex flex-wrap gap-2">
<button
v-for="preset in tempUnschedPresets"
:key="preset.label"
type="button"
@click="addTempUnschedRule(preset.rule)"
class="rounded-lg bg-gray-100 px-3 py-1.5 text-xs font-medium text-gray-600 transition-colors hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-300 dark:hover:bg-dark-500"
>
+ {{ preset.label }}
</button>
</div>
<div v-if="tempUnschedRules.length > 0" class="space-y-3">
<div
v-for="(rule, index) in tempUnschedRules"
:key="index"
class="rounded-lg border border-gray-200 p-3 dark:border-dark-600"
>
<div class="mb-2 flex items-center justify-between">
<span class="text-xs font-medium text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.tempUnschedulable.ruleIndex', { index: index + 1 }) }}
</span>
<div class="flex items-center gap-2">
<button
type="button"
:disabled="index === 0"
@click="moveTempUnschedRule(index, -1)"
class="rounded p-1 text-gray-400 transition-colors hover:text-gray-600 disabled:cursor-not-allowed disabled:opacity-40 dark:hover:text-gray-200"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7" />
</svg>
</button>
<button
type="button"
:disabled="index === tempUnschedRules.length - 1"
@click="moveTempUnschedRule(index, 1)"
class="rounded p-1 text-gray-400 transition-colors hover:text-gray-600 disabled:cursor-not-allowed disabled:opacity-40 dark:hover:text-gray-200"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
<button
type="button"
@click="removeTempUnschedRule(index)"
class="rounded p-1 text-red-500 transition-colors hover:text-red-600"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
</div>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<label class="input-label">{{ t('admin.accounts.tempUnschedulable.errorCode') }}</label>
<input
v-model.number="rule.error_code"
type="number"
min="100"
max="599"
class="input"
:placeholder="t('admin.accounts.tempUnschedulable.errorCodePlaceholder')"
/>
</div>
<div>
<label class="input-label">{{ t('admin.accounts.tempUnschedulable.durationMinutes') }}</label>
<input
v-model.number="rule.duration_minutes"
type="number"
min="1"
class="input"
:placeholder="t('admin.accounts.tempUnschedulable.durationPlaceholder')"
/>
</div>
<div class="sm:col-span-2">
<label class="input-label">{{ t('admin.accounts.tempUnschedulable.keywords') }}</label>
<input
v-model="rule.keywords"
type="text"
class="input"
:placeholder="t('admin.accounts.tempUnschedulable.keywordsPlaceholder')"
/>
<p class="input-hint">{{ t('admin.accounts.tempUnschedulable.keywordsHint') }}</p>
</div>
<div class="sm:col-span-2">
<label class="input-label">{{ t('admin.accounts.tempUnschedulable.description') }}</label>
<input
v-model="rule.description"
type="text"
class="input"
:placeholder="t('admin.accounts.tempUnschedulable.descriptionPlaceholder')"
/>
</div>
</div>
</div>
</div>
<button
type="button"
@click="addTempUnschedRule()"
class="w-full rounded-lg border-2 border-dashed border-gray-300 px-4 py-2 text-sm text-gray-600 transition-colors hover:border-gray-400 hover:text-gray-700 dark:border-dark-500 dark:text-gray-400 dark:hover:border-dark-400 dark:hover:text-gray-300"
>
<svg
class="mr-1 inline h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
{{ t('admin.accounts.tempUnschedulable.addRule') }}
</button>
</div>
</div>
<!-- Intercept Warmup Requests (Anthropic only) -->
<div
v-if="form.platform === 'anthropic'"
@@ -1619,6 +1788,13 @@ interface ModelMapping {
to: string
}
interface TempUnschedRuleForm {
error_code: number | null
keywords: string
duration_minutes: number | null
description: string
}
// State
const step = ref(1)
const submitting = ref(false)
@@ -1634,6 +1810,8 @@ const selectedErrorCodes = ref<number[]>([])
const customErrorCodeInput = ref<number | null>(null)
const interceptWarmupRequests = ref(false)
const mixedScheduling = ref(false) // For antigravity accounts: enable mixed scheduling
const tempUnschedEnabled = ref(false)
const tempUnschedRules = ref<TempUnschedRuleForm[]>([])
const geminiOAuthType = ref<'code_assist' | 'google_one' | 'ai_studio'>('google_one')
const geminiAIStudioOAuthEnabled = ref(false)
const showAdvancedOAuth = ref(false)
@@ -1654,6 +1832,35 @@ const geminiHelpLinks = {
// Computed: current preset mappings based on platform
const presetMappings = computed(() => getPresetMappingsByPlatform(form.platform))
const tempUnschedPresets = computed(() => [
{
label: t('admin.accounts.tempUnschedulable.presets.overloadLabel'),
rule: {
error_code: 529,
keywords: 'overloaded, too many',
duration_minutes: 60,
description: t('admin.accounts.tempUnschedulable.presets.overloadDesc')
}
},
{
label: t('admin.accounts.tempUnschedulable.presets.rateLimitLabel'),
rule: {
error_code: 429,
keywords: 'rate limit, too many requests',
duration_minutes: 10,
description: t('admin.accounts.tempUnschedulable.presets.rateLimitDesc')
}
},
{
label: t('admin.accounts.tempUnschedulable.presets.unavailableLabel'),
rule: {
error_code: 503,
keywords: 'unavailable, maintenance',
duration_minutes: 30,
description: t('admin.accounts.tempUnschedulable.presets.unavailableDesc')
}
}
])
const form = reactive({
name: '',
@@ -1828,6 +2035,89 @@ const removeErrorCode = (code: number) => {
}
}
const addTempUnschedRule = (preset?: TempUnschedRuleForm) => {
if (preset) {
tempUnschedRules.value.push({ ...preset })
return
}
tempUnschedRules.value.push({
error_code: null,
keywords: '',
duration_minutes: 30,
description: ''
})
}
const removeTempUnschedRule = (index: number) => {
tempUnschedRules.value.splice(index, 1)
}
const moveTempUnschedRule = (index: number, direction: number) => {
const target = index + direction
if (target < 0 || target >= tempUnschedRules.value.length) return
const rules = tempUnschedRules.value
const current = rules[index]
rules[index] = rules[target]
rules[target] = current
}
const buildTempUnschedRules = (rules: TempUnschedRuleForm[]) => {
const out: Array<{
error_code: number
keywords: string[]
duration_minutes: number
description: string
}> = []
for (const rule of rules) {
const errorCode = Number(rule.error_code)
const duration = Number(rule.duration_minutes)
const keywords = splitTempUnschedKeywords(rule.keywords)
if (!Number.isFinite(errorCode) || errorCode < 100 || errorCode > 599) {
continue
}
if (!Number.isFinite(duration) || duration <= 0) {
continue
}
if (keywords.length === 0) {
continue
}
out.push({
error_code: Math.trunc(errorCode),
keywords,
duration_minutes: Math.trunc(duration),
description: rule.description.trim()
})
}
return out
}
const applyTempUnschedConfig = (credentials: Record<string, unknown>) => {
if (!tempUnschedEnabled.value) {
delete credentials.temp_unschedulable_enabled
delete credentials.temp_unschedulable_rules
return true
}
const rules = buildTempUnschedRules(tempUnschedRules.value)
if (rules.length === 0) {
appStore.showError(t('admin.accounts.tempUnschedulable.rulesInvalid'))
return false
}
credentials.temp_unschedulable_enabled = true
credentials.temp_unschedulable_rules = rules
return true
}
const splitTempUnschedKeywords = (value: string) => {
return value
.split(/[,;]/)
.map((item) => item.trim())
.filter((item) => item.length > 0)
}
// Methods
const resetForm = () => {
step.value = 1
@@ -1850,6 +2140,8 @@ const resetForm = () => {
selectedErrorCodes.value = []
customErrorCodeInput.value = null
interceptWarmupRequests.value = false
tempUnschedEnabled.value = false
tempUnschedRules.value = []
geminiOAuthType.value = 'code_assist'
oauth.resetState()
openaiOAuth.resetState()
@@ -1910,6 +2202,10 @@ const handleSubmit = async () => {
credentials.intercept_warmup_requests = true
}
if (!applyTempUnschedConfig(credentials)) {
return
}
form.credentials = credentials
submitting.value = true
@@ -1956,6 +2252,9 @@ const createAccountAndFinish = async (
credentials: Record<string, unknown>,
extra?: Record<string, unknown>
) => {
if (!applyTempUnschedConfig(credentials)) {
return
}
await adminAPI.accounts.create({
name: form.name,
platform,
@@ -2024,7 +2323,8 @@ const handleGeminiExchange = async (authCode: string) => {
if (!tokenInfo) return
const credentials = geminiOAuth.buildCredentials(tokenInfo)
await createAccountAndFinish('gemini', 'oauth', credentials)
const extra = geminiOAuth.buildExtraInfo(tokenInfo)
await createAccountAndFinish('gemini', 'oauth', credentials, extra)
} catch (error: any) {
geminiOAuth.error.value = error.response?.data?.detail || t('admin.accounts.oauth.authFailed')
appStore.showError(geminiOAuth.error.value)
@@ -2131,6 +2431,14 @@ const handleCookieAuth = async (sessionKey: string) => {
return
}
const tempUnschedPayload = tempUnschedEnabled.value
? buildTempUnschedRules(tempUnschedRules.value)
: []
if (tempUnschedEnabled.value && tempUnschedPayload.length === 0) {
appStore.showError(t('admin.accounts.tempUnschedulable.rulesInvalid'))
return
}
const endpoint =
addMethod.value === 'oauth'
? '/admin/accounts/cookie-auth'
@@ -2156,6 +2464,10 @@ const handleCookieAuth = async (sessionKey: string) => {
...tokenInfo,
...(interceptWarmupRequests.value ? { intercept_warmup_requests: true } : {})
}
if (tempUnschedEnabled.value) {
credentials.temp_unschedulable_enabled = true
credentials.temp_unschedulable_rules = tempUnschedPayload
}
await adminAPI.accounts.create({
name: accountName,

View File

@@ -293,7 +293,7 @@
<!-- Manual input -->
<div class="flex items-center gap-2">
<input
v-model="customErrorCodeInput"
v-model.number="customErrorCodeInput"
type="number"
min="100"
max="599"
@@ -373,6 +373,175 @@
</div>
</div>
<!-- Temp Unschedulable Rules -->
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
<div class="mb-3 flex items-center justify-between">
<div>
<label class="input-label mb-0">{{ t('admin.accounts.tempUnschedulable.title') }}</label>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.tempUnschedulable.hint') }}
</p>
</div>
<button
type="button"
@click="tempUnschedEnabled = !tempUnschedEnabled"
:class="[
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
tempUnschedEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
]"
>
<span
:class="[
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
tempUnschedEnabled ? 'translate-x-5' : 'translate-x-0'
]"
/>
</button>
</div>
<div v-if="tempUnschedEnabled" class="space-y-3">
<div class="rounded-lg bg-blue-50 p-3 dark:bg-blue-900/20">
<p class="text-xs text-blue-700 dark:text-blue-400">
<svg
class="mr-1 inline h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
{{ t('admin.accounts.tempUnschedulable.notice') }}
</p>
</div>
<div class="flex flex-wrap gap-2">
<button
v-for="preset in tempUnschedPresets"
:key="preset.label"
type="button"
@click="addTempUnschedRule(preset.rule)"
class="rounded-lg bg-gray-100 px-3 py-1.5 text-xs font-medium text-gray-600 transition-colors hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-300 dark:hover:bg-dark-500"
>
+ {{ preset.label }}
</button>
</div>
<div v-if="tempUnschedRules.length > 0" class="space-y-3">
<div
v-for="(rule, index) in tempUnschedRules"
:key="index"
class="rounded-lg border border-gray-200 p-3 dark:border-dark-600"
>
<div class="mb-2 flex items-center justify-between">
<span class="text-xs font-medium text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.tempUnschedulable.ruleIndex', { index: index + 1 }) }}
</span>
<div class="flex items-center gap-2">
<button
type="button"
:disabled="index === 0"
@click="moveTempUnschedRule(index, -1)"
class="rounded p-1 text-gray-400 transition-colors hover:text-gray-600 disabled:cursor-not-allowed disabled:opacity-40 dark:hover:text-gray-200"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7" />
</svg>
</button>
<button
type="button"
:disabled="index === tempUnschedRules.length - 1"
@click="moveTempUnschedRule(index, 1)"
class="rounded p-1 text-gray-400 transition-colors hover:text-gray-600 disabled:cursor-not-allowed disabled:opacity-40 dark:hover:text-gray-200"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
<button
type="button"
@click="removeTempUnschedRule(index)"
class="rounded p-1 text-red-500 transition-colors hover:text-red-600"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
</div>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<label class="input-label">{{ t('admin.accounts.tempUnschedulable.errorCode') }}</label>
<input
v-model.number="rule.error_code"
type="number"
min="100"
max="599"
class="input"
:placeholder="t('admin.accounts.tempUnschedulable.errorCodePlaceholder')"
/>
</div>
<div>
<label class="input-label">{{ t('admin.accounts.tempUnschedulable.durationMinutes') }}</label>
<input
v-model.number="rule.duration_minutes"
type="number"
min="1"
class="input"
:placeholder="t('admin.accounts.tempUnschedulable.durationPlaceholder')"
/>
</div>
<div class="sm:col-span-2">
<label class="input-label">{{ t('admin.accounts.tempUnschedulable.keywords') }}</label>
<input
v-model="rule.keywords"
type="text"
class="input"
:placeholder="t('admin.accounts.tempUnschedulable.keywordsPlaceholder')"
/>
<p class="input-hint">{{ t('admin.accounts.tempUnschedulable.keywordsHint') }}</p>
</div>
<div class="sm:col-span-2">
<label class="input-label">{{ t('admin.accounts.tempUnschedulable.description') }}</label>
<input
v-model="rule.description"
type="text"
class="input"
:placeholder="t('admin.accounts.tempUnschedulable.descriptionPlaceholder')"
/>
</div>
</div>
</div>
</div>
<button
type="button"
@click="addTempUnschedRule()"
class="w-full rounded-lg border-2 border-dashed border-gray-300 px-4 py-2 text-sm text-gray-600 transition-colors hover:border-gray-400 hover:text-gray-700 dark:border-dark-500 dark:text-gray-400 dark:hover:border-dark-400 dark:hover:text-gray-300"
>
<svg
class="mr-1 inline h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
{{ t('admin.accounts.tempUnschedulable.addRule') }}
</button>
</div>
</div>
<!-- Intercept Warmup Requests (Anthropic only) -->
<div
v-if="account?.platform === 'anthropic'"
@@ -563,6 +732,13 @@ interface ModelMapping {
to: string
}
interface TempUnschedRuleForm {
error_code: number | null
keywords: string
duration_minutes: number | null
description: string
}
// State
const submitting = ref(false)
const editBaseUrl = ref('https://api.anthropic.com')
@@ -575,9 +751,40 @@ const selectedErrorCodes = ref<number[]>([])
const customErrorCodeInput = ref<number | null>(null)
const interceptWarmupRequests = ref(false)
const mixedScheduling = ref(false) // For antigravity accounts: enable mixed scheduling
const tempUnschedEnabled = ref(false)
const tempUnschedRules = ref<TempUnschedRuleForm[]>([])
// Computed: current preset mappings based on platform
const presetMappings = computed(() => getPresetMappingsByPlatform(props.account?.platform || 'anthropic'))
const tempUnschedPresets = computed(() => [
{
label: t('admin.accounts.tempUnschedulable.presets.overloadLabel'),
rule: {
error_code: 529,
keywords: 'overloaded, too many',
duration_minutes: 60,
description: t('admin.accounts.tempUnschedulable.presets.overloadDesc')
}
},
{
label: t('admin.accounts.tempUnschedulable.presets.rateLimitLabel'),
rule: {
error_code: 429,
keywords: 'rate limit, too many requests',
duration_minutes: 10,
description: t('admin.accounts.tempUnschedulable.presets.rateLimitDesc')
}
},
{
label: t('admin.accounts.tempUnschedulable.presets.unavailableLabel'),
rule: {
error_code: 503,
keywords: 'unavailable, maintenance',
duration_minutes: 30,
description: t('admin.accounts.tempUnschedulable.presets.unavailableDesc')
}
}
])
// Computed: default base URL based on platform
const defaultBaseUrl = computed(() => {
@@ -620,6 +827,8 @@ watch(
const extra = newAccount.extra as Record<string, unknown> | undefined
mixedScheduling.value = extra?.mixed_scheduling === true
loadTempUnschedRules(credentials)
// Initialize API Key fields for apikey type
if (newAccount.type === 'apikey' && newAccount.credentials) {
const credentials = newAccount.credentials as Record<string, unknown>
@@ -736,6 +945,130 @@ const removeErrorCode = (code: number) => {
}
}
const addTempUnschedRule = (preset?: TempUnschedRuleForm) => {
if (preset) {
tempUnschedRules.value.push({ ...preset })
return
}
tempUnschedRules.value.push({
error_code: null,
keywords: '',
duration_minutes: 30,
description: ''
})
}
const removeTempUnschedRule = (index: number) => {
tempUnschedRules.value.splice(index, 1)
}
const moveTempUnschedRule = (index: number, direction: number) => {
const target = index + direction
if (target < 0 || target >= tempUnschedRules.value.length) return
const rules = tempUnschedRules.value
const current = rules[index]
rules[index] = rules[target]
rules[target] = current
}
const buildTempUnschedRules = (rules: TempUnschedRuleForm[]) => {
const out: Array<{
error_code: number
keywords: string[]
duration_minutes: number
description: string
}> = []
for (const rule of rules) {
const errorCode = Number(rule.error_code)
const duration = Number(rule.duration_minutes)
const keywords = splitTempUnschedKeywords(rule.keywords)
if (!Number.isFinite(errorCode) || errorCode < 100 || errorCode > 599) {
continue
}
if (!Number.isFinite(duration) || duration <= 0) {
continue
}
if (keywords.length === 0) {
continue
}
out.push({
error_code: Math.trunc(errorCode),
keywords,
duration_minutes: Math.trunc(duration),
description: rule.description.trim()
})
}
return out
}
const applyTempUnschedConfig = (credentials: Record<string, unknown>) => {
if (!tempUnschedEnabled.value) {
delete credentials.temp_unschedulable_enabled
delete credentials.temp_unschedulable_rules
return true
}
const rules = buildTempUnschedRules(tempUnschedRules.value)
if (rules.length === 0) {
appStore.showError(t('admin.accounts.tempUnschedulable.rulesInvalid'))
return false
}
credentials.temp_unschedulable_enabled = true
credentials.temp_unschedulable_rules = rules
return true
}
function loadTempUnschedRules(credentials?: Record<string, unknown>) {
tempUnschedEnabled.value = credentials?.temp_unschedulable_enabled === true
const rawRules = credentials?.temp_unschedulable_rules
if (!Array.isArray(rawRules)) {
tempUnschedRules.value = []
return
}
tempUnschedRules.value = rawRules.map((rule) => {
const entry = rule as Record<string, unknown>
return {
error_code: toPositiveNumber(entry.error_code),
keywords: formatTempUnschedKeywords(entry.keywords),
duration_minutes: toPositiveNumber(entry.duration_minutes),
description: typeof entry.description === 'string' ? entry.description : ''
}
})
}
function formatTempUnschedKeywords(value: unknown) {
if (Array.isArray(value)) {
return value
.filter((item): item is string => typeof item === 'string')
.map((item) => item.trim())
.filter((item) => item.length > 0)
.join(', ')
}
if (typeof value === 'string') {
return value
}
return ''
}
const splitTempUnschedKeywords = (value: string) => {
return value
.split(/[,;]/)
.map((item) => item.trim())
.filter((item) => item.length > 0)
}
function toPositiveNumber(value: unknown) {
const num = Number(value)
if (!Number.isFinite(num) || num <= 0) {
return null
}
return Math.trunc(num)
}
// Methods
const handleClose = () => {
emit('close')
@@ -788,6 +1121,11 @@ const handleSubmit = async () => {
newCredentials.intercept_warmup_requests = true
}
if (!applyTempUnschedConfig(newCredentials)) {
submitting.value = false
return
}
updatePayload.credentials = newCredentials
} else {
// For oauth/setup-token types, only update intercept_warmup_requests if changed
@@ -800,6 +1138,11 @@ const handleSubmit = async () => {
delete newCredentials.intercept_warmup_requests
}
if (!applyTempUnschedConfig(newCredentials)) {
submitting.value = false
return
}
updatePayload.credentials = newCredentials
}

View File

@@ -0,0 +1,249 @@
<template>
<BaseDialog
:show="show"
:title="t('admin.accounts.tempUnschedulable.statusTitle')"
width="normal"
@close="handleClose"
>
<div class="space-y-4">
<div v-if="loading" class="flex items-center justify-center py-8">
<svg class="h-6 w-6 animate-spin text-gray-400" fill="none" viewBox="0 0 24 24">
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
</div>
<div v-else-if="!isActive" class="rounded-lg border border-gray-200 p-4 text-sm text-gray-500 dark:border-dark-600 dark:text-gray-400">
{{ t('admin.accounts.tempUnschedulable.notActive') }}
</div>
<div v-else class="space-y-4">
<div class="rounded-lg border border-gray-200 p-4 dark:border-dark-600">
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.tempUnschedulable.accountName') }}
</p>
<p class="mt-1 text-sm font-medium text-gray-900 dark:text-gray-100">
{{ account?.name || '-' }}
</p>
</div>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div class="rounded-lg border border-gray-200 p-3 dark:border-dark-600">
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.tempUnschedulable.triggeredAt') }}
</p>
<p class="mt-1 text-sm font-medium text-gray-900 dark:text-gray-100">
{{ triggeredAtText }}
</p>
</div>
<div class="rounded-lg border border-gray-200 p-3 dark:border-dark-600">
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.tempUnschedulable.until') }}
</p>
<p class="mt-1 text-sm font-medium text-gray-900 dark:text-gray-100">
{{ untilText }}
</p>
</div>
<div class="rounded-lg border border-gray-200 p-3 dark:border-dark-600">
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.tempUnschedulable.remaining') }}
</p>
<p class="mt-1 text-sm font-medium text-gray-900 dark:text-gray-100">
{{ remainingText }}
</p>
</div>
<div class="rounded-lg border border-gray-200 p-3 dark:border-dark-600">
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.tempUnschedulable.errorCode') }}
</p>
<p class="mt-1 text-sm font-medium text-gray-900 dark:text-gray-100">
{{ state?.status_code || '-' }}
</p>
</div>
<div class="rounded-lg border border-gray-200 p-3 dark:border-dark-600">
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.tempUnschedulable.matchedKeyword') }}
</p>
<p class="mt-1 text-sm font-medium text-gray-900 dark:text-gray-100">
{{ state?.matched_keyword || '-' }}
</p>
</div>
<div class="rounded-lg border border-gray-200 p-3 dark:border-dark-600">
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.tempUnschedulable.ruleOrder') }}
</p>
<p class="mt-1 text-sm font-medium text-gray-900 dark:text-gray-100">
{{ ruleIndexDisplay }}
</p>
</div>
</div>
<div class="rounded-lg border border-gray-200 p-3 dark:border-dark-600">
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.tempUnschedulable.errorMessage') }}
</p>
<div class="mt-2 rounded bg-gray-50 p-2 text-xs text-gray-700 dark:bg-dark-700 dark:text-gray-300">
{{ state?.error_message || '-' }}
</div>
</div>
</div>
</div>
<template #footer>
<div class="flex justify-end gap-3">
<button type="button" class="btn btn-secondary" @click="handleClose">
{{ t('common.close') }}
</button>
<button
type="button"
class="btn btn-primary"
:disabled="!isActive || resetting"
@click="handleReset"
>
<svg
v-if="resetting"
class="-ml-1 mr-2 h-4 w-4 animate-spin"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
{{ t('admin.accounts.tempUnschedulable.reset') }}
</button>
</div>
</template>
</BaseDialog>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { adminAPI } from '@/api/admin'
import type { Account, TempUnschedulableStatus } from '@/types'
import BaseDialog from '@/components/common/BaseDialog.vue'
import { formatDateTime } from '@/utils/format'
const props = defineProps<{
show: boolean
account: Account | null
}>()
const emit = defineEmits<{
close: []
reset: []
}>()
const { t } = useI18n()
const appStore = useAppStore()
const loading = ref(false)
const resetting = ref(false)
const status = ref<TempUnschedulableStatus | null>(null)
const state = computed(() => status.value?.state || null)
const isActive = computed(() => {
if (!status.value?.active || !state.value) return false
return state.value.until_unix * 1000 > Date.now()
})
const ruleIndexDisplay = computed(() => {
if (!state.value) return '-'
return state.value.rule_index + 1
})
const triggeredAtText = computed(() => {
if (!state.value?.triggered_at_unix) return '-'
return formatDateTime(new Date(state.value.triggered_at_unix * 1000))
})
const untilText = computed(() => {
if (!state.value?.until_unix) return '-'
return formatDateTime(new Date(state.value.until_unix * 1000))
})
const remainingText = computed(() => {
if (!state.value) return '-'
const remainingMs = state.value.until_unix * 1000 - Date.now()
if (remainingMs <= 0) {
return t('admin.accounts.tempUnschedulable.expired')
}
const minutes = Math.ceil(remainingMs / 60000)
if (minutes < 60) {
return t('admin.accounts.tempUnschedulable.remainingMinutes', { minutes })
}
const hours = Math.floor(minutes / 60)
const rest = minutes % 60
if (rest === 0) {
return t('admin.accounts.tempUnschedulable.remainingHours', { hours })
}
return t('admin.accounts.tempUnschedulable.remainingHoursMinutes', { hours, minutes: rest })
})
const loadStatus = async () => {
if (!props.account) return
loading.value = true
try {
status.value = await adminAPI.accounts.getTempUnschedulableStatus(props.account.id)
} catch (error: any) {
appStore.showError(error.response?.data?.detail || t('admin.accounts.tempUnschedulable.failedToLoad'))
status.value = null
} finally {
loading.value = false
}
}
const handleClose = () => {
emit('close')
}
const handleReset = async () => {
if (!props.account) return
resetting.value = true
try {
await adminAPI.accounts.resetTempUnschedulable(props.account.id)
appStore.showSuccess(t('admin.accounts.tempUnschedulable.resetSuccess'))
emit('reset')
handleClose()
} catch (error: any) {
appStore.showError(error.response?.data?.detail || t('admin.accounts.tempUnschedulable.resetFailed'))
} finally {
resetting.value = false
}
}
watch(
() => [props.show, props.account?.id],
([visible]) => {
if (visible && props.account) {
loadStatus()
return
}
status.value = null
}
)
</script>

View File

@@ -9,4 +9,5 @@ export { default as UsageProgressBar } from './UsageProgressBar.vue'
export { default as AccountStatsModal } from './AccountStatsModal.vue'
export { default as AccountTestModal } from './AccountTestModal.vue'
export { default as AccountTodayStatsCell } from './AccountTodayStatsCell.vue'
export { default as TempUnschedStatusModal } from './TempUnschedStatusModal.vue'
export { default as SyncFromCrsModal } from './SyncFromCrsModal.vue'

View File

@@ -330,6 +330,27 @@ export interface GeminiCredentials {
expires_at?: string
}
export interface TempUnschedulableRule {
error_code: number
keywords: string[]
duration_minutes: number
description: string
}
export interface TempUnschedulableState {
until_unix: number
triggered_at_unix: number
status_code: number
matched_keyword: string
rule_index: number
error_message: string
}
export interface TempUnschedulableStatus {
active: boolean
state?: TempUnschedulableState
}
export interface Account {
id: number
name: string
@@ -355,6 +376,8 @@ export interface Account {
rate_limited_at: string | null
rate_limit_reset_at: string | null
overload_until: string | null
temp_unschedulable_until: string | null
temp_unschedulable_reason: string | null
// Session window fields (5-hour window)
session_window_start: string | null
@@ -376,6 +399,12 @@ export interface UsageProgress {
window_stats?: WindowStats | null // 窗口期统计(从窗口开始到当前的使用量)
}
// Antigravity 单个模型的配额信息
export interface AntigravityModelQuota {
utilization: number // 使用率 0-100
reset_time: string // 重置时间 ISO8601
}
export interface AccountUsageInfo {
updated_at: string | null
five_hour: UsageProgress | null
@@ -383,6 +412,7 @@ export interface AccountUsageInfo {
seven_day_sonnet: UsageProgress | null
gemini_pro_daily?: UsageProgress | null
gemini_flash_daily?: UsageProgress | null
antigravity_quota?: Record<string, AntigravityModelQuota> | null
}
// OpenAI Codex usage snapshot (from response headers)
@@ -418,6 +448,7 @@ export interface CreateAccountRequest {
concurrency?: number
priority?: number
group_ids?: number[]
confirm_mixed_channel_risk?: boolean
}
export interface UpdateAccountRequest {
@@ -430,6 +461,7 @@ export interface UpdateAccountRequest {
priority?: number
status?: 'active' | 'inactive'
group_ids?: number[]
confirm_mixed_channel_risk?: boolean
}
export interface CreateProxyRequest {
@@ -619,7 +651,7 @@ export interface UserUsageTrendPoint {
actual_cost: number // 实际扣除
}
export interface APIKeyUsageTrendPoint {
export interface ApiKeyUsageTrendPoint {
date: string
api_key_id: number
key_name: string

View File

@@ -216,7 +216,7 @@
</template>
<template #cell-status="{ row }">
<AccountStatusIndicator :account="row" />
<AccountStatusIndicator :account="row" @show-temp-unsched="handleShowTempUnsched" />
</template>
<template #cell-schedulable="{ row }">
@@ -400,6 +400,14 @@
<!-- Account Stats Modal -->
<AccountStatsModal :show="showStatsModal" :account="statsAccount" @close="closeStatsModal" />
<!-- Temp Unschedulable Status Modal -->
<TempUnschedStatusModal
:show="showTempUnschedModal"
:account="tempUnschedAccount"
@close="closeTempUnschedModal"
@reset="handleTempUnschedReset"
/>
<!-- Delete Confirmation Dialog -->
<ConfirmDialog
:show="showDeleteDialog"
@@ -512,6 +520,7 @@ import {
BulkEditAccountModal,
ReAuthAccountModal,
AccountStatsModal,
TempUnschedStatusModal,
SyncFromCrsModal
} from '@/components/account'
import AccountStatusIndicator from '@/components/account/AccountStatusIndicator.vue'
@@ -604,6 +613,7 @@ const showDeleteDialog = ref(false)
const showBulkDeleteDialog = ref(false)
const showTestModal = ref(false)
const showStatsModal = ref(false)
const showTempUnschedModal = ref(false)
const showCrsSyncModal = ref(false)
const showBulkEditModal = ref(false)
const editingAccount = ref<Account | null>(null)
@@ -611,6 +621,7 @@ const reAuthAccount = ref<Account | null>(null)
const deletingAccount = ref<Account | null>(null)
const testingAccount = ref<Account | null>(null)
const statsAccount = ref<Account | null>(null)
const tempUnschedAccount = ref<Account | null>(null)
const togglingSchedulable = ref<number | null>(null)
const bulkDeleting = ref(false)
@@ -775,6 +786,21 @@ const closeReAuthModal = () => {
reAuthAccount.value = null
}
// Temp unschedulable modal
const handleShowTempUnsched = (account: Account) => {
tempUnschedAccount.value = account
showTempUnschedModal.value = true
}
const closeTempUnschedModal = () => {
showTempUnschedModal.value = false
tempUnschedAccount.value = null
}
const handleTempUnschedReset = () => {
loadAccounts()
}
// Token refresh
const handleRefreshToken = async (account: Account) => {
try {