fix(frontend): 修复文件管理器删除与上传稳定性
补齐文件管理器右键子菜单点击展开,新增拖拽上传目标确认, 并在上传完成后自动刷新当前可见目录 目录删除改为区分仅删空目录与强制递归删除,删除后自动回退 失效路径,避免文件树持续报 No such file 同步后端 sftp:rmdir 的 recursive 分支,并将关于页与版本检查 默认仓库链接切换到 Micah123321/nexus-terminal
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user