This commit is contained in:
Baobhan Sith
2025-04-29 19:37:01 +08:00
parent 03d3df88e9
commit 5e4e174c27
7 changed files with 586 additions and 11 deletions
@@ -10,6 +10,13 @@ export interface ContextMenuItem {
disabled?: boolean;
}
// 定义剪贴板状态类型
export interface ClipboardState {
hasContent: boolean;
operation?: 'copy' | 'cut';
// 可以添加 sourcePaths: string[] 等更多信息,但对于禁用/启用粘贴,hasContent 就够了
}
// 定义 Composable 的输入参数类型
export interface UseFileManagerContextMenuOptions {
selectedItems: Ref<Set<string>>;
@@ -18,6 +25,7 @@ export interface UseFileManagerContextMenuOptions {
currentPath: Ref<string>;
isConnected: Ref<boolean>;
isSftpReady: Ref<boolean>;
clipboardState: Ref<Readonly<ClipboardState>>; // +++ 新增:剪贴板状态 +++
t: ReturnType<typeof useI18n>['t']; // 使用 useI18n 获取 t 的类型
// --- 回调函数 ---
onRefresh: () => void;
@@ -28,6 +36,9 @@ export interface UseFileManagerContextMenuOptions {
onChangePermissions: (item: FileListItem) => void;
onNewFolder: () => void;
onNewFile: () => void;
onCopy: () => void; // +++ 新增:复制回调 +++
onCut: () => void; // +++ 新增:剪切回调 +++
onPaste: () => void; // +++ 新增:粘贴回调 +++
}
export function useFileManagerContextMenu(options: UseFileManagerContextMenuOptions) {
@@ -38,6 +49,7 @@ export function useFileManagerContextMenu(options: UseFileManagerContextMenuOpti
currentPath,
isConnected,
isSftpReady,
clipboardState, // +++ 解构剪贴板状态 +++
t,
onRefresh,
onUpload,
@@ -47,6 +59,9 @@ export function useFileManagerContextMenu(options: UseFileManagerContextMenuOpti
onChangePermissions,
onNewFolder,
onNewFile,
onCopy, // +++ 解构复制回调 +++
onCut, // +++ 解构剪切回调 +++
onPaste, // +++ 解构粘贴回调 +++
} = options;
const contextMenuVisible = ref(false);
@@ -77,24 +92,37 @@ export function useFileManagerContextMenu(options: UseFileManagerContextMenuOpti
const selectionSize = selectedItems.value.size;
const clickedItemIsSelected = targetItem && selectedItems.value.has(targetItem.filename);
const canPerformActions = isConnected.value && isSftpReady.value;
const hasClipboardContent = clipboardState.value.hasContent; // +++ 获取剪贴板状态 +++
// Build context menu items (使用传入的回调)
if (selectionSize > 1 && clickedItemIsSelected) {
// Multi-selection menu
menu = [
// +++ 添加复制/剪切 +++
{ label: t('fileManager.actions.copy'), action: onCopy, disabled: !canPerformActions },
{ label: t('fileManager.actions.cut'), action: onCut, disabled: !canPerformActions },
{ 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.copy'), action: onCopy, disabled: !canPerformActions },
{ label: t('fileManager.actions.cut'), action: onCut, disabled: !canPerformActions },
// --- 分隔符 (视觉上,实际由 CSS 处理) ---
// { label: '---', action: () => {}, disabled: true },
{ 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.splice(3, 0, { label: t('fileManager.actions.download', { name: targetItem.filename }), action: () => onDownload(targetItem), disabled: !canPerformActions }); // 调整插入位置
}
// +++ 如果目标是文件夹,添加粘贴 +++
if (targetItem.attrs.isDirectory) {
menu.splice(3, 0, { label: t('fileManager.actions.paste'), action: onPaste, disabled: !canPerformActions || !hasClipboardContent }); // 调整插入位置
}
menu.push({ label: t('fileManager.actions.delete'), action: onDelete, disabled: !canPerformActions });
menu.push({ label: t('fileManager.actions.rename'), action: () => onRename(targetItem), disabled: !canPerformActions });
@@ -106,10 +134,16 @@ export function useFileManagerContextMenu(options: UseFileManagerContextMenuOpti
{ 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.paste'), action: onPaste, disabled: !canPerformActions || !hasClipboardContent },
{ label: t('fileManager.actions.refresh'), action: onRefresh, disabled: !canPerformActions },
];
} else { // Clicked on '..'
menu = [{ label: t('fileManager.actions.refresh'), action: onRefresh, disabled: !canPerformActions }];
menu = [
// +++ 添加粘贴 (可以粘贴到上级目录) +++
{ label: t('fileManager.actions.paste'), action: onPaste, disabled: !canPerformActions || !hasClipboardContent },
{ label: t('fileManager.actions.refresh'), action: onRefresh, disabled: !canPerformActions }
];
}
contextMenuItems.value = menu;
@@ -406,6 +406,48 @@ export function createSftpActionsManager(
});
};
// +++ 新增:复制项目 +++
const copyItems = (sourcePaths: string[], destinationDir: string) => {
if (!isSftpReady.value) {
uiNotificationsStore.showError(t('fileManager.errors.sftpNotReady'), { timeout: 5000 });
console.warn(`[SFTP ${instanceSessionId}] 尝试复制项目但 SFTP 未就绪。`);
return;
}
if (sourcePaths.length === 0) return;
const requestId = generateRequestId();
sendMessage({
type: 'sftp:copy',
requestId: requestId,
payload: { sources: sourcePaths, destination: destinationDir }
});
console.log(`[SFTP ${instanceSessionId}] 发送 sftp:copy 请求 (ID: ${requestId}) Sources: ${sourcePaths.join(', ')}, Dest: ${destinationDir}`);
// 可选:显示一个“正在复制...”的通知
};
// +++ 新增:移动项目 +++
const moveItems = (sourcePaths: string[], destinationDir: string) => {
if (!isSftpReady.value) {
uiNotificationsStore.showError(t('fileManager.errors.sftpNotReady'), { timeout: 5000 });
console.warn(`[SFTP ${instanceSessionId}] 尝试移动项目但 SFTP 未就绪。`);
return;
}
if (sourcePaths.length === 0) return;
// 可以在这里再次检查源目录和目标目录是否相同,虽然 FileManager.vue 也检查了
// const sourceDir = sourcePaths[0].substring(0, sourcePaths[0].lastIndexOf('/')) || '/';
// if (sourceDir === destinationDir) {
// uiNotificationsStore.showWarning(t('fileManager.warnings.moveSameDirectory'), { timeout: 3000 });
// return;
// }
const requestId = generateRequestId();
sendMessage({
type: 'sftp:move', // 使用 'sftp:move' 类型
requestId: requestId,
payload: { sources: sourcePaths, destination: destinationDir }
});
console.log(`[SFTP ${instanceSessionId}] 发送 sftp:move 请求 (ID: ${requestId}) Sources: ${sourcePaths.join(', ')}, Dest: ${destinationDir}`);
// 可选:显示一个“正在移动...”的通知
};
// --- Message Handlers ---
@@ -716,6 +758,86 @@ export function createSftpActionsManager(
}
};
// +++ 新增:处理复制成功 +++
const onCopySuccess = (payload: MessagePayload, message: WebSocketMessage) => {
// 后端应发送 { destination: string, items: FileListItem[] | null }
const copyPayload = payload as { destination: string, items: FileListItem[] | null };
const destinationDir = copyPayload.destination;
const newItems = copyPayload.items;
console.log(`[SFTP ${instanceSessionId}] 复制成功到: ${destinationDir}`);
uiNotificationsStore.showSuccess(t('fileManager.notifications.copySuccess'), { timeout: 3000 }); // 添加成功通知
// 更新文件树
const destNode = findNodeByPath(fileTree, destinationDir);
if (destNode && newItems) {
// 如果目标节点已加载,直接添加新项目
if (destNode.childrenLoaded && destNode.children) {
newItems.forEach(item => addOrUpdateNodeInTree(destinationDir, item));
} else {
// 如果目标节点未加载,标记为需要刷新
destNode.childrenLoaded = false;
console.log(`[SFTP ${instanceSessionId}] 复制成功,但目标目录 ${destinationDir} 未加载,标记为需要刷新`);
// 如果复制发生在当前目录,触发刷新
if (destinationDir === currentPathRef.value) {
loadDirectory(currentPathRef.value);
}
}
} else if (destNode && !newItems) {
// 成功但没有收到项目详情,标记目标目录需要刷新
destNode.childrenLoaded = false;
console.warn(`[SFTP ${instanceSessionId}] Copy success to ${destinationDir} but no item details received. Marking parent for reload.`);
if (destinationDir === currentPathRef.value) {
loadDirectory(currentPathRef.value);
}
} else {
console.warn(`[SFTP ${instanceSessionId}] Copy success, but destination node ${destinationDir} not found in tree.`);
// 可能需要刷新根目录或采取其他措施
}
};
// +++ 新增:处理移动成功 +++
const onMoveSuccess = (payload: MessagePayload, message: WebSocketMessage) => {
// 后端应发送 { sources: string[], destination: string, items: FileListItem[] | null }
const movePayload = payload as { sources: string[], destination: string, items: FileListItem[] | null };
const sourcePaths = movePayload.sources;
const destinationDir = movePayload.destination;
const newItems = movePayload.items;
console.log(`[SFTP ${instanceSessionId}] 移动成功到: ${destinationDir}`);
uiNotificationsStore.showSuccess(t('fileManager.notifications.moveSuccess'), { timeout: 3000 }); // 添加成功通知
// 1. 从旧位置移除
sourcePaths.forEach(oldPath => {
const oldParentPath = oldPath.substring(0, oldPath.lastIndexOf('/')) || '/';
const oldFilename = oldPath.substring(oldPath.lastIndexOf('/') + 1);
removeNodeFromTree(oldParentPath, oldFilename);
});
// 2. 添加到新位置
const destNode = findNodeByPath(fileTree, destinationDir);
if (destNode && newItems) {
if (destNode.childrenLoaded && destNode.children) {
newItems.forEach(item => addOrUpdateNodeInTree(destinationDir, item));
} else {
destNode.childrenLoaded = false; // 标记需要刷新
console.log(`[SFTP ${instanceSessionId}] 移动成功,但目标目录 ${destinationDir} 未加载,标记为需要刷新`);
if (destinationDir === currentPathRef.value) {
loadDirectory(currentPathRef.value);
}
}
} else if (destNode && !newItems) {
destNode.childrenLoaded = false;
console.warn(`[SFTP ${instanceSessionId}] Move success to ${destinationDir} but no item details received. Marking parent for reload.`);
if (destinationDir === currentPathRef.value) {
loadDirectory(currentPathRef.value);
}
} else {
console.warn(`[SFTP ${instanceSessionId}] Move success, but destination node ${destinationDir} not found in tree.`);
}
};
// *** 新增:处理上传成功 ***
const onUploadSuccess = (payload: MessagePayload, message: WebSocketMessage) => {
const newItem = payload as FileListItem | null; // 后端应发送 FileListItem 或 null
@@ -750,6 +872,8 @@ export function createSftpActionsManager(
'sftp:rename:error': t('fileManager.errors.renameFailed'),
'sftp:chmod:error': t('fileManager.errors.chmodFailed'),
'sftp:writefile:error': t('fileManager.errors.saveFailed'),
'sftp:copy:error': t('fileManager.errors.copyFailed'), // +++
'sftp:move:error': t('fileManager.errors.moveFailed'), // +++
};
const prefix = actionTypeMap[message.type] || t('fileManager.errors.generic');
// error.value = `${prefix}: ${errorPayload}`; // 使用通知
@@ -773,6 +897,11 @@ export function createSftpActionsManager(
unregisterCallbacks.push(onMessage('sftp:rename:error', onActionError));
unregisterCallbacks.push(onMessage('sftp:chmod:error', onActionError));
unregisterCallbacks.push(onMessage('sftp:writefile:error', onActionError));
// +++ 新增:监听复制/移动错误 +++
unregisterCallbacks.push(onMessage('sftp:copy:success', onCopySuccess));
unregisterCallbacks.push(onMessage('sftp:copy:error', onActionError));
unregisterCallbacks.push(onMessage('sftp:move:success', onMoveSuccess));
unregisterCallbacks.push(onMessage('sftp:move:error', onActionError));
// 移除 onUnmounted 块
@@ -808,6 +937,8 @@ export function createSftpActionsManager(
changePermissions,
readFile,
writeFile,
copyItems, // +++ 暴露 copyItems +++
moveItems, // +++ 暴露 moveItems +++
joinPath, // 暴露辅助函数
// clearSftpError, // 移除 clearSftpError