130 lines
4.5 KiB
TypeScript
130 lines
4.5 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useState, useCallback } from "react";
|
||
import { useRouter } from "next/navigation";
|
||
import { api } from "@/lib/api";
|
||
import type { SearchResult } from "@/lib/types";
|
||
|
||
const TYPE_LABELS: Record<string, string> = {
|
||
destination: "目的地",
|
||
blog: "博客",
|
||
visa: "签证",
|
||
faq: "FAQ",
|
||
};
|
||
|
||
export default function GlobalSearch() {
|
||
const [open, setOpen] = useState(false);
|
||
const [query, setQuery] = useState("");
|
||
const [results, setResults] = useState<SearchResult[]>([]);
|
||
const [loading, setLoading] = useState(false);
|
||
const [activeIdx, setActiveIdx] = useState(0);
|
||
const router = useRouter();
|
||
|
||
useEffect(() => {
|
||
const onKey = (e: KeyboardEvent) => {
|
||
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
|
||
e.preventDefault();
|
||
setOpen((o) => !o);
|
||
}
|
||
if (e.key === "Escape") setOpen(false);
|
||
};
|
||
window.addEventListener("keydown", onKey);
|
||
return () => window.removeEventListener("keydown", onKey);
|
||
}, []);
|
||
|
||
const doSearch = useCallback(async (q: string) => {
|
||
if (!q.trim()) { setResults([]); return; }
|
||
setLoading(true);
|
||
try {
|
||
const data = await api.search(q);
|
||
setResults(data);
|
||
setActiveIdx(0);
|
||
} catch {
|
||
setResults([]);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
const timer = setTimeout(() => doSearch(query), 250);
|
||
return () => clearTimeout(timer);
|
||
}, [query, doSearch]);
|
||
|
||
const navigate = (url: string) => {
|
||
setOpen(false);
|
||
setQuery("");
|
||
if (url.startsWith("/#")) {
|
||
router.push("/");
|
||
setTimeout(() => {
|
||
document.querySelector(url.replace("/", ""))?.scrollIntoView({ behavior: "smooth" });
|
||
}, 300);
|
||
} else {
|
||
router.push(url);
|
||
}
|
||
};
|
||
|
||
const onKeyDown = (e: React.KeyboardEvent) => {
|
||
if (e.key === "ArrowDown") { e.preventDefault(); setActiveIdx((i) => Math.min(i + 1, results.length - 1)); }
|
||
if (e.key === "ArrowUp") { e.preventDefault(); setActiveIdx((i) => Math.max(i - 1, 0)); }
|
||
if (e.key === "Enter" && results[activeIdx]) navigate(results[activeIdx].url);
|
||
};
|
||
|
||
return (
|
||
<>
|
||
<button className="search-trigger" onClick={() => setOpen(true)} aria-label="搜索">
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||
<circle cx="11" cy="11" r="8" /><path d="M21 21l-4.35-4.35" />
|
||
</svg>
|
||
<span>搜索...</span>
|
||
<kbd>⌘K</kbd>
|
||
</button>
|
||
|
||
{open && (
|
||
<div className="search-overlay" onClick={() => setOpen(false)}>
|
||
<div className="search-modal" onClick={(e) => e.stopPropagation()}>
|
||
<div className="search-input-wrap">
|
||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||
<circle cx="11" cy="11" r="8" /><path d="M21 21l-4.35-4.35" />
|
||
</svg>
|
||
<input
|
||
autoFocus
|
||
placeholder="搜索目的地、博客、签证..."
|
||
value={query}
|
||
onChange={(e) => setQuery(e.target.value)}
|
||
onKeyDown={onKeyDown}
|
||
/>
|
||
<button onClick={() => setOpen(false)}>ESC</button>
|
||
</div>
|
||
<div className="search-results">
|
||
{loading && <p className="search-empty">搜索中...</p>}
|
||
{!loading && query && results.length === 0 && (
|
||
<p className="search-empty">😢 没有找到「{query}」相关结果</p>
|
||
)}
|
||
{!loading && results.map((r, i) => (
|
||
<button key={`${r.type}-${r.url}`}
|
||
className={`search-result${i === activeIdx ? " active" : ""}`}
|
||
onClick={() => navigate(r.url)}
|
||
onMouseEnter={() => setActiveIdx(i)}>
|
||
<span className="search-result-emoji">{r.emoji}</span>
|
||
<div className="search-result-text">
|
||
<strong>{r.title}</strong>
|
||
<span>{r.subtitle}</span>
|
||
</div>
|
||
<span className="search-result-type">{TYPE_LABELS[r.type] || r.type}</span>
|
||
</button>
|
||
))}
|
||
{!query && (
|
||
<div className="search-hints">
|
||
<p>💡 试试搜索:清迈、签证、税务</p>
|
||
<p>⌨️ ↑↓ 选择 · Enter 跳转 · Esc 关闭</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</>
|
||
);
|
||
}
|