diff --git a/web/src/components/table/model-pricing/layout/header/PricingTopSection.jsx b/web/src/components/table/model-pricing/layout/header/PricingTopSection.jsx index 76bbac47..78d46003 100644 --- a/web/src/components/table/model-pricing/layout/header/PricingTopSection.jsx +++ b/web/src/components/table/model-pricing/layout/header/PricingTopSection.jsx @@ -17,11 +17,11 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import React, { useState } from 'react'; +import React, { useState, memo } from 'react'; import PricingFilterModal from '../../modal/PricingFilterModal'; import PricingVendorIntroWithSkeleton from './PricingVendorIntroWithSkeleton'; -const PricingTopSection = ({ +const PricingTopSection = memo(({ selectedRowKeys, copyText, handleChange, @@ -40,7 +40,6 @@ const PricingTopSection = ({ return ( <> - {/* 供应商介绍区域(包含搜索功能) */} - {/* 移动端筛选Modal */} {isMobile && ( ); -}; +}); + +PricingTopSection.displayName = 'PricingTopSection'; export default PricingTopSection; \ No newline at end of file diff --git a/web/src/components/table/model-pricing/layout/header/PricingVendorIntro.jsx b/web/src/components/table/model-pricing/layout/header/PricingVendorIntro.jsx index e2c4664c..ee7695fe 100644 --- a/web/src/components/table/model-pricing/layout/header/PricingVendorIntro.jsx +++ b/web/src/components/table/model-pricing/layout/header/PricingVendorIntro.jsx @@ -17,14 +17,104 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import React, { useState, useEffect, useMemo } from 'react'; -import { Card, Tag, Avatar, Typography, Tooltip } from '@douyinfe/semi-ui'; +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 PricingVendorIntro = ({ +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 = () => ( +
+ AI +
+); + +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 ( + + {getAvatarText(vendor.name)} + + ); +}; + +const renderVendorAvatar = (vendor, t, isAllVendors = false) => { + if (!vendor) { + return createDefaultAvatar(); + } + + const displayName = getVendorDisplayName(vendor.name, t); + const avatarContent = createAvatarContent(vendor, isAllVendors); + + return ( + +
+ {avatarContent} +
+
+ ); +}; + +const PricingVendorIntro = memo(({ filterVendor, models = [], allModels = [], @@ -38,38 +128,66 @@ const PricingVendorIntro = ({ searchValue = '', setShowFilterModal }) => { - const MAX_VISIBLE_AVATARS = 8; - // 轮播动效状态(只对全部供应商生效) 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(() => ( + +
+ {descModalContent} +
+
+ ), [descModalVisible, descModalContent, handleCloseDescModal, isMobile, t]); - // 获取所有供应商信息 const vendorInfo = useMemo(() => { const vendors = new Map(); let unknownCount = 0; - (allModels.length > 0 ? allModels : models).forEach(model => { + const sourceModels = Array.isArray(allModels) && allModels.length > 0 ? allModels : models; + + sourceModels.forEach(model => { if (model.vendor_name) { - if (!vendors.has(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: 0 + count: 1 }); } - vendors.get(model.vendor_name).count++; } else { unknownCount++; } }); - const vendorList = Array.from(vendors.values()).sort((a, b) => a.name.localeCompare(b.name)); + const vendorList = Array.from(vendors.values()) + .sort((a, b) => a.name.localeCompare(b.name)); if (unknownCount > 0) { vendorList.push({ - name: 'unknown', + name: CONFIG.UNKNOWN_VENDOR, icon: null, - description: t('包含来自未知或未标明供应商的AI模型,这些模型可能来自小型供应商或开源项目。'), + description: CONTENT_TEXTS.unknown.description(t), count: unknownCount }); } @@ -77,171 +195,41 @@ const PricingVendorIntro = ({ return vendorList; }, [allModels, models, t]); - // 计算当前过滤器的模型数量 const currentModelCount = models.length; - // 设置轮播定时器(只对全部供应商且有足够头像时生效) useEffect(() => { - if (filterVendor !== 'all' || vendorInfo.length <= 3) { - setCurrentOffset(0); // 重置偏移 + if (filterVendor !== 'all' || vendorInfo.length <= 1) { + setCurrentOffset(0); return; } const interval = setInterval(() => { setCurrentOffset(prev => (prev + 1) % vendorInfo.length); - }, 2000); + }, CONFIG.CAROUSEL_INTERVAL); return () => clearInterval(interval); }, [filterVendor, vendorInfo.length]); - // 获取供应商描述信息(从后端数据中) - const getVendorDescription = (vendorKey) => { + const getVendorDescription = useCallback((vendorKey) => { if (vendorKey === 'all') { - return t('查看所有可用的AI模型供应商,包括众多知名供应商的模型。'); + return CONTENT_TEXTS.all.description(t); } - if (vendorKey === 'unknown') { - return t('包含来自未知或未标明供应商的AI模型,这些模型可能来自小型供应商或开源项目。'); + if (vendorKey === CONFIG.UNKNOWN_VENDOR) { + return CONTENT_TEXTS.unknown.description(t); } const vendor = vendorInfo.find(v => v.name === vendorKey); - return vendor?.description || t('该供应商提供多种AI模型,适用于不同的应用场景。'); - }; + return vendor?.description || CONTENT_TEXTS.fallback.description(t); + }, [vendorInfo, t]); - // 统一的 Tag 样式 - const tagStyle = { - backgroundColor: 'rgba(255,255,255,0.95)', - color: '#1f2937', - border: '1px solid rgba(255,255,255,0.8)', - fontWeight: '500' - }; - - // 生成封面背景样式 - const getCoverStyle = (primaryDarkerChannel) => ({ - '--palette-primary-darkerChannel': primaryDarkerChannel, + 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 renderHeaderCard = ({ title, count, description, rightContent, primaryDarkerChannel }) => ( - -
- {/* 左侧:标题与描述 */} -
-
-

- {title} -

- - {t('共 {{count}} 个模型', { count })} - -
- - {description} - -
- - {/* 右侧:展示区 */} -
- {rightContent} -
-
- - } - > - {/* 搜索与操作区 */} - {renderSearchActions()} -
- ); - - // 为全部供应商创建特殊的头像组合 - const renderAllVendorsAvatar = () => { - // 重新排列数组,让当前偏移量的头像在第一位 - const rotatedVendors = vendorInfo.length > 3 ? [ - ...vendorInfo.slice(currentOffset), - ...vendorInfo.slice(0, currentOffset) - ] : vendorInfo; - - // 如果没有供应商,显示占位符 - if (vendorInfo.length === 0) { - return ( -
- - AI - -
- ); - } - - const visible = rotatedVendors.slice(0, MAX_VISIBLE_AVATARS); - const rest = vendorInfo.length - visible.length; - - return ( -
-
- {visible.map((vendor) => ( - -
- {vendor.icon ? ( - getLobeHubIcon(vendor.icon, 18) - ) : ( - - {vendor.name === 'unknown' ? '?' : vendor.name.charAt(0).toUpperCase()} - - )} -
-
- ))} - {rest > 0 && ( -
- {`+${rest}`} -
- )} -
-
- ); - }; - - // 为具体供应商渲染单个图标 - const renderVendorAvatar = (vendor) => ( -
- {vendor.icon ? - getLobeHubIcon(vendor.icon, 40) : - - {vendor.name === 'unknown' ? '?' : vendor.name.charAt(0).toUpperCase()} - - } -
- ); - - // 渲染搜索和操作区域 - const renderSearchActions = () => ( + const renderSearchActions = useCallback(() => ( - ); + ), [selectedRowKeys, copyText, handleChange, handleCompositionStart, handleCompositionEnd, isMobile, searchValue, setShowFilterModal, t]); + + const renderHeaderCard = useCallback(({ title, count, description, rightContent, primaryDarkerChannel }) => ( + +
+
+
+

+ {title} +

+ + {t('共 {{count}} 个模型', { count })} + +
+ handleOpenDescModal(description)} + > + {description} + +
+ +
+ {rightContent} +
+
+ + } + > + {renderSearchActions()} +
+ ), [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') { - return renderHeaderCard({ + const headerCard = renderHeaderCard({ title: t('全部供应商'), count: currentModelCount, description: getVendorDescription('all'), rightContent: renderAllVendorsAvatar(), - primaryDarkerChannel: '37 99 235' + primaryDarkerChannel: THEME_COLORS.allVendors.primary }); + return ( + <> + {headerCard} + {renderDescriptionModal()} + + ); } - // 具体供应商 const currentVendor = vendorInfo.find(v => v.name === filterVendor); if (!currentVendor) { return null; } - const vendorDisplayName = currentVendor.name === 'unknown' ? t('未知供应商') : currentVendor.name; + const vendorDisplayName = getVendorDisplayName(currentVendor.name, t); - return renderHeaderCard({ + const headerCard = renderHeaderCard({ title: vendorDisplayName, count: currentModelCount, description: currentVendor.description || getVendorDescription(currentVendor.name), - rightContent: renderVendorAvatar(currentVendor), - primaryDarkerChannel: '16 185 129' + rightContent: renderVendorAvatar(currentVendor, t, false), + primaryDarkerChannel: THEME_COLORS.specific.primary }); -}; + + return ( + <> + {headerCard} + {renderDescriptionModal()} + + ); +}); + +PricingVendorIntro.displayName = 'PricingVendorIntro'; export default PricingVendorIntro; \ No newline at end of file diff --git a/web/src/components/table/model-pricing/layout/header/PricingVendorIntroSkeleton.jsx b/web/src/components/table/model-pricing/layout/header/PricingVendorIntroSkeleton.jsx index 3befb88e..411b8642 100644 --- a/web/src/components/table/model-pricing/layout/header/PricingVendorIntroSkeleton.jsx +++ b/web/src/components/table/model-pricing/layout/header/PricingVendorIntroSkeleton.jsx @@ -17,120 +17,148 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import React from 'react'; +import React, { memo } from 'react'; import { Card, Skeleton } from '@douyinfe/semi-ui'; -const PricingVendorIntroSkeleton = ({ - isAllVendors = false -}) => { - // 统一的封面样式函数 - const getCoverStyle = (primaryDarkerChannel) => ({ - '--palette-primary-darkerChannel': primaryDarkerChannel, +const THEME_COLORS = { + allVendors: { + primary: '37 99 235', + background: 'rgba(59, 130, 246, 0.1)', + border: 'rgba(59, 130, 246, 0.2)' + }, + specific: { + primary: '16 185 129', + background: 'rgba(16, 185, 129, 0.1)', + border: 'rgba(16, 185, 129, 0.2)' + }, + neutral: { + background: 'rgba(156, 163, 175, 0.1)', + border: 'rgba(156, 163, 175, 0.2)' + } +}; + +const SIZES = { + title: { width: { all: 120, specific: 100 }, height: 24 }, + tag: { width: 80, height: 20 }, + description: { height: 14 }, + avatar: { width: 40, height: 40 }, + searchInput: { height: 32 }, + button: { width: 80, height: 32 } +}; + +const SKELETON_STYLES = { + cover: (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' - }); + }), + title: { + backgroundColor: 'rgba(255, 255, 255, 0.25)', + borderRadius: 8, + backdropFilter: 'blur(4px)' + }, + tag: { + backgroundColor: 'rgba(255, 255, 255, 0.2)', + borderRadius: 9999, + backdropFilter: 'blur(4px)', + border: '1px solid rgba(255,255,255,0.3)' + }, + description: { + backgroundColor: 'rgba(255, 255, 255, 0.2)', + borderRadius: 4, + backdropFilter: 'blur(4px)' + }, + avatar: (isAllVendors) => { + const colors = isAllVendors ? THEME_COLORS.allVendors : THEME_COLORS.specific; + return { + backgroundColor: colors.background, + borderRadius: 12, + border: `1px solid ${colors.border}` + }; + }, + searchInput: { + backgroundColor: THEME_COLORS.neutral.background, + borderRadius: 8, + border: `1px solid ${THEME_COLORS.neutral.border}` + }, + button: { + backgroundColor: THEME_COLORS.allVendors.background, + borderRadius: 8, + border: `1px solid ${THEME_COLORS.allVendors.border}` + } +}; - // 快速生成骨架矩形 - const rect = (style = {}, key) => ( -
- ); +const createSkeletonRect = (style = {}, key = null) => ( +
+); + +const PricingVendorIntroSkeleton = memo(({ + isAllVendors = false +}) => { const placeholder = (
- {/* 左侧:标题和描述骨架 */}
- {rect({ - width: isAllVendors ? 120 : 100, - height: 24, - backgroundColor: 'rgba(255, 255, 255, 0.25)', - borderRadius: 8, - backdropFilter: 'blur(4px)' - })} - {rect({ - width: 80, - height: 20, - backgroundColor: 'rgba(255, 255, 255, 0.2)', - borderRadius: 9999, - backdropFilter: 'blur(4px)', - border: '1px solid rgba(255,255,255,0.3)' - })} + {createSkeletonRect({ + ...SKELETON_STYLES.title, + width: isAllVendors ? SIZES.title.width.all : SIZES.title.width.specific, + height: SIZES.title.height + }, 'title')} + {createSkeletonRect({ + ...SKELETON_STYLES.tag, + width: SIZES.tag.width, + height: SIZES.tag.height + }, 'tag')}
- {rect({ + {createSkeletonRect({ + ...SKELETON_STYLES.description, width: '100%', - height: 14, - backgroundColor: 'rgba(255, 255, 255, 0.2)', - borderRadius: 4, - backdropFilter: 'blur(4px)' - })} - {rect({ - width: '75%', - height: 14, + height: SIZES.description.height + }, 'desc1')} + {createSkeletonRect({ + ...SKELETON_STYLES.description, backgroundColor: 'rgba(255, 255, 255, 0.15)', - borderRadius: 4, - backdropFilter: 'blur(4px)' - })} + width: '75%', + height: SIZES.description.height + }, 'desc2')}
- {/* 右侧:供应商图标骨架 */} -
- {isAllVendors ? ( -
- {Array.from({ length: 4 }).map((_, index) => ( - rect({ - width: 32, - height: 32, - backgroundColor: 'rgba(59, 130, 246, 0.1)', - borderRadius: 9999, - border: '1px solid rgba(59, 130, 246, 0.2)' - }, index) - ))} -
- ) : ( - rect({ - width: 40, - height: 40, - backgroundColor: 'rgba(16, 185, 129, 0.1)', - borderRadius: 12, - border: '1px solid rgba(16, 185, 129, 0.2)' - }) - )} +
+ {createSkeletonRect({ + ...SKELETON_STYLES.avatar(isAllVendors), + width: SIZES.avatar.width, + height: SIZES.avatar.height + }, 'avatar')}
} > - {/* 搜索和操作区域骨架 */} -
- {/* 搜索框骨架 */} +
- {rect({ + {createSkeletonRect({ + ...SKELETON_STYLES.searchInput, width: '100%', - height: 32, - backgroundColor: 'rgba(156, 163, 175, 0.1)', - borderRadius: 8, - border: '1px solid rgba(156, 163, 175, 0.2)' - })} + height: SIZES.searchInput.height + }, 'search')}
- {/* 操作按钮骨架 */} - {rect({ - width: 80, - height: 32, - backgroundColor: 'rgba(59, 130, 246, 0.1)', - borderRadius: 8, - border: '1px solid rgba(59, 130, 246, 0.2)' - })} + {createSkeletonRect({ + ...SKELETON_STYLES.button, + width: SIZES.button.width, + height: SIZES.button.height + }, 'button')}
); @@ -138,6 +166,8 @@ const PricingVendorIntroSkeleton = ({ return ( ); -}; +}); + +PricingVendorIntroSkeleton.displayName = 'PricingVendorIntroSkeleton'; export default PricingVendorIntroSkeleton; \ No newline at end of file diff --git a/web/src/components/table/model-pricing/layout/header/PricingVendorIntroWithSkeleton.jsx b/web/src/components/table/model-pricing/layout/header/PricingVendorIntroWithSkeleton.jsx index 572a9056..15b3392b 100644 --- a/web/src/components/table/model-pricing/layout/header/PricingVendorIntroWithSkeleton.jsx +++ b/web/src/components/table/model-pricing/layout/header/PricingVendorIntroWithSkeleton.jsx @@ -17,25 +17,15 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import React from 'react'; +import React, { memo } from 'react'; import PricingVendorIntro from './PricingVendorIntro'; import PricingVendorIntroSkeleton from './PricingVendorIntroSkeleton'; import { useMinimumLoadingTime } from '../../../../../hooks/common/useMinimumLoadingTime'; -const PricingVendorIntroWithSkeleton = ({ +const PricingVendorIntroWithSkeleton = memo(({ loading = false, filterVendor, - models, - allModels, - t, - selectedRowKeys, - copyText, - handleChange, - handleCompositionStart, - handleCompositionEnd, - isMobile, - searchValue, - setShowFilterModal + ...restProps }) => { const showSkeleton = useMinimumLoadingTime(loading); @@ -50,19 +40,11 @@ const PricingVendorIntroWithSkeleton = ({ return ( ); -}; +}); + +PricingVendorIntroWithSkeleton.displayName = 'PricingVendorIntroWithSkeleton'; export default PricingVendorIntroWithSkeleton; \ No newline at end of file diff --git a/web/src/components/table/model-pricing/layout/header/SearchActions.jsx b/web/src/components/table/model-pricing/layout/header/SearchActions.jsx index 390577a1..cfec43e8 100644 --- a/web/src/components/table/model-pricing/layout/header/SearchActions.jsx +++ b/web/src/components/table/model-pricing/layout/header/SearchActions.jsx @@ -17,11 +17,11 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import React from 'react'; +import React, { memo, useCallback } from 'react'; import { Input, Button } from '@douyinfe/semi-ui'; import { IconSearch, IconCopy, IconFilter } from '@douyinfe/semi-icons'; -const SearchActions = ({ +const SearchActions = memo(({ selectedRowKeys = [], copyText, handleChange, @@ -32,9 +32,18 @@ const SearchActions = ({ setShowFilterModal, t }) => { + const handleCopyClick = useCallback(() => { + if (copyText && selectedRowKeys.length > 0) { + copyText(selectedRowKeys); + } + }, [copyText, selectedRowKeys]); + + const handleFilterClick = useCallback(() => { + setShowFilterModal?.(true); + }, [setShowFilterModal]); + return ( -
- {/* 搜索框 */} +
} @@ -47,33 +56,31 @@ const SearchActions = ({ />
- {/* 操作按钮 */} - {/* 移动端筛选按钮 */} {isMobile && ( )}
); -}; - -export default SearchActions; +}); +SearchActions.displayName = 'SearchActions'; +export default SearchActions; \ No newline at end of file diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 234b47fc..ee38bc39 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -1814,6 +1814,7 @@ "匹配类型": "Matching type", "描述": "Description", "供应商": "Vendor", + "供应商介绍": "Vendor introduction", "端点": "Endpoint", "已绑定渠道": "Bound channels", "更新时间": "Update time",