批量上传和databi
This commit is contained in:
parent
d4eaf20c8e
commit
e3ab3cba1e
@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { fetchStatisticsOverview, getCurrentAppCode } from "./api.js";
|
||||
import { fetchStatisticsOverview, getCurrentAppCode, setCurrentAppCode } from "./api.js";
|
||||
import { CountryPerformanceTable } from "./components/CountryPerformanceTable.jsx";
|
||||
import { DatabiHeader } from "./components/DatabiHeader.jsx";
|
||||
import { FunnelPanel } from "./components/FunnelPanel.jsx";
|
||||
@ -8,17 +8,27 @@ import { MetricCard } from "./components/MetricCard.jsx";
|
||||
import { RevenueTrendPanel } from "./components/RevenueTrendPanel.jsx";
|
||||
import { createDashboardModel } from "./data/createDashboardModel.js";
|
||||
import { sampleOverview } from "./data/sampleOverview.js";
|
||||
import { endOfDay, lastDaysRange, startOfDay } from "./utils/time.js";
|
||||
import { lastDaysRange, rangeEndMs, rangeStartMs } from "./utils/time.js";
|
||||
|
||||
export function DatabiApp() {
|
||||
const initialRange = useMemo(() => lastDaysRange(7), []);
|
||||
const [range, setRange] = useState(initialRange);
|
||||
const [countryId, setCountryId] = useState(0);
|
||||
const [regionId, setRegionId] = useState("all");
|
||||
const [appCode, setAppCode] = useState(() => getCurrentAppCode());
|
||||
const [overview, setOverview] = useState(sampleOverview);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [preview, setPreview] = useState(true);
|
||||
const appCode = getCurrentAppCode();
|
||||
|
||||
const handleAppChange = useCallback((value) => {
|
||||
setAppCode(setCurrentAppCode(value));
|
||||
}, []);
|
||||
|
||||
const handleRegionChange = useCallback((value) => {
|
||||
setRegionId(value);
|
||||
setCountryId(0);
|
||||
}, []);
|
||||
|
||||
const loadOverview = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@ -27,8 +37,8 @@ export function DatabiApp() {
|
||||
const data = await fetchStatisticsOverview({
|
||||
appCode,
|
||||
countryId,
|
||||
endMs: endOfDay(range.end),
|
||||
startMs: startOfDay(range.start)
|
||||
endMs: rangeEndMs(range),
|
||||
startMs: rangeStartMs(range)
|
||||
});
|
||||
setOverview(data || sampleOverview);
|
||||
setPreview(false);
|
||||
@ -39,7 +49,7 @@ export function DatabiApp() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [appCode, countryId, range.end, range.start]);
|
||||
}, [appCode, countryId, range]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadOverview();
|
||||
@ -57,11 +67,14 @@ export function DatabiApp() {
|
||||
countryId={countryId}
|
||||
error={error}
|
||||
loading={loading}
|
||||
onAppChange={handleAppChange}
|
||||
onCountryChange={setCountryId}
|
||||
onRefresh={loadOverview}
|
||||
onRangeChange={setRange}
|
||||
onRegionChange={handleRegionChange}
|
||||
preview={preview}
|
||||
range={range}
|
||||
regionId={regionId}
|
||||
updatedAt={model.updatedAt}
|
||||
/>
|
||||
|
||||
@ -85,10 +98,6 @@ export function DatabiApp() {
|
||||
</section>
|
||||
|
||||
<CountryPerformanceTable gameRanking={model.gameRanking} giftRanking={model.giftRanking} items={model.countryBreakdown} />
|
||||
<footer className="databi-footer">
|
||||
<span>所有数据按 UTC 展示,实时更新。</span>
|
||||
<strong>数据最多可能延迟 60 秒。</strong>
|
||||
</footer>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@ -7,6 +7,12 @@ export function getCurrentAppCode() {
|
||||
return normalizeAppCode(window.localStorage.getItem(APP_CODE_KEY) || "lalu");
|
||||
}
|
||||
|
||||
export function setCurrentAppCode(value) {
|
||||
const appCode = normalizeAppCode(value) || "lalu";
|
||||
window.localStorage.setItem(APP_CODE_KEY, appCode);
|
||||
return appCode;
|
||||
}
|
||||
|
||||
export async function fetchStatisticsOverview({ appCode, countryId, endMs, startMs }) {
|
||||
const query = new URLSearchParams();
|
||||
query.set("app_code", normalizeAppCode(appCode) || "lalu");
|
||||
|
||||
@ -1,35 +1,360 @@
|
||||
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 { countryOptions } from "../config/options.js";
|
||||
import { appOptions, countryOptions, regionOptions } from "../config/options.js";
|
||||
import { lastDaysRange } from "../utils/time.js";
|
||||
|
||||
const rangeOptions = [
|
||||
{ label: "近 7 日", value: "7d", getRange: () => lastDaysRange(7) },
|
||||
{ label: "近 14 日", value: "14d", getRange: () => lastDaysRange(14) },
|
||||
{ label: "近 30 日", value: "30d", getRange: () => lastDaysRange(30) },
|
||||
{ label: "本月", value: "month", getRange: thisMonthRange }
|
||||
];
|
||||
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({ appCode, countryId, onAppChange, onCountryChange, onRangeChange, onRegionChange, range, regionId }) {
|
||||
const [openControl, setOpenControl] = useState("");
|
||||
const headerRef = useRef(null);
|
||||
const appLabel = appOptions.find((item) => item.code === appCode)?.label || appCode;
|
||||
const regionLabel = regionOptions.find((item) => item.id === regionId)?.label || "全部区域";
|
||||
const filteredCountries = useMemo(() => {
|
||||
if (regionId === "all") {
|
||||
return countryOptions;
|
||||
}
|
||||
return countryOptions.filter((item) => item.id === 0 || item.regionId === regionId);
|
||||
}, [regionId]);
|
||||
const countryLabel = filteredCountries.find((item) => item.id === countryId)?.label || "全部国家";
|
||||
const activeRange = rangeOptions.find((item) => {
|
||||
const next = item.getRange();
|
||||
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("");
|
||||
};
|
||||
|
||||
export function DatabiHeader({ countryId, onCountryChange, onRangeChange, range }) {
|
||||
return (
|
||||
<header className="databi-header">
|
||||
<header className="databi-header" ref={headerRef}>
|
||||
<div className="brand-lockup">
|
||||
<div className="brand-emblem" aria-hidden="true" />
|
||||
<div>
|
||||
<h1>全球运营指挥中心</h1>
|
||||
<h1>App Data</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div className="header-controls">
|
||||
<div className="range-control">
|
||||
<input aria-label="开始日期" type="text" value={range.start} onChange={(event) => onRangeChange({ ...range, start: event.target.value })} />
|
||||
<span>~</span>
|
||||
<input aria-label="结束日期" type="text" value={range.end} onChange={(event) => onRangeChange({ ...range, end: event.target.value })} />
|
||||
<CalendarMonthOutlined fontSize="small" />
|
||||
</div>
|
||||
<label className="region-control">
|
||||
<PublicOutlined fontSize="small" />
|
||||
<select aria-label="国家区域" value={countryId} onChange={(event) => onCountryChange(Number(event.target.value))}>
|
||||
{countryOptions.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<FilterMenu
|
||||
icon={<span className="control-glyph">APP</span>}
|
||||
isOpen={openControl === "app"}
|
||||
label="App"
|
||||
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}
|
||||
/>
|
||||
<FilterMenu
|
||||
icon={<PublicOutlined fontSize="small" />}
|
||||
isOpen={openControl === "region"}
|
||||
label="区域"
|
||||
onSelect={(value) => selectItem(onRegionChange, value)}
|
||||
onToggle={() => toggleControl("region")}
|
||||
options={regionOptions}
|
||||
value={regionId}
|
||||
valueLabel={regionLabel}
|
||||
/>
|
||||
<FilterMenu
|
||||
icon={<span className="control-glyph">CN</span>}
|
||||
isOpen={openControl === "country"}
|
||||
label="国家"
|
||||
onSelect={(value) => selectItem(onCountryChange, Number(value))}
|
||||
onToggle={() => toggleControl("country")}
|
||||
options={filteredCountries}
|
||||
value={countryId}
|
||||
valueLabel={countryLabel}
|
||||
/>
|
||||
<span className="live-dot">实时</span>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function FilterMenu({ icon, isOpen, label, onSelect, onToggle, options, value, valueLabel }) {
|
||||
return (
|
||||
<div className="filter-control">
|
||||
<button aria-expanded={isOpen} className="filter-trigger" onClick={onToggle} type="button">
|
||||
{icon ? <span className="filter-icon">{icon}</span> : null}
|
||||
<span className="filter-copy">
|
||||
<small>{label}</small>
|
||||
<b>{valueLabel}</b>
|
||||
</span>
|
||||
<KeyboardArrowDownOutlined className="filter-chevron" fontSize="small" />
|
||||
</button>
|
||||
{isOpen ? (
|
||||
<div className="filter-popover" role="listbox">
|
||||
{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>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RangeMenu({ activeValue, isOpen, onSelect, onToggle, range }) {
|
||||
const options = rangeOptions.map((item) => ({ ...item, range: item.getRange() }));
|
||||
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) => {
|
||||
setDraft((current) => ({ ...current, [activeField]: dateValue(date) }));
|
||||
setError("");
|
||||
};
|
||||
|
||||
const selectTime = (part, value) => {
|
||||
setDraft((current) => ({ ...current, [`${activeField}Time`]: updateTimePart(current[`${activeField}Time`], part, value) }));
|
||||
setError("");
|
||||
};
|
||||
|
||||
const applyDraft = () => {
|
||||
const normalized = normalizeRange(draft);
|
||||
if (rangeToMs(normalized, "start") > rangeToMs(normalized, "end")) {
|
||||
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-icon"><CalendarMonthOutlined fontSize="small" /></span>
|
||||
<span className="date-range-copy">
|
||||
<span className="date-range-label">{activeOption?.label || "时间范围"}</span>
|
||||
<span className="date-range-values">
|
||||
<b>{range.start}</b>
|
||||
<i>~</i>
|
||||
<b>{range.end}</b>
|
||||
</span>
|
||||
</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 thisMonthRange() {
|
||||
const end = new Date();
|
||||
const start = new Date(end.getFullYear(), end.getMonth(), 1);
|
||||
return { end: dateValue(end), endTime: "23:59:59", start: dateValue(start), startTime: "00:00:00" };
|
||||
}
|
||||
|
||||
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 sameRange(left, right) {
|
||||
return left.start === right.start && left.end === right.end && normalizeTime(left.startTime) === normalizeTime(right.startTime) && normalizeTime(left.endTime) === normalizeTime(right.endTime);
|
||||
}
|
||||
|
||||
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 rangeToMs(range, field) {
|
||||
return new Date(`${range[field]}T${normalizeTime(range[`${field}Time`])}`).getTime();
|
||||
}
|
||||
|
||||
function pad2(value) {
|
||||
return String(value).padStart(2, "0");
|
||||
}
|
||||
|
||||
@ -1,10 +1,15 @@
|
||||
export function MetricCard({ item }) {
|
||||
const Icon = item.icon;
|
||||
const UnitIcon = item.unitIcon;
|
||||
return (
|
||||
<article className={["metric-card", Icon ? "" : "metric-card--no-icon"].filter(Boolean).join(" ")}>
|
||||
{Icon ? <div className="metric-icon"><Icon /></div> : null}
|
||||
<div className="metric-content">
|
||||
<span>{item.label}</span>
|
||||
<div className="metric-label-row">
|
||||
<span>{item.label}</span>
|
||||
{UnitIcon ? <i className="metric-unit-icon"><UnitIcon /></i> : null}
|
||||
{item.unit ? <em className="metric-unit">{item.unit}</em> : null}
|
||||
</div>
|
||||
<strong>{item.value}</strong>
|
||||
<div className={["metric-delta", item.deltaTone === "down" ? "metric-delta--down" : ""].join(" ")}>
|
||||
<span>{item.caption}</span>
|
||||
|
||||
@ -1,8 +1,20 @@
|
||||
export const countryOptions = [
|
||||
{ id: 0, label: "全部区域" },
|
||||
{ id: 101, label: "印度尼西亚" },
|
||||
{ id: 102, label: "印度" },
|
||||
{ id: 103, label: "美国" },
|
||||
{ id: 104, label: "巴西" },
|
||||
{ id: 105, label: "土耳其" }
|
||||
export const appOptions = [
|
||||
{ code: "lalu", label: "Lalu" },
|
||||
{ code: "mifa", label: "Mifa" },
|
||||
{ code: "mifapay", label: "MifaPay" }
|
||||
];
|
||||
|
||||
export const regionOptions = [
|
||||
{ id: "all", label: "全部区域" },
|
||||
{ id: "asia", label: "亚洲" },
|
||||
{ id: "americas", label: "美洲" }
|
||||
];
|
||||
|
||||
export const countryOptions = [
|
||||
{ id: 0, label: "全部国家", regionId: "all" },
|
||||
{ id: 101, label: "印度尼西亚", regionId: "asia" },
|
||||
{ id: 102, label: "印度", regionId: "asia" },
|
||||
{ id: 103, label: "美国", regionId: "americas" },
|
||||
{ id: 104, label: "巴西", regionId: "americas" },
|
||||
{ id: 105, label: "土耳其", regionId: "asia" }
|
||||
];
|
||||
|
||||
@ -1,16 +1,25 @@
|
||||
import { CoinIcon, ActiveUsersIcon, CrownIcon, StarUserIcon, TrendIcon, UserPlusIcon } from "../components/MetricIcons.jsx";
|
||||
import { countryFlag, resolveCountryMeta } from "./countryMeta.js";
|
||||
import { formatMoney, formatMoneyFull, formatNumber, formatPercent, formatWholeMoney } from "../utils/format.js";
|
||||
import { formatCoin, formatMoney, formatMoneyFull, formatNumber, formatPercent, formatWholeMoney } from "../utils/format.js";
|
||||
import { numberValue, ratio } from "../utils/number.js";
|
||||
import { formatDateTime } from "../utils/time.js";
|
||||
import { sampleOverview } from "./sampleOverview.js";
|
||||
|
||||
export function createDashboardModel(overview, { appCode, countryId, preview }) {
|
||||
const source = overview || sampleOverview;
|
||||
const recharge = numberValue(source.recharge_usd_minor);
|
||||
const activeUsers = numberValue(source.active_users);
|
||||
const paidUsers = numberValue(source.paid_users);
|
||||
const newUsers = numberValue(source.new_users);
|
||||
const recharge = readNumber(source, "recharge_usd_minor", "rechargeUsdMinor", "total_recharge_usd_minor", "totalRechargeUsdMinor");
|
||||
const newUserRecharge = readNumber(source, "new_user_recharge_usd_minor", "newUserRechargeUsdMinor");
|
||||
const activeUsers = readNumber(source, "active_users", "activeUsers");
|
||||
const paidUsers = readNumber(source, "paid_users", "paidUsers");
|
||||
const newUsers = readNumber(source, "new_users", "newUsers", "visitors");
|
||||
const arpu = readNumber(source, "arpu_usd_minor", "arpuUsdMinor");
|
||||
const arppu = readNumber(source, "arppu_usd_minor", "arppuUsdMinor");
|
||||
const giftCoinSpent = readNumber(source, "gift_coin_spent", "giftCoinSpent");
|
||||
const luckyGiftTurnover = readNumber(source, "room_lucky_gift_turnover", "roomLuckyGiftTurnover", "lucky_gift_turnover", "luckyGiftTurnover");
|
||||
const luckyGiftPayout = readNumber(source, "lucky_gift_payout", "luckyGiftPayout");
|
||||
const luckyGiftProfit = readNumber(source, "lucky_gift_profit", "luckyGiftProfit");
|
||||
const gameTurnover = readNumber(source, "game_turnover", "gameTurnover");
|
||||
const gameProfit = readNumber(source, "game_profit", "gameProfit");
|
||||
const countryBreakdown = normalizeCountries(source, countryId);
|
||||
const revenueSeries = normalizeRevenueSeries(source);
|
||||
const payoutDistribution = normalizeDistribution(source);
|
||||
@ -19,12 +28,12 @@ export function createDashboardModel(overview, { appCode, countryId, preview })
|
||||
return {
|
||||
appCode,
|
||||
businessKpis: [
|
||||
{ caption: "近 7 日", delta: preview ? "+16.80%" : "", label: "礼物消费", value: formatWholeMoney(source.gift_coin_spent) },
|
||||
{ caption: "近 7 日", delta: preview ? "+12.93%" : "", label: "幸运礼物流水", value: formatWholeMoney(source.room_lucky_gift_turnover ?? source.lucky_gift_turnover) },
|
||||
{ caption: "近 7 日", delta: preview ? "+8.15%" : "", label: "幸运礼物返奖", value: formatWholeMoney(source.lucky_gift_payout) },
|
||||
{ caption: "近 7 日", delta: preview ? "+4.66%" : "", label: "幸运礼物利润", value: formatWholeMoney(source.lucky_gift_profit) },
|
||||
{ caption: "近 7 日", delta: preview ? "+14.20%" : "", label: "游戏流水", value: formatWholeMoney(source.game_turnover) },
|
||||
{ caption: "近 7 日", delta: preview ? "+18.18%" : "", label: "游戏利润", value: formatWholeMoney(source.game_profit) }
|
||||
coinMetric("礼物消费", giftCoinSpent, preview ? "+16.80%" : ""),
|
||||
coinMetric("幸运礼物流水", luckyGiftTurnover, preview ? "+12.93%" : ""),
|
||||
coinMetric("幸运礼物返奖", luckyGiftPayout, preview ? "+8.15%" : ""),
|
||||
coinMetric("幸运礼物利润", luckyGiftProfit, preview ? "+4.66%" : ""),
|
||||
coinMetric("游戏流水", gameTurnover, preview ? "+14.20%" : ""),
|
||||
coinMetric("游戏利润", gameProfit, preview ? "+18.18%" : "")
|
||||
],
|
||||
countryBreakdown,
|
||||
funnel: [
|
||||
@ -43,11 +52,11 @@ export function createDashboardModel(overview, { appCode, countryId, preview })
|
||||
giftRanking: normalizeGiftRanking(source),
|
||||
kpis: [
|
||||
{ caption: "近 7 日", delta: preview ? "+18.10%" : "", icon: CoinIcon, label: "总充值", value: formatMoneyFull(recharge) },
|
||||
{ caption: "近 7 日", delta: preview ? "+16.80%" : "", icon: UserPlusIcon, label: "新用户充值", value: formatMoneyFull(source.new_user_recharge_usd_minor) },
|
||||
{ caption: "近 7 日", delta: preview ? "+16.80%" : "", icon: UserPlusIcon, label: "新用户充值", value: formatMoneyFull(newUserRecharge) },
|
||||
{ caption: "近 7 日", delta: preview ? "+10.77%" : "", icon: ActiveUsersIcon, label: "活跃用户", value: formatNumber(activeUsers) },
|
||||
{ caption: "近 7 日", delta: preview ? "+12.92%" : "", icon: CrownIcon, label: "付费用户", value: formatNumber(paidUsers) },
|
||||
{ caption: "近 7 日", delta: preview ? "-0.80%" : "", deltaTone: "down", icon: TrendIcon, label: "ARPU", value: formatMoney(source.arpu_usd_minor) },
|
||||
{ caption: "近 7 日", delta: preview ? "+3.06%" : "", icon: StarUserIcon, label: "ARPPU", value: formatMoney(source.arppu_usd_minor) }
|
||||
{ caption: "近 7 日", delta: preview ? "-0.80%" : "", deltaTone: "down", icon: TrendIcon, label: "ARPU", value: formatMoney(arpu) },
|
||||
{ caption: "近 7 日", delta: preview ? "+3.06%" : "", icon: StarUserIcon, label: "ARPPU", value: formatMoney(arppu) }
|
||||
],
|
||||
luckyMetrics: [
|
||||
{ caption: "近 7 日 +10.62%", label: "流水", value: formatWholeMoney(source.lucky_gift_turnover) },
|
||||
@ -63,15 +72,28 @@ export function createDashboardModel(overview, { appCode, countryId, preview })
|
||||
{ caption: "近 7 日 +12.93%", label: "幸运礼物流水", value: formatWholeMoney(source.room_lucky_gift_turnover ?? source.lucky_gift_turnover) }
|
||||
],
|
||||
sideMetrics: [
|
||||
{ caption: "近 7 日 -0.80%", deltaTone: "down", label: "ARPU", value: formatMoney(source.arpu_usd_minor) },
|
||||
{ caption: "近 7 日 -0.80%", deltaTone: "down", label: "ARPU", value: formatMoney(arpu) },
|
||||
{ caption: "近 7 日 +0.18pp", label: "付费转化率", value: formatPercent(ratio(paidUsers, newUsers)) },
|
||||
{ caption: "近 7 日 +3.06%", label: "ARPPU", value: formatMoney(source.arppu_usd_minor) }
|
||||
{ caption: "近 7 日 +3.06%", label: "ARPPU", value: formatMoney(arppu) }
|
||||
],
|
||||
topCountries: countryBreakdown.slice(0, 5),
|
||||
updatedAt: formatDateTime(source.updated_at_ms || source.updatedAtMs || Date.now())
|
||||
};
|
||||
}
|
||||
|
||||
function coinMetric(label, value, delta) {
|
||||
return { caption: "近 7 日", delta, label, unit: "金币", unitIcon: CoinIcon, value: formatCoin(value) };
|
||||
}
|
||||
|
||||
function readNumber(source, ...keys) {
|
||||
for (const key of keys) {
|
||||
if (source?.[key] !== undefined && source[key] !== null) {
|
||||
return numberValue(source[key]);
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function normalizeRevenueSeries(source) {
|
||||
if (Array.isArray(source.daily_series) && source.daily_series.length) {
|
||||
return source.daily_series.map((item) => ({
|
||||
|
||||
@ -41,7 +41,7 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.metric-content > span,
|
||||
.metric-label-row,
|
||||
.mini-stat > span,
|
||||
.section-label,
|
||||
.rank-title {
|
||||
@ -49,6 +49,47 @@
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.metric-label-row {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.metric-label-row > span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.metric-unit-icon {
|
||||
display: inline-grid;
|
||||
flex: 0 0 auto;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
place-items: center;
|
||||
color: #27e4f5;
|
||||
}
|
||||
|
||||
.metric-unit-icon svg {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.metric-unit {
|
||||
flex: 0 0 auto;
|
||||
color: #7f9bb7;
|
||||
font-size: 10px;
|
||||
font-style: normal;
|
||||
font-weight: 680;
|
||||
}
|
||||
|
||||
.metric-content strong {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
@ -56,7 +97,7 @@
|
||||
color: #f6fbff;
|
||||
font-size: clamp(24px, 1.5vw, 30px);
|
||||
font-weight: 780;
|
||||
line-height: 1.1;
|
||||
line-height: 1.25;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@ -87,60 +87,457 @@
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.range-control,
|
||||
.region-control {
|
||||
display: inline-flex;
|
||||
.filter-control {
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.filter-trigger {
|
||||
display: grid;
|
||||
min-width: 150px;
|
||||
height: 44px;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
border: 1px solid rgba(64, 148, 197, 0.42);
|
||||
padding: 0 12px;
|
||||
border: 1px solid rgba(64, 148, 197, 0.48);
|
||||
border-radius: 8px;
|
||||
background: rgba(7, 29, 48, 0.86);
|
||||
background: rgba(7, 29, 48, 0.9);
|
||||
color: #d8ebff;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.range-control {
|
||||
width: 378px;
|
||||
padding: 0 16px;
|
||||
.filter-trigger:hover,
|
||||
.filter-trigger[aria-expanded="true"] {
|
||||
border-color: rgba(39, 228, 245, 0.78);
|
||||
box-shadow: 0 0 0 3px rgba(39, 228, 245, 0.08);
|
||||
}
|
||||
|
||||
.range-control input,
|
||||
.region-control select {
|
||||
.filter-icon,
|
||||
.filter-chevron {
|
||||
color: #cbd9e9;
|
||||
}
|
||||
|
||||
.filter-icon {
|
||||
display: grid;
|
||||
min-width: 20px;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.control-glyph {
|
||||
color: #27e4f5;
|
||||
font-size: 10px;
|
||||
font-weight: 820;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.filter-copy {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.filter-copy small {
|
||||
overflow: hidden;
|
||||
color: #7f9bb7;
|
||||
font-size: 10px;
|
||||
line-height: 1.1;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.filter-copy b {
|
||||
overflow: hidden;
|
||||
color: #e6f3ff;
|
||||
font-size: 13px;
|
||||
font-weight: 720;
|
||||
line-height: 1.2;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.date-range-trigger {
|
||||
display: grid;
|
||||
width: 430px;
|
||||
height: 44px;
|
||||
grid-template-columns: 24px minmax(0, 1fr) 20px;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid rgba(64, 148, 197, 0.48);
|
||||
border-radius: 8px;
|
||||
background: rgba(7, 29, 48, 0.9);
|
||||
box-shadow: none;
|
||||
color: #e6f3ff;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.date-range-trigger:hover,
|
||||
.date-range-trigger[aria-expanded="true"] {
|
||||
border-color: rgba(39, 228, 245, 0.78);
|
||||
box-shadow: 0 0 0 3px rgba(39, 228, 245, 0.08);
|
||||
}
|
||||
|
||||
.date-range-icon {
|
||||
display: grid;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
place-items: center;
|
||||
color: #cbd9e9;
|
||||
}
|
||||
|
||||
.date-range-icon svg {
|
||||
width: 19px;
|
||||
height: 19px;
|
||||
}
|
||||
|
||||
.date-range-copy {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.date-range-label {
|
||||
overflow: hidden;
|
||||
color: #7f9bb7;
|
||||
font-size: 10px;
|
||||
font-weight: 720;
|
||||
line-height: 1.05;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.date-range-values {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
grid-template-columns: minmax(0, auto) 16px minmax(0, auto);
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.date-range-values b {
|
||||
overflow: hidden;
|
||||
color: #eaf6ff;
|
||||
font-size: 15px;
|
||||
font-weight: 760;
|
||||
line-height: 1.15;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.date-range-values i {
|
||||
color: #a7bdd3;
|
||||
font-size: 13px;
|
||||
font-style: normal;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.date-range-chevron {
|
||||
color: #cbd9e9;
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.filter-popover {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
right: 0;
|
||||
z-index: 20;
|
||||
display: grid;
|
||||
width: 100%;
|
||||
min-width: 168px;
|
||||
gap: 4px;
|
||||
padding: 8px;
|
||||
border: 1px solid rgba(64, 148, 197, 0.52);
|
||||
border-radius: 8px;
|
||||
background: rgba(4, 21, 36, 0.98);
|
||||
box-shadow: 0 18px 36px rgba(0, 0, 0, 0.36), inset 0 1px 0 rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.filter-popover button {
|
||||
display: flex;
|
||||
min-height: 34px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 0 10px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: #cfe3f7;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.filter-popover button:hover,
|
||||
.filter-popover button.is-active {
|
||||
background: rgba(39, 228, 245, 0.12);
|
||||
color: #f3fbff;
|
||||
}
|
||||
|
||||
.date-time-popover {
|
||||
position: absolute;
|
||||
top: calc(100% + 10px);
|
||||
right: 0;
|
||||
z-index: 30;
|
||||
display: grid;
|
||||
width: 680px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(39, 228, 245, 0.36);
|
||||
border-radius: 10px;
|
||||
background: rgba(4, 18, 31, 0.98);
|
||||
box-shadow: 0 20px 46px rgba(0, 0, 0, 0.44), inset 0 1px 0 rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.date-time-shortcuts,
|
||||
.date-time-fields,
|
||||
.date-time-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.date-time-shortcuts {
|
||||
gap: 8px;
|
||||
padding: 10px 12px 0;
|
||||
}
|
||||
|
||||
.date-time-shortcuts button,
|
||||
.date-time-fields button,
|
||||
.calendar-toolbar button,
|
||||
.calendar-day,
|
||||
.time-column button,
|
||||
.date-time-footer button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #d8ebff;
|
||||
color: #cfe3f7;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
.range-control input {
|
||||
width: 132px;
|
||||
color-scheme: dark;
|
||||
.date-time-shortcuts button {
|
||||
height: 28px;
|
||||
padding: 0 12px;
|
||||
border-radius: 7px;
|
||||
color: #8faac5;
|
||||
font-size: 12px;
|
||||
font-weight: 720;
|
||||
}
|
||||
|
||||
.range-control input::-webkit-calendar-picker-indicator {
|
||||
display: none;
|
||||
.date-time-shortcuts button:hover,
|
||||
.date-time-shortcuts button.is-active {
|
||||
background: rgba(39, 228, 245, 0.13);
|
||||
color: #f2fbff;
|
||||
}
|
||||
|
||||
.range-control svg,
|
||||
.region-control svg {
|
||||
color: #ccd9e8;
|
||||
.date-time-fields {
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid rgba(111, 148, 183, 0.18);
|
||||
}
|
||||
|
||||
.region-control {
|
||||
width: 280px;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.region-control select {
|
||||
.date-time-fields button {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
gap: 3px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid rgba(64, 148, 197, 0.3);
|
||||
border-radius: 8px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.range-control:focus-within,
|
||||
.region-control:focus-within {
|
||||
border-color: rgba(39, 228, 245, 0.86);
|
||||
box-shadow: 0 0 0 3px rgba(39, 228, 245, 0.1);
|
||||
.date-time-fields button.is-active {
|
||||
border-color: rgba(39, 228, 245, 0.76);
|
||||
background: rgba(39, 228, 245, 0.1);
|
||||
}
|
||||
|
||||
.date-time-fields span {
|
||||
color: #7f9bb7;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.date-time-fields b {
|
||||
overflow: hidden;
|
||||
color: #eaf6ff;
|
||||
font-size: 13px;
|
||||
font-weight: 760;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.date-time-picker {
|
||||
display: grid;
|
||||
grid-template-columns: 452px 1fr;
|
||||
min-height: 348px;
|
||||
}
|
||||
|
||||
.calendar-pane {
|
||||
display: grid;
|
||||
grid-template-rows: 42px 1fr;
|
||||
border-right: 1px solid rgba(111, 148, 183, 0.2);
|
||||
}
|
||||
|
||||
.calendar-toolbar {
|
||||
display: grid;
|
||||
grid-template-columns: 34px 34px 1fr 34px 34px;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid rgba(111, 148, 183, 0.18);
|
||||
}
|
||||
|
||||
.calendar-toolbar button {
|
||||
height: 34px;
|
||||
color: #9fb4c9;
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.calendar-toolbar button:hover {
|
||||
color: #27e4f5;
|
||||
}
|
||||
|
||||
.calendar-toolbar strong {
|
||||
color: #eaf6ff;
|
||||
font-size: 20px;
|
||||
font-weight: 760;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.calendar-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||
grid-auto-rows: 40px;
|
||||
align-content: start;
|
||||
gap: 4px;
|
||||
padding: 10px 18px 14px;
|
||||
}
|
||||
|
||||
.calendar-weekday {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #8faac5;
|
||||
font-size: 16px;
|
||||
font-weight: 720;
|
||||
}
|
||||
|
||||
.calendar-day {
|
||||
display: grid;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
place-self: center;
|
||||
place-items: center;
|
||||
border-radius: 8px;
|
||||
color: #d4e6f4;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.calendar-day:hover {
|
||||
background: rgba(39, 228, 245, 0.1);
|
||||
color: #f2fbff;
|
||||
}
|
||||
|
||||
.calendar-day.is-muted {
|
||||
color: rgba(178, 194, 207, 0.38);
|
||||
}
|
||||
|
||||
.calendar-day.is-between {
|
||||
background: rgba(39, 228, 245, 0.07);
|
||||
}
|
||||
|
||||
.calendar-day.is-start,
|
||||
.calendar-day.is-end {
|
||||
background: rgba(39, 228, 245, 0.18);
|
||||
color: #ffffff;
|
||||
font-weight: 780;
|
||||
}
|
||||
|
||||
.calendar-day.is-active {
|
||||
border: 1px solid #2f7cff;
|
||||
background: rgba(47, 124, 255, 0.15);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.time-pane {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.time-column {
|
||||
display: grid;
|
||||
grid-template-rows: 34px 1fr;
|
||||
min-height: 0;
|
||||
border-right: 1px solid rgba(111, 148, 183, 0.18);
|
||||
}
|
||||
|
||||
.time-column:last-child {
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
.time-column > span {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-bottom: 1px solid rgba(111, 148, 183, 0.18);
|
||||
color: #8faac5;
|
||||
font-size: 12px;
|
||||
font-weight: 720;
|
||||
}
|
||||
|
||||
.time-column > div {
|
||||
display: grid;
|
||||
max-height: 306px;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.time-column button {
|
||||
min-height: 34px;
|
||||
color: #d4e6f4;
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.time-column button:hover,
|
||||
.time-column button.is-active {
|
||||
background: rgba(39, 228, 245, 0.12);
|
||||
color: #ffffff;
|
||||
font-weight: 760;
|
||||
}
|
||||
|
||||
.date-time-footer {
|
||||
justify-content: space-between;
|
||||
min-height: 54px;
|
||||
padding: 10px 12px;
|
||||
border-top: 1px solid rgba(111, 148, 183, 0.18);
|
||||
}
|
||||
|
||||
.date-time-footer span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: #8faac5;
|
||||
font-size: 12px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.date-time-footer button {
|
||||
flex: 0 0 auto;
|
||||
height: 34px;
|
||||
min-width: 66px;
|
||||
padding: 0 18px;
|
||||
border: 1px solid rgba(39, 228, 245, 0.52);
|
||||
border-radius: 8px;
|
||||
background: rgba(39, 228, 245, 0.12);
|
||||
color: #f2fbff;
|
||||
font-size: 13px;
|
||||
font-weight: 760;
|
||||
}
|
||||
|
||||
.date-time-footer button:hover {
|
||||
border-color: #27e4f5;
|
||||
background: rgba(39, 228, 245, 0.18);
|
||||
}
|
||||
|
||||
.live-dot {
|
||||
@ -192,7 +589,7 @@
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.business-metric-grid .metric-content > span {
|
||||
.business-metric-grid .metric-label-row {
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
}
|
||||
@ -255,18 +652,3 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.databi-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 34px;
|
||||
margin-top: 16px;
|
||||
color: #7f91a8;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.databi-footer strong {
|
||||
color: #f7a33a;
|
||||
font-weight: 760;
|
||||
}
|
||||
|
||||
@ -2,7 +2,7 @@ export function lastDaysRange(days) {
|
||||
const end = new Date();
|
||||
const start = new Date(end);
|
||||
start.setDate(end.getDate() - days + 1);
|
||||
return { end: dateInputValue(end), start: dateInputValue(start) };
|
||||
return { end: dateInputValue(end), endTime: "23:59:59", start: dateInputValue(start), startTime: "00:00:00" };
|
||||
}
|
||||
|
||||
export function startOfDay(value) {
|
||||
@ -13,11 +13,28 @@ export function endOfDay(value) {
|
||||
return value ? new Date(`${value}T23:59:59`).getTime() : "";
|
||||
}
|
||||
|
||||
export function rangeEndMs(range) {
|
||||
return dateTimeMs(range?.end, range?.endTime || "23:59:59");
|
||||
}
|
||||
|
||||
export function rangeStartMs(range) {
|
||||
return dateTimeMs(range?.start, range?.startTime || "00:00:00");
|
||||
}
|
||||
|
||||
export function formatDateTime(value) {
|
||||
const date = new Date(Number(value) || Date.now());
|
||||
return `${dateInputValue(date)} ${pad2(date.getHours())}:${pad2(date.getMinutes())}`;
|
||||
}
|
||||
|
||||
function dateTimeMs(dateValue, timeValue) {
|
||||
return dateValue ? new Date(`${dateValue}T${normalizeTime(timeValue)}`).getTime() : "";
|
||||
}
|
||||
|
||||
function normalizeTime(value) {
|
||||
const parts = String(value || "00:00:00").split(":");
|
||||
return `${pad2(parts[0] || 0)}:${pad2(parts[1] || 0)}:${pad2(parts[2] || 0)}`;
|
||||
}
|
||||
|
||||
function dateInputValue(date) {
|
||||
return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;
|
||||
}
|
||||
|
||||
@ -2,6 +2,9 @@ import { profileCardMetadataJSON } from "@/features/resources/profileCardLayout.
|
||||
|
||||
export const resourceBatchUploadSize = 10;
|
||||
|
||||
const resourceCodeMaxLength = 96;
|
||||
const resourceCodeSuffixLength = 3;
|
||||
const randomResourceCodeChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
||||
const resourceTypeAliases = new Map([
|
||||
["头像框", "avatar_frame"],
|
||||
["坐骑", "vehicle"],
|
||||
@ -116,12 +119,19 @@ export function resourcePlanToPayload(item) {
|
||||
};
|
||||
}
|
||||
|
||||
export async function translateResourceCodes(resources) {
|
||||
export async function translateResourceCodes(resources, options = {}) {
|
||||
const translated = [];
|
||||
for (let index = 0; index < resources.length; index += 1) {
|
||||
const resourceCode = await resourceCodeFor(resources[index], index);
|
||||
translated.push({
|
||||
...resources[index],
|
||||
resourceCode: await resourceCodeFor(resources[index], index),
|
||||
resourceCode: appendRandomResourceCodeSuffix(resourceCode, index),
|
||||
});
|
||||
options.onProgress?.({
|
||||
completed: index + 1,
|
||||
index,
|
||||
resource: translated[index],
|
||||
total: resources.length,
|
||||
});
|
||||
}
|
||||
return translated;
|
||||
@ -296,13 +306,26 @@ function slugifyResourceCode(value) {
|
||||
.replace(/[^a-z0-9]+/gi, "_")
|
||||
.replace(/^_+|_+$/g, "")
|
||||
.toLowerCase()
|
||||
.slice(0, 96);
|
||||
.slice(0, resourceCodeMaxLength);
|
||||
}
|
||||
|
||||
function fallbackResourceCode(index) {
|
||||
return `resource_${String(index + 1).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function appendRandomResourceCodeSuffix(value, index) {
|
||||
const suffix = `_${randomResourceCodeChar()}${randomResourceCodeChar()}`;
|
||||
const maxBaseLength = resourceCodeMaxLength - resourceCodeSuffixLength;
|
||||
const base = String(value || fallbackResourceCode(index))
|
||||
.slice(0, maxBaseLength)
|
||||
.replace(/_+$/g, "");
|
||||
return `${base || fallbackResourceCode(index)}${suffix}`;
|
||||
}
|
||||
|
||||
function randomResourceCodeChar() {
|
||||
return randomResourceCodeChars[Math.floor(Math.random() * randomResourceCodeChars.length)];
|
||||
}
|
||||
|
||||
function hasChinese(value) {
|
||||
return /[\u3400-\u9fff]/.test(String(value || ""));
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ import { afterEach, expect, test, vi } from "vitest";
|
||||
import { parseResourceFolderFiles, resourcePlanToPayload, translateResourceCodes } from "./batchUpload.js";
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
@ -88,6 +89,11 @@ test("silently ignores invalid and unpaired material", () => {
|
||||
});
|
||||
|
||||
test("translates chinese names to english resource codes with API fallback", async () => {
|
||||
vi.spyOn(Math, "random")
|
||||
.mockReturnValueOnce(0)
|
||||
.mockReturnValueOnce(51.5 / 52)
|
||||
.mockReturnValueOnce(26 / 52)
|
||||
.mockReturnValueOnce(27 / 52);
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (url) => {
|
||||
@ -111,10 +117,11 @@ test("translates chinese names to english resource codes with API fallback", asy
|
||||
},
|
||||
]);
|
||||
|
||||
expect(translated.map((item) => item.resourceCode)).toEqual(["starlight", "c11"]);
|
||||
expect(translated.map((item) => item.resourceCode)).toEqual(["starlight_aZ", "c11_AB"]);
|
||||
});
|
||||
|
||||
test("uses local dictionary when public translation APIs fail", async () => {
|
||||
vi.spyOn(Math, "random").mockReturnValue(0);
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => new Response("{}", { status: 500 })),
|
||||
@ -128,7 +135,36 @@ test("uses local dictionary when public translation APIs fail", async () => {
|
||||
},
|
||||
]);
|
||||
|
||||
expect(translated[0].resourceCode).toBe("guardian");
|
||||
expect(translated[0].resourceCode).toBe("guardian_aa");
|
||||
});
|
||||
|
||||
test("reports translate progress after suffix is added", async () => {
|
||||
vi.spyOn(Math, "random").mockReturnValue(0);
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => new Response("{}", { status: 500 })),
|
||||
);
|
||||
const onProgress = vi.fn();
|
||||
|
||||
await translateResourceCodes(
|
||||
[
|
||||
{
|
||||
name: "守护",
|
||||
resourceCode: "守护",
|
||||
resourceType: "badge",
|
||||
},
|
||||
],
|
||||
{ onProgress },
|
||||
);
|
||||
|
||||
expect(onProgress).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
completed: 1,
|
||||
index: 0,
|
||||
resource: expect.objectContaining({ resourceCode: "guardian_aa" }),
|
||||
total: 1,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
function file(name) {
|
||||
|
||||
@ -4,6 +4,7 @@ import FileUploadOutlined from "@mui/icons-material/FileUploadOutlined";
|
||||
import HelpOutlineOutlined from "@mui/icons-material/HelpOutlineOutlined";
|
||||
import Inventory2Outlined from "@mui/icons-material/Inventory2Outlined";
|
||||
import CircularProgress from "@mui/material/CircularProgress";
|
||||
import LinearProgress from "@mui/material/LinearProgress";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
|
||||
import TextField from "@mui/material/TextField";
|
||||
@ -59,6 +60,7 @@ import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||
import styles from "@/features/resources/resources.module.css";
|
||||
|
||||
const resourceTypeOptions = resourceTypeFilters.filter(([value]) => value && value !== "emoji_pack");
|
||||
const emptyBatchProgress = { label: "", running: false, value: 0 };
|
||||
|
||||
const baseColumns = [
|
||||
{
|
||||
@ -109,7 +111,7 @@ export function ResourceListPage() {
|
||||
const folderInputRef = useRef(null);
|
||||
const [batchOpen, setBatchOpen] = useState(false);
|
||||
const [batchPlan, setBatchPlan] = useState({ errors: [], resources: [] });
|
||||
const [batchProgress, setBatchProgress] = useState({ label: "", running: false });
|
||||
const [batchProgress, setBatchProgress] = useState(emptyBatchProgress);
|
||||
const items = page.data.items || [];
|
||||
const total = page.data.total || 0;
|
||||
const createDisabled = !page.abilities.canCreate || !page.abilities.canUpload;
|
||||
@ -154,7 +156,7 @@ export function ResourceListPage() {
|
||||
|
||||
const openBatchDialog = () => {
|
||||
setBatchPlan({ errors: [], resources: [] });
|
||||
setBatchProgress({ label: "", running: false });
|
||||
setBatchProgress(emptyBatchProgress);
|
||||
setBatchOpen(true);
|
||||
};
|
||||
|
||||
@ -168,7 +170,7 @@ export function ResourceListPage() {
|
||||
const plan = parseResourceFolderFiles(event.target.files);
|
||||
event.target.value = "";
|
||||
setBatchPlan(plan);
|
||||
setBatchProgress({ label: "", running: false });
|
||||
setBatchProgress(emptyBatchProgress);
|
||||
};
|
||||
|
||||
const submitBatchUpload = async (event) => {
|
||||
@ -182,8 +184,18 @@ export function ResourceListPage() {
|
||||
{ file: resource.coverFile, resourceIndex, role: "cover" },
|
||||
{ file: resource.animationFile, resourceIndex, role: "animation" },
|
||||
]);
|
||||
const profileCardResources = resources.filter((resource) => resource.resourceType === "profile_card");
|
||||
const totalProgressSteps = Math.max(
|
||||
profileCardResources.length + uploadEntries.length + resources.length + resources.length,
|
||||
1,
|
||||
);
|
||||
let completedProgressSteps = 0;
|
||||
const updateBatchProgress = (label, completedSteps = completedProgressSteps) => {
|
||||
const value = Math.min(100, Math.round((completedSteps / totalProgressSteps) * 100));
|
||||
setBatchProgress({ label, running: true, value });
|
||||
};
|
||||
|
||||
setBatchProgress({ label: "解析资料卡内容高度", running: true });
|
||||
updateBatchProgress(profileCardResources.length ? "解析资料卡内容高度" : "准备上传素材");
|
||||
try {
|
||||
for (const resource of resources) {
|
||||
if (resource.resourceType !== "profile_card") {
|
||||
@ -195,28 +207,42 @@ export function ResourceListPage() {
|
||||
} catch {
|
||||
showToast(`${resource.name} 内容高度解析失败`, "error");
|
||||
}
|
||||
completedProgressSteps += 1;
|
||||
updateBatchProgress(
|
||||
`解析资料卡内容高度 ${completedProgressSteps}/${profileCardResources.length}`,
|
||||
completedProgressSteps,
|
||||
);
|
||||
}
|
||||
|
||||
setBatchProgress({ label: "上传素材 0/" + uploadEntries.length, running: true });
|
||||
updateBatchProgress(`上传素材 0/${uploadEntries.length}`);
|
||||
for (let index = 0; index < uploadEntries.length; index += resourceBatchUploadSize) {
|
||||
const chunk = uploadEntries.slice(index, index + resourceBatchUploadSize);
|
||||
setBatchProgress({
|
||||
label: `上传素材 ${index + 1}-${index + chunk.length}/${uploadEntries.length}`,
|
||||
running: true,
|
||||
});
|
||||
updateBatchProgress(`上传素材 ${index + 1}-${index + chunk.length}/${uploadEntries.length}`);
|
||||
const results = await uploadFilesBatch(chunk.map((entry) => entry.file));
|
||||
results.forEach((result, resultIndex) => {
|
||||
const entry = chunk[resultIndex];
|
||||
resources[entry.resourceIndex][entry.role === "cover" ? "coverUrl" : "animationUrl"] = result.url;
|
||||
});
|
||||
completedProgressSteps += chunk.length;
|
||||
updateBatchProgress(
|
||||
`上传素材 ${Math.min(index + chunk.length, uploadEntries.length)}/${uploadEntries.length}`,
|
||||
);
|
||||
}
|
||||
|
||||
setBatchProgress({ label: "翻译资源编码", running: true });
|
||||
const translatedResources = await translateResourceCodes(resources);
|
||||
const translationStartSteps = completedProgressSteps;
|
||||
updateBatchProgress(`翻译资源编码 0/${resources.length}`);
|
||||
const translatedResources = await translateResourceCodes(resources, {
|
||||
onProgress: ({ completed, total }) => {
|
||||
completedProgressSteps = translationStartSteps + completed;
|
||||
updateBatchProgress(`翻译资源编码 ${completed}/${total}`, completedProgressSteps);
|
||||
},
|
||||
});
|
||||
|
||||
for (let index = 0; index < translatedResources.length; index += 1) {
|
||||
setBatchProgress({ label: `创建资源 ${index + 1}/${translatedResources.length}`, running: true });
|
||||
updateBatchProgress(`创建资源 ${index + 1}/${translatedResources.length}`);
|
||||
await createResource(resourcePlanToPayload(translatedResources[index]));
|
||||
completedProgressSteps += 1;
|
||||
updateBatchProgress(`创建资源 ${index + 1}/${translatedResources.length}`, completedProgressSteps);
|
||||
}
|
||||
|
||||
showToast(`批量上传完成,共创建 ${translatedResources.length} 个资源`, "success");
|
||||
@ -226,7 +252,7 @@ export function ResourceListPage() {
|
||||
} catch (err) {
|
||||
showToast(err.message || "批量上传失败", "error");
|
||||
} finally {
|
||||
setBatchProgress({ label: "", running: false });
|
||||
setBatchProgress(emptyBatchProgress);
|
||||
}
|
||||
};
|
||||
|
||||
@ -345,8 +371,19 @@ function ResourceBatchUploadDialog({ disabled, fileInputRef, onClose, onFileChan
|
||||
/>
|
||||
{progress.running ? (
|
||||
<div className={styles.batchProgress}>
|
||||
<CircularProgress color="inherit" size={16} />
|
||||
{progress.label}
|
||||
<div className={styles.batchProgressHeader}>
|
||||
<span className={styles.batchProgressLabel}>
|
||||
<CircularProgress color="inherit" size={16} />
|
||||
{progress.label}
|
||||
</span>
|
||||
<span className={styles.batchProgressPercent}>{Math.round(progress.value || 0)}%</span>
|
||||
</div>
|
||||
<LinearProgress
|
||||
aria-label="批量上传进度"
|
||||
className={styles.batchProgressBar}
|
||||
value={progress.value || 0}
|
||||
variant="determinate"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<ResourceBatchPreview resources={plan.resources} />
|
||||
@ -694,7 +731,11 @@ function badgeKindFromMetadata(metadataJson) {
|
||||
}
|
||||
try {
|
||||
const metadata = JSON.parse(metadataJson);
|
||||
return String(metadata?.badge_kind || "").trim().toLowerCase() === "level" ? "level" : "normal";
|
||||
return String(metadata?.badge_kind || "")
|
||||
.trim()
|
||||
.toLowerCase() === "level"
|
||||
? "level"
|
||||
: "normal";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
|
||||
@ -261,10 +261,7 @@
|
||||
|
||||
.batchProgress,
|
||||
.batchEmpty {
|
||||
display: flex;
|
||||
min-height: 44px;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: 0 var(--space-3);
|
||||
border: 1px solid var(--border-muted);
|
||||
border-radius: var(--radius-sm);
|
||||
@ -272,6 +269,49 @@
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.batchEmpty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.batchProgress {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
padding-top: var(--space-2);
|
||||
padding-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.batchProgressHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.batchProgressLabel {
|
||||
display: inline-flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.batchProgressPercent {
|
||||
color: var(--text-primary);
|
||||
font-weight: 750;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.batchProgressBar {
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
background-color: var(--border-muted);
|
||||
}
|
||||
|
||||
.batchProgressBar :global(.MuiLinearProgress-bar) {
|
||||
border-radius: 999px;
|
||||
background-color: var(--primary);
|
||||
}
|
||||
|
||||
.batchTable {
|
||||
display: grid;
|
||||
overflow: hidden;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user