feat(ops-center): densify lucky gift configuration

This commit is contained in:
zhx 2026-07-15 16:59:22 +08:00
parent 460464ee18
commit 8629a52e33
6 changed files with 1539 additions and 478 deletions

View File

@ -309,7 +309,7 @@ test("opens config dialog from a published pool row and saves a new version", as
expect(within(dialog).getByLabelText(/初始奖池金币/).value).toBe("0"); expect(within(dialog).getByLabelText(/初始奖池金币/).value).toBe("0");
expect(within(dialog).getByLabelText(/初始奖池金币/)).toBeDisabled(); expect(within(dialog).getByLabelText(/初始奖池金币/)).toBeDisabled();
expect(within(dialog).getByLabelText(/大奖倍率/).value).toBe("200, 500, 1000"); expect(within(dialog).getByLabelText(/大奖倍率/).value).toBe("200, 500, 1000");
expect(within(dialog).getAllByLabelText("概率 %")[0].readOnly).toBe(true); expect(within(dialog).getByLabelText("新手阶段第 1 档概率 %").readOnly).toBe(true);
fireEvent.change(within(dialog).getByLabelText(/目标 RTP \(%\)/), { target: { value: "88" } }); fireEvent.change(within(dialog).getByLabelText(/目标 RTP \(%\)/), { target: { value: "88" } });
fireEvent.click(within(dialog).getByRole("button", { name: "保存配置" })); fireEvent.click(within(dialog).getByRole("button", { name: "保存配置" }));
@ -346,7 +346,7 @@ test("blocks an invalid enabled dynamic rule before calling the save API", async
expect(saveLuckyGiftConfig).not.toHaveBeenCalled(); expect(saveLuckyGiftConfig).not.toHaveBeenCalled();
// //
fireEvent.change(within(dialog).getAllByLabelText("倍率")[0], { target: { value: "0.1" } }); fireEvent.change(within(dialog).getByLabelText("新手阶段第 1 档倍率"), { target: { value: "0.1" } });
expect(within(dialog).queryByRole("alert")).toBeNull(); expect(within(dialog).queryByRole("alert")).toBeNull();
}); });

View File

@ -1,16 +1,9 @@
import AddOutlined from "@mui/icons-material/AddOutlined";
import Checkbox from "@mui/material/Checkbox";
import Dialog from "@mui/material/Dialog"; import Dialog from "@mui/material/Dialog";
import DialogActions from "@mui/material/DialogActions"; import DialogActions from "@mui/material/DialogActions";
import DialogContent from "@mui/material/DialogContent"; import DialogContent from "@mui/material/DialogContent";
import DialogTitle from "@mui/material/DialogTitle"; import DialogTitle from "@mui/material/DialogTitle";
import FormControlLabel from "@mui/material/FormControlLabel"; import { useEffect, useMemo, useRef, useState } from "react";
import MenuItem from "@mui/material/MenuItem";
import TextField from "@mui/material/TextField";
import { useMemo, useState } from "react";
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
import { Button } from "@/shared/ui/Button.jsx"; import { Button } from "@/shared/ui/Button.jsx";
import { formatDecimal } from "@/shared/utils/rtpProbability.js";
import { import {
applyDynamicStrategyDefaults, applyDynamicStrategyDefaults,
configToForm, configToForm,
@ -21,12 +14,14 @@ import {
validateLuckyGiftForm, validateLuckyGiftForm,
} from "../configForm.js"; } from "../configForm.js";
import { formatNumber, formatPercent } from "../format.js"; import { formatNumber, formatPercent } from "../format.js";
import { OpsStatusBadge } from "./OpsStatusBadge.jsx"; import { LuckyGiftConfigSections } from "./LuckyGiftConfigSections.jsx";
import { LuckyGiftStageDesigner } from "./LuckyGiftStageDesigner.jsx";
export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit", onClose, onSubmit, saving }) { export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit", onClose, onSubmit, saving }) {
const [form, setForm] = useState(() => configToForm(config)); const [form, setForm] = useState(() => configToForm(config));
const [activeStage, setActiveStage] = useState("novice"); const [activeStage, setActiveStage] = useState("novice");
const [validationErrors, setValidationErrors] = useState([]); const [validationErrors, setValidationErrors] = useState([]);
const validationRef = useRef(null);
const stageSummaries = useMemo(() => form.stages.map((stage) => stageSummary(stage, form)), [form]); const stageSummaries = useMemo(() => form.stages.map((stage) => stageSummary(stage, form)), [form]);
const activeStageKey = form.stages.some((stage) => stage.stage === activeStage) const activeStageKey = form.stages.some((stage) => stage.stage === activeStage)
@ -35,6 +30,12 @@ export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit",
const activeStageConfig = form.stages.find((stage) => stage.stage === activeStageKey); const activeStageConfig = form.stages.find((stage) => stage.stage === activeStageKey);
const activeSummary = stageSummaries.find((summary) => summary.stageKey === activeStageKey); const activeSummary = stageSummaries.find((summary) => summary.stageKey === activeStageKey);
useEffect(() => {
if (!validationErrors.length) return;
//
validationRef.current?.focus();
}, [validationErrors]);
const updateField = (field, value) => { const updateField = (field, value) => {
setValidationErrors([]); setValidationErrors([]);
setForm((current) => { setForm((current) => {
@ -129,31 +130,48 @@ export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit",
const title = isCreate ? "添加幸运礼物配置" : config.is_default ? "发布幸运礼物配置" : "新增幸运礼物版本"; const title = isCreate ? "添加幸运礼物配置" : config.is_default ? "发布幸运礼物配置" : "新增幸运礼物版本";
return ( return (
<Dialog fullWidth maxWidth="lg" open onClose={saving ? undefined : onClose}> <Dialog
aria-labelledby="ops-config-dialog-title"
fullWidth
maxWidth={false}
open
scroll="paper"
slotProps={{ paper: { className: "ops-config-dialog-paper" } }}
onClose={saving ? undefined : onClose}
>
<form className="ops-config-form" onSubmit={handleSubmit}> <form className="ops-config-form" onSubmit={handleSubmit}>
<DialogTitle className="ops-config-title"> <DialogTitle className="ops-config-title" id="ops-config-dialog-title">
<span>{title}</span> <span>{title}</span>
<small> <small>
{form.app_code || "-"} / {form.pool_id || "default"} /{" "} {form.app_code || "-"} / {form.pool_id || "default"} /{" "}
{config.is_default || isCreate ? "默认草稿" : `当前版本 v${config.rule_version ?? "-"}`} {config.is_default || isCreate ? "默认草稿" : `当前版本 v${config.rule_version ?? "-"}`}
</small> </small>
</DialogTitle> </DialogTitle>
<DialogContent dividers> <dl className="ops-config-summary" aria-label="配置摘要">
<div className="ops-config-metrics">
<ConfigMetric label="应用" value={form.app_code || "-"} /> <ConfigMetric label="应用" value={form.app_code || "-"} />
<ConfigMetric label="奖池" value={form.pool_id || "default"} /> <ConfigMetric label="奖池" value={form.pool_id || "default"} />
<ConfigMetric label="策略" value={form.strategy_version} /> <ConfigMetric label="策略" value={form.strategy_version} />
<ConfigMetric label="目标 RTP" value={formatPercent(Number(form.target_rtp_percent))} /> <ConfigMetric label="目标 RTP" value={formatPercent(Number(form.target_rtp_percent))} />
<ConfigMetric <ConfigMetric
label="资金拆分" label={form.strategy_version === "dynamic_v3" ? "资金拆分" : "奖池比例"}
value={`${form.pool_rate_percent}% / ${form.profit_rate_percent}% / ${form.anchor_rate_percent}%`} value={
form.strategy_version === "dynamic_v3"
? `${form.pool_rate_percent}% / ${form.profit_rate_percent}% / ${form.anchor_rate_percent}%`
: `${form.pool_rate_percent}%`
}
/> />
<ConfigMetric label="结算窗口" value={formatNumber(Number(form.settlement_window_wager))} /> <ConfigMetric label="结算窗口" value={formatNumber(Number(form.settlement_window_wager))} />
<ConfigMetric label="状态" value={form.enabled ? "启用" : "停用"} /> <ConfigMetric label="发布状态" value={form.enabled ? "启用" : "停用"} />
</div> </dl>
<DialogContent className="ops-config-dialog-content" dividers>
{validationErrors.length ? ( {validationErrors.length ? (
<div className="ops-config-validation" role="alert"> <div
aria-live="assertive"
className="ops-config-validation"
ref={validationRef}
role="alert"
tabIndex={-1}
>
<strong>配置不能发布</strong> <strong>配置不能发布</strong>
<ul> <ul>
{validationErrors.map((message) => ( {validationErrors.map((message) => (
@ -164,268 +182,27 @@ export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit",
) : null} ) : null}
<div className="ops-config-layout"> <div className="ops-config-layout">
<aside className="ops-config-params" aria-label="基础参数"> <LuckyGiftConfigSections
<section className="ops-config-section"> appOptions={appOptions}
<h3>规则身份</h3>
{isCreate ? (
<TextField
fullWidth
required
label="应用"
select
size="small"
value={form.app_code}
onChange={(event) => updateField("app_code", event.target.value)}
>
{appOptions.map(([value, label]) => (
<MenuItem key={value} value={value}>
{label}
</MenuItem>
))}
</TextField>
) : (
<TextField disabled fullWidth label="应用" size="small" value={form.app_code} />
)}
<TextField
fullWidth
required
helperText="修改奖池 ID 会发布到新的奖池,不影响原奖池"
label="奖池"
size="small"
value={form.pool_id}
onChange={(event) => updateField("pool_id", event.target.value)}
/>
<TextField
fullWidth
label="策略版本"
select
size="small"
value={form.strategy_version}
onChange={(event) => updateField("strategy_version", event.target.value)}
>
<MenuItem value="dynamic_v3">dynamic_v3</MenuItem>
<MenuItem value="fixed_v2">fixed_v2历史兼容</MenuItem>
</TextField>
<FormControlLabel
control={
<AdminSwitch
checked={form.enabled}
onChange={(event) => updateField("enabled", event.target.checked)}
/>
}
label={form.enabled ? "发布后立即启用" : "发布后保持停用"}
sx={{ gap: 1, marginLeft: 0 }}
/>
</section>
<section className="ops-config-section">
<h3>RTP 与奖池</h3>
<div className="ops-form-grid">
<NumberField
form={form} form={form}
label="目标 RTP (%)" isCreate={isCreate}
name="target_rtp_percent"
step="0.01"
onChange={updateField} onChange={updateField}
/> />
<NumberField <LuckyGiftStageDesigner
form={form} activeStageConfig={activeStageConfig}
label="公共奖池 (%)" activeStageKey={activeStageKey}
name="pool_rate_percent" activeSummary={activeSummary}
step="0.01" stageSummaries={stageSummaries}
onChange={updateField} strategyVersion={form.strategy_version}
onAddTier={addStageTier}
onRemoveTier={removeStageTier}
onStageChange={setActiveStage}
onStageFieldChange={updateStageField}
onTierChange={updateStageTier}
/> />
<NumberField
form={form}
label="平台盈利 (%)"
name="profit_rate_percent"
step="0.01"
onChange={updateField}
/>
<NumberField
form={form}
helperText="必须与该奖池实际送礼类型lucky / super_lucky在各适用地区的返币比例一致否则开奖账务校验会失败"
label="主播收益 (%)"
name="anchor_rate_percent"
step="0.01"
onChange={updateField}
/>
<NumberField
form={form}
label="控制带宽 (%)"
name="control_band_percent"
step="0.01"
onChange={updateField}
/>
<NumberField
form={form}
label="结算窗口流水"
name="settlement_window_wager"
onChange={updateField}
/>
<NumberField
form={form}
label="礼物参考金额"
name="gift_price_reference"
onChange={updateField}
/>
<NumberField
form={form}
label="新手等价抽数"
name="novice_max_equivalent_draws"
onChange={updateField}
/>
<NumberField
form={form}
label="普通等价抽数"
name="normal_max_equivalent_draws"
onChange={updateField}
/>
</div>
</section>
{form.strategy_version === "dynamic_v3" ? (
<DynamicStrategySections form={form} onChange={updateField} />
) : null}
</aside>
<section className="ops-stage-designer" aria-label="阶段奖档设计">
<div className="ops-stage-tabs" role="tablist" aria-label="阶段奖档">
{stageSummaries.map((summary) => (
<button
aria-selected={summary.stageKey === activeStageKey}
className={`ops-stage-tab ${summary.stageKey === activeStageKey ? "is-active" : ""} ${summary.ok ? "is-ok" : "is-warn"}`}
key={summary.stageKey}
role="tab"
type="button"
onClick={() => setActiveStage(summary.stageKey)}
>
<span>{summary.tabLabel}</span>
</button>
))}
</div>
{activeStageConfig ? (
<div className="ops-stage-stack">
<div className="ops-stage-block__head">
<div>
<h3>{activeSummary?.stageLabel}</h3>
<OpsStatusBadge
label={`${activeSummary?.status} · 概率 ${formatDecimal(activeSummary?.probability)}% · 期望 RTP ${formatDecimal(activeSummary?.expected)}%`}
tone={activeSummary?.ok ? "succeeded" : "warning"}
/>
</div>
<Button
startIcon={<AddOutlined fontSize="small" />}
onClick={() => addStageTier(activeStageConfig.stage)}
>
添加奖档
</Button>
</div>
{form.strategy_version === "dynamic_v3" ? (
<div className="ops-stage-thresholds">
<TextField
disabled={activeStageConfig.stage === "novice"}
label="7 日充值门槛"
size="small"
slotProps={{ htmlInput: { min: 0, step: 1 } }}
type="number"
value={activeStageConfig.min_recharge_7d_coins}
onChange={(event) =>
updateStageField(
activeStageConfig.stage,
"min_recharge_7d_coins",
event.target.value,
)
}
/>
<TextField
disabled={activeStageConfig.stage === "novice"}
label="30 日充值门槛"
size="small"
slotProps={{ htmlInput: { min: 0, step: 1 } }}
type="number"
value={activeStageConfig.min_recharge_30d_coins}
onChange={(event) =>
updateStageField(
activeStageConfig.stage,
"min_recharge_30d_coins",
event.target.value,
)
}
/>
</div>
) : null}
<div className="ops-tier-table">
<div className="ops-tier-head" aria-hidden="true">
<span>倍率</span>
<span>概率 %</span>
<span>高水位</span>
<span>启用</span>
<span>操作</span>
</div>
{activeStageConfig.tiers.map((tier, index) => (
<div className="ops-tier-row" key={`${activeStageConfig.stage}-${index}`}>
<TextField
required
size="small"
slotProps={{
htmlInput: { "aria-label": "倍率", min: 0, step: "0.01" },
}}
type="number"
value={tier.multiplier}
onChange={(event) =>
updateStageTier(activeStageConfig.stage, index, {
// 10
highWaterOnly:
Number(event.target.value) >= 10 || tier.highWaterOnly,
multiplier: event.target.value,
})
}
/>
{/* 概率由目标 RTP 和倍率统一校正,保持只读可以避免运营手填后绕过服务端概率闭合校验。 */}
<TextField
size="small"
slotProps={{
htmlInput: { "aria-label": "概率 %", readOnly: true },
}}
value={formatDecimal(tier.probabilityPercent)}
/>
<Checkbox
checked={Boolean(tier.highWaterOnly)}
disabled={Number(tier.multiplier) >= 10}
size="small"
slotProps={{ input: { "aria-label": "高水位" } }}
onChange={(event) =>
updateStageTier(activeStageConfig.stage, index, {
highWaterOnly: event.target.checked,
})
}
/>
<Checkbox
checked={tier.enabled !== false}
size="small"
slotProps={{ input: { "aria-label": "启用" } }}
onChange={(event) =>
updateStageTier(activeStageConfig.stage, index, {
enabled: event.target.checked,
})
}
/>
<Button
disabled={activeStageConfig.tiers.length <= 1}
variant="danger"
onClick={() => removeStageTier(activeStageConfig.stage, index)}
>
删除
</Button>
</div>
))}
</div>
</div>
) : null}
</section>
</div> </div>
</DialogContent> </DialogContent>
<DialogActions> <DialogActions className="ops-config-dialog-actions">
<Button disabled={saving} onClick={onClose}> <Button disabled={saving} onClick={onClose}>
取消 取消
</Button> </Button>
@ -438,137 +215,11 @@ export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit",
); );
} }
function DynamicStrategySections({ form, onChange }) {
return (
<>
<section className="ops-config-section">
<h3>动态水位</h3>
<div className="ops-form-grid">
<NumberField
disabled
form={form}
helperText="dynamic_v3 发布规则不注资,请在奖池水位中添加金币"
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 className="ops-config-section">
<h3>大奖控制</h3>
<div className="ops-form-grid">
<TextField
className="ops-form-grid__wide"
helperText="按从小到大填写,使用逗号分隔"
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="用户每日 RTP 上限 (%)"
name="jackpot_user_day_rtp_max_percent"
step="0.01"
onChange={onChange}
/>
<NumberField
form={form}
label="用户 72 小时 RTP 上限 (%)"
name="jackpot_user_72h_rtp_max_percent"
step="0.01"
onChange={onChange}
/>
<NumberField
form={form}
label="用户每日大奖次数"
name="max_jackpot_hits_per_user_day"
onChange={onChange}
/>
<NumberField
form={form}
label="50 美元等值消费门槛"
name="jackpot_spend_threshold_coins"
onChange={onChange}
/>
</div>
</section>
<section className="ops-config-section">
<h3>六维返奖上限</h3>
<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>
</>
);
}
function ConfigMetric({ label, value }) { function ConfigMetric({ label, value }) {
return ( return (
<article className="ops-config-metric"> <div className="ops-config-metric">
<span>{label}</span> <dt>{label}</dt>
<strong>{value}</strong> <dd>{value}</dd>
</article> </div>
);
}
function NumberField({ disabled = false, form, helperText, label, name, onChange, step = "1" }) {
return (
<TextField
disabled={disabled}
helperText={helperText}
label={label}
required
size="small"
slotProps={{ htmlInput: { min: 0, step } }}
type="number"
value={form[name]}
onChange={(event) => onChange(name, event.target.value)}
/>
); );
} }

View File

@ -0,0 +1,332 @@
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { afterEach, expect, test, vi } from "vitest";
import { LuckyGiftConfigDialog } from "./LuckyGiftConfigDialog.jsx";
afterEach(() => {
vi.unstubAllGlobals();
});
test("keeps every dynamic_v3 control reachable while locking rule-time pool injection", () => {
renderDialog(dynamicConfig());
const dialog = screen.getByRole("dialog");
expect(within(dialog).getByRole("navigation", { name: "全局参数分区" })).toBeInTheDocument();
["规则", "资金", "水位", "大奖", "限额"].forEach((label) => {
expect(within(dialog).getByRole("button", { name: label })).toBeInTheDocument();
});
expect(within(dialog).getByRole("switch", { name: "发布状态" })).toBeChecked();
expect(within(dialog).getByLabelText(/初始奖池金币/)).toHaveValue(0);
expect(within(dialog).getByLabelText(/初始奖池金币/)).toBeDisabled();
[
"低水位金币",
"高水位金币",
"充值加权窗口 (ms)",
"大奖倍率",
"全局大奖 RTP 上限 (%)",
"单次返奖上限",
"主播每日上限",
].forEach((label) =>
expect(within(dialog).getByLabelText((accessibleName) => accessibleName.startsWith(label))).toBeInTheDocument(),
);
expect(within(dialog).getAllByRole("tab")).toHaveLength(3);
expect(within(dialog).getByRole("columnheader", { name: "倍率" })).toBeInTheDocument();
expect(within(dialog).getByLabelText("新手阶段第 1 档倍率")).toBeInTheDocument();
expect(within(dialog).getByLabelText("新手阶段第 2 档概率 %")).toHaveAttribute("readonly");
expect(within(dialog).getByRole("button", { name: "删除新手阶段第 1 档" })).toBeInTheDocument();
});
test("section navigation handles reduced motion and falls back after switching from dynamic_v3 to fixed_v2", async () => {
vi.stubGlobal(
"matchMedia",
vi.fn().mockReturnValue({
addEventListener: vi.fn(),
matches: true,
media: "(prefers-reduced-motion: reduce)",
removeEventListener: vi.fn(),
}),
);
renderDialog(dynamicConfig());
const dialog = screen.getByRole("dialog");
const sectionList = dialog.querySelector(".ops-config-section-list");
const identitySection = dialog.querySelector('[data-config-section="identity"]');
const waterSection = dialog.querySelector('[data-config-section="water"]');
const scrollTo = vi.fn();
Object.defineProperty(sectionList, "clientHeight", { configurable: true, value: 480 });
Object.defineProperty(sectionList, "scrollHeight", { configurable: true, value: 1_400 });
Object.defineProperty(sectionList, "scrollTo", { configurable: true, value: scrollTo });
Object.defineProperty(sectionList, "getBoundingClientRect", {
configurable: true,
value: () => ({ top: 200 }),
});
Object.defineProperty(waterSection, "getBoundingClientRect", {
configurable: true,
value: () => ({ top: 520 }),
});
// scroller
fireEvent(window, new Event("resize"));
fireEvent.click(within(dialog).getByRole("button", { name: "水位" }));
expect(scrollTo).toHaveBeenLastCalledWith({ behavior: "auto", top: 312 });
expect(within(dialog).getByRole("button", { name: "水位" })).toHaveAttribute("aria-current", "true");
expect(within(dialog).getByRole("heading", { name: "动态水位" })).toHaveFocus();
sectionList.scrollTop = 137;
Object.defineProperty(identitySection, "getBoundingClientRect", {
configurable: true,
value: () => ({ top: 71 }),
});
fireEvent.click(within(dialog).getByRole("button", { name: "规则" }));
expect(scrollTo).toHaveBeenLastCalledWith({ behavior: "auto", top: 0 });
expect(within(dialog).getByRole("heading", { name: "规则身份" })).toHaveFocus();
fireEvent.click(within(dialog).getByRole("button", { name: "水位" }));
fireEvent.mouseDown(within(dialog).getByLabelText("策略版本"));
fireEvent.click(screen.getByRole("option", { name: "fixed_v2历史兼容" }));
await waitFor(() => {
expect(within(dialog).queryByRole("button", { name: "水位" })).not.toBeInTheDocument();
expect(within(dialog).getByRole("button", { name: "规则" })).toHaveAttribute("aria-current", "true");
});
expect(within(dialog).queryByLabelText(/初始奖池金币/)).not.toBeInTheDocument();
expect(within(dialog).getByRole("switch", { name: "发布状态" })).toBeInTheDocument();
});
test.each([390, 1024])(
"uses the dialog content scroller for %ipx responsive navigation and active-section sync",
(viewportWidth) => {
renderDialog(dynamicConfig());
const dialog = screen.getByRole("dialog");
const sectionNav = dialog.querySelector(".ops-config-section-nav");
const sectionList = dialog.querySelector(".ops-config-section-list");
const outerScroller = sectionList.closest(".ops-config-dialog-content");
const innerScrollTo = vi.fn();
const outerScrollTo = vi.fn();
// viewport flex
sectionNav.style.flexDirection = viewportWidth === 390 ? "row" : "column";
Object.defineProperty(sectionNav, "getBoundingClientRect", {
configurable: true,
value: () => ({ height: 50 }),
});
Object.defineProperty(sectionList, "clientHeight", { configurable: true, value: 1_200 });
Object.defineProperty(sectionList, "scrollHeight", { configurable: true, value: 1_200 });
Object.defineProperty(sectionList, "scrollTo", { configurable: true, value: innerScrollTo });
Object.defineProperty(outerScroller, "scrollTop", { configurable: true, value: 1_837, writable: true });
Object.defineProperty(outerScroller, "scrollTo", { configurable: true, value: outerScrollTo });
Object.defineProperty(outerScroller, "getBoundingClientRect", {
configurable: true,
value: () => ({ top: 120 }),
});
const sectionTops = {
funds: -800,
identity: -1_000,
jackpot: 180,
limits: 800,
water: -579,
};
for (const [sectionKey, top] of Object.entries(sectionTops)) {
Object.defineProperty(
dialog.querySelector(`[data-config-section="${sectionKey}"]`),
"getBoundingClientRect",
{
configurable: true,
value: () => ({ top }),
},
);
}
fireEvent.scroll(outerScroller);
const expectedActiveSection = viewportWidth === 390 ? "大奖" : "水位";
expect(within(dialog).getByRole("button", { name: expectedActiveSection })).toHaveAttribute(
"aria-current",
"true",
);
fireEvent.click(within(dialog).getByRole("button", { name: "水位" }));
expect(outerScrollTo).toHaveBeenCalledWith({
behavior: "smooth",
// sticky nav 50px 8px
top: viewportWidth === 390 ? 1_080 : 1_130,
});
expect(innerScrollTo).not.toHaveBeenCalled();
},
);
test("stage tabs expose complete aria relationships and support roving keyboard navigation", () => {
renderDialog(dynamicConfig());
const dialog = screen.getByRole("dialog");
const noviceTab = within(dialog).getByRole("tab", { name: /新手/ });
const normalTab = within(dialog).getByRole("tab", { name: /普通/ });
const advancedTab = within(dialog).getByRole("tab", { name: /高阶/ });
expect(noviceTab).toHaveAttribute("aria-controls", "ops-stage-panel-novice");
expect(noviceTab).toHaveAttribute("tabindex", "0");
expect(normalTab).toHaveAttribute("tabindex", "-1");
expect(within(dialog).getByRole("tabpanel")).toHaveAttribute("aria-labelledby", "ops-stage-tab-novice");
fireEvent.keyDown(noviceTab, { key: "ArrowRight" });
expect(normalTab).toHaveFocus();
expect(normalTab).toHaveAttribute("aria-selected", "true");
expect(within(dialog).getByRole("tabpanel")).toHaveAttribute("id", "ops-stage-panel-normal");
expect(within(dialog).getByLabelText("正常阶段第 1 档倍率")).toBeInTheDocument();
fireEvent.keyDown(normalTab, { key: "End" });
expect(advancedTab).toHaveFocus();
expect(advancedTab).toHaveAttribute("aria-selected", "true");
fireEvent.keyDown(advancedTab, { key: "Home" });
expect(noviceTab).toHaveFocus();
expect(noviceTab).toHaveAttribute("aria-selected", "true");
});
test("fixed_v2 hides only dynamic controls and preserves immutable snapshot fields on submit", () => {
const onSubmit = vi.fn();
renderDialog(fixedConfig(), { onSubmit });
const dialog = screen.getByRole("dialog");
expect(within(dialog).getByRole("switch", { name: "发布状态" })).toBeChecked();
expect(within(dialog).queryByRole("button", { name: "水位" })).not.toBeInTheDocument();
expect(within(dialog).queryByText(/资金合计/)).not.toBeInTheDocument();
expect(within(dialog).queryByLabelText(/初始奖池金币/)).not.toBeInTheDocument();
expect(within(dialog).getAllByRole("tab")).toHaveLength(3);
fireEvent.click(within(dialog).getByRole("button", { name: "保存配置" }));
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({
effective_from_ms: 987_654_321,
enabled: true,
strategy_version: "fixed_v2",
stages: expect.arrayContaining([
expect.objectContaining({
stage: "novice",
tiers: expect.arrayContaining([
expect.objectContaining({
enabled: true,
high_water_only: false,
tier_id: "novice_none",
}),
]),
}),
]),
}),
);
});
test("focuses the validation summary when the fixed action bar submission is rejected", async () => {
const onSubmit = vi.fn();
renderDialog(dynamicConfig({ low_watermark_coins: 0 }), { onSubmit });
const dialog = screen.getByRole("dialog");
fireEvent.click(within(dialog).getByRole("button", { name: "保存配置" }));
const alert = await within(dialog).findByRole("alert");
expect(alert).toHaveTextContent("高水位必须大于正数低水位");
expect(alert).toHaveFocus();
expect(onSubmit).not.toHaveBeenCalled();
});
function renderDialog(config, overrides = {}) {
return render(
<LuckyGiftConfigDialog
appOptions={[
["lalu", "Lalu"],
["yumi", "Yumi"],
]}
config={config}
mode="edit"
saving={false}
onClose={vi.fn()}
onSubmit={vi.fn()}
{...overrides}
/>,
);
}
function dynamicConfig(overrides = {}) {
return {
anchor_daily_payout_cap: 6_000_000,
anchor_rate_percent: 1,
app_code: "lalu",
control_band_percent: 3,
device_daily_payout_cap: 4_000_000,
effective_from_ms: 123_456_789,
enabled: true,
gift_price_reference: 100,
high_water_nonzero_factor_percent: 130,
high_watermark_coins: 20_000_000,
initial_pool_coins: 0,
jackpot_global_rtp_max_percent: 98,
jackpot_multipliers: [200, 500, 1000],
jackpot_spend_threshold_coins: 50_000,
jackpot_user_72h_rtp_max_percent: 96,
jackpot_user_day_rtp_max_percent: 96,
loss_streak_guarantee: 5,
low_water_nonzero_factor_percent: 70,
low_watermark_coins: 10_000_000,
max_jackpot_hits_per_user_day: 5,
max_single_payout: 1_000_000,
normal_max_equivalent_draws: 20_000,
novice_max_equivalent_draws: 2_000,
pool_id: "lucky",
pool_rate_percent: 98,
profit_rate_percent: 1,
recharge_boost_factor_percent: 110,
recharge_boost_window_ms: 300_000,
room_hourly_payout_cap: 5_000_000,
rule_version: 3,
settlement_window_wager: 1_000_000,
stages: stagesFor(98),
strategy_version: "dynamic_v3",
target_rtp_percent: 98,
user_daily_payout_cap: 3_000_000,
user_hourly_payout_cap: 2_000_000,
...overrides,
};
}
function fixedConfig() {
return {
app_code: "lalu",
control_band_percent: 1,
effective_from_ms: 987_654_321,
enabled: true,
gift_price_reference: 100,
pool_id: "legacy",
pool_rate_percent: 95,
rule_version: 8,
settlement_window_wager: 1_000_000,
stages: stagesFor(95),
strategy_version: "fixed_v2",
target_rtp_percent: 95,
};
}
function stagesFor(targetRTP) {
return ["novice", "normal", "advanced"].map((stage, index) => ({
min_recharge_30d_coins: index === 0 ? 0 : 1,
min_recharge_7d_coins: index === 2 ? 1 : 0,
stage,
tiers: [
{
enabled: true,
high_water_only: false,
multiplier: 0,
probability_percent: 100 - targetRTP,
tier_id: `${stage}_none`,
},
{
enabled: true,
high_water_only: false,
multiplier: 1,
probability_percent: targetRTP,
tier_id: `${stage}_1x`,
},
],
}));
}

View File

@ -0,0 +1,484 @@
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";
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="应用"
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="应用" size="small" value={form.app_code} />
)}
<TextField
fullWidth
required
label="奖池"
size="small"
value={form.pool_id}
onChange={(event) => onChange("pool_id", event.target.value)}
/>
<TextField
fullWidth
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={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="资金与 RTP"
/>
<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="礼物参考金额" name="gift_price_reference" onChange={onChange} />
<NumberField
form={form}
label="新手等价抽数"
name="novice_max_equivalent_draws"
onChange={onChange}
/>
<NumberField
form={form}
label="普通等价抽数"
name="normal_max_equivalent_draws"
onChange={onChange}
/>
</div>
<p className="ops-field-note">
主播收益必须与该奖池实际送礼类型在各适用地区的返币比例一致否则发布校验失败
</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="大奖控制" />
<div className="ops-form-grid">
<TextField
className="ops-form-grid__wide"
helperText="按从小到大填写,使用逗号分隔"
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="用户每日 RTP 上限 (%)"
name="jackpot_user_day_rtp_max_percent"
step="0.01"
onChange={onChange}
/>
<NumberField
form={form}
label="用户 72 小时 RTP 上限 (%)"
name="jackpot_user_72h_rtp_max_percent"
step="0.01"
onChange={onChange}
/>
<NumberField
form={form}
label="用户每日大奖次数"
name="max_jackpot_hits_per_user_day"
onChange={onChange}
/>
<NumberField
form={form}
label="50 美元等值消费门槛"
name="jackpot_spend_threshold_coins"
onChange={onChange}
/>
</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 NumberField({ disabled = false, form, helperText, label, name, onChange, step = "1" }) {
return (
<TextField
disabled={disabled}
helperText={helperText}
label={label}
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;
}

View File

@ -0,0 +1,223 @@
import AddOutlined from "@mui/icons-material/AddOutlined";
import DeleteOutlined from "@mui/icons-material/DeleteOutlined";
import Checkbox from "@mui/material/Checkbox";
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 { OpsStatusBadge } from "./OpsStatusBadge.jsx";
export function LuckyGiftStageDesigner({
activeStageConfig,
activeStageKey,
activeSummary,
onAddTier,
onRemoveTier,
onStageChange,
onStageFieldChange,
onTierChange,
stageSummaries,
strategyVersion,
}) {
const selectStageFromKeyboard = (event, currentIndex) => {
const lastIndex = stageSummaries.length - 1;
let nextIndex;
if (["ArrowRight", "ArrowDown"].includes(event.key)) nextIndex = (currentIndex + 1) % stageSummaries.length;
if (["ArrowLeft", "ArrowUp"].includes(event.key))
nextIndex = (currentIndex - 1 + stageSummaries.length) % stageSummaries.length;
if (event.key === "Home") nextIndex = 0;
if (event.key === "End") nextIndex = lastIndex;
if (nextIndex === undefined) return;
event.preventDefault();
const nextStage = stageSummaries[nextIndex]?.stageKey;
if (!nextStage) return;
// Tabs roving tabindex tab
onStageChange(nextStage);
event.currentTarget.ownerDocument.getElementById(`ops-stage-tab-${nextStage}`)?.focus();
};
return (
<section className="ops-stage-designer" aria-label="阶段奖档设计">
<div className="ops-stage-tabs" role="tablist" aria-label="阶段奖档">
{stageSummaries.map((summary, index) => {
const isActive = summary.stageKey === activeStageKey;
return (
<button
aria-controls={`ops-stage-panel-${summary.stageKey}`}
aria-selected={isActive}
className={`ops-stage-tab ${isActive ? "is-active" : ""} ${summary.ok ? "is-ok" : "is-warn"}`}
id={`ops-stage-tab-${summary.stageKey}`}
key={summary.stageKey}
role="tab"
tabIndex={isActive ? 0 : -1}
type="button"
onClick={() => onStageChange(summary.stageKey)}
onKeyDown={(event) => selectStageFromKeyboard(event, index)}
>
<span className="ops-stage-tab__name">
<strong>{summary.tabLabel}</strong>
<small>{summary.status}</small>
</span>
<span className="ops-stage-tab__metrics">
概率 {formatDecimal(summary.probability)}% · RTP {formatDecimal(summary.expected)}%
</span>
</button>
);
})}
</div>
{activeStageConfig ? (
<div
aria-labelledby={`ops-stage-tab-${activeStageKey}`}
className="ops-stage-stack"
id={`ops-stage-panel-${activeStageKey}`}
role="tabpanel"
>
<div className="ops-stage-block__head">
<div className="ops-stage-block__summary">
<h3>{activeSummary?.stageLabel}</h3>
<OpsStatusBadge
label={`${activeSummary?.status} · 概率 ${formatDecimal(activeSummary?.probability)}% · 期望 RTP ${formatDecimal(activeSummary?.expected)}%`}
tone={activeSummary?.ok ? "succeeded" : "warning"}
/>
</div>
<Button
aria-label={`${activeSummary?.stageLabel || "当前阶段"}添加奖档`}
startIcon={<AddOutlined fontSize="small" />}
onClick={() => onAddTier(activeStageConfig.stage)}
>
添加奖档
</Button>
</div>
{strategyVersion === "dynamic_v3" ? (
<div className="ops-stage-thresholds" aria-label={`${activeSummary?.stageLabel}充值门槛`}>
<span>充值门槛</span>
<TextField
disabled={activeStageConfig.stage === "novice"}
label="7 日充值"
size="small"
slotProps={{ htmlInput: { min: 0, step: 1 } }}
type="number"
value={activeStageConfig.min_recharge_7d_coins}
onChange={(event) =>
onStageFieldChange(
activeStageConfig.stage,
"min_recharge_7d_coins",
event.target.value,
)
}
/>
<TextField
disabled={activeStageConfig.stage === "novice"}
label="30 日充值"
size="small"
slotProps={{ htmlInput: { min: 0, step: 1 } }}
type="number"
value={activeStageConfig.min_recharge_30d_coins}
onChange={(event) =>
onStageFieldChange(
activeStageConfig.stage,
"min_recharge_30d_coins",
event.target.value,
)
}
/>
</div>
) : null}
<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">操作</span>
</div>
{activeStageConfig.tiers.map((tier, index) => {
const rowLabel = `${activeSummary?.stageLabel || activeStageConfig.stage}${index + 1}`;
return (
<div className="ops-tier-row" key={`${activeStageConfig.stage}-${index}`} role="row">
<div className="ops-tier-cell" role="cell">
<TextField
required
size="small"
slotProps={{
htmlInput: {
"aria-label": `${rowLabel}倍率`,
inputMode: "decimal",
min: 0,
step: "0.01",
},
}}
type="number"
value={tier.multiplier}
onChange={(event) =>
onTierChange(activeStageConfig.stage, index, {
// 10
highWaterOnly:
Number(event.target.value) >= 10 || tier.highWaterOnly,
multiplier: event.target.value,
})
}
/>
</div>
<div className="ops-tier-cell" role="cell">
{/* 概率由目标 RTP 和倍率统一校正;保持只读可避免运营绕过服务端概率闭合校验。 */}
<TextField
size="small"
slotProps={{
htmlInput: {
"aria-label": `${rowLabel}概率 %`,
readOnly: true,
},
}}
value={formatDecimal(tier.probabilityPercent)}
/>
</div>
<div className="ops-tier-cell ops-tier-cell--center" role="cell">
<Checkbox
checked={Boolean(tier.highWaterOnly)}
disabled={Number(tier.multiplier) >= 10}
size="small"
slotProps={{ input: { "aria-label": `${rowLabel}高水位限制` } }}
onChange={(event) =>
onTierChange(activeStageConfig.stage, index, {
highWaterOnly: event.target.checked,
})
}
/>
</div>
<div className="ops-tier-cell ops-tier-cell--center" role="cell">
<Checkbox
checked={tier.enabled !== false}
size="small"
slotProps={{ input: { "aria-label": `${rowLabel}启用` } }}
onChange={(event) =>
onTierChange(activeStageConfig.stage, index, {
enabled: event.target.checked,
})
}
/>
</div>
<div className="ops-tier-cell ops-tier-cell--center" role="cell">
<IconButton
aria-label={`删除${rowLabel}`}
color="error"
disabled={activeStageConfig.tiers.length <= 1}
size="small"
title={`删除${rowLabel}`}
onClick={() => onRemoveTier(activeStageConfig.stage, index)}
>
<DeleteOutlined fontSize="small" />
</IconButton>
</div>
</div>
);
})}
</div>
</div>
) : null}
</section>
);
}

View File

@ -186,13 +186,27 @@ body {
/* ---- 幸运礼物配置弹窗 ---- */ /* ---- 幸运礼物配置弹窗 ---- */
.MuiDialog-paper.ops-config-dialog-paper {
width: 94vw;
max-width: 1520px;
height: 92vh;
max-height: 92vh;
margin: var(--space-4);
overflow: hidden;
}
.ops-config-form { .ops-config-form {
display: contents; display: flex;
min-height: 0;
height: 100%;
flex-direction: column;
} }
.ops-config-title { .ops-config-title {
display: grid; display: grid;
flex: 0 0 auto;
gap: var(--space-1); gap: var(--space-1);
padding: var(--space-4) var(--space-6) var(--space-3);
} }
.ops-config-title small { .ops-config-title small {
@ -201,17 +215,31 @@ body {
font-weight: 700; font-weight: 700;
} }
.ops-config-metrics { .ops-config-summary {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); flex: 0 0 auto;
gap: var(--space-2); grid-template-columns: repeat(7, minmax(0, 1fr));
margin-bottom: var(--space-4); margin: 0;
padding: 0 var(--space-6) var(--space-3);
}
.ops-config-dialog-content {
display: flex;
min-height: 0;
flex: 1 1 auto;
flex-direction: column;
overflow: hidden !important;
background: var(--bg-card);
padding: 0 !important;
} }
.ops-config-validation { .ops-config-validation {
display: grid; display: grid;
max-height: 112px;
flex: 0 0 auto;
gap: var(--space-2); gap: var(--space-2);
margin-bottom: var(--space-4); overflow-y: auto;
margin: var(--space-3) var(--space-4) 0;
border: 1px solid var(--danger-border, #fecaca); border: 1px solid var(--danger-border, #fecaca);
border-radius: var(--radius-control); border-radius: var(--radius-control);
background: var(--danger-surface, #fef2f2); background: var(--danger-surface, #fef2f2);
@ -219,6 +247,11 @@ body {
padding: var(--space-3) var(--space-4); padding: var(--space-3) var(--space-4);
} }
.ops-config-validation:focus-visible {
outline: 2px solid var(--danger, #dc2626);
outline-offset: 2px;
}
.ops-config-validation ul { .ops-config-validation ul {
display: grid; display: grid;
gap: 2px; gap: 2px;
@ -229,26 +262,31 @@ body {
.ops-config-metric { .ops-config-metric {
display: grid; display: grid;
min-width: 0; min-width: 0;
gap: var(--space-1); gap: 2px;
border: 1px solid var(--border); border-left: 1px solid var(--border);
border-radius: var(--radius-control); padding: var(--space-1) var(--space-3);
background: var(--bg-card-strong, #f8fafc);
padding: 10px var(--space-3);
} }
.ops-config-metric span { .ops-config-metric:first-child {
border-left: 0;
padding-left: 0;
}
.ops-config-metric dt {
overflow: hidden; overflow: hidden;
color: var(--text-tertiary); color: var(--text-tertiary);
font-size: 12px; font-size: 11px;
font-weight: 800; font-weight: 800;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.ops-config-metric strong { .ops-config-metric dd {
overflow: hidden; overflow: hidden;
margin: 0;
color: var(--text-primary); color: var(--text-primary);
font-size: 15px; font-size: 13px;
font-weight: 800;
line-height: 1.25; line-height: 1.25;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
@ -256,24 +294,94 @@ body {
.ops-config-layout { .ops-config-layout {
display: grid; display: grid;
grid-template-columns: minmax(360px, 440px) minmax(0, 1fr); min-height: 0;
align-items: start; height: 100%;
gap: var(--space-4); flex: 1 1 auto;
grid-template-columns: clamp(500px, 40vw, 620px) minmax(0, 1fr);
overflow: hidden;
} }
.ops-config-params { .ops-config-params {
display: grid; display: grid;
min-width: 0; min-width: 0;
gap: var(--space-4); min-height: 0;
grid-template-columns: 92px minmax(0, 1fr);
overflow: hidden;
border-right: 1px solid var(--border);
}
.ops-config-section-nav {
display: flex;
min-width: 0;
flex-direction: column;
gap: var(--space-1);
overflow-y: auto;
border-right: 1px solid var(--border);
background: var(--bg-card-strong, #f8fafc);
padding: var(--space-3) var(--space-2);
}
.ops-config-section-nav strong {
color: var(--text-tertiary);
font-size: 11px;
letter-spacing: 0.04em;
padding: var(--space-1) var(--space-2) var(--space-2);
}
.ops-config-section-nav button {
min-height: 34px;
border: 0;
border-left: 2px solid transparent;
border-radius: var(--radius-control);
background: transparent;
color: var(--text-secondary);
cursor: pointer;
font: inherit;
font-size: 13px;
font-weight: 800;
padding: 0 var(--space-2);
text-align: left;
transition:
background var(--motion-fast) var(--ease-standard),
border-color var(--motion-fast) var(--ease-standard),
color var(--motion-fast) var(--ease-standard);
}
.ops-config-section-nav button:hover {
background: var(--primary-hover);
}
.ops-config-section-nav button.is-active {
border-left-color: var(--primary);
background: var(--primary-surface);
color: var(--primary);
}
.ops-config-section-list {
min-width: 0;
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
padding: var(--space-3) var(--space-4) var(--space-6);
scroll-behavior: smooth;
} }
.ops-config-section { .ops-config-section {
display: grid; display: grid;
gap: var(--space-3); gap: var(--space-3);
border: 1px solid var(--border); border: 0;
border-radius: var(--radius-card); border-bottom: 1px solid var(--border);
border-radius: 0;
background: var(--bg-card); background: var(--bg-card);
padding: var(--space-4); padding: var(--space-3) 0 var(--space-5);
}
.ops-config-section + .ops-config-section {
padding-top: var(--space-5);
}
.ops-config-section:last-child {
border-bottom: 0;
} }
.ops-config-section h3 { .ops-config-section h3 {
@ -283,6 +391,28 @@ body {
line-height: 1.3; line-height: 1.3;
} }
.ops-config-section__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
}
.ops-config-section__head span {
border-radius: var(--radius-pill);
background: var(--warning-surface);
color: var(--warning);
font-size: 11px;
font-weight: 800;
padding: 3px var(--space-2);
white-space: nowrap;
}
.ops-config-section__head span.is-ok {
background: var(--success-surface);
color: var(--success);
}
.ops-form-grid { .ops-form-grid {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
@ -293,44 +423,100 @@ body {
grid-column: 1 / -1; grid-column: 1 / -1;
} }
.ops-config-enabled-field {
min-height: var(--control-height);
justify-content: space-between;
margin: 0 !important;
border: 1px solid var(--border);
border-radius: var(--radius-control);
padding: 0 var(--space-3);
}
.ops-config-enabled-field .MuiFormControlLabel-label {
color: var(--text-secondary);
font-size: 13px;
font-weight: 700;
}
.ops-field-note {
margin: 0;
color: var(--text-tertiary);
font-size: 12px;
line-height: 1.5;
}
.ops-stage-designer { .ops-stage-designer {
display: grid; display: grid;
min-width: 0; min-width: 0;
gap: 0; min-height: 0;
border: 1px solid var(--border); align-content: start;
border-radius: var(--radius-card); overflow-y: auto;
overscroll-behavior: contain;
border: 0;
border-radius: 0;
background: var(--bg-card); background: var(--bg-card);
} }
.ops-stage-tabs { .ops-stage-tabs {
display: flex; position: sticky;
z-index: 3;
top: 0;
display: grid;
min-width: 0; min-width: 0;
gap: var(--space-1); min-height: 64px;
padding: 0 var(--space-3); grid-template-columns: repeat(3, minmax(0, 1fr));
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
background: var(--bg-card);
padding: 0 var(--space-4);
} }
.ops-stage-tab { .ops-stage-tab {
display: inline-flex; display: grid;
min-width: 0; min-width: 0;
height: 38px; min-height: 64px;
align-items: center; align-content: center;
gap: 3px;
border: 0; border: 0;
border-bottom: 2px solid transparent; border-bottom: 2px solid transparent;
border-radius: 0; border-radius: 0;
background: transparent; background: transparent;
color: var(--text-secondary); color: var(--text-secondary);
cursor: pointer; cursor: pointer;
font-size: 13px; padding: var(--space-2) var(--space-3);
font-weight: 800; text-align: left;
padding: 0 var(--space-4);
text-align: center;
transition: transition:
background var(--motion-fast) var(--ease-standard), background var(--motion-fast) var(--ease-standard),
border-color var(--motion-fast) var(--ease-standard), border-color var(--motion-fast) var(--ease-standard),
color var(--motion-fast) var(--ease-standard); color var(--motion-fast) var(--ease-standard);
} }
.ops-stage-tab__name {
display: flex;
min-width: 0;
align-items: baseline;
justify-content: space-between;
gap: var(--space-2);
}
.ops-stage-tab__name strong {
color: inherit;
font-size: 13px;
}
.ops-stage-tab__name small,
.ops-stage-tab__metrics {
overflow: hidden;
color: var(--text-tertiary);
font-size: 11px;
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
}
.ops-stage-tab.is-warn .ops-stage-tab__name small {
color: var(--warning);
}
.ops-stage-tab:hover { .ops-stage-tab:hover {
background: var(--primary-hover); background: var(--primary-hover);
} }
@ -353,17 +539,24 @@ body {
.ops-stage-stack { .ops-stage-stack {
display: grid; display: grid;
gap: var(--space-3); gap: var(--space-3);
padding: var(--space-4); padding: 0 var(--space-4) var(--space-6);
} }
.ops-stage-block__head { .ops-stage-block__head {
position: sticky;
z-index: 2;
top: 64px;
display: flex; display: flex;
min-height: 58px;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: var(--space-3); gap: var(--space-3);
border-bottom: 1px solid var(--border);
background: var(--bg-card);
padding: var(--space-2) 0;
} }
.ops-stage-block__head > div { .ops-stage-block__summary {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
align-items: center; align-items: center;
@ -377,12 +570,17 @@ body {
.ops-stage-thresholds { .ops-stage-thresholds {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: auto repeat(2, minmax(140px, 220px));
align-items: center;
gap: var(--space-3); gap: var(--space-3);
padding: var(--space-3); justify-content: end;
border: 1px solid var(--border); padding: var(--space-1) 0;
border-radius: var(--radius-control); }
background: var(--bg-card-strong, #f8fafc);
.ops-stage-thresholds > span {
color: var(--text-tertiary);
font-size: 12px;
font-weight: 800;
} }
.ops-tier-table { .ops-tier-table {
@ -398,13 +596,13 @@ body {
.ops-tier-row { .ops-tier-row {
display: grid; display: grid;
box-sizing: border-box; box-sizing: border-box;
min-width: 520px; min-width: 590px;
grid-template-columns: grid-template-columns:
minmax(100px, 1fr)
minmax(110px, 1fr) minmax(110px, 1fr)
minmax(110px, 1fr) 76px
minmax(64px, auto) 64px
minmax(64px, auto) 56px;
minmax(72px, auto);
align-items: center; align-items: center;
gap: var(--space-2); gap: var(--space-2);
} }
@ -419,17 +617,86 @@ body {
padding: 0 var(--space-3); padding: 0 var(--space-3);
} }
.ops-tier-head span:nth-child(n + 3) {
text-align: center;
}
.ops-tier-row { .ops-tier-row {
border-bottom: 1px solid var(--row-border); border-bottom: 1px solid var(--row-border);
padding: var(--space-2) var(--space-3); padding: 6px var(--space-3);
} }
.ops-tier-row:last-child { .ops-tier-row:last-child {
border-bottom: 0; border-bottom: 0;
} }
.ops-tier-cell {
display: flex;
min-width: 0;
align-items: center;
}
.ops-tier-cell > .MuiFormControl-root {
width: 100%;
}
.ops-tier-cell--center {
justify-content: center;
}
.ops-config-dialog-actions {
min-height: 60px;
flex: 0 0 auto;
border-top: 1px solid var(--border);
background: var(--bg-card);
padding: var(--space-2) var(--space-6);
}
/* ---- 响应式 ---- */ /* ---- 响应式 ---- */
@media (max-width: 1279px) {
.ops-config-summary {
grid-template-columns: repeat(4, minmax(0, 1fr));
row-gap: var(--space-2);
}
.ops-config-dialog-content {
overflow-y: auto !important;
}
.ops-config-layout {
height: auto;
min-height: 100%;
grid-template-columns: minmax(0, 1fr);
overflow: visible;
}
.ops-config-params {
min-height: auto;
grid-template-columns: 112px minmax(0, 1fr);
overflow: visible;
border-right: 0;
border-bottom: 1px solid var(--border);
}
.ops-config-section-nav {
position: sticky;
top: 0;
max-height: 100%;
align-self: start;
}
.ops-config-section-list,
.ops-stage-designer {
min-height: auto;
overflow: visible;
}
.ops-stage-designer {
border-top: var(--space-2) solid var(--bg-page);
}
}
@media (max-width: 960px) { @media (max-width: 960px) {
.ops-shell { .ops-shell {
grid-template-columns: 1fr; grid-template-columns: 1fr;
@ -440,14 +707,121 @@ body {
} }
.ops-nav, .ops-nav,
.ops-kpi-grid, .ops-kpi-grid {
.ops-config-metrics {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
}
.ops-config-layout { @media (max-width: 767px) {
.MuiDialog-paper.ops-config-dialog-paper {
width: 100vw;
max-width: none;
height: 100dvh;
max-height: 100dvh;
margin: 0;
border-radius: 0;
}
.ops-config-title {
padding: var(--space-3) var(--space-4) var(--space-2);
}
.ops-config-summary {
display: flex;
gap: 0;
overflow-x: auto;
padding: 0 var(--space-4) var(--space-2);
}
.ops-config-metric {
min-width: 124px;
}
.ops-config-params {
grid-template-columns: minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);
} }
.ops-config-section-nav {
z-index: 4;
display: flex;
max-width: 100vw;
flex-direction: row;
overflow-x: auto;
border-right: 0;
border-bottom: 1px solid var(--border);
padding: var(--space-2);
}
.ops-config-section-nav strong {
display: none;
}
.ops-config-section-nav button {
min-width: 72px;
flex: 0 0 auto;
border-left: 0;
border-bottom: 2px solid transparent;
text-align: center;
}
.ops-config-section-nav button.is-active {
border-bottom-color: var(--primary);
}
.ops-config-section-list {
padding: var(--space-2) var(--space-3) var(--space-5);
}
.ops-form-grid {
grid-template-columns: minmax(0, 1fr);
}
.ops-stage-tabs {
display: flex;
max-width: 100vw;
overflow-x: auto;
padding: 0 var(--space-2);
}
.ops-stage-tab {
min-width: 180px;
flex: 1 0 180px;
}
.ops-stage-stack {
padding: 0 var(--space-3) var(--space-5);
}
.ops-stage-block__head {
align-items: flex-start;
flex-wrap: wrap;
}
.ops-stage-block__summary {
align-items: flex-start;
flex-direction: column;
gap: var(--space-1);
}
.ops-stage-thresholds {
grid-template-columns: repeat(2, minmax(0, 1fr));
justify-content: stretch;
}
.ops-stage-thresholds > span {
grid-column: 1 / -1;
}
/* 小屏保留完整列头并让表格横向滚动,控件同时有阶段+行号标签,避免一列卡片失去字段关系。 */
.ops-tier-head,
.ops-tier-row {
min-width: 600px;
}
.ops-config-dialog-actions {
min-height: 56px;
padding: var(--space-2) var(--space-4) max(var(--space-2), env(safe-area-inset-bottom));
}
} }
@media (max-width: 640px) { @media (max-width: 640px) {
@ -457,22 +831,19 @@ body {
.ops-nav, .ops-nav,
.ops-kpi-grid, .ops-kpi-grid,
.ops-config-metrics, .ops-pool-adjustment-metrics {
.ops-pool-adjustment-metrics,
.ops-form-grid,
.ops-stage-thresholds,
.ops-tier-row {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.ops-tier-head {
display: none;
}
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.ops-nav button, .ops-nav button,
.ops-config-section-nav button,
.ops-stage-tab { .ops-stage-tab {
transition: none; transition: none;
} }
.ops-config-section-list {
scroll-behavior: auto;
}
} }