🎨 feat(ui): enhance pricing components with improved icons and responsive design

- Replace copy button icon from semi-ui IconCopy to lucide-react Copy in PricingCardView
- Add conditional tooltip functionality to SelectableButtonGroup that only shows when text overflows
- Implement responsive table column behavior in PricingTableColumns with mobile-aware fixed positioning
- Use DOM-based overflow detection (scrollWidth vs clientWidth) for better performance
- Apply useIsMobile hook to conditionally set fixed: 'right' only on desktop devices

These changes improve user experience across different screen sizes and provide more consistent iconography throughout the pricing interface.
This commit is contained in:
t0ng7u
2025-08-29 22:36:05 +08:00
parent 0f86c4df9e
commit 86964bb426
15 changed files with 207 additions and 62 deletions

View File

@@ -38,7 +38,7 @@ const ScrollableContainer = forwardRef(({
children,
maxHeight = '24rem',
className = '',
contentClassName = 'p-2',
contentClassName = '',
fadeIndicatorClassName = '',
checkInterval = 100,
scrollThreshold = 5,

View File

@@ -17,8 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React, { useState } from 'react';
import { useIsMobile } from '../../../hooks/common/useIsMobile';
import React, { useState, useRef, useEffect } from 'react';
import { useMinimumLoadingTime } from '../../../hooks/common/useMinimumLoadingTime';
import { useContainerWidth } from '../../../hooks/common/useContainerWidth';
import { Divider, Button, Tag, Row, Col, Collapsible, Checkbox, Skeleton, Tooltip } from '@douyinfe/semi-ui';
@@ -51,10 +50,34 @@ const SelectableButtonGroup = ({
loading = false
}) => {
const [isOpen, setIsOpen] = useState(false);
const [skeletonCount] = useState(6);
const isMobile = useIsMobile();
const [skeletonCount] = useState(12);
const [containerRef, containerWidth] = useContainerWidth();
const ConditionalTooltipText = ({ text }) => {
const textRef = useRef(null);
const [isOverflowing, setIsOverflowing] = useState(false);
useEffect(() => {
const el = textRef.current;
if (!el) return;
setIsOverflowing(el.scrollWidth > el.clientWidth);
}, [text, containerWidth]);
const textElement = (
<span ref={textRef} className="sbg-ellipsis">
{text}
</span>
);
return isOverflowing ? (
<Tooltip content={text}>
{textElement}
</Tooltip>
) : (
textElement
);
};
// 基于容器宽度计算响应式列数和标签显示策略
const getResponsiveConfig = () => {
if (containerWidth <= 280) return { columns: 1, showTags: true }; // 极窄1列+标签
@@ -176,9 +199,7 @@ const SelectableButtonGroup = ({
>
<div className="sbg-content">
{item.icon && (<span className="sbg-icon">{item.icon}</span>)}
<Tooltip content={item.label}>
<span className="sbg-ellipsis">{item.label}</span>
</Tooltip>
<ConditionalTooltipText text={item.label} />
{item.tagCount !== undefined && shouldShowTags && (
<Tag className="sbg-tag" color='white' shape="circle" size="small">{item.tagCount}</Tag>
)}
@@ -203,9 +224,7 @@ const SelectableButtonGroup = ({
>
<div className="sbg-content">
{item.icon && (<span className="sbg-icon">{item.icon}</span>)}
<Tooltip content={item.label}>
<span className="sbg-ellipsis">{item.label}</span>
</Tooltip>
<ConditionalTooltipText text={item.label} />
{item.tagCount !== undefined && shouldShowTags && (
<Tag className="sbg-tag" color='white' shape="circle" size="small">{item.tagCount}</Tag>
)}

View File

@@ -24,7 +24,7 @@ import PricingQuotaTypes from '../filter/PricingQuotaTypes';
import PricingEndpointTypes from '../filter/PricingEndpointTypes';
import PricingVendors from '../filter/PricingVendors';
import PricingTags from '../filter/PricingTags';
import PricingDisplaySettings from '../filter/PricingDisplaySettings';
import { resetPricingFilters } from '../../../../helpers/utils';
import { usePricingFilterCounts } from '../../../../hooks/model-pricing/usePricingFilterCounts';
@@ -107,21 +107,6 @@ const PricingSidebar = ({
</Button>
</div>
<PricingDisplaySettings
showWithRecharge={showWithRecharge}
setShowWithRecharge={setShowWithRecharge}
currency={currency}
setCurrency={setCurrency}
showRatio={showRatio}
setShowRatio={setShowRatio}
viewMode={viewMode}
setViewMode={setViewMode}
tokenUnit={tokenUnit}
setTokenUnit={setTokenUnit}
loading={loading}
t={t}
/>
<PricingVendors
filterVendor={filterVendor}
setFilterVendor={setFilterVendor}

View File

@@ -26,7 +26,21 @@ const PricingContent = ({ isMobile, sidebarProps, ...props }) => {
<div className={isMobile ? "pricing-content-mobile" : "pricing-scroll-hide"}>
{/* 固定的顶部区域(分类介绍 + 搜索和操作) */}
<div className="pricing-search-header">
<PricingTopSection {...props} isMobile={isMobile} sidebarProps={sidebarProps} />
<PricingTopSection
{...props}
isMobile={isMobile}
sidebarProps={sidebarProps}
showWithRecharge={sidebarProps.showWithRecharge}
setShowWithRecharge={sidebarProps.setShowWithRecharge}
currency={sidebarProps.currency}
setCurrency={sidebarProps.setCurrency}
showRatio={sidebarProps.showRatio}
setShowRatio={sidebarProps.setShowRatio}
viewMode={sidebarProps.viewMode}
setViewMode={sidebarProps.setViewMode}
tokenUnit={sidebarProps.tokenUnit}
setTokenUnit={sidebarProps.setTokenUnit}
/>
</div>
{/* 可滚动的内容区域 */}

View File

@@ -35,6 +35,16 @@ const PricingTopSection = memo(({
filteredModels,
loading,
searchValue,
showWithRecharge,
setShowWithRecharge,
currency,
setCurrency,
showRatio,
setShowRatio,
viewMode,
setViewMode,
tokenUnit,
setTokenUnit,
t
}) => {
const [showFilterModal, setShowFilterModal] = useState(false);
@@ -53,6 +63,16 @@ const PricingTopSection = memo(({
isMobile={isMobile}
searchValue={searchValue}
setShowFilterModal={setShowFilterModal}
showWithRecharge={showWithRecharge}
setShowWithRecharge={setShowWithRecharge}
currency={currency}
setCurrency={setCurrency}
showRatio={showRatio}
setShowRatio={setShowRatio}
viewMode={viewMode}
setViewMode={setViewMode}
tokenUnit={tokenUnit}
setTokenUnit={setTokenUnit}
t={t}
/>
</div>
@@ -78,6 +98,16 @@ const PricingTopSection = memo(({
isMobile={isMobile}
searchValue={searchValue}
setShowFilterModal={setShowFilterModal}
showWithRecharge={showWithRecharge}
setShowWithRecharge={setShowWithRecharge}
currency={currency}
setCurrency={setCurrency}
showRatio={showRatio}
setShowRatio={setShowRatio}
viewMode={viewMode}
setViewMode={setViewMode}
tokenUnit={tokenUnit}
setTokenUnit={setTokenUnit}
/>
)}
</>

View File

@@ -126,7 +126,17 @@ const PricingVendorIntro = memo(({
handleCompositionEnd,
isMobile = false,
searchValue = '',
setShowFilterModal
setShowFilterModal,
showWithRecharge,
setShowWithRecharge,
currency,
setCurrency,
showRatio,
setShowRatio,
viewMode,
setViewMode,
tokenUnit,
setTokenUnit
}) => {
const [currentOffset, setCurrentOffset] = useState(0);
const [descModalVisible, setDescModalVisible] = useState(false);
@@ -239,9 +249,19 @@ const PricingVendorIntro = memo(({
isMobile={isMobile}
searchValue={searchValue}
setShowFilterModal={setShowFilterModal}
showWithRecharge={showWithRecharge}
setShowWithRecharge={setShowWithRecharge}
currency={currency}
setCurrency={setCurrency}
showRatio={showRatio}
setShowRatio={setShowRatio}
viewMode={viewMode}
setViewMode={setViewMode}
tokenUnit={tokenUnit}
setTokenUnit={setTokenUnit}
t={t}
/>
), [selectedRowKeys, copyText, handleChange, handleCompositionStart, handleCompositionEnd, isMobile, searchValue, setShowFilterModal, t]);
), [selectedRowKeys, copyText, handleChange, handleCompositionStart, handleCompositionEnd, isMobile, searchValue, setShowFilterModal, showWithRecharge, setShowWithRecharge, currency, setCurrency, showRatio, setShowRatio, viewMode, setViewMode, tokenUnit, setTokenUnit, t]);
const renderHeaderCard = useCallback(({ title, count, description, rightContent, primaryDarkerChannel }) => (
<Card className="!rounded-2xl shadow-sm border-0"

View File

@@ -18,8 +18,8 @@ For commercial licensing, please contact support@quantumnous.com
*/
import React, { memo, useCallback } from 'react';
import { Input, Button } from '@douyinfe/semi-ui';
import { IconSearch, IconCopy, IconFilter } from '@douyinfe/semi-icons';
import { Input, Button, Switch, Select, Divider, Tooltip } from '@douyinfe/semi-ui';
import { IconSearch, IconCopy, IconFilter, IconHelpCircle } from '@douyinfe/semi-icons';
const SearchActions = memo(({
selectedRowKeys = [],
@@ -30,6 +30,16 @@ const SearchActions = memo(({
isMobile = false,
searchValue = '',
setShowFilterModal,
showWithRecharge,
setShowWithRecharge,
currency,
setCurrency,
showRatio,
setShowRatio,
viewMode,
setViewMode,
tokenUnit,
setTokenUnit,
t
}) => {
const handleCopyClick = useCallback(() => {
@@ -42,6 +52,14 @@ const SearchActions = memo(({
setShowFilterModal?.(true);
}, [setShowFilterModal]);
const handleViewModeToggle = useCallback(() => {
setViewMode?.(viewMode === 'table' ? 'card' : 'table');
}, [viewMode, setViewMode]);
const handleTokenUnitToggle = useCallback(() => {
setTokenUnit?.(tokenUnit === 'K' ? 'M' : 'K');
}, [tokenUnit, setTokenUnit]);
return (
<div className="flex items-center gap-2 w-full">
<div className="flex-1">
@@ -67,6 +85,63 @@ const SearchActions = memo(({
{t('复制')}
</Button>
{!isMobile && (
<>
<Divider layout="vertical" margin="8px" />
{/* 充值价格显示开关 */}
<div className="flex items-center gap-2">
<span className="text-sm text-gray-600">{t('充值价格显示')}</span>
<Switch
checked={showWithRecharge}
onChange={setShowWithRecharge}
/>
</div>
{/* 货币单位选择 */}
{showWithRecharge && (
<Select
value={currency}
onChange={setCurrency}
optionList={[
{ value: 'USD', label: 'USD' },
{ value: 'CNY', label: 'CNY' }
]}
/>
)}
{/* 显示倍率开关 */}
<div className="flex items-center gap-2">
<span className="text-sm text-gray-600">{t('倍率')}</span>
<Tooltip content={t('倍率是用于系统计算不同模型的最终价格用的,如果您不理解倍率,请忽略')}>
<IconHelpCircle size="small" style={{ color: 'var(--semi-color-text-2)', cursor: 'help' }} />
</Tooltip>
<Switch
checked={showRatio}
onChange={setShowRatio}
/>
</div>
{/* 视图模式切换按钮 */}
<Button
theme={viewMode === 'table' ? 'solid' : 'outline'}
type={viewMode === 'table' ? 'primary' : 'tertiary'}
onClick={handleViewModeToggle}
>
{t('表格视图')}
</Button>
{/* Token单位切换按钮 */}
<Button
theme={tokenUnit === 'K' ? 'solid' : 'outline'}
type={tokenUnit === 'K' ? 'primary' : 'tertiary'}
onClick={handleTokenUnitToggle}
>
{tokenUnit}
</Button>
</>
)}
{isMobile && (
<Button
theme="outline"

View File

@@ -71,7 +71,7 @@ const FilterModalContent = ({ sidebarProps, t }) => {
});
return (
<div className="p-2">
<>
<PricingDisplaySettings
showWithRecharge={showWithRecharge}
setShowWithRecharge={setShowWithRecharge}
@@ -131,7 +131,7 @@ const FilterModalContent = ({ sidebarProps, t }) => {
loading={loading}
t={t}
/>
</div>
</>
);
};

View File

@@ -92,7 +92,7 @@ const PricingCardSkeleton = ({
size="small"
style={{
width: 64,
height: 20,
height: 18,
borderRadius: 10
}}
/>

View File

@@ -19,7 +19,8 @@ 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 { IconHelpCircle } from '@douyinfe/semi-icons';
import { Copy } from 'lucide-react';
import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations';
import { stringToColor, calculateModelPrice, formatPriceInfo, getLobeHubIcon } from '../../../../../helpers';
import PricingCardSkeleton from './PricingCardSkeleton';
@@ -245,7 +246,7 @@ const PricingCardView = ({
size="small"
theme="outline"
type="tertiary"
icon={<IconCopy />}
icon={<Copy size={12} />}
onClick={(e) => {
e.stopPropagation();
copyText(model.model_name);

View File

@@ -38,7 +38,6 @@ const PricingTable = ({
setIsModalOpenurl,
currency,
tokenUnit,
setTokenUnit,
displayPrice,
searchValue,
showRatio,
@@ -99,7 +98,6 @@ const PricingTable = ({
dataSource={filteredModels}
loading={loading}
rowSelection={rowSelection}
className="custom-table"
scroll={compactMode ? undefined : { x: 'max-content' }}
onRow={(record) => ({
onClick: () => openModelDetail && openModelDetail(record),
@@ -114,7 +112,7 @@ const PricingTable = ({
/>
}
pagination={{
defaultPageSize: 100,
defaultPageSize: 20,
pageSize: pageSize,
showSizeChanger: true,
pageSizeOptions: [10, 20, 50, 100],

View File

@@ -22,6 +22,7 @@ import { Tag, Space, Tooltip } from '@douyinfe/semi-ui';
import { IconHelpCircle } from '@douyinfe/semi-icons';
import { renderModelTag, stringToColor, calculateModelPrice, getLobeHubIcon } from '../../../../../helpers';
import { renderLimitedItems, renderDescription } from '../../../../common/ui/RenderUtils';
import { useIsMobile } from '../../../../../hooks/common/useIsMobile';
function renderQuotaType(type, t) {
switch (type) {
@@ -98,7 +99,7 @@ export const getPricingTableColumns = ({
displayPrice,
showRatio,
}) => {
const isMobile = useIsMobile();
const priceDataCache = new WeakMap();
const getPriceData = (record) => {
@@ -207,7 +208,7 @@ export const getPricingTableColumns = ({
const priceColumn = {
title: t('模型价格'),
dataIndex: 'model_price',
fixed: 'right',
...(isMobile ? {} : { fixed: 'right' }),
render: (text, record, index) => {
const priceData = getPriceData(record);
@@ -215,10 +216,10 @@ export const getPricingTableColumns = ({
return (
<div className="space-y-1">
<div className="text-gray-700">
{t('提示')} {priceData.inputPrice} / 1{priceData.unitLabel} tokens
{t('输入')} {priceData.inputPrice} / 1{priceData.unitLabel} tokens
</div>
<div className="text-gray-700">
{t('补全')} {priceData.completionPrice} / 1{priceData.unitLabel} tokens
{t('输出')} {priceData.completionPrice} / 1{priceData.unitLabel} tokens
</div>
</div>
);

View File

@@ -39,7 +39,7 @@ export const useModelPricingData = () => {
const [filterEndpointType, setFilterEndpointType] = useState('all'); // 端点类型筛选: 'all' | string
const [filterVendor, setFilterVendor] = useState('all'); // 供应商筛选: 'all' | 'unknown' | string
const [filterTag, setFilterTag] = useState('all'); // 模型标签筛选: 'all' | string
const [pageSize, setPageSize] = useState(100);
const [pageSize, setPageSize] = useState(20);
const [currentPage, setCurrentPage] = useState(1);
const [currency, setCurrency] = useState('USD');
const [showWithRecharge, setShowWithRecharge] = useState(false);

View File

@@ -293,7 +293,8 @@
"账号绑定": "Account Binding",
"绑定微信账号": "Bind WeChat Account",
"微信扫码关注公众号": "Scan the QR code with WeChat to follow the official account",
"输入": "Enter",
"输入": "Input",
"输出": "Output",
"验证码": "Verification Code",
"获取验证码": "Get Verification Code",
"三分钟内有效": "Valid for three minutes",

View File

@@ -52,22 +52,6 @@ code {
}
/* ==================== 导航和侧边栏样式 ==================== */
.semi-radio,
.semi-tagInput,
.semi-input-textarea-wrapper,
.semi-navigation-sub-title,
.semi-chat-inputBox-sendButton,
.semi-page-item,
.semi-navigation-item,
.semi-tag-closable,
.semi-input-wrapper,
.semi-tabs-tab-button,
.semi-select,
.semi-button,
.semi-datepicker-range-input {
border-radius: 10px !important;
}
.semi-navigation-item {
margin-bottom: 4px !important;
padding: 4px 12px !important;
@@ -778,4 +762,21 @@ html.dark .with-pastel-balls::before {
.semi-card-header,
.semi-card-body {
padding: 10px !important;
}
/* ==================== 自定义圆角样式 ==================== */
.semi-radio,
.semi-tagInput,
.semi-input-textarea-wrapper,
.semi-navigation-sub-title,
.semi-chat-inputBox-sendButton,
.semi-page-item,
.semi-navigation-item,
.semi-tag-closable,
.semi-input-wrapper,
.semi-tabs-tab-button,
.semi-select,
.semi-button,
.semi-datepicker-range-input {
border-radius: 10px !important;
}