import { memo, useCallback, useEffect, useRef, useState } from "react"; import type { Comment, User } from "../types.ts"; import { CommentList } from "./CommentList"; interface CommentPanelProps { /** 侧栏打开且停在「评论」标签页 */ open: boolean; author: User; createdAt: string | Date; awemeId: string; } interface CommentsApiResponse { comments?: Comment[]; total?: number; hasMore?: boolean; mode?: string; seed?: string | number; snapshot?: string | number; } // 侧栏「评论」标签页的内容(外壳与标签切换在 SidePanel 里) export const CommentPanel = memo(function CommentPanel({ open, author, createdAt, awemeId, }: CommentPanelProps) { const [comments, setComments] = useState([]); const [loading, setLoading] = useState(false); const [hasMore, setHasMore] = useState(true); const [hasOpened, setHasOpened] = useState(open); const scrollRef = useRef(null); const sentinelRef = useRef(null); const loadingRef = useRef(false); const abortControllerRef = useRef(null); const commentsCountRef = useRef(0); const hasMoreRef = useRef(true); const rankParamsRef = useRef<{ seed: string; snapshot: string } | null>(null); useEffect(() => { if (open) setHasOpened(true); }, [open]); const loadComments = useCallback( async (reset = false): Promise => { if (loadingRef.current && !reset) return; if (!reset && !hasMoreRef.current) return; abortControllerRef.current?.abort(); const controller = new AbortController(); abortControllerRef.current = controller; loadingRef.current = true; setLoading(true); try { const skip = reset ? 0 : commentsCountRef.current; const query = new URLSearchParams({ skip: String(skip), take: String(20), mode: "ranked", }); const currentRank = reset ? null : rankParamsRef.current; if (currentRank) { query.set("seed", currentRank.seed); query.set("snapshot", currentRank.snapshot); } const response = await fetch( `/api/comments/${awemeId}?${query.toString()}`, { signal: controller.signal }, ); const data = (await response.json()) as CommentsApiResponse; if (controller.signal.aborted) return; const incoming = Array.isArray(data.comments) ? data.comments : []; setComments((prev) => { const source = reset ? incoming : [...prev, ...incoming]; const seen = new Set(); const deduped = source.filter((c) => { if (seen.has(c.cid)) return false; seen.add(c.cid); return true; }); commentsCountRef.current = deduped.length; return deduped; }); const nextHasMore = Boolean(data.hasMore); hasMoreRef.current = nextHasMore; setHasMore(nextHasMore); if (data.mode === "ranked" && data.seed && data.snapshot) { if (reset || !rankParamsRef.current) { rankParamsRef.current = { seed: String(data.seed), snapshot: String(data.snapshot), }; } } else if (reset) { rankParamsRef.current = null; } } catch (error) { if (error instanceof DOMException && error.name === "AbortError") { return; } console.error("加载评论失败:", error); } finally { if (abortControllerRef.current === controller) { setLoading(false); loadingRef.current = false; } } }, [awemeId], ); // 切换作品时清理旧评论状态与未完成请求 useEffect(() => { abortControllerRef.current?.abort(); loadingRef.current = false; commentsCountRef.current = 0; hasMoreRef.current = true; rankParamsRef.current = null; setComments([]); setHasMore(true); setLoading(false); if (open) { void loadComments(true); } return () => { abortControllerRef.current?.abort(); }; }, [awemeId, open, loadComments]); // Intersection Observer 监听底部触发分页加载(单容器) useEffect(() => { if (!open) return; const rootEl = scrollRef.current; const targetEl = sentinelRef.current; if (!rootEl || !targetEl) return; const io = new IntersectionObserver( (entries) => { const entry = entries[0]; if ( entry?.isIntersecting && !loadingRef.current && hasMoreRef.current ) { void loadComments(false); } }, { root: rootEl, rootMargin: "0px 0px 200px 0px", threshold: 0, }, ); io.observe(targetEl); return () => io.disconnect(); }, [open, hasMore, loadComments]); return (
{(open || hasOpened) && ( <> {hasMore &&
} {loading && (
加载中...
)} {!hasMore && comments.length > 0 && (
没有更多评论了
)} )}
); });