"use client"; import { ArrowLeft, FileText, Mic, Search, X } from "lucide-react"; import Image from "next/image"; import Link from "next/link"; import { useRouter, useSearchParams } from "next/navigation"; import { type FormEvent, useCallback, useEffect, useRef, useState, } from "react"; import type { SnippetPart } from "@/lib/search"; import { isVideoContentTag, VIDEO_CONTENT_TAGS } from "@/lib/video-tags"; // 匹配搜索结果类型(适配后端API) type SearchResultItem = { id: string; awemeId: string; type: "video" | "image"; score: number; matchedIn: ("desc" | "transcript")[]; descParts: SnippetPart[]; // 完整描述,标出命中位置 snippet: SnippetPart[] | null; // 语音转写的命中片段 contentTags: string[]; 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; }; }; }; const PAGE_SIZE = 30; function renderParts(parts: SnippetPart[]) { let offset = 0; return parts.map((part) => { const key = `${offset}-${part.text}`; offset += part.text.length; return part.hit ? ( {part.text} ) : ( {part.text} ); }); } export default function SearchClient({ initialQuery, }: { initialQuery: string; }) { const searchParams = useSearchParams(); const router = useRouter(); const [query, setQuery] = useState(initialQuery); const [results, setResults] = useState([]); const [total, setTotal] = useState(0); const [page, setPage] = useState(1); const [loading, setLoading] = useState(false); const [loadingMore, setLoadingMore] = useState(false); // 只采用最近一次请求的结果,避免快速切换关键词时旧结果覆盖新结果 const requestIdRef = useRef(0); const [searched, setSearched] = useState(false); const [selectedTag, setSelectedTag] = useState(null); const buildSearchHref = useCallback((q: string, tag: string | null) => { const params = new URLSearchParams(); const trimmed = q.trim(); if (trimmed) params.set("q", trimmed); if (tag) params.set("tag", tag); const queryString = params.toString(); return queryString ? `/search?${queryString}` : "/search"; }, []); const fetchPage = useCallback( async (q: string, tag: string | null, pageNum: number) => { const params = new URLSearchParams({ limit: String(PAGE_SIZE), page: String(pageNum), }); if (q) params.set("q", q); if (tag) params.set("tag", tag); const res = await fetch(`/api/search?${params.toString()}`); if (!res.ok) throw new Error("Search failed"); return (await res.json()) as { results?: SearchResultItem[]; total?: number; }; }, [], ); const performSearch = useCallback( async (q: string, tag: string | null) => { const trimmed = q.trim(); const requestId = ++requestIdRef.current; if (!trimmed && !tag) { setResults([]); setTotal(0); setSearched(false); return; } setLoading(true); setSearched(true); setPage(1); try { const data = await fetchPage(trimmed, tag, 1); if (requestId !== requestIdRef.current) return; setResults(data.results || []); setTotal(data.total ?? 0); } catch (err) { if (requestId !== requestIdRef.current) return; console.error("Search error:", err); setResults([]); setTotal(0); } finally { if (requestId === requestIdRef.current) setLoading(false); } }, [fetchPage], ); const loadMore = async () => { const q = (searchParams.get("q") || "").trim(); const tagParam = searchParams.get("tag") || ""; const tag = tagParam && isVideoContentTag(tagParam) ? tagParam : null; const requestId = requestIdRef.current; const nextPage = page + 1; setLoadingMore(true); try { const data = await fetchPage(q, tag, nextPage); if (requestId !== requestIdRef.current) return; setResults((prev) => { const seen = new Set(prev.map((r) => r.id)); return [ ...prev, ...(data.results || []).filter((r) => !seen.has(r.id)), ]; }); setTotal(data.total ?? 0); setPage(nextPage); } catch (err) { console.error("Search error:", err); } finally { if (requestId === requestIdRef.current) setLoadingMore(false); } }; // 初始 & URL 变化时同步(用 searchParams 订阅 URL 更稳妥) useEffect(() => { const q = searchParams.get("q") || ""; const tagParam = searchParams.get("tag") || ""; const tag = tagParam && isVideoContentTag(tagParam) ? tagParam : null; setQuery(q); setSelectedTag(tag); if (q || tag) { performSearch(q, tag); } else { requestIdRef.current++; setResults([]); setTotal(0); setSearched(false); } }, [searchParams, performSearch]); const handleSearch = (e: FormEvent) => { e.preventDefault(); const trimmed = query.trim(); if (!trimmed && !selectedTag) return; router.push(buildSearchHref(trimmed, selectedTag)); }; const handleTagSelect = (tag: string) => { const nextTag = selectedTag === tag ? null : tag; setSelectedTag(nextTag); router.push(buildSearchHref(query, nextTag)); }; const handleClear = () => { setQuery(""); setSelectedTag(null); setResults([]); setTotal(0); setSearched(false); router.push("/search"); }; return (
{/* 顶部搜索栏 */}
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" /> {(query || selectedTag) && ( )}
{VIDEO_CONTENT_TAGS.map((tag) => ( ))}
{/* 结果区域 */}
{loading && (

正在搜索...

)} {!loading && searched && results.length === 0 && (

未找到相关内容

试试其他关键词

)} {!loading && !searched && !query && !selectedTag && (

输入关键词开始搜索

按原文匹配描述和语音转写,多个关键词用空格分隔,以 - 开头表示排除

)} {!loading && results.length > 0 && (

找到 {total}{" "} 个结果

{/* 单列列表布局 */}
{results.map((item) => { const content = item.type === "video" ? item.video : item.imagePost; if (!content) return null; return (
{/* 主内容区 */}
{/* 左侧:封面预览 */} {content.cover_url ? ( {content.desc} ) : (
)} {/* 右侧:信息区 */}
{/* 类型标签 */}
{item.type === "video" ? "视频" : "图文"} {item.matchedIn.includes("desc") && ( 描述命中 )} {item.matchedIn.includes("transcript") && ( 语音命中 )} {item.contentTags.map((tag) => ( {tag} ))}
{/* 描述 */}

{item.descParts.length > 0 ? renderParts(item.descParts) : "无描述"}

{/* 作者信息 */}
{content.author.avatar_url ? ( {content.author.nickname} ) : (
)} {content.author.nickname}
{/* 语音转写片段:命中时展示命中位置附近的文字 */} {item.snippet && item.snippet.length > 0 && (

{item.matchedIn.includes("transcript") ? "语音内容" : "语音转写"}

{renderParts(item.snippet)}
)}
); })}
{results.length < total && (
)}
)}
); }