feat: add mixed-channel precheck API for account-group binding

Add a dedicated CheckMixedChannel endpoint that allows the frontend
to pre-validate mixed channel risk before submitting create/update
requests. This improves UX by showing warnings earlier in the flow
instead of only after form submission.

Backend changes:
- Add CheckMixedChannelRequest struct and CheckMixedChannel handler
- Register POST /check-mixed-channel route
- Expose CheckMixedChannelRisk as public method on AdminService
- Simplify Create/Update 409 responses (remove details/require_confirmation)
- Add comprehensive handler tests and stub methods

Frontend changes:
- Add checkMixedChannelRisk API function and TypeScript types
- Refactor CreateAccountModal to precheck before step transition and submission
- Refactor EditAccountModal to precheck before update submission
- Replace pendingPayload pattern with action-based dialog flow
This commit is contained in:
erio
2026-02-24 17:11:14 +08:00
parent 59898c16c6
commit aaac1aaca9
9 changed files with 576 additions and 129 deletions

View File

@@ -15,7 +15,9 @@ import type {
AccountUsageStatsResponse,
TempUnschedulableStatus,
AdminDataPayload,
AdminDataImportResult
AdminDataImportResult,
CheckMixedChannelRequest,
CheckMixedChannelResponse
} from '@/types'
/**
@@ -133,6 +135,16 @@ export async function update(id: number, updates: UpdateAccountRequest): Promise
return data
}
/**
* Check mixed-channel risk for account-group binding.
*/
export async function checkMixedChannelRisk(
payload: CheckMixedChannelRequest
): Promise<CheckMixedChannelResponse> {
const { data } = await apiClient.post<CheckMixedChannelResponse>('/admin/accounts/check-mixed-channel', payload)
return data
}
/**
* Delete account
* @param id - Account ID
@@ -535,6 +547,7 @@ export const accountsAPI = {
getById,
create,
update,
checkMixedChannelRisk,
delete: deleteAccount,
toggleStatus,
testAccount,

View File

@@ -2157,7 +2157,7 @@
<ConfirmDialog
:show="showMixedChannelWarning"
:title="t('admin.accounts.mixedChannelWarningTitle')"
:message="mixedChannelWarningDetails ? t('admin.accounts.mixedChannelWarning', mixedChannelWarningDetails) : ''"
:message="mixedChannelWarningMessageText"
:confirm-text="t('common.confirm')"
:cancel-text="t('common.cancel')"
:danger="true"
@@ -2189,7 +2189,14 @@ import {
import { useOpenAIOAuth } from '@/composables/useOpenAIOAuth'
import { useGeminiOAuth } from '@/composables/useGeminiOAuth'
import { useAntigravityOAuth } from '@/composables/useAntigravityOAuth'
import type { Proxy, AdminGroup, AccountPlatform, AccountType } from '@/types'
import type {
Proxy,
AdminGroup,
AccountPlatform,
AccountType,
CheckMixedChannelResponse,
CreateAccountRequest
} from '@/types'
import BaseDialog from '@/components/common/BaseDialog.vue'
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
import Icon from '@/components/icons/Icon.vue'
@@ -2338,10 +2345,13 @@ const getTempUnschedRuleKey = createStableObjectKeyResolver<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 mixedChannelWarningDetails = ref<{ groupName: string; currentPlatform: string; otherPlatform: string } | null>(
null
)
const mixedChannelWarningRawMessage = ref('')
const mixedChannelWarningAction = ref<(() => Promise<void>) | null>(null)
const antigravityMixedChannelConfirmed = ref(false)
const showAdvancedOAuth = ref(false)
const showGeminiHelpDialog = ref(false)
@@ -2379,6 +2389,13 @@ const isOpenAIModelRestrictionDisabled = computed(() =>
form.platform === 'openai' && openaiPassthroughEnabled.value
)
const mixedChannelWarningMessageText = computed(() => {
if (mixedChannelWarningDetails.value) {
return t('admin.accounts.mixedChannelWarning', mixedChannelWarningDetails.value)
}
return mixedChannelWarningRawMessage.value
})
const geminiQuotaDocs = {
codeAssist: 'https://developers.google.com/gemini-code-assist/resources/quotas',
aiStudio: 'https://ai.google.dev/pricing',
@@ -2795,6 +2812,105 @@ const splitTempUnschedKeywords = (value: string) => {
.filter((item) => item.length > 0)
}
const needsMixedChannelCheck = (platform: AccountPlatform) => platform === 'antigravity' || platform === 'anthropic'
const buildMixedChannelDetails = (resp?: CheckMixedChannelResponse) => {
const details = resp?.details
if (!details) {
return null
}
return {
groupName: details.group_name || 'Unknown',
currentPlatform: details.current_platform || 'Unknown',
otherPlatform: details.other_platform || 'Unknown'
}
}
const clearMixedChannelDialog = () => {
showMixedChannelWarning.value = false
mixedChannelWarningDetails.value = null
mixedChannelWarningRawMessage.value = ''
mixedChannelWarningAction.value = null
}
const openMixedChannelDialog = (opts: {
response?: CheckMixedChannelResponse
message?: string
onConfirm: () => Promise<void>
}) => {
mixedChannelWarningDetails.value = buildMixedChannelDetails(opts.response)
mixedChannelWarningRawMessage.value =
opts.message || opts.response?.message || t('admin.accounts.failedToCreate')
mixedChannelWarningAction.value = opts.onConfirm
showMixedChannelWarning.value = true
}
const withAntigravityConfirmFlag = (payload: CreateAccountRequest): CreateAccountRequest => {
if (needsMixedChannelCheck(payload.platform) && antigravityMixedChannelConfirmed.value) {
return {
...payload,
confirm_mixed_channel_risk: true
}
}
const cloned = { ...payload }
delete cloned.confirm_mixed_channel_risk
return cloned
}
const ensureAntigravityMixedChannelConfirmed = async (onConfirm: () => Promise<void>): Promise<boolean> => {
if (!needsMixedChannelCheck(form.platform)) {
return true
}
if (antigravityMixedChannelConfirmed.value) {
return true
}
try {
const result = await adminAPI.accounts.checkMixedChannelRisk({
platform: form.platform,
group_ids: form.group_ids
})
if (!result.has_risk) {
return true
}
openMixedChannelDialog({
response: result,
onConfirm: async () => {
antigravityMixedChannelConfirmed.value = true
await onConfirm()
}
})
return false
} catch (error: any) {
appStore.showError(error.response?.data?.message || error.response?.data?.detail || t('admin.accounts.failedToCreate'))
return false
}
}
const submitCreateAccount = async (payload: CreateAccountRequest) => {
submitting.value = true
try {
await adminAPI.accounts.create(withAntigravityConfirmFlag(payload))
appStore.showSuccess(t('admin.accounts.accountCreated'))
emit('created')
handleClose()
} catch (error: any) {
if (error.response?.status === 409 && error.response?.data?.error === 'mixed_channel_warning' && needsMixedChannelCheck(form.platform)) {
openMixedChannelDialog({
message: error.response?.data?.message,
onConfirm: async () => {
antigravityMixedChannelConfirmed.value = true
await submitCreateAccount(payload)
}
})
return
}
appStore.showError(error.response?.data?.message || error.response?.data?.detail || t('admin.accounts.failedToCreate'))
} finally {
submitting.value = false
}
}
// Methods
const resetForm = () => {
step.value = 1
@@ -2856,9 +2972,13 @@ const resetForm = () => {
geminiOAuth.resetState()
antigravityOAuth.resetState()
oauthFlowRef.value?.reset()
antigravityMixedChannelConfirmed.value = false
clearMixedChannelDialog()
}
const handleClose = () => {
antigravityMixedChannelConfirmed.value = false
clearMixedChannelDialog()
emit('close')
}
@@ -2917,56 +3037,34 @@ const buildSoraExtra = (
}
// Helper function to create account with mixed channel warning handling
const doCreateAccount = async (payload: any) => {
const doCreateAccount = async (payload: CreateAccountRequest) => {
const canContinue = await ensureAntigravityMixedChannelConfirmed(async () => {
await submitCreateAccount(payload)
})
if (!canContinue) {
return
}
await submitCreateAccount(payload)
}
// Handle mixed channel warning confirmation
const handleMixedChannelConfirm = async () => {
const action = mixedChannelWarningAction.value
if (!action) {
clearMixedChannelDialog()
return
}
clearMixedChannelDialog()
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'))
}
await action()
} 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
clearMixedChannelDialog()
}
const handleSubmit = async () => {
@@ -2976,6 +3074,12 @@ const handleSubmit = async () => {
appStore.showError(t('admin.accounts.pleaseEnterAccountName'))
return
}
const canContinue = await ensureAntigravityMixedChannelConfirmed(async () => {
step.value = 2
})
if (!canContinue) {
return
}
step.value = 2
return
}
@@ -3132,7 +3236,7 @@ const createAccountAndFinish = async (
if (!applyTempUnschedConfig(credentials)) {
return
}
await adminAPI.accounts.create({
await doCreateAccount({
name: form.name,
notes: form.notes,
platform,
@@ -3147,9 +3251,6 @@ const createAccountAndFinish = async (
expires_at: form.expires_at,
auto_pause_on_expired: autoPauseOnExpired.value
})
appStore.showSuccess(t('admin.accounts.accountCreated'))
emit('created')
handleClose()
}
// OpenAI OAuth 授权码兑换
@@ -3497,7 +3598,7 @@ const handleAntigravityValidateRT = async (refreshTokenInput: string) => {
const accountName = refreshTokens.length > 1 ? `${form.name} #${i + 1}` : form.name
// Note: Antigravity doesn't have buildExtraInfo, so we pass empty extra or rely on credentials
await adminAPI.accounts.create({
const createPayload = withAntigravityConfirmFlag({
name: accountName,
notes: form.notes,
platform: 'antigravity',
@@ -3512,6 +3613,7 @@ const handleAntigravityValidateRT = async (refreshTokenInput: string) => {
expires_at: form.expires_at,
auto_pause_on_expired: autoPauseOnExpired.value
})
await adminAPI.accounts.create(createPayload)
successCount++
} catch (error: any) {
failedCount++

View File

@@ -1139,7 +1139,7 @@
<ConfirmDialog
:show="showMixedChannelWarning"
:title="t('admin.accounts.mixedChannelWarningTitle')"
:message="mixedChannelWarningDetails ? t('admin.accounts.mixedChannelWarning', mixedChannelWarningDetails) : ''"
:message="mixedChannelWarningMessageText"
:confirm-text="t('common.confirm')"
:cancel-text="t('common.cancel')"
:danger="true"
@@ -1154,7 +1154,7 @@ import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { useAuthStore } from '@/stores/auth'
import { adminAPI } from '@/api/admin'
import type { Account, Proxy, AdminGroup } from '@/types'
import type { Account, Proxy, AdminGroup, CheckMixedChannelResponse } from '@/types'
import BaseDialog from '@/components/common/BaseDialog.vue'
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
import Select from '@/components/common/Select.vue'
@@ -1234,10 +1234,13 @@ const getModelMappingKey = createStableObjectKeyResolver<ModelMapping>('edit-mod
const getAntigravityModelMappingKey = createStableObjectKeyResolver<ModelMapping>('edit-antigravity-model-mapping')
const getTempUnschedRuleKey = createStableObjectKeyResolver<TempUnschedRuleForm>('edit-temp-unsched-rule')
// 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)
const mixedChannelWarningDetails = ref<{ groupName: string; currentPlatform: string; otherPlatform: string } | null>(
null
)
const mixedChannelWarningRawMessage = ref('')
const mixedChannelWarningAction = ref<(() => Promise<void>) | null>(null)
const antigravityMixedChannelConfirmed = ref(false)
// Quota control state (Anthropic OAuth/SetupToken only)
const windowCostEnabled = ref(false)
@@ -1298,6 +1301,13 @@ const defaultBaseUrl = computed(() => {
return 'https://api.anthropic.com'
})
const mixedChannelWarningMessageText = computed(() => {
if (mixedChannelWarningDetails.value) {
return t('admin.accounts.mixedChannelWarning', mixedChannelWarningDetails.value)
}
return mixedChannelWarningRawMessage.value
})
const form = reactive({
name: '',
notes: '',
@@ -1327,6 +1337,11 @@ watch(
() => props.account,
(newAccount) => {
if (newAccount) {
antigravityMixedChannelConfirmed.value = false
showMixedChannelWarning.value = false
mixedChannelWarningDetails.value = null
mixedChannelWarningRawMessage.value = ''
mixedChannelWarningAction.value = null
form.name = newAccount.name
form.notes = newAccount.notes || ''
form.proxy_id = newAccount.proxy_id
@@ -1726,18 +1741,123 @@ function toPositiveNumber(value: unknown) {
return Math.trunc(num)
}
const needsMixedChannelCheck = () => props.account?.platform === 'antigravity' || props.account?.platform === 'anthropic'
const buildMixedChannelDetails = (resp?: CheckMixedChannelResponse) => {
const details = resp?.details
if (!details) {
return null
}
return {
groupName: details.group_name || 'Unknown',
currentPlatform: details.current_platform || 'Unknown',
otherPlatform: details.other_platform || 'Unknown'
}
}
const clearMixedChannelDialog = () => {
showMixedChannelWarning.value = false
mixedChannelWarningDetails.value = null
mixedChannelWarningRawMessage.value = ''
mixedChannelWarningAction.value = null
}
const openMixedChannelDialog = (opts: {
response?: CheckMixedChannelResponse
message?: string
onConfirm: () => Promise<void>
}) => {
mixedChannelWarningDetails.value = buildMixedChannelDetails(opts.response)
mixedChannelWarningRawMessage.value =
opts.message || opts.response?.message || t('admin.accounts.failedToUpdate')
mixedChannelWarningAction.value = opts.onConfirm
showMixedChannelWarning.value = true
}
const withAntigravityConfirmFlag = (payload: Record<string, unknown>) => {
if (needsMixedChannelCheck() && antigravityMixedChannelConfirmed.value) {
return {
...payload,
confirm_mixed_channel_risk: true
}
}
const cloned = { ...payload }
delete cloned.confirm_mixed_channel_risk
return cloned
}
const ensureAntigravityMixedChannelConfirmed = async (onConfirm: () => Promise<void>): Promise<boolean> => {
if (!needsMixedChannelCheck()) {
return true
}
if (antigravityMixedChannelConfirmed.value) {
return true
}
if (!props.account) {
return false
}
try {
const result = await adminAPI.accounts.checkMixedChannelRisk({
platform: props.account.platform,
group_ids: form.group_ids,
account_id: props.account.id
})
if (!result.has_risk) {
return true
}
openMixedChannelDialog({
response: result,
onConfirm: async () => {
antigravityMixedChannelConfirmed.value = true
await onConfirm()
}
})
return false
} catch (error: any) {
appStore.showError(error.response?.data?.message || error.response?.data?.detail || t('admin.accounts.failedToUpdate'))
return false
}
}
const formatDateTimeLocal = formatDateTimeLocalInput
const parseDateTimeLocal = parseDateTimeLocalInput
// Methods
const handleClose = () => {
antigravityMixedChannelConfirmed.value = false
clearMixedChannelDialog()
emit('close')
}
const submitUpdateAccount = async (accountID: number, updatePayload: Record<string, unknown>) => {
submitting.value = true
try {
const updatedAccount = await adminAPI.accounts.update(accountID, withAntigravityConfirmFlag(updatePayload))
appStore.showSuccess(t('admin.accounts.accountUpdated'))
emit('updated', updatedAccount)
handleClose()
} catch (error: any) {
if (error.response?.status === 409 && error.response?.data?.error === 'mixed_channel_warning' && needsMixedChannelCheck()) {
openMixedChannelDialog({
message: error.response?.data?.message,
onConfirm: async () => {
antigravityMixedChannelConfirmed.value = true
await submitUpdateAccount(accountID, updatePayload)
}
})
return
}
appStore.showError(error.response?.data?.message || error.response?.data?.detail || t('admin.accounts.failedToUpdate'))
} finally {
submitting.value = false
}
}
const handleSubmit = async () => {
if (!props.account) return
const accountID = props.account.id
submitting.value = true
const updatePayload: Record<string, unknown> = { ...form }
try {
// 后端期望 proxy_id: 0 表示清除代理,而不是 null
@@ -1769,7 +1889,6 @@ const handleSubmit = async () => {
newCredentials.api_key = currentCredentials.api_key
} else {
appStore.showError(t('admin.accounts.apiKeyIsRequired'))
submitting.value = false
return
}
@@ -1792,7 +1911,6 @@ const handleSubmit = async () => {
// Add intercept warmup requests setting
applyInterceptWarmup(newCredentials, interceptWarmupRequests.value, 'edit')
if (!applyTempUnschedConfig(newCredentials)) {
submitting.value = false
return
}
@@ -1811,7 +1929,6 @@ const handleSubmit = async () => {
applyInterceptWarmup(newCredentials, interceptWarmupRequests.value, 'edit')
if (!applyTempUnschedConfig(newCredentials)) {
submitting.value = false
return
}
@@ -1823,7 +1940,6 @@ const handleSubmit = async () => {
applyInterceptWarmup(newCredentials, interceptWarmupRequests.value, 'edit')
if (!applyTempUnschedConfig(newCredentials)) {
submitting.value = false
return
}
@@ -1953,52 +2069,36 @@ const handleSubmit = async () => {
updatePayload.extra = newExtra
}
const updatedAccount = await adminAPI.accounts.update(props.account.id, updatePayload)
appStore.showSuccess(t('admin.accounts.accountUpdated'))
emit('updated', updatedAccount)
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'
}
pendingUpdatePayload.value = updatePayload
showMixedChannelWarning.value = true
} else {
appStore.showError(error.response?.data?.message || error.response?.data?.detail || t('admin.accounts.failedToUpdate'))
const canContinue = await ensureAntigravityMixedChannelConfirmed(async () => {
await submitUpdateAccount(accountID, updatePayload)
})
if (!canContinue) {
return
}
} finally {
submitting.value = false
await submitUpdateAccount(accountID, updatePayload)
} catch (error: any) {
appStore.showError(error.response?.data?.message || error.response?.data?.detail || t('admin.accounts.failedToUpdate'))
}
}
// 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 {
const updatedAccount = await adminAPI.accounts.update(props.account.id, pendingUpdatePayload.value)
appStore.showSuccess(t('admin.accounts.accountUpdated'))
emit('updated', updatedAccount)
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 action = mixedChannelWarningAction.value
if (!action) {
clearMixedChannelDialog()
return
}
clearMixedChannelDialog()
submitting.value = true
try {
await action()
} finally {
submitting.value = false
}
}
const handleMixedChannelCancel = () => {
showMixedChannelWarning.value = false
pendingUpdatePayload.value = null
mixedChannelWarningDetails.value = null
clearMixedChannelDialog()
}
</script>

View File

@@ -766,6 +766,26 @@ export interface UpdateAccountRequest {
confirm_mixed_channel_risk?: boolean
}
export interface CheckMixedChannelRequest {
platform: AccountPlatform
group_ids: number[]
account_id?: number
}
export interface MixedChannelWarningDetails {
group_id: number
group_name: string
current_platform: string
other_platform: string
}
export interface CheckMixedChannelResponse {
has_risk: boolean
error?: string
message?: string
details?: MixedChannelWarningDetails
}
export interface CreateProxyRequest {
name: string
protocol: ProxyProtocol