Files
nexus-terminal/packages/frontend/src/components/FileManagerContextMenu.vue
T
Baobhan Sith c91e44cb32 update
2025-05-16 16:43:56 +08:00

256 lines
9.5 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { ref, watch, nextTick, type PropType, onUnmounted } from 'vue'; // Added watch, nextTick
import { useI18n } from 'vue-i18n';
import SendFilesModal from './SendFilesModal.vue';
import type { ContextMenuItem } from '../composables/file-manager/useFileManagerContextMenu';
import type { FileListItem } from '../types/sftp.types'; // Import FileListItem
import { useDeviceDetection } from '../composables/useDeviceDetection';
const props = 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,
},
activeContextItem: { // Item that was right-clicked
type: Object as PropType<FileListItem | null>,
default: null,
},
currentDirectoryPath: { // Current path of the file manager
type: String,
required: true,
}
});
const { isMobile } = useDeviceDetection();
const { t } = useI18n();
const showSendFilesModal = ref(false);
// Update the type for itemsToSendData
const itemsToSendData = ref<{ name: string; path: string; type: 'file' | 'directory' }[]>([]);
// +++ 新增:用于菜单位置调整的 ref +++
const contextMenuRef = ref<HTMLDivElement | null>(null);
const computedRenderPosition = ref({ x: props.position.x, y: props.position.y });
watch(
[() => props.isVisible, () => props.position],
([newIsVisible, newPosition], [oldIsVisible, oldPosition]) => {
if (newIsVisible) {
// 仅当菜单从不可见变为可见,或当菜单可见时其初始位置改变时,才进行位置计算
// oldPosition 可能为 undefined,所以需要检查
const positionChangedWhileVisible = oldIsVisible && oldPosition && (newPosition.x !== oldPosition.x || newPosition.y !== oldPosition.y);
if (!oldIsVisible || positionChangedWhileVisible) {
computedRenderPosition.value = { ...newPosition }; // 设置初始位置为当前点击位置
nextTick(() => {
if (contextMenuRef.value) {
const menuElement = contextMenuRef.value;
const menuRect = menuElement.getBoundingClientRect();
// 如果菜单没有实际尺寸 (例如,内容为空或未渲染),则不进行调整
if (menuRect.width === 0 && menuRect.height === 0) {
// console.debug("[FileManagerContextMenu] Menu dimensions are zero, sticking to initial position.");
return;
}
let finalX = newPosition.x;
let finalY = newPosition.y;
const menuWidth = menuRect.width;
const menuHeight = menuRect.height;
const margin = 10; // 距离窗口边缘的最小间距
// console.debug(`[FileManagerContextMenu] Initial pos: (${finalX}, ${finalY}), Menu size: (${menuWidth}x${menuHeight}), Window: (${window.innerWidth}x${window.innerHeight})`);
// 调整水平位置,防止溢出右侧
if (finalX + menuWidth > window.innerWidth) {
finalX = window.innerWidth - menuWidth - margin;
}
// 调整垂直位置,防止溢出底部
if (finalY + menuHeight > window.innerHeight) {
finalY = window.innerHeight - menuHeight - margin;
}
// 确保菜单不超出屏幕左上角
finalX = Math.max(margin, finalX);
finalY = Math.max(margin, finalY);
// console.debug(`[FileManagerContextMenu] Adjusted pos: (${finalX}, ${finalY})`);
computedRenderPosition.value = { x: finalX, y: finalY };
}
});
}
} else {
// 如果菜单不可见,确保 computedRenderPosition 与 props.position 同步,为下次显示做准备
computedRenderPosition.value = { ...newPosition };
}
},
{ deep: true, immediate: true } // immediate 确保初始状态(如果isVisible为true)也设置正确
);
// 隐藏菜单的逻辑由 useFileManagerContextMenu 中的全局点击监听器处理
// 但我们仍然需要触发菜单项的 action,并通知父组件关闭菜单
const emit = defineEmits(['item-click', 'close-request']); // 添加 close-request
const handleItemClick = (item: ContextMenuItem) => {
if (item.action) {
item.action(); // 只有当 action 存在时才执行
emit('close-request'); // <-- 发出关闭请求
}
};
const handleSendToClick = () => {
if (props.activeContextItem) {
const item = props.activeContextItem;
const type = item.attrs.isDirectory ? 'directory' : 'file';
// Ensure path is constructed correctly, assuming currentDirectoryPath ends with / or is root '/'
// And filename does not start with /
let fullPath = props.currentDirectoryPath;
if (!fullPath.endsWith('/')) {
fullPath += '/';
}
fullPath += item.filename;
// Normalize path to remove any double slashes, except for protocol like sftp://
fullPath = fullPath.replace(/(?<!:)\/\//g, '/');
itemsToSendData.value = [{
name: item.filename,
path: fullPath,
type: type,
}];
} else {
// No specific item clicked, perhaps send selected items? Or disable "Send To"?
// For now, sending an empty array if no activeContextItem.
// This scenario should ideally be handled by disabling the "Send to..." option
// if no item is targeted or no selection is made that can be sent.
itemsToSendData.value = [];
}
showSendFilesModal.value = true;
emit('close-request');
};
const handleFilesSent = (payload: any) => {
console.log('Files to send (from FileManagerContextMenu):', payload);
// 实际发送逻辑可以后续添加或委派
};
// 管理二级菜单的展开状态
const expandedSubmenu = ref<string | null>(null);
let closeTimeout: NodeJS.Timeout | null = null;
const showSubmenu = (label: string) => {
if (closeTimeout) {
clearTimeout(closeTimeout);
closeTimeout = null;
}
expandedSubmenu.value = label;
};
const hideSubmenu = () => {
closeTimeout = setTimeout(() => {
expandedSubmenu.value = null;
closeTimeout = null;
}, 300); // 延迟300ms关闭
};
onUnmounted(() => {
if (closeTimeout) {
clearTimeout(closeTimeout);
}
});
</script>
<template>
<div
ref="contextMenuRef"
v-if="isVisible"
class="fixed bg-background border border-border shadow-lg rounded-md z-[1002] min-w-[150px]"
:style="{ top: `${computedRenderPosition.y}px`, left: `${computedRenderPosition.x}px` }"
@click.stop
>
<ul class="list-none p-1 m-0">
<template v-for="(menuItem, index) in items" :key="index">
<li v-if="menuItem.separator" class="border-t border-border/50 my-1 mx-1"></li>
<!-- 如果是移动设备且有子菜单则平铺子菜单 -->
<template v-else-if="isMobile && menuItem.submenu && menuItem.submenu.length > 0">
<li
v-for="(subItem, subIndex) in menuItem.submenu"
:key="`${index}-${subIndex}`"
@click.stop="handleItemClick(subItem)"
:class="[
'px-4 py-1.5 cursor-pointer text-foreground text-sm flex items-center transition-colors duration-150 rounded mx-1',
'hover:bg-primary/10 hover:text-primary' // 始终应用可点击样式
]"
>
{{ subItem.label }}
</li>
</template>
<!-- 否则按原有逻辑渲染一级菜单或带子菜单的一级菜单 -->
<li
v-else-if="!menuItem.submenu"
@click.stop="handleItemClick(menuItem)"
:class="[
'px-4 py-1.5 cursor-pointer text-foreground text-sm flex items-center transition-colors duration-150 rounded mx-1',
'hover:bg-primary/10 hover:text-primary' // 始终应用可点击样式
]"
>
{{ menuItem.label }}
</li>
<li
v-if="menuItem.submenu && !isMobile"
class="px-4 py-1.5 text-foreground text-sm flex items-center justify-between transition-colors duration-150 rounded mx-1 hover:bg-primary/10 hover:text-primary relative"
@mouseenter="showSubmenu(menuItem.label)"
@mouseleave="hideSubmenu()"
>
{{ menuItem.label }}
<span class="ml-2"></span>
<ul
v-if="expandedSubmenu === menuItem.label"
class="absolute left-full top-0 mt-0 ml-1 bg-background border border-border shadow-lg rounded-md z-[1003] min-w-[150px] list-none p-1"
@mouseenter="showSubmenu(menuItem.label)"
@mouseleave="hideSubmenu()"
>
<li
v-for="(subItem, subIndex) in menuItem.submenu"
:key="subIndex"
@click.stop="handleItemClick(subItem)"
:class="[
'px-4 py-1.5 cursor-pointer text-foreground text-sm flex items-center transition-colors duration-150 rounded mx-1',
'hover:bg-primary/10 hover:text-primary' // 始终应用可点击样式
]"
>
{{ subItem.label }}
</li>
</ul>
</li>
</template>
<li
@click.stop="handleSendToClick"
:class="[
'px-4 py-1.5 cursor-pointer text-foreground text-sm flex items-center transition-colors duration-150 rounded mx-1',
'hover:bg-primary/10 hover:text-primary'
]"
>
{{ t('fileManager.contextMenu.sendTo', 'Send to...') }}
</li>
</ul>
</div>
<SendFilesModal
v-model:visible="showSendFilesModal"
:items-to-send="itemsToSendData"
@send="handleFilesSent"
/>
</template>