"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 = { low: "积分消耗偏低,可考虑增加积分消耗场景", ok: "积分经济体健康", hot: "积分消耗较快,注意通胀", }; const HEALTH_LABEL: Record = { 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 (

积分存量概览

基于全站累计流水计算

{!empty ? ( 流通率 {granted > 0 ? `${rate}%` : "—"} · {HEALTH_LABEL[health]} ) : null}
{empty ? (

暂无流水,无法计算存量

) : (

流通中积分

{fmt(circulating)}

{circBarPct > 0 ? ( ) : null} {netSpentPct > 0 ? ( ) : null} {frozenPct > 0 ? ( ) : null}
流通中 {circLabelPct}% 消耗 {Math.round(spentLabelPct)}% {showFrozen ? ( 托管 {fmt(frozen)} ) : null}

累计发放{" "} {fmt(granted)} 累计消耗{" "} {fmt(spent)} {showFrozen ? ( 托管冻结{" "} {fmt(frozen)} ) : null}

{showFrozen ? (

托管的 {fmt(frozen)} 积分处于冻结态,悬赏采纳或退回后释放

) : null}
)}
); } function LedgerUserLink({ user, compact }: { user: LedgerParty; compact?: boolean }) { const urlStyle = useUrlStyle(); const name = partyName(user); return ( {name} ); } 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 (
  • {ledgerKindEmoji(kind)} {payer ? ( ) : ( 未知用户 )} {payee ? ( ) : null}
    {pointReasonLabel(it.reason)} {desc ? ( {desc} ) : ( )} {formatRelative(it.created_at)}
    0 ? "var(--ledger-in)" : "var(--ledger-out)" } : undefined} > {showAmount ? (delta > 0 ? `+${fmt(delta)}` : fmt(delta)) : null}
  • ); } export default function EconomyClient({ stats }: { stats: EconomyStats }) { const [filter, setFilter] = useState("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 (
    } /> } color="var(--clay)" soft="var(--clay-soft)" /> } color="var(--gold)" soft="var(--gold-soft)" /> } color={net >= 0 ? "var(--ok)" : "var(--danger)"} soft={net >= 0 ? "var(--ok-soft)" : "var(--danger-soft)"} />
    {checkinSeries.length === 0 ? (

    暂无签到数据

    ) : ( )}
    {ledgerByReason.length === 0 ? (

    暂无流水

    ) : ( )}
    {deltaSeries.length === 0 ? (

    暂无数据

    ) : ( )}
    {postsByType.length === 0 ? (

    暂无数据

    ) : ( )}

    最近流水

    今日 发放{" "} +{fmt(today.granted)} {" · "} 消耗{" "} -{fmt(today.spent)}

    {ledger.length === 0 ? (

    暂无流水

    ) : filtered.length === 0 ? (

    该分类下暂无流水

    ) : (
      {filtered.map((it) => ( ))}
    )}

    用户可在{" "} /points {" "} 查看个人流水。

    ); }