hyapp-admin-platform/src/shared/ui/UploadField.jsx
2026-06-04 11:04:48 +08:00

413 lines
13 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,
onFileSelected,
uploadKind,
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 uploadMode = uploadKind || kind;
const useImageUpload = uploadMode === "image";
const source = localPreview?.url || value;
const assetKind = localPreview?.assetKind || getAssetKind(value, kind);
const displayName = localPreview?.name || getDisplayValue(value);
const inputAccept = accept !== undefined ? accept : useImageUpload && 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 {
await onFileSelected?.(file);
const result = useImageUpload ? 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 (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}>
<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";
}
if (isRasterImageExtension(extension)) {
return "image";
}
const normalizedType = String(contentType || "").toLowerCase();
if (normalizedType.includes("svga")) {
return "svga";
}
if (normalizedType.includes("pag")) {
return "pag";
}
if (isRasterImageContentType(normalizedType)) {
return "image";
}
return kind === "image" ? "image" : "file";
}
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";
}
return isImage ? "图片" : "文件";
}