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,309 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { Plus, RotateCcw, Trash2 } from "lucide-react";
import { AdminStepper, AdminSwitch } from "@/components/admin";
import ConfirmDialog from "@/components/ConfirmDialog";
import useUnsavedGuard from "@/hooks/useUnsavedGuard";
import { apiGetSettings, apiUpdateSiteSettings } from "@/lib/api";
import {
DEFAULT_LEVEL_DEFS,
LEVEL_BADGE_CLASS,
LEVEL_COLOR_KEYS,
LEVEL_COLOR_TOKENS,
MAX_LEVEL_COUNT,
splitBackground,
type LevelDef,
} from "@/lib/levels";
import { toast } from "@/lib/toast";
const cloneDefs = (defs: LevelDef[]): LevelDef[] =>
defs.map((d) => ({ threshold: d.threshold, color: d.color }));
const sameDefs = (a: LevelDef[] | null, b: LevelDef[] | null) => {
if (a === b) return true;
if (!a || !b || a.length !== b.length) return false;
return a.every((d, i) => d.threshold === b[i].threshold && d.color === b[i].color);
};
/** 等级管理:配置等级数量(2–12)、每级所需累计积分与前景/背景配色、特效开关 */
export default function LevelsAdmin() {
const [form, setForm] = useState<LevelDef[] | null>(null); // null = 加载中
const [saved, setSaved] = useState<LevelDef[] | null>(null);
const [fx, setFx] = useState(true);
const [savedFx, setSavedFx] = useState(true);
const [resetOpen, setResetOpen] = useState(false);
const [saving, setSaving] = useState(false);
const dirty = useMemo(
() => !sameDefs(form, saved) || fx !== savedFx,
[form, saved, fx, savedFx],
);
const leave = useUnsavedGuard(dirty);
useEffect(() => {
let alive = true;
apiGetSettings()
.then((s) => {
if (!alive) return;
const defs = cloneDefs(s.levels);
setForm(defs);
setSaved(defs);
setFx(s.levels_fx);
setSavedFx(s.levels_fx);
})
.catch((e) => {
toast(e instanceof Error ? e.message : "获取等级配置失败");
if (alive) setForm([]);
});
return () => {
alive = false;
};
}, []);
const setThreshold = (i: number, v: number) =>
setForm((f) => (f ? f.map((d, j) => (j === i ? { ...d, threshold: v } : d)) : f));
const setColor = (i: number, color: string) =>
setForm((f) => (f ? f.map((d, j) => (j === i ? { ...d, color } : d)) : f));
const addLevel = () =>
setForm((f) => {
if (!f || f.length >= MAX_LEVEL_COUNT) return f;
const last = f[f.length - 1];
return [...f, { threshold: (last?.threshold ?? 0) + 100, color: "neutral" }];
});
const removeLast = () =>
setForm((f) => (f && f.length > 2 ? f.slice(0, -1) : f));
const save = async () => {
if (saving || !dirty || !form) return;
if (form.length < 2) {
toast("至少保留 2 个等级");
return;
}
if (form[0].threshold !== 0) {
toast("Lv.1 所需积分须为 0");
return;
}
for (let i = 1; i < form.length; i++) {
if (!(form[i].threshold > form[i - 1].threshold)) {
toast(`Lv.${i + 1} 所需积分须大于上一级`);
return;
}
}
setSaving(true);
try {
const res = await apiUpdateSiteSettings({ levels: form, levels_fx: fx });
const defs = cloneDefs(res.levels);
setForm(defs);
setSaved(defs);
setSavedFx(fx);
toast("等级配置已保存", "ok");
} catch (e) {
toast(e instanceof Error ? e.message : "保存失败");
} finally {
setSaving(false);
}
};
// 重置:仅回填默认等级表(2–12 约束内),需再点保存才生效
const resetToDefault = () => {
if (!form) return;
setForm(cloneDefs(DEFAULT_LEVEL_DEFS));
setResetOpen(false);
toast("已恢复默认等级表,点击保存生效", "ok");
};
return (
<div className="admin-settings-stack">
<div>
<h2 className="text-lg font-bold" style={{ color: "var(--ink)" }}>
等级管理
</h2>
<p className="meta text-sm mt-1">
用户等级由累计获得积分决定,达到某级阈值即晋升。可配置等级数量(2–{MAX_LEVEL_COUNT}{" "}
级)、每级所需积分与配色;等级徽章仅显示 Lv.n,不弹悬浮提示。
</p>
</div>
{!form ? (
<p className="meta px-1 py-6">加载等级配置…</p>
) : form.length === 0 ? (
<div className="panel text-center py-12">
<p className="meta">等级配置加载失败,请刷新页面重试。</p>
</div>
) : (
<div className="panel p-0 overflow-hidden">
{form.map((d, i) => {
const n = i + 1;
const tokens = LEVEL_COLOR_TOKENS[d.color] ?? LEVEL_COLOR_TOKENS.neutral;
const textBg = splitBackground(tokens.text ?? "none");
const isLast = i === form.length - 1;
return (
<div
key={n}
className="flex items-center gap-4 px-4 py-3 flex-wrap"
style={{ borderTop: i === 0 ? "none" : "1px solid var(--line)" }}
>
{/* 预览:与 LevelBadge 同构(外层底色 + .lv-text 文字层),实时反映未保存的修改 */}
<span className="w-14 shrink-0 flex">
<span
className={LEVEL_BADGE_CLASS}
style={{
background: tokens.bg,
boxShadow: `inset 0 0 0 1px ${tokens.ring ?? "transparent"}`,
}}
>
<span
className="lv-text"
style={{
backgroundImage: textBg.image,
backgroundPosition: textBg.position,
backgroundSize: textBg.size,
backgroundClip: "text",
WebkitBackgroundClip: "text",
color: tokens.fg,
}}
>
Lv.{n}
</span>
</span>
</span>
<div className="shrink-0">
<p className="text-xs mb-1" style={{ color: "var(--ink-2)" }}>
所需累计积分
</p>
<AdminStepper
value={d.threshold}
onChange={(v) => setThreshold(i, v)}
min={0}
max={1000000}
unit="分"
ariaLabel={`Lv.${n} 所需积分`}
disabled={saving || n === 1}
/>
</div>
<div className="min-w-0">
<p className="text-xs mb-1.5" style={{ color: "var(--ink-2)" }}>
配色(前景/背景)
</p>
<div className="flex gap-1.5 flex-wrap">
{LEVEL_COLOR_KEYS.map((c) => {
const t = LEVEL_COLOR_TOKENS[c];
const active = d.color === c;
return (
<button
key={c}
type="button"
aria-label={c}
title={c}
disabled={saving}
onClick={() => setColor(i, c)}
className="w-6 h-6 rounded-full transition-transform"
style={{
background: t.bg,
outline: active
? "2px solid var(--ink)"
: "1px solid var(--line)",
outlineOffset: 1,
}}
/>
);
})}
</div>
</div>
<span className="flex-1" />
{isLast && (
<button
type="button"
className="btn btn-line btn-sm shrink-0"
style={{ color: "var(--danger)" }}
disabled={saving || form.length <= 2}
onClick={removeLast}
>
<Trash2 size={13} /> 删除此级
</button>
)}
</div>
);
})}
{/* 添加新等级:整宽虚线条目,与上方项同宽 */}
{form.length < MAX_LEVEL_COUNT ? (
<button
type="button"
className="w-full flex items-center justify-center gap-1.5 py-3.5 text-sm transition-colors hover:opacity-80"
style={{
borderTop: "1.5px dashed var(--line)",
color: "var(--ink-2)",
}}
disabled={saving}
onClick={addLevel}
>
<Plus size={15} /> 添加新等级
</button>
) : (
<div
className="w-full flex items-center justify-center py-3.5 text-xs"
style={{
borderTop: "1.5px dashed var(--line)",
color: "var(--ink-3)",
}}
>
已达 {MAX_LEVEL_COUNT} 级上限
</div>
)}
</div>
)}
<div className="flex items-center gap-4 flex-wrap">
<button
type="button"
className="btn btn-primary btn-sm"
disabled={saving || !dirty}
onClick={() => void save()}
>
{saving ? "保存中…" : "保存等级配置"}
</button>
<button
type="button"
className="btn btn-line btn-sm"
disabled={saving || !form}
onClick={() => setResetOpen(true)}
>
<RotateCcw size={13} /> 重置
</button>
<div className="flex items-center gap-2">
<AdminSwitch
checked={fx}
disabled={saving}
onChange={() => setFx((v) => !v)}
label="特效动画"
/>
</div>
<span className="admin-settings-instant">
{dirty
? "有未保存的修改"
: "已保存的配色在页面刷新后生效,等级数值即时按新阈值重算"}
</span>
</div>
<ConfirmDialog
open={resetOpen}
title="重置等级配置"
message={`将恢复为默认 ${DEFAULT_LEVEL_DEFS.length} 级配置(当前修改不会立即生效,需再点击保存)。确定重置?`}
confirmLabel="重置"
danger
onCancel={() => setResetOpen(false)}
onConfirm={resetToDefault}
/>
<ConfirmDialog
open={leave.leaveOpen}
title="有未保存的修改"
message="离开后,当前尚未保存的等级配置修改会丢失。确定离开?"
confirmLabel="离开"
danger
onCancel={leave.cancelLeave}
onConfirm={leave.confirmLeave}
/>
</div>
);
}