- 播放上下文:作品链接带上来源(搜索、标签、作者、hashtag),上一条/下一条 在该列表内移动;当前作品不在列表中时退回全部作品的发布时间顺序 - 顶部提示标签显示当前列表与位置,点击打开侧栏列表,✕ 退出 - 侧栏改为「评论 | TA的作品 | 搜索结果/#hashtag」标签页,作品封面网格可就地切换 - 作者模式:打开「TA的作品」进入,关闭侧栏不退出,点提示 ✕ 后回到原来的列表 - 从搜索结果进入或点 hashtag 时自动打开对应列表 - 描述中的 hashtag 可点击,按完整 hashtag 精确匹配(#丰川祥子 不含 #丰川祥子cos) - 新增 /api/playlist;搜索 SQL 抽到 lib/search-query.ts 与播放页共用,保证顺序一致 - 移动端:底部抽屉、点作品后收起,媒体区支持上下滑切换 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
197 lines
5.5 KiB
TypeScript
197 lines
5.5 KiB
TypeScript
import { memo, useCallback, useEffect, useRef, useState } from "react";
|
|
import type { Comment, User } from "../types.ts";
|
|
import { CommentList } from "./CommentList";
|
|
|
|
interface CommentPanelProps {
|
|
/** 侧栏打开且停在「评论」标签页 */
|
|
open: boolean;
|
|
author: User;
|
|
createdAt: string | Date;
|
|
awemeId: string;
|
|
}
|
|
|
|
interface CommentsApiResponse {
|
|
comments?: Comment[];
|
|
total?: number;
|
|
hasMore?: boolean;
|
|
mode?: string;
|
|
seed?: string | number;
|
|
snapshot?: string | number;
|
|
}
|
|
|
|
// 侧栏「评论」标签页的内容(外壳与标签切换在 SidePanel 里)
|
|
export const CommentPanel = memo(function CommentPanel({
|
|
open,
|
|
author,
|
|
createdAt,
|
|
awemeId,
|
|
}: CommentPanelProps) {
|
|
const [comments, setComments] = useState<Comment[]>([]);
|
|
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;
|
|
});
|
|
|
|
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([]);
|
|
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 (
|
|
<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>
|
|
);
|
|
});
|