/** * 搜索栏组件 - 支持中文输入法 & 历史记录 & 自动补全 * * 核心设计: * - 使用本地状态显示输入内容(包括 IME 组合中的拼音) * - 只在 IME 组合完成后或非 IME 输入时触发搜索 * - 支持搜索历史记录(localStorage 存储,Enter 键保存) * - 支持搜索建议(自动补全) */ 'use client' import { useRef, useState, useCallback, useEffect, memo } from 'react' interface SearchBarProps { value: string onChange: (value: string) => void placeholder?: string isSearching?: boolean } interface Suggestion { type: 'spu' | 'author' | 'scene' value: string } function SearchBarInner({ value: externalValue, onChange, placeholder = '搜索素材 ID、SPU、任务号、店铺、场景或作者', isSearching = false, }: SearchBarProps) { // 本地状态用于显示(包括 IME 组合中的拼音) const [localValue, setLocalValue] = useState(externalValue) // IME 组合状态 const isComposingRef = useRef(false) // 历史记录状态 const [history, setHistory] = useState([]) // 建议状态 const [suggestions, setSuggestions] = useState([]) const [showDropdown, setShowDropdown] = useState(false) const [selectedIndex, setSelectedIndex] = useState(-1) const inputRef = useRef(null) const containerRef = useRef(null) // 加载历史记录 useEffect(() => { try { const saved = localStorage.getItem('searchHistory') if (saved) { setHistory(JSON.parse(saved)) } } catch (e) { console.error('Failed to load search history', e) } }, []) // 获取建议 useEffect(() => { const fetchSuggestions = async () => { if (!localValue || localValue.trim().length < 1 || isComposingRef.current) { setSuggestions([]) return } try { const res = await fetch(`/api/search/suggestions?q=${encodeURIComponent(localValue.trim())}`) if (res.ok) { const data = await res.json() setSuggestions(data.suggestions || []) } } catch (e) { console.error('Failed to fetch suggestions', e) } } const timer = setTimeout(fetchSuggestions, 300) return () => clearTimeout(timer) }, [localValue]) // 保存历史记录 const saveHistory = useCallback((value: string) => { const trimmed = value.trim() if (!trimmed) return setHistory(prev => { const newHistory = [trimmed, ...prev.filter(h => h !== trimmed)].slice(0, 10) localStorage.setItem('searchHistory', JSON.stringify(newHistory)) return newHistory }) }, []) // 删除单条历史 const deleteHistory = useCallback((e: React.MouseEvent, item: string) => { e.stopPropagation() setHistory(prev => { const newHistory = prev.filter(h => h !== item) localStorage.setItem('searchHistory', JSON.stringify(newHistory)) return newHistory }) }, []) // 清空历史 const clearHistory = useCallback(() => { setHistory([]) localStorage.removeItem('searchHistory') }, []) // 同步外部值到本地值 useEffect(() => { if (!isComposingRef.current) { setLocalValue(externalValue) } }, [externalValue]) // IME 组合开始 const handleCompositionStart = useCallback(() => { isComposingRef.current = true }, []) // IME 组合结束 const handleCompositionEnd = useCallback((e: React.CompositionEvent) => { isComposingRef.current = false const finalValue = e.currentTarget.value setLocalValue(finalValue) onChange(finalValue) }, [onChange]) // 处理输入变化 const handleChange = useCallback((e: React.ChangeEvent) => { const newValue = e.target.value setLocalValue(newValue) if (!isComposingRef.current) { onChange(newValue) } // 重置选择索引 setSelectedIndex(-1) setShowDropdown(true) }, [onChange]) // 处理按键 const handleKeyDown = useCallback((e: React.KeyboardEvent) => { if (e.key === 'Enter') { if (selectedIndex >= 0 && suggestions.length > 0) { // 选择建议 const item = suggestions[selectedIndex] setLocalValue(item.value) onChange(item.value) saveHistory(item.value) } else { // 普通搜索 saveHistory(localValue) } setShowDropdown(false) inputRef.current?.blur() } else if (e.key === 'ArrowDown') { e.preventDefault() const maxIndex = (localValue ? suggestions.length : history.length) - 1 setSelectedIndex(prev => (prev < maxIndex ? prev + 1 : 0)) } else if (e.key === 'ArrowUp') { e.preventDefault() const maxIndex = (localValue ? suggestions.length : history.length) - 1 setSelectedIndex(prev => (prev > 0 ? prev - 1 : maxIndex)) } else if (e.key === 'Escape') { setShowDropdown(false) } }, [localValue, saveHistory, selectedIndex, suggestions, history.length, onChange]) // 清空搜索 const handleClear = useCallback(() => { setLocalValue('') onChange('') setSuggestions([]) }, [onChange]) // 点击建议或历史记录 const handleItemClick = useCallback((value: string) => { setLocalValue(value) onChange(value) saveHistory(value) setShowDropdown(false) }, [onChange, saveHistory]) // 处理点击外部关闭 useEffect(() => { const handleClickOutside = (event: MouseEvent) => { if (containerRef.current && !containerRef.current.contains(event.target as Node)) { setShowDropdown(false) } } document.addEventListener('mousedown', handleClickOutside) return () => document.removeEventListener('mousedown', handleClickOutside) }, []) // 渲染建议类型标签 const renderSuggestionType = (type: string) => { switch (type) { case 'spu': return SPU case 'author': return 作者 case 'scene': return 场景 default: return null } } // 决定显示什么内容 const showSuggestionsList = localValue && suggestions.length > 0 const showHistoryList = !localValue && history.length > 0 const shouldShowDropdown = showDropdown && (showSuggestionsList || showHistoryList) return (
setShowDropdown(true)} placeholder={placeholder} autoComplete="off" className="h-12 w-full rounded-[8px] border border-[#e7e8ea] bg-white py-3 pl-11 pr-10 font-display text-[15px] tracking-[-0.02em] text-[#1a1c1d] placeholder:text-[#86898e] outline-none transition-colors focus:border-[#1a1c1d] focus-visible:ring-2 focus-visible:ring-[#1a1c1d]/70 focus-visible:ring-offset-2 focus-visible:ring-offset-[#fafafa]" /> {/* 搜索图标 / 加载动画 */} {isSearching ? (
) : ( )} {/* 清除按钮 */} {localValue && ( )} {/* 下拉列表 */} {shouldShowDropdown && (
{/* 建议列表 */} {showSuggestionsList && (
    {suggestions.map((item, index) => (
  • ))}
)} {/* 历史记录列表 */} {showHistoryList && ( <>
最近搜索
    {history.map((item, index) => (
  • ))}
)}
)}
) } const SearchBar = memo(SearchBarInner) export default SearchBar