feat: 站点媒体库与移动端底部导航,服务测试补强

- 新增媒体库:media_library/media_thumbs 服务(WebP 缩略图)、管理端 media 页面
- 移动端底部导航 MobileTabBar 替换 MobilePostBar
- 旧数据导入增强与测试、路由与登录会话/板块侧边栏测试补强
- site-doc 组件精简(移除 Breadcrumb),文档新增迁移公告说明
This commit is contained in:
2026-09-27 02:35:38 +08:00
parent 734afbc39f
commit 1d862a0dd3
61 changed files with 4757 additions and 1129 deletions

View File

@@ -0,0 +1,192 @@
"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 { onDocumentVisible } from "@/lib/visibilityReconcile";
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 [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="/leaderboard"
icon={<Trophy size={20} />}
label="排行榜"
active={isActive((p) => p.startsWith("/leaderboard"))}
/>
<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 ? `/u/${user.id}` : "/login"}
icon={<UserIcon size={20} />}
label="我的"
active={
!!user && isActive((p) => p === `/u/${user.id}` || p.startsWith(`/u/${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>
);
}