feat: 用户徽章与等级体系,积分经济管理重构

- 新增徽章系统:badge 服务与 handler、管理端配置页、BadgeChip/LevelBadge 展示组件
- 新增等级模型:model/level 及前端 levels 元数据
- 管理端经济模块拆分为 points/badges/levels 独立页面,EconomySettings 迁入设置
- 积分、签到、评论等服务适配等级与徽章逻辑
This commit is contained in:
2026-09-25 16:38:29 +08:00
parent 7b327bc8cc
commit 4ae57d9392
57 changed files with 3939 additions and 147 deletions

View File

@@ -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>