Merge branch 'test' of gitea.haiyihy.com:hy/hyapp-admin-platform into test
# Conflicts: # ops-center/src/configForm.js # src/app/permissions.ts
This commit is contained in:
commit
379703cd76
@ -390,6 +390,86 @@
|
|||||||
"x-permissions": ["lucky-gift:view"]
|
"x-permissions": ["lucky-gift:view"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/admin/ops-center/lucky-gifts/pools/credit": {
|
||||||
|
"post": {
|
||||||
|
"operationId": "creditLuckyGiftPool",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"in": "query",
|
||||||
|
"name": "app_code",
|
||||||
|
"required": true,
|
||||||
|
"schema": { "type": "string", "minLength": 1, "maxLength": 32 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"in": "query",
|
||||||
|
"name": "pool_id",
|
||||||
|
"required": true,
|
||||||
|
"schema": { "type": "string", "minLength": 1, "maxLength": 96 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"in": "query",
|
||||||
|
"name": "strategy_version",
|
||||||
|
"required": true,
|
||||||
|
"schema": { "type": "string", "enum": ["fixed_v2", "dynamic_v3"] }
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": { "$ref": "#/components/schemas/LuckyGiftPoolAdjustmentInput" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": { "$ref": "#/components/responses/LuckyGiftPoolAdjustmentResponse" },
|
||||||
|
"400": { "description": "调整参数不正确" },
|
||||||
|
"409": { "description": "幂等键冲突或可用水位不足" }
|
||||||
|
},
|
||||||
|
"x-permission": "lucky-gift:pool-credit",
|
||||||
|
"x-permissions": ["lucky-gift:pool-credit"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/admin/ops-center/lucky-gifts/pools/debit": {
|
||||||
|
"post": {
|
||||||
|
"operationId": "debitLuckyGiftPool",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"in": "query",
|
||||||
|
"name": "app_code",
|
||||||
|
"required": true,
|
||||||
|
"schema": { "type": "string", "minLength": 1, "maxLength": 32 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"in": "query",
|
||||||
|
"name": "pool_id",
|
||||||
|
"required": true,
|
||||||
|
"schema": { "type": "string", "minLength": 1, "maxLength": 96 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"in": "query",
|
||||||
|
"name": "strategy_version",
|
||||||
|
"required": true,
|
||||||
|
"schema": { "type": "string", "enum": ["fixed_v2", "dynamic_v3"] }
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": { "$ref": "#/components/schemas/LuckyGiftPoolAdjustmentInput" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": { "$ref": "#/components/responses/LuckyGiftPoolAdjustmentResponse" },
|
||||||
|
"400": { "description": "调整参数不正确" },
|
||||||
|
"409": { "description": "幂等键冲突或可用水位不足" }
|
||||||
|
},
|
||||||
|
"x-permission": "lucky-gift:pool-debit",
|
||||||
|
"x-permissions": ["lucky-gift:pool-debit"]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/admin/activity/wheel/config": {
|
"/admin/activity/wheel/config": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "getWheelConfig",
|
"operationId": "getWheelConfig",
|
||||||
@ -9000,6 +9080,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"LuckyGiftPoolAdjustmentResponse": {
|
||||||
|
"description": "奖池水位调整成功或同幂等请求的首次结果",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiResponseLuckyGiftPoolAdjustmentResult"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"RechargeBillPageResponse": {
|
"RechargeBillPageResponse": {
|
||||||
"description": "OK",
|
"description": "OK",
|
||||||
"content": {
|
"content": {
|
||||||
@ -9412,6 +9502,115 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"schemas": {
|
"schemas": {
|
||||||
|
"LuckyGiftPoolAdjustmentInput": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["adjustment_id", "amount_coins", "reason"],
|
||||||
|
"properties": {
|
||||||
|
"adjustment_id": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"maxLength": 128
|
||||||
|
},
|
||||||
|
"amount_coins": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int64",
|
||||||
|
"minimum": 1
|
||||||
|
},
|
||||||
|
"reason": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"maxLength": 255
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"LuckyGiftPoolBalance": {
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"app_code",
|
||||||
|
"pool_id",
|
||||||
|
"strategy_version",
|
||||||
|
"balance",
|
||||||
|
"reserve_floor",
|
||||||
|
"available_balance",
|
||||||
|
"total_in",
|
||||||
|
"total_out",
|
||||||
|
"manual_credit_total",
|
||||||
|
"manual_debit_total",
|
||||||
|
"materialized",
|
||||||
|
"updated_at_ms"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"app_code": { "type": "string" },
|
||||||
|
"pool_id": { "type": "string" },
|
||||||
|
"strategy_version": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["fixed_v2", "dynamic_v3"]
|
||||||
|
},
|
||||||
|
"balance": { "type": "integer", "format": "int64" },
|
||||||
|
"reserve_floor": { "type": "integer", "format": "int64" },
|
||||||
|
"available_balance": { "type": "integer", "format": "int64" },
|
||||||
|
"total_in": { "type": "integer", "format": "int64" },
|
||||||
|
"total_out": { "type": "integer", "format": "int64" },
|
||||||
|
"manual_credit_total": { "type": "integer", "format": "int64" },
|
||||||
|
"manual_debit_total": { "type": "integer", "format": "int64" },
|
||||||
|
"materialized": { "type": "boolean" },
|
||||||
|
"updated_at_ms": { "type": "integer", "format": "int64" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"LuckyGiftPoolAdjustment": {
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"adjustment_id",
|
||||||
|
"app_code",
|
||||||
|
"pool_id",
|
||||||
|
"strategy_version",
|
||||||
|
"direction",
|
||||||
|
"amount_coins",
|
||||||
|
"reason",
|
||||||
|
"operator_admin_id",
|
||||||
|
"balance_before",
|
||||||
|
"balance_after",
|
||||||
|
"created_at_ms"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"adjustment_id": { "type": "string" },
|
||||||
|
"app_code": { "type": "string" },
|
||||||
|
"pool_id": { "type": "string" },
|
||||||
|
"strategy_version": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["fixed_v2", "dynamic_v3"]
|
||||||
|
},
|
||||||
|
"direction": { "type": "string", "enum": ["in", "out"] },
|
||||||
|
"amount_coins": { "type": "integer", "format": "int64" },
|
||||||
|
"reason": { "type": "string" },
|
||||||
|
"operator_admin_id": { "type": "integer", "format": "int64" },
|
||||||
|
"balance_before": { "type": "integer", "format": "int64" },
|
||||||
|
"balance_after": { "type": "integer", "format": "int64" },
|
||||||
|
"created_at_ms": { "type": "integer", "format": "int64" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"LuckyGiftPoolAdjustmentResult": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["adjustment", "pool", "idempotent_replay"],
|
||||||
|
"properties": {
|
||||||
|
"adjustment": { "$ref": "#/components/schemas/LuckyGiftPoolAdjustment" },
|
||||||
|
"pool": { "$ref": "#/components/schemas/LuckyGiftPoolBalance" },
|
||||||
|
"idempotent_replay": { "type": "boolean" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ApiResponseLuckyGiftPoolAdjustmentResult": {
|
||||||
|
"allOf": [
|
||||||
|
{ "$ref": "#/components/schemas/Envelope" },
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"requestId": { "type": "string" },
|
||||||
|
"data": { "$ref": "#/components/schemas/LuckyGiftPoolAdjustmentResult" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
"ApiResponseAdminAppList": {
|
"ApiResponseAdminAppList": {
|
||||||
"allOf": [
|
"allOf": [
|
||||||
{
|
{
|
||||||
|
|||||||
@ -4,17 +4,20 @@ import CasinoOutlined from "@mui/icons-material/CasinoOutlined";
|
|||||||
import InsightsOutlined from "@mui/icons-material/InsightsOutlined";
|
import InsightsOutlined from "@mui/icons-material/InsightsOutlined";
|
||||||
import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
|
import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { PERMISSIONS } from "@/app/permissions";
|
||||||
import { Button } from "@/shared/ui/Button.jsx";
|
import { Button } from "@/shared/ui/Button.jsx";
|
||||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||||
import { PageHead } from "@/shared/ui/PageHead.jsx";
|
import { PageHead } from "@/shared/ui/PageHead.jsx";
|
||||||
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||||
import { DEFAULT_POOL_ID, fetchOpsDashboard, saveLuckyGiftConfig } from "./api.js";
|
import { adjustLuckyGiftPool, DEFAULT_POOL_ID, fetchOpsDashboard, saveLuckyGiftConfig } from "./api.js";
|
||||||
import { AppsView } from "./components/AppsView.jsx";
|
import { AppsView } from "./components/AppsView.jsx";
|
||||||
import { ConfigsView } from "./components/ConfigsView.jsx";
|
import { ConfigsView } from "./components/ConfigsView.jsx";
|
||||||
import { DrawsView } from "./components/DrawsView.jsx";
|
import { DrawsView } from "./components/DrawsView.jsx";
|
||||||
import { LuckyGiftConfigDialog } from "./components/LuckyGiftConfigDialog.jsx";
|
import { LuckyGiftConfigDialog } from "./components/LuckyGiftConfigDialog.jsx";
|
||||||
|
import { LuckyGiftPoolAdjustmentDialog } from "./components/LuckyGiftPoolAdjustmentDialog.jsx";
|
||||||
import { OpsShell } from "./components/OpsShell.jsx";
|
import { OpsShell } from "./components/OpsShell.jsx";
|
||||||
import { OverviewView } from "./components/OverviewView.jsx";
|
import { OverviewView } from "./components/OverviewView.jsx";
|
||||||
|
import { formatNumber } from "./format.js";
|
||||||
|
|
||||||
const views = [
|
const views = [
|
||||||
{ icon: InsightsOutlined, key: "overview", label: "数据总览" },
|
{ icon: InsightsOutlined, key: "overview", label: "数据总览" },
|
||||||
@ -37,13 +40,20 @@ export function initialOpsCenterView(search = globalThis.location?.search || "")
|
|||||||
return viewKeys.has(requested) ? requested : "overview";
|
return viewKeys.has(requested) ? requested : "overview";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function OpsCenterApp() {
|
const denyPermission = () => false;
|
||||||
|
|
||||||
|
export function OpsCenterApp({ can = denyPermission }) {
|
||||||
// 主后台旧入口通过 ?view=configs 进入独立 HTML;只接受白名单视图,异常参数仍回到总览。
|
// 主后台旧入口通过 ?view=configs 进入独立 HTML;只接受白名单视图,异常参数仍回到总览。
|
||||||
const [activeView, setActiveView] = useState(() => initialOpsCenterView());
|
const [activeView, setActiveView] = useState(() => initialOpsCenterView());
|
||||||
const [state, setState] = useState({ data: initialDashboard, error: "", loaded: false, loading: true });
|
const [state, setState] = useState({ data: initialDashboard, error: "", loaded: false, loading: true });
|
||||||
const [dialog, setDialog] = useState(null);
|
const [dialog, setDialog] = useState(null);
|
||||||
|
const [poolDialog, setPoolDialog] = useState(null);
|
||||||
|
const [adjustingPool, setAdjustingPool] = useState(false);
|
||||||
const [savingConfig, setSavingConfig] = useState(false);
|
const [savingConfig, setSavingConfig] = useState(false);
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
|
const canCreditPool = can(PERMISSIONS.luckyGiftPoolCredit);
|
||||||
|
const canDebitPool = can(PERMISSIONS.luckyGiftPoolDebit);
|
||||||
|
const canUpdateConfig = can(PERMISSIONS.luckyGiftUpdate);
|
||||||
|
|
||||||
const loadDashboard = useCallback(async () => {
|
const loadDashboard = useCallback(async () => {
|
||||||
setState((current) => ({ ...current, error: "", loading: true }));
|
setState((current) => ({ ...current, error: "", loading: true }));
|
||||||
@ -124,6 +134,18 @@ export function OpsCenterApp() {
|
|||||||
setDialog({ config: structuredClone(config), mode: "edit" });
|
setDialog({ config: structuredClone(config), mode: "edit" });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const openPoolAdjustment = useCallback((pool, direction) => {
|
||||||
|
if (!pool) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 幂等键绑定一次弹窗会话,而不是一次 fetch;网络超时或 409 后弹窗保留,后续重试不会重复入账。
|
||||||
|
setPoolDialog({
|
||||||
|
adjustmentId: createPoolAdjustmentID(),
|
||||||
|
direction,
|
||||||
|
pool: structuredClone(pool),
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleSaveConfig = useCallback(
|
const handleSaveConfig = useCallback(
|
||||||
async (config) => {
|
async (config) => {
|
||||||
setSavingConfig(true);
|
setSavingConfig(true);
|
||||||
@ -142,6 +164,31 @@ export function OpsCenterApp() {
|
|||||||
[loadDashboard, showToast],
|
[loadDashboard, showToast],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleAdjustPool = useCallback(
|
||||||
|
async (request) => {
|
||||||
|
setAdjustingPool(true);
|
||||||
|
try {
|
||||||
|
const result = await adjustLuckyGiftPool(request);
|
||||||
|
const adjustment = result.adjustment || {};
|
||||||
|
const action = request.direction === "out" ? "扣减" : "添加";
|
||||||
|
showToast(
|
||||||
|
`已${action}水位 ${request.appCode} / ${request.poolId} / ${request.strategyVersion}:${formatNumber(
|
||||||
|
adjustment.balance_before,
|
||||||
|
)} → ${formatNumber(adjustment.balance_after)}`,
|
||||||
|
"success",
|
||||||
|
);
|
||||||
|
setPoolDialog(null);
|
||||||
|
await loadDashboard();
|
||||||
|
} catch (error) {
|
||||||
|
// owner 可能已经成功但响应丢失;保留表单与 adjustment_id,用户重试时由 owner 返回首次结果而不重复调整。
|
||||||
|
showToast(error.message || "调整奖池水位失败", "error");
|
||||||
|
} finally {
|
||||||
|
setAdjustingPool(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[loadDashboard, showToast],
|
||||||
|
);
|
||||||
|
|
||||||
const defaultAppCode = data.apps[0]?.app_code ?? data.apps[0]?.appCode ?? "";
|
const defaultAppCode = data.apps[0]?.app_code ?? data.apps[0]?.appCode ?? "";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -170,13 +217,29 @@ export function OpsCenterApp() {
|
|||||||
loading={state.loading && !state.loaded}
|
loading={state.loading && !state.loaded}
|
||||||
onRetry={loadDashboard}
|
onRetry={loadDashboard}
|
||||||
>
|
>
|
||||||
{activeView === "overview" ? <OverviewView data={data} /> : null}
|
{activeView === "overview" ? (
|
||||||
|
<OverviewView
|
||||||
|
canCredit={canCreditPool}
|
||||||
|
canDebit={canDebitPool}
|
||||||
|
data={data}
|
||||||
|
disabled={adjustingPool || state.loading}
|
||||||
|
onCredit={(pool) => openPoolAdjustment(pool, "in")}
|
||||||
|
onDebit={(pool) => openPoolAdjustment(pool, "out")}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
{activeView === "configs" ? (
|
{activeView === "configs" ? (
|
||||||
<ConfigsView
|
<ConfigsView
|
||||||
apps={data.apps}
|
apps={data.apps}
|
||||||
|
canCredit={canCreditPool}
|
||||||
|
canDebit={canDebitPool}
|
||||||
|
canUpdate={canUpdateConfig}
|
||||||
|
disabled={adjustingPool || state.loading}
|
||||||
luckyGifts={data.luckyGifts}
|
luckyGifts={data.luckyGifts}
|
||||||
|
poolBalances={data.poolBalances}
|
||||||
saving={savingConfig}
|
saving={savingConfig}
|
||||||
onCreate={openCreateConfig}
|
onCreate={openCreateConfig}
|
||||||
|
onCredit={(pool) => openPoolAdjustment(pool, "in")}
|
||||||
|
onDebit={(pool) => openPoolAdjustment(pool, "out")}
|
||||||
onEdit={openEditConfig}
|
onEdit={openEditConfig}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
@ -195,7 +258,27 @@ export function OpsCenterApp() {
|
|||||||
onSubmit={handleSaveConfig}
|
onSubmit={handleSaveConfig}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
{poolDialog ? (
|
||||||
|
<LuckyGiftPoolAdjustmentDialog
|
||||||
|
adjustmentId={poolDialog.adjustmentId}
|
||||||
|
direction={poolDialog.direction}
|
||||||
|
pool={poolDialog.pool}
|
||||||
|
submitting={adjustingPool}
|
||||||
|
onClose={() => setPoolDialog(null)}
|
||||||
|
onSubmit={handleAdjustPool}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</OpsShell>
|
</OpsShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let fallbackAdjustmentSequence = 0;
|
||||||
|
|
||||||
|
function createPoolAdjustmentID() {
|
||||||
|
if (typeof globalThis.crypto?.randomUUID === "function") {
|
||||||
|
return globalThis.crypto.randomUUID();
|
||||||
|
}
|
||||||
|
fallbackAdjustmentSequence += 1;
|
||||||
|
return `pool-adjust-${Date.now().toString(36)}-${fallbackAdjustmentSequence.toString(36)}`;
|
||||||
|
}
|
||||||
|
|||||||
@ -1,13 +1,17 @@
|
|||||||
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||||
import { beforeEach, expect, test, vi } from "vitest";
|
import { beforeEach, expect, test, vi } from "vitest";
|
||||||
|
import { PERMISSIONS } from "@/app/permissions";
|
||||||
import { ToastProvider } from "@/shared/ui/ToastProvider.jsx";
|
import { ToastProvider } from "@/shared/ui/ToastProvider.jsx";
|
||||||
import { fetchLuckyGiftDraws, fetchOpsDashboard, saveLuckyGiftConfig } from "./api.js";
|
import { adjustLuckyGiftPool, fetchLuckyGiftDraws, fetchOpsDashboard, saveLuckyGiftConfig } from "./api.js";
|
||||||
import { initialOpsCenterView, OpsCenterApp } from "./OpsCenterApp.jsx";
|
import { initialOpsCenterView, OpsCenterApp } from "./OpsCenterApp.jsx";
|
||||||
|
|
||||||
vi.mock("./api.js", () => ({
|
vi.mock("./api.js", () => ({
|
||||||
DEFAULT_POOL_ID: "default",
|
DEFAULT_POOL_ID: "default",
|
||||||
|
adjustLuckyGiftPool: vi.fn(),
|
||||||
fetchLuckyGiftDraws: vi.fn(),
|
fetchLuckyGiftDraws: vi.fn(),
|
||||||
fetchOpsDashboard: vi.fn(),
|
fetchOpsDashboard: vi.fn(),
|
||||||
|
luckyGiftPoolKey: (item = {}) =>
|
||||||
|
`${item.app_code || item.appCode}:${item.pool_id || item.poolId}:${item.strategy_version || item.strategyVersion || "fixed_v2"}`,
|
||||||
saveLuckyGiftConfig: vi.fn(),
|
saveLuckyGiftConfig: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@ -15,6 +19,10 @@ beforeEach(() => {
|
|||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
window.history.replaceState({}, "", "/");
|
window.history.replaceState({}, "", "/");
|
||||||
saveLuckyGiftConfig.mockResolvedValue({ app_code: "lalu", pool_id: "lucky" });
|
saveLuckyGiftConfig.mockResolvedValue({ app_code: "lalu", pool_id: "lucky" });
|
||||||
|
adjustLuckyGiftPool.mockResolvedValue({
|
||||||
|
adjustment: { balance_after: 39_559_017, balance_before: 38_559_017 },
|
||||||
|
idempotent_replay: false,
|
||||||
|
});
|
||||||
fetchLuckyGiftDraws.mockResolvedValue({
|
fetchLuckyGiftDraws.mockResolvedValue({
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
@ -62,6 +70,14 @@ beforeEach(() => {
|
|||||||
pool_id: "lucky",
|
pool_id: "lucky",
|
||||||
rule_version: 3,
|
rule_version: 3,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
...mockFixedConfig(95),
|
||||||
|
app_code: "lalu",
|
||||||
|
enabled: false,
|
||||||
|
is_default: false,
|
||||||
|
pool_id: "lucky",
|
||||||
|
rule_version: 2,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
...mockFixedConfig(92),
|
...mockFixedConfig(92),
|
||||||
app_code: "lalu",
|
app_code: "lalu",
|
||||||
@ -84,9 +100,24 @@ beforeEach(() => {
|
|||||||
app_code: "lalu",
|
app_code: "lalu",
|
||||||
available_balance: 37950017,
|
available_balance: 37950017,
|
||||||
balance: 38559017,
|
balance: 38559017,
|
||||||
|
manual_credit_total: 1_000_000,
|
||||||
|
manual_debit_total: 200_000,
|
||||||
materialized: true,
|
materialized: true,
|
||||||
pool_id: "lucky",
|
pool_id: "lucky",
|
||||||
reserve_floor: 609000,
|
reserve_floor: 609000,
|
||||||
|
strategy_version: "dynamic_v3",
|
||||||
|
updated_at_ms: 1767000000000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
app_code: "lalu",
|
||||||
|
available_balance: 0,
|
||||||
|
balance: 900,
|
||||||
|
manual_credit_total: 100,
|
||||||
|
manual_debit_total: 20,
|
||||||
|
materialized: true,
|
||||||
|
pool_id: "lucky",
|
||||||
|
reserve_floor: 900,
|
||||||
|
strategy_version: "fixed_v2",
|
||||||
updated_at_ms: 1767000000000,
|
updated_at_ms: 1767000000000,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -96,6 +127,7 @@ beforeEach(() => {
|
|||||||
materialized: false,
|
materialized: false,
|
||||||
pool_id: "default",
|
pool_id: "default",
|
||||||
reserve_floor: 21000,
|
reserve_floor: 21000,
|
||||||
|
strategy_version: "dynamic_v3",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
summary: {
|
summary: {
|
||||||
@ -115,10 +147,12 @@ beforeEach(() => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function renderApp() {
|
const allPermissions = [PERMISSIONS.luckyGiftPoolCredit, PERMISSIONS.luckyGiftPoolDebit, PERMISSIONS.luckyGiftUpdate];
|
||||||
|
|
||||||
|
function renderApp(permissions = allPermissions) {
|
||||||
return render(
|
return render(
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
<OpsCenterApp />
|
<OpsCenterApp can={(code) => permissions.includes(code)} />
|
||||||
</ToastProvider>,
|
</ToastProvider>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -137,7 +171,7 @@ test("renders overview with aggregated kpis and pool balances", async () => {
|
|||||||
expect(screen.getAllByText("抽奖次数").length).toBeGreaterThan(0);
|
expect(screen.getAllByText("抽奖次数").length).toBeGreaterThan(0);
|
||||||
expect(screen.getAllByText("38,941,517").length).toBeGreaterThan(0);
|
expect(screen.getAllByText("38,941,517").length).toBeGreaterThan(0);
|
||||||
expect(screen.getByText("奖池实时水位")).toBeTruthy();
|
expect(screen.getByText("奖池实时水位")).toBeTruthy();
|
||||||
expect(screen.getByText("已入账")).toBeTruthy();
|
expect(screen.getAllByText("已入账").length).toBeGreaterThan(1);
|
||||||
expect(screen.getByText("默认水位")).toBeTruthy();
|
expect(screen.getByText("默认水位")).toBeTruthy();
|
||||||
expect(fetchOpsDashboard).toHaveBeenCalledTimes(1);
|
expect(fetchOpsDashboard).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
@ -150,7 +184,7 @@ test("opens the requested config view from the legacy admin document redirect",
|
|||||||
renderApp();
|
renderApp();
|
||||||
|
|
||||||
expect(await screen.findByRole("button", { name: "添加配置" })).toBeTruthy();
|
expect(await screen.findByRole("button", { name: "添加配置" })).toBeTruthy();
|
||||||
expect(screen.getByText("lucky")).toBeTruthy();
|
expect(screen.getAllByText("lucky").length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("lists every pool config including published non-default pools", async () => {
|
test("lists every pool config including published non-default pools", async () => {
|
||||||
@ -160,15 +194,92 @@ test("lists every pool config including published non-default pools", async () =
|
|||||||
fireEvent.click(screen.getByRole("button", { name: /幸运礼物配置/ }));
|
fireEvent.click(screen.getByRole("button", { name: /幸运礼物配置/ }));
|
||||||
|
|
||||||
// 核心回归:lalu 的 lucky / super_lucky 已发布奖池必须出现在配置列表里。
|
// 核心回归:lalu 的 lucky / super_lucky 已发布奖池必须出现在配置列表里。
|
||||||
expect(await screen.findByText("lucky")).toBeTruthy();
|
expect((await screen.findAllByText("lucky")).length).toBeGreaterThan(1);
|
||||||
expect(screen.getByText("super_lucky")).toBeTruthy();
|
expect(screen.getByText("super_lucky")).toBeTruthy();
|
||||||
expect(screen.getByText("v3")).toBeTruthy();
|
expect(screen.getByText("v3")).toBeTruthy();
|
||||||
expect(screen.getByText("v2")).toBeTruthy();
|
expect(screen.getAllByText("v2").length).toBeGreaterThan(0);
|
||||||
expect(screen.getByText("已启用")).toBeTruthy();
|
expect(screen.getByText("已启用")).toBeTruthy();
|
||||||
expect(screen.getByText("已停用")).toBeTruthy();
|
expect(screen.getAllByText("已停用").length).toBeGreaterThan(1);
|
||||||
expect(screen.getByText("默认草稿")).toBeTruthy();
|
expect(screen.getByText("默认草稿")).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("shows strategy-specific balances and adjustment actions directly in the config list", async () => {
|
||||||
|
renderApp();
|
||||||
|
await screen.findByText("运营应用");
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /幸运礼物配置/ }));
|
||||||
|
|
||||||
|
expect(await screen.findByRole("button", { name: "添加水位 lalu / lucky / dynamic_v3" })).toBeTruthy();
|
||||||
|
expect(screen.getByRole("button", { name: "扣减水位 lalu / lucky / dynamic_v3" })).toBeEnabled();
|
||||||
|
expect(screen.getByRole("button", { name: "添加水位 lalu / lucky / fixed_v2" })).toBeTruthy();
|
||||||
|
expect(screen.getByRole("button", { name: "扣减水位 lalu / lucky / fixed_v2" })).toBeDisabled();
|
||||||
|
expect(screen.getByText("38,559,017")).toBeTruthy();
|
||||||
|
expect(screen.getByText("900")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shows credit and debit buttons only when the standalone session grants each permission", async () => {
|
||||||
|
renderApp([PERMISSIONS.luckyGiftPoolCredit]);
|
||||||
|
await screen.findByText("运营应用");
|
||||||
|
|
||||||
|
expect(screen.getByRole("button", { name: "添加水位 lalu / lucky / dynamic_v3" })).toBeTruthy();
|
||||||
|
expect(screen.queryByRole("button", { name: "扣减水位 lalu / lucky / dynamic_v3" })).toBeNull();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /幸运礼物配置/ }));
|
||||||
|
expect(screen.queryByRole("button", { name: "添加配置" })).toBeNull();
|
||||||
|
expect(screen.queryByRole("button", { name: /新增版本 lalu/ })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("submits a pool credit, reports before and after balances, closes, and refreshes", async () => {
|
||||||
|
renderApp();
|
||||||
|
await screen.findByText("运营应用");
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "添加水位 lalu / lucky / dynamic_v3" }));
|
||||||
|
|
||||||
|
const dialog = await screen.findByRole("dialog", { name: "添加奖池水位" });
|
||||||
|
expect(within(dialog).getByText("dynamic_v3")).toBeTruthy();
|
||||||
|
fireEvent.change(within(dialog).getByLabelText(/调整金币/), { target: { value: "1000000" } });
|
||||||
|
fireEvent.change(within(dialog).getByLabelText(/调整原因/), { target: { value: "运营补水" } });
|
||||||
|
fireEvent.click(within(dialog).getByRole("button", { name: "确认添加" }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(adjustLuckyGiftPool).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
adjustmentId: expect.any(String),
|
||||||
|
amountCoins: 1_000_000,
|
||||||
|
appCode: "lalu",
|
||||||
|
direction: "in",
|
||||||
|
poolId: "lucky",
|
||||||
|
reason: "运营补水",
|
||||||
|
strategyVersion: "dynamic_v3",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
expect(await screen.findByText(/38,559,017 → 39,559,017/)).toBeTruthy();
|
||||||
|
await waitFor(() => expect(screen.queryByRole("dialog", { name: "添加奖池水位" })).toBeNull());
|
||||||
|
expect(fetchOpsDashboard).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps the form and adjustment id stable when a debit request is retried", async () => {
|
||||||
|
adjustLuckyGiftPool.mockRejectedValueOnce(new Error("可用水位已变化")).mockResolvedValueOnce({
|
||||||
|
adjustment: { balance_after: 38_558_917, balance_before: 38_559_017 },
|
||||||
|
idempotent_replay: false,
|
||||||
|
});
|
||||||
|
renderApp();
|
||||||
|
await screen.findByText("运营应用");
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "扣减水位 lalu / lucky / dynamic_v3" }));
|
||||||
|
|
||||||
|
const dialog = await screen.findByRole("dialog", { name: "扣减奖池水位" });
|
||||||
|
fireEvent.change(within(dialog).getByLabelText(/调整金币/), { target: { value: "100" } });
|
||||||
|
fireEvent.change(within(dialog).getByLabelText(/调整原因/), { target: { value: "冲正" } });
|
||||||
|
fireEvent.click(within(dialog).getByRole("button", { name: "确认扣减" }));
|
||||||
|
|
||||||
|
expect(await screen.findByText("可用水位已变化")).toBeTruthy();
|
||||||
|
expect(within(dialog).getByLabelText(/调整金币/)).toHaveValue("100");
|
||||||
|
expect(within(dialog).getByLabelText(/调整原因/)).toHaveValue("冲正");
|
||||||
|
const firstAdjustmentID = adjustLuckyGiftPool.mock.calls[0][0].adjustmentId;
|
||||||
|
|
||||||
|
fireEvent.click(within(dialog).getByRole("button", { name: "确认扣减" }));
|
||||||
|
await waitFor(() => expect(adjustLuckyGiftPool).toHaveBeenCalledTimes(2));
|
||||||
|
expect(adjustLuckyGiftPool.mock.calls[1][0].adjustmentId).toBe(firstAdjustmentID);
|
||||||
|
});
|
||||||
|
|
||||||
test("creates a disabled dynamic draft without copying another app monetary limits", async () => {
|
test("creates a disabled dynamic draft without copying another app monetary limits", async () => {
|
||||||
renderApp();
|
renderApp();
|
||||||
await screen.findByText("运营应用");
|
await screen.findByText("运营应用");
|
||||||
@ -178,7 +289,7 @@ test("creates a disabled dynamic draft without copying another app monetary limi
|
|||||||
const dialog = await screen.findByRole("dialog");
|
const dialog = await screen.findByRole("dialog");
|
||||||
expect(within(dialog).getByText("添加幸运礼物配置")).toBeTruthy();
|
expect(within(dialog).getByText("添加幸运礼物配置")).toBeTruthy();
|
||||||
expect(within(dialog).getByLabelText("策略版本").textContent).toContain("dynamic_v3");
|
expect(within(dialog).getByLabelText("策略版本").textContent).toContain("dynamic_v3");
|
||||||
expect(within(dialog).getByLabelText(/初始奖池金币/).value).toBe("1000000");
|
expect(within(dialog).getByLabelText(/初始奖池金币/).value).toBe("0");
|
||||||
// 第一条 lalu 已发布配置的单次上限为 100 万;新建跨 App 配置必须归零并保持停用。
|
// 第一条 lalu 已发布配置的单次上限为 100 万;新建跨 App 配置必须归零并保持停用。
|
||||||
expect(within(dialog).getByLabelText(/单次返奖上限/).value).toBe("0");
|
expect(within(dialog).getByLabelText(/单次返奖上限/).value).toBe("0");
|
||||||
expect(within(dialog).getByText("发布后保持停用")).toBeTruthy();
|
expect(within(dialog).getByText("发布后保持停用")).toBeTruthy();
|
||||||
@ -189,15 +300,16 @@ test("opens config dialog from a published pool row and saves a new version", as
|
|||||||
await screen.findByText("运营应用");
|
await screen.findByText("运营应用");
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: /幸运礼物配置/ }));
|
fireEvent.click(screen.getByRole("button", { name: /幸运礼物配置/ }));
|
||||||
await screen.findByText("lucky");
|
await screen.findAllByText("lucky");
|
||||||
|
|
||||||
fireEvent.click(screen.getAllByRole("button", { name: "新增版本" })[0]);
|
fireEvent.click(screen.getByRole("button", { name: "新增版本 lalu / lucky / dynamic_v3" }));
|
||||||
const dialog = await screen.findByRole("dialog");
|
const dialog = await screen.findByRole("dialog");
|
||||||
expect(within(dialog).getByText("新增幸运礼物版本")).toBeTruthy();
|
expect(within(dialog).getByText("新增幸运礼物版本")).toBeTruthy();
|
||||||
expect(within(dialog).getByLabelText("策略版本").textContent).toContain("dynamic_v3");
|
expect(within(dialog).getByLabelText("策略版本").textContent).toContain("dynamic_v3");
|
||||||
expect(within(dialog).getByLabelText(/初始奖池金币/).value).toBe("1000000");
|
expect(within(dialog).getByLabelText(/初始奖池金币/).value).toBe("0");
|
||||||
|
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: "保存配置" }));
|
||||||
@ -207,6 +319,7 @@ test("opens config dialog from a published pool row and saves a new version", as
|
|||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
app_code: "lalu",
|
app_code: "lalu",
|
||||||
anchor_rate_percent: 1,
|
anchor_rate_percent: 1,
|
||||||
|
initial_pool_coins: 0,
|
||||||
jackpot_multipliers: [200, 500, 1000],
|
jackpot_multipliers: [200, 500, 1000],
|
||||||
pool_id: "lucky",
|
pool_id: "lucky",
|
||||||
profit_rate_percent: 1,
|
profit_rate_percent: 1,
|
||||||
@ -222,18 +335,18 @@ test("blocks an invalid enabled dynamic rule before calling the save API", async
|
|||||||
renderApp();
|
renderApp();
|
||||||
await screen.findByText("运营应用");
|
await screen.findByText("运营应用");
|
||||||
fireEvent.click(screen.getByRole("button", { name: /幸运礼物配置/ }));
|
fireEvent.click(screen.getByRole("button", { name: /幸运礼物配置/ }));
|
||||||
await screen.findByText("lucky");
|
await screen.findAllByText("lucky");
|
||||||
fireEvent.click(screen.getAllByRole("button", { name: "新增版本" })[0]);
|
fireEvent.click(screen.getByRole("button", { name: "新增版本 lalu / lucky / dynamic_v3" }));
|
||||||
|
|
||||||
const dialog = await screen.findByRole("dialog");
|
const dialog = await screen.findByRole("dialog");
|
||||||
fireEvent.change(within(dialog).getByLabelText(/初始奖池金币/), { target: { value: "0" } });
|
fireEvent.change(within(dialog).getByLabelText(/低水位金币/), { target: { value: "0" } });
|
||||||
fireEvent.click(within(dialog).getByRole("button", { name: "保存配置" }));
|
fireEvent.click(within(dialog).getByRole("button", { name: "保存配置" }));
|
||||||
|
|
||||||
expect(await within(dialog).findByRole("alert")).toHaveTextContent("初始奖池必须大于 0");
|
expect(await within(dialog).findByRole("alert")).toHaveTextContent("高水位必须大于正数低水位");
|
||||||
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();
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -247,7 +360,7 @@ function mockDynamicConfig() {
|
|||||||
gift_price_reference: 100,
|
gift_price_reference: 100,
|
||||||
high_water_nonzero_factor_percent: 130,
|
high_water_nonzero_factor_percent: 130,
|
||||||
high_watermark_coins: 20_000_000,
|
high_watermark_coins: 20_000_000,
|
||||||
initial_pool_coins: 1_000_000,
|
initial_pool_coins: 0,
|
||||||
jackpot_global_rtp_max_percent: 98,
|
jackpot_global_rtp_max_percent: 98,
|
||||||
jackpot_multipliers: [200, 500, 1000],
|
jackpot_multipliers: [200, 500, 1000],
|
||||||
jackpot_spend_threshold_coins: 50_000,
|
jackpot_spend_threshold_coins: 50_000,
|
||||||
|
|||||||
26
ops-center/src/OpsCenterEntry.jsx
Normal file
26
ops-center/src/OpsCenterEntry.jsx
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { useAuth } from "@/app/auth/AuthProvider.jsx";
|
||||||
|
import { buildLoginRedirectPath } from "@/features/auth/loginRedirect.js";
|
||||||
|
import { PageSkeleton } from "@/shared/ui/PageSkeleton.jsx";
|
||||||
|
import { OpsCenterApp } from "./OpsCenterApp.jsx";
|
||||||
|
|
||||||
|
export function AuthenticatedOpsCenter({ targetWindow = window }) {
|
||||||
|
const { can, loading, user } = useAuth();
|
||||||
|
const loginPath = buildLoginRedirectPath(targetWindow.location);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!loading && !user) {
|
||||||
|
// ops-center 是独立 HTML 入口,不能使用 SPA Navigate。登录页沿用主站既有 `redirect` 协议,
|
||||||
|
// 成功后 LoginPage 会识别 /ops-center/ 为 document entry,再通过 location.replace 回到完整路径。
|
||||||
|
targetWindow.location.replace(loginPath);
|
||||||
|
}
|
||||||
|
}, [loading, loginPath, targetWindow, user]);
|
||||||
|
|
||||||
|
if (loading || !user) {
|
||||||
|
// AuthProvider 可能正在用 refresh cookie 更新过期 token,完成前不能挂载 OpsCenterApp;
|
||||||
|
// 否则 dashboard 的并发请求会先拿旧 token 返回 401,且认证完成后没有新的挂载时机自动恢复。
|
||||||
|
return <PageSkeleton />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <OpsCenterApp can={can} />;
|
||||||
|
}
|
||||||
100
ops-center/src/OpsCenterEntry.test.jsx
Normal file
100
ops-center/src/OpsCenterEntry.test.jsx
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
import { act, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { beforeEach, expect, test, vi } from "vitest";
|
||||||
|
import { AuthProvider } from "@/app/auth/AuthProvider.jsx";
|
||||||
|
import * as authApi from "@/features/auth/api";
|
||||||
|
import { setAccessToken, setRefreshHandler, setUnauthorizedHandler } from "@/shared/api/request";
|
||||||
|
import { AuthenticatedOpsCenter } from "./OpsCenterEntry.jsx";
|
||||||
|
|
||||||
|
vi.mock("@/features/auth/api", () => ({
|
||||||
|
getMe: vi.fn(),
|
||||||
|
login: vi.fn(),
|
||||||
|
logout: vi.fn(),
|
||||||
|
refreshSession: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./OpsCenterApp.jsx", () => ({
|
||||||
|
OpsCenterApp: ({ can }) => (
|
||||||
|
<div data-can-credit={String(can("lucky-gift:pool-credit"))} data-testid="ops-center-app" />
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
setAccessToken("");
|
||||||
|
setRefreshHandler(null);
|
||||||
|
setUnauthorizedHandler(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not mount the dashboard without a session and redirects to login with the full ops path", async () => {
|
||||||
|
authApi.refreshSession.mockRejectedValue(new Error("no session"));
|
||||||
|
const targetWindow = fakeWindow("/ops-center/", "?view=configs", "#dynamic_v3");
|
||||||
|
|
||||||
|
render(
|
||||||
|
<AuthProvider>
|
||||||
|
<AuthenticatedOpsCenter targetWindow={targetWindow} />
|
||||||
|
</AuthProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// refresh 尚未结束以及确定无用户后的跳转窗口都只展示骨架,Dashboard 不会发出任何业务请求。
|
||||||
|
expect(screen.queryByTestId("ops-center-app")).toBeNull();
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(targetWindow.location.replace).toHaveBeenCalledWith(
|
||||||
|
"/login?redirect=%2Fops-center%2F%3Fview%3Dconfigs%23dynamic_v3",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
expect(screen.queryByTestId("ops-center-app")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("waits for stale-token refresh fallback before mounting the dashboard", async () => {
|
||||||
|
const refresh = deferred();
|
||||||
|
const me = deferred();
|
||||||
|
setAccessToken("stale-token");
|
||||||
|
authApi.refreshSession.mockReturnValue(refresh.promise);
|
||||||
|
authApi.getMe.mockReturnValue(me.promise);
|
||||||
|
const targetWindow = fakeWindow("/ops-center/", "?view=overview", "");
|
||||||
|
|
||||||
|
render(
|
||||||
|
<AuthProvider>
|
||||||
|
<AuthenticatedOpsCenter targetWindow={targetWindow} />
|
||||||
|
</AuthProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByTestId("ops-center-app")).toBeNull();
|
||||||
|
await act(async () => {
|
||||||
|
refresh.reject(new Error("refresh cookie expired"));
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(authApi.getMe).toHaveBeenCalledTimes(1));
|
||||||
|
expect(screen.queryByTestId("ops-center-app")).toBeNull();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
me.resolve({
|
||||||
|
accessToken: "fresh-token",
|
||||||
|
permissions: ["lucky-gift:pool-credit"],
|
||||||
|
user: { id: 1, username: "ops" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await screen.findByTestId("ops-center-app")).toHaveAttribute("data-can-credit", "true");
|
||||||
|
expect(targetWindow.location.replace).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
function fakeWindow(pathname, search, hash) {
|
||||||
|
return {
|
||||||
|
location: {
|
||||||
|
hash,
|
||||||
|
pathname,
|
||||||
|
replace: vi.fn(),
|
||||||
|
search,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function deferred() {
|
||||||
|
let resolve;
|
||||||
|
let reject;
|
||||||
|
const promise = new Promise((resolvePromise, rejectPromise) => {
|
||||||
|
resolve = resolvePromise;
|
||||||
|
reject = rejectPromise;
|
||||||
|
});
|
||||||
|
return { promise, reject, resolve };
|
||||||
|
}
|
||||||
@ -1,4 +1,5 @@
|
|||||||
import { getAccessToken } from "@/shared/api/request";
|
import { API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints";
|
||||||
|
import { apiRequest } from "@/shared/api/request";
|
||||||
|
|
||||||
export const OPS_API_PREFIX = "/api/v1/admin/ops-center";
|
export const OPS_API_PREFIX = "/api/v1/admin/ops-center";
|
||||||
export const DEFAULT_POOL_ID = "default";
|
export const DEFAULT_POOL_ID = "default";
|
||||||
@ -87,20 +88,50 @@ export async function saveLuckyGiftConfig(config = {}) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function opsRequest(path, { body, method = body ? "POST" : "GET", query } = {}) {
|
export async function adjustLuckyGiftPool({
|
||||||
const response = await fetch(buildOpsUrl(path, query), {
|
adjustmentId,
|
||||||
body: body === undefined ? undefined : JSON.stringify(body),
|
amountCoins,
|
||||||
credentials: "include",
|
appCode,
|
||||||
headers: buildHeaders(body),
|
direction,
|
||||||
method,
|
poolId,
|
||||||
});
|
reason,
|
||||||
const payload = await readPayload(response);
|
strategyVersion,
|
||||||
|
} = {}) {
|
||||||
if (!response.ok || payload.code !== 0) {
|
if (direction !== "in" && direction !== "out") {
|
||||||
throw new Error(payload.message || response.statusText || "运营接口请求失败");
|
throw new Error("奖池水位调整方向不正确");
|
||||||
}
|
}
|
||||||
|
const operation = direction === "out" ? API_OPERATIONS.debitLuckyGiftPool : API_OPERATIONS.creditLuckyGiftPool;
|
||||||
|
const operationPath = apiEndpointPath(operation);
|
||||||
|
|
||||||
return payload.data ?? {};
|
// adjustment_id 是 owner 侧资金变更的幂等键,同一个弹窗失败重试必须原样复用;request_id 只做链路追踪,
|
||||||
|
// 不能替代业务幂等。方向映射到两条独立路由,使前后端权限也能按补水、扣水分别收敛。
|
||||||
|
const payload = await opsRequest(opsRelativePath(operationPath), {
|
||||||
|
body: {
|
||||||
|
adjustment_id: String(adjustmentId || "").trim(),
|
||||||
|
amount_coins: numberValue(amountCoins),
|
||||||
|
reason: String(reason || "").trim(),
|
||||||
|
},
|
||||||
|
method: "POST",
|
||||||
|
query: {
|
||||||
|
app_code: String(appCode || "")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase(),
|
||||||
|
pool_id: String(poolId || DEFAULT_POOL_ID).trim() || DEFAULT_POOL_ID,
|
||||||
|
strategy_version: normalizeStrategyVersion(strategyVersion),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return normalizePoolAdjustmentResult(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function opsRequest(path, { body, method = body ? "POST" : "GET", query } = {}) {
|
||||||
|
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
|
||||||
|
// 复用主后台请求层才能共享 refreshOnce:运行期间 token 过期时先刷新再重放一次请求。
|
||||||
|
// 水位写接口携带稳定 adjustment_id,因此认证重放仍由 owner 幂等收敛;409 文案继续原样抛给弹窗保留表单。
|
||||||
|
return apiRequest(`/v1/admin/ops-center${normalizedPath}`, {
|
||||||
|
body,
|
||||||
|
method,
|
||||||
|
query,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildOpsUrl(path, query = {}) {
|
export function buildOpsUrl(path, query = {}) {
|
||||||
@ -118,16 +149,40 @@ export function buildOpsUrl(path, query = {}) {
|
|||||||
|
|
||||||
export function normalizeDashboard({ apps = [], perApp = [] } = {}) {
|
export function normalizeDashboard({ apps = [], perApp = [] } = {}) {
|
||||||
const luckyGifts = perApp.flatMap((entry) => entry.luckyGifts || []);
|
const luckyGifts = perApp.flatMap((entry) => entry.luckyGifts || []);
|
||||||
const poolBalances = perApp
|
const normalizedPoolBalances = perApp
|
||||||
.flatMap((entry) => entry.poolBalances || [])
|
.flatMap((entry) => (entry.poolBalances || []).map((item) => normalizePoolBalance(item, entry.appCode)))
|
||||||
.filter((item) => item?.app_code || item?.appCode);
|
.filter((item) => item.app_code);
|
||||||
const appSummaries = perApp
|
const appSummaries = perApp
|
||||||
.map((entry) => normalizeDrawSummary(entry.drawSummary, entry.appCode))
|
.map((entry) => normalizeDrawSummary(entry.drawSummary, entry.appCode))
|
||||||
.filter((summary) => summary.app_code);
|
.filter((summary) => summary.app_code);
|
||||||
|
|
||||||
const publishedConfigs = luckyGifts.filter((config) => !config.is_default);
|
const publishedConfigs = luckyGifts.filter((config) => !config.is_default);
|
||||||
|
const currentConfigByPool = new Map();
|
||||||
|
luckyGifts.forEach((config) => {
|
||||||
|
const familyKey = luckyGiftPoolFamilyKey(config);
|
||||||
|
const ruleVersion = numberValue(config.rule_version ?? config.ruleVersion);
|
||||||
|
const current = currentConfigByPool.get(familyKey);
|
||||||
|
// 正常接口只返回每个奖池的最新版本;这里仍按最大 rule_version 收敛,避免兼容接口返回多版本时
|
||||||
|
// 由数组顺序把旧策略误判为当前。版本相同则保留首条,确保结果不随返回顺序抖动。
|
||||||
|
if (!current || ruleVersion > current.ruleVersion) {
|
||||||
|
currentConfigByPool.set(familyKey, {
|
||||||
|
ruleVersion,
|
||||||
|
strategyVersion: normalizeStrategyVersion(config.strategy_version ?? config.strategyVersion),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// 账本接口会同时返回同一 app+pool 的 V2/V3 独立水位;最新规则配置才决定哪口是当前奖池。
|
||||||
|
// 没有配置可关联的孤立账本继续按当前展示,避免前端误把仍需运营处理的资金静默藏进历史筛选。
|
||||||
|
const poolBalances = normalizedPoolBalances.map((pool) => {
|
||||||
|
const currentStrategy = currentConfigByPool.get(luckyGiftPoolFamilyKey(pool))?.strategyVersion;
|
||||||
|
return {
|
||||||
|
...pool,
|
||||||
|
is_historical: Boolean(currentStrategy && currentStrategy !== pool.strategy_version),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const currentPoolBalances = poolBalances.filter((pool) => !pool.is_historical);
|
||||||
|
|
||||||
// KPI 一律跨应用聚合。旧版只取第一个应用的汇总,导致 lalu 有大量抽奖时首屏"抽奖次数"仍显示 0。
|
// KPI 一律跨应用聚合;奖池金额只汇总当前策略,避免历史 V2 与当前 V3 的独立账本重复计入运营水位。
|
||||||
const summary = {
|
const summary = {
|
||||||
activeApps: apps.length,
|
activeApps: apps.length,
|
||||||
configuredPools: publishedConfigs.length,
|
configuredPools: publishedConfigs.length,
|
||||||
@ -135,9 +190,9 @@ export function normalizeDashboard({ apps = [], perApp = [] } = {}) {
|
|||||||
failedDraws: sumBy(appSummaries, "failed_draws"),
|
failedDraws: sumBy(appSummaries, "failed_draws"),
|
||||||
grantedDraws: sumBy(appSummaries, "granted_draws"),
|
grantedDraws: sumBy(appSummaries, "granted_draws"),
|
||||||
pendingDraws: sumBy(appSummaries, "pending_draws"),
|
pendingDraws: sumBy(appSummaries, "pending_draws"),
|
||||||
poolAvailableTotal: sumBy(poolBalances, "available_balance", "availableBalance"),
|
poolAvailableTotal: sumBy(currentPoolBalances, "available_balance", "availableBalance"),
|
||||||
poolBalanceTotal: sumBy(poolBalances, "balance", "Balance"),
|
poolBalanceTotal: sumBy(currentPoolBalances, "balance", "Balance"),
|
||||||
poolReserveTotal: sumBy(poolBalances, "reserve_floor", "reserveFloor"),
|
poolReserveTotal: sumBy(currentPoolBalances, "reserve_floor", "reserveFloor"),
|
||||||
totalDraws: sumBy(appSummaries, "total_draws"),
|
totalDraws: sumBy(appSummaries, "total_draws"),
|
||||||
totalRewardCoins: sumBy(appSummaries, "total_reward_coins"),
|
totalRewardCoins: sumBy(appSummaries, "total_reward_coins"),
|
||||||
totalSpentCoins: sumBy(appSummaries, "total_spent_coins"),
|
totalSpentCoins: sumBy(appSummaries, "total_spent_coins"),
|
||||||
@ -146,6 +201,45 @@ export function normalizeDashboard({ apps = [], perApp = [] } = {}) {
|
|||||||
return { apps, appSummaries, luckyGifts, poolBalances, summary };
|
return { apps, appSummaries, luckyGifts, poolBalances, summary };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function normalizePoolBalance(pool = {}, fallbackAppCode = "") {
|
||||||
|
return {
|
||||||
|
...pool,
|
||||||
|
app_code: String(pool.app_code || pool.appCode || fallbackAppCode)
|
||||||
|
.trim()
|
||||||
|
.toLowerCase(),
|
||||||
|
available_balance: numberValue(pool.available_balance ?? pool.availableBalance),
|
||||||
|
balance: numberValue(pool.balance ?? pool.Balance),
|
||||||
|
manual_credit_total: numberValue(pool.manual_credit_total ?? pool.manualCreditTotal),
|
||||||
|
manual_debit_total: numberValue(pool.manual_debit_total ?? pool.manualDebitTotal),
|
||||||
|
materialized: Boolean(pool.materialized),
|
||||||
|
pool_id: String(pool.pool_id || pool.poolId || DEFAULT_POOL_ID).trim() || DEFAULT_POOL_ID,
|
||||||
|
reserve_floor: numberValue(pool.reserve_floor ?? pool.reserveFloor),
|
||||||
|
strategy_version: normalizeStrategyVersion(pool.strategy_version ?? pool.strategyVersion),
|
||||||
|
total_in: numberValue(pool.total_in ?? pool.totalIn),
|
||||||
|
total_out: numberValue(pool.total_out ?? pool.totalOut),
|
||||||
|
updated_at_ms: numberValue(pool.updated_at_ms ?? pool.updatedAtMs),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function luckyGiftPoolKey(item = {}) {
|
||||||
|
const appCode = String(item.app_code || item.appCode || "")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
const poolID = String(item.pool_id || item.poolId || DEFAULT_POOL_ID).trim() || DEFAULT_POOL_ID;
|
||||||
|
const strategyVersion = normalizeStrategyVersion(item.strategy_version ?? item.strategyVersion);
|
||||||
|
// V2/V3 资金由 owner 按策略版本隔离;任何列表关联或 React rowKey 都必须包含 strategy_version,
|
||||||
|
// 否则同一 app+pool 的两口奖池会在前端被覆盖成一行。
|
||||||
|
return `${appCode}:${poolID}:${strategyVersion}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function luckyGiftPoolFamilyKey(item = {}) {
|
||||||
|
const appCode = String(item.app_code || item.appCode || "")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
const poolID = String(item.pool_id || item.poolId || DEFAULT_POOL_ID).trim() || DEFAULT_POOL_ID;
|
||||||
|
return `${appCode}:${poolID}`;
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeDrawSummary(summary = {}, appCode = "") {
|
function normalizeDrawSummary(summary = {}, appCode = "") {
|
||||||
return {
|
return {
|
||||||
actual_rtp_ppm: numberValue(summary.actual_rtp_ppm ?? summary.actualRtpPpm),
|
actual_rtp_ppm: numberValue(summary.actual_rtp_ppm ?? summary.actualRtpPpm),
|
||||||
@ -192,12 +286,39 @@ function normalizeLuckyGiftConfig(config = {}, fallbackAppCode = "") {
|
|||||||
pool_id: config.pool_id || config.poolId || DEFAULT_POOL_ID,
|
pool_id: config.pool_id || config.poolId || DEFAULT_POOL_ID,
|
||||||
rule_version: ruleVersion,
|
rule_version: ruleVersion,
|
||||||
// 升级前规则没有 strategy_version,lucky-gift owner 明确按 fixed_v2 解释;列表和编辑表单必须沿用该兼容语义。
|
// 升级前规则没有 strategy_version,lucky-gift owner 明确按 fixed_v2 解释;列表和编辑表单必须沿用该兼容语义。
|
||||||
strategy_version: String(config.strategy_version || config.strategyVersion || "fixed_v2")
|
strategy_version: normalizeStrategyVersion(config.strategy_version ?? config.strategyVersion),
|
||||||
.trim()
|
|
||||||
.toLowerCase(),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizePoolAdjustmentResult(payload = {}) {
|
||||||
|
const adjustment = payload.adjustment || {};
|
||||||
|
return {
|
||||||
|
...payload,
|
||||||
|
adjustment: {
|
||||||
|
...adjustment,
|
||||||
|
amount_coins: numberValue(adjustment.amount_coins ?? adjustment.amountCoins),
|
||||||
|
balance_after: numberValue(adjustment.balance_after ?? adjustment.balanceAfter),
|
||||||
|
balance_before: numberValue(adjustment.balance_before ?? adjustment.balanceBefore),
|
||||||
|
strategy_version: normalizeStrategyVersion(adjustment.strategy_version ?? adjustment.strategyVersion),
|
||||||
|
},
|
||||||
|
pool: normalizePoolBalance(payload.pool || {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function opsRelativePath(operationPath) {
|
||||||
|
const prefix = "/v1/admin/ops-center";
|
||||||
|
if (!operationPath.startsWith(prefix)) {
|
||||||
|
throw new Error(`运营接口不属于 ops-center: ${operationPath}`);
|
||||||
|
}
|
||||||
|
return operationPath.slice(prefix.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeStrategyVersion(value) {
|
||||||
|
return String(value || "fixed_v2")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
function luckyGiftConfigPayload(config = {}) {
|
function luckyGiftConfigPayload(config = {}) {
|
||||||
const appCode = String(config.app_code || config.appCode || "")
|
const appCode = String(config.app_code || config.appCode || "")
|
||||||
.trim()
|
.trim()
|
||||||
@ -222,7 +343,9 @@ function luckyGiftConfigPayload(config = {}) {
|
|||||||
config.high_water_nonzero_factor_percent ?? config.highWaterNonzeroFactorPercent,
|
config.high_water_nonzero_factor_percent ?? config.highWaterNonzeroFactorPercent,
|
||||||
),
|
),
|
||||||
high_watermark_coins: numberValue(config.high_watermark_coins ?? config.highWatermarkCoins),
|
high_watermark_coins: numberValue(config.high_watermark_coins ?? config.highWatermarkCoins),
|
||||||
initial_pool_coins: numberValue(config.initial_pool_coins ?? config.initialPoolCoins),
|
// dynamic_v3 已改为通过带审计的 pool credit 接口注资;即使旧表单快照仍带 1m,也不能在发布新版本时继续隐式 seed。
|
||||||
|
initial_pool_coins:
|
||||||
|
strategyVersion === "dynamic_v3" ? 0 : numberValue(config.initial_pool_coins ?? config.initialPoolCoins),
|
||||||
jackpot_global_rtp_max_percent: numberValue(
|
jackpot_global_rtp_max_percent: numberValue(
|
||||||
config.jackpot_global_rtp_max_percent ?? config.jackpotGlobalRTPMaxPercent,
|
config.jackpot_global_rtp_max_percent ?? config.jackpotGlobalRTPMaxPercent,
|
||||||
),
|
),
|
||||||
@ -230,11 +353,21 @@ function luckyGiftConfigPayload(config = {}) {
|
|||||||
jackpot_spend_threshold_coins: numberValue(
|
jackpot_spend_threshold_coins: numberValue(
|
||||||
config.jackpot_spend_threshold_coins ?? config.jackpotSpendThresholdCoins,
|
config.jackpot_spend_threshold_coins ?? config.jackpotSpendThresholdCoins,
|
||||||
),
|
),
|
||||||
jackpot_user_72h_rtp_max_percent: numberValue(
|
jackpot_user_48h_rtp_max_percent: numberValue(
|
||||||
config.jackpot_user_72h_rtp_max_percent ?? config.jackpotUser72hRTPMaxPercent,
|
config.jackpot_user_48h_rtp_max_percent ??
|
||||||
|
config.jackpotUser48hRTPMaxPercent ??
|
||||||
|
config.jackpotUser48hRtpMaxPercent ??
|
||||||
|
config.jackpot_user_72h_rtp_max_percent ??
|
||||||
|
config.jackpotUser72hRTPMaxPercent ??
|
||||||
|
config.jackpotUser72hRtpMaxPercent,
|
||||||
),
|
),
|
||||||
jackpot_user_day_rtp_max_percent: numberValue(
|
jackpot_user_round_rtp_max_percent: numberValue(
|
||||||
config.jackpot_user_day_rtp_max_percent ?? config.jackpotUserDayRTPMaxPercent,
|
config.jackpot_user_round_rtp_max_percent ??
|
||||||
|
config.jackpotUserRoundRTPMaxPercent ??
|
||||||
|
config.jackpotUserRoundRtpMaxPercent ??
|
||||||
|
config.jackpot_user_day_rtp_max_percent ??
|
||||||
|
config.jackpotUserDayRTPMaxPercent ??
|
||||||
|
config.jackpotUserDayRtpMaxPercent,
|
||||||
),
|
),
|
||||||
loss_streak_guarantee: numberValue(config.loss_streak_guarantee ?? config.lossStreakGuarantee),
|
loss_streak_guarantee: numberValue(config.loss_streak_guarantee ?? config.lossStreakGuarantee),
|
||||||
low_water_nonzero_factor_percent: numberValue(
|
low_water_nonzero_factor_percent: numberValue(
|
||||||
@ -266,32 +399,6 @@ function luckyGiftConfigPayload(config = {}) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildHeaders(body) {
|
|
||||||
const headers = {};
|
|
||||||
const token = getAccessToken();
|
|
||||||
|
|
||||||
if (body !== undefined) {
|
|
||||||
headers["Content-Type"] = "application/json";
|
|
||||||
}
|
|
||||||
if (token) {
|
|
||||||
headers.Authorization = `Bearer ${token}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return headers;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function readPayload(response) {
|
|
||||||
const text = await response.text();
|
|
||||||
if (!text) {
|
|
||||||
return { code: response.ok ? 0 : response.status, data: {} };
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return JSON.parse(text);
|
|
||||||
} catch {
|
|
||||||
return { code: response.ok ? 0 : response.status, data: text, message: text };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeItems(value) {
|
function normalizeItems(value) {
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
return value;
|
return value;
|
||||||
|
|||||||
@ -1,8 +1,22 @@
|
|||||||
import { afterEach, expect, test, vi } from "vitest";
|
import { afterEach, expect, test, vi } from "vitest";
|
||||||
import { buildOpsUrl, fetchLuckyGiftDraws, fetchOpsDashboard, OPS_API_PREFIX, saveLuckyGiftConfig } from "./api.js";
|
import { setAccessToken, setRefreshHandler, setUnauthorizedHandler } from "@/shared/api/request";
|
||||||
|
import {
|
||||||
|
adjustLuckyGiftPool,
|
||||||
|
buildOpsUrl,
|
||||||
|
fetchLuckyGiftDraws,
|
||||||
|
fetchOpsDashboard,
|
||||||
|
luckyGiftPoolKey,
|
||||||
|
normalizeDashboard,
|
||||||
|
opsRequest,
|
||||||
|
OPS_API_PREFIX,
|
||||||
|
saveLuckyGiftConfig,
|
||||||
|
} from "./api.js";
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
|
setAccessToken("");
|
||||||
|
setRefreshHandler(null);
|
||||||
|
setUnauthorizedHandler(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
function jsonResponse(data) {
|
function jsonResponse(data) {
|
||||||
@ -19,6 +33,27 @@ test("builds ops api urls under the dedicated prefix", () => {
|
|||||||
expect(url).toBe("http://localhost:3000/api/v1/admin/ops-center/apps?app_code=lalu&status=active");
|
expect(url).toBe("http://localhost:3000/api/v1/admin/ops-center/apps?app_code=lalu&status=active");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("refreshes a stale access token before retrying an ops-center request", async () => {
|
||||||
|
setAccessToken("stale-token");
|
||||||
|
setRefreshHandler(async () => {
|
||||||
|
setAccessToken("fresh-token");
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
vi.spyOn(window, "fetch")
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
new Response(JSON.stringify({ code: 40100, message: "访问凭证已失效" }), {
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
status: 401,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mockResolvedValueOnce(jsonResponse({ items: [{ app_code: "lalu" }] }));
|
||||||
|
|
||||||
|
await expect(opsRequest("/apps")).resolves.toEqual({ items: [{ app_code: "lalu" }] });
|
||||||
|
expect(window.fetch).toHaveBeenCalledTimes(2);
|
||||||
|
expect(window.fetch.mock.calls[0][1].headers.Authorization).toBe("Bearer stale-token");
|
||||||
|
expect(window.fetch.mock.calls[1][1].headers.Authorization).toBe("Bearer fresh-token");
|
||||||
|
});
|
||||||
|
|
||||||
test("loads every pool config per app through the plural configs endpoint", async () => {
|
test("loads every pool config per app through the plural configs endpoint", async () => {
|
||||||
const calls = [];
|
const calls = [];
|
||||||
vi.spyOn(window, "fetch").mockImplementation(async (url) => {
|
vi.spyOn(window, "fetch").mockImplementation(async (url) => {
|
||||||
@ -208,12 +243,12 @@ test("saves lucky gift config through admin ops route", async () => {
|
|||||||
gift_price_reference: 100,
|
gift_price_reference: 100,
|
||||||
high_water_nonzero_factor_percent: 130,
|
high_water_nonzero_factor_percent: 130,
|
||||||
high_watermark_coins: 20_000_000,
|
high_watermark_coins: 20_000_000,
|
||||||
initial_pool_coins: 1_000_000,
|
initial_pool_coins: 0,
|
||||||
jackpot_global_rtp_max_percent: 98,
|
jackpot_global_rtp_max_percent: 98,
|
||||||
jackpot_multipliers: [200, 500, 1000],
|
jackpot_multipliers: [200, 500, 1000],
|
||||||
jackpot_spend_threshold_coins: 50_000,
|
jackpot_spend_threshold_coins: 50_000,
|
||||||
jackpot_user_72h_rtp_max_percent: 96,
|
jackpot_user_48h_rtp_max_percent: 96,
|
||||||
jackpot_user_day_rtp_max_percent: 96,
|
jackpot_user_round_rtp_max_percent: 96,
|
||||||
loss_streak_guarantee: 5,
|
loss_streak_guarantee: 5,
|
||||||
low_water_nonzero_factor_percent: 70,
|
low_water_nonzero_factor_percent: 70,
|
||||||
low_watermark_coins: 10_000_000,
|
low_watermark_coins: 10_000_000,
|
||||||
@ -250,3 +285,95 @@ test("saves lucky gift config through admin ops route", async () => {
|
|||||||
user_hourly_payout_cap: 34_200,
|
user_hourly_payout_cap: 34_200,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("keeps fixed_v2 and dynamic_v3 balances independent for the same app and pool", () => {
|
||||||
|
const data = normalizeDashboard({
|
||||||
|
apps: [{ app_code: "lalu" }],
|
||||||
|
perApp: [
|
||||||
|
{
|
||||||
|
appCode: "lalu",
|
||||||
|
poolBalances: [
|
||||||
|
{
|
||||||
|
availableBalance: 700,
|
||||||
|
balance: 900,
|
||||||
|
manualCreditTotal: 100,
|
||||||
|
manualDebitTotal: 20,
|
||||||
|
poolId: "lucky",
|
||||||
|
reserveFloor: 200,
|
||||||
|
strategyVersion: "fixed_v2",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
available_balance: 3_000,
|
||||||
|
balance: 4_000,
|
||||||
|
manual_credit_total: 2_000,
|
||||||
|
manual_debit_total: 500,
|
||||||
|
pool_id: "lucky",
|
||||||
|
reserve_floor: 1_000,
|
||||||
|
strategy_version: "dynamic_v3",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(data.poolBalances).toHaveLength(2);
|
||||||
|
expect(new Set(data.poolBalances.map(luckyGiftPoolKey))).toEqual(
|
||||||
|
new Set(["lalu:lucky:fixed_v2", "lalu:lucky:dynamic_v3"]),
|
||||||
|
);
|
||||||
|
expect(data.poolBalances[0]).toMatchObject({ manual_credit_total: 100, manual_debit_total: 20 });
|
||||||
|
expect(data.poolBalances[1]).toMatchObject({ manual_credit_total: 2_000, manual_debit_total: 500 });
|
||||||
|
expect(data.summary.poolBalanceTotal).toBe(4_900);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
["in", "credit"],
|
||||||
|
["out", "debit"],
|
||||||
|
])("posts %s pool adjustments with the strategy identity and idempotency key", async (direction, route) => {
|
||||||
|
const calls = [];
|
||||||
|
vi.spyOn(window, "fetch").mockImplementation(async (url, init) => {
|
||||||
|
calls.push({ body: JSON.parse(init.body), method: init.method, url: String(url) });
|
||||||
|
return jsonResponse({
|
||||||
|
adjustment: {
|
||||||
|
adjustment_id: "adjust-1",
|
||||||
|
amount_coins: 1_000,
|
||||||
|
balance_after: direction === "out" ? 9_000 : 11_000,
|
||||||
|
balance_before: 10_000,
|
||||||
|
strategy_version: "dynamic_v3",
|
||||||
|
},
|
||||||
|
idempotent_replay: false,
|
||||||
|
pool: {
|
||||||
|
app_code: "lalu",
|
||||||
|
available_balance: 8_000,
|
||||||
|
balance: direction === "out" ? 9_000 : 11_000,
|
||||||
|
manual_credit_total: direction === "in" ? 1_000 : 0,
|
||||||
|
manual_debit_total: direction === "out" ? 1_000 : 0,
|
||||||
|
materialized: true,
|
||||||
|
pool_id: "lucky",
|
||||||
|
reserve_floor: 1_000,
|
||||||
|
strategy_version: "dynamic_v3",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await adjustLuckyGiftPool({
|
||||||
|
adjustmentId: "adjust-1",
|
||||||
|
amountCoins: 1_000,
|
||||||
|
appCode: "LALU",
|
||||||
|
direction,
|
||||||
|
poolId: "lucky",
|
||||||
|
reason: " 运营调节 ",
|
||||||
|
strategyVersion: "dynamic_v3",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(calls).toEqual([
|
||||||
|
{
|
||||||
|
body: { adjustment_id: "adjust-1", amount_coins: 1_000, reason: "运营调节" },
|
||||||
|
method: "POST",
|
||||||
|
url: `http://localhost:3000/api/v1/admin/ops-center/lucky-gifts/pools/${route}?app_code=lalu&pool_id=lucky&strategy_version=dynamic_v3`,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
adjustment: { balance_before: 10_000, strategy_version: "dynamic_v3" },
|
||||||
|
pool: { app_code: "lalu", pool_id: "lucky", strategy_version: "dynamic_v3" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -4,10 +4,25 @@ import { AdminFilterSelect, AdminListToolbar } from "@/shared/ui/AdminListLayout
|
|||||||
import { Button } from "@/shared/ui/Button.jsx";
|
import { Button } from "@/shared/ui/Button.jsx";
|
||||||
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||||
import { TimeText } from "@/shared/ui/TimeText.jsx";
|
import { TimeText } from "@/shared/ui/TimeText.jsx";
|
||||||
|
import { luckyGiftPoolKey } from "../api.js";
|
||||||
import { formatNumber, formatPercent } from "../format.js";
|
import { formatNumber, formatPercent } from "../format.js";
|
||||||
|
import { LuckyGiftPoolActions } from "./LuckyGiftPoolActions.jsx";
|
||||||
import { OpsStatusBadge } from "./OpsStatusBadge.jsx";
|
import { OpsStatusBadge } from "./OpsStatusBadge.jsx";
|
||||||
|
|
||||||
export function ConfigsView({ apps, luckyGifts, onCreate, onEdit, saving }) {
|
export function ConfigsView({
|
||||||
|
apps,
|
||||||
|
canCredit,
|
||||||
|
canDebit,
|
||||||
|
canUpdate,
|
||||||
|
disabled,
|
||||||
|
luckyGifts,
|
||||||
|
onCreate,
|
||||||
|
onCredit,
|
||||||
|
onDebit,
|
||||||
|
onEdit,
|
||||||
|
poolBalances,
|
||||||
|
saving,
|
||||||
|
}) {
|
||||||
const [appFilter, setAppFilter] = useState("");
|
const [appFilter, setAppFilter] = useState("");
|
||||||
|
|
||||||
const appOptions = useMemo(
|
const appOptions = useMemo(
|
||||||
@ -19,25 +34,46 @@ export function ConfigsView({ apps, luckyGifts, onCreate, onEdit, saving }) {
|
|||||||
[apps],
|
[apps],
|
||||||
);
|
);
|
||||||
// 配置列表已在 dashboard 阶段跨应用拉全,应用筛选只做前端过滤,避免重复扇出请求。
|
// 配置列表已在 dashboard 阶段跨应用拉全,应用筛选只做前端过滤,避免重复扇出请求。
|
||||||
const items = useMemo(
|
const items = useMemo(() => {
|
||||||
() => (appFilter ? luckyGifts.filter((config) => config.app_code === appFilter) : luckyGifts),
|
const poolByKey = new Map(poolBalances.map((pool) => [luckyGiftPoolKey(pool), pool]));
|
||||||
[appFilter, luckyGifts],
|
const visibleConfigs = appFilter ? luckyGifts.filter((config) => config.app_code === appFilter) : luckyGifts;
|
||||||
);
|
// 余额必须按 app+pool+strategy 精确关联;同一个 app/pool 同时存在 fixed_v2 和 dynamic_v3 时,
|
||||||
|
// 任何缺少 strategy_version 的关联都会把两口独立资金池串行或覆盖。
|
||||||
|
return visibleConfigs.map((config) => ({
|
||||||
|
...config,
|
||||||
|
pool_balance: poolByKey.get(luckyGiftPoolKey(config)),
|
||||||
|
}));
|
||||||
|
}, [appFilter, luckyGifts, poolBalances]);
|
||||||
|
|
||||||
const columns = useMemo(() => buildColumns({ onEdit, saving }), [onEdit, saving]);
|
const columns = useMemo(
|
||||||
|
() =>
|
||||||
|
buildColumns({
|
||||||
|
canCredit,
|
||||||
|
canDebit,
|
||||||
|
canUpdate,
|
||||||
|
disabled,
|
||||||
|
onCredit,
|
||||||
|
onDebit,
|
||||||
|
onEdit,
|
||||||
|
saving,
|
||||||
|
}),
|
||||||
|
[canCredit, canDebit, canUpdate, disabled, onCredit, onDebit, onEdit, saving],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="ops-view">
|
<div className="ops-view">
|
||||||
<AdminListToolbar
|
<AdminListToolbar
|
||||||
actions={
|
actions={
|
||||||
<Button
|
canUpdate ? (
|
||||||
disabled={saving}
|
<Button
|
||||||
startIcon={<AddOutlined fontSize="small" />}
|
disabled={saving}
|
||||||
variant="primary"
|
startIcon={<AddOutlined fontSize="small" />}
|
||||||
onClick={onCreate}
|
variant="primary"
|
||||||
>
|
onClick={onCreate}
|
||||||
添加配置
|
>
|
||||||
</Button>
|
添加配置
|
||||||
|
</Button>
|
||||||
|
) : null
|
||||||
}
|
}
|
||||||
filters={
|
filters={
|
||||||
<AdminFilterSelect
|
<AdminFilterSelect
|
||||||
@ -52,8 +88,8 @@ export function ConfigsView({ apps, luckyGifts, onCreate, onEdit, saving }) {
|
|||||||
columns={columns}
|
columns={columns}
|
||||||
emptyLabel="暂无幸运礼物配置"
|
emptyLabel="暂无幸运礼物配置"
|
||||||
items={items}
|
items={items}
|
||||||
minWidth="1260px"
|
minWidth="1540px"
|
||||||
rowKey={(item) => `${item.app_code}:${item.pool_id}:${item.rule_version}`}
|
rowKey={(item) => `${luckyGiftPoolKey(item)}:${item.rule_version}`}
|
||||||
title="幸运礼物配置"
|
title="幸运礼物配置"
|
||||||
total={items.length}
|
total={items.length}
|
||||||
/>
|
/>
|
||||||
@ -61,8 +97,8 @@ export function ConfigsView({ apps, luckyGifts, onCreate, onEdit, saving }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildColumns({ onEdit, saving }) {
|
function buildColumns({ canCredit, canDebit, canUpdate, disabled, onCredit, onDebit, onEdit, saving }) {
|
||||||
return [
|
const columns = [
|
||||||
{ key: "app_code", label: "应用", width: "minmax(88px, 0.7fr)" },
|
{ key: "app_code", label: "应用", width: "minmax(88px, 0.7fr)" },
|
||||||
{ key: "pool_id", label: "奖池", width: "minmax(110px, 0.9fr)" },
|
{ key: "pool_id", label: "奖池", width: "minmax(110px, 0.9fr)" },
|
||||||
{
|
{
|
||||||
@ -79,6 +115,12 @@ function buildColumns({ onEdit, saving }) {
|
|||||||
render: (item) => item.strategy_version || "fixed_v2",
|
render: (item) => item.strategy_version || "fixed_v2",
|
||||||
width: "minmax(112px, 0.8fr)",
|
width: "minmax(112px, 0.8fr)",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "pool_balance",
|
||||||
|
label: "奖池余额",
|
||||||
|
render: (item) => (item.pool_balance ? formatNumber(item.pool_balance.balance) : "-"),
|
||||||
|
width: "minmax(120px, 0.9fr)",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: "enabled",
|
key: "enabled",
|
||||||
label: "状态",
|
label: "状态",
|
||||||
@ -115,14 +157,46 @@ function buildColumns({ onEdit, saving }) {
|
|||||||
render: (item) => (item.is_default ? "-" : <TimeText value={item.created_at_ms} />),
|
render: (item) => (item.is_default ? "-" : <TimeText value={item.created_at_ms} />),
|
||||||
width: "minmax(176px, 1.1fr)",
|
width: "minmax(176px, 1.1fr)",
|
||||||
},
|
},
|
||||||
{
|
];
|
||||||
|
|
||||||
|
if (canUpdate || canCredit || canDebit) {
|
||||||
|
columns.push({
|
||||||
|
fixed: "right",
|
||||||
key: "actions",
|
key: "actions",
|
||||||
label: "操作",
|
label: "操作",
|
||||||
render: (item) => (
|
render: (item) => {
|
||||||
<Button disabled={saving} size="small" onClick={() => onEdit(item)}>
|
const identity = `${item.app_code} / ${item.pool_id} / ${item.strategy_version}`;
|
||||||
{item.is_default ? "发布配置" : "新增版本"}
|
const actionPool = item.pool_balance || {
|
||||||
</Button>
|
app_code: item.app_code,
|
||||||
),
|
pool_id: item.pool_id,
|
||||||
},
|
strategy_version: item.strategy_version,
|
||||||
];
|
};
|
||||||
|
return (
|
||||||
|
<div className="ops-config-actions">
|
||||||
|
<LuckyGiftPoolActions
|
||||||
|
canCredit={canCredit}
|
||||||
|
canDebit={canDebit}
|
||||||
|
disabled={disabled}
|
||||||
|
pool={actionPool}
|
||||||
|
onCredit={onCredit}
|
||||||
|
onDebit={onDebit}
|
||||||
|
/>
|
||||||
|
{canUpdate ? (
|
||||||
|
<Button
|
||||||
|
aria-label={`${item.is_default ? "发布配置" : "新增版本"} ${identity}`}
|
||||||
|
disabled={saving}
|
||||||
|
size="small"
|
||||||
|
onClick={() => onEdit(item)}
|
||||||
|
>
|
||||||
|
{item.is_default ? "发布配置" : "新增版本"}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
width: `${(canCredit ? 110 : 0) + (canDebit ? 110 : 0) + (canUpdate ? 110 : 0) + 20}px`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return columns;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
@ -34,6 +29,13 @@ export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit",
|
|||||||
: form.stages[0]?.stage;
|
: form.stages[0]?.stage;
|
||||||
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);
|
||||||
|
const normalStageConfig = form.stages.find((stage) => stage.stage === "normal");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!validationErrors.length) return;
|
||||||
|
// 保存按钮位于固定底栏;失败后把焦点送回错误摘要,避免键盘或读屏用户停留在无变化的按钮上。
|
||||||
|
validationRef.current?.focus();
|
||||||
|
}, [validationErrors]);
|
||||||
|
|
||||||
const updateField = (field, value) => {
|
const updateField = (field, value) => {
|
||||||
setValidationErrors([]);
|
setValidationErrors([]);
|
||||||
@ -87,7 +89,8 @@ export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit",
|
|||||||
...stage.tiers,
|
...stage.tiers,
|
||||||
{
|
{
|
||||||
enabled: true,
|
enabled: true,
|
||||||
// 10 倍以上属于高倍档,默认只在奖池高水位时开放,防止新档位直接击穿保底线。
|
// 10 倍以上先写入服务端要求的高倍率安全标记;实际开奖仍以实时余额和返奖上限
|
||||||
|
// 判断是否付得起,前端默认值不能被解释成“高余额时一定会发”。
|
||||||
highWaterOnly: nextStageMultiplier(stage.tiers) >= 10,
|
highWaterOnly: nextStageMultiplier(stage.tiers) >= 10,
|
||||||
multiplier: String(nextStageMultiplier(stage.tiers)),
|
multiplier: String(nextStageMultiplier(stage.tiers)),
|
||||||
probabilityPercent: "0",
|
probabilityPercent: "0",
|
||||||
@ -129,31 +132,51 @@ 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="基础返还目标" 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={form.strategy_version === "dynamic_v3" ? "每轮观察流水" : "V2 窗口配置"}
|
||||||
|
value={formatNumber(Number(form.settlement_window_wager))}
|
||||||
|
/>
|
||||||
|
<ConfigMetric label="发布状态" value={form.enabled ? "启用" : "停用"} />
|
||||||
|
</dl>
|
||||||
|
<DialogContent className="ops-config-dialog-content" dividers>
|
||||||
{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 +187,28 @@ 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}
|
normalStageConfig={normalStageConfig}
|
||||||
onChange={(event) => updateField("app_code", event.target.value)}
|
stageSummaries={stageSummaries}
|
||||||
>
|
strategyVersion={form.strategy_version}
|
||||||
{appOptions.map(([value, label]) => (
|
onAddTier={addStageTier}
|
||||||
<MenuItem key={value} value={value}>
|
onRemoveTier={removeStageTier}
|
||||||
{label}
|
onStageChange={setActiveStage}
|
||||||
</MenuItem>
|
onStageFieldChange={updateStageField}
|
||||||
))}
|
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,129 +221,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 form={form} 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({ form, helperText, label, name, onChange, step = "1" }) {
|
|
||||||
return (
|
|
||||||
<TextField
|
|
||||||
helperText={helperText}
|
|
||||||
label={label}
|
|
||||||
required
|
|
||||||
size="small"
|
|
||||||
slotProps={{ htmlInput: { min: 0, step } }}
|
|
||||||
type="number"
|
|
||||||
value={form[name]}
|
|
||||||
onChange={(event) => onChange(name, event.target.value)}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
341
ops-center/src/components/LuckyGiftConfigDialog.test.jsx
Normal file
341
ops-center/src/components/LuckyGiftConfigDialog.test.jsx
Normal file
@ -0,0 +1,341 @@
|
|||||||
|
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();
|
||||||
|
expect(within(dialog).getByLabelText("新手阶段派生充值范围")).toHaveTextContent("7 日充值 < 0 或 30 日充值 < 1");
|
||||||
|
expect(
|
||||||
|
within(dialog).getByText("任一未达到即为新手;7 日与 30 日均达到门槛(含等于)后进入普通。"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(within(dialog).getByRole("tab", { name: /普通/ }));
|
||||||
|
expect(within(dialog).getByLabelText("7 日最低充值(金币)")).toHaveValue(0);
|
||||||
|
expect(within(dialog).getByLabelText("30 日最低充值(金币)")).toHaveValue(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("section navigation handles reduced motion and falls back after switching from dynamic_v3 to fixed_v2", async () => {
|
||||||
|
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`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
}
|
||||||
530
ops-center/src/components/LuckyGiftConfigSections.jsx
Normal file
530
ops-center/src/components/LuckyGiftConfigSections.jsx
Normal file
@ -0,0 +1,530 @@
|
|||||||
|
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";
|
||||||
|
import { LuckyGiftFieldLabel } from "./LuckyGiftFieldHelp.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={<LuckyGiftFieldLabel field="app_code" 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={<LuckyGiftFieldLabel field="app_code" label="应用" />}
|
||||||
|
size="small"
|
||||||
|
value={form.app_code}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
required
|
||||||
|
label={<LuckyGiftFieldLabel field="pool_id" label="奖池" />}
|
||||||
|
size="small"
|
||||||
|
value={form.pool_id}
|
||||||
|
onChange={(event) => onChange("pool_id", event.target.value)}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label={<LuckyGiftFieldLabel field="strategy_version" 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={
|
||||||
|
<LuckyGiftFieldLabel
|
||||||
|
field="enabled"
|
||||||
|
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="返奖与资金分配"
|
||||||
|
/>
|
||||||
|
<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="V2 折算参考单价"
|
||||||
|
name="gift_price_reference"
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
|
<NumberField
|
||||||
|
disabled={form.strategy_version === "dynamic_v3"}
|
||||||
|
form={form}
|
||||||
|
label="新手阶段上限(折算次数)"
|
||||||
|
name="novice_max_equivalent_draws"
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
|
<NumberField
|
||||||
|
disabled={form.strategy_version === "dynamic_v3"}
|
||||||
|
form={form}
|
||||||
|
label="普通阶段上限(折算次数)"
|
||||||
|
name="normal_max_equivalent_draws"
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="ops-field-note">
|
||||||
|
发布前请核对收礼对象收益与实际送礼返币比例;不一致时,V3 真实开奖会拒绝账务结算。
|
||||||
|
</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="大奖触发条件" />
|
||||||
|
<JackpotFlowSummary />
|
||||||
|
<div className="ops-form-grid">
|
||||||
|
<TextField
|
||||||
|
className="ops-form-grid__wide"
|
||||||
|
helperText="按从小到大填写,使用逗号分隔"
|
||||||
|
label={
|
||||||
|
<LuckyGiftFieldLabel field="jackpot_multipliers" 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="用户本轮返还触发线 (%)"
|
||||||
|
name="jackpot_user_round_rtp_max_percent"
|
||||||
|
step="0.01"
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
|
<NumberField
|
||||||
|
form={form}
|
||||||
|
label="用户近 48 小时返还触发线 (%)"
|
||||||
|
name="jackpot_user_48h_rtp_max_percent"
|
||||||
|
step="0.01"
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
|
<NumberField
|
||||||
|
form={form}
|
||||||
|
label="每位用户每日大奖次数"
|
||||||
|
name="max_jackpot_hits_per_user_day"
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
|
<NumberField
|
||||||
|
disabled
|
||||||
|
form={form}
|
||||||
|
helperText="仅兼容历史配置,当前规则不参与大奖资格"
|
||||||
|
label="用户日累计消费触发门槛(金币)— 已停用"
|
||||||
|
name="jackpot_spend_threshold_coins"
|
||||||
|
onChange={onChange}
|
||||||
|
required={false}
|
||||||
|
/>
|
||||||
|
</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 JackpotFlowSummary() {
|
||||||
|
return (
|
||||||
|
<div className="ops-jackpot-flow" aria-label="大奖产生方式">
|
||||||
|
<div>
|
||||||
|
<strong>1. 大盘先通过</strong>
|
||||||
|
<span>本轮大盘 RTP 不高于全局触发线,才继续检查用户;等于触发线也可通过。</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>2. 用户两项都通过</strong>
|
||||||
|
<span>用户本轮 RTP 和近 48 小时 RTP 都必须严格低于各自触发线,少一项都不能出大奖。</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>3. 下次送礼尝试大奖</strong>
|
||||||
|
<span>
|
||||||
|
用户下次送礼时,先剔除奖池或限额付不起的倍率,再随机选择;没有可支付倍率就不出大奖,本轮资格也不会顺延或重复使用。
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NumberField({ disabled = false, form, helperText, label, name, onChange, required = true, step = "1" }) {
|
||||||
|
return (
|
||||||
|
<TextField
|
||||||
|
disabled={disabled}
|
||||||
|
helperText={helperText}
|
||||||
|
label={<LuckyGiftFieldLabel field={name} label={label} />}
|
||||||
|
required={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;
|
||||||
|
}
|
||||||
286
ops-center/src/components/LuckyGiftFieldHelp.jsx
Normal file
286
ops-center/src/components/LuckyGiftFieldHelp.jsx
Normal file
@ -0,0 +1,286 @@
|
|||||||
|
import HelpOutlineOutlined from "@mui/icons-material/HelpOutlineOutlined";
|
||||||
|
import Tooltip from "@mui/material/Tooltip";
|
||||||
|
|
||||||
|
// 字段说明集中维护,避免同一参数在不同分区出现互相矛盾的运营口径。
|
||||||
|
// 每条都回答“控制什么、调大会怎样、发布时受什么限制”,Tooltip 只负责展示,不参与表单值或校验。
|
||||||
|
const FIELD_HELP = {
|
||||||
|
app_code: {
|
||||||
|
summary: "决定这套幸运礼物规则属于哪个 App。不同 App 的配置、抽奖记录和奖池余额彼此隔离。",
|
||||||
|
adjust: "选择错 App 会把新版本发布到另一套业务数据中;编辑已有配置时不能在这里迁移 App。",
|
||||||
|
constraint: "发布前确认应用与实际送礼入口一致。",
|
||||||
|
},
|
||||||
|
pool_id: {
|
||||||
|
summary: "决定礼物使用哪一组规则和哪一本奖池账。同一 App 内,送礼接口会按这个 ID 找到配置。",
|
||||||
|
adjust: "改成新 ID 会创建一组新的规则,不会搬走旧奖池里的金币,也不会改写旧抽奖记录。",
|
||||||
|
constraint: "fixed_v2 与 dynamic_v3 即使使用相同 ID,也会分别记账;ID 必须与礼物接入配置一致。",
|
||||||
|
},
|
||||||
|
strategy_version: {
|
||||||
|
summary:
|
||||||
|
"选择开奖方式。fixed_v2 保留旧版固定规则;dynamic_v3 会根据奖池余额、充值阶段、大奖条件和返奖上限动态调整。",
|
||||||
|
adjust: "切到 dynamic_v3 会载入安全草稿并保持停用,确认全部金额口径后再发布。",
|
||||||
|
constraint: "dynamic_v3 使用独立奖池且从 0 开始,发布规则不会自动注入金币。",
|
||||||
|
},
|
||||||
|
enabled: {
|
||||||
|
summary: "决定新版本发布后是否立即用于真实送礼。关闭时规则仍会保存,但不会触发幸运开奖。",
|
||||||
|
adjust: "首次配置或大幅调整时可先保持停用,核对资金比例和上限后再启用。",
|
||||||
|
constraint: "启用 dynamic_v3 前必须填完三项大奖 RTP 触发线和所有返奖上限。",
|
||||||
|
},
|
||||||
|
target_rtp_percent: {
|
||||||
|
summary: "表示基础奖档的配置目标。例如填 98,代表各阶段的“倍率 × 基础概率”预计返还约为 98%。",
|
||||||
|
adjust: "修改后,页面会重新分配各档基础概率;调高通常增加基础返奖,调低则相反。",
|
||||||
|
constraint:
|
||||||
|
"只能填 10%–99%,进入奖池的比例不能低于它。水位、充值加成、大奖和上限都会改变实际结果,它不是线上返还承诺。",
|
||||||
|
},
|
||||||
|
control_band_percent: {
|
||||||
|
summary: "允许每个用户阶段的奖档组合与平均返还目标存在多大偏差。",
|
||||||
|
adjust: "调大更容易通过发布,但实际返还可能离目标更远;调小更严格,也更难配置出可发布的倍率组合。",
|
||||||
|
constraint: "必须大于 0 且不超过 5 个百分点,不会强行补偿单次或单个用户的结果。",
|
||||||
|
},
|
||||||
|
pool_rate_percent: {
|
||||||
|
summary: "每笔幸运礼物消费中,有多少金币进入公共奖池,后续中奖从这里支付。",
|
||||||
|
adjust: "调高会让奖池补充更快;调低会留出更多平台或收礼份额,但奖池更容易不足。",
|
||||||
|
constraint: "不能低于平均返还目标;V3 中它与平台、收礼份额合计必须等于 100%,按整数金币向下取整。",
|
||||||
|
},
|
||||||
|
profit_rate_percent: {
|
||||||
|
summary: "V3 每笔消费中直接计为平台收入的比例,不进入奖池,也不发给收礼对象。",
|
||||||
|
adjust: "调高会增加平台份额,但必须相应减少奖池或收礼份额。",
|
||||||
|
constraint:
|
||||||
|
"不能为负数;V3 三项资金比例必须精确合计 100%,整数拆分后不能整除的余数也计入平台;V2 不使用该比例开奖。",
|
||||||
|
},
|
||||||
|
anchor_rate_percent: {
|
||||||
|
summary: "V3 每笔消费中返给本次收礼对象的比例,通常是主播但不一定等于房主;这部分不是用户中奖奖励。",
|
||||||
|
adjust: "调高会增加收礼对象所得,并相应减少奖池或平台份额。",
|
||||||
|
constraint:
|
||||||
|
"按整数金币向下取整,且必须与钱包实际发给收礼对象的金币完全一致,否则 V3 会拒绝开奖;V2 不使用该比例开奖。",
|
||||||
|
},
|
||||||
|
settlement_window_wager: {
|
||||||
|
summary:
|
||||||
|
"V3 按实际消费金币累计一轮大盘 RTP;某一抽跨过门槛时仍归旧轮,下一抽才进入新轮。大盘结算并重置时,也会按完全相同的起止时间结算每个用户的本轮 RTP。",
|
||||||
|
adjust: "调大后大盘和用户本轮 RTP 都会更久才结算;调小后两者都会更频繁结算。V2 会先按礼物参考金额换算成窗口抽数。",
|
||||||
|
constraint: "必须大于 0。新一轮还没有消费样本时,不会触发返还偏低大奖。",
|
||||||
|
},
|
||||||
|
gift_price_reference: {
|
||||||
|
summary: "V2 用它把累计消费换算成“折算次数”,同时计算用户阶段和每轮窗口的抽数;它不是礼物真实售价。",
|
||||||
|
adjust: "调高后,同样消费折算成更少次数,用户升级更慢且 V2 窗口抽数更少;调低则相反。",
|
||||||
|
constraint: "必须大于 0。V3 的窗口直接看真实金币流水,用户阶段看 7 日和 30 日充值,不用它做这两项判断。",
|
||||||
|
},
|
||||||
|
novice_max_equivalent_draws: {
|
||||||
|
summary: "fixed_v2 中,用户累计消费折算次数不超过这个值时使用新手奖档。",
|
||||||
|
adjust: "调高会让用户在新手阶段停留更久;调低会更早进入普通阶段。",
|
||||||
|
constraint: "不能小于 0,也不能高于普通阶段上限;dynamic_v3 不使用这个值分层。",
|
||||||
|
},
|
||||||
|
normal_max_equivalent_draws: {
|
||||||
|
summary: "fixed_v2 中,用户超过新手上限后,不超过这个值时使用普通奖档;再超过才进入高阶奖档。",
|
||||||
|
adjust: "调高会推迟进入高阶阶段;调低会让用户更早进入高阶阶段。",
|
||||||
|
constraint: "不能小于新手阶段上限;dynamic_v3 不使用这个值分层。",
|
||||||
|
},
|
||||||
|
initial_pool_coins: {
|
||||||
|
summary: "表示发布规则时是否顺便给奖池放入启动金币。dynamic_v3 已改为不允许规则自动注资。",
|
||||||
|
adjust: "该值固定为 0 且不可编辑,避免每次发新版本重复增加奖池余额。",
|
||||||
|
constraint: "需要启动资金时,请在配置列表使用“添加水位”,该操作会保留独立审计记录。",
|
||||||
|
},
|
||||||
|
loss_streak_guarantee: {
|
||||||
|
summary:
|
||||||
|
"同一用户在当前礼物奖池连续多少次返还为 0 后,下一次普通开奖会先去掉 0 倍档。填 5 表示连续 5 次为 0 后,第 6 次普通开奖尝试正奖。",
|
||||||
|
adjust: "调小会更快触发体验保护;调大会降低触发频率并减少奖池压力。",
|
||||||
|
constraint: "必须大于 0;奖池不足或任一返奖上限不足时仍可返回 0,不会透支。",
|
||||||
|
},
|
||||||
|
low_watermark_coins: {
|
||||||
|
summary: "本抽应进入奖池的金币先入账;入账后余额低于这个值时,系统会降低普通开奖中非 0 倍档的基础机会。",
|
||||||
|
adjust: "调高会更早开始保护奖池;调低会让正常中奖节奏保持更久。",
|
||||||
|
constraint: "必须大于 0,并且小于高余额分界线。",
|
||||||
|
},
|
||||||
|
low_water_nonzero_factor_percent: {
|
||||||
|
summary: "奖池处于低余额时,普通开奖中非 0 倍档的基础机会保留多少。填 70 表示按原来的 70% 参与计算。",
|
||||||
|
adjust: "调低更保护奖池但中奖更少;调高更接近正常节奏但消耗更快。",
|
||||||
|
constraint: "必须大于 0% 且小于 100%,0 倍档接收剩余概率;它不会直接提高或降低两种大奖的触发机会。",
|
||||||
|
},
|
||||||
|
high_watermark_coins: {
|
||||||
|
summary: "本抽应进入奖池的金币先入账;入账后余额高于这个值时,系统会提高普通开奖中非 0 倍档的基础机会。",
|
||||||
|
adjust: "调低会更早提高中奖机会;调高会要求奖池积累更多后才加速返奖。",
|
||||||
|
constraint: "必须严格大于低余额分界线;刚好等于该值时仍按正常状态处理。",
|
||||||
|
},
|
||||||
|
high_water_nonzero_factor_percent: {
|
||||||
|
summary: "奖池处于高余额时,普通开奖中非 0 倍档的基础机会放大到多少。填 130 表示变为原来的 130%。",
|
||||||
|
adjust: "调高会更快释放奖池、增加正奖机会;调低则更接近正常节奏。",
|
||||||
|
constraint: "必须大于 100%;放大后会重新归一化,并继续受奖池和返奖上限限制;它不会直接提高大奖触发机会。",
|
||||||
|
},
|
||||||
|
recharge_boost_window_ms: {
|
||||||
|
summary: "用户完成一次可验证充值后,普通开奖中非 0 倍档的机会加成持续多久。300000 毫秒等于 5 分钟。",
|
||||||
|
adjust: "调大会延长充值后的加成时间;调小会让加成更快结束。",
|
||||||
|
constraint: "必须大于 0,生效区间包含充值时刻但不包含结束时刻;外部接入没有可信充值事实,不会触发加成。",
|
||||||
|
},
|
||||||
|
recharge_boost_factor_percent: {
|
||||||
|
summary: "充值加成期间,普通开奖中非 0 倍档的基础机会放大到多少。填 110 表示变为原来的 110%。",
|
||||||
|
adjust: "调高会增强充值后的中奖体验,也会增加奖池消耗;调低则更温和。",
|
||||||
|
constraint: "必须大于 100%,会与高低余额调整相乘;它只改变普通奖档,不会直接提高大奖触发机会。",
|
||||||
|
},
|
||||||
|
jackpot_multipliers: {
|
||||||
|
summary: "列出大奖可能采用的倍数,例如 200、500、1000。实际奖励按本抽消费金额乘以倍数计算。",
|
||||||
|
adjust: "增加或调高倍数会放大奖励金额和资金压力;删除倍数会减少可选大奖。",
|
||||||
|
constraint:
|
||||||
|
"用逗号分隔,必须都大于 1 且从小到大;系统会在所有付得起的大奖倍数中等机会选择一档。这里的倍率不能再作为普通奖档设置正概率,否则配置无法发布。",
|
||||||
|
},
|
||||||
|
jackpot_global_rtp_max_percent: {
|
||||||
|
summary:
|
||||||
|
"系统每次达到结算流水后,会结束并重置一轮大盘 RTP。刚结算的本轮大盘 RTP 不高于这条线时,才继续检查用户的两项返还条件。",
|
||||||
|
adjust: "调高会让补偿大奖更容易进入候选;调低会要求全局返还更低才触发。",
|
||||||
|
constraint:
|
||||||
|
"必须大于 0% 且不超过 100%。大盘 RTP 等于本项配置值时可以通过;还必须同时满足用户本轮 RTP 严格低于本轮触发线、用户近 48 小时 RTP 严格低于长期触发线,且奖池和各项限额足够。闭窗资格只在用户下一次送礼时尝试一次,奖池或限额不足也不会顺延。",
|
||||||
|
},
|
||||||
|
jackpot_user_round_rtp_max_percent: {
|
||||||
|
summary:
|
||||||
|
"系统每次重置大盘 RTP 时,会按完全相同的起止时间结算这个用户的本轮 RTP。例如 9 点重置一次、10 点再次重置,10 点结算时就检查用户在 9 点到 10 点这一轮的总返奖 ÷ 总消费。",
|
||||||
|
adjust: "调高会让更多本轮返还偏低的用户通过;调低会更严格。用户本轮 RTP 必须严格小于配置值,刚好等于也不通过。",
|
||||||
|
constraint:
|
||||||
|
"只统计当前 App、当前奖池和同一结算轮次内的实际消费与返奖;本轮没有消费样本时不通过。它必须与近 48 小时条件同时通过,不能只满足其中一项。",
|
||||||
|
},
|
||||||
|
jackpot_user_48h_rtp_max_percent: {
|
||||||
|
summary:
|
||||||
|
"判断这个用户近 48 小时是否整体返还偏低。本轮结算时,用户注册已满 48 小时,系统才会把结算时点向前 48 小时内的返奖金币加总,再除以同期实际消费金币;同一 App、奖池下跨房间、主播和设备合并统计,结果严格低于这条线才通过。",
|
||||||
|
adjust: "调高会更容易通过,调低会更严格;结果等于配置值也不通过。例如近 48 小时消费 10,000、返奖 9,500,RTP 为 95%,配置 96% 时通过;返奖 9,600、RTP 为 96% 时不通过。",
|
||||||
|
constraint:
|
||||||
|
"先看注册时间:注册未满 48 小时直接不通过;注册已满 48 小时后,不要求连续玩满 48 小时,只统计窗口内真实发生的消费和返奖。即使前 47 小时都没玩、最近只玩 5 分钟,也用这 5 分钟的数据计算;整段时间没有消费时不通过。它还必须与大盘条件、用户本轮条件同时通过,并受每日大奖次数、奖池余额和所有返奖上限限制。",
|
||||||
|
},
|
||||||
|
max_jackpot_hits_per_user_day: {
|
||||||
|
summary: "限制同一用户在当前奖池的一个 UTC 自然日内最多真正拿到多少次大奖。",
|
||||||
|
adjust: "调高允许同一用户一天获得更多大奖;调低会更早停止该用户当天的大奖,不保证转给其他用户。",
|
||||||
|
constraint:
|
||||||
|
"必须大于 0;达到次数后,即使大盘、本轮和近 48 小时 RTP 都通过也不再出大奖,但仍可参与普通奖档;下一个 UTC 自然日重新累计。",
|
||||||
|
},
|
||||||
|
jackpot_spend_threshold_coins: {
|
||||||
|
summary:
|
||||||
|
"这是旧规则按用户日累计消费保存独立大奖资格时使用的历史字段。当前规则已停用这条路径,保留数值只为读取和回传旧版本配置。",
|
||||||
|
adjust: "当前不可编辑;无论原值是多少,都不会增加用户出大奖的机会。",
|
||||||
|
constraint:
|
||||||
|
"当前大奖必须经过大盘条件,并同时满足用户本轮 RTP 与近 48 小时 RTP 两项条件;这个历史字段不能替代或绕过任何一项。",
|
||||||
|
},
|
||||||
|
max_single_payout: {
|
||||||
|
summary: "限制一抽最多能返多少金币;超过这个金额的奖档当次不会发出。",
|
||||||
|
adjust: "调高允许更大的单次奖励,也增加资金风险;调低会直接挡住更多高倍率结果。",
|
||||||
|
constraint:
|
||||||
|
"启用 V3 时必须大于 0;批量送礼按每个子抽分别判断。奖档超过任一剩余额度会整档挡住,不会裁剪成较小金额。",
|
||||||
|
},
|
||||||
|
user_hourly_payout_cap: {
|
||||||
|
summary: "限制同一用户在当前 UTC 小时内累计最多返多少金币。",
|
||||||
|
adjust: "调高允许用户短时间获得更多奖励;调低会更早限制高频送礼用户。",
|
||||||
|
constraint: "启用 V3 时必须大于 0;按当前奖池和实际支付时间所在的 UTC 小时计算,到下一小时重新累计。",
|
||||||
|
},
|
||||||
|
user_daily_payout_cap: {
|
||||||
|
summary: "限制同一用户在当前 UTC 自然日内累计最多返多少金币。",
|
||||||
|
adjust: "调高允许单个用户一天获得更多奖励;调低会更均匀地分散预算。",
|
||||||
|
constraint: "启用 V3 时必须大于 0;按当前奖池和实际支付时间所在的 UTC 日期累计。",
|
||||||
|
},
|
||||||
|
device_daily_payout_cap: {
|
||||||
|
summary: "限制同一设备上的所有账号在一个 UTC 自然日内合计最多返多少金币,用来防止换账号绕过用户上限。",
|
||||||
|
adjust: "调高会放宽同设备多账号的总额度;调低会更严格地控制集中领取。",
|
||||||
|
constraint: "启用 V3 时必须大于 0,并按当前奖池隔离;必须拿到稳定设备 ID,外部接入也不能用用户 ID 代替。",
|
||||||
|
},
|
||||||
|
room_hourly_payout_cap: {
|
||||||
|
summary: "限制同一房间内所有用户在当前 UTC 小时合计最多返多少金币。",
|
||||||
|
adjust: "调高允许热门房间更集中地产生奖励;调低会更早限制单房间的消耗速度。",
|
||||||
|
constraint:
|
||||||
|
"启用 V3 时必须大于 0,并按当前奖池隔离;外部接口没有真实房间,同一 App 的外部请求会共用一个虚拟房间额度。",
|
||||||
|
},
|
||||||
|
anchor_daily_payout_cap: {
|
||||||
|
summary: "限制同一房主关联的所有房间和用户在一个 UTC 自然日内合计最多返多少金币。",
|
||||||
|
adjust: "调高允许奖励更集中在单个主播;调低会把预算分散到更多主播。",
|
||||||
|
constraint:
|
||||||
|
"启用 V3 时必须大于 0,并按当前奖池隔离;外部接口没有房主,同一 App 的外部请求会共用一个虚拟主播额度。",
|
||||||
|
},
|
||||||
|
stage_novice: {
|
||||||
|
summary:
|
||||||
|
"新手阶段使用的一组基础奖档。V2 看累计消费折算次数;V3 中未同时达到普通阶段两项充值门槛的用户都会落在这里。",
|
||||||
|
adjust: "可通过倍率组合调整未达到普通门槛用户的体验,但预计平均返还仍要落在允许范围内。",
|
||||||
|
constraint:
|
||||||
|
"V3 的新手充值门槛固定为 0 / 0;外部接入当前也始终按新手阶段处理。每个阶段都必须保留启用的 0 倍档。",
|
||||||
|
},
|
||||||
|
stage_normal: {
|
||||||
|
summary: "普通阶段使用的一组基础奖档。V2 看累计消费折算次数;V3 必须同时达到这里的 7 日和 30 日最低充值。",
|
||||||
|
adjust: "V3 中提高充值门槛会减少进入普通阶段的人数,降低则会扩大范围;V2 由上方等价抽数上限控制阶段。",
|
||||||
|
constraint: "V3 中至少一个充值门槛必须大于 0,并且不能高于高阶阶段对应门槛。",
|
||||||
|
},
|
||||||
|
stage_advanced: {
|
||||||
|
summary:
|
||||||
|
"高阶阶段使用的一组基础奖档。V2 超过普通折算次数上限后进入;V3 会选择同时满足 7 日和 30 日门槛的最高阶段。",
|
||||||
|
adjust: "V3 中提高充值门槛会缩小高阶范围,降低则会扩大;V2 由上方等价抽数上限控制阶段。",
|
||||||
|
constraint: "V3 中两项门槛都不能低于普通阶段,且至少一项必须更高。",
|
||||||
|
},
|
||||||
|
min_recharge_7d_coins: {
|
||||||
|
summary: "用户最近 7 个 UTC 自然日(含当天)的充值金币至少达到多少,才满足当前阶段的一半条件。",
|
||||||
|
adjust: "调高会减少进入该阶段的人数;调低会扩大范围。",
|
||||||
|
constraint: "用户还必须同时达到 30 日门槛;新手阶段固定为 0。外部接入没有可信充值快照时按新手处理。",
|
||||||
|
},
|
||||||
|
min_recharge_30d_coins: {
|
||||||
|
summary: "用户最近 30 个 UTC 自然日(含当天)的充值金币至少达到多少,才满足当前阶段的另一半条件。",
|
||||||
|
adjust: "调高会更看重长期充值;调低会让近期活跃用户更容易进入该阶段。",
|
||||||
|
constraint: "用户还必须同时达到 7 日门槛;新手阶段固定为 0。",
|
||||||
|
},
|
||||||
|
tier_multiplier: {
|
||||||
|
summary: "这一档返还本抽消费金额的多少倍。0 倍表示没有返还,1 倍表示返还与本抽消费相同。",
|
||||||
|
adjust: "调高会增加这一档的奖励金额;系统会自动重算各档基础概率来尽量保持平均返还目标。",
|
||||||
|
constraint: "不能小于 0;10 倍及以上必须勾选“高倍率安全标记”,实际支付仍不能超过奖池和返奖上限。",
|
||||||
|
},
|
||||||
|
tier_probability: {
|
||||||
|
summary: "表示在当前用户阶段中抽到这一档的基础机会,所有启用奖档必须合计 100%。",
|
||||||
|
adjust: "该值由平均返还目标和各档倍数自动计算,只读展示;修改倍数或目标后会重新计算。",
|
||||||
|
constraint: "V3 会按水位和可信充值调整普通非 0 档,并受六项上限限制;V2 保持基础概率,只过滤奖池付不起的档位。",
|
||||||
|
},
|
||||||
|
tier_high_water_only: {
|
||||||
|
summary: "这是发布高倍率基础奖档前的安全确认标记;当前运行逻辑不会根据它判断奖池处于哪种水位。",
|
||||||
|
adjust: "勾选或取消不会直接放行、拦截奖档;实际是否能发仍看实时奖池余额和返奖上限。",
|
||||||
|
constraint:
|
||||||
|
"10 倍及以上必须勾选才能发布。不要把它当成独立的中奖开关;V3 的高低余额变化由上方两组中奖权重控制。",
|
||||||
|
},
|
||||||
|
tier_enabled: {
|
||||||
|
summary: "决定这一档是否参加当前阶段的普通基础抽奖。关闭后不会从普通奖档中选中。",
|
||||||
|
adjust: "关闭高倍率档会降低大额结果种类;重新开启后系统会重新计算整组概率。",
|
||||||
|
constraint:
|
||||||
|
"每个阶段启用档位的概率必须合计 100%,并至少保留一个 0 倍档;若同倍率另列在大奖倍率中,大奖机制仍可能选中它。",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function LuckyGiftFieldLabel({ field, label }) {
|
||||||
|
const help = FIELD_HELP[field];
|
||||||
|
if (!help) return label;
|
||||||
|
return (
|
||||||
|
<Tooltip
|
||||||
|
arrow
|
||||||
|
describeChild
|
||||||
|
enterDelay={120}
|
||||||
|
leaveDelay={80}
|
||||||
|
placement="top-start"
|
||||||
|
slotProps={{ tooltip: { className: "ops-field-help-tooltip" } }}
|
||||||
|
title={<FieldHelpContent help={help} label={label} />}
|
||||||
|
>
|
||||||
|
<span className="ops-field-label" tabIndex={0}>
|
||||||
|
<span>{label}</span>
|
||||||
|
<span aria-hidden="true" className="ops-field-help-trigger">
|
||||||
|
<HelpOutlineOutlined aria-hidden="true" fontSize="inherit" />
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldHelpContent({ help, label }) {
|
||||||
|
return (
|
||||||
|
<div className="ops-field-help-content" role="note">
|
||||||
|
<strong>{label}</strong>
|
||||||
|
<dl>
|
||||||
|
<div>
|
||||||
|
<dt>作用</dt>
|
||||||
|
<dd>{help.summary}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>调整影响</dt>
|
||||||
|
<dd>{help.adjust}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>注意</dt>
|
||||||
|
<dd>{help.constraint}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
ops-center/src/components/LuckyGiftPoolActions.jsx
Normal file
48
ops-center/src/components/LuckyGiftPoolActions.jsx
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import { Button } from "@/shared/ui/Button.jsx";
|
||||||
|
|
||||||
|
export function LuckyGiftPoolActions({ canCredit, canDebit, disabled, onCredit, onDebit, pool }) {
|
||||||
|
if (!canCredit && !canDebit) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const appCode = pool?.app_code || "-";
|
||||||
|
const poolID = pool?.pool_id || "-";
|
||||||
|
const strategyVersion = pool?.strategy_version || "fixed_v2";
|
||||||
|
const identity = `${appCode} / ${poolID} / ${strategyVersion}`;
|
||||||
|
const missingPool = !pool || pool.balance === undefined || pool.available_balance === undefined;
|
||||||
|
const availableBalance = Number(pool?.available_balance || 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ops-pool-actions">
|
||||||
|
{canCredit ? (
|
||||||
|
<Button
|
||||||
|
aria-label={`添加水位 ${identity}`}
|
||||||
|
disabled={disabled || missingPool}
|
||||||
|
size="small"
|
||||||
|
title={missingPool ? "尚未获取该策略的奖池水位" : undefined}
|
||||||
|
onClick={() => onCredit(pool)}
|
||||||
|
>
|
||||||
|
添加水位
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{canDebit ? (
|
||||||
|
<Button
|
||||||
|
aria-label={`扣减水位 ${identity}`}
|
||||||
|
disabled={disabled || missingPool || availableBalance <= 0}
|
||||||
|
size="small"
|
||||||
|
title={
|
||||||
|
missingPool
|
||||||
|
? "尚未获取该策略的奖池水位"
|
||||||
|
: availableBalance <= 0
|
||||||
|
? "当前无可扣减水位"
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
variant="danger"
|
||||||
|
onClick={() => onDebit(pool)}
|
||||||
|
>
|
||||||
|
扣减水位
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
132
ops-center/src/components/LuckyGiftPoolAdjustmentDialog.jsx
Normal file
132
ops-center/src/components/LuckyGiftPoolAdjustmentDialog.jsx
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
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 TextField from "@mui/material/TextField";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "@/shared/ui/Button.jsx";
|
||||||
|
import { formatNumber } from "../format.js";
|
||||||
|
|
||||||
|
export function LuckyGiftPoolAdjustmentDialog({ adjustmentId, direction, onClose, onSubmit, pool, submitting }) {
|
||||||
|
const [amount, setAmount] = useState("");
|
||||||
|
const [reason, setReason] = useState("");
|
||||||
|
const [errors, setErrors] = useState({});
|
||||||
|
const debit = direction === "out";
|
||||||
|
const appCode = pool.app_code;
|
||||||
|
const poolID = pool.pool_id;
|
||||||
|
const strategyVersion = pool.strategy_version;
|
||||||
|
const availableBalance = Number(pool.available_balance || 0);
|
||||||
|
const title = debit ? "扣减奖池水位" : "添加奖池水位";
|
||||||
|
|
||||||
|
const submit = (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const nextErrors = validateAdjustment({ amount, availableBalance, debit, reason });
|
||||||
|
setErrors(nextErrors);
|
||||||
|
if (Object.keys(nextErrors).length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// adjustmentId 在打开弹窗时创建,此处只原样透传。请求失败后组件不会卸载,因此用户修正或直接重试时
|
||||||
|
// 仍使用同一幂等键,网络超时场景不会把已成功的资金动作重复执行。
|
||||||
|
onSubmit({
|
||||||
|
adjustmentId,
|
||||||
|
amountCoins: Number(amount),
|
||||||
|
appCode,
|
||||||
|
direction,
|
||||||
|
poolId: poolID,
|
||||||
|
reason: reason.trim(),
|
||||||
|
strategyVersion,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
fullWidth
|
||||||
|
maxWidth="sm"
|
||||||
|
open
|
||||||
|
aria-labelledby="lucky-pool-adjustment-title"
|
||||||
|
onClose={submitting ? undefined : onClose}
|
||||||
|
>
|
||||||
|
<form className="ops-pool-adjustment-form" onSubmit={submit}>
|
||||||
|
<DialogTitle id="lucky-pool-adjustment-title">{title}</DialogTitle>
|
||||||
|
<DialogContent dividers className="ops-pool-adjustment-content">
|
||||||
|
<div className="ops-config-metrics ops-pool-adjustment-metrics">
|
||||||
|
<PoolMetric label="应用" value={appCode} />
|
||||||
|
<PoolMetric label="奖池" value={poolID} />
|
||||||
|
<PoolMetric label="策略" value={strategyVersion} />
|
||||||
|
<PoolMetric label="当前余额" value={formatNumber(pool.balance)} />
|
||||||
|
<PoolMetric label="保底线" value={formatNumber(pool.reserve_floor)} />
|
||||||
|
<PoolMetric label="可用余额" value={formatNumber(availableBalance)} />
|
||||||
|
</div>
|
||||||
|
{pool.materialized === false ? (
|
||||||
|
<div className="ops-pool-adjustment-note" role="status">
|
||||||
|
当前为默认水位,提交后将物化为独立策略奖池。
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<TextField
|
||||||
|
autoFocus
|
||||||
|
disabled={submitting}
|
||||||
|
error={Boolean(errors.amount)}
|
||||||
|
fullWidth
|
||||||
|
helperText={errors.amount || (debit ? `最多可扣减 ${formatNumber(availableBalance)} 金币` : "")}
|
||||||
|
label="调整金币"
|
||||||
|
required
|
||||||
|
slotProps={{ htmlInput: { inputMode: "numeric", pattern: "[0-9]*" } }}
|
||||||
|
value={amount}
|
||||||
|
onChange={(event) => {
|
||||||
|
setAmount(event.target.value);
|
||||||
|
setErrors((current) => ({ ...current, amount: "" }));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
disabled={submitting}
|
||||||
|
error={Boolean(errors.reason)}
|
||||||
|
fullWidth
|
||||||
|
helperText={errors.reason}
|
||||||
|
label="调整原因"
|
||||||
|
multiline
|
||||||
|
required
|
||||||
|
rows={3}
|
||||||
|
slotProps={{ htmlInput: { maxLength: 255 } }}
|
||||||
|
value={reason}
|
||||||
|
onChange={(event) => {
|
||||||
|
setReason(event.target.value);
|
||||||
|
setErrors((current) => ({ ...current, reason: "" }));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button disabled={submitting} onClick={onClose}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button disabled={submitting} type="submit" variant={debit ? "danger" : "primary"}>
|
||||||
|
{debit ? "确认扣减" : "确认添加"}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</form>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PoolMetric({ label, value }) {
|
||||||
|
return (
|
||||||
|
<div className="ops-config-metric">
|
||||||
|
<span>{label}</span>
|
||||||
|
<strong>{value || "-"}</strong>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateAdjustment({ amount, availableBalance, debit, reason }) {
|
||||||
|
const errors = {};
|
||||||
|
const rawAmount = String(amount || "").trim();
|
||||||
|
if (!/^[1-9]\d*$/.test(rawAmount) || !Number.isSafeInteger(Number(rawAmount))) {
|
||||||
|
errors.amount = "请输入大于 0 的整数金币数量";
|
||||||
|
} else if (debit && Number(rawAmount) > availableBalance) {
|
||||||
|
errors.amount = `扣减不能超过可用余额 ${formatNumber(availableBalance)}`;
|
||||||
|
}
|
||||||
|
if (!String(reason || "").trim()) {
|
||||||
|
errors.reason = "请输入调整原因";
|
||||||
|
}
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
273
ops-center/src/components/LuckyGiftStageDesigner.jsx
Normal file
273
ops-center/src/components/LuckyGiftStageDesigner.jsx
Normal file
@ -0,0 +1,273 @@
|
|||||||
|
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 { formatNumber } from "../format.js";
|
||||||
|
import { LuckyGiftFieldLabel } from "./LuckyGiftFieldHelp.jsx";
|
||||||
|
import { OpsStatusBadge } from "./OpsStatusBadge.jsx";
|
||||||
|
|
||||||
|
export function LuckyGiftStageDesigner({
|
||||||
|
activeStageConfig,
|
||||||
|
activeStageKey,
|
||||||
|
activeSummary,
|
||||||
|
normalStageConfig,
|
||||||
|
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>{plainStageStatus(summary.status)}</small>
|
||||||
|
</span>
|
||||||
|
<span className="ops-stage-tab__metrics">
|
||||||
|
概率 {formatDecimal(summary.probability)}% · 平均返还 {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>
|
||||||
|
<LuckyGiftFieldLabel
|
||||||
|
field={`stage_${activeStageKey}`}
|
||||||
|
label={activeSummary?.stageLabel || activeStageKey}
|
||||||
|
/>
|
||||||
|
</h3>
|
||||||
|
<OpsStatusBadge
|
||||||
|
label={`${plainStageStatus(activeSummary?.status)} · 概率 ${formatDecimal(activeSummary?.probability)}% · 预计平均返还 ${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" && activeStageConfig.stage === "novice" ? (
|
||||||
|
<NoviceRechargeRange normalStageConfig={normalStageConfig} />
|
||||||
|
) : strategyVersion === "dynamic_v3" ? (
|
||||||
|
<div className="ops-stage-thresholds" aria-label={`${activeSummary?.stageLabel}充值门槛`}>
|
||||||
|
<span>进入本阶段所需充值</span>
|
||||||
|
<TextField
|
||||||
|
label={
|
||||||
|
<LuckyGiftFieldLabel field="min_recharge_7d_coins" 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
|
||||||
|
label={
|
||||||
|
<LuckyGiftFieldLabel field="min_recharge_30d_coins" 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">
|
||||||
|
<LuckyGiftFieldLabel field="tier_multiplier" label="倍率" />
|
||||||
|
</span>
|
||||||
|
<span role="columnheader">
|
||||||
|
<LuckyGiftFieldLabel field="tier_probability" label="基础中奖机会 %" />
|
||||||
|
</span>
|
||||||
|
<span role="columnheader">
|
||||||
|
<LuckyGiftFieldLabel field="tier_high_water_only" label="高倍率安全标记" />
|
||||||
|
</span>
|
||||||
|
<span role="columnheader">
|
||||||
|
<LuckyGiftFieldLabel field="tier_enabled" label="启用" />
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NoviceRechargeRange({ normalStageConfig }) {
|
||||||
|
const normal7D = Number(normalStageConfig?.min_recharge_7d_coins || 0);
|
||||||
|
const normal30D = Number(normalStageConfig?.min_recharge_30d_coins || 0);
|
||||||
|
// normal 是二维 AND 下限,合法配置只需至少一维大于 0;为 0 的维度也必须照实展示。
|
||||||
|
const configured = Number.isFinite(normal7D) && Number.isFinite(normal30D) && (normal7D > 0 || normal30D > 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ops-stage-thresholds ops-stage-thresholds--derived" aria-label="新手阶段派生充值范围">
|
||||||
|
<span>派生范围</span>
|
||||||
|
<div className="ops-stage-threshold-rule">
|
||||||
|
<strong>
|
||||||
|
7 日充值 < {formatNumber(normal7D)} 或 30 日充值 < {formatNumber(normal30D)}
|
||||||
|
</strong>
|
||||||
|
{/* 普通阶段需要两个下限同时成立;因此任一维未达到都应留在新手,等于下限则进入普通。 */}
|
||||||
|
<small>
|
||||||
|
{configured ? "" : "普通阶段门槛待配置;"}任一未达到即为新手;7 日与 30
|
||||||
|
日均达到门槛(含等于)后进入普通。
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function plainStageStatus(status) {
|
||||||
|
if (status === "RTP 偏离") return "平均返还偏离";
|
||||||
|
if (status === "概率未闭合") return "概率合计不为 100%";
|
||||||
|
return status || "待检查";
|
||||||
|
}
|
||||||
@ -2,123 +2,206 @@ import AppsOutlined from "@mui/icons-material/AppsOutlined";
|
|||||||
import CardGiftcardOutlined from "@mui/icons-material/CardGiftcardOutlined";
|
import CardGiftcardOutlined from "@mui/icons-material/CardGiftcardOutlined";
|
||||||
import CasinoOutlined from "@mui/icons-material/CasinoOutlined";
|
import CasinoOutlined from "@mui/icons-material/CasinoOutlined";
|
||||||
import SavingsOutlined from "@mui/icons-material/SavingsOutlined";
|
import SavingsOutlined from "@mui/icons-material/SavingsOutlined";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { AdminFilterSelect, AdminListToolbar } from "@/shared/ui/AdminListLayout.jsx";
|
||||||
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||||
import { KpiCard } from "@/shared/ui/KpiCard.jsx";
|
import { KpiCard } from "@/shared/ui/KpiCard.jsx";
|
||||||
import { TimeText } from "@/shared/ui/TimeText.jsx";
|
import { TimeText } from "@/shared/ui/TimeText.jsx";
|
||||||
|
import { luckyGiftPoolKey } from "../api.js";
|
||||||
import { formatNumber, formatPercentFromPPM } from "../format.js";
|
import { formatNumber, formatPercentFromPPM } from "../format.js";
|
||||||
|
import { LuckyGiftPoolActions } from "./LuckyGiftPoolActions.jsx";
|
||||||
import { OpsStatusBadge } from "./OpsStatusBadge.jsx";
|
import { OpsStatusBadge } from "./OpsStatusBadge.jsx";
|
||||||
|
|
||||||
export function OverviewView({ data }) {
|
export function OverviewView({ canCredit, canDebit, data, disabled, onCredit, onDebit }) {
|
||||||
const { appSummaries, poolBalances, summary } = data;
|
const { appSummaries, poolBalances, summary } = data;
|
||||||
|
const [poolScope, setPoolScope] = useState("current");
|
||||||
|
const poolBalanceColumns = useMemo(
|
||||||
|
() => buildPoolBalanceColumns({ canCredit, canDebit, disabled, onCredit, onDebit }),
|
||||||
|
[canCredit, canDebit, disabled, onCredit, onDebit],
|
||||||
|
);
|
||||||
|
const poolCounts = useMemo(
|
||||||
|
() => ({
|
||||||
|
all: poolBalances.length,
|
||||||
|
current: poolBalances.filter((pool) => !pool.is_historical).length,
|
||||||
|
history: poolBalances.filter((pool) => pool.is_historical).length,
|
||||||
|
}),
|
||||||
|
[poolBalances],
|
||||||
|
);
|
||||||
|
const visiblePoolBalances = useMemo(() => {
|
||||||
|
if (poolScope === "history") {
|
||||||
|
return poolBalances.filter((pool) => pool.is_historical);
|
||||||
|
}
|
||||||
|
if (poolScope === "all") {
|
||||||
|
return poolBalances;
|
||||||
|
}
|
||||||
|
// 当前策略是默认视图;历史账本只从显式筛选进入,避免同名 V2/V3 被误读为重复奖池。
|
||||||
|
return poolBalances.filter((pool) => !pool.is_historical);
|
||||||
|
}, [poolBalances, poolScope]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="ops-view">
|
<div className="ops-view">
|
||||||
<div className="kpi-grid ops-kpi-grid">
|
<div className="kpi-grid ops-kpi-grid">
|
||||||
<KpiCard
|
<KpiCard
|
||||||
icon={AppsOutlined}
|
icon={AppsOutlined}
|
||||||
label="运营应用"
|
label="运营应用"
|
||||||
sub={`主数据 ${formatNumber(countBySource(data.apps, "registry"))} · 外部接入 ${formatNumber(countBySource(data.apps, "fixed"))}`}
|
sub={`主数据 ${formatNumber(countBySource(data.apps, "registry"))} · 外部接入 ${formatNumber(countBySource(data.apps, "fixed"))}`}
|
||||||
value={formatNumber(summary.activeApps)}
|
value={formatNumber(summary.activeApps)}
|
||||||
/>
|
/>
|
||||||
<KpiCard
|
<KpiCard
|
||||||
icon={CardGiftcardOutlined}
|
icon={CardGiftcardOutlined}
|
||||||
label="已发布奖池配置"
|
label="已发布奖池配置"
|
||||||
sub={`启用 ${formatNumber(summary.enabledPools)} · 停用 ${formatNumber(summary.configuredPools - summary.enabledPools)}`}
|
sub={`启用 ${formatNumber(summary.enabledPools)} · 停用 ${formatNumber(summary.configuredPools - summary.enabledPools)}`}
|
||||||
tone="info"
|
tone="info"
|
||||||
value={formatNumber(summary.configuredPools)}
|
value={formatNumber(summary.configuredPools)}
|
||||||
/>
|
/>
|
||||||
<KpiCard
|
<KpiCard
|
||||||
icon={CasinoOutlined}
|
icon={CasinoOutlined}
|
||||||
label="抽奖次数"
|
label="抽奖次数"
|
||||||
sub={`成功 ${formatNumber(summary.grantedDraws)} · 待发放 ${formatNumber(summary.pendingDraws)} · 失败 ${formatNumber(summary.failedDraws)}`}
|
sub={`成功 ${formatNumber(summary.grantedDraws)} · 待发放 ${formatNumber(summary.pendingDraws)} · 失败 ${formatNumber(summary.failedDraws)}`}
|
||||||
tone="warning"
|
tone="warning"
|
||||||
value={formatNumber(summary.totalDraws)}
|
value={formatNumber(summary.totalDraws)}
|
||||||
/>
|
/>
|
||||||
<KpiCard
|
<KpiCard
|
||||||
icon={SavingsOutlined}
|
icon={SavingsOutlined}
|
||||||
label="奖池金币"
|
label="当前奖池金币"
|
||||||
sub={`可用 ${formatNumber(summary.poolAvailableTotal)} · 保底 ${formatNumber(summary.poolReserveTotal)}`}
|
sub={`可用 ${formatNumber(summary.poolAvailableTotal)} · 保底 ${formatNumber(summary.poolReserveTotal)}`}
|
||||||
tone="success"
|
tone="success"
|
||||||
value={formatNumber(summary.poolBalanceTotal)}
|
value={formatNumber(summary.poolBalanceTotal)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DataTable
|
<AdminListToolbar
|
||||||
columns={poolBalanceColumns}
|
filters={
|
||||||
emptyLabel="暂无奖池余额"
|
<AdminFilterSelect
|
||||||
items={poolBalances}
|
label="奖池范围"
|
||||||
minWidth="920px"
|
options={[
|
||||||
rowKey={(item) => `${item.app_code ?? item.appCode}:${item.pool_id ?? item.poolId}`}
|
["current", `当前奖池 (${poolCounts.current})`],
|
||||||
title="奖池实时水位"
|
["history", `历史奖池 (${poolCounts.history})`],
|
||||||
total={poolBalances.length}
|
["all", `全部奖池 (${poolCounts.all})`],
|
||||||
/>
|
]}
|
||||||
|
value={poolScope}
|
||||||
|
onChange={setPoolScope}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<DataTable
|
||||||
|
columns={poolBalanceColumns}
|
||||||
|
emptyLabel={poolScope === "history" ? "暂无历史奖池" : "暂无奖池余额"}
|
||||||
|
items={visiblePoolBalances}
|
||||||
|
minWidth="1480px"
|
||||||
|
rowKey={luckyGiftPoolKey}
|
||||||
|
title="奖池实时水位"
|
||||||
|
total={visiblePoolBalances.length}
|
||||||
|
/>
|
||||||
|
|
||||||
<DataTable
|
<DataTable
|
||||||
columns={appSummaryColumns}
|
columns={appSummaryColumns}
|
||||||
emptyLabel="暂无抽奖数据"
|
emptyLabel="暂无抽奖数据"
|
||||||
items={appSummaries}
|
items={appSummaries}
|
||||||
minWidth="920px"
|
minWidth="920px"
|
||||||
rowKey={(item) => item.app_code}
|
rowKey={(item) => item.app_code}
|
||||||
title="各应用抽奖汇总"
|
title="各应用抽奖汇总"
|
||||||
total={appSummaries.length}
|
total={appSummaries.length}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const poolBalanceColumns = [
|
function buildPoolBalanceColumns({ canCredit, canDebit, disabled, onCredit, onDebit }) {
|
||||||
{ key: "app_code", label: "应用", render: (item) => item.app_code ?? item.appCode, width: "minmax(96px, 0.8fr)" },
|
const columns = [
|
||||||
{ key: "pool_id", label: "奖池", render: (item) => item.pool_id ?? item.poolId, width: "minmax(110px, 1fr)" },
|
{ key: "app_code", label: "应用", width: "minmax(96px, 0.8fr)" },
|
||||||
{
|
{ key: "pool_id", label: "奖池", width: "minmax(110px, 1fr)" },
|
||||||
key: "materialized",
|
{ key: "strategy_version", label: "策略", width: "minmax(112px, 0.9fr)" },
|
||||||
label: "水位状态",
|
{
|
||||||
// materialized=false 表示该奖池还没有真实入账流水,余额是规则推导的初始水位,不能当作可支配资金。
|
key: "materialized",
|
||||||
render: (item) =>
|
label: "水位状态",
|
||||||
item.materialized === false ? <OpsStatusBadge label="默认水位" /> : <OpsStatusBadge label="已入账" tone="succeeded" />,
|
// materialized=false 是 owner 根据当前策略推导的初始水位;仍允许补水或扣水,首次调整由 owner 原子物化该策略奖池。
|
||||||
width: "minmax(132px, 0.9fr)"
|
render: (item) => {
|
||||||
},
|
if (item.is_historical) {
|
||||||
{ key: "balance", label: "当前余额", render: (item) => formatNumber(item.balance ?? item.Balance) },
|
return <OpsStatusBadge label="历史奖池" tone="stopped" />;
|
||||||
{ key: "available_balance", label: "可用余额", render: (item) => formatNumber(item.available_balance ?? item.availableBalance) },
|
}
|
||||||
{ key: "reserve_floor", label: "保底线", render: (item) => formatNumber(item.reserve_floor ?? item.reserveFloor), width: "minmax(96px, 0.8fr)" },
|
return item.materialized === false ? (
|
||||||
{ key: "total_in", label: "累计流入", render: (item) => formatNumber(item.total_in ?? item.totalIn) },
|
<OpsStatusBadge label="默认水位" />
|
||||||
{ key: "total_out", label: "累计流出", render: (item) => formatNumber(item.total_out ?? item.totalOut) },
|
) : (
|
||||||
{
|
<OpsStatusBadge label="已入账" tone="succeeded" />
|
||||||
key: "updated_at_ms",
|
);
|
||||||
label: "更新时间",
|
},
|
||||||
render: (item) =>
|
width: "minmax(132px, 0.9fr)",
|
||||||
item.materialized === false ? "-" : <TimeText value={item.updated_at_ms ?? item.updatedAtMs} />,
|
},
|
||||||
width: "minmax(150px, 1fr)"
|
{ key: "balance", label: "当前余额", render: (item) => formatNumber(item.balance) },
|
||||||
}
|
{ key: "available_balance", label: "可用余额", render: (item) => formatNumber(item.available_balance) },
|
||||||
];
|
{
|
||||||
|
key: "reserve_floor",
|
||||||
|
label: "保底线",
|
||||||
|
render: (item) => formatNumber(item.reserve_floor),
|
||||||
|
width: "minmax(96px, 0.8fr)",
|
||||||
|
},
|
||||||
|
{ key: "total_in", label: "业务流入", render: (item) => formatNumber(item.total_in) },
|
||||||
|
{ key: "total_out", label: "返奖流出", render: (item) => formatNumber(item.total_out) },
|
||||||
|
{ key: "manual_credit_total", label: "人工添加", render: (item) => formatNumber(item.manual_credit_total) },
|
||||||
|
{ key: "manual_debit_total", label: "人工扣减", render: (item) => formatNumber(item.manual_debit_total) },
|
||||||
|
{
|
||||||
|
key: "updated_at_ms",
|
||||||
|
label: "更新时间",
|
||||||
|
render: (item) => (item.materialized === false ? "-" : <TimeText value={item.updated_at_ms} />),
|
||||||
|
width: "minmax(150px, 1fr)",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (canCredit || canDebit) {
|
||||||
|
columns.push({
|
||||||
|
fixed: "right",
|
||||||
|
key: "actions",
|
||||||
|
label: "操作",
|
||||||
|
render: (item) => (
|
||||||
|
<LuckyGiftPoolActions
|
||||||
|
canCredit={canCredit}
|
||||||
|
canDebit={canDebit}
|
||||||
|
disabled={disabled}
|
||||||
|
pool={item}
|
||||||
|
onCredit={onCredit}
|
||||||
|
onDebit={onDebit}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
width: canCredit && canDebit ? "220px" : "120px",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return columns;
|
||||||
|
}
|
||||||
|
|
||||||
const appSummaryColumns = [
|
const appSummaryColumns = [
|
||||||
{ key: "app_code", label: "应用", width: "minmax(96px, 0.8fr)" },
|
{ key: "app_code", label: "应用", width: "minmax(96px, 0.8fr)" },
|
||||||
{ key: "total_draws", label: "抽奖次数", render: (item) => formatNumber(item.total_draws) },
|
{ key: "total_draws", label: "抽奖次数", render: (item) => formatNumber(item.total_draws) },
|
||||||
{ key: "unique_users", label: "参与用户", render: (item) => formatNumber(item.unique_users) },
|
{ key: "unique_users", label: "参与用户", render: (item) => formatNumber(item.unique_users) },
|
||||||
{ key: "unique_rooms", label: "参与房间", render: (item) => formatNumber(item.unique_rooms) },
|
{ key: "unique_rooms", label: "参与房间", render: (item) => formatNumber(item.unique_rooms) },
|
||||||
{ key: "total_spent_coins", label: "消耗金币", render: (item) => formatNumber(item.total_spent_coins) },
|
{ key: "total_spent_coins", label: "消耗金币", render: (item) => formatNumber(item.total_spent_coins) },
|
||||||
{ key: "total_reward_coins", label: "返还金币", render: (item) => formatNumber(item.total_reward_coins) },
|
{ key: "total_reward_coins", label: "返还金币", render: (item) => formatNumber(item.total_reward_coins) },
|
||||||
{
|
{
|
||||||
key: "actual_rtp_ppm",
|
key: "actual_rtp_ppm",
|
||||||
label: "实际 RTP",
|
label: "实际 RTP",
|
||||||
render: (item) => (item.total_draws > 0 ? formatPercentFromPPM(item.actual_rtp_ppm) : "-")
|
render: (item) => (item.total_draws > 0 ? formatPercentFromPPM(item.actual_rtp_ppm) : "-"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "exceptions",
|
key: "exceptions",
|
||||||
label: "发放异常",
|
label: "发放异常",
|
||||||
// 待发放和失败合并成一列告警:正常应为 0,非 0 说明发奖链路有积压或失败,需要跟进抽奖记录。
|
// 待发放和失败合并成一列告警:正常应为 0,非 0 说明发奖链路有积压或失败,需要跟进抽奖记录。
|
||||||
render: (item) => {
|
render: (item) => {
|
||||||
const pending = Number(item.pending_draws || 0);
|
const pending = Number(item.pending_draws || 0);
|
||||||
const failed = Number(item.failed_draws || 0);
|
const failed = Number(item.failed_draws || 0);
|
||||||
if (!pending && !failed) {
|
if (!pending && !failed) {
|
||||||
return <OpsStatusBadge label="正常" tone="succeeded" />;
|
return <OpsStatusBadge label="正常" tone="succeeded" />;
|
||||||
}
|
}
|
||||||
return <OpsStatusBadge label={`待发放 ${formatNumber(pending)} · 失败 ${formatNumber(failed)}`} tone={failed ? "danger" : "warning"} />;
|
return (
|
||||||
|
<OpsStatusBadge
|
||||||
|
label={`待发放 ${formatNumber(pending)} · 失败 ${formatNumber(failed)}`}
|
||||||
|
tone={failed ? "danger" : "warning"}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
width: "minmax(170px, 1.2fr)",
|
||||||
},
|
},
|
||||||
width: "minmax(170px, 1.2fr)"
|
|
||||||
}
|
|
||||||
];
|
];
|
||||||
|
|
||||||
function countBySource(apps = [], source) {
|
function countBySource(apps = [], source) {
|
||||||
return apps.filter((item) => (item.source || "registry") === source).length;
|
return apps.filter((item) => (item.source || "registry") === source).length;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,8 +11,21 @@ import { DEFAULT_POOL_ID } from "./api.js";
|
|||||||
|
|
||||||
const DYNAMIC_STRATEGY = "dynamic_v3";
|
const DYNAMIC_STRATEGY = "dynamic_v3";
|
||||||
const PPM_SCALE = 1_000_000;
|
const PPM_SCALE = 1_000_000;
|
||||||
|
const LEGACY_JACKPOT_RTP_MODE = "__legacy_jackpot_rtp_fields";
|
||||||
|
const legacyJackpotRTPAliases = {
|
||||||
|
jackpot_user_48h_rtp_max_percent: [
|
||||||
|
"jackpot_user_72h_rtp_max_percent",
|
||||||
|
"jackpotUser72hRTPMaxPercent",
|
||||||
|
"jackpotUser72hRtpMaxPercent",
|
||||||
|
],
|
||||||
|
jackpot_user_round_rtp_max_percent: [
|
||||||
|
"jackpot_user_day_rtp_max_percent",
|
||||||
|
"jackpotUserDayRTPMaxPercent",
|
||||||
|
"jackpotUserDayRtpMaxPercent",
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
// 只有跨 App 通用的算法参数才有默认值;50 美元等值门槛和六维金额上限必须由运营按 App 金币口径填写,
|
// 只有跨 App 通用的算法参数才有默认值;六维金额上限必须由运营按 App 奖池的金币口径填写,
|
||||||
// 因而默认保持 0 且规则停用,避免把某个 App 的预算误发到其他 App。
|
// 因而默认保持 0 且规则停用,避免把某个 App 的预算误发到其他 App。
|
||||||
const dynamicDefaults = {
|
const dynamicDefaults = {
|
||||||
anchor_daily_payout_cap: 0,
|
anchor_daily_payout_cap: 0,
|
||||||
@ -21,12 +34,13 @@ const dynamicDefaults = {
|
|||||||
device_daily_payout_cap: 0,
|
device_daily_payout_cap: 0,
|
||||||
high_water_nonzero_factor_percent: 130,
|
high_water_nonzero_factor_percent: 130,
|
||||||
high_watermark_coins: 20_000_000,
|
high_watermark_coins: 20_000_000,
|
||||||
initial_pool_coins: 1_000_000,
|
// dynamic_v3 的运行资金只能通过带审计的奖池水位接口注入,发布规则不再隐式 seed。
|
||||||
|
initial_pool_coins: 0,
|
||||||
jackpot_global_rtp_max_percent: 98,
|
jackpot_global_rtp_max_percent: 98,
|
||||||
jackpot_multipliers: [200, 500, 1000],
|
jackpot_multipliers: [200, 500, 1000],
|
||||||
jackpot_spend_threshold_coins: 0,
|
jackpot_spend_threshold_coins: 0,
|
||||||
jackpot_user_72h_rtp_max_percent: 96,
|
jackpot_user_48h_rtp_max_percent: 96,
|
||||||
jackpot_user_day_rtp_max_percent: 96,
|
jackpot_user_round_rtp_max_percent: 96,
|
||||||
loss_streak_guarantee: 5,
|
loss_streak_guarantee: 5,
|
||||||
low_water_nonzero_factor_percent: 70,
|
low_water_nonzero_factor_percent: 70,
|
||||||
low_watermark_coins: 10_000_000,
|
low_watermark_coins: 10_000_000,
|
||||||
@ -54,8 +68,8 @@ const numericFields = [
|
|||||||
"initial_pool_coins",
|
"initial_pool_coins",
|
||||||
"jackpot_global_rtp_max_percent",
|
"jackpot_global_rtp_max_percent",
|
||||||
"jackpot_spend_threshold_coins",
|
"jackpot_spend_threshold_coins",
|
||||||
"jackpot_user_72h_rtp_max_percent",
|
"jackpot_user_48h_rtp_max_percent",
|
||||||
"jackpot_user_day_rtp_max_percent",
|
"jackpot_user_round_rtp_max_percent",
|
||||||
"loss_streak_guarantee",
|
"loss_streak_guarantee",
|
||||||
"low_water_nonzero_factor_percent",
|
"low_water_nonzero_factor_percent",
|
||||||
"low_watermark_coins",
|
"low_watermark_coins",
|
||||||
@ -108,12 +122,30 @@ export function configToForm(config = {}) {
|
|||||||
stages: normalizeFormStages(config.stages, targetRTPPercent, strategyVersion),
|
stages: normalizeFormStages(config.stages, targetRTPPercent, strategyVersion),
|
||||||
strategy_version: strategyVersion,
|
strategy_version: strategyVersion,
|
||||||
};
|
};
|
||||||
|
// 只在明确收到旧 day/72h 且缺少对应 canonical 字段时进入兼容模式。页面始终编辑 canonical 值,
|
||||||
|
// 这个标记仅让纯函数保留旧对象形状;HTTP 层仍会归一成 round/48h 后再发送。
|
||||||
|
form[LEGACY_JACKPOT_RTP_MODE] = Object.entries(legacyJackpotRTPAliases).some(
|
||||||
|
([canonical, aliases]) =>
|
||||||
|
!hasConfigValue(config, [canonical, snakeToCamel(canonical)]) && hasConfigValue(config, aliases),
|
||||||
|
);
|
||||||
for (const field of numericFields) {
|
for (const field of numericFields) {
|
||||||
if (field === "target_rtp_percent") {
|
if (field === "target_rtp_percent") {
|
||||||
form[field] = targetRTPPercent;
|
form[field] = targetRTPPercent;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
form[field] = formConfigNumber(config, field, snakeToCamel(field), defaultNumber(field, strategyVersion));
|
if (field === "initial_pool_coins" && strategyVersion === DYNAMIC_STRATEGY) {
|
||||||
|
// 旧 dynamic_v3 快照可能仍保存 1,000,000;新增版本必须显式归零,不能把历史隐式注资语义继续复制。
|
||||||
|
form[field] = "0";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const legacyAliases = legacyJackpotRTPAliases[field] || [];
|
||||||
|
form[field] = hasConfigValue(config, [field, snakeToCamel(field)])
|
||||||
|
? formConfigNumber(config, field, snakeToCamel(field), defaultNumber(field, strategyVersion))
|
||||||
|
: formNumber(firstConfigValue(config, legacyAliases) ?? defaultNumber(field, strategyVersion));
|
||||||
|
}
|
||||||
|
if (form[LEGACY_JACKPOT_RTP_MODE]) {
|
||||||
|
form.jackpot_user_72h_rtp_max_percent = form.jackpot_user_48h_rtp_max_percent;
|
||||||
|
form.jackpot_user_day_rtp_max_percent = form.jackpot_user_round_rtp_max_percent;
|
||||||
}
|
}
|
||||||
return form;
|
return form;
|
||||||
}
|
}
|
||||||
@ -125,22 +157,41 @@ export function formToConfig(config, form) {
|
|||||||
enabled: Boolean(form.enabled),
|
enabled: Boolean(form.enabled),
|
||||||
jackpot_multipliers: multiplierListFromForm(form.jackpot_multipliers),
|
jackpot_multipliers: multiplierListFromForm(form.jackpot_multipliers),
|
||||||
pool_id: form.pool_id,
|
pool_id: form.pool_id,
|
||||||
stages: (form.stages || []).map((stage) => ({
|
stages: (form.stages || []).map((stage) => {
|
||||||
min_recharge_30d_coins: numberFromForm(stage.min_recharge_30d_coins),
|
// dynamic_v3 的新手范围由普通阶段下限反向派生;0/0 只是旧 DTO 要求的兜底哨兵值,
|
||||||
min_recharge_7d_coins: numberFromForm(stage.min_recharge_7d_coins),
|
// 不是可运营的“充值为 0 才是新手”规则。序列化时强制归零,避免历史异常值改变分层。
|
||||||
stage: stage.stage,
|
const isDynamicNovice = form.strategy_version === DYNAMIC_STRATEGY && stage.stage === "novice";
|
||||||
tiers: (stage.tiers || []).map((tier) => ({
|
return {
|
||||||
enabled: tier.enabled !== false,
|
min_recharge_30d_coins: isDynamicNovice ? 0 : numberFromForm(stage.min_recharge_30d_coins),
|
||||||
high_water_only: Boolean(tier.highWaterOnly),
|
min_recharge_7d_coins: isDynamicNovice ? 0 : numberFromForm(stage.min_recharge_7d_coins),
|
||||||
multiplier: numberFromForm(tier.multiplier),
|
stage: stage.stage,
|
||||||
probability_percent: numberFromForm(tier.probabilityPercent),
|
tiers: (stage.tiers || []).map((tier) => ({
|
||||||
tier_id: tier.tierId || "",
|
enabled: tier.enabled !== false,
|
||||||
})),
|
high_water_only: Boolean(tier.highWaterOnly),
|
||||||
})),
|
multiplier: numberFromForm(tier.multiplier),
|
||||||
|
probability_percent: numberFromForm(tier.probabilityPercent),
|
||||||
|
tier_id: tier.tierId || "",
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}),
|
||||||
strategy_version: form.strategy_version,
|
strategy_version: form.strategy_version,
|
||||||
};
|
};
|
||||||
for (const field of numericFields) {
|
for (const field of numericFields) {
|
||||||
output[field] = numberFromForm(form[field]);
|
output[field] =
|
||||||
|
field === "initial_pool_coins" && form.strategy_version === DYNAMIC_STRATEGY
|
||||||
|
? 0
|
||||||
|
: numberFromForm(form[field]);
|
||||||
|
}
|
||||||
|
if (form[LEGACY_JACKPOT_RTP_MODE]) {
|
||||||
|
// 旧输入的纯函数 round-trip 保持原 shape,避免调用方只做本地比较时出现伪 diff;真正保存时
|
||||||
|
// luckyGiftConfigPayload 会把这两个别名归一成 canonical HTTP 字段,绝不会双发两套字段。
|
||||||
|
output.jackpot_user_72h_rtp_max_percent = output.jackpot_user_48h_rtp_max_percent;
|
||||||
|
output.jackpot_user_day_rtp_max_percent = output.jackpot_user_round_rtp_max_percent;
|
||||||
|
delete output.jackpot_user_48h_rtp_max_percent;
|
||||||
|
delete output.jackpot_user_round_rtp_max_percent;
|
||||||
|
} else {
|
||||||
|
delete output.jackpot_user_72h_rtp_max_percent;
|
||||||
|
delete output.jackpot_user_day_rtp_max_percent;
|
||||||
}
|
}
|
||||||
return output;
|
return output;
|
||||||
}
|
}
|
||||||
@ -257,7 +308,11 @@ export function validateLuckyGiftForm(form) {
|
|||||||
const jackpotStageMultipliers =
|
const jackpotStageMultipliers =
|
||||||
jackpotMultipliers &&
|
jackpotMultipliers &&
|
||||||
enabled
|
enabled
|
||||||
.filter((tier) => jackpotMultipliers.has(numberFromForm(tier.multiplier)) && percentToPPM(tier.probabilityPercent) > 0)
|
.filter(
|
||||||
|
(tier) =>
|
||||||
|
jackpotMultipliers.has(numberFromForm(tier.multiplier)) &&
|
||||||
|
percentToPPM(tier.probabilityPercent) > 0,
|
||||||
|
)
|
||||||
.map((tier) => formatDecimal(numberFromForm(tier.multiplier)));
|
.map((tier) => formatDecimal(numberFromForm(tier.multiplier)));
|
||||||
if (jackpotStageMultipliers?.length) {
|
if (jackpotStageMultipliers?.length) {
|
||||||
// dynamic_v3 的大奖倍率只能走大奖补偿路径;普通阶段再给正概率会绕过 owner 的双 RTP 门槛。
|
// dynamic_v3 的大奖倍率只能走大奖补偿路径;普通阶段再给正概率会绕过 owner 的双 RTP 门槛。
|
||||||
@ -282,7 +337,7 @@ export function validateLuckyGiftForm(form) {
|
|||||||
PPM_SCALE
|
PPM_SCALE
|
||||||
)
|
)
|
||||||
errors.push("奖池、平台盈利和主播收益比例合计必须等于 100%");
|
errors.push("奖池、平台盈利和主播收益比例合计必须等于 100%");
|
||||||
if (number("initial_pool_coins") <= 0) errors.push("初始奖池必须大于 0");
|
if (number("initial_pool_coins") !== 0) errors.push("dynamic_v3 初始奖池必须为 0,请通过水位操作注资");
|
||||||
if (number("loss_streak_guarantee") <= 0) errors.push("连续未中奖保护次数必须大于 0");
|
if (number("loss_streak_guarantee") <= 0) errors.push("连续未中奖保护次数必须大于 0");
|
||||||
if (number("low_watermark_coins") <= 0 || number("high_watermark_coins") <= number("low_watermark_coins"))
|
if (number("low_watermark_coins") <= 0 || number("high_watermark_coins") <= number("low_watermark_coins"))
|
||||||
errors.push("高水位必须大于正数低水位");
|
errors.push("高水位必须大于正数低水位");
|
||||||
@ -305,23 +360,24 @@ export function validateLuckyGiftForm(form) {
|
|||||||
if (number("jackpot_global_rtp_max_percent") <= 0 || number("jackpot_global_rtp_max_percent") > 100)
|
if (number("jackpot_global_rtp_max_percent") <= 0 || number("jackpot_global_rtp_max_percent") > 100)
|
||||||
errors.push("全局大奖 RTP 上限必须在 0%-100% 之间");
|
errors.push("全局大奖 RTP 上限必须在 0%-100% 之间");
|
||||||
if (
|
if (
|
||||||
number("jackpot_user_day_rtp_max_percent") <= 0 ||
|
number("jackpot_user_round_rtp_max_percent") <= 0 ||
|
||||||
number("jackpot_user_day_rtp_max_percent") > number("jackpot_global_rtp_max_percent")
|
number("jackpot_user_round_rtp_max_percent") > number("jackpot_global_rtp_max_percent")
|
||||||
)
|
)
|
||||||
errors.push("用户每日 RTP 上限必须大于 0 且不超过全局上限");
|
errors.push("用户本轮 RTP 触发线必须大于 0 且不超过全局上限");
|
||||||
if (
|
if (
|
||||||
number("jackpot_user_72h_rtp_max_percent") <= 0 ||
|
number("jackpot_user_48h_rtp_max_percent") <= 0 ||
|
||||||
number("jackpot_user_72h_rtp_max_percent") > number("jackpot_global_rtp_max_percent")
|
number("jackpot_user_48h_rtp_max_percent") > number("jackpot_global_rtp_max_percent")
|
||||||
)
|
)
|
||||||
errors.push("用户 72 小时 RTP 上限必须大于 0 且不超过全局上限");
|
errors.push("用户 48 小时 RTP 触发线必须大于 0 且不超过全局上限");
|
||||||
if (number("max_jackpot_hits_per_user_day") <= 0) errors.push("用户每日大奖次数必须大于 0");
|
if (number("max_jackpot_hits_per_user_day") <= 0) errors.push("用户每日大奖次数必须大于 0");
|
||||||
if (number("jackpot_spend_threshold_coins") < 0) errors.push("大奖消费门槛不能小于 0");
|
if (number("jackpot_spend_threshold_coins") < 0) errors.push("大奖消费门槛不能小于 0");
|
||||||
|
if (form[LEGACY_JACKPOT_RTP_MODE] && form.enabled && number("jackpot_spend_threshold_coins") <= 0)
|
||||||
|
errors.push("启用前必须填写用户日累计消费触发门槛(金币)");
|
||||||
for (const [field, label] of payoutCapFields) {
|
for (const [field, label] of payoutCapFields) {
|
||||||
if (number(field) < 0) errors.push(`${label}不能小于 0`);
|
if (number(field) < 0) errors.push(`${label}不能小于 0`);
|
||||||
}
|
}
|
||||||
validateRechargeStages(form.stages || [], errors);
|
validateRechargeStages(form.stages || [], errors, form.enabled);
|
||||||
if (form.enabled) {
|
if (form.enabled) {
|
||||||
if (number("jackpot_spend_threshold_coins") <= 0) errors.push("启用前必须填写 50 美元等值的大奖消费门槛");
|
|
||||||
for (const [field, label] of payoutCapFields) {
|
for (const [field, label] of payoutCapFields) {
|
||||||
if (number(field) <= 0) errors.push(`启用前必须填写${label}`);
|
if (number(field) <= 0) errors.push(`启用前必须填写${label}`);
|
||||||
}
|
}
|
||||||
@ -348,11 +404,15 @@ function normalizeFormStages(stages, targetRTPPercent, strategyVersion) {
|
|||||||
: correctedDefaultTiers(stageKey, targetRTPPercent);
|
: correctedDefaultTiers(stageKey, targetRTPPercent);
|
||||||
const [default7d, default30d] =
|
const [default7d, default30d] =
|
||||||
strategyVersion === DYNAMIC_STRATEGY ? defaultRechargeThreshold(stageKey) : [0, 0];
|
strategyVersion === DYNAMIC_STRATEGY ? defaultRechargeThreshold(stageKey) : [0, 0];
|
||||||
|
const isDynamicNovice = strategyVersion === DYNAMIC_STRATEGY && stageKey === "novice";
|
||||||
return {
|
return {
|
||||||
min_recharge_7d_coins: formNumber(source?.min_recharge_7d_coins ?? source?.minRecharge7DCoins ?? default7d),
|
// 只在表单内保留兼容哨兵值;界面上的新手上界始终从普通门槛派生,不读取历史 novice 门槛。
|
||||||
min_recharge_30d_coins: formNumber(
|
min_recharge_7d_coins: isDynamicNovice
|
||||||
source?.min_recharge_30d_coins ?? source?.minRecharge30DCoins ?? default30d,
|
? "0"
|
||||||
),
|
: formNumber(source?.min_recharge_7d_coins ?? source?.minRecharge7DCoins ?? default7d),
|
||||||
|
min_recharge_30d_coins: isDynamicNovice
|
||||||
|
? "0"
|
||||||
|
: formNumber(source?.min_recharge_30d_coins ?? source?.minRecharge30DCoins ?? default30d),
|
||||||
stage: stageKey,
|
stage: stageKey,
|
||||||
tiers: sourceTiers.map((tier) => ({
|
tiers: sourceTiers.map((tier) => ({
|
||||||
enabled: tier.enabled !== false,
|
enabled: tier.enabled !== false,
|
||||||
@ -385,13 +445,10 @@ function defaultTier(tierID, multiplier, probabilityPercent) {
|
|||||||
return { enabled: true, highWaterOnly: false, multiplier, probabilityPercent, tierId: tierID };
|
return { enabled: true, highWaterOnly: false, multiplier, probabilityPercent, tierId: tierID };
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateRechargeStages(stages, errors) {
|
function validateRechargeStages(stages, errors, requireComplete) {
|
||||||
const novice = stages.find((stage) => stage.stage === "novice");
|
|
||||||
const normal = stages.find((stage) => stage.stage === "normal");
|
const normal = stages.find((stage) => stage.stage === "normal");
|
||||||
const advanced = stages.find((stage) => stage.stage === "advanced");
|
const advanced = stages.find((stage) => stage.stage === "advanced");
|
||||||
const threshold = (stage, field) => numberFromForm(stage?.[field]);
|
const threshold = (stage, field) => numberFromForm(stage?.[field]);
|
||||||
if (threshold(novice, "min_recharge_7d_coins") !== 0 || threshold(novice, "min_recharge_30d_coins") !== 0)
|
|
||||||
errors.push("新手阶段充值门槛必须为 0 / 0");
|
|
||||||
const normal7d = threshold(normal, "min_recharge_7d_coins");
|
const normal7d = threshold(normal, "min_recharge_7d_coins");
|
||||||
const normal30d = threshold(normal, "min_recharge_30d_coins");
|
const normal30d = threshold(normal, "min_recharge_30d_coins");
|
||||||
const advanced7d = threshold(advanced, "min_recharge_7d_coins");
|
const advanced7d = threshold(advanced, "min_recharge_7d_coins");
|
||||||
@ -400,15 +457,21 @@ function validateRechargeStages(stages, errors) {
|
|||||||
errors.push("充值阶段门槛不能小于 0");
|
errors.push("充值阶段门槛不能小于 0");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (normal7d === 0 && normal30d === 0) errors.push("正常阶段至少一个充值门槛必须大于 0");
|
const isUnconfigured = [normal7d, normal30d, advanced7d, advanced30d].every((value) => value === 0);
|
||||||
|
// 新建的停用草稿可以全 0,避免猜测 App 金币价值;一旦开始填写或准备启用,
|
||||||
|
// 就必须遵守 owner 的二维下限契约:normal 至少一维大于 0,advanced 逐维不低且至少一维更高。
|
||||||
|
if (!requireComplete && isUnconfigured) return;
|
||||||
|
if (normal7d === 0 && normal30d === 0)
|
||||||
|
errors.push(
|
||||||
|
requireComplete ? "启用前普通阶段至少一个最低充值门槛必须大于 0" : "普通阶段至少一个充值门槛必须大于 0",
|
||||||
|
);
|
||||||
if (advanced7d < normal7d || advanced30d < normal30d || (advanced7d === normal7d && advanced30d === normal30d))
|
if (advanced7d < normal7d || advanced30d < normal30d || (advanced7d === normal7d && advanced30d === normal30d))
|
||||||
errors.push("高阶充值门槛必须逐维不低于正常阶段且至少一项更高");
|
errors.push("高阶充值门槛必须逐维不低于正常阶段且至少一项更高");
|
||||||
}
|
}
|
||||||
|
|
||||||
function defaultRechargeThreshold(stage) {
|
function defaultRechargeThreshold() {
|
||||||
if (stage === "novice") return [0, 0];
|
// 充值门槛受 App 金币比例影响,新草稿不猜测 1 金币等于可用配置;运营填完后才能启用。
|
||||||
if (stage === "normal") return [0, 1];
|
return [0, 0];
|
||||||
return [1, 1];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function defaultNumber(field, strategyVersion) {
|
function defaultNumber(field, strategyVersion) {
|
||||||
@ -443,6 +506,17 @@ function formConfigNumber(config, snake, camel, fallback) {
|
|||||||
return formNumber(config[snake] ?? config[camel] ?? fallback);
|
return formNumber(config[snake] ?? config[camel] ?? fallback);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function firstConfigValue(config, keys) {
|
||||||
|
for (const key of keys) {
|
||||||
|
if (config[key] !== undefined && config[key] !== null) return config[key];
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasConfigValue(config, keys) {
|
||||||
|
return firstConfigValue(config, keys) !== undefined;
|
||||||
|
}
|
||||||
|
|
||||||
function snakeToCamel(value) {
|
function snakeToCamel(value) {
|
||||||
return value.replace(/_([a-z0-9])/g, (_, letter) => letter.toUpperCase());
|
return value.replace(/_([a-z0-9])/g, (_, letter) => letter.toUpperCase());
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,7 +8,7 @@ describe("ops center lucky gift dynamic_v3 form", () => {
|
|||||||
expect(form).toMatchObject({
|
expect(form).toMatchObject({
|
||||||
anchor_rate_percent: "1",
|
anchor_rate_percent: "1",
|
||||||
enabled: false,
|
enabled: false,
|
||||||
initial_pool_coins: "1000000",
|
initial_pool_coins: "0",
|
||||||
jackpot_multipliers: "200, 500, 1000",
|
jackpot_multipliers: "200, 500, 1000",
|
||||||
max_single_payout: "0",
|
max_single_payout: "0",
|
||||||
pool_rate_percent: "98",
|
pool_rate_percent: "98",
|
||||||
@ -18,8 +18,8 @@ describe("ops center lucky gift dynamic_v3 form", () => {
|
|||||||
});
|
});
|
||||||
expect(form.stages.map((stage) => [stage.min_recharge_7d_coins, stage.min_recharge_30d_coins])).toEqual([
|
expect(form.stages.map((stage) => [stage.min_recharge_7d_coins, stage.min_recharge_30d_coins])).toEqual([
|
||||||
["0", "0"],
|
["0", "0"],
|
||||||
["0", "1"],
|
["0", "0"],
|
||||||
["1", "1"],
|
["0", "0"],
|
||||||
]);
|
]);
|
||||||
expect(form.stages.map((stage) => stage.tiers.map((tier) => Number(tier.probabilityPercent)))).toEqual([
|
expect(form.stages.map((stage) => stage.tiers.map((tier) => Number(tier.probabilityPercent)))).toEqual([
|
||||||
[5, 4, 86, 5],
|
[5, 4, 86, 5],
|
||||||
@ -49,6 +49,20 @@ describe("ops center lucky gift dynamic_v3 form", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("drops legacy dynamic initial seed when loading and publishing a new version", () => {
|
||||||
|
const legacy = completeDynamicConfig();
|
||||||
|
legacy.initial_pool_coins = 1_000_000;
|
||||||
|
legacy.stages[0].min_recharge_7d_coins = 12_345;
|
||||||
|
legacy.stages[0].min_recharge_30d_coins = 67_890;
|
||||||
|
|
||||||
|
const form = configToForm(legacy);
|
||||||
|
expect(form.initial_pool_coins).toBe("0");
|
||||||
|
expect(form.stages[0]).toMatchObject({ min_recharge_7d_coins: "0", min_recharge_30d_coins: "0" });
|
||||||
|
const output = formToConfig(legacy, form);
|
||||||
|
expect(output.initial_pool_coins).toBe(0);
|
||||||
|
expect(output.stages[0]).toMatchObject({ min_recharge_7d_coins: 0, min_recharge_30d_coins: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
test("allows disabled drafts but blocks enabled dynamic rules until app monetary limits are filled", () => {
|
test("allows disabled drafts but blocks enabled dynamic rules until app monetary limits are filled", () => {
|
||||||
const draft = configToForm({ app_code: "lalu", pool_id: "default", strategy_version: "dynamic_v3" });
|
const draft = configToForm({ app_code: "lalu", pool_id: "default", strategy_version: "dynamic_v3" });
|
||||||
expect(validateLuckyGiftForm(draft)).toEqual([]);
|
expect(validateLuckyGiftForm(draft)).toEqual([]);
|
||||||
@ -56,7 +70,8 @@ describe("ops center lucky gift dynamic_v3 form", () => {
|
|||||||
const enabled = { ...draft, enabled: true };
|
const enabled = { ...draft, enabled: true };
|
||||||
const errors = validateLuckyGiftForm(enabled);
|
const errors = validateLuckyGiftForm(enabled);
|
||||||
|
|
||||||
expect(errors).toContain("启用前必须填写 50 美元等值的大奖消费门槛");
|
expect(errors).toContain("启用前普通阶段至少一个最低充值门槛必须大于 0");
|
||||||
|
expect(errors).toContain("高阶充值门槛必须逐维不低于正常阶段且至少一项更高");
|
||||||
expect(errors).toContain("启用前必须填写单次返奖上限");
|
expect(errors).toContain("启用前必须填写单次返奖上限");
|
||||||
expect(errors).toContain("启用前必须填写主播每日上限");
|
expect(errors).toContain("启用前必须填写主播每日上限");
|
||||||
});
|
});
|
||||||
@ -98,7 +113,12 @@ describe("ops center lucky gift dynamic_v3 form", () => {
|
|||||||
});
|
});
|
||||||
const dynamic = applyDynamicStrategyDefaults(legacy);
|
const dynamic = applyDynamicStrategyDefaults(legacy);
|
||||||
|
|
||||||
expect(dynamic).toMatchObject({ enabled: false, pool_rate_percent: "98", strategy_version: "dynamic_v3" });
|
expect(dynamic).toMatchObject({
|
||||||
|
enabled: false,
|
||||||
|
initial_pool_coins: "0",
|
||||||
|
pool_rate_percent: "98",
|
||||||
|
strategy_version: "dynamic_v3",
|
||||||
|
});
|
||||||
expect(dynamic.stages.map((stage) => stage.tiers.map((tier) => Number(tier.multiplier)))).toEqual([
|
expect(dynamic.stages.map((stage) => stage.tiers.map((tier) => Number(tier.multiplier)))).toEqual([
|
||||||
[0, 0.5, 1, 2],
|
[0, 0.5, 1, 2],
|
||||||
[0, 0.5, 1, 2],
|
[0, 0.5, 1, 2],
|
||||||
@ -170,7 +190,7 @@ function completeDynamicConfig() {
|
|||||||
gift_price_reference: 100,
|
gift_price_reference: 100,
|
||||||
high_water_nonzero_factor_percent: 130,
|
high_water_nonzero_factor_percent: 130,
|
||||||
high_watermark_coins: 20_000_000,
|
high_watermark_coins: 20_000_000,
|
||||||
initial_pool_coins: 1_000_000,
|
initial_pool_coins: 0,
|
||||||
jackpot_global_rtp_max_percent: 98,
|
jackpot_global_rtp_max_percent: 98,
|
||||||
jackpot_multipliers: [200, 500, 1000],
|
jackpot_multipliers: [200, 500, 1000],
|
||||||
jackpot_spend_threshold_coins: 50_000,
|
jackpot_spend_threshold_coins: 50_000,
|
||||||
|
|||||||
@ -2,21 +2,24 @@ import CssBaseline from "@mui/material/CssBaseline";
|
|||||||
import { ThemeProvider } from "@mui/material/styles";
|
import { ThemeProvider } from "@mui/material/styles";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { AuthProvider } from "@/app/auth/AuthProvider.jsx";
|
||||||
import { ToastProvider } from "@/shared/ui/ToastProvider.jsx";
|
import { ToastProvider } from "@/shared/ui/ToastProvider.jsx";
|
||||||
import { theme } from "@/theme.js";
|
import { theme } from "@/theme.js";
|
||||||
import { OpsCenterApp } from "./OpsCenterApp.jsx";
|
import { AuthenticatedOpsCenter } from "./OpsCenterEntry.jsx";
|
||||||
import "@/styles/tokens.css";
|
import "@/styles/tokens.css";
|
||||||
import "@/styles/shared-ui.css";
|
import "@/styles/shared-ui.css";
|
||||||
import "@/styles/responsive.css";
|
import "@/styles/responsive.css";
|
||||||
import "./styles/index.css";
|
import "./styles/index.css";
|
||||||
|
|
||||||
createRoot(document.getElementById("ops-center-root")).render(
|
createRoot(document.getElementById("ops-center-root")).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<ThemeProvider theme={theme}>
|
<ThemeProvider theme={theme}>
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
<CssBaseline />
|
<AuthProvider>
|
||||||
<OpsCenterApp />
|
<CssBaseline />
|
||||||
</ToastProvider>
|
<AuthenticatedOpsCenter />
|
||||||
</ThemeProvider>
|
</AuthProvider>
|
||||||
</React.StrictMode>
|
</ToastProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
</React.StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
@ -147,15 +147,66 @@ body {
|
|||||||
padding: var(--space-3) var(--space-4);
|
padding: var(--space-3) var(--space-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ops-pool-actions,
|
||||||
|
.ops-config-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-config-actions {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-pool-adjustment-form {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-pool-adjustment-content {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-4);
|
||||||
|
padding-top: var(--space-4) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-pool-adjustment-metrics {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-pool-adjustment-note {
|
||||||
|
border: 1px solid var(--info-border);
|
||||||
|
border-radius: var(--radius-control);
|
||||||
|
background: var(--info-surface);
|
||||||
|
color: var(--info);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
/* ---- 幸运礼物配置弹窗 ---- */
|
/* ---- 幸运礼物配置弹窗 ---- */
|
||||||
|
|
||||||
|
.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 {
|
||||||
@ -164,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);
|
||||||
@ -182,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;
|
||||||
@ -192,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;
|
||||||
@ -219,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 {
|
||||||
@ -246,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));
|
||||||
@ -256,44 +423,220 @@ 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-jackpot-flow {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 1px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-control);
|
||||||
|
background: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-jackpot-flow > div {
|
||||||
|
display: grid;
|
||||||
|
align-content: start;
|
||||||
|
gap: 4px;
|
||||||
|
background: var(--bg-card-strong, #f8fafc);
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-jackpot-flow strong {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-jackpot-flow span {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-field-label {
|
||||||
|
display: inline-flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: help;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* OutlinedInput 会在 notch 的 legend 中复制一次 label;保留其宽度计算,但隐藏复制品,
|
||||||
|
* 避免同一个说明入口在键盘 Tab 顺序中出现两次。 */
|
||||||
|
.MuiOutlinedInput-notchedOutline legend .ops-field-label {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-field-help-trigger {
|
||||||
|
display: inline-flex;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
flex: 0 0 16px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 14px;
|
||||||
|
transition:
|
||||||
|
background var(--motion-fast) var(--ease-standard),
|
||||||
|
color var(--motion-fast) var(--ease-standard);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-field-label:hover .ops-field-help-trigger,
|
||||||
|
.ops-field-label:focus-visible .ops-field-help-trigger {
|
||||||
|
background: var(--primary-hover);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-field-label:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 2px var(--primary-border, #93c5fd);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tooltip 通过 portal 渲染,必须用自身 class 承载完整排版;字段内只保留问号,不牺牲表单密度。 */
|
||||||
|
.MuiTooltip-tooltip.ops-field-help-tooltip {
|
||||||
|
max-width: min(420px, calc(100vw - 32px));
|
||||||
|
border: 1px solid rgb(148 163 184 / 32%);
|
||||||
|
border-radius: var(--radius-control);
|
||||||
|
background: #0f172a;
|
||||||
|
box-shadow: 0 16px 40px rgb(15 23 42 / 28%);
|
||||||
|
color: #f8fafc;
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-field-help-content {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-field-help-content > strong {
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-field-help-content dl {
|
||||||
|
display: grid;
|
||||||
|
gap: 7px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-field-help-content dl > div {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 56px minmax(0, 1fr);
|
||||||
|
align-items: start;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-field-help-content dt {
|
||||||
|
color: #93c5fd;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-field-help-content dd {
|
||||||
|
margin: 0;
|
||||||
|
color: #e2e8f0;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
.ops-stage-designer {
|
.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);
|
||||||
}
|
}
|
||||||
@ -316,17 +659,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;
|
||||||
@ -340,12 +690,38 @@ 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);
|
}
|
||||||
|
|
||||||
|
.ops-stage-thresholds > span {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-stage-threshold-rule {
|
||||||
|
display: grid;
|
||||||
|
grid-column: 2 / -1;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
border-left: 3px solid var(--primary);
|
||||||
background: var(--bg-card-strong, #f8fafc);
|
background: var(--bg-card-strong, #f8fafc);
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-stage-threshold-rule strong {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-stage-threshold-rule small {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ops-tier-table {
|
.ops-tier-table {
|
||||||
@ -361,13 +737,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(110px, 1fr)
|
minmax(100px, 1fr)
|
||||||
minmax(110px, 1fr)
|
minmax(124px, 1fr)
|
||||||
minmax(64px, auto)
|
96px
|
||||||
minmax(64px, auto)
|
64px
|
||||||
minmax(72px, auto);
|
56px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
}
|
}
|
||||||
@ -382,17 +758,94 @@ body {
|
|||||||
padding: 0 var(--space-3);
|
padding: 0 var(--space-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ops-tier-head span:nth-child(n + 3) {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 奖档表头允许自然换行,说明图标不会在窄弹窗中挤到相邻列。 */
|
||||||
|
.ops-tier-head .ops-field-label {
|
||||||
|
justify-content: center;
|
||||||
|
line-height: 1.2;
|
||||||
|
text-align: center;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
.ops-tier-row {
|
.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;
|
||||||
@ -403,14 +856,129 @@ 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-jackpot-flow {
|
||||||
|
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-stage-threshold-rule {
|
||||||
|
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) {
|
||||||
@ -420,21 +988,19 @@ body {
|
|||||||
|
|
||||||
.ops-nav,
|
.ops-nav,
|
||||||
.ops-kpi-grid,
|
.ops-kpi-grid,
|
||||||
.ops-config-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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -55,7 +55,7 @@ export const PERMISSIONS = {
|
|||||||
financeOrderCoinSellerRechargeCreate: "finance-order:coin-seller-recharge:create",
|
financeOrderCoinSellerRechargeCreate: "finance-order:coin-seller-recharge:create",
|
||||||
financeOrderCoinSellerRechargeVerify: "finance-order:coin-seller-recharge:verify",
|
financeOrderCoinSellerRechargeVerify: "finance-order:coin-seller-recharge:verify",
|
||||||
financeOrderCoinSellerRechargeGrant: "finance-order:coin-seller-recharge:grant",
|
financeOrderCoinSellerRechargeGrant: "finance-order:coin-seller-recharge:grant",
|
||||||
financeOrderUSDTAddressUpdate: "finance-order:usdt-address:update",
|
financeOrderUsdtAddressUpdate: "finance-order:usdt-address:update",
|
||||||
coinLedgerView: "coin-ledger:view",
|
coinLedgerView: "coin-ledger:view",
|
||||||
coinSellerLedgerView: "coin-seller-ledger:view",
|
coinSellerLedgerView: "coin-seller-ledger:view",
|
||||||
giftRecordView: "gift-record:view",
|
giftRecordView: "gift-record:view",
|
||||||
@ -191,6 +191,8 @@ export const PERMISSIONS = {
|
|||||||
sevenDayCheckInUpdate: "seven-day-checkin:update",
|
sevenDayCheckInUpdate: "seven-day-checkin:update",
|
||||||
luckyGiftView: "lucky-gift:view",
|
luckyGiftView: "lucky-gift:view",
|
||||||
luckyGiftUpdate: "lucky-gift:update",
|
luckyGiftUpdate: "lucky-gift:update",
|
||||||
|
luckyGiftPoolCredit: "lucky-gift:pool-credit",
|
||||||
|
luckyGiftPoolDebit: "lucky-gift:pool-debit",
|
||||||
wheelView: "wheel:view",
|
wheelView: "wheel:view",
|
||||||
wheelUpdate: "wheel:update",
|
wheelUpdate: "wheel:update",
|
||||||
roomRocketView: "room-rocket:view",
|
roomRocketView: "room-rocket:view",
|
||||||
|
|||||||
@ -81,9 +81,11 @@ export const API_OPERATIONS = {
|
|||||||
createUser: "createUser",
|
createUser: "createUser",
|
||||||
createWeeklyStarCycle: "createWeeklyStarCycle",
|
createWeeklyStarCycle: "createWeeklyStarCycle",
|
||||||
creditCoinSellerStock: "creditCoinSellerStock",
|
creditCoinSellerStock: "creditCoinSellerStock",
|
||||||
|
creditLuckyGiftPool: "creditLuckyGiftPool",
|
||||||
dashboardOverview: "dashboardOverview",
|
dashboardOverview: "dashboardOverview",
|
||||||
dashboardUserProfileOverview: "dashboardUserProfileOverview",
|
dashboardUserProfileOverview: "dashboardUserProfileOverview",
|
||||||
debitCoinSellerStock: "debitCoinSellerStock",
|
debitCoinSellerStock: "debitCoinSellerStock",
|
||||||
|
debitLuckyGiftPool: "debitLuckyGiftPool",
|
||||||
deleteAchievementDefinition: "deleteAchievementDefinition",
|
deleteAchievementDefinition: "deleteAchievementDefinition",
|
||||||
deleteActivityTemplate: "deleteActivityTemplate",
|
deleteActivityTemplate: "deleteActivityTemplate",
|
||||||
deleteAgency: "deleteAgency",
|
deleteAgency: "deleteAgency",
|
||||||
@ -884,6 +886,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
permission: "coin-seller:stock-credit",
|
permission: "coin-seller:stock-credit",
|
||||||
permissions: ["coin-seller:stock-credit"]
|
permissions: ["coin-seller:stock-credit"]
|
||||||
},
|
},
|
||||||
|
creditLuckyGiftPool: {
|
||||||
|
method: "POST",
|
||||||
|
operationId: API_OPERATIONS.creditLuckyGiftPool,
|
||||||
|
path: "/v1/admin/ops-center/lucky-gifts/pools/credit",
|
||||||
|
permission: "lucky-gift:pool-credit",
|
||||||
|
permissions: ["lucky-gift:pool-credit"]
|
||||||
|
},
|
||||||
dashboardOverview: {
|
dashboardOverview: {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
operationId: API_OPERATIONS.dashboardOverview,
|
operationId: API_OPERATIONS.dashboardOverview,
|
||||||
@ -905,6 +914,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
permission: "coin-seller:stock-credit",
|
permission: "coin-seller:stock-credit",
|
||||||
permissions: ["coin-seller:stock-credit"]
|
permissions: ["coin-seller:stock-credit"]
|
||||||
},
|
},
|
||||||
|
debitLuckyGiftPool: {
|
||||||
|
method: "POST",
|
||||||
|
operationId: API_OPERATIONS.debitLuckyGiftPool,
|
||||||
|
path: "/v1/admin/ops-center/lucky-gifts/pools/debit",
|
||||||
|
permission: "lucky-gift:pool-debit",
|
||||||
|
permissions: ["lucky-gift:pool-debit"]
|
||||||
|
},
|
||||||
deleteAchievementDefinition: {
|
deleteAchievementDefinition: {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
operationId: API_OPERATIONS.deleteAchievementDefinition,
|
operationId: API_OPERATIONS.deleteAchievementDefinition,
|
||||||
|
|||||||
167
src/shared/api/generated/schema.d.ts
vendored
167
src/shared/api/generated/schema.d.ts
vendored
@ -324,6 +324,38 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/admin/ops-center/lucky-gifts/pools/credit": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
post: operations["creditLuckyGiftPool"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/admin/ops-center/lucky-gifts/pools/debit": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
post: operations["debitLuckyGiftPool"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/admin/activity/wheel/config": {
|
"/admin/activity/wheel/config": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -4760,6 +4792,64 @@ export interface paths {
|
|||||||
export type webhooks = Record<string, never>;
|
export type webhooks = Record<string, never>;
|
||||||
export interface components {
|
export interface components {
|
||||||
schemas: {
|
schemas: {
|
||||||
|
LuckyGiftPoolAdjustmentInput: {
|
||||||
|
adjustment_id: string;
|
||||||
|
/** Format: int64 */
|
||||||
|
amount_coins: number;
|
||||||
|
reason: string;
|
||||||
|
};
|
||||||
|
LuckyGiftPoolBalance: {
|
||||||
|
app_code: string;
|
||||||
|
pool_id: string;
|
||||||
|
/** @enum {string} */
|
||||||
|
strategy_version: "fixed_v2" | "dynamic_v3";
|
||||||
|
/** Format: int64 */
|
||||||
|
balance: number;
|
||||||
|
/** Format: int64 */
|
||||||
|
reserve_floor: number;
|
||||||
|
/** Format: int64 */
|
||||||
|
available_balance: number;
|
||||||
|
/** Format: int64 */
|
||||||
|
total_in: number;
|
||||||
|
/** Format: int64 */
|
||||||
|
total_out: number;
|
||||||
|
/** Format: int64 */
|
||||||
|
manual_credit_total: number;
|
||||||
|
/** Format: int64 */
|
||||||
|
manual_debit_total: number;
|
||||||
|
materialized: boolean;
|
||||||
|
/** Format: int64 */
|
||||||
|
updated_at_ms: number;
|
||||||
|
};
|
||||||
|
LuckyGiftPoolAdjustment: {
|
||||||
|
adjustment_id: string;
|
||||||
|
app_code: string;
|
||||||
|
pool_id: string;
|
||||||
|
/** @enum {string} */
|
||||||
|
strategy_version: "fixed_v2" | "dynamic_v3";
|
||||||
|
/** @enum {string} */
|
||||||
|
direction: "in" | "out";
|
||||||
|
/** Format: int64 */
|
||||||
|
amount_coins: number;
|
||||||
|
reason: string;
|
||||||
|
/** Format: int64 */
|
||||||
|
operator_admin_id: number;
|
||||||
|
/** Format: int64 */
|
||||||
|
balance_before: number;
|
||||||
|
/** Format: int64 */
|
||||||
|
balance_after: number;
|
||||||
|
/** Format: int64 */
|
||||||
|
created_at_ms: number;
|
||||||
|
};
|
||||||
|
LuckyGiftPoolAdjustmentResult: {
|
||||||
|
adjustment: components["schemas"]["LuckyGiftPoolAdjustment"];
|
||||||
|
pool: components["schemas"]["LuckyGiftPoolBalance"];
|
||||||
|
idempotent_replay: boolean;
|
||||||
|
};
|
||||||
|
ApiResponseLuckyGiftPoolAdjustmentResult: components["schemas"]["Envelope"] & {
|
||||||
|
requestId?: string;
|
||||||
|
data?: components["schemas"]["LuckyGiftPoolAdjustmentResult"];
|
||||||
|
};
|
||||||
ApiResponseAdminAppList: components["schemas"]["Envelope"] & {
|
ApiResponseAdminAppList: components["schemas"]["Envelope"] & {
|
||||||
data?: components["schemas"]["AdminAppList"];
|
data?: components["schemas"]["AdminAppList"];
|
||||||
};
|
};
|
||||||
@ -7251,6 +7341,15 @@ export interface components {
|
|||||||
"application/json": components["schemas"]["ApiResponseEmpty"];
|
"application/json": components["schemas"]["ApiResponseEmpty"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
/** @description 奖池水位调整成功或同幂等请求的首次结果 */
|
||||||
|
LuckyGiftPoolAdjustmentResponse: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ApiResponseLuckyGiftPoolAdjustmentResult"];
|
||||||
|
};
|
||||||
|
};
|
||||||
/** @description OK */
|
/** @description OK */
|
||||||
RechargeBillPageResponse: {
|
RechargeBillPageResponse: {
|
||||||
headers: {
|
headers: {
|
||||||
@ -8172,6 +8271,74 @@ export interface operations {
|
|||||||
200: components["responses"]["EmptyResponse"];
|
200: components["responses"]["EmptyResponse"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
creditLuckyGiftPool: {
|
||||||
|
parameters: {
|
||||||
|
query: {
|
||||||
|
app_code: string;
|
||||||
|
pool_id: string;
|
||||||
|
strategy_version: "fixed_v2" | "dynamic_v3";
|
||||||
|
};
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["LuckyGiftPoolAdjustmentInput"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
200: components["responses"]["LuckyGiftPoolAdjustmentResponse"];
|
||||||
|
/** @description 调整参数不正确 */
|
||||||
|
400: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
/** @description 幂等键冲突或可用水位不足 */
|
||||||
|
409: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
debitLuckyGiftPool: {
|
||||||
|
parameters: {
|
||||||
|
query: {
|
||||||
|
app_code: string;
|
||||||
|
pool_id: string;
|
||||||
|
strategy_version: "fixed_v2" | "dynamic_v3";
|
||||||
|
};
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["LuckyGiftPoolAdjustmentInput"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
200: components["responses"]["LuckyGiftPoolAdjustmentResponse"];
|
||||||
|
/** @description 调整参数不正确 */
|
||||||
|
400: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
/** @description 幂等键冲突或可用水位不足 */
|
||||||
|
409: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
getWheelConfig: {
|
getWheelConfig: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user