diff --git a/web/src/App.jsx b/web/src/App.jsx
index 8743d54..2a3983a 100644
--- a/web/src/App.jsx
+++ b/web/src/App.jsx
@@ -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() {
}
/>
} />
+ } />
} />
diff --git a/web/src/components/layout/PageLayout.jsx b/web/src/components/layout/PageLayout.jsx
index 6eae288..38c63ce 100644
--- a/web/src/components/layout/PageLayout.jsx
+++ b/web/src/components/layout/PageLayout.jsx
@@ -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 = () => {
)}
+
);
diff --git a/web/src/components/layout/SiderBar.jsx b/web/src/components/layout/SiderBar.jsx
index a1de32e..3d53ae6 100644
--- a/web/src/components/layout/SiderBar.jsx
+++ b/web/src/components/layout/SiderBar.jsx
@@ -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',
diff --git a/web/src/components/layout/headerbar/SearchDropdown.jsx b/web/src/components/layout/headerbar/SearchDropdown.jsx
index 2477dbc..edf856a 100644
--- a/web/src/components/layout/headerbar/SearchDropdown.jsx
+++ b/web/src/components/layout/headerbar/SearchDropdown.jsx
@@ -17,86 +17,88 @@ along with this program. If not, see .
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 (
+
+
+ 输入关键词搜索模型
+
+
+ );
+ }
+ if (loading) {
+ return (
+
+
+ 搜索中...
+
+
+ );
+ }
+ if (results.length === 0) {
+ return (
+
+
+ 未找到匹配结果
+
+
+ );
+ }
return (
-
- {filteredData.length > 0 ? (
- filteredData.map((group) => (
-
-
- {group.month}
-
-
- {group.items.map((item) => (
-
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'
- >
- {item.icon}
-
- {item.name}
-
-
- ))}
-
+
+ {results.map((item, i) => (
+
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'
+ >
+
+ {typeLabel(item.type)}
+
+
+
+ {item.label}
+
+ {item.desc && (
+
+ {item.desc}
+
+ )}
- ))
- ) : (
-
-
- No results found
-
- )}
+ ))}
);
};
return (
-
dropdownRef.current}
- render={
-
- {renderDropdownContent()}
-
- }
- >
-
-
- }
- suffix={
-
- /
-
- }
- 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' }}
- />
+
+ }
+ suffix={
+
+ /
+
+ }
+ value={searchValue}
+ onChange={setSearchValue}
+ 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' }}
+ />
+
+ {visible && (results.length > 0 || searchValue.trim()) && (
+
+ {renderDropdownContent()}
-
+ )}
);
};
diff --git a/web/src/components/layout/headerbar/index.jsx b/web/src/components/layout/headerbar/index.jsx
index f205fc1..5b5475c 100644
--- a/web/src/components/layout/headerbar/index.jsx
+++ b/web/src/components/layout/headerbar/index.jsx
@@ -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 }) => {
+
+
+
+
.
+
+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: (
+
+
{t('前往令牌管理页面创建您的第一个 API Key。')}
+
+
+ 1. 点击下方按钮进入令牌页面
+ 2. 点击「添加令牌」创建新 Key
+ 3. 复制生成的 sk- 开头的 Key 妥善保存
+
+
+
}
+ onClick={() => {
+ handleClose();
+ navigate('/console/token');
+ }}
+ >
+ {t('前往令牌管理')}
+
+
+ ),
+ },
+ {
+ title: t('选择模型'),
+ content: (
+
+
{t('浏览模型市场,选择适合您需求的模型。')}
+
+
+ 支持文本对话、图片生成、视频生成、语音等多种模型。
+ 可按价格、供应商、功能标签筛选。
+
+
+
}
+ onClick={() => {
+ handleClose();
+ navigate('/pricing');
+ }}
+ >
+ {t('浏览模型市场')}
+
+
+ ),
+ },
+ {
+ title: t('首次调用'),
+ content: (
+
+
{t('使用以下 cURL 命令发送您的第一个 API 请求:')}
+
+
}
+ className='!absolute top-2 right-2 !text-gray-400 hover:!text-white'
+ onClick={() => {
+ copy(getTokenStepExample());
+ showSuccess(t('已复制'));
+ }}
+ />
+
+ {getTokenStepExample()}
+
+
+
+ {t('将 {key} 替换为您上一步创建的 API Key,将 model 替换为您选择的模型名称。', { key: 'sk-your-api-key-here' })}
+
+
}
+ onClick={() => {
+ handleClose();
+ navigate('/docs');
+ }}
+ >
+ {t('查看完整 API 文档')}
+
+
+ ),
+ },
+ ];
+
+ return (
+
+ 🚀
+
+ {t('欢迎使用')}
+
+
+ }
+ visible={visible}
+ onCancel={handleClose}
+ footer={
+
+ {step > 0 && (
+
+ )}
+ {step < steps.length - 1 ? (
+
+ ) : (
+
+ )}
+
+ }
+ width={560}
+ maskClosable={false}
+ >
+
+ {steps.map((s) => (
+
+ ))}
+
+ {steps[step].content}
+
+ );
+};
+
+export default OnboardingWizard;
diff --git a/web/src/pages/ModelComparison/index.jsx b/web/src/pages/ModelComparison/index.jsx
new file mode 100644
index 0000000..f62f419
--- /dev/null
+++ b/web/src/pages/ModelComparison/index.jsx
@@ -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 .
+
+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 (
+
+
+
+
+ {t('模型对比')}
+
+
+ {t('已选 {{n}} / {{max}}', { n: selected.length, max: MAX_COMPARE })}
+
+
+
+
+
+
+
+
+ {selected.length > 0 && (
+
+ {selected.map((name) => (
+ handleRemove(name)}
+ >
+ {name}
+
+ ))}
+
+ )}
+
+
+ {selectedModels.length > 0 ? (
+
+
+
+
+
+ |
+ {t('属性')}
+ |
+ {selectedModels.map((m) => (
+
+
+ {m.model_name}
+ }
+ onClick={() => handleRemove(m.model_name)}
+ />
+
+ |
+ ))}
+
+
+
+ {compareRows.map((row) => (
+
+ |
+ {row.label}
+ |
+ {selectedModels.map((m) => (
+
+ {row.render(m)}
+ |
+ ))}
+
+ ))}
+
+
+
+
+ ) : (
+
+
+
+ )}
+
+
+ );
+};
+
+export default ModelComparison;