反爬虫优化,评论图片支持

This commit is contained in:
2025-10-23 13:25:51 +08:00
parent 590b26c420
commit 5a5eba19e4
25 changed files with 1082 additions and 112 deletions
+53 -1
View File
@@ -1,4 +1,5 @@
import { ThumbsUp } from "lucide-react";
import { ThumbsUp, X } from "lucide-react";
import { useState } from "react";
import type { Comment, User } from "../types";
import { formatRelativeTime, formatAbsoluteUTC } from "../utils";
import { CommentText } from "./CommentText";
@@ -10,6 +11,8 @@ interface CommentListProps {
}
export function CommentList({ author, createdAt, comments }: CommentListProps) {
const [previewImage, setPreviewImage] = useState<string | null>(null);
return (
<>
<header className="flex items-center gap-4 mb-5">
@@ -42,6 +45,29 @@ export function CommentList({ author, createdAt, comments }: CommentListProps) {
<p className="mt-1 text-sm leading-relaxed text-white/90 break-words">
<CommentText text={c.text} />
</p>
{c.images && c.images.length > 0 ? (
<div className="mt-2 grid grid-cols-3 gap-1.5 sm:gap-2">
{c.images.map((img, idx) => (
<button
key={idx}
type="button"
onClick={() => setPreviewImage(img.url)}
className="block overflow-hidden rounded-md bg-zinc-800/60 cursor-pointer hover:opacity-80 transition-opacity"
title="点击预览大图"
>
{/* 以真实比例显示:利用 width/height 属性提供固有尺寸,CSS 设定宽度 100% 高度自适应 */}
<img
src={img.url}
alt="comment-image"
width={img.width}
height={img.height}
className="w-full h-auto"
loading="lazy"
/>
</button>
))}
</div>
) : null}
<div className="mt-2 inline-flex items-center gap-1 text-xs text-white/70">
<ThumbsUp size={14} />
<span>{c.digg_count}</span>
@@ -51,6 +77,32 @@ export function CommentList({ author, createdAt, comments }: CommentListProps) {
))}
{comments.length === 0 ? <li className="text-sm text-white/60">暂无评论</li> : null}
</ul>
{/* 图片预览灯箱 */}
{previewImage && (
<div
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/90 backdrop-blur-sm"
onClick={() => setPreviewImage(null)}
>
{/* 关闭按钮 */}
<button
type="button"
className="absolute top-4 right-4 w-10 h-10 rounded-full bg-white/15 text-white border border-white/20 hover:bg-white/25 transition-colors flex items-center justify-center"
onClick={() => setPreviewImage(null)}
aria-label="关闭预览"
>
<X size={20} />
</button>
{/* 大图 */}
<img
src={previewImage}
alt="preview"
className="max-w-[90vw] max-h-[90vh] object-contain"
onClick={(e) => e.stopPropagation()}
/>
</div>
)}
</>
);
}
+177 -6
View File
@@ -1,4 +1,5 @@
import { X } from "lucide-react";
import { useState, useEffect, useRef, useCallback } from "react";
import type { Comment, User } from "../types";
import { CommentList } from "./CommentList";
@@ -7,13 +8,155 @@ interface CommentPanelProps {
onClose: () => void;
author: User;
createdAt: string | Date;
comments: Comment[];
awemeId: string;
mounted: boolean;
}
export function CommentPanel({ open, onClose, author, createdAt, comments, mounted }: CommentPanelProps) {
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={`
@@ -32,11 +175,25 @@ export function CommentPanel({ open, onClose, author, createdAt, comments, mount
>
<X size={18} />
</button>
<div className="text-white font-semibold">评论 {comments.length > 0 ? `(${comments.length})` : ""}</div>
<div className="text-white font-semibold">
评论 {total > 0 ? `(${total})` : ""}
</div>
</div>
<div className="p-3 overflow-auto">
<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>
@@ -52,7 +209,9 @@ export function CommentPanel({ open, onClose, author, createdAt, comments, mount
`}
>
<div className="flex items-center justify-between px-3 py-3 border-b border-white/10">
<div className="text-white font-semibold">评论 {comments.length > 0 ? `(${comments.length})` : ""}</div>
<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}
@@ -62,8 +221,20 @@ export function CommentPanel({ open, onClose, author, createdAt, comments, mount
</button>
</div>
<div className="p-3 overflow-auto">
<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>
</>