Files
douyin-archive/app/aweme/[awemeId]/components/CommentPanel.tsx
T
2026-04-28 17:11:42 +08:00

288 lines
8.6 KiB
TypeScript

import { X } from "lucide-react";
import { useState, useEffect, useRef, useCallback } 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;
}
export 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);
// ranked 排序的稳定参数(由后端返回,前端透传保证会话内稳定)
const [rankParams, setRankParams] = useState<null | {
seed: string;
snapshot: string;
}>(null);
// 两套滚动容器与哨兵,分别对应横屏与竖屏面板
const scrollRefLandscape = useRef<HTMLDivElement>(null);
const sentinelRefLandscape = useRef<HTMLDivElement>(null);
const scrollRefPortrait = useRef<HTMLDivElement>(null);
const sentinelRefPortrait = useRef<HTMLDivElement>(null);
const loadingRef = useRef(false);
// 加载评论
const loadComments = useCallback(
async (reset = false) => {
if (loadingRef.current || (!reset && !hasMore)) return;
loadingRef.current = true;
setLoading(true);
try {
const skip = reset ? 0 : comments.length;
const query = new URLSearchParams({
skip: String(skip),
take: String(20),
mode: "ranked",
});
if (rankParams) {
query.set("seed", rankParams.seed);
query.set("snapshot", rankParams.snapshot);
}
const response = await fetch(
`/api/comments/${awemeId}?${query.toString()}`,
);
const data = await response.json();
// 统一做一次基于 cid 的去重,避免分页偶发重复
if (reset) {
setComments(() => {
const seen = new Set<string>();
return (data.comments as Comment[]).filter((c) => {
if (seen.has(c.cid)) return false;
seen.add(c.cid);
return true;
});
});
} else {
setComments((prev) => {
const merged = [...prev, ...(data.comments as Comment[])];
const seen = new Set<string>();
// 保留首次出现的项,既保证顺序也避免重复
return merged.filter((c) => {
if (seen.has(c.cid)) return false;
seen.add(c.cid);
return true;
});
});
}
setTotal(data.total);
setHasMore(data.hasMore);
if (data.mode === "ranked" && data.seed && data.snapshot) {
// 初始化或重置时更新稳定参数
setRankParams((prev) => {
if (reset)
return {
seed: String(data.seed),
snapshot: String(data.snapshot),
};
return (
prev ?? {
seed: String(data.seed),
snapshot: String(data.snapshot),
}
);
});
} else if (reset) {
setRankParams(null);
}
} catch (error) {
console.error("加载评论失败:", error);
} finally {
setLoading(false);
loadingRef.current = false;
}
},
[awemeId, comments.length, hasMore],
);
// 面板打开时加载初始评论
useEffect(() => {
if (open && comments.length === 0) {
loadComments(true);
}
}, [open]);
// Intersection Observer 监听底部触发加载(针对横屏与竖屏两个容器分别监听)
useEffect(() => {
if (!open) return;
const observers: IntersectionObserver[] = [];
const setup = (
rootEl: HTMLDivElement | null,
targetEl: HTMLDivElement | null,
) => {
if (!rootEl || !targetEl) return;
const io = new IntersectionObserver(
(entries) => {
const entry = entries[0];
if (entry.isIntersecting && !loadingRef.current && hasMore) {
loadComments();
}
},
{
root: rootEl,
rootMargin: "0px 0px 200px 0px", // 距底部 200px 触发
threshold: 0,
},
);
io.observe(targetEl);
observers.push(io);
};
setup(scrollRefLandscape.current, sentinelRefLandscape.current);
setup(scrollRefPortrait.current, sentinelRefPortrait.current);
return () => {
for (const io of observers) io.disconnect();
};
}, [open, hasMore, loadComments]);
return (
<>
<style jsx global>{`
/* 自定义滚动条样式 */
.comment-scroll::-webkit-scrollbar {
width: 6px;
}
.comment-scroll::-webkit-scrollbar-track {
background: transparent;
}
.comment-scroll::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.2);
border-radius: 3px;
transition: background 0.2s;
}
.comment-scroll::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.3);
}
/* Firefox */
.comment-scroll {
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.2) transparent;
}
`}</style>
{/* 横屏评论面板:并排分栏 */}
<aside
className={`
hidden landscape:flex
z-30 flex-col bg-[rgba(22,22,22,0.92)] text-white
relative h-full overflow-hidden
${mounted ? "transition-[width] duration-200 ease-out" : ""}
${open ? "w-[min(420px,36vw)] border-l border-white/10" : "w-0"}
`}
>
<div className="flex items-center justify-between px-3 py-3 border-b border-white/10">
<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"
onClick={onClose}
aria-label="关闭评论"
>
<X size={18} />
</button>
<div className="text-white font-semibold">
评论 {total > 0 ? `(${total})` : ""}
</div>
</div>
<div
ref={scrollRefLandscape}
className="p-3 overflow-auto comment-scroll"
>
<CommentList
author={author}
createdAt={createdAt}
comments={comments}
/>
{/* 底部加载触发区 */}
{hasMore && <div ref={sentinelRefLandscape} 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>
{/* 竖屏评论面板:bottom sheet */}
<aside
className={`
landscape:hidden
z-30 flex flex-col bg-[rgba(22,22,22,0.92)] text-white
fixed inset-x-0 bottom-0 w-full h-[min(80vh,88dvh)]
${mounted ? "transition-transform duration-200 ease-out" : ""}
border-t border-white/10
${open ? "translate-y-0" : "translate-y-full"}
`}
>
<div className="flex items-center justify-between px-3 py-3 border-b border-white/10">
<div className="text-white font-semibold">
评论 {total > 0 ? `(${total})` : ""}
</div>
<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"
onClick={onClose}
aria-label="关闭评论"
>
<X size={18} />
</button>
</div>
<div
ref={scrollRefPortrait}
className="p-3 overflow-auto comment-scroll"
>
<CommentList
author={author}
createdAt={createdAt}
comments={comments}
/>
{/* 底部加载触发区 */}
{hasMore && <div ref={sentinelRefPortrait} 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>
</>
);
}