admin 增加批量上传

This commit is contained in:
zhx 2026-06-02 15:54:00 +08:00
parent 6ddf5ee211
commit b8983e7b4e
20 changed files with 825 additions and 225 deletions

View File

@ -132,7 +132,7 @@
"x-permissions": ["user-leaderboard:view"]
}
},
"/resident-activity/voice-room-red-packet/records": {
"/admin/activity/red-packets": {
"get": {
"operationId": "listRedPackets",
"responses": {
@ -144,7 +144,7 @@
"x-permissions": ["red-packet:view"]
}
},
"/resident-activity/voice-room-red-packet/records/{packet_id}": {
"/admin/activity/red-packets/{packet_id}": {
"get": {
"operationId": "getRedPacket",
"responses": {
@ -166,7 +166,7 @@
"x-permissions": ["red-packet:view"]
}
},
"/resident-activity/voice-room-red-packet/config": {
"/admin/activity/red-packets/config": {
"get": {
"operationId": "getRedPacketConfig",
"responses": {
@ -177,7 +177,7 @@
"x-permission": "red-packet:view",
"x-permissions": ["red-packet:view"]
},
"post": {
"put": {
"operationId": "updateRedPacketConfig",
"responses": {
"200": {
@ -188,7 +188,7 @@
"x-permissions": ["red-packet:update"]
}
},
"/resident-activity/voice-room-red-packet/refund/retry": {
"/admin/activity/red-packets/refund/retry": {
"post": {
"operationId": "retryRedPacketRefund",
"responses": {

View File

@ -3,12 +3,9 @@ import { fetchStatisticsOverview, getCurrentAppCode } from "./api.js";
import { CountryPerformanceTable } from "./components/CountryPerformanceTable.jsx";
import { DatabiHeader } from "./components/DatabiHeader.jsx";
import { FunnelPanel } from "./components/FunnelPanel.jsx";
import { GameEconomyPanel } from "./components/GameEconomyPanel.jsx";
import { GlobalOverviewPanel } from "./components/GlobalOverviewPanel.jsx";
import { LuckyGiftPanel } from "./components/LuckyGiftPanel.jsx";
import { MetricCard } from "./components/MetricCard.jsx";
import { RevenueTrendPanel } from "./components/RevenueTrendPanel.jsx";
import { RoomGiftPanel } from "./components/RoomGiftPanel.jsx";
import { createDashboardModel } from "./data/createDashboardModel.js";
import { sampleOverview } from "./data/sampleOverview.js";
import { endOfDay, lastDaysRange, startOfDay } from "./utils/time.js";
@ -74,17 +71,17 @@ export function DatabiApp() {
))}
</section>
<section className="business-metric-grid" aria-label="业务指标">
{model.businessKpis.map((item) => (
<MetricCard key={item.label} item={item} />
))}
</section>
<section className="databi-grid databi-grid--top">
<GlobalOverviewPanel countryBreakdown={model.countryBreakdown} topCountries={model.topCountries} />
<RevenueTrendPanel revenueSeries={model.revenueSeries} />
<FunnelPanel funnel={model.funnel} sideMetrics={model.sideMetrics} />
</section>
<section className="databi-grid databi-grid--middle">
<RoomGiftPanel giftRanking={model.giftRanking} roomMetrics={model.roomMetrics} />
<LuckyGiftPanel luckyMetrics={model.luckyMetrics} payoutDistribution={model.payoutDistribution} />
<GameEconomyPanel gameMetrics={model.gameMetrics} gameRanking={model.gameRanking} />
</section>
</section>
<CountryPerformanceTable gameRanking={model.gameRanking} giftRanking={model.giftRanking} items={model.countryBreakdown} />

View File

@ -8,7 +8,6 @@ export function createGeoOption(items) {
return {
animationDuration: 650,
geo: {
bottom: 0,
center: [38, 12],
emphasis: { disabled: true },
itemStyle: {
@ -17,12 +16,11 @@ export function createGeoOption(items) {
borderWidth: 0.65
},
label: { show: false },
left: 0,
layoutCenter: ["50%", "51%"],
layoutSize: "112%",
map: "databi-world",
right: 0,
roam: false,
silent: true,
top: 0,
zoom: 1.04
},
series: [

View File

@ -4,7 +4,7 @@ import { Panel } from "./Panel.jsx";
export function FunnelPanel({ funnel, sideMetrics }) {
return (
<Panel title="ARPU 与付费转化漏斗">
<Panel title="ARPU 转化漏斗">
<div className="funnel-layout">
<div className="funnel-stack">
{funnel.map((item, index) => (

View File

@ -1,8 +1,8 @@
export function MetricCard({ item }) {
const Icon = item.icon;
return (
<article className="metric-card">
<div className="metric-icon">{Icon ? <Icon /> : null}</div>
<article className={["metric-card", Icon ? "" : "metric-card--no-icon"].filter(Boolean).join(" ")}>
{Icon ? <div className="metric-icon"><Icon /></div> : null}
<div className="metric-content">
<span>{item.label}</span>
<strong>{item.value}</strong>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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) => (
<div className={styles.stack}>
<span>{packet.roomId || "-"}</span>
@ -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}
/>
<AdminListToolbar
actions={<AdminFilterResetButton label="重置筛选" onClick={page.resetFilters} />}
filters={
<>
<TextField
label="房间 ID"
size="small"
value={page.roomId}
onChange={(event) => page.setRoomId(event.target.value)}
/>
<TextField
label="发送人 ID"
size="small"
value={page.senderUserId}
onChange={(event) => page.setSenderUserId(event.target.value)}
/>
<TextField
select
label="类型"
size="small"
value={page.packetType}
onChange={(event) => page.setPacketType(event.target.value)}
>
{packetTypeOptions.map(([value, label]) => (
<MenuItem key={value || "all"} value={value}>
{label}
</MenuItem>
))}
</TextField>
<TextField
select
label="状态"
size="small"
value={page.status}
onChange={(event) => page.setStatus(event.target.value)}
>
{statusOptions.map(([value, label]) => (
<MenuItem key={value || "all"} value={value}>
{label}
</MenuItem>
))}
</TextField>
</>
}
/>
<DataState error={page.packetsError} loading={page.packetsLoading} onRetry={page.reloadPackets}>
<AdminListBody>
<DataTable

View File

@ -0,0 +1,138 @@
export const resourceBatchUploadSize = 10;
const resourceTypeAliases = new Map([
["头像框", "avatar_frame"],
["坐骑", "vehicle"],
["座驾", "vehicle"],
["气泡", "chat_bubble"],
["勋章", "badge"],
["徽章", "badge"],
["飘窗", "floating_screen"],
["飘屏", "floating_screen"],
["mic声波", "mic_seat_animation"],
["麦位动效", "mic_seat_animation"],
["背景卡", "profile_card"],
["资料卡", "profile_card"],
]);
const pricedResourceTypes = new Set(["avatar_frame", "vehicle"]);
export function parseResourceFolderFiles(fileList, now = Date.now()) {
const files = Array.from(fileList || []).filter((file) => 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(/\.[^.]+$/, "");
}

View File

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

View File

@ -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", "麦位动效"],

View File

@ -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 (
<AdminListPage>
<AdminListToolbar
actions={
page.abilities.canCreate ? (
<AdminActionIconButton label="添加资源" primary onClick={page.openCreateResource}>
<Add fontSize="small" />
</AdminActionIconButton>
<>
<AdminActionIconButton
disabled={createDisabled}
label="批量上传资源"
onClick={openBatchDialog}
>
<FileUploadOutlined fontSize="small" />
</AdminActionIconButton>
<AdminActionIconButton label="添加资源" primary onClick={page.openCreateResource}>
<Add fontSize="small" />
</AdminActionIconButton>
</>
) : null
}
/>
@ -169,10 +259,120 @@ export function ResourceListPage() {
onClose={page.closeAction}
onSubmit={page.submitResource}
/>
<ResourceBatchUploadDialog
disabled={createDisabled}
fileInputRef={folderInputRef}
open={batchOpen}
plan={batchPlan}
progress={batchProgress}
onClose={closeBatchDialog}
onFileChange={handleFolderChange}
onSubmit={submitBatchUpload}
/>
</AdminListPage>
);
}
function ResourceBatchUploadDialog({ disabled, fileInputRef, onClose, onFileChange, onSubmit, open, plan, progress }) {
const submitDisabled = disabled || progress.running || !plan.resources.length;
return (
<AdminFormDialog
loading={progress.running}
open={open}
size="wide"
submitDisabled={submitDisabled}
submitLabel="开始上传"
title="批量上传资源"
onClose={onClose}
onSubmit={onSubmit}
>
<AdminFormSection
actions={
<div className={styles.batchActions}>
<Tooltip arrow placement="top" title={<ResourceBatchRules />}>
<span className={styles.batchHelp} aria-label="文件规则" role="button" tabIndex={0}>
<HelpOutlineOutlined fontSize="small" />
</span>
</Tooltip>
<button
className={styles.batchPickButton}
disabled={disabled || progress.running}
type="button"
onClick={() => fileInputRef.current?.click()}
>
<FileUploadOutlined fontSize="small" />
选择文件夹
</button>
</div>
}
title="文件夹资源"
>
<input
ref={fileInputRef}
className={styles.batchInput}
directory=""
multiple
type="file"
webkitdirectory=""
onChange={onFileChange}
/>
{progress.running ? (
<div className={styles.batchProgress}>
<CircularProgress color="inherit" size={16} />
{progress.label}
</div>
) : null}
<ResourceBatchPreview resources={plan.resources} />
</AdminFormSection>
</AdminFormDialog>
);
}
function ResourceBatchRules() {
return (
<div className={styles.batchRules}>
<div>只读取符合命名规则且同时存在 cover/animation 的资源其他文件会被忽略</div>
<div>头像框_名称_价格_天数_cover / animation</div>
<div>坐骑_名称_价格_天数_cover / animation</div>
<div>气泡_名称_cover / animation</div>
<div>勋章_名称_长_cover / animation</div>
<div>飘窗_名称_cover / animation</div>
<div>mic声波_名称_cover / animation</div>
<div>背景卡_名称_cover / animation</div>
<div>天数字段会忽略勋章不写时按短徽章处理</div>
<div>服务端单批最多上传 10 个素材前端会自动分批串行上传</div>
</div>
);
}
function ResourceBatchPreview({ resources }) {
if (!resources.length) {
return <div className={styles.batchEmpty}>尚未选择资源文件夹</div>;
}
return (
<div className={styles.batchTable}>
<div className={styles.batchTableHead}>
<span>资源</span>
<span>类型</span>
<span>价格</span>
<span>素材</span>
</div>
{resources.map((resource) => (
<div className={styles.batchTableRow} key={resource.resourceCode}>
<span>{resource.name}</span>
<span>{resourceTypeLabels[resource.resourceType] || resource.resourceType}</span>
<span>{resource.price > 0 ? `金币 ${resource.price}` : "免费"}</span>
<span>
{resourceBatchUploadRoleLabels.cover} / {resourceBatchUploadRoleLabels.animation}
</span>
</div>
))}
</div>
);
}
function ResourceRowActions({ page, resource }) {
return (
<AdminRowActions>
@ -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 "";

View File

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

View File

@ -751,14 +751,14 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
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<ApiOperationId, ApiEndpoint> = {
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<ApiOperationId, ApiEndpoint> = {
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<ApiOperationId, ApiEndpoint> = {
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"]
},

View File

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

View File

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

View File

@ -9,7 +9,7 @@ export function uploadFile(file: File): Promise<UploadResultDto> {
return apiRequest<UploadResultDto, FormData>(apiEndpointPath(API_OPERATIONS.uploadFile), {
body: formData,
method: endpoint.method
method: endpoint.method,
});
}
@ -20,6 +20,18 @@ export function uploadImage(file: File): Promise<UploadResultDto> {
return apiRequest<UploadResultDto, FormData>(apiEndpointPath(API_OPERATIONS.uploadImage), {
body: formData,
method: endpoint.method
method: endpoint.method,
});
}
export function uploadImagesBatch(files: File[]): Promise<UploadResultDto[]> {
const formData = new FormData();
files.forEach((file) => {
formData.append("files", file);
});
return apiRequest<UploadResultDto[], FormData>("/v1/admin/files/image/batch-upload", {
body: formData,
method: "POST",
});
}