Merge lucky gift dynamic v3 admin into test
This commit is contained in:
commit
6589c77288
@ -320,7 +320,7 @@
|
|||||||
"x-permissions": ["red-packet:update"]
|
"x-permissions": ["red-packet:update"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/admin/activity/lucky-gifts/v2/config": {
|
"/admin/ops-center/lucky-gifts/config": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "getLuckyGiftConfig",
|
"operationId": "getLuckyGiftConfig",
|
||||||
"responses": {
|
"responses": {
|
||||||
@ -342,7 +342,7 @@
|
|||||||
"x-permissions": ["lucky-gift:update"]
|
"x-permissions": ["lucky-gift:update"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/admin/activity/lucky-gifts/v2/configs": {
|
"/admin/ops-center/lucky-gifts/configs": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "listLuckyGiftConfigs",
|
"operationId": "listLuckyGiftConfigs",
|
||||||
"responses": {
|
"responses": {
|
||||||
@ -354,7 +354,7 @@
|
|||||||
"x-permissions": ["lucky-gift:view"]
|
"x-permissions": ["lucky-gift:view"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/admin/activity/lucky-gifts/v2/draws": {
|
"/admin/ops-center/lucky-gifts/draws": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "listLuckyGiftDraws",
|
"operationId": "listLuckyGiftDraws",
|
||||||
"responses": {
|
"responses": {
|
||||||
@ -366,7 +366,7 @@
|
|||||||
"x-permissions": ["lucky-gift:view"]
|
"x-permissions": ["lucky-gift:view"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/admin/activity/lucky-gifts/v2/draw-summary": {
|
"/admin/ops-center/lucky-gifts/summary": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "getLuckyGiftDrawSummary",
|
"operationId": "getLuckyGiftDrawSummary",
|
||||||
"responses": {
|
"responses": {
|
||||||
@ -378,6 +378,18 @@
|
|||||||
"x-permissions": ["lucky-gift:view"]
|
"x-permissions": ["lucky-gift:view"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/admin/ops-center/lucky-gifts/pools": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "listLuckyGiftPoolBalances",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"$ref": "#/components/responses/EmptyResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"x-permission": "lucky-gift:view",
|
||||||
|
"x-permissions": ["lucky-gift:view"]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/admin/activity/wheel/config": {
|
"/admin/activity/wheel/config": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "getWheelConfig",
|
"operationId": "getWheelConfig",
|
||||||
|
|||||||
@ -17,149 +17,185 @@ import { OpsShell } from "./components/OpsShell.jsx";
|
|||||||
import { OverviewView } from "./components/OverviewView.jsx";
|
import { OverviewView } from "./components/OverviewView.jsx";
|
||||||
|
|
||||||
const views = [
|
const views = [
|
||||||
{ icon: InsightsOutlined, key: "overview", label: "数据总览" },
|
{ icon: InsightsOutlined, key: "overview", label: "数据总览" },
|
||||||
{ icon: CardGiftcardOutlined, key: "configs", label: "幸运礼物配置" },
|
{ icon: CardGiftcardOutlined, key: "configs", label: "幸运礼物配置" },
|
||||||
{ icon: CasinoOutlined, key: "draws", label: "抽奖记录" },
|
{ icon: CasinoOutlined, key: "draws", label: "抽奖记录" },
|
||||||
{ icon: AppsOutlined, key: "apps", label: "应用列表" }
|
{ icon: AppsOutlined, key: "apps", label: "应用列表" },
|
||||||
];
|
];
|
||||||
|
const viewKeys = new Set(views.map((view) => view.key));
|
||||||
|
|
||||||
const initialDashboard = {
|
const initialDashboard = {
|
||||||
apps: [],
|
apps: [],
|
||||||
appSummaries: [],
|
appSummaries: [],
|
||||||
luckyGifts: [],
|
luckyGifts: [],
|
||||||
poolBalances: [],
|
poolBalances: [],
|
||||||
summary: {}
|
summary: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
export function OpsCenterApp() {
|
export function initialOpsCenterView(search = globalThis.location?.search || "") {
|
||||||
const [activeView, setActiveView] = useState("overview");
|
const requested = new URLSearchParams(search).get("view") || "";
|
||||||
const [state, setState] = useState({ data: initialDashboard, error: "", loaded: false, loading: true });
|
return viewKeys.has(requested) ? requested : "overview";
|
||||||
const [dialog, setDialog] = useState(null);
|
}
|
||||||
const [savingConfig, setSavingConfig] = useState(false);
|
|
||||||
const { showToast } = useToast();
|
export function OpsCenterApp() {
|
||||||
|
// 主后台旧入口通过 ?view=configs 进入独立 HTML;只接受白名单视图,异常参数仍回到总览。
|
||||||
const loadDashboard = useCallback(async () => {
|
const [activeView, setActiveView] = useState(() => initialOpsCenterView());
|
||||||
setState((current) => ({ ...current, error: "", loading: true }));
|
const [state, setState] = useState({ data: initialDashboard, error: "", loaded: false, loading: true });
|
||||||
try {
|
const [dialog, setDialog] = useState(null);
|
||||||
const data = await fetchOpsDashboard();
|
const [savingConfig, setSavingConfig] = useState(false);
|
||||||
setState({ data, error: "", loaded: true, loading: false });
|
const { showToast } = useToast();
|
||||||
} catch (error) {
|
|
||||||
// 接口灰度期间保留已加载的数据和页面结构,单次失败只提示、不整页白屏。
|
const loadDashboard = useCallback(async () => {
|
||||||
setState((current) => ({
|
setState((current) => ({ ...current, error: "", loading: true }));
|
||||||
data: current.data || initialDashboard,
|
try {
|
||||||
error: error.message || "运营接口暂不可用",
|
const data = await fetchOpsDashboard();
|
||||||
loaded: current.loaded,
|
setState({ data, error: "", loaded: true, loading: false });
|
||||||
loading: false
|
} catch (error) {
|
||||||
}));
|
// 接口灰度期间保留已加载的数据和页面结构,单次失败只提示、不整页白屏。
|
||||||
}
|
setState((current) => ({
|
||||||
}, []);
|
data: current.data || initialDashboard,
|
||||||
|
error: error.message || "运营接口暂不可用",
|
||||||
useEffect(() => {
|
loaded: current.loaded,
|
||||||
loadDashboard();
|
loading: false,
|
||||||
}, [loadDashboard]);
|
}));
|
||||||
|
}
|
||||||
const { data } = state;
|
}, []);
|
||||||
|
|
||||||
const appOptions = useMemo(
|
useEffect(() => {
|
||||||
() => data.apps.map((item) => [item.app_code ?? item.appCode, `${item.app_name ?? item.appName ?? ""} (${item.app_code ?? item.appCode})`]),
|
loadDashboard();
|
||||||
[data.apps]
|
}, [loadDashboard]);
|
||||||
);
|
|
||||||
|
const { data } = state;
|
||||||
// 抽奖记录页的奖池下拉:配置和真实水位都可能先于对方存在(先发布未入账 / 老池子停用后仍有流水),取并集。
|
|
||||||
const poolIDsByApp = useMemo(() => {
|
const appOptions = useMemo(
|
||||||
const byApp = new Map();
|
() =>
|
||||||
const add = (appCode, poolID) => {
|
data.apps.map((item) => [
|
||||||
if (!appCode || !poolID) {
|
item.app_code ?? item.appCode,
|
||||||
return;
|
`${item.app_name ?? item.appName ?? ""} (${item.app_code ?? item.appCode})`,
|
||||||
}
|
]),
|
||||||
if (!byApp.has(appCode)) {
|
[data.apps],
|
||||||
byApp.set(appCode, new Set());
|
);
|
||||||
}
|
|
||||||
byApp.get(appCode).add(poolID);
|
// 抽奖记录页的奖池下拉:配置和真实水位都可能先于对方存在(先发布未入账 / 老池子停用后仍有流水),取并集。
|
||||||
};
|
const poolIDsByApp = useMemo(() => {
|
||||||
data.luckyGifts.forEach((config) => add(config.app_code, config.pool_id));
|
const byApp = new Map();
|
||||||
data.poolBalances.forEach((pool) => add(pool.app_code ?? pool.appCode, pool.pool_id ?? pool.poolId));
|
const add = (appCode, poolID) => {
|
||||||
return new Map(Array.from(byApp.entries(), ([appCode, poolIDs]) => [appCode, Array.from(poolIDs).sort()]));
|
if (!appCode || !poolID) {
|
||||||
}, [data.luckyGifts, data.poolBalances]);
|
return;
|
||||||
|
}
|
||||||
const openCreateConfig = useCallback(() => {
|
if (!byApp.has(appCode)) {
|
||||||
// 新建配置以第一个应用的现有配置做参数模板(每个应用至少有一行草稿或已发布配置),
|
byApp.set(appCode, new Set());
|
||||||
// 应用和奖池在弹窗里由运营自行选择,避免旧版"添加配置"悄悄绑定到第一个应用的问题。
|
}
|
||||||
const template = data.luckyGifts[0];
|
byApp.get(appCode).add(poolID);
|
||||||
if (!template) {
|
};
|
||||||
showToast("暂无可添加配置的应用", "warning");
|
data.luckyGifts.forEach((config) => add(config.app_code, config.pool_id));
|
||||||
return;
|
data.poolBalances.forEach((pool) => add(pool.app_code ?? pool.appCode, pool.pool_id ?? pool.poolId));
|
||||||
}
|
return new Map(Array.from(byApp.entries(), ([appCode, poolIDs]) => [appCode, Array.from(poolIDs).sort()]));
|
||||||
setDialog({
|
}, [data.luckyGifts, data.poolBalances]);
|
||||||
config: { ...structuredClone(template), is_default: true, pool_id: DEFAULT_POOL_ID, rule_version: 0 },
|
|
||||||
mode: "create"
|
const openCreateConfig = useCallback(() => {
|
||||||
});
|
const firstAppCode = data.apps
|
||||||
}, [data.luckyGifts, showToast]);
|
.map((item) =>
|
||||||
|
String(item.app_code ?? item.appCode ?? "")
|
||||||
const openEditConfig = useCallback((config) => {
|
.trim()
|
||||||
setDialog({ config: structuredClone(config), mode: "edit" });
|
.toLowerCase(),
|
||||||
}, []);
|
)
|
||||||
|
.find(Boolean);
|
||||||
const handleSaveConfig = useCallback(
|
if (!firstAppCode) {
|
||||||
async (config) => {
|
showToast("暂无可添加配置的应用", "warning");
|
||||||
setSavingConfig(true);
|
return;
|
||||||
try {
|
}
|
||||||
await saveLuckyGiftConfig(config);
|
// 金额门槛和六维上限按 App 金币口径配置,绝不能复制第一条已发布规则给另一个 App。
|
||||||
showToast(`已发布 ${config.app_code} / ${config.pool_id} 的新规则版本`, "success");
|
// 仅传规则身份,让 configToForm 生成 disabled dynamic_v3 安全草稿;金额字段保持 0,运营补齐后才能启用。
|
||||||
setDialog(null);
|
setDialog({
|
||||||
await loadDashboard();
|
config: {
|
||||||
} catch (error) {
|
app_code: firstAppCode,
|
||||||
// 保存失败保持弹窗打开,运营修完参数可直接重试,不丢已填内容。
|
enabled: false,
|
||||||
showToast(error.message || "保存幸运礼物配置失败", "error");
|
is_default: true,
|
||||||
} finally {
|
pool_id: DEFAULT_POOL_ID,
|
||||||
setSavingConfig(false);
|
rule_version: 0,
|
||||||
}
|
strategy_version: "dynamic_v3",
|
||||||
},
|
},
|
||||||
[loadDashboard, showToast]
|
mode: "create",
|
||||||
);
|
});
|
||||||
|
}, [data.apps, showToast]);
|
||||||
const defaultAppCode = data.apps[0]?.app_code ?? data.apps[0]?.appCode ?? "";
|
|
||||||
|
const openEditConfig = useCallback((config) => {
|
||||||
return (
|
setDialog({ config: structuredClone(config), mode: "edit" });
|
||||||
<OpsShell activeView={activeView} onViewChange={setActiveView} views={views}>
|
}, []);
|
||||||
<div className="ops-page">
|
|
||||||
<PageHead meta="跨应用幸运礼物配置与抽奖数据面板" title="运营配置中心">
|
const handleSaveConfig = useCallback(
|
||||||
<span className={state.loading ? "ops-status is-loading" : "ops-status"}>{state.loading ? "同步中" : "已就绪"}</span>
|
async (config) => {
|
||||||
<Button disabled={state.loading} startIcon={<RefreshOutlined fontSize="small" />} onClick={loadDashboard}>
|
setSavingConfig(true);
|
||||||
刷新
|
try {
|
||||||
</Button>
|
await saveLuckyGiftConfig(config);
|
||||||
</PageHead>
|
showToast(`已发布 ${config.app_code} / ${config.pool_id} 的新规则版本`, "success");
|
||||||
{/* 首屏失败走 DataState 的错误重试页;已有数据时刷新失败只弹提示条,保留旧数据可用。 */}
|
setDialog(null);
|
||||||
{state.error && state.loaded ? (
|
await loadDashboard();
|
||||||
<div className="ops-alert" role="alert">
|
} catch (error) {
|
||||||
{state.error}
|
// 保存失败保持弹窗打开,运营修完参数可直接重试,不丢已填内容。
|
||||||
</div>
|
showToast(error.message || "保存幸运礼物配置失败", "error");
|
||||||
) : null}
|
} finally {
|
||||||
<DataState error={state.loaded ? "" : state.error} loading={state.loading && !state.loaded} onRetry={loadDashboard}>
|
setSavingConfig(false);
|
||||||
{activeView === "overview" ? <OverviewView data={data} /> : null}
|
}
|
||||||
{activeView === "configs" ? (
|
},
|
||||||
<ConfigsView
|
[loadDashboard, showToast],
|
||||||
apps={data.apps}
|
);
|
||||||
luckyGifts={data.luckyGifts}
|
|
||||||
saving={savingConfig}
|
const defaultAppCode = data.apps[0]?.app_code ?? data.apps[0]?.appCode ?? "";
|
||||||
onCreate={openCreateConfig}
|
|
||||||
onEdit={openEditConfig}
|
return (
|
||||||
/>
|
<OpsShell activeView={activeView} onViewChange={setActiveView} views={views}>
|
||||||
) : null}
|
<div className="ops-page">
|
||||||
{activeView === "draws" ? <DrawsView apps={data.apps} defaultAppCode={defaultAppCode} poolIDsByApp={poolIDsByApp} /> : null}
|
<PageHead meta="跨应用幸运礼物配置与抽奖数据面板" title="运营配置中心">
|
||||||
{activeView === "apps" ? <AppsView apps={data.apps} /> : null}
|
<span className={state.loading ? "ops-status is-loading" : "ops-status"}>
|
||||||
</DataState>
|
{state.loading ? "同步中" : "已就绪"}
|
||||||
{dialog ? (
|
</span>
|
||||||
<LuckyGiftConfigDialog
|
<Button
|
||||||
appOptions={appOptions}
|
disabled={state.loading}
|
||||||
config={dialog.config}
|
startIcon={<RefreshOutlined fontSize="small" />}
|
||||||
mode={dialog.mode}
|
onClick={loadDashboard}
|
||||||
saving={savingConfig}
|
>
|
||||||
onClose={() => setDialog(null)}
|
刷新
|
||||||
onSubmit={handleSaveConfig}
|
</Button>
|
||||||
/>
|
</PageHead>
|
||||||
) : null}
|
{/* 首屏失败走 DataState 的错误重试页;已有数据时刷新失败只弹提示条,保留旧数据可用。 */}
|
||||||
</div>
|
{state.error && state.loaded ? (
|
||||||
</OpsShell>
|
<div className="ops-alert" role="alert">
|
||||||
);
|
{state.error}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<DataState
|
||||||
|
error={state.loaded ? "" : state.error}
|
||||||
|
loading={state.loading && !state.loaded}
|
||||||
|
onRetry={loadDashboard}
|
||||||
|
>
|
||||||
|
{activeView === "overview" ? <OverviewView data={data} /> : null}
|
||||||
|
{activeView === "configs" ? (
|
||||||
|
<ConfigsView
|
||||||
|
apps={data.apps}
|
||||||
|
luckyGifts={data.luckyGifts}
|
||||||
|
saving={savingConfig}
|
||||||
|
onCreate={openCreateConfig}
|
||||||
|
onEdit={openEditConfig}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{activeView === "draws" ? (
|
||||||
|
<DrawsView apps={data.apps} defaultAppCode={defaultAppCode} poolIDsByApp={poolIDsByApp} />
|
||||||
|
) : null}
|
||||||
|
{activeView === "apps" ? <AppsView apps={data.apps} /> : null}
|
||||||
|
</DataState>
|
||||||
|
{dialog ? (
|
||||||
|
<LuckyGiftConfigDialog
|
||||||
|
appOptions={appOptions}
|
||||||
|
config={dialog.config}
|
||||||
|
mode={dialog.mode}
|
||||||
|
saving={savingConfig}
|
||||||
|
onClose={() => setDialog(null)}
|
||||||
|
onSubmit={handleSaveConfig}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</OpsShell>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,175 +2,339 @@ import { fireEvent, render, screen, waitFor, within } from "@testing-library/rea
|
|||||||
import { beforeEach, expect, test, vi } from "vitest";
|
import { beforeEach, expect, test, vi } from "vitest";
|
||||||
import { ToastProvider } from "@/shared/ui/ToastProvider.jsx";
|
import { ToastProvider } from "@/shared/ui/ToastProvider.jsx";
|
||||||
import { fetchLuckyGiftDraws, fetchOpsDashboard, saveLuckyGiftConfig } from "./api.js";
|
import { fetchLuckyGiftDraws, fetchOpsDashboard, saveLuckyGiftConfig } from "./api.js";
|
||||||
import { OpsCenterApp } from "./OpsCenterApp.jsx";
|
import { initialOpsCenterView, OpsCenterApp } from "./OpsCenterApp.jsx";
|
||||||
|
|
||||||
vi.mock("./api.js", () => ({
|
vi.mock("./api.js", () => ({
|
||||||
DEFAULT_POOL_ID: "default",
|
DEFAULT_POOL_ID: "default",
|
||||||
fetchLuckyGiftDraws: vi.fn(),
|
fetchLuckyGiftDraws: vi.fn(),
|
||||||
fetchOpsDashboard: vi.fn(),
|
fetchOpsDashboard: vi.fn(),
|
||||||
saveLuckyGiftConfig: vi.fn()
|
saveLuckyGiftConfig: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
saveLuckyGiftConfig.mockResolvedValue({ app_code: "lalu", pool_id: "lucky" });
|
window.history.replaceState({}, "", "/");
|
||||||
fetchLuckyGiftDraws.mockResolvedValue({
|
saveLuckyGiftConfig.mockResolvedValue({ app_code: "lalu", pool_id: "lucky" });
|
||||||
items: [
|
fetchLuckyGiftDraws.mockResolvedValue({
|
||||||
{
|
items: [
|
||||||
app_code: "lalu",
|
{
|
||||||
created_at_ms: 1767000000000,
|
app_code: "lalu",
|
||||||
draw_id: "draw-1",
|
created_at_ms: 1767000000000,
|
||||||
effective_reward_coins: 100,
|
draw_id: "draw-1",
|
||||||
external_user_id: "ext-1",
|
effective_reward_coins: 100,
|
||||||
gift_id: "gift-9",
|
external_user_id: "ext-1",
|
||||||
multiplier_ppm: 2_000_000,
|
gift_id: "gift-9",
|
||||||
pool_id: "super_lucky",
|
multiplier_ppm: 2_000_000,
|
||||||
reward_status: "granted",
|
pool_id: "super_lucky",
|
||||||
rule_version: 2
|
reward_status: "granted",
|
||||||
}
|
rule_version: 2,
|
||||||
],
|
},
|
||||||
page: 1,
|
],
|
||||||
pageSize: 20,
|
page: 1,
|
||||||
total: 1
|
pageSize: 20,
|
||||||
});
|
total: 1,
|
||||||
fetchOpsDashboard.mockResolvedValue({
|
});
|
||||||
apps: [
|
fetchOpsDashboard.mockResolvedValue({
|
||||||
{ app_code: "lalu", app_name: "Lalu", source: "registry", status: "active", updated_at_ms: 1767000000000 },
|
apps: [
|
||||||
{ app_code: "yumi", app_name: "Yumi", source: "fixed", status: "active" }
|
{ app_code: "lalu", app_name: "Lalu", source: "registry", status: "active", updated_at_ms: 1767000000000 },
|
||||||
],
|
{ app_code: "yumi", app_name: "Yumi", source: "fixed", status: "active" },
|
||||||
appSummaries: [
|
],
|
||||||
{
|
appSummaries: [
|
||||||
actual_rtp_ppm: 900000,
|
{
|
||||||
app_code: "lalu",
|
actual_rtp_ppm: 900000,
|
||||||
failed_draws: 0,
|
app_code: "lalu",
|
||||||
granted_draws: 3,
|
failed_draws: 0,
|
||||||
pending_draws: 0,
|
granted_draws: 3,
|
||||||
total_draws: 3,
|
pending_draws: 0,
|
||||||
total_reward_coins: 900,
|
total_draws: 3,
|
||||||
total_spent_coins: 1000,
|
total_reward_coins: 900,
|
||||||
unique_rooms: 2,
|
total_spent_coins: 1000,
|
||||||
unique_users: 2
|
unique_rooms: 2,
|
||||||
}
|
unique_users: 2,
|
||||||
],
|
},
|
||||||
luckyGifts: [
|
],
|
||||||
{ app_code: "lalu", enabled: true, is_default: false, pool_id: "lucky", rule_version: 3, target_rtp_percent: 95 },
|
luckyGifts: [
|
||||||
{ app_code: "lalu", enabled: false, is_default: false, pool_id: "super_lucky", rule_version: 2, target_rtp_percent: 92 },
|
{
|
||||||
{ app_code: "yumi", enabled: false, is_default: true, pool_id: "default", rule_version: 0, target_rtp_percent: 95 }
|
...mockDynamicConfig(),
|
||||||
],
|
app_code: "lalu",
|
||||||
poolBalances: [
|
enabled: true,
|
||||||
{ app_code: "lalu", available_balance: 37950017, balance: 38559017, materialized: true, pool_id: "lucky", reserve_floor: 609000, updated_at_ms: 1767000000000 },
|
is_default: false,
|
||||||
{ app_code: "yumi", available_balance: 361500, balance: 382500, materialized: false, pool_id: "default", reserve_floor: 21000 }
|
pool_id: "lucky",
|
||||||
],
|
rule_version: 3,
|
||||||
summary: {
|
},
|
||||||
activeApps: 2,
|
{
|
||||||
configuredPools: 2,
|
...mockFixedConfig(92),
|
||||||
enabledPools: 1,
|
app_code: "lalu",
|
||||||
failedDraws: 0,
|
enabled: false,
|
||||||
grantedDraws: 3,
|
is_default: false,
|
||||||
pendingDraws: 0,
|
pool_id: "super_lucky",
|
||||||
poolAvailableTotal: 38311517,
|
rule_version: 2,
|
||||||
poolBalanceTotal: 38941517,
|
},
|
||||||
poolReserveTotal: 630000,
|
{
|
||||||
totalDraws: 3,
|
...mockDynamicConfig(),
|
||||||
totalRewardCoins: 900,
|
app_code: "yumi",
|
||||||
totalSpentCoins: 1000
|
enabled: false,
|
||||||
}
|
is_default: true,
|
||||||
});
|
pool_id: "default",
|
||||||
|
rule_version: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
poolBalances: [
|
||||||
|
{
|
||||||
|
app_code: "lalu",
|
||||||
|
available_balance: 37950017,
|
||||||
|
balance: 38559017,
|
||||||
|
materialized: true,
|
||||||
|
pool_id: "lucky",
|
||||||
|
reserve_floor: 609000,
|
||||||
|
updated_at_ms: 1767000000000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
app_code: "yumi",
|
||||||
|
available_balance: 361500,
|
||||||
|
balance: 382500,
|
||||||
|
materialized: false,
|
||||||
|
pool_id: "default",
|
||||||
|
reserve_floor: 21000,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
summary: {
|
||||||
|
activeApps: 2,
|
||||||
|
configuredPools: 2,
|
||||||
|
enabledPools: 1,
|
||||||
|
failedDraws: 0,
|
||||||
|
grantedDraws: 3,
|
||||||
|
pendingDraws: 0,
|
||||||
|
poolAvailableTotal: 38311517,
|
||||||
|
poolBalanceTotal: 38941517,
|
||||||
|
poolReserveTotal: 630000,
|
||||||
|
totalDraws: 3,
|
||||||
|
totalRewardCoins: 900,
|
||||||
|
totalSpentCoins: 1000,
|
||||||
|
},
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function renderApp() {
|
function renderApp() {
|
||||||
return render(
|
return render(
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
<OpsCenterApp />
|
<OpsCenterApp />
|
||||||
</ToastProvider>
|
</ToastProvider>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
test("renders overview with aggregated kpis and pool balances", async () => {
|
test("renders overview with aggregated kpis and pool balances", async () => {
|
||||||
renderApp();
|
renderApp();
|
||||||
|
|
||||||
expect(await screen.findByRole("heading", { name: "运营配置中心" })).toBeTruthy();
|
expect(await screen.findByRole("heading", { name: "运营配置中心" })).toBeTruthy();
|
||||||
expect(screen.getByRole("navigation", { name: "运营中心导航" })).toBeTruthy();
|
expect(screen.getByRole("navigation", { name: "运营中心导航" })).toBeTruthy();
|
||||||
["数据总览", "幸运礼物配置", "抽奖记录", "应用列表"].forEach((label) => {
|
["数据总览", "幸运礼物配置", "抽奖记录", "应用列表"].forEach((label) => {
|
||||||
expect(screen.getByRole("button", { name: new RegExp(label) })).toBeTruthy();
|
expect(screen.getByRole("button", { name: new RegExp(label) })).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(await screen.findByText("运营应用")).toBeTruthy();
|
expect(await screen.findByText("运营应用")).toBeTruthy();
|
||||||
// 抽奖次数 KPI 必须是跨应用聚合值,而不是第一个应用的汇总(KPI 卡和汇总表列头各出现一次)。
|
// 抽奖次数 KPI 必须是跨应用聚合值,而不是第一个应用的汇总(KPI 卡和汇总表列头各出现一次)。
|
||||||
expect(screen.getAllByText("抽奖次数").length).toBeGreaterThan(0);
|
expect(screen.getAllByText("抽奖次数").length).toBeGreaterThan(0);
|
||||||
expect(screen.getAllByText("38,941,517").length).toBeGreaterThan(0);
|
expect(screen.getAllByText("38,941,517").length).toBeGreaterThan(0);
|
||||||
expect(screen.getByText("奖池实时水位")).toBeTruthy();
|
expect(screen.getByText("奖池实时水位")).toBeTruthy();
|
||||||
expect(screen.getByText("已入账")).toBeTruthy();
|
expect(screen.getByText("已入账")).toBeTruthy();
|
||||||
expect(screen.getByText("默认水位")).toBeTruthy();
|
expect(screen.getByText("默认水位")).toBeTruthy();
|
||||||
expect(fetchOpsDashboard).toHaveBeenCalledTimes(1);
|
expect(fetchOpsDashboard).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("opens the requested config view from the legacy admin document redirect", async () => {
|
||||||
|
expect(initialOpsCenterView("?view=configs")).toBe("configs");
|
||||||
|
expect(initialOpsCenterView("?view=unknown")).toBe("overview");
|
||||||
|
window.history.replaceState({}, "", "/ops-center/?view=configs");
|
||||||
|
|
||||||
|
renderApp();
|
||||||
|
|
||||||
|
expect(await screen.findByRole("button", { name: "添加配置" })).toBeTruthy();
|
||||||
|
expect(screen.getByText("lucky")).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("lists every pool config including published non-default pools", async () => {
|
test("lists every pool config including published non-default pools", async () => {
|
||||||
renderApp();
|
renderApp();
|
||||||
await screen.findByText("运营应用");
|
await screen.findByText("运营应用");
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: /幸运礼物配置/ }));
|
fireEvent.click(screen.getByRole("button", { name: /幸运礼物配置/ }));
|
||||||
|
|
||||||
// 核心回归:lalu 的 lucky / super_lucky 已发布奖池必须出现在配置列表里。
|
// 核心回归:lalu 的 lucky / super_lucky 已发布奖池必须出现在配置列表里。
|
||||||
expect(await screen.findByText("lucky")).toBeTruthy();
|
expect(await screen.findByText("lucky")).toBeTruthy();
|
||||||
expect(screen.getByText("super_lucky")).toBeTruthy();
|
expect(screen.getByText("super_lucky")).toBeTruthy();
|
||||||
expect(screen.getByText("v3")).toBeTruthy();
|
expect(screen.getByText("v3")).toBeTruthy();
|
||||||
expect(screen.getByText("v2")).toBeTruthy();
|
expect(screen.getByText("v2")).toBeTruthy();
|
||||||
expect(screen.getByText("已启用")).toBeTruthy();
|
expect(screen.getByText("已启用")).toBeTruthy();
|
||||||
expect(screen.getByText("已停用")).toBeTruthy();
|
expect(screen.getByText("已停用")).toBeTruthy();
|
||||||
expect(screen.getByText("默认草稿")).toBeTruthy();
|
expect(screen.getByText("默认草稿")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("creates a disabled dynamic draft without copying another app monetary limits", async () => {
|
||||||
|
renderApp();
|
||||||
|
await screen.findByText("运营应用");
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /幸运礼物配置/ }));
|
||||||
|
fireEvent.click(await screen.findByRole("button", { name: "添加配置" }));
|
||||||
|
|
||||||
|
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");
|
||||||
|
// 第一条 lalu 已发布配置的单次上限为 100 万;新建跨 App 配置必须归零并保持停用。
|
||||||
|
expect(within(dialog).getByLabelText(/单次返奖上限/).value).toBe("0");
|
||||||
|
expect(within(dialog).getByText("发布后保持停用")).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("opens config dialog from a published pool row and saves a new version", async () => {
|
test("opens config dialog from a published pool row and saves a new version", async () => {
|
||||||
renderApp();
|
renderApp();
|
||||||
await screen.findByText("运营应用");
|
await screen.findByText("运营应用");
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: /幸运礼物配置/ }));
|
fireEvent.click(screen.getByRole("button", { name: /幸运礼物配置/ }));
|
||||||
await screen.findByText("lucky");
|
await screen.findByText("lucky");
|
||||||
|
|
||||||
fireEvent.click(screen.getAllByRole("button", { name: "新增版本" })[0]);
|
fireEvent.click(screen.getAllByRole("button", { name: "新增版本" })[0]);
|
||||||
const dialog = await screen.findByRole("dialog");
|
const dialog = await screen.findByRole("dialog");
|
||||||
expect(within(dialog).getByText("新增幸运礼物版本")).toBeTruthy();
|
expect(within(dialog).getByText("新增幸运礼物版本")).toBeTruthy();
|
||||||
expect(within(dialog).getAllByLabelText("概率 %")[0].readOnly).toBe(true);
|
expect(within(dialog).getByLabelText("策略版本").textContent).toContain("dynamic_v3");
|
||||||
|
expect(within(dialog).getByLabelText(/初始奖池金币/).value).toBe("1000000");
|
||||||
|
expect(within(dialog).getByLabelText(/大奖倍率/).value).toBe("200, 500, 1000");
|
||||||
|
expect(within(dialog).getAllByLabelText("概率 %")[0].readOnly).toBe(true);
|
||||||
|
|
||||||
fireEvent.change(within(dialog).getByLabelText(/目标 RTP \(%\)/), { target: { value: "88" } });
|
fireEvent.change(within(dialog).getByLabelText(/目标 RTP \(%\)/), { target: { value: "88" } });
|
||||||
fireEvent.click(within(dialog).getByRole("button", { name: "保存配置" }));
|
fireEvent.click(within(dialog).getByRole("button", { name: "保存配置" }));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(saveLuckyGiftConfig).toHaveBeenCalledWith(
|
expect(saveLuckyGiftConfig).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
app_code: "lalu",
|
app_code: "lalu",
|
||||||
pool_id: "lucky",
|
anchor_rate_percent: 1,
|
||||||
stages: expect.arrayContaining([expect.objectContaining({ stage: "novice" })]),
|
jackpot_multipliers: [200, 500, 1000],
|
||||||
target_rtp_percent: 88
|
pool_id: "lucky",
|
||||||
})
|
profit_rate_percent: 1,
|
||||||
);
|
stages: expect.arrayContaining([expect.objectContaining({ stage: "novice" })]),
|
||||||
});
|
strategy_version: "dynamic_v3",
|
||||||
|
target_rtp_percent: 88,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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]);
|
||||||
|
|
||||||
|
const dialog = await screen.findByRole("dialog");
|
||||||
|
fireEvent.change(within(dialog).getByLabelText(/初始奖池金币/), { target: { value: "0" } });
|
||||||
|
fireEvent.click(within(dialog).getByRole("button", { name: "保存配置" }));
|
||||||
|
|
||||||
|
expect(await within(dialog).findByRole("alert")).toHaveTextContent("初始奖池必须大于 0");
|
||||||
|
expect(saveLuckyGiftConfig).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// 奖档编辑同样必须清掉上一次提交错误,避免用户修正后仍看到已经过期的阻断原因。
|
||||||
|
fireEvent.change(within(dialog).getAllByLabelText("倍率")[0], { target: { value: "0.1" } });
|
||||||
|
expect(within(dialog).queryByRole("alert")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
function mockDynamicConfig() {
|
||||||
|
return {
|
||||||
|
anchor_daily_payout_cap: 6_000_000,
|
||||||
|
anchor_rate_percent: 1,
|
||||||
|
control_band_percent: 3,
|
||||||
|
device_daily_payout_cap: 4_000_000,
|
||||||
|
effective_from_ms: 0,
|
||||||
|
gift_price_reference: 100,
|
||||||
|
high_water_nonzero_factor_percent: 130,
|
||||||
|
high_watermark_coins: 20_000_000,
|
||||||
|
initial_pool_coins: 1_000_000,
|
||||||
|
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_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,
|
||||||
|
settlement_window_wager: 1_000_000,
|
||||||
|
stages: mockStages(98),
|
||||||
|
strategy_version: "dynamic_v3",
|
||||||
|
target_rtp_percent: 98,
|
||||||
|
user_daily_payout_cap: 3_000_000,
|
||||||
|
user_hourly_payout_cap: 2_000_000,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockFixedConfig(targetRTP) {
|
||||||
|
return {
|
||||||
|
control_band_percent: 1,
|
||||||
|
gift_price_reference: 100,
|
||||||
|
normal_max_equivalent_draws: 20_000,
|
||||||
|
novice_max_equivalent_draws: 2_000,
|
||||||
|
pool_rate_percent: targetRTP + 1,
|
||||||
|
settlement_window_wager: 1_000_000,
|
||||||
|
stages: mockStages(targetRTP),
|
||||||
|
strategy_version: "fixed_v2",
|
||||||
|
target_rtp_percent: targetRTP,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockStages(targetRTP) {
|
||||||
|
return ["novice", "normal", "advanced"].map((stage, index) => ({
|
||||||
|
min_recharge_30d_coins: index === 0 ? 0 : index,
|
||||||
|
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`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
test("loads draw records for the first app with server pagination", async () => {
|
test("loads draw records for the first app with server pagination", async () => {
|
||||||
renderApp();
|
renderApp();
|
||||||
await screen.findByText("运营应用");
|
await screen.findByText("运营应用");
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: /抽奖记录/ }));
|
fireEvent.click(screen.getByRole("button", { name: /抽奖记录/ }));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(fetchLuckyGiftDraws).toHaveBeenCalledWith(
|
expect(fetchLuckyGiftDraws).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ appCode: "lalu", page: 1, poolId: "", status: "" })
|
expect.objectContaining({ appCode: "lalu", page: 1, poolId: "", status: "" }),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
expect(await screen.findByText("ext-1")).toBeTruthy();
|
expect(await screen.findByText("ext-1")).toBeTruthy();
|
||||||
expect(screen.getByText("已发放")).toBeTruthy();
|
expect(screen.getByText("已发放")).toBeTruthy();
|
||||||
expect(screen.getByText("2x")).toBeTruthy();
|
expect(screen.getByText("2x")).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("shows app registry and fixed integrations in apps view", async () => {
|
test("shows app registry and fixed integrations in apps view", async () => {
|
||||||
renderApp();
|
renderApp();
|
||||||
await screen.findByText("运营应用");
|
await screen.findByText("运营应用");
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: /应用列表/ }));
|
fireEvent.click(screen.getByRole("button", { name: /应用列表/ }));
|
||||||
|
|
||||||
expect(await screen.findByText("Lalu")).toBeTruthy();
|
expect(await screen.findByText("Lalu")).toBeTruthy();
|
||||||
expect(screen.getByText("主数据")).toBeTruthy();
|
expect(screen.getByText("主数据")).toBeTruthy();
|
||||||
expect(screen.getByText("外部接入")).toBeTruthy();
|
expect(screen.getByText("外部接入")).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|||||||
@ -4,261 +4,344 @@ export const OPS_API_PREFIX = "/api/v1/admin/ops-center";
|
|||||||
export const DEFAULT_POOL_ID = "default";
|
export const DEFAULT_POOL_ID = "default";
|
||||||
|
|
||||||
export async function fetchOpsDashboard(query = {}) {
|
export async function fetchOpsDashboard(query = {}) {
|
||||||
// Ops Center 是跨 App 的运营面板:先拉应用主数据,再按 app_code 扇出拉取奖池配置、余额和抽奖汇总。
|
// Ops Center 是跨 App 的运营面板:先拉应用主数据,再按 app_code 扇出拉取奖池配置、余额和抽奖汇总。
|
||||||
// lucky-gift-service 的所有后台接口都以 app_code 作为租户维度过滤,没有"全部应用"一次拉取的入口。
|
// lucky-gift-service 的所有后台接口都以 app_code 作为租户维度过滤,没有"全部应用"一次拉取的入口。
|
||||||
const appsPayload = await opsRequest("/apps", { query });
|
const appsPayload = await opsRequest("/apps", { query });
|
||||||
const apps = normalizeItems(appsPayload);
|
const apps = normalizeItems(appsPayload);
|
||||||
const appCodes = uniqueAppCodes(apps);
|
const appCodes = uniqueAppCodes(apps);
|
||||||
|
|
||||||
const perApp = await Promise.all(
|
const perApp = await Promise.all(
|
||||||
appCodes.map(async (appCode) => {
|
appCodes.map(async (appCode) => {
|
||||||
const [luckyGifts, poolBalances, drawSummary] = await Promise.all([
|
const [luckyGifts, poolBalances, drawSummary] = await Promise.all([
|
||||||
fetchAppLuckyGiftConfigs(appCode),
|
fetchAppLuckyGiftConfigs(appCode),
|
||||||
opsRequest("/lucky-gifts/pools", { query: { app_code: appCode } }).then(normalizeItems),
|
opsRequest("/lucky-gifts/pools", { query: { app_code: appCode } }).then(normalizeItems),
|
||||||
opsRequest("/lucky-gifts/summary", { query: { app_code: appCode } })
|
opsRequest("/lucky-gifts/summary", { query: { app_code: appCode } }),
|
||||||
]);
|
]);
|
||||||
return { appCode, drawSummary, luckyGifts, poolBalances };
|
return { appCode, drawSummary, luckyGifts, poolBalances };
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
return normalizeDashboard({ apps, perApp });
|
return normalizeDashboard({ apps, perApp });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchAppLuckyGiftConfigs(appCode) {
|
async function fetchAppLuckyGiftConfigs(appCode) {
|
||||||
// 必须用复数 /configs:它返回该应用每个奖池的最新已发布规则版本。
|
// 必须用复数 /configs:它返回该应用每个奖池的最新已发布规则版本。
|
||||||
// 单数 /config 只回默认奖池(无配置时是服务端草稿),会把 lalu 的 lucky/super_lucky 等已发布奖池整体漏掉。
|
// 单数 /config 只回默认奖池(无配置时是服务端草稿),会把 lalu 的 lucky/super_lucky 等已发布奖池整体漏掉。
|
||||||
const published = normalizeItems(await opsRequest("/lucky-gifts/configs", { query: { app_code: appCode } }))
|
const published = normalizeItems(await opsRequest("/lucky-gifts/configs", { query: { app_code: appCode } }))
|
||||||
.map((config) => normalizeLuckyGiftConfig(config, appCode))
|
.map((config) => normalizeLuckyGiftConfig(config, appCode))
|
||||||
.filter((config) => config.app_code);
|
.filter((config) => config.app_code);
|
||||||
if (published.length) {
|
if (published.length) {
|
||||||
return published;
|
return published;
|
||||||
}
|
}
|
||||||
// 一个已发布配置都没有的应用仍要展示一行可编辑草稿,让运营从这里发布第一版;
|
// 一个已发布配置都没有的应用仍要展示一行可编辑草稿,让运营从这里发布第一版;
|
||||||
// 草稿的默认参数(RTP、回收率、结算窗口)以服务端 DefaultRuleConfig 为准,不在前端伪造。
|
// 草稿的默认参数(RTP、回收率、结算窗口)以服务端 DefaultRuleConfig 为准,不在前端伪造。
|
||||||
const draft = normalizeLuckyGiftConfig(await opsRequest("/lucky-gifts/config", { query: { app_code: appCode } }), appCode);
|
const draft = normalizeLuckyGiftConfig(
|
||||||
return draft.app_code ? [draft] : [];
|
await opsRequest("/lucky-gifts/config", { query: { app_code: appCode } }),
|
||||||
|
appCode,
|
||||||
|
);
|
||||||
|
return draft.app_code ? [draft] : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchLuckyGiftDraws({ appCode, page = 1, pageSize = 20, poolId = "", status = "", userId = "" } = {}) {
|
export async function fetchLuckyGiftDraws({
|
||||||
const query = {
|
appCode,
|
||||||
app_code: appCode,
|
page = 1,
|
||||||
page,
|
pageSize = 20,
|
||||||
page_size: pageSize,
|
poolId = "",
|
||||||
pool_id: poolId,
|
status = "",
|
||||||
status
|
userId = "",
|
||||||
};
|
} = {}) {
|
||||||
// 同一个输入框同时支持内部数字 UID 和外部接入方的字符串 ID:纯数字走 user_id 精确匹配,
|
const query = {
|
||||||
// 其余走 external_user_id,避免运营要先分辨用户来源再选字段。
|
app_code: appCode,
|
||||||
const trimmedUserID = String(userId || "").trim();
|
page,
|
||||||
if (/^\d+$/.test(trimmedUserID)) {
|
page_size: pageSize,
|
||||||
query.user_id = trimmedUserID;
|
pool_id: poolId,
|
||||||
} else if (trimmedUserID) {
|
status,
|
||||||
query.external_user_id = trimmedUserID;
|
};
|
||||||
}
|
// 同一个输入框同时支持内部数字 UID 和外部接入方的字符串 ID:纯数字走 user_id 精确匹配,
|
||||||
|
// 其余走 external_user_id,避免运营要先分辨用户来源再选字段。
|
||||||
|
const trimmedUserID = String(userId || "").trim();
|
||||||
|
if (/^\d+$/.test(trimmedUserID)) {
|
||||||
|
query.user_id = trimmedUserID;
|
||||||
|
} else if (trimmedUserID) {
|
||||||
|
query.external_user_id = trimmedUserID;
|
||||||
|
}
|
||||||
|
|
||||||
const payload = await opsRequest("/lucky-gifts/draws", { query });
|
const payload = await opsRequest("/lucky-gifts/draws", { query });
|
||||||
return {
|
return {
|
||||||
items: normalizeItems(payload),
|
items: normalizeItems(payload),
|
||||||
page: numberValue(payload.page) || page,
|
page: numberValue(payload.page) || page,
|
||||||
pageSize: numberValue(payload.pageSize ?? payload.page_size) || pageSize,
|
pageSize: numberValue(payload.pageSize ?? payload.page_size) || pageSize,
|
||||||
total: numberValue(payload.total)
|
total: numberValue(payload.total),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveLuckyGiftConfig(config = {}) {
|
export async function saveLuckyGiftConfig(config = {}) {
|
||||||
const payload = luckyGiftConfigPayload(config);
|
const payload = luckyGiftConfigPayload(config);
|
||||||
return opsRequest("/lucky-gifts/config", {
|
return opsRequest("/lucky-gifts/config", {
|
||||||
body: payload,
|
body: payload,
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
query: {
|
query: {
|
||||||
app_code: payload.app_code,
|
app_code: payload.app_code,
|
||||||
pool_id: payload.pool_id
|
pool_id: payload.pool_id,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function opsRequest(path, { body, method = body ? "POST" : "GET", query } = {}) {
|
export async function opsRequest(path, { body, method = body ? "POST" : "GET", query } = {}) {
|
||||||
const response = await fetch(buildOpsUrl(path, query), {
|
const response = await fetch(buildOpsUrl(path, query), {
|
||||||
body: body === undefined ? undefined : JSON.stringify(body),
|
body: body === undefined ? undefined : JSON.stringify(body),
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
headers: buildHeaders(body),
|
headers: buildHeaders(body),
|
||||||
method
|
method,
|
||||||
});
|
});
|
||||||
const payload = await readPayload(response);
|
const payload = await readPayload(response);
|
||||||
|
|
||||||
if (!response.ok || payload.code !== 0) {
|
if (!response.ok || payload.code !== 0) {
|
||||||
throw new Error(payload.message || response.statusText || "运营接口请求失败");
|
throw new Error(payload.message || response.statusText || "运营接口请求失败");
|
||||||
}
|
}
|
||||||
|
|
||||||
return payload.data ?? {};
|
return payload.data ?? {};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildOpsUrl(path, query = {}) {
|
export function buildOpsUrl(path, query = {}) {
|
||||||
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
|
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
|
||||||
const url = new URL(`${OPS_API_PREFIX}${normalizedPath}`, window.location.origin);
|
const url = new URL(`${OPS_API_PREFIX}${normalizedPath}`, window.location.origin);
|
||||||
|
|
||||||
Object.entries(query || {}).forEach(([key, value]) => {
|
Object.entries(query || {}).forEach(([key, value]) => {
|
||||||
if (value !== undefined && value !== null && value !== "") {
|
if (value !== undefined && value !== null && value !== "") {
|
||||||
url.searchParams.set(key, String(value));
|
url.searchParams.set(key, String(value));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return url.toString();
|
return url.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeDashboard({ apps = [], perApp = [] } = {}) {
|
export function normalizeDashboard({ apps = [], perApp = [] } = {}) {
|
||||||
const luckyGifts = perApp.flatMap((entry) => entry.luckyGifts || []);
|
const luckyGifts = perApp.flatMap((entry) => entry.luckyGifts || []);
|
||||||
const poolBalances = perApp
|
const poolBalances = perApp
|
||||||
.flatMap((entry) => entry.poolBalances || [])
|
.flatMap((entry) => entry.poolBalances || [])
|
||||||
.filter((item) => item?.app_code || item?.appCode);
|
.filter((item) => item?.app_code || item?.appCode);
|
||||||
const appSummaries = perApp
|
const appSummaries = perApp
|
||||||
.map((entry) => normalizeDrawSummary(entry.drawSummary, entry.appCode))
|
.map((entry) => normalizeDrawSummary(entry.drawSummary, entry.appCode))
|
||||||
.filter((summary) => summary.app_code);
|
.filter((summary) => summary.app_code);
|
||||||
|
|
||||||
const publishedConfigs = luckyGifts.filter((config) => !config.is_default);
|
const publishedConfigs = luckyGifts.filter((config) => !config.is_default);
|
||||||
|
|
||||||
// KPI 一律跨应用聚合。旧版只取第一个应用的汇总,导致 lalu 有大量抽奖时首屏"抽奖次数"仍显示 0。
|
// KPI 一律跨应用聚合。旧版只取第一个应用的汇总,导致 lalu 有大量抽奖时首屏"抽奖次数"仍显示 0。
|
||||||
const summary = {
|
const summary = {
|
||||||
activeApps: apps.length,
|
activeApps: apps.length,
|
||||||
configuredPools: publishedConfigs.length,
|
configuredPools: publishedConfigs.length,
|
||||||
enabledPools: publishedConfigs.filter((config) => config.enabled).length,
|
enabledPools: publishedConfigs.filter((config) => config.enabled).length,
|
||||||
failedDraws: sumBy(appSummaries, "failed_draws"),
|
failedDraws: sumBy(appSummaries, "failed_draws"),
|
||||||
grantedDraws: sumBy(appSummaries, "granted_draws"),
|
grantedDraws: sumBy(appSummaries, "granted_draws"),
|
||||||
pendingDraws: sumBy(appSummaries, "pending_draws"),
|
pendingDraws: sumBy(appSummaries, "pending_draws"),
|
||||||
poolAvailableTotal: sumBy(poolBalances, "available_balance", "availableBalance"),
|
poolAvailableTotal: sumBy(poolBalances, "available_balance", "availableBalance"),
|
||||||
poolBalanceTotal: sumBy(poolBalances, "balance", "Balance"),
|
poolBalanceTotal: sumBy(poolBalances, "balance", "Balance"),
|
||||||
poolReserveTotal: sumBy(poolBalances, "reserve_floor", "reserveFloor"),
|
poolReserveTotal: sumBy(poolBalances, "reserve_floor", "reserveFloor"),
|
||||||
totalDraws: sumBy(appSummaries, "total_draws"),
|
totalDraws: sumBy(appSummaries, "total_draws"),
|
||||||
totalRewardCoins: sumBy(appSummaries, "total_reward_coins"),
|
totalRewardCoins: sumBy(appSummaries, "total_reward_coins"),
|
||||||
totalSpentCoins: sumBy(appSummaries, "total_spent_coins")
|
totalSpentCoins: sumBy(appSummaries, "total_spent_coins"),
|
||||||
};
|
};
|
||||||
|
|
||||||
return { apps, appSummaries, luckyGifts, poolBalances, summary };
|
return { apps, appSummaries, luckyGifts, poolBalances, summary };
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeDrawSummary(summary = {}, appCode = "") {
|
function normalizeDrawSummary(summary = {}, appCode = "") {
|
||||||
return {
|
return {
|
||||||
actual_rtp_ppm: numberValue(summary.actual_rtp_ppm ?? summary.actualRtpPpm),
|
actual_rtp_ppm: numberValue(summary.actual_rtp_ppm ?? summary.actualRtpPpm),
|
||||||
app_code: String(appCode || summary.app_code || "").trim().toLowerCase(),
|
app_code: String(appCode || summary.app_code || "")
|
||||||
failed_draws: numberValue(summary.failed_draws ?? summary.failedDraws),
|
.trim()
|
||||||
granted_draws: numberValue(summary.granted_draws ?? summary.grantedDraws),
|
.toLowerCase(),
|
||||||
pending_draws: numberValue(summary.pending_draws ?? summary.pendingDraws),
|
failed_draws: numberValue(summary.failed_draws ?? summary.failedDraws),
|
||||||
total_draws: numberValue(summary.total_draws ?? summary.totalDraws),
|
granted_draws: numberValue(summary.granted_draws ?? summary.grantedDraws),
|
||||||
total_reward_coins: numberValue(summary.total_reward_coins ?? summary.totalRewardCoins),
|
pending_draws: numberValue(summary.pending_draws ?? summary.pendingDraws),
|
||||||
total_spent_coins: numberValue(summary.total_spent_coins ?? summary.totalSpentCoins),
|
total_draws: numberValue(summary.total_draws ?? summary.totalDraws),
|
||||||
unique_rooms: numberValue(summary.unique_rooms ?? summary.uniqueRooms),
|
total_reward_coins: numberValue(summary.total_reward_coins ?? summary.totalRewardCoins),
|
||||||
unique_users: numberValue(summary.unique_users ?? summary.uniqueUsers)
|
total_spent_coins: numberValue(summary.total_spent_coins ?? summary.totalSpentCoins),
|
||||||
};
|
unique_rooms: numberValue(summary.unique_rooms ?? summary.uniqueRooms),
|
||||||
|
unique_users: numberValue(summary.unique_users ?? summary.uniqueUsers),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function uniqueAppCodes(apps) {
|
function uniqueAppCodes(apps) {
|
||||||
const seen = new Set();
|
const seen = new Set();
|
||||||
const codes = [];
|
const codes = [];
|
||||||
apps.forEach((item) => {
|
apps.forEach((item) => {
|
||||||
const code = String(item?.app_code || item?.appCode || "").trim().toLowerCase();
|
const code = String(item?.app_code || item?.appCode || "")
|
||||||
if (!code || seen.has(code)) {
|
.trim()
|
||||||
return;
|
.toLowerCase();
|
||||||
}
|
if (!code || seen.has(code)) {
|
||||||
seen.add(code);
|
return;
|
||||||
codes.push(code);
|
}
|
||||||
});
|
seen.add(code);
|
||||||
return codes;
|
codes.push(code);
|
||||||
|
});
|
||||||
|
return codes;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeLuckyGiftConfig(config = {}, fallbackAppCode = "") {
|
function normalizeLuckyGiftConfig(config = {}, fallbackAppCode = "") {
|
||||||
const appCode = String(config.app_code || config.appCode || fallbackAppCode).trim().toLowerCase();
|
const appCode = String(config.app_code || config.appCode || fallbackAppCode)
|
||||||
const ruleVersion = numberValue(config.rule_version ?? config.ruleVersion);
|
.trim()
|
||||||
return {
|
.toLowerCase();
|
||||||
...config,
|
const ruleVersion = numberValue(config.rule_version ?? config.ruleVersion);
|
||||||
app_code: appCode,
|
return {
|
||||||
// rule_version <= 0 表示服务端返回的未发布草稿:列表要标成"默认草稿"并禁止当作已生效规则解读。
|
...config,
|
||||||
is_default: ruleVersion <= 0,
|
app_code: appCode,
|
||||||
pool_id: config.pool_id || config.poolId || DEFAULT_POOL_ID,
|
// rule_version <= 0 表示服务端返回的未发布草稿:列表要标成"默认草稿"并禁止当作已生效规则解读。
|
||||||
rule_version: ruleVersion
|
is_default: ruleVersion <= 0,
|
||||||
};
|
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(),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function luckyGiftConfigPayload(config = {}) {
|
function luckyGiftConfigPayload(config = {}) {
|
||||||
const appCode = String(config.app_code || config.appCode || "").trim().toLowerCase();
|
const appCode = String(config.app_code || config.appCode || "")
|
||||||
const poolID = String(config.pool_id || config.poolId || DEFAULT_POOL_ID).trim() || DEFAULT_POOL_ID;
|
.trim()
|
||||||
return {
|
.toLowerCase();
|
||||||
app_code: appCode,
|
const poolID = String(config.pool_id || config.poolId || DEFAULT_POOL_ID).trim() || DEFAULT_POOL_ID;
|
||||||
control_band_percent: numberValue(config.control_band_percent ?? config.controlBandPercent),
|
const strategyVersion = String(config.strategy_version || config.strategyVersion || "fixed_v2")
|
||||||
effective_from_ms: numberValue(config.effective_from_ms ?? config.effectiveFromMS),
|
.trim()
|
||||||
enabled: Boolean(config.enabled),
|
.toLowerCase();
|
||||||
gift_price_reference: numberValue(config.gift_price_reference ?? config.giftPriceReference),
|
|
||||||
normal_max_equivalent_draws: numberValue(config.normal_max_equivalent_draws ?? config.normalMaxEquivalentDraws),
|
// 规则版本是不可变快照,PUT 必须完整回传 owner GET 返回的策略字段。只提交旧版 RTP/奖档字段会让
|
||||||
novice_max_equivalent_draws: numberValue(config.novice_max_equivalent_draws ?? config.noviceMaxEquivalentDraws),
|
// 空 strategy_version 被服务端按 fixed_v2 发布,或让 dynamic_v3 因资金、水位、大奖字段归零而拒绝发布。
|
||||||
pool_id: poolID,
|
return {
|
||||||
pool_rate_percent: numberValue(config.pool_rate_percent ?? config.poolRatePercent),
|
anchor_daily_payout_cap: numberValue(config.anchor_daily_payout_cap ?? config.anchorDailyPayoutCap),
|
||||||
settlement_window_wager: numberValue(config.settlement_window_wager ?? config.settlementWindowWager),
|
anchor_rate_percent: numberValue(config.anchor_rate_percent ?? config.anchorRatePercent),
|
||||||
stages: normalizeStages(config.stages),
|
app_code: appCode,
|
||||||
target_rtp_percent: numberValue(config.target_rtp_percent ?? config.targetRtpPercent)
|
control_band_percent: numberValue(config.control_band_percent ?? config.controlBandPercent),
|
||||||
};
|
device_daily_payout_cap: numberValue(config.device_daily_payout_cap ?? config.deviceDailyPayoutCap),
|
||||||
|
effective_from_ms: numberValue(config.effective_from_ms ?? config.effectiveFromMs ?? config.effectiveFromMS),
|
||||||
|
enabled: Boolean(config.enabled),
|
||||||
|
gift_price_reference: numberValue(config.gift_price_reference ?? config.giftPriceReference),
|
||||||
|
high_water_nonzero_factor_percent: numberValue(
|
||||||
|
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),
|
||||||
|
jackpot_global_rtp_max_percent: numberValue(
|
||||||
|
config.jackpot_global_rtp_max_percent ?? config.jackpotGlobalRTPMaxPercent,
|
||||||
|
),
|
||||||
|
jackpot_multipliers: numberArray(config.jackpot_multipliers ?? config.jackpotMultipliers),
|
||||||
|
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_day_rtp_max_percent: numberValue(
|
||||||
|
config.jackpot_user_day_rtp_max_percent ?? config.jackpotUserDayRTPMaxPercent,
|
||||||
|
),
|
||||||
|
loss_streak_guarantee: numberValue(config.loss_streak_guarantee ?? config.lossStreakGuarantee),
|
||||||
|
low_water_nonzero_factor_percent: numberValue(
|
||||||
|
config.low_water_nonzero_factor_percent ?? config.lowWaterNonzeroFactorPercent,
|
||||||
|
),
|
||||||
|
low_watermark_coins: numberValue(config.low_watermark_coins ?? config.lowWatermarkCoins),
|
||||||
|
max_jackpot_hits_per_user_day: numberValue(
|
||||||
|
config.max_jackpot_hits_per_user_day ?? config.maxJackpotHitsPerUserDay,
|
||||||
|
),
|
||||||
|
max_single_payout: numberValue(config.max_single_payout ?? config.maxSinglePayout),
|
||||||
|
normal_max_equivalent_draws: numberValue(config.normal_max_equivalent_draws ?? config.normalMaxEquivalentDraws),
|
||||||
|
novice_max_equivalent_draws: numberValue(config.novice_max_equivalent_draws ?? config.noviceMaxEquivalentDraws),
|
||||||
|
pool_id: poolID,
|
||||||
|
pool_rate_percent: numberValue(config.pool_rate_percent ?? config.poolRatePercent),
|
||||||
|
profit_rate_percent: numberValue(config.profit_rate_percent ?? config.profitRatePercent),
|
||||||
|
recharge_boost_factor_percent: numberValue(
|
||||||
|
config.recharge_boost_factor_percent ?? config.rechargeBoostFactorPercent,
|
||||||
|
),
|
||||||
|
recharge_boost_window_ms: numberValue(
|
||||||
|
config.recharge_boost_window_ms ?? config.rechargeBoostWindowMs ?? config.rechargeBoostWindowMS,
|
||||||
|
),
|
||||||
|
room_hourly_payout_cap: numberValue(config.room_hourly_payout_cap ?? config.roomHourlyPayoutCap),
|
||||||
|
settlement_window_wager: numberValue(config.settlement_window_wager ?? config.settlementWindowWager),
|
||||||
|
stages: normalizeStages(config.stages),
|
||||||
|
strategy_version: strategyVersion,
|
||||||
|
user_daily_payout_cap: numberValue(config.user_daily_payout_cap ?? config.userDailyPayoutCap),
|
||||||
|
user_hourly_payout_cap: numberValue(config.user_hourly_payout_cap ?? config.userHourlyPayoutCap),
|
||||||
|
target_rtp_percent: numberValue(config.target_rtp_percent ?? config.targetRtpPercent),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildHeaders(body) {
|
function buildHeaders(body) {
|
||||||
const headers = {};
|
const headers = {};
|
||||||
const token = getAccessToken();
|
const token = getAccessToken();
|
||||||
|
|
||||||
if (body !== undefined) {
|
if (body !== undefined) {
|
||||||
headers["Content-Type"] = "application/json";
|
headers["Content-Type"] = "application/json";
|
||||||
}
|
}
|
||||||
if (token) {
|
if (token) {
|
||||||
headers.Authorization = `Bearer ${token}`;
|
headers.Authorization = `Bearer ${token}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return headers;
|
return headers;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function readPayload(response) {
|
async function readPayload(response) {
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
if (!text) {
|
if (!text) {
|
||||||
return { code: response.ok ? 0 : response.status, data: {} };
|
return { code: response.ok ? 0 : response.status, data: {} };
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
return JSON.parse(text);
|
return JSON.parse(text);
|
||||||
} catch {
|
} catch {
|
||||||
return { code: response.ok ? 0 : response.status, data: text, message: text };
|
return { code: response.ok ? 0 : response.status, data: text, message: text };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeItems(value) {
|
function normalizeItems(value) {
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
if (Array.isArray(value?.items)) {
|
if (Array.isArray(value?.items)) {
|
||||||
return value.items;
|
return value.items;
|
||||||
}
|
}
|
||||||
if (Array.isArray(value?.list)) {
|
if (Array.isArray(value?.list)) {
|
||||||
return value.list;
|
return value.list;
|
||||||
}
|
}
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
function sumBy(items, key, fallbackKey) {
|
function sumBy(items, key, fallbackKey) {
|
||||||
return items.reduce((total, item) => total + numberValue(item?.[key] ?? (fallbackKey ? item?.[fallbackKey] : 0)), 0);
|
return items.reduce(
|
||||||
|
(total, item) => total + numberValue(item?.[key] ?? (fallbackKey ? item?.[fallbackKey] : 0)),
|
||||||
|
0,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function numberValue(value) {
|
function numberValue(value) {
|
||||||
const parsed = Number(value);
|
const parsed = Number(value);
|
||||||
return Number.isFinite(parsed) ? parsed : 0;
|
return Number.isFinite(parsed) ? parsed : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function numberArray(values) {
|
||||||
|
if (!Array.isArray(values)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
// 不在传输层静默删除 0/负倍率;表单负责拦截,若有遗漏也应让 owner 返回明确校验错误,
|
||||||
|
// 否则数组位置变化会掩盖运营实际提交的错误值。
|
||||||
|
return values.map(numberValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeStages(stages) {
|
function normalizeStages(stages) {
|
||||||
if (!Array.isArray(stages)) {
|
if (!Array.isArray(stages)) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
return stages.map((stage) => ({
|
return stages.map((stage) => ({
|
||||||
stage: stage.stage || stage.Stage || "",
|
min_recharge_30d_coins: numberValue(stage.min_recharge_30d_coins ?? stage.minRecharge30DCoins),
|
||||||
tiers: Array.isArray(stage.tiers)
|
min_recharge_7d_coins: numberValue(stage.min_recharge_7d_coins ?? stage.minRecharge7DCoins),
|
||||||
? stage.tiers.map((tier) => ({
|
stage: stage.stage || stage.Stage || "",
|
||||||
enabled: tier.enabled !== false,
|
tiers: Array.isArray(stage.tiers)
|
||||||
high_water_only: Boolean(tier.high_water_only ?? tier.highWaterOnly),
|
? stage.tiers.map((tier) => ({
|
||||||
multiplier: numberValue(tier.multiplier),
|
enabled: tier.enabled !== false,
|
||||||
probability_percent: numberValue(tier.probability_percent ?? tier.probabilityPercent),
|
high_water_only: Boolean(tier.high_water_only ?? tier.highWaterOnly),
|
||||||
tier_id: tier.tier_id || tier.tierId || ""
|
multiplier: numberValue(tier.multiplier),
|
||||||
}))
|
probability_percent: numberValue(tier.probability_percent ?? tier.probabilityPercent),
|
||||||
: []
|
tier_id: tier.tier_id || tier.tierId || "",
|
||||||
}));
|
}))
|
||||||
|
: [],
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,145 +2,251 @@ import { afterEach, expect, test, vi } from "vitest";
|
|||||||
import { buildOpsUrl, fetchLuckyGiftDraws, fetchOpsDashboard, OPS_API_PREFIX, saveLuckyGiftConfig } from "./api.js";
|
import { buildOpsUrl, fetchLuckyGiftDraws, fetchOpsDashboard, OPS_API_PREFIX, saveLuckyGiftConfig } from "./api.js";
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
function jsonResponse(data) {
|
function jsonResponse(data) {
|
||||||
return new Response(JSON.stringify({ code: 0, data }), {
|
return new Response(JSON.stringify({ code: 0, data }), {
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
status: 200
|
status: 200,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
test("builds ops api urls under the dedicated prefix", () => {
|
test("builds ops api urls under the dedicated prefix", () => {
|
||||||
const url = buildOpsUrl("/apps", { app_code: "lalu", empty: "", status: "active" });
|
const url = buildOpsUrl("/apps", { app_code: "lalu", empty: "", status: "active" });
|
||||||
|
|
||||||
expect(OPS_API_PREFIX).toBe("/api/v1/admin/ops-center");
|
expect(OPS_API_PREFIX).toBe("/api/v1/admin/ops-center");
|
||||||
expect(url).toBe("http://localhost:3000/api/v1/admin/ops-center/apps?app_code=lalu&status=active");
|
expect(url).toBe("http://localhost:3000/api/v1/admin/ops-center/apps?app_code=lalu&status=active");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("loads every pool config per app through the plural configs endpoint", async () => {
|
test("loads every pool config per app through the plural configs endpoint", async () => {
|
||||||
const calls = [];
|
const calls = [];
|
||||||
vi.spyOn(window, "fetch").mockImplementation(async (url) => {
|
vi.spyOn(window, "fetch").mockImplementation(async (url) => {
|
||||||
const text = String(url);
|
const text = String(url);
|
||||||
calls.push(text);
|
calls.push(text);
|
||||||
if (text.endsWith("/apps")) {
|
if (text.endsWith("/apps")) {
|
||||||
return jsonResponse({ items: [{ app_code: "lalu", status: "active" }] });
|
return jsonResponse({ items: [{ app_code: "lalu", status: "active" }] });
|
||||||
}
|
}
|
||||||
if (text.includes("/lucky-gifts/configs?")) {
|
if (text.includes("/lucky-gifts/configs?")) {
|
||||||
// lalu 在 default 之外还有 lucky/super_lucky 两个已发布奖池,复数接口必须全部带回。
|
// lalu 在 default 之外还有 lucky/super_lucky 两个已发布奖池,复数接口必须全部带回。
|
||||||
return jsonResponse([
|
return jsonResponse([
|
||||||
{ app_code: "lalu", enabled: true, pool_id: "lucky", rule_version: 3, target_rtp_percent: 95 },
|
{ app_code: "lalu", enabled: true, pool_id: "lucky", rule_version: 3, target_rtp_percent: 95 },
|
||||||
{ app_code: "lalu", enabled: false, pool_id: "super_lucky", rule_version: 2, target_rtp_percent: 92 }
|
{ app_code: "lalu", enabled: false, pool_id: "super_lucky", rule_version: 2, target_rtp_percent: 92 },
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
if (text.includes("/lucky-gifts/pools?")) {
|
if (text.includes("/lucky-gifts/pools?")) {
|
||||||
return jsonResponse({
|
return jsonResponse({
|
||||||
items: [
|
items: [
|
||||||
{ app_code: "lalu", available_balance: 37950017, balance: 38559017, materialized: true, pool_id: "lucky", reserve_floor: 609000 },
|
{
|
||||||
{ app_code: "lalu", available_balance: 361500, balance: 382500, materialized: false, pool_id: "default", reserve_floor: 21000 }
|
app_code: "lalu",
|
||||||
]
|
available_balance: 37950017,
|
||||||
});
|
balance: 38559017,
|
||||||
}
|
materialized: true,
|
||||||
if (text.includes("/lucky-gifts/summary?")) {
|
pool_id: "lucky",
|
||||||
return jsonResponse({ granted_draws: 9, pending_draws: 1, total_draws: 10, total_reward_coins: 900, total_spent_coins: 1000 });
|
reserve_floor: 609000,
|
||||||
}
|
},
|
||||||
return jsonResponse({ items: [] });
|
{
|
||||||
});
|
app_code: "lalu",
|
||||||
|
available_balance: 361500,
|
||||||
|
balance: 382500,
|
||||||
|
materialized: false,
|
||||||
|
pool_id: "default",
|
||||||
|
reserve_floor: 21000,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (text.includes("/lucky-gifts/summary?")) {
|
||||||
|
return jsonResponse({
|
||||||
|
granted_draws: 9,
|
||||||
|
pending_draws: 1,
|
||||||
|
total_draws: 10,
|
||||||
|
total_reward_coins: 900,
|
||||||
|
total_spent_coins: 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return jsonResponse({ items: [] });
|
||||||
|
});
|
||||||
|
|
||||||
const data = await fetchOpsDashboard();
|
const data = await fetchOpsDashboard();
|
||||||
|
|
||||||
expect(calls).toEqual([
|
expect(calls).toEqual([
|
||||||
"http://localhost:3000/api/v1/admin/ops-center/apps",
|
"http://localhost:3000/api/v1/admin/ops-center/apps",
|
||||||
"http://localhost:3000/api/v1/admin/ops-center/lucky-gifts/configs?app_code=lalu",
|
"http://localhost:3000/api/v1/admin/ops-center/lucky-gifts/configs?app_code=lalu",
|
||||||
"http://localhost:3000/api/v1/admin/ops-center/lucky-gifts/pools?app_code=lalu",
|
"http://localhost:3000/api/v1/admin/ops-center/lucky-gifts/pools?app_code=lalu",
|
||||||
"http://localhost:3000/api/v1/admin/ops-center/lucky-gifts/summary?app_code=lalu"
|
"http://localhost:3000/api/v1/admin/ops-center/lucky-gifts/summary?app_code=lalu",
|
||||||
]);
|
]);
|
||||||
expect(data.luckyGifts).toEqual([
|
expect(data.luckyGifts).toEqual([
|
||||||
expect.objectContaining({ app_code: "lalu", is_default: false, pool_id: "lucky", rule_version: 3 }),
|
expect.objectContaining({ app_code: "lalu", is_default: false, pool_id: "lucky", rule_version: 3 }),
|
||||||
expect.objectContaining({ app_code: "lalu", is_default: false, pool_id: "super_lucky", rule_version: 2 })
|
expect.objectContaining({ app_code: "lalu", is_default: false, pool_id: "super_lucky", rule_version: 2 }),
|
||||||
]);
|
]);
|
||||||
expect(data.summary).toMatchObject({
|
expect(data.summary).toMatchObject({
|
||||||
activeApps: 1,
|
activeApps: 1,
|
||||||
configuredPools: 2,
|
configuredPools: 2,
|
||||||
enabledPools: 1,
|
enabledPools: 1,
|
||||||
poolBalanceTotal: 38941517,
|
poolBalanceTotal: 38941517,
|
||||||
totalDraws: 10
|
totalDraws: 10,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("falls back to the server default draft when an app has no published config", async () => {
|
test("falls back to the server default draft when an app has no published config", async () => {
|
||||||
const calls = [];
|
const calls = [];
|
||||||
vi.spyOn(window, "fetch").mockImplementation(async (url) => {
|
vi.spyOn(window, "fetch").mockImplementation(async (url) => {
|
||||||
const text = String(url);
|
const text = String(url);
|
||||||
calls.push(text);
|
calls.push(text);
|
||||||
if (text.endsWith("/apps")) {
|
if (text.endsWith("/apps")) {
|
||||||
return jsonResponse({ items: [{ app_code: "yumi", status: "active" }] });
|
return jsonResponse({ items: [{ app_code: "yumi", status: "active" }] });
|
||||||
}
|
}
|
||||||
if (text.includes("/lucky-gifts/configs?")) {
|
if (text.includes("/lucky-gifts/configs?")) {
|
||||||
return jsonResponse([]);
|
return jsonResponse([]);
|
||||||
}
|
}
|
||||||
if (text.includes("/lucky-gifts/config?")) {
|
if (text.includes("/lucky-gifts/config?")) {
|
||||||
return jsonResponse({ app_code: "yumi", enabled: false, pool_id: "default", rule_version: 0, target_rtp_percent: 95 });
|
return jsonResponse({
|
||||||
}
|
app_code: "yumi",
|
||||||
return jsonResponse({ items: [] });
|
enabled: false,
|
||||||
});
|
pool_id: "default",
|
||||||
|
rule_version: 0,
|
||||||
|
target_rtp_percent: 95,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return jsonResponse({ items: [] });
|
||||||
|
});
|
||||||
|
|
||||||
const data = await fetchOpsDashboard();
|
const data = await fetchOpsDashboard();
|
||||||
|
|
||||||
expect(calls).toContain("http://localhost:3000/api/v1/admin/ops-center/lucky-gifts/config?app_code=yumi");
|
expect(calls).toContain("http://localhost:3000/api/v1/admin/ops-center/lucky-gifts/config?app_code=yumi");
|
||||||
expect(data.luckyGifts).toEqual([
|
expect(data.luckyGifts).toEqual([
|
||||||
expect.objectContaining({ app_code: "yumi", is_default: true, pool_id: "default", rule_version: 0 })
|
expect.objectContaining({ app_code: "yumi", is_default: true, pool_id: "default", rule_version: 0 }),
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("fetches draws with numeric ids as user_id and other ids as external_user_id", async () => {
|
test("fetches draws with numeric ids as user_id and other ids as external_user_id", async () => {
|
||||||
const calls = [];
|
const calls = [];
|
||||||
vi.spyOn(window, "fetch").mockImplementation(async (url) => {
|
vi.spyOn(window, "fetch").mockImplementation(async (url) => {
|
||||||
calls.push(String(url));
|
calls.push(String(url));
|
||||||
return jsonResponse({ items: [{ draw_id: "draw-1" }], page: 1, pageSize: 20, total: 1 });
|
return jsonResponse({ items: [{ draw_id: "draw-1" }], page: 1, pageSize: 20, total: 1 });
|
||||||
});
|
});
|
||||||
|
|
||||||
const numericResult = await fetchLuckyGiftDraws({ appCode: "lalu", poolId: "lucky", userId: "10001" });
|
const numericResult = await fetchLuckyGiftDraws({ appCode: "lalu", poolId: "lucky", userId: "10001" });
|
||||||
await fetchLuckyGiftDraws({ appCode: "lalu", userId: "ext-abc" });
|
await fetchLuckyGiftDraws({ appCode: "lalu", userId: "ext-abc" });
|
||||||
|
|
||||||
const numericParams = new URL(calls[0]).searchParams;
|
const numericParams = new URL(calls[0]).searchParams;
|
||||||
expect(numericParams.get("app_code")).toBe("lalu");
|
expect(numericParams.get("app_code")).toBe("lalu");
|
||||||
expect(numericParams.get("pool_id")).toBe("lucky");
|
expect(numericParams.get("pool_id")).toBe("lucky");
|
||||||
expect(numericParams.get("user_id")).toBe("10001");
|
expect(numericParams.get("user_id")).toBe("10001");
|
||||||
expect(numericParams.get("external_user_id")).toBeNull();
|
expect(numericParams.get("external_user_id")).toBeNull();
|
||||||
const externalParams = new URL(calls[1]).searchParams;
|
const externalParams = new URL(calls[1]).searchParams;
|
||||||
expect(externalParams.get("external_user_id")).toBe("ext-abc");
|
expect(externalParams.get("external_user_id")).toBe("ext-abc");
|
||||||
expect(externalParams.get("user_id")).toBeNull();
|
expect(externalParams.get("user_id")).toBeNull();
|
||||||
expect(numericResult).toMatchObject({ items: [{ draw_id: "draw-1" }], page: 1, total: 1 });
|
expect(numericResult).toMatchObject({ items: [{ draw_id: "draw-1" }], page: 1, total: 1 });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("saves lucky gift config through admin ops route", async () => {
|
test("saves lucky gift config through admin ops route", async () => {
|
||||||
const calls = [];
|
const calls = [];
|
||||||
vi.spyOn(window, "fetch").mockImplementation(async (url, init) => {
|
vi.spyOn(window, "fetch").mockImplementation(async (url, init) => {
|
||||||
calls.push({ body: init.body, method: init.method, url: String(url) });
|
calls.push({ body: init.body, method: init.method, url: String(url) });
|
||||||
return jsonResponse({ app_code: "aslan", pool_id: "default" });
|
return jsonResponse({ app_code: "aslan", pool_id: "default" });
|
||||||
});
|
});
|
||||||
|
|
||||||
await saveLuckyGiftConfig({
|
await saveLuckyGiftConfig({
|
||||||
app_code: "aslan",
|
anchor_daily_payout_cap: 12_312_000,
|
||||||
pool_id: "default",
|
anchor_rate_percent: 1,
|
||||||
enabled: true,
|
app_code: "aslan",
|
||||||
target_rtp_percent: 95,
|
control_band_percent: 3,
|
||||||
pool_rate_percent: 96,
|
device_daily_payout_cap: 1_026_000,
|
||||||
settlement_window_wager: 1_000_000,
|
enabled: true,
|
||||||
control_band_percent: 1,
|
gift_price_reference: 100,
|
||||||
gift_price_reference: 100,
|
high_water_nonzero_factor_percent: 130,
|
||||||
stages: [{ stage: "novice", tiers: [{ multiplier: 1, probabilityPercent: 100, enabled: true }] }]
|
high_watermark_coins: 20_000_000,
|
||||||
});
|
initial_pool_coins: 1_000_000,
|
||||||
|
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: 50_000,
|
||||||
|
normal_max_equivalent_draws: 20_000,
|
||||||
|
novice_max_equivalent_draws: 2_000,
|
||||||
|
pool_id: "default",
|
||||||
|
pool_rate_percent: 98,
|
||||||
|
profit_rate_percent: 1,
|
||||||
|
recharge_boost_factor_percent: 110,
|
||||||
|
recharge_boost_window_ms: 300_000,
|
||||||
|
room_hourly_payout_cap: 684_000,
|
||||||
|
settlement_window_wager: 1_000_000,
|
||||||
|
stages: [
|
||||||
|
{
|
||||||
|
min_recharge_30d_coins: 0,
|
||||||
|
min_recharge_7d_coins: 0,
|
||||||
|
stage: "novice",
|
||||||
|
tiers: [{ multiplier: 1, probabilityPercent: 100, enabled: true }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
strategy_version: "dynamic_v3",
|
||||||
|
target_rtp_percent: 98,
|
||||||
|
user_daily_payout_cap: 615_600,
|
||||||
|
user_hourly_payout_cap: 34_200,
|
||||||
|
});
|
||||||
|
|
||||||
expect(calls[0].method).toBe("PUT");
|
expect(calls[0].method).toBe("PUT");
|
||||||
expect(calls[0].url).toBe("http://localhost:3000/api/v1/admin/ops-center/lucky-gifts/config?app_code=aslan&pool_id=default");
|
expect(calls[0].url).toBe(
|
||||||
expect(JSON.parse(calls[0].body)).toMatchObject({
|
"http://localhost:3000/api/v1/admin/ops-center/lucky-gifts/config?app_code=aslan&pool_id=default",
|
||||||
app_code: "aslan",
|
);
|
||||||
enabled: true,
|
// 精确比较完整 owner payload;子集断言会让新增/遗漏的策略字段在回归中静默通过。
|
||||||
pool_id: "default",
|
expect(JSON.parse(calls[0].body)).toEqual({
|
||||||
stages: [{ stage: "novice", tiers: [{ probability_percent: 100 }] }],
|
anchor_daily_payout_cap: 12_312_000,
|
||||||
target_rtp_percent: 95
|
anchor_rate_percent: 1,
|
||||||
});
|
app_code: "aslan",
|
||||||
|
control_band_percent: 3,
|
||||||
|
device_daily_payout_cap: 1_026_000,
|
||||||
|
effective_from_ms: 0,
|
||||||
|
enabled: true,
|
||||||
|
gift_price_reference: 100,
|
||||||
|
high_water_nonzero_factor_percent: 130,
|
||||||
|
high_watermark_coins: 20_000_000,
|
||||||
|
initial_pool_coins: 1_000_000,
|
||||||
|
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: 50_000,
|
||||||
|
normal_max_equivalent_draws: 20_000,
|
||||||
|
novice_max_equivalent_draws: 2_000,
|
||||||
|
pool_id: "default",
|
||||||
|
pool_rate_percent: 98,
|
||||||
|
profit_rate_percent: 1,
|
||||||
|
recharge_boost_factor_percent: 110,
|
||||||
|
recharge_boost_window_ms: 300_000,
|
||||||
|
room_hourly_payout_cap: 684_000,
|
||||||
|
settlement_window_wager: 1_000_000,
|
||||||
|
stages: [
|
||||||
|
{
|
||||||
|
min_recharge_30d_coins: 0,
|
||||||
|
min_recharge_7d_coins: 0,
|
||||||
|
stage: "novice",
|
||||||
|
tiers: [
|
||||||
|
{
|
||||||
|
enabled: true,
|
||||||
|
high_water_only: false,
|
||||||
|
multiplier: 1,
|
||||||
|
probability_percent: 100,
|
||||||
|
tier_id: "",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
strategy_version: "dynamic_v3",
|
||||||
|
target_rtp_percent: 98,
|
||||||
|
user_daily_payout_cap: 615_600,
|
||||||
|
user_hourly_payout_cap: 34_200,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -8,83 +8,121 @@ import { formatNumber, formatPercent } from "../format.js";
|
|||||||
import { OpsStatusBadge } from "./OpsStatusBadge.jsx";
|
import { OpsStatusBadge } from "./OpsStatusBadge.jsx";
|
||||||
|
|
||||||
export function ConfigsView({ apps, luckyGifts, onCreate, onEdit, saving }) {
|
export function ConfigsView({ apps, luckyGifts, onCreate, onEdit, saving }) {
|
||||||
const [appFilter, setAppFilter] = useState("");
|
const [appFilter, setAppFilter] = useState("");
|
||||||
|
|
||||||
const appOptions = useMemo(
|
const appOptions = useMemo(
|
||||||
() => apps.map((item) => [item.app_code ?? item.appCode, `${item.app_name ?? item.appName ?? ""} (${item.app_code ?? item.appCode})`]),
|
() =>
|
||||||
[apps]
|
apps.map((item) => [
|
||||||
);
|
item.app_code ?? item.appCode,
|
||||||
// 配置列表已在 dashboard 阶段跨应用拉全,应用筛选只做前端过滤,避免重复扇出请求。
|
`${item.app_name ?? item.appName ?? ""} (${item.app_code ?? item.appCode})`,
|
||||||
const items = useMemo(
|
]),
|
||||||
() => (appFilter ? luckyGifts.filter((config) => config.app_code === appFilter) : luckyGifts),
|
[apps],
|
||||||
[appFilter, luckyGifts]
|
);
|
||||||
);
|
// 配置列表已在 dashboard 阶段跨应用拉全,应用筛选只做前端过滤,避免重复扇出请求。
|
||||||
|
const items = useMemo(
|
||||||
|
() => (appFilter ? luckyGifts.filter((config) => config.app_code === appFilter) : luckyGifts),
|
||||||
|
[appFilter, luckyGifts],
|
||||||
|
);
|
||||||
|
|
||||||
const columns = useMemo(() => buildColumns({ onEdit, saving }), [onEdit, saving]);
|
const columns = useMemo(() => buildColumns({ onEdit, saving }), [onEdit, saving]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="ops-view">
|
<div className="ops-view">
|
||||||
<AdminListToolbar
|
<AdminListToolbar
|
||||||
actions={
|
actions={
|
||||||
<Button disabled={saving} startIcon={<AddOutlined fontSize="small" />} variant="primary" onClick={onCreate}>
|
<Button
|
||||||
添加配置
|
disabled={saving}
|
||||||
</Button>
|
startIcon={<AddOutlined fontSize="small" />}
|
||||||
}
|
variant="primary"
|
||||||
filters={<AdminFilterSelect label="应用" options={[["", "全部应用"], ...appOptions]} value={appFilter} onChange={setAppFilter} />}
|
onClick={onCreate}
|
||||||
/>
|
>
|
||||||
<DataTable
|
添加配置
|
||||||
columns={columns}
|
</Button>
|
||||||
emptyLabel="暂无幸运礼物配置"
|
}
|
||||||
items={items}
|
filters={
|
||||||
minWidth="1080px"
|
<AdminFilterSelect
|
||||||
rowKey={(item) => `${item.app_code}:${item.pool_id}:${item.rule_version}`}
|
label="应用"
|
||||||
title="幸运礼物配置"
|
options={[["", "全部应用"], ...appOptions]}
|
||||||
total={items.length}
|
value={appFilter}
|
||||||
/>
|
onChange={setAppFilter}
|
||||||
</div>
|
/>
|
||||||
);
|
}
|
||||||
|
/>
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
emptyLabel="暂无幸运礼物配置"
|
||||||
|
items={items}
|
||||||
|
minWidth="1260px"
|
||||||
|
rowKey={(item) => `${item.app_code}:${item.pool_id}:${item.rule_version}`}
|
||||||
|
title="幸运礼物配置"
|
||||||
|
total={items.length}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildColumns({ onEdit, saving }) {
|
function buildColumns({ onEdit, saving }) {
|
||||||
return [
|
return [
|
||||||
{ key: "app_code", label: "应用", width: "minmax(88px, 0.7fr)" },
|
{ key: "app_code", label: "应用", width: "minmax(88px, 0.7fr)" },
|
||||||
{ key: "pool_id", label: "奖池", width: "minmax(110px, 0.9fr)" },
|
{ key: "pool_id", label: "奖池", width: "minmax(110px, 0.9fr)" },
|
||||||
{
|
{
|
||||||
key: "rule_version",
|
key: "rule_version",
|
||||||
label: "版本",
|
label: "版本",
|
||||||
render: (item) => (item.is_default ? "-" : `v${item.rule_version}`),
|
render: (item) => (item.is_default ? "-" : `v${item.rule_version}`),
|
||||||
width: "minmax(72px, 0.5fr)"
|
width: "minmax(72px, 0.5fr)",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "enabled",
|
key: "strategy_version",
|
||||||
label: "状态",
|
label: "策略",
|
||||||
render: (item) => {
|
// 升级前的不可变版本可能没有该字段,owner 会按 fixed_v2 解释;列表保持同一兼容口径,
|
||||||
// 默认草稿是服务端为未配置奖池生成的占位,不参与线上抽奖,必须与"停用"区分开。
|
// 不能把空值展示成 dynamic_v3,避免运营误判已完成策略切换。
|
||||||
if (item.is_default) {
|
render: (item) => item.strategy_version || "fixed_v2",
|
||||||
return <OpsStatusBadge label="默认草稿" tone="warning" />;
|
width: "minmax(112px, 0.8fr)",
|
||||||
}
|
},
|
||||||
return item.enabled ? <OpsStatusBadge label="已启用" tone="succeeded" /> : <OpsStatusBadge label="已停用" />;
|
{
|
||||||
},
|
key: "enabled",
|
||||||
width: "minmax(128px, 0.8fr)"
|
label: "状态",
|
||||||
},
|
render: (item) => {
|
||||||
{ key: "target_rtp_percent", label: "目标 RTP", render: (item) => formatPercent(item.target_rtp_percent) },
|
// 默认草稿是服务端为未配置奖池生成的占位,不参与线上抽奖,必须与"停用"区分开。
|
||||||
{ key: "pool_rate_percent", label: "奖池回收", render: (item) => formatPercent(item.pool_rate_percent) },
|
if (item.is_default) {
|
||||||
{ key: "control_band_percent", label: "控制带宽", render: (item) => formatPercent(item.control_band_percent) },
|
return <OpsStatusBadge label="默认草稿" tone="warning" />;
|
||||||
{ key: "settlement_window_wager", label: "结算窗口流水", render: (item) => formatNumber(item.settlement_window_wager) },
|
}
|
||||||
{
|
return item.enabled ? (
|
||||||
key: "created_at_ms",
|
<OpsStatusBadge label="已启用" tone="succeeded" />
|
||||||
label: "发布时间",
|
) : (
|
||||||
render: (item) => (item.is_default ? "-" : <TimeText value={item.created_at_ms} />),
|
<OpsStatusBadge label="已停用" />
|
||||||
width: "minmax(176px, 1.1fr)"
|
);
|
||||||
},
|
},
|
||||||
{
|
width: "minmax(128px, 0.8fr)",
|
||||||
key: "actions",
|
},
|
||||||
label: "操作",
|
{ key: "target_rtp_percent", label: "目标 RTP", render: (item) => formatPercent(item.target_rtp_percent) },
|
||||||
render: (item) => (
|
{ key: "pool_rate_percent", label: "公共奖池", render: (item) => formatPercent(item.pool_rate_percent) },
|
||||||
<Button disabled={saving} size="small" onClick={() => onEdit(item)}>
|
{
|
||||||
{item.is_default ? "发布配置" : "新增版本"}
|
key: "profit_anchor_rate",
|
||||||
</Button>
|
label: "平台 / 主播",
|
||||||
)
|
render: (item) => `${formatPercent(item.profit_rate_percent)} / ${formatPercent(item.anchor_rate_percent)}`,
|
||||||
}
|
width: "minmax(144px, 1fr)",
|
||||||
];
|
},
|
||||||
|
{ key: "control_band_percent", label: "控制带宽", render: (item) => formatPercent(item.control_band_percent) },
|
||||||
|
{
|
||||||
|
key: "settlement_window_wager",
|
||||||
|
label: "结算窗口流水",
|
||||||
|
render: (item) => formatNumber(item.settlement_window_wager),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "created_at_ms",
|
||||||
|
label: "发布时间",
|
||||||
|
render: (item) => (item.is_default ? "-" : <TimeText value={item.created_at_ms} />),
|
||||||
|
width: "minmax(176px, 1.1fr)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "actions",
|
||||||
|
label: "操作",
|
||||||
|
render: (item) => (
|
||||||
|
<Button disabled={saving} size="small" onClick={() => onEdit(item)}>
|
||||||
|
{item.is_default ? "发布配置" : "新增版本"}
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,283 +12,555 @@ import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
|
|||||||
import { Button } from "@/shared/ui/Button.jsx";
|
import { Button } from "@/shared/ui/Button.jsx";
|
||||||
import { formatDecimal } from "@/shared/utils/rtpProbability.js";
|
import { formatDecimal } from "@/shared/utils/rtpProbability.js";
|
||||||
import {
|
import {
|
||||||
configToForm,
|
applyDynamicStrategyDefaults,
|
||||||
correctAllStageProbabilities,
|
configToForm,
|
||||||
formToConfig,
|
correctAllStageProbabilities,
|
||||||
nextStageMultiplier,
|
formToConfig,
|
||||||
stageSummary
|
nextStageMultiplier,
|
||||||
|
stageSummary,
|
||||||
|
validateLuckyGiftForm,
|
||||||
} from "../configForm.js";
|
} from "../configForm.js";
|
||||||
import { formatNumber, formatPercent } from "../format.js";
|
import { formatNumber, formatPercent } from "../format.js";
|
||||||
import { OpsStatusBadge } from "./OpsStatusBadge.jsx";
|
import { OpsStatusBadge } from "./OpsStatusBadge.jsx";
|
||||||
|
|
||||||
export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit", onClose, onSubmit, saving }) {
|
export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit", onClose, onSubmit, saving }) {
|
||||||
const [form, setForm] = useState(() => configToForm(config));
|
const [form, setForm] = useState(() => configToForm(config));
|
||||||
const [activeStage, setActiveStage] = useState("novice");
|
const [activeStage, setActiveStage] = useState("novice");
|
||||||
|
const [validationErrors, setValidationErrors] = useState([]);
|
||||||
|
|
||||||
const stageSummaries = useMemo(() => form.stages.map((stage) => stageSummary(stage, form)), [form]);
|
const stageSummaries = useMemo(() => form.stages.map((stage) => stageSummary(stage, form)), [form]);
|
||||||
const activeStageKey = form.stages.some((stage) => stage.stage === activeStage) ? activeStage : form.stages[0]?.stage;
|
const activeStageKey = form.stages.some((stage) => stage.stage === activeStage)
|
||||||
const activeStageConfig = form.stages.find((stage) => stage.stage === activeStageKey);
|
? activeStage
|
||||||
const activeSummary = stageSummaries.find((summary) => summary.stageKey === activeStageKey);
|
: form.stages[0]?.stage;
|
||||||
|
const activeStageConfig = form.stages.find((stage) => stage.stage === activeStageKey);
|
||||||
|
const activeSummary = stageSummaries.find((summary) => summary.stageKey === activeStageKey);
|
||||||
|
|
||||||
const updateField = (field, value) => {
|
const updateField = (field, value) => {
|
||||||
setForm((current) => {
|
setValidationErrors([]);
|
||||||
const next = { ...current, [field]: value };
|
setForm((current) => {
|
||||||
// 目标 RTP 决定各奖档概率的闭合解,改动后全部阶段重算,避免展示的概率和将要发布的值不一致。
|
const next = { ...current, [field]: value };
|
||||||
return field === "target_rtp_percent" ? correctAllStageProbabilities(next) : next;
|
if (field === "strategy_version" && value === "dynamic_v3" && current.strategy_version !== "dynamic_v3") {
|
||||||
});
|
return applyDynamicStrategyDefaults(next);
|
||||||
};
|
}
|
||||||
|
// 目标 RTP 决定各奖档概率的闭合解,改动后全部阶段重算,避免展示的概率和将要发布的值不一致。
|
||||||
|
return field === "target_rtp_percent" ? correctAllStageProbabilities(next) : next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const updateStageTier = (stageKey, tierIndex, patch) => {
|
const updateStageField = (stageKey, field, value) => {
|
||||||
setForm((current) =>
|
setValidationErrors([]);
|
||||||
correctAllStageProbabilities({
|
setForm((current) => ({
|
||||||
...current,
|
...current,
|
||||||
stages: current.stages.map((stage) =>
|
stages: current.stages.map((stage) => (stage.stage === stageKey ? { ...stage, [field]: value } : stage)),
|
||||||
stage.stage === stageKey
|
}));
|
||||||
? {
|
};
|
||||||
...stage,
|
|
||||||
tiers: stage.tiers.map((tier, index) => (index === tierIndex ? { ...tier, ...patch } : tier))
|
|
||||||
}
|
|
||||||
: stage
|
|
||||||
)
|
|
||||||
})
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const addStageTier = (stageKey) => {
|
const updateStageTier = (stageKey, tierIndex, patch) => {
|
||||||
setForm((current) =>
|
setValidationErrors([]);
|
||||||
correctAllStageProbabilities({
|
setForm((current) =>
|
||||||
...current,
|
correctAllStageProbabilities({
|
||||||
stages: current.stages.map((stage) =>
|
...current,
|
||||||
stage.stage === stageKey
|
stages: current.stages.map((stage) =>
|
||||||
? {
|
stage.stage === stageKey
|
||||||
...stage,
|
? {
|
||||||
tiers: [
|
...stage,
|
||||||
...stage.tiers,
|
tiers: stage.tiers.map((tier, index) =>
|
||||||
{
|
index === tierIndex ? { ...tier, ...patch } : tier,
|
||||||
enabled: true,
|
),
|
||||||
// 10 倍以上属于高倍档,默认只在奖池高水位时开放,防止新档位直接击穿保底线。
|
|
||||||
highWaterOnly: nextStageMultiplier(stage.tiers) >= 10,
|
|
||||||
multiplier: String(nextStageMultiplier(stage.tiers)),
|
|
||||||
probabilityPercent: "0",
|
|
||||||
tierId: ""
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
: stage
|
|
||||||
)
|
|
||||||
})
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeStageTier = (stageKey, tierIndex) => {
|
|
||||||
setForm((current) =>
|
|
||||||
correctAllStageProbabilities({
|
|
||||||
...current,
|
|
||||||
stages: current.stages.map((stage) =>
|
|
||||||
stage.stage === stageKey ? { ...stage, tiers: stage.tiers.filter((_, index) => index !== tierIndex) } : stage
|
|
||||||
)
|
|
||||||
})
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
onSubmit(formToConfig(config, form));
|
|
||||||
};
|
|
||||||
|
|
||||||
const isCreate = mode === "create";
|
|
||||||
const title = isCreate ? "添加幸运礼物配置" : config.is_default ? "发布幸运礼物配置" : "新增幸运礼物版本";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog fullWidth maxWidth="lg" open onClose={saving ? undefined : onClose}>
|
|
||||||
<form className="ops-config-form" onSubmit={handleSubmit}>
|
|
||||||
<DialogTitle className="ops-config-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">
|
|
||||||
<ConfigMetric label="应用" value={form.app_code || "-"} />
|
|
||||||
<ConfigMetric label="奖池" value={form.pool_id || "default"} />
|
|
||||||
<ConfigMetric label="目标 RTP" value={formatPercent(Number(form.target_rtp_percent))} />
|
|
||||||
<ConfigMetric label="奖池回收" value={formatPercent(Number(form.pool_rate_percent))} />
|
|
||||||
<ConfigMetric label="结算窗口" value={formatNumber(Number(form.settlement_window_wager))} />
|
|
||||||
<ConfigMetric label="状态" value={form.enabled ? "启用" : "停用"} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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)}
|
|
||||||
/>
|
|
||||||
<FormControlLabel
|
|
||||||
control={<AdminSwitch checked={form.enabled} onChange={(event) => updateField("enabled", event.target.checked)} />}
|
|
||||||
label={form.enabled ? "发布后立即启用" : "发布后保持停用"}
|
|
||||||
sx={{ gap: 1, marginLeft: 0 }}
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="ops-config-section">
|
|
||||||
<h3>RTP 与奖池</h3>
|
|
||||||
<div className="ops-form-grid">
|
|
||||||
<NumberField form={form} label="目标 RTP (%)" name="target_rtp_percent" step="0.01" onChange={updateField} />
|
|
||||||
<NumberField form={form} label="奖池回收率 (%)" name="pool_rate_percent" step="0.01" onChange={updateField} />
|
|
||||||
<NumberField form={form} label="控制带宽 (%)" name="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} />
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</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>
|
|
||||||
<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
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
: stage,
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addStageTier = (stageKey) => {
|
||||||
|
setValidationErrors([]);
|
||||||
|
setForm((current) =>
|
||||||
|
correctAllStageProbabilities({
|
||||||
|
...current,
|
||||||
|
stages: current.stages.map((stage) =>
|
||||||
|
stage.stage === stageKey
|
||||||
|
? {
|
||||||
|
...stage,
|
||||||
|
tiers: [
|
||||||
|
...stage.tiers,
|
||||||
|
{
|
||||||
|
enabled: true,
|
||||||
|
// 10 倍以上属于高倍档,默认只在奖池高水位时开放,防止新档位直接击穿保底线。
|
||||||
|
highWaterOnly: nextStageMultiplier(stage.tiers) >= 10,
|
||||||
|
multiplier: String(nextStageMultiplier(stage.tiers)),
|
||||||
|
probabilityPercent: "0",
|
||||||
|
tierId: "",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: stage,
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeStageTier = (stageKey, tierIndex) => {
|
||||||
|
setValidationErrors([]);
|
||||||
|
setForm((current) =>
|
||||||
|
correctAllStageProbabilities({
|
||||||
|
...current,
|
||||||
|
stages: current.stages.map((stage) =>
|
||||||
|
stage.stage === stageKey
|
||||||
|
? { ...stage, tiers: stage.tiers.filter((_, index) => index !== tierIndex) }
|
||||||
|
: stage,
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const errors = validateLuckyGiftForm(form);
|
||||||
|
if (errors.length) {
|
||||||
|
setValidationErrors(errors);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onSubmit(formToConfig(config, form));
|
||||||
|
};
|
||||||
|
|
||||||
|
const isCreate = mode === "create";
|
||||||
|
const title = isCreate ? "添加幸运礼物配置" : config.is_default ? "发布幸运礼物配置" : "新增幸运礼物版本";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog fullWidth maxWidth="lg" open onClose={saving ? undefined : onClose}>
|
||||||
|
<form className="ops-config-form" onSubmit={handleSubmit}>
|
||||||
|
<DialogTitle className="ops-config-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">
|
||||||
|
<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.pool_rate_percent}% / ${form.profit_rate_percent}% / ${form.anchor_rate_percent}%`}
|
||||||
/>
|
/>
|
||||||
{/* 概率由目标 RTP 和倍率统一校正,保持只读可以避免运营手填后绕过服务端概率闭合校验。 */}
|
<ConfigMetric label="结算窗口" value={formatNumber(Number(form.settlement_window_wager))} />
|
||||||
<TextField
|
<ConfigMetric label="状态" value={form.enabled ? "启用" : "停用"} />
|
||||||
size="small"
|
</div>
|
||||||
slotProps={{ htmlInput: { "aria-label": "概率 %", readOnly: true } }}
|
|
||||||
value={formatDecimal(tier.probabilityPercent)}
|
{validationErrors.length ? (
|
||||||
/>
|
<div className="ops-config-validation" role="alert">
|
||||||
<Checkbox
|
<strong>配置不能发布</strong>
|
||||||
checked={Boolean(tier.highWaterOnly)}
|
<ul>
|
||||||
disabled={Number(tier.multiplier) >= 10}
|
{validationErrors.map((message) => (
|
||||||
size="small"
|
<li key={message}>{message}</li>
|
||||||
slotProps={{ input: { "aria-label": "高水位" } }}
|
))}
|
||||||
onChange={(event) => updateStageTier(activeStageConfig.stage, index, { highWaterOnly: event.target.checked })}
|
</ul>
|
||||||
/>
|
</div>
|
||||||
<Checkbox
|
) : null}
|
||||||
checked={tier.enabled !== false}
|
|
||||||
size="small"
|
<div className="ops-config-layout">
|
||||||
slotProps={{ input: { "aria-label": "启用" } }}
|
<aside className="ops-config-params" aria-label="基础参数">
|
||||||
onChange={(event) => updateStageTier(activeStageConfig.stage, index, { enabled: event.target.checked })}
|
<section className="ops-config-section">
|
||||||
/>
|
<h3>规则身份</h3>
|
||||||
<Button
|
{isCreate ? (
|
||||||
disabled={activeStageConfig.tiers.length <= 1}
|
<TextField
|
||||||
variant="danger"
|
fullWidth
|
||||||
onClick={() => removeStageTier(activeStageConfig.stage, index)}
|
required
|
||||||
>
|
label="应用"
|
||||||
删除
|
select
|
||||||
</Button>
|
size="small"
|
||||||
</div>
|
value={form.app_code}
|
||||||
))}
|
onChange={(event) => updateField("app_code", event.target.value)}
|
||||||
</div>
|
>
|
||||||
|
{appOptions.map(([value, label]) => (
|
||||||
|
<MenuItem key={value} value={value}>
|
||||||
|
{label}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
) : (
|
||||||
|
<TextField disabled fullWidth label="应用" size="small" value={form.app_code} />
|
||||||
|
)}
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
required
|
||||||
|
helperText="修改奖池 ID 会发布到新的奖池,不影响原奖池"
|
||||||
|
label="奖池"
|
||||||
|
size="small"
|
||||||
|
value={form.pool_id}
|
||||||
|
onChange={(event) => updateField("pool_id", event.target.value)}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="策略版本"
|
||||||
|
select
|
||||||
|
size="small"
|
||||||
|
value={form.strategy_version}
|
||||||
|
onChange={(event) => updateField("strategy_version", event.target.value)}
|
||||||
|
>
|
||||||
|
<MenuItem value="dynamic_v3">dynamic_v3</MenuItem>
|
||||||
|
<MenuItem value="fixed_v2">fixed_v2(历史兼容)</MenuItem>
|
||||||
|
</TextField>
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<AdminSwitch
|
||||||
|
checked={form.enabled}
|
||||||
|
onChange={(event) => updateField("enabled", event.target.checked)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={form.enabled ? "发布后立即启用" : "发布后保持停用"}
|
||||||
|
sx={{ gap: 1, marginLeft: 0 }}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="ops-config-section">
|
||||||
|
<h3>RTP 与奖池</h3>
|
||||||
|
<div className="ops-form-grid">
|
||||||
|
<NumberField
|
||||||
|
form={form}
|
||||||
|
label="目标 RTP (%)"
|
||||||
|
name="target_rtp_percent"
|
||||||
|
step="0.01"
|
||||||
|
onChange={updateField}
|
||||||
|
/>
|
||||||
|
<NumberField
|
||||||
|
form={form}
|
||||||
|
label="公共奖池 (%)"
|
||||||
|
name="pool_rate_percent"
|
||||||
|
step="0.01"
|
||||||
|
onChange={updateField}
|
||||||
|
/>
|
||||||
|
<NumberField
|
||||||
|
form={form}
|
||||||
|
label="平台盈利 (%)"
|
||||||
|
name="profit_rate_percent"
|
||||||
|
step="0.01"
|
||||||
|
onChange={updateField}
|
||||||
|
/>
|
||||||
|
<NumberField
|
||||||
|
form={form}
|
||||||
|
helperText="必须与该奖池实际送礼类型(lucky / super_lucky)在各适用地区的返币比例一致,否则开奖账务校验会失败"
|
||||||
|
label="主播收益 (%)"
|
||||||
|
name="anchor_rate_percent"
|
||||||
|
step="0.01"
|
||||||
|
onChange={updateField}
|
||||||
|
/>
|
||||||
|
<NumberField
|
||||||
|
form={form}
|
||||||
|
label="控制带宽 (%)"
|
||||||
|
name="control_band_percent"
|
||||||
|
step="0.01"
|
||||||
|
onChange={updateField}
|
||||||
|
/>
|
||||||
|
<NumberField
|
||||||
|
form={form}
|
||||||
|
label="结算窗口流水"
|
||||||
|
name="settlement_window_wager"
|
||||||
|
onChange={updateField}
|
||||||
|
/>
|
||||||
|
<NumberField
|
||||||
|
form={form}
|
||||||
|
label="礼物参考金额"
|
||||||
|
name="gift_price_reference"
|
||||||
|
onChange={updateField}
|
||||||
|
/>
|
||||||
|
<NumberField
|
||||||
|
form={form}
|
||||||
|
label="新手等价抽数"
|
||||||
|
name="novice_max_equivalent_draws"
|
||||||
|
onChange={updateField}
|
||||||
|
/>
|
||||||
|
<NumberField
|
||||||
|
form={form}
|
||||||
|
label="普通等价抽数"
|
||||||
|
name="normal_max_equivalent_draws"
|
||||||
|
onChange={updateField}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{form.strategy_version === "dynamic_v3" ? (
|
||||||
|
<DynamicStrategySections form={form} onChange={updateField} />
|
||||||
|
) : null}
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section className="ops-stage-designer" aria-label="阶段奖档设计">
|
||||||
|
<div className="ops-stage-tabs" role="tablist" aria-label="阶段奖档">
|
||||||
|
{stageSummaries.map((summary) => (
|
||||||
|
<button
|
||||||
|
aria-selected={summary.stageKey === activeStageKey}
|
||||||
|
className={`ops-stage-tab ${summary.stageKey === activeStageKey ? "is-active" : ""} ${summary.ok ? "is-ok" : "is-warn"}`}
|
||||||
|
key={summary.stageKey}
|
||||||
|
role="tab"
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveStage(summary.stageKey)}
|
||||||
|
>
|
||||||
|
<span>{summary.tabLabel}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{activeStageConfig ? (
|
||||||
|
<div className="ops-stage-stack">
|
||||||
|
<div className="ops-stage-block__head">
|
||||||
|
<div>
|
||||||
|
<h3>{activeSummary?.stageLabel}</h3>
|
||||||
|
<OpsStatusBadge
|
||||||
|
label={`${activeSummary?.status} · 概率 ${formatDecimal(activeSummary?.probability)}% · 期望 RTP ${formatDecimal(activeSummary?.expected)}%`}
|
||||||
|
tone={activeSummary?.ok ? "succeeded" : "warning"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
startIcon={<AddOutlined fontSize="small" />}
|
||||||
|
onClick={() => addStageTier(activeStageConfig.stage)}
|
||||||
|
>
|
||||||
|
添加奖档
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{form.strategy_version === "dynamic_v3" ? (
|
||||||
|
<div className="ops-stage-thresholds">
|
||||||
|
<TextField
|
||||||
|
disabled={activeStageConfig.stage === "novice"}
|
||||||
|
label="7 日充值门槛"
|
||||||
|
size="small"
|
||||||
|
slotProps={{ htmlInput: { min: 0, step: 1 } }}
|
||||||
|
type="number"
|
||||||
|
value={activeStageConfig.min_recharge_7d_coins}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateStageField(
|
||||||
|
activeStageConfig.stage,
|
||||||
|
"min_recharge_7d_coins",
|
||||||
|
event.target.value,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
disabled={activeStageConfig.stage === "novice"}
|
||||||
|
label="30 日充值门槛"
|
||||||
|
size="small"
|
||||||
|
slotProps={{ htmlInput: { min: 0, step: 1 } }}
|
||||||
|
type="number"
|
||||||
|
value={activeStageConfig.min_recharge_30d_coins}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateStageField(
|
||||||
|
activeStageConfig.stage,
|
||||||
|
"min_recharge_30d_coins",
|
||||||
|
event.target.value,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="ops-tier-table">
|
||||||
|
<div className="ops-tier-head" aria-hidden="true">
|
||||||
|
<span>倍率</span>
|
||||||
|
<span>概率 %</span>
|
||||||
|
<span>高水位</span>
|
||||||
|
<span>启用</span>
|
||||||
|
<span>操作</span>
|
||||||
|
</div>
|
||||||
|
{activeStageConfig.tiers.map((tier, index) => (
|
||||||
|
<div className="ops-tier-row" key={`${activeStageConfig.stage}-${index}`}>
|
||||||
|
<TextField
|
||||||
|
required
|
||||||
|
size="small"
|
||||||
|
slotProps={{
|
||||||
|
htmlInput: { "aria-label": "倍率", min: 0, step: "0.01" },
|
||||||
|
}}
|
||||||
|
type="number"
|
||||||
|
value={tier.multiplier}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateStageTier(activeStageConfig.stage, index, {
|
||||||
|
// 10 倍及以上强制只走高水位,输入时同步勾选并锁死,防止高倍档在低水位放开。
|
||||||
|
highWaterOnly:
|
||||||
|
Number(event.target.value) >= 10 || tier.highWaterOnly,
|
||||||
|
multiplier: event.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{/* 概率由目标 RTP 和倍率统一校正,保持只读可以避免运营手填后绕过服务端概率闭合校验。 */}
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
slotProps={{
|
||||||
|
htmlInput: { "aria-label": "概率 %", readOnly: true },
|
||||||
|
}}
|
||||||
|
value={formatDecimal(tier.probabilityPercent)}
|
||||||
|
/>
|
||||||
|
<Checkbox
|
||||||
|
checked={Boolean(tier.highWaterOnly)}
|
||||||
|
disabled={Number(tier.multiplier) >= 10}
|
||||||
|
size="small"
|
||||||
|
slotProps={{ input: { "aria-label": "高水位" } }}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateStageTier(activeStageConfig.stage, index, {
|
||||||
|
highWaterOnly: event.target.checked,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Checkbox
|
||||||
|
checked={tier.enabled !== false}
|
||||||
|
size="small"
|
||||||
|
slotProps={{ input: { "aria-label": "启用" } }}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateStageTier(activeStageConfig.stage, index, {
|
||||||
|
enabled: event.target.checked,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
disabled={activeStageConfig.tiers.length <= 1}
|
||||||
|
variant="danger"
|
||||||
|
onClick={() => removeStageTier(activeStageConfig.stage, index)}
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button disabled={saving} onClick={onClose}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button disabled={saving} type="submit" variant="primary">
|
||||||
|
{saving ? "保存中" : "保存配置"}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</form>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
</div>
|
||||||
) : null}
|
|
||||||
</section>
|
</section>
|
||||||
</div>
|
|
||||||
</DialogContent>
|
<section className="ops-config-section">
|
||||||
<DialogActions>
|
<h3>大奖控制</h3>
|
||||||
<Button disabled={saving} onClick={onClose}>
|
<div className="ops-form-grid">
|
||||||
取消
|
<TextField
|
||||||
</Button>
|
className="ops-form-grid__wide"
|
||||||
<Button disabled={saving} type="submit" variant="primary">
|
helperText="按从小到大填写,使用逗号分隔"
|
||||||
{saving ? "保存中" : "保存配置"}
|
label="大奖倍率"
|
||||||
</Button>
|
required
|
||||||
</DialogActions>
|
size="small"
|
||||||
</form>
|
value={form.jackpot_multipliers}
|
||||||
</Dialog>
|
onChange={(event) => onChange("jackpot_multipliers", event.target.value)}
|
||||||
);
|
/>
|
||||||
|
<NumberField
|
||||||
|
form={form}
|
||||||
|
label="全局大奖 RTP 上限 (%)"
|
||||||
|
name="jackpot_global_rtp_max_percent"
|
||||||
|
step="0.01"
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
|
<NumberField
|
||||||
|
form={form}
|
||||||
|
label="用户每日 RTP 上限 (%)"
|
||||||
|
name="jackpot_user_day_rtp_max_percent"
|
||||||
|
step="0.01"
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
|
<NumberField
|
||||||
|
form={form}
|
||||||
|
label="用户 72 小时 RTP 上限 (%)"
|
||||||
|
name="jackpot_user_72h_rtp_max_percent"
|
||||||
|
step="0.01"
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
|
<NumberField
|
||||||
|
form={form}
|
||||||
|
label="用户每日大奖次数"
|
||||||
|
name="max_jackpot_hits_per_user_day"
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
|
<NumberField
|
||||||
|
form={form}
|
||||||
|
label="50 美元等值消费门槛"
|
||||||
|
name="jackpot_spend_threshold_coins"
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="ops-config-section">
|
||||||
|
<h3>六维返奖上限</h3>
|
||||||
|
<div className="ops-form-grid">
|
||||||
|
<NumberField form={form} label="单次返奖上限" name="max_single_payout" onChange={onChange} />
|
||||||
|
<NumberField form={form} label="用户小时上限" name="user_hourly_payout_cap" onChange={onChange} />
|
||||||
|
<NumberField form={form} label="用户每日上限" name="user_daily_payout_cap" onChange={onChange} />
|
||||||
|
<NumberField form={form} label="设备每日上限" name="device_daily_payout_cap" onChange={onChange} />
|
||||||
|
<NumberField form={form} label="房间小时上限" name="room_hourly_payout_cap" onChange={onChange} />
|
||||||
|
<NumberField form={form} label="主播每日上限" name="anchor_daily_payout_cap" onChange={onChange} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ConfigMetric({ label, value }) {
|
function ConfigMetric({ label, value }) {
|
||||||
return (
|
return (
|
||||||
<article className="ops-config-metric">
|
<article className="ops-config-metric">
|
||||||
<span>{label}</span>
|
<span>{label}</span>
|
||||||
<strong>{value}</strong>
|
<strong>{value}</strong>
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function NumberField({ form, label, name, onChange, step = "1" }) {
|
function NumberField({ form, helperText, label, name, onChange, step = "1" }) {
|
||||||
return (
|
return (
|
||||||
<TextField
|
<TextField
|
||||||
label={label}
|
helperText={helperText}
|
||||||
required
|
label={label}
|
||||||
size="small"
|
required
|
||||||
slotProps={{ htmlInput: { min: 0, step } }}
|
size="small"
|
||||||
type="number"
|
slotProps={{ htmlInput: { min: 0, step } }}
|
||||||
value={form[name]}
|
type="number"
|
||||||
onChange={(event) => onChange(name, event.target.value)}
|
value={form[name]}
|
||||||
/>
|
onChange={(event) => onChange(name, event.target.value)}
|
||||||
);
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,158 +1,458 @@
|
|||||||
import { stageOptions } from "@/features/lucky-gift/constants.js";
|
import { stageOptions } from "@/features/lucky-gift/constants.js";
|
||||||
import {
|
import {
|
||||||
applyProbabilityMap,
|
applyProbabilityMap,
|
||||||
correctRTPProbabilities,
|
correctRTPProbabilities,
|
||||||
decimal,
|
decimal,
|
||||||
expectedRTPPercent,
|
expectedRTPPercent,
|
||||||
formatDecimal,
|
formatDecimal,
|
||||||
probabilityTotal
|
probabilityTotal,
|
||||||
} from "@/shared/utils/rtpProbability.js";
|
} from "@/shared/utils/rtpProbability.js";
|
||||||
import { DEFAULT_POOL_ID } from "./api.js";
|
import { DEFAULT_POOL_ID } from "./api.js";
|
||||||
|
|
||||||
// 幸运礼物配置表单的纯领域逻辑:配置 <-> 表单换算、概率闭合校正、阶段摘要。
|
const DYNAMIC_STRATEGY = "dynamic_v3";
|
||||||
// 与展示层分离,弹窗组件只负责渲染和事件绑定。
|
const PPM_SCALE = 1_000_000;
|
||||||
|
|
||||||
|
// 只有跨 App 通用的算法参数才有默认值;50 美元等值门槛和六维金额上限必须由运营按 App 金币口径填写,
|
||||||
|
// 因而默认保持 0 且规则停用,避免把某个 App 的预算误发到其他 App。
|
||||||
|
const dynamicDefaults = {
|
||||||
|
anchor_daily_payout_cap: 0,
|
||||||
|
anchor_rate_percent: 1,
|
||||||
|
control_band_percent: 3,
|
||||||
|
device_daily_payout_cap: 0,
|
||||||
|
high_water_nonzero_factor_percent: 130,
|
||||||
|
high_watermark_coins: 20_000_000,
|
||||||
|
initial_pool_coins: 1_000_000,
|
||||||
|
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,
|
||||||
|
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: 0,
|
||||||
|
pool_rate_percent: 98,
|
||||||
|
profit_rate_percent: 1,
|
||||||
|
recharge_boost_factor_percent: 110,
|
||||||
|
recharge_boost_window_ms: 300_000,
|
||||||
|
room_hourly_payout_cap: 0,
|
||||||
|
target_rtp_percent: 98,
|
||||||
|
user_daily_payout_cap: 0,
|
||||||
|
user_hourly_payout_cap: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const numericFields = [
|
||||||
|
"anchor_daily_payout_cap",
|
||||||
|
"anchor_rate_percent",
|
||||||
|
"control_band_percent",
|
||||||
|
"device_daily_payout_cap",
|
||||||
|
"effective_from_ms",
|
||||||
|
"gift_price_reference",
|
||||||
|
"high_water_nonzero_factor_percent",
|
||||||
|
"high_watermark_coins",
|
||||||
|
"initial_pool_coins",
|
||||||
|
"jackpot_global_rtp_max_percent",
|
||||||
|
"jackpot_spend_threshold_coins",
|
||||||
|
"jackpot_user_72h_rtp_max_percent",
|
||||||
|
"jackpot_user_day_rtp_max_percent",
|
||||||
|
"loss_streak_guarantee",
|
||||||
|
"low_water_nonzero_factor_percent",
|
||||||
|
"low_watermark_coins",
|
||||||
|
"max_jackpot_hits_per_user_day",
|
||||||
|
"max_single_payout",
|
||||||
|
"normal_max_equivalent_draws",
|
||||||
|
"novice_max_equivalent_draws",
|
||||||
|
"pool_rate_percent",
|
||||||
|
"profit_rate_percent",
|
||||||
|
"recharge_boost_factor_percent",
|
||||||
|
"recharge_boost_window_ms",
|
||||||
|
"room_hourly_payout_cap",
|
||||||
|
"settlement_window_wager",
|
||||||
|
"target_rtp_percent",
|
||||||
|
"user_daily_payout_cap",
|
||||||
|
"user_hourly_payout_cap",
|
||||||
|
];
|
||||||
|
|
||||||
|
const payoutCapFields = [
|
||||||
|
["max_single_payout", "单次返奖上限"],
|
||||||
|
["user_hourly_payout_cap", "用户小时上限"],
|
||||||
|
["user_daily_payout_cap", "用户每日上限"],
|
||||||
|
["device_daily_payout_cap", "设备每日上限"],
|
||||||
|
["room_hourly_payout_cap", "房间小时上限"],
|
||||||
|
["anchor_daily_payout_cap", "主播每日上限"],
|
||||||
|
];
|
||||||
|
|
||||||
export function configToForm(config = {}) {
|
export function configToForm(config = {}) {
|
||||||
const targetRTPPercent = formNumber(config.target_rtp_percent ?? config.targetRtpPercent);
|
const hasPersistedShape = Object.keys(config).length > 0;
|
||||||
return {
|
const strategyVersion = stringConfigValue(
|
||||||
app_code: config.app_code || config.appCode || "",
|
config,
|
||||||
control_band_percent: formNumber(config.control_band_percent ?? config.controlBandPercent),
|
"strategy_version",
|
||||||
enabled: Boolean(config.enabled),
|
"strategyVersion",
|
||||||
gift_price_reference: formNumber(config.gift_price_reference ?? config.giftPriceReference),
|
hasPersistedShape ? "fixed_v2" : DYNAMIC_STRATEGY,
|
||||||
pool_id: config.pool_id || config.poolId || DEFAULT_POOL_ID,
|
);
|
||||||
pool_rate_percent: formNumber(config.pool_rate_percent ?? config.poolRatePercent),
|
const targetRTPPercent = formConfigNumber(
|
||||||
settlement_window_wager: formNumber(config.settlement_window_wager ?? config.settlementWindowWager),
|
config,
|
||||||
stages: normalizeFormStages(config.stages, targetRTPPercent),
|
"target_rtp_percent",
|
||||||
target_rtp_percent: targetRTPPercent
|
"targetRtpPercent",
|
||||||
};
|
strategyVersion === DYNAMIC_STRATEGY ? dynamicDefaults.target_rtp_percent : 95,
|
||||||
|
);
|
||||||
|
const form = {
|
||||||
|
app_code: config.app_code || config.appCode || "",
|
||||||
|
enabled: Boolean(config.enabled),
|
||||||
|
jackpot_multipliers: multiplierListToForm(
|
||||||
|
config.jackpot_multipliers ?? config.jackpotMultipliers,
|
||||||
|
strategyVersion,
|
||||||
|
),
|
||||||
|
pool_id: config.pool_id || config.poolId || DEFAULT_POOL_ID,
|
||||||
|
stages: normalizeFormStages(config.stages, targetRTPPercent, strategyVersion),
|
||||||
|
strategy_version: strategyVersion,
|
||||||
|
};
|
||||||
|
for (const field of numericFields) {
|
||||||
|
if (field === "target_rtp_percent") {
|
||||||
|
form[field] = targetRTPPercent;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
form[field] = formConfigNumber(config, field, snakeToCamel(field), defaultNumber(field, strategyVersion));
|
||||||
|
}
|
||||||
|
return form;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formToConfig(config, form) {
|
export function formToConfig(config, form) {
|
||||||
return {
|
const output = {
|
||||||
...config,
|
...config,
|
||||||
app_code: form.app_code,
|
app_code: form.app_code,
|
||||||
control_band_percent: numberFromForm(form.control_band_percent),
|
enabled: Boolean(form.enabled),
|
||||||
enabled: Boolean(form.enabled),
|
jackpot_multipliers: multiplierListFromForm(form.jackpot_multipliers),
|
||||||
gift_price_reference: numberFromForm(form.gift_price_reference),
|
pool_id: form.pool_id,
|
||||||
pool_id: form.pool_id,
|
stages: (form.stages || []).map((stage) => ({
|
||||||
pool_rate_percent: numberFromForm(form.pool_rate_percent),
|
min_recharge_30d_coins: numberFromForm(stage.min_recharge_30d_coins),
|
||||||
settlement_window_wager: numberFromForm(form.settlement_window_wager),
|
min_recharge_7d_coins: numberFromForm(stage.min_recharge_7d_coins),
|
||||||
stages: form.stages,
|
stage: stage.stage,
|
||||||
target_rtp_percent: numberFromForm(form.target_rtp_percent)
|
tiers: (stage.tiers || []).map((tier) => ({
|
||||||
};
|
enabled: tier.enabled !== false,
|
||||||
|
high_water_only: Boolean(tier.highWaterOnly),
|
||||||
|
multiplier: numberFromForm(tier.multiplier),
|
||||||
|
probability_percent: numberFromForm(tier.probabilityPercent),
|
||||||
|
tier_id: tier.tierId || "",
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
strategy_version: form.strategy_version,
|
||||||
|
};
|
||||||
|
for (const field of numericFields) {
|
||||||
|
output[field] = numberFromForm(form[field]);
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从历史 fixed_v2 显式切换到 dynamic_v3 时,先停用并载入安全草稿;金额型上限仍保持 0,
|
||||||
|
// 运营补齐后才能通过启用校验,避免一次策略切换直接发布无法结算的规则。
|
||||||
|
export function applyDynamicStrategyDefaults(form) {
|
||||||
|
const next = {
|
||||||
|
...form,
|
||||||
|
enabled: false,
|
||||||
|
jackpot_multipliers: dynamicDefaults.jackpot_multipliers.join(", "),
|
||||||
|
strategy_version: DYNAMIC_STRATEGY,
|
||||||
|
};
|
||||||
|
for (const [field, value] of Object.entries(dynamicDefaults)) {
|
||||||
|
if (field !== "jackpot_multipliers") {
|
||||||
|
next[field] = String(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
next.stages = stageOptions.map(([stage]) => {
|
||||||
|
const [min7d, min30d] = defaultRechargeThreshold(stage);
|
||||||
|
return {
|
||||||
|
min_recharge_7d_coins: String(min7d),
|
||||||
|
min_recharge_30d_coins: String(min30d),
|
||||||
|
stage,
|
||||||
|
// fixed_v2 可能含历史高倍基础档;切换策略时必须回到 owner 的 98% 安全基线,
|
||||||
|
// 不能只改策略名后把旧概率意外带入 dynamic_v3 的第一版。
|
||||||
|
tiers: correctedDefaultTiers(stage, dynamicDefaults.target_rtp_percent),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 概率列必须整体闭合到 100% 且期望 RTP 逼近目标值,所以任何倍率/目标 RTP 改动后都重算全阶段概率,
|
// 概率列必须整体闭合到 100% 且期望 RTP 逼近目标值,所以任何倍率/目标 RTP 改动后都重算全阶段概率,
|
||||||
// 不允许运营手工填概率绕过服务端的闭合校验。
|
// 不允许运营手工填概率绕过服务端的闭合校验。
|
||||||
export function correctAllStageProbabilities(form) {
|
export function correctAllStageProbabilities(form) {
|
||||||
return {
|
return {
|
||||||
...form,
|
...form,
|
||||||
stages: (form.stages || []).map((stage) => {
|
stages: (form.stages || []).map((stage) => {
|
||||||
const probabilityByIndex = correctRTPProbabilities(stage.tiers, form.target_rtp_percent, {
|
const probabilityByIndex = correctRTPProbabilities(stage.tiers, form.target_rtp_percent, {
|
||||||
multiplier: (item) => item.multiplier
|
multiplier: (item) => item.multiplier,
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
...stage,
|
...stage,
|
||||||
tiers: probabilityByIndex ? applyProbabilityMap(stage.tiers, probabilityByIndex, formatDecimal) : stage.tiers
|
tiers: probabilityByIndex
|
||||||
};
|
? applyProbabilityMap(stage.tiers, probabilityByIndex, formatDecimal)
|
||||||
})
|
: stage.tiers,
|
||||||
};
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function stageSummary(stage, form) {
|
export function stageSummary(stage, form) {
|
||||||
const probability = probabilityTotal(stage.tiers);
|
const probability = probabilityTotal(stage.tiers);
|
||||||
const expected = expectedRTPPercent(stage.tiers, {
|
const expected = expectedRTPPercent(stage.tiers, { multiplier: (item) => item.multiplier });
|
||||||
multiplier: (item) => item.multiplier
|
const target = Number(form.target_rtp_percent || 0);
|
||||||
});
|
const band = Number(form.control_band_percent || 0);
|
||||||
const target = Number(form.target_rtp_percent || 0);
|
const probabilityClosed = Math.abs(probability - 100) < 0.0001;
|
||||||
const band = Number(form.control_band_percent || 0);
|
const rtpInBand = expected >= target - band && expected <= target + band;
|
||||||
const probabilityClosed = Math.abs(probability - 100) < 0.0001;
|
const fullLabel = stageOptions.find(([key]) => key === stage.stage)?.[1] || stage.stage;
|
||||||
const rtpInBand = expected >= target - band && expected <= target + band;
|
return {
|
||||||
const fullLabel = stageOptions.find(([key]) => key === stage.stage)?.[1] || stage.stage;
|
expected,
|
||||||
|
ok: probabilityClosed && rtpInBand,
|
||||||
|
probability,
|
||||||
|
probabilityClosed,
|
||||||
|
rtpInBand,
|
||||||
|
stageKey: stage.stage,
|
||||||
|
stageLabel: fullLabel,
|
||||||
|
status: probabilityClosed && rtpInBand ? "可发布" : probabilityClosed ? "RTP 偏离" : "概率未闭合",
|
||||||
|
tabLabel: stage.stage === "normal" ? "普通" : fullLabel.replace(/阶段$/, ""),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
export function validateLuckyGiftForm(form) {
|
||||||
expected,
|
const errors = [];
|
||||||
ok: probabilityClosed && rtpInBand,
|
const number = (field) => numberFromForm(form[field]);
|
||||||
probability,
|
if (!String(form.app_code || "").trim()) errors.push("请选择应用");
|
||||||
probabilityClosed,
|
if (!String(form.pool_id || "").trim()) errors.push("奖池不能为空");
|
||||||
rtpInBand,
|
if (!["fixed_v2", DYNAMIC_STRATEGY].includes(form.strategy_version)) errors.push("策略版本不受支持");
|
||||||
stageKey: stage.stage,
|
if (number("target_rtp_percent") < 10 || number("target_rtp_percent") > 99)
|
||||||
stageLabel: fullLabel,
|
errors.push("目标 RTP 必须在 10%-99% 之间");
|
||||||
// 发布按钮不做硬拦截(服务端仍会校验),这里的状态只用于提示运营当前阶段是否可安全发布。
|
if (number("pool_rate_percent") < number("target_rtp_percent") || number("pool_rate_percent") > 100)
|
||||||
status: probabilityClosed && rtpInBand ? "可发布" : probabilityClosed ? "RTP 偏离" : "概率未闭合",
|
errors.push("奖池比例必须在目标 RTP 与 100% 之间");
|
||||||
tabLabel: stage.stage === "normal" ? "普通" : fullLabel.replace(/阶段$/, "")
|
if (number("control_band_percent") <= 0 || number("control_band_percent") > 5)
|
||||||
};
|
errors.push("控制带宽必须大于 0 且不超过 5%");
|
||||||
|
if (number("settlement_window_wager") <= 0) errors.push("结算窗口流水必须大于 0");
|
||||||
|
if (number("gift_price_reference") <= 0) errors.push("礼物参考金额必须大于 0");
|
||||||
|
if (
|
||||||
|
number("novice_max_equivalent_draws") < 0 ||
|
||||||
|
number("normal_max_equivalent_draws") < number("novice_max_equivalent_draws")
|
||||||
|
)
|
||||||
|
errors.push("等价抽数阶段门槛不正确");
|
||||||
|
|
||||||
|
// 阶段概率与 RTP 是两代策略共用的 owner 校验,fixed_v2 也不能跳过。
|
||||||
|
for (const stageKey of ["novice", "normal", "advanced"]) {
|
||||||
|
const stage = form.stages?.find((item) => item.stage === stageKey);
|
||||||
|
if (!stage) {
|
||||||
|
errors.push("必须配置新手、正常和高阶三个阶段");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const enabled = stage.tiers.filter((tier) => tier.enabled !== false);
|
||||||
|
if (enabled.some((tier) => numberFromForm(tier.multiplier) < 0))
|
||||||
|
errors.push(`${stageLabel(stageKey)}倍率不能小于 0`);
|
||||||
|
const probabilityPPM = enabled.reduce((sum, tier) => sum + percentToPPM(tier.probabilityPercent), 0);
|
||||||
|
if (probabilityPPM !== PPM_SCALE) errors.push(`${stageLabel(stageKey)}启用奖档概率必须合计 100%`);
|
||||||
|
if (!enabled.some((tier) => Number(tier.multiplier) === 0))
|
||||||
|
errors.push(`${stageLabel(stageKey)}必须包含启用的 0x 档`);
|
||||||
|
if (enabled.some((tier) => Number(tier.probabilityPercent) <= 0))
|
||||||
|
errors.push(`${stageLabel(stageKey)}启用奖档概率必须大于 0`);
|
||||||
|
if (enabled.some((tier) => Number(tier.multiplier) >= 10 && !tier.highWaterOnly))
|
||||||
|
errors.push(`${stageLabel(stageKey)}10x 及以上奖档必须限制为高水位`);
|
||||||
|
const expected = expectedRTPPercent(enabled, { multiplier: (item) => item.multiplier });
|
||||||
|
if (
|
||||||
|
expected < number("target_rtp_percent") - number("control_band_percent") ||
|
||||||
|
expected > number("target_rtp_percent") + number("control_band_percent")
|
||||||
|
)
|
||||||
|
errors.push(`${stageLabel(stageKey)}期望 RTP 超出控制带宽`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (form.strategy_version !== DYNAMIC_STRATEGY) return unique(errors);
|
||||||
|
|
||||||
|
if (number("profit_rate_percent") < 0 || number("anchor_rate_percent") < 0)
|
||||||
|
errors.push("平台盈利和主播收益比例不能小于 0");
|
||||||
|
if (
|
||||||
|
percentToPPM(number("pool_rate_percent")) +
|
||||||
|
percentToPPM(number("profit_rate_percent")) +
|
||||||
|
percentToPPM(number("anchor_rate_percent")) !==
|
||||||
|
PPM_SCALE
|
||||||
|
)
|
||||||
|
errors.push("奖池、平台盈利和主播收益比例合计必须等于 100%");
|
||||||
|
if (number("initial_pool_coins") <= 0) errors.push("初始奖池必须大于 0");
|
||||||
|
if (number("loss_streak_guarantee") <= 0) errors.push("连续未中奖保护次数必须大于 0");
|
||||||
|
if (number("low_watermark_coins") <= 0 || number("high_watermark_coins") <= number("low_watermark_coins"))
|
||||||
|
errors.push("高水位必须大于正数低水位");
|
||||||
|
if (number("low_water_nonzero_factor_percent") <= 0 || number("low_water_nonzero_factor_percent") >= 100)
|
||||||
|
errors.push("低水位非零因子必须大于 0% 且小于 100%");
|
||||||
|
if (number("high_water_nonzero_factor_percent") <= 100) errors.push("高水位非零因子必须大于 100%");
|
||||||
|
if (number("recharge_boost_window_ms") <= 0 || number("recharge_boost_factor_percent") <= 100)
|
||||||
|
errors.push("充值加权窗口必须大于 0 且因子必须大于 100%");
|
||||||
|
const jackpotTokens = String(form.jackpot_multipliers || "")
|
||||||
|
.split(/[,,\s]+/)
|
||||||
|
.filter(Boolean);
|
||||||
|
const jackpotMultipliers = multiplierListFromForm(form.jackpot_multipliers);
|
||||||
|
if (
|
||||||
|
jackpotTokens.length !== jackpotMultipliers.length ||
|
||||||
|
!jackpotMultipliers.length ||
|
||||||
|
jackpotMultipliers.some((value, index) => value <= 1 || (index > 0 && value <= jackpotMultipliers[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")
|
||||||
|
)
|
||||||
|
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")
|
||||||
|
)
|
||||||
|
errors.push("用户 72 小时 RTP 上限必须大于 0 且不超过全局上限");
|
||||||
|
if (number("max_jackpot_hits_per_user_day") <= 0) errors.push("用户每日大奖次数必须大于 0");
|
||||||
|
if (number("jackpot_spend_threshold_coins") < 0) errors.push("大奖消费门槛不能小于 0");
|
||||||
|
for (const [field, label] of payoutCapFields) {
|
||||||
|
if (number(field) < 0) errors.push(`${label}不能小于 0`);
|
||||||
|
}
|
||||||
|
validateRechargeStages(form.stages || [], errors);
|
||||||
|
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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return unique(errors);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function nextStageMultiplier(tiers = []) {
|
export function nextStageMultiplier(tiers = []) {
|
||||||
const positive = tiers.map((tier) => decimal(tier.multiplier)).filter((value) => value > 0);
|
const positive = tiers.map((tier) => decimal(tier.multiplier)).filter((value) => value > 0);
|
||||||
if (!positive.length) {
|
if (!positive.length) return 1;
|
||||||
return 1;
|
const max = Math.max(...positive);
|
||||||
}
|
if (max >= 5) return max * 2;
|
||||||
const max = Math.max(...positive);
|
if (max >= 2) return 5;
|
||||||
if (max >= 5) {
|
return max + 1;
|
||||||
return max * 2;
|
|
||||||
}
|
|
||||||
if (max >= 2) {
|
|
||||||
return 5;
|
|
||||||
}
|
|
||||||
return max + 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeFormStages(stages, targetRTPPercent = 95) {
|
function normalizeFormStages(stages, targetRTPPercent, strategyVersion) {
|
||||||
const byStage = new Map((Array.isArray(stages) ? stages : []).map((stage) => [stage.stage, stage]));
|
const byStage = new Map((Array.isArray(stages) ? stages : []).map((stage) => [stage.stage, stage]));
|
||||||
return correctAllStageProbabilities({
|
return stageOptions.map(([stageKey]) => {
|
||||||
stages: stageOptions.map(([stageKey]) => {
|
const source = byStage.get(stageKey);
|
||||||
const source = byStage.get(stageKey);
|
const sourceTiers =
|
||||||
const sourceTiers = Array.isArray(source?.tiers) && source.tiers.length ? source.tiers : defaultStageTiers(stageKey);
|
Array.isArray(source?.tiers) && source.tiers.length
|
||||||
return {
|
? source.tiers
|
||||||
stage: stageKey,
|
: correctedDefaultTiers(stageKey, targetRTPPercent);
|
||||||
tiers: sourceTiers.map((tier) => ({
|
const [default7d, default30d] =
|
||||||
enabled: tier.enabled !== false,
|
strategyVersion === DYNAMIC_STRATEGY ? defaultRechargeThreshold(stageKey) : [0, 0];
|
||||||
highWaterOnly: Boolean(tier.high_water_only ?? tier.highWaterOnly),
|
return {
|
||||||
multiplier: formNumber(tier.multiplier),
|
min_recharge_7d_coins: formNumber(source?.min_recharge_7d_coins ?? source?.minRecharge7DCoins ?? default7d),
|
||||||
probabilityPercent: formNumber(tier.probability_percent ?? tier.probabilityPercent),
|
min_recharge_30d_coins: formNumber(
|
||||||
tierId: tier.tier_id || tier.tierId || ""
|
source?.min_recharge_30d_coins ?? source?.minRecharge30DCoins ?? default30d,
|
||||||
}))
|
),
|
||||||
};
|
stage: stageKey,
|
||||||
}),
|
tiers: sourceTiers.map((tier) => ({
|
||||||
target_rtp_percent: targetRTPPercent
|
enabled: tier.enabled !== false,
|
||||||
}).stages;
|
highWaterOnly: Boolean(tier.high_water_only ?? tier.highWaterOnly),
|
||||||
|
multiplier: formNumber(tier.multiplier),
|
||||||
|
probabilityPercent: formNumber(tier.probability_percent ?? tier.probabilityPercent),
|
||||||
|
tierId: tier.tier_id || tier.tierId || "",
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function defaultStageTiers(stage) {
|
function correctedDefaultTiers(stage, targetRTPPercent) {
|
||||||
if (stage === "advanced") {
|
const tiers = [
|
||||||
return [
|
defaultTier(`${stage}_none`, 0, 5),
|
||||||
defaultTier("advanced_none", 0, 27),
|
defaultTier(`${stage}_0_5x`, 0.5, 4),
|
||||||
defaultTier("advanced_1x", 1, 60),
|
defaultTier(`${stage}_1x`, 1, 86),
|
||||||
defaultTier("advanced_2x", 2, 10),
|
defaultTier(`${stage}_2x`, 2, 5),
|
||||||
defaultTier("advanced_5x", 5, 3, { high_water_only: true })
|
|
||||||
];
|
];
|
||||||
}
|
// dynamic_v3 owner 的 98% 安全基线不只是数学 RTP,还规定了 0x/0.5x/1x/2x 的体验节奏。
|
||||||
return [
|
// 这组 5/4/86/5 本身已精确达到 98%,不能再交给无 baseline 的通用求解器重排成另一组同 RTP 概率。
|
||||||
defaultTier(`${stage}_none`, 0, 10),
|
if (Math.abs(numberFromForm(targetRTPPercent) - dynamicDefaults.target_rtp_percent) < 0.0001) {
|
||||||
defaultTier(`${stage}_0_5x`, 0.5, 20),
|
return tiers;
|
||||||
defaultTier(`${stage}_1x`, 1, 55),
|
}
|
||||||
defaultTier(`${stage}_2x`, 2, 15)
|
const probabilities = correctRTPProbabilities(tiers, targetRTPPercent, { multiplier: (item) => item.multiplier });
|
||||||
];
|
return probabilities ? applyProbabilityMap(tiers, probabilities, formatDecimal) : tiers;
|
||||||
}
|
}
|
||||||
|
|
||||||
function defaultTier(tierID, multiplier, probabilityPercent, options = {}) {
|
function defaultTier(tierID, multiplier, probabilityPercent) {
|
||||||
return {
|
return { enabled: true, highWaterOnly: false, multiplier, probabilityPercent, tierId: tierID };
|
||||||
enabled: true,
|
}
|
||||||
highWaterOnly: Boolean(options.high_water_only),
|
|
||||||
multiplier,
|
function validateRechargeStages(stages, errors) {
|
||||||
probabilityPercent,
|
const novice = stages.find((stage) => stage.stage === "novice");
|
||||||
tierId: tierID
|
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");
|
||||||
|
const advanced30d = threshold(advanced, "min_recharge_30d_coins");
|
||||||
|
if ([normal7d, normal30d, advanced7d, advanced30d].some((value) => value < 0)) {
|
||||||
|
errors.push("充值阶段门槛不能小于 0");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (normal7d === 0 && normal30d === 0) errors.push("正常阶段至少一个充值门槛必须大于 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 defaultNumber(field, strategyVersion) {
|
||||||
|
if (field === "gift_price_reference") return 100;
|
||||||
|
if (field === "settlement_window_wager") return 1_000_000;
|
||||||
|
if (field === "novice_max_equivalent_draws") return 2_000;
|
||||||
|
if (field === "normal_max_equivalent_draws") return 20_000;
|
||||||
|
if (field === "effective_from_ms") return 0;
|
||||||
|
return strategyVersion === DYNAMIC_STRATEGY ? (dynamicDefaults[field] ?? 0) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function multiplierListToForm(value, strategyVersion) {
|
||||||
|
if (Array.isArray(value) && value.length) return value.map(formatDecimal).join(", ");
|
||||||
|
if (typeof value === "string" && value.trim()) return value;
|
||||||
|
return strategyVersion === DYNAMIC_STRATEGY ? dynamicDefaults.jackpot_multipliers.join(", ") : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function multiplierListFromForm(value) {
|
||||||
|
return String(value || "")
|
||||||
|
.split(/[,,\s]+/)
|
||||||
|
.filter(Boolean)
|
||||||
|
.map(Number)
|
||||||
|
.filter(Number.isFinite);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringConfigValue(config, snake, camel, fallback) {
|
||||||
|
const value = config[snake] ?? config[camel];
|
||||||
|
return String(value || fallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formConfigNumber(config, snake, camel, fallback) {
|
||||||
|
return formNumber(config[snake] ?? config[camel] ?? fallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
function snakeToCamel(value) {
|
||||||
|
return value.replace(/_([a-z0-9])/g, (_, letter) => letter.toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
function stageLabel(stage) {
|
||||||
|
return stageOptions.find(([key]) => key === stage)?.[1] || stage;
|
||||||
|
}
|
||||||
|
|
||||||
|
function percentToPPM(value) {
|
||||||
|
return Math.round(Number(value || 0) * 10_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function unique(values) {
|
||||||
|
return Array.from(new Set(values));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formNumber(value) {
|
export function formNumber(value) {
|
||||||
const parsed = Number(value);
|
const parsed = Number(value);
|
||||||
return Number.isFinite(parsed) ? String(parsed) : "0";
|
return Number.isFinite(parsed) ? String(parsed) : "0";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function numberFromForm(value) {
|
export function numberFromForm(value) {
|
||||||
const parsed = Number(value);
|
const parsed = Number(value);
|
||||||
return Number.isFinite(parsed) ? parsed : 0;
|
return Number.isFinite(parsed) ? parsed : 0;
|
||||||
}
|
}
|
||||||
|
|||||||
217
ops-center/src/configForm.test.js
Normal file
217
ops-center/src/configForm.test.js
Normal file
@ -0,0 +1,217 @@
|
|||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
import { applyDynamicStrategyDefaults, configToForm, formToConfig, validateLuckyGiftForm } from "./configForm.js";
|
||||||
|
|
||||||
|
describe("ops center lucky gift dynamic_v3 form", () => {
|
||||||
|
test("loads safe dynamic defaults without inventing app-specific monetary caps", () => {
|
||||||
|
const form = configToForm({ app_code: "lalu", pool_id: "default", strategy_version: "dynamic_v3" });
|
||||||
|
|
||||||
|
expect(form).toMatchObject({
|
||||||
|
anchor_rate_percent: "1",
|
||||||
|
enabled: false,
|
||||||
|
initial_pool_coins: "1000000",
|
||||||
|
jackpot_multipliers: "200, 500, 1000",
|
||||||
|
max_single_payout: "0",
|
||||||
|
pool_rate_percent: "98",
|
||||||
|
profit_rate_percent: "1",
|
||||||
|
strategy_version: "dynamic_v3",
|
||||||
|
target_rtp_percent: "98",
|
||||||
|
});
|
||||||
|
expect(form.stages.map((stage) => [stage.min_recharge_7d_coins, stage.min_recharge_30d_coins])).toEqual([
|
||||||
|
["0", "0"],
|
||||||
|
["0", "1"],
|
||||||
|
["1", "1"],
|
||||||
|
]);
|
||||||
|
expect(form.stages.map((stage) => stage.tiers.map((tier) => Number(tier.probabilityPercent)))).toEqual([
|
||||||
|
[5, 4, 86, 5],
|
||||||
|
[5, 4, 86, 5],
|
||||||
|
[5, 4, 86, 5],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("round-trips every dynamic strategy field and canonical stage keys", () => {
|
||||||
|
const config = completeDynamicConfig();
|
||||||
|
// 使用空原对象可证明每个字段都真正经过 formToConfig 写回;若沿用 config 作为 spread 基底,
|
||||||
|
// 漏掉的动态字段也会被旧值掩盖,测试无法发现发布 payload 的字段丢失。
|
||||||
|
const output = formToConfig({}, configToForm(config));
|
||||||
|
|
||||||
|
expect(output).toEqual(config);
|
||||||
|
expect(output.stages[1]).toMatchObject({
|
||||||
|
min_recharge_30d_coins: 20_000,
|
||||||
|
min_recharge_7d_coins: 10_000,
|
||||||
|
stage: "normal",
|
||||||
|
});
|
||||||
|
expect(output.stages[1].tiers[0]).toEqual({
|
||||||
|
enabled: true,
|
||||||
|
high_water_only: false,
|
||||||
|
multiplier: 0,
|
||||||
|
probability_percent: 5,
|
||||||
|
tier_id: "normal_none",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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([]);
|
||||||
|
|
||||||
|
const enabled = { ...draft, enabled: true };
|
||||||
|
const errors = validateLuckyGiftForm(enabled);
|
||||||
|
|
||||||
|
expect(errors).toContain("启用前必须填写 50 美元等值的大奖消费门槛");
|
||||||
|
expect(errors).toContain("启用前必须填写单次返奖上限");
|
||||||
|
expect(errors).toContain("启用前必须填写主播每日上限");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps fixed_v2 readable and does not apply dynamic-only validation", () => {
|
||||||
|
const form = configToForm({
|
||||||
|
app_code: "yumi",
|
||||||
|
control_band_percent: 1,
|
||||||
|
enabled: true,
|
||||||
|
gift_price_reference: 100,
|
||||||
|
normal_max_equivalent_draws: 20_000,
|
||||||
|
novice_max_equivalent_draws: 2_000,
|
||||||
|
pool_id: "legacy",
|
||||||
|
pool_rate_percent: 96,
|
||||||
|
settlement_window_wager: 1_000_000,
|
||||||
|
stages: fixedStages(),
|
||||||
|
strategy_version: "fixed_v2",
|
||||||
|
target_rtp_percent: 95,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(form.strategy_version).toBe("fixed_v2");
|
||||||
|
expect(validateLuckyGiftForm(form)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("switching a legacy form to dynamic creates a disabled safe draft", () => {
|
||||||
|
const legacy = configToForm({
|
||||||
|
app_code: "yumi",
|
||||||
|
control_band_percent: 1,
|
||||||
|
enabled: true,
|
||||||
|
gift_price_reference: 100,
|
||||||
|
normal_max_equivalent_draws: 20_000,
|
||||||
|
novice_max_equivalent_draws: 2_000,
|
||||||
|
pool_id: "legacy",
|
||||||
|
pool_rate_percent: 96,
|
||||||
|
settlement_window_wager: 1_000_000,
|
||||||
|
stages: fixedStages(),
|
||||||
|
strategy_version: "fixed_v2",
|
||||||
|
target_rtp_percent: 95,
|
||||||
|
});
|
||||||
|
const dynamic = applyDynamicStrategyDefaults(legacy);
|
||||||
|
|
||||||
|
expect(dynamic).toMatchObject({ enabled: false, 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],
|
||||||
|
[0, 0.5, 1, 2],
|
||||||
|
]);
|
||||||
|
expect(dynamic.stages.map((stage) => stage.tiers.map((tier) => Number(tier.probabilityPercent)))).toEqual([
|
||||||
|
[5, 4, 86, 5],
|
||||||
|
[5, 4, 86, 5],
|
||||||
|
[5, 4, 86, 5],
|
||||||
|
]);
|
||||||
|
expect(validateLuckyGiftForm(dynamic)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects invalid disabled draft values that the owner never accepts", () => {
|
||||||
|
const draft = configToForm({ app_code: "lalu", pool_id: "default", strategy_version: "dynamic_v3" });
|
||||||
|
const errors = validateLuckyGiftForm({
|
||||||
|
...draft,
|
||||||
|
jackpot_spend_threshold_coins: "-1",
|
||||||
|
max_single_payout: "-1",
|
||||||
|
profit_rate_percent: "-1",
|
||||||
|
anchor_rate_percent: "2",
|
||||||
|
stages: draft.stages.map((stage) =>
|
||||||
|
stage.stage === "normal" ? { ...stage, min_recharge_7d_coins: "-1" } : stage,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(errors).toContain("平台盈利和主播收益比例不能小于 0");
|
||||||
|
expect(errors).toContain("大奖消费门槛不能小于 0");
|
||||||
|
expect(errors).toContain("单次返奖上限不能小于 0");
|
||||||
|
expect(errors).toContain("充值阶段门槛不能小于 0");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function completeDynamicConfig() {
|
||||||
|
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: 0,
|
||||||
|
enabled: true,
|
||||||
|
gift_price_reference: 100,
|
||||||
|
high_water_nonzero_factor_percent: 130,
|
||||||
|
high_watermark_coins: 20_000_000,
|
||||||
|
initial_pool_coins: 1_000_000,
|
||||||
|
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: "default",
|
||||||
|
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,
|
||||||
|
settlement_window_wager: 1_000_000,
|
||||||
|
stages: dynamicStages(),
|
||||||
|
strategy_version: "dynamic_v3",
|
||||||
|
target_rtp_percent: 98,
|
||||||
|
user_daily_payout_cap: 3_000_000,
|
||||||
|
user_hourly_payout_cap: 2_000_000,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function dynamicStages() {
|
||||||
|
return [
|
||||||
|
dynamicStage("novice", 0, 0),
|
||||||
|
dynamicStage("normal", 10_000, 20_000),
|
||||||
|
dynamicStage("advanced", 20_000, 30_000),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function dynamicStage(stage, min7d, min30d) {
|
||||||
|
return {
|
||||||
|
min_recharge_30d_coins: min30d,
|
||||||
|
min_recharge_7d_coins: min7d,
|
||||||
|
stage,
|
||||||
|
tiers: [
|
||||||
|
tier(`${stage}_none`, 0, 5),
|
||||||
|
tier(`${stage}_0_5x`, 0.5, 4),
|
||||||
|
tier(`${stage}_1x`, 1, 86),
|
||||||
|
tier(`${stage}_2x`, 2, 5),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fixedStages() {
|
||||||
|
return ["novice", "normal", "advanced"].map((stage) => ({
|
||||||
|
stage,
|
||||||
|
tiers: [
|
||||||
|
tier(`${stage}_none`, 0, 10),
|
||||||
|
tier(`${stage}_0_5x`, 0.5, 20),
|
||||||
|
tier(`${stage}_1x`, 1, 55),
|
||||||
|
tier(`${stage}_2x`, 2, 15),
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function tier(tierId, multiplier, probabilityPercent) {
|
||||||
|
return {
|
||||||
|
enabled: true,
|
||||||
|
high_water_only: false,
|
||||||
|
multiplier,
|
||||||
|
probability_percent: probabilityPercent,
|
||||||
|
tier_id: tierId,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -1,407 +1,440 @@
|
|||||||
:root {
|
:root {
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
background: var(--bg-page);
|
background: var(--bg-page);
|
||||||
font-family: var(--font-system);
|
font-family: var(--font-system);
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
min-width: 320px;
|
min-width: 320px;
|
||||||
background: var(--bg-page);
|
background: var(--bg-page);
|
||||||
}
|
}
|
||||||
|
|
||||||
#ops-center-root {
|
#ops-center-root {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- 应用外壳:左侧固定导航 + 内容区,宽度对齐主后台侧边栏(248px) ---- */
|
/* ---- 应用外壳:左侧固定导航 + 内容区,宽度对齐主后台侧边栏(248px) ---- */
|
||||||
|
|
||||||
.ops-shell {
|
.ops-shell {
|
||||||
display: grid;
|
display: grid;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
grid-template-columns: 248px minmax(0, 1fr);
|
grid-template-columns: 248px minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ops-sidebar {
|
.ops-sidebar {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--space-5);
|
gap: var(--space-5);
|
||||||
border-right: 1px solid var(--border);
|
border-right: 1px solid var(--border);
|
||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
padding: var(--space-5) var(--space-3);
|
padding: var(--space-5) var(--space-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ops-brand {
|
.ops-brand {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
padding: 0 var(--space-2);
|
padding: 0 var(--space-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ops-brand > span {
|
.ops-brand > span {
|
||||||
display: inline-grid;
|
display: inline-grid;
|
||||||
width: 36px;
|
width: 36px;
|
||||||
height: 36px;
|
height: 36px;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
border-radius: var(--radius-control);
|
border-radius: var(--radius-control);
|
||||||
background: var(--primary);
|
background: var(--primary);
|
||||||
color: var(--active-contrast, #fff);
|
color: var(--active-contrast, #fff);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ops-brand strong {
|
.ops-brand strong {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
line-height: 1.25;
|
line-height: 1.25;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ops-brand small {
|
.ops-brand small {
|
||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ops-nav {
|
.ops-nav {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ops-nav button {
|
.ops-nav button {
|
||||||
display: flex;
|
display: flex;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 38px;
|
height: 38px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: var(--radius-control);
|
border-radius: var(--radius-control);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 0 var(--space-3);
|
padding: 0 var(--space-3);
|
||||||
text-align: left;
|
text-align: left;
|
||||||
transition:
|
transition:
|
||||||
background var(--motion-fast) var(--ease-standard),
|
background var(--motion-fast) var(--ease-standard),
|
||||||
color var(--motion-fast) var(--ease-standard);
|
color var(--motion-fast) var(--ease-standard);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ops-nav button:hover {
|
.ops-nav button:hover {
|
||||||
background: var(--primary-hover);
|
background: var(--primary-hover);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* active 优先级必须高于 hover:hover 到别的项时当前页仍要保持高亮底色 */
|
/* active 优先级必须高于 hover:hover 到别的项时当前页仍要保持高亮底色 */
|
||||||
.ops-nav button.is-active,
|
.ops-nav button.is-active,
|
||||||
.ops-nav button.is-active:hover {
|
.ops-nav button.is-active:hover {
|
||||||
background: var(--primary-surface);
|
background: var(--primary-surface);
|
||||||
color: var(--primary);
|
color: var(--primary);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ops-main {
|
.ops-main {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
background: var(--bg-page);
|
background: var(--bg-page);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- 页面骨架 ---- */
|
/* ---- 页面骨架 ---- */
|
||||||
|
|
||||||
.ops-page {
|
.ops-page {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: var(--space-5);
|
gap: var(--space-5);
|
||||||
padding: var(--space-6);
|
padding: var(--space-6);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ops-view {
|
.ops-view {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: var(--space-5);
|
gap: var(--space-5);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ops-kpi-grid {
|
.ops-kpi-grid {
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
.ops-status {
|
.ops-status {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
height: 28px;
|
height: 28px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
border: 1px solid var(--success-border);
|
border: 1px solid var(--success-border);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: var(--success-surface);
|
background: var(--success-surface);
|
||||||
color: var(--success);
|
color: var(--success);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
padding: 0 var(--space-3);
|
padding: 0 var(--space-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ops-status.is-loading {
|
.ops-status.is-loading {
|
||||||
border-color: var(--info-border);
|
border-color: var(--info-border);
|
||||||
background: var(--info-surface);
|
background: var(--info-surface);
|
||||||
color: var(--info);
|
color: var(--info);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ops-alert {
|
.ops-alert {
|
||||||
border: 1px solid var(--warning-border);
|
border: 1px solid var(--warning-border);
|
||||||
border-radius: var(--radius-card);
|
border-radius: var(--radius-card);
|
||||||
background: var(--warning-surface);
|
background: var(--warning-surface);
|
||||||
color: var(--warning);
|
color: var(--warning);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
padding: var(--space-3) var(--space-4);
|
padding: var(--space-3) var(--space-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- 幸运礼物配置弹窗 ---- */
|
/* ---- 幸运礼物配置弹窗 ---- */
|
||||||
|
|
||||||
.ops-config-form {
|
.ops-config-form {
|
||||||
display: contents;
|
display: contents;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ops-config-title {
|
.ops-config-title {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: var(--space-1);
|
gap: var(--space-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ops-config-title small {
|
.ops-config-title small {
|
||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ops-config-metrics {
|
.ops-config-metrics {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
margin-bottom: var(--space-4);
|
margin-bottom: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-config-validation {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-2);
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
border: 1px solid var(--danger-border, #fecaca);
|
||||||
|
border-radius: var(--radius-control);
|
||||||
|
background: var(--danger-surface, #fef2f2);
|
||||||
|
color: var(--danger, #dc2626);
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-config-validation ul {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ops-config-metric {
|
.ops-config-metric {
|
||||||
display: grid;
|
display: grid;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
gap: var(--space-1);
|
gap: var(--space-1);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius-control);
|
border-radius: var(--radius-control);
|
||||||
background: var(--bg-card-strong, #f8fafc);
|
background: var(--bg-card-strong, #f8fafc);
|
||||||
padding: 10px var(--space-3);
|
padding: 10px var(--space-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ops-config-metric span {
|
.ops-config-metric span {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ops-config-metric strong {
|
.ops-config-metric strong {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
line-height: 1.25;
|
line-height: 1.25;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ops-config-layout {
|
.ops-config-layout {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(280px, 340px) minmax(0, 1fr);
|
grid-template-columns: minmax(360px, 440px) minmax(0, 1fr);
|
||||||
align-items: start;
|
align-items: start;
|
||||||
gap: var(--space-4);
|
gap: var(--space-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ops-config-params {
|
.ops-config-params {
|
||||||
display: grid;
|
display: grid;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
gap: var(--space-4);
|
gap: var(--space-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ops-config-section {
|
.ops-config-section {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius-card);
|
border-radius: var(--radius-card);
|
||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
padding: var(--space-4);
|
padding: var(--space-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ops-config-section h3 {
|
.ops-config-section h3 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ops-form-grid {
|
.ops-form-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-form-grid__wide {
|
||||||
|
grid-column: 1 / -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ops-stage-designer {
|
.ops-stage-designer {
|
||||||
display: grid;
|
display: grid;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius-card);
|
border-radius: var(--radius-card);
|
||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ops-stage-tabs {
|
.ops-stage-tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
gap: var(--space-1);
|
gap: var(--space-1);
|
||||||
padding: 0 var(--space-3);
|
padding: 0 var(--space-3);
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ops-stage-tab {
|
.ops-stage-tab {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
height: 38px;
|
height: 38px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-bottom: 2px solid transparent;
|
border-bottom: 2px solid transparent;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
padding: 0 var(--space-4);
|
padding: 0 var(--space-4);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
transition:
|
transition:
|
||||||
background var(--motion-fast) var(--ease-standard),
|
background var(--motion-fast) var(--ease-standard),
|
||||||
border-color var(--motion-fast) var(--ease-standard),
|
border-color var(--motion-fast) var(--ease-standard),
|
||||||
color var(--motion-fast) var(--ease-standard);
|
color var(--motion-fast) var(--ease-standard);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ops-stage-tab:hover {
|
.ops-stage-tab:hover {
|
||||||
background: var(--primary-hover);
|
background: var(--primary-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ops-stage-tab.is-active {
|
.ops-stage-tab.is-active {
|
||||||
border-bottom-color: var(--primary);
|
border-bottom-color: var(--primary);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 阶段有问题(概率未闭合 / RTP 偏离)时 tab 下划线转告警色,切换前就能看到哪个阶段需要处理 */
|
/* 阶段有问题(概率未闭合 / RTP 偏离)时 tab 下划线转告警色,切换前就能看到哪个阶段需要处理 */
|
||||||
.ops-stage-tab.is-warn {
|
.ops-stage-tab.is-warn {
|
||||||
color: var(--warning);
|
color: var(--warning);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ops-stage-tab.is-active.is-warn {
|
.ops-stage-tab.is-active.is-warn {
|
||||||
border-bottom-color: var(--warning);
|
border-bottom-color: var(--warning);
|
||||||
color: var(--warning);
|
color: var(--warning);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ops-stage-stack {
|
.ops-stage-stack {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
padding: var(--space-4);
|
padding: var(--space-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ops-stage-block__head {
|
.ops-stage-block__head {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ops-stage-block__head > div {
|
.ops-stage-block__head > div {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ops-stage-block__head h3 {
|
.ops-stage-block__head h3 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-stage-thresholds {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-3);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-control);
|
||||||
|
background: var(--bg-card-strong, #f8fafc);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ops-tier-table {
|
.ops-tier-table {
|
||||||
display: grid;
|
display: grid;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius-card);
|
border-radius: var(--radius-card);
|
||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ops-tier-head,
|
.ops-tier-head,
|
||||||
.ops-tier-row {
|
.ops-tier-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
min-width: 520px;
|
min-width: 520px;
|
||||||
grid-template-columns:
|
grid-template-columns:
|
||||||
minmax(110px, 1fr)
|
minmax(110px, 1fr)
|
||||||
minmax(110px, 1fr)
|
minmax(110px, 1fr)
|
||||||
minmax(64px, auto)
|
minmax(64px, auto)
|
||||||
minmax(64px, auto)
|
minmax(64px, auto)
|
||||||
minmax(72px, auto);
|
minmax(72px, auto);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ops-tier-head {
|
.ops-tier-head {
|
||||||
min-height: 34px;
|
min-height: 34px;
|
||||||
border-bottom: 1px solid var(--row-border);
|
border-bottom: 1px solid var(--row-border);
|
||||||
background: var(--bg-card-strong, #f8fafc);
|
background: var(--bg-card-strong, #f8fafc);
|
||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
padding: 0 var(--space-3);
|
padding: 0 var(--space-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ops-tier-row {
|
.ops-tier-row {
|
||||||
border-bottom: 1px solid var(--row-border);
|
border-bottom: 1px solid var(--row-border);
|
||||||
padding: var(--space-2) var(--space-3);
|
padding: var(--space-2) var(--space-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ops-tier-row:last-child {
|
.ops-tier-row:last-child {
|
||||||
border-bottom: 0;
|
border-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- 响应式 ---- */
|
/* ---- 响应式 ---- */
|
||||||
|
|
||||||
@media (max-width: 960px) {
|
@media (max-width: 960px) {
|
||||||
.ops-shell {
|
.ops-shell {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ops-sidebar {
|
.ops-sidebar {
|
||||||
min-height: auto;
|
min-height: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ops-nav,
|
.ops-nav,
|
||||||
.ops-kpi-grid,
|
.ops-kpi-grid,
|
||||||
.ops-config-metrics {
|
.ops-config-metrics {
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
.ops-config-layout {
|
.ops-config-layout {
|
||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.ops-page {
|
.ops-page {
|
||||||
padding: var(--space-4);
|
padding: var(--space-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ops-nav,
|
.ops-nav,
|
||||||
.ops-kpi-grid,
|
.ops-kpi-grid,
|
||||||
.ops-config-metrics,
|
.ops-config-metrics,
|
||||||
.ops-form-grid,
|
.ops-form-grid,
|
||||||
.ops-tier-row {
|
.ops-stage-thresholds,
|
||||||
grid-template-columns: 1fr;
|
.ops-tier-row {
|
||||||
}
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
.ops-tier-head {
|
.ops-tier-head {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.ops-nav button,
|
.ops-nav button,
|
||||||
.ops-stage-tab {
|
.ops-stage-tab {
|
||||||
transition: none;
|
transition: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,204 +1,18 @@
|
|||||||
import Dialog from "@mui/material/Dialog";
|
import { useEffect } from "react";
|
||||||
import DialogActions from "@mui/material/DialogActions";
|
|
||||||
import DialogContent from "@mui/material/DialogContent";
|
export const LUCKY_GIFT_OPS_CENTER_PATH = "/ops-center/?view=configs";
|
||||||
import DialogTitle from "@mui/material/DialogTitle";
|
|
||||||
import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
|
export function redirectToLuckyGiftOpsCenter(targetLocation = window.location) {
|
||||||
import TextField from "@mui/material/TextField";
|
// Ops Center 是独立 HTML 入口,React Router 的 SPA Navigate 会落回主后台兜底路由;
|
||||||
import { LuckyGiftConfigDrawer } from "@/features/lucky-gift/components/LuckyGiftConfigDrawer.jsx";
|
// 必须触发文档级导航,才能加载新版跨 App 配置页并避免旧页面调用已下线的 activity/v2 接口。
|
||||||
import { LuckyGiftConfigSummary } from "@/features/lucky-gift/components/LuckyGiftConfigSummary.jsx";
|
targetLocation.replace(LUCKY_GIFT_OPS_CENTER_PATH);
|
||||||
import { LuckyGiftSimulationPanel } from "@/features/lucky-gift/components/LuckyGiftSimulationPanel.jsx";
|
}
|
||||||
import { drawStatusOptions } from "@/features/lucky-gift/constants.js";
|
|
||||||
import { useLuckyGiftPage } from "@/features/lucky-gift/hooks/useLuckyGiftPage.js";
|
|
||||||
import styles from "@/features/lucky-gift/lucky-gift.module.css";
|
|
||||||
import { AdminFilterResetButton, AdminFilterSelect, AdminListPage } from "@/shared/ui/AdminListLayout.jsx";
|
|
||||||
import { Button } from "@/shared/ui/Button.jsx";
|
|
||||||
|
|
||||||
export function LuckyGiftConfigPage() {
|
export function LuckyGiftConfigPage() {
|
||||||
const page = useLuckyGiftPage();
|
useEffect(() => {
|
||||||
|
redirectToLuckyGiftOpsCenter();
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
// 文档跳转通常立即完成;保留可读状态,供慢网络和辅助技术明确当前动作。
|
||||||
<AdminListPage>
|
return <p role="status">正在打开新版幸运礼物配置…</p>;
|
||||||
<LuckyGiftConfigSummary
|
|
||||||
canUpdate={page.abilities.canUpdate}
|
|
||||||
config={page.config}
|
|
||||||
configLoading={page.configLoading}
|
|
||||||
poolId={page.poolId}
|
|
||||||
poolOptions={page.poolOptions}
|
|
||||||
onAddPool={page.openAddPool}
|
|
||||||
onEdit={page.openConfigDrawer}
|
|
||||||
onPoolChange={page.changePoolId}
|
|
||||||
onRefresh={page.reloadConfig}
|
|
||||||
/>
|
|
||||||
<LuckyGiftSimulationPanel config={page.config} />
|
|
||||||
<LuckyGiftDrawSummaryPanel page={page} />
|
|
||||||
<LuckyGiftConfigDrawer
|
|
||||||
abilities={page.abilities}
|
|
||||||
configLoading={page.configLoading}
|
|
||||||
configSaving={page.configSaving}
|
|
||||||
form={page.form}
|
|
||||||
open={page.configDrawerOpen}
|
|
||||||
setForm={page.setForm}
|
|
||||||
onClose={page.closeConfigDrawer}
|
|
||||||
onSubmit={page.submitConfig}
|
|
||||||
/>
|
|
||||||
<AddPoolDialog
|
|
||||||
disabled={page.configSaving}
|
|
||||||
open={page.addPoolOpen}
|
|
||||||
value={page.addPoolId}
|
|
||||||
onChange={page.setAddPoolId}
|
|
||||||
onClose={page.closeAddPool}
|
|
||||||
onSubmit={page.submitAddPool}
|
|
||||||
/>
|
|
||||||
</AdminListPage>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function LuckyGiftDrawSummaryPanel({ page }) {
|
|
||||||
const summary = page.drawSummary || {};
|
|
||||||
const netSpent = Number(summary.totalSpentCoins || 0) - Number(summary.totalRewardCoins || 0);
|
|
||||||
const stats = [
|
|
||||||
{
|
|
||||||
hint: `房间 ${formatNumber(summary.uniqueRooms)}`,
|
|
||||||
label: "参与用户",
|
|
||||||
value: formatNumber(summary.uniqueUsers),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
hint: `成功 ${formatNumber(summary.grantedDraws)} / 待发放 ${formatNumber(summary.pendingDraws)} / 失败 ${formatNumber(summary.failedDraws)}`,
|
|
||||||
label: "抽奖次数",
|
|
||||||
value: formatNumber(summary.totalDraws),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
hint: `目标 ${formatPercent(page.config?.targetRTPPercent)}`,
|
|
||||||
label: "实际 RTP",
|
|
||||||
value: formatPPM(summary.actualRTPPPM),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
hint: `净消耗 ${formatNumber(netSpent)}`,
|
|
||||||
label: "消耗金币",
|
|
||||||
value: formatNumber(summary.totalSpentCoins),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
hint: `基础 ${formatNumber(summary.baseRewardCoins)}`,
|
|
||||||
label: "返还金币",
|
|
||||||
value: formatNumber(summary.totalRewardCoins),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
hint: `房间 ${formatNumber(summary.roomAtmosphereRewardCoins)} / 活动 ${formatNumber(summary.activitySubsidyCoins)}`,
|
|
||||||
label: "额外预算",
|
|
||||||
value: formatNumber(
|
|
||||||
Number(summary.roomAtmosphereRewardCoins || 0) + Number(summary.activitySubsidyCoins || 0),
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className={styles.drawSummaryPanel}>
|
|
||||||
<header className={styles.drawSummaryHeader}>
|
|
||||||
<div className={styles.stack}>
|
|
||||||
<h2 className={styles.sectionTitle}>抽奖数据概览</h2>
|
|
||||||
<span className={styles.meta}>奖池 {summary.poolId || page.poolId}</span>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
disabled={page.drawSummaryLoading}
|
|
||||||
startIcon={<RefreshOutlined fontSize="small" />}
|
|
||||||
onClick={page.reloadDrawSummary}
|
|
||||||
>
|
|
||||||
刷新
|
|
||||||
</Button>
|
|
||||||
</header>
|
|
||||||
<div className={styles.drawSummaryFilters}>
|
|
||||||
<TextField className={styles.filterInput} disabled label="当前奖池" value={page.poolId} />
|
|
||||||
<TextField
|
|
||||||
className={styles.filterInput}
|
|
||||||
label="礼物 ID"
|
|
||||||
value={page.giftId}
|
|
||||||
onChange={(event) => page.changeGiftId(event.target.value)}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
className={styles.filterInput}
|
|
||||||
label="用户 ID"
|
|
||||||
value={page.userId}
|
|
||||||
onChange={(event) => page.changeUserId(event.target.value)}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
className={styles.filterInput}
|
|
||||||
label="房间 ID"
|
|
||||||
value={page.roomId}
|
|
||||||
onChange={(event) => page.changeRoomId(event.target.value)}
|
|
||||||
/>
|
|
||||||
<AdminFilterSelect
|
|
||||||
label="发放状态"
|
|
||||||
options={[["", "全部状态"], ...drawStatusOptions]}
|
|
||||||
value={page.status}
|
|
||||||
onChange={page.changeStatus}
|
|
||||||
/>
|
|
||||||
<AdminFilterResetButton
|
|
||||||
disabled={!page.giftId && !page.userId && !page.roomId && !page.status}
|
|
||||||
onClick={page.resetDrawFilters}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{page.drawSummaryError ? (
|
|
||||||
<div className={styles.summaryError}>
|
|
||||||
<span>{page.drawSummaryError}</span>
|
|
||||||
<Button startIcon={<RefreshOutlined fontSize="small" />} onClick={page.reloadDrawSummary}>
|
|
||||||
重试
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className={styles.drawSummaryGrid} aria-busy={page.drawSummaryLoading}>
|
|
||||||
{stats.map((item) => (
|
|
||||||
<div className={styles.metricItem} key={item.label}>
|
|
||||||
<span>{item.label}</span>
|
|
||||||
<strong>{page.drawSummaryLoading ? "-" : item.value}</strong>
|
|
||||||
<small>{page.drawSummaryLoading ? "" : item.hint}</small>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function AddPoolDialog({ disabled, onChange, onClose, onSubmit, open, value }) {
|
|
||||||
return (
|
|
||||||
<Dialog fullWidth maxWidth="xs" open={open} onClose={disabled ? undefined : onClose}>
|
|
||||||
<form onSubmit={onSubmit}>
|
|
||||||
<DialogTitle>添加奖池</DialogTitle>
|
|
||||||
<DialogContent>
|
|
||||||
<TextField
|
|
||||||
autoFocus
|
|
||||||
fullWidth
|
|
||||||
required
|
|
||||||
disabled={disabled}
|
|
||||||
label="奖池 ID"
|
|
||||||
margin="dense"
|
|
||||||
value={value}
|
|
||||||
onChange={(event) => onChange(event.target.value)}
|
|
||||||
/>
|
|
||||||
</DialogContent>
|
|
||||||
<DialogActions>
|
|
||||||
<Button disabled={disabled} type="button" onClick={onClose}>
|
|
||||||
取消
|
|
||||||
</Button>
|
|
||||||
<Button disabled={disabled} type="submit" variant="primary">
|
|
||||||
添加并编辑
|
|
||||||
</Button>
|
|
||||||
</DialogActions>
|
|
||||||
</form>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatPPM(value) {
|
|
||||||
return `${(Number(value || 0) / 10_000).toFixed(2).replace(/\.?0+$/, "")}%`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatPercent(value) {
|
|
||||||
return `${Number(value || 0)
|
|
||||||
.toFixed(2)
|
|
||||||
.replace(/\.?0+$/, "")}%`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatNumber(value) {
|
|
||||||
return new Intl.NumberFormat("zh-CN").format(Number(value || 0));
|
|
||||||
}
|
}
|
||||||
|
|||||||
14
src/features/lucky-gift/pages/LuckyGiftConfigPage.test.jsx
Normal file
14
src/features/lucky-gift/pages/LuckyGiftConfigPage.test.jsx
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { expect, test, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
LUCKY_GIFT_OPS_CENTER_PATH,
|
||||||
|
redirectToLuckyGiftOpsCenter,
|
||||||
|
} from "@/features/lucky-gift/pages/LuckyGiftConfigPage.jsx";
|
||||||
|
|
||||||
|
test("moves the legacy lucky gift route to the ops-center document entry", () => {
|
||||||
|
const targetLocation = { replace: vi.fn() };
|
||||||
|
|
||||||
|
redirectToLuckyGiftOpsCenter(targetLocation);
|
||||||
|
|
||||||
|
expect(targetLocation.replace).toHaveBeenCalledWith(LUCKY_GIFT_OPS_CENTER_PATH);
|
||||||
|
expect(LUCKY_GIFT_OPS_CENTER_PATH).toBe("/ops-center/?view=configs");
|
||||||
|
});
|
||||||
@ -229,6 +229,7 @@ export const API_OPERATIONS = {
|
|||||||
listLoginLogs: "listLoginLogs",
|
listLoginLogs: "listLoginLogs",
|
||||||
listLuckyGiftConfigs: "listLuckyGiftConfigs",
|
listLuckyGiftConfigs: "listLuckyGiftConfigs",
|
||||||
listLuckyGiftDraws: "listLuckyGiftDraws",
|
listLuckyGiftDraws: "listLuckyGiftDraws",
|
||||||
|
listLuckyGiftPoolBalances: "listLuckyGiftPoolBalances",
|
||||||
listManagers: "listManagers",
|
listManagers: "listManagers",
|
||||||
listOperationLogs: "listOperationLogs",
|
listOperationLogs: "listOperationLogs",
|
||||||
listPermissions: "listPermissions",
|
listPermissions: "listPermissions",
|
||||||
@ -1307,14 +1308,14 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
getLuckyGiftConfig: {
|
getLuckyGiftConfig: {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
operationId: API_OPERATIONS.getLuckyGiftConfig,
|
operationId: API_OPERATIONS.getLuckyGiftConfig,
|
||||||
path: "/v1/admin/activity/lucky-gifts/v2/config",
|
path: "/v1/admin/ops-center/lucky-gifts/config",
|
||||||
permission: "lucky-gift:view",
|
permission: "lucky-gift:view",
|
||||||
permissions: ["lucky-gift:view"]
|
permissions: ["lucky-gift:view"]
|
||||||
},
|
},
|
||||||
getLuckyGiftDrawSummary: {
|
getLuckyGiftDrawSummary: {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
operationId: API_OPERATIONS.getLuckyGiftDrawSummary,
|
operationId: API_OPERATIONS.getLuckyGiftDrawSummary,
|
||||||
path: "/v1/admin/activity/lucky-gifts/v2/draw-summary",
|
path: "/v1/admin/ops-center/lucky-gifts/summary",
|
||||||
permission: "lucky-gift:view",
|
permission: "lucky-gift:view",
|
||||||
permissions: ["lucky-gift:view"]
|
permissions: ["lucky-gift:view"]
|
||||||
},
|
},
|
||||||
@ -1900,14 +1901,21 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
listLuckyGiftConfigs: {
|
listLuckyGiftConfigs: {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
operationId: API_OPERATIONS.listLuckyGiftConfigs,
|
operationId: API_OPERATIONS.listLuckyGiftConfigs,
|
||||||
path: "/v1/admin/activity/lucky-gifts/v2/configs",
|
path: "/v1/admin/ops-center/lucky-gifts/configs",
|
||||||
permission: "lucky-gift:view",
|
permission: "lucky-gift:view",
|
||||||
permissions: ["lucky-gift:view"]
|
permissions: ["lucky-gift:view"]
|
||||||
},
|
},
|
||||||
listLuckyGiftDraws: {
|
listLuckyGiftDraws: {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
operationId: API_OPERATIONS.listLuckyGiftDraws,
|
operationId: API_OPERATIONS.listLuckyGiftDraws,
|
||||||
path: "/v1/admin/activity/lucky-gifts/v2/draws",
|
path: "/v1/admin/ops-center/lucky-gifts/draws",
|
||||||
|
permission: "lucky-gift:view",
|
||||||
|
permissions: ["lucky-gift:view"]
|
||||||
|
},
|
||||||
|
listLuckyGiftPoolBalances: {
|
||||||
|
method: "GET",
|
||||||
|
operationId: API_OPERATIONS.listLuckyGiftPoolBalances,
|
||||||
|
path: "/v1/admin/ops-center/lucky-gifts/pools",
|
||||||
permission: "lucky-gift:view",
|
permission: "lucky-gift:view",
|
||||||
permissions: ["lucky-gift:view"]
|
permissions: ["lucky-gift:view"]
|
||||||
},
|
},
|
||||||
@ -3034,7 +3042,7 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
upsertLuckyGiftConfig: {
|
upsertLuckyGiftConfig: {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
operationId: API_OPERATIONS.upsertLuckyGiftConfig,
|
operationId: API_OPERATIONS.upsertLuckyGiftConfig,
|
||||||
path: "/v1/admin/activity/lucky-gifts/v2/config",
|
path: "/v1/admin/ops-center/lucky-gifts/config",
|
||||||
permission: "lucky-gift:update",
|
permission: "lucky-gift:update",
|
||||||
permissions: ["lucky-gift:update"]
|
permissions: ["lucky-gift:update"]
|
||||||
},
|
},
|
||||||
|
|||||||
36
src/shared/api/generated/schema.d.ts
vendored
36
src/shared/api/generated/schema.d.ts
vendored
@ -244,7 +244,7 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
"/admin/activity/lucky-gifts/v2/config": {
|
"/admin/ops-center/lucky-gifts/config": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
header?: never;
|
header?: never;
|
||||||
@ -260,7 +260,7 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
"/admin/activity/lucky-gifts/v2/configs": {
|
"/admin/ops-center/lucky-gifts/configs": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
header?: never;
|
header?: never;
|
||||||
@ -276,7 +276,7 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
"/admin/activity/lucky-gifts/v2/draws": {
|
"/admin/ops-center/lucky-gifts/draws": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
header?: never;
|
header?: never;
|
||||||
@ -292,7 +292,7 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
"/admin/activity/lucky-gifts/v2/draw-summary": {
|
"/admin/ops-center/lucky-gifts/summary": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
header?: never;
|
header?: never;
|
||||||
@ -308,6 +308,22 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/admin/ops-center/lucky-gifts/pools": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get: operations["listLuckyGiftPoolBalances"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/admin/activity/wheel/config": {
|
"/admin/activity/wheel/config": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -7979,6 +7995,18 @@ export interface operations {
|
|||||||
200: components["responses"]["EmptyResponse"];
|
200: components["responses"]["EmptyResponse"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
listLuckyGiftPoolBalances: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: components["responses"]["EmptyResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
getWheelConfig: {
|
getWheelConfig: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user