完善前端直连oss功能,新增重命名以及删除文件功能,完善后端接口

This commit is contained in:
yoyuzh
2026-03-19 10:26:50 +08:00
parent 96079b7e5b
commit e0d859bd82
26 changed files with 2545 additions and 183 deletions

View File

@@ -1,7 +1,7 @@
import assert from 'node:assert/strict';
import { afterEach, beforeEach, test } from 'node:test';
import { apiRequest, shouldRetryRequest, toNetworkApiError } from './api';
import { apiBinaryUploadRequest, apiRequest, apiUploadRequest, shouldRetryRequest, toNetworkApiError } from './api';
import { clearStoredSession, saveStoredSession } from './session';
class MemoryStorage implements Storage {
@@ -34,12 +34,88 @@ class MemoryStorage implements Storage {
const originalFetch = globalThis.fetch;
const originalStorage = globalThis.localStorage;
const originalXMLHttpRequest = globalThis.XMLHttpRequest;
class FakeXMLHttpRequest {
static latest: FakeXMLHttpRequest | null = null;
method = '';
url = '';
requestBody: Document | XMLHttpRequestBodyInit | null = null;
responseText = '';
status = 200;
headers = new Map<string, string>();
responseHeaders = new Map<string, string>();
onload: null | (() => void) = null;
onerror: null | (() => void) = null;
upload = {
addEventListener: (type: string, listener: EventListenerOrEventListenerObject) => {
if (type !== 'progress') {
return;
}
this.progressListeners.push(listener);
},
};
private progressListeners: EventListenerOrEventListenerObject[] = [];
constructor() {
FakeXMLHttpRequest.latest = this;
}
open(method: string, url: string) {
this.method = method;
this.url = url;
}
setRequestHeader(name: string, value: string) {
this.headers.set(name.toLowerCase(), value);
}
getResponseHeader(name: string) {
return this.responseHeaders.get(name) ?? null;
}
send(body: Document | XMLHttpRequestBodyInit | null) {
this.requestBody = body;
}
triggerProgress(loaded: number, total: number) {
const event = {
lengthComputable: true,
loaded,
total,
} as ProgressEvent<EventTarget>;
for (const listener of this.progressListeners) {
if (typeof listener === 'function') {
listener(event);
} else {
listener.handleEvent(event);
}
}
}
respond(body: unknown, status = 200, contentType = 'application/json') {
this.status = status;
this.responseText = typeof body === 'string' ? body : JSON.stringify(body);
this.responseHeaders.set('content-type', contentType);
this.onload?.();
}
}
beforeEach(() => {
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: new MemoryStorage(),
});
Object.defineProperty(globalThis, 'XMLHttpRequest', {
configurable: true,
value: FakeXMLHttpRequest,
});
FakeXMLHttpRequest.latest = null;
clearStoredSession();
});
@@ -49,6 +125,10 @@ afterEach(() => {
configurable: true,
value: originalStorage,
});
Object.defineProperty(globalThis, 'XMLHttpRequest', {
configurable: true,
value: originalXMLHttpRequest,
});
});
test('apiRequest attaches bearer token and unwraps response payload', async () => {
@@ -133,9 +213,99 @@ test('network get failures are retried up to two times after the first attempt',
assert.equal(shouldRetryRequest('/files/list', {method: 'GET'}, error, 3), false);
});
test('network rename failures are retried once for idempotent file rename requests', () => {
const error = new TypeError('Failed to fetch');
assert.equal(shouldRetryRequest('/files/32/rename', {method: 'PATCH'}, error, 0), true);
assert.equal(shouldRetryRequest('/files/32/rename', {method: 'PATCH'}, error, 1), false);
});
test('network fetch failures are converted to readable api errors', () => {
const apiError = toNetworkApiError(new TypeError('Failed to fetch'));
assert.equal(apiError.status, 0);
assert.match(apiError.message, /网络连接异常|Failed to fetch/);
});
test('apiUploadRequest attaches auth header and forwards upload progress', async () => {
saveStoredSession({
token: 'token-456',
user: {
id: 2,
username: 'uploader',
email: 'uploader@example.com',
createdAt: '2026-03-18T10:00:00',
},
});
const progressCalls: Array<{loaded: number; total: number}> = [];
const formData = new FormData();
formData.append('file', new Blob(['hello']), 'hello.txt');
const uploadPromise = apiUploadRequest<{id: number}>('/files/upload?path=%2F', {
body: formData,
onProgress: (progress) => {
progressCalls.push(progress);
},
});
const request = FakeXMLHttpRequest.latest;
assert.ok(request);
assert.equal(request.method, 'POST');
assert.equal(request.url, '/api/files/upload?path=%2F');
assert.equal(request.headers.get('authorization'), 'Bearer token-456');
assert.equal(request.headers.get('accept'), 'application/json');
assert.equal(request.requestBody, formData);
request.triggerProgress(128, 512);
request.triggerProgress(512, 512);
request.respond({
code: 0,
msg: 'success',
data: {
id: 7,
},
});
const payload = await uploadPromise;
assert.deepEqual(payload, {id: 7});
assert.deepEqual(progressCalls, [
{loaded: 128, total: 512},
{loaded: 512, total: 512},
]);
});
test('apiBinaryUploadRequest sends raw file body to signed upload url', async () => {
const progressCalls: Array<{loaded: number; total: number}> = [];
const fileBody = new Blob(['hello-oss']);
const uploadPromise = apiBinaryUploadRequest('https://upload.example.com/object', {
method: 'PUT',
headers: {
'Content-Type': 'text/plain',
'x-oss-meta-test': '1',
},
body: fileBody,
onProgress: (progress) => {
progressCalls.push(progress);
},
});
const request = FakeXMLHttpRequest.latest;
assert.ok(request);
assert.equal(request.method, 'PUT');
assert.equal(request.url, 'https://upload.example.com/object');
assert.equal(request.headers.get('content-type'), 'text/plain');
assert.equal(request.headers.get('x-oss-meta-test'), '1');
assert.equal(request.requestBody, fileBody);
request.triggerProgress(64, 128);
request.triggerProgress(128, 128);
request.respond('', 200, 'text/plain');
await uploadPromise;
assert.deepEqual(progressCalls, [
{loaded: 64, total: 128},
{loaded: 128, total: 128},
]);
});

View File

@@ -10,6 +10,20 @@ interface ApiRequestInit extends Omit<RequestInit, 'body'> {
body?: unknown;
}
interface ApiUploadRequestInit {
body: FormData;
headers?: HeadersInit;
method?: 'POST' | 'PUT' | 'PATCH';
onProgress?: (progress: {loaded: number; total: number}) => void;
}
interface ApiBinaryUploadRequestInit {
body: Blob;
headers?: HeadersInit;
method?: 'PUT' | 'POST';
onProgress?: (progress: {loaded: number; total: number}) => void;
}
const API_BASE_URL = (import.meta.env?.VITE_API_BASE_URL || '/api').replace(/\/$/, '');
export class ApiError extends Error {
@@ -48,6 +62,10 @@ function getMaxRetryAttempts(path: string, init: ApiRequestInit = {}) {
return 1;
}
if (method === 'PATCH' && /^\/files\/\d+\/rename$/.test(path)) {
return 0;
}
if (method === 'GET' || method === 'HEAD' || method === 'OPTIONS') {
return 2;
}
@@ -191,6 +209,116 @@ export async function apiRequest<T>(path: string, init?: ApiRequestInit) {
return payload.data;
}
export function apiUploadRequest<T>(path: string, init: ApiUploadRequestInit) {
const session = readStoredSession();
const headers = new Headers(init.headers);
if (session?.token) {
headers.set('Authorization', `Bearer ${session.token}`);
}
if (!headers.has('Accept')) {
headers.set('Accept', 'application/json');
}
return new Promise<T>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open(init.method || 'POST', resolveUrl(path));
headers.forEach((value, key) => {
xhr.setRequestHeader(key, value);
});
if (init.onProgress) {
xhr.upload.addEventListener('progress', (event) => {
if (!event.lengthComputable) {
return;
}
init.onProgress?.({
loaded: event.loaded,
total: event.total,
});
});
}
xhr.onerror = () => {
reject(toNetworkApiError(new TypeError('Failed to fetch')));
};
xhr.onload = () => {
const contentType = xhr.getResponseHeader('content-type') || '';
if (xhr.status === 401 || xhr.status === 403) {
clearStoredSession();
}
if (!contentType.includes('application/json')) {
if (xhr.status >= 200 && xhr.status < 300) {
resolve(undefined as T);
return;
}
reject(new ApiError(`请求失败 (${xhr.status})`, xhr.status));
return;
}
const payload = JSON.parse(xhr.responseText) as ApiEnvelope<T>;
if (xhr.status < 200 || xhr.status >= 300 || payload.code !== 0) {
if (xhr.status === 401 || payload.code === 401) {
clearStoredSession();
}
reject(new ApiError(payload.msg || `请求失败 (${xhr.status})`, xhr.status, payload.code));
return;
}
resolve(payload.data);
};
xhr.send(init.body);
});
}
export function apiBinaryUploadRequest(path: string, init: ApiBinaryUploadRequestInit) {
const headers = new Headers(init.headers);
return new Promise<void>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open(init.method || 'PUT', resolveUrl(path));
headers.forEach((value, key) => {
xhr.setRequestHeader(key, value);
});
if (init.onProgress) {
xhr.upload.addEventListener('progress', (event) => {
if (!event.lengthComputable) {
return;
}
init.onProgress?.({
loaded: event.loaded,
total: event.total,
});
});
}
xhr.onerror = () => {
reject(toNetworkApiError(new TypeError('Failed to fetch')));
};
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve();
return;
}
reject(new ApiError(`请求失败 (${xhr.status})`, xhr.status));
};
xhr.send(init.body);
});
}
export async function apiDownload(path: string) {
const response = await performRequest(path, {
headers: {

View File

@@ -32,6 +32,18 @@ export interface FileMetadata {
createdAt: string;
}
export interface InitiateUploadResponse {
direct: boolean;
uploadUrl: string;
method: 'POST' | 'PUT';
headers: Record<string, string>;
storageName: string;
}
export interface DownloadUrlResponse {
url: string;
}
export interface CourseResponse {
courseName: string;
teacher: string | null;

View File

@@ -1,27 +1,55 @@
import React, { useEffect, useRef, useState } from 'react';
import { motion } from 'motion/react';
import { AnimatePresence, motion } from 'motion/react';
import {
CheckCircle2,
ChevronDown,
Folder,
FileText,
Image as ImageIcon,
Download,
Monitor,
ChevronRight,
ChevronUp,
FileUp,
Upload,
UploadCloud,
Plus,
LayoutGrid,
List,
MoreVertical,
TriangleAlert,
X,
Edit2,
Trash2,
} from 'lucide-react';
import { Button } from '@/src/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/src/components/ui/card';
import { apiDownload, apiRequest } from '@/src/lib/api';
import { Input } from '@/src/components/ui/input';
import { ApiError, apiBinaryUploadRequest, apiDownload, apiRequest, apiUploadRequest } from '@/src/lib/api';
import { readCachedValue, writeCachedValue } from '@/src/lib/cache';
import { getFilesLastPathCacheKey, getFilesListCacheKey } from '@/src/lib/page-cache';
import type { FileMetadata, PageResponse } from '@/src/lib/types';
import type { DownloadUrlResponse, FileMetadata, InitiateUploadResponse, PageResponse } from '@/src/lib/types';
import { cn } from '@/src/lib/utils';
import {
buildUploadProgressSnapshot,
completeUploadTask,
createUploadTask,
failUploadTask,
prepareUploadFile,
type UploadMeasurement,
type UploadTask,
} from './files-upload';
import {
clearSelectionIfDeleted,
getNextAvailableName,
getActionErrorMessage,
removeUiFile,
replaceUiFile,
syncSelectedFile,
} from './files-state';
const QUICK_ACCESS = [
{ name: '桌面', icon: Monitor, path: [] as string[] },
{ name: '下载', icon: Download, path: ['下载'] },
@@ -81,13 +109,28 @@ function toUiFile(file: FileMetadata) {
};
}
type UiFile = ReturnType<typeof toUiFile>;
export default function Files() {
const initialPath = readCachedValue<string[]>(getFilesLastPathCacheKey()) ?? [];
const initialCachedFiles = readCachedValue<FileMetadata[]>(getFilesListCacheKey(toBackendPath(initialPath))) ?? [];
const fileInputRef = useRef<HTMLInputElement | null>(null);
const uploadMeasurementsRef = useRef(new Map<string, UploadMeasurement>());
const [currentPath, setCurrentPath] = useState<string[]>(initialPath);
const [selectedFile, setSelectedFile] = useState<any | null>(null);
const [currentFiles, setCurrentFiles] = useState<any[]>(initialCachedFiles.map(toUiFile));
const currentPathRef = useRef(currentPath);
const [selectedFile, setSelectedFile] = useState<UiFile | null>(null);
const [currentFiles, setCurrentFiles] = useState<UiFile[]>(initialCachedFiles.map(toUiFile));
const [uploads, setUploads] = useState<UploadTask[]>([]);
const [isUploadPanelOpen, setIsUploadPanelOpen] = useState(true);
const [renameModalOpen, setRenameModalOpen] = useState(false);
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
const [fileToRename, setFileToRename] = useState<UiFile | null>(null);
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 [renameError, setRenameError] = useState('');
const [isRenaming, setIsRenaming] = useState(false);
const loadCurrentPath = async (pathParts: string[]) => {
const response = await apiRequest<PageResponse<FileMetadata>>(
@@ -99,6 +142,7 @@ export default function Files() {
};
useEffect(() => {
currentPathRef.current = currentPath;
const cachedFiles = readCachedValue<FileMetadata[]>(getFilesListCacheKey(toBackendPath(currentPath)));
writeCachedValue(getFilesLastPathCacheKey(), currentPath);
@@ -116,9 +160,10 @@ export default function Files() {
const handleSidebarClick = (pathParts: string[]) => {
setCurrentPath(pathParts);
setSelectedFile(null);
setActiveDropdown(null);
};
const handleFolderDoubleClick = (file: any) => {
const handleFolderDoubleClick = (file: UiFile) => {
if (file.type === 'folder') {
setCurrentPath([...currentPath, file.name]);
setSelectedFile(null);
@@ -128,6 +173,19 @@ export default function Files() {
const handleBreadcrumbClick = (index: number) => {
setCurrentPath(currentPath.slice(0, index + 1));
setSelectedFile(null);
setActiveDropdown(null);
};
const openRenameModal = (file: UiFile) => {
setFileToRename(file);
setNewFileName(file.name);
setRenameError('');
setRenameModalOpen(true);
};
const openDeleteModal = (file: UiFile) => {
setFileToDelete(file);
setDeleteModalOpen(true);
};
const handleUploadClick = () => {
@@ -135,21 +193,134 @@ export default function Files() {
};
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) {
const files = event.target.files ? (Array.from(event.target.files) as File[]) : [];
event.target.value = '';
if (files.length === 0) {
return;
}
const formData = new FormData();
formData.append('file', file);
const uploadPathParts = [...currentPath];
const uploadPath = toBackendPath(uploadPathParts);
const reservedNames = new Set<string>(currentFiles.map((file) => file.name));
setIsUploadPanelOpen(true);
await apiRequest(`/files/upload?path=${encodeURIComponent(toBackendPath(currentPath))}`, {
method: 'POST',
body: formData,
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]);
try {
const updateProgress = ({loaded, total}: {loaded: number; total: number}) => {
const snapshot = buildUploadProgressSnapshot({
loaded,
total,
now: Date.now(),
previous: uploadMeasurementsRef.current.get(uploadTask.id),
});
uploadMeasurementsRef.current.set(uploadTask.id, snapshot.measurement);
setUploads((previous) =>
previous.map((task) =>
task.id === uploadTask.id
? {
...task,
progress: snapshot.progress,
speed: snapshot.speed,
}
: task,
),
);
};
let initiated: InitiateUploadResponse | null = null;
try {
initiated = await apiRequest<InitiateUploadResponse>('/files/upload/initiate', {
method: 'POST',
body: {
path: uploadPath,
filename: uploadFile.name,
contentType: uploadFile.type || null,
size: uploadFile.size,
},
});
} catch (error) {
if (!(error instanceof ApiError && error.status === 404)) {
throw error;
}
}
let uploadedFile: FileMetadata;
if (initiated?.direct) {
try {
await apiBinaryUploadRequest(initiated.uploadUrl, {
method: initiated.method,
headers: initiated.headers,
body: uploadFile,
onProgress: updateProgress,
});
uploadedFile = await apiRequest<FileMetadata>('/files/upload/complete', {
method: 'POST',
body: {
path: uploadPath,
filename: uploadFile.name,
storageName: initiated.storageName,
contentType: uploadFile.type || null,
size: uploadFile.size,
},
});
} catch (error) {
if (!(error instanceof ApiError && error.isNetworkError)) {
throw error;
}
const formData = new FormData();
formData.append('file', uploadFile);
uploadedFile = await apiUploadRequest<FileMetadata>(`/files/upload?path=${encodeURIComponent(uploadPath)}`, {
body: formData,
onProgress: updateProgress,
});
}
} else if (initiated) {
const formData = new FormData();
formData.append('file', uploadFile);
uploadedFile = await apiUploadRequest<FileMetadata>(initiated.uploadUrl, {
body: formData,
method: initiated.method,
headers: initiated.headers,
onProgress: updateProgress,
});
} else {
const formData = new FormData();
formData.append('file', uploadFile);
uploadedFile = await apiUploadRequest<FileMetadata>(`/files/upload?path=${encodeURIComponent(uploadPath)}`, {
body: formData,
onProgress: updateProgress,
});
}
uploadMeasurementsRef.current.delete(uploadTask.id);
setUploads((previous) =>
previous.map((task) => (task.id === uploadTask.id ? completeUploadTask(task) : task)),
);
return uploadedFile;
} catch (error) {
uploadMeasurementsRef.current.delete(uploadTask.id);
const message = error instanceof Error && error.message ? error.message : '上传失败,请稍后重试';
setUploads((previous) =>
previous.map((task) => (task.id === uploadTask.id ? failUploadTask(task, message) : task)),
);
return null;
}
});
await loadCurrentPath(currentPath);
event.target.value = '';
const results = await Promise.all(uploadJobs);
if (results.some(Boolean) && toBackendPath(currentPathRef.current) === uploadPath) {
await loadCurrentPath(uploadPathParts).catch(() => undefined);
}
};
const handleCreateFolder = async () => {
@@ -158,8 +329,17 @@ export default function Files() {
return;
}
const normalizedFolderName = folderName.trim();
const nextFolderName = getNextAvailableName(
normalizedFolderName,
new Set(currentFiles.filter((file) => file.type === 'folder').map((file) => file.name)),
);
if (nextFolderName !== normalizedFolderName) {
window.alert(`检测到同名文件夹,已自动重命名为 ${nextFolderName}`);
}
const basePath = toBackendPath(currentPath).replace(/\/$/, '');
const fullPath = `${basePath}/${folderName.trim()}` || '/';
const fullPath = `${basePath}/${nextFolderName}` || '/';
await apiRequest('/files/mkdir', {
method: 'POST',
@@ -174,11 +354,72 @@ export default function Files() {
await loadCurrentPath(currentPath);
};
const handleRename = async () => {
if (!fileToRename || !newFileName.trim() || isRenaming) {
return;
}
setIsRenaming(true);
setRenameError('');
try {
const renamedFile = await apiRequest<FileMetadata>(`/files/${fileToRename.id}/rename`, {
method: 'PATCH',
body: {
filename: newFileName.trim(),
},
});
const nextUiFile = toUiFile(renamedFile);
setCurrentFiles((previous) => replaceUiFile(previous, nextUiFile));
setSelectedFile((previous) => syncSelectedFile(previous, nextUiFile));
setRenameModalOpen(false);
setFileToRename(null);
setNewFileName('');
await loadCurrentPath(currentPath).catch(() => undefined);
} catch (error) {
setRenameError(getActionErrorMessage(error, '重命名失败,请稍后重试'));
} finally {
setIsRenaming(false);
}
};
const handleDelete = async () => {
if (!fileToDelete) {
return;
}
await apiRequest(`/files/${fileToDelete.id}`, {
method: 'DELETE',
});
setCurrentFiles((previous) => removeUiFile(previous, fileToDelete.id));
setSelectedFile((previous) => clearSelectionIfDeleted(previous, fileToDelete.id));
setDeleteModalOpen(false);
setFileToDelete(null);
await loadCurrentPath(currentPath).catch(() => undefined);
};
const handleDownload = async () => {
if (!selectedFile || selectedFile.type === 'folder') {
return;
}
try {
const response = await apiRequest<DownloadUrlResponse>(`/files/download/${selectedFile.id}/url`);
const url = response.url;
const link = document.createElement('a');
link.href = url;
link.download = selectedFile.name;
link.rel = 'noreferrer';
link.target = '_blank';
link.click();
return;
} catch (error) {
if (!(error instanceof ApiError && error.status === 404)) {
throw error;
}
}
const response = await apiDownload(`/files/download/${selectedFile.id}`);
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
@@ -189,6 +430,11 @@ export default function Files() {
window.URL.revokeObjectURL(url);
};
const handleClearUploads = () => {
uploadMeasurementsRef.current.clear();
setUploads([]);
};
return (
<div className="flex flex-col lg:flex-row gap-6 h-[calc(100vh-8rem)]">
{/* Left Sidebar */}
@@ -255,33 +501,54 @@ export default function Files() {
))}
</div>
<div className="flex items-center gap-2 bg-black/20 p-1 rounded-lg">
<button className="p-1.5 rounded-md bg-white/10 text-white"><List className="w-4 h-4" /></button>
<button className="p-1.5 rounded-md text-slate-400 hover:text-white"><LayoutGrid className="w-4 h-4" /></button>
<button
onClick={() => setViewMode('list')}
className={cn(
'p-1.5 rounded-md transition-colors',
viewMode === 'list' ? 'bg-white/10 text-white' : 'text-slate-400 hover:text-white',
)}
>
<List className="w-4 h-4" />
</button>
<button
onClick={() => setViewMode('grid')}
className={cn(
'p-1.5 rounded-md transition-colors',
viewMode === 'grid' ? 'bg-white/10 text-white' : 'text-slate-400 hover:text-white',
)}
>
<LayoutGrid className="w-4 h-4" />
</button>
</div>
</div>
{/* File List */}
<div className="flex-1 overflow-y-auto p-4">
<table className="w-full text-left border-collapse">
<thead>
<tr className="text-xs font-semibold text-slate-500 uppercase tracking-wider border-b border-white/5">
<th className="pb-3 pl-4 font-medium"></th>
<th className="pb-3 font-medium hidden md:table-cell"></th>
<th className="pb-3 font-medium hidden lg:table-cell"></th>
<th className="pb-3 font-medium"></th>
<th className="pb-3"></th>
</tr>
</thead>
<tbody>
{currentFiles.length > 0 ? (
currentFiles.map((file) => (
{currentFiles.length === 0 ? (
<div className="flex flex-col items-center justify-center space-y-3 py-12 text-slate-500">
<Folder className="w-12 h-12 opacity-20" />
<p className="text-sm"></p>
</div>
) : viewMode === 'list' ? (
<table className="w-full text-left border-collapse">
<thead>
<tr className="text-xs font-semibold text-slate-500 uppercase tracking-wider border-b border-white/5">
<th className="pb-3 pl-4 font-medium"></th>
<th className="pb-3 font-medium hidden md:table-cell"></th>
<th className="pb-3 font-medium hidden lg:table-cell"></th>
<th className="pb-3 font-medium"></th>
<th className="pb-3"></th>
</tr>
</thead>
<tbody>
{currentFiles.map((file) => (
<tr
key={file.id}
onClick={() => setSelectedFile(file)}
onDoubleClick={() => handleFolderDoubleClick(file)}
className={cn(
'group cursor-pointer transition-colors border-b border-white/5 last:border-0',
selectedFile?.id === file.id ? 'bg-[#336EFF]/10' : 'hover:bg-white/[0.02]'
selectedFile?.id === file.id ? 'bg-[#336EFF]/10' : 'hover:bg-white/[0.02]',
)}
>
<td className="py-3 pl-4">
@@ -302,24 +569,64 @@ export default function Files() {
<td className="py-3 text-sm text-slate-400 hidden lg:table-cell uppercase">{file.type}</td>
<td className="py-3 text-sm text-slate-400 font-mono">{file.size}</td>
<td className="py-3 pr-4 text-right">
<button className="p-1.5 rounded-md text-slate-500 opacity-0 group-hover:opacity-100 hover:bg-white/10 hover:text-white transition-all">
<MoreVertical className="w-4 h-4" />
</button>
<FileActionMenu
file={file}
activeDropdown={activeDropdown}
onToggle={(fileId) => setActiveDropdown((previous) => (previous === fileId ? null : fileId))}
onRename={openRenameModal}
onDelete={openDeleteModal}
onClose={() => setActiveDropdown(null)}
/>
</td>
</tr>
))
) : (
<tr>
<td colSpan={5} className="py-12 text-center text-slate-500">
<div className="flex flex-col items-center justify-center space-y-3">
<Folder className="w-12 h-12 opacity-20" />
<p className="text-sm"></p>
</div>
</td>
</tr>
)}
</tbody>
</table>
))}
</tbody>
</table>
) : (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
{currentFiles.map((file) => (
<div
key={file.id}
onClick={() => setSelectedFile(file)}
onDoubleClick={() => handleFolderDoubleClick(file)}
className={cn(
'group relative flex cursor-pointer flex-col items-center rounded-xl border p-4 transition-all',
selectedFile?.id === file.id
? 'border-[#336EFF]/30 bg-[#336EFF]/10'
: 'border-white/5 bg-white/[0.02] hover:border-white/10 hover:bg-white/[0.04]',
)}
>
<div className="absolute right-2 top-2">
<FileActionMenu
file={file}
activeDropdown={activeDropdown}
onToggle={(fileId) => setActiveDropdown((previous) => (previous === fileId ? null : fileId))}
onRename={openRenameModal}
onDelete={openDeleteModal}
onClose={() => setActiveDropdown(null)}
/>
</div>
<div className="mb-3 flex h-16 w-16 items-center justify-center rounded-2xl bg-white/5 transition-colors group-hover:bg-white/10">
{file.type === 'folder' ? (
<Folder className="w-8 h-8 text-[#336EFF]" />
) : file.type === 'image' ? (
<ImageIcon className="w-8 h-8 text-purple-400" />
) : (
<FileText className="w-8 h-8 text-blue-400" />
)}
</div>
<span className={cn('w-full truncate px-2 text-center text-sm font-medium', selectedFile?.id === file.id ? 'text-[#336EFF]' : 'text-slate-200')}>
{file.name}
</span>
<span className="mt-1 text-xs text-slate-500">
{file.type === 'folder' ? file.modified : file.size}
</span>
</div>
))}
</div>
)}
</div>
{/* Bottom Actions */}
@@ -330,7 +637,7 @@ export default function Files() {
<Button variant="outline" className="gap-2" onClick={handleCreateFolder}>
<Plus className="w-4 h-4" />
</Button>
<input ref={fileInputRef} type="file" className="hidden" onChange={handleFileChange} />
<input ref={fileInputRef} type="file" multiple className="hidden" onChange={handleFileChange} />
</div>
</Card>
@@ -366,20 +673,255 @@ export default function Files() {
<DetailItem label="类型" value={selectedFile.type.toUpperCase()} />
</div>
{selectedFile.type !== 'folder' && (
<Button variant="outline" className="w-full gap-2 mt-4" onClick={handleDownload}>
<Download className="w-4 h-4" />
</Button>
)}
{selectedFile.type === 'folder' && (
<Button variant="default" className="w-full gap-2 mt-4" onClick={() => handleFolderDoubleClick(selectedFile)}>
</Button>
)}
<div className="pt-4 space-y-3 border-t border-white/10">
<div className="grid grid-cols-2 gap-3">
<Button variant="outline" className="w-full gap-2 bg-white/5 border-white/10 hover:bg-white/10" onClick={() => openRenameModal(selectedFile)}>
<Edit2 className="w-4 h-4" />
</Button>
<Button
variant="outline"
className="w-full gap-2 border-red-500/20 bg-red-500/5 text-red-400 hover:bg-red-500/10 hover:text-red-300"
onClick={() => openDeleteModal(selectedFile)}
>
<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)}>
</Button>
)}
</div>
</CardContent>
</Card>
</motion.div>
)}
<AnimatePresence>
{uploads.length > 0 && (
<motion.div
initial={{ opacity: 0, y: 50, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 50, scale: 0.95 }}
className="fixed bottom-6 right-6 z-50 flex w-[min(24rem,calc(100vw-2rem))] flex-col overflow-hidden rounded-xl border border-white/10 bg-[#0f172a]/95 shadow-2xl backdrop-blur-xl"
>
<div
className="flex cursor-pointer items-center justify-between border-b border-white/10 bg-white/5 px-4 py-3 transition-colors hover:bg-white/10"
onClick={() => setIsUploadPanelOpen((previous) => !previous)}
>
<div className="flex items-center gap-2">
<UploadCloud className="h-4 w-4 text-[#336EFF]" />
<span className="text-sm font-medium text-white">
({uploads.filter((task) => task.status === 'completed').length}/{uploads.length})
</span>
</div>
<div className="flex items-center gap-1">
<button className="rounded p-1 text-slate-400 transition-colors hover:bg-white/10 hover:text-white">
{isUploadPanelOpen ? <ChevronDown className="h-4 w-4" /> : <ChevronUp className="h-4 w-4" />}
</button>
<button
className="rounded p-1 text-slate-400 transition-colors hover:bg-white/10 hover:text-white"
onClick={(event) => {
event.stopPropagation();
handleClearUploads();
}}
>
<X className="h-4 w-4" />
</button>
</div>
</div>
<AnimatePresence initial={false}>
{isUploadPanelOpen && (
<motion.div initial={{ height: 0 }} animate={{ height: 'auto' }} exit={{ height: 0 }} className="max-h-80 overflow-y-auto">
<div className="space-y-1 p-2">
{uploads.map((task) => (
<div
key={task.id}
className={cn(
'group relative overflow-hidden rounded-lg p-3 transition-colors hover:bg-white/5',
task.status === 'error' && 'bg-rose-500/5',
)}
>
{task.status === 'uploading' && (
<div
className="absolute inset-y-0 left-0 bg-[#336EFF]/10 transition-all duration-300 ease-out"
style={{ width: `${task.progress}%` }}
/>
)}
<div className="relative z-10 flex items-start gap-3">
<div className="mt-0.5">
{task.status === 'completed' ? (
<CheckCircle2 className="h-5 w-5 text-emerald-500" />
) : task.status === 'error' ? (
<TriangleAlert className="h-5 w-5 text-rose-400" />
) : (
<FileUp className="h-5 w-5 animate-pulse text-[#336EFF]" />
)}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-slate-200">{task.fileName}</p>
<p className="mt-0.5 truncate text-xs text-slate-500">: {task.destination}</p>
{task.noticeMessage && (
<p className="mt-2 truncate text-xs text-amber-300">{task.noticeMessage}</p>
)}
{task.status === 'uploading' && (
<div className="mt-2 flex items-center justify-between text-xs">
<span className="font-medium text-[#336EFF]">{Math.round(task.progress)}%</span>
<span className="font-mono text-slate-400">{task.speed}</span>
</div>
)}
{task.status === 'completed' && (
<p className="mt-2 text-xs text-emerald-400"></p>
)}
{task.status === 'error' && (
<p className="mt-2 truncate text-xs text-rose-400">{task.errorMessage ?? '上传失败,请稍后重试'}</p>
)}
</div>
</div>
</div>
))}
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
)}
</AnimatePresence>
<AnimatePresence>
{renameModalOpen && (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm">
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
className="w-full max-w-sm overflow-hidden rounded-xl border border-white/10 bg-[#0f172a] shadow-2xl"
>
<div className="flex items-center justify-between border-b border-white/10 bg-white/5 p-4">
<h3 className="flex items-center gap-2 text-lg font-semibold text-white">
<Edit2 className="w-5 h-5 text-[#336EFF]" />
</h3>
<button
onClick={() => {
setRenameModalOpen(false);
setFileToRename(null);
setRenameError('');
}}
className="rounded-md p-1 text-slate-400 transition-colors hover:bg-white/10 hover:text-white"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="space-y-5 p-5">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-300"></label>
<Input
value={newFileName}
onChange={(event) => setNewFileName(event.target.value)}
className="bg-black/20 border-white/10 text-white focus-visible:ring-[#336EFF]"
autoFocus
disabled={isRenaming}
onKeyDown={(event) => {
if (event.key === 'Enter' && !isRenaming) {
void handleRename();
}
}}
/>
</div>
{renameError && (
<div className="rounded-xl border border-red-500/20 bg-red-500/10 p-3 text-sm text-red-400">
{renameError}
</div>
)}
<div className="flex justify-end gap-3 pt-2">
<Button
variant="outline"
onClick={() => {
setRenameModalOpen(false);
setFileToRename(null);
setRenameError('');
}}
disabled={isRenaming}
className="border-white/10 text-slate-300 hover:bg-white/10"
>
</Button>
<Button variant="default" onClick={() => void handleRename()} disabled={isRenaming}>
{isRenaming ? (
<span className="flex items-center gap-2">
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white/20 border-t-white" />
...
</span>
) : (
'确定'
)}
</Button>
</div>
</div>
</motion.div>
</div>
)}
{deleteModalOpen && (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm">
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
className="w-full max-w-sm overflow-hidden rounded-xl border border-white/10 bg-[#0f172a] shadow-2xl"
>
<div className="flex items-center justify-between border-b border-white/10 bg-white/5 p-4">
<h3 className="flex items-center gap-2 text-lg font-semibold text-white">
<Trash2 className="w-5 h-5 text-red-500" />
</h3>
<button
onClick={() => {
setDeleteModalOpen(false);
setFileToDelete(null);
}}
className="rounded-md p-1 text-slate-400 transition-colors hover:bg-white/10 hover:text-white"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="space-y-5 p-5">
<p className="text-sm leading-relaxed text-slate-300">
<span className="rounded bg-white/10 px-1 py-0.5 font-medium text-white">{fileToDelete?.name}</span>
</p>
<div className="flex justify-end gap-3 pt-2">
<Button
variant="outline"
onClick={() => {
setDeleteModalOpen(false);
setFileToDelete(null);
}}
className="border-white/10 text-slate-300 hover:bg-white/10"
>
</Button>
<Button
variant="outline"
className="border-red-500/30 bg-red-500 text-white hover:bg-red-600"
onClick={() => void handleDelete()}
>
</Button>
</div>
</div>
</motion.div>
</div>
)}
</AnimatePresence>
</div>
);
}
@@ -392,3 +934,74 @@ function DetailItem({ label, value }: { label: string; value: string }) {
</div>
);
}
function FileActionMenu({
file,
activeDropdown,
onToggle,
onRename,
onDelete,
onClose,
}: {
file: UiFile;
activeDropdown: number | null;
onToggle: (fileId: number) => void;
onRename: (file: UiFile) => void;
onDelete: (file: UiFile) => void;
onClose: () => void;
}) {
return (
<div className="relative inline-block text-left">
<button
onClick={(event) => {
event.stopPropagation();
onToggle(file.id);
}}
className="rounded-md p-1.5 text-slate-500 opacity-0 transition-all hover:bg-white/10 hover:text-white group-hover:opacity-100"
>
<MoreVertical className="w-4 h-4" />
</button>
{activeDropdown === file.id && (
<div
className="fixed inset-0 z-40"
onClick={(event) => {
event.stopPropagation();
onClose();
}}
/>
)}
<AnimatePresence>
{activeDropdown === file.id && (
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 10 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 10 }}
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();
onRename(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"
>
<Edit2 className="w-4 h-4" />
</button>
<button
onClick={(event) => {
event.stopPropagation();
onDelete(file);
onClose();
}}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-red-400 transition-colors hover:bg-red-500/10 hover:text-red-300"
>
<Trash2 className="w-4 h-4" />
</button>
</motion.div>
)}
</AnimatePresence>
</div>
);
}

View File

@@ -0,0 +1,77 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
clearSelectionIfDeleted,
getActionErrorMessage,
getNextAvailableName,
removeUiFile,
replaceUiFile,
syncSelectedFile,
} from './files-state';
const files = [
{id: 1, name: 'notes.txt', type: 'txt', size: '2 KB', modified: '2026/03/18 10:00'},
{id: 2, name: 'photos', type: 'folder', size: '—', modified: '2026/03/18 09:00'},
];
test('replaceUiFile updates the matching file only', () => {
const nextFiles = replaceUiFile(files, {
id: 2,
name: 'photos-2026',
type: 'folder',
size: '—',
modified: '2026/03/18 09:00',
});
assert.deepEqual(nextFiles, [
files[0],
{
id: 2,
name: 'photos-2026',
type: 'folder',
size: '—',
modified: '2026/03/18 09:00',
},
]);
});
test('removeUiFile drops the deleted file from the current list', () => {
assert.deepEqual(removeUiFile(files, 1), [files[1]]);
});
test('syncSelectedFile keeps details sidebar in sync after rename', () => {
const selectedFile = files[1];
const renamedFile = {
...selectedFile,
name: 'photos-2026',
};
assert.deepEqual(syncSelectedFile(selectedFile, renamedFile), renamedFile);
assert.equal(syncSelectedFile(files[0], renamedFile), files[0]);
});
test('clearSelectionIfDeleted removes details selection for deleted file', () => {
assert.equal(clearSelectionIfDeleted(files[0], 1), null);
assert.equal(clearSelectionIfDeleted(files[1], 1), files[1]);
});
test('getActionErrorMessage uses backend message when present', () => {
assert.equal(getActionErrorMessage(new Error('重命名失败:同名文件已存在'), '重命名失败,请稍后重试'), '重命名失败:同名文件已存在');
assert.equal(getActionErrorMessage(null, '重命名失败,请稍后重试'), '重命名失败,请稍后重试');
});
test('getNextAvailableName appends an incrementing suffix for duplicate folder names', () => {
assert.equal(
getNextAvailableName('新建文件夹', new Set(['新建文件夹'])),
'新建文件夹 (1)',
);
assert.equal(
getNextAvailableName('新建文件夹', new Set(['新建文件夹', '新建文件夹 (1)', '新建文件夹 (2)'])),
'新建文件夹 (3)',
);
});
test('getNextAvailableName keeps the original name when no duplicate exists', () => {
assert.equal(getNextAvailableName('课程资料', new Set(['实验数据', '下载'])), '课程资料');
});

View File

@@ -0,0 +1,55 @@
export interface FilesUiItem {
id: number;
name: string;
type: string;
size: string;
modified: string;
}
export function getNextAvailableName(name: string, existingNames: Set<string>) {
if (!existingNames.has(name)) {
return name;
}
let index = 1;
let nextName = `${name} (${index})`;
while (existingNames.has(nextName)) {
index += 1;
nextName = `${name} (${index})`;
}
return nextName;
}
export function replaceUiFile<T extends FilesUiItem>(files: T[], nextFile: T) {
return files.map((file) => (file.id === nextFile.id ? nextFile : file));
}
export function removeUiFile<T extends FilesUiItem>(files: T[], fileId: number) {
return files.filter((file) => file.id !== fileId);
}
export function syncSelectedFile<T extends FilesUiItem>(selectedFile: T | null, nextFile: T) {
if (!selectedFile || selectedFile.id !== nextFile.id) {
return selectedFile;
}
return nextFile;
}
export function clearSelectionIfDeleted<T extends FilesUiItem>(selectedFile: T | null, fileId: number) {
if (!selectedFile || selectedFile.id !== fileId) {
return selectedFile;
}
return null;
}
export function getActionErrorMessage(error: unknown, fallbackMessage: string) {
if (error instanceof Error && error.message) {
return error.message;
}
return fallbackMessage;
}

View File

@@ -0,0 +1,97 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
buildUploadProgressSnapshot,
completeUploadTask,
createUploadTask,
formatTransferSpeed,
prepareUploadFile,
} from './files-upload';
test('createUploadTask uses current path as upload destination', () => {
const task = createUploadTask(new File(['hello'], 'notes.md', {type: 'text/markdown'}), ['文档', '课程资料'], 'task-1');
assert.equal(task.id, 'task-1');
assert.equal(task.fileName, 'notes.md');
assert.equal(task.destination, '/文档/课程资料');
assert.equal(task.progress, 0);
assert.equal(task.status, 'uploading');
assert.equal(task.speed, '等待上传...');
});
test('formatTransferSpeed chooses a readable unit', () => {
assert.equal(formatTransferSpeed(800), '800 B/s');
assert.equal(formatTransferSpeed(2048), '2.0 KB/s');
assert.equal(formatTransferSpeed(3.5 * 1024 * 1024), '3.5 MB/s');
});
test('buildUploadProgressSnapshot derives progress and speed from bytes transferred', () => {
const firstSnapshot = buildUploadProgressSnapshot({
loaded: 1024,
total: 4096,
now: 1_000,
});
assert.equal(firstSnapshot.progress, 25);
assert.equal(firstSnapshot.speed, '1.0 KB/s');
const nextSnapshot = buildUploadProgressSnapshot({
loaded: 3072,
total: 4096,
now: 2_000,
previous: firstSnapshot.measurement,
});
assert.equal(nextSnapshot.progress, 75);
assert.equal(nextSnapshot.speed, '2.0 KB/s');
});
test('buildUploadProgressSnapshot keeps progress below 100 until request completes', () => {
const snapshot = buildUploadProgressSnapshot({
loaded: 4096,
total: 4096,
now: 1_500,
});
assert.equal(snapshot.progress, 99);
});
test('completeUploadTask marks upload as completed', () => {
const task = createUploadTask(new File(['hello'], 'photo.png', {type: 'image/png'}), [], 'task-2');
const nextTask = completeUploadTask(task);
assert.equal(nextTask.destination, '/');
assert.equal(nextTask.progress, 100);
assert.equal(nextTask.status, 'completed');
assert.equal(nextTask.speed, '');
});
test('prepareUploadFile appends an incrementing suffix when the same file name already exists', () => {
const firstDuplicate = prepareUploadFile(
new File(['hello'], 'notes.md', {type: 'text/markdown'}),
new Set(['notes.md']),
);
assert.equal(firstDuplicate.file.name, 'notes (1).md');
assert.equal(firstDuplicate.noticeMessage, '检测到同名文件,已自动重命名为 notes (1).md');
const secondDuplicate = prepareUploadFile(
new File(['hello'], 'notes.md', {type: 'text/markdown'}),
new Set(['notes.md', 'notes (1).md']),
);
assert.equal(secondDuplicate.file.name, 'notes (2).md');
assert.equal(secondDuplicate.noticeMessage, '检测到同名文件,已自动重命名为 notes (2).md');
});
test('prepareUploadFile keeps files without conflicts unchanged', () => {
const prepared = prepareUploadFile(
new File(['hello'], 'syllabus', {type: 'text/plain'}),
new Set(['notes.md']),
);
assert.equal(prepared.file.name, 'syllabus');
assert.equal(prepared.noticeMessage, undefined);
});

View File

@@ -0,0 +1,185 @@
export type UploadTaskStatus = 'uploading' | 'completed' | 'error';
export interface UploadTask {
id: string;
fileName: string;
progress: number;
speed: string;
destination: string;
status: UploadTaskStatus;
type: string;
errorMessage?: string;
noticeMessage?: string;
}
export interface UploadMeasurement {
startedAt: number;
lastLoaded: number;
lastUpdatedAt: number;
}
function getUploadType(file: File) {
const extension = file.name.includes('.') ? file.name.split('.').pop()?.toLowerCase() : '';
if (file.type.startsWith('image/')) {
return 'image';
}
if (file.type.includes('pdf') || extension === 'pdf') {
return 'pdf';
}
if (extension === 'doc' || extension === 'docx') {
return 'word';
}
if (extension === 'xls' || extension === 'xlsx' || extension === 'csv') {
return 'excel';
}
return extension || 'document';
}
function createTaskId() {
return globalThis.crypto?.randomUUID?.() ?? `upload-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
function splitFileName(fileName: string) {
const lastDotIndex = fileName.lastIndexOf('.');
if (lastDotIndex <= 0) {
return {
stem: fileName,
extension: '',
};
}
return {
stem: fileName.slice(0, lastDotIndex),
extension: fileName.slice(lastDotIndex),
};
}
export function getUploadDestination(pathParts: string[]) {
return pathParts.length === 0 ? '/' : `/${pathParts.join('/')}`;
}
export function prepareUploadFile(file: File, usedNames: Set<string>) {
if (!usedNames.has(file.name)) {
return {
file,
noticeMessage: undefined,
};
}
const {stem, extension} = splitFileName(file.name);
let index = 1;
let nextName = `${stem} (${index})${extension}`;
while (usedNames.has(nextName)) {
index += 1;
nextName = `${stem} (${index})${extension}`;
}
return {
file: new File([file], nextName, {
type: file.type,
lastModified: file.lastModified,
}),
noticeMessage: `检测到同名文件,已自动重命名为 ${nextName}`,
};
}
export function createUploadTask(
file: File,
pathParts: string[],
taskId: string = createTaskId(),
noticeMessage?: string,
): UploadTask {
return {
id: taskId,
fileName: file.name,
progress: 0,
speed: '等待上传...',
destination: getUploadDestination(pathParts),
status: 'uploading',
type: getUploadType(file),
noticeMessage,
};
}
export function formatTransferSpeed(bytesPerSecond: number) {
if (bytesPerSecond < 1024) {
return `${Math.round(bytesPerSecond)} B/s`;
}
const units = ['KB/s', 'MB/s', 'GB/s'];
let value = bytesPerSecond / 1024;
let unitIndex = 0;
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024;
unitIndex += 1;
}
return `${value.toFixed(1)} ${units[unitIndex]}`;
}
export function buildUploadProgressSnapshot({
loaded,
total,
now,
previous,
}: {
loaded: number;
total: number;
now: number;
previous?: UploadMeasurement;
}) {
const safeTotal = total > 0 ? total : loaded;
const rawProgress = safeTotal > 0 ? Math.round((loaded / safeTotal) * 100) : 0;
const progress = Math.min(loaded >= safeTotal ? 99 : rawProgress, 99);
const measurement: UploadMeasurement = previous
? {
startedAt: previous.startedAt,
lastLoaded: loaded,
lastUpdatedAt: now,
}
: {
startedAt: now,
lastLoaded: loaded,
lastUpdatedAt: now,
};
let bytesPerSecond = 0;
if (previous) {
const bytesDelta = Math.max(0, loaded - previous.lastLoaded);
const timeDelta = Math.max(1, now - previous.lastUpdatedAt);
bytesPerSecond = (bytesDelta * 1000) / timeDelta;
} else if (loaded > 0) {
bytesPerSecond = loaded;
}
return {
progress,
speed: formatTransferSpeed(bytesPerSecond),
measurement,
};
}
export function completeUploadTask(task: UploadTask): UploadTask {
return {
...task,
progress: 100,
speed: '',
status: 'completed',
errorMessage: undefined,
};
}
export function failUploadTask(task: UploadTask, errorMessage: string): UploadTask {
return {
...task,
speed: '',
status: 'error',
errorMessage,
};
}