Merge branch 'main' into test

This commit is contained in:
yangjianbo
2026-02-03 22:48:04 +08:00
235 changed files with 25155 additions and 7955 deletions

View File

@@ -56,6 +56,65 @@
></div>
</div>
</div>
<!-- Rate Limit Indicator (429) -->
<div v-if="isRateLimited" class="group relative">
<span
class="inline-flex items-center gap-1 rounded bg-amber-100 px-1.5 py-0.5 text-xs font-medium text-amber-700 dark:bg-amber-900/30 dark:text-amber-400"
>
<Icon name="exclamationTriangle" size="xs" :stroke-width="2" />
429
</span>
<!-- Tooltip -->
<div
class="pointer-events-none absolute bottom-full left-1/2 z-50 mb-2 -translate-x-1/2 whitespace-nowrap rounded bg-gray-900 px-2 py-1 text-xs text-white opacity-0 transition-opacity group-hover:opacity-100 dark:bg-gray-700"
>
{{ t('admin.accounts.status.rateLimitedUntil', { time: formatTime(account.rate_limit_reset_at) }) }}
<div
class="absolute left-1/2 top-full -translate-x-1/2 border-4 border-transparent border-t-gray-900 dark:border-t-gray-700"
></div>
</div>
</div>
<!-- Scope Rate Limit Indicators (Antigravity) -->
<template v-if="activeScopeRateLimits.length > 0">
<div v-for="item in activeScopeRateLimits" :key="item.scope" class="group relative">
<span
class="inline-flex items-center gap-1 rounded bg-orange-100 px-1.5 py-0.5 text-xs font-medium text-orange-700 dark:bg-orange-900/30 dark:text-orange-400"
>
<Icon name="exclamationTriangle" size="xs" :stroke-width="2" />
{{ formatScopeName(item.scope) }}
</span>
<!-- Tooltip -->
<div
class="pointer-events-none absolute bottom-full left-1/2 z-50 mb-2 -translate-x-1/2 whitespace-nowrap rounded bg-gray-900 px-2 py-1 text-xs text-white opacity-0 transition-opacity group-hover:opacity-100 dark:bg-gray-700"
>
{{ t('admin.accounts.status.scopeRateLimitedUntil', { scope: formatScopeName(item.scope), time: formatTime(item.reset_at) }) }}
<div
class="absolute left-1/2 top-full -translate-x-1/2 border-4 border-transparent border-t-gray-900 dark:border-t-gray-700"
></div>
</div>
</div>
</template>
<!-- Overload Indicator (529) -->
<div v-if="isOverloaded" class="group relative">
<span
class="inline-flex items-center gap-1 rounded bg-red-100 px-1.5 py-0.5 text-xs font-medium text-red-700 dark:bg-red-900/30 dark:text-red-400"
>
<Icon name="exclamationTriangle" size="xs" :stroke-width="2" />
529
</span>
<!-- Tooltip -->
<div
class="pointer-events-none absolute bottom-full left-1/2 z-50 mb-2 -translate-x-1/2 whitespace-nowrap rounded bg-gray-900 px-2 py-1 text-xs text-white opacity-0 transition-opacity group-hover:opacity-100 dark:bg-gray-700"
>
{{ t('admin.accounts.status.overloadedUntil', { time: formatTime(account.overload_until) }) }}
<div
class="absolute left-1/2 top-full -translate-x-1/2 border-4 border-transparent border-t-gray-900 dark:border-t-gray-700"
></div>
</div>
</div>
</div>
</template>
@@ -63,7 +122,7 @@
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { Account } from '@/types'
import { formatCountdownWithSuffix } from '@/utils/format'
import { formatCountdownWithSuffix, formatTime } from '@/utils/format'
const { t } = useI18n()
@@ -81,6 +140,25 @@ const isRateLimited = computed(() => {
return new Date(props.account.rate_limit_reset_at) > new Date()
})
// Computed: active scope rate limits (Antigravity)
const activeScopeRateLimits = computed(() => {
const scopeLimits = props.account.scope_rate_limits
if (!scopeLimits) return []
const now = new Date()
return Object.entries(scopeLimits)
.filter(([, info]) => new Date(info.reset_at) > now)
.map(([scope, info]) => ({ scope, reset_at: info.reset_at }))
})
const formatScopeName = (scope: string): string => {
const names: Record<string, string> = {
claude: 'Claude',
gemini_text: 'Gemini',
gemini_image: 'Image'
}
return names[scope] || scope
}
// Computed: is overloaded (529)
const isOverloaded = computed(() => {
if (!props.account.overload_until) return false

View File

@@ -1825,6 +1825,18 @@
</div>
</template>
</BaseDialog>
<!-- Mixed Channel Warning Dialog -->
<ConfirmDialog
:show="showMixedChannelWarning"
:title="t('admin.accounts.mixedChannelWarningTitle')"
:message="mixedChannelWarningDetails ? t('admin.accounts.mixedChannelWarning', mixedChannelWarningDetails) : ''"
:confirm-text="t('common.confirm')"
:cancel-text="t('common.cancel')"
:danger="true"
@confirm="handleMixedChannelConfirm"
@cancel="handleMixedChannelCancel"
/>
</template>
<script setup lang="ts">
@@ -1844,6 +1856,7 @@ import { useGeminiOAuth } from '@/composables/useGeminiOAuth'
import { useAntigravityOAuth } from '@/composables/useAntigravityOAuth'
import type { Proxy, AdminGroup, AccountPlatform, AccountType } from '@/types'
import BaseDialog from '@/components/common/BaseDialog.vue'
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
import Icon from '@/components/icons/Icon.vue'
import ProxySelector from '@/components/common/ProxySelector.vue'
import GroupSelector from '@/components/common/GroupSelector.vue'
@@ -1971,6 +1984,11 @@ const tempUnschedEnabled = ref(false)
const tempUnschedRules = ref<TempUnschedRuleForm[]>([])
const geminiOAuthType = ref<'code_assist' | 'google_one' | 'ai_studio'>('google_one')
const geminiAIStudioOAuthEnabled = ref(false)
// Mixed channel warning dialog state
const showMixedChannelWarning = ref(false)
const mixedChannelWarningDetails = ref<{ groupName: string; currentPlatform: string; otherPlatform: string } | null>(null)
const pendingCreatePayload = ref<any>(null)
const showAdvancedOAuth = ref(false)
const showGeminiHelpDialog = ref(false)
@@ -2388,6 +2406,59 @@ const handleClose = () => {
emit('close')
}
// Helper function to create account with mixed channel warning handling
const doCreateAccount = async (payload: any) => {
submitting.value = true
try {
await adminAPI.accounts.create(payload)
appStore.showSuccess(t('admin.accounts.accountCreated'))
emit('created')
handleClose()
} catch (error: any) {
// Handle 409 mixed_channel_warning - show confirmation dialog
if (error.response?.status === 409 && error.response?.data?.error === 'mixed_channel_warning') {
const details = error.response.data.details || {}
mixedChannelWarningDetails.value = {
groupName: details.group_name || 'Unknown',
currentPlatform: details.current_platform || 'Unknown',
otherPlatform: details.other_platform || 'Unknown'
}
pendingCreatePayload.value = payload
showMixedChannelWarning.value = true
} else {
appStore.showError(error.response?.data?.detail || t('admin.accounts.failedToCreate'))
}
} finally {
submitting.value = false
}
}
// Handle mixed channel warning confirmation
const handleMixedChannelConfirm = async () => {
showMixedChannelWarning.value = false
if (pendingCreatePayload.value) {
pendingCreatePayload.value.confirm_mixed_channel_risk = true
submitting.value = true
try {
await adminAPI.accounts.create(pendingCreatePayload.value)
appStore.showSuccess(t('admin.accounts.accountCreated'))
emit('created')
handleClose()
} catch (error: any) {
appStore.showError(error.response?.data?.detail || t('admin.accounts.failedToCreate'))
} finally {
submitting.value = false
pendingCreatePayload.value = null
}
}
}
const handleMixedChannelCancel = () => {
showMixedChannelWarning.value = false
pendingCreatePayload.value = null
mixedChannelWarningDetails.value = null
}
const handleSubmit = async () => {
// For OAuth-based type, handle OAuth flow (goes to step 2)
if (isOAuthFlow.value) {
@@ -2444,21 +2515,11 @@ const handleSubmit = async () => {
form.credentials = credentials
submitting.value = true
try {
await adminAPI.accounts.create({
...form,
group_ids: form.group_ids,
auto_pause_on_expired: autoPauseOnExpired.value
})
appStore.showSuccess(t('admin.accounts.accountCreated'))
emit('created')
handleClose()
} catch (error: any) {
appStore.showError(error.response?.data?.detail || t('admin.accounts.failedToCreate'))
} finally {
submitting.value = false
}
await doCreateAccount({
...form,
group_ids: form.group_ids,
auto_pause_on_expired: autoPauseOnExpired.value
})
}
const goBackToBasicInfo = () => {

View File

@@ -875,6 +875,18 @@
</div>
</template>
</BaseDialog>
<!-- Mixed Channel Warning Dialog -->
<ConfirmDialog
:show="showMixedChannelWarning"
:title="t('admin.accounts.mixedChannelWarningTitle')"
:message="mixedChannelWarningDetails ? t('admin.accounts.mixedChannelWarning', mixedChannelWarningDetails) : ''"
:confirm-text="t('common.confirm')"
:cancel-text="t('common.cancel')"
:danger="true"
@confirm="handleMixedChannelConfirm"
@cancel="handleMixedChannelCancel"
/>
</template>
<script setup lang="ts">
@@ -885,6 +897,7 @@ import { useAuthStore } from '@/stores/auth'
import { adminAPI } from '@/api/admin'
import type { Account, Proxy, AdminGroup } from '@/types'
import BaseDialog from '@/components/common/BaseDialog.vue'
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
import Select from '@/components/common/Select.vue'
import Icon from '@/components/icons/Icon.vue'
import ProxySelector from '@/components/common/ProxySelector.vue'
@@ -951,6 +964,11 @@ const mixedScheduling = ref(false) // For antigravity accounts: enable mixed sch
const tempUnschedEnabled = ref(false)
const tempUnschedRules = ref<TempUnschedRuleForm[]>([])
// Mixed channel warning dialog state
const showMixedChannelWarning = ref(false)
const mixedChannelWarningDetails = ref<{ groupName: string; currentPlatform: string; otherPlatform: string } | null>(null)
const pendingUpdatePayload = ref<Record<string, unknown> | null>(null)
// Quota control state (Anthropic OAuth/SetupToken only)
const windowCostEnabled = ref(false)
const windowCostLimit = ref<number | null>(null)
@@ -1366,8 +1384,8 @@ const handleSubmit = async () => {
if (!props.account) return
submitting.value = true
const updatePayload: Record<string, unknown> = { ...form }
try {
const updatePayload: Record<string, unknown> = { ...form }
// 后端期望 proxy_id: 0 表示清除代理,而不是 null
if (updatePayload.proxy_id === null) {
updatePayload.proxy_id = 0
@@ -1497,9 +1515,47 @@ const handleSubmit = async () => {
emit('updated')
handleClose()
} catch (error: any) {
appStore.showError(error.response?.data?.message || error.response?.data?.detail || t('admin.accounts.failedToUpdate'))
// Handle 409 mixed_channel_warning - show confirmation dialog
if (error.response?.status === 409 && error.response?.data?.error === 'mixed_channel_warning') {
const details = error.response.data.details || {}
mixedChannelWarningDetails.value = {
groupName: details.group_name || 'Unknown',
currentPlatform: details.current_platform || 'Unknown',
otherPlatform: details.other_platform || 'Unknown'
}
pendingUpdatePayload.value = updatePayload
showMixedChannelWarning.value = true
} else {
appStore.showError(error.response?.data?.message || error.response?.data?.detail || t('admin.accounts.failedToUpdate'))
}
} finally {
submitting.value = false
}
}
// Handle mixed channel warning confirmation
const handleMixedChannelConfirm = async () => {
showMixedChannelWarning.value = false
if (pendingUpdatePayload.value && props.account) {
pendingUpdatePayload.value.confirm_mixed_channel_risk = true
submitting.value = true
try {
await adminAPI.accounts.update(props.account.id, pendingUpdatePayload.value)
appStore.showSuccess(t('admin.accounts.accountUpdated'))
emit('updated')
handleClose()
} catch (error: any) {
appStore.showError(error.response?.data?.message || error.response?.data?.detail || t('admin.accounts.failedToUpdate'))
} finally {
submitting.value = false
pendingUpdatePayload.value = null
}
}
}
const handleMixedChannelCancel = () => {
showMixedChannelWarning.value = false
pendingUpdatePayload.value = null
mixedChannelWarningDetails.value = null
}
</script>