批量重算

This commit is contained in:
zhx 2026-06-09 13:41:26 +08:00
parent 8a6e9dc18d
commit fcd44f8c13
4 changed files with 255 additions and 48 deletions

View File

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

View File

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

View File

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

View File

@ -68,6 +68,10 @@
white-space: nowrap;
}
.selectColumn {
justify-content: center;
}
.dateTimeField {
width: 100%;
}