feat(frontend): 增强文件管理器上传与右键菜单

新增“上传文件夹”入口,选择目录后先在浏览器端打包为 zip,
上传完成后自动触发远端解压并尝试清理临时压缩包。
同时重排文件右键菜单,补齐终端子菜单、复制文件名与绝对路径等操作,
并扩展上传任务状态展示。

同步前后端包版本到 1.0.0,并将设置页版本显示规范为 1.0
This commit is contained in:
yinjianm
2026-03-26 02:56:19 +08:00
parent dcdc8deab8
commit 3d26bffc99
26 changed files with 1142 additions and 198 deletions
@@ -13,6 +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 FileUploadPopup from './FileUploadPopup.vue';
import FileManagerContextMenu from './FileManagerContextMenu.vue';
import FileManagerActionModal from './FileManagerActionModal.vue';
@@ -102,6 +103,9 @@ const {
uploads,
startFileUpload,
cancelUpload,
createUploadTask,
updateUploadTask,
cleanupUploadTask,
} = useFileUploader(
computed(() => props.sessionId),
// 传递 manager 的 currentPath 和 fileList ref
@@ -131,6 +135,7 @@ const {
// --- UI 状态 Refs (Remain mostly the same) ---
const fileInputRef = ref<HTMLInputElement | null>(null);
const folderInputRef = ref<HTMLInputElement | null>(null);
const sortKey = ref<keyof FileListItem | 'type' | 'size' | 'mtime'>('filename');
const sortDirection = ref<'asc' | 'desc'>('asc');
const isEditingPath = ref(false);
@@ -142,6 +147,7 @@ const pathInputRef = ref<HTMLInputElement | null>(null);
const editablePath = ref('');
const fileListContainerRef = ref<HTMLDivElement | null>(null); // 文件列表容器引用
const dropOverlayRef = ref<HTMLDivElement | null>(null); // +++ 拖拽蒙版引用 +++
const isFolderUploadBusy = ref(false);
// +++ Favorite Paths Modal State +++
const showFavoritePathsModal = ref(false);
@@ -869,6 +875,90 @@ const handlePaste = () => {
// --- 文件上传触发器 (定义在此处,供 Composable 使用) ---
const triggerFileUpload = () => { fileInputRef.value?.click(); };
const triggerFolderUpload = () => {
if (isFolderUploadBusy.value) {
return;
}
folderInputRef.value?.click();
};
const getFolderUploadName = (files: File[]) => {
const firstRelativePath = files[0]?.webkitRelativePath || files[0]?.name || '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;
}
if (!currentSftpManager.value || !props.wsDeps.isConnected.value) {
uiNotificationsStore.showError(t('fileManager.errors.sftpNotReady'));
return;
}
const folderName = getFolderUploadName(files);
const uploadId = createUploadTask(folderName, {
status: 'compressing',
mode: 'folder-archive',
detail: t('fileManager.notifications.folderArchiveQueued', { count: files.length }),
});
isFolderUploadBusy.value = true;
try {
const { archiveFile, entryCount } = await createFolderArchive(files, (progress) => {
updateUploadTask(uploadId, {
status: 'compressing',
progress,
detail: t('fileManager.notifications.folderArchivePreparing', { count: files.length }),
});
});
updateUploadTask(uploadId, {
progress: 100,
detail: t('fileManager.notifications.folderArchiveReady', { count: entryCount }),
});
startFileUpload(archiveFile, undefined, {
uploadId,
displayName: folderName,
mode: 'folder-archive',
detail: t('fileManager.notifications.folderArchiveUploading', { count: entryCount }),
afterUpload: async ({ remotePath }) => {
if (!currentSftpManager.value) {
throw new Error(t('fileManager.errors.sftpManagerNotFound'));
}
await currentSftpManager.value.decompressPath(remotePath, folderName);
try {
await currentSftpManager.value.unlinkPath(remotePath);
} catch (cleanupError: any) {
console.warn(`[FileManager ${props.sessionId}-${props.instanceId}] Failed to cleanup uploaded archive ${remotePath}:`, cleanupError);
uiNotificationsStore.showWarning(
t('fileManager.errors.archiveCleanupFailed', {
name: remotePath.split('/').pop() || remotePath,
}),
);
}
},
});
} catch (error: any) {
console.error(`[FileManager ${props.sessionId}-${props.instanceId}] Failed to archive folder before upload:`, error);
updateUploadTask(uploadId, {
status: 'error',
error: error?.message || t('fileManager.errors.folderCompressionFailed'),
});
cleanupUploadTask(uploadId, 5000);
} finally {
isFolderUploadBusy.value = false;
}
};
// --- 下载触发器 (定义在此处,供 Composable 使用) ---
const triggerDownload = (items: FileListItem[]) => { // 修改:接受 FileListItem 数组
@@ -1038,6 +1128,90 @@ const handleCopyPath = async (item: FileListItem) => {
}
};
const handleCopyFilename = async (item: FileListItem) => {
try {
await navigator.clipboard.writeText(item.filename);
uiNotificationsStore.showSuccess(t('fileManager.notifications.filenameCopied', 'Filename copied to clipboard'));
} catch (err) {
console.error(`[FileManager ${props.sessionId}-${props.instanceId}] Failed to copy filename: `, err);
uiNotificationsStore.showError(t('fileManager.errors.copyFilenameFailed', 'Failed to copy filename'));
}
};
const getTargetPathForItem = (item?: FileListItem | null): string | null => {
if (!currentSftpManager.value) {
return null;
}
const currentPath = currentSftpManager.value.currentPath.value;
if (!item || item.filename === '..') {
return currentPath;
}
if (item.attrs.isDirectory) {
return currentSftpManager.value.joinPath(currentPath, item.filename);
}
return currentPath;
};
const sendCdCommandToPath = (targetPath: string, sessionId?: string) => {
if (!props.wsDeps.isConnected.value) {
console.warn(`[FileManager ${props.sessionId}-${props.instanceId}] Cannot send CD command: not connected.`);
return;
}
const escapedPath = `"${targetPath}"`;
const command = `cd ${escapedPath}\n`;
try {
const targetSession = sessionId ? sessionStore.sessions.get(sessionId) : sessionStore.activeSession;
if (!targetSession?.terminalManager) {
console.error(`[FileManager ${props.sessionId}-${props.instanceId}] Failed to send command: Terminal manager not found.`);
return;
}
targetSession.terminalManager.sendData(command);
} catch (error) {
console.error(`[FileManager ${props.sessionId}-${props.instanceId}] Failed to send command to terminal:`, error);
}
};
const handleSendItemPathToTerminal = (item: FileListItem) => {
const targetPath = getTargetPathForItem(item);
if (!targetPath) {
return;
}
sendCdCommandToPath(targetPath);
};
const handleOpenTerminalAtItemPath = (item: FileListItem) => {
const targetPath = getTargetPathForItem(item);
const connectionId = Number(props.dbConnectionId);
if (!targetPath || Number.isNaN(connectionId)) {
return;
}
const previousActiveSessionId = sessionStore.activeSessionId;
sessionStore.handleOpenNewSession(connectionId);
const newSessionId = sessionStore.activeSessionId;
if (!newSessionId || newSessionId === previousActiveSessionId) {
return;
}
const newSession = sessionStore.sessions.get(newSessionId);
if (!newSession?.wsManager) {
return;
}
const unregister = newSession.wsManager.onMessage('ssh:connected', () => {
sendCdCommandToPath(targetPath, newSessionId);
unregister?.();
});
};
// --- 上下文菜单逻辑 (使用 Composable, 需要 Selection 和 Action Handlers) ---
const {
contextMenuVisible,
@@ -1065,6 +1239,7 @@ const {
}
},
onUpload: triggerFileUpload,
onUploadFolder: triggerFolderUpload,
onDownload: triggerDownload,
onDelete: handleDeleteSelectedClick,
onRename: handleRenameContextMenuClick,
@@ -1079,6 +1254,9 @@ const {
onCompressRequest: handleCompress,
onDecompressRequest: handleDecompress,
onCopyPath: handleCopyPath, // +++ 传递复制路径回调 +++
onCopyFilename: handleCopyFilename,
onSendCdToTerminal: handleSendItemPathToTerminal,
onOpenTerminalAtPath: handleOpenTerminalAtItemPath,
});
// --- 目录加载与导航 ---
@@ -1970,10 +2148,11 @@ watch(
class="left-0 right-0 top-full mt-1"
/>
</div>
</div> <!-- End Wrapper -->
</div> <!-- End Wrapper -->
<!-- Main Actions Bar -->
<div class="flex items-center gap-2 flex-shrink-0">
<input type="file" ref="fileInputRef" @change="handleFileSelected" multiple class="hidden" />
<input type="file" ref="folderInputRef" @change="handleFolderSelected" webkitdirectory directory multiple class="hidden" />
<!-- 打开编辑器按钮 -->
<button
v-if="showPopupFileEditorBoolean"
@@ -1995,7 +2174,17 @@ watch(
:class="{ 'px-1.5': props.isMobile }"
>
<i class="fas fa-upload text-sm"></i>
<span v-if="!props.isMobile">{{ t('fileManager.actions.upload') }}</span>
<span v-if="!props.isMobile">{{ t('fileManager.actions.uploadFile') }}</span>
</button>
<button
@click="triggerFolderUpload"
:disabled="!currentSftpManager || !props.wsDeps.isConnected.value || isFolderUploadBusy"
:title="t('fileManager.actions.uploadFolder')"
class="flex items-center gap-1 px-2.5 py-1 bg-background border border-border rounded text-foreground text-xs transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed hover:enabled:bg-header hover:enabled:border-primary hover:enabled:text-primary"
:class="{ 'px-1.5': props.isMobile }"
>
<i :class="isFolderUploadBusy ? 'fas fa-spinner fa-spin text-sm' : 'fas fa-folder-open text-sm'"></i>
<span v-if="!props.isMobile">{{ t('fileManager.actions.uploadFolder') }}</span>
</button>
<button
@click="handleNewFolderContextMenuClick"
@@ -133,6 +133,9 @@ onUnmounted(() => {
const emit = defineEmits(['item-click', 'close-request']); // 添加 close-request
const handleItemClick = (item: ContextMenuItem) => {
if (item.disabled) {
return;
}
if (item.action) {
item.action(); // 只有当 action 存在时才执行
emit('close-request'); // <-- 发出关闭请求
@@ -221,13 +224,13 @@ onUnmounted(() => {
<div
ref="contextMenuRef"
v-if="isVisible"
class="fixed bg-background border border-border shadow-lg rounded-md z-[1002] min-w-[150px]"
class="fixed bg-background/98 border border-border/80 shadow-[0_18px_40px_rgba(0,0,0,0.36)] rounded-xl z-[1002] min-w-[184px] overflow-hidden backdrop-blur-sm"
:style="{ top: `${computedRenderPosition.y}px`, left: `${computedRenderPosition.x}px` }"
@click.stop
>
<ul class="list-none p-1 m-0">
<ul class="list-none p-1.5 m-0">
<template v-for="(menuItem, index) in items" :key="index">
<li v-if="menuItem.separator" class="border-t border-border/50 my-1 mx-1"></li>
<li v-if="menuItem.separator" class="border-t border-border/60 my-1.5 mx-2"></li>
<!-- 如果是移动设备且有子菜单则平铺子菜单 -->
<template v-else-if="isMobile && menuItem.submenu && menuItem.submenu.length > 0">
<li
@@ -235,10 +238,15 @@ onUnmounted(() => {
:key="`${index}-${subIndex}`"
@click.stop="handleItemClick(subItem)"
:class="[
'px-4 py-1.5 cursor-pointer text-foreground text-sm flex items-center transition-colors duration-150 rounded mx-1',
'hover:bg-primary/10 hover:text-primary'
'px-3 py-2.5 text-sm flex items-center gap-2.5 transition-colors duration-150 rounded-lg mx-0.5',
subItem.disabled
? 'cursor-not-allowed opacity-50 text-text-secondary'
: subItem.danger
? 'cursor-pointer text-error hover:bg-error/10'
: 'cursor-pointer text-foreground hover:bg-primary/10 hover:text-primary'
]"
>
<i v-if="subItem.icon" :class="[subItem.icon, 'w-4 text-center flex-shrink-0']"></i>
{{ subItem.label }}
</li>
<!-- 如果 menuItem (作为移动端子菜单容器) "压缩", 在其子项后添加 "发送到" -->
@@ -246,7 +254,7 @@ onUnmounted(() => {
<li
@click.stop="handleSendToClick"
:class="[
'px-4 py-1.5 cursor-pointer text-foreground text-sm flex items-center transition-colors duration-150 rounded mx-1',
'px-3 py-2.5 cursor-pointer text-foreground text-sm flex items-center gap-2.5 transition-colors duration-150 rounded-lg mx-0.5',
'hover:bg-primary/10 hover:text-primary'
]"
>
@@ -259,10 +267,15 @@ onUnmounted(() => {
v-else-if="!menuItem.submenu"
@click.stop="handleItemClick(menuItem)"
:class="[
'px-4 py-1.5 cursor-pointer text-foreground text-sm flex items-center transition-colors duration-150 rounded mx-1',
'hover:bg-primary/10 hover:text-primary'
'px-3 py-2.5 text-sm flex items-center gap-2.5 transition-colors duration-150 rounded-lg mx-0.5',
menuItem.disabled
? 'cursor-not-allowed opacity-50 text-text-secondary'
: menuItem.danger
? 'cursor-pointer text-error hover:bg-error/10'
: 'cursor-pointer text-foreground hover:bg-primary/10 hover:text-primary'
]"
>
<i v-if="menuItem.icon" :class="[menuItem.icon, 'w-4 text-center flex-shrink-0']"></i>
{{ menuItem.label }}
</li>
<!-- 如果普通菜单项是 "压缩", 在其后添加 "发送到" -->
@@ -279,15 +292,25 @@ onUnmounted(() => {
</template>
<li
v-if="menuItem.submenu && !isMobile"
class="px-4 py-1.5 text-foreground text-sm flex items-center justify-between transition-colors duration-150 rounded mx-1 hover:bg-primary/10 hover:text-primary relative"
:class="[
'px-3 py-2.5 text-sm flex items-center justify-between transition-colors duration-150 rounded-lg mx-0.5 relative',
menuItem.disabled
? 'cursor-not-allowed opacity-50 text-text-secondary'
: menuItem.danger
? 'cursor-pointer text-error hover:bg-error/10'
: 'cursor-pointer text-foreground hover:bg-primary/10 hover:text-primary'
]"
@mouseenter="showSubmenu(menuItem.label)"
@mouseleave="hideSubmenu()"
>
{{ menuItem.label }}
<span class="flex items-center gap-3 min-w-0">
<i v-if="menuItem.icon" :class="[menuItem.icon, 'w-4 text-center flex-shrink-0']"></i>
<span class="truncate">{{ menuItem.label }}</span>
</span>
<span class="ml-2"></span>
<ul
v-if="expandedSubmenu === menuItem.label"
class="absolute left-full top-0 mt-0 ml-1 bg-background border border-border shadow-lg rounded-md z-[1003] min-w-[150px] list-none p-1"
class="absolute left-full top-0 mt-0 ml-2 bg-background/98 border border-border/80 shadow-[0_18px_40px_rgba(0,0,0,0.32)] rounded-xl z-[1003] min-w-[184px] list-none p-1.5"
@mouseenter="showSubmenu(menuItem.label)"
@mouseleave="hideSubmenu()"
>
@@ -296,10 +319,15 @@ onUnmounted(() => {
:key="subIndex"
@click.stop="handleItemClick(subItem)"
:class="[
'px-4 py-1.5 cursor-pointer text-foreground text-sm flex items-center transition-colors duration-150 rounded mx-1',
'hover:bg-primary/10 hover:text-primary'
'px-3 py-2.5 text-sm flex items-center gap-2.5 transition-colors duration-150 rounded-lg mx-0.5',
subItem.disabled
? 'cursor-not-allowed opacity-50 text-text-secondary'
: subItem.danger
? 'cursor-pointer text-error hover:bg-error/10'
: 'cursor-pointer text-foreground hover:bg-primary/10 hover:text-primary'
]"
>
<i v-if="subItem.icon" :class="[subItem.icon, 'w-4 text-center flex-shrink-0']"></i>
{{ subItem.label }}
</li>
</ul>
@@ -309,7 +337,7 @@ onUnmounted(() => {
<li
@click.stop="handleSendToClick"
:class="[
'px-4 py-1.5 cursor-pointer text-foreground text-sm flex items-center transition-colors duration-150 rounded mx-1',
'px-3 py-2.5 cursor-pointer text-foreground text-sm flex items-center gap-2.5 transition-colors duration-150 rounded-lg mx-0.5',
'hover:bg-primary/10 hover:text-primary'
]"
>
@@ -27,6 +27,14 @@ const uploadList = computed(() => Object.values(props.uploads).filter(upload =>
return !isEffectivelySuccess && upload.status !== 'cancelled';
}));
const showProgressBar = (upload: UploadItem) => {
return ['compressing', 'pending', 'uploading'].includes(upload.status);
};
const showProgressValue = (upload: UploadItem) => {
return ['compressing', 'uploading'].includes(upload.status);
};
const handleCancel = (uploadId: string) => {
emit('cancel-upload', uploadId);
};
@@ -39,9 +47,14 @@ const handleCancel = (uploadId: string) => {
<ul class="list-none p-0 m-0">
<li v-for="upload in uploadList" :key="upload.id" class="mb-1.5 text-xs flex items-center flex-wrap gap-2">
<span class="flex-grow truncate" :title="upload.filename">{{ upload.filename }} ({{ t(`fileManager.uploadStatus.${upload.status}`) }})</span>
<progress v-if="(upload.status === 'uploading' && upload.progress < 100) || upload.status === 'pending'" :value="upload.progress" max="100" class="w-20 h-2 flex-shrink-0 [&::-webkit-progress-bar]:rounded-lg [&::-webkit-progress-value]:rounded-lg [&::-webkit-progress-bar]:bg-gray-300 [&::-webkit-progress-value]:bg-blue-600 [&::-moz-progress-bar]:bg-blue-600"></progress>
<span v-if="upload.status === 'uploading' && upload.progress < 100" class="text-xs flex-shrink-0"> {{ upload.progress }}%</span>
<span v-if="upload.detail" class="basis-full text-[11px] text-text-secondary">{{ upload.detail }}</span>
<progress v-if="showProgressBar(upload)" :value="upload.progress" max="100" class="w-20 h-2 flex-shrink-0 [&::-webkit-progress-bar]:rounded-lg [&::-webkit-progress-value]:rounded-lg [&::-webkit-progress-bar]:bg-gray-300 [&::-webkit-progress-value]:bg-blue-600 [&::-moz-progress-bar]:bg-blue-600"></progress>
<span v-if="showProgressValue(upload)" class="text-xs flex-shrink-0"> {{ upload.progress }}%</span>
<span v-if="upload.status === 'error'" class="text-red-600 basis-full text-xs"> {{ t('fileManager.errors.generic') }}: {{ upload.error }}</span>
<span v-if="upload.status === 'decompressing'" class="text-xs text-text-secondary flex items-center gap-1">
<i class="fas fa-spinner fa-spin"></i>
{{ t('fileManager.uploadStatus.decompressing') }}
</span>
<span v-if="upload.status === 'success' || (upload.status === 'uploading' && upload.progress === 100)" class="text-green-600"> </span>
<span v-if="upload.status === 'cancelled'" class="text-red-600"> {{ t('fileManager.uploadStatus.cancelled') }}</span>
<!-- 只有在可取消状态时显示取消按钮 -->