这是一次包含多个功能升级的提交: 1. 后端新增批量查询帖子最后回复的逻辑,在前端帖子列表项中展示最后回复信息 2. 重构点赞按钮,新增文章样式变体并统一样式逻辑 3. 重写工作台导航栏,精简默认展示的菜单选项 4. 新增文章目录组件,为帖子详情页添加文章目录导航 5. 重构帖子详情页,实现三栏响应式布局,新增板块卡片、作者信息栏与同板块热议推荐 6. 重构帖子列表行组件,优化排版与信息展示逻辑 7. 重构帖子管理操作组件,使用下拉菜单整合所有管理功能 8. 新增全局通用的滚动容器与点赞按钮样式
94 lines
3.2 KiB
TypeScript
94 lines
3.2 KiB
TypeScript
"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>
|
||
);
|
||
}
|