hyapp-admin-platform/databi/src/social/useSocialBiData.js
2026-07-06 11:47:04 +08:00

239 lines
8.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 社交 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/appDailyApp 粒度、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;
}