285 lines
12 KiB
JavaScript
285 lines
12 KiB
JavaScript
import AppsOutlined from "@mui/icons-material/AppsOutlined";
|
||
import CardGiftcardOutlined from "@mui/icons-material/CardGiftcardOutlined";
|
||
import CasinoOutlined from "@mui/icons-material/CasinoOutlined";
|
||
import InsightsOutlined from "@mui/icons-material/InsightsOutlined";
|
||
import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
|
||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||
import { PERMISSIONS } from "@/app/permissions";
|
||
import { Button } from "@/shared/ui/Button.jsx";
|
||
import { DataState } from "@/shared/ui/DataState.jsx";
|
||
import { PageHead } from "@/shared/ui/PageHead.jsx";
|
||
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||
import { adjustLuckyGiftPool, DEFAULT_POOL_ID, fetchOpsDashboard, saveLuckyGiftConfig } from "./api.js";
|
||
import { AppsView } from "./components/AppsView.jsx";
|
||
import { ConfigsView } from "./components/ConfigsView.jsx";
|
||
import { DrawsView } from "./components/DrawsView.jsx";
|
||
import { LuckyGiftConfigDialog } from "./components/LuckyGiftConfigDialog.jsx";
|
||
import { LuckyGiftPoolAdjustmentDialog } from "./components/LuckyGiftPoolAdjustmentDialog.jsx";
|
||
import { OpsShell } from "./components/OpsShell.jsx";
|
||
import { OverviewView } from "./components/OverviewView.jsx";
|
||
import { formatNumber } from "./format.js";
|
||
|
||
const views = [
|
||
{ icon: InsightsOutlined, key: "overview", label: "数据总览" },
|
||
{ icon: CardGiftcardOutlined, key: "configs", label: "幸运礼物配置" },
|
||
{ icon: CasinoOutlined, key: "draws", label: "抽奖记录" },
|
||
{ icon: AppsOutlined, key: "apps", label: "应用列表" },
|
||
];
|
||
const viewKeys = new Set(views.map((view) => view.key));
|
||
|
||
const initialDashboard = {
|
||
apps: [],
|
||
appSummaries: [],
|
||
luckyGifts: [],
|
||
poolBalances: [],
|
||
summary: {},
|
||
};
|
||
|
||
export function initialOpsCenterView(search = globalThis.location?.search || "") {
|
||
const requested = new URLSearchParams(search).get("view") || "";
|
||
return viewKeys.has(requested) ? requested : "overview";
|
||
}
|
||
|
||
const denyPermission = () => false;
|
||
|
||
export function OpsCenterApp({ can = denyPermission }) {
|
||
// 主后台旧入口通过 ?view=configs 进入独立 HTML;只接受白名单视图,异常参数仍回到总览。
|
||
const [activeView, setActiveView] = useState(() => initialOpsCenterView());
|
||
const [state, setState] = useState({ data: initialDashboard, error: "", loaded: false, loading: true });
|
||
const [dialog, setDialog] = useState(null);
|
||
const [poolDialog, setPoolDialog] = useState(null);
|
||
const [adjustingPool, setAdjustingPool] = useState(false);
|
||
const [savingConfig, setSavingConfig] = useState(false);
|
||
const { showToast } = useToast();
|
||
const canCreditPool = can(PERMISSIONS.luckyGiftPoolCredit);
|
||
const canDebitPool = can(PERMISSIONS.luckyGiftPoolDebit);
|
||
const canUpdateConfig = can(PERMISSIONS.luckyGiftUpdate);
|
||
|
||
const loadDashboard = useCallback(async () => {
|
||
setState((current) => ({ ...current, error: "", loading: true }));
|
||
try {
|
||
const data = await fetchOpsDashboard();
|
||
setState({ data, error: "", loaded: true, loading: false });
|
||
} catch (error) {
|
||
// 接口灰度期间保留已加载的数据和页面结构,单次失败只提示、不整页白屏。
|
||
setState((current) => ({
|
||
data: current.data || initialDashboard,
|
||
error: error.message || "运营接口暂不可用",
|
||
loaded: current.loaded,
|
||
loading: false,
|
||
}));
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
loadDashboard();
|
||
}, [loadDashboard]);
|
||
|
||
const { data } = state;
|
||
|
||
const appOptions = useMemo(
|
||
() =>
|
||
data.apps.map((item) => [
|
||
item.app_code ?? item.appCode,
|
||
`${item.app_name ?? item.appName ?? ""} (${item.app_code ?? item.appCode})`,
|
||
]),
|
||
[data.apps],
|
||
);
|
||
|
||
// 抽奖记录页的奖池下拉:配置和真实水位都可能先于对方存在(先发布未入账 / 老池子停用后仍有流水),取并集。
|
||
const poolIDsByApp = useMemo(() => {
|
||
const byApp = new Map();
|
||
const add = (appCode, poolID) => {
|
||
if (!appCode || !poolID) {
|
||
return;
|
||
}
|
||
if (!byApp.has(appCode)) {
|
||
byApp.set(appCode, new Set());
|
||
}
|
||
byApp.get(appCode).add(poolID);
|
||
};
|
||
data.luckyGifts.forEach((config) => add(config.app_code, config.pool_id));
|
||
data.poolBalances.forEach((pool) => add(pool.app_code ?? pool.appCode, pool.pool_id ?? pool.poolId));
|
||
return new Map(Array.from(byApp.entries(), ([appCode, poolIDs]) => [appCode, Array.from(poolIDs).sort()]));
|
||
}, [data.luckyGifts, data.poolBalances]);
|
||
|
||
const openCreateConfig = useCallback(() => {
|
||
const firstAppCode = data.apps
|
||
.map((item) =>
|
||
String(item.app_code ?? item.appCode ?? "")
|
||
.trim()
|
||
.toLowerCase(),
|
||
)
|
||
.find(Boolean);
|
||
if (!firstAppCode) {
|
||
showToast("暂无可添加配置的应用", "warning");
|
||
return;
|
||
}
|
||
// 金额门槛和六维上限按 App 金币口径配置,绝不能复制第一条已发布规则给另一个 App。
|
||
// 仅传规则身份,让 configToForm 生成 disabled dynamic_v3 安全草稿;金额字段保持 0,运营补齐后才能启用。
|
||
setDialog({
|
||
config: {
|
||
app_code: firstAppCode,
|
||
enabled: false,
|
||
is_default: true,
|
||
pool_id: DEFAULT_POOL_ID,
|
||
rule_version: 0,
|
||
strategy_version: "dynamic_v3",
|
||
},
|
||
mode: "create",
|
||
});
|
||
}, [data.apps, showToast]);
|
||
|
||
const openEditConfig = useCallback((config) => {
|
||
setDialog({ config: structuredClone(config), mode: "edit" });
|
||
}, []);
|
||
|
||
const openPoolAdjustment = useCallback((pool, direction) => {
|
||
if (!pool) {
|
||
return;
|
||
}
|
||
// 幂等键绑定一次弹窗会话,而不是一次 fetch;网络超时或 409 后弹窗保留,后续重试不会重复入账。
|
||
setPoolDialog({
|
||
adjustmentId: createPoolAdjustmentID(),
|
||
direction,
|
||
pool: structuredClone(pool),
|
||
});
|
||
}, []);
|
||
|
||
const handleSaveConfig = useCallback(
|
||
async (config) => {
|
||
setSavingConfig(true);
|
||
try {
|
||
await saveLuckyGiftConfig(config);
|
||
showToast(`已发布 ${config.app_code} / ${config.pool_id} 的新规则版本`, "success");
|
||
setDialog(null);
|
||
await loadDashboard();
|
||
} catch (error) {
|
||
// 保存失败保持弹窗打开,运营修完参数可直接重试,不丢已填内容。
|
||
showToast(error.message || "保存幸运礼物配置失败", "error");
|
||
} finally {
|
||
setSavingConfig(false);
|
||
}
|
||
},
|
||
[loadDashboard, showToast],
|
||
);
|
||
|
||
const handleAdjustPool = useCallback(
|
||
async (request) => {
|
||
setAdjustingPool(true);
|
||
try {
|
||
const result = await adjustLuckyGiftPool(request);
|
||
const adjustment = result.adjustment || {};
|
||
const action = request.direction === "out" ? "扣减" : "添加";
|
||
showToast(
|
||
`已${action}水位 ${request.appCode} / ${request.poolId} / ${request.strategyVersion}:${formatNumber(
|
||
adjustment.balance_before,
|
||
)} → ${formatNumber(adjustment.balance_after)}`,
|
||
"success",
|
||
);
|
||
setPoolDialog(null);
|
||
await loadDashboard();
|
||
} catch (error) {
|
||
// owner 可能已经成功但响应丢失;保留表单与 adjustment_id,用户重试时由 owner 返回首次结果而不重复调整。
|
||
showToast(error.message || "调整奖池水位失败", "error");
|
||
} finally {
|
||
setAdjustingPool(false);
|
||
}
|
||
},
|
||
[loadDashboard, showToast],
|
||
);
|
||
|
||
const defaultAppCode = data.apps[0]?.app_code ?? data.apps[0]?.appCode ?? "";
|
||
|
||
return (
|
||
<OpsShell activeView={activeView} onViewChange={setActiveView} views={views}>
|
||
<div className="ops-page">
|
||
<PageHead meta="跨应用幸运礼物配置与抽奖数据面板" title="运营配置中心">
|
||
<span className={state.loading ? "ops-status is-loading" : "ops-status"}>
|
||
{state.loading ? "同步中" : "已就绪"}
|
||
</span>
|
||
<Button
|
||
disabled={state.loading}
|
||
startIcon={<RefreshOutlined fontSize="small" />}
|
||
onClick={loadDashboard}
|
||
>
|
||
刷新
|
||
</Button>
|
||
</PageHead>
|
||
{/* 首屏失败走 DataState 的错误重试页;已有数据时刷新失败只弹提示条,保留旧数据可用。 */}
|
||
{state.error && state.loaded ? (
|
||
<div className="ops-alert" role="alert">
|
||
{state.error}
|
||
</div>
|
||
) : null}
|
||
<DataState
|
||
error={state.loaded ? "" : state.error}
|
||
loading={state.loading && !state.loaded}
|
||
onRetry={loadDashboard}
|
||
>
|
||
{activeView === "overview" ? (
|
||
<OverviewView
|
||
canCredit={canCreditPool}
|
||
canDebit={canDebitPool}
|
||
data={data}
|
||
disabled={adjustingPool || state.loading}
|
||
onCredit={(pool) => openPoolAdjustment(pool, "in")}
|
||
onDebit={(pool) => openPoolAdjustment(pool, "out")}
|
||
/>
|
||
) : null}
|
||
{activeView === "configs" ? (
|
||
<ConfigsView
|
||
apps={data.apps}
|
||
canCredit={canCreditPool}
|
||
canDebit={canDebitPool}
|
||
canUpdate={canUpdateConfig}
|
||
disabled={adjustingPool || state.loading}
|
||
luckyGifts={data.luckyGifts}
|
||
poolBalances={data.poolBalances}
|
||
saving={savingConfig}
|
||
onCreate={openCreateConfig}
|
||
onCredit={(pool) => openPoolAdjustment(pool, "in")}
|
||
onDebit={(pool) => openPoolAdjustment(pool, "out")}
|
||
onEdit={openEditConfig}
|
||
/>
|
||
) : null}
|
||
{activeView === "draws" ? (
|
||
<DrawsView apps={data.apps} defaultAppCode={defaultAppCode} poolIDsByApp={poolIDsByApp} />
|
||
) : null}
|
||
{activeView === "apps" ? <AppsView apps={data.apps} /> : null}
|
||
</DataState>
|
||
{dialog ? (
|
||
<LuckyGiftConfigDialog
|
||
appOptions={appOptions}
|
||
config={dialog.config}
|
||
mode={dialog.mode}
|
||
saving={savingConfig}
|
||
onClose={() => setDialog(null)}
|
||
onSubmit={handleSaveConfig}
|
||
/>
|
||
) : null}
|
||
{poolDialog ? (
|
||
<LuckyGiftPoolAdjustmentDialog
|
||
adjustmentId={poolDialog.adjustmentId}
|
||
direction={poolDialog.direction}
|
||
pool={poolDialog.pool}
|
||
submitting={adjustingPool}
|
||
onClose={() => setPoolDialog(null)}
|
||
onSubmit={handleAdjustPool}
|
||
/>
|
||
) : null}
|
||
</div>
|
||
</OpsShell>
|
||
);
|
||
}
|
||
|
||
let fallbackAdjustmentSequence = 0;
|
||
|
||
function createPoolAdjustmentID() {
|
||
if (typeof globalThis.crypto?.randomUUID === "function") {
|
||
return globalThis.crypto.randomUUID();
|
||
}
|
||
fallbackAdjustmentSequence += 1;
|
||
return `pool-adjust-${Date.now().toString(36)}-${fallbackAdjustmentSequence.toString(36)}`;
|
||
}
|