fix(frontend): 修复文件管理器删除与上传稳定性

补齐文件管理器右键子菜单点击展开,新增拖拽上传目标确认,
并在上传完成后自动刷新当前可见目录

目录删除改为区分仅删空目录与强制递归删除,删除后自动回退
失效路径,避免文件树持续报 No such file

同步后端 sftp:rmdir 的 recursive 分支,并将关于页与版本检查
默认仓库链接切换到 Micah123321/nexus-terminal
This commit is contained in:
yinjianm
2026-03-26 03:48:50 +08:00
parent 1a326cc01f
commit cda7e0a018
32 changed files with 661 additions and 115 deletions
+28 -4
View File
@@ -489,16 +489,40 @@ export class SftpService {
}
}
/** 删除目录 (强制递归) */
async rmdir(sessionId: string, path: string, requestId: string): Promise<void> {
/** 删除目录 */
async rmdir(sessionId: string, path: string, requestId: string, recursive = true): Promise<void> {
const state = this.clientStates.get(sessionId);
if (!state || !state.sshClient) {
console.warn(`[SSH Exec] SSH 客户端未准备好,无法在 ${sessionId} 上执行 rmdir (ID: ${requestId})`);
if (!state || (!state.sshClient && recursive) || (!state.sftp && !recursive)) {
console.warn(`[SSH Exec] 会话未准备好,无法在 ${sessionId} 上执行 rmdir (ID: ${requestId})`);
state?.ws.send(JSON.stringify({ type: 'sftp:rmdir:error', path: path, payload: 'SSH 会话未就绪', requestId: requestId }));
return;
}
console.debug(`[SSH Exec ${sessionId}] Received rmdir request for ${path} (ID: ${requestId})`);
if (!recursive) {
const sftp = state.sftp;
if (!sftp) {
state.ws.send(JSON.stringify({ type: 'sftp:rmdir:error', path: path, payload: 'SFTP 会话未就绪', requestId: requestId }));
return;
}
try {
sftp.rmdir(path, (err) => {
if (err) {
console.error(`[SFTP ${sessionId}] rmdir ${path} failed (ID: ${requestId}):`, err);
state.ws.send(JSON.stringify({ type: 'sftp:rmdir:error', path: path, payload: `删除目录失败: ${err.message}`, requestId: requestId }));
} else {
console.log(`[SFTP ${sessionId}] rmdir ${path} success (ID: ${requestId})`);
state.ws.send(JSON.stringify({ type: 'sftp:rmdir:success', path: path, requestId: requestId }));
}
});
} catch (error: any) {
console.error(`[SFTP ${sessionId}] rmdir ${path} caught unexpected error (ID: ${requestId}):`, error);
state.ws.send(JSON.stringify({ type: 'sftp:rmdir:error', path: path, payload: `删除目录失败: ${error.message}`, requestId: requestId }));
}
return;
}
// 第一种方案:尝试 rm -rf 命令
const tryRmRfCommand = async (isSudo: boolean) => {
const commandPrefix = isSudo ? 'sudo ' : '';
@@ -57,7 +57,7 @@ export async function handleSftpOperation(
else throw new Error("Missing 'path' in payload for mkdir");
break;
case 'sftp:rmdir':
if (payload?.path) sftpService.rmdir(sessionId, payload.path, requestId);
if (payload?.path) sftpService.rmdir(sessionId, payload.path, requestId, payload?.recursive !== false);
else throw new Error("Missing 'path' in payload for rmdir");
break;
case 'sftp:unlink':
@@ -169,4 +169,4 @@ export function handleSftpUploadCancel(ws: AuthenticatedWebSocket, payload: any)
return;
}
sftpService.cancelUpload(sessionId, payload.uploadId);
}
}
+1 -1
View File
@@ -296,7 +296,7 @@ const isElementVisibleAndFocusable = (element: HTMLElement): boolean => {
<!-- Right navigation links with Tailwind classes using theme variables -->
<div class="flex items-center space-x-1">
<!-- GitHub Icon (Hide on mobile) -->
<a v-if="!isMobile" href="https://github.com/Heavrnl/nexus-terminal" target="_blank" rel="noopener noreferrer" title="Heavrnl/nexus-terminal" class="px-2 py-2 rounded-md text-lg text-icon hover:text-icon-hover hover:bg-nav-active-bg hover:no-underline transition duration-150 ease-in-out">
<a v-if="!isMobile" href="https://github.com/Micah123321/nexus-terminal" target="_blank" rel="noopener noreferrer" title="Micah123321/nexus-terminal" class="px-2 py-2 rounded-md text-lg text-icon hover:text-icon-hover hover:bg-nav-active-bg hover:no-underline transition duration-150 ease-in-out">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" viewBox="0 0 16 16">
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8"/>
</svg>
+160 -28
View File
@@ -23,6 +23,7 @@ import PathHistoryDropdown from './PathHistoryDropdown.vue';
import { usePathHistoryStore } from '../stores/pathHistory.store';
import FavoritePathsModal from './FavoritePathsModal.vue';
import { useUiNotificationsStore } from '../stores/uiNotifications.store';
import { useConfirmDialog } from '../composables/useConfirmDialog';
type SftpManagerInstance = ReturnType<typeof createSftpActionsManager>;
@@ -121,6 +122,7 @@ const settingsStore = useSettingsStore(); // +++ 实例化 Settings Store +++
const focusSwitcherStore = useFocusSwitcherStore(); // +++ 实例化焦点切换 Store +++
const pathHistoryStore = usePathHistoryStore(); // +++ 实例化 PathHistoryStore +++
const uiNotificationsStore = useUiNotificationsStore(); // +++ 实例化通知 store +++
const { showConfirmDialog } = useConfirmDialog();
// 从 Settings Store 获取共享设置
const {
@@ -194,6 +196,15 @@ const startWidth = ref(0);
// --- 辅助函数 ---
const generateRequestId = (): string => `req-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
type ManagedUploadOptions = {
uploadId?: string;
displayName?: string;
mode?: 'file' | 'folder-archive';
detail?: string;
targetPath?: string;
afterUpload?: (context: { uploadId: string; remotePath: string; item: any }) => Promise<void>;
};
// UI 格式化函数保持不变
const formatSize = (size: number): string => {
@@ -309,6 +320,110 @@ const createContextMenuItemFromTreeRow = (row: ExplorerTreeRow): FileListItem =>
attrs: row.item.attrs,
});
const refreshDirectory = (targetPath?: string) => {
if (!currentSftpManager.value) {
return;
}
currentSftpManager.value.loadDirectory(targetPath || currentSftpManager.value.currentPath.value, true);
};
const startManagedFileUpload = (
file: File,
relativePath?: string,
options?: ManagedUploadOptions,
) => {
const resolvedTargetPath = options?.targetPath || currentSftpManager.value?.currentPath.value || '/';
const visiblePathAtStart = currentSftpManager.value?.currentPath.value || '/';
const shouldRefreshVisibleDirectory = resolvedTargetPath === visiblePathAtStart;
startFileUpload(file, relativePath, {
...options,
targetPath: resolvedTargetPath,
afterUpload: async (context) => {
try {
await options?.afterUpload?.(context);
} finally {
if (
shouldRefreshVisibleDirectory &&
currentSftpManager.value?.currentPath.value === visiblePathAtStart &&
getParentPath(context.remotePath) === visiblePathAtStart
) {
refreshDirectory(visiblePathAtStart);
}
}
},
});
};
const sendDeleteRequests = (items: FileListItem[], directoryRecursive = true) => {
if (!props.wsDeps.isConnected.value || !currentSftpManager.value) {
return;
}
items.forEach((item) => {
const targetPath = getItemAbsolutePath(item);
props.wsDeps.sendMessage({
type: item.attrs.isDirectory ? 'sftp:rmdir' : 'sftp:unlink',
requestId: generateRequestId(),
payload: item.attrs.isDirectory
? { path: targetPath, recursive: directoryRecursive }
: { path: targetPath },
});
});
selectedItems.value.clear();
};
const confirmDirectoryDeleteMode = async (items: FileListItem[]): Promise<boolean | null> => {
const directoryItems = items.filter((item) => item.attrs.isDirectory);
const fileItems = items.filter((item) => !item.attrs.isDirectory);
const directoryNames = directoryItems.map((item) => item.filename).join('、');
const fileSummary = fileItems.length > 0
? t('fileManager.prompts.deleteIncludedFiles', { count: fileItems.length })
: '';
const deleteEmptyOnly = await showConfirmDialog({
title: t('common.confirmationTitle', '请确认'),
message: t('fileManager.prompts.confirmDeleteDirectoryEmptyOnly', {
count: directoryItems.length,
names: directoryNames,
fileSummary,
}),
confirmText: t('fileManager.prompts.deleteEmptyOnly', '仅删除空目录'),
cancelText: t('fileManager.prompts.moreDeleteOptions', '更多选项'),
});
if (deleteEmptyOnly) {
return false;
}
const forceRecursiveDelete = await showConfirmDialog({
title: t('common.confirmationTitle', '请确认'),
message: t('fileManager.prompts.confirmDeleteDirectoryRecursive', {
count: directoryItems.length,
names: directoryNames,
fileSummary,
}),
confirmText: t('fileManager.prompts.forceRecursiveDelete', '强制递归删除'),
cancelText: t('fileManager.actions.cancel', '取消'),
});
return forceRecursiveDelete ? true : null;
};
const confirmExternalDropTarget = async (targetPath: string, itemCount: number): Promise<boolean> => {
return showConfirmDialog({
title: t('fileManager.actions.uploadMenu', '上传'),
message: t('fileManager.prompts.confirmUploadToPath', {
count: itemCount,
path: targetPath,
}),
confirmText: t('fileManager.actions.upload', '上传'),
cancelText: t('fileManager.actions.cancel', '取消'),
});
};
const explorerTreeRows = computed<ExplorerTreeRow[]>(() => {
const rows: ExplorerTreeRow[] = [];
const rootNode = findTreeNodeByPath('/');
@@ -750,15 +865,7 @@ const handleModalConfirm = (value?: string) => {
switch (currentActionType.value) {
case 'delete':
if (actionItems.value.length > 0) {
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
sendDeleteRequests(actionItems.value);
}
break;
case 'rename':
@@ -812,7 +919,7 @@ const handleModalConfirm = (value?: string) => {
// --- SFTP 操作处理函数 (定义在此处,供 Composable 使用) ---
const handleDeleteSelectedClick = () => {
const handleDeleteSelectedClick = async () => {
// 修改:检查 currentSftpManager 是否存在
if (!currentSftpManager.value) return;
// 使用 props.wsDeps 和 currentSftpManager.value.fileList
@@ -820,28 +927,30 @@ const handleDeleteSelectedClick = () => {
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 =
const itemsToDelete =
selectedListItems.length > 0
? selectedListItems
: (contextTargetItem.value ? [contextTargetItem.value] : []);
if (itemsToDelete.length === 0) return;
const hasDirectory = itemsToDelete.some((item) => item.attrs.isDirectory);
if (hasDirectory) {
const recursiveDelete = await confirmDirectoryDeleteMode(itemsToDelete);
if (recursiveDelete === null) {
return;
}
sendDeleteRequests(itemsToDelete, recursiveDelete);
return;
}
// 根据设置决定是否显示确认模态框
if (settingsStore.fileManagerShowDeleteConfirmationBoolean) {
openActionModal('delete', null, itemsToDelete);
} else {
// 直接执行删除
if (currentSftpManager.value) {
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
}
sendDeleteRequests(itemsToDelete);
}
};
@@ -985,7 +1094,7 @@ const startFolderArchiveUpload = async (
detail: t('fileManager.notifications.folderArchiveReady', { count: entryCount }),
});
startFileUpload(archiveFile, undefined, {
startManagedFileUpload(archiveFile, undefined, {
uploadId,
displayName: folderName,
mode: 'folder-archive',
@@ -1362,8 +1471,9 @@ const {
joinPath: (base: string, target: string): string => {
return currentSftpManager.value?.joinPath(base, target) ?? `${base}/${target}`.replace(/\/+/g, '/'); // 提供简单的默认实现
},
onFileUpload: (file, relativePath, targetPath) => startFileUpload(file, relativePath, { targetPath }),
onFileUpload: (file, relativePath, targetPath) => startManagedFileUpload(file, relativePath, { targetPath }),
onFolderUpload: startFolderArchiveUpload,
onConfirmExternalDropTarget: confirmExternalDropTarget,
// 修改:确保在调用前检查 currentSftpManager.value
onItemMove: (item, newName) => {
currentSftpManager.value?.renameItem(item, newName);
@@ -1380,7 +1490,7 @@ const handleFileSelected = (event: Event) => {
// 恢复使用 props.wsDeps.isConnected
if (!input.files || !props.wsDeps.isConnected.value) return;
// --- 修正:使用匿名函数包装 startFileUpload 调用 ---
Array.from(input.files).forEach(file => startFileUpload(file)); // 只传递 file 参数
Array.from(input.files).forEach(file => startManagedFileUpload(file)); // 只传递 file 参数
// --- 结束修正 ---
input.value = '';
};
@@ -2033,6 +2143,22 @@ const handleNavigateToPathFromFavorites = (path: string) => {
showFavoritePathsModal.value = false; // Close modal after navigation
};
const expandExplorerPathChain = (path: string | null | undefined) => {
explorerExpandedPaths.value['/'] = true;
if (!path || path === '/') {
return;
}
const segments = path.split('/').filter(Boolean);
let currentPath = '';
segments.forEach((segment) => {
currentPath += `/${segment}`;
explorerExpandedPaths.value[currentPath] = true;
});
};
const toggleDirectoryPath = (path: string, currentExpanded = false) => {
const nextExpanded = !(explorerExpandedPaths.value[path] ?? currentExpanded);
explorerExpandedPaths.value[path] = nextExpanded;
@@ -2103,9 +2229,7 @@ watch(
return;
}
if (explorerExpandedPaths.value['/'] === undefined) {
explorerExpandedPaths.value['/'] = true;
}
expandExplorerPathChain(manager.currentPath.value);
if (!manager.fileTree.childrenLoaded || manager.currentPath.value !== '/') {
manager.loadDirectory('/');
@@ -2113,6 +2237,14 @@ watch(
},
{ immediate: true },
);
watch(
() => currentSftpManager.value?.currentPath.value,
(path) => {
expandExplorerPathChain(path);
},
{ immediate: true },
);
</script>
<template>
@@ -206,6 +206,15 @@ const showSubmenu = (label: string) => {
expandedSubmenu.value = label;
};
const toggleSubmenu = (label: string) => {
if (expandedSubmenu.value === label) {
expandedSubmenu.value = null;
return;
}
showSubmenu(label);
};
const hideSubmenu = () => {
closeTimeout = setTimeout(() => {
expandedSubmenu.value = null;
@@ -302,6 +311,7 @@ onUnmounted(() => {
]"
@mouseenter="showSubmenu(menuItem.label)"
@mouseleave="hideSubmenu()"
@click.stop="!menuItem.disabled && toggleSubmenu(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>
@@ -15,21 +15,16 @@
{{ $t('settings.about.latestVersion') }}
</span>
<a v-else-if="isUpdateAvailable && latestVersion"
:href="`https://github.com/Heavrnl/nexus-terminal/releases/tag/${latestVersion}`"
:href="`https://github.com/Micah123321/nexus-terminal/releases/tag/${latestVersion}`"
target="_blank" rel="noopener noreferrer"
class="inline-flex items-center text-xs ml-2 px-2 py-0.5 rounded-full bg-warning text-white hover:bg-warning/80">
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="mr-1 h-3 w-3"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" x2="12" y1="3" y2="15"/></svg>
{{ $t('settings.about.updateAvailable', { version: latestVersion }) }}
</a>
<span class="opacity-50">|</span>
<a href="https://github.com/Heavrnl/nexus-terminal" target="_blank" rel="noopener noreferrer" class="text-primary hover:underline inline-flex items-center">
<a href="https://github.com/Micah123321/nexus-terminal" target="_blank" rel="noopener noreferrer" class="text-primary hover:underline inline-flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="mr-1" viewBox="0 0 16 16"> <path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8"/> </svg>
Heavrnl/nexus-terminal
</a>
<span class="opacity-50">|</span>
<a href="https://ko-fi.com/0heavrnl" target="_blank" rel="noopener noreferrer" title="Support me on Ko-fi" class="text-primary hover:underline inline-flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="14" height="14" class="mr-1"> <path d="M20.33 6.08c-.28-.4-.7-.68-1.18-.82-.48-.14-.98-.14-1.47-.02-.48.12-.9.38-1.22.75-.32.37-.5.83-.5 1.32 0 .48.18.93.5 1.3.32.37.75.63 1.22.75.48.12.98.12 1.47 0 .48-.12.9-.38 1.18-.75.28-.37.45-.82.45-1.3 0-.48-.17-.95-.45-1.32zm-2.75 1.5c-.14.17-.33.25-.53.25s-.38-.08-.53-.25c-.14-.17-.22-.38-.22-.6s.08-.43.22-.6c.14-.17.33-.25.53-.25s.38.08.53.25c.14.17.22.38.22.6s-.08.43-.22.6zM18 10H6c-1.1 0-2 .9-2 2v6c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2v-6c0-1.1-.9-2-2-2zm-6 8c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2z"/> </svg>
Ko-fi
Micah123321/nexus-terminal
</a>
</div>
</div>
@@ -59,4 +54,4 @@ onMounted(async () => {
<style scoped>
/* Styles specific to AboutSection if any */
</style>
</style>
@@ -73,7 +73,7 @@ const initializeEditableState = () => {
// localTerminalCustomHTML.value = terminalCustomHTML.value || ''; // Replaced
uploadError.value = null;
currentActiveTab.value = activeHtmlPresetTab.value; // Sync with store state
localRemoteHtmlPresetsRepositoryUrl.value = remoteHtmlPresetsRepositoryUrl.value || 'https://github.com/Heavrnl/nexus-terminal/tree/main/doc/custom_html_theme';
localRemoteHtmlPresetsRepositoryUrl.value = remoteHtmlPresetsRepositoryUrl.value || 'https://github.com/Micah123321/nexus-terminal/tree/main/doc/custom_html_theme';
};
onMounted(async () => {
@@ -592,7 +592,7 @@ const filteredRemoteHtmlPresets = computed(() => {
id="remoteRepoUrl"
v-model="localRemoteHtmlPresetsRepositoryUrl"
class="flex-grow p-2 border border-border rounded bg-input text-foreground focus:ring-primary focus:border-primary"
:placeholder="t('styleCustomizer.remoteRepoUrlPlaceholder', 'https://github.com/Heavrnl/nexus-terminal/tree/main/doc/custom_html_theme')"
:placeholder="t('styleCustomizer.remoteRepoUrlPlaceholder', 'https://github.com/Micah123321/nexus-terminal/tree/main/doc/custom_html_theme')"
/>
<button @click="handleSaveRemoteRepositoryUrl" class="px-3 py-1.5 text-sm border border-border rounded bg-header hover:bg-border transition duration-200 ease-in-out whitespace-nowrap flex-shrink-0">
{{ t('common.save') }}
@@ -677,4 +677,4 @@ const filteredRemoteHtmlPresets = computed(() => {
</div>
</div>
</section>
</template>
</template>
@@ -13,9 +13,10 @@ export interface UseFileManagerDragAndDropOptions {
// 函数依赖
joinPath: (base: string, target: string) => string; // 路径拼接函数
onFileUpload: (file: File, relativePath?: string, targetPath?: string) => void; // 修改:触发文件上传的回调,增加相对路径
onFileUpload: (file: File, relativePath?: string, targetPath?: string) => void | Promise<void>; // 修改:触发文件上传的回调,增加相对路径
onFolderUpload: (files: FolderArchiveSource[], targetPath?: string) => void | Promise<void>;
onItemMove: (sourceItem: FileListItem, newFullPath: string) => void; // 触发文件/文件夹移动的回调
onConfirmExternalDropTarget?: (targetPath: string, itemCount: number) => Promise<boolean>;
}
export function useFileManagerDragAndDrop(options: UseFileManagerDragAndDropOptions) {
@@ -27,6 +28,7 @@ export function useFileManagerDragAndDrop(options: UseFileManagerDragAndDropOpti
onFileUpload,
onFolderUpload,
onItemMove,
onConfirmExternalDropTarget,
selectedItems, // 获取传入的 selectedItems
fileList, // 获取传入的 fileList
} = options;
@@ -243,6 +245,13 @@ export function useFileManagerDragAndDrop(options: UseFileManagerDragAndDropOpti
return;
}
if (onConfirmExternalDropTarget) {
const confirmed = await onConfirmExternalDropTarget(targetPath, items.length);
if (!confirmed) {
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];
@@ -3,11 +3,41 @@ import axios from 'axios';
import pkg from '../../../package.json'; // 路径相对于当前文件
import { useI18n } from 'vue-i18n';
const GITHUB_REPO_PATH = 'Micah123321/nexus-terminal';
const normalizeVersionLabel = (version: string) => {
const cleanVersion = version.startsWith('v') ? version.slice(1) : version;
return cleanVersion.replace(/^(\d+\.\d+)\.0$/, '$1');
};
const parseVersion = (version: string) => {
const cleanVersion = version.startsWith('v') ? version.slice(1) : version;
const [major = '0', minor = '0', patch = '0'] = cleanVersion.split('.');
return [
Number.parseInt(major, 10) || 0,
Number.parseInt(minor, 10) || 0,
Number.parseInt(patch, 10) || 0,
] as const;
};
const compareVersions = (left: string, right: string) => {
const leftParts = parseVersion(left);
const rightParts = parseVersion(right);
for (let index = 0; index < leftParts.length; index += 1) {
if (leftParts[index] > rightParts[index]) {
return 1;
}
if (leftParts[index] < rightParts[index]) {
return -1;
}
}
return 0;
};
export function useAboutSection() {
const { t } = useI18n();
const appVersion = ref(normalizeVersionLabel(pkg.version));
@@ -18,18 +48,9 @@ export function useAboutSection() {
const versionCheckError = ref<string | null>(null);
const isUpdateAvailable = computed(() => {
// 简单的字符串比较,假设 tag 格式为 vX.Y.Z 或 X.Y.Z
// 后端返回的 tag_name 可能包含 'v' 前缀,也可能不包含
// appVersion.value 通常不包含 'v'
if (!latestVersion.value) return false;
const cleanLatestVersion = normalizeVersionLabel(latestVersion.value);
const cleanAppVersion = appVersion.value;
// 进行版本比较,更健壮的比较可能需要拆分版本号进行数字比较
// 此处简单比较字符串,对于 "1.0.10" > "1.0.9" 是有效的
// 但对于 "1.0.9" > "1.0.10" 可能会出错,如果需要更精确,可以引入 semver 库或手动比较
return cleanLatestVersion !== cleanAppVersion && cleanLatestVersion > cleanAppVersion;
return compareVersions(latestVersion.value, appVersion.value) > 0;
});
@@ -38,7 +59,7 @@ export function useAboutSection() {
versionCheckError.value = null;
latestVersion.value = null; // Reset before check
try {
const response = await axios.get('https://api.github.com/repos/Heavrnl/nexus-terminal/releases/latest', {
const response = await axios.get(`https://api.github.com/repos/${GITHUB_REPO_PATH}/releases/latest`, {
// 移除 headers 以尝试解决潜在的CORS或请求问题,GitHub API 通常不需要特定 headers 进行公共读取
});
if (response.data && response.data.tag_name) {
@@ -3,11 +3,41 @@ import axios from 'axios';
import pkg from '../../../package.json'; // 调整路径以正确导入 package.json
import { useI18n } from 'vue-i18n';
const GITHUB_REPO_PATH = 'Micah123321/nexus-terminal';
const normalizeVersionLabel = (version: string) => {
const cleanVersion = version.startsWith('v') ? version.slice(1) : version;
return cleanVersion.replace(/^(\d+\.\d+)\.0$/, '$1');
};
const parseVersion = (version: string) => {
const cleanVersion = version.startsWith('v') ? version.slice(1) : version;
const [major = '0', minor = '0', patch = '0'] = cleanVersion.split('.');
return [
Number.parseInt(major, 10) || 0,
Number.parseInt(minor, 10) || 0,
Number.parseInt(patch, 10) || 0,
] as const;
};
const compareVersions = (left: string, right: string) => {
const leftParts = parseVersion(left);
const rightParts = parseVersion(right);
for (let index = 0; index < leftParts.length; index += 1) {
if (leftParts[index] > rightParts[index]) {
return 1;
}
if (leftParts[index] < rightParts[index]) {
return -1;
}
}
return 0;
};
export function useVersionCheck() {
const { t } = useI18n();
const appVersion = ref(normalizeVersionLabel(pkg.version));
@@ -20,7 +50,7 @@ export function useVersionCheck() {
return false;
}
return normalizeVersionLabel(latestVersion.value) !== appVersion.value;
return compareVersions(latestVersion.value, appVersion.value) > 0;
});
const checkLatestVersion = async () => {
@@ -28,7 +58,7 @@ export function useVersionCheck() {
versionCheckError.value = null;
latestVersion.value = null;
try {
const response = await axios.get('https://api.github.com/repos/Heavrnl/nexus-terminal/releases/latest');
const response = await axios.get(`https://api.github.com/repos/${GITHUB_REPO_PATH}/releases/latest`);
if (response.data && response.data.tag_name) {
latestVersion.value = response.data.tag_name;
} else {
@@ -287,7 +287,9 @@ export function useFileUploader(
const hook = uploadHooks.get(uploadId);
if (hook?.afterUpload && remotePath) {
upload.status = 'decompressing';
if (upload.mode === 'folder-archive') {
upload.status = 'decompressing';
}
try {
await hook.afterUpload({ uploadId, remotePath, item: upload });
upload.status = 'success';
@@ -74,6 +74,18 @@ const basename = (targetPath: string): string => {
return lastSlashIndex >= 0 ? normalized.substring(lastSlashIndex + 1) : normalized;
};
const isSameOrDescendantPath = (candidatePath: string | null | undefined, targetPath: string | null | undefined): boolean => {
if (!candidatePath || !targetPath) {
return false;
}
if (targetPath === '/') {
return candidatePath.startsWith('/');
}
return candidatePath === targetPath || candidatePath.startsWith(`${targetPath}/`);
};
// Helper function
const sortFiles = (a: FileListItem, b: FileListItem): number => {
if (a.attrs.isDirectory && !b.attrs.isDirectory) return -1;
@@ -149,6 +161,24 @@ export function createSftpActionsManager(
unregisterCallbacks.length = 0; // 清空数组
};
const recoverFromInvalidPath = (invalidPath: string | null | undefined): string => {
const fallbackPath = dirname(invalidPath || currentPathRef.value || '/');
if (isSameOrDescendantPath(currentPathRef.value, invalidPath)) {
currentPathRef.value = fallbackPath;
}
if (isSameOrDescendantPath(loadingPath.value, invalidPath)) {
loadingPath.value = null;
}
if (isSameOrDescendantPath(pendingPathAfterRootBootstrap.value, invalidPath)) {
pendingPathAfterRootBootstrap.value = null;
}
return fallbackPath;
};
// 不再需要 clearSftpError 函数
// const clearSftpError = () => { ... };
@@ -824,6 +854,13 @@ export function createSftpActionsManager(
// 类型断言,因为我们知道 readdir:error 的 payload 是 string
const errorPayload = payload as string;
const errorPath = message.path;
const missingPath = errorPath || loadingPath.value || currentPathRef.value;
const isMissingPathError = /No such file/i.test(errorPayload || '');
const shouldRecoverMissingPath = isMissingPathError && (
isSameOrDescendantPath(currentPathRef.value, missingPath) ||
isSameOrDescendantPath(loadingPath.value, missingPath) ||
isSameOrDescendantPath(pendingPathAfterRootBootstrap.value, missingPath)
);
// 检查请求 ID 是否匹配当前加载请求
if (message.requestId !== loadingRequestId.value) {
@@ -832,14 +869,22 @@ export function createSftpActionsManager(
}
console.error(`[SFTP ${instanceSessionId}] 加载目录 ${errorPath} 出错:`, errorPayload); // 日志改为中文
// error.value = errorPayload; // 使用通知
uiNotificationsStore.showError(`${t('fileManager.errors.loadDirectoryFailed')}: ${errorPayload}`);
// 重置加载状态,因为这是匹配的响应
isLoading.value = false;
loadingRequestId.value = null;
loadingPath.value = null;
console.log(`[SFTP ${instanceSessionId}] isLoading reset after failed readdir for ${errorPath}.`);
if (shouldRecoverMissingPath) {
const fallbackPath = recoverFromInvalidPath(missingPath);
console.warn(`[SFTP ${instanceSessionId}] 路径 ${missingPath} 不存在,自动回退到 ${fallbackPath}`);
loadDirectory(fallbackPath, true);
return;
}
// error.value = errorPayload; // 使用通知
uiNotificationsStore.showError(`${t('fileManager.errors.loadDirectoryFailed')}: ${errorPayload}`);
};
// 移除通用的 onActionSuccessRefresh
@@ -948,6 +993,9 @@ export function createSftpActionsManager(
const removedPath = message.path;
const parentPath = removedPath?.substring(0, removedPath.lastIndexOf('/')) || '/';
const removedFilename = removedPath?.substring(removedPath.lastIndexOf('/') + 1);
const shouldRecoverPath = isSameOrDescendantPath(currentPathRef.value, removedPath) ||
isSameOrDescendantPath(loadingPath.value, removedPath) ||
isSameOrDescendantPath(pendingPathAfterRootBootstrap.value, removedPath);
console.log(`[SFTP ${instanceSessionId}] 删除成功: ${removedPath}`);
// *** 修改:直接修改文件树 ***
@@ -958,6 +1006,12 @@ export function createSftpActionsManager(
// 理论上 removeNodeFromTree 已经移除了它,这里可以加日志或额外清理
console.log(`[SFTP ${instanceSessionId}] 目录 ${removedPath} 已从树中移除`);
}
if (shouldRecoverPath) {
const fallbackPath = recoverFromInvalidPath(removedPath);
console.log(`[SFTP ${instanceSessionId}] 当前或待加载路径已被删除,自动回退到 ${fallbackPath}`);
loadDirectory(fallbackPath, true);
}
};
// 处理重命名成功
+7
View File
@@ -558,6 +558,13 @@
"confirmDeleteMultiple": "Are you sure you want to delete the selected {count} items? This cannot be undone.",
"confirmDeleteFolder": "Are you sure you want to delete the directory \"{name}\" and all its contents? This cannot be undone.",
"confirmDeleteFile": "Are you sure you want to delete the file \"{name}\"? This cannot be undone.",
"confirmUploadToPath": "Upload {count} dropped item(s) to \"{path}\"?",
"deleteIncludedFiles": ", plus {count} file(s)",
"confirmDeleteDirectoryEmptyOnly": "This will delete {count} directory(s) ({names}){fileSummary}.\n\nIt will only remove empty directories. Non-empty directories will fail.",
"confirmDeleteDirectoryRecursive": "This will recursively delete {count} directory(s) ({names}){fileSummary}.\n\nThis action cannot be undone.",
"deleteEmptyOnly": "Delete Empty Only",
"moreDeleteOptions": "More Options",
"forceRecursiveDelete": "Force Recursive Delete",
"enterNewName": "Enter the new name for \"{oldName}\":",
"enterNewPermissions": "Enter new permissions for \"{name}\" (octal, e.g., 755):",
"enterFileName": "Enter the name for the new file:"
+7
View File
@@ -543,6 +543,13 @@
"confirmDeleteFolder": "フォルダー \"{name}\" とそのすべての内容を削除しますか?この操作は元に戻せません。",
"confirmDeleteMultiple": "選択した {count} 個の項目を削除しますか?この操作は元に戻せません。",
"confirmOverwrite": "ファイル \"{name}\" はすでに存在します。上書きしますか?",
"confirmUploadToPath": "ドラッグした {count} 件の項目を \"{path}\" にアップロードしますか?",
"deleteIncludedFiles": "、および {count} 件のファイル",
"confirmDeleteDirectoryEmptyOnly": "{count} 個のディレクトリ({names}{fileSummary} を削除します。\n\nまず空ディレクトリのみ削除します。中身があるディレクトリは失敗します。",
"confirmDeleteDirectoryRecursive": "{count} 個のディレクトリ({names}{fileSummary} を再帰的に削除します。\n\nこの操作は元に戻せません。",
"deleteEmptyOnly": "空ディレクトリのみ削除",
"moreDeleteOptions": "他の選択肢",
"forceRecursiveDelete": "再帰削除を強制",
"enterFileName": "新しいファイルの名前を入力してください:",
"enterFolderName": "新しいフォルダーの名前を入力してください:",
"enterNewName": "\"{oldName}\" の新しい名前を入力してください:",
+7
View File
@@ -558,6 +558,13 @@
"confirmDeleteMultiple": "确定要删除选定的 {count} 个项目吗?此操作不可撤销。",
"confirmDeleteFolder": "确定要删除目录 \"{name}\" 及其所有内容吗?此操作不可撤销。",
"confirmDeleteFile": "确定要删除文件 \"{name}\" 吗?此操作不可撤销。",
"confirmUploadToPath": "确认将 {count} 个拖入项目上传到 \"{path}\" 吗?",
"deleteIncludedFiles": ",以及 {count} 个文件",
"confirmDeleteDirectoryEmptyOnly": "将删除 {count} 个目录({names}{fileSummary}。\n\n先尝试仅删除空目录;非空目录会直接失败。",
"confirmDeleteDirectoryRecursive": "将强制递归删除 {count} 个目录({names}{fileSummary}。\n\n此操作不可撤销。",
"deleteEmptyOnly": "仅删除空目录",
"moreDeleteOptions": "更多选项",
"forceRecursiveDelete": "强制递归删除",
"enterNewName": "请输入 \"{oldName}\" 的新名称:",
"enterNewPermissions": "请输入 \"{name}\" 的新权限 (八进制, 例如 755):",
"enterFileName": "请输入新文件的名称:"