批量上传和databi

This commit is contained in:
zhx 2026-06-04 14:56:02 +08:00
parent d4eaf20c8e
commit e3ab3cba1e
13 changed files with 1086 additions and 127 deletions

View File

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

View File

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

View File

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

View File

@ -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>

View File

@ -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" }
];

View File

@ -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) => ({

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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())}`;
}

View File

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

View File

@ -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) {

View File

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

View File

@ -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;