diff --git a/ops-center/src/OpsCenterApp.test.jsx b/ops-center/src/OpsCenterApp.test.jsx index 4f30e7c..e91954c 100644 --- a/ops-center/src/OpsCenterApp.test.jsx +++ b/ops-center/src/OpsCenterApp.test.jsx @@ -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(/初始奖池金币/)).toBeDisabled(); 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.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(); // 奖档编辑同样必须清掉上一次提交错误,避免用户修正后仍看到已经过期的阻断原因。 - 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(); }); diff --git a/ops-center/src/components/LuckyGiftConfigDialog.jsx b/ops-center/src/components/LuckyGiftConfigDialog.jsx index c017168..bc7fd61 100644 --- a/ops-center/src/components/LuckyGiftConfigDialog.jsx +++ b/ops-center/src/components/LuckyGiftConfigDialog.jsx @@ -1,16 +1,9 @@ -import AddOutlined from "@mui/icons-material/AddOutlined"; -import Checkbox from "@mui/material/Checkbox"; import Dialog from "@mui/material/Dialog"; import DialogActions from "@mui/material/DialogActions"; import DialogContent from "@mui/material/DialogContent"; import DialogTitle from "@mui/material/DialogTitle"; -import FormControlLabel from "@mui/material/FormControlLabel"; -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 { useEffect, useMemo, useRef, useState } from "react"; import { Button } from "@/shared/ui/Button.jsx"; -import { formatDecimal } from "@/shared/utils/rtpProbability.js"; import { applyDynamicStrategyDefaults, configToForm, @@ -21,12 +14,14 @@ import { validateLuckyGiftForm, } from "../configForm.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 }) { const [form, setForm] = useState(() => configToForm(config)); const [activeStage, setActiveStage] = useState("novice"); const [validationErrors, setValidationErrors] = useState([]); + const validationRef = useRef(null); const stageSummaries = useMemo(() => form.stages.map((stage) => stageSummary(stage, form)), [form]); 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 activeSummary = stageSummaries.find((summary) => summary.stageKey === activeStageKey); + useEffect(() => { + if (!validationErrors.length) return; + // 保存按钮位于固定底栏;失败后把焦点送回错误摘要,避免键盘或读屏用户停留在无变化的按钮上。 + validationRef.current?.focus(); + }, [validationErrors]); + const updateField = (field, value) => { setValidationErrors([]); setForm((current) => { @@ -129,31 +130,48 @@ export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit", const title = isCreate ? "添加幸运礼物配置" : config.is_default ? "发布幸运礼物配置" : "新增幸运礼物版本"; return ( - +
- + {title} {form.app_code || "-"} / {form.pool_id || "default"} /{" "} {config.is_default || isCreate ? "默认草稿" : `当前版本 v${config.rule_version ?? "-"}`} - -
- - - - - - - -
- +
+ + + + + + + +
+ {validationErrors.length ? ( -
+
配置不能发布
    {validationErrors.map((message) => ( @@ -164,268 +182,27 @@ export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit", ) : null}
    - - -
    -
    - {stageSummaries.map((summary) => ( - - ))} -
    - {activeStageConfig ? ( -
    -
    -
    -

    {activeSummary?.stageLabel}

    - -
    - -
    - {form.strategy_version === "dynamic_v3" ? ( -
    - - updateStageField( - activeStageConfig.stage, - "min_recharge_7d_coins", - event.target.value, - ) - } - /> - - updateStageField( - activeStageConfig.stage, - "min_recharge_30d_coins", - event.target.value, - ) - } - /> -
    - ) : null} -
    - - {activeStageConfig.tiers.map((tier, index) => ( -
    - - updateStageTier(activeStageConfig.stage, index, { - // 10 倍及以上强制只走高水位,输入时同步勾选并锁死,防止高倍档在低水位放开。 - highWaterOnly: - Number(event.target.value) >= 10 || tier.highWaterOnly, - multiplier: event.target.value, - }) - } - /> - {/* 概率由目标 RTP 和倍率统一校正,保持只读可以避免运营手填后绕过服务端概率闭合校验。 */} - - = 10} - size="small" - slotProps={{ input: { "aria-label": "高水位" } }} - onChange={(event) => - updateStageTier(activeStageConfig.stage, index, { - highWaterOnly: event.target.checked, - }) - } - /> - - updateStageTier(activeStageConfig.stage, index, { - enabled: event.target.checked, - }) - } - /> - -
    - ))} -
    -
    - ) : null} -
    + +
    - + @@ -438,137 +215,11 @@ export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit", ); } -function DynamicStrategySections({ form, onChange }) { - return ( - <> -
    -

    动态水位

    -
    - - - - - - - - -
    -
    - -
    -

    大奖控制

    -
    - onChange("jackpot_multipliers", event.target.value)} - /> - - - - - -
    -
    - -
    -

    六维返奖上限

    -
    - - - - - - -
    -
    - - ); -} - function ConfigMetric({ label, value }) { return ( -
    - {label} - {value} -
    - ); -} - -function NumberField({ disabled = false, form, helperText, label, name, onChange, step = "1" }) { - return ( - onChange(name, event.target.value)} - /> +
    +
    {label}
    +
    {value}
    +
    ); } diff --git a/ops-center/src/components/LuckyGiftConfigDialog.test.jsx b/ops-center/src/components/LuckyGiftConfigDialog.test.jsx new file mode 100644 index 0000000..3a49876 --- /dev/null +++ b/ops-center/src/components/LuckyGiftConfigDialog.test.jsx @@ -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( + , + ); +} + +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`, + }, + ], + })); +} diff --git a/ops-center/src/components/LuckyGiftConfigSections.jsx b/ops-center/src/components/LuckyGiftConfigSections.jsx new file mode 100644 index 0000000..19c7a0e --- /dev/null +++ b/ops-center/src/components/LuckyGiftConfigSections.jsx @@ -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 ( + + ); +} + +function SectionHeading({ detail, id, ok = false, title }) { + return ( +
    +

    + {title} +

    + {detail ? {detail} : null} +
    + ); +} + +function NumberField({ disabled = false, form, helperText, label, name, onChange, step = "1" }) { + return ( + 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; +} diff --git a/ops-center/src/components/LuckyGiftStageDesigner.jsx b/ops-center/src/components/LuckyGiftStageDesigner.jsx new file mode 100644 index 0000000..7fb5d49 --- /dev/null +++ b/ops-center/src/components/LuckyGiftStageDesigner.jsx @@ -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 ( +
    +
    + {stageSummaries.map((summary, index) => { + const isActive = summary.stageKey === activeStageKey; + return ( + + ); + })} +
    + + {activeStageConfig ? ( +
    +
    +
    +

    {activeSummary?.stageLabel}

    + +
    + +
    + + {strategyVersion === "dynamic_v3" ? ( +
    + 充值门槛 + + onStageFieldChange( + activeStageConfig.stage, + "min_recharge_7d_coins", + event.target.value, + ) + } + /> + + onStageFieldChange( + activeStageConfig.stage, + "min_recharge_30d_coins", + event.target.value, + ) + } + /> +
    + ) : null} + +
    +
    + 倍率 + 概率 % + 高水位 + 启用 + 操作 +
    + {activeStageConfig.tiers.map((tier, index) => { + const rowLabel = `${activeSummary?.stageLabel || activeStageConfig.stage}第 ${index + 1} 档`; + return ( +
    +
    + + onTierChange(activeStageConfig.stage, index, { + // 10 倍及以上强制只走高水位,输入时同步勾选并锁死,防止高倍档在低水位放开。 + highWaterOnly: + Number(event.target.value) >= 10 || tier.highWaterOnly, + multiplier: event.target.value, + }) + } + /> +
    +
    + {/* 概率由目标 RTP 和倍率统一校正;保持只读可避免运营绕过服务端概率闭合校验。 */} + +
    +
    + = 10} + size="small" + slotProps={{ input: { "aria-label": `${rowLabel}高水位限制` } }} + onChange={(event) => + onTierChange(activeStageConfig.stage, index, { + highWaterOnly: event.target.checked, + }) + } + /> +
    +
    + + onTierChange(activeStageConfig.stage, index, { + enabled: event.target.checked, + }) + } + /> +
    +
    + onRemoveTier(activeStageConfig.stage, index)} + > + + +
    +
    + ); + })} +
    +
    + ) : null} +
    + ); +} diff --git a/ops-center/src/styles/index.css b/ops-center/src/styles/index.css index 277fc65..56a5129 100644 --- a/ops-center/src/styles/index.css +++ b/ops-center/src/styles/index.css @@ -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 { - display: contents; + display: flex; + min-height: 0; + height: 100%; + flex-direction: column; } .ops-config-title { display: grid; + flex: 0 0 auto; gap: var(--space-1); + padding: var(--space-4) var(--space-6) var(--space-3); } .ops-config-title small { @@ -201,17 +215,31 @@ body { font-weight: 700; } -.ops-config-metrics { +.ops-config-summary { display: grid; - grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); - gap: var(--space-2); - margin-bottom: var(--space-4); + flex: 0 0 auto; + grid-template-columns: repeat(7, minmax(0, 1fr)); + 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 { display: grid; + max-height: 112px; + flex: 0 0 auto; 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-radius: var(--radius-control); background: var(--danger-surface, #fef2f2); @@ -219,6 +247,11 @@ body { 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 { display: grid; gap: 2px; @@ -229,26 +262,31 @@ body { .ops-config-metric { display: grid; min-width: 0; - gap: var(--space-1); - border: 1px solid var(--border); - border-radius: var(--radius-control); - background: var(--bg-card-strong, #f8fafc); - padding: 10px var(--space-3); + gap: 2px; + border-left: 1px solid var(--border); + padding: var(--space-1) var(--space-3); } -.ops-config-metric span { +.ops-config-metric:first-child { + border-left: 0; + padding-left: 0; +} + +.ops-config-metric dt { overflow: hidden; color: var(--text-tertiary); - font-size: 12px; + font-size: 11px; font-weight: 800; text-overflow: ellipsis; white-space: nowrap; } -.ops-config-metric strong { +.ops-config-metric dd { overflow: hidden; + margin: 0; color: var(--text-primary); - font-size: 15px; + font-size: 13px; + font-weight: 800; line-height: 1.25; text-overflow: ellipsis; white-space: nowrap; @@ -256,24 +294,94 @@ body { .ops-config-layout { display: grid; - grid-template-columns: minmax(360px, 440px) minmax(0, 1fr); - align-items: start; - gap: var(--space-4); + min-height: 0; + height: 100%; + flex: 1 1 auto; + grid-template-columns: clamp(500px, 40vw, 620px) minmax(0, 1fr); + overflow: hidden; } .ops-config-params { display: grid; 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 { display: grid; gap: var(--space-3); - border: 1px solid var(--border); - border-radius: var(--radius-card); + border: 0; + border-bottom: 1px solid var(--border); + border-radius: 0; 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 { @@ -283,6 +391,28 @@ body { 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 { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); @@ -293,44 +423,100 @@ body { 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 { display: grid; min-width: 0; - gap: 0; - border: 1px solid var(--border); - border-radius: var(--radius-card); + min-height: 0; + align-content: start; + overflow-y: auto; + overscroll-behavior: contain; + border: 0; + border-radius: 0; background: var(--bg-card); } .ops-stage-tabs { - display: flex; + position: sticky; + z-index: 3; + top: 0; + display: grid; min-width: 0; - gap: var(--space-1); - padding: 0 var(--space-3); + min-height: 64px; + grid-template-columns: repeat(3, minmax(0, 1fr)); border-bottom: 1px solid var(--border); + background: var(--bg-card); + padding: 0 var(--space-4); } .ops-stage-tab { - display: inline-flex; + display: grid; min-width: 0; - height: 38px; - align-items: center; + min-height: 64px; + align-content: center; + gap: 3px; border: 0; border-bottom: 2px solid transparent; border-radius: 0; background: transparent; color: var(--text-secondary); cursor: pointer; - font-size: 13px; - font-weight: 800; - padding: 0 var(--space-4); - text-align: center; + padding: var(--space-2) var(--space-3); + 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-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 { background: var(--primary-hover); } @@ -353,17 +539,24 @@ body { .ops-stage-stack { display: grid; gap: var(--space-3); - padding: var(--space-4); + padding: 0 var(--space-4) var(--space-6); } .ops-stage-block__head { + position: sticky; + z-index: 2; + top: 64px; display: flex; + min-height: 58px; align-items: center; justify-content: space-between; 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; flex-wrap: wrap; align-items: center; @@ -377,12 +570,17 @@ body { .ops-stage-thresholds { 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); - padding: var(--space-3); - border: 1px solid var(--border); - border-radius: var(--radius-control); - background: var(--bg-card-strong, #f8fafc); + justify-content: end; + padding: var(--space-1) 0; +} + +.ops-stage-thresholds > span { + color: var(--text-tertiary); + font-size: 12px; + font-weight: 800; } .ops-tier-table { @@ -398,13 +596,13 @@ body { .ops-tier-row { display: grid; box-sizing: border-box; - min-width: 520px; + min-width: 590px; grid-template-columns: + minmax(100px, 1fr) minmax(110px, 1fr) - minmax(110px, 1fr) - minmax(64px, auto) - minmax(64px, auto) - minmax(72px, auto); + 76px + 64px + 56px; align-items: center; gap: var(--space-2); } @@ -419,17 +617,86 @@ body { padding: 0 var(--space-3); } +.ops-tier-head span:nth-child(n + 3) { + text-align: center; +} + .ops-tier-row { border-bottom: 1px solid var(--row-border); - padding: var(--space-2) var(--space-3); + padding: 6px var(--space-3); } .ops-tier-row:last-child { 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) { .ops-shell { grid-template-columns: 1fr; @@ -440,14 +707,121 @@ body { } .ops-nav, - .ops-kpi-grid, - .ops-config-metrics { + .ops-kpi-grid { 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); } + + .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) { @@ -457,22 +831,19 @@ body { .ops-nav, .ops-kpi-grid, - .ops-config-metrics, - .ops-pool-adjustment-metrics, - .ops-form-grid, - .ops-stage-thresholds, - .ops-tier-row { + .ops-pool-adjustment-metrics { grid-template-columns: 1fr; } - - .ops-tier-head { - display: none; - } } @media (prefers-reduced-motion: reduce) { .ops-nav button, + .ops-config-section-nav button, .ops-stage-tab { transition: none; } + + .ops-config-section-list { + scroll-behavior: auto; + } }