hyapp-admin-platform/ops-center/src/components/LuckyGiftConfigDialog.jsx
zhx f8c7c50316 ops-center 幸运礼物 AB 实验 UI 与契约;清理 money 残留构建入口
- 配置页实验徽章/实验详情入口,实验期禁用常规发布
- 创建实验弹窗(复用配置表单+流量/白名单),实验管理弹窗(分组指标/放量/暂停/结束)
- 契约新增 5 个实验端点与 lucky-gift:experiment 权限,gen:api 再生成
- vite.config.js 移除已删除 money/index.html 的构建入口,修复 build 阻断

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 00:22:29 +08:00

287 lines
13 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 { 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>
);
}