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

@@ -12,6 +12,7 @@ import {
type FooterLink,
type FooterLinksAlign,
} from "./brand";
import { DEFAULT_LEVEL_DEFS, LEVEL_COLOR_KEYS, MAX_LEVEL_COUNT, type LevelDef } from "./levels";
import { CSRF_COOKIE } from "./cookies";
import { publishPublicMetaSnapshot } from "./publicMetaSnapshot";
import { emitForceLogout } from "./userEvents";
@@ -47,6 +48,27 @@ function ssrInit(headers?: HeadersInit): RequestInit {
}
// 类型定义
export interface Badge {
id: number;
name: string;
/** lucide 图标名(前端白名单渲染,未知回落 Medal) */
icon: string;
/** CSS token 色板键(gold/accent/ok/danger/info/purple/teal) */
color: string;
created_at: string;
updated_at: string;
}
/** 徽章授予记录(badge 为嵌套定义,后端 Preload 填充) */
export interface UserBadgeView {
id: number;
badge_id: number;
user_id: number;
badge: Badge;
awarded_by: number;
awarded_at: string;
}
export interface User {
id: number;
username: string;
@@ -62,6 +84,11 @@ export interface User {
// 仅 /api/me 与 /api/profile 响应中返回(用户自身敏感信息)
email?: string;
signature?: string;
// 用户等级(后端按累计获得积分计算;1 起)与累计获得积分
level?: number;
total_points?: number;
// 管理员颁布的徽章(/me、用户资料页全量;作者点处仅展示前 2 枚)
badges?: UserBadgeView[];
}
// /api/me 响应:用户信息 + 未读通知数(SSR layout 一次请求直出)
@@ -544,6 +571,11 @@ export interface UserProfile {
created_at: string;
/** 仅超管/站长访客可见(公开资料不下发) */
banned?: boolean;
// 用户等级(后端按累计获得积分计算)与累计获得积分
level?: number;
total_points?: number;
// 徽章墙(全量,含授予时间)
badges?: UserBadgeView[];
};
stats: {
post_count: number;
@@ -576,7 +608,7 @@ export interface NotificationItem {
id: number;
user_id: number;
actor_id: number;
type: "comment" | "reply" | "like" | "approved" | "rejected" | "mention" | "pending_review" | "deleted";
type: "comment" | "reply" | "like" | "approved" | "rejected" | "mention" | "pending_review" | "deleted" | "badge";
post_id: number;
comment_id: number;
room_id?: number;
@@ -797,6 +829,26 @@ export interface PublicSettings {
bg_admin_url: string;
/** 后台铺放模式 */
bg_admin_mode: string;
/** 签到基础分;0=关闭;缺省 5 */
points_checkin_base: number;
/** 每满 N 天连续签到触发加成;0=关闭;缺省 7 */
points_streak_every_days: number;
/** 连续签到加成分值;缺省 0 */
points_streak_bonus: number;
/** 发帖奖励;0=关闭;缺省 0 */
points_post_reward: number;
/** 发帖奖励每日上限笔数;0=不限;缺省 0 */
points_post_daily_cap: number;
/** 回复奖励;0=关闭;缺省 0 */
points_reply_reward: number;
/** 回复奖励每日上限笔数;0=不限;缺省 0 */
points_reply_daily_cap: number;
/** 帖子被推荐奖励(每帖仅一次);0=关闭;缺省 0 */
points_recommend_reward: number;
/** 用户等级体系(数量/每级阈值/每级色板 key);等级数值由后端计算 */
levels: LevelDef[];
/** 等级特效动画总开关(流光动画);缺省开 */
levels_fx: boolean;
}
/** 与后端 DefaultAttachmentExts 保持同步 */
@@ -847,6 +899,16 @@ const DEFAULT_PUBLIC_SETTINGS: PublicSettings = {
bg_site_mode: "cover",
bg_admin_url: "",
bg_admin_mode: "cover",
points_checkin_base: 5,
points_streak_every_days: 7,
points_streak_bonus: 0,
points_post_reward: 0,
points_post_daily_cap: 0,
points_reply_reward: 0,
points_reply_daily_cap: 0,
points_recommend_reward: 0,
levels: [...DEFAULT_LEVEL_DEFS],
levels_fx: true,
};
function normalizeAttachmentExts(raw: unknown): string[] {
@@ -932,9 +994,70 @@ function normalizePublicSettings(data: Partial<PublicSettings> | null | undefine
bg_site_mode: typeof data?.bg_site_mode === "string" ? data.bg_site_mode : "cover",
bg_admin_url: typeof data?.bg_admin_url === "string" ? data.bg_admin_url : "",
bg_admin_mode: typeof data?.bg_admin_mode === "string" ? data.bg_admin_mode : "cover",
...normalizePointsSettings(data),
levels: normalizeLevels(data?.levels),
levels_fx: data?.levels_fx !== false,
};
}
/** 积分规则 8 键:0–100 有效整数,越界/异常回落默认值 */
function normalizePointsSettings(
data: Partial<PublicSettings> | null | undefined,
): Pick<
PublicSettings,
| "points_checkin_base"
| "points_streak_every_days"
| "points_streak_bonus"
| "points_post_reward"
| "points_post_daily_cap"
| "points_reply_reward"
| "points_reply_daily_cap"
| "points_recommend_reward"
> {
const num = (key: keyof PublicSettings): number | undefined => {
const v = data?.[key];
return typeof v === "number" && v >= 0 && v <= 100 ? Math.round(v) : undefined;
};
return {
points_checkin_base: num("points_checkin_base") ?? DEFAULT_PUBLIC_SETTINGS.points_checkin_base,
points_streak_every_days:
num("points_streak_every_days") ?? DEFAULT_PUBLIC_SETTINGS.points_streak_every_days,
points_streak_bonus: num("points_streak_bonus") ?? DEFAULT_PUBLIC_SETTINGS.points_streak_bonus,
points_post_reward: num("points_post_reward") ?? DEFAULT_PUBLIC_SETTINGS.points_post_reward,
points_post_daily_cap:
num("points_post_daily_cap") ?? DEFAULT_PUBLIC_SETTINGS.points_post_daily_cap,
points_reply_reward: num("points_reply_reward") ?? DEFAULT_PUBLIC_SETTINGS.points_reply_reward,
points_reply_daily_cap:
num("points_reply_daily_cap") ?? DEFAULT_PUBLIC_SETTINGS.points_reply_daily_cap,
points_recommend_reward:
num("points_recommend_reward") ?? DEFAULT_PUBLIC_SETTINGS.points_recommend_reward,
};
}
/** 等级配置:2–12 级、阈值非负且严格递增、首级 0、色板 key 合法;异常回落默认 */
function normalizeLevels(raw: unknown): LevelDef[] {
if (!Array.isArray(raw) || raw.length < 2 || raw.length > MAX_LEVEL_COUNT) {
return [...DEFAULT_LEVEL_DEFS];
}
const out: LevelDef[] = [];
let prev = -1;
for (const item of raw) {
const threshold =
typeof item?.threshold === "number" && Number.isFinite(item.threshold) && item.threshold >= 0
? Math.round(item.threshold)
: NaN;
const color =
typeof item?.color === "string" && LEVEL_COLOR_KEYS.includes(item.color) ? item.color : "";
if (Number.isNaN(threshold) || !color || threshold <= prev) {
return [...DEFAULT_LEVEL_DEFS];
}
out.push({ threshold, color });
prev = threshold;
}
if (out[0]?.threshold !== 0) return [...DEFAULT_LEVEL_DEFS];
return out;
}
// SSR 读取公开站点设置(主题色注入用);后端不可用时降级为默认色,不阻塞渲染
export async function fetchPublicSettings(): Promise<PublicSettings> {
try {
@@ -2139,6 +2262,98 @@ export async function apiAdminToggleAnnouncementPin(
return data as { announcement: Announcement };
}
// ---------- 后台徽章管理 ----------
export interface BadgeWithHolders extends Badge {
holders: number;
}
export interface BadgeHolder {
user_id: number;
username: string;
nickname: string;
awarded_at: string;
}
export async function apiAdminListBadges(): Promise<{ badges: BadgeWithHolders[] }> {
const res = await fetchWithRefresh("/api/admin/badges", { headers: clientHeaders() });
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || "获取徽章列表失败");
}
return res.json();
}
export interface BadgeInput {
name: string;
icon: string;
color: string;
}
async function saveBadge(method: "POST" | "PUT", url: string, input: BadgeInput): Promise<Badge> {
const res = await fetchWithRefresh(url, {
method,
headers: clientHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify(input),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "保存徽章失败");
return (data.badge ?? data) as Badge;
}
export function apiAdminCreateBadge(input: BadgeInput) {
return saveBadge("POST", "/api/admin/badges", input);
}
export function apiAdminUpdateBadge(id: number, input: BadgeInput) {
return saveBadge("PUT", `/api/admin/badges/${id}`, input);
}
export async function apiAdminDeleteBadge(id: number): Promise<void> {
const res = await fetchWithRefresh(`/api/admin/badges/${id}`, {
method: "DELETE",
headers: clientHeaders(),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || "删除徽章失败");
}
}
export async function apiAdminGrantBadge(id: number, userId: number): Promise<void> {
const res = await fetchWithRefresh(`/api/admin/badges/${id}/grant`, {
method: "POST",
headers: clientHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify({ user_id: userId }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || "授予失败");
}
}
export async function apiAdminRevokeBadge(id: number, userId: number): Promise<void> {
const res = await fetchWithRefresh(`/api/admin/badges/${id}/grant/${userId}`, {
method: "DELETE",
headers: clientHeaders(),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || "撤销失败");
}
}
export async function apiAdminBadgeHolders(id: number): Promise<{ holders: BadgeHolder[] }> {
const res = await fetchWithRefresh(`/api/admin/badges/${id}/holders`, {
headers: clientHeaders(),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || "获取持有者失败");
}
return res.json();
}
/** SSR / 公开:分页公告列表 */
export async function fetchAnnouncementsPage(
page = 1,
@@ -2257,6 +2472,8 @@ export interface AdminUser {
banned: boolean;
can_manage_messages?: boolean;
points?: number;
total_points?: number;
level?: number;
post_count: number;
comment_count: number;
created_at: string;
@@ -2397,6 +2614,41 @@ export async function apiAdminSetUserMessages(
return data as { user: AdminUser };
}
// 管理员手动调整用户积分(加分计累计可升级;扣分仅扣余额不降级),理由必填
export interface AdminAdjustPointsResult {
balance: number;
total_points: number;
level: number;
}
export async function apiAdminAdjustUserPoints(
id: number,
delta: number,
note: string
): Promise<AdminAdjustPointsResult> {
const res = await fetchWithRefresh(`/api/admin/users/${id}/points`, {
method: "PUT",
headers: clientHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify({ delta, note }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "积分调整失败");
return data as AdminAdjustPointsResult;
}
// 管理端查询某用户积分流水(分页,id DESC)
export async function apiAdminUserPointLedger(
id: number,
page = 1
): Promise<{ items: PointLedgerItem[]; total: number; page: number; size: number }> {
const res = await fetchWithRefresh(`/api/admin/users/${id}/points/ledger?page=${page}`, {
headers: clientHeaders(),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "获取积分流水失败");
return data as { items: PointLedgerItem[]; total: number; page: number; size: number };
}
// 某用户的登录历史(IP/UA/成败)
export async function apiAdminUserLoginLogs(
id: number,
@@ -2759,6 +3011,17 @@ export type UpdateSiteSettingsBody = {
comments_require_login?: boolean;
allow_messages?: boolean;
post_cooldown_hours?: number;
points_checkin_base?: number;
points_streak_every_days?: number;
points_streak_bonus?: number;
points_post_reward?: number;
points_post_daily_cap?: number;
points_reply_reward?: number;
points_reply_daily_cap?: number;
points_recommend_reward?: number;
/** 用户等级体系(数量/每级阈值/每级色板 key);整体替换保存 */
levels?: LevelDef[];
levels_fx?: boolean;
code_block_auto_fold?: boolean;
code_block_fold_lines?: number;
ui_animations?: boolean;

View File

@@ -0,0 +1,47 @@
import type { LucideIcon } from "lucide-react";
import {
Medal,
Award,
Trophy,
Crown,
Star,
Heart,
Zap,
Flame,
Gem,
Shield,
Rocket,
Sparkles,
ThumbsUp,
BookOpen,
Code,
Wrench,
} from "lucide-react";
/**
* 徽章图标白名单:后台管理以 lucide 图标名存储,前端仅渲染此表内的图标,
* 未知/空名回落 Medal(与后端 model.Badge.Icon 注释约定一致)。
*/
export const BADGE_ICONS: Record<string, LucideIcon> = {
medal: Medal,
award: Award,
trophy: Trophy,
crown: Crown,
star: Star,
heart: Heart,
zap: Zap,
flame: Flame,
gem: Gem,
shield: Shield,
rocket: Rocket,
sparkles: Sparkles,
thumbs_up: ThumbsUp,
book_open: BookOpen,
code: Code,
wrench: Wrench,
};
/** 取徽章图标组件;未知/空名回落 Medal */
export function badgeIcon(name?: string | null): LucideIcon {
return (name && BADGE_ICONS[name]) || Medal;
}

137
frontend/lib/levels.ts Normal file
View File

@@ -0,0 +1,137 @@
// 用户等级展示映射:等级体系(数量/阈值/颜色)由后台配置、后端下发
// (PublicSettings.levels),本模块只负责把它翻译成 CSS 变量(--lv-N-fg/-bg),
// 不持有阈值逻辑;等级数值由后端按累计获得积分计算(user.level),前端零计算。
/** 单级配置:threshold 为该级累计积分下限(Lv1 恒为 0),color 为色板 key */
export interface LevelDef {
threshold: number;
color: string;
}
/** 单级色规格:bg 为完整 background 简写值(特效档为含高光段的流动渐变) */
export interface LevelColorSpec {
fg: string;
bg: string;
/** 内描边色(黑金档金边用),默认透明 */
ring?: string;
/** 文字渐变层(完整 background 值):存在时 fg 应为 transparent,
* 文字以流动渐变呈现(鎏金字,金属质感的来源),见 .lv-text */
text?: string;
}
/**
* 等级色板:低段淡雅、高段金属流光,等级专属固定色,不随明暗主题切换。
* 段位语义:danger=红牌、accent=曜黑(黑金流光)、purple=鎏紫、gold=鎏金(暗底金渐变字)。
* 流光不靠扫光伪元素,由渐变内嵌高光段随 background-position 流动自然扫过。
*/
export const LEVEL_COLOR_TOKENS: Record<string, LevelColorSpec> = {
neutral: { fg: "#9aa3b2", bg: "rgba(148,158,176,0.16)" },
info: { fg: "#4d8dff", bg: "rgba(59,118,240,0.15)" },
teal: { fg: "#12a89b", bg: "rgba(14,148,136,0.15)" },
ok: { fg: "#27a94f", bg: "rgba(31,156,71,0.15)" },
danger: {
fg: "#ffffff",
// 90deg 水平渐变且首尾同色;size 固定 200%(配 lv-flow 位移 0→200% 严格无缝循环)
bg: "linear-gradient(90deg, #b91c1c, #ef4444 25%, #fecaca 38%, #dc2626 52%, #991b1b 78%, #b91c1c) 0% 0% / 200% 100%",
},
accent: {
fg: "#e9c46a",
bg: "linear-gradient(90deg, #16171c, #23252c 22%, #4a3d1c 36%, #23252c 52%, #16171c 78%) 0% 0% / 200% 100%",
ring: "rgba(233, 196, 106, 0.5)",
},
purple: {
fg: "#ffffff",
bg: "linear-gradient(90deg, #6d28d9, #a855f7 24%, #e9d5ff 37%, #8b5cf6 52%, #6d28d9 78%) 0% 0% / 200% 100%",
},
gold: {
// 鎏金字:暗底衬托,亮金流动渐变只在文字上(金底小面积易显廉价);
// 高光占比放大让 11px 小字保持干净亮泽
fg: "transparent",
bg: "linear-gradient(180deg, #221b0f, #100d07)",
ring: "rgba(240, 200, 120, 0.45)",
text: "linear-gradient(90deg, #c9973f, #ffe9a8 30%, #fff6d8 46%, #f0c964 66%, #c9973f) 0% 0% / 200% 100%",
},
};
export const LEVEL_COLOR_KEYS = Object.keys(LEVEL_COLOR_TOKENS);
/**
* 等级徽章共享类:LevelBadge 与后台等级管理预览必须逐字同用,
* 保证前后台胶囊大小完全一致(尺寸基准:text-11px + 紧凑行高 + px-2 圆角胶囊)
*/
export const LEVEL_BADGE_CLASS =
"lv-badge inline-flex items-center justify-center rounded-full px-2 py-[2px] text-[11px] font-semibold leading-[1.35] whitespace-nowrap";
/**
* 把 background 简写值拆为长写属性(React inline style 不允许简写与
* backgroundClip 等长写混用)。支持三种形态:
* "none" / 纯色(rgba、#hex)/ "linear-gradient(...) <position> / <size>"
*/
export function splitBackground(value: string): {
image: string;
color?: string;
position: string;
size: string;
} {
const v = value.trim();
if (v === "" || v === "none") {
return { image: "none", position: "0% 0%", size: "auto" };
}
// 渐变内部 stops 均为 hex 色,无嵌套括号,取到首个右括号即渐变结束
const m = v.match(/^(linear-gradient\([^)]*\))\s*(.*)$/);
if (m) {
const rest = m[2].trim();
const [pos, sizePart] = rest ? rest.split("/").map((s) => s.trim()) : [];
return {
image: m[1],
position: pos || "0% 0%",
size: sizePart || "auto",
};
}
return { image: "none", color: v, position: "0% 0%", size: "auto" };
}
/** 等级数量上限(CSS 变量按此档数固定下发;与后端 model.MaxLevelCount 同步) */
export const MAX_LEVEL_COUNT = 12;
/** 默认 7 级(与后端 model.DefaultLevelDefs 保持同步,异常回落用) */
export const DEFAULT_LEVEL_DEFS: LevelDef[] = [
{ threshold: 0, color: "neutral" },
{ threshold: 50, color: "info" },
{ threshold: 200, color: "teal" },
{ threshold: 500, color: "ok" },
{ threshold: 1200, color: "accent" },
{ threshold: 3000, color: "purple" },
{ threshold: 8000, color: "gold" },
];
/** 等级号钳制到 1..MAX_LEVEL_COUNT(游客/异常数据回落 Lv1) */
export function clampLevel(level?: number | null): number {
return Math.min(Math.max(Math.trunc(level ?? 1), 1), MAX_LEVEL_COUNT);
}
function tokensOf(color: string) {
return LEVEL_COLOR_TOKENS[color] ?? LEVEL_COLOR_TOKENS.neutral;
}
/**
* 生成等级颜色 CSS(:root 下的 --lv-N-fg / --lv-N-bg)。
* 固定输出 1..MAX_LEVEL_COUNT 全部档位:超配置数量的档位重复末级颜色,
* 保证配置缩级后旧数据/越界场景徽章仍有着色。
*/
export function levelVarsCSS(levels?: LevelDef[] | null): string {
const defs = levels && levels.length > 0 ? levels : DEFAULT_LEVEL_DEFS;
const lines: string[] = [];
for (let n = 1; n <= MAX_LEVEL_COUNT; n++) {
const idx = Math.min(n, defs.length) - 1;
const t = tokensOf(defs[idx]?.color ?? "");
// 文字渐变拆成 image/position/size 三个变量下发,
// 供 inline style 全长写引用(避免 React 简写/长写混用警告)
const text = splitBackground(t.text ?? "none");
lines.push(
`--lv-${n}-fg:${t.fg};--lv-${n}-bg:${t.bg};--lv-${n}-ring:${t.ring ?? "transparent"};` +
`--lv-${n}-text-image:${text.image};--lv-${n}-text-position:${text.position};--lv-${n}-text-size:${text.size}`,
);
}
return `:root{${lines.join(";")}}`;
}

View File

@@ -7,6 +7,11 @@ const REASON_LABELS: Record<string, string> = {
bounty_escrow: "悬赏托管",
bounty_refund: "悬赏退回",
bounty_award: "悬赏采纳",
post_reward: "发帖奖励",
reply_reward: "回复奖励",
streak_bonus: "连续签到加成",
recommend_reward: "帖子被推荐",
admin_adjust: "管理员调整",
};
export function pointReasonLabel(reason: string): string {