// 社交 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 {
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);
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 (
{rangeLabel(data.range)}
{data.isLoading ?
: null}
);
}
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}
);
}