Files
jiang13-bbs/frontend/components/MobileTabBar.tsx
freefire 7a44c1f2cf feat: 可配置站点 URL 风格
- 新增 urlStyle 工具与测试:postPath/userPath/sitePagePath 统一生成链接
- 管理端新增 UrlStylePanel 设置面板,settings 服务与 handler 适配
- 全站路由链接与 JSON-LD/sitemap 切换为统一 URL 生成
- middleware 与相关组件适配
2026-09-27 04:01:42 +08:00

204 lines
6.2 KiB
TypeScript
Raw Permalink 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 { useEffect, useState } from "react";
import {
Home,
Trophy,
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 { onDocumentVisible } from "@/lib/visibilityReconcile";
import { parseEntityPath, userPath, leaderboardPath } from "@/lib/urlStyle";
import { realtime, RT_CHAT_UNREAD } from "@/lib/realtime";
import type { RtChatUnreadData } from "@/lib/realtime";
/**
* 移动端全局底部公共菜单(lg 以下显示):
* 首页 / 排行榜 / 发帖 / 消息(未登录不显示)/ 我的。
* 聊天页、发帖工作台、管理后台由 SiteChrome 控制不渲染,避免与锁视口布局冲突。
*/
export default function MobileTabBar({
user,
initialChatUnread = 0,
}: {
user: User | null;
initialChatUnread?: number;
}) {
const pathname = usePathname();
const allowMessages = useAllowMessages();
const urlStyle = useUrlStyle();
const lbHref = leaderboardPath(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);
return (
<nav
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">
<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}
/>
)}
<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>
);
}
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>
);
}