hyapp-admin-platform/src/shared/ui/UploadField.jsx
2026-07-17 20:59:25 +08:00

609 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 (
<div
className={[
styles.root,
density === "compact" ? styles.compact : "",
disabled ? styles.disabled : "",
className || "",
]
.filter(Boolean)
.join(" ")}
>
<div className={styles.header}>
<span className={styles.label}>{label}</span>
{hideType ? null : <span className={styles.type}>{assetKindLabel(assetKind, isImage)}</span>}
</div>
<div className={[styles.panel, panelClassName || ""].filter(Boolean).join(" ")}>
<div className={styles.previewArea}>
<button
aria-label={label}
className={[styles.preview, previewClassName || ""].filter(Boolean).join(" ")}
disabled={disabled || uploading}
type="button"
onClick={openPicker}
>
<AssetPreview
assetKind={assetKind}
isImage={isImage}
mp4Layout={previewMp4Layout}
src={source}
/>
{uploading ? (
<span className={styles.overlay}>
<CircularProgress color="inherit" size={18} />
</span>
) : null}
</button>
{source && showSourceActions ? (
<span className={styles.previewActions}>
<Tooltip arrow title="打开">
<button
aria-label={`打开${label}`}
className={styles.previewAction}
type="button"
onClick={openSource}
>
<OpenInNewOutlined fontSize="small" />
</button>
</Tooltip>
<Tooltip arrow title="下载">
<span>
<button
aria-label={`下载${label}`}
className={styles.previewAction}
disabled={downloading}
type="button"
onClick={downloadSource}
>
{downloading ? (
<CircularProgress color="inherit" size={16} />
) : (
<DownloadOutlined fontSize="small" />
)}
</button>
</span>
</Tooltip>
</span>
) : null}
</div>
<div className={styles.footer}>
<span className={styles.name}>{displayName || "未上传"}</span>
<span className={styles.actions}>
<Tooltip arrow title={source ? "替换" : "上传"}>
<span>
<button
className={styles.action}
disabled={disabled || uploading}
type="button"
onClick={openPicker}
>
{uploading ? (
<CircularProgress color="inherit" size={16} />
) : (
<FileUploadOutlined fontSize="small" />
)}
{source ? "替换" : "上传"}
</button>
</span>
</Tooltip>
{canPreviewVideo ? (
<Tooltip arrow title="查看 MP4">
<span>
<button
className={styles.action}
disabled={disabled || uploading}
type="button"
onClick={() => setVideoPreviewOpen(true)}
>
<VisibilityOutlined fontSize="small" />
查看
</button>
</span>
</Tooltip>
) : null}
{source ? (
<Tooltip arrow title="清除">
<span>
<button
className={styles.action}
disabled={disabled || uploading}
type="button"
onClick={clearValue}
>
<DeleteOutlineOutlined fontSize="small" />
清除
</button>
</span>
</Tooltip>
) : null}
</span>
</div>
</div>
<VideoPreviewDialog
label={label}
mp4Layout={previewMp4Layout}
open={videoPreviewOpen}
src={canPreviewVideo ? source : ""}
onClose={() => setVideoPreviewOpen(false)}
/>
<input
ref={inputRef}
accept={inputAccept}
className={styles.input}
id={inputId}
type="file"
onChange={handleFileChange}
/>
</div>
);
}
function VideoPreviewDialog({ label, mp4Layout, onClose, open, src }) {
return (
<Dialog fullWidth maxWidth="sm" open={open} onClose={onClose}>
<DialogTitle>{label} MP4</DialogTitle>
<DialogContent>
<div className={styles.videoPreviewFrame}>
{src ? <Mp4Preview autoPlay controls expanded layout={mp4Layout} src={src} /> : null}
</div>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>关闭</Button>
</DialogActions>
</Dialog>
);
}
function AssetPreview({ assetKind, isImage, mp4Layout, src }) {
if (!src) {
return (
<span className={styles.empty}>
{isImage ? <ImageOutlined fontSize="small" /> : <InsertDriveFileOutlined fontSize="small" />}
</span>
);
}
if (assetKind === "svga") {
return <SVGAAssetPreview src={src} />;
}
if (assetKind === "pag") {
return <PAGAssetPreview src={src} />;
}
if (assetKind === "mp4") {
return <Mp4Preview autoPlay layout={mp4Layout} src={src} />;
}
if (assetKind === "image" || isImage) {
return <RasterAssetPreview src={src} />;
}
return (
<span className={styles.empty}>
<InsertDriveFileOutlined fontSize="small" />
</span>
);
}
function RasterAssetPreview({ src }) {
const [failed, setFailed] = useState(false);
useEffect(() => {
setFailed(false);
}, [src]);
if (failed) {
return (
<span className={styles.empty}>
<ImageOutlined fontSize="small" />
</span>
);
}
return <img alt="" className={styles.image} src={src} onError={() => 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 (
<span className={styles.player}>
{/* svgaplayerweb 2.3.2 只会为 HTMLDivElement 创建内部 Canvasspan 会在 setVideoItem 时稳定失败。 */}
<div ref={containerRef} className={styles.playerSurface} />
{failed ? <span className={styles.empty}>SVGA 预览失败</span> : null}
</span>
);
}
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 (
<span className={styles.player}>
<canvas ref={canvasRef} className={styles.pagCanvas} />
{failed ? <span className={styles.empty}>PAG 预览失败</span> : null}
</span>
);
}
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 ? "图片" : "文件";
}