♻️ refactor: restructure ModelPricing component into modular architecture
- Break down monolithic ModelPricing.js (685 lines) into focused components: * ModelPricingHeader.jsx - top status card with pricing information * ModelPricingTabs.jsx - model category navigation tabs * ModelPricingFilters.jsx - search and action controls * ModelPricingTable.jsx - data table with pricing details * ModelPricingColumnDefs.js - table column definitions and renderers - Create custom hook useModelPricingData.js for centralized state management: * Consolidate all business logic and API calls * Manage pricing calculations and data transformations * Handle search, filtering, and UI interactions - Follow project conventions matching other table components: * Adopt same file structure as channels/, users/, tokens/ modules * Maintain consistent naming patterns and component organization * Preserve all original functionality including responsive design - Update import paths: * Remove obsolete ModelPricing.js file * Update Pricing page to use new ModelPricingPage component * Fix missing import references Benefits: - Improved maintainability with single-responsibility components - Enhanced code reusability and testability - Better team collaboration with modular structure - Consistent codebase architecture across all table components
This commit is contained in:
@@ -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 <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<Tag color='teal' shape='circle'>
|
|
||||||
{t('按次计费')}
|
|
||||||
</Tag>
|
|
||||||
);
|
|
||||||
case 0:
|
|
||||||
return (
|
|
||||||
<Tag color='violet' shape='circle'>
|
|
||||||
{t('按量计费')}
|
|
||||||
</Tag>
|
|
||||||
);
|
|
||||||
default:
|
|
||||||
return t('未知');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderAvailable(available) {
|
|
||||||
return available ? (
|
|
||||||
<Popover
|
|
||||||
content={
|
|
||||||
<div style={{ padding: 8 }}>{t('您的分组可以使用该模型')}</div>
|
|
||||||
}
|
|
||||||
position='top'
|
|
||||||
key={available}
|
|
||||||
className="bg-green-50"
|
|
||||||
>
|
|
||||||
<IconVerify style={{ color: 'rgb(22 163 74)' }} size='large' />
|
|
||||||
</Popover>
|
|
||||||
) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderSupportedEndpoints(endpoints) {
|
|
||||||
if (!endpoints || endpoints.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<Space wrap>
|
|
||||||
{endpoints.map((endpoint, idx) => (
|
|
||||||
<Tag
|
|
||||||
key={endpoint}
|
|
||||||
color={stringToColor(endpoint)}
|
|
||||||
shape='circle'
|
|
||||||
>
|
|
||||||
{endpoint}
|
|
||||||
</Tag>
|
|
||||||
))}
|
|
||||||
</Space>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<Space wrap>
|
|
||||||
{text.map((group) => {
|
|
||||||
if (usableGroup[group]) {
|
|
||||||
if (group === selectedGroup) {
|
|
||||||
return (
|
|
||||||
<Tag color='blue' shape='circle' prefixIcon={<IconVerify />}>
|
|
||||||
{group}
|
|
||||||
</Tag>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return (
|
|
||||||
<Tag
|
|
||||||
color='blue'
|
|
||||||
shape='circle'
|
|
||||||
onClick={() => {
|
|
||||||
setSelectedGroup(group);
|
|
||||||
showInfo(
|
|
||||||
t('当前查看的分组为:{{group}},倍率为:{{ratio}}', {
|
|
||||||
group: group,
|
|
||||||
ratio: groupRatio[group],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
className="cursor-pointer hover:opacity-80 transition-opacity"
|
|
||||||
>
|
|
||||||
{group}
|
|
||||||
</Tag>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})}
|
|
||||||
</Space>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
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';
|
|
||||||
|
|
||||||
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;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
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(() => (
|
|
||||||
<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]);
|
|
||||||
|
|
||||||
const ModelTable = useMemo(() => (
|
|
||||||
<Card className="!rounded-xl overflow-hidden" bordered={false}>
|
|
||||||
<Table
|
|
||||||
columns={columns}
|
|
||||||
dataSource={filteredModels}
|
|
||||||
loading={loading}
|
|
||||||
rowSelection={rowSelection}
|
|
||||||
className="custom-table"
|
|
||||||
empty={
|
|
||||||
<Empty
|
|
||||||
image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
|
|
||||||
darkModeImage={<IllustrationNoResultDark style={{ width: 150, height: 150 }} />}
|
|
||||||
description={t('搜索无结果')}
|
|
||||||
style={{ padding: 30 }}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
pagination={{
|
|
||||||
defaultPageSize: 10,
|
|
||||||
pageSize: pageSize,
|
|
||||||
showSizeChanger: true,
|
|
||||||
pageSizeOptions: [10, 20, 50, 100],
|
|
||||||
onPageSizeChange: (size) => setPageSize(size),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
), [filteredModels, loading, columns, rowSelection, pageSize, t]);
|
|
||||||
|
|
||||||
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">
|
|
||||||
{/* 顶部状态卡片 */}
|
|
||||||
<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>
|
|
||||||
|
|
||||||
{/* 模型分类 Tabs */}
|
|
||||||
<div className="mb-6">
|
|
||||||
{renderTabs()}
|
|
||||||
|
|
||||||
{/* 搜索和表格区域 */}
|
|
||||||
{SearchAndActions}
|
|
||||||
{ModelTable}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 倍率说明图预览 */}
|
|
||||||
<ImagePreview
|
|
||||||
src={modalImageUrl}
|
|
||||||
visible={isModalOpenurl}
|
|
||||||
onVisibleChange={(visible) => setIsModalOpenurl(visible)}
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Layout.Content>
|
|
||||||
</Layout>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ModelPricing;
|
|
||||||
261
web/src/components/table/model-pricing/ModelPricingColumnDefs.js
Normal file
261
web/src/components/table/model-pricing/ModelPricingColumnDefs.js
Normal file
@@ -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 <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Tag color='teal' shape='circle'>
|
||||||
|
{t('按次计费')}
|
||||||
|
</Tag>
|
||||||
|
);
|
||||||
|
case 0:
|
||||||
|
return (
|
||||||
|
<Tag color='violet' shape='circle'>
|
||||||
|
{t('按量计费')}
|
||||||
|
</Tag>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return t('未知');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAvailable(available, t) {
|
||||||
|
return available ? (
|
||||||
|
<Popover
|
||||||
|
content={
|
||||||
|
<div style={{ padding: 8 }}>{t('您的分组可以使用该模型')}</div>
|
||||||
|
}
|
||||||
|
position='top'
|
||||||
|
key={available}
|
||||||
|
className="bg-green-50"
|
||||||
|
>
|
||||||
|
<IconVerify style={{ color: 'rgb(22 163 74)' }} size='large' />
|
||||||
|
</Popover>
|
||||||
|
) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSupportedEndpoints(endpoints) {
|
||||||
|
if (!endpoints || endpoints.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Space wrap>
|
||||||
|
{endpoints.map((endpoint, idx) => (
|
||||||
|
<Tag
|
||||||
|
key={endpoint}
|
||||||
|
color={stringToColor(endpoint)}
|
||||||
|
shape='circle'
|
||||||
|
>
|
||||||
|
{endpoint}
|
||||||
|
</Tag>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Space wrap>
|
||||||
|
{text.map((group) => {
|
||||||
|
if (usableGroup[group]) {
|
||||||
|
if (group === selectedGroup) {
|
||||||
|
return (
|
||||||
|
<Tag key={group} color='blue' shape='circle' prefixIcon={<IconVerify />}>
|
||||||
|
{group}
|
||||||
|
</Tag>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<Tag
|
||||||
|
key={group}
|
||||||
|
color='blue'
|
||||||
|
shape='circle'
|
||||||
|
onClick={() => handleGroupClick(group)}
|
||||||
|
className="cursor-pointer hover:opacity-80 transition-opacity"
|
||||||
|
>
|
||||||
|
{group}
|
||||||
|
</Tag>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
</Space>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
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';
|
||||||
|
|
||||||
|
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;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
};
|
||||||
@@ -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 <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;
|
||||||
123
web/src/components/table/model-pricing/ModelPricingHeader.jsx
Normal file
123
web/src/components/table/model-pricing/ModelPricingHeader.jsx
Normal file
@@ -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 <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 ModelPricingHeader = ({
|
||||||
|
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 ModelPricingHeader;
|
||||||
124
web/src/components/table/model-pricing/ModelPricingTable.jsx
Normal file
124
web/src/components/table/model-pricing/ModelPricingTable.jsx
Normal file
@@ -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 <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
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(() => (
|
||||||
|
<Card className="!rounded-xl overflow-hidden" bordered={false}>
|
||||||
|
<Table
|
||||||
|
columns={tableColumns}
|
||||||
|
dataSource={filteredModels}
|
||||||
|
loading={loading}
|
||||||
|
rowSelection={rowSelection}
|
||||||
|
className="custom-table"
|
||||||
|
empty={
|
||||||
|
<Empty
|
||||||
|
image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
|
||||||
|
darkModeImage={<IllustrationNoResultDark style={{ width: 150, height: 150 }} />}
|
||||||
|
description={t('搜索无结果')}
|
||||||
|
style={{ padding: 30 }}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
pagination={{
|
||||||
|
defaultPageSize: 10,
|
||||||
|
pageSize: pageSize,
|
||||||
|
showSizeChanger: true,
|
||||||
|
pageSizeOptions: [10, 20, 50, 100],
|
||||||
|
onPageSizeChange: (size) => setPageSize(size),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
), [filteredModels, loading, tableColumns, rowSelection, pageSize, setPageSize, t]);
|
||||||
|
|
||||||
|
return ModelTable;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ModelPricingTable;
|
||||||
67
web/src/components/table/model-pricing/ModelPricingTabs.jsx
Normal file
67
web/src/components/table/model-pricing/ModelPricingTabs.jsx
Normal file
@@ -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 <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;
|
||||||
66
web/src/components/table/model-pricing/index.jsx
Normal file
66
web/src/components/table/model-pricing/index.jsx
Normal file
@@ -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 <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;
|
||||||
254
web/src/hooks/model-pricing/useModelPricingData.js
Normal file
254
web/src/hooks/model-pricing/useModelPricingData.js
Normal file
@@ -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 <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -18,11 +18,11 @@ For commercial licensing, please contact support@quantumnous.com
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ModelPricing from '../../components/table/ModelPricing.js';
|
import ModelPricingPage from '../../components/table/model-pricing';
|
||||||
|
|
||||||
const Pricing = () => (
|
const Pricing = () => (
|
||||||
<div className="mt-[60px] px-2">
|
<div className="mt-[60px] px-2">
|
||||||
<ModelPricing />
|
<ModelPricingPage />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user