merge: explain lucky gift configuration

This commit is contained in:
zhx 2026-07-15 18:38:28 +08:00
commit 2860bdcdad
5 changed files with 495 additions and 49 deletions

View File

@ -89,7 +89,8 @@ export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit",
...stage.tiers,
{
enabled: true,
// 10 穿线
// 10
//
highWaterOnly: nextStageMultiplier(stage.tiers) >= 10,
multiplier: String(nextStageMultiplier(stage.tiers)),
probabilityPercent: "0",
@ -151,8 +152,8 @@ export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit",
<dl className="ops-config-summary" aria-label="配置摘要">
<ConfigMetric label="应用" value={form.app_code || "-"} />
<ConfigMetric label="奖池" value={form.pool_id || "default"} />
<ConfigMetric label="策略" value={form.strategy_version} />
<ConfigMetric label="目标 RTP" value={formatPercent(Number(form.target_rtp_percent))} />
<ConfigMetric label="开奖策略" value={form.strategy_version} />
<ConfigMetric label="基础返还目标" value={formatPercent(Number(form.target_rtp_percent))} />
<ConfigMetric
label={form.strategy_version === "dynamic_v3" ? "资金拆分" : "奖池比例"}
value={
@ -161,7 +162,10 @@ export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit",
: `${form.pool_rate_percent}%`
}
/>
<ConfigMetric label="结算窗口" value={formatNumber(Number(form.settlement_window_wager))} />
<ConfigMetric
label={form.strategy_version === "dynamic_v3" ? "每轮观察流水" : "V2 窗口配置"}
value={formatNumber(Number(form.settlement_window_wager))}
/>
<ConfigMetric label="发布状态" value={form.enabled ? "启用" : "停用"} />
</dl>
<DialogContent className="ops-config-dialog-content" dividers>

View File

@ -3,6 +3,7 @@ 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", "规则"],
@ -137,7 +138,7 @@ export function LuckyGiftConfigSections({ appOptions, form, isCreate, onChange }
<TextField
fullWidth
required
label="应用"
label={<LuckyGiftFieldLabel field="app_code" label="应用" />}
select
size="small"
value={form.app_code}
@ -150,19 +151,25 @@ export function LuckyGiftConfigSections({ appOptions, form, isCreate, onChange }
))}
</TextField>
) : (
<TextField disabled fullWidth label="应用" size="small" value={form.app_code} />
<TextField
disabled
fullWidth
label={<LuckyGiftFieldLabel field="app_code" label="应用" />}
size="small"
value={form.app_code}
/>
)}
<TextField
fullWidth
required
label="奖池"
label={<LuckyGiftFieldLabel field="pool_id" label="奖池" />}
size="small"
value={form.pool_id}
onChange={(event) => onChange("pool_id", event.target.value)}
/>
<TextField
fullWidth
label="策略版本"
label={<LuckyGiftFieldLabel field="strategy_version" label="策略版本" />}
select
size="small"
value={form.strategy_version}
@ -180,8 +187,13 @@ export function LuckyGiftConfigSections({ appOptions, form, isCreate, onChange }
onChange={(event) => onChange("enabled", event.target.checked)}
/>
}
label={
<LuckyGiftFieldLabel
field="enabled"
label={form.enabled ? "发布后立即启用" : "发布后保持停用"}
/>
}
/>
</div>
<p className="ops-field-note">修改奖池 ID 会发布到新奖池不影响原奖池</p>
</section>
@ -200,7 +212,7 @@ export function LuckyGiftConfigSections({ appOptions, form, isCreate, onChange }
}
id="ops-config-heading-funds"
ok={Math.abs(fundTotal - 100) < 0.0001}
title="资金与 RTP"
title="返奖与资金分配"
/>
<div className="ops-form-grid">
<NumberField
@ -212,54 +224,61 @@ export function LuckyGiftConfigSections({ appOptions, form, isCreate, onChange }
/>
<NumberField
form={form}
label="控制带宽 (%)"
label="奖档允许偏差 (%)"
name="control_band_percent"
step="0.01"
onChange={onChange}
/>
<NumberField
form={form}
label="公共奖池 (%)"
label="进入奖池比例 (%)"
name="pool_rate_percent"
step="0.01"
onChange={onChange}
/>
<NumberField
form={form}
label="平台盈利 (%)"
label="平台收入比例 (%)"
name="profit_rate_percent"
step="0.01"
onChange={onChange}
/>
<NumberField
form={form}
label="主播收益 (%)"
label="收礼对象收益比例 (%)"
name="anchor_rate_percent"
step="0.01"
onChange={onChange}
/>
<NumberField
form={form}
label="结算窗口流水"
label="每轮观察流水"
name="settlement_window_wager"
onChange={onChange}
/>
<NumberField form={form} label="礼物参考金额" name="gift_price_reference" onChange={onChange} />
<NumberField
form={form}
label="新手等价抽数"
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="普通等价抽数"
label="普通阶段上限(折算次数)"
name="normal_max_equivalent_draws"
onChange={onChange}
/>
</div>
<p className="ops-field-note">
主播收益必须与该奖池实际送礼类型在各适用地区的返币比例一致否则发布校验失败
发布前请核对收礼对象收益与实际送礼返币比例不一致时V3 真实开奖会拒绝账务结算
</p>
</section>
@ -283,7 +302,7 @@ export function LuckyGiftConfigSections({ appOptions, form, isCreate, onChange }
/>
<NumberField
form={form}
label="连续未中奖保护"
label="连续未中奖保护次数"
name="loss_streak_guarantee"
onChange={onChange}
/>
@ -295,7 +314,7 @@ export function LuckyGiftConfigSections({ appOptions, form, isCreate, onChange }
/>
<NumberField
form={form}
label="低水位非零因子 (%)"
label="低余额中奖权重 (%)"
name="low_water_nonzero_factor_percent"
step="0.01"
onChange={onChange}
@ -308,7 +327,7 @@ export function LuckyGiftConfigSections({ appOptions, form, isCreate, onChange }
/>
<NumberField
form={form}
label="高水位非零因子 (%)"
label="高余额中奖权重 (%)"
name="high_water_nonzero_factor_percent"
step="0.01"
onChange={onChange}
@ -321,7 +340,7 @@ export function LuckyGiftConfigSections({ appOptions, form, isCreate, onChange }
/>
<NumberField
form={form}
label="充值加权因子 (%)"
label="充值后中奖权重 (%)"
name="recharge_boost_factor_percent"
step="0.01"
onChange={onChange}
@ -335,12 +354,15 @@ export function LuckyGiftConfigSections({ appOptions, form, isCreate, onChange }
data-config-section="jackpot"
id="ops-config-section-jackpot"
>
<SectionHeading id="ops-config-heading-jackpot" title="大奖控制" />
<SectionHeading id="ops-config-heading-jackpot" title="大奖触发条件" />
<JackpotFlowSummary />
<div className="ops-form-grid">
<TextField
className="ops-form-grid__wide"
helperText="按从小到大填写,使用逗号分隔"
label="大奖倍率"
label={
<LuckyGiftFieldLabel field="jackpot_multipliers" label="大奖倍率(可选倍数)" />
}
required
size="small"
value={form.jackpot_multipliers}
@ -355,21 +377,21 @@ export function LuckyGiftConfigSections({ appOptions, form, isCreate, onChange }
/>
<NumberField
form={form}
label="用户每日 RTP 上限 (%)"
label="用户当日返还触发线 (%)"
name="jackpot_user_day_rtp_max_percent"
step="0.01"
onChange={onChange}
/>
<NumberField
form={form}
label="用户 72 小时 RTP 上限 (%)"
label="用户近 72 小时返还触发线 (%)"
name="jackpot_user_72h_rtp_max_percent"
step="0.01"
onChange={onChange}
/>
<NumberField
form={form}
label="用户每日大奖次数"
label="每位用户每日大奖次数"
name="max_jackpot_hits_per_user_day"
onChange={onChange}
/>
@ -389,7 +411,7 @@ export function LuckyGiftConfigSections({ appOptions, form, isCreate, onChange }
data-config-section="limits"
id="ops-config-section-limits"
>
<SectionHeading id="ops-config-heading-limits" title="六维返奖上限" />
<SectionHeading id="ops-config-heading-limits" title="各场景返奖上限" />
<div className="ops-form-grid">
<NumberField
form={form}
@ -399,25 +421,25 @@ export function LuckyGiftConfigSections({ appOptions, form, isCreate, onChange }
/>
<NumberField
form={form}
label="用户小时上限"
label="每位用户每小时最高返奖"
name="user_hourly_payout_cap"
onChange={onChange}
/>
<NumberField
form={form}
label="用户每日上限"
label="每位用户每日最高返奖"
name="user_daily_payout_cap"
onChange={onChange}
/>
<NumberField
form={form}
label="设备每日上限"
label="每台设备每日最高返奖"
name="device_daily_payout_cap"
onChange={onChange}
/>
<NumberField
form={form}
label="房间小时上限"
label="每个房间每小时最高返奖"
name="room_hourly_payout_cap"
onChange={onChange}
/>
@ -447,12 +469,31 @@ function SectionHeading({ detail, id, ok = false, title }) {
);
}
function JackpotFlowSummary() {
return (
<div className="ops-jackpot-flow" aria-label="大奖产生方式">
<div>
<strong>1. 消费达标优先</strong>
<span>当天累计消费每跨过一次门槛就保存一次大奖资格资格从下一抽开始使用</span>
</div>
<div>
<strong>2. 返还偏低补偿</strong>
<span>没有待用资格时整体用户当天和近 72 小时平均返还都不高于触发线才尝试大奖</span>
</div>
<div>
<strong>3. 只从付得起的档位选</strong>
<span>先剔除奖池或限额不足的倍率再在剩余倍率中等机会选择消费资格付不起时会保留</span>
</div>
</div>
);
}
function NumberField({ disabled = false, form, helperText, label, name, onChange, step = "1" }) {
return (
<TextField
disabled={disabled}
helperText={helperText}
label={label}
label={<LuckyGiftFieldLabel field={name} label={label} />}
required
size="small"
slotProps={{ htmlInput: { inputMode: "decimal", min: 0, step } }}

View File

@ -0,0 +1,277 @@
import HelpOutlineOutlined from "@mui/icons-material/HelpOutlineOutlined";
import Tooltip from "@mui/material/Tooltip";
//
// Tooltip
const FIELD_HELP = {
app_code: {
summary: "决定这套幸运礼物规则属于哪个 App。不同 App 的配置、抽奖记录和奖池余额彼此隔离。",
adjust: "选择错 App 会把新版本发布到另一套业务数据中;编辑已有配置时不能在这里迁移 App。",
constraint: "发布前确认应用与实际送礼入口一致。",
},
pool_id: {
summary: "决定礼物使用哪一组规则和哪一本奖池账。同一 App 内,送礼接口会按这个 ID 找到配置。",
adjust: "改成新 ID 会创建一组新的规则,不会搬走旧奖池里的金币,也不会改写旧抽奖记录。",
constraint: "fixed_v2 与 dynamic_v3 即使使用相同 ID也会分别记账ID 必须与礼物接入配置一致。",
},
strategy_version: {
summary:
"选择开奖方式。fixed_v2 保留旧版固定规则dynamic_v3 会根据奖池余额、充值阶段、大奖条件和返奖上限动态调整。",
adjust: "切到 dynamic_v3 会载入安全草稿并保持停用,确认全部金额口径后再发布。",
constraint: "dynamic_v3 使用独立奖池且从 0 开始,发布规则不会自动注入金币。",
},
enabled: {
summary: "决定新版本发布后是否立即用于真实送礼。关闭时规则仍会保存,但不会触发幸运开奖。",
adjust: "首次配置或大幅调整时可先保持停用,核对资金比例和上限后再启用。",
constraint: "启用 dynamic_v3 前必须填完大奖消费门槛和所有返奖上限。",
},
target_rtp_percent: {
summary: "表示基础奖档的配置目标。例如填 98代表各阶段的“倍率 × 基础概率”预计返还约为 98%。",
adjust: "修改后,页面会重新分配各档基础概率;调高通常增加基础返奖,调低则相反。",
constraint:
"只能填 10%99%,进入奖池的比例不能低于它。水位、充值加成、大奖和上限都会改变实际结果,它不是线上返还承诺。",
},
control_band_percent: {
summary: "允许每个用户阶段的奖档组合与平均返还目标存在多大偏差。",
adjust: "调大更容易通过发布,但实际返还可能离目标更远;调小更严格,也更难配置出可发布的倍率组合。",
constraint: "必须大于 0 且不超过 5 个百分点,不会强行补偿单次或单个用户的结果。",
},
pool_rate_percent: {
summary: "每笔幸运礼物消费中,有多少金币进入公共奖池,后续中奖从这里支付。",
adjust: "调高会让奖池补充更快;调低会留出更多平台或收礼份额,但奖池更容易不足。",
constraint: "不能低于平均返还目标V3 中它与平台、收礼份额合计必须等于 100%,按整数金币向下取整。",
},
profit_rate_percent: {
summary: "V3 每笔消费中直接计为平台收入的比例,不进入奖池,也不发给收礼对象。",
adjust: "调高会增加平台份额,但必须相应减少奖池或收礼份额。",
constraint:
"不能为负数V3 三项资金比例必须精确合计 100%整数拆分后不能整除的余数也计入平台V2 不使用该比例开奖。",
},
anchor_rate_percent: {
summary: "V3 每笔消费中返给本次收礼对象的比例,通常是主播但不一定等于房主;这部分不是用户中奖奖励。",
adjust: "调高会增加收礼对象所得,并相应减少奖池或平台份额。",
constraint:
"按整数金币向下取整,且必须与钱包实际发给收礼对象的金币完全一致,否则 V3 会拒绝开奖V2 不使用该比例开奖。",
},
settlement_window_wager: {
summary: "V3 按实际消费金币累计一轮返还观察窗口;某一抽跨过门槛时仍归旧窗口,下一抽才进入新窗口。",
adjust: "调大后更少重开窗口调小后会更频繁清空全局窗口样本。V2 会先按礼物参考金额换算成窗口抽数。",
constraint: "必须大于 0。新窗口还没有消费样本时不会触发返还偏低补偿大奖。",
},
gift_price_reference: {
summary: "V2 用它把累计消费换算成“折算次数”,同时计算用户阶段和每轮窗口的抽数;它不是礼物真实售价。",
adjust: "调高后,同样消费折算成更少次数,用户升级更慢且 V2 窗口抽数更少;调低则相反。",
constraint: "必须大于 0。V3 的窗口直接看真实金币流水,用户阶段看 7 日和 30 日充值,不用它做这两项判断。",
},
novice_max_equivalent_draws: {
summary: "fixed_v2 中,用户累计消费折算次数不超过这个值时使用新手奖档。",
adjust: "调高会让用户在新手阶段停留更久;调低会更早进入普通阶段。",
constraint: "不能小于 0也不能高于普通阶段上限dynamic_v3 不使用这个值分层。",
},
normal_max_equivalent_draws: {
summary: "fixed_v2 中,用户超过新手上限后,不超过这个值时使用普通奖档;再超过才进入高阶奖档。",
adjust: "调高会推迟进入高阶阶段;调低会让用户更早进入高阶阶段。",
constraint: "不能小于新手阶段上限dynamic_v3 不使用这个值分层。",
},
initial_pool_coins: {
summary: "表示发布规则时是否顺便给奖池放入启动金币。dynamic_v3 已改为不允许规则自动注资。",
adjust: "该值固定为 0 且不可编辑,避免每次发新版本重复增加奖池余额。",
constraint: "需要启动资金时,请在配置列表使用“添加水位”,该操作会保留独立审计记录。",
},
loss_streak_guarantee: {
summary:
"同一用户在当前礼物奖池连续多少次返还为 0 后,下一次普通开奖会先去掉 0 倍档。填 5 表示连续 5 次为 0 后,第 6 次普通开奖尝试正奖。",
adjust: "调小会更快触发体验保护;调大会降低触发频率并减少奖池压力。",
constraint: "必须大于 0奖池不足或任一返奖上限不足时仍可返回 0不会透支。",
},
low_watermark_coins: {
summary: "本抽应进入奖池的金币先入账;入账后余额低于这个值时,系统会降低普通开奖中非 0 倍档的基础机会。",
adjust: "调高会更早开始保护奖池;调低会让正常中奖节奏保持更久。",
constraint: "必须大于 0并且小于高余额分界线。",
},
low_water_nonzero_factor_percent: {
summary: "奖池处于低余额时,普通开奖中非 0 倍档的基础机会保留多少。填 70 表示按原来的 70% 参与计算。",
adjust: "调低更保护奖池但中奖更少;调高更接近正常节奏但消耗更快。",
constraint: "必须大于 0% 且小于 100%0 倍档接收剩余概率;它不会直接提高或降低两种大奖的触发机会。",
},
high_watermark_coins: {
summary: "本抽应进入奖池的金币先入账;入账后余额高于这个值时,系统会提高普通开奖中非 0 倍档的基础机会。",
adjust: "调低会更早提高中奖机会;调高会要求奖池积累更多后才加速返奖。",
constraint: "必须严格大于低余额分界线;刚好等于该值时仍按正常状态处理。",
},
high_water_nonzero_factor_percent: {
summary: "奖池处于高余额时,普通开奖中非 0 倍档的基础机会放大到多少。填 130 表示变为原来的 130%。",
adjust: "调高会更快释放奖池、增加正奖机会;调低则更接近正常节奏。",
constraint: "必须大于 100%;放大后会重新归一化,并继续受奖池和返奖上限限制;它不会直接提高大奖触发机会。",
},
recharge_boost_window_ms: {
summary: "用户完成一次可验证充值后,普通开奖中非 0 倍档的机会加成持续多久。300000 毫秒等于 5 分钟。",
adjust: "调大会延长充值后的加成时间;调小会让加成更快结束。",
constraint: "必须大于 0生效区间包含充值时刻但不包含结束时刻外部接入没有可信充值事实不会触发加成。",
},
recharge_boost_factor_percent: {
summary: "充值加成期间,普通开奖中非 0 倍档的基础机会放大到多少。填 110 表示变为原来的 110%。",
adjust: "调高会增强充值后的中奖体验,也会增加奖池消耗;调低则更温和。",
constraint: "必须大于 100%,会与高低余额调整相乘;它只改变普通奖档,不会直接提高大奖触发机会。",
},
jackpot_multipliers: {
summary: "列出大奖可能采用的倍数,例如 200、500、1000。实际奖励按本抽消费金额乘以倍数计算。",
adjust: "增加或调高倍数会放大奖励金额和资金压力;删除倍数会减少可选大奖。",
constraint: "用逗号分隔,必须都大于 1 且从小到大;系统会在所有付得起的大奖倍数中等机会选择一档。",
},
jackpot_global_rtp_max_percent: {
summary: "当前 App、奖池、规则版本在本轮窗口内的平均返还不高于这条线时才满足补偿大奖的全局条件。",
adjust: "调高会让补偿大奖更容易进入候选;调低会要求全局返还更低才触发。",
constraint:
"必须大于 0% 且不超过 100%。全局、用户当日和近 72 小时三项都必须有消费样本并全部通过,才会直接选择可支付大奖。",
},
jackpot_user_day_rtp_max_percent: {
summary: "用户当天的平均返还不高于这条线时,才允许尝试补偿大奖。",
adjust: "调高会让更多用户满足条件;调低会把大奖更集中给当天返还明显偏低的用户。",
constraint: "按当前奖池的 UTC 自然日统计;没有消费样本时不触发。必须大于 0 且不能超过全局触发线。",
},
jackpot_user_72h_rtp_max_percent: {
summary: "用户最近连续 72 小时的平均返还不高于这条线时,才允许尝试补偿大奖。",
adjust: "调高会放宽长期条件;调低会要求用户近三天整体返还更低。",
constraint: "按当前奖池滚动 72 小时统计,不是三个本地自然日;没有消费样本时不触发,且不能超过全局触发线。",
},
max_jackpot_hits_per_user_day: {
summary: "限制同一用户在当前奖池的一个 UTC 自然日内最多真正拿到多少次大奖。",
adjust: "调高允许同一用户一天获得更多大奖;调低会更早停止该用户当天的大奖,不保证转给其他用户。",
constraint: "必须大于 0达到次数后仍可参与普通奖档尚未使用的消费资格会保留。",
},
jackpot_spend_threshold_coins: {
summary: "用户在当前奖池的同一 UTC 日内,每累计消费满一份门槛,就保存一个供后续抽奖使用的大奖资格。",
adjust: "按各 App 奖池确认的金币口径填写;调高会要求消费更多才获得资格,调低会更频繁地产生资格。",
constraint:
"启用前必须大于 0系统不会根据币价或充值记录自行猜额度一笔消费可产生多个资格资格从下一抽开始用未能支付时不会丢失。",
},
max_single_payout: {
summary: "限制一抽最多能返多少金币;超过这个金额的奖档当次不会发出。",
adjust: "调高允许更大的单次奖励,也增加资金风险;调低会直接挡住更多高倍率结果。",
constraint:
"启用 V3 时必须大于 0批量送礼按每个子抽分别判断。奖档超过任一剩余额度会整档挡住不会裁剪成较小金额。",
},
user_hourly_payout_cap: {
summary: "限制同一用户在当前 UTC 小时内累计最多返多少金币。",
adjust: "调高允许用户短时间获得更多奖励;调低会更早限制高频送礼用户。",
constraint: "启用 V3 时必须大于 0按当前奖池和实际支付时间所在的 UTC 小时计算,到下一小时重新累计。",
},
user_daily_payout_cap: {
summary: "限制同一用户在当前 UTC 自然日内累计最多返多少金币。",
adjust: "调高允许单个用户一天获得更多奖励;调低会更均匀地分散预算。",
constraint: "启用 V3 时必须大于 0按当前奖池和实际支付时间所在的 UTC 日期累计。",
},
device_daily_payout_cap: {
summary: "限制同一设备上的所有账号在一个 UTC 自然日内合计最多返多少金币,用来防止换账号绕过用户上限。",
adjust: "调高会放宽同设备多账号的总额度;调低会更严格地控制集中领取。",
constraint: "启用 V3 时必须大于 0并按当前奖池隔离必须拿到稳定设备 ID外部接入也不能用用户 ID 代替。",
},
room_hourly_payout_cap: {
summary: "限制同一房间内所有用户在当前 UTC 小时合计最多返多少金币。",
adjust: "调高允许热门房间更集中地产生奖励;调低会更早限制单房间的消耗速度。",
constraint:
"启用 V3 时必须大于 0并按当前奖池隔离外部接口没有真实房间同一 App 的外部请求会共用一个虚拟房间额度。",
},
anchor_daily_payout_cap: {
summary: "限制同一房主关联的所有房间和用户在一个 UTC 自然日内合计最多返多少金币。",
adjust: "调高允许奖励更集中在单个主播;调低会把预算分散到更多主播。",
constraint:
"启用 V3 时必须大于 0并按当前奖池隔离外部接口没有房主同一 App 的外部请求会共用一个虚拟主播额度。",
},
stage_novice: {
summary:
"新手阶段使用的一组基础奖档。V2 看累计消费折算次数V3 中未同时达到普通阶段两项充值门槛的用户都会落在这里。",
adjust: "可通过倍率组合调整未达到普通门槛用户的体验,但预计平均返还仍要落在允许范围内。",
constraint:
"V3 的新手充值门槛固定为 0 / 0外部接入当前也始终按新手阶段处理。每个阶段都必须保留启用的 0 倍档。",
},
stage_normal: {
summary: "普通阶段使用的一组基础奖档。V2 看累计消费折算次数V3 必须同时达到这里的 7 日和 30 日最低充值。",
adjust: "V3 中提高充值门槛会减少进入普通阶段的人数降低则会扩大范围V2 由上方等价抽数上限控制阶段。",
constraint: "V3 中至少一个充值门槛必须大于 0并且不能高于高阶阶段对应门槛。",
},
stage_advanced: {
summary:
"高阶阶段使用的一组基础奖档。V2 超过普通折算次数上限后进入V3 会选择同时满足 7 日和 30 日门槛的最高阶段。",
adjust: "V3 中提高充值门槛会缩小高阶范围降低则会扩大V2 由上方等价抽数上限控制阶段。",
constraint: "V3 中两项门槛都不能低于普通阶段,且至少一项必须更高。",
},
min_recharge_7d_coins: {
summary: "用户最近 7 个 UTC 自然日(含当天)的充值金币至少达到多少,才满足当前阶段的一半条件。",
adjust: "调高会减少进入该阶段的人数;调低会扩大范围。",
constraint: "用户还必须同时达到 30 日门槛;新手阶段固定为 0。外部接入没有可信充值快照时按新手处理。",
},
min_recharge_30d_coins: {
summary: "用户最近 30 个 UTC 自然日(含当天)的充值金币至少达到多少,才满足当前阶段的另一半条件。",
adjust: "调高会更看重长期充值;调低会让近期活跃用户更容易进入该阶段。",
constraint: "用户还必须同时达到 7 日门槛;新手阶段固定为 0。",
},
tier_multiplier: {
summary: "这一档返还本抽消费金额的多少倍。0 倍表示没有返还1 倍表示返还与本抽消费相同。",
adjust: "调高会增加这一档的奖励金额;系统会自动重算各档基础概率来尽量保持平均返还目标。",
constraint: "不能小于 010 倍及以上必须勾选“高倍率安全标记”,实际支付仍不能超过奖池和返奖上限。",
},
tier_probability: {
summary: "表示在当前用户阶段中抽到这一档的基础机会,所有启用奖档必须合计 100%。",
adjust: "该值由平均返还目标和各档倍数自动计算,只读展示;修改倍数或目标后会重新计算。",
constraint: "V3 会按水位和可信充值调整普通非 0 档并受六项上限限制V2 保持基础概率,只过滤奖池付不起的档位。",
},
tier_high_water_only: {
summary: "这是发布高倍率基础奖档前的安全确认标记;当前运行逻辑不会根据它判断奖池处于哪种水位。",
adjust: "勾选或取消不会直接放行、拦截奖档;实际是否能发仍看实时奖池余额和返奖上限。",
constraint:
"10 倍及以上必须勾选才能发布。不要把它当成独立的中奖开关V3 的高低余额变化由上方两组中奖权重控制。",
},
tier_enabled: {
summary: "决定这一档是否参加当前阶段的普通基础抽奖。关闭后不会从普通奖档中选中。",
adjust: "关闭高倍率档会降低大额结果种类;重新开启后系统会重新计算整组概率。",
constraint:
"每个阶段启用档位的概率必须合计 100%,并至少保留一个 0 倍档;若同倍率另列在大奖倍率中,大奖机制仍可能选中它。",
},
};
export function LuckyGiftFieldLabel({ field, label }) {
const help = FIELD_HELP[field];
if (!help) return label;
return (
<Tooltip
arrow
describeChild
enterDelay={120}
leaveDelay={80}
placement="top-start"
slotProps={{ tooltip: { className: "ops-field-help-tooltip" } }}
title={<FieldHelpContent help={help} label={label} />}
>
<span className="ops-field-label" tabIndex={0}>
<span>{label}</span>
<span aria-hidden="true" className="ops-field-help-trigger">
<HelpOutlineOutlined aria-hidden="true" fontSize="inherit" />
</span>
</span>
</Tooltip>
);
}
function FieldHelpContent({ help, label }) {
return (
<div className="ops-field-help-content" role="note">
<strong>{label}</strong>
<dl>
<div>
<dt>作用</dt>
<dd>{help.summary}</dd>
</div>
<div>
<dt>调整影响</dt>
<dd>{help.adjust}</dd>
</div>
<div>
<dt>注意</dt>
<dd>{help.constraint}</dd>
</div>
</dl>
</div>
);
}

View File

@ -6,6 +6,7 @@ import TextField from "@mui/material/TextField";
import { Button } from "@/shared/ui/Button.jsx";
import { formatDecimal } from "@/shared/utils/rtpProbability.js";
import { formatNumber } from "../format.js";
import { LuckyGiftFieldLabel } from "./LuckyGiftFieldHelp.jsx";
import { OpsStatusBadge } from "./OpsStatusBadge.jsx";
export function LuckyGiftStageDesigner({
@ -58,10 +59,10 @@ export function LuckyGiftStageDesigner({
>
<span className="ops-stage-tab__name">
<strong>{summary.tabLabel}</strong>
<small>{summary.status}</small>
<small>{plainStageStatus(summary.status)}</small>
</span>
<span className="ops-stage-tab__metrics">
概率 {formatDecimal(summary.probability)}% · RTP {formatDecimal(summary.expected)}%
概率 {formatDecimal(summary.probability)}% · 平均返还 {formatDecimal(summary.expected)}%
</span>
</button>
);
@ -77,9 +78,14 @@ export function LuckyGiftStageDesigner({
>
<div className="ops-stage-block__head">
<div className="ops-stage-block__summary">
<h3>{activeSummary?.stageLabel}</h3>
<h3>
<LuckyGiftFieldLabel
field={`stage_${activeStageKey}`}
label={activeSummary?.stageLabel || activeStageKey}
/>
</h3>
<OpsStatusBadge
label={`${activeSummary?.status} · 概率 ${formatDecimal(activeSummary?.probability)}% · 期望 RTP ${formatDecimal(activeSummary?.expected)}%`}
label={`${plainStageStatus(activeSummary?.status)} · 概率 ${formatDecimal(activeSummary?.probability)}% · 预计平均返还 ${formatDecimal(activeSummary?.expected)}%`}
tone={activeSummary?.ok ? "succeeded" : "warning"}
/>
</div>
@ -96,9 +102,11 @@ export function LuckyGiftStageDesigner({
<NoviceRechargeRange normalStageConfig={normalStageConfig} />
) : strategyVersion === "dynamic_v3" ? (
<div className="ops-stage-thresholds" aria-label={`${activeSummary?.stageLabel}充值门槛`}>
<span>充值门槛</span>
<span>进入本阶段所需充值</span>
<TextField
label="7 日最低充值(金币)"
label={
<LuckyGiftFieldLabel field="min_recharge_7d_coins" label="7 日最低充值(金币)" />
}
size="small"
slotProps={{ htmlInput: { min: 0, step: 1 } }}
type="number"
@ -112,7 +120,9 @@ export function LuckyGiftStageDesigner({
}
/>
<TextField
label="30 日最低充值(金币)"
label={
<LuckyGiftFieldLabel field="min_recharge_30d_coins" label="30 日最低充值(金币)" />
}
size="small"
slotProps={{ htmlInput: { min: 0, step: 1 } }}
type="number"
@ -130,10 +140,18 @@ export function LuckyGiftStageDesigner({
<div className="ops-tier-table" role="table" aria-label={`${activeSummary?.stageLabel}奖档`}>
<div className="ops-tier-head" role="row">
<span role="columnheader">倍率</span>
<span role="columnheader">概率 %</span>
<span role="columnheader">高水位</span>
<span role="columnheader">启用</span>
<span role="columnheader">
<LuckyGiftFieldLabel field="tier_multiplier" label="倍率" />
</span>
<span role="columnheader">
<LuckyGiftFieldLabel field="tier_probability" label="基础中奖机会 %" />
</span>
<span role="columnheader">
<LuckyGiftFieldLabel field="tier_high_water_only" label="高倍率安全标记" />
</span>
<span role="columnheader">
<LuckyGiftFieldLabel field="tier_enabled" label="启用" />
</span>
<span role="columnheader">操作</span>
</div>
{activeStageConfig.tiers.map((tier, index) => {
@ -156,7 +174,8 @@ export function LuckyGiftStageDesigner({
value={tier.multiplier}
onChange={(event) =>
onTierChange(activeStageConfig.stage, index, {
// 10
// 10
//
highWaterOnly:
Number(event.target.value) >= 10 || tier.highWaterOnly,
multiplier: event.target.value,
@ -182,7 +201,7 @@ export function LuckyGiftStageDesigner({
checked={Boolean(tier.highWaterOnly)}
disabled={Number(tier.multiplier) >= 10}
size="small"
slotProps={{ input: { "aria-label": `${rowLabel}水位限制` } }}
slotProps={{ input: { "aria-label": `${rowLabel}倍率安全标记` } }}
onChange={(event) =>
onTierChange(activeStageConfig.stage, index, {
highWaterOnly: event.target.checked,
@ -246,3 +265,9 @@ function NoviceRechargeRange({ normalStageConfig }) {
</div>
);
}
function plainStageStatus(status) {
if (status === "RTP 偏离") return "平均返还偏离";
if (status === "概率未闭合") return "概率合计不为 100%";
return status || "待检查";
}

View File

@ -445,6 +445,97 @@ body {
line-height: 1.5;
}
.ops-field-label {
display: inline-flex;
min-width: 0;
align-items: center;
gap: 4px;
border-radius: 3px;
cursor: help;
white-space: nowrap;
}
/* OutlinedInput 会在 notch legend 中复制一次 label保留其宽度计算但隐藏复制品
* 避免同一个说明入口在键盘 Tab 顺序中出现两次 */
.MuiOutlinedInput-notchedOutline legend .ops-field-label {
visibility: hidden;
}
.ops-field-help-trigger {
display: inline-flex;
width: 16px;
height: 16px;
flex: 0 0 16px;
align-items: center;
justify-content: center;
border-radius: 50%;
color: var(--text-tertiary);
font-size: 14px;
transition:
background var(--motion-fast) var(--ease-standard),
color var(--motion-fast) var(--ease-standard);
}
.ops-field-label:hover .ops-field-help-trigger,
.ops-field-label:focus-visible .ops-field-help-trigger {
background: var(--primary-hover);
color: var(--primary);
}
.ops-field-label:focus-visible {
outline: none;
box-shadow: 0 0 0 2px var(--primary-border, #93c5fd);
}
/* Tooltip 通过 portal 渲染,必须用自身 class 承载完整排版;字段内只保留问号,不牺牲表单密度。 */
.MuiTooltip-tooltip.ops-field-help-tooltip {
max-width: min(420px, calc(100vw - 32px));
border: 1px solid rgb(148 163 184 / 32%);
border-radius: var(--radius-control);
background: #0f172a;
box-shadow: 0 16px 40px rgb(15 23 42 / 28%);
color: #f8fafc;
padding: var(--space-3) var(--space-4);
}
.ops-field-help-content {
display: grid;
gap: var(--space-2);
}
.ops-field-help-content > strong {
color: #ffffff;
font-size: 13px;
line-height: 1.4;
}
.ops-field-help-content dl {
display: grid;
gap: 7px;
margin: 0;
}
.ops-field-help-content dl > div {
display: grid;
grid-template-columns: 56px minmax(0, 1fr);
align-items: start;
gap: var(--space-2);
}
.ops-field-help-content dt {
color: #93c5fd;
font-size: 12px;
font-weight: 800;
line-height: 1.55;
}
.ops-field-help-content dd {
margin: 0;
color: #e2e8f0;
font-size: 12px;
line-height: 1.55;
}
.ops-stage-designer {
display: grid;
min-width: 0;
@ -620,8 +711,8 @@ body {
min-width: 590px;
grid-template-columns:
minmax(100px, 1fr)
minmax(110px, 1fr)
76px
minmax(124px, 1fr)
96px
64px
56px;
align-items: center;
@ -642,6 +733,14 @@ body {
text-align: center;
}
/* 奖档表头允许自然换行,说明图标不会在窄弹窗中挤到相邻列。 */
.ops-tier-head .ops-field-label {
justify-content: center;
line-height: 1.2;
text-align: center;
white-space: normal;
}
.ops-tier-row {
border-bottom: 1px solid var(--row-border);
padding: 6px var(--space-3);