467 lines
19 KiB
JavaScript
467 lines
19 KiB
JavaScript
import { useEffect, useMemo, useRef, useState } from "react";
|
||
import CalendarMonthOutlined from "@mui/icons-material/CalendarMonthOutlined";
|
||
import KeyboardArrowDownOutlined from "@mui/icons-material/KeyboardArrowDownOutlined";
|
||
import PublicOutlined from "@mui/icons-material/PublicOutlined";
|
||
import { getTimeZoneLabel, lastDaysRange, rangeEndMs, rangeStartMs, sameRange, thisMonthRange, TIME_ZONE_OPTIONS, todayRange } from "../utils/time.js";
|
||
|
||
const defaultCountryOptions = [{ countryCode: "", id: 0, label: "全部国家", regionIds: [], searchText: "全部国家 all", value: 0 }];
|
||
const defaultRegionOptions = [{ countryCodes: [], id: "all", label: "全部区域", value: "all" }];
|
||
const rangeOptions = [
|
||
{ label: "今日", value: "today", getRange: (timeZone) => todayRange(timeZone) },
|
||
{ label: "近 7 日", value: "7d", getRange: (timeZone) => lastDaysRange(7, timeZone) },
|
||
{ label: "近 14 日", value: "14d", getRange: (timeZone) => lastDaysRange(14, timeZone) },
|
||
{ label: "近 30 日", value: "30d", getRange: (timeZone) => lastDaysRange(30, timeZone) },
|
||
{ label: "本月", value: "month", getRange: (timeZone) => thisMonthRange(timeZone) }
|
||
];
|
||
const weekLabels = ["一", "二", "三", "四", "五", "六", "日"];
|
||
const hours = Array.from({ length: 24 }, (_, index) => pad2(index));
|
||
const minutes = Array.from({ length: 60 }, (_, index) => pad2(index));
|
||
const seconds = minutes;
|
||
|
||
export function DatabiHeader({
|
||
activeScreen = "overview",
|
||
appCode,
|
||
appOptions = [],
|
||
countryId,
|
||
countryOptions = defaultCountryOptions,
|
||
error = "",
|
||
filtersLoading = false,
|
||
loading = false,
|
||
onScreenChange,
|
||
onAppChange,
|
||
onCountryChange,
|
||
onRangeChange,
|
||
onRegionChange,
|
||
onTimeZoneChange,
|
||
onViewModeChange,
|
||
range,
|
||
regionId,
|
||
regionOptions = defaultRegionOptions,
|
||
timeZone,
|
||
timeZoneOptions = TIME_ZONE_OPTIONS,
|
||
viewMode = "report"
|
||
}) {
|
||
const [openControl, setOpenControl] = useState("");
|
||
const headerRef = useRef(null);
|
||
const appLabel = appOptions.find((item) => item.code === appCode || item.value === appCode)?.label || appCode;
|
||
const regionLabel = regionOptions.find((item) => String(item.id) === String(regionId) || String(item.value) === String(regionId))?.label || "全部区域";
|
||
const timeZoneLabel = getTimeZoneLabel(timeZone);
|
||
const filteredCountries = useMemo(() => {
|
||
if (regionId === "all" || !regionOptions.some((item) => item.countryCodes?.length)) {
|
||
return countryOptions;
|
||
}
|
||
return countryOptions.filter((item) => Number(item.id) === 0 || item.regionIds?.includes(String(regionId)) || item.regionId === regionId);
|
||
}, [countryOptions, regionId, regionOptions]);
|
||
const countryLabel = filteredCountries.find((item) => Number(item.id) === Number(countryId))?.label || "全部国家";
|
||
const activeRange = rangeOptions.find((item) => {
|
||
const next = item.getRange(timeZone);
|
||
return sameRange(next, range);
|
||
});
|
||
|
||
useEffect(() => {
|
||
const closeOnOutsideClick = (event) => {
|
||
if (!headerRef.current?.contains(event.target)) {
|
||
setOpenControl("");
|
||
}
|
||
};
|
||
document.addEventListener("mousedown", closeOnOutsideClick);
|
||
return () => document.removeEventListener("mousedown", closeOnOutsideClick);
|
||
}, []);
|
||
|
||
const toggleControl = (name) => {
|
||
setOpenControl((current) => (current === name ? "" : name));
|
||
};
|
||
|
||
const selectItem = (callback, value) => {
|
||
callback(value);
|
||
setOpenControl("");
|
||
};
|
||
|
||
return (
|
||
<header className="databi-header" ref={headerRef}>
|
||
<div className="databi-header-left">
|
||
<div className="brand-lockup">
|
||
<div>
|
||
<h1>数据中心</h1>
|
||
</div>
|
||
</div>
|
||
<div className="view-mode-switch" role="tablist" aria-label="视图切换">
|
||
<button aria-selected={viewMode === "report"} className={viewMode === "report" ? "is-active" : ""} onClick={() => onViewModeChange?.("report")} role="tab" type="button">
|
||
报表视图
|
||
</button>
|
||
<button aria-selected={viewMode === "bigscreen"} className={viewMode === "bigscreen" ? "is-active" : ""} onClick={() => onViewModeChange?.("bigscreen")} role="tab" type="button">
|
||
大屏 BI
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<nav className="screen-switch" aria-label="数据大屏切换">
|
||
<button className={activeScreen === "overview" ? "is-active" : ""} onClick={() => onScreenChange?.("overview")} type="button">
|
||
运营总览
|
||
</button>
|
||
<button className={activeScreen === "selfGames" ? "is-active" : ""} onClick={() => onScreenChange?.("selfGames")} type="button">
|
||
自研游戏
|
||
</button>
|
||
</nav>
|
||
<div className="header-controls">
|
||
<FilterMenu
|
||
className="filter-control--timezone"
|
||
icon={<span className="control-glyph">TZ</span>}
|
||
isOpen={openControl === "timezone"}
|
||
label="时区"
|
||
onSelect={(value) => selectItem(onTimeZoneChange, value)}
|
||
onToggle={() => toggleControl("timezone")}
|
||
options={timeZoneOptions}
|
||
value={timeZone}
|
||
valueLabel={timeZoneLabel}
|
||
/>
|
||
<FilterMenu
|
||
className="filter-control--app"
|
||
icon={<span className="control-glyph">APP</span>}
|
||
isOpen={openControl === "app"}
|
||
label="App"
|
||
loading={filtersLoading}
|
||
onSelect={(value) => selectItem(onAppChange, value)}
|
||
onToggle={() => toggleControl("app")}
|
||
options={appOptions.map((item) => ({ label: item.label, value: item.code }))}
|
||
value={appCode}
|
||
valueLabel={appLabel}
|
||
/>
|
||
<RangeMenu
|
||
activeValue={activeRange?.value}
|
||
isOpen={openControl === "range"}
|
||
onSelect={(nextRange) => selectItem(onRangeChange, nextRange)}
|
||
onToggle={() => toggleControl("range")}
|
||
range={range}
|
||
timeZone={timeZone}
|
||
/>
|
||
<FilterMenu
|
||
icon={<PublicOutlined fontSize="small" />}
|
||
isOpen={openControl === "region"}
|
||
label="区域"
|
||
loading={filtersLoading}
|
||
onSelect={(value) => selectItem(onRegionChange, value)}
|
||
onToggle={() => toggleControl("region")}
|
||
options={regionOptions}
|
||
value={regionId}
|
||
valueLabel={regionLabel}
|
||
/>
|
||
<SearchableFilterMenu
|
||
icon={<span className="control-glyph">CN</span>}
|
||
isOpen={openControl === "country"}
|
||
label="国家"
|
||
loading={filtersLoading}
|
||
onSelect={(value) => selectItem(onCountryChange, Number(value))}
|
||
onToggle={() => toggleControl("country")}
|
||
options={filteredCountries}
|
||
value={countryId}
|
||
valueLabel={countryLabel}
|
||
/>
|
||
<span className={error ? "live-dot live-dot--warn" : "live-dot"} title={error || ""}>
|
||
{error ? "异常" : loading ? "刷新中" : "实时"}
|
||
</span>
|
||
{error ? <span className="header-error">{error}</span> : null}
|
||
</div>
|
||
</header>
|
||
);
|
||
}
|
||
|
||
function FilterMenu({ className = "", icon, isOpen, label, loading = false, onSelect, onToggle, options, value, valueLabel }) {
|
||
return (
|
||
<div className={["filter-control", className].filter(Boolean).join(" ")}>
|
||
<button aria-expanded={isOpen} className="filter-trigger" disabled={loading} onClick={onToggle} type="button">
|
||
<span className="filter-label">
|
||
{icon ? <span className="filter-icon">{icon}</span> : null}
|
||
<span>{label}</span>
|
||
</span>
|
||
<span className="filter-value">{valueLabel}</span>
|
||
<KeyboardArrowDownOutlined className="filter-chevron" fontSize="small" />
|
||
</button>
|
||
{isOpen ? (
|
||
<div className="filter-popover" role="listbox">
|
||
{loading ? <span className="filter-empty">加载中...</span> : null}
|
||
{!loading && !options.length ? <span className="filter-empty">暂无选项</span> : null}
|
||
{!loading
|
||
? options.map((item) => (
|
||
<button className={item.value === value || item.id === value ? "is-active" : ""} key={item.value ?? item.id} onClick={() => onSelect(item.value ?? item.id)} role="option" type="button">
|
||
{item.label}
|
||
</button>
|
||
))
|
||
: null}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function SearchableFilterMenu({ icon, isOpen, label, loading = false, onSelect, onToggle, options, value, valueLabel }) {
|
||
const [query, setQuery] = useState("");
|
||
const filteredOptions = useMemo(() => {
|
||
const keyword = query.trim().toLowerCase();
|
||
if (!keyword) {
|
||
return options;
|
||
}
|
||
return options.filter((item) => `${item.label || ""} ${item.countryCode || ""} ${item.searchText || ""}`.toLowerCase().includes(keyword));
|
||
}, [options, query]);
|
||
|
||
useEffect(() => {
|
||
if (isOpen) {
|
||
setQuery("");
|
||
}
|
||
}, [isOpen]);
|
||
|
||
return (
|
||
<div className="filter-control filter-control--country">
|
||
<button aria-expanded={isOpen} className="filter-trigger" disabled={loading} onClick={onToggle} type="button">
|
||
<span className="filter-label">
|
||
{icon ? <span className="filter-icon">{icon}</span> : null}
|
||
<span>{label}</span>
|
||
</span>
|
||
<span className="filter-value">{valueLabel}</span>
|
||
<KeyboardArrowDownOutlined className="filter-chevron" fontSize="small" />
|
||
</button>
|
||
{isOpen ? (
|
||
<div className="filter-popover filter-popover--searchable" role="listbox">
|
||
<label className="filter-search">
|
||
<span>搜索国家</span>
|
||
<input placeholder="国家 / Code" type="text" value={query} onChange={(event) => setQuery(event.target.value)} />
|
||
</label>
|
||
{loading ? <span className="filter-empty">加载中...</span> : null}
|
||
{!loading && !filteredOptions.length ? <span className="filter-empty">没有匹配国家</span> : null}
|
||
{!loading && filteredOptions.length ? (
|
||
<div className="filter-options-scroll">
|
||
{filteredOptions.map((item) => (
|
||
<button className={Number(item.id) === Number(value) ? "is-active" : ""} key={item.value ?? item.id} onClick={() => onSelect(item.value ?? item.id)} role="option" type="button">
|
||
<span>{item.label}</span>
|
||
{item.countryCode ? <small>{item.countryCode}</small> : null}
|
||
</button>
|
||
))}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function RangeMenu({ activeValue, isOpen, onSelect, onToggle, range, timeZone }) {
|
||
const options = rangeOptions.map((item) => ({ ...item, range: item.getRange(timeZone) }));
|
||
const activeOption = options.find((item) => item.value === activeValue);
|
||
const [draft, setDraft] = useState(() => normalizeRange(range));
|
||
const [activeField, setActiveField] = useState("start");
|
||
const [viewMonth, setViewMonth] = useState(() => monthDate(range.start));
|
||
const [error, setError] = useState("");
|
||
const calendarDays = useMemo(() => createCalendarDays(viewMonth), [viewMonth]);
|
||
const activeTime = splitTime(draft[`${activeField}Time`]);
|
||
|
||
useEffect(() => {
|
||
if (isOpen) {
|
||
const normalized = normalizeRange(range);
|
||
setDraft(normalized);
|
||
setActiveField("start");
|
||
setViewMonth(monthDate(normalized.start));
|
||
setError("");
|
||
}
|
||
}, [isOpen, range]);
|
||
|
||
const changeMonth = (offset) => {
|
||
setViewMonth((current) => new Date(current.getFullYear(), current.getMonth() + offset, 1));
|
||
};
|
||
|
||
const changeYear = (offset) => {
|
||
setViewMonth((current) => new Date(current.getFullYear() + offset, current.getMonth(), 1));
|
||
};
|
||
|
||
const selectQuickRange = (nextRange) => {
|
||
const normalized = normalizeRange(nextRange);
|
||
setDraft(normalized);
|
||
setActiveField("start");
|
||
setViewMonth(monthDate(normalized.start));
|
||
setError("");
|
||
};
|
||
|
||
const activateField = (field) => {
|
||
setActiveField(field);
|
||
setViewMonth(monthDate(draft[field]));
|
||
setError("");
|
||
};
|
||
|
||
const selectDate = (date) => {
|
||
const selectedValue = dateValue(date);
|
||
setDraft((current) => ({ ...current, [activeField]: selectedValue }));
|
||
if (activeField === "start") {
|
||
setActiveField("end");
|
||
setViewMonth(monthDate(selectedValue));
|
||
}
|
||
setError("");
|
||
};
|
||
|
||
const selectTime = (part, value) => {
|
||
setDraft((current) => ({ ...current, [`${activeField}Time`]: updateTimePart(current[`${activeField}Time`], part, value) }));
|
||
setError("");
|
||
};
|
||
|
||
const applyDraft = () => {
|
||
const normalized = normalizeRange(draft);
|
||
if (rangeStartMs(normalized, timeZone) > rangeEndMs(normalized, timeZone)) {
|
||
setError("结束时间必须晚于开始时间");
|
||
return;
|
||
}
|
||
onSelect(normalized);
|
||
};
|
||
|
||
return (
|
||
<div className="filter-control filter-control--range">
|
||
<button aria-expanded={isOpen} aria-haspopup="listbox" className="date-range-trigger" onClick={onToggle} type="button">
|
||
<span className="date-range-label">
|
||
<CalendarMonthOutlined fontSize="inherit" />
|
||
<span>{activeOption?.label || "时间范围"}</span>
|
||
</span>
|
||
<span className="date-range-values">
|
||
<b>{range.start}</b>
|
||
<i>~</i>
|
||
<b>{range.end}</b>
|
||
</span>
|
||
<KeyboardArrowDownOutlined className="date-range-chevron" fontSize="small" />
|
||
</button>
|
||
{isOpen ? (
|
||
<div className="date-time-popover" role="dialog">
|
||
<div className="date-time-shortcuts" role="listbox">
|
||
{options.map((item) => (
|
||
<button aria-selected={sameRange(item.range, draft)} className={sameRange(item.range, draft) ? "is-active" : ""} key={item.value} onClick={() => selectQuickRange(item.range)} role="option" type="button">
|
||
{item.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<div className="date-time-fields" role="tablist">
|
||
<button aria-selected={activeField === "start"} className={activeField === "start" ? "is-active" : ""} onClick={() => activateField("start")} role="tab" type="button">
|
||
<span>开始时间</span>
|
||
<b>{formatDateTimePart(draft.start, draft.startTime)}</b>
|
||
</button>
|
||
<button aria-selected={activeField === "end"} className={activeField === "end" ? "is-active" : ""} onClick={() => activateField("end")} role="tab" type="button">
|
||
<span>结束时间</span>
|
||
<b>{formatDateTimePart(draft.end, draft.endTime)}</b>
|
||
</button>
|
||
</div>
|
||
<div className="date-time-picker">
|
||
<div className="calendar-pane">
|
||
<div className="calendar-toolbar">
|
||
<button aria-label="上一年" onClick={() => changeYear(-1)} type="button">«</button>
|
||
<button aria-label="上个月" onClick={() => changeMonth(-1)} type="button">‹</button>
|
||
<strong>{viewMonth.getFullYear()}年 {viewMonth.getMonth() + 1}月</strong>
|
||
<button aria-label="下个月" onClick={() => changeMonth(1)} type="button">›</button>
|
||
<button aria-label="下一年" onClick={() => changeYear(1)} type="button">»</button>
|
||
</div>
|
||
<div className="calendar-grid">
|
||
{weekLabels.map((label) => (
|
||
<span className="calendar-weekday" key={label}>{label}</span>
|
||
))}
|
||
{calendarDays.map((date) => {
|
||
const value = dateValue(date);
|
||
const className = [
|
||
"calendar-day",
|
||
date.getMonth() !== viewMonth.getMonth() ? "is-muted" : "",
|
||
value === draft.start ? "is-start" : "",
|
||
value === draft.end ? "is-end" : "",
|
||
isBetween(value, draft.start, draft.end) ? "is-between" : "",
|
||
value === draft[activeField] ? "is-active" : ""
|
||
].filter(Boolean).join(" ");
|
||
return (
|
||
<button className={className} key={value} onClick={() => selectDate(date)} type="button">
|
||
{date.getDate()}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
<div className="time-pane" aria-label="时间">
|
||
<TimeColumn label="时" onSelect={(value) => selectTime("hour", value)} options={hours} value={activeTime.hour} />
|
||
<TimeColumn label="分" onSelect={(value) => selectTime("minute", value)} options={minutes} value={activeTime.minute} />
|
||
<TimeColumn label="秒" onSelect={(value) => selectTime("second", value)} options={seconds} value={activeTime.second} />
|
||
</div>
|
||
</div>
|
||
<div className="date-time-footer">
|
||
{error ? <span>{error}</span> : <span>{formatRange(draft)}</span>}
|
||
<button onClick={applyDraft} type="button">确定</button>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function TimeColumn({ label, onSelect, options, value }) {
|
||
return (
|
||
<div className="time-column">
|
||
<span>{label}</span>
|
||
<div>
|
||
{options.map((item) => (
|
||
<button className={item === value ? "is-active" : ""} key={item} onClick={() => onSelect(item)} type="button">
|
||
{item}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function dateValue(date) {
|
||
return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;
|
||
}
|
||
|
||
function formatRange(range) {
|
||
return `${formatDateTimePart(range.start, range.startTime)} ~ ${formatDateTimePart(range.end, range.endTime)}`;
|
||
}
|
||
|
||
function formatDateTimePart(date, time) {
|
||
return `${date} ${normalizeTime(time)}`;
|
||
}
|
||
|
||
function normalizeRange(range) {
|
||
return {
|
||
end: range?.end || dateValue(new Date()),
|
||
endTime: normalizeTime(range?.endTime || "23:59:59"),
|
||
start: range?.start || dateValue(new Date()),
|
||
startTime: normalizeTime(range?.startTime || "00:00:00")
|
||
};
|
||
}
|
||
|
||
function monthDate(value) {
|
||
const [year, month] = String(value || dateValue(new Date())).split("-").map(Number);
|
||
return new Date(year || new Date().getFullYear(), (month || 1) - 1, 1);
|
||
}
|
||
|
||
function createCalendarDays(viewMonth) {
|
||
const firstDay = new Date(viewMonth.getFullYear(), viewMonth.getMonth(), 1);
|
||
const mondayIndex = (firstDay.getDay() + 6) % 7;
|
||
const start = new Date(firstDay);
|
||
start.setDate(firstDay.getDate() - mondayIndex);
|
||
return Array.from({ length: 42 }, (_, index) => {
|
||
const date = new Date(start);
|
||
date.setDate(start.getDate() + index);
|
||
return date;
|
||
});
|
||
}
|
||
|
||
function isBetween(value, start, end) {
|
||
return value > start && value < end;
|
||
}
|
||
|
||
function splitTime(value) {
|
||
const [hour, minute, second] = normalizeTime(value).split(":");
|
||
return { hour, minute, second };
|
||
}
|
||
|
||
function updateTimePart(value, part, nextValue) {
|
||
const parts = splitTime(value);
|
||
return [part === "hour" ? nextValue : parts.hour, part === "minute" ? nextValue : parts.minute, part === "second" ? nextValue : parts.second].join(":");
|
||
}
|
||
|
||
function normalizeTime(value) {
|
||
const [hour = "00", minute = "00", second = "00"] = String(value || "00:00:00").split(":");
|
||
return `${pad2(hour)}:${pad2(minute)}:${pad2(second)}`;
|
||
}
|
||
|
||
function pad2(value) {
|
||
return String(value).padStart(2, "0");
|
||
}
|