- 新增 urlStyle 工具与测试:postPath/userPath/sitePagePath 统一生成链接 - 管理端新增 UrlStylePanel 设置面板,settings 服务与 handler 适配 - 全站路由链接与 JSON-LD/sitemap 切换为统一 URL 生成 - middleware 与相关组件适配
506 lines
18 KiB
TypeScript
506 lines
18 KiB
TypeScript
"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 (
|
||
<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 urlStyle = useUrlStyle();
|
||
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">
|
||
{/* lg 起放开横向滚动容器:sticky th 才能相对 admin-canvas 吸顶;窄屏保留横向滚动(不吸顶) */}
|
||
<div className="overflow-x-auto lg:overflow-x-visible">
|
||
<table className="w-full text-[13px]" style={{ borderCollapse: "collapse" }}>
|
||
<thead>
|
||
<tr>
|
||
<th
|
||
className="sticky top-0 z-10 px-4 py-2.5 text-left font-medium"
|
||
style={{ color: "var(--ink-3)", ...thStickStyle }}
|
||
>
|
||
用户
|
||
</th>
|
||
<th
|
||
className="sticky top-0 z-10 px-4 py-2.5 text-right font-medium"
|
||
style={{ color: "var(--ink-3)", ...thStickStyle }}
|
||
>
|
||
<SortHeader label="余额" active={sort === "points"} dir={dir} onClick={() => toggleSort("points")} />
|
||
</th>
|
||
<th
|
||
className="sticky top-0 z-10 px-4 py-2.5 text-right font-medium"
|
||
style={{ color: "var(--ink-3)", ...thStickStyle }}
|
||
>
|
||
<SortHeader label="累计获得" active={sort === "total_points"} dir={dir} onClick={() => toggleSort("total_points")} />
|
||
</th>
|
||
<th
|
||
className="sticky top-0 z-10 px-4 py-2.5 text-left font-medium"
|
||
style={{ color: "var(--ink-3)", ...thStickStyle }}
|
||
>
|
||
等级
|
||
</th>
|
||
<th
|
||
className="sticky top-0 z-10 px-4 py-2.5 text-right font-medium"
|
||
style={{ color: "var(--ink-3)", ...thStickStyle }}
|
||
>
|
||
操作
|
||
</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={userPath(urlStyle, 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>
|
||
);
|
||
}
|