feat: 用户徽章与等级体系,积分经济管理重构
- 新增徽章系统:badge 服务与 handler、管理端配置页、BadgeChip/LevelBadge 展示组件 - 新增等级模型:model/level 及前端 levels 元数据 - 管理端经济模块拆分为 points/badges/levels 独立页面,EconomySettings 迁入设置 - 积分、签到、评论等服务适配等级与徽章逻辑
This commit is contained in:
570
frontend/app/admin/points/EconomyOverview.tsx
Normal file
570
frontend/app/admin/points/EconomyOverview.tsx
Normal file
@@ -0,0 +1,570 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { ArrowRight, Coins, Flame, Gift, TrendingUp } from "lucide-react";
|
||||
import type { EconomyStats, LedgerParty, PointLedgerItem } from "@/lib/api";
|
||||
import { postTypeLabel } from "@/lib/postMeta";
|
||||
import { pointReasonLabel } from "@/lib/points";
|
||||
import { formatRelative } from "@/lib/format";
|
||||
import { AdminPageHeader, AdminSegmented, AdminStatCard } from "@/components/admin";
|
||||
import Avatar from "@/components/Avatar";
|
||||
import {
|
||||
AdminChartCard,
|
||||
DeltaBarChart,
|
||||
NamedBarChart,
|
||||
NamedPieChart,
|
||||
SingleAreaChart,
|
||||
} from "@/components/admin/charts";
|
||||
|
||||
const fmt = (n: number) => n.toLocaleString("zh-CN");
|
||||
|
||||
function netDeltaLabel(n: number) {
|
||||
if (n > 0) return `+${fmt(n)}`;
|
||||
return fmt(n);
|
||||
}
|
||||
|
||||
type LedgerKind = "checkin" | "bounty" | "file" | "typed" | "system";
|
||||
type LedgerFilter =
|
||||
| "all"
|
||||
| "granted"
|
||||
| "spent"
|
||||
| "poll"
|
||||
| "lottery"
|
||||
| "discussion"
|
||||
| "question";
|
||||
|
||||
const LEDGER_FILTERS: { key: LedgerFilter; label: string }[] = [
|
||||
{ key: "all", label: "全部" },
|
||||
{ key: "granted", label: "发放" },
|
||||
{ key: "spent", label: "消耗" },
|
||||
{ key: "poll", label: "投票" },
|
||||
{ key: "lottery", label: "抽奖" },
|
||||
{ key: "discussion", label: "讨论" },
|
||||
{ key: "question", label: "问答" },
|
||||
];
|
||||
|
||||
const EMPTY_LEDGER: PointLedgerItem[] = [];
|
||||
|
||||
const NOTE_STRIP_EXTRAS = [
|
||||
"解锁帖子",
|
||||
"下载附件",
|
||||
"附件收益",
|
||||
"正文解锁收益",
|
||||
"悬赏采纳",
|
||||
"悬赏截止后采纳扣款",
|
||||
"悬赏过期退回",
|
||||
"历史签到积分回填",
|
||||
"每日签到",
|
||||
];
|
||||
|
||||
function partyName(u: LedgerParty) {
|
||||
return u.nickname || u.username || `#${u.id}`;
|
||||
}
|
||||
|
||||
function isToday(iso: string) {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return false;
|
||||
const n = new Date();
|
||||
return (
|
||||
d.getFullYear() === n.getFullYear() &&
|
||||
d.getMonth() === n.getMonth() &&
|
||||
d.getDate() === n.getDate()
|
||||
);
|
||||
}
|
||||
|
||||
function ledgerKind(reason: string): LedgerKind {
|
||||
switch (reason) {
|
||||
case "checkin":
|
||||
case "migrate_checkin":
|
||||
return "checkin";
|
||||
case "bounty_escrow":
|
||||
case "bounty_refund":
|
||||
case "bounty_award":
|
||||
return "bounty";
|
||||
case "download_file":
|
||||
return "file";
|
||||
default: {
|
||||
const blob = pointReasonLabel(reason);
|
||||
if (/投票|抽奖|问答/.test(blob)) return "typed";
|
||||
return "system";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function ledgerKindEmoji(kind: LedgerKind) {
|
||||
if (kind === "checkin") return "📅";
|
||||
if (kind === "bounty") return "🎯";
|
||||
if (kind === "file") return "📎";
|
||||
if (kind === "typed") return "🗳";
|
||||
return "⚙";
|
||||
}
|
||||
|
||||
function blobOf(it: PointLedgerItem) {
|
||||
return `${pointReasonLabel(it.reason)} ${it.note || ""} ${it.reason}`;
|
||||
}
|
||||
|
||||
function matchLedgerFilter(it: PointLedgerItem, filter: LedgerFilter) {
|
||||
if (filter === "all") return true;
|
||||
const delta = Number(it.delta) || 0;
|
||||
const transfer = Boolean(it.from && it.to);
|
||||
if (filter === "granted") return transfer || delta > 0;
|
||||
if (filter === "spent") return transfer || delta < 0;
|
||||
const blob = blobOf(it);
|
||||
if (filter === "poll") return /投票/.test(blob);
|
||||
if (filter === "lottery") return /抽奖/.test(blob);
|
||||
if (filter === "question") return /问答/.test(blob);
|
||||
if (filter === "discussion") return it.reason === "unlock_post" || /讨论/.test(blob);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 去掉与类型标签重复的前缀,避免「悬赏托管:悬赏托管:悬赏」 */
|
||||
function ledgerDesc(reason: string, note: string) {
|
||||
const label = pointReasonLabel(reason);
|
||||
let s = (note || "").trim();
|
||||
const prefixes = [label, ...NOTE_STRIP_EXTRAS].filter(Boolean);
|
||||
let guard = 0;
|
||||
let changed = true;
|
||||
while (changed && s && guard < 8) {
|
||||
changed = false;
|
||||
guard += 1;
|
||||
for (const p of prefixes) {
|
||||
if (s.startsWith(p)) {
|
||||
s = s.slice(p.length).replace(/^[\s::·\-—]+/, "");
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (s === label) return "";
|
||||
return s;
|
||||
}
|
||||
|
||||
function todayDelta(items: PointLedgerItem[]) {
|
||||
let granted = 0;
|
||||
let spent = 0;
|
||||
for (const it of items) {
|
||||
if (!isToday(it.created_at)) continue;
|
||||
const n = Math.abs(Number(it.delta) || 0);
|
||||
if (n === 0) continue;
|
||||
if (it.from && it.to) {
|
||||
granted += n;
|
||||
spent += n;
|
||||
continue;
|
||||
}
|
||||
if ((it.delta || 0) > 0) granted += n;
|
||||
else spent += n;
|
||||
}
|
||||
return { granted, spent };
|
||||
}
|
||||
|
||||
type FlowHealth = "low" | "ok" | "hot";
|
||||
|
||||
function stockFromStats(stats: EconomyStats) {
|
||||
let granted = 0;
|
||||
let spent = 0;
|
||||
const series = stats.delta_series || [];
|
||||
const seriesHas = series.some((d) => (Number(d.granted) || 0) > 0 || (Number(d.spent) || 0) > 0);
|
||||
if (seriesHas) {
|
||||
for (const d of series) {
|
||||
granted += Math.max(0, Number(d.granted) || 0);
|
||||
spent += Math.max(0, Number(d.spent) || 0);
|
||||
}
|
||||
} else {
|
||||
for (const it of stats.recent_ledger || []) {
|
||||
const n = Number(it.delta) || 0;
|
||||
if (n > 0) granted += n;
|
||||
else if (n < 0) spent += -n;
|
||||
}
|
||||
}
|
||||
let frozen = 0;
|
||||
for (const it of stats.recent_ledger || []) {
|
||||
if (it.reason === "bounty_escrow" && (Number(it.delta) || 0) < 0) {
|
||||
frozen += Math.abs(Number(it.delta));
|
||||
}
|
||||
}
|
||||
if ((stats.escrowed_points || 0) > frozen) {
|
||||
frozen = stats.escrowed_points;
|
||||
}
|
||||
const circulating = granted - spent;
|
||||
const empty = granted <= 0 && spent <= 0 && frozen <= 0;
|
||||
const rate = granted > 0 ? Math.round((spent / granted) * 100) : 0;
|
||||
let health: FlowHealth = "ok";
|
||||
if (rate < 20) health = "low";
|
||||
else if (rate > 60) health = "hot";
|
||||
return { granted, spent, circulating, frozen, empty, rate, health };
|
||||
}
|
||||
|
||||
const HEALTH_COPY: Record<FlowHealth, string> = {
|
||||
low: "积分消耗偏低,可考虑增加积分消耗场景",
|
||||
ok: "积分经济体健康",
|
||||
hot: "积分消耗较快,注意通胀",
|
||||
};
|
||||
|
||||
const HEALTH_LABEL: Record<FlowHealth, string> = {
|
||||
low: "偏低",
|
||||
ok: "健康",
|
||||
hot: "偏快",
|
||||
};
|
||||
|
||||
function StockOverviewCard({ stats }: { stats: EconomyStats }) {
|
||||
const stock = useMemo(() => stockFromStats(stats), [stats]);
|
||||
const { granted, spent, circulating, frozen, empty, rate, health } = stock;
|
||||
const tip = HEALTH_COPY[health];
|
||||
const showFrozen = frozen > 0;
|
||||
|
||||
let circBarPct = granted > 0 ? Math.max(0, (circulating / granted) * 100) : 0;
|
||||
const spentLabelPct = granted > 0 ? Math.min(100, (spent / granted) * 100) : 0;
|
||||
const circLabelPct = granted > 0 ? Math.max(0, Math.round((Math.max(0, circulating) / granted) * 100)) : 0;
|
||||
const netSpent = Math.max(0, spent - frozen);
|
||||
let netSpentPct = granted > 0 ? (netSpent / granted) * 100 : 0;
|
||||
let frozenPct = granted > 0 && showFrozen ? (frozen / granted) * 100 : 0;
|
||||
const segSum = circBarPct + netSpentPct + frozenPct;
|
||||
if (segSum > 100 && segSum > 0) {
|
||||
const scale = 100 / segSum;
|
||||
circBarPct *= scale;
|
||||
netSpentPct *= scale;
|
||||
frozenPct *= scale;
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="stock-card">
|
||||
<div className="flex items-start justify-between gap-3 mb-3">
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-[14.5px] font-semibold" style={{ color: "var(--ink)" }}>
|
||||
积分存量概览
|
||||
</h3>
|
||||
<p className="meta text-[11.5px] mt-0.5 leading-relaxed">基于全站累计流水计算</p>
|
||||
</div>
|
||||
{!empty ? (
|
||||
<span
|
||||
className={`stock-badge is-${health} shrink-0`}
|
||||
title={tip}
|
||||
>
|
||||
流通率 {granted > 0 ? `${rate}%` : "—"} · {HEALTH_LABEL[health]}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{empty ? (
|
||||
<div className="stock-card-body items-center justify-center">
|
||||
<p className="meta text-center">暂无流水,无法计算存量</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="stock-card-body">
|
||||
<div>
|
||||
<p className="text-[13px]" style={{ color: "var(--ink-3)" }}>
|
||||
流通中积分
|
||||
</p>
|
||||
<p
|
||||
className="tabular-nums text-[32px] font-bold leading-none mt-1"
|
||||
style={{ color: "var(--stock-circ)", fontVariantNumeric: "tabular-nums" }}
|
||||
>
|
||||
{fmt(circulating)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div
|
||||
className="stock-bar"
|
||||
role="img"
|
||||
aria-label={`流通中 ${circLabelPct}%,消耗 ${Math.round(spentLabelPct)}%${
|
||||
showFrozen ? `,托管冻结 ${fmt(frozen)}` : ""
|
||||
}`}
|
||||
>
|
||||
{circBarPct > 0 ? (
|
||||
<span className="stock-bar-seg is-circ" style={{ width: `${circBarPct}%` }} />
|
||||
) : null}
|
||||
{netSpentPct > 0 ? (
|
||||
<span className="stock-bar-seg is-spent" style={{ width: `${netSpentPct}%` }} />
|
||||
) : null}
|
||||
{frozenPct > 0 ? (
|
||||
<span className="stock-bar-seg is-frozen" style={{ width: `${frozenPct}%` }} />
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 mt-1.5 text-[12px] tabular-nums">
|
||||
<span style={{ color: "var(--stock-circ)" }}>流通中 {circLabelPct}%</span>
|
||||
<span style={{ color: "var(--ink-3)" }}>消耗 {Math.round(spentLabelPct)}%</span>
|
||||
{showFrozen ? (
|
||||
<span style={{ color: "var(--stock-frozen)" }}>托管 {fmt(frozen)}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-[13px] flex flex-wrap items-center gap-x-4 gap-y-1" style={{ color: "var(--ink-2)" }}>
|
||||
<span>
|
||||
累计发放{" "}
|
||||
<strong className="tabular-nums font-semibold" style={{ color: "var(--ink)" }}>
|
||||
{fmt(granted)}
|
||||
</strong>
|
||||
</span>
|
||||
<span>
|
||||
累计消耗{" "}
|
||||
<strong className="tabular-nums font-semibold" style={{ color: "var(--ink)" }}>
|
||||
{fmt(spent)}
|
||||
</strong>
|
||||
</span>
|
||||
{showFrozen ? (
|
||||
<span>
|
||||
托管冻结{" "}
|
||||
<strong className="tabular-nums font-semibold" style={{ color: "var(--ink)" }}>
|
||||
{fmt(frozen)}
|
||||
</strong>
|
||||
</span>
|
||||
) : null}
|
||||
</p>
|
||||
{showFrozen ? (
|
||||
<p className="text-[12px] mt-1.5" style={{ color: "var(--ink-3)" }}>
|
||||
托管的 {fmt(frozen)} 积分处于冻结态,悬赏采纳或退回后释放
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function LedgerUserLink({ user, compact }: { user: LedgerParty; compact?: boolean }) {
|
||||
const name = partyName(user);
|
||||
return (
|
||||
<Link
|
||||
href={`/u/${user.id}`}
|
||||
className="flex items-center gap-1.5 min-w-0 max-w-[128px] rounded-md hover:bg-[var(--panel-2)] -mx-0.5 px-0.5 py-0.5 transition-colors"
|
||||
title={`${name} @${user.username}`}
|
||||
>
|
||||
<Avatar name={name} src={user.avatar || undefined} size={compact ? 22 : 24} />
|
||||
<span className="text-[13px] truncate" style={{ color: "var(--ink)" }}>
|
||||
{name}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function ledgerAria(it: PointLedgerItem) {
|
||||
const reason = pointReasonLabel(it.reason);
|
||||
const delta = Number(it.delta) || 0;
|
||||
const signed = delta > 0 ? `+${fmt(delta)}` : fmt(delta);
|
||||
if (it.from && it.to) {
|
||||
return `${partyName(it.from)} 向 ${partyName(it.to)} 支付 ${fmt(Math.abs(delta))} 积分,${reason}`;
|
||||
}
|
||||
const who = it.user ? partyName(it.user) : "未知用户";
|
||||
return `${who} ${signed} 积分,${reason}`;
|
||||
}
|
||||
|
||||
function RecentLedgerRow({ it }: { it: PointLedgerItem }) {
|
||||
const payer = it.from || it.user;
|
||||
const payee = it.from && it.to ? it.to : undefined;
|
||||
const kind = ledgerKind(it.reason);
|
||||
const desc = ledgerDesc(it.reason, it.note || "");
|
||||
const delta = Number(it.delta);
|
||||
const showAmount = Number.isFinite(delta) && delta !== 0;
|
||||
|
||||
return (
|
||||
<li className="ledger-row" aria-label={ledgerAria(it)}>
|
||||
<span className={`ledger-kind-icon is-${kind}`} aria-hidden>
|
||||
{ledgerKindEmoji(kind)}
|
||||
</span>
|
||||
|
||||
{payer ? (
|
||||
<LedgerUserLink user={payer} />
|
||||
) : (
|
||||
<span className="text-[13px] shrink-0" style={{ color: "var(--ink-3)" }}>
|
||||
未知用户
|
||||
</span>
|
||||
)}
|
||||
|
||||
{payee ? (
|
||||
<span className="hidden xl:flex items-center gap-1.5 min-w-0 shrink-0">
|
||||
<ArrowRight size={13} className="shrink-0" style={{ color: "var(--ink-3)" }} aria-hidden />
|
||||
<LedgerUserLink user={payee} compact />
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
<div className="min-w-0 flex-1 flex items-center gap-2">
|
||||
<span className={`ledger-kind-tag is-${kind}`}>{pointReasonLabel(it.reason)}</span>
|
||||
{desc ? (
|
||||
<span className="min-w-0 flex-1 truncate text-[12.5px]" style={{ color: "var(--ink-2)" }}>
|
||||
{desc}
|
||||
</span>
|
||||
) : (
|
||||
<span className="min-w-0 flex-1" />
|
||||
)}
|
||||
<span className="shrink-0 text-[12px] tabular-nums" style={{ color: "var(--ink-3)" }}>
|
||||
{formatRelative(it.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<span
|
||||
className="ledger-amount"
|
||||
style={showAmount ? { color: delta > 0 ? "var(--ledger-in)" : "var(--ledger-out)" } : undefined}
|
||||
>
|
||||
{showAmount ? (delta > 0 ? `+${fmt(delta)}` : fmt(delta)) : null}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export default function EconomyClient({ stats }: { stats: EconomyStats }) {
|
||||
const [filter, setFilter] = useState<LedgerFilter>("all");
|
||||
const ledger = stats.recent_ledger ?? EMPTY_LEDGER;
|
||||
|
||||
const checkinSeries = (stats.checkin_series || []).map((d) => ({
|
||||
date: d.date,
|
||||
count: d.count,
|
||||
}));
|
||||
|
||||
const ledgerByReason = (stats.ledger_by_reason || []).map((r) => ({
|
||||
label: pointReasonLabel(r.key || r.label),
|
||||
count: r.count,
|
||||
}));
|
||||
|
||||
const deltaSeries = stats.delta_series || [];
|
||||
|
||||
const postsByType = Object.entries(stats.posts_by_type || {})
|
||||
.map(([t, n]) => ({ label: postTypeLabel(t), count: Number(n) || 0 }))
|
||||
.filter((d) => d.count > 0)
|
||||
.sort((a, b) => b.count - a.count);
|
||||
|
||||
const net = stats.net_delta_7d ?? 0;
|
||||
const today = useMemo(() => todayDelta(ledger), [ledger]);
|
||||
const filtered = useMemo(
|
||||
() => ledger.filter((it) => matchLedgerFilter(it, filter)),
|
||||
[ledger, filter]
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<AdminPageHeader
|
||||
icon={Coins}
|
||||
title="积分与类型帖看板"
|
||||
description="观察全站积分流通、悬赏托管与帖子类型分布"
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-3 mb-5">
|
||||
<AdminStatCard
|
||||
label="用户余额合计"
|
||||
value={fmt(stats.total_balance)}
|
||||
hint="全站可用积分之和"
|
||||
icon={<Coins size={16} />}
|
||||
/>
|
||||
<AdminStatCard
|
||||
label="今日签到"
|
||||
value={fmt(stats.checkin_today)}
|
||||
hint="今日已签到人数"
|
||||
icon={<Flame size={16} />}
|
||||
color="var(--clay)"
|
||||
soft="var(--clay-soft)"
|
||||
/>
|
||||
<AdminStatCard
|
||||
label="托管中悬赏"
|
||||
value={`${fmt(stats.open_bounties)} / ${fmt(stats.escrowed_points)}`}
|
||||
hint="未结算帖数 / 托管积分"
|
||||
icon={<Gift size={16} />}
|
||||
color="var(--gold)"
|
||||
soft="var(--gold-soft)"
|
||||
/>
|
||||
<AdminStatCard
|
||||
label="近 7 日净变动"
|
||||
value={netDeltaLabel(net)}
|
||||
hint="发放减去消耗"
|
||||
icon={<TrendingUp size={16} />}
|
||||
color={net >= 0 ? "var(--ok)" : "var(--danger)"}
|
||||
soft={net >= 0 ? "var(--ok-soft)" : "var(--danger-soft)"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4 [&>*]:min-w-0">
|
||||
<AdminChartCard title="签到趋势" hint="近 14 日每日签到人数">
|
||||
{checkinSeries.length === 0 ? (
|
||||
<p className="meta text-center py-16">暂无签到数据</p>
|
||||
) : (
|
||||
<SingleAreaChart
|
||||
data={checkinSeries}
|
||||
dataKey="count"
|
||||
name="签到"
|
||||
color="var(--clay)"
|
||||
/>
|
||||
)}
|
||||
</AdminChartCard>
|
||||
|
||||
<AdminChartCard title="流水原因分布" hint="按入账原因聚合条数">
|
||||
{ledgerByReason.length === 0 ? (
|
||||
<p className="meta text-center py-16">暂无流水</p>
|
||||
) : (
|
||||
<NamedBarChart data={ledgerByReason} layout="horizontal" />
|
||||
)}
|
||||
</AdminChartCard>
|
||||
|
||||
<AdminChartCard title="发放 / 消耗" hint="近 14 日积分进出">
|
||||
{deltaSeries.length === 0 ? (
|
||||
<p className="meta text-center py-16">暂无数据</p>
|
||||
) : (
|
||||
<DeltaBarChart data={deltaSeries} />
|
||||
)}
|
||||
</AdminChartCard>
|
||||
|
||||
<AdminChartCard title="帖子类型分布" hint="全站帖子按类型占比">
|
||||
{postsByType.length === 0 ? (
|
||||
<p className="meta text-center py-16">暂无数据</p>
|
||||
) : (
|
||||
<NamedPieChart data={postsByType} />
|
||||
)}
|
||||
</AdminChartCard>
|
||||
|
||||
<StockOverviewCard stats={stats} />
|
||||
|
||||
<section className="panel overflow-hidden flex flex-col max-h-[420px]">
|
||||
<div
|
||||
className="px-4 py-3 flex items-start justify-between gap-2 flex-wrap"
|
||||
style={{ borderBottom: "1px solid var(--line)" }}
|
||||
>
|
||||
<p className="font-medium text-[15px] pt-0.5" style={{ color: "var(--ink)" }}>
|
||||
最近流水
|
||||
</p>
|
||||
<AdminSegmented
|
||||
size="sm"
|
||||
role="group"
|
||||
ariaLabel="按类型筛选最近流水"
|
||||
options={LEDGER_FILTERS}
|
||||
value={filter}
|
||||
onChange={setFilter}
|
||||
/>
|
||||
</div>
|
||||
<p className="px-4 py-2 text-[12.5px] shrink-0" style={{ borderBottom: "1px solid var(--line)", color: "var(--ink-2)" }}>
|
||||
今日 发放{" "}
|
||||
<span className="tabular-nums font-semibold" style={{ color: "var(--ledger-in)" }}>
|
||||
+{fmt(today.granted)}
|
||||
</span>
|
||||
{" · "}
|
||||
消耗{" "}
|
||||
<span className="tabular-nums font-semibold" style={{ color: "var(--ledger-out)" }}>
|
||||
-{fmt(today.spent)}
|
||||
</span>
|
||||
</p>
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
{ledger.length === 0 ? (
|
||||
<p className="meta text-center py-10">暂无流水</p>
|
||||
) : filtered.length === 0 ? (
|
||||
<p className="meta text-center py-10">该分类下暂无流水</p>
|
||||
) : (
|
||||
<ul>
|
||||
{filtered.map((it) => (
|
||||
<RecentLedgerRow key={it.id} it={it} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<p className="meta text-[12.5px] mt-5">
|
||||
用户可在{" "}
|
||||
<Link href="/points" className="hover:text-[var(--accent)]">
|
||||
/points
|
||||
</Link>{" "}
|
||||
查看个人流水。
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
93
frontend/app/admin/points/PointsTabs.tsx
Normal file
93
frontend/app/admin/points/PointsTabs.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { AdminSegmented } from "@/components/admin";
|
||||
import type { EconomyStats, PublicSettings } from "@/lib/api";
|
||||
import EconomyOverview from "./EconomyOverview";
|
||||
import EconomySettings from "../settings/EconomySettings";
|
||||
import UserPointsTab from "./UserPointsTab";
|
||||
|
||||
type TabKey = "overview" | "rules" | "users";
|
||||
|
||||
const TAB_OPTIONS = [
|
||||
{ key: "overview", label: "经济总览" },
|
||||
{ key: "rules", label: "积分规则" },
|
||||
{ key: "users", label: "用户积分" },
|
||||
];
|
||||
|
||||
/**
|
||||
* 积分管理页标签壳:经济总览(管理员及以上)/ 积分规则 + 用户积分(超管/站长)。
|
||||
* tab 记在 URL query(?tab=)便于刷新/分享保持;三个面板保持挂载、用 display 切换,
|
||||
* 避免切标签丢失规则的未保存修改与用户列表状态。
|
||||
*/
|
||||
export default function PointsTabs({
|
||||
stats,
|
||||
settings,
|
||||
canManage,
|
||||
}: {
|
||||
stats: EconomyStats | null;
|
||||
settings: PublicSettings | null;
|
||||
canManage: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const queryTab = searchParams.get("tab");
|
||||
const [tab, setTab] = useState<TabKey>(() => {
|
||||
const t = queryTab;
|
||||
if (canManage && (t === "rules" || t === "users")) return t;
|
||||
return "overview";
|
||||
});
|
||||
|
||||
const switchTab = useCallback(
|
||||
(next: string) => {
|
||||
setTab(next as TabKey);
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
if (next === "overview") params.delete("tab");
|
||||
else params.set("tab", next);
|
||||
const qs = params.toString();
|
||||
router.replace(qs ? `?${qs}` : "?", { scroll: false });
|
||||
},
|
||||
[router, searchParams]
|
||||
);
|
||||
|
||||
const options = canManage
|
||||
? TAB_OPTIONS
|
||||
: TAB_OPTIONS.filter((o) => o.key === "overview");
|
||||
|
||||
return (
|
||||
<div>
|
||||
{canManage ? (
|
||||
<div className="mb-4">
|
||||
<AdminSegmented
|
||||
options={options}
|
||||
value={tab}
|
||||
onChange={(v) => switchTab(String(v))}
|
||||
ariaLabel="积分管理标签"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* 各面板保持挂载(display 切换):规则未保存修改与用户列表状态不因切标签丢失 */}
|
||||
<div style={tab === "overview" ? undefined : { display: "none" }}>
|
||||
{stats ? (
|
||||
<EconomyOverview stats={stats} />
|
||||
) : (
|
||||
<div className="panel p-8 text-center">
|
||||
<p className="meta">暂时无法加载积分数据</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{canManage ? (
|
||||
<>
|
||||
<div style={tab === "rules" ? undefined : { display: "none" }}>
|
||||
{settings ? <EconomySettings initial={settings} /> : null}
|
||||
</div>
|
||||
<div style={tab === "users" ? undefined : { display: "none" }}>
|
||||
<UserPointsTab />
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
44
frontend/app/admin/points/page.tsx
Normal file
44
frontend/app/admin/points/page.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { Metadata } from "next";
|
||||
import { cookies } from "next/headers";
|
||||
import { fetchAdminEconomyStats, type EconomyStats, type PublicSettings } from "@/lib/api";
|
||||
import { authCookieHeader } from "@/lib/cookies";
|
||||
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
|
||||
import { isAdminOrAbove, isSuperOrOwner } from "@/lib/roles";
|
||||
import Forbidden from "../Forbidden";
|
||||
import PointsTabs from "./PointsTabs";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "积分管理",
|
||||
};
|
||||
|
||||
export default async function AdminPointsPage() {
|
||||
const cookie = authCookieHeader(await cookies());
|
||||
const me = await getMeCached(cookie || undefined);
|
||||
if (!me.user || !isAdminOrAbove(me.user.role)) {
|
||||
return <Forbidden text="积分管理仅管理员及以上可查看。" />;
|
||||
}
|
||||
|
||||
const canManage = isSuperOrOwner(me.user.role);
|
||||
|
||||
// 经济总览预取(管理员及以上可读);失败降级为空态,不阻塞规则/用户积分标签
|
||||
let stats: Awaited<ReturnType<typeof fetchAdminEconomyStats>> | null = null;
|
||||
try {
|
||||
stats = await fetchAdminEconomyStats(cookie);
|
||||
} catch {
|
||||
stats = null;
|
||||
}
|
||||
|
||||
// 积分规则初始值(公开设置缓存,超管/站长的规则标签用)
|
||||
let settings: PublicSettings | null = null;
|
||||
if (canManage) {
|
||||
settings = await getPublicSettingsCached();
|
||||
}
|
||||
|
||||
return (
|
||||
<PointsTabs
|
||||
stats={stats}
|
||||
settings={settings}
|
||||
canManage={canManage}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user