418 lines
16 KiB
JavaScript
418 lines
16 KiB
JavaScript
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import { fetchFilterOptions, fetchSelfGameStatisticsOverview, fetchStatisticsOverview, getCurrentAppCode, getDefaultAppOptions, 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 { SocialBiApp } from "./social/SocialBiApp.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: getDefaultAppOptions(),
|
|
countryOptions: [{ countryCode: "", id: 0, label: "全部国家", regionIds: [], searchText: "全部国家 all", value: 0 }],
|
|
regionOptions: [{ countryCodes: [], id: "all", label: "全部区域", value: "all" }]
|
|
};
|
|
|
|
export function DatabiApp() {
|
|
if (isSocialBiPath()) {
|
|
return <SocialBiApp />;
|
|
}
|
|
return <DatabiOverviewApp />;
|
|
}
|
|
|
|
function DatabiOverviewApp() {
|
|
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
|
|
grantDrilldownQuery={{
|
|
appCode,
|
|
countryId,
|
|
endMs: rangeEndMs(range, timeZone),
|
|
regionId,
|
|
startMs: rangeStartMs(range, timeZone),
|
|
statTz: timeZone
|
|
}}
|
|
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) => optionAppCode(item) === normalized)) {
|
|
return normalized;
|
|
}
|
|
if (options.length) {
|
|
return optionAppCode(options[0]);
|
|
}
|
|
return normalized || "lalu";
|
|
}
|
|
|
|
function optionAppCode(option) {
|
|
return String(option?.code ?? option?.value ?? "").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
|
|
};
|
|
}
|
|
|
|
function isSocialBiPath() {
|
|
return window.location.pathname.replace(/\/+$/, "") === "/databi/social";
|
|
}
|