243 lines
8.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
</>
);
}