| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import React, { useState, useEffect, useMemo, useCallback, memo } from 'react'; |
| import { |
| Card, |
| Tag, |
| Avatar, |
| Typography, |
| Tooltip, |
| Modal, |
| } from '@douyinfe/semi-ui'; |
| import { getLobeHubIcon } from '../../../../../helpers'; |
| import SearchActions from './SearchActions'; |
|
|
| const { Paragraph } = Typography; |
|
|
| const CONFIG = { |
| CAROUSEL_INTERVAL: 2000, |
| ICON_SIZE: 40, |
| UNKNOWN_VENDOR: 'unknown', |
| }; |
|
|
| const THEME_COLORS = { |
| allVendors: { |
| primary: '37 99 235', |
| background: 'rgba(59, 130, 246, 0.08)', |
| }, |
| specific: { |
| primary: '16 185 129', |
| background: 'rgba(16, 185, 129, 0.1)', |
| }, |
| }; |
|
|
| const COMPONENT_STYLES = { |
| tag: { |
| backgroundColor: 'rgba(255,255,255,0.95)', |
| color: '#1f2937', |
| border: '1px solid rgba(255,255,255,0.8)', |
| fontWeight: '500', |
| }, |
| avatarContainer: |
| 'w-16 h-16 rounded-2xl bg-white/90 shadow-md backdrop-blur-sm flex items-center justify-center', |
| titleText: { color: 'white' }, |
| descriptionText: { color: 'rgba(255,255,255,0.9)' }, |
| }; |
|
|
| const CONTENT_TEXTS = { |
| unknown: { |
| displayName: (t) => t('未知供应商'), |
| description: (t) => |
| t( |
| '包含来自未知或未标明供应商的AI模型,这些模型可能来自小型供应商或开源项目。', |
| ), |
| }, |
| all: { |
| description: (t) => |
| t('查看所有可用的AI模型供应商,包括众多知名供应商的模型。'), |
| }, |
| fallback: { |
| description: (t) => t('该供应商提供多种AI模型,适用于不同的应用场景。'), |
| }, |
| }; |
|
|
| const getVendorDisplayName = (vendorName, t) => { |
| return vendorName === CONFIG.UNKNOWN_VENDOR |
| ? CONTENT_TEXTS.unknown.displayName(t) |
| : vendorName; |
| }; |
|
|
| const createDefaultAvatar = () => ( |
| <div className={COMPONENT_STYLES.avatarContainer}> |
| <Avatar size='large' color='transparent'> |
| AI |
| </Avatar> |
| </div> |
| ); |
|
|
| const getAvatarBackgroundColor = (isAllVendors) => |
| isAllVendors |
| ? THEME_COLORS.allVendors.background |
| : THEME_COLORS.specific.background; |
|
|
| const getAvatarText = (vendorName) => |
| vendorName === CONFIG.UNKNOWN_VENDOR |
| ? '?' |
| : vendorName.charAt(0).toUpperCase(); |
|
|
| const createAvatarContent = (vendor, isAllVendors) => { |
| if (vendor.icon) { |
| return getLobeHubIcon(vendor.icon, CONFIG.ICON_SIZE); |
| } |
|
|
| return ( |
| <Avatar |
| size='large' |
| style={{ backgroundColor: getAvatarBackgroundColor(isAllVendors) }} |
| > |
| {getAvatarText(vendor.name)} |
| </Avatar> |
| ); |
| }; |
|
|
| const renderVendorAvatar = (vendor, t, isAllVendors = false) => { |
| if (!vendor) { |
| return createDefaultAvatar(); |
| } |
|
|
| const displayName = getVendorDisplayName(vendor.name, t); |
| const avatarContent = createAvatarContent(vendor, isAllVendors); |
|
|
| return ( |
| <Tooltip content={displayName} position='top'> |
| <div className={COMPONENT_STYLES.avatarContainer}>{avatarContent}</div> |
| </Tooltip> |
| ); |
| }; |
|
|
| const PricingVendorIntro = memo( |
| ({ |
| filterVendor, |
| models = [], |
| allModels = [], |
| t, |
| selectedRowKeys = [], |
| copyText, |
| handleChange, |
| handleCompositionStart, |
| handleCompositionEnd, |
| isMobile = false, |
| searchValue = '', |
| setShowFilterModal, |
| showWithRecharge, |
| setShowWithRecharge, |
| currency, |
| setCurrency, |
| showRatio, |
| setShowRatio, |
| viewMode, |
| setViewMode, |
| tokenUnit, |
| setTokenUnit, |
| }) => { |
| const [currentOffset, setCurrentOffset] = useState(0); |
| const [descModalVisible, setDescModalVisible] = useState(false); |
| const [descModalContent, setDescModalContent] = useState(''); |
|
|
| const handleOpenDescModal = useCallback((content) => { |
| setDescModalContent(content || ''); |
| setDescModalVisible(true); |
| }, []); |
|
|
| const handleCloseDescModal = useCallback(() => { |
| setDescModalVisible(false); |
| }, []); |
|
|
| const renderDescriptionModal = useCallback( |
| () => ( |
| <Modal |
| title={t('供应商介绍')} |
| visible={descModalVisible} |
| onCancel={handleCloseDescModal} |
| footer={null} |
| width={isMobile ? '95%' : 600} |
| bodyStyle={{ |
| maxHeight: isMobile ? '70vh' : '60vh', |
| overflowY: 'auto', |
| }} |
| > |
| <div className='text-sm mb-4'>{descModalContent}</div> |
| </Modal> |
| ), |
| [descModalVisible, descModalContent, handleCloseDescModal, isMobile, t], |
| ); |
|
|
| const vendorInfo = useMemo(() => { |
| const vendors = new Map(); |
| let unknownCount = 0; |
|
|
| const sourceModels = |
| Array.isArray(allModels) && allModels.length > 0 ? allModels : models; |
|
|
| sourceModels.forEach((model) => { |
| if (model.vendor_name) { |
| const existing = vendors.get(model.vendor_name); |
| if (existing) { |
| existing.count++; |
| } else { |
| vendors.set(model.vendor_name, { |
| name: model.vendor_name, |
| icon: model.vendor_icon, |
| description: model.vendor_description, |
| count: 1, |
| }); |
| } |
| } else { |
| unknownCount++; |
| } |
| }); |
|
|
| const vendorList = Array.from(vendors.values()).sort((a, b) => |
| a.name.localeCompare(b.name), |
| ); |
|
|
| if (unknownCount > 0) { |
| vendorList.push({ |
| name: CONFIG.UNKNOWN_VENDOR, |
| icon: null, |
| description: CONTENT_TEXTS.unknown.description(t), |
| count: unknownCount, |
| }); |
| } |
|
|
| return vendorList; |
| }, [allModels, models, t]); |
|
|
| const currentModelCount = models.length; |
|
|
| useEffect(() => { |
| if (filterVendor !== 'all' || vendorInfo.length <= 1) { |
| setCurrentOffset(0); |
| return; |
| } |
|
|
| const interval = setInterval(() => { |
| setCurrentOffset((prev) => (prev + 1) % vendorInfo.length); |
| }, CONFIG.CAROUSEL_INTERVAL); |
|
|
| return () => clearInterval(interval); |
| }, [filterVendor, vendorInfo.length]); |
|
|
| const getVendorDescription = useCallback( |
| (vendorKey) => { |
| if (vendorKey === 'all') { |
| return CONTENT_TEXTS.all.description(t); |
| } |
| if (vendorKey === CONFIG.UNKNOWN_VENDOR) { |
| return CONTENT_TEXTS.unknown.description(t); |
| } |
| const vendor = vendorInfo.find((v) => v.name === vendorKey); |
| return vendor?.description || CONTENT_TEXTS.fallback.description(t); |
| }, |
| [vendorInfo, t], |
| ); |
|
|
| const createCoverStyle = useCallback( |
| (primaryColor) => ({ |
| '--palette-primary-darkerChannel': primaryColor, |
| backgroundImage: `linear-gradient(0deg, rgba(var(--palette-primary-darkerChannel) / 80%), rgba(var(--palette-primary-darkerChannel) / 80%)), url('/cover-4.webp')`, |
| backgroundSize: 'cover', |
| backgroundPosition: 'center', |
| backgroundRepeat: 'no-repeat', |
| }), |
| [], |
| ); |
|
|
| const renderSearchActions = useCallback( |
| () => ( |
| <SearchActions |
| selectedRowKeys={selectedRowKeys} |
| copyText={copyText} |
| handleChange={handleChange} |
| handleCompositionStart={handleCompositionStart} |
| handleCompositionEnd={handleCompositionEnd} |
| isMobile={isMobile} |
| searchValue={searchValue} |
| setShowFilterModal={setShowFilterModal} |
| showWithRecharge={showWithRecharge} |
| setShowWithRecharge={setShowWithRecharge} |
| currency={currency} |
| setCurrency={setCurrency} |
| showRatio={showRatio} |
| setShowRatio={setShowRatio} |
| viewMode={viewMode} |
| setViewMode={setViewMode} |
| tokenUnit={tokenUnit} |
| setTokenUnit={setTokenUnit} |
| t={t} |
| /> |
| ), |
| [ |
| selectedRowKeys, |
| copyText, |
| handleChange, |
| handleCompositionStart, |
| handleCompositionEnd, |
| isMobile, |
| searchValue, |
| setShowFilterModal, |
| showWithRecharge, |
| setShowWithRecharge, |
| currency, |
| setCurrency, |
| showRatio, |
| setShowRatio, |
| viewMode, |
| setViewMode, |
| tokenUnit, |
| setTokenUnit, |
| t, |
| ], |
| ); |
|
|
| const renderHeaderCard = useCallback( |
| ({ title, count, description, rightContent, primaryDarkerChannel }) => ( |
| <Card |
| className='!rounded-2xl shadow-sm border-0' |
| cover={ |
| <div |
| className='relative h-full' |
| style={createCoverStyle(primaryDarkerChannel)} |
| > |
| <div className='relative z-10 h-full flex items-center justify-between p-4'> |
| <div className='flex-1 min-w-0 mr-4'> |
| <div className='flex flex-row flex-wrap items-center gap-2 sm:gap-3 mb-2'> |
| <h2 |
| className='text-lg sm:text-xl font-bold truncate' |
| style={COMPONENT_STYLES.titleText} |
| > |
| {title} |
| </h2> |
| <Tag |
| style={COMPONENT_STYLES.tag} |
| shape='circle' |
| size='small' |
| className='self-center' |
| > |
| {t('共 {{count}} 个模型', { count })} |
| </Tag> |
| </div> |
| <Paragraph |
| className='text-xs sm:text-sm leading-relaxed !mb-0 cursor-pointer' |
| style={COMPONENT_STYLES.descriptionText} |
| ellipsis={{ rows: 2 }} |
| onClick={() => handleOpenDescModal(description)} |
| > |
| {description} |
| </Paragraph> |
| </div> |
| |
| <div className='flex-shrink-0'>{rightContent}</div> |
| </div> |
| </div> |
| } |
| > |
| {renderSearchActions()} |
| </Card> |
| ), |
| [renderSearchActions, createCoverStyle, handleOpenDescModal, t], |
| ); |
|
|
| const renderAllVendorsAvatar = useCallback(() => { |
| const currentVendor = |
| vendorInfo.length > 0 |
| ? vendorInfo[currentOffset % vendorInfo.length] |
| : null; |
| return renderVendorAvatar(currentVendor, t, true); |
| }, [vendorInfo, currentOffset, t]); |
|
|
| if (filterVendor === 'all') { |
| const headerCard = renderHeaderCard({ |
| title: t('全部供应商'), |
| count: currentModelCount, |
| description: getVendorDescription('all'), |
| rightContent: renderAllVendorsAvatar(), |
| primaryDarkerChannel: THEME_COLORS.allVendors.primary, |
| }); |
| return ( |
| <> |
| {headerCard} |
| {renderDescriptionModal()} |
| </> |
| ); |
| } |
|
|
| const currentVendor = vendorInfo.find((v) => v.name === filterVendor); |
| if (!currentVendor) { |
| return null; |
| } |
|
|
| const vendorDisplayName = getVendorDisplayName(currentVendor.name, t); |
|
|
| const headerCard = renderHeaderCard({ |
| title: vendorDisplayName, |
| count: currentModelCount, |
| description: |
| currentVendor.description || getVendorDescription(currentVendor.name), |
| rightContent: renderVendorAvatar(currentVendor, t, false), |
| primaryDarkerChannel: THEME_COLORS.specific.primary, |
| }); |
|
|
| return ( |
| <> |
| {headerCard} |
| {renderDescriptionModal()} |
| </> |
| ); |
| }, |
| ); |
|
|
| PricingVendorIntro.displayName = 'PricingVendorIntro'; |
|
|
| export default PricingVendorIntro; |
|
|