mirror of
https://gitee.com/wanwujie/sub2api
synced 2026-04-09 17:44:46 +08:00
Merge release/custom-0.1.83 into release/custom-0.1.84
This commit is contained in:
@@ -77,7 +77,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Model Rate Limit Indicators (Antigravity OAuth Smart Retry) -->
|
||||
<template v-if="activeModelRateLimits.length > 0">
|
||||
<div v-if="activeModelRateLimits.length > 0" class="grid grid-cols-3 gap-1">
|
||||
<div v-for="item in activeModelRateLimits" :key="item.model" class="group relative">
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded bg-purple-100 px-1.5 py-0.5 text-xs font-medium text-purple-700 dark:bg-purple-900/30 dark:text-purple-400"
|
||||
@@ -95,7 +95,7 @@
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Overload Indicator (529) -->
|
||||
<div v-if="isOverloaded" class="group relative">
|
||||
|
||||
@@ -654,6 +654,7 @@ import Select from '@/components/common/Select.vue'
|
||||
import ProxySelector from '@/components/common/ProxySelector.vue'
|
||||
import GroupSelector from '@/components/common/GroupSelector.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import { buildModelMappingObject as buildModelMappingPayload } from '@/composables/useModelWhitelist'
|
||||
|
||||
interface Props {
|
||||
show: boolean
|
||||
@@ -705,7 +706,7 @@ const rateMultiplier = ref(1)
|
||||
const status = ref<'active' | 'inactive'>('active')
|
||||
const groupIds = ref<number[]>([])
|
||||
|
||||
// All models list (combined Anthropic + OpenAI)
|
||||
// All models list (combined Anthropic + OpenAI + Gemini)
|
||||
const allModels = [
|
||||
{ value: 'claude-opus-4-6', label: 'Claude Opus 4.6' },
|
||||
{ value: 'claude-sonnet-4-6', label: 'Claude Sonnet 4.6' },
|
||||
@@ -723,10 +724,15 @@ const allModels = [
|
||||
{ value: 'gpt-5.1-codex', label: 'GPT-5.1 Codex' },
|
||||
{ value: 'gpt-5.1-2025-11-13', label: 'GPT-5.1' },
|
||||
{ value: 'gpt-5.1-codex-mini', label: 'GPT-5.1 Codex Mini' },
|
||||
{ value: 'gpt-5-2025-08-07', label: 'GPT-5' }
|
||||
{ value: 'gpt-5-2025-08-07', label: 'GPT-5' },
|
||||
{ value: 'gemini-2.0-flash', label: 'Gemini 2.0 Flash' },
|
||||
{ value: 'gemini-2.5-flash', label: 'Gemini 2.5 Flash' },
|
||||
{ value: 'gemini-2.5-pro', label: 'Gemini 2.5 Pro' },
|
||||
{ value: 'gemini-3-flash-preview', label: 'Gemini 3 Flash Preview' },
|
||||
{ value: 'gemini-3-pro-preview', label: 'Gemini 3 Pro Preview' }
|
||||
]
|
||||
|
||||
// Preset mappings (combined Anthropic + OpenAI)
|
||||
// Preset mappings (combined Anthropic + OpenAI + Gemini)
|
||||
const presetMappings = [
|
||||
{
|
||||
label: 'Sonnet 4',
|
||||
@@ -785,6 +791,24 @@ const presetMappings = [
|
||||
from: 'gpt-5.1-codex-max',
|
||||
to: 'gpt-5.1-codex',
|
||||
color: 'bg-pink-100 text-pink-700 hover:bg-pink-200 dark:bg-pink-900/30 dark:text-pink-400'
|
||||
},
|
||||
{
|
||||
label: 'Gemini Flash 2.0',
|
||||
from: 'gemini-2.0-flash',
|
||||
to: 'gemini-2.0-flash',
|
||||
color: 'bg-cyan-100 text-cyan-700 hover:bg-cyan-200 dark:bg-cyan-900/30 dark:text-cyan-400'
|
||||
},
|
||||
{
|
||||
label: 'Gemini 2.5 Flash',
|
||||
from: 'gemini-2.5-flash',
|
||||
to: 'gemini-2.5-flash',
|
||||
color: 'bg-teal-100 text-teal-700 hover:bg-teal-200 dark:bg-teal-900/30 dark:text-teal-400'
|
||||
},
|
||||
{
|
||||
label: 'Gemini 2.5 Pro',
|
||||
from: 'gemini-2.5-pro',
|
||||
to: 'gemini-2.5-pro',
|
||||
color: 'bg-sky-100 text-sky-700 hover:bg-sky-200 dark:bg-sky-900/30 dark:text-sky-400'
|
||||
}
|
||||
]
|
||||
|
||||
@@ -874,23 +898,11 @@ const removeErrorCode = (code: number) => {
|
||||
}
|
||||
|
||||
const buildModelMappingObject = (): Record<string, string> | null => {
|
||||
const mapping: Record<string, string> = {}
|
||||
|
||||
if (modelRestrictionMode.value === 'whitelist') {
|
||||
for (const model of allowedModels.value) {
|
||||
mapping[model] = model
|
||||
}
|
||||
} else {
|
||||
for (const m of modelMappings.value) {
|
||||
const from = m.from.trim()
|
||||
const to = m.to.trim()
|
||||
if (from && to) {
|
||||
mapping[from] = to
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(mapping).length > 0 ? mapping : null
|
||||
return buildModelMappingPayload(
|
||||
modelRestrictionMode.value,
|
||||
allowedModels.value,
|
||||
modelMappings.value
|
||||
)
|
||||
}
|
||||
|
||||
const buildUpdatePayload = (): Record<string, unknown> | null => {
|
||||
|
||||
@@ -862,8 +862,8 @@
|
||||
<p class="input-hint">{{ t('admin.accounts.gemini.tier.aiStudioHint') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Model Restriction Section (不适用于 Gemini,Antigravity 已在上层条件排除) -->
|
||||
<div v-if="form.platform !== 'gemini'" class="border-t border-gray-200 pt-4 dark:border-dark-600">
|
||||
<!-- Model Restriction Section (Antigravity 已在上层条件排除) -->
|
||||
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
|
||||
<label class="input-label">{{ t('admin.accounts.modelRestriction') }}</label>
|
||||
|
||||
<!-- Mode Toggle -->
|
||||
@@ -1135,34 +1135,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Gemini 模型说明 -->
|
||||
<div v-if="form.platform === 'gemini'" class="border-t border-gray-200 pt-4 dark:border-dark-600">
|
||||
<div class="rounded-lg bg-blue-50 p-4 dark:bg-blue-900/20">
|
||||
<div class="flex items-start gap-3">
|
||||
<svg
|
||||
class="h-5 w-5 flex-shrink-0 text-blue-600 dark:text-blue-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-blue-800 dark:text-blue-300">
|
||||
{{ t('admin.accounts.gemini.modelPassthrough') }}
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-blue-700 dark:text-blue-400">
|
||||
{{ t('admin.accounts.gemini.modelPassthroughDesc') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Temp Unschedulable Rules -->
|
||||
@@ -1313,9 +1285,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Intercept Warmup Requests (Anthropic only) -->
|
||||
<!-- Intercept Warmup Requests (Anthropic/Antigravity) -->
|
||||
<div
|
||||
v-if="form.platform === 'anthropic'"
|
||||
v-if="form.platform === 'anthropic' || form.platform === 'antigravity'"
|
||||
class="border-t border-gray-200 pt-4 dark:border-dark-600"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
@@ -2000,7 +1972,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"
|
||||
@@ -2032,7 +2004,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'
|
||||
@@ -2170,10 +2149,13 @@ 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 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)
|
||||
|
||||
@@ -2207,6 +2189,13 @@ const geminiSelectedTier = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
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',
|
||||
@@ -2373,8 +2362,8 @@ watch(
|
||||
antigravityModelMappings.value = []
|
||||
antigravityModelRestrictionMode.value = 'mapping'
|
||||
}
|
||||
// Reset Anthropic-specific settings when switching to other platforms
|
||||
if (newPlatform !== 'anthropic') {
|
||||
// Reset Anthropic/Antigravity-specific settings when switching to other platforms
|
||||
if (newPlatform !== 'anthropic' && newPlatform !== 'antigravity') {
|
||||
interceptWarmupRequests.value = false
|
||||
}
|
||||
// Reset OAuth states
|
||||
@@ -2598,6 +2587,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
|
||||
@@ -2655,63 +2743,45 @@ const resetForm = () => {
|
||||
geminiOAuth.resetState()
|
||||
antigravityOAuth.resetState()
|
||||
oauthFlowRef.value?.reset()
|
||||
antigravityMixedChannelConfirmed.value = false
|
||||
clearMixedChannelDialog()
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
antigravityMixedChannelConfirmed.value = false
|
||||
clearMixedChannelDialog()
|
||||
emit('close')
|
||||
}
|
||||
|
||||
// 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 () => {
|
||||
@@ -2721,6 +2791,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
|
||||
}
|
||||
@@ -2756,15 +2832,8 @@ const handleSubmit = async () => {
|
||||
credentials.model_mapping = antigravityModelMapping
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
const extra = mixedScheduling.value ? { mixed_scheduling: true } : undefined
|
||||
await createAccountAndFinish(form.platform, 'apikey', credentials, extra)
|
||||
} catch (error: any) {
|
||||
appStore.showError(error.response?.data?.detail || t('admin.accounts.failedToCreate'))
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
const extra = mixedScheduling.value ? { mixed_scheduling: true } : undefined
|
||||
await createAccountAndFinish(form.platform, 'apikey', credentials, extra)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2867,7 +2936,7 @@ const createAccountAndFinish = async (
|
||||
if (!applyTempUnschedConfig(credentials)) {
|
||||
return
|
||||
}
|
||||
await adminAPI.accounts.create({
|
||||
await doCreateAccount({
|
||||
name: form.name,
|
||||
notes: form.notes,
|
||||
platform,
|
||||
@@ -2882,9 +2951,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 授权码兑换
|
||||
@@ -3044,7 +3110,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',
|
||||
@@ -3059,6 +3125,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++
|
||||
|
||||
@@ -65,8 +65,8 @@
|
||||
<p class="input-hint">{{ t('admin.accounts.leaveEmptyToKeep') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Model Restriction Section (不适用于 Gemini 和 Antigravity) -->
|
||||
<div v-if="account.platform !== 'gemini' && account.platform !== 'antigravity'" class="border-t border-gray-200 pt-4 dark:border-dark-600">
|
||||
<!-- Model Restriction Section (不适用于 Antigravity) -->
|
||||
<div v-if="account.platform !== 'antigravity'" class="border-t border-gray-200 pt-4 dark:border-dark-600">
|
||||
<label class="input-label">{{ t('admin.accounts.modelRestriction') }}</label>
|
||||
|
||||
<!-- Mode Toggle -->
|
||||
@@ -338,34 +338,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Gemini 模型说明 -->
|
||||
<div v-if="account.platform === 'gemini'" class="border-t border-gray-200 pt-4 dark:border-dark-600">
|
||||
<div class="rounded-lg bg-blue-50 p-4 dark:bg-blue-900/20">
|
||||
<div class="flex items-start gap-3">
|
||||
<svg
|
||||
class="h-5 w-5 flex-shrink-0 text-blue-600 dark:text-blue-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-blue-800 dark:text-blue-300">
|
||||
{{ t('admin.accounts.gemini.modelPassthrough') }}
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-blue-700 dark:text-blue-400">
|
||||
{{ t('admin.accounts.gemini.modelPassthroughDesc') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Upstream fields (only for upstream type) -->
|
||||
@@ -630,9 +602,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Intercept Warmup Requests (Anthropic only) -->
|
||||
<!-- Intercept Warmup Requests (Anthropic/Antigravity) -->
|
||||
<div
|
||||
v-if="account?.platform === 'anthropic'"
|
||||
v-if="account?.platform === 'anthropic' || account?.platform === 'antigravity'"
|
||||
class="border-t border-gray-200 pt-4 dark:border-dark-600"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
@@ -1038,7 +1010,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"
|
||||
@@ -1053,7 +1025,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'
|
||||
@@ -1128,10 +1100,13 @@ const antigravityModelMappings = ref<ModelMapping[]>([])
|
||||
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)
|
||||
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)
|
||||
@@ -1184,6 +1159,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: '',
|
||||
@@ -1213,6 +1195,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
|
||||
@@ -1598,18 +1585,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 {
|
||||
await adminAPI.accounts.update(accountID, withAntigravityConfirmFlag(updatePayload))
|
||||
appStore.showSuccess(t('admin.accounts.accountUpdated'))
|
||||
emit('updated')
|
||||
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
|
||||
@@ -1641,7 +1733,6 @@ const handleSubmit = async () => {
|
||||
newCredentials.api_key = currentCredentials.api_key
|
||||
} else {
|
||||
appStore.showError(t('admin.accounts.apiKeyIsRequired'))
|
||||
submitting.value = false
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1661,7 +1752,6 @@ const handleSubmit = async () => {
|
||||
newCredentials.intercept_warmup_requests = true
|
||||
}
|
||||
if (!applyTempUnschedConfig(newCredentials)) {
|
||||
submitting.value = false
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1677,7 +1767,6 @@ const handleSubmit = async () => {
|
||||
}
|
||||
|
||||
if (!applyTempUnschedConfig(newCredentials)) {
|
||||
submitting.value = false
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1693,7 +1782,6 @@ const handleSubmit = async () => {
|
||||
delete newCredentials.intercept_warmup_requests
|
||||
}
|
||||
if (!applyTempUnschedConfig(newCredentials)) {
|
||||
submitting.value = false
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1785,52 +1873,36 @@ const handleSubmit = async () => {
|
||||
updatePayload.extra = newExtra
|
||||
}
|
||||
|
||||
await adminAPI.accounts.update(props.account.id, updatePayload)
|
||||
appStore.showSuccess(t('admin.accounts.accountUpdated'))
|
||||
emit('updated')
|
||||
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 {
|
||||
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 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>
|
||||
|
||||
104
frontend/src/components/common/WechatServiceButton.vue
Normal file
104
frontend/src/components/common/WechatServiceButton.vue
Normal file
@@ -0,0 +1,104 @@
|
||||
<template>
|
||||
<!-- 悬浮按钮 - 使用主题色 -->
|
||||
<button
|
||||
@click="showModal = true"
|
||||
class="fixed bottom-6 right-6 z-50 flex items-center gap-2 rounded-full bg-gradient-to-r from-primary-500 to-primary-600 px-4 py-3 text-white shadow-lg shadow-primary-500/25 transition-all hover:from-primary-600 hover:to-primary-700 hover:shadow-xl hover:shadow-primary-500/30"
|
||||
>
|
||||
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M8.691 2.188C3.891 2.188 0 5.476 0 9.53c0 2.212 1.17 4.203 3.002 5.55a.59.59 0 01.213.665l-.39 1.48c-.019.07-.048.141-.048.213 0 .163.13.295.29.295a.328.328 0 00.186-.059l2.114-1.225a.87.87 0 01.415-.106.807.807 0 01.213.026 10.07 10.07 0 002.696.37c.262 0 .52-.011.776-.028a5.91 5.91 0 01-.193-1.479c0-3.644 3.374-6.6 7.536-6.6.262 0 .52.011.776.028-.628-3.513-4.27-6.472-8.885-6.472zM5.785 5.97a1.1 1.1 0 110 2.2 1.1 1.1 0 010-2.2zm5.813 0a1.1 1.1 0 110 2.2 1.1 1.1 0 010-2.2zm5.192 2.642c-3.703 0-6.71 2.567-6.71 5.73 0 3.163 3.007 5.73 6.71 5.73a7.9 7.9 0 002.126-.288.644.644 0 01.17-.022.69.69 0 01.329.085l1.672.97a.262.262 0 00.147.046c.128 0 .23-.104.23-.233a.403.403 0 00-.038-.168l-.309-1.17a.468.468 0 01.168-.527c1.449-1.065 2.374-2.643 2.374-4.423 0-3.163-3.007-5.73-6.71-5.73h-.159zm-2.434 3.34a.88.88 0 110 1.76.88.88 0 010-1.76zm4.868 0a.88.88 0 110 1.76.88.88 0 010-1.76z"/>
|
||||
</svg>
|
||||
<span class="text-sm font-medium">客服</span>
|
||||
</button>
|
||||
|
||||
<!-- 弹窗 -->
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
<div
|
||||
v-if="showModal"
|
||||
class="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm"
|
||||
@click.self="showModal = false"
|
||||
>
|
||||
<Transition name="scale">
|
||||
<div
|
||||
v-if="showModal"
|
||||
class="relative w-full max-w-sm rounded-2xl bg-white p-6 shadow-2xl dark:bg-dark-700"
|
||||
>
|
||||
<!-- 关闭按钮 -->
|
||||
<button
|
||||
@click="showModal = false"
|
||||
class="absolute right-4 top-4 text-gray-400 transition-colors hover:text-gray-600 dark:text-dark-400 dark:hover:text-dark-200"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- 标题 -->
|
||||
<div class="mb-4 flex items-center gap-3">
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-gradient-to-br from-primary-500 to-primary-600">
|
||||
<svg class="h-6 w-6 text-white" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M8.691 2.188C3.891 2.188 0 5.476 0 9.53c0 2.212 1.17 4.203 3.002 5.55a.59.59 0 01.213.665l-.39 1.48c-.019.07-.048.141-.048.213 0 .163.13.295.29.295a.328.328 0 00.186-.059l2.114-1.225a.87.87 0 01.415-.106.807.807 0 01.213.026 10.07 10.07 0 002.696.37c.262 0 .52-.011.776-.028a5.91 5.91 0 01-.193-1.479c0-3.644 3.374-6.6 7.536-6.6.262 0 .52.011.776.028-.628-3.513-4.27-6.472-8.885-6.472zM5.785 5.97a1.1 1.1 0 110 2.2 1.1 1.1 0 010-2.2zm5.813 0a1.1 1.1 0 110 2.2 1.1 1.1 0 010-2.2zm5.192 2.642c-3.703 0-6.71 2.567-6.71 5.73 0 3.163 3.007 5.73 6.71 5.73a7.9 7.9 0 002.126-.288.644.644 0 01.17-.022.69.69 0 01.329.085l1.672.97a.262.262 0 00.147.046c.128 0 .23-.104.23-.233a.403.403 0 00-.038-.168l-.309-1.17a.468.468 0 01.168-.527c1.449-1.065 2.374-2.643 2.374-4.423 0-3.163-3.007-5.73-6.71-5.73h-.159zm-2.434 3.34a.88.88 0 110 1.76.88.88 0 010-1.76zm4.868 0a.88.88 0 110 1.76.88.88 0 010-1.76z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">联系客服</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-dark-400">扫码添加好友</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 二维码卡片 -->
|
||||
<div class="mb-4 overflow-hidden rounded-xl border border-primary-100 bg-gradient-to-br from-primary-50 to-white p-3 dark:border-primary-800/30 dark:from-primary-900/10 dark:to-dark-800">
|
||||
<img
|
||||
src="/wechat-qr.jpg"
|
||||
alt="微信二维码"
|
||||
class="w-full rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 提示文字 -->
|
||||
<div class="text-center">
|
||||
<p class="mb-2 text-sm font-medium text-primary-600 dark:text-primary-400">
|
||||
微信扫码添加客服
|
||||
</p>
|
||||
<p class="flex items-center justify-center gap-1 text-xs text-gray-500 dark:text-dark-400">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
工作时间:周一至周五 9:00-18:00
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
const showModal = ref(false)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.scale-enter-active,
|
||||
.scale-leave-active {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.scale-enter-from,
|
||||
.scale-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
</style>
|
||||
@@ -121,23 +121,6 @@
|
||||
<Icon name="key" size="sm" />
|
||||
{{ t('nav.apiKeys') }}
|
||||
</router-link>
|
||||
|
||||
<a
|
||||
href="https://github.com/Wei-Shaw/sub2api"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
@click="closeDropdown"
|
||||
class="dropdown-item"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M12 2C6.477 2 2 6.477 2 12c0 4.42 2.865 8.17 6.839 9.49.5.092.682-.217.682-.482 0-.237-.008-.866-.013-1.7-2.782.604-3.369-1.34-3.369-1.34-.454-1.156-1.11-1.464-1.11-1.464-.908-.62.069-.608.069-.608 1.003.07 1.531 1.03 1.531 1.03.892 1.529 2.341 1.087 2.91.831.092-.646.35-1.086.636-1.336-2.22-.253-4.555-1.11-4.555-4.943 0-1.091.39-1.984 1.029-2.683-.103-.253-.446-1.27.098-2.647 0 0 .84-.269 2.75 1.025A9.578 9.578 0 0112 6.836c.85.004 1.705.114 2.504.336 1.909-1.294 2.747-1.025 2.747-1.025.546 1.377.203 2.394.1 2.647.64.699 1.028 1.592 1.028 2.683 0 3.842-2.339 4.687-4.566 4.935.359.309.678.919.678 1.852 0 1.336-.012 2.415-.012 2.743 0 .267.18.578.688.48C19.138 20.167 22 16.418 22 12c0-5.523-4.477-10-10-10z"
|
||||
/>
|
||||
</svg>
|
||||
{{ t('nav.github') }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Contact Support (only show if configured) -->
|
||||
|
||||
Reference in New Issue
Block a user