- 配置页实验徽章/实验详情入口,实验期禁用常规发布 - 创建实验弹窗(复用配置表单+流量/白名单),实验管理弹窗(分组指标/放量/暂停/结束) - 契约新增 5 个实验端点与 lucky-gift:experiment 权限,gen:api 再生成 - vite.config.js 移除已删除 money/index.html 的构建入口,修复 build 阻断 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
287 lines
13 KiB
JavaScript
287 lines
13 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 { useEffect, useMemo, useRef, useState } from "react";
|
||
import { Button } from "@/shared/ui/Button.jsx";
|
||
import {
|
||
applyDynamicStrategyDefaults,
|
||
configToForm,
|
||
correctAllStageProbabilities,
|
||
formToConfig,
|
||
nextStageMultiplier,
|
||
stageSummary,
|
||
validateLuckyGiftForm,
|
||
} from "../configForm.js";
|
||
import {
|
||
buildExperimentCreateRequest,
|
||
experimentDraft,
|
||
formatTrafficPercent,
|
||
validateExperimentDraft,
|
||
} from "../experimentForm.js";
|
||
import { formatNumber, formatPercent } from "../format.js";
|
||
import { LuckyGiftConfigSections } from "./LuckyGiftConfigSections.jsx";
|
||
import { LuckyGiftExperimentFields } from "./LuckyGiftExperimentFields.jsx";
|
||
import { LuckyGiftStageDesigner } from "./LuckyGiftStageDesigner.jsx";
|
||
|
||
export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit", onClose, onSubmit, saving }) {
|
||
const isExperiment = mode === "experiment";
|
||
// 实验组配置由 owner 原子发布并立即参与分流开奖,enabled 强制 true 且开关锁定;
|
||
// 以当前行配置为初始值,运营在其上修改出实验组版本。
|
||
const [form, setForm] = useState(() => configToForm(isExperiment ? { ...config, enabled: true } : config));
|
||
const [experiment, setExperiment] = useState(experimentDraft);
|
||
const [activeStage, setActiveStage] = useState("novice");
|
||
const [validationErrors, setValidationErrors] = useState([]);
|
||
const validationRef = useRef(null);
|
||
|
||
const stageSummaries = useMemo(() => form.stages.map((stage) => stageSummary(stage, form)), [form]);
|
||
const activeStageKey = form.stages.some((stage) => stage.stage === activeStage)
|
||
? activeStage
|
||
: form.stages[0]?.stage;
|
||
const activeStageConfig = form.stages.find((stage) => stage.stage === activeStageKey);
|
||
const activeSummary = stageSummaries.find((summary) => summary.stageKey === activeStageKey);
|
||
const normalStageConfig = form.stages.find((stage) => stage.stage === "normal");
|
||
|
||
useEffect(() => {
|
||
if (!validationErrors.length) return;
|
||
// 保存按钮位于固定底栏;失败后把焦点送回错误摘要,避免键盘或读屏用户停留在无变化的按钮上。
|
||
validationRef.current?.focus();
|
||
}, [validationErrors]);
|
||
|
||
const updateField = (field, value) => {
|
||
setValidationErrors([]);
|
||
setForm((current) => {
|
||
const next = { ...current, [field]: value };
|
||
if (field === "strategy_version" && value === "dynamic_v3" && current.strategy_version !== "dynamic_v3") {
|
||
const dynamicDraft = applyDynamicStrategyDefaults(next);
|
||
// 常规模式安全草稿保持停用;实验模式下实验组必须直接可运行,启用态不允许被安全草稿覆盖回停用。
|
||
return isExperiment ? { ...dynamicDraft, enabled: true } : dynamicDraft;
|
||
}
|
||
// 目标 RTP 决定各奖档概率的闭合解,改动后全部阶段重算,避免展示的概率和将要发布的值不一致。
|
||
return field === "target_rtp_percent" ? correctAllStageProbabilities(next) : next;
|
||
});
|
||
};
|
||
|
||
const updateStageField = (stageKey, field, value) => {
|
||
setValidationErrors([]);
|
||
setForm((current) => ({
|
||
...current,
|
||
stages: current.stages.map((stage) => (stage.stage === stageKey ? { ...stage, [field]: value } : stage)),
|
||
}));
|
||
};
|
||
|
||
const updateStageTier = (stageKey, tierIndex, patch) => {
|
||
setValidationErrors([]);
|
||
setForm((current) =>
|
||
correctAllStageProbabilities({
|
||
...current,
|
||
stages: current.stages.map((stage) =>
|
||
stage.stage === stageKey
|
||
? {
|
||
...stage,
|
||
tiers: stage.tiers.map((tier, index) =>
|
||
index === tierIndex ? { ...tier, ...patch } : tier,
|
||
),
|
||
}
|
||
: stage,
|
||
),
|
||
}),
|
||
);
|
||
};
|
||
|
||
const addStageTier = (stageKey) => {
|
||
setValidationErrors([]);
|
||
setForm((current) =>
|
||
correctAllStageProbabilities({
|
||
...current,
|
||
stages: current.stages.map((stage) =>
|
||
stage.stage === stageKey
|
||
? {
|
||
...stage,
|
||
tiers: [
|
||
...stage.tiers,
|
||
{
|
||
enabled: true,
|
||
// 10 倍以上先写入服务端要求的高倍率安全标记;实际开奖仍以实时余额和返奖上限
|
||
// 判断是否付得起,前端默认值不能被解释成“高余额时一定会发”。
|
||
highWaterOnly: nextStageMultiplier(stage.tiers) >= 10,
|
||
multiplier: String(nextStageMultiplier(stage.tiers)),
|
||
probabilityPercent: "0",
|
||
tierId: "",
|
||
},
|
||
],
|
||
}
|
||
: stage,
|
||
),
|
||
}),
|
||
);
|
||
};
|
||
|
||
const removeStageTier = (stageKey, tierIndex) => {
|
||
setValidationErrors([]);
|
||
setForm((current) =>
|
||
correctAllStageProbabilities({
|
||
...current,
|
||
stages: current.stages.map((stage) =>
|
||
stage.stage === stageKey
|
||
? { ...stage, tiers: stage.tiers.filter((_, index) => index !== tierIndex) }
|
||
: stage,
|
||
),
|
||
}),
|
||
);
|
||
};
|
||
|
||
const updateExperimentField = (field, value) => {
|
||
setValidationErrors([]);
|
||
setExperiment((current) => ({ ...current, [field]: value }));
|
||
};
|
||
|
||
const handleSubmit = (event) => {
|
||
event.preventDefault();
|
||
// 实验模式即使开关被绕过也按启用校验:提交的实验组配置必须直接可运行,
|
||
// 校验口径与最终 payload 完全一致,避免“校验通过、发布被拒”的错位。
|
||
const submittedForm = isExperiment ? { ...form, enabled: true } : form;
|
||
const errors = isExperiment
|
||
? [...validateExperimentDraft(experiment), ...validateLuckyGiftForm(submittedForm)]
|
||
: validateLuckyGiftForm(submittedForm);
|
||
if (errors.length) {
|
||
setValidationErrors(errors);
|
||
return;
|
||
}
|
||
if (isExperiment) {
|
||
onSubmit(buildExperimentCreateRequest(experiment, formToConfig(config, submittedForm)));
|
||
return;
|
||
}
|
||
onSubmit(formToConfig(config, form));
|
||
};
|
||
|
||
const isCreate = mode === "create";
|
||
const title = isExperiment
|
||
? "创建 AB 实验"
|
||
: isCreate
|
||
? "添加幸运礼物配置"
|
||
: config.is_default
|
||
? "发布幸运礼物配置"
|
||
: "新增幸运礼物版本";
|
||
|
||
return (
|
||
<Dialog
|
||
aria-labelledby="ops-config-dialog-title"
|
||
fullWidth
|
||
maxWidth={false}
|
||
open
|
||
scroll="paper"
|
||
slotProps={{ paper: { className: "ops-config-dialog-paper" } }}
|
||
onClose={saving ? undefined : onClose}
|
||
>
|
||
<form className="ops-config-form" onSubmit={handleSubmit}>
|
||
<DialogTitle className="ops-config-title" id="ops-config-dialog-title">
|
||
<span>{title}</span>
|
||
<small>
|
||
{form.app_code || "-"} / {form.pool_id || "default"} /{" "}
|
||
{isExperiment
|
||
? `对照组为当前版本 v${config.rule_version ?? "-"},本表单发布为实验组新版本`
|
||
: config.is_default || isCreate
|
||
? "默认草稿"
|
||
: `当前版本 v${config.rule_version ?? "-"}`}
|
||
</small>
|
||
</DialogTitle>
|
||
<dl className="ops-config-summary" aria-label="配置摘要">
|
||
<ConfigMetric label="应用" value={form.app_code || "-"} />
|
||
<ConfigMetric label="奖池" value={form.pool_id || "default"} />
|
||
<ConfigMetric label="开奖策略" value={form.strategy_version} />
|
||
<ConfigMetric label="基础返还目标" value={formatPercent(Number(form.target_rtp_percent))} />
|
||
<ConfigMetric
|
||
label={form.strategy_version === "dynamic_v3" ? "资金拆分" : "奖池比例"}
|
||
value={
|
||
form.strategy_version === "dynamic_v3"
|
||
? `${form.pool_rate_percent}% / ${form.profit_rate_percent}% / ${form.anchor_rate_percent}%`
|
||
: `${form.pool_rate_percent}%`
|
||
}
|
||
/>
|
||
<ConfigMetric
|
||
label={form.strategy_version === "dynamic_v3" ? "每轮观察流水" : "V2 窗口配置"}
|
||
value={formatNumber(Number(form.settlement_window_wager))}
|
||
/>
|
||
<ConfigMetric
|
||
label={isExperiment ? "实验组流量" : "发布状态"}
|
||
value={
|
||
isExperiment
|
||
? formatTrafficPercent(Number(experiment.trafficPercent))
|
||
: form.enabled
|
||
? "启用"
|
||
: "停用"
|
||
}
|
||
/>
|
||
</dl>
|
||
<DialogContent className="ops-config-dialog-content" dividers>
|
||
{validationErrors.length ? (
|
||
<div
|
||
aria-live="assertive"
|
||
className="ops-config-validation"
|
||
ref={validationRef}
|
||
role="alert"
|
||
tabIndex={-1}
|
||
>
|
||
<strong>{isExperiment ? "实验不能创建" : "配置不能发布"}</strong>
|
||
<ul>
|
||
{validationErrors.map((message) => (
|
||
<li key={message}>{message}</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
) : null}
|
||
|
||
{isExperiment ? (
|
||
<LuckyGiftExperimentFields
|
||
disabled={saving}
|
||
draft={experiment}
|
||
onChange={updateExperimentField}
|
||
/>
|
||
) : null}
|
||
|
||
<div className="ops-config-layout">
|
||
<LuckyGiftConfigSections
|
||
appOptions={appOptions}
|
||
experimentMode={isExperiment}
|
||
form={form}
|
||
isCreate={isCreate}
|
||
onChange={updateField}
|
||
/>
|
||
<LuckyGiftStageDesigner
|
||
activeStageConfig={activeStageConfig}
|
||
activeStageKey={activeStageKey}
|
||
activeSummary={activeSummary}
|
||
normalStageConfig={normalStageConfig}
|
||
stageSummaries={stageSummaries}
|
||
strategyVersion={form.strategy_version}
|
||
onAddTier={addStageTier}
|
||
onRemoveTier={removeStageTier}
|
||
onStageChange={setActiveStage}
|
||
onStageFieldChange={updateStageField}
|
||
onTierChange={updateStageTier}
|
||
/>
|
||
</div>
|
||
</DialogContent>
|
||
<DialogActions className="ops-config-dialog-actions">
|
||
<Button disabled={saving} onClick={onClose}>
|
||
取消
|
||
</Button>
|
||
<Button disabled={saving} type="submit" variant="primary">
|
||
{saving ? (isExperiment ? "创建中" : "保存中") : isExperiment ? "创建实验" : "保存配置"}
|
||
</Button>
|
||
</DialogActions>
|
||
</form>
|
||
</Dialog>
|
||
);
|
||
}
|
||
|
||
function ConfigMetric({ label, value }) {
|
||
return (
|
||
<div className="ops-config-metric">
|
||
<dt>{label}</dt>
|
||
<dd>{value}</dd>
|
||
</div>
|
||
);
|
||
}
|