feat(admin): 完善站点设置、内容回收站与后台菜单分组
新增站点设置与内容管理(含已删除恢复/清除),用户页改用独有运营指标,并按概览/内容/成员与经济/站点重组侧栏。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -13,6 +13,8 @@ import {
|
||||
ArrowLeft,
|
||||
ClipboardCheck,
|
||||
Coins,
|
||||
Globe,
|
||||
FolderOpen,
|
||||
} from "lucide-react";
|
||||
import type { User } from "@/lib/api";
|
||||
import { apiAdminPendingCounts } from "@/lib/api";
|
||||
@@ -32,45 +34,80 @@ interface NavItem {
|
||||
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 },
|
||||
interface NavGroup {
|
||||
title: string;
|
||||
items: NavItem[];
|
||||
}
|
||||
|
||||
// 分组导航:概览 → 内容运营 → 成员经济 → 站点配置,便于后期在组内扩展
|
||||
const NAV_GROUPS: NavGroup[] = [
|
||||
{
|
||||
href: "/admin/moderation",
|
||||
label: "内容审核",
|
||||
icon: ClipboardCheck,
|
||||
visible: (u) => canModerateAny(u),
|
||||
pendingBadge: true,
|
||||
title: "概览",
|
||||
items: [
|
||||
{ href: "/admin", label: "仪表盘", icon: LayoutDashboard, exact: true, visible: () => true },
|
||||
],
|
||||
},
|
||||
{
|
||||
href: "/admin/economy",
|
||||
label: "积分看板",
|
||||
icon: Coins,
|
||||
visible: (u) => isAdminOrAbove(u.role),
|
||||
title: "内容",
|
||||
items: [
|
||||
{
|
||||
href: "/admin/moderation",
|
||||
label: "内容审核",
|
||||
icon: ClipboardCheck,
|
||||
visible: (u) => canModerateAny(u),
|
||||
pendingBadge: true,
|
||||
},
|
||||
{
|
||||
href: "/admin/content",
|
||||
label: "内容管理",
|
||||
icon: FolderOpen,
|
||||
visible: (u) => canModerateAny(u),
|
||||
},
|
||||
{
|
||||
href: "/admin/announcements",
|
||||
label: "公告管理",
|
||||
icon: Megaphone,
|
||||
visible: (u) => isAdminOrAbove(u.role),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
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),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
href: "/admin/users",
|
||||
label: "用户权限",
|
||||
icon: Users,
|
||||
visible: (u) => isSuperOrOwner(u.role),
|
||||
},
|
||||
{
|
||||
href: "/admin/appearance",
|
||||
label: "外观设置",
|
||||
icon: Palette,
|
||||
visible: (u) => isSuperOrOwner(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),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -78,7 +115,6 @@ 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(() => {
|
||||
@@ -116,19 +152,24 @@ function PendingBadge({ count }: { count: number }) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 桌面端后台侧边栏:品牌头 + 分组导航 + 返回站点 + 身份卡。
|
||||
* 选中态复用全站导航语言(--nav-active-bg 淡底 + accent 文字),
|
||||
* 与 j13-bcard / 顶部 pill 保持一致。
|
||||
*/
|
||||
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 items = NAV_ITEMS.filter((i) => i.visible(user));
|
||||
const pendingTotal = usePendingCounts(items.some((i) => i.pendingBadge));
|
||||
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"
|
||||
@@ -146,35 +187,39 @@ export function AdminSidebar({ user }: { user: User }) {
|
||||
</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-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>
|
||||
);
|
||||
})}
|
||||
<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="/"
|
||||
@@ -186,7 +231,6 @@ export function AdminSidebar({ user }: { user: User }) {
|
||||
</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">
|
||||
@@ -205,13 +249,9 @@ export function AdminSidebar({ user }: { user: User }) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 移动端后台导航:横向滚动 pill,吸附在全站 Header 下方,
|
||||
* 窄屏下保持与桌面侧栏同等的切换能力且不占垂直空间。
|
||||
*/
|
||||
export function AdminNavMobile({ user }: { user: User }) {
|
||||
const pathname = usePathname();
|
||||
const items = NAV_ITEMS.filter((i) => i.visible(user));
|
||||
const items = flatItems(user);
|
||||
const pendingTotal = usePendingCounts(items.some((i) => i.pendingBadge));
|
||||
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user