diff --git a/src/features/resources/pages/ResourceListPage.jsx b/src/features/resources/pages/ResourceListPage.jsx index 4b1628e..19e7de2 100644 --- a/src/features/resources/pages/ResourceListPage.jsx +++ b/src/features/resources/pages/ResourceListPage.jsx @@ -1036,11 +1036,21 @@ function ResourceFormDialog({ disabled, form, loading, mode, onClose, onSubmit, const handleAnimationSelected = async (file) => { let detectedMp4Layout = null; if (isMp4File(file)) { - detectedMp4Layout = await detectMp4AlphaLayout(file); + try { + detectedMp4Layout = await detectMp4AlphaLayout(file); + } catch { + // 布局解析只负责补充客户端渲染元数据,失败不能把 MP4 变成不可上传格式。 + detectedMp4Layout = null; + } } let detectedProfileCardLayout = null; if (form.resourceType === "profile_card") { - detectedProfileCardLayout = await detectProfileCardLayout(file); + try { + detectedProfileCardLayout = await detectProfileCardLayout(file); + } catch { + // 部分 PAG 版本无法被浏览器侧 libpag 解码,但原文件仍可供客户端消费,继续走通用上传。 + detectedProfileCardLayout = null; + } } // 只返回当次素材的检测增量,上传成功后再合并到最新表单,避免异步旧快照覆盖用户新修改。 return { diff --git a/src/shared/ui/UploadField.jsx b/src/shared/ui/UploadField.jsx index 1fa8efe..fb2aff0 100644 --- a/src/shared/ui/UploadField.jsx +++ b/src/shared/ui/UploadField.jsx @@ -19,7 +19,6 @@ import { Mp4Preview } from "@/shared/ui/Mp4Preview.jsx"; import { useToast } from "@/shared/ui/ToastProvider.jsx"; import styles from "./UploadField.module.css"; -const imageAccept = "image/jpeg,image/png,image/webp,.jpg,.jpeg,.png,.webp,.svga,.pag"; const pagWasmURL = "/vendor/libpag.wasm"; let pagModulePromise; @@ -51,12 +50,10 @@ export function UploadField({ const [downloading, setDownloading] = useState(false); const [videoPreviewOpen, setVideoPreviewOpen] = useState(false); const isImage = kind === "image"; - const uploadMode = uploadKind || kind; - const useImageUpload = uploadMode === "image"; const source = localPreview?.url || value; const assetKind = localPreview?.assetKind || getAssetKind(value, kind); const displayName = localPreview?.name || getDisplayValue(value); - const inputAccept = accept !== undefined ? accept : useImageUpload && isImage ? imageAccept : undefined; + const inputAccept = accept || undefined; const canPreviewVideo = Boolean(source && assetKind === "mp4"); const localPreviewURL = localPreview?.url; const previewMp4Layout = Object.prototype.hasOwnProperty.call(localPreview?.selectionResult || {}, "mp4Layout") @@ -115,7 +112,9 @@ export function UploadField({ return; } setLocalPreview((previous) => (previous ? { ...previous, selectionResult } : previous)); - const result = useImageUpload ? await uploadImage(file) : await uploadFile(file); + // 图片接口会校验固定的位图格式;资源素材不能被这层校验变成格式白名单, + // 因此仅 JPEG/PNG/WebP 走图片接口,其余 PAG/SVGA/GIF/AVIF 或未知格式走通用文件接口。 + const result = shouldUploadAsImage(file, kind, uploadKind) ? await uploadImage(file) : await uploadFile(file); if (uploadGeneration !== uploadGenerationRef.current) { return; } @@ -550,6 +549,20 @@ function getAssetKind(value, kind, contentType = "") { return kind === "image" ? "image" : "file"; } +function shouldUploadAsImage(file, kind, uploadKind) { + if (uploadKind !== undefined) { + return uploadKind === "image"; + } + if (kind !== "image") { + return false; + } + const extension = getExtension(file?.name); + if (extension) { + return ["jpg", "jpeg", "png", "webp"].includes(extension); + } + return ["image/jpeg", "image/png", "image/webp"].includes(String(file?.type || "").toLowerCase()); +} + function isRasterImageExtension(extension) { return ["avif", "gif", "jpg", "jpeg", "png", "webp"].includes(extension); } diff --git a/src/shared/ui/UploadField.test.jsx b/src/shared/ui/UploadField.test.jsx index e3f3fc2..ed19855 100644 --- a/src/shared/ui/UploadField.test.jsx +++ b/src/shared/ui/UploadField.test.jsx @@ -1,5 +1,5 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react"; -import { expect, test, vi } from "vitest"; +import { beforeEach, expect, test, vi } from "vitest"; import { downloadResponse } from "@/shared/api/download"; import { uploadFile, uploadImage } from "@/shared/api/upload"; import { ToastProvider } from "@/shared/ui/ToastProvider.jsx"; @@ -14,6 +14,10 @@ vi.mock("@/shared/api/download", () => ({ downloadResponse: vi.fn(async () => undefined), })); +beforeEach(() => { + vi.clearAllMocks(); +}); + test("file upload field does not restrict picker type and uses generic upload endpoint", async () => { const onChange = vi.fn(); const onFileSelected = vi.fn(); @@ -38,6 +42,40 @@ test("file upload field does not restrict picker type and uses generic upload en expect(onChange).toHaveBeenCalledWith("https://media.haiyihy.com/admin/files/effect.bin"); }); +test("image-style resource field accepts PAG and routes it through generic upload", async () => { + const onChange = vi.fn(); + const { container } = render( + + + , + ); + const input = container.querySelector('input[type="file"]'); + const file = new File(["pag"], "effect.pag", { type: "application/octet-stream" }); + + expect(input).not.toHaveAttribute("accept"); + fireEvent.change(input, { target: { files: [file] } }); + + await waitFor(() => expect(uploadFile).toHaveBeenCalledWith(file)); + expect(uploadImage).not.toHaveBeenCalled(); + expect(onChange).toHaveBeenCalledWith("https://media.haiyihy.com/admin/files/effect.bin"); +}); + +test("standard raster image keeps the strict image upload endpoint", async () => { + const onChange = vi.fn(); + const { container } = render( + + + , + ); + const file = new File(["png"], "cover.png", { type: "image/png" }); + + fireEvent.change(container.querySelector('input[type="file"]'), { target: { files: [file] } }); + + await waitFor(() => expect(uploadImage).toHaveBeenCalledWith(file)); + expect(uploadFile).not.toHaveBeenCalled(); + expect(onChange).toHaveBeenCalledWith("https://media.haiyihy.com/admin/images/effect.png"); +}); + test("mp4 file upload field can open video preview dialog", async () => { const source = "https://media.haiyihy.com/admin/files/gift.mp4?sign=1"; const onChange = vi.fn();