feat: 帖子收藏与用户关注

- 新增收藏功能:favorite 服务与 handler、FavoriteButton 组件
- 新增关注功能:follow 服务与 handler、FollowButton 组件
- 用户主页适配关注/粉丝与收藏展示
- flash 消息工具、广告与侧边栏组件微调
This commit is contained in:
2026-09-26 03:02:40 +08:00
parent 31f5360c26
commit d7c6b3a961
40 changed files with 1528 additions and 268 deletions

View File

@@ -39,6 +39,8 @@ import MarkdownEditor from "@/components/MarkdownEditor";
import ConfirmDialog from "@/components/ConfirmDialog";
import DeleteCommentModal, { type DeleteCommentPayload } from "@/components/DeleteCommentModal";
import EditHistoryModal from "@/components/EditHistoryModal";
import { scrollFlashTo } from "@/lib/flash";
import { fetchCommentLocation } from "@/lib/api";
interface CommentSectionProps {
postId: string;
@@ -288,6 +290,59 @@ export default function CommentSection({
return () => window.removeEventListener("hashchange", focusComposer);
}, [user, commentsOpen]);
// 深链定位:#comment-{id} → 滚动 + 主题色浅底高亮渐隐。
// 目标是折叠楼层里的子回复时先展开所属楼层;不在当前页时查楼层号换页后重试。
// 楼层号锚点(#楼)、「回复 @xx」、采纳答案卡等页内跳转经 hashchange 同样获得高亮。
const locatedRef = useRef<number | null>(null); // 已跨页尝试过的评论 ID,防循环
useEffect(() => {
if (!commentsOpen) return;
let cancelled = false;
const locate = async () => {
const m = window.location.hash.match(/^#comment-(\d+)$/);
if (!m) return;
const id = Number(m[1]);
const el = document.getElementById(`comment-${id}`);
if (el) {
scrollFlashTo(el);
return;
}
// 不在 DOM:先看是否在本页数据里(折叠的子回复)→ 展开所属楼层后定位
const node = findComment(comments, id);
if (node) {
const rootId = node.root_id ?? node.parent_id ?? node.id;
setExpandedFloors((prev) => {
if (prev.has(rootId)) return prev;
const next = new Set(prev);
next.add(rootId);
return next;
});
window.setTimeout(() => {
if (!cancelled) scrollFlashTo(document.getElementById(`comment-${id}`));
}, 160);
return;
}
// 不在本页:查楼层号 → 换页(comments 变化后本 effect 再次运行并定位)
if (locatedRef.current === id) return; // 已尝试仍找不到(如被拒/未公开),放弃
locatedRef.current = id;
try {
const floor = await fetchCommentLocation(postId, id);
if (cancelled || floor <= 0) return;
const destPage = Math.max(1, Math.ceil(floor / size));
if (destPage === page) return; // 同页却无节点(异常数据),放弃
router.replace(`/post/${postId}?page=${destPage}#comment-${id}`, { scroll: false });
} catch {
/* 定位失败静默:停留在当前页 */
}
};
locate();
window.addEventListener("hashchange", locate);
return () => {
cancelled = true;
window.removeEventListener("hashchange", locate);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [comments, commentsOpen, page, postId, size]);
const submitFloor = async () => {
if (!commentsOpen || !content.trim() || submitLock.current || floorUploading) return;
submitLock.current = true;