Files
jiang13-bbs/frontend/app/admin/AdminNav.tsx
freefire 5507552ca5 polish(frontend): 侧栏与徽章可读性,智能悬停提示
降权全站字重;优化右栏公告/数据块;类型状态徽章用固定浅色区分;TipHost 仅在文案不一致、非文字或省略时显示。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-16 03:54:14 +08:00

269 lines
9.0 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,
Coins,
} 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;
/** 按 /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/economy",
label: "积分看板",
icon: Coins,
visible: (u) => isAdminOrAbove(u.role),
},
{
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 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>
);
}
/**
* 桌面端后台侧边栏:品牌头 + 分组导航 + 返回站点 + 身份卡。
* 选中态复用全站导航语言(--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] 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] 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] 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] 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>
);
}
/**
* 移动端后台导航:横向滚动 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] 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-medium 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] transition-colors"
style={{ color: "var(--ink-2)", background: "var(--panel)", border: "1px solid var(--line)" }}
>
<ArrowLeft size={14} />
返回站点
</Link>
</nav>
</div>
);
}