feat: 友链/书库/移动端导航/排行榜等多模块功能
新增模块: - 友链(friendlink)后端处理 + 前端管理页与友链板 - 书库(library):导入、章节、封面、阅读页、横竖版自适应 AdaptiveCoverSlot - 顶栏导航(header_nav)配置与 MobileTabBar/MobileRailDrawers 移动端抽屉 - 排行榜 service 测试、站点页面测试、时间线发布(timeline_release) 其它改动: - 后端 handlers/services 全量小幅调整 - 前端组件、库函数、URL/品牌/站点 URL 工具更新 - Lightbox 图片、MdEntries 条目卡、coverColor 派生色相等前端能力
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user