gongxiangp2/components/home/SearchBar.tsx
2026-07-30 14:39:30 +08:00

347 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 搜索栏组件 - 支持中文输入法 & 历史记录 & 自动补全
*
* 核心设计:
* - 使用本地状态显示输入内容(包括 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<string[]>([])
// 建议状态
const [suggestions, setSuggestions] = useState<Suggestion[]>([])
const [showDropdown, setShowDropdown] = useState(false)
const [selectedIndex, setSelectedIndex] = useState(-1)
const inputRef = useRef<HTMLInputElement>(null)
const containerRef = useRef<HTMLDivElement>(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<HTMLInputElement>) => {
isComposingRef.current = false
const finalValue = e.currentTarget.value
setLocalValue(finalValue)
onChange(finalValue)
}, [onChange])
// 处理输入变化
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value
setLocalValue(newValue)
if (!isComposingRef.current) {
onChange(newValue)
}
// 重置选择索引
setSelectedIndex(-1)
setShowDropdown(true)
}, [onChange])
// 处理按键
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
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 <span className="text-xs bg-blue-100 text-blue-600 px-1.5 py-0.5 rounded">SPU</span>
case 'author': return <span className="text-xs bg-green-100 text-green-600 px-1.5 py-0.5 rounded">作者</span>
case 'scene': return <span className="text-xs bg-orange-100 text-orange-600 px-1.5 py-0.5 rounded">场景</span>
default: return null
}
}
// 决定显示什么内容
const showSuggestionsList = localValue && suggestions.length > 0
const showHistoryList = !localValue && history.length > 0
const shouldShowDropdown = showDropdown && (showSuggestionsList || showHistoryList)
return (
<div ref={containerRef} className="relative mb-6 z-20">
<input
ref={inputRef}
type="text"
value={localValue}
onChange={handleChange}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
onKeyDown={handleKeyDown}
onFocus={() => setShowDropdown(true)}
placeholder={placeholder}
autoComplete="off"
className="w-full pl-10 pr-10 py-3 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:border-blue-500 dark:focus:border-blue-400 focus:ring-2 focus:ring-blue-200 dark:focus:ring-blue-600 outline-none transition-all shadow-sm"
/>
{/* 搜索图标 / 加载动画 */}
{isSearching ? (
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5">
<div className="w-4 h-4 border-2 border-gray-300 border-t-blue-500 rounded-full animate-spin" />
</div>
) : (
<svg
className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
)}
{/* 清除按钮 */}
{localValue && (
<button
onClick={handleClear}
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
aria-label="清除搜索"
type="button"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
)}
{/* 下拉列表 */}
{shouldShowDropdown && (
<div className="absolute top-full left-0 right-0 mt-2 bg-white dark:bg-gray-800 rounded-xl shadow-lg border border-gray-100 dark:border-gray-700 overflow-hidden animate-fade-in z-30">
{/* 建议列表 */}
{showSuggestionsList && (
<ul>
{suggestions.map((item, index) => (
<li key={`${item.type}-${item.value}`}>
<button
onClick={() => handleItemClick(item.value)}
className={`w-full text-left px-4 py-2.5 flex items-center justify-between group transition-colors text-sm text-gray-700 dark:text-gray-200 ${
index === selectedIndex ? 'bg-gray-100 dark:bg-gray-700' : 'hover:bg-gray-50 dark:hover:bg-gray-700'
}`}
>
<div className="flex items-center gap-2 overflow-hidden">
<svg className="w-4 h-4 text-gray-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<span className="truncate flex-1">
{item.value}
</span>
{renderSuggestionType(item.type)}
</div>
</button>
</li>
))}
</ul>
)}
{/* 历史记录列表 */}
{showHistoryList && (
<>
<div className="flex items-center justify-between px-4 py-2 bg-gray-50 dark:bg-gray-700/50 text-xs text-gray-500 dark:text-gray-400">
<span>最近搜索</span>
<button
onClick={clearHistory}
className="hover:text-red-500 transition-colors"
>
清空历史
</button>
</div>
<ul>
{history.map((item, index) => (
<li key={item}>
<button
onClick={() => handleItemClick(item)}
className={`w-full text-left px-4 py-2.5 flex items-center justify-between group transition-colors text-sm text-gray-700 dark:text-gray-200 ${
index === selectedIndex ? 'bg-gray-100 dark:bg-gray-700' : 'hover:bg-gray-50 dark:hover:bg-gray-700'
}`}
>
<div className="flex items-center gap-2 overflow-hidden">
<svg className="w-4 h-4 text-gray-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span className="truncate">{item}</span>
</div>
<div
onClick={(e) => deleteHistory(e, item)}
className="p-1 rounded-full hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-400 opacity-0 group-hover:opacity-100 transition-all"
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
</button>
</li>
))}
</ul>
</>
)}
</div>
)}
</div>
)
}
const SearchBar = memo(SearchBarInner)
export default SearchBar