399 lines
18 KiB
JavaScript
399 lines
18 KiB
JavaScript
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 (
|
||
<Dialog
|
||
aria-labelledby="ops-experiment-manage-title"
|
||
fullWidth
|
||
maxWidth="md"
|
||
open
|
||
scroll="paper"
|
||
onClose={busy ? undefined : onClose}
|
||
>
|
||
<DialogTitle className="ops-experiment-title" id="ops-experiment-manage-title">
|
||
<span>{completed ? "实验详情" : "实验管理"}</span>
|
||
<small>{experiment.name || experiment.experiment_id}</small>
|
||
</DialogTitle>
|
||
<DialogContent className="ops-experiment-content" dividers>
|
||
<div className="ops-experiment-metrics">
|
||
<ExperimentMetric label="应用" value={experiment.app_code} />
|
||
<ExperimentMetric label="奖池" value={experiment.pool_id} />
|
||
<ExperimentMetric
|
||
label="状态"
|
||
value={badge ? <OpsStatusBadge label={badge.label} tone={badge.tone} /> : "-"}
|
||
/>
|
||
<ExperimentMetric label="开始时间" value={<TimeText value={experiment.started_at_ms} />} />
|
||
<ExperimentMetric
|
||
label="实验组流量"
|
||
value={formatTrafficPercent(experiment.treatment_traffic_percent)}
|
||
/>
|
||
<ExperimentMetric label="白名单" value={`${overrides.length} 人`} />
|
||
</div>
|
||
|
||
{paused ? (
|
||
<div className="ops-pool-adjustment-note" role="status">
|
||
实验已暂停:全部流量(含白名单)临时回到对照组,恢复后回到原分组。
|
||
</div>
|
||
) : null}
|
||
{completed ? (
|
||
<div className="ops-pool-adjustment-note" role="status">
|
||
实验已结束:{EXPERIMENT_ARM_LABELS[experiment.winner_arm] || "获胜方"}获胜,配置已重新发布为 v
|
||
{experiment.final_rule_version || experiment.winner_rule_version || "-"} 并全量生效。
|
||
</div>
|
||
) : null}
|
||
|
||
<div className="ops-experiment-arms">
|
||
{EXPERIMENT_ARMS.map((arm) => (
|
||
<ExperimentArmCard
|
||
arm={arm}
|
||
busy={busy}
|
||
canCreditPool={canCreditPool}
|
||
canDebitPool={canDebitPool}
|
||
canEnd={editable}
|
||
experiment={experiment}
|
||
key={arm}
|
||
poolBalances={poolBalances}
|
||
poolBusy={poolBusy}
|
||
statsLoading={statsLoading}
|
||
onCreditPool={onCreditPool}
|
||
onDebitPool={onDebitPool}
|
||
onPickWinner={setEndConfirmArm}
|
||
/>
|
||
))}
|
||
</div>
|
||
{sharedStrategyPool && (canCreditPool || canDebitPool) ? (
|
||
<div className="ops-pool-adjustment-note" role="status">
|
||
两组使用同一策略,共用这口奖池水位;从任一组入口调整都会同步生效。
|
||
</div>
|
||
) : null}
|
||
|
||
{/* editable 才渲染确认层:结束成功后 status 变为 completed,确认层随之收起。 */}
|
||
{editable && endConfirmArm ? (
|
||
<div className="ops-experiment-confirm" role="alert">
|
||
<strong>确认以{EXPERIMENT_ARM_LABELS[endConfirmArm]}结束实验?</strong>
|
||
<p>
|
||
{EXPERIMENT_ARM_LABELS[endConfirmArm]}(v
|
||
{endConfirmArm === "control"
|
||
? experiment.control_rule_version
|
||
: experiment.treatment_rule_version}
|
||
)的配置将重新发布为新的最新版本并对全量用户生效;实验立即结束且不可恢复,之后配置页恢复常规发布。
|
||
</p>
|
||
<div>
|
||
<Button disabled={busy} onClick={() => setEndConfirmArm("")}>
|
||
取消
|
||
</Button>
|
||
<Button disabled={busy} variant="danger" onClick={() => onEnd(endConfirmArm)}>
|
||
确认结束并发布
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
{editable ? (
|
||
<section aria-label="实验控制" className="ops-experiment-section">
|
||
<h3>放量与状态</h3>
|
||
<div className="ops-experiment-controls">
|
||
<form className="ops-experiment-inline-form" onSubmit={submitTraffic}>
|
||
<TextField
|
||
disabled={busy}
|
||
error={Boolean(trafficError)}
|
||
helperText={
|
||
trafficError ||
|
||
"0 表示仅白名单进入实验组;放量单调,调大不会把已在实验组的用户踢回"
|
||
}
|
||
label="实验组流量 (%)"
|
||
size="small"
|
||
slotProps={{ htmlInput: { inputMode: "decimal", max: 100, min: 0, step: "0.01" } }}
|
||
type="number"
|
||
value={trafficInput}
|
||
onChange={(event) => {
|
||
setTrafficInput(event.target.value);
|
||
setTrafficError("");
|
||
}}
|
||
/>
|
||
<Button disabled={busy} type="submit">
|
||
应用
|
||
</Button>
|
||
</form>
|
||
<div className="ops-experiment-inline-form">
|
||
<Button
|
||
disabled={busy}
|
||
variant={paused ? "success" : undefined}
|
||
onClick={() => onSetStatus(paused ? "running" : "paused")}
|
||
>
|
||
{paused ? "恢复实验" : "暂停实验"}
|
||
</Button>
|
||
<span className="ops-field-note">
|
||
{paused
|
||
? "恢复后所有用户回到暂停前的分组。"
|
||
: "暂停后全部流量(含白名单)回到对照组。"}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
) : null}
|
||
|
||
<section aria-label="白名单用户" className="ops-experiment-section ops-experiment-overrides">
|
||
<h3>白名单用户</h3>
|
||
<p className="ops-field-note">
|
||
白名单用户不参与哈希分流,固定进入指定分组;流量为 0 时只有白名单进入实验组。
|
||
</p>
|
||
{overrides.length ? (
|
||
<ul>
|
||
{overrides.map((override) => (
|
||
<li key={override.user_key}>
|
||
<code>{override.user_key}</code>
|
||
<span>{EXPERIMENT_ARM_LABELS[override.arm] || override.arm}</span>
|
||
{editable ? (
|
||
<Button
|
||
aria-label={`移除白名单 ${override.user_key}`}
|
||
disabled={busy}
|
||
size="small"
|
||
variant="danger"
|
||
onClick={() => onRemoveOverride(override.user_key)}
|
||
>
|
||
移除
|
||
</Button>
|
||
) : null}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
) : (
|
||
<p className="ops-field-note">暂无白名单用户</p>
|
||
)}
|
||
{editable ? (
|
||
<form className="ops-experiment-inline-form" onSubmit={submitOverride}>
|
||
<TextField
|
||
disabled={busy}
|
||
error={Boolean(overrideError)}
|
||
helperText={overrideError}
|
||
label="用户 ID"
|
||
size="small"
|
||
value={overrideUserKey}
|
||
onChange={(event) => {
|
||
setOverrideUserKey(event.target.value);
|
||
setOverrideError("");
|
||
}}
|
||
/>
|
||
<TextField
|
||
disabled={busy}
|
||
label="分组"
|
||
select
|
||
size="small"
|
||
value={overrideArm}
|
||
onChange={(event) => setOverrideArm(event.target.value)}
|
||
>
|
||
{EXPERIMENT_ARMS.map((arm) => (
|
||
<MenuItem key={arm} value={arm}>
|
||
{EXPERIMENT_ARM_LABELS[arm]}
|
||
</MenuItem>
|
||
))}
|
||
</TextField>
|
||
<Button disabled={busy} type="submit">
|
||
添加
|
||
</Button>
|
||
</form>
|
||
) : null}
|
||
</section>
|
||
</DialogContent>
|
||
<DialogActions>
|
||
<Button disabled={busy} onClick={onClose}>
|
||
关闭
|
||
</Button>
|
||
</DialogActions>
|
||
</Dialog>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<section
|
||
aria-label={EXPERIMENT_ARM_LABELS[arm]}
|
||
className={["ops-experiment-arm", isWinner ? "ops-experiment-arm--winner" : ""].filter(Boolean).join(" ")}
|
||
>
|
||
<header className="ops-experiment-arm__head">
|
||
<strong>{EXPERIMENT_ARM_LABELS[arm]}</strong>
|
||
<span>{ruleVersion ? `v${ruleVersion}` : "-"}</span>
|
||
{isWinner ? <OpsStatusBadge label="获胜" tone="succeeded" /> : null}
|
||
</header>
|
||
<dl className="ops-experiment-stats">
|
||
<div>
|
||
<dt>策略</dt>
|
||
<dd>{stat?.strategy_version || "-"}</dd>
|
||
</div>
|
||
<div>
|
||
<dt>抽奖次数</dt>
|
||
<dd>{statValue(stat?.total_draws, formatNumber)}</dd>
|
||
</div>
|
||
<div>
|
||
<dt>参与用户</dt>
|
||
<dd>{statValue(stat?.unique_users, formatNumber)}</dd>
|
||
</div>
|
||
<div>
|
||
<dt>消费金币</dt>
|
||
<dd>{statValue(stat?.total_spent_coins, formatNumber)}</dd>
|
||
</div>
|
||
<div>
|
||
<dt>返奖金币</dt>
|
||
<dd>{statValue(stat?.total_reward_coins, formatNumber)}</dd>
|
||
</div>
|
||
<div>
|
||
<dt>实际 RTP</dt>
|
||
<dd>{statValue(stat?.actual_rtp_percent, formatPercent)}</dd>
|
||
</div>
|
||
<div>
|
||
<dt>奖池余额</dt>
|
||
<dd>{poolValue(pool?.balance)}</dd>
|
||
</div>
|
||
<div>
|
||
<dt>可用水位</dt>
|
||
<dd>{poolValue(pool?.available_balance)}</dd>
|
||
</div>
|
||
</dl>
|
||
{strategyVersion ? (
|
||
<LuckyGiftPoolActions
|
||
canCredit={canCreditPool}
|
||
canDebit={canDebitPool}
|
||
disabled={poolBusy}
|
||
pool={poolActionTarget}
|
||
onCredit={onCreditPool}
|
||
onDebit={onDebitPool}
|
||
/>
|
||
) : null}
|
||
{canEnd ? (
|
||
<Button
|
||
aria-label={`选择${EXPERIMENT_ARM_LABELS[arm]}为最终版本`}
|
||
disabled={busy}
|
||
size="small"
|
||
onClick={() => onPickWinner(arm)}
|
||
>
|
||
选择此组为最终版本
|
||
</Button>
|
||
) : null}
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function ExperimentMetric({ label, value }) {
|
||
return (
|
||
<div className="ops-config-metric">
|
||
<span>{label}</span>
|
||
<strong>{value || "-"}</strong>
|
||
</div>
|
||
);
|
||
}
|