修复部分显示问题

This commit is contained in:
yoyuzh
2026-03-26 14:29:30 +08:00
parent b2d9db7be9
commit 448e2a6886
14 changed files with 630 additions and 163 deletions

6
components.json Normal file
View File

@@ -0,0 +1,6 @@
{
"registries": {
"@shadcn": "https://ui.shadcn.com/r/styles/default/{name}.json",
"@react-bits": "https://reactbits.dev/r/{name}.json"
}
}

View File

@@ -22,6 +22,7 @@
"express": "^4.21.2", "express": "^4.21.2",
"lucide-react": "^0.546.0", "lucide-react": "^0.546.0",
"motion": "^12.23.24", "motion": "^12.23.24",
"ogl": "^1.0.11",
"react": "^19.0.0", "react": "^19.0.0",
"react-admin": "^5.14.4", "react-admin": "^5.14.4",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
@@ -4404,6 +4405,12 @@
"node": ">= 0.4" "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": { "node_modules/on-finished": {
"version": "2.4.1", "version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",

View File

@@ -26,6 +26,7 @@
"express": "^4.21.2", "express": "^4.21.2",
"lucide-react": "^0.546.0", "lucide-react": "^0.546.0",
"motion": "^12.23.24", "motion": "^12.23.24",
"ogl": "^1.0.11",
"react": "^19.0.0", "react": "^19.0.0",
"react-admin": "^5.14.4", "react-admin": "^5.14.4",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",

View File

@@ -24,6 +24,7 @@ import { Button } from '@/src/components/ui/button';
import { Input } from '@/src/components/ui/input'; import { Input } from '@/src/components/ui/input';
import { buildAccountDraft, getRoleLabel, shouldLoadAvatarWithAuth } from './account-utils'; import { buildAccountDraft, getRoleLabel, shouldLoadAvatarWithAuth } from './account-utils';
import { UploadProgressPanel } from './UploadProgressPanel';
const NAV_ITEMS = [ const NAV_ITEMS = [
{ name: '总览', path: '/overview', icon: LayoutDashboard }, { name: '总览', path: '/overview', icon: LayoutDashboard },
@@ -435,6 +436,8 @@ export function Layout({ children }: LayoutProps = {}) {
{children ?? <Outlet />} {children ?? <Outlet />}
</main> </main>
<UploadProgressPanel />
<AnimatePresence> <AnimatePresence>
{activeModal === 'security' && ( {activeModal === 'security' && (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 backdrop-blur-sm p-4"> <div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">

View File

@@ -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 (
<AnimatePresence>
<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={() => toggleFilesUploadPanelOpen()}
>
<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 type="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
type="button"
className="rounded p-1 text-slate-400 transition-colors hover:bg-white/10 hover:text-white"
onClick={(event) => {
event.stopPropagation();
clearFilesUploads();
}}
>
<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 === 'cancelled' && 'bg-amber-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">
<FileTypeIcon type={task.type} size="sm" className="mt-0.5" />
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-3">
<p className="truncate text-sm font-medium text-slate-200" title={task.fileName}>
{ellipsizeFileName(task.fileName, 30)}
</p>
<div className="shrink-0 flex items-center gap-2">
{task.status === 'uploading' ? (
<button
type="button"
className="rounded-md border border-amber-500/30 bg-amber-500/10 px-2 py-0.5 text-[11px] font-medium text-amber-200 transition-colors hover:bg-amber-500/20"
onClick={() => {
cancelFilesUploadTask(task.id);
}}
>
</button>
) : null}
{task.status === 'completed' ? (
<CheckCircle2 className="h-[18px] w-[18px] text-emerald-400" />
) : task.status === 'cancelled' ? (
<Ban className="h-[18px] w-[18px] text-amber-300" />
) : task.status === 'error' ? (
<TriangleAlert className="h-[18px] w-[18px] text-rose-400" />
) : (
<FileUp className="h-[18px] w-[18px] animate-pulse text-[#78A1FF]" />
)}
</div>
</div>
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs">
<span className={cn('rounded-full px-2 py-1 font-medium', getFileTypeTheme(task.type).badgeClassName)}>
{task.typeLabel}
</span>
<span className="truncate text-slate-500">: {task.destination}</span>
</div>
{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 === 'cancelled' && (
<p className="mt-2 text-xs text-amber-300"></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>
);
}

View File

@@ -48,6 +48,8 @@ class FakeXMLHttpRequest {
responseHeaders = new Map<string, string>(); responseHeaders = new Map<string, string>();
onload: null | (() => void) = null; onload: null | (() => void) = null;
onerror: null | (() => void) = null; onerror: null | (() => void) = null;
onabort: null | (() => void) = null;
aborted = false;
upload = { upload = {
addEventListener: (type: string, listener: EventListenerOrEventListenerObject) => { addEventListener: (type: string, listener: EventListenerOrEventListenerObject) => {
@@ -82,6 +84,11 @@ class FakeXMLHttpRequest {
this.requestBody = body; this.requestBody = body;
} }
abort() {
this.aborted = true;
this.onabort?.();
}
triggerProgress(loaded: number, total: number) { triggerProgress(loaded: number, total: number) {
const event = { const event = {
lengthComputable: true, 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 () => { test('apiRequest refreshes expired access token once and retries the original request', async () => {
const calls: Array<{url: string; authorization: string | null; body: string | null}> = []; const calls: Array<{url: string; authorization: string | null; body: string | null}> = [];
saveStoredSession({ saveStoredSession({

View File

@@ -16,6 +16,7 @@ interface ApiUploadRequestInit {
headers?: HeadersInit; headers?: HeadersInit;
method?: 'POST' | 'PUT' | 'PATCH'; method?: 'POST' | 'PUT' | 'PATCH';
onProgress?: (progress: {loaded: number; total: number}) => void; onProgress?: (progress: {loaded: number; total: number}) => void;
signal?: AbortSignal;
} }
interface ApiBinaryUploadRequestInit { interface ApiBinaryUploadRequestInit {
@@ -23,6 +24,7 @@ interface ApiBinaryUploadRequestInit {
headers?: HeadersInit; headers?: HeadersInit;
method?: 'PUT' | 'POST'; method?: 'PUT' | 'POST';
onProgress?: (progress: {loaded: number; total: number}) => void; onProgress?: (progress: {loaded: number; total: number}) => void;
signal?: AbortSignal;
} }
const API_BASE_URL = (import.meta.env?.VITE_API_BASE_URL || '/api').replace(/\/$/, ''); 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); return new ApiError(message === 'Failed to fetch' ? fallbackMessage : message, 0);
} }
function toUploadAbortApiError() {
return new ApiError('上传已取消', 0);
}
export function shouldRetryRequest( export function shouldRetryRequest(
path: string, path: string,
init: ApiRequestInit = {}, init: ApiRequestInit = {},
@@ -296,6 +302,46 @@ function apiUploadRequestInternal<T>(path: string, init: ApiUploadRequestInit, a
return new Promise<T>((resolve, reject) => { return new Promise<T>((resolve, reject) => {
const xhr = new XMLHttpRequest(); const xhr = new XMLHttpRequest();
let settled = false;
const detachAbortSignal = () => {
init.signal?.removeEventListener('abort', handleAbortSignal);
};
const resolveOnce = (value: T | PromiseLike<T>) => {
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)); xhr.open(init.method || 'POST', resolveUrl(path));
headers.forEach((value, key) => { headers.forEach((value, key) => {
@@ -316,7 +362,16 @@ function apiUploadRequestInternal<T>(path: string, init: ApiUploadRequestInit, a
} }
xhr.onerror = () => { 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 = () => { xhr.onload = () => {
@@ -326,26 +381,26 @@ function apiUploadRequestInternal<T>(path: string, init: ApiUploadRequestInit, a
refreshAccessToken() refreshAccessToken()
.then((refreshed) => { .then((refreshed) => {
if (refreshed) { if (refreshed) {
resolve(apiUploadRequestInternal<T>(path, init, false)); resolveOnce(apiUploadRequestInternal<T>(path, init, false));
return; return;
} }
clearStoredSession(); clearStoredSession();
reject(new ApiError('登录状态已失效,请重新登录', 401)); rejectOnce(new ApiError('登录状态已失效,请重新登录', 401));
}) })
.catch((error) => { .catch((error) => {
clearStoredSession(); clearStoredSession();
reject(error instanceof ApiError ? error : toNetworkApiError(error)); rejectOnce(error instanceof ApiError ? error : toNetworkApiError(error));
}); });
return; return;
} }
if (!contentType.includes('application/json')) { if (!contentType.includes('application/json')) {
if (xhr.status >= 200 && xhr.status < 300) { if (xhr.status >= 200 && xhr.status < 300) {
resolve(undefined as T); resolveOnce(undefined as T);
return; return;
} }
reject(new ApiError(`请求失败 (${xhr.status})`, xhr.status)); rejectOnce(new ApiError(`请求失败 (${xhr.status})`, xhr.status));
return; return;
} }
@@ -354,13 +409,17 @@ function apiUploadRequestInternal<T>(path: string, init: ApiUploadRequestInit, a
if (xhr.status === 401) { if (xhr.status === 401) {
clearStoredSession(); clearStoredSession();
} }
reject(new ApiError(payload.msg || `请求失败 (${xhr.status})`, xhr.status, payload.code)); rejectOnce(new ApiError(payload.msg || `请求失败 (${xhr.status})`, xhr.status, payload.code));
return; return;
} }
resolve(payload.data); resolveOnce(payload.data);
}; };
if (init.signal) {
init.signal.addEventListener('abort', handleAbortSignal, {once: true});
}
xhr.send(init.body); xhr.send(init.body);
}); });
} }
@@ -374,6 +433,46 @@ export function apiBinaryUploadRequest(path: string, init: ApiBinaryUploadReques
return new Promise<void>((resolve, reject) => { return new Promise<void>((resolve, reject) => {
const xhr = new XMLHttpRequest(); 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)); xhr.open(init.method || 'PUT', resolveUrl(path));
headers.forEach((value, key) => { headers.forEach((value, key) => {
@@ -394,18 +493,31 @@ export function apiBinaryUploadRequest(path: string, init: ApiBinaryUploadReques
} }
xhr.onerror = () => { 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 = () => { xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) { if (xhr.status >= 200 && xhr.status < 300) {
resolve(); resolveOnce();
return; 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); xhr.send(init.body);
}); });
} }

View File

@@ -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...',
);
});

View File

@@ -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)}...`;
}

View File

@@ -1,23 +1,18 @@
import React, { useEffect, useRef, useState } from 'react'; import React, { useEffect, useRef, useState } from 'react';
import { AnimatePresence, motion } from 'motion/react'; import { AnimatePresence, motion } from 'motion/react';
import { import {
CheckCircle2,
ChevronDown, ChevronDown,
Folder, Folder,
Download, Download,
ChevronRight, ChevronRight,
ChevronUp,
FileUp,
FolderUp, FolderUp,
Upload, Upload,
UploadCloud,
Plus, Plus,
LayoutGrid, LayoutGrid,
List, List,
MoreVertical, MoreVertical,
Copy, Copy,
Share2, Share2,
TriangleAlert,
X, X,
Edit2, Edit2,
Trash2, Trash2,
@@ -34,12 +29,14 @@ import { moveFileToNetdiskPath } from '@/src/lib/file-move';
import { resolveStoredFileType, type FileTypeKind } from '@/src/lib/file-type'; import { resolveStoredFileType, type FileTypeKind } from '@/src/lib/file-type';
import { readCachedValue, writeCachedValue } from '@/src/lib/cache'; import { readCachedValue, writeCachedValue } from '@/src/lib/cache';
import { createFileShareLink, getCurrentFileShareUrl } from '@/src/lib/file-share'; import { createFileShareLink, getCurrentFileShareUrl } from '@/src/lib/file-share';
import { ellipsizeFileName } from '@/src/lib/file-name';
import { getFilesLastPathCacheKey, getFilesListCacheKey } from '@/src/lib/page-cache'; import { getFilesLastPathCacheKey, getFilesListCacheKey } from '@/src/lib/page-cache';
import type { DownloadUrlResponse, FileMetadata, InitiateUploadResponse, PageResponse } from '@/src/lib/types'; import type { DownloadUrlResponse, FileMetadata, InitiateUploadResponse, PageResponse } from '@/src/lib/types';
import { cn } from '@/src/lib/utils'; import { cn } from '@/src/lib/utils';
import { import {
buildUploadProgressSnapshot, buildUploadProgressSnapshot,
cancelUploadTask,
createUploadMeasurement, createUploadMeasurement,
createUploadTasks, createUploadTasks,
completeUploadTask, completeUploadTask,
@@ -52,6 +49,13 @@ import {
type UploadMeasurement, type UploadMeasurement,
type UploadTask, type UploadTask,
} from './files-upload'; } from './files-upload';
import {
registerFilesUploadTaskCanceler,
replaceFilesUploads,
setFilesUploadPanelOpen,
unregisterFilesUploadTaskCanceler,
updateFilesUploadTask,
} from './files-upload-store';
import { import {
clearSelectionIfDeleted, clearSelectionIfDeleted,
getNextAvailableName, getNextAvailableName,
@@ -199,8 +203,6 @@ export default function Files() {
const [expandedDirectories, setExpandedDirectories] = useState(() => createExpandedDirectorySet(initialPath)); const [expandedDirectories, setExpandedDirectories] = useState(() => createExpandedDirectorySet(initialPath));
const [selectedFile, setSelectedFile] = useState<UiFile | null>(null); const [selectedFile, setSelectedFile] = useState<UiFile | null>(null);
const [currentFiles, setCurrentFiles] = useState<UiFile[]>(initialCachedFiles.map(toUiFile)); const [currentFiles, setCurrentFiles] = useState<UiFile[]>(initialCachedFiles.map(toUiFile));
const [uploads, setUploads] = useState<UploadTask[]>([]);
const [isUploadPanelOpen, setIsUploadPanelOpen] = useState(true);
const [renameModalOpen, setRenameModalOpen] = useState(false); const [renameModalOpen, setRenameModalOpen] = useState(false);
const [deleteModalOpen, setDeleteModalOpen] = useState(false); const [deleteModalOpen, setDeleteModalOpen] = useState(false);
const [fileToRename, setFileToRename] = useState<UiFile | null>(null); const [fileToRename, setFileToRename] = useState<UiFile | null>(null);
@@ -411,11 +413,11 @@ export default function Files() {
return; return;
} }
setIsUploadPanelOpen(true); setFilesUploadPanelOpen(true);
uploadMeasurementsRef.current.clear(); uploadMeasurementsRef.current.clear();
const batchTasks = createUploadTasks(entries); const batchTasks = createUploadTasks(entries);
setUploads(batchTasks); replaceFilesUploads(batchTasks);
const runSingleUpload = async ( const runSingleUpload = async (
{file: uploadFile, pathParts: uploadPathParts}: PendingUploadEntry, {file: uploadFile, pathParts: uploadPathParts}: PendingUploadEntry,
@@ -423,6 +425,10 @@ export default function Files() {
) => { ) => {
const uploadPath = toBackendPath(uploadPathParts); const uploadPath = toBackendPath(uploadPathParts);
const startedAt = Date.now(); const startedAt = Date.now();
const uploadAbortController = new AbortController();
registerFilesUploadTaskCanceler(uploadTask.id, () => {
uploadAbortController.abort();
});
uploadMeasurementsRef.current.set(uploadTask.id, createUploadMeasurement(startedAt)); uploadMeasurementsRef.current.set(uploadTask.id, createUploadMeasurement(startedAt));
try { try {
@@ -435,17 +441,11 @@ export default function Files() {
}); });
uploadMeasurementsRef.current.set(uploadTask.id, snapshot.measurement); uploadMeasurementsRef.current.set(uploadTask.id, snapshot.measurement);
setUploads((previous) => updateFilesUploadTask(uploadTask.id, (task) => ({
previous.map((task) => ...task,
task.id === uploadTask.id progress: snapshot.progress,
? { speed: snapshot.speed,
...task, }));
progress: snapshot.progress,
speed: snapshot.speed,
}
: task,
),
);
}; };
let initiated: InitiateUploadResponse | null = null; let initiated: InitiateUploadResponse | null = null;
@@ -473,10 +473,12 @@ export default function Files() {
headers: initiated.headers, headers: initiated.headers,
body: uploadFile, body: uploadFile,
onProgress: updateProgress, onProgress: updateProgress,
signal: uploadAbortController.signal,
}); });
uploadedFile = await apiRequest<FileMetadata>('/files/upload/complete', { uploadedFile = await apiRequest<FileMetadata>('/files/upload/complete', {
method: 'POST', method: 'POST',
signal: uploadAbortController.signal,
body: { body: {
path: uploadPath, path: uploadPath,
filename: uploadFile.name, filename: uploadFile.name,
@@ -495,6 +497,7 @@ export default function Files() {
uploadedFile = await apiUploadRequest<FileMetadata>(`/files/upload?path=${encodeURIComponent(uploadPath)}`, { uploadedFile = await apiUploadRequest<FileMetadata>(`/files/upload?path=${encodeURIComponent(uploadPath)}`, {
body: formData, body: formData,
onProgress: updateProgress, onProgress: updateProgress,
signal: uploadAbortController.signal,
}); });
} }
} else if (initiated) { } else if (initiated) {
@@ -505,6 +508,7 @@ export default function Files() {
method: initiated.method, method: initiated.method,
headers: initiated.headers, headers: initiated.headers,
onProgress: updateProgress, onProgress: updateProgress,
signal: uploadAbortController.signal,
}); });
} else { } else {
const formData = new FormData(); const formData = new FormData();
@@ -512,25 +516,26 @@ export default function Files() {
uploadedFile = await apiUploadRequest<FileMetadata>(`/files/upload?path=${encodeURIComponent(uploadPath)}`, { uploadedFile = await apiUploadRequest<FileMetadata>(`/files/upload?path=${encodeURIComponent(uploadPath)}`, {
body: formData, body: formData,
onProgress: updateProgress, onProgress: updateProgress,
signal: uploadAbortController.signal,
}); });
} }
uploadMeasurementsRef.current.delete(uploadTask.id); updateFilesUploadTask(uploadTask.id, (task) => prepareUploadTaskForCompletion(task));
setUploads((previous) =>
previous.map((task) => (task.id === uploadTask.id ? prepareUploadTaskForCompletion(task) : task)),
);
await sleep(120); await sleep(120);
setUploads((previous) => updateFilesUploadTask(uploadTask.id, (task) => completeUploadTask(task));
previous.map((task) => (task.id === uploadTask.id ? completeUploadTask(task) : task)),
);
return uploadedFile; return uploadedFile;
} catch (error) { } 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 : '上传失败,请稍后重试'; const message = error instanceof Error && error.message ? error.message : '上传失败,请稍后重试';
setUploads((previous) => updateFilesUploadTask(uploadTask.id, (task) => failUploadTask(task, message));
previous.map((task) => (task.id === uploadTask.id ? failUploadTask(task, message) : task)),
);
return null; return null;
} finally {
uploadMeasurementsRef.current.delete(uploadTask.id);
unregisterFilesUploadTaskCanceler(uploadTask.id);
} }
}; };
@@ -726,11 +731,6 @@ export default function Files() {
window.URL.revokeObjectURL(url); window.URL.revokeObjectURL(url);
}; };
const handleClearUploads = () => {
uploadMeasurementsRef.current.clear();
setUploads([]);
};
const handleShare = async (targetFile: UiFile) => { const handleShare = async (targetFile: UiFile) => {
try { try {
const response = await createFileShareLink(targetFile.id); const response = await createFileShareLink(targetFile.id);
@@ -835,14 +835,14 @@ export default function Files() {
<p className="text-sm"></p> <p className="text-sm"></p>
</div> </div>
) : viewMode === 'list' ? ( ) : viewMode === 'list' ? (
<table className="w-full text-left border-collapse"> <table className="w-full table-fixed text-left border-collapse">
<thead> <thead>
<tr className="text-xs font-semibold text-slate-500 uppercase tracking-wider border-b border-white/5"> <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 pl-4 font-medium w-[44%]"></th>
<th className="pb-3 font-medium hidden md:table-cell"></th> <th className="pb-3 font-medium hidden md:table-cell w-[22%]"></th>
<th className="pb-3 font-medium hidden lg:table-cell"></th> <th className="pb-3 font-medium hidden lg:table-cell w-[14%]"></th>
<th className="pb-3 font-medium"></th> <th className="pb-3 font-medium w-[10%]"></th>
<th className="pb-3"></th> <th className="pb-3 w-[10%]"></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -856,11 +856,14 @@ export default function Files() {
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"> <td className="py-3 pl-4 max-w-0">
<div className="flex items-center gap-3"> <div className="flex min-w-0 items-center gap-3">
<FileTypeIcon type={file.type} size="sm" /> <FileTypeIcon type={file.type} size="sm" />
<span className={cn('text-sm font-medium', selectedFile?.id === file.id ? 'text-[#336EFF]' : 'text-slate-200')}> <span
{file.name} className={cn('block truncate text-sm font-medium', selectedFile?.id === file.id ? 'text-[#336EFF]' : 'text-slate-200')}
title={file.name}
>
{ellipsizeFileName(file.name, 48)}
</span> </span>
</div> </div>
</td> </td>
@@ -926,7 +929,7 @@ export default function Files() {
<FileTypeIcon type={file.type} size="lg" className="mb-3 transition-transform duration-200 group-hover:scale-[1.03]" /> <FileTypeIcon type={file.type} size="lg" className="mb-3 transition-transform duration-200 group-hover:scale-[1.03]" />
<span className={cn('w-full truncate px-2 text-center text-sm font-medium', selectedFile?.id === file.id ? 'text-[#336EFF]' : 'text-slate-200')}> <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} {ellipsizeFileName(file.name, 24)}
</span> </span>
<span className={cn('mt-1 inline-flex rounded-full px-2 py-1 text-[11px] font-medium', getFileTypeTheme(file.type).badgeClassName)}> <span className={cn('mt-1 inline-flex rounded-full px-2 py-1 text-[11px] font-medium', getFileTypeTheme(file.type).badgeClassName)}>
{file.typeLabel} {file.typeLabel}
@@ -968,9 +971,11 @@ export default function Files() {
<CardTitle className="text-base"></CardTitle> <CardTitle className="text-base"></CardTitle>
</CardHeader> </CardHeader>
<CardContent className="p-6 space-y-6"> <CardContent className="p-6 space-y-6">
<div className="flex flex-col items-center text-center space-y-3"> <div className="flex w-full flex-col items-center text-center space-y-3">
<FileTypeIcon type={selectedFile.type} size="lg" /> <FileTypeIcon type={selectedFile.type} size="lg" />
<h3 className="text-sm font-medium text-white break-all">{selectedFile.name}</h3> <h3 className="w-full truncate text-sm font-medium text-white" title={selectedFile.name}>
{selectedFile.name}
</h3>
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
@@ -1030,108 +1035,6 @@ export default function Files() {
</motion.div> </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">
<FileTypeIcon type={task.type} size="sm" className="mt-0.5" />
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-3">
<p className="truncate text-sm font-medium text-slate-200">{task.fileName}</p>
<div className="shrink-0">
{task.status === 'completed' ? (
<CheckCircle2 className="h-[18px] w-[18px] text-emerald-400" />
) : task.status === 'error' ? (
<TriangleAlert className="h-[18px] w-[18px] text-rose-400" />
) : (
<FileUp className="h-[18px] w-[18px] animate-pulse text-[#78A1FF]" />
)}
</div>
</div>
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs">
<span className={cn('rounded-full px-2 py-1 font-medium', getFileTypeTheme(task.type).badgeClassName)}>
{task.typeLabel}
</span>
<span className="truncate text-slate-500">: {task.destination}</span>
</div>
{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> <AnimatePresence>
{renameModalOpen && ( {renameModalOpen && (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm"> <div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm">

View File

@@ -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']);
});

View File

@@ -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<string, () => 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;
}

View File

@@ -3,6 +3,7 @@ import test from 'node:test';
import { import {
buildUploadProgressSnapshot, buildUploadProgressSnapshot,
cancelUploadTask,
completeUploadTask, completeUploadTask,
createUploadTasks, createUploadTasks,
createUploadTask, createUploadTask,
@@ -136,6 +137,16 @@ test('completeUploadTask marks upload as completed', () => {
assert.equal(nextTask.speed, ''); 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', () => { test('prepareUploadFile appends an incrementing suffix when the same file name already exists', () => {
const firstDuplicate = prepareUploadFile( const firstDuplicate = prepareUploadFile(
new File(['hello'], 'notes.md', {type: 'text/markdown'}), new File(['hello'], 'notes.md', {type: 'text/markdown'}),

View File

@@ -1,7 +1,7 @@
import { getNextAvailableName } from './files-state'; import { getNextAvailableName } from './files-state';
import { resolveFileType, type FileTypeKind } from '@/src/lib/file-type'; 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 { export interface UploadTask {
id: string; id: string;
@@ -280,3 +280,12 @@ export function failUploadTask(task: UploadTask, errorMessage: string): UploadTa
errorMessage, errorMessage,
}; };
} }
export function cancelUploadTask(task: UploadTask): UploadTask {
return {
...task,
speed: '',
status: 'cancelled',
errorMessage: undefined,
};
}