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