202 lines
8.5 KiB
JavaScript
202 lines
8.5 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 { 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 { 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 { OpsShell } from "./components/OpsShell.jsx";
|
||
import { OverviewView } from "./components/OverviewView.jsx";
|
||
|
||
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";
|
||
}
|
||
|
||
export function OpsCenterApp() {
|
||
// 主后台旧入口通过 ?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 [savingConfig, setSavingConfig] = useState(false);
|
||
const { showToast } = useToast();
|
||
|
||
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 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 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 data={data} /> : null}
|
||
{activeView === "configs" ? (
|
||
<ConfigsView
|
||
apps={data.apps}
|
||
luckyGifts={data.luckyGifts}
|
||
saving={savingConfig}
|
||
onCreate={openCreateConfig}
|
||
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}
|
||
</div>
|
||
</OpsShell>
|
||
);
|
||
}
|