merge: latest test lucky gift thresholds

# Conflicts:
#	ops-center/src/components/LuckyGiftConfigSections.jsx
#	ops-center/src/components/LuckyGiftStageDesigner.jsx
This commit is contained in:
zhx 2026-07-15 18:37:58 +08:00
commit d57a8bdbd9
8 changed files with 171 additions and 62 deletions

View File

@ -29,6 +29,7 @@ export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit",
: form.stages[0]?.stage;
const activeStageConfig = form.stages.find((stage) => stage.stage === activeStageKey);
const activeSummary = stageSummaries.find((summary) => summary.stageKey === activeStageKey);
const normalStageConfig = form.stages.find((stage) => stage.stage === "normal");
useEffect(() => {
if (!validationErrors.length) return;
@ -152,7 +153,7 @@ export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit",
<ConfigMetric label="应用" value={form.app_code || "-"} />
<ConfigMetric label="奖池" value={form.pool_id || "default"} />
<ConfigMetric label="开奖策略" value={form.strategy_version} />
<ConfigMetric label="平均返还目标" value={formatPercent(Number(form.target_rtp_percent))} />
<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>
@ -193,6 +197,7 @@ export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit",
activeStageConfig={activeStageConfig}
activeStageKey={activeStageKey}
activeSummary={activeSummary}
normalStageConfig={normalStageConfig}
stageSummaries={stageSummaries}
strategyVersion={form.strategy_version}
onAddTier={addStageTier}

View File

@ -24,6 +24,7 @@ test("keeps every dynamic_v3 control reachable while locking rule-time pool inje
"充值加权窗口 (ms)",
"大奖倍率",
"全局大奖 RTP 上限 (%)",
"用户日累计消费触发门槛(金币)",
"单次返奖上限",
"主播每日上限",
].forEach((label) =>
@ -35,6 +36,14 @@ test("keeps every dynamic_v3 control reachable while locking rule-time pool inje
expect(within(dialog).getByLabelText("新手阶段第 1 档倍率")).toBeInTheDocument();
expect(within(dialog).getByLabelText("新手阶段第 2 档概率 %")).toHaveAttribute("readonly");
expect(within(dialog).getByRole("button", { name: "删除新手阶段第 1 档" })).toBeInTheDocument();
expect(within(dialog).getByLabelText("新手阶段派生充值范围")).toHaveTextContent("7 日充值 < 0 或 30 日充值 < 1");
expect(
within(dialog).getByText("任一未达到即为新手7 日与 30 日均达到门槛(含等于)后进入普通。"),
).toBeInTheDocument();
fireEvent.click(within(dialog).getByRole("tab", { name: /普通/ }));
expect(within(dialog).getByLabelText("7 日最低充值(金币)")).toHaveValue(0);
expect(within(dialog).getByLabelText("30 日最低充值(金币)")).toHaveValue(1);
});
test("section navigation handles reduced motion and falls back after switching from dynamic_v3 to fixed_v2", async () => {

View File

@ -217,7 +217,7 @@ export function LuckyGiftConfigSections({ appOptions, form, isCreate, onChange }
<div className="ops-form-grid">
<NumberField
form={form}
label="目标 RTP (%)(基础奖档目标)"
label="目标 RTP (%)"
name="target_rtp_percent"
step="0.01"
onChange={onChange}
@ -278,7 +278,7 @@ export function LuckyGiftConfigSections({ appOptions, form, isCreate, onChange }
/>
</div>
<p className="ops-field-note">
收礼对象收益必须与该奖池实际送礼类型在各适用地区的返币比例一致否则发布校验失败
发布前请核对收礼对象收益与实际送礼返币比例不一致时V3 真实开奖会拒绝账务结算
</p>
</section>
@ -296,7 +296,7 @@ export function LuckyGiftConfigSections({ appOptions, form, isCreate, onChange }
disabled
form={form}
helperText="请通过奖池水位操作注资"
label="初始奖池金币(固定为 0"
label="初始奖池金币"
name="initial_pool_coins"
onChange={onChange}
/>
@ -308,7 +308,7 @@ export function LuckyGiftConfigSections({ appOptions, form, isCreate, onChange }
/>
<NumberField
form={form}
label="低水位金币(低余额分界线)"
label="低水位金币"
name="low_watermark_coins"
onChange={onChange}
/>
@ -321,7 +321,7 @@ export function LuckyGiftConfigSections({ appOptions, form, isCreate, onChange }
/>
<NumberField
form={form}
label="高水位金币(高余额分界线)"
label="高水位金币"
name="high_watermark_coins"
onChange={onChange}
/>
@ -334,7 +334,7 @@ export function LuckyGiftConfigSections({ appOptions, form, isCreate, onChange }
/>
<NumberField
form={form}
label="充值加权窗口 (ms)(充值后加成时长)"
label="充值加权窗口 (ms)"
name="recharge_boost_window_ms"
onChange={onChange}
/>
@ -355,6 +355,7 @@ export function LuckyGiftConfigSections({ appOptions, form, isCreate, onChange }
id="ops-config-section-jackpot"
>
<SectionHeading id="ops-config-heading-jackpot" title="大奖触发条件" />
<JackpotFlowSummary />
<div className="ops-form-grid">
<TextField
className="ops-form-grid__wide"
@ -369,7 +370,7 @@ export function LuckyGiftConfigSections({ appOptions, form, isCreate, onChange }
/>
<NumberField
form={form}
label="全局大奖 RTP 上限 (%)(平均返还触发线)"
label="全局大奖 RTP 上限 (%)"
name="jackpot_global_rtp_max_percent"
step="0.01"
onChange={onChange}
@ -396,7 +397,8 @@ export function LuckyGiftConfigSections({ appOptions, form, isCreate, onChange }
/>
<NumberField
form={form}
label="每次大奖资格所需消费"
helperText="按应用奖池规则配置"
label="用户日累计消费触发门槛(金币)"
name="jackpot_spend_threshold_coins"
onChange={onChange}
/>
@ -413,7 +415,7 @@ export function LuckyGiftConfigSections({ appOptions, form, isCreate, onChange }
<div className="ops-form-grid">
<NumberField
form={form}
label="单次返奖上限(每次最高返奖)"
label="单次返奖上限"
name="max_single_payout"
onChange={onChange}
/>
@ -443,7 +445,7 @@ export function LuckyGiftConfigSections({ appOptions, form, isCreate, onChange }
/>
<NumberField
form={form}
label="主播每日上限(每位主播每日最高返奖)"
label="主播每日上限"
name="anchor_daily_payout_cap"
onChange={onChange}
/>
@ -467,6 +469,25 @@ 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

View File

@ -42,14 +42,16 @@ const FIELD_HELP = {
constraint: "不能低于平均返还目标V3 中它与平台、收礼份额合计必须等于 100%,按整数金币向下取整。",
},
profit_rate_percent: {
summary: "每笔消费中直接计为平台收入的比例,不进入奖池,也不发给收礼对象。",
summary: "V3 每笔消费中直接计为平台收入的比例,不进入奖池,也不发给收礼对象。",
adjust: "调高会增加平台份额,但必须相应减少奖池或收礼份额。",
constraint: "不能为负数V3 的三项资金比例必须精确合计 100%,整数拆分后不能整除的余数也计入平台。",
constraint:
"不能为负数V3 三项资金比例必须精确合计 100%整数拆分后不能整除的余数也计入平台V2 不使用该比例开奖。",
},
anchor_rate_percent: {
summary: "每笔消费中返给本次收礼对象的比例,通常是主播但不一定等于房主;这部分不是用户中奖奖励。",
summary: "V3 每笔消费中返给本次收礼对象的比例,通常是主播但不一定等于房主;这部分不是用户中奖奖励。",
adjust: "调高会增加收礼对象所得,并相应减少奖池或平台份额。",
constraint: "按整数金币向下取整,且必须与钱包实际发给收礼对象的金币完全一致,否则 V3 会拒绝开奖。",
constraint:
"按整数金币向下取整,且必须与钱包实际发给收礼对象的金币完全一致,否则 V3 会拒绝开奖V2 不使用该比例开奖。",
},
settlement_window_wager: {
summary: "V3 按实际消费金币累计一轮返还观察窗口;某一抽跨过门槛时仍归旧窗口,下一抽才进入新窗口。",
@ -139,11 +141,10 @@ const FIELD_HELP = {
constraint: "必须大于 0达到次数后仍可参与普通奖档尚未使用的消费资格会保留。",
},
jackpot_spend_threshold_coins: {
summary:
"填写该 App 中约等于 50 美元的金币数。用户在当前奖池的同一 UTC 日内,每消费满一份门槛就保存一个后续大奖资格。",
adjust: "调高会要求消费更多才获得资格;调低会更频繁地产生资格。",
summary: "用户在当前奖池的同一 UTC 日内,每累计消费满一份门槛,就保存一个供后续抽奖使用的大奖资格。",
adjust: "按各 App 奖池确认的金币口径填写;调高会要求消费更多才获得资格,调低会更频繁地产生资格。",
constraint:
"启用前必须大于 0系统不会猜币价;一笔消费可产生多个资格,资格从下一抽开始用,未能支付时不会丢失。",
"启用前必须大于 0系统不会根据币价或充值记录自行猜额度;一笔消费可产生多个资格,资格从下一抽开始用,未能支付时不会丢失。",
},
max_single_payout: {
summary: "限制一抽最多能返多少金币;超过这个金额的奖档当次不会发出。",
@ -187,14 +188,14 @@ const FIELD_HELP = {
},
stage_normal: {
summary: "普通阶段使用的一组基础奖档。V2 看累计消费折算次数V3 必须同时达到这里的 7 日和 30 日最低充值。",
adjust: "提高门槛会减少进入普通阶段的人数;降低门槛会让更多用户使用这组奖档。",
constraint: "至少一个充值门槛必须大于 0并且不能高于高阶阶段对应门槛。",
adjust: "V3 中提高充值门槛会减少进入普通阶段的人数降低则会扩大范围V2 由上方等价抽数上限控制阶段。",
constraint: "V3 中至少一个充值门槛必须大于 0并且不能高于高阶阶段对应门槛。",
},
stage_advanced: {
summary:
"高阶阶段使用的一组基础奖档。V2 超过普通折算次数上限后进入V3 会选择同时满足 7 日和 30 日门槛的最高阶段。",
adjust: "提高门槛会缩小高阶用户范围;降低门槛会扩大范围。",
constraint: "两项门槛都不能低于普通阶段,且至少一项必须更高。",
adjust: "V3 中提高充值门槛会缩小高阶范围降低则会扩大V2 由上方等价抽数上限控制阶段。",
constraint: "V3 中两项门槛都不能低于普通阶段,且至少一项必须更高。",
},
min_recharge_7d_coins: {
summary: "用户最近 7 个 UTC 自然日(含当天)的充值金币至少达到多少,才满足当前阶段的一半条件。",
@ -214,7 +215,7 @@ const FIELD_HELP = {
tier_probability: {
summary: "表示在当前用户阶段中抽到这一档的基础机会,所有启用奖档必须合计 100%。",
adjust: "该值由平均返还目标和各档倍数自动计算,只读展示;修改倍数或目标后会重新计算。",
constraint: "水位、充值加成、奖池是否付得起和返奖上限会在开奖时继续调整或过滤候选。",
constraint: "V3 会按水位和可信充值调整普通非 0 档并受六项上限限制V2 保持基础概率,只过滤奖池付不起的档位。",
},
tier_high_water_only: {
summary: "这是发布高倍率基础奖档前的安全确认标记;当前运行逻辑不会根据它判断奖池处于哪种水位。",

View File

@ -5,6 +5,7 @@ import IconButton from "@mui/material/IconButton";
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";
@ -12,6 +13,7 @@ export function LuckyGiftStageDesigner({
activeStageConfig,
activeStageKey,
activeSummary,
normalStageConfig,
onAddTier,
onRemoveTier,
onStageChange,
@ -96,12 +98,15 @@ export function LuckyGiftStageDesigner({
</Button>
</div>
{strategyVersion === "dynamic_v3" ? (
{strategyVersion === "dynamic_v3" && activeStageConfig.stage === "novice" ? (
<NoviceRechargeRange normalStageConfig={normalStageConfig} />
) : strategyVersion === "dynamic_v3" ? (
<div className="ops-stage-thresholds" aria-label={`${activeSummary?.stageLabel}充值门槛`}>
<span>进入本阶段所需充值</span>
<TextField
disabled={activeStageConfig.stage === "novice"}
label={<LuckyGiftFieldLabel field="min_recharge_7d_coins" label="近 7 日最低充值" />}
label={
<LuckyGiftFieldLabel field="min_recharge_7d_coins" label="7 日最低充值(金币)" />
}
size="small"
slotProps={{ htmlInput: { min: 0, step: 1 } }}
type="number"
@ -115,8 +120,9 @@ export function LuckyGiftStageDesigner({
}
/>
<TextField
disabled={activeStageConfig.stage === "novice"}
label={<LuckyGiftFieldLabel field="min_recharge_30d_coins" label="近 30 日最低充值" />}
label={
<LuckyGiftFieldLabel field="min_recharge_30d_coins" label="30 日最低充值(金币)" />
}
size="small"
slotProps={{ htmlInput: { min: 0, step: 1 } }}
type="number"
@ -237,6 +243,29 @@ export function LuckyGiftStageDesigner({
);
}
function NoviceRechargeRange({ normalStageConfig }) {
const normal7D = Number(normalStageConfig?.min_recharge_7d_coins || 0);
const normal30D = Number(normalStageConfig?.min_recharge_30d_coins || 0);
// normal AND 0 0
const configured = Number.isFinite(normal7D) && Number.isFinite(normal30D) && (normal7D > 0 || normal30D > 0);
return (
<div className="ops-stage-thresholds ops-stage-thresholds--derived" aria-label="新手阶段派生充值范围">
<span>派生范围</span>
<div className="ops-stage-threshold-rule">
<strong>
7 日充值 &lt; {formatNumber(normal7D)} 30 日充值 &lt; {formatNumber(normal30D)}
</strong>
{/* 普通阶段需要两个下限同时成立;因此任一维未达到都应留在新手,等于下限则进入普通。 */}
<small>
{configured ? "" : "普通阶段门槛待配置;"}任一未达到即为新手7 日与 30
日均达到门槛含等于后进入普通
</small>
</div>
</div>
);
}
function plainStageStatus(status) {
if (status === "RTP 偏离") return "平均返还偏离";
if (status === "概率未闭合") return "概率合计不为 100%";

View File

@ -12,7 +12,7 @@ import { DEFAULT_POOL_ID } from "./api.js";
const DYNAMIC_STRATEGY = "dynamic_v3";
const PPM_SCALE = 1_000_000;
// 只有跨 App 通用的算法参数才有默认值;50 美元等值门槛和六维金额上限必须由运营按 App 金币口径填写,
// 只有跨 App 通用的算法参数才有默认值;用户日累计消费触发门槛和六维金额上限必须由运营按 App 奖池的金币口径填写,
// 因而默认保持 0 且规则停用,避免把某个 App 的预算误发到其他 App。
const dynamicDefaults = {
anchor_daily_payout_cap: 0,
@ -131,18 +131,23 @@ export function formToConfig(config, form) {
enabled: Boolean(form.enabled),
jackpot_multipliers: multiplierListFromForm(form.jackpot_multipliers),
pool_id: form.pool_id,
stages: (form.stages || []).map((stage) => ({
min_recharge_30d_coins: numberFromForm(stage.min_recharge_30d_coins),
min_recharge_7d_coins: numberFromForm(stage.min_recharge_7d_coins),
stage: stage.stage,
tiers: (stage.tiers || []).map((tier) => ({
enabled: tier.enabled !== false,
high_water_only: Boolean(tier.highWaterOnly),
multiplier: numberFromForm(tier.multiplier),
probability_percent: numberFromForm(tier.probabilityPercent),
tier_id: tier.tierId || "",
})),
})),
stages: (form.stages || []).map((stage) => {
// dynamic_v3 的新手范围由普通阶段下限反向派生0/0 只是旧 DTO 要求的兜底哨兵值,
// 不是可运营的“充值为 0 才是新手”规则。序列化时强制归零,避免历史异常值改变分层。
const isDynamicNovice = form.strategy_version === DYNAMIC_STRATEGY && stage.stage === "novice";
return {
min_recharge_30d_coins: isDynamicNovice ? 0 : numberFromForm(stage.min_recharge_30d_coins),
min_recharge_7d_coins: isDynamicNovice ? 0 : numberFromForm(stage.min_recharge_7d_coins),
stage: stage.stage,
tiers: (stage.tiers || []).map((tier) => ({
enabled: tier.enabled !== false,
high_water_only: Boolean(tier.highWaterOnly),
multiplier: numberFromForm(tier.multiplier),
probability_percent: numberFromForm(tier.probabilityPercent),
tier_id: tier.tierId || "",
})),
};
}),
strategy_version: form.strategy_version,
};
for (const field of numericFields) {
@ -316,9 +321,9 @@ export function validateLuckyGiftForm(form) {
for (const [field, label] of payoutCapFields) {
if (number(field) < 0) errors.push(`${label}不能小于 0`);
}
validateRechargeStages(form.stages || [], errors);
validateRechargeStages(form.stages || [], errors, form.enabled);
if (form.enabled) {
if (number("jackpot_spend_threshold_coins") <= 0) errors.push("启用前必须填写 50 美元等值的大奖消费门槛");
if (number("jackpot_spend_threshold_coins") <= 0) errors.push("启用前必须填写用户日累计消费触发门槛(金币)");
for (const [field, label] of payoutCapFields) {
if (number(field) <= 0) errors.push(`启用前必须填写${label}`);
}
@ -345,11 +350,15 @@ function normalizeFormStages(stages, targetRTPPercent, strategyVersion) {
: correctedDefaultTiers(stageKey, targetRTPPercent);
const [default7d, default30d] =
strategyVersion === DYNAMIC_STRATEGY ? defaultRechargeThreshold(stageKey) : [0, 0];
const isDynamicNovice = strategyVersion === DYNAMIC_STRATEGY && stageKey === "novice";
return {
min_recharge_7d_coins: formNumber(source?.min_recharge_7d_coins ?? source?.minRecharge7DCoins ?? default7d),
min_recharge_30d_coins: formNumber(
source?.min_recharge_30d_coins ?? source?.minRecharge30DCoins ?? default30d,
),
// 只在表单内保留兼容哨兵值;界面上的新手上界始终从普通门槛派生,不读取历史 novice 门槛。
min_recharge_7d_coins: isDynamicNovice
? "0"
: formNumber(source?.min_recharge_7d_coins ?? source?.minRecharge7DCoins ?? default7d),
min_recharge_30d_coins: isDynamicNovice
? "0"
: formNumber(source?.min_recharge_30d_coins ?? source?.minRecharge30DCoins ?? default30d),
stage: stageKey,
tiers: sourceTiers.map((tier) => ({
enabled: tier.enabled !== false,
@ -382,13 +391,10 @@ function defaultTier(tierID, multiplier, probabilityPercent) {
return { enabled: true, highWaterOnly: false, multiplier, probabilityPercent, tierId: tierID };
}
function validateRechargeStages(stages, errors) {
const novice = stages.find((stage) => stage.stage === "novice");
function validateRechargeStages(stages, errors, requireComplete) {
const normal = stages.find((stage) => stage.stage === "normal");
const advanced = stages.find((stage) => stage.stage === "advanced");
const threshold = (stage, field) => numberFromForm(stage?.[field]);
if (threshold(novice, "min_recharge_7d_coins") !== 0 || threshold(novice, "min_recharge_30d_coins") !== 0)
errors.push("新手阶段充值门槛必须为 0 / 0");
const normal7d = threshold(normal, "min_recharge_7d_coins");
const normal30d = threshold(normal, "min_recharge_30d_coins");
const advanced7d = threshold(advanced, "min_recharge_7d_coins");
@ -397,15 +403,21 @@ function validateRechargeStages(stages, errors) {
errors.push("充值阶段门槛不能小于 0");
return;
}
if (normal7d === 0 && normal30d === 0) errors.push("正常阶段至少一个充值门槛必须大于 0");
const isUnconfigured = [normal7d, normal30d, advanced7d, advanced30d].every((value) => value === 0);
// 新建的停用草稿可以全 0避免猜测 App 金币价值;一旦开始填写或准备启用,
// 就必须遵守 owner 的二维下限契约normal 至少一维大于 0advanced 逐维不低且至少一维更高。
if (!requireComplete && isUnconfigured) return;
if (normal7d === 0 && normal30d === 0)
errors.push(
requireComplete ? "启用前普通阶段至少一个最低充值门槛必须大于 0" : "普通阶段至少一个充值门槛必须大于 0",
);
if (advanced7d < normal7d || advanced30d < normal30d || (advanced7d === normal7d && advanced30d === normal30d))
errors.push("高阶充值门槛必须逐维不低于正常阶段且至少一项更高");
}
function defaultRechargeThreshold(stage) {
if (stage === "novice") return [0, 0];
if (stage === "normal") return [0, 1];
return [1, 1];
function defaultRechargeThreshold() {
// 充值门槛受 App 金币比例影响,新草稿不猜测 1 金币等于可用配置;运营填完后才能启用。
return [0, 0];
}
function defaultNumber(field, strategyVersion) {

View File

@ -18,8 +18,8 @@ describe("ops center lucky gift dynamic_v3 form", () => {
});
expect(form.stages.map((stage) => [stage.min_recharge_7d_coins, stage.min_recharge_30d_coins])).toEqual([
["0", "0"],
["0", "1"],
["1", "1"],
["0", "0"],
["0", "0"],
]);
expect(form.stages.map((stage) => stage.tiers.map((tier) => Number(tier.probabilityPercent)))).toEqual([
[5, 4, 86, 5],
@ -52,10 +52,15 @@ describe("ops center lucky gift dynamic_v3 form", () => {
test("drops legacy dynamic initial seed when loading and publishing a new version", () => {
const legacy = completeDynamicConfig();
legacy.initial_pool_coins = 1_000_000;
legacy.stages[0].min_recharge_7d_coins = 12_345;
legacy.stages[0].min_recharge_30d_coins = 67_890;
const form = configToForm(legacy);
expect(form.initial_pool_coins).toBe("0");
expect(formToConfig(legacy, form).initial_pool_coins).toBe(0);
expect(form.stages[0]).toMatchObject({ min_recharge_7d_coins: "0", min_recharge_30d_coins: "0" });
const output = formToConfig(legacy, form);
expect(output.initial_pool_coins).toBe(0);
expect(output.stages[0]).toMatchObject({ min_recharge_7d_coins: 0, min_recharge_30d_coins: 0 });
});
test("allows disabled drafts but blocks enabled dynamic rules until app monetary limits are filled", () => {
@ -65,7 +70,9 @@ describe("ops center lucky gift dynamic_v3 form", () => {
const enabled = { ...draft, enabled: true };
const errors = validateLuckyGiftForm(enabled);
expect(errors).toContain("启用前必须填写 50 美元等值的大奖消费门槛");
expect(errors).toContain("启用前必须填写用户日累计消费触发门槛(金币)");
expect(errors).toContain("启用前普通阶段至少一个最低充值门槛必须大于 0");
expect(errors).toContain("高阶充值门槛必须逐维不低于正常阶段且至少一项更高");
expect(errors).toContain("启用前必须填写单次返奖上限");
expect(errors).toContain("启用前必须填写主播每日上限");
});

View File

@ -674,6 +674,27 @@ body {
font-weight: 800;
}
.ops-stage-threshold-rule {
display: grid;
grid-column: 2 / -1;
gap: 2px;
min-width: 0;
border-left: 3px solid var(--primary);
background: var(--bg-card-strong, #f8fafc);
padding: var(--space-2) var(--space-3);
}
.ops-stage-threshold-rule strong {
color: var(--text-primary);
font-size: 13px;
}
.ops-stage-threshold-rule small {
color: var(--text-secondary);
font-size: 12px;
line-height: 1.5;
}
.ops-tier-table {
display: grid;
min-width: 0;
@ -911,6 +932,10 @@ body {
grid-column: 1 / -1;
}
.ops-stage-threshold-rule {
grid-column: 1 / -1;
}
/* 小屏保留完整列头并让表格横向滚动,控件同时有阶段+行号标签,避免一列卡片失去字段关系。 */
.ops-tier-head,
.ops-tier-row {