douyin-archive/app/search/SearchClient.tsx

265 lines
10 KiB
TypeScript
Raw Permalink 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.

"use client";
import { FormEvent, useCallback, useEffect, useState } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import Link from "next/link";
import { Search, ArrowLeft, X, MessageSquare } from "lucide-react";
// 匹配搜索结果类型适配后端API
type SearchResultItem = {
id: string;
awemeId: string;
type: 'video' | 'image';
rank: number;
snippet: string; // 后端返回的高亮片段,已包含<mark>标签
video?: {
aweme_id: string;
desc: string;
cover_url: string | null;
video_url: string | null;
duration_ms: number;
author: {
sec_uid: string;
nickname: string;
avatar_url: string | null;
};
};
imagePost?: {
aweme_id: string;
desc: string;
cover_url: string | null;
author: {
sec_uid: string;
nickname: string;
avatar_url: string | null;
};
};
};
export default function SearchClient({ initialQuery }: { initialQuery: string }) {
const searchParams = useSearchParams();
const router = useRouter();
const [query, setQuery] = useState(initialQuery);
const [results, setResults] = useState<SearchResultItem[]>([]);
const [loading, setLoading] = useState(false);
const [searched, setSearched] = useState(false);
const performSearch = useCallback(async (q: string) => {
if (!q.trim()) {
setResults([]);
setSearched(false);
return;
}
setLoading(true);
setSearched(true);
try {
const res = await fetch(`/api/search?q=${encodeURIComponent(q.trim())}&limit=60`);
if (!res.ok) throw new Error("Search failed");
const data = await res.json();
setResults(data.results || []);
} catch (err) {
console.error("Search error:", err);
setResults([]);
} finally {
setLoading(false);
}
}, []);
// 初始 & URL 变化时同步(用 searchParams 订阅 URL 更稳妥)
useEffect(() => {
const q = searchParams.get("q") || "";
setQuery(q);
if (q) performSearch(q);
}, [searchParams, performSearch]);
const handleSearch = (e: FormEvent) => {
e.preventDefault();
const trimmed = query.trim();
if (!trimmed) return;
router.push(`/search?q=${encodeURIComponent(trimmed)}`);
// 可省略:上面的 push 会触发上面的 effect 从 URL 拉取并搜索
// performSearch(trimmed);
};
const handleClear = () => {
setQuery("");
setResults([]);
setSearched(false);
router.push("/search");
};
return (
<div className="min-h-screen bg-zinc-950 text-white">
{/* 顶部搜索栏 */}
<header className="sticky top-0 z-50 bg-zinc-900/80 backdrop-blur-xl border-b border-white/10">
<div className="max-w-7xl mx-auto px-4 py-4 flex items-center gap-4">
<button
onClick={() => router.push("/")}
className="p-2 rounded-full hover:bg-white/10 transition-colors"
aria-label="返回首页"
>
<ArrowLeft size={24} />
</button>
<form onSubmit={handleSearch} className="flex-1 flex items-center gap-3">
<div className="relative flex-1">
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="搜索视频、图文描述或语音内容..."
className="w-full bg-white/5 border border-white/10 rounded-full px-5 py-3 pl-12 pr-12 text-white placeholder-white/40 focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500/50 transition-all"
autoFocus
/>
<Search
size={20}
className="absolute left-4 top-1/2 -translate-y-1/2 text-white/40"
/>
{query && (
<button
type="button"
onClick={handleClear}
className="absolute right-4 top-1/2 -translate-y-1/2 p-1 rounded-full hover:bg-white/10 transition-colors"
aria-label="清空"
>
<X size={18} className="text-white/60" />
</button>
)}
</div>
<button
type="submit"
className="px-6 py-3 bg-blue-600 hover:bg-blue-700 rounded-full font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
disabled={loading || !query.trim()}
>
{loading ? "搜索中..." : "搜索"}
</button>
</form>
</div>
</header>
{/* 结果区域 */}
<main className="max-w-7xl mx-auto px-4 py-8">
{loading && (
<div className="flex items-center justify-center py-20">
<div className="flex flex-col items-center gap-4">
<div className="w-12 h-12 border-4 border-blue-500/30 border-t-blue-500 rounded-full animate-spin" />
<p className="text-white/60">...</p>
</div>
</div>
)}
{!loading && searched && results.length === 0 && (
<div className="flex flex-col items-center justify-center py-20 text-white/60">
<Search size={64} className="mb-4 opacity-20" />
<p className="text-xl"></p>
<p className="text-sm mt-2"></p>
</div>
)}
{!loading && !searched && !query && (
<div className="flex flex-col items-center justify-center py-20 text-white/40">
<Search size={64} className="mb-4 opacity-20" />
<p className="text-xl"></p>
<p className="text-sm mt-2"></p>
</div>
)}
{!loading && results.length > 0 && (
<div>
<div className="mb-6 flex items-center justify-between">
<h2 className="text-lg text-white/80">
<span className="text-white font-semibold">{results.length}</span>
</h2>
</div>
{/* 单列列表布局 */}
<div className="space-y-4">
{results.map((item) => {
const content = item.type === 'video' ? item.video : item.imagePost;
if (!content) return null;
return (
<div
key={item.awemeId}
className="bg-white/5 backdrop-blur-sm border border-white/10 rounded-2xl overflow-hidden hover:border-white/20 transition-all hover:-translate-y-1"
>
{/* 主内容区 */}
<div className="flex gap-4 p-4">
{/* 左侧:封面预览 */}
<Link
href={`/aweme/${content.aweme_id}`}
target="_blank"
className="flex-shrink-0 w-64 h-80 sm:w-70 sm:h-96 rounded-xl overflow-hidden bg-zinc-900 group"
>
<img
src={content.cover_url || ""}
alt={content.desc}
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
/>
</Link>
{/* 右侧:信息区 */}
<div className="flex-1 min-w-0 flex flex-col">
{/* 类型标签 */}
<div className="flex items-center gap-2 mb-2">
<span className={`px-2 py-0.5 text-xs rounded ${
item.type === 'video'
? 'bg-blue-600/20 text-blue-300'
: 'bg-purple-600/20 text-purple-300'
}`}>
{item.type === 'video' ? '视频' : '图文'}
</span>
<span className="flex items-center gap-1 px-2 py-0.5 bg-green-600/20 text-green-300 text-xs rounded">
<MessageSquare size={12} />
: {(item.rank * 100).toFixed(1)}%
</span>
</div>
{/* 描述 */}
<Link href={`/aweme/${content.aweme_id}`} target="_blank">
<p className="text-white/90 text-sm sm:text-base line-clamp-2 mb-3 hover:text-white transition-colors">
{content.desc || "无描述"}
</p>
</Link>
{/* 作者信息 */}
<div className="flex items-center gap-2 mb-4">
<img
src={content.author.avatar_url || ""}
alt={content.author.nickname}
className="w-6 h-6 rounded-full ring-1 ring-white/20"
/>
<span className="text-xs text-white/60">
{content.author.nickname}
</span>
</div>
{/* 匹配详情展示后端返回的snippet已包含<mark>标签) */}
<div className="space-y-3">
<div>
<h4 className="flex items-center gap-2 text-xs font-medium text-green-300 mb-1.5">
<MessageSquare size={14} />
</h4>
<div
className="text-xs text-white/70 bg-white/5 rounded-lg p-2 leading-relaxed"
dangerouslySetInnerHTML={{ __html: item.snippet }}
style={{
wordBreak: 'break-word',
}}
/>
</div>
</div>
</div>
</div>
</div>
);
})}
</div>
</div>
)}
</main>
</div>
);
}