feat: 用户徽章与等级体系,积分经济管理重构
- 新增徽章系统:badge 服务与 handler、管理端配置页、BadgeChip/LevelBadge 展示组件 - 新增等级模型:model/level 及前端 levels 元数据 - 管理端经济模块拆分为 points/badges/levels 独立页面,EconomySettings 迁入设置 - 积分、签到、评论等服务适配等级与徽章逻辑
This commit is contained in:
33
frontend/components/BadgeChip.tsx
Normal file
33
frontend/components/BadgeChip.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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} />
|
||||
)}
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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>
|
||||
|
||||
45
frontend/components/LevelBadge.tsx
Normal file
45
frontend/components/LevelBadge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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} /> };
|
||||
};
|
||||
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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})` }}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user