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

575 lines
30 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 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)}
/>
);
}