@@ -989,7 +1093,7 @@ import { ref, watch, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { adminAPI } from '@/api/admin'
-import type { Proxy as ProxyConfig, AdminGroup, AccountPlatform, AccountType } from '@/types'
+import type { Proxy as ProxyConfig, AdminGroup, AccountPlatform, AccountType, OpenAICompactMode } from '@/types'
import BaseDialog from '@/components/common/BaseDialog.vue'
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
import Select from '@/components/common/Select.vue'
@@ -1115,6 +1219,8 @@ const enableOpenAIPassthrough = ref(false)
const enableOpenAIWSMode = ref(false)
const enableOpenAIAPIKeyWSMode = ref(false)
const enableCodexCLIOnly = ref(false)
+const enableOpenAICompactMode = ref(false)
+const enableOpenAICompactModelMapping = ref(false)
const enableRpmLimit = ref(false)
// State - field values
@@ -1140,6 +1246,8 @@ const openaiPassthroughEnabled = ref(false)
const openaiOAuthResponsesWebSocketV2Mode = ref(OPENAI_WS_MODE_OFF)
const openaiAPIKeyResponsesWebSocketV2Mode = ref(OPENAI_WS_MODE_OFF)
const codexCLIOnlyEnabled = ref(false)
+const openAICompactMode = ref('auto')
+const openAICompactModelMappings = ref([])
const rpmLimitEnabled = ref(false)
const bulkBaseRpm = ref(null)
const bulkRpmStrategy = ref<'tiered' | 'sticky_exempt'>('tiered')
@@ -1178,6 +1286,11 @@ const openAIWSModeOptions = computed(() => [
{ value: OPENAI_WS_MODE_CTX_POOL, label: t('admin.accounts.openai.wsModeCtxPool') },
{ value: OPENAI_WS_MODE_PASSTHROUGH, label: t('admin.accounts.openai.wsModePassthrough') }
])
+const openAICompactModeOptions = computed(() => [
+ { value: 'auto', label: t('admin.accounts.openai.compactModeAuto') },
+ { value: 'force_on', label: t('admin.accounts.openai.compactModeForceOn') },
+ { value: 'force_off', label: t('admin.accounts.openai.compactModeForceOff') }
+])
const openAIWSModeConcurrencyHintKey = computed(() =>
resolveOpenAIWSModeConcurrencyHintKey(openaiOAuthResponsesWebSocketV2Mode.value)
)
@@ -1194,6 +1307,14 @@ const removeModelMapping = (index: number) => {
modelMappings.value.splice(index, 1)
}
+const addOpenAICompactModelMapping = () => {
+ openAICompactModelMappings.value.push({ from: '', to: '' })
+}
+
+const removeOpenAICompactModelMapping = (index: number) => {
+ openAICompactModelMappings.value.splice(index, 1)
+}
+
const addPresetMapping = (from: string, to: string) => {
const exists = modelMappings.value.some((m) => m.from === from)
if (exists) {
@@ -1262,6 +1383,10 @@ const buildModelMappingObject = (): Record | null => {
)
}
+const buildOpenAICompactModelMapping = (): Record | null => {
+ return buildModelMappingPayload('mapping', [], openAICompactModelMappings.value)
+}
+
const buildUpdatePayload = (): Record | null => {
const updates: Record = {}
const credentials: Record = {}
@@ -1350,10 +1475,6 @@ const buildUpdatePayload = (): Record | null => {
credentialsChanged = true
}
- if (credentialsChanged) {
- updates.credentials = credentials
- }
-
if (enableOpenAIWSMode.value) {
const extra = ensureExtra()
extra.openai_oauth_responses_websockets_v2_mode = openaiOAuthResponsesWebSocketV2Mode.value
@@ -1375,6 +1496,16 @@ const buildUpdatePayload = (): Record | null => {
extra.codex_cli_only = codexCLIOnlyEnabled.value
}
+ if (enableOpenAICompactMode.value) {
+ const extra = ensureExtra()
+ extra.openai_compact_mode = openAICompactMode.value
+ }
+
+ if (enableOpenAICompactModelMapping.value) {
+ credentials.compact_model_mapping = buildOpenAICompactModelMapping() ?? {}
+ credentialsChanged = true
+ }
+
// RPM limit settings (写入 extra 字段)
if (enableRpmLimit.value) {
const extra = ensureExtra()
@@ -1402,6 +1533,10 @@ const buildUpdatePayload = (): Record | null => {
umqExtra.user_msg_queue_enabled = false // 清理旧字段(JSONB merge)
}
+ if (credentialsChanged) {
+ updates.credentials = credentials
+ }
+
return Object.keys(updates).length > 0 ? updates : null
}
@@ -1467,6 +1602,8 @@ const handleSubmit = async () => {
enableOpenAIWSMode.value ||
enableOpenAIAPIKeyWSMode.value ||
enableCodexCLIOnly.value ||
+ enableOpenAICompactMode.value ||
+ enableOpenAICompactModelMapping.value ||
enableRpmLimit.value ||
userMsgQueueMode.value !== null
@@ -1567,6 +1704,8 @@ watch(
enableOpenAIWSMode.value = false
enableOpenAIAPIKeyWSMode.value = false
enableCodexCLIOnly.value = false
+ enableOpenAICompactMode.value = false
+ enableOpenAICompactModelMapping.value = false
enableRpmLimit.value = false
// Reset all values
@@ -1588,6 +1727,8 @@ watch(
openaiOAuthResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF
openaiAPIKeyResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF
codexCLIOnlyEnabled.value = false
+ openAICompactMode.value = 'auto'
+ openAICompactModelMappings.value = []
rpmLimitEnabled.value = false
bulkBaseRpm.value = null
bulkRpmStrategy.value = 'tiered'
diff --git a/frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts b/frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts
index 50d170da..caa307fc 100644
--- a/frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts
+++ b/frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts
@@ -217,6 +217,44 @@ describe('BulkEditAccountModal', () => {
})
})
+ it('筛选 OpenAI 账号批量编辑应提交 Compact 模式和专属模型映射', async () => {
+ const wrapper = mountModal({
+ accountIds: [],
+ selectedPlatforms: [],
+ selectedTypes: [],
+ target: {
+ mode: 'filtered',
+ filters: { platform: 'openai' },
+ previewCount: 12,
+ selectedPlatforms: ['openai'],
+ selectedTypes: ['oauth', 'apikey']
+ }
+ })
+
+ await wrapper.get('#bulk-edit-openai-compact-mode-enabled').setValue(true)
+ await wrapper.get('[data-testid="bulk-edit-openai-compact-mode-select"]').setValue('force_on')
+ await wrapper.get('#bulk-edit-openai-compact-model-mapping-enabled').setValue(true)
+ await wrapper.get('[data-testid="bulk-edit-openai-compact-model-mapping-add"]').trigger('click')
+ const inputs = wrapper.findAll('[data-testid="bulk-edit-openai-compact-model-mapping-input"]')
+ await inputs[0].setValue('gpt-5.4')
+ await inputs[1].setValue('gpt-5.4-openai-compact')
+ await wrapper.get('#bulk-edit-account-form').trigger('submit.prevent')
+ await flushPromises()
+
+ expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledTimes(1)
+ expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledWith({
+ filters: { platform: 'openai' },
+ extra: {
+ openai_compact_mode: 'force_on'
+ },
+ credentials: {
+ compact_model_mapping: {
+ 'gpt-5.4': 'gpt-5.4-openai-compact'
+ }
+ }
+ })
+ })
+
it('OpenAI 账号批量编辑可关闭自动透传', async () => {
const wrapper = mountModal({
selectedPlatforms: ['openai'],
diff --git a/frontend/src/components/admin/usage/UsageTable.vue b/frontend/src/components/admin/usage/UsageTable.vue
index adcb3cc6..629e6aa2 100644
--- a/frontend/src/components/admin/usage/UsageTable.vue
+++ b/frontend/src/components/admin/usage/UsageTable.vue
@@ -291,9 +291,23 @@
+
+
+ {{ t('usage.imageCount') }}
+ {{ tooltipData.image_count }}{{ t('usage.imageUnit') }} ({{ tooltipData.image_size || '2K' }})
+
+
+ {{ t('usage.imageUnitPrice') }}
+ ${{ imageUnitPrice(tooltipData).toFixed(6) }}
+
+
+ {{ t('usage.imageTotalPrice') }}
+ ${{ tooltipData.total_cost?.toFixed(6) || '0.000000' }}
+
+
- {{ tooltipData.billing_mode === BILLING_MODE_IMAGE ? t('usage.imageUnitPrice') : t('usage.unitPrice') }}
- ${{ tooltipData.total_cost?.toFixed(6) || '0.000000' }}
+ {{ t('usage.unitPrice') }}
+ ${{ tooltipData?.total_cost?.toFixed(6) || '0.000000' }}
{{ t('admin.usage.cacheCreationCost') }}
@@ -360,6 +374,13 @@ function accountBilled(row: { total_cost?: number | null; account_stats_cost?: n
return Number.isNaN(result) ? 0 : result
}
+function imageUnitPrice(row: AdminUsageLog | null): number {
+ if (!row || row.image_count <= 0) return 0
+ const total = row.total_cost ?? 0
+ const price = total / row.image_count
+ return Number.isFinite(price) ? price : 0
+}
+
import DataTable from '@/components/common/DataTable.vue'
import EmptyState from '@/components/common/EmptyState.vue'
import Icon from '@/components/icons/Icon.vue'
diff --git a/frontend/src/components/admin/user/UserBalanceHistoryModal.vue b/frontend/src/components/admin/user/UserBalanceHistoryModal.vue
index 1a79e4e3..6d48ed77 100644
--- a/frontend/src/components/admin/user/UserBalanceHistoryModal.vue
+++ b/frontend/src/components/admin/user/UserBalanceHistoryModal.vue
@@ -196,6 +196,7 @@ const totalPages = computed(() => Math.ceil(total.value / pageSize) || 1)
const typeOptions = computed(() => [
{ value: '', label: t('admin.users.allTypes') },
{ value: 'balance', label: t('admin.users.typeBalance') },
+ { value: 'affiliate_balance', label: t('admin.users.typeAffiliateBalance') },
{ value: 'admin_balance', label: t('admin.users.typeAdminBalance') },
{ value: 'concurrency', label: t('admin.users.typeConcurrency') },
{ value: 'admin_concurrency', label: t('admin.users.typeAdminConcurrency') },
@@ -235,7 +236,7 @@ const loadHistory = async (page: number) => {
const isAdminType = (type: string) => type === 'admin_balance' || type === 'admin_concurrency'
// Helper: check if balance type (includes admin_balance)
-const isBalanceType = (type: string) => type === 'balance' || type === 'admin_balance'
+const isBalanceType = (type: string) => type === 'balance' || type === 'admin_balance' || type === 'affiliate_balance'
// Helper: check if subscription type
const isSubscriptionType = (type: string) => type === 'subscription'
@@ -291,6 +292,8 @@ const getItemTitle = (item: BalanceHistoryItem) => {
switch (item.type) {
case 'balance':
return t('redeem.balanceAddedRedeem')
+ case 'affiliate_balance':
+ return t('redeem.balanceAddedAffiliate')
case 'admin_balance':
return item.value >= 0 ? t('redeem.balanceAddedAdmin') : t('redeem.balanceDeductedAdmin')
case 'concurrency':
diff --git a/frontend/src/components/common/GroupSelector.vue b/frontend/src/components/common/GroupSelector.vue
index 582b6f0b..e5980ef5 100644
--- a/frontend/src/components/common/GroupSelector.vue
+++ b/frontend/src/components/common/GroupSelector.vue
@@ -5,7 +5,24 @@
{{ t('common.selectedCount', { count: modelValue.length }) }}
+
+
+
+
+
+
+ {{ t('usage.imageCount') }}
+ {{ tooltipData.image_count }}{{ t('usage.imageUnit') }} ({{ tooltipData.image_size || '2K' }})
+
+
+ {{ t('usage.imageUnitPrice') }}
+ ${{ imageUnitPrice(tooltipData).toFixed(6) }}
+
+
+ {{ t('usage.imageTotalPrice') }}
+ ${{ tooltipData.total_cost?.toFixed(6) || '0.000000' }}
+
+
- {{ tooltipData.billing_mode === 'image' ? t('usage.imageUnitPrice') : t('usage.unitPrice') }}
- ${{ tooltipData.total_cost?.toFixed(6) || '0.000000' }}
+ {{ t('usage.unitPrice') }}
+ ${{ tooltipData?.total_cost?.toFixed(6) || '0.000000' }}
{{ t('admin.usage.cacheCreationCost') }}
@@ -625,6 +639,13 @@ const formatDuration = (ms: number): string => {
return `${(ms / 1000).toFixed(2)}s`
}
+const imageUnitPrice = (row: UsageLog | null): number => {
+ if (!row || row.image_count <= 0) return 0
+ const total = row.total_cost ?? 0
+ const price = total / row.image_count
+ return Number.isFinite(price) ? price : 0
+}
+
const formatUserAgent = (ua: string): string => {
return ua
}
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
index b71f9d58..38770704 100644
--- a/frontend/vite.config.ts
+++ b/frontend/vite.config.ts
@@ -44,7 +44,6 @@ export default defineConfig(({ mode }) => {
plugins: [
vue(),
checker({
- typescript: true,
vueTsc: true
}),
injectPublicSettings(backendUrl)