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

@@ -13,11 +13,13 @@ import {
Bell,
Home,
Trophy,
BookOpen,
User as UserIcon,
ChevronDown,
ShieldCheck,
MessageSquare,
X,
ExternalLink,
} from "lucide-react";
import { apiLogout, apiMe, apiChatUnreadSummary, type User } from "@/lib/api";
import { hasAuthCookieHint } from "@/lib/cookies";
@@ -34,8 +36,13 @@ import LevelBadge from "./LevelBadge";
import BadgeChip from "./BadgeChip";
import BrandLockup from "./BrandLockup";
import { useAllowMessages } from "./MessagesPolicyProvider";
import { useUrlStyle } from "./SiteBrandProvider";
import { leaderboardPath } from "@/lib/urlStyle";
import { useSiteBrand, useUrlStyle } from "./SiteBrandProvider";
import { leaderboardPath, libraryPath } from "@/lib/urlStyle";
import {
HEADER_NAV_BUILTIN_LABELS,
isHeaderNavExternal,
type HeaderNavBuiltinKey,
} from "@/lib/headerNav";
import type { BrandLogoFit, BrandLogoSize, BrandMark } from "@/lib/brand";
// 搜索关键词长度上限(与后端 handler maxSearchKeywordRunes 一致);
@@ -72,6 +79,33 @@ export default function Header({
const pathname = usePathname();
const urlStyle = useUrlStyle();
const lbHref = leaderboardPath(urlStyle);
const libHref = libraryPath(urlStyle);
// 顶部主导航由后台「基本信息 → 顶部导航」配置(顺序/显隐/自定义链接),
// 配置为空时整个 nav 不渲染(含左侧分隔线);移动端导航走底部公共菜单
const headerNav = useSiteBrand().header_nav;
const builtinNavIcon: Record<HeaderNavBuiltinKey, React.ReactNode> = {
home: <Home size={15} />,
leaderboard: <Trophy size={15} />,
library: <BookOpen size={15} />,
};
const builtinNavHref: Record<HeaderNavBuiltinKey, string> = {
home: "/",
leaderboard: lbHref,
library: libHref,
};
const isBuiltinNavActive = (key: HeaderNavBuiltinKey): boolean => {
if (!pathname) return false;
if (key === "home") return pathname === "/";
if (key === "leaderboard") return pathname === lbHref || pathname.startsWith(`${lbHref}/`);
// 书库详情/阅读页标准路径与伪静态(/library-*.html)统一按 /library 前缀高亮
return pathname.startsWith("/library");
};
// 自定义站内菜单高亮:精确或子路径;"/" 仅精确,避免高亮全站
const isCustomNavActive = (url: string): boolean => {
if (!pathname || !url.startsWith("/") || url.startsWith("//")) return false;
if (url === "/") return pathname === "/";
return pathname === url || pathname.startsWith(`${url}/`);
};
const allowMessages = useAllowMessages();
const hideComposeNav =
pathname === "/compose" ||
@@ -244,18 +278,35 @@ export default function Header({
logoDark={logoDark}
/>
</Link>
<nav className="hidden lg:flex items-center gap-1.5 pl-3" style={{ borderLeft: "1px solid var(--line)" }}>
<NavPill href="/" active={pathname === "/"} icon={<Home size={15} />}>
首页
</NavPill>
<NavPill
href={lbHref}
active={!!pathname && (pathname === lbHref || pathname.startsWith(`${lbHref}/`))}
icon={<Trophy size={15} />}
>
排行榜
</NavPill>
</nav>
{headerNav.length > 0 && (
<nav className="hidden lg:flex items-center gap-1.5 pl-3" style={{ borderLeft: "1px solid var(--line)" }}>
{headerNav.map((item, idx) =>
item.type === "builtin" ? (
<NavPill
key={`builtin-${item.key}`}
href={builtinNavHref[item.key]}
active={isBuiltinNavActive(item.key)}
icon={builtinNavIcon[item.key]}
>
{HEADER_NAV_BUILTIN_LABELS[item.key]}
</NavPill>
) : (
<NavPill
key={`custom-${idx}`}
href={item.url}
external={isHeaderNavExternal(item.url)}
newTab={item.new_tab}
active={!isHeaderNavExternal(item.url) && isCustomNavActive(item.url)}
>
{item.label}
{isHeaderNavExternal(item.url) && item.new_tab ? (
<ExternalLink size={12} aria-hidden style={{ color: "var(--ink-3)" }} />
) : null}
</NavPill>
),
)}
</nav>
)}
</div>
{/* 中部弹性留白,把搜索与操作区推到右侧 */}
@@ -415,23 +466,36 @@ function NavPill({
href,
active,
icon,
external,
newTab,
children,
}: {
href: string;
active?: boolean;
icon?: React.ReactNode;
external?: boolean;
newTab?: boolean;
children: React.ReactNode;
}) {
const cls =
"inline-flex items-center gap-1.5 px-3.5 py-2 rounded-full text-[15px] font-semibold transition-colors";
const style =
active
? { background: "transparent", color: "var(--accent)" }
: { color: "var(--ink)" };
const target = newTab ? "_blank" : undefined;
const rel = newTab ? "noopener noreferrer" : undefined;
// 自定义外链渲染原生 <a>;站内链接(含勾选新标签的)走 Next Link 保留软导航
if (external) {
return (
<a href={href} target={target} rel={rel} className={cls} style={style}>
{icon}
{children}
</a>
);
}
return (
<Link
href={href}
className="inline-flex items-center gap-1.5 px-3.5 py-2 rounded-full text-[15px] font-semibold transition-colors"
style={
active
? { background: "transparent", color: "var(--accent)" }
: { color: "var(--ink)" }
}
>
<Link href={href} target={target} rel={rel} className={cls} style={style}>
{icon}
{children}
</Link>