OrangeTV/src/components/SearchSuggestions.tsx

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>
);
}