// 社交 BI v2 Shell:深色侧栏(视图导航)+ 顶栏全局筛选(日期/App/区域/粒度), // 视图组件通过 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"; import RefreshOutlined from "@mui/icons-material/RefreshOutlined"; import RepeatOutlined from "@mui/icons-material/RepeatOutlined"; import TableChartOutlined from "@mui/icons-material/TableChartOutlined"; import Autocomplete from "@mui/material/Autocomplete"; import TextField from "@mui/material/TextField"; import { ALL, DATE_PRESETS, GRANULARITIES, createDefaultFilters, rangeLabel, readStateFromURL, toggleMultiValue, writeStateToURL } from "./state.js"; import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx"; import { AppIdentity } from "@/shared/ui/AppIdentity.jsx"; 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 = [ { component: OverviewView, icon: InsightsOutlined, key: "overview", label: "经营概览" }, { 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: FunnelView, icon: AccountTreeOutlined, key: "funnel", label: "埋点漏斗" } ]; const SocialBiContext = createContext(null); const EMPTY_OPERATORS = []; export function useSocialBi() { const context = useContext(SocialBiContext); if (!context) { throw new Error("useSocialBi must be used inside SocialBiApp"); } return context; } // Social BI 的统计结果只携带 app_code/app_name;Logo 从 master 目录按 app_code 回填,保证所有视图使用同一份品牌事实。 export function SocialAppIdentity({ app, appCode, appName, className = "", size = "small" }) { const context = useSocialBi(); const resolvedCode = String(app?.app_code || app?.appCode || appCode || "").trim().toLowerCase(); const catalogApp = (context.master?.apps || []).find((item) => String(item.app_code || item.appCode || "").trim().toLowerCase() === resolvedCode); return ; } export function SocialBiApp() { const initial = useMemo(() => readStateFromURL(), []); const [filters, setFilters] = useState(initial.filters); const [viewKey, setViewKey] = useState(() => (VIEWS.some((view) => view.key === initial.view) ? initial.view : "overview")); const data = useSocialBiData(filters, { includeFunnel: viewKey === "funnel", includeKpi: viewKey === "team" }); useEffect(() => { writeStateToURL(filters, viewKey); }, [filters, viewKey]); const updateFilter = useCallback((key, value) => { setFilters((current) => ({ ...current, [key]: value })); }, []); const resetFilters = useCallback(() => { setFilters(createDefaultFilters()); }, []); const contextValue = useMemo( () => ({ ...data, filters, resetFilters, setViewKey, updateFilter, viewKey }), [data, filters, resetFilters, updateFilter, viewKey] ); const ActiveView = (VIEWS.find((view) => view.key === viewKey) || VIEWS[0]).component; return (
{data.errors.length ? (
{data.errors.map((message, index) => ( {message} ))}
) : null}
); } function Sidebar({ data, onViewChange, viewKey }) { const access = data.master?.access; const scopeLabel = !data.master ? "" : access?.all ? "全量数据范围" : `负责 ${access?.scopes?.length || 0} 个 App/区域`; return ( ); } function TopBar({ data, filters, updateFilter, viewKey }) { return (
{viewKey === "team" ? : null} {rangeLabel(data.range)} {data.isLoading ? : null}
); } function operatorIdentityLabel(operator) { if (String(operator?.user_id) === ALL) { return "全部运营人员"; } return operator?.name || operator?.account || `#${operator?.user_id}`; } function filterOperatorOptions(options, { inputValue }) { const keyword = String(inputValue || "").trim().toLowerCase(); if (!keyword) { return options; } return options.filter((operator) => [operatorIdentityLabel(operator), operator.account, operator.team, operator.user_id] .filter(Boolean) .join(" ") .toLowerCase() .includes(keyword) ); } function OperatorSelect({ data, filters, updateFilter }) { const operators = data.master?.operators || EMPTY_OPERATORS; const options = useMemo(() => [{ name: "全部运营人员", user_id: ALL }, ...operators], [operators]); const selected = options.find((operator) => String(operator.user_id) === String(filters.operator)) || options[0]; useEffect(() => { if (!data.master || filters.operator === ALL) { return; } // 人员离职或数据权限收回后,旧分享链接里的 ID 会失效;回退到全部,避免页面显示一个无法解释的空筛选。 if (!operators.some((operator) => String(operator.user_id) === String(filters.operator))) { updateFilter("operator", ALL); } }, [data.master, filters.operator, operators, updateFilter]); return ( String(option.user_id) === String(value.user_id)} noOptionsText="无匹配运营人员" options={options} size="small" value={selected} onChange={(_, operator) => updateFilter("operator", String(operator?.user_id || ALL))} renderOption={(props, operator) => { const { key, ...optionProps } = props; const details = [operator.account, operator.team].filter(Boolean).join(" · "); return (
  • {operatorIdentityLabel(operator)} {details ? {details} : null}
  • ); }} renderInput={(params) => ( )} /> ); } function DatePresetControl({ filters, updateFilter }) { return (
    {DATE_PRESETS.map((preset) => ( ))} {filters.preset === "custom" ? ( { updateFilter("customStart", msToDayText(range.startMs)); updateFilter("customEnd", msToDayText(range.endMs)); }} /> ) : null}
    ); } // 自定义区间在筛选状态里以天字符串(YYYY-MM-DD)存储,选择器以本地时区毫秒交互,这里做双向换算。 function parseDayText(value) { const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(value || "")); return match ? { day: Number(match[3]), month: Number(match[2]), year: Number(match[1]) } : null; } function dayTextToStartMs(dayText) { const parts = parseDayText(dayText); return parts ? new Date(parts.year, parts.month - 1, parts.day, 0, 0, 0, 0).getTime() : ""; } function dayTextToEndMs(dayText) { const parts = parseDayText(dayText); return parts ? new Date(parts.year, parts.month - 1, parts.day, 23, 59, 59, 999).getTime() : ""; } function msToDayText(ms) { if (!ms) { return ""; } const date = new Date(Number(ms)); const pad = (value) => String(value).padStart(2, "0"); return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`; } function GranularityControl({ filters, updateFilter }) { return (
    {GRANULARITIES.map((item) => ( ))}
    ); } 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 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); return ( ); })}
    ); } function RegionSelect({ data, filters, updateFilter }) { const [isOpen, setIsOpen] = useState(false); const containerRef = useRef(null); const apps = data.master?.apps || []; const regions = data.master?.regions || []; const groups = apps .map((app) => ({ app, regions: regions.filter((region) => region.app_code === app.app_code) })) .filter((group) => group.regions.length); useEffect(() => { const handlePointerDown = (event) => { if (!containerRef.current?.contains(event.target)) { setIsOpen(false); } }; document.addEventListener("pointerdown", handlePointerDown); return () => document.removeEventListener("pointerdown", handlePointerDown); }, []); const selectedCount = filters.regions.includes(ALL) ? 0 : filters.regions.length; return (
    {isOpen ? (
    {groups.map((group) => (
    {group.regions.map((region) => { const value = `${region.app_code}:${region.region_id}`; return ( ); })}
    ))} {!groups.length ?

    暂无区域目录

    : null}
    ) : null}
    ); }