feat: 新增站点公告系统与多项体验优化

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

View File

@@ -18,8 +18,10 @@ import {
Home,
User as UserIcon,
ChevronDown,
Megaphone,
Layers,
} from "lucide-react";
import { apiLogout, apiMe, type User } from "@/lib/api";
import { apiLogout, apiMe, type User, type Board } from "@/lib/api";
import { hasAuthCookieHint } from "@/lib/cookies";
import NotificationBell from "./NotificationBell";
import Avatar from "./Avatar";
@@ -28,10 +30,12 @@ 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();
@@ -167,44 +171,46 @@ export default function Header({
<NavPill href="/" active={pathname === "/"} icon={<Home size={14} />}>
首页
</NavPill>
<BoardsMenu boards={boards} />
</nav>
</div>
{/* 中组:搜索居中 */}
<form onSubmit={handleSearch} className="hidden lg:flex flex-1 justify-center">
<div className="relative w-full max-w-[380px] focus-within:max-w-[440px] transition-[max-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>
{/* 中部留白:为后续主导航菜单项预留位置 */}
<div className="hidden lg:block flex-1" aria-hidden />
{/* 右侧操作区:桌面/移动共用铃铛(仅挂载一次,避免重复轮询),
桌面额外显示主题/发帖/用户菜单,移动端收进汉堡抽屉 */}
<div className="flex items-center gap-2">
{/* 右侧操作区:搜索靠右停靠 + 桌面/移动共用铃铛(仅挂载一次,避免重复轮询),
桌面额外显示主题/发帖/用户菜单,移动端搜索收进汉堡抽屉 */}
<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`}
@@ -271,9 +277,47 @@ export default function Header({
</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} />}
@@ -318,6 +362,96 @@ function NavPill({
);
}
/**
* 板块下拉菜单(桌面端):全站任意页面的 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,
@@ -428,6 +562,13 @@ function UserMenu({ user, onLogout }: { user: User; onLogout: () => void }) {
<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={() => {