295 lines
13 KiB
JavaScript
295 lines
13 KiB
JavaScript
import AddOutlined from "@mui/icons-material/AddOutlined";
|
|
import Checkbox from "@mui/material/Checkbox";
|
|
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 FormControlLabel from "@mui/material/FormControlLabel";
|
|
import MenuItem from "@mui/material/MenuItem";
|
|
import TextField from "@mui/material/TextField";
|
|
import { useMemo, useState } from "react";
|
|
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
|
|
import { Button } from "@/shared/ui/Button.jsx";
|
|
import { formatDecimal } from "@/shared/utils/rtpProbability.js";
|
|
import {
|
|
configToForm,
|
|
correctAllStageProbabilities,
|
|
formToConfig,
|
|
nextStageMultiplier,
|
|
stageSummary
|
|
} from "../configForm.js";
|
|
import { formatNumber, formatPercent } from "../format.js";
|
|
import { OpsStatusBadge } from "./OpsStatusBadge.jsx";
|
|
|
|
export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit", onClose, onSubmit, saving }) {
|
|
const [form, setForm] = useState(() => configToForm(config));
|
|
const [activeStage, setActiveStage] = useState("novice");
|
|
|
|
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 updateField = (field, value) => {
|
|
setForm((current) => {
|
|
const next = { ...current, [field]: value };
|
|
// 目标 RTP 决定各奖档概率的闭合解,改动后全部阶段重算,避免展示的概率和将要发布的值不一致。
|
|
return field === "target_rtp_percent" ? correctAllStageProbabilities(next) : next;
|
|
});
|
|
};
|
|
|
|
const updateStageTier = (stageKey, tierIndex, patch) => {
|
|
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) => {
|
|
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) => {
|
|
setForm((current) =>
|
|
correctAllStageProbabilities({
|
|
...current,
|
|
stages: current.stages.map((stage) =>
|
|
stage.stage === stageKey ? { ...stage, tiers: stage.tiers.filter((_, index) => index !== tierIndex) } : stage
|
|
)
|
|
})
|
|
);
|
|
};
|
|
|
|
const handleSubmit = (event) => {
|
|
event.preventDefault();
|
|
onSubmit(formToConfig(config, form));
|
|
};
|
|
|
|
const isCreate = mode === "create";
|
|
const title = isCreate ? "添加幸运礼物配置" : config.is_default ? "发布幸运礼物配置" : "新增幸运礼物版本";
|
|
|
|
return (
|
|
<Dialog fullWidth maxWidth="lg" open onClose={saving ? undefined : onClose}>
|
|
<form className="ops-config-form" onSubmit={handleSubmit}>
|
|
<DialogTitle className="ops-config-title">
|
|
<span>{title}</span>
|
|
<small>
|
|
{form.app_code || "-"} / {form.pool_id || "default"} /{" "}
|
|
{config.is_default || isCreate ? "默认草稿" : `当前版本 v${config.rule_version ?? "-"}`}
|
|
</small>
|
|
</DialogTitle>
|
|
<DialogContent dividers>
|
|
<div className="ops-config-metrics">
|
|
<ConfigMetric label="应用" value={form.app_code || "-"} />
|
|
<ConfigMetric label="奖池" value={form.pool_id || "default"} />
|
|
<ConfigMetric label="目标 RTP" value={formatPercent(Number(form.target_rtp_percent))} />
|
|
<ConfigMetric label="奖池回收" value={formatPercent(Number(form.pool_rate_percent))} />
|
|
<ConfigMetric label="结算窗口" value={formatNumber(Number(form.settlement_window_wager))} />
|
|
<ConfigMetric label="状态" value={form.enabled ? "启用" : "停用"} />
|
|
</div>
|
|
|
|
<div className="ops-config-layout">
|
|
<aside className="ops-config-params" aria-label="基础参数">
|
|
<section className="ops-config-section">
|
|
<h3>规则身份</h3>
|
|
{isCreate ? (
|
|
<TextField
|
|
fullWidth
|
|
required
|
|
label="应用"
|
|
select
|
|
size="small"
|
|
value={form.app_code}
|
|
onChange={(event) => updateField("app_code", event.target.value)}
|
|
>
|
|
{appOptions.map(([value, label]) => (
|
|
<MenuItem key={value} value={value}>
|
|
{label}
|
|
</MenuItem>
|
|
))}
|
|
</TextField>
|
|
) : (
|
|
<TextField disabled fullWidth label="应用" size="small" value={form.app_code} />
|
|
)}
|
|
<TextField
|
|
fullWidth
|
|
required
|
|
helperText="修改奖池 ID 会发布到新的奖池,不影响原奖池"
|
|
label="奖池"
|
|
size="small"
|
|
value={form.pool_id}
|
|
onChange={(event) => updateField("pool_id", event.target.value)}
|
|
/>
|
|
<FormControlLabel
|
|
control={<AdminSwitch checked={form.enabled} onChange={(event) => updateField("enabled", event.target.checked)} />}
|
|
label={form.enabled ? "发布后立即启用" : "发布后保持停用"}
|
|
sx={{ gap: 1, marginLeft: 0 }}
|
|
/>
|
|
</section>
|
|
|
|
<section className="ops-config-section">
|
|
<h3>RTP 与奖池</h3>
|
|
<div className="ops-form-grid">
|
|
<NumberField form={form} label="目标 RTP (%)" name="target_rtp_percent" step="0.01" onChange={updateField} />
|
|
<NumberField form={form} label="奖池回收率 (%)" name="pool_rate_percent" step="0.01" onChange={updateField} />
|
|
<NumberField form={form} label="控制带宽 (%)" name="control_band_percent" step="0.01" onChange={updateField} />
|
|
<NumberField form={form} label="结算窗口流水" name="settlement_window_wager" onChange={updateField} />
|
|
<NumberField form={form} label="礼物参考金额" name="gift_price_reference" onChange={updateField} />
|
|
</div>
|
|
</section>
|
|
</aside>
|
|
|
|
<section className="ops-stage-designer" aria-label="阶段奖档设计">
|
|
<div className="ops-stage-tabs" role="tablist" aria-label="阶段奖档">
|
|
{stageSummaries.map((summary) => (
|
|
<button
|
|
aria-selected={summary.stageKey === activeStageKey}
|
|
className={`ops-stage-tab ${summary.stageKey === activeStageKey ? "is-active" : ""} ${summary.ok ? "is-ok" : "is-warn"}`}
|
|
key={summary.stageKey}
|
|
role="tab"
|
|
type="button"
|
|
onClick={() => setActiveStage(summary.stageKey)}
|
|
>
|
|
<span>{summary.tabLabel}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
{activeStageConfig ? (
|
|
<div className="ops-stage-stack">
|
|
<div className="ops-stage-block__head">
|
|
<div>
|
|
<h3>{activeSummary?.stageLabel}</h3>
|
|
<OpsStatusBadge
|
|
label={`${activeSummary?.status} · 概率 ${formatDecimal(activeSummary?.probability)}% · 期望 RTP ${formatDecimal(activeSummary?.expected)}%`}
|
|
tone={activeSummary?.ok ? "succeeded" : "warning"}
|
|
/>
|
|
</div>
|
|
<Button startIcon={<AddOutlined fontSize="small" />} onClick={() => addStageTier(activeStageConfig.stage)}>
|
|
添加奖档
|
|
</Button>
|
|
</div>
|
|
<div className="ops-tier-table">
|
|
<div className="ops-tier-head" aria-hidden="true">
|
|
<span>倍率</span>
|
|
<span>概率 %</span>
|
|
<span>高水位</span>
|
|
<span>启用</span>
|
|
<span>操作</span>
|
|
</div>
|
|
{activeStageConfig.tiers.map((tier, index) => (
|
|
<div className="ops-tier-row" key={`${activeStageConfig.stage}-${index}`}>
|
|
<TextField
|
|
required
|
|
size="small"
|
|
slotProps={{ htmlInput: { "aria-label": "倍率", min: 0, step: "0.01" } }}
|
|
type="number"
|
|
value={tier.multiplier}
|
|
onChange={(event) =>
|
|
updateStageTier(activeStageConfig.stage, index, {
|
|
// 10 倍及以上强制只走高水位,输入时同步勾选并锁死,防止高倍档在低水位放开。
|
|
highWaterOnly: Number(event.target.value) >= 10 || tier.highWaterOnly,
|
|
multiplier: event.target.value
|
|
})
|
|
}
|
|
/>
|
|
{/* 概率由目标 RTP 和倍率统一校正,保持只读可以避免运营手填后绕过服务端概率闭合校验。 */}
|
|
<TextField
|
|
size="small"
|
|
slotProps={{ htmlInput: { "aria-label": "概率 %", readOnly: true } }}
|
|
value={formatDecimal(tier.probabilityPercent)}
|
|
/>
|
|
<Checkbox
|
|
checked={Boolean(tier.highWaterOnly)}
|
|
disabled={Number(tier.multiplier) >= 10}
|
|
size="small"
|
|
slotProps={{ input: { "aria-label": "高水位" } }}
|
|
onChange={(event) => updateStageTier(activeStageConfig.stage, index, { highWaterOnly: event.target.checked })}
|
|
/>
|
|
<Checkbox
|
|
checked={tier.enabled !== false}
|
|
size="small"
|
|
slotProps={{ input: { "aria-label": "启用" } }}
|
|
onChange={(event) => updateStageTier(activeStageConfig.stage, index, { enabled: event.target.checked })}
|
|
/>
|
|
<Button
|
|
disabled={activeStageConfig.tiers.length <= 1}
|
|
variant="danger"
|
|
onClick={() => removeStageTier(activeStageConfig.stage, index)}
|
|
>
|
|
删除
|
|
</Button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</section>
|
|
</div>
|
|
</DialogContent>
|
|
<DialogActions>
|
|
<Button disabled={saving} onClick={onClose}>
|
|
取消
|
|
</Button>
|
|
<Button disabled={saving} type="submit" variant="primary">
|
|
{saving ? "保存中" : "保存配置"}
|
|
</Button>
|
|
</DialogActions>
|
|
</form>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
function ConfigMetric({ label, value }) {
|
|
return (
|
|
<article className="ops-config-metric">
|
|
<span>{label}</span>
|
|
<strong>{value}</strong>
|
|
</article>
|
|
);
|
|
}
|
|
|
|
function NumberField({ form, label, name, onChange, step = "1" }) {
|
|
return (
|
|
<TextField
|
|
label={label}
|
|
required
|
|
size="small"
|
|
slotProps={{ htmlInput: { min: 0, step } }}
|
|
type="number"
|
|
value={form[name]}
|
|
onChange={(event) => onChange(name, event.target.value)}
|
|
/>
|
|
);
|
|
}
|