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 ( {label} MP4
{src ? : null}
); } 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 ? "图片" : "文件"; }