update
This commit is contained in:
@@ -11,8 +11,10 @@ import { useFileEditorStore, type FileInfo } from '../stores/fileEditor.store';
|
|||||||
import { useSessionStore } from '../stores/session.store';
|
import { useSessionStore } from '../stores/session.store';
|
||||||
import { useSettingsStore } from '../stores/settings.store';
|
import { useSettingsStore } from '../stores/settings.store';
|
||||||
import { useFocusSwitcherStore } from '../stores/focusSwitcher.store'; // +++ 导入焦点切换 Store +++
|
import { useFocusSwitcherStore } from '../stores/focusSwitcher.store'; // +++ 导入焦点切换 Store +++
|
||||||
|
import { useFileManagerContextMenu } from '../composables/file-manager/useFileManagerContextMenu'; // +++ 导入上下文菜单 Composable +++
|
||||||
// WebSocket composable 不再直接使用
|
// WebSocket composable 不再直接使用
|
||||||
import FileUploadPopup from './FileUploadPopup.vue';
|
import FileUploadPopup from './FileUploadPopup.vue';
|
||||||
|
import FileManagerContextMenu from './FileManagerContextMenu.vue'; // +++ 导入上下文菜单组件 +++
|
||||||
// import FileEditorOverlay from './FileEditorOverlay.vue'; // 不再在此处渲染
|
// import FileEditorOverlay from './FileEditorOverlay.vue'; // 不再在此处渲染
|
||||||
// 从类型文件导入所需类型
|
// 从类型文件导入所需类型
|
||||||
import type { FileListItem } from '../types/sftp.types';
|
import type { FileListItem } from '../types/sftp.types';
|
||||||
@@ -120,10 +122,11 @@ const { shareFileEditorTabsBoolean } = storeToRefs(settingsStore); // 使用 sto
|
|||||||
const fileInputRef = ref<HTMLInputElement | null>(null);
|
const fileInputRef = ref<HTMLInputElement | null>(null);
|
||||||
const selectedItems = ref(new Set<string>());
|
const selectedItems = ref(new Set<string>());
|
||||||
const lastClickedIndex = ref(-1);
|
const lastClickedIndex = ref(-1);
|
||||||
const contextMenuVisible = ref(false);
|
// --- 上下文菜单状态 (移至 useFileManagerContextMenu) ---
|
||||||
const contextMenuPosition = ref({ x: 0, y: 0 });
|
// const contextMenuVisible = ref(false);
|
||||||
const contextMenuItems = ref<Array<{ label: string; action: () => void; disabled?: boolean }>>([]);
|
// const contextMenuPosition = ref({ x: 0, y: 0 });
|
||||||
const contextTargetItem = ref<FileListItem | null>(null);
|
// const contextMenuItems = ref<Array<{ label: string; action: () => void; disabled?: boolean }>>([]);
|
||||||
|
// const contextTargetItem = ref<FileListItem | null>(null);
|
||||||
const isDraggingOver = ref(false);
|
const isDraggingOver = ref(false);
|
||||||
const sortKey = ref<keyof FileListItem | 'type' | 'size' | 'mtime'>('filename');
|
const sortKey = ref<keyof FileListItem | 'type' | 'size' | 'mtime'>('filename');
|
||||||
const sortDirection = ref<'asc' | 'desc'>('asc');
|
const sortDirection = ref<'asc' | 'desc'>('asc');
|
||||||
@@ -135,7 +138,7 @@ const isSearchActive = ref(false); // 新增:控制搜索框激活状态
|
|||||||
const searchInputRef = ref<HTMLInputElement | null>(null); // 新增:搜索输入框 ref
|
const searchInputRef = ref<HTMLInputElement | null>(null); // 新增:搜索输入框 ref
|
||||||
const pathInputRef = ref<HTMLInputElement | null>(null);
|
const pathInputRef = ref<HTMLInputElement | null>(null);
|
||||||
const editablePath = ref('');
|
const editablePath = ref('');
|
||||||
const contextMenuRef = ref<HTMLDivElement | null>(null); // <-- Add ref for context menu element
|
// const contextMenuRef = ref<HTMLDivElement | null>(null); // <-- 移至 useFileManagerContextMenu
|
||||||
const draggedItem = ref<FileListItem | null>(null); // 新增:存储被拖拽的项
|
const draggedItem = ref<FileListItem | null>(null); // 新增:存储被拖拽的项
|
||||||
const dragOverTarget = ref<string | null>(null); // 新增:存储当前拖拽悬停的目标文件夹名称
|
const dragOverTarget = ref<string | null>(null); // 新增:存储当前拖拽悬停的目标文件夹名称
|
||||||
const fileListContainerRef = ref<HTMLDivElement | null>(null); // 新增:文件列表容器引用
|
const fileListContainerRef = ref<HTMLDivElement | null>(null); // 新增:文件列表容器引用
|
||||||
@@ -180,118 +183,129 @@ const formatMode = (mode: number): string => {
|
|||||||
return str;
|
return str;
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- 上下文菜单逻辑 ---
|
// --- SFTP 操作处理函数 (定义在此处,供 Composable 使用) ---
|
||||||
// Actions now call methods from props.sftpManager
|
const handleDeleteSelectedClick = () => {
|
||||||
const showContextMenu = (event: MouseEvent, item?: FileListItem) => {
|
if (!props.wsDeps.isConnected.value || selectedItems.value.size === 0) return; // 恢复使用 props.wsDeps
|
||||||
event.preventDefault();
|
const itemsToDelete = Array.from(selectedItems.value)
|
||||||
const targetItem = item || null;
|
.map(filename => fileList.value.find((f: FileListItem) => f.filename === filename)) // f 已有类型
|
||||||
|
.filter((item): item is FileListItem => item !== undefined);
|
||||||
|
if (itemsToDelete.length === 0) return;
|
||||||
|
|
||||||
// Adjust selection based on right-click target
|
const names = itemsToDelete.map(i => i.filename).join(', ');
|
||||||
if (targetItem && !event.ctrlKey && !event.metaKey && !event.shiftKey && !selectedItems.value.has(targetItem.filename)) {
|
const confirmMsg = itemsToDelete.length > 1
|
||||||
|
? t('fileManager.prompts.confirmDeleteMultiple', { count: itemsToDelete.length, names: names })
|
||||||
|
: itemsToDelete[0].attrs.isDirectory
|
||||||
|
? t('fileManager.prompts.confirmDeleteFolder', { name: itemsToDelete[0].filename })
|
||||||
|
: t('fileManager.prompts.confirmDeleteFile', { name: itemsToDelete[0].filename });
|
||||||
|
|
||||||
|
if (confirm(confirmMsg)) {
|
||||||
|
deleteItems(itemsToDelete); // Use deleteItems from props
|
||||||
selectedItems.value.clear();
|
selectedItems.value.clear();
|
||||||
selectedItems.value.add(targetItem.filename);
|
|
||||||
// 使用 props.sftpManager 中的 fileList
|
|
||||||
lastClickedIndex.value = fileList.value.findIndex((f: FileListItem) => f.filename === targetItem.filename); // 已添加类型
|
|
||||||
} else if (!targetItem) {
|
|
||||||
selectedItems.value.clear();
|
|
||||||
lastClickedIndex.value = -1;
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
contextTargetItem.value = targetItem;
|
const handleRenameContextMenuClick = (item: FileListItem) => { // item 已有类型
|
||||||
let menu: Array<{ label: string; action: () => void; disabled?: boolean }> = [];
|
if (!props.wsDeps.isConnected.value || !item) return; // 恢复使用 props.wsDeps
|
||||||
const selectionSize = selectedItems.value.size;
|
const newName = prompt(t('fileManager.prompts.enterNewName', { oldName: item.filename }), item.filename);
|
||||||
const clickedItemIsSelected = targetItem && selectedItems.value.has(targetItem.filename);
|
if (newName && newName !== item.filename) {
|
||||||
const canPerformActions = props.wsDeps.isConnected.value && props.wsDeps.isSftpReady.value; // 恢复使用 props.wsDeps
|
renameItem(item, newName); // Use renameItem from props.sftpManager
|
||||||
|
|
||||||
// Build context menu items
|
|
||||||
if (selectionSize > 1 && clickedItemIsSelected) {
|
|
||||||
// Multi-selection menu
|
|
||||||
menu = [
|
|
||||||
{ label: t('fileManager.actions.deleteMultiple', { count: selectionSize }), action: handleDeleteSelectedClick, disabled: !canPerformActions },
|
|
||||||
{ label: t('fileManager.actions.refresh'), action: () => loadDirectory(currentPath.value), disabled: !canPerformActions },
|
|
||||||
];
|
|
||||||
} else if (targetItem && targetItem.filename !== '..') {
|
|
||||||
// Single item (not '..') menu
|
|
||||||
menu = [
|
|
||||||
{ label: t('fileManager.actions.newFolder'), action: handleNewFolderContextMenuClick, disabled: !canPerformActions },
|
|
||||||
{ label: t('fileManager.actions.newFile'), action: handleNewFileContextMenuClick, disabled: !canPerformActions },
|
|
||||||
{ label: t('fileManager.actions.upload'), action: triggerFileUpload, disabled: !canPerformActions }, // Upload depends on connection
|
|
||||||
{ label: t('fileManager.actions.refresh'), action: () => loadDirectory(currentPath.value), disabled: !canPerformActions },
|
|
||||||
];
|
|
||||||
if (targetItem.attrs.isFile) {
|
|
||||||
menu.splice(1, 0, { label: t('fileManager.actions.download', { name: targetItem.filename }), action: () => triggerDownload(targetItem), disabled: !canPerformActions }); // Download depends on connection
|
|
||||||
}
|
}
|
||||||
menu.push({ label: t('fileManager.actions.delete'), action: handleDeleteSelectedClick, disabled: !canPerformActions });
|
};
|
||||||
menu.push({ label: t('fileManager.actions.rename'), action: () => handleRenameContextMenuClick(targetItem), disabled: !canPerformActions });
|
|
||||||
menu.push({ label: t('fileManager.actions.changePermissions'), action: () => handleChangePermissionsContextMenuClick(targetItem), disabled: !canPerformActions });
|
|
||||||
|
|
||||||
} else if (!targetItem) {
|
const handleChangePermissionsContextMenuClick = (item: FileListItem) => { // item 已有类型
|
||||||
// Right-click on empty space menu
|
if (!props.wsDeps.isConnected.value || !item) return; // 恢复使用 props.wsDeps
|
||||||
menu = [
|
const currentModeOctal = (item.attrs.mode & 0o777).toString(8).padStart(3, '0');
|
||||||
{ label: t('fileManager.actions.newFolder'), action: handleNewFolderContextMenuClick, disabled: !canPerformActions },
|
const newModeStr = prompt(t('fileManager.prompts.enterNewPermissions', { name: item.filename, currentMode: currentModeOctal }), currentModeOctal);
|
||||||
{ label: t('fileManager.actions.newFile'), action: handleNewFileContextMenuClick, disabled: !canPerformActions },
|
if (newModeStr) {
|
||||||
{ label: t('fileManager.actions.upload'), action: triggerFileUpload, disabled: !canPerformActions },
|
if (!/^[0-7]{3,4}$/.test(newModeStr)) {
|
||||||
{ label: t('fileManager.actions.refresh'), action: () => loadDirectory(currentPath.value), disabled: !canPerformActions },
|
alert(t('fileManager.errors.invalidPermissionsFormat'));
|
||||||
];
|
return;
|
||||||
} else { // Clicked on '..'
|
|
||||||
menu = [{ label: t('fileManager.actions.refresh'), action: () => loadDirectory(currentPath.value), disabled: !canPerformActions }];
|
|
||||||
}
|
}
|
||||||
|
const newMode = parseInt(newModeStr, 8);
|
||||||
contextMenuItems.value = menu;
|
changePermissions(item, newMode); // Use changePermissions from props.sftpManager
|
||||||
|
|
||||||
// Set initial position based on click event
|
|
||||||
contextMenuPosition.value = { x: event.clientX, y: event.clientY };
|
|
||||||
contextMenuVisible.value = true; // Make menu visible so we can measure it
|
|
||||||
|
|
||||||
// Use nextTick to allow the DOM to update and the menu to render
|
|
||||||
nextTick(() => {
|
|
||||||
if (contextMenuRef.value && contextMenuVisible.value) {
|
|
||||||
const menuElement = contextMenuRef.value;
|
|
||||||
const menuRect = menuElement.getBoundingClientRect(); // Get actual dimensions and position
|
|
||||||
const menuWidth = menuRect.width;
|
|
||||||
const menuHeight = menuRect.height;
|
|
||||||
|
|
||||||
let finalX = contextMenuPosition.value.x;
|
|
||||||
let finalY = contextMenuPosition.value.y;
|
|
||||||
|
|
||||||
// Adjust horizontally if needed
|
|
||||||
if (finalX + menuWidth > window.innerWidth) {
|
|
||||||
finalX = window.innerWidth - menuWidth - 5; // Adjust left
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Adjust vertically if needed (using actual height)
|
const handleNewFolderContextMenuClick = () => {
|
||||||
if (finalY + menuHeight > window.innerHeight) {
|
if (!props.wsDeps.isConnected.value) return; // 恢复使用 props.wsDeps
|
||||||
finalY = window.innerHeight - menuHeight - 5; // Adjust up
|
const folderName = prompt(t('fileManager.prompts.enterFolderName'));
|
||||||
|
if (folderName) {
|
||||||
|
if (fileList.value.some((item: FileListItem) => item.filename === folderName)) { // item 已有类型
|
||||||
|
alert(t('fileManager.errors.folderExists', { name: folderName }));
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
createDirectory(folderName); // Use createDirectory from props.sftpManager
|
||||||
// Ensure menu doesn't go off-screen top or left
|
|
||||||
finalX = Math.max(5, finalX); // Add small margin from left edge
|
|
||||||
finalY = Math.max(5, finalY); // Add small margin from top edge
|
|
||||||
|
|
||||||
// Update the position state if adjustments were made
|
|
||||||
if (finalX !== contextMenuPosition.value.x || finalY !== contextMenuPosition.value.y) {
|
|
||||||
console.log(`[FileManager ${props.sessionId}] Adjusting context menu position: (${contextMenuPosition.value.x}, ${contextMenuPosition.value.y}) -> (${finalX}, ${finalY})`);
|
|
||||||
contextMenuPosition.value = { x: finalX, y: finalY };
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Add global listener to hide menu *after* positioning
|
const handleNewFileContextMenuClick = () => {
|
||||||
document.removeEventListener('click', hideContextMenu, { capture: true });
|
if (!props.wsDeps.isConnected.value) return; // 恢复使用 props.wsDeps
|
||||||
document.addEventListener('click', hideContextMenu, { capture: true, once: true });
|
const fileName = prompt(t('fileManager.prompts.enterFileName'));
|
||||||
} else {
|
if (fileName) {
|
||||||
// Fallback listener if measurement fails
|
if (fileList.value.some((item: FileListItem) => item.filename === fileName)) { // item 已有类型
|
||||||
document.removeEventListener('click', hideContextMenu, { capture: true });
|
alert(t('fileManager.errors.fileExists', { name: fileName }));
|
||||||
document.addEventListener('click', hideContextMenu, { capture: true, once: true });
|
return;
|
||||||
}
|
}
|
||||||
|
createFile(fileName); // Use createFile from props.sftpManager
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- 文件上传触发器 (定义在此处,供 Composable 使用) ---
|
||||||
|
const triggerFileUpload = () => { fileInputRef.value?.click(); };
|
||||||
|
|
||||||
|
// --- 下载触发器 (定义在此处,供 Composable 使用) ---
|
||||||
|
const triggerDownload = (item: FileListItem) => { // item 已有类型
|
||||||
|
// 恢复使用 props.wsDeps.isConnected
|
||||||
|
if (!props.wsDeps.isConnected.value) {
|
||||||
|
alert(t('fileManager.errors.notConnected'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// connectionId might need to be passed differently, maybe via sftpManager or wsDeps
|
||||||
|
// 使用 props 传入的 dbConnectionId
|
||||||
|
const currentConnectionId = props.dbConnectionId; // <-- 使用 Prop
|
||||||
|
if (!currentConnectionId) {
|
||||||
|
console.error(`[FileManager ${props.sessionId}] Cannot download: Missing connection ID.`);
|
||||||
|
alert(t('fileManager.errors.missingConnectionId'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const downloadPath = joinPath(currentPath.value, item.filename); // Use joinPath from props
|
||||||
|
const downloadUrl = `/api/v1/sftp/download?connectionId=${currentConnectionId}&remotePath=${encodeURIComponent(downloadPath)}`;
|
||||||
|
console.log(`[FileManager ${props.sessionId}] Triggering download: ${downloadUrl}`);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = downloadUrl;
|
||||||
|
link.setAttribute('download', item.filename);
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// --- 上下文菜单逻辑 (使用 Composable) ---
|
||||||
|
const {
|
||||||
|
contextMenuVisible,
|
||||||
|
contextMenuPosition,
|
||||||
|
contextMenuItems,
|
||||||
|
contextMenuRef, // 获取 ref 以传递给子组件
|
||||||
|
showContextMenu, // 使用 Composable 提供的函数
|
||||||
|
hideContextMenu, // <-- 获取 hideContextMenu 函数
|
||||||
|
} = useFileManagerContextMenu({
|
||||||
|
selectedItems,
|
||||||
|
lastClickedIndex,
|
||||||
|
fileList, // 传递 sftpManager 的 fileList
|
||||||
|
currentPath, // 传递 sftpManager 的 currentPath
|
||||||
|
isConnected: props.wsDeps.isConnected, // 传递响应式引用
|
||||||
|
isSftpReady: props.wsDeps.isSftpReady, // 传递响应式引用
|
||||||
|
t, // 传递 i18n 的 t 函数
|
||||||
|
// --- 传递回调函数 ---
|
||||||
|
onRefresh: () => loadDirectory(currentPath.value),
|
||||||
|
onUpload: triggerFileUpload,
|
||||||
|
onDownload: triggerDownload,
|
||||||
|
onDelete: handleDeleteSelectedClick,
|
||||||
|
onRename: handleRenameContextMenuClick,
|
||||||
|
onChangePermissions: handleChangePermissionsContextMenuClick,
|
||||||
|
onNewFolder: handleNewFolderContextMenuClick,
|
||||||
|
onNewFile: handleNewFileContextMenuClick,
|
||||||
});
|
});
|
||||||
};
|
|
||||||
|
|
||||||
const hideContextMenu = () => {
|
|
||||||
if (!contextMenuVisible.value) return;
|
|
||||||
contextMenuVisible.value = false;
|
|
||||||
contextMenuItems.value = [];
|
|
||||||
contextTargetItem.value = null;
|
|
||||||
document.removeEventListener('click', hideContextMenu, { capture: true });
|
|
||||||
};
|
|
||||||
|
|
||||||
// --- 目录加载与导航 ---
|
// --- 目录加载与导航 ---
|
||||||
// loadDirectory is provided by props.sftpManager
|
// loadDirectory is provided by props.sftpManager
|
||||||
@@ -360,35 +374,6 @@ const handleItemClick = (event: MouseEvent, item: FileListItem) => { // item 已
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- 下载逻辑 ---
|
|
||||||
// triggerDownload 中的 item 参数已有类型
|
|
||||||
|
|
||||||
// --- 下载逻辑 ---
|
|
||||||
const triggerDownload = (item: FileListItem) => { // item 已有类型
|
|
||||||
// 恢复使用 props.wsDeps.isConnected
|
|
||||||
if (!props.wsDeps.isConnected.value) {
|
|
||||||
alert(t('fileManager.errors.notConnected'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// connectionId might need to be passed differently, maybe via sftpManager or wsDeps
|
|
||||||
// For now, keep using route.params as a fallback, but this is not ideal for multi-session
|
|
||||||
const currentConnectionId = route.params.connectionId as string; // TODO: Revisit this for multi-session
|
|
||||||
if (!currentConnectionId) {
|
|
||||||
console.error(`[FileManager ${props.sessionId}] Cannot download: Missing connection ID.`);
|
|
||||||
alert(t('fileManager.errors.missingConnectionId'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const downloadPath = joinPath(currentPath.value, item.filename); // Use joinPath from props
|
|
||||||
const downloadUrl = `/api/v1/sftp/download?connectionId=${currentConnectionId}&remotePath=${encodeURIComponent(downloadPath)}`;
|
|
||||||
console.log(`[FileManager ${props.sessionId}] Triggering download: ${downloadUrl}`);
|
|
||||||
const link = document.createElement('a');
|
|
||||||
link.href = downloadUrl;
|
|
||||||
link.setAttribute('download', item.filename);
|
|
||||||
document.body.appendChild(link);
|
|
||||||
link.click();
|
|
||||||
document.body.removeChild(link);
|
|
||||||
};
|
|
||||||
|
|
||||||
// --- 拖放上传逻辑 ---
|
// --- 拖放上传逻辑 ---
|
||||||
const handleDragEnter = (event: DragEvent) => {
|
const handleDragEnter = (event: DragEvent) => {
|
||||||
if (props.wsDeps.isConnected.value && event.dataTransfer?.types.includes('Files')) { // 恢复使用 props.wsDeps.isConnected
|
if (props.wsDeps.isConnected.value && event.dataTransfer?.types.includes('Files')) { // 恢复使用 props.wsDeps.isConnected
|
||||||
@@ -707,8 +692,7 @@ const handleDropOnRow = (targetItem: FileListItem, event: DragEvent) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
// --- 文件上传逻辑 ---
|
// --- 文件上传逻辑 (handleFileSelected 保持在此处) ---
|
||||||
const triggerFileUpload = () => { fileInputRef.value?.click(); };
|
|
||||||
const handleFileSelected = (event: Event) => {
|
const handleFileSelected = (event: Event) => {
|
||||||
const input = event.target as HTMLInputElement;
|
const input = event.target as HTMLInputElement;
|
||||||
// 恢复使用 props.wsDeps.isConnected
|
// 恢复使用 props.wsDeps.isConnected
|
||||||
@@ -717,75 +701,6 @@ const handleFileSelected = (event: Event) => {
|
|||||||
input.value = '';
|
input.value = '';
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- SFTP 操作处理函数 ---
|
|
||||||
// 恢复使用 props.wsDeps.isConnected 和 props.sftpManager 的方法
|
|
||||||
const handleDeleteSelectedClick = () => {
|
|
||||||
if (!props.wsDeps.isConnected.value || selectedItems.value.size === 0) return; // 恢复使用 props.wsDeps
|
|
||||||
const itemsToDelete = Array.from(selectedItems.value)
|
|
||||||
.map(filename => fileList.value.find((f: FileListItem) => f.filename === filename)) // f 已有类型
|
|
||||||
.filter((item): item is FileListItem => item !== undefined);
|
|
||||||
if (itemsToDelete.length === 0) return;
|
|
||||||
|
|
||||||
const names = itemsToDelete.map(i => i.filename).join(', ');
|
|
||||||
const confirmMsg = itemsToDelete.length > 1
|
|
||||||
? t('fileManager.prompts.confirmDeleteMultiple', { count: itemsToDelete.length, names: names })
|
|
||||||
: itemsToDelete[0].attrs.isDirectory
|
|
||||||
? t('fileManager.prompts.confirmDeleteFolder', { name: itemsToDelete[0].filename })
|
|
||||||
: t('fileManager.prompts.confirmDeleteFile', { name: itemsToDelete[0].filename });
|
|
||||||
|
|
||||||
if (confirm(confirmMsg)) {
|
|
||||||
deleteItems(itemsToDelete); // Use deleteItems from props
|
|
||||||
selectedItems.value.clear();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRenameContextMenuClick = (item: FileListItem) => { // item 已有类型
|
|
||||||
if (!props.wsDeps.isConnected.value || !item) return; // 恢复使用 props.wsDeps
|
|
||||||
const newName = prompt(t('fileManager.prompts.enterNewName', { oldName: item.filename }), item.filename);
|
|
||||||
if (newName && newName !== item.filename) {
|
|
||||||
renameItem(item, newName); // Use renameItem from props.sftpManager
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleChangePermissionsContextMenuClick = (item: FileListItem) => { // item 已有类型
|
|
||||||
if (!props.wsDeps.isConnected.value || !item) return; // 恢复使用 props.wsDeps
|
|
||||||
const currentModeOctal = (item.attrs.mode & 0o777).toString(8).padStart(3, '0');
|
|
||||||
const newModeStr = prompt(t('fileManager.prompts.enterNewPermissions', { name: item.filename, currentMode: currentModeOctal }), currentModeOctal);
|
|
||||||
if (newModeStr) {
|
|
||||||
if (!/^[0-7]{3,4}$/.test(newModeStr)) {
|
|
||||||
alert(t('fileManager.errors.invalidPermissionsFormat'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const newMode = parseInt(newModeStr, 8);
|
|
||||||
changePermissions(item, newMode); // Use changePermissions from props.sftpManager
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleNewFolderContextMenuClick = () => {
|
|
||||||
if (!props.wsDeps.isConnected.value) return; // 恢复使用 props.wsDeps
|
|
||||||
const folderName = prompt(t('fileManager.prompts.enterFolderName'));
|
|
||||||
if (folderName) {
|
|
||||||
if (fileList.value.some((item: FileListItem) => item.filename === folderName)) { // item 已有类型
|
|
||||||
alert(t('fileManager.errors.folderExists', { name: folderName }));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
createDirectory(folderName); // Use createDirectory from props.sftpManager
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleNewFileContextMenuClick = () => {
|
|
||||||
if (!props.wsDeps.isConnected.value) return; // 恢复使用 props.wsDeps
|
|
||||||
const fileName = prompt(t('fileManager.prompts.enterFileName'));
|
|
||||||
if (fileName) {
|
|
||||||
if (fileList.value.some((item: FileListItem) => item.filename === fileName)) { // item 已有类型
|
|
||||||
alert(t('fileManager.errors.fileExists', { name: fileName }));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
createFile(fileName); // Use createFile from props.sftpManager
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
// --- 排序逻辑 ---
|
// --- 排序逻辑 ---
|
||||||
// Uses fileList from props.sftpManager
|
// Uses fileList from props.sftpManager
|
||||||
const sortedFileList = computed(() => {
|
const sortedFileList = computed(() => {
|
||||||
@@ -999,8 +914,8 @@ onBeforeUnmount(() => {
|
|||||||
// 如果其他 composables 也提供了 cleanup 函数,在此处调用
|
// 如果其他 composables 也提供了 cleanup 函数,在此处调用
|
||||||
// cleanupUploader?.();
|
// cleanupUploader?.();
|
||||||
// cleanupEditor?.();
|
// cleanupEditor?.();
|
||||||
// 移除上下文菜单监听器
|
// 移除上下文菜单监听器 (现在由 Composable 处理)
|
||||||
document.removeEventListener('click', hideContextMenu, { capture: true });
|
// document.removeEventListener('click', hideContextMenu, { capture: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- 列宽调整逻辑 (保持不变) ---
|
// --- 列宽调整逻辑 (保持不变) ---
|
||||||
@@ -1259,7 +1174,7 @@ const handleWheel = (event: WheelEvent) => {
|
|||||||
</tbody>
|
</tbody>
|
||||||
|
|
||||||
<!-- File List State -->
|
<!-- File List State -->
|
||||||
<tbody v-else @contextmenu.prevent="showContextMenu($event)">
|
<tbody v-else @contextmenu.prevent="showContextMenu($event)"> <!-- 使用 Composable 的 showContextMenu -->
|
||||||
<!-- '..' 条目 -->
|
<!-- '..' 条目 -->
|
||||||
<tr v-if="currentPath !== '/'"
|
<tr v-if="currentPath !== '/'"
|
||||||
class="clickable file-row folder-row"
|
class="clickable file-row folder-row"
|
||||||
@@ -1313,20 +1228,13 @@ const handleWheel = (event: WheelEvent) => {
|
|||||||
<!-- 使用 FileUploadPopup 组件 -->
|
<!-- 使用 FileUploadPopup 组件 -->
|
||||||
<FileUploadPopup :uploads="uploads" @cancel-upload="cancelUpload" />
|
<FileUploadPopup :uploads="uploads" @cancel-upload="cancelUpload" />
|
||||||
|
|
||||||
<div ref="contextMenuRef"
|
<FileManagerContextMenu
|
||||||
v-if="contextMenuVisible"
|
ref="contextMenuRef"
|
||||||
class="context-menu"
|
:is-visible="contextMenuVisible"
|
||||||
:style="{ top: `${contextMenuPosition.y}px`, left: `${contextMenuPosition.x}px` }"
|
:position="contextMenuPosition"
|
||||||
@click.stop> <!-- Keep @click.stop to prevent clicks inside menu from closing it immediately -->
|
:items="contextMenuItems"
|
||||||
<ul>
|
@close-request="hideContextMenu"
|
||||||
<li v-for="(menuItem, index) in contextMenuItems"
|
/>
|
||||||
:key="index"
|
|
||||||
@click.stop="menuItem.action(); hideContextMenu()"
|
|
||||||
:class="{ disabled: menuItem.disabled }">
|
|
||||||
{{ menuItem.label }}
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- FileEditorOverlay 不再在此处渲染 -->
|
<!-- FileEditorOverlay 不再在此处渲染 -->
|
||||||
<!--
|
<!--
|
||||||
@@ -1683,11 +1591,12 @@ td:nth-child(5) { /* Modified */
|
|||||||
font-size: calc(var(--base-font-size) * 0.9 * max(0.85, var(--row-size-multiplier) * 0.5 + 0.5));
|
font-size: calc(var(--base-font-size) * 0.9 * max(0.85, var(--row-size-multiplier) * 0.5 + 0.5));
|
||||||
}
|
}
|
||||||
|
|
||||||
.context-menu { position: fixed; background-color: var(--app-bg-color); border: 1px solid var(--border-color); box-shadow: 2px 2px 5px rgba(0,0,0,0.2); z-index: 1002; min-width: 150px; border-radius: 4px; } /* Add radius */
|
/* 移除旧的上下文菜单样式 */
|
||||||
.context-menu ul { list-style: none; padding: var(--base-margin) 0; margin: 0; }
|
/* .context-menu { ... } */
|
||||||
.context-menu li { padding: 0.6rem var(--base-padding); cursor: pointer; color: var(--text-color); font-size: 0.9em; display: flex; align-items: center; } /* Adjust padding/font */
|
/* .context-menu ul { ... } */
|
||||||
.context-menu li:hover { background-color: var(--header-bg-color); } /* Use theme variable */
|
/* .context-menu li { ... } */
|
||||||
.context-menu li.disabled { color: var(--text-color-secondary); cursor: not-allowed; background-color: var(--app-bg-color); opacity: 0.6; } /* Use theme variables */
|
/* .context-menu li:hover { ... } */
|
||||||
|
/* .context-menu li.disabled { ... } */
|
||||||
|
|
||||||
/* Resizer Handle Styles */
|
/* Resizer Handle Styles */
|
||||||
.resizer {
|
.resizer {
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { type PropType } from 'vue';
|
||||||
|
import type { ContextMenuItem } from '../composables/file-manager/useFileManagerContextMenu'; // 导入菜单项类型
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
isVisible: {
|
||||||
|
type: Boolean,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
position: {
|
||||||
|
type: Object as PropType<{ x: number; y: number }>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
items: {
|
||||||
|
type: Array as PropType<ContextMenuItem[]>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 隐藏菜单的逻辑由 useFileManagerContextMenu 中的全局点击监听器处理
|
||||||
|
// 但我们仍然需要触发菜单项的 action,并通知父组件关闭菜单
|
||||||
|
const emit = defineEmits(['item-click', 'close-request']); // 添加 close-request
|
||||||
|
|
||||||
|
const handleItemClick = (item: ContextMenuItem) => {
|
||||||
|
if (!item.disabled) {
|
||||||
|
item.action(); // 直接执行 action
|
||||||
|
emit('close-request'); // <-- 发出关闭请求
|
||||||
|
// 不需要 emit('item-click', item) 了
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-if="isVisible"
|
||||||
|
class="context-menu"
|
||||||
|
:style="{ top: `${position.y}px`, left: `${position.x}px` }"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
<ul>
|
||||||
|
<li
|
||||||
|
v-for="(menuItem, index) in items"
|
||||||
|
:key="index"
|
||||||
|
@click.stop="handleItemClick(menuItem)"
|
||||||
|
:class="{ disabled: menuItem.disabled }"
|
||||||
|
>
|
||||||
|
{{ menuItem.label }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* 从 FileManager.vue 移动过来的样式 */
|
||||||
|
.context-menu {
|
||||||
|
position: fixed;
|
||||||
|
background-color: var(--app-bg-color);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
box-shadow: 2px 2px 5px rgba(0,0,0,0.2);
|
||||||
|
z-index: 1002;
|
||||||
|
min-width: 150px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.context-menu ul {
|
||||||
|
list-style: none;
|
||||||
|
padding: var(--base-margin, 0.5rem) 0; /* 使用 CSS 变量 */
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.context-menu li {
|
||||||
|
padding: 0.6rem var(--base-padding, 1rem); /* 使用 CSS 变量 */
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-color);
|
||||||
|
font-size: 0.9em;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
transition: background-color 0.15s ease; /* 添加过渡效果 */
|
||||||
|
}
|
||||||
|
.context-menu li:hover:not(.disabled) { /* 仅在非禁用时应用悬停效果 */
|
||||||
|
background-color: var(--header-bg-color);
|
||||||
|
}
|
||||||
|
.context-menu li.disabled {
|
||||||
|
color: var(--text-color-secondary);
|
||||||
|
cursor: not-allowed;
|
||||||
|
background-color: var(--app-bg-color); /* 确保禁用项背景与菜单一致 */
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import { ref, nextTick, type Ref, type ComponentPublicInstance } from 'vue'; // 导入 ComponentPublicInstance
|
||||||
|
import type { FileListItem } from '../../types/sftp.types'; // 修正路径
|
||||||
|
import { type useI18n } from 'vue-i18n'; // 导入 useI18n 以获取 t 的类型
|
||||||
|
import type FileManagerContextMenu from '../../components/FileManagerContextMenu.vue'; // <-- 导入组件类型
|
||||||
|
|
||||||
|
// 定义菜单项类型 (可以根据需要扩展)
|
||||||
|
export interface ContextMenuItem {
|
||||||
|
label: string;
|
||||||
|
action: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 定义 Composable 的输入参数类型
|
||||||
|
export interface UseFileManagerContextMenuOptions {
|
||||||
|
selectedItems: Ref<Set<string>>;
|
||||||
|
lastClickedIndex: Ref<number>;
|
||||||
|
fileList: Ref<Readonly<FileListItem[]>>; // 使用 Readonly 避免直接修改
|
||||||
|
currentPath: Ref<string>;
|
||||||
|
isConnected: Ref<boolean>;
|
||||||
|
isSftpReady: Ref<boolean>;
|
||||||
|
t: ReturnType<typeof useI18n>['t']; // 使用 useI18n 获取 t 的类型
|
||||||
|
// --- 回调函数 ---
|
||||||
|
onRefresh: () => void;
|
||||||
|
onUpload: () => void;
|
||||||
|
onDownload: (item: FileListItem) => void;
|
||||||
|
onDelete: () => void; // 删除操作现在由外部处理
|
||||||
|
onRename: (item: FileListItem) => void;
|
||||||
|
onChangePermissions: (item: FileListItem) => void;
|
||||||
|
onNewFolder: () => void;
|
||||||
|
onNewFile: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFileManagerContextMenu(options: UseFileManagerContextMenuOptions) {
|
||||||
|
const {
|
||||||
|
selectedItems,
|
||||||
|
lastClickedIndex,
|
||||||
|
fileList,
|
||||||
|
currentPath,
|
||||||
|
isConnected,
|
||||||
|
isSftpReady,
|
||||||
|
t,
|
||||||
|
onRefresh,
|
||||||
|
onUpload,
|
||||||
|
onDownload,
|
||||||
|
onDelete,
|
||||||
|
onRename,
|
||||||
|
onChangePermissions,
|
||||||
|
onNewFolder,
|
||||||
|
onNewFile,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const contextMenuVisible = ref(false);
|
||||||
|
const contextMenuPosition = ref({ x: 0, y: 0 });
|
||||||
|
const contextMenuItems = ref<ContextMenuItem[]>([]);
|
||||||
|
const contextTargetItem = ref<FileListItem | null>(null);
|
||||||
|
// 修正 Ref 类型为组件实例类型
|
||||||
|
const contextMenuRef = ref<InstanceType<typeof FileManagerContextMenu> | null>(null);
|
||||||
|
|
||||||
|
const showContextMenu = (event: MouseEvent, item?: FileListItem) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const targetItem = item || null;
|
||||||
|
|
||||||
|
// Adjust selection based on right-click target (逻辑保持不变)
|
||||||
|
if (targetItem && !event.ctrlKey && !event.metaKey && !event.shiftKey && !selectedItems.value.has(targetItem.filename)) {
|
||||||
|
selectedItems.value.clear();
|
||||||
|
selectedItems.value.add(targetItem.filename);
|
||||||
|
// 使用传入的 fileList ref
|
||||||
|
const index = fileList.value.findIndex((f: FileListItem) => f.filename === targetItem.filename); // 添加类型
|
||||||
|
lastClickedIndex.value = index;
|
||||||
|
} else if (!targetItem) {
|
||||||
|
selectedItems.value.clear();
|
||||||
|
lastClickedIndex.value = -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
contextTargetItem.value = targetItem;
|
||||||
|
let menu: ContextMenuItem[] = [];
|
||||||
|
const selectionSize = selectedItems.value.size;
|
||||||
|
const clickedItemIsSelected = targetItem && selectedItems.value.has(targetItem.filename);
|
||||||
|
const canPerformActions = isConnected.value && isSftpReady.value;
|
||||||
|
|
||||||
|
// Build context menu items (使用传入的回调)
|
||||||
|
if (selectionSize > 1 && clickedItemIsSelected) {
|
||||||
|
// Multi-selection menu
|
||||||
|
menu = [
|
||||||
|
{ label: t('fileManager.actions.deleteMultiple', { count: selectionSize }), action: onDelete, disabled: !canPerformActions },
|
||||||
|
{ label: t('fileManager.actions.refresh'), action: onRefresh, disabled: !canPerformActions },
|
||||||
|
];
|
||||||
|
} else if (targetItem && targetItem.filename !== '..') {
|
||||||
|
// Single item (not '..') menu
|
||||||
|
menu = [
|
||||||
|
{ label: t('fileManager.actions.newFolder'), action: onNewFolder, disabled: !canPerformActions },
|
||||||
|
{ label: t('fileManager.actions.newFile'), action: onNewFile, disabled: !canPerformActions },
|
||||||
|
{ label: t('fileManager.actions.upload'), action: onUpload, disabled: !canPerformActions },
|
||||||
|
{ label: t('fileManager.actions.refresh'), action: onRefresh, disabled: !canPerformActions },
|
||||||
|
];
|
||||||
|
if (targetItem.attrs.isFile) {
|
||||||
|
menu.splice(1, 0, { label: t('fileManager.actions.download', { name: targetItem.filename }), action: () => onDownload(targetItem), disabled: !canPerformActions });
|
||||||
|
}
|
||||||
|
menu.push({ label: t('fileManager.actions.delete'), action: onDelete, disabled: !canPerformActions });
|
||||||
|
menu.push({ label: t('fileManager.actions.rename'), action: () => onRename(targetItem), disabled: !canPerformActions });
|
||||||
|
menu.push({ label: t('fileManager.actions.changePermissions'), action: () => onChangePermissions(targetItem), disabled: !canPerformActions });
|
||||||
|
|
||||||
|
} else if (!targetItem) {
|
||||||
|
// Right-click on empty space menu
|
||||||
|
menu = [
|
||||||
|
{ label: t('fileManager.actions.newFolder'), action: onNewFolder, disabled: !canPerformActions },
|
||||||
|
{ label: t('fileManager.actions.newFile'), action: onNewFile, disabled: !canPerformActions },
|
||||||
|
{ label: t('fileManager.actions.upload'), action: onUpload, disabled: !canPerformActions },
|
||||||
|
{ label: t('fileManager.actions.refresh'), action: onRefresh, disabled: !canPerformActions },
|
||||||
|
];
|
||||||
|
} else { // Clicked on '..'
|
||||||
|
menu = [{ label: t('fileManager.actions.refresh'), action: onRefresh, disabled: !canPerformActions }];
|
||||||
|
}
|
||||||
|
|
||||||
|
contextMenuItems.value = menu;
|
||||||
|
|
||||||
|
// Set initial position based on click event
|
||||||
|
contextMenuPosition.value = { x: event.clientX, y: event.clientY };
|
||||||
|
contextMenuVisible.value = true; // Make menu visible so we can measure it
|
||||||
|
|
||||||
|
// Use nextTick to allow the DOM to update and the menu to render
|
||||||
|
nextTick(() => {
|
||||||
|
// Access the DOM element via $el from the component instance ref
|
||||||
|
const menuElement = contextMenuRef.value?.$el as HTMLDivElement | undefined;
|
||||||
|
if (menuElement && contextMenuVisible.value) {
|
||||||
|
// const menuElement = contextMenuRef.value; // Old way
|
||||||
|
const menuRect = menuElement.getBoundingClientRect(); // Now should work
|
||||||
|
const menuWidth = menuRect.width;
|
||||||
|
const menuHeight = menuRect.height;
|
||||||
|
|
||||||
|
let finalX = contextMenuPosition.value.x;
|
||||||
|
let finalY = contextMenuPosition.value.y;
|
||||||
|
|
||||||
|
// Adjust horizontally if needed
|
||||||
|
if (finalX + menuWidth > window.innerWidth) {
|
||||||
|
finalX = window.innerWidth - menuWidth - 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adjust vertically if needed
|
||||||
|
if (finalY + menuHeight > window.innerHeight) {
|
||||||
|
finalY = window.innerHeight - menuHeight - 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure menu doesn't go off-screen top or left
|
||||||
|
finalX = Math.max(5, finalX);
|
||||||
|
finalY = Math.max(5, finalY);
|
||||||
|
|
||||||
|
// Update the position state if adjustments were made
|
||||||
|
if (finalX !== contextMenuPosition.value.x || finalY !== contextMenuPosition.value.y) {
|
||||||
|
console.log(`[useFileManagerContextMenu] Adjusting context menu position: (${contextMenuPosition.value.x}, ${contextMenuPosition.value.y}) -> (${finalX}, ${finalY})`);
|
||||||
|
contextMenuPosition.value = { x: finalX, y: finalY };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add global listener to hide menu *after* positioning
|
||||||
|
document.removeEventListener('click', hideContextMenu, { capture: true });
|
||||||
|
document.addEventListener('click', hideContextMenu, { capture: true, once: true });
|
||||||
|
} else {
|
||||||
|
// Fallback listener if measurement fails
|
||||||
|
document.removeEventListener('click', hideContextMenu, { capture: true });
|
||||||
|
document.addEventListener('click', hideContextMenu, { capture: true, once: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const hideContextMenu = () => {
|
||||||
|
if (!contextMenuVisible.value) return;
|
||||||
|
contextMenuVisible.value = false;
|
||||||
|
contextMenuItems.value = [];
|
||||||
|
contextTargetItem.value = null; // 清理目标项
|
||||||
|
document.removeEventListener('click', hideContextMenu, { capture: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 返回需要暴露的状态和方法
|
||||||
|
return {
|
||||||
|
contextMenuVisible,
|
||||||
|
contextMenuPosition,
|
||||||
|
contextMenuItems,
|
||||||
|
contextTargetItem, // 可能外部需要知道右键点击了哪个项
|
||||||
|
contextMenuRef,
|
||||||
|
showContextMenu,
|
||||||
|
hideContextMenu,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user