diff --git a/web/src/components/table/ModelPricing.js b/web/src/components/table/ModelPricing.js deleted file mode 100644 index 07acba1c..00000000 --- a/web/src/components/table/ModelPricing.js +++ /dev/null @@ -1,684 +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, { useContext, useEffect, useRef, useMemo, useState } from 'react'; -import { API, copy, showError, showInfo, showSuccess, getModelCategories, renderModelTag, stringToColor } from '../../helpers'; -import { useTranslation } from 'react-i18next'; - -import { - Input, - Layout, - Modal, - Space, - Table, - Tag, - Tooltip, - Popover, - ImagePreview, - Button, - Card, - Tabs, - TabPane, - Empty, - Switch, - Select -} from '@douyinfe/semi-ui'; -import { - IllustrationNoResult, - IllustrationNoResultDark -} from '@douyinfe/semi-illustrations'; -import { - IconVerify, - IconHelpCircle, - IconSearch, - IconCopy, - IconInfoCircle, - IconLayers -} from '@douyinfe/semi-icons'; -import { UserContext } from '../../context/User/index.js'; -import { AlertCircle } from 'lucide-react'; -import { StatusContext } from '../../context/Status/index.js'; - -const ModelPricing = () => { - const { t } = useTranslation(); - const [filteredValue, setFilteredValue] = useState([]); - const compositionRef = useRef({ isComposition: false }); - const [selectedRowKeys, setSelectedRowKeys] = useState([]); - const [modalImageUrl, setModalImageUrl] = useState(''); - const [isModalOpenurl, setIsModalOpenurl] = useState(false); - const [selectedGroup, setSelectedGroup] = useState('default'); - const [activeKey, setActiveKey] = useState('all'); - const [pageSize, setPageSize] = useState(10); - - const [currency, setCurrency] = useState('USD'); - const [showWithRecharge, setShowWithRecharge] = useState(false); - const [tokenUnit, setTokenUnit] = useState('M'); - const [statusState] = useContext(StatusContext); - // 充值汇率(price)与美元兑人民币汇率(usd_exchange_rate) - const priceRate = useMemo(() => statusState?.status?.price ?? 1, [statusState]); - const usdExchangeRate = useMemo(() => statusState?.status?.usd_exchange_rate ?? priceRate, [statusState, priceRate]); - - const rowSelection = useMemo( - () => ({ - onChange: (selectedRowKeys, selectedRows) => { - setSelectedRowKeys(selectedRowKeys); - }, - }), - [], - ); - - const handleChange = (value) => { - if (compositionRef.current.isComposition) { - return; - } - const newFilteredValue = value ? [value] : []; - setFilteredValue(newFilteredValue); - }; - - const handleCompositionStart = () => { - compositionRef.current.isComposition = true; - }; - - const handleCompositionEnd = (event) => { - compositionRef.current.isComposition = false; - const value = event.target.value; - const newFilteredValue = value ? [value] : []; - setFilteredValue(newFilteredValue); - }; - - function renderQuotaType(type) { - switch (type) { - case 1: - return ( - - {t('按次计费')} - - ); - case 0: - return ( - - {t('按量计费')} - - ); - default: - return t('未知'); - } - } - - function renderAvailable(available) { - return available ? ( - {t('您的分组可以使用该模型')} - } - position='top' - key={available} - className="bg-green-50" - > - - - ) : null; - } - - function renderSupportedEndpoints(endpoints) { - if (!endpoints || endpoints.length === 0) { - return null; - } - return ( - - {endpoints.map((endpoint, idx) => ( - - {endpoint} - - ))} - - ); - } - - const displayPrice = (usdPrice) => { - let priceInUSD = usdPrice; - if (showWithRecharge) { - priceInUSD = usdPrice * priceRate / usdExchangeRate; - } - - if (currency === 'CNY') { - return `¥${(priceInUSD * usdExchangeRate).toFixed(3)}`; - } - return `$${priceInUSD.toFixed(3)}`; - }; - - const columns = [ - { - title: t('可用性'), - dataIndex: 'available', - render: (text, record, index) => { - return renderAvailable(record.enable_groups.includes(selectedGroup)); - }, - sorter: (a, b) => { - const aAvailable = a.enable_groups.includes(selectedGroup); - const bAvailable = b.enable_groups.includes(selectedGroup); - return Number(aAvailable) - Number(bAvailable); - }, - defaultSortOrder: 'descend', - }, - { - title: t('可用端点类型'), - dataIndex: 'supported_endpoint_types', - render: (text, record, index) => { - return renderSupportedEndpoints(text); - }, - }, - { - title: t('模型名称'), - dataIndex: 'model_name', - render: (text, record, index) => { - return renderModelTag(text, { - onClick: () => { - copyText(text); - } - }); - }, - onFilter: (value, record) => - record.model_name.toLowerCase().includes(value.toLowerCase()), - filteredValue, - }, - { - title: t('计费类型'), - dataIndex: 'quota_type', - render: (text, record, index) => { - return renderQuotaType(parseInt(text)); - }, - sorter: (a, b) => a.quota_type - b.quota_type, - }, - { - title: t('可用分组'), - dataIndex: 'enable_groups', - render: (text, record, index) => { - return ( - - {text.map((group) => { - if (usableGroup[group]) { - if (group === selectedGroup) { - return ( - }> - {group} - - ); - } else { - return ( - { - setSelectedGroup(group); - showInfo( - t('当前查看的分组为:{{group}},倍率为:{{ratio}}', { - group: group, - ratio: groupRatio[group], - }), - ); - }} - className="cursor-pointer hover:opacity-80 transition-opacity" - > - {group} - - ); - } - } - })} - - ); - }, - }, - { - title: () => ( -
- {t('倍率')} - - { - setModalImageUrl('/ratio.png'); - setIsModalOpenurl(true); - }} - /> - -
- ), - dataIndex: 'model_ratio', - render: (text, record, index) => { - let content = text; - let completionRatio = parseFloat(record.completion_ratio.toFixed(3)); - content = ( -
-
- {t('模型倍率')}:{record.quota_type === 0 ? text : t('无')} -
-
- {t('补全倍率')}: - {record.quota_type === 0 ? completionRatio : t('无')} -
-
- {t('分组倍率')}:{groupRatio[selectedGroup]} -
-
- ); - return content; - }, - }, - { - title: ( -
- {t('模型价格')} - {/* 计费单位切换 */} - setTokenUnit(checked ? 'K' : 'M')} - checkedText="K" - uncheckedText="M" - /> -
- ), - 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 = ( -
-
- {t('提示')} {displayInput} / 1{unitLabel} tokens -
-
- {t('补全')} {displayCompletion} / 1{unitLabel} tokens -
-
- ); - } else { - let priceUSD = parseFloat(text) * groupRatio[selectedGroup]; - let displayVal = displayPrice(priceUSD); - content = ( -
- {t('模型价格')}:{displayVal} -
- ); - } - return content; - }, - }, - ]; - - const [models, setModels] = useState([]); - const [loading, setLoading] = useState(true); - const [userState] = useContext(UserContext); - const [groupRatio, setGroupRatio] = useState({}); - const [usableGroup, setUsableGroup] = useState({}); - - const setModelsFormat = (models, groupRatio) => { - for (let i = 0; i < models.length; i++) { - models[i].key = models[i].model_name; - models[i].group_ratio = groupRatio[models[i].model_name]; - } - models.sort((a, b) => { - return a.quota_type - b.quota_type; - }); - - models.sort((a, b) => { - if (a.model_name.startsWith('gpt') && !b.model_name.startsWith('gpt')) { - return -1; - } else if ( - !a.model_name.startsWith('gpt') && - b.model_name.startsWith('gpt') - ) { - return 1; - } else { - return a.model_name.localeCompare(b.model_name); - } - }); - - setModels(models); - }; - - const loadPricing = async () => { - setLoading(true); - let url = '/api/pricing'; - const res = await API.get(url); - const { success, message, data, group_ratio, usable_group } = res.data; - if (success) { - setGroupRatio(group_ratio); - setUsableGroup(usable_group); - setSelectedGroup(userState.user ? userState.user.group : 'default'); - setModelsFormat(data, group_ratio); - } else { - showError(message); - } - setLoading(false); - }; - - const refresh = async () => { - await loadPricing(); - }; - - const copyText = async (text) => { - if (await copy(text)) { - showSuccess(t('已复制:') + text); - } else { - Modal.error({ title: t('无法复制到剪贴板,请手动复制'), content: text }); - } - }; - - useEffect(() => { - refresh().then(); - }, []); - - const modelCategories = getModelCategories(t); - - const categoryCounts = useMemo(() => { - const counts = {}; - if (models.length > 0) { - counts['all'] = models.length; - - Object.entries(modelCategories).forEach(([key, category]) => { - if (key !== 'all') { - counts[key] = models.filter(model => category.filter(model)).length; - } - }); - } - return counts; - }, [models, modelCategories]); - - const availableCategories = useMemo(() => { - if (!models.length) return ['all']; - - return Object.entries(modelCategories).filter(([key, category]) => { - if (key === 'all') return true; - return models.some(model => category.filter(model)); - }).map(([key]) => key); - }, [models]); - - const renderTabs = () => { - return ( - setActiveKey(key)} - className="mt-2" - > - {Object.entries(modelCategories) - .filter(([key]) => availableCategories.includes(key)) - .map(([key, category]) => { - const modelCount = categoryCounts[key] || 0; - - return ( - - {category.icon && {category.icon}} - {category.label} - - {modelCount} - - - } - itemKey={key} - key={key} - /> - ); - })} - - ); - }; - - const filteredModels = useMemo(() => { - let result = models; - - if (activeKey !== 'all') { - result = result.filter(model => modelCategories[activeKey].filter(model)); - } - - if (filteredValue.length > 0) { - const searchTerm = filteredValue[0].toLowerCase(); - result = result.filter(model => - model.model_name.toLowerCase().includes(searchTerm) - ); - } - - return result; - }, [activeKey, models, filteredValue]); - - const SearchAndActions = useMemo(() => ( - -
-
- } - placeholder={t('模糊搜索模型名称')} - onCompositionStart={handleCompositionStart} - onCompositionEnd={handleCompositionEnd} - onChange={handleChange} - showClear - /> -
- - - {/* 充值价格显示开关 */} - - {t('以充值价格显示')} - - {showWithRecharge && ( - - )} - -
-
- ), [selectedRowKeys, t, showWithRecharge, currency]); - - const ModelTable = useMemo(() => ( - - } - darkModeImage={} - description={t('搜索无结果')} - style={{ padding: 30 }} - /> - } - pagination={{ - defaultPageSize: 10, - pageSize: pageSize, - showSizeChanger: true, - pageSizeOptions: [10, 20, 50, 100], - onPageSizeChange: (size) => setPageSize(size), - }} - /> - - ), [filteredModels, loading, columns, rowSelection, pageSize, t]); - - return ( -
- - -
-
- {/* 主卡片容器 */} - - {/* 顶部状态卡片 */} - -
-
-
-
- -
-
-
- {t('模型定价')} -
-
- {userState.user ? ( -
- - - {t('当前分组')}: {userState.user.group},{t('倍率')}: {groupRatio[userState.user.group]} - -
- ) : ( -
- - - {t('未登录,使用默认分组倍率:')}{groupRatio['default']} - -
- )} -
-
-
- -
-
-
{t('分组倍率')}
-
{groupRatio[selectedGroup] || '1.0'}x
-
-
-
{t('可用模型')}
-
- {models.filter(m => m.enable_groups.includes(selectedGroup)).length} -
-
-
-
{t('计费类型')}
-
2
-
-
-
- - {/* 计费说明 */} -
-
-
- - - {t('按量计费费用 = 分组倍率 × 模型倍率 × (提示token数 + 补全token数 × 补全倍率)/ 500000 (单位:美元)')} - -
-
-
- -
-
-
- - {/* 模型分类 Tabs */} -
- {renderTabs()} - - {/* 搜索和表格区域 */} - {SearchAndActions} - {ModelTable} -
- - {/* 倍率说明图预览 */} - setIsModalOpenurl(visible)} - /> -
-
-
-
-
-
- ); -}; - -export default ModelPricing; diff --git a/web/src/components/table/model-pricing/ModelPricingColumnDefs.js b/web/src/components/table/model-pricing/ModelPricingColumnDefs.js new file mode 100644 index 00000000..bf71533c --- /dev/null +++ b/web/src/components/table/model-pricing/ModelPricingColumnDefs.js @@ -0,0 +1,261 @@ +/* +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 { Tag, Space, Tooltip, Switch } from '@douyinfe/semi-ui'; +import { IconVerify, IconHelpCircle } from '@douyinfe/semi-icons'; +import { Popover } from '@douyinfe/semi-ui'; +import { renderModelTag, stringToColor } from '../../../helpers'; + +function renderQuotaType(type, t) { + switch (type) { + case 1: + return ( + + {t('按次计费')} + + ); + case 0: + return ( + + {t('按量计费')} + + ); + default: + return t('未知'); + } +} + +function renderAvailable(available, t) { + return available ? ( + {t('您的分组可以使用该模型')} + } + position='top' + key={available} + className="bg-green-50" + > + + + ) : null; +} + +function renderSupportedEndpoints(endpoints) { + if (!endpoints || endpoints.length === 0) { + return null; + } + return ( + + {endpoints.map((endpoint, idx) => ( + + {endpoint} + + ))} + + ); +} + +export const getModelPricingColumns = ({ + t, + selectedGroup, + usableGroup, + groupRatio, + copyText, + setModalImageUrl, + setIsModalOpenurl, + currency, + showWithRecharge, + tokenUnit, + setTokenUnit, + displayPrice, + handleGroupClick, +}) => { + return [ + { + title: t('可用性'), + dataIndex: 'available', + render: (text, record, index) => { + return renderAvailable(record.enable_groups.includes(selectedGroup), t); + }, + sorter: (a, b) => { + const aAvailable = a.enable_groups.includes(selectedGroup); + const bAvailable = b.enable_groups.includes(selectedGroup); + return Number(aAvailable) - Number(bAvailable); + }, + defaultSortOrder: 'descend', + }, + { + title: t('可用端点类型'), + dataIndex: 'supported_endpoint_types', + render: (text, record, index) => { + return renderSupportedEndpoints(text); + }, + }, + { + title: t('模型名称'), + dataIndex: 'model_name', + render: (text, record, index) => { + return renderModelTag(text, { + onClick: () => { + copyText(text); + } + }); + }, + onFilter: (value, record) => + record.model_name.toLowerCase().includes(value.toLowerCase()), + }, + { + title: t('计费类型'), + dataIndex: 'quota_type', + render: (text, record, index) => { + return renderQuotaType(parseInt(text), t); + }, + sorter: (a, b) => a.quota_type - b.quota_type, + }, + { + title: t('可用分组'), + dataIndex: 'enable_groups', + render: (text, record, index) => { + return ( + + {text.map((group) => { + if (usableGroup[group]) { + if (group === selectedGroup) { + return ( + }> + {group} + + ); + } else { + return ( + handleGroupClick(group)} + className="cursor-pointer hover:opacity-80 transition-opacity" + > + {group} + + ); + } + } + })} + + ); + }, + }, + { + title: () => ( +
+ {t('倍率')} + + { + setModalImageUrl('/ratio.png'); + setIsModalOpenurl(true); + }} + /> + +
+ ), + dataIndex: 'model_ratio', + render: (text, record, index) => { + let content = text; + let completionRatio = parseFloat(record.completion_ratio.toFixed(3)); + content = ( +
+
+ {t('模型倍率')}:{record.quota_type === 0 ? text : t('无')} +
+
+ {t('补全倍率')}: + {record.quota_type === 0 ? completionRatio : t('无')} +
+
+ {t('分组倍率')}:{groupRatio[selectedGroup]} +
+
+ ); + return content; + }, + }, + { + title: ( +
+ {t('模型价格')} + {/* 计费单位切换 */} + setTokenUnit(checked ? 'K' : 'M')} + checkedText="K" + uncheckedText="M" + /> +
+ ), + 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 = ( +
+
+ {t('提示')} {displayInput} / 1{unitLabel} tokens +
+
+ {t('补全')} {displayCompletion} / 1{unitLabel} tokens +
+
+ ); + } else { + let priceUSD = parseFloat(text) * groupRatio[selectedGroup]; + let displayVal = displayPrice(priceUSD); + content = ( +
+ {t('模型价格')}:{displayVal} +
+ ); + } + return content; + }, + }, + ]; +}; \ No newline at end of file diff --git a/web/src/components/table/model-pricing/ModelPricingFilters.jsx b/web/src/components/table/model-pricing/ModelPricingFilters.jsx new file mode 100644 index 00000000..57b5e7e1 --- /dev/null +++ b/web/src/components/table/model-pricing/ModelPricingFilters.jsx @@ -0,0 +1,87 @@ +/* +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, { 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(() => ( + +
+
+ } + placeholder={t('模糊搜索模型名称')} + onCompositionStart={handleCompositionStart} + onCompositionEnd={handleCompositionEnd} + onChange={handleChange} + showClear + /> +
+ + + {/* 充值价格显示开关 */} + + {t('以充值价格显示')} + + {showWithRecharge && ( + + )} + +
+
+ ), [selectedRowKeys, t, showWithRecharge, currency, handleCompositionStart, handleCompositionEnd, handleChange, copyText, setShowWithRecharge, setCurrency]); + + return SearchAndActions; +}; + +export default ModelPricingFilters; \ No newline at end of file diff --git a/web/src/components/table/model-pricing/ModelPricingHeader.jsx b/web/src/components/table/model-pricing/ModelPricingHeader.jsx new file mode 100644 index 00000000..40075f3a --- /dev/null +++ b/web/src/components/table/model-pricing/ModelPricingHeader.jsx @@ -0,0 +1,123 @@ +/* +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 } from '@douyinfe/semi-ui'; +import { IconVerify, IconLayers, IconInfoCircle } from '@douyinfe/semi-icons'; +import { AlertCircle } from 'lucide-react'; + +const ModelPricingHeader = ({ + userState, + groupRatio, + selectedGroup, + models, + t +}) => { + return ( + +
+
+
+
+ +
+
+
+ {t('模型定价')} +
+
+ {userState.user ? ( +
+ + + {t('当前分组')}: {userState.user.group},{t('倍率')}: {groupRatio[userState.user.group]} + +
+ ) : ( +
+ + + {t('未登录,使用默认分组倍率:')}{groupRatio['default']} + +
+ )} +
+
+
+ +
+
+
{t('分组倍率')}
+
{groupRatio[selectedGroup] || '1.0'}x
+
+
+
{t('可用模型')}
+
+ {models.filter(m => m.enable_groups.includes(selectedGroup)).length} +
+
+
+
{t('计费类型')}
+
2
+
+
+
+ + {/* 计费说明 */} +
+
+
+ + + {t('按量计费费用 = 分组倍率 × 模型倍率 × (提示token数 + 补全token数 × 补全倍率)/ 500000 (单位:美元)')} + +
+
+
+ +
+
+
+ ); +}; + +export default ModelPricingHeader; \ No newline at end of file diff --git a/web/src/components/table/model-pricing/ModelPricingTable.jsx b/web/src/components/table/model-pricing/ModelPricingTable.jsx new file mode 100644 index 00000000..22d94f29 --- /dev/null +++ b/web/src/components/table/model-pricing/ModelPricingTable.jsx @@ -0,0 +1,124 @@ +/* +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, { useMemo } from 'react'; +import { Card, Table, Empty } from '@douyinfe/semi-ui'; +import { + IllustrationNoResult, + IllustrationNoResultDark +} from '@douyinfe/semi-illustrations'; +import { getModelPricingColumns } from './ModelPricingColumnDefs.js'; + +const ModelPricingTable = ({ + filteredModels, + loading, + rowSelection, + pageSize, + setPageSize, + selectedGroup, + usableGroup, + groupRatio, + copyText, + setModalImageUrl, + setIsModalOpenurl, + currency, + showWithRecharge, + tokenUnit, + setTokenUnit, + displayPrice, + filteredValue, + handleGroupClick, + t +}) => { + const columns = useMemo(() => { + return getModelPricingColumns({ + t, + selectedGroup, + usableGroup, + groupRatio, + copyText, + setModalImageUrl, + setIsModalOpenurl, + currency, + showWithRecharge, + tokenUnit, + setTokenUnit, + displayPrice, + handleGroupClick, + }); + }, [ + t, + selectedGroup, + usableGroup, + groupRatio, + copyText, + setModalImageUrl, + setIsModalOpenurl, + currency, + showWithRecharge, + tokenUnit, + setTokenUnit, + displayPrice, + handleGroupClick, + ]); + + // 更新列定义中的 filteredValue + const tableColumns = useMemo(() => { + return columns.map(column => { + if (column.dataIndex === 'model_name') { + return { + ...column, + filteredValue + }; + } + return column; + }); + }, [columns, filteredValue]); + + const ModelTable = useMemo(() => ( + +
} + darkModeImage={} + description={t('搜索无结果')} + style={{ padding: 30 }} + /> + } + pagination={{ + defaultPageSize: 10, + pageSize: pageSize, + showSizeChanger: true, + pageSizeOptions: [10, 20, 50, 100], + onPageSizeChange: (size) => setPageSize(size), + }} + /> + + ), [filteredModels, loading, tableColumns, rowSelection, pageSize, setPageSize, t]); + + return ModelTable; +}; + +export default ModelPricingTable; \ No newline at end of file diff --git a/web/src/components/table/model-pricing/ModelPricingTabs.jsx b/web/src/components/table/model-pricing/ModelPricingTabs.jsx new file mode 100644 index 00000000..11a58b79 --- /dev/null +++ b/web/src/components/table/model-pricing/ModelPricingTabs.jsx @@ -0,0 +1,67 @@ +/* +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 { Tabs, TabPane, Tag } from '@douyinfe/semi-ui'; + +const ModelPricingTabs = ({ + activeKey, + setActiveKey, + modelCategories, + categoryCounts, + availableCategories, + t +}) => { + return ( + setActiveKey(key)} + className="mt-2" + > + {Object.entries(modelCategories) + .filter(([key]) => availableCategories.includes(key)) + .map(([key, category]) => { + const modelCount = categoryCounts[key] || 0; + + return ( + + {category.icon && {category.icon}} + {category.label} + + {modelCount} + + + } + itemKey={key} + key={key} + /> + ); + })} + + ); +}; + +export default ModelPricingTabs; \ No newline at end of file diff --git a/web/src/components/table/model-pricing/index.jsx b/web/src/components/table/model-pricing/index.jsx new file mode 100644 index 00000000..a8641ce5 --- /dev/null +++ b/web/src/components/table/model-pricing/index.jsx @@ -0,0 +1,66 @@ +/* +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 { 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 ( +
+ + +
+
+ {/* 主卡片容器 */} + + {/* 顶部状态卡片 */} + + + {/* 模型分类 Tabs */} +
+ + + {/* 搜索和表格区域 */} + + +
+ + {/* 倍率说明图预览 */} + modelPricingData.setIsModalOpenurl(visible)} + /> +
+
+
+
+
+
+ ); +}; + +export default ModelPricingPage; \ No newline at end of file diff --git a/web/src/hooks/model-pricing/useModelPricingData.js b/web/src/hooks/model-pricing/useModelPricingData.js new file mode 100644 index 00000000..60445f1e --- /dev/null +++ b/web/src/hooks/model-pricing/useModelPricingData.js @@ -0,0 +1,254 @@ +/* +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, useContext, useRef, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { API, copy, showError, showInfo, showSuccess, getModelCategories } from '../../helpers'; +import { Modal } from '@douyinfe/semi-ui'; +import { UserContext } from '../../context/User/index.js'; +import { StatusContext } from '../../context/Status/index.js'; + +export const useModelPricingData = () => { + const { t } = useTranslation(); + const [filteredValue, setFilteredValue] = useState([]); + const compositionRef = useRef({ isComposition: false }); + const [selectedRowKeys, setSelectedRowKeys] = useState([]); + const [modalImageUrl, setModalImageUrl] = useState(''); + const [isModalOpenurl, setIsModalOpenurl] = useState(false); + const [selectedGroup, setSelectedGroup] = useState('default'); + const [activeKey, setActiveKey] = useState('all'); + const [pageSize, setPageSize] = useState(10); + const [currency, setCurrency] = useState('USD'); + const [showWithRecharge, setShowWithRecharge] = useState(false); + const [tokenUnit, setTokenUnit] = useState('M'); + const [models, setModels] = useState([]); + const [loading, setLoading] = useState(true); + const [groupRatio, setGroupRatio] = useState({}); + const [usableGroup, setUsableGroup] = useState({}); + + const [statusState] = useContext(StatusContext); + const [userState] = useContext(UserContext); + + // 充值汇率(price)与美元兑人民币汇率(usd_exchange_rate) + const priceRate = useMemo(() => statusState?.status?.price ?? 1, [statusState]); + const usdExchangeRate = useMemo(() => statusState?.status?.usd_exchange_rate ?? priceRate, [statusState, priceRate]); + + const modelCategories = getModelCategories(t); + + const categoryCounts = useMemo(() => { + const counts = {}; + if (models.length > 0) { + counts['all'] = models.length; + Object.entries(modelCategories).forEach(([key, category]) => { + if (key !== 'all') { + counts[key] = models.filter(model => category.filter(model)).length; + } + }); + } + return counts; + }, [models, modelCategories]); + + const availableCategories = useMemo(() => { + if (!models.length) return ['all']; + return Object.entries(modelCategories).filter(([key, category]) => { + if (key === 'all') return true; + return models.some(model => category.filter(model)); + }).map(([key]) => key); + }, [models]); + + const filteredModels = useMemo(() => { + let result = models; + + if (activeKey !== 'all') { + result = result.filter(model => modelCategories[activeKey].filter(model)); + } + + if (filteredValue.length > 0) { + const searchTerm = filteredValue[0].toLowerCase(); + result = result.filter(model => + model.model_name.toLowerCase().includes(searchTerm) + ); + } + + return result; + }, [activeKey, models, filteredValue]); + + const rowSelection = useMemo( + () => ({ + onChange: (selectedRowKeys, selectedRows) => { + setSelectedRowKeys(selectedRowKeys); + }, + }), + [], + ); + + const displayPrice = (usdPrice) => { + let priceInUSD = usdPrice; + if (showWithRecharge) { + priceInUSD = usdPrice * priceRate / usdExchangeRate; + } + + if (currency === 'CNY') { + return `¥${(priceInUSD * usdExchangeRate).toFixed(3)}`; + } + return `$${priceInUSD.toFixed(3)}`; + }; + + const setModelsFormat = (models, groupRatio) => { + for (let i = 0; i < models.length; i++) { + models[i].key = models[i].model_name; + models[i].group_ratio = groupRatio[models[i].model_name]; + } + models.sort((a, b) => { + return a.quota_type - b.quota_type; + }); + + models.sort((a, b) => { + if (a.model_name.startsWith('gpt') && !b.model_name.startsWith('gpt')) { + return -1; + } else if ( + !a.model_name.startsWith('gpt') && + b.model_name.startsWith('gpt') + ) { + return 1; + } else { + return a.model_name.localeCompare(b.model_name); + } + }); + + setModels(models); + }; + + const loadPricing = async () => { + setLoading(true); + let url = '/api/pricing'; + const res = await API.get(url); + const { success, message, data, group_ratio, usable_group } = res.data; + if (success) { + setGroupRatio(group_ratio); + setUsableGroup(usable_group); + setSelectedGroup(userState.user ? userState.user.group : 'default'); + setModelsFormat(data, group_ratio); + } else { + showError(message); + } + setLoading(false); + }; + + const refresh = async () => { + await loadPricing(); + }; + + const copyText = async (text) => { + if (await copy(text)) { + showSuccess(t('已复制:') + text); + } else { + Modal.error({ title: t('无法复制到剪贴板,请手动复制'), content: text }); + } + }; + + const handleChange = (value) => { + if (compositionRef.current.isComposition) { + return; + } + const newFilteredValue = value ? [value] : []; + setFilteredValue(newFilteredValue); + }; + + const handleCompositionStart = () => { + compositionRef.current.isComposition = true; + }; + + const handleCompositionEnd = (event) => { + compositionRef.current.isComposition = false; + const value = event.target.value; + const newFilteredValue = value ? [value] : []; + setFilteredValue(newFilteredValue); + }; + + const handleGroupClick = (group) => { + setSelectedGroup(group); + showInfo( + t('当前查看的分组为:{{group}},倍率为:{{ratio}}', { + group: group, + ratio: groupRatio[group], + }), + ); + }; + + useEffect(() => { + refresh().then(); + }, []); + + return { + // 状态 + filteredValue, + setFilteredValue, + selectedRowKeys, + setSelectedRowKeys, + modalImageUrl, + setModalImageUrl, + isModalOpenurl, + setIsModalOpenurl, + selectedGroup, + setSelectedGroup, + activeKey, + setActiveKey, + pageSize, + setPageSize, + currency, + setCurrency, + showWithRecharge, + setShowWithRecharge, + tokenUnit, + setTokenUnit, + models, + loading, + groupRatio, + usableGroup, + + // 计算属性 + priceRate, + usdExchangeRate, + modelCategories, + categoryCounts, + availableCategories, + filteredModels, + rowSelection, + + // 用户和状态 + userState, + statusState, + + // 方法 + displayPrice, + refresh, + copyText, + handleChange, + handleCompositionStart, + handleCompositionEnd, + handleGroupClick, + + // 引用 + compositionRef, + + // 国际化 + t, + }; +}; \ No newline at end of file diff --git a/web/src/pages/Pricing/index.js b/web/src/pages/Pricing/index.js index 48f69f54..036e94ad 100644 --- a/web/src/pages/Pricing/index.js +++ b/web/src/pages/Pricing/index.js @@ -18,11 +18,11 @@ For commercial licensing, please contact support@quantumnous.com */ import React from 'react'; -import ModelPricing from '../../components/table/ModelPricing.js'; +import ModelPricingPage from '../../components/table/model-pricing'; const Pricing = () => (
- +
);