Skip to content

File Upload

  • 使用 formData 传输
import { createFileRoute } from "@tanstack/react-router";
import {
AlertCircle,
CheckCircle2,
ImageIcon,
Trash2,
UploadCloud,
} from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { Button } from "#/components/ui/button";
export const Route = createFileRoute("/upload/")({
component: RouteComponent,
});
const acceptedImageTypes = [
"image/jpeg",
"image/png",
"image/webp",
"image/gif",
];
const acceptedImageText = "JPG、PNG、WEBP、GIF";
const maxFileSize = 5 * 1024 * 1024;
function formatFileSize(size: number) {
if (size < 1024 * 1024) {
return `${(size / 1024).toFixed(1)} KB`;
}
return `${(size / 1024 / 1024).toFixed(2)} MB`;
}
function RouteComponent() {
const [file, setFile] = useState<File | null>(null);
const [error, setError] = useState("");
const [isDragging, setIsDragging] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const previewUrl = useMemo(() => {
if (!file) {
return "";
}
return URL.createObjectURL(file);
}, [file]);
useEffect(() => {
return () => {
if (previewUrl) {
URL.revokeObjectURL(previewUrl);
}
};
}, [previewUrl]);
const validateAndSetFile = (selectedFile?: File) => {
setError("");
if (!selectedFile) {
setFile(null);
return false;
}
if (!acceptedImageTypes.includes(selectedFile.type)) {
setError(`请上传 ${acceptedImageText} 格式的图片`);
setFile(null);
return false;
}
if (selectedFile.size > maxFileSize) {
setError("图片大小不能超过 5MB");
setFile(null);
return false;
}
setFile(selectedFile);
return true;
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const isValid = validateAndSetFile(e.target.files?.[0]);
if (!isValid) {
e.target.value = "";
}
};
const handleDrop = (e: React.DragEvent<HTMLLabelElement>) => {
e.preventDefault();
setIsDragging(false);
validateAndSetFile(e.dataTransfer.files?.[0]);
};
const handleClear = () => {
setFile(null);
setError("");
if (inputRef.current) {
inputRef.current.value = "";
}
};
const handleSubmit = async () => {
if (!file) {
setError("请先选择需要上传的图片");
return;
}
const formData = new FormData();
formData.append("image", file);
try {
console.log("准备上传的文件:", formData.get("image"));
// 示例: const response = await fetch('/api/upload', { method: 'POST', body: formData });
// if (response.ok) alert('上传成功');
} catch (err) {
console.error("上传失败", err);
setError("上传失败,请重试");
}
};
return (
<main className="min-h-dvh bg-muted/30 px-4 py-8 sm:px-6 lg:px-8">
<div className="mx-auto flex w-full max-w-3xl flex-col gap-5">
<header className="flex flex-col gap-2">
<p className="text-sm font-medium text-muted-foreground">图片上传</p>
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-normal text-foreground">
上传素材图片
</h1>
<p className="mt-2 max-w-xl text-sm leading-6 text-muted-foreground">
支持常见图片格式,单张图片最大
5MB。选择文件后可先确认预览再上传。
</p>
</div>
<div className="flex w-fit items-center gap-2 rounded-lg border bg-background px-3 py-2 text-xs text-muted-foreground">
<CheckCircle2 className="size-4 text-emerald-600" />
本地校验已启用
</div>
</div>
</header>
<section className="overflow-hidden rounded-xl border bg-background shadow-sm">
<div className="grid gap-0 lg:grid-cols-[1fr_280px]">
<div className="p-4 sm:p-6">
<label
className={[
"flex min-h-70 cursor-pointer flex-col items-center justify-center gap-4 rounded-lg border border-dashed px-5 py-8 text-center transition-colors",
isDragging
? "border-primary bg-primary/5"
: "border-border bg-muted/30 hover:bg-muted/50",
error ? "border-destructive/70 bg-destructive/5" : "",
].join(" ")}
onDragEnter={() => setIsDragging(true)}
onDragLeave={() => setIsDragging(false)}
onDragOver={(e) => e.preventDefault()}
onDrop={handleDrop}
>
<input
ref={inputRef}
className="sr-only"
type="file"
name="image"
accept="image/png, image/jpeg, image/webp, image/gif"
onChange={handleFileChange}
/>
<span className="flex size-14 items-center justify-center rounded-lg border bg-background">
<UploadCloud className="size-7 text-muted-foreground" />
</span>
<span className="space-y-1">
<span className="block text-base font-medium text-foreground">
拖拽图片到这里,或点击选择文件
</span>
<span className="block text-sm text-muted-foreground">
{acceptedImageText},最大 5MB
</span>
</span>
</label>
</div>
<aside className="border-t bg-muted/20 p-4 sm:p-6 lg:border-l lg:border-t-0">
<div className="flex h-full min-h-55 flex-col gap-4">
<div className="flex items-center justify-between gap-3">
<h2 className="text-sm font-medium text-foreground">
文件详情
</h2>
{file ? (
<Button
aria-label="清除已选文件"
onClick={handleClear}
size="icon-sm"
type="button"
variant="ghost"
>
<Trash2 className="size-4" />
</Button>
) : null}
</div>
<div className="flex flex-1 flex-col gap-4">
<div className="flex aspect-4/3 items-center justify-center overflow-hidden rounded-lg border bg-background">
{previewUrl ? (
<img
alt={file?.name ?? "已选择图片"}
className="h-full w-full object-contain"
src={previewUrl}
/>
) : (
<div className="flex flex-col items-center gap-2 text-muted-foreground">
<ImageIcon className="size-8" />
<span className="text-sm">等待选择图片</span>
</div>
)}
</div>
{file ? (
<dl className="grid gap-3 text-sm">
<div>
<dt className="text-muted-foreground">文件名</dt>
<dd className="mt-1 break-all font-medium text-foreground">
{file.name}
</dd>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<dt className="text-muted-foreground">大小</dt>
<dd className="mt-1 font-medium text-foreground">
{formatFileSize(file.size)}
</dd>
</div>
<div>
<dt className="text-muted-foreground">类型</dt>
<dd className="mt-1 font-medium text-foreground">
{file.type.replace("image/", "").toUpperCase()}
</dd>
</div>
</div>
</dl>
) : (
<p className="text-sm leading-6 text-muted-foreground">
选择图片后,这里会显示预览、文件名、大小和格式。
</p>
)}
</div>
</div>
</aside>
</div>
<div className="flex flex-col gap-3 border-t bg-background px-4 py-4 sm:flex-row sm:items-center sm:justify-between sm:px-6">
<div className="min-h-5">
{error ? (
<p className="flex items-center gap-2 text-sm text-destructive">
<AlertCircle className="size-4" />
{error}
</p>
) : file ? (
<p className="flex items-center gap-2 text-sm text-emerald-700">
<CheckCircle2 className="size-4" />
文件已就绪,可以上传
</p>
) : (
<p className="text-sm text-muted-foreground">
请先选择一张需要上传的图片。
</p>
)}
</div>
<div className="flex gap-2">
<Button
disabled={!file}
onClick={handleSubmit}
type="button"
className="w-full sm:w-auto"
>
<UploadCloud className="size-4" />
上传图片
</Button>
</div>
</div>
</section>
</div>
</main>
);
}
import { createFileRoute } from "@tanstack/react-router";
import {
AlertCircle,
CheckCircle2,
ImageIcon,
Trash2,
UploadCloud,
X,
} from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { Button } from "#/components/ui/button";
export const Route = createFileRoute("/upload/")({
component: RouteComponent,
});
const acceptedImageTypes = [
"image/jpeg",
"image/png",
"image/webp",
"image/gif",
];
const acceptedImageText = "JPG、PNG、WEBP、GIF";
const maxFileSize = 5 * 1024 * 1024;
const maxFileCount = 9;
type UploadItem = {
id: string;
file: File;
previewUrl: string;
};
function formatFileSize(size: number) {
if (size < 1024 * 1024) {
return `${(size / 1024).toFixed(1)} KB`;
}
return `${(size / 1024 / 1024).toFixed(2)} MB`;
}
function createId() {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
return crypto.randomUUID();
}
return `${Date.now()}-${Math.random().toString(36).slice(2)}`;
}
function RouteComponent() {
const [items, setItems] = useState<UploadItem[]>([]);
const [error, setError] = useState("");
const [isDragging, setIsDragging] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const itemsRef = useRef(items);
itemsRef.current = items;
useEffect(() => {
return () => {
for (const item of itemsRef.current) {
URL.revokeObjectURL(item.previewUrl);
}
};
}, []);
const addFiles = (incoming: FileList | File[] | null | undefined) => {
if (!incoming || incoming.length === 0) {
return;
}
const incomingList = Array.from(incoming);
const errors: string[] = [];
const accepted: UploadItem[] = [];
setItems((prev) => {
const seen = new Set(
prev.map((item) => `${item.file.name}_${item.file.size}`),
);
let remaining = maxFileCount - prev.length;
for (const file of incomingList) {
if (remaining <= 0) {
errors.push(`最多只能上传 ${maxFileCount} 张图片`);
break;
}
if (!acceptedImageTypes.includes(file.type)) {
errors.push(`${file.name}:格式不支持`);
continue;
}
if (file.size > maxFileSize) {
errors.push(`${file.name}:超过 5MB`);
continue;
}
const key = `${file.name}_${file.size}`;
if (seen.has(key)) {
continue;
}
seen.add(key);
accepted.push({
id: createId(),
file,
previewUrl: URL.createObjectURL(file),
});
remaining -= 1;
}
setError(errors.length > 0 ? Array.from(new Set(errors)).join("") : "");
return [...prev, ...accepted];
});
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
addFiles(e.target.files);
e.target.value = "";
};
const handleDrop = (e: React.DragEvent<HTMLLabelElement>) => {
e.preventDefault();
setIsDragging(false);
addFiles(e.dataTransfer.files);
};
const removeItem = (id: string) => {
setItems((prev) => {
const target = prev.find((item) => item.id === id);
if (target) {
URL.revokeObjectURL(target.previewUrl);
}
return prev.filter((item) => item.id !== id);
});
setError("");
};
const handleClearAll = () => {
for (const item of items) {
URL.revokeObjectURL(item.previewUrl);
}
setItems([]);
setError("");
if (inputRef.current) {
inputRef.current.value = "";
}
};
const handleSubmit = async () => {
if (items.length === 0) {
setError("请先选择需要上传的图片");
return;
}
const formData = new FormData();
for (const item of items) {
formData.append("images", item.file, item.file.name);
}
try {
console.log("准备上传的文件:", formData.getAll("images"));
// 示例: const response = await fetch('/api/upload', { method: 'POST', body: formData });
// if (response.ok) alert('上传成功');
} catch (err) {
console.error("上传失败", err);
setError("上传失败,请重试");
}
};
const totalSize = items.reduce((sum, item) => sum + item.file.size, 0);
const hasItems = items.length > 0;
return (
<main className="min-h-dvh bg-muted/30 px-4 py-8 sm:px-6 lg:px-8">
<div className="mx-auto flex w-full max-w-3xl flex-col gap-5">
<header className="flex flex-col gap-2">
<p className="text-sm font-medium text-muted-foreground">图片上传</p>
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-normal text-foreground">
上传素材图片
</h1>
<p className="mt-2 max-w-xl text-sm leading-6 text-muted-foreground">
支持常见图片格式,单张图片最大 5MB,一次最多上传 {maxFileCount}{" "}
张,可在列表中单独删除。
</p>
</div>
<div className="flex w-fit items-center gap-2 rounded-lg border bg-background px-3 py-2 text-xs text-muted-foreground">
<CheckCircle2 className="size-4 text-emerald-600" />
本地校验已启用
</div>
</div>
</header>
<section className="overflow-hidden rounded-xl border bg-background shadow-sm">
<div className="grid gap-0 lg:grid-cols-[1fr_320px]">
<div className="p-4 sm:p-6">
<label
className={[
"flex min-h-70 cursor-pointer flex-col items-center justify-center gap-4 rounded-lg border border-dashed px-5 py-8 text-center transition-colors",
isDragging
? "border-primary bg-primary/5"
: "border-border bg-muted/30 hover:bg-muted/50",
error ? "border-destructive/70 bg-destructive/5" : "",
].join(" ")}
onDragEnter={() => setIsDragging(true)}
onDragLeave={() => setIsDragging(false)}
onDragOver={(e) => e.preventDefault()}
onDrop={handleDrop}
>
<input
ref={inputRef}
className="sr-only"
type="file"
name="images"
multiple
accept="image/png, image/jpeg, image/webp, image/gif"
onChange={handleFileChange}
/>
<span className="flex size-14 items-center justify-center rounded-lg border bg-background">
<UploadCloud className="size-7 text-muted-foreground" />
</span>
<span className="space-y-1">
<span className="block text-base font-medium text-foreground">
拖拽图片到这里,或点击选择文件
</span>
<span className="block text-sm text-muted-foreground">
支持多选,{acceptedImageText},单张最大 5MB
</span>
</span>
</label>
</div>
<aside className="border-t bg-muted/20 p-4 sm:p-6 lg:border-l lg:border-t-0">
<div className="flex h-full min-h-55 flex-col gap-4">
<div className="flex items-center justify-between gap-3">
<h2 className="text-sm font-medium text-foreground">
已选文件
{hasItems ? (
<span className="ml-2 text-xs font-normal text-muted-foreground">
{items.length} / {maxFileCount} ·{" "}
{formatFileSize(totalSize)}
</span>
) : null}
</h2>
{hasItems ? (
<Button
aria-label="清除全部文件"
onClick={handleClearAll}
size="icon-sm"
type="button"
variant="ghost"
>
<Trash2 className="size-4" />
</Button>
) : null}
</div>
{hasItems ? (
<ul className="flex max-h-96 flex-1 flex-col gap-2 overflow-y-auto pr-1">
{items.map((item) => (
<li
key={item.id}
className="group flex items-center gap-3 rounded-lg border bg-background p-2"
>
<div className="size-12 shrink-0 overflow-hidden rounded-md border bg-muted">
<img
alt={item.file.name}
className="h-full w-full object-cover"
src={item.previewUrl}
/>
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-foreground">
{item.file.name}
</p>
<p className="mt-0.5 text-xs text-muted-foreground">
{formatFileSize(item.file.size)} ·{" "}
{item.file.type.replace("image/", "").toUpperCase()}
</p>
</div>
<Button
aria-label={`移除 ${item.file.name}`}
onClick={() => removeItem(item.id)}
size="icon-sm"
type="button"
variant="ghost"
>
<X className="size-4" />
</Button>
</li>
))}
</ul>
) : (
<div className="flex flex-1 flex-col items-center justify-center gap-2 rounded-lg border border-dashed bg-background/50 py-10 text-muted-foreground">
<ImageIcon className="size-8" />
<span className="text-sm">等待选择图片</span>
</div>
)}
</div>
</aside>
</div>
<div className="flex flex-col gap-3 border-t bg-background px-4 py-4 sm:flex-row sm:items-center sm:justify-between sm:px-6">
<div className="min-h-5">
{error ? (
<p className="flex items-center gap-2 text-sm text-destructive">
<AlertCircle className="size-4" />
{error}
</p>
) : hasItems ? (
<p className="flex items-center gap-2 text-sm text-emerald-700">
<CheckCircle2 className="size-4" />
{items.length} 个文件已就绪,可以上传
</p>
) : (
<p className="text-sm text-muted-foreground">
请先选择需要上传的图片,支持一次选择多张。
</p>
)}
</div>
<div className="flex gap-2">
<Button
disabled={!hasItems}
onClick={handleSubmit}
type="button"
className="w-full sm:w-auto"
>
<UploadCloud className="size-4" />
上传 {hasItems ? `${items.length}` : "图片"}
</Button>
</div>
</div>
</section>
</div>
</main>
);
}
import { createFileRoute } from "@tanstack/react-router";
import {
AlertCircle,
CheckCircle2,
File as FileIcon,
ImageIcon,
Pause,
Play,
Trash2,
UploadCloud,
X,
} from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { Button } from "#/components/ui/button";
export const Route = createFileRoute("/upload/")({
component: RouteComponent,
});
const maxFileSize = 10 * 1024 * 1024 * 1024;
const chunkSize = 5 * 1024 * 1024;
const maxConcurrency = 3;
type UploadState = "idle" | "uploading" | "paused" | "completed" | "error";
type Chunk = {
index: number;
start: number;
end: number;
uploaded: boolean;
uploading: boolean;
};
function formatFileSize(size: number) {
if (size < 1024) {
return `${size} B`;
}
if (size < 1024 * 1024) {
return `${(size / 1024).toFixed(1)} KB`;
}
if (size < 1024 * 1024 * 1024) {
return `${(size / 1024 / 1024).toFixed(2)} MB`;
}
return `${(size / 1024 / 1024 / 1024).toFixed(2)} GB`;
}
async function uploadChunkRequest(
_blob: Blob,
_meta: { index: number; total: number; fileName: string },
signal: AbortSignal,
) {
// 真实实现示例:
// const formData = new FormData();
// formData.append("chunk", _blob);
// formData.append("index", String(_meta.index));
// formData.append("total", String(_meta.total));
// formData.append("fileName", _meta.fileName);
// await fetch("/api/upload/chunk", { method: "POST", body: formData, signal });
return new Promise<void>((resolve, reject) => {
if (signal.aborted) {
reject(new DOMException("Aborted", "AbortError"));
return;
}
const onAbort = () => {
clearTimeout(timer);
signal.removeEventListener("abort", onAbort);
reject(new DOMException("Aborted", "AbortError"));
};
const timer = setTimeout(
() => {
signal.removeEventListener("abort", onAbort);
resolve();
},
300 + Math.random() * 400,
);
signal.addEventListener("abort", onAbort);
});
}
function RouteComponent() {
const [file, setFile] = useState<File | null>(null);
const [error, setError] = useState("");
const [isDragging, setIsDragging] = useState(false);
const [uploadState, setUploadState] = useState<UploadState>("idle");
const [uploadedBytes, setUploadedBytes] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const chunksRef = useRef<Chunk[]>([]);
const abortRef = useRef<AbortController | null>(null);
const pauseRef = useRef(false);
const isImage = file?.type.startsWith("image/") ?? false;
const previewUrl = useMemo(() => {
if (!file || !isImage) {
return "";
}
return URL.createObjectURL(file);
}, [file, isImage]);
useEffect(() => {
return () => {
if (previewUrl) {
URL.revokeObjectURL(previewUrl);
}
};
}, [previewUrl]);
useEffect(() => {
return () => {
abortRef.current?.abort();
};
}, []);
const totalChunks = chunksRef.current.length;
const uploadedChunkCount = chunksRef.current.filter((c) => c.uploaded).length;
const progress =
file && file.size > 0
? Math.min(100, (uploadedBytes / file.size) * 100)
: 0;
const resetUploadState = () => {
chunksRef.current = [];
abortRef.current = null;
pauseRef.current = false;
setUploadedBytes(0);
setUploadState("idle");
};
const validateAndSetFile = (selectedFile?: File) => {
setError("");
if (!selectedFile) {
setFile(null);
resetUploadState();
return false;
}
if (selectedFile.size > maxFileSize) {
setError(`文件大小不能超过 ${formatFileSize(maxFileSize)}`);
setFile(null);
resetUploadState();
return false;
}
setFile(selectedFile);
resetUploadState();
return true;
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const isValid = validateAndSetFile(e.target.files?.[0]);
if (!isValid) {
e.target.value = "";
}
};
const handleDrop = (e: React.DragEvent<HTMLLabelElement>) => {
e.preventDefault();
setIsDragging(false);
validateAndSetFile(e.dataTransfer.files?.[0]);
};
const handleClear = () => {
abortRef.current?.abort();
setFile(null);
setError("");
resetUploadState();
if (inputRef.current) {
inputRef.current.value = "";
}
};
const startUpload = async () => {
if (!file) {
setError("请先选择需要上传的文件");
return;
}
if (chunksRef.current.length === 0) {
const chunks: Chunk[] = [];
let index = 0;
for (let start = 0; start < file.size; start += chunkSize) {
chunks.push({
index,
start,
end: Math.min(start + chunkSize, file.size),
uploaded: false,
uploading: false,
});
index++;
}
chunksRef.current = chunks;
setUploadedBytes(0);
}
pauseRef.current = false;
const controller = new AbortController();
abortRef.current = controller;
setUploadState("uploading");
setError("");
const total = chunksRef.current.length;
const worker = async () => {
while (!pauseRef.current && !controller.signal.aborted) {
const target = chunksRef.current.find(
(c) => !c.uploaded && !c.uploading,
);
if (!target) {
return;
}
target.uploading = true;
try {
await uploadChunkRequest(
file.slice(target.start, target.end),
{ index: target.index, total, fileName: file.name },
controller.signal,
);
target.uploaded = true;
target.uploading = false;
setUploadedBytes((prev) => prev + (target.end - target.start));
} catch (err) {
target.uploading = false;
if ((err as Error).name === "AbortError") {
return;
}
throw err;
}
}
};
try {
const workers = Array.from(
{ length: Math.min(maxConcurrency, total) },
worker,
);
await Promise.all(workers);
if (controller.signal.aborted) {
return;
}
if (chunksRef.current.every((c) => c.uploaded)) {
setUploadState("completed");
console.log("所有分片上传完成,文件:", file.name);
// 真实实现:通知后端合并分片
// await fetch("/api/upload/complete", { method: "POST", body: ... });
}
} catch (err) {
console.error("上传失败", err);
setUploadState("error");
setError("上传失败,请重试");
}
};
const pauseUpload = () => {
pauseRef.current = true;
abortRef.current?.abort();
setUploadState("paused");
};
const cancelUpload = () => {
abortRef.current?.abort();
pauseRef.current = false;
chunksRef.current = [];
setUploadedBytes(0);
setUploadState("idle");
};
const isUploading = uploadState === "uploading";
const isPaused = uploadState === "paused";
const isCompleted = uploadState === "completed";
const isErrored = uploadState === "error";
const hasProgress = totalChunks > 0;
return (
<main className="min-h-dvh bg-muted/30 px-4 py-8 sm:px-6 lg:px-8">
<div className="mx-auto flex w-full max-w-3xl flex-col gap-5">
<header className="flex flex-col gap-2">
<p className="text-sm font-medium text-muted-foreground">文件上传</p>
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-normal text-foreground">
大文件分片上传
</h1>
<p className="mt-2 max-w-xl text-sm leading-6 text-muted-foreground">
支持任意类型文件,单文件最大 {formatFileSize(maxFileSize)}
,按 {formatFileSize(chunkSize)} 切片上传,{maxConcurrency}{" "}
并发,可暂停/继续/取消。
</p>
</div>
<div className="flex w-fit items-center gap-2 rounded-lg border bg-background px-3 py-2 text-xs text-muted-foreground">
<CheckCircle2 className="size-4 text-emerald-600" />
本地校验已启用
</div>
</div>
</header>
<section className="overflow-hidden rounded-xl border bg-background shadow-sm">
<div className="grid gap-0 lg:grid-cols-[1fr_280px]">
<div className="p-4 sm:p-6">
<label
className={[
"flex min-h-70 cursor-pointer flex-col items-center justify-center gap-4 rounded-lg border border-dashed px-5 py-8 text-center transition-colors",
isDragging
? "border-primary bg-primary/5"
: "border-border bg-muted/30 hover:bg-muted/50",
error ? "border-destructive/70 bg-destructive/5" : "",
isUploading ? "pointer-events-none opacity-60" : "",
].join(" ")}
onDragEnter={() => setIsDragging(true)}
onDragLeave={() => setIsDragging(false)}
onDragOver={(e) => e.preventDefault()}
onDrop={handleDrop}
>
<input
ref={inputRef}
className="sr-only"
type="file"
name="file"
disabled={isUploading}
onChange={handleFileChange}
/>
<span className="flex size-14 items-center justify-center rounded-lg border bg-background">
<UploadCloud className="size-7 text-muted-foreground" />
</span>
<span className="space-y-1">
<span className="block text-base font-medium text-foreground">
拖拽文件到这里,或点击选择文件
</span>
<span className="block text-sm text-muted-foreground">
单文件最大 {formatFileSize(maxFileSize)}
</span>
</span>
</label>
{file && hasProgress ? (
<div className="mt-4 space-y-2">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>
{uploadedChunkCount} / {totalChunks} 分片 ·{" "}
{formatFileSize(uploadedBytes)} /{" "}
{formatFileSize(file.size)}
</span>
<span className="font-medium text-foreground">
{progress.toFixed(1)}%
</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-muted">
<div
className={[
"h-full transition-all",
isErrored
? "bg-destructive"
: isCompleted
? "bg-emerald-500"
: "bg-primary",
].join(" ")}
style={{ width: `${progress}%` }}
/>
</div>
</div>
) : null}
</div>
<aside className="border-t bg-muted/20 p-4 sm:p-6 lg:border-l lg:border-t-0">
<div className="flex h-full min-h-55 flex-col gap-4">
<div className="flex items-center justify-between gap-3">
<h2 className="text-sm font-medium text-foreground">
文件详情
</h2>
{file ? (
<Button
aria-label="清除已选文件"
onClick={handleClear}
size="icon-sm"
type="button"
variant="ghost"
>
<Trash2 className="size-4" />
</Button>
) : null}
</div>
<div className="flex flex-1 flex-col gap-4">
<div className="flex aspect-4/3 items-center justify-center overflow-hidden rounded-lg border bg-background">
{previewUrl ? (
<img
alt={file?.name ?? "已选择文件"}
className="h-full w-full object-contain"
src={previewUrl}
/>
) : file ? (
<div className="flex flex-col items-center gap-2 text-muted-foreground">
<FileIcon className="size-8" />
<span className="px-4 text-center text-xs break-all">
{file.name}
</span>
</div>
) : (
<div className="flex flex-col items-center gap-2 text-muted-foreground">
<ImageIcon className="size-8" />
<span className="text-sm">等待选择文件</span>
</div>
)}
</div>
{file ? (
<dl className="grid gap-3 text-sm">
<div>
<dt className="text-muted-foreground">文件名</dt>
<dd className="mt-1 break-all font-medium text-foreground">
{file.name}
</dd>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<dt className="text-muted-foreground">大小</dt>
<dd className="mt-1 font-medium text-foreground">
{formatFileSize(file.size)}
</dd>
</div>
<div>
<dt className="text-muted-foreground">类型</dt>
<dd className="mt-1 truncate font-medium text-foreground">
{file.type || "未知"}
</dd>
</div>
</div>
</dl>
) : (
<p className="text-sm leading-6 text-muted-foreground">
选择文件后,这里会显示预览、文件名、大小和格式。
</p>
)}
</div>
</div>
</aside>
</div>
<div className="flex flex-col gap-3 border-t bg-background px-4 py-4 sm:flex-row sm:items-center sm:justify-between sm:px-6">
<div className="min-h-5">
{error ? (
<p className="flex items-center gap-2 text-sm text-destructive">
<AlertCircle className="size-4" />
{error}
</p>
) : isCompleted ? (
<p className="flex items-center gap-2 text-sm text-emerald-700">
<CheckCircle2 className="size-4" />
上传完成
</p>
) : isUploading ? (
<p className="flex items-center gap-2 text-sm text-primary">
<UploadCloud className="size-4" />
上传中…{progress.toFixed(1)}%
</p>
) : isPaused ? (
<p className="flex items-center gap-2 text-sm text-amber-600">
<Pause className="size-4" />
已暂停,可继续上传
</p>
) : file ? (
<p className="flex items-center gap-2 text-sm text-emerald-700">
<CheckCircle2 className="size-4" />
文件已就绪,可以上传
</p>
) : (
<p className="text-sm text-muted-foreground">
请先选择需要上传的文件。
</p>
)}
</div>
<div className="flex flex-wrap gap-2">
{isUploading ? (
<Button
onClick={pauseUpload}
type="button"
variant="outline"
className="w-full sm:w-auto"
>
<Pause className="size-4" />
暂停
</Button>
) : null}
{(isPaused || isErrored) && hasProgress ? (
<Button
onClick={cancelUpload}
type="button"
variant="outline"
className="w-full sm:w-auto"
>
<X className="size-4" />
取消
</Button>
) : null}
{isUploading ? null : (
<Button
disabled={!file || isCompleted}
onClick={() => void startUpload()}
type="button"
className="w-full sm:w-auto"
>
{isPaused ? (
<>
<Play className="size-4" />
继续上传
</>
) : isErrored ? (
<>
<UploadCloud className="size-4" />
重试
</>
) : isCompleted ? (
<>
<CheckCircle2 className="size-4" />
已完成
</>
) : (
<>
<UploadCloud className="size-4" />
开始上传
</>
)}
</Button>
)}
</div>
</div>
</section>
</div>
</main>
);
}
import { createFileRoute } from "@tanstack/react-router";
import {
AlertCircle,
CheckCircle2,
File as FileIcon,
ImageIcon,
Loader2,
Pause,
Play,
RotateCcw,
Trash2,
UploadCloud,
X,
} from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { Button } from "#/components/ui/button";
export const Route = createFileRoute("/upload/")({
component: RouteComponent,
});
const maxFileSize = 10 * 1024 * 1024 * 1024;
const chunkSize = 5 * 1024 * 1024;
const maxConcurrency = 3;
const storagePrefix = "upload:resume:";
type UploadState =
| "idle"
| "preparing"
| "uploading"
| "paused"
| "completed"
| "error";
type Chunk = {
index: number;
start: number;
end: number;
uploaded: boolean;
uploading: boolean;
};
type StoredProgress = {
fileName: string;
fileSize: number;
totalChunks: number;
uploadedIndices: number[];
updatedAt: number;
};
function formatFileSize(size: number) {
if (size < 1024) {
return `${size} B`;
}
if (size < 1024 * 1024) {
return `${(size / 1024).toFixed(1)} KB`;
}
if (size < 1024 * 1024 * 1024) {
return `${(size / 1024 / 1024).toFixed(2)} MB`;
}
return `${(size / 1024 / 1024 / 1024).toFixed(2)} GB`;
}
function buildChunks(file: File): Chunk[] {
const chunks: Chunk[] = [];
let index = 0;
for (let start = 0; start < file.size; start += chunkSize) {
chunks.push({
index,
start,
end: Math.min(start + chunkSize, file.size),
uploaded: false,
uploading: false,
});
index++;
}
return chunks;
}
async function computeFingerprint(file: File): Promise<string> {
const sampleSize = Math.min(chunkSize, file.size);
const head = await file.slice(0, sampleSize).arrayBuffer();
const tail =
file.size > sampleSize
? await file.slice(file.size - sampleSize).arrayBuffer()
: new ArrayBuffer(0);
const meta = new TextEncoder().encode(
`${file.name}|${file.size}|${file.lastModified}`,
);
const combined = new Uint8Array(
meta.byteLength + head.byteLength + tail.byteLength,
);
combined.set(meta, 0);
combined.set(new Uint8Array(head), meta.byteLength);
combined.set(new Uint8Array(tail), meta.byteLength + head.byteLength);
const hash = await crypto.subtle.digest("SHA-256", combined);
return Array.from(new Uint8Array(hash))
.map((b) => b.toString(16).padStart(2, "0"))
.join("")
.slice(0, 32);
}
function loadProgress(fingerprint: string): StoredProgress | null {
try {
const raw = localStorage.getItem(storagePrefix + fingerprint);
if (!raw) {
return null;
}
const parsed = JSON.parse(raw) as StoredProgress;
if (
!parsed ||
typeof parsed.fileSize !== "number" ||
!Array.isArray(parsed.uploadedIndices)
) {
return null;
}
return parsed;
} catch {
return null;
}
}
function saveProgress(fingerprint: string, progress: StoredProgress) {
try {
localStorage.setItem(storagePrefix + fingerprint, JSON.stringify(progress));
} catch (err) {
console.warn("持久化上传进度失败", err);
}
}
function clearProgress(fingerprint: string) {
try {
localStorage.removeItem(storagePrefix + fingerprint);
} catch {
/* noop */
}
}
async function uploadChunkRequest(
_blob: Blob,
_meta: {
index: number;
total: number;
fingerprint: string;
fileName: string;
},
signal: AbortSignal,
) {
// 真实实现:
// const formData = new FormData();
// formData.append("chunk", _blob);
// formData.append("index", String(_meta.index));
// formData.append("total", String(_meta.total));
// formData.append("fingerprint", _meta.fingerprint);
// formData.append("fileName", _meta.fileName);
// await fetch("/api/upload/chunk", { method: "POST", body: formData, signal });
return new Promise<void>((resolve, reject) => {
if (signal.aborted) {
reject(new DOMException("Aborted", "AbortError"));
return;
}
const onAbort = () => {
clearTimeout(timer);
signal.removeEventListener("abort", onAbort);
reject(new DOMException("Aborted", "AbortError"));
};
const timer = setTimeout(
() => {
signal.removeEventListener("abort", onAbort);
resolve();
},
300 + Math.random() * 400,
);
signal.addEventListener("abort", onAbort);
});
}
function RouteComponent() {
const [file, setFile] = useState<File | null>(null);
const [error, setError] = useState("");
const [isDragging, setIsDragging] = useState(false);
const [uploadState, setUploadState] = useState<UploadState>("idle");
const [uploadedBytes, setUploadedBytes] = useState(0);
const [fingerprint, setFingerprint] = useState("");
const [resumedCount, setResumedCount] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const chunksRef = useRef<Chunk[]>([]);
const abortRef = useRef<AbortController | null>(null);
const pauseRef = useRef(false);
const fingerprintRef = useRef("");
fingerprintRef.current = fingerprint;
const isImage = file?.type.startsWith("image/") ?? false;
const previewUrl = useMemo(() => {
if (!file || !isImage) {
return "";
}
return URL.createObjectURL(file);
}, [file, isImage]);
useEffect(() => {
return () => {
if (previewUrl) {
URL.revokeObjectURL(previewUrl);
}
};
}, [previewUrl]);
useEffect(() => {
return () => {
abortRef.current?.abort();
};
}, []);
useEffect(() => {
if (!file) {
setFingerprint("");
setResumedCount(0);
chunksRef.current = [];
setUploadedBytes(0);
return;
}
let cancelled = false;
setUploadState("preparing");
setError("");
computeFingerprint(file)
.then((fp) => {
if (cancelled) {
return;
}
setFingerprint(fp);
const chunks = buildChunks(file);
const stored = loadProgress(fp);
if (
stored &&
stored.fileSize === file.size &&
stored.fileName === file.name &&
stored.totalChunks === chunks.length
) {
const restored = new Set(stored.uploadedIndices);
let restoredBytes = 0;
for (const chunk of chunks) {
if (restored.has(chunk.index)) {
chunk.uploaded = true;
restoredBytes += chunk.end - chunk.start;
}
}
chunksRef.current = chunks;
setUploadedBytes(restoredBytes);
setResumedCount(restored.size);
if (restored.size === chunks.length) {
setUploadState("completed");
} else {
setUploadState("paused");
}
} else {
chunksRef.current = chunks;
setUploadedBytes(0);
setResumedCount(0);
setUploadState("idle");
}
})
.catch((err) => {
if (cancelled) {
return;
}
console.error("计算文件指纹失败", err);
setError("无法识别该文件,请重试");
setUploadState("error");
});
return () => {
cancelled = true;
};
}, [file]);
const totalChunks = chunksRef.current.length;
const uploadedChunkCount = chunksRef.current.filter((c) => c.uploaded).length;
const progress =
file && file.size > 0
? Math.min(100, (uploadedBytes / file.size) * 100)
: 0;
const validateAndSetFile = (selectedFile?: File) => {
abortRef.current?.abort();
pauseRef.current = false;
setError("");
if (!selectedFile) {
setFile(null);
return false;
}
if (selectedFile.size > maxFileSize) {
setError(`文件大小不能超过 ${formatFileSize(maxFileSize)}`);
setFile(null);
return false;
}
setFile(selectedFile);
return true;
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const isValid = validateAndSetFile(e.target.files?.[0]);
if (!isValid) {
e.target.value = "";
}
};
const handleDrop = (e: React.DragEvent<HTMLLabelElement>) => {
e.preventDefault();
setIsDragging(false);
validateAndSetFile(e.dataTransfer.files?.[0]);
};
const handleClear = () => {
abortRef.current?.abort();
pauseRef.current = false;
setFile(null);
setError("");
if (inputRef.current) {
inputRef.current.value = "";
}
};
const persistCurrentProgress = () => {
const fp = fingerprintRef.current;
if (!fp || !file) {
return;
}
const uploadedIndices = chunksRef.current
.filter((c) => c.uploaded)
.map((c) => c.index);
saveProgress(fp, {
fileName: file.name,
fileSize: file.size,
totalChunks: chunksRef.current.length,
uploadedIndices,
updatedAt: Date.now(),
});
};
const startUpload = async () => {
if (!file || !fingerprint) {
setError("请先选择需要上传的文件");
return;
}
if (chunksRef.current.length === 0) {
chunksRef.current = buildChunks(file);
}
pauseRef.current = false;
const controller = new AbortController();
abortRef.current = controller;
setUploadState("uploading");
setError("");
const total = chunksRef.current.length;
const fp = fingerprint;
const worker = async () => {
while (!pauseRef.current && !controller.signal.aborted) {
const target = chunksRef.current.find(
(c) => !c.uploaded && !c.uploading,
);
if (!target) {
return;
}
target.uploading = true;
try {
await uploadChunkRequest(
file.slice(target.start, target.end),
{
index: target.index,
total,
fingerprint: fp,
fileName: file.name,
},
controller.signal,
);
target.uploaded = true;
target.uploading = false;
setUploadedBytes((prev) => prev + (target.end - target.start));
persistCurrentProgress();
} catch (err) {
target.uploading = false;
if ((err as Error).name === "AbortError") {
return;
}
throw err;
}
}
};
try {
const workers = Array.from(
{ length: Math.min(maxConcurrency, total) },
worker,
);
await Promise.all(workers);
if (controller.signal.aborted) {
return;
}
if (chunksRef.current.every((c) => c.uploaded)) {
setUploadState("completed");
clearProgress(fp);
console.log("所有分片上传完成,文件:", file.name, "指纹:", fp);
// 真实实现:通知后端合并分片
// await fetch("/api/upload/complete", { method: "POST", body: JSON.stringify({ fingerprint: fp }) });
}
} catch (err) {
console.error("上传失败", err);
setUploadState("error");
setError("上传失败,已保留进度可重试");
}
};
const pauseUpload = () => {
pauseRef.current = true;
abortRef.current?.abort();
setUploadState("paused");
persistCurrentProgress();
};
const cancelUpload = () => {
abortRef.current?.abort();
pauseRef.current = false;
if (fingerprint) {
clearProgress(fingerprint);
}
chunksRef.current = file ? buildChunks(file) : [];
setUploadedBytes(0);
setResumedCount(0);
setUploadState(file ? "idle" : "idle");
};
const isPreparing = uploadState === "preparing";
const isUploading = uploadState === "uploading";
const isPaused = uploadState === "paused";
const isCompleted = uploadState === "completed";
const isErrored = uploadState === "error";
const hasProgress = totalChunks > 0 && uploadedChunkCount > 0;
const canStart =
!!file && !!fingerprint && !isUploading && !isPreparing && !isCompleted;
return (
<main className="min-h-dvh bg-muted/30 px-4 py-8 sm:px-6 lg:px-8">
<div className="mx-auto flex w-full max-w-3xl flex-col gap-5">
<header className="flex flex-col gap-2">
<p className="text-sm font-medium text-muted-foreground">文件上传</p>
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-normal text-foreground">
大文件分片 · 断点续传
</h1>
<p className="mt-2 max-w-xl text-sm leading-6 text-muted-foreground">
单文件最大 {formatFileSize(maxFileSize)},按{" "}
{formatFileSize(chunkSize)} 切片,{maxConcurrency}{" "}
并发上传;进度持久化到本地,刷新或重选同一文件可自动续传。
</p>
</div>
<div className="flex w-fit items-center gap-2 rounded-lg border bg-background px-3 py-2 text-xs text-muted-foreground">
<CheckCircle2 className="size-4 text-emerald-600" />
本地校验已启用
</div>
</div>
</header>
<section className="overflow-hidden rounded-xl border bg-background shadow-sm">
<div className="grid gap-0 lg:grid-cols-[1fr_280px]">
<div className="p-4 sm:p-6">
<label
className={[
"flex min-h-70 cursor-pointer flex-col items-center justify-center gap-4 rounded-lg border border-dashed px-5 py-8 text-center transition-colors",
isDragging
? "border-primary bg-primary/5"
: "border-border bg-muted/30 hover:bg-muted/50",
error ? "border-destructive/70 bg-destructive/5" : "",
isUploading || isPreparing
? "pointer-events-none opacity-60"
: "",
].join(" ")}
onDragEnter={() => setIsDragging(true)}
onDragLeave={() => setIsDragging(false)}
onDragOver={(e) => e.preventDefault()}
onDrop={handleDrop}
>
<input
ref={inputRef}
className="sr-only"
type="file"
name="file"
disabled={isUploading || isPreparing}
onChange={handleFileChange}
/>
<span className="flex size-14 items-center justify-center rounded-lg border bg-background">
<UploadCloud className="size-7 text-muted-foreground" />
</span>
<span className="space-y-1">
<span className="block text-base font-medium text-foreground">
拖拽文件到这里,或点击选择文件
</span>
<span className="block text-sm text-muted-foreground">
单文件最大 {formatFileSize(maxFileSize)}
</span>
</span>
</label>
{file && totalChunks > 0 ? (
<div className="mt-4 space-y-2">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>
{uploadedChunkCount} / {totalChunks} 分片 ·{" "}
{formatFileSize(uploadedBytes)} /{" "}
{formatFileSize(file.size)}
</span>
<span className="font-medium text-foreground">
{progress.toFixed(1)}%
</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-muted">
<div
className={[
"h-full transition-all",
isErrored
? "bg-destructive"
: isCompleted
? "bg-emerald-500"
: "bg-primary",
].join(" ")}
style={{ width: `${progress}%` }}
/>
</div>
{resumedCount > 0 && !isCompleted ? (
<p className="flex items-center gap-1.5 text-xs text-amber-600">
<RotateCcw className="size-3.5" />
已从本地恢复 {resumedCount} 个分片
</p>
) : null}
</div>
) : null}
</div>
<aside className="border-t bg-muted/20 p-4 sm:p-6 lg:border-l lg:border-t-0">
<div className="flex h-full min-h-55 flex-col gap-4">
<div className="flex items-center justify-between gap-3">
<h2 className="text-sm font-medium text-foreground">
文件详情
</h2>
{file ? (
<Button
aria-label="清除已选文件"
onClick={handleClear}
size="icon-sm"
type="button"
variant="ghost"
>
<Trash2 className="size-4" />
</Button>
) : null}
</div>
<div className="flex flex-1 flex-col gap-4">
<div className="flex aspect-4/3 items-center justify-center overflow-hidden rounded-lg border bg-background">
{previewUrl ? (
<img
alt={file?.name ?? "已选择文件"}
className="h-full w-full object-contain"
src={previewUrl}
/>
) : file ? (
<div className="flex flex-col items-center gap-2 text-muted-foreground">
<FileIcon className="size-8" />
<span className="px-4 text-center text-xs break-all">
{file.name}
</span>
</div>
) : (
<div className="flex flex-col items-center gap-2 text-muted-foreground">
<ImageIcon className="size-8" />
<span className="text-sm">等待选择文件</span>
</div>
)}
</div>
{file ? (
<dl className="grid gap-3 text-sm">
<div>
<dt className="text-muted-foreground">文件名</dt>
<dd className="mt-1 break-all font-medium text-foreground">
{file.name}
</dd>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<dt className="text-muted-foreground">大小</dt>
<dd className="mt-1 font-medium text-foreground">
{formatFileSize(file.size)}
</dd>
</div>
<div>
<dt className="text-muted-foreground">类型</dt>
<dd className="mt-1 truncate font-medium text-foreground">
{file.type || "未知"}
</dd>
</div>
</div>
<div>
<dt className="text-muted-foreground">指纹</dt>
<dd className="mt-1 truncate font-mono text-xs text-foreground">
{isPreparing ? (
<span className="inline-flex items-center gap-1 text-muted-foreground">
<Loader2 className="size-3 animate-spin" />
计算中…
</span>
) : (
fingerprint.slice(0, 16) || ""
)}
</dd>
</div>
</dl>
) : (
<p className="text-sm leading-6 text-muted-foreground">
选择文件后,这里会显示预览、文件名、大小和指纹。
</p>
)}
</div>
</div>
</aside>
</div>
<div className="flex flex-col gap-3 border-t bg-background px-4 py-4 sm:flex-row sm:items-center sm:justify-between sm:px-6">
<div className="min-h-5">
{error ? (
<p className="flex items-center gap-2 text-sm text-destructive">
<AlertCircle className="size-4" />
{error}
</p>
) : isCompleted ? (
<p className="flex items-center gap-2 text-sm text-emerald-700">
<CheckCircle2 className="size-4" />
上传完成
</p>
) : isUploading ? (
<p className="flex items-center gap-2 text-sm text-primary">
<UploadCloud className="size-4" />
上传中… {progress.toFixed(1)}%
</p>
) : isPaused ? (
<p className="flex items-center gap-2 text-sm text-amber-600">
<Pause className="size-4" />
已暂停,进度已保存到本地
</p>
) : isPreparing ? (
<p className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
正在计算文件指纹…
</p>
) : file ? (
<p className="flex items-center gap-2 text-sm text-emerald-700">
<CheckCircle2 className="size-4" />
文件已就绪,可以上传
</p>
) : (
<p className="text-sm text-muted-foreground">
请先选择需要上传的文件。
</p>
)}
</div>
<div className="flex flex-wrap gap-2">
{isUploading ? (
<Button
onClick={pauseUpload}
type="button"
variant="outline"
className="w-full sm:w-auto"
>
<Pause className="size-4" />
暂停
</Button>
) : null}
{(isPaused || isErrored) && hasProgress ? (
<Button
onClick={cancelUpload}
type="button"
variant="outline"
className="w-full sm:w-auto"
>
<X className="size-4" />
清除进度
</Button>
) : null}
{isUploading ? null : (
<Button
disabled={!canStart}
onClick={() => void startUpload()}
type="button"
className="w-full sm:w-auto"
>
{isCompleted ? (
<>
<CheckCircle2 className="size-4" />
已完成
</>
) : isErrored ? (
<>
<UploadCloud className="size-4" />
重试
</>
) : isPaused ? (
<>
<Play className="size-4" />
继续上传
</>
) : (
<>
<UploadCloud className="size-4" />
开始上传
</>
)}
</Button>
)}
</div>
</div>
</section>
</div>
</main>
);
}
  • 转为 base64 传输
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<style>
#preview {
max-width: 80%;
}
</style>
</head>
<body>
<input type="file">
<img src="" alt="" id="preview">
</html>
const input = document.querySelector("input");
const preview = document.getElementById("preview");
input.addEventListener("change", (event) => {
const file = event.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = function (e) {
preview.src = e.target.result;
};
reader.readAsDataURL(file);
}
});

转化关系