From 1880164e29dbb6831151e43d4684b01e11e1ff81 Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Thu, 24 Jul 2025 17:10:08 +0800 Subject: [PATCH] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Refactor:=20Move=20token?= =?UTF-8?q?=20unit=20toggle=20from=20table=20header=20to=20filter=20settin?= =?UTF-8?q?gs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove K/M switch from model price column header in pricing table - Add "Display in K units" option to pricing display settings panel - Update parameter passing for tokenUnit and setTokenUnit across components: - PricingDisplaySettings: Add tokenUnit toggle functionality - PricingSidebar: Pass tokenUnit props to display settings - PricingFilterModal: Include tokenUnit in mobile filter modal - Enhance resetPricingFilters utility to reset token unit to default 'M' - Clean up PricingTableColumns by removing unused setTokenUnit parameter - Add English translation for "按K显示单位" as "Display in K units" This change improves UX by consolidating all display-related controls in the filter settings panel, making the interface more organized and the token unit setting more discoverable alongside other display options. Affected components: - PricingTableColumns.js - PricingDisplaySettings.jsx - PricingSidebar.jsx - PricingFilterModal.jsx - PricingTable.jsx - utils.js (resetPricingFilters) - en.json (translations) --- web/src/components/common/ui/CardTable.js | 22 +- .../common/ui/SelectableButtonGroup.jsx | 28 +- web/src/components/layout/HeaderBar.js | 19 +- .../filter/PricingDisplaySettings.jsx | 10 + .../model-pricing/layout/PricingPage.jsx | 2 +- .../model-pricing/layout/PricingSidebar.jsx | 5 + .../layout/{ => content}/PricingContent.jsx | 6 +- .../layout/{ => content}/PricingView.jsx | 4 +- .../layout/header/PricingCategoryIntro.jsx | 228 +++++++++ .../header/PricingCategoryIntroSkeleton.jsx | 75 +++ .../PricingCategoryIntroWithSkeleton.jsx | 54 +++ .../PricingTopSection.jsx} | 23 +- .../modal/PricingFilterModal.jsx | 5 + .../model-pricing/view/PricingCardView.jsx | 444 ------------------ .../view/card/PricingCardSkeleton.jsx | 137 ++++++ .../view/card/PricingCardView.jsx | 321 +++++++++++++ .../view/{ => table}/PricingTable.jsx | 2 - .../view/{ => table}/PricingTableColumns.js | 18 +- .../table/usage-logs/UsageLogsActions.jsx | 23 +- web/src/helpers/utils.js | 25 +- web/src/hooks/common/useMinimumLoadingTime.js | 50 ++ web/src/hooks/dashboard/useDashboardData.js | 11 +- .../model-pricing/useModelPricingData.js | 7 +- web/src/i18n/locales/en.json | 3 +- 24 files changed, 963 insertions(+), 559 deletions(-) rename web/src/components/table/model-pricing/layout/{ => content}/PricingContent.jsx (85%) rename web/src/components/table/model-pricing/layout/{ => content}/PricingView.jsx (88%) create mode 100644 web/src/components/table/model-pricing/layout/header/PricingCategoryIntro.jsx create mode 100644 web/src/components/table/model-pricing/layout/header/PricingCategoryIntroSkeleton.jsx create mode 100644 web/src/components/table/model-pricing/layout/header/PricingCategoryIntroWithSkeleton.jsx rename web/src/components/table/model-pricing/layout/{PricingSearchBar.jsx => header/PricingTopSection.jsx} (80%) delete mode 100644 web/src/components/table/model-pricing/view/PricingCardView.jsx create mode 100644 web/src/components/table/model-pricing/view/card/PricingCardSkeleton.jsx create mode 100644 web/src/components/table/model-pricing/view/card/PricingCardView.jsx rename web/src/components/table/model-pricing/view/{ => table}/PricingTable.jsx (98%) rename web/src/components/table/model-pricing/view/{ => table}/PricingTableColumns.js (91%) create mode 100644 web/src/hooks/common/useMinimumLoadingTime.js diff --git a/web/src/components/common/ui/CardTable.js b/web/src/components/common/ui/CardTable.js index bb80046d..f91ff200 100644 --- a/web/src/components/common/ui/CardTable.js +++ b/web/src/components/common/ui/CardTable.js @@ -23,6 +23,7 @@ import { Table, Card, Skeleton, Pagination, Empty, Button, Collapsible } from '@ import { IconChevronDown, IconChevronUp } from '@douyinfe/semi-icons'; import PropTypes from 'prop-types'; import { useIsMobile } from '../../../hooks/common/useIsMobile'; +import { useMinimumLoadingTime } from '../../../hooks/common/useMinimumLoadingTime'; /** * CardTable 响应式表格组件 @@ -40,25 +41,8 @@ const CardTable = ({ }) => { const isMobile = useIsMobile(); const { t } = useTranslation(); - - const [showSkeleton, setShowSkeleton] = useState(loading); - const loadingStartRef = useRef(Date.now()); - - useEffect(() => { - if (loading) { - loadingStartRef.current = Date.now(); - setShowSkeleton(true); - } else { - const elapsed = Date.now() - loadingStartRef.current; - const remaining = Math.max(0, 1000 - elapsed); - if (remaining === 0) { - setShowSkeleton(false); - } else { - const timer = setTimeout(() => setShowSkeleton(false), remaining); - return () => clearTimeout(timer); - } - } - }, [loading]); + + const showSkeleton = useMinimumLoadingTime(loading); const getRowKey = (record, index) => { if (typeof rowKey === 'function') return rowKey(record); diff --git a/web/src/components/common/ui/SelectableButtonGroup.jsx b/web/src/components/common/ui/SelectableButtonGroup.jsx index c3fe28ff..6792c5aa 100644 --- a/web/src/components/common/ui/SelectableButtonGroup.jsx +++ b/web/src/components/common/ui/SelectableButtonGroup.jsx @@ -17,8 +17,9 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import React, { useState, useRef, useEffect } from 'react'; +import React, { useState, useRef } from 'react'; import { useIsMobile } from '../../../hooks/common/useIsMobile'; +import { useMinimumLoadingTime } from '../../../hooks/common/useMinimumLoadingTime'; import { Divider, Button, Tag, Row, Col, Collapsible, Checkbox, Skeleton } from '@douyinfe/semi-ui'; import { IconChevronDown, IconChevronUp } from '@douyinfe/semi-icons'; @@ -49,32 +50,15 @@ const SelectableButtonGroup = ({ loading = false }) => { const [isOpen, setIsOpen] = useState(false); - const [showSkeleton, setShowSkeleton] = useState(loading); const [skeletonCount] = useState(6); const isMobile = useIsMobile(); const perRow = 3; const maxVisibleRows = Math.max(1, Math.floor(collapseHeight / 32)); // Approx row height 32 const needCollapse = collapsible && items.length > perRow * maxVisibleRows; - const loadingStartRef = useRef(Date.now()); + const showSkeleton = useMinimumLoadingTime(loading); const contentRef = useRef(null); - useEffect(() => { - if (loading) { - loadingStartRef.current = Date.now(); - setShowSkeleton(true); - } else { - const elapsed = Date.now() - loadingStartRef.current; - const remaining = Math.max(0, 1000 - elapsed); - if (remaining === 0) { - setShowSkeleton(false); - } else { - const timer = setTimeout(() => setShowSkeleton(false), remaining); - return () => clearTimeout(timer); - } - } - }, [loading]); - const maskStyle = isOpen ? {} : { @@ -110,7 +94,7 @@ const SelectableButtonGroup = ({ @@ -158,7 +142,7 @@ const SelectableButtonGroup = ({ @@ -197,7 +181,7 @@ const SelectableButtonGroup = ({ diff --git a/web/src/components/layout/HeaderBar.js b/web/src/components/layout/HeaderBar.js index a935da12..13cbf092 100644 --- a/web/src/components/layout/HeaderBar.js +++ b/web/src/components/layout/HeaderBar.js @@ -52,6 +52,7 @@ import { import { StatusContext } from '../../context/Status/index.js'; import { useIsMobile } from '../../hooks/common/useIsMobile.js'; import { useSidebarCollapsed } from '../../hooks/common/useSidebarCollapsed.js'; +import { useMinimumLoadingTime } from '../../hooks/common/useMinimumLoadingTime.js'; const HeaderBar = ({ onMobileMenuToggle, drawerOpen }) => { const { t, i18n } = useTranslation(); @@ -59,7 +60,6 @@ const HeaderBar = ({ onMobileMenuToggle, drawerOpen }) => { const [statusState, statusDispatch] = useContext(StatusContext); const isMobile = useIsMobile(); const [collapsed, toggleCollapsed] = useSidebarCollapsed(); - const [isLoading, setIsLoading] = useState(true); const [logoLoaded, setLogoLoaded] = useState(false); let navigate = useNavigate(); const [currentLang, setCurrentLang] = useState(i18n.language); @@ -67,7 +67,9 @@ const HeaderBar = ({ onMobileMenuToggle, drawerOpen }) => { const location = useLocation(); const [noticeVisible, setNoticeVisible] = useState(false); const [unreadCount, setUnreadCount] = useState(0); - const loadingStartRef = useRef(Date.now()); + + const loading = statusState?.status === undefined; + const isLoading = useMinimumLoadingTime(loading); const systemName = getSystemName(); const logo = getLogo(); @@ -128,7 +130,7 @@ const HeaderBar = ({ onMobileMenuToggle, drawerOpen }) => { to: '/console', }, { - text: t('定价'), + text: t('模型广场'), itemKey: 'pricing', to: '/pricing', }, @@ -216,17 +218,6 @@ const HeaderBar = ({ onMobileMenuToggle, drawerOpen }) => { }; }, [i18n]); - useEffect(() => { - if (statusState?.status !== undefined) { - const elapsed = Date.now() - loadingStartRef.current; - const remaining = Math.max(0, 1000 - elapsed); - const timer = setTimeout(() => { - setIsLoading(false); - }, remaining); - return () => clearTimeout(timer); - } - }, [statusState?.status]); - useEffect(() => { setLogoLoaded(false); if (!logo) return; diff --git a/web/src/components/table/model-pricing/filter/PricingDisplaySettings.jsx b/web/src/components/table/model-pricing/filter/PricingDisplaySettings.jsx index 321450a3..05942279 100644 --- a/web/src/components/table/model-pricing/filter/PricingDisplaySettings.jsx +++ b/web/src/components/table/model-pricing/filter/PricingDisplaySettings.jsx @@ -31,6 +31,8 @@ const PricingDisplaySettings = ({ setShowRatio, viewMode, setViewMode, + tokenUnit, + setTokenUnit, loading = false, t }) => { @@ -56,6 +58,10 @@ const PricingDisplaySettings = ({ { value: 'tableView', label: t('表格视图') + }, + { + value: 'tokenUnit', + label: t('按K显示单位') } ]; @@ -75,6 +81,9 @@ const PricingDisplaySettings = ({ case 'tableView': setViewMode(viewMode === 'table' ? 'card' : 'table'); break; + case 'tokenUnit': + setTokenUnit(tokenUnit === 'K' ? 'M' : 'K'); + break; } }; @@ -83,6 +92,7 @@ const PricingDisplaySettings = ({ if (showWithRecharge) activeValues.push('recharge'); if (showRatio) activeValues.push('ratio'); if (viewMode === 'table') activeValues.push('tableView'); + if (tokenUnit === 'K') activeValues.push('tokenUnit'); return activeValues; }; diff --git a/web/src/components/table/model-pricing/layout/PricingPage.jsx b/web/src/components/table/model-pricing/layout/PricingPage.jsx index 0f150122..5db359b3 100644 --- a/web/src/components/table/model-pricing/layout/PricingPage.jsx +++ b/web/src/components/table/model-pricing/layout/PricingPage.jsx @@ -20,7 +20,7 @@ For commercial licensing, please contact support@quantumnous.com import React from 'react'; import { Layout, ImagePreview } from '@douyinfe/semi-ui'; import PricingSidebar from './PricingSidebar'; -import PricingContent from './PricingContent'; +import PricingContent from './content/PricingContent'; import { useModelPricingData } from '../../../../hooks/model-pricing/useModelPricingData'; import { useIsMobile } from '../../../../hooks/common/useIsMobile'; diff --git a/web/src/components/table/model-pricing/layout/PricingSidebar.jsx b/web/src/components/table/model-pricing/layout/PricingSidebar.jsx index f503e246..a3e275c6 100644 --- a/web/src/components/table/model-pricing/layout/PricingSidebar.jsx +++ b/web/src/components/table/model-pricing/layout/PricingSidebar.jsx @@ -45,6 +45,8 @@ const PricingSidebar = ({ setFilterEndpointType, currentPage, setCurrentPage, + tokenUnit, + setTokenUnit, loading, t, ...categoryProps @@ -63,6 +65,7 @@ const PricingSidebar = ({ setFilterQuotaType, setFilterEndpointType, setCurrentPage, + setTokenUnit, }); return ( @@ -90,6 +93,8 @@ const PricingSidebar = ({ setShowRatio={setShowRatio} viewMode={viewMode} setViewMode={setViewMode} + tokenUnit={tokenUnit} + setTokenUnit={setTokenUnit} loading={loading} t={t} /> diff --git a/web/src/components/table/model-pricing/layout/PricingContent.jsx b/web/src/components/table/model-pricing/layout/content/PricingContent.jsx similarity index 85% rename from web/src/components/table/model-pricing/layout/PricingContent.jsx rename to web/src/components/table/model-pricing/layout/content/PricingContent.jsx index edb97514..177d104c 100644 --- a/web/src/components/table/model-pricing/layout/PricingContent.jsx +++ b/web/src/components/table/model-pricing/layout/content/PricingContent.jsx @@ -18,15 +18,15 @@ For commercial licensing, please contact support@quantumnous.com */ import React from 'react'; -import PricingSearchBar from './PricingSearchBar'; +import PricingTopSection from '../header/PricingTopSection'; import PricingView from './PricingView'; const PricingContent = ({ isMobile, sidebarProps, ...props }) => { return (
- {/* 固定的搜索和操作区域 */} + {/* 固定的顶部区域(分类介绍 + 搜索和操作) */}
- +
{/* 可滚动的内容区域 */} diff --git a/web/src/components/table/model-pricing/layout/PricingView.jsx b/web/src/components/table/model-pricing/layout/content/PricingView.jsx similarity index 88% rename from web/src/components/table/model-pricing/layout/PricingView.jsx rename to web/src/components/table/model-pricing/layout/content/PricingView.jsx index 16e9db99..e25d0f47 100644 --- a/web/src/components/table/model-pricing/layout/PricingView.jsx +++ b/web/src/components/table/model-pricing/layout/content/PricingView.jsx @@ -18,8 +18,8 @@ For commercial licensing, please contact support@quantumnous.com */ import React from 'react'; -import PricingTable from '../view/PricingTable'; -import PricingCardView from '../view/PricingCardView'; +import PricingTable from '../../view/table/PricingTable'; +import PricingCardView from '../../view/card/PricingCardView'; const PricingView = ({ viewMode = 'table', diff --git a/web/src/components/table/model-pricing/layout/header/PricingCategoryIntro.jsx b/web/src/components/table/model-pricing/layout/header/PricingCategoryIntro.jsx new file mode 100644 index 00000000..df1e3c97 --- /dev/null +++ b/web/src/components/table/model-pricing/layout/header/PricingCategoryIntro.jsx @@ -0,0 +1,228 @@ +/* +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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React, { useState, useEffect } from 'react'; +import { Card, Tag, Avatar, AvatarGroup } from '@douyinfe/semi-ui'; + +const PricingCategoryIntro = ({ + activeKey, + modelCategories, + categoryCounts, + availableCategories, + t +}) => { + // 轮播动效状态(只对全部模型生效) + const [currentOffset, setCurrentOffset] = useState(0); + + // 获取除了 'all' 之外的可用分类 + const validCategories = (availableCategories || []).filter(key => key !== 'all'); + + // 设置轮播定时器(只对全部模型且有足够头像时生效) + useEffect(() => { + if (activeKey !== 'all' || validCategories.length <= 3) { + setCurrentOffset(0); // 重置偏移 + return; + } + + const interval = setInterval(() => { + setCurrentOffset(prev => (prev + 1) % validCategories.length); + }, 2000); // 每2秒切换一次 + + return () => clearInterval(interval); + }, [activeKey, validCategories.length]); + + // 如果没有有效的分类键或分类数据,不显示 + if (!activeKey || !modelCategories) { + return null; + } + + const modelCount = categoryCounts[activeKey] || 0; + + // 获取分类描述信息 + const getCategoryDescription = (categoryKey) => { + const descriptions = { + all: t('查看所有可用的AI模型,包括文本生成、图像处理、音频转换等多种类型的模型。'), + openai: t('令牌分发介绍:SSVIP 为纯OpenAI官方。SVIP 为纯Azure。Default 为Azure 消费。VIP为近似的复数。VVIP为近似的书发。'), + anthropic: t('Anthropic Claude系列模型,以安全性和可靠性著称,擅长对话、分析和创作任务。'), + gemini: t('Google Gemini系列模型,具备强大的多模态能力,支持文本、图像和代码理解。'), + moonshot: t('月之暗面Moonshot系列模型,专注于长文本处理和深度理解能力。'), + zhipu: t('智谱AI ChatGLM系列模型,在中文理解和生成方面表现优秀。'), + qwen: t('阿里云通义千问系列模型,覆盖多个领域的智能问答和内容生成。'), + deepseek: t('DeepSeek系列模型,在代码生成和数学推理方面具有出色表现。'), + minimax: t('MiniMax ABAB系列模型,专注于对话和内容创作的AI助手。'), + baidu: t('百度文心一言系列模型,在中文自然语言处理方面具有强大能力。'), + xunfei: t('科大讯飞星火系列模型,在语音识别和自然语言理解方面领先。'), + midjourney: t('Midjourney图像生成模型,专业的AI艺术创作和图像生成服务。'), + tencent: t('腾讯混元系列模型,提供全面的AI能力和企业级服务。'), + cohere: t('Cohere Command系列模型,专注于企业级自然语言处理应用。'), + cloudflare: t('Cloudflare Workers AI模型,提供边缘计算和高性能AI服务。'), + ai360: t('360智脑系列模型,在安全和智能助手方面具有独特优势。'), + yi: t('零一万物Yi系列模型,提供高质量的多语言理解和生成能力。'), + jina: t('Jina AI模型,专注于嵌入和向量搜索的AI解决方案。'), + mistral: t('Mistral AI系列模型,欧洲领先的开源大语言模型。'), + xai: t('xAI Grok系列模型,具有独特的幽默感和实时信息处理能力。'), + llama: t('Meta Llama系列模型,开源的大语言模型,在各种任务中表现优秀。'), + doubao: t('字节跳动豆包系列模型,在内容创作和智能对话方面表现出色。'), + }; + return descriptions[categoryKey] || t('该分类包含多种AI模型,适用于不同的应用场景。'); + }; + + // 为全部模型创建特殊的头像组合 + const renderAllModelsAvatar = () => { + // 重新排列数组,让当前偏移量的头像在第一位 + const rotatedCategories = validCategories.length > 3 ? [ + ...validCategories.slice(currentOffset), + ...validCategories.slice(0, currentOffset) + ] : validCategories; + + // 如果没有有效分类,使用模型分类名称的前两个字符 + if (validCategories.length === 0) { + // 获取所有分类(除了 'all')的名称前两个字符 + const fallbackCategories = Object.entries(modelCategories) + .filter(([key]) => key !== 'all') + .slice(0, 3) + .map(([key, category]) => ({ + key, + label: category.label, + text: category.label.slice(0, 2) || key.slice(0, 2).toUpperCase() + })); + + return ( +
+ + {fallbackCategories.map((item) => ( + + {item.text} + + ))} + +
+ ); + } + + return ( +
+ ( + + {`+${restNumber}`} + + )} + > + {rotatedCategories.map((categoryKey) => { + const category = modelCategories[categoryKey]; + + return ( + + {category?.icon ? + React.cloneElement(category.icon, { size: 20 }) : + (category?.label?.charAt(0) || categoryKey.charAt(0).toUpperCase()) + } + + ); + })} + +
+ ); + }; + + // 为具体分类渲染单个图标 + const renderCategoryAvatar = (category) => ( +
+ {category.icon && React.cloneElement(category.icon, { size: 40 })} +
+ ); + + // 如果是全部模型分类 + if (activeKey === 'all') { + return ( +
+ +
+ {/* 全部模型的头像组合 */} + {renderAllModelsAvatar()} + + {/* 分类信息 */} +
+
+

{modelCategories.all.label}

+ + {t('共 {{count}} 个模型', { count: modelCount })} + +
+

+ {getCategoryDescription(activeKey)} +

+
+
+
+
+ ); + } + + // 具体分类 + const currentCategory = modelCategories[activeKey]; + if (!currentCategory) { + return null; + } + + return ( +
+ +
+ {/* 分类图标 */} + {renderCategoryAvatar(currentCategory)} + + {/* 分类信息 */} +
+
+

{currentCategory.label}

+ + {t('共 {{count}} 个模型', { count: modelCount })} + +
+

+ {getCategoryDescription(activeKey)} +

+
+
+
+
+ ); +}; + +export default PricingCategoryIntro; \ No newline at end of file diff --git a/web/src/components/table/model-pricing/layout/header/PricingCategoryIntroSkeleton.jsx b/web/src/components/table/model-pricing/layout/header/PricingCategoryIntroSkeleton.jsx new file mode 100644 index 00000000..06d029ef --- /dev/null +++ b/web/src/components/table/model-pricing/layout/header/PricingCategoryIntroSkeleton.jsx @@ -0,0 +1,75 @@ +/* +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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React from 'react'; +import { Card, Skeleton } from '@douyinfe/semi-ui'; + +const PricingCategoryIntroSkeleton = ({ + isAllModels = false +}) => { + const placeholder = ( +
+ +
+ {/* 分类图标骨架 */} +
+ {isAllModels ? ( +
+ {Array.from({ length: 5 }).map((_, index) => ( + + ))} +
+ ) : ( + + )} +
+ + {/* 分类信息骨架 */} +
+
+ + +
+ +
+
+
+
+ ); + + return ( + + ); +}; + +export default PricingCategoryIntroSkeleton; \ No newline at end of file diff --git a/web/src/components/table/model-pricing/layout/header/PricingCategoryIntroWithSkeleton.jsx b/web/src/components/table/model-pricing/layout/header/PricingCategoryIntroWithSkeleton.jsx new file mode 100644 index 00000000..fbb7113a --- /dev/null +++ b/web/src/components/table/model-pricing/layout/header/PricingCategoryIntroWithSkeleton.jsx @@ -0,0 +1,54 @@ +/* +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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React from 'react'; +import PricingCategoryIntro from './PricingCategoryIntro'; +import PricingCategoryIntroSkeleton from './PricingCategoryIntroSkeleton'; +import { useMinimumLoadingTime } from '../../../../../hooks/common/useMinimumLoadingTime'; + +const PricingCategoryIntroWithSkeleton = ({ + loading = false, + activeKey, + modelCategories, + categoryCounts, + availableCategories, + t +}) => { + const showSkeleton = useMinimumLoadingTime(loading); + + if (showSkeleton) { + return ( + + ); + } + + return ( + + ); +}; + +export default PricingCategoryIntroWithSkeleton; \ No newline at end of file diff --git a/web/src/components/table/model-pricing/layout/PricingSearchBar.jsx b/web/src/components/table/model-pricing/layout/header/PricingTopSection.jsx similarity index 80% rename from web/src/components/table/model-pricing/layout/PricingSearchBar.jsx rename to web/src/components/table/model-pricing/layout/header/PricingTopSection.jsx index 8b223252..dbdee4f9 100644 --- a/web/src/components/table/model-pricing/layout/PricingSearchBar.jsx +++ b/web/src/components/table/model-pricing/layout/header/PricingTopSection.jsx @@ -20,9 +20,10 @@ For commercial licensing, please contact support@quantumnous.com import React, { useMemo, useState } from 'react'; import { Input, Button } from '@douyinfe/semi-ui'; import { IconSearch, IconCopy, IconFilter } from '@douyinfe/semi-icons'; -import PricingFilterModal from '../modal/PricingFilterModal'; +import PricingFilterModal from '../../modal/PricingFilterModal'; +import PricingCategoryIntroWithSkeleton from './PricingCategoryIntroWithSkeleton'; -const PricingSearchBar = ({ +const PricingTopSection = ({ selectedRowKeys, copyText, handleChange, @@ -30,6 +31,11 @@ const PricingSearchBar = ({ handleCompositionEnd, isMobile, sidebarProps, + activeKey, + modelCategories, + categoryCounts, + availableCategories, + loading, t }) => { const [showFilterModal, setShowFilterModal] = useState(false); @@ -76,6 +82,17 @@ const PricingSearchBar = ({ return ( <> + {/* 分类介绍区域(含骨架屏) */} + + + {/* 搜索和操作区域 */} {SearchAndActions} {/* 移动端筛选Modal */} @@ -91,4 +108,4 @@ const PricingSearchBar = ({ ); }; -export default PricingSearchBar; \ No newline at end of file +export default PricingTopSection; \ No newline at end of file diff --git a/web/src/components/table/model-pricing/modal/PricingFilterModal.jsx b/web/src/components/table/model-pricing/modal/PricingFilterModal.jsx index ff8459d4..1b1be43c 100644 --- a/web/src/components/table/model-pricing/modal/PricingFilterModal.jsx +++ b/web/src/components/table/model-pricing/modal/PricingFilterModal.jsx @@ -51,6 +51,8 @@ const PricingFilterModal = ({ setFilterEndpointType, currentPage, setCurrentPage, + tokenUnit, + setTokenUnit, loading, ...categoryProps } = sidebarProps; @@ -68,6 +70,7 @@ const PricingFilterModal = ({ setFilterQuotaType, setFilterEndpointType, setCurrentPage, + setTokenUnit, }); const footer = ( @@ -114,6 +117,8 @@ const PricingFilterModal = ({ setShowRatio={setShowRatio} viewMode={viewMode} setViewMode={setViewMode} + tokenUnit={tokenUnit} + setTokenUnit={setTokenUnit} loading={loading} t={t} /> diff --git a/web/src/components/table/model-pricing/view/PricingCardView.jsx b/web/src/components/table/model-pricing/view/PricingCardView.jsx deleted file mode 100644 index 4d7c3d3b..00000000 --- a/web/src/components/table/model-pricing/view/PricingCardView.jsx +++ /dev/null @@ -1,444 +0,0 @@ -/* -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 . - -For commercial licensing, please contact support@quantumnous.com -*/ - -import React, { useState, useRef, useEffect } from 'react'; -import { Card, Tag, Tooltip, Checkbox, Empty, Pagination, Button, Skeleton } from '@douyinfe/semi-ui'; -import { IconHelpCircle, IconCopy } from '@douyinfe/semi-icons'; -import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations'; -import { stringToColor, getModelCategories, calculateModelPrice, formatPriceInfo } from '../../../../helpers'; - -const PricingCardView = ({ - filteredModels, - loading, - rowSelection, - pageSize, - setPageSize, - currentPage, - setCurrentPage, - selectedGroup, - groupRatio, - copyText, - setModalImageUrl, - setIsModalOpenurl, - currency, - tokenUnit, - setTokenUnit, - displayPrice, - showRatio, - t -}) => { - const [showSkeleton, setShowSkeleton] = useState(loading); - const [skeletonCount] = useState(10); - const loadingStartRef = useRef(Date.now()); - - useEffect(() => { - if (loading) { - loadingStartRef.current = Date.now(); - setShowSkeleton(true); - } else { - const elapsed = Date.now() - loadingStartRef.current; - const remaining = Math.max(0, 1000 - elapsed); - if (remaining === 0) { - setShowSkeleton(false); - } else { - const timer = setTimeout(() => setShowSkeleton(false), remaining); - return () => clearTimeout(timer); - } - } - }, [loading]); - - // 计算当前页面要显示的数据 - const startIndex = (currentPage - 1) * pageSize; - const endIndex = startIndex + pageSize; - const paginatedModels = filteredModels.slice(startIndex, endIndex); - - // 渲染骨架屏卡片 - const renderSkeletonCards = () => { - const placeholder = ( -
-
- {Array.from({ length: skeletonCount }).map((_, index) => ( - - {/* 头部:图标 + 模型名称 + 操作按钮 */} -
-
- {/* 模型图标骨架 */} -
- -
- {/* 模型名称骨架 */} -
- -
-
- -
- {/* 操作按钮骨架 */} - - {rowSelection && ( - - )} -
-
- - {/* 价格信息骨架 */} -
- -
- - {/* 模型描述骨架 */} -
- -
- - {/* 标签区域骨架 */} -
- {Array.from({ length: 3 + (index % 2) }).map((_, tagIndex) => ( - - ))} -
- - {/* 倍率信息骨架(可选) */} - {showRatio && ( -
-
- - -
-
- {Array.from({ length: 3 }).map((_, ratioIndex) => ( - - ))} -
-
- )} -
- ))} -
- - {/* 分页骨架 */} -
- -
-
- ); - - return ( - - ); - }; - - // 获取模型图标 - const getModelIcon = (modelName) => { - const categories = getModelCategories(t); - let icon = null; - - // 遍历分类,找到匹配的模型图标 - for (const [key, category] of Object.entries(categories)) { - if (key !== 'all' && category.filter({ model_name: modelName })) { - icon = category.icon; - break; - } - } - - // 如果找到了匹配的图标,返回包装后的图标 - if (icon) { - return ( -
-
- {React.cloneElement(icon, { size: 32 })} -
-
- ); - } - - // 默认图标(如果没有匹配到任何分类) - return ( -
- {/* 默认的螺旋图案 */} - - - -
- ); - }; - - // 获取模型描述 - const getModelDescription = (modelName) => { - // 根据模型名称返回描述,这里可以扩展 - if (modelName.includes('gpt-3.5-turbo')) { - return t('该模型目前指向gpt-35-turbo-0125模型,综合能力强,过去使用最广泛的文本模型。'); - } - if (modelName.includes('gpt-4')) { - return t('更强大的GPT-4模型,具有更好的推理能力和更准确的输出。'); - } - if (modelName.includes('claude')) { - return t('Anthropic开发的Claude模型,以安全性和有用性著称。'); - } - return t('高性能AI模型,适用于各种文本生成和理解任务。'); - }; - - // 渲染价格信息 - const renderPriceInfo = (record) => { - const priceData = calculateModelPrice({ - record, - selectedGroup, - groupRatio, - tokenUnit, - displayPrice, - currency, - precision: 4 - }); - return formatPriceInfo(priceData, t); - }; - - // 渲染标签 - const renderTags = (record) => { - const tags = []; - - // 计费类型标签 - if (record.quota_type === 1) { - tags.push( - - {t('按次计费')} - - ); - } else { - tags.push( - - {t('按量计费')} - - ); - } - - // 热度标签(示例) - if (record.model_name.includes('gpt-3.5-turbo') || record.model_name.includes('gpt-4')) { - tags.push( - - {t('热')} - - ); - } - - // 端点类型标签 - if (record.supported_endpoint_types && record.supported_endpoint_types.length > 0) { - record.supported_endpoint_types.slice(0, 2).forEach((endpoint, index) => { - tags.push( - - {endpoint} - - ); - }); - } - - // 上下文长度标签(示例) - if (record.model_name.includes('16k')) { - tags.push(16K); - } else if (record.model_name.includes('32k')) { - tags.push(32K); - } else { - tags.push(4K); - } - - return tags; - }; - - // 显示骨架屏 - if (showSkeleton) { - return renderSkeletonCards(); - } - - if (!filteredModels || filteredModels.length === 0) { - return ( -
- } - darkModeImage={} - description={t('搜索无结果')} - /> -
- ); - } - - return ( -
-
- {paginatedModels.map((model, index) => { - const isSelected = rowSelection?.selectedRowKeys?.includes(model.id); - - return ( - - {/* 头部:图标 + 模型名称 + 操作按钮 */} -
-
- {getModelIcon(model.model_name)} -
-

- {model.model_name} -

-
-
- -
- {/* 复制按钮 */} -
-
- - {/* 价格信息 */} -
-
- {renderPriceInfo(model)} -
-
- - {/* 模型描述 */} -
-

- {getModelDescription(model.model_name)} -

-
- - {/* 标签区域 */} -
- {renderTags(model)} -
- - {/* 倍率信息(可选) */} - {showRatio && ( -
-
- {t('倍率信息')} - - { - setModalImageUrl('/ratio.png'); - setIsModalOpenurl(true); - }} - /> - -
-
-
- {t('模型')}: {model.quota_type === 0 ? model.model_ratio : t('无')} -
-
- {t('补全')}: {model.quota_type === 0 ? parseFloat(model.completion_ratio.toFixed(3)) : t('无')} -
-
- {t('分组')}: {groupRatio[selectedGroup]} -
-
-
- )} -
- ); - })} -
- - {/* 分页 */} - {filteredModels.length > 0 && ( -
- setCurrentPage(page)} - onPageSizeChange={(size) => { - setPageSize(size); - setCurrentPage(1); - }} - /> -
- )} -
- ); -}; - -export default PricingCardView; \ No newline at end of file diff --git a/web/src/components/table/model-pricing/view/card/PricingCardSkeleton.jsx b/web/src/components/table/model-pricing/view/card/PricingCardSkeleton.jsx new file mode 100644 index 00000000..13eb5ecc --- /dev/null +++ b/web/src/components/table/model-pricing/view/card/PricingCardSkeleton.jsx @@ -0,0 +1,137 @@ +/* +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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React from 'react'; +import { Card, Skeleton } from '@douyinfe/semi-ui'; + +const PricingCardSkeleton = ({ + skeletonCount = 10, + rowSelection = false, + showRatio = false +}) => { + const placeholder = ( +
+
+ {Array.from({ length: skeletonCount }).map((_, index) => ( + + {/* 头部:图标 + 模型名称 + 操作按钮 */} +
+
+ {/* 模型图标骨架 */} +
+ +
+ {/* 模型名称和价格区域 */} +
+ {/* 模型名称骨架 */} + + {/* 价格信息骨架 */} + +
+
+ +
+ {/* 复制按钮骨架 */} + + {/* 勾选框骨架 */} + {rowSelection && ( + + )} +
+
+ + {/* 模型描述骨架 */} +
+ +
+ + {/* 标签区域骨架 */} +
+ {Array.from({ length: 2 + (index % 3) }).map((_, tagIndex) => ( + + ))} +
+ + {/* 倍率信息骨架(可选) */} + {showRatio && ( +
+
+ + +
+
+ {Array.from({ length: 3 }).map((_, ratioIndex) => ( + + ))} +
+
+ )} +
+ ))} +
+ + {/* 分页骨架 */} +
+ +
+
+ ); + + return ( + + ); +}; + +export default PricingCardSkeleton; \ No newline at end of file diff --git a/web/src/components/table/model-pricing/view/card/PricingCardView.jsx b/web/src/components/table/model-pricing/view/card/PricingCardView.jsx new file mode 100644 index 00000000..b1868cee --- /dev/null +++ b/web/src/components/table/model-pricing/view/card/PricingCardView.jsx @@ -0,0 +1,321 @@ +/* +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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React from 'react'; +import { Card, Tag, Tooltip, Checkbox, Empty, Pagination, Button, Avatar } from '@douyinfe/semi-ui'; +import { IconHelpCircle, IconCopy } from '@douyinfe/semi-icons'; +import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations'; +import { stringToColor, getModelCategories, calculateModelPrice, formatPriceInfo } from '../../../../../helpers'; +import PricingCardSkeleton from './PricingCardSkeleton'; +import { useMinimumLoadingTime } from '../../../../../hooks/common/useMinimumLoadingTime'; + +const CARD_STYLES = { + container: "w-12 h-12 rounded-2xl flex items-center justify-center relative shadow-sm", + 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, + activeKey, + availableCategories, +}) => { + const showSkeleton = useMinimumLoadingTime(loading); + + const startIndex = (currentPage - 1) * pageSize; + const endIndex = startIndex + pageSize; + const paginatedModels = filteredModels.slice(startIndex, endIndex); + + const getModelKey = (model) => model.key ?? model.model_name ?? model.id; + + 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 = (modelName) => { + const categories = getModelCategories(t); + let icon = null; + + // 遍历分类,找到匹配的模型图标 + for (const [key, category] of Object.entries(categories)) { + if (key !== 'all' && category.filter({ model_name: modelName })) { + icon = category.icon; + break; + } + } + + // 如果找到了匹配的图标,返回包装后的图标 + if (icon) { + return ( +
+
+ {React.cloneElement(icon, { size: 32 })} +
+
+ ); + } + + const avatarText = modelName.slice(0, 2).toUpperCase(); + return ( +
+ + {avatarText} + +
+ ); + }; + + // 获取模型描述 + const getModelDescription = (modelName) => { + return t('高性能AI模型,适用于各种文本生成和理解任务。'); + }; + + // 渲染价格信息 + const renderPriceInfo = (record) => { + const priceData = calculateModelPrice({ + record, + selectedGroup, + groupRatio, + tokenUnit, + displayPrice, + currency + }); + return formatPriceInfo(priceData, t); + }; + + // 渲染标签 + const renderTags = (record) => { + const tags = []; + + // 计费类型标签 + const billingType = record.quota_type === 1 ? 'teal' : 'violet'; + const billingText = record.quota_type === 1 ? t('按次计费') : t('按量计费'); + tags.push( + + {billingText} + + ); + + // 热门模型标签 + if (record.model_name.includes('gpt')) { + tags.push( + + {t('热')} + + ); + } + + // 端点类型标签 + if (record.supported_endpoint_types?.length > 0) { + record.supported_endpoint_types.slice(0, 2).forEach((endpoint, index) => { + tags.push( + + {endpoint} + + ); + }); + } + + // 上下文长度标签 + const contextMatch = record.model_name.match(/(\d+)k/i); + const contextSize = contextMatch ? contextMatch[1] + 'K' : '4K'; + tags.push( + + {contextSize} + + ); + + return tags; + }; + + // 显示骨架屏 + if (showSkeleton) { + return ( + + ); + } + + if (!filteredModels || filteredModels.length === 0) { + return ( +
+ } + darkModeImage={} + description={t('搜索无结果')} + /> +
+ ); + } + + return ( +
+
+ {paginatedModels.map((model, index) => { + const modelKey = getModelKey(model); + const isSelected = selectedRowKeys.includes(modelKey); + + return ( + + {/* 头部:图标 + 模型名称 + 操作按钮 */} +
+
+ {getModelIcon(model.model_name)} +
+

+ {model.model_name} +

+
+ {renderPriceInfo(model)} +
+
+
+ +
+ {/* 复制按钮 */} +
+
+ + {/* 模型描述 */} +
+

+ {getModelDescription(model.model_name)} +

+
+ + {/* 标签区域 */} +
+ {renderTags(model)} +
+ + {/* 倍率信息(可选) */} + {showRatio && ( +
+
+ {t('倍率信息')} + + { + setModalImageUrl('/ratio.png'); + setIsModalOpenurl(true); + }} + /> + +
+
+
+ {t('模型')}: {model.quota_type === 0 ? model.model_ratio : t('无')} +
+
+ {t('补全')}: {model.quota_type === 0 ? parseFloat(model.completion_ratio.toFixed(3)) : t('无')} +
+
+ {t('分组')}: {groupRatio[selectedGroup]} +
+
+
+ )} +
+ ); + })} +
+ + {/* 分页 */} + {filteredModels.length > 0 && ( +
+ setCurrentPage(page)} + onPageSizeChange={(size) => { + setPageSize(size); + setCurrentPage(1); + }} + /> +
+ )} +
+ ); +}; + +export default PricingCardView; \ No newline at end of file diff --git a/web/src/components/table/model-pricing/view/PricingTable.jsx b/web/src/components/table/model-pricing/view/table/PricingTable.jsx similarity index 98% rename from web/src/components/table/model-pricing/view/PricingTable.jsx rename to web/src/components/table/model-pricing/view/table/PricingTable.jsx index 26c7edbb..09d9f53e 100644 --- a/web/src/components/table/model-pricing/view/PricingTable.jsx +++ b/web/src/components/table/model-pricing/view/table/PricingTable.jsx @@ -56,7 +56,6 @@ const PricingTable = ({ setIsModalOpenurl, currency, tokenUnit, - setTokenUnit, displayPrice, showRatio, }); @@ -69,7 +68,6 @@ const PricingTable = ({ setIsModalOpenurl, currency, tokenUnit, - setTokenUnit, displayPrice, showRatio, ]); diff --git a/web/src/components/table/model-pricing/view/PricingTableColumns.js b/web/src/components/table/model-pricing/view/table/PricingTableColumns.js similarity index 91% rename from web/src/components/table/model-pricing/view/PricingTableColumns.js rename to web/src/components/table/model-pricing/view/table/PricingTableColumns.js index 54b3889c..7ff77a57 100644 --- a/web/src/components/table/model-pricing/view/PricingTableColumns.js +++ b/web/src/components/table/model-pricing/view/table/PricingTableColumns.js @@ -18,9 +18,9 @@ For commercial licensing, please contact support@quantumnous.com */ import React from 'react'; -import { Tag, Space, Tooltip, Switch } from '@douyinfe/semi-ui'; +import { Tag, Space, Tooltip } from '@douyinfe/semi-ui'; import { IconHelpCircle } from '@douyinfe/semi-icons'; -import { renderModelTag, stringToColor, calculateModelPrice } from '../../../../helpers'; +import { renderModelTag, stringToColor, calculateModelPrice } from '../../../../../helpers'; function renderQuotaType(type, t) { switch (type) { @@ -69,7 +69,6 @@ export const getPricingTableColumns = ({ setIsModalOpenurl, currency, tokenUnit, - setTokenUnit, displayPrice, showRatio, }) => { @@ -144,18 +143,7 @@ export const getPricingTableColumns = ({ }; const priceColumn = { - title: ( -
- {t('模型价格')} - {/* 计费单位切换 */} - setTokenUnit(checked ? 'K' : 'M')} - checkedText="K" - uncheckedText="M" - /> -
- ), + title: t('模型价格'), dataIndex: 'model_price', render: (text, record, index) => { const priceData = calculateModelPrice({ diff --git a/web/src/components/table/usage-logs/UsageLogsActions.jsx b/web/src/components/table/usage-logs/UsageLogsActions.jsx index c14ffcbf..7c416183 100644 --- a/web/src/components/table/usage-logs/UsageLogsActions.jsx +++ b/web/src/components/table/usage-logs/UsageLogsActions.jsx @@ -17,10 +17,11 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import React, { useState, useEffect, useRef } from 'react'; +import React from 'react'; import { Tag, Space, Skeleton } from '@douyinfe/semi-ui'; import { renderQuota } from '../../../helpers'; import CompactModeToggle from '../../common/ui/CompactModeToggle'; +import { useMinimumLoadingTime } from '../../../hooks/common/useMinimumLoadingTime'; const LogsActions = ({ stat, @@ -30,27 +31,9 @@ const LogsActions = ({ setCompactMode, t, }) => { - const [showSkeleton, setShowSkeleton] = useState(loadingStat); + const showSkeleton = useMinimumLoadingTime(loadingStat); const needSkeleton = !showStat || showSkeleton; - const loadingStartRef = useRef(Date.now()); - useEffect(() => { - if (loadingStat) { - loadingStartRef.current = Date.now(); - setShowSkeleton(true); - } else { - const elapsed = Date.now() - loadingStartRef.current; - const remaining = Math.max(0, 1000 - elapsed); - if (remaining === 0) { - setShowSkeleton(false); - } else { - const timer = setTimeout(() => setShowSkeleton(false), remaining); - return () => clearTimeout(timer); - } - } - }, [loadingStat]); - - // Skeleton placeholder layout (three tag-size blocks) const placeholder = ( diff --git a/web/src/helpers/utils.js b/web/src/helpers/utils.js index 9972fb3a..5919b45c 100644 --- a/web/src/helpers/utils.js +++ b/web/src/helpers/utils.js @@ -612,12 +612,25 @@ export const calculateModelPrice = ({ } }; -// 格式化价格信息为字符串(用于卡片视图) +// 格式化价格信息(用于卡片视图) export const formatPriceInfo = (priceData, t) => { if (priceData.isPerToken) { - return `${t('输入')} ${priceData.inputPrice}/${priceData.unitLabel} ${t('输出')} ${priceData.completionPrice}/${priceData.unitLabel}`; + return ( + <> + + {t('提示')} {priceData.inputPrice}/{priceData.unitLabel} + + + {t('补全')} {priceData.completionPrice}/{priceData.unitLabel} + + + ); } else { - return `${t('模型价格')} ${priceData.price}`; + return ( + + {t('模型价格')} {priceData.price} + + ); } }; @@ -684,6 +697,7 @@ export const resetPricingFilters = ({ setFilterQuotaType, setFilterEndpointType, setCurrentPage, + setTokenUnit, }) => { // 重置搜索 if (typeof handleChange === 'function') { @@ -719,6 +733,11 @@ export const resetPricingFilters = ({ setViewMode('card'); } + // 重置token单位 + if (typeof setTokenUnit === 'function') { + setTokenUnit('M'); + } + // 重置分组筛选 if (typeof setFilterGroup === 'function') { setFilterGroup('all'); diff --git a/web/src/hooks/common/useMinimumLoadingTime.js b/web/src/hooks/common/useMinimumLoadingTime.js new file mode 100644 index 00000000..f9a176f1 --- /dev/null +++ b/web/src/hooks/common/useMinimumLoadingTime.js @@ -0,0 +1,50 @@ +/* +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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import { useState, useEffect, useRef } from 'react'; + +/** + * 自定义 Hook:确保骨架屏至少显示指定的时间 + * @param {boolean} loading - 实际的加载状态 + * @param {number} minimumTime - 最小显示时间(毫秒),默认 1000ms + * @returns {boolean} showSkeleton - 是否显示骨架屏的状态 + */ +export const useMinimumLoadingTime = (loading, minimumTime = 1000) => { + const [showSkeleton, setShowSkeleton] = useState(loading); + const loadingStartRef = useRef(Date.now()); + + useEffect(() => { + if (loading) { + loadingStartRef.current = Date.now(); + setShowSkeleton(true); + } else { + const elapsed = Date.now() - loadingStartRef.current; + const remaining = Math.max(0, minimumTime - elapsed); + + if (remaining === 0) { + setShowSkeleton(false); + } else { + const timer = setTimeout(() => setShowSkeleton(false), remaining); + return () => clearTimeout(timer); + } + } + }, [loading, minimumTime]); + + return showSkeleton; +}; \ No newline at end of file diff --git a/web/src/hooks/dashboard/useDashboardData.js b/web/src/hooks/dashboard/useDashboardData.js index 255f48d3..c4299f66 100644 --- a/web/src/hooks/dashboard/useDashboardData.js +++ b/web/src/hooks/dashboard/useDashboardData.js @@ -24,6 +24,7 @@ import { API, isAdmin, showError, timestamp2string } from '../../helpers'; import { getDefaultTime, getInitialTimestamp } from '../../helpers/dashboard'; import { TIME_OPTIONS } from '../../constants/dashboard.constants'; import { useIsMobile } from '../common/useIsMobile'; +import { useMinimumLoadingTime } from '../common/useMinimumLoadingTime'; export const useDashboardData = (userState, userDispatch, statusState) => { const { t } = useTranslation(); @@ -35,6 +36,7 @@ export const useDashboardData = (userState, userDispatch, statusState) => { const [loading, setLoading] = useState(false); const [greetingVisible, setGreetingVisible] = useState(false); const [searchModalVisible, setSearchModalVisible] = useState(false); + const showLoading = useMinimumLoadingTime(loading); // ========== 输入状态 ========== const [inputs, setInputs] = useState({ @@ -145,7 +147,6 @@ export const useDashboardData = (userState, userDispatch, statusState) => { // ========== API 调用函数 ========== const loadQuotaData = useCallback(async () => { setLoading(true); - const startTime = Date.now(); try { let url = ''; const { start_timestamp, end_timestamp, username } = inputs; @@ -177,11 +178,7 @@ export const useDashboardData = (userState, userDispatch, statusState) => { return []; } } finally { - const elapsed = Date.now() - startTime; - const remainingTime = Math.max(0, 1000 - elapsed); - setTimeout(() => { - setLoading(false); - }, remainingTime); + setLoading(false); } }, [inputs, dataExportDefaultTime, isAdminUser, now]); @@ -246,7 +243,7 @@ export const useDashboardData = (userState, userDispatch, statusState) => { return { // 基础状态 - loading, + loading: showLoading, greetingVisible, searchModalVisible, diff --git a/web/src/hooks/model-pricing/useModelPricingData.js b/web/src/hooks/model-pricing/useModelPricingData.js index 6d750b87..3e3c4a92 100644 --- a/web/src/hooks/model-pricing/useModelPricingData.js +++ b/web/src/hooks/model-pricing/useModelPricingData.js @@ -115,11 +115,12 @@ export const useModelPricingData = () => { const rowSelection = useMemo( () => ({ - onChange: (selectedRowKeys, selectedRows) => { - setSelectedRowKeys(selectedRowKeys); + selectedRowKeys, + onChange: (keys) => { + setSelectedRowKeys(keys); }, }), - [], + [selectedRowKeys], ); const displayPrice = (usdPrice) => { diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 50c10a21..67dbfc9d 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -950,7 +950,7 @@ "黑夜模式": "Dark mode", "管理员设置": "Admin", "待更新": "To be updated", - "定价": "Pricing", + "模型广场": "Pricing", "支付中..": "Paying", "查看图片": "View pictures", "并发限制": "Concurrency limit", @@ -1195,6 +1195,7 @@ "兑换码创建成功,是否下载兑换码?": "Redemption code created successfully. Do you want to download it?", "兑换码将以文本文件的形式下载,文件名为兑换码的名称。": "The redemption code will be downloaded as a text file, with the filename being the redemption code name.", "模型价格": "Model price", + "按K显示单位": "Display in K units", "可用分组": "Available groups", "您的默认分组为:{{group}},分组倍率为:{{ratio}}": "Your default group is: {{group}}, group ratio: {{ratio}}", "按量计费费用 = 分组倍率 × 模型倍率 × (提示token数 + 补全token数 × 补全倍率)/ 500000 (单位:美元)": "The cost of pay-as-you-go = Group ratio × Model ratio × (Prompt token number + Completion token number × Completion ratio) / 500000 (Unit: USD)",