import Dialog from "@mui/material/Dialog"; import DialogActions from "@mui/material/DialogActions"; import DialogContent from "@mui/material/DialogContent"; import DialogTitle from "@mui/material/DialogTitle"; import MenuItem from "@mui/material/MenuItem"; import TextField from "@mui/material/TextField"; import { useState } from "react"; import { Button } from "@/shared/ui/Button.jsx"; import { TimeText } from "@/shared/ui/TimeText.jsx"; import { luckyGiftPoolKey } from "../api.js"; import { EXPERIMENT_ARM_LABELS, EXPERIMENT_ARMS, experimentArmStat, experimentStatusBadge, formatTrafficPercent, isActiveExperiment, trafficPercentError, } from "../experimentForm.js"; import { formatNumber, formatPercent } from "../format.js"; import { LuckyGiftPoolActions } from "./LuckyGiftPoolActions.jsx"; import { OpsStatusBadge } from "./OpsStatusBadge.jsx"; // AB 实验管理弹窗:展示两组统计并承载放量、暂停/恢复、白名单、结束实验四类操作。 // 所有请求都由 OpsCenterApp 的回调执行(失败 toast 保留弹窗),这里只负责输入校验与二次确认。 export function LuckyGiftExperimentManageDialog({ busy, canCreditPool, canDebitPool, canManage, experiment, onAdjustTraffic, onClose, onCreditPool, onDebitPool, onEnd, onRemoveOverride, onSetStatus, onUpsertOverride, poolBalances = [], poolBusy = false, statsLoading, }) { const [trafficInput, setTrafficInput] = useState(() => String(experiment.treatment_traffic_percent ?? "")); const [trafficError, setTrafficError] = useState(""); const [overrideUserKey, setOverrideUserKey] = useState(""); const [overrideArm, setOverrideArm] = useState("treatment"); const [overrideError, setOverrideError] = useState(""); const [endConfirmArm, setEndConfirmArm] = useState(""); const paused = experiment.status === "paused"; const completed = experiment.status === "completed"; // completed 只读:历史入口打开时仅展示分组数据与获胜结果,不再提供任何写操作。 const editable = canManage && isActiveExperiment(experiment); const badge = experimentStatusBadge(experiment); const overrides = Array.isArray(experiment.overrides) ? experiment.overrides : []; const armStrategies = EXPERIMENT_ARMS.map((arm) => experimentArmStat(experiment, arm)?.strategy_version).filter( Boolean, ); // 水位以 app+pool+strategy 为账本主键,而不是实验 arm。跨策略实验两组各自命中一口水位; // 同策略实验则必须展示共享语义,避免运营从两张卡各补一次而误以为资金彼此隔离。 const sharedStrategyPool = armStrategies.length === EXPERIMENT_ARMS.length && new Set(armStrategies).size === 1; const submitTraffic = (event) => { event.preventDefault(); const error = trafficPercentError(trafficInput); setTrafficError(error); if (error) { return; } onAdjustTraffic(Number(trafficInput)); }; const submitOverride = (event) => { event.preventDefault(); const userKey = overrideUserKey.trim(); if (!userKey) { setOverrideError("请输入用户 ID"); return; } setOverrideError(""); setOverrideUserKey(""); onUpsertOverride({ arm: overrideArm, userKey }); }; return ( {completed ? "实验详情" : "实验管理"} {experiment.name || experiment.experiment_id}
: "-"} /> } />
{paused ? (
实验已暂停:全部流量(含白名单)临时回到对照组,恢复后回到原分组。
) : null} {completed ? (
实验已结束:{EXPERIMENT_ARM_LABELS[experiment.winner_arm] || "获胜方"}获胜,配置已重新发布为 v {experiment.final_rule_version || experiment.winner_rule_version || "-"} 并全量生效。
) : null}
{EXPERIMENT_ARMS.map((arm) => ( ))}
{sharedStrategyPool && (canCreditPool || canDebitPool) ? (
两组使用同一策略,共用这口奖池水位;从任一组入口调整都会同步生效。
) : null} {/* editable 才渲染确认层:结束成功后 status 变为 completed,确认层随之收起。 */} {editable && endConfirmArm ? (
确认以{EXPERIMENT_ARM_LABELS[endConfirmArm]}结束实验?

{EXPERIMENT_ARM_LABELS[endConfirmArm]}(v {endConfirmArm === "control" ? experiment.control_rule_version : experiment.treatment_rule_version} )的配置将重新发布为新的最新版本并对全量用户生效;实验立即结束且不可恢复,之后配置页恢复常规发布。

) : null} {editable ? (

放量与状态

{ setTrafficInput(event.target.value); setTrafficError(""); }} />
{paused ? "恢复后所有用户回到暂停前的分组。" : "暂停后全部流量(含白名单)回到对照组。"}
) : null}

白名单用户

白名单用户不参与哈希分流,固定进入指定分组;流量为 0 时只有白名单进入实验组。

{overrides.length ? (
    {overrides.map((override) => (
  • {override.user_key} {EXPERIMENT_ARM_LABELS[override.arm] || override.arm} {editable ? ( ) : null}
  • ))}
) : (

暂无白名单用户

)} {editable ? (
{ setOverrideUserKey(event.target.value); setOverrideError(""); }} /> setOverrideArm(event.target.value)} > {EXPERIMENT_ARMS.map((arm) => ( {EXPERIMENT_ARM_LABELS[arm]} ))} ) : null}
); } function ExperimentArmCard({ arm, busy, canCreditPool, canDebitPool, canEnd, experiment, onCreditPool, onDebitPool, onPickWinner, poolBalances, poolBusy, statsLoading, }) { const stat = experimentArmStat(experiment, arm); const ruleVersion = arm === "control" ? experiment.control_rule_version : experiment.treatment_rule_version; const isWinner = experiment.status === "completed" && experiment.winner_arm === arm; const strategyVersion = stat?.strategy_version || ""; const poolIdentity = { app_code: experiment.app_code, pool_id: experiment.pool_id, strategy_version: strategyVersion, }; const pool = strategyVersion ? (poolBalances || []).find((item) => luckyGiftPoolKey(item) === luckyGiftPoolKey(poolIdentity)) : null; // 保留完整策略身份作为禁用按钮的 aria/tooltip 上下文;若水位接口暂未返回该账本, // LuckyGiftPoolActions 会禁止提交,不能用 0 余额伪装成已加载的可调整奖池。 const poolActionTarget = pool || poolIdentity; // 统计尚未返回时明确展示“加载中”,避免运营把未加载误读成 0(0 消费也是有效实验结论)。 const statValue = (value, format) => (stat ? format(value) : statsLoading ? "加载中" : "-"); const poolValue = (value) => (pool ? formatNumber(value) : statsLoading ? "加载中" : "-"); return (
{EXPERIMENT_ARM_LABELS[arm]} {ruleVersion ? `v${ruleVersion}` : "-"} {isWinner ? : null}
策略
{stat?.strategy_version || "-"}
抽奖次数
{statValue(stat?.total_draws, formatNumber)}
参与用户
{statValue(stat?.unique_users, formatNumber)}
消费金币
{statValue(stat?.total_spent_coins, formatNumber)}
返奖金币
{statValue(stat?.total_reward_coins, formatNumber)}
实际 RTP
{statValue(stat?.actual_rtp_percent, formatPercent)}
奖池余额
{poolValue(pool?.balance)}
可用水位
{poolValue(pool?.available_balance)}
{strategyVersion ? ( ) : null} {canEnd ? ( ) : null}
); } function ExperimentMetric({ label, value }) { return (
{label} {value || "-"}
); }