285 lines
12 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 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)}`;
}