admin 增加批量上传
This commit is contained in:
parent
6ddf5ee211
commit
b8983e7b4e
@ -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": {
|
||||
|
||||
@ -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} />
|
||||
|
||||
@ -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: [
|
||||
|
||||
@ -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) => (
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 },
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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));
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
138
src/features/resources/batchUpload.js
Normal file
138
src/features/resources/batchUpload.js
Normal 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(/\.[^.]+$/, "");
|
||||
}
|
||||
55
src/features/resources/batchUpload.test.js
Normal file
55
src/features/resources/batchUpload.test.js
Normal 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" });
|
||||
}
|
||||
@ -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", "麦位动效"],
|
||||
|
||||
@ -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 "";
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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"]
|
||||
},
|
||||
|
||||
12
src/shared/api/generated/schema.d.ts
vendored
12
src/shared/api/generated/schema.d.ts
vendored
@ -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;
|
||||
|
||||
@ -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");
|
||||
});
|
||||
|
||||
@ -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",
|
||||
});
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user