- 新增 urlStyle 工具与测试:postPath/userPath/sitePagePath 统一生成链接 - 管理端新增 UrlStylePanel 设置面板,settings 服务与 handler 适配 - 全站路由链接与 JSON-LD/sitemap 切换为统一 URL 生成 - middleware 与相关组件适配
204 lines
6.2 KiB
TypeScript
204 lines
6.2 KiB
TypeScript
"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>
|
||
);
|
||
}
|