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, createLuckyGiftExperiment, DEFAULT_POOL_ID, endLuckyGiftExperiment, fetchLuckyGiftExperiments, fetchOpsDashboard, saveLuckyGiftConfig, setLuckyGiftExperimentOverrides, updateLuckyGiftExperiment, } 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 { LuckyGiftExperimentManageDialog } from "./components/LuckyGiftExperimentManageDialog.jsx"; import { LuckyGiftPoolAdjustmentDialog } from "./components/LuckyGiftPoolAdjustmentDialog.jsx"; import { OpsShell } from "./components/OpsShell.jsx"; import { OverviewView } from "./components/OverviewView.jsx"; import { EXPERIMENT_ARM_LABELS, formatTrafficPercent } from "./experimentForm.js"; 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: [], experiments: [], luckyGifts: [], poolBalances: [], summary: {}, }; // 实验管理权限码与 permissions.ts 常量并行开发:常量尚未生成时回退到后端约定的字面量, // 避免部署顺序影响入口显隐;两者最终值一致("lucky-gift:experiment")。 const EXPERIMENT_PERMISSION = PERMISSIONS.luckyGiftExperiment || "lucky-gift:experiment"; 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 [experimentManage, setExperimentManage] = useState(null); const [adjustingPool, setAdjustingPool] = useState(false); const [savingConfig, setSavingConfig] = useState(false); const [experimentBusy, setExperimentBusy] = useState(false); const { showToast } = useToast(); const canCreditPool = can(PERMISSIONS.luckyGiftPoolCredit); const canDebitPool = can(PERMISSIONS.luckyGiftPoolDebit); const canUpdateConfig = can(PERMISSIONS.luckyGiftUpdate); const canManageExperiment = can(EXPERIMENT_PERMISSION); 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 openCreateExperiment = useCallback((config) => { // 复用配置弹窗的实验模式:以当前行配置为实验组初始值,当前最新启用版本自动成为对照组。 setDialog({ config: structuredClone(config), mode: "experiment" }); }, []); 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 handleCreateExperiment = useCallback( async (request) => { setSavingConfig(true); try { const result = await createLuckyGiftExperiment(request.config.app_code, request); showToast( `已创建 AB 实验「${request.name}」:${request.config.app_code} / ${request.config.pool_id},实验组 v${ result.experiment?.treatment_rule_version || "-" } 按 ${formatTrafficPercent(request.treatment_traffic_percent)} 放量`, "success", ); setDialog(null); await loadDashboard(); } catch (error) { // 创建失败(含活跃实验冲突 409)保持弹窗打开,运营修完参数可直接重试,不丢已填内容。 showToast(error.message || "创建 AB 实验失败", "error"); } finally { setSavingConfig(false); } }, [loadDashboard, showToast], ); const refreshExperimentDetail = useCallback(async (experiment) => { // 统计(with_stats=1)由 owner 按 control/treatment 聚合,代价较高,只在管理弹窗里拉取; // dashboard 扇出保持轻量列表。按 pool 过滤后再按 experiment_id 精确匹配。 const items = await fetchLuckyGiftExperiments(experiment.app_code, { poolId: experiment.pool_id, withStats: true, }); return items.find((item) => item.experiment_id === experiment.experiment_id) || null; }, []); const openExperimentManage = useCallback( (experiment) => { setExperimentManage({ experiment, statsLoading: true }); refreshExperimentDetail(experiment) .then((detail) => { setExperimentManage((current) => current && current.experiment.experiment_id === experiment.experiment_id ? { experiment: detail || current.experiment, statsLoading: false } : current, ); }) .catch((error) => { // 统计加载失败不关闭弹窗:基础字段来自列表数据仍可管理,统计位展示“-”。 showToast(error.message || "加载实验统计失败", "error"); setExperimentManage((current) => current && current.experiment.experiment_id === experiment.experiment_id ? { ...current, statsLoading: false } : current, ); }); }, [refreshExperimentDetail, showToast], ); const runExperimentAction = useCallback( async (action, successMessage) => { const current = experimentManage?.experiment; if (!current) { return; } setExperimentBusy(true); try { const updated = await action(current); showToast(successMessage(updated), "success"); // PATCH/PUT 响应不含 arm_stats:先用响应回填状态字段让弹窗即时一致,再补拉统计; // 同时刷新 dashboard,让配置列表的实验徽章与发布禁用状态同步收敛。 setExperimentManage((state) => state && state.experiment.experiment_id === current.experiment_id ? { experiment: { ...state.experiment, ...updated }, statsLoading: true } : state, ); await loadDashboard(); try { const detail = await refreshExperimentDetail(current); setExperimentManage((state) => state && state.experiment.experiment_id === current.experiment_id ? { experiment: detail || state.experiment, statsLoading: false } : state, ); } catch { // 操作本身已成功,统计刷新失败不能再报错误 toast;保留已回填的基础字段。 setExperimentManage((state) => (state ? { ...state, statsLoading: false } : state)); } } catch (error) { // 操作失败保留弹窗与输入内容,运营修正后可直接重试。 showToast(error.message || "实验操作失败", "error"); } finally { setExperimentBusy(false); } }, [experimentManage, loadDashboard, refreshExperimentDetail, showToast], ); const adjustExperimentTraffic = useCallback( (percent) => runExperimentAction( (current) => updateLuckyGiftExperiment(current.app_code, current.experiment_id, { treatment_traffic_percent: percent, }), () => `已调整实验组流量为 ${formatTrafficPercent(percent)}`, ), [runExperimentAction], ); const setExperimentStatus = useCallback( (status) => runExperimentAction( (current) => updateLuckyGiftExperiment(current.app_code, current.experiment_id, { status }), () => status === "paused" ? "实验已暂停,全部流量(含白名单)回到对照组" : "实验已恢复,按原分组继续分流", ), [runExperimentAction], ); const upsertExperimentOverride = useCallback( ({ arm, userKey }) => runExperimentAction( (current) => setLuckyGiftExperimentOverrides(current.app_code, current.experiment_id, { removes: [], upserts: [{ arm, user_key: userKey }], }), () => `已把用户 ${userKey} 固定到${EXPERIMENT_ARM_LABELS[arm] || arm}`, ), [runExperimentAction], ); const removeExperimentOverride = useCallback( (userKey) => runExperimentAction( (current) => setLuckyGiftExperimentOverrides(current.app_code, current.experiment_id, { removes: [userKey], upserts: [], }), () => `已移除白名单用户 ${userKey},该用户回到哈希分流`, ), [runExperimentAction], ); const endExperiment = useCallback( (winnerArm) => runExperimentAction( async (current) => { const result = await endLuckyGiftExperiment(current.app_code, current.experiment_id, winnerArm); return result.experiment; }, (updated) => `实验已结束:${EXPERIMENT_ARM_LABELS[winnerArm]}配置已重新发布为 v${ updated?.final_rule_version || updated?.winner_rule_version || "-" } 并全量生效`, ), [runExperimentAction], ); 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} onManageExperiment={openExperimentManage} /> ) : null} {activeView === "draws" ? ( ) : null} {activeView === "apps" ? : null} {dialog ? ( setDialog(null)} // 实验模式提交的是 {name, treatment_traffic_percent, overrides, config} 创建请求, // 其余模式仍是 PUT /lucky-gifts/config 的完整配置对象。 onSubmit={dialog.mode === "experiment" ? handleCreateExperiment : handleSaveConfig} /> ) : null} {experimentManage ? ( setExperimentManage(null)} onEnd={endExperiment} onRemoveOverride={removeExperimentOverride} onSetStatus={setExperimentStatus} onUpsertOverride={upsertExperimentOverride} /> ) : 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)}`; }