2026-05-29 12:55:44 +08:00

423 lines
21 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 { 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+$/, "")}%`;
}