609 lines
21 KiB
JavaScript
609 lines
21 KiB
JavaScript
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 创建内部 Canvas,span 会在 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 ? "图片" : "文件";
|
||
}
|