前端模型定价配置 UI 增强(Editor/GeneratorModal/PreviewPanel/PricingGrid), 后端新增 representativeMatrixPrice 取矩阵定价表中位数作为展示价格, 替代原来的 model_price=0 导致的价格展示为空问题。 Co-Authored-By: Claude <noreply@anthropic.com>master
| @@ -3,12 +3,14 @@ package controller | |||||
| import ( | import ( | ||||
| "fmt" | "fmt" | ||||
| "math" | "math" | ||||
| "sort" | |||||
| "strings" | "strings" | ||||
| "github.com/QuantumNous/new-api/common" | "github.com/QuantumNous/new-api/common" | ||||
| "github.com/QuantumNous/new-api/model" | "github.com/QuantumNous/new-api/model" | ||||
| "github.com/QuantumNous/new-api/service" | "github.com/QuantumNous/new-api/service" | ||||
| "github.com/QuantumNous/new-api/setting/ratio_setting" | "github.com/QuantumNous/new-api/setting/ratio_setting" | ||||
| "github.com/QuantumNous/new-api/types" | |||||
| "github.com/gin-gonic/gin" | "github.com/gin-gonic/gin" | ||||
| ) | ) | ||||
| @@ -139,8 +141,9 @@ func GetUserPricing(c *gin.Context) { | |||||
| if pricingData.QuotaType == model.QuotaTypeByTokens { | if pricingData.QuotaType == model.QuotaTypeByTokens { | ||||
| if pricingData.PricingConfig != nil { | if pricingData.PricingConfig != nil { | ||||
| result["original_price"] = pricingData.ModelPrice | |||||
| result["user_price"] = pricingData.ModelPrice * totalRatio | |||||
| originalPrice := representativeMatrixPrice(pricingData.PricingConfig, pricingData.ModelPrice) | |||||
| result["original_price"] = originalPrice | |||||
| result["user_price"] = originalPrice * totalRatio | |||||
| } | } | ||||
| originalInput := pricingData.ModelRatio * 2 | originalInput := pricingData.ModelRatio * 2 | ||||
| originalOutput := pricingData.ModelRatio * pricingData.CompletionRatio * 2 | originalOutput := pricingData.ModelRatio * pricingData.CompletionRatio * 2 | ||||
| @@ -171,7 +174,7 @@ func respondOriginalPrice(c *gin.Context, pricingData *model.Pricing) { | |||||
| if pricingData.QuotaType == model.QuotaTypeByTokens { | if pricingData.QuotaType == model.QuotaTypeByTokens { | ||||
| if pricingData.PricingConfig != nil { | if pricingData.PricingConfig != nil { | ||||
| result["pricing_config"] = pricingData.PricingConfig | result["pricing_config"] = pricingData.PricingConfig | ||||
| result["original_price"] = pricingData.ModelPrice | |||||
| result["original_price"] = representativeMatrixPrice(pricingData.PricingConfig, pricingData.ModelPrice) | |||||
| } | } | ||||
| result["original_input"] = pricingData.ModelRatio * 2 | result["original_input"] = pricingData.ModelRatio * 2 | ||||
| result["original_output"] = pricingData.ModelRatio * pricingData.CompletionRatio * 2 | result["original_output"] = pricingData.ModelRatio * pricingData.CompletionRatio * 2 | ||||
| @@ -184,6 +187,45 @@ func respondOriginalPrice(c *gin.Context, pricingData *model.Pricing) { | |||||
| c.JSON(200, result) | c.JSON(200, result) | ||||
| } | } | ||||
| func representativeMatrixPrice(cfg *types.PricingConfig, fallback float64) float64 { | |||||
| if cfg == nil || len(cfg.Table) == 0 { | |||||
| return fallback | |||||
| } | |||||
| prices := make([]float64, 0, len(cfg.Table)) | |||||
| for _, row := range cfg.Table { | |||||
| price, ok := pricingRowFloat(row["price"]) | |||||
| if ok { | |||||
| prices = append(prices, price) | |||||
| } | |||||
| } | |||||
| if len(prices) == 0 { | |||||
| return fallback | |||||
| } | |||||
| sort.Float64s(prices) | |||||
| mid := len(prices) / 2 | |||||
| if len(prices)%2 == 1 { | |||||
| return prices[mid] | |||||
| } | |||||
| return (prices[mid-1] + prices[mid]) / 2 | |||||
| } | |||||
| func pricingRowFloat(value any) (float64, bool) { | |||||
| switch n := value.(type) { | |||||
| case float64: | |||||
| return n, true | |||||
| case float32: | |||||
| return float64(n), true | |||||
| case int: | |||||
| return float64(n), true | |||||
| case int64: | |||||
| return float64(n), true | |||||
| case int32: | |||||
| return float64(n), true | |||||
| default: | |||||
| return 0, false | |||||
| } | |||||
| } | |||||
| // formatDiscount 将倍率转换为中文折扣格式 | // formatDiscount 将倍率转换为中文折扣格式 | ||||
| func formatDiscount(ratio float64) string { | func formatDiscount(ratio float64) string { | ||||
| if ratio <= 0 { | if ratio <= 0 { | ||||
| @@ -3,6 +3,7 @@ package helper | |||||
| import ( | import ( | ||||
| "fmt" | "fmt" | ||||
| "net/http" | "net/http" | ||||
| "strings" | |||||
| "github.com/QuantumNous/new-api/common" | "github.com/QuantumNous/new-api/common" | ||||
| "github.com/QuantumNous/new-api/dto" | "github.com/QuantumNous/new-api/dto" | ||||
| @@ -97,7 +98,7 @@ func buildRemixMatrixDecision(origin *types.OriginPricingSnapshot, group types.G | |||||
| decision.OtherRatios = types.CloneRatios(origin.OtherRatios) | decision.OtherRatios = types.CloneRatios(origin.OtherRatios) | ||||
| decision.PerCallBilling = origin.PerCallBilling | decision.PerCallBilling = origin.PerCallBilling | ||||
| if billingUnit == types.BillingUnitPer1MTokens { | if billingUnit == types.BillingUnitPer1MTokens { | ||||
| decision.PriceUSD = 0 | |||||
| decision.PriceUSD = origin.TokenUnitPriceUSD | |||||
| decision.Quota = minimumUsagePreconsumeQuota(origin.TokenUnitPriceUSD, group.GroupRatio) | decision.Quota = minimumUsagePreconsumeQuota(origin.TokenUnitPriceUSD, group.GroupRatio) | ||||
| } | } | ||||
| return decision, nil | return decision, nil | ||||
| @@ -139,6 +140,7 @@ func buildMatrixDecision(cfg *types.PricingConfig, result *pricingLookupResult, | |||||
| if billingUnit == types.BillingUnitPer1MTokens { | if billingUnit == types.BillingUnitPer1MTokens { | ||||
| decision.TokenUnitPriceUSD = result.PriceUSD | decision.TokenUnitPriceUSD = result.PriceUSD | ||||
| decision.PriceUSD = result.PriceUSD | |||||
| decision.Quota = minimumUsagePreconsumeQuota(result.PriceUSD, group.GroupRatio) | decision.Quota = minimumUsagePreconsumeQuota(result.PriceUSD, group.GroupRatio) | ||||
| decision.PerCallBilling = false | decision.PerCallBilling = false | ||||
| return decision | return decision | ||||
| @@ -199,7 +201,7 @@ func rowMatchesDimensions(dimensions []types.PricingDimension, row types.Pricing | |||||
| wildcards++ | wildcards++ | ||||
| continue | continue | ||||
| } | } | ||||
| if !samePricingLiteral(rv, values[dim.Key]) { | |||||
| if !samePricingLiteral(rv, values[dim.Key], dim.Type) { | |||||
| return 0, false | return 0, false | ||||
| } | } | ||||
| } | } | ||||
| @@ -266,12 +268,15 @@ func pricingNumberValue(v any) (float64, bool) { | |||||
| } | } | ||||
| } | } | ||||
| func samePricingLiteral(a, b any) bool { | |||||
| func samePricingLiteral(a, b any, dimensionType string) bool { | |||||
| af, aok := pricingNumberValue(a) | af, aok := pricingNumberValue(a) | ||||
| bf, bok := pricingNumberValue(b) | bf, bok := pricingNumberValue(b) | ||||
| if aok && bok { | if aok && bok { | ||||
| return af == bf | return af == bf | ||||
| } | } | ||||
| if dimensionType == "string" { | |||||
| return strings.EqualFold(strings.TrimSpace(fmt.Sprintf("%v", a)), strings.TrimSpace(fmt.Sprintf("%v", b))) | |||||
| } | |||||
| return fmt.Sprintf("%v", a) == fmt.Sprintf("%v", b) | return fmt.Sprintf("%v", a) == fmt.Sprintf("%v", b) | ||||
| } | } | ||||
| @@ -20,6 +20,17 @@ func TestPricingLookup_MatchSpecificity(t *testing.T) { | |||||
| require.Equal(t, 10.0, result.Snapshot["duration"]) | require.Equal(t, 10.0, result.Snapshot["duration"]) | ||||
| } | } | ||||
| func TestPricingLookup_StringDimensionMatchesCaseInsensitive(t *testing.T) { | |||||
| cfg := testPricingLookupConfig(types.PricingFallbackReject) | |||||
| resolved := &ResolvedDimensionValues{Values: map[string]any{"resolution": "720p", "duration": 10.0}} | |||||
| result, taskErr := lookupPricingConfig(cfg, resolved) | |||||
| require.Nil(t, taskErr) | |||||
| require.Equal(t, types.PricingModeMatched, result.Mode) | |||||
| require.Equal(t, 0.08, result.PriceUSD) | |||||
| require.Equal(t, "720p", result.Snapshot["resolution"]) | |||||
| } | |||||
| func TestPricingLookup_WildcardFallbackMatch(t *testing.T) { | func TestPricingLookup_WildcardFallbackMatch(t *testing.T) { | ||||
| cfg := testPricingLookupConfig(types.PricingFallbackReject) | cfg := testPricingLookupConfig(types.PricingFallbackReject) | ||||
| resolved := &ResolvedDimensionValues{Values: map[string]any{"resolution": "480P", "duration": 10.0}} | resolved := &ResolvedDimensionValues{Values: map[string]any{"resolution": "480P", "duration": 10.0}} | ||||
| @@ -94,11 +105,28 @@ func TestPricingLookup_UsageDecisionMinimumPreconsume(t *testing.T) { | |||||
| result := &pricingLookupResult{Mode: types.PricingModeMatched, PriceUSD: 2.0, Snapshot: map[string]any{"price": 2.0}} | result := &pricingLookupResult{Mode: types.PricingModeMatched, PriceUSD: 2.0, Snapshot: map[string]any{"price": 2.0}} | ||||
| decision := buildMatchedDecision(cfg, result, types.GroupRatioInfo{GroupRatio: 1}) | decision := buildMatchedDecision(cfg, result, types.GroupRatioInfo{GroupRatio: 1}) | ||||
| require.Equal(t, 2.0, decision.PriceUSD) | |||||
| require.Equal(t, 2.0, decision.TokenUnitPriceUSD) | require.Equal(t, 2.0, decision.TokenUnitPriceUSD) | ||||
| require.Equal(t, minimumUsagePreconsumeQuota(2.0, 1), decision.Quota) | require.Equal(t, minimumUsagePreconsumeQuota(2.0, 1), decision.Quota) | ||||
| require.False(t, decision.PerCallBilling) | require.False(t, decision.PerCallBilling) | ||||
| } | } | ||||
| func TestPricingLookup_RemixUsageDecisionKeepsDisplayPrice(t *testing.T) { | |||||
| decision, taskErr := buildRemixMatrixDecision(&types.OriginPricingSnapshot{ | |||||
| PriceUSD: 0, | |||||
| TokenUnitPriceUSD: 46, | |||||
| BillingUnit: types.BillingUnitPer1MTokens, | |||||
| Snapshot: map[string]any{"price": 46}, | |||||
| PerCallBilling: false, | |||||
| }, types.GroupRatioInfo{GroupRatio: 1}) | |||||
| require.Nil(t, taskErr) | |||||
| require.Equal(t, 46.0, decision.PriceUSD) | |||||
| require.Equal(t, 46.0, decision.TokenUnitPriceUSD) | |||||
| require.Equal(t, minimumUsagePreconsumeQuota(46, 1), decision.Quota) | |||||
| require.False(t, decision.PerCallBilling) | |||||
| } | |||||
| func testPricingLookupConfig(fallback string) *types.PricingConfig { | func testPricingLookupConfig(fallback string) *types.PricingConfig { | ||||
| return &types.PricingConfig{ | return &types.PricingConfig{ | ||||
| SchemaVersion: types.PricingSchemaVersion, | SchemaVersion: types.PricingSchemaVersion, | ||||
| @@ -94,7 +94,7 @@ const ModelPricingTable = ({ | |||||
| }); | }); | ||||
| }); | }); | ||||
| columns.push({ | columns.push({ | ||||
| title: t('价格'), | |||||
| title: t('价格({{unit}})', { unit }), | |||||
| dataIndex: 'price', | dataIndex: 'price', | ||||
| render: (price) => ( | render: (price) => ( | ||||
| <> | <> | ||||
| @@ -171,7 +171,9 @@ const ModelPricingTable = ({ | |||||
| ? priceData.cachePrice | ? priceData.cachePrice | ||||
| : '-', | : '-', | ||||
| cacheCreationPrice: | cacheCreationPrice: | ||||
| modelData?.quota_type === 0 && priceData.cacheRatio !== 0 && priceData.cacheCreationRatio !== 0 | |||||
| modelData?.quota_type === 0 && | |||||
| priceData.cacheRatio !== 0 && | |||||
| priceData.cacheCreationRatio !== 0 | |||||
| ? priceData.cacheCreationPrice | ? priceData.cacheCreationPrice | ||||
| : '-', | : '-', | ||||
| fixedPrice: modelData?.quota_type === 1 ? priceData.price : '-', | fixedPrice: modelData?.quota_type === 1 ? priceData.price : '-', | ||||
| @@ -225,7 +227,7 @@ const ModelPricingTable = ({ | |||||
| const hasCachePricing = tableData.some((row) => row.cachePrice !== '-'); | const hasCachePricing = tableData.some((row) => row.cachePrice !== '-'); | ||||
| // 检查是否有缓存创建价格数据(有些模型只有缓存读取没有缓存创建) | // 检查是否有缓存创建价格数据(有些模型只有缓存读取没有缓存创建) | ||||
| const hasCacheCreationPricing = tableData.some( | const hasCacheCreationPricing = tableData.some( | ||||
| (row) => row.cacheCreationPrice !== '-' | |||||
| (row) => row.cacheCreationPrice !== '-', | |||||
| ); | ); | ||||
| // 根据计费类型添加价格列 | // 根据计费类型添加价格列 | ||||
| @@ -39,6 +39,7 @@ import { | |||||
| stringToColor, | stringToColor, | ||||
| getLobeHubIcon, | getLobeHubIcon, | ||||
| hasMatrixPricing, | hasMatrixPricing, | ||||
| calculateModelPrice, | |||||
| } from '../../../../../helpers'; | } from '../../../../../helpers'; | ||||
| import PricingCardSkeleton from './PricingCardSkeleton'; | import PricingCardSkeleton from './PricingCardSkeleton'; | ||||
| import { useMinimumLoadingTime } from '../../../../../hooks/common/useMinimumLoadingTime'; | import { useMinimumLoadingTime } from '../../../../../hooks/common/useMinimumLoadingTime'; | ||||
| @@ -72,6 +73,11 @@ const PricingCardView = ({ | |||||
| gridColsClass = 'grid-cols-1 xl:grid-cols-2 2xl:grid-cols-3', | gridColsClass = 'grid-cols-1 xl:grid-cols-2 2xl:grid-cols-3', | ||||
| showPagination = true, | showPagination = true, | ||||
| showTryButton = false, | showTryButton = false, | ||||
| selectedGroup, | |||||
| groupRatio = {}, | |||||
| currency, | |||||
| tokenUnit, | |||||
| displayPrice, | |||||
| }) => { | }) => { | ||||
| const showSkeleton = useMinimumLoadingTime(loading); | const showSkeleton = useMinimumLoadingTime(loading); | ||||
| const startIndex = (currentPage - 1) * pageSize; | const startIndex = (currentPage - 1) * pageSize; | ||||
| @@ -148,6 +154,48 @@ const PricingCardView = ({ | |||||
| return record.description || ''; | return record.description || ''; | ||||
| }; | }; | ||||
| const renderCardPrice = (model) => { | |||||
| if (!displayPrice) return null; | |||||
| const priceData = calculateModelPrice({ | |||||
| record: model, | |||||
| selectedGroup, | |||||
| groupRatio, | |||||
| tokenUnit, | |||||
| displayPrice, | |||||
| currency, | |||||
| }); | |||||
| if (!priceData?.isMatrix) { | |||||
| return null; | |||||
| } | |||||
| return ( | |||||
| <div className='mb-3 text-xs'> | |||||
| <div className='font-semibold text-gray-900'> | |||||
| {priceData.price}{' '} | |||||
| <span className='font-normal text-gray-500'>/ {priceData.unit}</span> | |||||
| </div> | |||||
| {(priceData.details || []).map((detail, detailIndex) => ( | |||||
| <div | |||||
| key={`${detail.dimensionText}-${detailIndex}`} | |||||
| className='mt-1 flex items-center justify-between gap-2 text-gray-500' | |||||
| title={`${detail.dimensionText} ${detail.formattedPrice}`} | |||||
| > | |||||
| <span className='min-w-0 truncate'>{detail.dimensionText}</span> | |||||
| <span className='shrink-0 text-gray-700'> | |||||
| {detail.formattedPrice} | |||||
| </span> | |||||
| </div> | |||||
| ))} | |||||
| {priceData.hiddenDetailCount > 0 && ( | |||||
| <div className='mt-1 text-gray-400'> | |||||
| + {priceData.hiddenDetailCount} | |||||
| </div> | |||||
| )} | |||||
| </div> | |||||
| ); | |||||
| }; | |||||
| // 渲染标签(卡片底部) | // 渲染标签(卡片底部) | ||||
| // 左侧: 计费类型标签(quota_type: 0=按量计费, 1=按次计费) | // 左侧: 计费类型标签(quota_type: 0=按量计费, 1=按次计费) | ||||
| // 右侧: 自定义标签(来自 Model.tags,逗号分隔,最多显示3个,颜色由 stringToColor 哈希生成) | // 右侧: 自定义标签(来自 Model.tags,逗号分隔,最多显示3个,颜色由 stringToColor 哈希生成) | ||||
| @@ -300,6 +348,8 @@ const PricingCardView = ({ | |||||
| </p> | </p> | ||||
| </div> | </div> | ||||
| {renderCardPrice(model)} | |||||
| {/* 底部区域 */} | {/* 底部区域 */} | ||||
| <div className='mt-auto'> | <div className='mt-auto'> | ||||
| {/* 标签区域 */} | {/* 标签区域 */} | ||||
| @@ -314,7 +364,10 @@ const PricingCardView = ({ | |||||
| style={{ marginTop: 8, width: '100%' }} | style={{ marginTop: 8, width: '100%' }} | ||||
| onClick={(e) => { | onClick={(e) => { | ||||
| e.stopPropagation(); | e.stopPropagation(); | ||||
| navigate('/console/playground?model=' + encodeURIComponent(model.model_name)); | |||||
| navigate( | |||||
| '/console/playground?model=' + | |||||
| encodeURIComponent(model.model_name), | |||||
| ); | |||||
| }} | }} | ||||
| > | > | ||||
| {t('去体验')} → | {t('去体验')} → | ||||
| @@ -198,6 +198,11 @@ const HomePricingFilters = ({ t }) => { | |||||
| gridColsClass='grid-cols-1 md:grid-cols-2 lg:grid-cols-3' | gridColsClass='grid-cols-1 md:grid-cols-2 lg:grid-cols-3' | ||||
| showPagination={true} | showPagination={true} | ||||
| showTryButton={true} | showTryButton={true} | ||||
| selectedGroup={pricingData.selectedGroup} | |||||
| groupRatio={pricingData.groupRatio} | |||||
| currency={pricingData.currency} | |||||
| tokenUnit={pricingData.tokenUnit} | |||||
| displayPrice={pricingData.displayPrice} | |||||
| /> | /> | ||||
| </div> | </div> | ||||
| @@ -1,5 +1,15 @@ | |||||
| import React, { useEffect, useMemo, useState } from 'react'; | import React, { useEffect, useMemo, useState } from 'react'; | ||||
| import { Button, Input, Select, SideSheet, Space, TabPane, Tabs, TextArea, Typography } from '@douyinfe/semi-ui'; | |||||
| import { | |||||
| Button, | |||||
| Input, | |||||
| Select, | |||||
| SideSheet, | |||||
| Space, | |||||
| TabPane, | |||||
| Tabs, | |||||
| TextArea, | |||||
| Typography, | |||||
| } from '@douyinfe/semi-ui'; | |||||
| import { Redo2, Save, Undo2, Wand2 } from 'lucide-react'; | import { Redo2, Save, Undo2, Wand2 } from 'lucide-react'; | ||||
| import { useTranslation } from 'react-i18next'; | import { useTranslation } from 'react-i18next'; | ||||
| import { API, showError, showSuccess } from '../../../../helpers'; | import { API, showError, showSuccess } from '../../../../helpers'; | ||||
| @@ -7,9 +17,20 @@ import DimensionEditor from './DimensionEditor'; | |||||
| import GeneratorModal from './GeneratorModal'; | import GeneratorModal from './GeneratorModal'; | ||||
| import PreviewPanel from './PreviewPanel'; | import PreviewPanel from './PreviewPanel'; | ||||
| import PricingGrid from './PricingGrid'; | import PricingGrid from './PricingGrid'; | ||||
| import { BILLING_UNITS, FALLBACKS, createDefaultConfig, validatePricingConfig } from './pricingConfig'; | |||||
| import { | |||||
| BILLING_UNITS, | |||||
| FALLBACKS, | |||||
| createDefaultConfig, | |||||
| validatePricingConfig, | |||||
| } from './pricingConfig'; | |||||
| export default function Editor({ visible, modelName, config, onClose, onSaved }) { | |||||
| export default function Editor({ | |||||
| visible, | |||||
| modelName, | |||||
| config, | |||||
| onClose, | |||||
| onSaved, | |||||
| }) { | |||||
| const { t } = useTranslation(); | const { t } = useTranslation(); | ||||
| const [name, setName] = useState(modelName || ''); | const [name, setName] = useState(modelName || ''); | ||||
| const [draft, setDraft] = useState(createDefaultConfig()); | const [draft, setDraft] = useState(createDefaultConfig()); | ||||
| @@ -76,7 +97,10 @@ export default function Editor({ visible, modelName, config, onClose, onSaved }) | |||||
| } | } | ||||
| setSaving(true); | setSaving(true); | ||||
| try { | try { | ||||
| const res = await API.put(`/api/option/model_pricing/${encodeURIComponent(name.trim())}`, draft); | |||||
| const res = await API.put( | |||||
| `/api/option/model_pricing/${encodeURIComponent(name.trim())}`, | |||||
| draft, | |||||
| ); | |||||
| if (!res.data?.success) { | if (!res.data?.success) { | ||||
| showError(res.data?.message || t('保存模型多维计费失败')); | showError(res.data?.message || t('保存模型多维计费失败')); | ||||
| return; | return; | ||||
| @@ -95,7 +119,8 @@ export default function Editor({ visible, modelName, config, onClose, onSaved }) | |||||
| updateDraft({ | updateDraft({ | ||||
| ...draft, | ...draft, | ||||
| billing_unit: value, | billing_unit: value, | ||||
| preconsume_strategy: value === BILLING_UNITS.per1MTokens ? 'minimum' : 'exact', | |||||
| preconsume_strategy: | |||||
| value === BILLING_UNITS.per1MTokens ? 'minimum' : 'exact', | |||||
| }); | }); | ||||
| }; | }; | ||||
| @@ -108,10 +133,24 @@ export default function Editor({ visible, modelName, config, onClose, onSaved }) | |||||
| onCancel={onClose} | onCancel={onClose} | ||||
| footer={ | footer={ | ||||
| <Space> | <Space> | ||||
| <Button icon={<Undo2 size={16} />} onClick={undo} disabled={history.length === 0} /> | |||||
| <Button icon={<Redo2 size={16} />} onClick={redo} disabled={future.length === 0} /> | |||||
| <Button | |||||
| icon={<Undo2 size={16} />} | |||||
| onClick={undo} | |||||
| disabled={history.length === 0} | |||||
| /> | |||||
| <Button | |||||
| icon={<Redo2 size={16} />} | |||||
| onClick={redo} | |||||
| disabled={future.length === 0} | |||||
| /> | |||||
| <Button onClick={onClose}>{t('取消')}</Button> | <Button onClick={onClose}>{t('取消')}</Button> | ||||
| <Button type='primary' icon={<Save size={16} />} data-testid='model-pricing-save' onClick={save} loading={saving}> | |||||
| <Button | |||||
| type='primary' | |||||
| icon={<Save size={16} />} | |||||
| data-testid='model-pricing-save' | |||||
| onClick={save} | |||||
| loading={saving} | |||||
| > | |||||
| {t('保存')} | {t('保存')} | ||||
| </Button> | </Button> | ||||
| </Space> | </Space> | ||||
| @@ -119,21 +158,47 @@ export default function Editor({ visible, modelName, config, onClose, onSaved }) | |||||
| > | > | ||||
| <Space vertical align='start' style={{ width: '100%' }}> | <Space vertical align='start' style={{ width: '100%' }}> | ||||
| <Space wrap> | <Space wrap> | ||||
| <Input prefix={t('模型')} value={name} onChange={setName} style={{ width: 260 }} disabled={Boolean(modelName)} data-testid='model-pricing-model-name' /> | |||||
| <Select value={draft.billing_unit} onChange={setBillingUnit} style={{ width: 180 }}> | |||||
| <Input | |||||
| prefix={t('模型')} | |||||
| value={name} | |||||
| onChange={setName} | |||||
| style={{ width: 260 }} | |||||
| disabled={Boolean(modelName)} | |||||
| data-testid='model-pricing-model-name' | |||||
| /> | |||||
| <Select | |||||
| value={draft.billing_unit} | |||||
| onChange={setBillingUnit} | |||||
| style={{ width: 180 }} | |||||
| > | |||||
| <Select.Option value='per_call'>{t('每次调用')}</Select.Option> | <Select.Option value='per_call'>{t('每次调用')}</Select.Option> | ||||
| <Select.Option value='per_1m_tokens'>{t('每百万 tokens')}</Select.Option> | |||||
| <Select.Option value='per_1m_tokens'> | |||||
| {t('每百万 tokens')} | |||||
| </Select.Option> | |||||
| </Select> | </Select> | ||||
| <Select | <Select | ||||
| value={draft.fallback?.strategy} | value={draft.fallback?.strategy} | ||||
| onChange={(value) => updateDraft({ ...draft, fallback: { ...draft.fallback, strategy: value } })} | |||||
| onChange={(value) => | |||||
| updateDraft({ | |||||
| ...draft, | |||||
| fallback: { ...draft.fallback, strategy: value }, | |||||
| }) | |||||
| } | |||||
| style={{ width: 160 }} | style={{ width: 160 }} | ||||
| > | > | ||||
| <Select.Option value={FALLBACKS.reject}>{t('拒绝')}</Select.Option> | <Select.Option value={FALLBACKS.reject}>{t('拒绝')}</Select.Option> | ||||
| <Select.Option value={FALLBACKS.max}>{t('使用最高价')}</Select.Option> | |||||
| <Select.Option value={FALLBACKS.default}>{t('使用默认价')}</Select.Option> | |||||
| <Select.Option value={FALLBACKS.max}> | |||||
| {t('使用最高价')} | |||||
| </Select.Option> | |||||
| <Select.Option value={FALLBACKS.default}> | |||||
| {t('使用默认价')} | |||||
| </Select.Option> | |||||
| </Select> | </Select> | ||||
| <Button icon={<Wand2 size={16} />} data-testid='model-pricing-open-generator' onClick={() => setGeneratorVisible(true)}> | |||||
| <Button | |||||
| icon={<Wand2 size={16} />} | |||||
| data-testid='model-pricing-open-generator' | |||||
| onClick={() => setGeneratorVisible(true)} | |||||
| > | |||||
| {t('生成价格行')} | {t('生成价格行')} | ||||
| </Button> | </Button> | ||||
| </Space> | </Space> | ||||
| @@ -144,16 +209,29 @@ export default function Editor({ visible, modelName, config, onClose, onSaved }) | |||||
| )} | )} | ||||
| <Tabs type='line' style={{ width: '100%' }}> | <Tabs type='line' style={{ width: '100%' }}> | ||||
| <TabPane tab={t('维度')} itemKey='dimensions'> | <TabPane tab={t('维度')} itemKey='dimensions'> | ||||
| <DimensionEditor dimensions={draft.dimensions || []} onChange={(dimensions) => updateDraft({ ...draft, dimensions })} /> | |||||
| <DimensionEditor | |||||
| dimensions={draft.dimensions || []} | |||||
| onChange={(dimensions) => updateDraft({ ...draft, dimensions })} | |||||
| /> | |||||
| </TabPane> | </TabPane> | ||||
| <TabPane tab={t('价格表')} itemKey='table'> | <TabPane tab={t('价格表')} itemKey='table'> | ||||
| <PricingGrid dimensions={draft.dimensions || []} rows={draft.table || []} onChange={(table) => updateDraft({ ...draft, table })} /> | |||||
| <PricingGrid | |||||
| dimensions={draft.dimensions || []} | |||||
| rows={draft.table || []} | |||||
| billingUnit={draft.billing_unit} | |||||
| onChange={(table) => updateDraft({ ...draft, table })} | |||||
| /> | |||||
| </TabPane> | </TabPane> | ||||
| <TabPane tab={t('预览')} itemKey='preview'> | <TabPane tab={t('预览')} itemKey='preview'> | ||||
| <PreviewPanel config={draft} /> | <PreviewPanel config={draft} /> | ||||
| </TabPane> | </TabPane> | ||||
| <TabPane tab={t('JSON')} itemKey='json'> | <TabPane tab={t('JSON')} itemKey='json'> | ||||
| <TextArea value={jsonText} onChange={setJsonText} autosize={{ minRows: 18, maxRows: 28 }} data-testid='model-pricing-json-editor' /> | |||||
| <TextArea | |||||
| value={jsonText} | |||||
| onChange={setJsonText} | |||||
| autosize={{ minRows: 18, maxRows: 28 }} | |||||
| data-testid='model-pricing-json-editor' | |||||
| /> | |||||
| <Button style={{ marginTop: 12 }} onClick={applyJson}> | <Button style={{ marginTop: 12 }} onClick={applyJson}> | ||||
| {t('应用 JSON')} | {t('应用 JSON')} | ||||
| </Button> | </Button> | ||||
| @@ -1,15 +1,26 @@ | |||||
| import React, { useEffect, useMemo, useState } from 'react'; | import React, { useEffect, useMemo, useState } from 'react'; | ||||
| import { Button, Input, InputNumber, Modal, Radio, Select, Space, Table, Typography } from '@douyinfe/semi-ui'; | |||||
| import { | |||||
| Button, | |||||
| Input, | |||||
| InputNumber, | |||||
| Modal, | |||||
| Radio, | |||||
| Select, | |||||
| Space, | |||||
| Table, | |||||
| Typography, | |||||
| } from '@douyinfe/semi-ui'; | |||||
| import { Plus, Trash2 } from 'lucide-react'; | import { Plus, Trash2 } from 'lucide-react'; | ||||
| import { useTranslation } from 'react-i18next'; | import { useTranslation } from 'react-i18next'; | ||||
| import { getCurrencyConfig } from '../../../../helpers'; | import { getCurrencyConfig } from '../../../../helpers'; | ||||
| import { | import { | ||||
| BILLING_UNITS, | |||||
| countRatioEntries, | countRatioEntries, | ||||
| displayCurrencyToUSD, | displayCurrencyToUSD, | ||||
| estimateGeneratedRowCount, | estimateGeneratedRowCount, | ||||
| formatDisplayCurrency, | formatDisplayCurrency, | ||||
| generateRowsFromRatios, | generateRowsFromRatios, | ||||
| getBillingUnitLabel, | |||||
| getPriceUnitLabel, | |||||
| mergeGeneratedRows, | mergeGeneratedRows, | ||||
| parseDimensionValue, | parseDimensionValue, | ||||
| } from './pricingConfig'; | } from './pricingConfig'; | ||||
| @@ -19,25 +30,48 @@ const DEFAULT_RATIOS = { | |||||
| duration: { 5: 1, 10: 1.5 }, | duration: { 5: 1, 10: 1.5 }, | ||||
| }; | }; | ||||
| export default function GeneratorModal({ visible, dimensions, billingUnit, existingRows, onCancel, onGenerate }) { | |||||
| export default function GeneratorModal({ | |||||
| visible, | |||||
| dimensions, | |||||
| billingUnit, | |||||
| existingRows, | |||||
| onCancel, | |||||
| onGenerate, | |||||
| }) { | |||||
| const { t } = useTranslation(); | const { t } = useTranslation(); | ||||
| const [basePrice, setBasePrice] = useState(0.1); | const [basePrice, setBasePrice] = useState(0.1); | ||||
| const [ratioEntries, setRatioEntries] = useState([]); | const [ratioEntries, setRatioEntries] = useState([]); | ||||
| const [strategy, setStrategy] = useState('preserve_manual'); | const [strategy, setStrategy] = useState('preserve_manual'); | ||||
| const currencyConfig = useMemo(() => getCurrencyConfig(), [visible]); | const currencyConfig = useMemo(() => getCurrencyConfig(), [visible]); | ||||
| const basePriceUSD = useMemo(() => displayCurrencyToUSD(basePrice, currencyConfig), [basePrice, currencyConfig]); | |||||
| const unitLabel = billingUnit === BILLING_UNITS.per1MTokens ? t('每 1M tokens') : t('每次调用'); | |||||
| const basePriceUSD = useMemo( | |||||
| () => displayCurrencyToUSD(basePrice, currencyConfig), | |||||
| [basePrice, currencyConfig], | |||||
| ); | |||||
| const billingUnitLabel = getBillingUnitLabel(billingUnit, t); | |||||
| const displayUnitLabel = getPriceUnitLabel( | |||||
| billingUnit, | |||||
| currencyConfig.symbol, | |||||
| t, | |||||
| ); | |||||
| const savedUnitLabel = getPriceUnitLabel(billingUnit, 'USD', t); | |||||
| useEffect(() => { | useEffect(() => { | ||||
| if (!visible) return; | if (!visible) return; | ||||
| setRatioEntries(createDefaultRatioEntries(dimensions)); | setRatioEntries(createDefaultRatioEntries(dimensions)); | ||||
| }, [visible, dimensions]); | }, [visible, dimensions]); | ||||
| const ratios = useMemo(() => entriesToRatios(ratioEntries, dimensions), [ratioEntries, dimensions]); | |||||
| const ratios = useMemo( | |||||
| () => entriesToRatios(ratioEntries, dimensions), | |||||
| [ratioEntries, dimensions], | |||||
| ); | |||||
| const preview = useMemo(() => { | const preview = useMemo(() => { | ||||
| const rows = generateRowsFromRatios({ dimensions, basePrice: basePriceUSD, ratios }); | |||||
| const rows = generateRowsFromRatios({ | |||||
| dimensions, | |||||
| basePrice: basePriceUSD, | |||||
| ratios, | |||||
| }); | |||||
| return { | return { | ||||
| rows, | rows, | ||||
| linearCount: countRatioEntries(ratios), | linearCount: countRatioEntries(ratios), | ||||
| @@ -46,7 +80,11 @@ export default function GeneratorModal({ visible, dimensions, billingUnit, exist | |||||
| }, [dimensions, basePriceUSD, ratios]); | }, [dimensions, basePriceUSD, ratios]); | ||||
| const apply = () => { | const apply = () => { | ||||
| const table = mergeGeneratedRows({ existingRows, generatedRows: preview.rows, strategy }); | |||||
| const table = mergeGeneratedRows({ | |||||
| existingRows, | |||||
| generatedRows: preview.rows, | |||||
| strategy, | |||||
| }); | |||||
| onGenerate(table); | onGenerate(table); | ||||
| }; | }; | ||||
| @@ -58,10 +96,14 @@ export default function GeneratorModal({ visible, dimensions, billingUnit, exist | |||||
| const addEntry = () => { | const addEntry = () => { | ||||
| const firstDimension = dimensions?.[0]?.key || ''; | const firstDimension = dimensions?.[0]?.key || ''; | ||||
| setRatioEntries([...ratioEntries, { dimensionKey: firstDimension, value: '', ratio: 1 }]); | |||||
| setRatioEntries([ | |||||
| ...ratioEntries, | |||||
| { dimensionKey: firstDimension, value: '', ratio: 1 }, | |||||
| ]); | |||||
| }; | }; | ||||
| const removeEntry = (index) => setRatioEntries(ratioEntries.filter((_, itemIndex) => itemIndex !== index)); | |||||
| const removeEntry = (index) => | |||||
| setRatioEntries(ratioEntries.filter((_, itemIndex) => itemIndex !== index)); | |||||
| const ratioColumns = [ | const ratioColumns = [ | ||||
| { | { | ||||
| @@ -69,7 +111,13 @@ export default function GeneratorModal({ visible, dimensions, billingUnit, exist | |||||
| dataIndex: 'dimensionKey', | dataIndex: 'dimensionKey', | ||||
| width: 160, | width: 160, | ||||
| render: (value, _, index) => ( | render: (value, _, index) => ( | ||||
| <Select value={value} onChange={(dimensionKey) => updateEntry(index, { dimensionKey, value: '' })} style={{ width: 140 }}> | |||||
| <Select | |||||
| value={value} | |||||
| onChange={(dimensionKey) => | |||||
| updateEntry(index, { dimensionKey, value: '' }) | |||||
| } | |||||
| style={{ width: 140 }} | |||||
| > | |||||
| {(dimensions || []).map((dim) => ( | {(dimensions || []).map((dim) => ( | ||||
| <Select.Option key={dim.key} value={dim.key}> | <Select.Option key={dim.key} value={dim.key}> | ||||
| {dim.key} | {dim.key} | ||||
| @@ -81,35 +129,59 @@ export default function GeneratorModal({ visible, dimensions, billingUnit, exist | |||||
| { | { | ||||
| title: t('候选值'), | title: t('候选值'), | ||||
| dataIndex: 'value', | dataIndex: 'value', | ||||
| render: (value, _, index) => <Input value={value} onChange={(nextValue) => updateEntry(index, { value: nextValue })} data-testid={`generator-ratio-value-${index}`} />, | |||||
| render: (value, _, index) => ( | |||||
| <Input | |||||
| value={value} | |||||
| onChange={(nextValue) => updateEntry(index, { value: nextValue })} | |||||
| data-testid={`generator-ratio-value-${index}`} | |||||
| /> | |||||
| ), | |||||
| }, | }, | ||||
| { | { | ||||
| title: t('倍率'), | title: t('倍率'), | ||||
| dataIndex: 'ratio', | dataIndex: 'ratio', | ||||
| width: 150, | width: 150, | ||||
| render: (value, _, index) => ( | render: (value, _, index) => ( | ||||
| <InputNumber value={Number(value || 0)} min={0} step={0.000001} onChange={(ratio) => updateEntry(index, { ratio: Number(ratio || 0) })} data-testid={`generator-ratio-ratio-${index}`} /> | |||||
| <InputNumber | |||||
| value={Number(value || 0)} | |||||
| min={0} | |||||
| step={0.000001} | |||||
| onChange={(ratio) => | |||||
| updateEntry(index, { ratio: Number(ratio || 0) }) | |||||
| } | |||||
| data-testid={`generator-ratio-ratio-${index}`} | |||||
| /> | |||||
| ), | ), | ||||
| }, | }, | ||||
| { | { | ||||
| title: t('操作'), | title: t('操作'), | ||||
| width: 80, | width: 80, | ||||
| render: (_, __, index) => <Button icon={<Trash2 size={16} />} type='danger' theme='borderless' onClick={() => removeEntry(index)} />, | |||||
| render: (_, __, index) => ( | |||||
| <Button | |||||
| icon={<Trash2 size={16} />} | |||||
| type='danger' | |||||
| theme='borderless' | |||||
| onClick={() => removeEntry(index)} | |||||
| /> | |||||
| ), | |||||
| }, | }, | ||||
| ]; | ]; | ||||
| const previewColumns = [ | const previewColumns = [ | ||||
| ...(dimensions || []).map((dim) => ({ title: dim.key, dataIndex: dim.key })), | |||||
| ...(dimensions || []).map((dim) => ({ | |||||
| title: dim.key, | |||||
| dataIndex: dim.key, | |||||
| })), | |||||
| { | { | ||||
| title: t('展示价格'), | |||||
| title: t('展示价格({{unit}})', { unit: displayUnitLabel }), | |||||
| dataIndex: 'display_price', | dataIndex: 'display_price', | ||||
| width: 140, | |||||
| width: 170, | |||||
| render: (_, row) => formatDisplayCurrency(row.price, currencyConfig, 6), | render: (_, row) => formatDisplayCurrency(row.price, currencyConfig, 6), | ||||
| }, | }, | ||||
| { | { | ||||
| title: t('保存价格 USD'), | |||||
| title: t('保存价格 USD({{unit}})', { unit: savedUnitLabel }), | |||||
| dataIndex: 'price', | dataIndex: 'price', | ||||
| width: 140, | |||||
| width: 190, | |||||
| render: (price) => `$${Number(price || 0).toFixed(6)}`, | render: (price) => `$${Number(price || 0).toFixed(6)}`, | ||||
| }, | }, | ||||
| { title: t('来源'), dataIndex: 'source', width: 120 }, | { title: t('来源'), dataIndex: 'source', width: 120 }, | ||||
| @@ -124,22 +196,38 @@ export default function GeneratorModal({ visible, dimensions, billingUnit, exist | |||||
| footer={ | footer={ | ||||
| <Space> | <Space> | ||||
| <Button onClick={onCancel}>{t('取消')}</Button> | <Button onClick={onCancel}>{t('取消')}</Button> | ||||
| <Button type='primary' data-testid='generator-apply' onClick={apply} disabled={preview.rows.length === 0}> | |||||
| <Button | |||||
| type='primary' | |||||
| data-testid='generator-apply' | |||||
| onClick={apply} | |||||
| disabled={preview.rows.length === 0} | |||||
| > | |||||
| {t('应用生成结果')} | {t('应用生成结果')} | ||||
| </Button> | </Button> | ||||
| </Space> | </Space> | ||||
| } | } | ||||
| > | > | ||||
| <Space vertical align='start' style={{ width: '100%' }} data-testid='model-pricing-generator'> | |||||
| <Space | |||||
| vertical | |||||
| align='start' | |||||
| style={{ width: '100%' }} | |||||
| data-testid='model-pricing-generator' | |||||
| > | |||||
| <Typography.Text> | <Typography.Text> | ||||
| {t('基础价格是所有倍率计算的起点,当前单位为 {{unitLabel}}。最终价格 = 基础价格 × 各维度倍率。', { unitLabel })} | |||||
| {t( | |||||
| '基础价格是所有倍率计算的起点,当前单位为 {{unitLabel}}。最终价格 = 基础价格 × 各维度倍率。', | |||||
| { unitLabel: billingUnitLabel }, | |||||
| )} | |||||
| </Typography.Text> | </Typography.Text> | ||||
| <Typography.Text type='tertiary'> | <Typography.Text type='tertiary'> | ||||
| {t('当前按 {{symbol}} 展示和输入,应用时会按汇率换算为 USD 保存到 table[].price。当前输入 {{symbol}}{{displayPrice}} = ${{usdPrice}}。', { | |||||
| symbol: currencyConfig.symbol, | |||||
| displayPrice: Number(basePrice || 0).toFixed(6), | |||||
| usdPrice: basePriceUSD.toFixed(6), | |||||
| })} | |||||
| {t( | |||||
| '当前按 {{symbol}} 展示和输入,应用时会按汇率换算为 USD 保存到 table[].price。当前输入 {{symbol}}{{displayPrice}} = ${{usdPrice}}。', | |||||
| { | |||||
| symbol: currencyConfig.symbol, | |||||
| displayPrice: Number(basePrice || 0).toFixed(6), | |||||
| usdPrice: basePriceUSD.toFixed(6), | |||||
| }, | |||||
| )} | |||||
| </Typography.Text> | </Typography.Text> | ||||
| <Space wrap> | <Space wrap> | ||||
| <InputNumber | <InputNumber | ||||
| @@ -147,29 +235,56 @@ export default function GeneratorModal({ visible, dimensions, billingUnit, exist | |||||
| min={0} | min={0} | ||||
| step={0.000001} | step={0.000001} | ||||
| onChange={(value) => setBasePrice(Number(value || 0))} | onChange={(value) => setBasePrice(Number(value || 0))} | ||||
| prefix={`${t('基础价格')}(${currencyConfig.symbol}/${unitLabel})`} | |||||
| prefix={t('价格({{unit}})', { unit: displayUnitLabel })} | |||||
| data-testid='generator-base-price' | data-testid='generator-base-price' | ||||
| /> | /> | ||||
| <Radio.Group value={strategy} onChange={(event) => setStrategy(event.target.value)}> | |||||
| <Radio value='preserve_manual' data-testid='generator-strategy-preserve-manual'> | |||||
| <Radio.Group | |||||
| value={strategy} | |||||
| onChange={(event) => setStrategy(event.target.value)} | |||||
| > | |||||
| <Radio | |||||
| value='preserve_manual' | |||||
| data-testid='generator-strategy-preserve-manual' | |||||
| > | |||||
| {t('保留手动行')} | {t('保留手动行')} | ||||
| </Radio> | </Radio> | ||||
| <Radio value='replace_all' data-testid='generator-strategy-replace-all'> | |||||
| <Radio | |||||
| value='replace_all' | |||||
| data-testid='generator-strategy-replace-all' | |||||
| > | |||||
| {t('全部替换')} | {t('全部替换')} | ||||
| </Radio> | </Radio> | ||||
| </Radio.Group> | </Radio.Group> | ||||
| </Space> | </Space> | ||||
| <Table columns={ratioColumns} dataSource={ratioEntries} pagination={false} rowKey={(_, index) => `${index}`} /> | |||||
| <Button icon={<Plus size={16} />} data-testid='generator-add-ratio' onClick={addEntry}> | |||||
| <Table | |||||
| columns={ratioColumns} | |||||
| dataSource={ratioEntries} | |||||
| pagination={false} | |||||
| rowKey={(_, index) => `${index}`} | |||||
| /> | |||||
| <Button | |||||
| icon={<Plus size={16} />} | |||||
| data-testid='generator-add-ratio' | |||||
| onClick={addEntry} | |||||
| > | |||||
| {t('添加倍率项')} | {t('添加倍率项')} | ||||
| </Button> | </Button> | ||||
| <Typography.Text type='tertiary'> | <Typography.Text type='tertiary'> | ||||
| {t('线性倍率项 {{linearCount}} 项,最终展开 {{expandedCount}} 行。任一维度没有候选值时不会生成结果。预览最多展示前 50 行。', { | |||||
| linearCount: preview.linearCount, | |||||
| expandedCount: preview.expandedCount, | |||||
| })} | |||||
| {t( | |||||
| '线性倍率项 {{linearCount}} 项,最终展开 {{expandedCount}} 行。任一维度没有候选值时不会生成结果。预览最多展示前 50 行。', | |||||
| { | |||||
| linearCount: preview.linearCount, | |||||
| expandedCount: preview.expandedCount, | |||||
| }, | |||||
| )} | |||||
| </Typography.Text> | </Typography.Text> | ||||
| <Table columns={previewColumns} dataSource={preview.rows.slice(0, 50)} pagination={false} rowKey={(_, index) => `${index}`} scroll={{ x: 720 }} /> | |||||
| <Table | |||||
| columns={previewColumns} | |||||
| dataSource={preview.rows.slice(0, 50)} | |||||
| pagination={false} | |||||
| rowKey={(_, index) => `${index}`} | |||||
| scroll={{ x: 720 }} | |||||
| /> | |||||
| </Space> | </Space> | ||||
| </Modal> | </Modal> | ||||
| ); | ); | ||||
| @@ -197,7 +312,10 @@ const entriesToRatios = (entries, dimensions) => { | |||||
| const dim = dimByKey.get(entry.dimensionKey); | const dim = dimByKey.get(entry.dimensionKey); | ||||
| if (!dim || entry.value === '') continue; | if (!dim || entry.value === '') continue; | ||||
| const parsedValue = String(parseDimensionValue(entry.value, dim.type)); | const parsedValue = String(parseDimensionValue(entry.value, dim.type)); | ||||
| ratios[dim.key] = { ...(ratios[dim.key] || {}), [parsedValue]: Number(entry.ratio || 0) }; | |||||
| ratios[dim.key] = { | |||||
| ...(ratios[dim.key] || {}), | |||||
| [parsedValue]: Number(entry.ratio || 0), | |||||
| }; | |||||
| } | } | ||||
| return ratios; | return ratios; | ||||
| }; | }; | ||||
| @@ -1,11 +1,26 @@ | |||||
| import React, { useMemo, useState } from 'react'; | import React, { useMemo, useState } from 'react'; | ||||
| import { Descriptions, TextArea, Typography } from '@douyinfe/semi-ui'; | import { Descriptions, TextArea, Typography } from '@douyinfe/semi-ui'; | ||||
| import { useTranslation } from 'react-i18next'; | import { useTranslation } from 'react-i18next'; | ||||
| import { previewLookup } from './pricingConfig'; | |||||
| import { getCurrencyConfig } from '../../../../helpers'; | |||||
| import { | |||||
| formatDisplayCurrency, | |||||
| getBillingUnitLabel, | |||||
| getPriceUnitLabel, | |||||
| previewLookup, | |||||
| } from './pricingConfig'; | |||||
| export default function PreviewPanel({ config }) { | export default function PreviewPanel({ config }) { | ||||
| const { t } = useTranslation(); | const { t } = useTranslation(); | ||||
| const [requestText, setRequestText] = useState('{\n "resolution": "720P"\n}'); | |||||
| const [requestText, setRequestText] = useState( | |||||
| '{\n "resolution": "720P"\n}', | |||||
| ); | |||||
| const currencyConfig = getCurrencyConfig(); | |||||
| const displayUnitLabel = getPriceUnitLabel( | |||||
| config?.billing_unit, | |||||
| currencyConfig.symbol, | |||||
| t, | |||||
| ); | |||||
| const savedBillingUnitLabel = getBillingUnitLabel(config?.billing_unit, t); | |||||
| const result = useMemo(() => { | const result = useMemo(() => { | ||||
| try { | try { | ||||
| @@ -15,15 +30,32 @@ export default function PreviewPanel({ config }) { | |||||
| } | } | ||||
| }, [config, requestText]); | }, [config, requestText]); | ||||
| const displayPrice = | |||||
| result.price === undefined | |||||
| ? '-' | |||||
| : `${formatDisplayCurrency(result.price, currencyConfig, 6)} / ${displayUnitLabel}`; | |||||
| const savedPrice = | |||||
| result.price === undefined | |||||
| ? '-' | |||||
| : `$${Number(result.price || 0).toFixed(6)} / ${savedBillingUnitLabel}`; | |||||
| return ( | return ( | ||||
| <> | <> | ||||
| <TextArea value={requestText} onChange={setRequestText} autosize={{ minRows: 8, maxRows: 14 }} /> | |||||
| <TextArea | |||||
| value={requestText} | |||||
| onChange={setRequestText} | |||||
| autosize={{ minRows: 8, maxRows: 14 }} | |||||
| /> | |||||
| <Descriptions | <Descriptions | ||||
| style={{ marginTop: 16 }} | style={{ marginTop: 16 }} | ||||
| data={[ | data={[ | ||||
| { key: t('命中模式'), value: result.mode }, | { key: t('命中模式'), value: result.mode }, | ||||
| { key: t('最终价格'), value: result.price ?? '-' }, | |||||
| { key: t('快照'), value: result.snapshot ? JSON.stringify(result.snapshot) : '-' }, | |||||
| { key: t('最终价格'), value: displayPrice }, | |||||
| { key: t('保存价格 USD'), value: savedPrice }, | |||||
| { | |||||
| key: t('快照'), | |||||
| value: result.snapshot ? JSON.stringify(result.snapshot) : '-', | |||||
| }, | |||||
| ]} | ]} | ||||
| /> | /> | ||||
| {result.error && ( | {result.error && ( | ||||
| @@ -1,11 +1,32 @@ | |||||
| import React from 'react'; | import React from 'react'; | ||||
| import { Button, Input, InputNumber, Select, Space, Table } from '@douyinfe/semi-ui'; | |||||
| import { | |||||
| Button, | |||||
| Input, | |||||
| InputNumber, | |||||
| Select, | |||||
| Space, | |||||
| Table, | |||||
| } from '@douyinfe/semi-ui'; | |||||
| import { Plus, Trash2 } from 'lucide-react'; | import { Plus, Trash2 } from 'lucide-react'; | ||||
| import { useTranslation } from 'react-i18next'; | import { useTranslation } from 'react-i18next'; | ||||
| import { ROW_SOURCES, parseDimensionValue } from './pricingConfig'; | |||||
| import { getCurrencyConfig } from '../../../../helpers'; | |||||
| import { | |||||
| ROW_SOURCES, | |||||
| displayCurrencyToUSD, | |||||
| formatDisplayCurrencyNumber, | |||||
| getPriceUnitLabel, | |||||
| parseDimensionValue, | |||||
| } from './pricingConfig'; | |||||
| export default function PricingGrid({ dimensions, rows, onChange }) { | |||||
| export default function PricingGrid({ | |||||
| dimensions, | |||||
| rows, | |||||
| billingUnit, | |||||
| onChange, | |||||
| }) { | |||||
| const { t } = useTranslation(); | const { t } = useTranslation(); | ||||
| const currencyConfig = getCurrencyConfig(); | |||||
| const unitLabel = getPriceUnitLabel(billingUnit, currencyConfig.symbol, t); | |||||
| const update = (index, patch) => { | const update = (index, patch) => { | ||||
| const next = [...rows]; | const next = [...rows]; | ||||
| @@ -19,22 +40,43 @@ export default function PricingGrid({ dimensions, rows, onChange }) { | |||||
| onChange([...(rows || []), row]); | onChange([...(rows || []), row]); | ||||
| }; | }; | ||||
| const remove = (index) => onChange(rows.filter((_, itemIndex) => itemIndex !== index)); | |||||
| const remove = (index) => | |||||
| onChange(rows.filter((_, itemIndex) => itemIndex !== index)); | |||||
| const columns = [ | const columns = [ | ||||
| ...dimensions.map((dim) => ({ | ...dimensions.map((dim) => ({ | ||||
| title: dim.key, | title: dim.key, | ||||
| dataIndex: dim.key, | dataIndex: dim.key, | ||||
| render: (value, _, index) => ( | render: (value, _, index) => ( | ||||
| <Input value={value ?? ''} onChange={(text) => update(index, { [dim.key]: text === '*' ? '*' : parseDimensionValue(text, dim.type) })} /> | |||||
| <Input | |||||
| value={value ?? ''} | |||||
| onChange={(text) => | |||||
| update(index, { | |||||
| [dim.key]: | |||||
| text === '*' ? '*' : parseDimensionValue(text, dim.type), | |||||
| }) | |||||
| } | |||||
| /> | |||||
| ), | ), | ||||
| })), | })), | ||||
| { | { | ||||
| title: t('价格'), | |||||
| title: t('价格({{unit}})', { unit: unitLabel }), | |||||
| dataIndex: 'price', | dataIndex: 'price', | ||||
| width: 140, | |||||
| width: 220, | |||||
| render: (value, _, index) => ( | render: (value, _, index) => ( | ||||
| <InputNumber value={Number(value || 0)} min={0} step={0.000001} onChange={(price) => update(index, { price: Number(price || 0), source: ROW_SOURCES.manual })} /> | |||||
| <InputNumber | |||||
| value={formatDisplayCurrencyNumber(value, currencyConfig)} | |||||
| min={0} | |||||
| step={0.000001} | |||||
| suffix={unitLabel} | |||||
| style={{ width: 200 }} | |||||
| onChange={(price) => | |||||
| update(index, { | |||||
| price: displayCurrencyToUSD(price, currencyConfig), | |||||
| source: ROW_SOURCES.manual, | |||||
| }) | |||||
| } | |||||
| /> | |||||
| ), | ), | ||||
| }, | }, | ||||
| { | { | ||||
| @@ -42,22 +84,40 @@ export default function PricingGrid({ dimensions, rows, onChange }) { | |||||
| dataIndex: 'source', | dataIndex: 'source', | ||||
| width: 140, | width: 140, | ||||
| render: (value, _, index) => ( | render: (value, _, index) => ( | ||||
| <Select value={value || ROW_SOURCES.manual} onChange={(source) => update(index, { source })} style={{ width: 120 }}> | |||||
| <Select | |||||
| value={value || ROW_SOURCES.manual} | |||||
| onChange={(source) => update(index, { source })} | |||||
| style={{ width: 120 }} | |||||
| > | |||||
| <Select.Option value={ROW_SOURCES.manual}>{t('手动')}</Select.Option> | <Select.Option value={ROW_SOURCES.manual}>{t('手动')}</Select.Option> | ||||
| <Select.Option value={ROW_SOURCES.generated}>{t('生成')}</Select.Option> | |||||
| <Select.Option value={ROW_SOURCES.generated}> | |||||
| {t('生成')} | |||||
| </Select.Option> | |||||
| </Select> | </Select> | ||||
| ), | ), | ||||
| }, | }, | ||||
| { | { | ||||
| title: t('操作'), | title: t('操作'), | ||||
| width: 80, | width: 80, | ||||
| render: (_, __, index) => <Button icon={<Trash2 size={16} />} type='danger' theme='borderless' onClick={() => remove(index)} />, | |||||
| render: (_, __, index) => ( | |||||
| <Button | |||||
| icon={<Trash2 size={16} />} | |||||
| type='danger' | |||||
| theme='borderless' | |||||
| onClick={() => remove(index)} | |||||
| /> | |||||
| ), | |||||
| }, | }, | ||||
| ]; | ]; | ||||
| return ( | return ( | ||||
| <Space vertical align='start' style={{ width: '100%' }}> | <Space vertical align='start' style={{ width: '100%' }}> | ||||
| <Table columns={columns} dataSource={rows} pagination={{ pageSize: 8 }} rowKey={(_, index) => `${index}`} /> | |||||
| <Table | |||||
| columns={columns} | |||||
| dataSource={rows} | |||||
| pagination={{ pageSize: 8 }} | |||||
| rowKey={(_, index) => `${index}`} | |||||
| /> | |||||
| <Button icon={<Plus size={16} />} onClick={add}> | <Button icon={<Plus size={16} />} onClick={add}> | ||||
| {t('添加价格行')} | {t('添加价格行')} | ||||
| </Button> | </Button> | ||||
| @@ -74,32 +74,51 @@ export const validatePricingConfig = (config) => { | |||||
| const billingUnit = config.billing_unit || BILLING_UNITS.perCall; | const billingUnit = config.billing_unit || BILLING_UNITS.perCall; | ||||
| const preconsume = config.preconsume_strategy || PRECONSUME_STRATEGIES.exact; | const preconsume = config.preconsume_strategy || PRECONSUME_STRATEGIES.exact; | ||||
| if (!Object.values(BILLING_UNITS).includes(billingUnit)) errors.push('billing_unit is invalid'); | |||||
| if (!Object.values(PRECONSUME_STRATEGIES).includes(preconsume)) errors.push('preconsume_strategy is invalid'); | |||||
| if (billingUnit === BILLING_UNITS.perCall && preconsume !== PRECONSUME_STRATEGIES.exact) { | |||||
| if (!Object.values(BILLING_UNITS).includes(billingUnit)) | |||||
| errors.push('billing_unit is invalid'); | |||||
| if (!Object.values(PRECONSUME_STRATEGIES).includes(preconsume)) | |||||
| errors.push('preconsume_strategy is invalid'); | |||||
| if ( | |||||
| billingUnit === BILLING_UNITS.perCall && | |||||
| preconsume !== PRECONSUME_STRATEGIES.exact | |||||
| ) { | |||||
| errors.push('per_call requires exact preconsume_strategy'); | errors.push('per_call requires exact preconsume_strategy'); | ||||
| } | } | ||||
| if (billingUnit === BILLING_UNITS.per1MTokens && preconsume !== PRECONSUME_STRATEGIES.minimum) { | |||||
| if ( | |||||
| billingUnit === BILLING_UNITS.per1MTokens && | |||||
| preconsume !== PRECONSUME_STRATEGIES.minimum | |||||
| ) { | |||||
| errors.push('per_1m_tokens requires minimum preconsume_strategy'); | errors.push('per_1m_tokens requires minimum preconsume_strategy'); | ||||
| } | } | ||||
| if (!Array.isArray(config.dimensions) || config.dimensions.length === 0) errors.push('dimensions must not be empty'); | |||||
| if (!Array.isArray(config.table) || config.table.length === 0) errors.push('table must not be empty'); | |||||
| if (!Array.isArray(config.dimensions) || config.dimensions.length === 0) | |||||
| errors.push('dimensions must not be empty'); | |||||
| if (!Array.isArray(config.table) || config.table.length === 0) | |||||
| errors.push('table must not be empty'); | |||||
| const dimensionKeys = new Set(); | const dimensionKeys = new Set(); | ||||
| for (const dim of config.dimensions || []) { | for (const dim of config.dimensions || []) { | ||||
| if (!/^[a-z][a-z0-9_]{0,31}$/.test(dim.key || '')) errors.push(`dimension key "${dim.key || ''}" is invalid`); | |||||
| if (RESERVED_KEYS.has(dim.key)) errors.push(`dimension key "${dim.key}" is reserved`); | |||||
| if (dimensionKeys.has(dim.key)) errors.push(`dimension key "${dim.key}" is duplicated`); | |||||
| if (!/^[a-z][a-z0-9_]{0,31}$/.test(dim.key || '')) | |||||
| errors.push(`dimension key "${dim.key || ''}" is invalid`); | |||||
| if (RESERVED_KEYS.has(dim.key)) | |||||
| errors.push(`dimension key "${dim.key}" is reserved`); | |||||
| if (dimensionKeys.has(dim.key)) | |||||
| errors.push(`dimension key "${dim.key}" is duplicated`); | |||||
| dimensionKeys.add(dim.key); | dimensionKeys.add(dim.key); | ||||
| if (!['string', 'number', 'boolean'].includes(dim.type)) errors.push(`dimension "${dim.key}" type is invalid`); | |||||
| if (!['string', 'number', 'boolean'].includes(dim.type)) | |||||
| errors.push(`dimension "${dim.key}" type is invalid`); | |||||
| if (dim.optional && (dim.default === null || dim.default === undefined)) { | if (dim.optional && (dim.default === null || dim.default === undefined)) { | ||||
| errors.push(`optional dimension "${dim.key}" requires a default value`); | errors.push(`optional dimension "${dim.key}" requires a default value`); | ||||
| } | } | ||||
| if (!dim.optional && dim.default !== null && dim.default !== undefined) { | if (!dim.optional && dim.default !== null && dim.default !== undefined) { | ||||
| errors.push(`required dimension "${dim.key}" must use null default`); | errors.push(`required dimension "${dim.key}" must use null default`); | ||||
| } | } | ||||
| if (dim.optional && dim.default !== null && dim.default !== undefined && !valueMatchesType(dim.default, dim.type)) { | |||||
| if ( | |||||
| dim.optional && | |||||
| dim.default !== null && | |||||
| dim.default !== undefined && | |||||
| !valueMatchesType(dim.default, dim.type) | |||||
| ) { | |||||
| errors.push(`dimension "${dim.key}" default does not match type`); | errors.push(`dimension "${dim.key}" default does not match type`); | ||||
| } | } | ||||
| } | } | ||||
| @@ -108,7 +127,8 @@ export const validatePricingConfig = (config) => { | |||||
| const rowPatterns = []; | const rowPatterns = []; | ||||
| for (const [index, row] of (config.table || []).entries()) { | for (const [index, row] of (config.table || []).entries()) { | ||||
| for (const key of Object.keys(row || {})) { | for (const key of Object.keys(row || {})) { | ||||
| if (!dimensionKeys.has(key) && key !== 'price' && key !== 'source') errors.push(`row ${index + 1} contains extra field "${key}"`); | |||||
| if (!dimensionKeys.has(key) && key !== 'price' && key !== 'source') | |||||
| errors.push(`row ${index + 1} contains extra field "${key}"`); | |||||
| } | } | ||||
| for (const dim of config.dimensions || []) { | for (const dim of config.dimensions || []) { | ||||
| if (!(dim.key in row)) { | if (!(dim.key in row)) { | ||||
| @@ -116,20 +136,28 @@ export const validatePricingConfig = (config) => { | |||||
| continue; | continue; | ||||
| } | } | ||||
| if (row[dim.key] !== '*' && !valueMatchesType(row[dim.key], dim.type)) { | if (row[dim.key] !== '*' && !valueMatchesType(row[dim.key], dim.type)) { | ||||
| errors.push(`row ${index + 1} value for "${dim.key}" does not match type`); | |||||
| errors.push( | |||||
| `row ${index + 1} value for "${dim.key}" does not match type`, | |||||
| ); | |||||
| } | } | ||||
| } | } | ||||
| const price = Number(row.price); | const price = Number(row.price); | ||||
| if (Number.isNaN(price) || price < 0) errors.push(`row ${index + 1} price must be >= 0`); | |||||
| if (!Object.values(ROW_SOURCES).includes(row.source)) errors.push(`row ${index + 1} source must be manual or generated`); | |||||
| const signature = (config.dimensions || []).map((dim) => String(row[dim.key])).join('\u0001'); | |||||
| if (rowSignatures.has(signature)) errors.push(`row ${index + 1} duplicates another row`); | |||||
| if (Number.isNaN(price) || price < 0) | |||||
| errors.push(`row ${index + 1} price must be >= 0`); | |||||
| if (!Object.values(ROW_SOURCES).includes(row.source)) | |||||
| errors.push(`row ${index + 1} source must be manual or generated`); | |||||
| const signature = (config.dimensions || []) | |||||
| .map((dim) => String(row[dim.key])) | |||||
| .join('\u0001'); | |||||
| if (rowSignatures.has(signature)) | |||||
| errors.push(`row ${index + 1} duplicates another row`); | |||||
| rowSignatures.add(signature); | rowSignatures.add(signature); | ||||
| rowPatterns.push({ | rowPatterns.push({ | ||||
| index, | index, | ||||
| values: (config.dimensions || []).map((dim) => row[dim.key]), | values: (config.dimensions || []).map((dim) => row[dim.key]), | ||||
| wildcards: (config.dimensions || []).filter((dim) => row[dim.key] === '*').length, | |||||
| wildcards: (config.dimensions || []).filter((dim) => row[dim.key] === '*') | |||||
| .length, | |||||
| }); | }); | ||||
| } | } | ||||
| @@ -137,14 +165,21 @@ export const validatePricingConfig = (config) => { | |||||
| for (let j = i + 1; j < rowPatterns.length; j += 1) { | for (let j = i + 1; j < rowPatterns.length; j += 1) { | ||||
| if (rowPatterns[i].wildcards !== rowPatterns[j].wildcards) continue; | if (rowPatterns[i].wildcards !== rowPatterns[j].wildcards) continue; | ||||
| if (patternsOverlap(rowPatterns[i].values, rowPatterns[j].values)) { | if (patternsOverlap(rowPatterns[i].values, rowPatterns[j].values)) { | ||||
| errors.push(`rows ${rowPatterns[i].index + 1} and ${rowPatterns[j].index + 1} are ambiguous`); | |||||
| errors.push( | |||||
| `rows ${rowPatterns[i].index + 1} and ${rowPatterns[j].index + 1} are ambiguous`, | |||||
| ); | |||||
| } | } | ||||
| } | } | ||||
| } | } | ||||
| const strategy = config.fallback?.strategy; | const strategy = config.fallback?.strategy; | ||||
| if (!Object.values(FALLBACKS).includes(strategy)) errors.push('fallback.strategy is invalid'); | |||||
| if (strategy === FALLBACKS.default && (config.fallback.default_price === undefined || Number(config.fallback.default_price) < 0)) { | |||||
| if (!Object.values(FALLBACKS).includes(strategy)) | |||||
| errors.push('fallback.strategy is invalid'); | |||||
| if ( | |||||
| strategy === FALLBACKS.default && | |||||
| (config.fallback.default_price === undefined || | |||||
| Number(config.fallback.default_price) < 0) | |||||
| ) { | |||||
| errors.push('fallback.default_price must be >= 0'); | errors.push('fallback.default_price must be >= 0'); | ||||
| } | } | ||||
| @@ -156,11 +191,22 @@ export const generateRowsFromRatios = ({ dimensions, basePrice, ratios }) => { | |||||
| if (dims.length === 0) return []; | if (dims.length === 0) return []; | ||||
| const build = (index, prefix, price) => { | const build = (index, prefix, price) => { | ||||
| if (index >= dims.length) return [{ ...prefix, price: Number(Number(price).toFixed(6)), source: ROW_SOURCES.generated }]; | |||||
| if (index >= dims.length) | |||||
| return [ | |||||
| { | |||||
| ...prefix, | |||||
| price: Number(Number(price).toFixed(6)), | |||||
| source: ROW_SOURCES.generated, | |||||
| }, | |||||
| ]; | |||||
| const dim = dims[index]; | const dim = dims[index]; | ||||
| const dimRatios = ratios?.[dim.key] || {}; | const dimRatios = ratios?.[dim.key] || {}; | ||||
| return Object.entries(dimRatios).flatMap(([value, ratio]) => | return Object.entries(dimRatios).flatMap(([value, ratio]) => | ||||
| build(index + 1, { ...prefix, [dim.key]: parseDimensionValue(value, dim.type) }, price * Number(ratio || 1)), | |||||
| build( | |||||
| index + 1, | |||||
| { ...prefix, [dim.key]: parseDimensionValue(value, dim.type) }, | |||||
| price * Number(ratio || 1), | |||||
| ), | |||||
| ); | ); | ||||
| }; | }; | ||||
| @@ -169,8 +215,16 @@ export const generateRowsFromRatios = ({ dimensions, basePrice, ratios }) => { | |||||
| export const splitDimensionSource = (source) => { | export const splitDimensionSource = (source) => { | ||||
| const text = String(source || '').trim(); | const text = String(source || '').trim(); | ||||
| if (text.startsWith('request.')) return { sourceType: DIMENSION_SOURCE_TYPES.request, path: text.slice('request.'.length) }; | |||||
| if (text.startsWith('derived.')) return { sourceType: DIMENSION_SOURCE_TYPES.derived, path: text.slice('derived.'.length) }; | |||||
| if (text.startsWith('request.')) | |||||
| return { | |||||
| sourceType: DIMENSION_SOURCE_TYPES.request, | |||||
| path: text.slice('request.'.length), | |||||
| }; | |||||
| if (text.startsWith('derived.')) | |||||
| return { | |||||
| sourceType: DIMENSION_SOURCE_TYPES.derived, | |||||
| path: text.slice('derived.'.length), | |||||
| }; | |||||
| return { sourceType: DIMENSION_SOURCE_TYPES.advanced, path: text }; | return { sourceType: DIMENSION_SOURCE_TYPES.advanced, path: text }; | ||||
| }; | }; | ||||
| @@ -186,7 +240,10 @@ export const composeDimensionSource = (sourceType, path) => { | |||||
| }; | }; | ||||
| export const countRatioEntries = (ratios) => | export const countRatioEntries = (ratios) => | ||||
| Object.values(ratios || {}).reduce((total, values) => total + Object.keys(values || {}).length, 0); | |||||
| Object.values(ratios || {}).reduce( | |||||
| (total, values) => total + Object.keys(values || {}).length, | |||||
| 0, | |||||
| ); | |||||
| export const estimateGeneratedRowCount = (dimensions, ratios) => { | export const estimateGeneratedRowCount = (dimensions, ratios) => { | ||||
| const dims = dimensions || []; | const dims = dimensions || []; | ||||
| @@ -202,24 +259,54 @@ export const displayCurrencyToUSD = (amount, currencyConfig) => { | |||||
| const rate = Number(currencyConfig?.rate || 1); | const rate = Number(currencyConfig?.rate || 1); | ||||
| const value = Number(amount || 0); | const value = Number(amount || 0); | ||||
| if (!Number.isFinite(rate) || rate <= 0) return value; | if (!Number.isFinite(rate) || rate <= 0) return value; | ||||
| return Number((value / rate).toFixed(6)); | |||||
| return Number((value / rate).toFixed(12)); | |||||
| }; | }; | ||||
| export const formatDisplayCurrency = (usdAmount, currencyConfig, digits = 6) => { | |||||
| const symbol = currencyConfig?.symbol || '$'; | |||||
| export const formatDisplayCurrencyNumber = (usdAmount, currencyConfig) => { | |||||
| const rate = Number(currencyConfig?.rate || 1); | const rate = Number(currencyConfig?.rate || 1); | ||||
| const value = Number(usdAmount || 0) * (Number.isFinite(rate) && rate > 0 ? rate : 1); | |||||
| const value = | |||||
| Number(usdAmount || 0) * (Number.isFinite(rate) && rate > 0 ? rate : 1); | |||||
| return Number(value.toFixed(6)); | |||||
| }; | |||||
| export const formatDisplayCurrency = ( | |||||
| usdAmount, | |||||
| currencyConfig, | |||||
| digits = 6, | |||||
| ) => { | |||||
| const symbol = currencyConfig?.symbol || '$'; | |||||
| const value = formatDisplayCurrencyNumber(usdAmount, currencyConfig); | |||||
| return `${symbol}${value.toFixed(digits)}`; | return `${symbol}${value.toFixed(digits)}`; | ||||
| }; | }; | ||||
| export const mergeGeneratedRows = ({ existingRows, generatedRows, strategy }) => { | |||||
| export const getBillingUnitLabel = (billingUnit, t = (key) => key) => { | |||||
| if (billingUnit === BILLING_UNITS.per1MTokens) return t('每 1M tokens'); | |||||
| return t('每次调用'); | |||||
| }; | |||||
| export const getPriceUnitLabel = ( | |||||
| billingUnit, | |||||
| symbol = '$', | |||||
| t = (key) => key, | |||||
| ) => `${symbol} / ${getBillingUnitLabel(billingUnit, t)}`; | |||||
| export const mergeGeneratedRows = ({ | |||||
| existingRows, | |||||
| generatedRows, | |||||
| strategy, | |||||
| }) => { | |||||
| if (strategy === 'replace_all') return [...(generatedRows || [])]; | if (strategy === 'replace_all') return [...(generatedRows || [])]; | ||||
| const manualRows = (existingRows || []).filter((row) => row.source === ROW_SOURCES.manual); | |||||
| const manualRows = (existingRows || []).filter( | |||||
| (row) => row.source === ROW_SOURCES.manual, | |||||
| ); | |||||
| return [...manualRows, ...(generatedRows || [])]; | return [...manualRows, ...(generatedRows || [])]; | ||||
| }; | }; | ||||
| export const importPricingCsv = (text, dimensions) => { | export const importPricingCsv = (text, dimensions) => { | ||||
| const lines = String(text || '').trim().split(/\r?\n/).filter(Boolean); | |||||
| const lines = String(text || '') | |||||
| .trim() | |||||
| .split(/\r?\n/) | |||||
| .filter(Boolean); | |||||
| if (lines.length === 0) return { rows: [], errors: [] }; | if (lines.length === 0) return { rows: [], errors: [] }; | ||||
| const headers = parseCsvLine(lines[0]); | const headers = parseCsvLine(lines[0]); | ||||
| @@ -230,10 +317,15 @@ export const importPricingCsv = (text, dimensions) => { | |||||
| headers.forEach((header, i) => { | headers.forEach((header, i) => { | ||||
| row[header] = cells[i] ?? ''; | row[header] = cells[i] ?? ''; | ||||
| }); | }); | ||||
| for (const dim of dimensions || []) row[dim.key] = row[dim.key] === '*' ? '*' : parseDimensionValue(row[dim.key], dim.type); | |||||
| for (const dim of dimensions || []) | |||||
| row[dim.key] = | |||||
| row[dim.key] === '*' | |||||
| ? '*' | |||||
| : parseDimensionValue(row[dim.key], dim.type); | |||||
| row.price = Number(row.price); | row.price = Number(row.price); | ||||
| row.source = row.source || ROW_SOURCES.manual; | row.source = row.source || ROW_SOURCES.manual; | ||||
| if (Number.isNaN(row.price)) errors.push(`line ${index + 2} has invalid price`); | |||||
| if (Number.isNaN(row.price)) | |||||
| errors.push(`line ${index + 2} has invalid price`); | |||||
| return row; | return row; | ||||
| }); | }); | ||||
| @@ -241,14 +333,21 @@ export const importPricingCsv = (text, dimensions) => { | |||||
| }; | }; | ||||
| export const exportPricingCsv = (rows, dimensions) => { | export const exportPricingCsv = (rows, dimensions) => { | ||||
| const headers = [...(dimensions || []).map((dim) => dim.key), 'price', 'source']; | |||||
| const body = (rows || []).map((row) => headers.map((header) => escapeCsvValue(row[header] ?? '')).join(',')); | |||||
| const headers = [ | |||||
| ...(dimensions || []).map((dim) => dim.key), | |||||
| 'price', | |||||
| 'source', | |||||
| ]; | |||||
| const body = (rows || []).map((row) => | |||||
| headers.map((header) => escapeCsvValue(row[header] ?? '')).join(','), | |||||
| ); | |||||
| return [headers.join(','), ...body].join('\n'); | return [headers.join(','), ...body].join('\n'); | ||||
| }; | }; | ||||
| export const previewLookup = (config, requestValues) => { | export const previewLookup = (config, requestValues) => { | ||||
| const validation = validatePricingConfig(config); | const validation = validatePricingConfig(config); | ||||
| if (!validation.ok) return { mode: 'invalid', error: validation.errors.join('\n') }; | |||||
| if (!validation.ok) | |||||
| return { mode: 'invalid', error: validation.errors.join('\n') }; | |||||
| const matches = []; | const matches = []; | ||||
| for (const row of config.table || []) { | for (const row of config.table || []) { | ||||
| @@ -269,25 +368,54 @@ export const previewLookup = (config, requestValues) => { | |||||
| } | } | ||||
| matches.sort((a, b) => a.wildcards - b.wildcards); | matches.sort((a, b) => a.wildcards - b.wildcards); | ||||
| if (matches.length > 1 && matches[0].wildcards === matches[1].wildcards) return { mode: 'reject', error: 'ambiguous matching rows' }; | |||||
| if (matches.length > 1 && matches[0].wildcards === matches[1].wildcards) | |||||
| return { mode: 'reject', error: 'ambiguous matching rows' }; | |||||
| if (matches.length > 0) { | if (matches.length > 0) { | ||||
| const best = matches[0]; | const best = matches[0]; | ||||
| return { | return { | ||||
| mode: 'matched', | mode: 'matched', | ||||
| row: best.row, | row: best.row, | ||||
| price: Number(best.row.price), | price: Number(best.row.price), | ||||
| snapshot: buildSnapshot(config, requestValues, Number(best.row.price), 'matched'), | |||||
| snapshot: buildSnapshot( | |||||
| config, | |||||
| requestValues, | |||||
| Number(best.row.price), | |||||
| 'matched', | |||||
| ), | |||||
| }; | }; | ||||
| } | } | ||||
| if (config.fallback.strategy === FALLBACKS.reject) return { mode: 'reject', error: 'no pricing row matched' }; | |||||
| if (config.fallback.strategy === FALLBACKS.reject) | |||||
| return { mode: 'reject', error: 'no pricing row matched' }; | |||||
| if (config.fallback.strategy === FALLBACKS.max) { | if (config.fallback.strategy === FALLBACKS.max) { | ||||
| const price = Math.max(...config.table.map((row) => Number(row.price || 0))); | |||||
| return { mode: 'fallback_max', price, snapshot: buildSnapshot(config, requestValues, price, 'fallback_max', FALLBACKS.max) }; | |||||
| const price = Math.max( | |||||
| ...config.table.map((row) => Number(row.price || 0)), | |||||
| ); | |||||
| return { | |||||
| mode: 'fallback_max', | |||||
| price, | |||||
| snapshot: buildSnapshot( | |||||
| config, | |||||
| requestValues, | |||||
| price, | |||||
| 'fallback_max', | |||||
| FALLBACKS.max, | |||||
| ), | |||||
| }; | |||||
| } | } | ||||
| const price = Number(config.fallback.default_price || 0); | const price = Number(config.fallback.default_price || 0); | ||||
| return { mode: 'fallback_default', price, snapshot: buildSnapshot(config, requestValues, price, 'fallback_default', FALLBACKS.default) }; | |||||
| return { | |||||
| mode: 'fallback_default', | |||||
| price, | |||||
| snapshot: buildSnapshot( | |||||
| config, | |||||
| requestValues, | |||||
| price, | |||||
| 'fallback_default', | |||||
| FALLBACKS.default, | |||||
| ), | |||||
| }; | |||||
| }; | }; | ||||
| export const parseDimensionValue = (value, type) => { | export const parseDimensionValue = (value, type) => { | ||||
| @@ -298,13 +426,19 @@ export const parseDimensionValue = (value, type) => { | |||||
| }; | }; | ||||
| const valueMatchesType = (value, type) => { | const valueMatchesType = (value, type) => { | ||||
| if (type === 'number') return typeof value === 'number' && !Number.isNaN(value); | |||||
| if (type === 'number') | |||||
| return typeof value === 'number' && !Number.isNaN(value); | |||||
| if (type === 'boolean') return typeof value === 'boolean'; | if (type === 'boolean') return typeof value === 'boolean'; | ||||
| return typeof value === 'string'; | return typeof value === 'string'; | ||||
| }; | }; | ||||
| const patternsOverlap = (left, right) => | const patternsOverlap = (left, right) => | ||||
| left.every((leftValue, index) => leftValue === '*' || right[index] === '*' || String(leftValue) === String(right[index])); | |||||
| left.every( | |||||
| (leftValue, index) => | |||||
| leftValue === '*' || | |||||
| right[index] === '*' || | |||||
| String(leftValue) === String(right[index]), | |||||
| ); | |||||
| const parseCsvLine = (line) => line.split(',').map((item) => item.trim()); | const parseCsvLine = (line) => line.split(',').map((item) => item.trim()); | ||||
| @@ -316,7 +450,8 @@ const escapeCsvValue = (value) => { | |||||
| const buildSnapshot = (config, values, price, mode, fallbackStrategy) => { | const buildSnapshot = (config, values, price, mode, fallbackStrategy) => { | ||||
| const snapshot = {}; | const snapshot = {}; | ||||
| for (const dim of config.dimensions || []) snapshot[dim.key] = values?.[dim.key] ?? null; | |||||
| for (const dim of config.dimensions || []) | |||||
| snapshot[dim.key] = values?.[dim.key] ?? null; | |||||
| snapshot.price = price; | snapshot.price = price; | ||||
| snapshot.billing_unit = config.billing_unit || BILLING_UNITS.perCall; | snapshot.billing_unit = config.billing_unit || BILLING_UNITS.perCall; | ||||
| snapshot.pricing_mode = mode; | snapshot.pricing_mode = mode; | ||||
| @@ -8,7 +8,10 @@ import { | |||||
| estimateGeneratedRowCount, | estimateGeneratedRowCount, | ||||
| exportPricingCsv, | exportPricingCsv, | ||||
| formatDisplayCurrency, | formatDisplayCurrency, | ||||
| formatDisplayCurrencyNumber, | |||||
| generateRowsFromRatios, | generateRowsFromRatios, | ||||
| getBillingUnitLabel, | |||||
| getPriceUnitLabel, | |||||
| importPricingCsv, | importPricingCsv, | ||||
| mergeGeneratedRows, | mergeGeneratedRows, | ||||
| PRECONSUME_STRATEGIES, | PRECONSUME_STRATEGIES, | ||||
| @@ -43,7 +46,14 @@ describe('pricingConfig utilities', () => { | |||||
| scope: 'model', | scope: 'model', | ||||
| billing_unit: BILLING_UNITS.per1MTokens, | billing_unit: BILLING_UNITS.per1MTokens, | ||||
| preconsume_strategy: PRECONSUME_STRATEGIES.minimum, | preconsume_strategy: PRECONSUME_STRATEGIES.minimum, | ||||
| dimensions: [{ key: 'video_input', source: 'derived.video_input', type: 'boolean', default: null }], | |||||
| dimensions: [ | |||||
| { | |||||
| key: 'video_input', | |||||
| source: 'derived.video_input', | |||||
| type: 'boolean', | |||||
| default: null, | |||||
| }, | |||||
| ], | |||||
| table: [ | table: [ | ||||
| { video_input: false, price: 46, source: ROW_SOURCES.manual }, | { video_input: false, price: 46, source: ROW_SOURCES.manual }, | ||||
| { video_input: true, price: 28, source: ROW_SOURCES.manual }, | { video_input: true, price: 28, source: ROW_SOURCES.manual }, | ||||
| @@ -70,7 +80,9 @@ describe('pricingConfig utilities', () => { | |||||
| expect(generatedRows).toHaveLength(4); | expect(generatedRows).toHaveLength(4); | ||||
| const merged = mergeGeneratedRows({ | const merged = mergeGeneratedRows({ | ||||
| existingRows: [{ resolution: '480P', duration: 5, price: 0.05, source: 'manual' }], | |||||
| existingRows: [ | |||||
| { resolution: '480P', duration: 5, price: 0.05, source: 'manual' }, | |||||
| ], | |||||
| generatedRows, | generatedRows, | ||||
| strategy: 'preserve_manual', | strategy: 'preserve_manual', | ||||
| }); | }); | ||||
| @@ -79,14 +91,31 @@ describe('pricingConfig utilities', () => { | |||||
| }); | }); | ||||
| test('split and compose dimension source', () => { | test('split and compose dimension source', () => { | ||||
| expect(splitDimensionSource('request.duration')).toEqual({ sourceType: 'request', path: 'duration' }); | |||||
| expect(splitDimensionSource('derived.aspect_ratio')).toEqual({ sourceType: 'derived', path: 'aspect_ratio' }); | |||||
| expect(splitDimensionSource('metadata.custom')).toEqual({ sourceType: 'advanced', path: 'metadata.custom' }); | |||||
| expect(composeDimensionSource('request', 'duration')).toBe('request.duration'); | |||||
| expect(composeDimensionSource('request', 'request.duration')).toBe('request.duration'); | |||||
| expect(composeDimensionSource('derived', 'aspect_ratio')).toBe('derived.aspect_ratio'); | |||||
| expect(composeDimensionSource('advanced', 'metadata.custom')).toBe('metadata.custom'); | |||||
| expect(splitDimensionSource('request.duration')).toEqual({ | |||||
| sourceType: 'request', | |||||
| path: 'duration', | |||||
| }); | |||||
| expect(splitDimensionSource('derived.aspect_ratio')).toEqual({ | |||||
| sourceType: 'derived', | |||||
| path: 'aspect_ratio', | |||||
| }); | |||||
| expect(splitDimensionSource('metadata.custom')).toEqual({ | |||||
| sourceType: 'advanced', | |||||
| path: 'metadata.custom', | |||||
| }); | |||||
| expect(composeDimensionSource('request', 'duration')).toBe( | |||||
| 'request.duration', | |||||
| ); | |||||
| expect(composeDimensionSource('request', 'request.duration')).toBe( | |||||
| 'request.duration', | |||||
| ); | |||||
| expect(composeDimensionSource('derived', 'aspect_ratio')).toBe( | |||||
| 'derived.aspect_ratio', | |||||
| ); | |||||
| expect(composeDimensionSource('advanced', 'metadata.custom')).toBe( | |||||
| 'metadata.custom', | |||||
| ); | |||||
| }); | }); | ||||
| test('count linear ratio entries and expanded rows', () => { | test('count linear ratio entries and expanded rows', () => { | ||||
| @@ -100,7 +129,9 @@ describe('pricingConfig utilities', () => { | |||||
| }; | }; | ||||
| expect(countRatioEntries(ratios)).toBe(5); | expect(countRatioEntries(ratios)).toBe(5); | ||||
| expect(estimateGeneratedRowCount(dimensions, ratios)).toBe(6); | expect(estimateGeneratedRowCount(dimensions, ratios)).toBe(6); | ||||
| expect(estimateGeneratedRowCount(dimensions, { resolution: { '720P': 1 } })).toBe(0); | |||||
| expect( | |||||
| estimateGeneratedRowCount(dimensions, { resolution: { '720P': 1 } }), | |||||
| ).toBe(0); | |||||
| }); | }); | ||||
| test('convert display currency amounts to stored USD prices', () => { | test('convert display currency amounts to stored USD prices', () => { | ||||
| @@ -108,16 +139,33 @@ describe('pricingConfig utilities', () => { | |||||
| const custom = { symbol: '¤', rate: 2, type: 'CUSTOM' }; | const custom = { symbol: '¤', rate: 2, type: 'CUSTOM' }; | ||||
| expect(displayCurrencyToUSD(0.7, cny)).toBe(0.1); | expect(displayCurrencyToUSD(0.7, cny)).toBe(0.1); | ||||
| expect(displayCurrencyToUSD(0.4, custom)).toBe(0.2); | expect(displayCurrencyToUSD(0.4, custom)).toBe(0.2); | ||||
| expect(displayCurrencyToUSD(0.1, { symbol: '$', rate: 1, type: 'USD' })).toBe(0.1); | |||||
| expect( | |||||
| displayCurrencyToUSD(0.1, { symbol: '$', rate: 1, type: 'USD' }), | |||||
| ).toBe(0.1); | |||||
| expect(formatDisplayCurrency(0.1, cny, 4)).toBe('¥0.7000'); | expect(formatDisplayCurrency(0.1, cny, 4)).toBe('¥0.7000'); | ||||
| expect(formatDisplayCurrencyNumber(46, cny)).toBe(322); | |||||
| expect(formatDisplayCurrencyNumber(displayCurrencyToUSD(37, cny), cny)).toBe( | |||||
| 37, | |||||
| ); | |||||
| expect(getBillingUnitLabel(BILLING_UNITS.per1MTokens, (key) => key)).toBe( | |||||
| '每 1M tokens', | |||||
| ); | |||||
| expect( | |||||
| getPriceUnitLabel(BILLING_UNITS.per1MTokens, '¥', (key) => key), | |||||
| ).toBe('¥ / 每 1M tokens'); | |||||
| }); | }); | ||||
| test('csv import and export', () => { | test('csv import and export', () => { | ||||
| const dimensions = [{ key: 'resolution', type: 'string' }]; | const dimensions = [{ key: 'resolution', type: 'string' }]; | ||||
| const parsed = importPricingCsv('resolution,price,source\n720P,0.1,manual', dimensions); | |||||
| const parsed = importPricingCsv( | |||||
| 'resolution,price,source\n720P,0.1,manual', | |||||
| dimensions, | |||||
| ); | |||||
| expect(parsed.errors).toHaveLength(0); | expect(parsed.errors).toHaveLength(0); | ||||
| expect(parsed.rows[0].price).toBe(0.1); | expect(parsed.rows[0].price).toBe(0.1); | ||||
| expect(exportPricingCsv(parsed.rows, dimensions)).toContain('720P,0.1,manual'); | |||||
| expect(exportPricingCsv(parsed.rows, dimensions)).toContain( | |||||
| '720P,0.1,manual', | |||||
| ); | |||||
| }); | }); | ||||
| test('preview exact row, wildcard row, and fallbacks', () => { | test('preview exact row, wildcard row, and fallbacks', () => { | ||||