- 新增徽章系统:badge 服务与 handler、管理端配置页、BadgeChip/LevelBadge 展示组件 - 新增等级模型:model/level 及前端 levels 元数据 - 管理端经济模块拆分为 points/badges/levels 独立页面,EconomySettings 迁入设置 - 积分、签到、评论等服务适配等级与徽章逻辑
310 lines
11 KiB
TypeScript
310 lines
11 KiB
TypeScript
"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>
|
||
);
|
||
}
|