fix(frontend): 修复文件树上传落点与根目录补全
修正外部拖拽上传的目标路径判定,按当前悬停目录上传, 目录拖拽继续走压缩后上传链路,避免文件落到错误位置 补齐新会话初始化时根目录与当前目录并发加载的竞态处理, 先完成 `/` 根树引导再继续目标目录加载,避免根节点缺少 同级目录且不影响当前工作目录状态
This commit is contained in:
@@ -13,7 +13,7 @@ import { useFileManagerContextMenu, type ClipboardState, type CompressFormat } f
|
||||
import { useFileManagerSelection } from '../composables/file-manager/useFileManagerSelection';
|
||||
import { useFileManagerDragAndDrop } from '../composables/file-manager/useFileManagerDragAndDrop';
|
||||
import { useFileManagerKeyboardNavigation } from '../composables/file-manager/useFileManagerKeyboardNavigation';
|
||||
import { createFolderArchive } from '../composables/file-manager/useFolderArchiveUpload';
|
||||
import { createFolderArchive, type FolderArchiveSource } from '../composables/file-manager/useFolderArchiveUpload';
|
||||
import FileUploadPopup from './FileUploadPopup.vue';
|
||||
import FileManagerContextMenu from './FileManagerContextMenu.vue';
|
||||
import FileManagerActionModal from './FileManagerActionModal.vue';
|
||||
@@ -153,6 +153,7 @@ const isFolderUploadBusy = ref(false);
|
||||
const showFavoritePathsModal = ref(false);
|
||||
const favoritePathsButtonRef = ref<HTMLButtonElement | null>(null); // Ref for the trigger button
|
||||
const explorerExpandedPaths = ref<Record<string, boolean>>({});
|
||||
const selectedExplorerPath = ref<string | null>(null);
|
||||
|
||||
// +++ Path History Refs +++
|
||||
const showPathHistoryDropdown = ref(false);
|
||||
@@ -284,6 +285,30 @@ const openFileInWorkspace = (filePath: string, filename: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
const getItemAbsolutePath = (item: FileListItem): string => {
|
||||
if (typeof item.longname === 'string' && item.longname.startsWith('/')) {
|
||||
return item.longname;
|
||||
}
|
||||
|
||||
return currentSftpManager.value?.joinPath(currentSftpManager.value.currentPath.value, item.filename) ?? item.filename;
|
||||
};
|
||||
|
||||
const getParentPath = (path: string): string => {
|
||||
if (!path || path === '/') {
|
||||
return '/';
|
||||
}
|
||||
|
||||
const normalized = path.endsWith('/') ? path.slice(0, -1) : path;
|
||||
const lastSlashIndex = normalized.lastIndexOf('/');
|
||||
return lastSlashIndex <= 0 ? '/' : normalized.slice(0, lastSlashIndex);
|
||||
};
|
||||
|
||||
const createContextMenuItemFromTreeRow = (row: ExplorerTreeRow): FileListItem => ({
|
||||
filename: row.name,
|
||||
longname: row.path,
|
||||
attrs: row.item.attrs,
|
||||
});
|
||||
|
||||
const explorerTreeRows = computed<ExplorerTreeRow[]>(() => {
|
||||
const rows: ExplorerTreeRow[] = [];
|
||||
const rootNode = findTreeNodeByPath('/');
|
||||
@@ -725,19 +750,36 @@ const handleModalConfirm = (value?: string) => {
|
||||
switch (currentActionType.value) {
|
||||
case 'delete':
|
||||
if (actionItems.value.length > 0) {
|
||||
manager.deleteItems(actionItems.value);
|
||||
actionItems.value.forEach((item) => {
|
||||
const targetPath = getItemAbsolutePath(item);
|
||||
props.wsDeps.sendMessage({
|
||||
type: item.attrs.isDirectory ? 'sftp:rmdir' : 'sftp:unlink',
|
||||
requestId: generateRequestId(),
|
||||
payload: { path: targetPath },
|
||||
});
|
||||
});
|
||||
selectedItems.value.clear(); // Clear selection after delete
|
||||
}
|
||||
break;
|
||||
case 'rename':
|
||||
if (actionItem.value && value && value !== actionItem.value.filename) {
|
||||
manager.renameItem(actionItem.value, value);
|
||||
const oldPath = getItemAbsolutePath(actionItem.value);
|
||||
const newPath = value.startsWith('/') ? value : `${getParentPath(oldPath)}/${value}`.replace(/\/+/g, '/');
|
||||
props.wsDeps.sendMessage({
|
||||
type: 'sftp:rename',
|
||||
requestId: generateRequestId(),
|
||||
payload: { oldPath, newPath },
|
||||
});
|
||||
}
|
||||
break;
|
||||
case 'chmod':
|
||||
if (actionItem.value && value && /^[0-7]{3,4}$/.test(value)) {
|
||||
const newMode = parseInt(value, 8);
|
||||
manager.changePermissions(actionItem.value, newMode);
|
||||
props.wsDeps.sendMessage({
|
||||
type: 'sftp:chmod',
|
||||
requestId: generateRequestId(),
|
||||
payload: { path: getItemAbsolutePath(actionItem.value), mode: newMode },
|
||||
});
|
||||
} else if (value) { // value exists but is invalid
|
||||
// Optionally, re-open modal with error or use a notification
|
||||
// For now, just log and close
|
||||
@@ -774,10 +816,14 @@ const handleDeleteSelectedClick = () => {
|
||||
// 修改:检查 currentSftpManager 是否存在
|
||||
if (!currentSftpManager.value) return;
|
||||
// 使用 props.wsDeps 和 currentSftpManager.value.fileList
|
||||
if (!props.wsDeps.isConnected.value || selectedItems.value.size === 0) return;
|
||||
const itemsToDelete = Array.from(selectedItems.value)
|
||||
if (!props.wsDeps.isConnected.value) return;
|
||||
const selectedListItems = Array.from(selectedItems.value)
|
||||
.map(filename => currentSftpManager.value?.fileList.value.find((f: FileListItem) => f.filename === filename))
|
||||
.filter((item): item is FileListItem => item !== undefined);
|
||||
const itemsToDelete =
|
||||
selectedListItems.length > 0
|
||||
? selectedListItems
|
||||
: (contextTargetItem.value ? [contextTargetItem.value] : []);
|
||||
if (itemsToDelete.length === 0) return;
|
||||
|
||||
// 根据设置决定是否显示确认模态框
|
||||
@@ -786,7 +832,14 @@ const handleDeleteSelectedClick = () => {
|
||||
} else {
|
||||
// 直接执行删除
|
||||
if (currentSftpManager.value) {
|
||||
currentSftpManager.value.deleteItems(itemsToDelete);
|
||||
itemsToDelete.forEach((item) => {
|
||||
const targetPath = getItemAbsolutePath(item);
|
||||
props.wsDeps.sendMessage({
|
||||
type: item.attrs.isDirectory ? 'sftp:rmdir' : 'sftp:unlink',
|
||||
requestId: generateRequestId(),
|
||||
payload: { path: targetPath },
|
||||
});
|
||||
});
|
||||
selectedItems.value.clear(); // Clear selection after delete
|
||||
}
|
||||
}
|
||||
@@ -819,23 +872,33 @@ const handleNewFileContextMenuClick = () => {
|
||||
|
||||
// +++ 复制、剪切、粘贴处理函数 +++
|
||||
const handleCopy = () => {
|
||||
if (!currentSftpManager.value || selectedItems.value.size === 0) return;
|
||||
const manager = currentSftpManager.value;
|
||||
clipboardSourcePaths.value = Array.from(selectedItems.value)
|
||||
.map(filename => manager.joinPath(manager.currentPath.value, filename));
|
||||
if (!currentSftpManager.value) return;
|
||||
const selectedFileItems =
|
||||
selectedItems.value.size > 0
|
||||
? Array.from(selectedItems.value)
|
||||
.map(filename => currentSftpManager.value?.fileList.value.find((f: FileListItem) => f.filename === filename))
|
||||
.filter((item): item is FileListItem => item !== undefined)
|
||||
: (contextTargetItem.value ? [contextTargetItem.value] : []);
|
||||
if (selectedFileItems.length === 0) return;
|
||||
clipboardSourcePaths.value = selectedFileItems.map((item) => getItemAbsolutePath(item));
|
||||
clipboardState.value = { hasContent: true, operation: 'copy' };
|
||||
clipboardSourceBaseDir.value = manager.currentPath.value; // 记录源目录
|
||||
clipboardSourceBaseDir.value = getParentPath(clipboardSourcePaths.value[0] || currentSftpManager.value.currentPath.value); // 记录源目录
|
||||
console.log(`[FileManager ${props.sessionId}-${props.instanceId}] Copied to clipboard:`, clipboardSourcePaths.value);
|
||||
// 可选:添加 UI 通知
|
||||
};
|
||||
|
||||
const handleCut = () => {
|
||||
if (!currentSftpManager.value || selectedItems.value.size === 0) return;
|
||||
const manager = currentSftpManager.value;
|
||||
clipboardSourcePaths.value = Array.from(selectedItems.value)
|
||||
.map(filename => manager.joinPath(manager.currentPath.value, filename));
|
||||
if (!currentSftpManager.value) return;
|
||||
const selectedFileItems =
|
||||
selectedItems.value.size > 0
|
||||
? Array.from(selectedItems.value)
|
||||
.map(filename => currentSftpManager.value?.fileList.value.find((f: FileListItem) => f.filename === filename))
|
||||
.filter((item): item is FileListItem => item !== undefined)
|
||||
: (contextTargetItem.value ? [contextTargetItem.value] : []);
|
||||
if (selectedFileItems.length === 0) return;
|
||||
clipboardSourcePaths.value = selectedFileItems.map((item) => getItemAbsolutePath(item));
|
||||
clipboardState.value = { hasContent: true, operation: 'cut' };
|
||||
clipboardSourceBaseDir.value = manager.currentPath.value; // 记录源目录
|
||||
clipboardSourceBaseDir.value = getParentPath(clipboardSourcePaths.value[0] || currentSftpManager.value.currentPath.value); // 记录源目录
|
||||
console.log(`[FileManager ${props.sessionId}-${props.instanceId}] Cut to clipboard:`, clipboardSourcePaths.value);
|
||||
// 可选:添加 UI 通知
|
||||
};
|
||||
@@ -882,20 +945,18 @@ const triggerFolderUpload = () => {
|
||||
folderInputRef.value?.click();
|
||||
};
|
||||
|
||||
const getFolderUploadName = (files: File[]) => {
|
||||
const firstRelativePath = files[0]?.webkitRelativePath || files[0]?.name || 'folder';
|
||||
const getFolderUploadName = (files: Array<File | FolderArchiveSource>) => {
|
||||
const firstItem = files[0];
|
||||
const firstRelativePath = firstItem instanceof File
|
||||
? firstItem.webkitRelativePath || firstItem.name || 'folder'
|
||||
: firstItem?.relativePath || 'folder';
|
||||
return firstRelativePath.split('/').filter(Boolean)[0] || 'folder';
|
||||
};
|
||||
|
||||
const handleFolderSelected = async (event: Event) => {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const files = input.files ? Array.from(input.files) : [];
|
||||
input.value = '';
|
||||
|
||||
if (files.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const startFolderArchiveUpload = async (
|
||||
files: File[] | FolderArchiveSource[],
|
||||
targetPath?: string,
|
||||
) => {
|
||||
if (!currentSftpManager.value || !props.wsDeps.isConnected.value) {
|
||||
uiNotificationsStore.showError(t('fileManager.errors.sftpNotReady'));
|
||||
return;
|
||||
@@ -929,6 +990,7 @@ const handleFolderSelected = async (event: Event) => {
|
||||
displayName: folderName,
|
||||
mode: 'folder-archive',
|
||||
detail: t('fileManager.notifications.folderArchiveUploading', { count: entryCount }),
|
||||
targetPath,
|
||||
afterUpload: async ({ remotePath }) => {
|
||||
if (!currentSftpManager.value) {
|
||||
throw new Error(t('fileManager.errors.sftpManagerNotFound'));
|
||||
@@ -960,6 +1022,18 @@ const handleFolderSelected = async (event: Event) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleFolderSelected = async (event: Event) => {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const files = input.files ? Array.from(input.files) : [];
|
||||
input.value = '';
|
||||
|
||||
if (files.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await startFolderArchiveUpload(files);
|
||||
};
|
||||
|
||||
// --- 下载触发器 (定义在此处,供 Composable 使用) ---
|
||||
const triggerDownload = (items: FileListItem[]) => { // 修改:接受 FileListItem 数组
|
||||
// 恢复使用 props.wsDeps.isConnected
|
||||
@@ -986,7 +1060,7 @@ const triggerDownload = (items: FileListItem[]) => { // 修改:接受 FileList
|
||||
return;
|
||||
}
|
||||
|
||||
const downloadPath = currentSftpManager.value!.joinPath(currentSftpManager.value!.currentPath.value, item.filename);
|
||||
const downloadPath = getItemAbsolutePath(item);
|
||||
const downloadUrl = `/api/v1/sftp/download?connectionId=${currentConnectionId}&remotePath=${encodeURIComponent(downloadPath)}`;
|
||||
console.log(`[FileManager ${props.sessionId}-${props.instanceId}] Triggering download for ${item.filename}: ${downloadUrl}`);
|
||||
|
||||
@@ -1029,7 +1103,7 @@ const triggerDownloadDirectory = (item: FileListItem) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const directoryPath = currentSftpManager.value.joinPath(currentSftpManager.value.currentPath.value, item.filename);
|
||||
const directoryPath = getItemAbsolutePath(item);
|
||||
// 定义新的后端 API 端点 URL (稍后实现)
|
||||
const downloadUrl = `/api/v1/sftp/download-directory?connectionId=${currentConnectionId}&remotePath=${encodeURIComponent(directoryPath)}`;
|
||||
|
||||
@@ -1115,7 +1189,7 @@ const handleDecompress = (item: FileListItem) => {
|
||||
// +++ 复制路径到剪贴板 +++
|
||||
const handleCopyPath = async (item: FileListItem) => {
|
||||
if (!currentSftpManager.value) return;
|
||||
const fullPath = currentSftpManager.value.joinPath(currentSftpManager.value.currentPath.value, item.filename);
|
||||
const fullPath = getItemAbsolutePath(item);
|
||||
try {
|
||||
await navigator.clipboard.writeText(fullPath);
|
||||
// 可选:显示成功通知
|
||||
@@ -1149,10 +1223,10 @@ const getTargetPathForItem = (item?: FileListItem | null): string | null => {
|
||||
}
|
||||
|
||||
if (item.attrs.isDirectory) {
|
||||
return currentSftpManager.value.joinPath(currentPath, item.filename);
|
||||
return getItemAbsolutePath(item);
|
||||
}
|
||||
|
||||
return currentPath;
|
||||
return getParentPath(getItemAbsolutePath(item));
|
||||
};
|
||||
|
||||
const sendCdCommandToPath = (targetPath: string, sessionId?: string) => {
|
||||
@@ -1267,13 +1341,13 @@ const {
|
||||
// isDraggingOver, // 不再直接使用容器的悬停状态
|
||||
showExternalDropOverlay, // 控制蒙版显示
|
||||
dragOverTarget, // 行拖拽悬停目标 (内部)
|
||||
externalDropTargetPath,
|
||||
// draggedItem, // 内部状态,不需要在 FileManager 中直接使用
|
||||
// --- 事件处理器 ---
|
||||
handleDragEnter,
|
||||
handleDragOver, // 容器的 dragover (主要处理内部滚动)
|
||||
handleDragLeave,
|
||||
handleDrop, // 容器的 drop (主要用于清理)
|
||||
handleOverlayDrop, // 蒙版的 drop
|
||||
handleDragStart,
|
||||
handleDragEnd,
|
||||
handleDragOverRow,
|
||||
@@ -1288,7 +1362,8 @@ const {
|
||||
joinPath: (base: string, target: string): string => {
|
||||
return currentSftpManager.value?.joinPath(base, target) ?? `${base}/${target}`.replace(/\/+/g, '/'); // 提供简单的默认实现
|
||||
},
|
||||
onFileUpload: startFileUpload,
|
||||
onFileUpload: (file, relativePath, targetPath) => startFileUpload(file, relativePath, { targetPath }),
|
||||
onFolderUpload: startFolderArchiveUpload,
|
||||
// 修改:确保在调用前检查 currentSftpManager.value
|
||||
onItemMove: (item, newName) => {
|
||||
currentSftpManager.value?.renameItem(item, newName);
|
||||
@@ -1984,6 +2059,10 @@ const handleExplorerToggle = (row: ExplorerTreeRow) => {
|
||||
toggleDirectoryPath(row.path, row.expanded);
|
||||
};
|
||||
|
||||
const handleExplorerSelect = (row: ExplorerTreeRow) => {
|
||||
selectedExplorerPath.value = row.path;
|
||||
};
|
||||
|
||||
const handleExplorerOpen = (row: ExplorerTreeRow) => {
|
||||
if (row.isDirectory) {
|
||||
focusDirectoryPath(row.path);
|
||||
@@ -1993,8 +2072,15 @@ const handleExplorerOpen = (row: ExplorerTreeRow) => {
|
||||
openFileInWorkspace(row.path, row.name);
|
||||
};
|
||||
|
||||
const handleExplorerContextMenu = (event: MouseEvent, row: ExplorerTreeRow) => {
|
||||
selectedExplorerPath.value = row.path;
|
||||
selectedItems.value.clear();
|
||||
lastClickedIndex.value = -1;
|
||||
showContextMenu(event, createContextMenuItemFromTreeRow(row));
|
||||
};
|
||||
|
||||
const isExplorerRowActive = (row: ExplorerTreeRow) => {
|
||||
return isPathActive(row.path);
|
||||
return selectedExplorerPath.value === row.path;
|
||||
};
|
||||
|
||||
const isExplorerRowRelated = (row: ExplorerTreeRow) => {
|
||||
@@ -2250,20 +2336,22 @@ watch(
|
||||
<div
|
||||
v-if="showExternalDropOverlay"
|
||||
ref="dropOverlayRef"
|
||||
class="absolute inset-0 flex items-center justify-center bg-black/70 text-white text-xl font-semibold rounded z-50 pointer-events-auto"
|
||||
@dragover.prevent
|
||||
@dragleave.prevent="handleDragLeave"
|
||||
@drop.prevent="handleOverlayDrop"
|
||||
class="absolute inset-0 flex items-center justify-center bg-black/70 text-white text-xl font-semibold rounded z-50 pointer-events-none"
|
||||
>
|
||||
{{ t('fileManager.dropFilesHere', 'Drop files here to upload') }}
|
||||
</div>
|
||||
|
||||
<div class="p-2 space-y-1" :class="{ 'pointer-events-none': showExternalDropOverlay }">
|
||||
<div class="p-2 space-y-1">
|
||||
<div
|
||||
v-for="row in explorerTreeRows"
|
||||
:key="row.id"
|
||||
:data-drop-path="row.path"
|
||||
:data-is-directory="row.isDirectory"
|
||||
:class="[
|
||||
'group flex items-center gap-2 rounded-lg border px-2 py-1.5 transition-colors cursor-pointer',
|
||||
showExternalDropOverlay && externalDropTargetPath === row.path
|
||||
? 'border-primary bg-primary/15 text-foreground'
|
||||
: '',
|
||||
isExplorerRowActive(row)
|
||||
? 'bg-primary text-white border-primary shadow-sm'
|
||||
: isExplorerRowRelated(row)
|
||||
@@ -2271,7 +2359,9 @@ watch(
|
||||
: 'border-transparent text-text-secondary hover:bg-background hover:text-foreground'
|
||||
]"
|
||||
:style="{ paddingLeft: `${0.6 + row.depth * 0.85}rem` }"
|
||||
@click="handleExplorerOpen(row)"
|
||||
@click="handleExplorerSelect(row)"
|
||||
@dblclick="handleExplorerOpen(row)"
|
||||
@contextmenu.prevent.stop="handleExplorerContextMenu($event, row)"
|
||||
>
|
||||
<button
|
||||
v-if="row.isDirectory"
|
||||
|
||||
@@ -106,14 +106,17 @@ export function useFileManagerContextMenu(options: UseFileManagerContextMenuOpti
|
||||
const showContextMenu = (event: MouseEvent, item?: FileListItem) => {
|
||||
event.preventDefault();
|
||||
const targetItem = item || null;
|
||||
const targetItemIndex = targetItem
|
||||
? fileList.value.findIndex((f: FileListItem) => f.filename === targetItem.filename)
|
||||
: -1;
|
||||
const isExternalTreeItem = Boolean(targetItem && targetItemIndex === -1 && typeof targetItem.longname === 'string' && targetItem.longname.startsWith('/'));
|
||||
|
||||
// Adjust selection based on right-click target (逻辑保持不变)
|
||||
if (targetItem && !event.ctrlKey && !event.metaKey && !event.shiftKey && !selectedItems.value.has(targetItem.filename)) {
|
||||
if (targetItem && !isExternalTreeItem && !event.ctrlKey && !event.metaKey && !event.shiftKey && !selectedItems.value.has(targetItem.filename)) {
|
||||
selectedItems.value.clear();
|
||||
selectedItems.value.add(targetItem.filename);
|
||||
// 使用传入的 fileList ref
|
||||
const index = fileList.value.findIndex((f: FileListItem) => f.filename === targetItem.filename); // 添加类型
|
||||
lastClickedIndex.value = index;
|
||||
lastClickedIndex.value = targetItemIndex;
|
||||
} else if (!targetItem) {
|
||||
selectedItems.value.clear();
|
||||
lastClickedIndex.value = -1;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ref, type Ref } from 'vue';
|
||||
import type { FileListItem } from '../../types/sftp.types'; // 确保路径正确
|
||||
import type { FolderArchiveSource } from './useFolderArchiveUpload';
|
||||
|
||||
// 定义 Composable 的输入参数类型
|
||||
export interface UseFileManagerDragAndDropOptions {
|
||||
@@ -12,7 +13,8 @@ export interface UseFileManagerDragAndDropOptions {
|
||||
|
||||
// 函数依赖
|
||||
joinPath: (base: string, target: string) => string; // 路径拼接函数
|
||||
onFileUpload: (file: File, relativePath?: string) => void; // 修改:触发文件上传的回调,增加相对路径
|
||||
onFileUpload: (file: File, relativePath?: string, targetPath?: string) => void; // 修改:触发文件上传的回调,增加相对路径
|
||||
onFolderUpload: (files: FolderArchiveSource[], targetPath?: string) => void | Promise<void>;
|
||||
onItemMove: (sourceItem: FileListItem, newFullPath: string) => void; // 触发文件/文件夹移动的回调
|
||||
}
|
||||
|
||||
@@ -23,6 +25,7 @@ export function useFileManagerDragAndDrop(options: UseFileManagerDragAndDropOpti
|
||||
fileListContainerRef,
|
||||
joinPath,
|
||||
onFileUpload,
|
||||
onFolderUpload,
|
||||
onItemMove,
|
||||
selectedItems, // 获取传入的 selectedItems
|
||||
fileList, // 获取传入的 fileList
|
||||
@@ -33,6 +36,7 @@ export function useFileManagerDragAndDrop(options: UseFileManagerDragAndDropOpti
|
||||
const showExternalDropOverlay = ref(false); // 控制外部文件拖拽蒙版的显示
|
||||
const draggedItem = ref<FileListItem | null>(null); // 内部拖拽时,被拖拽的项
|
||||
const dragOverTarget = ref<string | null>(null); // 内部拖拽时,悬停的目标文件夹名称 (用于行高亮)
|
||||
const externalDropTargetPath = ref<string | null>(null);
|
||||
const scrollIntervalId = ref<number | null>(null); // 自动滚动计时器 ID
|
||||
|
||||
// --- 自动滚动常量 ---
|
||||
@@ -47,6 +51,56 @@ export function useFileManagerDragAndDrop(options: UseFileManagerDragAndDropOpti
|
||||
}
|
||||
};
|
||||
|
||||
const resolveExternalDropTargetPath = (event: DragEvent): string => {
|
||||
const hoveredElement = document.elementFromPoint(event.clientX, event.clientY) as HTMLElement | null;
|
||||
const dropTargetElement = hoveredElement?.closest('[data-drop-path]') as HTMLElement | null;
|
||||
const targetPath = dropTargetElement?.dataset.dropPath;
|
||||
const isDirectory = dropTargetElement?.dataset.isDirectory === 'true';
|
||||
|
||||
if (targetPath && isDirectory) {
|
||||
return targetPath;
|
||||
}
|
||||
|
||||
return currentPath.value;
|
||||
};
|
||||
|
||||
const readAllDirectoryEntries = async (reader: FileSystemDirectoryReader): Promise<FileSystemEntry[]> => {
|
||||
const allEntries: FileSystemEntry[] = [];
|
||||
|
||||
while (true) {
|
||||
const entries = await new Promise<FileSystemEntry[]>((resolve, reject) => {
|
||||
reader.readEntries(resolve, reject);
|
||||
});
|
||||
|
||||
if (entries.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
allEntries.push(...entries);
|
||||
}
|
||||
|
||||
return allEntries;
|
||||
};
|
||||
|
||||
const collectDroppedEntry = async (entry: FileSystemEntry, path = ''): Promise<FolderArchiveSource[]> => {
|
||||
if (entry.isFile) {
|
||||
const file = await new Promise<File>((resolve, reject) => {
|
||||
(entry as FileSystemFileEntry).file(resolve, reject);
|
||||
});
|
||||
|
||||
return [{
|
||||
file,
|
||||
relativePath: `${path}${file.name}`,
|
||||
}];
|
||||
}
|
||||
|
||||
const directoryEntry = entry as FileSystemDirectoryEntry;
|
||||
const childEntries = await readAllDirectoryEntries(directoryEntry.createReader());
|
||||
const childBasePath = `${path}${directoryEntry.name}/`;
|
||||
const nestedFiles = await Promise.all(childEntries.map((childEntry) => collectDroppedEntry(childEntry, childBasePath)));
|
||||
return nestedFiles.flat();
|
||||
};
|
||||
|
||||
// --- 事件处理函数 ---
|
||||
const handleDragEnter = (event: DragEvent) => {
|
||||
// 检查是否是外部文件拖拽
|
||||
@@ -72,6 +126,7 @@ export function useFileManagerDragAndDrop(options: UseFileManagerDragAndDropOpti
|
||||
showExternalDropOverlay.value = true; // 确保蒙版显示
|
||||
event.preventDefault(); // 必须阻止默认行为以允许 drop
|
||||
if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy'; // 指示效果
|
||||
externalDropTargetPath.value = resolveExternalDropTargetPath(event);
|
||||
// 外部拖拽时,不处理容器的自动滚动,因为鼠标在蒙版上
|
||||
stopAutoScroll();
|
||||
return; // 外部拖拽不由容器 handleDragOver 处理滚动
|
||||
@@ -146,6 +201,7 @@ export function useFileManagerDragAndDrop(options: UseFileManagerDragAndDropOpti
|
||||
if (showExternalDropOverlay.value) {
|
||||
// console.log("[DragDrop] Hiding external drop overlay due to leaving container.");
|
||||
showExternalDropOverlay.value = false; // 隐藏蒙版
|
||||
externalDropTargetPath.value = null;
|
||||
}
|
||||
// isDraggingOver.value = false; // 不再使用
|
||||
dragOverTarget.value = null; // 清除行高亮
|
||||
@@ -163,72 +219,67 @@ export function useFileManagerDragAndDrop(options: UseFileManagerDragAndDropOpti
|
||||
}
|
||||
};
|
||||
|
||||
// --- 递归遍历文件树的辅助函数 ---
|
||||
const traverseFileTree = (item: FileSystemEntry, path = '') => {
|
||||
path = path || '';
|
||||
if (item.isFile) {
|
||||
// 文件处理
|
||||
(item as FileSystemFileEntry).file((file) => {
|
||||
// 调用上传函数,传递文件和相对路径
|
||||
console.log(`[DragDrop] Uploading file: ${path}${file.name}`);
|
||||
onFileUpload(file, path); // 传递相对路径
|
||||
}, (err) => {
|
||||
console.error(`[DragDrop] Error getting file from entry: ${path}${item.name}`, err);
|
||||
});
|
||||
} else if (item.isDirectory) {
|
||||
// 目录处理
|
||||
const dirReader = (item as FileSystemDirectoryEntry).createReader();
|
||||
dirReader.readEntries((entries) => {
|
||||
console.log(`[DragDrop] Traversing directory: ${path}${item.name}, found ${entries.length} entries.`);
|
||||
// 递归遍历目录中的每个条目
|
||||
entries.forEach((entry) => {
|
||||
traverseFileTree(entry, path + item.name + '/'); // 更新相对路径
|
||||
});
|
||||
}, (err) => {
|
||||
console.error(`[DragDrop] Error reading directory entries: ${path}${item.name}`, err);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// 处理蒙版上的 Drop 事件
|
||||
const handleOverlayDrop = (event: DragEvent) => {
|
||||
event.preventDefault(); // 必须阻止,以防浏览器打开文件
|
||||
showExternalDropOverlay.value = false; // 隐藏蒙版
|
||||
stopAutoScroll(); // 停止滚动
|
||||
|
||||
const items = event.dataTransfer?.items;
|
||||
if (!items || items.length === 0 || !isConnected.value) {
|
||||
console.log("[DragDrop] Overlay drop ignored: No items or not connected.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[DragDrop] Processing ${items.length} items from overlay drop.`);
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
if (item.kind === 'file') {
|
||||
const entry = item.webkitGetAsEntry();
|
||||
if (entry) {
|
||||
traverseFileTree(entry); // 处理文件/文件夹
|
||||
} else {
|
||||
console.warn(`[DragDrop] Could not get entry for item ${i} from overlay.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 原有的 handleDrop (容器的 drop) 现在基本不需要了,
|
||||
// 因为外部 drop 由蒙版处理,内部 drop 由行处理并阻止冒泡。
|
||||
// 保留一个空的或只做清理的函数以防万一。
|
||||
const handleDrop = (event: DragEvent) => {
|
||||
// console.log("[DragDrop] Container drop event triggered (should be rare).");
|
||||
// 清理所有状态以防异常情况
|
||||
showExternalDropOverlay.value = false;
|
||||
draggedItem.value = null; // 清理内部拖拽状态
|
||||
dragOverTarget.value = null; // 清理行高亮
|
||||
stopAutoScroll(); // 停止滚动
|
||||
// 阻止默认行为以防万一
|
||||
const handleDrop = async (event: DragEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
const isExternalFileDrop = (event.dataTransfer?.types.includes('Files') ?? false) && !draggedItem.value;
|
||||
const targetPath = externalDropTargetPath.value || currentPath.value;
|
||||
|
||||
showExternalDropOverlay.value = false;
|
||||
draggedItem.value = null;
|
||||
dragOverTarget.value = null;
|
||||
externalDropTargetPath.value = null;
|
||||
stopAutoScroll();
|
||||
|
||||
if (!isExternalFileDrop || !isConnected.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const items = event.dataTransfer?.items;
|
||||
if (!items || items.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[DragDrop] Processing ${items.length} dropped items for target path ${targetPath}.`);
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
if (item.kind !== 'file') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const entry = item.webkitGetAsEntry?.();
|
||||
if (!entry) {
|
||||
const file = item.getAsFile();
|
||||
if (file) {
|
||||
onFileUpload(file, undefined, targetPath);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.isDirectory) {
|
||||
try {
|
||||
const files = await collectDroppedEntry(entry);
|
||||
if (files.length > 0) {
|
||||
await onFolderUpload(files, targetPath);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[DragDrop] Failed to collect dropped directory ${entry.name}:`, error);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const [fileSource] = await collectDroppedEntry(entry);
|
||||
if (fileSource) {
|
||||
onFileUpload(fileSource.file, undefined, targetPath);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[DragDrop] Failed to process dropped file ${entry.name}:`, error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragStart = (item: FileListItem) => {
|
||||
@@ -367,17 +418,17 @@ export function useFileManagerDragAndDrop(options: UseFileManagerDragAndDropOpti
|
||||
return {
|
||||
showExternalDropOverlay,
|
||||
dragOverTarget,
|
||||
externalDropTargetPath,
|
||||
draggedItem, // 需要暴露以供 handleDragOverRow 等函数内部判断
|
||||
// --- 事件处理器 ---
|
||||
handleDragEnter,
|
||||
handleDragOver,
|
||||
handleDragLeave,
|
||||
handleDrop, // 容器的 drop (主要用于清理)
|
||||
handleOverlayDrop,
|
||||
handleDrop, // 容器同时处理外部上传与内部拖拽清理
|
||||
handleDragStart,
|
||||
handleDragEnd,
|
||||
handleDragOverRow,
|
||||
handleDragLeaveRow,
|
||||
handleDropOnRow,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import JSZip from 'jszip';
|
||||
|
||||
export interface FolderArchiveSource {
|
||||
file: File;
|
||||
relativePath: string;
|
||||
}
|
||||
|
||||
export interface FolderArchiveResult {
|
||||
archiveFile: File;
|
||||
folderName: string;
|
||||
@@ -23,22 +28,33 @@ const getFolderNameFromRelativePath = (relativePath: string): string => {
|
||||
};
|
||||
|
||||
export const createFolderArchive = async (
|
||||
selectedFiles: File[] | FileList,
|
||||
selectedFiles: File[] | FileList | FolderArchiveSource[],
|
||||
onProgress?: (percent: number) => void,
|
||||
): Promise<FolderArchiveResult> => {
|
||||
const files = Array.from(selectedFiles).filter((file) => {
|
||||
return Boolean(file.webkitRelativePath && file.webkitRelativePath.includes('/'));
|
||||
});
|
||||
const entries: Array<File | FolderArchiveSource> = Array.isArray(selectedFiles)
|
||||
? selectedFiles
|
||||
: Array.from(selectedFiles);
|
||||
|
||||
const files = entries
|
||||
.map<FolderArchiveSource | null>((entry) => {
|
||||
if (entry instanceof File) {
|
||||
const relativePath = entry.webkitRelativePath?.replace(/\\/g, '/');
|
||||
return relativePath && relativePath.includes('/') ? { file: entry, relativePath } : null;
|
||||
}
|
||||
|
||||
const relativePath = entry.relativePath.replace(/\\/g, '/');
|
||||
return relativePath && relativePath.includes('/') ? { file: entry.file, relativePath } : null;
|
||||
})
|
||||
.filter((entry): entry is FolderArchiveSource => entry !== null);
|
||||
|
||||
if (files.length === 0) {
|
||||
throw new Error('No folder files were selected.');
|
||||
}
|
||||
|
||||
const folderName = getFolderNameFromRelativePath(files[0].webkitRelativePath);
|
||||
const folderName = getFolderNameFromRelativePath(files[0].relativePath);
|
||||
const zip = new JSZip();
|
||||
|
||||
files.forEach((file) => {
|
||||
const relativePath = file.webkitRelativePath.replace(/\\/g, '/');
|
||||
files.forEach(({ file, relativePath }) => {
|
||||
zip.file(relativePath, file);
|
||||
});
|
||||
|
||||
|
||||
@@ -93,15 +93,16 @@ export function useFileUploader(
|
||||
Object.assign(upload, patch);
|
||||
};
|
||||
|
||||
const buildRemotePath = (file: File, relativePath?: string) => {
|
||||
const buildRemotePath = (file: File, relativePath?: string, targetPath?: string) => {
|
||||
const uploadBasePath = targetPath || currentPathRef.value;
|
||||
let finalRemotePath: string;
|
||||
if (relativePath) {
|
||||
const basePath = currentPathRef.value.endsWith('/') ? currentPathRef.value : `${currentPathRef.value}/`;
|
||||
const basePath = uploadBasePath.endsWith('/') ? uploadBasePath : `${uploadBasePath}/`;
|
||||
let cleanRelativePath = relativePath.startsWith('/') ? relativePath.substring(1) : relativePath;
|
||||
cleanRelativePath = cleanRelativePath.endsWith('/') ? cleanRelativePath.slice(0, -1) : cleanRelativePath;
|
||||
finalRemotePath = `${basePath}${cleanRelativePath ? `${cleanRelativePath}/` : ''}${file.name}`;
|
||||
} else {
|
||||
finalRemotePath = joinPath(currentPathRef.value, file.name);
|
||||
finalRemotePath = joinPath(uploadBasePath, file.name);
|
||||
}
|
||||
|
||||
return finalRemotePath.replace(/\/+/g, '/');
|
||||
@@ -199,6 +200,7 @@ export function useFileUploader(
|
||||
displayName?: string;
|
||||
mode?: UploadTaskMode;
|
||||
detail?: string;
|
||||
targetPath?: string;
|
||||
afterUpload?: (context: { uploadId: string; remotePath: string; item: UploadItem; }) => Promise<void>;
|
||||
}
|
||||
) => {
|
||||
@@ -215,8 +217,8 @@ export function useFileUploader(
|
||||
}
|
||||
|
||||
const uploadId = options?.uploadId ?? generateUploadId();
|
||||
const finalRemotePath = buildRemotePath(file, relativePath);
|
||||
console.log(`[FileUploader ${sessionIdForLog.value}] Calculated finalRemotePath: ${finalRemotePath} (current: ${currentPathRef.value}, relative: ${relativePath}, filename: ${file.name}) // wsDeps.isSftpReady: ${wsDeps.value.isSftpReady.value}`);
|
||||
const finalRemotePath = buildRemotePath(file, relativePath, options?.targetPath);
|
||||
console.log(`[FileUploader ${sessionIdForLog.value}] Calculated finalRemotePath: ${finalRemotePath} (current: ${currentPathRef.value}, relative: ${relativePath}, target: ${options?.targetPath}, filename: ${file.name}) // wsDeps.isSftpReady: ${wsDeps.value.isSftpReady.value}`);
|
||||
|
||||
uploads[uploadId] = {
|
||||
id: uploadId,
|
||||
|
||||
@@ -112,6 +112,8 @@ export function createSftpActionsManager(
|
||||
// const fileList = ref<FileListItem[]>([]); // 不再直接使用 fileList ref
|
||||
const isLoading = ref<boolean>(false);
|
||||
const loadingRequestId = ref<string | null>(null); // 跟踪当前加载请求 ID
|
||||
const loadingPath = ref<string | null>(null);
|
||||
const pendingPathAfterRootBootstrap = ref<string | null>(null);
|
||||
// const error = ref<string | null>(null); // 不再使用本地 error ref
|
||||
const instanceSessionId = sessionId; // 保存会话 ID 用于日志
|
||||
const uiNotificationsStore = useUiNotificationsStore(); // 初始化 UI 通知 store
|
||||
@@ -242,6 +244,24 @@ export function createSftpActionsManager(
|
||||
};
|
||||
|
||||
const loadDirectory = (path: string, forceRefresh: boolean = false) => { // 添加 forceRefresh 参数
|
||||
const needsRootBootstrap = path !== '/' && !fileTree.childrenLoaded;
|
||||
if (needsRootBootstrap) {
|
||||
pendingPathAfterRootBootstrap.value = path;
|
||||
|
||||
if (isLoading.value) {
|
||||
if (loadingPath.value === '/') {
|
||||
console.log(`[SFTP ${instanceSessionId}] Root bootstrap already in progress. Queued target path: ${path}`);
|
||||
return;
|
||||
}
|
||||
console.warn(`[SFTP ${instanceSessionId}] Tried to queue target path ${path} while loading ${loadingPath.value}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[SFTP ${instanceSessionId}] Bootstrapping root tree before loading target path: ${path}`);
|
||||
path = '/';
|
||||
forceRefresh = false;
|
||||
}
|
||||
|
||||
// *** 修改:检查文件树 ***
|
||||
const targetNode = findNodeByPath(fileTree, path);
|
||||
|
||||
@@ -285,6 +305,7 @@ export function createSftpActionsManager(
|
||||
// currentPathRef.value = path; // <-- 移除此行,延迟更新
|
||||
const requestId = generateRequestId();
|
||||
loadingRequestId.value = requestId; // 记录当前加载请求 ID
|
||||
loadingPath.value = path;
|
||||
sendMessage({ type: 'sftp:readdir', requestId: requestId, payload: { path } });
|
||||
};
|
||||
|
||||
@@ -700,8 +721,10 @@ export function createSftpActionsManager(
|
||||
return;
|
||||
}
|
||||
|
||||
const isLatestRequest = message.requestId === loadingRequestId.value;
|
||||
|
||||
// 检查请求 ID 是否匹配当前加载请求
|
||||
if (message.requestId !== loadingRequestId.value) {
|
||||
if (!isLatestRequest) {
|
||||
console.log(`[SFTP ${instanceSessionId}] Received stale readdir success for ${path} (ID: ${message.requestId}, expected: ${loadingRequestId.value}). Ignoring.`);
|
||||
return; // 忽略过时的响应
|
||||
}
|
||||
@@ -772,14 +795,29 @@ export function createSftpActionsManager(
|
||||
targetNode.childrenLoaded = true;
|
||||
console.log(`[SFTP ${instanceSessionId}] File tree node ${path}'s children updated after merge.`);
|
||||
|
||||
// *** 在成功加载并更新树之后,才更新当前路径 ***
|
||||
currentPathRef.value = path;
|
||||
console.log(`[SFTP ${instanceSessionId}] currentPathRef updated to ${path} after successful readdir.`);
|
||||
const queuedPath = path === '/' ? pendingPathAfterRootBootstrap.value : null;
|
||||
const shouldContinueToQueuedPath = Boolean(queuedPath && queuedPath !== '/');
|
||||
if (shouldContinueToQueuedPath) {
|
||||
pendingPathAfterRootBootstrap.value = null;
|
||||
}
|
||||
|
||||
if (!shouldContinueToQueuedPath) {
|
||||
// *** 在成功加载并更新树之后,才更新当前路径 ***
|
||||
currentPathRef.value = path;
|
||||
console.log(`[SFTP ${instanceSessionId}] currentPathRef updated to ${path} after successful readdir.`);
|
||||
} else {
|
||||
console.log(`[SFTP ${instanceSessionId}] Root tree bootstrap completed. Will continue loading queued path: ${queuedPath}`);
|
||||
}
|
||||
|
||||
// 重置加载状态,因为这是匹配的响应
|
||||
isLoading.value = false;
|
||||
loadingRequestId.value = null;
|
||||
loadingPath.value = null;
|
||||
console.log(`[SFTP ${instanceSessionId}] isLoading reset after successful readdir for ${path}.`);
|
||||
|
||||
if (shouldContinueToQueuedPath && queuedPath) {
|
||||
loadDirectory(queuedPath);
|
||||
}
|
||||
};
|
||||
|
||||
const onSftpReaddirError = (payload: MessagePayload, message: WebSocketMessage) => {
|
||||
@@ -800,6 +838,7 @@ export function createSftpActionsManager(
|
||||
// 重置加载状态,因为这是匹配的响应
|
||||
isLoading.value = false;
|
||||
loadingRequestId.value = null;
|
||||
loadingPath.value = null;
|
||||
console.log(`[SFTP ${instanceSessionId}] isLoading reset after failed readdir for ${errorPath}.`);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user