From 980c827febb177e96174373556e9f8a953b36f9f Mon Sep 17 00:00:00 2001 From: dengqichen Date: Sat, 6 Dec 2025 18:53:40 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E5=86=99ssh=E5=89=8D=E7=AB=AF?= =?UTF-8?q?=E7=BB=84=E4=BB=B6=EF=BC=8C=E9=80=9A=E7=94=A8=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/components/Terminal/Terminal.tsx | 252 ++++++ .../components/Terminal/TerminalToolbar.tsx | 139 +++ .../Terminal/TerminalWindowManager.tsx | 253 ++++++ .../src/components/Terminal/index.module.less | 185 ++++ frontend/src/components/Terminal/index.ts | 20 + frontend/src/components/Terminal/types.ts | 134 +++ .../src/components/Terminal/useTerminal.ts | 226 +++++ .../List/components/SSHTerminalContent.tsx | 792 ------------------ .../List/components/SSHWindowManager.tsx | 281 ++----- .../Server/List/components/WindowManager.tsx | 118 --- 10 files changed, 1281 insertions(+), 1119 deletions(-) create mode 100644 frontend/src/components/Terminal/Terminal.tsx create mode 100644 frontend/src/components/Terminal/TerminalToolbar.tsx create mode 100644 frontend/src/components/Terminal/TerminalWindowManager.tsx create mode 100644 frontend/src/components/Terminal/index.module.less create mode 100644 frontend/src/components/Terminal/index.ts create mode 100644 frontend/src/components/Terminal/types.ts create mode 100644 frontend/src/components/Terminal/useTerminal.ts delete mode 100644 frontend/src/pages/Resource/Server/List/components/SSHTerminalContent.tsx delete mode 100644 frontend/src/pages/Resource/Server/List/components/WindowManager.tsx diff --git a/frontend/src/components/Terminal/Terminal.tsx b/frontend/src/components/Terminal/Terminal.tsx new file mode 100644 index 00000000..5dea3c70 --- /dev/null +++ b/frontend/src/components/Terminal/Terminal.tsx @@ -0,0 +1,252 @@ +/** + * Terminal 组件 - 通用终端 + * 支持 SSH、K8s Pod、Docker Container 等多种场景 + */ +import React, { useEffect, useCallback, useState } from 'react'; +import 'xterm/css/xterm.css'; +import styles from './index.module.less'; +import { useTerminal } from './useTerminal'; +import { TerminalToolbar } from './TerminalToolbar'; +import type { TerminalProps, TerminalToolbarConfig } from './types'; +import { Loader2, XCircle } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { message } from 'antd'; + +export const Terminal: React.FC = ({ + id, + connection, + display, + audit, + toolbar, + onStatusChange, + onCloseReady, + onError, +}) => { + const [fontSize, setFontSize] = useState(display?.fontSize ?? 14); + const [showSearch, setShowSearch] = useState(false); + + const { + terminalRef, + terminalInstance, + fitAddon, + searchAddon, + connectionStatus, + errorMessage, + initializeTerminal, + connectWebSocket, + sendInput, + sendResize, + cleanup, + } = useTerminal({ + connection, + display: { ...display, fontSize }, + onStatusChange, + onError, + }); + + // 默认工具栏配置 + const toolbarConfig: TerminalToolbarConfig = { + show: toolbar?.show ?? true, + showSearch: toolbar?.showSearch ?? true, + showClear: toolbar?.showClear ?? true, + showCopy: toolbar?.showCopy ?? true, + showFontSize: toolbar?.showFontSize ?? true, + showStatus: toolbar?.showStatus ?? true, + showFontSizeLabel: toolbar?.showFontSizeLabel ?? true, + extraActions: toolbar?.extraActions, + }; + + // 初始化 + useEffect(() => { + const terminal = initializeTerminal(); + if (!terminal) return; + + // 监听终端输入 + terminal.onData((data) => { + sendInput(data); + }); + + // 监听终端尺寸变化 + terminal.onResize((size) => { + sendResize(size.rows, size.cols); + }); + + // 延迟连接 + const timer = setTimeout(() => { + connectWebSocket(); + }, 300); + + return () => { + clearTimeout(timer); + cleanup(); + }; + }, [id]); + + // 显示审计警告 + useEffect(() => { + if (connectionStatus === 'connected' && audit?.enabled && terminalInstance) { + const companyName = audit.companyName || ''; + const customMessage = audit.message; + + if (customMessage) { + terminalInstance.writeln(`\r\n\x1b[33m${customMessage}\x1b[0m\r\n`); + } else { + terminalInstance.writeln('\r\n\x1b[33m┌─────────────────────────────────────────────────────────────\x1b[0m'); + terminalInstance.writeln(`\x1b[33m│ ⚠️ ${companyName} - 安全提示\x1b[0m`); + terminalInstance.writeln('\x1b[33m│ 本次会话将被全程审计记录\x1b[0m'); + terminalInstance.writeln('\x1b[33m│ • 所有操作命令、输入、输出都将被完整记录\x1b[0m'); + terminalInstance.writeln('\x1b[33m│ • 审计日志用于安全审查、故障排查和合规要求\x1b[0m'); + terminalInstance.writeln('\x1b[33m│ • 请规范操作,遵守企业信息安全管理制度\x1b[0m'); + terminalInstance.writeln('\x1b[33m└─────────────────────────────────────────────────────────────\x1b[0m\r\n'); + } + + setTimeout(() => { + fitAddon?.fit(); + }, 100); + } + }, [connectionStatus, audit, terminalInstance, fitAddon]); + + // 重连处理 + const handleReconnect = useCallback(() => { + cleanup(); + setTimeout(() => { + initializeTerminal(); + connectWebSocket(); + }, 100); + }, [cleanup, initializeTerminal, connectWebSocket]); + + // 搜索 + const [searchQuery, setSearchQuery] = useState(''); + + const handleSearch = useCallback(() => { + setShowSearch(!showSearch); + if (!showSearch) { + // 打开搜索时聚焦输入框 + setTimeout(() => { + const input = document.querySelector('.terminal-search-input') as HTMLInputElement; + input?.focus(); + }, 100); + } + }, [showSearch]); + + const handleSearchChange = useCallback((value: string) => { + setSearchQuery(value); + if (searchAddon && terminalInstance) { + searchAddon.findNext(value, { incremental: true }); + } + }, [searchAddon, terminalInstance]); + + const handleSearchNext = useCallback(() => { + if (searchAddon && searchQuery) { + searchAddon.findNext(searchQuery); + } + }, [searchAddon, searchQuery]); + + const handleSearchPrev = useCallback(() => { + if (searchAddon && searchQuery) { + searchAddon.findPrevious(searchQuery); + } + }, [searchAddon, searchQuery]); + + const handleCloseSearch = useCallback(() => { + setShowSearch(false); + setSearchQuery(''); + searchAddon?.clearDecorations(); + }, [searchAddon]); + + // 清屏 + const handleClear = useCallback(() => { + if (terminalInstance) { + terminalInstance.clear(); + message.success('终端已清屏'); + } + }, [terminalInstance]); + + // 复制 + const handleCopy = useCallback(() => { + if (terminalInstance) { + const selection = terminalInstance.getSelection(); + if (selection) { + navigator.clipboard.writeText(selection); + message.success('已复制到剪贴板'); + } else { + message.warning('请先选中要复制的内容'); + } + } + }, [terminalInstance]); + + // 放大字体 + const handleZoomIn = useCallback(() => { + const newSize = Math.min(fontSize + 2, 32); + setFontSize(newSize); + if (terminalInstance) { + terminalInstance.options.fontSize = newSize; + setTimeout(() => fitAddon?.fit(), 50); + } + }, [fontSize, terminalInstance, fitAddon]); + + // 缩小字体 + const handleZoomOut = useCallback(() => { + const newSize = Math.max(fontSize - 2, 10); + setFontSize(newSize); + if (terminalInstance) { + terminalInstance.options.fontSize = newSize; + setTimeout(() => fitAddon?.fit(), 50); + } + }, [fontSize, terminalInstance, fitAddon]); + + return ( +
+ {/* 工具栏 */} + + + {/* 终端区域 */} +
+
+ + {/* 连接中 */} + {connectionStatus === 'connecting' && ( +
+ +

正在连接...

+
+ )} + + {/* 重连中 */} + {connectionStatus === 'reconnecting' && ( +
+ +

连接中断,正在重连...

+
+ )} + + {/* 错误 */} + {connectionStatus === 'error' && ( +
+ +

连接失败

+ {errorMessage && ( +

{errorMessage}

+ )} + +
+ )} +
+
+ ); +}; diff --git a/frontend/src/components/Terminal/TerminalToolbar.tsx b/frontend/src/components/Terminal/TerminalToolbar.tsx new file mode 100644 index 00000000..01be479b --- /dev/null +++ b/frontend/src/components/Terminal/TerminalToolbar.tsx @@ -0,0 +1,139 @@ +/** + * Terminal 工具栏组件 + */ +import React from 'react'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Search, Trash2, Copy, ZoomIn, ZoomOut, RotateCcw, Loader2, XCircle } from 'lucide-react'; +import type { ConnectionStatus, TerminalToolbarConfig } from './types'; +import styles from './index.module.less'; + +interface TerminalToolbarProps { + config: TerminalToolbarConfig; + connectionStatus: ConnectionStatus; + fontSize: number; + onSearch?: () => void; + onClear?: () => void; + onCopy?: () => void; + onZoomIn?: () => void; + onZoomOut?: () => void; + onReconnect?: () => void; +} + +export const TerminalToolbar: React.FC = ({ + config, + connectionStatus, + fontSize, + onSearch, + onClear, + onCopy, + onZoomIn, + onZoomOut, + onReconnect, +}) => { + if (!config.show) return null; + + const getStatusBadge = () => { + switch (connectionStatus) { + case 'connecting': + return 连接中; + case 'connected': + return
已连接; + case 'reconnecting': + return 重连中; + case 'error': + return 连接失败; + case 'disconnected': + return 已断开; + } + }; + + return ( +
+ {/* 左侧:状态指示器 */} +
+ {config.showStatus && getStatusBadge()} + {config.showFontSizeLabel && ( + + 字体 {fontSize}px + + )} +
+ + {/* 右侧:所有操作按钮 */} +
+ {config.showSearch && ( + + )} + {config.showClear && ( + + )} + {config.showCopy && ( + + )} + {config.showFontSize && ( + <> +
+ + + + )} + {(connectionStatus === 'disconnected' || connectionStatus === 'error') && ( + <> +
+ + + )} + {config.extraActions} +
+
+ ); +}; diff --git a/frontend/src/components/Terminal/TerminalWindowManager.tsx b/frontend/src/components/Terminal/TerminalWindowManager.tsx new file mode 100644 index 00000000..5983ab2c --- /dev/null +++ b/frontend/src/components/Terminal/TerminalWindowManager.tsx @@ -0,0 +1,253 @@ +/** + * Terminal 窗口管理器 - 通用组件 + * 管理多个终端窗口:拖拽、最小化、层叠等 + * 支持 SSH、K8s Pod、Docker Container 等多种终端类型 + */ +import React, { useState, useCallback } from 'react'; +import { Terminal } from 'lucide-react'; +import { DraggableWindow } from '@/components/ui/draggable-window'; +import type { ConnectionStatus, TerminalType } from './types'; + +/** + * 终端窗口接口(泛型) + */ +export interface TerminalWindow { + id: string; + resource: TResource; + isMinimized: boolean; + position: { x: number; y: number }; + size: { width: number; height: number }; + connectionStatus?: ConnectionStatus; +} + +/** + * 窗口管理器 Props + */ +interface TerminalWindowManagerProps { + /** 终端类型 */ + type: TerminalType; + /** 获取窗口标题 */ + getWindowTitle: (resource: TResource) => string; + /** 获取窗口副标题 */ + getWindowSubtitle: (resource: TResource) => string; + /** 获取资源ID */ + getResourceId: (resource: TResource) => string | number; + /** 渲染终端内容 */ + renderTerminal: (windowId: string, resource: TResource, callbacks: { + onCloseReady: () => void; + onStatusChange: (status: ConnectionStatus) => void; + }) => React.ReactNode; + /** 窗口打开回调 */ + onOpenWindow?: (windowId: string) => void; +} + +export function TerminalWindowManager({ + type, + getWindowTitle, + getWindowSubtitle, + getResourceId, + renderTerminal, + onOpenWindow, +}: TerminalWindowManagerProps) { + const [windows, setWindows] = useState[]>([]); + const [activeWindowId, setActiveWindowId] = useState(null); + + // 打开新窗口 + const openWindow = useCallback((resource: TResource) => { + const resourceId = getResourceId(resource); + const windowId = `${type}-${resourceId}-${Date.now()}`; + const offset = windows.length * 30; + + const newWindow: TerminalWindow = { + id: windowId, + resource, + isMinimized: false, + position: { x: 100 + offset, y: 100 + offset }, + size: { width: 1200, height: 700 }, + connectionStatus: 'connecting', + }; + + setWindows(prev => [...prev, newWindow]); + setActiveWindowId(windowId); + onOpenWindow?.(windowId); + + console.log(`✅ 打开${type.toUpperCase()}窗口: ${getWindowTitle(resource)} (${windowId})`); + }, [windows.length, type, getWindowTitle, getResourceId, onOpenWindow]); + + // 真正关闭窗口 + const actuallyCloseWindow = useCallback((windowId: string) => { + console.log(`❌ 真正关闭窗口: ${windowId}`); + setWindows(prev => prev.filter(w => w.id !== windowId)); + if (activeWindowId === windowId) { + setActiveWindowId(null); + } + }, [activeWindowId]); + + // 关闭窗口(优雅关闭) + const closeWindow = useCallback((windowId: string) => { + console.log(`🚪 准备关闭窗口: ${windowId}`); + + // 调用优雅关闭方法 + const closeMethod = (window as any)[`__closeSSH_${windowId}`]; + if (closeMethod && typeof closeMethod === 'function') { + console.log('✅ 调用优雅关闭方法'); + closeMethod(); + } 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); + }, []); + + // 更新窗口连接状态 + const updateWindowStatus = useCallback((windowId: string, status: ConnectionStatus) => { + setWindows(prev => + prev.map(w => + w.id === windowId ? { ...w, connectionStatus: status } : w + ) + ); + }, []); + + // 获取状态对应的按钮样式 + 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'; + case 'connecting': + return 'bg-yellow-600 hover:bg-yellow-700'; + case 'reconnecting': + return 'bg-orange-600 hover:bg-orange-700'; + default: + return 'bg-blue-600 hover:bg-blue-700'; + } + }; + + // 暴露 openWindow 方法给外部使用 + React.useEffect(() => { + // 处理缩写词:ssh -> SSH, k8s -> K8s 等 + const typeMap: Record = { + 'ssh': 'SSH', + 'k8s-pod': 'K8sPod', + 'docker-container': 'DockerContainer', + }; + const typeName = typeMap[type] || type.charAt(0).toUpperCase() + type.slice(1); + const globalKey = `__open${typeName}Window`; + (window as any)[globalKey] = openWindow; + + console.log(`✅ 注册全局方法: ${globalKey}`); + + return () => { + delete (window as any)[globalKey]; + }; + }, [openWindow, type]); + + return ( + <> + {/* 渲染所有窗口 */} + {windows.map(win => ( +
+ closeWindow(win.id)} + onMinimize={() => minimizeWindow(win.id)} + isActive={activeWindowId === win.id} + onFocus={() => focusWindow(win.id)} + initialPosition={win.position} + initialSize={win.size} + > + {renderTerminal(win.id, win.resource, { + onCloseReady: () => actuallyCloseWindow(win.id), + onStatusChange: (status) => updateWindowStatus(win.id, status), + })} + +
+ ))} + + {/* 最小化的窗口悬浮按钮 */} + {windows.filter(w => w.isMinimized).length > 0 && ( +
+ {windows + .filter(w => w.isMinimized) + .map((win) => { + // 计算同一资源的窗口数量 + const resourceId = getResourceId(win.resource); + const sameResourceWindows = windows.filter(w => getResourceId(w.resource) === resourceId); + const needsIndex = sameResourceWindows.length > 1; + const windowIndex = sameResourceWindows.findIndex(w => w.id === win.id) + 1; + + return ( + + ); + })} +
+ )} + + ); +} diff --git a/frontend/src/components/Terminal/index.module.less b/frontend/src/components/Terminal/index.module.less new file mode 100644 index 00000000..bb53bcaa --- /dev/null +++ b/frontend/src/components/Terminal/index.module.less @@ -0,0 +1,185 @@ +/* Terminal 组件样式 - 通用终端样式 */ + +.terminalWrapper { + display: flex; + flex-direction: column; + height: 100%; + background-color: #0a0a0a; +} + +.toolbar { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.5rem 1rem; + background: #f9fafb; + border-bottom: 1px solid #e5e7eb; + min-height: 48px; +} + +:global(.dark) .toolbar { + background: #1f2937; + border-bottom: 1px solid #374151; +} + +.left { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.right { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.container { + flex: 1; + background-color: #1e1e1e; + position: relative; + overflow: hidden; +} + +.content { + width: 100%; + height: 100%; + padding: 0.5rem; +} + +/* xterm.js 滚动条样式优化 */ +.content :global(.xterm) { + height: 100%; + padding: 0; +} + +.content :global(.xterm-screen) { + padding: 0; +} + +.content :global(.xterm-viewport) { + scrollbar-width: thin; + scrollbar-color: rgba(255, 255, 255, 0.3) transparent; +} + +.content :global(.xterm-viewport::-webkit-scrollbar) { + width: 10px; +} + +.content :global(.xterm-viewport::-webkit-scrollbar-track) { + background: transparent; +} + +.content :global(.xterm-viewport::-webkit-scrollbar-thumb) { + background-color: rgba(255, 255, 255, 0.3); + border-radius: 5px; + border: 2px solid transparent; + background-clip: padding-box; +} + +.content :global(.xterm-viewport::-webkit-scrollbar-thumb:hover) { + background-color: rgba(255, 255, 255, 0.5); +} + +.statusOverlay { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + background-color: rgba(30, 30, 30, 0.95); + backdrop-filter: blur(4px); + z-index: 10; +} + +.connecting { + color: #facc15; +} + +.reconnecting { + color: #fb923c; +} + +.error { + color: #ef4444; +} + +.statusIcon { + width: 3rem; + height: 3rem; + margin-bottom: 1rem; +} + +.statusText { + font-size: 1.125rem; + line-height: 1.75rem; + margin-bottom: 0.5rem; +} + +.statusDetail { + font-size: 0.875rem; + color: #9ca3af; + margin-bottom: 1rem; +} + +.searchBar { + position: absolute; + top: 0.5rem; + right: 0.5rem; + z-index: 20; + display: flex; + align-items: center; + gap: 0.25rem; + background-color: rgba(17, 24, 39, 0.95); + border: 1px solid #374151; + border-radius: 0.5rem; + padding: 0.5rem 0.75rem; + box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1); +} + +.searchInput { + background: transparent; + border: none; + outline: none; + color: #fff; + width: 200px; + font-size: 0.875rem; +} + +.searchInput::placeholder { + color: #9ca3af; +} + +.searchButton { + padding: 0.25rem; + cursor: pointer; + color: #9ca3af; + transition: color 0.2s; +} + +.searchButton:hover { + color: #fff; +} + +/* 响应式适配 */ +@media (max-width: 768px) { + .toolbar { + padding: 0.5rem; + flex-wrap: wrap; + gap: 0.5rem; + } + + .content { + padding: 0.25rem; + } + + .searchBar { + top: 0.25rem; + right: 0.25rem; + } + + .searchInput { + width: 150px; + } +} diff --git a/frontend/src/components/Terminal/index.ts b/frontend/src/components/Terminal/index.ts new file mode 100644 index 00000000..768f4576 --- /dev/null +++ b/frontend/src/components/Terminal/index.ts @@ -0,0 +1,20 @@ +/** + * Terminal 组件入口 + * 通用终端组件,支持 SSH、K8s Pod、Docker Container 等多种场景 + */ + +export { Terminal } from './Terminal'; +export { useTerminal } from './useTerminal'; +export { TerminalWindowManager } from './TerminalWindowManager'; +export type { TerminalWindow } from './TerminalWindowManager'; +export type { + TerminalType, + ConnectionStatus, + TerminalReceiveMessage, + TerminalSendMessage, + TerminalConnectionConfig, + TerminalDisplayConfig, + TerminalAuditConfig, + TerminalToolbarConfig, + TerminalProps, +} from './types'; diff --git a/frontend/src/components/Terminal/types.ts b/frontend/src/components/Terminal/types.ts new file mode 100644 index 00000000..600cc687 --- /dev/null +++ b/frontend/src/components/Terminal/types.ts @@ -0,0 +1,134 @@ +/** + * Terminal 组件类型定义 + * 支持 SSH、K8s Pod 等多种终端场景 + */ + +export type TerminalType = 'ssh' | 'k8s-pod' | 'docker-container'; + +export type ConnectionStatus = 'connecting' | 'connected' | 'reconnecting' | 'disconnected' | 'error'; + +/** + * WebSocket 消息格式 - 接收 + */ +export interface TerminalReceiveMessage { + type: 'output' | 'status' | 'error'; + data: string | { response: { type: string; data: string } }; + timestamp?: number; + metadata?: Record | null; +} + +/** + * WebSocket 消息格式 - 发送 + */ +export interface TerminalSendMessage { + type: 'input' | 'resize'; + data: { + request: { + type: 'input' | 'resize'; + command?: string; + rows?: number; + cols?: number; + }; + }; +} + +/** + * Terminal 连接配置 + */ +export interface TerminalConnectionConfig { + /** 连接类型 */ + type: TerminalType; + /** WebSocket URL */ + wsUrl: string; + /** 认证 Token */ + token?: string; + /** 连接超时时间(毫秒) */ + timeout?: number; + /** 是否自动重连 */ + autoReconnect?: boolean; + /** 重连间隔(毫秒) */ + reconnectInterval?: number; + /** 最大重连次数 */ + maxReconnectAttempts?: number; +} + +/** + * Terminal 显示配置 + */ +export interface TerminalDisplayConfig { + /** 字体大小 */ + fontSize?: number; + /** 字体系列 */ + fontFamily?: string; + /** 行数 */ + rows?: number; + /** 列数 */ + cols?: number; + /** 滚动缓冲区大小 */ + scrollback?: number; + /** 光标闪烁 */ + cursorBlink?: boolean; + /** 主题配置 */ + theme?: { + background?: string; + foreground?: string; + cursor?: string; + [key: string]: string | undefined; + }; +} + +/** + * Terminal 审计配置 + */ +export interface TerminalAuditConfig { + /** 是否启用审计 */ + enabled: boolean; + /** 审计提示信息 */ + message?: string; + /** 公司名称 */ + companyName?: string; +} + +/** + * Terminal 工具栏配置 + */ +export interface TerminalToolbarConfig { + /** 是否显示工具栏 */ + show?: boolean; + /** 是否显示搜索按钮 */ + showSearch?: boolean; + /** 是否显示清屏按钮 */ + showClear?: boolean; + /** 是否显示复制按钮 */ + showCopy?: boolean; + /** 是否显示字体缩放按钮 */ + showFontSize?: boolean; + /** 是否显示状态徽章 */ + showStatus?: boolean; + /** 是否显示字体大小 */ + showFontSizeLabel?: boolean; + /** 自定义额外操作 */ + extraActions?: React.ReactNode; +} + +/** + * Terminal 组件 Props + */ +export interface TerminalProps { + /** 唯一标识符 */ + id: string; + /** 连接配置 */ + connection: TerminalConnectionConfig; + /** 显示配置 */ + display?: TerminalDisplayConfig; + /** 审计配置 */ + audit?: TerminalAuditConfig; + /** 工具栏配置 */ + toolbar?: TerminalToolbarConfig; + /** 连接状态变化回调 */ + onStatusChange?: (status: ConnectionStatus) => void; + /** 关闭就绪回调 */ + onCloseReady?: () => void; + /** 错误回调 */ + onError?: (error: string) => void; +} diff --git a/frontend/src/components/Terminal/useTerminal.ts b/frontend/src/components/Terminal/useTerminal.ts new file mode 100644 index 00000000..6257df48 --- /dev/null +++ b/frontend/src/components/Terminal/useTerminal.ts @@ -0,0 +1,226 @@ +/** + * Terminal Hook - 终端核心逻辑 + */ +import { useEffect, useRef, useState, useCallback } from 'react'; +import { Terminal as XTerm } from 'xterm'; +import { FitAddon } from '@xterm/addon-fit'; +import { WebLinksAddon } from '@xterm/addon-web-links'; +import { SearchAddon } from '@xterm/addon-search'; +import type { + ConnectionStatus, + TerminalConnectionConfig, + TerminalDisplayConfig, + TerminalReceiveMessage, +} from './types'; + +interface UseTerminalOptions { + connection: TerminalConnectionConfig; + display?: TerminalDisplayConfig; + onStatusChange?: (status: ConnectionStatus) => void; + onError?: (error: string) => void; +} + +export const useTerminal = (options: UseTerminalOptions) => { + const { connection, display, onStatusChange, onError } = options; + + const terminalRef = useRef(null); + const terminalInstanceRef = useRef(null); + const wsRef = useRef(null); + const fitAddonRef = useRef(null); + const searchAddonRef = useRef(null); + + const [connectionStatus, setConnectionStatus] = useState('disconnected'); + const [errorMessage, setErrorMessage] = useState(''); + + // 更新状态并触发回调 + const updateStatus = useCallback((status: ConnectionStatus) => { + setConnectionStatus(status); + onStatusChange?.(status); + }, [onStatusChange]); + + // 初始化终端 + const initializeTerminal = useCallback(() => { + if (!terminalRef.current) return null; + + const terminal = new XTerm({ + cursorBlink: display?.cursorBlink ?? true, + fontSize: display?.fontSize ?? 14, + fontFamily: display?.fontFamily ?? 'Consolas, "Courier New", monospace', + theme: display?.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: display?.rows ?? 30, + cols: display?.cols ?? 100, + scrollback: display?.scrollback ?? 10000, + }); + + const fitAddon = new FitAddon(); + const webLinksAddon = new WebLinksAddon(); + const searchAddon = new SearchAddon(); + + terminal.loadAddon(fitAddon); + terminal.loadAddon(webLinksAddon); + terminal.loadAddon(searchAddon); + + terminal.open(terminalRef.current); + + terminalInstanceRef.current = terminal; + fitAddonRef.current = fitAddon; + searchAddonRef.current = searchAddon; + + setTimeout(() => { + fitAddon.fit(); + }, 100); + + return terminal; + }, [display]); + + // 连接 WebSocket + const connectWebSocket = useCallback(() => { + if (wsRef.current?.readyState === WebSocket.OPEN) return; + + updateStatus('connecting'); + setErrorMessage(''); + + const ws = new WebSocket(connection.wsUrl); + wsRef.current = ws; + + ws.onopen = () => { + console.log('🔗 WebSocket connected:', connection.wsUrl); + + // 发送初始终端尺寸 + if (terminalInstanceRef.current) { + const terminal = terminalInstanceRef.current; + ws.send(JSON.stringify({ + type: 'resize', + data: { + request: { + type: 'resize', + rows: terminal.rows, + cols: terminal.cols, + } + } + })); + } + }; + + ws.onmessage = (event) => { + try { + const msg: TerminalReceiveMessage = JSON.parse(event.data); + + // 提取实际数据:处理后端 response 包装格式 + const actualData = typeof msg.data === 'string' + ? msg.data + : msg.data?.response?.data || ''; + + switch (msg.type) { + case 'output': + if (actualData && terminalInstanceRef.current) { + terminalInstanceRef.current.write(actualData); + } + break; + + case 'error': + setErrorMessage(actualData); + updateStatus('error'); + onError?.(actualData); + terminalInstanceRef.current?.writeln(`\r\n\x1b[31m错误: ${actualData}\x1b[0m\r\n`); + break; + + case 'status': + updateStatus(actualData as ConnectionStatus); + break; + } + } catch (error) { + console.error('Failed to parse WebSocket message:', error); + } + }; + + ws.onerror = () => { + updateStatus('error'); + setErrorMessage('WebSocket 连接错误'); + onError?.('WebSocket 连接错误'); + }; + + ws.onclose = () => { + if (connectionStatus === 'connected') { + updateStatus('disconnected'); + } + }; + }, [connection.wsUrl, connectionStatus, updateStatus, onError]); + + // 发送输入 + const sendInput = useCallback((data: string) => { + if (wsRef.current?.readyState === WebSocket.OPEN) { + wsRef.current.send(JSON.stringify({ + type: 'input', + data: { + request: { + type: 'input', + command: data, + } + } + })); + } + }, []); + + // 发送尺寸变化 + const sendResize = useCallback((rows: number, cols: number) => { + if (wsRef.current?.readyState === WebSocket.OPEN) { + wsRef.current.send(JSON.stringify({ + type: 'resize', + data: { + request: { + type: 'resize', + rows, + cols, + } + } + })); + } + }, []); + + // 清理资源 + const cleanup = useCallback(() => { + if (terminalInstanceRef.current) { + terminalInstanceRef.current.dispose(); + terminalInstanceRef.current = null; + } + if (wsRef.current) { + wsRef.current.close(); + wsRef.current = null; + } + }, []); + + return { + terminalRef, + terminalInstance: terminalInstanceRef.current, + fitAddon: fitAddonRef.current, + searchAddon: searchAddonRef.current, + connectionStatus, + errorMessage, + initializeTerminal, + connectWebSocket, + sendInput, + sendResize, + cleanup, + }; +}; diff --git a/frontend/src/pages/Resource/Server/List/components/SSHTerminalContent.tsx b/frontend/src/pages/Resource/Server/List/components/SSHTerminalContent.tsx deleted file mode 100644 index 237a3bfd..00000000 --- a/frontend/src/pages/Resource/Server/List/components/SSHTerminalContent.tsx +++ /dev/null @@ -1,792 +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 { 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'; -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; - } -`; - -interface SSHTerminalContentProps { - server: ServerResponse; - windowId: string; - onCloseReady?: () => void; // 断开完成后的回调 - onStatusChange?: (status: ConnectionStatus) => void; // 状态变化回调 -} - -type ConnectionStatus = 'connecting' | 'connected' | 'reconnecting' | 'disconnected' | 'error'; - -interface WebSocketMessage { - type: 'output' | 'input' | 'status' | 'error'; - data: string | { response: { type: string; data: string } }; // 支持字符串或 response 包装格式 - timestamp?: number; - metadata?: Record | null; -} - -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('connecting'); - const [errorMessage, setErrorMessage] = useState(''); - const [fontSize, setFontSize] = useState(14); - const [showSearch, setShowSearch] = useState(false); - const [searchKeyword, setSearchKeyword] = useState(''); - - // 更新ref - useEffect(() => { - onStatusChangeRef.current = onStatusChange; - }, [onStatusChange]); - - // 状态变化时通知父组件 - useEffect(() => { - onStatusChangeRef.current?.(connectionStatus); - }, [connectionStatus]); - - // 初始化水印 - 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', - }); - - // 获取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 = () => { - 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('connecting'); - 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: { - request: { - type: 'input', - command: data, - } - } - })); - } - }); - - // 监听终端尺寸变化 - terminal.onResize((size) => { - console.log('📐 终端尺寸变化:', size); - if (wsRef.current?.readyState === WebSocket.OPEN) { - wsRef.current.send(JSON.stringify({ - type: 'resize', - data: { - request: { - type: 'resize', - rows: size.rows, - cols: size.cols, - } - } - })); - console.log('📤 已发送尺寸调整消息:', size); - } - }); - - // 延迟连接 - setTimeout(() => { - connectWebSocket(); - }, 300); - }; - - 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 = () => { - console.log('🔗 WebSocket已连接:', wsUrl); - console.log('📺 Terminal实例存在:', !!terminalInstanceRef.current); - - // 发送初始终端尺寸 - if (terminalInstanceRef.current) { - const terminal = terminalInstanceRef.current; - ws.send(JSON.stringify({ - type: 'resize', - data: { - request: { - type: 'resize', - rows: terminal.rows, - cols: terminal.cols, - } - } - })); - console.log('📤 已发送初始终端尺寸:', { rows: terminal.rows, cols: terminal.cols }); - } - }; - - ws.onmessage = (event) => { - console.log('📨 收到WebSocket消息:', event.data); - try { - const msg: WebSocketMessage = JSON.parse(event.data); - console.log('📦 解析后的消息:', msg); - - // 提取实际数据:处理后端 response 包装格式 - const actualData = typeof msg.data === 'string' - ? msg.data - : msg.data?.response?.data || ''; - - switch (msg.type) { - case 'output': - console.log('📝 输出数据:', actualData?.substring(0, 100)); - console.log('📺 Terminal实例:', !!terminalInstanceRef.current); - if (actualData && terminalInstanceRef.current) { - terminalInstanceRef.current.write(actualData); - console.log('✅ 数据已写入终端'); - } else { - console.warn('⚠️ 无法写入终端:', { hasData: !!actualData, hasTerminal: !!terminalInstanceRef.current }); - } - break; - - case 'error': - console.error('❌ SSH错误:', actualData); - terminalInstanceRef.current?.writeln(`\r\n\x1b[31m错误: ${actualData}\x1b[0m\r\n`); - message.error(actualData || '连接错误'); - break; - - case 'status': - console.log('📊 状态变化:', actualData); - setConnectionStatus(actualData as ConnectionStatus); - if (actualData === 'connected') { - // 显示审计警告 - terminalInstanceRef.current?.writeln('\r\n\x1b[33m┌─────────────────────────────────────────────────────────────\x1b[0m'); - terminalInstanceRef.current?.writeln('\x1b[33m│ ⚠️ 链宇技术有限公司 - 安全提示\x1b[0m'); - terminalInstanceRef.current?.writeln('\x1b[33m│ 本次SSH会话将被全程审计记录\x1b[0m'); - terminalInstanceRef.current?.writeln('\x1b[33m│ • 所有操作命令、输入、输出都将被完整记录\x1b[0m'); - terminalInstanceRef.current?.writeln('\x1b[33m│ • 审计日志用于安全审查、故障排查和合规要求\x1b[0m'); - terminalInstanceRef.current?.writeln('\x1b[33m│ • 请规范操作,遵守企业信息安全管理制度\x1b[0m'); - terminalInstanceRef.current?.writeln('\x1b[33m└─────────────────────────────────────────────────────────────\x1b[0m\r\n'); - setTimeout(() => { - fitAddonRef.current?.fit(); - }, 100); - } - break; - } - } catch (error) { - console.error('❌ 解析WebSocket消息失败:', error, 'Raw data:', event.data); - } - }; - - 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; - 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(() => { - console.log('📦 组件挂载,开始初始化...'); - const timer = setTimeout(() => { - initializeTerminalAndConnect(); - }, 50); - - 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(() => { - fitAddonRef.current?.fit(); - }); - resizeObserver.observe(terminalRef.current); - } - - const handleWindowResize = () => { - fitAddonRef.current?.fit(); - }; - window.addEventListener('resize', handleWindowResize); - - return () => { - 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; - } - }; - }, []); - - const getStatusBadge = () => { - switch (connectionStatus) { - case 'connecting': - return 连接中; - case 'connected': - return
已连接; - case 'reconnecting': - 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 === 'connecting' && ( -
- -

正在连接SSH...

-
- )} - - {connectionStatus === 'reconnecting' && ( -
- -

连接中断,正在重连...

-
- )} - - {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 index a5937c11..c0305cab 100644 --- a/frontend/src/pages/Resource/Server/List/components/SSHWindowManager.tsx +++ b/frontend/src/pages/Resource/Server/List/components/SSHWindowManager.tsx @@ -1,9 +1,77 @@ -import React, { useState, useCallback } from 'react'; -import { Terminal } from 'lucide-react'; -import { DraggableWindow } from '@/components/ui/draggable-window'; -import { SSHTerminalContent } from './SSHTerminalContent'; +/** + * SSH 窗口管理器 + * 直接使用通用 Terminal 和 TerminalWindowManager + */ +import React from 'react'; +import { + TerminalWindowManager, + Terminal, + type TerminalConnectionConfig, + type TerminalAuditConfig, + type TerminalToolbarConfig +} from '@/components/Terminal'; import type { ServerResponse } from '../types'; +interface SSHWindowManagerProps { + onOpenWindow?: (windowId: string) => void; +} + +export const SSHWindowManager: React.FC = ({ onOpenWindow }) => { + return ( + + type="ssh" + getWindowTitle={(server) => `SSH终端 - ${server.serverName} (${server.hostIp})`} + getWindowSubtitle={(server) => server.serverName} + getResourceId={(server) => server.id} + renderTerminal={(windowId, server, callbacks) => { + // SSH 连接配置 + const token = localStorage.getItem('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 connectionConfig: TerminalConnectionConfig = { + type: 'ssh', + wsUrl, + token: token || undefined, + autoReconnect: true, + reconnectInterval: 3000, + maxReconnectAttempts: 5, + }; + + // 审计配置 + const auditConfig: TerminalAuditConfig = { + enabled: true, + companyName: '链宇技术有限公司', + }; + + // 工具栏配置 + const toolbarConfig: TerminalToolbarConfig = { + show: true, + showSearch: true, + showClear: true, + showCopy: true, + showFontSize: true, + showStatus: true, + showFontSizeLabel: true, + }; + + return ( + + ); + }} + onOpenWindow={onOpenWindow} + /> + ); +}; + +// 保留类型定义以兼容现有代码 export interface SSHWindow { id: string; server: ServerResponse; @@ -12,208 +80,3 @@ export interface SSHWindow { size: { width: number; height: number }; connectionStatus?: 'connecting' | 'connected' | 'reconnecting' | 'disconnected' | 'error'; } - -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 }, - connectionStatus: 'connecting', - }; - - 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); - }, []); - - // 更新窗口连接状态 - 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': - return 'bg-yellow-600 hover:bg-yellow-700'; - case 'reconnecting': - 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; - 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)} - onStatusChange={(status) => updateWindowStatus(window.id, status)} - /> - -
- ))} - - {/* 最小化的窗口悬浮按钮 */} - {windows.filter(w => w.isMinimized).length > 0 && ( -
- {windows - .filter(w => w.isMinimized) - .map((window) => { - // 计算同一服务器的窗口数量和当前窗口序号 - const sameServerWindows = windows.filter(w => w.server.id === window.server.id); - const needsIndex = sameServerWindows.length > 1; - const windowIndex = sameServerWindows.findIndex(w => w.id === window.id) + 1; - - return ( - - ); - })} -
- )} - - ); -}; diff --git a/frontend/src/pages/Resource/Server/List/components/WindowManager.tsx b/frontend/src/pages/Resource/Server/List/components/WindowManager.tsx deleted file mode 100644 index e1d5634a..00000000 --- a/frontend/src/pages/Resource/Server/List/components/WindowManager.tsx +++ /dev/null @@ -1,118 +0,0 @@ -import React, { useState, useCallback } from 'react'; -import { Terminal } from 'lucide-react'; -import { DraggableWindow } from './DraggableWindow'; -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 }; -} - -export const WindowManager: React.FC = () => { - 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; - - setWindows(prev => [ - ...prev, - { - id: windowId, - server, - isMinimized: false, - position: { x: 100 + offset, y: 100 + offset }, - size: { width: 1200, height: 700 }, - }, - ]); - setActiveWindowId(windowId); - }, [windows.length]); - - // 关闭窗口 - const closeWindow = useCallback((windowId: string) => { - setWindows(prev => prev.filter(w => w.id !== windowId)); - if (activeWindowId === windowId) { - setActiveWindowId(null); - } - }, [activeWindowId]); - - // 最小化窗口 - const minimizeWindow = useCallback((windowId: string) => { - setWindows(prev => - prev.map(w => - w.id === windowId ? { ...w, isMinimized: true } : w - ) - ); - }, []); - - // 恢复窗口 - const restoreWindow = useCallback((windowId: string) => { - setWindows(prev => - prev.map(w => - w.id === windowId ? { ...w, isMinimized: false } : w - ) - ); - setActiveWindowId(windowId); - }, []); - - // 激活窗口 - const focusWindow = useCallback((windowId: string) => { - setActiveWindowId(windowId); - }, []); - - // 暴露openWindow方法给外部使用 - React.useEffect(() => { - (window as any).__openSSHWindow = openWindow; - return () => { - delete (window as any).__openSSHWindow; - }; - }, [openWindow]); - - return ( - <> - {/* 渲染所有窗口 */} - {windows.map(window => ( - !window.isMinimized && ( - closeWindow(window.id)} - onMinimize={() => minimizeWindow(window.id)} - isActive={activeWindowId === window.id} - onFocus={() => focusWindow(window.id)} - initialPosition={window.position} - initialSize={window.size} - > - - - ) - ))} - - {/* 最小化的窗口悬浮按钮 */} -
- {windows - .filter(w => w.isMinimized) - .map((window, index) => ( - - ))} -
- - ); -};