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