"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"; import { userPath } from "@/lib/urlStyle"; import { useUrlStyle } from "@/components/SiteBrandProvider"; 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"; /** * 表头吸顶样式:sticky 相对 .admin-canvas 滚动口生效(lg 起外层不再是滚动容器,见下方包装层)。 * 分隔线用 inset 阴影而非 border——border-collapse: collapse 下吸顶 th 的边线会丢失。 */ const thStickStyle = { background: "var(--panel)", boxShadow: "inset 0 -1px 0 var(--line)", } as const; /** 表头排序按钮:未激活显示双向箭头,激活显示单向箭头并高亮 */ function SortHeader({ label, active, dir, onClick, }: { label: string; active: boolean; dir: "asc" | "desc"; onClick: () => void; }) { return ( ); } /** * 用户积分标签:搜索用户 → 余额/累计/等级一览;行内「调整」弹窗(加分计累计可升级, * 扣分仅扣余额不降级,理由必填)与「流水」弹窗(分页查看该用户积分流水)。 */ export default function UserPointsTab() { const urlStyle = useUrlStyle(); const [keyword, setKeyword] = useState(""); const [appliedQ, setAppliedQ] = useState(""); const [page, setPage] = useState(1); const [users, setUsers] = useState([]); const [total, setTotal] = useState(0); const [loading, setLoading] = useState(false); const [error, setError] = useState(""); const [loaded, setLoaded] = useState(false); const [sort, setSort] = useState("id"); const [dir, setDir] = useState<"asc" | "desc">("desc"); const [adjustTarget, setAdjustTarget] = useState(null); const [ledgerTarget, setLedgerTarget] = useState(null); const seqRef = useRef(0); const debounceRef = useRef | 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) => { 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 (
{/* lg 起放开横向滚动容器:sticky th 才能相对 admin-canvas 吸顶;窄屏保留横向滚动(不吸顶) */}
{users.map((u) => ( ))}
用户 toggleSort("points")} /> toggleSort("total_points")} /> 等级 操作
{u.nickname || u.username} @{u.username} {fmt(u.points)} {fmt(u.total_points)}
{loading ?

加载中…

: null} {!loading && error ?

{error}

: null} {!loading && !error && loaded && users.length === 0 ? (

没有匹配的用户

) : null}
{!error && users.length > 0 ? (
void load({ page: p, q: appliedQ })} />
) : null} {adjustTarget ? ( setAdjustTarget(null)} onAdjusted={(balance, totalPoints, level) => { patchUser(adjustTarget.id, { points: balance, total_points: totalPoints, level }); }} /> ) : null} {ledgerTarget ? ( setLedgerTarget(null)} /> ) : null}
); } /** 调整弹窗:方向(加/减)+ 数量 + 理由;成功后回填余额/累计/等级 */ 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 ( } >

方向

{dir === "add" ? "加分同时计入累计获得,可能触发升级" : "扣分仅扣减余额,不改变累计获得与等级"}

数量(1–{MAX_DELTA})

调整理由(必填,写入积分流水)