mirror of https://github.com/djteang/OrangeTV.git
174 lines
4.6 KiB
TypeScript
174 lines
4.6 KiB
TypeScript
'use client';
|
|
|
|
import { Card, Label, ListBox, ScrollShadow } from '@heroui/react';
|
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
|
|
interface SearchSuggestionsProps {
|
|
query: string;
|
|
isVisible: boolean;
|
|
onSelect: (suggestion: string) => void;
|
|
onClose: () => void;
|
|
onEnterKey: () => void; // 新增:处理回车键的回调
|
|
}
|
|
|
|
interface SuggestionItem {
|
|
text: string;
|
|
type: 'related';
|
|
icon?: React.ReactNode;
|
|
}
|
|
|
|
export default function SearchSuggestions({
|
|
query,
|
|
isVisible,
|
|
onSelect,
|
|
onClose,
|
|
onEnterKey,
|
|
}: SearchSuggestionsProps) {
|
|
const [suggestions, setSuggestions] = useState<SuggestionItem[]>([]);
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
|
|
// 防抖定时器
|
|
const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
|
|
// 用于中止旧请求
|
|
const abortControllerRef = useRef<AbortController | null>(null);
|
|
|
|
const fetchSuggestionsFromAPI = useCallback(async (searchQuery: string) => {
|
|
// 每次请求前取消上一次的请求
|
|
if (abortControllerRef.current) {
|
|
abortControllerRef.current.abort();
|
|
}
|
|
const controller = new AbortController();
|
|
abortControllerRef.current = controller;
|
|
|
|
try {
|
|
const response = await fetch(
|
|
`/api/search/suggestions?q=${encodeURIComponent(searchQuery)}`,
|
|
{
|
|
signal: controller.signal,
|
|
}
|
|
);
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
const apiSuggestions = data.suggestions.map(
|
|
(item: { text: string }) => ({
|
|
text: item.text,
|
|
type: 'related' as const,
|
|
})
|
|
);
|
|
setSuggestions(apiSuggestions);
|
|
}
|
|
} catch (err: unknown) {
|
|
// 类型保护判断 err 是否是 Error 类型
|
|
if (err instanceof Error) {
|
|
if (err.name !== 'AbortError') {
|
|
// 不是取消请求导致的错误才清空
|
|
setSuggestions([]);
|
|
}
|
|
} else {
|
|
// 如果 err 不是 Error 类型,也清空提示
|
|
setSuggestions([]);
|
|
}
|
|
}
|
|
}, []);
|
|
|
|
// 防抖触发
|
|
const debouncedFetchSuggestions = useCallback(
|
|
(searchQuery: string) => {
|
|
if (debounceTimer.current) {
|
|
clearTimeout(debounceTimer.current);
|
|
}
|
|
debounceTimer.current = setTimeout(() => {
|
|
if (searchQuery.trim() && isVisible) {
|
|
fetchSuggestionsFromAPI(searchQuery);
|
|
} else {
|
|
setSuggestions([]);
|
|
}
|
|
}, 300); //300ms
|
|
},
|
|
[isVisible, fetchSuggestionsFromAPI]
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (!query.trim() || !isVisible) {
|
|
setSuggestions([]);
|
|
return;
|
|
}
|
|
debouncedFetchSuggestions(query);
|
|
|
|
// 清理定时器
|
|
return () => {
|
|
if (debounceTimer.current) {
|
|
clearTimeout(debounceTimer.current);
|
|
}
|
|
};
|
|
}, [query, isVisible, debouncedFetchSuggestions]);
|
|
|
|
// 点击外部关闭
|
|
useEffect(() => {
|
|
const handleClickOutside = (e: MouseEvent) => {
|
|
if (
|
|
containerRef.current &&
|
|
!containerRef.current.contains(e.target as Node)
|
|
) {
|
|
onClose();
|
|
}
|
|
};
|
|
|
|
if (isVisible) {
|
|
document.addEventListener('mousedown', handleClickOutside);
|
|
}
|
|
|
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
}, [isVisible, onClose]);
|
|
|
|
// 处理键盘事件,特别是回车键
|
|
useEffect(() => {
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if (e.key === 'Enter' && isVisible) {
|
|
// 阻止默认行为,避免浏览器自动选择建议
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
// 关闭搜索建议并触发搜索
|
|
onClose();
|
|
onEnterKey();
|
|
}
|
|
};
|
|
|
|
if (isVisible) {
|
|
document.addEventListener('keydown', handleKeyDown, true);
|
|
}
|
|
|
|
return () => document.removeEventListener('keydown', handleKeyDown, true);
|
|
}, [isVisible, onClose, onEnterKey]);
|
|
|
|
if (!isVisible || suggestions.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<Card
|
|
ref={containerRef}
|
|
className='absolute left-0 right-0 top-full z-[600] mt-1 p-0'
|
|
>
|
|
<ScrollShadow className='max-h-80' hideScrollBar>
|
|
<ListBox
|
|
aria-label='搜索建议'
|
|
selectionMode='none'
|
|
onAction={(key) => onSelect(String(key))}
|
|
>
|
|
{suggestions.map((suggestion) => (
|
|
<ListBox.Item
|
|
key={`related-${suggestion.text}`}
|
|
id={suggestion.text}
|
|
textValue={suggestion.text}
|
|
>
|
|
<Label className='truncate'>{suggestion.text}</Label>
|
|
</ListBox.Item>
|
|
))}
|
|
</ListBox>
|
|
</ScrollShadow>
|
|
</Card>
|
|
);
|
|
}
|