"use client"; import { Check, ThumbsUp, X } from "lucide-react"; import Image from "next/image"; import Link from "next/link"; import { useCallback, useEffect, useRef, useState } from "react"; import type { FeedItem, FeedResponse } from "@/app/types/feed"; import { VIDEO_CONTENT_TAGS } from "@/lib/video-tags"; import HoverVideo from "./HoverVideo"; const COLUMN_KEYS = ["feed-col-0", "feed-col-1", "feed-col-2", "feed-col-3"]; function getAspectRatio(item: FeedItem) { return item.width && item.height ? `${item.width}/${item.height}` : undefined; } type Props = { initialItems: FeedItem[]; initialCursor: string | null; fetchUrl?: string; /** 附加在作品链接上的播放上下文查询串(含 ?),决定播放页上下切换的列表 */ linkQuery?: string; }; type TagUpdateResponse = { updated?: { aweme_id: string; content_tags: string[]; }[]; }; export default function FeedMasonry({ initialItems, initialCursor, fetchUrl = "/api/feed", linkQuery = "", }: Props) { // 哨兵与容器 const [cursor, setCursor] = useState(initialCursor); const [loading, setLoading] = useState(false); const [ended, setEnded] = useState(!initialCursor); const [selectedIds, setSelectedIds] = useState>(new Set()); const [batchTag, setBatchTag] = useState(VIDEO_CONTENT_TAGS[0]); const [batchSaving, setBatchSaving] = useState(false); const [batchError, setBatchError] = useState(null); const sentinelRef = useRef(null); const containerRef = useRef(null); // 响应式列数:<640:1, >=640:2, >=1024:3, >=1280:4 const getColumnCount = useCallback(() => { if (typeof window === "undefined") return 1; const w = window.innerWidth; if (w >= 1280) return 4; // xl if (w >= 1024) return 3; // lg if (w >= 640) return 2; // sm return 1; }, []); // 为避免 SSR 与客户端初次渲染不一致(window 未定义导致服务端为 1 列,客户端首次渲染为多列), // 这里将初始列数固定为 1,待挂载后再根据窗口宽度更新。 const [columnCount, setColumnCount] = useState(1); useEffect(() => { // 挂载后立即根据当前窗口宽度更新一次列数 setColumnCount(getColumnCount()); const onResize = () => setColumnCount(getColumnCount()); window.addEventListener("resize", onResize); return () => window.removeEventListener("resize", onResize); }, [getColumnCount]); // 估算卡片高度(用于分配到“最短列”) const estimateItemHeight = useCallback((item: FeedItem, colWidth: number) => { // 媒体区域高度 let mediaH = 200; // fallback if (item.width && item.height) { mediaH = Math.max( 80, (Number(item.height) / Number(item.width)) * colWidth, ); } else if (item.type === "video") { mediaH = (9 / 16) * colWidth; // 常见视频比例 } // 文本 + 作者栏的高度粗估 const textH = item.content_tags?.length ? 72 : 48; // 标题+标签区域 const authorH = 48; // 作者行 const gap = 16; // 卡片内边距/间隙 return mediaH + textH + authorH + gap; }, []); // 维护列数据与列高 const [columns, setColumns] = useState(() => { const cols: FeedItem[][] = Array.from({ length: columnCount }, () => []); return cols; }); const [colHeights, setColHeights] = useState(() => Array.from({ length: columnCount }, () => 0), ); // 初始化与当列数变化时重排 useEffect(() => { const containerWidth = containerRef.current?.clientWidth ?? 0; const colWidth = columnCount > 0 ? containerWidth / columnCount : containerWidth; // 用 initialItems 重排 const newCols: FeedItem[][] = Array.from({ length: columnCount }, () => []); const newHeights: number[] = Array.from({ length: columnCount }, () => 0); for (const item of initialItems) { // 找最短列 let minIdx = 0; for (let i = 1; i < columnCount; i++) { if (newHeights[i] < newHeights[minIdx]) minIdx = i; } newCols[minIdx].push(item); newHeights[minIdx] += estimateItemHeight(item, colWidth || 300); } setColumns(newCols); setColHeights(newHeights); }, [columnCount, estimateItemHeight, initialItems]); const fetchMore = useCallback(async () => { if (loading || ended) return; setLoading(true); try { const params = new URLSearchParams(); if (cursor) params.set("before", cursor); params.set("limit", "24"); const url = fetchUrl.includes("?") ? `${fetchUrl}&${params.toString()}` : `${fetchUrl}?${params.toString()}`; const res = await fetch(url, { cache: "no-store" }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const data: FeedResponse = await res.json(); // 将新数据按最短列分配 setColumns((prevCols) => { const containerWidth = containerRef.current?.clientWidth ?? 0; const colWidth = columnCount > 0 ? containerWidth / columnCount : containerWidth; const cols = prevCols.map((c) => [...c]); const heights = [...colHeights]; for (const item of data.items) { // 找当前最短列 let minIdx = 0; for (let i = 1; i < columnCount; i++) { if (heights[i] < heights[minIdx]) minIdx = i; } cols[minIdx].push(item); heights[minIdx] += estimateItemHeight(item, colWidth || 300); } setColHeights(heights); return cols; }); setCursor(data.nextCursor); if (!data.nextCursor || data.items.length === 0) setEnded(true); } catch (e) { console.error("fetch more feed failed", e); // 失败也不要死循环 setEnded(true); } finally { setLoading(false); } }, [ cursor, ended, loading, columnCount, colHeights, estimateItemHeight, fetchUrl, ]); useEffect(() => { const el = sentinelRef.current; if (!el) return; const io = new IntersectionObserver( (entries) => { const entry = entries[0]; if (entry.isIntersecting) { fetchMore(); } }, { rootMargin: "800px 0px 800px 0px" }, ); io.observe(el); return () => io.disconnect(); }, [fetchMore]); const toggleSelection = useCallback((awemeId: string) => { setSelectedIds((current) => { const next = new Set(current); if (next.has(awemeId)) next.delete(awemeId); else next.add(awemeId); return next; }); setBatchError(null); }, []); const clearSelection = useCallback(() => { setSelectedIds(new Set()); setBatchError(null); }, []); const applyUpdatedTags = useCallback( (updates: TagUpdateResponse["updated"]) => { if (!updates?.length) return; const tagMap = new Map( updates.map((item) => [item.aweme_id, item.content_tags] as const), ); setColumns((currentColumns) => currentColumns.map((column) => column.map((item) => tagMap.has(item.aweme_id) ? { ...item, content_tags: tagMap.get(item.aweme_id) ?? [] } : item, ), ), ); }, [], ); const handleBatchAddTag = useCallback(async () => { const awemeIds = [...selectedIds]; if (awemeIds.length === 0 || batchSaving) return; setBatchSaving(true); setBatchError(null); try { const response = await fetch("/api/asr/tags", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ awemeIds, tag: batchTag, mode: "add" }), }); if (!response.ok) throw new Error("批量添加标签失败"); const data: TagUpdateResponse = await response.json(); applyUpdatedTags(data.updated); clearSelection(); } catch (error) { setBatchError( error instanceof Error ? error.message : "批量添加标签失败", ); } finally { setBatchSaving(false); } }, [applyUpdatedTags, batchSaving, batchTag, clearSelection, selectedIds]); const renderCard = useCallback( (item: FeedItem) => { const selected = selectedIds.has(item.aweme_id); return (
{item.type === "video" && ( )}
{item.type === "video" ? ( ) : ( {item.desc?.slice(0, )}

{item.desc}

{!!item.content_tags?.length && (
{item.content_tags.map((tag) => ( {tag} ))}
)}
{item.type === "video" ? "视频" : "图文"}
{item.author.sec_uid ? (
{item.author.avatar_url ? ( avatar ) : null}
{item.author.nickname} ) : (
{item.author.avatar_url ? ( avatar ) : null}
{item.author.nickname}
)} {item.likes}{" "}
); }, [selectedIds, toggleSelection], ); return ( <> {/* Masonry(按列渲染,动态分配到最短列) */}
{columns.map((col, idx) => (
{col.map((item) => renderCard(item))}
))}
{ended ? "没有更多了" : loading ? "加载中…" : "下拉加载更多"}
{selectedIds.size > 0 && (
已选择 {selectedIds.size} 个视频 {batchError && ( {batchError} )}
)} ); }