Files
jiang13-bbs/frontend/components/Header.tsx
freefire 996a870015 feat: 新增站点公告系统与多项体验优化
此提交完成了以下核心功能与优化:
1. 新增站点公告全流程支持:包含公告模型、CRUD接口、管理后台页面、公告详情页与首页展示
2. 实现评论楼层化与回复嵌套:重构评论系统为树状结构,支持回复评论与层级限制
3. 添加用户在线状态追踪:异步更新用户最后活跃时间并做SQL限频
4. 优化用户体验:移动端浮动操作条、统一板块标签样式、用户页签到积分展示、注册即登录优化
5. 其他细节优化:SEO站点地图新增公告页、评论分页与统计、登录/注册表单增强等
2026-09-13 21:38:01 +08:00

590 lines
21 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 {
Menu,
X,
Sun,
Moon,
LogIn,
UserPlus,
LogOut,
PenSquare,
Search,
Settings,
Bell,
Home,
User as UserIcon,
ChevronDown,
Megaphone,
Layers,
} from "lucide-react";
import { apiLogout, apiMe, type User, type Board } from "@/lib/api";
import { hasAuthCookieHint } from "@/lib/cookies";
import NotificationBell from "./NotificationBell";
import Avatar from "./Avatar";
export default function Header({
initialUser,
initialUnread = 0,
initialTheme = "light",
boards = [],
}: {
initialUser: User | null;
initialUnread?: number;
initialTheme?: "light" | "dark";
boards?: Board[];
}) {
const router = useRouter();
const pathname = usePathname();
const [menuOpen, setMenuOpen] = useState(false);
const [theme, setTheme] = useState<"light" | "dark">(initialTheme);
// 登录态以 SSR 直出为初始值,F5 无切换闪动;router.refresh() 后随 props 校正
const [user, setUser] = useState<User | null>(initialUser);
const [unread, setUnread] = useState(initialUnread);
const [searchQuery, setSearchQuery] = useState("");
const searchInputRef = useRef<HTMLInputElement>(null);
// 登录/登出触发 router.refresh() 后,服务端 layout 重新取态并下发新 props
useEffect(() => {
setUser(initialUser);
}, [initialUser]);
useEffect(() => {
setUnread(initialUnread);
}, [initialUnread]);
// 挂载后静默校正一次(仅一次):覆盖 SSR 期间后端不可用、旧 Path refresh
// 半登录等边缘情况。正常 F5 下结果与 SSR 一致,不产生可见闪动;
// 无可读 CSRF cookie 说明必然是游客,跳过请求
useEffect(() => {
if (!hasAuthCookieHint()) return;
apiMe()
.then((res) => {
setUser(res.user);
if (typeof res.unread_count === "number") setUnread(res.unread_count);
})
.catch(() => {});
}, []);
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
const q = searchQuery.trim();
router.push(q ? `/?q=${encodeURIComponent(q)}` : "/");
setMenuOpen(false);
searchInputRef.current?.blur();
};
useEffect(() => {
// cookie 为单一真相源,客户端导航后校正(预取载荷可能回滚类名)
const isDark = document.cookie.match(/(?:^|;\s*)j13-theme=dark/);
const savedTheme: "light" | "dark" = isDark ? "dark" : "light";
document.documentElement.classList.toggle("dark", savedTheme === "dark");
setTheme(savedTheme);
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 toggleTheme = () => {
const newTheme = theme === "dark" ? "light" : "dark";
setTheme(newTheme);
document.documentElement.classList.toggle("dark", newTheme === "dark");
document.cookie = `j13-theme=${newTheme}; path=/; max-age=31536000`;
};
const handleLogout = async () => {
try {
await apiLogout();
} catch {
/* ignore */
}
setUser(null);
setMenuOpen(false);
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: "color-mix(in srgb, var(--bg) 82%, transparent)",
backdropFilter: "blur(14px)",
WebkitBackdropFilter: "blur(14px)",
}}
>
<div className="max-w-[1440px] mx-auto px-4 sm:px-6 h-[68px] 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 gap-2.5 group">
<span
className="w-9 h-9 rounded-2xl flex items-center justify-center transition-transform duration-300 group-hover:rotate-[-6deg]"
style={{ background: "var(--accent)", color: "var(--accent-on)" }}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z" />
</svg>
</span>
<span className="leading-none">
<span className="block font-extrabold text-[17px] tracking-tight" style={{ color: "var(--ink)" }}>
姜十三
</span>
<span className="block text-[10.5px] font-semibold mt-0.5" style={{ color: "var(--ink-3)" }}>
J13 社区
</span>
</span>
</Link>
<nav className="hidden lg:flex items-center gap-1 pl-3" style={{ borderLeft: "1px solid var(--line)" }}>
<NavPill href="/" active={pathname === "/"} icon={<Home size={14} />}>
首页
</NavPill>
<BoardsMenu boards={boards} />
</nav>
</div>
{/* 中部留白:为后续主导航菜单项预留位置 */}
<div className="hidden lg:block flex-1" aria-hidden />
{/* 右侧操作区:搜索靠右停靠 + 桌面/移动共用铃铛(仅挂载一次,避免重复轮询),
桌面额外显示主题/发帖/用户菜单,移动端搜索收进汉堡抽屉 */}
<div className="flex items-center gap-2 lg:gap-3">
<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="w-full pl-10 pr-10 py-2.5 text-sm rounded-full"
style={{
background: "var(--panel)",
color: "var(--ink)",
border: "1px solid var(--line)",
}}
/>
<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>
<button
onClick={toggleTheme}
className={`${iconBtn} hidden lg:inline-flex`}
style={iconBtnStyle}
aria-label="切换主题"
>
{theme === "dark" ? <Sun size={17} /> : <Moon size={17} />}
</button>
{user && <NotificationBell initialUnread={unread} />}
{user ? (
<>
<Link href="/compose" className="btn btn-primary btn-sm hidden lg:inline-flex">
<PenSquare size={14} /> 发帖
</Link>
<UserMenu user={user} onLogout={handleLogout} />
</>
) : (
<div className="hidden lg: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">
<UserPlus size={14} /> 注册
</Link>
</div>
)}
<button
onClick={() => setMenuOpen(!menuOpen)}
className="w-10 h-10 flex items-center justify-center rounded-full lg:hidden"
style={{ border: "1px solid var(--line)", background: "var(--panel)", color: "var(--ink)" }}
aria-label="菜单"
aria-expanded={menuOpen}
>
{menuOpen ? <X size={19} /> : <Menu size={19} />}
</button>
</div>
</div>
{/* 移动端抽屉 */}
{menuOpen && (
<div
className="lg:hidden border-t px-4 py-4"
style={{ borderColor: "var(--line)", background: "var(--bg)" }}
>
<form onSubmit={handleSearch} className="relative mb-3">
<Search size={16} className="absolute left-4 top-1/2 -translate-y-1/2" style={{ color: "var(--ink-3)" }} />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={onSearchKeyDown}
aria-label="搜索帖子"
placeholder="搜索帖子...(Esc 清除)"
className="w-full pl-10 pr-4 py-3 rounded-full text-sm"
style={{ background: "var(--panel)", color: "var(--ink)", border: "1px solid var(--line-2)" }}
/>
</form>
<div className="flex flex-col">
<MobileLink href="/" active={pathname === "/"}>首页</MobileLink>
{user && <MobileLink href="/compose">发帖</MobileLink>}
{user && (
<MobileLink href="/notifications">
<span className="inline-flex items-center gap-2"><Bell size={15} /> 通知中心</span>
</MobileLink>
)}
{user && <MobileLink href="/settings">账号设置</MobileLink>}
{user?.role === "admin" && (
<MobileLink href="/admin/announcements">
<span className="inline-flex items-center gap-2"><Megaphone size={15} /> 公告管理</span>
</MobileLink>
)}
{!user && <MobileLink href="/login">登录</MobileLink>}
{!user && <MobileLink href="/register">注册</MobileLink>}
</div>
{/* 全站板块入口:手机用户在任意页面都能切板块,不必回首页 */}
{boards.length > 0 && (
<div className="mt-3 pt-3 border-t" style={{ borderColor: "var(--line)" }}>
<p className="px-3 mb-1.5 text-[11.5px] font-bold tracking-[0.08em] uppercase" style={{ color: "var(--ink-3)" }}>
板块
</p>
<div className="grid grid-cols-2 gap-1">
{boards.map((b, i) => {
const active = pathname === `/board/${b.id}`;
return (
<Link
key={b.id}
href={`/board/${b.id}`}
onClick={() => setMenuOpen(false)}
aria-current={active ? "page" : undefined}
className="flex items-center gap-2 px-3 py-2.5 rounded-xl text-[13.5px] font-semibold transition-colors"
style={{
color: active ? "var(--accent)" : "var(--ink)",
background: active ? "var(--accent-soft)" : "transparent",
}}
>
<span
className={`av-${Math.max(0, b.color_index ?? i) % 8} w-6 h-6 rounded-lg flex items-center justify-center text-[11px] font-bold shrink-0`}
>
{b.name.slice(0, 1)}
</span>
<span className="truncate">{b.name}</span>
</Link>
);
})}
</div>
</div>
)}
<div className="pt-3 mt-2 border-t flex items-center justify-between" style={{ borderColor: "var(--line)" }}>
<button onClick={toggleTheme} className="btn btn-line btn-sm">
{theme === "dark" ? <Sun size={14} /> : <Moon size={14} />}
{theme === "dark" ? "浅色模式" : "深色模式"}
</button>
{user && (
<button onClick={handleLogout} className="btn btn-danger btn-sm">
<LogOut size={14} /> 退出
</button>
)}
</div>
</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-[13.5px] font-semibold transition-colors"
style={
active
? { background: "var(--accent-soft)", color: "var(--accent)" }
: { color: "var(--ink-2)" }
}
>
{icon}
{children}
</Link>
);
}
/**
* 板块下拉菜单(桌面端):全站任意页面的 Header 都能直达板块,
* 替代原先只有首页左栏才有板块导航的布局局限
*/
function BoardsMenu({ boards }: { boards: Board[] }) {
const pathname = usePathname();
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]);
if (boards.length === 0) return null;
const active = pathname.startsWith("/board/");
return (
<div className="relative" ref={menuRef}>
<button
onClick={() => setOpen((v) => !v)}
aria-haspopup="menu"
aria-expanded={open}
className="inline-flex items-center gap-1.5 px-3.5 py-2 rounded-full text-[13.5px] font-semibold transition-colors"
style={
active || open
? { background: "var(--accent-soft)", color: "var(--accent)" }
: { color: "var(--ink-2)" }
}
>
<Layers size={14} />
板块
<ChevronDown
size={12}
className="transition-transform duration-200"
style={{ transform: open ? "rotate(180deg)" : "none" }}
/>
</button>
{open && (
<div
role="menu"
className="absolute left-0 mt-2.5 w-60 max-h-[60vh] overflow-y-auto p-1.5 z-50 j13-toast-in"
style={{
background: "var(--panel)",
border: "1px solid var(--line)",
borderRadius: "var(--r)",
boxShadow: "var(--shadow-lg)",
}}
>
{boards.map((b, i) => {
const isActive = pathname === `/board/${b.id}`;
return (
<Link
key={b.id}
href={`/board/${b.id}`}
onClick={() => setOpen(false)}
role="menuitem"
className="flex items-center gap-2.5 px-3 py-2.5 rounded-xl text-[13.5px] font-semibold transition-colors hover:bg-[var(--panel-2)]"
style={{
color: isActive ? "var(--accent)" : "var(--ink)",
background: isActive ? "var(--nav-active-bg)" : undefined,
}}
>
<span
className={`av-${Math.max(0, b.color_index ?? i) % 8} w-7 h-7 rounded-lg flex items-center justify-center text-[12px] font-bold shrink-0`}
>
{b.name.slice(0, 1)}
</span>
<span className="flex-1 truncate">{b.name}</span>
</Link>
);
})}
</div>
)}
</div>
);
}
function MobileLink({
href,
active,
children,
}: {
href: string;
active?: boolean;
children: React.ReactNode;
}) {
return (
<Link
href={href}
className="px-3 py-3 rounded-2xl text-[15px] font-semibold transition-colors"
style={{
color: active ? "var(--accent)" : "var(--ink)",
background: active ? "var(--accent-soft)" : "transparent",
}}
>
{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] font-semibold transition-colors hover:bg-[var(--panel-2)]";
return (
<div className="relative hidden lg:block" ref={menuRef}>
<button
onClick={() => setOpen((v) => !v)}
aria-haspopup="menu"
aria-expanded={open}
className="flex items-center gap-2 rounded-full pl-1 pr-2.5 py-1 transition-colors"
style={{
border: "1px solid " + (open ? "var(--accent)" : "var(--line)"),
background: open ? "var(--accent-soft)" : "var(--panel)",
}}
>
<Avatar name={user.nickname || user.username} size={28} />
<span
className="text-[13px] font-semibold max-w-[90px] truncate"
style={{ color: open ? "var(--accent)" : "var(--ink)" }}
>
{user.nickname}
</span>
<ChevronDown
size={14}
className="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} size={38} />
<div className="min-w-0">
<p className="text-[14px] font-bold truncate" style={{ color: "var(--ink)" }}>
{user.nickname}
</p>
<p className="meta text-xs truncate">@ {user.username}</p>
</div>
</div>
<div className="p-1.5">
<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>
{user.role === "admin" && (
<div className="p-1.5" style={{ borderTop: "1px solid var(--line)" }}>
<Link href="/admin/announcements" onClick={() => setOpen(false)} className={itemCls} role="menuitem">
<Megaphone 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>
);
}