tokenFactory/web/src/components/table/model-pricing/view/card/PricingCardView.jsx

844 lines
27 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React from 'react';
import {
Card,
Empty,
Pagination,
Avatar,
} from '@douyinfe/semi-ui';
import {
IllustrationNoResult,
IllustrationNoResultDark,
} from '@douyinfe/semi-illustrations';
import {
calculateModelPrice,
getModelPriceItems,
getLobeHubIcon,
getUsedGroupContext,
pickChannelScopedModelFloat,
computeChannelBillingRates,
} from '../../../../../helpers';
import PricingCardSkeleton from './PricingCardSkeleton';
import { useMinimumLoadingTime } from '../../../../../hooks/common/useMinimumLoadingTime';
import { useIsMobile } from '../../../../../hooks/common/useIsMobile';
const CARD_STYLES = {
container:
'w-12 h-12 rounded-2xl flex items-center justify-center relative shadow-md',
icon: 'w-8 h-8 flex items-center justify-center',
selected: 'border-blue-500 bg-blue-50',
default: 'border-gray-200 hover:border-gray-300',
};
const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const PricingCardView = ({
filteredModels,
loading,
pageSize,
setPageSize,
currentPage,
setCurrentPage,
selectedGroup,
groupRatio,
groupModelPrice,
groupModelRatio,
currency,
siteDisplayType,
tokenUnit,
displayPrice,
t,
openModelDetail,
showSizeChanger = true,
blurPricing = false,
searchValue = '',
channelVideoRatio = {},
channelVideoCompletionRatio = {},
channelVideoPrice = {},
gridCols = 0, // 当 > 0 时使用 grid 布局(如 gridCols=2 表示 2 列)
}) => {
const showSkeleton = useMinimumLoadingTime(loading);
const startIndex = (currentPage - 1) * pageSize;
const paginatedModels = filteredModels.slice(
startIndex,
startIndex + pageSize,
);
const getModelKey = (model) => model.key ?? model.model_name ?? model.id;
const isMobile = useIsMobile();
const normalizedSearchValue = String(searchValue || '').trim();
const renderHighlightedText = (value) => {
const text = value == null ? '' : String(value);
if (!normalizedSearchValue) return text;
const regex = new RegExp(`(${escapeRegExp(normalizedSearchValue)})`, 'ig');
return text.split(regex).map((part, idx) =>
part.toLowerCase() === normalizedSearchValue.toLowerCase() ? (
<span
key={idx}
style={{
color: '#ef4444',
fontWeight: 700,
backgroundColor: 'rgba(239, 68, 68, 0.12)',
borderRadius: 4,
}}
>
{part}
</span>
) : (
part
),
);
};
const calculateChannelPrices = (model, opts = {}) => {
const { skipSimpleVideoFlat = false, skipSimpleFixed = false } = opts;
if (!model.channel_list || model.channel_list.length === 0) {
return null;
}
const { usedGroupRatio } = getUsedGroupContext(
model,
selectedGroup,
groupRatio,
);
// 辅助函数:格式化价格
const formatPrice = (priceUSD) => {
const rawDisplayPrice = displayPrice(priceUSD);
const unitDivisor = tokenUnit === 'K' ? 1000 : 1;
const numericPrice =
parseFloat(rawDisplayPrice.replace(/[^0-9.]/g, '')) / unitDivisor;
let symbol = '$';
if (currency === 'CNY') {
symbol = '¥';
} else if (currency === 'CUSTOM') {
try {
const statusStr = localStorage.getItem('status');
if (statusStr) {
const s = JSON.parse(statusStr);
symbol = s?.custom_currency_symbol || '¤';
}
} catch (e) {
symbol = '¤';
}
}
return { value: parseFloat(numericPrice.toFixed(2)), symbol };
};
const modelHasVideoRatio =
model.video_ratio != null &&
model.video_ratio !== undefined &&
Number.isFinite(Number(model.video_ratio));
const modelHasVideoCompletion =
model.video_completion_ratio != null &&
model.video_completion_ratio !== undefined &&
Number.isFinite(Number(model.video_completion_ratio));
const modelHasVideoFlatPrice =
model.video_price != null &&
model.video_price !== undefined &&
Number.isFinite(Number(model.video_price));
// 提取所有通道的价格(与 relay 一致ch.model_ratio 已含渠道折扣;再乘分组倍率)
const prices = {
input: [],
output: [],
cache: [],
createCache: [],
fixed: [],
videoToken: [],
videoFlat: [],
};
const originalPrices = {
input: [],
output: [],
cache: [],
createCache: [],
fixed: [],
videoToken: [],
videoFlat: [],
};
model.channel_list.forEach((ch) => {
const cid = ch.channel_id;
const mname = model.model_name;
// ============================================================
// 新计费公式参数:
// ch.model_ratio / ch.model_price 为原始渠道倍率(后端不再预乘成本折扣)
// 成本折扣率 = price_discount_percent / 100
// 加价倍率 = markup_discount_rate / 100
//
// 输入 = (ch.model_ratio × costDisc + globalMr × markupRate) × 2 × groupRatio
// 输出 = (ch.model_ratio × cr × costDisc + globalMr × globalCR × markupRate) × 2 × groupRatio
// 缓存读 = (ch.model_ratio × cacheRatio × costDisc + globalMr × globalCacheR × markupRate) × 2 × groupRatio
// 缓存写 = (ch.model_ratio × createCacheRatio × costDisc + globalMr × globalCreateCacheR × markupRate) × 2 × groupRatio
// 固定价 = (ch.model_price × costDisc + globalMp × markupRate) × groupRatio
// ============================================================
const priceDiscountPercent =
ch.price_discount_percent != null ? ch.price_discount_percent : 100;
const markupDiscountPercent = ch.markup_discount_rate || 0;
const globalMr = model.model_ratio || 0;
const globalMp = model.model_price || 0;
const channelVideoFlatUsd =
pickChannelScopedModelFloat(channelVideoPrice, cid, mname);
const flatUsd =
channelVideoFlatUsd != null
? channelVideoFlatUsd
: modelHasVideoFlatPrice
? Number(model.video_price)
: null;
if (!skipSimpleVideoFlat && flatUsd != null && flatUsd > 0) {
prices.videoFlat.push(formatPrice(flatUsd * usedGroupRatio));
originalPrices.videoFlat.push(formatPrice(flatUsd));
}
// 全局子倍率(用于加价侧)
const globalCR = model.completion_ratio || 0;
const globalCacheR =
model.cache_ratio != null ? Number(model.cache_ratio) : 0;
const globalCreateCacheR =
model.create_cache_ratio != null
? Number(model.create_cache_ratio)
: 0;
const billingRates = computeChannelBillingRates({
channelModelRatio: ch.model_ratio,
channelCompletionRatio: ch.completion_ratio,
channelCacheRatio: ch.cache_ratio,
channelCreateCacheRatio: ch.create_cache_ratio,
channelModelPrice: ch.model_price,
priceDiscountPercent,
markupDiscountPercent,
globalModelRatio: globalMr,
globalModelPrice: globalMp,
globalCompletionRatio: globalCR,
globalCacheRatio: globalCacheR,
globalCreateCacheRatio: globalCreateCacheR,
});
// 按量计费
if (model.quota_type === 0) {
if (ch.model_ratio !== undefined && ch.model_ratio !== null) {
prices.input.push(
formatPrice(billingRates.inputRatioPrice * usedGroupRatio),
);
originalPrices.input.push(formatPrice(billingRates.inputRatioPrice));
// 输出价格:渠道配置了 completion_ratio 即可展示
if (
ch.completion_ratio !== undefined &&
ch.completion_ratio !== null
) {
prices.output.push(
formatPrice(billingRates.completionRatioPrice * usedGroupRatio),
);
originalPrices.output.push(
formatPrice(billingRates.completionRatioPrice),
);
}
// 缓存读取价格:渠道配置了 cache_ratio 即可展示
if (
ch.cache_ratio !== undefined &&
ch.cache_ratio !== null
) {
prices.cache.push(
formatPrice(billingRates.cacheRatioPrice * usedGroupRatio),
);
originalPrices.cache.push(formatPrice(billingRates.cacheRatioPrice));
}
// 缓存创建价格:渠道配置了 create_cache_ratio 即可展示
if (
ch.create_cache_ratio !== undefined &&
ch.create_cache_ratio !== null
) {
prices.createCache.push(
formatPrice(
billingRates.cacheCreationRatioPrice * usedGroupRatio,
),
);
originalPrices.createCache.push(
formatPrice(billingRates.cacheCreationRatioPrice),
);
}
const vrCh = pickChannelScopedModelFloat(
channelVideoRatio,
cid,
mname,
);
const vcrCh = pickChannelScopedModelFloat(
channelVideoCompletionRatio,
cid,
mname,
);
const effVr =
vrCh != null
? vrCh
: modelHasVideoRatio
? Number(model.video_ratio)
: 1;
const effVcr =
vcrCh != null
? vcrCh
: modelHasVideoCompletion
? Number(model.video_completion_ratio)
: 1;
const showVideoToken =
modelHasVideoRatio ||
modelHasVideoCompletion ||
vrCh != null ||
vcrCh != null;
if (showVideoToken) {
const videoTokUsd =
billingRates.inputRatioPrice * effVr * effVcr * usedGroupRatio;
prices.videoToken.push(formatPrice(videoTokUsd));
originalPrices.videoToken.push(
formatPrice(billingRates.inputRatioPrice * effVr * effVcr),
);
}
}
}
// 按次计费
else if (model.quota_type === 1 || ch.quota_type === 1) {
if (!skipSimpleFixed && ch.model_price !== undefined && ch.model_price !== null) {
prices.fixed.push(
formatPrice(billingRates.effModelPrice * usedGroupRatio),
);
originalPrices.fixed.push(formatPrice(billingRates.effModelPrice));
}
}
});
// 根数据价格(用同一口径计算,用于与 channel 价格比较)
const rootPrices = {};
if (model.quota_type === 0) {
if (model.model_ratio !== undefined && model.model_ratio !== null) {
rootPrices.input = formatPrice(model.model_ratio * 2);
if (
model.completion_ratio !== undefined &&
model.completion_ratio !== null
) {
rootPrices.output = formatPrice(
model.model_ratio * model.completion_ratio * 2,
);
}
if (model.cache_ratio !== undefined && model.cache_ratio !== null) {
rootPrices.cache = formatPrice(
model.model_ratio * model.cache_ratio * 2,
);
}
if (
model.create_cache_ratio !== undefined &&
model.create_cache_ratio !== null
) {
rootPrices.createCache = formatPrice(
model.model_ratio * model.create_cache_ratio * 2,
);
}
}
} else if (model.quota_type === 1) {
if (
!skipSimpleFixed &&
model.model_price !== undefined &&
model.model_price !== null
) {
rootPrices.fixed = formatPrice(model.model_price);
}
}
if (model.quota_type === 0) {
if (model.model_ratio !== undefined && model.model_ratio !== null) {
const rootVr = modelHasVideoRatio ? Number(model.video_ratio) : 1;
const rootVcr = modelHasVideoCompletion
? Number(model.video_completion_ratio)
: 1;
if (modelHasVideoRatio || modelHasVideoCompletion) {
rootPrices.videoToken = formatPrice(
model.model_ratio * rootVr * rootVcr * 2,
);
}
}
}
if (modelHasVideoFlatPrice && !skipSimpleVideoFlat) {
rootPrices.videoFlat = formatPrice(Number(model.video_price));
}
// 若根价格高于任意一个 channel 的对应价格,则返回划线原价与折扣
const getOriginal = (rootPrice, channelPriceArray) => {
if (!rootPrice || !channelPriceArray || channelPriceArray.length === 0)
return null;
const minChannel = Math.min(...channelPriceArray.map((p) => p.value));
if (rootPrice.value > minChannel && rootPrice.value > 0) {
const discount = Math.round((1 - minChannel / rootPrice.value) * 100);
return {
text: `${rootPrice.symbol}${rootPrice.value}`,
discount,
};
}
return null;
};
// 计算范围
const calculateRange = (priceArray) => {
if (priceArray.length === 0) return null;
if (priceArray.length === 1) {
const p = priceArray[0];
return {
single: `${p.symbol}${p.value}`,
min: null,
max: null,
symbol: p.symbol,
};
}
const values = priceArray.map((p) => p.value);
const uniqueValues = [...new Set(values)];
if (uniqueValues.length === 1) {
const p = priceArray[0];
return {
single: `${p.symbol}${p.value}`,
min: null,
max: null,
symbol: p.symbol,
};
}
const min = Math.min(...values);
const max = Math.max(...values);
const symbol = priceArray[0].symbol;
return { single: null, min, max, symbol };
};
const unitLabel = tokenUnit === 'K' ? 'K' : 'M';
const unitSuffix = ` / 1${unitLabel} Tokens`;
const fixedSuffix = ` / ${t('次')}`;
return {
input: calculateRange(prices.input),
output: calculateRange(prices.output),
cache: calculateRange(prices.cache),
createCache: calculateRange(prices.createCache),
fixed: calculateRange(prices.fixed),
original: {
input: getOriginal(rootPrices.input, originalPrices.input),
output: getOriginal(rootPrices.output, originalPrices.output),
cache: getOriginal(rootPrices.cache, originalPrices.cache),
createCache: getOriginal(
rootPrices.createCache,
originalPrices.createCache,
),
fixed: getOriginal(rootPrices.fixed, originalPrices.fixed),
videoToken: getOriginal(
rootPrices.videoToken,
originalPrices.videoToken,
),
videoFlat: getOriginal(rootPrices.videoFlat, originalPrices.videoFlat),
},
videoToken: calculateRange(prices.videoToken),
videoFlat: calculateRange(prices.videoFlat),
unitSuffix,
fixedSuffix,
videoFlatSuffix: ` / ${t('条')}`,
quotaType: model.quota_type,
};
};
// 获取模型的价格项(优先使用 channel 价格)
const getModelPriceItemsForCard = (model, priceData) => {
const hint = model.video_flat_clip_hint;
const useTieredVideoFlat =
hint &&
Number(hint.tier_count) > 0 &&
Number(hint.min_usd_after_channel_discount) > 0;
const imageHint = model.image_per_image_hint;
const useTieredImagePerImage =
imageHint &&
Number(imageHint.tier_count) > 0 &&
Number(imageHint.min_usd_after_channel_discount) > 0;
const channelPrices = calculateChannelPrices(model, {
skipSimpleVideoFlat: useTieredVideoFlat,
skipSimpleFixed: useTieredImagePerImage,
});
// 如果没有 channel 价格,使用原有逻辑
if (!channelPrices) {
return getModelPriceItems(priceData, t, siteDisplayType);
}
// 使用 channel 价格构建价格项
const items = [];
const {
input,
output,
cache,
createCache,
fixed,
original,
unitSuffix,
fixedSuffix,
videoToken,
videoFlat,
videoFlatSuffix,
quotaType,
} = channelPrices;
// 按次计费
if (quotaType === 1 && fixed) {
items.push({
key: 'fixed',
label: t('模型价格'),
value:
fixed.single ||
`${fixed.symbol}${fixed.min} ~ ${fixed.symbol}${fixed.max}`,
suffix: fixedSuffix,
original: original?.fixed,
});
}
// 按量计费
else {
if (input) {
items.push({
key: 'input',
label: t('输入价格'),
value:
input.single ||
`${input.symbol}${input.min} ~ ${input.symbol}${input.max}`,
suffix: unitSuffix,
original: original?.input,
});
}
if (output) {
items.push({
key: 'output',
label: t('输出价格'),
value:
output.single ||
`${output.symbol}${output.min} ~ ${output.symbol}${output.max}`,
suffix: unitSuffix,
original: original?.output,
});
}
if (videoToken) {
items.push({
key: 'video-token',
label: t('视频(倍率计价)'),
value:
videoToken.single ||
`${videoToken.symbol}${videoToken.min} ~ ${videoToken.symbol}${videoToken.max}`,
suffix: unitSuffix,
original: original?.videoToken,
});
}
// 卡片表面仅展示输入/输出价格,缓存价格不在卡片上显示(详情弹窗中仍可见)
// if (cache) {
// items.push({
// key: 'cache',
// label: t('缓存读取价格'),
// value: cache.single || `${cache.symbol}${cache.min} ~ ${cache.symbol}${cache.max}`,
// suffix: unitSuffix,
// original: original?.cache,
// });
// }
//
// if (createCache) {
// items.push({
// key: 'create-cache',
// label: t('缓存创建价格'),
// value: createCache.single || `${createCache.symbol}${createCache.min} ~ ${createCache.symbol}${createCache.max}`,
// suffix: unitSuffix,
// original: original?.createCache,
// });
// }
}
if (videoFlat) {
items.push({
key: 'video-flat',
label: t('视频按条(固定价)'),
value:
videoFlat.single ||
`${videoFlat.symbol}${videoFlat.min} ~ ${videoFlat.symbol}${videoFlat.max}`,
suffix: videoFlatSuffix || ` / ${t('条')}`,
original: original?.videoFlat,
});
}
if (useTieredVideoFlat) {
const { usedGroupRatio } = getUsedGroupContext(
model,
selectedGroup,
groupRatio,
);
const usd = Number(hint.min_usd_after_channel_discount) * usedGroupRatio;
const perSecond = hint.billing_mode === 'per_second';
items.push({
key: 'video-flat-tiered',
label: perSecond ? t('按秒') : t('按条'),
valueNode: (
<span className='font-bold text-black'>
{t('最低价')}
{displayPrice(usd)}
{perSecond ? t('/秒起') : t('/条起')}
</span>
),
});
}
if (useTieredImagePerImage) {
const { usedGroupRatio } = getUsedGroupContext(
model,
selectedGroup,
groupRatio,
);
const usd =
Number(imageHint.min_usd_after_channel_discount) * usedGroupRatio;
items.push({
key: 'image-per-image-tiered',
label: t('按张'),
valueNode: (
<span className='font-bold text-black'>
{t('最低价')}
{displayPrice(usd)}
{t('/张起')}
</span>
),
});
}
return items;
};
// 获取模型图标
const getModelIcon = (model) => {
if (!model || !model.model_name) {
return (
<div className={CARD_STYLES.container}>
<Avatar size='large'>?</Avatar>
</div>
);
}
// 1) 优先使用模型自定义图标
if (model.icon) {
return (
<div className={CARD_STYLES.container}>
<div className={CARD_STYLES.icon}>
{getLobeHubIcon(model.icon, 32)}
</div>
</div>
);
}
// 2) 退化为供应商图标
if (model.vendor_icon) {
return (
<div className={CARD_STYLES.container}>
<div className={CARD_STYLES.icon}>
{getLobeHubIcon(model.vendor_icon, 32)}
</div>
</div>
);
}
// 如果没有供应商图标,使用模型名称生成头像
const avatarText =
(model.model_name || '').slice(0, 2).toUpperCase() || 'AI';
return (
<div className={CARD_STYLES.container}>
<Avatar
size='large'
style={{
width: 48,
height: 48,
borderRadius: 16,
fontSize: 16,
fontWeight: 'bold',
}}
>
{avatarText}
</Avatar>
</div>
);
};
// 获取模型描述
const getModelDescription = (record) => {
return record.description || '';
};
// 显示骨架屏
if (showSkeleton) {
return (
<>
<PricingCardSkeleton />
</>
);
}
if (!filteredModels || filteredModels.length === 0) {
return (
<>
<div className='flex justify-center items-center py-20'>
<Empty
image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
darkModeImage={
<IllustrationNoResultDark style={{ width: 150, height: 150 }} />
}
description={t('搜索无结果')}
/>
</div>
</>
);
}
return (
<>
<div className={gridCols > 0 ? '' : 'px-2 pt-2'}>
<div className={gridCols > 0 ? 'grid gap-4' : 'flex flex-wrap gap-4'} style={gridCols > 0 ? { gridTemplateColumns: `repeat(${gridCols}, 1fr)` } : undefined}>
{paginatedModels.map((model, index) => {
const modelKey = getModelKey(model);
const priceData = calculateModelPrice({
record: model,
selectedGroup,
groupRatio,
groupModelPrice,
groupModelRatio,
tokenUnit,
displayPrice,
currency,
quotaDisplayType: siteDisplayType,
});
return (
<Card
key={modelKey || index}
className={`${gridCols > 0 ? '!w-full' : 'flex-1 min-w-[350px] max-w-[600px]'} !rounded-2xl transition-all duration-200 hover:shadow-lg border ${blurPricing ? '' : 'cursor-pointer'} ${CARD_STYLES.default}`}
bodyStyle={{ height: '100%' }}
onClick={() =>
!blurPricing && openModelDetail && openModelDetail(model)
}
>
<div className='flex flex-col h-full'>
{/* 头部:图标 + 模型名称 */}
<div className='flex items-start mb-3'>
<div className='flex items-start space-x-3 flex-1 min-w-0'>
{getModelIcon(model)}
<div className='flex-1 min-w-0'>
<h3 className='text-lg font-bold text-gray-900 truncate'>
{renderHighlightedText(model.model_name)}
</h3>
</div>
</div>
</div>
{/* 模型描述 */}
<div
className='mb-4'
style={
blurPricing
? {
filter: 'blur(6px)',
userSelect: 'none',
pointerEvents: 'none',
}
: undefined
}
>
<p
className='text-xs line-clamp-2 leading-relaxed'
style={{ color: 'var(--semi-color-text-2)' }}
>
{renderHighlightedText(getModelDescription(model))}
</p>
</div>
{/* 价格:输入 + 输出并排 */}
<div
className='mt-auto'
style={
blurPricing
? {
filter: 'blur(6px)',
userSelect: 'none',
pointerEvents: 'none',
}
: undefined
}
>
{(() => {
const items = getModelPriceItemsForCard(model, priceData);
if (items.length === 0) return null;
return (
<div className='flex flex-wrap gap-3'>
{items.map(item => (
<div key={item.key} className='flex-1 rounded-xl px-3 py-2.5' style={{backgroundColor: 'var(--semi-color-fill-0)', minWidth: '120px'}}>
<div className='text-[10px] mb-0.5' style={{color: 'var(--semi-color-text-2)'}}>{item.label}</div>
<div className='text-sm font-bold text-gray-900'>
{item.valueNode || (item.value + (item.suffix || ''))}
</div>
</div>
))}
</div>
);
})()}
</div>
</div>
</Card>
);
})}
</div>
{/* 分页 */}
{filteredModels.length > 0 && (
<div className='flex justify-center mt-6 py-4 border-t pricing-pagination-divider'>
<Pagination
currentPage={currentPage}
pageSize={pageSize}
total={filteredModels.length}
showSizeChanger={showSizeChanger}
pageSizeOptions={[10, 20, 50, 100]}
size={isMobile ? 'small' : 'default'}
showQuickJumper={isMobile}
onPageChange={(page) => setCurrentPage(page)}
onPageSizeChange={(size) => {
setPageSize(size);
setCurrentPage(1);
}}
/>
</div>
)}
</div>
</>
);
};
export default PricingCardView;