merge: sync batch image branch with origin main

This commit is contained in:
Turtle_Li
2026-07-07 03:27:11 +08:00
107 changed files with 4469 additions and 263 deletions
@@ -486,6 +486,129 @@
</div>
</div>
<!-- Header Override (anthropic/openai apikey only) -->
<div v-if="allHeaderOverrideCapable" class="border-t border-gray-200 pt-4 dark:border-dark-600">
<div class="flex items-center justify-between">
<div class="flex-1 pr-4">
<label
id="bulk-edit-header-override-label"
class="input-label mb-0"
for="bulk-edit-header-override-enabled"
>
{{ 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>
<input
v-model="enableHeaderOverride"
id="bulk-edit-header-override-enabled"
type="checkbox"
aria-controls="bulk-edit-header-override-body"
class="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/>
</div>
<div v-if="enableHeaderOverride" id="bulk-edit-header-override-body" class="mt-3 space-y-3">
<button
type="button"
: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'
]"
@click="headerOverrideEnabled = !headerOverrideEnabled"
>
<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 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>
<p class="text-xs text-amber-600 dark:text-amber-400">
{{ 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 v-if="headerOverrideTemplatePlatform" class="flex flex-wrap gap-2">
<button
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>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.headerOverride.emptyValueHint') }}
</p>
</div>
<p v-else class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.headerOverride.bulkDisableHint') }}
</p>
</div>
</div>
<!-- Proxy -->
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
<div class="mb-3 flex items-center justify-between">
@@ -1149,6 +1272,16 @@ import {
buildModelMappingObject as buildModelMappingPayload,
getPresetMappingsByPlatform
} from '@/composables/useModelWhitelist'
import {
buildHeaderOverridesObject,
getHeaderOverrideTemplate,
isHeaderOverridePlatform,
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,
@@ -1217,6 +1350,16 @@ const allOpenAIAPIKey = computed(() => {
)
})
// 是否全部为 anthropic/openai 平台的 apikey 账号(请求头覆写仅在此条件下显示)
const allHeaderOverrideCapable = computed(() => {
return (
targetSelectedPlatforms.value.length > 0 &&
targetSelectedPlatforms.value.every(p => isHeaderOverridePlatform(p)) &&
targetSelectedTypes.value.length > 0 &&
targetSelectedTypes.value.every(t => t === 'apikey')
)
})
// 是否全部为 Anthropic OAuth/SetupToken(RPM 配置仅在此条件下显示)
const allAnthropicOAuthOrSetupToken = computed(() => {
return (
@@ -1253,6 +1396,7 @@ const enableBaseUrl = ref(false)
const enableModelRestriction = ref(false)
const enableCustomErrorCodes = ref(false)
const enableInterceptWarmup = ref(false)
const enableHeaderOverride = ref(false)
const enableProxy = ref(false)
const enableConcurrency = ref(false)
const enableLoadFactor = ref(false)
@@ -1281,6 +1425,39 @@ const modelMappings = ref<ModelMapping[]>([])
const selectedErrorCodes = ref<number[]>([])
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)
@@ -1523,6 +1700,15 @@ const buildUpdatePayload = (): Record<string, unknown> | null => {
credentialsChanged = true
}
if (enableHeaderOverride.value) {
// 后端使用 JSONB || merge 语义:关闭时显式写入 false + 空对象以清除旧配置
credentials[HEADER_OVERRIDE_ENABLED_CREDENTIAL_KEY] = headerOverrideEnabled.value
credentials[HEADER_OVERRIDES_CREDENTIAL_KEY] = headerOverrideEnabled.value
? buildHeaderOverridesObject(headerOverrideRows.value)
: {}
credentialsChanged = true
}
if (enableOpenAIWSMode.value) {
const extra = ensureExtra()
extra.openai_oauth_responses_websockets_v2_mode = openaiOAuthResponsesWebSocketV2Mode.value
@@ -1651,6 +1837,7 @@ const handleSubmit = async () => {
enableModelRestriction.value ||
enableCustomErrorCodes.value ||
enableInterceptWarmup.value ||
enableHeaderOverride.value ||
enableProxy.value ||
enableConcurrency.value ||
enableLoadFactor.value ||
@@ -1672,6 +1859,20 @@ const handleSubmit = async () => {
return
}
if (enableHeaderOverride.value && headerOverrideEnabled.value) {
// 批量保存对 header_overrides 是整键替换:开启但没有任何有效行会把所选账号的
// 既有覆写配置静默清空,必须显式拦截(清空请走关闭开关的路径,有专门提示)
if (!headerOverrideRows.value.some((row) => row.name.trim())) {
appStore.showError(t('admin.accounts.headerOverride.bulkEmptyRows'))
return
}
const headerError = validateHeaderOverrideRows(headerOverrideRows.value)
if (headerError) {
appStore.showError(t(`admin.accounts.headerOverride.${headerError}`))
return
}
}
const built = buildUpdatePayload()
if (!built) {
appStore.showError(t('admin.accounts.bulkEdit.noFieldsSelected'))
@@ -1753,6 +1954,7 @@ watch(
enableModelRestriction.value = false
enableCustomErrorCodes.value = false
enableInterceptWarmup.value = false
enableHeaderOverride.value = false
enableProxy.value = false
enableConcurrency.value = false
enableLoadFactor.value = false
@@ -1778,6 +1980,8 @@ watch(
selectedErrorCodes.value = []
customErrorCodeInput.value = null
interceptWarmupRequests.value = false
headerOverrideEnabled.value = false
headerOverrideRows.value = []
proxyId.value = null
concurrency.value = 1
loadFactor.value = null
@@ -1468,6 +1468,110 @@
</div>
</div>
<!-- Header Override Section (anthropic/openai apikey only) -->
<div
v-if="isHeaderOverridePlatform(form.platform)"
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>
<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>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.headerOverride.emptyValueHint') }}
</p>
</div>
</div>
</div>
<!-- Bedrock credentials (only for Anthropic Bedrock type) -->
@@ -3328,7 +3432,12 @@ import ModelWhitelistSelector from '@/components/account/ModelWhitelistSelector.
import QuotaLimitCard from '@/components/account/QuotaLimitCard.vue'
import {
applyAntigravityProjectID,
applyInterceptWarmup
applyHeaderOverride,
applyInterceptWarmup,
getHeaderOverrideTemplate,
isHeaderOverridePlatform,
validateHeaderOverrideRows,
type HeaderOverrideRow
} from '@/components/account/credentialsBuilder'
import { formatDateTimeLocalInput, parseDateTimeLocalInput } from '@/utils/format'
import { createStableObjectKeyResolver } from '@/utils/stableObjectKey'
@@ -3512,6 +3621,30 @@ function parsePoolModeRetryStatusCodes(input: string): number[] {
const customErrorCodesEnabled = ref(false)
const selectedErrorCodes = ref<number[]>([])
const customErrorCodeInput = ref<number | null>(null)
const headerOverrideEnabled = ref(false)
const headerOverrideRows = ref<HeaderOverrideRow[]>([])
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(form.platform)) {
if (!existing.has(row.name)) {
rows.push(row)
}
}
headerOverrideRows.value = rows
}
const interceptWarmupRequests = ref(false)
const autoPauseOnExpired = ref(true)
const openaiPassthroughEnabled = ref(false)
@@ -3569,6 +3702,7 @@ 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')
@@ -3970,6 +4104,10 @@ watch(
anthropicAPIKeyAuthScheme.value = 'x_api_key'
webSearchEmulationMode.value = 'default'
}
// 请求头覆写为平台相关配置(模板/常用头集合不同),切换平台时清空,
// 避免上一平台的模板行被提交到新平台账号
headerOverrideEnabled.value = false
headerOverrideRows.value = []
// Reset OAuth states
oauth.resetState()
openaiOAuth.resetState()
@@ -4359,6 +4497,8 @@ const resetForm = () => {
customErrorCodesEnabled.value = false
selectedErrorCodes.value = []
customErrorCodeInput.value = null
headerOverrideEnabled.value = false
headerOverrideRows.value = []
interceptWarmupRequests.value = false
autoPauseOnExpired.value = true
openaiPassthroughEnabled.value = false
@@ -4789,6 +4929,18 @@ const handleSubmit = async () => {
credentials.custom_error_codes = [...selectedErrorCodes.value]
}
// Add header override if enabled (anthropic/openai apikey only)
if (isHeaderOverridePlatform(form.platform)) {
if (headerOverrideEnabled.value) {
const headerError = validateHeaderOverrideRows(headerOverrideRows.value)
if (headerError) {
appStore.showError(t(`admin.accounts.headerOverride.${headerError}`))
return
}
}
applyHeaderOverride(credentials, headerOverrideEnabled.value, headerOverrideRows.value, 'create')
}
applyInterceptWarmup(credentials, interceptWarmupRequests.value, 'create')
if (!applyTempUnschedConfig(credentials)) {
return
@@ -417,6 +417,110 @@
</div>
</div>
<!-- Header Override Section (anthropic/openai apikey only) -->
<div
v-if="isHeaderOverridePlatform(account.platform)"
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>
<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>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.headerOverride.emptyValueHint') }}
</p>
</div>
</div>
</div>
<!-- OpenAI/Grok OAuth Model Mapping (OAuth 类型没有 apikey 容器,需要独立的模型映射区域) -->
@@ -2433,7 +2537,15 @@ import ModelWhitelistSelector from '@/components/account/ModelWhitelistSelector.
import QuotaLimitCard from '@/components/account/QuotaLimitCard.vue'
import {
applyAntigravityProjectID,
applyInterceptWarmup
applyHeaderOverride,
applyInterceptWarmup,
getHeaderOverrideTemplate,
isHeaderOverridePlatform,
splitHeaderOverridesObject,
validateHeaderOverrideRows,
HEADER_OVERRIDE_ENABLED_CREDENTIAL_KEY,
HEADER_OVERRIDES_CREDENTIAL_KEY,
type HeaderOverrideRow
} from '@/components/account/credentialsBuilder'
import { formatDateTime, formatDateTimeLocalInput, parseDateTimeLocalInput } from '@/utils/format'
import { createStableObjectKeyResolver } from '@/utils/stableObjectKey'
@@ -2564,6 +2676,30 @@ function formatPoolModeRetryStatusCodes(value: unknown): string {
const customErrorCodesEnabled = ref(false)
const selectedErrorCodes = ref<number[]>([])
const customErrorCodeInput = ref<number | null>(null)
const headerOverrideEnabled = ref(false)
const headerOverrideRows = ref<HeaderOverrideRow[]>([])
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)
@@ -2580,6 +2716,7 @@ 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')
@@ -3186,6 +3323,10 @@ const syncFormFromAccount = (newAccount: Account | null) => {
loadTempUnschedRules(credentials)
// Reset header override state (loaded below only for apikey accounts)
headerOverrideEnabled.value = false
headerOverrideRows.value = []
// Initialize API Key fields for apikey type
if (newAccount.type === 'apikey' && newAccount.credentials) {
const credentials = newAccount.credentials as Record<string, unknown>
@@ -3215,6 +3356,14 @@ const syncFormFromAccount = (newAccount: Account | null) => {
} else {
selectedErrorCodes.value = []
}
// Load header override (anthropic/openai apikey only)
headerOverrideEnabled.value =
isHeaderOverridePlatform(newAccount.platform) &&
credentials[HEADER_OVERRIDE_ENABLED_CREDENTIAL_KEY] === true
headerOverrideRows.value = splitHeaderOverridesObject(
credentials[HEADER_OVERRIDES_CREDENTIAL_KEY]
)
} else if (newAccount.type === 'bedrock' && newAccount.credentials) {
const bedrockCreds = newAccount.credentials as Record<string, unknown>
const authMode = (bedrockCreds.auth_mode as string) || 'sigv4'
@@ -3850,6 +3999,18 @@ const handleSubmit = async () => {
delete newCredentials.custom_error_codes
}
// Add header override if enabled (anthropic/openai apikey only)
if (isHeaderOverridePlatform(props.account.platform)) {
if (headerOverrideEnabled.value) {
const headerError = validateHeaderOverrideRows(headerOverrideRows.value)
if (headerError) {
appStore.showError(t(`admin.accounts.headerOverride.${headerError}`))
return
}
}
applyHeaderOverride(newCredentials, headerOverrideEnabled.value, headerOverrideRows.value, 'edit')
}
// Add intercept warmup requests setting
applyInterceptWarmup(newCredentials, interceptWarmupRequests.value, 'edit')
if (!applyTempUnschedConfig(newCredentials)) {
@@ -107,7 +107,7 @@ describe('BulkEditAccountModal', () => {
expect(mappingTab).toBeTruthy()
await mappingTab!.trigger('click')
expect(wrapper.text()).toContain('3.1-Flash-Image透传')
expect(wrapper.text()).toContain('3.1-Flash-Image passthrough')
expect(wrapper.text()).toContain('3-Pro-Image→3.1')
expect(wrapper.text()).not.toContain('GPT-5.3 Codex Spark')
})
@@ -1,8 +1,16 @@
import { describe, it, expect } from 'vitest'
import {
ANTIGRAVITY_PROJECT_ID_CREDENTIAL_KEY,
HEADER_OVERRIDE_ENABLED_CREDENTIAL_KEY,
HEADER_OVERRIDES_CREDENTIAL_KEY,
applyAntigravityProjectID,
applyInterceptWarmup
applyHeaderOverride,
applyInterceptWarmup,
buildHeaderOverridesObject,
getHeaderOverrideTemplate,
isHeaderOverridePlatform,
splitHeaderOverridesObject,
validateHeaderOverrideRows
} from '../credentialsBuilder'
describe('applyInterceptWarmup', () => {
@@ -82,3 +90,202 @@ describe('applyAntigravityProjectID', () => {
expect(creds[ANTIGRAVITY_PROJECT_ID_CREDENTIAL_KEY]).toBe('configured-project')
})
})
describe('isHeaderOverridePlatform', () => {
it('only anthropic and openai are supported', () => {
expect(isHeaderOverridePlatform('anthropic')).toBe(true)
expect(isHeaderOverridePlatform('openai')).toBe(true)
expect(isHeaderOverridePlatform('gemini')).toBe(false)
expect(isHeaderOverridePlatform('grok')).toBe(false)
expect(isHeaderOverridePlatform('antigravity')).toBe(false)
expect(isHeaderOverridePlatform('')).toBe(false)
})
})
describe('validateHeaderOverrideRows', () => {
it('accepts valid rows and empty placeholder rows', () => {
expect(
validateHeaderOverrideRows([
{ name: 'user-agent', value: 'my-agent/1.0' },
{ name: 'x-app', value: '' },
{ name: '', value: '' }
])
).toBeNull()
})
it('rejects empty name with non-empty value', () => {
expect(validateHeaderOverrideRows([{ name: '', value: 'v' }])).toBe('invalidName')
})
it('rejects invalid header names', () => {
expect(validateHeaderOverrideRows([{ name: 'bad name', value: '' }])).toBe('invalidName')
expect(validateHeaderOverrideRows([{ name: 'bad:name', value: '' }])).toBe('invalidName')
expect(validateHeaderOverrideRows([{ name: '名称', value: '' }])).toBe('invalidName')
})
it('rejects blocked header names case-insensitively', () => {
expect(validateHeaderOverrideRows([{ name: 'Authorization', value: '' }])).toBe('blockedName')
expect(validateHeaderOverrideRows([{ name: 'X-Api-Key', value: '' }])).toBe('blockedName')
expect(validateHeaderOverrideRows([{ name: 'host', value: '' }])).toBe('blockedName')
expect(validateHeaderOverrideRows([{ name: 'Content-Length', value: '' }])).toBe('blockedName')
expect(validateHeaderOverrideRows([{ name: 'Content-Type', value: '' }])).toBe('blockedName')
expect(validateHeaderOverrideRows([{ name: 'Cookie', value: '' }])).toBe('blockedName')
expect(validateHeaderOverrideRows([{ name: 'x-goog-api-key', value: '' }])).toBe('blockedName')
})
it('rejects duplicate names case-insensitively', () => {
expect(
validateHeaderOverrideRows([
{ name: 'User-Agent', value: 'a' },
{ name: 'user-agent', value: 'b' }
])
).toBe('duplicateName')
})
})
describe('buildHeaderOverridesObject / splitHeaderOverridesObject', () => {
it('lowercases names, trims values and drops empty-name rows', () => {
expect(
buildHeaderOverridesObject([
{ name: ' User-Agent ', value: ' my-agent ' },
{ name: 'X-App', value: '' },
{ name: '', value: 'ignored' }
])
).toEqual({ 'user-agent': 'my-agent', 'x-app': '' })
})
it('splits an object into sorted rows and ignores non-string values', () => {
expect(
splitHeaderOverridesObject({ 'x-app': 'cli', 'user-agent': 'ua', bogus: 42 })
).toEqual([
{ name: 'user-agent', value: 'ua' },
{ name: 'x-app', value: 'cli' }
])
expect(splitHeaderOverridesObject(null)).toEqual([])
expect(splitHeaderOverridesObject(['a'])).toEqual([])
expect(splitHeaderOverridesObject('str')).toEqual([])
})
it('roundtrips through build and split', () => {
const rows = [
{ name: 'user-agent', value: 'ua' },
{ name: 'x-app', value: 'cli' }
]
expect(splitHeaderOverridesObject(buildHeaderOverridesObject(rows))).toEqual(rows)
})
})
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()
})
})
describe('applyHeaderOverride', () => {
it('create + enabled: writes enabled flag and overrides object', () => {
const creds: Record<string, unknown> = { api_key: 'sk' }
applyHeaderOverride(creds, true, [{ name: 'User-Agent', value: 'ua' }], 'create')
expect(creds[HEADER_OVERRIDE_ENABLED_CREDENTIAL_KEY]).toBe(true)
expect(creds[HEADER_OVERRIDES_CREDENTIAL_KEY]).toEqual({ 'user-agent': 'ua' })
})
it('create + disabled: does not add fields', () => {
const creds: Record<string, unknown> = { api_key: 'sk' }
applyHeaderOverride(creds, false, [{ name: 'user-agent', value: 'ua' }], 'create')
expect(HEADER_OVERRIDE_ENABLED_CREDENTIAL_KEY in creds).toBe(false)
expect(HEADER_OVERRIDES_CREDENTIAL_KEY in creds).toBe(false)
})
it('edit + disabled: deletes existing fields', () => {
const creds: Record<string, unknown> = {
api_key: 'sk',
[HEADER_OVERRIDE_ENABLED_CREDENTIAL_KEY]: true,
[HEADER_OVERRIDES_CREDENTIAL_KEY]: { 'user-agent': 'ua' }
}
applyHeaderOverride(creds, false, [], 'edit')
expect(HEADER_OVERRIDE_ENABLED_CREDENTIAL_KEY in creds).toBe(false)
expect(HEADER_OVERRIDES_CREDENTIAL_KEY in creds).toBe(false)
expect(creds.api_key).toBe('sk')
})
it('edit + enabled: replaces overrides object wholesale', () => {
const creds: Record<string, unknown> = {
[HEADER_OVERRIDE_ENABLED_CREDENTIAL_KEY]: true,
[HEADER_OVERRIDES_CREDENTIAL_KEY]: { 'x-old': 'old' }
}
applyHeaderOverride(creds, true, [{ name: 'x-new', value: 'new' }], 'edit')
expect(creds[HEADER_OVERRIDES_CREDENTIAL_KEY]).toEqual({ 'x-new': 'new' })
})
})
describe('validateHeaderOverrideRows value/entry limits', () => {
it('rejects websocket handshake headers', () => {
expect(validateHeaderOverrideRows([{ name: 'Sec-WebSocket-Key', value: '' }])).toBe(
'blockedName'
)
})
it('rejects control characters in values', () => {
expect(validateHeaderOverrideRows([{ name: 'x-app', value: 'a\x0bb' }])).toBe('invalidValue')
})
it('rejects oversized values', () => {
expect(validateHeaderOverrideRows([{ name: 'x-app', value: 'a'.repeat(8193) }])).toBe(
'invalidValue'
)
})
it('measures value length in UTF-8 bytes to match backend', () => {
// 3000 个 CJK 字符 = 3000 UTF-16 code units,但 9000 UTF-8 字节 > 8192
expect(validateHeaderOverrideRows([{ name: 'x-app', value: '测'.repeat(3000) }])).toBe(
'invalidValue'
)
expect(validateHeaderOverrideRows([{ name: 'x-app', value: '测'.repeat(2000) }])).toBeNull()
})
it('rejects too many entries', () => {
const rows = Array.from({ length: 65 }, (_, i) => ({ name: `x-h-${i}`, value: 'v' }))
expect(validateHeaderOverrideRows(rows)).toBe('tooManyEntries')
})
})
describe('validateHeaderOverrideRows session isolation headers', () => {
it('rejects per-request session headers', () => {
expect(validateHeaderOverrideRows([{ name: 'session_id', value: '' }])).toBe('blockedName')
expect(validateHeaderOverrideRows([{ name: 'Conversation_ID', value: '' }])).toBe('blockedName')
expect(validateHeaderOverrideRows([{ name: 'x-codex-turn-state', value: '' }])).toBe(
'blockedName'
)
expect(validateHeaderOverrideRows([{ name: 'X-Claude-Code-Session-Id', value: '' }])).toBe(
'blockedName'
)
expect(validateHeaderOverrideRows([{ name: 'x-client-request-id', value: '' }])).toBe(
'blockedName'
)
})
it('allows tab inside value', () => {
expect(validateHeaderOverrideRows([{ name: 'x-app', value: 'a\tb' }])).toBeNull()
})
it('rejects oversized names', () => {
expect(validateHeaderOverrideRows([{ name: 'x'.repeat(201), value: 'v' }])).toBe('invalidName')
})
})
@@ -24,3 +24,180 @@ export function applyAntigravityProjectID(
delete credentials[ANTIGRAVITY_PROJECT_ID_CREDENTIAL_KEY]
}
}
// ========== 请求头覆写(仅 anthropic/openai 平台的 api_key 账号) ==========
export const HEADER_OVERRIDE_ENABLED_CREDENTIAL_KEY = 'header_override_enabled'
export const HEADER_OVERRIDES_CREDENTIAL_KEY = 'header_overrides'
export interface HeaderOverrideRow {
name: string
value: string
}
/** 请求头覆写支持的平台(与后端 IsHeaderOverrideEligible 保持一致) */
export function isHeaderOverridePlatform(platform: string): boolean {
return platform === 'anthropic' || platform === 'openai'
}
/** 禁止覆写的请求头(与后端 headerOverrideBlockedNames 保持一致) */
const HEADER_OVERRIDE_BLOCKED_NAMES = new Set([
'host',
'content-length',
'content-type',
'transfer-encoding',
'connection',
'keep-alive',
'proxy-authenticate',
'proxy-authorization',
'proxy-connection',
'te',
'trailer',
'upgrade',
'authorization',
'x-api-key',
'x-goog-api-key',
'cookie',
'accept-encoding',
'sec-websocket-key',
'sec-websocket-version',
'sec-websocket-extensions',
'sec-websocket-protocol',
'sec-websocket-accept',
'session_id',
'conversation_id',
'x-codex-turn-state',
'x-codex-turn-metadata',
'chatgpt-account-id',
'x-claude-code-session-id',
'x-client-request-id'
])
/** RFC 7230 token:合法的 HTTP header 名称字符集 */
const HEADER_NAME_PATTERN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/
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'
]
export function getHeaderOverrideTemplate(platform: string): HeaderOverrideRow[] {
const names =
platform === 'openai' ? OPENAI_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
const HEADER_OVERRIDE_MAX_VALUE_LENGTH = 8192
/** header value 不允许包含控制字符(与后端 httpguts.ValidHeaderFieldValue 对齐) */
// eslint-disable-next-line no-control-regex
const HEADER_VALUE_INVALID_PATTERN = /[\x00-\x08\x0a-\x1f\x7f]/
/** 长度限制按 UTF-8 字节计(与后端 Go len() 对齐,避免多字节值前端放行后端 400) */
const HEADER_TEXT_ENCODER = new TextEncoder()
function utf8ByteLength(value: string): number {
return HEADER_TEXT_ENCODER.encode(value).length
}
/**
* 校验请求头覆写行,返回首个错误的 i18n key(无错误返回 null)。
* 名称为空但值非空 → invalidName;名称非法 → invalidName;
* 禁止覆写 → blockedName;大小写不敏感重名 → duplicateName;
* 值含控制字符或超长 → invalidValue;条目过多 → tooManyEntries。
*/
export function validateHeaderOverrideRows(
rows: HeaderOverrideRow[]
): 'invalidName' | 'blockedName' | 'duplicateName' | 'invalidValue' | 'tooManyEntries' | null {
const seen = new Set<string>()
for (const row of rows) {
const name = row.name.trim()
const value = row.value.trim()
if (!name) {
if (value) return 'invalidName'
continue
}
if (!isValidHeaderOverrideName(name) || name.length > HEADER_OVERRIDE_MAX_NAME_LENGTH) {
return 'invalidName'
}
const lower = name.toLowerCase()
if (HEADER_OVERRIDE_BLOCKED_NAMES.has(lower)) return 'blockedName'
if (seen.has(lower)) return 'duplicateName'
if (
HEADER_VALUE_INVALID_PATTERN.test(value) ||
utf8ByteLength(value) > HEADER_OVERRIDE_MAX_VALUE_LENGTH
) {
return 'invalidValue'
}
seen.add(lower)
}
if (seen.size > HEADER_OVERRIDE_MAX_ENTRIES) return 'tooManyEntries'
return null
}
/** 行数组 → credentials 存储对象(名称小写化,丢弃空行) */
export function buildHeaderOverridesObject(rows: HeaderOverrideRow[]): Record<string, string> {
const result: Record<string, string> = {}
for (const row of rows) {
const name = row.name.trim().toLowerCase()
if (!name) continue
result[name] = row.value.trim()
}
return result
}
/** credentials 存储对象 → 行数组(按名称排序保证稳定展示) */
export function splitHeaderOverridesObject(record: unknown): HeaderOverrideRow[] {
if (!record || typeof record !== 'object' || Array.isArray(record)) return []
return Object.entries(record as Record<string, unknown>)
.filter(([, value]) => typeof value === 'string')
.map(([name, value]) => ({ name, value: value as string }))
.sort((a, b) => a.name.localeCompare(b.name))
}
/**
* 将请求头覆写写入 credentials。
* create 模式:关闭时不写入任何字段;edit 模式:关闭时删除字段(全量替换语义)。
*/
export function applyHeaderOverride(
credentials: Record<string, unknown>,
enabled: boolean,
rows: HeaderOverrideRow[],
mode: 'create' | 'edit'
): void {
if (enabled) {
credentials[HEADER_OVERRIDE_ENABLED_CREDENTIAL_KEY] = true
credentials[HEADER_OVERRIDES_CREDENTIAL_KEY] = buildHeaderOverridesObject(rows)
} else if (mode === 'edit') {
delete credentials[HEADER_OVERRIDE_ENABLED_CREDENTIAL_KEY]
delete credentials[HEADER_OVERRIDES_CREDENTIAL_KEY]
}
}
@@ -636,6 +636,54 @@ function generateOpenCodeConfig(platform: string, baseUrl: string, apiKey: strin
xhigh: {}
}
},
'gpt-5.6-sol': {
name: 'GPT-5.6 Sol',
limit: {
context: 1050000,
output: 128000
},
options: {
store: false
},
variants: {
low: {},
medium: {},
high: {},
xhigh: {}
}
},
'gpt-5.6-terra': {
name: 'GPT-5.6 Terra',
limit: {
context: 1050000,
output: 128000
},
options: {
store: false
},
variants: {
low: {},
medium: {},
high: {},
xhigh: {}
}
},
'gpt-5.6-luna': {
name: 'GPT-5.6 Luna',
limit: {
context: 1050000,
output: 128000
},
options: {
store: false
},
variants: {
low: {},
medium: {},
high: {},
xhigh: {}
}
},
'gpt-5.5': {
name: 'GPT-5.5',
limit: {
@@ -20,9 +20,9 @@
@click="method.available && emit('select', method.type)"
>
<span class="flex items-center gap-2">
<img :src="methodIcon(method.type)" :alt="t(`payment.methods.${method.type}`)" class="h-7 w-7 object-contain" />
<img :src="methodIcon(method.type)" :alt="methodLabel(method)" class="h-7 w-7 object-contain" />
<span class="flex flex-col items-start leading-none">
<span class="text-base font-semibold">{{ t(`payment.methods.${method.type}`) }}</span>
<span class="text-base font-semibold">{{ methodLabel(method) }}</span>
<span
v-if="method.fee_rate > 0"
class="text-[10px] tracking-wide text-gray-500 dark:text-dark-400"
@@ -39,14 +39,16 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { METHOD_ORDER } from './providerConfig'
import { METHOD_ORDER, isBuiltInAlipayMethod, isBuiltInWxpayMethod } from './providerConfig'
import alipayIcon from '@/assets/icons/alipay.svg'
import wxpayIcon from '@/assets/icons/wxpay.svg'
import stripeIcon from '@/assets/icons/stripe.svg'
import airwallexIcon from '@/assets/icons/airwallex.svg'
import paymentIcon from '@/assets/icons/payment.svg'
export interface PaymentMethodOption {
type: string
display_name?: string
fee_rate: number
available: boolean
}
@@ -67,6 +69,7 @@ const METHOD_ICONS: Record<string, string> = {
wxpay: wxpayIcon,
stripe: stripeIcon,
airwallex: airwallexIcon,
credit_card: paymentIcon,
}
const sortedMethods = computed(() => {
@@ -79,15 +82,19 @@ const sortedMethods = computed(() => {
})
function methodIcon(type: string): string {
if (type.includes('alipay')) return METHOD_ICONS.alipay
if (type.includes('wxpay')) return METHOD_ICONS.wxpay
if (isBuiltInAlipayMethod(type)) return METHOD_ICONS.alipay
if (isBuiltInWxpayMethod(type)) return METHOD_ICONS.wxpay
if (type === 'airwallex') return METHOD_ICONS.airwallex
return METHOD_ICONS[type] || alipayIcon
return METHOD_ICONS[type] || paymentIcon
}
function methodLabel(method: PaymentMethodOption): string {
return method.display_name || t(`payment.methods.${method.type}`, method.type)
}
function methodSelectedClass(type: string): string {
if (type.includes('alipay')) return 'border-[#02A9F1] bg-blue-50 text-gray-900 shadow-sm dark:bg-blue-950 dark:text-gray-100'
if (type.includes('wxpay')) return 'border-[#09BB07] bg-green-50 text-gray-900 shadow-sm dark:bg-green-950 dark:text-gray-100'
if (isBuiltInAlipayMethod(type)) return 'border-[#02A9F1] bg-blue-50 text-gray-900 shadow-sm dark:bg-blue-950 dark:text-gray-100'
if (isBuiltInWxpayMethod(type)) return 'border-[#09BB07] bg-green-50 text-gray-900 shadow-sm dark:bg-green-950 dark:text-gray-100'
if (type === 'stripe') return 'border-[#676BE5] bg-indigo-50 text-gray-900 shadow-sm dark:bg-indigo-950 dark:text-gray-100'
if (type === 'airwallex') return 'border-[#FF6B3D] bg-orange-50 text-gray-900 shadow-sm dark:border-[#FF8E3C] dark:bg-orange-950 dark:text-gray-100'
return 'border-primary-500 bg-primary-50 text-gray-900 shadow-sm dark:bg-primary-950 dark:text-gray-100'
@@ -70,6 +70,49 @@
</div>
</div>
<div v-if="form.provider_key === 'easypay'" class="space-y-3 rounded-lg border border-gray-100 p-3 dark:border-dark-700">
<div class="flex items-center justify-between gap-3">
<div>
<h5 class="text-sm font-medium text-gray-900 dark:text-white">
{{ t('admin.settings.payment.easypayCustomMethods') }}
</h5>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.settings.payment.easypayCustomMethodsHint') }}
</p>
</div>
<button type="button" class="btn btn-secondary btn-sm" @click="addEasyPayCustomMethod">
{{ t('admin.settings.payment.addCustomMethod') }}
</button>
</div>
<div v-if="easyPayCustomMethods.length" class="space-y-2">
<div
v-for="(method, index) in easyPayCustomMethods"
:key="index"
class="grid grid-cols-[1fr_1fr_1fr_auto] items-end gap-2"
>
<div>
<label class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.settings.payment.customMethodType') }}</label>
<input v-model="method.type" type="text" class="input mt-0.5" placeholder="credit_card" />
</div>
<div>
<label class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.settings.payment.customMethodUpstreamType') }}</label>
<input v-model="method.upstreamType" type="text" class="input mt-0.5" placeholder="credit_card" />
</div>
<div>
<label class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.settings.payment.customMethodDisplayName') }}</label>
<input v-model="method.displayName" type="text" class="input mt-0.5" placeholder="信用卡" />
</div>
<button
type="button"
class="rounded-lg border border-red-200 px-2.5 py-2 text-xs font-medium text-red-600 transition-colors hover:bg-red-50 dark:border-red-800/60 dark:text-red-300 dark:hover:bg-red-900/20"
@click="removeEasyPayCustomMethod(index)"
>
{{ t('common.delete') }}
</button>
</div>
</div>
</div>
<!-- Config fields -->
<div class="border-t border-gray-200 pt-4 dark:border-dark-700">
@@ -270,7 +313,7 @@ import Select from '@/components/common/Select.vue'
import type { SelectOption } from '@/components/common/Select.vue'
import ToggleSwitch from './ToggleSwitch.vue'
import type { ProviderInstance } from '@/types/payment'
import type { TypeOption } from './providerConfig'
import type { EasyPayCustomMethod, TypeOption } from './providerConfig'
import {
PROVIDER_CONFIG_FIELDS,
PROVIDER_SUPPORTED_TYPES,
@@ -282,6 +325,8 @@ import {
STRIPE_SDK_API_VERSION,
getAvailableTypes,
extractBaseUrl,
parseEasyPayCustomMethods,
serializeEasyPayCustomMethods,
} from './providerConfig'
/** Default payment_mode per provider key — "" means "no preference, use
@@ -365,6 +410,7 @@ const notifyBaseUrl = ref('')
const returnBaseUrl = ref('')
const limitsExpanded = ref(false)
const visibleFields = reactive<Record<string, boolean>>({})
const easyPayCustomMethods = reactive<EasyPayCustomMethod[]>([])
// --- Computed ---
const defaultBaseUrl = typeof window !== 'undefined' ? window.location.origin : ''
@@ -404,6 +450,16 @@ const paymentModeOptions = computed(() => {
const availableTypes = computed(() => {
const base = getAvailableTypes(form.provider_key, props.allPaymentTypes, props.redirectLabel)
if (form.provider_key === 'easypay') {
for (const method of normalizedEasyPayCustomMethods()) {
if (!base.some(opt => opt.value === method.type)) {
base.push({
value: method.type,
label: method.displayName || method.type,
})
}
}
}
// Resolve i18n labels for types not in allPaymentTypes (e.g. card, link inside stripe)
return base.map(opt =>
opt.label === opt.value
@@ -510,6 +566,28 @@ function toggleType(type: string) {
}
}
function normalizedEasyPayCustomMethods(): EasyPayCustomMethod[] {
return easyPayCustomMethods
.map(method => ({
type: normalizeEasyPayCustomMethodCode(method.type),
upstreamType: normalizeEasyPayCustomMethodCode(method.upstreamType),
displayName: method.displayName.trim(),
}))
.filter(method => method.type || method.upstreamType || method.displayName)
}
function normalizeEasyPayCustomMethodCode(value: string): string {
return value.trim().toLowerCase()
}
function addEasyPayCustomMethod() {
easyPayCustomMethods.push({ type: '', upstreamType: '', displayName: '' })
}
function removeEasyPayCustomMethod(index: number) {
easyPayCustomMethods.splice(index, 1)
}
function onKeyChange() {
form.supported_types = [...(PROVIDER_SUPPORTED_TYPES[form.provider_key] || [])]
form.payment_mode = defaultPaymentMode(form.provider_key)
@@ -524,6 +602,7 @@ function clearConfig() {
notifyBaseUrl.value = ''
returnBaseUrl.value = ''
limitsExpanded.value = false
easyPayCustomMethods.splice(0, easyPayCustomMethods.length)
}
function applyDefaults() {
@@ -581,6 +660,14 @@ function handleSave() {
emitValidationError(t('admin.settings.payment.validationNameRequired'))
return
}
if (form.provider_key === 'easypay') {
const validationError = validateEasyPayCustomMethods()
if (validationError) {
emitValidationError(validationError)
return
}
syncEasyPayCustomMethods()
}
// Validate required config fields — all non-optional fields must be filled.
// In edit mode, sensitive fields may be left blank to preserve the stored
// value (backend merges blanks by preserving the existing secret).
@@ -610,6 +697,9 @@ function handleSave() {
}
filteredConfig[k] = v
}
if (form.provider_key === 'easypay') {
filteredConfig.customMethods = serializeEasyPayCustomMethods(normalizedEasyPayCustomMethods())
}
// Inject computed callback URLs (each URL = independent base + fixed path)
// If base URL is empty, auto-fill with current domain
@@ -636,6 +726,56 @@ function handleSave() {
})
}
function syncEasyPayCustomMethods(): string[] {
if (form.provider_key !== 'easypay') return []
const baseTypes = new Set(PROVIDER_SUPPORTED_TYPES.easypay || [])
const customTypes: string[] = []
const seen = new Set<string>()
for (const method of normalizedEasyPayCustomMethods()) {
if (!method.type || !method.upstreamType) continue
if (seen.has(method.type)) continue
seen.add(method.type)
customTypes.push(method.type)
}
form.supported_types = form.supported_types
.map(type => normalizeEasyPayCustomMethodCode(type))
.filter(type => baseTypes.has(type) || customTypes.includes(type))
for (const customType of customTypes) {
if (!form.supported_types.includes(customType)) {
form.supported_types.push(customType)
}
}
return customTypes
}
function validateEasyPayCustomMethods(): string | null {
const seen = new Set<string>()
for (const method of normalizedEasyPayCustomMethods()) {
const hasAnyValue = Boolean(method.type || method.upstreamType || method.displayName)
if (!hasAnyValue) continue
if (!method.type || !method.upstreamType) {
return t('admin.settings.payment.validationEasyPayCustomMethodRequired')
}
if (!/^[a-z0-9_-]+$/.test(method.type)) {
return t('admin.settings.payment.validationEasyPayCustomMethodTypeInvalid')
}
if (!/^[a-z0-9_-]+$/.test(method.upstreamType)) {
return t('admin.settings.payment.validationEasyPayCustomMethodUpstreamTypeInvalid')
}
if ((PROVIDER_SUPPORTED_TYPES.easypay || []).includes(method.type)) {
return t('admin.settings.payment.validationEasyPayCustomMethodReserved')
}
if (method.type.startsWith('alipay') || method.type.startsWith('wxpay')) {
return t('admin.settings.payment.validationEasyPayCustomMethodPrefixReserved')
}
if (seen.has(method.type)) {
return t('admin.settings.payment.validationEasyPayCustomMethodDuplicate')
}
seen.add(method.type)
}
return null
}
function emitValidationError(msg: string) {
// Use a custom event or inject appStore — for now use window alert fallback
// The parent handles this via the save event validation
@@ -677,6 +817,10 @@ function loadProvider(provider: ProviderInstance) {
for (const [k, v] of Object.entries(provider.config)) {
// Skip notifyUrl/returnUrl — they are derived from callbackBaseUrl
if (k === 'notifyUrl' || k === 'returnUrl') continue
if (k === 'customMethods' && provider.provider_key === 'easypay') {
easyPayCustomMethods.push(...parseEasyPayCustomMethods(v))
continue
}
config[k] = v
}
// Extract base URLs from existing callback URLs
@@ -79,7 +79,7 @@ import { usePaymentStore } from '@/stores/payment'
import { useAppStore } from '@/stores'
import { paymentAPI } from '@/api/payment'
import { extractI18nErrorMessage } from '@/utils/apiError'
import { getPaymentPopupFeatures } from '@/components/payment/providerConfig'
import { getPaymentPopupFeatures, isBuiltInAlipayMethod, isBuiltInWxpayMethod } from '@/components/payment/providerConfig'
import type { PaymentOrder } from '@/types/payment'
import { currencySymbol } from '@/components/payment/currency'
import QRCode from 'qrcode'
@@ -122,8 +122,8 @@ let lastVerifyAt = 0
const VERIFY_RETRY_INTERVAL_MS = 15000
const VERIFY_RETRY_MAX_ATTEMPTS = 6
const isAlipay = computed(() => props.paymentType.includes('alipay'))
const isWxpay = computed(() => props.paymentType.includes('wxpay'))
const isAlipay = computed(() => isBuiltInAlipayMethod(props.paymentType))
const isWxpay = computed(() => isBuiltInWxpayMethod(props.paymentType))
const dialogTitle = computed(() => {
if (success.value) return t('payment.result.success')
@@ -79,7 +79,7 @@
<!-- Brand logo overlay -->
<div class="pointer-events-none absolute inset-0 flex items-center justify-center">
<span :class="['rounded-full p-2 shadow ring-2 ring-white', qrLogoBgClass]">
<img :src="isAlipay ? alipayIcon : wxpayIcon" alt="" class="h-5 w-5 brightness-0 invert" />
<img :src="qrLogoIcon" alt="" class="h-5 w-5 brightness-0 invert" />
</span>
</div>
</div>
@@ -128,13 +128,14 @@ import { usePaymentStore } from '@/stores/payment'
import { useAppStore } from '@/stores'
import { paymentAPI } from '@/api/payment'
import { extractI18nErrorMessage } from '@/utils/apiError'
import { getPaymentPopupFeatures } from '@/components/payment/providerConfig'
import { getPaymentPopupFeatures, isBuiltInAlipayMethod, isBuiltInWxpayMethod } from '@/components/payment/providerConfig'
import { currencySymbol, formatPaymentAmount, normalizePaymentCurrency } from '@/components/payment/currency'
import type { PaymentOrder } from '@/types/payment'
import Icon from '@/components/icons/Icon.vue'
import QRCode from 'qrcode'
import alipayIcon from '@/assets/icons/alipay.svg'
import wxpayIcon from '@/assets/icons/wxpay.svg'
import paymentIcon from '@/assets/icons/payment.svg'
const props = defineProps<{
orderId: number
@@ -182,8 +183,8 @@ let lastVerifyAt = 0
const VERIFY_RETRY_INTERVAL_MS = 15000
const VERIFY_RETRY_MAX_ATTEMPTS = 6
const isAlipay = computed(() => props.paymentType.includes('alipay'))
const isWxpay = computed(() => props.paymentType.includes('wxpay'))
const isAlipay = computed(() => isBuiltInAlipayMethod(props.paymentType))
const isWxpay = computed(() => isBuiltInWxpayMethod(props.paymentType))
const qrBorderClass = computed(() => {
if (isAlipay.value) return 'border-[#00AEEF] bg-blue-50 dark:border-[#00AEEF]/70 dark:bg-blue-950/20'
@@ -197,6 +198,12 @@ const qrLogoBgClass = computed(() => {
return 'bg-gray-400'
})
const qrLogoIcon = computed(() => {
if (isAlipay.value) return alipayIcon
if (isWxpay.value) return wxpayIcon
return paymentIcon
})
const scanTitle = computed(() => {
if (isAlipay.value) return t('payment.qr.scanAlipay')
if (isWxpay.value) return t('payment.qr.scanWxpay')
@@ -0,0 +1,37 @@
import { describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import PaymentMethodSelector from '@/components/payment/PaymentMethodSelector.vue'
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string, fallback?: string) => fallback ?? key,
}),
}))
describe('PaymentMethodSelector', () => {
it('shows the configured display name for custom EasyPay methods', () => {
const wrapper = mount(PaymentMethodSelector, {
props: {
selected: 'ldc',
methods: [{ type: 'ldc', display_name: 'LDC Pay', fee_rate: 0, available: true }],
},
})
expect(wrapper.text()).toContain('LDC Pay')
expect(wrapper.text()).not.toContain('ldc')
expect(wrapper.text()).not.toContain('payment.methods.ldc')
})
it('uses the generic selected style for custom methods that contain built-in names', () => {
const wrapper = mount(PaymentMethodSelector, {
props: {
selected: 'card_alipay',
methods: [{ type: 'card_alipay', display_name: 'Card Pay', fee_rate: 0, available: true }],
},
})
const button = wrapper.get('button')
expect(button.classes()).toContain('border-primary-500')
expect(button.classes()).not.toContain('border-[#02A9F1]')
})
})
@@ -7,6 +7,12 @@ import type { ProviderInstance } from '@/types/payment'
const messages: Record<string, string> = {
'admin.settings.payment.providerConfig': 'Credentials',
'admin.settings.payment.easypayCustomMethods': 'Custom EasyPay methods',
'admin.settings.payment.easypayCustomMethodsHint': 'Add provider-specific EasyPay type values.',
'admin.settings.payment.addCustomMethod': 'Add method',
'admin.settings.payment.customMethodType': 'Payment type',
'admin.settings.payment.customMethodUpstreamType': 'Upstream type',
'admin.settings.payment.customMethodDisplayName': 'Display name',
'admin.settings.payment.paymentGuideTrigger': 'View payment guide',
'admin.settings.payment.alipayGuideSummary': 'Desktop prefers QR precreate and falls back to cashier; mobile prefers WAP checkout.',
'admin.settings.payment.wxpayGuideSummary': 'Desktop prefers Native QR; mobile routes to JSAPI or H5 based on browser context.',
@@ -53,12 +59,14 @@ function mountDialog(options: { editing?: ProviderInstance | null } = {}) {
saving: false,
editing: options.editing ?? null,
allKeyOptions: [
{ value: 'easypay', label: 'EasyPay' },
{ value: 'alipay', label: 'Alipay' },
{ value: 'wxpay', label: 'WeChat Pay' },
{ value: 'stripe', label: 'Stripe' },
{ value: 'airwallex', label: 'Airwallex' },
],
enabledKeyOptions: [
{ value: 'easypay', label: 'EasyPay' },
{ value: 'alipay', label: 'Alipay' },
{ value: 'wxpay', label: 'WeChat Pay' },
{ value: 'airwallex', label: 'Airwallex' },
@@ -156,4 +164,85 @@ describe('PaymentProviderDialog payment guide', () => {
const payload = wrapper.emitted('save')?.[0]?.[0] as { config: Record<string, string> }
expect(payload.config.accountId).toBe('')
})
it('serializes EasyPay custom methods and adds them to supported_types', async () => {
const provider = providerFactory({
provider_key: 'easypay',
name: 'EasyPay',
config: {
pid: 'pid-1',
apiBase: 'https://pay.example.com',
notifyUrl: 'https://example.com/api/v1/payment/webhook/easypay',
returnUrl: 'https://example.com/payment/result',
},
supported_types: ['alipay', 'wxpay'],
payment_mode: 'qrcode',
})
const wrapper = mountDialog({ editing: provider })
;(wrapper.vm as unknown as { loadProvider: (provider: ProviderInstance) => void }).loadProvider(provider)
await nextTick()
await wrapper.find('button.btn-sm').trigger('click')
await nextTick()
const inputs = wrapper.findAll('input[type="text"]')
const customTypeInputs = inputs.filter(input => (input.element as HTMLInputElement).placeholder === 'credit_card')
const ldcTypeInput = customTypeInputs[0]
const upstreamTypeInput = customTypeInputs[1]
const displayNameInput = inputs.find(input => (input.element as HTMLInputElement).placeholder === '信用卡')
if (!ldcTypeInput || !upstreamTypeInput || !displayNameInput) {
throw new Error('custom method inputs not found')
}
await ldcTypeInput.setValue('ldc')
await upstreamTypeInput.setValue('epay')
await displayNameInput.setValue('LDC')
await wrapper.find('form').trigger('submit.prevent')
const payload = wrapper.emitted('save')?.[0]?.[0] as {
config: Record<string, string>
supported_types: string[]
}
expect(payload.config.customMethods).toBe('[{"type":"ldc","upstreamType":"epay","displayName":"LDC"}]')
expect(payload.supported_types).toEqual(['alipay', 'wxpay', 'ldc'])
})
it('rejects custom EasyPay method types with built-in payment prefixes', async () => {
const provider = providerFactory({
provider_key: 'easypay',
name: 'EasyPay',
config: {
pid: 'pid-1',
apiBase: 'https://pay.example.com',
notifyUrl: 'https://example.com/api/v1/payment/webhook/easypay',
returnUrl: 'https://example.com/payment/result',
},
supported_types: ['alipay', 'wxpay'],
payment_mode: 'qrcode',
})
const wrapper = mountDialog({ editing: provider })
;(wrapper.vm as unknown as { loadProvider: (provider: ProviderInstance) => void }).loadProvider(provider)
await nextTick()
await wrapper.find('button.btn-sm').trigger('click')
await nextTick()
const inputs = wrapper.findAll('input[type="text"]')
const customTypeInputs = inputs.filter(input => (input.element as HTMLInputElement).placeholder === 'credit_card')
const typeInput = customTypeInputs[0]
const upstreamTypeInput = customTypeInputs[1]
const displayNameInput = inputs.find(input => (input.element as HTMLInputElement).placeholder === '信用卡')
if (!typeInput || !upstreamTypeInput || !displayNameInput) {
throw new Error('custom method inputs not found')
}
await typeInput.setValue('alipay_hk')
await upstreamTypeInput.setValue('hkpay')
await displayNameInput.setValue('Hong Kong Alipay')
await wrapper.find('form').trigger('submit.prevent')
expect(wrapper.emitted('save')).toBeUndefined()
})
})
@@ -132,6 +132,28 @@ describe('PaymentStatusPanel', () => {
openSpy.mockRestore()
})
it('uses generic QR copy for custom methods that contain built-in names', async () => {
const wrapper = mount(PaymentStatusPanel, {
props: {
orderId: 42,
qrCode: 'https://pay.example.com/qr/42',
expiresAt: '2099-01-01T12:30:00Z',
paymentType: 'card_alipay',
orderType: 'balance',
},
global: {
stubs: {
Icon: true,
},
},
})
await flushPromises()
expect(wrapper.text()).toContain('payment.qr.scanToPay')
expect(wrapper.text()).not.toContain('payment.qr.scanAlipay')
})
it('actively verifies a stuck pending order and settles it when upstream confirms payment', async () => {
pollOrderStatus.mockResolvedValue(orderFactory('PENDING'))
verifyOrder.mockResolvedValue({
@@ -59,6 +59,18 @@ describe('getVisibleMethods', () => {
expect(visible.alipay.single_min).toBe(2)
expect(visible.wxpay.fee_rate).toBe(1.2)
})
it('keeps custom EasyPay methods as visible methods', () => {
const visible = getVisibleMethods({
ldc: methodLimit({ single_min: 3 }),
usdt_trc20: methodLimit({ fee_rate: 1 }),
})
expect(visible).toEqual({
ldc: methodLimit({ single_min: 3 }),
usdt_trc20: methodLimit({ fee_rate: 1 }),
})
})
})
describe('decidePaymentLaunch', () => {
@@ -1,5 +1,12 @@
import { describe, expect, it } from 'vitest'
import { PAYMENT_CURRENCY_OPTIONS, PROVIDER_CONFIG_FIELDS } from '@/components/payment/providerConfig'
import {
PAYMENT_CURRENCY_OPTIONS,
PROVIDER_CONFIG_FIELDS,
isBuiltInAlipayMethod,
isBuiltInWxpayMethod,
parseEasyPayCustomMethods,
serializeEasyPayCustomMethods,
} from '@/components/payment/providerConfig'
function findField(providerKey: string, key: string) {
const fields = PROVIDER_CONFIG_FIELDS[providerKey] || []
@@ -50,3 +57,39 @@ describe('PROVIDER_CONFIG_FIELDS.stripe', () => {
expect(currency?.options).toBe(PAYMENT_CURRENCY_OPTIONS)
})
})
describe('EasyPay custom methods config', () => {
it('parses customMethods from the JSON string stored in provider config', () => {
expect(parseEasyPayCustomMethods(
'[{"type":"ldc","upstreamType":"epay","displayName":"LDC"},{"type":"usdt_trc20","upstreamType":"usdt","displayName":"USDT-TRC20"}]',
)).toEqual([
{ type: 'ldc', upstreamType: 'epay', displayName: 'LDC' },
{ type: 'usdt_trc20', upstreamType: 'usdt', displayName: 'USDT-TRC20' },
])
})
it('serializes non-empty custom methods into the config string format', () => {
expect(serializeEasyPayCustomMethods([
{ type: 'ldc', upstreamType: 'epay', displayName: 'LDC' },
{ type: ' ', upstreamType: 'ignored', displayName: 'Ignored' },
{ type: 'usdt_trc20', upstreamType: 'usdt', displayName: '' },
])).toBe('[{"type":"ldc","upstreamType":"epay","displayName":"LDC"},{"type":"usdt_trc20","upstreamType":"usdt","displayName":""}]')
})
it('returns an empty string for invalid or empty custom methods', () => {
expect(parseEasyPayCustomMethods('not-json')).toEqual([])
expect(serializeEasyPayCustomMethods([{ type: '', upstreamType: 'epay', displayName: 'LDC' }])).toBe('')
})
})
describe('built-in payment method helpers', () => {
it('only treats exact built-in aliases as Alipay or WeChat Pay', () => {
expect(isBuiltInAlipayMethod('alipay')).toBe(true)
expect(isBuiltInAlipayMethod('alipay_direct')).toBe(true)
expect(isBuiltInAlipayMethod('card_alipay')).toBe(false)
expect(isBuiltInWxpayMethod('wxpay')).toBe(true)
expect(isBuiltInWxpayMethod('wxpay_direct')).toBe(true)
expect(isBuiltInWxpayMethod('card_wxpay')).toBe(false)
})
})
@@ -99,7 +99,7 @@ export function getVisibleMethods(methods: Record<string, MethodLimit>): Record<
const visible: Record<string, MethodLimit> = {}
Object.entries(methods).forEach(([type, limit]) => {
const normalized = normalizeVisibleMethod(type)
const normalized = normalizeVisibleMethod(type) || type.trim()
if (!normalized) return
const isCanonical = type === normalized
@@ -21,6 +21,12 @@ export interface TypeOption {
[key: string]: unknown
}
export interface EasyPayCustomMethod {
type: string
upstreamType: string
displayName: string
}
/** Callback URL paths for a provider. */
export interface CallbackPaths {
notifyUrl?: string
@@ -44,6 +50,14 @@ export const EASYPAY_PAYMENT_MODES = ['qrcode', 'popup'] as const
/** Fixed display order for user-facing payment methods */
export const METHOD_ORDER = ['alipay', 'alipay_direct', 'wxpay', 'wxpay_direct', 'stripe', 'airwallex'] as const
export function isBuiltInAlipayMethod(type: string): boolean {
return type === 'alipay' || type === 'alipay_direct'
}
export function isBuiltInWxpayMethod(type: string): boolean {
return type === 'wxpay' || type === 'wxpay_direct'
}
/** Payment mode constants */
export const PAYMENT_MODE_QRCODE = 'qrcode'
export const PAYMENT_MODE_POPUP = 'popup'
@@ -171,6 +185,34 @@ export function getAvailableTypes(
return types.map(t => resolveTypeLabel(t, providerKey, allTypes, redirectLabel))
}
export function parseEasyPayCustomMethods(raw: string | undefined): EasyPayCustomMethod[] {
if (!raw || !raw.trim()) return []
try {
const parsed = JSON.parse(raw)
if (!Array.isArray(parsed)) return []
return parsed
.map(item => ({
type: String(item?.type || '').trim(),
upstreamType: String(item?.upstreamType || '').trim(),
displayName: String(item?.displayName || '').trim(),
}))
.filter(item => item.type && item.upstreamType)
} catch {
return []
}
}
export function serializeEasyPayCustomMethods(methods: EasyPayCustomMethod[]): string {
const clean = methods
.map(method => ({
type: method.type.trim(),
upstreamType: method.upstreamType.trim(),
displayName: method.displayName.trim(),
}))
.filter(method => method.type && method.upstreamType)
return clean.length ? JSON.stringify(clean) : ''
}
/** Extract base URL from a full callback URL by removing the known path suffix. */
export function extractBaseUrl(fullUrl: string, path: string): string {
if (!fullUrl) return ''