diff --git a/src/features/resources/api.test.ts b/src/features/resources/api.test.ts index eb46ecf..a460a95 100644 --- a/src/features/resources/api.test.ts +++ b/src/features/resources/api.test.ts @@ -26,6 +26,7 @@ import { updateGift, updateGiftTypes, updateResource, + updateResourceMp4Layouts, updateResourceGroup, upsertResourceShopItems, } from "./api"; @@ -255,6 +256,25 @@ test("resource APIs use generated admin paths", async () => { expect(JSON.parse(String(grantGroupInit?.body))).toMatchObject({ groupId: 22, targetUserId: 1001 }); }); +test("resource mp4 layout batch API uses narrow update endpoint", async () => { + vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify({ code: 0, data: [] })))); + + await updateResourceMp4Layouts([ + { + metadataJson: + '{"animation_format":"mp4","mp4_alpha_layout":{"alpha_layout":"normal","video_w":750,"video_h":1334,"rgb_frame":[0,0,750,1334],"alpha_frame":null,"confirmed":true,"detect_version":1}}', + resourceId: 11, + }, + ]); + + const [url, init] = vi.mocked(fetch).mock.calls[0]; + expect(String(url)).toContain("/api/v1/admin/resources/mp4-layouts"); + expect(init?.method).toBe("PUT"); + expect(JSON.parse(String(init?.body))).toMatchObject({ + items: [{ resourceId: 11 }], + }); +}); + test("emoji pack APIs use generated admin paths", async () => { vi.stubGlobal( "fetch", diff --git a/src/features/resources/api.ts b/src/features/resources/api.ts index 9d9c941..e9facc1 100644 --- a/src/features/resources/api.ts +++ b/src/features/resources/api.ts @@ -48,6 +48,15 @@ export interface ResourcePayload { status: string; } +export interface ResourceMp4LayoutUpdatePayload { + metadataJson: string; + resourceId: EntityId; +} + +export interface ResourceMp4LayoutsPayload { + items: ResourceMp4LayoutUpdatePayload[]; +} + export interface EmojiPackDto { appCode?: string; resourceId: number; @@ -302,6 +311,13 @@ export function updateResource(resourceId: EntityId, payload: ResourcePayload): ); } +export function updateResourceMp4Layouts(items: ResourceMp4LayoutUpdatePayload[]): Promise { + return apiRequest("/v1/admin/resources/mp4-layouts", { + body: { items }, + method: "PUT", + }); +} + export function enableResource(resourceId: EntityId): Promise { const endpoint = API_ENDPOINTS.enableResource; return apiRequest(apiEndpointPath(API_OPERATIONS.enableResource, { resource_id: resourceId }), { diff --git a/src/features/resources/pages/ResourceListPage.jsx b/src/features/resources/pages/ResourceListPage.jsx index 1b1db69..682c748 100644 --- a/src/features/resources/pages/ResourceListPage.jsx +++ b/src/features/resources/pages/ResourceListPage.jsx @@ -4,13 +4,14 @@ import FileUploadOutlined from "@mui/icons-material/FileUploadOutlined"; import HelpOutlineOutlined from "@mui/icons-material/HelpOutlineOutlined"; import Inventory2Outlined from "@mui/icons-material/Inventory2Outlined"; import RefreshOutlined from "@mui/icons-material/RefreshOutlined"; +import Checkbox from "@mui/material/Checkbox"; import CircularProgress from "@mui/material/CircularProgress"; import LinearProgress from "@mui/material/LinearProgress"; import MenuItem from "@mui/material/MenuItem"; import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx"; import TextField from "@mui/material/TextField"; import Tooltip from "@mui/material/Tooltip"; -import { useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { AdminFormDialog, AdminFormFieldGrid, @@ -20,6 +21,7 @@ import { import { DataState } from "@/shared/ui/DataState.jsx"; import { DataTable } from "@/shared/ui/DataTable.jsx"; import { UploadField } from "@/shared/ui/UploadField.jsx"; +import { useConfirm } from "@/shared/ui/ConfirmProvider.jsx"; import { AdminActionIconButton, AdminListBody, @@ -43,7 +45,7 @@ import { resourceTypeFilters, resourceTypeLabels, } from "@/features/resources/constants.js"; -import { createResource } from "@/features/resources/api"; +import { createResource, updateResourceMp4Layouts } from "@/features/resources/api"; import { parseResourceFolderFiles, resourceBatchUploadSize, @@ -130,57 +132,130 @@ const baseColumns = [ export function ResourceListPage() { const page = useResourceListPage(); + const confirm = useConfirm(); const { showToast } = useToast(); const folderInputRef = useRef(null); const [batchOpen, setBatchOpen] = useState(false); const [batchPlan, setBatchPlan] = useState({ errors: [], resources: [] }); const [batchProgress, setBatchProgress] = useState(emptyBatchProgress); - const items = page.data.items || []; + const [batchMp4Progress, setBatchMp4Progress] = useState(emptyBatchProgress); + const [selectedResourceIds, setSelectedResourceIds] = useState([]); + const rawItems = page.data.items; + const items = useMemo(() => rawItems || [], [rawItems]); const total = page.data.total || 0; const createDisabled = !page.abilities.canCreate || !page.abilities.canUpload; - const columns = baseColumns.map((column) => - column.key === "resource" - ? { - ...column, - filter: createTextColumnFilter({ - placeholder: "搜索资源名称、资源编码", - value: page.query, - onChange: page.changeQuery, - }), - } - : column.key === "type" - ? { - ...column, - filter: createOptionsColumnFilter({ - options: resourceTypeFilters, - placeholder: "搜索类型", - value: page.resourceType, - onChange: page.changeResourceType, - }), - } - : column.key === "status" + const selectedIdSet = useMemo(() => new Set(selectedResourceIds), [selectedResourceIds]); + const selectedResources = useMemo( + () => items.filter((resource) => selectedIdSet.has(resource.resourceId)), + [items, selectedIdSet], + ); + const selectedMp4Resources = useMemo( + () => selectedResources.filter((resource) => mp4ResourceAnimationSource(resource)), + [selectedResources], + ); + const allChecked = items.length > 0 && items.every((resource) => selectedIdSet.has(resource.resourceId)); + const selectedOnPageCount = items.filter((resource) => selectedIdSet.has(resource.resourceId)).length; + const someChecked = selectedOnPageCount > 0 && !allChecked; + const batchMp4Running = batchMp4Progress.running; + + useEffect(() => { + const itemIds = new Set(items.map((resource) => resource.resourceId)); + setSelectedResourceIds((current) => { + const next = current.filter((resourceId) => itemIds.has(resourceId)); + return next.length === current.length ? current : next; + }); + }, [items]); + + const toggleAllResources = (checked) => { + const pageIds = items.map((resource) => resource.resourceId); + setSelectedResourceIds((current) => { + if (!checked) { + return current.filter((resourceId) => !pageIds.includes(resourceId)); + } + return Array.from(new Set([...current, ...pageIds])); + }); + }; + + const toggleResourceSelection = (resource, checked) => { + setSelectedResourceIds((current) => + checked + ? Array.from(new Set([...current, resource.resourceId])) + : current.filter((resourceId) => resourceId !== resource.resourceId), + ); + }; + + const selectionColumn = { + key: "select", + className: styles.selectColumn, + header: ( + toggleAllResources(event.target.checked)} + /> + ), + resizable: false, + width: "56px", + render: (resource) => { + return ( + toggleResourceSelection(resource, event.target.checked)} + /> + ); + }, + }; + const columns = [ + selectionColumn, + ...baseColumns.map((column) => + column.key === "resource" ? { ...column, - filter: createOptionsColumnFilter({ - options: resourceStatusFilters, - placeholder: "搜索状态", - value: page.status, - onChange: page.changeStatus, + filter: createTextColumnFilter({ + placeholder: "搜索资源名称、资源编码", + value: page.query, + onChange: page.changeQuery, }), - render: (resource) => , } - : column.key === "managerGrant" + : column.key === "type" ? { ...column, - render: (resource) => , + filter: createOptionsColumnFilter({ + options: resourceTypeFilters, + placeholder: "搜索类型", + value: page.resourceType, + onChange: page.changeResourceType, + }), } - : column.key === "actions" + : column.key === "status" ? { ...column, - render: (resource) => , + filter: createOptionsColumnFilter({ + options: resourceStatusFilters, + placeholder: "搜索状态", + value: page.status, + onChange: page.changeStatus, + }), + render: (resource) => , } - : column, - ); + : column.key === "managerGrant" + ? { + ...column, + render: (resource) => , + } + : column.key === "actions" + ? { + ...column, + render: (resource) => , + } + : column, + ), + ]; const openBatchDialog = () => { setBatchPlan({ errors: [], resources: [] }); @@ -260,6 +335,77 @@ export function ResourceListPage() { })); }; + const recalculateSelectedMp4Layouts = async () => { + if (!page.abilities.canUpdate || !selectedResources.length || batchMp4Running) { + return; + } + const ignoredCount = selectedResources.length - selectedMp4Resources.length; + if (!selectedMp4Resources.length) { + showToast("所选资源里没有 MP4,已自动忽略", "warning"); + return; + } + const updates = []; + const failures = []; + const layoutCounts = { normal: 0, transparent: 0 }; + setBatchMp4Progress({ label: "批量重算 MP4 布局", running: true, value: 0 }); + for (let index = 0; index < selectedMp4Resources.length; index += 1) { + const resource = selectedMp4Resources[index]; + try { + setBatchMp4Progress({ + label: `重算 MP4 布局 ${index + 1}/${selectedMp4Resources.length}`, + running: true, + value: Math.round((index / selectedMp4Resources.length) * 80), + }); + const file = await mp4FileFromSource(mp4ResourceAnimationSource(resource)); + const layout = await detectMp4AlphaLayout(file); + const metadataJson = confirmMp4AlphaLayoutMetadata( + mergeMp4AlphaLayoutMetadata(resource.metadataJson, layout), + ); + const confirmedLayout = mp4AlphaLayoutFromMetadata(metadataJson); + if (!confirmedLayout?.confirmed) { + throw new Error("MP4 布局确认失败"); + } + if (confirmedLayout.alpha_layout === "normal") { + layoutCounts.normal += 1; + } else { + layoutCounts.transparent += 1; + } + updates.push({ metadataJson, resourceId: resource.resourceId }); + } catch (err) { + failures.push(`${resource.name || resource.resourceCode || resource.resourceId}: ${err.message || "解析失败"}`); + } + } + if (failures.length) { + setBatchMp4Progress(emptyBatchProgress); + showToast(`MP4 批量重算失败:${failures.slice(0, 3).join(";")}`, "error"); + return; + } + if (!updates.length) { + setBatchMp4Progress(emptyBatchProgress); + return; + } + const accepted = await confirm({ + confirmText: "写入", + message: `检测完成:普通 MP4 ${layoutCounts.normal} 个,透明 MP4 ${layoutCounts.transparent} 个,非 MP4 自动忽略 ${ignoredCount} 个。`, + title: "写入 MP4 布局", + }); + if (!accepted) { + setBatchMp4Progress(emptyBatchProgress); + return; + } + try { + setBatchMp4Progress({ label: `保存 MP4 布局 ${updates.length} 个`, running: true, value: 90 }); + await updateResourceMp4Layouts(updates); + showToast(`已更新 ${updates.length} 个 MP4 布局`, "success"); + setSelectedResourceIds([]); + await page.reload(); + } catch (err) { + showToast(err.message || "MP4 布局批量保存失败", "error"); + } finally { + setBatchMp4Progress(emptyBatchProgress); + } + }; + const submitBatchUpload = async (event) => { event.preventDefault(); if (!batchPlan.resources.length || batchProgress.running) { @@ -351,18 +497,39 @@ export function ResourceListPage() { - - - - - - + {page.abilities.canUpdate ? ( + + {batchMp4Running ? ( + + ) : ( + + )} + + ) : null} + {page.abilities.canCreate ? ( + <> + + + + + + + + ) : null} ) : null } @@ -372,7 +539,7 @@ export function ResourceListPage() { 0 ? { diff --git a/src/features/resources/resources.module.css b/src/features/resources/resources.module.css index 4913fef..55442c1 100644 --- a/src/features/resources/resources.module.css +++ b/src/features/resources/resources.module.css @@ -68,6 +68,10 @@ white-space: nowrap; } +.selectColumn { + justify-content: center; +} + .dateTimeField { width: 100%; }