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