Files
jiang13-bbs/frontend/app/admin/levels/LevelsAdmin.tsx
freefire 4ae57d9392 feat: 用户徽章与等级体系,积分经济管理重构
- 新增徽章系统:badge 服务与 handler、管理端配置页、BadgeChip/LevelBadge 展示组件
- 新增等级模型:model/level 及前端 levels 元数据
- 管理端经济模块拆分为 points/badges/levels 独立页面,EconomySettings 迁入设置
- 积分、签到、评论等服务适配等级与徽章逻辑
2026-09-25 16:38:29 +08:00

310 lines
11 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}