From 8c002e023bc81c8a9ce14531849d2e2e83a43b48 Mon Sep 17 00:00:00 2001 From: zhx Date: Thu, 16 Jul 2026 10:36:48 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B8=B8=E6=88=8F=E6=B5=81=E6=B0=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/launch.json | 6 ++++ databi/src/social/SocialBiApp.jsx | 20 ++++++++--- databi/src/social/state.js | 35 ++++++++++++++++--- databi/src/social/views/DataTableView.jsx | 12 ++++--- databi/src/social/views/OverviewView.jsx | 14 +++++--- external-admin/__repro__.html | 13 +++++++ external-admin/__repro__.jsx | 42 +++++++++++++++++++++++ ops-center/src/configForm.js | 20 ++++++++--- ops-center/src/configForm.test.js | 26 ++++++++++++++ 9 files changed, 166 insertions(+), 22 deletions(-) create mode 100644 external-admin/__repro__.html create mode 100644 external-admin/__repro__.jsx diff --git a/.claude/launch.json b/.claude/launch.json index 90164a1..63cf92b 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -6,6 +6,12 @@ "runtimeExecutable": "npm", "runtimeArgs": ["run", "dev"], "port": 7001 + }, + { + "name": "admin-platform-alt", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "dev", "--", "--port", "7031"], + "port": 7031 } ] } diff --git a/databi/src/social/SocialBiApp.jsx b/databi/src/social/SocialBiApp.jsx index 1f79363..cd8af39 100644 --- a/databi/src/social/SocialBiApp.jsx +++ b/databi/src/social/SocialBiApp.jsx @@ -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; diff --git a/databi/src/social/state.js b/databi/src/social/state.js index efdc835..b0c8b9b 100644 --- a/databi/src/social/state.js +++ b/databi/src/social/state.js @@ -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}` : ""}`); } diff --git a/databi/src/social/views/DataTableView.jsx b/databi/src/social/views/DataTableView.jsx index f574b16..cf9004d 100644 --- a/databi/src/social/views/DataTableView.jsx +++ b/databi/src/social/views/DataTableView.jsx @@ -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" > diff --git a/databi/src/social/views/OverviewView.jsx b/databi/src/social/views/OverviewView.jsx index 68d76db..7909faa 100644 --- a/databi/src/social/views/OverviewView.jsx +++ b/databi/src/social/views/OverviewView.jsx @@ -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() { {trendLabel}趋势 按 App 堆叠
-
+
{TREND_METRICS.map((item) => ( ))} +
diff --git a/external-admin/__repro__.html b/external-admin/__repro__.html new file mode 100644 index 0000000..54d509c --- /dev/null +++ b/external-admin/__repro__.html @@ -0,0 +1,13 @@ + + + + + + filter-bar repro + + + +
+ + + diff --git a/external-admin/__repro__.jsx b/external-admin/__repro__.jsx new file mode 100644 index 0000000..36c9fbf --- /dev/null +++ b/external-admin/__repro__.jsx @@ -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( + + + + + +); + +function Root() { + const { direction } = useExternalI18n(); + const theme = useMemo(() => createExternalTheme(direction), [direction]); + return ( + + + + + + + + + + + ); +} diff --git a/ops-center/src/configForm.js b/ops-center/src/configForm.js index f814777..2274762 100644 --- a/ops-center/src/configForm.js +++ b/ops-center/src/configForm.js @@ -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 并严格递增"); } diff --git a/ops-center/src/configForm.test.js b/ops-center/src/configForm.test.js index df9118d..6e22daf 100644 --- a/ops-center/src/configForm.test.js +++ b/ops-center/src/configForm.test.js @@ -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() {