From 46bd69cb017f5b319d13196c48db25090ec58101 Mon Sep 17 00:00:00 2001 From: zhx Date: Sun, 5 Jul 2026 00:08:35 +0800 Subject: [PATCH] =?UTF-8?q?=E6=BC=8F=E6=96=97=E6=95=B0=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- contracts/admin-openapi.json | 19 ++ databi/src/DatabiApp.test.jsx | 91 +++++- databi/src/api.js | 20 ++ databi/src/social/SocialBiApp.jsx | 24 +- databi/src/social/useSocialBiData.js | 32 +- databi/src/social/views/FunnelView.jsx | 382 ++++++++++++++++++++++++ databi/src/social/views/funnel-view.css | 221 ++++++++++++++ src/shared/api/generated/endpoints.ts | 8 + src/shared/api/generated/schema.d.ts | 34 +++ 9 files changed, 816 insertions(+), 15 deletions(-) create mode 100644 databi/src/social/views/FunnelView.jsx create mode 100644 databi/src/social/views/funnel-view.css diff --git a/contracts/admin-openapi.json b/contracts/admin-openapi.json index 9a2286c..4590762 100644 --- a/contracts/admin-openapi.json +++ b/contracts/admin-openapi.json @@ -3148,6 +3148,25 @@ "x-permissions": ["overview:view"] } }, + "/admin/databi/social/funnel": { + "get": { + "operationId": "getSocialBiFunnel", + "x-permission": "overview:view", + "parameters": [ + { "in": "query", "name": "stat_tz", "schema": { "type": "string" } }, + { "in": "query", "name": "start_ms", "schema": { "type": "integer", "format": "int64" } }, + { "in": "query", "name": "end_ms", "schema": { "type": "integer", "format": "int64" } }, + { "in": "query", "name": "app_codes", "schema": { "type": "string" } }, + { "in": "query", "name": "region_id", "schema": { "type": "integer", "format": "int64" } } + ], + "responses": { + "200": { + "$ref": "#/components/responses/StatisticsObjectResponse" + } + }, + "x-permissions": ["overview:view"] + } + }, "/admin/databi/social/kpi": { "get": { "operationId": "getSocialBiKpi", diff --git a/databi/src/DatabiApp.test.jsx b/databi/src/DatabiApp.test.jsx index b1e80e3..e31223a 100644 --- a/databi/src/DatabiApp.test.jsx +++ b/databi/src/DatabiApp.test.jsx @@ -4,6 +4,7 @@ import { DatabiApp } from "./DatabiApp.jsx"; import { fetchFilterOptions, fetchSelfGameStatisticsOverview, + fetchSocialBiFunnel, fetchSocialBiKpi, fetchSocialBiMaster, fetchSocialBiOverview, @@ -13,6 +14,7 @@ import { vi.mock("./api.js", () => ({ fetchFilterOptions: vi.fn(), fetchSocialBiFilterOptions: vi.fn(), + fetchSocialBiFunnel: vi.fn(), fetchSocialBiKpi: vi.fn(), fetchSocialBiKpiTargets: vi.fn(), fetchSocialBiMaster: vi.fn(), @@ -56,6 +58,7 @@ beforeEach(() => { regions: [] }); fetchSocialBiOverview.mockResolvedValue({ access: { all: true, scopes: [] }, apps: [] }); + fetchSocialBiFunnel.mockResolvedValue({ access: { all: true, scopes: [] }, apps: [] }); fetchSocialBiKpi.mockResolvedValue({ items: [], period_month: "2026-06", summary: {} }); }); @@ -322,16 +325,17 @@ test("routes databi social page to Social BI and loads real overview rows", asyn await flushEffects(); - // v2 Shell:主视图容器 + 侧栏五个视图 tab,默认选中“经营概览”。 + // v2 Shell:主视图容器 + 侧栏视图 tab,默认选中“经营概览”。 expect(screen.getByLabelText("Social BI 数据中心")).toBeTruthy(); const viewTabs = screen.getByRole("tablist", { name: "数据视图" }); - ["经营概览", "地区洞察", "运营中心", "留存质量", "数据明细"].forEach((name) => { + ["经营概览", "地区洞察", "运营中心", "留存质量", "数据明细", "埋点漏斗"].forEach((name) => { expect(within(viewTabs).getByRole("tab", { name })).toBeTruthy(); }); expect(within(viewTabs).getByRole("tab", { name: "经营概览" })).toHaveAttribute("aria-selected", "true"); expect(fetchSocialBiMaster).toHaveBeenCalledTimes(1); expect(fetchSocialBiOverview).toHaveBeenCalledWith(expect.objectContaining({ statTz: "Asia/Shanghai" })); + expect(fetchSocialBiFunnel).toHaveBeenCalledWith(expect.objectContaining({ statTz: "Asia/Shanghai" })); expect(fetchSocialBiKpi).toHaveBeenCalledWith(expect.objectContaining({ statTz: "Asia/Shanghai" })); // 经营概览:App 名、Hero 汇总充值(12,300 + 5,000 USD 分 = $173)、 @@ -362,6 +366,89 @@ test("routes databi social page to Social BI and loads real overview rows", asyn expect(screen.getByRole("button", { name: "配置目标" })).toBeTruthy(); }); +test("renders social BI funnel view and limits funnel apps to supported apps", async () => { + window.history.pushState(null, "", "/databi/social/?view=funnel"); + fetchSocialBiMaster.mockResolvedValue({ + access: { all: true, scopes: [] }, + apps: [ + { app_code: "lalu", app_name: "Lalu", kind: "hyapp" }, + { app_code: "huwaa", app_name: "Huwaa", kind: "hyapp" }, + { app_code: "fami", app_name: "Fami", kind: "hyapp" }, + { app_code: "aslan", app_name: "Aslan", kind: "legacy" }, + { app_code: "yumi", app_name: "Yumi", kind: "legacy" } + ], + operators: [], + permissions: { kpi_manage: false, kpi_view_all: true }, + regions: [] + }); + fetchSocialBiFunnel.mockResolvedValue({ + access: { all: true, scopes: [] }, + apps: [ + { + app_code: "lalu", + app_name: "Lalu", + cohorts: [ + { + base_users: 1, + d1_retention_rate: 1, + d1_retention_users: 1, + dimension: "country", + label: "US", + steps: [ + { event_name: "login_success", user_count: 1 }, + { event_name: "profile_complete", user_count: 1 }, + { event_name: "room_join_success", user_count: 1 }, + { event_name: "stay_3m", user_count: 1 }, + { event_name: "send_message", user_count: 1 } + ], + value: "US" + } + ], + kind: "hyapp", + steps: [ + { event_name: "login_start", event_count: 2, label: "登录开始", user_count: 2 }, + { event_name: "login_success", event_count: 1, label: "登录成功", user_count: 1 }, + { event_name: "room_join_success", event_count: 1, label: "进房成功", user_count: 1 }, + { event_name: "room_join_fail", event_count: 1, is_failure: true, label: "进房失败", user_count: 1 }, + { event_name: "stay_3m", event_count: 1, label: "停留 3 分钟", user_count: 1 }, + { event_name: "send_message", event_count: 1, label: "发消息", user_count: 1 } + ], + totals: { + d1_retention_base_users: 1, + d1_retention_rate: 1, + d1_retention_users: 1, + login_start_users: 2, + login_success_users: 1, + room_join_fail_users: 1, + room_join_success_users: 1 + } + } + ] + }); + + render(); + + await flushEffects(); + + expect(screen.getByRole("tab", { name: "埋点漏斗" })).toHaveAttribute("aria-selected", "true"); + expect(screen.getByRole("button", { name: "全部漏斗 App" })).toBeTruthy(); + expect(screen.getByRole("button", { name: /Lalu/ })).toBeTruthy(); + expect(screen.getByRole("button", { name: /Huwaa/ })).toBeTruthy(); + expect(screen.getByRole("button", { name: /Fami/ })).toBeTruthy(); + expect(screen.queryByRole("button", { name: /Aslan/ })).toBeNull(); + expect(screen.queryByRole("button", { name: /Yumi/ })).toBeNull(); + expect(fetchSocialBiFunnel).toHaveBeenCalledWith(expect.objectContaining({ appCodes: ["lalu", "huwaa", "fami"] })); + + expect(screen.getByText("主路径转化")).toBeTruthy(); + expect(screen.getByText("事件明细")).toBeTruthy(); + expect(screen.getByText("D1 Cohort")).toBeTruthy(); + expect(screen.getByText("login_start")).toBeTruthy(); + expect(screen.getByText("room_join_fail")).toBeTruthy(); + expect(screen.getByText("US")).toBeTruthy(); + expect(screen.getAllByText("100.0%").length).toBeGreaterThan(0); + expect(screen.getByTestId("databi-chart")).toBeTruthy(); +}); + async function flushEffects() { await act(async () => { for (let index = 0; index < 10; index += 1) { diff --git a/databi/src/api.js b/databi/src/api.js index 93fe769..ac77c4d 100644 --- a/databi/src/api.js +++ b/databi/src/api.js @@ -180,6 +180,26 @@ export async function fetchSocialBiOverview({ appCodes, endMs, regionId, startMs return fetchDatabiData(apiEndpointPath(API_OPERATIONS.getSocialBiOverview), { query }); } +export async function fetchSocialBiFunnel({ appCodes, endMs, regionId, startMs, statTz }) { + const query = {}; + if (statTz) { + query.stat_tz = statTz; + } + if (startMs) { + query.start_ms = String(startMs); + } + if (endMs) { + query.end_ms = String(endMs); + } + if (appCodes?.length) { + query.app_codes = appCodes.join(","); + } + if (regionId) { + query.region_id = String(regionId); + } + return fetchDatabiData(apiEndpointPath(API_OPERATIONS.getSocialBiFunnel), { query }); +} + export async function fetchSocialBiKpi({ appCodes, endMs, operatorUserId, periodMonth, startMs, statTz }) { const query = {}; if (statTz) { diff --git a/databi/src/social/SocialBiApp.jsx b/databi/src/social/SocialBiApp.jsx index 303acb0..5845ef8 100644 --- a/databi/src/social/SocialBiApp.jsx +++ b/databi/src/social/SocialBiApp.jsx @@ -2,6 +2,7 @@ // 视图组件通过 useSocialBi() 消费统一的筛选状态与数据派生结果。 import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; +import AccountTreeOutlined from "@mui/icons-material/AccountTreeOutlined"; import GroupsOutlined from "@mui/icons-material/GroupsOutlined"; import InsightsOutlined from "@mui/icons-material/InsightsOutlined"; import PublicOutlined from "@mui/icons-material/PublicOutlined"; @@ -20,12 +21,13 @@ import { } from "./state.js"; import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx"; import { appColor } from "./metrics.js"; -import { useSocialBiData } from "./useSocialBiData.js"; +import { SOCIAL_BI_FUNNEL_APP_CODES, useSocialBiData } from "./useSocialBiData.js"; import { OverviewView } from "./views/OverviewView.jsx"; import { RegionsView } from "./views/RegionsView.jsx"; import { TeamKpiView } from "./views/TeamKpiView.jsx"; import { RetentionView } from "./views/RetentionView.jsx"; import { DataTableView } from "./views/DataTableView.jsx"; +import { FunnelView } from "./views/FunnelView.jsx"; import "../styles/social-v2.css"; const VIEWS = [ @@ -33,7 +35,8 @@ const VIEWS = [ { component: RegionsView, icon: PublicOutlined, key: "regions", label: "地区洞察" }, { component: TeamKpiView, icon: GroupsOutlined, key: "team", label: "运营中心" }, { component: RetentionView, icon: RepeatOutlined, key: "retention", label: "留存质量" }, - { component: DataTableView, icon: TableChartOutlined, key: "table", label: "数据明细" } + { component: DataTableView, icon: TableChartOutlined, key: "table", label: "数据明细" }, + { component: FunnelView, icon: AccountTreeOutlined, key: "funnel", label: "埋点漏斗" } ]; const SocialBiContext = createContext(null); @@ -76,7 +79,7 @@ export function SocialBiApp() {
- + {data.errors.length ? (
{data.errors.map((message, index) => ( @@ -133,7 +136,7 @@ function Sidebar({ data, onViewChange, viewKey }) { ); } -function TopBar({ data, filters, updateFilter }) { +function TopBar({ data, filters, updateFilter, viewKey }) { return (
@@ -144,7 +147,7 @@ function TopBar({ data, filters, updateFilter }) {
- + {rangeLabel(data.range)} {data.isLoading ? : null} @@ -228,10 +231,13 @@ function GranularityControl({ filters, updateFilter }) { ); } -function AppChips({ data, filters, updateFilter }) { - const apps = data.master?.apps || []; +function AppChips({ data, filters, updateFilter, viewKey }) { + const apps = viewKey === "funnel" + ? (data.master?.apps || []).filter((app) => SOCIAL_BI_FUNNEL_APP_CODES.includes(app.app_code)) + : data.master?.apps || []; const allCodes = apps.map((app) => app.app_code); - const isAll = filters.apps.includes(ALL); + const hasSelectedVisibleApp = filters.apps.some((appCode) => allCodes.includes(appCode)); + const isAll = filters.apps.includes(ALL) || (viewKey === "funnel" && !hasSelectedVisibleApp); return (
{apps.map((app) => { const selected = !isAll && filters.apps.includes(app.app_code); diff --git a/databi/src/social/useSocialBiData.js b/databi/src/social/useSocialBiData.js index b095083..e0cbd5d 100644 --- a/databi/src/social/useSocialBiData.js +++ b/databi/src/social/useSocialBiData.js @@ -1,18 +1,20 @@ -// 社交 BI v2 的数据层:负责 master/overview/kpi 三个接口的拉取与派生行, +// 社交 BI v2 的数据层:负责 master/overview/funnel/kpi 接口的拉取与派生行, // 视图不直接调 API,只消费这里的产物(原始 snake_case 指标行 + 维度字段)。 import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { fetchSocialBiKpi, fetchSocialBiMaster, fetchSocialBiOverview } from "../api.js"; +import { fetchSocialBiFunnel, fetchSocialBiKpi, fetchSocialBiMaster, fetchSocialBiOverview } from "../api.js"; import { DEFAULT_TIME_ZONE, rangeEndMs, rangeStartMs } from "../utils/time.js"; import { ALL, appSelectionMatches, regionSelectionMatches, resolveDateRange } from "./state.js"; export const SOCIAL_BI_TZ = DEFAULT_TIME_ZONE; +export const SOCIAL_BI_FUNNEL_APP_CODES = ["lalu", "huwaa", "fami"]; export function useSocialBiData(filters) { const [master, setMaster] = useState(null); // 数据请求等 master 结果落地后再发:否则会先用兜底 App 列表打一轮,master 到达后立刻重打一轮(整轮浪费)。 const [isMasterSettled, setIsMasterSettled] = useState(false); const [overview, setOverview] = useState(null); + const [funnel, setFunnel] = useState(null); const [kpi, setKpi] = useState(null); const [isLoading, setIsLoading] = useState(true); const [errors, setErrors] = useState([]); @@ -55,6 +57,14 @@ export function useSocialBiData(filters) { } return filters.apps.filter((appCode) => !available.length || available.includes(appCode)); }, [filters.apps, master]); + const funnelAppCodes = useMemo(() => { + const available = (master?.apps || []).map((app) => app.app_code).filter((appCode) => SOCIAL_BI_FUNNEL_APP_CODES.includes(appCode)); + if (!filters.apps.length || filters.apps.includes(ALL)) { + return available; + } + const selected = filters.apps.filter((appCode) => available.includes(appCode)); + return selected.length ? selected : available; + }, [filters.apps, master]); useEffect(() => { if (!isMasterSettled) { @@ -66,8 +76,9 @@ export function useSocialBiData(filters) { setIsLoading(true); Promise.allSettled([ fetchSocialBiOverview({ appCodes, endMs, startMs, statTz: SOCIAL_BI_TZ }), + fetchSocialBiFunnel({ appCodes: funnelAppCodes, endMs, startMs, statTz: SOCIAL_BI_TZ }), fetchSocialBiKpi({ appCodes, endMs, startMs, statTz: SOCIAL_BI_TZ }) - ]).then(([overviewResult, kpiResult]) => { + ]).then(([overviewResult, funnelResult, kpiResult]) => { if (sequence !== requestSeq.current) { return; } @@ -83,6 +94,17 @@ export function useSocialBiData(filters) { setOverview(null); nextErrors.push(`统计数据加载失败: ${overviewResult.reason?.message || "未知错误"}`); } + if (funnelResult.status === "fulfilled") { + setFunnel(funnelResult.value); + (funnelResult.value?.apps || []).forEach((app) => { + if (app.error) { + nextErrors.push(`${app.app_name || app.app_code}: ${app.error}`); + } + }); + } else { + setFunnel(null); + nextErrors.push(`埋点漏斗加载失败: ${funnelResult.reason?.message || "未知错误"}`); + } if (kpiResult.status === "fulfilled") { setKpi(kpiResult.value); Object.entries(kpiResult.value?.app_errors || {}).forEach(([appCode, message]) => { @@ -95,7 +117,7 @@ export function useSocialBiData(filters) { setErrors(nextErrors); setIsLoading(false); }); - }, [appCodes, isMasterSettled, range, refreshToken]); + }, [appCodes, funnelAppCodes, isMasterSettled, range, refreshToken]); const refresh = useCallback(() => { setRefreshToken((current) => current + 1); @@ -107,6 +129,8 @@ export function useSocialBiData(filters) { appCodes, derived, errors, + funnel, + funnelAppCodes, isLoading, kpi, master, diff --git a/databi/src/social/views/FunnelView.jsx b/databi/src/social/views/FunnelView.jsx new file mode 100644 index 0000000..25d2d0d --- /dev/null +++ b/databi/src/social/views/FunnelView.jsx @@ -0,0 +1,382 @@ +// 埋点漏斗:展示 App 原始 tracking 事件的总体漏斗、失败诊断和 D1 cohort。 +// 数据源为 statistics-service 的 app_tracking_events 聚合结果,视图只做跨 App 求和和排序。 + +import { useMemo, useState } from "react"; +import { EChart } from "../../charts/EChart.jsx"; +import { formatCount, formatRatioPercent, isBlank } from "../format.js"; +import { rangeLabel } from "../state.js"; +import { useSocialBi } from "../SocialBiApp.jsx"; +import "./funnel-view.css"; + +const COHORT_DIMENSIONS = [ + { key: "country", label: "国家" }, + { key: "language", label: "语言" }, + { key: "channel", label: "渠道" }, + { key: "login_method", label: "登录方式" }, + { key: "first_room_stay", label: "首房停留时长" } +]; + +const COHORT_STEP_COLUMNS = [ + { key: "login_success", label: "登录成功" }, + { key: "profile_complete", label: "资料完成" }, + { key: "room_join_success", label: "进房成功" }, + { key: "stay_3m", label: "停留 3m" }, + { key: "stay_10m", label: "停留 10m" }, + { key: "send_message", label: "发消息" } +]; + +const SUMMARY_CARDS = [ + { key: "login_start_users", label: "登录开始", type: "count" }, + { key: "login_success_rate", label: "登录成功率", type: "ratio" }, + { key: "room_join_success_users", label: "进房成功", type: "count" }, + { key: "d1_retention_rate", label: "D1 留存", type: "ratio" }, + { key: "room_join_fail_users", label: "进房失败", type: "count" } +]; + +const SUPPORTED_FUNNEL_APPS = "Lalu / Huwaa / Fami"; + +export function FunnelView() { + const { funnel, isLoading, range } = useSocialBi(); + const [dimension, setDimension] = useState("country"); + const appRows = useMemo(() => (funnel?.apps || []).filter((app) => !app.error), [funnel]); + const appErrors = useMemo(() => (funnel?.apps || []).filter((app) => app.error), [funnel]); + const steps = useMemo(() => aggregateSteps(appRows), [appRows]); + const totals = useMemo(() => aggregateTotals(appRows), [appRows]); + const cohorts = useMemo(() => aggregateCohorts(appRows), [appRows]); + const selectedCohorts = useMemo( + () => (cohorts.get(dimension) || []).slice(0, 50), + [cohorts, dimension] + ); + const chartOption = useMemo(() => funnelChartOption(steps), [steps]); + + if (isLoading && !appRows.length) { + return ( +
+
+ {["70%", "92%", "84%", "76%", "88%"].map((width) => ( + + ))} +
+
+ ); + } + + if (!appRows.length) { + return ( +
+
+ 当前无埋点漏斗数据 + + {appErrors.length + ? "所选 App 暂未接入漏斗或统计服务返回错误" + : `埋点漏斗 App 筛选目前仅支持 ${SUPPORTED_FUNNEL_APPS}`} + +
+
+ ); + } + + return ( +
+
+
+

埋点漏斗

+ + {rangeLabel(range)} · 仅支持 {SUPPORTED_FUNNEL_APPS} + +
+
+ {appRows.map((app) => ( + {app.app_name || app.app_code} + ))} +
+
+ + {appErrors.length ? ( +
+ {appErrors.map((app) => ( + + {app.app_name || app.app_code}: {app.error} + + ))} +
+ ) : null} + +
+ {SUMMARY_CARDS.map((item) => ( +
+ {item.label} + {item.type === "ratio" ? formatRatioPercent(totals[item.key]) : formatCount(totals[item.key])} +
+ ))} +
+ +
+
+
+
+

主路径转化

+ 按去重用户数计算 +
+
+ +
+ +
+
+
+

事件明细

+ 含进房失败和互动动作 +
+
+
+ + + + + + + + + + + + + {steps.map((step) => ( + + + + + + + + + ))} + +
事件用户次数上一步总转化流失
+ {step.label || step.event_name} + {step.event_name} + {formatCount(step.user_count)}{formatCount(step.event_count)}{formatRatioPercent(step.previous_conversion_rate)}{formatRatioPercent(step.overall_conversion_rate)}{formatCount(step.dropoff_users)}
+
+
+
+ +
+
+
+

D1 Cohort

+ 基准用户为登录成功用户 +
+
+ {COHORT_DIMENSIONS.map((item) => ( + + ))} +
+
+
+ + + + + + + + {COHORT_STEP_COLUMNS.map((item) => ( + + ))} + + + + {selectedCohorts.length ? ( + selectedCohorts.map((row) => ( + + + + + + {COHORT_STEP_COLUMNS.map((item) => ( + + ))} + + )) + ) : ( + + + + )} + +
{COHORT_DIMENSIONS.find((item) => item.key === dimension)?.label || "Cohort"}基准用户D1 用户D1 留存{item.label}
+ {row.label || row.value || "unknown"} + {formatCount(row.base_users)}{formatCount(row.d1_retention_users)}{formatRatioPercent(row.d1_retention_rate)}{formatCount(stepUserCount(row, item.key))}
+ 当前维度暂无 cohort 数据 +
+
+
+
+ ); +} + +function aggregateSteps(appRows) { + const order = []; + const byEvent = new Map(); + appRows.forEach((app) => { + (app.steps || []).forEach((step) => { + const key = step.event_name || step.key; + if (!key) { + return; + } + if (!byEvent.has(key)) { + order.push(key); + byEvent.set(key, { + ...step, + device_count: 0, + event_count: 0, + user_count: 0 + }); + } + const current = byEvent.get(key); + current.device_count += Number(step.device_count || 0); + current.event_count += Number(step.event_count || 0); + current.user_count += Number(step.user_count || 0); + current.is_failure = Boolean(current.is_failure || step.is_failure); + if (!current.label) { + current.label = step.label; + } + }); + }); + const baseUsers = Number(byEvent.get("login_start")?.user_count || (order.length ? byEvent.get(order[0])?.user_count || 0 : 0)); + let previousUsers = 0; + return order.map((key) => { + const step = byEvent.get(key); + const userCount = Number(step.user_count || 0); + const out = { + ...step, + dropoff_users: previousUsers > userCount ? previousUsers - userCount : 0, + overall_conversion_rate: ratio(userCount, baseUsers), + previous_conversion_rate: ratio(userCount, previousUsers) + }; + previousUsers = userCount; + return out; + }); +} + +function aggregateTotals(appRows) { + const totals = {}; + let d1Base = 0; + let d1Users = 0; + appRows.forEach((app) => { + Object.entries(app.totals || {}).forEach(([key, value]) => { + if (key.endsWith("_rate")) { + return; + } + totals[key] = Number(totals[key] || 0) + Number(value || 0); + }); + d1Base += Number(app.totals?.d1_retention_base_users || 0); + d1Users += Number(app.totals?.d1_retention_users || 0); + }); + totals.login_success_rate = ratio(totals.login_success_users, totals.login_start_users); + totals.d1_retention_rate = ratio(d1Users, d1Base); + return totals; +} + +function aggregateCohorts(appRows) { + const grouped = new Map(); + appRows.forEach((app) => { + (app.cohorts || []).forEach((cohort) => { + const dimension = cohort.dimension || "unknown"; + const value = cohort.value || cohort.label || "unknown"; + if (!grouped.has(dimension)) { + grouped.set(dimension, new Map()); + } + const bucket = grouped.get(dimension); + if (!bucket.has(value)) { + bucket.set(value, { + dimension, + label: cohort.label || value, + value, + base_users: 0, + d1_retention_users: 0, + d1_retention_rate: 0, + steps: new Map() + }); + } + const row = bucket.get(value); + row.base_users += Number(cohort.base_users || 0); + row.d1_retention_users += Number(cohort.d1_retention_users || 0); + (cohort.steps || []).forEach((step) => { + const eventName = step.event_name; + if (!eventName) { + return; + } + row.steps.set(eventName, Number(row.steps.get(eventName) || 0) + Number(step.user_count || 0)); + }); + }); + }); + const out = new Map(); + grouped.forEach((bucket, dimension) => { + const rows = [...bucket.values()].map((row) => ({ + ...row, + d1_retention_rate: ratio(row.d1_retention_users, row.base_users), + steps: [...row.steps.entries()].map(([event_name, user_count]) => ({ event_name, user_count })) + })); + rows.sort((left, right) => Number(right.base_users || 0) - Number(left.base_users || 0)); + out.set(dimension, rows); + }); + return out; +} + +function stepUserCount(row, eventName) { + const step = (row.steps || []).find((item) => item.event_name === eventName); + return step?.user_count; +} + +function funnelChartOption(steps) { + const data = steps + .filter((step) => !step.is_failure && Number(step.user_count || 0) > 0) + .map((step) => ({ + name: step.label || step.event_name, + value: Number(step.user_count || 0) + })); + return { + color: ["#3056d3", "#3b82f6", "#14b8a6", "#22c55e", "#f59e0b", "#ef4444"], + series: [ + { + bottom: 18, + data, + gap: 4, + label: { + color: "#1f2a44", + formatter: ({ name, value }) => `${name}\n${formatCount(value)}` + }, + left: 18, + minSize: "8%", + right: 18, + sort: "none", + top: 10, + type: "funnel" + } + ], + tooltip: { + backgroundColor: "#ffffff", + borderColor: "#e3eaf3", + textStyle: { color: "#263246" }, + valueFormatter: (value) => formatCount(value) + } + }; +} + +function ratio(numerator, denominator) { + if (isBlank(denominator) || Number(denominator) <= 0) { + return null; + } + return Number(numerator || 0) / Number(denominator); +} diff --git a/databi/src/social/views/funnel-view.css b/databi/src/social/views/funnel-view.css new file mode 100644 index 0000000..c376d4b --- /dev/null +++ b/databi/src/social/views/funnel-view.css @@ -0,0 +1,221 @@ +/* 埋点漏斗视图专属样式:类名前缀 sbi-funnel-,沿用 social-v2 token。 */ + +.sbi-funnel { + display: flex; + flex-direction: column; + gap: 16px; +} + +.sbi-funnel-head { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 16px; +} + +.sbi-funnel-head h1 { + margin: 0 0 4px; + color: var(--sbi-text); + font-size: 22px; + line-height: 1.2; +} + +.sbi-funnel-head span { + color: var(--sbi-text-2); + font-size: 12.5px; +} + +.sbi-funnel-apps { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 8px; +} + +.sbi-funnel-apps span { + padding: 5px 10px; + border: 1px solid var(--sbi-border); + border-radius: 999px; + background: #ffffff; + color: var(--sbi-text-2); + font-weight: 700; +} + +.sbi-funnel-warning { + display: grid; + gap: 4px; + padding: 10px 14px; + border: 1px solid #f3ddc0; + border-radius: 10px; + background: var(--sbi-warn-bg); + color: var(--sbi-warn-text); + font-size: 12.5px; +} + +.sbi-funnel-summary { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 12px; +} + +.sbi-funnel-kpi { + display: grid; + gap: 8px; + padding: 14px 16px; +} + +.sbi-funnel-kpi span { + color: var(--sbi-text-2); + font-size: 12.5px; + font-weight: 700; +} + +.sbi-funnel-kpi strong { + color: var(--sbi-text); + font-size: 23px; + line-height: 1.1; + font-variant-numeric: tabular-nums; +} + +.sbi-funnel-grid { + display: grid; + grid-template-columns: minmax(380px, 0.95fr) minmax(520px, 1.25fr); + gap: 16px; +} + +.sbi-funnel-chart-card, +.sbi-funnel-table-card, +.sbi-funnel-cohort-card { + min-width: 0; +} + +.sbi-card-title { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 14px 18px 0; +} + +.sbi-card-title h2 { + margin: 0 0 3px; + color: var(--sbi-text); + font-size: 15px; + line-height: 1.2; +} + +.sbi-card-title span { + color: var(--sbi-text-3); + font-size: 12px; +} + +.sbi-funnel-chart { + height: 358px; + margin: 8px 8px 12px; +} + +.sbi-funnel-step-scroll { + max-height: 390px; + margin-top: 10px; +} + +.sbi-funnel-step-table td:first-child { + min-width: 150px; +} + +.sbi-funnel-step-table td strong { + display: block; + color: var(--sbi-text); +} + +.sbi-funnel-step-table td span { + display: block; + margin-top: 2px; + color: var(--sbi-text-3); + font-size: 11.5px; +} + +.sbi-funnel-step-table tr.is-failure td { + background: #fff7ed; +} + +.sbi-funnel-step-table tr.is-failure td:first-child strong { + color: #b45309; +} + +.sbi-funnel-cohort-card { + overflow: hidden; +} + +.sbi-funnel-cohort-title { + align-items: flex-start; +} + +.sbi-funnel-tabs { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 4px; + padding: 3px; + border: 1px solid var(--sbi-border); + border-radius: 9px; + background: #f1f5fa; +} + +.sbi-funnel-tabs button { + padding: 6px 10px; + border: 0; + border-radius: 7px; + background: transparent; + color: var(--sbi-text-2); + font-size: 12.5px; + font-weight: 700; + cursor: pointer; +} + +.sbi-funnel-tabs button.is-active { + background: #ffffff; + color: var(--sbi-accent); + box-shadow: 0 1px 3px rgba(16, 34, 56, 0.12); +} + +.sbi-funnel-cohort-scroll { + max-height: 520px; + margin-top: 12px; +} + +.sbi-funnel-cohort-table th, +.sbi-funnel-cohort-table td { + min-width: 104px; +} + +.sbi-funnel-cohort-table th:first-child, +.sbi-funnel-cohort-table td:first-child { + min-width: 150px; +} + +.sbi-funnel-empty-cell { + padding: 34px 12px !important; + color: var(--sbi-text-3); + text-align: center !important; +} + +.sbi-funnel-skeleton { + display: grid; + gap: 12px; + padding: 20px; +} + +.sbi-funnel-skeleton .sbi-skeleton { + height: 34px; +} + +@media (max-width: 1360px) { + .sbi-funnel-summary { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .sbi-funnel-grid { + grid-template-columns: 1fr; + } +} diff --git a/src/shared/api/generated/endpoints.ts b/src/shared/api/generated/endpoints.ts index dbc3ddb..e62027b 100644 --- a/src/shared/api/generated/endpoints.ts +++ b/src/shared/api/generated/endpoints.ts @@ -139,6 +139,7 @@ export const API_OPERATIONS = { getRoomTurnoverRewardConfig: "getRoomTurnoverRewardConfig", getRoomWhitelistConfig: "getRoomWhitelistConfig", getSevenDayCheckInConfig: "getSevenDayCheckInConfig", + getSocialBiFunnel: "getSocialBiFunnel", getSocialBiKpi: "getSocialBiKpi", getSocialBiMaster: "getSocialBiMaster", getSocialBiOverview: "getSocialBiOverview", @@ -1222,6 +1223,13 @@ export const API_ENDPOINTS: Record = { permission: "seven-day-checkin:view", permissions: ["seven-day-checkin:view"] }, + getSocialBiFunnel: { + method: "GET", + operationId: API_OPERATIONS.getSocialBiFunnel, + path: "/v1/admin/databi/social/funnel", + permission: "overview:view", + permissions: ["overview:view"] + }, getSocialBiKpi: { method: "GET", operationId: API_OPERATIONS.getSocialBiKpi, diff --git a/src/shared/api/generated/schema.d.ts b/src/shared/api/generated/schema.d.ts index e834f85..df08fa4 100644 --- a/src/shared/api/generated/schema.d.ts +++ b/src/shared/api/generated/schema.d.ts @@ -2036,6 +2036,22 @@ export interface paths { patch?: never; trace?: never; }; + "/admin/databi/social/funnel": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getSocialBiFunnel"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/admin/databi/social/kpi": { parameters: { query?: never; @@ -7077,6 +7093,24 @@ export interface operations { 200: components["responses"]["StatisticsObjectResponse"]; }; }; + getSocialBiFunnel: { + parameters: { + query?: { + stat_tz?: string; + start_ms?: number; + end_ms?: number; + app_codes?: string; + region_id?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["StatisticsObjectResponse"]; + }; + }; getSocialBiKpi: { parameters: { query?: {