2026-07-14 14:22:24 +08:00

350 lines
13 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 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_nameLogo 从 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>
);
}