feat: 新增帖子最后回复展示、文章目录与三栏布局,优化点赞按钮与导航栏

这是一次包含多个功能升级的提交:
1. 后端新增批量查询帖子最后回复的逻辑,在前端帖子列表项中展示最后回复信息
2. 重构点赞按钮,新增文章样式变体并统一样式逻辑
3. 重写工作台导航栏,精简默认展示的菜单选项
4. 新增文章目录组件,为帖子详情页添加文章目录导航
5. 重构帖子详情页,实现三栏响应式布局,新增板块卡片、作者信息栏与同板块热议推荐
6. 重构帖子列表行组件,优化排版与信息展示逻辑
7. 重构帖子管理操作组件,使用下拉菜单整合所有管理功能
8. 新增全局通用的滚动容器与点赞按钮样式
This commit is contained in:
2026-09-13 04:06:56 +08:00
parent bee06fa962
commit 52124f2682
10 changed files with 926 additions and 226 deletions

View File

@@ -0,0 +1,93 @@
"use client";
import { useEffect, useRef, useState } from "react";
export interface TocItem {
level: 2 | 3;
text: string;
id: string;
}
/**
* 文章目录树:右栏 flex 限高列中的可收缩模块。
* - 标题常驻,目录列表在超高时于面板内部滚动(细滚动条,不顶出侧栏);
* - IntersectionObserver 高亮当前小节;
* - 当前项被滚动到目录可视区外时,列表自动最小化跟随(首屏不滚动、尊重 reduced-motion)。
*/
export default function PostToc({ items }: { items: TocItem[] }) {
const [active, setActive] = useState<string>(items[0]?.id ?? "");
const navRef = useRef<HTMLElement>(null);
// 首次挂载时 active 取首项,不触发跟随滚动,避免进页跳动
const skipFollow = useRef(true);
useEffect(() => {
const heads = items
.map((i) => document.getElementById(i.id))
.filter((el): el is HTMLElement => Boolean(el));
if (heads.length === 0) return;
const obs = new IntersectionObserver(
(entries) => {
for (const e of entries) if (e.isIntersecting) setActive(e.target.id);
},
// 顶部让出固定头部高度,底部提前切换到下一节
{ rootMargin: "-80px 0px -70% 0px", threshold: 0 }
);
heads.forEach((h) => obs.observe(h));
return () => obs.disconnect();
}, [items]);
// active 变化时把当前目录项滚入列表可视区(block: nearest 仅在贴边时最小移动)
useEffect(() => {
if (skipFollow.current) {
skipFollow.current = false;
return;
}
const el = navRef.current?.querySelector<HTMLElement>(`[data-toc-id="${active}"]`);
if (!el) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
el.scrollIntoView({ block: "nearest", behavior: reduce ? "auto" : "smooth" });
}, [active]);
const jump = (id: string) => (e: React.MouseEvent) => {
e.preventDefault();
document.getElementById(id)?.scrollIntoView({ behavior: "smooth", block: "start" });
};
return (
<nav ref={navRef} aria-label="文章目录" className="panel p-4 flex min-h-0 flex-col">
<p
className="text-[11.5px] font-bold tracking-[0.08em] uppercase mb-2.5 px-1 shrink-0"
style={{ color: "var(--ink-3)" }}
>
目录
</p>
<ul
className="j13-rail-scroll space-y-0.5 pr-0.5"
style={{ borderLeft: "1px solid var(--line)" }}
>
{items.map((it) => {
const isActive = it.id === active;
return (
<li key={it.id}>
<a
data-toc-id={it.id}
href={`#${it.id}`}
onClick={jump(it.id)}
className="block py-1.5 pr-1 text-[12.5px] leading-snug transition-colors"
style={{
paddingLeft: it.level === 3 ? 20 : 12,
marginLeft: -1,
borderLeft: `2px solid ${isActive ? "var(--accent)" : "transparent"}`,
color: isActive ? "var(--accent)" : "var(--ink-2)",
fontWeight: isActive ? 600 : 400,
}}
>
{it.text}
</a>
</li>
);
})}
</ul>
</nav>
);
}