370 lines
9.9 KiB
JavaScript
370 lines
9.9 KiB
JavaScript
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
|
|
import FileUploadOutlined from "@mui/icons-material/FileUploadOutlined";
|
|
import ImageOutlined from "@mui/icons-material/ImageOutlined";
|
|
import InsertDriveFileOutlined from "@mui/icons-material/InsertDriveFileOutlined";
|
|
import CircularProgress from "@mui/material/CircularProgress";
|
|
import Tooltip from "@mui/material/Tooltip";
|
|
import { useEffect, useId, useRef, useState } from "react";
|
|
import { uploadFile, uploadImage } from "@/shared/api/upload";
|
|
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;
|
|
|
|
export function UploadField({
|
|
accept,
|
|
disabled = false,
|
|
kind = "image",
|
|
label,
|
|
onChange,
|
|
value = ""
|
|
}) {
|
|
const inputId = useId();
|
|
const inputRef = useRef(null);
|
|
const { showToast } = useToast();
|
|
const [uploading, setUploading] = useState(false);
|
|
const [localPreview, setLocalPreview] = useState(null);
|
|
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 || (isImage ? imageAccept : undefined);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
if (localPreview?.url) {
|
|
URL.revokeObjectURL(localPreview.url);
|
|
}
|
|
};
|
|
}, [localPreview]);
|
|
|
|
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)
|
|
};
|
|
});
|
|
setUploading(true);
|
|
try {
|
|
const result = isImage ? await uploadImage(file) : await uploadFile(file);
|
|
onChange(result.url);
|
|
setLocalPreview(null);
|
|
showToast("上传成功", "success");
|
|
} catch (err) {
|
|
setLocalPreview(null);
|
|
showToast(err.message || "上传失败", "error");
|
|
} finally {
|
|
setUploading(false);
|
|
}
|
|
};
|
|
|
|
const clearValue = () => {
|
|
setLocalPreview(null);
|
|
onChange("");
|
|
};
|
|
|
|
return (
|
|
<div className={[styles.root, disabled ? styles.disabled : ""].filter(Boolean).join(" ")}>
|
|
<div className={styles.header}>
|
|
<span className={styles.label}>{label}</span>
|
|
<span className={styles.type}>{assetKindLabel(assetKind, isImage)}</span>
|
|
</div>
|
|
<div className={styles.panel}>
|
|
<button
|
|
aria-label={label}
|
|
className={styles.preview}
|
|
disabled={disabled || uploading}
|
|
type="button"
|
|
onClick={openPicker}
|
|
>
|
|
<AssetPreview assetKind={assetKind} isImage={isImage} src={source} />
|
|
{uploading ? (
|
|
<span className={styles.overlay}>
|
|
<CircularProgress color="inherit" size={18} />
|
|
</span>
|
|
) : null}
|
|
</button>
|
|
<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>
|
|
{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>
|
|
<input ref={inputRef} accept={inputAccept} className={styles.input} id={inputId} type="file" onChange={handleFileChange} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function AssetPreview({ assetKind, isImage, 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 (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}>
|
|
<span 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";
|
|
}
|
|
const normalizedType = String(contentType || "").toLowerCase();
|
|
if (normalizedType.includes("svga")) {
|
|
return "svga";
|
|
}
|
|
if (normalizedType.includes("pag")) {
|
|
return "pag";
|
|
}
|
|
return kind === "image" ? "image" : "file";
|
|
}
|
|
|
|
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 === "svga") {
|
|
return "SVGA";
|
|
}
|
|
if (assetKind === "pag") {
|
|
return "PAG";
|
|
}
|
|
return isImage ? "图片" : "文件";
|
|
}
|