From 448e2a6886601d0db2c0ae185c54ad20ac24c72b Mon Sep 17 00:00:00 2001 From: yoyuzh Date: Thu, 26 Mar 2026 14:29:30 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=83=A8=E5=88=86=E6=98=BE?= =?UTF-8?q?=E7=A4=BA=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components.json | 6 + front/package-lock.json | 7 + front/package.json | 1 + front/src/components/layout/Layout.tsx | 3 + .../components/layout/UploadProgressPanel.tsx | 142 ++++++++++++ front/src/lib/api.test.ts | 26 +++ front/src/lib/api.ts | 134 +++++++++++- front/src/lib/file-name.test.ts | 22 ++ front/src/lib/file-name.ts | 23 ++ front/src/pages/Files.tsx | 205 +++++------------- front/src/pages/files-upload-store.test.ts | 70 ++++++ front/src/pages/files-upload-store.ts | 132 +++++++++++ front/src/pages/files-upload.test.ts | 11 + front/src/pages/files-upload.ts | 11 +- 14 files changed, 630 insertions(+), 163 deletions(-) create mode 100644 components.json create mode 100644 front/src/components/layout/UploadProgressPanel.tsx create mode 100644 front/src/lib/file-name.test.ts create mode 100644 front/src/lib/file-name.ts create mode 100644 front/src/pages/files-upload-store.test.ts create mode 100644 front/src/pages/files-upload-store.ts diff --git a/components.json b/components.json new file mode 100644 index 0000000..b2d2c82 --- /dev/null +++ b/components.json @@ -0,0 +1,6 @@ +{ + "registries": { + "@shadcn": "https://ui.shadcn.com/r/styles/default/{name}.json", + "@react-bits": "https://reactbits.dev/r/{name}.json" + } +} diff --git a/front/package-lock.json b/front/package-lock.json index c8c008e..2be3420 100644 --- a/front/package-lock.json +++ b/front/package-lock.json @@ -22,6 +22,7 @@ "express": "^4.21.2", "lucide-react": "^0.546.0", "motion": "^12.23.24", + "ogl": "^1.0.11", "react": "^19.0.0", "react-admin": "^5.14.4", "react-dom": "^19.0.0", @@ -4404,6 +4405,12 @@ "node": ">= 0.4" } }, + "node_modules/ogl": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ogl/-/ogl-1.0.11.tgz", + "integrity": "sha512-kUpC154AFfxi16pmZUK4jk3J+8zxwTWGPo03EoYA8QPbzikHoaC82n6pNTbd+oEaJonaE8aPWBlX7ad9zrqLsA==", + "license": "Unlicense" + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", diff --git a/front/package.json b/front/package.json index 8f8f6d5..a9c73e8 100644 --- a/front/package.json +++ b/front/package.json @@ -26,6 +26,7 @@ "express": "^4.21.2", "lucide-react": "^0.546.0", "motion": "^12.23.24", + "ogl": "^1.0.11", "react": "^19.0.0", "react-admin": "^5.14.4", "react-dom": "^19.0.0", diff --git a/front/src/components/layout/Layout.tsx b/front/src/components/layout/Layout.tsx index 8a3658d..288318c 100644 --- a/front/src/components/layout/Layout.tsx +++ b/front/src/components/layout/Layout.tsx @@ -24,6 +24,7 @@ import { Button } from '@/src/components/ui/button'; import { Input } from '@/src/components/ui/input'; import { buildAccountDraft, getRoleLabel, shouldLoadAvatarWithAuth } from './account-utils'; +import { UploadProgressPanel } from './UploadProgressPanel'; const NAV_ITEMS = [ { name: '总览', path: '/overview', icon: LayoutDashboard }, @@ -435,6 +436,8 @@ export function Layout({ children }: LayoutProps = {}) { {children ?? } + + {activeModal === 'security' && (
diff --git a/front/src/components/layout/UploadProgressPanel.tsx b/front/src/components/layout/UploadProgressPanel.tsx new file mode 100644 index 0000000..8db8509 --- /dev/null +++ b/front/src/components/layout/UploadProgressPanel.tsx @@ -0,0 +1,142 @@ +import { AnimatePresence, motion } from 'motion/react'; +import { Ban, CheckCircle2, ChevronDown, ChevronUp, FileUp, TriangleAlert, UploadCloud, X } from 'lucide-react'; + +import { FileTypeIcon, getFileTypeTheme } from '@/src/components/ui/FileTypeIcon'; +import { ellipsizeFileName } from '@/src/lib/file-name'; +import { cn } from '@/src/lib/utils'; +import { + cancelFilesUploadTask, + clearFilesUploads, + toggleFilesUploadPanelOpen, + useFilesUploadStore, +} from '@/src/pages/files-upload-store'; + +export function UploadProgressPanel() { + const { uploads, isUploadPanelOpen } = useFilesUploadStore(); + + if (uploads.length === 0) { + return null; + } + + return ( + + +
toggleFilesUploadPanelOpen()} + > +
+ + + 上传进度 ({uploads.filter((task) => task.status === 'completed').length}/{uploads.length}) + +
+
+ + +
+
+ + + {isUploadPanelOpen && ( + +
+ {uploads.map((task) => ( +
+ {task.status === 'uploading' && ( +
+ )} + +
+ +
+
+

+ {ellipsizeFileName(task.fileName, 30)} +

+
+ {task.status === 'uploading' ? ( + + ) : null} + {task.status === 'completed' ? ( + + ) : task.status === 'cancelled' ? ( + + ) : task.status === 'error' ? ( + + ) : ( + + )} +
+
+
+ + {task.typeLabel} + + 上传至: {task.destination} +
+ {task.noticeMessage && ( +

{task.noticeMessage}

+ )} + + {task.status === 'uploading' && ( +
+ {Math.round(task.progress)}% + {task.speed} +
+ )} + {task.status === 'completed' && ( +

上传完成

+ )} + {task.status === 'cancelled' && ( +

已取消上传

+ )} + {task.status === 'error' && ( +

{task.errorMessage ?? '上传失败,请稍后重试'}

+ )} +
+
+
+ ))} +
+ + )} + + + + ); +} diff --git a/front/src/lib/api.test.ts b/front/src/lib/api.test.ts index efb3575..cc1d4bd 100644 --- a/front/src/lib/api.test.ts +++ b/front/src/lib/api.test.ts @@ -48,6 +48,8 @@ class FakeXMLHttpRequest { responseHeaders = new Map(); onload: null | (() => void) = null; onerror: null | (() => void) = null; + onabort: null | (() => void) = null; + aborted = false; upload = { addEventListener: (type: string, listener: EventListenerOrEventListenerObject) => { @@ -82,6 +84,11 @@ class FakeXMLHttpRequest { this.requestBody = body; } + abort() { + this.aborted = true; + this.onabort?.(); + } + triggerProgress(loaded: number, total: number) { const event = { lengthComputable: true, @@ -312,6 +319,25 @@ test('apiBinaryUploadRequest sends raw file body to signed upload url', async () ]); }); +test('apiUploadRequest supports aborting a single upload task', async () => { + const controller = new AbortController(); + const formData = new FormData(); + formData.append('file', new Blob(['hello']), 'hello.txt'); + + const uploadPromise = apiUploadRequest<{id: number}>('/files/upload?path=%2F', { + body: formData, + signal: controller.signal, + }); + + const request = FakeXMLHttpRequest.latest; + assert.ok(request); + + controller.abort(); + + await assert.rejects(uploadPromise, /上传已取消/); + assert.equal(request.aborted, true); +}); + test('apiRequest refreshes expired access token once and retries the original request', async () => { const calls: Array<{url: string; authorization: string | null; body: string | null}> = []; saveStoredSession({ diff --git a/front/src/lib/api.ts b/front/src/lib/api.ts index f31d8dd..06c55b4 100644 --- a/front/src/lib/api.ts +++ b/front/src/lib/api.ts @@ -16,6 +16,7 @@ interface ApiUploadRequestInit { headers?: HeadersInit; method?: 'POST' | 'PUT' | 'PATCH'; onProgress?: (progress: {loaded: number; total: number}) => void; + signal?: AbortSignal; } interface ApiBinaryUploadRequestInit { @@ -23,6 +24,7 @@ interface ApiBinaryUploadRequestInit { headers?: HeadersInit; method?: 'PUT' | 'POST'; onProgress?: (progress: {loaded: number; total: number}) => void; + signal?: AbortSignal; } const API_BASE_URL = (import.meta.env?.VITE_API_BASE_URL || '/api').replace(/\/$/, ''); @@ -197,6 +199,10 @@ export function toNetworkApiError(error: unknown) { return new ApiError(message === 'Failed to fetch' ? fallbackMessage : message, 0); } +function toUploadAbortApiError() { + return new ApiError('上传已取消', 0); +} + export function shouldRetryRequest( path: string, init: ApiRequestInit = {}, @@ -296,6 +302,46 @@ function apiUploadRequestInternal(path: string, init: ApiUploadRequestInit, a return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); + let settled = false; + + const detachAbortSignal = () => { + init.signal?.removeEventListener('abort', handleAbortSignal); + }; + + const resolveOnce = (value: T | PromiseLike) => { + if (settled) { + return; + } + + settled = true; + detachAbortSignal(); + resolve(value); + }; + + const rejectOnce = (error: unknown) => { + if (settled) { + return; + } + + settled = true; + detachAbortSignal(); + reject(error); + }; + + const handleAbortSignal = () => { + if (settled) { + return; + } + + xhr.abort(); + rejectOnce(toUploadAbortApiError()); + }; + + if (init.signal?.aborted) { + rejectOnce(toUploadAbortApiError()); + return; + } + xhr.open(init.method || 'POST', resolveUrl(path)); headers.forEach((value, key) => { @@ -316,7 +362,16 @@ function apiUploadRequestInternal(path: string, init: ApiUploadRequestInit, a } xhr.onerror = () => { - reject(toNetworkApiError(new TypeError('Failed to fetch'))); + if (init.signal?.aborted) { + rejectOnce(toUploadAbortApiError()); + return; + } + + rejectOnce(toNetworkApiError(new TypeError('Failed to fetch'))); + }; + + xhr.onabort = () => { + rejectOnce(toUploadAbortApiError()); }; xhr.onload = () => { @@ -326,26 +381,26 @@ function apiUploadRequestInternal(path: string, init: ApiUploadRequestInit, a refreshAccessToken() .then((refreshed) => { if (refreshed) { - resolve(apiUploadRequestInternal(path, init, false)); + resolveOnce(apiUploadRequestInternal(path, init, false)); return; } clearStoredSession(); - reject(new ApiError('登录状态已失效,请重新登录', 401)); + rejectOnce(new ApiError('登录状态已失效,请重新登录', 401)); }) .catch((error) => { clearStoredSession(); - reject(error instanceof ApiError ? error : toNetworkApiError(error)); + rejectOnce(error instanceof ApiError ? error : toNetworkApiError(error)); }); return; } if (!contentType.includes('application/json')) { if (xhr.status >= 200 && xhr.status < 300) { - resolve(undefined as T); + resolveOnce(undefined as T); return; } - reject(new ApiError(`请求失败 (${xhr.status})`, xhr.status)); + rejectOnce(new ApiError(`请求失败 (${xhr.status})`, xhr.status)); return; } @@ -354,13 +409,17 @@ function apiUploadRequestInternal(path: string, init: ApiUploadRequestInit, a if (xhr.status === 401) { clearStoredSession(); } - reject(new ApiError(payload.msg || `请求失败 (${xhr.status})`, xhr.status, payload.code)); + rejectOnce(new ApiError(payload.msg || `请求失败 (${xhr.status})`, xhr.status, payload.code)); return; } - resolve(payload.data); + resolveOnce(payload.data); }; + if (init.signal) { + init.signal.addEventListener('abort', handleAbortSignal, {once: true}); + } + xhr.send(init.body); }); } @@ -374,6 +433,46 @@ export function apiBinaryUploadRequest(path: string, init: ApiBinaryUploadReques return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); + let settled = false; + + const detachAbortSignal = () => { + init.signal?.removeEventListener('abort', handleAbortSignal); + }; + + const resolveOnce = () => { + if (settled) { + return; + } + + settled = true; + detachAbortSignal(); + resolve(); + }; + + const rejectOnce = (error: unknown) => { + if (settled) { + return; + } + + settled = true; + detachAbortSignal(); + reject(error); + }; + + const handleAbortSignal = () => { + if (settled) { + return; + } + + xhr.abort(); + rejectOnce(toUploadAbortApiError()); + }; + + if (init.signal?.aborted) { + rejectOnce(toUploadAbortApiError()); + return; + } + xhr.open(init.method || 'PUT', resolveUrl(path)); headers.forEach((value, key) => { @@ -394,18 +493,31 @@ export function apiBinaryUploadRequest(path: string, init: ApiBinaryUploadReques } xhr.onerror = () => { - reject(toNetworkApiError(new TypeError('Failed to fetch'))); + if (init.signal?.aborted) { + rejectOnce(toUploadAbortApiError()); + return; + } + + rejectOnce(toNetworkApiError(new TypeError('Failed to fetch'))); + }; + + xhr.onabort = () => { + rejectOnce(toUploadAbortApiError()); }; xhr.onload = () => { if (xhr.status >= 200 && xhr.status < 300) { - resolve(); + resolveOnce(); return; } - reject(new ApiError(`请求失败 (${xhr.status})`, xhr.status)); + rejectOnce(new ApiError(`请求失败 (${xhr.status})`, xhr.status)); }; + if (init.signal) { + init.signal.addEventListener('abort', handleAbortSignal, {once: true}); + } + xhr.send(init.body); }); } diff --git a/front/src/lib/file-name.test.ts b/front/src/lib/file-name.test.ts new file mode 100644 index 0000000..f16e5fd --- /dev/null +++ b/front/src/lib/file-name.test.ts @@ -0,0 +1,22 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { ellipsizeFileName } from './file-name'; + +test('ellipsizeFileName keeps short names unchanged', () => { + assert.equal(ellipsizeFileName('report.pdf', 24), 'report.pdf'); +}); + +test('ellipsizeFileName truncates long file names and preserves extension when possible', () => { + assert.equal( + ellipsizeFileName('2026-very-long-course-material-final-version.pdf', 24), + '2026-very-long-co....pdf', + ); +}); + +test('ellipsizeFileName truncates long names without extension', () => { + assert.equal( + ellipsizeFileName('this-is-a-very-long-folder-name-without-extension', 20), + 'this-is-a-very-lo...', + ); +}); diff --git a/front/src/lib/file-name.ts b/front/src/lib/file-name.ts new file mode 100644 index 0000000..5dc3160 --- /dev/null +++ b/front/src/lib/file-name.ts @@ -0,0 +1,23 @@ +export function ellipsizeFileName(fileName: string, maxLength = 36) { + if (fileName.length <= maxLength) { + return fileName; + } + + if (maxLength <= 3) { + return '.'.repeat(Math.max(0, maxLength)); + } + + const extensionIndex = fileName.lastIndexOf('.'); + const hasUsableExtension = extensionIndex > 0 && extensionIndex < fileName.length - 1; + + if (hasUsableExtension) { + const extension = fileName.slice(extensionIndex); + const prefixLength = maxLength - extension.length - 3; + + if (prefixLength > 0) { + return `${fileName.slice(0, prefixLength)}...${extension}`; + } + } + + return `${fileName.slice(0, maxLength - 3)}...`; +} diff --git a/front/src/pages/Files.tsx b/front/src/pages/Files.tsx index a11b475..6358c2d 100644 --- a/front/src/pages/Files.tsx +++ b/front/src/pages/Files.tsx @@ -1,23 +1,18 @@ import React, { useEffect, useRef, useState } from 'react'; import { AnimatePresence, motion } from 'motion/react'; import { - CheckCircle2, ChevronDown, Folder, Download, ChevronRight, - ChevronUp, - FileUp, FolderUp, Upload, - UploadCloud, Plus, LayoutGrid, List, MoreVertical, Copy, Share2, - TriangleAlert, X, Edit2, Trash2, @@ -34,12 +29,14 @@ import { moveFileToNetdiskPath } from '@/src/lib/file-move'; import { resolveStoredFileType, type FileTypeKind } from '@/src/lib/file-type'; import { readCachedValue, writeCachedValue } from '@/src/lib/cache'; import { createFileShareLink, getCurrentFileShareUrl } from '@/src/lib/file-share'; +import { ellipsizeFileName } from '@/src/lib/file-name'; import { getFilesLastPathCacheKey, getFilesListCacheKey } from '@/src/lib/page-cache'; import type { DownloadUrlResponse, FileMetadata, InitiateUploadResponse, PageResponse } from '@/src/lib/types'; import { cn } from '@/src/lib/utils'; import { buildUploadProgressSnapshot, + cancelUploadTask, createUploadMeasurement, createUploadTasks, completeUploadTask, @@ -52,6 +49,13 @@ import { type UploadMeasurement, type UploadTask, } from './files-upload'; +import { + registerFilesUploadTaskCanceler, + replaceFilesUploads, + setFilesUploadPanelOpen, + unregisterFilesUploadTaskCanceler, + updateFilesUploadTask, +} from './files-upload-store'; import { clearSelectionIfDeleted, getNextAvailableName, @@ -199,8 +203,6 @@ export default function Files() { const [expandedDirectories, setExpandedDirectories] = useState(() => createExpandedDirectorySet(initialPath)); const [selectedFile, setSelectedFile] = useState(null); const [currentFiles, setCurrentFiles] = useState(initialCachedFiles.map(toUiFile)); - const [uploads, setUploads] = useState([]); - const [isUploadPanelOpen, setIsUploadPanelOpen] = useState(true); const [renameModalOpen, setRenameModalOpen] = useState(false); const [deleteModalOpen, setDeleteModalOpen] = useState(false); const [fileToRename, setFileToRename] = useState(null); @@ -411,11 +413,11 @@ export default function Files() { return; } - setIsUploadPanelOpen(true); + setFilesUploadPanelOpen(true); uploadMeasurementsRef.current.clear(); const batchTasks = createUploadTasks(entries); - setUploads(batchTasks); + replaceFilesUploads(batchTasks); const runSingleUpload = async ( {file: uploadFile, pathParts: uploadPathParts}: PendingUploadEntry, @@ -423,6 +425,10 @@ export default function Files() { ) => { const uploadPath = toBackendPath(uploadPathParts); const startedAt = Date.now(); + const uploadAbortController = new AbortController(); + registerFilesUploadTaskCanceler(uploadTask.id, () => { + uploadAbortController.abort(); + }); uploadMeasurementsRef.current.set(uploadTask.id, createUploadMeasurement(startedAt)); try { @@ -435,17 +441,11 @@ export default function Files() { }); uploadMeasurementsRef.current.set(uploadTask.id, snapshot.measurement); - setUploads((previous) => - previous.map((task) => - task.id === uploadTask.id - ? { - ...task, - progress: snapshot.progress, - speed: snapshot.speed, - } - : task, - ), - ); + updateFilesUploadTask(uploadTask.id, (task) => ({ + ...task, + progress: snapshot.progress, + speed: snapshot.speed, + })); }; let initiated: InitiateUploadResponse | null = null; @@ -473,10 +473,12 @@ export default function Files() { headers: initiated.headers, body: uploadFile, onProgress: updateProgress, + signal: uploadAbortController.signal, }); uploadedFile = await apiRequest('/files/upload/complete', { method: 'POST', + signal: uploadAbortController.signal, body: { path: uploadPath, filename: uploadFile.name, @@ -495,6 +497,7 @@ export default function Files() { uploadedFile = await apiUploadRequest(`/files/upload?path=${encodeURIComponent(uploadPath)}`, { body: formData, onProgress: updateProgress, + signal: uploadAbortController.signal, }); } } else if (initiated) { @@ -505,6 +508,7 @@ export default function Files() { method: initiated.method, headers: initiated.headers, onProgress: updateProgress, + signal: uploadAbortController.signal, }); } else { const formData = new FormData(); @@ -512,25 +516,26 @@ export default function Files() { uploadedFile = await apiUploadRequest(`/files/upload?path=${encodeURIComponent(uploadPath)}`, { body: formData, onProgress: updateProgress, + signal: uploadAbortController.signal, }); } - uploadMeasurementsRef.current.delete(uploadTask.id); - setUploads((previous) => - previous.map((task) => (task.id === uploadTask.id ? prepareUploadTaskForCompletion(task) : task)), - ); + updateFilesUploadTask(uploadTask.id, (task) => prepareUploadTaskForCompletion(task)); await sleep(120); - setUploads((previous) => - previous.map((task) => (task.id === uploadTask.id ? completeUploadTask(task) : task)), - ); + updateFilesUploadTask(uploadTask.id, (task) => completeUploadTask(task)); return uploadedFile; } catch (error) { - uploadMeasurementsRef.current.delete(uploadTask.id); + if (uploadAbortController.signal.aborted) { + updateFilesUploadTask(uploadTask.id, (task) => cancelUploadTask(task)); + return null; + } + const message = error instanceof Error && error.message ? error.message : '上传失败,请稍后重试'; - setUploads((previous) => - previous.map((task) => (task.id === uploadTask.id ? failUploadTask(task, message) : task)), - ); + updateFilesUploadTask(uploadTask.id, (task) => failUploadTask(task, message)); return null; + } finally { + uploadMeasurementsRef.current.delete(uploadTask.id); + unregisterFilesUploadTaskCanceler(uploadTask.id); } }; @@ -726,11 +731,6 @@ export default function Files() { window.URL.revokeObjectURL(url); }; - const handleClearUploads = () => { - uploadMeasurementsRef.current.clear(); - setUploads([]); - }; - const handleShare = async (targetFile: UiFile) => { try { const response = await createFileShareLink(targetFile.id); @@ -835,14 +835,14 @@ export default function Files() {

此文件夹为空

) : viewMode === 'list' ? ( - +
- - - - - + + + + + @@ -856,11 +856,14 @@ export default function Files() { selectedFile?.id === file.id ? 'bg-[#336EFF]/10' : 'hover:bg-white/[0.02]', )} > - @@ -926,7 +929,7 @@ export default function Files() { - {file.name} + {ellipsizeFileName(file.name, 24)} {file.typeLabel} @@ -968,9 +971,11 @@ export default function Files() { 详细信息 -
+
-

{selectedFile.name}

+

+ {selectedFile.name} +

@@ -1030,108 +1035,6 @@ export default function Files() { )} - - {uploads.length > 0 && ( - -
setIsUploadPanelOpen((previous) => !previous)} - > -
- - - 上传进度 ({uploads.filter((task) => task.status === 'completed').length}/{uploads.length}) - -
-
- - -
-
- - - {isUploadPanelOpen && ( - -
- {uploads.map((task) => ( -
- {task.status === 'uploading' && ( -
- )} - -
- -
-
-

{task.fileName}

-
- {task.status === 'completed' ? ( - - ) : task.status === 'error' ? ( - - ) : ( - - )} -
-
-
- - {task.typeLabel} - - 上传至: {task.destination} -
- {task.noticeMessage && ( -

{task.noticeMessage}

- )} - - {task.status === 'uploading' && ( -
- {Math.round(task.progress)}% - {task.speed} -
- )} - {task.status === 'completed' && ( -

上传完成

- )} - {task.status === 'error' && ( -

{task.errorMessage ?? '上传失败,请稍后重试'}

- )} -
-
-
- ))} -
- - )} - - - )} - - {renameModalOpen && (
diff --git a/front/src/pages/files-upload-store.test.ts b/front/src/pages/files-upload-store.test.ts new file mode 100644 index 0000000..c9740ea --- /dev/null +++ b/front/src/pages/files-upload-store.test.ts @@ -0,0 +1,70 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + cancelFilesUploadTask, + clearFilesUploads, + getFilesUploadStoreSnapshot, + registerFilesUploadTaskCanceler, + replaceFilesUploads, + resetFilesUploadStoreForTests, + setFilesUploadPanelOpen, + subscribeFilesUploadStore, + unregisterFilesUploadTaskCanceler, + updateFilesUploadTask, +} from './files-upload-store'; +import { createUploadTask } from './files-upload'; + +test('files upload store keeps tasks after page-level subscriber unmounts', () => { + resetFilesUploadStoreForTests(); + const task = createUploadTask(new File(['hello'], 'manual.pdf', {type: 'application/pdf'}), ['文档'], 'task-1'); + + const unsubscribe = subscribeFilesUploadStore(() => undefined); + replaceFilesUploads([task]); + unsubscribe(); + + const snapshot = getFilesUploadStoreSnapshot(); + assert.equal(snapshot.uploads.length, 1); + assert.equal(snapshot.uploads[0].fileName, 'manual.pdf'); +}); + +test('files upload store supports task updates and panel visibility toggles', () => { + resetFilesUploadStoreForTests(); + const task = createUploadTask(new File(['hello'], 'manual.pdf', {type: 'application/pdf'}), ['文档'], 'task-2'); + + replaceFilesUploads([task]); + updateFilesUploadTask(task.id, (current) => ({ + ...current, + progress: 80, + })); + setFilesUploadPanelOpen(false); + + const snapshot = getFilesUploadStoreSnapshot(); + assert.equal(snapshot.uploads[0].progress, 80); + assert.equal(snapshot.isUploadPanelOpen, false); + + clearFilesUploads(); + assert.equal(getFilesUploadStoreSnapshot().uploads.length, 0); +}); + +test('files upload store cancels one task by its id', () => { + resetFilesUploadStoreForTests(); + const cancelled: string[] = []; + registerFilesUploadTaskCanceler('task-1', () => { + cancelled.push('task-1'); + }); + registerFilesUploadTaskCanceler('task-2', () => { + cancelled.push('task-2'); + }); + + const task = createUploadTask(new File(['hello'], 'manual.pdf', {type: 'application/pdf'}), ['文档'], 'task-1'); + replaceFilesUploads([task]); + + const didCancel = cancelFilesUploadTask('task-1'); + const didCancelUnknown = cancelFilesUploadTask('missing-task'); + unregisterFilesUploadTaskCanceler('task-2'); + + assert.equal(didCancel, true); + assert.equal(didCancelUnknown, false); + assert.deepEqual(cancelled, ['task-1']); +}); diff --git a/front/src/pages/files-upload-store.ts b/front/src/pages/files-upload-store.ts new file mode 100644 index 0000000..3f5ede7 --- /dev/null +++ b/front/src/pages/files-upload-store.ts @@ -0,0 +1,132 @@ +import { useSyncExternalStore } from 'react'; + +import type { UploadTask } from './files-upload'; + +interface FilesUploadStoreSnapshot { + uploads: UploadTask[]; + isUploadPanelOpen: boolean; +} + +const listeners = new Set<() => void>(); +const uploadTaskCancelers = new Map void>(); + +let snapshot: FilesUploadStoreSnapshot = { + uploads: [], + isUploadPanelOpen: true, +}; + +function emitFilesUploadStoreChange() { + for (const listener of listeners) { + listener(); + } +} + +function setFilesUploadStoreSnapshot( + updater: (current: FilesUploadStoreSnapshot) => FilesUploadStoreSnapshot, +) { + const nextSnapshot = updater(snapshot); + if (nextSnapshot === snapshot) { + return; + } + + snapshot = nextSnapshot; + emitFilesUploadStoreChange(); +} + +export function subscribeFilesUploadStore(listener: () => void) { + listeners.add(listener); + + return () => { + listeners.delete(listener); + }; +} + +export function getFilesUploadStoreSnapshot() { + return snapshot; +} + +export function useFilesUploadStore() { + return useSyncExternalStore( + subscribeFilesUploadStore, + getFilesUploadStoreSnapshot, + getFilesUploadStoreSnapshot, + ); +} + +export function replaceFilesUploads(uploads: UploadTask[]) { + setFilesUploadStoreSnapshot((current) => ({ + ...current, + uploads, + })); +} + +export function clearFilesUploads() { + setFilesUploadStoreSnapshot((current) => { + if (current.uploads.length === 0) { + return current; + } + + return { + ...current, + uploads: [], + }; + }); +} + +export function updateFilesUploadTask( + taskId: string, + updateTask: (task: UploadTask) => UploadTask, +) { + setFilesUploadStoreSnapshot((current) => ({ + ...current, + uploads: current.uploads.map((task) => (task.id === taskId ? updateTask(task) : task)), + })); +} + +export function setFilesUploadPanelOpen(isOpen: boolean) { + setFilesUploadStoreSnapshot((current) => { + if (current.isUploadPanelOpen === isOpen) { + return current; + } + + return { + ...current, + isUploadPanelOpen: isOpen, + }; + }); +} + +export function toggleFilesUploadPanelOpen() { + setFilesUploadStoreSnapshot((current) => ({ + ...current, + isUploadPanelOpen: !current.isUploadPanelOpen, + })); +} + +export function resetFilesUploadStoreForTests() { + snapshot = { + uploads: [], + isUploadPanelOpen: true, + }; + listeners.clear(); + uploadTaskCancelers.clear(); +} + +export function registerFilesUploadTaskCanceler(taskId: string, cancel: () => void) { + uploadTaskCancelers.set(taskId, cancel); +} + +export function unregisterFilesUploadTaskCanceler(taskId: string) { + uploadTaskCancelers.delete(taskId); +} + +export function cancelFilesUploadTask(taskId: string) { + const cancel = uploadTaskCancelers.get(taskId); + if (!cancel) { + return false; + } + + uploadTaskCancelers.delete(taskId); + cancel(); + return true; +} diff --git a/front/src/pages/files-upload.test.ts b/front/src/pages/files-upload.test.ts index 78ea143..240bc63 100644 --- a/front/src/pages/files-upload.test.ts +++ b/front/src/pages/files-upload.test.ts @@ -3,6 +3,7 @@ import test from 'node:test'; import { buildUploadProgressSnapshot, + cancelUploadTask, completeUploadTask, createUploadTasks, createUploadTask, @@ -136,6 +137,16 @@ test('completeUploadTask marks upload as completed', () => { assert.equal(nextTask.speed, ''); }); +test('cancelUploadTask marks upload as cancelled', () => { + const task = createUploadTask(new File(['hello'], 'photo.png', {type: 'image/png'}), [], 'task-cancel'); + + const nextTask = cancelUploadTask(task); + + assert.equal(nextTask.destination, '/'); + assert.equal(nextTask.status, 'cancelled'); + 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'}), diff --git a/front/src/pages/files-upload.ts b/front/src/pages/files-upload.ts index 8a5433c..8276d97 100644 --- a/front/src/pages/files-upload.ts +++ b/front/src/pages/files-upload.ts @@ -1,7 +1,7 @@ import { getNextAvailableName } from './files-state'; import { resolveFileType, type FileTypeKind } from '@/src/lib/file-type'; -export type UploadTaskStatus = 'uploading' | 'completed' | 'error'; +export type UploadTaskStatus = 'uploading' | 'completed' | 'error' | 'cancelled'; export interface UploadTask { id: string; @@ -280,3 +280,12 @@ export function failUploadTask(task: UploadTask, errorMessage: string): UploadTa errorMessage, }; } + +export function cancelUploadTask(task: UploadTask): UploadTask { + return { + ...task, + speed: '', + status: 'cancelled', + errorMessage: undefined, + }; +}
名称修改日期类型大小名称修改日期类型大小
-
+
+
- - {file.name} + + {ellipsizeFileName(file.name, 48)}