tokenFactory/web/src/components/layout/headerbar/SearchDropdown.jsx

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;