新增全局搜索、新用户引导、模型对比工具
- 顶部搜索框:/ 键唤起,搜索模型名称/描述/标签,点击跳转到定价页 - 新用户引导:首次登录弹出三步向导(获取 Key → 选择模型 → 首次调用) - 模型对比:并排对比最多4个模型的价格、支持功能、标签等属性
This commit is contained in:
parent
42070a5300
commit
04c4e17dcc
|
|
@ -90,6 +90,7 @@ import SupplierApplication from './pages/SupplierAdmin/application';
|
|||
import Suppliers from './pages/SupplierAdmin/list';
|
||||
import Setup from './pages/Setup';
|
||||
import SetupCheck from './components/layout/SetupCheck';
|
||||
import ModelComparison from './pages/ModelComparison';
|
||||
import OperationLog from './pages/OperationLog';
|
||||
import LuckyBag from './pages/LuckyBag';
|
||||
import Drawing from './pages/Drawing';
|
||||
|
|
@ -589,6 +590,7 @@ function App() {
|
|||
}
|
||||
/>
|
||||
<Route path='/benchmarks' element={<Benchmarks />} />
|
||||
<Route path='/model-comparison' element={<ModelComparison />} />
|
||||
<Route path='*' element={<NotFound />} />
|
||||
</Routes>
|
||||
</SetupCheck>
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import { IconClose } from '@douyinfe/semi-icons';
|
|||
import SiderBar from './SiderBar';
|
||||
import App from '../../App';
|
||||
import FooterBar from './Footer';
|
||||
import OnboardingWizard from '../onboarding/OnboardingWizard';
|
||||
import { ToastContainer } from 'react-toastify';
|
||||
import React, { useContext, useEffect, useState } from 'react';
|
||||
import { useIsMobile } from '../../hooks/common/useIsMobile';
|
||||
|
|
@ -402,6 +403,7 @@ const PageLayout = () => {
|
|||
)}
|
||||
</Layout>
|
||||
</Layout>
|
||||
<OnboardingWizard />
|
||||
<ToastContainer />
|
||||
</Layout>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -91,6 +91,11 @@ const SiderBar = ({ onNavigate = () => {} }) => {
|
|||
itemKey: 'benchmarks',
|
||||
to: '/benchmarks',
|
||||
},
|
||||
{
|
||||
text: t('模型对比'),
|
||||
itemKey: 'model-comparison',
|
||||
to: '/model-comparison',
|
||||
},
|
||||
{
|
||||
text: t('API 测速'),
|
||||
itemKey: 'api-tester',
|
||||
|
|
|
|||
|
|
@ -17,86 +17,88 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { Input, Dropdown, Typography } from '@douyinfe/semi-ui';
|
||||
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { Input, Typography } from '@douyinfe/semi-ui';
|
||||
import { IconSearch } from '@douyinfe/semi-icons';
|
||||
import { API, isAdmin, showSuccess } from '../../../helpers';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
const mockSearchData = [
|
||||
{
|
||||
month: '四月 2026',
|
||||
items: [
|
||||
{ id: 1, name: 'Google: Gemma 4 31B', icon: '⬥', color: 'text-blue-500' },
|
||||
{
|
||||
id: 2,
|
||||
name: 'Qwen: Qwen3.6 Plus (free)',
|
||||
icon: '⬡',
|
||||
color: 'text-purple-500',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'Z.ai: GLM 5V Turbo',
|
||||
icon: '⬢',
|
||||
color: 'text-gray-800 dark:text-white',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: 'Arcee AI: Trinity Large Thinking',
|
||||
icon: '⬢',
|
||||
color: 'text-teal-500',
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: 'xAI: Grok 4.20 Multi-Agent',
|
||||
icon: '⚡',
|
||||
color: 'text-gray-800 dark:text-white',
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
name: 'xAI: Grok 4.20',
|
||||
icon: '⚡',
|
||||
color: 'text-gray-800 dark:text-white',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
month: '三月 2026',
|
||||
items: [
|
||||
{
|
||||
id: 7,
|
||||
name: 'Google: Lyria 3 Pro Preview',
|
||||
icon: '⬥',
|
||||
color: 'text-blue-500',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
const { Text } = Typography;
|
||||
|
||||
const SEARCH_DEBOUNCE_MS = 300;
|
||||
|
||||
function debounce(fn, ms) {
|
||||
let timer;
|
||||
return (...args) => {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => fn(...args), ms);
|
||||
};
|
||||
}
|
||||
|
||||
const SearchDropdown = ({ isMobile }) => {
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [filteredData, setFilteredData] = useState(mockSearchData);
|
||||
const [models, setModels] = useState([]);
|
||||
const [results, setResults] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const dropdownRef = useRef(null);
|
||||
const inputRef = useRef(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
// 预加载模型数据
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const res = await API.get('/api/pricing', { disableDuplicate: true });
|
||||
if (res?.data?.success && Array.isArray(res.data.data)) {
|
||||
setModels(res.data.data);
|
||||
}
|
||||
} catch {}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const doSearch = useCallback(
|
||||
(q) => {
|
||||
const trimmed = q.trim();
|
||||
if (!trimmed) {
|
||||
setResults([]);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
const lower = trimmed.toLowerCase();
|
||||
const matched = [];
|
||||
for (const m of models) {
|
||||
const name = (m.model_name || '').toLowerCase();
|
||||
const desc = (m.description || '').toLowerCase();
|
||||
const tags = (m.tags || '').toLowerCase();
|
||||
if (name.includes(lower) || desc.includes(lower) || tags.includes(lower)) {
|
||||
matched.push({
|
||||
type: 'model',
|
||||
label: m.model_name,
|
||||
desc: m.description || '',
|
||||
navigate: `/pricing?search=${encodeURIComponent(trimmed)}`,
|
||||
});
|
||||
}
|
||||
if (matched.length >= 8) break;
|
||||
}
|
||||
setResults(matched);
|
||||
setLoading(false);
|
||||
},
|
||||
[models],
|
||||
);
|
||||
|
||||
const debouncedSearch = useCallback(
|
||||
debounce(doSearch, SEARCH_DEBOUNCE_MS),
|
||||
[doSearch],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchValue.trim() === '') {
|
||||
setFilteredData(mockSearchData);
|
||||
} else {
|
||||
const filtered = mockSearchData
|
||||
.map((group) => ({
|
||||
...group,
|
||||
items: group.items.filter((item) =>
|
||||
item.name.toLowerCase().includes(searchValue.toLowerCase()),
|
||||
),
|
||||
}))
|
||||
.filter((group) => group.items.length > 0);
|
||||
setFilteredData(filtered);
|
||||
}
|
||||
}, [searchValue]);
|
||||
debouncedSearch(searchValue);
|
||||
}, [searchValue, debouncedSearch]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event) => {
|
||||
if (event.key === '/') {
|
||||
if (event.key === '/' && document.activeElement === document.body) {
|
||||
event.preventDefault();
|
||||
inputRef.current?.focus();
|
||||
setVisible(true);
|
||||
|
|
@ -106,89 +108,110 @@ const SearchDropdown = ({ isMobile }) => {
|
|||
inputRef.current?.blur();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, []);
|
||||
|
||||
const handleItemClick = (item) => {
|
||||
console.log('Selected:', item);
|
||||
setVisible(false);
|
||||
setSearchValue('');
|
||||
setResults([]);
|
||||
if (item.navigate) {
|
||||
if (item.navigate.startsWith('/')) {
|
||||
navigate(item.navigate);
|
||||
} else {
|
||||
window.open(item.navigate, '_blank');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const typeLabel = (type) => {
|
||||
switch (type) {
|
||||
case 'model': return '模型';
|
||||
default: return '';
|
||||
}
|
||||
};
|
||||
|
||||
const renderDropdownContent = () => {
|
||||
if (!searchValue.trim()) {
|
||||
return (
|
||||
<div className='w-80 md:w-96 max-h-96 overflow-y-auto'>
|
||||
{filteredData.length > 0 ? (
|
||||
filteredData.map((group) => (
|
||||
<div key={group.month} className='py-2'>
|
||||
<Typography.Text className='!px-4 !py-2 !text-xs !font-semibold !text-semi-color-text-2 dark:!text-gray-400 uppercase tracking-wider block'>
|
||||
{group.month}
|
||||
</Typography.Text>
|
||||
<div>
|
||||
{group.items.map((item) => (
|
||||
<div className='px-4 py-8 text-center'>
|
||||
<Text className='!text-sm !text-semi-color-text-2 dark:!text-gray-400'>
|
||||
输入关键词搜索模型
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (loading) {
|
||||
return (
|
||||
<div className='px-4 py-8 text-center'>
|
||||
<Text className='!text-sm !text-semi-color-text-2 dark:!text-gray-400'>
|
||||
搜索中...
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (results.length === 0) {
|
||||
return (
|
||||
<div className='px-4 py-8 text-center'>
|
||||
<Text className='!text-sm !text-semi-color-text-2 dark:!text-gray-400'>
|
||||
未找到匹配结果
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className='max-h-96 overflow-y-auto py-2'>
|
||||
{results.map((item, i) => (
|
||||
<div
|
||||
key={item.id}
|
||||
key={`${item.type}-${i}`}
|
||||
onClick={() => handleItemClick(item)}
|
||||
className='px-4 py-2.5 flex items-center gap-3 cursor-pointer hover:!bg-semi-color-fill-1 dark:hover:!bg-gray-700 transition-colors'
|
||||
className='px-4 py-2.5 flex items-center gap-3 cursor-pointer hover:bg-semi-color-fill-1 dark:hover:bg-gray-700 transition-colors'
|
||||
>
|
||||
<span className={`text-lg ${item.color}`}>{item.icon}</span>
|
||||
<Typography.Text className='!text-sm !font-medium !text-semi-color-text-0 dark:!text-gray-200'>
|
||||
{item.name}
|
||||
</Typography.Text>
|
||||
<span className='shrink-0 rounded bg-blue-100 dark:bg-blue-900/40 px-1.5 py-0.5 text-xs text-blue-600 dark:text-blue-300'>
|
||||
{typeLabel(item.type)}
|
||||
</span>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<Text className='!text-sm !font-medium !text-semi-color-text-0 dark:!text-gray-200 block truncate'>
|
||||
{item.label}
|
||||
</Text>
|
||||
{item.desc && (
|
||||
<Text className='!text-xs !text-semi-color-text-2 dark:!text-gray-400 block truncate'>
|
||||
{item.desc}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className='px-4 py-8 text-center'>
|
||||
<Typography.Text className='!text-sm !text-semi-color-text-2 dark:!text-gray-400'>
|
||||
No results found
|
||||
</Typography.Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='relative' ref={dropdownRef}>
|
||||
<Dropdown
|
||||
visible={visible}
|
||||
onVisibleChange={setVisible}
|
||||
position='bottomLeft'
|
||||
trigger='custom'
|
||||
getPopupContainer={() => dropdownRef.current}
|
||||
render={
|
||||
<div className='!bg-semi-color-bg-overlay !border-semi-color-border !shadow-lg !rounded-lg dark:!bg-gray-800 dark:!border-gray-600'>
|
||||
{renderDropdownContent()}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className='relative'>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
placeholder='Search'
|
||||
prefix={
|
||||
<IconSearch className='text-semi-color-text-2 dark:text-gray-400' />
|
||||
}
|
||||
placeholder='搜索模型...'
|
||||
prefix={<IconSearch className='text-semi-color-text-2 dark:text-gray-400' />}
|
||||
suffix={
|
||||
<kbd className='px-1.5 py-0.5 text-xs font-semibold !text-semi-color-text-2 dark:!text-gray-400 !bg-semi-color-fill-0 dark:!bg-gray-700 border !border-semi-color-border dark:!border-gray-600 rounded'>
|
||||
<kbd className='hidden sm:inline-block px-1.5 py-0.5 text-xs font-semibold text-semi-color-text-2 dark:text-gray-400 bg-semi-color-fill-0 dark:bg-gray-700 border border-semi-color-border dark:border-gray-600 rounded'>
|
||||
/
|
||||
</kbd>
|
||||
}
|
||||
value={searchValue}
|
||||
onChange={setSearchValue}
|
||||
onFocus={() => setVisible(true)}
|
||||
className='!w-48 lg:!w-64 !h-9 !text-sm !bg-semi-color-fill-0 dark:!bg-gray-800/50 !border-semi-color-border dark:!border-gray-700 hover:!border-semi-color-primary dark:hover:!border-blue-400 focus:!border-semi-color-primary dark:focus:!border-blue-400'
|
||||
style={{ borderRadius: '6px', paddingRight: '10px' }}
|
||||
onFocus={() => { if (searchValue.trim()) setVisible(true); }}
|
||||
onBlur={() => setTimeout(() => setVisible(false), 150)}
|
||||
className='!w-40 lg:!w-56 !h-9 !text-sm !bg-semi-color-fill-0 dark:!bg-gray-800/50 !border-semi-color-border dark:!border-gray-700 hover:!border-semi-color-primary dark:hover:!border-blue-400 focus:!border-semi-color-primary dark:focus:!border-blue-400'
|
||||
style={{ borderRadius: '6px' }}
|
||||
/>
|
||||
</div>
|
||||
</Dropdown>
|
||||
{visible && (results.length > 0 || searchValue.trim()) && (
|
||||
<div className='absolute left-0 top-full mt-1 w-80 md:w-96 bg-semi-color-bg-overlay border border-semi-color-border shadow-lg rounded-lg dark:bg-gray-800 dark:border-gray-600 z-50'>
|
||||
{renderDropdownContent()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import MobileMenuButton from './MobileMenuButton';
|
|||
import HeaderLogo from './HeaderLogo';
|
||||
import MobileSiteNavDropdown from './MobileSiteNavDropdown';
|
||||
import Navigation from './Navigation';
|
||||
import SearchDropdown from './SearchDropdown';
|
||||
import ActionButtons from './ActionButtons';
|
||||
|
||||
const HeaderBar = ({ onMobileMenuToggle, drawerOpen }) => {
|
||||
|
|
@ -115,6 +116,10 @@ const HeaderBar = ({ onMobileMenuToggle, drawerOpen }) => {
|
|||
|
||||
<div className="flex-1" />
|
||||
|
||||
<div className="hidden md:flex items-center mr-2">
|
||||
<SearchDropdown />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 md:gap-2">
|
||||
<Navigation
|
||||
mainNavLinks={mainNavLinks}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,198 @@
|
|||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useContext } from 'react';
|
||||
import { Modal, Button, Steps, Typography, Space } from '@douyinfe/semi-ui';
|
||||
import { IconCopy, IconLink } from '@douyinfe/semi-icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { UserContext } from '../../context/User';
|
||||
import { copy, showSuccess } from '../../helpers';
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
|
||||
const STORAGE_KEY = 'onboarding_completed';
|
||||
|
||||
const OnboardingWizard = () => {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [step, setStep] = useState(0);
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [userState] = useContext(UserContext);
|
||||
|
||||
useEffect(() => {
|
||||
const completed = localStorage.getItem(STORAGE_KEY);
|
||||
if (!completed && userState?.user) {
|
||||
setVisible(true);
|
||||
}
|
||||
}, [userState?.user]);
|
||||
|
||||
const handleClose = () => {
|
||||
localStorage.setItem(STORAGE_KEY, '1');
|
||||
setVisible(false);
|
||||
};
|
||||
|
||||
const getTokenStepExample = () => {
|
||||
const baseUrl = window.location.origin;
|
||||
const demoKey = 'sk-your-api-key-here';
|
||||
return `curl ${baseUrl}/v1/chat/completions \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-H "Authorization: Bearer ${demoKey}" \\
|
||||
-d '{
|
||||
"model": "gpt-3.5-turbo",
|
||||
"messages": [{"role": "user", "content": "Hello!"}]
|
||||
}'`;
|
||||
};
|
||||
|
||||
const steps = [
|
||||
{
|
||||
title: t('获取 API Key'),
|
||||
content: (
|
||||
<div className='space-y-4'>
|
||||
<Text>{t('前往令牌管理页面创建您的第一个 API Key。')}</Text>
|
||||
<div className='rounded-lg bg-semi-color-fill-0 dark:bg-gray-800 p-4'>
|
||||
<Text className='!text-sm !text-semi-color-text-2'>
|
||||
1. 点击下方按钮进入令牌页面<br />
|
||||
2. 点击「添加令牌」创建新 Key<br />
|
||||
3. 复制生成的 sk- 开头的 Key 妥善保存
|
||||
</Text>
|
||||
</div>
|
||||
<Button
|
||||
type='primary'
|
||||
icon={<IconLink />}
|
||||
onClick={() => {
|
||||
handleClose();
|
||||
navigate('/console/token');
|
||||
}}
|
||||
>
|
||||
{t('前往令牌管理')}
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('选择模型'),
|
||||
content: (
|
||||
<div className='space-y-4'>
|
||||
<Text>{t('浏览模型市场,选择适合您需求的模型。')}</Text>
|
||||
<div className='rounded-lg bg-semi-color-fill-0 dark:bg-gray-800 p-4'>
|
||||
<Text className='!text-sm !text-semi-color-text-2'>
|
||||
支持文本对话、图片生成、视频生成、语音等多种模型。<br />
|
||||
可按价格、供应商、功能标签筛选。
|
||||
</Text>
|
||||
</div>
|
||||
<Button
|
||||
type='primary'
|
||||
icon={<IconLink />}
|
||||
onClick={() => {
|
||||
handleClose();
|
||||
navigate('/pricing');
|
||||
}}
|
||||
>
|
||||
{t('浏览模型市场')}
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('首次调用'),
|
||||
content: (
|
||||
<div className='space-y-4'>
|
||||
<Text>{t('使用以下 cURL 命令发送您的第一个 API 请求:')}</Text>
|
||||
<div className='relative rounded-lg bg-gray-900 dark:bg-gray-950 p-4 group'>
|
||||
<Button
|
||||
theme='borderless'
|
||||
type='tertiary'
|
||||
size='small'
|
||||
icon={<IconCopy />}
|
||||
className='!absolute top-2 right-2 !text-gray-400 hover:!text-white'
|
||||
onClick={() => {
|
||||
copy(getTokenStepExample());
|
||||
showSuccess(t('已复制'));
|
||||
}}
|
||||
/>
|
||||
<pre className='text-xs text-green-400 overflow-x-auto whitespace-pre-wrap'>
|
||||
{getTokenStepExample()}
|
||||
</pre>
|
||||
</div>
|
||||
<Text className='!text-xs !text-semi-color-text-2'>
|
||||
{t('将 {key} 替换为您上一步创建的 API Key,将 model 替换为您选择的模型名称。', { key: 'sk-your-api-key-here' })}
|
||||
</Text>
|
||||
<Button
|
||||
type='tertiary'
|
||||
icon={<IconLink />}
|
||||
onClick={() => {
|
||||
handleClose();
|
||||
navigate('/docs');
|
||||
}}
|
||||
>
|
||||
{t('查看完整 API 文档')}
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='text-lg'>🚀</span>
|
||||
<Title heading={5} className='!mb-0'>
|
||||
{t('欢迎使用')}
|
||||
</Title>
|
||||
</div>
|
||||
}
|
||||
visible={visible}
|
||||
onCancel={handleClose}
|
||||
footer={
|
||||
<Space>
|
||||
{step > 0 && (
|
||||
<Button onClick={() => setStep((s) => s - 1)}>
|
||||
{t('上一步')}
|
||||
</Button>
|
||||
)}
|
||||
{step < steps.length - 1 ? (
|
||||
<Button
|
||||
type='primary'
|
||||
onClick={() => setStep((s) => s + 1)}
|
||||
>
|
||||
{t('下一步')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button type='primary' onClick={handleClose}>
|
||||
{t('开始使用')}
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
}
|
||||
width={560}
|
||||
maskClosable={false}
|
||||
>
|
||||
<Steps current={step} className='mb-6'>
|
||||
{steps.map((s) => (
|
||||
<Steps.Step key={s.title} title={s.title} />
|
||||
))}
|
||||
</Steps>
|
||||
<div className='min-h-[160px]'>{steps[step].content}</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default OnboardingWizard;
|
||||
|
|
@ -0,0 +1,231 @@
|
|||
/*
|
||||
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, { useEffect, useState, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Table,
|
||||
Select,
|
||||
Tag,
|
||||
Typography,
|
||||
Card,
|
||||
Button,
|
||||
Empty,
|
||||
Toast,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { IconDelete, IconPlus } from '@douyinfe/semi-icons';
|
||||
import { API, renderQuota, isAdmin } from '../../helpers';
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
|
||||
const MAX_COMPARE = 4;
|
||||
|
||||
const endpointLabels = {
|
||||
chat: '对话',
|
||||
image: '图片',
|
||||
audio: '音频',
|
||||
video: '视频',
|
||||
embeddings: '嵌入',
|
||||
rerank: '重排',
|
||||
moderate: '审核',
|
||||
};
|
||||
|
||||
const ModelComparison = () => {
|
||||
const { t } = useTranslation();
|
||||
const [models, setModels] = useState([]);
|
||||
const [selected, setSelected] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await API.get('/api/pricing', { disableDuplicate: true });
|
||||
if (res?.data?.success && Array.isArray(res.data.data)) {
|
||||
setModels(res.data.data);
|
||||
}
|
||||
} catch {
|
||||
Toast.error({ content: t('加载模型列表失败') });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}, [t]);
|
||||
|
||||
const modelOptions = useMemo(
|
||||
() =>
|
||||
models.map((m) => ({
|
||||
value: m.model_name,
|
||||
label: m.model_name,
|
||||
})),
|
||||
[models],
|
||||
);
|
||||
|
||||
const selectedModels = useMemo(
|
||||
() => models.filter((m) => selected.includes(m.model_name)),
|
||||
[models, selected],
|
||||
);
|
||||
|
||||
const handleSelect = (vals) => {
|
||||
if (vals.length > MAX_COMPARE) {
|
||||
Toast.warning({ content: t('最多同时对比 {{max}} 个模型', { max: MAX_COMPARE }) });
|
||||
return;
|
||||
}
|
||||
setSelected(vals);
|
||||
};
|
||||
|
||||
const handleRemove = (name) => {
|
||||
setSelected((prev) => prev.filter((n) => n !== name));
|
||||
};
|
||||
|
||||
const formatEndpointTypes = (types) => {
|
||||
if (!Array.isArray(types) || types.length === 0) return '-';
|
||||
return types.map((ep) => endpointLabels[ep] || ep).join('、');
|
||||
};
|
||||
|
||||
const formatTags = (tags) => {
|
||||
if (!tags) return '-';
|
||||
const arr = typeof tags === 'string' ? tags.split(',').map((s) => s.trim()).filter(Boolean) : [];
|
||||
return arr.length > 0 ? arr.join('、') : '-';
|
||||
};
|
||||
|
||||
const formatPrice = (model) => {
|
||||
const ratio = Number(model.model_ratio || 0);
|
||||
const completionRatio = model.completion_ratio != null
|
||||
? Number(model.completion_ratio)
|
||||
: ratio;
|
||||
if (Number(model.model_price || 0) > 0) {
|
||||
return `$${Number(model.model_price).toFixed(4)} (固定价)`;
|
||||
}
|
||||
return `输入 ${ratio.toFixed(1)}x / 输出 ${completionRatio.toFixed(1)}x`;
|
||||
};
|
||||
|
||||
// Comparison rows
|
||||
const compareRows = [
|
||||
{ key: 'vendor', label: t('供应商'), render: (m) => m.owner_by || '-' },
|
||||
{ key: 'description', label: t('简介'), render: (m) => m.description || '-' },
|
||||
{ key: 'pricing', label: t('价格'), render: (m) => formatPrice(m) },
|
||||
{ key: 'endpoints', label: t('支持功能'), render: (m) => formatEndpointTypes(m.supported_endpoint_types) },
|
||||
{ key: 'tags', label: t('标签'), render: (m) => formatTags(m.tags) },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className='min-h-full pb-16 pt-20 text-slate-900 dark:text-white'>
|
||||
<div className='mx-auto w-full max-w-[1400px] px-4 sm:px-6 lg:px-10'>
|
||||
<div className='mb-6 flex items-center justify-between'>
|
||||
<Title heading={3} className='!mb-0'>
|
||||
{t('模型对比')}
|
||||
</Title>
|
||||
<Text className='!text-semi-color-text-2'>
|
||||
{t('已选 {{n}} / {{max}}', { n: selected.length, max: MAX_COMPARE })}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Card className='mb-6'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<IconPlus className='text-semi-color-text-2' />
|
||||
<Select
|
||||
placeholder={t('选择模型进行对比(最多 {{max}} 个)', { max: MAX_COMPARE })}
|
||||
multiple
|
||||
value={selected}
|
||||
onChange={handleSelect}
|
||||
optionList={modelOptions}
|
||||
filter
|
||||
style={{ flex: 1, minWidth: 300 }}
|
||||
loading={loading}
|
||||
/>
|
||||
</div>
|
||||
{selected.length > 0 && (
|
||||
<div className='mt-3 flex flex-wrap gap-2'>
|
||||
{selected.map((name) => (
|
||||
<Tag
|
||||
key={name}
|
||||
color='blue'
|
||||
closable
|
||||
onClose={() => handleRemove(name)}
|
||||
>
|
||||
{name}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{selectedModels.length > 0 ? (
|
||||
<Card>
|
||||
<div className='overflow-x-auto'>
|
||||
<table className='w-full border-collapse text-sm'>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className='sticky left-0 z-10 bg-semi-color-bg-2 dark:bg-gray-900 px-4 py-3 text-left font-medium text-semi-color-text-2 border-b border-semi-color-border w-[120px]'>
|
||||
{t('属性')}
|
||||
</th>
|
||||
{selectedModels.map((m) => (
|
||||
<th
|
||||
key={m.model_name}
|
||||
className='px-4 py-3 text-left font-semibold text-semi-color-text-0 border-b border-semi-color-border min-w-[180px]'
|
||||
>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Text strong>{m.model_name}</Text>
|
||||
<Button
|
||||
theme='borderless'
|
||||
type='tertiary'
|
||||
size='small'
|
||||
icon={<IconDelete />}
|
||||
onClick={() => handleRemove(m.model_name)}
|
||||
/>
|
||||
</div>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{compareRows.map((row) => (
|
||||
<tr key={row.key}>
|
||||
<td className='sticky left-0 z-10 bg-semi-color-bg-2 dark:bg-gray-900 px-4 py-3 font-medium text-semi-color-text-2 border-b border-semi-color-border'>
|
||||
{row.label}
|
||||
</td>
|
||||
{selectedModels.map((m) => (
|
||||
<td
|
||||
key={m.model_name}
|
||||
className='px-4 py-3 text-semi-color-text-0 border-b border-semi-color-border'
|
||||
>
|
||||
{row.render(m)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<Empty
|
||||
title={t('请选择模型')}
|
||||
description={t('在上方选择 2-4 个模型进行并排对比')}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModelComparison;
|
||||
Loading…
Reference in New Issue