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 妥善保存 +
+
+ +
+ ), + }, + { + title: t('选择模型'), + content: ( +
+ {t('浏览模型市场,选择适合您需求的模型。')} +
+ + 支持文本对话、图片生成、视频生成、语音等多种模型。
+ 可按价格、供应商、功能标签筛选。 +
+
+ +
+ ), + }, + { + title: t('首次调用'), + content: ( +
+ {t('使用以下 cURL 命令发送您的第一个 API 请求:')} +
+
+ + {t('将 {key} 替换为您上一步创建的 API Key,将 model 替换为您选择的模型名称。', { key: 'sk-your-api-key-here' })} + + +
+ ), + }, + ]; + + 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 })} + +
+ + +
+ +