批量重算
This commit is contained in:
parent
8a6e9dc18d
commit
fcd44f8c13
@ -26,6 +26,7 @@ import {
|
|||||||
updateGift,
|
updateGift,
|
||||||
updateGiftTypes,
|
updateGiftTypes,
|
||||||
updateResource,
|
updateResource,
|
||||||
|
updateResourceMp4Layouts,
|
||||||
updateResourceGroup,
|
updateResourceGroup,
|
||||||
upsertResourceShopItems,
|
upsertResourceShopItems,
|
||||||
} from "./api";
|
} 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 });
|
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 () => {
|
test("emoji pack APIs use generated admin paths", async () => {
|
||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
"fetch",
|
"fetch",
|
||||||
|
|||||||
@ -48,6 +48,15 @@ export interface ResourcePayload {
|
|||||||
status: string;
|
status: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ResourceMp4LayoutUpdatePayload {
|
||||||
|
metadataJson: string;
|
||||||
|
resourceId: EntityId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResourceMp4LayoutsPayload {
|
||||||
|
items: ResourceMp4LayoutUpdatePayload[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface EmojiPackDto {
|
export interface EmojiPackDto {
|
||||||
appCode?: string;
|
appCode?: string;
|
||||||
resourceId: number;
|
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> {
|
export function enableResource(resourceId: EntityId): Promise<ResourceDto> {
|
||||||
const endpoint = API_ENDPOINTS.enableResource;
|
const endpoint = API_ENDPOINTS.enableResource;
|
||||||
return apiRequest<ResourceDto>(apiEndpointPath(API_OPERATIONS.enableResource, { resource_id: resourceId }), {
|
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 HelpOutlineOutlined from "@mui/icons-material/HelpOutlineOutlined";
|
||||||
import Inventory2Outlined from "@mui/icons-material/Inventory2Outlined";
|
import Inventory2Outlined from "@mui/icons-material/Inventory2Outlined";
|
||||||
import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
|
import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
|
||||||
|
import Checkbox from "@mui/material/Checkbox";
|
||||||
import CircularProgress from "@mui/material/CircularProgress";
|
import CircularProgress from "@mui/material/CircularProgress";
|
||||||
import LinearProgress from "@mui/material/LinearProgress";
|
import LinearProgress from "@mui/material/LinearProgress";
|
||||||
import MenuItem from "@mui/material/MenuItem";
|
import MenuItem from "@mui/material/MenuItem";
|
||||||
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
|
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
|
||||||
import TextField from "@mui/material/TextField";
|
import TextField from "@mui/material/TextField";
|
||||||
import Tooltip from "@mui/material/Tooltip";
|
import Tooltip from "@mui/material/Tooltip";
|
||||||
import { useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
AdminFormDialog,
|
AdminFormDialog,
|
||||||
AdminFormFieldGrid,
|
AdminFormFieldGrid,
|
||||||
@ -20,6 +21,7 @@ import {
|
|||||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||||
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||||
import { UploadField } from "@/shared/ui/UploadField.jsx";
|
import { UploadField } from "@/shared/ui/UploadField.jsx";
|
||||||
|
import { useConfirm } from "@/shared/ui/ConfirmProvider.jsx";
|
||||||
import {
|
import {
|
||||||
AdminActionIconButton,
|
AdminActionIconButton,
|
||||||
AdminListBody,
|
AdminListBody,
|
||||||
@ -43,7 +45,7 @@ import {
|
|||||||
resourceTypeFilters,
|
resourceTypeFilters,
|
||||||
resourceTypeLabels,
|
resourceTypeLabels,
|
||||||
} from "@/features/resources/constants.js";
|
} from "@/features/resources/constants.js";
|
||||||
import { createResource } from "@/features/resources/api";
|
import { createResource, updateResourceMp4Layouts } from "@/features/resources/api";
|
||||||
import {
|
import {
|
||||||
parseResourceFolderFiles,
|
parseResourceFolderFiles,
|
||||||
resourceBatchUploadSize,
|
resourceBatchUploadSize,
|
||||||
@ -130,57 +132,130 @@ const baseColumns = [
|
|||||||
|
|
||||||
export function ResourceListPage() {
|
export function ResourceListPage() {
|
||||||
const page = useResourceListPage();
|
const page = useResourceListPage();
|
||||||
|
const confirm = useConfirm();
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
const folderInputRef = useRef(null);
|
const folderInputRef = useRef(null);
|
||||||
const [batchOpen, setBatchOpen] = useState(false);
|
const [batchOpen, setBatchOpen] = useState(false);
|
||||||
const [batchPlan, setBatchPlan] = useState({ errors: [], resources: [] });
|
const [batchPlan, setBatchPlan] = useState({ errors: [], resources: [] });
|
||||||
const [batchProgress, setBatchProgress] = useState(emptyBatchProgress);
|
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 total = page.data.total || 0;
|
||||||
const createDisabled = !page.abilities.canCreate || !page.abilities.canUpload;
|
const createDisabled = !page.abilities.canCreate || !page.abilities.canUpload;
|
||||||
const columns = baseColumns.map((column) =>
|
const selectedIdSet = useMemo(() => new Set(selectedResourceIds), [selectedResourceIds]);
|
||||||
column.key === "resource"
|
const selectedResources = useMemo(
|
||||||
? {
|
() => items.filter((resource) => selectedIdSet.has(resource.resourceId)),
|
||||||
...column,
|
[items, selectedIdSet],
|
||||||
filter: createTextColumnFilter({
|
);
|
||||||
placeholder: "搜索资源名称、资源编码",
|
const selectedMp4Resources = useMemo(
|
||||||
value: page.query,
|
() => selectedResources.filter((resource) => mp4ResourceAnimationSource(resource)),
|
||||||
onChange: page.changeQuery,
|
[selectedResources],
|
||||||
}),
|
);
|
||||||
}
|
const allChecked = items.length > 0 && items.every((resource) => selectedIdSet.has(resource.resourceId));
|
||||||
: column.key === "type"
|
const selectedOnPageCount = items.filter((resource) => selectedIdSet.has(resource.resourceId)).length;
|
||||||
? {
|
const someChecked = selectedOnPageCount > 0 && !allChecked;
|
||||||
...column,
|
const batchMp4Running = batchMp4Progress.running;
|
||||||
filter: createOptionsColumnFilter({
|
|
||||||
options: resourceTypeFilters,
|
useEffect(() => {
|
||||||
placeholder: "搜索类型",
|
const itemIds = new Set(items.map((resource) => resource.resourceId));
|
||||||
value: page.resourceType,
|
setSelectedResourceIds((current) => {
|
||||||
onChange: page.changeResourceType,
|
const next = current.filter((resourceId) => itemIds.has(resourceId));
|
||||||
}),
|
return next.length === current.length ? current : next;
|
||||||
}
|
});
|
||||||
: column.key === "status"
|
}, [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,
|
...column,
|
||||||
filter: createOptionsColumnFilter({
|
filter: createTextColumnFilter({
|
||||||
options: resourceStatusFilters,
|
placeholder: "搜索资源名称、资源编码",
|
||||||
placeholder: "搜索状态",
|
value: page.query,
|
||||||
value: page.status,
|
onChange: page.changeQuery,
|
||||||
onChange: page.changeStatus,
|
|
||||||
}),
|
}),
|
||||||
render: (resource) => <ResourceStatusSwitch page={page} resource={resource} />,
|
|
||||||
}
|
}
|
||||||
: column.key === "managerGrant"
|
: column.key === "type"
|
||||||
? {
|
? {
|
||||||
...column,
|
...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,
|
...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 = () => {
|
const openBatchDialog = () => {
|
||||||
setBatchPlan({ errors: [], resources: [] });
|
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) => {
|
const submitBatchUpload = async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!batchPlan.resources.length || batchProgress.running) {
|
if (!batchPlan.resources.length || batchProgress.running) {
|
||||||
@ -351,18 +497,39 @@ export function ResourceListPage() {
|
|||||||
<AdminListPage>
|
<AdminListPage>
|
||||||
<AdminListToolbar
|
<AdminListToolbar
|
||||||
actions={
|
actions={
|
||||||
page.abilities.canCreate ? (
|
page.abilities.canCreate || page.abilities.canUpdate ? (
|
||||||
<>
|
<>
|
||||||
<AdminActionIconButton
|
{page.abilities.canUpdate ? (
|
||||||
disabled={createDisabled}
|
<AdminActionIconButton
|
||||||
label="批量上传资源"
|
disabled={!selectedResources.length || batchMp4Running}
|
||||||
onClick={openBatchDialog}
|
label={
|
||||||
>
|
batchMp4Running
|
||||||
<FileUploadOutlined fontSize="small" />
|
? batchMp4Progress.label || "批量重算 MP4"
|
||||||
</AdminActionIconButton>
|
: `批量重算 MP4(${selectedMp4Resources.length}/${selectedResources.length})`
|
||||||
<AdminActionIconButton label="添加资源" primary onClick={page.openCreateResource}>
|
}
|
||||||
<Add fontSize="small" />
|
onClick={recalculateSelectedMp4Layouts}
|
||||||
</AdminActionIconButton>
|
>
|
||||||
|
{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
|
) : null
|
||||||
}
|
}
|
||||||
@ -372,7 +539,7 @@ export function ResourceListPage() {
|
|||||||
<DataTable
|
<DataTable
|
||||||
columns={columns}
|
columns={columns}
|
||||||
items={items}
|
items={items}
|
||||||
minWidth="1060px"
|
minWidth="1120px"
|
||||||
pagination={
|
pagination={
|
||||||
total > 0
|
total > 0
|
||||||
? {
|
? {
|
||||||
|
|||||||
@ -68,6 +68,10 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.selectColumn {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
.dateTimeField {
|
.dateTimeField {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user