feat: 用户徽章与等级体系,积分经济管理重构
- 新增徽章系统:badge 服务与 handler、管理端配置页、BadgeChip/LevelBadge 展示组件 - 新增等级模型:model/level 及前端 levels 元数据 - 管理端经济模块拆分为 points/badges/levels 独立页面,EconomySettings 迁入设置 - 积分、签到、评论等服务适配等级与徽章逻辑
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useRef, useState, useCallback } from "react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import {
|
||||
LayoutDashboard,
|
||||
@@ -26,6 +26,8 @@ import {
|
||||
Columns3,
|
||||
PanelLeft,
|
||||
BarChart3,
|
||||
Award,
|
||||
Layers,
|
||||
} from "lucide-react";
|
||||
import type { User } from "@/lib/api";
|
||||
import { apiAdminPendingCounts, apiLogout } from "@/lib/api";
|
||||
@@ -115,15 +117,27 @@ const NAV_GROUPS: NavGroup[] = [
|
||||
visible: (u) => isSuperOrOwner(u.role),
|
||||
},
|
||||
{
|
||||
href: "/admin/economy",
|
||||
label: "积分看板",
|
||||
href: "/admin/points",
|
||||
label: "积分管理",
|
||||
icon: Coins,
|
||||
visible: (u) => isAdminOrAbove(u.role),
|
||||
},
|
||||
{
|
||||
href: "/admin/levels",
|
||||
label: "等级管理",
|
||||
icon: Layers,
|
||||
visible: (u) => isSuperOrOwner(u.role),
|
||||
},
|
||||
{
|
||||
href: "/admin/badges",
|
||||
label: "徽章管理",
|
||||
icon: Award,
|
||||
visible: (u) => isSuperOrOwner(u.role),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "板块",
|
||||
title: "站点",
|
||||
items: [
|
||||
{
|
||||
href: "/admin/boards",
|
||||
@@ -131,11 +145,6 @@ const NAV_GROUPS: NavGroup[] = [
|
||||
icon: Columns3,
|
||||
visible: (u) => isOwner(u.role),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "站点",
|
||||
items: [
|
||||
{
|
||||
href: "/admin/settings",
|
||||
label: "站点设置",
|
||||
@@ -401,9 +410,24 @@ export default function AdminShell({
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const [user, setUser] = useState(initialUser);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
// 移动抽屉三态:closing 期间播放退场动画,结束后卸载
|
||||
const [drawer, setDrawer] = useState<"closed" | "open" | "closing">("closed");
|
||||
const drawerCloseBtnRef = useRef<HTMLButtonElement>(null);
|
||||
const menuBtnRef = useRef<HTMLButtonElement>(null);
|
||||
const wasDrawerOpenRef = useRef(false);
|
||||
const brand = siteName.trim() || "姜十三论坛";
|
||||
|
||||
const drawerOpen = drawer !== "closed";
|
||||
|
||||
const closeDrawer = useCallback(() => {
|
||||
setDrawer((prev) => {
|
||||
if (prev !== "open") return prev;
|
||||
// 减少动态偏好:跳过退场动画直接卸载
|
||||
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return "closed";
|
||||
return "closing";
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setUser(initialUser);
|
||||
}, [initialUser]);
|
||||
@@ -413,7 +437,7 @@ export default function AdminShell({
|
||||
}), []);
|
||||
|
||||
useEffect(() => onForceLogout(() => {
|
||||
setDrawerOpen(false);
|
||||
setDrawer("closed");
|
||||
apiLogout()
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
@@ -424,18 +448,36 @@ export default function AdminShell({
|
||||
|
||||
// 路由切换时关闭移动抽屉
|
||||
useEffect(() => {
|
||||
setDrawerOpen(false);
|
||||
}, [pathname]);
|
||||
closeDrawer();
|
||||
}, [pathname, closeDrawer]);
|
||||
|
||||
// 抽屉打开时锁 body 滚(壳层本身已 overflow:hidden,这里防焦点穿透)
|
||||
// 退场动画结束后卸载;timeout 兜底防 animationend 丢失
|
||||
useEffect(() => {
|
||||
if (!drawerOpen) return;
|
||||
if (drawer !== "closing") return;
|
||||
const t = setTimeout(() => setDrawer("closed"), 240);
|
||||
return () => clearTimeout(t);
|
||||
}, [drawer]);
|
||||
|
||||
// 抽屉打开时聚焦关闭按钮;关闭后焦点归还顶栏汉堡按钮
|
||||
useEffect(() => {
|
||||
if (drawer === "open") {
|
||||
wasDrawerOpenRef.current = true;
|
||||
drawerCloseBtnRef.current?.focus();
|
||||
} else if (drawer === "closed" && wasDrawerOpenRef.current) {
|
||||
wasDrawerOpenRef.current = false;
|
||||
menuBtnRef.current?.focus();
|
||||
}
|
||||
}, [drawer]);
|
||||
|
||||
// 抽屉打开时监听 Esc 关闭
|
||||
useEffect(() => {
|
||||
if (drawer !== "open") return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setDrawerOpen(false);
|
||||
if (e.key === "Escape") closeDrawer();
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [drawerOpen]);
|
||||
}, [drawer, closeDrawer]);
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
@@ -458,7 +500,7 @@ export default function AdminShell({
|
||||
const railInner = (
|
||||
<>
|
||||
<AdminBrand siteName={brand} />
|
||||
<AdminRailNav user={user} onNavigate={() => setDrawerOpen(false)} />
|
||||
<AdminRailNav user={user} onNavigate={closeDrawer} />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -470,28 +512,34 @@ export default function AdminShell({
|
||||
</aside>
|
||||
|
||||
{/* 移动抽屉 */}
|
||||
{drawerOpen && (
|
||||
<div className="admin-drawer lg:hidden" role="dialog" aria-modal="true" aria-label="后台导航">
|
||||
{drawer !== "closed" && (
|
||||
<div
|
||||
className={`admin-drawer lg:hidden${drawer === "closing" ? " is-closing" : ""}`}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="后台导航"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-drawer-backdrop"
|
||||
aria-label="关闭导航"
|
||||
onClick={() => setDrawerOpen(false)}
|
||||
onClick={closeDrawer}
|
||||
/>
|
||||
<aside className="admin-rail admin-drawer-panel">
|
||||
<div className="flex items-center justify-between gap-2 mb-1">
|
||||
<AdminBrand siteName={brand} />
|
||||
<button
|
||||
type="button"
|
||||
ref={drawerCloseBtnRef}
|
||||
className={iconBtn}
|
||||
style={iconBtnStyle}
|
||||
aria-label="关闭导航"
|
||||
onClick={() => setDrawerOpen(false)}
|
||||
onClick={closeDrawer}
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<AdminRailNav user={user} onNavigate={() => setDrawerOpen(false)} />
|
||||
<AdminRailNav user={user} onNavigate={closeDrawer} />
|
||||
</aside>
|
||||
</div>
|
||||
)}
|
||||
@@ -501,11 +549,12 @@ export default function AdminShell({
|
||||
<div className="admin-topbar-left">
|
||||
<button
|
||||
type="button"
|
||||
ref={menuBtnRef}
|
||||
className={`${iconBtn} lg:hidden`}
|
||||
style={iconBtnStyle}
|
||||
aria-label="打开导航"
|
||||
aria-expanded={drawerOpen}
|
||||
onClick={() => setDrawerOpen(true)}
|
||||
onClick={() => setDrawer("open")}
|
||||
>
|
||||
<Menu size={18} />
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user