批量重算
This commit is contained in:
parent
8a6e9dc18d
commit
fcd44f8c13
@ -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",
|
||||
|
||||
@ -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<ResourceDto[]> {
|
||||
return apiRequest<ResourceDto[], ResourceMp4LayoutsPayload>("/v1/admin/resources/mp4-layouts", {
|
||||
body: { items },
|
||||
method: "PUT",
|
||||
});
|
||||
}
|
||||
|
||||
export function enableResource(resourceId: EntityId): Promise<ResourceDto> {
|
||||
const endpoint = API_ENDPOINTS.enableResource;
|
||||
return apiRequest<ResourceDto>(apiEndpointPath(API_OPERATIONS.enableResource, { resource_id: resourceId }), {
|
||||
|
||||
@ -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: (
|
||||
<Checkbox
|
||||
checked={allChecked}
|
||||
disabled={!page.abilities.canUpdate || items.length === 0 || batchMp4Running}
|
||||
indeterminate={someChecked}
|
||||
size="small"
|
||||
onChange={(event) => toggleAllResources(event.target.checked)}
|
||||
/>
|
||||
),
|
||||
resizable: false,
|
||||
width: "56px",
|
||||
render: (resource) => {
|
||||
return (
|
||||
<Checkbox
|
||||
checked={selectedIdSet.has(resource.resourceId)}
|
||||
disabled={!page.abilities.canUpdate || batchMp4Running}
|
||||
inputProps={{ "aria-label": `选择 ${resource.name || resource.resourceCode || resource.resourceId}` }}
|
||||
size="small"
|
||||
onChange={(event) => 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) => <ResourceStatusSwitch page={page} resource={resource} />,
|
||||
}
|
||||
: column.key === "managerGrant"
|
||||
: column.key === "type"
|
||||
? {
|
||||
...column,
|
||||
render: (resource) => <ManagerGrantSwitch page={page} resource={resource} />,
|
||||
filter: createOptionsColumnFilter({
|
||||
options: resourceTypeFilters,
|
||||
placeholder: "搜索类型",
|
||||
value: page.resourceType,
|
||||
onChange: page.changeResourceType,
|
||||
}),
|
||||
}
|
||||
: column.key === "actions"
|
||||
: column.key === "status"
|
||||
? {
|
||||
...column,
|
||||
render: (resource) => <ResourceRowActions page={page} resource={resource} />,
|
||||
filter: createOptionsColumnFilter({
|
||||
options: resourceStatusFilters,
|
||||
placeholder: "搜索状态",
|
||||
value: page.status,
|
||||
onChange: page.changeStatus,
|
||||
}),
|
||||
render: (resource) => <ResourceStatusSwitch page={page} resource={resource} />,
|
||||
}
|
||||
: column,
|
||||
);
|
||||
: column.key === "managerGrant"
|
||||
? {
|
||||
...column,
|
||||
render: (resource) => <ManagerGrantSwitch page={page} resource={resource} />,
|
||||
}
|
||||
: column.key === "actions"
|
||||
? {
|
||||
...column,
|
||||
render: (resource) => <ResourceRowActions page={page} resource={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() {
|
||||
<AdminListPage>
|
||||
<AdminListToolbar
|
||||
actions={
|
||||
page.abilities.canCreate ? (
|
||||
page.abilities.canCreate || page.abilities.canUpdate ? (
|
||||
<>
|
||||
<AdminActionIconButton
|
||||
disabled={createDisabled}
|
||||
label="批量上传资源"
|
||||
onClick={openBatchDialog}
|
||||
>
|
||||
<FileUploadOutlined fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
<AdminActionIconButton label="添加资源" primary onClick={page.openCreateResource}>
|
||||
<Add fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
{page.abilities.canUpdate ? (
|
||||
<AdminActionIconButton
|
||||
disabled={!selectedResources.length || batchMp4Running}
|
||||
label={
|
||||
batchMp4Running
|
||||
? batchMp4Progress.label || "批量重算 MP4"
|
||||
: `批量重算 MP4(${selectedMp4Resources.length}/${selectedResources.length})`
|
||||
}
|
||||
onClick={recalculateSelectedMp4Layouts}
|
||||
>
|
||||
{batchMp4Running ? (
|
||||
<CircularProgress size={16} />
|
||||
) : (
|
||||
<RefreshOutlined fontSize="small" />
|
||||
)}
|
||||
</AdminActionIconButton>
|
||||
) : null}
|
||||
{page.abilities.canCreate ? (
|
||||
<>
|
||||
<AdminActionIconButton
|
||||
disabled={createDisabled}
|
||||
label="批量上传资源"
|
||||
onClick={openBatchDialog}
|
||||
>
|
||||
<FileUploadOutlined fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
<AdminActionIconButton label="添加资源" primary onClick={page.openCreateResource}>
|
||||
<Add fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
) : null
|
||||
}
|
||||
@ -372,7 +539,7 @@ export function ResourceListPage() {
|
||||
<DataTable
|
||||
columns={columns}
|
||||
items={items}
|
||||
minWidth="1060px"
|
||||
minWidth="1120px"
|
||||
pagination={
|
||||
total > 0
|
||||
? {
|
||||
|
||||
@ -68,6 +68,10 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.selectColumn {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.dateTimeField {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user