mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-30 16:23:31 +00:00
merge: sync batch image branch with origin main
This commit is contained in:
@@ -24,6 +24,7 @@ export interface AdminPaymentConfig {
|
||||
enabled_payment_types: string[]
|
||||
balance_disabled: boolean
|
||||
balance_recharge_multiplier: number
|
||||
subscription_usd_to_cny_rate: number
|
||||
load_balance_strategy: string
|
||||
product_name_prefix: string
|
||||
product_name_suffix: string
|
||||
@@ -42,6 +43,7 @@ export interface UpdatePaymentConfigRequest {
|
||||
enabled_payment_types?: string[]
|
||||
balance_disabled?: boolean
|
||||
balance_recharge_multiplier?: number
|
||||
subscription_usd_to_cny_rate?: number
|
||||
load_balance_strategy?: string
|
||||
product_name_prefix?: string
|
||||
product_name_suffix?: string
|
||||
|
||||
@@ -589,6 +589,7 @@ export interface SystemSettings {
|
||||
payment_enabled_types: string[];
|
||||
payment_balance_disabled: boolean;
|
||||
payment_balance_recharge_multiplier: number;
|
||||
payment_subscription_usd_to_cny_rate: number;
|
||||
payment_recharge_fee_rate: number;
|
||||
payment_load_balance_strategy: string;
|
||||
payment_product_name_prefix: string;
|
||||
@@ -860,6 +861,7 @@ export interface UpdateSettingsRequest {
|
||||
payment_enabled_types?: string[];
|
||||
payment_balance_disabled?: boolean;
|
||||
payment_balance_recharge_multiplier?: number;
|
||||
payment_subscription_usd_to_cny_rate?: number;
|
||||
payment_recharge_fee_rate?: number;
|
||||
payment_load_balance_strategy?: string;
|
||||
payment_product_name_prefix?: string;
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" role="img" aria-label="Payment">
|
||||
<path d="M512 64c247.424 0 448 200.576 448 448S759.424 960 512 960 64 759.424 64 512 264.576 64 512 64Z" fill="#4F46E5"/>
|
||||
<path d="M307 329c0-39.765 32.235-72 72-72h274c39.765 0 72 32.235 72 72v36H371c-35.346 0-64 28.654-64 64V329Z" fill="#C7D2FE"/>
|
||||
<path d="M260 413c0-35.346 28.654-64 64-64h392c35.346 0 64 28.654 64 64v258c0 35.346-28.654 64-64 64H324c-35.346 0-64-28.654-64-64V413Z" fill="#FFFFFF"/>
|
||||
<path d="M636 481c0-30.928 25.072-56 56-56h88v214h-88c-30.928 0-56-25.072-56-56V481Z" fill="#EEF2FF"/>
|
||||
<path d="M708 494c30.928 0 56 25.072 56 56s-25.072 56-56 56-56-25.072-56-56 25.072-56 56-56Z" fill="#FBBF24"/>
|
||||
<path d="M348 457c0-17.673 14.327-32 32-32h172c17.673 0 32 14.327 32 32s-14.327 32-32 32H380c-17.673 0-32-14.327-32-32Z" fill="#4F46E5"/>
|
||||
<path d="M348 557c0-15.464 12.536-28 28-28h152c15.464 0 28 12.536 28 28s-12.536 28-28 28H376c-15.464 0-28-12.536-28-28Z" fill="#A5B4FC"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1012 B |
@@ -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 ''
|
||||
|
||||
@@ -7,6 +7,8 @@ const openaiModels = [
|
||||
// GPT-5.2 系列
|
||||
'gpt-5.2', 'gpt-5.2-2025-12-11', 'gpt-5.2-chat-latest',
|
||||
'gpt-5.2-pro', 'gpt-5.2-pro-2025-12-11',
|
||||
// GPT-5.6 系列
|
||||
'gpt-5.6-sol', 'gpt-5.6-terra', 'gpt-5.6-luna',
|
||||
// GPT-5.5 系列
|
||||
'gpt-5.5',
|
||||
// GPT-5.4 系列
|
||||
@@ -272,6 +274,9 @@ const openaiPresetMappings = [
|
||||
{ label: 'o3', from: 'o3', to: 'o3', color: 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400' },
|
||||
{ label: 'GPT-5.3 Codex Spark', from: 'gpt-5.3-codex-spark', to: 'gpt-5.3-codex-spark', color: 'bg-teal-100 text-teal-700 hover:bg-teal-200 dark:bg-teal-900/30 dark:text-teal-400' },
|
||||
{ label: 'GPT-5.2', from: 'gpt-5.2', to: 'gpt-5.2', color: 'bg-red-100 text-red-700 hover:bg-red-200 dark:bg-red-900/30 dark:text-red-400' },
|
||||
{ label: 'GPT-5.6 Sol', from: 'gpt-5.6-sol', to: 'gpt-5.6-sol', color: 'bg-orange-100 text-orange-700 hover:bg-orange-200 dark:bg-orange-900/30 dark:text-orange-400' },
|
||||
{ label: 'GPT-5.6 Terra', from: 'gpt-5.6-terra', to: 'gpt-5.6-terra', color: 'bg-lime-100 text-lime-700 hover:bg-lime-200 dark:bg-lime-900/30 dark:text-lime-400' },
|
||||
{ label: 'GPT-5.6 Luna', from: 'gpt-5.6-luna', to: 'gpt-5.6-luna', color: 'bg-sky-100 text-sky-700 hover:bg-sky-200 dark:bg-sky-900/30 dark:text-sky-400' },
|
||||
{ label: 'GPT-5.5', from: 'gpt-5.5', to: 'gpt-5.5', color: 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400' },
|
||||
{ label: 'GPT-5.4', from: 'gpt-5.4', to: 'gpt-5.4', color: 'bg-rose-100 text-rose-700 hover:bg-rose-200 dark:bg-rose-900/30 dark:text-rose-400' },
|
||||
{ label: 'Haiku→5.4', from: 'claude-haiku-4-5-20251001', to: 'gpt-5.4', color: 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400' },
|
||||
|
||||
@@ -755,6 +755,7 @@ export default {
|
||||
deleteConfirmMessage: "Are you sure you want to delete '{name}'? This action cannot be undone.",
|
||||
apiKey: 'API Key',
|
||||
group: 'Group',
|
||||
currentConcurrency: 'Current Concurrency',
|
||||
noGroup: 'No group',
|
||||
searchGroup: 'Search groups...',
|
||||
noGroupFound: 'No groups found',
|
||||
@@ -3693,6 +3694,24 @@ export default {
|
||||
interceptWarmupRequests: 'Intercept Warmup Requests',
|
||||
interceptWarmupRequestsDesc:
|
||||
'When enabled, warmup requests like title generation will return mock responses without consuming upstream tokens',
|
||||
headerOverride: {
|
||||
title: 'Header Override',
|
||||
hint: 'Override same-named request headers on forwarding (case-insensitive)',
|
||||
info: 'Applies to outbound requests of this account only: configured headers override client/gateway-generated headers of the same name before forwarding. Auth headers (authorization, x-api-key) and connection-control headers cannot be overridden.',
|
||||
namePlaceholder: 'Header name (e.g. user-agent)',
|
||||
valuePlaceholder: 'Override value (leave empty to skip)',
|
||||
addRow: 'Add Header',
|
||||
fillTemplate: 'Fill Template',
|
||||
emptyValueHint: 'Rows with an empty value are placeholders and do not override anything.',
|
||||
bulkDisableHint: 'Saving will disable header override and clear existing configuration on the selected accounts.',
|
||||
bulkReplaceHint: 'Saving will replace the existing header override configuration on all selected accounts with the rows below.',
|
||||
bulkEmptyRows: 'Add at least one header row before saving, or turn the toggle off to clear existing configuration.',
|
||||
invalidName: 'Invalid header name (only letters, digits and !#$%&\'*+-.^_`|~ are allowed)',
|
||||
blockedName: 'This header cannot be overridden (auth and connection-control headers are managed by the system)',
|
||||
duplicateName: 'Duplicate header name (matching is case-insensitive)',
|
||||
invalidValue: 'Invalid header value (control characters are not allowed; max length 8192)',
|
||||
tooManyEntries: 'Too many header override entries (max 64)'
|
||||
},
|
||||
autoPauseOnExpired: 'Auto Pause On Expired',
|
||||
autoPauseOnExpiredDesc: 'When enabled, the account will auto pause scheduling after it expires',
|
||||
autoPause5hThreshold: '5h Usage Threshold (%)',
|
||||
@@ -6203,6 +6222,10 @@ export default {
|
||||
balanceRechargeMultiplier: 'Balance Recharge Multiplier',
|
||||
balanceRechargeMultiplierHint: 'How many USD balance the user receives for each 1 CNY paid',
|
||||
balanceRechargePreview: 'Preview: 1 CNY = {usd} USD',
|
||||
subscriptionUsdToCnyRate: 'Subscription USD to CNY Rate',
|
||||
subscriptionUsdToCnyRateHint:
|
||||
'CNY charged per 1 USD of plan price on CNY channels (e.g. 7.15). 0 or empty = disabled, plan price is charged as-is. When enabled, all plan prices must be set in USD',
|
||||
subscriptionUsdToCnyRateDisabled: 'Disabled (price charged as-is)',
|
||||
rechargeFeeRate: 'Recharge Fee Rate',
|
||||
rechargeFeeRateHint: 'Percentage of service fee charged on top of recharge amount, 0 means no fee',
|
||||
rechargeFeePreview: 'Preview: Recharge 100, fee {fee}',
|
||||
@@ -6248,6 +6271,12 @@ export default {
|
||||
validationNameRequired: 'Provider name is required',
|
||||
validationTypesRequired: 'Please select at least one supported payment type',
|
||||
validationFieldRequired: '{field} is required',
|
||||
validationEasyPayCustomMethodRequired: 'Each custom EasyPay method requires both a payment type and an upstream type',
|
||||
validationEasyPayCustomMethodTypeInvalid: 'Custom EasyPay payment types may only contain lowercase letters, digits, underscores, and hyphens',
|
||||
validationEasyPayCustomMethodUpstreamTypeInvalid: 'EasyPay upstream types may only contain lowercase letters, digits, underscores, and hyphens',
|
||||
validationEasyPayCustomMethodReserved: 'Custom EasyPay payment types cannot use built-in alipay or wxpay',
|
||||
validationEasyPayCustomMethodPrefixReserved: 'Custom EasyPay payment types cannot start with alipay or wxpay',
|
||||
validationEasyPayCustomMethodDuplicate: 'Custom EasyPay payment types must be unique',
|
||||
field_apiBase: 'API Base URL',
|
||||
field_notifyUrl: 'Notify URL',
|
||||
field_returnUrl: 'Return URL',
|
||||
@@ -6277,6 +6306,12 @@ export default {
|
||||
field_cid: 'Channel ID',
|
||||
field_cidAlipay: 'Alipay Channel ID',
|
||||
field_cidWxpay: 'WeChat Channel ID',
|
||||
easypayCustomMethods: 'Custom EasyPay methods',
|
||||
easypayCustomMethodsHint: 'Add provider-specific methods supported by this EasyPay endpoint. The payment type is stored on Sub2API orders; the upstream type is sent as EasyPay type.',
|
||||
addCustomMethod: 'Add method',
|
||||
customMethodType: 'Payment type',
|
||||
customMethodUpstreamType: 'Upstream type',
|
||||
customMethodDisplayName: 'Display name',
|
||||
stripeWebhookHint: 'Configure the following URL as a Webhook endpoint in Stripe Dashboard:',
|
||||
stripeWebhookApiVersionHint: 'Set this Webhook endpoint API version to match the integrated Stripe SDK. Recommended: {version}. A mismatch can cause webhook parsing errors.',
|
||||
airwallexWebhookHint: 'Configure the following URL as a Webhook endpoint in Airwallex. Select at least Payment Intent -> Succeeded (payment_intent.succeeded), preferably also Payment Intent -> Cancelled (payment_intent.cancelled). Use the account default or latest stable API version.',
|
||||
|
||||
@@ -754,6 +754,7 @@ export default {
|
||||
deleteConfirmMessage: "确定要删除 '{name}' 吗?此操作无法撤销。",
|
||||
apiKey: 'API 密钥',
|
||||
group: '分组',
|
||||
currentConcurrency: '当前并发',
|
||||
noGroup: '无分组',
|
||||
searchGroup: '搜索分组...',
|
||||
noGroupFound: '未找到匹配的分组',
|
||||
@@ -3860,6 +3861,24 @@ export default {
|
||||
errorCodeExists: '该错误码已被选中',
|
||||
interceptWarmupRequests: '拦截预热请求',
|
||||
interceptWarmupRequestsDesc: '启用后,标题生成等预热请求将返回 mock 响应,不消耗上游 token',
|
||||
headerOverride: {
|
||||
title: '请求头覆写',
|
||||
hint: '转发时用配置值覆盖同名请求头(不区分大小写)',
|
||||
info: '仅对本账号的出站请求生效:配置的请求头会在转发前覆盖客户端/网关生成的同名头。认证头(authorization、x-api-key)与连接控制头不允许覆写。',
|
||||
namePlaceholder: '请求头名称(如 user-agent)',
|
||||
valuePlaceholder: '覆写值(留空表示不覆写)',
|
||||
addRow: '添加请求头',
|
||||
fillTemplate: '填入模板',
|
||||
emptyValueHint: '值留空的行不会参与覆盖,仅作为待填写的占位。',
|
||||
bulkDisableHint: '保存后将关闭所选账号的请求头覆写并清空已有配置。',
|
||||
bulkReplaceHint: '保存后将用下方配置整体替换所选账号已有的请求头覆写配置。',
|
||||
bulkEmptyRows: '请至少添加一行请求头再保存;如需清空已有配置,请关闭上方开关。',
|
||||
invalidName: '请求头名称格式不正确(仅允许字母、数字和 !#$%&\'*+-.^_`|~ 字符)',
|
||||
blockedName: '该请求头不允许覆写(认证头与连接控制头由系统管理)',
|
||||
duplicateName: '存在重复的请求头名称(匹配不区分大小写)',
|
||||
invalidValue: '请求头值不合法(不允许控制字符,长度不超过 8192)',
|
||||
tooManyEntries: '请求头覆写条目过多(最多 64 条)'
|
||||
},
|
||||
autoPauseOnExpired: '过期自动暂停调度',
|
||||
autoPauseOnExpiredDesc: '启用后,账号过期将自动暂停调度',
|
||||
autoPause5hThreshold: '5h 用量阈值(%)',
|
||||
@@ -6357,6 +6376,10 @@ export default {
|
||||
balanceRechargeMultiplier: '余额充值倍率',
|
||||
balanceRechargeMultiplierHint: '用户每支付 1 CNY 可获得多少 USD 余额',
|
||||
balanceRechargePreview: '预览:1 CNY = {usd} USD',
|
||||
subscriptionUsdToCnyRate: '订阅 CNY 换算汇率',
|
||||
subscriptionUsdToCnyRateHint:
|
||||
'CNY 支付通道下,套餐每 1 USD 价格收取多少 CNY(如 7.15)。0 或留空 = 不换算,订阅按 price 数值直接收款。启用后所有套餐 price 必须按 USD 定价',
|
||||
subscriptionUsdToCnyRateDisabled: '未启用(按 price 直付)',
|
||||
rechargeFeeRate: '充值手续费率',
|
||||
rechargeFeeRateHint: '用户充值时额外收取的手续费百分比,0 表示不收取手续费',
|
||||
rechargeFeePreview: '预览:充值 100 元,手续费 {fee} 元',
|
||||
@@ -6402,6 +6425,12 @@ export default {
|
||||
validationNameRequired: '服务商名称不能为空',
|
||||
validationTypesRequired: '请至少选择一种支持的支付方式',
|
||||
validationFieldRequired: '{field} 不能为空',
|
||||
validationEasyPayCustomMethodRequired: '每个易支付自定义方式都必须填写支付方式和上游 type',
|
||||
validationEasyPayCustomMethodTypeInvalid: '易支付自定义支付方式只能包含小写字母、数字、下划线和短横线',
|
||||
validationEasyPayCustomMethodUpstreamTypeInvalid: '易支付上游 type 只能包含小写字母、数字、下划线和短横线',
|
||||
validationEasyPayCustomMethodReserved: '易支付自定义支付方式不能使用内置的 alipay 或 wxpay',
|
||||
validationEasyPayCustomMethodPrefixReserved: '易支付自定义支付方式不能以 alipay 或 wxpay 开头',
|
||||
validationEasyPayCustomMethodDuplicate: '易支付自定义支付方式不能重复',
|
||||
field_apiBase: 'API 基础地址',
|
||||
field_notifyUrl: '异步通知地址',
|
||||
field_returnUrl: '同步跳转地址',
|
||||
@@ -6431,6 +6460,12 @@ export default {
|
||||
field_cid: '支付渠道 ID',
|
||||
field_cidAlipay: '支付宝渠道 ID',
|
||||
field_cidWxpay: '微信渠道 ID',
|
||||
easypayCustomMethods: '易支付自定义支付方式',
|
||||
easypayCustomMethodsHint: '添加当前易支付服务商额外支持的支付方式。支付方式会记录到 Sub2API 订单中,上游 type 会作为易支付 type 参数提交。',
|
||||
addCustomMethod: '添加方式',
|
||||
customMethodType: '支付方式',
|
||||
customMethodUpstreamType: '上游 type',
|
||||
customMethodDisplayName: '显示名称',
|
||||
stripeWebhookHint: '请在 Stripe Dashboard 中将以下地址配置为 Webhook 端点:',
|
||||
stripeWebhookApiVersionHint: 'Webhook 端点的 API 版本请与当前集成的 Stripe SDK 对齐,建议选择 {version};版本不一致可能导致回调事件解析失败。',
|
||||
airwallexWebhookHint: '请在 Airwallex 后台将以下地址配置为 Webhook 端点;事件至少选择 Payment Intent -> Succeeded(payment_intent.succeeded),建议同时选择 Payment Intent -> Cancelled(payment_intent.cancelled);API version 选择账户默认或最新稳定版本。',
|
||||
|
||||
@@ -590,6 +590,7 @@ export interface ApiKey {
|
||||
expires_at: string | null // Expiration time (null = never expires)
|
||||
created_at: string
|
||||
updated_at: string
|
||||
current_concurrency: number
|
||||
group?: Group
|
||||
rate_limit_5h: number
|
||||
rate_limit_1d: number
|
||||
|
||||
@@ -34,6 +34,7 @@ export interface PaymentConfig {
|
||||
order_timeout_minutes: number
|
||||
balance_disabled: boolean
|
||||
balance_recharge_multiplier: number
|
||||
subscription_usd_to_cny_rate: number
|
||||
enabled_payment_types: PaymentType[]
|
||||
help_image_url: string
|
||||
help_text: string
|
||||
@@ -42,6 +43,7 @@ export interface PaymentConfig {
|
||||
|
||||
export interface MethodLimit {
|
||||
currency?: string
|
||||
display_name?: string
|
||||
daily_limit: number
|
||||
daily_used: number
|
||||
daily_remaining: number
|
||||
@@ -66,6 +68,8 @@ export interface CheckoutInfoResponse {
|
||||
plans: SubscriptionPlan[]
|
||||
balance_disabled: boolean
|
||||
balance_recharge_multiplier: number
|
||||
/** Subscription CNY conversion rate (1 USD = X CNY); 0 = disabled, plan price is charged as-is */
|
||||
subscription_usd_to_cny_rate: number
|
||||
recharge_fee_rate: number
|
||||
help_text: string
|
||||
help_image_url: string
|
||||
|
||||
@@ -679,14 +679,21 @@ const formatStickySchedulerScore = (score: AccountSchedulerGroupScore): string =
|
||||
}
|
||||
|
||||
const getSchedulerScoreRows = (account: Account): AccountSchedulerGroupScore[] => {
|
||||
if (!Array.isArray(account.scheduler_scores)) return []
|
||||
return account.scheduler_scores.filter(score => score.group_id != null)
|
||||
const groupRows = Array.isArray(account.scheduler_scores)
|
||||
? account.scheduler_scores.filter(score => score.group_id != null)
|
||||
: []
|
||||
if (groupRows.length) return groupRows
|
||||
// 未分组账号没有分组维度分数,回退展示后端返回的基础分
|
||||
if (account.scheduler_score) {
|
||||
return [{ group_id: null, ...account.scheduler_score }]
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
const formatSchedulerScoreGroup = (score: AccountSchedulerGroupScore): string => {
|
||||
if ('group_name' in score && score.group_name) return score.group_name
|
||||
if ('group_id' in score && score.group_id != null) return `#${score.group_id}`
|
||||
return '-'
|
||||
return t('admin.accounts.schedulerScore.ungrouped')
|
||||
}
|
||||
|
||||
const loadSavedColumns = () => {
|
||||
|
||||
@@ -6480,6 +6480,34 @@
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{
|
||||
t("admin.settings.payment.subscriptionUsdToCnyRate")
|
||||
}}</label>
|
||||
<input
|
||||
:value="form.payment_subscription_usd_to_cny_rate || ''"
|
||||
@input="
|
||||
form.payment_subscription_usd_to_cny_rate =
|
||||
parseFloat(
|
||||
($event.target as HTMLInputElement).value,
|
||||
) || 0
|
||||
"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
class="input"
|
||||
:placeholder="
|
||||
t(
|
||||
'admin.settings.payment.subscriptionUsdToCnyRateDisabled',
|
||||
)
|
||||
"
|
||||
/>
|
||||
<p class="mt-0.5 text-xs text-gray-400">
|
||||
{{
|
||||
t("admin.settings.payment.subscriptionUsdToCnyRateHint")
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{
|
||||
t("admin.settings.payment.rechargeFeeRate")
|
||||
@@ -8037,6 +8065,7 @@ const form = reactive<SettingsForm>({
|
||||
payment_order_timeout_minutes: 30,
|
||||
payment_balance_disabled: false,
|
||||
payment_balance_recharge_multiplier: 1,
|
||||
payment_subscription_usd_to_cny_rate: 0,
|
||||
payment_recharge_fee_rate: 0,
|
||||
payment_enabled_types: [],
|
||||
payment_help_image_url: "",
|
||||
@@ -9538,6 +9567,8 @@ async function saveSettings() {
|
||||
payment_balance_disabled: form.payment_balance_disabled,
|
||||
payment_balance_recharge_multiplier:
|
||||
Number(form.payment_balance_recharge_multiplier) || 1,
|
||||
payment_subscription_usd_to_cny_rate:
|
||||
Number(form.payment_subscription_usd_to_cny_rate) || 0,
|
||||
payment_recharge_fee_rate: Number(form.payment_recharge_fee_rate) || 0,
|
||||
payment_enabled_types: form.payment_enabled_types,
|
||||
payment_load_balance_strategy: form.payment_load_balance_strategy,
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
|
||||
import AccountsView from '../AccountsView.vue'
|
||||
|
||||
const {
|
||||
listAccounts,
|
||||
listWithEtag,
|
||||
getBatchTodayStats,
|
||||
getAllProxies,
|
||||
getAllGroups
|
||||
} = vi.hoisted(() => ({
|
||||
listAccounts: vi.fn(),
|
||||
listWithEtag: vi.fn(),
|
||||
getBatchTodayStats: vi.fn(),
|
||||
getAllProxies: vi.fn(),
|
||||
getAllGroups: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/api/admin', () => ({
|
||||
adminAPI: {
|
||||
accounts: {
|
||||
list: listAccounts,
|
||||
listWithEtag,
|
||||
getBatchTodayStats,
|
||||
delete: vi.fn(),
|
||||
batchClearError: vi.fn(),
|
||||
batchRefresh: vi.fn(),
|
||||
toggleSchedulable: vi.fn()
|
||||
},
|
||||
proxies: {
|
||||
getAll: getAllProxies
|
||||
},
|
||||
groups: {
|
||||
getAll: getAllGroups
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/app', () => ({
|
||||
useAppStore: () => ({
|
||||
showError: vi.fn(),
|
||||
showSuccess: vi.fn(),
|
||||
showInfo: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/auth', () => ({
|
||||
useAuthStore: () => ({
|
||||
token: 'test-token'
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', async () => {
|
||||
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
|
||||
return {
|
||||
...actual,
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Render the scheduler-score cell slot for every row so the fallback logic is observable.
|
||||
const DataTableStub = {
|
||||
props: ['columns', 'data'],
|
||||
template: `
|
||||
<div data-test="data-table">
|
||||
<div v-for="row in data" :key="row.id" :data-test="'scheduler-score-' + row.id">
|
||||
<slot name="cell-scheduler_score" :row="row" />
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
function mountView() {
|
||||
return mount(AccountsView, {
|
||||
global: {
|
||||
stubs: {
|
||||
AppLayout: { template: '<div><slot /></div>' },
|
||||
TablePageLayout: {
|
||||
template: '<div><slot name="filters" /><slot name="table" /><slot name="pagination" /></div>'
|
||||
},
|
||||
DataTable: DataTableStub,
|
||||
HelpTooltip: true,
|
||||
Pagination: true,
|
||||
ConfirmDialog: true,
|
||||
AccountTableActions: { template: '<div><slot name="beforeCreate" /><slot name="after" /></div>' },
|
||||
AccountTableFilters: { template: '<div></div>' },
|
||||
AccountBulkActionsBar: true,
|
||||
AccountActionMenu: true,
|
||||
ImportDataModal: true,
|
||||
ReAuthAccountModal: true,
|
||||
AccountTestModal: true,
|
||||
AccountStatsModal: true,
|
||||
ScheduledTestsPanel: true,
|
||||
SyncFromCrsModal: true,
|
||||
TempUnschedStatusModal: true,
|
||||
ErrorPassthroughRulesModal: true,
|
||||
TLSFingerprintProfilesModal: true,
|
||||
CreateAccountModal: true,
|
||||
EditAccountModal: true,
|
||||
BulkEditAccountModal: true,
|
||||
PlatformTypeBadge: true,
|
||||
AccountCapacityCell: true,
|
||||
AccountStatusIndicator: true,
|
||||
AccountTodayStatsCell: true,
|
||||
AccountGroupsCell: true,
|
||||
AccountUsageCell: true,
|
||||
Icon: true
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const baseAccount = {
|
||||
platform: 'openai',
|
||||
type: 'apikey',
|
||||
status: 'active',
|
||||
schedulable: true,
|
||||
concurrency: 1,
|
||||
priority: 0,
|
||||
error_message: null,
|
||||
last_used_at: null,
|
||||
expires_at: null,
|
||||
auto_pause_on_expired: false,
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
updated_at: '2026-01-01T00:00:00Z'
|
||||
}
|
||||
|
||||
describe('admin AccountsView scheduler score column', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
|
||||
listAccounts.mockReset()
|
||||
listWithEtag.mockReset()
|
||||
getBatchTodayStats.mockReset()
|
||||
getAllProxies.mockReset()
|
||||
getAllGroups.mockReset()
|
||||
|
||||
listAccounts.mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
...baseAccount,
|
||||
id: 1,
|
||||
name: 'ungrouped-openai',
|
||||
// 未分组账号:后端只返回基础分(scheduler_score),无分组维度分数
|
||||
scheduler_score: {
|
||||
base_score: 1.234567,
|
||||
sticky_score: 0,
|
||||
sticky_weighted_enabled: false
|
||||
}
|
||||
},
|
||||
{
|
||||
...baseAccount,
|
||||
id: 2,
|
||||
name: 'grouped-openai',
|
||||
scheduler_score: {
|
||||
base_score: 2,
|
||||
sticky_score: 3,
|
||||
sticky_weighted_enabled: true
|
||||
},
|
||||
scheduler_scores: [
|
||||
{
|
||||
group_id: 5,
|
||||
group_name: 'group-five',
|
||||
base_score: 2,
|
||||
sticky_score: 3,
|
||||
sticky_weighted_enabled: true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
...baseAccount,
|
||||
id: 3,
|
||||
name: 'no-score',
|
||||
platform: 'anthropic'
|
||||
}
|
||||
],
|
||||
total: 3,
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
pages: 1
|
||||
})
|
||||
listWithEtag.mockResolvedValue({
|
||||
notModified: true,
|
||||
etag: null,
|
||||
data: null
|
||||
})
|
||||
getBatchTodayStats.mockResolvedValue({ stats: {} })
|
||||
getAllProxies.mockResolvedValue([])
|
||||
getAllGroups.mockResolvedValue([])
|
||||
})
|
||||
|
||||
it('falls back to the base score for ungrouped accounts instead of showing a dash', async () => {
|
||||
const wrapper = mountView()
|
||||
await flushPromises()
|
||||
|
||||
const ungroupedCell = wrapper.find('[data-test="scheduler-score-1"]')
|
||||
expect(ungroupedCell.exists()).toBe(true)
|
||||
expect(ungroupedCell.text()).toContain('1.234567')
|
||||
expect(ungroupedCell.text()).toContain('admin.accounts.schedulerScore.ungrouped')
|
||||
expect(ungroupedCell.text()).not.toBe('-')
|
||||
})
|
||||
|
||||
it('renders per-group scores for grouped accounts', async () => {
|
||||
const wrapper = mountView()
|
||||
await flushPromises()
|
||||
|
||||
const groupedCell = wrapper.find('[data-test="scheduler-score-2"]')
|
||||
expect(groupedCell.exists()).toBe(true)
|
||||
expect(groupedCell.text()).toContain('group-five')
|
||||
expect(groupedCell.text()).toContain('2')
|
||||
})
|
||||
|
||||
it('still shows a dash when no scheduler score is available', async () => {
|
||||
const wrapper = mountView()
|
||||
await flushPromises()
|
||||
|
||||
const emptyCell = wrapper.find('[data-test="scheduler-score-3"]')
|
||||
expect(emptyCell.exists()).toBe(true)
|
||||
expect(emptyCell.text()).toBe('-')
|
||||
})
|
||||
})
|
||||
@@ -412,6 +412,7 @@ const baseSettingsResponse = {
|
||||
payment_enabled_types: [],
|
||||
payment_balance_disabled: false,
|
||||
payment_balance_recharge_multiplier: 1,
|
||||
payment_subscription_usd_to_cny_rate: 0,
|
||||
payment_recharge_fee_rate: 0,
|
||||
payment_load_balance_strategy: "round-robin",
|
||||
payment_product_name_prefix: "",
|
||||
|
||||
@@ -170,6 +170,19 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #cell-current_concurrency="{ value }">
|
||||
<span
|
||||
:class="[
|
||||
'inline-flex min-w-8 items-center justify-center rounded px-2 py-1 text-sm font-semibold tabular-nums',
|
||||
(value ?? 0) > 0
|
||||
? 'bg-emerald-50 text-emerald-700 ring-1 ring-emerald-200 dark:bg-emerald-900/25 dark:text-emerald-300 dark:ring-emerald-800'
|
||||
: 'bg-gray-100 text-gray-500 dark:bg-dark-700 dark:text-dark-400'
|
||||
]"
|
||||
>
|
||||
{{ value ?? 0 }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #cell-usage="{ row }">
|
||||
<div class="text-sm">
|
||||
<div class="flex items-center gap-1.5">
|
||||
@@ -1155,6 +1168,7 @@ const allColumns = computed<Column[]>(() => [
|
||||
{ key: 'name', label: t('common.name'), sortable: true },
|
||||
{ key: 'key', label: t('keys.apiKey'), sortable: false },
|
||||
{ key: 'group', label: t('keys.group'), sortable: false },
|
||||
{ key: 'current_concurrency', label: t('keys.currentConcurrency'), sortable: false },
|
||||
{ key: 'usage', label: t('keys.usage'), sortable: false },
|
||||
{ key: 'rate_limit', label: t('keys.rateLimitColumn'), sortable: false },
|
||||
{ key: 'expires_at', label: t('keys.expiresAt'), sortable: true },
|
||||
|
||||
@@ -41,6 +41,7 @@ import { usePaymentStore } from '@/stores/payment'
|
||||
import { paymentAPI } from '@/api/payment'
|
||||
import { extractI18nErrorMessage } from '@/utils/apiError'
|
||||
import { useAppStore } from '@/stores'
|
||||
import { isBuiltInAlipayMethod, isBuiltInWxpayMethod } from '@/components/payment/providerConfig'
|
||||
import QRCode from 'qrcode'
|
||||
import alipayIcon from '@/assets/icons/alipay.svg'
|
||||
import wxpayIcon from '@/assets/icons/wxpay.svg'
|
||||
@@ -69,8 +70,8 @@ const countdownDisplay = computed(() => {
|
||||
return m.toString().padStart(2, '0') + ':' + s.toString().padStart(2, '0')
|
||||
})
|
||||
|
||||
const isAlipay = computed(() => paymentType.value.includes('alipay'))
|
||||
const isWxpay = computed(() => paymentType.value.includes('wxpay'))
|
||||
const isAlipay = computed(() => isBuiltInAlipayMethod(paymentType.value))
|
||||
const isWxpay = computed(() => isBuiltInWxpayMethod(paymentType.value))
|
||||
|
||||
const scanTitle = computed(() => {
|
||||
if (isAlipay.value) return t('payment.qr.scanAlipay')
|
||||
|
||||
@@ -267,7 +267,7 @@ import type { SubscriptionPlan, CheckoutInfoResponse, CreateOrderResult, OrderTy
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
import AmountInput from '@/components/payment/AmountInput.vue'
|
||||
import PaymentMethodSelector from '@/components/payment/PaymentMethodSelector.vue'
|
||||
import { METHOD_ORDER, getPaymentPopupFeatures } from '@/components/payment/providerConfig'
|
||||
import { METHOD_ORDER, getPaymentPopupFeatures, isBuiltInAlipayMethod, isBuiltInWxpayMethod } from '@/components/payment/providerConfig'
|
||||
import {
|
||||
PAYMENT_RECOVERY_STORAGE_KEY,
|
||||
buildCreateOrderPayload,
|
||||
@@ -283,7 +283,7 @@ import { platformAccentBarClass, platformBadgeLightClass, platformBadgeClass, pl
|
||||
import SubscriptionPlanCard from '@/components/payment/SubscriptionPlanCard.vue'
|
||||
import PaymentStatusPanel from '@/components/payment/PaymentStatusPanel.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import { formatPaymentAmount, normalizePaymentCurrency } from '@/components/payment/currency'
|
||||
import { DEFAULT_PAYMENT_CURRENCY, formatPaymentAmount, normalizePaymentCurrency } from '@/components/payment/currency'
|
||||
import type { PaymentMethodOption } from '@/components/payment/PaymentMethodSelector.vue'
|
||||
import { buildPaymentErrorToastMessage, describePaymentScenarioError } from './paymentUx'
|
||||
import { hasWechatResumeQuery, parseWechatResumeRoute, stripWechatResumeQuery } from './paymentWechatResume'
|
||||
@@ -494,7 +494,7 @@ function onPaymentSettled() {
|
||||
// All checkout data from single API call
|
||||
const checkout = ref<CheckoutInfoResponse>({
|
||||
methods: {}, global_min: 0, global_max: 0,
|
||||
plans: [], balance_disabled: false, balance_recharge_multiplier: 1, recharge_fee_rate: 0, help_text: '', help_image_url: '', stripe_publishable_key: '',
|
||||
plans: [], balance_disabled: false, balance_recharge_multiplier: 1, subscription_usd_to_cny_rate: 0, recharge_fee_rate: 0, help_text: '', help_image_url: '', stripe_publishable_key: '',
|
||||
})
|
||||
|
||||
const tabs = computed(() => {
|
||||
@@ -511,6 +511,11 @@ const balanceRechargeMultiplier = computed(() => {
|
||||
const multiplier = checkout.value.balance_recharge_multiplier
|
||||
return Number.isFinite(multiplier) && multiplier > 0 ? multiplier : 1
|
||||
})
|
||||
// 订阅 CNY 换算汇率(1 USD = X CNY)。0 = 未配置,订阅保持 price 直付(与后端 opt-in 条件严格镜像)。
|
||||
const subscriptionUsdToCnyRate = computed(() => {
|
||||
const rate = checkout.value.subscription_usd_to_cny_rate
|
||||
return Number.isFinite(rate) && rate > 0 ? rate : 0
|
||||
})
|
||||
const creditedAmount = computed(() => Math.round((validAmount.value * balanceRechargeMultiplier.value) * 100) / 100)
|
||||
|
||||
// Adaptive grid: center single card, 2-col for 2 plans, 3-col for 3+
|
||||
@@ -579,12 +584,18 @@ function ceilPaymentAmount(value: number, currency: string): number {
|
||||
return Math.ceil(value * factor) / factor
|
||||
}
|
||||
|
||||
function subscriptionPaymentAmountForCurrency(value: number, currency: string): number {
|
||||
const rate = subscriptionUsdToCnyRate.value
|
||||
if (rate <= 0 || currency !== DEFAULT_PAYMENT_CURRENCY) return roundPaymentAmount(value, currency)
|
||||
return roundPaymentAmount(value * rate, currency)
|
||||
}
|
||||
|
||||
function formatSelectedPaymentAmount(value: number): string {
|
||||
return formatPaymentAmount(value, selectedCurrency.value, localeCode.value)
|
||||
}
|
||||
|
||||
function formatSelectedSubscriptionPaymentAmount(value: number): string {
|
||||
return formatSelectedPaymentAmount(roundPaymentAmount(value, selectedCurrency.value))
|
||||
return formatSelectedPaymentAmount(subscriptionPaymentAmountForCurrency(value, selectedCurrency.value))
|
||||
}
|
||||
|
||||
const methodOptions = computed<PaymentMethodOption[]>(() =>
|
||||
@@ -592,6 +603,7 @@ const methodOptions = computed<PaymentMethodOption[]>(() =>
|
||||
const ml = visibleMethods.value[type]
|
||||
return {
|
||||
type,
|
||||
display_name: ml?.display_name,
|
||||
fee_rate: ml?.fee_rate ?? 0,
|
||||
available: ml?.available !== false && amountFitsMethod(validAmount.value, type),
|
||||
}
|
||||
@@ -633,7 +645,7 @@ const canSubmit = computed(() =>
|
||||
|
||||
const subPaymentAmount = computed(() => {
|
||||
const price = selectedPlan.value?.price ?? 0
|
||||
return roundPaymentAmount(price, selectedCurrency.value)
|
||||
return subscriptionPaymentAmountForCurrency(price, selectedCurrency.value)
|
||||
})
|
||||
|
||||
const subFeeAmount = computed(() => {
|
||||
@@ -647,7 +659,7 @@ const subTotalAmount = computed(() => {
|
||||
})
|
||||
|
||||
function subscriptionTotalAmountForCurrency(value: number, currency: string): number {
|
||||
const paymentAmount = roundPaymentAmount(value, currency)
|
||||
const paymentAmount = subscriptionPaymentAmountForCurrency(value, currency)
|
||||
if (feeRate.value <= 0 || paymentAmount <= 0) return paymentAmount
|
||||
const fee = ceilPaymentAmount((paymentAmount * feeRate.value) / 100, currency)
|
||||
return roundPaymentAmount(paymentAmount + fee, currency)
|
||||
@@ -661,6 +673,7 @@ const subMethodOptions = computed<PaymentMethodOption[]>(() => {
|
||||
const currency = normalizePaymentCurrency(ml?.currency)
|
||||
return {
|
||||
type,
|
||||
display_name: ml?.display_name,
|
||||
fee_rate: ml?.fee_rate ?? 0,
|
||||
available: ml?.available !== false && amountFitsMethod(subscriptionTotalAmountForCurrency(price, currency), type),
|
||||
}
|
||||
@@ -684,8 +697,8 @@ watch(() => [validAmount.value, selectedMethod.value] as const, ([amt, method])
|
||||
const paymentButtonClass = computed(() => {
|
||||
const m = selectedMethod.value
|
||||
if (!m) return 'btn-primary'
|
||||
if (m.includes('alipay')) return 'btn-alipay'
|
||||
if (m.includes('wxpay')) return 'btn-wxpay'
|
||||
if (isBuiltInAlipayMethod(m)) return 'btn-alipay'
|
||||
if (isBuiltInWxpayMethod(m)) return 'btn-wxpay'
|
||||
if (m === 'stripe') return 'btn-stripe'
|
||||
if (m === 'airwallex') return 'btn-airwallex'
|
||||
return 'btn-primary'
|
||||
@@ -1106,6 +1119,7 @@ onMounted(async () => {
|
||||
paymentState.value = restored
|
||||
paymentPhase.value = 'paying'
|
||||
const restoredMethod = normalizeVisibleMethod(restored.paymentType)
|
||||
|| (visibleMethods.value[restored.paymentType] ? restored.paymentType : '')
|
||||
if (restoredMethod) {
|
||||
selectedMethod.value = restoredMethod
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ const messages: Record<string, string> = {
|
||||
'keys.created': 'Created',
|
||||
'keys.expiresAt': 'Expires',
|
||||
'keys.group': 'Group',
|
||||
'keys.currentConcurrency': 'Current Concurrency',
|
||||
'keys.lastUsedAt': 'Last Used',
|
||||
'keys.rateLimitColumn': 'Rate Limit',
|
||||
'keys.searchPlaceholder': 'Search name or key...',
|
||||
@@ -117,6 +118,7 @@ const createApiKey = (): ApiKey => ({
|
||||
expires_at: null,
|
||||
created_at: '2026-06-27T00:00:00Z',
|
||||
updated_at: '2026-06-27T00:00:00Z',
|
||||
current_concurrency: 3,
|
||||
rate_limit_5h: 0,
|
||||
rate_limit_1d: 0,
|
||||
rate_limit_7d: 0,
|
||||
@@ -154,6 +156,9 @@ const DataTableStub = {
|
||||
<div data-test="columns">{{ columns.map((col) => col.key).join(',') }}</div>
|
||||
<div v-for="row in data" :key="row.id">
|
||||
<slot name="cell-name" :value="row.name" :row="row" />
|
||||
<div data-test="current-concurrency">
|
||||
<slot name="cell-current_concurrency" :value="row.current_concurrency" :row="row" />
|
||||
</div>
|
||||
</div>
|
||||
<slot name="empty" />
|
||||
</div>
|
||||
@@ -251,6 +256,7 @@ describe('user KeysView column settings', () => {
|
||||
'name',
|
||||
'key',
|
||||
'group',
|
||||
'current_concurrency',
|
||||
'usage',
|
||||
'expires_at',
|
||||
'status',
|
||||
@@ -282,6 +288,7 @@ describe('user KeysView column settings', () => {
|
||||
expect(visibleColumnKeys(wrapper)).toEqual([
|
||||
'name',
|
||||
'key',
|
||||
'current_concurrency',
|
||||
'usage',
|
||||
'rate_limit',
|
||||
'expires_at',
|
||||
@@ -299,8 +306,15 @@ describe('user KeysView column settings', () => {
|
||||
|
||||
const columnMenuText = wrapper.text()
|
||||
expect(columnMenuText).toContain('API Key')
|
||||
expect(columnMenuText).toContain('Current Concurrency')
|
||||
expect(columnMenuText).toContain('Rate Limit')
|
||||
expect(columnMenuText).not.toContain('Name')
|
||||
expect(columnMenuText).not.toContain('Actions')
|
||||
})
|
||||
|
||||
it('renders the current concurrency value', async () => {
|
||||
const wrapper = await mountView()
|
||||
|
||||
expect(wrapper.get('[data-test="current-concurrency"]').text()).toBe('3')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -105,6 +105,7 @@ function checkoutInfoFixture(overrides: Partial<CheckoutInfoResponse> = {}) {
|
||||
plans: [],
|
||||
balance_disabled: false,
|
||||
balance_recharge_multiplier: 1,
|
||||
subscription_usd_to_cny_rate: 0,
|
||||
recharge_fee_rate: 0,
|
||||
help_text: '',
|
||||
help_image_url: '',
|
||||
@@ -236,35 +237,39 @@ async function mountSubscriptionConfirm(options: Parameters<typeof checkoutInfoW
|
||||
}
|
||||
|
||||
describe('PaymentView subscription confirmation amounts', () => {
|
||||
it('keeps subscription plan price independent from balance recharge multiplier', async () => {
|
||||
it('shows converted CNY pay amount using the subscription rate, not the balance multiplier', async () => {
|
||||
const wrapper = await mountSubscriptionConfirm({
|
||||
checkout: {
|
||||
balance_recharge_multiplier: 4,
|
||||
balance_recharge_multiplier: 0.14,
|
||||
subscription_usd_to_cny_rate: 7.15,
|
||||
},
|
||||
method: {
|
||||
currency: 'CNY',
|
||||
},
|
||||
plan: {
|
||||
price: 200,
|
||||
original_price: 300,
|
||||
price: 9.99,
|
||||
original_price: 12.99,
|
||||
},
|
||||
})
|
||||
|
||||
const text = wrapper.text()
|
||||
const planPrice = formatPaymentAmount(200, 'CNY')
|
||||
const originalPrice = formatPaymentAmount(300, 'CNY')
|
||||
const convertedByRechargeMultiplier = formatPaymentAmount(50, 'CNY')
|
||||
const convertedPrice = formatPaymentAmount(71.43, 'CNY')
|
||||
const convertedOriginalPrice = formatPaymentAmount(92.88, 'CNY')
|
||||
|
||||
expect(text).toContain(planPrice)
|
||||
expect(text).toContain(originalPrice)
|
||||
expect(text).not.toContain(convertedByRechargeMultiplier)
|
||||
expect(wrapper.findAll('button').some(button => button.text().includes(planPrice))).toBe(true)
|
||||
expect(text).toContain(convertedPrice)
|
||||
expect(text).toContain(convertedOriginalPrice)
|
||||
expect(text).not.toContain(formatPaymentAmount(9.99, 'CNY'))
|
||||
// 换算必须使用订阅汇率(×7.15),而不是余额倍率(÷0.14 = 71.36)
|
||||
expect(text).not.toContain(formatPaymentAmount(71.36, 'CNY'))
|
||||
expect(wrapper.findAll('button').some(button => button.text().includes(convertedPrice))).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps plan price when multiplier is not configured or payment currency is not CNY', async () => {
|
||||
it('keeps plan price when the subscription rate is not configured or payment currency is not CNY', async () => {
|
||||
// opt-in 回归锁:即使余额倍率已配置,未配置订阅汇率时 CNY 订阅仍按 price 直付
|
||||
const cnyWrapper = await mountSubscriptionConfirm({
|
||||
checkout: {
|
||||
balance_recharge_multiplier: 0,
|
||||
balance_recharge_multiplier: 0.14,
|
||||
subscription_usd_to_cny_rate: 0,
|
||||
},
|
||||
method: {
|
||||
currency: 'CNY',
|
||||
@@ -276,10 +281,11 @@ describe('PaymentView subscription confirmation amounts', () => {
|
||||
|
||||
expect(cnyWrapper.text()).toContain(formatPaymentAmount(7.99, 'CNY'))
|
||||
expect(cnyWrapper.text()).not.toContain(formatPaymentAmount(57.07, 'CNY'))
|
||||
expect(cnyWrapper.text()).not.toContain(formatPaymentAmount(57.13, 'CNY'))
|
||||
|
||||
const usdWrapper = await mountSubscriptionConfirm({
|
||||
checkout: {
|
||||
balance_recharge_multiplier: 0.14,
|
||||
subscription_usd_to_cny_rate: 7.15,
|
||||
},
|
||||
method: {
|
||||
currency: 'USD',
|
||||
@@ -294,32 +300,114 @@ describe('PaymentView subscription confirmation amounts', () => {
|
||||
expect(usdWrapper.text()).toContain(formatPaymentAmount(9.99, 'USD'))
|
||||
})
|
||||
|
||||
it('adds fee rate to the direct subscription plan price to match backend pay_amount', async () => {
|
||||
it('adds fee rate after CNY rate conversion to match backend pay_amount', async () => {
|
||||
const wrapper = await mountSubscriptionConfirm({
|
||||
checkout: {
|
||||
balance_recharge_multiplier: 4,
|
||||
subscription_usd_to_cny_rate: 7.15,
|
||||
recharge_fee_rate: 2.5,
|
||||
},
|
||||
method: {
|
||||
currency: 'CNY',
|
||||
},
|
||||
plan: {
|
||||
price: 7.99,
|
||||
price: 9.99,
|
||||
},
|
||||
})
|
||||
|
||||
const text = wrapper.text()
|
||||
const price = formatPaymentAmount(7.99, 'CNY')
|
||||
const fee = formatPaymentAmount(0.20, 'CNY')
|
||||
const total = formatPaymentAmount(8.19, 'CNY')
|
||||
const convertedPrice = formatPaymentAmount(71.43, 'CNY')
|
||||
const fee = formatPaymentAmount(1.79, 'CNY')
|
||||
const total = formatPaymentAmount(73.22, 'CNY')
|
||||
|
||||
expect(text).toContain(price)
|
||||
expect(text).toContain(convertedPrice)
|
||||
expect(text).toContain(fee)
|
||||
expect(text).toContain(total)
|
||||
expect(wrapper.findAll('button').some(button => button.text().includes(total))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('PaymentView payment recovery', () => {
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers()
|
||||
routeState.path = '/purchase'
|
||||
routeState.query = {}
|
||||
routerReplace.mockReset().mockResolvedValue(undefined)
|
||||
routerPush.mockReset().mockResolvedValue(undefined)
|
||||
routerResolve.mockClear()
|
||||
createOrder.mockReset()
|
||||
refreshUser.mockReset()
|
||||
fetchActiveSubscriptions.mockReset().mockResolvedValue(undefined)
|
||||
showError.mockReset()
|
||||
showInfo.mockReset()
|
||||
showWarning.mockReset()
|
||||
bridgeInvoke.mockReset()
|
||||
window.localStorage.clear()
|
||||
;(window as Window & { WeixinJSBridge?: { invoke: typeof bridgeInvoke } }).WeixinJSBridge = undefined
|
||||
})
|
||||
|
||||
it('restores a custom EasyPay method as the selected payment method', async () => {
|
||||
getCheckoutInfo.mockResolvedValue(checkoutInfoFixture({
|
||||
methods: {
|
||||
wxpay: checkoutInfoFixture().data.methods.wxpay,
|
||||
ldc: {
|
||||
daily_limit: 0,
|
||||
daily_used: 0,
|
||||
daily_remaining: 0,
|
||||
single_min: 0,
|
||||
single_max: 0,
|
||||
fee_rate: 0,
|
||||
available: true,
|
||||
display_name: 'LDC Pay',
|
||||
},
|
||||
},
|
||||
}))
|
||||
window.localStorage.setItem(PAYMENT_RECOVERY_STORAGE_KEY, JSON.stringify({
|
||||
orderId: 888,
|
||||
amount: 66,
|
||||
qrCode: 'ldc-qr',
|
||||
expiresAt: '2099-01-01T00:10:00.000Z',
|
||||
paymentType: 'ldc',
|
||||
payUrl: 'https://pay.example.com/ldc',
|
||||
outTradeNo: 'sub2_ldc_888',
|
||||
clientSecret: '',
|
||||
intentId: '',
|
||||
currency: '',
|
||||
countryCode: '',
|
||||
paymentEnv: '',
|
||||
payAmount: 66,
|
||||
orderType: 'balance',
|
||||
paymentMode: 'popup',
|
||||
resumeToken: '',
|
||||
createdAt: Date.now(),
|
||||
}))
|
||||
|
||||
const wrapper = shallowMount(PaymentView, {
|
||||
global: {
|
||||
stubs: {
|
||||
AppLayout: {
|
||||
template: '<div><slot /></div>',
|
||||
},
|
||||
PaymentStatusPanel: {
|
||||
template: '<button data-test="payment-done" @click="$emit(\'done\')" />',
|
||||
},
|
||||
PaymentMethodSelector: {
|
||||
props: ['selected'],
|
||||
template: '<div data-test="method-selector">{{ selected }}</div>',
|
||||
},
|
||||
Teleport: true,
|
||||
Transition: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
await flushPromises()
|
||||
await flushPromises()
|
||||
await wrapper.find('[data-test="payment-done"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('[data-test="method-selector"]').text()).toBe('ldc')
|
||||
})
|
||||
})
|
||||
|
||||
describe('PaymentView WeChat JSAPI flow', () => {
|
||||
beforeEach(() => {
|
||||
routeState.path = '/purchase'
|
||||
|
||||
Reference in New Issue
Block a user