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

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(";")}}`;
}