844 lines
27 KiB
JavaScript
844 lines
27 KiB
JavaScript
/*
|
||
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;
|