Files
jiang13-bbs/frontend/app/admin/AdminNav.tsx
freefire 42c395a864 feat: 实时消息与管理端运营能力
新增群聊/私信与公共 WebSocket 总线,站长用户内容档案,并将通知与待审角标改为推送驱动;同步精简管理仪表盘并修复主题换肤 DOM 冲突。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-15 22:58:16 +08:00

264 lines
9.1 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 } from "next/navigation";
import { useEffect, useState } from "react";
import type { LucideIcon } from "lucide-react";
import {
LayoutDashboard,
Users,
Megaphone,
Palette,
ShieldCheck,
ArrowLeft,
ClipboardCheck,
} from "lucide-react";
import type { User } from "@/lib/api";
import { apiAdminPendingCounts } from "@/lib/api";
import {
canModerateAny,
isAdminOrAbove,
isSuperOrOwner,
type RoleUserLike,
} from "@/lib/roles";
import { realtime, RT_MODERATION_CHANGED } from "@/lib/realtime";
import Avatar from "@/components/Avatar";
import RoleBadge from "@/components/RoleBadge";
interface NavItem {
href: string;
label: string;
icon: LucideIcon;
exact?: boolean;
/** 按 /me 角色过滤可见性;最终权限以后端路由守卫为准 */
visible: (u: RoleUserLike) => boolean;
/** 是否携带待审核角标 */
pendingBadge?: boolean;
}
// 后台导航项:exact 为精确匹配(仪表盘 /admin),其余按路径前缀匹配
const NAV_ITEMS: NavItem[] = [
{ href: "/admin", label: "仪表盘", icon: LayoutDashboard, exact: true, visible: () => true },
{
href: "/admin/moderation",
label: "内容审核",
icon: ClipboardCheck,
visible: (u) => canModerateAny(u),
pendingBadge: true,
},
{
href: "/admin/announcements",
label: "公告管理",
icon: Megaphone,
visible: (u) => isAdminOrAbove(u.role),
},
{
href: "/admin/users",
label: "用户权限",
icon: Users,
visible: (u) => isSuperOrOwner(u.role),
},
{
href: "/admin/appearance",
label: "外观设置",
icon: Palette,
visible: (u) => isSuperOrOwner(u.role),
},
];
function isActive(pathname: string, href: string, exact?: boolean) {
return exact ? pathname === href : pathname === href || pathname.startsWith(`${href}/`);
}
/** 待审核数量:首屏 + WS 推送 + 回前台校准(无定时轮询) */
function usePendingCounts(enabled: boolean) {
const [counts, setCounts] = useState({ posts: 0, comments: 0 });
useEffect(() => {
if (!enabled) return;
let alive = true;
const load = () => {
apiAdminPendingCounts()
.then((c) => {
if (alive) setCounts(c);
})
.catch(() => {});
};
load();
const off = realtime.on(RT_MODERATION_CHANGED, () => load());
const onVisible = () => {
if (document.visibilityState === "visible") load();
};
document.addEventListener("visibilitychange", onVisible);
return () => {
alive = false;
off();
document.removeEventListener("visibilitychange", onVisible);
};
}, [enabled]);
return counts.posts + counts.comments;
}
function PendingBadge({ count }: { count: number }) {
if (count <= 0) return null;
return (
<span
className="ml-auto shrink-0 min-w-[20px] h-5 px-1.5 rounded-full inline-flex items-center justify-center text-[10.5px] font-extrabold tabular-nums"
style={{ background: "var(--gold-soft)", color: "var(--gold)" }}
aria-label={`${count} 条待审核`}
>
{count > 99 ? "99+" : count}
</span>
);
}
/**
* 桌面端后台侧边栏:品牌头 + 分组导航 + 返回站点 + 身份卡。
* 选中态复用全站导航语言(--nav-active-bg 淡底 + accent 文字),
* 与 j13-bcard / 顶部 pill 保持一致。
*/
export function AdminSidebar({ user }: { user: User }) {
const pathname = usePathname();
const items = NAV_ITEMS.filter((i) => i.visible(user));
const pendingTotal = usePendingCounts(items.some((i) => i.pendingBadge));
return (
<div className="panel p-3">
{/* 品牌头 */}
<div className="flex items-center gap-2.5 px-2 pt-1.5 pb-3.5" style={{ borderBottom: "1px solid var(--line)" }}>
<span
className="w-9 h-9 rounded-xl flex items-center justify-center shrink-0"
style={{ background: "var(--accent)", color: "var(--accent-on)" }}
>
<ShieldCheck size={18} />
</span>
<span className="leading-tight min-w-0">
<span className="block text-[14.5px] font-extrabold tracking-tight truncate" style={{ color: "var(--ink)" }}>
管理面板
</span>
<span className="block text-[10.5px] font-semibold mt-0.5 tracking-wide" style={{ color: "var(--ink-3)" }}>
J13 ADMIN
</span>
</span>
</div>
{/* 导航 */}
<nav className="flex flex-col gap-1 pt-3 pb-2" aria-label="后台导航">
<p className="field-label px-2 pb-1.5" style={{ marginBottom: 0 }}>
站点管理
</p>
{items.map((item) => {
const active = isActive(pathname, item.href, item.exact);
const Icon = item.icon;
return (
<Link
key={item.href}
href={item.href}
aria-current={active ? "page" : undefined}
className="flex items-center gap-2.5 px-3 py-2.5 rounded-xl text-[13.5px] font-semibold transition-colors"
style={
active
? { background: "var(--nav-active-bg)", color: "var(--nav-active-ink)" }
: { color: "var(--ink-2)" }
}
>
<Icon size={16} className="shrink-0" style={{ color: active ? "var(--nav-active-ink)" : "var(--ink-3)" }} />
{item.label}
{item.pendingBadge && <PendingBadge count={pendingTotal} />}
</Link>
);
})}
</nav>
{/* 返回前台 */}
<div className="pt-2" style={{ borderTop: "1px solid var(--line)" }}>
<Link
href="/"
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: "var(--ink-2)" }}
>
<ArrowLeft size={16} className="shrink-0" style={{ color: "var(--ink-3)" }} />
返回站点
</Link>
</div>
{/* 身份卡:按当前角色显示对应徽章(站长为皇冠标识) */}
<div className="flex items-center gap-2.5 mt-2 px-2.5 py-2.5 rounded-xl" style={{ background: "var(--panel-2)" }}>
<Avatar name={user.nickname || user.username} src={user.avatar || undefined} size={32} />
<div className="min-w-0 leading-tight">
<p className="text-[13px] font-bold truncate" style={{ color: "var(--ink)" }}>
{user.nickname}
</p>
<p className="text-[11px] font-semibold truncate" style={{ color: "var(--ink-3)" }}>
@{user.username}
</p>
</div>
<span className="ml-auto shrink-0">
<RoleBadge role={user.role} boardIds={user.board_ids} size={11} />
</span>
</div>
</div>
);
}
/**
* 移动端后台导航:横向滚动 pill,吸附在全站 Header 下方,
* 窄屏下保持与桌面侧栏同等的切换能力且不占垂直空间。
*/
export function AdminNavMobile({ user }: { user: User }) {
const pathname = usePathname();
const items = NAV_ITEMS.filter((i) => i.visible(user));
const pendingTotal = usePendingCounts(items.some((i) => i.pendingBadge));
return (
<div
className="lg:hidden sticky z-30 -mx-4 sm:-mx-6 px-4 sm:px-6 py-2.5 mb-4"
style={{
top: 68,
background: "color-mix(in srgb, var(--bg) 88%, transparent)",
backdropFilter: "blur(14px)",
WebkitBackdropFilter: "blur(14px)",
borderBottom: "1px solid var(--line)",
}}
>
<nav className="flex items-center gap-2 overflow-x-auto" aria-label="后台导航">
{items.map((item) => {
const active = isActive(pathname, item.href, item.exact);
const Icon = item.icon;
return (
<Link
key={item.href}
href={item.href}
aria-current={active ? "page" : undefined}
className="shrink-0 inline-flex items-center gap-1.5 px-3.5 py-2 rounded-full text-[13px] font-semibold transition-colors"
style={
active
? { background: "var(--nav-active-bg)", color: "var(--nav-active-ink)" }
: { color: "var(--ink-2)", background: "var(--panel)", border: "1px solid var(--line)" }
}
>
<Icon size={14} />
{item.label}
{item.pendingBadge && pendingTotal > 0 && (
<span
className="min-w-[18px] h-[18px] px-1 rounded-full inline-flex items-center justify-center text-[10px] font-extrabold tabular-nums"
style={{ background: "var(--gold-soft)", color: "var(--gold)" }}
>
{pendingTotal > 99 ? "99+" : pendingTotal}
</span>
)}
</Link>
);
})}
<Link
href="/"
className="shrink-0 inline-flex items-center gap-1.5 px-3.5 py-2 rounded-full text-[13px] font-semibold transition-colors"
style={{ color: "var(--ink-2)", background: "var(--panel)", border: "1px solid var(--line)" }}
>
<ArrowLeft size={14} />
返回站点
</Link>
</nav>
</div>
);
}