423 lines
21 KiB
JavaScript
423 lines
21 KiB
JavaScript
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 (
|
||
<Drawer anchor="right" open={open} onClose={configSaving ? undefined : onClose}>
|
||
<form className={["form-drawer", styles.configDrawer].join(" ")} onSubmit={onSubmit}>
|
||
<h2>幸运礼物配置</h2>
|
||
<section className="form-drawer__section">
|
||
<div className="form-drawer__section-title">基础控制</div>
|
||
<div className={styles.configGrid}>
|
||
<TextField
|
||
required
|
||
disabled
|
||
label={<FieldLabel help={helpText.poolId} text="奖池 ID" />}
|
||
value={form.poolId}
|
||
/>
|
||
<TextField
|
||
required
|
||
disabled={disabled}
|
||
inputProps={{ max: 99, min: 10, step: 0.01 }}
|
||
label={<FieldLabel help={helpText.targetRtpPercent} text="RTP %" />}
|
||
type="number"
|
||
value={form.targetRtpPercent}
|
||
onChange={(event) => updateForm(setForm, "targetRtpPercent", event.target.value)}
|
||
/>
|
||
<TextField
|
||
required
|
||
disabled={disabled}
|
||
inputProps={{ max: 100, min: 10, step: 0.01 }}
|
||
label={<FieldLabel help={helpText.poolRatePercent} text="入池比例 %" />}
|
||
type="number"
|
||
value={form.poolRatePercent}
|
||
onChange={(event) => updateForm(setForm, "poolRatePercent", event.target.value)}
|
||
/>
|
||
<TextField
|
||
required
|
||
disabled={disabled}
|
||
inputProps={{ min: 1, step: 1 }}
|
||
label={<FieldLabel help={helpText.settlementWindowWager} text="结算窗口流水" />}
|
||
type="number"
|
||
value={form.settlementWindowWager}
|
||
onChange={(event) => updateForm(setForm, "settlementWindowWager", event.target.value)}
|
||
/>
|
||
<TextField
|
||
required
|
||
disabled={disabled}
|
||
inputProps={{ max: 5, min: 0.01, step: 0.01 }}
|
||
label={<FieldLabel help={helpText.controlBandPercent} text="波动带 %" />}
|
||
type="number"
|
||
value={form.controlBandPercent}
|
||
onChange={(event) => updateForm(setForm, "controlBandPercent", event.target.value)}
|
||
/>
|
||
<TextField
|
||
required
|
||
disabled={disabled}
|
||
inputProps={{ min: 1, step: 1 }}
|
||
label={<FieldLabel help={helpText.giftPriceReference} text="参考价格" />}
|
||
type="number"
|
||
value={form.giftPriceReference}
|
||
onChange={(event) => updateForm(setForm, "giftPriceReference", event.target.value)}
|
||
/>
|
||
<TextField
|
||
required
|
||
disabled={disabled}
|
||
inputProps={{ min: 0, step: 1 }}
|
||
label={<FieldLabel help={helpText.noviceMaxEquivalentDraws} text="新手等价抽数" />}
|
||
type="number"
|
||
value={form.noviceMaxEquivalentDraws}
|
||
onChange={(event) => updateForm(setForm, "noviceMaxEquivalentDraws", event.target.value)}
|
||
/>
|
||
<TextField
|
||
required
|
||
disabled={disabled}
|
||
inputProps={{ min: 0, step: 1 }}
|
||
label={<FieldLabel help={helpText.normalMaxEquivalentDraws} text="正常等价抽数" />}
|
||
type="number"
|
||
value={form.normalMaxEquivalentDraws}
|
||
onChange={(event) => updateForm(setForm, "normalMaxEquivalentDraws", event.target.value)}
|
||
/>
|
||
<SwitchField
|
||
checked={form.enabled}
|
||
className={styles.configWide}
|
||
disabled={disabled}
|
||
help={helpText.enabled}
|
||
label="幸运礼物状态"
|
||
onChange={(event) => updateForm(setForm, "enabled", event.target.checked)}
|
||
/>
|
||
</div>
|
||
</section>
|
||
|
||
<section className="form-drawer__section">
|
||
<div className="form-drawer__section-title">风控上限</div>
|
||
<div className={styles.configGrid}>
|
||
<TextField
|
||
required
|
||
disabled={disabled}
|
||
inputProps={{ min: 1, step: 1 }}
|
||
label={<FieldLabel help={helpText.maxSinglePayout} text="单次返奖上限" />}
|
||
type="number"
|
||
value={form.maxSinglePayout}
|
||
onChange={(event) => updateForm(setForm, "maxSinglePayout", event.target.value)}
|
||
/>
|
||
<TextField
|
||
required
|
||
disabled={disabled}
|
||
inputProps={{ min: 1, step: 1 }}
|
||
label={<FieldLabel help={helpText.userHourlyPayoutCap} text="用户小时上限" />}
|
||
type="number"
|
||
value={form.userHourlyPayoutCap}
|
||
onChange={(event) => updateForm(setForm, "userHourlyPayoutCap", event.target.value)}
|
||
/>
|
||
<TextField
|
||
required
|
||
disabled={disabled}
|
||
inputProps={{ min: 1, step: 1 }}
|
||
label={<FieldLabel help={helpText.userDailyPayoutCap} text="用户每日上限" />}
|
||
type="number"
|
||
value={form.userDailyPayoutCap}
|
||
onChange={(event) => updateForm(setForm, "userDailyPayoutCap", event.target.value)}
|
||
/>
|
||
<TextField
|
||
required
|
||
disabled={disabled}
|
||
inputProps={{ min: 1, step: 1 }}
|
||
label={<FieldLabel help={helpText.deviceDailyPayoutCap} text="设备每日上限" />}
|
||
type="number"
|
||
value={form.deviceDailyPayoutCap}
|
||
onChange={(event) => updateForm(setForm, "deviceDailyPayoutCap", event.target.value)}
|
||
/>
|
||
<TextField
|
||
required
|
||
disabled={disabled}
|
||
inputProps={{ min: 1, step: 1 }}
|
||
label={<FieldLabel help={helpText.roomHourlyPayoutCap} text="房间小时上限" />}
|
||
type="number"
|
||
value={form.roomHourlyPayoutCap}
|
||
onChange={(event) => updateForm(setForm, "roomHourlyPayoutCap", event.target.value)}
|
||
/>
|
||
<TextField
|
||
required
|
||
disabled={disabled}
|
||
inputProps={{ min: 1, step: 1 }}
|
||
label={<FieldLabel help={helpText.anchorDailyPayoutCap} text="主播每日上限" />}
|
||
type="number"
|
||
value={form.anchorDailyPayoutCap}
|
||
onChange={(event) => updateForm(setForm, "anchorDailyPayoutCap", event.target.value)}
|
||
/>
|
||
</div>
|
||
</section>
|
||
|
||
<section className="form-drawer__section">
|
||
<div className="form-drawer__section-title">阶段奖档</div>
|
||
<StageTierEditor disabled={disabled} form={form} setForm={setForm} />
|
||
</section>
|
||
|
||
<div className="form-drawer__actions">
|
||
<Button disabled={configSaving} type="button" onClick={onClose}>
|
||
取消
|
||
</Button>
|
||
<Button disabled={disabled} startIcon={<SaveOutlined fontSize="small" />} type="submit" variant="primary">
|
||
保存
|
||
</Button>
|
||
</div>
|
||
</form>
|
||
</Drawer>
|
||
);
|
||
}
|
||
|
||
function StageTierEditor({ disabled, form, setForm }) {
|
||
const [stageTargetRTP, setStageTargetRTP] = useState(() => defaultStageTargetRTP(form.targetRtpPercent));
|
||
|
||
useEffect(() => {
|
||
setStageTargetRTP(defaultStageTargetRTP(form.targetRtpPercent));
|
||
}, [form.targetRtpPercent]);
|
||
|
||
return (
|
||
<div className={styles.stageStack}>
|
||
{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 (
|
||
<div key={stageKey} className={styles.stageBlock}>
|
||
<div className={styles.stageHeader}>
|
||
<FieldLabel help={helpText.stages} text={stageLabel} />
|
||
<div className={styles.stageMetrics}>
|
||
<span className={styles.stageTotal}>概率 {formatPercent(stageProbabilityTotal(stage))}</span>
|
||
<span className={rtpInBand ? styles.stageTotal : styles.stageRTPError}>
|
||
期望 RTP {formatPercent(expectedRTP)}
|
||
</span>
|
||
</div>
|
||
<div className={styles.stageTools}>
|
||
<TextField
|
||
disabled={disabled}
|
||
inputProps={{ max: 500, min: 0, step: 0.01 }}
|
||
label="纠正 RTP %"
|
||
size="small"
|
||
type="number"
|
||
value={stageTargetRTP[stageKey] ?? form.targetRtpPercent}
|
||
onChange={(event) =>
|
||
setStageTargetRTP((current) => ({ ...current, [stageKey]: event.target.value }))
|
||
}
|
||
/>
|
||
<Button
|
||
disabled={disabled}
|
||
type="button"
|
||
onClick={() =>
|
||
updateStages(setForm, (stages) =>
|
||
correctStageTierProbabilities(stages, stageKey, stageTargetRTP[stageKey])
|
||
)
|
||
}
|
||
>
|
||
纠正概率
|
||
</Button>
|
||
<Button
|
||
disabled={disabled}
|
||
startIcon={<AddOutlined fontSize="small" />}
|
||
type="button"
|
||
onClick={() => updateStages(setForm, (stages) => addTierToStage(stages, stageKey))}
|
||
>
|
||
添加奖档
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
<div className={styles.tierList}>
|
||
{stage.tiers.map((tier, index) => (
|
||
<TierRow
|
||
key={`${stageKey}-${index}`}
|
||
disabled={disabled}
|
||
stageKey={stageKey}
|
||
stageLabel={stageLabel}
|
||
tier={tier}
|
||
tierIndex={index}
|
||
tierCount={stage.tiers.length}
|
||
setForm={setForm}
|
||
/>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<div className={styles.tierRow}>
|
||
<TextField
|
||
required
|
||
disabled={disabled}
|
||
label="奖档 ID"
|
||
value={tier.tierId}
|
||
onChange={(event) => updateTier({ tierId: event.target.value })}
|
||
/>
|
||
<TextField
|
||
required
|
||
disabled={disabled}
|
||
inputProps={{ min: 0, step: 0.01 }}
|
||
label="倍率"
|
||
type="number"
|
||
value={tier.multiplier}
|
||
onChange={(event) => updateTier({ multiplier: event.target.value })}
|
||
/>
|
||
<TextField
|
||
required
|
||
disabled={disabled}
|
||
inputProps={{ max: 100, min: 0, step: 0.01 }}
|
||
label="概率 %"
|
||
type="number"
|
||
value={tier.probabilityPercent}
|
||
onChange={(event) => updateTier({ probabilityPercent: event.target.value })}
|
||
/>
|
||
<div className={styles.tierContribution}>贡献 {formatPercent(tierRTPContribution(tier))}</div>
|
||
<SelectField
|
||
disabled={disabled}
|
||
label="预算来源"
|
||
options={rewardSourceOptions}
|
||
value={tier.rewardSource}
|
||
onChange={(value) => updateTier({ rewardSource: value })}
|
||
/>
|
||
<SelectField
|
||
disabled={disabled}
|
||
label="广播"
|
||
options={broadcastLevelOptions}
|
||
value={tier.broadcastLevel}
|
||
onChange={(value) => updateTier({ broadcastLevel: value })}
|
||
/>
|
||
<div className={styles.tierFlags}>
|
||
<FormControlLabel
|
||
control={
|
||
<Checkbox
|
||
checked={Boolean(tier.highWaterOnly)}
|
||
disabled={disabled}
|
||
onChange={(event) => updateTier({ highWaterOnly: event.target.checked })}
|
||
/>
|
||
}
|
||
label="高水位"
|
||
/>
|
||
<FormControlLabel
|
||
control={
|
||
<Checkbox
|
||
checked={tier.enabled !== false}
|
||
disabled={disabled}
|
||
onChange={(event) => updateTier({ enabled: event.target.checked })}
|
||
/>
|
||
}
|
||
label="启用"
|
||
/>
|
||
</div>
|
||
<IconButton
|
||
aria-label={`删除${stageLabel}奖档`}
|
||
disabled={disabled || tierCount <= 1}
|
||
size="small"
|
||
onClick={() => updateStages(setForm, (stages) => removeTierFromStage(stages, stageKey, tierIndex))}
|
||
>
|
||
<DeleteOutlineOutlined fontSize="small" />
|
||
</IconButton>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function SelectField({ disabled, label, onChange, options, value }) {
|
||
return (
|
||
<TextField select disabled={disabled} label={label} value={value} onChange={(event) => onChange(event.target.value)}>
|
||
{options.map(([optionValue, optionLabelText]) => (
|
||
<MenuItem key={optionValue} value={optionValue}>
|
||
{optionLabelText || optionLabel(options, optionValue)}
|
||
</MenuItem>
|
||
))}
|
||
</TextField>
|
||
);
|
||
}
|
||
|
||
function SwitchField({ checked, className, disabled, help, label, onChange }) {
|
||
return (
|
||
<div className={[styles.switchField, className].filter(Boolean).join(" ")}>
|
||
<FieldLabel help={help} text={label} />
|
||
<AdminSwitch
|
||
checked={checked}
|
||
checkedLabel="开启"
|
||
disabled={disabled}
|
||
label={label}
|
||
uncheckedLabel="关闭"
|
||
onChange={onChange}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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+$/, "")}%`;
|
||
}
|