feat: 友链/书库/移动端导航/排行榜等多模块功能

新增模块:
- 友链(friendlink)后端处理 + 前端管理页与友链板
- 书库(library):导入、章节、封面、阅读页、横竖版自适应 AdaptiveCoverSlot
- 顶栏导航(header_nav)配置与 MobileTabBar/MobileRailDrawers 移动端抽屉
- 排行榜 service 测试、站点页面测试、时间线发布(timeline_release)

其它改动:
- 后端 handlers/services 全量小幅调整
- 前端组件、库函数、URL/品牌/站点 URL 工具更新
- Lightbox 图片、MdEntries 条目卡、coverColor 派生色相等前端能力
This commit is contained in:
2026-09-30 16:30:07 +08:00
parent e487bc2e92
commit 4f3f3a265c
105 changed files with 12371 additions and 852 deletions

View File

@@ -16,7 +16,15 @@ export interface TocItem {
* - IntersectionObserver 高亮当前小节;
* - 当前项被滚动到目录可视区外时,列表自动最小化跟随(首屏不滚动、尊重 reduced-motion)。
*/
export default function PostToc({ items }: { items: TocItem[] }) {
export default function PostToc({
items,
title = "目录",
bare = false,
}: {
items: TocItem[];
title?: string;
bare?: boolean;
}) {
const [active, setActive] = useState<string>(items[0]?.id ?? "");
const navRef = useRef<HTMLElement>(null);
// 首次挂载时 active 取首项,不触发跟随滚动,避免进页跳动
@@ -38,15 +46,25 @@ export default function PostToc({ items }: { items: TocItem[] }) {
return () => obs.disconnect();
}, [items]);
// active 变化时把当前目录项滚入列表可视区(block: nearest 仅在贴边时最小移动)
// active 变化时把当前目录项滚入列表可视区(仅在列表容器内最小滚动)。
// 不用 scrollIntoView:移动端目录下沉到文末(普通文档流、容器本身不可滚),
// scrollIntoView(nearest) 会连带滚动窗口,把正在阅读的页面拽到底部目录,
// 表现为"进页自动下滚 / 无法向上滑动"。
useEffect(() => {
if (skipFollow.current) {
skipFollow.current = false;
return;
}
const el = navRef.current?.querySelector<HTMLElement>(`[data-toc-id="${active}"]`);
if (!el) return;
el.scrollIntoView({ block: "nearest", behavior: scrollBehavior() });
const list = navRef.current?.querySelector<HTMLElement>(".j13-rail-scroll");
const el = list?.querySelector<HTMLElement>(`[data-toc-id="${active}"]`);
if (!list || !el) return;
const listRect = list.getBoundingClientRect();
const elRect = el.getBoundingClientRect();
if (elRect.top < listRect.top) {
list.scrollBy({ top: elRect.top - listRect.top, behavior: scrollBehavior() });
} else if (elRect.bottom > listRect.bottom) {
list.scrollBy({ top: elRect.bottom - listRect.bottom, behavior: scrollBehavior() });
}
}, [active]);
const jump = (id: string) => (e: React.MouseEvent) => {
@@ -59,16 +77,29 @@ export default function PostToc({ items }: { items: TocItem[] }) {
};
return (
<nav ref={navRef} aria-label="文章目录" className="panel p-4 flex min-h-0 flex-col">
<nav
ref={navRef}
aria-label="文章目录"
className={`${bare ? "py-4" : "panel p-4"} flex min-h-0 flex-col`}
style={
bare
? {
background: "var(--panel)",
borderTop: "1px solid var(--line)",
borderBottom: "1px solid var(--line)",
}
: undefined
}
>
<p
className="text-[11.5px] font-bold tracking-[0.08em] uppercase mb-2.5 px-1 shrink-0"
style={{ color: "var(--ink-3)" }}
>
目录
{title}
</p>
<ul
className="j13-rail-scroll space-y-0.5 pr-0.5"
style={{ borderLeft: "1px solid var(--line)" }}
className="j13-rail-scroll min-h-0 flex-1 space-y-0.5 pr-0.5"
style={bare ? undefined : { borderLeft: "1px solid var(--line)" }}
>
{items.map((it) => {
const isActive = it.id === active;