208 lines
9.2 KiB
JavaScript
Raw 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.

import AppsOutlined from "@mui/icons-material/AppsOutlined";
import CardGiftcardOutlined from "@mui/icons-material/CardGiftcardOutlined";
import CasinoOutlined from "@mui/icons-material/CasinoOutlined";
import SavingsOutlined from "@mui/icons-material/SavingsOutlined";
import { useMemo, useState } from "react";
import { AdminFilterSelect, AdminListToolbar } from "@/shared/ui/AdminListLayout.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
import { KpiCard } from "@/shared/ui/KpiCard.jsx";
import { TimeText } from "@/shared/ui/TimeText.jsx";
import { luckyGiftPoolKey } from "../api.js";
import { formatNumber, formatPercentFromPPM } from "../format.js";
import { LuckyGiftPoolActions } from "./LuckyGiftPoolActions.jsx";
import { OpsStatusBadge } from "./OpsStatusBadge.jsx";
export function OverviewView({ canCredit, canDebit, data, disabled, onCredit, onDebit }) {
const { appSummaries, poolBalances, summary } = data;
const [poolScope, setPoolScope] = useState("current");
const poolBalanceColumns = useMemo(
() => buildPoolBalanceColumns({ canCredit, canDebit, disabled, onCredit, onDebit }),
[canCredit, canDebit, disabled, onCredit, onDebit],
);
const poolCounts = useMemo(
() => ({
all: poolBalances.length,
current: poolBalances.filter((pool) => !pool.is_historical).length,
history: poolBalances.filter((pool) => pool.is_historical).length,
}),
[poolBalances],
);
const visiblePoolBalances = useMemo(() => {
if (poolScope === "history") {
return poolBalances.filter((pool) => pool.is_historical);
}
if (poolScope === "all") {
return poolBalances;
}
// 当前策略是默认视图;历史账本只从显式筛选进入,避免同名 V2/V3 被误读为重复奖池。
return poolBalances.filter((pool) => !pool.is_historical);
}, [poolBalances, poolScope]);
return (
<div className="ops-view">
<div className="kpi-grid ops-kpi-grid">
<KpiCard
icon={AppsOutlined}
label="运营应用"
sub={`主数据 ${formatNumber(countBySource(data.apps, "registry"))} · 外部接入 ${formatNumber(countBySource(data.apps, "fixed"))}`}
value={formatNumber(summary.activeApps)}
/>
<KpiCard
icon={CardGiftcardOutlined}
label="已发布奖池配置"
sub={`启用 ${formatNumber(summary.enabledPools)} · 停用 ${formatNumber(summary.configuredPools - summary.enabledPools)}`}
tone="info"
value={formatNumber(summary.configuredPools)}
/>
<KpiCard
icon={CasinoOutlined}
label="抽奖次数"
sub={`成功 ${formatNumber(summary.grantedDraws)} · 待发放 ${formatNumber(summary.pendingDraws)} · 失败 ${formatNumber(summary.failedDraws)}`}
tone="warning"
value={formatNumber(summary.totalDraws)}
/>
<KpiCard
icon={SavingsOutlined}
label="当前奖池金币"
sub={`可用 ${formatNumber(summary.poolAvailableTotal)} · 保底 ${formatNumber(summary.poolReserveTotal)}`}
tone="success"
value={formatNumber(summary.poolBalanceTotal)}
/>
</div>
<AdminListToolbar
filters={
<AdminFilterSelect
label="奖池范围"
options={[
["current", `当前奖池 (${poolCounts.current})`],
["history", `历史奖池 (${poolCounts.history})`],
["all", `全部奖池 (${poolCounts.all})`],
]}
value={poolScope}
onChange={setPoolScope}
/>
}
/>
<DataTable
columns={poolBalanceColumns}
emptyLabel={poolScope === "history" ? "暂无历史奖池" : "暂无奖池余额"}
items={visiblePoolBalances}
minWidth="1480px"
rowKey={luckyGiftPoolKey}
title="奖池实时水位"
total={visiblePoolBalances.length}
/>
<DataTable
columns={appSummaryColumns}
emptyLabel="暂无抽奖数据"
items={appSummaries}
minWidth="920px"
rowKey={(item) => item.app_code}
title="各应用抽奖汇总"
total={appSummaries.length}
/>
</div>
);
}
function buildPoolBalanceColumns({ canCredit, canDebit, disabled, onCredit, onDebit }) {
const columns = [
{ key: "app_code", label: "应用", width: "minmax(96px, 0.8fr)" },
{ key: "pool_id", label: "奖池", width: "minmax(110px, 1fr)" },
{ key: "strategy_version", label: "策略", width: "minmax(112px, 0.9fr)" },
{
key: "materialized",
label: "水位状态",
// materialized=false 是 owner 根据当前策略推导的初始水位;仍允许补水或扣水,首次调整由 owner 原子物化该策略奖池。
render: (item) => {
if (item.is_historical) {
return <OpsStatusBadge label="历史奖池" tone="stopped" />;
}
return item.materialized === false ? (
<OpsStatusBadge label="默认水位" />
) : (
<OpsStatusBadge label="已入账" tone="succeeded" />
);
},
width: "minmax(132px, 0.9fr)",
},
{ key: "balance", label: "当前余额", render: (item) => formatNumber(item.balance) },
{ key: "available_balance", label: "可用余额", render: (item) => formatNumber(item.available_balance) },
{
key: "reserve_floor",
label: "保底线",
render: (item) => formatNumber(item.reserve_floor),
width: "minmax(96px, 0.8fr)",
},
{ key: "total_in", label: "业务流入", render: (item) => formatNumber(item.total_in) },
{ key: "total_out", label: "返奖流出", render: (item) => formatNumber(item.total_out) },
{ key: "manual_credit_total", label: "人工添加", render: (item) => formatNumber(item.manual_credit_total) },
{ key: "manual_debit_total", label: "人工扣减", render: (item) => formatNumber(item.manual_debit_total) },
{
key: "updated_at_ms",
label: "更新时间",
render: (item) => (item.materialized === false ? "-" : <TimeText value={item.updated_at_ms} />),
width: "minmax(150px, 1fr)",
},
];
if (canCredit || canDebit) {
columns.push({
fixed: "right",
key: "actions",
label: "操作",
render: (item) => (
<LuckyGiftPoolActions
canCredit={canCredit}
canDebit={canDebit}
disabled={disabled}
pool={item}
onCredit={onCredit}
onDebit={onDebit}
/>
),
width: canCredit && canDebit ? "220px" : "120px",
});
}
return columns;
}
const appSummaryColumns = [
{ key: "app_code", label: "应用", width: "minmax(96px, 0.8fr)" },
{ key: "total_draws", label: "抽奖次数", render: (item) => formatNumber(item.total_draws) },
{ key: "unique_users", label: "参与用户", render: (item) => formatNumber(item.unique_users) },
{ key: "unique_rooms", label: "参与房间", render: (item) => formatNumber(item.unique_rooms) },
{ key: "total_spent_coins", label: "消耗金币", render: (item) => formatNumber(item.total_spent_coins) },
{ key: "total_reward_coins", label: "返还金币", render: (item) => formatNumber(item.total_reward_coins) },
{
key: "actual_rtp_ppm",
label: "实际 RTP",
render: (item) => (item.total_draws > 0 ? formatPercentFromPPM(item.actual_rtp_ppm) : "-"),
},
{
key: "exceptions",
label: "发放异常",
// 待发放和失败合并成一列告警:正常应为 0非 0 说明发奖链路有积压或失败,需要跟进抽奖记录。
render: (item) => {
const pending = Number(item.pending_draws || 0);
const failed = Number(item.failed_draws || 0);
if (!pending && !failed) {
return <OpsStatusBadge label="正常" tone="succeeded" />;
}
return (
<OpsStatusBadge
label={`待发放 ${formatNumber(pending)} · 失败 ${formatNumber(failed)}`}
tone={failed ? "danger" : "warning"}
/>
);
},
width: "minmax(170px, 1.2fr)",
},
];
function countBySource(apps = [], source) {
return apps.filter((item) => (item.source || "registry") === source).length;
}