347 lines
12 KiB
TypeScript
347 lines
12 KiB
TypeScript
/**
|
||
* 搜索栏组件 - 支持中文输入法 & 历史记录 & 自动补全
|
||
*
|
||
* 核心设计:
|
||
* - 使用本地状态显示输入内容(包括 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
|