游戏流水

This commit is contained in:
zhx 2026-07-16 10:36:48 +08:00
parent a99fe68c36
commit 8c002e023b
9 changed files with 166 additions and 22 deletions

View File

@ -6,6 +6,12 @@
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"],
"port": 7001
},
{
"name": "admin-platform-alt",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev", "--", "--port", "7031"],
"port": 7031
}
]
}

View File

@ -16,6 +16,7 @@ import {
DATE_PRESETS,
GRANULARITIES,
createDefaultFilters,
createDefaultTableState,
rangeLabel,
readStateFromURL,
toggleMultiValue,
@ -63,6 +64,7 @@ export function SocialAppIdentity({ app, appCode, appName, className = "", size
export function SocialBiApp() {
const initial = useMemo(() => readStateFromURL(), []);
const [filters, setFilters] = useState(initial.filters);
const [tableState, setTableState] = useState(() => initial.table || createDefaultTableState());
const [viewKey, setViewKey] = useState(() => (VIEWS.some((view) => view.key === initial.view) ? initial.view : "overview"));
const data = useSocialBiData(filters, {
includeFunnel: viewKey === "funnel",
@ -70,8 +72,8 @@ export function SocialBiApp() {
});
useEffect(() => {
writeStateToURL(filters, viewKey);
}, [filters, viewKey]);
writeStateToURL(filters, viewKey, tableState);
}, [filters, tableState, viewKey]);
const updateFilter = useCallback((key, value) => {
setFilters((current) => ({ ...current, [key]: value }));
@ -81,9 +83,19 @@ export function SocialBiApp() {
setFilters(createDefaultFilters());
}, []);
const updateTableState = useCallback((patch) => {
setTableState((current) => ({ ...current, ...patch }));
}, []);
const openRequirementSection = useCallback((requirementSection) => {
// DataTableView /
setTableState({ mode: "requirements", requirementSection });
setViewKey("table");
}, []);
const contextValue = useMemo(
() => ({ ...data, filters, resetFilters, setViewKey, updateFilter, viewKey }),
[data, filters, resetFilters, updateFilter, viewKey]
() => ({ ...data, filters, openRequirementSection, resetFilters, setViewKey, tableState, updateFilter, updateTableState, viewKey }),
[data, filters, openRequirementSection, resetFilters, tableState, updateFilter, updateTableState, viewKey]
);
const ActiveView = (VIEWS.find((view) => view.key === viewKey) || VIEWS[0]).component;

View File

@ -1,10 +1,20 @@
// 社交 BI v2 的全局筛选状态:日期预设/自定义区间、App 多选、区域多选、运营人员、趋势粒度
// 并同步到 URL query保证筛选状态可以直接作为链接分享。
// 社交 BI v2 的全局筛选与可下钻视图状态:日期/App/区域/运营人员/趋势粒度,以及数据明细模式
// 统一同步到 URL query保证筛选结果和“数据需求”目标分组都可以直接作为链接分享。
import { lastDaysRange, thisMonthRange, todayRange } from "../utils/time.js";
export const ALL = "all";
const TABLE_MODES = new Set(["wide", "requirements"]);
const REQUIREMENT_SECTIONS = new Set(["new_users", "revenue", "retention", "active", "gift", "game"]);
export function createDefaultTableState() {
return {
mode: "wide",
requirementSection: "new_users"
};
}
export const DATE_PRESETS = [
{ key: "today", label: "今日" },
{ key: "yesterday", label: "昨日" },
@ -117,11 +127,12 @@ export function bucketDay(statDay, granularity) {
return statDay;
}
const URL_KEYS = ["preset", "start", "end", "apps", "regions", "operator", "view", "granularity"];
const URL_KEYS = ["preset", "start", "end", "apps", "regions", "operator", "view", "granularity", "mode", "section"];
export function readStateFromURL(location = window.location) {
const params = new URLSearchParams(location.search);
const filters = createDefaultFilters();
const table = createDefaultTableState();
let view = "";
if (params.get("preset") && DATE_PRESETS.some((item) => item.key === params.get("preset"))) {
filters.preset = params.get("preset");
@ -143,16 +154,23 @@ export function readStateFromURL(location = window.location) {
if (params.get("view")) {
view = params.get("view");
}
const requestedTableMode = params.get("mode");
if (view === "table" && TABLE_MODES.has(requestedTableMode)) {
table.mode = requestedTableMode;
if (requestedTableMode === "requirements" && REQUIREMENT_SECTIONS.has(params.get("section"))) {
table.requirementSection = params.get("section");
}
}
if (!filters.apps.length) {
filters.apps = [ALL];
}
if (!filters.regions.length) {
filters.regions = [ALL];
}
return { filters, view };
return { filters, table, view };
}
export function writeStateToURL(filters, view, history = window.history, location = window.location) {
export function writeStateToURL(filters, view, table, history = window.history, location = window.location) {
const params = new URLSearchParams(location.search);
URL_KEYS.forEach((key) => params.delete(key));
if (filters.preset !== "yesterday") {
@ -181,6 +199,13 @@ export function writeStateToURL(filters, view, history = window.history, locatio
if (view && view !== "overview") {
params.set("view", view);
}
if (view === "table" && table?.mode === "requirements") {
// 目标分组与数据需求模式一起入 URL保证概览下钻和复制后的链接都能落到同一个游戏数据视图。
params.set("mode", "requirements");
if (REQUIREMENT_SECTIONS.has(table.requirementSection) && table.requirementSection !== "new_users") {
params.set("section", table.requirementSection);
}
}
const query = params.toString();
history.replaceState(null, "", `${location.pathname}${query ? `?${query}` : ""}`);
}

View File

@ -477,16 +477,18 @@ export function DataTableView() {
range,
refreshToken,
requirements,
requirementsError
requirementsError,
tableState,
updateTableState
} = useSocialBi();
const [mode, setMode] = useState("wide");
const [dimKey, setDimKey] = useState("appDaily");
const [closedGroups, setClosedGroups] = useState(readClosedGroups);
const [sort, setSort] = useState(null);
const [requirementSection, setRequirementSection] = useState(REQUIREMENT_SECTIONS[0].key);
const [requirementRole, setRequirementRole] = useState("all");
const [requirementPayer, setRequirementPayer] = useState("all");
const [requirementNewUser, setRequirementNewUser] = useState("all");
const mode = tableState?.mode || TABLE_MODES[0].key;
const requirementSection = tableState?.requirementSection || REQUIREMENT_SECTIONS[0].key;
const dim = DIMENSIONS.find((item) => item.key === dimKey) || DIMENSIONS[0];
const dimColumns = dim.dims.map((name) => DIM_COLUMNS[name]);
@ -575,7 +577,7 @@ export function DataTableView() {
const handleRequirementSectionChange = (key) => {
const nextSection = requirementSectionConfig(key);
setRequirementSection(nextSection.key);
updateTableState({ requirementSection: nextSection.key });
if (!nextSection.roleFilter) {
setRequirementRole("all");
}
@ -889,7 +891,7 @@ export function DataTableView() {
aria-checked={mode === item.key}
className={mode === item.key ? "is-active" : ""}
key={item.key}
onClick={() => setMode(item.key)}
onClick={() => updateTableState({ mode: item.key })}
role="radio"
type="button"
>

View File

@ -174,7 +174,7 @@ function OverviewSkeleton() {
}
export function OverviewView() {
const { appCodes, derived, filters, isLoading, setViewKey, updateFilter } = useSocialBi();
const { appCodes, derived, filters, isLoading, openRequirementSection, setViewKey, updateFilter } = useSocialBi();
const [trendMetric, setTrendMetric] = useState("recharge_usd_minor");
const [compareMetric, setCompareMetric] = useState("recharge_usd_minor");
@ -332,20 +332,26 @@ export function OverviewView() {
<strong>{trendLabel}趋势</strong>
<small> App 堆叠</small>
<div className="sbi-card-toolbar">
<div className="sbi-seg" role="radiogroup" aria-label="趋势指标">
<div className="sbi-seg" role="group" aria-label="趋势指标与明细入口">
{TREND_METRICS.map((item) => (
<button
aria-checked={trendMetric === item.key}
aria-pressed={trendMetric === item.key}
className={trendMetric === item.key ? "is-active" : ""}
key={item.key}
onClick={() => setTrendMetric(item.key)}
role="radio"
title={metricTooltip(item.key)}
type="button"
>
{item.label}
</button>
))}
<button
onClick={() => openRequirementSection("game")}
title="查看数据需求中的游戏数据"
type="button"
>
游戏流水
</button>
</div>
</div>
</header>

View File

@ -0,0 +1,13 @@
<!doctype html>
<html dir="ltr" lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>filter-bar repro</title>
<style>html { background: #0d0d11; }</style>
</head>
<body>
<div id="external-admin-root"></div>
<script type="module" src="/external-admin/__repro__.jsx"></script>
</body>
</html>

View File

@ -0,0 +1,42 @@
// Untracked verification harness: renders the real UsersPage (filter bar included)
// with the production theme, emotion cache and stylesheets, but without routing/auth
// guards, so layout can be measured at arbitrary viewport widths. Delete after use.
import { CacheProvider } from "@emotion/react";
import CssBaseline from "@mui/material/CssBaseline";
import { ThemeProvider } from "@mui/material/styles";
import React, { useMemo } from "react";
import { createRoot } from "react-dom/client";
import { ToastProvider } from "@/shared/ui/ToastProvider.jsx";
import { createExternalTheme } from "./src/theme.js";
import { ExternalAuthProvider } from "./src/auth/ExternalAuthProvider.jsx";
import { ExternalI18nProvider, useExternalI18n } from "./src/i18n/ExternalI18nProvider.jsx";
import { externalEmotionCache } from "./src/i18n/emotionCache.js";
import UsersPage from "./src/pages/UsersPage.jsx";
import "@/styles/tokens.css";
import "@/styles/shared-ui.css";
import "./src/styles/index.css";
createRoot(document.getElementById("external-admin-root")).render(
<React.StrictMode>
<ExternalI18nProvider>
<Root />
</ExternalI18nProvider>
</React.StrictMode>
);
function Root() {
const { direction } = useExternalI18n();
const theme = useMemo(() => createExternalTheme(direction), [direction]);
return (
<CacheProvider value={externalEmotionCache(direction)}>
<ThemeProvider theme={theme}>
<ToastProvider>
<CssBaseline />
<ExternalAuthProvider>
<UsersPage />
</ExternalAuthProvider>
</ToastProvider>
</ThemeProvider>
</CacheProvider>
);
}

View File

@ -233,6 +233,9 @@ export function validateLuckyGiftForm(form) {
)
errors.push("等价抽数阶段门槛不正确");
const jackpotMultipliers =
form.strategy_version === DYNAMIC_STRATEGY ? new Set(multiplierListFromForm(form.jackpot_multipliers)) : null;
// 阶段概率与 RTP 是两代策略共用的 owner 校验fixed_v2 也不能跳过。
for (const stageKey of ["novice", "normal", "advanced"]) {
const stage = form.stages?.find((item) => item.stage === stageKey);
@ -251,6 +254,15 @@ export function validateLuckyGiftForm(form) {
errors.push(`${stageLabel(stageKey)}启用奖档概率必须大于 0`);
if (enabled.some((tier) => Number(tier.multiplier) >= 10 && !tier.highWaterOnly))
errors.push(`${stageLabel(stageKey)}10x 及以上奖档必须限制为高水位`);
const jackpotStageMultipliers =
jackpotMultipliers &&
enabled
.filter((tier) => jackpotMultipliers.has(numberFromForm(tier.multiplier)) && percentToPPM(tier.probabilityPercent) > 0)
.map((tier) => formatDecimal(numberFromForm(tier.multiplier)));
if (jackpotStageMultipliers?.length) {
// dynamic_v3 的大奖倍率只能走大奖补偿路径;普通阶段再给正概率会绕过 owner 的双 RTP 门槛。
errors.push(`${stageLabel(stageKey)}普通奖档不能包含大奖倍率:${unique(jackpotStageMultipliers).join("x、")}x`);
}
const expected = expectedRTPPercent(enabled, { multiplier: (item) => item.multiplier });
if (
expected < number("target_rtp_percent") - number("control_band_percent") ||
@ -282,11 +294,11 @@ export function validateLuckyGiftForm(form) {
const jackpotTokens = String(form.jackpot_multipliers || "")
.split(/[,\s]+/)
.filter(Boolean);
const jackpotMultipliers = multiplierListFromForm(form.jackpot_multipliers);
const jackpotMultiplierValues = multiplierListFromForm(form.jackpot_multipliers);
if (
jackpotTokens.length !== jackpotMultipliers.length ||
!jackpotMultipliers.length ||
jackpotMultipliers.some((value, index) => value <= 1 || (index > 0 && value <= jackpotMultipliers[index - 1]))
jackpotTokens.length !== jackpotMultiplierValues.length ||
!jackpotMultiplierValues.length ||
jackpotMultiplierValues.some((value, index) => value <= 1 || (index > 0 && value <= jackpotMultiplierValues[index - 1]))
) {
errors.push("大奖倍率必须大于 1x 并严格递增");
}

View File

@ -130,6 +130,32 @@ describe("ops center lucky gift dynamic_v3 form", () => {
expect(errors).toContain("单次返奖上限不能小于 0");
expect(errors).toContain("充值阶段门槛不能小于 0");
});
test("rejects jackpot multipliers that still have ordinary stage probability", () => {
const form = configToForm(completeDynamicConfig());
const errors = validateLuckyGiftForm({
...form,
stages: form.stages.map((stage) =>
stage.stage === "normal"
? {
...stage,
tiers: [
...stage.tiers,
{
enabled: true,
highWaterOnly: true,
multiplier: "200",
probabilityPercent: "0.01",
tierId: "normal_200x",
},
],
}
: stage,
),
});
expect(errors).toContain("正常阶段普通奖档不能包含大奖倍率200x");
});
});
function completeDynamicConfig() {