feat(ops-center): densify lucky gift configuration
This commit is contained in:
parent
460464ee18
commit
8629a52e33
@ -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();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -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={form.strategy_version === "dynamic_v3" ? "资金拆分" : "奖池比例"}
|
||||||
label="资金拆分"
|
value={
|
||||||
value={`${form.pool_rate_percent}% / ${form.profit_rate_percent}% / ${form.anchor_rate_percent}%`}
|
form.strategy_version === "dynamic_v3"
|
||||||
/>
|
? `${form.pool_rate_percent}% / ${form.profit_rate_percent}% / ${form.anchor_rate_percent}%`
|
||||||
<ConfigMetric label="结算窗口" value={formatNumber(Number(form.settlement_window_wager))} />
|
: `${form.pool_rate_percent}%`
|
||||||
<ConfigMetric label="状态" value={form.enabled ? "启用" : "停用"} />
|
}
|
||||||
</div>
|
/>
|
||||||
|
<ConfigMetric label="结算窗口" value={formatNumber(Number(form.settlement_window_wager))} />
|
||||||
|
<ConfigMetric label="发布状态" value={form.enabled ? "启用" : "停用"} />
|
||||||
|
</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>
|
form={form}
|
||||||
{isCreate ? (
|
isCreate={isCreate}
|
||||||
<TextField
|
onChange={updateField}
|
||||||
fullWidth
|
/>
|
||||||
required
|
<LuckyGiftStageDesigner
|
||||||
label="应用"
|
activeStageConfig={activeStageConfig}
|
||||||
select
|
activeStageKey={activeStageKey}
|
||||||
size="small"
|
activeSummary={activeSummary}
|
||||||
value={form.app_code}
|
stageSummaries={stageSummaries}
|
||||||
onChange={(event) => updateField("app_code", event.target.value)}
|
strategyVersion={form.strategy_version}
|
||||||
>
|
onAddTier={addStageTier}
|
||||||
{appOptions.map(([value, label]) => (
|
onRemoveTier={removeStageTier}
|
||||||
<MenuItem key={value} value={value}>
|
onStageChange={setActiveStage}
|
||||||
{label}
|
onStageFieldChange={updateStageField}
|
||||||
</MenuItem>
|
onTierChange={updateStageTier}
|
||||||
))}
|
/>
|
||||||
</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}
|
|
||||||
label="目标 RTP (%)"
|
|
||||||
name="target_rtp_percent"
|
|
||||||
step="0.01"
|
|
||||||
onChange={updateField}
|
|
||||||
/>
|
|
||||||
<NumberField
|
|
||||||
form={form}
|
|
||||||
label="公共奖池 (%)"
|
|
||||||
name="pool_rate_percent"
|
|
||||||
step="0.01"
|
|
||||||
onChange={updateField}
|
|
||||||
/>
|
|
||||||
<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)}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
332
ops-center/src/components/LuckyGiftConfigDialog.test.jsx
Normal file
332
ops-center/src/components/LuckyGiftConfigDialog.test.jsx
Normal 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`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
}
|
||||||
484
ops-center/src/components/LuckyGiftConfigSections.jsx
Normal file
484
ops-center/src/components/LuckyGiftConfigSections.jsx
Normal 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 为横向 row,1024px 时仍是左侧 column。
|
||||||
|
// 读取 computed style 而不是猜 innerWidth,才能兼容浏览器缩放、嵌入容器和未来断点调整。
|
||||||
|
const flexDirection = nav.ownerDocument.defaultView?.getComputedStyle(nav).flexDirection;
|
||||||
|
return flexDirection === "row" ? nav.getBoundingClientRect().height : 0;
|
||||||
|
}
|
||||||
223
ops-center/src/components/LuckyGiftStageDesigner.jsx
Normal file
223
ops-center/src/components/LuckyGiftStageDesigner.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user