添加上传和下载文件夹
This commit is contained in:
@@ -11,6 +11,7 @@ import {
|
||||
ChevronRight,
|
||||
ChevronUp,
|
||||
FileUp,
|
||||
FolderUp,
|
||||
Upload,
|
||||
UploadCloud,
|
||||
Plus,
|
||||
@@ -34,10 +35,15 @@ import { cn } from '@/src/lib/utils';
|
||||
|
||||
import {
|
||||
buildUploadProgressSnapshot,
|
||||
createUploadMeasurement,
|
||||
createUploadTasks,
|
||||
completeUploadTask,
|
||||
createUploadTask,
|
||||
failUploadTask,
|
||||
prepareUploadTaskForCompletion,
|
||||
prepareFolderUploadEntries,
|
||||
prepareUploadFile,
|
||||
shouldUploadEntriesSequentially,
|
||||
type PendingUploadEntry,
|
||||
type UploadMeasurement,
|
||||
type UploadTask,
|
||||
} from './files-upload';
|
||||
@@ -63,6 +69,12 @@ const DIRECTORIES = [
|
||||
{ name: '图片', icon: Folder },
|
||||
];
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
|
||||
function toBackendPath(pathParts: string[]) {
|
||||
return pathParts.length === 0 ? '/' : `/${pathParts.join('/')}`;
|
||||
}
|
||||
@@ -115,6 +127,7 @@ export default function Files() {
|
||||
const initialPath = readCachedValue<string[]>(getFilesLastPathCacheKey()) ?? [];
|
||||
const initialCachedFiles = readCachedValue<FileMetadata[]>(getFilesListCacheKey(toBackendPath(initialPath))) ?? [];
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const directoryInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const uploadMeasurementsRef = useRef(new Map<string, UploadMeasurement>());
|
||||
const [currentPath, setCurrentPath] = useState<string[]>(initialPath);
|
||||
const currentPathRef = useRef(currentPath);
|
||||
@@ -128,7 +141,7 @@ export default function Files() {
|
||||
const [fileToDelete, setFileToDelete] = useState<UiFile | null>(null);
|
||||
const [newFileName, setNewFileName] = useState('');
|
||||
const [activeDropdown, setActiveDropdown] = useState<number | null>(null);
|
||||
const [viewMode, setViewMode] = useState<'list' | 'grid'>('grid');
|
||||
const [viewMode, setViewMode] = useState<'list' | 'grid'>('list');
|
||||
const [renameError, setRenameError] = useState('');
|
||||
const [isRenaming, setIsRenaming] = useState(false);
|
||||
|
||||
@@ -157,6 +170,15 @@ export default function Files() {
|
||||
});
|
||||
}, [currentPath]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!directoryInputRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
directoryInputRef.current.setAttribute('webkitdirectory', '');
|
||||
directoryInputRef.current.setAttribute('directory', '');
|
||||
}, []);
|
||||
|
||||
const handleSidebarClick = (pathParts: string[]) => {
|
||||
setCurrentPath(pathParts);
|
||||
setSelectedFile(null);
|
||||
@@ -192,25 +214,28 @@ export default function Files() {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = event.target.files ? (Array.from(event.target.files) as File[]) : [];
|
||||
event.target.value = '';
|
||||
const handleUploadFolderClick = () => {
|
||||
directoryInputRef.current?.click();
|
||||
};
|
||||
|
||||
if (files.length === 0) {
|
||||
const runUploadEntries = async (entries: PendingUploadEntry[]) => {
|
||||
if (entries.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const uploadPathParts = [...currentPath];
|
||||
const uploadPath = toBackendPath(uploadPathParts);
|
||||
const reservedNames = new Set<string>(currentFiles.map((file) => file.name));
|
||||
setIsUploadPanelOpen(true);
|
||||
uploadMeasurementsRef.current.clear();
|
||||
|
||||
const uploadJobs = files.map(async (file) => {
|
||||
const preparedUpload = prepareUploadFile(file, reservedNames);
|
||||
reservedNames.add(preparedUpload.file.name);
|
||||
const uploadFile = preparedUpload.file;
|
||||
const uploadTask = createUploadTask(uploadFile, uploadPathParts, undefined, preparedUpload.noticeMessage);
|
||||
setUploads((previous) => [...previous, uploadTask]);
|
||||
const batchTasks = createUploadTasks(entries);
|
||||
setUploads(batchTasks);
|
||||
|
||||
const runSingleUpload = async (
|
||||
{file: uploadFile, pathParts: uploadPathParts}: PendingUploadEntry,
|
||||
uploadTask: UploadTask,
|
||||
) => {
|
||||
const uploadPath = toBackendPath(uploadPathParts);
|
||||
const startedAt = Date.now();
|
||||
uploadMeasurementsRef.current.set(uploadTask.id, createUploadMeasurement(startedAt));
|
||||
|
||||
try {
|
||||
const updateProgress = ({loaded, total}: {loaded: number; total: number}) => {
|
||||
@@ -303,6 +328,10 @@ export default function Files() {
|
||||
}
|
||||
|
||||
uploadMeasurementsRef.current.delete(uploadTask.id);
|
||||
setUploads((previous) =>
|
||||
previous.map((task) => (task.id === uploadTask.id ? prepareUploadTaskForCompletion(task) : task)),
|
||||
);
|
||||
await sleep(120);
|
||||
setUploads((previous) =>
|
||||
previous.map((task) => (task.id === uploadTask.id ? completeUploadTask(task) : task)),
|
||||
);
|
||||
@@ -315,12 +344,62 @@ export default function Files() {
|
||||
);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const results = shouldUploadEntriesSequentially(entries)
|
||||
? await entries.reduce<Promise<Array<FileMetadata | null>>>(
|
||||
async (previousPromise, entry, index) => {
|
||||
const previous = await previousPromise;
|
||||
const current = await runSingleUpload(entry, batchTasks[index]);
|
||||
return [...previous, current];
|
||||
},
|
||||
Promise.resolve([]),
|
||||
)
|
||||
: await Promise.all(entries.map((entry, index) => runSingleUpload(entry, batchTasks[index])));
|
||||
|
||||
if (results.some(Boolean)) {
|
||||
await loadCurrentPath(currentPathRef.current).catch(() => undefined);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = event.target.files ? (Array.from(event.target.files) as File[]) : [];
|
||||
event.target.value = '';
|
||||
|
||||
if (files.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const reservedNames = new Set<string>(currentFiles.map((file) => file.name));
|
||||
const entries: PendingUploadEntry[] = files.map((file) => {
|
||||
const preparedUpload = prepareUploadFile(file, reservedNames);
|
||||
reservedNames.add(preparedUpload.file.name);
|
||||
return {
|
||||
file: preparedUpload.file,
|
||||
pathParts: [...currentPath],
|
||||
source: 'file' as const,
|
||||
noticeMessage: preparedUpload.noticeMessage,
|
||||
};
|
||||
});
|
||||
|
||||
const results = await Promise.all(uploadJobs);
|
||||
if (results.some(Boolean) && toBackendPath(currentPathRef.current) === uploadPath) {
|
||||
await loadCurrentPath(uploadPathParts).catch(() => undefined);
|
||||
await runUploadEntries(entries);
|
||||
};
|
||||
|
||||
const handleFolderChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = event.target.files ? (Array.from(event.target.files) as File[]) : [];
|
||||
event.target.value = '';
|
||||
|
||||
if (files.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const entries = prepareFolderUploadEntries(
|
||||
files,
|
||||
[...currentPath],
|
||||
currentFiles.map((file) => file.name),
|
||||
);
|
||||
|
||||
await runUploadEntries(entries);
|
||||
};
|
||||
|
||||
const handleCreateFolder = async () => {
|
||||
@@ -399,17 +478,29 @@ export default function Files() {
|
||||
await loadCurrentPath(currentPath).catch(() => undefined);
|
||||
};
|
||||
|
||||
const handleDownload = async () => {
|
||||
if (!selectedFile || selectedFile.type === 'folder') {
|
||||
const handleDownload = async (targetFile: UiFile | null = selectedFile) => {
|
||||
if (!targetFile) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (targetFile.type === 'folder') {
|
||||
const response = await apiDownload(`/files/download/${targetFile.id}`);
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `${targetFile.name}.zip`;
|
||||
link.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await apiRequest<DownloadUrlResponse>(`/files/download/${selectedFile.id}/url`);
|
||||
const response = await apiRequest<DownloadUrlResponse>(`/files/download/${targetFile.id}/url`);
|
||||
const url = response.url;
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = selectedFile.name;
|
||||
link.download = targetFile.name;
|
||||
link.rel = 'noreferrer';
|
||||
link.target = '_blank';
|
||||
link.click();
|
||||
@@ -420,12 +511,12 @@ export default function Files() {
|
||||
}
|
||||
}
|
||||
|
||||
const response = await apiDownload(`/files/download/${selectedFile.id}`);
|
||||
const response = await apiDownload(`/files/download/${targetFile.id}`);
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = selectedFile.name;
|
||||
link.download = targetFile.name;
|
||||
link.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
};
|
||||
@@ -573,6 +664,7 @@ export default function Files() {
|
||||
file={file}
|
||||
activeDropdown={activeDropdown}
|
||||
onToggle={(fileId) => setActiveDropdown((previous) => (previous === fileId ? null : fileId))}
|
||||
onDownload={handleDownload}
|
||||
onRename={openRenameModal}
|
||||
onDelete={openDeleteModal}
|
||||
onClose={() => setActiveDropdown(null)}
|
||||
@@ -601,6 +693,7 @@ export default function Files() {
|
||||
file={file}
|
||||
activeDropdown={activeDropdown}
|
||||
onToggle={(fileId) => setActiveDropdown((previous) => (previous === fileId ? null : fileId))}
|
||||
onDownload={handleDownload}
|
||||
onRename={openRenameModal}
|
||||
onDelete={openDeleteModal}
|
||||
onClose={() => setActiveDropdown(null)}
|
||||
@@ -634,10 +727,14 @@ export default function Files() {
|
||||
<Button variant="default" className="gap-2" onClick={handleUploadClick}>
|
||||
<Upload className="w-4 h-4" /> 上传文件
|
||||
</Button>
|
||||
<Button variant="outline" className="gap-2" onClick={handleUploadFolderClick}>
|
||||
<FolderUp className="w-4 h-4" /> 上传文件夹
|
||||
</Button>
|
||||
<Button variant="outline" className="gap-2" onClick={handleCreateFolder}>
|
||||
<Plus className="w-4 h-4" /> 新建文件夹
|
||||
</Button>
|
||||
<input ref={fileInputRef} type="file" multiple className="hidden" onChange={handleFileChange} />
|
||||
<input ref={directoryInputRef} type="file" multiple className="hidden" onChange={handleFolderChange} />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -686,14 +783,19 @@ export default function Files() {
|
||||
<Trash2 className="w-4 h-4" /> 删除
|
||||
</Button>
|
||||
</div>
|
||||
{selectedFile.type !== 'folder' && (
|
||||
<Button variant="default" className="w-full gap-2" onClick={handleDownload}>
|
||||
<Download className="w-4 h-4" /> 下载文件
|
||||
</Button>
|
||||
)}
|
||||
{selectedFile.type === 'folder' && (
|
||||
<Button variant="default" className="w-full gap-2" onClick={() => handleFolderDoubleClick(selectedFile)}>
|
||||
打开文件夹
|
||||
<div className="space-y-3">
|
||||
<Button variant="default" className="w-full gap-2" onClick={() => handleFolderDoubleClick(selectedFile)}>
|
||||
打开文件夹
|
||||
</Button>
|
||||
<Button variant="default" className="w-full gap-2" onClick={() => void handleDownload(selectedFile)}>
|
||||
<Download className="w-4 h-4" /> 下载文件夹
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{selectedFile.type !== 'folder' && (
|
||||
<Button variant="default" className="w-full gap-2" onClick={() => void handleDownload(selectedFile)}>
|
||||
<Download className="w-4 h-4" /> 下载文件
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -939,6 +1041,7 @@ function FileActionMenu({
|
||||
file,
|
||||
activeDropdown,
|
||||
onToggle,
|
||||
onDownload,
|
||||
onRename,
|
||||
onDelete,
|
||||
onClose,
|
||||
@@ -946,6 +1049,7 @@ function FileActionMenu({
|
||||
file: UiFile;
|
||||
activeDropdown: number | null;
|
||||
onToggle: (fileId: number) => void;
|
||||
onDownload: (file: UiFile) => Promise<void>;
|
||||
onRename: (file: UiFile) => void;
|
||||
onDelete: (file: UiFile) => void;
|
||||
onClose: () => void;
|
||||
@@ -979,6 +1083,16 @@ function FileActionMenu({
|
||||
transition={{ duration: 0.15 }}
|
||||
className="absolute right-0 top-full z-50 mt-1 w-32 overflow-hidden rounded-lg border border-white/10 bg-[#1e293b] py-1 shadow-xl"
|
||||
>
|
||||
<button
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
void onDownload(file);
|
||||
onClose();
|
||||
}}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-slate-300 transition-colors hover:bg-white/10 hover:text-white"
|
||||
>
|
||||
<Download className="w-4 h-4" /> {file.type === 'folder' ? '下载文件夹' : '下载文件'}
|
||||
</button>
|
||||
<button
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
|
||||
@@ -4,9 +4,13 @@ import test from 'node:test';
|
||||
import {
|
||||
buildUploadProgressSnapshot,
|
||||
completeUploadTask,
|
||||
createUploadTasks,
|
||||
createUploadTask,
|
||||
prepareUploadTaskForCompletion,
|
||||
formatTransferSpeed,
|
||||
prepareFolderUploadEntries,
|
||||
prepareUploadFile,
|
||||
shouldUploadEntriesSequentially,
|
||||
} from './files-upload';
|
||||
|
||||
test('createUploadTask uses current path as upload destination', () => {
|
||||
@@ -95,3 +99,79 @@ test('prepareUploadFile keeps files without conflicts unchanged', () => {
|
||||
assert.equal(prepared.file.name, 'syllabus');
|
||||
assert.equal(prepared.noticeMessage, undefined);
|
||||
});
|
||||
|
||||
test('prepareFolderUploadEntries keeps relative directories and renames conflicting root folders', () => {
|
||||
const first = new File(['alpha'], 'a.txt', {type: 'text/plain'});
|
||||
Object.defineProperty(first, 'webkitRelativePath', {
|
||||
configurable: true,
|
||||
value: '设计稿/a.txt',
|
||||
});
|
||||
|
||||
const second = new File(['beta'], 'b.txt', {type: 'text/plain'});
|
||||
Object.defineProperty(second, 'webkitRelativePath', {
|
||||
configurable: true,
|
||||
value: '设计稿/子目录/b.txt',
|
||||
});
|
||||
|
||||
const entries = prepareFolderUploadEntries([first, second], ['文档'], ['设计稿']);
|
||||
|
||||
assert.equal(entries[0].pathParts.join('/'), '文档/设计稿 (1)');
|
||||
assert.equal(entries[1].pathParts.join('/'), '文档/设计稿 (1)/子目录');
|
||||
assert.equal(entries[0].noticeMessage, '检测到同名文件夹,已自动重命名为 设计稿 (1)');
|
||||
assert.equal(entries[1].noticeMessage, '检测到同名文件夹,已自动重命名为 设计稿 (1)');
|
||||
assert.equal(shouldUploadEntriesSequentially(entries), true);
|
||||
});
|
||||
|
||||
test('shouldUploadEntriesSequentially keeps plain file uploads in parallel mode', () => {
|
||||
const entries = [
|
||||
{
|
||||
file: new File(['alpha'], 'a.txt', {type: 'text/plain'}),
|
||||
pathParts: ['文档'],
|
||||
source: 'file' as const,
|
||||
},
|
||||
{
|
||||
file: new File(['beta'], 'b.txt', {type: 'text/plain'}),
|
||||
pathParts: ['文档'],
|
||||
source: 'file' as const,
|
||||
},
|
||||
];
|
||||
|
||||
assert.equal(shouldUploadEntriesSequentially(entries), false);
|
||||
});
|
||||
|
||||
test('createUploadTasks creates a stable task list for the whole batch', () => {
|
||||
const entries = [
|
||||
{
|
||||
file: new File(['alpha'], 'a.txt', {type: 'text/plain'}),
|
||||
pathParts: ['文档'],
|
||||
source: 'file' as const,
|
||||
noticeMessage: 'alpha',
|
||||
},
|
||||
{
|
||||
file: new File(['beta'], 'b.txt', {type: 'text/plain'}),
|
||||
pathParts: ['文档', '资料'],
|
||||
source: 'folder' as const,
|
||||
noticeMessage: 'beta',
|
||||
},
|
||||
];
|
||||
|
||||
const tasks = createUploadTasks(entries);
|
||||
|
||||
assert.equal(tasks.length, 2);
|
||||
assert.equal(tasks[0].fileName, 'a.txt');
|
||||
assert.equal(tasks[0].destination, '/文档');
|
||||
assert.equal(tasks[0].noticeMessage, 'alpha');
|
||||
assert.equal(tasks[1].fileName, 'b.txt');
|
||||
assert.equal(tasks[1].destination, '/文档/资料');
|
||||
assert.equal(tasks[1].noticeMessage, 'beta');
|
||||
});
|
||||
|
||||
test('prepareUploadTaskForCompletion keeps a visible progress state before marking complete', () => {
|
||||
const task = createUploadTask(new File(['alpha'], 'a.txt', {type: 'text/plain'}), ['文档'], 'task-3');
|
||||
|
||||
const nextTask = prepareUploadTaskForCompletion(task);
|
||||
|
||||
assert.equal(nextTask.status, 'uploading');
|
||||
assert.equal(nextTask.progress, 99);
|
||||
assert.equal(nextTask.speed, '即将完成...');
|
||||
});
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { getNextAvailableName } from './files-state';
|
||||
|
||||
export type UploadTaskStatus = 'uploading' | 'completed' | 'error';
|
||||
|
||||
export interface UploadTask {
|
||||
@@ -18,6 +20,13 @@ export interface UploadMeasurement {
|
||||
lastUpdatedAt: number;
|
||||
}
|
||||
|
||||
export interface PendingUploadEntry {
|
||||
file: File;
|
||||
pathParts: string[];
|
||||
source: 'file' | 'folder';
|
||||
noticeMessage?: string;
|
||||
}
|
||||
|
||||
function getUploadType(file: File) {
|
||||
const extension = file.name.includes('.') ? file.name.split('.').pop()?.toLowerCase() : '';
|
||||
|
||||
@@ -56,6 +65,18 @@ function splitFileName(fileName: string) {
|
||||
};
|
||||
}
|
||||
|
||||
function getRelativePathSegments(file: File) {
|
||||
const rawRelativePath = ('webkitRelativePath' in file && typeof file.webkitRelativePath === 'string' && file.webkitRelativePath)
|
||||
? file.webkitRelativePath
|
||||
: file.name;
|
||||
|
||||
return rawRelativePath
|
||||
.replaceAll('\\', '/')
|
||||
.split('/')
|
||||
.map((segment) => segment.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function getUploadDestination(pathParts: string[]) {
|
||||
return pathParts.length === 0 ? '/' : `/${pathParts.join('/')}`;
|
||||
}
|
||||
@@ -86,6 +107,67 @@ export function prepareUploadFile(file: File, usedNames: Set<string>) {
|
||||
};
|
||||
}
|
||||
|
||||
export function prepareFolderUploadEntries(
|
||||
files: File[],
|
||||
currentPathParts: string[],
|
||||
existingRootNames: string[],
|
||||
): PendingUploadEntry[] {
|
||||
const rootReservedNames = new Set(existingRootNames);
|
||||
const renamedRootFolders = new Map<string, string>();
|
||||
const usedNamesByDestination = new Map<string, Set<string>>();
|
||||
|
||||
return files.map((file) => {
|
||||
const relativeSegments = getRelativePathSegments(file);
|
||||
if (relativeSegments.length === 0) {
|
||||
return {
|
||||
file,
|
||||
pathParts: [...currentPathParts],
|
||||
source: 'folder',
|
||||
};
|
||||
}
|
||||
|
||||
let noticeMessage: string | undefined;
|
||||
if (relativeSegments.length > 1) {
|
||||
const originalRootFolder = relativeSegments[0];
|
||||
let renamedRootFolder = renamedRootFolders.get(originalRootFolder);
|
||||
if (!renamedRootFolder) {
|
||||
renamedRootFolder = getNextAvailableName(originalRootFolder, rootReservedNames);
|
||||
rootReservedNames.add(renamedRootFolder);
|
||||
renamedRootFolders.set(originalRootFolder, renamedRootFolder);
|
||||
}
|
||||
|
||||
if (renamedRootFolder !== originalRootFolder) {
|
||||
relativeSegments[0] = renamedRootFolder;
|
||||
noticeMessage = `检测到同名文件夹,已自动重命名为 ${renamedRootFolder}`;
|
||||
}
|
||||
}
|
||||
|
||||
const pathParts = [...currentPathParts, ...relativeSegments.slice(0, -1)];
|
||||
const destinationKey = getUploadDestination(pathParts);
|
||||
const usedNames = usedNamesByDestination.get(destinationKey) ?? new Set<string>();
|
||||
const preparedUpload = prepareUploadFile(
|
||||
new File([file], relativeSegments.at(-1) ?? file.name, {
|
||||
type: file.type,
|
||||
lastModified: file.lastModified,
|
||||
}),
|
||||
usedNames,
|
||||
);
|
||||
usedNames.add(preparedUpload.file.name);
|
||||
usedNamesByDestination.set(destinationKey, usedNames);
|
||||
|
||||
return {
|
||||
file: preparedUpload.file,
|
||||
pathParts,
|
||||
source: 'folder',
|
||||
noticeMessage: noticeMessage ?? preparedUpload.noticeMessage,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function shouldUploadEntriesSequentially(entries: PendingUploadEntry[]) {
|
||||
return entries.some((entry) => entry.source === 'folder');
|
||||
}
|
||||
|
||||
export function createUploadTask(
|
||||
file: File,
|
||||
pathParts: string[],
|
||||
@@ -104,6 +186,28 @@ export function createUploadTask(
|
||||
};
|
||||
}
|
||||
|
||||
export function createUploadTasks(entries: PendingUploadEntry[]) {
|
||||
return entries.map((entry) =>
|
||||
createUploadTask(entry.file, entry.pathParts, undefined, entry.noticeMessage),
|
||||
);
|
||||
}
|
||||
|
||||
export function createUploadMeasurement(startedAt: number): UploadMeasurement {
|
||||
return {
|
||||
startedAt,
|
||||
lastLoaded: 0,
|
||||
lastUpdatedAt: startedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function prepareUploadTaskForCompletion(task: UploadTask): UploadTask {
|
||||
return {
|
||||
...task,
|
||||
progress: Math.max(task.progress, 99),
|
||||
speed: task.speed && task.speed !== '等待上传...' ? task.speed : '即将完成...',
|
||||
};
|
||||
}
|
||||
|
||||
export function formatTransferSpeed(bytesPerSecond: number) {
|
||||
if (bytesPerSecond < 1024) {
|
||||
return `${Math.round(bytesPerSecond)} B/s`;
|
||||
|
||||
Reference in New Issue
Block a user