()
// 表格容器引用
@@ -381,6 +386,8 @@ interface Props {
* will emit 'sort' events instead of performing client-side sorting.
*/
serverSideSort?: boolean
+ /** Emit 'rowClick' on row/card click and show pointer cursor (interactive cells should @click.stop) */
+ clickableRows?: boolean
/** Estimated row height in px for the virtualizer (default 56) */
estimateRowHeight?: number
/** Number of rows to render beyond the visible area (default 5) */
diff --git a/frontend/src/components/common/IpGeoBatchToolbar.vue b/frontend/src/components/common/IpGeoBatchToolbar.vue
new file mode 100644
index 0000000000..07199f1b2a
--- /dev/null
+++ b/frontend/src/components/common/IpGeoBatchToolbar.vue
@@ -0,0 +1,59 @@
+
+
+
+ {{ t('usage.ipGeo.pending', { count: pendingCount }) }}
+
+
+ {{ loading ? t('usage.ipGeo.batchFetching') : t('usage.ipGeo.batchFetch') }}
+
+
+
+
+
diff --git a/frontend/src/components/keys/UseKeyModal.vue b/frontend/src/components/keys/UseKeyModal.vue
index 6a08fb1722..5900644814 100644
--- a/frontend/src/components/keys/UseKeyModal.vue
+++ b/frontend/src/components/keys/UseKeyModal.vue
@@ -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: {
diff --git a/frontend/src/components/layout/AppSidebar.vue b/frontend/src/components/layout/AppSidebar.vue
index 3d7f1604c7..4e11008832 100644
--- a/frontend/src/components/layout/AppSidebar.vue
+++ b/frontend/src/components/layout/AppSidebar.vue
@@ -9,13 +9,21 @@
+
+
+
+
+ {{ t('admin.settings.payment.easypayCustomMethods') }}
+
+
+ {{ t('admin.settings.payment.easypayCustomMethodsHint') }}
+
+
+
+ {{ t('admin.settings.payment.addCustomMethod') }}
+
+
+
+
+
@@ -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
>({})
+const easyPayCustomMethods = reactive([])
// --- 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()
+ 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()
+ 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
diff --git a/frontend/src/components/payment/PaymentQRDialog.vue b/frontend/src/components/payment/PaymentQRDialog.vue
index f6278e93e0..7dff831a6a 100644
--- a/frontend/src/components/payment/PaymentQRDialog.vue
+++ b/frontend/src/components/payment/PaymentQRDialog.vue
@@ -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')
diff --git a/frontend/src/components/payment/PaymentStatusPanel.vue b/frontend/src/components/payment/PaymentStatusPanel.vue
index d77db58a2f..c7232fd640 100644
--- a/frontend/src/components/payment/PaymentStatusPanel.vue
+++ b/frontend/src/components/payment/PaymentStatusPanel.vue
@@ -79,7 +79,7 @@
-
+
@@ -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')
diff --git a/frontend/src/components/payment/__tests__/PaymentMethodSelector.spec.ts b/frontend/src/components/payment/__tests__/PaymentMethodSelector.spec.ts
new file mode 100644
index 0000000000..e481325fe7
--- /dev/null
+++ b/frontend/src/components/payment/__tests__/PaymentMethodSelector.spec.ts
@@ -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]')
+ })
+})
diff --git a/frontend/src/components/payment/__tests__/PaymentProviderDialog.spec.ts b/frontend/src/components/payment/__tests__/PaymentProviderDialog.spec.ts
index 099152d8a3..a84ff4cbda 100644
--- a/frontend/src/components/payment/__tests__/PaymentProviderDialog.spec.ts
+++ b/frontend/src/components/payment/__tests__/PaymentProviderDialog.spec.ts
@@ -7,6 +7,12 @@ import type { ProviderInstance } from '@/types/payment'
const messages: Record = {
'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 }
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
+ 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()
+ })
})
diff --git a/frontend/src/components/payment/__tests__/PaymentStatusPanel.spec.ts b/frontend/src/components/payment/__tests__/PaymentStatusPanel.spec.ts
index 7e39247831..d5919867c5 100644
--- a/frontend/src/components/payment/__tests__/PaymentStatusPanel.spec.ts
+++ b/frontend/src/components/payment/__tests__/PaymentStatusPanel.spec.ts
@@ -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({
diff --git a/frontend/src/components/payment/__tests__/paymentFlow.spec.ts b/frontend/src/components/payment/__tests__/paymentFlow.spec.ts
index 7eda7a0df4..85e79de6bf 100644
--- a/frontend/src/components/payment/__tests__/paymentFlow.spec.ts
+++ b/frontend/src/components/payment/__tests__/paymentFlow.spec.ts
@@ -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', () => {
diff --git a/frontend/src/components/payment/__tests__/providerConfig.spec.ts b/frontend/src/components/payment/__tests__/providerConfig.spec.ts
index bafc7cd754..267693b5cb 100644
--- a/frontend/src/components/payment/__tests__/providerConfig.spec.ts
+++ b/frontend/src/components/payment/__tests__/providerConfig.spec.ts
@@ -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)
+ })
+})
diff --git a/frontend/src/components/payment/paymentFlow.ts b/frontend/src/components/payment/paymentFlow.ts
index ab5acf26db..a8176f7472 100644
--- a/frontend/src/components/payment/paymentFlow.ts
+++ b/frontend/src/components/payment/paymentFlow.ts
@@ -99,7 +99,7 @@ export function getVisibleMethods(methods: Record): Record<
const visible: Record = {}
Object.entries(methods).forEach(([type, limit]) => {
- const normalized = normalizeVisibleMethod(type)
+ const normalized = normalizeVisibleMethod(type) || type.trim()
if (!normalized) return
const isCanonical = type === normalized
diff --git a/frontend/src/components/payment/providerConfig.ts b/frontend/src/components/payment/providerConfig.ts
index 2b612b4302..395c32725f 100644
--- a/frontend/src/components/payment/providerConfig.ts
+++ b/frontend/src/components/payment/providerConfig.ts
@@ -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 ''
diff --git a/frontend/src/components/user/UserErrorRequestsTable.vue b/frontend/src/components/user/UserErrorRequestsTable.vue
index 42cb93ce04..2465fe2f66 100644
--- a/frontend/src/components/user/UserErrorRequestsTable.vue
+++ b/frontend/src/components/user/UserErrorRequestsTable.vue
@@ -1,91 +1,119 @@
-
-
-
- {{ t('usage.errors.model') }}
-
-
-
- {{ t('usage.errors.keyName') }}
-
-
-
- {{ t('usage.errors.category') }}
-
-
-
-
- {{ t('common.search') }}
-
-
-
+
+
-
-
-
-
- {{ t('usage.errors.model') }}
- {{ t('usage.errors.keyName') }}
- {{ t('usage.errors.endpoint') }}
- {{ t('usage.errors.status') }}
- {{ t('usage.errors.category') }}
- {{ t('usage.errors.message') }}
- {{ t('usage.errors.platform') }}
- {{ t('usage.errors.time') }}
-
-
-
-
- {{ row.model || '-' }}
-
- {{ row.key_name || '-' }}
- {{ t('usage.errors.keyDeleted') }}
-
- {{ row.inbound_endpoint || '-' }}
- {{ row.status_code || '-' }}
- {{ t('usage.errors.categories.' + row.category) }}
- {{ row.message || '-' }}
- {{ row.platform || '-' }}
- {{ formatDateTime(row.created_at) }}
-
-
- {{ t('usage.errors.empty') }}
-
-
-
+
openDetail(row.id)"
+ >
+
+ {{ row.model }}
+ -
+
+
+
+
+ {{ row.key_name || '-' }}
+ {{ t('usage.errors.keyDeleted') }}
+
+
+
+
+
+
+ {{ t('usage.inbound') }}:
+ {{ row.inbound_endpoint?.trim() || '-' }}
+
+
+
+
+
+
+ {{ row.status_code || '-' }}
+
+
+
+
+ {{ t('usage.errors.categories.' + row.category) }}
+
+
+
+ {{ row.message }}
+ -
+
+
+
+ {{ row.group_name }}
+ -
+
+
+
+ {{ requestTypeBadge(row)!.label }}
+ -
+
+
+
+ {{ row.platform || '-' }}
+
+
+
+
+
+ {{ row.client_ip }}
+
+
+
-
+
+
+
+
+ {{ formatDateTime(row.created_at) }}
+
+
+
+ {{ row.user_agent }}
+ -
+
+
+
+
-
+ @update:pageSize="$emit('update:pageSize', $event)"
+ />
@@ -95,12 +123,22 @@
diff --git a/frontend/src/composables/__tests__/useModelWhitelist.spec.ts b/frontend/src/composables/__tests__/useModelWhitelist.spec.ts
index 7f3157dfff..d7e70e309d 100644
--- a/frontend/src/composables/__tests__/useModelWhitelist.spec.ts
+++ b/frontend/src/composables/__tests__/useModelWhitelist.spec.ts
@@ -58,6 +58,12 @@ describe('useModelWhitelist', () => {
expect(models.indexOf('gemini-2.5-flash-image')).toBeLessThan(models.indexOf('gemini-2.5-flash-lite'))
})
+ it('antigravity 模型列表包含 Gemini 3.1 Pro 通用别名', () => {
+ const models = getModelsByPlatform('antigravity')
+
+ expect(models).toContain('gemini-3.1-pro')
+ })
+
it('whitelist 模式会忽略通配符条目', () => {
const mapping = buildModelMappingObject('whitelist', ['claude-*', 'gemini-3.1-flash-image'], [])
expect(mapping).toEqual({
diff --git a/frontend/src/composables/useModelWhitelist.ts b/frontend/src/composables/useModelWhitelist.ts
index 43449b09ee..06cb95a393 100644
--- a/frontend/src/composables/useModelWhitelist.ts
+++ b/frontend/src/composables/useModelWhitelist.ts
@@ -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 系列
@@ -74,6 +76,7 @@ const antigravityModels = [
'gemini-3-pro-high',
'gemini-3-pro-low',
// Gemini 3.1 系列
+ 'gemini-3.1-pro',
'gemini-3.1-pro-high',
'gemini-3.1-pro-low',
'gemini-3-pro-image',
@@ -141,7 +144,13 @@ const xaiModels = [
'grok-latest',
'grok-build',
'grok-4.20-reasoning',
- 'grok-4.20-non-reasoning'
+ 'grok-4.20-non-reasoning',
+ 'grok-imagine',
+ 'grok-imagine-image-quality',
+ 'grok-imagine-image',
+ 'grok-imagine-edit',
+ 'grok-imagine-video',
+ 'grok-imagine-video-1.5'
]
// Cohere
@@ -265,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' },
@@ -286,7 +298,10 @@ const grokPresetMappings = [
{ label: 'Grok Latest', from: 'grok-latest', to: 'grok-4.3', color: 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400' },
{ label: 'Build 0.1', from: 'grok-build', to: 'grok-build-0.1', color: 'bg-cyan-100 text-cyan-700 hover:bg-cyan-200 dark:bg-cyan-900/30 dark:text-cyan-400' },
{ label: '4.20 Reasoning', from: 'grok-4.20-reasoning', to: 'grok-4.20-0309-reasoning', color: 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400' },
- { label: '4.20 Non Reasoning', from: 'grok-4.20-non-reasoning', to: 'grok-4.20-0309-non-reasoning', color: 'bg-violet-100 text-violet-700 hover:bg-violet-200 dark:bg-violet-900/30 dark:text-violet-400' }
+ { label: '4.20 Non Reasoning', from: 'grok-4.20-non-reasoning', to: 'grok-4.20-0309-non-reasoning', color: 'bg-violet-100 text-violet-700 hover:bg-violet-200 dark:bg-violet-900/30 dark:text-violet-400' },
+ { label: 'Imagine Image', from: 'grok-imagine', to: 'grok-imagine-image-quality', color: 'bg-sky-100 text-sky-700 hover:bg-sky-200 dark:bg-sky-900/30 dark:text-sky-400' },
+ { label: 'Imagine Edit', from: 'grok-imagine-edit', to: 'grok-imagine-edit', color: 'bg-rose-100 text-rose-700 hover:bg-rose-200 dark:bg-rose-900/30 dark:text-rose-400' },
+ { label: 'Imagine Video', from: 'grok-imagine-video-1.5', to: 'grok-imagine-video-1.5', color: 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400' }
]
// Antigravity 预设映射(支持通配符)
@@ -302,11 +317,13 @@ const antigravityPresetMappings = [
{ label: 'Sonnet3.5→4.6', from: 'claude-3-5-sonnet-20241022', to: 'claude-sonnet-4-6', color: 'bg-teal-100 text-teal-700 hover:bg-teal-200 dark:bg-teal-900/30 dark:text-teal-400' },
{ label: 'Opus4.5→4.6', from: 'claude-opus-4-5-20251101', to: 'claude-opus-4-6-thinking', color: 'bg-violet-100 text-violet-700 hover:bg-violet-200 dark:bg-violet-900/30 dark:text-violet-400' },
// Gemini 3→3.1 映射
- { label: '3-Pro-Preview→3.1-Pro-High', from: 'gemini-3-pro-preview', to: 'gemini-3.1-pro-high', color: 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400' },
- { label: '3-Pro-High→3.1-Pro-High', from: 'gemini-3-pro-high', to: 'gemini-3.1-pro-high', color: 'bg-orange-100 text-orange-700 hover:bg-orange-200 dark:bg-orange-900/30 dark:text-orange-400' },
+ { label: '3-Pro-Preview→Pro-Agent', from: 'gemini-3-pro-preview', to: 'gemini-pro-agent', color: 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400' },
+ { label: '3-Pro-High→Pro-Agent', from: 'gemini-3-pro-high', to: 'gemini-pro-agent', color: 'bg-orange-100 text-orange-700 hover:bg-orange-200 dark:bg-orange-900/30 dark:text-orange-400' },
{ label: '3-Pro-Low→3.1-Pro-Low', from: 'gemini-3-pro-low', to: 'gemini-3.1-pro-low', color: 'bg-yellow-100 text-yellow-700 hover:bg-yellow-200 dark:bg-yellow-900/30 dark:text-yellow-400' },
- { label: '3.1-Pro-High passthrough', from: 'gemini-3.1-pro-high', to: 'gemini-3.1-pro-high', color: 'bg-orange-100 text-orange-700 hover:bg-orange-200 dark:bg-orange-900/30 dark:text-orange-400' },
+ { label: '3.1-Pro→Pro-Agent', from: 'gemini-3.1-pro', to: 'gemini-pro-agent', color: 'bg-orange-100 text-orange-700 hover:bg-orange-200 dark:bg-orange-900/30 dark:text-orange-400' },
+ { label: '3.1-Pro-High→Pro-Agent', from: 'gemini-3.1-pro-high', to: 'gemini-pro-agent', color: 'bg-orange-100 text-orange-700 hover:bg-orange-200 dark:bg-orange-900/30 dark:text-orange-400' },
{ label: '3.1-Pro-Low passthrough', from: 'gemini-3.1-pro-low', to: 'gemini-3.1-pro-low', color: 'bg-yellow-100 text-yellow-700 hover:bg-yellow-200 dark:bg-yellow-900/30 dark:text-yellow-400' },
+ { label: '3.1-Pro-Preview→Pro-Agent', from: 'gemini-3.1-pro-preview', to: 'gemini-pro-agent', color: 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400' },
// Gemini 通配符映射
{ label: 'Gemini 3→Flash', from: 'gemini-3*', to: 'gemini-3-flash', color: 'bg-yellow-100 text-yellow-700 hover:bg-yellow-200 dark:bg-yellow-900/30 dark:text-yellow-400' },
{ label: 'Gemini 2.5→Flash', from: 'gemini-2.5*', to: 'gemini-2.5-flash', color: 'bg-orange-100 text-orange-700 hover:bg-orange-200 dark:bg-orange-900/30 dark:text-orange-400' },
diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts
index 831b7d7eae..3ae82f4490 100644
--- a/frontend/src/i18n/locales/en.ts
+++ b/frontend/src/i18n/locales/en.ts
@@ -745,6 +745,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',
@@ -1017,7 +1018,7 @@ export default {
time: 'Time', model: 'Model', endpoint: 'Endpoint', status: 'Status',
category: 'Category', platform: 'Platform', message: 'Message',
keyName: 'Key Name', keyDeleted: 'Deleted', allKeys: 'All keys',
- modelPlaceholder: 'Search model', allCategories: 'All categories',
+ modelPlaceholder: 'Search model', allCategories: 'All categories', allStatuses: 'All status codes',
empty: 'No error requests', failedToLoad: 'Failed to load error requests',
categories: {
auth: 'Auth failed', rate_limit: 'Rate limited', quota: 'Balance/Subscription',
@@ -3107,7 +3108,7 @@ export default {
resetQuota: 'Reset Quota',
resetQuotaDesc: 'Reset daily/weekly/monthly usage to zero',
revoke: 'Revoke',
- revokeDesc: 'Immediately terminate the subscription (irreversible)'
+ revokeDesc: 'Immediately terminate the subscription (restorable from the revoked list)'
},
tip: 'Tip: Only groups with billing type "Subscription" and status "Active" appear in the group dropdown. If no options are available, create one in Group Management first.'
}
@@ -3265,6 +3266,7 @@ export default {
priority: 'Priority',
billingRateMultiplier: 'Billing Rate',
weight: 'Weight',
+ schedulerScore: 'Scheduler Score',
status: 'Status',
schedulable: 'Schedulable',
todayStats: 'Today Stats',
@@ -3276,6 +3278,12 @@ export default {
expiresAt: 'Expires At',
actions: 'Actions'
},
+ schedulerScore: {
+ baseShort: 'Base',
+ stickyShort: 'Sticky',
+ ungrouped: 'Ungrouped',
+ hint: 'Displayed as "group / base score / sticky bonus". The base score is computed within the current filtered candidate set and includes priority, load, queue depth, error rate, first-token latency, reset window, quota headroom, and related factors. The sticky bonus applies only when sticky weighting is enabled for previous_response_id or session_hash. Higher scores are preferred.'
+ },
usageWindowsHint: '"5h / 7d" are the upstream account\'s official rolling usage windows (e.g. OpenAI ChatGPT, Claude). They are imposed by the upstream provider on the account itself — not configured by sub2api, and unrelated to the models you map. Usage resets automatically once each window rolls over, and the limit cannot be lifted from within sub2api.',
allPrivacyModes: 'All Privacy States',
privacyUnset: 'Unset',
@@ -3557,18 +3565,21 @@ export default {
codexCLIOnlyAppServer: 'Allow Codex app-server clients',
codexCLIOnlyAppServerDesc:
"Effective only when the switch above is on. When enabled, this account also allows third-party clients that embed the Codex engine over the app-server protocol (e.g. Claude Code's codex plugin); they still pass the global engine-fingerprint gate. OR-combined with the global app-server toggle.",
- codexImageGenerationBridge: 'Codex image-generation bridge',
- codexImageGenerationBridgeDesc:
- 'Account policy takes precedence over channel and global settings. Only controls whether Codex requests through the /responses text endpoint receive the image_generation tool; standalone image-generation endpoints are unaffected.',
- codexImageGenerationBridgeInherit: 'Follow channel',
- codexImageGenerationBridgeInheritDesc: 'Do not write an account override; use the channel or global policy.',
- codexImageGenerationBridgeEnabled: 'Force on',
- codexImageGenerationBridgeEnabledDesc: 'Allow image tool injection for Codex /responses requests.',
- codexImageGenerationBridgeDisabled: 'Force off',
- codexImageGenerationBridgeDisabledDesc: 'Block image tool injection for Codex /responses requests.',
- codexImageGenerationBridgeBadgeInherit: 'Channel policy',
- codexImageGenerationBridgeBadgeEnabled: 'Account on',
- codexImageGenerationBridgeBadgeDisabled: 'Account off',
+ codexImageTool: 'Codex image tool',
+ codexImageToolDesc:
+ 'One policy for the image_generation tool on Codex /responses text requests: whether it is auto-injected, and whether client-provided tools pass through. Account policy takes precedence over channel and global settings; standalone image-generation endpoints are unaffected.',
+ codexImageToolInherit: 'Follow channel',
+ codexImageToolInheritDesc: 'No account override; injection follows the channel or global policy, and client-provided image tools pass through.',
+ codexImageToolEnabled: 'Force inject',
+ codexImageToolEnabledDesc: 'Always inject the image tool for Codex /responses requests.',
+ codexImageToolDisabled: 'No injection',
+ codexImageToolDisabledDesc: 'Never auto-inject; client-provided image tools still pass through.',
+ codexImageToolBlock: 'Block all',
+ codexImageToolBlockDesc: 'No injection, and client-provided image tools plus matching tool_choice are removed.',
+ codexImageToolBadgeInherit: 'Channel policy',
+ codexImageToolBadgeEnabled: 'Force inject',
+ codexImageToolBadgeDisabled: 'No injection',
+ codexImageToolBadgeBlock: 'Blocked',
compactMode: 'Compact mode',
compactModeDesc:
'Controls how this account participates in /responses/compact routing. Auto follows probe results, Force On always allows, Force Off always excludes.',
@@ -3663,6 +3674,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 (%)',
@@ -5088,6 +5117,7 @@ export default {
accountId: 'Account ID',
status: 'Status',
message: 'Message',
+ ip: 'IP',
latency: 'Request Duration',
action: 'Action',
noErrors: 'No errors in this window.',
@@ -6172,6 +6202,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}',
@@ -6217,6 +6251,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',
@@ -6246,6 +6286,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.',
@@ -6720,7 +6766,24 @@ export default {
},
openaiExperimentalScheduler: {
title: 'OpenAI experimental scheduler policy',
- description: "Disabled by default. When enabled, this only changes the gateway's experimental account-selection policy for OpenAI traffic; it does not indicate an upstream OpenAI capability."
+ description: "Disabled by default. When enabled, this only changes the gateway's experimental account-selection policy for OpenAI traffic; it does not indicate an upstream OpenAI capability.",
+ stickyWeightedTitle: 'Sticky weighting',
+ stickyWeightedDescription: 'When enabled, previous_response_id and session_hash affinity are scored by the advanced scheduler. When disabled, sticky accounts keep the legacy hard-hit behavior.',
+ subscriptionPriorityTitle: 'Subscription priority',
+ subscriptionPriorityDescription: 'When enabled, the scheduler scores ChatGPT subscription accounts first and falls back to non-subscription accounts only if no subscription slot can be acquired.',
+ weightsTitle: 'Scheduler weight overrides',
+ weightsDescription: 'Blank values use config/environment values; when config is not set, built-in defaults apply. Non-blank page settings take priority.',
+ defaultPlaceholder: 'config/default: {value}',
+ topKLabel: 'TopK',
+ priorityWeight: 'Priority',
+ loadWeight: 'Load',
+ queueWeight: 'Queue',
+ errorRateWeight: 'Error rate',
+ ttftWeight: 'TTFT',
+ resetWeight: 'Reset window',
+ quotaHeadroomWeight: 'Quota headroom',
+ previousResponseWeight: 'previous_response sticky',
+ sessionStickyWeight: 'session_hash sticky'
},
usageRecords: {
title: 'Usage Records',
@@ -7402,6 +7465,8 @@ export default {
deletePlanConfirm: 'Are you sure you want to delete this plan?',
originalPrice: 'Original Price',
price: 'Price',
+ subscriptionCnyPayPreview: 'CNY channel charge preview: {amount}',
+ subscriptionCnyPayPreviewWithFee: '({feeRate}% fee included: {total})',
validityDays: 'Validity (days)',
validityUnit: 'Validity Unit',
sortOrder: 'Sort Order',
diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts
index 64c62f39f8..11438efab2 100644
--- a/frontend/src/i18n/locales/zh.ts
+++ b/frontend/src/i18n/locales/zh.ts
@@ -744,6 +744,7 @@ export default {
deleteConfirmMessage: "确定要删除 '{name}' 吗?此操作无法撤销。",
apiKey: 'API 密钥',
group: '分组',
+ currentConcurrency: '当前并发',
noGroup: '无分组',
searchGroup: '搜索分组...',
noGroupFound: '未找到匹配的分组',
@@ -896,7 +897,7 @@ export default {
usage: {
title: '使用记录',
description: '查看和分析您的 API 使用历史',
- costDetails: '成本明细',
+ costDetails: '费用明细',
tokenDetails: 'Token 明细',
cacheTtlOverriddenHint: '缓存 TTL Override 已启用',
cacheTtlOverriddenLabel: 'TTL 替换',
@@ -972,7 +973,7 @@ export default {
imageOutputSize: '输出尺寸',
imageOutputTokens: '图片输出 Token',
imageOutputTokenPrice: '图片输出单价',
- imageOutputCost: '图片输出成本',
+ imageOutputCost: '图片输出费用',
imageSizeSource: '尺寸来源',
imageSizeBreakdown: '尺寸明细',
imageSizeSourceOutput: '上游输出',
@@ -1021,7 +1022,7 @@ export default {
time: '时间', model: '模型', endpoint: '端点', status: '状态码',
category: '分类', platform: '平台', message: '错误信息',
keyName: 'Key 名称', keyDeleted: '已删除', allKeys: '全部 Key',
- modelPlaceholder: '搜索模型', allCategories: '全部分类',
+ modelPlaceholder: '搜索模型', allCategories: '全部分类', allStatuses: '全部状态码',
empty: '暂无错误请求', failedToLoad: '加载错误请求失败',
categories: {
auth: '认证失败', rate_limit: '限流', quota: '余额/订阅',
@@ -3182,7 +3183,7 @@ export default {
resetQuota: '重置配额',
resetQuotaDesc: '将日/周/月用量归零,重新开始计算',
revoke: '撤销',
- revokeDesc: '立即终止该用户的订阅,不可恢复'
+ revokeDesc: '立即终止该用户的订阅,可在已撤销列表中恢复'
},
tip: '提示:订阅分组下拉列表中只会显示计费类型为「订阅」且状态为「正常」的分组。如果没有可选项,请先到分组管理中创建。'
}
@@ -3296,6 +3297,7 @@ export default {
priority: '优先级',
billingRateMultiplier: '账号倍率',
weight: '权重',
+ schedulerScore: '调度权值',
status: '状态',
schedulable: '调度',
todayStats: '今日统计',
@@ -3307,6 +3309,12 @@ export default {
expiresAt: '过期时间',
actions: '操作'
},
+ schedulerScore: {
+ baseShort: '普通',
+ stickyShort: '粘性',
+ ungrouped: '未分组',
+ hint: '显示格式为“分组名 / 基础分 / 粘性加分”。基础分按当前筛选条件限定的候选账号计算,包含优先级、负载、排队、错误率、首包延迟、重置窗口、额度余量等因子;粘性加分只在开启粘性加权时用于 previous_response_id 或 session_hash。分数越大越优先。'
+ },
usageWindowsHint: '“5h / 7d”是上游账号(如 OpenAI ChatGPT、Claude)官方的滚动用量窗口限制,由上游对账号设定,并非 sub2api 配置,也与你映射的模型无关。窗口滚动到期后用量会自动重置,无法在 sub2api 端解除该限制。',
allPrivacyModes: '全部Privacy状态',
privacyUnset: '未设置',
@@ -3730,18 +3738,21 @@ export default {
codexCLIOnlyDesc: '仅对 OpenAI OAuth 生效。开启后仅允许 Codex 官方客户端家族访问;关闭后完全绕过并保持原逻辑。',
codexCLIOnlyAppServer: '允许 Codex app-server 客户端',
codexCLIOnlyAppServerDesc: '仅在上方开关开启时生效。开启后本账号额外放行内嵌 Codex 引擎、经 app-server 协议接入的第三方客户端(如 Claude Code 的 codex 插件),仍需通过全局引擎指纹门;与全局 app-server 开关取 OR(任一开即放行)。',
- codexImageGenerationBridge: 'Codex 图片生成桥接',
- codexImageGenerationBridgeDesc:
- '账号级策略优先于渠道和全局配置。仅控制 Codex 走 /responses 文本端点时是否注入 image_generation 工具;不影响独立图片生成接口。',
- codexImageGenerationBridgeInherit: '跟随渠道',
- codexImageGenerationBridgeInheritDesc: '不写入账号覆盖,继续使用渠道或全局策略。',
- codexImageGenerationBridgeEnabled: '强制开启',
- codexImageGenerationBridgeEnabledDesc: '允许 Codex /responses 请求获得图片工具注入。',
- codexImageGenerationBridgeDisabled: '强制关闭',
- codexImageGenerationBridgeDisabledDesc: '阻断 Codex /responses 的图片工具注入。',
- codexImageGenerationBridgeBadgeInherit: '渠道策略',
- codexImageGenerationBridgeBadgeEnabled: '账号开启',
- codexImageGenerationBridgeBadgeDisabled: '账号关闭',
+ codexImageTool: 'Codex 图片工具',
+ codexImageToolDesc:
+ '统一控制 Codex /responses 文本请求的 image_generation 图片工具:是否自动注入,以及客户端自带该工具时是否放行。账号级策略优先于渠道和全局配置,不影响独立图片生成接口。',
+ codexImageToolInherit: '跟随渠道',
+ codexImageToolInheritDesc: '不写入账号覆盖,是否注入由渠道或全局策略决定;客户端自带的图片工具照常放行。',
+ codexImageToolEnabled: '强制注入',
+ codexImageToolEnabledDesc: '始终为 Codex /responses 请求注入图片工具。',
+ codexImageToolDisabled: '关闭注入',
+ codexImageToolDisabledDesc: '不自动注入;客户端自带的图片工具仍会放行。',
+ codexImageToolBlock: '完全阻断',
+ codexImageToolBlockDesc: '不注入,并移除客户端自带的图片工具及指向它的 tool_choice。',
+ codexImageToolBadgeInherit: '渠道策略',
+ codexImageToolBadgeEnabled: '强制注入',
+ codexImageToolBadgeDisabled: '关闭注入',
+ codexImageToolBadgeBlock: '完全阻断',
compactMode: 'Compact 模式',
compactModeDesc:
'控制本账号在 /responses/compact 调度中的参与方式。Auto 跟随探测结果,Force On 强制允许,Force Off 强制排除。',
@@ -3831,6 +3842,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 用量阈值(%)',
@@ -4933,10 +4962,10 @@ export default {
allAccounts: '全部账户',
allGroups: '全部分组',
allTypes: '全部类型',
- inputCost: '输入成本',
- outputCost: '输出成本',
- cacheCreationCost: '缓存创建成本',
- cacheReadCost: '缓存读取成本',
+ inputCost: '输入费用',
+ outputCost: '输出费用',
+ cacheCreationCost: '缓存创建费用',
+ cacheReadCost: '缓存读取费用',
inputTokens: '输入 Token',
outputTokens: '输出 Token',
cacheCreationTokens: '缓存创建 Token',
@@ -5246,6 +5275,7 @@ export default {
accountId: '账号 ID',
status: '状态码',
message: '响应内容',
+ ip: 'IP',
latency: '请求时长',
action: '操作',
noErrors: '该窗口内暂无错误。',
@@ -6327,6 +6357,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} 元',
@@ -6372,6 +6406,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: '同步跳转地址',
@@ -6401,6 +6441,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 选择账户默认或最新稳定版本。',
@@ -6874,7 +6920,24 @@ export default {
},
openaiExperimentalScheduler: {
title: 'OpenAI 实验调度策略',
- description: '默认关闭。开启后仅影响本网关在 OpenAI 账号间的实验性调度选择逻辑,不代表上游 OpenAI 官方能力。'
+ description: '默认关闭。开启后仅影响本网关在 OpenAI 账号间的实验性调度选择逻辑,不代表上游 OpenAI 官方能力。',
+ stickyWeightedTitle: '粘性加权',
+ stickyWeightedDescription: '开启后 previous_response_id 和 session_hash 粘性进入高级调度打分;关闭时仍按旧逻辑硬命中粘性账号。',
+ subscriptionPriorityTitle: '订阅优先',
+ subscriptionPriorityDescription: '开启后先在 ChatGPT 订阅账号池中按权值选取;订阅池拿不到席位时再回退到非订阅账号池。',
+ weightsTitle: '调度权值覆盖',
+ weightsDescription: '留空时使用配置/环境变量值;配置未设置时使用内置默认值。页面非空设置优先。',
+ defaultPlaceholder: '配置/默认:{value}',
+ topKLabel: 'TopK',
+ priorityWeight: '优先级',
+ loadWeight: '负载',
+ queueWeight: '排队',
+ errorRateWeight: '错误率',
+ ttftWeight: '首包延迟',
+ resetWeight: '重置窗口',
+ quotaHeadroomWeight: '额度余量',
+ previousResponseWeight: 'previous_response 粘性',
+ sessionStickyWeight: 'session_hash 粘性'
},
usageRecords: {
title: '使用记录',
@@ -7580,6 +7643,8 @@ export default {
deletePlanConfirm: '确定要删除此套餐吗?',
originalPrice: '原价',
price: '价格',
+ subscriptionCnyPayPreview: 'CNY 通道实扣预览:{amount}',
+ subscriptionCnyPayPreviewWithFee: '(含 {feeRate}% 手续费:{total})',
validityDays: '有效期(天)',
validityUnit: '有效期单位',
sortOrder: '排序',
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts
index 3636791ebd..e2dbacbbab 100644
--- a/frontend/src/types/index.ts
+++ b/frontend/src/types/index.ts
@@ -586,6 +586,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
@@ -857,6 +858,13 @@ export interface Account {
concurrency: number
load_factor?: number | null
current_concurrency?: number // Real-time concurrency count from Redis
+ scheduler_score?: {
+ base_score: number
+ sticky_score?: number
+ sticky_score_infinity?: boolean
+ sticky_weighted_enabled: boolean
+ } | null
+ scheduler_scores?: AccountSchedulerGroupScore[] | null
priority: number
rate_multiplier?: number // Account billing multiplier (>=0, 0 means free)
status: 'active' | 'inactive' | 'error'
@@ -947,6 +955,16 @@ export interface Account {
parent_chatgpt_account_id?: string
}
+export interface AccountSchedulerGroupScore {
+ group_id?: number | null
+ group_name?: string
+ group_priority?: number | null
+ base_score: number
+ sticky_score?: number
+ sticky_score_infinity?: boolean
+ sticky_weighted_enabled: boolean
+}
+
// Account Usage types
export interface WindowStats {
requests: number
@@ -984,6 +1002,7 @@ export interface AccountUsageInfo {
five_hour: UsageProgress | null
seven_day: UsageProgress | null
seven_day_sonnet: UsageProgress | null
+ seven_day_fable?: UsageProgress | null
gemini_shared_daily?: UsageProgress | null
gemini_pro_daily?: UsageProgress | null
gemini_flash_daily?: UsageProgress | null
@@ -1688,6 +1707,11 @@ export interface UserErrorRequest {
message: string
key_name: string
key_deleted: boolean
+ client_ip?: string
+ group_name?: string
+ request_type?: number
+ stream?: boolean
+ user_agent?: string
}
export interface UserErrorRequestDetail extends UserErrorRequest {
@@ -1705,6 +1729,9 @@ export interface UserErrorListParams {
status_code?: number
category?: string
api_key_id?: number
+ // 服务端排序,列白名单见后端 opsErrorLogsOrderBy(created_at/model/status_code)
+ sort_by?: string
+ sort_order?: 'asc' | 'desc'
}
export interface UsageQueryParams {
diff --git a/frontend/src/types/payment.ts b/frontend/src/types/payment.ts
index a02ef1b78f..303ad9961f 100644
--- a/frontend/src/types/payment.ts
+++ b/frontend/src/types/payment.ts
@@ -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
diff --git a/frontend/src/utils/errorBadges.ts b/frontend/src/utils/errorBadges.ts
new file mode 100644
index 0000000000..bdb4657547
--- /dev/null
+++ b/frontend/src/utils/errorBadges.ts
@@ -0,0 +1,61 @@
+/**
+ * 错误请求/用量明细共享的徽章配色与列映射。
+ * 配色统一 bg-X-100/text-X-800 体系,与 UsageTable 一致。
+ */
+
+import type { UsageRequestType } from '@/types'
+
+export type UsageRequestKind = UsageRequestType
+
+/** 状态码徽章:≥500 红、429 紫、≥400 琥珀、其余灰 */
+export function statusCodeBadgeClass(code: number): string {
+ if (code >= 500) return 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'
+ if (code === 429) return 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200'
+ if (code >= 400) return 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200'
+ return 'bg-gray-100 text-gray-800 dark:bg-dark-700 dark:text-gray-200'
+}
+
+/** 请求类型徽章配色(cyber 红、ws 紫、stream 蓝、sync 灰、未知琥珀) */
+export function requestTypeBadgeClass(kind: UsageRequestKind): string {
+ if (kind === 'cyber') return 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'
+ if (kind === 'ws_v2') return 'bg-violet-100 text-violet-800 dark:bg-violet-900 dark:text-violet-200'
+ if (kind === 'stream') return 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200'
+ if (kind === 'sync') return 'bg-gray-100 text-gray-800 dark:bg-dark-700 dark:text-gray-200'
+ return 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200'
+}
+
+/** 请求类型 i18n 键(展示方自行 t()) */
+export function requestTypeLabelKey(kind: UsageRequestKind): string {
+ if (kind === 'cyber') return 'usage.cyber'
+ if (kind === 'ws_v2') return 'usage.ws'
+ if (kind === 'stream') return 'usage.stream'
+ if (kind === 'sync') return 'usage.sync'
+ return 'usage.unknown'
+}
+
+/**
+ * 数字 request_type(1 同步/2 流式/3 WS)→ kind;
+ * 缺失时按 stream 布尔回退,两者都缺返回 null(展示为 -)。
+ */
+export function numericRequestTypeKind(
+ requestType?: number | null,
+ stream?: boolean | null
+): UsageRequestKind | null {
+ const rt = requestType ?? (stream == null ? 0 : stream ? 2 : 1)
+ if (rt === 3) return 'ws_v2'
+ if (rt === 2) return 'stream'
+ if (rt === 1) return 'sync'
+ return null
+}
+
+/** 错误表列 key → 后端 sort_by(status 列实际按 status_code 排序) */
+export function mapErrorSortKey(key: string): string {
+ return key === 'status' ? 'status_code' : key
+}
+
+/**
+ * 错误请求筛选的常用状态码固定候选(管理端 + 用户端共用)。
+ * 用固定列表而非「当前页出现过的码」派生,避免目标状态码只在后续页/筛选外时无法选中
+ * ——后端 status_code 过滤对全量数据生效,选项不应被当前页数据限制。
+ */
+export const COMMON_ERROR_STATUS_CODES = [400, 401, 403, 404, 408, 413, 429, 499, 500, 502, 503, 504, 529]
diff --git a/frontend/src/utils/errorCategory.ts b/frontend/src/utils/errorCategory.ts
new file mode 100644
index 0000000000..58e97e14c4
--- /dev/null
+++ b/frontend/src/utils/errorCategory.ts
@@ -0,0 +1,31 @@
+/**
+ * 错误请求"分类"虚拟维度:phase + error type → 用户侧粗分类码。
+ * 镜像后端 service.MapUserErrorCategory(backend/internal/service/ops_user_error.go),
+ * 两处修改须同步。返回稳定分类码,展示文案走 i18n `usage.errors.categories.*`。
+ */
+export function mapErrorCategory(phase?: string | null, errType?: string | null): string {
+ switch ((phase || '').toLowerCase()) {
+ case 'auth':
+ return 'auth'
+ case 'routing':
+ return 'service_unavailable'
+ case 'upstream':
+ case 'network':
+ return 'upstream'
+ case 'internal':
+ return 'internal'
+ case 'request':
+ switch ((errType || '').toLowerCase()) {
+ case 'rate_limit_error':
+ return 'rate_limit'
+ case 'billing_error':
+ case 'subscription_error':
+ return 'quota'
+ case 'invalid_request_error':
+ return 'invalid_request'
+ case 'cyber_policy':
+ return 'cyber'
+ }
+ }
+ return 'other'
+}
diff --git a/frontend/src/views/admin/AccountsView.vue b/frontend/src/views/admin/AccountsView.vue
index e8cd05cb2b..b4e1630a85 100644
--- a/frontend/src/views/admin/AccountsView.vue
+++ b/frontend/src/views/admin/AccountsView.vue
@@ -322,6 +322,29 @@
{{ value }}
+
+
+ {{ column.label }}
+
+
+
+
+
+
+ {{ formatSchedulerScoreGroup(score) }}
+ /
+ {{ formatSchedulerScore(score.base_score) }}
+ /
+ {{ formatStickySchedulerScore(score) }}
+
+
+ -
+
{{ formatRelativeTime(value) }}
@@ -441,7 +464,7 @@ import TLSFingerprintProfilesModal from '@/components/admin/TLSFingerprintProfil
import { buildOpenAIUsageRefreshKey } from '@/utils/accountUsageRefresh'
import { formatDateTime, formatRelativeTime } from '@/utils/format'
import { proxyExpiryBadgeClass, proxyExpiryLabelKey } from '@/utils/proxyExpiry'
-import type { Account, AccountPlatform, AccountType, Proxy as AccountProxy, AdminGroup, WindowStats, ClaudeModel } from '@/types'
+import type { Account, AccountPlatform, AccountSchedulerGroupScore, AccountType, Proxy as AccountProxy, AdminGroup, WindowStats, ClaudeModel } from '@/types'
const { t } = useI18n()
const appStore = useAppStore()
@@ -643,6 +666,36 @@ const autoRefreshIntervalLabel = (sec: number) => {
return `${sec}s`
}
+const formatSchedulerScore = (value: unknown): string => {
+ const num = Number(value)
+ if (!Number.isFinite(num)) return '-'
+ return num.toFixed(6).replace(/\.?0+$/, '')
+}
+
+const formatStickySchedulerScore = (score: AccountSchedulerGroupScore): string => {
+ if (!score) return '-'
+ if (score.sticky_score_infinity) return '+∞'
+ return formatSchedulerScore(score.sticky_score)
+}
+
+const getSchedulerScoreRows = (account: Account): AccountSchedulerGroupScore[] => {
+ 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 t('admin.accounts.schedulerScore.ungrouped')
+}
+
const loadSavedColumns = () => {
try {
const saved = localStorage.getItem(HIDDEN_COLUMNS_KEY)
@@ -1166,6 +1219,7 @@ const allColumns = computed(() => {
{ key: 'usage', label: t('admin.accounts.columns.usageWindows'), sortable: false },
{ key: 'proxy', label: t('admin.accounts.columns.proxy'), sortable: false },
{ key: 'priority', label: t('admin.accounts.columns.priority'), sortable: true },
+ { key: 'scheduler_score', label: t('admin.accounts.columns.schedulerScore'), sortable: false },
{ key: 'rate_multiplier', label: t('admin.accounts.columns.billingRateMultiplier'), sortable: true },
{ key: 'last_used_at', label: t('admin.accounts.columns.lastUsed'), sortable: true },
{ key: 'created_at', label: t('admin.accounts.columns.createdAt'), sortable: true },
diff --git a/frontend/src/views/admin/GroupsView.vue b/frontend/src/views/admin/GroupsView.vue
index 56d21c86c1..1901971e45 100644
--- a/frontend/src/views/admin/GroupsView.vue
+++ b/frontend/src/views/admin/GroupsView.vue
@@ -789,11 +789,7 @@
+
+
+
+
+ {{ t("admin.settings.openaiExperimentalScheduler.stickyWeightedTitle") }}
+
+
+ {{
+ t("admin.settings.openaiExperimentalScheduler.stickyWeightedDescription")
+ }}
+
+
+
+
+
+
+
+
+ {{ t("admin.settings.openaiExperimentalScheduler.subscriptionPriorityTitle") }}
+
+
+ {{
+ t("admin.settings.openaiExperimentalScheduler.subscriptionPriorityDescription")
+ }}
+
+
+
+
+
+
+
+
+ {{ t("admin.settings.openaiExperimentalScheduler.weightsTitle") }}
+
+
+ {{
+ t("admin.settings.openaiExperimentalScheduler.weightsDescription")
+ }}
+
+
+
+
+
+
+ {{ field.label }}
+
+
+
+
+
@@ -6405,6 +6480,34 @@
}}
+
+
{{
+ t("admin.settings.payment.subscriptionUsdToCnyRate")
+ }}
+
+
+ {{
+ t("admin.settings.payment.subscriptionUsdToCnyRateHint")
+ }}
+
+
{{
t("admin.settings.payment.rechargeFeeRate")
@@ -7903,6 +8006,18 @@ type SettingsForm = Omit<
google_oauth_client_secret: string;
force_email_on_third_party_signup: boolean;
openai_advanced_scheduler_enabled: boolean;
+ openai_advanced_scheduler_sticky_weighted_enabled: boolean;
+ openai_advanced_scheduler_subscription_priority_enabled: boolean;
+ openai_advanced_scheduler_lb_top_k: string;
+ openai_advanced_scheduler_weight_priority: string;
+ openai_advanced_scheduler_weight_load: string;
+ openai_advanced_scheduler_weight_queue: string;
+ openai_advanced_scheduler_weight_error_rate: string;
+ openai_advanced_scheduler_weight_ttft: string;
+ openai_advanced_scheduler_weight_reset: string;
+ openai_advanced_scheduler_weight_quota_headroom: string;
+ openai_advanced_scheduler_weight_previous_response: string;
+ openai_advanced_scheduler_weight_session_sticky: string;
// 系统全局平台限额 map;form 内始终归一化为全 4 平台对象(模板非空绑定依赖此不变量)
default_platform_quotas: DefaultPlatformQuotasMap;
};
@@ -7950,6 +8065,7 @@ const form = reactive({
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: "",
@@ -8094,6 +8210,18 @@ const form = reactive({
// 分组隔离
allow_ungrouped_key_scheduling: false,
openai_advanced_scheduler_enabled: false,
+ openai_advanced_scheduler_sticky_weighted_enabled: false,
+ openai_advanced_scheduler_subscription_priority_enabled: false,
+ openai_advanced_scheduler_lb_top_k: "",
+ openai_advanced_scheduler_weight_priority: "",
+ openai_advanced_scheduler_weight_load: "",
+ openai_advanced_scheduler_weight_queue: "",
+ openai_advanced_scheduler_weight_error_rate: "",
+ openai_advanced_scheduler_weight_ttft: "",
+ openai_advanced_scheduler_weight_reset: "",
+ openai_advanced_scheduler_weight_quota_headroom: "",
+ openai_advanced_scheduler_weight_previous_response: "",
+ openai_advanced_scheduler_weight_session_sticky: "",
// Gateway forwarding behavior
enable_fingerprint_unification: true,
enable_metadata_passthrough: false,
@@ -8131,6 +8259,103 @@ const form = reactive({
allow_user_view_error_requests: false,
});
+type OpenAIAdvancedSchedulerOverrideKey =
+ | "openai_advanced_scheduler_lb_top_k"
+ | "openai_advanced_scheduler_weight_priority"
+ | "openai_advanced_scheduler_weight_load"
+ | "openai_advanced_scheduler_weight_queue"
+ | "openai_advanced_scheduler_weight_error_rate"
+ | "openai_advanced_scheduler_weight_ttft"
+ | "openai_advanced_scheduler_weight_reset"
+ | "openai_advanced_scheduler_weight_quota_headroom"
+ | "openai_advanced_scheduler_weight_previous_response"
+ | "openai_advanced_scheduler_weight_session_sticky";
+
+type OpenAIAdvancedSchedulerEffectiveKey =
+ | "openai_advanced_scheduler_effective_lb_top_k"
+ | "openai_advanced_scheduler_effective_weight_priority"
+ | "openai_advanced_scheduler_effective_weight_load"
+ | "openai_advanced_scheduler_effective_weight_queue"
+ | "openai_advanced_scheduler_effective_weight_error_rate"
+ | "openai_advanced_scheduler_effective_weight_ttft"
+ | "openai_advanced_scheduler_effective_weight_reset"
+ | "openai_advanced_scheduler_effective_weight_quota_headroom"
+ | "openai_advanced_scheduler_effective_weight_previous_response"
+ | "openai_advanced_scheduler_effective_weight_session_sticky";
+
+const openAIAdvancedSchedulerWeightFields = computed<
+ Array<{
+ key: OpenAIAdvancedSchedulerOverrideKey;
+ label: string;
+ placeholder: string;
+ }>
+>(() => {
+ const placeholder = (
+ effectiveKey: OpenAIAdvancedSchedulerEffectiveKey,
+ fallbackValue: string,
+ ) => {
+ const effectiveValue = String(
+ (form as Record)[effectiveKey] ?? "",
+ ).trim();
+ return t("admin.settings.openaiExperimentalScheduler.defaultPlaceholder", {
+ value: effectiveValue || fallbackValue,
+ });
+ };
+
+ return [
+ {
+ key: "openai_advanced_scheduler_lb_top_k",
+ label: t("admin.settings.openaiExperimentalScheduler.topKLabel"),
+ placeholder: placeholder("openai_advanced_scheduler_effective_lb_top_k", "7"),
+ },
+ {
+ key: "openai_advanced_scheduler_weight_priority",
+ label: t("admin.settings.openaiExperimentalScheduler.priorityWeight"),
+ placeholder: placeholder("openai_advanced_scheduler_effective_weight_priority", "1"),
+ },
+ {
+ key: "openai_advanced_scheduler_weight_load",
+ label: t("admin.settings.openaiExperimentalScheduler.loadWeight"),
+ placeholder: placeholder("openai_advanced_scheduler_effective_weight_load", "1"),
+ },
+ {
+ key: "openai_advanced_scheduler_weight_queue",
+ label: t("admin.settings.openaiExperimentalScheduler.queueWeight"),
+ placeholder: placeholder("openai_advanced_scheduler_effective_weight_queue", "0.7"),
+ },
+ {
+ key: "openai_advanced_scheduler_weight_error_rate",
+ label: t("admin.settings.openaiExperimentalScheduler.errorRateWeight"),
+ placeholder: placeholder("openai_advanced_scheduler_effective_weight_error_rate", "0.8"),
+ },
+ {
+ key: "openai_advanced_scheduler_weight_ttft",
+ label: t("admin.settings.openaiExperimentalScheduler.ttftWeight"),
+ placeholder: placeholder("openai_advanced_scheduler_effective_weight_ttft", "0.5"),
+ },
+ {
+ key: "openai_advanced_scheduler_weight_reset",
+ label: t("admin.settings.openaiExperimentalScheduler.resetWeight"),
+ placeholder: placeholder("openai_advanced_scheduler_effective_weight_reset", "0"),
+ },
+ {
+ key: "openai_advanced_scheduler_weight_quota_headroom",
+ label: t("admin.settings.openaiExperimentalScheduler.quotaHeadroomWeight"),
+ placeholder: placeholder("openai_advanced_scheduler_effective_weight_quota_headroom", "0"),
+ },
+ {
+ key: "openai_advanced_scheduler_weight_previous_response",
+ label: t("admin.settings.openaiExperimentalScheduler.previousResponseWeight"),
+ placeholder: placeholder("openai_advanced_scheduler_effective_weight_previous_response", "5"),
+ },
+ {
+ key: "openai_advanced_scheduler_weight_session_sticky",
+ label: t("admin.settings.openaiExperimentalScheduler.sessionStickyWeight"),
+ placeholder: placeholder("openai_advanced_scheduler_effective_weight_session_sticky", "3"),
+ },
+ ];
+});
+
const authSourceDefaults = reactive(
buildAuthSourceDefaultsState({}),
);
@@ -9342,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,
@@ -9359,6 +9586,30 @@ async function saveSettings() {
form.payment_cancel_rate_limit_window_mode,
payment_alipay_force_qrcode: form.payment_alipay_force_qrcode,
openai_advanced_scheduler_enabled: form.openai_advanced_scheduler_enabled,
+ openai_advanced_scheduler_sticky_weighted_enabled:
+ form.openai_advanced_scheduler_sticky_weighted_enabled,
+ openai_advanced_scheduler_subscription_priority_enabled:
+ form.openai_advanced_scheduler_subscription_priority_enabled,
+ openai_advanced_scheduler_lb_top_k:
+ form.openai_advanced_scheduler_lb_top_k.trim(),
+ openai_advanced_scheduler_weight_priority:
+ form.openai_advanced_scheduler_weight_priority.trim(),
+ openai_advanced_scheduler_weight_load:
+ form.openai_advanced_scheduler_weight_load.trim(),
+ openai_advanced_scheduler_weight_queue:
+ form.openai_advanced_scheduler_weight_queue.trim(),
+ openai_advanced_scheduler_weight_error_rate:
+ form.openai_advanced_scheduler_weight_error_rate.trim(),
+ openai_advanced_scheduler_weight_ttft:
+ form.openai_advanced_scheduler_weight_ttft.trim(),
+ openai_advanced_scheduler_weight_reset:
+ form.openai_advanced_scheduler_weight_reset.trim(),
+ openai_advanced_scheduler_weight_quota_headroom:
+ form.openai_advanced_scheduler_weight_quota_headroom.trim(),
+ openai_advanced_scheduler_weight_previous_response:
+ form.openai_advanced_scheduler_weight_previous_response.trim(),
+ openai_advanced_scheduler_weight_session_sticky:
+ form.openai_advanced_scheduler_weight_session_sticky.trim(),
// 余额、订阅到期与账号限额通知
balance_low_notify_enabled: form.balance_low_notify_enabled,
balance_low_notify_threshold:
diff --git a/frontend/src/views/admin/UsageView.vue b/frontend/src/views/admin/UsageView.vue
index fb2588cb7f..eb9a047662 100644
--- a/frontend/src/views/admin/UsageView.vue
+++ b/frontend/src/views/admin/UsageView.vue
@@ -64,7 +64,7 @@
-
+
{{ col.label }}
+ @update:pageSize="onErrPageSize"
+ @ipGeoBatchFailed="handleIpGeoBatchFailed" />
@@ -609,6 +614,74 @@ const toggleColumn = (key: string) => {
}
}
+// ---- 错误请求 tab 列设置(与用量明细同机制,独立存储) ----
+const ERR_ALWAYS_VISIBLE = ['user', 'status', 'created_at', 'actions']
+const ERR_DEFAULT_HIDDEN_COLUMNS = ['user_agent']
+const ERR_HIDDEN_COLUMNS_KEY = 'usage-error-hidden-columns'
+
+// key 集合须与 OpsErrorLogTable 内部 allColumns 一致
+const errAllColumns = computed(() => [
+ { key: 'user', label: t('admin.ops.errorLog.user') },
+ { key: 'api_key', label: t('admin.ops.errorLog.apiKey') },
+ { key: 'account', label: t('admin.ops.errorLog.account') },
+ { key: 'platform', label: t('admin.ops.errorLog.platform') },
+ { key: 'model', label: t('admin.ops.errorLog.model') },
+ { key: 'endpoint', label: t('admin.ops.errorLog.endpoint') },
+ { key: 'group', label: t('admin.ops.errorLog.group') },
+ { key: 'type', label: t('admin.ops.errorLog.type') },
+ { key: 'category', label: t('usage.errors.category') },
+ { key: 'status', label: t('admin.ops.errorLog.status') },
+ { key: 'message', label: t('admin.ops.errorLog.message') },
+ { key: 'created_at', label: t('admin.ops.errorLog.time') },
+ { key: 'user_agent', label: t('usage.userAgent') },
+ { key: 'client_ip', label: t('admin.ops.errorLog.ip') },
+ { key: 'actions', label: t('admin.ops.errorLog.action') },
+])
+
+const errHiddenColumns = reactive>(new Set())
+
+const errToggleableColumns = computed(() =>
+ errAllColumns.value.filter(col => !ERR_ALWAYS_VISIBLE.includes(col.key))
+)
+
+const errVisibleColumnKeys = computed(() =>
+ errAllColumns.value
+ .filter(col => ERR_ALWAYS_VISIBLE.includes(col.key) || !errHiddenColumns.has(col.key))
+ .map(col => col.key)
+)
+
+const toggleErrColumn = (key: string) => {
+ if (errHiddenColumns.has(key)) {
+ errHiddenColumns.delete(key)
+ } else {
+ errHiddenColumns.add(key)
+ }
+ try {
+ localStorage.setItem(ERR_HIDDEN_COLUMNS_KEY, JSON.stringify([...errHiddenColumns]))
+ } catch (e) {
+ console.error('Failed to save error columns:', e)
+ }
+}
+
+const loadSavedErrColumns = () => {
+ try {
+ const saved = localStorage.getItem(ERR_HIDDEN_COLUMNS_KEY)
+ const keys = saved ? (JSON.parse(saved) as string[]) : ERR_DEFAULT_HIDDEN_COLUMNS
+ keys.forEach((key) => errHiddenColumns.add(key))
+ } catch {
+ ERR_DEFAULT_HIDDEN_COLUMNS.forEach((key) => errHiddenColumns.add(key))
+ }
+}
+
+// 列设置下拉按当前 tab 分发
+const currentToggleableColumns = computed(() =>
+ activeTab.value === 'errors' ? errToggleableColumns.value : toggleableColumns.value
+)
+const isCurrentColumnVisible = (key: string) =>
+ activeTab.value === 'errors' ? !errHiddenColumns.has(key) : isColumnVisible(key)
+const toggleCurrentColumn = (key: string) =>
+ activeTab.value === 'errors' ? toggleErrColumn(key) : toggleColumn(key)
+
const loadSavedColumns = () => {
try {
const saved = localStorage.getItem(HIDDEN_COLUMNS_KEY)
@@ -635,6 +708,8 @@ const errLoading = ref(false)
const errPage = ref(1)
const errPageSize = ref(20)
const errTotal = ref(0)
+const errSortBy = ref('created_at')
+const errSortOrder = ref<'asc' | 'desc'>('desc')
const showErrorModal = ref(false)
const selectedErrorId = ref(null)
@@ -656,6 +731,11 @@ const loadAdminErrors = async () => {
account_id: filters.value.account_id ?? undefined,
group_id: filters.value.group_id ?? undefined,
model: filters.value.model || undefined,
+ phase: filters.value.error_phase || undefined,
+ category: filters.value.error_category || undefined,
+ status_codes: filters.value.status_code != null ? String(filters.value.status_code) : undefined,
+ sort_by: errSortBy.value,
+ sort_order: errSortOrder.value,
})
errRows.value = resp.items
errTotal.value = resp.total
@@ -667,6 +747,12 @@ const loadAdminErrors = async () => {
}
}
+const onErrSort = (sortBy: string, sortOrder: 'asc' | 'desc') => {
+ errSortBy.value = sortBy
+ errSortOrder.value = sortOrder
+ errPage.value = 1
+ loadAdminErrors()
+}
const onErrPage = (p: number) => { errPage.value = p; loadAdminErrors() }
const onErrPageSize = (s: number) => { errPageSize.value = s; errPage.value = 1; loadAdminErrors() }
const openError = (id: number) => { selectedErrorId.value = id; showErrorModal.value = true }
@@ -690,6 +776,7 @@ onMounted(() => {
void loadChartData()
}, 120)
loadSavedColumns()
+ loadSavedErrColumns()
document.addEventListener('click', handleColumnClickOutside)
})
onUnmounted(() => { abortController?.abort(); exportAbortController?.abort(); document.removeEventListener('click', handleColumnClickOutside) })
diff --git a/frontend/src/views/admin/__tests__/AccountsView.schedulerScore.spec.ts b/frontend/src/views/admin/__tests__/AccountsView.schedulerScore.spec.ts
new file mode 100644
index 0000000000..0865a6ec91
--- /dev/null
+++ b/frontend/src/views/admin/__tests__/AccountsView.schedulerScore.spec.ts
@@ -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('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: `
+
+ `
+}
+
+function mountView() {
+ return mount(AccountsView, {
+ global: {
+ stubs: {
+ AppLayout: { template: '
' },
+ TablePageLayout: {
+ template: '
'
+ },
+ DataTable: DataTableStub,
+ HelpTooltip: true,
+ Pagination: true,
+ ConfirmDialog: true,
+ AccountTableActions: { template: '
' },
+ AccountTableFilters: { template: '
' },
+ 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('-')
+ })
+})
diff --git a/frontend/src/views/admin/__tests__/SettingsView.spec.ts b/frontend/src/views/admin/__tests__/SettingsView.spec.ts
index e458728b66..4705e9face 100644
--- a/frontend/src/views/admin/__tests__/SettingsView.spec.ts
+++ b/frontend/src/views/admin/__tests__/SettingsView.spec.ts
@@ -161,6 +161,23 @@ vi.mock("vue-i18n", async () => {
"admin.settings.payment.findProvider": "查看支持的支付方式",
"admin.settings.openaiExperimentalScheduler.title": "OpenAI 实验调度策略",
"admin.settings.openaiExperimentalScheduler.description": "默认关闭。开启后仅影响本网关在 OpenAI 账号间的实验性调度选择逻辑,不代表上游 OpenAI 官方能力。",
+ "admin.settings.openaiExperimentalScheduler.stickyWeightedTitle": "粘性加权",
+ "admin.settings.openaiExperimentalScheduler.stickyWeightedDescription": "开启后 previous_response_id 和 session_hash 粘性进入高级调度打分;关闭时仍按旧逻辑硬命中粘性账号。",
+ "admin.settings.openaiExperimentalScheduler.subscriptionPriorityTitle": "订阅优先",
+ "admin.settings.openaiExperimentalScheduler.subscriptionPriorityDescription": "开启后先在 ChatGPT 订阅账号池中按权值选取;订阅池拿不到席位时再回退到非订阅账号池。",
+ "admin.settings.openaiExperimentalScheduler.weightsTitle": "调度权值覆盖",
+ "admin.settings.openaiExperimentalScheduler.weightsDescription": "留空时使用配置/环境变量值;配置未设置时使用内置默认值。页面非空设置优先。",
+ "admin.settings.openaiExperimentalScheduler.defaultPlaceholder": "配置/默认:{value}",
+ "admin.settings.openaiExperimentalScheduler.topKLabel": "TopK",
+ "admin.settings.openaiExperimentalScheduler.priorityWeight": "优先级",
+ "admin.settings.openaiExperimentalScheduler.loadWeight": "负载",
+ "admin.settings.openaiExperimentalScheduler.queueWeight": "排队",
+ "admin.settings.openaiExperimentalScheduler.errorRateWeight": "错误率",
+ "admin.settings.openaiExperimentalScheduler.ttftWeight": "首包延迟",
+ "admin.settings.openaiExperimentalScheduler.resetWeight": "重置窗口",
+ "admin.settings.openaiExperimentalScheduler.quotaHeadroomWeight": "额度余量",
+ "admin.settings.openaiExperimentalScheduler.previousResponseWeight": "previous_response 粘性",
+ "admin.settings.openaiExperimentalScheduler.sessionStickyWeight": "session_hash 粘性",
"admin.settings.site.uploadImage": "上传图片",
"admin.settings.site.remove": "移除",
"admin.settings.platformQuota.platform": "平台",
@@ -395,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: "",
@@ -411,6 +429,28 @@ const baseSettingsResponse = {
payment_visible_method_alipay_enabled: true,
payment_visible_method_wxpay_enabled: true,
openai_advanced_scheduler_enabled: false,
+ openai_advanced_scheduler_sticky_weighted_enabled: false,
+ openai_advanced_scheduler_subscription_priority_enabled: false,
+ openai_advanced_scheduler_lb_top_k: "",
+ openai_advanced_scheduler_weight_priority: "",
+ openai_advanced_scheduler_weight_load: "",
+ openai_advanced_scheduler_weight_queue: "",
+ openai_advanced_scheduler_weight_error_rate: "",
+ openai_advanced_scheduler_weight_ttft: "",
+ openai_advanced_scheduler_weight_reset: "",
+ openai_advanced_scheduler_weight_quota_headroom: "",
+ openai_advanced_scheduler_weight_previous_response: "",
+ openai_advanced_scheduler_weight_session_sticky: "",
+ openai_advanced_scheduler_effective_lb_top_k: "7",
+ openai_advanced_scheduler_effective_weight_priority: "1",
+ openai_advanced_scheduler_effective_weight_load: "1",
+ openai_advanced_scheduler_effective_weight_queue: "0.7",
+ openai_advanced_scheduler_effective_weight_error_rate: "0.8",
+ openai_advanced_scheduler_effective_weight_ttft: "0.5",
+ openai_advanced_scheduler_effective_weight_reset: "0",
+ openai_advanced_scheduler_effective_weight_quota_headroom: "0",
+ openai_advanced_scheduler_effective_weight_previous_response: "5",
+ openai_advanced_scheduler_effective_weight_session_sticky: "3",
balance_low_notify_enabled: false,
balance_low_notify_threshold: 0,
balance_low_notify_recharge_url: "",
diff --git a/frontend/src/views/admin/__tests__/groupsImagePricing.spec.ts b/frontend/src/views/admin/__tests__/groupsImagePricing.spec.ts
new file mode 100644
index 0000000000..5f6ebce2fa
--- /dev/null
+++ b/frontend/src/views/admin/__tests__/groupsImagePricing.spec.ts
@@ -0,0 +1,17 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ imagePricingPlatforms,
+ supportsImagePricingPlatform,
+} from "../groupsImagePricing";
+
+describe("groups image pricing platform support", () => {
+ it("includes Grok media groups", () => {
+ expect(supportsImagePricingPlatform("grok")).toBe(true);
+ expect(imagePricingPlatforms.has("grok")).toBe(true);
+ });
+
+ it("keeps non-media group platforms out of the image pricing controls", () => {
+ expect(supportsImagePricingPlatform("anthropic")).toBe(false);
+ });
+});
diff --git a/frontend/src/views/admin/groupsImagePricing.ts b/frontend/src/views/admin/groupsImagePricing.ts
new file mode 100644
index 0000000000..1a2c5170ce
--- /dev/null
+++ b/frontend/src/views/admin/groupsImagePricing.ts
@@ -0,0 +1,9 @@
+export const imagePricingPlatforms = new Set([
+ "antigravity",
+ "gemini",
+ "grok",
+ "openai",
+]);
+
+export const supportsImagePricingPlatform = (platform: string): boolean =>
+ imagePricingPlatforms.has(platform);
diff --git a/frontend/src/views/admin/ops/components/OpsErrorDetailsModal.vue b/frontend/src/views/admin/ops/components/OpsErrorDetailsModal.vue
index e9e4401001..0400033e89 100644
--- a/frontend/src/views/admin/ops/components/OpsErrorDetailsModal.vue
+++ b/frontend/src/views/admin/ops/components/OpsErrorDetailsModal.vue
@@ -84,6 +84,16 @@ function close() {
emit('update:show', false)
}
+const sortBy = ref('created_at')
+const sortOrder = ref<'asc' | 'desc'>('desc')
+
+function onSort(nextSortBy: string, nextSortOrder: 'asc' | 'desc') {
+ sortBy.value = nextSortBy
+ sortOrder.value = nextSortOrder
+ page.value = 1
+ void fetchErrorLogs()
+}
+
async function fetchErrorLogs() {
if (!props.show) return
@@ -93,7 +103,9 @@ async function fetchErrorLogs() {
page: page.value,
page_size: pageSize.value,
time_range: props.timeRange,
- view: viewMode.value
+ view: viewMode.value,
+ sort_by: sortBy.value,
+ sort_order: sortOrder.value
}
const platform = String(props.platform || '').trim()
@@ -253,6 +265,7 @@ watch(
:page="page"
:page-size="pageSize"
@openErrorDetail="emit('openErrorDetail', $event)"
+ @sort="onSort"
@update:page="page = $event"
@update:pageSize="pageSize = $event"
diff --git a/frontend/src/views/admin/ops/components/OpsErrorLogTable.vue b/frontend/src/views/admin/ops/components/OpsErrorLogTable.vue
index becf6327a2..437f0960bd 100644
--- a/frontend/src/views/admin/ops/components/OpsErrorLogTable.vue
+++ b/frontend/src/views/admin/ops/components/OpsErrorLogTable.vue
@@ -1,272 +1,251 @@
-
-
-
-
+
+
+
+
+
emit('openErrorDetail', row.id)"
+ >
+
+ {{ formatDateTime(row.created_at) }}
+
+
+
+
+ {{ getTypeBadge(row).label }}
+
+
+
+
+
+
+ {{ t('usage.inbound') }}:
+ {{ row.inbound_endpoint?.trim() || '-' }}
+
+
+ {{ t('usage.upstream') }}:
+ {{ row.upstream_endpoint?.trim() || '-' }}
+
+
+
+
+
+ {{ row.platform || '-' }}
+
+
+
+
+
{{ row.requested_model }}
+
↳ {{ row.upstream_model }}
+
+ {{ displayModel(row) }}
+ -
+
+
+
+
+ {{ row.group_name || '#' + row.group_id }}
+
+ -
+
+
+
+
+
+ {{ row.user_email }}
+
+ {{ row.user_email || '-' }}
+ #{{ row.user_id }}
+
+
+
+
+ {{ row.deleted_key_owner_email }}
+
+ {{ row.deleted_key_owner_email || '-' }}
+ #{{ row.deleted_key_owner_user_id }}
+
+ -
+
+
+
+
+ {{ row.api_key_name || '#' + row.api_key_id }}
+ {{ t('admin.ops.errorLog.keyDeletedBadge') }}
+
+ -
+
+
+
+ {{ row.account_name || '#' + row.account_id }}
+ -
+
+
+
+
+ {{ t('usage.errors.categories.' + mapErrorCategory(row.phase, row.type)) }}
+
+
+
+
+
+
+ {{ row.status_code }}
+
+ {{ row.severity }}
+ {{ formatRequestType(row.request_type) }}
+
+
+
+
+ {{ formatSmartMessage(row.message) || '-' }}
+ -
+
+
+
+ {{ row.user_agent }}
+ -
+
+
+
+
+
+ {{ row.client_ip }}
+
+
+
-
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
- {{ t('admin.ops.errorLog.time') }}
-
-
- {{ t('admin.ops.errorLog.type') }}
-
-
- {{ t('admin.ops.errorLog.endpoint') }}
-
-
- {{ t('admin.ops.errorLog.platform') }}
-
-
- {{ t('admin.ops.errorLog.model') }}
-
-
- {{ t('admin.ops.errorLog.group') }}
-
-
- {{ t('admin.ops.errorLog.user') }}
-
-
- {{ t('admin.ops.errorLog.apiKey') }}
-
-
- {{ t('admin.ops.errorLog.account') }}
-
-
- {{ t('admin.ops.errorLog.status') }}
-
-
- {{ t('admin.ops.errorLog.message') }}
-
-
- {{ t('admin.ops.errorLog.action') }}
-
-
-
-
-
-
- {{ t('admin.ops.errorLog.noErrors') }}
-
-
-
-
-
-
-
-
- {{ formatDateTime(log.created_at).split(' ')[1] }}
-
-
-
-
-
-
-
- {{ getTypeBadge(log).label }}
-
-
-
-
-
-
-
-
- {{ log.inbound_endpoint }}
-
-
- -
-
-
-
-
-
-
- {{ log.platform || '-' }}
-
-
-
-
-
-
-
-
-
- {{ log.requested_model }}
- →
- {{ log.upstream_model }}
-
-
-
-
-
- {{ displayModel(log) }}
-
- -
-
-
-
-
-
-
-
-
- {{ log.group_name || '-' }}
-
-
- -
-
-
-
-
-
-
- {{ log.user_email || '-' }}
-
-
- -
-
-
-
-
-
-
- {{ log.api_key_name || ('#' + log.api_key_id) }}
-
-
- {{ t('admin.ops.errorLog.keyDeletedBadge') }}
-
-
- -
-
-
-
-
-
-
- {{ log.account_name || '-' }}
-
-
- -
-
-
-
-
-
-
- {{ log.status_code }}
-
-
- {{ log.severity }}
-
-
- {{ formatRequestType(log.request_type) }}
-
-
-
-
-
-
-
-
- {{ formatSmartMessage(log.message) || '-' }}
-
-
-
-
-
-
-
-
- {{ t('admin.ops.errorLog.details') }}
-
-
-
-
-
-
-
-
-
-
+
diff --git a/frontend/src/views/admin/orders/AdminPaymentPlansView.vue b/frontend/src/views/admin/orders/AdminPaymentPlansView.vue
index c2fc26fe90..f99c33cd2f 100644
--- a/frontend/src/views/admin/orders/AdminPaymentPlansView.vue
+++ b/frontend/src/views/admin/orders/AdminPaymentPlansView.vue
@@ -67,7 +67,7 @@
-
+
@@ -78,6 +78,7 @@ import { ref, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { adminPaymentAPI } from '@/api/admin/payment'
+import type { AdminPaymentConfig } from '@/api/admin/payment'
import { extractI18nErrorMessage } from '@/utils/apiError'
import adminAPI from '@/api/admin'
import type { SubscriptionPlan } from '@/types/payment'
@@ -97,6 +98,7 @@ const appStore = useAppStore()
// ==================== Groups ====================
const groups = ref
([])
+const paymentConfig = ref(null)
async function loadGroups() {
try {
@@ -104,6 +106,13 @@ async function loadGroups() {
} catch { /* ignore */ }
}
+async function loadPaymentConfig() {
+ try {
+ const res = await adminPaymentAPI.getConfig()
+ paymentConfig.value = res.data
+ } catch { /* preview only */ }
+}
+
function getGroup(id: number): AdminGroup | undefined {
return groups.value.find(g => g.id === id)
}
@@ -181,6 +190,7 @@ async function handleDeletePlan() {
onMounted(() => {
loadGroups()
+ loadPaymentConfig()
loadPlans()
})
diff --git a/frontend/src/views/admin/orders/PlanEditDialog.vue b/frontend/src/views/admin/orders/PlanEditDialog.vue
index acc70bef16..92b6574734 100644
--- a/frontend/src/views/admin/orders/PlanEditDialog.vue
+++ b/frontend/src/views/admin/orders/PlanEditDialog.vue
@@ -35,7 +35,16 @@
{{ t('payment.admin.planDescription') }} *
-
{{ t('payment.admin.price') }} *
+
+
{{ t('payment.admin.price') }} *
+
+
+ {{ t('payment.admin.subscriptionCnyPayPreview', { amount: subscriptionCnyPreview.amount }) }}
+
+ {{ t('payment.admin.subscriptionCnyPayPreviewWithFee', { feeRate: subscriptionCnyPreview.feeRate, total: subscriptionCnyPreview.total }) }}
+
+
+
{{ t('payment.admin.originalPrice') }}
@@ -81,7 +90,9 @@ import { ref, reactive, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { adminPaymentAPI } from '@/api/admin/payment'
+import type { AdminPaymentConfig } from '@/api/admin/payment'
import { extractApiErrorMessage } from '@/utils/apiError'
+import { formatPaymentAmount } from '@/components/payment/currency'
import type { SubscriptionPlan } from '@/types/payment'
import type { AdminGroup } from '@/types'
import BaseDialog from '@/components/common/BaseDialog.vue'
@@ -94,6 +105,7 @@ const props = defineProps<{
show: boolean
plan: SubscriptionPlan | null
groups: AdminGroup[]
+ paymentConfig?: AdminPaymentConfig | null
}>()
const emit = defineEmits<{
@@ -129,6 +141,31 @@ const selectedGroupInfo = computed(() => {
return props.groups.find(g => g.id === planForm.group_id) || null
})
+function roundCnyAmount(value: number): number {
+ return Math.round(value * 100) / 100
+}
+
+function ceilCnyAmount(value: number): number {
+ return Math.ceil(value * 100) / 100
+}
+
+const subscriptionCnyPreview = computed(() => {
+ const price = Number(planForm.price) || 0
+ const rate = Number(props.paymentConfig?.subscription_usd_to_cny_rate) || 0
+ if (price <= 0 || rate <= 0) return null
+
+ const amount = roundCnyAmount(price * rate)
+ const feeRate = Number(props.paymentConfig?.recharge_fee_rate) || 0
+ const fee = feeRate > 0 ? ceilCnyAmount((amount * feeRate) / 100) : 0
+ const total = feeRate > 0 ? roundCnyAmount(amount + fee) : amount
+
+ return {
+ amount: formatPaymentAmount(amount, 'CNY'),
+ feeRate,
+ total: formatPaymentAmount(total, 'CNY'),
+ }
+})
+
// Reset form when dialog opens
watch(() => props.show, (visible) => {
if (!visible) return
diff --git a/frontend/src/views/admin/orders/__tests__/PlanEditDialog.spec.ts b/frontend/src/views/admin/orders/__tests__/PlanEditDialog.spec.ts
new file mode 100644
index 0000000000..9c31e7c176
--- /dev/null
+++ b/frontend/src/views/admin/orders/__tests__/PlanEditDialog.spec.ts
@@ -0,0 +1,77 @@
+import { describe, expect, it, vi } from 'vitest'
+import { mount } from '@vue/test-utils'
+import PlanEditDialog from '../PlanEditDialog.vue'
+
+vi.mock('vue-i18n', () => ({
+ useI18n: () => ({
+ t: (key: string, params?: Record
) => {
+ if (key === 'payment.admin.subscriptionCnyPayPreview') return `preview ${params?.amount}`
+ if (key === 'payment.admin.subscriptionCnyPayPreviewWithFee') return `fee ${params?.feeRate} ${params?.total}`
+ return key
+ },
+ }),
+}))
+
+vi.mock('@/stores/app', () => ({
+ useAppStore: () => ({
+ showError: vi.fn(),
+ showSuccess: vi.fn(),
+ }),
+}))
+
+vi.mock('@/api/admin/payment', () => ({
+ adminPaymentAPI: {
+ createPlan: vi.fn(),
+ updatePlan: vi.fn(),
+ },
+}))
+
+function mountDialog(paymentConfig: Record | null) {
+ return mount(PlanEditDialog, {
+ props: {
+ show: true,
+ plan: null,
+ groups: [],
+ paymentConfig,
+ },
+ global: {
+ stubs: {
+ BaseDialog: {
+ props: ['show'],
+ template: '
',
+ },
+ Select: true,
+ Icon: true,
+ GroupBadge: true,
+ },
+ },
+ })
+}
+
+describe('PlanEditDialog subscription CNY payment preview', () => {
+ it('shows CNY channel charge using the configured subscription rate and fee', async () => {
+ const wrapper = mountDialog({
+ subscription_usd_to_cny_rate: 7.15,
+ recharge_fee_rate: 2.5,
+ })
+
+ await wrapper.find('input[type="number"]').setValue('9.99')
+
+ expect(wrapper.text()).toContain('preview')
+ expect(wrapper.text()).toContain('¥71.43')
+ expect(wrapper.text()).toContain('fee 2.5')
+ expect(wrapper.text()).toContain('¥73.22')
+ })
+
+ it('hides the preview when the subscription rate is not configured', async () => {
+ const wrapper = mountDialog({
+ subscription_usd_to_cny_rate: 0,
+ recharge_fee_rate: 2.5,
+ })
+
+ await wrapper.find('input[type="number"]').setValue('9.99')
+
+ expect(wrapper.text()).not.toContain('preview')
+ expect(wrapper.text()).not.toContain('¥71.43')
+ })
+})
diff --git a/frontend/src/views/user/KeysView.vue b/frontend/src/views/user/KeysView.vue
index 1aabf6b834..087e0e4175 100644
--- a/frontend/src/views/user/KeysView.vue
+++ b/frontend/src/views/user/KeysView.vue
@@ -170,6 +170,19 @@
+
+
+ {{ value ?? 0 }}
+
+
+
@@ -1155,6 +1168,7 @@ const allColumns = computed
(() => [
{ 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 },
diff --git a/frontend/src/views/user/PaymentQRCodeView.vue b/frontend/src/views/user/PaymentQRCodeView.vue
index f844858daf..5df67d0fe9 100644
--- a/frontend/src/views/user/PaymentQRCodeView.vue
+++ b/frontend/src/views/user/PaymentQRCodeView.vue
@@ -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')
diff --git a/frontend/src/views/user/PaymentView.vue b/frontend/src/views/user/PaymentView.vue
index e3901ea0af..6d1d2fd31e 100644
--- a/frontend/src/views/user/PaymentView.vue
+++ b/frontend/src/views/user/PaymentView.vue
@@ -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({
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(() =>
@@ -592,6 +603,7 @@ const methodOptions = computed(() =>
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(() => {
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
}
diff --git a/frontend/src/views/user/UsageView.vue b/frontend/src/views/user/UsageView.vue
index b66fdaa336..eb2b124839 100644
--- a/frontend/src/views/user/UsageView.vue
+++ b/frontend/src/views/user/UsageView.vue
@@ -68,7 +68,33 @@
-
+
+
+ {{ t('usage.errors.keyName') }}
+
+
+
+ {{ t('usage.errors.model') }}
+
+
+
+ {{ t('usage.errors.category') }}
+
+
+
+ {{ t('usage.errors.status') }}
+
+
+
+
{{ t('usage.apiKeyFilter') }}
@@ -96,7 +122,7 @@
-
+
{{ t('common.refresh') }}
@@ -117,18 +143,18 @@
class="absolute right-0 top-full z-50 mt-1 max-h-80 w-48 overflow-y-auto rounded-lg border border-gray-200 bg-white py-1 shadow-lg dark:border-dark-600 dark:bg-dark-800"
>
{{ col.label }}
-
+
-
+
{{ exporting ? t('usage.exporting') : t('usage.exportCsv') }}
@@ -175,10 +201,11 @@
:loading="errorLoading"
:page="errorPage"
:page-size="errorPageSize"
- :api-keys="apiKeys"
- @filter="onErrorFilter"
+ :visible-column-keys="errVisibleColumnKeys"
+ @sort="onErrorSort"
@update:page="onErrorPage"
@update:pageSize="onErrorPageSize"
+ @ipGeoBatchFailed="handleIpGeoBatchFailed"
/>
@@ -219,6 +246,7 @@ import type {
UserErrorRequest,
} from '@/types'
import type { Column } from '@/components/common/types'
+import { COMMON_ERROR_STATUS_CODES } from '@/utils/errorBadges'
const { t } = useI18n()
const appStore = useAppStore()
@@ -244,13 +272,53 @@ const errorRows = ref
([])
const errorLoading = ref(false)
const errorPage = ref(1)
const errorPageSize = ref(20)
+const errorSortBy = ref('created_at')
+const errorSortOrder = ref<'asc' | 'desc'>('desc')
const errorTotal = ref(0)
-const errorFilter = ref<{ model: string; category: string; api_key_id: number | null }>({
+const errorFilter = ref<{ model: string | null; category: string; api_key_id: number | null; status_code: number | null }>({
model: '',
category: '',
api_key_id: null,
+ status_code: null,
})
+const errorKeyOptions = computed(() => [
+ { value: null, label: t('usage.errors.allKeys') },
+ ...apiKeys.value.map((k) => ({ value: k.id, label: k.name })),
+])
+
+// 模型候选取自当前已加载错误中出现过的模型;creatable 允许输入任意片段做后端模糊。
+const errorModelOptions = computed(() => {
+ const seen = new Set()
+ const opts: SelectOption[] = []
+ for (const r of errorRows.value) {
+ if (r.model && !seen.has(r.model)) {
+ seen.add(r.model)
+ opts.push({ value: r.model, label: r.model })
+ }
+ }
+ return opts
+})
+
+const errorCategoryCodes = ['auth', 'rate_limit', 'quota', 'invalid_request', 'service_unavailable', 'upstream', 'internal', 'cyber']
+
+const errorCategoryOptions = computed(() => [
+ { value: '', label: t('usage.errors.allCategories') },
+ ...errorCategoryCodes.map((c) => ({ value: c, label: t('usage.errors.categories.' + c) })),
+])
+
+// 状态码候选用固定常用列表(与管理端 UsageFilters 共用常量),不受当前页数据限制:
+// 后端 status_code 过滤对全量生效,若只列当前页出现过的码,用户就选不到仅在后续页的码。
+const errorStatusOptions = computed(() => [
+ { value: null, label: t('usage.errors.allStatuses') },
+ ...COMMON_ERROR_STATUS_CODES.map((c) => ({ value: c, label: String(c) })),
+])
+
+const applyErrorFilters = () => {
+ errorPage.value = 1
+ void loadErrors()
+}
+
let abortController: AbortController | null = null
let chartReqSeq = 0
let statsReqSeq = 0
@@ -486,6 +554,10 @@ const resetFilters = () => {
}
granularity.value = getGranularityForRange(range.start, range.end)
applyFilters()
+ if (activeTab.value === 'errors') {
+ errorFilter.value = { model: '', category: '', api_key_id: null, status_code: null }
+ applyErrorFilters()
+ }
}
const onDateRangeChange = (range: { startDate: string; endDate: string; preset: string | null }) => {
@@ -605,7 +677,7 @@ const exportToCSV = async () => {
headers.map(escapeCSVValue).join(','),
...rows.map((row) => row.join(',')),
].join('\n')
- const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' })
+ const blob = new Blob(['\uFEFF' + csvContent], { type: 'text/csv;charset=utf-8;' })
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
@@ -663,6 +735,63 @@ const loadSavedColumns = () => {
}
}
+// 错误请求 tab 独立列设置(机制同用量列设置,存储互不影响)
+const ERR_ALWAYS_VISIBLE = ['status', 'created_at']
+const ERR_DEFAULT_HIDDEN_COLUMNS = ['user_agent']
+const ERR_HIDDEN_COLUMNS_KEY = 'user-usage-error-hidden-columns'
+
+// key 须与 UserErrorRequestsTable 的 allColumns 一致
+const errAllColumns = computed(() => [
+ { key: 'key_name', label: t('usage.errors.keyName') },
+ { key: 'model', label: t('usage.errors.model') },
+ { key: 'endpoint', label: t('usage.errors.endpoint') },
+ { key: 'client_ip', label: 'IP' },
+ { key: 'group', label: t('admin.usage.group') },
+ { key: 'type', label: t('usage.type') },
+ { key: 'platform', label: t('usage.errors.platform') },
+ { key: 'category', label: t('usage.errors.category') },
+ { key: 'status', label: t('usage.errors.status') },
+ { key: 'message', label: t('usage.errors.message') },
+ { key: 'created_at', label: t('usage.errors.time') },
+ { key: 'user_agent', label: t('usage.userAgent') },
+])
+
+const errHiddenColumns = reactive>(new Set())
+const errToggleableColumns = computed(() =>
+ errAllColumns.value.filter((col) => !ERR_ALWAYS_VISIBLE.includes(col.key))
+)
+const errVisibleColumnKeys = computed(() =>
+ errAllColumns.value
+ .filter((col) => ERR_ALWAYS_VISIBLE.includes(col.key) || !errHiddenColumns.has(col.key))
+ .map((col) => col.key)
+)
+const isErrColumnVisible = (key: string) => !errHiddenColumns.has(key)
+const toggleErrColumn = (key: string) => {
+ if (errHiddenColumns.has(key)) errHiddenColumns.delete(key)
+ else errHiddenColumns.add(key)
+ localStorage.setItem(ERR_HIDDEN_COLUMNS_KEY, JSON.stringify([...errHiddenColumns]))
+}
+const loadSavedErrColumns = () => {
+ try {
+ const saved = localStorage.getItem(ERR_HIDDEN_COLUMNS_KEY)
+ const values = saved ? (JSON.parse(saved) as string[]) : ERR_DEFAULT_HIDDEN_COLUMNS
+ values.forEach((key) => errHiddenColumns.add(key))
+ } catch {
+ ERR_DEFAULT_HIDDEN_COLUMNS.forEach((key) => errHiddenColumns.add(key))
+ }
+}
+
+// 列设置下拉按当前 tab 分发
+const currentToggleableColumns = computed(() =>
+ activeTab.value === 'errors' ? errToggleableColumns.value : toggleableColumns.value
+)
+const isCurrentColumnVisible = (key: string) =>
+ activeTab.value === 'errors' ? isErrColumnVisible(key) : isColumnVisible(key)
+const toggleCurrentColumn = (key: string) => {
+ if (activeTab.value === 'errors') toggleErrColumn(key)
+ else toggleColumn(key)
+}
+
const showColumnDropdown = ref(false)
const columnDropdownRef = ref(null)
const handleColumnClickOutside = (event: MouseEvent) => {
@@ -702,9 +831,12 @@ const loadErrors = async () => {
page_size: errorPageSize.value,
start_date: startDate.value,
end_date: endDate.value,
- model: errorFilter.value.model || undefined,
+ model: (errorFilter.value.model ?? '').trim() || undefined,
category: errorFilter.value.category || undefined,
api_key_id: errorFilter.value.api_key_id ?? undefined,
+ status_code: errorFilter.value.status_code ?? undefined,
+ sort_by: errorSortBy.value,
+ sort_order: errorSortOrder.value,
})
errorRows.value = resp.items
errorTotal.value = resp.total
@@ -716,8 +848,9 @@ const loadErrors = async () => {
}
}
-const onErrorFilter = (filter: { model: string; category: string; api_key_id: number | null }) => {
- errorFilter.value = filter
+const onErrorSort = (sortBy: string, sortOrder: 'asc' | 'desc') => {
+ errorSortBy.value = sortBy
+ errorSortOrder.value = sortOrder
errorPage.value = 1
void loadErrors()
}
@@ -740,6 +873,7 @@ const switchToErrors = () => {
onMounted(() => {
loadSavedColumns()
+ loadSavedErrColumns()
document.addEventListener('click', handleColumnClickOutside)
void loadFilterOptions()
refreshData()
diff --git a/frontend/src/views/user/__tests__/KeysView.spec.ts b/frontend/src/views/user/__tests__/KeysView.spec.ts
index 4f671ad427..2417cd9e5c 100644
--- a/frontend/src/views/user/__tests__/KeysView.spec.ts
+++ b/frontend/src/views/user/__tests__/KeysView.spec.ts
@@ -42,6 +42,7 @@ const messages: Record = {
'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 = {
{{ columns.map((col) => col.key).join(',') }}
@@ -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')
+ })
})
diff --git a/frontend/src/views/user/__tests__/PaymentView.spec.ts b/frontend/src/views/user/__tests__/PaymentView.spec.ts
index 3b16d42af1..aa7c401349 100644
--- a/frontend/src/views/user/__tests__/PaymentView.spec.ts
+++ b/frontend/src/views/user/__tests__/PaymentView.spec.ts
@@ -105,6 +105,7 @@ function checkoutInfoFixture(overrides: Partial = {}) {
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 {
- 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: '
',
+ },
+ PaymentStatusPanel: {
+ template: ' ',
+ },
+ PaymentMethodSelector: {
+ props: ['selected'],
+ template: '{{ selected }}
',
+ },
+ 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'
diff --git a/frontend/src/views/user/__tests__/UsageView.spec.ts b/frontend/src/views/user/__tests__/UsageView.spec.ts
index 555ea4b0f6..23fc3c256a 100644
--- a/frontend/src/views/user/__tests__/UsageView.spec.ts
+++ b/frontend/src/views/user/__tests__/UsageView.spec.ts
@@ -237,6 +237,11 @@ describe('user UsageView', () => {
}))
expect(clickSpy).toHaveBeenCalled()
expect(showSuccess).toHaveBeenCalled()
+ expect(csvContent.startsWith('\uFEFF')).toBe(true)
+ expect(csvContent.slice(1)).toBe([
+ 'Time,API Key Name,Model,Reasoning Effort,Inbound Endpoint,IP Address,Type,Billing Mode,Input Tokens,Output Tokens,Cache Read Tokens,Cache Creation Tokens,Rate Multiplier,Billed Cost,Original Cost,First Token (ms),Duration (ms)',
+ '2026-03-08T00:00:00Z,demo-key,gpt-5.4,"\'-",,203.0.113.10,Sync,Token,4057,101,278272,4,1,0.09288300,0.09288300,12,345',
+ ].join('\n'))
expect(csvContent).toContain('IP Address')
expect(csvContent).toContain('203.0.113.10')
expect(csvContent).toContain('Billed Cost')