From 440784914210ab4e628699752b6a3d3554bc6412 Mon Sep 17 00:00:00 2001 From: duckducktw Date: Thu, 1 Oct 2026 23:59:58 +0800 Subject: [PATCH] =?UTF-8?q?fix(channels):=20=E8=BF=BD=E5=8A=A0=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E6=97=B6=E4=B8=8D=E5=86=8D=E6=94=B9=E5=86=99=E5=B7=B2?= =?UTF-8?q?=E7=95=99=E7=A9=BA=E7=9A=84=E4=BB=B7=E6=A0=BC=E6=9D=A1=E7=9B=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 渠道定价条目价格留空时按各模型官方默认价回退计费,但向这类条目追加 模型时,前端会用「第一个新增模型」的默认价回填整条价格字段,保存后 条目内所有模型被静默统一成同一个价格,造成少计费或多计费。 自动填充现在只作用于「空条目首次加入单个模型」:条目已有模型时追加、 或一次加入多个模型时都不再改写价格,留空条目继续按各模型默认价回退。 - 新增 PricingEntryCard.modelDefaultPrice.spec.ts 覆盖 4 种场景 - 把该 spec 加入 Makefile 的 FRONTEND_CRITICAL_VITEST,使 CI 覆盖该回归 Closes #7796 --- Makefile | 3 +- .../admin/channel/PricingEntryCard.vue | 6 +- ...PricingEntryCard.modelDefaultPrice.spec.ts | 108 ++++++++++++++++++ 3 files changed, 114 insertions(+), 3 deletions(-) create mode 100644 frontend/src/components/admin/channel/__tests__/PricingEntryCard.modelDefaultPrice.spec.ts 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') + } + }) +})