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,26 +2,28 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useEffect, useState } from "react";
import { Fragment, useEffect, useState } from "react";
import {
Home,
Trophy,
BookOpen,
PenSquare,
MessageSquare,
User as UserIcon,
} from "lucide-react";
import { apiChatUnreadSummary, type User } from "@/lib/api";
import { useAllowMessages } from "@/components/MessagesPolicyProvider";
import { useUrlStyle } from "@/components/SiteBrandProvider";
import { useSiteBrand } from "@/components/SiteBrandProvider";
import { onDocumentVisible } from "@/lib/visibilityReconcile";
import { parseEntityPath, userPath, leaderboardPath } from "@/lib/urlStyle";
import { userPath, libraryPath } from "@/lib/urlStyle";
import { realtime, RT_CHAT_UNREAD } from "@/lib/realtime";
import type { RtChatUnreadData } from "@/lib/realtime";
import type { MobileTabKey } from "@/lib/mobileTabs";
/**
* 移动端全局底部公共菜单(lg 以下显示):
* 首页 / 排行榜 / 发帖 / 消息(未登录不显示)/ 我的。
* 聊天页、发帖工作台、管理后台由 SiteChrome 控制不渲染,避免与锁视口布局冲突。
* 按 SiteBrand.mobile_tabs 配置顺序渲染(消息在未登录/未开放时不显示)。
* SiteChrome 只在启用 tab 的根路径渲染本组件,
* 避免与聊天 / 发帖工作台的锁视口布局冲突。
*/
export default function MobileTabBar({
user,
@@ -32,8 +34,8 @@ export default function MobileTabBar({
}) {
const pathname = usePathname();
const allowMessages = useAllowMessages();
const urlStyle = useUrlStyle();
const lbHref = leaderboardPath(urlStyle);
const { url_style: urlStyle, mobile_tabs: mobileTabs } = useSiteBrand();
const libHref = libraryPath(urlStyle);
const [chatUnread, setChatUnread] = useState(initialChatUnread);
// 登录态经 router.refresh() 下发新 props 时校正徽标
@@ -80,8 +82,55 @@ export default function MobileTabBar({
const isActive = (match: (p: string) => boolean) =>
!!pathname && match(pathname);
// 配置已由 SiteBrandProvider normalize(白名单内子集,空回落全量);数组顺序即展示顺序
const tabNodes: Record<MobileTabKey, React.ReactNode> = {
home: (
<TabLink
href="/"
icon={<Home size={20} />}
label="首页"
active={isActive((p) => p === "/")}
/>
),
library: (
<TabLink
href={libHref}
icon={<BookOpen size={20} />}
label="书库"
active={isActive((p) => p === libHref)}
/>
),
compose: (
<TabLink
href={user ? "/compose" : "/login"}
icon={<PenSquare size={18} />}
label="发帖"
prominent
active={false}
/>
),
messages: showMessages ? (
<TabLink
href="/chat"
icon={<MessageSquare size={20} />}
label="消息"
active={isActive((p) => p === "/chat")}
badge={chatUnread > 0 ? (chatUnread > 99 ? "99+" : chatUnread) : 0}
/>
) : null,
me: (
<TabLink
href={user ? userPath(urlStyle, user.id) : "/login"}
icon={<UserIcon size={20} />}
label="我的"
active={!!user && isActive((p) => p === userPath(urlStyle, user.id))}
/>
),
};
return (
<nav
data-j13-tabbar
className="lg:hidden fixed bottom-0 inset-x-0 z-50 border-t"
aria-label="底部导航"
style={{
@@ -93,49 +142,9 @@ export default function MobileTabBar({
}}
>
<div className="flex items-stretch">
<TabLink
href="/"
icon={<Home size={20} />}
label="首页"
active={isActive((p) => p === "/")}
/>
<TabLink
href={lbHref}
icon={<Trophy size={20} />}
label="排行榜"
active={!!pathname && (pathname === lbHref || pathname.startsWith(`${lbHref}/`))}
/>
<TabLink
href={user ? "/compose" : "/login"}
icon={<PenSquare size={18} />}
label="发帖"
prominent
active={false}
/>
{showMessages && (
<TabLink
href="/chat"
icon={<MessageSquare size={20} />}
label="消息"
active={isActive((p) => p.startsWith("/chat"))}
badge={chatUnread > 0 ? (chatUnread > 99 ? "99+" : chatUnread) : 0}
/>
{mobileTabs.map((key) =>
tabNodes[key] ? <Fragment key={key}>{tabNodes[key]}</Fragment> : null,
)}
<TabLink
href={user ? userPath(urlStyle, user.id) : "/login"}
icon={<UserIcon size={20} />}
label="我的"
active={
!!user &&
isActive((p) => {
const parsed = parseEntityPath(p);
return (
(parsed?.kind === "user" && parsed.id === user.id) ||
p.startsWith(`${userPath(urlStyle, user.id)}/`)
);
})
}
/>
</div>
</nav>
);