Files
jiang13-bbs/frontend/app/admin/points/EconomyOverview.tsx
freefire 7a44c1f2cf feat: 可配置站点 URL 风格
- 新增 urlStyle 工具与测试:postPath/userPath/sitePagePath 统一生成链接
- 管理端新增 UrlStylePanel 设置面板,settings 服务与 handler 适配
- 全站路由链接与 JSON-LD/sitemap 切换为统一 URL 生成
- middleware 与相关组件适配
2026-09-27 04:01:42 +08:00

574 lines
19 KiB
TypeScript
Raw Permalink 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 { 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 { useUrlStyle } from "@/components/SiteBrandProvider";
import { userPath } from "@/lib/urlStyle";
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 urlStyle = useUrlStyle();
const name = partyName(user);
return (
<Link
href={userPath(urlStyle, 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>
);
}