Files
jiang13-bbs/frontend/app/admin/points/UserPointsTab.tsx
freefire 4ae57d9392 feat: 用户徽章与等级体系,积分经济管理重构
- 新增徽章系统:badge 服务与 handler、管理端配置页、BadgeChip/LevelBadge 展示组件
- 新增等级模型:model/level 及前端 levels 元数据
- 管理端经济模块拆分为 points/badges/levels 独立页面,EconomySettings 迁入设置
- 积分、签到、评论等服务适配等级与徽章逻辑
2026-09-25 16:38:29 +08:00

478 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}