220 lines
7.0 KiB
JavaScript
220 lines
7.0 KiB
JavaScript
/*
|
|
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, 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 { 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 [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(() => {
|
|
debouncedSearch(searchValue);
|
|
}, [searchValue, debouncedSearch]);
|
|
|
|
useEffect(() => {
|
|
const handleKeyDown = (event) => {
|
|
if (event.key === '/' && document.activeElement === document.body) {
|
|
event.preventDefault();
|
|
inputRef.current?.focus();
|
|
setVisible(true);
|
|
}
|
|
if (event.key === 'Escape') {
|
|
setVisible(false);
|
|
inputRef.current?.blur();
|
|
}
|
|
};
|
|
document.addEventListener('keydown', handleKeyDown);
|
|
return () => document.removeEventListener('keydown', handleKeyDown);
|
|
}, []);
|
|
|
|
const handleItemClick = (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='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.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'
|
|
>
|
|
<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>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<div className='relative' ref={dropdownRef}>
|
|
<div className='relative'>
|
|
<Input
|
|
ref={inputRef}
|
|
placeholder='搜索模型...'
|
|
prefix={<IconSearch className='text-semi-color-text-2 dark:text-gray-400' />}
|
|
suffix={
|
|
<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={() => { 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>
|
|
{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>
|
|
);
|
|
};
|
|
|
|
export default SearchDropdown;
|