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 (
{state.loading ? "同步中" : "已就绪"} {/* 首屏失败走 DataState 的错误重试页;已有数据时刷新失败只弹提示条,保留旧数据可用。 */} {state.error && state.loaded ? (
{state.error}
) : null} {activeView === "overview" ? ( openPoolAdjustment(pool, "in")} onDebit={(pool) => openPoolAdjustment(pool, "out")} /> ) : null} {activeView === "configs" ? ( openPoolAdjustment(pool, "in")} onDebit={(pool) => openPoolAdjustment(pool, "out")} onEdit={openEditConfig} /> ) : null} {activeView === "draws" ? ( ) : null} {activeView === "apps" ? : null} {dialog ? ( setDialog(null)} onSubmit={handleSaveConfig} /> ) : null} {poolDialog ? ( setPoolDialog(null)} onSubmit={handleAdjustPool} /> ) : null}
); } 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)}`; }