301 lines
9.0 KiB
TypeScript
301 lines
9.0 KiB
TypeScript
"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,
|
|
Coins,
|
|
Globe,
|
|
FolderOpen,
|
|
} 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 { onDocumentVisible } from "@/lib/visibilityReconcile";
|
|
import Avatar from "@/components/Avatar";
|
|
import RoleBadge from "@/components/RoleBadge";
|
|
|
|
interface NavItem {
|
|
href: string;
|
|
label: string;
|
|
icon: LucideIcon;
|
|
exact?: boolean;
|
|
visible: (u: RoleUserLike) => boolean;
|
|
pendingBadge?: boolean;
|
|
}
|
|
|
|
interface NavGroup {
|
|
title: string;
|
|
items: NavItem[];
|
|
}
|
|
|
|
// 分组导航:概览 → 内容运营 → 成员经济 → 站点配置,便于后期在组内扩展
|
|
const NAV_GROUPS: NavGroup[] = [
|
|
{
|
|
title: "概览",
|
|
items: [
|
|
{ href: "/admin", label: "仪表盘", icon: LayoutDashboard, exact: true, visible: () => true },
|
|
],
|
|
},
|
|
{
|
|
title: "内容",
|
|
items: [
|
|
{
|
|
href: "/admin/content",
|
|
label: "内容管理",
|
|
icon: FolderOpen,
|
|
visible: (u) => canModerateAny(u),
|
|
pendingBadge: true,
|
|
},
|
|
{
|
|
href: "/admin/announcements",
|
|
label: "公告管理",
|
|
icon: Megaphone,
|
|
visible: (u) => isAdminOrAbove(u.role),
|
|
},
|
|
],
|
|
},
|
|
{
|
|
title: "成员与经济",
|
|
items: [
|
|
{
|
|
href: "/admin/users",
|
|
label: "用户权限",
|
|
icon: Users,
|
|
visible: (u) => isSuperOrOwner(u.role),
|
|
},
|
|
{
|
|
href: "/admin/economy",
|
|
label: "积分看板",
|
|
icon: Coins,
|
|
visible: (u) => isAdminOrAbove(u.role),
|
|
},
|
|
],
|
|
},
|
|
{
|
|
title: "站点",
|
|
items: [
|
|
{
|
|
href: "/admin/settings",
|
|
label: "站点设置",
|
|
icon: Globe,
|
|
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}/`);
|
|
}
|
|
|
|
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 offVisible = onDocumentVisible(load);
|
|
return () => {
|
|
alive = false;
|
|
off();
|
|
offVisible();
|
|
};
|
|
}, [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-medium tabular-nums"
|
|
style={{ background: "var(--gold-soft)", color: "var(--gold)" }}
|
|
aria-label={`${count} 条待审核`}
|
|
>
|
|
{count > 99 ? "99+" : count}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function visibleGroups(user: RoleUserLike) {
|
|
return NAV_GROUPS.map((g) => ({
|
|
...g,
|
|
items: g.items.filter((i) => i.visible(user)),
|
|
})).filter((g) => g.items.length > 0);
|
|
}
|
|
|
|
function flatItems(user: RoleUserLike) {
|
|
return visibleGroups(user).flatMap((g) => g.items);
|
|
}
|
|
|
|
export function AdminSidebar({ user }: { user: User }) {
|
|
const pathname = usePathname();
|
|
const groups = visibleGroups(user);
|
|
const pendingTotal = usePendingCounts(flatItems(user).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] mt-0.5 tracking-wide" style={{ color: "var(--ink-3)" }}>
|
|
J13 ADMIN
|
|
</span>
|
|
</span>
|
|
</div>
|
|
|
|
<nav className="flex flex-col gap-3 pt-3 pb-2" aria-label="后台导航">
|
|
{groups.map((group) => (
|
|
<div key={group.title}>
|
|
<p className="field-label px-2 pb-1.5" style={{ marginBottom: 0 }}>
|
|
{group.title}
|
|
</p>
|
|
<div className="flex flex-col gap-1">
|
|
{group.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-medium 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>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</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] 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-medium truncate" style={{ color: "var(--ink)" }}>
|
|
{user.nickname}
|
|
</p>
|
|
<p className="text-[11px] 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>
|
|
);
|
|
}
|
|
|
|
export function AdminNavMobile({ user }: { user: User }) {
|
|
const pathname = usePathname();
|
|
const items = flatItems(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="admin-seg shrink-0"
|
|
style={
|
|
active
|
|
? undefined
|
|
: { background: "var(--panel)", border: "1px solid var(--line)" }
|
|
}
|
|
>
|
|
<Icon size={14} />
|
|
{item.label}
|
|
{item.pendingBadge && pendingTotal > 0 && (
|
|
<span className="admin-seg-count">{pendingTotal > 99 ? "99+" : pendingTotal}</span>
|
|
)}
|
|
</Link>
|
|
);
|
|
})}
|
|
<Link
|
|
href="/"
|
|
className="admin-seg shrink-0"
|
|
style={{ background: "var(--panel)", border: "1px solid var(--line)" }}
|
|
>
|
|
<ArrowLeft size={14} />
|
|
返回站点
|
|
</Link>
|
|
</nav>
|
|
</div>
|
|
);
|
|
}
|