From 7b03216363be51fac2eb84d4d49a4425e70099fb Mon Sep 17 00:00:00 2001 From: tbphp Date: Wed, 29 Jul 2026 02:17:16 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=20=E6=8F=90=E4=BE=9B=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E4=BB=B7=E6=A0=BC=E5=93=8D=E5=BA=94=E5=BC=8F=E9=9B=86?= =?UTF-8?q?=E5=90=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../model-prices/ModelPriceCollection.test.ts | 58 +++ .../model-prices/ModelPriceCollection.vue | 385 ++++++++++++++++++ .../features/model-prices/ModelPricesView.vue | 253 +----------- .../model-price-presenter.test.ts | 55 +++ .../model-prices/model-price-presenter.ts | 87 ++++ web/src/i18n/locales/en-US/model-prices.ts | 3 + web/src/i18n/locales/ja-JP/model-prices.ts | 3 + web/src/i18n/locales/zh-CN/model-prices.ts | 3 + 8 files changed, 613 insertions(+), 234 deletions(-) create mode 100644 web/src/features/model-prices/ModelPriceCollection.test.ts create mode 100644 web/src/features/model-prices/ModelPriceCollection.vue create mode 100644 web/src/features/model-prices/model-price-presenter.test.ts create mode 100644 web/src/features/model-prices/model-price-presenter.ts diff --git a/web/src/features/model-prices/ModelPriceCollection.test.ts b/web/src/features/model-prices/ModelPriceCollection.test.ts new file mode 100644 index 00000000..03a0410b --- /dev/null +++ b/web/src/features/model-prices/ModelPriceCollection.test.ts @@ -0,0 +1,58 @@ +import { QueryClient } from '@tanstack/vue-query' + +import type { ModelPriceRuleDto } from '@/api/control/model-prices' +import { mountApp } from '@/test/mount-app' + +import ModelPriceCollection from './ModelPriceCollection.vue' + +const rule: ModelPriceRuleDto = { + pattern: `vendor-${'segment-'.repeat(14)}*`, + source: 'builtin', + prices: { + uncached_input: null, + cache_read: 0, + cache_write_5m: 1.25, + cache_write_1h: null, + output: 8, + }, + source_url: null, + updated_at: '2026-07-29T00:00:00Z', + pricing_policy: null, +} + +describe('ModelPriceCollection', () => { + it('renders mobile identity and action outside native price details with a plain missing source', async () => { + vi.stubGlobal('matchMedia', () => ({ + matches: true, + media: '(max-width: 767px)', + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })) + const { wrapper } = await mountApp(ModelPriceCollection, { + api: { request: vi.fn() }, + queryClient: new QueryClient(), + locale: 'en-US', + mounting: { + props: { + rules: [rule], + source: 'builtin', + }, + }, + }) + + const card = wrapper.get('[data-test="builtin-price-card-0"]') + expect(card.get('h3').text()).toBe(rule.pattern) + expect(card.get('[data-test="builtin-price-edit-0"]').text()).toContain('Create override') + expect(card.get('details').attributes()).not.toHaveProperty('open') + expect(card.get('[data-test="builtin-source-0"]').element.tagName).toBe('SPAN') + expect(card.find('a[data-test="builtin-source-0"]').exists()).toBe(false) + expect(card.get('time').attributes('datetime')).toBe('2026-07-29T00:00:00Z') + + wrapper.unmount() + vi.unstubAllGlobals() + }) +}) diff --git a/web/src/features/model-prices/ModelPriceCollection.vue b/web/src/features/model-prices/ModelPriceCollection.vue new file mode 100644 index 00000000..9c2ed603 --- /dev/null +++ b/web/src/features/model-prices/ModelPriceCollection.vue @@ -0,0 +1,385 @@ + + + + + diff --git a/web/src/features/model-prices/ModelPricesView.vue b/web/src/features/model-prices/ModelPricesView.vue index 50ec8b6a..1bc89dcc 100644 --- a/web/src/features/model-prices/ModelPricesView.vue +++ b/web/src/features/model-prices/ModelPricesView.vue @@ -1,5 +1,5 @@ @@ -331,9 +167,7 @@ function pricingPolicySummary(rule: ModelPriceRuleDto): string { min-width: 0; gap: var(--space-5); } -.model-prices__back, -.model-prices__source, -.model-prices__row-actions { +.model-prices__back { display: inline-flex; align-items: center; } @@ -344,8 +178,7 @@ function pricingPolicySummary(rule: ModelPriceRuleDto): string { color: var(--color-text-muted); font-weight: 650; } -.model-prices__back:hover, -.model-prices__source:hover { +.model-prices__back:hover { color: var(--color-primary); } .model-prices__notice { @@ -377,54 +210,6 @@ function pricingPolicySummary(rule: ModelPriceRuleDto): string { margin: 0; font-size: 1.125rem; } -.model-prices code { - color: var(--color-code); - font-family: ui-monospace, SFMono-Regular, Consolas, monospace; - white-space: nowrap; -} -.model-prices td:not(:first-child, :last-child) { - font-variant-numeric: tabular-nums; -} -.model-prices__source { - min-height: 44px; - gap: var(--space-1); - color: var(--color-primary); - font-weight: 650; - white-space: nowrap; -} -.model-prices__source-label { - display: block; - margin-top: var(--space-1); - color: var(--color-text-muted); - font-size: 0.75rem; -} -.model-prices__pricing-policy { - display: grid; - max-width: 22rem; - gap: var(--space-1); - margin-top: var(--space-2); - color: var(--color-text-muted); - font-size: 0.75rem; - line-height: 1.5; - white-space: normal; -} -.model-prices__pricing-policy > :first-child { - width: fit-content; -} -.model-prices__global-warning { - display: flex; - align-items: center; - gap: var(--space-1); - margin-top: var(--space-1); - color: var(--color-warning); - font-size: 0.75rem; - font-weight: 650; - white-space: normal; -} -.model-prices__row-actions { - gap: var(--space-2); - white-space: nowrap; -} @media (max-width: 640px) { .model-prices__section-heading { align-items: stretch; diff --git a/web/src/features/model-prices/model-price-presenter.test.ts b/web/src/features/model-prices/model-price-presenter.test.ts new file mode 100644 index 00000000..95569a10 --- /dev/null +++ b/web/src/features/model-prices/model-price-presenter.test.ts @@ -0,0 +1,55 @@ +import type { ModelPriceRuleDto } from '@/api/control/model-prices' + +import { presentModelPriceRule } from './model-price-presenter' + +const rule: ModelPriceRuleDto = { + pattern: 'gpt-*', + source: 'builtin', + prices: { + uncached_input: null, + cache_read: 0, + cache_write_5m: 1.25, + cache_write_1h: null, + output: 8, + }, + source_url: null, + updated_at: '2026-07-29T00:00:00Z', + pricing_policy: { + input_threshold_tokens: 272000, + input_multiplier: 2, + output_multiplier: 1.5, + }, +} + +describe('Model Price presenter', () => { + it('keeps null, explicit zero, and configured prices distinct', () => { + const presentation = presentModelPriceRule(rule, { + fieldLabels: { + uncached_input: 'Uncached input', + cache_read: 'Cache read', + cache_write_5m: '5-minute cache write', + cache_write_1h: '1-hour cache write', + output: 'Output', + }, + notConfigured: 'Not configured', + explicitlyFree: '$0 · Explicitly free', + configuredPrice: (value) => `$${value} / 1M`, + kindLabel: () => 'Prefix rule', + sourceLabel: () => 'Built-in', + policySummary: (policy) => + `More than ${policy.input_threshold_tokens}: ×${policy.input_multiplier}/×${policy.output_multiplier}`, + }) + + expect(presentation.priceRows.map(({ state, value }) => ({ state, value }))).toEqual([ + { state: 'not-configured', value: 'Not configured' }, + { state: 'free', value: '$0 · Explicitly free' }, + { state: 'configured', value: '$1.25 / 1M' }, + { state: 'not-configured', value: 'Not configured' }, + { state: 'configured', value: '$8 / 1M' }, + ]) + expect(presentation.kind).toBe('Prefix rule') + expect(presentation.source).toBe('Built-in') + expect(presentation.sourceUrl).toBeUndefined() + expect(presentation.policySummary).toContain('272000') + }) +}) diff --git a/web/src/features/model-prices/model-price-presenter.ts b/web/src/features/model-prices/model-price-presenter.ts new file mode 100644 index 00000000..a2b8c149 --- /dev/null +++ b/web/src/features/model-prices/model-price-presenter.ts @@ -0,0 +1,87 @@ +import type { + ModelPricePolicyDto, + ModelPriceRuleDto, + ModelPriceSource, + ModelPriceValues, +} from '@/api/control/model-prices' + +export type ModelPriceField = keyof ModelPriceValues +export type ModelPriceValueState = 'not-configured' | 'free' | 'configured' + +export interface ModelPriceRowPresentation { + field: ModelPriceField + label: string + value: string + state: ModelPriceValueState +} + +export interface ModelPricePresentation { + pattern: string + source: string + kind: string + priceRows: readonly ModelPriceRowPresentation[] + updatedAt: string + sourceUrl?: string + policySummary?: string + globalOverride: boolean +} + +export interface ModelPricePresenterOptions { + fieldLabels: Record + notConfigured: string + explicitlyFree: string + configuredPrice(value: number): string + kindLabel(pattern: string): string + sourceLabel(source: ModelPriceSource): string + policySummary(policy: ModelPricePolicyDto): string +} + +const priceFields: readonly ModelPriceField[] = [ + 'uncached_input', + 'cache_read', + 'cache_write_5m', + 'cache_write_1h', + 'output', +] + +export function presentModelPriceRule( + rule: ModelPriceRuleDto, + options: ModelPricePresenterOptions, +): ModelPricePresentation { + const priceRows = priceFields.map((field): ModelPriceRowPresentation => { + const price = rule.prices[field] + if (price === null) { + return { + field, + label: options.fieldLabels[field], + value: options.notConfigured, + state: 'not-configured', + } + } + if (price === 0) { + return { + field, + label: options.fieldLabels[field], + value: options.explicitlyFree, + state: 'free', + } + } + return { + field, + label: options.fieldLabels[field], + value: options.configuredPrice(price), + state: 'configured', + } + }) + + return { + pattern: rule.pattern, + source: options.sourceLabel(rule.source), + kind: options.kindLabel(rule.pattern), + priceRows, + updatedAt: rule.updated_at, + ...(rule.source_url ? { sourceUrl: rule.source_url } : {}), + ...(rule.pricing_policy ? { policySummary: options.policySummary(rule.pricing_policy) } : {}), + globalOverride: rule.source === 'user' && rule.pattern === '*', + } +} diff --git a/web/src/i18n/locales/en-US/model-prices.ts b/web/src/i18n/locales/en-US/model-prices.ts index 5c1dcc5d..c45198bc 100644 --- a/web/src/i18n/locales/en-US/model-prices.ts +++ b/web/src/i18n/locales/en-US/model-prices.ts @@ -16,6 +16,9 @@ export default { precedenceNote: 'User overrides take precedence over every built-in exact and prefix rule.', wholeRuleNote: 'A user override replaces the whole five-slot rule. Unset slots do not fall back to built-in values.', + scrollHint: 'Scroll horizontally to inspect every model price column.', + details: 'Price fields and metadata', + sourceUnavailable: 'Source unavailable', notConfigured: 'Not configured', explicitlyFree: '$0 · Explicitly free', configuredPrice: '${price} / 1M', diff --git a/web/src/i18n/locales/ja-JP/model-prices.ts b/web/src/i18n/locales/ja-JP/model-prices.ts index 9e856ae3..5a789c03 100644 --- a/web/src/i18n/locales/ja-JP/model-prices.ts +++ b/web/src/i18n/locales/ja-JP/model-prices.ts @@ -16,6 +16,9 @@ export default { 'ユーザー上書きは、すべての組み込み完全一致ルールとプレフィックスルールより優先されます。', wholeRuleNote: 'ユーザー上書きは 5 枠のルール全体を置き換え、未設定の枠は組み込み値へフォールバックしません。', + scrollHint: '横方向にスクロールすると、モデル価格のすべての列を確認できます。', + details: '価格フィールドとメタデータ', + sourceUnavailable: 'ソースを利用できません', notConfigured: '未設定', explicitlyFree: '$0 · 明示的に無料', configuredPrice: '${price} / 1M', diff --git a/web/src/i18n/locales/zh-CN/model-prices.ts b/web/src/i18n/locales/zh-CN/model-prices.ts index bd557760..e6d43ee1 100644 --- a/web/src/i18n/locales/zh-CN/model-prices.ts +++ b/web/src/i18n/locales/zh-CN/model-prices.ts @@ -12,6 +12,9 @@ export default { modelIdentityNote: '规则匹配路由后的上游模型 ID;相同模型 ID 在所有 Group 间共用一个全局价格。', precedenceNote: '所有用户覆盖都优先于内置精确规则和前缀规则。', wholeRuleNote: '用户覆盖会替换完整的五槽位规则;未配置槽位不会回退到内置值。', + scrollHint: '可横向滚动查看模型价格的全部列。', + details: '价格字段与元数据', + sourceUnavailable: '来源不可用', notConfigured: '未配置', explicitlyFree: '$0 · 显式免费', configuredPrice: '${price} / 1M',