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

540 lines
26 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 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 通过后还要同时满足用户本轮 48 小时返还条件和当日大奖次数限制
</span>
</div>
<div>
<strong>2. 尝试可支付大奖</strong>
<span>资格通过后从奖池余额和各项返奖上限都付得起的独立大奖倍率中选择一档</span>
</div>
<div>
<strong>3. 大奖成功直接结束</strong>
<span>本抽直接采用独立大奖结果并累计一次大奖次数不再叠加或继续抽普通奖档</span>
</div>
<div>
<strong>4. 未成功才走普通开奖</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 为横向 row1024px 时仍是左侧 column。
// 读取 computed style 而不是猜 innerWidth才能兼容浏览器缩放、嵌入容器和未来断点调整。
const flexDirection = nav.ownerDocument.defaultView?.getComputedStyle(nav).flexDirection;
return flexDirection === "row" ? nav.getBoundingClientRect().height : 0;
}