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

@@ -2,13 +2,14 @@
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { useCallback, useEffect, useRef, useState } from "react";
import { Bookmark, Home, ShieldCheck } from "lucide-react";
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import { Bookmark, Home, LayoutGrid, Menu, ShieldCheck } from "lucide-react";
import type { BoardWithCount, User } from "@/lib/api";
import { isStaff } from "@/lib/roles";
import BoardIcon, { boardColor } from "@/components/BoardIcon";
import { useUrlStyle } from "@/components/SiteBrandProvider";
import { userPath } from "@/lib/urlStyle";
import { RailDrawerPanel, useDrawerChrome } from "@/components/MobileRailDrawers";
// 首页壳内板块切换:仅替换中栏帖子流,不离开工作台
function boardHref(id: number) {
@@ -116,13 +117,27 @@ export function WorkspaceNav({ boards, user }: { boards: BoardWithCount[]; user?
);
}
// 移动端:横向滑动板块条(两端渐隐指示「可左右滑动」);发帖入口由底部公共菜单承载
export function WorkspaceNavMobile({ boards }: { boards: BoardWithCount[] }) {
// 移动端:横向滑动板块条(两端渐隐指示「可左右滑动」);发帖入口由底部公共菜单承载。
// 板块条两端嵌左右抽屉入口(☰ 导航 / ▦ 面板),与书籍阅读页的左右工具栏同构:
// 左抽屉 = 桌面左栏(板块目录 + 工作台),右抽屉 = 桌面右栏面板;内容为空则对应入口不渲染
export function WorkspaceNavMobile({
boards,
left,
right,
}: {
boards: BoardWithCount[];
/** 左抽屉内容(桌面左栏,服务端渲染节点) */
left?: ReactNode;
/** 右抽屉内容(桌面右栏,服务端渲染节点) */
right?: ReactNode;
}) {
const params = useSearchParams();
const activeBoard = Number(params.get("board")) || 0;
const scrollRef = useRef<HTMLDivElement>(null);
const [canScrollLeft, setCanScrollLeft] = useState(false);
const [canScrollRight, setCanScrollRight] = useState(false);
const [drawer, setDrawer] = useState<"left" | "right" | null>(null);
useDrawerChrome(drawer !== null, () => setDrawer(null));
const updateScrollState = useCallback(() => {
const el = scrollRef.current;
@@ -151,43 +166,88 @@ export function WorkspaceNavMobile({ boards }: { boards: BoardWithCount[] }) {
});
}, [activeBoard, boards]);
const triggerBtn =
"relative inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-full border transition-colors hover:bg-[var(--panel-2)] active:scale-95 " +
// 视觉 28px、触控热区扩到 44px(HIG 最小触控目标),仅扩大命中范围不改变外观
"before:absolute before:-inset-2 before:rounded-full before:content-['']";
return (
<div className="lg:hidden -mx-4 sm:-mx-6 px-4 sm:px-6 bg-[var(--main-bg)] border-b border-[var(--line)] py-2.5">
<div className="relative">
<div
ref={scrollRef}
className="j13-chips"
onScroll={updateScrollState}
>
<Link
href="/"
className="j13-chip"
aria-current={activeBoard === 0 ? "page" : undefined}
<div className="relative flex items-center gap-1.5">
{left ? (
<button
type="button"
className={triggerBtn}
style={{ borderColor: "var(--line)", color: "var(--ink-2)" }}
onClick={() => setDrawer("left")}
aria-haspopup="dialog"
aria-label="打开导航抽屉"
>
<Menu size={15} aria-hidden />
</button>
) : null}
<div className="relative min-w-0 flex-1">
<div
ref={scrollRef}
className="j13-chips"
onScroll={updateScrollState}
>
<Home size={13} /> 全部
</Link>
{boards.map((b) => (
<Link
key={b.id}
href={boardHref(b.id)}
href="/"
className="j13-chip"
aria-current={activeBoard === b.id ? "page" : undefined}
aria-current={activeBoard === 0 ? "page" : undefined}
>
<BoardIcon icon={b.icon} name={b.name} size={13} />
{b.name}
<Home size={13} /> 全部
</Link>
))}
{boards.map((b) => (
<Link
key={b.id}
href={boardHref(b.id)}
className="j13-chip"
aria-current={activeBoard === b.id ? "page" : undefined}
>
<BoardIcon icon={b.icon} name={b.name} size={13} />
{b.name}
</Link>
))}
</div>
{/* 渐隐滑动指示:遮罩与背景同色,pointer-events-none 不挡触控 */}
<div
className={`j13-chip-fade j13-chip-fade-left ${canScrollLeft ? "is-on" : ""}`}
aria-hidden="true"
/>
<div
className={`j13-chip-fade j13-chip-fade-right ${canScrollRight ? "is-on" : ""}`}
aria-hidden="true"
/>
</div>
{/* 渐隐滑动指示:遮罩与背景同色,pointer-events-none 不挡触控 */}
<div
className={`j13-chip-fade j13-chip-fade-left ${canScrollLeft ? "is-on" : ""}`}
aria-hidden="true"
/>
<div
className={`j13-chip-fade j13-chip-fade-right ${canScrollRight ? "is-on" : ""}`}
aria-hidden="true"
/>
{right ? (
<button
type="button"
className={triggerBtn}
style={{ borderColor: "var(--line)", color: "var(--ink-2)" }}
onClick={() => setDrawer("right")}
aria-haspopup="dialog"
aria-label="打开面板抽屉"
>
<LayoutGrid size={15} aria-hidden />
</button>
) : null}
</div>
{/* 左右抽屉:内容由服务端渲染后传入;lg 起隐藏(桌面用常驻侧栏) */}
{left ? (
<RailDrawerPanel side="left" title="导航" open={drawer === "left"} onClose={() => setDrawer(null)}>
{left}
</RailDrawerPanel>
) : null}
{right ? (
<RailDrawerPanel side="right" title="面板" open={drawer === "right"} onClose={() => setDrawer(null)}>
{right}
</RailDrawerPanel>
) : null}
</div>
);
}