diff --git a/ops-center/src/components/LuckyGiftConfigDialog.jsx b/ops-center/src/components/LuckyGiftConfigDialog.jsx index bc7fd61..0cf4a16 100644 --- a/ops-center/src/components/LuckyGiftConfigDialog.jsx +++ b/ops-center/src/components/LuckyGiftConfigDialog.jsx @@ -88,7 +88,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", @@ -150,8 +151,8 @@ export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit",
- - + + - +
diff --git a/ops-center/src/components/LuckyGiftConfigSections.jsx b/ops-center/src/components/LuckyGiftConfigSections.jsx index 19c7a0e..97a78b1 100644 --- a/ops-center/src/components/LuckyGiftConfigSections.jsx +++ b/ops-center/src/components/LuckyGiftConfigSections.jsx @@ -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 } } select size="small" value={form.app_code} @@ -150,19 +151,25 @@ export function LuckyGiftConfigSections({ appOptions, form, isCreate, onChange } ))} ) : ( - + } + size="small" + value={form.app_code} + /> )} } size="small" value={form.pool_id} onChange={(event) => onChange("pool_id", event.target.value)} /> } select size="small" value={form.strategy_version} @@ -180,7 +187,12 @@ export function LuckyGiftConfigSections({ appOptions, form, isCreate, onChange } onChange={(event) => onChange("enabled", event.target.checked)} /> } - label={form.enabled ? "发布后立即启用" : "发布后保持停用"} + label={ + + } />

修改奖池 ID 会发布到新奖池,不影响原奖池。

@@ -200,66 +212,73 @@ export function LuckyGiftConfigSections({ appOptions, form, isCreate, onChange } } id="ops-config-heading-funds" ok={Math.abs(fundTotal - 100) < 0.0001} - title="资金与 RTP" + title="返奖与资金分配" />
- +

- 主播收益必须与该奖池实际送礼类型在各适用地区的返币比例一致,否则发布校验失败。 + 收礼对象收益必须与该奖池实际送礼类型在各适用地区的返币比例一致,否则发布校验失败。

@@ -277,51 +296,51 @@ export function LuckyGiftConfigSections({ appOptions, form, isCreate, onChange } disabled form={form} helperText="请通过奖池水位操作注资" - label="初始奖池金币" + label="初始奖池金币(固定为 0)" name="initial_pool_coins" onChange={onChange} /> - +
+ } required size="small" value={form.jackpot_multipliers} @@ -348,34 +369,34 @@ export function LuckyGiftConfigSections({ appOptions, form, isCreate, onChange } /> @@ -388,41 +409,41 @@ export function LuckyGiftConfigSections({ appOptions, form, isCreate, onChange } data-config-section="limits" id="ops-config-section-limits" > - +
@@ -451,7 +472,7 @@ function NumberField({ disabled = false, form, helperText, label, name, onChange } required size="small" slotProps={{ htmlInput: { inputMode: "decimal", min: 0, step } }} diff --git a/ops-center/src/components/LuckyGiftFieldHelp.jsx b/ops-center/src/components/LuckyGiftFieldHelp.jsx new file mode 100644 index 0000000..30c9e95 --- /dev/null +++ b/ops-center/src/components/LuckyGiftFieldHelp.jsx @@ -0,0 +1,276 @@ +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: "每笔消费中直接计为平台收入的比例,不进入奖池,也不发给收礼对象。", + adjust: "调高会增加平台份额,但必须相应减少奖池或收礼份额。", + constraint: "不能为负数;V3 的三项资金比例必须精确合计 100%,整数拆分后不能整除的余数也计入平台。", + }, + anchor_rate_percent: { + summary: "每笔消费中返给本次收礼对象的比例,通常是主播,但不一定等于房主;这部分不是用户中奖奖励。", + adjust: "调高会增加收礼对象所得,并相应减少奖池或平台份额。", + constraint: "按整数金币向下取整,且必须与钱包实际发给收礼对象的金币完全一致,否则 V3 会拒绝开奖。", + }, + 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: + "填写该 App 中约等于 50 美元的金币数。用户在当前奖池的同一 UTC 日内,每消费满一份门槛就保存一个后续大奖资格。", + adjust: "调高会要求消费更多才获得资格;调低会更频繁地产生资格。", + 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: "提高门槛会减少进入普通阶段的人数;降低门槛会让更多用户使用这组奖档。", + constraint: "至少一个充值门槛必须大于 0,并且不能高于高阶阶段对应门槛。", + }, + stage_advanced: { + summary: + "高阶阶段使用的一组基础奖档。V2 超过普通折算次数上限后进入;V3 会选择同时满足 7 日和 30 日门槛的最高阶段。", + adjust: "提高门槛会缩小高阶用户范围;降低门槛会扩大范围。", + constraint: "两项门槛都不能低于普通阶段,且至少一项必须更高。", + }, + 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: "不能小于 0;10 倍及以上必须勾选“高倍率安全标记”,实际支付仍不能超过奖池和返奖上限。", + }, + tier_probability: { + summary: "表示在当前用户阶段中抽到这一档的基础机会,所有启用奖档必须合计 100%。", + adjust: "该值由平均返还目标和各档倍数自动计算,只读展示;修改倍数或目标后会重新计算。", + constraint: "水位、充值加成、奖池是否付得起和返奖上限会在开奖时继续调整或过滤候选。", + }, + 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 ( + } + > + + {label} + + + + ); +} + +function FieldHelpContent({ help, label }) { + return ( +
+ {label} +
+
+
作用
+
{help.summary}
+
+
+
调整影响
+
{help.adjust}
+
+
+
注意
+
{help.constraint}
+
+
+
+ ); +} diff --git a/ops-center/src/components/LuckyGiftStageDesigner.jsx b/ops-center/src/components/LuckyGiftStageDesigner.jsx index 7fb5d49..6e296c8 100644 --- a/ops-center/src/components/LuckyGiftStageDesigner.jsx +++ b/ops-center/src/components/LuckyGiftStageDesigner.jsx @@ -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 { LuckyGiftFieldLabel } from "./LuckyGiftFieldHelp.jsx"; import { OpsStatusBadge } from "./OpsStatusBadge.jsx"; export function LuckyGiftStageDesigner({ @@ -56,10 +57,10 @@ export function LuckyGiftStageDesigner({ > {summary.tabLabel} - {summary.status} + {plainStageStatus(summary.status)} - 概率 {formatDecimal(summary.probability)}% · RTP {formatDecimal(summary.expected)}% + 概率 {formatDecimal(summary.probability)}% · 平均返还 {formatDecimal(summary.expected)}% ); @@ -75,9 +76,14 @@ export function LuckyGiftStageDesigner({ >
-

{activeSummary?.stageLabel}

+

+ +

@@ -92,10 +98,10 @@ export function LuckyGiftStageDesigner({ {strategyVersion === "dynamic_v3" ? (
- 充值门槛 + 进入本阶段所需充值 } size="small" slotProps={{ htmlInput: { min: 0, step: 1 } }} type="number" @@ -110,7 +116,7 @@ export function LuckyGiftStageDesigner({ /> } size="small" slotProps={{ htmlInput: { min: 0, step: 1 } }} type="number" @@ -128,10 +134,18 @@ export function LuckyGiftStageDesigner({
- 倍率 - 概率 % - 高水位 - 启用 + + + + + + + + + + + + 操作
{activeStageConfig.tiers.map((tier, index) => { @@ -154,7 +168,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, @@ -180,7 +195,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, @@ -221,3 +236,9 @@ export function LuckyGiftStageDesigner({ ); } + +function plainStageStatus(status) { + if (status === "RTP 偏离") return "平均返还偏离"; + if (status === "概率未闭合") return "概率合计不为 100%"; + return status || "待检查"; +} diff --git a/ops-center/src/styles/index.css b/ops-center/src/styles/index.css index 56a5129..e5bb8fa 100644 --- a/ops-center/src/styles/index.css +++ b/ops-center/src/styles/index.css @@ -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; @@ -599,8 +690,8 @@ body { min-width: 590px; grid-template-columns: minmax(100px, 1fr) - minmax(110px, 1fr) - 76px + minmax(124px, 1fr) + 96px 64px 56px; align-items: center; @@ -621,6 +712,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);