漏斗数据
This commit is contained in:
parent
ef90b9ed27
commit
46bd69cb01
@ -3148,6 +3148,25 @@
|
|||||||
"x-permissions": ["overview:view"]
|
"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": {
|
"/admin/databi/social/kpi": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "getSocialBiKpi",
|
"operationId": "getSocialBiKpi",
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { DatabiApp } from "./DatabiApp.jsx";
|
|||||||
import {
|
import {
|
||||||
fetchFilterOptions,
|
fetchFilterOptions,
|
||||||
fetchSelfGameStatisticsOverview,
|
fetchSelfGameStatisticsOverview,
|
||||||
|
fetchSocialBiFunnel,
|
||||||
fetchSocialBiKpi,
|
fetchSocialBiKpi,
|
||||||
fetchSocialBiMaster,
|
fetchSocialBiMaster,
|
||||||
fetchSocialBiOverview,
|
fetchSocialBiOverview,
|
||||||
@ -13,6 +14,7 @@ import {
|
|||||||
vi.mock("./api.js", () => ({
|
vi.mock("./api.js", () => ({
|
||||||
fetchFilterOptions: vi.fn(),
|
fetchFilterOptions: vi.fn(),
|
||||||
fetchSocialBiFilterOptions: vi.fn(),
|
fetchSocialBiFilterOptions: vi.fn(),
|
||||||
|
fetchSocialBiFunnel: vi.fn(),
|
||||||
fetchSocialBiKpi: vi.fn(),
|
fetchSocialBiKpi: vi.fn(),
|
||||||
fetchSocialBiKpiTargets: vi.fn(),
|
fetchSocialBiKpiTargets: vi.fn(),
|
||||||
fetchSocialBiMaster: vi.fn(),
|
fetchSocialBiMaster: vi.fn(),
|
||||||
@ -56,6 +58,7 @@ beforeEach(() => {
|
|||||||
regions: []
|
regions: []
|
||||||
});
|
});
|
||||||
fetchSocialBiOverview.mockResolvedValue({ access: { all: true, scopes: [] }, apps: [] });
|
fetchSocialBiOverview.mockResolvedValue({ access: { all: true, scopes: [] }, apps: [] });
|
||||||
|
fetchSocialBiFunnel.mockResolvedValue({ access: { all: true, scopes: [] }, apps: [] });
|
||||||
fetchSocialBiKpi.mockResolvedValue({ items: [], period_month: "2026-06", summary: {} });
|
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();
|
await flushEffects();
|
||||||
|
|
||||||
// v2 Shell:主视图容器 + 侧栏五个视图 tab,默认选中“经营概览”。
|
// v2 Shell:主视图容器 + 侧栏视图 tab,默认选中“经营概览”。
|
||||||
expect(screen.getByLabelText("Social BI 数据中心")).toBeTruthy();
|
expect(screen.getByLabelText("Social BI 数据中心")).toBeTruthy();
|
||||||
const viewTabs = screen.getByRole("tablist", { name: "数据视图" });
|
const viewTabs = screen.getByRole("tablist", { name: "数据视图" });
|
||||||
["经营概览", "地区洞察", "运营中心", "留存质量", "数据明细"].forEach((name) => {
|
["经营概览", "地区洞察", "运营中心", "留存质量", "数据明细", "埋点漏斗"].forEach((name) => {
|
||||||
expect(within(viewTabs).getByRole("tab", { name })).toBeTruthy();
|
expect(within(viewTabs).getByRole("tab", { name })).toBeTruthy();
|
||||||
});
|
});
|
||||||
expect(within(viewTabs).getByRole("tab", { name: "经营概览" })).toHaveAttribute("aria-selected", "true");
|
expect(within(viewTabs).getByRole("tab", { name: "经营概览" })).toHaveAttribute("aria-selected", "true");
|
||||||
|
|
||||||
expect(fetchSocialBiMaster).toHaveBeenCalledTimes(1);
|
expect(fetchSocialBiMaster).toHaveBeenCalledTimes(1);
|
||||||
expect(fetchSocialBiOverview).toHaveBeenCalledWith(expect.objectContaining({ statTz: "Asia/Shanghai" }));
|
expect(fetchSocialBiOverview).toHaveBeenCalledWith(expect.objectContaining({ statTz: "Asia/Shanghai" }));
|
||||||
|
expect(fetchSocialBiFunnel).toHaveBeenCalledWith(expect.objectContaining({ statTz: "Asia/Shanghai" }));
|
||||||
expect(fetchSocialBiKpi).toHaveBeenCalledWith(expect.objectContaining({ statTz: "Asia/Shanghai" }));
|
expect(fetchSocialBiKpi).toHaveBeenCalledWith(expect.objectContaining({ statTz: "Asia/Shanghai" }));
|
||||||
|
|
||||||
// 经营概览:App 名、Hero 汇总充值(12,300 + 5,000 USD 分 = $173)、
|
// 经营概览: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();
|
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(<DatabiApp />);
|
||||||
|
|
||||||
|
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() {
|
async function flushEffects() {
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
for (let index = 0; index < 10; index += 1) {
|
for (let index = 0; index < 10; index += 1) {
|
||||||
|
|||||||
@ -180,6 +180,26 @@ export async function fetchSocialBiOverview({ appCodes, endMs, regionId, startMs
|
|||||||
return fetchDatabiData(apiEndpointPath(API_OPERATIONS.getSocialBiOverview), { query });
|
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 }) {
|
export async function fetchSocialBiKpi({ appCodes, endMs, operatorUserId, periodMonth, startMs, statTz }) {
|
||||||
const query = {};
|
const query = {};
|
||||||
if (statTz) {
|
if (statTz) {
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
// 视图组件通过 useSocialBi() 消费统一的筛选状态与数据派生结果。
|
// 视图组件通过 useSocialBi() 消费统一的筛选状态与数据派生结果。
|
||||||
|
|
||||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
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 GroupsOutlined from "@mui/icons-material/GroupsOutlined";
|
||||||
import InsightsOutlined from "@mui/icons-material/InsightsOutlined";
|
import InsightsOutlined from "@mui/icons-material/InsightsOutlined";
|
||||||
import PublicOutlined from "@mui/icons-material/PublicOutlined";
|
import PublicOutlined from "@mui/icons-material/PublicOutlined";
|
||||||
@ -20,12 +21,13 @@ import {
|
|||||||
} from "./state.js";
|
} from "./state.js";
|
||||||
import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx";
|
import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx";
|
||||||
import { appColor } from "./metrics.js";
|
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 { OverviewView } from "./views/OverviewView.jsx";
|
||||||
import { RegionsView } from "./views/RegionsView.jsx";
|
import { RegionsView } from "./views/RegionsView.jsx";
|
||||||
import { TeamKpiView } from "./views/TeamKpiView.jsx";
|
import { TeamKpiView } from "./views/TeamKpiView.jsx";
|
||||||
import { RetentionView } from "./views/RetentionView.jsx";
|
import { RetentionView } from "./views/RetentionView.jsx";
|
||||||
import { DataTableView } from "./views/DataTableView.jsx";
|
import { DataTableView } from "./views/DataTableView.jsx";
|
||||||
|
import { FunnelView } from "./views/FunnelView.jsx";
|
||||||
import "../styles/social-v2.css";
|
import "../styles/social-v2.css";
|
||||||
|
|
||||||
const VIEWS = [
|
const VIEWS = [
|
||||||
@ -33,7 +35,8 @@ const VIEWS = [
|
|||||||
{ component: RegionsView, icon: PublicOutlined, key: "regions", label: "地区洞察" },
|
{ component: RegionsView, icon: PublicOutlined, key: "regions", label: "地区洞察" },
|
||||||
{ component: TeamKpiView, icon: GroupsOutlined, key: "team", label: "运营中心" },
|
{ component: TeamKpiView, icon: GroupsOutlined, key: "team", label: "运营中心" },
|
||||||
{ component: RetentionView, icon: RepeatOutlined, key: "retention", 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);
|
const SocialBiContext = createContext(null);
|
||||||
@ -76,7 +79,7 @@ export function SocialBiApp() {
|
|||||||
<div className="sbi-shell">
|
<div className="sbi-shell">
|
||||||
<Sidebar data={data} onViewChange={setViewKey} viewKey={viewKey} />
|
<Sidebar data={data} onViewChange={setViewKey} viewKey={viewKey} />
|
||||||
<div className="sbi-main">
|
<div className="sbi-main">
|
||||||
<TopBar data={data} filters={filters} updateFilter={updateFilter} />
|
<TopBar data={data} filters={filters} updateFilter={updateFilter} viewKey={viewKey} />
|
||||||
{data.errors.length ? (
|
{data.errors.length ? (
|
||||||
<div className="sbi-error-banner" role="alert">
|
<div className="sbi-error-banner" role="alert">
|
||||||
{data.errors.map((message, index) => (
|
{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 (
|
return (
|
||||||
<header className="sbi-topbar">
|
<header className="sbi-topbar">
|
||||||
<div className="sbi-topbar-row">
|
<div className="sbi-topbar-row">
|
||||||
@ -144,7 +147,7 @@ function TopBar({ data, filters, updateFilter }) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="sbi-topbar-row">
|
<div className="sbi-topbar-row">
|
||||||
<AppChips data={data} filters={filters} updateFilter={updateFilter} />
|
<AppChips data={data} filters={filters} updateFilter={updateFilter} viewKey={viewKey} />
|
||||||
<RegionSelect data={data} filters={filters} updateFilter={updateFilter} />
|
<RegionSelect data={data} filters={filters} updateFilter={updateFilter} />
|
||||||
<span className="sbi-range-label">{rangeLabel(data.range)}</span>
|
<span className="sbi-range-label">{rangeLabel(data.range)}</span>
|
||||||
{data.isLoading ? <span className="sbi-loading-dot" aria-label="加载中" /> : null}
|
{data.isLoading ? <span className="sbi-loading-dot" aria-label="加载中" /> : null}
|
||||||
@ -228,10 +231,13 @@ function GranularityControl({ filters, updateFilter }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AppChips({ data, filters, updateFilter }) {
|
function AppChips({ data, filters, updateFilter, viewKey }) {
|
||||||
const apps = data.master?.apps || [];
|
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 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 (
|
return (
|
||||||
<div className="sbi-app-chips" role="group" aria-label="App 筛选">
|
<div className="sbi-app-chips" role="group" aria-label="App 筛选">
|
||||||
<button
|
<button
|
||||||
@ -240,7 +246,7 @@ function AppChips({ data, filters, updateFilter }) {
|
|||||||
onClick={() => updateFilter("apps", [ALL])}
|
onClick={() => updateFilter("apps", [ALL])}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
全部 App
|
{viewKey === "funnel" ? "全部漏斗 App" : "全部 App"}
|
||||||
</button>
|
</button>
|
||||||
{apps.map((app) => {
|
{apps.map((app) => {
|
||||||
const selected = !isAll && filters.apps.includes(app.app_code);
|
const selected = !isAll && filters.apps.includes(app.app_code);
|
||||||
|
|||||||
@ -1,18 +1,20 @@
|
|||||||
// 社交 BI v2 的数据层:负责 master/overview/kpi 三个接口的拉取与派生行,
|
// 社交 BI v2 的数据层:负责 master/overview/funnel/kpi 接口的拉取与派生行,
|
||||||
// 视图不直接调 API,只消费这里的产物(原始 snake_case 指标行 + 维度字段)。
|
// 视图不直接调 API,只消费这里的产物(原始 snake_case 指标行 + 维度字段)。
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
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 { DEFAULT_TIME_ZONE, rangeEndMs, rangeStartMs } from "../utils/time.js";
|
||||||
import { ALL, appSelectionMatches, regionSelectionMatches, resolveDateRange } from "./state.js";
|
import { ALL, appSelectionMatches, regionSelectionMatches, resolveDateRange } from "./state.js";
|
||||||
|
|
||||||
export const SOCIAL_BI_TZ = DEFAULT_TIME_ZONE;
|
export const SOCIAL_BI_TZ = DEFAULT_TIME_ZONE;
|
||||||
|
export const SOCIAL_BI_FUNNEL_APP_CODES = ["lalu", "huwaa", "fami"];
|
||||||
|
|
||||||
export function useSocialBiData(filters) {
|
export function useSocialBiData(filters) {
|
||||||
const [master, setMaster] = useState(null);
|
const [master, setMaster] = useState(null);
|
||||||
// 数据请求等 master 结果落地后再发:否则会先用兜底 App 列表打一轮,master 到达后立刻重打一轮(整轮浪费)。
|
// 数据请求等 master 结果落地后再发:否则会先用兜底 App 列表打一轮,master 到达后立刻重打一轮(整轮浪费)。
|
||||||
const [isMasterSettled, setIsMasterSettled] = useState(false);
|
const [isMasterSettled, setIsMasterSettled] = useState(false);
|
||||||
const [overview, setOverview] = useState(null);
|
const [overview, setOverview] = useState(null);
|
||||||
|
const [funnel, setFunnel] = useState(null);
|
||||||
const [kpi, setKpi] = useState(null);
|
const [kpi, setKpi] = useState(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [errors, setErrors] = useState([]);
|
const [errors, setErrors] = useState([]);
|
||||||
@ -55,6 +57,14 @@ export function useSocialBiData(filters) {
|
|||||||
}
|
}
|
||||||
return filters.apps.filter((appCode) => !available.length || available.includes(appCode));
|
return filters.apps.filter((appCode) => !available.length || available.includes(appCode));
|
||||||
}, [filters.apps, master]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!isMasterSettled) {
|
if (!isMasterSettled) {
|
||||||
@ -66,8 +76,9 @@ export function useSocialBiData(filters) {
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
Promise.allSettled([
|
Promise.allSettled([
|
||||||
fetchSocialBiOverview({ appCodes, endMs, startMs, statTz: SOCIAL_BI_TZ }),
|
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 })
|
fetchSocialBiKpi({ appCodes, endMs, startMs, statTz: SOCIAL_BI_TZ })
|
||||||
]).then(([overviewResult, kpiResult]) => {
|
]).then(([overviewResult, funnelResult, kpiResult]) => {
|
||||||
if (sequence !== requestSeq.current) {
|
if (sequence !== requestSeq.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -83,6 +94,17 @@ export function useSocialBiData(filters) {
|
|||||||
setOverview(null);
|
setOverview(null);
|
||||||
nextErrors.push(`统计数据加载失败: ${overviewResult.reason?.message || "未知错误"}`);
|
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") {
|
if (kpiResult.status === "fulfilled") {
|
||||||
setKpi(kpiResult.value);
|
setKpi(kpiResult.value);
|
||||||
Object.entries(kpiResult.value?.app_errors || {}).forEach(([appCode, message]) => {
|
Object.entries(kpiResult.value?.app_errors || {}).forEach(([appCode, message]) => {
|
||||||
@ -95,7 +117,7 @@ export function useSocialBiData(filters) {
|
|||||||
setErrors(nextErrors);
|
setErrors(nextErrors);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
});
|
});
|
||||||
}, [appCodes, isMasterSettled, range, refreshToken]);
|
}, [appCodes, funnelAppCodes, isMasterSettled, range, refreshToken]);
|
||||||
|
|
||||||
const refresh = useCallback(() => {
|
const refresh = useCallback(() => {
|
||||||
setRefreshToken((current) => current + 1);
|
setRefreshToken((current) => current + 1);
|
||||||
@ -107,6 +129,8 @@ export function useSocialBiData(filters) {
|
|||||||
appCodes,
|
appCodes,
|
||||||
derived,
|
derived,
|
||||||
errors,
|
errors,
|
||||||
|
funnel,
|
||||||
|
funnelAppCodes,
|
||||||
isLoading,
|
isLoading,
|
||||||
kpi,
|
kpi,
|
||||||
master,
|
master,
|
||||||
|
|||||||
382
databi/src/social/views/FunnelView.jsx
Normal file
382
databi/src/social/views/FunnelView.jsx
Normal file
@ -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 (
|
||||||
|
<div className="sbi-funnel" aria-label="埋点漏斗加载中">
|
||||||
|
<div className="sbi-funnel-skeleton">
|
||||||
|
{["70%", "92%", "84%", "76%", "88%"].map((width) => (
|
||||||
|
<span className="sbi-skeleton" key={width} style={{ width }} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!appRows.length) {
|
||||||
|
return (
|
||||||
|
<div className="sbi-funnel">
|
||||||
|
<div className="sbi-empty">
|
||||||
|
<strong>当前无埋点漏斗数据</strong>
|
||||||
|
<span>
|
||||||
|
{appErrors.length
|
||||||
|
? "所选 App 暂未接入漏斗或统计服务返回错误"
|
||||||
|
: `埋点漏斗 App 筛选目前仅支持 ${SUPPORTED_FUNNEL_APPS}`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="sbi-funnel">
|
||||||
|
<div className="sbi-funnel-head">
|
||||||
|
<div>
|
||||||
|
<h1>埋点漏斗</h1>
|
||||||
|
<span>
|
||||||
|
{rangeLabel(range)} · 仅支持 {SUPPORTED_FUNNEL_APPS}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="sbi-funnel-apps">
|
||||||
|
{appRows.map((app) => (
|
||||||
|
<span key={app.app_code}>{app.app_name || app.app_code}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{appErrors.length ? (
|
||||||
|
<div className="sbi-funnel-warning">
|
||||||
|
{appErrors.map((app) => (
|
||||||
|
<span key={app.app_code}>
|
||||||
|
{app.app_name || app.app_code}: {app.error}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<section className="sbi-funnel-summary" aria-label="漏斗核心指标">
|
||||||
|
{SUMMARY_CARDS.map((item) => (
|
||||||
|
<article className="sbi-card sbi-funnel-kpi" key={item.key}>
|
||||||
|
<span>{item.label}</span>
|
||||||
|
<strong>{item.type === "ratio" ? formatRatioPercent(totals[item.key]) : formatCount(totals[item.key])}</strong>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="sbi-funnel-grid">
|
||||||
|
<article className="sbi-card sbi-funnel-chart-card">
|
||||||
|
<div className="sbi-card-title">
|
||||||
|
<div>
|
||||||
|
<h2>主路径转化</h2>
|
||||||
|
<span>按去重用户数计算</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<EChart className="sbi-funnel-chart" option={chartOption} />
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="sbi-card sbi-funnel-table-card">
|
||||||
|
<div className="sbi-card-title">
|
||||||
|
<div>
|
||||||
|
<h2>事件明细</h2>
|
||||||
|
<span>含进房失败和互动动作</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="sbi-table-scroll sbi-funnel-step-scroll">
|
||||||
|
<table className="sbi-table sbi-funnel-step-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className="is-left">事件</th>
|
||||||
|
<th>用户</th>
|
||||||
|
<th>次数</th>
|
||||||
|
<th>上一步</th>
|
||||||
|
<th>总转化</th>
|
||||||
|
<th>流失</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{steps.map((step) => (
|
||||||
|
<tr className={step.is_failure ? "is-failure" : ""} key={step.event_name}>
|
||||||
|
<td className="is-left">
|
||||||
|
<strong>{step.label || step.event_name}</strong>
|
||||||
|
<span>{step.event_name}</span>
|
||||||
|
</td>
|
||||||
|
<td>{formatCount(step.user_count)}</td>
|
||||||
|
<td>{formatCount(step.event_count)}</td>
|
||||||
|
<td>{formatRatioPercent(step.previous_conversion_rate)}</td>
|
||||||
|
<td>{formatRatioPercent(step.overall_conversion_rate)}</td>
|
||||||
|
<td>{formatCount(step.dropoff_users)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="sbi-card sbi-funnel-cohort-card">
|
||||||
|
<div className="sbi-card-title sbi-funnel-cohort-title">
|
||||||
|
<div>
|
||||||
|
<h2>D1 Cohort</h2>
|
||||||
|
<span>基准用户为登录成功用户</span>
|
||||||
|
</div>
|
||||||
|
<div className="sbi-funnel-tabs" role="tablist" aria-label="D1 cohort 维度">
|
||||||
|
{COHORT_DIMENSIONS.map((item) => (
|
||||||
|
<button
|
||||||
|
aria-selected={dimension === item.key}
|
||||||
|
className={dimension === item.key ? "is-active" : ""}
|
||||||
|
key={item.key}
|
||||||
|
onClick={() => setDimension(item.key)}
|
||||||
|
role="tab"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="sbi-table-scroll sbi-funnel-cohort-scroll">
|
||||||
|
<table className="sbi-table sbi-funnel-cohort-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className="is-left">{COHORT_DIMENSIONS.find((item) => item.key === dimension)?.label || "Cohort"}</th>
|
||||||
|
<th>基准用户</th>
|
||||||
|
<th>D1 用户</th>
|
||||||
|
<th>D1 留存</th>
|
||||||
|
{COHORT_STEP_COLUMNS.map((item) => (
|
||||||
|
<th key={item.key}>{item.label}</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{selectedCohorts.length ? (
|
||||||
|
selectedCohorts.map((row) => (
|
||||||
|
<tr key={`${row.dimension}:${row.value}`}>
|
||||||
|
<td className="is-left">
|
||||||
|
<strong>{row.label || row.value || "unknown"}</strong>
|
||||||
|
</td>
|
||||||
|
<td>{formatCount(row.base_users)}</td>
|
||||||
|
<td>{formatCount(row.d1_retention_users)}</td>
|
||||||
|
<td>{formatRatioPercent(row.d1_retention_rate)}</td>
|
||||||
|
{COHORT_STEP_COLUMNS.map((item) => (
|
||||||
|
<td key={item.key}>{formatCount(stepUserCount(row, item.key))}</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<tr>
|
||||||
|
<td className="sbi-funnel-empty-cell" colSpan={4 + COHORT_STEP_COLUMNS.length}>
|
||||||
|
当前维度暂无 cohort 数据
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
221
databi/src/social/views/funnel-view.css
Normal file
221
databi/src/social/views/funnel-view.css
Normal file
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -139,6 +139,7 @@ export const API_OPERATIONS = {
|
|||||||
getRoomTurnoverRewardConfig: "getRoomTurnoverRewardConfig",
|
getRoomTurnoverRewardConfig: "getRoomTurnoverRewardConfig",
|
||||||
getRoomWhitelistConfig: "getRoomWhitelistConfig",
|
getRoomWhitelistConfig: "getRoomWhitelistConfig",
|
||||||
getSevenDayCheckInConfig: "getSevenDayCheckInConfig",
|
getSevenDayCheckInConfig: "getSevenDayCheckInConfig",
|
||||||
|
getSocialBiFunnel: "getSocialBiFunnel",
|
||||||
getSocialBiKpi: "getSocialBiKpi",
|
getSocialBiKpi: "getSocialBiKpi",
|
||||||
getSocialBiMaster: "getSocialBiMaster",
|
getSocialBiMaster: "getSocialBiMaster",
|
||||||
getSocialBiOverview: "getSocialBiOverview",
|
getSocialBiOverview: "getSocialBiOverview",
|
||||||
@ -1222,6 +1223,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
permission: "seven-day-checkin:view",
|
permission: "seven-day-checkin:view",
|
||||||
permissions: ["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: {
|
getSocialBiKpi: {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
operationId: API_OPERATIONS.getSocialBiKpi,
|
operationId: API_OPERATIONS.getSocialBiKpi,
|
||||||
|
|||||||
34
src/shared/api/generated/schema.d.ts
vendored
34
src/shared/api/generated/schema.d.ts
vendored
@ -2036,6 +2036,22 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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": {
|
"/admin/databi/social/kpi": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -7077,6 +7093,24 @@ export interface operations {
|
|||||||
200: components["responses"]["StatisticsObjectResponse"];
|
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: {
|
getSocialBiKpi: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: {
|
query?: {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user