From 7b612b8b5add270b7e1f69e5b1366ffa15fb0a7e Mon Sep 17 00:00:00 2001 From: ZheFox <77232781+zhefox@users.noreply.github.com> Date: Thu, 3 Sep 2026 20:49:42 +0800 Subject: [PATCH] feat(frontend): persist model pricing sources --- .../components/GlobalModelFormDialog.vue | 65 +- .../GlobalModelPricingSourceSelect.vue | 146 +++++ .../GlobalModelFormDialog.prefill.spec.ts | 75 ++- .../useModelsDevPricingSources.spec.ts | 62 +- .../composables/useModelsDevPricingSources.ts | 77 ++- frontend/src/i18n/messages.ts | 130 ++++ frontend/src/views/admin/ModelManagement.vue | 583 ++++++++++++++---- .../ModelManagement.pricing-source.spec.ts | 296 +++++++++ 8 files changed, 1288 insertions(+), 146 deletions(-) create mode 100644 frontend/src/features/models/components/GlobalModelPricingSourceSelect.vue create mode 100644 frontend/src/views/admin/__tests__/ModelManagement.pricing-source.spec.ts diff --git a/frontend/src/features/models/components/GlobalModelFormDialog.vue b/frontend/src/features/models/components/GlobalModelFormDialog.vue index c091c86db..a8ad73510 100644 --- a/frontend/src/features/models/components/GlobalModelFormDialog.vue +++ b/frontend/src/features/models/components/GlobalModelFormDialog.vue @@ -408,10 +408,14 @@ () const { success, error: showError } = useToast() +const { t } = useI18n() const { getSource, setSource } = useModelsDevPricingSources() const submitting = ref(false) const syncingOnlinePricing = ref(false) @@ -842,6 +858,9 @@ const selectedOnlinePricingCandidate = computed(() => ( candidate.providerId === selectedOnlinePricingProviderId.value )) ?? null )) +const currentOnlinePricingSource = computed(() => ( + props.model ? getSource(props.model.id, props.model.config) : null +)) const firstSyncableOnlinePricingProviderId = computed(() => ( onlinePricingCandidates.value.find(isOnlinePricingCandidateSyncable)?.providerId ?? '' )) @@ -1407,7 +1426,7 @@ function resolveOnlinePricingModel( ): ModelsDevModelItem | null { const modelId = normalizeModelId(model.name) const transientSource = editingOnlinePricingSource.value - const storedSource = getSource(model.id) + const storedSource = getSource(model.id, model.config) const preferredProviderId = transientSource?.model_id && normalizeModelId(transientSource.model_id) === modelId ? transientSource.provider_id : storedSource?.provider_id @@ -1530,28 +1549,44 @@ async function applyOnlinePricingModel(onlineModel: ModelsDevModelItem) { props.model.default_tiered_pricing, pricing, ) + const pricingSource = { + provider_id: onlineModel.providerId, + provider_name: onlineModel.providerName, + } + const sourceChanged = !modelsDevPricingSourcesEqual( + getModelsDevPricingSourceFromConfig(props.model.config), + pricingSource, + ) + const nextConfig = withModelsDevPricingSource(props.model.config, pricingSource) let syncedModel: GlobalModelResponse - if (pricingChanged) { - syncedModel = await updateGlobalModel(props.model.id, { + if (pricingChanged || sourceChanged) { + const updatedModel = await updateGlobalModel(props.model.id, { default_tiered_pricing: pricing, + config: nextConfig, }) + syncedModel = { + ...updatedModel, + default_tiered_pricing: pricing, + config: nextConfig, + } } else { syncedModel = { ...props.model, default_tiered_pricing: pricing, + config: nextConfig, } } tieredPricing.value = cloneTieredPricingConfig(pricing) + form.value.config = { ...nextConfig } billingMode.value = 'token' - setSource(props.model.id, { - provider_id: onlineModel.providerId, - provider_name: onlineModel.providerName, - }) + setSource(props.model.id, pricingSource) emit('pricingSynced', syncedModel) success( pricingChanged ? `已同步 ${onlineModel.providerName} 的最新价格` - : `当前价格已是 ${onlineModel.providerName} 的最新价格`, + : sourceChanged + ? t('models.pricingSource.savedNoPriceChange', { provider: onlineModel.providerName }) + : `当前价格已是 ${onlineModel.providerName} 的最新价格`, ) } @@ -1725,6 +1760,12 @@ async function handleSubmit() { success('模型更新成功') } else { const createData = buildGlobalModelCreatePayload(form.value, finalTieredPricing) + if (selectedModel.value) { + createData.config = withModelsDevPricingSource(createData.config, { + provider_id: selectedModel.value.providerId, + provider_name: selectedModel.value.providerName, + }) + } const createdModel = await createGlobalModel(createData) existingModelsCache.value.unshift(createdModel) if (selectedModel.value) { diff --git a/frontend/src/features/models/components/GlobalModelPricingSourceSelect.vue b/frontend/src/features/models/components/GlobalModelPricingSourceSelect.vue new file mode 100644 index 000000000..37c80437b --- /dev/null +++ b/frontend/src/features/models/components/GlobalModelPricingSourceSelect.vue @@ -0,0 +1,146 @@ + + + diff --git a/frontend/src/features/models/components/__tests__/GlobalModelFormDialog.prefill.spec.ts b/frontend/src/features/models/components/__tests__/GlobalModelFormDialog.prefill.spec.ts index b782ecb62..7396f90d5 100644 --- a/frontend/src/features/models/components/__tests__/GlobalModelFormDialog.prefill.spec.ts +++ b/frontend/src/features/models/components/__tests__/GlobalModelFormDialog.prefill.spec.ts @@ -306,6 +306,10 @@ describe('GlobalModelFormDialog preset replacement', () => { family: 'fresh-family', input_modalities: ['text'], output_modalities: ['text'], + models_dev_pricing_source: { + provider_id: 'openai', + provider_name: 'OpenAI', + }, }, default_tiered_pricing: { tiers: [ @@ -477,9 +481,17 @@ describe('GlobalModelFormDialog preset replacement', () => { it('refreshes and applies the latest online price from the edit dialog', async () => { const existingStaleModel = buildExistingStaleModel() + const nextConfig = { + ...existingStaleModel.config, + models_dev_pricing_source: { + provider_id: stalePreset.providerId, + provider_name: stalePreset.providerName, + }, + } const syncedModel = { ...existingStaleModel, default_tiered_pricing: stalePreset.tieredPricing!, + config: nextConfig, } globalModelMocks.updateGlobalModel.mockResolvedValue(syncedModel) globalModelMocks.listGlobalModels.mockResolvedValue({ @@ -495,7 +507,7 @@ describe('GlobalModelFormDialog preset replacement', () => { '[data-testid="sync-online-pricing"]', ) if (!syncButton) throw new Error('Missing online pricing sync button') - expect(syncButton.title).toBe('同步最新在线价格') + expect(syncButton.title).toBe('选择并同步在线价格来源') expect(syncButton.getAttribute('aria-label')).toBe('同步最新在线价格') syncButton.click() @@ -505,7 +517,10 @@ describe('GlobalModelFormDialog preset replacement', () => { expect(modelsDevMocks.refreshModelsDevList).toHaveBeenCalledWith(false) expect(globalModelMocks.updateGlobalModel).toHaveBeenCalledWith( existingStaleModel.id, - { default_tiered_pricing: stalePreset.tieredPricing }, + { + default_tiered_pricing: stalePreset.tieredPricing, + config: nextConfig, + }, ) expect(pricingSynced).toHaveBeenCalledWith(syncedModel) expect(document.body.querySelector('[data-testid="tier-input-price"]')?.value) @@ -524,11 +539,62 @@ describe('GlobalModelFormDialog preset replacement', () => { }) }) + it('persists a newly selected source even when prices already match and keeps it on save', async () => { + const existingModel = { + ...buildExistingStaleModel(), + default_tiered_pricing: stalePreset.tieredPricing!, + } + const nextConfig = { + ...existingModel.config, + models_dev_pricing_source: { + provider_id: stalePreset.providerId, + provider_name: stalePreset.providerName, + }, + } + const syncedModel = { ...existingModel, config: nextConfig } + globalModelMocks.updateGlobalModel.mockResolvedValue(syncedModel) + const { editingModel, pricingSynced } = mountDialog() + await settle() + + editingModel.value = existingModel + await settle() + const syncButton = document.body.querySelector( + '[data-testid="sync-online-pricing"]', + ) + if (!syncButton) throw new Error('Missing online pricing sync button') + syncButton.click() + await settle() + + expect(globalModelMocks.updateGlobalModel).toHaveBeenNthCalledWith( + 1, + existingModel.id, + { + default_tiered_pricing: stalePreset.tieredPricing, + config: nextConfig, + }, + ) + expect(pricingSynced).toHaveBeenCalledWith(syncedModel) + + findExactButton('保存').click() + await settle() + + expect(globalModelMocks.updateGlobalModel).toHaveBeenCalledTimes(2) + expect(globalModelMocks.updateGlobalModel.mock.calls[1][1].config).toEqual(nextConfig) + }) + it('offers a provider choice when the remembered source is unavailable', async () => { const existingStaleModel = buildExistingStaleModel() + const nextConfig = { + ...existingStaleModel.config, + models_dev_pricing_source: { + provider_id: alternateStalePreset.providerId, + provider_name: alternateStalePreset.providerName, + }, + } const syncedModel = { ...existingStaleModel, default_tiered_pricing: alternateStalePreset.tieredPricing!, + config: nextConfig, } modelsDevMocks.refreshModelsDevList.mockResolvedValue([ unavailableStalePreset, @@ -578,7 +644,10 @@ describe('GlobalModelFormDialog preset replacement', () => { expect(globalModelMocks.updateGlobalModel).toHaveBeenCalledWith( existingStaleModel.id, - { default_tiered_pricing: alternateStalePreset.tieredPricing }, + { + default_tiered_pricing: alternateStalePreset.tieredPricing, + config: nextConfig, + }, ) expect(pricingSynced).toHaveBeenCalledWith(syncedModel) expect(document.body.textContent).not.toContain('选择在线价格来源') diff --git a/frontend/src/features/models/composables/__tests__/useModelsDevPricingSources.spec.ts b/frontend/src/features/models/composables/__tests__/useModelsDevPricingSources.spec.ts index 33c5a6ca1..9711a5078 100644 --- a/frontend/src/features/models/composables/__tests__/useModelsDevPricingSources.spec.ts +++ b/frontend/src/features/models/composables/__tests__/useModelsDevPricingSources.spec.ts @@ -1,6 +1,11 @@ import { beforeEach, describe, expect, it } from 'vitest' -import { useModelsDevPricingSources } from '../useModelsDevPricingSources' +import { + getModelsDevPricingSourceFromConfig, + modelsDevPricingSourcesEqual, + useModelsDevPricingSources, + withModelsDevPricingSource, +} from '../useModelsDevPricingSources' const STORAGE_KEY = 'aether:models-dev-pricing-sources:v1' const LEGACY_STORAGE_KEY = 'aether:models-dev-pricing-preferences:v1' @@ -33,6 +38,24 @@ describe('useModelsDevPricingSources', () => { }) }) + it('prefers the database-backed model config over the local migration fallback', () => { + const { getSource, setSource } = useModelsDevPricingSources() + setSource('model-1', { + provider_id: 'openai', + provider_name: 'OpenAI', + }) + + expect(getSource('model-1', { + models_dev_pricing_source: { + provider_id: 'anthropic', + provider_name: 'Anthropic', + }, + })).toEqual({ + provider_id: 'anthropic', + provider_name: 'Anthropic', + }) + }) + it('migrates the previous provider record without retaining its automatic preference key', () => { localStorage.setItem(LEGACY_STORAGE_KEY, JSON.stringify({ version: 1, @@ -65,4 +88,39 @@ describe('useModelsDevPricingSources', () => { expect(getSource('model-1')).toBeNull() }) -}) \ No newline at end of file +}) + +describe('database-backed models.dev pricing sources', () => { + it('merges the source into model config without dropping unrelated settings', () => { + const config = withModelsDevPricingSource({ + streaming: true, + billing: { video: { price_per_second: 0.1 } }, + }, { + provider_id: ' google ', + provider_name: ' Google ', + }) + + expect(config).toEqual({ + streaming: true, + billing: { video: { price_per_second: 0.1 } }, + models_dev_pricing_source: { + provider_id: 'google', + provider_name: 'Google', + }, + }) + expect(getModelsDevPricingSourceFromConfig(config)).toEqual({ + provider_id: 'google', + provider_name: 'Google', + }) + }) + + it('rejects malformed config records and compares provider ids case-insensitively', () => { + expect(getModelsDevPricingSourceFromConfig({ + models_dev_pricing_source: { provider_id: '', provider_name: 'Missing id' }, + })).toBeNull() + expect(modelsDevPricingSourcesEqual( + { provider_id: 'OpenAI', provider_name: 'OpenAI' }, + { provider_id: 'openai', provider_name: 'OpenAI' }, + )).toBe(true) + }) +}) diff --git a/frontend/src/features/models/composables/useModelsDevPricingSources.ts b/frontend/src/features/models/composables/useModelsDevPricingSources.ts index 8ab71adc4..8d2da5593 100644 --- a/frontend/src/features/models/composables/useModelsDevPricingSources.ts +++ b/frontend/src/features/models/composables/useModelsDevPricingSources.ts @@ -5,6 +5,8 @@ export interface ModelsDevPricingSource { provider_name: string } +export const MODELS_DEV_PRICING_SOURCE_CONFIG_KEY = 'models_dev_pricing_source' + interface StoredModelsDevPricingSources { version: 1 models: Record @@ -14,6 +16,50 @@ const STORAGE_KEY = 'aether:models-dev-pricing-sources:v1' const LEGACY_STORAGE_KEY = 'aether:models-dev-pricing-preferences:v1' const sources = ref>({}) +function normalizePricingSource(value: unknown): ModelsDevPricingSource | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null + const source = value as Partial + const providerId = typeof source.provider_id === 'string' ? source.provider_id.trim() : '' + const providerName = typeof source.provider_name === 'string' ? source.provider_name.trim() : '' + if (!providerId || !providerName) return null + return { + provider_id: providerId, + provider_name: providerName, + } +} + +/** + * Reads the shared price-source record persisted with global_models.config. + * localStorage remains only as a migration fallback for records created by + * older Aether builds. + */ +export function getModelsDevPricingSourceFromConfig( + config: Record | null | undefined, +): ModelsDevPricingSource | null { + if (!config || typeof config !== 'object' || Array.isArray(config)) return null + return normalizePricingSource(config[MODELS_DEV_PRICING_SOURCE_CONFIG_KEY]) +} + +export function withModelsDevPricingSource( + config: Record | null | undefined, + source: ModelsDevPricingSource, +): Record { + const normalizedSource = normalizePricingSource(source) + if (!normalizedSource) return { ...(config ?? {}) } + return { + ...(config ?? {}), + [MODELS_DEV_PRICING_SOURCE_CONFIG_KEY]: normalizedSource, + } +} + +export function modelsDevPricingSourcesEqual( + left: ModelsDevPricingSource | null | undefined, + right: ModelsDevPricingSource | null | undefined, +): boolean { + return left?.provider_id.trim().toLowerCase() === right?.provider_id.trim().toLowerCase() + && left?.provider_name.trim() === right?.provider_name.trim() +} + function parseStoredSources(key: string): Record | null { try { const stored = JSON.parse(localStorage.getItem(key) || 'null') as unknown @@ -23,19 +69,8 @@ function parseStoredSources(key: string): Record const validSources: Record = {} for (const [modelId, value] of Object.entries(document.models)) { - if (!value || typeof value !== 'object') continue - const source = value as Partial - if ( - typeof source.provider_id === 'string' - && source.provider_id.length > 0 - && typeof source.provider_name === 'string' - && source.provider_name.length > 0 - ) { - validSources[modelId] = { - provider_id: source.provider_id, - provider_name: source.provider_name, - } - } + const source = normalizePricingSource(value) + if (source) validSources[modelId] = source } return validSources } catch { @@ -75,14 +110,23 @@ function readStoredSources(): Record { export function useModelsDevPricingSources() { sources.value = readStoredSources() - function getSource(modelId: string): ModelsDevPricingSource | null { + function getLocalSource(modelId: string): ModelsDevPricingSource | null { return sources.value[modelId] ?? null } + function getSource( + modelId: string, + config?: Record | null, + ): ModelsDevPricingSource | null { + return getModelsDevPricingSourceFromConfig(config) ?? getLocalSource(modelId) + } + function setSource(modelId: string, source: ModelsDevPricingSource) { + const normalizedSource = normalizePricingSource(source) + if (!normalizedSource) return const nextSources = { ...sources.value, - [modelId]: source, + [modelId]: normalizedSource, } sources.value = nextSources writeStoredSources(nextSources) @@ -90,6 +134,7 @@ export function useModelsDevPricingSources() { return { getSource, + getLocalSource, setSource, } -} \ No newline at end of file +} diff --git a/frontend/src/i18n/messages.ts b/frontend/src/i18n/messages.ts index 60142b6ed..d67766d4a 100644 --- a/frontend/src/i18n/messages.ts +++ b/frontend/src/i18n/messages.ts @@ -323,6 +323,71 @@ export const messages = { 'models.externalCatalog.saved': '外部模型目录代理节点已保存', 'models.externalCatalog.saveFailed': '保存外部模型目录代理节点失败', 'models.externalCatalog.loadFailed': '加载外部模型目录代理节点失败', + 'models.pricingSource.label': '价格来源', + 'models.pricingSource.choose': '选择来源', + 'models.pricingSource.currentTitle': '当前来源:{provider};选择其他来源会立即同步价格', + 'models.pricingSource.chooseTitle': '选择来源并立即同步在线价格', + 'models.pricingSource.editCurrentTitle': '当前来源:{provider};点击选择或重新同步', + 'models.pricingSource.editChooseTitle': '选择并同步在线价格来源', + 'models.pricingSource.syncingTitle': '正在同步在线价格', + 'models.pricingSource.buttonCurrent': '价格来源 · {provider}', + 'models.pricingSource.loading': '加载来源...', + 'models.pricingSource.loadingOptions': '正在加载在线价格来源...', + 'models.pricingSource.catalogEmpty': 'models.dev 暂无匹配来源', + 'models.pricingSource.resyncTitle': '从当前来源重新同步价格', + 'models.pricingSource.pendingDatabase': '待保存到数据库', + 'models.pricingSource.incompatible': '计价不兼容', + 'models.pricingSource.noTokenPrice': '无 Token 价格', + 'models.pricingSource.inputPrice': '输入 ${price}', + 'models.pricingSource.outputPrice': '输出 ${price}', + 'models.pricingSource.sourceGone': '所选价格来源不再提供该模型,请刷新在线目录后重试', + 'models.pricingSource.cannotSync': '无法同步', + 'models.pricingSource.unsupported': '该来源包含当前计费引擎无法独立结算的{fields}', + 'models.pricingSource.noUsablePrice': '该来源没有可用的 Token 价格', + 'models.pricingSource.selectedAndSynced': '已选择 {provider} 并同步模型价格', + 'models.pricingSource.updateFailed': '更新模型价格来源失败', + 'models.pricingSource.syncFailed': '同步失败', + 'models.pricingSource.savedNoPriceChange': '已保存 {provider} 为价格来源,当前价格无需更新', + 'models.pricingField.reasoning': '推理 Token', + 'models.pricingField.inputAudio': '输入音频 Token', + 'models.pricingField.outputAudio': '输出音频 Token', + 'models.management.selectedCount': '已选 {count} 个', + 'models.management.manageSelectedTitle': '管理已选择的 {count} 个模型', + 'models.management.batchButtonSelected': '批量操作 ({count})', + 'models.management.batchButton': '批量管理', + 'models.management.selectCurrentPage': '选择当前页模型', + 'models.management.selectModel': '选择模型 {name}', + 'models.management.batch.title': '快速筛选与批量操作', + 'models.management.batch.description': '默认按每个模型保存在数据库中的在线来源同步,也可手动指定统一来源', + 'models.management.batch.providerPlaceholder': '选择在线价格来源', + 'models.management.batch.rememberedProvider': '上次选择(按模型)', + 'models.management.batch.selectionEmpty': '选择模型后执行批量操作', + 'models.management.batch.selectionSummary': '已选择 {selected} 个 · 可同步 {syncable} · 待保存来源 {sourcePending} · 已一致 {unchanged} · 不兼容 {unsupported} · 无在线价格 {unavailable}', + 'models.management.batch.state.syncable': '价格可更新', + 'models.management.batch.state.sourcePending': '来源待保存', + 'models.management.batch.state.unchanged': '价格一致', + 'models.management.batch.state.unsupported': '计价不兼容', + 'models.management.batch.state.unavailable': '无在线价格', + 'models.management.batch.source.noneSelected': '未选择来源', + 'models.management.batch.source.noneRecorded': '未记录来源', + 'models.management.batch.shortcut.syncable': '当前价格与所选供应商在线价格不同', + 'models.management.batch.shortcut.sourcePending': '价格已一致,但来源尚未保存到数据库', + 'models.management.batch.shortcut.unchanged': '当前价格与所选供应商在线价格一致', + 'models.management.batch.shortcut.unsupported': '在线来源包含当前计费引擎无法独立结算的价格维度', + 'models.management.batch.shortcut.unavailable': '所选供应商没有该模型的在线价格', + 'models.management.batch.syncButton': '同步价格与来源 ({count})', + 'models.management.batch.syncing': '同步中...', + 'models.management.batch.confirmTitle': '批量同步价格与来源', + 'models.management.batch.confirmMain': '将根据 {provider} 为 {count} 个模型同步在线价格并把价格来源保存到数据库。', + 'models.management.batch.confirmSourceOnly': '其中 {count} 个模型价格已一致,只保存来源。', + 'models.management.batch.confirmSkipped': '另有 {count} 个模型因价格已一致且来源已保存、计价不兼容或无在线价格而跳过。', + 'models.management.batch.confirmUnchanged': '不会修改模型名称、能力或其他配置。', + 'models.management.batch.confirmButton': '同步并保存', + 'models.management.batch.rememberedProviderLabel': '各模型上次选择的提供商', + 'models.management.batch.selectedProviderLabel': '所选供应商', + 'models.management.batch.success': '成功同步 {count} 个模型的价格与来源', + 'models.management.batch.partialFailure': '{count} 个模型同步失败:{details}', + 'models.management.batch.catalogMissingPrice': '在线目录未提供价格配置', 'nav.routing': '调度策略', 'nav.pool': '号池管理', 'nav.standaloneKeys': '独立密钥', @@ -675,6 +740,71 @@ export const messages = { 'models.externalCatalog.saved': 'External model catalog proxy saved', 'models.externalCatalog.saveFailed': 'Failed to save external model catalog proxy', 'models.externalCatalog.loadFailed': 'Failed to load external model catalog proxy', + 'models.pricingSource.label': 'Price source', + 'models.pricingSource.choose': 'Choose source', + 'models.pricingSource.currentTitle': 'Current source: {provider}. Choosing another source syncs its prices immediately.', + 'models.pricingSource.chooseTitle': 'Choose a source and sync its online prices immediately', + 'models.pricingSource.editCurrentTitle': 'Current source: {provider}. Click to choose another source or sync again.', + 'models.pricingSource.editChooseTitle': 'Choose and sync an online price source', + 'models.pricingSource.syncingTitle': 'Syncing online prices', + 'models.pricingSource.buttonCurrent': 'Price source · {provider}', + 'models.pricingSource.loading': 'Loading sources...', + 'models.pricingSource.loadingOptions': 'Loading online price sources...', + 'models.pricingSource.catalogEmpty': 'No matching source on models.dev', + 'models.pricingSource.resyncTitle': 'Sync prices again from the current source', + 'models.pricingSource.pendingDatabase': 'Pending database save', + 'models.pricingSource.incompatible': 'Incompatible pricing', + 'models.pricingSource.noTokenPrice': 'No token prices', + 'models.pricingSource.inputPrice': 'In ${price}', + 'models.pricingSource.outputPrice': 'Out ${price}', + 'models.pricingSource.sourceGone': 'The selected source no longer provides this model. Refresh the online catalog and try again.', + 'models.pricingSource.cannotSync': 'Unable to sync', + 'models.pricingSource.unsupported': 'This source includes {fields}, which the current billing engine cannot price independently.', + 'models.pricingSource.noUsablePrice': 'This source has no usable token prices.', + 'models.pricingSource.selectedAndSynced': 'Selected {provider} and synced the model prices', + 'models.pricingSource.updateFailed': 'Failed to update the model price source', + 'models.pricingSource.syncFailed': 'Sync failed', + 'models.pricingSource.savedNoPriceChange': 'Saved {provider} as the price source; prices were already current', + 'models.pricingField.reasoning': 'reasoning tokens', + 'models.pricingField.inputAudio': 'input audio tokens', + 'models.pricingField.outputAudio': 'output audio tokens', + 'models.management.selectedCount': '{count} selected', + 'models.management.manageSelectedTitle': 'Manage the {count} selected models', + 'models.management.batchButtonSelected': 'Batch actions ({count})', + 'models.management.batchButton': 'Batch manage', + 'models.management.selectCurrentPage': 'Select models on this page', + 'models.management.selectModel': 'Select model {name}', + 'models.management.batch.title': 'Filters and batch actions', + 'models.management.batch.description': 'Prices sync from each model’s source saved in the database by default, or you can choose one source for all selected models.', + 'models.management.batch.providerPlaceholder': 'Choose an online price source', + 'models.management.batch.rememberedProvider': 'Saved choice for each model', + 'models.management.batch.selectionEmpty': 'Select models to run batch actions', + 'models.management.batch.selectionSummary': '{selected} selected · {syncable} to sync · {sourcePending} sources to save · {unchanged} current · {unsupported} incompatible · {unavailable} without online prices', + 'models.management.batch.state.syncable': 'Price update available', + 'models.management.batch.state.sourcePending': 'Source not saved', + 'models.management.batch.state.unchanged': 'Price current', + 'models.management.batch.state.unsupported': 'Incompatible pricing', + 'models.management.batch.state.unavailable': 'No online price', + 'models.management.batch.source.noneSelected': 'No source selected', + 'models.management.batch.source.noneRecorded': 'No source saved', + 'models.management.batch.shortcut.syncable': 'The current price differs from the selected source', + 'models.management.batch.shortcut.sourcePending': 'The price matches, but the source has not been saved to the database', + 'models.management.batch.shortcut.unchanged': 'The current price matches the selected source', + 'models.management.batch.shortcut.unsupported': 'The online source includes price dimensions the billing engine cannot price independently', + 'models.management.batch.shortcut.unavailable': 'The selected source has no online price for this model', + 'models.management.batch.syncButton': 'Sync prices and sources ({count})', + 'models.management.batch.syncing': 'Syncing...', + 'models.management.batch.confirmTitle': 'Sync prices and sources', + 'models.management.batch.confirmMain': 'Sync online prices for {count} models from {provider} and save each price source to the database.', + 'models.management.batch.confirmSourceOnly': '{count} models already have matching prices, so only their sources will be saved.', + 'models.management.batch.confirmSkipped': '{count} models will be skipped because their prices and saved sources already match, their pricing is incompatible, or no online price is available.', + 'models.management.batch.confirmUnchanged': 'Model names, capabilities, and other settings will not change.', + 'models.management.batch.confirmButton': 'Sync and save', + 'models.management.batch.rememberedProviderLabel': 'each model’s saved provider', + 'models.management.batch.selectedProviderLabel': 'the selected provider', + 'models.management.batch.success': 'Synced prices and sources for {count} models', + 'models.management.batch.partialFailure': '{count} models failed to sync: {details}', + 'models.management.batch.catalogMissingPrice': 'The online catalog did not provide a price configuration', 'nav.routing': 'Routing', 'nav.pool': 'Pool', 'nav.standaloneKeys': 'Standalone keys', diff --git a/frontend/src/views/admin/ModelManagement.vue b/frontend/src/views/admin/ModelManagement.vue index 04ebc058f..e8b09d9cc 100644 --- a/frontend/src/views/admin/ModelManagement.vue +++ b/frontend/src/views/admin/ModelManagement.vue @@ -8,9 +8,18 @@
-

- 模型管理 -

+
+

+ 模型管理 +

+ + {{ t('models.management.selectedCount', { count: selectedBatchManageModelIds.size }) }} + +
@@ -28,13 +37,18 @@ +
+ +
+
+ {{ model.display_name }} +
+
+ {{ model.name }} + +
@@ -157,6 +196,24 @@
+ + + {{ model.active_provider_count || 0 }}/{{ model.provider_count || 0 }} @@ -224,28 +281,39 @@ v-for="model in paginatedGlobalModels" :key="model.id" class="p-4 space-y-3 hover:bg-muted/50 cursor-pointer transition-colors" + :class="selectedBatchManageModelIds.has(model.id) ? 'bg-primary/5' : ''" @click="selectModel(model)" >
-
-
- {{ model.display_name }} - - {{ model.is_active ? '活跃' : '停用' }} - -
-
- {{ model.name }} - +
+ +
+
+ {{ model.display_name }} + + {{ model.is_active ? '活跃' : '停用' }} + +
+
+ {{ model.name }} + +
+ +
+ {{ t('models.pricingSource.label') }} + +
@@ -472,8 +560,8 @@ - + - 上次选择(按模型) + {{ t('models.management.batch.rememberedProvider') }}