From e3ab3cba1e6973a76c31ee7a0a17f254588056a4 Mon Sep 17 00:00:00 2001 From: zhx Date: Thu, 4 Jun 2026 14:56:02 +0800 Subject: [PATCH] =?UTF-8?q?=E6=89=B9=E9=87=8F=E4=B8=8A=E4=BC=A0=E5=92=8Cda?= =?UTF-8?q?tabi?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- databi/src/DatabiApp.jsx | 29 +- databi/src/api.js | 6 + databi/src/components/DatabiHeader.jsx | 365 +++++++++++++- databi/src/components/MetricCard.jsx | 7 +- databi/src/config/options.js | 26 +- databi/src/data/createDashboardModel.js | 54 +- databi/src/styles/cards.css | 45 +- databi/src/styles/layout.css | 474 ++++++++++++++++-- databi/src/utils/time.js | 19 +- src/features/resources/batchUpload.js | 29 +- src/features/resources/batchUpload.test.js | 40 +- .../resources/pages/ResourceListPage.jsx | 73 ++- src/features/resources/resources.module.css | 46 +- 13 files changed, 1086 insertions(+), 127 deletions(-) diff --git a/databi/src/DatabiApp.jsx b/databi/src/DatabiApp.jsx index 5f7bc48..7a7d8bc 100644 --- a/databi/src/DatabiApp.jsx +++ b/databi/src/DatabiApp.jsx @@ -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() { -
- 所有数据按 UTC 展示,实时更新。 - 数据最多可能延迟 60 秒。 -
); } diff --git a/databi/src/api.js b/databi/src/api.js index e34a135..54cedd2 100644 --- a/databi/src/api.js +++ b/databi/src/api.js @@ -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"); diff --git a/databi/src/components/DatabiHeader.jsx b/databi/src/components/DatabiHeader.jsx index 1b487c8..88a998c 100644 --- a/databi/src/components/DatabiHeader.jsx +++ b/databi/src/components/DatabiHeader.jsx @@ -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 ( -
+
-
- onRangeChange({ ...range, start: event.target.value })} /> - ~ - onRangeChange({ ...range, end: event.target.value })} /> - -
- + APP} + 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} + /> + selectItem(onRangeChange, nextRange)} + onToggle={() => toggleControl("range")} + range={range} + /> + } + isOpen={openControl === "region"} + label="区域" + onSelect={(value) => selectItem(onRegionChange, value)} + onToggle={() => toggleControl("region")} + options={regionOptions} + value={regionId} + valueLabel={regionLabel} + /> + CN} + isOpen={openControl === "country"} + label="国家" + onSelect={(value) => selectItem(onCountryChange, Number(value))} + onToggle={() => toggleControl("country")} + options={filteredCountries} + value={countryId} + valueLabel={countryLabel} + /> 实时
); } + +function FilterMenu({ icon, isOpen, label, onSelect, onToggle, options, value, valueLabel }) { + return ( +
+ + {isOpen ? ( +
+ {options.map((item) => ( + + ))} +
+ ) : null} +
+ ); +} + +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 ( +
+ + {isOpen ? ( +
+
+ {options.map((item) => ( + + ))} +
+
+ + +
+
+
+
+ + + {viewMonth.getFullYear()}年 {viewMonth.getMonth() + 1}月 + + +
+
+ {weekLabels.map((label) => ( + {label} + ))} + {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 ( + + ); + })} +
+
+
+ selectTime("hour", value)} options={hours} value={activeTime.hour} /> + selectTime("minute", value)} options={minutes} value={activeTime.minute} /> + selectTime("second", value)} options={seconds} value={activeTime.second} /> +
+
+
+ {error ? {error} : {formatRange(draft)}} + +
+
+ ) : null} +
+ ); +} + +function TimeColumn({ label, onSelect, options, value }) { + return ( +
+ {label} +
+ {options.map((item) => ( + + ))} +
+
+ ); +} + +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"); +} diff --git a/databi/src/components/MetricCard.jsx b/databi/src/components/MetricCard.jsx index e323538..0e269d9 100644 --- a/databi/src/components/MetricCard.jsx +++ b/databi/src/components/MetricCard.jsx @@ -1,10 +1,15 @@ export function MetricCard({ item }) { const Icon = item.icon; + const UnitIcon = item.unitIcon; return (
{Icon ?
: null}
- {item.label} +
+ {item.label} + {UnitIcon ? : null} + {item.unit ? {item.unit} : null} +
{item.value}
{item.caption} diff --git a/databi/src/config/options.js b/databi/src/config/options.js index 803dd83..02717fa 100644 --- a/databi/src/config/options.js +++ b/databi/src/config/options.js @@ -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" } ]; diff --git a/databi/src/data/createDashboardModel.js b/databi/src/data/createDashboardModel.js index d5d3dd1..65f6094 100644 --- a/databi/src/data/createDashboardModel.js +++ b/databi/src/data/createDashboardModel.js @@ -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) => ({ diff --git a/databi/src/styles/cards.css b/databi/src/styles/cards.css index 4885dd4..97e125a 100644 --- a/databi/src/styles/cards.css +++ b/databi/src/styles/cards.css @@ -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; } diff --git a/databi/src/styles/layout.css b/databi/src/styles/layout.css index 2fe96c6..5880af7 100644 --- a/databi/src/styles/layout.css +++ b/databi/src/styles/layout.css @@ -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; -} diff --git a/databi/src/utils/time.js b/databi/src/utils/time.js index cd82a91..a61e73d 100644 --- a/databi/src/utils/time.js +++ b/databi/src/utils/time.js @@ -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())}`; } diff --git a/src/features/resources/batchUpload.js b/src/features/resources/batchUpload.js index 6bddac8..1feabc6 100644 --- a/src/features/resources/batchUpload.js +++ b/src/features/resources/batchUpload.js @@ -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 || "")); } diff --git a/src/features/resources/batchUpload.test.js b/src/features/resources/batchUpload.test.js index 7328246..289931d 100644 --- a/src/features/resources/batchUpload.test.js +++ b/src/features/resources/batchUpload.test.js @@ -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) { diff --git a/src/features/resources/pages/ResourceListPage.jsx b/src/features/resources/pages/ResourceListPage.jsx index bac9946..34a7efd 100644 --- a/src/features/resources/pages/ResourceListPage.jsx +++ b/src/features/resources/pages/ResourceListPage.jsx @@ -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 ? (
- - {progress.label} +
+ + + {progress.label} + + {Math.round(progress.value || 0)}% +
+
) : null} @@ -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 ""; } diff --git a/src/features/resources/resources.module.css b/src/features/resources/resources.module.css index 4ec6247..735b38a 100644 --- a/src/features/resources/resources.module.css +++ b/src/features/resources/resources.module.css @@ -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;