575 lines
30 KiB
JavaScript
575 lines
30 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 {
|
||
applyDynamicStrategyDefaults,
|
||
configToForm,
|
||
correctAllStageProbabilities,
|
||
formToConfig,
|
||
nextStageMultiplier,
|
||
stageSummary,
|
||
validateLuckyGiftForm,
|
||
} 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 [validationErrors, setValidationErrors] = useState([]);
|
||
|
||
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) => {
|
||
setValidationErrors([]);
|
||
setForm((current) => {
|
||
const next = { ...current, [field]: value };
|
||
if (field === "strategy_version" && value === "dynamic_v3" && current.strategy_version !== "dynamic_v3") {
|
||
return applyDynamicStrategyDefaults(next);
|
||
}
|
||
// 目标 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 handleSubmit = (event) => {
|
||
event.preventDefault();
|
||
const errors = validateLuckyGiftForm(form);
|
||
if (errors.length) {
|
||
setValidationErrors(errors);
|
||
return;
|
||
}
|
||
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="策略" value={form.strategy_version} />
|
||
<ConfigMetric label="目标 RTP" value={formatPercent(Number(form.target_rtp_percent))} />
|
||
<ConfigMetric
|
||
label="资金拆分"
|
||
value={`${form.pool_rate_percent}% / ${form.profit_rate_percent}% / ${form.anchor_rate_percent}%`}
|
||
/>
|
||
<ConfigMetric label="结算窗口" value={formatNumber(Number(form.settlement_window_wager))} />
|
||
<ConfigMetric label="状态" value={form.enabled ? "启用" : "停用"} />
|
||
</div>
|
||
|
||
{validationErrors.length ? (
|
||
<div className="ops-config-validation" role="alert">
|
||
<strong>配置不能发布</strong>
|
||
<ul>
|
||
{validationErrors.map((message) => (
|
||
<li key={message}>{message}</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
) : null}
|
||
|
||
<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)}
|
||
/>
|
||
<TextField
|
||
fullWidth
|
||
label="策略版本"
|
||
select
|
||
size="small"
|
||
value={form.strategy_version}
|
||
onChange={(event) => updateField("strategy_version", event.target.value)}
|
||
>
|
||
<MenuItem value="dynamic_v3">dynamic_v3</MenuItem>
|
||
<MenuItem value="fixed_v2">fixed_v2(历史兼容)</MenuItem>
|
||
</TextField>
|
||
<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="profit_rate_percent"
|
||
step="0.01"
|
||
onChange={updateField}
|
||
/>
|
||
<NumberField
|
||
form={form}
|
||
helperText="必须与该奖池实际送礼类型(lucky / super_lucky)在各适用地区的返币比例一致,否则开奖账务校验会失败"
|
||
label="主播收益 (%)"
|
||
name="anchor_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}
|
||
/>
|
||
<NumberField
|
||
form={form}
|
||
label="新手等价抽数"
|
||
name="novice_max_equivalent_draws"
|
||
onChange={updateField}
|
||
/>
|
||
<NumberField
|
||
form={form}
|
||
label="普通等价抽数"
|
||
name="normal_max_equivalent_draws"
|
||
onChange={updateField}
|
||
/>
|
||
</div>
|
||
</section>
|
||
{form.strategy_version === "dynamic_v3" ? (
|
||
<DynamicStrategySections form={form} onChange={updateField} />
|
||
) : null}
|
||
</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>
|
||
{form.strategy_version === "dynamic_v3" ? (
|
||
<div className="ops-stage-thresholds">
|
||
<TextField
|
||
disabled={activeStageConfig.stage === "novice"}
|
||
label="7 日充值门槛"
|
||
size="small"
|
||
slotProps={{ htmlInput: { min: 0, step: 1 } }}
|
||
type="number"
|
||
value={activeStageConfig.min_recharge_7d_coins}
|
||
onChange={(event) =>
|
||
updateStageField(
|
||
activeStageConfig.stage,
|
||
"min_recharge_7d_coins",
|
||
event.target.value,
|
||
)
|
||
}
|
||
/>
|
||
<TextField
|
||
disabled={activeStageConfig.stage === "novice"}
|
||
label="30 日充值门槛"
|
||
size="small"
|
||
slotProps={{ htmlInput: { min: 0, step: 1 } }}
|
||
type="number"
|
||
value={activeStageConfig.min_recharge_30d_coins}
|
||
onChange={(event) =>
|
||
updateStageField(
|
||
activeStageConfig.stage,
|
||
"min_recharge_30d_coins",
|
||
event.target.value,
|
||
)
|
||
}
|
||
/>
|
||
</div>
|
||
) : null}
|
||
<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 DynamicStrategySections({ form, onChange }) {
|
||
return (
|
||
<>
|
||
<section className="ops-config-section">
|
||
<h3>动态水位</h3>
|
||
<div className="ops-form-grid">
|
||
<NumberField
|
||
disabled
|
||
form={form}
|
||
helperText="dynamic_v3 发布规则不注资,请在奖池水位中添加金币"
|
||
label="初始奖池金币"
|
||
name="initial_pool_coins"
|
||
onChange={onChange}
|
||
/>
|
||
<NumberField form={form} label="连续未中奖保护" name="loss_streak_guarantee" onChange={onChange} />
|
||
<NumberField form={form} label="低水位金币" name="low_watermark_coins" onChange={onChange} />
|
||
<NumberField
|
||
form={form}
|
||
label="低水位非零因子 (%)"
|
||
name="low_water_nonzero_factor_percent"
|
||
step="0.01"
|
||
onChange={onChange}
|
||
/>
|
||
<NumberField form={form} label="高水位金币" name="high_watermark_coins" onChange={onChange} />
|
||
<NumberField
|
||
form={form}
|
||
label="高水位非零因子 (%)"
|
||
name="high_water_nonzero_factor_percent"
|
||
step="0.01"
|
||
onChange={onChange}
|
||
/>
|
||
<NumberField
|
||
form={form}
|
||
label="充值加权窗口 (ms)"
|
||
name="recharge_boost_window_ms"
|
||
onChange={onChange}
|
||
/>
|
||
<NumberField
|
||
form={form}
|
||
label="充值加权因子 (%)"
|
||
name="recharge_boost_factor_percent"
|
||
step="0.01"
|
||
onChange={onChange}
|
||
/>
|
||
</div>
|
||
</section>
|
||
|
||
<section className="ops-config-section">
|
||
<h3>大奖控制</h3>
|
||
<div className="ops-form-grid">
|
||
<TextField
|
||
className="ops-form-grid__wide"
|
||
helperText="按从小到大填写,使用逗号分隔"
|
||
label="大奖倍率"
|
||
required
|
||
size="small"
|
||
value={form.jackpot_multipliers}
|
||
onChange={(event) => onChange("jackpot_multipliers", event.target.value)}
|
||
/>
|
||
<NumberField
|
||
form={form}
|
||
label="全局大奖 RTP 上限 (%)"
|
||
name="jackpot_global_rtp_max_percent"
|
||
step="0.01"
|
||
onChange={onChange}
|
||
/>
|
||
<NumberField
|
||
form={form}
|
||
label="用户每日 RTP 上限 (%)"
|
||
name="jackpot_user_day_rtp_max_percent"
|
||
step="0.01"
|
||
onChange={onChange}
|
||
/>
|
||
<NumberField
|
||
form={form}
|
||
label="用户 72 小时 RTP 上限 (%)"
|
||
name="jackpot_user_72h_rtp_max_percent"
|
||
step="0.01"
|
||
onChange={onChange}
|
||
/>
|
||
<NumberField
|
||
form={form}
|
||
label="用户每日大奖次数"
|
||
name="max_jackpot_hits_per_user_day"
|
||
onChange={onChange}
|
||
/>
|
||
<NumberField
|
||
form={form}
|
||
label="50 美元等值消费门槛"
|
||
name="jackpot_spend_threshold_coins"
|
||
onChange={onChange}
|
||
/>
|
||
</div>
|
||
</section>
|
||
|
||
<section className="ops-config-section">
|
||
<h3>六维返奖上限</h3>
|
||
<div className="ops-form-grid">
|
||
<NumberField form={form} label="单次返奖上限" name="max_single_payout" onChange={onChange} />
|
||
<NumberField form={form} label="用户小时上限" name="user_hourly_payout_cap" onChange={onChange} />
|
||
<NumberField form={form} label="用户每日上限" name="user_daily_payout_cap" onChange={onChange} />
|
||
<NumberField form={form} label="设备每日上限" name="device_daily_payout_cap" onChange={onChange} />
|
||
<NumberField form={form} label="房间小时上限" name="room_hourly_payout_cap" onChange={onChange} />
|
||
<NumberField form={form} label="主播每日上限" name="anchor_daily_payout_cap" onChange={onChange} />
|
||
</div>
|
||
</section>
|
||
</>
|
||
);
|
||
}
|
||
|
||
function ConfigMetric({ label, value }) {
|
||
return (
|
||
<article className="ops-config-metric">
|
||
<span>{label}</span>
|
||
<strong>{value}</strong>
|
||
</article>
|
||
);
|
||
}
|
||
|
||
function NumberField({ disabled = false, form, helperText, label, name, onChange, step = "1" }) {
|
||
return (
|
||
<TextField
|
||
disabled={disabled}
|
||
helperText={helperText}
|
||
label={label}
|
||
required
|
||
size="small"
|
||
slotProps={{ htmlInput: { min: 0, step } }}
|
||
type="number"
|
||
value={form[name]}
|
||
onChange={(event) => onChange(name, event.target.value)}
|
||
/>
|
||
);
|
||
}
|