feat: redesign directory upload workflow

This commit is contained in:
2026-07-21 20:47:07 +08:00
parent 59cf2ea67c
commit dcc1917580
2 changed files with 422 additions and 100 deletions
+121
View File
@@ -0,0 +1,121 @@
export interface ScannedFile {
file: File;
relativePath: string;
}
export type ScanResult =
| { success: true; files: ScannedFile[] }
| { success: false; error: string };
export function normalizePath(rawPath: string): string | null {
if (!rawPath) return null;
const normalized = rawPath.replace(/\\/g, '/');
if (normalized.startsWith('/')) return null;
if (/^[a-zA-Z]:\//.test(normalized)) return null;
const parts = normalized.split('/');
if (parts.includes('') || parts.includes('.') || parts.includes('..')) return null;
if (parts.length < 2) return null; // Needs at least top dir and file name
parts.shift(); // Remove EXACTLY one top-level directory
const finalPath = parts.join('/');
if (!finalPath) return null;
return finalPath;
}
function readEntriesPromise(reader: FileSystemDirectoryReader): Promise<FileSystemEntry[]> {
return new Promise((resolve, reject) => {
reader.readEntries(resolve, reject);
});
}
async function readAllEntries(reader: FileSystemDirectoryReader): Promise<FileSystemEntry[]> {
const entries: FileSystemEntry[] = [];
while (true) {
const batch = await readEntriesPromise(reader);
if (batch.length === 0) break;
entries.push(...batch);
}
return entries;
}
async function getFilePromise(fileEntry: FileSystemFileEntry): Promise<File> {
return new Promise((resolve, reject) => {
fileEntry.file(resolve, reject);
});
}
async function scanDirectory(dirEntry: FileSystemDirectoryEntry, pathPrefix: string, scannedFiles: ScannedFile[]): Promise<void> {
const reader = dirEntry.createReader();
const entries = await readAllEntries(reader);
for (const entry of entries) {
if (entry.isDirectory) {
await scanDirectory(entry as FileSystemDirectoryEntry, `${pathPrefix}${entry.name}/`, scannedFiles);
} else if (entry.isFile) {
const fileEntry = entry as FileSystemFileEntry;
const file = await getFilePromise(fileEntry);
const relativePath = `${pathPrefix}${entry.name}`;
scannedFiles.push({ file, relativePath });
}
}
}
export async function scanDataTransfer(dataTransfer: DataTransfer): Promise<ScanResult> {
const items = dataTransfer.items;
if (!items || items.length === 0) {
return { success: false, error: '未检测到文件或目录' };
}
const entries: FileSystemEntry[] = [];
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.webkitGetAsEntry) {
const entry = item.webkitGetAsEntry();
if (entry) {
entries.push(entry);
}
}
}
if (entries.length === 0) {
return { success: false, error: '无法读取拖拽内容' };
}
// Reject multiple top-level directories or loose files
if (entries.length > 1) {
return { success: false, error: '请每次仅拖入或选择一个目录' };
}
const rootEntry = entries[0];
if (!rootEntry.isDirectory) {
return { success: false, error: '请拖入或选择一个目录,而不是单个文件' };
}
try {
const scannedFiles: ScannedFile[] = [];
// Start scanning with the top-level directory name in the path prefix
await scanDirectory(rootEntry as FileSystemDirectoryEntry, `${rootEntry.name}/`, scannedFiles);
// Filter out invalid paths if any
const validFiles: ScannedFile[] = [];
for (const item of scannedFiles) {
const norm = normalizePath(item.relativePath);
if (norm) {
validFiles.push({ file: item.file, relativePath: norm });
} else {
return { success: false, error: '目录包含无效的路径格式' };
}
}
if (validFiles.length === 0) {
return { success: false, error: '选中的目录为空或没有有效文件' };
}
return { success: true, files: validFiles };
} catch (err: unknown) {
return { success: false, error: '扫描目录时发生错误' };
}
}