mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-04-20 02:58:38 +00:00
Refactor model tag rendering to ensure consistency throughout the application: - Replace direct Tag component in ModelPricing with centralized renderModelTag function - Update renderModelTag in render.js to use stringToColor for consistent color generation - Remove redundant stringToColor calls in LogsTable.js renderModelName function This change improves UI consistency by ensuring all model tags have the same styling, iconography, and color generation logic. Model tags now automatically display appropriate vendor icons based on the model name pattern.
599 lines
20 KiB
JavaScript
599 lines
20 KiB
JavaScript
import React, { useContext, useEffect, useRef, useMemo, useState } from 'react';
|
||
import { API, copy, showError, showInfo, showSuccess, getModelCategories, renderModelTag } from '../../helpers/index.js';
|
||
import { useTranslation } from 'react-i18next';
|
||
|
||
import {
|
||
Input,
|
||
Layout,
|
||
Modal,
|
||
Space,
|
||
Table,
|
||
Tag,
|
||
Tooltip,
|
||
Popover,
|
||
ImagePreview,
|
||
Button,
|
||
Card,
|
||
Tabs,
|
||
TabPane,
|
||
Dropdown,
|
||
} from '@douyinfe/semi-ui';
|
||
import {
|
||
IconVerify,
|
||
IconHelpCircle,
|
||
IconSearch,
|
||
IconCopy,
|
||
IconInfoCircle,
|
||
IconCrown,
|
||
} from '@douyinfe/semi-icons';
|
||
import { UserContext } from '../../context/User/index.js';
|
||
import { AlertCircle } from 'lucide-react';
|
||
|
||
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 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' size='large' shape='circle'>
|
||
{t('按次计费')}
|
||
</Tag>
|
||
);
|
||
case 0:
|
||
return (
|
||
<Tag color='violet' size='large' 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;
|
||
}
|
||
|
||
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: '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' size='large' shape='circle' prefixIcon={<IconVerify />}>
|
||
{group}
|
||
</Tag>
|
||
);
|
||
} else {
|
||
return (
|
||
<Tag
|
||
color='blue'
|
||
size='large'
|
||
onClick={() => {
|
||
setSelectedGroup(group);
|
||
showInfo(
|
||
t('当前查看的分组为:{{group}},倍率为:{{ratio}}', {
|
||
group: group,
|
||
ratio: groupRatio[group],
|
||
}),
|
||
);
|
||
}}
|
||
className="cursor-pointer hover:opacity-80 transition-opacity !rounded-full"
|
||
>
|
||
{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: t('模型价格'),
|
||
dataIndex: 'model_price',
|
||
render: (text, record, index) => {
|
||
let content = text;
|
||
if (record.quota_type === 0) {
|
||
let inputRatioPrice =
|
||
record.model_ratio * 2 * groupRatio[selectedGroup];
|
||
let completionRatioPrice =
|
||
record.model_ratio *
|
||
record.completion_ratio *
|
||
2 *
|
||
groupRatio[selectedGroup];
|
||
content = (
|
||
<div className="space-y-1">
|
||
<div className="text-gray-700">
|
||
{t('提示')} ${inputRatioPrice.toFixed(3)} / 1M tokens
|
||
</div>
|
||
<div className="text-gray-700">
|
||
{t('补全')} ${completionRatioPrice.toFixed(3)} / 1M tokens
|
||
</div>
|
||
</div>
|
||
);
|
||
} else {
|
||
let price = parseFloat(text) * groupRatio[selectedGroup];
|
||
content = (
|
||
<div className="text-gray-700">
|
||
${t('模型价格')}:${price.toFixed(3)}
|
||
</div>
|
||
);
|
||
}
|
||
return content;
|
||
},
|
||
},
|
||
];
|
||
|
||
const [models, setModels] = useState([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [userState, userDispatch] = 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 renderArrow = (items, pos, handleArrowClick) => {
|
||
const style = {
|
||
width: 32,
|
||
height: 32,
|
||
margin: '0 12px',
|
||
display: 'flex',
|
||
justifyContent: 'center',
|
||
alignItems: 'center',
|
||
borderRadius: '100%',
|
||
background: 'rgba(var(--semi-grey-1), 1)',
|
||
color: 'var(--semi-color-text)',
|
||
cursor: 'pointer',
|
||
};
|
||
return (
|
||
<Dropdown
|
||
render={
|
||
<Dropdown.Menu>
|
||
{items.map(item => (
|
||
<Dropdown.Item
|
||
key={item.itemKey}
|
||
onClick={() => setActiveKey(item.itemKey)}
|
||
icon={modelCategories[item.itemKey]?.icon}
|
||
>
|
||
{modelCategories[item.itemKey]?.label || item.itemKey}
|
||
</Dropdown.Item>
|
||
))}
|
||
</Dropdown.Menu>
|
||
}
|
||
>
|
||
<div style={style} onClick={handleArrowClick}>
|
||
{pos === 'start' ? '←' : '→'}
|
||
</div>
|
||
</Dropdown>
|
||
);
|
||
};
|
||
|
||
// 检查分类是否有对应的模型
|
||
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
|
||
renderArrow={renderArrow}
|
||
activeKey={activeKey}
|
||
type="card"
|
||
collapsible
|
||
onChange={key => setActiveKey(key)}
|
||
className="mt-2"
|
||
>
|
||
{Object.entries(modelCategories)
|
||
.filter(([key]) => availableCategories.includes(key))
|
||
.map(([key, category]) => (
|
||
<TabPane
|
||
tab={
|
||
<span className="flex items-center gap-2">
|
||
{category.icon && <span className="w-4 h-4">{category.icon}</span>}
|
||
{category.label}
|
||
</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" shadows='hover'>
|
||
<div className="flex flex-wrap items-center gap-4">
|
||
<div className="flex-1 min-w-[200px]">
|
||
<Input
|
||
prefix={<IconSearch />}
|
||
placeholder={t('模糊搜索模型名称')}
|
||
className="!rounded-lg"
|
||
onCompositionStart={handleCompositionStart}
|
||
onCompositionEnd={handleCompositionEnd}
|
||
onChange={handleChange}
|
||
showClear
|
||
size="large"
|
||
/>
|
||
</div>
|
||
<Button
|
||
theme='light'
|
||
type='primary'
|
||
icon={<IconCopy />}
|
||
onClick={() => copyText(selectedRowKeys)}
|
||
disabled={selectedRowKeys.length === 0}
|
||
className="!rounded-lg !bg-blue-500 hover:!bg-blue-600 text-white"
|
||
size="large"
|
||
>
|
||
{t('复制选中模型')}
|
||
</Button>
|
||
</div>
|
||
</Card>
|
||
), [selectedRowKeys, t]);
|
||
|
||
// 表格组件
|
||
const ModelTable = useMemo(() => (
|
||
<Card className="!rounded-xl overflow-hidden" shadows='hover'>
|
||
<Table
|
||
columns={columns}
|
||
dataSource={filteredModels}
|
||
loading={loading}
|
||
rowSelection={rowSelection}
|
||
className="custom-table"
|
||
pagination={{
|
||
defaultPageSize: 10,
|
||
pageSize: pageSize,
|
||
showSizeChanger: true,
|
||
pageSizeOptions: [10, 20, 50, 100],
|
||
formatPageText: (page) =>
|
||
t('第 {{start}} - {{end}} 条,共 {{total}} 条', {
|
||
start: page.currentStart,
|
||
end: page.currentEnd,
|
||
total: filteredModels.length,
|
||
}),
|
||
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 p-4 sm:p-6 md:p-8">
|
||
<div className="w-full">
|
||
{/* 主卡片容器 */}
|
||
<Card 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="absolute inset-0 overflow-hidden">
|
||
<div className="absolute -top-10 -right-10 w-40 h-40 bg-white opacity-5 rounded-full"></div>
|
||
<div className="absolute -bottom-16 -left-16 w-48 h-48 bg-white opacity-3 rounded-full"></div>
|
||
<div className="absolute top-1/2 right-1/4 w-24 h-24 bg-yellow-400 opacity-10 rounded-full"></div>
|
||
</div>
|
||
|
||
<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">
|
||
<IconCrown size="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;
|