修复部分显示问题
This commit is contained in:
6
components.json
Normal file
6
components.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"registries": {
|
||||||
|
"@shadcn": "https://ui.shadcn.com/r/styles/default/{name}.json",
|
||||||
|
"@react-bits": "https://reactbits.dev/r/{name}.json"
|
||||||
|
}
|
||||||
|
}
|
||||||
7
front/package-lock.json
generated
7
front/package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
142
front/src/components/layout/UploadProgressPanel.tsx
Normal file
142
front/src/components/layout/UploadProgressPanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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({
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
22
front/src/lib/file-name.test.ts
Normal file
22
front/src/lib/file-name.test.ts
Normal 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...',
|
||||||
|
);
|
||||||
|
});
|
||||||
23
front/src/lib/file-name.ts
Normal file
23
front/src/lib/file-name.ts
Normal 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)}...`;
|
||||||
|
}
|
||||||
@@ -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.id === uploadTask.id
|
|
||||||
? {
|
|
||||||
...task,
|
...task,
|
||||||
progress: snapshot.progress,
|
progress: snapshot.progress,
|
||||||
speed: snapshot.speed,
|
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,26 +516,27 @@ 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) {
|
||||||
const message = error instanceof Error && error.message ? error.message : '上传失败,请稍后重试';
|
updateFilesUploadTask(uploadTask.id, (task) => cancelUploadTask(task));
|
||||||
setUploads((previous) =>
|
|
||||||
previous.map((task) => (task.id === uploadTask.id ? failUploadTask(task, message) : task)),
|
|
||||||
);
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const message = error instanceof Error && error.message ? error.message : '上传失败,请稍后重试';
|
||||||
|
updateFilesUploadTask(uploadTask.id, (task) => failUploadTask(task, message));
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
uploadMeasurementsRef.current.delete(uploadTask.id);
|
||||||
|
unregisterFilesUploadTaskCanceler(uploadTask.id);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const results = shouldUploadEntriesSequentially(entries)
|
const results = shouldUploadEntriesSequentially(entries)
|
||||||
@@ -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">
|
||||||
|
|||||||
70
front/src/pages/files-upload-store.test.ts
Normal file
70
front/src/pages/files-upload-store.test.ts
Normal 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']);
|
||||||
|
});
|
||||||
132
front/src/pages/files-upload-store.ts
Normal file
132
front/src/pages/files-upload-store.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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'}),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user