添加上传和下载文件夹

This commit is contained in:
yoyuzh
2026-03-19 13:39:48 +08:00
parent 64e146dfee
commit 41a83d2805
17 changed files with 729 additions and 33 deletions

37
front/AGENTS.md Normal file
View File

@@ -0,0 +1,37 @@
# Frontend AGENTS
This directory is a Vite + React + TypeScript frontend. Follow the current split between pages, shared state/helpers, auth context, and reusable UI.
## Frontend layout
- `src/pages`: route-level screens and page-scoped state modules.
- `src/lib`: API helpers, cache helpers, schedule utilities, shared types, and test files.
- `src/auth`: authentication context/provider.
- `src/components/layout`: page shell/layout components.
- `src/components/ui`: reusable UI primitives.
- `src/index.css`: global styles.
## Real frontend commands
Run these from `front/`:
- `npm run dev`
- `npm run build`
- `npm run preview`
- `npm run clean`
- `npm run lint`
- `npm run test`
Important:
- `npm run lint` is the current TypeScript check because it runs `tsc --noEmit`.
- There is no separate ESLint script.
- There is no separate `typecheck` script beyond `npm run lint`.
## Frontend rules
- Keep route behavior in `src/pages` and shared non-UI logic in `src/lib`.
- Add or update tests next to the state/helper module they exercise, following the existing `*.test.ts` pattern.
- Preserve the current Vite alias usage: `@/*` resolves from the `front/` directory root.
- If a change depends on backend API behavior, verify the proxy expectations in `vite.config.ts` before hardcoding URLs.
- Use the existing `npm run build`, `npm run test`, and `npm run lint` commands for validation; do not invent a separate frontend verification command.

View File

@@ -11,6 +11,7 @@ import {
ChevronRight,
ChevronUp,
FileUp,
FolderUp,
Upload,
UploadCloud,
Plus,
@@ -34,10 +35,15 @@ import { cn } from '@/src/lib/utils';
import {
buildUploadProgressSnapshot,
createUploadMeasurement,
createUploadTasks,
completeUploadTask,
createUploadTask,
failUploadTask,
prepareUploadTaskForCompletion,
prepareFolderUploadEntries,
prepareUploadFile,
shouldUploadEntriesSequentially,
type PendingUploadEntry,
type UploadMeasurement,
type UploadTask,
} from './files-upload';
@@ -63,6 +69,12 @@ const DIRECTORIES = [
{ name: '图片', icon: Folder },
];
function sleep(ms: number) {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
function toBackendPath(pathParts: string[]) {
return pathParts.length === 0 ? '/' : `/${pathParts.join('/')}`;
}
@@ -115,6 +127,7 @@ export default function Files() {
const initialPath = readCachedValue<string[]>(getFilesLastPathCacheKey()) ?? [];
const initialCachedFiles = readCachedValue<FileMetadata[]>(getFilesListCacheKey(toBackendPath(initialPath))) ?? [];
const fileInputRef = useRef<HTMLInputElement | null>(null);
const directoryInputRef = useRef<HTMLInputElement | null>(null);
const uploadMeasurementsRef = useRef(new Map<string, UploadMeasurement>());
const [currentPath, setCurrentPath] = useState<string[]>(initialPath);
const currentPathRef = useRef(currentPath);
@@ -128,7 +141,7 @@ export default function Files() {
const [fileToDelete, setFileToDelete] = useState<UiFile | null>(null);
const [newFileName, setNewFileName] = useState('');
const [activeDropdown, setActiveDropdown] = useState<number | null>(null);
const [viewMode, setViewMode] = useState<'list' | 'grid'>('grid');
const [viewMode, setViewMode] = useState<'list' | 'grid'>('list');
const [renameError, setRenameError] = useState('');
const [isRenaming, setIsRenaming] = useState(false);
@@ -157,6 +170,15 @@ export default function Files() {
});
}, [currentPath]);
useEffect(() => {
if (!directoryInputRef.current) {
return;
}
directoryInputRef.current.setAttribute('webkitdirectory', '');
directoryInputRef.current.setAttribute('directory', '');
}, []);
const handleSidebarClick = (pathParts: string[]) => {
setCurrentPath(pathParts);
setSelectedFile(null);
@@ -192,25 +214,28 @@ export default function Files() {
fileInputRef.current?.click();
};
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const files = event.target.files ? (Array.from(event.target.files) as File[]) : [];
event.target.value = '';
const handleUploadFolderClick = () => {
directoryInputRef.current?.click();
};
if (files.length === 0) {
const runUploadEntries = async (entries: PendingUploadEntry[]) => {
if (entries.length === 0) {
return;
}
const uploadPathParts = [...currentPath];
const uploadPath = toBackendPath(uploadPathParts);
const reservedNames = new Set<string>(currentFiles.map((file) => file.name));
setIsUploadPanelOpen(true);
uploadMeasurementsRef.current.clear();
const uploadJobs = files.map(async (file) => {
const preparedUpload = prepareUploadFile(file, reservedNames);
reservedNames.add(preparedUpload.file.name);
const uploadFile = preparedUpload.file;
const uploadTask = createUploadTask(uploadFile, uploadPathParts, undefined, preparedUpload.noticeMessage);
setUploads((previous) => [...previous, uploadTask]);
const batchTasks = createUploadTasks(entries);
setUploads(batchTasks);
const runSingleUpload = async (
{file: uploadFile, pathParts: uploadPathParts}: PendingUploadEntry,
uploadTask: UploadTask,
) => {
const uploadPath = toBackendPath(uploadPathParts);
const startedAt = Date.now();
uploadMeasurementsRef.current.set(uploadTask.id, createUploadMeasurement(startedAt));
try {
const updateProgress = ({loaded, total}: {loaded: number; total: number}) => {
@@ -303,6 +328,10 @@ export default function Files() {
}
uploadMeasurementsRef.current.delete(uploadTask.id);
setUploads((previous) =>
previous.map((task) => (task.id === uploadTask.id ? prepareUploadTaskForCompletion(task) : task)),
);
await sleep(120);
setUploads((previous) =>
previous.map((task) => (task.id === uploadTask.id ? completeUploadTask(task) : task)),
);
@@ -315,12 +344,62 @@ export default function Files() {
);
return null;
}
};
const results = shouldUploadEntriesSequentially(entries)
? await entries.reduce<Promise<Array<FileMetadata | null>>>(
async (previousPromise, entry, index) => {
const previous = await previousPromise;
const current = await runSingleUpload(entry, batchTasks[index]);
return [...previous, current];
},
Promise.resolve([]),
)
: await Promise.all(entries.map((entry, index) => runSingleUpload(entry, batchTasks[index])));
if (results.some(Boolean)) {
await loadCurrentPath(currentPathRef.current).catch(() => undefined);
}
};
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const files = event.target.files ? (Array.from(event.target.files) as File[]) : [];
event.target.value = '';
if (files.length === 0) {
return;
}
const reservedNames = new Set<string>(currentFiles.map((file) => file.name));
const entries: PendingUploadEntry[] = files.map((file) => {
const preparedUpload = prepareUploadFile(file, reservedNames);
reservedNames.add(preparedUpload.file.name);
return {
file: preparedUpload.file,
pathParts: [...currentPath],
source: 'file' as const,
noticeMessage: preparedUpload.noticeMessage,
};
});
const results = await Promise.all(uploadJobs);
if (results.some(Boolean) && toBackendPath(currentPathRef.current) === uploadPath) {
await loadCurrentPath(uploadPathParts).catch(() => undefined);
await runUploadEntries(entries);
};
const handleFolderChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const files = event.target.files ? (Array.from(event.target.files) as File[]) : [];
event.target.value = '';
if (files.length === 0) {
return;
}
const entries = prepareFolderUploadEntries(
files,
[...currentPath],
currentFiles.map((file) => file.name),
);
await runUploadEntries(entries);
};
const handleCreateFolder = async () => {
@@ -399,17 +478,29 @@ export default function Files() {
await loadCurrentPath(currentPath).catch(() => undefined);
};
const handleDownload = async () => {
if (!selectedFile || selectedFile.type === 'folder') {
const handleDownload = async (targetFile: UiFile | null = selectedFile) => {
if (!targetFile) {
return;
}
if (targetFile.type === 'folder') {
const response = await apiDownload(`/files/download/${targetFile.id}`);
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${targetFile.name}.zip`;
link.click();
window.URL.revokeObjectURL(url);
return;
}
try {
const response = await apiRequest<DownloadUrlResponse>(`/files/download/${selectedFile.id}/url`);
const response = await apiRequest<DownloadUrlResponse>(`/files/download/${targetFile.id}/url`);
const url = response.url;
const link = document.createElement('a');
link.href = url;
link.download = selectedFile.name;
link.download = targetFile.name;
link.rel = 'noreferrer';
link.target = '_blank';
link.click();
@@ -420,12 +511,12 @@ export default function Files() {
}
}
const response = await apiDownload(`/files/download/${selectedFile.id}`);
const response = await apiDownload(`/files/download/${targetFile.id}`);
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = selectedFile.name;
link.download = targetFile.name;
link.click();
window.URL.revokeObjectURL(url);
};
@@ -573,6 +664,7 @@ export default function Files() {
file={file}
activeDropdown={activeDropdown}
onToggle={(fileId) => setActiveDropdown((previous) => (previous === fileId ? null : fileId))}
onDownload={handleDownload}
onRename={openRenameModal}
onDelete={openDeleteModal}
onClose={() => setActiveDropdown(null)}
@@ -601,6 +693,7 @@ export default function Files() {
file={file}
activeDropdown={activeDropdown}
onToggle={(fileId) => setActiveDropdown((previous) => (previous === fileId ? null : fileId))}
onDownload={handleDownload}
onRename={openRenameModal}
onDelete={openDeleteModal}
onClose={() => setActiveDropdown(null)}
@@ -634,10 +727,14 @@ export default function Files() {
<Button variant="default" className="gap-2" onClick={handleUploadClick}>
<Upload className="w-4 h-4" />
</Button>
<Button variant="outline" className="gap-2" onClick={handleUploadFolderClick}>
<FolderUp className="w-4 h-4" />
</Button>
<Button variant="outline" className="gap-2" onClick={handleCreateFolder}>
<Plus className="w-4 h-4" />
</Button>
<input ref={fileInputRef} type="file" multiple className="hidden" onChange={handleFileChange} />
<input ref={directoryInputRef} type="file" multiple className="hidden" onChange={handleFolderChange} />
</div>
</Card>
@@ -686,14 +783,19 @@ export default function Files() {
<Trash2 className="w-4 h-4" />
</Button>
</div>
{selectedFile.type !== 'folder' && (
<Button variant="default" className="w-full gap-2" onClick={handleDownload}>
<Download className="w-4 h-4" />
</Button>
)}
{selectedFile.type === 'folder' && (
<Button variant="default" className="w-full gap-2" onClick={() => handleFolderDoubleClick(selectedFile)}>
<div className="space-y-3">
<Button variant="default" className="w-full gap-2" onClick={() => handleFolderDoubleClick(selectedFile)}>
</Button>
<Button variant="default" className="w-full gap-2" onClick={() => void handleDownload(selectedFile)}>
<Download className="w-4 h-4" />
</Button>
</div>
)}
{selectedFile.type !== 'folder' && (
<Button variant="default" className="w-full gap-2" onClick={() => void handleDownload(selectedFile)}>
<Download className="w-4 h-4" />
</Button>
)}
</div>
@@ -939,6 +1041,7 @@ function FileActionMenu({
file,
activeDropdown,
onToggle,
onDownload,
onRename,
onDelete,
onClose,
@@ -946,6 +1049,7 @@ function FileActionMenu({
file: UiFile;
activeDropdown: number | null;
onToggle: (fileId: number) => void;
onDownload: (file: UiFile) => Promise<void>;
onRename: (file: UiFile) => void;
onDelete: (file: UiFile) => void;
onClose: () => void;
@@ -979,6 +1083,16 @@ function FileActionMenu({
transition={{ duration: 0.15 }}
className="absolute right-0 top-full z-50 mt-1 w-32 overflow-hidden rounded-lg border border-white/10 bg-[#1e293b] py-1 shadow-xl"
>
<button
onClick={(event) => {
event.stopPropagation();
void onDownload(file);
onClose();
}}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-slate-300 transition-colors hover:bg-white/10 hover:text-white"
>
<Download className="w-4 h-4" /> {file.type === 'folder' ? '下载文件夹' : '下载文件'}
</button>
<button
onClick={(event) => {
event.stopPropagation();

View File

@@ -4,9 +4,13 @@ import test from 'node:test';
import {
buildUploadProgressSnapshot,
completeUploadTask,
createUploadTasks,
createUploadTask,
prepareUploadTaskForCompletion,
formatTransferSpeed,
prepareFolderUploadEntries,
prepareUploadFile,
shouldUploadEntriesSequentially,
} from './files-upload';
test('createUploadTask uses current path as upload destination', () => {
@@ -95,3 +99,79 @@ test('prepareUploadFile keeps files without conflicts unchanged', () => {
assert.equal(prepared.file.name, 'syllabus');
assert.equal(prepared.noticeMessage, undefined);
});
test('prepareFolderUploadEntries keeps relative directories and renames conflicting root folders', () => {
const first = new File(['alpha'], 'a.txt', {type: 'text/plain'});
Object.defineProperty(first, 'webkitRelativePath', {
configurable: true,
value: '设计稿/a.txt',
});
const second = new File(['beta'], 'b.txt', {type: 'text/plain'});
Object.defineProperty(second, 'webkitRelativePath', {
configurable: true,
value: '设计稿/子目录/b.txt',
});
const entries = prepareFolderUploadEntries([first, second], ['文档'], ['设计稿']);
assert.equal(entries[0].pathParts.join('/'), '文档/设计稿 (1)');
assert.equal(entries[1].pathParts.join('/'), '文档/设计稿 (1)/子目录');
assert.equal(entries[0].noticeMessage, '检测到同名文件夹,已自动重命名为 设计稿 (1)');
assert.equal(entries[1].noticeMessage, '检测到同名文件夹,已自动重命名为 设计稿 (1)');
assert.equal(shouldUploadEntriesSequentially(entries), true);
});
test('shouldUploadEntriesSequentially keeps plain file uploads in parallel mode', () => {
const entries = [
{
file: new File(['alpha'], 'a.txt', {type: 'text/plain'}),
pathParts: ['文档'],
source: 'file' as const,
},
{
file: new File(['beta'], 'b.txt', {type: 'text/plain'}),
pathParts: ['文档'],
source: 'file' as const,
},
];
assert.equal(shouldUploadEntriesSequentially(entries), false);
});
test('createUploadTasks creates a stable task list for the whole batch', () => {
const entries = [
{
file: new File(['alpha'], 'a.txt', {type: 'text/plain'}),
pathParts: ['文档'],
source: 'file' as const,
noticeMessage: 'alpha',
},
{
file: new File(['beta'], 'b.txt', {type: 'text/plain'}),
pathParts: ['文档', '资料'],
source: 'folder' as const,
noticeMessage: 'beta',
},
];
const tasks = createUploadTasks(entries);
assert.equal(tasks.length, 2);
assert.equal(tasks[0].fileName, 'a.txt');
assert.equal(tasks[0].destination, '/文档');
assert.equal(tasks[0].noticeMessage, 'alpha');
assert.equal(tasks[1].fileName, 'b.txt');
assert.equal(tasks[1].destination, '/文档/资料');
assert.equal(tasks[1].noticeMessage, 'beta');
});
test('prepareUploadTaskForCompletion keeps a visible progress state before marking complete', () => {
const task = createUploadTask(new File(['alpha'], 'a.txt', {type: 'text/plain'}), ['文档'], 'task-3');
const nextTask = prepareUploadTaskForCompletion(task);
assert.equal(nextTask.status, 'uploading');
assert.equal(nextTask.progress, 99);
assert.equal(nextTask.speed, '即将完成...');
});

View File

@@ -1,3 +1,5 @@
import { getNextAvailableName } from './files-state';
export type UploadTaskStatus = 'uploading' | 'completed' | 'error';
export interface UploadTask {
@@ -18,6 +20,13 @@ export interface UploadMeasurement {
lastUpdatedAt: number;
}
export interface PendingUploadEntry {
file: File;
pathParts: string[];
source: 'file' | 'folder';
noticeMessage?: string;
}
function getUploadType(file: File) {
const extension = file.name.includes('.') ? file.name.split('.').pop()?.toLowerCase() : '';
@@ -56,6 +65,18 @@ function splitFileName(fileName: string) {
};
}
function getRelativePathSegments(file: File) {
const rawRelativePath = ('webkitRelativePath' in file && typeof file.webkitRelativePath === 'string' && file.webkitRelativePath)
? file.webkitRelativePath
: file.name;
return rawRelativePath
.replaceAll('\\', '/')
.split('/')
.map((segment) => segment.trim())
.filter(Boolean);
}
export function getUploadDestination(pathParts: string[]) {
return pathParts.length === 0 ? '/' : `/${pathParts.join('/')}`;
}
@@ -86,6 +107,67 @@ export function prepareUploadFile(file: File, usedNames: Set<string>) {
};
}
export function prepareFolderUploadEntries(
files: File[],
currentPathParts: string[],
existingRootNames: string[],
): PendingUploadEntry[] {
const rootReservedNames = new Set(existingRootNames);
const renamedRootFolders = new Map<string, string>();
const usedNamesByDestination = new Map<string, Set<string>>();
return files.map((file) => {
const relativeSegments = getRelativePathSegments(file);
if (relativeSegments.length === 0) {
return {
file,
pathParts: [...currentPathParts],
source: 'folder',
};
}
let noticeMessage: string | undefined;
if (relativeSegments.length > 1) {
const originalRootFolder = relativeSegments[0];
let renamedRootFolder = renamedRootFolders.get(originalRootFolder);
if (!renamedRootFolder) {
renamedRootFolder = getNextAvailableName(originalRootFolder, rootReservedNames);
rootReservedNames.add(renamedRootFolder);
renamedRootFolders.set(originalRootFolder, renamedRootFolder);
}
if (renamedRootFolder !== originalRootFolder) {
relativeSegments[0] = renamedRootFolder;
noticeMessage = `检测到同名文件夹,已自动重命名为 ${renamedRootFolder}`;
}
}
const pathParts = [...currentPathParts, ...relativeSegments.slice(0, -1)];
const destinationKey = getUploadDestination(pathParts);
const usedNames = usedNamesByDestination.get(destinationKey) ?? new Set<string>();
const preparedUpload = prepareUploadFile(
new File([file], relativeSegments.at(-1) ?? file.name, {
type: file.type,
lastModified: file.lastModified,
}),
usedNames,
);
usedNames.add(preparedUpload.file.name);
usedNamesByDestination.set(destinationKey, usedNames);
return {
file: preparedUpload.file,
pathParts,
source: 'folder',
noticeMessage: noticeMessage ?? preparedUpload.noticeMessage,
};
});
}
export function shouldUploadEntriesSequentially(entries: PendingUploadEntry[]) {
return entries.some((entry) => entry.source === 'folder');
}
export function createUploadTask(
file: File,
pathParts: string[],
@@ -104,6 +186,28 @@ export function createUploadTask(
};
}
export function createUploadTasks(entries: PendingUploadEntry[]) {
return entries.map((entry) =>
createUploadTask(entry.file, entry.pathParts, undefined, entry.noticeMessage),
);
}
export function createUploadMeasurement(startedAt: number): UploadMeasurement {
return {
startedAt,
lastLoaded: 0,
lastUpdatedAt: startedAt,
};
}
export function prepareUploadTaskForCompletion(task: UploadTask): UploadTask {
return {
...task,
progress: Math.max(task.progress, 99),
speed: task.speed && task.speed !== '等待上传...' ? task.speed : '即将完成...',
};
}
export function formatTransferSpeed(bytesPerSecond: number) {
if (bytesPerSecond < 1024) {
return `${Math.round(bytesPerSecond)} B/s`;