fix(frontend): 修复 Grok OAuth 建号缺上游配置入口与 JSON 导入渲染崩溃

- CreateAccountModal 为 Grok OAuth 新增自定义上游地址与请求头覆写区块,
  经统一校验注入授权码兑换/RT 批量/SSO 批量三条创建路径(授权码路径
  在兑换前校验,避免烧掉一次性 code)
- JSON 导入示例文案内嵌花括号被 vue-i18n 消息编译器当占位符解析,
  渲染时抛 Invalid token in placeholder 炸掉面板:示例移出 i18n 硬编码;
  同类修复 settings 的死键 claudeOAuthSystemPromptBlocksPlaceholder(删)
  与 Hint 中被吞掉的 {billing_header}(字面量转义)
- 新增 localesMessageCompile 守卫测试:预编译中英全部文案,拦截非法占位符
- 面板打开后 scrollIntoView + 聚焦,避免在弹窗滚动容器视野外展开
- 删除「填入模板」按钮及三平台模板常量(产品决策:无意义)
- 抽取 HeaderOverrideEditor 共享组件,消除 Create/Edit/BulkEdit 三处重复
This commit is contained in:
shaw
2026-07-15 22:05:59 +08:00
parent be6cd1250c
commit eb2b8632de
13 changed files with 298 additions and 371 deletions
@@ -538,75 +538,10 @@
{{ t('admin.accounts.headerOverride.bulkReplaceHint') }}
</p>
<div v-if="headerOverrideRows.length > 0" class="space-y-2">
<div
v-for="(row, index) in headerOverrideRows"
:key="getHeaderOverrideRowKey(row)"
class="flex items-center gap-2"
>
<input
v-model="row.name"
type="text"
class="input flex-1"
:placeholder="t('admin.accounts.headerOverride.namePlaceholder')"
/>
<input
v-model="row.value"
type="text"
class="input flex-1"
:placeholder="t('admin.accounts.headerOverride.valuePlaceholder')"
/>
<button
type="button"
class="rounded-lg p-2 text-red-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
@click="removeHeaderOverrideRow(index)"
>
<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 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</div>
</div>
<button
type="button"
class="w-full rounded-lg border-2 border-dashed border-gray-300 px-4 py-2 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"
@click="addHeaderOverrideRow"
>
<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.headerOverride.addRow') }}
</button>
<div class="flex flex-wrap gap-2">
<button
v-if="headerOverrideTemplatePlatform"
type="button"
class="rounded-lg bg-primary-50 px-3 py-1 text-xs text-primary-700 transition-colors hover:bg-primary-100 dark:bg-primary-900/30 dark:text-primary-400 dark:hover:bg-primary-900/50"
@click="fillHeaderOverrideTemplate"
>
+ {{ t('admin.accounts.headerOverride.fillTemplate') }}
</button>
<HeaderOverrideJsonTools
:rows="headerOverrideRows"
@update:rows="headerOverrideRows = $event"
/>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.headerOverride.emptyValueHint') }}
</p>
<HeaderOverrideEditor
:rows="headerOverrideRows"
@update:rows="headerOverrideRows = $event"
/>
</div>
<p v-else class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.headerOverride.bulkDisableHint') }}
@@ -1277,17 +1212,15 @@ import {
buildModelMappingObject as buildModelMappingPayload,
getPresetMappingsByPlatform
} from '@/composables/useModelWhitelist'
import HeaderOverrideJsonTools from '@/components/account/HeaderOverrideJsonTools.vue'
import HeaderOverrideEditor from '@/components/account/HeaderOverrideEditor.vue'
import {
buildHeaderOverridesObject,
getHeaderOverrideTemplate,
isHeaderOverrideCapable,
validateHeaderOverrideRows,
HEADER_OVERRIDE_ENABLED_CREDENTIAL_KEY,
HEADER_OVERRIDES_CREDENTIAL_KEY,
type HeaderOverrideRow
} from '@/components/account/credentialsBuilder'
import { createStableObjectKeyResolver } from '@/utils/stableObjectKey'
import {
OPENAI_WS_MODE_CTX_POOL,
OPENAI_WS_MODE_OFF,
@@ -1436,37 +1369,6 @@ const customErrorCodeInput = ref<number | null>(null)
const interceptWarmupRequests = ref(false)
const headerOverrideEnabled = ref(false)
const headerOverrideRows = ref<HeaderOverrideRow[]>([])
const getHeaderOverrideRowKey = createStableObjectKeyResolver<HeaderOverrideRow>('bulk-header-override-row')
const addHeaderOverrideRow = () => {
headerOverrideRows.value.push({ name: '', value: '' })
}
const removeHeaderOverrideRow = (index: number) => {
headerOverrideRows.value.splice(index, 1)
}
// 模板仅在所选账号平台唯一时可用:混合 anthropic+openai 选择无法确定用哪套模板,
// 误填会把另一平台的专有头写进所有所选账号
const headerOverrideTemplatePlatform = computed(() => {
return targetSelectedPlatforms.value.length === 1 ? targetSelectedPlatforms.value[0] : null
})
// 模板按钮:填入所选平台的标准客户端请求头名称(值留空),跳过已存在的同名行
const fillHeaderOverrideTemplate = () => {
const platform = headerOverrideTemplatePlatform.value
if (!platform) return
const existing = new Set(
headerOverrideRows.value.map((row) => row.name.trim().toLowerCase()).filter(Boolean)
)
const rows = headerOverrideRows.value.filter((row) => row.name.trim() || row.value.trim())
for (const row of getHeaderOverrideTemplate(platform)) {
if (!existing.has(row.name)) {
rows.push(row)
}
}
headerOverrideRows.value = rows
}
const proxyId = ref<number | null>(null)
const concurrency = ref(1)
const loadFactor = ref<number | null>(null)
@@ -1533,74 +1533,10 @@
</p>
</div>
<div v-if="headerOverrideRows.length > 0" class="space-y-2">
<div
v-for="(row, index) in headerOverrideRows"
:key="getHeaderOverrideRowKey(row)"
class="flex items-center gap-2"
>
<input
v-model="row.name"
type="text"
class="input flex-1"
:placeholder="t('admin.accounts.headerOverride.namePlaceholder')"
/>
<input
v-model="row.value"
type="text"
class="input flex-1"
:placeholder="t('admin.accounts.headerOverride.valuePlaceholder')"
/>
<button
type="button"
@click="removeHeaderOverrideRow(index)"
class="rounded-lg p-2 text-red-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
>
<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 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</div>
</div>
<button
type="button"
@click="addHeaderOverrideRow"
class="w-full rounded-lg border-2 border-dashed border-gray-300 px-4 py-2 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.headerOverride.addRow') }}
</button>
<div class="flex flex-wrap gap-2">
<button
type="button"
@click="fillHeaderOverrideTemplate"
class="rounded-lg bg-primary-50 px-3 py-1 text-xs text-primary-700 transition-colors hover:bg-primary-100 dark:bg-primary-900/30 dark:text-primary-400 dark:hover:bg-primary-900/50"
>
+ {{ t('admin.accounts.headerOverride.fillTemplate') }}
</button>
<HeaderOverrideJsonTools
:rows="headerOverrideRows"
@update:rows="headerOverrideRows = $event"
/>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.headerOverride.emptyValueHint') }}
</p>
<HeaderOverrideEditor
:rows="headerOverrideRows"
@update:rows="headerOverrideRows = $event"
/>
</div>
</div>
@@ -1968,6 +1904,90 @@
/>
</div>
<!-- Grok OAuth Custom Upstream URL (仅改写转发端点OAuth 授权/刷新不受影响) -->
<div
v-if="form.platform === 'grok' && isOAuthFlow"
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.grokCustomBaseUrl.title') }}</label>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.grokCustomBaseUrl.hint') }}
</p>
</div>
<button
type="button"
data-testid="grok-custom-base-url-toggle"
@click="grokOAuthCustomBaseUrlEnabled = !grokOAuthCustomBaseUrlEnabled"
: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',
grokOAuthCustomBaseUrlEnabled ? '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',
grokOAuthCustomBaseUrlEnabled ? 'translate-x-5' : 'translate-x-0'
]"
/>
</button>
</div>
<div v-if="grokOAuthCustomBaseUrlEnabled">
<input
v-model="grokOAuthBaseUrl"
type="text"
class="input"
data-testid="grok-custom-base-url-input"
:placeholder="t('admin.accounts.grokCustomBaseUrl.placeholder')"
/>
</div>
</div>
<!-- Grok OAuth Header Override (OAuth 类型没有 apikey 容器需要独立区域) -->
<div
v-if="form.platform === 'grok' && isOAuthFlow"
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.headerOverride.title') }}</label>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.headerOverride.hint') }}
</p>
</div>
<button
type="button"
@click="headerOverrideEnabled = !headerOverrideEnabled"
: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',
headerOverrideEnabled ? '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',
headerOverrideEnabled ? 'translate-x-5' : 'translate-x-0'
]"
/>
</button>
</div>
<div v-if="headerOverrideEnabled" 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">
<Icon name="exclamationCircle" size="sm" class="mr-1 inline" :stroke-width="2" />
{{ t('admin.accounts.headerOverride.info') }}
</p>
</div>
<HeaderOverrideEditor
:rows="headerOverrideRows"
@update:rows="headerOverrideRows = $event"
/>
</div>
</div>
<!-- OpenAI OAuth Model Mapping (OAuth 类型没有 apikey 容器需要独立的模型映射区域) -->
<div
v-if="(form.platform === 'openai' || form.platform === 'grok') && isOAuthFlow"
@@ -3499,12 +3519,11 @@ import ProxyAdBanner from '@/components/common/ProxyAdBanner.vue'
import GroupSelector from '@/components/common/GroupSelector.vue'
import ModelWhitelistSelector from '@/components/account/ModelWhitelistSelector.vue'
import QuotaLimitCard from '@/components/account/QuotaLimitCard.vue'
import HeaderOverrideJsonTools from '@/components/account/HeaderOverrideJsonTools.vue'
import HeaderOverrideEditor from '@/components/account/HeaderOverrideEditor.vue'
import {
applyAntigravityProjectID,
applyHeaderOverride,
applyInterceptWarmup,
getHeaderOverrideTemplate,
isHeaderOverrideCapable,
validateHeaderOverrideRows,
type HeaderOverrideRow
@@ -3695,26 +3714,40 @@ const customErrorCodeInput = ref<number | null>(null)
const headerOverrideEnabled = ref(false)
const headerOverrideRows = ref<HeaderOverrideRow[]>([])
const addHeaderOverrideRow = () => {
headerOverrideRows.value.push({ name: '', value: '' })
}
// Grok OAuth:自定义上游地址(base_url 仅改写转发端点,OAuth 授权/刷新不受影响)
const grokOAuthCustomBaseUrlEnabled = ref(false)
const grokOAuthBaseUrl = ref('')
const removeHeaderOverrideRow = (index: number) => {
headerOverrideRows.value.splice(index, 1)
}
// 模板按钮:填入标准客户端请求头名称(值留空),跳过已存在的同名行
const fillHeaderOverrideTemplate = () => {
const existing = new Set(
headerOverrideRows.value.map((row) => row.name.trim().toLowerCase()).filter(Boolean)
)
const rows = headerOverrideRows.value.filter((row) => row.name.trim() || row.value.trim())
for (const row of getHeaderOverrideTemplate(form.platform)) {
if (!existing.has(row.name)) {
rows.push(row)
// Grok OAuth 三条创建路径(授权码/RT 批量/SSO 批量)共用的前置校验。
// 授权码路径必须在兑换 code 之前调用,避免校验失败时白白消耗一次性授权码。
const validateGrokOAuthUpstreamConfig = (): boolean => {
if (grokOAuthCustomBaseUrlEnabled.value) {
const trimmed = grokOAuthBaseUrl.value.trim()
if (!trimmed) {
appStore.showError(t('admin.accounts.grokCustomBaseUrl.required'))
return false
}
if (!/^https?:\/\//i.test(trimmed)) {
appStore.showError(t('admin.accounts.grokCustomBaseUrl.invalid'))
return false
}
}
headerOverrideRows.value = rows
if (headerOverrideEnabled.value) {
const headerError = validateHeaderOverrideRows(headerOverrideRows.value)
if (headerError) {
appStore.showError(t(`admin.accounts.headerOverride.${headerError}`))
return false
}
}
return true
}
// 把已通过校验的自定义上游地址与请求头覆写写入 credentials
const applyGrokOAuthUpstreamConfig = (credentials: Record<string, unknown>) => {
if (grokOAuthCustomBaseUrlEnabled.value) {
credentials.base_url = grokOAuthBaseUrl.value.trim()
}
applyHeaderOverride(credentials, headerOverrideEnabled.value, headerOverrideRows.value, 'create')
}
const interceptWarmupRequests = ref(false)
const autoPauseOnExpired = ref(true)
@@ -3780,7 +3813,6 @@ const vertexServiceAccountDragActive = ref(false)
const tempUnschedEnabled = ref(false)
const tempUnschedRules = ref<TempUnschedRuleForm[]>([])
const getModelMappingKey = createStableObjectKeyResolver<ModelMapping>('create-model-mapping')
const getHeaderOverrideRowKey = createStableObjectKeyResolver<HeaderOverrideRow>('create-header-override-row')
const getOpenAICompactModelMappingKey = createStableObjectKeyResolver<ModelMapping>('create-openai-compact-model-mapping')
const getAntigravityModelMappingKey = createStableObjectKeyResolver<ModelMapping>('create-antigravity-model-mapping')
const getTempUnschedRuleKey = createStableObjectKeyResolver<TempUnschedRuleForm>('create-temp-unsched-rule')
@@ -4184,10 +4216,12 @@ watch(
anthropicAPIKeyAuthScheme.value = 'x_api_key'
webSearchEmulationMode.value = 'default'
}
// 请求头覆写为平台相关配置(模板/常用头集合不同),切换平台时清空,
// 避免上一平台的模板行被提交到新平台账号
// 请求头覆写为平台相关配置(常用头集合不同),切换平台时清空,
// 避免上一平台的配置行被提交到新平台账号
headerOverrideEnabled.value = false
headerOverrideRows.value = []
grokOAuthCustomBaseUrlEnabled.value = false
grokOAuthBaseUrl.value = ''
// Reset OAuth states
oauth.resetState()
openaiOAuth.resetState()
@@ -4579,6 +4613,8 @@ const resetForm = () => {
customErrorCodeInput.value = null
headerOverrideEnabled.value = false
headerOverrideRows.value = []
grokOAuthCustomBaseUrlEnabled.value = false
grokOAuthBaseUrl.value = ''
interceptWarmupRequests.value = false
autoPauseOnExpired.value = true
openaiPassthroughEnabled.value = false
@@ -5194,6 +5230,7 @@ const handleGrokValidateRT = async (refreshTokenInput: string) => {
grokOAuth.error.value = t('admin.accounts.oauth.grok.pleaseEnterRefreshToken')
return
}
if (!validateGrokOAuthUpstreamConfig()) return
grokOAuth.loading.value = true
grokOAuth.error.value = ''
@@ -5214,6 +5251,7 @@ const handleGrokValidateRT = async (refreshTokenInput: string) => {
}
const credentials = grokOAuth.buildCredentials(tokenInfo)
applyGrokOAuthUpstreamConfig(credentials)
const extra = grokOAuth.buildExtraInfo(tokenInfo)
const accountName = refreshTokens.length > 1 ? `${form.name || tokenInfo.email || 'Grok OAuth Account'} #${i + 1}` : (form.name || tokenInfo.email || 'Grok OAuth Account')
@@ -5277,11 +5315,13 @@ const handleGrokImportSSO = async (ssoInput: string) => {
.map((token) => token.trim())
.filter((token) => token)
if (ssoTokens.length === 0) return
if (!validateGrokOAuthUpstreamConfig()) return
grokOAuth.loading.value = true
grokOAuth.error.value = ''
const credentials: Record<string, unknown> = {}
applyGrokOAuthUpstreamConfig(credentials)
const modelMapping = buildModelMappingObject(modelRestrictionMode.value, allowedModels.value, modelMappings.value)
if (modelMapping) {
credentials.model_mapping = modelMapping
@@ -5904,6 +5944,7 @@ const handleAntigravityExchange = async (authCode: string) => {
// Grok OAuth 授权码兑换
const handleGrokExchange = async (authCode: string) => {
if (!authCode.trim() || !grokOAuth.sessionId.value) return
if (!validateGrokOAuthUpstreamConfig()) return
grokOAuth.loading.value = true
grokOAuth.error.value = ''
@@ -5926,6 +5967,7 @@ const handleGrokExchange = async (authCode: string) => {
if (!tokenInfo) return
const credentials = grokOAuth.buildCredentials(tokenInfo)
applyGrokOAuthUpstreamConfig(credentials)
const extra = grokOAuth.buildExtraInfo(tokenInfo)
await createAccountAndFinish('grok', 'oauth', credentials, extra)
} catch (error: any) {
@@ -497,74 +497,10 @@
</p>
</div>
<div v-if="headerOverrideRows.length > 0" class="space-y-2">
<div
v-for="(row, index) in headerOverrideRows"
:key="getHeaderOverrideRowKey(row)"
class="flex items-center gap-2"
>
<input
v-model="row.name"
type="text"
class="input flex-1"
:placeholder="t('admin.accounts.headerOverride.namePlaceholder')"
/>
<input
v-model="row.value"
type="text"
class="input flex-1"
:placeholder="t('admin.accounts.headerOverride.valuePlaceholder')"
/>
<button
type="button"
@click="removeHeaderOverrideRow(index)"
class="rounded-lg p-2 text-red-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
>
<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 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</div>
</div>
<button
type="button"
@click="addHeaderOverrideRow"
class="w-full rounded-lg border-2 border-dashed border-gray-300 px-4 py-2 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.headerOverride.addRow') }}
</button>
<div class="flex flex-wrap gap-2">
<button
type="button"
@click="fillHeaderOverrideTemplate"
class="rounded-lg bg-primary-50 px-3 py-1 text-xs text-primary-700 transition-colors hover:bg-primary-100 dark:bg-primary-900/30 dark:text-primary-400 dark:hover:bg-primary-900/50"
>
+ {{ t('admin.accounts.headerOverride.fillTemplate') }}
</button>
<HeaderOverrideJsonTools
:rows="headerOverrideRows"
@update:rows="headerOverrideRows = $event"
/>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.headerOverride.emptyValueHint') }}
</p>
<HeaderOverrideEditor
:rows="headerOverrideRows"
@update:rows="headerOverrideRows = $event"
/>
</div>
</div>
@@ -2630,7 +2566,7 @@ import ProxyAdBanner from '@/components/common/ProxyAdBanner.vue'
import GroupSelector from '@/components/common/GroupSelector.vue'
import ModelWhitelistSelector from '@/components/account/ModelWhitelistSelector.vue'
import QuotaLimitCard from '@/components/account/QuotaLimitCard.vue'
import HeaderOverrideJsonTools from '@/components/account/HeaderOverrideJsonTools.vue'
import HeaderOverrideEditor from '@/components/account/HeaderOverrideEditor.vue'
import {
applyAntigravityProjectID,
applyHeaderOverride,
@@ -2638,7 +2574,6 @@ import {
applyPlanType,
buildPlanTypeOptions,
readPlanType,
getHeaderOverrideTemplate,
isCustomGrokBaseUrl,
isHeaderOverrideCapable,
splitHeaderOverridesObject,
@@ -2788,27 +2723,6 @@ const headerOverrideCapable = computed(
const grokOAuthCustomBaseUrlEnabled = ref(false)
const grokOAuthBaseUrl = ref('')
const addHeaderOverrideRow = () => {
headerOverrideRows.value.push({ name: '', value: '' })
}
const removeHeaderOverrideRow = (index: number) => {
headerOverrideRows.value.splice(index, 1)
}
// 模板按钮:填入标准客户端请求头名称(值留空),跳过已存在的同名行
const fillHeaderOverrideTemplate = () => {
const existing = new Set(
headerOverrideRows.value.map((row) => row.name.trim().toLowerCase()).filter(Boolean)
)
const rows = headerOverrideRows.value.filter((row) => row.name.trim() || row.value.trim())
for (const row of getHeaderOverrideTemplate(props.account?.platform || '')) {
if (!existing.has(row.name)) {
rows.push(row)
}
}
headerOverrideRows.value = rows
}
const interceptWarmupRequests = ref(false)
const autoPauseOnExpired = ref(false)
const autoPause5hThreshold = ref<number | null>(null)
@@ -2825,7 +2739,6 @@ const isSyncingAntigravityUpstream = ref(false)
const tempUnschedEnabled = ref(false)
const tempUnschedRules = ref<TempUnschedRuleForm[]>([])
const getModelMappingKey = createStableObjectKeyResolver<ModelMapping>('edit-model-mapping')
const getHeaderOverrideRowKey = createStableObjectKeyResolver<HeaderOverrideRow>('edit-header-override-row')
const getOpenAICompactModelMappingKey = createStableObjectKeyResolver<ModelMapping>('edit-openai-compact-model-mapping')
const getAntigravityModelMappingKey = createStableObjectKeyResolver<ModelMapping>('edit-antigravity-model-mapping')
const getTempUnschedRuleKey = createStableObjectKeyResolver<TempUnschedRuleForm>('edit-temp-unsched-rule')
@@ -0,0 +1,84 @@
<template>
<div v-if="rows.length > 0" class="space-y-2">
<div
v-for="(row, index) in rows"
:key="getHeaderOverrideRowKey(row)"
class="flex items-center gap-2"
>
<input
v-model="row.name"
type="text"
class="input flex-1"
:placeholder="t('admin.accounts.headerOverride.namePlaceholder')"
/>
<input
v-model="row.value"
type="text"
class="input flex-1"
:placeholder="t('admin.accounts.headerOverride.valuePlaceholder')"
/>
<button
type="button"
class="rounded-lg p-2 text-red-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
@click="removeRow(index)"
>
<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 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</div>
</div>
<button
type="button"
class="w-full rounded-lg border-2 border-dashed border-gray-300 px-4 py-2 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"
@click="addRow"
>
<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.headerOverride.addRow') }}
</button>
<div class="flex flex-wrap gap-2">
<HeaderOverrideJsonTools :rows="rows" @update:rows="emit('update:rows', $event)" />
</div>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.headerOverride.emptyValueHint') }}
</p>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { createStableObjectKeyResolver } from '@/utils/stableObjectKey'
import HeaderOverrideJsonTools from './HeaderOverrideJsonTools.vue'
import type { HeaderOverrideRow } from './credentialsBuilder'
const props = defineProps<{
rows: HeaderOverrideRow[]
}>()
const emit = defineEmits<{
(e: 'update:rows', rows: HeaderOverrideRow[]): void
}>()
const { t } = useI18n()
const getHeaderOverrideRowKey = createStableObjectKeyResolver<HeaderOverrideRow>(
'header-override-row'
)
const addRow = () => {
emit('update:rows', [...props.rows, { name: '', value: '' }])
}
const removeRow = (index: number) => {
emit('update:rows', props.rows.filter((_, i) => i !== index))
}
</script>
@@ -15,12 +15,13 @@
{{ t('admin.accounts.headerOverride.copyJson') }}
</button>
<div v-if="showImportPanel" class="w-full space-y-2">
<div v-if="showImportPanel" ref="importPanelRef" class="w-full space-y-2">
<textarea
ref="importTextareaRef"
v-model="importText"
rows="5"
class="input font-mono text-xs"
:placeholder="t('admin.accounts.headerOverride.importJsonPlaceholder')"
:placeholder="IMPORT_JSON_PLACEHOLDER"
></textarea>
<div class="flex gap-2">
<button
@@ -45,7 +46,7 @@
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { computed, nextTick, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { useClipboard } from '@/composables/useClipboard'
@@ -67,13 +68,25 @@ const { t } = useI18n()
const appStore = useAppStore()
const { copyToClipboard } = useClipboard()
// JSON 示例语言中立,且花括号会被 vue-i18n 消息编译器当作插值占位符解析
// 导致渲染时抛 SyntaxError,因此不走 i18n。
const IMPORT_JSON_PLACEHOLDER = '{"user-agent": "my-client/1.0", "x-relay-token": "..."}'
const showImportPanel = ref(false)
const importText = ref('')
const importPanelRef = ref<HTMLElement | null>(null)
const importTextareaRef = ref<HTMLTextAreaElement | null>(null)
const hasNamedRows = computed(() => props.rows.some((row) => row.name.trim()))
const toggleImportPanel = () => {
// 面板在弹窗滚动容器(.modal-body)内向下展开,可能落在当前视野之外,
// 打开后必须主动滚入视野,否则看起来像点击无效。
const toggleImportPanel = async () => {
showImportPanel.value = !showImportPanel.value
if (!showImportPanel.value) return
await nextTick()
importPanelRef.value?.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
importTextareaRef.value?.focus({ preventScroll: true })
}
const closeImportPanel = () => {
@@ -16,4 +16,16 @@ describe('CreateAccountModal Grok account types', () => {
expect(source).toContain("form.platform === 'grok'")
expect(source).toContain("? 'xai-...'")
})
it('exposes custom upstream URL and header override for the OAuth create flow', () => {
expect(source).toContain('data-testid="grok-custom-base-url-toggle"')
expect(source).toContain('data-testid="grok-custom-base-url-input"')
expect(source).toContain('form.platform === \'grok\' && isOAuthFlow')
})
it('validates and applies upstream config on all three Grok OAuth create paths', () => {
// 授权码兑换 / RT 批量 / SSO 批量 3 处调用(定义为箭头函数,不计入)
expect(source.match(/validateGrokOAuthUpstreamConfig\(\)/g)?.length).toBe(3)
expect(source.match(/applyGrokOAuthUpstreamConfig\(credentials\)/g)?.length).toBe(3)
})
})
@@ -9,7 +9,6 @@ import {
applyPlanType,
buildHeaderOverridesObject,
buildPlanTypeOptions,
getHeaderOverrideTemplate,
isCustomGrokBaseUrl,
isHeaderOverrideCapable,
parseHeaderOverridesJson,
@@ -265,39 +264,6 @@ describe('buildHeaderOverridesObject / splitHeaderOverridesObject', () => {
})
})
describe('getHeaderOverrideTemplate', () => {
it('returns Claude Code CLI headers with empty values for anthropic', () => {
const rows = getHeaderOverrideTemplate('anthropic')
expect(rows.every((r) => r.value === '')).toBe(true)
const names = rows.map((r) => r.name)
expect(names).toContain('user-agent')
expect(names).toContain('x-app')
expect(names).toContain('anthropic-beta')
expect(names).toContain('x-stainless-lang')
expect(validateHeaderOverrideRows(rows)).toBeNull()
})
it('returns Codex CLI headers with empty values for openai', () => {
const rows = getHeaderOverrideTemplate('openai')
expect(rows.every((r) => r.value === '')).toBe(true)
const names = rows.map((r) => r.name)
expect(names).toContain('user-agent')
expect(names).toContain('originator')
expect(names).toContain('openai-beta')
expect(validateHeaderOverrideRows(rows)).toBeNull()
})
it('returns Grok forwarding headers with empty values for grok', () => {
const rows = getHeaderOverrideTemplate('grok')
expect(rows.every((r) => r.value === '')).toBe(true)
const names = rows.map((r) => r.name)
expect(names).toContain('user-agent')
expect(names).toContain('x-xai-token-auth')
expect(names).toContain('x-grok-client-version')
expect(validateHeaderOverrideRows(rows)).toBeNull()
})
})
describe('applyHeaderOverride', () => {
it('create + enabled: writes enabled flag and overrides object', () => {
const creds: Record<string, unknown> = { api_key: 'sk' }
@@ -87,46 +87,6 @@ function isValidHeaderOverrideName(name: string): boolean {
return HEADER_NAME_PATTERN.test(name)
}
/** 模板:Claude Code CLI 标准客户端请求头(值留空由管理员填写) */
const ANTHROPIC_HEADER_OVERRIDE_TEMPLATE = [
'user-agent',
'x-app',
'anthropic-beta',
'anthropic-version',
'anthropic-dangerous-direct-browser-access',
'x-stainless-lang',
'x-stainless-package-version',
'x-stainless-os',
'x-stainless-arch',
'x-stainless-runtime',
'x-stainless-runtime-version',
'x-stainless-retry-count',
'x-stainless-timeout'
]
/** 模板:Codex CLI 标准客户端请求头(值留空由管理员填写) */
const OPENAI_HEADER_OVERRIDE_TEMPLATE = [
'user-agent',
'originator',
'openai-beta',
'version',
'accept',
'accept-language'
]
/** 模板:Grok 转发常用请求头(第三方转发网关通常需要的身份/准入头,值留空由管理员填写) */
const GROK_HEADER_OVERRIDE_TEMPLATE = ['user-agent', 'x-xai-token-auth', 'x-grok-client-version']
export function getHeaderOverrideTemplate(platform: string): HeaderOverrideRow[] {
const names =
platform === 'openai'
? OPENAI_HEADER_OVERRIDE_TEMPLATE
: platform === 'grok'
? GROK_HEADER_OVERRIDE_TEMPLATE
: ANTHROPIC_HEADER_OVERRIDE_TEMPLATE
return names.map((name) => ({ name, value: '' }))
}
/** 与后端 maxHeaderOverride* 常量保持一致 */
const HEADER_OVERRIDE_MAX_ENTRIES = 64
const HEADER_OVERRIDE_MAX_NAME_LENGTH = 200
@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest'
import { baseCompile } from '@intlify/message-compiler'
import en from '../locales/en'
import zh from '../locales/zh'
// vue-i18n 在运行时才编译消息:文案里未转义的花括号(如内嵌 JSON 示例
// "{\"user-agent\": ...}")会在渲染时抛 "Invalid token in placeholder"
// 直接炸掉整个组件树,且构建期完全无感。本测试把全部文案预编译一遍,
// 将该类问题固化为显式失败。字面量花括号请用 {'{'} / {'}'} 转义,
// 或将语言中立的示例文本(如 JSON)移出 i18n。
function collectCompileErrors(node: unknown, path: string, out: string[]): void {
if (typeof node === 'string') {
baseCompile(node, {
onError: (err) => {
out.push(`${path}: ${err.message}`)
}
})
return
}
if (Array.isArray(node)) {
node.forEach((item, index) => collectCompileErrors(item, `${path}[${index}]`, out))
return
}
if (node && typeof node === 'object') {
for (const [key, value] of Object.entries(node as Record<string, unknown>)) {
collectCompileErrors(value, path ? `${path}.${key}` : key, out)
}
}
}
describe('locale messages compile', () => {
it.each([
['zh', zh],
['en', en]
] as const)('%s messages all compile without placeholder errors', (locale, messages) => {
const errors: string[] = []
collectCompileErrors(messages, locale, errors)
expect(errors).toEqual([])
})
})
@@ -578,9 +578,7 @@ export default {
namePlaceholder: 'Header name (e.g. user-agent)',
valuePlaceholder: 'Override value (leave empty to skip)',
addRow: 'Add Header',
fillTemplate: 'Fill Template',
importJson: 'Import JSON',
importJsonPlaceholder: '{"user-agent": "my-client/1.0", "x-relay-token": "..."}',
importJsonApply: 'Parse & Fill',
importJsonCancel: 'Cancel',
importJsonHint: 'Paste a flat JSON object (header name → value). Parsing replaces the current rows.',
@@ -321,8 +321,7 @@ export default {
claudeOAuthSystemPromptPlaceholder: 'Leave empty to use the built-in Claude Code expansion prompt.',
claudeOAuthSystemPromptHint: 'Legacy compatibility: controls only the third injected system block.',
claudeOAuthSystemPromptBlocks: 'Claude OAuth System Blocks',
claudeOAuthSystemPromptBlocksPlaceholder: 'Leave empty to use the built-in 3 blocks. Supports an array or {"blocks": [...]}.',
claudeOAuthSystemPromptBlocksHint: 'Each block is saved as JSON with enabled, type, text, and optional cache_control. {billing_header} stays dynamic per request; the Claude Code identity and expansion prompts can be edited directly or restored from presets.',
claudeOAuthSystemPromptBlocksHint: "Each block is saved as JSON with enabled, type, text, and optional cache_control. {'{'}billing_header{'}'} stays dynamic per request; the Claude Code identity and expansion prompts can be edited directly or restored from presets.",
systemBlockTitle: 'System Block {index}',
systemBlockPreset: 'Preset',
systemBlockPresetBilling: 'Billing header',
@@ -671,9 +671,7 @@ export default {
namePlaceholder: '请求头名称(如 user-agent',
valuePlaceholder: '覆写值(留空表示不覆写)',
addRow: '添加请求头',
fillTemplate: '填入模板',
importJson: 'JSON 导入',
importJsonPlaceholder: '{"user-agent": "my-client/1.0", "x-relay-token": "..."}',
importJsonApply: '解析并填入',
importJsonCancel: '取消',
importJsonHint: '粘贴扁平 JSON 对象(请求头名 → 值),解析后将整体替换当前列表。',
@@ -315,8 +315,7 @@ export default {
claudeOAuthSystemPromptPlaceholder: '留空时使用内置 Claude Code 扩展提示词。',
claudeOAuthSystemPromptHint: '兼容旧配置:仅控制第三个注入的 system block。',
claudeOAuthSystemPromptBlocks: 'Claude OAuth System Blocks',
claudeOAuthSystemPromptBlocksPlaceholder: '留空时使用内置 3 个 blocks。支持数组或 {"blocks": [...]}。',
claudeOAuthSystemPromptBlocksHint: '每个 block 会保存为带 enabled、type、text、可选 cache_control 的 JSON。{billing_header} 会按请求动态生成;Claude Code 身份提示词和扩展提示词可直接编辑,也可用预设恢复默认值。',
claudeOAuthSystemPromptBlocksHint: "每个 block 会保存为带 enabled、type、text、可选 cache_control 的 JSON。{'{'}billing_header{'}'} 会按请求动态生成;Claude Code 身份提示词和扩展提示词可直接编辑,也可用预设恢复默认值。",
systemBlockTitle: 'System Block {index}',
systemBlockPreset: '预设',
systemBlockPresetBilling: 'Billing Header',