// 社交 BI v2 的数据层:负责 master/overview/funnel/kpi 接口的拉取与派生行, // 视图不直接调 API,只消费这里的产物(原始 snake_case 指标行 + 维度字段)。 import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { fetchSocialBiFunnel, fetchSocialBiKpi, fetchSocialBiMaster, fetchSocialBiOverview, fetchSocialBiRequirements } 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, { includeFunnel = false } = {}) { 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 [requirements, setRequirements] = useState(null); const [requirementsError, setRequirementsError] = useState(""); const [isRequirementsLoading, setIsRequirementsLoading] = useState(false); const [isLoading, setIsLoading] = useState(true); const [errors, setErrors] = useState([]); const [refreshToken, setRefreshToken] = useState(0); const requestSeq = useRef(0); const requirementsSeq = 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); // 漏斗会执行 cohort 聚合,只在用户进入漏斗视图时请求;其他视图不能为不可见数据承担查询成本或错误提示。 const funnelRequest = includeFunnel ? fetchSocialBiFunnel({ appCodes: funnelAppCodes, endMs, startMs, statTz: SOCIAL_BI_TZ }) : Promise.resolve(null); Promise.allSettled([ fetchSocialBiOverview({ appCodes, endMs, startMs, statTz: SOCIAL_BI_TZ }), funnelRequest, 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 (!includeFunnel) { setFunnel(null); } else 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, includeFunnel, isMasterSettled, range, refreshToken]); const loadRequirements = useCallback( ({ newUserType, payerType, section, userRole } = {}) => { if (!isMasterSettled) { return Promise.resolve(null); } const sequence = ++requirementsSeq.current; const startMs = rangeStartMs(range, SOCIAL_BI_TZ); const endMs = rangeEndMs(range, SOCIAL_BI_TZ); const requestScopes = buildRequirementRequestScopes(appCodes, filters.regions); setIsRequirementsLoading(true); setRequirementsError(""); setRequirements(null); if (!requestScopes.length) { const emptyResult = { access: overview?.access || null, apps: [] }; setRequirements(emptyResult); setIsRequirementsLoading(false); return Promise.resolve(emptyResult); } // 数据需求按表格页局部 section/角色/付费筛选请求;全局 App/日期仍复用 Social BI 顶栏口径。 return Promise.all(requestScopes.map((scope) => fetchSocialBiRequirements({ ...scope, endMs, newUserType, payerType, section, startMs, statTz: SOCIAL_BI_TZ, userRole }))) .then((results) => { if (sequence !== requirementsSeq.current) { return null; } const data = mergeRequirementResponses(results); setRequirements(data); return data; }) .catch((error) => { if (sequence === requirementsSeq.current) { setRequirements(null); setRequirementsError(error?.message || "数据需求加载失败"); } return null; }) .finally(() => { if (sequence === requirementsSeq.current) { setIsRequirementsLoading(false); } }); }, [appCodes, filters.regions, isMasterSettled, overview?.access, range] ); const refresh = useCallback(() => { setRefreshToken((current) => current + 1); }, []); const derived = useMemo(() => deriveRows(overview, filters, master), [filters, master, overview]); return { appCodes, derived, errors, funnel, funnelAppCodes, isLoading, isRequirementsLoading, kpi, loadRequirements, master, overview, range, refreshToken, requirements, requirementsError, 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; } function buildRequirementRequestScopes(appCodes, regions) { const selectedApps = new Set((appCodes || []).filter(Boolean)); if (!regions?.length || regions.includes(ALL)) { return [{ appCodes: [...selectedApps] }]; } const byApp = new Map(); regions.forEach((selection) => { const value = String(selection || "").trim(); if (!value || value === ALL) { return; } if (value.startsWith("app:")) { const appCode = value.slice(4).trim().toLowerCase(); if (appCode) { byApp.set(appCode, null); } return; } const [rawAppCode, rawRegionID] = value.split(":"); const appCode = String(rawAppCode || "").trim().toLowerCase(); const regionID = Number(rawRegionID); if (!appCode || !Number.isFinite(regionID) || regionID <= 0 || byApp.get(appCode) === null) { return; } if (!byApp.has(appCode)) { byApp.set(appCode, new Set()); } byApp.get(appCode).add(regionID); }); const scopes = []; byApp.forEach((regionIDs, appCode) => { if (selectedApps.size && !selectedApps.has(appCode)) { return; } if (regionIDs === null) { scopes.push({ appCodes: [appCode] }); return; } const ids = [...regionIDs].sort((left, right) => left - right); if (ids.length === 1) { scopes.push({ appCodes: [appCode], regionId: ids[0] }); } else if (ids.length > 1) { scopes.push({ appCodes: [appCode], regionIds: ids }); } }); return scopes; } function mergeRequirementResponses(results) { const merged = { access: results.find((item) => item?.access)?.access || null, apps: [] }; results.forEach((item) => { if (Array.isArray(item?.apps)) { merged.apps.push(...item.apps); } }); return merged; }