350 lines
13 KiB
JavaScript
350 lines
13 KiB
JavaScript
// 社交 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 <AppIdentity app={catalogApp || app} appCode={resolvedCode} appName={appName} className={className} size={size} />;
|
||
}
|
||
|
||
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 (
|
||
<SocialBiContext.Provider value={contextValue}>
|
||
<div className="sbi-shell">
|
||
<Sidebar data={data} onViewChange={setViewKey} viewKey={viewKey} />
|
||
<div className="sbi-main">
|
||
<TopBar data={data} filters={filters} updateFilter={updateFilter} viewKey={viewKey} />
|
||
{data.errors.length ? (
|
||
<div className="sbi-error-banner" role="alert">
|
||
{data.errors.map((message, index) => (
|
||
<span key={`${index}-${message}`}>{message}</span>
|
||
))}
|
||
</div>
|
||
) : null}
|
||
<main className="sbi-view" aria-label="Social BI 数据中心">
|
||
<ActiveView />
|
||
</main>
|
||
</div>
|
||
</div>
|
||
</SocialBiContext.Provider>
|
||
);
|
||
}
|
||
|
||
function Sidebar({ data, onViewChange, viewKey }) {
|
||
const access = data.master?.access;
|
||
const scopeLabel = !data.master ? "" : access?.all ? "全量数据范围" : `负责 ${access?.scopes?.length || 0} 个 App/区域`;
|
||
return (
|
||
<aside className="sbi-sidebar" aria-label="Social BI 导航">
|
||
<div className="sbi-brand">
|
||
<span className="sbi-brand-mark">HY</span>
|
||
<div className="sbi-brand-copy">
|
||
<strong>运营数据总览</strong>
|
||
</div>
|
||
</div>
|
||
<nav className="sbi-nav" role="tablist" aria-label="数据视图">
|
||
{VIEWS.map((view) => {
|
||
const Icon = view.icon;
|
||
const isActive = viewKey === view.key;
|
||
return (
|
||
<button
|
||
aria-selected={isActive}
|
||
className={isActive ? "sbi-nav-item is-active" : "sbi-nav-item"}
|
||
key={view.key}
|
||
onClick={() => onViewChange(view.key)}
|
||
role="tab"
|
||
type="button"
|
||
>
|
||
<Icon fontSize="small" />
|
||
<span>{view.label}</span>
|
||
</button>
|
||
);
|
||
})}
|
||
</nav>
|
||
{scopeLabel ? (
|
||
<div className="sbi-scope-badge" title="数据按你的负责范围裁剪,可在用户管理里调整">
|
||
<span className="sbi-scope-dot" />
|
||
{scopeLabel}
|
||
</div>
|
||
) : null}
|
||
</aside>
|
||
);
|
||
}
|
||
|
||
function TopBar({ data, filters, updateFilter, viewKey }) {
|
||
return (
|
||
<header className="sbi-topbar">
|
||
<div className="sbi-topbar-row">
|
||
<DatePresetControl filters={filters} updateFilter={updateFilter} />
|
||
<GranularityControl filters={filters} updateFilter={updateFilter} />
|
||
<button className="sbi-refresh" onClick={data.refresh} title="刷新数据" type="button">
|
||
<RefreshOutlined fontSize="small" />
|
||
</button>
|
||
</div>
|
||
<div className="sbi-topbar-row">
|
||
<AppChips data={data} filters={filters} updateFilter={updateFilter} viewKey={viewKey} />
|
||
<RegionSelect data={data} filters={filters} updateFilter={updateFilter} />
|
||
<span className="sbi-range-label">{rangeLabel(data.range)}</span>
|
||
{data.isLoading ? <span className="sbi-loading-dot" aria-label="加载中" /> : null}
|
||
</div>
|
||
</header>
|
||
);
|
||
}
|
||
|
||
function DatePresetControl({ filters, updateFilter }) {
|
||
return (
|
||
<div className="sbi-date-presets" role="radiogroup" aria-label="日期区间">
|
||
{DATE_PRESETS.map((preset) => (
|
||
<button
|
||
aria-checked={filters.preset === preset.key}
|
||
className={filters.preset === preset.key ? "is-active" : ""}
|
||
key={preset.key}
|
||
onClick={() => updateFilter("preset", preset.key)}
|
||
role="radio"
|
||
type="button"
|
||
>
|
||
{preset.label}
|
||
</button>
|
||
))}
|
||
{filters.preset === "custom" ? (
|
||
<TimeRangeFilter
|
||
className="sbi-custom-range"
|
||
label="自定义区间"
|
||
value={{ endMs: dayTextToEndMs(filters.customEnd), startMs: dayTextToStartMs(filters.customStart) }}
|
||
withTime={false}
|
||
onChange={(range) => {
|
||
updateFilter("customStart", msToDayText(range.startMs));
|
||
updateFilter("customEnd", msToDayText(range.endMs));
|
||
}}
|
||
/>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// 自定义区间在筛选状态里以天字符串(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 (
|
||
<div className="sbi-granularity" role="radiogroup" aria-label="趋势粒度">
|
||
{GRANULARITIES.map((item) => (
|
||
<button
|
||
aria-checked={filters.granularity === item.key}
|
||
className={filters.granularity === item.key ? "is-active" : ""}
|
||
key={item.key}
|
||
onClick={() => updateFilter("granularity", item.key)}
|
||
role="radio"
|
||
type="button"
|
||
>
|
||
{item.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<div className="sbi-app-chips" role="group" aria-label="App 筛选">
|
||
<button
|
||
aria-pressed={isAll}
|
||
className={isAll ? "sbi-chip is-active" : "sbi-chip"}
|
||
onClick={() => updateFilter("apps", [ALL])}
|
||
type="button"
|
||
>
|
||
{viewKey === "funnel" ? "全部漏斗 App" : "全部 App"}
|
||
</button>
|
||
{apps.map((app) => {
|
||
const selected = !isAll && filters.apps.includes(app.app_code);
|
||
return (
|
||
<button
|
||
aria-pressed={selected}
|
||
className={selected ? "sbi-chip is-active" : "sbi-chip"}
|
||
key={app.app_code}
|
||
onClick={() => updateFilter("apps", toggleMultiValue(filters.apps, app.app_code, allCodes))}
|
||
type="button"
|
||
>
|
||
<AppIdentity app={app} size="small" />
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<div className="sbi-region-select" ref={containerRef}>
|
||
<button aria-expanded={isOpen} className="sbi-region-trigger" onClick={() => setIsOpen((open) => !open)} type="button">
|
||
地区:{selectedCount ? `已选 ${selectedCount} 项` : "全部"}
|
||
</button>
|
||
{isOpen ? (
|
||
<div className="sbi-region-menu" role="listbox" aria-label="地区筛选">
|
||
<button
|
||
className={filters.regions.includes(ALL) ? "sbi-region-option is-active" : "sbi-region-option"}
|
||
onClick={() => updateFilter("regions", [ALL])}
|
||
type="button"
|
||
>
|
||
全部地区
|
||
</button>
|
||
{groups.map((group) => (
|
||
<div className="sbi-region-group" key={group.app.app_code}>
|
||
<button
|
||
className={
|
||
filters.regions.includes(`app:${group.app.app_code}`) ? "sbi-region-option is-group is-active" : "sbi-region-option is-group"
|
||
}
|
||
onClick={() => updateFilter("regions", toggleMultiValue(filters.regions, `app:${group.app.app_code}`))}
|
||
type="button"
|
||
>
|
||
<AppIdentity app={group.app} size="small" />
|
||
<span>全部区域</span>
|
||
</button>
|
||
{group.regions.map((region) => {
|
||
const value = `${region.app_code}:${region.region_id}`;
|
||
return (
|
||
<button
|
||
className={filters.regions.includes(value) ? "sbi-region-option is-active" : "sbi-region-option"}
|
||
key={value}
|
||
onClick={() => updateFilter("regions", toggleMultiValue(filters.regions, value))}
|
||
type="button"
|
||
>
|
||
{region.region_name || region.region_code}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
))}
|
||
{!groups.length ? <p className="sbi-region-empty">暂无区域目录</p> : null}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|