💄 style(pricing): enhance card view UI and skeleton loading experience (#1365)

- Increase skeleton card count from 6 to 10 for better visual coverage
- Extend minimum skeleton display duration from 500ms to 1000ms for smoother UX
- Add circle shape to all pricing tags for consistent rounded design
- Apply circle styling to billing type, popularity, endpoint, and context tags

This commit improves the visual consistency and user experience of the pricing
card view by standardizing tag appearance and optimizing skeleton loading timing.
This commit is contained in:
t0ng7u
2025-07-24 03:19:32 +08:00
parent c4b69b341a
commit 53be79a00e
20 changed files with 706 additions and 254 deletions

View File

@@ -50,7 +50,7 @@ const CardTable = ({
setShowSkeleton(true);
} else {
const elapsed = Date.now() - loadingStartRef.current;
const remaining = Math.max(0, 500 - elapsed);
const remaining = Math.max(0, 1000 - elapsed);
if (remaining === 0) {
setShowSkeleton(false);
} else {

View File

@@ -65,7 +65,7 @@ const SelectableButtonGroup = ({
setShowSkeleton(true);
} else {
const elapsed = Date.now() - loadingStartRef.current;
const remaining = Math.max(0, 500 - elapsed);
const remaining = Math.max(0, 1000 - elapsed);
if (remaining === 0) {
setShowSkeleton(false);
} else {

View File

@@ -219,7 +219,7 @@ const HeaderBar = ({ onMobileMenuToggle, drawerOpen }) => {
useEffect(() => {
if (statusState?.status !== undefined) {
const elapsed = Date.now() - loadingStartRef.current;
const remaining = Math.max(0, 500 - elapsed);
const remaining = Math.max(0, 1000 - elapsed);
const timer = setTimeout(() => {
setIsLoading(false);
}, remaining);

View File

@@ -1,123 +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 <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React from 'react';
import { Card } from '@douyinfe/semi-ui';
import { IconVerify, IconLayers, IconInfoCircle } from '@douyinfe/semi-icons';
import { AlertCircle } from 'lucide-react';
const PricingHeader = ({
userState,
groupRatio,
selectedGroup,
models,
t
}) => {
return (
<Card
className="!rounded-2xl !border-0 !shadow-md overflow-hidden mb-6"
style={{
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 25%, #a855f7 50%, #c084fc 75%, #d8b4fe 100%)',
position: 'relative'
}}
bodyStyle={{ padding: 0 }}
>
<div className="relative p-6 sm:p-8" style={{ color: 'white' }}>
<div className="flex flex-col lg:flex-row lg:items-start lg:justify-between gap-4 lg:gap-6">
<div className="flex items-start">
<div className="w-10 h-10 sm:w-12 sm:h-12 rounded-xl bg-white/10 flex items-center justify-center mr-3 sm:mr-4">
<IconLayers size="extra-large" className="text-white" />
</div>
<div className="flex-1 min-w-0">
<div className="text-base sm:text-lg font-semibold mb-1 sm:mb-2">
{t('模型定价')}
</div>
<div className="text-sm text-white/80">
{userState.user ? (
<div className="flex items-center">
<IconVerify className="mr-1.5 flex-shrink-0" size="small" />
<span className="truncate">
{t('当前分组')}: {userState.user.group}{t('倍率')}: {groupRatio[userState.user.group]}
</span>
</div>
) : (
<div className="flex items-center">
<AlertCircle size={14} className="mr-1.5 flex-shrink-0" />
<span className="truncate">
{t('未登录,使用默认分组倍率:')}{groupRatio['default']}
</span>
</div>
)}
</div>
</div>
</div>
<div className="grid grid-cols-3 gap-2 sm:gap-3 mt-2 lg:mt-0">
<div
className="text-center px-2 py-2 sm:px-3 sm:py-2.5 bg-white/10 rounded-lg backdrop-blur-sm hover:bg-white/20 transition-colors duration-200"
style={{ backdropFilter: 'blur(10px)' }}
>
<div className="text-xs text-white/70 mb-0.5">{t('分组倍率')}</div>
<div className="text-sm sm:text-base font-semibold">{groupRatio[selectedGroup] || '1.0'}x</div>
</div>
<div
className="text-center px-2 py-2 sm:px-3 sm:py-2.5 bg-white/10 rounded-lg backdrop-blur-sm hover:bg-white/20 transition-colors duration-200"
style={{ backdropFilter: 'blur(10px)' }}
>
<div className="text-xs text-white/70 mb-0.5">{t('可用模型')}</div>
<div className="text-sm sm:text-base font-semibold">
{models.filter(m => m.enable_groups.includes(selectedGroup)).length}
</div>
</div>
<div
className="text-center px-2 py-2 sm:px-3 sm:py-2.5 bg-white/10 rounded-lg backdrop-blur-sm hover:bg-white/20 transition-colors duration-200"
style={{ backdropFilter: 'blur(10px)' }}
>
<div className="text-xs text-white/70 mb-0.5">{t('计费类型')}</div>
<div className="text-sm sm:text-base font-semibold">2</div>
</div>
</div>
</div>
{/* 计费说明 */}
<div className="mt-4 sm:mt-5">
<div className="flex items-start">
<div
className="w-full flex items-start space-x-2 px-3 py-2 sm:px-4 sm:py-2.5 rounded-lg text-xs sm:text-sm"
style={{
backgroundColor: 'rgba(255, 255, 255, 0.2)',
color: 'white',
backdropFilter: 'blur(10px)'
}}
>
<IconInfoCircle className="flex-shrink-0 mt-0.5" size="small" />
<span>
{t('按量计费费用 = 分组倍率 × 模型倍率 × 提示token数 + 补全token数 × 补全倍率)/ 500000 (单位:美元)')}
</span>
</div>
</div>
</div>
<div className="absolute top-0 left-0 w-full h-2 bg-gradient-to-r from-yellow-400 via-orange-400 to-red-400" style={{ opacity: 0.6 }}></div>
</div>
</Card>
);
};
export default PricingHeader;

View File

@@ -29,6 +29,8 @@ const PricingDisplaySettings = ({
setCurrency,
showRatio,
setShowRatio,
viewMode,
setViewMode,
loading = false,
t
}) => {
@@ -50,6 +52,10 @@ const PricingDisplaySettings = ({
</Tooltip>
</span>
),
},
{
value: 'tableView',
label: t('表格视图')
}
];
@@ -59,10 +65,16 @@ const PricingDisplaySettings = ({
];
const handleChange = (value) => {
if (value === 'recharge') {
setShowWithRecharge(!showWithRecharge);
} else if (value === 'ratio') {
setShowRatio(!showRatio);
switch (value) {
case 'recharge':
setShowWithRecharge(!showWithRecharge);
break;
case 'ratio':
setShowRatio(!showRatio);
break;
case 'tableView':
setViewMode(viewMode === 'table' ? 'card' : 'table');
break;
}
};
@@ -70,6 +82,7 @@ const PricingDisplaySettings = ({
const activeValues = [];
if (showWithRecharge) activeValues.push('recharge');
if (showRatio) activeValues.push('ratio');
if (viewMode === 'table') activeValues.push('tableView');
return activeValues;
};

View File

@@ -19,43 +19,19 @@ For commercial licensing, please contact support@quantumnous.com
import React from 'react';
import PricingSearchBar from './PricingSearchBar';
import PricingTable from './PricingTable';
import PricingView from './PricingView';
const PricingContent = ({ isMobile, sidebarProps, ...props }) => {
return (
<div
className={isMobile ? "" : "pricing-scroll-hide"}
style={isMobile ? {
height: '100%',
display: 'flex',
flexDirection: 'column',
overflow: 'auto'
} : {}}
>
<div className={isMobile ? "pricing-content-mobile" : "pricing-scroll-hide"}>
{/* 固定的搜索和操作区域 */}
<div
style={{
padding: '16px 24px',
borderBottom: '1px solid var(--semi-color-border)',
backgroundColor: 'var(--semi-color-bg-0)',
flexShrink: 0,
position: 'sticky',
top: 0,
zIndex: 5,
}}
>
<div className="pricing-search-header">
<PricingSearchBar {...props} isMobile={isMobile} sidebarProps={sidebarProps} />
</div>
{/* 可滚动的内容区域 */}
<div
style={{
flex: 1,
overflow: 'auto',
...(isMobile && { minHeight: 0 })
}}
>
<PricingTable {...props} />
<div className={isMobile ? "pricing-view-container-mobile" : "pricing-view-container"}>
<PricingView {...props} viewMode={sidebarProps.viewMode} />
</div>
</div>
);

View File

@@ -21,56 +21,46 @@ import React from 'react';
import { Layout, ImagePreview } from '@douyinfe/semi-ui';
import PricingSidebar from './PricingSidebar';
import PricingContent from './PricingContent';
import { useModelPricingData } from '../../../hooks/model-pricing/useModelPricingData';
import { useIsMobile } from '../../../hooks/common/useIsMobile';
import { useModelPricingData } from '../../../../hooks/model-pricing/useModelPricingData';
import { useIsMobile } from '../../../../hooks/common/useIsMobile';
const PricingPage = () => {
const pricingData = useModelPricingData();
const { Sider, Content } = Layout;
const isMobile = useIsMobile();
//
const [showRatio, setShowRatio] = React.useState(false);
const [viewMode, setViewMode] = React.useState('card');
const allProps = {
...pricingData,
showRatio,
setShowRatio,
viewMode,
setViewMode
};
return (
<div className="bg-white">
<Layout style={{ height: 'calc(100vh - 60px)', overflow: 'hidden', marginTop: '60px' }}>
{/* 左侧边栏 - 只在桌面端显示 */}
<Layout className="pricing-layout">
{!isMobile && (
<Sider
className="pricing-scroll-hide"
style={{
width: 460,
height: 'calc(100vh - 60px)',
backgroundColor: 'var(--semi-color-bg-0)',
borderRight: '1px solid var(--semi-color-border)',
overflow: 'auto'
}}
className="pricing-scroll-hide pricing-sidebar"
width={460}
>
<PricingSidebar {...pricingData} showRatio={showRatio} setShowRatio={setShowRatio} />
<PricingSidebar {...allProps} />
</Sider>
)}
{/* 右侧内容区 */}
<Content
className="pricing-scroll-hide"
style={{
height: 'calc(100vh - 60px)',
backgroundColor: 'var(--semi-color-bg-0)',
display: 'flex',
flexDirection: 'column'
}}
className="pricing-scroll-hide pricing-content"
>
<PricingContent
{...pricingData}
showRatio={showRatio}
<PricingContent
{...allProps}
isMobile={isMobile}
sidebarProps={{ ...pricingData, showRatio, setShowRatio }}
sidebarProps={allProps}
/>
</Content>
</Layout>
{/* 倍率说明图预览 */}
<ImagePreview
src={pricingData.modalImageUrl}
visible={pricingData.isModalOpenurl}

View File

@@ -20,7 +20,7 @@ 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';
const PricingSearchBar = ({
selectedRowKeys,

View File

@@ -19,11 +19,11 @@ For commercial licensing, please contact support@quantumnous.com
import React from 'react';
import { Button } from '@douyinfe/semi-ui';
import PricingCategories from './filter/PricingCategories';
import PricingGroups from './filter/PricingGroups';
import PricingQuotaTypes from './filter/PricingQuotaTypes';
import PricingDisplaySettings from './filter/PricingDisplaySettings';
import { resetPricingFilters } from '../../../helpers/utils';
import PricingCategories from '../filter/PricingCategories';
import PricingGroups from '../filter/PricingGroups';
import PricingQuotaTypes from '../filter/PricingQuotaTypes';
import PricingDisplaySettings from '../filter/PricingDisplaySettings';
import { resetPricingFilters } from '../../../../helpers/utils';
const PricingSidebar = ({
showWithRecharge,
@@ -34,10 +34,14 @@ const PricingSidebar = ({
setActiveKey,
showRatio,
setShowRatio,
viewMode,
setViewMode,
filterGroup,
setFilterGroup,
filterQuotaType,
setFilterQuotaType,
currentPage,
setCurrentPage,
loading,
t,
...categoryProps
@@ -51,8 +55,10 @@ const PricingSidebar = ({
setShowWithRecharge,
setCurrency,
setShowRatio,
setViewMode,
setFilterGroup,
setFilterQuotaType,
setCurrentPage,
});
return (
@@ -78,15 +84,36 @@ const PricingSidebar = ({
setCurrency={setCurrency}
showRatio={showRatio}
setShowRatio={setShowRatio}
viewMode={viewMode}
setViewMode={setViewMode}
loading={loading}
t={t}
/>
<PricingCategories {...categoryProps} setActiveKey={setActiveKey} loading={loading} t={t} />
<PricingCategories
{...categoryProps}
setActiveKey={setActiveKey}
loading={loading}
t={t}
/>
<PricingGroups filterGroup={filterGroup} setFilterGroup={setFilterGroup} usableGroup={categoryProps.usableGroup} groupRatio={categoryProps.groupRatio} models={categoryProps.models} loading={loading} t={t} />
<PricingGroups
filterGroup={filterGroup}
setFilterGroup={setFilterGroup}
usableGroup={categoryProps.usableGroup}
groupRatio={categoryProps.groupRatio}
models={categoryProps.models}
loading={loading}
t={t}
/>
<PricingQuotaTypes filterQuotaType={filterQuotaType} setFilterQuotaType={setFilterQuotaType} models={categoryProps.models} loading={loading} t={t} />
<PricingQuotaTypes
filterQuotaType={filterQuotaType}
setFilterQuotaType={setFilterQuotaType}
models={categoryProps.models}
loading={loading}
t={t}
/>
</div>
);
};

View File

@@ -17,5 +17,17 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
// PricingPage
export { default } from './PricingPage';
import React from 'react';
import PricingTable from '../view/PricingTable';
import PricingCardView from '../view/PricingCardView';
const PricingView = ({
viewMode = 'table',
...props
}) => {
return viewMode === 'card' ?
<PricingCardView {...props} /> :
<PricingTable {...props} />;
};
export default PricingView;

View File

@@ -40,10 +40,14 @@ const PricingFilterModal = ({
setActiveKey,
showRatio,
setShowRatio,
viewMode,
setViewMode,
filterGroup,
setFilterGroup,
filterQuotaType,
setFilterQuotaType,
currentPage,
setCurrentPage,
loading,
...categoryProps
} = sidebarProps;
@@ -56,14 +60,12 @@ const PricingFilterModal = ({
setShowWithRecharge,
setCurrency,
setShowRatio,
setViewMode,
setFilterGroup,
setFilterQuotaType,
setCurrentPage,
});
const handleConfirm = () => {
onClose();
};
const footer = (
<div className="flex justify-end">
<Button
@@ -76,7 +78,7 @@ const PricingFilterModal = ({
<Button
theme="solid"
type="primary"
onClick={handleConfirm}
onClick={onClose}
>
{t('确定')}
</Button>
@@ -106,6 +108,8 @@ const PricingFilterModal = ({
setCurrency={setCurrency}
showRatio={showRatio}
setShowRatio={setShowRatio}
viewMode={viewMode}
setViewMode={setViewMode}
loading={loading}
t={t}
/>

View File

@@ -0,0 +1,444 @@
/*
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, { 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 = (
<div className="p-4">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{Array.from({ length: skeletonCount }).map((_, index) => (
<Card
key={index}
className="!rounded-2xl border border-gray-200"
bodyStyle={{ padding: '24px' }}
>
{/* 头部:图标 + 模型名称 + 操作按钮 */}
<div className="flex items-start justify-between mb-3">
<div className="flex items-center space-x-3 flex-1 min-w-0">
{/* 模型图标骨架 */}
<div className="w-12 h-12 rounded-2xl flex items-center justify-center relative shadow-sm">
<Skeleton.Avatar
size="large"
style={{ width: 48, height: 48, borderRadius: 16 }}
/>
</div>
{/* 模型名称骨架 */}
<div className="flex-1 min-w-0">
<Skeleton.Title
style={{
width: `${120 + (index % 3) * 30}px`,
height: 20,
marginBottom: 0
}}
/>
</div>
</div>
<div className="flex items-center space-x-2 ml-3">
{/* 操作按钮骨架 */}
<Skeleton.Button size="small" style={{ width: 32, height: 32 }} />
{rowSelection && (
<Skeleton.Button size="small" style={{ width: 16, height: 16 }} />
)}
</div>
</div>
{/* 价格信息骨架 */}
<div className="mb-3">
<Skeleton.Title
style={{
width: `${180 + (index % 4) * 20}px`,
height: 16,
marginBottom: 0
}}
/>
</div>
{/* 模型描述骨架 */}
<div className="mb-4">
<Skeleton.Paragraph
rows={2}
style={{ marginBottom: 0 }}
title={false}
/>
</div>
{/* 标签区域骨架 */}
<div className="flex flex-wrap gap-2 mb-4">
{Array.from({ length: 3 + (index % 2) }).map((_, tagIndex) => (
<Skeleton.Button
key={tagIndex}
size="small"
style={{
width: `${40 + (tagIndex % 3) * 15}px`,
height: 24,
borderRadius: 12
}}
/>
))}
</div>
{/* 倍率信息骨架(可选) */}
{showRatio && (
<div className="mt-4 pt-3 border-t border-gray-100">
<div className="flex items-center space-x-1 mb-2">
<Skeleton.Title
style={{ width: 60, height: 12, marginBottom: 0 }}
/>
<Skeleton.Button size="small" style={{ width: 14, height: 14 }} />
</div>
<div className="grid grid-cols-3 gap-2">
{Array.from({ length: 3 }).map((_, ratioIndex) => (
<Skeleton.Title
key={ratioIndex}
style={{ width: '100%', height: 12, marginBottom: 0 }}
/>
))}
</div>
</div>
)}
</Card>
))}
</div>
{/* 分页骨架 */}
<div className="flex justify-center mt-6 pt-4 border-t pricing-pagination-divider">
<Skeleton.Button style={{ width: 300, height: 32 }} />
</div>
</div>
);
return (
<Skeleton loading={true} active placeholder={placeholder}></Skeleton>
);
};
// 获取模型图标
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 (
<div className="w-12 h-12 rounded-2xl flex items-center justify-center relative shadow-sm">
<div className="w-8 h-8 flex items-center justify-center">
{React.cloneElement(icon, { size: 32 })}
</div>
</div>
);
}
// 默认图标(如果没有匹配到任何分类)
return (
<div className="w-12 h-12 rounded-2xl flex items-center justify-center relative shadow-sm">
{/* 默认的螺旋图案 */}
<svg width="24" height="24" viewBox="0 0 24 24" className="text-gray-600">
<path
d="M12 2C17.5 2 22 6.5 22 12S17.5 22 12 22 2 17.5 2 12 6.5 2 12 2M12 4C7.6 4 4 7.6 4 12S7.6 20 12 20 20 16.4 20 12 16.4 4 12 4M12 6C15.3 6 18 8.7 18 12S15.3 18 12 18 6 15.3 6 12 8.7 6 12 6M12 8C10.9 8 10 8.9 10 10S10.9 12 12 12 14 11.1 14 10 13.1 8 12 8Z"
fill="currentColor"
opacity="0.6"
/>
</svg>
</div>
);
};
// 获取模型描述
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(
<Tag shape='circle' key="billing" color='teal' size='small'>
{t('按次计费')}
</Tag>
);
} else {
tags.push(
<Tag shape='circle' key="billing" color='violet' size='small'>
{t('按量计费')}
</Tag>
);
}
// 热度标签(示例)
if (record.model_name.includes('gpt-3.5-turbo') || record.model_name.includes('gpt-4')) {
tags.push(
<Tag shape='circle' key="hot" color='red' size='small'>
{t('热')}
</Tag>
);
}
// 端点类型标签
if (record.supported_endpoint_types && record.supported_endpoint_types.length > 0) {
record.supported_endpoint_types.slice(0, 2).forEach((endpoint, index) => {
tags.push(
<Tag shape='circle' key={`endpoint-${index}`} color={stringToColor(endpoint)} size='small'>
{endpoint}
</Tag>
);
});
}
// 上下文长度标签(示例)
if (record.model_name.includes('16k')) {
tags.push(<Tag shape='circle' key="context" color='blue' size='small'>16K</Tag>);
} else if (record.model_name.includes('32k')) {
tags.push(<Tag shape='circle' key="context" color='blue' size='small'>32K</Tag>);
} else {
tags.push(<Tag shape='circle' key="context" color='blue' size='small'>4K</Tag>);
}
return tags;
};
// 显示骨架屏
if (showSkeleton) {
return renderSkeletonCards();
}
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="p-4">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{paginatedModels.map((model, index) => {
const isSelected = rowSelection?.selectedRowKeys?.includes(model.id);
return (
<Card
key={model.id || index}
className={`!rounded-2xl transition-all duration-200 hover:shadow-lg border ${isSelected
? 'border-blue-500 bg-blue-50'
: 'border-gray-200 hover:border-gray-300'
}`}
bodyStyle={{ padding: '24px' }}
>
{/* 头部:图标 + 模型名称 + 操作按钮 */}
<div className="flex items-start justify-between mb-3">
<div className="flex items-center space-x-3 flex-1 min-w-0">
{getModelIcon(model.model_name)}
<div className="flex-1 min-w-0">
<h3 className="text-xl font-bold text-gray-900 truncate">
{model.model_name}
</h3>
</div>
</div>
<div className="flex items-center space-x-2 ml-3">
{/* 复制按钮 */}
<Button
size="small"
type="tertiary"
icon={<IconCopy />}
onClick={() => copyText(model.model_name)}
/>
{/* 选择框 */}
{rowSelection && (
<Checkbox
checked={isSelected}
onChange={(checked) => {
if (checked) {
rowSelection.onSelect(model, true);
} else {
rowSelection.onSelect(model, false);
}
}}
/>
)}
</div>
</div>
{/* 价格信息 */}
<div className="mb-3">
<div className="text-gray-700 text-base font-medium">
{renderPriceInfo(model)}
</div>
</div>
{/* 模型描述 */}
<div className="mb-4">
<p className="text-gray-500 text-sm leading-relaxed">
{getModelDescription(model.model_name)}
</p>
</div>
{/* 标签区域 */}
<div className="flex flex-wrap gap-2">
{renderTags(model)}
</div>
{/* 倍率信息(可选) */}
{showRatio && (
<div className="mt-4 pt-3 border-t border-gray-100">
<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={() => {
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('分组')}: {groupRatio[selectedGroup]}
</div>
</div>
</div>
)}
</Card>
);
})}
</div>
{/* 分页 */}
{filteredModels.length > 0 && (
<div className="flex justify-center mt-6 pt-4 border-t pricing-pagination-divider">
<Pagination
currentPage={currentPage}
pageSize={pageSize}
total={filteredModels.length}
showSizeChanger={true}
pageSizeOptions={[10, 20, 50, 100]}
onPageChange={(page) => setCurrentPage(page)}
onPageSizeChange={(size) => {
setPageSize(size);
setCurrentPage(1);
}}
/>
</div>
)}
</div>
);
};
export default PricingCardView;

View File

@@ -32,18 +32,15 @@ const PricingTable = ({
pageSize,
setPageSize,
selectedGroup,
usableGroup,
groupRatio,
copyText,
setModalImageUrl,
setIsModalOpenurl,
currency,
showWithRecharge,
tokenUnit,
setTokenUnit,
displayPrice,
filteredValue,
handleGroupClick,
searchValue,
showRatio,
compactMode = false,
t
@@ -53,43 +50,37 @@ const PricingTable = ({
return getPricingTableColumns({
t,
selectedGroup,
usableGroup,
groupRatio,
copyText,
setModalImageUrl,
setIsModalOpenurl,
currency,
showWithRecharge,
tokenUnit,
setTokenUnit,
displayPrice,
handleGroupClick,
showRatio,
});
}, [
t,
selectedGroup,
usableGroup,
groupRatio,
copyText,
setModalImageUrl,
setIsModalOpenurl,
currency,
showWithRecharge,
tokenUnit,
setTokenUnit,
displayPrice,
handleGroupClick,
showRatio,
]);
// filteredValue
// searchValue
const processedColumns = useMemo(() => {
const cols = columns.map(column => {
if (column.dataIndex === 'model_name') {
return {
...column,
filteredValue
filteredValue: searchValue ? [searchValue] : []
};
}
return column;
@@ -100,7 +91,7 @@ const PricingTable = ({
return cols.map(({ fixed, ...rest }) => rest);
}
return cols;
}, [columns, filteredValue, compactMode]);
}, [columns, searchValue, compactMode]);
const ModelTable = useMemo(() => (
<Card className="!rounded-xl overflow-hidden" bordered={false}>

View File

@@ -20,7 +20,7 @@ For commercial licensing, please contact support@quantumnous.com
import React from 'react';
import { Tag, Space, Tooltip, Switch } from '@douyinfe/semi-ui';
import { IconHelpCircle } from '@douyinfe/semi-icons';
import { renderModelTag, stringToColor } from '../../../helpers';
import { renderModelTag, stringToColor, calculateModelPrice } from '../../../../helpers';
function renderQuotaType(type, t) {
switch (type) {
@@ -158,38 +158,30 @@ export const getPricingTableColumns = ({
),
dataIndex: 'model_price',
render: (text, record, index) => {
if (record.quota_type === 0) {
const inputRatioPriceUSD = record.model_ratio * 2 * groupRatio[selectedGroup];
const completionRatioPriceUSD =
record.model_ratio * record.completion_ratio * 2 * groupRatio[selectedGroup];
const priceData = calculateModelPrice({
record,
selectedGroup,
groupRatio,
tokenUnit,
displayPrice,
currency
});
const unitDivisor = tokenUnit === 'K' ? 1000 : 1;
const unitLabel = tokenUnit === 'K' ? 'K' : 'M';
const rawDisplayInput = displayPrice(inputRatioPriceUSD);
const rawDisplayCompletion = displayPrice(completionRatioPriceUSD);
const numInput = parseFloat(rawDisplayInput.replace(/[^0-9.]/g, '')) / unitDivisor;
const numCompletion = parseFloat(rawDisplayCompletion.replace(/[^0-9.]/g, '')) / unitDivisor;
const displayInput = `${currency === 'CNY' ? '¥' : '$'}${numInput.toFixed(3)}`;
const displayCompletion = `${currency === 'CNY' ? '¥' : '$'}${numCompletion.toFixed(3)}`;
if (priceData.isPerToken) {
return (
<div className="space-y-1">
<div className="text-gray-700">
{t('提示')} {displayInput} / 1{unitLabel} tokens
{t('提示')} {priceData.inputPrice} / 1{priceData.unitLabel} tokens
</div>
<div className="text-gray-700">
{t('补全')} {displayCompletion} / 1{unitLabel} tokens
{t('补全')} {priceData.completionPrice} / 1{priceData.unitLabel} tokens
</div>
</div>
);
} else {
const priceUSD = parseFloat(text) * groupRatio[selectedGroup];
const displayVal = displayPrice(priceUSD);
return (
<div className="text-gray-700">
{t('模型价格')}{displayVal}
{t('模型价格')}{priceData.price}
</div>
);
}

View File

@@ -40,7 +40,7 @@ const LogsActions = ({
setShowSkeleton(true);
} else {
const elapsed = Date.now() - loadingStartRef.current;
const remaining = Math.max(0, 500 - elapsed);
const remaining = Math.max(0, 1000 - elapsed);
if (remaining === 0) {
setShowSkeleton(false);
} else {

View File

@@ -568,6 +568,59 @@ export const modelSelectFilter = (input, option) => {
return val.includes(input.trim().toLowerCase());
};
// -------------------------------
// 模型定价计算工具函数
export const calculateModelPrice = ({
record,
selectedGroup,
groupRatio,
tokenUnit,
displayPrice,
currency,
precision = 3
}) => {
if (record.quota_type === 0) {
// 按量计费
const inputRatioPriceUSD = record.model_ratio * 2 * groupRatio[selectedGroup];
const completionRatioPriceUSD =
record.model_ratio * record.completion_ratio * 2 * groupRatio[selectedGroup];
const unitDivisor = tokenUnit === 'K' ? 1000 : 1;
const unitLabel = tokenUnit === 'K' ? 'K' : 'M';
const rawDisplayInput = displayPrice(inputRatioPriceUSD);
const rawDisplayCompletion = displayPrice(completionRatioPriceUSD);
const numInput = parseFloat(rawDisplayInput.replace(/[^0-9.]/g, '')) / unitDivisor;
const numCompletion = parseFloat(rawDisplayCompletion.replace(/[^0-9.]/g, '')) / unitDivisor;
return {
inputPrice: `${currency === 'CNY' ? '¥' : '$'}${numInput.toFixed(precision)}`,
completionPrice: `${currency === 'CNY' ? '¥' : '$'}${numCompletion.toFixed(precision)}`,
unitLabel,
isPerToken: true
};
} else {
// 按次计费
const priceUSD = parseFloat(record.model_price) * groupRatio[selectedGroup];
const displayVal = displayPrice(priceUSD);
return {
price: displayVal,
isPerToken: false
};
}
};
// 格式化价格信息为字符串(用于卡片视图)
export const formatPriceInfo = (priceData, t) => {
if (priceData.isPerToken) {
return `${t('输入')} ${priceData.inputPrice}/${priceData.unitLabel} ${t('输出')} ${priceData.completionPrice}/${priceData.unitLabel}`;
} else {
return `${t('模型价格')} ${priceData.price}`;
}
};
// -------------------------------
// CardPro 分页配置函数
// 用于创建 CardPro 的 paginationArea 配置
@@ -626,8 +679,10 @@ export const resetPricingFilters = ({
setShowWithRecharge,
setCurrency,
setShowRatio,
setViewMode,
setFilterGroup,
setFilterQuotaType,
setCurrentPage,
}) => {
// 重置搜索
if (typeof handleChange === 'function') {
@@ -658,6 +713,11 @@ export const resetPricingFilters = ({
setShowRatio(false);
}
// 重置视图模式
if (typeof setViewMode === 'function') {
setViewMode('card');
}
// 重置分组筛选
if (typeof setFilterGroup === 'function') {
setFilterGroup('all');
@@ -667,4 +727,9 @@ export const resetPricingFilters = ({
if (typeof setFilterQuotaType === 'function') {
setFilterQuotaType('all');
}
// 重置当前页面
if (typeof setCurrentPage === 'function') {
setCurrentPage(1);
}
};

View File

@@ -178,7 +178,7 @@ export const useDashboardData = (userState, userDispatch, statusState) => {
}
} finally {
const elapsed = Date.now() - startTime;
const remainingTime = Math.max(0, 500 - elapsed);
const remainingTime = Math.max(0, 1000 - elapsed);
setTimeout(() => {
setLoading(false);
}, remainingTime);

View File

@@ -26,18 +26,17 @@ import { StatusContext } from '../../context/Status/index.js';
export const useModelPricingData = () => {
const { t } = useTranslation();
const [filteredValue, setFilteredValue] = useState([]);
const [searchValue, setSearchValue] = useState('');
const compositionRef = useRef({ isComposition: false });
const [selectedRowKeys, setSelectedRowKeys] = useState([]);
const [modalImageUrl, setModalImageUrl] = useState('');
const [isModalOpenurl, setIsModalOpenurl] = useState(false);
const [selectedGroup, setSelectedGroup] = useState('default');
// 用于 Table 的可用分组筛选“all” 表示不过滤
const [filterGroup, setFilterGroup] = useState('all');
// 计费类型筛选: 'all' | 0 | 1
const [filterQuotaType, setFilterQuotaType] = useState('all');
const [filterGroup, setFilterGroup] = useState('all'); // 用于 Table 的可用分组筛选“all” 表示不过滤
const [filterQuotaType, setFilterQuotaType] = useState('all'); // 计费类型筛选: 'all' | 0 | 1
const [activeKey, setActiveKey] = useState('all');
const [pageSize, setPageSize] = useState(10);
const [currentPage, setCurrentPage] = useState(1);
const [currency, setCurrency] = useState('USD');
const [showWithRecharge, setShowWithRecharge] = useState(false);
const [tokenUnit, setTokenUnit] = useState('M');
@@ -95,15 +94,15 @@ export const useModelPricingData = () => {
}
// 搜索筛选
if (filteredValue.length > 0) {
const searchTerm = filteredValue[0].toLowerCase();
if (searchValue.length > 0) {
const searchTerm = searchValue.toLowerCase();
result = result.filter(model =>
model.model_name.toLowerCase().includes(searchTerm)
);
}
return result;
}, [activeKey, models, filteredValue, filterGroup, filterQuotaType]);
}, [activeKey, models, searchValue, filterGroup, filterQuotaType]);
const rowSelection = useMemo(
() => ({
@@ -183,8 +182,8 @@ export const useModelPricingData = () => {
if (compositionRef.current.isComposition) {
return;
}
const newFilteredValue = value ? [value] : [];
setFilteredValue(newFilteredValue);
const newSearchValue = value ? value : '';
setSearchValue(newSearchValue);
};
const handleCompositionStart = () => {
@@ -194,8 +193,8 @@ export const useModelPricingData = () => {
const handleCompositionEnd = (event) => {
compositionRef.current.isComposition = false;
const value = event.target.value;
const newFilteredValue = value ? [value] : [];
setFilteredValue(newFilteredValue);
const newSearchValue = value ? value : '';
setSearchValue(newSearchValue);
};
const handleGroupClick = (group) => {
@@ -214,10 +213,15 @@ export const useModelPricingData = () => {
refresh().then();
}, []);
// 当筛选条件变化时重置到第一页
useEffect(() => {
setCurrentPage(1);
}, [activeKey, filterGroup, filterQuotaType, searchValue]);
return {
// 状态
filteredValue,
setFilteredValue,
searchValue,
setSearchValue,
selectedRowKeys,
setSelectedRowKeys,
modalImageUrl,
@@ -234,6 +238,8 @@ export const useModelPricingData = () => {
setActiveKey,
pageSize,
setPageSize,
currentPage,
setCurrentPage,
currency,
setCurrency,
showWithRecharge,

View File

@@ -617,4 +617,59 @@ html:not(.dark) .blur-ball-teal {
height: calc(100vh - 77px);
max-height: calc(100vh - 77px);
}
}
/* ==================== 模型定价页面布局 ==================== */
.pricing-layout {
height: calc(100vh - 60px);
overflow: hidden;
margin-top: 60px;
}
.pricing-sidebar {
min-width: 460px;
max-width: 460px;
height: calc(100vh - 60px);
background-color: var(--semi-color-bg-0);
border-right: 1px solid var(--semi-color-border);
overflow: auto;
}
.pricing-content {
height: calc(100vh - 60px);
background-color: var(--semi-color-bg-0);
display: flex;
flex-direction: column;
}
.pricing-pagination-divider {
border-color: var(--semi-color-border);
}
.pricing-content-mobile {
height: 100%;
display: flex;
flex-direction: column;
overflow: auto;
}
.pricing-search-header {
padding: 16px 24px;
border-bottom: 1px solid var(--semi-color-border);
background-color: var(--semi-color-bg-0);
flex-shrink: 0;
position: sticky;
top: 0;
z-index: 5;
}
.pricing-view-container {
flex: 1;
overflow: auto;
}
.pricing-view-container-mobile {
flex: 1;
overflow: auto;
min-height: 0;
}

View File

@@ -18,7 +18,7 @@ For commercial licensing, please contact support@quantumnous.com
*/
import React from 'react';
import ModelPricingPage from '../../components/table/model-pricing';
import ModelPricingPage from '../../components/table/model-pricing/layout/PricingPage';
const Pricing = () => (
<>