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,477 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import Link from "next/link";
import { ChevronDown, ChevronUp, ChevronsUpDown, History, SlidersHorizontal, Users as UsersIcon } from "lucide-react";
import {
apiAdminAdjustUserPoints,
apiAdminListUsers,
apiAdminUserPointLedger,
type AdminUser,
type PointLedgerItem,
} from "@/lib/api";
import Avatar from "@/components/Avatar";
import LevelBadge from "@/components/LevelBadge";
import Modal from "@/components/Modal";
import { AdminPageHeader, AdminPager, AdminSearchField, AdminStepper } from "@/components/admin";
import { pointReasonLabel } from "@/lib/points";
import { formatRelative } from "@/lib/format";
import { toast } from "@/lib/toast";
const PAGE_SIZE = 20;
const MAX_DELTA = 100000; // 与后端 adminAdjustMaxDelta 一致
const fmt = (n?: number) => (Number(n) || 0).toLocaleString("zh-CN");
type PointsSortKey = "id" | "points" | "total_points";
/** 表头排序按钮:未激活显示双向箭头,激活显示单向箭头并高亮 */
function SortHeader({
label,
active,
dir,
onClick,
}: {
label: string;
active: boolean;
dir: "asc" | "desc";
onClick: () => void;
}) {
return (
<button
type="button"
className="inline-flex items-center gap-1 transition-colors hover:text-[var(--accent)]"
style={{ color: active ? "var(--accent)" : "inherit" }}
onClick={onClick}
aria-label={`按${label}排序`}
>
{label}
{active ? (
dir === "asc" ? (
<ChevronUp size={13} />
) : (
<ChevronDown size={13} />
)
) : (
<ChevronsUpDown size={13} className="opacity-50" />
)}
</button>
);
}
/**
* 用户积分标签:搜索用户 → 余额/累计/等级一览;行内「调整」弹窗(加分计累计可升级,
* 扣分仅扣余额不降级,理由必填)与「流水」弹窗(分页查看该用户积分流水)。
*/
export default function UserPointsTab() {
const [keyword, setKeyword] = useState("");
const [appliedQ, setAppliedQ] = useState("");
const [page, setPage] = useState(1);
const [users, setUsers] = useState<AdminUser[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [loaded, setLoaded] = useState(false);
const [sort, setSort] = useState<PointsSortKey>("id");
const [dir, setDir] = useState<"asc" | "desc">("desc");
const [adjustTarget, setAdjustTarget] = useState<AdminUser | null>(null);
const [ledgerTarget, setLedgerTarget] = useState<AdminUser | null>(null);
const seqRef = useRef(0);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// 排序走 ref:load 身份保持稳定,防抖搜索与切页自动沿用当前排序
const sortRef = useRef<{ sort: PointsSortKey; dir: "asc" | "desc" }>({ sort: "id", dir: "desc" });
const load = useCallback(async (opts: { page: number; q: string }) => {
const seq = ++seqRef.current;
setLoading(true);
try {
const res = await apiAdminListUsers({
page: opts.page,
size: PAGE_SIZE,
q: opts.q || undefined,
sort: sortRef.current.sort,
order: sortRef.current.dir,
});
if (seq !== seqRef.current) return;
setUsers(res.users);
setTotal(res.total);
setPage(opts.page);
setAppliedQ(opts.q);
setError("");
setLoaded(true);
} catch (e) {
if (seq !== seqRef.current) return;
setError(e instanceof Error ? e.message : "获取用户列表失败");
} finally {
if (seq === seqRef.current) setLoading(false);
}
}, []);
useEffect(() => {
void load({ page: 1, q: "" });
}, [load]);
// 搜索 400ms 防抖(对齐 UsersAdmin)
const onKeywordChange = useCallback(
(v: string) => {
setKeyword(v);
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
void load({ page: 1, q: v.trim() });
}, 400);
},
[load]
);
const patchUser = useCallback((id: number, patch: Partial<AdminUser>) => {
setUsers((prev) => prev.map((u) => (u.id === id ? { ...u, ...patch } : u)));
}, []);
// 切换排序:点未激活列 → 该列降序;点已激活列 → 升降切换;始终回到第 1 页
const toggleSort = useCallback(
(key: PointsSortKey) => {
const cur = sortRef.current;
const next = cur.sort === key ? { sort: key, dir: cur.dir === "desc" ? ("asc" as const) : ("desc" as const) } : { sort: key, dir: "desc" as const };
sortRef.current = next;
setSort(next.sort);
setDir(next.dir);
void load({ page: 1, q: appliedQ });
},
[load, appliedQ]
);
return (
<div>
<AdminPageHeader
icon={UsersIcon}
title="用户积分"
description="查看与管理用户积分:加分计入累计获得(可升级),扣分仅扣余额不降级"
/>
<div className="mb-4 max-w-xs">
<AdminSearchField
value={keyword}
onChange={onKeywordChange}
placeholder="搜索用户名 / 昵称 / 邮箱"
ariaLabel="搜索用户"
/>
</div>
<div className="panel p-0 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-[13px]" style={{ borderCollapse: "collapse" }}>
<thead>
<tr style={{ borderBottom: "1px solid var(--line)" }}>
<th className="px-4 py-2.5 text-left font-medium" style={{ color: "var(--ink-3)" }}>
用户
</th>
<th className="px-4 py-2.5 text-right font-medium" style={{ color: "var(--ink-3)" }}>
<SortHeader label="余额" active={sort === "points"} dir={dir} onClick={() => toggleSort("points")} />
</th>
<th className="px-4 py-2.5 text-right font-medium" style={{ color: "var(--ink-3)" }}>
<SortHeader label="累计获得" active={sort === "total_points"} dir={dir} onClick={() => toggleSort("total_points")} />
</th>
<th className="px-4 py-2.5 text-left font-medium" style={{ color: "var(--ink-3)" }}>
等级
</th>
<th className="px-4 py-2.5 text-right font-medium" style={{ color: "var(--ink-3)" }}>
操作
</th>
</tr>
</thead>
<tbody>
{users.map((u) => (
<tr key={u.id} style={{ borderBottom: "1px solid var(--line)" }}>
<td className="px-4 py-2.5">
<Link href={`/u/${u.id}`} className="flex items-center gap-2.5 min-w-0 group">
<Avatar name={u.nickname || u.username} src={u.avatar || undefined} size={30} />
<span className="min-w-0">
<span
className="block truncate text-[13.5px] font-medium group-hover:text-[var(--accent)] transition-colors"
style={{ color: "var(--ink)" }}
>
{u.nickname || u.username}
</span>
<span className="block meta text-[11.5px] truncate">@{u.username}</span>
</span>
</Link>
</td>
<td className="px-4 py-2.5 text-right tabular-nums" style={{ color: "var(--ink)" }}>
{fmt(u.points)}
</td>
<td className="px-4 py-2.5 text-right tabular-nums" style={{ color: "var(--ink-2)" }}>
{fmt(u.total_points)}
</td>
<td className="px-4 py-2.5">
<LevelBadge level={u.level} />
</td>
<td className="px-4 py-2.5">
<div className="flex items-center justify-end gap-2">
<button
type="button"
className="btn btn-line btn-sm"
onClick={() => setLedgerTarget(u)}
>
<History size={13} /> 流水
</button>
<button
type="button"
className="btn btn-line btn-sm"
onClick={() => setAdjustTarget(u)}
>
<SlidersHorizontal size={13} /> 调整
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{loading ? <p className="meta text-center py-4">加载中…</p> : null}
{!loading && error ? <p className="text-center py-6 text-[13px]" style={{ color: "var(--danger)" }}>{error}</p> : null}
{!loading && !error && loaded && users.length === 0 ? (
<p className="meta text-center py-10">没有匹配的用户</p>
) : null}
</div>
{!error && users.length > 0 ? (
<div className="mt-4">
<AdminPager page={page} total={total} pageSize={PAGE_SIZE} loading={loading} onChange={(p) => void load({ page: p, q: appliedQ })} />
</div>
) : null}
{adjustTarget ? (
<AdjustModal
user={adjustTarget}
onClose={() => setAdjustTarget(null)}
onAdjusted={(balance, totalPoints, level) => {
patchUser(adjustTarget.id, { points: balance, total_points: totalPoints, level });
}}
/>
) : null}
{ledgerTarget ? (
<LedgerModal user={ledgerTarget} onClose={() => setLedgerTarget(null)} />
) : null}
</div>
);
}
/** 调整弹窗:方向(加/减)+ 数量 + 理由;成功后回填余额/累计/等级 */
function AdjustModal({
user,
onClose,
onAdjusted,
}: {
user: AdminUser;
onClose: () => void;
onAdjusted: (balance: number, totalPoints: number, level: number) => void;
}) {
const [dir, setDir] = useState<"add" | "sub">("add");
const [amount, setAmount] = useState(10);
const [note, setNote] = useState("");
const [saving, setSaving] = useState(false);
const submit = async () => {
if (saving) return;
if (!Number.isInteger(amount) || amount < 1 || amount > MAX_DELTA) {
toast(`积分数量须为 1–${MAX_DELTA} 的整数`);
return;
}
const trimmed = note.trim();
if (!trimmed) {
toast("请填写调整理由");
return;
}
setSaving(true);
try {
const delta = dir === "add" ? amount : -amount;
const res = await apiAdminAdjustUserPoints(user.id, delta, trimmed);
onAdjusted(res.balance, res.total_points, res.level);
toast(
`已${dir === "add" ? "增加" : "扣减"} ${fmt(amount)} 积分:余额 ${fmt(res.balance)},当前 Lv.${res.level}`,
"ok"
);
onClose();
} catch (e) {
toast(e instanceof Error ? e.message : "积分调整失败");
} finally {
setSaving(false);
}
};
return (
<Modal
open
title={`调整积分 - ${user.nickname || user.username}`}
description={`当前余额 ${fmt(user.points)} · 累计获得 ${fmt(user.total_points)} · Lv.${user.level ?? 1}`}
onClose={onClose}
busy={saving}
footer={
<>
<button type="button" className="btn btn-line btn-sm" onClick={onClose} disabled={saving}>
取消
</button>
<button type="button" className="btn btn-primary btn-sm" onClick={() => void submit()} disabled={saving}>
{saving ? "提交中…" : "确认调整"}
</button>
</>
}
>
<div className="flex flex-col gap-4">
<div>
<p className="text-xs mb-1.5" style={{ color: "var(--ink-2)" }}>
方向
</p>
<div className="flex gap-2">
<button
type="button"
onClick={() => setDir("add")}
className={`btn btn-sm flex-1 ${dir === "add" ? "btn-primary" : "btn-line"}`}
>
增加
</button>
<button
type="button"
onClick={() => setDir("sub")}
className={`btn btn-sm flex-1 ${dir === "sub" ? "btn-danger" : "btn-line"}`}
>
扣减
</button>
</div>
<p className="meta text-[11.5px] mt-1.5">
{dir === "add"
? "加分同时计入累计获得,可能触发升级"
: "扣分仅扣减余额,不改变累计获得与等级"}
</p>
</div>
<div>
<p className="text-xs mb-1.5" style={{ color: "var(--ink-2)" }}>
数量(1–{MAX_DELTA})
</p>
<AdminStepper
value={amount}
onChange={setAmount}
min={1}
max={MAX_DELTA}
unit="分"
ariaLabel="调整数量"
disabled={saving}
/>
</div>
<div>
<p className="text-xs mb-1.5" style={{ color: "var(--ink-2)" }}>
调整理由(必填,写入积分流水)
</p>
<textarea
value={note}
onChange={(e) => setNote(e.target.value)}
maxLength={200}
rows={3}
className="w-full px-3 py-2 text-[13px] rounded-[var(--r-sm)] outline-none focus:border-[var(--accent)] transition-colors"
style={{ background: "var(--panel-2)", border: "1px solid var(--line-2)", color: "var(--ink)" }}
placeholder="例如:活动奖励补偿 / 违规扣减"
/>
</div>
</div>
</Modal>
);
}
/** 流水弹窗:懒加载分页查询该用户积分流水 */
function LedgerModal({ user, onClose }: { user: AdminUser; onClose: () => void }) {
const [items, setItems] = useState<PointLedgerItem[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const load = useCallback(async (p: number) => {
setLoading(true);
try {
const res = await apiAdminUserPointLedger(user.id, p);
setItems(res.items);
setTotal(res.total);
setPage(p);
setError("");
} catch (e) {
setError(e instanceof Error ? e.message : "获取积分流水失败");
} finally {
setLoading(false);
}
}, [user.id]);
useEffect(() => {
void load(1);
}, [load]);
return (
<Modal
open
title={`积分流水 - ${user.nickname || user.username}`}
description={`余额 ${fmt(user.points)} · 累计获得 ${fmt(user.total_points)} · 共 ${fmt(total)} 条`}
onClose={onClose}
maxWidthClass="max-w-2xl"
>
{loading && items.length === 0 ? (
<p className="meta text-center py-8">加载中…</p>
) : error ? (
<p className="text-center py-8 text-[13px]" style={{ color: "var(--danger)" }}>
{error}
</p>
) : items.length === 0 ? (
<p className="meta text-center py-8">暂无积分流水</p>
) : (
<>
<ul className="flex flex-col max-h-[50vh] overflow-y-auto">
{items.map((it) => {
const delta = Number(it.delta) || 0;
return (
<li
key={it.id}
className="flex items-center gap-3 py-2.5"
style={{ borderBottom: "1px solid var(--line)" }}
>
<span
className="shrink-0 inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium select-none"
style={{
background: delta > 0 ? "var(--ok-soft)" : "var(--danger-soft)",
color: delta > 0 ? "var(--ok)" : "var(--danger)",
}}
>
{delta > 0 ? `+${fmt(delta)}` : fmt(delta)}
</span>
<span className="min-w-0 flex-1">
<span className="block text-[13px] truncate" style={{ color: "var(--ink)" }}>
{pointReasonLabel(it.reason)}
{it.note ? (
<span className="ml-1.5 text-[12px]" style={{ color: "var(--ink-3)" }}>
{it.note}
</span>
) : null}
</span>
<span className="block meta text-[11.5px] mt-0.5">
变动后余额 {fmt(it.balance)} · {formatRelative(it.created_at)}
</span>
</span>
</li>
);
})}
</ul>
<div className="mt-4">
<AdminPager
page={page}
total={total}
pageSize={20}
loading={loading}
onChange={(p) => void load(p)}
/>
</div>
</>
)}
</Modal>
);
}