Files
douyin-archive/app/aweme/[awemeId]/components/CommentPanel.tsx
T

231 lines
6.9 KiB
TypeScript

import { X } from "lucide-react";
import { memo, useCallback, useEffect, useRef, useState } from "react";
import type { Comment, User } from "../types.ts";
import { CommentList } from "./CommentList";
interface CommentPanelProps {
open: boolean;
onClose: () => void;
author: User;
createdAt: string | Date;
awemeId: string;
mounted: boolean;
}
interface CommentsApiResponse {
comments?: Comment[];
total?: number;
hasMore?: boolean;
mode?: string;
seed?: string | number;
snapshot?: string | number;
}
export const CommentPanel = memo(function CommentPanel({
open,
onClose,
author,
createdAt,
awemeId,
mounted,
}: CommentPanelProps) {
const [comments, setComments] = useState<Comment[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
const [hasOpened, setHasOpened] = useState(open);
const scrollRef = useRef<HTMLDivElement>(null);
const sentinelRef = useRef<HTMLDivElement>(null);
const loadingRef = useRef(false);
const abortControllerRef = useRef<AbortController | null>(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<void> => {
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<string>();
const deduped = source.filter((c) => {
if (seen.has(c.cid)) return false;
seen.add(c.cid);
return true;
});
commentsCountRef.current = deduped.length;
return deduped;
});
setTotal(typeof data.total === "number" ? data.total : 0);
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([]);
setTotal(0);
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 (
<aside
className={`
z-30 flex flex-col bg-[rgba(22,22,22,0.92)] text-white overflow-hidden
fixed inset-x-0 bottom-0 w-full h-[min(80vh,88dvh)] border-t border-white/10
landscape:relative landscape:inset-auto landscape:h-full landscape:translate-y-0 landscape:border-t-0
${mounted ? "transition-transform landscape:transition-[width] duration-200 ease-out" : ""}
${
open
? "translate-y-0 pointer-events-auto landscape:w-[min(420px,36vw)] landscape:border-l landscape:border-white/10"
: "translate-y-full pointer-events-none landscape:w-0 landscape:border-l-0"
}
`}
>
<div className="flex items-center justify-between landscape:flex-row-reverse px-3 py-3 border-b border-white/10 shrink-0">
<div className="text-white font-semibold">
评论 {total > 0 ? `(${total})` : ""}
</div>
<button
type="button"
className="w-8 h-8 inline-flex items-center justify-center rounded-full bg-white/15 text-white/90 border border-white/20 hover:bg-white/25 transition-colors cursor-pointer"
onClick={onClose}
aria-label="关闭评论"
>
<X size={18} />
</button>
</div>
<div ref={scrollRef} className="p-3 overflow-auto comment-scroll flex-1">
{(open || hasOpened) && (
<>
<CommentList
author={author}
createdAt={createdAt}
comments={comments}
/>
{hasMore && <div ref={sentinelRef} className="h-1 w-full" />}
{loading && (
<div className="py-4 text-center text-white/60 text-sm">
加载中...
</div>
)}
{!hasMore && comments.length > 0 && (
<div className="py-4 text-center text-white/40 text-sm">
没有更多评论了
</div>
)}
</>
)}
</div>
</aside>
);
});