diff --git a/frontend/package.json b/frontend/package.json index 378c0a9f..02c3d1c1 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -55,6 +55,9 @@ "@tisoap/react-flow-smart-edge": "^4.0.1", "@types/recharts": "^1.8.29", "@types/uuid": "^10.0.0", + "@xterm/addon-fit": "^0.10.0", + "@xterm/addon-search": "^0.15.0", + "@xterm/addon-web-links": "^0.11.0", "@xyflow/react": "^12.8.6", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", @@ -78,6 +81,7 @@ "recharts": "^2.15.0", "rsuite": "^5.83.3", "uuid": "^13.0.0", + "xterm": "^5.3.0", "zod": "^3.24.1" }, "devDependencies": { diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index b6f41752..3f68c368 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -140,6 +140,15 @@ importers: '@types/uuid': specifier: ^10.0.0 version: 10.0.0 + '@xterm/addon-fit': + specifier: ^0.10.0 + version: 0.10.0(@xterm/xterm@5.5.0) + '@xterm/addon-search': + specifier: ^0.15.0 + version: 0.15.0(@xterm/xterm@5.5.0) + '@xterm/addon-web-links': + specifier: ^0.11.0 + version: 0.11.0(@xterm/xterm@5.5.0) '@xyflow/react': specifier: ^12.8.6 version: 12.9.0(@types/react@18.3.18)(immer@10.1.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -209,6 +218,9 @@ importers: uuid: specifier: ^13.0.0 version: 13.0.0 + xterm: + specifier: ^5.3.0 + version: 5.3.0 zod: specifier: ^3.24.1 version: 3.24.1 @@ -2314,6 +2326,24 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 + '@xterm/addon-fit@0.10.0': + resolution: {integrity: sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==} + peerDependencies: + '@xterm/xterm': ^5.0.0 + + '@xterm/addon-search@0.15.0': + resolution: {integrity: sha512-ZBZKLQ+EuKE83CqCmSSz5y1tx+aNOCUaA7dm6emgOX+8J9H1FWXZyrKfzjwzV+V14TV3xToz1goIeRhXBS5qjg==} + peerDependencies: + '@xterm/xterm': ^5.0.0 + + '@xterm/addon-web-links@0.11.0': + resolution: {integrity: sha512-nIHQ38pQI+a5kXnRaTgwqSHnX7KE6+4SVoceompgHL26unAxdfP6IPqUTSYPQgSwM56hsElfoNrrW5V7BUED/Q==} + peerDependencies: + '@xterm/xterm': ^5.0.0 + + '@xterm/xterm@5.5.0': + resolution: {integrity: sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==} + '@xyflow/react@12.9.0': resolution: {integrity: sha512-bt37E8Wf2HQ7hHQaMSnOw4UEWQqWlNwzfgF9tjix5Fu9Pn/ph3wbexSS/wbWnTkv0vhgMVyphQLfFWIuCe59hQ==} peerDependencies: @@ -4352,6 +4382,10 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + xterm@5.3.0: + resolution: {integrity: sha512-8QqjlekLUFTrU6x7xck1MsPzPA571K5zNqWm0M0oroYEWVOptZ0+ubQSkQ3uxIEhcIHRujJy6emDWX4A7qyFzg==} + deprecated: This package is now deprecated. Move to @xterm/xterm instead. + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -6746,6 +6780,20 @@ snapshots: transitivePeerDependencies: - supports-color + '@xterm/addon-fit@0.10.0(@xterm/xterm@5.5.0)': + dependencies: + '@xterm/xterm': 5.5.0 + + '@xterm/addon-search@0.15.0(@xterm/xterm@5.5.0)': + dependencies: + '@xterm/xterm': 5.5.0 + + '@xterm/addon-web-links@0.11.0(@xterm/xterm@5.5.0)': + dependencies: + '@xterm/xterm': 5.5.0 + + '@xterm/xterm@5.5.0': {} + '@xyflow/react@12.9.0(@types/react@18.3.18)(immer@10.1.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@xyflow/system': 0.0.71 @@ -8984,6 +9032,8 @@ snapshots: wrappy@1.0.2: {} + xterm@5.3.0: {} + yallist@3.1.1: {} yaml@1.10.2: {} diff --git a/frontend/src/components/ui/draggable-window.tsx b/frontend/src/components/ui/draggable-window.tsx new file mode 100644 index 00000000..ce08338f --- /dev/null +++ b/frontend/src/components/ui/draggable-window.tsx @@ -0,0 +1,212 @@ +import React, { useRef, useState, useEffect } from 'react'; +import { X, Minus, Maximize2, Minimize2 } from 'lucide-react'; +import { Button } from '@/components/ui/button'; + +interface DraggableWindowProps { + id: string; + title: string; + children: React.ReactNode; + onClose: () => void; + onMinimize: () => void; + isActive: boolean; + onFocus: () => void; + initialPosition?: { x: number; y: number }; + initialSize?: { width: number; height: number }; +} + +export const DraggableWindow: React.FC = ({ + id, + title, + children, + onClose, + onMinimize, + isActive, + onFocus, + initialPosition = { x: 100, y: 100 }, + initialSize = { width: 1200, height: 700 }, +}) => { + const windowRef = useRef(null); + const headerRef = useRef(null); + const [isDragging, setIsDragging] = useState(false); + const [isResizing, setIsResizing] = useState(false); + const [position, setPosition] = useState(initialPosition); + const [size, setSize] = useState(initialSize); + const [isMaximized, setIsMaximized] = useState(false); + const [savedState, setSavedState] = useState({ position: initialPosition, size: initialSize }); + const dragStartRef = useRef({ x: 0, y: 0 }); + const resizeStartRef = useRef({ x: 0, y: 0, width: 0, height: 0 }); + + // 拖动开始 + const handleDragStart = (e: React.MouseEvent) => { + if (isMaximized) return; + setIsDragging(true); + dragStartRef.current = { + x: e.clientX - position.x, + y: e.clientY - position.y, + }; + onFocus(); + e.preventDefault(); + }; + + // 拖动中 + useEffect(() => { + if (!isDragging) return; + + const handleMouseMove = (e: MouseEvent) => { + setPosition({ + x: e.clientX - dragStartRef.current.x, + y: e.clientY - dragStartRef.current.y, + }); + }; + + const handleMouseUp = () => { + setIsDragging(false); + }; + + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + + return () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + }, [isDragging]); + + // 调整大小开始 + const handleResizeStart = (e: React.MouseEvent) => { + if (isMaximized) return; + setIsResizing(true); + resizeStartRef.current = { + x: e.clientX, + y: e.clientY, + width: size.width, + height: size.height, + }; + onFocus(); + e.preventDefault(); + e.stopPropagation(); + }; + + // 调整大小中 + useEffect(() => { + if (!isResizing) return; + + const handleMouseMove = (e: MouseEvent) => { + const deltaX = e.clientX - resizeStartRef.current.x; + const deltaY = e.clientY - resizeStartRef.current.y; + + setSize({ + width: Math.max(600, resizeStartRef.current.width + deltaX), + height: Math.max(400, resizeStartRef.current.height + deltaY), + }); + }; + + const handleMouseUp = () => { + setIsResizing(false); + }; + + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + + return () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + }, [isResizing]); + + // 最大化/还原 + const toggleMaximize = () => { + if (isMaximized) { + setPosition(savedState.position); + setSize(savedState.size); + } else { + setSavedState({ position, size }); + setPosition({ x: 0, y: 0 }); + setSize({ width: window.innerWidth, height: window.innerHeight }); + } + setIsMaximized(!isMaximized); + }; + + // 双击标题栏最大化 + const handleHeaderDoubleClick = () => { + toggleMaximize(); + }; + + const zIndex = isActive ? 1000 : 999; + + return ( +
+ {/* 窗口标题栏 */} +
+
+ {title} +
+
+ + + +
+
+ + {/* 窗口内容 */} +
+ {children} +
+ + {/* 调整大小手柄 */} + {!isMaximized && ( +
+ )} +
+ ); +}; diff --git a/frontend/src/pages/Resource/Server/List/components/SSHTerminalContent.tsx b/frontend/src/pages/Resource/Server/List/components/SSHTerminalContent.tsx new file mode 100644 index 00000000..4c98d818 --- /dev/null +++ b/frontend/src/pages/Resource/Server/List/components/SSHTerminalContent.tsx @@ -0,0 +1,709 @@ +import React, { useEffect, useRef, useState, useCallback } from 'react'; +import { Terminal } from 'xterm'; +import { FitAddon } from '@xterm/addon-fit'; +import { WebLinksAddon } from '@xterm/addon-web-links'; +import { SearchAddon } from '@xterm/addon-search'; +import 'xterm/css/xterm.css'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Loader2, XCircle, Trash2, Copy, ZoomIn, ZoomOut, RotateCcw, Search, ChevronUp, ChevronDown, X } from 'lucide-react'; +import { message } from 'antd'; +import type { ServerResponse } from '../types'; + +// 自定义样式 +const customStyles = ` + .ssh-terminal-content .xterm-viewport::-webkit-scrollbar { + width: 8px; + } + + .ssh-terminal-content .xterm-viewport::-webkit-scrollbar-track { + background: #2d2d2d; + border-radius: 4px; + } + + .ssh-terminal-content .xterm-viewport::-webkit-scrollbar-thumb { + background: #555; + border-radius: 4px; + } + + .ssh-terminal-content .xterm-viewport::-webkit-scrollbar-thumb:hover { + background: #666; + } + + /* 水印样式 */ + .terminal-watermark { + position: absolute; + inset: 0; + pointer-events: none; + user-select: none; + z-index: 1; + overflow: hidden; + } + + .watermark-item { + position: absolute; + font-size: 14px; + font-weight: 500; + color: rgba(255, 255, 255, 0.03); + white-space: nowrap; + transform: rotate(-20deg); + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + } +`; + +interface SSHTerminalContentProps { + server: ServerResponse; + windowId: string; + onCloseReady?: () => void; // 断开完成后的回调 +} + +type ConnectionStatus = 'initializing' | 'connecting' | 'connected' | 'disconnecting' | 'disconnected' | 'error'; + +interface WebSocketMessage { + type: 'output' | 'error' | 'status'; + data?: string; + message?: string; + status?: ConnectionStatus; +} + +export const SSHTerminalContent: React.FC = ({ + server, + windowId, + onCloseReady, +}) => { + const terminalRef = useRef(null); + const terminalInstanceRef = useRef(null); + const wsRef = useRef(null); + const fitAddonRef = useRef(null); + const [connectionStatus, setConnectionStatus] = useState('initializing'); + const [errorMessage, setErrorMessage] = useState(''); + const isClosingRef = useRef(false); + const [fontSize, setFontSize] = useState(14); + const [showSearch, setShowSearch] = useState(false); + const [searchKeyword, setSearchKeyword] = useState(''); + const searchAddonRef = useRef(null); + const [currentTime, setCurrentTime] = useState(''); + + // 生成水印内容 + const getWatermarkText = useCallback(() => { + const username = localStorage.getItem('username') || 'User'; + return `${username} · ${server.serverName} · ${currentTime}`; + }, [server.serverName, currentTime]); + + // 更新时间 + useEffect(() => { + const updateTime = () => { + const now = new Date(); + setCurrentTime(now.toLocaleString('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false + })); + }; + + updateTime(); + const timer = setInterval(updateTime, 1000); + + return () => clearInterval(timer); + }, []); + + // 生成水印元素 + const renderWatermark = () => { + const watermarkText = getWatermarkText(); + const items = []; + const rows = 8; + const cols = 4; + + for (let i = 0; i < rows; i++) { + for (let j = 0; j < cols; j++) { + items.push( +
+ {watermarkText} +
+ ); + } + } + + return items; + }; + + // 初始化终端和建立连接 + const initializeTerminalAndConnect = useCallback(() => { + if (!terminalRef.current) { + console.error('❌ terminalRef.current 为 null,无法初始化'); + return; + } + + console.log('✅ 开始初始化SSH终端:', server.serverName); + setConnectionStatus('initializing'); + setErrorMessage(''); + + const terminal = new Terminal({ + cursorBlink: true, + fontSize: 14, + fontFamily: 'Consolas, "Courier New", monospace', + theme: { + background: '#1e1e1e', + foreground: '#d4d4d4', + cursor: '#d4d4d4', + black: '#000000', + red: '#cd3131', + green: '#0dbc79', + yellow: '#e5e510', + blue: '#2472c8', + magenta: '#bc3fbc', + cyan: '#11a8cd', + white: '#e5e5e5', + brightBlack: '#666666', + brightRed: '#f14c4c', + brightGreen: '#23d18b', + brightYellow: '#f5f543', + brightBlue: '#3b8eea', + brightMagenta: '#d670d6', + brightCyan: '#29b8db', + brightWhite: '#e5e5e5', + }, + rows: 30, + cols: 100, + }); + + const fitAddon = new FitAddon(); + const webLinksAddon = new WebLinksAddon(); + const searchAddon = new SearchAddon(); + + terminal.loadAddon(fitAddon); + terminal.loadAddon(webLinksAddon); + terminal.loadAddon(searchAddon); + + searchAddonRef.current = searchAddon; + terminal.open(terminalRef.current); + + terminalInstanceRef.current = terminal; + fitAddonRef.current = fitAddon; + + setTimeout(() => { + fitAddon.fit(); + }, 100); + + // 监听终端输入 + terminal.onData((data) => { + if (wsRef.current?.readyState === WebSocket.OPEN) { + wsRef.current.send(JSON.stringify({ + type: 'input', + data: data, + })); + } + }); + + // 延迟连接 + setTimeout(() => { + connectWebSocket(); + }, 300); + }, [server.id]); + + const connectWebSocket = () => { + if (wsRef.current?.readyState === WebSocket.OPEN) return; + + setConnectionStatus('connecting'); + setErrorMessage(''); + + const token = localStorage.getItem('token'); + if (!token) { + const errorMsg = '身份认证失败'; + const tips = '您的登录状态已过期,请重新登录系统'; + setConnectionStatus('error'); + setErrorMessage(errorMsg); + terminalInstanceRef.current?.writeln(`\r\n\x1b[31m✗ ${errorMsg}\x1b[0m`); + terminalInstanceRef.current?.writeln(`\x1b[33m💡 ${tips}\x1b[0m`); + message.error({ + content: errorMsg, + duration: 4, + }); + return; + } + + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const wsUrl = `${protocol}//${window.location.host}/api/v1/server-ssh/connect/${server.id}?token=${token}`; + + const ws = new WebSocket(wsUrl); + wsRef.current = ws; + + ws.onopen = () => { + terminalInstanceRef.current?.writeln('\x1b[32m✓ WebSocket连接已建立\x1b[0m'); + terminalInstanceRef.current?.writeln('\x1b[36m正在建立SSH会话...\x1b[0m\r\n'); + }; + + ws.onmessage = (event) => { + try { + const msg: WebSocketMessage = JSON.parse(event.data); + + switch (msg.type) { + case 'output': + if (msg.data) { + terminalInstanceRef.current?.write(msg.data); + } + break; + + case 'error': + terminalInstanceRef.current?.writeln(`\r\n\x1b[31m错误: ${msg.message}\x1b[0m\r\n`); + message.error(msg.message || '连接错误'); + break; + + case 'status': + if (msg.status) { + setConnectionStatus(msg.status); + if (msg.status === 'connected') { + terminalInstanceRef.current?.writeln('\x1b[32m✓ SSH会话已建立\x1b[0m\r\n'); + setTimeout(() => { + fitAddonRef.current?.fit(); + }, 100); + } + } + break; + } + } catch (error) { + console.error('❌ 解析WebSocket消息失败:', error); + } + }; + + ws.onerror = () => { + const errorMsg = '无法连接到SSH服务'; + const tips = '可能原因:后端服务未启动、网络不通或防火墙限制'; + setConnectionStatus('error'); + setErrorMessage(errorMsg); + terminalInstanceRef.current?.writeln(`\r\n\x1b[31m✗ ${errorMsg}\x1b[0m`); + terminalInstanceRef.current?.writeln(`\x1b[33m💡 ${tips}\x1b[0m`); + message.error({ + content: errorMsg, + duration: 4, + }); + }; + + ws.onclose = (event) => { + console.log('🔌 WebSocket关闭, code:', event.code, 'isClosing:', isClosingRef.current); + wsRef.current = null; + + if (isClosingRef.current) { + // 用户主动关闭,通知父组件可以关闭窗口了 + setConnectionStatus('disconnected'); + console.log('✅ 断开完成,通知父组件'); + setTimeout(() => { + onCloseReady?.(); + }, 100); + } else { + // 非主动关闭 + setConnectionStatus('disconnected'); + if (!event.wasClean) { + let errorMsg = ''; + let tips = ''; + + // 根据关闭代码提供更有针对性的提示 + switch (event.code) { + case 1006: + errorMsg = '连接意外中断'; + tips = '可能是网络波动或服务器重启,请尝试重新连接'; + break; + case 1008: + errorMsg = '连接被拒绝'; + tips = '服务器拒绝了您的连接请求,请检查权限配置'; + break; + case 1011: + errorMsg = '服务器遇到错误'; + tips = '后端服务出现异常,请联系管理员'; + break; + case 4001: + errorMsg = '认证失败'; + tips = '登录凭证已过期,请重新登录'; + break; + case 4004: + errorMsg = '服务器未找到'; + tips = '目标服务器信息不存在或已被删除'; + break; + default: + errorMsg = event.reason || '连接已断开'; + tips = event.code ? `错误代码: ${event.code}` : '请检查网络连接后重试'; + } + + setErrorMessage(errorMsg); + terminalInstanceRef.current?.writeln(`\r\n\x1b[31m✗ ${errorMsg}\x1b[0m`); + if (tips) { + terminalInstanceRef.current?.writeln(`\x1b[33m💡 ${tips}\x1b[0m`); + } + } + } + }; + }; + + // 优雅关闭连接 + const gracefulClose = useCallback(() => { + console.log('🚪 开始优雅关闭 SSH连接'); + isClosingRef.current = true; + setConnectionStatus('disconnecting'); + terminalInstanceRef.current?.writeln('\r\n\x1b[33m正在断开连接...\x1b[0m'); + + if (wsRef.current) { + if (wsRef.current.readyState === WebSocket.OPEN) { + wsRef.current.close(1000, 'User closed'); + } else { + // 连接已经断开,直接通知父组件 + wsRef.current = null; + setTimeout(() => { + onCloseReady?.(); + }, 100); + } + } else { + // 没有WebSocket连接,直接通知父组件 + setTimeout(() => { + onCloseReady?.(); + }, 100); + } + }, [onCloseReady]); + + const handleReconnect = () => { + if (wsRef.current) { + wsRef.current.close(); + wsRef.current = null; + } + if (terminalInstanceRef.current) { + terminalInstanceRef.current.clear(); + } + connectWebSocket(); + }; + + // 快捷操作:清屏 + const handleClear = () => { + terminalInstanceRef.current?.clear(); + message.success('终端已清屏'); + }; + + // 快捷操作:复制选中文本 + const handleCopy = async () => { + const selection = terminalInstanceRef.current?.getSelection(); + if (selection) { + await navigator.clipboard.writeText(selection); + message.success('已复制到剪贴板'); + } else { + message.warning('请先选中要复制的文本'); + } + }; + + // 快捷操作:缩小字体 + const handleZoomOut = () => { + const newSize = Math.max(fontSize - 2, 10); + setFontSize(newSize); + if (terminalInstanceRef.current) { + terminalInstanceRef.current.options.fontSize = newSize; + fitAddonRef.current?.fit(); + } + }; + + // 快捷操作:放大字体 + const handleZoomIn = () => { + const newSize = Math.min(fontSize + 2, 24); + setFontSize(newSize); + if (terminalInstanceRef.current) { + terminalInstanceRef.current.options.fontSize = newSize; + fitAddonRef.current?.fit(); + } + }; + + // 搜索功能:切换显示 + const toggleSearch = () => { + setShowSearch(prev => !prev); + if (!showSearch) { + // 打开搜索框时聚焦 + setTimeout(() => { + document.getElementById('ssh-search-input')?.focus(); + }, 100); + } else { + // 关闭搜索框时清除高亮 + setSearchKeyword(''); + } + }; + + // 搜索功能:查找下一个 + const handleSearchNext = () => { + if (searchKeyword && searchAddonRef.current) { + const found = searchAddonRef.current.findNext(searchKeyword, { + caseSensitive: false, + wholeWord: false, + regex: false, + }); + if (!found) { + message.info('已到达最后一个结果'); + } + } + }; + + // 搜索功能:查找上一个 + const handleSearchPrev = () => { + if (searchKeyword && searchAddonRef.current) { + const found = searchAddonRef.current.findPrevious(searchKeyword, { + caseSensitive: false, + wholeWord: false, + regex: false, + }); + if (!found) { + message.info('已到达第一个结果'); + } + } + }; + + // 搜索功能:处理搜索输入 + const handleSearchChange = (value: string) => { + setSearchKeyword(value); + if (value && searchAddonRef.current) { + searchAddonRef.current.findNext(value, { + caseSensitive: false, + wholeWord: false, + regex: false, + }); + } + }; + + // 搜索功能:处理回车键 + const handleSearchKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + if (e.shiftKey) { + handleSearchPrev(); + } else { + handleSearchNext(); + } + } else if (e.key === 'Escape') { + toggleSearch(); + } + }; + + // 暴露关闭方法给父组件 + useEffect(() => { + (window as any)[`__closeSSH_${windowId}`] = gracefulClose; + return () => { + delete (window as any)[`__closeSSH_${windowId}`]; + }; + }, [windowId, gracefulClose]); + + useEffect(() => { + const timer = setTimeout(() => { + initializeTerminalAndConnect(); + }, 50); + + // 监听窗口大小变化(ResizeObserver) + let resizeObserver: ResizeObserver | null = null; + if (terminalRef.current) { + resizeObserver = new ResizeObserver(() => { + fitAddonRef.current?.fit(); + }); + resizeObserver.observe(terminalRef.current); + } + + // 监听全局resize事件(用于窗口恢复时触发) + const handleWindowResize = () => { + fitAddonRef.current?.fit(); + }; + window.addEventListener('resize', handleWindowResize); + + return () => { + clearTimeout(timer); + window.removeEventListener('resize', handleWindowResize); + if (resizeObserver && terminalRef.current) { + resizeObserver.unobserve(terminalRef.current); + } + if (wsRef.current) { + wsRef.current.close(1000, 'Component unmounted'); + } + if (terminalInstanceRef.current) { + terminalInstanceRef.current.dispose(); + } + }; + }, [initializeTerminalAndConnect]); + + const getStatusBadge = () => { + switch (connectionStatus) { + case 'initializing': + return 初始化中; + case 'connecting': + return 连接中; + case 'connected': + return
已连接; + case 'disconnecting': + return 断开中; + case 'error': + return 连接失败; + case 'disconnected': + return 已断开; + } + }; + + return ( + <> + + +
+ {/* 工具栏 - 快捷操作 */} +
+ {/* 左侧:状态指示器 */} +
+ {getStatusBadge()} + + 字体 {fontSize}px + +
+ + {/* 右侧:所有操作按钮 */} +
+ + + +
+ + + {(connectionStatus === 'disconnected' || connectionStatus === 'error') && ( + <> +
+ + + )} +
+
+ + {/* 终端区域 */} +
+ {/* 搜索栏 */} + {showSearch && ( +
+ + handleSearchChange(e.target.value)} + onKeyDown={handleSearchKeyDown} + className="h-7 w-48 bg-gray-800 border-gray-700 text-white placeholder:text-gray-500 focus:border-blue-500" + /> +
+ + +
+ +
+ )} + +
+ + {connectionStatus === 'initializing' && ( +
+ +

初始化SSH终端...

+
+ )} + + {connectionStatus === 'disconnecting' && ( +
+ +

正在断开连接...

+
+ )} + + {connectionStatus === 'error' && errorMessage && ( +
+ +

连接失败

+

{errorMessage}

+ +
+ )} +
+
+ + ); +}; diff --git a/frontend/src/pages/Resource/Server/List/components/SSHTerminalDialog.tsx b/frontend/src/pages/Resource/Server/List/components/SSHTerminalDialog.tsx deleted file mode 100644 index 508de09f..00000000 --- a/frontend/src/pages/Resource/Server/List/components/SSHTerminalDialog.tsx +++ /dev/null @@ -1,492 +0,0 @@ -import React, { useEffect, useRef, useState, useCallback } from 'react'; -import { Terminal } from 'xterm'; -import { FitAddon } from '@xterm/addon-fit'; -import { WebLinksAddon } from '@xterm/addon-web-links'; -import 'xterm/css/xterm.css'; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogDescription, -} from '@/components/ui/dialog'; -import { Badge } from '@/components/ui/badge'; -import { Button } from '@/components/ui/button'; -import { Loader2, XCircle } from 'lucide-react'; -import { message } from 'antd'; -import type { ServerResponse } from '../types'; - -// 添加自定义样式 -const customStyles = ` - /* 自定义滚动条样式 */ - .terminal-container .xterm-viewport::-webkit-scrollbar { - width: 8px; - } - - .terminal-container .xterm-viewport::-webkit-scrollbar-track { - background: #2d2d2d; - border-radius: 4px; - } - - .terminal-container .xterm-viewport::-webkit-scrollbar-thumb { - background: #555; - border-radius: 4px; - } - - .terminal-container .xterm-viewport::-webkit-scrollbar-thumb:hover { - background: #666; - } - - /* 可调整大小的Dialog */ - .resizable-dialog { - resize: both; - overflow: hidden; - min-width: 600px; - min-height: 400px; - max-width: 98vw; - max-height: 95vh; - } -`; - -interface SSHTerminalDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; - server: ServerResponse; -} - -type ConnectionStatus = 'initializing' | 'connecting' | 'connected' | 'disconnecting' | 'disconnected' | 'error'; - -interface WebSocketMessage { - type: 'output' | 'error' | 'status'; - data?: string; - message?: string; - status?: ConnectionStatus; -} - -export const SSHTerminalDialog: React.FC = ({ - open, - onOpenChange, - server, -}) => { - const terminalRef = useRef(null); - const terminalInstanceRef = useRef(null); - const wsRef = useRef(null); - const fitAddonRef = useRef(null); - const [connectionStatus, setConnectionStatus] = useState('initializing'); - const [errorMessage, setErrorMessage] = useState(''); - const reconnectTimerRef = useRef(null); - const isClosingRef = useRef(false); - const dialogContentRef = useRef(null); - - // 初始化终端和建立连接 - const initializeTerminalAndConnect = useCallback(() => { - console.log('🚀 准备初始化SSH终端, terminalRef.current:', terminalRef.current); - - if (!terminalRef.current) { - console.error('❌ terminalRef.current 为 null,无法初始化'); - return; - } - - console.log('✅ 开始初始化SSH终端'); - setConnectionStatus('initializing'); - setErrorMessage(''); - - // 1. 初始化终端 - const terminal = new Terminal({ - cursorBlink: true, - fontSize: 14, - fontFamily: 'Consolas, "Courier New", monospace', - theme: { - background: '#1e1e1e', - foreground: '#d4d4d4', - cursor: '#d4d4d4', - black: '#000000', - red: '#cd3131', - green: '#0dbc79', - yellow: '#e5e510', - blue: '#2472c8', - magenta: '#bc3fbc', - cyan: '#11a8cd', - white: '#e5e5e5', - brightBlack: '#666666', - brightRed: '#f14c4c', - brightGreen: '#23d18b', - brightYellow: '#f5f543', - brightBlue: '#3b8eea', - brightMagenta: '#d670d6', - brightCyan: '#29b8db', - brightWhite: '#e5e5e5', - }, - rows: 30, - cols: 100, - }); - - const fitAddon = new FitAddon(); - const webLinksAddon = new WebLinksAddon(); - - terminal.loadAddon(fitAddon); - terminal.loadAddon(webLinksAddon); - terminal.open(terminalRef.current); - - terminalInstanceRef.current = terminal; - fitAddonRef.current = fitAddon; - - // 延迟fit,确保容器已完全渲染 - setTimeout(() => { - fitAddon.fit(); - console.log('📐 终端尺寸已调整:', terminal.cols, 'x', terminal.rows); - }, 100); - - // 2. 监听终端输入 - terminal.onData((data) => { - if (wsRef.current?.readyState === WebSocket.OPEN) { - console.log('\u2328\ufe0f \u53d1\u9001\u8f93\u5165:', data.replace(/\r/g, '\\r').replace(/\n/g, '\\n')); - wsRef.current.send(JSON.stringify({ - type: 'input', - data: data, - })); - } else { - console.warn('\u26a0\ufe0f WebSocket\u672a\u8fde\u63a5\uff0c\u65e0\u6cd5\u53d1\u9001\u8f93\u5165'); - } - }); - - // 3. 延迟一点时间再连接,让loading状态显示出来 - setTimeout(() => { - connectWebSocket(); - }, 300); - }, [server.id]); - - useEffect(() => { - if (!open) return; - - // 延迟执行,确保 DOM 已经挂载 - const timer = setTimeout(() => { - initializeTerminalAndConnect(); - }, 50); - - // 窗口大小调整 - const handleResize = () => { - fitAddonRef.current?.fit(); - console.log('📐 窗口resize触发终端尺寸调整'); - }; - window.addEventListener('resize', handleResize); - - // 监听Dialog内容区域大小变化(用户拖动resize) - let resizeObserver: ResizeObserver | null = null; - if (terminalRef.current) { - resizeObserver = new ResizeObserver(() => { - fitAddonRef.current?.fit(); - console.log('📐 Dialog大小变化触发终端尺寸调整'); - }); - resizeObserver.observe(terminalRef.current); - } - - return () => { - clearTimeout(timer); - window.removeEventListener('resize', handleResize); - if (resizeObserver && terminalRef.current) { - resizeObserver.unobserve(terminalRef.current); - resizeObserver.disconnect(); - } - // 清理时直接关闭,不显示断开中状态 - if (wsRef.current) { - wsRef.current.close(1000, 'Component unmounted'); - wsRef.current = null; - } - if (terminalInstanceRef.current) { - terminalInstanceRef.current.dispose(); - terminalInstanceRef.current = null; - } - if (reconnectTimerRef.current) { - clearTimeout(reconnectTimerRef.current); - } - isClosingRef.current = false; - }; - }, [open, initializeTerminalAndConnect]); - - const connectWebSocket = () => { - if (wsRef.current?.readyState === WebSocket.OPEN) return; - - console.log('\ud83d\udd0c 开始建立WebSocket连接'); - setConnectionStatus('connecting'); - setErrorMessage(''); - - // 获取token - const token = localStorage.getItem('token'); - if (!token) { - const errorMsg = '认证失败:未登录或登录已过期'; - console.error('❌', errorMsg); - setConnectionStatus('error'); - setErrorMessage(errorMsg); - message.error(errorMsg); - return; - } - - // 获取WebSocket URL - 根据当前协议自动判断,并携带token - const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - const wsUrl = `${protocol}//${window.location.host}/api/v1/server-ssh/connect/${server.id}?token=${token}`; - - const ws = new WebSocket(wsUrl); - wsRef.current = ws; - - ws.onopen = () => { - console.log('\u2705 WebSocket\u8fde\u63a5\u5df2\u5efa\u7acb'); - // \u6ce8\u610f\uff1a\u8fd9\u91cc\u4e0d\u76f4\u63a5\u8bbe\u7f6e\u4e3aconnected\uff0c\u7b49\u5f85\u540e\u7aef\u53d1\u9001status:connected - terminalInstanceRef.current?.writeln('\x1b[32m✓ WebSocket连接已建立\x1b[0m'); - terminalInstanceRef.current?.writeln('\x1b[36m正在建立SSH会话...\x1b[0m\r\n'); - }; - - ws.onmessage = (event) => { - console.log('📥 收到WebSocket消息:', event.data.substring(0, 200)); - try { - const msg: WebSocketMessage = JSON.parse(event.data); - console.log('📦 解析后的消息:', msg.type, msg); - - switch (msg.type) { - case 'output': - if (msg.data) { - console.log('📝 输出数据长度:', msg.data.length); - terminalInstanceRef.current?.write(msg.data); - } - break; - - case 'error': - console.error('❌ SSH错误:', msg.message); - terminalInstanceRef.current?.writeln(`\r\n\x1b[31m错误: ${msg.message}\x1b[0m\r\n`); - message.error(msg.message || '连接错误'); - break; - - case 'status': - console.log('🔄 状态变更:', msg.status); - if (msg.status) { - setConnectionStatus(msg.status); - if (msg.status === 'connected') { - terminalInstanceRef.current?.writeln('\x1b[32m✓ SSH会话已建立\x1b[0m\r\n'); - // 连接成功后重新fit,确保尺寸正确 - setTimeout(() => { - fitAddonRef.current?.fit(); - console.log('📐 连接成功后重新调整终端尺寸'); - }, 100); - } else if (msg.status === 'disconnected') { - terminalInstanceRef.current?.writeln('\r\n\x1b[33m连接已断开\x1b[0m'); - } - } - break; - } - } catch (error) { - console.error('❌ 解析WebSocket消息失败:', error, '原始数据:', event.data); - } - }; - - ws.onerror = (error) => { - console.error('❌ WebSocket错误:', error); - const errorMsg = 'WebSocket连接失败,请检查后端服务'; - setConnectionStatus('error'); - setErrorMessage(errorMsg); - terminalInstanceRef.current?.writeln('\r\n\x1b[31m✗ ' + errorMsg + '\x1b[0m'); - message.error(errorMsg); - }; - - ws.onclose = (event) => { - console.log('🔌 WebSocket连接已关闭, code:', event.code, 'reason:', event.reason); - wsRef.current = null; - - if (isClosingRef.current) { - // 用户主动关闭,直接关闭弹窗 - handleConnectionClosed(); - } else { - // 非主动关闭,显示断开状态 - setConnectionStatus('disconnected'); - if (!event.wasClean) { - const errorMsg = event.reason || '连接异常断开'; - setErrorMessage(errorMsg); - terminalInstanceRef.current?.writeln('\r\n\x1b[31m✗ ' + errorMsg + '\x1b[0m'); - } else { - terminalInstanceRef.current?.writeln('\r\n\x1b[33m连接已正常关闭\x1b[0m'); - } - } - }; - }; - - const disconnectWebSocket = (showDisconnecting: boolean = false) => { - if (wsRef.current) { - console.log('🔌 主动关闭WebSocket连接'); - if (showDisconnecting) { - setConnectionStatus('disconnecting'); - terminalInstanceRef.current?.writeln('\r\n\x1b[33m正在断开连接...\x1b[0m'); - } - if (wsRef.current.readyState === WebSocket.OPEN) { - wsRef.current.close(1000, 'User closed'); - } else { - // 如果连接已经断开,直接清理 - wsRef.current = null; - if (isClosingRef.current) { - handleConnectionClosed(); - } - } - } else if (isClosingRef.current) { - // 没有WebSocket连接,直接关闭 - handleConnectionClosed(); - } - }; - - const handleConnectionClosed = () => { - console.log('✅ 连接已完全关闭,关闭弹窗'); - isClosingRef.current = false; - // 延迟一点关闭,让用户看到断开完成 - setTimeout(() => { - // 关闭弹窗并重置所有状态 - setConnectionStatus('initializing'); - setErrorMessage(''); - onOpenChange(false); - }, 100); - }; - - const handleDialogClose = (open: boolean) => { - if (!open && connectionStatus !== 'disconnected') { - // 用户点击X或ESC关闭,需要先断开连接 - console.log('🚪 用户关闭弹窗,开始断开连接'); - isClosingRef.current = true; - disconnectWebSocket(true); - } else if (!open) { - // 已经断开,直接关闭 - onOpenChange(false); - } else { - // 打开弹窗 - onOpenChange(true); - } - }; - - const handleReconnect = () => { - console.log('🔄 用户重新连接'); - isClosingRef.current = false; - disconnectWebSocket(); - if (terminalInstanceRef.current) { - terminalInstanceRef.current.clear(); - terminalInstanceRef.current.dispose(); - terminalInstanceRef.current = null; - } - // 重新初始化 - initializeTerminalAndConnect(); - }; - - const getStatusBadge = () => { - switch (connectionStatus) { - case 'initializing': - return ( - - - 初始化中 - - ); - case 'connecting': - return ( - - - 连接中 - - ); - case 'connected': - return ( - -
- 已连接 - - ); - case 'error': - return ( - - - 连接失败 - - ); - case 'disconnecting': - return ( - - - 断开中 - - ); - case 'disconnected': - return ( - - - 已断开 - - ); - } - }; - - return ( - <> - {/* 注入自定义样式 */} - - - - - -
-
- SSH终端 - - {server.serverName} ({server.hostIp}) - -
-
- {getStatusBadge()} - {(connectionStatus === 'disconnected' || connectionStatus === 'error') && ( - - )} -
-
-
-
- {/* 终端容器 - 始终渲染,确保fit能正确计算尺寸 */} -
- - {/* Loading 状态 - 覆盖在终端上方 */} - {connectionStatus === 'initializing' && ( -
- -

初始化SSH终端...

-
- )} - - {/* 断开中状态 - 覆盖在终端上方 */} - {connectionStatus === 'disconnecting' && ( -
- -

正在断开连接...

-
- )} - - {/* 错误状态 - 覆盖在终端上方 */} - {connectionStatus === 'error' && errorMessage && ( -
- -

连接失败

-

{errorMessage}

- -
- )} -
- -
- - ); -}; diff --git a/frontend/src/pages/Resource/Server/List/components/SSHWindowManager.tsx b/frontend/src/pages/Resource/Server/List/components/SSHWindowManager.tsx new file mode 100644 index 00000000..d2dd97ce --- /dev/null +++ b/frontend/src/pages/Resource/Server/List/components/SSHWindowManager.tsx @@ -0,0 +1,171 @@ +import React, { useState, useCallback } from 'react'; +import { Terminal } from 'lucide-react'; +import { DraggableWindow } from '@/components/ui/draggable-window'; +import { SSHTerminalContent } from './SSHTerminalContent'; +import type { ServerResponse } from '../types'; + +export interface SSHWindow { + id: string; + server: ServerResponse; + isMinimized: boolean; + position: { x: number; y: number }; + size: { width: number; height: number }; +} + +interface SSHWindowManagerProps { + onOpenWindow?: (windowId: string) => void; +} + +export const SSHWindowManager: React.FC = ({ onOpenWindow }) => { + const [windows, setWindows] = useState([]); + const [activeWindowId, setActiveWindowId] = useState(null); + + // 打开新窗口 + const openWindow = useCallback((server: ServerResponse) => { + const windowId = `ssh-${server.id}-${Date.now()}`; + const offset = windows.length * 30; + + const newWindow: SSHWindow = { + id: windowId, + server, + isMinimized: false, + position: { x: 100 + offset, y: 100 + offset }, + size: { width: 1200, height: 700 }, + }; + + setWindows(prev => [...prev, newWindow]); + setActiveWindowId(windowId); + onOpenWindow?.(windowId); + + console.log(`✅ 打开SSH窗口: ${server.serverName} (${windowId})`); + }, [windows.length, onOpenWindow]); + + // 真正关闭窗口(在连接断开后调用) + const actuallyCloseWindow = useCallback((windowId: string) => { + console.log(`❌ 真正关闭SSH窗口: ${windowId}`); + setWindows(prev => prev.filter(w => w.id !== windowId)); + if (activeWindowId === windowId) { + setActiveWindowId(null); + } + }, [activeWindowId]); + + // 关闭窗口 + const closeWindow = useCallback((windowId: string) => { + console.log(`🚪 准备关闭SSH窗口: ${windowId}`); + + // 调用gracefulClose方法优雅断开连接 + const closeMethod = (window as any)[`__closeSSH_${windowId}`]; + if (closeMethod && typeof closeMethod === 'function') { + console.log('✅ 调用优雅关闭方法'); + closeMethod(); + // 等待回调后再关闭窗口(通过onCloseReady) + } else { + // 如果没有找到关闭方法,直接关闭 + console.warn('⚠️ 未找到优雅关闭方法,直接关闭窗口'); + actuallyCloseWindow(windowId); + } + }, [actuallyCloseWindow]); + + // 最小化窗口 + const minimizeWindow = useCallback((windowId: string) => { + setWindows(prev => + prev.map(w => + w.id === windowId ? { ...w, isMinimized: true } : w + ) + ); + console.log(`➖ 最小化窗口: ${windowId}`); + }, []); + + // 恢复窗口 + const restoreWindow = useCallback((windowId: string) => { + setWindows(prev => + prev.map(w => + w.id === windowId ? { ...w, isMinimized: false } : w + ) + ); + setActiveWindowId(windowId); + 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); + }, []); + + // 激活窗口 + const focusWindow = useCallback((windowId: string) => { + setActiveWindowId(windowId); + }, []); + + // 暴露openWindow方法给外部使用 + React.useEffect(() => { + (window as any).__openSSHWindow = openWindow; + return () => { + delete (window as any).__openSSHWindow; + }; + }, [openWindow]); + + return ( + <> + {/* 渲染所有窗口 - 最小化时隐藏但保持渲染,SSH连接和尺寸 */} + {windows.map(window => ( +
+ closeWindow(window.id)} + onMinimize={() => minimizeWindow(window.id)} + isActive={activeWindowId === window.id} + onFocus={() => focusWindow(window.id)} + initialPosition={window.position} + initialSize={window.size} + > + actuallyCloseWindow(window.id)} + /> + +
+ ))} + + {/* 最小化的窗口悬浮按钮 */} + {windows.filter(w => w.isMinimized).length > 0 && ( +
+ {windows + .filter(w => w.isMinimized) + .map((window) => ( + + ))} +
+ )} + + ); +}; diff --git a/frontend/src/pages/Resource/Server/List/components/ServerCard.tsx b/frontend/src/pages/Resource/Server/List/components/ServerCard.tsx index dc627d9d..36d1502c 100644 --- a/frontend/src/pages/Resource/Server/List/components/ServerCard.tsx +++ b/frontend/src/pages/Resource/Server/List/components/ServerCard.tsx @@ -10,6 +10,7 @@ import { Trash2, Loader2, ChevronDown, + Terminal, } from 'lucide-react'; import { Card, CardContent } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; @@ -30,11 +31,12 @@ interface ServerCardProps { onTest: (server: ServerResponse) => void; onEdit: (server: ServerResponse) => void; onDelete: (server: ServerResponse) => void; + onSSHConnect: (server: ServerResponse) => void; isTesting?: boolean; getOsIcon: (osType?: string) => React.ReactNode; } -export const ServerCard: React.FC = ({ server, onTest, onEdit, onDelete, isTesting, getOsIcon }) => { +export const ServerCard: React.FC = ({ server, onTest, onEdit, onDelete, onSSHConnect, isTesting, getOsIcon }) => { const [expanded, setExpanded] = React.useState(false); const formatTime = (time?: string) => { @@ -155,6 +157,20 @@ export const ServerCard: React.FC = ({ server, onTest, onEdit, )}
+ + + + + SSH连接 + + ))} +
+ + ); +}; diff --git a/frontend/src/pages/Resource/Server/List/index.tsx b/frontend/src/pages/Resource/Server/List/index.tsx index 1afe0c75..7fbffc6d 100644 --- a/frontend/src/pages/Resource/Server/List/index.tsx +++ b/frontend/src/pages/Resource/Server/List/index.tsx @@ -40,6 +40,7 @@ import { CategoryManageDialog } from './components/CategoryManageDialog'; import { ServerEditDialog } from './components/ServerEditDialog'; import { ServerCard } from './components/ServerCard'; import { ServerTable } from './components/ServerTable'; +import { SSHWindowManager } from './components/SSHWindowManager'; const ServerList: React.FC = () => { const { toast } = useToast(); @@ -204,6 +205,20 @@ const ServerList: React.FC = () => { setDeleteDialogOpen(true); }; + // SSH连接 - 使用多窗口系统 + const handleSSHConnect = (server: ServerResponse) => { + // 调用全局方法打开新的SSH窗口 + if ((window as any).__openSSHWindow) { + (window as any).__openSSHWindow(server); + } else { + toast({ + title: "系统错误", + description: "SSH窗口管理器未初始化", + variant: "destructive", + }); + } + }; + const confirmDelete = async () => { if (!serverToDelete) return; await deleteServer(serverToDelete.id); @@ -513,6 +528,7 @@ const ServerList: React.FC = () => { onTest={handleTestConnection} onEdit={handleEdit} onDelete={handleDelete} + onSSHConnect={handleSSHConnect} isTesting={testingServerId === server.id} getOsIcon={getOsIcon} /> @@ -592,6 +608,9 @@ const ServerList: React.FC = () => { variant="destructive" confirmText="确定" /> + + {/* SSH多窗口管理器 */} + ); }; diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 8a803b5c..24f06d91 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -70,6 +70,7 @@ export default defineConfig(({ mode }) => { '/api': { target: proxyTarget, changeOrigin: true, + ws: true, // 支持WebSocket代理 // 打印代理信息 configure: (proxy, _options) => { proxy.on('error', (err, _req, _res) => { @@ -78,6 +79,9 @@ export default defineConfig(({ mode }) => { proxy.on('proxyReq', (proxyReq, req, _res) => { console.log('📡 代理请求:', req.method, req.url, '→', proxyTarget); }); + proxy.on('proxyReqWs', (proxyReq, req, socket, options, head) => { + console.log('🔌 WebSocket代理:', req.url, '→', proxyTarget); + }); } } },