import { useEffect, useState } from "react"; import AddOutlined from "@mui/icons-material/AddOutlined"; import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined"; import SaveOutlined from "@mui/icons-material/SaveOutlined"; import Checkbox from "@mui/material/Checkbox"; import Drawer from "@mui/material/Drawer"; import FormControlLabel from "@mui/material/FormControlLabel"; import IconButton from "@mui/material/IconButton"; import MenuItem from "@mui/material/MenuItem"; import TextField from "@mui/material/TextField"; import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx"; import { Button } from "@/shared/ui/Button.jsx"; import { FieldLabel } from "@/features/lucky-gift/components/FieldLabel.jsx"; import { addTierToStage, correctStageTierProbabilities, removeTierFromStage, stageExpectedRTP, stageProbabilityTotal, tierRTPContribution, updateStageTier, } from "@/features/lucky-gift/configModel.js"; import { broadcastLevelOptions, rewardSourceOptions, stageOptions, optionLabel } from "@/features/lucky-gift/constants.js"; import styles from "@/features/lucky-gift/lucky-gift.module.css"; const helpText = { controlBandPercent: "RTP 正常波动带。1 表示允许当前窗口在目标值上下 1% 内自然波动。", enabled: "控制全站幸运礼物抽奖是否生效。关闭后用户仍可送礼,但不会触发幸运抽奖。", giftPriceReference: "配置奖档时使用的参考礼物价格。真实抽奖会按用户实际消耗金币等比缩放奖励。", noviceMaxEquivalentDraws: "新手阶段截止等价抽数。等价抽数=累计消耗金币/参考价格,不同价格礼物会按金币流水推进。", normalMaxEquivalentDraws: "正常阶段截止等价抽数。超过该值后进入高阶阶段,不能小于新手阶段阈值。", maxSinglePayout: "单次基础返奖上限。超过该值的候选会被风控剪掉,真实抽奖按实际消耗金币等比缩放。", poolId: "奖池 ID 是幸运礼物规则隔离键。不同奖池拥有独立 RTP、窗口和阶段奖档。", poolRatePercent: "每笔幸运礼物扣费进入基础奖池的比例,必须大于等于 RTP,避免奖池长期亏空。", userHourlyPayoutCap: "同一用户每小时基础返奖上限。达到上限后,该用户会优先进入 0x 或低倍率。", userDailyPayoutCap: "同一用户每日基础返奖上限。按 UTC 自然日统计。", deviceDailyPayoutCap: "同一设备每日基础返奖上限。用于限制多账号集中领取。", roomHourlyPayoutCap: "同一房间每小时基础返奖上限。房间触顶后,新用户也会被该房间上限影响。", anchorDailyPayoutCap: "同一收礼主播每日基础返奖上限。按 UTC 自然日统计。", settlementWindowWager: "RTP 结算窗口按金币流水计算,不按抽数计算,低价和高价礼物会自然等比参与控制。", stages: "新手、正常、高阶三个阶段必须各自配置概率,抽奖引擎按用户阶段读取对应奖档。", targetRtpPercent: "基础返奖目标。95 表示长期每消耗 100 金币,基础 RTP 目标返还 95 金币。", }; export function LuckyGiftConfigDrawer({ abilities, configLoading, configSaving, form, onClose, onSubmit, open, setForm, }) { const disabled = !abilities.canUpdate || configLoading || configSaving; return (

幸运礼物配置

基础控制
} value={form.poolId} /> } type="number" value={form.targetRtpPercent} onChange={(event) => updateForm(setForm, "targetRtpPercent", event.target.value)} /> } type="number" value={form.poolRatePercent} onChange={(event) => updateForm(setForm, "poolRatePercent", event.target.value)} /> } type="number" value={form.settlementWindowWager} onChange={(event) => updateForm(setForm, "settlementWindowWager", event.target.value)} /> } type="number" value={form.controlBandPercent} onChange={(event) => updateForm(setForm, "controlBandPercent", event.target.value)} /> } type="number" value={form.giftPriceReference} onChange={(event) => updateForm(setForm, "giftPriceReference", event.target.value)} /> } type="number" value={form.noviceMaxEquivalentDraws} onChange={(event) => updateForm(setForm, "noviceMaxEquivalentDraws", event.target.value)} /> } type="number" value={form.normalMaxEquivalentDraws} onChange={(event) => updateForm(setForm, "normalMaxEquivalentDraws", event.target.value)} /> updateForm(setForm, "enabled", event.target.checked)} />
风控上限
} type="number" value={form.maxSinglePayout} onChange={(event) => updateForm(setForm, "maxSinglePayout", event.target.value)} /> } type="number" value={form.userHourlyPayoutCap} onChange={(event) => updateForm(setForm, "userHourlyPayoutCap", event.target.value)} /> } type="number" value={form.userDailyPayoutCap} onChange={(event) => updateForm(setForm, "userDailyPayoutCap", event.target.value)} /> } type="number" value={form.deviceDailyPayoutCap} onChange={(event) => updateForm(setForm, "deviceDailyPayoutCap", event.target.value)} /> } type="number" value={form.roomHourlyPayoutCap} onChange={(event) => updateForm(setForm, "roomHourlyPayoutCap", event.target.value)} /> } type="number" value={form.anchorDailyPayoutCap} onChange={(event) => updateForm(setForm, "anchorDailyPayoutCap", event.target.value)} />
阶段奖档
); } function StageTierEditor({ disabled, form, setForm }) { const [stageTargetRTP, setStageTargetRTP] = useState(() => defaultStageTargetRTP(form.targetRtpPercent)); useEffect(() => { setStageTargetRTP(defaultStageTargetRTP(form.targetRtpPercent)); }, [form.targetRtpPercent]); return (
{stageOptions.map(([stageKey, stageLabel]) => { const stage = (form.stages || []).find((item) => item.stage === stageKey) || { stage: stageKey, tiers: [] }; const expectedRTP = stageExpectedRTP(stage); const lowerRTP = Number(form.targetRtpPercent || 0) - Number(form.controlBandPercent || 0); const upperRTP = Number(form.targetRtpPercent || 0) + Number(form.controlBandPercent || 0); const rtpInBand = expectedRTP >= lowerRTP && expectedRTP <= upperRTP; return (
概率 {formatPercent(stageProbabilityTotal(stage))} 期望 RTP {formatPercent(expectedRTP)}
setStageTargetRTP((current) => ({ ...current, [stageKey]: event.target.value })) } />
{stage.tiers.map((tier, index) => ( ))}
); })}
); } function defaultStageTargetRTP(targetRTPPercent) { return Object.fromEntries(stageOptions.map(([stageKey]) => [stageKey, String(targetRTPPercent || 95)])); } function TierRow({ disabled, setForm, stageKey, stageLabel, tier, tierCount, tierIndex }) { const updateTier = (patch) => updateStages(setForm, (stages) => updateStageTier(stages, stageKey, tierIndex, patch)); return (
updateTier({ tierId: event.target.value })} /> updateTier({ multiplier: event.target.value })} /> updateTier({ probabilityPercent: event.target.value })} />
贡献 {formatPercent(tierRTPContribution(tier))}
updateTier({ rewardSource: value })} /> updateTier({ broadcastLevel: value })} />
updateTier({ highWaterOnly: event.target.checked })} /> } label="高水位" /> updateTier({ enabled: event.target.checked })} /> } label="启用" />
updateStages(setForm, (stages) => removeTierFromStage(stages, stageKey, tierIndex))} >
); } function SelectField({ disabled, label, onChange, options, value }) { return ( onChange(event.target.value)}> {options.map(([optionValue, optionLabelText]) => ( {optionLabelText || optionLabel(options, optionValue)} ))} ); } function SwitchField({ checked, className, disabled, help, label, onChange }) { return (
); } function updateForm(setForm, key, value) { setForm((current) => ({ ...current, [key]: value })); } function updateStages(setForm, updater) { setForm((current) => ({ ...current, stages: updater(current.stages || []) })); } function formatPercent(value) { const number = Number(value || 0); return `${number.toFixed(2).replace(/\.?0+$/, "")}%`; }