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
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@nexus-terminal/backend",
"version": "0.1.0",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@nexus-terminal/backend",
"version": "0.1.0",
"version": "1.0.0",
"dependencies": {
"@simplewebauthn/server": "^13.1.1",
"@types/archiver": "^6.0.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@nexus-terminal/backend",
"version": "0.1.0",
"version": "1.0.0",
"private": true,
"main": "dist/index.js",
"scripts": {
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@nexus-terminal/frontend",
"version": "0.8.1",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
@@ -39,6 +39,7 @@
"date-fns": "^4.1.0",
"guacamole-common-js": "^1.5.0",
"iconv-lite": "^0.6.3",
"jszip": "^3.10.1",
"mitt": "^3.0.1",
"monaco-editor": "^0.52.2",
"pinia": "^3.0.2",
@@ -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>
<!-- 只有在可取消状态时显示取消按钮 -->
@@ -10,6 +10,8 @@ export interface ContextMenuItem {
disabled?: boolean;
separator?: boolean; // 添加分隔符类型
submenu?: ContextMenuItem[]; // 添加二级菜单支持
icon?: string;
danger?: boolean;
}
// 支持的压缩格式
@@ -35,6 +37,7 @@ export interface UseFileManagerContextMenuOptions {
// --- 回调函数 ---
onRefresh: () => void;
onUpload: () => void;
onUploadFolder: () => void;
onDownload: (items: FileListItem[]) => void; // 文件下载回调
onDownloadDirectory: (item: FileListItem) => void; // +++ 文件夹下载回调 +++
onDelete: () => void; // 删除操作现在由外部处理
@@ -49,6 +52,9 @@ export interface UseFileManagerContextMenuOptions {
onCompressRequest: (items: FileListItem[], format: CompressFormat) => void; // +++ 压缩回调 +++
onDecompressRequest: (item: FileListItem) => void; // +++ 解压回调 +++
onCopyPath?: (item: FileListItem) => void; // +++ 复制路径回调 +++
onCopyFilename?: (item: FileListItem) => void;
onSendCdToTerminal?: (item: FileListItem) => void;
onOpenTerminalAtPath?: (item: FileListItem) => void;
}
// 辅助函数:检查文件是否为支持的压缩格式
@@ -71,6 +77,7 @@ export function useFileManagerContextMenu(options: UseFileManagerContextMenuOpti
t,
onRefresh,
onUpload,
onUploadFolder,
onDownload,
onDelete,
onRename,
@@ -84,6 +91,9 @@ export function useFileManagerContextMenu(options: UseFileManagerContextMenuOpti
onCompressRequest, // +++ 解构压缩回调 +++
onDecompressRequest, // +++ 解构解压回调 +++
onCopyPath, // +++ 解构复制路径回调 +++
onCopyFilename,
onSendCdToTerminal,
onOpenTerminalAtPath,
} = options;
const contextMenuVisible = ref(false);
@@ -126,8 +136,8 @@ export function useFileManagerContextMenu(options: UseFileManagerContextMenuOpti
menu = [
// 调整顺序:剪切、复制优先
{ label: t('fileManager.actions.cut'), action: onCut, disabled: !(isConnected.value && isSftpReady.value) },
{ label: t('fileManager.actions.copy'), action: onCopy, disabled: !(isConnected.value && isSftpReady.value) },
{ label: t('fileManager.actions.cut'), action: onCut, disabled: !(isConnected.value && isSftpReady.value), icon: 'fas fa-scissors' },
{ label: t('fileManager.actions.copy'), action: onCopy, disabled: !(isConnected.value && isSftpReady.value), icon: 'far fa-copy' },
];
// --- 多选下载 ---
@@ -135,7 +145,7 @@ export function useFileManagerContextMenu(options: UseFileManagerContextMenuOpti
// 如果需要支持多选文件夹下载或混合下载,需要更复杂的逻辑和后端支持(例如打包成 zip)
// 目前仅在 allFilesSelected 为 true 时启用多文件下载
if (allFilesSelected) {
menu.push({ label: t('fileManager.actions.downloadMultiple', { count: selectionSize }), action: () => onDownload(selectedFileItems), disabled: !(isConnected.value && isSftpReady.value) });
menu.push({ label: t('fileManager.actions.downloadMultiple', { count: selectionSize }), action: () => onDownload(selectedFileItems), disabled: !(isConnected.value && isSftpReady.value), icon: 'fas fa-download' });
}
@@ -143,10 +153,11 @@ export function useFileManagerContextMenu(options: UseFileManagerContextMenuOpti
menu.push({
label: t('fileManager.contextMenu.compress'),
submenu: [
{ label: t('fileManager.contextMenu.compressZip'), action: () => onCompressRequest(selectedFileItems, 'zip'), disabled: !(isConnected.value && isSftpReady.value) },
{ label: t('fileManager.contextMenu.compressTarGz'), action: () => onCompressRequest(selectedFileItems, 'targz'), disabled: !(isConnected.value && isSftpReady.value) },
{ label: t('fileManager.contextMenu.compressTarBz2'), action: () => onCompressRequest(selectedFileItems, 'tarbz2'), disabled: !(isConnected.value && isSftpReady.value) }
]
{ label: t('fileManager.contextMenu.compressZip'), action: () => onCompressRequest(selectedFileItems, 'zip'), disabled: !(isConnected.value && isSftpReady.value), icon: 'far fa-file-archive' },
{ label: t('fileManager.contextMenu.compressTarGz'), action: () => onCompressRequest(selectedFileItems, 'targz'), disabled: !(isConnected.value && isSftpReady.value), icon: 'far fa-file-archive' },
{ label: t('fileManager.contextMenu.compressTarBz2'), action: () => onCompressRequest(selectedFileItems, 'tarbz2'), disabled: !(isConnected.value && isSftpReady.value), icon: 'far fa-file-archive' }
],
icon: 'fas fa-box-archive'
});
menu.push({ label: '', action: () => {}, disabled: true, separator: true }); // Separator
@@ -154,103 +165,127 @@ export function useFileManagerContextMenu(options: UseFileManagerContextMenuOpti
menu.push(
// --- 分隔符 (视觉) ---
{ label: t('fileManager.actions.deleteMultiple', { count: selectionSize }), action: onDelete, disabled: !(isConnected.value && isSftpReady.value) },
{ label: t('fileManager.actions.deleteMultiple', { count: selectionSize }), action: onDelete, disabled: !(isConnected.value && isSftpReady.value), icon: 'far fa-trash-alt', danger: true },
// --- 分隔符 (视觉) ---
{ label: t('fileManager.actions.refresh'), action: onRefresh, disabled: !(isConnected.value && isSftpReady.value) }
{ label: t('fileManager.actions.refresh'), action: onRefresh, disabled: !(isConnected.value && isSftpReady.value), icon: 'fas fa-rotate-right' }
);
} else if (targetItem && targetItem.filename !== '..') {
// Single item (not '..') menu
menu = [];
// --- 修改:区分文件和文件夹下载 ---
const canOperate = isConnected.value && isSftpReady.value;
const canCompress = canOperate;
const canDecompress = canOperate && targetItem.attrs.isFile && isSupportedArchive(targetItem.filename);
menu.push({ label: t('fileManager.actions.refresh'), action: onRefresh, disabled: !canOperate, icon: 'fas fa-rotate-right' });
menu.push({ label: '', action: () => {}, disabled: true, separator: true });
menu.push({ label: t('fileManager.actions.newFile'), action: onNewFile, disabled: !canOperate, icon: 'far fa-file' });
menu.push({ label: t('fileManager.actions.newFolder'), action: onNewFolder, disabled: !canOperate, icon: 'far fa-folder' });
menu.push({ label: '', action: () => {}, disabled: true, separator: true });
menu.push({ label: t('fileManager.actions.rename'), action: () => onRename(targetItem), disabled: !canOperate, icon: 'far fa-pen-to-square' });
if (targetItem.attrs.isFile) {
menu.push({ label: t('fileManager.actions.download', { name: targetItem.filename }), action: () => onDownload([targetItem]), disabled: !(isConnected.value && isSftpReady.value) }); // 文件下载
menu.push({ label: t('fileManager.actions.download', { name: targetItem.filename }), action: () => onDownload([targetItem]), disabled: !canOperate, icon: 'fas fa-download' });
} else if (targetItem.attrs.isDirectory) {
menu.push({ label: t('fileManager.actions.downloadFolder', { name: targetItem.filename }), action: () => onDownloadDirectory(targetItem), disabled: !(isConnected.value && isSftpReady.value) }); // 文件夹下载
menu.push({ label: t('fileManager.actions.downloadFolder', { name: targetItem.filename }), action: () => onDownloadDirectory(targetItem), disabled: !canOperate, icon: 'fas fa-download' });
}
// 2. 剪切、复制、粘贴 (粘贴 - 如果是文件夹)
menu.push({ label: t('fileManager.actions.cut'), action: onCut, disabled: !(isConnected.value && isSftpReady.value) });
menu.push({ label: t('fileManager.actions.copy'), action: onCopy, disabled: !(isConnected.value && isSftpReady.value) });
if (targetItem.attrs.isDirectory) {
menu.push({ label: t('fileManager.actions.paste'), action: onPaste, disabled: !(isConnected.value && isSftpReady.value) || !hasClipboardContent });
}
// +++ 添加复制路径菜单项 +++
if (onCopyPath) {
menu.push({ label: t('fileManager.actions.copyPath', 'Copy Path'), action: () => onCopyPath(targetItem), disabled: !(isConnected.value && isSftpReady.value) });
}
// --- 分隔符 (视觉) ---
// The invalid object literal was here and is now removed.
// The separator below handles the division correctly.
// Ensure separator is pushed separately and correctly
menu.push({ label: '', action: () => {}, disabled: true, separator: true }); // Separator
// 3. 删除、重命名
menu.push({ label: t('fileManager.actions.delete'), action: onDelete, disabled: !(isConnected.value && isSftpReady.value) });
menu.push({ label: t('fileManager.actions.rename'), action: () => onRename(targetItem), disabled: !(isConnected.value && isSftpReady.value) });
// --- 分隔符 (视觉) ---
// Ensure separator is pushed separately and correctly
menu.push({ label: '', action: () => {}, disabled: true, separator: true }); // Separator
// --- 压缩 & 解压 ---
const canCompress = isConnected.value && isSftpReady.value;
const canDecompress = isConnected.value && isSftpReady.value && targetItem.attrs.isFile && isSupportedArchive(targetItem.filename);
// 添加压缩选项作为二级菜单
menu.push({ label: t('fileManager.actions.changePermissions'), action: () => onChangePermissions(targetItem), disabled: !canOperate, icon: 'fas fa-lock' });
menu.push({
label: t('fileManager.contextMenu.compress'),
label: t('fileManager.actions.terminalMenu', '终端'),
icon: 'fas fa-terminal',
submenu: [
{ label: t('fileManager.contextMenu.compressZip'), action: () => onCompressRequest([targetItem], 'zip'), disabled: !canCompress },
{ label: t('fileManager.contextMenu.compressTarGz'), action: () => onCompressRequest([targetItem], 'targz'), disabled: !canCompress },
{ label: t('fileManager.contextMenu.compressTarBz2'), action: () => onCompressRequest([targetItem], 'tarbz2'), disabled: !canCompress }
]
{
label: t('fileManager.actions.cdToTerminalMenu', '执行 cd 命令到终端'),
action: () => onSendCdToTerminal?.(targetItem),
disabled: !canOperate || !onSendCdToTerminal,
icon: 'fas fa-terminal',
},
{
label: t('fileManager.actions.newTerminalAtPath', '新建终端到当前目录'),
action: () => onOpenTerminalAtPath?.(targetItem),
disabled: !canOperate || !onOpenTerminalAtPath,
icon: 'far fa-square-plus',
},
],
});
menu.push({ label: '', action: () => {}, disabled: true, separator: true });
if (onCopyFilename) {
menu.push({ label: t('fileManager.actions.copyFilename', '复制文件名'), action: () => onCopyFilename(targetItem), disabled: !canOperate, icon: 'far fa-copy' });
}
if (onCopyPath) {
menu.push({ label: t('fileManager.actions.copyPath', 'Copy Path'), action: () => onCopyPath(targetItem), disabled: !canOperate, icon: 'far fa-copy' });
}
menu.push({ label: t('fileManager.actions.delete'), action: onDelete, disabled: !canOperate, icon: 'far fa-trash-alt', danger: true });
menu.push({
label: t('fileManager.actions.uploadMenu', '上传'),
icon: 'fas fa-upload',
submenu: [
{
label: t('fileManager.actions.uploadFile', '上传文件'),
action: onUpload,
disabled: !canOperate,
icon: 'fas fa-upload',
},
{
label: t('fileManager.actions.uploadFolder', '上传文件夹'),
action: onUploadFolder,
disabled: !canOperate,
icon: 'fas fa-folder-open',
},
],
});
// 只有在支持解压的文件上才显示解压选项
if (canDecompress) {
menu.push({ label: t('fileManager.contextMenu.decompress'), action: () => onDecompressRequest(targetItem) });
if (targetItem.attrs.isDirectory) {
menu.push({ label: t('fileManager.actions.paste'), action: onPaste, disabled: !canOperate || !hasClipboardContent, icon: 'far fa-clipboard' });
}
menu.push({ label: t('fileManager.actions.copy'), action: onCopy, disabled: !canOperate, icon: 'far fa-copy' });
menu.push({ label: t('fileManager.actions.cut'), action: onCut, disabled: !canOperate, icon: 'fas fa-scissors' });
if (canCompress) {
menu.push({ label: '', action: () => {}, disabled: true, separator: true });
menu.push({
label: t('fileManager.contextMenu.compress'),
submenu: [
{ label: t('fileManager.contextMenu.compressZip'), action: () => onCompressRequest([targetItem], 'zip'), disabled: !canCompress, icon: 'far fa-file-archive' },
{ label: t('fileManager.contextMenu.compressTarGz'), action: () => onCompressRequest([targetItem], 'targz'), disabled: !canCompress, icon: 'far fa-file-archive' },
{ label: t('fileManager.contextMenu.compressTarBz2'), action: () => onCompressRequest([targetItem], 'tarbz2'), disabled: !canCompress, icon: 'far fa-file-archive' }
],
icon: 'fas fa-box-archive'
});
}
// --- 分隔符 (视觉) ---
menu.push({ label: '', action: () => {}, disabled: true, separator: true }); // Separator
// --- 分隔符 (视觉) ---
// 4. 新建、上传 (这些更像空白处操作,但保留)
menu.push({ label: t('fileManager.actions.newFolder'), action: onNewFolder, disabled: !(isConnected.value && isSftpReady.value) });
menu.push({ label: t('fileManager.actions.newFile'), action: onNewFile, disabled: !(isConnected.value && isSftpReady.value) });
menu.push({ label: t('fileManager.actions.upload'), action: onUpload, disabled: !(isConnected.value && isSftpReady.value) }); // 上传放在新建之后
// --- 分隔符 (视觉) ---
// 5. 权限、刷新
menu.push({ label: t('fileManager.actions.changePermissions'), action: () => onChangePermissions(targetItem), disabled: !(isConnected.value && isSftpReady.value) });
menu.push({ label: t('fileManager.actions.refresh'), action: onRefresh, disabled: !(isConnected.value && isSftpReady.value) });
if (canDecompress) {
menu.push({ label: t('fileManager.contextMenu.decompress'), action: () => onDecompressRequest(targetItem), icon: 'fas fa-box-open' });
}
} else if (!targetItem) {
// Right-click on empty space menu
menu = [
// 1. 粘贴
{ label: t('fileManager.actions.paste'), action: onPaste, disabled: !(isConnected.value && isSftpReady.value) || !hasClipboardContent },
{ label: t('fileManager.actions.paste'), action: onPaste, disabled: !(isConnected.value && isSftpReady.value) || !hasClipboardContent, icon: 'far fa-clipboard' },
// --- 分隔符 (视觉) ---
// 2. 新建、上传
{ label: t('fileManager.actions.newFolder'), action: onNewFolder, disabled: !(isConnected.value && isSftpReady.value) },
{ label: t('fileManager.actions.newFile'), action: onNewFile, disabled: !(isConnected.value && isSftpReady.value) },
{ label: t('fileManager.actions.upload'), action: onUpload, disabled: !(isConnected.value && isSftpReady.value) },
{ label: t('fileManager.actions.newFolder'), action: onNewFolder, disabled: !(isConnected.value && isSftpReady.value), icon: 'far fa-folder' },
{ label: t('fileManager.actions.newFile'), action: onNewFile, disabled: !(isConnected.value && isSftpReady.value), icon: 'far fa-file' },
{
label: t('fileManager.actions.uploadMenu', '上传'),
icon: 'fas fa-upload',
submenu: [
{ label: t('fileManager.actions.uploadFile', '上传文件'), action: onUpload, disabled: !(isConnected.value && isSftpReady.value), icon: 'fas fa-upload' },
{ label: t('fileManager.actions.uploadFolder', '上传文件夹'), action: onUploadFolder, disabled: !(isConnected.value && isSftpReady.value), icon: 'fas fa-folder-open' },
],
},
// --- 分隔符 (视觉) ---
// 3. 刷新
{ label: t('fileManager.actions.refresh'), action: onRefresh, disabled: !(isConnected.value && isSftpReady.value) },
{ label: t('fileManager.actions.refresh'), action: onRefresh, disabled: !(isConnected.value && isSftpReady.value), icon: 'fas fa-rotate-right' },
];
} else { // Clicked on '..'
menu = [
// +++ 粘贴 (可以粘贴到上级目录) +++
{ label: t('fileManager.actions.paste'), action: onPaste, disabled: !(isConnected.value && isSftpReady.value) || !hasClipboardContent },
{ label: t('fileManager.actions.refresh'), action: onRefresh, disabled: !(isConnected.value && isSftpReady.value) }
{ label: t('fileManager.actions.paste'), action: onPaste, disabled: !(isConnected.value && isSftpReady.value) || !hasClipboardContent, icon: 'far fa-clipboard' },
{ label: t('fileManager.actions.refresh'), action: onRefresh, disabled: !(isConnected.value && isSftpReady.value), icon: 'fas fa-rotate-right' }
];
}
@@ -322,4 +357,4 @@ export function useFileManagerContextMenu(options: UseFileManagerContextMenuOpti
showContextMenu,
hideContextMenu,
};
}
}
@@ -0,0 +1,67 @@
import JSZip from 'jszip';
export interface FolderArchiveResult {
archiveFile: File;
folderName: string;
entryCount: number;
}
const TEMP_ARCHIVE_SUFFIX = '__nexus_upload__.zip';
const sanitizeFileName = (name: string): string => {
return name
.trim()
.replace(/[<>:"/\\|?*\u0000-\u001F]/g, '-')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '') || 'folder';
};
const getFolderNameFromRelativePath = (relativePath: string): string => {
const [folderName] = relativePath.split('/').filter(Boolean);
return sanitizeFileName(folderName || 'folder');
};
export const createFolderArchive = async (
selectedFiles: File[] | FileList,
onProgress?: (percent: number) => void,
): Promise<FolderArchiveResult> => {
const files = Array.from(selectedFiles).filter((file) => {
return Boolean(file.webkitRelativePath && file.webkitRelativePath.includes('/'));
});
if (files.length === 0) {
throw new Error('No folder files were selected.');
}
const folderName = getFolderNameFromRelativePath(files[0].webkitRelativePath);
const zip = new JSZip();
files.forEach((file) => {
const relativePath = file.webkitRelativePath.replace(/\\/g, '/');
zip.file(relativePath, file);
});
const archiveBlob = await zip.generateAsync(
{
type: 'blob',
compression: 'DEFLATE',
compressionOptions: { level: 6 },
},
(metadata) => {
onProgress?.(Math.max(0, Math.min(100, Math.round(metadata.percent))));
},
);
const archiveFileName = `${folderName}${TEMP_ARCHIVE_SUFFIX}`;
const archiveFile = new File([archiveBlob], archiveFileName, {
type: 'application/zip',
lastModified: Date.now(),
});
return {
archiveFile,
folderName,
entryCount: files.length,
};
};
@@ -3,9 +3,14 @@ import axios from 'axios';
import pkg from '../../../package.json'; // 路径相对于当前文件
import { useI18n } from 'vue-i18n';
const normalizeVersionLabel = (version: string) => {
const cleanVersion = version.startsWith('v') ? version.slice(1) : version;
return cleanVersion.replace(/^(\d+\.\d+)\.0$/, '$1');
};
export function useAboutSection() {
const { t } = useI18n();
const appVersion = ref(pkg.version);
const appVersion = ref(normalizeVersionLabel(pkg.version));
// --- Version Check State ---
const latestVersion = ref<string | null>(null);
@@ -18,12 +23,8 @@ export function useAboutSection() {
// appVersion.value 通常不包含 'v'
if (!latestVersion.value) return false;
const cleanLatestVersion = latestVersion.value.startsWith('v')
? latestVersion.value.substring(1)
: latestVersion.value;
const cleanAppVersion = appVersion.value.startsWith('v')
? appVersion.value.substring(1)
: appVersion.value;
const cleanLatestVersion = normalizeVersionLabel(latestVersion.value);
const cleanAppVersion = appVersion.value;
// 进行版本比较,更健壮的比较可能需要拆分版本号进行数字比较
// 此处简单比较字符串,对于 "1.0.10" > "1.0.9" 是有效的
@@ -75,4 +76,4 @@ export function useAboutSection() {
isUpdateAvailable,
checkLatestVersion, // Expose if manual refresh is needed
};
}
}
@@ -3,16 +3,24 @@ import axios from 'axios';
import pkg from '../../../package.json'; // 调整路径以正确导入 package.json
import { useI18n } from 'vue-i18n';
const normalizeVersionLabel = (version: string) => {
const cleanVersion = version.startsWith('v') ? version.slice(1) : version;
return cleanVersion.replace(/^(\d+\.\d+)\.0$/, '$1');
};
export function useVersionCheck() {
const { t } = useI18n();
const appVersion = ref(pkg.version);
const appVersion = ref(normalizeVersionLabel(pkg.version));
const latestVersion = ref<string | null>(null);
const isCheckingVersion = ref(false);
const versionCheckError = ref<string | null>(null);
const isUpdateAvailable = computed(() => {
// 简单的字符串比较,假设 tag 格式为 vX.Y.Z
return latestVersion.value && latestVersion.value !== `v${appVersion.value}`;
if (!latestVersion.value) {
return false;
}
return normalizeVersionLabel(latestVersion.value) !== appVersion.value;
});
const checkLatestVersion = async () => {
@@ -48,4 +56,4 @@ export function useVersionCheck() {
isUpdateAvailable,
checkLatestVersion,
};
}
}
@@ -1,8 +1,7 @@
import { ref, reactive, nextTick, onUnmounted, readonly, type Ref, watchEffect } from 'vue';
import { createWebSocketConnectionManager } from './useWebSocketConnection';
import { reactive, nextTick, onUnmounted, type Ref, watchEffect } from 'vue';
import { useI18n } from 'vue-i18n';
import type { FileListItem } from '../types/sftp.types';
import type { UploadItem } from '../types/upload.types';
import type { UploadItem, UploadTaskMode } from '../types/upload.types';
import type { WebSocketMessage, MessagePayload } from '../types/websocket.types';
@@ -27,10 +26,86 @@ export function useFileUploader(
wsDeps: Ref<WebSocketDependencies>
) {
const { t } = useI18n();
wsDeps;
// 对 uploads 字典使用 reactive 以获得更好的深度响应性
const uploads = reactive<Record<string, UploadItem>>({});
const uploadHooks = new Map<string, { afterUpload?: (context: { uploadId: string; remotePath: string; item: UploadItem; }) => Promise<void> }>();
const cleanupUploadTask = (uploadId: string, delayMs = 0) => {
const removeTask = () => {
delete uploads[uploadId];
uploadHooks.delete(uploadId);
};
if (delayMs > 0) {
setTimeout(removeTask, delayMs);
return;
}
removeTask();
};
const getErrorMessage = (payload: MessagePayload, fallback: string): string => {
if (typeof payload === 'string') {
return payload;
}
if (payload && typeof payload === 'object' && 'message' in payload && typeof payload.message === 'string') {
return payload.message;
}
return fallback;
};
const getUploadId = (payload: MessagePayload, message: WebSocketMessage): string | undefined => {
if (message.uploadId) {
return message.uploadId;
}
if (payload && typeof payload === 'object' && 'uploadId' in payload && typeof payload.uploadId === 'string') {
return payload.uploadId;
}
return undefined;
};
const createUploadTask = (
filename: string,
initial: Partial<UploadItem> = {},
): string => {
const uploadId = generateUploadId();
uploads[uploadId] = {
id: uploadId,
file: null,
filename,
progress: 0,
status: 'pending',
mode: initial.mode ?? 'file',
...initial,
};
return uploadId;
};
const updateUploadTask = (uploadId: string, patch: Partial<UploadItem>) => {
const upload = uploads[uploadId];
if (!upload) {
return;
}
Object.assign(upload, patch);
};
const buildRemotePath = (file: File, relativePath?: string) => {
let finalRemotePath: string;
if (relativePath) {
const basePath = currentPathRef.value.endsWith('/') ? currentPathRef.value : `${currentPathRef.value}/`;
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);
}
return finalRemotePath.replace(/\/+/g, '/');
};
// --- 上传逻辑 ---
@@ -116,44 +191,50 @@ wsDeps;
};
const startFileUpload = (file: File, relativePath?: string) => {
// Roo: 使用 .value 访问响应式的 sessionIdForLog
const startFileUpload = (
file: File,
relativePath?: string,
options?: {
uploadId?: string;
displayName?: string;
mode?: UploadTaskMode;
detail?: string;
afterUpload?: (context: { uploadId: string; remotePath: string; item: UploadItem; }) => Promise<void>;
}
) => {
if (!wsDeps.value.isConnected.value) {
console.warn(`[FileUploader ${sessionIdForLog.value}] Cannot start upload: WebSocket not connected.`);
if (options?.uploadId && uploads[options.uploadId]) {
updateUploadTask(options.uploadId, {
status: 'error',
error: t('fileManager.errors.uploadFailed'),
});
cleanupUploadTask(options.uploadId, 5000);
}
return;
}
const uploadId = generateUploadId();
let finalRemotePath: string;
if (relativePath) {
const basePath = currentPathRef.value.endsWith('/') ? currentPathRef.value : `${currentPathRef.value}/`;
// 确保 relativePath 开头没有斜杠,末尾有斜杠 (如果非空)
let cleanRelativePath = relativePath.startsWith('/') ? relativePath.substring(1) : relativePath;
// 移除末尾斜杠(如果有),因为文件名会加上
cleanRelativePath = cleanRelativePath.endsWith('/') ? cleanRelativePath.slice(0, -1) : cleanRelativePath;
// 拼接路径,确保 cleanRelativePath 和 file.name 之间只有一个斜杠
finalRemotePath = `${basePath}${cleanRelativePath ? cleanRelativePath + '/' : ''}${file.name}`;
} else {
finalRemotePath = joinPath(currentPathRef.value, file.name); // 对于非文件夹上传,保持原样
}
// 规范化路径,移除多余的斜杠 e.g. /root//dir -> /root/dir
finalRemotePath = finalRemotePath.replace(/\/+/g, '/');
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}`);
// --- 结束修正 ---
// 添加到响应式 uploads 字典
uploads[uploadId] = {
id: uploadId,
file,
filename: file.name,
filename: options?.displayName ?? uploads[uploadId]?.filename ?? file.name,
progress: 0,
status: 'pending' // 初始状态
status: 'pending',
mode: options?.mode ?? uploads[uploadId]?.mode ?? 'file',
remotePath: finalRemotePath,
detail: options?.detail,
};
if (options?.afterUpload) {
uploadHooks.set(uploadId, { afterUpload: options.afterUpload });
} else {
uploadHooks.delete(uploadId);
}
console.log(`[FileUploader ${sessionIdForLog.value}] Starting upload ${uploadId} to ${finalRemotePath}`);
wsDeps.value.sendMessage({
type: 'sftp:upload:start',
@@ -172,23 +253,18 @@ wsDeps;
wsDeps.value.sendMessage({ type: 'sftp:upload:cancel', payload: { uploadId } });
}
// 短暂延迟后从列表中移除,以显示取消状态
setTimeout(() => {
if (uploads[uploadId]?.status === 'cancelled') {
delete uploads[uploadId];
}
}, 3000);
cleanupUploadTask(uploadId, 3000);
}
};
// --- 消息处理器 ---
const onUploadReady = (payload: MessagePayload, message: WebSocketMessage) => {
const uploadId = message.uploadId || payload?.uploadId;
const uploadId = getUploadId(payload, message);
if (!uploadId) return;
const upload = uploads[uploadId];
if (upload && upload.status === 'pending') {
if (upload && upload.status === 'pending' && upload.file) {
console.log(`[FileUploader ${sessionIdForLog.value}] Upload ${uploadId} ready, starting chunk sending.`);
upload.status = 'uploading';
sendFileChunks(uploadId, upload.file); // 开始发送块
@@ -197,22 +273,32 @@ wsDeps;
}
};
const onUploadSuccess = (payload: MessagePayload, message: WebSocketMessage) => {
const uploadId = message.uploadId || payload?.uploadId;
const onUploadSuccess = async (payload: MessagePayload, message: WebSocketMessage) => {
const uploadId = getUploadId(payload, message);
if (!uploadId) return;
const upload = uploads[uploadId];
if (upload) {
const remotePath = message.path || upload.remotePath;
console.log(`[FileUploader ${sessionIdForLog.value}] Upload ${uploadId} successful.`);
upload.status = 'success';
upload.progress = 100;
// 立即删除记录
if (uploads[uploadId]) { // 确保记录仍然存在
delete uploads[uploadId];
const hook = uploadHooks.get(uploadId);
if (hook?.afterUpload && remotePath) {
upload.status = 'decompressing';
try {
await hook.afterUpload({ uploadId, remotePath, item: upload });
upload.status = 'success';
cleanupUploadTask(uploadId, 1200);
} catch (error: any) {
upload.status = 'error';
upload.error = error?.message || t('fileManager.errors.decompressFailed');
cleanupUploadTask(uploadId, 5000);
}
} else {
upload.status = 'success';
cleanupUploadTask(uploadId);
}
} else {
console.warn(`[FileUploader ${sessionIdForLog.value}] Received upload:success for unknown upload ID: ${uploadId}`);
}
@@ -228,24 +314,18 @@ wsDeps;
const upload = uploads[uploadId];
if (upload) {
const errorMessage = typeof payload === 'string' ? payload : t('fileManager.errors.uploadFailed');
const errorMessage = getErrorMessage(payload, t('fileManager.errors.uploadFailed'));
console.error(`[FileUploader ${sessionIdForLog.value}] Upload ${uploadId} error:`, errorMessage);
upload.status = 'error';
upload.error = errorMessage; // 使用 payload 作为错误消息
// 让错误消息可见时间长一些
setTimeout(() => {
if (uploads[uploadId]?.status === 'error') {
delete uploads[uploadId];
}
}, 5000);
upload.error = errorMessage;
cleanupUploadTask(uploadId, 5000);
} else {
console.warn(`[FileUploader ${sessionIdForLog.value}] Received upload:error for unknown upload ID: ${uploadId}`);
}
};
const onUploadPause = (payload: MessagePayload, message: WebSocketMessage) => {
const uploadId = message.uploadId || payload?.uploadId;
const uploadId = getUploadId(payload, message);
if (!uploadId) return;
const upload = uploads[uploadId];
if (upload && upload.status === 'uploading') {
@@ -255,10 +335,10 @@ wsDeps;
};
const onUploadResume = (payload: MessagePayload, message: WebSocketMessage) => {
const uploadId = message.uploadId || payload?.uploadId;
const uploadId = getUploadId(payload, message);
if (!uploadId) return;
const upload = uploads[uploadId];
if (upload && upload.status === 'paused') {
if (upload && upload.status === 'paused' && upload.file) {
console.log(`[FileUploader ${sessionIdForLog.value}] Resuming upload ${uploadId}`);
upload.status = 'uploading';
sendFileChunks(uploadId, upload.file);
@@ -266,7 +346,7 @@ wsDeps;
};
const onUploadCancelled = (payload: MessagePayload, message: WebSocketMessage) => {
const uploadId = message.uploadId || payload?.uploadId;
const uploadId = getUploadId(payload, message);
if (!uploadId) return;
const upload = uploads[uploadId];
if (upload) {
@@ -277,7 +357,7 @@ wsDeps;
// 确保它会被移除(如果尚未计划移除)
setTimeout(() => {
if (uploads[uploadId]?.status === 'cancelled') {
delete uploads[uploadId];
cleanupUploadTask(uploadId);
}
}, 3000);
}
@@ -285,7 +365,7 @@ wsDeps;
// +++ 处理上传进度更新 +++
const onUploadProgress = (payload: MessagePayload, message: WebSocketMessage) => {
const uploadId = message.uploadId || payload?.uploadId; // 从顶层获取 uploadId
const uploadId = getUploadId(payload, message);
if (!uploadId) {
return;
}
@@ -349,5 +429,8 @@ wsDeps;
uploads,
startFileUpload,
cancelUpload,
createUploadTask,
updateUploadTask,
cleanupUploadTask,
};
}
@@ -40,6 +40,8 @@ export interface SftpManagerInstance {
moveItems: (sourcePaths: string[], destinationDir: string) => void;
compressItems: (items: FileListItem[], format: 'zip' | 'targz' | 'tarbz2') => Promise<void>; // Assume async
decompressItem: (item: FileListItem) => Promise<void>; // Assume async
decompressPath: (archivePath: string, displayName?: string) => Promise<void>;
unlinkPath: (targetPath: string) => Promise<void>;
joinPath: (base: string, name: string) => string;
setInitialLoadDone: (value: boolean) => void;
@@ -56,6 +58,22 @@ const joinPath = (base: string, name: string): string => {
return base.endsWith('/') ? `${base}${name}` : `${base}/${name}`;
};
const dirname = (targetPath: string): string => {
const normalized = targetPath.replace(/\/+$/, '') || '/';
if (normalized === '/') {
return '/';
}
const lastSlashIndex = normalized.lastIndexOf('/');
return lastSlashIndex <= 0 ? '/' : normalized.substring(0, lastSlashIndex);
};
const basename = (targetPath: string): string => {
const normalized = targetPath.replace(/\/+$/, '');
const lastSlashIndex = normalized.lastIndexOf('/');
return lastSlashIndex >= 0 ? normalized.substring(lastSlashIndex + 1) : normalized;
};
// Helper function
const sortFiles = (a: FileListItem, b: FileListItem): number => {
if (a.attrs.isDirectory && !b.attrs.isDirectory) return -1;
@@ -558,17 +576,18 @@ export function createSftpActionsManager(
});
};
const decompressItem = (item: FileListItem): Promise<void> => {
const decompressPath = (archivePath: string, displayName?: string): Promise<void> => {
return new Promise((resolve, reject) => {
if (!isSftpReady.value) {
const errMsg = t('fileManager.errors.sftpNotReady');
uiNotificationsStore.showError(errMsg);
console.warn(`[SFTP ${instanceSessionId}] 尝试解压项目 ${item.filename} 但 SFTP 未就绪。`);
console.warn(`[SFTP ${instanceSessionId}] 尝试解压项目 ${archivePath} 但 SFTP 未就绪。`);
return reject(new Error(errMsg));
}
const sourcePath = joinPath(currentPathRef.value, item.filename);
const destinationDir = currentPathRef.value; // 默认解压到当前目录
const sourcePath = archivePath;
const destinationDir = dirname(archivePath);
const requestId = generateRequestId();
const successName = displayName || basename(archivePath);
let unregisterSuccess: (() => void) | null = null;
let unregisterError: (() => void) | null = null;
@@ -586,8 +605,8 @@ export function createSftpActionsManager(
clearTimeout(timeoutId);
unregisterSuccess?.();
unregisterError?.();
uiNotificationsStore.showSuccess(t('fileManager.notifications.decompressSuccess', { name: item.filename })); // 使用 i18n
loadDirectory(currentPathRef.value, true); // 强制刷新当前目录
uiNotificationsStore.showSuccess(t('fileManager.notifications.decompressSuccess', { name: successName })); // 使用 i18n
loadDirectory(destinationDir, true); // 强制刷新当前目录
resolve();
}
});
@@ -613,6 +632,57 @@ export function createSftpActionsManager(
});
};
const decompressItem = (item: FileListItem): Promise<void> => {
const sourcePath = joinPath(currentPathRef.value, item.filename);
return decompressPath(sourcePath, item.filename);
};
const unlinkPath = (targetPath: string): Promise<void> => {
return new Promise((resolve, reject) => {
if (!isSftpReady.value) {
const errMsg = t('fileManager.errors.sftpNotReady');
uiNotificationsStore.showError(errMsg);
console.warn(`[SFTP ${instanceSessionId}] 尝试删除路径 ${targetPath} 但 SFTP 未就绪。`);
return reject(new Error(errMsg));
}
const requestId = generateRequestId();
let unregisterSuccess: (() => void) | null = null;
let unregisterError: (() => void) | null = null;
const timeoutId = setTimeout(() => {
unregisterSuccess?.();
unregisterError?.();
reject(new Error(t('fileManager.errors.deleteFailed')));
}, 20000);
unregisterSuccess = onMessage('sftp:unlink:success', (_payload: MessagePayload, message: WebSocketMessage) => {
if (message.requestId === requestId && message.path === targetPath) {
clearTimeout(timeoutId);
unregisterSuccess?.();
unregisterError?.();
resolve();
}
});
unregisterError = onMessage('sftp:unlink:error', (payload: MessagePayload, message: WebSocketMessage) => {
if (message.requestId === requestId && message.path === targetPath) {
clearTimeout(timeoutId);
unregisterSuccess?.();
unregisterError?.();
const errorMsg = (payload as string) || t('fileManager.errors.deleteFailed');
reject(new Error(errorMsg));
}
});
sendMessage({
type: 'sftp:unlink',
requestId,
payload: { path: targetPath },
});
});
};
// --- Message Handlers ---
@@ -1170,10 +1240,12 @@ export function createSftpActionsManager(
changePermissions,
readFile,
writeFile,
copyItems, // +++ 暴露 copyItems +++
copyItems, // +++ 暴露 copyItems +++
moveItems, // +++ 暴露 moveItems +++
compressItems, // +++ 暴露 compressItems +++
decompressItem, // +++ 暴露 decompressItem +++
decompressPath,
unlinkPath,
joinPath, // 暴露辅助函数
// clearSftpError, // 移除 clearSftpError
+20 -3
View File
@@ -448,6 +448,7 @@
"refresh": "Refresh",
"parentDirectory": "Parent Directory",
"uploadFile": "Upload File",
"uploadFolder": "Upload Folder",
"upload": "Upload",
"newFolder": "New Folder",
"rename": "Rename",
@@ -463,11 +464,16 @@
"closeTab": "Close Tab",
"closeEditor": "Close Editor",
"cdToTerminal": "Change terminal directory to current path",
"cdToTerminalMenu": "Run cd in terminal",
"copy": "Copy",
"copyFilename": "Copy Filename",
"cut": "Cut",
"paste": "Paste",
"openEditor": "Open Editor",
"copyPath": "Copy Path"
"copyPath": "Copy Absolute Path",
"terminalMenu": "Terminal",
"newTerminalAtPath": "Open New Terminal Here",
"uploadMenu": "Upload"
},
"contextMenu": {
"compress": "Compress",
@@ -485,7 +491,9 @@
"modified": "Modified"
},
"uploadStatus": {
"compressing": "Compressing",
"cancelled": "Cancelled",
"decompressing": "Extracting",
"pending": "Pending",
"uploading": "Uploading"
},
@@ -506,6 +514,7 @@
"loadDirectoryFailed": "Failed to load directory",
"copyFailed": "Copy failed",
"moveFailed": "Move failed",
"uploadFailed": "Upload failed",
"sftpNotReady": "SFTP session not ready",
"sftpManagerNotFound": "SFTP manager not found",
"noActiveSession": "No active session found",
@@ -522,7 +531,10 @@
"commandNotFoundCompress": "Command '{command}' not found on server, cannot complete compression.",
"commandNotFoundDecompress": "Command '{command}' not found on server, cannot complete decompression.",
"genericCommandNotFound": "Command '{command}' not found on server, cannot complete '{operation}' operation.",
"copyPathFailed": "Failed to copy path"
"folderCompressionFailed": "Failed to compress the selected folder",
"archiveCleanupFailed": "Failed to remove temporary archive {name} automatically. Please delete it manually.",
"copyPathFailed": "Failed to copy path",
"copyFilenameFailed": "Failed to copy filename"
},
"notifications": {
"copySuccess": "Copy successful",
@@ -530,7 +542,12 @@
"cdCommandSent": "CD command sent to terminal",
"compressSuccess": "Compressed {name} successfully",
"decompressSuccess": "Decompressed {name} successfully",
"pathCopied": "Path copied to clipboard"
"folderArchiveQueued": "{count} files selected. Preparing archive upload.",
"folderArchivePreparing": "Compressing {count} files",
"folderArchiveReady": "Archive ready. {count} files prepared for upload.",
"folderArchiveUploading": "Uploading archive and preparing automatic extraction",
"pathCopied": "Path copied to clipboard",
"filenameCopied": "Filename copied to clipboard"
},
"warnings": {
"moveSameDirectory": "Cannot cut and paste in the same directory."
+20 -3
View File
@@ -439,6 +439,7 @@
"closeEditor": "エディターを閉じる",
"closeTab": "タブを閉じる",
"copy": "コピー",
"copyFilename": "ファイル名をコピー",
"cut": "切り取り",
"delete": "削除",
"deleteMultiple": "{count} 個の項目を削除",
@@ -455,7 +456,12 @@
"save": "保存",
"upload": "アップロード",
"uploadFile": "ファイルをアップロード",
"copyPath": "パスをコピー"
"uploadFolder": "フォルダをアップロード",
"copyPath": "絶対パスをコピー",
"cdToTerminalMenu": "cd コマンドをターミナルで実行",
"terminalMenu": "ターミナル",
"newTerminalAtPath": "このディレクトリで新しいターミナルを開く",
"uploadMenu": "アップロード"
},
"contextMenu": {
"compress": "圧縮",
@@ -484,6 +490,7 @@
"loadDirectoryFailed": "ディレクトリの読み込みに失敗しました",
"missingConnectionId": "現在の接続 ID を取得できません",
"moveFailed": "移動に失敗しました",
"uploadFailed": "アップロードに失敗しました",
"noActiveSession": "アクティブなセッションが見つかりません",
"readFileError": "ファイルの読み取り中にエラーが発生しました",
"readFileFailed": "ファイルの読み取りに失敗しました",
@@ -503,7 +510,10 @@
"commandNotFoundCompress": "サーバーにコマンド '{command}' が見つからないため、圧縮操作を完了できません。",
"commandNotFoundDecompress": "サーバーにコマンド '{command}' が見つからないため、解凍操作を完了できません。",
"genericCommandNotFound": "サーバーにコマンド '{command}' が見つからないため、'{operation}' 操作を完了できません。",
"copyPathFailed": "パスのコピーに失敗しました"
"folderCompressionFailed": "選択したフォルダの圧縮に失敗しました",
"archiveCleanupFailed": "一時アーカイブ {name} の自動削除に失敗しました。手動で削除してください。",
"copyPathFailed": "パスのコピーに失敗しました",
"copyFilenameFailed": "ファイル名のコピーに失敗しました"
},
"headers": {
"modified": "変更日",
@@ -521,7 +531,12 @@
"moveSuccess": "移動に成功しました",
"compressSuccess": "{name} を正常に圧縮しました",
"decompressSuccess": "{name} を正常に解凍しました",
"pathCopied": "パスがクリップードにコピーされました"
"folderArchiveQueued": "{count} 件のファイルを選択しました。圧縮アップードを準備しています。",
"folderArchivePreparing": "{count} 件のファイルを圧縮中",
"folderArchiveReady": "圧縮が完了しました。{count} 件のファイルをアップロードできます。",
"folderArchiveUploading": "アーカイブをアップロードし、自動展開を準備しています",
"pathCopied": "パスがクリップボードにコピーされました",
"filenameCopied": "ファイル名がクリップボードにコピーされました"
},
"prompts": {
"confirmDeleteFile": "ファイル \"{name}\" を削除しますか?この操作は元に戻せません。",
@@ -539,7 +554,9 @@
"searchPlaceholder": "ファイルを検索...",
"selectFileToEdit": "ファイルマネージャーから編集するファイルを選択してください。",
"uploadStatus": {
"compressing": "圧縮中",
"cancelled": "キャンセルされました",
"decompressing": "展開中",
"pending": "待機中",
"uploading": "アップロード中"
},
+20 -3
View File
@@ -448,6 +448,7 @@
"refresh": "刷新",
"parentDirectory": "上一级",
"uploadFile": "上传文件",
"uploadFolder": "上传文件夹",
"upload": "上传",
"newFolder": "新建文件夹",
"newFile": "新建文件",
@@ -463,11 +464,16 @@
"closeTab": "关闭标签页",
"closeEditor": "关闭编辑器",
"cdToTerminal": "将终端目录切换到当前路径",
"cdToTerminalMenu": "执行 cd 命令到终端",
"copy": "复制",
"copyFilename": "复制文件名",
"cut": "剪切",
"paste": "粘贴",
"openEditor": "打开编辑器",
"copyPath": "复制路径"
"copyPath": "复制绝对路径",
"terminalMenu": "终端",
"newTerminalAtPath": "新建终端到当前目录",
"uploadMenu": "上传"
},
"contextMenu": {
"compress": "压缩",
@@ -485,7 +491,9 @@
"modified": "修改时间"
},
"uploadStatus": {
"compressing": "压缩中",
"cancelled": "已取消",
"decompressing": "解压中",
"pending": "等待中",
"uploading": "上传中"
},
@@ -506,6 +514,7 @@
"loadDirectoryFailed": "加载目录失败",
"copyFailed": "复制失败",
"moveFailed": "移动失败",
"uploadFailed": "上传失败",
"sftpNotReady": "SFTP 会话未就绪",
"sftpManagerNotFound": "SFTP 管理器未找到",
"noActiveSession": "未找到活动会话",
@@ -522,7 +531,10 @@
"commandNotFoundCompress": "服务器上缺少 '{command}' 命令,无法完成压缩操作。",
"commandNotFoundDecompress": "服务器上缺少 '{command}' 命令,无法完成解压操作。",
"genericCommandNotFound": "服务器上缺少 '{command}' 命令,无法完成 '{operation}' 操作。",
"copyPathFailed": "复制路径失败"
"folderCompressionFailed": "文件夹压缩失败",
"archiveCleanupFailed": "自动清理临时压缩包 {name} 失败,请手动删除。",
"copyPathFailed": "复制路径失败",
"copyFilenameFailed": "复制文件名失败"
},
"notifications": {
"copySuccess": "复制成功",
@@ -530,7 +542,12 @@
"cdCommandSent": "CD 命令已发送到终端",
"compressSuccess": "压缩 {name} 成功",
"decompressSuccess": "解压 {name} 成功",
"pathCopied": "路径已复制到剪贴板"
"folderArchiveQueued": "已选择 {count} 个文件,准备压缩上传",
"folderArchivePreparing": "正在压缩 {count} 个文件",
"folderArchiveReady": "压缩完成,准备上传 {count} 个文件",
"folderArchiveUploading": "正在上传压缩包并准备自动解压",
"pathCopied": "路径已复制到剪贴板",
"filenameCopied": "文件名已复制到剪贴板"
},
"warnings": {
"moveSameDirectory": "不能在同一目录下剪切和粘贴。"
+19 -6
View File
@@ -1,11 +1,24 @@
export type UploadTaskMode = 'file' | 'folder-archive';
export type UploadStatus =
| 'compressing'
| 'pending'
| 'uploading'
| 'decompressing'
| 'paused'
| 'success'
| 'error'
| 'cancelled';
// 类型定义:用于文件上传任务
export interface UploadItem {
id: string; // 上传任务的唯一标识符
file: File; // 要上传的文件对象
filename: string; // 文件名
progress: number; // 上传进度 (0-100)
file: File | null; // 要上传的文件对象;本地预处理阶段可为空
filename: string; // 展示给用户的名称
progress: number; // 上传/压缩进度 (0-100)
error?: string; // 错误信息
status: 'pending' | 'uploading' | 'paused' | 'success' | 'error' | 'cancelled'; // 上传状态
status: UploadStatus; // 上传状态
mode?: UploadTaskMode;
remotePath?: string;
detail?: string;
}
// 可以根据需要添加其他与上传相关的类型