import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
import DownloadOutlined from "@mui/icons-material/DownloadOutlined";
import FileUploadOutlined from "@mui/icons-material/FileUploadOutlined";
import ImageOutlined from "@mui/icons-material/ImageOutlined";
import InsertDriveFileOutlined from "@mui/icons-material/InsertDriveFileOutlined";
import OpenInNewOutlined from "@mui/icons-material/OpenInNewOutlined";
import VisibilityOutlined from "@mui/icons-material/VisibilityOutlined";
import CircularProgress from "@mui/material/CircularProgress";
import Dialog from "@mui/material/Dialog";
import DialogActions from "@mui/material/DialogActions";
import DialogContent from "@mui/material/DialogContent";
import DialogTitle from "@mui/material/DialogTitle";
import Tooltip from "@mui/material/Tooltip";
import { useEffect, useId, useRef, useState } from "react";
import { downloadResponse } from "@/shared/api/download";
import { uploadFile, uploadImage } from "@/shared/api/upload";
import { Button } from "@/shared/ui/Button.jsx";
import { Mp4Preview } from "@/shared/ui/Mp4Preview.jsx";
import { useToast } from "@/shared/ui/ToastProvider.jsx";
import styles from "./UploadField.module.css";
const pagWasmURL = "/vendor/libpag.wasm";
let pagModulePromise;
export function UploadField({
accept,
className,
disabled = false,
density = "regular",
kind = "image",
label,
hideType = false,
mp4Layout,
onChange,
onFileSelected,
onUploadingChange,
panelClassName,
previewClassName,
showSourceActions = false,
uploadKind,
value = "",
}) {
const inputId = useId();
const inputRef = useRef(null);
const uploadGenerationRef = useRef(0);
const onUploadingChangeRef = useRef(onUploadingChange);
const { showToast } = useToast();
const [uploading, setUploading] = useState(false);
const [localPreview, setLocalPreview] = useState(null);
const [downloading, setDownloading] = useState(false);
const [videoPreviewOpen, setVideoPreviewOpen] = useState(false);
const isImage = kind === "image";
const source = localPreview?.url || value;
const assetKind = localPreview?.assetKind || getAssetKind(value, kind);
const displayName = localPreview?.name || getDisplayValue(value);
const inputAccept = accept || undefined;
const canPreviewVideo = Boolean(source && assetKind === "mp4");
const localPreviewURL = localPreview?.url;
const previewMp4Layout = Object.prototype.hasOwnProperty.call(localPreview?.selectionResult || {}, "mp4Layout")
? localPreview.selectionResult.mp4Layout
: mp4Layout;
useEffect(() => {
onUploadingChangeRef.current = onUploadingChange;
}, [onUploadingChange]);
useEffect(() => {
return () => {
// 上传请求无法中途取消时,用代次作废迟到结果,避免弹窗关闭后回写下一次编辑表单。
uploadGenerationRef.current += 1;
onUploadingChangeRef.current?.(false);
};
}, []);
useEffect(() => {
return () => {
if (localPreviewURL) {
URL.revokeObjectURL(localPreviewURL);
}
};
}, [localPreviewURL]);
const openPicker = () => {
if (!disabled && !uploading) {
inputRef.current?.click();
}
};
const handleFileChange = async (event) => {
const file = event.target.files?.[0];
event.target.value = "";
if (!file) {
return;
}
setLocalPreview((previous) => {
if (previous?.url) {
URL.revokeObjectURL(previous.url);
}
return {
assetKind: getAssetKind(file.name, kind, file.type),
name: file.name,
url: URL.createObjectURL(file),
};
});
const uploadGeneration = ++uploadGenerationRef.current;
setUploading(true);
onUploadingChange?.(true);
try {
const selectionResult = await onFileSelected?.(file);
if (uploadGeneration !== uploadGenerationRef.current) {
return;
}
setLocalPreview((previous) => (previous ? { ...previous, selectionResult } : previous));
// 图片接口会校验固定的位图格式;资源素材不能被这层校验变成格式白名单,
// 因此仅 JPEG/PNG/WebP 走图片接口,其余 PAG/SVGA/GIF/AVIF 或未知格式走通用文件接口。
const result = shouldUploadAsImage(file, kind, uploadKind) ? await uploadImage(file) : await uploadFile(file);
if (uploadGeneration !== uploadGenerationRef.current) {
return;
}
// 只有媒体检测和上传都成功后才把检测结果交给表单,避免新布局残留在旧 URL 上。
if (selectionResult === undefined) {
onChange(result.url);
} else {
onChange(result.url, selectionResult);
}
setLocalPreview(null);
showToast("上传成功", "success");
} catch (err) {
if (uploadGeneration !== uploadGenerationRef.current) {
return;
}
setLocalPreview(null);
showToast(err.message || "上传失败", "error");
} finally {
if (uploadGeneration === uploadGenerationRef.current) {
setUploading(false);
onUploadingChange?.(false);
}
}
};
const clearValue = () => {
setLocalPreview(null);
setVideoPreviewOpen(false);
onChange("");
};
const openSource = () => {
if (source) {
window.open(source, "_blank", "noopener,noreferrer");
}
};
const downloadSource = async () => {
if (!source || downloading) {
return;
}
setDownloading(true);
try {
// 先拉取为同源 Blob 再触发下载,避免跨域素材 URL 被浏览器忽略 download 属性并直接打开。
const response = await fetch(source);
if (!response.ok) {
throw new Error(`素材下载失败 (${response.status})`);
}
await downloadResponse(response, displayName || "asset");
} catch (err) {
showToast(err.message || "素材下载失败", "error");
} finally {
setDownloading(false);
}
};
return (
{label}
{hideType ? null : {assetKindLabel(assetKind, isImage)}}
{source && showSourceActions ? (
) : null}
{displayName || "未上传"}
{canPreviewVideo ? (
) : null}
{source ? (
) : null}
setVideoPreviewOpen(false)}
/>
);
}
function VideoPreviewDialog({ label, mp4Layout, onClose, open, src }) {
return (
);
}
function AssetPreview({ assetKind, isImage, mp4Layout, src }) {
if (!src) {
return (
{isImage ? : }
);
}
if (assetKind === "svga") {
return ;
}
if (assetKind === "pag") {
return ;
}
if (assetKind === "mp4") {
return ;
}
if (assetKind === "image" || isImage) {
return ;
}
return (
);
}
function RasterAssetPreview({ src }) {
const [failed, setFailed] = useState(false);
useEffect(() => {
setFailed(false);
}, [src]);
if (failed) {
return (
);
}
return
setFailed(true)} />;
}
function SVGAAssetPreview({ src }) {
const containerRef = useRef(null);
const [failed, setFailed] = useState(false);
useEffect(() => {
let cancelled = false;
const container = containerRef.current;
let player;
setFailed(false);
async function load() {
try {
const module = await import("svgaplayerweb");
const SVGA = module.default || module;
if (cancelled || !container) {
return;
}
container.innerHTML = "";
player = new SVGA.Player(container);
player.loops = 0;
player.clearsAfterStop = false;
player.setContentMode("AspectFit");
const parser = new SVGA.Parser(container);
parser.load(
src,
(videoItem) => {
if (cancelled) {
return;
}
player.setVideoItem(videoItem);
player.startAnimation();
},
() => {
if (!cancelled) {
setFailed(true);
}
},
);
} catch {
if (!cancelled) {
setFailed(true);
}
}
}
load();
return () => {
cancelled = true;
if (player) {
try {
player.stopAnimation(true);
player.clear();
} catch {
// Ignore player cleanup errors from partially loaded assets.
}
}
if (container) {
container.innerHTML = "";
}
};
}, [src]);
return (
{/* svgaplayerweb 2.3.2 只会为 HTMLDivElement 创建内部 Canvas,span 会在 setVideoItem 时稳定失败。 */}
{failed ? SVGA 预览失败 : null}
);
}
function PAGAssetPreview({ src }) {
const canvasRef = useRef(null);
const [failed, setFailed] = useState(false);
useEffect(() => {
let cancelled = false;
let pagFile;
let pagView;
setFailed(false);
async function load() {
try {
const PAG = await getPAGModule();
const buffer = await fetch(src).then((response) => response.arrayBuffer());
pagFile = await PAG.PAGFile.load(buffer);
if (cancelled || !canvasRef.current) {
return;
}
canvasRef.current.width = pagFile.width();
canvasRef.current.height = pagFile.height();
pagView = await PAG.PAGView.init(pagFile, canvasRef.current, { useScale: true });
if (!pagView) {
throw new Error("PAGView init failed");
}
pagView.setRepeatCount(0);
await pagView.play();
} catch {
if (!cancelled) {
setFailed(true);
}
}
}
load();
return () => {
cancelled = true;
if (pagView) {
pagView.destroy();
}
if (pagFile?.destroy) {
pagFile.destroy();
}
};
}, [src]);
return (
{failed ? PAG 预览失败 : null}
);
}
async function getPAGModule() {
if (!pagModulePromise) {
pagModulePromise = import("libpag").then(({ PAGInit }) => PAGInit({ locateFile: () => pagWasmURL }));
}
return pagModulePromise;
}
function getDisplayValue(value) {
if (!value) {
return "";
}
const raw = String(value);
try {
const url = new URL(raw);
const name = url.pathname.split("/").filter(Boolean).pop();
return name ? decodeURIComponent(name) : raw;
} catch {
return raw;
}
}
function getAssetKind(value, kind, contentType = "") {
const extension = getExtension(value);
if (extension === "svga") {
return "svga";
}
if (extension === "pag") {
return "pag";
}
if (extension === "mp4") {
return "mp4";
}
if (isRasterImageExtension(extension)) {
return "image";
}
const normalizedType = String(contentType || "").toLowerCase();
if (normalizedType.includes("svga")) {
return "svga";
}
if (normalizedType.includes("pag")) {
return "pag";
}
if (normalizedType === "video/mp4") {
return "mp4";
}
if (isRasterImageContentType(normalizedType)) {
return "image";
}
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);
}
function isRasterImageContentType(contentType) {
return ["image/avif", "image/gif", "image/jpeg", "image/png", "image/webp"].includes(contentType);
}
function getExtension(value) {
if (!value) {
return "";
}
const raw = String(value);
const pathname = safePathname(raw);
const name = pathname.split("/").filter(Boolean).pop() || raw;
const cleanName = name.split("?")[0].split("#")[0];
const dotIndex = cleanName.lastIndexOf(".");
return dotIndex >= 0 ? cleanName.slice(dotIndex + 1).toLowerCase() : "";
}
function safePathname(value) {
try {
return new URL(value).pathname;
} catch {
return value;
}
}
function assetKindLabel(assetKind, isImage) {
if (assetKind === "image") {
return "图片";
}
if (assetKind === "svga") {
return "SVGA";
}
if (assetKind === "pag") {
return "PAG";
}
if (assetKind === "mp4") {
return "MP4";
}
return isImage ? "图片" : "文件";
}