This commit is contained in:
Baobhan Sith
2025-04-15 23:16:00 +08:00
parent 6ee18743ad
commit 1a6ea421e6
16 changed files with 1435 additions and 915 deletions
@@ -50,14 +50,14 @@ export class StatusMonitorService {
startStatusPolling(sessionId: string, interval: number = DEFAULT_POLLING_INTERVAL): void { startStatusPolling(sessionId: string, interval: number = DEFAULT_POLLING_INTERVAL): void {
const state = this.clientStates.get(sessionId); const state = this.clientStates.get(sessionId);
if (!state || !state.sshClient) { if (!state || !state.sshClient) {
console.warn(`[StatusMonitor] 无法为会话 ${sessionId} 启动状态轮询:状态无效或 SSH 客户端不存在。`); //console.warn(`[StatusMonitor] 无法为会话 ${sessionId} 启动状态轮询:状态无效或 SSH 客户端不存在。`);
return; return;
} }
if (state.statusIntervalId) { if (state.statusIntervalId) {
console.warn(`[StatusMonitor] 会话 ${sessionId} 的状态轮询已在运行中。`); //console.warn(`[StatusMonitor] 会话 ${sessionId} 的状态轮询已在运行中。`);
return; return;
} }
console.log(`[StatusMonitor] 为会话 ${sessionId} 启动状态轮询,间隔 ${interval}ms`); //console.warn(`[StatusMonitor] 为会话 ${sessionId} 启动状态轮询,间隔 ${interval}ms`);
this.fetchAndSendServerStatus(sessionId); // 立即执行一次 this.fetchAndSendServerStatus(sessionId); // 立即执行一次
state.statusIntervalId = setInterval(() => { state.statusIntervalId = setInterval(() => {
this.fetchAndSendServerStatus(sessionId); this.fetchAndSendServerStatus(sessionId);
@@ -71,7 +71,7 @@ export class StatusMonitorService {
stopStatusPolling(sessionId: string): void { stopStatusPolling(sessionId: string): void {
const state = this.clientStates.get(sessionId); const state = this.clientStates.get(sessionId);
if (state?.statusIntervalId) { if (state?.statusIntervalId) {
console.log(`[StatusMonitor] 停止会话 ${sessionId} 的状态轮询。`); //console.warn(`[StatusMonitor] 停止会话 ${sessionId} 的状态轮询。`);
clearInterval(state.statusIntervalId); clearInterval(state.statusIntervalId);
state.statusIntervalId = undefined; state.statusIntervalId = undefined;
previousNetStats.delete(sessionId); // 清理网络统计缓存 previousNetStats.delete(sessionId); // 清理网络统计缓存
@@ -85,7 +85,7 @@ export class StatusMonitorService {
private async fetchAndSendServerStatus(sessionId: string): Promise<void> { private async fetchAndSendServerStatus(sessionId: string): Promise<void> {
const state = this.clientStates.get(sessionId); const state = this.clientStates.get(sessionId);
if (!state || !state.sshClient || state.ws.readyState !== WebSocket.OPEN) { if (!state || !state.sshClient || state.ws.readyState !== WebSocket.OPEN) {
console.warn(`[StatusMonitor] 无法获取会话 ${sessionId} 的状态,停止轮询。原因:状态无效、SSH断开或WS关闭。`); //console.warn(`[StatusMonitor] 无法获取会话 ${sessionId} 的状态,停止轮询。原因:状态无效、SSH断开或WS关闭。`);
this.stopStatusPolling(sessionId); this.stopStatusPolling(sessionId);
return; return;
} }
@@ -94,7 +94,7 @@ export class StatusMonitorService {
const status = await this.fetchServerStatus(state.sshClient, sessionId); const status = await this.fetchServerStatus(state.sshClient, sessionId);
state.ws.send(JSON.stringify({ type: 'status_update', payload: { connectionId: state.dbConnectionId, status } })); state.ws.send(JSON.stringify({ type: 'status_update', payload: { connectionId: state.dbConnectionId, status } }));
} catch (error: any) { } catch (error: any) {
console.error(`[StatusMonitor] 获取会话 ${sessionId} 服务器状态失败:`, error); //console.warn(`[StatusMonitor] 获取会话 ${sessionId} 服务器状态失败:`, error);
state.ws.send(JSON.stringify({ type: 'status_error', payload: { connectionId: state.dbConnectionId, message: `获取状态失败: ${error.message}` } })); state.ws.send(JSON.stringify({ type: 'status_error', payload: { connectionId: state.dbConnectionId, message: `获取状态失败: ${error.message}` } }));
} }
} }
@@ -308,13 +308,13 @@ export class StatusMonitorService {
stream.on('close', (code: number, signal?: string) => { stream.on('close', (code: number, signal?: string) => {
// Don't reject on non-zero exit code, as some commands might return non-zero normally // Don't reject on non-zero exit code, as some commands might return non-zero normally
// if (code !== 0) { // if (code !== 0) {
// console.warn(`[StatusMonitor] Command '${command}' exited with code ${code}`); // //console.warn(`[StatusMonitor] Command '${command}' exited with code ${code}`);
// } // }
resolve(output.trim()); resolve(output.trim());
}).on('data', (data: Buffer) => { }).on('data', (data: Buffer) => {
output += data.toString('utf8'); output += data.toString('utf8');
}).stderr.on('data', (data: Buffer) => { }).stderr.on('data', (data: Buffer) => {
console.warn(`[StatusMonitor] Command '${command}' stderr: ${data.toString('utf8').trim()}`); //console.warn(`[StatusMonitor] Command '${command}' stderr: ${data.toString('utf8').trim()}`);
}); });
}); });
}); });
+258 -298
View File
@@ -1,62 +1,85 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount, nextTick, watch, watchEffect } from 'vue'; // Import watchEffect import { ref, computed, onMounted, onBeforeUnmount, nextTick, watchEffect, type PropType, readonly } from 'vue'; // 恢复导入
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router'; // 保留用于生成下载 URL (如果下载逻辑移动则可移除)
// 移除 MonacoEditor 直接导入,因为它现在在 FileEditorOverlay 中 // 导入 SFTP Actions 工厂函数和所需的类型
// import MonacoEditor from './MonacoEditor.vue'; import { createSftpActionsManager, type WebSocketDependencies } from '../composables/useSftpActions'; // 恢复 WebSocketDependencies
import { useSftpActions } from '../composables/useSftpActions';
import { useFileUploader } from '../composables/useFileUploader'; import { useFileUploader } from '../composables/useFileUploader';
import { useFileEditor } from '../composables/useFileEditor'; import { useFileEditor } from '../composables/useFileEditor';
import { useWebSocketConnection } from '../composables/useWebSocketConnection'; // 导入 WebSocket composable // WebSocket composable 不再直接使用
// 导入新拆分的 UI 组件
import FileUploadPopup from './FileUploadPopup.vue'; import FileUploadPopup from './FileUploadPopup.vue';
import FileEditorOverlay from './FileEditorOverlay.vue'; import FileEditorOverlay from './FileEditorOverlay.vue';
// 从类型文件导入所需类型 // 从类型文件导入所需类型
import type { FileListItem, FileAttributes } from '../types/sftp.types'; import type { FileListItem } from '../types/sftp.types';
import type { UploadItem } from '../types/upload.types'; // 从 websocket 类型文件导入所需类型
import type { WebSocketMessage } from '../types/websocket.types'; // 导入 WebSocketMessage import type { WebSocketMessage } from '../types/websocket.types'; // 导入 WebSocketMessage
// 定义 SftpManagerInstance 类型,基于 createSftpActionsManager 的返回类型
type SftpManagerInstance = ReturnType<typeof createSftpActionsManager>;
// --- 接口定义 (已移至类型文件) ---
// --- Props --- // --- Props ---
const props = defineProps<{ const props = defineProps({
// ws: WebSocket | null; // 移除 ws prop sessionId: {
isConnected: boolean; // 保留 isConnected prop,用于禁用操作 type: String,
}>(); required: true,
},
// 注入此会话特定的 SFTP 管理器实例
sftpManager: {
type: Object as PropType<SftpManagerInstance>,
required: true,
},
// 注入数据库连接 ID
dbConnectionId: {
type: String,
required: true,
},
// 注入此组件及其子 composables 所需的 WebSocket 依赖项
wsDeps: {
type: Object as PropType<WebSocketDependencies>,
required: true,
},
});
// --- 核心 Composables --- // --- 核心 Composables ---
const { t } = useI18n(); const { t } = useI18n();
const route = useRoute(); const route = useRoute(); // Keep for download URL generation for now
// 导入 sendMessage 和 onMessage 用于 realpath 请求 const currentPath = ref<string>('.'); // Current path state remains local, passed to manager if needed
const { isSftpReady, sendMessage, onMessage } = useWebSocketConnection();
const currentPath = ref<string>('.'); // 当前路径状态保留在组件中,传递给 composables
// SFTP 操作模块 // Access SFTP state and methods from the injected manager instance
const { const {
fileList, // 从 composable 获取文件列表 fileList,
isLoading, // 从 composable 获取加载状态 isLoading,
error, // 从 composable 获取错误状态 error,
loadDirectory, loadDirectory,
createDirectory, createDirectory,
createFile, createFile,
deleteItems, deleteItems,
renameItem, renameItem,
changePermissions, changePermissions,
readFile, // 暴露给 useFileEditor readFile, // Provided by the manager
writeFile, // 暴露给 useFileEditor writeFile, // Provided by the manager
joinPath, // 从 composable 获取 joinPath joinPath,
clearSftpError, // 导入清除错误的函数 clearSftpError,
} = useSftpActions(currentPath); // 传入 currentPath ref cleanup: cleanupSftpHandlers, // Get the cleanup function from the manager
} = props.sftpManager; // 直接从 props 获取
// 文件上传模块 // 文件上传模块 - Needs WebSocket dependencies and session context
const { const {
uploads, // 从 composable 获取上传列表 uploads,
startFileUpload, startFileUpload,
cancelUpload, cancelUpload,
} = useFileUploader(currentPath, fileList, () => loadDirectory(currentPath.value)); // 传入依赖 // cleanup: cleanupUploader, // 假设 uploader 也提供 cleanup
} = useFileUploader(
currentPath,
fileList, // 传递来自 sftpManager 的 fileList ref
() => loadDirectory(currentPath.value), // Refresh function uses manager's loadDirectory
props.sessionId, // 传递 sessionId
props.dbConnectionId // 传递 dbConnectionId
// useFileUploader 内部创建自己的 ws 连接, 不需要 wsDeps
);
// 文件编辑器模块 // 文件编辑器模块 - Needs file operations from sftpManager
const { const {
isEditorVisible, isEditorVisible,
editingFilePath, editingFilePath,
@@ -66,32 +89,36 @@ const {
isSaving, isSaving,
saveStatus, saveStatus,
saveError, saveError,
editingFileContent, // v-model 绑定 editingFileContent,
openFile, openFile,
saveFile, saveFile,
closeEditor, closeEditor,
} = useFileEditor(readFile, writeFile); // 传入依赖 // cleanup: cleanupEditor, // 假设 editor 也提供 cleanup
} = useFileEditor(
readFile, // 使用注入的 sftpManager 中的 readFile
writeFile // Use writeFile from the injected sftpManager
);
// --- UI 状态 Refs --- // --- UI 状态 Refs (Remain mostly the same) ---
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); // 用于 Shift 多选 const lastClickedIndex = ref(-1);
const contextMenuVisible = ref(false); // 右键菜单可见性 const contextMenuVisible = ref(false);
const contextMenuPosition = ref({ x: 0, y: 0 }); // 右键菜单位置 const contextMenuPosition = ref({ x: 0, y: 0 });
const contextMenuItems = ref<Array<{ label: string; action: () => void; disabled?: boolean }>>([]); // 右键菜单项 const contextMenuItems = ref<Array<{ label: string; action: () => void; disabled?: boolean }>>([]);
const contextTargetItem = ref<FileListItem | null>(null); // 右键菜单目标项 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');
const initialLoadDone = ref(false); // Track if the initial load has been triggered const initialLoadDone = ref(false);
const isFetchingInitialPath = ref(false); // Track if fetching realpath const isFetchingInitialPath = ref(false);
const isEditingPath = ref(false); // State for path editing mode const isEditingPath = ref(false);
const pathInputRef = ref<HTMLInputElement | null>(null); // Ref for the path input element const pathInputRef = ref<HTMLInputElement | null>(null);
const editablePath = ref(''); // Temp storage for the path being edited const editablePath = ref('');
// --- Column Resizing State --- // --- Column Resizing State (Remains the same) ---
const tableRef = ref<HTMLTableElement | null>(null); const tableRef = ref<HTMLTableElement | null>(null);
const colWidths = ref({ // Initial widths (adjust as needed) const colWidths = ref({
type: 50, type: 50,
name: 300, name: 300,
size: 100, size: 100,
@@ -103,21 +130,13 @@ const resizingColumnIndex = ref(-1);
const startX = ref(0); const startX = ref(0);
const startWidth = ref(0); const startWidth = ref(0);
// --- Editor State (已移至 useFileEditor) --- // --- 辅助函数 ---
// const isEditorVisible = ref(false); // 重新添加 generateRequestId,因为 watchEffect 中需要它
// ... 其他编辑器状态 ... const generateRequestId = (): string => `req-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
// joinPath 由 props.sftpManager 提供
// sortFiles 在此组件内部用于排序显示
// --- 辅助函数 (部分移至 composables) --- // UI 格式化函数保持不变
// generateRequestId 已移至 composables 内部使用
// joinPath 从 useSftpActions 获取
// sortFiles 已移至 useSftpActions 内部使用
// Helper function (Copied from useSftpActions) - needed for realpath request
const generateRequestId = (): string => {
return `req-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
};
// 保留 UI 格式化函数
const formatSize = (size: number): string => { const formatSize = (size: number): string => {
if (size < 1024) return `${size} B`; if (size < 1024) return `${size} B`;
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`; if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
@@ -133,13 +152,8 @@ const formatMode = (mode: number): string => {
return str; return str;
}; };
// --- 编辑器辅助函数 (已移至 useFileEditor) --- // --- 上下文菜单逻辑 ---
// getLanguageFromFilename 已移至 useFileEditor // Actions now call methods from props.sftpManager
// closeEditor 由 useFileEditor 提供
// handleSaveFile 由 useFileEditor 提供
// --- 上下文菜单逻辑 (部分操作现在调用 composable 方法) ---
const showContextMenu = (event: MouseEvent, item?: FileListItem) => { const showContextMenu = (event: MouseEvent, item?: FileListItem) => {
event.preventDefault(); event.preventDefault();
const targetItem = item || null; const targetItem = item || null;
@@ -148,7 +162,8 @@ const showContextMenu = (event: MouseEvent, item?: FileListItem) => {
if (targetItem && !event.ctrlKey && !event.metaKey && !event.shiftKey && !selectedItems.value.has(targetItem.filename)) { if (targetItem && !event.ctrlKey && !event.metaKey && !event.shiftKey && !selectedItems.value.has(targetItem.filename)) {
selectedItems.value.clear(); selectedItems.value.clear();
selectedItems.value.add(targetItem.filename); selectedItems.value.add(targetItem.filename);
lastClickedIndex.value = fileList.value.findIndex(f => f.filename === targetItem.filename); // 使用 props.sftpManager 中的 fileList
lastClickedIndex.value = fileList.value.findIndex((f: FileListItem) => f.filename === targetItem.filename); // 已添加类型
} else if (!targetItem) { } else if (!targetItem) {
selectedItems.value.clear(); selectedItems.value.clear();
lastClickedIndex.value = -1; lastClickedIndex.value = -1;
@@ -158,43 +173,40 @@ const showContextMenu = (event: MouseEvent, item?: FileListItem) => {
let menu: Array<{ label: string; action: () => void; disabled?: boolean }> = []; let menu: Array<{ label: string; action: () => void; disabled?: boolean }> = [];
const selectionSize = selectedItems.value.size; const selectionSize = selectedItems.value.size;
const clickedItemIsSelected = targetItem && selectedItems.value.has(targetItem.filename); const clickedItemIsSelected = targetItem && selectedItems.value.has(targetItem.filename);
const canPerformActions = props.wsDeps.isConnected.value && props.wsDeps.isSftpReady.value; // 恢复使用 props.wsDeps
// 构建上下文菜单项 // Build context menu items
if (selectionSize > 1 && clickedItemIsSelected) { if (selectionSize > 1 && clickedItemIsSelected) {
// 多选时的菜单 // Multi-selection menu
menu = [ menu = [
{ label: t('fileManager.actions.deleteMultiple', { count: selectionSize }), action: handleDeleteSelectedClick }, // 修改为调用新的处理函数 { label: t('fileManager.actions.deleteMultiple', { count: selectionSize }), action: handleDeleteSelectedClick, disabled: !canPerformActions },
{ label: t('fileManager.actions.refresh'), action: () => loadDirectory(currentPath.value) }, // 调用 useSftpActions 的方法 { label: t('fileManager.actions.refresh'), action: () => loadDirectory(currentPath.value), disabled: !canPerformActions },
]; ];
} else if (targetItem && targetItem.filename !== '..') { } else if (targetItem && targetItem.filename !== '..') {
// 单个项目(非 '..')的菜单 // Single item (not '..') menu
menu = [ menu = [
{ label: t('fileManager.actions.newFolder'), action: handleNewFolderContextMenuClick }, // 修改为调用新的处理函数 { label: t('fileManager.actions.newFolder'), action: handleNewFolderContextMenuClick, disabled: !canPerformActions },
{ label: t('fileManager.actions.newFile'), action: handleNewFileContextMenuClick }, // 修改为调用新的处理函数 { label: t('fileManager.actions.newFile'), action: handleNewFileContextMenuClick, disabled: !canPerformActions },
{ label: t('fileManager.actions.upload'), action: triggerFileUpload }, // 调用组件内的方法触发 input { label: t('fileManager.actions.upload'), action: triggerFileUpload, disabled: !canPerformActions }, // Upload depends on connection
{ label: t('fileManager.actions.refresh'), action: () => loadDirectory(currentPath.value) }, // 调用 useSftpActions 的方法 { label: t('fileManager.actions.refresh'), action: () => loadDirectory(currentPath.value), disabled: !canPerformActions },
]; ];
if (targetItem.attrs.isFile) { 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.splice(1, 0, { label: t('fileManager.actions.download', { name: targetItem.filename }), action: () => triggerDownload(targetItem) });
} }
// 添加删除选项 menu.push({ label: t('fileManager.actions.delete'), action: handleDeleteSelectedClick, disabled: !canPerformActions });
menu.push({ label: t('fileManager.actions.delete'), action: handleDeleteSelectedClick }); // 修改为调用新的处理函数 menu.push({ label: t('fileManager.actions.rename'), action: () => handleRenameContextMenuClick(targetItem), disabled: !canPerformActions });
// 添加重命名选项 menu.push({ label: t('fileManager.actions.changePermissions'), action: () => handleChangePermissionsContextMenuClick(targetItem), disabled: !canPerformActions });
menu.push({ label: t('fileManager.actions.rename'), action: () => handleRenameContextMenuClick(targetItem) }); // 调用新的处理函数
// 添加修改权限选项
menu.push({ label: t('fileManager.actions.changePermissions'), action: () => handleChangePermissionsContextMenuClick(targetItem) }); // 调用新的处理函数
} else if (!targetItem) { } else if (!targetItem) {
// 在空白处右键的菜单 // Right-click on empty space menu
menu = [ menu = [
{ label: t('fileManager.actions.newFolder'), action: handleNewFolderContextMenuClick }, // 修改为调用新的处理函数 { label: t('fileManager.actions.newFolder'), action: handleNewFolderContextMenuClick, disabled: !canPerformActions },
{ label: t('fileManager.actions.newFile'), action: handleNewFileContextMenuClick }, // 修改为调用新的处理函数 { label: t('fileManager.actions.newFile'), action: handleNewFileContextMenuClick, disabled: !canPerformActions },
{ label: t('fileManager.actions.upload'), action: triggerFileUpload }, // 调用组件内的方法触发 input { label: t('fileManager.actions.upload'), action: triggerFileUpload, disabled: !canPerformActions },
{ label: t('fileManager.actions.refresh'), action: () => loadDirectory(currentPath.value) }, // 调用 useSftpActions 的方法 { label: t('fileManager.actions.refresh'), action: () => loadDirectory(currentPath.value), disabled: !canPerformActions },
]; ];
} else { // 点击 '..' 时的菜单 } else { // Clicked on '..'
menu = [ { label: t('fileManager.actions.refresh'), action: () => loadDirectory(currentPath.value) } ]; // 调用 useSftpActions 的方法 menu = [{ label: t('fileManager.actions.refresh'), action: () => loadDirectory(currentPath.value), disabled: !canPerformActions }];
} }
contextMenuItems.value = menu; contextMenuItems.value = menu;
@@ -203,55 +215,47 @@ const showContextMenu = (event: MouseEvent, item?: FileListItem) => {
let posX = event.clientX; let posX = event.clientX;
let posY = event.clientY; let posY = event.clientY;
// Estimate menu dimensions (adjust if necessary based on actual menu size) // Estimate menu dimensions
const estimatedMenuWidth = 180; const estimatedMenuWidth = 180;
const estimatedMenuHeight = 200; // Adjust based on max items const estimatedMenuHeight = contextMenuItems.value.length * 35; // Estimate height based on items
// Adjust position if menu would go off-screen // Adjust position if menu would go off-screen
if (posX + estimatedMenuWidth > window.innerWidth) { if (posX + estimatedMenuWidth > window.innerWidth) {
posX = window.innerWidth - estimatedMenuWidth - 5; // Adjust and add small padding posX = window.innerWidth - estimatedMenuWidth - 5;
} }
if (posY + estimatedMenuHeight > window.innerHeight) { if (posY + estimatedMenuHeight > window.innerHeight) {
posY = window.innerHeight - estimatedMenuHeight - 5; // Adjust and add small padding posY = window.innerHeight - estimatedMenuHeight - 5;
} }
// Ensure position is not negative
posX = Math.max(0, posX); posX = Math.max(0, posX);
posY = Math.max(0, posY); posY = Math.max(0, posY);
contextMenuPosition.value = { x: posX, y: posY }; contextMenuPosition.value = { x: posX, y: posY };
contextMenuVisible.value = true; contextMenuVisible.value = true;
// Add global listener to hide menu, using capture phase and once // Add global listener to hide menu
nextTick(() => { nextTick(() => {
document.removeEventListener('click', hideContextMenu, { capture: true }); // Clean up just in case document.removeEventListener('click', hideContextMenu, { capture: true });
document.addEventListener('click', hideContextMenu, { capture: true, once: true }); document.addEventListener('click', hideContextMenu, { capture: true, once: true });
}); });
}; };
const hideContextMenu = () => { const hideContextMenu = () => {
if (!contextMenuVisible.value) return; // Prevent unnecessary runs if (!contextMenuVisible.value) return;
contextMenuVisible.value = false; contextMenuVisible.value = false;
contextMenuItems.value = []; contextMenuItems.value = [];
contextTargetItem.value = null; contextTargetItem.value = null;
// Explicitly remove listener just in case 'once' didn't fire or was removed prematurely
document.removeEventListener('click', hideContextMenu, { capture: true }); document.removeEventListener('click', hideContextMenu, { capture: true });
}; };
// --- WebSocket 消息处理 (已移至 composables) ---
// watch(() => props.ws, ...) 已移除
// watch(() => props.isConnected, ...) 已移除 (部分逻辑移至 onMounted 和 isConnected watch)
// handleWebSocketMessage 已移除
// --- 目录加载与导航 --- // --- 目录加载与导航 ---
// loadDirectory 由 useSftpActions 提供 // loadDirectory is provided by props.sftpManager
// --- 列表项点击与选择逻辑 --- // --- 列表项点击与选择逻辑 ---
const handleItemClick = (event: MouseEvent, item: FileListItem) => { // handleItemClick 中的 item 参数已有类型
// Do not hide context menu here, let the global listener or menu item click handle it.
const itemIndex = fileList.value.findIndex(f => f.filename === item.filename); // --- 列表项点击与选择逻辑 ---
const handleItemClick = (event: MouseEvent, item: FileListItem) => { // item 已有类型
const itemIndex = fileList.value.findIndex((f: FileListItem) => f.filename === item.filename); // f 已有类型
if (itemIndex === -1 && item.filename !== '..') return; if (itemIndex === -1 && item.filename !== '..') return;
if (event.ctrlKey || event.metaKey) { if (event.ctrlKey || event.metaKey) {
@@ -265,6 +269,7 @@ const handleItemClick = (event: MouseEvent, item: FileListItem) => {
const start = Math.min(lastClickedIndex.value, itemIndex); const start = Math.min(lastClickedIndex.value, itemIndex);
const end = Math.max(lastClickedIndex.value, itemIndex); const end = Math.max(lastClickedIndex.value, itemIndex);
for (let i = start; i <= end; i++) { for (let i = start; i <= end; i++) {
// Use fileList from props
if (fileList.value[i]) selectedItems.value.add(fileList.value[i].filename); if (fileList.value[i]) selectedItems.value.add(fileList.value[i].filename);
} }
} else { } else {
@@ -277,38 +282,42 @@ const handleItemClick = (event: MouseEvent, item: FileListItem) => {
} }
if (item.attrs.isDirectory) { if (item.attrs.isDirectory) {
// 检查是否已在加载,防止快速重复点击 if (isLoading.value) { // Use isLoading from props
if (isLoading.value) { console.log(`[FileManager ${props.sessionId}] Ignoring directory click, already loading...`);
console.log('[文件管理器] 忽略目录点击,因为正在加载...');
return; return;
} }
// 处理目录点击:导航
const newPath = item.filename === '..' const newPath = item.filename === '..'
? currentPath.value.substring(0, currentPath.value.lastIndexOf('/')) || '/' ? currentPath.value.substring(0, currentPath.value.lastIndexOf('/')) || '/'
: joinPath(currentPath.value, item.filename); // 使用 composable 的 joinPath : joinPath(currentPath.value, item.filename); // Use joinPath from props
loadDirectory(newPath); // 使用 composable 的 loadDirectory loadDirectory(newPath); // Use loadDirectory from props
} else if (item.attrs.isFile) { } else if (item.attrs.isFile) {
// 处理文件点击:打开编辑器 const filePath = joinPath(currentPath.value, item.filename); // Use joinPath from props
const filePath = joinPath(currentPath.value, item.filename); // 使用 composable 的 joinPath openFile(filePath); // Use openFile from useFileEditor
openFile(filePath); // 使用 useFileEditor 的 openFile
} }
} }
}; };
// --- 下载逻辑 --- // --- 下载逻辑 ---
// triggerDownload 中的 item 参数已有类型
const triggerDownload = (item: FileListItem) => { // --- 下载逻辑 ---
const currentConnectionId = route.params.connectionId as string; 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) { if (!currentConnectionId) {
// error.value = t('fileManager.errors.missingConnectionId'); // 错误状态由 useSftpActions 管理 console.error(`[FileManager ${props.sessionId}] Cannot download: Missing connection ID.`);
console.error("无法下载:缺少连接 ID"); // 或者显示一个临时的 alert
alert(t('fileManager.errors.missingConnectionId')); alert(t('fileManager.errors.missingConnectionId'));
return; return;
} }
const downloadPath = joinPath(currentPath.value, item.filename); // 使用 composable 的 joinPath const downloadPath = joinPath(currentPath.value, item.filename); // Use joinPath from props
// TODO: 考虑将 API URL 基础部分提取到配置或环境变量中
const downloadUrl = `/api/v1/sftp/download?connectionId=${currentConnectionId}&remotePath=${encodeURIComponent(downloadPath)}`; const downloadUrl = `/api/v1/sftp/download?connectionId=${currentConnectionId}&remotePath=${encodeURIComponent(downloadPath)}`;
console.log(`[文件管理器] 触发下载: ${downloadUrl}`); console.log(`[FileManager ${props.sessionId}] Triggering download: ${downloadUrl}`);
const link = document.createElement('a'); const link = document.createElement('a');
link.href = downloadUrl; link.href = downloadUrl;
link.setAttribute('download', item.filename); link.setAttribute('download', item.filename);
@@ -319,25 +328,22 @@ const triggerDownload = (item: FileListItem) => {
// --- 拖放上传逻辑 --- // --- 拖放上传逻辑 ---
const handleDragEnter = (event: DragEvent) => { const handleDragEnter = (event: DragEvent) => {
// Check if files are being dragged if (props.wsDeps.isConnected.value && event.dataTransfer?.types.includes('Files')) { // 恢复使用 props.wsDeps.isConnected
if (event.dataTransfer?.types.includes('Files')) {
isDraggingOver.value = true; isDraggingOver.value = true;
} }
}; };
const handleDragOver = (event: DragEvent) => { const handleDragOver = (event: DragEvent) => {
// Necessary to allow drop
event.preventDefault(); event.preventDefault();
if (event.dataTransfer && event.dataTransfer.types.includes('Files')) { // Added null check if (props.wsDeps.isConnected.value && event.dataTransfer?.types.includes('Files')) { // 恢复使用 props.wsDeps.isConnected
event.dataTransfer.dropEffect = 'copy'; // Show copy cursor event.dataTransfer.dropEffect = 'copy';
isDraggingOver.value = true; // Ensure state is true isDraggingOver.value = true;
} else if (event.dataTransfer) { // Added null check } else if (event.dataTransfer) {
event.dataTransfer.dropEffect = 'none'; event.dataTransfer.dropEffect = 'none';
} }
}; };
const handleDragLeave = (event: DragEvent) => { const handleDragLeave = (event: DragEvent) => {
// Check if the leave event is going outside the container or onto a child element
const target = event.relatedTarget as Node | null; const target = event.relatedTarget as Node | null;
const container = (event.currentTarget as HTMLElement); const container = (event.currentTarget as HTMLElement);
if (!target || !container.contains(target)) { if (!target || !container.contains(target)) {
@@ -347,34 +353,33 @@ const handleDragLeave = (event: DragEvent) => {
const handleDrop = (event: DragEvent) => { const handleDrop = (event: DragEvent) => {
isDraggingOver.value = false; isDraggingOver.value = false;
if (!event.dataTransfer?.files || !props.isConnected) { // 使用 props.isConnected // 恢复使用 props.wsDeps.isConnected
if (!event.dataTransfer?.files || !props.wsDeps.isConnected.value) {
return; return;
} }
const files = Array.from(event.dataTransfer.files); const files = Array.from(event.dataTransfer.files);
if (files.length > 0) { if (files.length > 0) {
console.log(`[文件管理器] 拖放了 ${files.length} 个文件。`); console.log(`[FileManager ${props.sessionId}] Dropped ${files.length} files.`);
files.forEach(startFileUpload); // 调用 useFileUploader 的方法 files.forEach(startFileUpload); // Use startFileUpload from useFileUploader
} }
}; };
// --- 文件上传逻辑 (已移至 useFileUploader) --- // --- 文件上传逻辑 ---
const triggerFileUpload = () => { fileInputRef.value?.click(); }; // 保留触发器 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;
if (!input.files || !props.isConnected) return; // 使用 props.isConnected // 恢复使用 props.wsDeps.isConnected
Array.from(input.files).forEach(startFileUpload); // 调用 useFileUploader 的方法 if (!input.files || !props.wsDeps.isConnected.value) return;
input.value = ''; // 清空 input 以允许再次选择相同文件 Array.from(input.files).forEach(startFileUpload); // Use startFileUpload from useFileUploader
input.value = '';
}; };
// startFileUpload 已移至 useFileUploader
// sendFileChunks 已移至 useFileUploader
// cancelUpload 由 useFileUploader 提供
// --- SFTP 操作处理函数 (现在调用 composable 方法) --- // --- SFTP 操作处理函数 ---
// 恢复使用 props.wsDeps.isConnected 和 props.sftpManager 的方法
const handleDeleteSelectedClick = () => { const handleDeleteSelectedClick = () => {
if (!props.isConnected || selectedItems.value.size === 0) return; if (!props.wsDeps.isConnected.value || selectedItems.value.size === 0) return; // 恢复使用 props.wsDeps
// 从 composable 获取 fileList 来查找选中的项
const itemsToDelete = Array.from(selectedItems.value) const itemsToDelete = Array.from(selectedItems.value)
.map(filename => fileList.value.find(f => f.filename === filename)) .map(filename => fileList.value.find((f: FileListItem) => f.filename === filename)) // f 已有类型
.filter((item): item is FileListItem => item !== undefined); .filter((item): item is FileListItem => item !== undefined);
if (itemsToDelete.length === 0) return; if (itemsToDelete.length === 0) return;
@@ -386,21 +391,21 @@ const handleDeleteSelectedClick = () => {
: t('fileManager.prompts.confirmDeleteFile', { name: itemsToDelete[0].filename }); : t('fileManager.prompts.confirmDeleteFile', { name: itemsToDelete[0].filename });
if (confirm(confirmMsg)) { if (confirm(confirmMsg)) {
deleteItems(itemsToDelete); // 调用 useSftpActions 的方法 deleteItems(itemsToDelete); // Use deleteItems from props
selectedItems.value.clear(); // 清空选择 selectedItems.value.clear();
} }
}; };
const handleRenameContextMenuClick = (item: FileListItem) => { const handleRenameContextMenuClick = (item: FileListItem) => { // item 已有类型
if (!props.isConnected || !item) return; if (!props.wsDeps.isConnected.value || !item) return; // 恢复使用 props.wsDeps
const newName = prompt(t('fileManager.prompts.enterNewName', { oldName: item.filename }), item.filename); const newName = prompt(t('fileManager.prompts.enterNewName', { oldName: item.filename }), item.filename);
if (newName && newName !== item.filename) { if (newName && newName !== item.filename) {
renameItem(item, newName); // 调用 useSftpActions 的方法 renameItem(item, newName); // Use renameItem from props.sftpManager
} }
}; };
const handleChangePermissionsContextMenuClick = (item: FileListItem) => { const handleChangePermissionsContextMenuClick = (item: FileListItem) => { // item 已有类型
if (!props.isConnected || !item) return; if (!props.wsDeps.isConnected.value || !item) return; // 恢复使用 props.wsDeps
const currentModeOctal = (item.attrs.mode & 0o777).toString(8).padStart(3, '0'); const currentModeOctal = (item.attrs.mode & 0o777).toString(8).padStart(3, '0');
const newModeStr = prompt(t('fileManager.prompts.enterNewPermissions', { name: item.filename, currentMode: currentModeOctal }), currentModeOctal); const newModeStr = prompt(t('fileManager.prompts.enterNewPermissions', { name: item.filename, currentMode: currentModeOctal }), currentModeOctal);
if (newModeStr) { if (newModeStr) {
@@ -409,91 +414,69 @@ const handleChangePermissionsContextMenuClick = (item: FileListItem) => {
return; return;
} }
const newMode = parseInt(newModeStr, 8); const newMode = parseInt(newModeStr, 8);
changePermissions(item, newMode); // 调用 useSftpActions 的方法 changePermissions(item, newMode); // Use changePermissions from props.sftpManager
} }
}; };
const handleNewFolderContextMenuClick = () => { const handleNewFolderContextMenuClick = () => {
if (!props.isConnected) return; if (!props.wsDeps.isConnected.value) return; // 恢复使用 props.wsDeps
const folderName = prompt(t('fileManager.prompts.enterFolderName')); const folderName = prompt(t('fileManager.prompts.enterFolderName'));
if (folderName) { if (folderName) {
// 可以在这里添加客户端的文件名验证(例如,是否已存在) if (fileList.value.some((item: FileListItem) => item.filename === folderName)) { // item 已有类型
if (fileList.value.some(item => item.filename === folderName)) { alert(t('fileManager.errors.folderExists', { name: folderName }));
alert(t('fileManager.errors.folderExists', { name: folderName })); // 假设有这个翻译
return; return;
} }
createDirectory(folderName); // 调用 useSftpActions 的方法 createDirectory(folderName); // Use createDirectory from props.sftpManager
} }
}; };
const handleNewFileContextMenuClick = () => { const handleNewFileContextMenuClick = () => {
if (!props.isConnected) return; if (!props.wsDeps.isConnected.value) return; // 恢复使用 props.wsDeps
const fileName = prompt(t('fileManager.prompts.enterFileName')); const fileName = prompt(t('fileManager.prompts.enterFileName'));
if (fileName) { if (fileName) {
// 可以在这里添加客户端的文件名验证 if (fileList.value.some((item: FileListItem) => item.filename === fileName)) { // item 已有类型
if (fileList.value.some(item => item.filename === fileName)) {
alert(t('fileManager.errors.fileExists', { name: fileName })); alert(t('fileManager.errors.fileExists', { name: fileName }));
return; return;
} }
createFile(fileName); // 调用 useSftpActions 的方法 createFile(fileName); // Use createFile from props.sftpManager
} }
}; };
// --- 排序逻辑 (现在作用于从 composable 获取的 fileList) --- // --- 排序逻辑 ---
// Uses fileList from props.sftpManager
const sortedFileList = computed(() => { const sortedFileList = computed(() => {
const list = [...fileList.value]; // Create a shallow copy to avoid mutating original // Ensure fileList.value is used (it's reactive from the manager)
if (!fileList.value) return [];
const list = [...fileList.value];
const key = sortKey.value; const key = sortKey.value;
const direction = sortDirection.value === 'asc' ? 1 : -1; const direction = sortDirection.value === 'asc' ? 1 : -1;
list.sort((a, b) => { list.sort((a, b) => {
// Always keep directories first when sorting by anything other than type
if (key !== 'type') { if (key !== 'type') {
if (a.attrs.isDirectory && !b.attrs.isDirectory) return -1; if (a.attrs.isDirectory && !b.attrs.isDirectory) return -1;
if (!a.attrs.isDirectory && b.attrs.isDirectory) return 1; if (!a.attrs.isDirectory && b.attrs.isDirectory) return 1;
} }
let valA: string | number | boolean; let valA: string | number | boolean;
let valB: string | number | boolean; let valB: string | number | boolean;
switch (key) { switch (key) {
case 'type': case 'type':
// Sort by type: Directory > Symlink > File
valA = a.attrs.isDirectory ? 0 : (a.attrs.isSymbolicLink ? 1 : 2); valA = a.attrs.isDirectory ? 0 : (a.attrs.isSymbolicLink ? 1 : 2);
valB = b.attrs.isDirectory ? 0 : (b.attrs.isSymbolicLink ? 1 : 2); valB = b.attrs.isDirectory ? 0 : (b.attrs.isSymbolicLink ? 1 : 2);
break; break;
case 'filename': case 'filename': valA = a.filename.toLowerCase(); valB = b.filename.toLowerCase(); break;
valA = a.filename.toLowerCase(); case 'size': valA = a.attrs.isFile ? a.attrs.size : -1; valB = b.attrs.isFile ? b.attrs.size : -1; break;
valB = b.filename.toLowerCase(); case 'mtime': valA = a.attrs.mtime; valB = b.attrs.mtime; break;
break; default: valA = a.filename.toLowerCase(); valB = b.filename.toLowerCase();
case 'size':
valA = a.attrs.isFile ? a.attrs.size : -1; // Treat dirs as -1 size for sorting
valB = b.attrs.isFile ? b.attrs.size : -1;
break;
case 'mtime':
valA = a.attrs.mtime;
valB = b.attrs.mtime;
break;
default: // Should not happen with defined keys, but fallback to filename
valA = a.filename.toLowerCase();
valB = b.filename.toLowerCase();
} }
if (valA < valB) return -1 * direction; if (valA < valB) return -1 * direction;
if (valA > valB) return 1 * direction; if (valA > valB) return 1 * direction;
if (key !== 'filename') return a.filename.localeCompare(b.filename);
// Secondary sort by filename if primary values are equal
if (key !== 'filename') {
return a.filename.localeCompare(b.filename);
}
return 0; return 0;
}); });
// 返回排序后的列表副本
return list; return list;
}); });
// 处理表头点击排序
const handleSort = (key: keyof FileListItem | 'type' | 'size' | 'mtime') => { const handleSort = (key: keyof FileListItem | 'type' | 'size' | 'mtime') => {
if (sortKey.value === key) { if (sortKey.value === key) {
sortDirection.value = sortDirection.value === 'asc' ? 'desc' : 'asc'; sortDirection.value = sortDirection.value === 'asc' ? 'desc' : 'asc';
@@ -505,96 +488,86 @@ const handleSort = (key: keyof FileListItem | 'type' | 'size' | 'mtime') => {
// --- 生命周期钩子 --- // --- 生命周期钩子 ---
onMounted(() => { onMounted(() => {
console.log('[文件管理器] 组件已挂载。'); console.log(`[FileManager ${props.sessionId}] Component mounted.`);
// 初始加载逻辑现在由 isConnected watch 处理 // Initial load logic is handled by watchEffect
// if (props.isConnected) {
// console.log('[文件管理器] 挂载时连接已激活,加载初始目录。');
// loadDirectory(currentPath.value); // 调用 composable 的方法
// }
}); });
// 使用 watchEffect 监听连接和 SFTP 就绪状态以触发初始加载 // 使用 watchEffect 监听连接和 SFTP 就绪状态以触发初始加载
// 恢复使用 props.wsDeps
watchEffect((onCleanup) => { watchEffect((onCleanup) => {
let unregisterSuccess: (() => void) | undefined; let unregisterSuccess: (() => void) | undefined;
let unregisterError: (() => void) | undefined; let unregisterError: (() => void) | undefined;
let timeoutId: number | undefined; // Use number for browser timeout ID let timeoutId: number | undefined;
// 清理函数,用于注销监听器和清除超时
const cleanupListeners = () => { const cleanupListeners = () => {
unregisterSuccess?.(); unregisterSuccess?.();
unregisterError?.(); unregisterError?.();
if (timeoutId) clearTimeout(timeoutId); if (timeoutId) clearTimeout(timeoutId);
// Only reset isFetchingInitialPath if it was set by this effect instance
if (isFetchingInitialPath.value) { if (isFetchingInitialPath.value) {
isFetchingInitialPath.value = false; isFetchingInitialPath.value = false;
} }
}; };
// 注册清理回调
onCleanup(cleanupListeners); onCleanup(cleanupListeners);
// 条件判断:连接、SFTP就绪、不在加载、初始加载未完成、未在获取初始路径 // 恢复使用 props.wsDeps.isConnected 和 props.wsDeps.isSftpReady
// Note: Removed fileList.value.length === 0 check to allow re-fetching if needed after disconnect/reconnect // 恢复使用 props.sftpManager.isLoading
if (props.isConnected && isSftpReady.value && !isLoading.value && !initialLoadDone.value && !isFetchingInitialPath.value) { if (props.wsDeps.isConnected.value && props.wsDeps.isSftpReady.value && !isLoading.value && !initialLoadDone.value && !isFetchingInitialPath.value) {
console.log('[文件管理器] 连接已建立,SFTP 已就绪,触发初始路径获取。'); console.log(`[FileManager ${props.sessionId}] Connection ready, fetching initial path.`);
isFetchingInitialPath.value = true; // 标记正在获取初始路径 isFetchingInitialPath.value = true;
const requestId = generateRequestId(); // 恢复使用 props.wsDeps 中的 sendMessage 和 onMessage
const requestedPath = '.'; // Always request the real path for '.' const { sendMessage: wsSend, onMessage: wsOnMessage } = props.wsDeps;
const requestId = generateRequestId(); // 使用本地辅助函数
const requestedPath = '.';
// 设置成功回调 unregisterSuccess = wsOnMessage('sftp:realpath:success', (payload: any, message: WebSocketMessage) => { // message 已有类型
unregisterSuccess = onMessage('sftp:realpath:success', (payload, message: WebSocketMessage) => {
if (message.requestId === requestId && payload.requestedPath === requestedPath) { if (message.requestId === requestId && payload.requestedPath === requestedPath) {
const absolutePath = payload.absolutePath; const absolutePath = payload.absolutePath;
console.log(`[文件管理器] 收到 "." 的绝对路径: ${absolutePath}开始加载目录。`); console.log(`[FileManager ${props.sessionId}] 收到 '.' 的绝对路径: ${absolutePath}开始加载目录。`);
currentPath.value = absolutePath; // 更新当前路径 currentPath.value = absolutePath;
loadDirectory(absolutePath); // 加载实际路径 loadDirectory(absolutePath); // 使用 props 中的 loadDirectory
initialLoadDone.value = true; // 标记初始加载完成 initialLoadDone.value = true;
cleanupListeners(); // 清理监听器和超时 cleanupListeners();
} }
}); });
// 设置错误回调 unregisterError = wsOnMessage('sftp:realpath:error', (payload: any, message: WebSocketMessage) => { // message 已有类型
unregisterError = onMessage('sftp:realpath:error', (payload, message: WebSocketMessage) => {
// Check if the error corresponds to *this* specific realpath request
if (message.requestId === requestId && message.path === requestedPath) { if (message.requestId === requestId && message.path === requestedPath) {
console.error(`[文件管理器] 获取初始路径 "." 失败:`, payload); console.error(`[FileManager ${props.sessionId}] 获取 '.' 的 realpath 失败:`, payload);
// Display error via console or a dedicated UI element, cannot assign to readonly 'error' // 适当地显示错误,也许设置 props.sftpManager.error?
console.error(t('fileManager.errors.getInitialPathFailed', { message: payload?.message || payload || 'Unknown error' })); // 目前仅记录日志。
// Do NOT set initialLoadDone = true, allowing retry if conditions change cleanupListeners();
// Do NOT call loadDirectory('.') as it might loop on error
cleanupListeners(); // Clean up listeners and timeout
} }
}); });
// 发送 realpath 请求 console.log(`[FileManager ${props.sessionId}] 发送 sftp:realpath 请求 (ID: ${requestId}) for path: ${requestedPath}`);
console.log(`[文件管理器] 发送 sftp:realpath 请求 (ID: ${requestId}) for path: ${requestedPath}`); wsSend({ type: 'sftp:realpath', requestId: requestId, payload: { path: requestedPath } });
sendMessage({ type: 'sftp:realpath', requestId: requestId, payload: { path: requestedPath } });
// 设置超时
timeoutId = setTimeout(() => { timeoutId = setTimeout(() => {
console.error(`[文件管理器] 获取初始路径 "." 超时 (ID: ${requestId})。`); console.error(`[FileManager ${props.sessionId}] 获取 '.' 的 realpath 超时 (ID: ${requestId})。`);
// Display error via console or a dedicated UI element cleanupListeners();
console.error(t('fileManager.errors.getInitialPathTimeout'));
cleanupListeners(); // 清理监听器
}, 10000); // 10 秒超时 }, 10000); // 10 秒超时
} else if (!props.isConnected) { } else if (!props.wsDeps.isConnected.value && initialLoadDone.value) { // 恢复使用 props.wsDeps.isConnected
// 连接断开时的清理 console.log(`[FileManager ${props.sessionId}] 连接丢失 (之前已加载),重置状态。`);
console.log('[文件管理器] 连接已断开 (watchEffect),重置 initialLoadDone 和 isFetchingInitialPath。');
selectedItems.value.clear(); selectedItems.value.clear();
lastClickedIndex.value = -1; lastClickedIndex.value = -1;
initialLoadDone.value = false; // 重置初始加载状态,允许下次连接时重新获取 initialLoadDone.value = false; // 重置初始加载状态
isFetchingInitialPath.value = false; // 重置获取状态 isFetchingInitialPath.value = false; // 重置获取状态
cleanupListeners(); // 确保断开连接时清理监听器和超时 cleanupListeners();
} }
}); });
onBeforeUnmount(() => { onBeforeUnmount(() => {
console.log('[文件管理器] 组件将卸载。'); console.log(`[FileManager ${props.sessionId}] 组件将卸载。`);
// WebSocket 监听器和上传任务的清理由各自的 composable 处理 // 调用注入的 SFTP 管理器提供的清理函数
// 确保上下文菜单监听器被移除 cleanupSftpHandlers();
// 如果其他 composables 也提供了 cleanup 函数,在此处调用
// cleanupUploader?.();
// cleanupEditor?.();
// 移除上下文菜单监听器
document.removeEventListener('click', hideContextMenu, { capture: true }); document.removeEventListener('click', hideContextMenu, { capture: true });
}); });
@@ -605,8 +578,8 @@ const getColumnKeyByIndex = (index: number): keyof typeof colWidths.value | null
}; };
const startResize = (event: MouseEvent, index: number) => { const startResize = (event: MouseEvent, index: number) => {
event.stopPropagation(); // Stop the event from bubbling up to the th's click handler event.stopPropagation();
event.preventDefault(); // Prevent text selection during drag event.preventDefault();
isResizing.value = true; isResizing.value = true;
resizingColumnIndex.value = index; resizingColumnIndex.value = index;
startX.value = event.clientX; startX.value = event.clientX;
@@ -614,32 +587,24 @@ const startResize = (event: MouseEvent, index: number) => {
if (colKey) { if (colKey) {
startWidth.value = colWidths.value[colKey]; startWidth.value = colWidths.value[colKey];
} else { } else {
// Fallback or error handling if index is out of bounds
const thElement = (event.target as HTMLElement).closest('th'); const thElement = (event.target as HTMLElement).closest('th');
startWidth.value = thElement?.offsetWidth ?? 100; // Estimate if key not found startWidth.value = thElement?.offsetWidth ?? 100;
} }
document.addEventListener('mousemove', handleResize); document.addEventListener('mousemove', handleResize);
document.addEventListener('mouseup', stopResize); document.addEventListener('mouseup', stopResize);
document.body.style.cursor = 'col-resize'; // Change cursor globally document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none'; // Prevent text selection globally document.body.style.userSelect = 'none';
}; };
const handleResize = (event: MouseEvent) => { const handleResize = (event: MouseEvent) => {
if (!isResizing.value || resizingColumnIndex.value < 0) return; if (!isResizing.value || resizingColumnIndex.value < 0) return;
const currentX = event.clientX; const currentX = event.clientX;
const diffX = currentX - startX.value; const diffX = currentX - startX.value;
const newWidth = Math.max(30, startWidth.value + diffX); // Minimum width 30px const newWidth = Math.max(30, startWidth.value + diffX);
const colKey = getColumnKeyByIndex(resizingColumnIndex.value); const colKey = getColumnKeyByIndex(resizingColumnIndex.value);
if (colKey) { if (colKey) {
colWidths.value[colKey] = newWidth; colWidths.value[colKey] = newWidth;
} }
// Note: Direct manipulation of <col> width via style might be needed
// if reactive updates to :style don't work reliably with table-layout:fixed.
// Let's try with reactive refs first.
}; };
const stopResize = () => { const stopResize = () => {
@@ -648,55 +613,46 @@ const stopResize = () => {
resizingColumnIndex.value = -1; resizingColumnIndex.value = -1;
document.removeEventListener('mousemove', handleResize); document.removeEventListener('mousemove', handleResize);
document.removeEventListener('mouseup', stopResize); document.removeEventListener('mouseup', stopResize);
document.body.style.cursor = ''; // Reset cursor document.body.style.cursor = '';
document.body.style.userSelect = ''; // Reset text selection document.body.style.userSelect = '';
} }
}; };
// --- Path Editing Logic --- // --- 路径编辑逻辑 ---
const startPathEdit = () => { const startPathEdit = () => {
if (isLoading.value || !props.isConnected) return; // Don't allow edit while loading or disconnected // 恢复使用 props.sftpManager.isLoading 和 props.wsDeps.isConnected
editablePath.value = currentPath.value; // Initialize input with current path if (isLoading.value || !props.wsDeps.isConnected.value) return;
editablePath.value = currentPath.value;
isEditingPath.value = true; isEditingPath.value = true;
nextTick(() => { nextTick(() => {
pathInputRef.value?.focus(); // Focus the input after it becomes visible pathInputRef.value?.focus();
pathInputRef.value?.select(); // Select the text pathInputRef.value?.select();
}); });
}; };
const handlePathInput = async (event?: Event) => { const handlePathInput = async (event?: Event) => {
// Check if triggered by blur or Enter key
if (event && event instanceof KeyboardEvent && event.key !== 'Enter') { if (event && event instanceof KeyboardEvent && event.key !== 'Enter') {
return; // Ignore other key presses return;
} }
const newPath = editablePath.value.trim(); const newPath = editablePath.value.trim();
isEditingPath.value = false; // Exit editing mode immediately isEditingPath.value = false;
if (newPath === currentPath.value || !newPath) { if (newPath === currentPath.value || !newPath) {
return; // No change or empty path, do nothing return;
} }
console.log(`[FileManager ${props.sessionId}] 尝试导航到新路径: ${newPath}`);
console.log(`[文件管理器] 尝试导航到新路径: ${newPath}`); // 调用 props 中的 loadDirectory
// Call loadDirectory which handles path validation via backend
await loadDirectory(newPath); await loadDirectory(newPath);
// If loadDirectory resulted in an error (handled within useSftpActions),
// the currentPath will not have changed, effectively reverting the UI.
// If successful, currentPath is updated by loadDirectory, and the UI reflects the new path.
}; };
const cancelPathEdit = () => { const cancelPathEdit = () => {
isEditingPath.value = false; isEditingPath.value = false;
// No need to reset editablePath, it will be set on next edit start
}; };
// Function to clear the error message - now calls the composable's function // 清除错误消息的函数 - 调用 props 中的 clearSftpError
const clearError = () => { const clearError = () => {
clearSftpError(); clearSftpError();
}; };
</script> </script>
<template> <template>
@@ -704,7 +660,8 @@ const clearError = () => {
<div class="toolbar"> <div class="toolbar">
<div class="path-bar"> <div class="path-bar">
<span v-show="!isEditingPath"> <span v-show="!isEditingPath">
{{ t('fileManager.currentPath') }}: <strong @click="startPathEdit" :title="t('fileManager.editPathTooltip')" class="editable-path">{{ currentPath }}</strong> <!-- 恢复使用 props.sftpManager.isLoading props.wsDeps.isConnected -->
{{ t('fileManager.currentPath') }}: <strong @click="startPathEdit" :title="t('fileManager.editPathTooltip')" class="editable-path" :class="{ 'disabled': isLoading || !props.wsDeps.isConnected.value }">{{ currentPath }}</strong>
</span> </span>
<input <input
v-show="isEditingPath" v-show="isEditingPath"
@@ -715,21 +672,24 @@ const clearError = () => {
@keyup.enter="handlePathInput" @keyup.enter="handlePathInput"
@blur="handlePathInput" @blur="handlePathInput"
@keyup.esc="cancelPathEdit" @keyup.esc="cancelPathEdit"
/> />
<button @click.stop="loadDirectory(currentPath)" :disabled="isLoading || !isConnected || isEditingPath" :title="t('fileManager.actions.refresh')">🔄</button> <!-- 恢复使用 props.sftpManager.isLoading props.wsDeps.isConnected.value -->
<!-- Pass event to handleItemClick for '..' --> <button @click.stop="loadDirectory(currentPath)" :disabled="isLoading || !props.wsDeps.isConnected.value || isEditingPath" :title="t('fileManager.actions.refresh')">🔄</button>
<button @click.stop="handleItemClick($event, { filename: '..', longname: '', attrs: { isDirectory: true, isFile: false, isSymbolicLink: false, size: 0, uid: 0, gid: 0, mode: 0, atime: 0, mtime: 0 } })" :disabled="isLoading || !isConnected || currentPath === '/' || isEditingPath" :title="t('fileManager.actions.parentDirectory')"></button> <!-- 恢复使用 props.sftpManager.isLoading props.wsDeps.isConnected.value -->
<button @click.stop="handleItemClick($event, { filename: '..', longname: '..', attrs: { isDirectory: true, isFile: false, isSymbolicLink: false, size: 0, uid: 0, gid: 0, mode: 0, atime: 0, mtime: 0 } })" :disabled="isLoading || !props.wsDeps.isConnected.value || currentPath === '/' || isEditingPath" :title="t('fileManager.actions.parentDirectory')"></button>
</div> </div>
<div class="actions-bar"> <div class="actions-bar">
<input type="file" ref="fileInputRef" @change="handleFileSelected" multiple style="display: none;" /> <input type="file" ref="fileInputRef" @change="handleFileSelected" multiple style="display: none;" />
<button @click="triggerFileUpload" :disabled="isLoading || !props.isConnected" :title="t('fileManager.actions.uploadFile')">📤 {{ t('fileManager.actions.upload') }}</button> <!-- 恢复使用 props.sftpManager.isLoading props.wsDeps.isConnected.value -->
<button @click="handleNewFolderContextMenuClick" :disabled="isLoading || !props.isConnected" :title="t('fileManager.actions.newFolder')"> {{ t('fileManager.actions.newFolder') }}</button> <!-- 调用修改后的函数 --> <button @click="triggerFileUpload" :disabled="isLoading || !props.wsDeps.isConnected.value" :title="t('fileManager.actions.uploadFile')">📤 {{ t('fileManager.actions.upload') }}</button>
<button @click="handleNewFileContextMenuClick" :disabled="isLoading || !props.isConnected" :title="t('fileManager.actions.newFile')">📄 {{ t('fileManager.actions.newFile') }}</button> <!-- 调用修改后的函数 --> <!-- 恢复使用 props.sftpManager.isLoading props.wsDeps.isConnected.value -->
<button @click="handleNewFolderContextMenuClick" :disabled="isLoading || !props.wsDeps.isConnected.value" :title="t('fileManager.actions.newFolder')"> {{ t('fileManager.actions.newFolder') }}</button>
<!-- 恢复使用 props.sftpManager.isLoading props.wsDeps.isConnected.value -->
<button @click="handleNewFileContextMenuClick" :disabled="isLoading || !props.wsDeps.isConnected.value" :title="t('fileManager.actions.newFile')">📄 {{ t('fileManager.actions.newFile') }}</button>
</div> </div>
</div> </div>
<!-- File List Container --> <!-- 文件列表容器 -->
<div <div
class="file-list-container" class="file-list-container"
:class="{ 'drag-over': isDraggingOver }" :class="{ 'drag-over': isDraggingOver }"
@@ -1,7 +1,7 @@
<template> <template>
<div class="status-monitor"> <div class="status-monitor">
<h4>服务器状态</h4> <h4>服务器状态</h4>
<div v-if="!statusData" class="loading-status"> <div v-if="!serverStatus" class="loading-status">
等待数据... 等待数据...
</div> </div>
<div v-else class="status-grid"> <div v-else class="status-grid">
@@ -19,14 +19,14 @@
<div class="status-item"> <div class="status-item">
<label>CPU:</label> <label>CPU:</label>
<div class="progress-bar-container"> <div class="progress-bar-container">
<div class="progress-bar" :style="{ width: `${statusData.cpuPercent ?? 0}%` }"></div> <div class="progress-bar" :style="{ width: `${serverStatus.cpuPercent ?? 0}%` }"></div>
</div> </div>
<span>{{ statusData.cpuPercent?.toFixed(1) ?? 'N/A' }}%</span> <span>{{ serverStatus.cpuPercent?.toFixed(1) ?? 'N/A' }}%</span>
</div> </div>
<div class="status-item"> <div class="status-item">
<label>内存:</label> <label>内存:</label>
<div class="progress-bar-container"> <div class="progress-bar-container">
<div class="progress-bar" :style="{ width: `${statusData.memPercent ?? 0}%` }"></div> <div class="progress-bar" :style="{ width: `${serverStatus.memPercent ?? 0}%` }"></div>
</div> </div>
<span class="mem-disk-details">{{ memDisplay }}</span> <span class="mem-disk-details">{{ memDisplay }}</span>
</div> </div>
@@ -34,25 +34,25 @@
<div class="status-item"> <div class="status-item">
<label>Swap:</label> <label>Swap:</label>
<div class="progress-bar-container"> <div class="progress-bar-container">
<div class="progress-bar swap-bar" :style="{ width: `${statusData.swapPercent ?? 0}%` }"></div> <div class="progress-bar swap-bar" :style="{ width: `${serverStatus.swapPercent ?? 0}%` }"></div>
</div> </div>
<span class="mem-disk-details">{{ swapDisplay }}</span> <span class="mem-disk-details">{{ swapDisplay }}</span>
</div> </div>
<div class="status-item"> <div class="status-item">
<label>磁盘 (/):</label> <label>磁盘 (/):</label>
<div class="progress-bar-container"> <div class="progress-bar-container">
<div class="progress-bar" :style="{ width: `${statusData.diskPercent ?? 0}%` }"></div> <div class="progress-bar" :style="{ width: `${serverStatus.diskPercent ?? 0}%` }"></div>
</div> </div>
<span class="mem-disk-details">{{ diskDisplay }}</span> <span class="mem-disk-details">{{ diskDisplay }}</span>
</div> </div>
<div class="status-item network-rate"> <div class="status-item network-rate">
<label>网络 ({{ statusData.netInterface || '...' }}):</label> <label>网络 ({{ serverStatus.netInterface || '...' }}):</label>
<span class="rate down"> {{ formatBytesPerSecond(statusData.netRxRate) }}</span> <span class="rate down"> {{ formatBytesPerSecond(serverStatus.netRxRate) }}</span>
<span class="rate up"> {{ formatBytesPerSecond(statusData.netTxRate) }}</span> <span class="rate up"> {{ formatBytesPerSecond(serverStatus.netTxRate) }}</span>
</div> </div>
</div> </div>
<div v-if="error" class="status-error"> <div v-if="statusError" class="status-error">
错误: {{ error }} 错误: {{ statusError }}
</div> </div>
</div> </div>
</template> </template>
@@ -79,18 +79,19 @@ interface ServerStatus {
osName?: string; // 操作系统名称 osName?: string; // 操作系统名称
} }
// Props 用于接收父组件传递的状态数据和错误信息 // 更新 Props 定义
const props = defineProps<{ const props = defineProps<{
statusData: ServerStatus | null; sessionId: string; // 添加会话 ID
error?: string | null; serverStatus: ServerStatus | null; // 更改名称从 statusData 到 serverStatus
statusError?: string | null; // 更改名称从 error 到 statusError
}>(); }>();
// --- 缓存状态 --- // --- 缓存状态 ---
const cachedCpuModel = ref<string | null>(null); const cachedCpuModel = ref<string | null>(null);
const cachedOsName = ref<string | null>(null); const cachedOsName = ref<string | null>(null);
// 监听传入的 statusData 变化以更新缓存 // 监听传入的 serverStatus 变化以更新缓存 (更新引用)
watch(() => props.statusData, (newData) => { watch(() => props.serverStatus, (newData) => {
if (newData) { if (newData) {
// 仅当新数据有效时更新缓存 // 仅当新数据有效时更新缓存
if (newData.cpuModel !== undefined && newData.cpuModel !== null && newData.cpuModel !== '') { if (newData.cpuModel !== undefined && newData.cpuModel !== null && newData.cpuModel !== '') {
@@ -103,15 +104,15 @@ watch(() => props.statusData, (newData) => {
// 如果 newData 为 null (例如断开连接),不清除缓存 // 如果 newData 为 null (例如断开连接),不清除缓存
}, { immediate: true }); // 立即执行一次以初始化缓存 }, { immediate: true }); // 立即执行一次以初始化缓存
// --- 显示计算属性 (包含缓存逻辑) --- // --- 显示计算属性 (包含缓存逻辑) - 更新引用 ---
const displayCpuModel = computed(() => { const displayCpuModel = computed(() => {
// 优先使用当前有效数据,否则回退到缓存,最后是 'N/A' // 优先使用当前有效数据,否则回退到缓存,最后是 'N/A'
return (props.statusData?.cpuModel ?? cachedCpuModel.value) || 'N/A'; return (props.serverStatus?.cpuModel ?? cachedCpuModel.value) || 'N/A';
}); });
const displayOsName = computed(() => { const displayOsName = computed(() => {
// 优先使用当前有效数据,否则回退到缓存,最后是 'N/A' // 优先使用当前有效数据,否则回退到缓存,最后是 'N/A'
return (props.statusData?.osName ?? cachedOsName.value) || 'N/A'; return (props.serverStatus?.osName ?? cachedOsName.value) || 'N/A';
}); });
@@ -133,9 +134,9 @@ const formatKbToGb = (kb?: number): string => {
return `${gb.toFixed(1)} GB`; return `${gb.toFixed(1)} GB`;
}; };
// 计算属性用于显示内存信息 // 计算属性用于显示内存信息 (更新引用)
const memDisplay = computed(() => { const memDisplay = computed(() => {
const data = props.statusData; const data = props.serverStatus;
if (!data || data.memUsed === undefined || data.memTotal === undefined) return 'N/A'; // 检查数据有效性 if (!data || data.memUsed === undefined || data.memTotal === undefined) return 'N/A'; // 检查数据有效性
const percent = data.memPercent !== undefined ? `(${data.memPercent.toFixed(1)}%)` : ''; const percent = data.memPercent !== undefined ? `(${data.memPercent.toFixed(1)}%)` : '';
// 确保 MB 值在是整数时不显示小数 // 确保 MB 值在是整数时不显示小数
@@ -144,9 +145,9 @@ const memDisplay = computed(() => {
return `${usedMb} MB / ${totalMb} MB ${percent}`; return `${usedMb} MB / ${totalMb} MB ${percent}`;
}); });
// 计算属性用于显示磁盘信息 // 计算属性用于显示磁盘信息 (更新引用)
const diskDisplay = computed(() => { const diskDisplay = computed(() => {
const data = props.statusData; const data = props.serverStatus;
if (!data || data.diskUsed === undefined || data.diskTotal === undefined) return 'N/A'; // 检查数据有效性 if (!data || data.diskUsed === undefined || data.diskTotal === undefined) return 'N/A'; // 检查数据有效性
// 百分比代表已用空间 // 百分比代表已用空间
const percent = data.diskPercent !== undefined ? `(${data.diskPercent.toFixed(1)}%)` : ''; const percent = data.diskPercent !== undefined ? `(${data.diskPercent.toFixed(1)}%)` : '';
@@ -154,9 +155,9 @@ const diskDisplay = computed(() => {
return `${formatKbToGb(data.diskUsed)} / ${formatKbToGb(data.diskTotal)} ${percent}`; return `${formatKbToGb(data.diskUsed)} / ${formatKbToGb(data.diskTotal)} ${percent}`;
}); });
// 计算属性用于显示 Swap 信息 // 计算属性用于显示 Swap 信息 (更新引用)
const swapDisplay = computed(() => { const swapDisplay = computed(() => {
const data = props.statusData; const data = props.serverStatus;
// 处理 swap 可能为 undefined 或 0 的情况 // 处理 swap 可能为 undefined 或 0 的情况
const used = data?.swapUsed ?? 0; const used = data?.swapUsed ?? 0;
const total = data?.swapTotal ?? 0; const total = data?.swapTotal ?? 0;
@@ -7,6 +7,7 @@ import 'xterm/css/xterm.css'; // 引入 xterm 样式
// 定义 props 和 emits // 定义 props 和 emits
const props = defineProps<{ const props = defineProps<{
sessionId: string; // 会话 ID
stream?: ReadableStream<string>; // 用于接收来自 WebSocket 的数据流 (可选) stream?: ReadableStream<string>; // 用于接收来自 WebSocket 的数据流 (可选)
options?: object; // xterm 的配置选项 options?: object; // xterm 的配置选项
}>(); }>();
@@ -0,0 +1,146 @@
<script setup lang="ts">
import { PropType } from 'vue';
// 定义会话状态的简化接口 (仅包含标签栏需要的信息)
interface SessionTabInfo {
sessionId: string;
connectionName: string; // 显示在标签上的名称
}
// 定义 Props
const props = defineProps({
sessions: {
type: Array as PropType<SessionTabInfo[]>,
required: true,
},
activeSessionId: {
type: String as PropType<string | null>,
required: true,
},
});
// 定义事件
const emit = defineEmits(['activate-session', 'close-session']);
const activateSession = (sessionId: string) => {
if (sessionId !== props.activeSessionId) {
emit('activate-session', sessionId);
}
};
const closeSession = (event: MouseEvent, sessionId: string) => {
event.stopPropagation(); // 阻止事件冒泡到标签点击事件
emit('close-session', sessionId);
};
</script>
<template>
<div class="terminal-tab-bar">
<ul class="tab-list">
<li
v-for="session in sessions"
:key="session.sessionId"
:class="['tab-item', { active: session.sessionId === activeSessionId }]"
@click="activateSession(session.sessionId)"
:title="session.connectionName"
>
<span class="tab-name">{{ session.connectionName }}</span>
<button class="close-tab-button" @click="closeSession($event, session.sessionId)" title="关闭标签页">
&times; <!-- 使用 HTML 实体 '×' -->
</button>
</li>
</ul>
<!-- 可以添加一个 "+" 按钮来打开新标签/连接 -->
<!-- <button class="add-tab-button">+</button> -->
</div>
</template>
<style scoped>
.terminal-tab-bar {
display: flex;
background-color: #e0e0e0; /* 标签栏背景色 */
border-bottom: 1px solid #bdbdbd;
overflow-x: auto; /* 如果标签过多则允许水平滚动 */
white-space: nowrap;
padding: 0 0.5rem; /* 左右留出一点空间 */
}
.tab-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
}
.tab-item {
display: flex;
align-items: center;
padding: 0.5rem 0.8rem;
cursor: pointer;
border-right: 1px solid #bdbdbd;
background-color: #f0f0f0; /* 未激活标签背景 */
color: #616161; /* 未激活标签文字颜色 */
transition: background-color 0.2s ease, color 0.2s ease;
max-width: 200px; /* 限制标签最大宽度 */
position: relative; /* 为了关闭按钮定位 */
}
.tab-item:hover {
background-color: #e0e0e0; /* 悬停时背景 */
}
.tab-item.active {
background-color: #ffffff; /* 激活标签背景 */
color: #333333; /* 激活标签文字颜色 */
border-bottom: 1px solid #ffffff; /* 覆盖底部边框,使其看起来与下方内容区域连接 */
position: relative;
z-index: 1; /* 确保激活标签在上方 */
}
.tab-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-right: 0.8rem; /* 与关闭按钮的间距 */
}
.close-tab-button {
background: none;
border: none;
color: #9e9e9e;
cursor: pointer;
font-size: 1.1em;
padding: 0 0.3rem;
line-height: 1;
border-radius: 50%;
margin-left: auto; /* 将按钮推到右侧 */
}
.close-tab-button:hover {
background-color: #bdbdbd;
color: #ffffff;
}
.tab-item.active .close-tab-button {
color: #757575; /* 激活标签上的关闭按钮颜色 */
}
.tab-item.active .close-tab-button:hover {
background-color: #e0e0e0;
color: #333333;
}
/* 可选:添加新标签按钮样式 */
/*
.add-tab-button {
background: none;
border: none;
padding: 0.5rem 0.8rem;
cursor: pointer;
font-size: 1.2em;
color: #616161;
}
.add-tab-button:hover {
background-color: #d0d0d0;
}
*/
</style>
@@ -7,7 +7,12 @@ import { useConnectionsStore, ConnectionInfo } from '../stores/connections.store
import { useTagsStore, TagInfo } from '../stores/tags.store'; import { useTagsStore, TagInfo } from '../stores/tags.store';
// 定义事件 // 定义事件
const emit = defineEmits(['connect-request', 'request-add-connection', 'request-edit-connection']); // 添加新事件 const emit = defineEmits([
'connect-request', // 左键单击 - 请求激活或替换当前标签
'open-new-session', // 中键单击 - 请求在新标签中打开
'request-add-connection', // 右键菜单 - 添加
'request-edit-connection' // 右键菜单 - 编辑
]);
const { t } = useI18n(); const { t } = useI18n();
// const router = useRouter(); // 不再需要 // const router = useRouter(); // 不再需要
@@ -127,6 +132,12 @@ onMounted(() => {
connectionsStore.fetchConnections(); connectionsStore.fetchConnections();
tagsStore.fetchTags(); tagsStore.fetchTags();
}); });
// 处理中键点击(在新标签页打开)
const handleOpenInNewTab = (connectionId: number) => {
emit('open-new-session', connectionId);
closeContextMenu(); // 如果右键菜单是打开的,也关闭它
};
</script> </script>
<template> <template>
@@ -156,7 +167,9 @@ onMounted(() => {
v-for="conn in group.connections" v-for="conn in group.connections"
:key="conn.id" :key="conn.id"
class="connection-item" class="connection-item"
@click="handleConnect(conn.id)" @click.left="handleConnect(conn.id)"
@click.middle.prevent="handleOpenInNewTab(conn.id)"
@auxclick.prevent="handleOpenInNewTab(conn.id)"
@contextmenu.prevent="showContextMenu($event, conn)" @contextmenu.prevent="showContextMenu($event, conn)"
> >
<i class="fas fa-server connection-icon"></i> <i class="fas fa-server connection-icon"></i>
@@ -1,5 +1,5 @@
import { ref, reactive, nextTick, onUnmounted, type Ref } from 'vue'; import { ref, reactive, nextTick, onUnmounted, readonly, type Ref } from 'vue'; // 再次修正导入,移除大写 Readonly
import { useWebSocketConnection } from './useWebSocketConnection'; // 导入 hook import { createWebSocketConnectionManager } from './useWebSocketConnection'; // 导入工厂函数
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import type { FileListItem } from '../types/sftp.types'; // 从 sftp 类型文件导入 import type { FileListItem } from '../types/sftp.types'; // 从 sftp 类型文件导入
import type { UploadItem } from '../types/upload.types'; // 从 upload 类型文件导入 import type { UploadItem } from '../types/upload.types'; // 从 upload 类型文件导入
@@ -20,14 +20,16 @@ const joinPath = (base: string, name: string): string => {
return `${base}/${name}`; return `${base}/${name}`;
}; };
export function useFileUploader( export function useFileUploader(
currentPathRef: Ref<string>, currentPathRef: Ref<string>,
fileListRef: Ref<Readonly<FileListItem[]>>, // 传入 fileList 用于检查覆盖 fileListRef: Readonly<Ref<readonly FileListItem[]>>, // 使用 Readonly 类型
refreshDirectory: () => void // 上传成功后刷新目录的回调函数 refreshDirectory: () => void, // 上传成功后刷新目录的回调函数
sessionId: string,
dbConnectionId: string
) { ) {
const { t } = useI18n(); const { t } = useI18n();
const { sendMessage, onMessage, isConnected } = useWebSocketConnection(); // 使用工厂函数创建WebSocket连接管理器,并传入t函数
const { sendMessage, onMessage, isConnected } = createWebSocketConnectionManager(sessionId, dbConnectionId, t);
// 对 uploads 字典使用 reactive 以获得更好的深度响应性 // 对 uploads 字典使用 reactive 以获得更好的深度响应性
const uploads = reactive<Record<string, UploadItem>>({}); const uploads = reactive<Record<string, UploadItem>>({});
@@ -128,8 +130,8 @@ export function useFileUploader(
const remotePath = joinPath(currentPathRef.value, file.name); const remotePath = joinPath(currentPathRef.value, file.name);
// 使用传入的 fileListRef 检查是否覆盖 // 使用传入的 fileListRef 检查是否覆盖
// 为 item 添加显式类型 FileListItem // fileListRef.value 现在是 readonly FileListItem[]
if (fileListRef.value.some((item: FileListItem) => item.filename === file.name && !item.attrs.isDirectory)) { if (fileListRef.value.some((item: FileListItem) => item.filename === file.name && !item.attrs.isDirectory)) { // 添加 item 类型注解
if (!confirm(t('fileManager.prompts.confirmOverwrite', { name: file.name }))) { if (!confirm(t('fileManager.prompts.confirmOverwrite', { name: file.name }))) {
console.log(`[文件上传模块] 用户取消了 ${file.name} 的上传`); console.log(`[文件上传模块] 用户取消了 ${file.name} 的上传`);
return; // 用户取消覆盖 return; // 用户取消覆盖
@@ -1,43 +1,68 @@
import { ref, readonly, type Ref, onUnmounted } from 'vue'; import { ref, readonly, type Ref, type ComputedRef } from 'vue'; // Removed onUnmounted, added ComputedRef
import { useWebSocketConnection } from './useWebSocketConnection'; // 只导入 hook import type { FileListItem, EditorFileContent } from '../types/sftp.types';
import { useI18n } from 'vue-i18n'; import type { WebSocketMessage, MessagePayload, MessageHandler } from '../types/websocket.types'; // 从类型文件导入
// 确保从类型文件导入所有需要的类型
import type { FileListItem, FileAttributes, EditorFileContent } from '../types/sftp.types';
import type { WebSocketMessage, MessagePayload } from '../types/websocket.types'; // 从类型文件导入
// --- 接口定义 (已移至 sftp.types.ts) --- /**
* @interface WebSocketDependencies
* @description Defines the necessary functions and state required from a WebSocket manager instance.
*/
export interface WebSocketDependencies {
sendMessage: (message: WebSocketMessage) => void;
onMessage: (type: string, handler: MessageHandler) => () => void;
isConnected: ComputedRef<boolean>;
isSftpReady: Readonly<Ref<boolean>>;
}
// Helper function (Copied from FileManager.vue) // Helper function
const generateRequestId = (): string => { const generateRequestId = (): string => `req-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
return `req-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
};
// Helper function (Copied from FileManager.vue) // Helper function
const joinPath = (base: string, name: string): string => { const joinPath = (base: string, name: string): string => {
if (base === '/') return `/${name}`; if (base === '/') return `/${name}`;
// Handle cases where base might end with '/' already return base.endsWith('/') ? `${base}${name}` : `${base}/${name}`;
if (base.endsWith('/')) return `${base}${name}`;
return `${base}/${name}`;
}; };
// Helper function (Copied from FileManager.vue) // Helper function
const sortFiles = (a: FileListItem, b: FileListItem): number => { const sortFiles = (a: FileListItem, b: FileListItem): number => {
if (a.attrs.isDirectory && !b.attrs.isDirectory) return -1; if (a.attrs.isDirectory && !b.attrs.isDirectory) return -1;
if (!a.attrs.isDirectory && b.attrs.isDirectory) return 1; if (!a.attrs.isDirectory && b.attrs.isDirectory) return 1;
return a.filename.localeCompare(b.filename); return a.filename.localeCompare(b.filename);
}; };
/**
export function useSftpActions(currentPathRef: Ref<string>) { * 创建并管理单个 SFTP 会话的操作。
const { t } = useI18n(); * 每个实例对应一个会话 (Session) 并依赖于一个 WebSocket 管理器实例。
// Import isSftpReady along with other needed functions/state *
const { sendMessage, onMessage, isConnected, isSftpReady } = useWebSocketConnection(); * @param {string} sessionId - 此 SFTP 管理器关联的会话 ID (用于日志记录)。
* @param {Ref<string>} currentPathRef - 一个外部 ref,用于跟踪和更新当前目录路径。
* @param {WebSocketDependencies} wsDeps - 从对应的 WebSocket 管理器实例注入的依赖项。
* @param {Function} t - i18n 翻译函数,从父组件传入
* @returns 一个包含状态、方法和清理函数的 SFTP 操作管理器对象。
*/
export function createSftpActionsManager(
sessionId: string,
currentPathRef: Ref<string>,
wsDeps: WebSocketDependencies,
t: Function
) {
const { sendMessage, onMessage, isConnected, isSftpReady } = wsDeps; // 使用注入的依赖
const fileList = ref<FileListItem[]>([]); const fileList = ref<FileListItem[]>([]);
const isLoading = ref<boolean>(false); const isLoading = ref<boolean>(false);
const error = ref<string | null>(null); const error = ref<string | null>(null);
const instanceSessionId = sessionId; // 保存会话 ID 用于日志
// Function to clear the error state // 用于存储注销函数的数组
const unregisterCallbacks: (() => void)[] = [];
// 清理函数,用于注销所有消息处理器
const cleanup = () => {
console.log(`[SFTP ${instanceSessionId}] Cleaning up message handlers.`);
unregisterCallbacks.forEach(cb => cb());
unregisterCallbacks.length = 0; // 清空数组
};
// 清除错误状态的函数
const clearSftpError = () => { const clearSftpError = () => {
error.value = null; error.value = null;
}; };
@@ -45,42 +70,37 @@ export function useSftpActions(currentPathRef: Ref<string>) {
// --- Action Methods --- // --- Action Methods ---
const loadDirectory = (path: string) => { const loadDirectory = (path: string) => {
// Check if SFTP is ready first
if (!isSftpReady.value) { if (!isSftpReady.value) {
error.value = t('fileManager.errors.sftpNotReady'); // Use a specific error message error.value = t('fileManager.errors.sftpNotReady');
isLoading.value = false; isLoading.value = false;
fileList.value = []; // Clear list if not ready fileList.value = [];
console.warn(`[useSftpActions] Attempted to load directory ${path} but SFTP is not ready.`); console.warn(`[SFTP ${instanceSessionId}] Attempted to load directory ${path} but SFTP is not ready.`);
return; return;
} }
// Original isConnected check might still be relevant as a fallback, but isSftpReady implies isConnected
// if (!isConnected.value) { ... } // Can likely be removed if isSftpReady logic is robust
console.log(`[useSftpActions] Loading directory: ${path}`); console.log(`[SFTP ${instanceSessionId}] Loading directory: ${path}`);
isLoading.value = true; isLoading.value = true;
error.value = null; error.value = null;
currentPathRef.value = path; // Update the external ref passed in currentPathRef.value = path; // 更新外部 ref
const requestId = generateRequestId(); const requestId = generateRequestId();
sendMessage({ type: 'sftp:readdir', requestId: requestId, payload: { path } }); sendMessage({ type: 'sftp:readdir', requestId: requestId, payload: { path } });
// Response handled by onSftpReaddirSuccess/Error
}; };
const createDirectory = (newDirName: string) => { const createDirectory = (newDirName: string) => {
if (!isSftpReady.value) { if (!isSftpReady.value) {
error.value = t('fileManager.errors.sftpNotReady'); error.value = t('fileManager.errors.sftpNotReady');
console.warn(`[useSftpActions] Attempted to create directory ${newDirName} but SFTP is not ready.`); console.warn(`[SFTP ${instanceSessionId}] Attempted to create directory ${newDirName} but SFTP is not ready.`);
return; return;
} }
const newFolderPath = joinPath(currentPathRef.value, newDirName); const newFolderPath = joinPath(currentPathRef.value, newDirName);
const requestId = generateRequestId(); const requestId = generateRequestId();
sendMessage({ type: 'sftp:mkdir', requestId: requestId, payload: { path: newFolderPath } }); sendMessage({ type: 'sftp:mkdir', requestId: requestId, payload: { path: newFolderPath } });
// Response handled by onSftpMkdirSuccess/Error
}; };
const createFile = (newFileName: string) => { const createFile = (newFileName: string) => {
if (!isSftpReady.value) { if (!isSftpReady.value) {
error.value = t('fileManager.errors.sftpNotReady'); error.value = t('fileManager.errors.sftpNotReady');
console.warn(`[useSftpActions] Attempted to create file ${newFileName} but SFTP is not ready.`); console.warn(`[SFTP ${instanceSessionId}] Attempted to create file ${newFileName} but SFTP is not ready.`);
return; return;
} }
const newFilePath = joinPath(currentPathRef.value, newFileName); const newFilePath = joinPath(currentPathRef.value, newFileName);
@@ -88,15 +108,14 @@ export function useSftpActions(currentPathRef: Ref<string>) {
sendMessage({ sendMessage({
type: 'sftp:writefile', type: 'sftp:writefile',
requestId: requestId, requestId: requestId,
payload: { path: newFilePath, content: '', encoding: 'utf8' } // Create by writing empty content payload: { path: newFilePath, content: '', encoding: 'utf8' }
}); });
// Response handled by onSftpWriteFileSuccess/Error (will trigger refresh)
}; };
const deleteItems = (items: FileListItem[]) => { const deleteItems = (items: FileListItem[]) => {
if (!isSftpReady.value) { if (!isSftpReady.value) {
error.value = t('fileManager.errors.sftpNotReady'); error.value = t('fileManager.errors.sftpNotReady');
console.warn(`[useSftpActions] Attempted to delete items but SFTP is not ready.`); console.warn(`[SFTP ${instanceSessionId}] Attempted to delete items but SFTP is not ready.`);
return; return;
} }
if (items.length === 0) return; if (items.length === 0) return;
@@ -106,13 +125,12 @@ export function useSftpActions(currentPathRef: Ref<string>) {
const requestId = generateRequestId(); const requestId = generateRequestId();
sendMessage({ type: actionType, requestId: requestId, payload: { path: targetPath } }); sendMessage({ type: actionType, requestId: requestId, payload: { path: targetPath } });
}); });
// Responses handled by onSftpRmdirSuccess/Error, onSftpUnlinkSuccess/Error (will trigger refresh)
}; };
const renameItem = (item: FileListItem, newName: string) => { const renameItem = (item: FileListItem, newName: string) => {
if (!isSftpReady.value) { if (!isSftpReady.value) {
error.value = t('fileManager.errors.sftpNotReady'); error.value = t('fileManager.errors.sftpNotReady');
console.warn(`[useSftpActions] Attempted to rename item ${item.filename} but SFTP is not ready.`); console.warn(`[SFTP ${instanceSessionId}] Attempted to rename item ${item.filename} but SFTP is not ready.`);
return; return;
} }
if (!newName || item.filename === newName) return; if (!newName || item.filename === newName) return;
@@ -120,82 +138,96 @@ export function useSftpActions(currentPathRef: Ref<string>) {
const newPath = joinPath(currentPathRef.value, newName); const newPath = joinPath(currentPathRef.value, newName);
const requestId = generateRequestId(); const requestId = generateRequestId();
sendMessage({ type: 'sftp:rename', requestId: requestId, payload: { oldPath, newPath } }); sendMessage({ type: 'sftp:rename', requestId: requestId, payload: { oldPath, newPath } });
// Response handled by onSftpRenameSuccess/Error (will trigger refresh)
}; };
const changePermissions = (item: FileListItem, mode: number) => { const changePermissions = (item: FileListItem, mode: number) => {
if (!isSftpReady.value) { if (!isSftpReady.value) {
error.value = t('fileManager.errors.sftpNotReady'); error.value = t('fileManager.errors.sftpNotReady');
console.warn(`[useSftpActions] Attempted to change permissions for ${item.filename} but SFTP is not ready.`); console.warn(`[SFTP ${instanceSessionId}] Attempted to change permissions for ${item.filename} but SFTP is not ready.`);
return; return;
} }
const targetPath = joinPath(currentPathRef.value, item.filename); const targetPath = joinPath(currentPathRef.value, item.filename);
const requestId = generateRequestId(); const requestId = generateRequestId();
sendMessage({ type: 'sftp:chmod', requestId: requestId, payload: { path: targetPath, mode: mode } }); sendMessage({ type: 'sftp:chmod', requestId: requestId, payload: { path: targetPath, mode: mode } });
// Response handled by onSftpChmodSuccess/Error (will trigger refresh)
}; };
// 注意: readFile 和 writeFile 的核心逻辑将由 useFileEditor 管理, // readFile 和 writeFile 仍然返回 Promise,并在内部处理自己的消息监听器注销
// 但 useSftpActions 可以提供基础的发送/接收机制(如果其他地方需要), const readFile = (path: string): Promise<EditorFileContent> => {
// 或者 useFileEditor 可以直接调用 sendMessage。暂时保留这些方法在这里。
const readFile = (path: string): Promise<EditorFileContent> => { // 使用导入的 EditorFileContent 类型
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (!isSftpReady.value) { if (!isSftpReady.value) {
console.warn(`[useSftpActions] Attempted to read file ${path} but SFTP is not ready.`); console.warn(`[SFTP ${instanceSessionId}] Attempted to read file ${path} but SFTP is not ready.`);
return reject(new Error(t('fileManager.errors.sftpNotReady'))); return reject(new Error(t('fileManager.errors.sftpNotReady')));
} }
const requestId = generateRequestId(); const requestId = generateRequestId();
let unregisterSuccess: (() => void) | null = null;
let unregisterError: (() => void) | null = null;
const unregisterSuccess = onMessage('sftp:readfile:success', (payload, message) => { const timeoutId = setTimeout(() => {
if (message.requestId === requestId && message.path === path) {
unregisterSuccess?.(); unregisterSuccess?.();
unregisterError?.(); unregisterError?.();
resolve({ content: payload.content, encoding: payload.encoding }); reject(new Error(t('fileManager.errors.readFileTimeout')));
}, 20000); // 20 秒超时
unregisterSuccess = onMessage('sftp:readfile:success', (payload: MessagePayload, message: WebSocketMessage) => {
// 确保 payload 是期望的类型
const successPayload = payload as { content: string; encoding: 'utf8' | 'base64' };
if (message.requestId === requestId && message.path === path) {
clearTimeout(timeoutId);
unregisterSuccess?.();
unregisterError?.();
resolve({ content: successPayload.content, encoding: successPayload.encoding });
} }
}); });
const unregisterError = onMessage('sftp:readfile:error', (payload, message) => { unregisterError = onMessage('sftp:readfile:error', (payload: MessagePayload, message: WebSocketMessage) => {
// 确保 payload 是期望的类型 (string)
const errorPayload = payload as string;
if (message.requestId === requestId && message.path === path) { if (message.requestId === requestId && message.path === path) {
clearTimeout(timeoutId);
unregisterSuccess?.(); unregisterSuccess?.();
unregisterError?.(); unregisterError?.();
reject(new Error(payload || 'Failed to read file')); reject(new Error(errorPayload || 'Failed to read file'));
} }
}); });
sendMessage({ type: 'sftp:readfile', requestId: requestId, payload: { path } }); sendMessage({ type: 'sftp:readfile', requestId: requestId, payload: { path } });
// Timeout for the request
setTimeout(() => {
unregisterSuccess?.();
unregisterError?.();
reject(new Error(t('fileManager.errors.readFileTimeout')));
}, 20000); // 20 second timeout
}); });
}; };
const writeFile = (path: string, content: string): Promise<void> => { const writeFile = (path: string, content: string): Promise<void> => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (!isSftpReady.value) { if (!isSftpReady.value) {
console.warn(`[useSftpActions] Attempted to write file ${path} but SFTP is not ready.`); console.warn(`[SFTP ${instanceSessionId}] Attempted to write file ${path} but SFTP is not ready.`);
return reject(new Error(t('fileManager.errors.sftpNotReady'))); return reject(new Error(t('fileManager.errors.sftpNotReady')));
} }
const requestId = generateRequestId(); const requestId = generateRequestId();
const encoding: 'utf8' | 'base64' = 'utf8'; // Assuming always sending utf8 const encoding: 'utf8' | 'base64' = 'utf8'; // 假设总是 utf8
let unregisterSuccess: (() => void) | null = null;
let unregisterError: (() => void) | null = null;
const unregisterSuccess = onMessage('sftp:writefile:success', (payload, message) => { const timeoutId = setTimeout(() => {
unregisterSuccess?.();
unregisterError?.();
reject(new Error(t('fileManager.errors.saveTimeout')));
}, 20000); // 20 秒超时
unregisterSuccess = onMessage('sftp:writefile:success', (payload: MessagePayload, message: WebSocketMessage) => {
if (message.requestId === requestId && message.path === path) { if (message.requestId === requestId && message.path === path) {
clearTimeout(timeoutId);
unregisterSuccess?.(); unregisterSuccess?.();
unregisterError?.(); unregisterError?.();
resolve(); resolve();
} }
}); });
const unregisterError = onMessage('sftp:writefile:error', (payload, message) => { unregisterError = onMessage('sftp:writefile:error', (payload: MessagePayload, message: WebSocketMessage) => {
// 确保 payload 是期望的类型 (string)
const errorPayload = payload as string;
if (message.requestId === requestId && message.path === path) { if (message.requestId === requestId && message.path === path) {
clearTimeout(timeoutId);
unregisterSuccess?.(); unregisterSuccess?.();
unregisterError?.(); unregisterError?.();
reject(new Error(payload || 'Failed to write file')); reject(new Error(errorPayload || 'Failed to write file'));
} }
}); });
@@ -204,112 +236,80 @@ export function useSftpActions(currentPathRef: Ref<string>) {
requestId: requestId, requestId: requestId,
payload: { path, content, encoding } payload: { path, content, encoding }
}); });
// Timeout for the request
setTimeout(() => {
unregisterSuccess?.();
unregisterError?.();
reject(new Error(t('fileManager.errors.saveTimeout')));
}, 20000); // 20 second timeout
}); });
}; };
// --- Message Handlers --- // --- Message Handlers ---
const onSftpReaddirSuccess = (payload: FileListItem[], message: WebSocketMessage) => { const onSftpReaddirSuccess = (payload: MessagePayload, message: WebSocketMessage) => {
// Only update if the path matches the current path this composable instance is tracking // 类型断言,因为我们知道 readdir:success 的 payload 是 FileListItem[]
const fileListPayload = payload as FileListItem[];
if (message.path === currentPathRef.value) { if (message.path === currentPathRef.value) {
console.log(`[useSftpActions] Received file list for ${message.path}`); console.log(`[SFTP ${instanceSessionId}] Received file list for ${message.path}`);
fileList.value = payload.sort(sortFiles); fileList.value = fileListPayload.sort(sortFiles);
isLoading.value = false; isLoading.value = false;
error.value = null; error.value = null;
} else { } else {
console.log(`[useSftpActions] Ignoring readdir success for ${message.path} (current: ${currentPathRef.value})`); console.log(`[SFTP ${instanceSessionId}] Ignoring readdir success for ${message.path} (current: ${currentPathRef.value})`);
} }
}; };
const onSftpReaddirError = (payload: string, message: WebSocketMessage) => { const onSftpReaddirError = (payload: MessagePayload, message: WebSocketMessage) => {
// 类型断言,因为我们知道 readdir:error 的 payload 是 string
const errorPayload = payload as string;
if (message.path === currentPathRef.value) { if (message.path === currentPathRef.value) {
console.error(`[useSftpActions] Error loading directory ${message.path}:`, payload); console.error(`[SFTP ${instanceSessionId}] Error loading directory ${message.path}:`, errorPayload);
error.value = payload; // Set the error message error.value = errorPayload;
isLoading.value = false; isLoading.value = false;
// Do NOT clear fileList.value here, keep the previous list visible
} }
}; };
// Generic handler for actions that should trigger a refresh on success
const onActionSuccessRefresh = (payload: MessagePayload, message: WebSocketMessage) => { const onActionSuccessRefresh = (payload: MessagePayload, message: WebSocketMessage) => {
// Simplify: Always refresh the current directory on any relevant success action. console.log(`[SFTP ${instanceSessionId}] Action ${message.type} successful. Refreshing current directory: ${currentPathRef.value}`);
// This avoids potential issues with path comparison logic. loadDirectory(currentPathRef.value);
console.log(`[useSftpActions] Action ${message.type} successful. Refreshing current directory: ${currentPathRef.value}`); error.value = null;
loadDirectory(currentPathRef.value); // Refresh the current directory
error.value = null; // Clear previous errors on success
}; };
// Generic handler for action errors const onActionError = (payload: MessagePayload, message: WebSocketMessage) => {
const onActionError = (payload: string, message: WebSocketMessage) => { // 类型断言,因为我们知道这些错误的 payload 是 string
console.error(`[useSftpActions] Action ${message.type} failed:`, payload); const errorPayload = payload as string;
// Display a generic error or use specific messages based on type console.error(`[SFTP ${instanceSessionId}] Action ${message.type} failed:`, errorPayload);
const actionTypeMap: Record<string, string> = { const actionTypeMap: Record<string, string> = {
'sftp:mkdir:error': t('fileManager.errors.createFolderFailed'), 'sftp:mkdir:error': t('fileManager.errors.createFolderFailed'),
'sftp:rmdir:error': t('fileManager.errors.deleteFailed'), 'sftp:rmdir:error': t('fileManager.errors.deleteFailed'),
'sftp:unlink:error': t('fileManager.errors.deleteFailed'), 'sftp:unlink:error': t('fileManager.errors.deleteFailed'),
'sftp:rename:error': t('fileManager.errors.renameFailed'), 'sftp:rename:error': t('fileManager.errors.renameFailed'),
'sftp:chmod:error': t('fileManager.errors.chmodFailed'), 'sftp:chmod:error': t('fileManager.errors.chmodFailed'),
'sftp:writefile:error': t('fileManager.errors.saveFailed'), // Added writefile error 'sftp:writefile:error': t('fileManager.errors.saveFailed'),
}; };
const prefix = actionTypeMap[message.type] || t('fileManager.errors.generic'); const prefix = actionTypeMap[message.type] || t('fileManager.errors.generic');
error.value = `${prefix}: ${payload}`; error.value = `${prefix}: ${errorPayload}`;
// Optionally stop loading indicator if one was active for this action
}; };
// --- Register Handlers --- // --- Register Handlers & Store Unregister Callbacks ---
const unregisterReaddirSuccess = onMessage('sftp:readdir:success', onSftpReaddirSuccess); unregisterCallbacks.push(onMessage('sftp:readdir:success', onSftpReaddirSuccess));
const unregisterReaddirError = onMessage('sftp:readdir:error', onSftpReaddirError); unregisterCallbacks.push(onMessage('sftp:readdir:error', onSftpReaddirError));
unregisterCallbacks.push(onMessage('sftp:mkdir:success', onActionSuccessRefresh));
unregisterCallbacks.push(onMessage('sftp:rmdir:success', onActionSuccessRefresh));
unregisterCallbacks.push(onMessage('sftp:unlink:success', onActionSuccessRefresh));
unregisterCallbacks.push(onMessage('sftp:rename:success', onActionSuccessRefresh));
unregisterCallbacks.push(onMessage('sftp:chmod:success', onActionSuccessRefresh));
unregisterCallbacks.push(onMessage('sftp:writefile:success', onActionSuccessRefresh));
unregisterCallbacks.push(onMessage('sftp:mkdir:error', onActionError));
unregisterCallbacks.push(onMessage('sftp:rmdir:error', onActionError));
unregisterCallbacks.push(onMessage('sftp:unlink:error', onActionError));
unregisterCallbacks.push(onMessage('sftp:rename:error', onActionError));
unregisterCallbacks.push(onMessage('sftp:chmod:error', onActionError));
unregisterCallbacks.push(onMessage('sftp:writefile:error', onActionError));
// Register generic handlers for actions that trigger refresh on success // 移除 onUnmounted 块
const unregisterMkdirSuccess = onMessage('sftp:mkdir:success', onActionSuccessRefresh);
const unregisterRmdirSuccess = onMessage('sftp:rmdir:success', onActionSuccessRefresh);
const unregisterUnlinkSuccess = onMessage('sftp:unlink:success', onActionSuccessRefresh);
const unregisterRenameSuccess = onMessage('sftp:rename:success', onActionSuccessRefresh);
const unregisterChmodSuccess = onMessage('sftp:chmod:success', onActionSuccessRefresh);
const unregisterWritefileSuccess = onMessage('sftp:writefile:success', onActionSuccessRefresh); // Refresh on successful write too
// Register generic error handlers
const unregisterMkdirError = onMessage('sftp:mkdir:error', onActionError);
const unregisterRmdirError = onMessage('sftp:rmdir:error', onActionError);
const unregisterUnlinkError = onMessage('sftp:unlink:error', onActionError);
const unregisterRenameError = onMessage('sftp:rename:error', onActionError);
const unregisterChmodError = onMessage('sftp:chmod:error', onActionError);
const unregisterWritefileError = onMessage('sftp:writefile:error', onActionError); // Handle writefile error display
// Unregister handlers when the composable's scope is destroyed
onUnmounted(() => {
console.log('[useSftpActions] Unmounting and unregistering handlers.');
unregisterReaddirSuccess?.();
unregisterReaddirError?.();
unregisterMkdirSuccess?.();
unregisterRmdirSuccess?.();
unregisterUnlinkSuccess?.();
unregisterRenameSuccess?.();
unregisterChmodSuccess?.();
unregisterWritefileSuccess?.();
unregisterMkdirError?.();
unregisterRmdirError?.();
unregisterUnlinkError?.();
unregisterRenameError?.();
unregisterChmodError?.();
unregisterWritefileError?.();
// Note: readFile/writeFile promise handlers are unregistered within the promise logic
});
return { return {
// State // State
fileList: readonly(fileList), fileList: readonly(fileList),
isLoading: readonly(isLoading), isLoading: readonly(isLoading),
error: readonly(error), error: readonly(error),
// currentPath: readonly(currentPath), // Path is managed via the passed ref
// Methods // Methods
loadDirectory, loadDirectory,
@@ -318,9 +318,12 @@ export function useSftpActions(currentPathRef: Ref<string>) {
deleteItems, deleteItems,
renameItem, renameItem,
changePermissions, changePermissions,
readFile, // Expose if needed by editor composable readFile,
writeFile, // Expose if needed by editor composable writeFile,
joinPath, // Expose helper if needed externally joinPath, // 暴露辅助函数
clearSftpError, // Expose the clear error function clearSftpError,
// Cleanup function
cleanup,
}; };
} }
@@ -1,12 +1,25 @@
import { ref, onUnmounted, type Ref } from 'vue'; import { ref, readonly, type Ref, ComputedRef } from 'vue'; // 修正导入,移除大写 Readonly
import { useWebSocketConnection } from './useWebSocketConnection'; // 只导入 hook 本身 // import { useWebSocketConnection } from './useWebSocketConnection'; // 移除全局导入
import { useI18n } from 'vue-i18n';
import type { Terminal } from 'xterm'; import type { Terminal } from 'xterm';
import type { WebSocketMessage, MessagePayload } from '../types/websocket.types'; // 从类型文件导入 import type { WebSocketMessage, MessagePayload } from '../types/websocket.types';
export function useSshTerminal() { // 定义与 WebSocket 相关的依赖接口
const { t } = useI18n(); export interface SshTerminalDependencies {
const { sendMessage, onMessage, isConnected } = useWebSocketConnection(); sendMessage: (message: WebSocketMessage) => void;
onMessage: (type: string, handler: (payload: any, fullMessage?: WebSocketMessage) => void) => () => void;
isConnected: ComputedRef<boolean>;
}
/**
* 创建一个 SSH 终端管理器实例
* @param sessionId 会话唯一标识符
* @param wsDeps WebSocket 依赖对象
* @param t i18n 翻译函数,从父组件传入
* @returns SSH 终端管理器实例
*/
export function createSshTerminalManager(sessionId: string, wsDeps: SshTerminalDependencies, t: Function) {
// 使用依赖注入的 WebSocket 函数
const { sendMessage, onMessage, isConnected } = wsDeps;
const terminalInstance = ref<Terminal | null>(null); const terminalInstance = ref<Terminal | null>(null);
const terminalOutputBuffer = ref<string[]>([]); // 缓冲 WebSocket 消息直到终端准备好 const terminalOutputBuffer = ref<string[]>([]); // 缓冲 WebSocket 消息直到终端准备好
@@ -22,7 +35,7 @@ export function useSshTerminal() {
// --- 终端事件处理 --- // --- 终端事件处理 ---
const handleTerminalReady = (term: Terminal) => { const handleTerminalReady = (term: Terminal) => {
console.log('[SSH终端模块] 终端实例已就绪。'); console.log(`[会话 ${sessionId}][SSH终端模块] 终端实例已就绪。`);
terminalInstance.value = term; terminalInstance.value = term;
// 将缓冲区的输出写入终端 // 将缓冲区的输出写入终端
terminalOutputBuffer.value.forEach(data => term.write(data)); terminalOutputBuffer.value.forEach(data => term.write(data));
@@ -32,31 +45,36 @@ export function useSshTerminal() {
}; };
const handleTerminalData = (data: string) => { const handleTerminalData = (data: string) => {
// console.debug('[SSH终端模块] 接收到终端输入:', data); // console.debug(`[会话 ${sessionId}][SSH终端模块] 接收到终端输入:`, data);
sendMessage({ type: 'ssh:input', payload: { data } }); sendMessage({ type: 'ssh:input', sessionId, payload: { data } });
}; };
const handleTerminalResize = (dimensions: { cols: number; rows: number }) => { const handleTerminalResize = (dimensions: { cols: number; rows: number }) => {
console.log('[SSH终端模块] 发送终端大小调整:', dimensions); console.log(`[会话 ${sessionId}][SSH终端模块] 发送终端大小调整:`, dimensions);
sendMessage({ type: 'ssh:resize', payload: dimensions }); sendMessage({ type: 'ssh:resize', sessionId, payload: dimensions });
}; };
// --- WebSocket 消息处理 --- // --- WebSocket 消息处理 ---
const handleSshOutput = (payload: MessagePayload, message: WebSocketMessage) => { const handleSshOutput = (payload: MessagePayload, message?: WebSocketMessage) => {
// 检查消息是否属于此会话
if (message?.sessionId && message.sessionId !== sessionId) {
return; // 忽略不属于此会话的消息
}
let outputData = payload; let outputData = payload;
// 检查是否为 Base64 编码 (需要后端配合发送 encoding 字段) // 检查是否为 Base64 编码 (需要后端配合发送 encoding 字段)
if (message.encoding === 'base64' && typeof outputData === 'string') { if (message?.encoding === 'base64' && typeof outputData === 'string') {
try { try {
outputData = atob(outputData); // 在浏览器环境中使用 atob outputData = atob(outputData); // 在浏览器环境中使用 atob
} catch (e) { } catch (e) {
console.error('[SSH终端模块] Base64 解码失败:', e, '原始数据:', message.payload); console.error(`[会话 ${sessionId}][SSH终端模块] Base64 解码失败:`, e, '原始数据:', message.payload);
outputData = `\r\n[解码错误: ${e}]\r\n`; // 在终端显示解码错误 outputData = `\r\n[解码错误: ${e}]\r\n`; // 在终端显示解码错误
} }
} }
// 如果不是 base64 或解码失败,确保它是字符串 // 如果不是 base64 或解码失败,确保它是字符串
else if (typeof outputData !== 'string') { else if (typeof outputData !== 'string') {
console.warn('[SSH终端模块] 收到非字符串 ssh:output payload:', outputData); console.warn(`[会话 ${sessionId}][SSH终端模块] 收到非字符串 ssh:output payload:`, outputData);
try { try {
outputData = JSON.stringify(outputData); // 尝试序列化 outputData = JSON.stringify(outputData); // 尝试序列化
} catch { } catch {
@@ -64,6 +82,19 @@ export function useSshTerminal() {
} }
} }
// 尝试过滤掉非标准的 OSC 184 序列
// 注意:这个正则表达式可能需要根据实际序列进行调整
// 它尝试匹配 \x1b]184;... 直到 \x1b\\ 或 \x07
const osc184Regex = /\x1b]184;[^\x1b\x07]*(\x1b\\|\x07)/g;
if (typeof outputData === 'string') {
const originalLength = outputData.length;
outputData = outputData.replace(osc184Regex, '');
if (outputData.length < originalLength) {
console.warn(`[会话 ${sessionId}][SSH终端模块] 过滤掉 OSC 184 序列。`);
}
}
if (terminalInstance.value) { if (terminalInstance.value) {
terminalInstance.value.write(outputData); terminalInstance.value.write(outputData);
} else { } else {
@@ -72,50 +103,80 @@ export function useSshTerminal() {
} }
}; };
const handleSshConnected = () => { const handleSshConnected = (payload: MessagePayload, message?: WebSocketMessage) => {
console.log('[SSH终端模块] SSH 会话已连接。'); // 检查消息是否属于此会话
if (message?.sessionId && message.sessionId !== sessionId) {
return; // 忽略不属于此会话的消息
}
console.log(`[会话 ${sessionId}][SSH终端模块] SSH 会话已连接。`);
// 连接成功后聚焦终端 // 连接成功后聚焦终端
terminalInstance.value?.focus(); terminalInstance.value?.focus();
// 清空可能存在的旧缓冲(虽然理论上此时应该已经 ready 了) // 清空可能存在的旧缓冲(虽然理论上此时应该已经 ready 了)
if (terminalOutputBuffer.value.length > 0) { if (terminalOutputBuffer.value.length > 0) {
console.warn('[SSH终端模块] SSH 连接时仍有缓冲数据,正在写入...'); console.warn(`[会话 ${sessionId}][SSH终端模块] SSH 连接时仍有缓冲数据,正在写入...`);
terminalOutputBuffer.value.forEach(data => terminalInstance.value?.write(data)); terminalOutputBuffer.value.forEach(data => terminalInstance.value?.write(data));
terminalOutputBuffer.value = []; terminalOutputBuffer.value = [];
} }
}; };
const handleSshDisconnected = (payload: MessagePayload) => { const handleSshDisconnected = (payload: MessagePayload, message?: WebSocketMessage) => {
// 检查消息是否属于此会话
if (message?.sessionId && message.sessionId !== sessionId) {
return; // 忽略不属于此会话的消息
}
const reason = payload || t('workspace.terminal.unknownReason'); // 使用 i18n 获取未知原因文本 const reason = payload || t('workspace.terminal.unknownReason'); // 使用 i18n 获取未知原因文本
console.log('[SSH终端模块] SSH 会话已断开:', reason); console.log(`[会话 ${sessionId}][SSH终端模块] SSH 会话已断开:`, reason);
terminalInstance.value?.writeln(`\r\n\x1b[31m${getTerminalText('disconnectMsg', { reason })}\x1b[0m`); terminalInstance.value?.writeln(`\r\n\x1b[31m${getTerminalText('disconnectMsg', { reason })}\x1b[0m`);
// 可以在这里添加其他清理逻辑,例如禁用输入 // 可以在这里添加其他清理逻辑,例如禁用输入
}; };
const handleSshError = (payload: MessagePayload) => { const handleSshError = (payload: MessagePayload, message?: WebSocketMessage) => {
// 检查消息是否属于此会话
if (message?.sessionId && message.sessionId !== sessionId) {
return; // 忽略不属于此会话的消息
}
const errorMsg = payload || t('workspace.terminal.unknownSshError'); // 使用 i18n const errorMsg = payload || t('workspace.terminal.unknownSshError'); // 使用 i18n
console.error('[SSH终端模块] SSH 错误:', errorMsg); console.error(`[会话 ${sessionId}][SSH终端模块] SSH 错误:`, errorMsg);
terminalInstance.value?.writeln(`\r\n\x1b[31m${getTerminalText('genericErrorMsg', { message: errorMsg })}\x1b[0m`); terminalInstance.value?.writeln(`\r\n\x1b[31m${getTerminalText('genericErrorMsg', { message: errorMsg })}\x1b[0m`);
}; };
const handleSshStatus = (payload: MessagePayload) => { const handleSshStatus = (payload: MessagePayload, message?: WebSocketMessage) => {
// 检查消息是否属于此会话
if (message?.sessionId && message.sessionId !== sessionId) {
return; // 忽略不属于此会话的消息
}
// 这个消息现在由 useWebSocketConnection 处理以更新全局状态栏消息 // 这个消息现在由 useWebSocketConnection 处理以更新全局状态栏消息
// 这里可以保留日志或用于其他特定于终端的 UI 更新(如果需要) // 这里可以保留日志或用于其他特定于终端的 UI 更新(如果需要)
const statusKey = payload?.key || 'unknown'; const statusKey = payload?.key || 'unknown';
const statusParams = payload?.params || {}; const statusParams = payload?.params || {};
console.log('[SSH终端模块] 收到 SSH 状态更新:', statusKey, statusParams); console.log(`[会话 ${sessionId}][SSH终端模块] 收到 SSH 状态更新:`, statusKey, statusParams);
// 可以在终端打印一些状态信息吗? // 可以在终端打印一些状态信息吗?
// terminalInstance.value?.writeln(`\r\n\x1b[34m[状态: ${statusKey}]\x1b[0m`); // terminalInstance.value?.writeln(`\r\n\x1b[34m[状态: ${statusKey}]\x1b[0m`);
}; };
const handleInfoMessage = (payload: MessagePayload) => { const handleInfoMessage = (payload: MessagePayload, message?: WebSocketMessage) => {
console.log('[SSH终端模块] 收到后端信息:', payload); // 检查消息是否属于此会话
if (message?.sessionId && message.sessionId !== sessionId) {
return; // 忽略不属于此会话的消息
}
console.log(`[会话 ${sessionId}][SSH终端模块] 收到后端信息:`, payload);
terminalInstance.value?.writeln(`\r\n\x1b[34m${getTerminalText('infoPrefix')} ${payload}\x1b[0m`); terminalInstance.value?.writeln(`\r\n\x1b[34m${getTerminalText('infoPrefix')} ${payload}\x1b[0m`);
}; };
const handleErrorMessage = (payload: MessagePayload) => { const handleErrorMessage = (payload: MessagePayload, message?: WebSocketMessage) => {
// 检查消息是否属于此会话
if (message?.sessionId && message.sessionId !== sessionId) {
return; // 忽略不属于此会话的消息
}
// 通用错误也可能需要显示在终端 // 通用错误也可能需要显示在终端
const errorMsg = payload || t('workspace.terminal.unknownGenericError'); // 使用 i18n const errorMsg = payload || t('workspace.terminal.unknownGenericError'); // 使用 i18n
console.error('[SSH终端模块] 收到后端通用错误:', errorMsg); console.error(`[会话 ${sessionId}][SSH终端模块] 收到后端通用错误:`, errorMsg);
terminalInstance.value?.writeln(`\r\n\x1b[31m${getTerminalText('errorPrefix')} ${errorMsg}\x1b[0m`); terminalInstance.value?.writeln(`\r\n\x1b[31m${getTerminalText('errorPrefix')} ${errorMsg}\x1b[0m`);
}; };
@@ -131,30 +192,62 @@ export function useSshTerminal() {
unregisterHandlers.push(onMessage('ssh:status', handleSshStatus)); unregisterHandlers.push(onMessage('ssh:status', handleSshStatus));
unregisterHandlers.push(onMessage('info', handleInfoMessage)); unregisterHandlers.push(onMessage('info', handleInfoMessage));
unregisterHandlers.push(onMessage('error', handleErrorMessage)); // 也处理通用错误 unregisterHandlers.push(onMessage('error', handleErrorMessage)); // 也处理通用错误
console.log('[SSH终端模块] 已注册 SSH 相关消息处理器。'); console.log(`[会话 ${sessionId}][SSH终端模块] 已注册 SSH 相关消息处理器。`);
}; };
const unregisterAllSshHandlers = () => { const unregisterAllSshHandlers = () => {
console.log('[SSH终端模块] 注销 SSH 相关消息处理器...'); console.log(`[会话 ${sessionId}][SSH终端模块] 注销 SSH 相关消息处理器...`);
unregisterHandlers.forEach(unregister => unregister?.()); unregisterHandlers.forEach(unregister => unregister?.());
unregisterHandlers.length = 0; // 清空数组 unregisterHandlers.length = 0; // 清空数组
}; };
// --- 清理 --- // 初始化时自动注册处理程序
onUnmounted(() => { registerSshHandlers();
// --- 清理函数 ---
const cleanup = () => {
unregisterAllSshHandlers(); unregisterAllSshHandlers();
// terminalInstance.value?.dispose(); // 终端实例的销毁由 TerminalComponent 负责 // terminalInstance.value?.dispose(); // 终端实例的销毁由 TerminalComponent 负责
terminalInstance.value = null; terminalInstance.value = null;
console.log('[SSH终端模块] Composable 已卸载。'); console.log(`[会话 ${sessionId}][SSH终端模块] 已清理。`);
}); };
// --- 暴露给组件的接口 --- // 返回工厂实例
return { return {
terminalInstance, // 暴露终端实例 ref,以便组件可以访问(如果需要) // 公共接口
handleTerminalReady, handleTerminalReady,
handleTerminalData, handleTerminalData,
handleTerminalResize, handleTerminalResize,
registerSshHandlers, // 暴露注册函数,由父组件在连接后调用 cleanup
unregisterAllSshHandlers, // 暴露注销函数,在断开或卸载时调用 };
}
// 保留兼容旧代码的函数(将在完全迁移后移除)
export function useSshTerminal(t: (key: string) => string) {
console.warn('⚠️ 使用已弃用的 useSshTerminal() 全局单例。请迁移到 createSshTerminalManager() 工厂函数。');
const terminalInstance = ref<Terminal | null>(null);
const handleTerminalReady = (term: Terminal) => {
console.log('[SSH终端模块][旧] 终端实例已就绪,但使用了已弃用的单例模式。');
terminalInstance.value = term;
};
const handleTerminalData = (data: string) => {
console.warn('[SSH终端模块][旧] 收到终端数据,但使用了已弃用的单例模式,无法发送。');
};
const handleTerminalResize = (dimensions: { cols: number; rows: number }) => {
console.warn('[SSH终端模块][旧] 收到终端大小调整,但使用了已弃用的单例模式,无法发送。');
};
// 返回与旧接口兼容的空函数,以避免错误
return {
terminalInstance,
handleTerminalReady,
handleTerminalData,
handleTerminalResize,
registerSshHandlers: () => console.warn('[SSH终端模块][旧] 调用了已弃用的 registerSshHandlers'),
unregisterAllSshHandlers: () => console.warn('[SSH终端模块][旧] 调用了已弃用的 unregisterAllSshHandlers'),
}; };
} }
@@ -1,32 +1,52 @@
import { ref, readonly, onUnmounted } from 'vue'; import { ref, readonly, watch, type Ref, ComputedRef } from 'vue'; // 修正导入,移除大写 Readonly, 添加 watch
import { useWebSocketConnection } from './useWebSocketConnection'; // 只导入 hook // import { useWebSocketConnection } from './useWebSocketConnection'; // 移除全局导入
import type { ServerStatus } from '../types/server.types'; // 从类型文件导入 import type { ServerStatus } from '../types/server.types';
import type { WebSocketMessage, MessagePayload } from '../types/websocket.types'; // 从类型文件导入 import type { WebSocketMessage, MessagePayload } from '../types/websocket.types';
// --- 接口定义 (已移至 server.types.ts) --- // 定义与 WebSocket 相关的依赖接口
export interface StatusMonitorDependencies {
onMessage: (type: string, handler: (payload: any, fullMessage?: WebSocketMessage) => void) => () => void;
isConnected: ComputedRef<boolean>;
}
export function useStatusMonitor() { /**
const { onMessage, isConnected } = useWebSocketConnection(); * 创建一个状态监控管理器实例
* @param sessionId 会话唯一标识符
* @param wsDeps WebSocket 依赖对象
* @returns 状态监控管理器实例
*/
export function createStatusMonitorManager(sessionId: string, wsDeps: StatusMonitorDependencies) {
const { onMessage, isConnected } = wsDeps;
const serverStatus = ref<ServerStatus | null>(null); const serverStatus = ref<ServerStatus | null>(null);
const statusError = ref<string | null>(null); // 存储状态获取错误 const statusError = ref<string | null>(null); // 存储状态获取错误
// --- WebSocket 消息处理 --- // --- WebSocket 消息处理 ---
const handleStatusUpdate = (payload: MessagePayload, message: WebSocketMessage) => { const handleStatusUpdate = (payload: MessagePayload, message?: WebSocketMessage) => {
// console.debug('[状态监控模块] 收到 status_update:', payload); // 检查消息是否属于此会话
if (message?.sessionId && message.sessionId !== sessionId) {
return; // 忽略不属于此会话的消息
}
// console.debug(`[会话 ${sessionId}][状态监控模块] 收到 status_update:`, JSON.stringify(payload)); // 添加日志
if (payload && payload.status) { if (payload && payload.status) {
serverStatus.value = payload.status; serverStatus.value = payload.status;
statusError.value = null; // 收到有效状态时清除错误 statusError.value = null; // 收到有效状态时清除错误
} else { } else {
console.warn('[状态监控模块] 收到缺少 payload.status 的 status_update 消息'); console.warn(`[会话 ${sessionId}][状态监控模块] 收到缺少 payload.status 的 status_update 消息`);
// 可以选择设置一个错误状态,表明数据格式不正确 // 可以选择设置一个错误状态,表明数据格式不正确
// statusError.value = '收到的状态数据格式无效'; // statusError.value = '收到的状态数据格式无效';
} }
}; };
// 处理可能的后端状态错误消息 (如果后端会发送的话) // 处理可能的后端状态错误消息 (如果后端会发送的话)
const handleStatusError = (payload: MessagePayload, message: WebSocketMessage) => { const handleStatusError = (payload: MessagePayload, message?: WebSocketMessage) => {
console.error('[状态监控模块] 收到状态错误消息:', payload); // 检查消息是否属于此会话
if (message?.sessionId && message.sessionId !== sessionId) {
return; // 忽略不属于此会话的消息
}
console.error(`[会话 ${sessionId}][状态监控模块] 收到状态错误消息:`, payload);
statusError.value = typeof payload === 'string' ? payload : '获取服务器状态时发生未知错误'; statusError.value = typeof payload === 'string' ? payload : '获取服务器状态时发生未知错误';
serverStatus.value = null; // 出错时清除状态数据 serverStatus.value = null; // 出错时清除状态数据
}; };
@@ -36,36 +56,81 @@ export function useStatusMonitor() {
let unregisterError: (() => void) | null = null; let unregisterError: (() => void) | null = null;
const registerStatusHandlers = () => { const registerStatusHandlers = () => {
// 仅在连接时注册处理器 // 防止重复注册
if (unregisterUpdate || unregisterError) {
console.log(`[会话 ${sessionId}][状态监控模块] 处理器已注册,跳过。`);
return;
}
if (isConnected.value) { if (isConnected.value) {
console.log('[状态监控模块] 注册状态消息处理器。'); console.log(`[会话 ${sessionId}][状态监控模块] 注册状态消息处理器。`);
unregisterUpdate = onMessage('status_update', handleStatusUpdate); unregisterUpdate = onMessage('status_update', handleStatusUpdate);
// 假设后端可能发送 'status:error' 类型的特定错误
unregisterError = onMessage('status:error', handleStatusError); unregisterError = onMessage('status:error', handleStatusError);
} else { } else {
console.warn('[状态监控模块] WebSocket 未连接,无法注册状态处理器。'); console.warn(`[会话 ${sessionId}][状态监控模块] WebSocket 未连接,无法注册状态处理器。`);
} }
}; };
const unregisterAllStatusHandlers = () => { const unregisterAllStatusHandlers = () => {
console.log('[状态监控模块] 注销状态消息处理器。'); if (unregisterUpdate || unregisterError) {
console.log(`[会话 ${sessionId}][状态监控模块] 注销状态消息处理器。`);
unregisterUpdate?.(); unregisterUpdate?.();
unregisterError?.(); unregisterError?.();
unregisterUpdate = null; unregisterUpdate = null;
unregisterError = null; unregisterError = null;
}
}; };
// --- 清理 --- // 监听连接状态变化以自动注册/注销处理器
onUnmounted(() => { watch(isConnected, (newValue, oldValue) => {
console.log(`[会话 ${sessionId}][状态监控模块] 连接状态变化: ${oldValue} -> ${newValue}`);
if (newValue) {
registerStatusHandlers();
// 连接成功后,可以考虑请求一次初始状态(如果后端支持)
// sendMessage({ type: 'status:get', sessionId });
} else {
unregisterAllStatusHandlers(); unregisterAllStatusHandlers();
console.log('[状态监控模块] Composable 已卸载。'); // 连接断开时清除状态
}); serverStatus.value = null;
statusError.value = '连接已断开'; // 或者使用 i18n
}
}, { immediate: true }); // immediate: true 确保初始状态下也会执行一次
// --- 清理函数 ---
const cleanup = () => {
unregisterAllStatusHandlers();
console.log(`[会话 ${sessionId}][状态监控模块] 已清理。`);
};
// --- 暴露接口 --- // --- 暴露接口 ---
return { return {
serverStatus: readonly(serverStatus), // 只读状态 serverStatus: readonly(serverStatus), // 只读状态
statusError: readonly(statusError), // 只读错误状态 statusError: readonly(statusError), // 只读错误状态
registerStatusHandlers, // 暴露注册函数 registerStatusHandlers, // 暴露注册函数,以便在需要时可以重新注册
unregisterAllStatusHandlers, // 暴露注销函数 unregisterAllStatusHandlers, // 暴露注销函数,以便在需要时可以手动注销
cleanup, // 暴露清理函数,在会话关闭时调用
};
}
// 保留兼容旧代码的函数(将在完全迁移后移除)
export function useStatusMonitor() {
console.warn('⚠️ 使用已弃用的 useStatusMonitor() 全局单例。请迁移到 createStatusMonitorManager() 工厂函数。');
const serverStatus = ref<ServerStatus | null>(null);
const statusError = ref<string | null>(null);
const registerStatusHandlers = () => {
console.warn('[状态监控模块][旧] 调用了已弃用的 registerStatusHandlers');
};
const unregisterAllStatusHandlers = () => {
console.warn('[状态监控模块][旧] 调用了已弃用的 unregisterAllStatusHandlers');
};
// 返回与旧接口兼容的空对象,以避免错误
return {
serverStatus: readonly(serverStatus),
statusError: readonly(statusError),
registerStatusHandlers,
unregisterAllStatusHandlers,
}; };
} }
@@ -1,215 +1,223 @@
import { ref, shallowRef, onUnmounted, computed, type Ref, readonly } from 'vue'; import { ref, shallowRef, computed, readonly } from 'vue';
import { useI18n } from 'vue-i18n';
// 从类型文件导入 WebSocket 相关类型
import type { ConnectionStatus, MessagePayload, WebSocketMessage, MessageHandler } from '../types/websocket.types'; import type { ConnectionStatus, MessagePayload, WebSocketMessage, MessageHandler } from '../types/websocket.types';
// --- 类型定义 (已移至 websocket.types.ts) --- /**
// export type ConnectionStatus = ...; * 创建并管理单个 WebSocket 连接实例。
// export type MessagePayload = ...; * 每个实例对应一个会话 (Session)。
// export interface WebSocketMessage { ... } *
// export type MessageHandler = ...; * @param {string} sessionId - 此 WebSocket 连接关联的会话 ID (用于日志记录)。
* @param {string} dbConnectionId - 此 WebSocket 连接关联的数据库连接 ID (用于后端识别)。
* @param {Function} t - i18n 翻译函数,从父组件传入
* @returns 一个包含状态和方法的 WebSocket 连接管理器对象。
*/
export function createWebSocketConnectionManager(sessionId: string, dbConnectionId: string, t: Function) {
// --- Instance State ---
// 每个实例拥有独立的 WebSocket 对象、状态和消息处理器
const ws = shallowRef<WebSocket | null>(null); // WebSocket 实例
const connectionStatus = ref<ConnectionStatus>('disconnected'); // 连接状态
const statusMessage = ref<string>(''); // 状态描述文本
const isSftpReady = ref<boolean>(false); // SFTP 是否就绪
const messageHandlers = new Map<string, Set<MessageHandler>>(); // 此实例的消息处理器注册表
const instanceSessionId = sessionId; // 保存会话 ID 用于日志
const instanceDbConnectionId = dbConnectionId; // 保存数据库连接 ID
// --- End Instance State ---
// --- Singleton State within the module scope --- /**
// This ensures only one WebSocket connection and state is managed across the app. * 安全地获取状态文本的辅助函数
const ws = shallowRef<WebSocket | null>(null); // Use shallowRef for the WebSocket object itself * @param {string} statusKey - i18n 键名 (例如 'connectingWs')
const connectionStatus = ref<ConnectionStatus>('disconnected'); * @param {Record<string, unknown>} [params] - i18n 插值参数
const statusMessage = ref<string>(''); * @returns {string} 翻译后的文本或键名本身 (如果翻译失败)
const connectionIdForSession = ref<string | null>(null); // Store the connectionId used for the current session */
const isSftpReady = ref<boolean>(false); // Track SFTP readiness
// Registry for message handlers
const messageHandlers = new Map<string, Set<MessageHandler>>();
// --- End Singleton State ---
export function useWebSocketConnection() {
const { t } = useI18n(); // Get t function for status messages
// Helper to get status text safely
const getStatusText = (statusKey: string, params?: Record<string, unknown>): string => { const getStatusText = (statusKey: string, params?: Record<string, unknown>): string => {
try { try {
// Use a fallback key or message if translation is missing
const translated = t(`workspace.status.${statusKey}`, params || {}); const translated = t(`workspace.status.${statusKey}`, params || {});
// Check if the key itself was returned (indicating missing translation)
return translated === `workspace.status.${statusKey}` ? statusKey : translated; return translated === `workspace.status.${statusKey}` ? statusKey : translated;
} catch (e) { } catch (e) {
console.warn(`[i18n] Error getting translation for workspace.status.${statusKey}:`, e); console.warn(`[WebSocket ${instanceSessionId}] i18n 错误 (键: workspace.status.${statusKey}):`, e);
return statusKey; // Fallback to the key itself return statusKey;
} }
}; };
// Function to dispatch a message to all registered handlers for its type /**
* 将收到的消息分发给已注册的处理器
* @param {string} type - 消息类型
* @param {MessagePayload} payload - 消息负载
* @param {WebSocketMessage} fullMessage - 完整的消息对象
*/
const dispatchMessage = (type: string, payload: MessagePayload, fullMessage: WebSocketMessage) => { const dispatchMessage = (type: string, payload: MessagePayload, fullMessage: WebSocketMessage) => {
if (messageHandlers.has(type)) { if (messageHandlers.has(type)) {
messageHandlers.get(type)?.forEach(handler => { messageHandlers.get(type)?.forEach(handler => {
try { try {
handler(payload, fullMessage); handler(payload, fullMessage);
} catch (e) { } catch (e) {
console.error(`[WebSocket] Error in message handler for type "${type}":`, e); console.error(`[WebSocket ${instanceSessionId}] 消息处理器错误 (类型: "${type}"):`, e);
} }
}); });
} }
}; };
/**
const connect = (url: string, connId: string) => { * 建立 WebSocket 连接
// Prevent multiple connections or connection attempts * @param {string} url - WebSocket 服务器 URL
*/
const connect = (url: string) => {
// 防止重复连接同一实例
if (ws.value && (ws.value.readyState === WebSocket.OPEN || ws.value.readyState === WebSocket.CONNECTING)) { if (ws.value && (ws.value.readyState === WebSocket.OPEN || ws.value.readyState === WebSocket.CONNECTING)) {
// If it's the same connection ID and already open/connecting, do nothing console.warn(`[WebSocket ${instanceSessionId}] 连接已打开或正在连接中。`);
if (connectionIdForSession.value === connId) {
console.warn(`[WebSocket] Connection for ${connId} already open or connecting.`);
return; return;
} }
// If different connection ID, close the old one first
console.log(`[WebSocket] Closing existing connection for ${connectionIdForSession.value} before connecting to ${connId}`);
disconnect(); // Ensure cleanup before new connection
}
console.log(`[WebSocket] Attempting to connect to: ${url} for connection ${connId}`); console.log(`[WebSocket ${instanceSessionId}] 尝试连接到: ${url} (DB Conn ID: ${instanceDbConnectionId})`);
connectionIdForSession.value = connId;
statusMessage.value = getStatusText('connectingWs', { url }); statusMessage.value = getStatusText('connectingWs', { url });
connectionStatus.value = 'connecting'; connectionStatus.value = 'connecting';
isSftpReady.value = false; // 重置 SFTP 状态
try { try {
ws.value = new WebSocket(url); ws.value = new WebSocket(url);
ws.value.onopen = () => { ws.value.onopen = () => {
console.log('[WebSocket] Connection opened.'); console.log(`[WebSocket ${instanceSessionId}] 连接已打开。`);
statusMessage.value = getStatusText('wsConnected'); statusMessage.value = getStatusText('wsConnected');
// Status remains 'connecting' until ssh:connected is received // 状态保持 'connecting' 直到收到 ssh:connected
// Send the initial connection message required by the backend // 发送后端所需的初始连接消息,包含数据库连接 ID
sendMessage({ type: 'ssh:connect', payload: { connectionId: connId } }); sendMessage({ type: 'ssh:connect', payload: { connectionId: instanceDbConnectionId } });
// Dispatch an internal event if needed dispatchMessage('internal:opened', {}, { type: 'internal:opened' }); // 触发内部打开事件
// dispatchMessage('internal:opened', {}, { type: 'internal:opened' });
}; };
ws.value.onmessage = (event: MessageEvent) => { ws.value.onmessage = (event: MessageEvent) => {
try { try {
const message: WebSocketMessage = JSON.parse(event.data); const message: WebSocketMessage = JSON.parse(event.data);
// console.debug('[WebSocket] Received:', message.type); // Less verbose logging // console.debug(`[WebSocket ${instanceSessionId}] 收到:`, message.type);
// --- Update Global Connection Status based on specific messages --- // --- 更新此实例的连接状态 ---
if (message.type === 'ssh:connected') { if (message.type === 'ssh:connected') {
if (connectionStatus.value !== 'connected') { if (connectionStatus.value !== 'connected') {
console.log('[WebSocket] SSH session connected.'); console.log(`[WebSocket ${instanceSessionId}] SSH 会话已连接。`);
connectionStatus.value = 'connected'; connectionStatus.value = 'connected';
statusMessage.value = getStatusText('connected'); statusMessage.value = getStatusText('connected');
} }
} else if (message.type === 'ssh:disconnected') { } else if (message.type === 'ssh:disconnected') {
if (connectionStatus.value !== 'disconnected') { if (connectionStatus.value !== 'disconnected') {
console.log('[WebSocket] SSH session disconnected.'); console.log(`[WebSocket ${instanceSessionId}] SSH 会话已断开。`);
connectionStatus.value = 'disconnected'; connectionStatus.value = 'disconnected';
statusMessage.value = getStatusText('disconnected', { reason: message.payload || 'Unknown reason' }); statusMessage.value = getStatusText('disconnected', { reason: message.payload || '未知原因' });
isSftpReady.value = false; // SSH 断开,SFTP 也应不可用
} }
} else if (message.type === 'ssh:error' || message.type === 'error') { // Handle generic backend errors too } else if (message.type === 'ssh:error' || message.type === 'error') {
if (connectionStatus.value !== 'disconnected' && connectionStatus.value !== 'error') { if (connectionStatus.value !== 'disconnected' && connectionStatus.value !== 'error') {
console.error('[WebSocket] Received error message:', message.payload); console.error(`[WebSocket ${instanceSessionId}] 收到错误消息:`, message.payload);
connectionStatus.value = 'error'; connectionStatus.value = 'error';
let errorMsg = message.payload || 'Unknown error'; let errorMsg = message.payload || '未知错误';
if (typeof errorMsg === 'object' && errorMsg.message) errorMsg = errorMsg.message; if (typeof errorMsg === 'object' && errorMsg.message) errorMsg = errorMsg.message;
statusMessage.value = getStatusText('error', { message: errorMsg }); statusMessage.value = getStatusText('error', { message: errorMsg });
isSftpReady.value = false; // Reset SFTP status on error isSftpReady.value = false;
} }
} else if (message.type === 'sftp_ready') { } else if (message.type === 'sftp_ready') {
console.log('[WebSocket] SFTP session ready.'); console.log(`[WebSocket ${instanceSessionId}] SFTP 会话已就绪。`);
isSftpReady.value = true; isSftpReady.value = true;
} }
// --- End Status Update --- // --- 状态更新结束 ---
// Dispatch message to specific handlers // 分发消息给此实例的处理器
dispatchMessage(message.type, message.payload, message); dispatchMessage(message.type, message.payload, message);
} catch (e) { } catch (e) {
console.error('[WebSocket] Error processing message:', e, 'Raw data:', event.data); console.error(`[WebSocket ${instanceSessionId}] 处理消息时出错:`, e, '原始数据:', event.data);
// Optionally dispatch raw data if needed by some handler dispatchMessage('internal:raw', event.data, { type: 'internal:raw' });
// dispatchMessage('internal:raw', event.data, { type: 'internal:raw' });
} }
}; };
ws.value.onerror = (event) => { ws.value.onerror = (event) => {
console.error('[WebSocket] Connection error:', event); console.error(`[WebSocket ${instanceSessionId}] 连接错误:`, event);
if (connectionStatus.value !== 'disconnected') { // Avoid overwriting disconnect status if (connectionStatus.value !== 'disconnected') {
connectionStatus.value = 'error'; connectionStatus.value = 'error';
statusMessage.value = getStatusText('wsError'); statusMessage.value = getStatusText('wsError');
} }
dispatchMessage('internal:error', event, { type: 'internal:error' }); dispatchMessage('internal:error', event, { type: 'internal:error' });
isSftpReady.value = false; // Reset SFTP status on WS error isSftpReady.value = false;
ws.value = null; // Clean up on error ws.value = null; // 清理实例
connectionIdForSession.value = null;
}; };
ws.value.onclose = (event) => { ws.value.onclose = (event) => {
console.log(`[WebSocket] Connection closed: Code=${event.code}, Reason=${event.reason}`); console.log(`[WebSocket ${instanceSessionId}] 连接已关闭: Code=${event.code}, Reason=${event.reason}`);
// Update status only if not already handled by ssh:disconnected or error
if (connectionStatus.value !== 'disconnected' && connectionStatus.value !== 'error') { if (connectionStatus.value !== 'disconnected' && connectionStatus.value !== 'error') {
connectionStatus.value = 'disconnected'; connectionStatus.value = 'disconnected';
statusMessage.value = getStatusText('wsClosed', { code: event.code }); statusMessage.value = getStatusText('wsClosed', { code: event.code });
} }
dispatchMessage('internal:closed', { code: event.code, reason: event.reason }, { type: 'internal:closed' }); dispatchMessage('internal:closed', { code: event.code, reason: event.reason }, { type: 'internal:closed' });
isSftpReady.value = false; // Reset SFTP status on close isSftpReady.value = false;
ws.value = null; // Clean up reference ws.value = null; // 清理实例引用
connectionIdForSession.value = null; // 不自动清除处理器,以便在重连时可能复用
// Optionally clear handlers on close? Depends on desired behavior.
// messageHandlers.clear();
}; };
} catch (err) { } catch (err) {
console.error('[WebSocket] Failed to create WebSocket instance:', err); console.error(`[WebSocket ${instanceSessionId}] 创建 WebSocket 实例失败:`, err);
connectionStatus.value = 'error'; connectionStatus.value = 'error';
statusMessage.value = getStatusText('wsError'); // Or a more specific creation error statusMessage.value = getStatusText('wsError');
isSftpReady.value = false; // Reset SFTP status on creation error isSftpReady.value = false;
ws.value = null; ws.value = null;
connectionIdForSession.value = null;
} }
}; };
/**
* 手动断开此 WebSocket 连接
*/
const disconnect = () => { const disconnect = () => {
if (ws.value) { if (ws.value) {
console.log('[WebSocket] Closing connection manually...'); console.log(`[WebSocket ${instanceSessionId}] 手动关闭连接...`);
// Set status immediately to prevent race conditions with onclose
if (connectionStatus.value !== 'disconnected') { if (connectionStatus.value !== 'disconnected') {
connectionStatus.value = 'disconnected'; connectionStatus.value = 'disconnected';
statusMessage.value = getStatusText('disconnected', { reason: 'Manual disconnect' }); statusMessage.value = getStatusText('disconnected', { reason: '手动断开' });
} }
ws.value.close(1000, 'Client initiated disconnect'); // Use standard code and reason ws.value.close(1000, '客户端主动断开'); // 使用标准代码和原因
ws.value = null; ws.value = null;
connectionIdForSession.value = null; isSftpReady.value = false;
isSftpReady.value = false; // Reset SFTP status on manual disconnect // 手动断开时可以考虑清除处理器,取决于是否需要重连逻辑
// messageHandlers.clear(); // Clear handlers on manual disconnect // messageHandlers.clear();
} else {
console.log(`[WebSocket ${instanceSessionId}] 连接已关闭或不存在,无需断开。`);
} }
}; };
/**
* 发送 WebSocket 消息
* @param {WebSocketMessage} message - 要发送的消息对象
*/
const sendMessage = (message: WebSocketMessage) => { const sendMessage = (message: WebSocketMessage) => {
if (ws.value && ws.value.readyState === WebSocket.OPEN) { if (ws.value && ws.value.readyState === WebSocket.OPEN) {
try { try {
const messageString = JSON.stringify(message); const messageString = JSON.stringify(message);
// console.debug('[WebSocket] Sending:', message.type); // Less verbose // console.debug(`[WebSocket ${instanceSessionId}] 发送:`, message.type);
ws.value.send(messageString); ws.value.send(messageString);
} catch (e) { } catch (e) {
console.error('[WebSocket] Failed to stringify or send message:', e, message); console.error(`[WebSocket ${instanceSessionId}] 序列化或发送消息失败:`, e, message);
} }
} else { } else {
console.warn(`[WebSocket] Cannot send message, connection not open. State: ${connectionStatus.value}, ReadyState: ${ws.value?.readyState}`); console.warn(`[WebSocket ${instanceSessionId}] 无法发送消息,连接未打开。状态: ${connectionStatus.value}, ReadyState: ${ws.value?.readyState}`);
} }
}; };
// Register a handler for a specific message type /**
const onMessage = (type: string, handler: MessageHandler) => { * 注册一个消息处理器
* @param {string} type - 要监听的消息类型
* @param {MessageHandler} handler - 处理函数
* @returns {Function} 一个用于注销此处理器的函数
*/
const onMessage = (type: string, handler: MessageHandler): (() => void) => {
if (!messageHandlers.has(type)) { if (!messageHandlers.has(type)) {
messageHandlers.set(type, new Set()); messageHandlers.set(type, new Set());
} }
const handlersSet = messageHandlers.get(type); const handlersSet = messageHandlers.get(type);
if (handlersSet) { if (handlersSet) {
handlersSet.add(handler); handlersSet.add(handler);
console.debug(`[WebSocket] Handler registered for type: ${type}`); // console.debug(`[WebSocket ${instanceSessionId}] 已注册处理器: ${type}`);
} }
// 返回注销函数
// Return an unregister function
return () => { return () => {
const currentSet = messageHandlers.get(type); const currentSet = messageHandlers.get(type);
if (currentSet) { if (currentSet) {
currentSet.delete(handler); currentSet.delete(handler);
console.debug(`[WebSocket] Handler unregistered for type: ${type}`); // console.debug(`[WebSocket ${instanceSessionId}] 已注销处理器: ${type}`);
if (currentSet.size === 0) { if (currentSet.size === 0) {
messageHandlers.delete(type); messageHandlers.delete(type);
} }
@@ -217,20 +225,18 @@ export function useWebSocketConnection() {
}; };
}; };
// Cleanup logic: The singleton nature means disconnect should be called explicitly // 注意:没有在此处使用 onUnmounted。
// when the connection is no longer needed (e.g., when WorkspaceView unmounts). // disconnect 方法需要由外部调用者 (例如 WorkspaceView) 在会话关闭时显式调用。
// onUnmounted is generally tied to the component instance using the composable.
// If useWebSocketConnection is called in WorkspaceView's setup, its onUnmounted
// will trigger disconnect, which is the desired behavior.
// 返回此实例的状态和方法
return { return {
// State (Exported as readonly refs where appropriate) // 状态 (只读引用)
isConnected: computed(() => connectionStatus.value === 'connected'), isConnected: computed(() => connectionStatus.value === 'connected'),
isSftpReady: readonly(isSftpReady), // Expose SFTP readiness state isSftpReady: readonly(isSftpReady),
connectionStatus: readonly(connectionStatus), connectionStatus: readonly(connectionStatus),
statusMessage: readonly(statusMessage), statusMessage: readonly(statusMessage),
// Methods // 方法
connect, connect,
disconnect, disconnect,
sendMessage, sendMessage,
+5 -1
View File
@@ -3,6 +3,7 @@
"nav": { "nav": {
"dashboard": "Dashboard", "dashboard": "Dashboard",
"connections": "Connections", "connections": "Connections",
"terminal": "Terminal",
"proxies": "Proxies", "proxies": "Proxies",
"login": "Login", "login": "Login",
"logout": "Logout", "logout": "Logout",
@@ -149,7 +150,10 @@
} }
}, },
"workspace": { "workspace": {
"statusBar": "Status: {status} (Connection ID: {id})", "noActiveSession": "No Active Session",
"selectConnectionPrompt": "Please select a connection",
"selectConnectionHint": "Select a connection from the left list, or click the 'Add New Connection' button to create a new one.",
"statusBar": "Status: {status} | Connection: {id}",
"status": { "status": {
"initializing": "Initializing...", "initializing": "Initializing...",
"connectingWs": "Connecting to {url}...", "connectingWs": "Connecting to {url}...",
+6 -5
View File
@@ -28,7 +28,7 @@
"addConnection": "添加新连接", "addConnection": "添加新连接",
"loading": "正在加载连接...", "loading": "正在加载连接...",
"error": "加载连接失败: {error}", "error": "加载连接失败: {error}",
"noConnections": "还没有任何连接。点击添加新连接来创建一个吧!", "noConnections": "还没有任何连接。点击'添加新连接'来创建一个吧!",
"table": { "table": {
"name": "名称", "name": "名称",
"host": "主机", "host": "主机",
@@ -108,7 +108,7 @@
"addProxy": "添加新代理", "addProxy": "添加新代理",
"loading": "正在加载代理...", "loading": "正在加载代理...",
"error": "加载代理列表失败: {error}", "error": "加载代理列表失败: {error}",
"noProxies": "还没有任何代理配置。点击添加新代理来创建一个吧!", "noProxies": "还没有任何代理配置。点击'添加新代理'来创建一个吧!",
"table": { "table": {
"name": "名称", "name": "名称",
"type": "类型", "type": "类型",
@@ -151,7 +151,8 @@
} }
}, },
"workspace": { "workspace": {
"statusBar": "状态: {status} (连接 ID: {id})", "noActiveSession": "无活动会话",
"statusBar": "状态: {status} | 连接: {id}",
"status": { "status": {
"initializing": "正在初始化...", "initializing": "正在初始化...",
"connectingWs": "正在连接到 {url}...", "connectingWs": "正在连接到 {url}...",
@@ -172,7 +173,7 @@
"unknown": "未知状态" "unknown": "未知状态"
}, },
"selectConnectionPrompt": "请选择一个连接", "selectConnectionPrompt": "请选择一个连接",
"selectConnectionHint": "从左侧列表中选择一个连接以开始。", "selectConnectionHint": "从左侧列表中选择一个连接,或点击'添加新连接'按钮创建一个新连接。",
"terminal": { "terminal": {
"infoPrefix": "[信息]", "infoPrefix": "[信息]",
"errorPrefix": "[错误]", "errorPrefix": "[错误]",
@@ -257,7 +258,7 @@
"addTag": "添加新标签", "addTag": "添加新标签",
"loading": "正在加载标签...", "loading": "正在加载标签...",
"error": "加载标签列表失败: {error}", "error": "加载标签列表失败: {error}",
"noTags": "还没有任何标签。点击添加新标签来创建一个吧!", "noTags": "还没有任何标签。点击'添加新标签'来创建一个吧!",
"table": { "table": {
"name": "名称", "name": "名称",
"updatedAt": "更新时间", "updatedAt": "更新时间",
@@ -0,0 +1,252 @@
import { ref, computed, shallowRef, type Ref } from 'vue'; // 导入 shallowRef
import { defineStore } from 'pinia';
import { useI18n } from 'vue-i18n';
import { useConnectionsStore, type ConnectionInfo } from './connections.store';
// 导入管理器工厂函数 (用于创建实例)
import { createWebSocketConnectionManager } from '../composables/useWebSocketConnection';
import { createSftpActionsManager, type WebSocketDependencies } from '../composables/useSftpActions';
import { createSshTerminalManager, type SshTerminalDependencies } from '../composables/useSshTerminal';
import { createStatusMonitorManager, type StatusMonitorDependencies } from '../composables/useStatusMonitor';
// --- 辅助函数 ---
function generateSessionId(): string {
return Date.now().toString(36) + Math.random().toString(36).substring(2);
}
// --- 类型定义 (导出以便其他模块使用) ---
export type WsManagerInstance = ReturnType<typeof createWebSocketConnectionManager>;
export type SftpManagerInstance = ReturnType<typeof createSftpActionsManager>;
export type SshTerminalInstance = ReturnType<typeof createSshTerminalManager>;
export type StatusMonitorInstance = ReturnType<typeof createStatusMonitorManager>;
export interface SessionState {
sessionId: string;
connectionId: string; // 数据库中的连接 ID
connectionName: string; // 用于显示
wsManager: WsManagerInstance;
sftpManager: SftpManagerInstance;
terminalManager: SshTerminalInstance;
statusMonitorManager: StatusMonitorInstance;
currentSftpPath: Ref<string>; // SFTP 当前路径 (可能需要保留在此处或移至 SftpManager 内部)
}
export const useSessionStore = defineStore('session', () => {
// --- 依赖 ---
const { t } = useI18n();
const connectionsStore = useConnectionsStore();
// --- State ---
// 使用 shallowRef 避免深度响应性问题,保留管理器实例内部的响应性
const sessions = shallowRef<Map<string, SessionState>>(new Map());
const activeSessionId = ref<string | null>(null);
// --- Getters ---
const sessionTabs = computed(() => {
return Array.from(sessions.value.values()).map(session => ({
sessionId: session.sessionId,
connectionName: session.connectionName,
}));
});
const activeSession = computed((): SessionState | null => {
if (!activeSessionId.value) return null;
return sessions.value.get(activeSessionId.value) || null;
});
// --- Actions ---
/**
* 根据连接 ID 查找连接信息
*/
const findConnectionInfo = (connectionId: number | string): ConnectionInfo | undefined => {
return connectionsStore.connections.find(c => c.id === Number(connectionId));
};
/**
* 打开一个新的会话标签页
*/
const openNewSession = (connectionId: number | string) => {
console.log(`[SessionStore] 请求打开新会话: ${connectionId}`);
const connInfo = findConnectionInfo(connectionId);
if (!connInfo) {
console.error(`[SessionStore] 无法打开新会话:找不到 ID 为 ${connectionId} 的连接信息。`);
// TODO: 向用户显示错误
return;
}
const newSessionId = generateSessionId();
const dbConnId = String(connInfo.id);
// 1. 创建管理器实例 (从 WorkspaceView 迁移)
const wsManager = createWebSocketConnectionManager(newSessionId, dbConnId, t);
const currentSftpPath = ref<string>('.'); // SFTP 路径状态
const wsDeps: WebSocketDependencies = {
sendMessage: wsManager.sendMessage,
onMessage: wsManager.onMessage,
isConnected: wsManager.isConnected,
isSftpReady: wsManager.isSftpReady,
};
const sftpManager = createSftpActionsManager(newSessionId, currentSftpPath, wsDeps, t);
const sshTerminalDeps: SshTerminalDependencies = {
sendMessage: wsManager.sendMessage,
onMessage: wsManager.onMessage,
isConnected: wsManager.isConnected,
};
const terminalManager = createSshTerminalManager(newSessionId, sshTerminalDeps, t);
const statusMonitorDeps: StatusMonitorDependencies = {
onMessage: wsManager.onMessage,
isConnected: wsManager.isConnected,
};
const statusMonitorManager = createStatusMonitorManager(newSessionId, statusMonitorDeps);
// 2. 创建 SessionState 对象
const newSession: SessionState = {
sessionId: newSessionId,
connectionId: dbConnId,
connectionName: connInfo.name || connInfo.host,
wsManager: wsManager,
sftpManager: sftpManager,
terminalManager: terminalManager,
statusMonitorManager: statusMonitorManager,
currentSftpPath: currentSftpPath,
};
// 3. 添加到 Map 并激活 (需要创建 Map 的新实例以触发 shallowRef 更新)
const newSessionsMap = new Map(sessions.value);
newSessionsMap.set(newSessionId, newSession);
sessions.value = newSessionsMap; // 触发 shallowRef 更新
activeSessionId.value = newSessionId;
console.log(`[SessionStore] 已创建新会话实例: ${newSessionId} for connection ${dbConnId}`);
// 4. 启动 WebSocket 连接
const wsUrl = `ws://${window.location.hostname}:3001`; // TODO: 从配置获取 URL
wsManager.connect(wsUrl);
console.log(`[SessionStore] 已为会话 ${newSessionId} 启动 WebSocket 连接。`);
};
/**
* 激活指定 ID 的会话标签页
*/
const activateSession = (sessionId: string) => {
if (sessions.value.has(sessionId)) {
if (activeSessionId.value !== sessionId) {
activeSessionId.value = sessionId;
console.log(`[SessionStore] 已激活会话: ${sessionId}`);
// TODO: 可能需要 nextTick 来聚焦终端?
} else {
console.log(`[SessionStore] 会话 ${sessionId} 已经是活动状态。`);
}
} else {
console.warn(`[SessionStore] 尝试激活不存在的会话 ID: ${sessionId}`);
}
};
/**
* 关闭指定 ID 的会话标签页
*/
const closeSession = (sessionId: string) => {
console.log(`[SessionStore] 请求关闭会话 ID: ${sessionId}`);
const sessionToClose = sessions.value.get(sessionId);
if (!sessionToClose) {
console.warn(`[SessionStore] 尝试关闭不存在的会话 ID: ${sessionId}`);
return;
}
// 1. 调用实例上的清理和断开方法 (从 WorkspaceView 迁移)
sessionToClose.wsManager.disconnect();
console.log(`[SessionStore] 已为会话 ${sessionId} 调用 wsManager.disconnect()`);
sessionToClose.sftpManager.cleanup();
console.log(`[SessionStore] 已为会话 ${sessionId} 调用 sftpManager.cleanup()`);
sessionToClose.terminalManager.cleanup();
console.log(`[SessionStore] 已为会话 ${sessionId} 调用 terminalManager.cleanup()`);
sessionToClose.statusMonitorManager.cleanup();
console.log(`[SessionStore] 已为会话 ${sessionId} 调用 statusMonitorManager.cleanup()`);
// 2. 从 Map 中移除会话 (需要创建 Map 的新实例以触发 shallowRef 更新)
const newSessionsMap = new Map(sessions.value);
newSessionsMap.delete(sessionId);
sessions.value = newSessionsMap; // 触发 shallowRef 更新
console.log(`[SessionStore] 已从 Map 中移除会话: ${sessionId}`);
// 3. 切换活动标签页
if (activeSessionId.value === sessionId) {
const remainingSessions = Array.from(sessions.value.keys());
const nextActiveId = remainingSessions.length > 0 ? remainingSessions[remainingSessions.length - 1] : null;
activeSessionId.value = nextActiveId;
console.log(`[SessionStore] 关闭活动会话后,切换到: ${nextActiveId}`);
}
};
/**
* 处理连接列表的左键点击(连接或激活)
*/
const handleConnectRequest = (connectionId: number | string) => {
const connIdStr = String(connectionId);
console.log(`[SessionStore] 处理连接请求: ${connIdStr}`);
let existingSessionId: string | null = null;
for (const [sessionId, session] of sessions.value.entries()) {
if (session.connectionId === connIdStr) {
existingSessionId = sessionId;
break;
}
}
if (existingSessionId) {
if (activeSessionId.value !== existingSessionId) {
console.log(`[SessionStore] 激活已存在的会话: ${existingSessionId}`);
activateSession(existingSessionId);
} else {
console.log(`[SessionStore] 点击的连接 ${connIdStr} 已在活动会话 ${existingSessionId} 中,无需操作。`);
}
} else {
// 当前行为:替换当前活动会话(如果存在)
if (activeSession.value) {
console.log(`[SessionStore] 替换当前会话 ${activeSessionId.value} 为新连接 ${connIdStr}`);
closeSession(activeSessionId.value!); // 确保 activeSessionId 存在
openNewSession(connIdStr);
} else {
console.log(`[SessionStore] 当前无活动会话,打开新会话: ${connIdStr}`);
openNewSession(connIdStr);
}
// 备选行为:总是打开新标签页?需要调整 openNewSession 逻辑
}
};
/**
* 处理连接列表的中键点击(总是打开新会话)
*/
const handleOpenNewSession = (connectionId: number | string) => {
console.log(`[SessionStore] 处理打开新会话请求: ${connectionId}`);
openNewSession(connectionId);
};
/**
* 清理所有会话(例如在应用卸载时)
*/
const cleanupAllSessions = () => {
console.log('[SessionStore] 清理所有会话...');
sessions.value.forEach((session, sessionId) => {
closeSession(sessionId); // 调用单个会话的关闭逻辑
});
sessions.value.clear();
activeSessionId.value = null;
};
return {
// State
sessions,
activeSessionId,
// Getters
sessionTabs,
activeSession,
// Actions
openNewSession,
activateSession,
closeSession,
handleConnectRequest,
handleOpenNewSession,
cleanupAllSessions,
};
});
@@ -14,4 +14,4 @@ export interface WebSocketMessage {
} }
// 消息处理器函数类型 // 消息处理器函数类型
export type MessageHandler = (payload: MessagePayload, message: WebSocketMessage) => void; export type MessageHandler = (payload: MessagePayload, message: WebSocketMessage) => void; // 恢复 message 参数为必需
+139 -166
View File
@@ -1,203 +1,175 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, watch } from 'vue'; // 移除 computed, useRoute, useRouter import { onMounted, onBeforeUnmount, computed, ref } from 'vue'; // 移除不再需要的导入
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { storeToRefs } from 'pinia'; // 导入 storeToRefs
import TerminalComponent from '../components/Terminal.vue'; import TerminalComponent from '../components/Terminal.vue';
import FileManagerComponent from '../components/FileManager.vue'; import FileManagerComponent from '../components/FileManager.vue';
import StatusMonitorComponent from '../components/StatusMonitor.vue'; import StatusMonitorComponent from '../components/StatusMonitor.vue';
import WorkspaceConnectionListComponent from '../components/WorkspaceConnectionList.vue'; import WorkspaceConnectionListComponent from '../components/WorkspaceConnectionList.vue';
import AddConnectionFormComponent from '../components/AddConnectionForm.vue'; // 引入表单组件 import AddConnectionFormComponent from '../components/AddConnectionForm.vue';
import type { ConnectionInfo } from '../stores/connections.store'; // 引入 ConnectionInfo 类型 import TerminalTabBar from '../components/TerminalTabBar.vue';
import type { Terminal } from 'xterm'; import { useSessionStore } from '../stores/session.store'; // 导入 Session Store
import { useWebSocketConnection } from '../composables/useWebSocketConnection'; import type { ConnectionInfo } from '../stores/connections.store'; // 保持 ConnectionInfo 类型导入
import { useSshTerminal } from '../composables/useSshTerminal'; // 导入 SSH 终端模块 // 导入管理器实例类型,用于 FileManagerComponent 的 prop 类型断言
import { useStatusMonitor } from '../composables/useStatusMonitor'; import type { SftpManagerInstance } from '../stores/session.store';
import type { ServerStatus } from '../types/server.types';
// import { useConnectionsStore } from '../stores/connections.store'; // 不再直接在此处使用 store
// import { storeToRefs } from 'pinia'; // 不再直接在此处使用 storeToRefs
// --- 接口定义 ---
// ServerStatus 现在从 types/server.types.ts 导入
// --- Setup ---
const { t } = useI18n(); const { t } = useI18n();
const sessionStore = useSessionStore();
// --- 内部状态 --- // --- 从 Store 获取响应式状态和 Getters ---
const activeConnectionId = ref<string | null>(null); // 使用 storeToRefs 保持响应性,或者直接在模板中使用 sessionStore.xxx
const showAddEditForm = ref(false); // 控制表单模态框显示 const { sessionTabs, activeSessionId, activeSession } = storeToRefs(sessionStore);
const connectionToEdit = ref<ConnectionInfo | null>(null); // 要编辑的连接
// --- 连接 Store (不再需要在此处直接引用 connections, loading, error) ---
// const connectionsStore = useConnectionsStore();
// const { connections, isLoading: connectionsLoading, error: connectionsError } = storeToRefs(connectionsStore);
// --- WebSocket 连接模块 ---
const {
isConnected,
connectionStatus, // Get reactive status from composable
statusMessage, // Get reactive status message from composable
connect,
disconnect,
sendMessage,
onMessage,
} = useWebSocketConnection();
// --- SSH 终端模块 ---
const {
// terminalInstance, // 不再需要直接从这里访问
handleTerminalReady,
handleTerminalData,
handleTerminalResize,
registerSshHandlers,
unregisterAllSshHandlers,
} = useSshTerminal();
// --- 状态监控模块 ---
const {
serverStatus, // 从 composable 获取状态
statusError, // 从 composable 获取错误
registerStatusHandlers, // 重命名以避免与 SSH 冲突
unregisterAllStatusHandlers, // 重命名以避免与 SSH 冲突
} = useStatusMonitor();
// --- UI 状态 (保持本地) ---
const showAddEditForm = ref(false);
const connectionToEdit = ref<ConnectionInfo | null>(null);
// --- 生命周期钩子 --- // --- 生命周期钩子 ---
onMounted(() => { onMounted(() => {
// 组件挂载时不自动连接,等待用户选择 console.log('[工作区视图] 组件挂载。');
// if (activeConnectionId.value) { // 可以在这里执行一些初始化操作,如果需要的话
// const wsUrl = `ws://${window.location.hostname}:3001`;
// connect(wsUrl, activeConnectionId.value);
// 不在此处立即注册,等待 isConnected 变为 true
// registerSshHandlers();
// registerStatusHandlers();
// } else {
// console.log('[工作区视图] 没有活动的连接 ID。'); // 不再是错误
// }
}); });
onBeforeUnmount(() => { onBeforeUnmount(() => {
disconnect(); // 使用 WebSocket 模块的 disconnect console.log('[工作区视图] 组件即将卸载,清理所有会话...');
unregisterAllSshHandlers(); // 注销 SSH 终端处理器 sessionStore.cleanupAllSessions(); // 调用 store action 清理
unregisterAllStatusHandlers(); // 使用状态监控模块的注销函数
}); });
// 监听 activeConnectionId 变化以处理连接切换 // --- 监听器 (如果需要监听 store 状态变化) ---
watch(activeConnectionId, (newId, oldId) => { // watch(activeSessionId, (newSessionId, oldSessionId) => {
console.log(`[工作区视图] 活动连接 ID 从 ${oldId} 更改为 ${newId}`); // console.log(`[工作区视图] 活动会话 ID 从 ${oldSessionId} 更改为 ${newSessionId}`);
// 断开旧连接 (如果存在) // if (newSessionId) {
if (oldId) { // nextTick(() => {
disconnect(); // isConnected 会变为 false,触发清理 // // TODO: 聚焦到活动会话的终端 (此逻辑可能移至 Store 或保留在此处)
} // console.log(`[工作区视图] TODO: 聚焦会话 ${newSessionId} 的终端`);
// 连接新的 WebSocket (如果新 ID 有效) // });
if (newId) { // }
console.log(`[工作区视图] 正在连接到 ID: ${newId}...`); // });
const wsUrl = `ws://${window.location.hostname}:3001`;
connect(wsUrl, newId); // connect 会处理 isConnected 状态
}
// 注意:处理器的注册/注销现在完全由 isConnected 的 watch 驱动
});
// 监听 WebSocket 连接状态变化来注册/注销处理器 // --- 本地方法 (仅处理 UI 状态) ---
watch(isConnected, (connected) => {
if (connected) {
console.log('[工作区视图] WebSocket 已连接,注册 SSH 和状态处理器。');
registerSshHandlers();
registerStatusHandlers();
} else {
console.log('[工作区视图] WebSocket 已断开,注销 SSH 和状态处理器。');
// isConnected 变为 false 时,确保清理
unregisterAllSshHandlers();
unregisterAllStatusHandlers();
// 注意:disconnect() 应该在 connectionId 变化或组件卸载时调用,
// isConnected 变为 false 是结果,而不是原因。
}
});
// 辅助函数:获取终端消息文本 (已移至 useSshTerminal)
// --- 连接列表点击处理 ---
const handleConnectRequest = (id: number | string) => {
console.log(`[工作区视图] 请求激活连接 ID: ${id}`);
activeConnectionId.value = String(id);
};
// --- 表单模态框处理 ---
const handleRequestAddConnection = () => { const handleRequestAddConnection = () => {
connectionToEdit.value = null; // 确保是添加模式 connectionToEdit.value = null;
showAddEditForm.value = true; showAddEditForm.value = true;
}; };
const handleRequestEditConnection = (connection: ConnectionInfo) => { const handleRequestEditConnection = (connection: ConnectionInfo) => {
connectionToEdit.value = connection; // 设置要编辑的连接 connectionToEdit.value = connection;
showAddEditForm.value = true; showAddEditForm.value = true;
}; };
const handleFormClose = () => { const handleFormClose = () => {
showAddEditForm.value = false; showAddEditForm.value = false;
connectionToEdit.value = null; // 清除编辑状态 connectionToEdit.value = null;
}; };
const handleConnectionAdded = () => { const handleConnectionAdded = () => {
console.log('[工作区视图] 连接已添加'); console.log('[工作区视图] 连接已添加');
handleFormClose(); handleFormClose();
// WorkspaceConnectionList 会自动从 store 更新
}; };
const handleConnectionUpdated = () => { const handleConnectionUpdated = () => {
console.log('[工作区视图] 连接已更新'); console.log('[工作区视图] 连接已更新');
handleFormClose(); handleFormClose();
// WorkspaceConnectionList 会自动从 store 更新
}; };
// --- 移除本地会话管理函数 ---
// findConnectionInfo, openNewSession, activateSession, closeSession,
// handleConnectRequest, handleOpenNewSession 已移至 sessionStore
</script> </script>
<template> <template>
<div class="workspace-view"> <div class="workspace-view">
<!-- 标签栏: 绑定到 store 的状态和 actions -->
<TerminalTabBar
:sessions="sessionTabs"
:active-session-id="activeSessionId"
@activate-session="sessionStore.activateSession"
@close-session="sessionStore.closeSession"
/>
<div class="status-bar"> <div class="status-bar">
<!-- 使用 t 函数渲染状态栏文本, 显示 activeConnectionId --> <!-- 状态栏显示活动会话的信息: store getter 获取 -->
{{ t('workspace.statusBar', { status: statusMessage, id: activeConnectionId ?? 'N/A' }) }} {{ t('workspace.statusBar', {
<!-- 状态颜色仍然通过 class 绑定 --> status: activeSession?.wsManager.statusMessage.value ?? t('workspace.status.disconnected'),
<!-- 使用来自 useWebSocketConnection 的状态 --> id: activeSession?.connectionId ?? t('workspace.noActiveSession')
<span :class="`status-${connectionStatus}`"></span> })
}}
<!-- activeSession getter 获取连接状态 -->
<span :class="`status-${activeSession?.wsManager.connectionStatus.value ?? 'disconnected'}`"></span>
</div> </div>
<div class="main-content-area"> <div class="main-content-area">
<!-- 新增左侧边栏 --> <!-- 左侧边栏: 事件绑定到 store actions -->
<div class="left-sidebar"> <div class="left-sidebar">
<!-- 监听新的事件 -->
<WorkspaceConnectionListComponent <WorkspaceConnectionListComponent
@connect-request="handleConnectRequest" @connect-request="sessionStore.handleConnectRequest"
@open-new-session="sessionStore.handleOpenNewSession"
@request-add-connection="handleRequestAddConnection" @request-add-connection="handleRequestAddConnection"
@request-edit-connection="handleRequestEditConnection" @request-edit-connection="handleRequestEditConnection"
/> />
</div> </div>
<!-- 主工作区 (添加 v-if/v-else), activeConnectionId --> <!-- 主工作区容器 -->
<div v-if="activeConnectionId" class="main-workspace-area"> <div class="main-workspace-container">
<!-- 会话区域: 循环 store 中的 sessions Map -->
<!-- 注意: v-for sessions.values() 可能不是响应式的因为 sessions shallowRef -->
<!-- 改为 v-for session in sessionTabs然后通过 session.sessionId 获取完整 session -->
<div
v-for="tabInfo in sessionTabs"
:key="tabInfo.sessionId"
v-show="tabInfo.sessionId === activeSessionId"
class="main-workspace-area-session"
>
<!-- 获取当前循环的完整 session 对象 -->
<template v-if="sessionStore.sessions.get(tabInfo.sessionId)">
<div class="left-pane"> <div class="left-pane">
<div class="terminal-wrapper"> <div class="terminal-wrapper" :data-session-id="tabInfo.sessionId">
<!-- 事件绑定到 useSshTerminal 处理函数 --> <!-- TerminalComponent: 事件绑定到 activeSession 管理器方法 -->
<TerminalComponent <TerminalComponent
@ready="handleTerminalReady" :key="tabInfo.sessionId"
@data="handleTerminalData" :session-id="tabInfo.sessionId"
@resize="handleTerminalResize" @ready="sessionStore.sessions.get(tabInfo.sessionId)?.terminalManager.handleTerminalReady"
@data="sessionStore.sessions.get(tabInfo.sessionId)?.terminalManager.handleTerminalData"
@resize="sessionStore.sessions.get(tabInfo.sessionId)?.terminalManager.handleTerminalResize"
/> />
</div> </div>
<!-- 文件管理器窗格 -->
<div class="file-manager-wrapper"> <div class="file-manager-wrapper">
<!-- Removed :ws prop. Communication will be handled via composables --> <!-- FileManagerComponent: Props 绑定到 activeSession 的管理器 -->
<FileManagerComponent :is-connected="isConnected" /> <!-- 确保传递正确的 wsDeps -->
<FileManagerComponent
:key="tabInfo.sessionId"
:session-id="tabInfo.sessionId"
:db-connection-id="sessionStore.sessions.get(tabInfo.sessionId)!.connectionId"
:sftp-manager="sessionStore.sessions.get(tabInfo.sessionId)!.sftpManager"
:ws-deps="{
sendMessage: sessionStore.sessions.get(tabInfo.sessionId)!.wsManager.sendMessage,
onMessage: sessionStore.sessions.get(tabInfo.sessionId)!.wsManager.onMessage,
isConnected: sessionStore.sessions.get(tabInfo.sessionId)!.wsManager.isConnected,
isSftpReady: sessionStore.sessions.get(tabInfo.sessionId)!.wsManager.isSftpReady
}"
/>
</div> </div>
</div> </div>
<!-- 状态监控窗格 -->
<div class="status-monitor-wrapper"> <div class="status-monitor-wrapper">
<StatusMonitorComponent :status-data="serverStatus" :error="statusError" /> <!-- StatusMonitorComponent: Props 绑定到 activeSession 的管理器状态 -->
<StatusMonitorComponent
:key="tabInfo.sessionId"
:session-id="tabInfo.sessionId"
:server-status="(sessionStore.sessions.get(tabInfo.sessionId)?.statusMonitorManager.serverStatus.value) ?? null"
:status-error="(sessionStore.sessions.get(tabInfo.sessionId)?.statusMonitorManager.statusError.value) ?? null"
/>
</div> </div>
</template>
</div> </div>
<!-- 当没有 connectionId 时显示提示 --> <!-- 占位符 -->
<div v-else class="main-workspace-area placeholder"> <div v-if="!activeSessionId" class="main-workspace-area placeholder">
<h2>{{ t('workspace.selectConnectionPrompt') }}</h2> <h2>{{ t('workspace.selectConnectionPrompt') }}</h2>
<p>{{ t('workspace.selectConnectionHint') }}</p> <p>{{ t('workspace.selectConnectionHint') }}</p>
</div> </div>
</div> </div>
</div>
<!-- 添加/编辑连接表单模态框 --> <!-- 添加/编辑连接表单模态框 (保持不变) -->
<AddConnectionFormComponent <AddConnectionFormComponent
v-if="showAddEditForm" v-if="showAddEditForm"
:connection-to-edit="connectionToEdit" :connection-to-edit="connectionToEdit"
@@ -212,8 +184,7 @@ watch(isConnected, (connected) => {
.workspace-view { .workspace-view {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
/* 调整高度计算以适应可能的 header/footer/status-bar */ height: calc(100vh - 60px - 30px - 60px - 2rem); /* 调整以适应布局 */
height: calc(100vh - 60px - 30px - 2rem); /* 假设 header 60px, footer 30px, padding 2rem */
overflow: hidden; overflow: hidden;
} }
@@ -232,83 +203,85 @@ watch(isConnected, (connected) => {
.main-content-area { .main-content-area {
display: flex; display: flex;
flex-grow: 1; /* Take remaining vertical space */ flex-grow: 1;
overflow: hidden; overflow: hidden;
/* 新增样式 */ border-top: 1px solid #ccc;
border-top: 1px solid #ccc; /* Add a top border for separation */
} }
/* 新增左侧边栏样式 */
.left-sidebar { .left-sidebar {
width: 250px; /* 示例宽度 */ width: 250px;
min-width: 200px; /* 最小宽度 */ min-width: 200px;
height: 100%; height: 100%;
border-right: 2px solid #ccc; border-right: 2px solid #ccc;
overflow-y: auto; /* 如果列表过长则允许滚动 */ overflow-y: auto;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
.left-sidebar > * { .left-sidebar > * {
flex-grow: 1; /* 让 WorkspaceConnectionList 填充 */ flex-grow: 1;
} }
.main-workspace-container {
/* 主工作区容器 */ flex-grow: 1;
.main-workspace-area { position: relative;
flex-grow: 1; /* 占据剩余空间 */ overflow: hidden;
display: flex; display: flex;
}
.main-workspace-area-session {
width: 100%;
height: 100%; height: 100%;
display: flex;
overflow: hidden; overflow: hidden;
} }
.left-pane { .left-pane {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
/* width: 80%; */ /* 不再固定宽度,改为 flex */ flex-grow: 1;
flex-grow: 1; /* 占据主工作区大部分空间 */
height: 100%; height: 100%;
min-width: 300px; /* 保证终端和文件管理器有最小宽度 */ min-width: 300px;
} }
.terminal-wrapper { .terminal-wrapper {
height: 60%; /* 示例:终端占 60% 高度 */ height: 60%;
background-color: #1e1e1e; /* 终端背景色 */ background-color: #1e1e1e;
overflow: hidden; /* 内部滚动由 xterm 处理 */ overflow: hidden;
display: flex; /* Ensure TerminalComponent fills this wrapper */ display: flex;
flex-direction: column; flex-direction: column;
} }
.terminal-wrapper > * { .terminal-wrapper > * {
flex-grow: 1; /* Make TerminalComponent fill the wrapper */ flex-grow: 1;
} }
.file-manager-wrapper { .file-manager-wrapper {
height: 40%; /* 示例:文件管理器占 40% 高度 */ height: 40%;
border-top: 2px solid #ccc; /* Add top border */ border-top: 2px solid #ccc;
overflow: hidden; /* 防止自身滚动 */ overflow: hidden;
display: flex; /* Ensure FileManagerComponent fills this wrapper */ display: flex;
flex-direction: column; flex-direction: column;
} }
.file-manager-wrapper > * { .file-manager-wrapper > * {
flex-grow: 1; /* Make FileManagerComponent fill the wrapper */ flex-grow: 1;
} }
.status-monitor-wrapper { .status-monitor-wrapper {
/* width: 20%; */ /* 不再固定宽度,改为 flex-basis */ flex-basis: 250px;
flex-basis: 250px; /* 示例基础宽度 */ min-width: 200px;
min-width: 200px; /* 最小宽度 */
height: 100%; height: 100%;
border-left: 2px solid #ccc; border-left: 2px solid #ccc;
overflow: hidden; overflow: hidden;
display: flex; /* Ensure StatusMonitorComponent fills this wrapper */ display: flex;
flex-direction: column; flex-direction: column;
} }
.status-monitor-wrapper > * { .status-monitor-wrapper > * {
flex-grow: 1; /* Make StatusMonitorComponent fill the wrapper */ flex-grow: 1;
} }
/* 新增:占位符样式 */
.main-workspace-area.placeholder { .main-workspace-area.placeholder {
width: 100%;
height: 100%;
box-sizing: border-box;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;
@@ -316,7 +289,7 @@ watch(isConnected, (connected) => {
text-align: center; text-align: center;
color: #6c757d; color: #6c757d;
padding: 2rem; padding: 2rem;
background-color: #f8f9fa; /* Match sidebar background */ background-color: #f8f9fa;
} }
.main-workspace-area.placeholder h2 { .main-workspace-area.placeholder h2 {
margin-bottom: 0.5rem; margin-bottom: 0.5rem;