{item.label}
{item.value}
diff --git a/databi/src/data/createDashboardModel.js b/databi/src/data/createDashboardModel.js
index 41267a4..d5d3dd1 100644
--- a/databi/src/data/createDashboardModel.js
+++ b/databi/src/data/createDashboardModel.js
@@ -18,6 +18,14 @@ 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) }
+ ],
countryBreakdown,
funnel: [
{ name: "访客", rate: 1, value: newUsers },
diff --git a/databi/src/styles/cards.css b/databi/src/styles/cards.css
index 5faffd4..4885dd4 100644
--- a/databi/src/styles/cards.css
+++ b/databi/src/styles/cards.css
@@ -5,19 +5,18 @@
background:
linear-gradient(180deg, rgba(9, 43, 70, 0.95), rgba(5, 27, 47, 0.92)),
rgba(8, 34, 56, 0.92);
- box-shadow:
- inset 0 1px 0 rgba(255, 255, 255, 0.04),
- 0 14px 36px rgba(0, 0, 0, 0.18);
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
}
.metric-card {
+ grid-column: span 2;
display: grid;
grid-template-columns: 44px 1fr;
- height: 120px;
+ height: 128px;
align-items: center;
gap: 15px;
padding: 14px 18px;
- border-radius: 6px;
+ border-radius: 8px;
}
.metric-icon {
@@ -55,7 +54,7 @@
margin-top: 6px;
overflow: hidden;
color: #f6fbff;
- font-size: 24px;
+ font-size: clamp(24px, 1.5vw, 30px);
font-weight: 780;
line-height: 1.1;
text-overflow: ellipsis;
@@ -102,7 +101,7 @@
.mini-stat {
min-width: 0;
padding: 10px 11px;
- border-radius: 5px;
+ border-radius: 8px;
}
.mini-stat strong {
diff --git a/databi/src/styles/layout.css b/databi/src/styles/layout.css
index 9bd6fb3..2fe96c6 100644
--- a/databi/src/styles/layout.css
+++ b/databi/src/styles/layout.css
@@ -2,13 +2,16 @@
width: 100vw;
min-width: 1280px;
min-height: 720px;
- padding: 8px 24px 18px;
+ padding: 0 24px 24px;
}
.databi-screen {
- display: flex;
- min-height: calc(100vh - 8px);
- flex-direction: column;
+ --analysis-row-height: 320px;
+ --business-metric-row-height: 88px;
+ display: grid;
+ grid-template-rows: 72px 128px var(--business-metric-row-height) var(--analysis-row-height);
+ gap: 16px;
+ min-height: 0;
}
.databi-header {
@@ -16,11 +19,11 @@
align-items: center;
justify-content: space-between;
gap: 24px;
- min-height: 46px;
- margin: 0 -24px 14px;
+ height: 72px;
+ margin: 0 -24px;
padding: 0 24px;
border-bottom: 1px solid rgba(34, 121, 170, 0.46);
- background: rgba(2, 15, 31, 0.36);
+ background: rgba(2, 15, 31, 0.72);
}
.brand-lockup {
@@ -33,15 +36,13 @@
.brand-emblem {
display: grid;
position: relative;
- width: 36px;
- height: 36px;
+ width: 40px;
+ height: 40px;
place-items: center;
border: 1px solid rgba(39, 228, 245, 0.7);
border-radius: 50%;
background: rgba(11, 45, 70, 0.82);
- box-shadow:
- inset 0 0 16px rgba(39, 228, 245, 0.18),
- 0 0 22px rgba(39, 228, 245, 0.16);
+ box-shadow: inset 0 0 12px rgba(39, 228, 245, 0.16);
}
.brand-emblem::before {
@@ -66,8 +67,8 @@
.brand-lockup h1 {
margin: 0;
- font-size: 24px;
- font-weight: 760;
+ font-size: 26px;
+ font-weight: 780;
letter-spacing: 0;
}
@@ -89,18 +90,18 @@
.range-control,
.region-control {
display: inline-flex;
- height: 34px;
+ height: 44px;
align-items: center;
gap: 10px;
border: 1px solid rgba(64, 148, 197, 0.42);
- border-radius: 6px;
+ border-radius: 8px;
background: rgba(7, 29, 48, 0.86);
color: #d8ebff;
}
.range-control {
- width: 278px;
- padding: 0 10px;
+ width: 378px;
+ padding: 0 16px;
}
.range-control input,
@@ -114,7 +115,7 @@
}
.range-control input {
- width: 105px;
+ width: 132px;
color-scheme: dark;
}
@@ -128,8 +129,8 @@
}
.region-control {
- width: 244px;
- padding: 0 12px;
+ width: 280px;
+ padding: 0 16px;
}
.region-control select {
@@ -169,47 +170,86 @@
box-shadow: 0 0 14px rgba(247, 163, 58, 0.72);
}
-.metric-grid {
+.metric-grid,
+.business-metric-grid {
display: grid;
- grid-template-columns: repeat(6, minmax(0, 1fr));
- gap: 14px;
- margin-bottom: 12px;
+ grid-template-columns: repeat(12, minmax(0, 1fr));
+ gap: 16px;
+}
+
+.business-metric-grid .metric-card {
+ height: var(--business-metric-row-height);
+ grid-template-columns: minmax(0, 1fr);
+ align-items: stretch;
+ padding: 10px 16px;
+}
+
+.business-metric-grid .metric-content {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) max-content;
+ grid-template-rows: auto 1fr;
+ column-gap: 12px;
+ align-items: center;
+}
+
+.business-metric-grid .metric-content > span {
+ grid-column: 1;
+ grid-row: 1;
+}
+
+.business-metric-grid .metric-content strong {
+ grid-column: 1;
+ grid-row: 2;
+ margin-top: 3px;
+ font-size: clamp(18px, 1vw, 20px);
+ line-height: 1.25;
+}
+
+.business-metric-grid .metric-delta {
+ grid-column: 2;
+ grid-row: 1 / 3;
+ display: grid;
+ gap: 3px;
+ align-self: center;
+ justify-self: end;
+ margin-top: 0;
+ padding-top: 0;
+ border-top: 0;
+ font-size: 11px;
+ text-align: right;
+ white-space: nowrap;
+}
+
+.business-metric-grid .metric-delta span {
+ color: #7f9bb7;
}
.databi-grid {
display: grid;
- gap: 12px;
- margin-bottom: 12px;
+ grid-template-columns: repeat(12, minmax(0, 1fr));
+ gap: 16px;
}
.databi-grid--top {
- grid-template-columns: 1.1fr 1.05fr 1.1fr;
+ height: var(--analysis-row-height);
+ min-height: 0;
}
-.databi-grid--middle {
- grid-template-columns: 1fr 1fr 1fr;
+.databi-grid--top > .databi-panel,
+.databi-grid--middle > .databi-panel {
+ grid-column: span 4;
}
.databi-grid--top .databi-panel {
- height: auto;
+ height: var(--analysis-row-height);
min-height: 0;
}
.databi-grid--middle .databi-panel {
- height: auto;
+ height: 100%;
min-height: 0;
}
-.databi-screen .databi-grid--top,
-.databi-screen .databi-grid--middle {
- flex: 1;
- min-height: 0;
-}
-
-.databi-screen .databi-grid--middle {
- margin-bottom: 0;
-}
-
.databi-screen .databi-grid--top .databi-panel,
.databi-screen .databi-grid--middle .databi-panel {
display: flex;
@@ -221,7 +261,7 @@
align-items: center;
justify-content: center;
gap: 34px;
- margin-top: 10px;
+ margin-top: 16px;
color: #7f91a8;
font-size: 12px;
}
diff --git a/databi/src/styles/panels.css b/databi/src/styles/panels.css
index f408acd..e4a4a30 100644
--- a/databi/src/styles/panels.css
+++ b/databi/src/styles/panels.css
@@ -1,13 +1,13 @@
.databi-panel {
min-width: 0;
min-height: 236px;
- padding: 12px 14px;
- border-radius: 6px;
+ padding: 16px 20px;
+ border-radius: 8px;
}
.panel--table {
min-height: 190px;
- margin-top: 18px;
+ margin-top: 16px;
}
.panel-head {
@@ -15,32 +15,35 @@
align-items: center;
justify-content: space-between;
gap: 12px;
- margin-bottom: 8px;
+ min-height: 28px;
+ margin-bottom: 12px;
}
.panel-title {
margin: 0;
color: #f3f9ff;
- font-size: 14px;
- font-weight: 760;
+ font-size: 18px;
+ font-weight: 780;
}
.panel-info {
display: grid;
- width: 16px;
- height: 16px;
+ width: 22px;
+ height: 22px;
place-items: center;
border: 1px solid rgba(157, 199, 232, 0.72);
border-radius: 50%;
color: #9dc7e8;
- font-size: 11px;
+ font-size: 13px;
}
.geo-layout {
display: grid;
- grid-template-columns: minmax(0, 1fr) 220px;
- gap: 14px;
- height: 232px;
+ grid-template-columns: minmax(0, 1fr) minmax(218px, 0.72fr);
+ align-items: start;
+ gap: 16px;
+ height: 100%;
+ min-height: 0;
}
.databi-grid--top .geo-layout,
@@ -51,14 +54,16 @@
}
.databi-grid--top .geo-map {
- height: 100%;
+ aspect-ratio: 1.66;
+ height: auto;
+ max-height: 210px;
}
.geo-map {
position: relative;
min-width: 0;
overflow: hidden;
- border-radius: 4px;
+ border-radius: 8px;
background:
radial-gradient(circle at 50% 50%, rgba(39, 228, 245, 0.1), transparent 48%),
radial-gradient(ellipse at 48% 52%, rgba(36, 116, 154, 0.2), transparent 68%),
@@ -94,7 +99,7 @@
.rank-block {
display: grid;
align-content: start;
- gap: 7px;
+ gap: 8px;
min-width: 0;
}
@@ -171,20 +176,20 @@
.rank-list {
display: grid;
align-content: start;
- gap: 7px;
+ gap: 8px;
min-width: 0;
}
.rank-row {
display: grid;
- grid-template-columns: 17px 18px minmax(0, 1fr) 54px;
+ grid-template-columns: 20px 20px minmax(0, 1fr) 62px;
align-items: center;
- gap: 6px;
+ gap: 8px;
min-width: 0;
- min-height: 27px;
- padding: 0 6px;
+ min-height: 36px;
+ padding: 0 10px;
border: 1px solid rgba(81, 154, 203, 0.2);
- border-radius: 4px;
+ border-radius: 8px;
background: rgba(6, 27, 46, 0.72);
}
@@ -217,16 +222,16 @@
.rank-row strong,
.rank-row b {
- font-size: 13px;
+ font-size: 14px;
}
.all-countries-button {
- height: 25px;
+ height: 34px;
border: 1px solid rgba(39, 148, 203, 0.5);
- border-radius: 4px;
+ border-radius: 8px;
background: rgba(7, 35, 58, 0.72);
color: #2fe8f3;
- font-size: 12px;
+ font-size: 14px;
}
.rank-row b,
@@ -240,25 +245,29 @@
.funnel-layout {
display: grid;
- grid-template-columns: minmax(0, 1fr) 220px;
- gap: 14px;
- align-items: center;
+ grid-template-columns: 1fr;
+ align-content: center;
+ gap: 8px;
+ flex: 1;
+ min-height: 0;
}
.funnel-stack {
display: grid;
- gap: 13px;
- padding: 2px 0 0 18px;
+ width: min(420px, 88%);
+ justify-self: center;
+ gap: 5px;
+ padding: 2px 0 0;
}
.funnel-stage-row {
display: grid;
- grid-template-columns: minmax(210px, 1fr) 52px;
+ grid-template-columns: minmax(210px, 1fr);
align-items: center;
- gap: 12px;
}
.funnel-stage-row > b {
+ display: none;
color: #9eb7ce;
font-size: 12px;
font-weight: 700;
@@ -267,43 +276,72 @@
}
.funnel-stage {
- display: grid;
- grid-template-columns: 1fr auto;
+ display: flex;
align-items: center;
- gap: 10px;
- height: 55px;
- padding: 0 38px;
+ justify-content: center;
+ gap: 18px;
+ height: 37px;
+ padding: 0 26px;
clip-path: polygon(4% 0, 96% 0, 88% 100%, 12% 100%);
border: 1px solid rgba(196, 241, 255, 0.65);
background: linear-gradient(180deg, rgba(54, 138, 234, 0.94), rgba(30, 106, 196, 0.92));
- box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.18), 0 0 16px rgba(39, 228, 245, 0.12);
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.18);
color: #edf8ff;
}
.funnel-stage span {
- font-size: 12px;
+ font-size: 14px;
font-weight: 720;
line-height: 1.1;
+ white-space: nowrap;
}
.funnel-stage strong {
color: #f6fbff;
- font-size: 17px;
+ font-size: 19px;
font-weight: 820;
font-variant-numeric: tabular-nums;
+ white-space: nowrap;
+}
+
+.funnel-layout .side-metrics {
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 10px;
+}
+
+.funnel-layout .mini-stat {
+ height: 72px;
+ overflow: hidden;
+ padding: 6px 10px;
+}
+
+.funnel-layout .mini-stat > span {
+ font-size: 12px;
+}
+
+.funnel-layout .mini-stat strong {
+ margin-top: 2px;
+ font-size: 18px;
+ line-height: 1.2;
+}
+
+.funnel-layout .mini-stat small {
+ margin-top: 3px;
+ font-size: 9px;
+ line-height: 1.15;
}
.funnel-stage--2 {
- margin-inline: 16px;
+ margin-inline: 18px;
background: linear-gradient(180deg, rgba(20, 174, 194, 0.94), rgba(16, 137, 173, 0.92));
}
.funnel-stage--3 {
- margin-inline: 34px;
+ margin-inline: 36px;
background: linear-gradient(180deg, rgba(31, 190, 177, 0.94), rgba(19, 155, 157, 0.92));
}
.funnel-stage--4 {
- margin-inline: 52px;
+ margin-inline: 54px;
background: linear-gradient(180deg, rgba(32, 203, 161, 0.94), rgba(18, 168, 137, 0.92));
}
diff --git a/databi/src/styles/responsive.css b/databi/src/styles/responsive.css
index fc9d58a..ae40dab 100644
--- a/databi/src/styles/responsive.css
+++ b/databi/src/styles/responsive.css
@@ -1,12 +1,4 @@
@media (max-width: 1440px) {
- .databi-shell {
- padding: 14px;
- }
-
- .metric-grid {
- gap: 10px;
- }
-
.metric-card {
grid-template-columns: 34px 1fr;
gap: 10px;
@@ -14,35 +6,35 @@
}
.metric-content strong {
- font-size: 18px;
+ font-size: 20px;
}
.funnel-layout {
- grid-template-columns: minmax(0, 1fr) 176px;
- gap: 10px;
- }
-
- .funnel-stack {
- gap: 12px;
- padding-left: 2px;
- }
-
- .funnel-stage-row {
- grid-template-columns: minmax(160px, 1fr) 46px;
+ grid-template-columns: 1fr;
gap: 8px;
}
+ .funnel-stack {
+ width: min(330px, 100%);
+ gap: 5px;
+ padding-left: 0;
+ }
+
+ .funnel-stage-row {
+ grid-template-columns: minmax(0, 1fr);
+ }
+
.funnel-stage {
- height: 55px;
+ height: 37px;
padding: 0 18px;
}
.funnel-stage span {
- font-size: 11px;
+ font-size: 12px;
}
.funnel-stage strong {
- font-size: 15px;
+ font-size: 16px;
}
.funnel-stage--2 {
@@ -58,12 +50,12 @@
}
.funnel-layout .mini-stat {
- height: 74px;
- padding: 9px 10px;
+ height: 72px;
+ padding: 6px 10px;
}
.funnel-layout .mini-stat strong {
- font-size: 20px;
+ font-size: 16px;
}
.funnel-layout .mini-stat small {
@@ -80,12 +72,17 @@
}
.distribution-row {
- grid-template-columns: 8px minmax(118px, 1fr) 48px 68px;
- gap: 7px;
+ grid-template-columns: 8px minmax(80px, 1fr) 42px 56px;
+ gap: 5px;
}
.distribution-row em {
- font-size: 12px;
+ font-size: 11px;
+ }
+
+ .distribution-row strong,
+ .distribution-row b {
+ font-size: 11px;
}
.five-metric-row {
@@ -135,12 +132,6 @@
@media (max-width: 1300px) {
.databi-shell {
min-width: 1280px;
- padding-inline: 14px;
- }
-
- .databi-header {
- margin-inline: -14px;
- padding-inline: 14px;
}
.metric-card {
@@ -161,13 +152,12 @@
}
.funnel-layout {
- grid-template-columns: minmax(0, 1fr) 160px;
+ grid-template-columns: 1fr;
gap: 8px;
}
.funnel-stage-row {
- grid-template-columns: minmax(150px, 1fr) 40px;
- gap: 6px;
+ grid-template-columns: minmax(0, 1fr);
}
.funnel-stage {
diff --git a/src/features/red-packets/pages/RedPacketPage.jsx b/src/features/red-packets/pages/RedPacketPage.jsx
index 3c21068..1f9e67e 100644
--- a/src/features/red-packets/pages/RedPacketPage.jsx
+++ b/src/features/red-packets/pages/RedPacketPage.jsx
@@ -1,7 +1,5 @@
import VisibilityOutlined from "@mui/icons-material/VisibilityOutlined";
import ReplayOutlined from "@mui/icons-material/ReplayOutlined";
-import MenuItem from "@mui/material/MenuItem";
-import TextField from "@mui/material/TextField";
import { RedPacketConfigDrawer } from "@/features/red-packets/components/RedPacketConfigDrawer.jsx";
import { RedPacketConfigSummary } from "@/features/red-packets/components/RedPacketConfigSummary.jsx";
import { RedPacketDetailDrawer } from "@/features/red-packets/components/RedPacketDetailDrawer.jsx";
@@ -9,14 +7,13 @@ import { useRedPacketPage } from "@/features/red-packets/hooks/useRedPacketPage.
import styles from "@/features/red-packets/red-packets.module.css";
import {
AdminActionIconButton,
- AdminFilterResetButton,
AdminListBody,
AdminListPage,
- AdminListToolbar,
AdminRowActions,
} from "@/shared/ui/AdminListLayout.jsx";
import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
+import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
import { formatMillis } from "@/shared/utils/time.js";
const packetTypeOptions = [
@@ -52,6 +49,11 @@ export function RedPacketPage() {
key: "room",
label: "房间/区域",
width: "minmax(180px, 0.75fr)",
+ filter: createTextColumnFilter({
+ placeholder: "搜索房间 ID",
+ value: page.roomId,
+ onChange: page.setRoomId,
+ }),
render: (packet) => (
{packet.roomId || "-"}
@@ -63,12 +65,23 @@ export function RedPacketPage() {
key: "sender",
label: "发送人",
width: "minmax(130px, 0.55fr)",
+ filter: createTextColumnFilter({
+ placeholder: "搜索发送人 ID",
+ value: page.senderUserId,
+ onChange: page.setSenderUserId,
+ }),
render: (packet) => packet.senderUserId || "-",
},
{
key: "type",
label: "类型",
width: "minmax(110px, 0.5fr)",
+ filter: createOptionsColumnFilter({
+ options: packetTypeOptions,
+ placeholder: "搜索类型",
+ value: page.packetType,
+ onChange: page.setPacketType,
+ }),
render: (packet) => packetTypeLabel(packet.packetType),
},
{
@@ -99,6 +112,12 @@ export function RedPacketPage() {
key: "status",
label: "状态",
width: "minmax(110px, 0.5fr)",
+ filter: createOptionsColumnFilter({
+ options: statusOptions,
+ placeholder: "搜索状态",
+ value: page.status,
+ onChange: page.setStatus,
+ }),
render: (packet) => packetStatusLabel(packet.status),
},
{
@@ -150,51 +169,6 @@ export function RedPacketPage() {
onEdit={page.openConfigDrawer}
onRefresh={page.reloadConfig}
/>
-
}
- filters={
- <>
-
page.setRoomId(event.target.value)}
- />
- page.setSenderUserId(event.target.value)}
- />
- page.setPacketType(event.target.value)}
- >
- {packetTypeOptions.map(([value, label]) => (
-
- ))}
-
- page.setStatus(event.target.value)}
- >
- {statusOptions.map(([value, label]) => (
-
- ))}
-
- >
- }
- />
file?.name && !file.name.startsWith("."));
+ const groups = new Map();
+
+ files.forEach((file) => {
+ const parsed = parseResourceFilename(file);
+ if (!parsed.ok) {
+ return;
+ }
+
+ const key = [parsed.resourceType, parsed.name, parsed.price, parsed.badgeForm].join("|");
+ const current = groups.get(key) || {
+ badgeForm: parsed.badgeForm,
+ coverFile: null,
+ animationFile: null,
+ name: parsed.name,
+ price: parsed.price,
+ resourceType: parsed.resourceType,
+ };
+
+ const fileKey = parsed.role === "cover" ? "coverFile" : "animationFile";
+ if (!current[fileKey]) {
+ current[fileKey] = file;
+ }
+ groups.set(key, current);
+ });
+
+ const resources = Array.from(groups.values())
+ .filter((item) => item.coverFile && item.animationFile)
+ .map((item, index) => ({
+ ...item,
+ resourceCode: resourceCodeFor(item, index, now),
+ }));
+
+ return {
+ errors: [],
+ resources,
+ };
+}
+
+export function resourcePlanToPayload(item) {
+ const coinPrice = pricedResourceTypes.has(item.resourceType) ? Number(item.price || 0) : 0;
+ return {
+ amount: 0,
+ animationUrl: item.animationUrl,
+ assetUrl: item.animationUrl,
+ badgeForm: item.resourceType === "badge" ? item.badgeForm || "tile" : undefined,
+ coinPrice,
+ name: item.name,
+ previewUrl: item.coverUrl,
+ priceType: coinPrice > 0 ? "coin" : "free",
+ resourceCode: item.resourceCode,
+ resourceType: item.resourceType,
+ sortOrder: 0,
+ status: "active",
+ };
+}
+
+function parseResourceFilename(file) {
+ const baseName = stripExtension(file.name);
+ const parts = baseName
+ .split("_")
+ .map((part) => part.trim())
+ .filter(Boolean);
+ if (parts.length < 3) {
+ return { ok: false };
+ }
+
+ const role = normalizeRole(parts[parts.length - 1]);
+ if (!role) {
+ return { ok: false };
+ }
+
+ const resourceType = resourceTypeAliases.get(parts[0].toLowerCase()) || resourceTypeAliases.get(parts[0]);
+ if (!resourceType) {
+ return { ok: false };
+ }
+
+ const name = parts[1];
+ if (!name) {
+ return { ok: false };
+ }
+
+ let price = 0;
+ let badgeForm = "tile";
+ if (pricedResourceTypes.has(resourceType)) {
+ price = Number(parts[2]);
+ if (!Number.isInteger(price) || price <= 0) {
+ return { ok: false };
+ }
+ } else if (resourceType === "badge") {
+ const formToken = parts.length >= 4 ? parts[2] : "";
+ badgeForm = formToken === "长" || formToken === "long" || formToken === "strip" ? "strip" : "tile";
+ }
+
+ return { ok: true, badgeForm, name, price, resourceType, role };
+}
+
+function normalizeRole(value) {
+ const role = String(value || "")
+ .trim()
+ .toLowerCase();
+ if (role === "cover" || role === "封面") {
+ return "cover";
+ }
+ if (role === "animation" || role === "anim" || role === "动效") {
+ return "animation";
+ }
+ return "";
+}
+
+function resourceCodeFor(item, index, now) {
+ const suffix = Number(now || Date.now()).toString(36);
+ return `batch_${item.resourceType}_${suffix}_${String(index + 1).padStart(2, "0")}`;
+}
+
+function stripExtension(filename) {
+ return String(filename || "").replace(/\.[^.]+$/, "");
+}
diff --git a/src/features/resources/batchUpload.test.js b/src/features/resources/batchUpload.test.js
new file mode 100644
index 0000000..85f03f6
--- /dev/null
+++ b/src/features/resources/batchUpload.test.js
@@ -0,0 +1,55 @@
+import { expect, test } from "vitest";
+import { parseResourceFolderFiles, resourcePlanToPayload } from "./batchUpload.js";
+
+test("parses folder resource filenames into resource plans", () => {
+ const files = [
+ file("头像框_星光_99_7_cover.png"),
+ file("头像框_星光_99_7_animation.svga"),
+ file("勋章_守护_长_cover.png"),
+ file("勋章_守护_长_animation.pag"),
+ file("背景卡_夜色_cover.png"),
+ file("背景卡_夜色_animation.svga"),
+ file("mic声波_律动_cover.png"),
+ file("mic声波_律动_animation.svga"),
+ ];
+
+ const plan = parseResourceFolderFiles(files, 1770000000000);
+
+ expect(plan.errors).toEqual([]);
+ expect(plan.resources.map((item) => [item.name, item.resourceType, item.price, item.badgeForm])).toEqual([
+ ["星光", "avatar_frame", 99, "tile"],
+ ["守护", "badge", 0, "strip"],
+ ["夜色", "profile_card", 0, "tile"],
+ ["律动", "mic_seat_animation", 0, "tile"],
+ ]);
+
+ expect(resourcePlanToPayload({ ...plan.resources[0], coverUrl: "cover", animationUrl: "animation" })).toMatchObject(
+ {
+ animationUrl: "animation",
+ coinPrice: 99,
+ previewUrl: "cover",
+ priceType: "coin",
+ resourceType: "avatar_frame",
+ },
+ );
+});
+
+test("silently ignores invalid and unpaired material", () => {
+ const plan = parseResourceFolderFiles(
+ [
+ file("frame/vip_TOPO2.png"),
+ file("气泡_清新_cover.png"),
+ file("头像框_星光_99_7_cover.png"),
+ file("头像框_星光_99_7_animation.svga"),
+ ],
+ 1770000000000,
+ );
+
+ expect(plan.errors).toEqual([]);
+ expect(plan.resources).toHaveLength(1);
+ expect(plan.resources[0]).toMatchObject({ name: "星光", resourceType: "avatar_frame" });
+});
+
+function file(name) {
+ return new File(["payload"], name, { type: "image/png" });
+}
diff --git a/src/features/resources/constants.js b/src/features/resources/constants.js
index 4e14d16..f442aa7 100644
--- a/src/features/resources/constants.js
+++ b/src/features/resources/constants.js
@@ -55,15 +55,20 @@ export const badgeFormOptions = [
export const badgeFormLabels = Object.fromEntries(badgeFormOptions);
+export const resourceBatchUploadRoleLabels = {
+ animation: "动效素材",
+ cover: "封面图",
+};
+
export const resourceTypeFilters = [
["", "全部类型"],
["avatar_frame", "头像框"],
- ["profile_card", "资料卡"],
+ ["profile_card", "背景卡"],
["coin", "金币"],
["vehicle", "座驾"],
["chat_bubble", "气泡"],
["badge", "徽章"],
- ["floating_screen", "飘屏"],
+ ["floating_screen", "飘窗"],
["gift", "礼物"],
["mic_seat_icon", "麦位图标"],
["mic_seat_animation", "麦位动效"],
diff --git a/src/features/resources/pages/ResourceListPage.jsx b/src/features/resources/pages/ResourceListPage.jsx
index 980569f..967d0c5 100644
--- a/src/features/resources/pages/ResourceListPage.jsx
+++ b/src/features/resources/pages/ResourceListPage.jsx
@@ -1,9 +1,14 @@
import Add from "@mui/icons-material/Add";
import EditOutlined from "@mui/icons-material/EditOutlined";
+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 MenuItem from "@mui/material/MenuItem";
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
import TextField from "@mui/material/TextField";
+import Tooltip from "@mui/material/Tooltip";
+import { useRef, useState } from "react";
import {
AdminFormDialog,
AdminFormFieldGrid,
@@ -25,13 +30,22 @@ import { formatMillis } from "@/shared/utils/time.js";
import {
badgeFormLabels,
badgeFormOptions,
+ resourceBatchUploadRoleLabels,
resourceStatusFilters,
resourcePriceTypeLabels,
resourcePriceTypeOptions,
resourceTypeFilters,
resourceTypeLabels,
} from "@/features/resources/constants.js";
+import { createResource } from "@/features/resources/api";
+import {
+ parseResourceFolderFiles,
+ resourceBatchUploadSize,
+ resourcePlanToPayload,
+} from "@/features/resources/batchUpload.js";
import { useResourceListPage } from "@/features/resources/hooks/useResourcePages.js";
+import { uploadImagesBatch } from "@/shared/api/upload";
+import { useToast } from "@/shared/ui/ToastProvider.jsx";
import styles from "@/features/resources/resources.module.css";
const resourceTypeOptions = resourceTypeFilters.filter(([value]) => value && value !== "emoji_pack");
@@ -81,6 +95,11 @@ const baseColumns = [
export function ResourceListPage() {
const page = useResourceListPage();
+ const { showToast } = useToast();
+ const folderInputRef = useRef(null);
+ const [batchOpen, setBatchOpen] = useState(false);
+ const [batchPlan, setBatchPlan] = useState({ errors: [], resources: [] });
+ const [batchProgress, setBatchProgress] = useState({ label: "", running: false });
const items = page.data.items || [];
const total = page.data.total || 0;
const createDisabled = !page.abilities.canCreate || !page.abilities.canUpload;
@@ -123,14 +142,85 @@ export function ResourceListPage() {
: column,
);
+ const openBatchDialog = () => {
+ setBatchPlan({ errors: [], resources: [] });
+ setBatchProgress({ label: "", running: false });
+ setBatchOpen(true);
+ };
+
+ const closeBatchDialog = () => {
+ if (!batchProgress.running) {
+ setBatchOpen(false);
+ }
+ };
+
+ const handleFolderChange = (event) => {
+ const plan = parseResourceFolderFiles(event.target.files);
+ event.target.value = "";
+ setBatchPlan(plan);
+ setBatchProgress({ label: "", running: false });
+ };
+
+ const submitBatchUpload = async (event) => {
+ event.preventDefault();
+ if (!batchPlan.resources.length || batchProgress.running) {
+ return;
+ }
+
+ const resources = batchPlan.resources.map((item) => ({ ...item }));
+ const uploadEntries = resources.flatMap((resource, resourceIndex) => [
+ { file: resource.coverFile, resourceIndex, role: "cover" },
+ { file: resource.animationFile, resourceIndex, role: "animation" },
+ ]);
+
+ setBatchProgress({ label: "上传素材 0/" + uploadEntries.length, running: true });
+ try {
+ 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,
+ });
+ const results = await uploadImagesBatch(chunk.map((entry) => entry.file));
+ results.forEach((result, resultIndex) => {
+ const entry = chunk[resultIndex];
+ resources[entry.resourceIndex][entry.role === "cover" ? "coverUrl" : "animationUrl"] = result.url;
+ });
+ }
+
+ for (let index = 0; index < resources.length; index += 1) {
+ setBatchProgress({ label: `创建资源 ${index + 1}/${resources.length}`, running: true });
+ await createResource(resourcePlanToPayload(resources[index]));
+ }
+
+ showToast(`批量上传完成,共创建 ${resources.length} 个资源`, "success");
+ setBatchOpen(false);
+ setBatchPlan({ errors: [], resources: [] });
+ await page.reload();
+ } catch (err) {
+ showToast(err.message || "批量上传失败", "error");
+ } finally {
+ setBatchProgress({ label: "", running: false });
+ }
+ };
+
return (
-
-
+ <>
+
+
+
+
+
+
+ >
) : null
}
/>
@@ -169,10 +259,120 @@ export function ResourceListPage() {
onClose={page.closeAction}
onSubmit={page.submitResource}
/>
+
);
}
+function ResourceBatchUploadDialog({ disabled, fileInputRef, onClose, onFileChange, onSubmit, open, plan, progress }) {
+ const submitDisabled = disabled || progress.running || !plan.resources.length;
+
+ return (
+
+
+ }>
+
+
+
+
+
+
+ }
+ title="文件夹资源"
+ >
+
+ {progress.running ? (
+
+
+ {progress.label}
+
+ ) : null}
+
+
+
+ );
+}
+
+function ResourceBatchRules() {
+ return (
+
+
只读取符合命名规则且同时存在 cover/animation 的资源,其他文件会被忽略。
+
头像框_名称_价格_天数_cover / animation
+
坐骑_名称_价格_天数_cover / animation
+
气泡_名称_cover / animation
+
勋章_名称_长_cover / animation
+
飘窗_名称_cover / animation
+
mic声波_名称_cover / animation
+
背景卡_名称_cover / animation
+
天数字段会忽略;勋章不写“长”时按短徽章处理。
+
服务端单批最多上传 10 个素材,前端会自动分批串行上传。
+
+ );
+}
+
+function ResourceBatchPreview({ resources }) {
+ if (!resources.length) {
+ return
尚未选择资源文件夹
;
+ }
+
+ return (
+
+
+ 资源
+ 类型
+ 价格
+ 素材
+
+ {resources.map((resource) => (
+
+ {resource.name}
+ {resourceTypeLabels[resource.resourceType] || resource.resourceType}
+ {resource.price > 0 ? `金币 ${resource.price}` : "免费"}
+
+ {resourceBatchUploadRoleLabels.cover} / {resourceBatchUploadRoleLabels.animation}
+
+
+ ))}
+
+ );
+}
+
function ResourceRowActions({ page, resource }) {
return (
@@ -378,7 +578,9 @@ function badgeFormFromMetadata(metadataJson) {
}
try {
const metadata = JSON.parse(metadataJson);
- const badgeForm = String(metadata?.badge_form || "").trim().toLowerCase();
+ const badgeForm = String(metadata?.badge_form || "")
+ .trim()
+ .toLowerCase();
return badgeForm === "strip" || badgeForm === "tile" ? badgeForm : "";
} catch {
return "";
diff --git a/src/features/resources/resources.module.css b/src/features/resources/resources.module.css
index dcc3d69..4ec6247 100644
--- a/src/features/resources/resources.module.css
+++ b/src/features/resources/resources.module.css
@@ -195,10 +195,123 @@
color: var(--text-tertiary);
}
+.batchInput {
+ display: none;
+}
+
+.batchActions {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-2);
+}
+
+.batchHelp {
+ display: inline-flex;
+ width: var(--control-height);
+ height: var(--control-height);
+ align-items: center;
+ justify-content: center;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ background: var(--bg-card);
+ color: var(--text-secondary);
+ cursor: help;
+}
+
+.batchHelp:hover {
+ border-color: var(--primary-border);
+ background: var(--primary-surface);
+ color: var(--primary);
+}
+
+.batchRules {
+ display: grid;
+ max-width: 460px;
+ gap: 6px;
+ color: inherit;
+ font-size: 13px;
+ line-height: 1.5;
+}
+
+.batchPickButton {
+ display: inline-flex;
+ height: var(--control-height);
+ align-items: center;
+ justify-content: center;
+ gap: var(--space-2);
+ padding: 0 var(--space-3);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ background: var(--bg-card);
+ color: var(--text-primary);
+ cursor: pointer;
+ font: inherit;
+ font-weight: 650;
+}
+
+.batchPickButton:disabled {
+ cursor: not-allowed;
+ opacity: 0.55;
+}
+
+.batchHint {
+ color: var(--text-secondary);
+ line-height: 1.6;
+}
+
+.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);
+ background: var(--bg-card-strong);
+ color: var(--text-secondary);
+}
+
+.batchTable {
+ display: grid;
+ overflow: hidden;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+}
+
+.batchTableHead,
+.batchTableRow {
+ display: grid;
+ min-height: 42px;
+ align-items: center;
+ gap: var(--space-3);
+ grid-template-columns: minmax(180px, 1.2fr) minmax(110px, 0.7fr) minmax(100px, 0.55fr) minmax(150px, 0.9fr);
+ padding: 0 var(--space-3);
+}
+
+.batchTableHead {
+ border-bottom: 1px solid var(--border);
+ background: var(--bg-card-strong);
+ color: var(--text-secondary);
+ font-size: var(--admin-font-size);
+ font-weight: 700;
+}
+
+.batchTableRow {
+ border-bottom: 1px solid var(--border-muted);
+ color: var(--text-primary);
+}
+
+.batchTableRow:last-child {
+ border-bottom: 0;
+}
+
@media (max-width: 860px) {
.shopDrawerTools,
.shopSelectionRow,
- .shopSelectionControls {
+ .shopSelectionControls,
+ .batchTableHead,
+ .batchTableRow {
grid-template-columns: 1fr;
}
diff --git a/src/shared/api/generated/endpoints.ts b/src/shared/api/generated/endpoints.ts
index c2f4d80..cbc989d 100644
--- a/src/shared/api/generated/endpoints.ts
+++ b/src/shared/api/generated/endpoints.ts
@@ -751,14 +751,14 @@ export const API_ENDPOINTS: Record = {
getRedPacket: {
method: "GET",
operationId: API_OPERATIONS.getRedPacket,
- path: "/v1/resident-activity/voice-room-red-packet/records/{packet_id}",
+ path: "/v1/admin/activity/red-packets/{packet_id}",
permission: "red-packet:view",
permissions: ["red-packet:view"]
},
getRedPacketConfig: {
method: "GET",
operationId: API_OPERATIONS.getRedPacketConfig,
- path: "/v1/resident-activity/voice-room-red-packet/config",
+ path: "/v1/admin/activity/red-packets/config",
permission: "red-packet:view",
permissions: ["red-packet:view"]
},
@@ -1057,7 +1057,7 @@ export const API_ENDPOINTS: Record = {
listRedPackets: {
method: "GET",
operationId: API_OPERATIONS.listRedPackets,
- path: "/v1/resident-activity/voice-room-red-packet/records",
+ path: "/v1/admin/activity/red-packets",
permission: "red-packet:view",
permissions: ["red-packet:view"]
},
@@ -1257,7 +1257,7 @@ export const API_ENDPOINTS: Record = {
retryRedPacketRefund: {
method: "POST",
operationId: API_OPERATIONS.retryRedPacketRefund,
- path: "/v1/resident-activity/voice-room-red-packet/refund/retry",
+ path: "/v1/admin/activity/red-packets/refund/retry",
permission: "red-packet:update",
permissions: ["red-packet:update"]
},
@@ -1449,9 +1449,9 @@ export const API_ENDPOINTS: Record = {
permissions: ["payment-product:update"]
},
updateRedPacketConfig: {
- method: "POST",
+ method: "PUT",
operationId: API_OPERATIONS.updateRedPacketConfig,
- path: "/v1/resident-activity/voice-room-red-packet/config",
+ path: "/v1/admin/activity/red-packets/config",
permission: "red-packet:update",
permissions: ["red-packet:update"]
},
diff --git a/src/shared/api/generated/schema.d.ts b/src/shared/api/generated/schema.d.ts
index 876b149..018b908 100644
--- a/src/shared/api/generated/schema.d.ts
+++ b/src/shared/api/generated/schema.d.ts
@@ -100,7 +100,7 @@ export interface paths {
patch?: never;
trace?: never;
};
- "/resident-activity/voice-room-red-packet/records": {
+ "/admin/activity/red-packets": {
parameters: {
query?: never;
header?: never;
@@ -116,7 +116,7 @@ export interface paths {
patch?: never;
trace?: never;
};
- "/resident-activity/voice-room-red-packet/records/{packet_id}": {
+ "/admin/activity/red-packets/{packet_id}": {
parameters: {
query?: never;
header?: never;
@@ -132,7 +132,7 @@ export interface paths {
patch?: never;
trace?: never;
};
- "/resident-activity/voice-room-red-packet/config": {
+ "/admin/activity/red-packets/config": {
parameters: {
query?: never;
header?: never;
@@ -140,15 +140,15 @@ export interface paths {
cookie?: never;
};
get: operations["getRedPacketConfig"];
- put?: never;
- post: operations["updateRedPacketConfig"];
+ put: operations["updateRedPacketConfig"];
+ post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
- "/resident-activity/voice-room-red-packet/refund/retry": {
+ "/admin/activity/red-packets/refund/retry": {
parameters: {
query?: never;
header?: never;
diff --git a/src/shared/api/upload.test.ts b/src/shared/api/upload.test.ts
index c0b70cc..6691127 100644
--- a/src/shared/api/upload.test.ts
+++ b/src/shared/api/upload.test.ts
@@ -1,6 +1,6 @@
import { afterEach, expect, test, vi } from "vitest";
import { setAccessToken } from "@/shared/api/request";
-import { uploadFile, uploadImage } from "@/shared/api/upload";
+import { uploadFile, uploadImage, uploadImagesBatch } from "@/shared/api/upload";
afterEach(() => {
setAccessToken("");
@@ -10,7 +10,12 @@ afterEach(() => {
test("uploadImage sends multipart data to generated image endpoint", async () => {
vi.stubGlobal(
"fetch",
- vi.fn(async () => new Response(JSON.stringify({ code: 0, data: { url: "https://media.haiyihy.com/admin/images/a.png" } })))
+ vi.fn(
+ async () =>
+ new Response(
+ JSON.stringify({ code: 0, data: { url: "https://media.haiyihy.com/admin/images/a.png" } }),
+ ),
+ ),
);
const result = await uploadImage(new File(["image"], "avatar.png", { type: "image/png" }));
@@ -26,7 +31,10 @@ test("uploadImage sends multipart data to generated image endpoint", async () =>
test("uploadFile sends multipart data to generated file endpoint", async () => {
vi.stubGlobal(
"fetch",
- vi.fn(async () => new Response(JSON.stringify({ code: 0, data: { url: "https://media.haiyihy.com/admin/files/a.pdf" } })))
+ vi.fn(
+ async () =>
+ new Response(JSON.stringify({ code: 0, data: { url: "https://media.haiyihy.com/admin/files/a.pdf" } })),
+ ),
);
await uploadFile(new File(["file"], "doc.pdf", { type: "application/pdf" }));
@@ -37,3 +45,26 @@ test("uploadFile sends multipart data to generated file endpoint", async () => {
expect(init?.body).toBeInstanceOf(FormData);
expect(init?.headers).not.toHaveProperty("Content-Type");
});
+
+test("uploadImagesBatch sends multipart data to admin batch endpoint", async () => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(
+ async () =>
+ new Response(
+ JSON.stringify({
+ code: 0,
+ data: [{ url: "https://media.haiyihy.com/admin/images/cover.png" }],
+ }),
+ ),
+ ),
+ );
+
+ await uploadImagesBatch([new File(["image"], "cover.png", { type: "image/png" })]);
+ const [url, init] = vi.mocked(fetch).mock.calls[0];
+
+ expect(String(url)).toContain("/api/v1/admin/files/image/batch-upload");
+ expect(init?.method).toBe("POST");
+ expect(init?.body).toBeInstanceOf(FormData);
+ expect(init?.headers).not.toHaveProperty("Content-Type");
+});
diff --git a/src/shared/api/upload.ts b/src/shared/api/upload.ts
index 7f4e70c..8c3b59f 100644
--- a/src/shared/api/upload.ts
+++ b/src/shared/api/upload.ts
@@ -9,7 +9,7 @@ export function uploadFile(file: File): Promise {
return apiRequest(apiEndpointPath(API_OPERATIONS.uploadFile), {
body: formData,
- method: endpoint.method
+ method: endpoint.method,
});
}
@@ -20,6 +20,18 @@ export function uploadImage(file: File): Promise {
return apiRequest(apiEndpointPath(API_OPERATIONS.uploadImage), {
body: formData,
- method: endpoint.method
+ method: endpoint.method,
+ });
+}
+
+export function uploadImagesBatch(files: File[]): Promise {
+ const formData = new FormData();
+ files.forEach((file) => {
+ formData.append("files", file);
+ });
+
+ return apiRequest("/v1/admin/files/image/batch-upload", {
+ body: formData,
+ method: "POST",
});
}