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

94 lines
3.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}