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

@@ -0,0 +1,33 @@
import { badgeIcon } from "@/lib/badgeIcons";
import type { Badge } from "@/lib/api";
/**
* 徽章 pill:图标 + 名称,配色由后台设定的 CSS token 决定
* (color: gold/accent/ok/danger/info/purple/teal → var(--token))。
* 仅色板内的值生效,异常值回落 accent。
*/
export default function BadgeChip({
badge,
tip,
className = "",
}: {
badge: Pick<Badge, "name" | "icon" | "color">;
/** 悬浮提示(默认徽章名;徽章墙可传名称+授予时间) */
tip?: string;
className?: string;
}) {
const Icon = badgeIcon(badge.icon);
const color = `var(--${badge.color}, var(--accent))`;
const soft = `var(--${badge.color}-soft, var(--accent-soft))`;
return (
<span
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-medium leading-[1.6] whitespace-nowrap select-none ${className}`}
style={{ background: soft, color }}
data-tip={tip ?? badge.name}
>
<Icon size={11} className="shrink-0" />
{badge.name}
</span>
);
}

View File

@@ -7,6 +7,8 @@ import { Calendar, Crown, MessageSquare, VolumeX } from "lucide-react";
import Modal from "@/components/Modal";
import Avatar from "@/components/Avatar";
import RoleBadge from "@/components/RoleBadge";
import LevelBadge from "@/components/LevelBadge";
import BadgeChip from "@/components/BadgeChip";
import ImageLightbox from "@/components/markdown/ImageLightbox";
import { useAllowMessages } from "@/components/MessagesPolicyProvider";
import { apiGetUserProfile, apiOpenDirectChat, type UserProfile, type User } from "@/lib/api";
@@ -105,6 +107,10 @@ export default function ChatUserCard({
>
{name}
</p>
<LevelBadge level={user.level} />
{(user.badges ?? []).slice(0, 2).map((ub) => (
<BadgeChip key={ub.id} badge={ub.badge} />
))}
{user.role && user.role !== "user" && (
<RoleBadge role={user.role} boardIds={user.board_ids} />
)}

View File

@@ -1,4 +1,4 @@
"use client";
"use client";
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
@@ -29,6 +29,8 @@ import { useAllowComments, useCommentsRequireLogin } from "@/components/Comments
import CommentsUnsupported from "@/components/CommentsUnsupported";
import CommentsLoginRequired from "@/components/CommentsLoginRequired";
import Avatar from "./Avatar";
import LevelBadge from "./LevelBadge";
import BadgeChip from "./BadgeChip";
import Pagination from "./Pagination";
import MarkdownBodyClient from "@/components/MarkdownBodyClient";
import MarkdownEditor from "@/components/MarkdownEditor";
@@ -994,6 +996,11 @@ export default function CommentSection({
) : (
<span className="cmt-name">{authorName}</span>
)}
{author && <LevelBadge level={author.level} />}
{author &&
(author.badges ?? []).slice(0, 2).map((ub) => (
<BadgeChip key={ub.id} badge={ub.badge} />
))}
{isOp && <span className="cmt-badge">楼主</span>}
{c.status === "pending" && <span className="badge-pending shrink-0">待审</span>}
{acceptedCommentId === c.id && <span className="badge-accepted shrink-0">已采纳</span>}
@@ -1090,6 +1097,11 @@ export default function CommentSection({
) : (
<span className="cmt-name">{authorName}</span>
)}
{author && <LevelBadge level={author.level} />}
{author &&
(author.badges ?? []).slice(0, 2).map((ub) => (
<BadgeChip key={ub.id} badge={ub.badge} />
))}
{isOp && <span className="cmt-badge">楼主</span>}
{c.status === "pending" && <span className="badge-pending shrink-0">待审</span>}
{acceptedCommentId === c.id && <span className="badge-accepted shrink-0">已采纳</span>}

View File

@@ -29,6 +29,8 @@ import { onDocumentVisible } from "@/lib/visibilityReconcile";
import NotificationBell from "./NotificationBell";
import ThemeToggle from "./ThemeToggle";
import Avatar from "./Avatar";
import LevelBadge from "./LevelBadge";
import BadgeChip from "./BadgeChip";
import BrandLockup from "./BrandLockup";
import { useAllowMessages } from "./MessagesPolicyProvider";
import type { BrandLogoFit, BrandLogoSize, BrandMark } from "@/lib/brand";
@@ -536,8 +538,12 @@ function UserMenu({ user, onLogout }: { user: User; onLogout: () => void }) {
>
<Avatar name={user.nickname || user.username} src={user.avatar || undefined} size={38} />
<div className="min-w-0">
<p className="text-[14px] truncate" style={{ color: "var(--ink)" }}>
{user.nickname}
<p className="text-[14px] truncate flex items-center gap-1.5" style={{ color: "var(--ink)" }}>
<span className="truncate">{user.nickname}</span>
<LevelBadge level={user.level} />
{(user.badges ?? []).slice(0, 2).map((ub) => (
<BadgeChip key={ub.id} badge={ub.badge} />
))}
</p>
<p className="meta text-xs truncate">@ {user.username}</p>
</div>

View File

@@ -0,0 +1,45 @@
import { clampLevel, LEVEL_BADGE_CLASS } from "@/lib/levels";
/**
* 等级徽章:仅显示 Lv.n,颜色/特效由根布局 SSR 注入的 --lv-N-* 变量驱动
* (--lv-N-fg 前景 / --lv-N-bg 背景 / --lv-N-ring 内描边 / --lv-N-text 文字渐变层,
* text 存在时 fg 为 transparent,文字以流动金渐变呈现——鎏金字)。
* 等级数值由后端按累计获得积分计算,前端零计算;不弹悬浮提示。
* 流动动画见 globals.css 的 .lv-badge / .lv-text。
*/
export default function LevelBadge({
level,
className = "",
}: {
level?: number | null;
className?: string;
}) {
const n = clampLevel(level);
return (
<span
className={`${LEVEL_BADGE_CLASS} ${className}`}
style={{
background: `var(--lv-${n}-bg)`,
color: `var(--lv-${n}-fg)`,
boxShadow: `inset 0 0 0 1px var(--lv-${n}-ring, transparent)`,
}}
>
{/* 内层承载可选文字渐变。background 全长写(React 禁止简写与 backgroundClip
等长写混用);clip 同为长写,无渐变档 image=none 时 clip 无副作用 */}
<span
className="lv-text"
style={{
backgroundImage: `var(--lv-${n}-text-image, none)`,
backgroundPosition: `var(--lv-${n}-text-position, 0% 0%)`,
backgroundSize: `var(--lv-${n}-text-size, auto)`,
backgroundClip: "text",
WebkitBackgroundClip: "text",
color: `var(--lv-${n}-fg)`,
}}
>
Lv.{n}
</span>
</span>
);
}

View File

@@ -3,7 +3,7 @@
import { useState, useEffect, useRef } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { Bell, CheckCheck, Heart, MessageCircle, CheckCircle2, XCircle, AtSign, ClipboardList } from "lucide-react";
import { Bell, CheckCheck, Heart, MessageCircle, CheckCircle2, XCircle, AtSign, ClipboardList, Award } from "lucide-react";
import {
apiFetchNotifications,
apiUnreadCount,
@@ -99,11 +99,13 @@ export default function NotificationBell({ initialUnread = 0 }: { initialUnread?
);
}
setOpen(false);
// @提及通知跳到群聊房间;其余跳到帖子/评论
// @提及通知跳到群聊房间;徽章通知无关联帖子(仅标已读);其余跳到帖子/评论
if (n.type === "mention" && n.room_id) {
router.push(`/chat/${n.room_id}`);
} else if (n.type === "pending_review") {
router.push(n.post_id ? `/post/${n.post_id}` : "/admin/content?status=pending");
} else if (n.type === "badge") {
return;
} else {
router.push(`/post/${n.post_id}`);
}
@@ -134,6 +136,7 @@ export default function NotificationBell({ initialUnread = 0 }: { initialUnread?
if (n.type === "mention") return "在消息中提到了你";
if (n.type === "pending_review") return "有新内容待审核";
if (n.type === "deleted") return "删除了你的帖子";
if (n.type === "badge") return "向你颁布了徽章";
return "点赞了你的帖子";
};
@@ -151,6 +154,8 @@ export default function NotificationBell({ initialUnread = 0 }: { initialUnread?
return { color: "var(--accent)", bg: "var(--accent-soft)", node: <AtSign size={15} /> };
if (n.type === "pending_review")
return { color: "var(--accent)", bg: "var(--accent-soft)", node: <ClipboardList size={15} /> };
if (n.type === "badge")
return { color: "var(--gold)", bg: "var(--gold-soft)", node: <Award size={15} /> };
return { color: "var(--accent)", bg: "var(--accent-soft)", node: <MessageCircle size={15} /> };
};

View File

@@ -6,6 +6,7 @@ import { getPublicSettingsCached } from "@/lib/serverData";
import { normalizePostType, postTypeLabel, typeStatusLabel, typeStatusClass } from "@/lib/postMeta";
import LikeButton from "./LikeButton";
import Avatar from "./Avatar";
import BadgeChip from "./BadgeChip";
import { IconComment, IconEye } from "./Icons";
/**
@@ -103,6 +104,9 @@ export default async function PostRow({
>
{authorName}
</Link>
{(post.user.badges ?? []).slice(0, 2).map((ub) => (
<BadgeChip key={ub.id} badge={ub.badge} />
))}
<span aria-hidden>·</span>
<span className="tabular-nums">{formatRelative(post.created_at)}</span>
{replied && (

View File

@@ -57,7 +57,7 @@ export default function RoleBadge({
return (
<span
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-medium leading-[1.6] ${
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-medium leading-[1.6] select-none ${
truncate ? "max-w-full whitespace-nowrap" : "whitespace-nowrap"
} ${className}`}
style={{ background: `var(${meta.softToken})`, color: `var(${meta.colorToken})` }}

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>

View File

@@ -4,6 +4,7 @@ import type { UserProfile, PostListItem, Board } from "@/lib/api";
import BoardIcon, { boardColor } from "@/components/BoardIcon";
import Avatar from "@/components/Avatar";
import RoleBadge from "@/components/RoleBadge";
import LevelBadge from "@/components/LevelBadge";
// formatYMD 帖子详情页左栏「作者其他帖」日期
function formatYMD(dateStr: string) {
@@ -77,13 +78,16 @@ export function AuthorRailCard({
<Avatar name={u.nickname || u.username} src={u.avatar || undefined} size={48} />
</Link>
<div className="min-w-0">
<Link
href={`/u/${u.id}`}
className="block font-semibold text-[14.5px] truncate hover:text-[var(--accent)] transition-colors"
style={{ color: "var(--ink)" }}
>
{u.nickname}
</Link>
<div className="flex items-center gap-1.5 min-w-0">
<Link
href={`/u/${u.id}`}
className="block font-semibold text-[14.5px] truncate hover:text-[var(--accent)] transition-colors"
style={{ color: "var(--ink)" }}
>
{u.nickname}
</Link>
<LevelBadge level={u.level} className="shrink-0" />
</div>
<p className="meta mt-0.5 truncate">@{u.username}</p>
{u.role !== "user" && (
<div className="mt-1">