游戏流水
This commit is contained in:
parent
a99fe68c36
commit
8c002e023b
@ -6,6 +6,12 @@
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "dev"],
|
||||
"port": 7001
|
||||
},
|
||||
{
|
||||
"name": "admin-platform-alt",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "dev", "--", "--port", "7031"],
|
||||
"port": 7031
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -16,6 +16,7 @@ import {
|
||||
DATE_PRESETS,
|
||||
GRANULARITIES,
|
||||
createDefaultFilters,
|
||||
createDefaultTableState,
|
||||
rangeLabel,
|
||||
readStateFromURL,
|
||||
toggleMultiValue,
|
||||
@ -63,6 +64,7 @@ export function SocialAppIdentity({ app, appCode, appName, className = "", size
|
||||
export function SocialBiApp() {
|
||||
const initial = useMemo(() => readStateFromURL(), []);
|
||||
const [filters, setFilters] = useState(initial.filters);
|
||||
const [tableState, setTableState] = useState(() => initial.table || createDefaultTableState());
|
||||
const [viewKey, setViewKey] = useState(() => (VIEWS.some((view) => view.key === initial.view) ? initial.view : "overview"));
|
||||
const data = useSocialBiData(filters, {
|
||||
includeFunnel: viewKey === "funnel",
|
||||
@ -70,8 +72,8 @@ export function SocialBiApp() {
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
writeStateToURL(filters, viewKey);
|
||||
}, [filters, viewKey]);
|
||||
writeStateToURL(filters, viewKey, tableState);
|
||||
}, [filters, tableState, viewKey]);
|
||||
|
||||
const updateFilter = useCallback((key, value) => {
|
||||
setFilters((current) => ({ ...current, [key]: value }));
|
||||
@ -81,9 +83,19 @@ export function SocialBiApp() {
|
||||
setFilters(createDefaultFilters());
|
||||
}, []);
|
||||
|
||||
const updateTableState = useCallback((patch) => {
|
||||
setTableState((current) => ({ ...current, ...patch }));
|
||||
}, []);
|
||||
|
||||
const openRequirementSection = useCallback((requirementSection) => {
|
||||
// 下钻必须同时设置明细模式和业务分组,否则 DataTableView 会按默认值落回“运营宽表 / 新用户”。
|
||||
setTableState({ mode: "requirements", requirementSection });
|
||||
setViewKey("table");
|
||||
}, []);
|
||||
|
||||
const contextValue = useMemo(
|
||||
() => ({ ...data, filters, resetFilters, setViewKey, updateFilter, viewKey }),
|
||||
[data, filters, resetFilters, updateFilter, viewKey]
|
||||
() => ({ ...data, filters, openRequirementSection, resetFilters, setViewKey, tableState, updateFilter, updateTableState, viewKey }),
|
||||
[data, filters, openRequirementSection, resetFilters, tableState, updateFilter, updateTableState, viewKey]
|
||||
);
|
||||
|
||||
const ActiveView = (VIEWS.find((view) => view.key === viewKey) || VIEWS[0]).component;
|
||||
|
||||
@ -1,10 +1,20 @@
|
||||
// 社交 BI v2 的全局筛选状态:日期预设/自定义区间、App 多选、区域多选、运营人员、趋势粒度,
|
||||
// 并同步到 URL query,保证筛选状态可以直接作为链接分享。
|
||||
// 社交 BI v2 的全局筛选与可下钻视图状态:日期/App/区域/运营人员/趋势粒度,以及数据明细模式,
|
||||
// 统一同步到 URL query,保证筛选结果和“数据需求”目标分组都可以直接作为链接分享。
|
||||
|
||||
import { lastDaysRange, thisMonthRange, todayRange } from "../utils/time.js";
|
||||
|
||||
export const ALL = "all";
|
||||
|
||||
const TABLE_MODES = new Set(["wide", "requirements"]);
|
||||
const REQUIREMENT_SECTIONS = new Set(["new_users", "revenue", "retention", "active", "gift", "game"]);
|
||||
|
||||
export function createDefaultTableState() {
|
||||
return {
|
||||
mode: "wide",
|
||||
requirementSection: "new_users"
|
||||
};
|
||||
}
|
||||
|
||||
export const DATE_PRESETS = [
|
||||
{ key: "today", label: "今日" },
|
||||
{ key: "yesterday", label: "昨日" },
|
||||
@ -117,11 +127,12 @@ export function bucketDay(statDay, granularity) {
|
||||
return statDay;
|
||||
}
|
||||
|
||||
const URL_KEYS = ["preset", "start", "end", "apps", "regions", "operator", "view", "granularity"];
|
||||
const URL_KEYS = ["preset", "start", "end", "apps", "regions", "operator", "view", "granularity", "mode", "section"];
|
||||
|
||||
export function readStateFromURL(location = window.location) {
|
||||
const params = new URLSearchParams(location.search);
|
||||
const filters = createDefaultFilters();
|
||||
const table = createDefaultTableState();
|
||||
let view = "";
|
||||
if (params.get("preset") && DATE_PRESETS.some((item) => item.key === params.get("preset"))) {
|
||||
filters.preset = params.get("preset");
|
||||
@ -143,16 +154,23 @@ export function readStateFromURL(location = window.location) {
|
||||
if (params.get("view")) {
|
||||
view = params.get("view");
|
||||
}
|
||||
const requestedTableMode = params.get("mode");
|
||||
if (view === "table" && TABLE_MODES.has(requestedTableMode)) {
|
||||
table.mode = requestedTableMode;
|
||||
if (requestedTableMode === "requirements" && REQUIREMENT_SECTIONS.has(params.get("section"))) {
|
||||
table.requirementSection = params.get("section");
|
||||
}
|
||||
}
|
||||
if (!filters.apps.length) {
|
||||
filters.apps = [ALL];
|
||||
}
|
||||
if (!filters.regions.length) {
|
||||
filters.regions = [ALL];
|
||||
}
|
||||
return { filters, view };
|
||||
return { filters, table, view };
|
||||
}
|
||||
|
||||
export function writeStateToURL(filters, view, history = window.history, location = window.location) {
|
||||
export function writeStateToURL(filters, view, table, history = window.history, location = window.location) {
|
||||
const params = new URLSearchParams(location.search);
|
||||
URL_KEYS.forEach((key) => params.delete(key));
|
||||
if (filters.preset !== "yesterday") {
|
||||
@ -181,6 +199,13 @@ export function writeStateToURL(filters, view, history = window.history, locatio
|
||||
if (view && view !== "overview") {
|
||||
params.set("view", view);
|
||||
}
|
||||
if (view === "table" && table?.mode === "requirements") {
|
||||
// 目标分组与数据需求模式一起入 URL,保证概览下钻和复制后的链接都能落到同一个游戏数据视图。
|
||||
params.set("mode", "requirements");
|
||||
if (REQUIREMENT_SECTIONS.has(table.requirementSection) && table.requirementSection !== "new_users") {
|
||||
params.set("section", table.requirementSection);
|
||||
}
|
||||
}
|
||||
const query = params.toString();
|
||||
history.replaceState(null, "", `${location.pathname}${query ? `?${query}` : ""}`);
|
||||
}
|
||||
|
||||
@ -477,16 +477,18 @@ export function DataTableView() {
|
||||
range,
|
||||
refreshToken,
|
||||
requirements,
|
||||
requirementsError
|
||||
requirementsError,
|
||||
tableState,
|
||||
updateTableState
|
||||
} = useSocialBi();
|
||||
const [mode, setMode] = useState("wide");
|
||||
const [dimKey, setDimKey] = useState("appDaily");
|
||||
const [closedGroups, setClosedGroups] = useState(readClosedGroups);
|
||||
const [sort, setSort] = useState(null);
|
||||
const [requirementSection, setRequirementSection] = useState(REQUIREMENT_SECTIONS[0].key);
|
||||
const [requirementRole, setRequirementRole] = useState("all");
|
||||
const [requirementPayer, setRequirementPayer] = useState("all");
|
||||
const [requirementNewUser, setRequirementNewUser] = useState("all");
|
||||
const mode = tableState?.mode || TABLE_MODES[0].key;
|
||||
const requirementSection = tableState?.requirementSection || REQUIREMENT_SECTIONS[0].key;
|
||||
|
||||
const dim = DIMENSIONS.find((item) => item.key === dimKey) || DIMENSIONS[0];
|
||||
const dimColumns = dim.dims.map((name) => DIM_COLUMNS[name]);
|
||||
@ -575,7 +577,7 @@ export function DataTableView() {
|
||||
|
||||
const handleRequirementSectionChange = (key) => {
|
||||
const nextSection = requirementSectionConfig(key);
|
||||
setRequirementSection(nextSection.key);
|
||||
updateTableState({ requirementSection: nextSection.key });
|
||||
if (!nextSection.roleFilter) {
|
||||
setRequirementRole("all");
|
||||
}
|
||||
@ -889,7 +891,7 @@ export function DataTableView() {
|
||||
aria-checked={mode === item.key}
|
||||
className={mode === item.key ? "is-active" : ""}
|
||||
key={item.key}
|
||||
onClick={() => setMode(item.key)}
|
||||
onClick={() => updateTableState({ mode: item.key })}
|
||||
role="radio"
|
||||
type="button"
|
||||
>
|
||||
|
||||
@ -174,7 +174,7 @@ function OverviewSkeleton() {
|
||||
}
|
||||
|
||||
export function OverviewView() {
|
||||
const { appCodes, derived, filters, isLoading, setViewKey, updateFilter } = useSocialBi();
|
||||
const { appCodes, derived, filters, isLoading, openRequirementSection, setViewKey, updateFilter } = useSocialBi();
|
||||
const [trendMetric, setTrendMetric] = useState("recharge_usd_minor");
|
||||
const [compareMetric, setCompareMetric] = useState("recharge_usd_minor");
|
||||
|
||||
@ -332,20 +332,26 @@ export function OverviewView() {
|
||||
<strong>{trendLabel}趋势</strong>
|
||||
<small>按 App 堆叠</small>
|
||||
<div className="sbi-card-toolbar">
|
||||
<div className="sbi-seg" role="radiogroup" aria-label="趋势指标">
|
||||
<div className="sbi-seg" role="group" aria-label="趋势指标与明细入口">
|
||||
{TREND_METRICS.map((item) => (
|
||||
<button
|
||||
aria-checked={trendMetric === item.key}
|
||||
aria-pressed={trendMetric === item.key}
|
||||
className={trendMetric === item.key ? "is-active" : ""}
|
||||
key={item.key}
|
||||
onClick={() => setTrendMetric(item.key)}
|
||||
role="radio"
|
||||
title={metricTooltip(item.key)}
|
||||
type="button"
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
onClick={() => openRequirementSection("game")}
|
||||
title="查看数据需求中的游戏数据"
|
||||
type="button"
|
||||
>
|
||||
游戏流水
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
13
external-admin/__repro__.html
Normal file
13
external-admin/__repro__.html
Normal 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>
|
||||
42
external-admin/__repro__.jsx
Normal file
42
external-admin/__repro__.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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 并严格递增");
|
||||
}
|
||||
|
||||
@ -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() {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user