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