diff --git a/Makefile b/Makefile index e205902e2f..e83f001937 100644 --- a/Makefile +++ b/Makefile @@ -21,7 +21,8 @@ FRONTEND_CRITICAL_VITEST := \ src/views/admin/__tests__/SettingsView.spec.ts \ src/features/channel-monitor-v2/__tests__/designSystem.structure.spec.ts \ src/features/channel-monitor-v2/__tests__/monitorFormat.spec.ts \ - src/features/channel-monitor-v2/__tests__/monitorZoom.spec.ts + src/features/channel-monitor-v2/__tests__/monitorZoom.spec.ts \ + src/components/admin/channel/__tests__/PricingEntryCard.modelDefaultPrice.spec.ts # 一键编译前后端 build: build-backend build-frontend diff --git a/frontend/src/components/admin/channel/PricingEntryCard.vue b/frontend/src/components/admin/channel/PricingEntryCard.vue index f2448d6d08..4c59e20a29 100644 --- a/frontend/src/components/admin/channel/PricingEntryCard.vue +++ b/frontend/src/components/admin/channel/PricingEntryCard.vue @@ -411,9 +411,11 @@ async function onModelsUpdate(newModels: string[]) { const oldModels = props.entry.models emit('update', { ...props.entry, models: newModels }) - // 只在新增模型且当前无价格时自动填充 + // 仅在「空条目首次加入单个模型」时自动填充默认价: + // 条目已有模型时(即使价格留空、按各模型的官方默认价回退)追加模型不应改写整条价格, + // 一次加入多个模型也不应把第一个模型的价格套用到整条,否则条目内所有模型都会被静默改价。 const addedModels = newModels.filter(m => !oldModels.includes(m)) - if (addedModels.length === 0) return + if (oldModels.length !== 0 || addedModels.length !== 1) return // 检查是否所有价格字段都为空 const e = props.entry diff --git a/frontend/src/components/admin/channel/__tests__/PricingEntryCard.modelDefaultPrice.spec.ts b/frontend/src/components/admin/channel/__tests__/PricingEntryCard.modelDefaultPrice.spec.ts new file mode 100644 index 0000000000..3eaab643a7 --- /dev/null +++ b/frontend/src/components/admin/channel/__tests__/PricingEntryCard.modelDefaultPrice.spec.ts @@ -0,0 +1,108 @@ +import { flushPromises, shallowMount } from '@vue/test-utils' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import PricingEntryCard from '../PricingEntryCard.vue' +import type { PricingFormEntry } from '../types' +import channelsAPI from '@/api/admin/channels' + +vi.mock('@/api/admin/channels', () => ({ + default: { getModelDefaultPricing: vi.fn() }, +})) + +vi.mock('vue-i18n', async importOriginal => ({ + ...await importOriginal(), + useI18n: () => ({ t: (key: string) => key }), +})) + +const getModelDefaultPricing = vi.mocked(channelsAPI.getModelDefaultPricing) + +function createEntry(models: string[] = []): PricingFormEntry { + return { + models, + billing_mode: 'token', + input_price: null, + output_price: null, + cache_write_price: null, + cache_read_price: null, + fast_multiplier: null, + flex_multiplier: null, + reasoning_effort_multipliers: null, + image_input_price: null, + image_output_price: null, + per_request_price: null, + intervals: [], + time_pricing: { + timezone: 'Asia/Shanghai', + weekdays_only: false, + periods: [], + }, + } +} + +function addModels(entry: PricingFormEntry, models: string[]) { + const wrapper = shallowMount(PricingEntryCard, { props: { entry } }) + wrapper.findComponent({ name: 'ModelTagInput' }).vm.$emit('update:models', models) + return wrapper +} + +const emittedEntries = (wrapper: ReturnType) => + (wrapper.emitted('update') ?? []).map(e => e[0] as PricingFormEntry) + +describe('PricingEntryCard default price auto-fill', () => { + beforeEach(() => { + vi.clearAllMocks() + getModelDefaultPricing.mockResolvedValue({ found: true, input_price: 3e-6, output_price: 15e-6 }) + }) + + it('auto-fills the default price when a model is added to an empty entry', async () => { + const wrapper = addModels(createEntry(), ['claude-opus-4-8']) + await flushPromises() + + expect(getModelDefaultPricing).toHaveBeenCalledTimes(1) + expect(getModelDefaultPricing).toHaveBeenCalledWith('claude-opus-4-8') + expect(emittedEntries(wrapper).at(-1)).toMatchObject({ + models: ['claude-opus-4-8'], + input_price: 3, + output_price: 15, + }) + }) + + it('does not overwrite prices when appending a model to an entry that already has one', async () => { + const entry = createEntry(['claude-opus-4-7']) + const wrapper = addModels(entry, ['claude-opus-4-7', 'claude-opus-4-8']) + await flushPromises() + + expect(getModelDefaultPricing).not.toHaveBeenCalled() + expect(emittedEntries(wrapper).at(-1)).toMatchObject({ + models: ['claude-opus-4-7', 'claude-opus-4-8'], + }) + for (const emitted of emittedEntries(wrapper)) { + expect(emitted.input_price).toBeNull() + expect(emitted.output_price).toBeNull() + expect(emitted.cache_write_price).toBeNull() + expect(emitted.cache_read_price).toBeNull() + } + }) + + it('does not auto-fill a shared price when several models are added to an empty entry at once', async () => { + const wrapper = addModels(createEntry(), ['claude-opus-4-7', 'claude-opus-4-8']) + await flushPromises() + + expect(getModelDefaultPricing).not.toHaveBeenCalled() + expect(emittedEntries(wrapper).at(-1)).toMatchObject({ + models: ['claude-opus-4-7', 'claude-opus-4-8'], + input_price: null, + output_price: null, + }) + }) + + it('keeps manual prices untouched when a model is appended', async () => { + const entry = { ...createEntry(['claude-opus-4-7']), input_price: '1.5' } + const wrapper = addModels(entry, ['claude-opus-4-7', 'claude-opus-4-8']) + await flushPromises() + + expect(getModelDefaultPricing).not.toHaveBeenCalled() + for (const emitted of emittedEntries(wrapper)) { + expect(emitted.input_price).toBe('1.5') + } + }) +})