gongxiangp2/components/home/SearchBar.tsx

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 border border-[#e7e8ea] bg-white text-[#6b7075] px-1.5 py-0.5 rounded-[8px]">SPU</span>
case 'author': return <span className="text-xs border border-[#e7e8ea] bg-white text-[#6b7075] px-1.5 py-0.5 rounded-[8px]">作者</span>
case 'scene': return <span className="text-xs border border-[#e7e8ea] bg-white text-[#6b7075] px-1.5 py-0.5 rounded-[8px]">场景</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 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="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 ? (
<div className="absolute left-4 top-1/2 transform -translate-y-1/2 w-5 h-5">
<div className="w-4 h-4 border-2 border-[#e7e8ea] border-t-[#1a1c1d] rounded-full animate-spin" />
</div>
) : (
<svg
className="absolute left-4 top-1/2 transform -translate-y-1/2 w-5 h-5 text-[#6b7075]"
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-[#86898e] hover:text-[#1a1c1d] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1a1c1d]/70 focus-visible:ring-offset-2 rounded-[8px]"
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 z-30 mt-2 overflow-hidden rounded-[8px] border border-[#e7e8ea] bg-white">
{/* 建议列表 */}
{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-[#1a1c1d] ${
index === selectedIndex ? 'bg-[#fafafa]' : 'hover:bg-[#fafafa]'
}`}
>
<div className="flex items-center gap-2 overflow-hidden">
<svg className="w-4 h-4 text-[#86898e] 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-[#fafafa] text-xs text-[#86898e]">
<span>最近搜索</span>
<button
onClick={clearHistory}
className="hover:text-[#1a1c1d] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1a1c1d]/70 focus-visible:ring-offset-2 rounded-[8px]"
>
清空历史
</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-[#1a1c1d] ${
index === selectedIndex ? 'bg-[#fafafa]' : 'hover:bg-[#fafafa]'
}`}
>
<div className="flex items-center gap-2 overflow-hidden">
<svg className="w-4 h-4 text-[#86898e] 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-[8px] hover:bg-[#e7e8ea] text-[#86898e] 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