531 lines
25 KiB
JavaScript
531 lines
25 KiB
JavaScript
import FormControlLabel from "@mui/material/FormControlLabel";
|
||
import MenuItem from "@mui/material/MenuItem";
|
||
import TextField from "@mui/material/TextField";
|
||
import { useEffect, useMemo, useRef, useState } from "react";
|
||
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
|
||
import { LuckyGiftFieldLabel } from "./LuckyGiftFieldHelp.jsx";
|
||
|
||
const BASE_SECTIONS = [
|
||
["identity", "规则"],
|
||
["funds", "资金"],
|
||
];
|
||
|
||
const DYNAMIC_SECTIONS = [
|
||
["water", "水位"],
|
||
["jackpot", "大奖"],
|
||
["limits", "限额"],
|
||
];
|
||
|
||
export function LuckyGiftConfigSections({ appOptions, form, isCreate, onChange }) {
|
||
const scrollRef = useRef(null);
|
||
const sections = useMemo(
|
||
() => (form.strategy_version === "dynamic_v3" ? [...BASE_SECTIONS, ...DYNAMIC_SECTIONS] : BASE_SECTIONS),
|
||
[form.strategy_version],
|
||
);
|
||
const [activeSection, setActiveSection] = useState(sections[0][0]);
|
||
|
||
useEffect(() => {
|
||
if (sections.some(([sectionKey]) => sectionKey === activeSection)) return;
|
||
// dynamic_v3 的专属分区会在切回 fixed_v2 后卸载;同步回首个有效分区,避免导航继续指向不存在的面板。
|
||
setActiveSection(sections[0][0]);
|
||
if (scrollRef.current) resolveConfigScroller(scrollRef.current).scrollTop = 0;
|
||
}, [activeSection, sections]);
|
||
|
||
useEffect(() => {
|
||
const sectionList = scrollRef.current;
|
||
if (!sectionList) return undefined;
|
||
const view = sectionList.ownerDocument.defaultView;
|
||
let boundScroller;
|
||
|
||
const syncActiveSection = () => {
|
||
if (!boundScroller) return;
|
||
const readingLine =
|
||
boundScroller.getBoundingClientRect().top + configNavInset(sectionList, boundScroller) + 32;
|
||
let nextSection = sections[0][0];
|
||
for (const [sectionKey] of sections) {
|
||
const section = sectionList.querySelector(`[data-config-section="${sectionKey}"]`);
|
||
if (section && section.getBoundingClientRect().top <= readingLine) nextSection = sectionKey;
|
||
}
|
||
setActiveSection((current) => (current === nextSection ? current : nextSection));
|
||
};
|
||
|
||
const bindActualScroller = () => {
|
||
const nextScroller = resolveConfigScroller(sectionList);
|
||
if (nextScroller === boundScroller) return;
|
||
boundScroller?.removeEventListener("scroll", syncActiveSection);
|
||
boundScroller = nextScroller;
|
||
// 桌面由参数面板独立滚动,平板/手机由 DialogContent 单滚动;只监听真实 scroller,
|
||
// 避免小屏内层 overflow:visible 时导航状态更新、内容位置却完全没有变化。
|
||
boundScroller.addEventListener("scroll", syncActiveSection, { passive: true });
|
||
};
|
||
|
||
bindActualScroller();
|
||
view?.addEventListener("resize", bindActualScroller);
|
||
const ResizeObserverClass = view?.ResizeObserver;
|
||
const resizeObserver = ResizeObserverClass ? new ResizeObserverClass(bindActualScroller) : null;
|
||
resizeObserver?.observe(sectionList);
|
||
|
||
return () => {
|
||
boundScroller?.removeEventListener("scroll", syncActiveSection);
|
||
view?.removeEventListener("resize", bindActualScroller);
|
||
resizeObserver?.disconnect();
|
||
};
|
||
}, [sections]);
|
||
|
||
const jumpToSection = (sectionKey) => {
|
||
const sectionList = scrollRef.current;
|
||
const section = sectionList?.querySelector(`[data-config-section="${sectionKey}"]`);
|
||
if (!sectionList || !section) return;
|
||
const scroller = resolveConfigScroller(sectionList);
|
||
setActiveSection(sectionKey);
|
||
// offsetParent 不是可靠的滚动参考系;无论当前是桌面内层还是响应式外层滚动,都用视口坐标
|
||
// 换算同一个 scroller 的目标位点;窄屏横向导航是 sticky 覆盖层,目标还必须落到导航下方。
|
||
const navInset = configNavInset(sectionList, scroller);
|
||
const top = Math.max(
|
||
scroller.scrollTop +
|
||
section.getBoundingClientRect().top -
|
||
scroller.getBoundingClientRect().top -
|
||
navInset -
|
||
8,
|
||
0,
|
||
);
|
||
const reduceMotion = scroller.ownerDocument.defaultView?.matchMedia?.(
|
||
"(prefers-reduced-motion: reduce)",
|
||
).matches;
|
||
if (typeof scroller.scrollTo === "function") {
|
||
scroller.scrollTo({ behavior: reduceMotion ? "auto" : "smooth", top });
|
||
} else {
|
||
// jsdom 和部分旧 WebView 没有 Element.scrollTo,直接写 scrollTop 仍能保持导航可用。
|
||
scroller.scrollTop = top;
|
||
}
|
||
// 导航按钮只负责选择分区;焦点落到标题后,读屏会立即获得当前编辑上下文。
|
||
section.querySelector("h3")?.focus({ preventScroll: true });
|
||
};
|
||
|
||
const fundTotal =
|
||
Number(form.pool_rate_percent || 0) +
|
||
Number(form.profit_rate_percent || 0) +
|
||
Number(form.anchor_rate_percent || 0);
|
||
|
||
return (
|
||
<aside className="ops-config-params" aria-label="全局参数">
|
||
<nav className="ops-config-section-nav" aria-label="全局参数分区">
|
||
<strong>全局参数</strong>
|
||
{sections.map(([sectionKey, label]) => (
|
||
<button
|
||
aria-controls={`ops-config-section-${sectionKey}`}
|
||
aria-current={activeSection === sectionKey ? "true" : undefined}
|
||
className={activeSection === sectionKey ? "is-active" : ""}
|
||
key={sectionKey}
|
||
type="button"
|
||
onClick={() => jumpToSection(sectionKey)}
|
||
>
|
||
{label}
|
||
</button>
|
||
))}
|
||
</nav>
|
||
|
||
<div className="ops-config-section-list" ref={scrollRef}>
|
||
<section
|
||
aria-labelledby="ops-config-heading-identity"
|
||
className="ops-config-section"
|
||
data-config-section="identity"
|
||
id="ops-config-section-identity"
|
||
>
|
||
<SectionHeading id="ops-config-heading-identity" title="规则身份" />
|
||
<div className="ops-form-grid">
|
||
{isCreate ? (
|
||
<TextField
|
||
fullWidth
|
||
required
|
||
label={<LuckyGiftFieldLabel field="app_code" label="应用" />}
|
||
select
|
||
size="small"
|
||
value={form.app_code}
|
||
onChange={(event) => onChange("app_code", event.target.value)}
|
||
>
|
||
{appOptions.map(([value, label]) => (
|
||
<MenuItem key={value} value={value}>
|
||
{label}
|
||
</MenuItem>
|
||
))}
|
||
</TextField>
|
||
) : (
|
||
<TextField
|
||
disabled
|
||
fullWidth
|
||
label={<LuckyGiftFieldLabel field="app_code" label="应用" />}
|
||
size="small"
|
||
value={form.app_code}
|
||
/>
|
||
)}
|
||
<TextField
|
||
fullWidth
|
||
required
|
||
label={<LuckyGiftFieldLabel field="pool_id" label="奖池" />}
|
||
size="small"
|
||
value={form.pool_id}
|
||
onChange={(event) => onChange("pool_id", event.target.value)}
|
||
/>
|
||
<TextField
|
||
fullWidth
|
||
label={<LuckyGiftFieldLabel field="strategy_version" label="策略版本" />}
|
||
select
|
||
size="small"
|
||
value={form.strategy_version}
|
||
onChange={(event) => onChange("strategy_version", event.target.value)}
|
||
>
|
||
<MenuItem value="dynamic_v3">dynamic_v3</MenuItem>
|
||
<MenuItem value="fixed_v2">fixed_v2(历史兼容)</MenuItem>
|
||
</TextField>
|
||
<FormControlLabel
|
||
className="ops-config-enabled-field"
|
||
control={
|
||
<AdminSwitch
|
||
checked={form.enabled}
|
||
label="发布状态"
|
||
onChange={(event) => onChange("enabled", event.target.checked)}
|
||
/>
|
||
}
|
||
label={
|
||
<LuckyGiftFieldLabel
|
||
field="enabled"
|
||
label={form.enabled ? "发布后立即启用" : "发布后保持停用"}
|
||
/>
|
||
}
|
||
/>
|
||
</div>
|
||
<p className="ops-field-note">修改奖池 ID 会发布到新奖池,不影响原奖池。</p>
|
||
</section>
|
||
|
||
<section
|
||
aria-labelledby="ops-config-heading-funds"
|
||
className="ops-config-section"
|
||
data-config-section="funds"
|
||
id="ops-config-section-funds"
|
||
>
|
||
<SectionHeading
|
||
detail={
|
||
form.strategy_version === "dynamic_v3"
|
||
? `资金合计 ${formatCompactNumber(fundTotal)}%`
|
||
: undefined
|
||
}
|
||
id="ops-config-heading-funds"
|
||
ok={Math.abs(fundTotal - 100) < 0.0001}
|
||
title="返奖与资金分配"
|
||
/>
|
||
<div className="ops-form-grid">
|
||
<NumberField
|
||
form={form}
|
||
label="目标 RTP (%)"
|
||
name="target_rtp_percent"
|
||
step="0.01"
|
||
onChange={onChange}
|
||
/>
|
||
<NumberField
|
||
form={form}
|
||
label="奖档允许偏差 (%)"
|
||
name="control_band_percent"
|
||
step="0.01"
|
||
onChange={onChange}
|
||
/>
|
||
<NumberField
|
||
form={form}
|
||
label="进入奖池比例 (%)"
|
||
name="pool_rate_percent"
|
||
step="0.01"
|
||
onChange={onChange}
|
||
/>
|
||
<NumberField
|
||
form={form}
|
||
label="平台收入比例 (%)"
|
||
name="profit_rate_percent"
|
||
step="0.01"
|
||
onChange={onChange}
|
||
/>
|
||
<NumberField
|
||
form={form}
|
||
label="收礼对象收益比例 (%)"
|
||
name="anchor_rate_percent"
|
||
step="0.01"
|
||
onChange={onChange}
|
||
/>
|
||
<NumberField
|
||
form={form}
|
||
label="每轮观察流水"
|
||
name="settlement_window_wager"
|
||
onChange={onChange}
|
||
/>
|
||
<NumberField
|
||
form={form}
|
||
label="V2 折算参考单价"
|
||
name="gift_price_reference"
|
||
onChange={onChange}
|
||
/>
|
||
<NumberField
|
||
disabled={form.strategy_version === "dynamic_v3"}
|
||
form={form}
|
||
label="新手阶段上限(折算次数)"
|
||
name="novice_max_equivalent_draws"
|
||
onChange={onChange}
|
||
/>
|
||
<NumberField
|
||
disabled={form.strategy_version === "dynamic_v3"}
|
||
form={form}
|
||
label="普通阶段上限(折算次数)"
|
||
name="normal_max_equivalent_draws"
|
||
onChange={onChange}
|
||
/>
|
||
</div>
|
||
<p className="ops-field-note">
|
||
发布前请核对收礼对象收益与实际送礼返币比例;不一致时,V3 真实开奖会拒绝账务结算。
|
||
</p>
|
||
</section>
|
||
|
||
{form.strategy_version === "dynamic_v3" ? (
|
||
<>
|
||
<section
|
||
aria-labelledby="ops-config-heading-water"
|
||
className="ops-config-section"
|
||
data-config-section="water"
|
||
id="ops-config-section-water"
|
||
>
|
||
<SectionHeading id="ops-config-heading-water" title="动态水位" />
|
||
<div className="ops-form-grid">
|
||
<NumberField
|
||
disabled
|
||
form={form}
|
||
helperText="请通过奖池水位操作注资"
|
||
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
|
||
aria-labelledby="ops-config-heading-jackpot"
|
||
className="ops-config-section"
|
||
data-config-section="jackpot"
|
||
id="ops-config-section-jackpot"
|
||
>
|
||
<SectionHeading id="ops-config-heading-jackpot" title="大奖触发条件" />
|
||
<JackpotFlowSummary />
|
||
<div className="ops-form-grid">
|
||
<TextField
|
||
className="ops-form-grid__wide"
|
||
helperText="按从小到大填写,使用逗号分隔"
|
||
label={
|
||
<LuckyGiftFieldLabel field="jackpot_multipliers" 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="用户本轮返还触发线 (%)"
|
||
name="jackpot_user_round_rtp_max_percent"
|
||
step="0.01"
|
||
onChange={onChange}
|
||
/>
|
||
<NumberField
|
||
form={form}
|
||
label="用户近 48 小时返还触发线 (%)"
|
||
name="jackpot_user_48h_rtp_max_percent"
|
||
step="0.01"
|
||
onChange={onChange}
|
||
/>
|
||
<NumberField
|
||
form={form}
|
||
label="每位用户每日大奖次数"
|
||
name="max_jackpot_hits_per_user_day"
|
||
onChange={onChange}
|
||
/>
|
||
<NumberField
|
||
disabled
|
||
form={form}
|
||
helperText="仅兼容历史配置,当前规则不参与大奖资格"
|
||
label="用户日累计消费触发门槛(金币)— 已停用"
|
||
name="jackpot_spend_threshold_coins"
|
||
onChange={onChange}
|
||
required={false}
|
||
/>
|
||
</div>
|
||
</section>
|
||
|
||
<section
|
||
aria-labelledby="ops-config-heading-limits"
|
||
className="ops-config-section"
|
||
data-config-section="limits"
|
||
id="ops-config-section-limits"
|
||
>
|
||
<SectionHeading id="ops-config-heading-limits" title="各场景返奖上限" />
|
||
<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>
|
||
</>
|
||
) : null}
|
||
</div>
|
||
</aside>
|
||
);
|
||
}
|
||
|
||
function SectionHeading({ detail, id, ok = false, title }) {
|
||
return (
|
||
<div className="ops-config-section__head">
|
||
<h3 id={id} tabIndex={-1}>
|
||
{title}
|
||
</h3>
|
||
{detail ? <span className={ok ? "is-ok" : "is-warn"}>{detail}</span> : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function JackpotFlowSummary() {
|
||
return (
|
||
<div className="ops-jackpot-flow" aria-label="大奖产生方式">
|
||
<div>
|
||
<strong>1. 大盘先通过</strong>
|
||
<span>本轮大盘 RTP 不高于全局触发线,才继续检查用户;等于触发线也可通过。</span>
|
||
</div>
|
||
<div>
|
||
<strong>2. 用户两项都通过</strong>
|
||
<span>用户本轮 RTP 和近 48 小时 RTP 都必须严格低于各自触发线,少一项都不能出大奖。</span>
|
||
</div>
|
||
<div>
|
||
<strong>3. 下次送礼尝试大奖</strong>
|
||
<span>
|
||
用户下次送礼时,先剔除奖池或限额付不起的倍率,再随机选择;没有可支付倍率就不出大奖,本轮资格也不会顺延或重复使用。
|
||
</span>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function NumberField({ disabled = false, form, helperText, label, name, onChange, required = true, step = "1" }) {
|
||
return (
|
||
<TextField
|
||
disabled={disabled}
|
||
helperText={helperText}
|
||
label={<LuckyGiftFieldLabel field={name} label={label} />}
|
||
required={required}
|
||
size="small"
|
||
slotProps={{ htmlInput: { inputMode: "decimal", min: 0, step } }}
|
||
type="number"
|
||
value={form[name]}
|
||
onChange={(event) => onChange(name, event.target.value)}
|
||
/>
|
||
);
|
||
}
|
||
|
||
function formatCompactNumber(value) {
|
||
return Number.isFinite(value) ? Number(value.toFixed(4)).toString() : "-";
|
||
}
|
||
|
||
function resolveConfigScroller(sectionList) {
|
||
// 桌面参数列限定高度后由自己滚动;响应式规则把它改为 overflow:visible,此时 scrollHeight 与
|
||
// clientHeight 相等,实际滚动责任上移到 DialogContent。判定能力而非 viewport 宽度可兼容缩放和嵌入场景。
|
||
if (sectionList.scrollHeight > sectionList.clientHeight) return sectionList;
|
||
return sectionList.closest(".ops-config-dialog-content") || sectionList;
|
||
}
|
||
|
||
function configNavInset(sectionList, scroller) {
|
||
if (scroller === sectionList) return 0;
|
||
const nav = sectionList.previousElementSibling;
|
||
if (!nav?.classList.contains("ops-config-section-nav")) return 0;
|
||
// 响应式是否形成覆盖层由真实布局决定:390px 时 nav 为横向 row,1024px 时仍是左侧 column。
|
||
// 读取 computed style 而不是猜 innerWidth,才能兼容浏览器缩放、嵌入容器和未来断点调整。
|
||
const flexDirection = nav.ownerDocument.defaultView?.getComputedStyle(nav).flexDirection;
|
||
return flexDirection === "row" ? nav.getBoundingClientRect().height : 0;
|
||
}
|