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