批量上传和databi
This commit is contained in:
parent
d4eaf20c8e
commit
e3ab3cba1e
@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
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 { CountryPerformanceTable } from "./components/CountryPerformanceTable.jsx";
|
||||||
import { DatabiHeader } from "./components/DatabiHeader.jsx";
|
import { DatabiHeader } from "./components/DatabiHeader.jsx";
|
||||||
import { FunnelPanel } from "./components/FunnelPanel.jsx";
|
import { FunnelPanel } from "./components/FunnelPanel.jsx";
|
||||||
@ -8,17 +8,27 @@ import { MetricCard } from "./components/MetricCard.jsx";
|
|||||||
import { RevenueTrendPanel } from "./components/RevenueTrendPanel.jsx";
|
import { RevenueTrendPanel } from "./components/RevenueTrendPanel.jsx";
|
||||||
import { createDashboardModel } from "./data/createDashboardModel.js";
|
import { createDashboardModel } from "./data/createDashboardModel.js";
|
||||||
import { sampleOverview } from "./data/sampleOverview.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() {
|
export function DatabiApp() {
|
||||||
const initialRange = useMemo(() => lastDaysRange(7), []);
|
const initialRange = useMemo(() => lastDaysRange(7), []);
|
||||||
const [range, setRange] = useState(initialRange);
|
const [range, setRange] = useState(initialRange);
|
||||||
const [countryId, setCountryId] = useState(0);
|
const [countryId, setCountryId] = useState(0);
|
||||||
|
const [regionId, setRegionId] = useState("all");
|
||||||
|
const [appCode, setAppCode] = useState(() => getCurrentAppCode());
|
||||||
const [overview, setOverview] = useState(sampleOverview);
|
const [overview, setOverview] = useState(sampleOverview);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [preview, setPreview] = useState(true);
|
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 () => {
|
const loadOverview = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@ -27,8 +37,8 @@ export function DatabiApp() {
|
|||||||
const data = await fetchStatisticsOverview({
|
const data = await fetchStatisticsOverview({
|
||||||
appCode,
|
appCode,
|
||||||
countryId,
|
countryId,
|
||||||
endMs: endOfDay(range.end),
|
endMs: rangeEndMs(range),
|
||||||
startMs: startOfDay(range.start)
|
startMs: rangeStartMs(range)
|
||||||
});
|
});
|
||||||
setOverview(data || sampleOverview);
|
setOverview(data || sampleOverview);
|
||||||
setPreview(false);
|
setPreview(false);
|
||||||
@ -39,7 +49,7 @@ export function DatabiApp() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [appCode, countryId, range.end, range.start]);
|
}, [appCode, countryId, range]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadOverview();
|
void loadOverview();
|
||||||
@ -57,11 +67,14 @@ export function DatabiApp() {
|
|||||||
countryId={countryId}
|
countryId={countryId}
|
||||||
error={error}
|
error={error}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
|
onAppChange={handleAppChange}
|
||||||
onCountryChange={setCountryId}
|
onCountryChange={setCountryId}
|
||||||
onRefresh={loadOverview}
|
onRefresh={loadOverview}
|
||||||
onRangeChange={setRange}
|
onRangeChange={setRange}
|
||||||
|
onRegionChange={handleRegionChange}
|
||||||
preview={preview}
|
preview={preview}
|
||||||
range={range}
|
range={range}
|
||||||
|
regionId={regionId}
|
||||||
updatedAt={model.updatedAt}
|
updatedAt={model.updatedAt}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@ -85,10 +98,6 @@ export function DatabiApp() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<CountryPerformanceTable gameRanking={model.gameRanking} giftRanking={model.giftRanking} items={model.countryBreakdown} />
|
<CountryPerformanceTable gameRanking={model.gameRanking} giftRanking={model.giftRanking} items={model.countryBreakdown} />
|
||||||
<footer className="databi-footer">
|
|
||||||
<span>所有数据按 UTC 展示,实时更新。</span>
|
|
||||||
<strong>数据最多可能延迟 60 秒。</strong>
|
|
||||||
</footer>
|
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,6 +7,12 @@ export function getCurrentAppCode() {
|
|||||||
return normalizeAppCode(window.localStorage.getItem(APP_CODE_KEY) || "lalu");
|
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 }) {
|
export async function fetchStatisticsOverview({ appCode, countryId, endMs, startMs }) {
|
||||||
const query = new URLSearchParams();
|
const query = new URLSearchParams();
|
||||||
query.set("app_code", normalizeAppCode(appCode) || "lalu");
|
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 CalendarMonthOutlined from "@mui/icons-material/CalendarMonthOutlined";
|
||||||
|
import KeyboardArrowDownOutlined from "@mui/icons-material/KeyboardArrowDownOutlined";
|
||||||
import PublicOutlined from "@mui/icons-material/PublicOutlined";
|
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 (
|
return (
|
||||||
<header className="databi-header">
|
<header className="databi-header" ref={headerRef}>
|
||||||
<div className="brand-lockup">
|
<div className="brand-lockup">
|
||||||
<div className="brand-emblem" aria-hidden="true" />
|
<div className="brand-emblem" aria-hidden="true" />
|
||||||
<div>
|
<div>
|
||||||
<h1>全球运营指挥中心</h1>
|
<h1>App Data</h1>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="header-controls">
|
<div className="header-controls">
|
||||||
<div className="range-control">
|
<FilterMenu
|
||||||
<input aria-label="开始日期" type="text" value={range.start} onChange={(event) => onRangeChange({ ...range, start: event.target.value })} />
|
icon={<span className="control-glyph">APP</span>}
|
||||||
<span>~</span>
|
isOpen={openControl === "app"}
|
||||||
<input aria-label="结束日期" type="text" value={range.end} onChange={(event) => onRangeChange({ ...range, end: event.target.value })} />
|
label="App"
|
||||||
<CalendarMonthOutlined fontSize="small" />
|
onSelect={(value) => selectItem(onAppChange, value)}
|
||||||
</div>
|
onToggle={() => toggleControl("app")}
|
||||||
<label className="region-control">
|
options={appOptions.map((item) => ({ label: item.label, value: item.code }))}
|
||||||
<PublicOutlined fontSize="small" />
|
value={appCode}
|
||||||
<select aria-label="国家区域" value={countryId} onChange={(event) => onCountryChange(Number(event.target.value))}>
|
valueLabel={appLabel}
|
||||||
{countryOptions.map((item) => (
|
/>
|
||||||
<option key={item.id} value={item.id}>
|
<RangeMenu
|
||||||
{item.label}
|
activeValue={activeRange?.value}
|
||||||
</option>
|
isOpen={openControl === "range"}
|
||||||
))}
|
onSelect={(nextRange) => selectItem(onRangeChange, nextRange)}
|
||||||
</select>
|
onToggle={() => toggleControl("range")}
|
||||||
</label>
|
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>
|
<span className="live-dot">实时</span>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</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 }) {
|
export function MetricCard({ item }) {
|
||||||
const Icon = item.icon;
|
const Icon = item.icon;
|
||||||
|
const UnitIcon = item.unitIcon;
|
||||||
return (
|
return (
|
||||||
<article className={["metric-card", Icon ? "" : "metric-card--no-icon"].filter(Boolean).join(" ")}>
|
<article className={["metric-card", Icon ? "" : "metric-card--no-icon"].filter(Boolean).join(" ")}>
|
||||||
{Icon ? <div className="metric-icon"><Icon /></div> : null}
|
{Icon ? <div className="metric-icon"><Icon /></div> : null}
|
||||||
<div className="metric-content">
|
<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>
|
<strong>{item.value}</strong>
|
||||||
<div className={["metric-delta", item.deltaTone === "down" ? "metric-delta--down" : ""].join(" ")}>
|
<div className={["metric-delta", item.deltaTone === "down" ? "metric-delta--down" : ""].join(" ")}>
|
||||||
<span>{item.caption}</span>
|
<span>{item.caption}</span>
|
||||||
|
|||||||
@ -1,8 +1,20 @@
|
|||||||
export const countryOptions = [
|
export const appOptions = [
|
||||||
{ id: 0, label: "全部区域" },
|
{ code: "lalu", label: "Lalu" },
|
||||||
{ id: 101, label: "印度尼西亚" },
|
{ code: "mifa", label: "Mifa" },
|
||||||
{ id: 102, label: "印度" },
|
{ code: "mifapay", label: "MifaPay" }
|
||||||
{ id: 103, label: "美国" },
|
];
|
||||||
{ id: 104, label: "巴西" },
|
|
||||||
{ id: 105, label: "土耳其" }
|
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 { CoinIcon, ActiveUsersIcon, CrownIcon, StarUserIcon, TrendIcon, UserPlusIcon } from "../components/MetricIcons.jsx";
|
||||||
import { countryFlag, resolveCountryMeta } from "./countryMeta.js";
|
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 { numberValue, ratio } from "../utils/number.js";
|
||||||
import { formatDateTime } from "../utils/time.js";
|
import { formatDateTime } from "../utils/time.js";
|
||||||
import { sampleOverview } from "./sampleOverview.js";
|
import { sampleOverview } from "./sampleOverview.js";
|
||||||
|
|
||||||
export function createDashboardModel(overview, { appCode, countryId, preview }) {
|
export function createDashboardModel(overview, { appCode, countryId, preview }) {
|
||||||
const source = overview || sampleOverview;
|
const source = overview || sampleOverview;
|
||||||
const recharge = numberValue(source.recharge_usd_minor);
|
const recharge = readNumber(source, "recharge_usd_minor", "rechargeUsdMinor", "total_recharge_usd_minor", "totalRechargeUsdMinor");
|
||||||
const activeUsers = numberValue(source.active_users);
|
const newUserRecharge = readNumber(source, "new_user_recharge_usd_minor", "newUserRechargeUsdMinor");
|
||||||
const paidUsers = numberValue(source.paid_users);
|
const activeUsers = readNumber(source, "active_users", "activeUsers");
|
||||||
const newUsers = numberValue(source.new_users);
|
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 countryBreakdown = normalizeCountries(source, countryId);
|
||||||
const revenueSeries = normalizeRevenueSeries(source);
|
const revenueSeries = normalizeRevenueSeries(source);
|
||||||
const payoutDistribution = normalizeDistribution(source);
|
const payoutDistribution = normalizeDistribution(source);
|
||||||
@ -19,12 +28,12 @@ export function createDashboardModel(overview, { appCode, countryId, preview })
|
|||||||
return {
|
return {
|
||||||
appCode,
|
appCode,
|
||||||
businessKpis: [
|
businessKpis: [
|
||||||
{ caption: "近 7 日", delta: preview ? "+16.80%" : "", label: "礼物消费", value: formatWholeMoney(source.gift_coin_spent) },
|
coinMetric("礼物消费", giftCoinSpent, preview ? "+16.80%" : ""),
|
||||||
{ caption: "近 7 日", delta: preview ? "+12.93%" : "", label: "幸运礼物流水", value: formatWholeMoney(source.room_lucky_gift_turnover ?? source.lucky_gift_turnover) },
|
coinMetric("幸运礼物流水", luckyGiftTurnover, preview ? "+12.93%" : ""),
|
||||||
{ caption: "近 7 日", delta: preview ? "+8.15%" : "", label: "幸运礼物返奖", value: formatWholeMoney(source.lucky_gift_payout) },
|
coinMetric("幸运礼物返奖", luckyGiftPayout, preview ? "+8.15%" : ""),
|
||||||
{ caption: "近 7 日", delta: preview ? "+4.66%" : "", label: "幸运礼物利润", value: formatWholeMoney(source.lucky_gift_profit) },
|
coinMetric("幸运礼物利润", luckyGiftProfit, preview ? "+4.66%" : ""),
|
||||||
{ caption: "近 7 日", delta: preview ? "+14.20%" : "", label: "游戏流水", value: formatWholeMoney(source.game_turnover) },
|
coinMetric("游戏流水", gameTurnover, preview ? "+14.20%" : ""),
|
||||||
{ caption: "近 7 日", delta: preview ? "+18.18%" : "", label: "游戏利润", value: formatWholeMoney(source.game_profit) }
|
coinMetric("游戏利润", gameProfit, preview ? "+18.18%" : "")
|
||||||
],
|
],
|
||||||
countryBreakdown,
|
countryBreakdown,
|
||||||
funnel: [
|
funnel: [
|
||||||
@ -43,11 +52,11 @@ export function createDashboardModel(overview, { appCode, countryId, preview })
|
|||||||
giftRanking: normalizeGiftRanking(source),
|
giftRanking: normalizeGiftRanking(source),
|
||||||
kpis: [
|
kpis: [
|
||||||
{ caption: "近 7 日", delta: preview ? "+18.10%" : "", icon: CoinIcon, label: "总充值", value: formatMoneyFull(recharge) },
|
{ 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 ? "+10.77%" : "", icon: ActiveUsersIcon, label: "活跃用户", value: formatNumber(activeUsers) },
|
||||||
{ caption: "近 7 日", delta: preview ? "+12.92%" : "", icon: CrownIcon, label: "付费用户", value: formatNumber(paidUsers) },
|
{ 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 ? "-0.80%" : "", deltaTone: "down", icon: TrendIcon, label: "ARPU", value: formatMoney(arpu) },
|
||||||
{ caption: "近 7 日", delta: preview ? "+3.06%" : "", icon: StarUserIcon, label: "ARPPU", value: formatMoney(source.arppu_usd_minor) }
|
{ caption: "近 7 日", delta: preview ? "+3.06%" : "", icon: StarUserIcon, label: "ARPPU", value: formatMoney(arppu) }
|
||||||
],
|
],
|
||||||
luckyMetrics: [
|
luckyMetrics: [
|
||||||
{ caption: "近 7 日 +10.62%", label: "流水", value: formatWholeMoney(source.lucky_gift_turnover) },
|
{ 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) }
|
{ caption: "近 7 日 +12.93%", label: "幸运礼物流水", value: formatWholeMoney(source.room_lucky_gift_turnover ?? source.lucky_gift_turnover) }
|
||||||
],
|
],
|
||||||
sideMetrics: [
|
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 日 +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),
|
topCountries: countryBreakdown.slice(0, 5),
|
||||||
updatedAt: formatDateTime(source.updated_at_ms || source.updatedAtMs || Date.now())
|
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) {
|
function normalizeRevenueSeries(source) {
|
||||||
if (Array.isArray(source.daily_series) && source.daily_series.length) {
|
if (Array.isArray(source.daily_series) && source.daily_series.length) {
|
||||||
return source.daily_series.map((item) => ({
|
return source.daily_series.map((item) => ({
|
||||||
|
|||||||
@ -41,7 +41,7 @@
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.metric-content > span,
|
.metric-label-row,
|
||||||
.mini-stat > span,
|
.mini-stat > span,
|
||||||
.section-label,
|
.section-label,
|
||||||
.rank-title {
|
.rank-title {
|
||||||
@ -49,6 +49,47 @@
|
|||||||
font-size: 12px;
|
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 {
|
.metric-content strong {
|
||||||
display: block;
|
display: block;
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
@ -56,7 +97,7 @@
|
|||||||
color: #f6fbff;
|
color: #f6fbff;
|
||||||
font-size: clamp(24px, 1.5vw, 30px);
|
font-size: clamp(24px, 1.5vw, 30px);
|
||||||
font-weight: 780;
|
font-weight: 780;
|
||||||
line-height: 1.1;
|
line-height: 1.25;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -87,60 +87,457 @@
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.range-control,
|
.filter-control {
|
||||||
.region-control {
|
position: relative;
|
||||||
display: inline-flex;
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-trigger {
|
||||||
|
display: grid;
|
||||||
|
min-width: 150px;
|
||||||
height: 44px;
|
height: 44px;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
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;
|
border-radius: 8px;
|
||||||
background: rgba(7, 29, 48, 0.86);
|
background: rgba(7, 29, 48, 0.9);
|
||||||
color: #d8ebff;
|
color: #d8ebff;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.range-control {
|
.filter-trigger:hover,
|
||||||
width: 378px;
|
.filter-trigger[aria-expanded="true"] {
|
||||||
padding: 0 16px;
|
border-color: rgba(39, 228, 245, 0.78);
|
||||||
|
box-shadow: 0 0 0 3px rgba(39, 228, 245, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
.range-control input,
|
.filter-icon,
|
||||||
.region-control select {
|
.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;
|
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;
|
border: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: #d8ebff;
|
color: #cfe3f7;
|
||||||
|
cursor: pointer;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
outline: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.range-control input {
|
.date-time-shortcuts button {
|
||||||
width: 132px;
|
height: 28px;
|
||||||
color-scheme: dark;
|
padding: 0 12px;
|
||||||
|
border-radius: 7px;
|
||||||
|
color: #8faac5;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 720;
|
||||||
}
|
}
|
||||||
|
|
||||||
.range-control input::-webkit-calendar-picker-indicator {
|
.date-time-shortcuts button:hover,
|
||||||
display: none;
|
.date-time-shortcuts button.is-active {
|
||||||
|
background: rgba(39, 228, 245, 0.13);
|
||||||
|
color: #f2fbff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.range-control svg,
|
.date-time-fields {
|
||||||
.region-control svg {
|
gap: 10px;
|
||||||
color: #ccd9e8;
|
padding: 10px 12px;
|
||||||
|
border-bottom: 1px solid rgba(111, 148, 183, 0.18);
|
||||||
}
|
}
|
||||||
|
|
||||||
.region-control {
|
.date-time-fields button {
|
||||||
width: 280px;
|
display: grid;
|
||||||
padding: 0 16px;
|
min-width: 0;
|
||||||
}
|
|
||||||
|
|
||||||
.region-control select {
|
|
||||||
flex: 1;
|
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,
|
.date-time-fields button.is-active {
|
||||||
.region-control:focus-within {
|
border-color: rgba(39, 228, 245, 0.76);
|
||||||
border-color: rgba(39, 228, 245, 0.86);
|
background: rgba(39, 228, 245, 0.1);
|
||||||
box-shadow: 0 0 0 3px 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 {
|
.live-dot {
|
||||||
@ -192,7 +589,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.business-metric-grid .metric-content > span {
|
.business-metric-grid .metric-label-row {
|
||||||
grid-column: 1;
|
grid-column: 1;
|
||||||
grid-row: 1;
|
grid-row: 1;
|
||||||
}
|
}
|
||||||
@ -255,18 +652,3 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
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 end = new Date();
|
||||||
const start = new Date(end);
|
const start = new Date(end);
|
||||||
start.setDate(end.getDate() - days + 1);
|
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) {
|
export function startOfDay(value) {
|
||||||
@ -13,11 +13,28 @@ export function endOfDay(value) {
|
|||||||
return value ? new Date(`${value}T23:59:59`).getTime() : "";
|
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) {
|
export function formatDateTime(value) {
|
||||||
const date = new Date(Number(value) || Date.now());
|
const date = new Date(Number(value) || Date.now());
|
||||||
return `${dateInputValue(date)} ${pad2(date.getHours())}:${pad2(date.getMinutes())}`;
|
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) {
|
function dateInputValue(date) {
|
||||||
return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;
|
return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,9 @@ import { profileCardMetadataJSON } from "@/features/resources/profileCardLayout.
|
|||||||
|
|
||||||
export const resourceBatchUploadSize = 10;
|
export const resourceBatchUploadSize = 10;
|
||||||
|
|
||||||
|
const resourceCodeMaxLength = 96;
|
||||||
|
const resourceCodeSuffixLength = 3;
|
||||||
|
const randomResourceCodeChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
||||||
const resourceTypeAliases = new Map([
|
const resourceTypeAliases = new Map([
|
||||||
["头像框", "avatar_frame"],
|
["头像框", "avatar_frame"],
|
||||||
["坐骑", "vehicle"],
|
["坐骑", "vehicle"],
|
||||||
@ -116,12 +119,19 @@ export function resourcePlanToPayload(item) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function translateResourceCodes(resources) {
|
export async function translateResourceCodes(resources, options = {}) {
|
||||||
const translated = [];
|
const translated = [];
|
||||||
for (let index = 0; index < resources.length; index += 1) {
|
for (let index = 0; index < resources.length; index += 1) {
|
||||||
|
const resourceCode = await resourceCodeFor(resources[index], index);
|
||||||
translated.push({
|
translated.push({
|
||||||
...resources[index],
|
...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;
|
return translated;
|
||||||
@ -296,13 +306,26 @@ function slugifyResourceCode(value) {
|
|||||||
.replace(/[^a-z0-9]+/gi, "_")
|
.replace(/[^a-z0-9]+/gi, "_")
|
||||||
.replace(/^_+|_+$/g, "")
|
.replace(/^_+|_+$/g, "")
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.slice(0, 96);
|
.slice(0, resourceCodeMaxLength);
|
||||||
}
|
}
|
||||||
|
|
||||||
function fallbackResourceCode(index) {
|
function fallbackResourceCode(index) {
|
||||||
return `resource_${String(index + 1).padStart(2, "0")}`;
|
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) {
|
function hasChinese(value) {
|
||||||
return /[\u3400-\u9fff]/.test(String(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";
|
import { parseResourceFolderFiles, resourcePlanToPayload, translateResourceCodes } from "./batchUpload.js";
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
vi.unstubAllGlobals();
|
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 () => {
|
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(
|
vi.stubGlobal(
|
||||||
"fetch",
|
"fetch",
|
||||||
vi.fn(async (url) => {
|
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 () => {
|
test("uses local dictionary when public translation APIs fail", async () => {
|
||||||
|
vi.spyOn(Math, "random").mockReturnValue(0);
|
||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
"fetch",
|
"fetch",
|
||||||
vi.fn(async () => new Response("{}", { status: 500 })),
|
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) {
|
function file(name) {
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import FileUploadOutlined from "@mui/icons-material/FileUploadOutlined";
|
|||||||
import HelpOutlineOutlined from "@mui/icons-material/HelpOutlineOutlined";
|
import HelpOutlineOutlined from "@mui/icons-material/HelpOutlineOutlined";
|
||||||
import Inventory2Outlined from "@mui/icons-material/Inventory2Outlined";
|
import Inventory2Outlined from "@mui/icons-material/Inventory2Outlined";
|
||||||
import CircularProgress from "@mui/material/CircularProgress";
|
import CircularProgress from "@mui/material/CircularProgress";
|
||||||
|
import LinearProgress from "@mui/material/LinearProgress";
|
||||||
import MenuItem from "@mui/material/MenuItem";
|
import MenuItem from "@mui/material/MenuItem";
|
||||||
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
|
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
|
||||||
import TextField from "@mui/material/TextField";
|
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";
|
import styles from "@/features/resources/resources.module.css";
|
||||||
|
|
||||||
const resourceTypeOptions = resourceTypeFilters.filter(([value]) => value && value !== "emoji_pack");
|
const resourceTypeOptions = resourceTypeFilters.filter(([value]) => value && value !== "emoji_pack");
|
||||||
|
const emptyBatchProgress = { label: "", running: false, value: 0 };
|
||||||
|
|
||||||
const baseColumns = [
|
const baseColumns = [
|
||||||
{
|
{
|
||||||
@ -109,7 +111,7 @@ export function ResourceListPage() {
|
|||||||
const folderInputRef = useRef(null);
|
const folderInputRef = useRef(null);
|
||||||
const [batchOpen, setBatchOpen] = useState(false);
|
const [batchOpen, setBatchOpen] = useState(false);
|
||||||
const [batchPlan, setBatchPlan] = useState({ errors: [], resources: [] });
|
const [batchPlan, setBatchPlan] = useState({ errors: [], resources: [] });
|
||||||
const [batchProgress, setBatchProgress] = useState({ label: "", running: false });
|
const [batchProgress, setBatchProgress] = useState(emptyBatchProgress);
|
||||||
const items = page.data.items || [];
|
const items = page.data.items || [];
|
||||||
const total = page.data.total || 0;
|
const total = page.data.total || 0;
|
||||||
const createDisabled = !page.abilities.canCreate || !page.abilities.canUpload;
|
const createDisabled = !page.abilities.canCreate || !page.abilities.canUpload;
|
||||||
@ -154,7 +156,7 @@ export function ResourceListPage() {
|
|||||||
|
|
||||||
const openBatchDialog = () => {
|
const openBatchDialog = () => {
|
||||||
setBatchPlan({ errors: [], resources: [] });
|
setBatchPlan({ errors: [], resources: [] });
|
||||||
setBatchProgress({ label: "", running: false });
|
setBatchProgress(emptyBatchProgress);
|
||||||
setBatchOpen(true);
|
setBatchOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -168,7 +170,7 @@ export function ResourceListPage() {
|
|||||||
const plan = parseResourceFolderFiles(event.target.files);
|
const plan = parseResourceFolderFiles(event.target.files);
|
||||||
event.target.value = "";
|
event.target.value = "";
|
||||||
setBatchPlan(plan);
|
setBatchPlan(plan);
|
||||||
setBatchProgress({ label: "", running: false });
|
setBatchProgress(emptyBatchProgress);
|
||||||
};
|
};
|
||||||
|
|
||||||
const submitBatchUpload = async (event) => {
|
const submitBatchUpload = async (event) => {
|
||||||
@ -182,8 +184,18 @@ export function ResourceListPage() {
|
|||||||
{ file: resource.coverFile, resourceIndex, role: "cover" },
|
{ file: resource.coverFile, resourceIndex, role: "cover" },
|
||||||
{ file: resource.animationFile, resourceIndex, role: "animation" },
|
{ 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 {
|
try {
|
||||||
for (const resource of resources) {
|
for (const resource of resources) {
|
||||||
if (resource.resourceType !== "profile_card") {
|
if (resource.resourceType !== "profile_card") {
|
||||||
@ -195,28 +207,42 @@ export function ResourceListPage() {
|
|||||||
} catch {
|
} catch {
|
||||||
showToast(`${resource.name} 内容高度解析失败`, "error");
|
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) {
|
for (let index = 0; index < uploadEntries.length; index += resourceBatchUploadSize) {
|
||||||
const chunk = uploadEntries.slice(index, index + resourceBatchUploadSize);
|
const chunk = uploadEntries.slice(index, index + resourceBatchUploadSize);
|
||||||
setBatchProgress({
|
updateBatchProgress(`上传素材 ${index + 1}-${index + chunk.length}/${uploadEntries.length}`);
|
||||||
label: `上传素材 ${index + 1}-${index + chunk.length}/${uploadEntries.length}`,
|
|
||||||
running: true,
|
|
||||||
});
|
|
||||||
const results = await uploadFilesBatch(chunk.map((entry) => entry.file));
|
const results = await uploadFilesBatch(chunk.map((entry) => entry.file));
|
||||||
results.forEach((result, resultIndex) => {
|
results.forEach((result, resultIndex) => {
|
||||||
const entry = chunk[resultIndex];
|
const entry = chunk[resultIndex];
|
||||||
resources[entry.resourceIndex][entry.role === "cover" ? "coverUrl" : "animationUrl"] = result.url;
|
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 translationStartSteps = completedProgressSteps;
|
||||||
const translatedResources = await translateResourceCodes(resources);
|
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) {
|
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]));
|
await createResource(resourcePlanToPayload(translatedResources[index]));
|
||||||
|
completedProgressSteps += 1;
|
||||||
|
updateBatchProgress(`创建资源 ${index + 1}/${translatedResources.length}`, completedProgressSteps);
|
||||||
}
|
}
|
||||||
|
|
||||||
showToast(`批量上传完成,共创建 ${translatedResources.length} 个资源`, "success");
|
showToast(`批量上传完成,共创建 ${translatedResources.length} 个资源`, "success");
|
||||||
@ -226,7 +252,7 @@ export function ResourceListPage() {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
showToast(err.message || "批量上传失败", "error");
|
showToast(err.message || "批量上传失败", "error");
|
||||||
} finally {
|
} finally {
|
||||||
setBatchProgress({ label: "", running: false });
|
setBatchProgress(emptyBatchProgress);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -345,8 +371,19 @@ function ResourceBatchUploadDialog({ disabled, fileInputRef, onClose, onFileChan
|
|||||||
/>
|
/>
|
||||||
{progress.running ? (
|
{progress.running ? (
|
||||||
<div className={styles.batchProgress}>
|
<div className={styles.batchProgress}>
|
||||||
<CircularProgress color="inherit" size={16} />
|
<div className={styles.batchProgressHeader}>
|
||||||
{progress.label}
|
<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>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<ResourceBatchPreview resources={plan.resources} />
|
<ResourceBatchPreview resources={plan.resources} />
|
||||||
@ -694,7 +731,11 @@ function badgeKindFromMetadata(metadataJson) {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const metadata = JSON.parse(metadataJson);
|
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 {
|
} catch {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|||||||
@ -261,10 +261,7 @@
|
|||||||
|
|
||||||
.batchProgress,
|
.batchProgress,
|
||||||
.batchEmpty {
|
.batchEmpty {
|
||||||
display: flex;
|
|
||||||
min-height: 44px;
|
min-height: 44px;
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-2);
|
|
||||||
padding: 0 var(--space-3);
|
padding: 0 var(--space-3);
|
||||||
border: 1px solid var(--border-muted);
|
border: 1px solid var(--border-muted);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
@ -272,6 +269,49 @@
|
|||||||
color: var(--text-secondary);
|
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 {
|
.batchTable {
|
||||||
display: grid;
|
display: grid;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user