🎨 feat(model-pricing): refactor layout and component structure (#1365)

* Re-architected model-pricing page into modular components:
  * PricingPage / PricingSidebar / PricingContent
  * Removed obsolete `ModelPricing*` components and column defs
* Introduced reusable `SelectableButtonGroup` in `common/ui`
  * Supports Row/Col grid (3 per row)
  * Optional collapsible mode with gradient mask & toggle
* Rebuilt filter panels with the new button-group:
  * Model categories, token groups, and quota types
  * Added dynamic `tagCount` badges to display item totals
* Extended `useModelPricingData` hook
  * Added `filterGroup` and `filterQuotaType` state and logic
* Updated PricingTable columns & sidebar reset logic to respect new states
* Ensured backward compatibility via re-export in `index.jsx`
* Polished styling, icons and i18n keys
This commit is contained in:
t0ng7u
2025-07-23 01:58:51 +08:00
parent e0b859dbbe
commit a044070e1d
18 changed files with 773 additions and 294 deletions

View File

@@ -0,0 +1,147 @@
/*
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 } from 'react';
import { Divider, Button, Tag, Row, Col, Collapsible } from '@douyinfe/semi-ui';
import { IconChevronDown, IconChevronUp } from '@douyinfe/semi-icons';
/**
* 通用可选择按钮组组件
*
* @param {string} title 标题
* @param {Array<{value:any,label:string,icon?:React.ReactNode,tagCount?:number}>} items 按钮项
* @param {*} activeValue 当前激活的值
* @param {(value:any)=>void} onChange 选择改变回调
* @param {function} t i18n
* @param {object} style 额外样式
* @param {boolean} collapsible 是否支持折叠默认true
* @param {number} collapseHeight 折叠时的高度默认200
*/
const SelectableButtonGroup = ({
title,
items = [],
activeValue,
onChange,
t = (v) => v,
style = {},
collapsible = true,
collapseHeight = 200
}) => {
const [isOpen, setIsOpen] = useState(false);
const perRow = 3;
const maxVisibleRows = Math.max(1, Math.floor(collapseHeight / 32)); // Approx row height 32
const needCollapse = collapsible && items.length > perRow * maxVisibleRows;
const contentRef = useRef(null);
const maskStyle = isOpen
? {}
: {
WebkitMaskImage:
'linear-gradient(to bottom, black 0%, rgba(0, 0, 0, 1) 60%, rgba(0, 0, 0, 0.2) 80%, transparent 100%)',
};
const toggle = () => {
setIsOpen(!isOpen);
};
const linkStyle = {
position: 'absolute',
left: 0,
right: 0,
textAlign: 'center',
bottom: -10,
fontWeight: 400,
cursor: 'pointer',
fontSize: '12px',
color: 'var(--semi-color-text-2)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 4,
};
const contentElement = (
<Row gutter={[8, 8]} style={{ lineHeight: '32px', ...style }} ref={contentRef}>
{items.map((item) => {
const isActive = activeValue === item.value;
return (
<Col span={8} key={item.value}>
<Button
onClick={() => onChange(item.value)}
theme={isActive ? 'solid' : 'outline'}
type={isActive ? 'primary' : 'tertiary'}
icon={item.icon}
style={{ width: '100%' }}
>
<span style={{ marginRight: item.tagCount !== undefined ? 4 : 0 }}>{item.label}</span>
{item.tagCount !== undefined && (
<Tag
color='white'
shape="circle"
size="small"
>
{item.tagCount}
</Tag>
)}
</Button>
</Col>
);
})}
</Row>
);
return (
<div className="mb-8">
{title && (
<Divider margin="12px" align="left">
{title}
</Divider>
)}
{needCollapse ? (
<div style={{ position: 'relative' }}>
<Collapsible isOpen={isOpen} collapseHeight={collapseHeight} style={{ ...maskStyle }}>
{contentElement}
</Collapsible>
{isOpen ? null : (
<div onClick={toggle} style={{ ...linkStyle }}>
<IconChevronDown size="small" />
<span>{t('展开更多')}</span>
</div>
)}
{isOpen && (
<div onClick={toggle} style={{
...linkStyle,
position: 'static',
marginTop: 8,
bottom: 'auto'
}}>
<IconChevronUp size="small" />
<span>{t('收起')}</span>
</div>
)}
</div>
) : (
contentElement
)}
</div>
);
};
export default SelectableButtonGroup;

View File

@@ -467,7 +467,7 @@ const HeaderBar = ({ onMobileMenuToggle, drawerOpen }) => {
};
return (
<header className="text-semi-color-text-0 sticky top-0 z-50 transition-colors duration-300 bg-white/75 dark:bg-zinc-900/75 backdrop-blur-lg">
<header className="text-semi-color-text-0 sticky top-0 z-50 transition-colors duration-300 bg-white/75 dark:bg-zinc-900/75 backdrop-blur-lg" style={{ borderBottom: '1px solid var(--semi-color-border)' }}>
<NoticeModal
visible={noticeVisible}
onClose={handleNoticeClose}

View File

@@ -42,7 +42,7 @@ const PageLayout = () => {
const { i18n } = useTranslation();
const location = useLocation();
const shouldHideFooter = location.pathname.startsWith('/console');
const shouldHideFooter = location.pathname.startsWith('/console') || location.pathname === '/pricing';
const shouldInnerPadding = location.pathname.includes('/console') &&
!location.pathname.startsWith('/console/chat') &&

View File

@@ -1,87 +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, { useMemo } from 'react';
import { Card, Input, Button, Space, Switch, Select } from '@douyinfe/semi-ui';
import { IconSearch, IconCopy } from '@douyinfe/semi-icons';
const ModelPricingFilters = ({
selectedRowKeys,
copyText,
showWithRecharge,
setShowWithRecharge,
currency,
setCurrency,
handleChange,
handleCompositionStart,
handleCompositionEnd,
t
}) => {
const SearchAndActions = useMemo(() => (
<Card className="!rounded-xl mb-6" bordered={false}>
<div className="flex flex-wrap items-center gap-4">
<div className="flex-1 min-w-[200px]">
<Input
prefix={<IconSearch />}
placeholder={t('模糊搜索模型名称')}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
onChange={handleChange}
showClear
/>
</div>
<Button
theme='light'
type='primary'
icon={<IconCopy />}
onClick={() => copyText(selectedRowKeys)}
disabled={selectedRowKeys.length === 0}
className="!bg-blue-500 hover:!bg-blue-600 text-white"
>
{t('复制选中模型')}
</Button>
{/* 充值价格显示开关 */}
<Space align="center">
<span>{t('以充值价格显示')}</span>
<Switch
checked={showWithRecharge}
onChange={setShowWithRecharge}
size="small"
/>
{showWithRecharge && (
<Select
value={currency}
onChange={setCurrency}
size="small"
style={{ width: 100 }}
>
<Select.Option value="USD">USD ($)</Select.Option>
<Select.Option value="CNY">CNY (¥)</Select.Option>
</Select>
)}
</Space>
</div>
</Card>
), [selectedRowKeys, t, showWithRecharge, currency, handleCompositionStart, handleCompositionEnd, handleChange, copyText, setShowWithRecharge, setCurrency]);
return SearchAndActions;
};
export default ModelPricingFilters;

View File

@@ -1,67 +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 { Tabs, TabPane, Tag } from '@douyinfe/semi-ui';
const ModelPricingTabs = ({
activeKey,
setActiveKey,
modelCategories,
categoryCounts,
availableCategories,
t
}) => {
return (
<Tabs
activeKey={activeKey}
type="card"
collapsible
onChange={key => setActiveKey(key)}
className="mt-2"
>
{Object.entries(modelCategories)
.filter(([key]) => availableCategories.includes(key))
.map(([key, category]) => {
const modelCount = categoryCounts[key] || 0;
return (
<TabPane
tab={
<span className="flex items-center gap-2">
{category.icon && <span className="w-4 h-4">{category.icon}</span>}
{category.label}
<Tag
color={activeKey === key ? 'red' : 'grey'}
shape='circle'
>
{modelCount}
</Tag>
</span>
}
itemKey={key}
key={key}
/>
);
})}
</Tabs>
);
};
export default ModelPricingTabs;

View File

@@ -0,0 +1,52 @@
/*
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 PricingSearchBar from './PricingSearchBar.jsx';
import PricingTable from './PricingTable.jsx';
const PricingContent = (props) => {
return (
<>
{/* 固定的搜索和操作区域 */}
<div
style={{
padding: '16px 24px',
borderBottom: '1px solid var(--semi-color-border)',
backgroundColor: 'var(--semi-color-bg-0)',
flexShrink: 0
}}
>
<PricingSearchBar {...props} />
</div>
{/* 可滚动的内容区域 */}
<div
style={{
flex: 1,
overflow: 'auto'
}}
>
<PricingTable {...props} />
</div>
</>
);
};
export default PricingContent;

View File

@@ -22,7 +22,7 @@ import { Card } from '@douyinfe/semi-ui';
import { IconVerify, IconLayers, IconInfoCircle } from '@douyinfe/semi-icons';
import { AlertCircle } from 'lucide-react';
const ModelPricingHeader = ({
const PricingHeader = ({
userState,
groupRatio,
selectedGroup,
@@ -120,4 +120,4 @@ const ModelPricingHeader = ({
);
};
export default ModelPricingHeader;
export default PricingHeader;

View File

@@ -0,0 +1,72 @@
/*
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 { Layout, ImagePreview } from '@douyinfe/semi-ui';
import PricingSidebar from './PricingSidebar.jsx';
import PricingContent from './PricingContent.jsx';
import { useModelPricingData } from '../../../hooks/model-pricing/useModelPricingData.js';
const PricingPage = () => {
const pricingData = useModelPricingData();
const { Sider, Content } = Layout;
// 显示倍率状态
const [showRatio, setShowRatio] = React.useState(false);
return (
<div className="bg-white">
<Layout style={{ height: 'calc(100vh - 60px)', overflow: 'hidden', marginTop: '60px' }}>
{/* 左侧边栏 */}
<Sider
style={{
width: 460,
height: 'calc(100vh - 60px)',
backgroundColor: 'var(--semi-color-bg-0)',
borderRight: '1px solid var(--semi-color-border)',
overflow: 'auto'
}}
>
<PricingSidebar {...pricingData} showRatio={showRatio} setShowRatio={setShowRatio} />
</Sider>
{/* 右侧内容区 */}
<Content
style={{
height: 'calc(100vh - 60px)',
backgroundColor: 'var(--semi-color-bg-0)',
display: 'flex',
flexDirection: 'column'
}}
>
<PricingContent {...pricingData} showRatio={showRatio} />
</Content>
</Layout>
{/* 倍率说明图预览 */}
<ImagePreview
src={pricingData.modalImageUrl}
visible={pricingData.isModalOpenurl}
onVisibleChange={(visible) => pricingData.setIsModalOpenurl(visible)}
/>
</div>
);
};
export default PricingPage;

View File

@@ -0,0 +1,63 @@
/*
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, { useMemo } from 'react';
import { Input, Button } from '@douyinfe/semi-ui';
import { IconSearch, IconCopy } from '@douyinfe/semi-icons';
const PricingSearchBar = ({
selectedRowKeys,
copyText,
handleChange,
handleCompositionStart,
handleCompositionEnd,
t
}) => {
const SearchAndActions = useMemo(() => (
<div className="flex items-center gap-4 w-full">
{/* 搜索框 */}
<div className="flex-1">
<Input
prefix={<IconSearch />}
placeholder={t('模糊搜索模型名称')}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
onChange={handleChange}
showClear
/>
</div>
{/* 操作按钮 */}
<Button
theme='light'
type='primary'
icon={<IconCopy />}
onClick={() => copyText(selectedRowKeys)}
disabled={selectedRowKeys.length === 0}
className="!bg-blue-500 hover:!bg-blue-600 text-white"
>
{t('复制选中模型')}
</Button>
</div>
), [selectedRowKeys, t, handleCompositionStart, handleCompositionEnd, handleChange, copyText]);
return SearchAndActions;
};
export default PricingSearchBar;

View File

@@ -0,0 +1,153 @@
/*
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 { Divider, Button, Switch, Select, Tooltip } from '@douyinfe/semi-ui';
import { IconHelpCircle } from '@douyinfe/semi-icons';
import PricingCategories from './sidebar/PricingCategories.jsx';
import PricingGroups from './sidebar/PricingGroups.jsx';
import PricingQuotaTypes from './sidebar/PricingQuotaTypes.jsx';
const PricingSidebar = ({
showWithRecharge,
setShowWithRecharge,
currency,
setCurrency,
handleChange,
setActiveKey,
showRatio,
setShowRatio,
filterGroup,
setFilterGroup,
filterQuotaType,
setFilterQuotaType,
t,
...categoryProps
}) => {
// 重置所有筛选条件
const handleResetFilters = () => {
// 重置搜索
if (handleChange) {
handleChange('');
}
// 重置模型分类到默认
if (setActiveKey && categoryProps.availableCategories?.length > 0) {
setActiveKey(categoryProps.availableCategories[0]);
}
// 重置充值价格显示
if (setShowWithRecharge) {
setShowWithRecharge(false);
}
// 重置货币
if (setCurrency) {
setCurrency('USD');
}
// 重置显示倍率
setShowRatio(false);
// 重置分组筛选
if (setFilterGroup) {
setFilterGroup('all');
}
// 重置计费类型筛选
if (setFilterQuotaType) {
setFilterQuotaType('all');
}
};
return (
<div className="p-4">
{/* 筛选标题和重置按钮 */}
<div className="flex items-center justify-between mb-6">
<div className="text-lg font-semibold text-gray-800">
{t('筛选')}
</div>
<Button
theme="outline"
onClick={handleResetFilters}
className="text-gray-500 hover:text-gray-700"
>
{t('重置')}
</Button>
</div>
{/* 显示设置 */}
<div className="mb-6">
<Divider margin='12px' align='left'>
{t('显示设置')}
</Divider>
<div className="px-2">
<div className="flex items-center justify-between mb-3">
<span className="text-sm text-gray-700">{t('以充值价格显示')}</span>
<Switch
checked={showWithRecharge}
onChange={setShowWithRecharge}
size="small"
/>
</div>
{showWithRecharge && (
<div className="mt-2 mb-3">
<div className="text-xs text-gray-500 mb-1">{t('货币单位')}</div>
<Select
value={currency}
onChange={setCurrency}
size="small"
className="w-full"
>
<Select.Option value="USD">USD ($)</Select.Option>
<Select.Option value="CNY">CNY (¥)</Select.Option>
</Select>
</div>
)}
<div className="flex items-center justify-between">
<div className="flex items-center gap-1">
<span className="text-sm text-gray-700">{t('显示倍率')}</span>
<Tooltip content={t('倍率是用于系统计算不同模型的最终价格用的,如果您不理解倍率,请忽略')}>
<IconHelpCircle
size="small"
style={{ color: 'var(--semi-color-text-2)', cursor: 'help' }}
/>
</Tooltip>
</div>
<Switch
checked={showRatio}
onChange={setShowRatio}
size="small"
/>
</div>
</div>
</div>
{/* 模型分类 */}
<PricingCategories {...categoryProps} setActiveKey={setActiveKey} t={t} />
<PricingGroups filterGroup={filterGroup} setFilterGroup={setFilterGroup} usableGroup={categoryProps.usableGroup} models={categoryProps.models} t={t} />
<PricingQuotaTypes filterQuotaType={filterQuotaType} setFilterQuotaType={setFilterQuotaType} models={categoryProps.models} t={t} />
</div>
);
};
export default PricingSidebar;

View File

@@ -23,9 +23,9 @@ import {
IllustrationNoResult,
IllustrationNoResultDark
} from '@douyinfe/semi-illustrations';
import { getModelPricingColumns } from './ModelPricingColumnDefs.js';
import { getPricingTableColumns } from './PricingTableColumns.js';
const ModelPricingTable = ({
const PricingTable = ({
filteredModels,
loading,
rowSelection,
@@ -44,10 +44,12 @@ const ModelPricingTable = ({
displayPrice,
filteredValue,
handleGroupClick,
showRatio,
t
}) => {
const columns = useMemo(() => {
return getModelPricingColumns({
return getPricingTableColumns({
t,
selectedGroup,
usableGroup,
@@ -61,6 +63,7 @@ const ModelPricingTable = ({
setTokenUnit,
displayPrice,
handleGroupClick,
showRatio,
});
}, [
t,
@@ -76,6 +79,7 @@ const ModelPricingTable = ({
setTokenUnit,
displayPrice,
handleGroupClick,
showRatio,
]);
// filteredValue
@@ -121,4 +125,4 @@ const ModelPricingTable = ({
return ModelTable;
};
export default ModelPricingTable;
export default PricingTable;

View File

@@ -76,7 +76,7 @@ function renderSupportedEndpoints(endpoints) {
);
}
export const getModelPricingColumns = ({
export const getPricingTableColumns = ({
t,
selectedGroup,
usableGroup,
@@ -90,8 +90,9 @@ export const getModelPricingColumns = ({
setTokenUnit,
displayPrice,
handleGroupClick,
showRatio,
}) => {
return [
const baseColumns = [
{
title: t('可用性'),
dataIndex: 'available',
@@ -166,96 +167,109 @@ export const getModelPricingColumns = ({
);
},
},
{
title: () => (
<div className="flex items-center space-x-1">
<span>{t('倍率')}</span>
<Tooltip content={t('倍率是为了方便换算不同价格的模型')}>
<IconHelpCircle
className="text-blue-500 cursor-pointer"
onClick={() => {
setModalImageUrl('/ratio.png');
setIsModalOpenurl(true);
}}
/>
</Tooltip>
];
// 倍率列 - 只有在showRatio为true时才包含
const ratioColumn = {
title: () => (
<div className="flex items-center space-x-1">
<span>{t('倍率')}</span>
<Tooltip content={t('倍率是为了方便换算不同价格的模型')}>
<IconHelpCircle
className="text-blue-500 cursor-pointer"
onClick={() => {
setModalImageUrl('/ratio.png');
setIsModalOpenurl(true);
}}
/>
</Tooltip>
</div>
),
dataIndex: 'model_ratio',
render: (text, record, index) => {
let content = text;
let completionRatio = parseFloat(record.completion_ratio.toFixed(3));
content = (
<div className="space-y-1">
<div className="text-gray-700">
{t('模型倍率')}{record.quota_type === 0 ? text : t('无')}
</div>
<div className="text-gray-700">
{t('补全倍率')}
{record.quota_type === 0 ? completionRatio : t('无')}
</div>
<div className="text-gray-700">
{t('分组倍率')}{groupRatio[selectedGroup]}
</div>
</div>
),
dataIndex: 'model_ratio',
render: (text, record, index) => {
let content = text;
let completionRatio = parseFloat(record.completion_ratio.toFixed(3));
);
return content;
},
};
// 价格列
const priceColumn = {
title: (
<div className="flex items-center space-x-2">
<span>{t('模型价格')}</span>
{/* 计费单位切换 */}
<Switch
checked={tokenUnit === 'K'}
onChange={(checked) => setTokenUnit(checked ? 'K' : 'M')}
checkedText="K"
uncheckedText="M"
/>
</div>
),
dataIndex: 'model_price',
render: (text, record, index) => {
let content = text;
if (record.quota_type === 0) {
let inputRatioPriceUSD = record.model_ratio * 2 * groupRatio[selectedGroup];
let completionRatioPriceUSD =
record.model_ratio * record.completion_ratio * 2 * groupRatio[selectedGroup];
const unitDivisor = tokenUnit === 'K' ? 1000 : 1;
const unitLabel = tokenUnit === 'K' ? 'K' : 'M';
let displayInput = displayPrice(inputRatioPriceUSD);
let displayCompletion = displayPrice(completionRatioPriceUSD);
const divisor = unitDivisor;
const numInput = parseFloat(displayInput.replace(/[^0-9.]/g, '')) / divisor;
const numCompletion = parseFloat(displayCompletion.replace(/[^0-9.]/g, '')) / divisor;
displayInput = `${currency === 'CNY' ? '¥' : '$'}${numInput.toFixed(3)}`;
displayCompletion = `${currency === 'CNY' ? '¥' : '$'}${numCompletion.toFixed(3)}`;
content = (
<div className="space-y-1">
<div className="text-gray-700">
{t('模型倍率')}{record.quota_type === 0 ? text : t('无')}
{t('提示')} {displayInput} / 1{unitLabel} tokens
</div>
<div className="text-gray-700">
{t('补全倍率')}
{record.quota_type === 0 ? completionRatio : t('无')}
</div>
<div className="text-gray-700">
{t('分组倍率')}{groupRatio[selectedGroup]}
{t('补全')} {displayCompletion} / 1{unitLabel} tokens
</div>
</div>
);
return content;
},
} else {
let priceUSD = parseFloat(text) * groupRatio[selectedGroup];
let displayVal = displayPrice(priceUSD);
content = (
<div className="text-gray-700">
{t('模型价格')}{displayVal}
</div>
);
}
return content;
},
{
title: (
<div className="flex items-center space-x-2">
<span>{t('模型价格')}</span>
{/* 计费单位切换 */}
<Switch
checked={tokenUnit === 'K'}
onChange={(checked) => setTokenUnit(checked ? 'K' : 'M')}
checkedText="K"
uncheckedText="M"
/>
</div>
),
dataIndex: 'model_price',
render: (text, record, index) => {
let content = text;
if (record.quota_type === 0) {
let inputRatioPriceUSD = record.model_ratio * 2 * groupRatio[selectedGroup];
let completionRatioPriceUSD =
record.model_ratio * record.completion_ratio * 2 * groupRatio[selectedGroup];
};
const unitDivisor = tokenUnit === 'K' ? 1000 : 1;
const unitLabel = tokenUnit === 'K' ? 'K' : 'M';
// 根据showRatio决定是否包含倍率列
const columns = [...baseColumns];
if (showRatio) {
columns.push(ratioColumn);
}
columns.push(priceColumn);
let displayInput = displayPrice(inputRatioPriceUSD);
let displayCompletion = displayPrice(completionRatioPriceUSD);
const divisor = unitDivisor;
const numInput = parseFloat(displayInput.replace(/[^0-9.]/g, '')) / divisor;
const numCompletion = parseFloat(displayCompletion.replace(/[^0-9.]/g, '')) / divisor;
displayInput = `${currency === 'CNY' ? '¥' : '$'}${numInput.toFixed(3)}`;
displayCompletion = `${currency === 'CNY' ? '¥' : '$'}${numCompletion.toFixed(3)}`;
content = (
<div className="space-y-1">
<div className="text-gray-700">
{t('提示')} {displayInput} / 1{unitLabel} tokens
</div>
<div className="text-gray-700">
{t('补全')} {displayCompletion} / 1{unitLabel} tokens
</div>
</div>
);
} else {
let priceUSD = parseFloat(text) * groupRatio[selectedGroup];
let displayVal = displayPrice(priceUSD);
content = (
<div className="text-gray-700">
{t('模型价格')}{displayVal}
</div>
);
}
return content;
},
},
];
return columns;
};

View File

@@ -17,50 +17,5 @@ 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 { Layout, Card, ImagePreview } from '@douyinfe/semi-ui';
import ModelPricingTabs from './ModelPricingTabs.jsx';
import ModelPricingFilters from './ModelPricingFilters.jsx';
import ModelPricingTable from './ModelPricingTable.jsx';
import ModelPricingHeader from './ModelPricingHeader.jsx';
import { useModelPricingData } from '../../../hooks/model-pricing/useModelPricingData.js';
const ModelPricingPage = () => {
const modelPricingData = useModelPricingData();
return (
<div className="bg-gray-50">
<Layout>
<Layout.Content>
<div className="flex justify-center">
<div className="w-full">
{/* 主卡片容器 */}
<Card bordered={false} className="!rounded-2xl shadow-lg border-0">
{/* 顶部状态卡片 */}
<ModelPricingHeader {...modelPricingData} />
{/* 模型分类 Tabs */}
<div className="mb-6">
<ModelPricingTabs {...modelPricingData} />
{/* 搜索和表格区域 */}
<ModelPricingFilters {...modelPricingData} />
<ModelPricingTable {...modelPricingData} />
</div>
{/* 倍率说明图预览 */}
<ImagePreview
src={modelPricingData.modalImageUrl}
visible={modelPricingData.isModalOpenurl}
onVisibleChange={(visible) => modelPricingData.setIsModalOpenurl(visible)}
/>
</Card>
</div>
</div>
</Layout.Content>
</Layout>
</div>
);
};
export default ModelPricingPage;
// 为了向后兼容,这里重新导出新的 PricingPage 组件
export { default } from './PricingPage.jsx';

View File

@@ -0,0 +1,44 @@
/*
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 SelectableButtonGroup from '../../../common/ui/SelectableButtonGroup.jsx';
const PricingCategories = ({ activeKey, setActiveKey, modelCategories, categoryCounts, availableCategories, t }) => {
const items = Object.entries(modelCategories)
.filter(([key]) => availableCategories.includes(key))
.map(([key, category]) => ({
value: key,
label: category.label,
icon: category.icon,
tagCount: categoryCounts[key] || 0,
}));
return (
<SelectableButtonGroup
title={t('模型分类')}
items={items}
activeValue={activeKey}
onChange={setActiveKey}
t={t}
/>
);
};
export default PricingCategories;

View File

@@ -0,0 +1,58 @@
/*
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 SelectableButtonGroup from '../../../common/ui/SelectableButtonGroup.jsx';
/**
* 分组筛选组件
* @param {string} filterGroup 当前选中的分组,'all' 表示不过滤
* @param {Function} setFilterGroup 设置选中分组
* @param {Record<string, any>} usableGroup 后端返回的可用分组对象
* @param {Function} t i18n
*/
const PricingGroups = ({ filterGroup, setFilterGroup, usableGroup = {}, models = [], t }) => {
const groups = ['all', ...Object.keys(usableGroup)];
const items = groups.map((g) => {
let count = 0;
if (g === 'all') {
count = models.length;
} else {
count = models.filter(m => m.enable_groups && m.enable_groups.includes(g)).length;
}
return {
value: g,
label: g === 'all' ? t('全部分组') : g,
tagCount: count,
};
});
return (
<SelectableButtonGroup
title={t('可用令牌分组')}
items={items}
activeValue={filterGroup}
onChange={setFilterGroup}
t={t}
/>
);
};
export default PricingGroups;

View File

@@ -0,0 +1,49 @@
/*
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 SelectableButtonGroup from '../../../common/ui/SelectableButtonGroup.jsx';
/**
* 计费类型筛选组件
* @param {string|'all'|0|1} filterQuotaType 当前值
* @param {Function} setFilterQuotaType setter
* @param {Function} t i18n
*/
const PricingQuotaTypes = ({ filterQuotaType, setFilterQuotaType, models = [], t }) => {
const qtyCount = (type) => models.filter(m => type === 'all' ? true : m.quota_type === type).length;
const items = [
{ value: 'all', label: t('全部类型'), tagCount: qtyCount('all') },
{ value: 0, label: t('按量计费'), tagCount: qtyCount(0) },
{ value: 1, label: t('按次计费'), tagCount: qtyCount(1) },
];
return (
<SelectableButtonGroup
title={t('计费类型')}
items={items}
activeValue={filterQuotaType}
onChange={setFilterQuotaType}
t={t}
/>
);
};
export default PricingQuotaTypes;

View File

@@ -32,6 +32,10 @@ export const useModelPricingData = () => {
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 [activeKey, setActiveKey] = useState('all');
const [pageSize, setPageSize] = useState(10);
const [currency, setCurrency] = useState('USD');
@@ -75,10 +79,22 @@ export const useModelPricingData = () => {
const filteredModels = useMemo(() => {
let result = models;
// 分类筛选
if (activeKey !== 'all') {
result = result.filter(model => modelCategories[activeKey].filter(model));
}
// 分组筛选
if (filterGroup !== 'all') {
result = result.filter(model => model.enable_groups.includes(filterGroup));
}
// 计费类型筛选
if (filterQuotaType !== 'all') {
result = result.filter(model => model.quota_type === filterQuotaType);
}
// 搜索筛选
if (filteredValue.length > 0) {
const searchTerm = filteredValue[0].toLowerCase();
result = result.filter(model =>
@@ -87,7 +103,7 @@ export const useModelPricingData = () => {
}
return result;
}, [activeKey, models, filteredValue]);
}, [activeKey, models, filteredValue, filterGroup, filterQuotaType]);
const rowSelection = useMemo(
() => ({
@@ -184,6 +200,8 @@ export const useModelPricingData = () => {
const handleGroupClick = (group) => {
setSelectedGroup(group);
// 同时将分组过滤设置为该分组
setFilterGroup(group);
showInfo(
t('当前查看的分组为:{{group}},倍率为:{{ratio}}', {
group: group,
@@ -208,6 +226,10 @@ export const useModelPricingData = () => {
setIsModalOpenurl,
selectedGroup,
setSelectedGroup,
filterGroup,
setFilterGroup,
filterQuotaType,
setFilterQuotaType,
activeKey,
setActiveKey,
pageSize,

View File

@@ -21,9 +21,9 @@ import React from 'react';
import ModelPricingPage from '../../components/table/model-pricing';
const Pricing = () => (
<div className="mt-[60px] px-2">
<>
<ModelPricingPage />
</div>
</>
);
export default Pricing;