This commit is contained in:
Baobhan Sith
2025-04-21 19:33:28 +08:00
parent 4df9a9a319
commit 23bc3f4b3d
3 changed files with 416 additions and 237 deletions
+134 -225
View File
@@ -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,
};
}