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,237 @@
"use client";
import { useMemo, useState } from "react";
import { CalendarCheck, Flame, MessageSquarePlus, PenLine, Star } from "lucide-react";
import { AdminSettingsGroup, AdminSettingsRow, AdminStepper } from "@/components/admin";
import ConfirmDialog from "@/components/ConfirmDialog";
import useUnsavedGuard from "@/hooks/useUnsavedGuard";
import { apiUpdateSiteSettings, type PublicSettings } from "@/lib/api";
import { toast } from "@/lib/toast";
/** 积分规则 8 键(0–100,后端按 0–100 裁剪) */
type PointsKeys = Pick<
PublicSettings,
| "points_checkin_base"
| "points_streak_every_days"
| "points_streak_bonus"
| "points_post_reward"
| "points_post_daily_cap"
| "points_reply_reward"
| "points_reply_daily_cap"
| "points_recommend_reward"
>;
const POINTS_KEYS: (keyof PointsKeys)[] = [
"points_checkin_base",
"points_streak_every_days",
"points_streak_bonus",
"points_post_reward",
"points_post_daily_cap",
"points_reply_reward",
"points_reply_daily_cap",
"points_recommend_reward",
];
/** 管理员可配置的积分增长规则:签到基础分 / 连续签到加成 / 发帖奖励 / 回复奖励(各带每日上限)/ 推荐奖励 */
export default function EconomySettings({ initial }: { initial: PublicSettings }) {
const pickInitial = (): PointsKeys =>
Object.fromEntries(POINTS_KEYS.map((k) => [k, initial[k]])) as PointsKeys;
const [form, setForm] = useState<PointsKeys>(pickInitial);
const [saved, setSaved] = useState<PointsKeys>(pickInitial);
const [saving, setSaving] = useState(false);
const dirty = useMemo(() => POINTS_KEYS.some((k) => form[k] !== saved[k]), [form, saved]);
const leave = useUnsavedGuard(dirty);
const set = (k: keyof PointsKeys, v: number) => setForm((f) => ({ ...f, [k]: v }));
const save = async () => {
if (saving || !dirty) return;
setSaving(true);
try {
const payload = Object.fromEntries(POINTS_KEYS.map((k) => [k, form[k]]));
const res = await apiUpdateSiteSettings(payload);
const next = Object.fromEntries(POINTS_KEYS.map((k) => [k, res[k]])) as PointsKeys;
setForm(next);
setSaved(next);
toast("积分规则已保存", "ok");
} catch (e) {
toast(e instanceof Error ? e.message : "保存失败");
} finally {
setSaving(false);
}
};
return (
<div className="admin-settings-stack">
<AdminSettingsGroup title="签到">
<AdminSettingsRow
label={
<span className="inline-flex items-center gap-1.5">
<CalendarCheck size={15} /> 签到基础分
</span>
}
hintBelow="每日首次签到固定获得的积分(0=关闭,上限 100)。"
>
<AdminStepper
value={form.points_checkin_base}
onChange={(v) => set("points_checkin_base", v)}
min={0}
max={100}
unit="分"
ariaLabel="签到基础分"
disabled={saving}
/>
</AdminSettingsRow>
<AdminSettingsRow
label={
<span className="inline-flex items-center gap-1.5">
<Flame size={15} /> 连续签到触发天数
</span>
}
hintBelow="连续签到每满 N 天额外发放一次加成(0=关闭,上限 100)。"
>
<AdminStepper
value={form.points_streak_every_days}
onChange={(v) => set("points_streak_every_days", v)}
min={0}
max={100}
unit="天"
ariaLabel="连续签到触发天数"
disabled={saving}
/>
</AdminSettingsRow>
<AdminSettingsRow
label={
<span className="inline-flex items-center gap-1.5">
<Flame size={15} /> 连续签到加成
</span>
}
hintBelow="触发加成时额外获得的积分(0=关闭,上限 100);与触发天数配合使用。"
>
<AdminStepper
value={form.points_streak_bonus}
onChange={(v) => set("points_streak_bonus", v)}
min={0}
max={100}
unit="分"
ariaLabel="连续签到加成分值"
disabled={saving}
/>
</AdminSettingsRow>
</AdminSettingsGroup>
<AdminSettingsGroup title="发帖与回复">
<AdminSettingsRow
label={
<span className="inline-flex items-center gap-1.5">
<PenLine size={15} /> 发帖奖励
</span>
}
hintBelow="帖子发布成功后发放(0=关闭,上限 100);仅公开可见的帖子计奖,删除不回追。"
>
<AdminStepper
value={form.points_post_reward}
onChange={(v) => set("points_post_reward", v)}
min={0}
max={100}
unit="分"
ariaLabel="发帖奖励分值"
disabled={saving}
/>
</AdminSettingsRow>
<AdminSettingsRow
label={<span className="inline-flex items-center gap-1.5">发帖奖励每日上限</span>}
hintBelow="每人每天最多计奖的帖子数(0=不限,上限 100),防止刷分。"
>
<AdminStepper
value={form.points_post_daily_cap}
onChange={(v) => set("points_post_daily_cap", v)}
min={0}
max={100}
unit="帖"
ariaLabel="发帖奖励每日上限"
disabled={saving}
/>
</AdminSettingsRow>
<AdminSettingsRow
label={
<span className="inline-flex items-center gap-1.5">
<MessageSquarePlus size={15} /> 回复奖励
</span>
}
hintBelow="评论发布成功后发放(0=关闭,上限 100);仅公开可见的评论计奖。"
>
<AdminStepper
value={form.points_reply_reward}
onChange={(v) => set("points_reply_reward", v)}
min={0}
max={100}
unit="分"
ariaLabel="回复奖励分值"
disabled={saving}
/>
</AdminSettingsRow>
<AdminSettingsRow
label={<span className="inline-flex items-center gap-1.5">回复奖励每日上限</span>}
hintBelow="每人每天最多计奖的评论数(0=不限,上限 100),防止刷分。"
>
<AdminStepper
value={form.points_reply_daily_cap}
onChange={(v) => set("points_reply_daily_cap", v)}
min={0}
max={100}
unit="条"
ariaLabel="回复奖励每日上限"
disabled={saving}
/>
</AdminSettingsRow>
</AdminSettingsGroup>
<AdminSettingsGroup title="帖子推荐">
<AdminSettingsRow
label={
<span className="inline-flex items-center gap-1.5">
<Star size={15} /> 推荐奖励
</span>
}
hintBelow="帖子被管理员推荐(加精)时,作者一次性获得该积分(0=关闭,上限 100);推荐人为作者本人时不发放,同一帖子只发放一次。"
>
<AdminStepper
value={form.points_recommend_reward}
onChange={(v) => set("points_recommend_reward", v)}
min={0}
max={100}
unit="分"
ariaLabel="推荐奖励分值"
disabled={saving}
/>
</AdminSettingsRow>
</AdminSettingsGroup>
<div className="flex items-center gap-3">
<button
type="button"
className="btn btn-solid btn-sm"
disabled={saving || !dirty}
onClick={() => void save()}
>
{saving ? "保存中…" : "保存积分规则"}
</button>
<span className="admin-settings-instant">
{dirty ? "有未保存的修改" : "已保存的规则对后续签到/发帖/回复/推荐生效"}
</span>
</div>
<ConfirmDialog
open={leave.leaveOpen}
title="有未保存的修改"
message="离开后,当前尚未保存的积分规则修改会丢失。确定离开?"
confirmLabel="离开"
danger
onCancel={leave.cancelLeave}
onConfirm={leave.confirmLeave}
/>
</div>
);
}