hyapp-admin-platform/ops-center/src/components/LuckyGiftExperimentManageDialog.jsx

399 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
// 统计尚未返回时明确展示“加载中”,避免运营把未加载误读成 00 消费也是有效实验结论)。
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>
);
}