|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
import React from 'react'; |
|
|
import { |
|
|
Card, |
|
|
Tag, |
|
|
Tooltip, |
|
|
Checkbox, |
|
|
Empty, |
|
|
Pagination, |
|
|
Button, |
|
|
Avatar, |
|
|
} from '@douyinfe/semi-ui'; |
|
|
import { IconHelpCircle } from '@douyinfe/semi-icons'; |
|
|
import { Copy } from 'lucide-react'; |
|
|
import { |
|
|
IllustrationNoResult, |
|
|
IllustrationNoResultDark, |
|
|
} from '@douyinfe/semi-illustrations'; |
|
|
import { |
|
|
stringToColor, |
|
|
calculateModelPrice, |
|
|
formatPriceInfo, |
|
|
getLobeHubIcon, |
|
|
} from '../../../../../helpers'; |
|
|
import PricingCardSkeleton from './PricingCardSkeleton'; |
|
|
import { useMinimumLoadingTime } from '../../../../../hooks/common/useMinimumLoadingTime'; |
|
|
import { renderLimitedItems } from '../../../../common/ui/RenderUtils'; |
|
|
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 PricingCardView = ({ |
|
|
filteredModels, |
|
|
loading, |
|
|
rowSelection, |
|
|
pageSize, |
|
|
setPageSize, |
|
|
currentPage, |
|
|
setCurrentPage, |
|
|
selectedGroup, |
|
|
groupRatio, |
|
|
copyText, |
|
|
setModalImageUrl, |
|
|
setIsModalOpenurl, |
|
|
currency, |
|
|
tokenUnit, |
|
|
displayPrice, |
|
|
showRatio, |
|
|
t, |
|
|
selectedRowKeys = [], |
|
|
setSelectedRowKeys, |
|
|
openModelDetail, |
|
|
}) => { |
|
|
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 handleCheckboxChange = (model, checked) => { |
|
|
if (!setSelectedRowKeys) return; |
|
|
const modelKey = getModelKey(model); |
|
|
const newKeys = checked |
|
|
? Array.from(new Set([...selectedRowKeys, modelKey])) |
|
|
: selectedRowKeys.filter((key) => key !== modelKey); |
|
|
setSelectedRowKeys(newKeys); |
|
|
rowSelection?.onChange?.(newKeys, null); |
|
|
}; |
|
|
|
|
|
|
|
|
const getModelIcon = (model) => { |
|
|
if (!model || !model.model_name) { |
|
|
return ( |
|
|
<div className={CARD_STYLES.container}> |
|
|
<Avatar size='large'>?</Avatar> |
|
|
</div> |
|
|
); |
|
|
} |
|
|
|
|
|
if (model.icon) { |
|
|
return ( |
|
|
<div className={CARD_STYLES.container}> |
|
|
<div className={CARD_STYLES.icon}> |
|
|
{getLobeHubIcon(model.icon, 32)} |
|
|
</div> |
|
|
</div> |
|
|
); |
|
|
} |
|
|
|
|
|
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(); |
|
|
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 || ''; |
|
|
}; |
|
|
|
|
|
|
|
|
const renderTags = (record) => { |
|
|
|
|
|
let billingTag = ( |
|
|
<Tag key='billing' shape='circle' color='white' size='small'> |
|
|
- |
|
|
</Tag> |
|
|
); |
|
|
if (record.quota_type === 1) { |
|
|
billingTag = ( |
|
|
<Tag key='billing' shape='circle' color='teal' size='small'> |
|
|
{t('按次计费')} |
|
|
</Tag> |
|
|
); |
|
|
} else if (record.quota_type === 0) { |
|
|
billingTag = ( |
|
|
<Tag key='billing' shape='circle' color='violet' size='small'> |
|
|
{t('按量计费')} |
|
|
</Tag> |
|
|
); |
|
|
} |
|
|
|
|
|
|
|
|
const customTags = []; |
|
|
if (record.tags) { |
|
|
const tagArr = record.tags.split(',').filter(Boolean); |
|
|
tagArr.forEach((tg, idx) => { |
|
|
customTags.push( |
|
|
<Tag |
|
|
key={`custom-${idx}`} |
|
|
shape='circle' |
|
|
color={stringToColor(tg)} |
|
|
size='small' |
|
|
> |
|
|
{tg} |
|
|
</Tag>, |
|
|
); |
|
|
}); |
|
|
} |
|
|
|
|
|
return ( |
|
|
<div className='flex items-center justify-between'> |
|
|
<div className='flex items-center gap-2'>{billingTag}</div> |
|
|
<div className='flex items-center gap-1'> |
|
|
{customTags.length > 0 && |
|
|
renderLimitedItems({ |
|
|
items: customTags.map((tag, idx) => ({ |
|
|
key: `custom-${idx}`, |
|
|
element: tag, |
|
|
})), |
|
|
renderItem: (item, idx) => item.element, |
|
|
maxDisplay: 3, |
|
|
})} |
|
|
</div> |
|
|
</div> |
|
|
); |
|
|
}; |
|
|
|
|
|
|
|
|
if (showSkeleton) { |
|
|
return ( |
|
|
<PricingCardSkeleton |
|
|
rowSelection={!!rowSelection} |
|
|
showRatio={showRatio} |
|
|
/> |
|
|
); |
|
|
} |
|
|
|
|
|
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='px-2 pt-2'> |
|
|
<div className='grid grid-cols-1 xl:grid-cols-2 2xl:grid-cols-3 gap-4'> |
|
|
{paginatedModels.map((model, index) => { |
|
|
const modelKey = getModelKey(model); |
|
|
const isSelected = selectedRowKeys.includes(modelKey); |
|
|
|
|
|
const priceData = calculateModelPrice({ |
|
|
record: model, |
|
|
selectedGroup, |
|
|
groupRatio, |
|
|
tokenUnit, |
|
|
displayPrice, |
|
|
currency, |
|
|
}); |
|
|
|
|
|
return ( |
|
|
<Card |
|
|
key={modelKey || index} |
|
|
className={`!rounded-2xl transition-all duration-200 hover:shadow-lg border cursor-pointer ${isSelected ? CARD_STYLES.selected : CARD_STYLES.default}`} |
|
|
bodyStyle={{ height: '100%' }} |
|
|
onClick={() => openModelDetail && openModelDetail(model)} |
|
|
> |
|
|
<div className='flex flex-col h-full'> |
|
|
{/* 头部:图标 + 模型名称 + 操作按钮 */} |
|
|
<div className='flex items-start justify-between 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'> |
|
|
{model.model_name} |
|
|
</h3> |
|
|
<div className='flex items-center gap-3 text-xs mt-1'> |
|
|
{formatPriceInfo(priceData, t)} |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div className='flex items-center space-x-2 ml-3'> |
|
|
{/* 复制按钮 */} |
|
|
<Button |
|
|
size='small' |
|
|
theme='outline' |
|
|
type='tertiary' |
|
|
icon={<Copy size={12} />} |
|
|
onClick={(e) => { |
|
|
e.stopPropagation(); |
|
|
copyText(model.model_name); |
|
|
}} |
|
|
/> |
|
|
|
|
|
{/* 选择框 */} |
|
|
{rowSelection && ( |
|
|
<Checkbox |
|
|
checked={isSelected} |
|
|
onChange={(e) => { |
|
|
e.stopPropagation(); |
|
|
handleCheckboxChange(model, e.target.checked); |
|
|
}} |
|
|
/> |
|
|
)} |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
{/* 模型描述 - 占据剩余空间 */} |
|
|
<div className='flex-1 mb-4'> |
|
|
<p |
|
|
className='text-xs line-clamp-2 leading-relaxed' |
|
|
style={{ color: 'var(--semi-color-text-2)' }} |
|
|
> |
|
|
{getModelDescription(model)} |
|
|
</p> |
|
|
</div> |
|
|
|
|
|
{/* 底部区域 */} |
|
|
<div className='mt-auto'> |
|
|
{/* 标签区域 */} |
|
|
{renderTags(model)} |
|
|
|
|
|
{/* 倍率信息(可选) */} |
|
|
{showRatio && ( |
|
|
<div className='pt-3'> |
|
|
<div className='flex items-center space-x-1 mb-2'> |
|
|
<span className='text-xs font-medium text-gray-700'> |
|
|
{t('倍率信息')} |
|
|
</span> |
|
|
<Tooltip |
|
|
content={t('倍率是为了方便换算不同价格的模型')} |
|
|
> |
|
|
<IconHelpCircle |
|
|
className='text-blue-500 cursor-pointer' |
|
|
size='small' |
|
|
onClick={(e) => { |
|
|
e.stopPropagation(); |
|
|
setModalImageUrl('/ratio.png'); |
|
|
setIsModalOpenurl(true); |
|
|
}} |
|
|
/> |
|
|
</Tooltip> |
|
|
</div> |
|
|
<div className='grid grid-cols-3 gap-2 text-xs text-gray-600'> |
|
|
<div> |
|
|
{t('模型')}:{' '} |
|
|
{model.quota_type === 0 ? model.model_ratio : t('无')} |
|
|
</div> |
|
|
<div> |
|
|
{t('补全')}:{' '} |
|
|
{model.quota_type === 0 |
|
|
? parseFloat(model.completion_ratio.toFixed(3)) |
|
|
: t('无')} |
|
|
</div> |
|
|
<div> |
|
|
{t('分组')}: {priceData?.usedGroupRatio ?? '-'} |
|
|
</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={true} |
|
|
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; |
|
|
|