Merge test into main
This commit is contained in:
commit
231437f0fb
@ -390,6 +390,86 @@
|
||||
"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": {
|
||||
"get": {
|
||||
"operationId": "getWheelConfig",
|
||||
@ -9071,6 +9151,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"LuckyGiftPoolAdjustmentResponse": {
|
||||
"description": "奖池水位调整成功或同幂等请求的首次结果",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResponseLuckyGiftPoolAdjustmentResult"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"RechargeBillPageResponse": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
@ -9483,6 +9573,115 @@
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"allOf": [
|
||||
{
|
||||
|
||||
@ -16,6 +16,7 @@ import {
|
||||
DATE_PRESETS,
|
||||
GRANULARITIES,
|
||||
createDefaultFilters,
|
||||
createDefaultTableState,
|
||||
rangeLabel,
|
||||
readStateFromURL,
|
||||
toggleMultiValue,
|
||||
@ -63,6 +64,7 @@ export function SocialAppIdentity({ app, appCode, appName, className = "", size
|
||||
export function SocialBiApp() {
|
||||
const initial = useMemo(() => readStateFromURL(), []);
|
||||
const [filters, setFilters] = useState(initial.filters);
|
||||
const [tableState, setTableState] = useState(() => initial.table || createDefaultTableState());
|
||||
const [viewKey, setViewKey] = useState(() => (VIEWS.some((view) => view.key === initial.view) ? initial.view : "overview"));
|
||||
const data = useSocialBiData(filters, {
|
||||
includeFunnel: viewKey === "funnel",
|
||||
@ -70,8 +72,8 @@ export function SocialBiApp() {
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
writeStateToURL(filters, viewKey);
|
||||
}, [filters, viewKey]);
|
||||
writeStateToURL(filters, viewKey, tableState);
|
||||
}, [filters, tableState, viewKey]);
|
||||
|
||||
const updateFilter = useCallback((key, value) => {
|
||||
setFilters((current) => ({ ...current, [key]: value }));
|
||||
@ -81,9 +83,19 @@ export function SocialBiApp() {
|
||||
setFilters(createDefaultFilters());
|
||||
}, []);
|
||||
|
||||
const updateTableState = useCallback((patch) => {
|
||||
setTableState((current) => ({ ...current, ...patch }));
|
||||
}, []);
|
||||
|
||||
const openRequirementSection = useCallback((requirementSection) => {
|
||||
// 下钻必须同时设置明细模式和业务分组,否则 DataTableView 会按默认值落回“运营宽表 / 新用户”。
|
||||
setTableState({ mode: "requirements", requirementSection });
|
||||
setViewKey("table");
|
||||
}, []);
|
||||
|
||||
const contextValue = useMemo(
|
||||
() => ({ ...data, filters, resetFilters, setViewKey, updateFilter, viewKey }),
|
||||
[data, filters, resetFilters, updateFilter, viewKey]
|
||||
() => ({ ...data, filters, openRequirementSection, resetFilters, setViewKey, tableState, updateFilter, updateTableState, viewKey }),
|
||||
[data, filters, openRequirementSection, resetFilters, tableState, updateFilter, updateTableState, viewKey]
|
||||
);
|
||||
|
||||
const ActiveView = (VIEWS.find((view) => view.key === viewKey) || VIEWS[0]).component;
|
||||
|
||||
@ -1,10 +1,20 @@
|
||||
// 社交 BI v2 的全局筛选状态:日期预设/自定义区间、App 多选、区域多选、运营人员、趋势粒度,
|
||||
// 并同步到 URL query,保证筛选状态可以直接作为链接分享。
|
||||
// 社交 BI v2 的全局筛选与可下钻视图状态:日期/App/区域/运营人员/趋势粒度,以及数据明细模式,
|
||||
// 统一同步到 URL query,保证筛选结果和“数据需求”目标分组都可以直接作为链接分享。
|
||||
|
||||
import { lastDaysRange, thisMonthRange, todayRange } from "../utils/time.js";
|
||||
|
||||
export const ALL = "all";
|
||||
|
||||
const TABLE_MODES = new Set(["wide", "requirements"]);
|
||||
const REQUIREMENT_SECTIONS = new Set(["new_users", "revenue", "retention", "active", "gift", "game"]);
|
||||
|
||||
export function createDefaultTableState() {
|
||||
return {
|
||||
mode: "wide",
|
||||
requirementSection: "new_users"
|
||||
};
|
||||
}
|
||||
|
||||
export const DATE_PRESETS = [
|
||||
{ key: "today", label: "今日" },
|
||||
{ key: "yesterday", label: "昨日" },
|
||||
@ -117,11 +127,12 @@ export function bucketDay(statDay, granularity) {
|
||||
return statDay;
|
||||
}
|
||||
|
||||
const URL_KEYS = ["preset", "start", "end", "apps", "regions", "operator", "view", "granularity"];
|
||||
const URL_KEYS = ["preset", "start", "end", "apps", "regions", "operator", "view", "granularity", "mode", "section"];
|
||||
|
||||
export function readStateFromURL(location = window.location) {
|
||||
const params = new URLSearchParams(location.search);
|
||||
const filters = createDefaultFilters();
|
||||
const table = createDefaultTableState();
|
||||
let view = "";
|
||||
if (params.get("preset") && DATE_PRESETS.some((item) => item.key === params.get("preset"))) {
|
||||
filters.preset = params.get("preset");
|
||||
@ -143,16 +154,23 @@ export function readStateFromURL(location = window.location) {
|
||||
if (params.get("view")) {
|
||||
view = params.get("view");
|
||||
}
|
||||
const requestedTableMode = params.get("mode");
|
||||
if (view === "table" && TABLE_MODES.has(requestedTableMode)) {
|
||||
table.mode = requestedTableMode;
|
||||
if (requestedTableMode === "requirements" && REQUIREMENT_SECTIONS.has(params.get("section"))) {
|
||||
table.requirementSection = params.get("section");
|
||||
}
|
||||
}
|
||||
if (!filters.apps.length) {
|
||||
filters.apps = [ALL];
|
||||
}
|
||||
if (!filters.regions.length) {
|
||||
filters.regions = [ALL];
|
||||
}
|
||||
return { filters, view };
|
||||
return { filters, table, view };
|
||||
}
|
||||
|
||||
export function writeStateToURL(filters, view, history = window.history, location = window.location) {
|
||||
export function writeStateToURL(filters, view, table, history = window.history, location = window.location) {
|
||||
const params = new URLSearchParams(location.search);
|
||||
URL_KEYS.forEach((key) => params.delete(key));
|
||||
if (filters.preset !== "yesterday") {
|
||||
@ -181,6 +199,13 @@ export function writeStateToURL(filters, view, history = window.history, locatio
|
||||
if (view && view !== "overview") {
|
||||
params.set("view", view);
|
||||
}
|
||||
if (view === "table" && table?.mode === "requirements") {
|
||||
// 目标分组与数据需求模式一起入 URL,保证概览下钻和复制后的链接都能落到同一个游戏数据视图。
|
||||
params.set("mode", "requirements");
|
||||
if (REQUIREMENT_SECTIONS.has(table.requirementSection) && table.requirementSection !== "new_users") {
|
||||
params.set("section", table.requirementSection);
|
||||
}
|
||||
}
|
||||
const query = params.toString();
|
||||
history.replaceState(null, "", `${location.pathname}${query ? `?${query}` : ""}`);
|
||||
}
|
||||
|
||||
@ -477,16 +477,18 @@ export function DataTableView() {
|
||||
range,
|
||||
refreshToken,
|
||||
requirements,
|
||||
requirementsError
|
||||
requirementsError,
|
||||
tableState,
|
||||
updateTableState
|
||||
} = useSocialBi();
|
||||
const [mode, setMode] = useState("wide");
|
||||
const [dimKey, setDimKey] = useState("appDaily");
|
||||
const [closedGroups, setClosedGroups] = useState(readClosedGroups);
|
||||
const [sort, setSort] = useState(null);
|
||||
const [requirementSection, setRequirementSection] = useState(REQUIREMENT_SECTIONS[0].key);
|
||||
const [requirementRole, setRequirementRole] = useState("all");
|
||||
const [requirementPayer, setRequirementPayer] = useState("all");
|
||||
const [requirementNewUser, setRequirementNewUser] = useState("all");
|
||||
const mode = tableState?.mode || TABLE_MODES[0].key;
|
||||
const requirementSection = tableState?.requirementSection || REQUIREMENT_SECTIONS[0].key;
|
||||
|
||||
const dim = DIMENSIONS.find((item) => item.key === dimKey) || DIMENSIONS[0];
|
||||
const dimColumns = dim.dims.map((name) => DIM_COLUMNS[name]);
|
||||
@ -575,7 +577,7 @@ export function DataTableView() {
|
||||
|
||||
const handleRequirementSectionChange = (key) => {
|
||||
const nextSection = requirementSectionConfig(key);
|
||||
setRequirementSection(nextSection.key);
|
||||
updateTableState({ requirementSection: nextSection.key });
|
||||
if (!nextSection.roleFilter) {
|
||||
setRequirementRole("all");
|
||||
}
|
||||
@ -889,7 +891,7 @@ export function DataTableView() {
|
||||
aria-checked={mode === item.key}
|
||||
className={mode === item.key ? "is-active" : ""}
|
||||
key={item.key}
|
||||
onClick={() => setMode(item.key)}
|
||||
onClick={() => updateTableState({ mode: item.key })}
|
||||
role="radio"
|
||||
type="button"
|
||||
>
|
||||
|
||||
@ -174,7 +174,7 @@ function OverviewSkeleton() {
|
||||
}
|
||||
|
||||
export function OverviewView() {
|
||||
const { appCodes, derived, filters, isLoading, setViewKey, updateFilter } = useSocialBi();
|
||||
const { appCodes, derived, filters, isLoading, openRequirementSection, setViewKey, updateFilter } = useSocialBi();
|
||||
const [trendMetric, setTrendMetric] = useState("recharge_usd_minor");
|
||||
const [compareMetric, setCompareMetric] = useState("recharge_usd_minor");
|
||||
|
||||
@ -332,20 +332,26 @@ export function OverviewView() {
|
||||
<strong>{trendLabel}趋势</strong>
|
||||
<small>按 App 堆叠</small>
|
||||
<div className="sbi-card-toolbar">
|
||||
<div className="sbi-seg" role="radiogroup" aria-label="趋势指标">
|
||||
<div className="sbi-seg" role="group" aria-label="趋势指标与明细入口">
|
||||
{TREND_METRICS.map((item) => (
|
||||
<button
|
||||
aria-checked={trendMetric === item.key}
|
||||
aria-pressed={trendMetric === item.key}
|
||||
className={trendMetric === item.key ? "is-active" : ""}
|
||||
key={item.key}
|
||||
onClick={() => setTrendMetric(item.key)}
|
||||
role="radio"
|
||||
title={metricTooltip(item.key)}
|
||||
type="button"
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
onClick={() => openRequirementSection("game")}
|
||||
title="查看数据需求中的游戏数据"
|
||||
type="button"
|
||||
>
|
||||
游戏流水
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@ -4,17 +4,20 @@ import CasinoOutlined from "@mui/icons-material/CasinoOutlined";
|
||||
import InsightsOutlined from "@mui/icons-material/InsightsOutlined";
|
||||
import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { PERMISSIONS } from "@/app/permissions";
|
||||
import { Button } from "@/shared/ui/Button.jsx";
|
||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||
import { PageHead } from "@/shared/ui/PageHead.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 { ConfigsView } from "./components/ConfigsView.jsx";
|
||||
import { DrawsView } from "./components/DrawsView.jsx";
|
||||
import { LuckyGiftConfigDialog } from "./components/LuckyGiftConfigDialog.jsx";
|
||||
import { LuckyGiftPoolAdjustmentDialog } from "./components/LuckyGiftPoolAdjustmentDialog.jsx";
|
||||
import { OpsShell } from "./components/OpsShell.jsx";
|
||||
import { OverviewView } from "./components/OverviewView.jsx";
|
||||
import { formatNumber } from "./format.js";
|
||||
|
||||
const views = [
|
||||
{ icon: InsightsOutlined, key: "overview", label: "数据总览" },
|
||||
@ -37,13 +40,20 @@ export function initialOpsCenterView(search = globalThis.location?.search || "")
|
||||
return viewKeys.has(requested) ? requested : "overview";
|
||||
}
|
||||
|
||||
export function OpsCenterApp() {
|
||||
const denyPermission = () => false;
|
||||
|
||||
export function OpsCenterApp({ can = denyPermission }) {
|
||||
// 主后台旧入口通过 ?view=configs 进入独立 HTML;只接受白名单视图,异常参数仍回到总览。
|
||||
const [activeView, setActiveView] = useState(() => initialOpsCenterView());
|
||||
const [state, setState] = useState({ data: initialDashboard, error: "", loaded: false, loading: true });
|
||||
const [dialog, setDialog] = useState(null);
|
||||
const [poolDialog, setPoolDialog] = useState(null);
|
||||
const [adjustingPool, setAdjustingPool] = useState(false);
|
||||
const [savingConfig, setSavingConfig] = useState(false);
|
||||
const { showToast } = useToast();
|
||||
const canCreditPool = can(PERMISSIONS.luckyGiftPoolCredit);
|
||||
const canDebitPool = can(PERMISSIONS.luckyGiftPoolDebit);
|
||||
const canUpdateConfig = can(PERMISSIONS.luckyGiftUpdate);
|
||||
|
||||
const loadDashboard = useCallback(async () => {
|
||||
setState((current) => ({ ...current, error: "", loading: true }));
|
||||
@ -124,6 +134,18 @@ export function OpsCenterApp() {
|
||||
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(
|
||||
async (config) => {
|
||||
setSavingConfig(true);
|
||||
@ -142,6 +164,31 @@ export function OpsCenterApp() {
|
||||
[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 ?? "";
|
||||
|
||||
return (
|
||||
@ -170,13 +217,29 @@ export function OpsCenterApp() {
|
||||
loading={state.loading && !state.loaded}
|
||||
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" ? (
|
||||
<ConfigsView
|
||||
apps={data.apps}
|
||||
canCredit={canCreditPool}
|
||||
canDebit={canDebitPool}
|
||||
canUpdate={canUpdateConfig}
|
||||
disabled={adjustingPool || state.loading}
|
||||
luckyGifts={data.luckyGifts}
|
||||
poolBalances={data.poolBalances}
|
||||
saving={savingConfig}
|
||||
onCreate={openCreateConfig}
|
||||
onCredit={(pool) => openPoolAdjustment(pool, "in")}
|
||||
onDebit={(pool) => openPoolAdjustment(pool, "out")}
|
||||
onEdit={openEditConfig}
|
||||
/>
|
||||
) : null}
|
||||
@ -195,7 +258,27 @@ export function OpsCenterApp() {
|
||||
onSubmit={handleSaveConfig}
|
||||
/>
|
||||
) : null}
|
||||
{poolDialog ? (
|
||||
<LuckyGiftPoolAdjustmentDialog
|
||||
adjustmentId={poolDialog.adjustmentId}
|
||||
direction={poolDialog.direction}
|
||||
pool={poolDialog.pool}
|
||||
submitting={adjustingPool}
|
||||
onClose={() => setPoolDialog(null)}
|
||||
onSubmit={handleAdjustPool}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</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 { beforeEach, expect, test, vi } from "vitest";
|
||||
import { PERMISSIONS } from "@/app/permissions";
|
||||
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";
|
||||
|
||||
vi.mock("./api.js", () => ({
|
||||
DEFAULT_POOL_ID: "default",
|
||||
adjustLuckyGiftPool: vi.fn(),
|
||||
fetchLuckyGiftDraws: 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(),
|
||||
}));
|
||||
|
||||
@ -15,6 +19,10 @@ beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
window.history.replaceState({}, "", "/");
|
||||
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({
|
||||
items: [
|
||||
{
|
||||
@ -62,6 +70,14 @@ beforeEach(() => {
|
||||
pool_id: "lucky",
|
||||
rule_version: 3,
|
||||
},
|
||||
{
|
||||
...mockFixedConfig(95),
|
||||
app_code: "lalu",
|
||||
enabled: false,
|
||||
is_default: false,
|
||||
pool_id: "lucky",
|
||||
rule_version: 2,
|
||||
},
|
||||
{
|
||||
...mockFixedConfig(92),
|
||||
app_code: "lalu",
|
||||
@ -84,9 +100,24 @@ beforeEach(() => {
|
||||
app_code: "lalu",
|
||||
available_balance: 37950017,
|
||||
balance: 38559017,
|
||||
manual_credit_total: 1_000_000,
|
||||
manual_debit_total: 200_000,
|
||||
materialized: true,
|
||||
pool_id: "lucky",
|
||||
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,
|
||||
},
|
||||
{
|
||||
@ -96,6 +127,7 @@ beforeEach(() => {
|
||||
materialized: false,
|
||||
pool_id: "default",
|
||||
reserve_floor: 21000,
|
||||
strategy_version: "dynamic_v3",
|
||||
},
|
||||
],
|
||||
summary: {
|
||||
@ -115,10 +147,12 @@ beforeEach(() => {
|
||||
});
|
||||
});
|
||||
|
||||
function renderApp() {
|
||||
const allPermissions = [PERMISSIONS.luckyGiftPoolCredit, PERMISSIONS.luckyGiftPoolDebit, PERMISSIONS.luckyGiftUpdate];
|
||||
|
||||
function renderApp(permissions = allPermissions) {
|
||||
return render(
|
||||
<ToastProvider>
|
||||
<OpsCenterApp />
|
||||
<OpsCenterApp can={(code) => permissions.includes(code)} />
|
||||
</ToastProvider>,
|
||||
);
|
||||
}
|
||||
@ -137,7 +171,7 @@ test("renders overview with aggregated kpis and pool balances", async () => {
|
||||
expect(screen.getAllByText("抽奖次数").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText("38,941,517").length).toBeGreaterThan(0);
|
||||
expect(screen.getByText("奖池实时水位")).toBeTruthy();
|
||||
expect(screen.getByText("已入账")).toBeTruthy();
|
||||
expect(screen.getAllByText("已入账").length).toBeGreaterThan(1);
|
||||
expect(screen.getByText("默认水位")).toBeTruthy();
|
||||
expect(fetchOpsDashboard).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@ -150,7 +184,7 @@ test("opens the requested config view from the legacy admin document redirect",
|
||||
renderApp();
|
||||
|
||||
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 () => {
|
||||
@ -160,15 +194,92 @@ test("lists every pool config including published non-default pools", async () =
|
||||
fireEvent.click(screen.getByRole("button", { name: /幸运礼物配置/ }));
|
||||
|
||||
// 核心回归: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("v3")).toBeTruthy();
|
||||
expect(screen.getByText("v2")).toBeTruthy();
|
||||
expect(screen.getAllByText("v2").length).toBeGreaterThan(0);
|
||||
expect(screen.getByText("已启用")).toBeTruthy();
|
||||
expect(screen.getByText("已停用")).toBeTruthy();
|
||||
expect(screen.getAllByText("已停用").length).toBeGreaterThan(1);
|
||||
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 () => {
|
||||
renderApp();
|
||||
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");
|
||||
expect(within(dialog).getByText("添加幸运礼物配置")).toBeTruthy();
|
||||
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 配置必须归零并保持停用。
|
||||
expect(within(dialog).getByLabelText(/单次返奖上限/).value).toBe("0");
|
||||
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("运营应用");
|
||||
|
||||
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");
|
||||
expect(within(dialog).getByText("新增幸运礼物版本")).toBeTruthy();
|
||||
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).getAllByLabelText("概率 %")[0].readOnly).toBe(true);
|
||||
expect(within(dialog).getByLabelText("新手阶段第 1 档概率 %").readOnly).toBe(true);
|
||||
|
||||
fireEvent.change(within(dialog).getByLabelText(/目标 RTP \(%\)/), { target: { value: "88" } });
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "保存配置" }));
|
||||
@ -207,6 +319,7 @@ test("opens config dialog from a published pool row and saves a new version", as
|
||||
expect.objectContaining({
|
||||
app_code: "lalu",
|
||||
anchor_rate_percent: 1,
|
||||
initial_pool_coins: 0,
|
||||
jackpot_multipliers: [200, 500, 1000],
|
||||
pool_id: "lucky",
|
||||
profit_rate_percent: 1,
|
||||
@ -222,18 +335,18 @@ test("blocks an invalid enabled dynamic rule before calling the save API", async
|
||||
renderApp();
|
||||
await screen.findByText("运营应用");
|
||||
fireEvent.click(screen.getByRole("button", { name: /幸运礼物配置/ }));
|
||||
await screen.findByText("lucky");
|
||||
fireEvent.click(screen.getAllByRole("button", { name: "新增版本" })[0]);
|
||||
await screen.findAllByText("lucky");
|
||||
fireEvent.click(screen.getByRole("button", { name: "新增版本 lalu / lucky / dynamic_v3" }));
|
||||
|
||||
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: "保存配置" }));
|
||||
|
||||
expect(await within(dialog).findByRole("alert")).toHaveTextContent("初始奖池必须大于 0");
|
||||
expect(await within(dialog).findByRole("alert")).toHaveTextContent("高水位必须大于正数低水位");
|
||||
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();
|
||||
});
|
||||
|
||||
@ -247,7 +360,7 @@ function mockDynamicConfig() {
|
||||
gift_price_reference: 100,
|
||||
high_water_nonzero_factor_percent: 130,
|
||||
high_watermark_coins: 20_000_000,
|
||||
initial_pool_coins: 1_000_000,
|
||||
initial_pool_coins: 0,
|
||||
jackpot_global_rtp_max_percent: 98,
|
||||
jackpot_multipliers: [200, 500, 1000],
|
||||
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 DEFAULT_POOL_ID = "default";
|
||||
@ -87,20 +88,50 @@ export async function saveLuckyGiftConfig(config = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
export async function opsRequest(path, { body, method = body ? "POST" : "GET", query } = {}) {
|
||||
const response = await fetch(buildOpsUrl(path, query), {
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
credentials: "include",
|
||||
headers: buildHeaders(body),
|
||||
method,
|
||||
});
|
||||
const payload = await readPayload(response);
|
||||
export async function adjustLuckyGiftPool({
|
||||
adjustmentId,
|
||||
amountCoins,
|
||||
appCode,
|
||||
direction,
|
||||
poolId,
|
||||
reason,
|
||||
strategyVersion,
|
||||
} = {}) {
|
||||
if (direction !== "in" && direction !== "out") {
|
||||
throw new Error("奖池水位调整方向不正确");
|
||||
}
|
||||
const operation = direction === "out" ? API_OPERATIONS.debitLuckyGiftPool : API_OPERATIONS.creditLuckyGiftPool;
|
||||
const operationPath = apiEndpointPath(operation);
|
||||
|
||||
if (!response.ok || payload.code !== 0) {
|
||||
throw new Error(payload.message || response.statusText || "运营接口请求失败");
|
||||
// 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);
|
||||
}
|
||||
|
||||
return payload.data ?? {};
|
||||
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 = {}) {
|
||||
@ -118,16 +149,40 @@ export function buildOpsUrl(path, query = {}) {
|
||||
|
||||
export function normalizeDashboard({ apps = [], perApp = [] } = {}) {
|
||||
const luckyGifts = perApp.flatMap((entry) => entry.luckyGifts || []);
|
||||
const poolBalances = perApp
|
||||
.flatMap((entry) => entry.poolBalances || [])
|
||||
.filter((item) => item?.app_code || item?.appCode);
|
||||
const normalizedPoolBalances = perApp
|
||||
.flatMap((entry) => (entry.poolBalances || []).map((item) => normalizePoolBalance(item, entry.appCode)))
|
||||
.filter((item) => item.app_code);
|
||||
const appSummaries = perApp
|
||||
.map((entry) => normalizeDrawSummary(entry.drawSummary, entry.appCode))
|
||||
.filter((summary) => summary.app_code);
|
||||
|
||||
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 = {
|
||||
activeApps: apps.length,
|
||||
configuredPools: publishedConfigs.length,
|
||||
@ -135,9 +190,9 @@ export function normalizeDashboard({ apps = [], perApp = [] } = {}) {
|
||||
failedDraws: sumBy(appSummaries, "failed_draws"),
|
||||
grantedDraws: sumBy(appSummaries, "granted_draws"),
|
||||
pendingDraws: sumBy(appSummaries, "pending_draws"),
|
||||
poolAvailableTotal: sumBy(poolBalances, "available_balance", "availableBalance"),
|
||||
poolBalanceTotal: sumBy(poolBalances, "balance", "Balance"),
|
||||
poolReserveTotal: sumBy(poolBalances, "reserve_floor", "reserveFloor"),
|
||||
poolAvailableTotal: sumBy(currentPoolBalances, "available_balance", "availableBalance"),
|
||||
poolBalanceTotal: sumBy(currentPoolBalances, "balance", "Balance"),
|
||||
poolReserveTotal: sumBy(currentPoolBalances, "reserve_floor", "reserveFloor"),
|
||||
totalDraws: sumBy(appSummaries, "total_draws"),
|
||||
totalRewardCoins: sumBy(appSummaries, "total_reward_coins"),
|
||||
totalSpentCoins: sumBy(appSummaries, "total_spent_coins"),
|
||||
@ -146,6 +201,45 @@ export function normalizeDashboard({ apps = [], perApp = [] } = {}) {
|
||||
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 = "") {
|
||||
return {
|
||||
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,
|
||||
rule_version: ruleVersion,
|
||||
// 升级前规则没有 strategy_version,lucky-gift owner 明确按 fixed_v2 解释;列表和编辑表单必须沿用该兼容语义。
|
||||
strategy_version: String(config.strategy_version || config.strategyVersion || "fixed_v2")
|
||||
.trim()
|
||||
.toLowerCase(),
|
||||
strategy_version: normalizeStrategyVersion(config.strategy_version ?? config.strategyVersion),
|
||||
};
|
||||
}
|
||||
|
||||
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 = {}) {
|
||||
const appCode = String(config.app_code || config.appCode || "")
|
||||
.trim()
|
||||
@ -222,7 +343,9 @@ function luckyGiftConfigPayload(config = {}) {
|
||||
config.high_water_nonzero_factor_percent ?? config.highWaterNonzeroFactorPercent,
|
||||
),
|
||||
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(
|
||||
config.jackpot_global_rtp_max_percent ?? config.jackpotGlobalRTPMaxPercent,
|
||||
),
|
||||
@ -230,11 +353,21 @@ function luckyGiftConfigPayload(config = {}) {
|
||||
jackpot_spend_threshold_coins: numberValue(
|
||||
config.jackpot_spend_threshold_coins ?? config.jackpotSpendThresholdCoins,
|
||||
),
|
||||
jackpot_user_72h_rtp_max_percent: numberValue(
|
||||
config.jackpot_user_72h_rtp_max_percent ?? config.jackpotUser72hRTPMaxPercent,
|
||||
jackpot_user_48h_rtp_max_percent: numberValue(
|
||||
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(
|
||||
config.jackpot_user_day_rtp_max_percent ?? config.jackpotUserDayRTPMaxPercent,
|
||||
jackpot_user_round_rtp_max_percent: numberValue(
|
||||
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),
|
||||
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) {
|
||||
if (Array.isArray(value)) {
|
||||
return value;
|
||||
|
||||
@ -1,8 +1,22 @@
|
||||
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(() => {
|
||||
vi.restoreAllMocks();
|
||||
setAccessToken("");
|
||||
setRefreshHandler(null);
|
||||
setUnauthorizedHandler(null);
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const calls = [];
|
||||
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,
|
||||
high_water_nonzero_factor_percent: 130,
|
||||
high_watermark_coins: 20_000_000,
|
||||
initial_pool_coins: 1_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,
|
||||
jackpot_user_48h_rtp_max_percent: 96,
|
||||
jackpot_user_round_rtp_max_percent: 96,
|
||||
loss_streak_guarantee: 5,
|
||||
low_water_nonzero_factor_percent: 70,
|
||||
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,
|
||||
});
|
||||
});
|
||||
|
||||
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 { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||
import { TimeText } from "@/shared/ui/TimeText.jsx";
|
||||
import { luckyGiftPoolKey } from "../api.js";
|
||||
import { formatNumber, formatPercent } from "../format.js";
|
||||
import { LuckyGiftPoolActions } from "./LuckyGiftPoolActions.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 appOptions = useMemo(
|
||||
@ -19,17 +34,37 @@ export function ConfigsView({ apps, luckyGifts, onCreate, onEdit, saving }) {
|
||||
[apps],
|
||||
);
|
||||
// 配置列表已在 dashboard 阶段跨应用拉全,应用筛选只做前端过滤,避免重复扇出请求。
|
||||
const items = useMemo(
|
||||
() => (appFilter ? luckyGifts.filter((config) => config.app_code === appFilter) : luckyGifts),
|
||||
[appFilter, luckyGifts],
|
||||
);
|
||||
const items = useMemo(() => {
|
||||
const poolByKey = new Map(poolBalances.map((pool) => [luckyGiftPoolKey(pool), pool]));
|
||||
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 (
|
||||
<div className="ops-view">
|
||||
<AdminListToolbar
|
||||
actions={
|
||||
canUpdate ? (
|
||||
<Button
|
||||
disabled={saving}
|
||||
startIcon={<AddOutlined fontSize="small" />}
|
||||
@ -38,6 +73,7 @@ export function ConfigsView({ apps, luckyGifts, onCreate, onEdit, saving }) {
|
||||
>
|
||||
添加配置
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
filters={
|
||||
<AdminFilterSelect
|
||||
@ -52,8 +88,8 @@ export function ConfigsView({ apps, luckyGifts, onCreate, onEdit, saving }) {
|
||||
columns={columns}
|
||||
emptyLabel="暂无幸运礼物配置"
|
||||
items={items}
|
||||
minWidth="1260px"
|
||||
rowKey={(item) => `${item.app_code}:${item.pool_id}:${item.rule_version}`}
|
||||
minWidth="1540px"
|
||||
rowKey={(item) => `${luckyGiftPoolKey(item)}:${item.rule_version}`}
|
||||
title="幸运礼物配置"
|
||||
total={items.length}
|
||||
/>
|
||||
@ -61,8 +97,8 @@ export function ConfigsView({ apps, luckyGifts, onCreate, onEdit, saving }) {
|
||||
);
|
||||
}
|
||||
|
||||
function buildColumns({ onEdit, saving }) {
|
||||
return [
|
||||
function buildColumns({ canCredit, canDebit, canUpdate, disabled, onCredit, onDebit, onEdit, saving }) {
|
||||
const columns = [
|
||||
{ key: "app_code", label: "应用", width: "minmax(88px, 0.7fr)" },
|
||||
{ key: "pool_id", label: "奖池", width: "minmax(110px, 0.9fr)" },
|
||||
{
|
||||
@ -79,6 +115,12 @@ function buildColumns({ onEdit, saving }) {
|
||||
render: (item) => item.strategy_version || "fixed_v2",
|
||||
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",
|
||||
label: "状态",
|
||||
@ -115,14 +157,46 @@ function buildColumns({ onEdit, saving }) {
|
||||
render: (item) => (item.is_default ? "-" : <TimeText value={item.created_at_ms} />),
|
||||
width: "minmax(176px, 1.1fr)",
|
||||
},
|
||||
{
|
||||
];
|
||||
|
||||
if (canUpdate || canCredit || canDebit) {
|
||||
columns.push({
|
||||
fixed: "right",
|
||||
key: "actions",
|
||||
label: "操作",
|
||||
render: (item) => (
|
||||
<Button disabled={saving} size="small" onClick={() => onEdit(item)}>
|
||||
render: (item) => {
|
||||
const identity = `${item.app_code} / ${item.pool_id} / ${item.strategy_version}`;
|
||||
const actionPool = item.pool_balance || {
|
||||
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 DialogActions from "@mui/material/DialogActions";
|
||||
import DialogContent from "@mui/material/DialogContent";
|
||||
import DialogTitle from "@mui/material/DialogTitle";
|
||||
import FormControlLabel from "@mui/material/FormControlLabel";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import { useMemo, useState } from "react";
|
||||
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Button } from "@/shared/ui/Button.jsx";
|
||||
import { formatDecimal } from "@/shared/utils/rtpProbability.js";
|
||||
import {
|
||||
applyDynamicStrategyDefaults,
|
||||
configToForm,
|
||||
@ -21,12 +14,14 @@ import {
|
||||
validateLuckyGiftForm,
|
||||
} from "../configForm.js";
|
||||
import { formatNumber, formatPercent } from "../format.js";
|
||||
import { OpsStatusBadge } from "./OpsStatusBadge.jsx";
|
||||
import { LuckyGiftConfigSections } from "./LuckyGiftConfigSections.jsx";
|
||||
import { LuckyGiftStageDesigner } from "./LuckyGiftStageDesigner.jsx";
|
||||
|
||||
export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit", onClose, onSubmit, saving }) {
|
||||
const [form, setForm] = useState(() => configToForm(config));
|
||||
const [activeStage, setActiveStage] = useState("novice");
|
||||
const [validationErrors, setValidationErrors] = useState([]);
|
||||
const validationRef = useRef(null);
|
||||
|
||||
const stageSummaries = useMemo(() => form.stages.map((stage) => stageSummary(stage, form)), [form]);
|
||||
const activeStageKey = form.stages.some((stage) => stage.stage === activeStage)
|
||||
@ -34,6 +29,13 @@ export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit",
|
||||
: form.stages[0]?.stage;
|
||||
const activeStageConfig = form.stages.find((stage) => stage.stage === 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) => {
|
||||
setValidationErrors([]);
|
||||
@ -87,7 +89,8 @@ export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit",
|
||||
...stage.tiers,
|
||||
{
|
||||
enabled: true,
|
||||
// 10 倍以上属于高倍档,默认只在奖池高水位时开放,防止新档位直接击穿保底线。
|
||||
// 10 倍以上先写入服务端要求的高倍率安全标记;实际开奖仍以实时余额和返奖上限
|
||||
// 判断是否付得起,前端默认值不能被解释成“高余额时一定会发”。
|
||||
highWaterOnly: nextStageMultiplier(stage.tiers) >= 10,
|
||||
multiplier: String(nextStageMultiplier(stage.tiers)),
|
||||
probabilityPercent: "0",
|
||||
@ -129,31 +132,51 @@ export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit",
|
||||
const title = isCreate ? "添加幸运礼物配置" : config.is_default ? "发布幸运礼物配置" : "新增幸运礼物版本";
|
||||
|
||||
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}>
|
||||
<DialogTitle className="ops-config-title">
|
||||
<DialogTitle className="ops-config-title" id="ops-config-dialog-title">
|
||||
<span>{title}</span>
|
||||
<small>
|
||||
{form.app_code || "-"} / {form.pool_id || "default"} /{" "}
|
||||
{config.is_default || isCreate ? "默认草稿" : `当前版本 v${config.rule_version ?? "-"}`}
|
||||
</small>
|
||||
</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
<div className="ops-config-metrics">
|
||||
<dl className="ops-config-summary" aria-label="配置摘要">
|
||||
<ConfigMetric label="应用" value={form.app_code || "-"} />
|
||||
<ConfigMetric label="奖池" value={form.pool_id || "default"} />
|
||||
<ConfigMetric label="策略" value={form.strategy_version} />
|
||||
<ConfigMetric label="目标 RTP" value={formatPercent(Number(form.target_rtp_percent))} />
|
||||
<ConfigMetric label="开奖策略" value={form.strategy_version} />
|
||||
<ConfigMetric label="基础返还目标" value={formatPercent(Number(form.target_rtp_percent))} />
|
||||
<ConfigMetric
|
||||
label="资金拆分"
|
||||
value={`${form.pool_rate_percent}% / ${form.profit_rate_percent}% / ${form.anchor_rate_percent}%`}
|
||||
label={form.strategy_version === "dynamic_v3" ? "资金拆分" : "奖池比例"}
|
||||
value={
|
||||
form.strategy_version === "dynamic_v3"
|
||||
? `${form.pool_rate_percent}% / ${form.profit_rate_percent}% / ${form.anchor_rate_percent}%`
|
||||
: `${form.pool_rate_percent}%`
|
||||
}
|
||||
/>
|
||||
<ConfigMetric label="结算窗口" value={formatNumber(Number(form.settlement_window_wager))} />
|
||||
<ConfigMetric label="状态" value={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 ? (
|
||||
<div className="ops-config-validation" role="alert">
|
||||
<div
|
||||
aria-live="assertive"
|
||||
className="ops-config-validation"
|
||||
ref={validationRef}
|
||||
role="alert"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<strong>配置不能发布</strong>
|
||||
<ul>
|
||||
{validationErrors.map((message) => (
|
||||
@ -164,268 +187,28 @@ export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit",
|
||||
) : null}
|
||||
|
||||
<div className="ops-config-layout">
|
||||
<aside className="ops-config-params" aria-label="基础参数">
|
||||
<section className="ops-config-section">
|
||||
<h3>规则身份</h3>
|
||||
{isCreate ? (
|
||||
<TextField
|
||||
fullWidth
|
||||
required
|
||||
label="应用"
|
||||
select
|
||||
size="small"
|
||||
value={form.app_code}
|
||||
onChange={(event) => updateField("app_code", event.target.value)}
|
||||
>
|
||||
{appOptions.map(([value, label]) => (
|
||||
<MenuItem key={value} value={value}>
|
||||
{label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
) : (
|
||||
<TextField disabled fullWidth label="应用" size="small" value={form.app_code} />
|
||||
)}
|
||||
<TextField
|
||||
fullWidth
|
||||
required
|
||||
helperText="修改奖池 ID 会发布到新的奖池,不影响原奖池"
|
||||
label="奖池"
|
||||
size="small"
|
||||
value={form.pool_id}
|
||||
onChange={(event) => updateField("pool_id", event.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="策略版本"
|
||||
select
|
||||
size="small"
|
||||
value={form.strategy_version}
|
||||
onChange={(event) => updateField("strategy_version", event.target.value)}
|
||||
>
|
||||
<MenuItem value="dynamic_v3">dynamic_v3</MenuItem>
|
||||
<MenuItem value="fixed_v2">fixed_v2(历史兼容)</MenuItem>
|
||||
</TextField>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<AdminSwitch
|
||||
checked={form.enabled}
|
||||
onChange={(event) => updateField("enabled", event.target.checked)}
|
||||
/>
|
||||
}
|
||||
label={form.enabled ? "发布后立即启用" : "发布后保持停用"}
|
||||
sx={{ gap: 1, marginLeft: 0 }}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="ops-config-section">
|
||||
<h3>RTP 与奖池</h3>
|
||||
<div className="ops-form-grid">
|
||||
<NumberField
|
||||
<LuckyGiftConfigSections
|
||||
appOptions={appOptions}
|
||||
form={form}
|
||||
label="目标 RTP (%)"
|
||||
name="target_rtp_percent"
|
||||
step="0.01"
|
||||
isCreate={isCreate}
|
||||
onChange={updateField}
|
||||
/>
|
||||
<NumberField
|
||||
form={form}
|
||||
label="公共奖池 (%)"
|
||||
name="pool_rate_percent"
|
||||
step="0.01"
|
||||
onChange={updateField}
|
||||
<LuckyGiftStageDesigner
|
||||
activeStageConfig={activeStageConfig}
|
||||
activeStageKey={activeStageKey}
|
||||
activeSummary={activeSummary}
|
||||
normalStageConfig={normalStageConfig}
|
||||
stageSummaries={stageSummaries}
|
||||
strategyVersion={form.strategy_version}
|
||||
onAddTier={addStageTier}
|
||||
onRemoveTier={removeStageTier}
|
||||
onStageChange={setActiveStage}
|
||||
onStageFieldChange={updateStageField}
|
||||
onTierChange={updateStageTier}
|
||||
/>
|
||||
<NumberField
|
||||
form={form}
|
||||
label="平台盈利 (%)"
|
||||
name="profit_rate_percent"
|
||||
step="0.01"
|
||||
onChange={updateField}
|
||||
/>
|
||||
<NumberField
|
||||
form={form}
|
||||
helperText="必须与该奖池实际送礼类型(lucky / super_lucky)在各适用地区的返币比例一致,否则开奖账务校验会失败"
|
||||
label="主播收益 (%)"
|
||||
name="anchor_rate_percent"
|
||||
step="0.01"
|
||||
onChange={updateField}
|
||||
/>
|
||||
<NumberField
|
||||
form={form}
|
||||
label="控制带宽 (%)"
|
||||
name="control_band_percent"
|
||||
step="0.01"
|
||||
onChange={updateField}
|
||||
/>
|
||||
<NumberField
|
||||
form={form}
|
||||
label="结算窗口流水"
|
||||
name="settlement_window_wager"
|
||||
onChange={updateField}
|
||||
/>
|
||||
<NumberField
|
||||
form={form}
|
||||
label="礼物参考金额"
|
||||
name="gift_price_reference"
|
||||
onChange={updateField}
|
||||
/>
|
||||
<NumberField
|
||||
form={form}
|
||||
label="新手等价抽数"
|
||||
name="novice_max_equivalent_draws"
|
||||
onChange={updateField}
|
||||
/>
|
||||
<NumberField
|
||||
form={form}
|
||||
label="普通等价抽数"
|
||||
name="normal_max_equivalent_draws"
|
||||
onChange={updateField}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
{form.strategy_version === "dynamic_v3" ? (
|
||||
<DynamicStrategySections form={form} onChange={updateField} />
|
||||
) : null}
|
||||
</aside>
|
||||
|
||||
<section className="ops-stage-designer" aria-label="阶段奖档设计">
|
||||
<div className="ops-stage-tabs" role="tablist" aria-label="阶段奖档">
|
||||
{stageSummaries.map((summary) => (
|
||||
<button
|
||||
aria-selected={summary.stageKey === activeStageKey}
|
||||
className={`ops-stage-tab ${summary.stageKey === activeStageKey ? "is-active" : ""} ${summary.ok ? "is-ok" : "is-warn"}`}
|
||||
key={summary.stageKey}
|
||||
role="tab"
|
||||
type="button"
|
||||
onClick={() => setActiveStage(summary.stageKey)}
|
||||
>
|
||||
<span>{summary.tabLabel}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{activeStageConfig ? (
|
||||
<div className="ops-stage-stack">
|
||||
<div className="ops-stage-block__head">
|
||||
<div>
|
||||
<h3>{activeSummary?.stageLabel}</h3>
|
||||
<OpsStatusBadge
|
||||
label={`${activeSummary?.status} · 概率 ${formatDecimal(activeSummary?.probability)}% · 期望 RTP ${formatDecimal(activeSummary?.expected)}%`}
|
||||
tone={activeSummary?.ok ? "succeeded" : "warning"}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
startIcon={<AddOutlined fontSize="small" />}
|
||||
onClick={() => addStageTier(activeStageConfig.stage)}
|
||||
>
|
||||
添加奖档
|
||||
</Button>
|
||||
</div>
|
||||
{form.strategy_version === "dynamic_v3" ? (
|
||||
<div className="ops-stage-thresholds">
|
||||
<TextField
|
||||
disabled={activeStageConfig.stage === "novice"}
|
||||
label="7 日充值门槛"
|
||||
size="small"
|
||||
slotProps={{ htmlInput: { min: 0, step: 1 } }}
|
||||
type="number"
|
||||
value={activeStageConfig.min_recharge_7d_coins}
|
||||
onChange={(event) =>
|
||||
updateStageField(
|
||||
activeStageConfig.stage,
|
||||
"min_recharge_7d_coins",
|
||||
event.target.value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
<TextField
|
||||
disabled={activeStageConfig.stage === "novice"}
|
||||
label="30 日充值门槛"
|
||||
size="small"
|
||||
slotProps={{ htmlInput: { min: 0, step: 1 } }}
|
||||
type="number"
|
||||
value={activeStageConfig.min_recharge_30d_coins}
|
||||
onChange={(event) =>
|
||||
updateStageField(
|
||||
activeStageConfig.stage,
|
||||
"min_recharge_30d_coins",
|
||||
event.target.value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="ops-tier-table">
|
||||
<div className="ops-tier-head" aria-hidden="true">
|
||||
<span>倍率</span>
|
||||
<span>概率 %</span>
|
||||
<span>高水位</span>
|
||||
<span>启用</span>
|
||||
<span>操作</span>
|
||||
</div>
|
||||
{activeStageConfig.tiers.map((tier, index) => (
|
||||
<div className="ops-tier-row" key={`${activeStageConfig.stage}-${index}`}>
|
||||
<TextField
|
||||
required
|
||||
size="small"
|
||||
slotProps={{
|
||||
htmlInput: { "aria-label": "倍率", min: 0, step: "0.01" },
|
||||
}}
|
||||
type="number"
|
||||
value={tier.multiplier}
|
||||
onChange={(event) =>
|
||||
updateStageTier(activeStageConfig.stage, index, {
|
||||
// 10 倍及以上强制只走高水位,输入时同步勾选并锁死,防止高倍档在低水位放开。
|
||||
highWaterOnly:
|
||||
Number(event.target.value) >= 10 || tier.highWaterOnly,
|
||||
multiplier: event.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
{/* 概率由目标 RTP 和倍率统一校正,保持只读可以避免运营手填后绕过服务端概率闭合校验。 */}
|
||||
<TextField
|
||||
size="small"
|
||||
slotProps={{
|
||||
htmlInput: { "aria-label": "概率 %", readOnly: true },
|
||||
}}
|
||||
value={formatDecimal(tier.probabilityPercent)}
|
||||
/>
|
||||
<Checkbox
|
||||
checked={Boolean(tier.highWaterOnly)}
|
||||
disabled={Number(tier.multiplier) >= 10}
|
||||
size="small"
|
||||
slotProps={{ input: { "aria-label": "高水位" } }}
|
||||
onChange={(event) =>
|
||||
updateStageTier(activeStageConfig.stage, index, {
|
||||
highWaterOnly: event.target.checked,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Checkbox
|
||||
checked={tier.enabled !== false}
|
||||
size="small"
|
||||
slotProps={{ input: { "aria-label": "启用" } }}
|
||||
onChange={(event) =>
|
||||
updateStageTier(activeStageConfig.stage, index, {
|
||||
enabled: event.target.checked,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
disabled={activeStageConfig.tiers.length <= 1}
|
||||
variant="danger"
|
||||
onClick={() => removeStageTier(activeStageConfig.stage, index)}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<DialogActions className="ops-config-dialog-actions">
|
||||
<Button disabled={saving} onClick={onClose}>
|
||||
取消
|
||||
</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 }) {
|
||||
return (
|
||||
<article className="ops-config-metric">
|
||||
<span>{label}</span>
|
||||
<strong>{value}</strong>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
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)}
|
||||
/>
|
||||
<div className="ops-config-metric">
|
||||
<dt>{label}</dt>
|
||||
<dd>{value}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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,14 +2,41 @@ import AppsOutlined from "@mui/icons-material/AppsOutlined";
|
||||
import CardGiftcardOutlined from "@mui/icons-material/CardGiftcardOutlined";
|
||||
import CasinoOutlined from "@mui/icons-material/CasinoOutlined";
|
||||
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 { KpiCard } from "@/shared/ui/KpiCard.jsx";
|
||||
import { TimeText } from "@/shared/ui/TimeText.jsx";
|
||||
import { luckyGiftPoolKey } from "../api.js";
|
||||
import { formatNumber, formatPercentFromPPM } from "../format.js";
|
||||
import { LuckyGiftPoolActions } from "./LuckyGiftPoolActions.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 [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 (
|
||||
<div className="ops-view">
|
||||
@ -36,21 +63,35 @@ export function OverviewView({ data }) {
|
||||
/>
|
||||
<KpiCard
|
||||
icon={SavingsOutlined}
|
||||
label="奖池金币"
|
||||
label="当前奖池金币"
|
||||
sub={`可用 ${formatNumber(summary.poolAvailableTotal)} · 保底 ${formatNumber(summary.poolReserveTotal)}`}
|
||||
tone="success"
|
||||
value={formatNumber(summary.poolBalanceTotal)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AdminListToolbar
|
||||
filters={
|
||||
<AdminFilterSelect
|
||||
label="奖池范围"
|
||||
options={[
|
||||
["current", `当前奖池 (${poolCounts.current})`],
|
||||
["history", `历史奖池 (${poolCounts.history})`],
|
||||
["all", `全部奖池 (${poolCounts.all})`],
|
||||
]}
|
||||
value={poolScope}
|
||||
onChange={setPoolScope}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<DataTable
|
||||
columns={poolBalanceColumns}
|
||||
emptyLabel="暂无奖池余额"
|
||||
items={poolBalances}
|
||||
minWidth="920px"
|
||||
rowKey={(item) => `${item.app_code ?? item.appCode}:${item.pool_id ?? item.poolId}`}
|
||||
emptyLabel={poolScope === "history" ? "暂无历史奖池" : "暂无奖池余额"}
|
||||
items={visiblePoolBalances}
|
||||
minWidth="1480px"
|
||||
rowKey={luckyGiftPoolKey}
|
||||
title="奖池实时水位"
|
||||
total={poolBalances.length}
|
||||
total={visiblePoolBalances.length}
|
||||
/>
|
||||
|
||||
<DataTable
|
||||
@ -66,31 +107,68 @@ export function OverviewView({ data }) {
|
||||
);
|
||||
}
|
||||
|
||||
const poolBalanceColumns = [
|
||||
{ key: "app_code", label: "应用", render: (item) => item.app_code ?? item.appCode, width: "minmax(96px, 0.8fr)" },
|
||||
{ key: "pool_id", label: "奖池", render: (item) => item.pool_id ?? item.poolId, width: "minmax(110px, 1fr)" },
|
||||
function buildPoolBalanceColumns({ canCredit, canDebit, disabled, onCredit, onDebit }) {
|
||||
const columns = [
|
||||
{ key: "app_code", label: "应用", width: "minmax(96px, 0.8fr)" },
|
||||
{ key: "pool_id", label: "奖池", width: "minmax(110px, 1fr)" },
|
||||
{ key: "strategy_version", label: "策略", width: "minmax(112px, 0.9fr)" },
|
||||
{
|
||||
key: "materialized",
|
||||
label: "水位状态",
|
||||
// materialized=false 表示该奖池还没有真实入账流水,余额是规则推导的初始水位,不能当作可支配资金。
|
||||
render: (item) =>
|
||||
item.materialized === false ? <OpsStatusBadge label="默认水位" /> : <OpsStatusBadge label="已入账" tone="succeeded" />,
|
||||
width: "minmax(132px, 0.9fr)"
|
||||
// materialized=false 是 owner 根据当前策略推导的初始水位;仍允许补水或扣水,首次调整由 owner 原子物化该策略奖池。
|
||||
render: (item) => {
|
||||
if (item.is_historical) {
|
||||
return <OpsStatusBadge label="历史奖池" tone="stopped" />;
|
||||
}
|
||||
return item.materialized === false ? (
|
||||
<OpsStatusBadge label="默认水位" />
|
||||
) : (
|
||||
<OpsStatusBadge label="已入账" tone="succeeded" />
|
||||
);
|
||||
},
|
||||
{ key: "balance", label: "当前余额", render: (item) => formatNumber(item.balance ?? item.Balance) },
|
||||
{ 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)" },
|
||||
{ key: "total_in", label: "累计流入", render: (item) => formatNumber(item.total_in ?? item.totalIn) },
|
||||
{ key: "total_out", label: "累计流出", render: (item) => formatNumber(item.total_out ?? item.totalOut) },
|
||||
width: "minmax(132px, 0.9fr)",
|
||||
},
|
||||
{ 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 ?? item.updatedAtMs} />,
|
||||
width: "minmax(150px, 1fr)"
|
||||
}
|
||||
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 = [
|
||||
{ key: "app_code", label: "应用", width: "minmax(96px, 0.8fr)" },
|
||||
{ key: "total_draws", label: "抽奖次数", render: (item) => formatNumber(item.total_draws) },
|
||||
@ -101,7 +179,7 @@ const appSummaryColumns = [
|
||||
{
|
||||
key: "actual_rtp_ppm",
|
||||
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",
|
||||
@ -113,10 +191,15 @@ const appSummaryColumns = [
|
||||
if (!pending && !failed) {
|
||||
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) {
|
||||
|
||||
@ -11,8 +11,21 @@ import { DEFAULT_POOL_ID } from "./api.js";
|
||||
|
||||
const DYNAMIC_STRATEGY = "dynamic_v3";
|
||||
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。
|
||||
const dynamicDefaults = {
|
||||
anchor_daily_payout_cap: 0,
|
||||
@ -21,12 +34,13 @@ const dynamicDefaults = {
|
||||
device_daily_payout_cap: 0,
|
||||
high_water_nonzero_factor_percent: 130,
|
||||
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_multipliers: [200, 500, 1000],
|
||||
jackpot_spend_threshold_coins: 0,
|
||||
jackpot_user_72h_rtp_max_percent: 96,
|
||||
jackpot_user_day_rtp_max_percent: 96,
|
||||
jackpot_user_48h_rtp_max_percent: 96,
|
||||
jackpot_user_round_rtp_max_percent: 96,
|
||||
loss_streak_guarantee: 5,
|
||||
low_water_nonzero_factor_percent: 70,
|
||||
low_watermark_coins: 10_000_000,
|
||||
@ -54,8 +68,8 @@ const numericFields = [
|
||||
"initial_pool_coins",
|
||||
"jackpot_global_rtp_max_percent",
|
||||
"jackpot_spend_threshold_coins",
|
||||
"jackpot_user_72h_rtp_max_percent",
|
||||
"jackpot_user_day_rtp_max_percent",
|
||||
"jackpot_user_48h_rtp_max_percent",
|
||||
"jackpot_user_round_rtp_max_percent",
|
||||
"loss_streak_guarantee",
|
||||
"low_water_nonzero_factor_percent",
|
||||
"low_watermark_coins",
|
||||
@ -108,12 +122,30 @@ export function configToForm(config = {}) {
|
||||
stages: normalizeFormStages(config.stages, targetRTPPercent, 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) {
|
||||
if (field === "target_rtp_percent") {
|
||||
form[field] = targetRTPPercent;
|
||||
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;
|
||||
}
|
||||
@ -125,9 +157,13 @@ export function formToConfig(config, form) {
|
||||
enabled: Boolean(form.enabled),
|
||||
jackpot_multipliers: multiplierListFromForm(form.jackpot_multipliers),
|
||||
pool_id: form.pool_id,
|
||||
stages: (form.stages || []).map((stage) => ({
|
||||
min_recharge_30d_coins: numberFromForm(stage.min_recharge_30d_coins),
|
||||
min_recharge_7d_coins: numberFromForm(stage.min_recharge_7d_coins),
|
||||
stages: (form.stages || []).map((stage) => {
|
||||
// dynamic_v3 的新手范围由普通阶段下限反向派生;0/0 只是旧 DTO 要求的兜底哨兵值,
|
||||
// 不是可运营的“充值为 0 才是新手”规则。序列化时强制归零,避免历史异常值改变分层。
|
||||
const isDynamicNovice = form.strategy_version === DYNAMIC_STRATEGY && stage.stage === "novice";
|
||||
return {
|
||||
min_recharge_30d_coins: isDynamicNovice ? 0 : numberFromForm(stage.min_recharge_30d_coins),
|
||||
min_recharge_7d_coins: isDynamicNovice ? 0 : numberFromForm(stage.min_recharge_7d_coins),
|
||||
stage: stage.stage,
|
||||
tiers: (stage.tiers || []).map((tier) => ({
|
||||
enabled: tier.enabled !== false,
|
||||
@ -136,11 +172,26 @@ export function formToConfig(config, form) {
|
||||
probability_percent: numberFromForm(tier.probabilityPercent),
|
||||
tier_id: tier.tierId || "",
|
||||
})),
|
||||
})),
|
||||
};
|
||||
}),
|
||||
strategy_version: form.strategy_version,
|
||||
};
|
||||
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;
|
||||
}
|
||||
@ -233,6 +284,9 @@ export function validateLuckyGiftForm(form) {
|
||||
)
|
||||
errors.push("等价抽数阶段门槛不正确");
|
||||
|
||||
const jackpotMultipliers =
|
||||
form.strategy_version === DYNAMIC_STRATEGY ? new Set(multiplierListFromForm(form.jackpot_multipliers)) : null;
|
||||
|
||||
// 阶段概率与 RTP 是两代策略共用的 owner 校验,fixed_v2 也不能跳过。
|
||||
for (const stageKey of ["novice", "normal", "advanced"]) {
|
||||
const stage = form.stages?.find((item) => item.stage === stageKey);
|
||||
@ -251,6 +305,19 @@ export function validateLuckyGiftForm(form) {
|
||||
errors.push(`${stageLabel(stageKey)}启用奖档概率必须大于 0`);
|
||||
if (enabled.some((tier) => Number(tier.multiplier) >= 10 && !tier.highWaterOnly))
|
||||
errors.push(`${stageLabel(stageKey)}10x 及以上奖档必须限制为高水位`);
|
||||
const jackpotStageMultipliers =
|
||||
jackpotMultipliers &&
|
||||
enabled
|
||||
.filter(
|
||||
(tier) =>
|
||||
jackpotMultipliers.has(numberFromForm(tier.multiplier)) &&
|
||||
percentToPPM(tier.probabilityPercent) > 0,
|
||||
)
|
||||
.map((tier) => formatDecimal(numberFromForm(tier.multiplier)));
|
||||
if (jackpotStageMultipliers?.length) {
|
||||
// dynamic_v3 的大奖倍率只能走大奖补偿路径;普通阶段再给正概率会绕过 owner 的双 RTP 门槛。
|
||||
errors.push(`${stageLabel(stageKey)}普通奖档不能包含大奖倍率:${unique(jackpotStageMultipliers).join("x、")}x`);
|
||||
}
|
||||
const expected = expectedRTPPercent(enabled, { multiplier: (item) => item.multiplier });
|
||||
if (
|
||||
expected < number("target_rtp_percent") - number("control_band_percent") ||
|
||||
@ -270,7 +337,7 @@ export function validateLuckyGiftForm(form) {
|
||||
PPM_SCALE
|
||||
)
|
||||
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("low_watermark_coins") <= 0 || number("high_watermark_coins") <= number("low_watermark_coins"))
|
||||
errors.push("高水位必须大于正数低水位");
|
||||
@ -282,34 +349,35 @@ export function validateLuckyGiftForm(form) {
|
||||
const jackpotTokens = String(form.jackpot_multipliers || "")
|
||||
.split(/[,,\s]+/)
|
||||
.filter(Boolean);
|
||||
const jackpotMultipliers = multiplierListFromForm(form.jackpot_multipliers);
|
||||
const jackpotMultiplierValues = multiplierListFromForm(form.jackpot_multipliers);
|
||||
if (
|
||||
jackpotTokens.length !== jackpotMultipliers.length ||
|
||||
!jackpotMultipliers.length ||
|
||||
jackpotMultipliers.some((value, index) => value <= 1 || (index > 0 && value <= jackpotMultipliers[index - 1]))
|
||||
jackpotTokens.length !== jackpotMultiplierValues.length ||
|
||||
!jackpotMultiplierValues.length ||
|
||||
jackpotMultiplierValues.some((value, index) => value <= 1 || (index > 0 && value <= jackpotMultiplierValues[index - 1]))
|
||||
) {
|
||||
errors.push("大奖倍率必须大于 1x 并严格递增");
|
||||
}
|
||||
if (number("jackpot_global_rtp_max_percent") <= 0 || number("jackpot_global_rtp_max_percent") > 100)
|
||||
errors.push("全局大奖 RTP 上限必须在 0%-100% 之间");
|
||||
if (
|
||||
number("jackpot_user_day_rtp_max_percent") <= 0 ||
|
||||
number("jackpot_user_day_rtp_max_percent") > number("jackpot_global_rtp_max_percent")
|
||||
number("jackpot_user_round_rtp_max_percent") <= 0 ||
|
||||
number("jackpot_user_round_rtp_max_percent") > number("jackpot_global_rtp_max_percent")
|
||||
)
|
||||
errors.push("用户每日 RTP 上限必须大于 0 且不超过全局上限");
|
||||
errors.push("用户本轮 RTP 触发线必须大于 0 且不超过全局上限");
|
||||
if (
|
||||
number("jackpot_user_72h_rtp_max_percent") <= 0 ||
|
||||
number("jackpot_user_72h_rtp_max_percent") > number("jackpot_global_rtp_max_percent")
|
||||
number("jackpot_user_48h_rtp_max_percent") <= 0 ||
|
||||
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("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) {
|
||||
if (number(field) < 0) errors.push(`${label}不能小于 0`);
|
||||
}
|
||||
validateRechargeStages(form.stages || [], errors);
|
||||
validateRechargeStages(form.stages || [], errors, form.enabled);
|
||||
if (form.enabled) {
|
||||
if (number("jackpot_spend_threshold_coins") <= 0) errors.push("启用前必须填写 50 美元等值的大奖消费门槛");
|
||||
for (const [field, label] of payoutCapFields) {
|
||||
if (number(field) <= 0) errors.push(`启用前必须填写${label}`);
|
||||
}
|
||||
@ -336,11 +404,15 @@ function normalizeFormStages(stages, targetRTPPercent, strategyVersion) {
|
||||
: correctedDefaultTiers(stageKey, targetRTPPercent);
|
||||
const [default7d, default30d] =
|
||||
strategyVersion === DYNAMIC_STRATEGY ? defaultRechargeThreshold(stageKey) : [0, 0];
|
||||
const isDynamicNovice = strategyVersion === DYNAMIC_STRATEGY && stageKey === "novice";
|
||||
return {
|
||||
min_recharge_7d_coins: formNumber(source?.min_recharge_7d_coins ?? source?.minRecharge7DCoins ?? default7d),
|
||||
min_recharge_30d_coins: formNumber(
|
||||
source?.min_recharge_30d_coins ?? source?.minRecharge30DCoins ?? default30d,
|
||||
),
|
||||
// 只在表单内保留兼容哨兵值;界面上的新手上界始终从普通门槛派生,不读取历史 novice 门槛。
|
||||
min_recharge_7d_coins: isDynamicNovice
|
||||
? "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,
|
||||
tiers: sourceTiers.map((tier) => ({
|
||||
enabled: tier.enabled !== false,
|
||||
@ -373,13 +445,10 @@ function defaultTier(tierID, multiplier, probabilityPercent) {
|
||||
return { enabled: true, highWaterOnly: false, multiplier, probabilityPercent, tierId: tierID };
|
||||
}
|
||||
|
||||
function validateRechargeStages(stages, errors) {
|
||||
const novice = stages.find((stage) => stage.stage === "novice");
|
||||
function validateRechargeStages(stages, errors, requireComplete) {
|
||||
const normal = stages.find((stage) => stage.stage === "normal");
|
||||
const advanced = stages.find((stage) => stage.stage === "advanced");
|
||||
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 normal30d = threshold(normal, "min_recharge_30d_coins");
|
||||
const advanced7d = threshold(advanced, "min_recharge_7d_coins");
|
||||
@ -388,15 +457,21 @@ function validateRechargeStages(stages, errors) {
|
||||
errors.push("充值阶段门槛不能小于 0");
|
||||
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))
|
||||
errors.push("高阶充值门槛必须逐维不低于正常阶段且至少一项更高");
|
||||
}
|
||||
|
||||
function defaultRechargeThreshold(stage) {
|
||||
if (stage === "novice") return [0, 0];
|
||||
if (stage === "normal") return [0, 1];
|
||||
return [1, 1];
|
||||
function defaultRechargeThreshold() {
|
||||
// 充值门槛受 App 金币比例影响,新草稿不猜测 1 金币等于可用配置;运营填完后才能启用。
|
||||
return [0, 0];
|
||||
}
|
||||
|
||||
function defaultNumber(field, strategyVersion) {
|
||||
@ -431,6 +506,17 @@ function formConfigNumber(config, snake, 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) {
|
||||
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({
|
||||
anchor_rate_percent: "1",
|
||||
enabled: false,
|
||||
initial_pool_coins: "1000000",
|
||||
initial_pool_coins: "0",
|
||||
jackpot_multipliers: "200, 500, 1000",
|
||||
max_single_payout: "0",
|
||||
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([
|
||||
["0", "0"],
|
||||
["0", "1"],
|
||||
["1", "1"],
|
||||
["0", "0"],
|
||||
["0", "0"],
|
||||
]);
|
||||
expect(form.stages.map((stage) => stage.tiers.map((tier) => Number(tier.probabilityPercent)))).toEqual([
|
||||
[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", () => {
|
||||
const draft = configToForm({ app_code: "lalu", pool_id: "default", strategy_version: "dynamic_v3" });
|
||||
expect(validateLuckyGiftForm(draft)).toEqual([]);
|
||||
@ -56,7 +70,8 @@ describe("ops center lucky gift dynamic_v3 form", () => {
|
||||
const enabled = { ...draft, enabled: true };
|
||||
const errors = validateLuckyGiftForm(enabled);
|
||||
|
||||
expect(errors).toContain("启用前必须填写 50 美元等值的大奖消费门槛");
|
||||
expect(errors).toContain("启用前普通阶段至少一个最低充值门槛必须大于 0");
|
||||
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);
|
||||
|
||||
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([
|
||||
[0, 0.5, 1, 2],
|
||||
[0, 0.5, 1, 2],
|
||||
@ -130,6 +150,32 @@ describe("ops center lucky gift dynamic_v3 form", () => {
|
||||
expect(errors).toContain("单次返奖上限不能小于 0");
|
||||
expect(errors).toContain("充值阶段门槛不能小于 0");
|
||||
});
|
||||
|
||||
test("rejects jackpot multipliers that still have ordinary stage probability", () => {
|
||||
const form = configToForm(completeDynamicConfig());
|
||||
const errors = validateLuckyGiftForm({
|
||||
...form,
|
||||
stages: form.stages.map((stage) =>
|
||||
stage.stage === "normal"
|
||||
? {
|
||||
...stage,
|
||||
tiers: [
|
||||
...stage.tiers,
|
||||
{
|
||||
enabled: true,
|
||||
highWaterOnly: true,
|
||||
multiplier: "200",
|
||||
probabilityPercent: "0.01",
|
||||
tierId: "normal_200x",
|
||||
},
|
||||
],
|
||||
}
|
||||
: stage,
|
||||
),
|
||||
});
|
||||
|
||||
expect(errors).toContain("正常阶段普通奖档不能包含大奖倍率:200x");
|
||||
});
|
||||
});
|
||||
|
||||
function completeDynamicConfig() {
|
||||
@ -144,7 +190,7 @@ function completeDynamicConfig() {
|
||||
gift_price_reference: 100,
|
||||
high_water_nonzero_factor_percent: 130,
|
||||
high_watermark_coins: 20_000_000,
|
||||
initial_pool_coins: 1_000_000,
|
||||
initial_pool_coins: 0,
|
||||
jackpot_global_rtp_max_percent: 98,
|
||||
jackpot_multipliers: [200, 500, 1000],
|
||||
jackpot_spend_threshold_coins: 50_000,
|
||||
|
||||
@ -2,9 +2,10 @@ import CssBaseline from "@mui/material/CssBaseline";
|
||||
import { ThemeProvider } from "@mui/material/styles";
|
||||
import React from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { AuthProvider } from "@/app/auth/AuthProvider.jsx";
|
||||
import { ToastProvider } from "@/shared/ui/ToastProvider.jsx";
|
||||
import { theme } from "@/theme.js";
|
||||
import { OpsCenterApp } from "./OpsCenterApp.jsx";
|
||||
import { AuthenticatedOpsCenter } from "./OpsCenterEntry.jsx";
|
||||
import "@/styles/tokens.css";
|
||||
import "@/styles/shared-ui.css";
|
||||
import "@/styles/responsive.css";
|
||||
@ -14,9 +15,11 @@ createRoot(document.getElementById("ops-center-root")).render(
|
||||
<React.StrictMode>
|
||||
<ThemeProvider theme={theme}>
|
||||
<ToastProvider>
|
||||
<AuthProvider>
|
||||
<CssBaseline />
|
||||
<OpsCenterApp />
|
||||
<AuthenticatedOpsCenter />
|
||||
</AuthProvider>
|
||||
</ToastProvider>
|
||||
</ThemeProvider>
|
||||
</React.StrictMode>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
||||
@ -147,15 +147,66 @@ body {
|
||||
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 {
|
||||
display: contents;
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.ops-config-title {
|
||||
display: grid;
|
||||
flex: 0 0 auto;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-4) var(--space-6) var(--space-3);
|
||||
}
|
||||
|
||||
.ops-config-title small {
|
||||
@ -164,17 +215,31 @@ body {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.ops-config-metrics {
|
||||
.ops-config-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-4);
|
||||
flex: 0 0 auto;
|
||||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||
margin: 0;
|
||||
padding: 0 var(--space-6) var(--space-3);
|
||||
}
|
||||
|
||||
.ops-config-dialog-content {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
overflow: hidden !important;
|
||||
background: var(--bg-card);
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.ops-config-validation {
|
||||
display: grid;
|
||||
max-height: 112px;
|
||||
flex: 0 0 auto;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-4);
|
||||
overflow-y: auto;
|
||||
margin: var(--space-3) var(--space-4) 0;
|
||||
border: 1px solid var(--danger-border, #fecaca);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--danger-surface, #fef2f2);
|
||||
@ -182,6 +247,11 @@ body {
|
||||
padding: var(--space-3) var(--space-4);
|
||||
}
|
||||
|
||||
.ops-config-validation:focus-visible {
|
||||
outline: 2px solid var(--danger, #dc2626);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.ops-config-validation ul {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
@ -192,26 +262,31 @@ body {
|
||||
.ops-config-metric {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: var(--space-1);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--bg-card-strong, #f8fafc);
|
||||
padding: 10px var(--space-3);
|
||||
gap: 2px;
|
||||
border-left: 1px solid var(--border);
|
||||
padding: var(--space-1) var(--space-3);
|
||||
}
|
||||
|
||||
.ops-config-metric span {
|
||||
.ops-config-metric:first-child {
|
||||
border-left: 0;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.ops-config-metric dt {
|
||||
overflow: hidden;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ops-config-metric strong {
|
||||
.ops-config-metric dd {
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
font-size: 15px;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
line-height: 1.25;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@ -219,24 +294,94 @@ body {
|
||||
|
||||
.ops-config-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(360px, 440px) minmax(0, 1fr);
|
||||
align-items: start;
|
||||
gap: var(--space-4);
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
flex: 1 1 auto;
|
||||
grid-template-columns: clamp(500px, 40vw, 620px) minmax(0, 1fr);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ops-config-params {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: var(--space-4);
|
||||
min-height: 0;
|
||||
grid-template-columns: 92px minmax(0, 1fr);
|
||||
overflow: hidden;
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.ops-config-section-nav {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
overflow-y: auto;
|
||||
border-right: 1px solid var(--border);
|
||||
background: var(--bg-card-strong, #f8fafc);
|
||||
padding: var(--space-3) var(--space-2);
|
||||
}
|
||||
|
||||
.ops-config-section-nav strong {
|
||||
color: var(--text-tertiary);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.04em;
|
||||
padding: var(--space-1) var(--space-2) var(--space-2);
|
||||
}
|
||||
|
||||
.ops-config-section-nav button {
|
||||
min-height: 34px;
|
||||
border: 0;
|
||||
border-left: 2px solid transparent;
|
||||
border-radius: var(--radius-control);
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
padding: 0 var(--space-2);
|
||||
text-align: left;
|
||||
transition:
|
||||
background var(--motion-fast) var(--ease-standard),
|
||||
border-color var(--motion-fast) var(--ease-standard),
|
||||
color var(--motion-fast) var(--ease-standard);
|
||||
}
|
||||
|
||||
.ops-config-section-nav button:hover {
|
||||
background: var(--primary-hover);
|
||||
}
|
||||
|
||||
.ops-config-section-nav button.is-active {
|
||||
border-left-color: var(--primary);
|
||||
background: var(--primary-surface);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.ops-config-section-list {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
padding: var(--space-3) var(--space-4) var(--space-6);
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
.ops-config-section {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-card);
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
border-radius: 0;
|
||||
background: var(--bg-card);
|
||||
padding: var(--space-4);
|
||||
padding: var(--space-3) 0 var(--space-5);
|
||||
}
|
||||
|
||||
.ops-config-section + .ops-config-section {
|
||||
padding-top: var(--space-5);
|
||||
}
|
||||
|
||||
.ops-config-section:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.ops-config-section h3 {
|
||||
@ -246,6 +391,28 @@ body {
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.ops-config-section__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.ops-config-section__head span {
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--warning-surface);
|
||||
color: var(--warning);
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
padding: 3px var(--space-2);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ops-config-section__head span.is-ok {
|
||||
background: var(--success-surface);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.ops-form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
@ -256,44 +423,220 @@ body {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.ops-config-enabled-field {
|
||||
min-height: var(--control-height);
|
||||
justify-content: space-between;
|
||||
margin: 0 !important;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-control);
|
||||
padding: 0 var(--space-3);
|
||||
}
|
||||
|
||||
.ops-config-enabled-field .MuiFormControlLabel-label {
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.ops-field-note {
|
||||
margin: 0;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.ops-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 {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-card);
|
||||
min-height: 0;
|
||||
align-content: start;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: var(--bg-card);
|
||||
}
|
||||
|
||||
.ops-stage-tabs {
|
||||
display: flex;
|
||||
position: sticky;
|
||||
z-index: 3;
|
||||
top: 0;
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: var(--space-1);
|
||||
padding: 0 var(--space-3);
|
||||
min-height: 64px;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg-card);
|
||||
padding: 0 var(--space-4);
|
||||
}
|
||||
|
||||
.ops-stage-tab {
|
||||
display: inline-flex;
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
height: 38px;
|
||||
align-items: center;
|
||||
min-height: 64px;
|
||||
align-content: center;
|
||||
gap: 3px;
|
||||
border: 0;
|
||||
border-bottom: 2px solid transparent;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
padding: 0 var(--space-4);
|
||||
text-align: center;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
text-align: left;
|
||||
transition:
|
||||
background var(--motion-fast) var(--ease-standard),
|
||||
border-color var(--motion-fast) var(--ease-standard),
|
||||
color var(--motion-fast) var(--ease-standard);
|
||||
}
|
||||
|
||||
.ops-stage-tab__name {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.ops-stage-tab__name strong {
|
||||
color: inherit;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.ops-stage-tab__name small,
|
||||
.ops-stage-tab__metrics {
|
||||
overflow: hidden;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ops-stage-tab.is-warn .ops-stage-tab__name small {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.ops-stage-tab:hover {
|
||||
background: var(--primary-hover);
|
||||
}
|
||||
@ -316,17 +659,24 @@ body {
|
||||
.ops-stage-stack {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-4);
|
||||
padding: 0 var(--space-4) var(--space-6);
|
||||
}
|
||||
|
||||
.ops-stage-block__head {
|
||||
position: sticky;
|
||||
z-index: 2;
|
||||
top: 64px;
|
||||
display: flex;
|
||||
min-height: 58px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg-card);
|
||||
padding: var(--space-2) 0;
|
||||
}
|
||||
|
||||
.ops-stage-block__head > div {
|
||||
.ops-stage-block__summary {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
@ -340,12 +690,38 @@ body {
|
||||
|
||||
.ops-stage-thresholds {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
grid-template-columns: auto repeat(2, minmax(140px, 220px));
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-control);
|
||||
justify-content: end;
|
||||
padding: var(--space-1) 0;
|
||||
}
|
||||
|
||||
.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);
|
||||
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 {
|
||||
@ -361,13 +737,13 @@ body {
|
||||
.ops-tier-row {
|
||||
display: grid;
|
||||
box-sizing: border-box;
|
||||
min-width: 520px;
|
||||
min-width: 590px;
|
||||
grid-template-columns:
|
||||
minmax(110px, 1fr)
|
||||
minmax(110px, 1fr)
|
||||
minmax(64px, auto)
|
||||
minmax(64px, auto)
|
||||
minmax(72px, auto);
|
||||
minmax(100px, 1fr)
|
||||
minmax(124px, 1fr)
|
||||
96px
|
||||
64px
|
||||
56px;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
@ -382,17 +758,94 @@ body {
|
||||
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 {
|
||||
border-bottom: 1px solid var(--row-border);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
padding: 6px var(--space-3);
|
||||
}
|
||||
|
||||
.ops-tier-row:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.ops-tier-cell {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ops-tier-cell > .MuiFormControl-root {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ops-tier-cell--center {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.ops-config-dialog-actions {
|
||||
min-height: 60px;
|
||||
flex: 0 0 auto;
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--bg-card);
|
||||
padding: var(--space-2) var(--space-6);
|
||||
}
|
||||
|
||||
/* ---- 响应式 ---- */
|
||||
|
||||
@media (max-width: 1279px) {
|
||||
.ops-config-summary {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
row-gap: var(--space-2);
|
||||
}
|
||||
|
||||
.ops-config-dialog-content {
|
||||
overflow-y: auto !important;
|
||||
}
|
||||
|
||||
.ops-config-layout {
|
||||
height: auto;
|
||||
min-height: 100%;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.ops-config-params {
|
||||
min-height: auto;
|
||||
grid-template-columns: 112px minmax(0, 1fr);
|
||||
overflow: visible;
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.ops-config-section-nav {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
max-height: 100%;
|
||||
align-self: start;
|
||||
}
|
||||
|
||||
.ops-config-section-list,
|
||||
.ops-stage-designer {
|
||||
min-height: auto;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.ops-stage-designer {
|
||||
border-top: var(--space-2) solid var(--bg-page);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.ops-shell {
|
||||
grid-template-columns: 1fr;
|
||||
@ -403,14 +856,129 @@ body {
|
||||
}
|
||||
|
||||
.ops-nav,
|
||||
.ops-kpi-grid,
|
||||
.ops-config-metrics {
|
||||
.ops-kpi-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
.ops-config-layout {
|
||||
@media (max-width: 767px) {
|
||||
.MuiDialog-paper.ops-config-dialog-paper {
|
||||
width: 100vw;
|
||||
max-width: none;
|
||||
height: 100dvh;
|
||||
max-height: 100dvh;
|
||||
margin: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.ops-config-title {
|
||||
padding: var(--space-3) var(--space-4) var(--space-2);
|
||||
}
|
||||
|
||||
.ops-config-summary {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
overflow-x: auto;
|
||||
padding: 0 var(--space-4) var(--space-2);
|
||||
}
|
||||
|
||||
.ops-config-metric {
|
||||
min-width: 124px;
|
||||
}
|
||||
|
||||
.ops-config-params {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.ops-config-section-nav {
|
||||
z-index: 4;
|
||||
display: flex;
|
||||
max-width: 100vw;
|
||||
flex-direction: row;
|
||||
overflow-x: auto;
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: var(--space-2);
|
||||
}
|
||||
|
||||
.ops-config-section-nav strong {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ops-config-section-nav button {
|
||||
min-width: 72px;
|
||||
flex: 0 0 auto;
|
||||
border-left: 0;
|
||||
border-bottom: 2px solid transparent;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ops-config-section-nav button.is-active {
|
||||
border-bottom-color: var(--primary);
|
||||
}
|
||||
|
||||
.ops-config-section-list {
|
||||
padding: var(--space-2) var(--space-3) var(--space-5);
|
||||
}
|
||||
|
||||
.ops-form-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.ops-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) {
|
||||
@ -420,21 +988,19 @@ body {
|
||||
|
||||
.ops-nav,
|
||||
.ops-kpi-grid,
|
||||
.ops-config-metrics,
|
||||
.ops-form-grid,
|
||||
.ops-stage-thresholds,
|
||||
.ops-tier-row {
|
||||
.ops-pool-adjustment-metrics {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.ops-tier-head {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.ops-nav button,
|
||||
.ops-config-section-nav button,
|
||||
.ops-stage-tab {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.ops-config-section-list {
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@ -55,7 +55,7 @@ export const PERMISSIONS = {
|
||||
financeOrderCoinSellerRechargeCreate: "finance-order:coin-seller-recharge:create",
|
||||
financeOrderCoinSellerRechargeVerify: "finance-order:coin-seller-recharge:verify",
|
||||
financeOrderCoinSellerRechargeGrant: "finance-order:coin-seller-recharge:grant",
|
||||
financeOrderUSDTAddressUpdate: "finance-order:usdt-address:update",
|
||||
financeOrderUsdtAddressUpdate: "finance-order:usdt-address:update",
|
||||
coinLedgerView: "coin-ledger:view",
|
||||
coinSellerLedgerView: "coin-seller-ledger:view",
|
||||
giftRecordView: "gift-record:view",
|
||||
@ -192,6 +192,8 @@ export const PERMISSIONS = {
|
||||
sevenDayCheckInUpdate: "seven-day-checkin:update",
|
||||
luckyGiftView: "lucky-gift:view",
|
||||
luckyGiftUpdate: "lucky-gift:update",
|
||||
luckyGiftPoolCredit: "lucky-gift:pool-credit",
|
||||
luckyGiftPoolDebit: "lucky-gift:pool-debit",
|
||||
wheelView: "wheel:view",
|
||||
wheelUpdate: "wheel:update",
|
||||
roomRocketView: "room-rocket:view",
|
||||
|
||||
@ -81,9 +81,11 @@ export const API_OPERATIONS = {
|
||||
createUser: "createUser",
|
||||
createWeeklyStarCycle: "createWeeklyStarCycle",
|
||||
creditCoinSellerStock: "creditCoinSellerStock",
|
||||
creditLuckyGiftPool: "creditLuckyGiftPool",
|
||||
dashboardOverview: "dashboardOverview",
|
||||
dashboardUserProfileOverview: "dashboardUserProfileOverview",
|
||||
debitCoinSellerStock: "debitCoinSellerStock",
|
||||
debitLuckyGiftPool: "debitLuckyGiftPool",
|
||||
deleteAchievementDefinition: "deleteAchievementDefinition",
|
||||
deleteActivityTemplate: "deleteActivityTemplate",
|
||||
deleteAgency: "deleteAgency",
|
||||
@ -887,6 +889,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
||||
permission: "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: {
|
||||
method: "GET",
|
||||
operationId: API_OPERATIONS.dashboardOverview,
|
||||
@ -908,6 +917,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
||||
permission: "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: {
|
||||
method: "DELETE",
|
||||
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;
|
||||
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": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@ -4792,6 +4824,64 @@ export interface paths {
|
||||
export type webhooks = Record<string, never>;
|
||||
export interface components {
|
||||
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"] & {
|
||||
data?: components["schemas"]["AdminAppList"];
|
||||
};
|
||||
@ -7333,6 +7423,15 @@ export interface components {
|
||||
"application/json": components["schemas"]["ApiResponseEmpty"];
|
||||
};
|
||||
};
|
||||
/** @description 奖池水位调整成功或同幂等请求的首次结果 */
|
||||
LuckyGiftPoolAdjustmentResponse: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ApiResponseLuckyGiftPoolAdjustmentResult"];
|
||||
};
|
||||
};
|
||||
/** @description OK */
|
||||
RechargeBillPageResponse: {
|
||||
headers: {
|
||||
@ -8254,6 +8353,74 @@ export interface operations {
|
||||
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: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user