新增全局搜索、新用户引导、模型对比工具

- 顶部搜索框:/ 键唤起,搜索模型名称/描述/标签,点击跳转到定价页
- 新用户引导:首次登录弹出三步向导(获取 Key → 选择模型 → 首次调用)
- 模型对比:并排对比最多4个模型的价格、支持功能、标签等属性
This commit is contained in:
wangxiaoji 2026-07-01 16:24:26 +08:00
parent 42070a5300
commit 04c4e17dcc
7 changed files with 597 additions and 131 deletions

2
web/src/App.jsx vendored
View File

@ -90,6 +90,7 @@ import SupplierApplication from './pages/SupplierAdmin/application';
import Suppliers from './pages/SupplierAdmin/list'; import Suppliers from './pages/SupplierAdmin/list';
import Setup from './pages/Setup'; import Setup from './pages/Setup';
import SetupCheck from './components/layout/SetupCheck'; import SetupCheck from './components/layout/SetupCheck';
import ModelComparison from './pages/ModelComparison';
import OperationLog from './pages/OperationLog'; import OperationLog from './pages/OperationLog';
import LuckyBag from './pages/LuckyBag'; import LuckyBag from './pages/LuckyBag';
import Drawing from './pages/Drawing'; import Drawing from './pages/Drawing';
@ -589,6 +590,7 @@ function App() {
} }
/> />
<Route path='/benchmarks' element={<Benchmarks />} /> <Route path='/benchmarks' element={<Benchmarks />} />
<Route path='/model-comparison' element={<ModelComparison />} />
<Route path='*' element={<NotFound />} /> <Route path='*' element={<NotFound />} />
</Routes> </Routes>
</SetupCheck> </SetupCheck>

View File

@ -23,6 +23,7 @@ import { IconClose } from '@douyinfe/semi-icons';
import SiderBar from './SiderBar'; import SiderBar from './SiderBar';
import App from '../../App'; import App from '../../App';
import FooterBar from './Footer'; import FooterBar from './Footer';
import OnboardingWizard from '../onboarding/OnboardingWizard';
import { ToastContainer } from 'react-toastify'; import { ToastContainer } from 'react-toastify';
import React, { useContext, useEffect, useState } from 'react'; import React, { useContext, useEffect, useState } from 'react';
import { useIsMobile } from '../../hooks/common/useIsMobile'; import { useIsMobile } from '../../hooks/common/useIsMobile';
@ -402,6 +403,7 @@ const PageLayout = () => {
)} )}
</Layout> </Layout>
</Layout> </Layout>
<OnboardingWizard />
<ToastContainer /> <ToastContainer />
</Layout> </Layout>
); );

View File

@ -91,6 +91,11 @@ const SiderBar = ({ onNavigate = () => {} }) => {
itemKey: 'benchmarks', itemKey: 'benchmarks',
to: '/benchmarks', to: '/benchmarks',
}, },
{
text: t('模型对比'),
itemKey: 'model-comparison',
to: '/model-comparison',
},
{ {
text: t('API 测速'), text: t('API 测速'),
itemKey: 'api-tester', itemKey: 'api-tester',

View File

@ -17,86 +17,88 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com For commercial licensing, please contact support@quantumnous.com
*/ */
import React, { useState, useRef, useEffect } from 'react'; import React, { useState, useRef, useEffect, useCallback } from 'react';
import { Input, Dropdown, Typography } from '@douyinfe/semi-ui'; import { Input, Typography } from '@douyinfe/semi-ui';
import { IconSearch } from '@douyinfe/semi-icons'; import { IconSearch } from '@douyinfe/semi-icons';
import { API, isAdmin, showSuccess } from '../../../helpers';
import { useNavigate } from 'react-router-dom';
const mockSearchData = [ const { Text } = Typography;
{
month: '四月 2026', const SEARCH_DEBOUNCE_MS = 300;
items: [
{ id: 1, name: 'Google: Gemma 4 31B', icon: '⬥', color: 'text-blue-500' }, function debounce(fn, ms) {
{ let timer;
id: 2, return (...args) => {
name: 'Qwen: Qwen3.6 Plus (free)', clearTimeout(timer);
icon: '⬡', timer = setTimeout(() => fn(...args), ms);
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 SearchDropdown = ({ isMobile }) => { const SearchDropdown = ({ isMobile }) => {
const [searchValue, setSearchValue] = useState(''); const [searchValue, setSearchValue] = useState('');
const [visible, setVisible] = useState(false); 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 dropdownRef = useRef(null);
const inputRef = 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(() => { useEffect(() => {
if (searchValue.trim() === '') { debouncedSearch(searchValue);
setFilteredData(mockSearchData); }, [searchValue, debouncedSearch]);
} 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]);
useEffect(() => { useEffect(() => {
const handleKeyDown = (event) => { const handleKeyDown = (event) => {
if (event.key === '/') { if (event.key === '/' && document.activeElement === document.body) {
event.preventDefault(); event.preventDefault();
inputRef.current?.focus(); inputRef.current?.focus();
setVisible(true); setVisible(true);
@ -106,89 +108,110 @@ const SearchDropdown = ({ isMobile }) => {
inputRef.current?.blur(); inputRef.current?.blur();
} }
}; };
document.addEventListener('keydown', handleKeyDown); document.addEventListener('keydown', handleKeyDown);
return () => { return () => document.removeEventListener('keydown', handleKeyDown);
document.removeEventListener('keydown', handleKeyDown);
};
}, []); }, []);
const handleItemClick = (item) => { const handleItemClick = (item) => {
console.log('Selected:', item);
setVisible(false); setVisible(false);
setSearchValue(''); 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 = () => { const renderDropdownContent = () => {
if (!searchValue.trim()) {
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 (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 ( return (
<div className='w-80 md:w-96 max-h-96 overflow-y-auto'> <div className='max-h-96 overflow-y-auto py-2'>
{filteredData.length > 0 ? ( {results.map((item, i) => (
filteredData.map((group) => ( <div
<div key={group.month} className='py-2'> key={`${item.type}-${i}`}
<Typography.Text className='!px-4 !py-2 !text-xs !font-semibold !text-semi-color-text-2 dark:!text-gray-400 uppercase tracking-wider block'> onClick={() => handleItemClick(item)}
{group.month} 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'
</Typography.Text> >
<div> <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'>
{group.items.map((item) => ( {typeLabel(item.type)}
<div </span>
key={item.id} <div className='min-w-0 flex-1'>
onClick={() => handleItemClick(item)} <Text className='!text-sm !font-medium !text-semi-color-text-0 dark:!text-gray-200 block truncate'>
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' {item.label}
> </Text>
<span className={`text-lg ${item.color}`}>{item.icon}</span> {item.desc && (
<Typography.Text className='!text-sm !font-medium !text-semi-color-text-0 dark:!text-gray-200'> <Text className='!text-xs !text-semi-color-text-2 dark:!text-gray-400 block truncate'>
{item.name} {item.desc}
</Typography.Text> </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>
)} ))}
</div> </div>
); );
}; };
return ( return (
<div className='relative' ref={dropdownRef}> <div className='relative' ref={dropdownRef}>
<Dropdown <div className='relative'>
visible={visible} <Input
onVisibleChange={setVisible} ref={inputRef}
position='bottomLeft' placeholder='搜索模型...'
trigger='custom' prefix={<IconSearch className='text-semi-color-text-2 dark:text-gray-400' />}
getPopupContainer={() => dropdownRef.current} suffix={
render={ <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'>
<div className='!bg-semi-color-bg-overlay !border-semi-color-border !shadow-lg !rounded-lg dark:!bg-gray-800 dark:!border-gray-600'> /
{renderDropdownContent()} </kbd>
</div> }
} value={searchValue}
> onChange={setSearchValue}
<div className='relative'> onFocus={() => { if (searchValue.trim()) setVisible(true); }}
<Input onBlur={() => setTimeout(() => setVisible(false), 150)}
ref={inputRef} 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'
placeholder='Search' style={{ borderRadius: '6px' }}
prefix={ />
<IconSearch className='text-semi-color-text-2 dark:text-gray-400' /> </div>
} {visible && (results.length > 0 || searchValue.trim()) && (
suffix={ <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'>
<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'> {renderDropdownContent()}
/
</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' }}
/>
</div> </div>
</Dropdown> )}
</div> </div>
); );
}; };

View File

@ -26,6 +26,7 @@ import MobileMenuButton from './MobileMenuButton';
import HeaderLogo from './HeaderLogo'; import HeaderLogo from './HeaderLogo';
import MobileSiteNavDropdown from './MobileSiteNavDropdown'; import MobileSiteNavDropdown from './MobileSiteNavDropdown';
import Navigation from './Navigation'; import Navigation from './Navigation';
import SearchDropdown from './SearchDropdown';
import ActionButtons from './ActionButtons'; import ActionButtons from './ActionButtons';
const HeaderBar = ({ onMobileMenuToggle, drawerOpen }) => { const HeaderBar = ({ onMobileMenuToggle, drawerOpen }) => {
@ -115,6 +116,10 @@ const HeaderBar = ({ onMobileMenuToggle, drawerOpen }) => {
<div className="flex-1" /> <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"> <div className="flex items-center gap-1 md:gap-2">
<Navigation <Navigation
mainNavLinks={mainNavLinks} mainNavLinks={mainNavLinks}

View File

@ -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;

View File

@ -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;