"use client" import * as React from "react" import Link from "next/link" import { useRouter } from "next/navigation" import { Search, FileText, BookOpen, FileQuestion, Megaphone, Loader2 } from "lucide-react" import { Input } from "@/shared/components/ui/input" import { useDebounce } from "@/shared/hooks/use-debounce" import { cn } from "@/shared/lib/utils" type ResultType = "question" | "textbook" | "exam" | "announcement" interface SearchResultItem { id: string title: string snippet: string type: ResultType href: string createdAt: string } interface SearchResponse { success: boolean results: SearchResultItem[] total: number query: string } const TYPE_ICON: Record> = { question: FileQuestion, textbook: BookOpen, exam: FileText, announcement: Megaphone, } const TYPE_LABEL: Record = { question: "Question", textbook: "Textbook", exam: "Exam", announcement: "Announcement", } interface GlobalSearchProps { className?: string placeholder?: string } export function GlobalSearch({ className, placeholder = "Search... (Cmd+K)", }: GlobalSearchProps) { const router = useRouter() const [query, setQuery] = React.useState("") const [open, setOpen] = React.useState(false) const [loading, setLoading] = React.useState(false) const [results, setResults] = React.useState([]) const [error, setError] = React.useState(null) const [activeIndex, setActiveIndex] = React.useState(0) const debouncedQuery = useDebounce(query, 300) const containerRef = React.useRef(null) const inputRef = React.useRef(null) // Cmd/Ctrl + K 快捷键聚焦 React.useEffect(() => { const handler = (e: KeyboardEvent) => { if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") { e.preventDefault() inputRef.current?.focus() setOpen(true) } if (e.key === "Escape") { setOpen(false) inputRef.current?.blur() } } window.addEventListener("keydown", handler) return () => window.removeEventListener("keydown", handler) }, []) // 点击外部关闭 React.useEffect(() => { const handler = (e: MouseEvent) => { if (containerRef.current && !containerRef.current.contains(e.target as Node)) { setOpen(false) } } document.addEventListener("mousedown", handler) return () => document.removeEventListener("mousedown", handler) }, []) // 防抖后发起搜索 React.useEffect(() => { const q = debouncedQuery.trim() if (!q) { setResults([]) setError(null) setLoading(false) return } let cancelled = false setLoading(true) setError(null) fetch(`/api/search?q=${encodeURIComponent(q)}&type=all&pageSize=20`) .then((r) => r.json()) .then((data: SearchResponse) => { if (cancelled) return if (!data.success) { setError("Search failed") setResults([]) } else { setResults(data.results) setActiveIndex(0) } }) .catch(() => { if (cancelled) return setError("Network error") setResults([]) }) .finally(() => { if (!cancelled) setLoading(false) }) return () => { cancelled = true } }, [debouncedQuery]) const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === "ArrowDown") { e.preventDefault() setActiveIndex((i) => Math.min(i + 1, results.length - 1)) } else if (e.key === "ArrowUp") { e.preventDefault() setActiveIndex((i) => Math.max(i - 1, 0)) } else if (e.key === "Enter") { e.preventDefault() const item = results[activeIndex] if (item) { setOpen(false) router.push(item.href) } } } const showDropdown = open && query.trim().length > 0 return (
{ setQuery(e.target.value) setOpen(true) }} onFocus={() => setOpen(true)} onKeyDown={handleKeyDown} aria-label="Global search" /> {showDropdown ? (
{loading ? (
Searching...
) : error ? (
{error}
) : results.length === 0 ? (
No results found for “{query}”
) : (
    {results.map((item, idx) => { const Icon = TYPE_ICON[item.type] return (
  • { setOpen(false) setQuery("") }} className={cn( "flex items-start gap-3 px-3 py-2 text-sm transition-colors hover:bg-accent", idx === activeIndex && "bg-accent" )} onMouseEnter={() => setActiveIndex(idx)} >

    {item.title}

    {TYPE_LABEL[item.type]}
    {item.snippet ? (

    {item.snippet}

    ) : null}
  • ) })}
)}
) : null}
) }