2026-06-29 14:26:00 +08:00

396 lines
16 KiB
JavaScript

import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { fetchFilterOptions, fetchSelfGameStatisticsOverview, fetchStatisticsOverview, getCurrentAppCode, setCurrentAppCode } from "./api.js";
import { CountryPerformanceTable } from "./components/CountryPerformanceTable.jsx";
import { DatabiHeader } from "./components/DatabiHeader.jsx";
import { FunnelPanel } from "./components/FunnelPanel.jsx";
import { GlobalOverviewPanel } from "./components/GlobalOverviewPanel.jsx";
import { LuckyGiftPoolModal } from "./components/LuckyGiftPoolModal.jsx";
import { MetricCard } from "./components/MetricCard.jsx";
import { MetricTrendModal } from "./components/MetricTrendModal.jsx";
import { ReportOverview } from "./components/ReportOverview.jsx";
import { EmptyReportSection, ReportSidebar } from "./components/ReportSidebar.jsx";
import { RevenueTrendPanel } from "./components/RevenueTrendPanel.jsx";
import { SelfGameScreen } from "./components/SelfGameScreen.jsx";
import { createDashboardModel } from "./data/createDashboardModel.js";
import { lastDaysRange, rangeEndMs, rangeStartMs, readStoredTimeZone, sameRange, thisMonthRange, TIME_ZONE_OPTIONS, todayRange, writeStoredTimeZone } from "./utils/time.js";
const initialFilterOptions = {
appOptions: [],
countryOptions: [{ countryCode: "", id: 0, label: "全部国家", regionIds: [], searchText: "全部国家 all", value: 0 }],
regionOptions: [{ countryCodes: [], id: "all", label: "全部区域", value: "all" }]
};
export function DatabiApp() {
const initialTimeZone = useMemo(() => readStoredTimeZone(), []);
const initialRange = useMemo(() => todayRange(initialTimeZone), [initialTimeZone]);
const [timeZone, setTimeZone] = useState(initialTimeZone);
const [range, setRange] = useState(initialRange);
const [countryId, setCountryId] = useState(0);
const [regionId, setRegionId] = useState("all");
const [appCode, setAppCode] = useState(() => getCurrentAppCode());
const [viewMode, setViewMode] = useState("report");
// 侧边栏目前只有数据报表接入真实业务流,财务和运营保持空组件,避免误触发数据报表请求。
const [activeReportSection, setActiveReportSection] = useState("data");
const [activeScreen, setActiveScreen] = useState("overview");
const [gameId, setGameId] = useState("all");
const [filterOptions, setFilterOptions] = useState(initialFilterOptions);
const [filtersLoading, setFiltersLoading] = useState(false);
const [overview, setOverview] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [selfGameOverview, setSelfGameOverview] = useState(null);
const [selfGameLoading, setSelfGameLoading] = useState(false);
const [selfGameError, setSelfGameError] = useState("");
const [poolModalOpen, setPoolModalOpen] = useState(false);
const [trendModalItem, setTrendModalItem] = useState(null);
const overviewRequestIdRef = useRef(0);
const selfGameRequestIdRef = useRef(0);
const overviewRef = useRef(null);
const selfGameOverviewRef = useRef(null);
const scopedCountries = useMemo(() => {
return filterOptions.countryOptions.filter((country) => Number(country.id) > 0 && countryBelongsToRegion(country, regionId));
}, [filterOptions.countryOptions, regionId]);
const isDataReportSection = activeReportSection === "data";
const handleAppChange = useCallback((value) => {
setAppCode(setCurrentAppCode(value));
}, []);
const handleRegionChange = useCallback((value) => {
setRegionId(String(value || "all"));
setCountryId(0);
}, []);
const handleTimeZoneChange = useCallback((value) => {
const nextTimeZone = writeStoredTimeZone(value);
setRange((currentRange) => {
const currentPreset = resolveRangePresetValue(currentRange, timeZone);
return currentPreset ? rangeForPreset(currentPreset, nextTimeZone) : currentRange;
});
setTimeZone(nextTimeZone);
}, [timeZone]);
const loadFilterOptions = useCallback(async () => {
setFiltersLoading(true);
try {
const options = await fetchFilterOptions({ appCode });
setFilterOptions(options);
const nextAppCode = resolveSelectedAppCode(options.appOptions, appCode);
if (nextAppCode !== appCode) {
setAppCode(setCurrentAppCode(nextAppCode));
}
setRegionId((current) => (options.regionOptions.some((item) => String(item.id) === String(current)) ? current : "all"));
setCountryId((current) => (options.countryOptions.some((item) => Number(item.id) === Number(current)) ? current : 0));
} catch (err) {
setError(err.message || "筛选项加载失败");
} finally {
setFiltersLoading(false);
}
}, [appCode]);
useEffect(() => {
overviewRef.current = overview;
}, [overview]);
useEffect(() => {
selfGameOverviewRef.current = selfGameOverview;
}, [selfGameOverview]);
const loadOverview = useCallback(async ({ preserveData = false } = {}) => {
const requestId = overviewRequestIdRef.current + 1;
overviewRequestIdRef.current = requestId;
const keepCurrentOverview = preserveData && overviewRef.current;
setLoading(true);
setError("");
if (!keepCurrentOverview) {
setOverview(null);
}
try {
const startMs = rangeStartMs(range, timeZone);
const endMs = rangeEndMs(range, timeZone);
const seriesRange = sameRange(range, todayRange(timeZone)) ? lastDaysRange(7, timeZone) : range;
const data = await fetchStatisticsOverview({
appCode,
countryId,
endMs,
regionId,
seriesEndMs: rangeEndMs(seriesRange, timeZone),
seriesStartMs: rangeStartMs(seriesRange, timeZone),
startMs,
statTz: timeZone
});
const nextOverview = enrichCountryBreakdown(data || {}, scopedCountries);
if (requestId === overviewRequestIdRef.current) {
setOverview(nextOverview);
}
} catch (err) {
if (requestId === overviewRequestIdRef.current) {
if (!keepCurrentOverview) {
setOverview(null);
}
setError(err.message || "请求失败");
}
} finally {
if (requestId === overviewRequestIdRef.current) {
setLoading(false);
}
}
}, [appCode, countryId, range, regionId, scopedCountries, timeZone]);
const loadSelfGameOverview = useCallback(async ({ preserveData = false } = {}) => {
const requestId = selfGameRequestIdRef.current + 1;
selfGameRequestIdRef.current = requestId;
const keepCurrentOverview = preserveData && selfGameOverviewRef.current;
setSelfGameLoading(true);
setSelfGameError("");
if (!keepCurrentOverview) {
setSelfGameOverview(null);
}
try {
const data = await fetchSelfGameStatisticsOverview({
appCode,
countryId,
endMs: rangeEndMs(range, timeZone),
gameId,
regionId,
startMs: rangeStartMs(range, timeZone),
statTz: timeZone
});
if (requestId === selfGameRequestIdRef.current) {
setSelfGameOverview(data || {});
}
} catch (err) {
if (requestId === selfGameRequestIdRef.current) {
if (!keepCurrentOverview) {
setSelfGameOverview(null);
}
setSelfGameError(err.message || "请求失败");
}
} finally {
if (requestId === selfGameRequestIdRef.current) {
setSelfGameLoading(false);
}
}
}, [appCode, countryId, gameId, range, regionId, timeZone]);
useEffect(() => {
// 非数据报表没有筛选控件,提前停止筛选项加载,避免空报表页产生无意义网络请求。
if (!isDataReportSection) {
return undefined;
}
void loadFilterOptions();
}, [isDataReportSection, loadFilterOptions]);
useEffect(() => {
// 当前数据轮询只属于数据报表;切到财务或运营时清理定时器并保留已有数据,方便切回。
if (!isDataReportSection) {
return undefined;
}
if (activeScreen === "selfGames") {
void loadSelfGameOverview();
const timer = window.setInterval(() => void loadSelfGameOverview({ preserveData: true }), 60_000);
return () => window.clearInterval(timer);
}
void loadOverview();
const timer = window.setInterval(() => void loadOverview({ preserveData: true }), 60_000);
return () => window.clearInterval(timer);
}, [activeScreen, isDataReportSection, loadOverview, loadSelfGameOverview]);
const handleRefresh = useCallback(() => {
if (!isDataReportSection) {
return;
}
if (activeScreen === "selfGames") {
void loadSelfGameOverview({ preserveData: true });
return;
}
void loadOverview({ preserveData: true });
}, [activeScreen, isDataReportSection, loadOverview, loadSelfGameOverview]);
const model = useMemo(() => createDashboardModel(overview, { appCode, countryId, range, timeZone }), [appCode, countryId, overview, range, timeZone]);
const showSkeleton = loading && !overview;
const isReportView = viewMode === "report";
const openLuckyGiftPools = useCallback(() => {
setPoolModalOpen(true);
}, []);
return (
<main className={`databi-shell databi-shell--${viewMode}`}>
<ReportSidebar activeSection={activeReportSection} onSectionChange={setActiveReportSection} />
<div className="databi-shell-content">
{isDataReportSection ? (
<section className={[
"databi-screen",
isReportView ? "databi-screen--report" : "databi-screen--bigscreen",
activeScreen === "selfGames" ? "databi-screen--self-game" : ""
].filter(Boolean).join(" ")}>
<DatabiHeader
activeScreen={activeScreen}
appCode={appCode}
appOptions={filterOptions.appOptions}
countryOptions={filterOptions.countryOptions}
countryId={countryId}
error={activeScreen === "selfGames" ? selfGameError : error}
filtersLoading={filtersLoading}
loading={activeScreen === "selfGames" ? selfGameLoading : loading}
onAppChange={handleAppChange}
onCountryChange={setCountryId}
onRefresh={handleRefresh}
onRangeChange={setRange}
onRegionChange={handleRegionChange}
onScreenChange={setActiveScreen}
onTimeZoneChange={handleTimeZoneChange}
range={range}
regionId={regionId}
regionOptions={filterOptions.regionOptions}
timeZone={timeZone}
timeZoneOptions={TIME_ZONE_OPTIONS}
updatedAt={model.updatedAt}
viewMode={viewMode}
onViewModeChange={setViewMode}
/>
{activeScreen === "selfGames" ? (
<SelfGameScreen gameId={gameId} loading={selfGameLoading && !selfGameOverview} onGameChange={setGameId} overview={selfGameOverview} />
) : isReportView ? (
<ReportOverview
loading={showSkeleton}
model={model}
onMetricTrend={setTrendModalItem}
onOpenLuckyGiftPools={openLuckyGiftPools}
/>
) : (
<BigscreenOverview
loading={showSkeleton}
model={model}
onMetricTrend={setTrendModalItem}
onOpenLuckyGiftPools={openLuckyGiftPools}
/>
)}
</section>
) : (
<EmptyReportSection section={activeReportSection} />
)}
{isDataReportSection && activeScreen === "overview" && !isReportView ? (
<>
<CountryPerformanceTable gameRanking={model.gameRanking} giftRanking={model.giftRanking} items={model.countryBreakdown} loading={showSkeleton} />
</>
) : null}
{isDataReportSection && activeScreen === "overview" && poolModalOpen ? (
<LuckyGiftPoolModal items={model.luckyGiftPools} onClose={() => setPoolModalOpen(false)} />
) : null}
{isDataReportSection && activeScreen === "overview" && trendModalItem ? (
<MetricTrendModal item={trendModalItem} onClose={() => setTrendModalItem(null)} />
) : null}
</div>
</main>
);
}
function BigscreenOverview({ loading, model, onMetricTrend, onOpenLuckyGiftPools }) {
return (
<>
<section className="metric-grid" aria-label="核心指标">
{model.kpis.map((item) => (
<MetricCard key={item.label} item={item} loading={loading} onClick={() => onMetricTrend(item)} />
))}
</section>
<section className="business-metric-grid" aria-label="业务指标">
{model.businessKpis.map((item) => (
<MetricCard
key={item.label}
item={item}
loading={loading}
onClick={item.detailKey === "luckyGiftPools" ? onOpenLuckyGiftPools : undefined}
/>
))}
</section>
<section className="business-metric-grid robot-gift-metric-grid" aria-label="真人房机器人送礼指标">
{model.robotGiftKpis.map((item) => (
<MetricCard key={item.label} item={item} loading={loading} onClick={() => onMetricTrend(item)} />
))}
</section>
<section className="databi-grid databi-grid--top">
<GlobalOverviewPanel countryBreakdown={model.countryBreakdown} loading={loading} topCountries={model.topCountries} />
<RevenueTrendPanel loading={loading} revenueSeries={model.revenueSeries} />
<FunnelPanel funnel={model.funnel} loading={loading} sideMetrics={model.sideMetrics} />
</section>
</>
);
}
function resolveSelectedAppCode(options, current) {
const normalized = String(current || "").trim().toLowerCase();
if (options.some((item) => String(item.code || item.value || "").trim().toLowerCase() === normalized)) {
return normalized;
}
return String(options[0]?.code || options[0]?.value || normalized || "lalu").trim().toLowerCase();
}
function resolveRangePresetValue(range, timeZone) {
const presets = [
{ range: todayRange(timeZone), value: "today" },
{ range: lastDaysRange(7, timeZone), value: "7d" },
{ range: lastDaysRange(14, timeZone), value: "14d" },
{ range: lastDaysRange(30, timeZone), value: "30d" },
{ range: thisMonthRange(timeZone), value: "month" }
];
return presets.find((preset) => sameRange(preset.range, range))?.value || "";
}
function rangeForPreset(value, timeZone) {
switch (value) {
case "today":
return todayRange(timeZone);
case "7d":
return lastDaysRange(7, timeZone);
case "14d":
return lastDaysRange(14, timeZone);
case "30d":
return lastDaysRange(30, timeZone);
case "month":
return thisMonthRange(timeZone);
default:
return null;
}
}
function countryBelongsToRegion(country, regionId) {
if (!regionId || regionId === "all") {
return true;
}
if (Array.isArray(country.regionIds) && country.regionIds.length) {
return country.regionIds.includes(String(regionId));
}
return String(country.regionId || "") === String(regionId);
}
function enrichCountryBreakdown(data, countries) {
if ((!Array.isArray(data?.country_breakdown) || !data.country_breakdown.length) &&
(!Array.isArray(data?.daily_country_breakdown) || !data.daily_country_breakdown.length)) {
return data;
}
const countryById = new Map(countries.map((country) => [Number(country.id), country]));
const enrichRows = (rows) => rows.map((row) => {
const country = countryById.get(Number(row.country_id ?? row.countryId));
if (!country) {
return row;
}
return {
...row,
country: row.country || country.name || country.label,
country_code: row.country_code || country.countryCode,
flag: row.flag || country.flag
};
});
return {
...data,
country_breakdown: Array.isArray(data.country_breakdown) ? enrichRows(data.country_breakdown) : data.country_breakdown,
daily_country_breakdown: Array.isArray(data.daily_country_breakdown) ? enrichRows(data.daily_country_breakdown) : data.daily_country_breakdown
};
}