diff --git a/frontend/package.json b/frontend/package.json index 02c3d1c1..6de02303 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -81,6 +81,7 @@ "recharts": "^2.15.0", "rsuite": "^5.83.3", "uuid": "^13.0.0", + "watermark-js-plus": "^1.6.3", "xterm": "^5.3.0", "zod": "^3.24.1" }, diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 3f68c368..ba3eac42 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -218,6 +218,9 @@ importers: uuid: specifier: ^13.0.0 version: 13.0.0 + watermark-js-plus: + specifier: ^1.6.3 + version: 1.6.3 xterm: specifier: ^5.3.0 version: 5.3.0 @@ -4359,6 +4362,9 @@ packages: warning@4.0.3: resolution: {integrity: sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==} + watermark-js-plus@1.6.3: + resolution: {integrity: sha512-iCLOGf70KacIwjGF9MDViYxQcRiVwOH7l42qDHLeE2HeUsQD1EQuUC9cKRG/4SErTUmdqV3yf5WnKk2dRARHPQ==} + whatwg-fetch@2.0.4: resolution: {integrity: sha512-dcQ1GWpOD/eEQ97k66aiEVpNnapVj90/+R+SXTPYGHpYBBypfKJEQjLrvMZ7YXbKm21gXd4NcuxUTjiv1YtLng==} @@ -9010,6 +9016,8 @@ snapshots: dependencies: loose-envify: 1.4.0 + watermark-js-plus@1.6.3: {} + whatwg-fetch@2.0.4: {} which@2.0.2: diff --git a/frontend/src/pages/Resource/Server/List/components/SSHTerminalContent.tsx b/frontend/src/pages/Resource/Server/List/components/SSHTerminalContent.tsx index 4c98d818..3748545b 100644 --- a/frontend/src/pages/Resource/Server/List/components/SSHTerminalContent.tsx +++ b/frontend/src/pages/Resource/Server/List/components/SSHTerminalContent.tsx @@ -3,6 +3,7 @@ import { Terminal } from 'xterm'; import { FitAddon } from '@xterm/addon-fit'; import { WebLinksAddon } from '@xterm/addon-web-links'; import { SearchAddon } from '@xterm/addon-search'; +import { Watermark } from 'watermark-js-plus'; import 'xterm/css/xterm.css'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; @@ -30,32 +31,13 @@ const customStyles = ` .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; // 断开完成后的回调 + onStatusChange?: (status: ConnectionStatus) => void; // 状态变化回调 } type ConnectionStatus = 'initializing' | 'connecting' | 'connected' | 'disconnecting' | 'disconnected' | 'error'; @@ -71,82 +53,111 @@ export const SSHTerminalContent: React.FC = ({ server, windowId, onCloseReady, + onStatusChange, }) => { const terminalRef = useRef(null); const terminalInstanceRef = useRef(null); const wsRef = useRef(null); const fitAddonRef = useRef(null); + const terminalContainerRef = useRef(null); + const watermarkRef = useRef(null); + const searchAddonRef = useRef(null); + const onStatusChangeRef = useRef(onStatusChange); + const isClosingRef = useRef(false); + 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]); - - // 更新时间 + + // 更新ref 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); - }, []); + onStatusChangeRef.current = onStatusChange; + }, [onStatusChange]); + + // 状态变化时通知父组件 + useEffect(() => { + onStatusChangeRef.current?.(connectionStatus); + }, [connectionStatus]); - // 生成水印元素 - 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} -
- ); + // 初始化水印 + useEffect(() => { + if (!terminalContainerRef.current) return; + + // 从localStorage获取用户信息 + const userInfoStr = localStorage.getItem('userInfo'); + let username = '用户'; + if (userInfoStr) { + try { + const userInfo = JSON.parse(userInfoStr); + username = userInfo.nickname || userInfo.username || '用户'; + } catch (e) { + console.error('解析用户信息失败:', e); } } + + const now = new Date().toLocaleString('zh-CN', { + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }); - return items; - }; + // 获取IP地址 + const serverIP = server.hostIp || ''; + + watermarkRef.current = new Watermark({ + content: `${username} · ${server.serverName}(${serverIP}) · ${now}`, + width: 350, + height: 200, + rotate: -20, + fontSize: '12px', // 减小字体 + fontColor: 'rgba(255, 255, 255, 0.15)', + fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif', + zIndex: 1, + parent: terminalContainerRef.current, + globalAlpha: 0.5, + }); + + watermarkRef.current.create(); + + return () => { + watermarkRef.current?.destroy(); + }; + }, [server.serverName, server.hostIp]); // 初始化终端和建立连接 - const initializeTerminalAndConnect = useCallback(() => { + const initializeTerminalAndConnect = () => { if (!terminalRef.current) { console.error('❌ terminalRef.current 为 null,无法初始化'); return; } console.log('✅ 开始初始化SSH终端:', server.serverName); + + // 清理旧的Terminal实例 + if (terminalInstanceRef.current) { + console.log('🧹 清理旧的Terminal实例'); + try { + terminalInstanceRef.current.dispose(); + terminalInstanceRef.current = null; + } catch (e) { + console.error('清理Terminal实例失败:', e); + } + } + + // 关闭旧的WebSocket连接 + if (wsRef.current) { + console.log('🧹 关闭旧的WebSocket连接'); + try { + wsRef.current.close(1000, 'Reinitializing'); + wsRef.current = null; + } catch (e) { + console.error('关闭WebSocket失败:', e); + } + } + setConnectionStatus('initializing'); setErrorMessage(''); @@ -211,7 +222,7 @@ export const SSHTerminalContent: React.FC = ({ setTimeout(() => { connectWebSocket(); }, 300); - }, [server.id]); + }; const connectWebSocket = () => { if (wsRef.current?.readyState === WebSocket.OPEN) return; @@ -241,27 +252,38 @@ export const SSHTerminalContent: React.FC = ({ wsRef.current = ws; ws.onopen = () => { + console.log('🔗 WebSocket已连接:', wsUrl); + console.log('📺 Terminal实例存在:', !!terminalInstanceRef.current); 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); try { const msg: WebSocketMessage = JSON.parse(event.data); + console.log('📦 解析后的消息:', msg); switch (msg.type) { case 'output': - if (msg.data) { - terminalInstanceRef.current?.write(msg.data); + console.log('📝 输出数据:', msg.data?.substring(0, 100)); + console.log('📺 Terminal实例:', !!terminalInstanceRef.current); + if (msg.data && terminalInstanceRef.current) { + terminalInstanceRef.current.write(msg.data); + console.log('✅ 数据已写入终端'); + } else { + console.warn('⚠️ 无法写入终端:', { hasData: !!msg.data, hasTerminal: !!terminalInstanceRef.current }); } 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') { @@ -274,7 +296,7 @@ export const SSHTerminalContent: React.FC = ({ break; } } catch (error) { - console.error('❌ 解析WebSocket消息失败:', error); + console.error('❌ 解析WebSocket消息失败:', error, 'Raw data:', event.data); } }; @@ -494,12 +516,22 @@ export const SSHTerminalContent: React.FC = ({ }; }, [windowId, gracefulClose]); + // 组件首次挂载时初始化终端 useEffect(() => { + console.log('📦 组件挂载,开始初始化...'); const timer = setTimeout(() => { initializeTerminalAndConnect(); }, 50); - // 监听窗口大小变化(ResizeObserver) + return () => { + console.log('🧹 组件卸载,清理资源...'); + clearTimeout(timer); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); // 空数组,只在首次挂载时运行 + + // 监听窗口大小变化 + useEffect(() => { let resizeObserver: ResizeObserver | null = null; if (terminalRef.current) { resizeObserver = new ResizeObserver(() => { @@ -508,26 +540,33 @@ export const SSHTerminalContent: React.FC = ({ 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); } + }; + }, []); + + // 组件卸载时清理资源 + useEffect(() => { + return () => { + console.log('🧹 最终清理: 关闭WebSocket和Terminal'); if (wsRef.current) { wsRef.current.close(1000, 'Component unmounted'); + wsRef.current = null; } if (terminalInstanceRef.current) { terminalInstanceRef.current.dispose(); + terminalInstanceRef.current = null; } }; - }, [initializeTerminalAndConnect]); + }, []); const getStatusBadge = () => { switch (connectionStatus) { @@ -627,7 +666,7 @@ export const SSHTerminalContent: React.FC = ({ {/* 终端区域 */} -
+
{/* 搜索栏 */} {showSearch && (
diff --git a/frontend/src/pages/Resource/Server/List/components/SSHWindowManager.tsx b/frontend/src/pages/Resource/Server/List/components/SSHWindowManager.tsx index d2dd97ce..391cb909 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 @@ export interface SSHWindow { isMinimized: boolean; position: { x: number; y: number }; size: { width: number; height: number }; + connectionStatus?: 'initializing' | 'connecting' | 'connected' | 'disconnecting' | 'disconnected' | 'error'; } interface SSHWindowManagerProps { @@ -31,6 +32,7 @@ export const SSHWindowManager: React.FC = ({ onOpenWindow isMinimized: false, position: { x: 100 + offset, y: 100 + offset }, size: { width: 1200, height: 700 }, + connectionStatus: 'initializing', }; setWindows(prev => [...prev, newWindow]); @@ -103,6 +105,33 @@ export const SSHWindowManager: React.FC = ({ onOpenWindow setActiveWindowId(windowId); }, []); + // 更新窗口连接状态 + const updateWindowStatus = useCallback((windowId: string, status: SSHWindow['connectionStatus']) => { + setWindows(prev => + prev.map(w => + w.id === windowId ? { ...w, connectionStatus: status } : w + ) + ); + }, []); + + // 获取状态对应的按钮样式 + const getButtonStyle = (status?: SSHWindow['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'; + case 'connecting': + case 'initializing': + return 'bg-yellow-600 hover:bg-yellow-700'; + case 'disconnecting': + return 'bg-orange-600 hover:bg-orange-700'; + default: + return 'bg-blue-600 hover:bg-blue-700'; + } + }; + // 暴露openWindow方法给外部使用 React.useEffect(() => { (window as any).__openSSHWindow = openWindow; @@ -136,6 +165,7 @@ export const SSHWindowManager: React.FC = ({ onOpenWindow server={window.server} windowId={window.id} onCloseReady={() => actuallyCloseWindow(window.id)} + onStatusChange={(status) => updateWindowStatus(window.id, status)} />
@@ -149,10 +179,19 @@ export const SSHWindowManager: React.FC = ({ onOpenWindow .map((window) => (