Files
jiang13-bbs/frontend/components/Header.tsx
freefire 1d862a0dd3 feat: 站点媒体库与移动端底部导航,服务测试补强
- 新增媒体库:media_library/media_thumbs 服务(WebP 缩略图)、管理端 media 页面
- 移动端底部导航 MobileTabBar 替换 MobilePostBar
- 旧数据导入增强与测试、路由与登录会话/板块侧边栏测试补强
- site-doc 组件精简(移除 Breadcrumb),文档新增迁移公告说明
2026-09-27 02:36:11 +08:00

528 lines
19 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, useRouter } from "next/navigation";
import { useState, useEffect, useRef } from "react";
import {
LogIn,
UserPlus,
LogOut,
PenSquare,
Search,
Settings,
Bell,
Home,
Trophy,
User as UserIcon,
ChevronDown,
ShieldCheck,
MessageSquare,
X,
} from "lucide-react";
import { apiLogout, apiMe, apiChatUnreadSummary, type User } from "@/lib/api";
import { hasAuthCookieHint } from "@/lib/cookies";
import { canAccessAdminMessages, isStaff } from "@/lib/roles";
import { onUserUpdate, onForceLogout } from "@/lib/userEvents";
import { realtime, RT_CHAT_UNREAD } from "@/lib/realtime";
import type { RtChatUnreadData } from "@/lib/realtime";
import { onDocumentVisible } from "@/lib/visibilityReconcile";
import NotificationBell from "./NotificationBell";
import ThemeToggle from "./ThemeToggle";
import Avatar from "./Avatar";
import LevelBadge from "./LevelBadge";
import BadgeChip from "./BadgeChip";
import BrandLockup from "./BrandLockup";
import { useAllowMessages } from "./MessagesPolicyProvider";
import type { BrandLogoFit, BrandLogoSize, BrandMark } from "@/lib/brand";
export default function Header({
initialUser,
initialUnread = 0,
initialChatUnread = 0,
initialTheme = "light",
siteName = "姜十三",
wordmark = "",
logoLight = "",
logoDark = "",
brandMark = "image_text",
brandLogoSize = "sq",
brandLogoFit = "contain",
}: {
initialUser: User | null;
initialUnread?: number;
initialChatUnread?: number;
initialTheme?: "light" | "dark";
siteName?: string;
wordmark?: string;
logoLight?: string;
logoDark?: string;
brandMark?: BrandMark;
brandLogoSize?: BrandLogoSize;
brandLogoFit?: BrandLogoFit;
}) {
const router = useRouter();
const pathname = usePathname();
const allowMessages = useAllowMessages();
const hideComposeNav =
pathname === "/compose" ||
(pathname?.startsWith("/compose/") ?? false) ||
/^\/post\/[^/]+\/edit\/?$/.test(pathname || "");
const [user, setUser] = useState<User | null>(initialUser);
const [unread, setUnread] = useState(initialUnread);
const [chatUnread, setChatUnread] = useState(initialChatUnread);
const [searchQuery, setSearchQuery] = useState("");
// 移动端展开式搜索行(导航已交给底部公共菜单,顶部只留搜索/主题/铃铛/头像)
const [searchOpen, setSearchOpen] = useState(false);
const searchInputRef = useRef<HTMLInputElement>(null);
const mobileSearchRef = useRef<HTMLInputElement>(null);
// 登录/登出触发 router.refresh() 后,服务端 layout 重新取态并下发新 props
useEffect(() => {
setUser(initialUser);
}, [initialUser]);
useEffect(() => {
setUnread(initialUnread);
}, [initialUnread]);
useEffect(() => {
setChatUnread(initialChatUnread);
}, [initialChatUnread]);
// 同页内资料变更(如上传/删除头像)立即生效,不依赖 router.refresh 时序
useEffect(() => onUserUpdate((patch) => {
setUser((prev) => (prev ? { ...prev, ...patch } : prev));
}), []);
// 强制下线(当前唯一来源:账号被管理员封禁)。
// 统一复用登出清理:尽力调用 /api/logout 清除 HttpOnly cookie(该端点不要求
// 有效登录态),本地立即游客化并 router.refresh 校正 SSR 区域,
// 封禁告知弹窗由 AccountGuard 负责,避免状态与 UI 清理分散
useEffect(() => onForceLogout(() => {
apiLogout()
.catch(() => {})
.finally(() => {
setUser(null);
setUnread(0);
router.refresh();
});
}), [router]);
// 挂载后静默校正一次(仅一次):覆盖 SSR 期间后端不可用等边缘情况。
// 正常 F5 下结果与 SSR 一致,不产生可见闪动;
// 无可读 CSRF cookie 说明必然是游客,跳过请求
useEffect(() => {
if (!hasAuthCookieHint()) return;
apiMe()
.then((res) => {
setUser(res.user);
if (typeof res.unread_count === "number") setUnread(res.unread_count);
if (typeof res.chat_unread_count === "number")
setChatUnread(res.chat_unread_count);
})
.catch(() => {});
}, []);
// 实时未读: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]);
// 会话内已读 / 回前台:合并对账(短时间多次 markRead 只打一次 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 handleSearch = (e: React.FormEvent) => {
e.preventDefault();
const q = searchQuery.trim();
router.push(q ? `/?q=${encodeURIComponent(q)}` : "/");
setSearchOpen(false);
searchInputRef.current?.blur();
mobileSearchRef.current?.blur();
};
useEffect(() => {
setSearchQuery(new URLSearchParams(window.location.search).get("q")?.trim() || "");
}, [pathname]);
// "/" 快速聚焦搜索
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const el = e.target as HTMLElement;
const typing =
el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.tagName === "SELECT" || el.isContentEditable;
if (e.key === "/" && !typing && !e.metaKey && !e.ctrlKey && !e.altKey) {
e.preventDefault();
searchInputRef.current?.focus();
searchInputRef.current?.select();
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);
const onSearchKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Escape") {
setSearchQuery("");
if (new URLSearchParams(window.location.search).get("q")) router.push("/");
e.currentTarget.blur();
}
};
const handleLogout = async () => {
try {
await apiLogout();
} catch {
/* ignore */
}
setUser(null);
router.refresh();
};
const iconBtn =
"w-10 h-10 flex items-center justify-center rounded-full transition-colors";
const iconBtnStyle = {
border: "1px solid var(--line)",
background: "var(--panel)",
color: "var(--ink-2)",
} as const;
return (
<header
className="sticky top-0 z-50 border-b"
style={{
borderColor: "var(--line)",
background: "var(--panel)",
}}
>
<div className="max-w-[1440px] mx-auto px-4 sm:px-6 h-[var(--header-bar)] flex items-center gap-4 lg:gap-7">
{/* 左组:品牌 + 主导航 */}
<div className="flex items-center gap-3 lg:gap-5 shrink-0">
<Link href="/" className="flex items-center group min-w-0" aria-label={siteName}>
<BrandLockup
mode={brandMark}
size={brandLogoSize}
fit={brandLogoFit}
name={siteName}
wordmark={wordmark}
logoLight={logoLight}
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="/leaderboard"
active={pathname?.startsWith("/leaderboard") ?? false}
icon={<Trophy size={15} />}
>
排行榜
</NavPill>
</nav>
</div>
{/* 中部弹性留白,把搜索与操作区推到右侧 */}
<div className="hidden lg:block flex-1" aria-hidden />
{/* 右侧操作区:搜索靠右停靠 + 桌面/移动共用铃铛(仅挂载一次,避免重复轮询)。
移动端导航交给底部公共菜单,顶部只保留 搜索/主题/铃铛/头像 */}
<div className="flex items-center gap-2 lg:gap-3 ml-auto lg:ml-0">
{/* 移动端搜索开关 */}
<button
onClick={() => setSearchOpen((v) => !v)}
aria-label="搜索"
aria-expanded={searchOpen}
className={`${iconBtn} lg:hidden`}
style={iconBtnStyle}
>
{searchOpen ? <X size={17} /> : <Search size={17} />}
</button>
<form onSubmit={handleSearch} className="hidden lg:block">
<div className="relative w-[220px] xl:w-[260px] focus-within:w-[268px] xl:focus-within:w-[300px] transition-[width] duration-200">
<Search
size={15}
className="absolute left-4 top-1/2 -translate-y-1/2"
style={{ color: "var(--ink-3)" }}
/>
<input
ref={searchInputRef}
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={onSearchKeyDown}
placeholder="搜索帖子..."
aria-label="搜索帖子"
className="j13-search-field w-full pl-10 pr-10 py-2.5 text-sm rounded-full"
/>
<kbd
className="absolute right-3.5 top-1/2 -translate-y-1/2 text-[11px] w-5 h-5 flex items-center justify-center rounded-md pointer-events-none"
style={{ color: "var(--ink-3)", border: "1px solid var(--line-2)", background: "var(--bg)" }}
>
/
</kbd>
</div>
</form>
<ThemeToggle initialTheme={initialTheme} className={iconBtn} style={iconBtnStyle} />
{user && allowMessages && (
<Link
href="/chat"
prefetch={false}
aria-label="消息"
data-tip="消息"
className="relative hidden lg:inline-flex items-center justify-center w-10 h-10 rounded-full transition-colors"
style={{
color: pathname?.startsWith("/chat")
? "var(--accent)"
: "var(--ink-2)",
border: `1px solid ${
pathname?.startsWith("/chat") ? "var(--accent)" : "var(--line)"
}`,
background: pathname?.startsWith("/chat")
? "var(--accent-soft)"
: "var(--panel)",
}}
>
<MessageSquare size={17} />
{chatUnread > 0 && (
<span
className="absolute -top-1 -right-1 min-w-[18px] h-[18px] px-1 text-[10px] font-medium flex items-center justify-center rounded-full"
style={{
background: "var(--clay)",
color: "#fff",
border: "2px solid var(--bg)",
}}
>
{chatUnread > 99 ? "99+" : chatUnread}
</span>
)}
</Link>
)}
{user && <NotificationBell initialUnread={unread} />}
{user ? (
<>
{!hideComposeNav && (
<Link href="/compose" className="btn btn-primary btn-sm hidden lg:inline-flex">
<PenSquare size={14} /> 发帖
</Link>
)}
<UserMenu user={user} onLogout={handleLogout} />
</>
) : (
<div className="flex items-center gap-2">
<Link href="/login" className="btn btn-line btn-sm">
<LogIn size={14} /> 登录
</Link>
<Link href="/register" className="btn btn-primary btn-sm hidden sm:inline-flex">
<UserPlus size={14} /> 注册
</Link>
</div>
)}
</div>
</div>
{/* 移动端展开式搜索行 */}
{searchOpen && (
<div
className="lg:hidden border-t px-4 py-2.5"
style={{ borderColor: "var(--line)", background: "var(--bg)" }}
>
<form onSubmit={handleSearch} className="relative">
<Search size={16} className="absolute left-4 top-1/2 -translate-y-1/2" style={{ color: "var(--ink-3)" }} />
<input
ref={mobileSearchRef}
autoFocus
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Escape") {
setSearchOpen(false);
if (new URLSearchParams(window.location.search).get("q")) router.push("/");
}
}}
aria-label="搜索帖子"
placeholder="搜索帖子..."
className="j13-search-field w-full pl-10 pr-4 py-3 rounded-full text-sm"
/>
</form>
</div>
)}
</header>
);
}
function NavPill({
href,
active,
icon,
children,
}: {
href: string;
active?: boolean;
icon?: React.ReactNode;
children: React.ReactNode;
}) {
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)" }
}
>
{icon}
{children}
</Link>
);
}
/**
* 用户菜单(两端可用):头像触发器,下拉承载通知中心/个人主页/设置/登出,
* 把零散的图标按钮收拢为一个语义明确的控件;移动端只显示头像
*/
function UserMenu({ user, onLogout }: { user: User; onLogout: () => void }) {
const [open, setOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const onDown = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) setOpen(false);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
document.addEventListener("mousedown", onDown);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onDown);
document.removeEventListener("keydown", onKey);
};
}, [open]);
const itemCls =
"w-full flex items-center gap-2.5 px-4 py-2.5 text-[13.5px] transition-colors hover:bg-[var(--panel-2)] focus-visible:bg-[var(--panel-2)]";
return (
<div className="relative" ref={menuRef}>
<button
onClick={() => setOpen((v) => !v)}
aria-haspopup="menu"
aria-expanded={open}
aria-label="用户菜单"
className="flex items-center gap-2 rounded-full p-1 lg:pl-1 lg:pr-2.5 transition-colors"
style={{
border: "1px solid " + (open ? "var(--accent)" : "var(--line)"),
background: open ? "var(--accent-soft)" : "var(--panel)",
}}
>
<Avatar name={user.nickname || user.username} src={user.avatar || undefined} size={28} />
<span
className="hidden lg:inline text-[13px] max-w-[90px] truncate"
style={{ color: open ? "var(--accent)" : "var(--ink)" }}
>
{user.nickname}
</span>
<ChevronDown
size={14}
className="hidden lg:block transition-transform duration-200"
style={{
color: "var(--ink-3)",
transform: open ? "rotate(180deg)" : "none",
}}
/>
</button>
{open && (
<div
role="menu"
className="absolute right-0 mt-2.5 w-60 overflow-hidden z-50 j13-toast-in"
style={{
background: "var(--panel)",
border: "1px solid var(--line)",
borderRadius: "var(--r)",
boxShadow: "var(--shadow-lg)",
}}
>
<div
className="flex items-center gap-3 px-4 py-3.5"
style={{ borderBottom: "1px solid var(--line)" }}
>
<Avatar name={user.nickname || user.username} src={user.avatar || undefined} size={38} />
<div className="min-w-0">
<p className="text-[14px] truncate flex items-center gap-1.5" style={{ color: "var(--ink)" }}>
<span className="truncate">{user.nickname}</span>
<LevelBadge level={user.level} />
{(user.badges ?? []).slice(0, 2).map((ub) => (
<BadgeChip key={ub.id} badge={ub.badge} />
))}
</p>
<p className="meta text-xs truncate">@ {user.username}</p>
</div>
</div>
<div className="p-1.5">
<Link href="/notifications" onClick={() => setOpen(false)} className={itemCls} role="menuitem">
<Bell size={15} style={{ color: "var(--ink-3)" }} /> 通知中心
</Link>
<Link href={`/u/${user.id}`} onClick={() => setOpen(false)} className={itemCls} role="menuitem">
<UserIcon size={15} style={{ color: "var(--ink-3)" }} /> 个人主页
</Link>
<Link href="/settings" onClick={() => setOpen(false)} className={itemCls} role="menuitem">
<Settings size={15} style={{ color: "var(--ink-3)" }} /> 账号设置
</Link>
</div>
{(isStaff(user.role) || canAccessAdminMessages(user)) && (
<div className="p-1.5" style={{ borderTop: "1px solid var(--line)" }}>
<Link href="/admin" onClick={() => setOpen(false)} className={itemCls} role="menuitem">
<ShieldCheck size={15} style={{ color: "var(--accent)" }} /> 管理面板
</Link>
</div>
)}
<div className="p-1.5" style={{ borderTop: "1px solid var(--line)" }}>
<button
onClick={() => {
setOpen(false);
onLogout();
}}
className={itemCls}
role="menuitem"
style={{ color: "var(--danger)" }}
>
<LogOut size={15} /> 退出登录
</button>
</div>
</div>
)}
</div>
);
}