243 lines
8.2 KiB
TypeScript
243 lines
8.2 KiB
TypeScript
import { X } from "lucide-react";
|
||
import { useState, useEffect, useRef, useCallback } from "react";
|
||
import type { Comment, User } from "../types";
|
||
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>
|
||
</>
|
||
);
|
||
}
|