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

其它改动:
- 后端 handlers/services 全量小幅调整
- 前端组件、库函数、URL/品牌/站点 URL 工具更新
- Lightbox 图片、MdEntries 条目卡、coverColor 派生色相等前端能力
2026-09-30 16:30:07 +08:00

213 lines
6.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { Fragment, useEffect, useState } from "react";
import {
Home,
BookOpen,
PenSquare,
MessageSquare,
User as UserIcon,
} from "lucide-react";
import { apiChatUnreadSummary, type User } from "@/lib/api";
import { useAllowMessages } from "@/components/MessagesPolicyProvider";
import { useSiteBrand } from "@/components/SiteBrandProvider";
import { onDocumentVisible } from "@/lib/visibilityReconcile";
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 以下显示):
* 按 SiteBrand.mobile_tabs 配置顺序渲染(消息在未登录/未开放时不显示)。
* SiteChrome 只在启用 tab 的根路径渲染本组件,
* 避免与聊天 / 发帖工作台的锁视口布局冲突。
*/
export default function MobileTabBar({
user,
initialChatUnread = 0,
}: {
user: User | null;
initialChatUnread?: number;
}) {
const pathname = usePathname();
const allowMessages = useAllowMessages();
const { url_style: urlStyle, mobile_tabs: mobileTabs } = useSiteBrand();
const libHref = libraryPath(urlStyle);
const [chatUnread, setChatUnread] = useState(initialChatUnread);
// 登录态经 router.refresh() 下发新 props 时校正徽标
useEffect(() => {
setChatUnread(initialChatUnread);
}, [initialChatUnread]);
// 实时未读:与 Header 同源(chat:unread 对全体成员推送;在当前会话页则忽略)
useEffect(() => {
if (!user || !allowMessages) return;
const off = realtime.on<RtChatUnreadData>(RT_CHAT_UNREAD, (data) => {
if (!data || typeof data.room_id !== "number") return;
if (pathname === `/chat/${data.room_id}`) return;
const delta = typeof data.delta === "number" ? data.delta : 1;
setChatUnread((c) => Math.max(0, c + delta));
});
return off;
}, [user, pathname, allowMessages]);
// 会话内已读 / 回前台:合并对账(短时间多次同步只打一次 summary)
useEffect(() => {
if (!user || !allowMessages) return;
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
const pullSummary = () => {
apiChatUnreadSummary()
.then((res) => setChatUnread(res.total || 0))
.catch(() => {});
};
const onSync = () => {
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(pullSummary, 250);
};
window.addEventListener("j13-chat-unread-sync", onSync);
const offVisible = onDocumentVisible(pullSummary);
return () => {
if (debounceTimer) clearTimeout(debounceTimer);
window.removeEventListener("j13-chat-unread-sync", onSync);
offVisible();
};
}, [user, allowMessages]);
const showMessages = !!user && allowMessages;
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={{
borderColor: "var(--line)",
background: "color-mix(in srgb, var(--panel) 92%, transparent)",
backdropFilter: "blur(14px)",
WebkitBackdropFilter: "blur(14px)",
paddingBottom: "env(safe-area-inset-bottom)",
}}
>
<div className="flex items-stretch">
{mobileTabs.map((key) =>
tabNodes[key] ? <Fragment key={key}>{tabNodes[key]}</Fragment> : null,
)}
</div>
</nav>
);
}
function TabLink({
href,
icon,
label,
active,
prominent = false,
badge = 0,
}: {
href: string;
icon: React.ReactNode;
label: string;
active: boolean;
prominent?: boolean;
badge?: number | string;
}) {
const ink = active ? "var(--accent)" : "var(--ink-2)";
return (
<Link
href={href}
prefetch={false}
aria-label={label}
aria-current={active ? "page" : undefined}
className="flex-1 min-w-0 flex flex-col items-center justify-center gap-0.5 pt-2 pb-1.5 transition-colors"
style={{ color: ink }}
>
{prominent ? (
<span
className="w-9 h-9 -mt-1 flex items-center justify-center rounded-full"
style={{ background: "var(--accent)", color: "var(--accent-on)" }}
aria-hidden="true"
>
{icon}
</span>
) : (
<span className="relative" aria-hidden="true">
{icon}
{badge ? (
<span
className="absolute -top-1.5 -right-2.5 min-w-[16px] h-[16px] px-1 text-[9.5px] font-medium flex items-center justify-center rounded-full"
style={{
background: "var(--clay)",
color: "#fff",
border: "2px solid var(--panel)",
}}
>
{badge}
</span>
) : null}
</span>
)}
<span
className={`text-[10px] leading-none ${
prominent ? "mt-0.5" : ""
} ${active ? "font-semibold" : "font-medium"}`}
>
{label}
</span>
</Link>
);
}