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

467 lines
19 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.

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");
}