239 lines
8.8 KiB
JavaScript
239 lines
8.8 KiB
JavaScript
// 社交 BI v2 的数据层:负责 master/overview/funnel/kpi 接口的拉取与派生行,
|
||
// 视图不直接调 API,只消费这里的产物(原始 snake_case 指标行 + 维度字段)。
|
||
|
||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||
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([]);
|
||
const [refreshToken, setRefreshToken] = useState(0);
|
||
const requestSeq = useRef(0);
|
||
|
||
useEffect(() => {
|
||
let ignore = false;
|
||
fetchSocialBiMaster()
|
||
.then((data) => {
|
||
if (!ignore && data) {
|
||
setMaster(data);
|
||
}
|
||
})
|
||
.catch(() => {
|
||
if (!ignore) {
|
||
setMaster(null);
|
||
}
|
||
})
|
||
.finally(() => {
|
||
if (!ignore) {
|
||
setIsMasterSettled(true);
|
||
}
|
||
});
|
||
return () => {
|
||
ignore = true;
|
||
};
|
||
}, []);
|
||
|
||
// range 只依赖日期相关筛选:切换粒度/区域/App 多选不应触发接口重拉(那些是纯前端派生)。
|
||
const { customEnd, customStart, preset } = filters;
|
||
const range = useMemo(
|
||
() => resolveDateRange({ customEnd, customStart, preset }, SOCIAL_BI_TZ),
|
||
[customEnd, customStart, preset]
|
||
);
|
||
const appCodes = useMemo(() => {
|
||
const available = (master?.apps || []).map((app) => app.app_code);
|
||
if (!filters.apps.length || filters.apps.includes(ALL)) {
|
||
return available;
|
||
}
|
||
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) {
|
||
return;
|
||
}
|
||
const sequence = ++requestSeq.current;
|
||
const startMs = rangeStartMs(range, SOCIAL_BI_TZ);
|
||
const endMs = rangeEndMs(range, SOCIAL_BI_TZ);
|
||
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, funnelResult, kpiResult]) => {
|
||
if (sequence !== requestSeq.current) {
|
||
return;
|
||
}
|
||
const nextErrors = [];
|
||
if (overviewResult.status === "fulfilled") {
|
||
setOverview(overviewResult.value);
|
||
(overviewResult.value?.apps || []).forEach((app) => {
|
||
if (app.error) {
|
||
nextErrors.push(`${app.app_name || app.app_code}: ${app.error}`);
|
||
}
|
||
});
|
||
} else {
|
||
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]) => {
|
||
nextErrors.push(`${appCode}: ${message}`);
|
||
});
|
||
} else {
|
||
setKpi(null);
|
||
nextErrors.push(`运营充值数据加载失败: ${kpiResult.reason?.message || "未知错误"}`);
|
||
}
|
||
setErrors(nextErrors);
|
||
setIsLoading(false);
|
||
});
|
||
}, [appCodes, funnelAppCodes, isMasterSettled, range, refreshToken]);
|
||
|
||
const refresh = useCallback(() => {
|
||
setRefreshToken((current) => current + 1);
|
||
}, []);
|
||
|
||
const derived = useMemo(() => deriveRows(overview, filters, master), [filters, master, overview]);
|
||
|
||
return {
|
||
appCodes,
|
||
derived,
|
||
errors,
|
||
funnel,
|
||
funnelAppCodes,
|
||
isLoading,
|
||
kpi,
|
||
master,
|
||
overview,
|
||
range,
|
||
refresh
|
||
};
|
||
}
|
||
|
||
// deriveRows 输出四类行,全部带维度字段 + 原始指标字段:
|
||
// appTotals/appDaily(App 粒度)、regionTotals/regionDaily(大区粒度,已按顶栏区域选择过滤)、
|
||
// countryTotals(国家粒度,已按国家→区域目录归一 region_id 并跟随区域筛选)。restricted=true 表示该 App 已被数据范围裁剪。
|
||
function deriveRows(overview, filters, master) {
|
||
const appTotals = [];
|
||
const appDaily = [];
|
||
const regionTotals = [];
|
||
const regionDaily = [];
|
||
const countryTotals = [];
|
||
const appErrors = [];
|
||
const regionByCountry = buildRegionByCountryIndex(master);
|
||
|
||
(overview?.apps || []).forEach((app) => {
|
||
if (!appSelectionMatches(filters.apps, app.app_code)) {
|
||
return;
|
||
}
|
||
const context = {
|
||
app_code: app.app_code,
|
||
app_name: app.app_name || app.app_code,
|
||
kind: app.kind,
|
||
restricted: Boolean(app.restricted)
|
||
};
|
||
if (app.error) {
|
||
appErrors.push({ ...context, error: app.error });
|
||
return;
|
||
}
|
||
appTotals.push({ ...context, ...(app.total || {}) });
|
||
(app.daily_series || []).forEach((row) => {
|
||
appDaily.push({ ...context, ...row, stat_day: row.stat_day || row.label });
|
||
});
|
||
(app.region_breakdown || []).forEach((row) => {
|
||
if (!regionSelectionMatches(filters.regions, app.app_code, Number(row.region_id ?? 0))) {
|
||
return;
|
||
}
|
||
regionTotals.push({
|
||
...context,
|
||
...row,
|
||
region_id: Number(row.region_id ?? 0),
|
||
region_name: row.region_name || row.region_code || `区域 ${row.region_id}`
|
||
});
|
||
});
|
||
(app.daily_region_breakdown || []).forEach((row) => {
|
||
if (!regionSelectionMatches(filters.regions, app.app_code, Number(row.region_id ?? 0))) {
|
||
return;
|
||
}
|
||
regionDaily.push({
|
||
...context,
|
||
...row,
|
||
region_id: Number(row.region_id ?? 0),
|
||
region_name: row.region_name || row.region_code || `区域 ${row.region_id}`,
|
||
stat_day: row.stat_day || row.label
|
||
});
|
||
});
|
||
(app.country_breakdown || []).forEach((row) => {
|
||
const countryCode = row.country_code || String(row.country_id || "");
|
||
// legacy 国家行不带 region_id,用区域目录(countries 列表)归一,保证下钻与区域筛选口径一致。
|
||
let regionId = Number(row.region_id ?? 0);
|
||
let regionName = row.region_name || "";
|
||
if (!regionId) {
|
||
const region = regionByCountry.get(`${app.app_code}:${String(countryCode).toUpperCase()}`);
|
||
if (region) {
|
||
regionId = region.region_id;
|
||
regionName = region.region_name || region.region_code || regionName;
|
||
}
|
||
}
|
||
if (!regionSelectionMatches(filters.regions, app.app_code, regionId)) {
|
||
return;
|
||
}
|
||
countryTotals.push({
|
||
...context,
|
||
...row,
|
||
country_code: countryCode,
|
||
country_name: row.country_name || row.country || row.country_code || `国家 ${row.country_id}`,
|
||
region_id: regionId,
|
||
region_name: regionName
|
||
});
|
||
});
|
||
});
|
||
|
||
// 顶栏选了区域时,App 合计/日行切换成所选区域行的聚合视角由各视图用 regionScoped 判断。
|
||
const regionScoped = Boolean(filters.regions.length && !filters.regions.includes(ALL));
|
||
return { appDaily, appErrors, appTotals, countryTotals, regionDaily, regionScoped, regionTotals };
|
||
}
|
||
|
||
function buildRegionByCountryIndex(master) {
|
||
const index = new Map();
|
||
(master?.regions || []).forEach((region) => {
|
||
(region.countries || []).forEach((countryCode) => {
|
||
const key = `${region.app_code}:${String(countryCode).toUpperCase()}`;
|
||
if (!index.has(key)) {
|
||
index.set(key, region);
|
||
}
|
||
});
|
||
});
|
||
return index;
|
||
}
|