diff --git a/frontend/src/components/FileManager/FileManager.tsx b/frontend/src/components/FileManager/FileManager.tsx new file mode 100644 index 00000000..b7602556 --- /dev/null +++ b/frontend/src/components/FileManager/FileManager.tsx @@ -0,0 +1,77 @@ +/** + * SSH文件管理器 + * 双面板布局:左侧显示服务器文件,右侧用于上传 + */ +import React, { useState } from 'react'; +import { DraggableWindow } from '@/components/ui/draggable-window'; +import { ServerFilePanel } from './ServerFilePanel'; +import { UploadPanel } from './UploadPanel'; +import type { FileManagerProps } from './types'; + +export const FileManager: React.FC = ({ + serverId, + serverName, + open, + onClose, + onMinimize, + isActive, + onFocus, +}) => { + const [currentPath, setCurrentPath] = useState('/'); + const [refreshTrigger, setRefreshTrigger] = useState(0); + + // 上传完成后刷新服务器文件列表 + const handleUploadComplete = () => { + setRefreshTrigger(prev => prev + 1); + }; + + if (!open) return null; + + // 计算屏幕中心位置 + const windowWidth = 1100; + const windowHeight = 650; + const centerX = Math.max(0, (window.innerWidth - windowWidth) / 2); + const centerY = Math.max(0, (window.innerHeight - windowHeight) / 2); + + return ( + +
+ {/* 左侧:服务器文件浏览 - 占2/3宽度 */} +
+
+

服务器文件

+
+
+ +
+
+ + {/* 右侧:文件上传 - 占1/3宽度 */} +
+
+

文件上传

+
+ +
+
+
+ ); +}; diff --git a/frontend/src/components/FileManager/ServerFilePanel.tsx b/frontend/src/components/FileManager/ServerFilePanel.tsx new file mode 100644 index 00000000..9b791ea8 --- /dev/null +++ b/frontend/src/components/FileManager/ServerFilePanel.tsx @@ -0,0 +1,298 @@ +/** + * 服务器文件浏览面板 + */ +import React, { useEffect, useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Skeleton } from '@/components/ui/skeleton'; +import { ChevronRight, Folder, File, Home, ArrowLeft, Plus, Trash2, RefreshCw, Link } from 'lucide-react'; +import { browseDirectory, createDirectory, removeFile, pathUtils, type RemoteFileInfo } from '@/services/fileService'; +import { message } from 'antd'; +import type { ServerFilePanelProps } from './types'; + +export const ServerFilePanel: React.FC = ({ + serverId, + currentPath, + onPathChange, + refreshTrigger, +}) => { + const [files, setFiles] = useState([]); + const [loading, setLoading] = useState(false); + const [showNewFolder, setShowNewFolder] = useState(false); + const [newFolderName, setNewFolderName] = useState(''); + + // 加载文件列表 + const loadFiles = async () => { + setLoading(true); + try { + const data = await browseDirectory(serverId, currentPath); + // 排序:目录在前,文件在后 + const sorted = data.sort((a, b) => { + if (a.isDirectory === b.isDirectory) { + return a.name.localeCompare(b.name); + } + return a.isDirectory ? -1 : 1; + }); + setFiles(sorted); + } catch (error: any) { + message.error(error.response?.data?.message || '加载文件列表失败'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + loadFiles(); + }, [serverId, currentPath, refreshTrigger]); + + // 进入目录 + const handleEnterDirectory = (file: RemoteFileInfo) => { + if (file.isDirectory) { + onPathChange(file.path); + } + }; + + // 返回上级目录 + const handleGoBack = () => { + const parent = pathUtils.getParent(currentPath); + onPathChange(parent); + }; + + // 回到Home + const handleGoHome = () => { + onPathChange('/'); + }; + + // 创建新文件夹 + const handleCreateFolder = async () => { + if (!newFolderName.trim()) { + message.warning('请输入文件夹名称'); + return; + } + try { + const newPath = pathUtils.join(currentPath, newFolderName); + await createDirectory(serverId, newPath); + message.success('创建成功'); + setShowNewFolder(false); + setNewFolderName(''); + loadFiles(); + } catch (error: any) { + message.error(error.response?.data?.message || '创建失败'); + } + }; + + // 删除文件/目录 + const handleDelete = async (file: RemoteFileInfo) => { + const confirmed = window.confirm( + file.isDirectory + ? `确定删除目录 "${file.name}" 及其所有内容吗?` + : `确定删除文件 "${file.name}" 吗?` + ); + if (!confirmed) return; + + try { + await removeFile(serverId, file.path, file.isDirectory); + message.success('删除成功'); + loadFiles(); + } catch (error: any) { + message.error(error.response?.data?.message || '删除失败'); + } + }; + + // 面包屑导航 + const breadcrumbs = currentPath.split('/').filter(Boolean); + + return ( +
+ {/* 工具栏 */} +
+ + + + +
+ + {/* 路径导航 */} +
+ / + {breadcrumbs.map((name, index) => ( + + + + + ))} +
+ + {/* 新建文件夹输入框 */} + {showNewFolder && ( +
+ setNewFolderName(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') handleCreateFolder(); + if (e.key === 'Escape') { + setShowNewFolder(false); + setNewFolderName(''); + } + }} + autoFocus + className="flex-1" + /> + + +
+ )} + + {/* 文件列表 */} +
+ {/* 表头 */} + {!loading && files.length > 0 && ( +
+
名称
+
大小
+
权限
+
所有者
+
修改时间
+
+
+ )} + +
+ {loading ? ( +
+ {/* 骨架屏 - 模拟文件列表 */} + {Array.from({ length: 8 }).map((_, i) => ( +
+ + + + + + +
+ ))} +
+ ) : files.length === 0 ? ( +
+ 空目录 +
+ ) : ( +
+ {files.map((file) => ( +
+ {/* 文件名 */} +
handleEnterDirectory(file)} + title={file.isSymlink ? `链接到: ${file.linkTarget || '未知'}` : file.name} + > + {file.isDirectory ? ( + + ) : ( + + )} + + {file.name} + {file.isSymlink && ( + + )} + +
+ + {/* 文件大小 */} + + {file.sizeFormatted} + + + {/* 权限 */} + + {file.permissions} + + + {/* 所有者:组 */} + + {file.owner} + + + {/* 修改时间 */} + + {new Date(file.modifyTime).toLocaleDateString('zh-CN', { + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit' + })} + + + {/* 删除按钮 */} + +
+ ))} +
+ )} +
+
+
+ ); +}; diff --git a/frontend/src/components/FileManager/UploadPanel.tsx b/frontend/src/components/FileManager/UploadPanel.tsx new file mode 100644 index 00000000..0af63393 --- /dev/null +++ b/frontend/src/components/FileManager/UploadPanel.tsx @@ -0,0 +1,272 @@ +/** + * 文件上传面板 + */ +import React, { useState, useRef } from 'react'; +import { Button } from '@/components/ui/button'; +import { Progress } from '@/components/ui/progress'; +import { Upload, X, File as FileIcon, CheckCircle, AlertCircle } from 'lucide-react'; +import { uploadFile, pathUtils } from '@/services/fileService'; +import { message } from 'antd'; +import type { UploadPanelProps, UploadFileItem } from './types'; + +export const UploadPanel: React.FC = ({ + serverId, + currentPath, + onUploadComplete, +}) => { + const [uploadFiles, setUploadFiles] = useState([]); + const [isDragging, setIsDragging] = useState(false); + const fileInputRef = useRef(null); + + // 添加文件到上传列表 + const addFiles = (files: FileList | null) => { + if (!files || files.length === 0) return; + + const newFiles: UploadFileItem[] = Array.from(files).map(file => ({ + file, + id: `${file.name}-${Date.now()}-${Math.random()}`, + status: 'pending', + progress: 0, + })); + + setUploadFiles(prev => [...prev, ...newFiles]); + }; + + // 从列表移除文件 + const removeFile = (id: string) => { + setUploadFiles(prev => prev.filter(f => f.id !== id)); + }; + + // 上传单个文件 + const uploadSingleFile = async (item: UploadFileItem) => { + const remotePath = pathUtils.join(currentPath, item.file.name); + + try { + setUploadFiles(prev => + prev.map(f => + f.id === item.id + ? { ...f, status: 'uploading', progress: 0 } + : f + ) + ); + + await uploadFile( + serverId, + item.file, + remotePath, + false, + (progress) => { + setUploadFiles(prev => + prev.map(f => + f.id === item.id ? { ...f, progress } : f + ) + ); + } + ); + + setUploadFiles(prev => + prev.map(f => + f.id === item.id + ? { ...f, status: 'success', progress: 100 } + : f + ) + ); + + onUploadComplete(); + } catch (error: any) { + const errorMsg = error.response?.data?.message || '上传失败'; + setUploadFiles(prev => + prev.map(f => + f.id === item.id + ? { ...f, status: 'error', errorMessage: errorMsg } + : f + ) + ); + message.error(`${item.file.name}: ${errorMsg}`); + } + }; + + // 上传所有待上传文件 + const handleUploadAll = async () => { + const pendingFiles = uploadFiles.filter(f => f.status === 'pending'); + + for (const file of pendingFiles) { + await uploadSingleFile(file); + } + }; + + // 清空列表 + const handleClearList = () => { + setUploadFiles([]); + }; + + // 拖拽事件处理 + const handleDragEnter = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(true); + }; + + const handleDragLeave = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(false); + }; + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + }; + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(false); + + const files = e.dataTransfer.files; + addFiles(files); + }; + + // 文件选择 + const handleFileSelect = () => { + fileInputRef.current?.click(); + }; + + const handleFileInputChange = (e: React.ChangeEvent) => { + addFiles(e.target.files); + // 重置input,允许选择相同文件 + e.target.value = ''; + }; + + const pendingCount = uploadFiles.filter(f => f.status === 'pending').length; + const uploadingCount = uploadFiles.filter(f => f.status === 'uploading').length; + + return ( +
+ {/* 工具栏 */} +
+ + + +
+ + {/* 隐藏的文件输入 */} + + + {/* 拖拽上传区域 */} +
+ {uploadFiles.length === 0 ? ( +
+ +

拖拽文件到此处

+

或点击选择文件按钮

+

+ 上传目标: {currentPath} +

+
+ ) : ( +
+ {uploadFiles.map(item => ( +
+
+ +
+
+ + {item.file.name} + +
+ {item.status === 'success' && ( + + )} + {item.status === 'error' && ( + + )} + {item.status === 'pending' && ( + + )} +
+
+
+ + {(item.file.size / 1024).toFixed(1)} KB + + {item.status === 'uploading' && ( + + {item.progress}% + + )} + {item.status === 'success' && ( + + 上传成功 + + )} + {item.status === 'error' && ( + + {item.errorMessage} + + )} +
+ {item.status === 'uploading' && ( + + )} +
+
+
+ ))} +
+ )} +
+
+ ); +}; diff --git a/frontend/src/components/FileManager/index.ts b/frontend/src/components/FileManager/index.ts new file mode 100644 index 00000000..4508002d --- /dev/null +++ b/frontend/src/components/FileManager/index.ts @@ -0,0 +1 @@ +export { FileManager } from './FileManager'; diff --git a/frontend/src/components/FileManager/types.ts b/frontend/src/components/FileManager/types.ts new file mode 100644 index 00000000..69abd1ee --- /dev/null +++ b/frontend/src/components/FileManager/types.ts @@ -0,0 +1,34 @@ +/** + * FileManager 相关类型定义 + */ + +export interface UploadPanelProps { + serverId: number; + currentPath: string; + onUploadComplete: () => void; +} + +export interface UploadFileItem { + file: File; + id: string; + status: 'pending' | 'uploading' | 'success' | 'error'; + progress: number; + errorMessage?: string; +} + +export interface ServerFilePanelProps { + serverId: number; + currentPath: string; + onPathChange: (path: string) => void; + refreshTrigger: number; +} + +export interface FileManagerProps { + serverId: number; + serverName: string; + open: boolean; + onClose: () => void; + onMinimize: () => void; + isActive: boolean; + onFocus: () => void; +} diff --git a/frontend/src/components/Terminal/Terminal.tsx b/frontend/src/components/Terminal/Terminal.tsx index 00eefbc2..9013fcf9 100644 --- a/frontend/src/components/Terminal/Terminal.tsx +++ b/frontend/src/components/Terminal/Terminal.tsx @@ -43,7 +43,10 @@ export const Terminal: React.FC = ({ const [auditShown, setAuditShown] = useState(false); const [connectionStatus, setConnectionStatus] = useState('disconnected'); // 初始状态,会被实例状态覆盖 const [errorMessage, setErrorMessage] = useState(''); + const [connectedTime, setConnectedTime] = useState(null); const [showFileManager, setShowFileManager] = useState(false); + const [fileManagerMinimized, setFileManagerMinimized] = useState(false); + const [fileManagerActive, setFileManagerActive] = useState(false); // 默认工具栏配置 const toolbarConfig: TerminalToolbarConfig = { @@ -90,6 +93,14 @@ export const Terminal: React.FC = ({ const unsubscribe = instance.onStateChange((state) => { setConnectionStatus(state.status); setErrorMessage(state.errorMessage || ''); + + // 记录连接成功时间 + if (state.status === 'connected' && !connectedTime) { + setConnectedTime(new Date()); + } else if (state.status === 'disconnected' || state.status === 'error') { + setConnectedTime(null); + } + onStatusChange?.(state.status); if (state.errorMessage) { onError?.(state.errorMessage); @@ -274,14 +285,43 @@ export const Terminal: React.FC = ({ return; } setShowFileManager(true); + setFileManagerMinimized(false); + setFileManagerActive(true); }, [connection.type]); + // 最小化文件管理器 + const handleFileManagerMinimize = useCallback(() => { + setFileManagerMinimized(true); + setFileManagerActive(false); + }, []); + + // 恢复文件管理器 + const handleFileManagerRestore = useCallback(() => { + setFileManagerMinimized(false); + setFileManagerActive(true); + }, []); + + // 激活文件管理器 + const handleFileManagerFocus = useCallback(() => { + setFileManagerActive(true); + }, []); + + // 关闭文件管理器 + const handleFileManagerClose = useCallback(() => { + setShowFileManager(false); + setFileManagerMinimized(false); + setFileManagerActive(false); + }, []); + return (
{/* 工具栏 */} = ({ )}
+ {/* 最小化窗口状态栏 */} + {connection.type === 'ssh' && showFileManager && fileManagerMinimized && ( +
+ +
+ )} + {/* 文件管理器 */} - {connection.type === 'ssh' && showFileManager && connection.serverId && ( + {connection.type === 'ssh' && showFileManager && connection.serverId && !fileManagerMinimized && ( setShowFileManager(false)} + onClose={handleFileManagerClose} + onMinimize={handleFileManagerMinimize} + isActive={fileManagerActive} + onFocus={handleFileManagerFocus} /> )} diff --git a/frontend/src/components/Terminal/TerminalSplitNode.tsx b/frontend/src/components/Terminal/TerminalSplitNode.tsx index 50588408..59b514e1 100644 --- a/frontend/src/components/Terminal/TerminalSplitNode.tsx +++ b/frontend/src/components/Terminal/TerminalSplitNode.tsx @@ -6,7 +6,7 @@ import React from 'react'; import { Terminal } from './Terminal'; import { TerminalSplitDivider } from './TerminalSplitDivider'; import { X, Plus } from 'lucide-react'; -import type { SplitNode, TerminalTab, TerminalConnectionConfig, TerminalAuditConfig, TerminalToolbarConfig } from './types'; +import type { SplitNode, TerminalTab, TerminalConnectionConfig, TerminalAuditConfig, TerminalToolbarConfig, ConnectionStatus } from './types'; /** * 分屏节点渲染器 Props @@ -27,6 +27,7 @@ export interface TerminalSplitNodeProps { getAuditConfig: () => TerminalAuditConfig; getToolbarConfig: () => TerminalToolbarConfig; hasMultipleGroups?: boolean; // 是否有多个分屏组 + onTerminalStatusChange?: (tabId: string, status: ConnectionStatus) => void; // Terminal状态变化回调 } /** @@ -50,6 +51,7 @@ const TerminalSplitNodeComponent: React.FC = ({ getAuditConfig, getToolbarConfig, hasMultipleGroups = false, + onTerminalStatusChange, }) => { // 渲染 Group 节点(包含 Tabs 和 Terminal) if (node.type === 'group') { @@ -126,6 +128,7 @@ const TerminalSplitNodeComponent: React.FC = ({ toolbar={toolbarConfig} isActive={tab.isActive} compact={hasMultipleGroups} + onStatusChange={(status) => onTerminalStatusChange?.(tab.id, status)} onSplitUp={onSplitUp} onSplitDown={onSplitDown} onSplitLeft={onSplitLeft} @@ -169,6 +172,7 @@ const TerminalSplitNodeComponent: React.FC = ({ getAuditConfig={getAuditConfig} getToolbarConfig={getToolbarConfig} hasMultipleGroups={hasMultipleGroups} + onTerminalStatusChange={onTerminalStatusChange} /> diff --git a/frontend/src/components/Terminal/TerminalSplitView.tsx b/frontend/src/components/Terminal/TerminalSplitView.tsx index 83431cd9..430884d9 100644 --- a/frontend/src/components/Terminal/TerminalSplitView.tsx +++ b/frontend/src/components/Terminal/TerminalSplitView.tsx @@ -2,10 +2,10 @@ * Terminal 分屏视图 - 主组件 * 管理分屏状态、键盘快捷键,并委托给 TerminalSplitNode 进行递归渲染 */ -import React, { useEffect } from 'react'; +import React, { useRef, useEffect } from 'react'; import { useSplitView } from './useSplitView'; import { TerminalSplitNode } from './TerminalSplitNode'; -import type { TerminalTab, TerminalConnectionConfig, TerminalAuditConfig, TerminalToolbarConfig, SplitNode } from './types'; +import type { TerminalTab, TerminalConnectionConfig, TerminalAuditConfig, TerminalToolbarConfig, SplitNode, ConnectionStatus } from './types'; // 对外暴露的主组件Props export interface TerminalSplitViewProps { @@ -15,6 +15,7 @@ export interface TerminalSplitViewProps { getToolbarConfig: () => TerminalToolbarConfig; onCloseAllReady?: (closeAllFn: () => void) => void; // 暴露closeAll函数给外部 onWindowClose?: () => void; // 最后一个Tab关闭时,通知关闭整个窗口 + onStatusChange?: (status: ConnectionStatus) => void; // 当前激活Tab的连接状态变化 } /** @@ -32,6 +33,7 @@ export const TerminalSplitView: React.FC = ({ getToolbarConfig, onCloseAllReady, onWindowClose, + onStatusChange, }) => { const { layout, @@ -48,6 +50,62 @@ export const TerminalSplitView: React.FC = ({ setActiveGroupId, } = useSplitView({ initialTab, onWindowClose }); + // 使用useRef追踪所有Tab的连接状态,避免无限循环 + const tabStatusesRef = useRef>(new Map()); + const lastReportedStatusRef = useRef<{tabId: string | null, status: ConnectionStatus | null}>({ tabId: null, status: null }); + const onStatusChangeRef = useRef(onStatusChange); + + // 保持onStatusChange引用最新 + useEffect(() => { + onStatusChangeRef.current = onStatusChange; + }, [onStatusChange]); + + // 找到当前激活的Tab ID (不使用useCallback,直接定义) + const findActiveTabId = (node: SplitNode, targetGroupId: string): string | null => { + if (node.type === 'group' && node.id === targetGroupId) { + const activeTab = node.tabs.find(tab => tab.isActive); + return activeTab?.id || null; + } + if (node.type === 'container') { + for (const child of node.children) { + const result = findActiveTabId(child, targetGroupId); + if (result) return result; + } + } + return null; + }; + + // 当Terminal状态变化时的回调 - 使用稳定的引用 + const handleTerminalStatusChange = React.useCallback((tabId: string, status: ConnectionStatus) => { + // 更新状态映射 + tabStatusesRef.current.set(tabId, status); + + // 使用ref获取最新的layout和activeGroupId,避免依赖导致重建 + const activeTabId = findActiveTabId(layout.root, activeGroupId); + if (activeTabId === tabId && onStatusChangeRef.current) { + // 只有状态真正变化时才报告 + const lastReported = lastReportedStatusRef.current; + if (lastReported.tabId !== tabId || lastReported.status !== status) { + lastReportedStatusRef.current = { tabId, status }; + onStatusChangeRef.current(status); + } + } + }, []); // 空依赖,永远不重新创建 + + // 当激活Tab变化时,报告新激活Tab的状态 + useEffect(() => { + const activeTabId = findActiveTabId(layout.root, activeGroupId); + if (activeTabId && onStatusChangeRef.current) { + const status = tabStatusesRef.current.get(activeTabId) || 'connecting'; + const lastReported = lastReportedStatusRef.current; + // 只有Tab或状态变化时才报告 + if (lastReported.tabId !== activeTabId || lastReported.status !== status) { + lastReportedStatusRef.current = { tabId: activeTabId, status }; + onStatusChangeRef.current(status); + } + } + }, [activeGroupId, layout.root]); + // 暴露closeAll方法给外部 useEffect(() => { if (onCloseAllReady) { @@ -110,6 +168,7 @@ export const TerminalSplitView: React.FC = ({ getAuditConfig={getAuditConfig} getToolbarConfig={getToolbarConfig} hasMultipleGroups={hasMultipleGroups} + onTerminalStatusChange={handleTerminalStatusChange} /> ); diff --git a/frontend/src/components/Terminal/TerminalToolbar.tsx b/frontend/src/components/Terminal/TerminalToolbar.tsx index 2100d0d1..3e39213b 100644 --- a/frontend/src/components/Terminal/TerminalToolbar.tsx +++ b/frontend/src/components/Terminal/TerminalToolbar.tsx @@ -20,6 +20,9 @@ import styles from './index.module.less'; interface TerminalToolbarProps { config: TerminalToolbarConfig; connectionStatus: ConnectionStatus; + serverName?: string; + hostIp?: string; + connectedTime?: Date | null; fontSize: number; currentTheme?: string; themes?: TerminalTheme[]; @@ -30,7 +33,7 @@ interface TerminalToolbarProps { onZoomIn?: () => void; onZoomOut?: () => void; onReconnect?: () => void; - onThemeChange?: (themeName: string) => void; + onThemeChange?: (theme: string) => void; onFileManager?: () => void; // 分屏操作 onSplitUp?: () => void; @@ -43,6 +46,9 @@ interface TerminalToolbarProps { export const TerminalToolbar: React.FC = ({ config, connectionStatus, + serverName, + hostIp, + connectedTime, fontSize, currentTheme, themes = [], @@ -61,8 +67,35 @@ export const TerminalToolbar: React.FC = ({ onSplitRight, onSplitInGroup, }) => { + const [, forceUpdate] = React.useState(0); + + // 定期更新连接时长显示 + React.useEffect(() => { + if (connectionStatus === 'connected' && connectedTime) { + const timer = setInterval(() => { + forceUpdate(prev => prev + 1); + }, 1000); // 每秒更新一次 + return () => clearInterval(timer); + } + }, [connectionStatus, connectedTime]); + if (!config.show) return null; + // 计算连接时长 + const getConnectionDuration = () => { + if (!connectedTime) return ''; + const now = new Date(); + const diff = Math.floor((now.getTime() - connectedTime.getTime()) / 1000); // 秒 + const hours = Math.floor(diff / 3600); + const minutes = Math.floor((diff % 3600) / 60); + const seconds = diff % 60; + + if (hours > 0) { + return `${hours}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; + } + return `${minutes}:${seconds.toString().padStart(2, '0')}`; + }; + const getStatusBadge = () => { // 紧凑模式:只显示状态圆点 if (compact) { @@ -70,7 +103,7 @@ export const TerminalToolbar: React.FC = ({ case 'connecting': return
; case 'connected': - return
; + return
; case 'reconnecting': return
; case 'error': @@ -85,7 +118,25 @@ export const TerminalToolbar: React.FC = ({ case 'connecting': return 连接中; case 'connected': - return
已连接; + // 已连接状态:显示IP和连接时长 + return ( + +
+ 已连接 + {hostIp && ( + <> + | + {hostIp} + + )} + {connectedTime && ( + <> + | + {getConnectionDuration()} + + )} + + ); case 'reconnecting': return 重连中; case 'error': diff --git a/frontend/src/components/Terminal/TerminalWindowManager.tsx b/frontend/src/components/Terminal/TerminalWindowManager.tsx index 12767a0d..85f7f7c0 100644 --- a/frontend/src/components/Terminal/TerminalWindowManager.tsx +++ b/frontend/src/components/Terminal/TerminalWindowManager.tsx @@ -125,9 +125,10 @@ export function TerminalWindowManager({ console.log(`⬆️ 恢复窗口: ${windowId}`); // 触发resize事件,确保终端正确调整尺寸 - setTimeout(() => window.dispatchEvent(new Event('resize')), 50); - setTimeout(() => window.dispatchEvent(new Event('resize')), 150); - setTimeout(() => window.dispatchEvent(new Event('resize')), 300); + // 延迟100ms等待DOM更新完成 + setTimeout(() => { + window.dispatchEvent(new Event('resize')); + }, 100); }, []); // 激活窗口 @@ -148,14 +149,15 @@ export function TerminalWindowManager({ const getButtonStyle = (status?: ConnectionStatus) => { switch (status) { case 'connected': - return 'bg-green-600 hover:bg-green-700'; - case 'error': - case 'disconnected': - return 'bg-red-600 hover:bg-red-700'; + return 'bg-green-600 hover:bg-green-700'; // 绿色:已连接 case 'connecting': - return 'bg-yellow-600 hover:bg-yellow-700'; + return 'bg-yellow-600 hover:bg-yellow-700'; // 黄色:正在连接 case 'reconnecting': - return 'bg-orange-600 hover:bg-orange-700'; + return 'bg-orange-600 hover:bg-orange-700'; // 橙色:重连中 + case 'error': + return 'bg-red-600 hover:bg-red-700'; // 红色:连接失败 + case 'disconnected': + return 'bg-yellow-600 hover:bg-yellow-700'; // 黄色:已断开 default: return 'bg-blue-600 hover:bg-blue-700'; } diff --git a/frontend/src/components/Terminal/core/TerminalInstance.ts b/frontend/src/components/Terminal/core/TerminalInstance.ts index 3b6ab9ed..436bc317 100644 --- a/frontend/src/components/Terminal/core/TerminalInstance.ts +++ b/frontend/src/components/Terminal/core/TerminalInstance.ts @@ -55,6 +55,7 @@ export class TerminalInstance { private stateListeners: Set = new Set(); private unsubscribers: Array<() => void> = []; private auditShown: boolean = false; + private firstOutputReceived: boolean = false; // 追踪是否已接收首次output constructor(private config: TerminalInstanceConfig) { // 初始化 XTerm @@ -313,6 +314,19 @@ export class TerminalInstance { // 监听消息接收 const unsubMessage = this.connectionStrategy.onMessage((data) => { this.xterm.write(data); + + // 首次接收output时,触发resize以正确适应窗口大小 + if (!this.firstOutputReceived && data && data.length > 0) { + this.firstOutputReceived = true; + // 延迟一点确保DOM已渲染 + setTimeout(() => { + this.fitAddon.fit(); + // 发送终端尺寸给后端 + const cols = this.xterm.cols; + const rows = this.xterm.rows; + this.connectionStrategy.resize(cols, rows); + }, 50); + } }); // 监听状态变化 diff --git a/frontend/src/components/Terminal/types.ts b/frontend/src/components/Terminal/types.ts index 90b04ad8..8f898bbc 100644 --- a/frontend/src/components/Terminal/types.ts +++ b/frontend/src/components/Terminal/types.ts @@ -120,6 +120,10 @@ export interface TerminalConnectionConfig { type: TerminalType; /** 服务器ID(仅SSH需要) */ serverId?: string | number; + /** 服务器名称 */ + serverName?: string; + /** 服务器IP地址 */ + hostIp?: string; /** 认证token */ token?: string; /** 连接超时时间(毫秒) */ diff --git a/frontend/src/pages/Resource/Server/List/components/SSHWindowManager.tsx b/frontend/src/pages/Resource/Server/List/components/SSHWindowManager.tsx index 1174fad3..3a872481 100644 --- a/frontend/src/pages/Resource/Server/List/components/SSHWindowManager.tsx +++ b/frontend/src/pages/Resource/Server/List/components/SSHWindowManager.tsx @@ -10,6 +10,7 @@ import { type TerminalConnectionConfig, type TerminalAuditConfig, type TerminalToolbarConfig, + type ConnectionStatus, } from '@/components/Terminal'; import type { ServerResponse } from '../types'; @@ -24,6 +25,7 @@ interface SSHTerminalSplitViewWrapperProps { getAuditConfig: () => TerminalAuditConfig; getToolbarConfig: () => TerminalToolbarConfig; onCloseReady: () => void; + onStatusChange?: (status: ConnectionStatus) => void; } const SSHTerminalSplitViewWrapper: React.FC = ({ @@ -33,8 +35,20 @@ const SSHTerminalSplitViewWrapper: React.FC = getAuditConfig, getToolbarConfig, onCloseReady, + onStatusChange, }) => { const closeAllRef = useRef<(() => void) | null>(null); + const onCloseReadyRef = useRef(onCloseReady); + const onStatusChangeRef = useRef(onStatusChange); + + // 保持最新的回调引用 + useEffect(() => { + onCloseReadyRef.current = onCloseReady; + }, [onCloseReady]); + + useEffect(() => { + onStatusChangeRef.current = onStatusChange; + }, [onStatusChange]); // 注册优雅关闭方法到window对象 useEffect(() => { @@ -46,7 +60,7 @@ const SSHTerminalSplitViewWrapper: React.FC = closeAllRef.current(); } // 再通知窗口可以关闭 - onCloseReady(); + onCloseReadyRef.current(); }; console.log(`[SSHTerminalSplitViewWrapper] 注册优雅关闭方法: ${closeMethodName}`); @@ -54,7 +68,16 @@ const SSHTerminalSplitViewWrapper: React.FC = delete (window as any)[closeMethodName]; console.log(`[SSHTerminalSplitViewWrapper] 注销优雅关闭方法: ${closeMethodName}`); }; - }, [windowId, onCloseReady]); + }, [windowId]); // 只依赖windowId,不依赖onCloseReady + + // 使用稳定的回调引用 + const stableOnWindowClose = React.useCallback(() => { + onCloseReadyRef.current(); + }, []); + + const stableOnStatusChange = React.useCallback((status: ConnectionStatus) => { + onStatusChangeRef.current?.(status); + }, []); return ( = // 保存closeAll函数到ref closeAllRef.current = closeAllFn; }} - onWindowClose={onCloseReady} + onWindowClose={stableOnWindowClose} + onStatusChange={stableOnStatusChange} /> ); }; @@ -98,6 +122,8 @@ export const SSHWindowManager: React.FC = ({ onOpenWindow return { type: 'ssh', serverId: tab.serverId, + serverName: server.serverName, + hostIp: server.hostIp, token: token || undefined, autoReconnect: true, reconnectInterval: 3000, @@ -130,6 +156,7 @@ export const SSHWindowManager: React.FC = ({ onOpenWindow getAuditConfig={getAuditConfig} getToolbarConfig={getToolbarConfig} onCloseReady={onCloseReady} + onStatusChange={onStatusChange} /> ); }} diff --git a/frontend/src/services/fileService.ts b/frontend/src/services/fileService.ts new file mode 100644 index 00000000..25217c52 --- /dev/null +++ b/frontend/src/services/fileService.ts @@ -0,0 +1,155 @@ +/** + * SSH文件管理API服务 + */ +import request from '@/utils/request'; + +const API_BASE = '/api/v1/server-ssh'; + +export interface RemoteFileInfo { + name: string; + path: string; + isDirectory: boolean; + size: number | null; + sizeFormatted: string; + permissions: string; + permissionsOctal?: string; + modifyTime: string; + owner: string; + ownerUid?: number; + group: string; + groupGid?: number; + extension: string | null; + isSymlink: boolean; + linkTarget: string | null; +} + +export interface FileUploadResult { + fileName: string; + remotePath: string; + fileSize: number; + fileSizeFormatted: string; + uploadTime: string; + permissions: string; + success: boolean; + errorMessage: string | null; +} + +/** + * 浏览目录 + */ +export const browseDirectory = async ( + serverId: number, + path?: string +): Promise => { + const response = await request.get(`${API_BASE}/${serverId}/files/browse`, { + params: { path }, + }); + return response; // request拦截器已经提取了data字段 +}; + +/** + * 创建目录 + */ +export const createDirectory = async ( + serverId: number, + path: string +): Promise => { + await request.post(`${API_BASE}/${serverId}/files/mkdir`, null, { + params: { path }, + }); +}; + +/** + * 删除文件/目录 + */ +export const removeFile = async ( + serverId: number, + path: string, + recursive?: boolean +): Promise => { + await request.delete(`${API_BASE}/${serverId}/files/remove`, { + params: { path, recursive }, + }); +}; + +/** + * 上传文件 + */ +export const uploadFile = async ( + serverId: number, + file: File, + remotePath: string, + overwrite?: boolean, + onProgress?: (progress: number) => void +): Promise => { + const formData = new FormData(); + formData.append('file', file); + formData.append('remotePath', remotePath); + if (overwrite !== undefined) { + formData.append('overwrite', String(overwrite)); + } + + const response = await request.post( + `${API_BASE}/${serverId}/files/upload`, + formData, + { + headers: { + 'Content-Type': 'multipart/form-data', + }, + onUploadProgress: (progressEvent) => { + if (onProgress && progressEvent.total) { + const progress = Math.round( + (progressEvent.loaded * 100) / progressEvent.total + ); + onProgress(progress); + } + }, + } + ); + return response; // request拦截器已经提取了data字段 +}; + +/** + * 重命名文件/目录 + */ +export const renameFile = async ( + serverId: number, + oldPath: string, + newPath: string +): Promise => { + await request.put(`${API_BASE}/${serverId}/files/rename`, null, { + params: { oldPath, newPath }, + }); +}; + +/** + * 路径工具函数 + */ +export const pathUtils = { + /** + * 拼接路径 + */ + join(basePath: string, fileName: string): string { + if (!basePath) return fileName; + return basePath.endsWith('/') + ? basePath + fileName + : basePath + '/' + fileName; + }, + + /** + * 获取父路径 + */ + getParent(path: string): string { + if (!path || path === '/') return '/'; + const lastSlash = path.lastIndexOf('/'); + return lastSlash <= 0 ? '/' : path.substring(0, lastSlash); + }, + + /** + * 获取文件名 + */ + getFileName(path: string): string { + const lastSlash = path.lastIndexOf('/'); + return lastSlash === -1 ? path : path.substring(lastSlash + 1); + }, +};