diff --git a/frontend/src/components/Terminal/SplitDivider.tsx b/frontend/src/components/Terminal/SplitDivider.tsx new file mode 100644 index 00000000..9a97b966 --- /dev/null +++ b/frontend/src/components/Terminal/SplitDivider.tsx @@ -0,0 +1,71 @@ +/** + * 分屏分隔条组件 + * 可拖动调整分屏大小 + */ +import React, { useRef, useState, useEffect } from 'react'; +import type { LayoutOrientation } from './types'; + +interface SplitDividerProps { + orientation: LayoutOrientation; + onResize: (delta: number) => void; +} + +export const SplitDivider: React.FC = ({ orientation, onResize }) => { + const [isDragging, setIsDragging] = useState(false); + const startPosRef = useRef(0); + + useEffect(() => { + if (!isDragging) return; + + const handleMouseMove = (e: MouseEvent) => { + const delta = orientation === 'horizontal' + ? e.clientX - startPosRef.current + : e.clientY - startPosRef.current; + + onResize(delta); + startPosRef.current = orientation === 'horizontal' ? e.clientX : e.clientY; + }; + + const handleMouseUp = () => { + setIsDragging(false); + }; + + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + + return () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + }, [isDragging, orientation, onResize]); + + const handleMouseDown = (e: React.MouseEvent) => { + e.preventDefault(); + setIsDragging(true); + startPosRef.current = orientation === 'horizontal' ? e.clientX : e.clientY; + }; + + return ( +
+ {/* 拖动热区 - 增加可拖动区域 */} +
+
+ ); +}; diff --git a/frontend/src/components/Terminal/Terminal.tsx b/frontend/src/components/Terminal/Terminal.tsx index a3915144..b750f578 100644 --- a/frontend/src/components/Terminal/Terminal.tsx +++ b/frontend/src/components/Terminal/Terminal.tsx @@ -2,17 +2,18 @@ * Terminal 组件 - 通用终端 * 支持 SSH、K8s Pod、Docker Container 等多种场景 */ -import React, { useEffect, useCallback, useState } from 'react'; +import React, { useState, useEffect, useCallback, useRef } from 'react'; +import { Input } from '@/components/ui/input'; 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 { TERMINAL_THEMES, getThemeByName } from './themes'; import { Loader2, XCircle, ChevronUp, ChevronDown, X } from 'lucide-react'; import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; import { message } from 'antd'; +import { TerminalInstanceManager } from './core/TerminalInstanceManager'; +import type { ConnectionStatus } from './strategies/BaseConnectionStrategy'; export const Terminal: React.FC = ({ id, @@ -20,37 +21,25 @@ export const Terminal: React.FC = ({ display, audit, toolbar, + isActive, onStatusChange, onCloseReady, onError, + onSplitUp, + onSplitDown, + onSplitLeft, + onSplitRight, + onSplitInGroup, }) => { + const terminalRef = useRef(null); + const instanceRef = useRef | null>(null); + const [fontSize, setFontSize] = useState(display?.fontSize ?? 14); const [showSearch, setShowSearch] = useState(false); const [currentTheme, setCurrentTheme] = useState('dark'); const [auditShown, setAuditShown] = useState(false); - - const { - terminalRef, - terminalInstance, - fitAddon, - searchAddon, - connectionStatus, - errorMessage, - initializeTerminal, - connectWebSocket, - sendInput, - sendResize, - cleanup, - } = useTerminal({ - connection, - display: { - ...display, - fontSize, - theme: getThemeByName(currentTheme).theme, - }, - onStatusChange, - onError, - }); + const [connectionStatus, setConnectionStatus] = useState('disconnected'); // 初始状态,会被实例状态覆盖 + const [errorMessage, setErrorMessage] = useState(''); // 默认工具栏配置 const toolbarConfig: TerminalToolbarConfig = { @@ -64,37 +53,72 @@ export const Terminal: React.FC = ({ extraActions: toolbar?.extraActions, }; - // 初始化 + // 初始化 Terminal 实例 useEffect(() => { - const terminal = initializeTerminal(); - if (!terminal) return; - - // 监听终端输入 - terminal.onData((data) => { - sendInput(data); + console.log(`[Terminal ${id}] 组件挂载 - 使用实例管理器`); + + const manager = TerminalInstanceManager.getInstance(); + + // 获取或创建实例(Manager 内部会处理连接策略的创建和复用) + const instance = manager.getOrCreate(id, { + id, + connectionConfig: connection, // 传配置,不传策略实例 + display: { + ...display, + fontSize, + theme: getThemeByName(currentTheme).theme, + }, }); - - // 监听终端尺寸变化 - terminal.onResize((size) => { - sendResize(size.rows, size.cols); + + instanceRef.current = instance; + + // 立即同步当前状态(避免初始状态不一致导致闪烁) + const currentState = instance.getState(); + setConnectionStatus(currentState.status); + if (currentState.errorMessage) { + setErrorMessage(currentState.errorMessage); + } + + // 挂载到 DOM + if (terminalRef.current) { + instance.mount(terminalRef.current); + } + + // 订阅状态变化 + const unsubscribe = instance.onStateChange((state) => { + setConnectionStatus(state.status); + setErrorMessage(state.errorMessage || ''); + onStatusChange?.(state.status); + if (state.errorMessage) { + onError?.(state.errorMessage); + } }); - - // 延迟连接 + + // 延迟连接(仅在未连接时) const timer = setTimeout(() => { - connectWebSocket(); + const currentState = instance.getState(); + if (currentState.status === 'disconnected' || currentState.status === 'error') { + console.log(`[Terminal ${id}] 开始连接`); + instance.connect(); + } else { + console.log(`[Terminal ${id}] 实例已连接,跳过connect调用,当前状态: ${currentState.status}`); + } }, 300); return () => { + console.log(`[Terminal ${id}] 组件卸载 - 只unmount,不销毁实例`); clearTimeout(timer); - cleanup(); + unsubscribe(); + instance.unmount(); + // 注意:不调用 manager.destroy(),实例会被复用 }; }, [id]); // 监听窗口大小变化,自动调整终端尺寸 useEffect(() => { const handleResize = () => { + const fitAddon = instanceRef.current?.getFitAddon(); if (fitAddon) { - // 延迟执行以确保窗口大小已经更新 setTimeout(() => { fitAddon.fit(); }, 100); @@ -106,43 +130,45 @@ export const Terminal: React.FC = ({ return () => { window.removeEventListener('resize', handleResize); }; - }, [fitAddon]); + }, []); // 显示审计警告(只显示一次) useEffect(() => { - if (connectionStatus === 'connected' && audit?.enabled && terminalInstance && !auditShown) { + if (connectionStatus === 'connected' && audit?.enabled && instanceRef.current && !auditShown) { + const instance = instanceRef.current; const companyName = audit.companyName || ''; const customMessage = audit.message; if (customMessage) { - terminalInstance.writeln(`\r\n\x1b[33m${customMessage}\x1b[0m\r\n`); + instance.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'); + instance.writeln('\r\n\x1b[33m┌─────────────────────────────────────────────────────────────\x1b[0m'); + instance.writeln(`\x1b[33m│ ⚠️ ${companyName} - 安全提示\x1b[0m`); + instance.writeln('\x1b[33m│ 本次会话将被全程审计记录\x1b[0m'); + instance.writeln('\x1b[33m│ • 所有操作命令、输入、输出都将被完整记录\x1b[0m'); + instance.writeln('\x1b[33m│ • 审计日志用于安全审查、故障排查和合规要求\x1b[0m'); + instance.writeln('\x1b[33m│ • 请规范操作,遵守企业信息安全管理制度\x1b[0m'); + instance.writeln('\x1b[33m└─────────────────────────────────────────────────────────────\x1b[0m\r\n'); } setAuditShown(true); setTimeout(() => { - fitAddon?.fit(); + instance.getFitAddon()?.fit(); }, 100); } - }, [connectionStatus, audit, terminalInstance, fitAddon, auditShown]); + }, [connectionStatus, audit, auditShown]); // 重连处理 const handleReconnect = useCallback(() => { - cleanup(); - setAuditShown(false); // 重置审计警告标记,重连后重新显示 - setTimeout(() => { - initializeTerminal(); - connectWebSocket(); - }, 100); - }, [cleanup, initializeTerminal, connectWebSocket]); + if (instanceRef.current) { + instanceRef.current.disconnect(); + setAuditShown(false); // 重置审计警告标记,重连后重新显示 + setTimeout(() => { + instanceRef.current?.connect(); + }, 100); + } + }, []); // 搜索 const [searchQuery, setSearchQuery] = useState(''); @@ -160,41 +186,44 @@ export const Terminal: React.FC = ({ const handleSearchChange = useCallback((value: string) => { setSearchQuery(value); - if (searchAddon && terminalInstance) { + const searchAddon = instanceRef.current?.getSearchAddon(); + if (searchAddon) { searchAddon.findNext(value, { incremental: true }); } - }, [searchAddon, terminalInstance]); + }, []); const handleSearchNext = useCallback(() => { + const searchAddon = instanceRef.current?.getSearchAddon(); if (searchAddon && searchQuery) { searchAddon.findNext(searchQuery); } - }, [searchAddon, searchQuery]); + }, [searchQuery]); const handleSearchPrev = useCallback(() => { + const searchAddon = instanceRef.current?.getSearchAddon(); if (searchAddon && searchQuery) { searchAddon.findPrevious(searchQuery); } - }, [searchAddon, searchQuery]); + }, [searchQuery]); const handleCloseSearch = useCallback(() => { setShowSearch(false); setSearchQuery(''); - searchAddon?.clearDecorations(); - }, [searchAddon]); + instanceRef.current?.getSearchAddon()?.clearDecorations(); + }, []); // 清屏 const handleClear = useCallback(() => { - if (terminalInstance) { - terminalInstance.clear(); + if (instanceRef.current) { + instanceRef.current.clear(); message.success('终端已清屏'); } - }, [terminalInstance]); + }, []); // 复制 const handleCopy = useCallback(() => { - if (terminalInstance) { - const selection = terminalInstance.getSelection(); + if (instanceRef.current) { + const selection = instanceRef.current.getSelection(); if (selection) { navigator.clipboard.writeText(selection); message.success('已复制到剪贴板'); @@ -202,37 +231,35 @@ export const Terminal: React.FC = ({ 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); + if (instanceRef.current) { + instanceRef.current.updateDisplay({ fontSize: newSize }); } - }, [fontSize, terminalInstance, fitAddon]); + }, [fontSize]); // 缩小字体 const handleZoomOut = useCallback(() => { const newSize = Math.max(fontSize - 2, 10); setFontSize(newSize); - if (terminalInstance) { - terminalInstance.options.fontSize = newSize; - setTimeout(() => fitAddon?.fit(), 50); + if (instanceRef.current) { + instanceRef.current.updateDisplay({ fontSize: newSize }); } - }, [fontSize, terminalInstance, fitAddon]); + }, [fontSize]); // 切换主题 const handleThemeChange = useCallback((themeName: string) => { setCurrentTheme(themeName); - if (terminalInstance) { + if (instanceRef.current) { const theme = getThemeByName(themeName); - terminalInstance.options.theme = theme.theme; + instanceRef.current.updateDisplay({ theme: theme.theme }); message.success(`已切换到 ${theme.label} 主题`); } - }, [terminalInstance]); + }, []); return (
@@ -250,6 +277,11 @@ export const Terminal: React.FC = ({ onZoomOut={handleZoomOut} onReconnect={handleReconnect} onThemeChange={handleThemeChange} + onSplitUp={onSplitUp} + onSplitDown={onSplitDown} + onSplitLeft={onSplitLeft} + onSplitRight={onSplitRight} + onSplitInGroup={onSplitInGroup} /> {/* 终端区域 */} diff --git a/frontend/src/components/Terminal/TerminalSplitView.tsx b/frontend/src/components/Terminal/TerminalSplitView.tsx new file mode 100644 index 00000000..278c792c --- /dev/null +++ b/frontend/src/components/Terminal/TerminalSplitView.tsx @@ -0,0 +1,256 @@ +/** + * Terminal 分屏组件 + * 支持树形嵌套布局,类似 VS Code 的编辑器分屏系统 + */ +import React, { useEffect } from 'react'; +import { Terminal } from './Terminal'; +import { SplitDivider } from './SplitDivider'; +import { X, Plus } from 'lucide-react'; +import { useSplitView } from './useSplitView'; +import type { SplitNode, TerminalTab, TerminalConnectionConfig, TerminalAuditConfig, TerminalToolbarConfig } from './types'; + +// 对外暴露的主组件Props +export interface TerminalSplitViewProps { + initialTab: TerminalTab; + getConnectionConfig: (tab: TerminalTab) => TerminalConnectionConfig; + getAuditConfig: () => TerminalAuditConfig; + getToolbarConfig: () => TerminalToolbarConfig; +} + +// 内部递归组件Props +interface SplitNodeRendererProps { + node: SplitNode; + activeGroupId: string; + onTabClick: (groupId: string, tabId: string) => void; + onTabClose: (groupId: string, tabId: string) => void; + onNewTab: () => void; + onSplitUp: () => void; + onSplitDown: () => void; + onSplitLeft: () => void; + onSplitRight: () => void; + onFocus: (groupId: string) => void; + onResize: (nodeId: string, delta: number) => void; + getConnectionConfig: (tab: TerminalTab) => TerminalConnectionConfig; + getAuditConfig: () => TerminalAuditConfig; + getToolbarConfig: () => TerminalToolbarConfig; +} + +const SplitNodeRendererComponent: React.FC = ({ + node, + activeGroupId, + onTabClick, + onTabClose, + onNewTab, + onSplitUp, + onSplitDown, + onSplitLeft, + onSplitRight, + onFocus, + onResize, + getConnectionConfig, + getAuditConfig, + getToolbarConfig, +}) => { + // 渲染编辑器组(叶子节点) + if (node.type === 'group') { + const isActive = node.id === activeGroupId; + const connectionConfigs: Record = {}; + node.tabs.forEach(tab => { + connectionConfigs[tab.id] = getConnectionConfig(tab); + }); + const auditConfig = getAuditConfig(); + const toolbarConfig = getToolbarConfig(); + + return ( +
+ {/* Tab栏 */} +
+
+ {node.tabs.map((tab) => ( +
onTabClick(node.id, tab.id)} + > + + {tab.serverName} + + +
+ ))} + +
+
+ + {/* 终端内容 */} +
+ {node.tabs.map((tab) => ( +
+ +
+ ))} +
+
+ ); + } + + // 渲染容器(内部节点)- 递归渲染子节点 + const isHorizontal = node.orientation === 'horizontal'; + + return ( +
+ {node.children.map((child, index) => ( + +
+ +
+ + {/* 分隔条 - 不在最后一个节点之后显示 */} + {index < node.children.length - 1 && ( + onResize(child.id, delta)} + /> + )} +
+ ))} +
+ ); +}; + +// 使用 React.memo 优化,避免不必要的重新渲染 +const SplitNodeRenderer = React.memo(SplitNodeRendererComponent); + +/** + * Terminal 分屏视图 - 主组件 + * 管理分屏状态并渲染分屏树 + */ +export const TerminalSplitView: React.FC = ({ + initialTab, + getConnectionConfig, + getAuditConfig, + getToolbarConfig, +}) => { + const { + layout, + activeGroupId, + splitUp, + splitDown, + splitLeft, + splitRight, + splitInGroup, + switchTab, + closeTab, + resizeGroups, + setActiveGroupId, + } = useSplitView({ initialTab }); + + // 快捷键支持 + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + // Cmd/Ctrl + \ - 向右拆分 + if ((e.metaKey || e.ctrlKey) && e.key === '\\') { + e.preventDefault(); + splitRight(); + } + // Cmd/Ctrl + Shift + 方向键 - 向指定方向拆分 + if ((e.metaKey || e.ctrlKey) && e.shiftKey) { + if (e.key === 'ArrowUp') { + e.preventDefault(); + splitUp(); + } else if (e.key === 'ArrowDown') { + e.preventDefault(); + splitDown(); + } else if (e.key === 'ArrowLeft') { + e.preventDefault(); + splitLeft(); + } else if (e.key === 'ArrowRight') { + e.preventDefault(); + splitRight(); + } + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [splitUp, splitDown, splitLeft, splitRight]); + + return ( +
+ +
+ ); +}; diff --git a/frontend/src/components/Terminal/TerminalToolbar.tsx b/frontend/src/components/Terminal/TerminalToolbar.tsx index 99bc649a..49f72ec0 100644 --- a/frontend/src/components/Terminal/TerminalToolbar.tsx +++ b/frontend/src/components/Terminal/TerminalToolbar.tsx @@ -5,7 +5,14 @@ import React from 'react'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; -import { Search, Trash2, Copy, ZoomIn, ZoomOut, RotateCcw, Loader2, XCircle, Palette } from 'lucide-react'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { Search, Trash2, Copy, ZoomIn, ZoomOut, RotateCcw, Loader2, XCircle, Palette, SplitSquareVertical, SplitSquareHorizontal, Plus, ChevronDown } from 'lucide-react'; import type { ConnectionStatus, TerminalToolbarConfig } from './types'; import type { TerminalTheme } from './themes'; import styles from './index.module.less'; @@ -23,6 +30,12 @@ interface TerminalToolbarProps { onZoomOut?: () => void; onReconnect?: () => void; onThemeChange?: (themeName: string) => void; + // 分屏操作 + onSplitUp?: () => void; + onSplitDown?: () => void; + onSplitLeft?: () => void; + onSplitRight?: () => void; + onSplitInGroup?: () => void; } export const TerminalToolbar: React.FC = ({ @@ -38,6 +51,11 @@ export const TerminalToolbar: React.FC = ({ onZoomOut, onReconnect, onThemeChange, + onSplitUp, + onSplitDown, + onSplitLeft, + onSplitRight, + onSplitInGroup, }) => { if (!config.show) return null; @@ -145,6 +163,63 @@ export const TerminalToolbar: React.FC = ({ )} + {/* 分屏菜单 */} + {(onSplitUp || onSplitDown || onSplitLeft || onSplitRight || onSplitInGroup) && ( + <> +
+ + + + + + {onSplitUp && ( + { e.stopPropagation(); onSplitUp(); }}> + + 向上拆分 + + )} + {onSplitDown && ( + { e.stopPropagation(); onSplitDown(); }}> + + 向下拆分 + + )} + {(onSplitUp || onSplitDown) && (onSplitLeft || onSplitRight) && ( + + )} + {onSplitLeft && ( + { e.stopPropagation(); onSplitLeft(); }}> + + 向左拆分 + + )} + {onSplitRight && ( + { e.stopPropagation(); onSplitRight(); }}> + + 向右拆分 + + )} + {onSplitInGroup && ( + <> + + { e.stopPropagation(); onSplitInGroup(); }}> + + 在组中拆分 + + + )} + + + + )} {(connectionStatus === 'disconnected' || connectionStatus === 'error') && ( <>
diff --git a/frontend/src/components/Terminal/core/TerminalInstance.ts b/frontend/src/components/Terminal/core/TerminalInstance.ts new file mode 100644 index 00000000..f4ce000c --- /dev/null +++ b/frontend/src/components/Terminal/core/TerminalInstance.ts @@ -0,0 +1,317 @@ +/** + * Terminal 实例封装 + * 职责:封装 XTerm 实例 + 连接策略,管理完整的终端生命周期 + */ + +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 { BaseConnectionStrategy, ConnectionStatus } from '../strategies/BaseConnectionStrategy'; +import type { TerminalConnectionConfig } from '../types'; + +export interface TerminalDisplayConfig { + cursorBlink?: boolean; + fontSize?: number; + fontFamily?: string; + theme?: any; + rows?: number; + cols?: number; + scrollback?: number; +} + +export interface TerminalInstanceConfig { + id: string; + connection: BaseConnectionStrategy; // 实际的连接策略实例 + display?: TerminalDisplayConfig; +} + +// Manager 使用的配置(传入配置,Manager 负责创建策略) +export interface TerminalInstanceCreateConfig { + id: string; + connectionConfig: TerminalConnectionConfig; // 连接配置 + display?: TerminalDisplayConfig; +} + +export type StateChangeCallback = (state: { + status: ConnectionStatus; + errorMessage?: string; +}) => void; + +/** + * Terminal 实例类 + * 独立于 React 组件生命周期,可以跨组件挂载/卸载复用 + */ +export class TerminalInstance { + private xterm: XTerm; + private fitAddon: FitAddon; + private searchAddon: SearchAddon; + private webLinksAddon: WebLinksAddon; + + private mounted = false; + private currentContainer: HTMLElement | null = null; + private connectionStrategy: BaseConnectionStrategy; + + private stateListeners: Set = new Set(); + private unsubscribers: Array<() => void> = []; + + constructor(private config: TerminalInstanceConfig) { + // 初始化 XTerm + const display = config.display || {}; + this.xterm = new XTerm({ + cursorBlink: display.cursorBlink ?? true, + fontSize: display.fontSize ?? 14, + fontFamily: display.fontFamily ?? 'Consolas, "Courier New", monospace', + theme: display.theme, // 主题由外部传入,保持主题系统的完整性 + rows: display.rows ?? 30, + cols: display.cols ?? 100, + scrollback: display.scrollback ?? 10000, + }); + + // 初始化插件 + this.fitAddon = new FitAddon(); + this.searchAddon = new SearchAddon(); + this.webLinksAddon = new WebLinksAddon(); + + this.xterm.loadAddon(this.fitAddon); + this.xterm.loadAddon(this.searchAddon); + this.xterm.loadAddon(this.webLinksAddon); + + // 绑定连接策略 + this.connectionStrategy = config.connection; + this.setupConnectionListeners(); + this.setupTerminalListeners(); + + console.log(`[TerminalInstance ${config.id}] Created`); + } + + /** + * 挂载到 DOM 容器 + */ + mount(container: HTMLElement): void { + if (this.mounted && this.currentContainer === container) { + console.log(`[TerminalInstance ${this.config.id}] Already mounted to this container`); + return; + } + + if (this.mounted) { + this.unmount(); + } + + this.xterm.open(container); + this.currentContainer = container; + this.mounted = true; + + // 自适应尺寸 + setTimeout(() => { + this.fitAddon.fit(); + }, 100); + + console.log(`[TerminalInstance ${this.config.id}] Mounted to DOM`); + } + + /** + * 从 DOM 卸载(但不销毁实例) + */ + unmount(): void { + if (!this.mounted) return; + + // XTerm 没有直接的 unmount 方法,我们只需标记状态 + this.currentContainer = null; + this.mounted = false; + + console.log(`[TerminalInstance ${this.config.id}] Unmounted from DOM`); + } + + /** + * 连接到服务器 + */ + async connect(): Promise { + console.log(`[TerminalInstance ${this.config.id}] Connecting...`); + await this.connectionStrategy.connect(); + } + + /** + * 断开连接 + */ + disconnect(): void { + console.log(`[TerminalInstance ${this.config.id}] Disconnecting...`); + this.connectionStrategy.disconnect(); + } + + /** + * 获取 XTerm 实例(用于 UI 层操作) + */ + getXTerm(): XTerm { + return this.xterm; + } + + /** + * 获取 FitAddon(用于尺寸调整) + */ + getFitAddon(): FitAddon { + return this.fitAddon; + } + + /** + * 获取 SearchAddon(用于搜索功能) + */ + getSearchAddon(): SearchAddon { + return this.searchAddon; + } + + /** + * 获取当前连接状态 + */ + getConnectionStatus(): ConnectionStatus { + return this.connectionStrategy.getStatus(); + } + + /** + * 获取当前状态 + */ + getState(): { status: ConnectionStatus; errorMessage?: string } { + return { + status: this.connectionStrategy.getStatus(), + errorMessage: undefined, + }; + } + + /** + * 订阅状态变化 + */ + onStateChange(callback: StateChangeCallback): () => void { + this.stateListeners.add(callback); + return () => this.stateListeners.delete(callback); + } + + /** + * 发送输入 + */ + sendInput(data: string): void { + this.connectionStrategy.sendInput(data); + } + + /** + * 发送尺寸变化 + */ + sendResize(rows: number, cols: number): void { + this.connectionStrategy.sendResize(rows, cols); + } + + /** + * 设置终端配置(字体大小、主题等) + */ + updateDisplay(config: Partial): void { + if (config.fontSize !== undefined) { + this.xterm.options.fontSize = config.fontSize; + } + if (config.theme !== undefined) { + this.xterm.options.theme = config.theme; + } + + // 更新后重新适配尺寸 + setTimeout(() => this.fitAddon.fit(), 50); + } + + /** + * 清屏 + */ + clear(): void { + this.xterm.clear(); + } + + /** + * 写入文本 + */ + write(data: string): void { + this.xterm.write(data); + } + + /** + * 写入一行文本 + */ + writeln(data: string): void { + this.xterm.writeln(data); + } + + /** + * 获取选中内容 + */ + getSelection(): string { + return this.xterm.getSelection(); + } + + /** + * 销毁实例(完全清理) + */ + dispose(): void { + console.log(`[TerminalInstance ${this.config.id}] Disposing...`); + + // 清理连接 + this.disconnect(); + + // 取消所有订阅 + this.unsubscribers.forEach(unsub => unsub()); + this.unsubscribers = []; + + // 清理状态监听器 + this.stateListeners.clear(); + + // 销毁 XTerm + if (this.xterm) { + this.xterm.dispose(); + } + + this.mounted = false; + this.currentContainer = null; + } + + /** + * 设置连接策略的监听器 + */ + private setupConnectionListeners(): void { + // 监听消息接收 + const unsubMessage = this.connectionStrategy.onMessage((data) => { + this.xterm.write(data); + }); + + // 监听状态变化 + const unsubStatus = this.connectionStrategy.onStatusChange((status) => { + this.notifyStateChange({ status }); + }); + + // 监听错误 + const unsubError = this.connectionStrategy.onError((error) => { + this.xterm.writeln(`\r\n\x1b[31m错误: ${error}\x1b[0m\r\n`); + this.notifyStateChange({ + status: this.connectionStrategy.getStatus(), + errorMessage: error + }); + }); + + this.unsubscribers.push(unsubMessage, unsubStatus, unsubError); + } + + /** + * 设置终端事件监听器 + */ + private setupTerminalListeners(): void { + // 监听用户输入 + this.xterm.onData((data) => { + this.sendInput(data); + }); + + // 监听尺寸变化 + this.xterm.onResize((size) => { + this.sendResize(size.rows, size.cols); + }); + } + + /** + * 通知状态变化 + */ + private notifyStateChange(state: { status: ConnectionStatus; errorMessage?: string }): void { + this.stateListeners.forEach(listener => listener(state)); + } +} diff --git a/frontend/src/components/Terminal/core/TerminalInstanceManager.ts b/frontend/src/components/Terminal/core/TerminalInstanceManager.ts new file mode 100644 index 00000000..4976975b --- /dev/null +++ b/frontend/src/components/Terminal/core/TerminalInstanceManager.ts @@ -0,0 +1,157 @@ +/** + * Terminal 实例管理器(单例模式) + * 职责:管理所有 Terminal 实例的生命周期,确保同一 Tab 复用同一实例 + * 同时管理连接策略的创建,确保每个 Tab 有独立的连接策略 + */ + +import { TerminalInstance, TerminalInstanceCreateConfig } from './TerminalInstance'; +import { SSHConnectionStrategy } from '../strategies/SSHConnectionStrategy'; +import type { BaseConnectionStrategy } from '../strategies/BaseConnectionStrategy'; + +/** + * Terminal 实例管理器 + * 使用单例模式确保全局只有一个管理器实例 + */ +export class TerminalInstanceManager { + private static instance: TerminalInstanceManager | null = null; + private instances: Map = new Map(); + private connectionStrategies: Map = new Map(); + + private constructor() { + console.log('[TerminalInstanceManager] Initialized'); + } + + /** + * 获取管理器单例 + */ + static getInstance(): TerminalInstanceManager { + if (!TerminalInstanceManager.instance) { + TerminalInstanceManager.instance = new TerminalInstanceManager(); + } + return TerminalInstanceManager.instance; + } + + /** + * 获取或创建 Terminal 实例 + * @param tabId Tab 的唯一标识 + * @param config Terminal 创建配置(包含连接配置) + * @returns Terminal 实例 + */ + getOrCreate(tabId: string, config: TerminalInstanceCreateConfig): TerminalInstance { + // 如果实例已存在,直接返回 + if (this.instances.has(tabId)) { + console.log(`[TerminalInstanceManager] Reusing instance for tab: ${tabId}`); + return this.instances.get(tabId)!; + } + + // 创建连接策略(每个 Tab 独立的策略) + console.log(`[TerminalInstanceManager] Creating connection strategy for tab: ${tabId}`); + const connectionStrategy = this.createConnectionStrategy(config.connectionConfig); + this.connectionStrategies.set(tabId, connectionStrategy); + + // 创建新实例 + console.log(`[TerminalInstanceManager] Creating new instance for tab: ${tabId}`); + const instance = new TerminalInstance({ + id: config.id, + connection: connectionStrategy, + display: config.display, + }); + this.instances.set(tabId, instance); + + return instance; + } + + /** + * 根据连接配置创建对应的连接策略 + */ + private createConnectionStrategy(config: any): BaseConnectionStrategy { + switch (config.type) { + case 'ssh': + return new SSHConnectionStrategy({ + type: 'ssh', + serverId: config.serverId, + token: config.token, + autoReconnect: config.autoReconnect, + reconnectInterval: config.reconnectInterval, + maxReconnectAttempts: config.maxReconnectAttempts, + }); + // 未来可以添加其他类型:k8s, docker 等 + default: + throw new Error(`Unsupported connection type: ${config.type}`); + } + } + + /** + * 获取指定 Tab 的 Terminal 实例 + * @param tabId Tab 的唯一标识 + * @returns Terminal 实例,如果不存在则返回 undefined + */ + get(tabId: string): TerminalInstance | undefined { + return this.instances.get(tabId); + } + + /** + * 检查实例是否存在 + * @param tabId Tab 的唯一标识 + * @returns 是否存在 + */ + has(tabId: string): boolean { + return this.instances.has(tabId); + } + + /** + * 销毁指定 Tab 的 Terminal 实例 + * @param tabId Tab 的唯一标识 + */ + destroy(tabId: string): void { + const instance = this.instances.get(tabId); + if (instance) { + console.log(`[TerminalInstanceManager] Destroying instance for tab: ${tabId}`); + instance.dispose(); + this.instances.delete(tabId); + } + + // 同时销毁连接策略 + const strategy = this.connectionStrategies.get(tabId); + if (strategy && 'dispose' in strategy) { + console.log(`[TerminalInstanceManager] Destroying connection strategy for tab: ${tabId}`); + (strategy as any).dispose(); + } + this.connectionStrategies.delete(tabId); + } + + /** + * 销毁所有实例(通常用于应用卸载时) + */ + destroyAll(): void { + console.log('[TerminalInstanceManager] Destroying all instances'); + this.instances.forEach((instance, tabId) => { + console.log(`[TerminalInstanceManager] Destroying instance: ${tabId}`); + instance.dispose(); + }); + this.instances.clear(); + + // 同时销毁所有连接策略 + this.connectionStrategies.forEach((strategy, tabId) => { + if ('dispose' in strategy) { + console.log(`[TerminalInstanceManager] Destroying connection strategy: ${tabId}`); + (strategy as any).dispose(); + } + }); + this.connectionStrategies.clear(); + } + + /** + * 获取当前实例数量 + */ + getCount(): number { + return this.instances.size; + } + + /** + * 获取所有实例的 Tab ID 列表 + */ + getAllTabIds(): string[] { + return Array.from(this.instances.keys()); + } +} diff --git a/frontend/src/components/Terminal/core/index.ts b/frontend/src/components/Terminal/core/index.ts new file mode 100644 index 00000000..94026d69 --- /dev/null +++ b/frontend/src/components/Terminal/core/index.ts @@ -0,0 +1,11 @@ +/** + * Terminal Core - 核心层导出 + */ + +export { TerminalInstance } from './TerminalInstance'; +export { TerminalInstanceManager } from './TerminalInstanceManager'; +export type { + TerminalInstanceConfig, + TerminalInstanceCreateConfig, + TerminalDisplayConfig +} from './TerminalInstance'; diff --git a/frontend/src/components/Terminal/index.ts b/frontend/src/components/Terminal/index.ts index 256cf984..0058a1cf 100644 --- a/frontend/src/components/Terminal/index.ts +++ b/frontend/src/components/Terminal/index.ts @@ -3,9 +3,21 @@ * 通用终端组件,支持 SSH、K8s Pod、Docker Container 等多种场景 */ +// UI Components export { Terminal } from './Terminal'; -export { useTerminal } from './useTerminal'; export { TerminalWindowManager } from './TerminalWindowManager'; +export { TerminalSplitView } from './TerminalSplitView'; + +// Hooks +export { useSplitView } from './useSplitView'; + +// Core & Strategies +export { TerminalInstanceManager } from './core/TerminalInstanceManager'; +export { TerminalInstance } from './core/TerminalInstance'; +export { SSHConnectionStrategy } from './strategies/SSHConnectionStrategy'; +export { BaseConnectionStrategy } from './strategies/BaseConnectionStrategy'; + +// Themes export { TERMINAL_THEMES, getThemeByName } from './themes'; export type { TerminalWindow } from './TerminalWindowManager'; export type { TerminalTheme } from './themes'; @@ -20,3 +32,13 @@ export type { TerminalToolbarConfig, TerminalProps, } from './types'; +export type { + SplitDirection, + LayoutOrientation, + TerminalTab, + EditorGroup as EditorGroupType, + SplitContainer, + SplitNode, + SplitLayout, + SplitAction, +} from './types'; diff --git a/frontend/src/components/Terminal/strategies/BaseConnectionStrategy.ts b/frontend/src/components/Terminal/strategies/BaseConnectionStrategy.ts new file mode 100644 index 00000000..7303cd42 --- /dev/null +++ b/frontend/src/components/Terminal/strategies/BaseConnectionStrategy.ts @@ -0,0 +1,113 @@ +/** + * 连接策略基类 + * 职责:定义所有连接策略的统一接口规范 + * 支持:SSH、K8S Pod、Docker Container 等多种连接类型 + */ + +// 注意:ConnectionStatus 类型定义与 types.ts 保持一致 +export type ConnectionStatus = 'connecting' | 'connected' | 'reconnecting' | 'disconnected' | 'error'; + +export interface ConnectionConfig { + type: 'ssh' | 'k8s' | 'docker'; + autoReconnect?: boolean; + reconnectInterval?: number; + maxReconnectAttempts?: number; +} + +export type StatusChangeCallback = (status: ConnectionStatus) => void; +export type MessageCallback = (data: string) => void; +export type ErrorCallback = (error: string) => void; + +/** + * 抽象连接策略基类 + */ +export abstract class BaseConnectionStrategy { + protected status: ConnectionStatus = 'disconnected'; + protected statusListeners: Set = new Set(); + protected messageListeners: Set = new Set(); + protected errorListeners: Set = new Set(); + + constructor(protected config: ConnectionConfig) {} + + /** + * 建立连接 + */ + abstract connect(): Promise; + + /** + * 断开连接 + */ + abstract disconnect(): void; + + /** + * 发送输入数据 + */ + abstract sendInput(data: string): void; + + /** + * 发送终端尺寸变化 + */ + abstract sendResize(rows: number, cols: number): void; + + /** + * 获取当前连接状态 + */ + getStatus(): ConnectionStatus { + return this.status; + } + + /** + * 订阅状态变化 + */ + onStatusChange(callback: StatusChangeCallback): () => void { + this.statusListeners.add(callback); + return () => this.statusListeners.delete(callback); + } + + /** + * 订阅消息接收 + */ + onMessage(callback: MessageCallback): () => void { + this.messageListeners.add(callback); + return () => this.messageListeners.delete(callback); + } + + /** + * 订阅错误事件 + */ + onError(callback: ErrorCallback): () => void { + this.errorListeners.add(callback); + return () => this.errorListeners.delete(callback); + } + + /** + * 触发状态变化通知 + */ + protected notifyStatusChange(status: ConnectionStatus): void { + this.status = status; + this.statusListeners.forEach(listener => listener(status)); + } + + /** + * 触发消息接收通知 + */ + protected notifyMessage(data: string): void { + this.messageListeners.forEach(listener => listener(data)); + } + + /** + * 触发错误通知 + */ + protected notifyError(error: string): void { + this.errorListeners.forEach(listener => listener(error)); + } + + /** + * 清理所有监听器 + */ + protected clearListeners(): void { + this.statusListeners.clear(); + this.messageListeners.clear(); + this.errorListeners.clear(); + } +} diff --git a/frontend/src/components/Terminal/strategies/SSHConnectionStrategy.ts b/frontend/src/components/Terminal/strategies/SSHConnectionStrategy.ts new file mode 100644 index 00000000..8de41e40 --- /dev/null +++ b/frontend/src/components/Terminal/strategies/SSHConnectionStrategy.ts @@ -0,0 +1,216 @@ +/** + * SSH 连接策略 + * 职责:专门处理 SSH WebSocket 连接、消息协议、重连逻辑 + */ + +import { BaseConnectionStrategy, ConnectionConfig, ConnectionStatus } from './BaseConnectionStrategy'; + +export interface SSHConnectionConfig extends ConnectionConfig { + type: 'ssh'; + serverId: string | number; + token?: string; +} + +interface TerminalReceiveMessage { + type: 'output' | 'error' | 'status'; + data: string | { response?: { data?: string } }; +} + +/** + * SSH 连接策略实现 + */ +export class SSHConnectionStrategy extends BaseConnectionStrategy { + private ws: WebSocket | null = null; + private reconnectAttempts = 0; + private reconnectTimer: NodeJS.Timeout | null = null + + constructor(private sshConfig: SSHConnectionConfig) { + super(sshConfig); + } + + + /** + * 建立 SSH WebSocket 连接 + */ + async connect(): Promise { + if (this.ws?.readyState === WebSocket.OPEN) { + console.log('[SSHConnectionStrategy] Already connected'); + return; + } + + this.notifyStatusChange('connecting'); + this.reconnectAttempts = 0; + + // 直接构建WebSocket URL(不使用sessionId) + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const wsUrl = `${protocol}//${window.location.host}/api/v1/server-ssh/connect/${this.sshConfig.serverId}?token=${this.sshConfig.token}`; + + console.log('[SSHConnectionStrategy] 连接WebSocket:', wsUrl); + await this.connectWebSocket(wsUrl); + } + + /** + * 建立WebSocket连接 + */ + private connectWebSocket(wsUrl: string): Promise { + return new Promise((resolve, reject) => { + try { + const ws = new WebSocket(wsUrl); + this.ws = ws; + + ws.onopen = () => { + console.log('🔗 SSH WebSocket connected:', wsUrl); + this.notifyStatusChange('connected'); + this.reconnectAttempts = 0; + resolve(); + }; + + ws.onmessage = (event) => { + this.handleMessage(event); + }; + + ws.onerror = (error) => { + console.error('[SSHConnectionStrategy] WebSocket error:', error); + this.notifyStatusChange('error'); + this.notifyError('WebSocket 连接错误'); + reject(error); + }; + + ws.onclose = () => { + console.log('[SSHConnectionStrategy] WebSocket closed'); + + // 只有在已连接状态下关闭才尝试重连 + if (this.status === 'connected' && this.config.autoReconnect) { + this.handleReconnect(); + } else { + this.notifyStatusChange('disconnected'); + } + }; + } catch (error) { + console.error('[SSHConnectionStrategy] Connection failed:', error); + this.notifyStatusChange('error'); + reject(error); + } + }); + } + + /** + * 断开连接 + */ + disconnect(): void { + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + + if (this.ws) { + this.ws.close(); + this.ws = null; + } + + this.notifyStatusChange('disconnected'); + console.log('[SSHConnectionStrategy] Disconnected'); + } + + /** + * 发送输入命令 + */ + sendInput(data: string): void { + if (this.ws?.readyState === WebSocket.OPEN) { + this.ws.send(JSON.stringify({ + type: 'input', + data: { + request: { + type: 'input', + command: data, + } + } + })); + } + } + + /** + * 发送终端尺寸变化 + */ + sendResize(rows: number, cols: number): void { + if (this.ws?.readyState === WebSocket.OPEN) { + this.ws.send(JSON.stringify({ + type: 'resize', + data: { + request: { + type: 'resize', + rows, + cols, + } + } + })); + } + } + + /** + * 处理接收到的消息 + */ + private handleMessage(event: MessageEvent): void { + 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) { + this.notifyMessage(actualData); + } + break; + + case 'error': + this.notifyError(actualData); + this.notifyStatusChange('error'); + break; + + case 'status': + this.notifyStatusChange(actualData as ConnectionStatus); + break; + } + } catch (error) { + console.error('[SSHConnectionStrategy] Failed to parse message:', error); + } + } + + /** + * 处理重连逻辑 + */ + private handleReconnect(): void { + const maxAttempts = this.config.maxReconnectAttempts ?? 5; + const interval = this.config.reconnectInterval ?? 3000; + + if (this.reconnectAttempts >= maxAttempts) { + console.log('[SSHConnectionStrategy] Max reconnect attempts reached'); + this.notifyStatusChange('error'); + this.notifyError('连接断开,已达到最大重连次数'); + return; + } + + this.reconnectAttempts++; + this.notifyStatusChange('reconnecting'); + + console.log(`[SSHConnectionStrategy] Reconnecting... (${this.reconnectAttempts}/${maxAttempts})`); + + this.reconnectTimer = setTimeout(() => { + this.connect().catch(error => { + console.error('[SSHConnectionStrategy] Reconnect failed:', error); + }); + }, interval); + } + + /** + * 清理资源 + */ + dispose(): void { + this.disconnect(); + this.clearListeners(); + } +} diff --git a/frontend/src/components/Terminal/strategies/index.ts b/frontend/src/components/Terminal/strategies/index.ts new file mode 100644 index 00000000..e0c6ce26 --- /dev/null +++ b/frontend/src/components/Terminal/strategies/index.ts @@ -0,0 +1,14 @@ +/** + * Connection Strategies - 连接策略层导出 + */ + +export { BaseConnectionStrategy } from './BaseConnectionStrategy'; +export { SSHConnectionStrategy } from './SSHConnectionStrategy'; +export type { + ConnectionStatus, + ConnectionConfig, + StatusChangeCallback, + MessageCallback, + ErrorCallback +} from './BaseConnectionStrategy'; +export type { SSHConnectionConfig } from './SSHConnectionStrategy'; diff --git a/frontend/src/components/Terminal/types.ts b/frontend/src/components/Terminal/types.ts index 600cc687..510a3b33 100644 --- a/frontend/src/components/Terminal/types.ts +++ b/frontend/src/components/Terminal/types.ts @@ -7,6 +7,86 @@ export type TerminalType = 'ssh' | 'k8s-pod' | 'docker-container'; export type ConnectionStatus = 'connecting' | 'connected' | 'reconnecting' | 'disconnected' | 'error'; +/** + * ======================================== + * 分屏相关类型 + * ======================================== + */ + +export type SplitDirection = 'up' | 'down' | 'left' | 'right'; +export type LayoutOrientation = 'horizontal' | 'vertical'; + +/** + * 终端标签页 + */ +export interface TerminalTab { + id: string; + title: string; + serverId: string | number; + serverName: string; + isActive: boolean; +} + +/** + * 编辑器组(叶子节点) + * 包含多个终端标签页 + */ +export interface EditorGroup { + type: 'group'; + id: string; + tabs: TerminalTab[]; + activeTabId: string; + size: number; // 在父容器中的占比 (0-100) +} + +/** + * 分屏容器(内部节点) + * 可以包含编辑器组或其他容器,实现嵌套布局 + */ +export interface SplitContainer { + type: 'container'; + id: string; + orientation: LayoutOrientation; + children: SplitNode[]; + size: number; // 在父容器中的占比 (0-100) +} + +/** + * 分屏节点(容器或组) + */ +export type SplitNode = SplitContainer | EditorGroup; + +/** + * 根布局 + */ +export interface SplitLayout { + root: SplitNode; +} + +/** + * 分屏操作类型 + */ +export type SplitAction = + | { type: 'SPLIT_UP' } + | { type: 'SPLIT_DOWN' } + | { type: 'SPLIT_LEFT' } + | { type: 'SPLIT_RIGHT' } + | { type: 'SPLIT_IN_GROUP' } + | { type: 'MOVE_UP' } + | { type: 'MOVE_DOWN' } + | { type: 'MOVE_LEFT' } + | { type: 'MOVE_RIGHT' } + | { type: 'CLOSE_TAB'; payload: { tabId: string } } + | { type: 'CLOSE_GROUP'; payload: { groupId: string } } + | { type: 'SWITCH_TAB'; payload: { tabId: string } } + | { type: 'ADD_TAB'; payload: { groupId: string; tab: TerminalTab } }; + +/** + * ======================================== + * Terminal 消息相关类型 + * ======================================== + */ + /** * WebSocket 消息格式 - 接收 */ @@ -38,9 +118,9 @@ export interface TerminalSendMessage { export interface TerminalConnectionConfig { /** 连接类型 */ type: TerminalType; - /** WebSocket URL */ - wsUrl: string; - /** 认证 Token */ + /** 服务器ID(仅SSH需要) */ + serverId?: string | number; + /** 认证token */ token?: string; /** 连接超时时间(毫秒) */ timeout?: number; @@ -109,6 +189,16 @@ export interface TerminalToolbarConfig { showFontSizeLabel?: boolean; /** 自定义额外操作 */ extraActions?: React.ReactNode; + /** 分屏操作 - 向上拆分 */ + onSplitUp?: () => void; + /** 分屏操作 - 向下拆分 */ + onSplitDown?: () => void; + /** 分屏操作 - 向左拆分 */ + onSplitLeft?: () => void; + /** 分屏操作 - 向右拆分 */ + onSplitRight?: () => void; + /** 分屏操作 - 在组中拆分 */ + onSplitInGroup?: () => void; } /** @@ -125,10 +215,18 @@ export interface TerminalProps { audit?: TerminalAuditConfig; /** 工具栏配置 */ toolbar?: TerminalToolbarConfig; + /** 是否为激活状态(用于Tab切换时重新focus) */ + isActive?: boolean; /** 连接状态变化回调 */ onStatusChange?: (status: ConnectionStatus) => void; /** 关闭就绪回调 */ onCloseReady?: () => void; /** 错误回调 */ onError?: (error: string) => void; + /** 分屏回调 */ + onSplitUp?: () => void; + onSplitDown?: () => void; + onSplitLeft?: () => void; + onSplitRight?: () => void; + onSplitInGroup?: () => void; } diff --git a/frontend/src/components/Terminal/useSplitView.ts b/frontend/src/components/Terminal/useSplitView.ts new file mode 100644 index 00000000..a14b20b3 --- /dev/null +++ b/frontend/src/components/Terminal/useSplitView.ts @@ -0,0 +1,384 @@ +/** + * Terminal 分屏管理 Hook + * 支持树形嵌套布局,参考 VS Code 的分屏逻辑 + */ +import { useState, useCallback } from 'react'; +import type { EditorGroup, TerminalTab, SplitDirection, SplitLayout, SplitNode, SplitContainer, LayoutOrientation } from './types'; +import { TerminalInstanceManager } from './core/TerminalInstanceManager'; + +interface UseSplitViewOptions { + initialTab: TerminalTab; +} + +export const useSplitView = ({ initialTab }: UseSplitViewOptions) => { + const [layout, setLayout] = useState(() => ({ + root: { + type: 'group', + id: 'group-1', + tabs: [initialTab], + activeTabId: initialTab.id, + size: 100, + } as EditorGroup, + })); + + const [activeGroupId, setActiveGroupId] = useState('group-1'); + + // 在树中查找组 + const findGroup = useCallback((node: SplitNode, groupId: string): EditorGroup | null => { + if (node.type === 'group') { + return node.id === groupId ? node : null; + } + for (const child of node.children) { + const found = findGroup(child, groupId); + if (found) return found; + } + return null; + }, []); + + // 查找父容器和节点索引 + const findParent = useCallback(( + node: SplitNode, + targetId: string, + parent: SplitContainer | null = null, + index: number = 0 + ): { parent: SplitContainer | null; index: number; node: SplitNode } | null => { + if (node.id === targetId) { + return { parent, index, node }; + } + if (node.type === 'container') { + for (let i = 0; i < node.children.length; i++) { + const result = findParent(node.children[i], targetId, node, i); + if (result) return result; + } + } + return null; + }, []); + + // 获取当前激活的组 + const getActiveGroup = useCallback(() => { + return findGroup(layout.root, activeGroupId); + }, [layout.root, activeGroupId, findGroup]); + + // 向指定方向拆分 + // 辅助函数:安全地更新节点的 size,保持其他属性引用不变 + const updateNodeSize = (node: SplitNode, newSize: number): SplitNode => { + if (node.size === newSize) { + return node; // size 没变,返回原对象 + } + // size 变了,创建新对象,但保持其他属性引用 + if (node.type === 'group') { + return { + ...node, + size: newSize, + // tabs 数组引用保持不变 + }; + } else { + return { + ...node, + size: newSize, + // children 数组引用保持不变 + }; + } + }; + + const splitToDirection = useCallback((direction: SplitDirection) => { + const activeGroup = getActiveGroup(); + if (!activeGroup) return; + + const activeTab = activeGroup.tabs.find(t => t.id === activeGroup.activeTabId); + if (!activeTab) return; + + const newGroupId = `group-${Date.now()}`; + const newTab: TerminalTab = { + id: `tab-${Date.now()}`, + title: activeTab.title, + serverId: activeTab.serverId, + serverName: activeTab.serverName, + isActive: true, + }; + + const newGroup: EditorGroup = { + type: 'group', + id: newGroupId, + tabs: [newTab], + activeTabId: newTab.id, + size: 50, + }; + + setLayout(prev => { + const result = findParent(prev.root, activeGroup.id); + if (!result) return prev; + + const { parent, index, node } = result; + const isHorizontalSplit = direction === 'left' || direction === 'right'; + const newOrientation: LayoutOrientation = isHorizontalSplit ? 'horizontal' : 'vertical'; + + // 如果是根节点 + if (!parent) { + const updatedNode = updateNodeSize(node, 50); + + const container: SplitContainer = { + type: 'container', + id: `container-${Date.now()}`, + orientation: newOrientation, + children: direction === 'right' || direction === 'down' + ? [updatedNode, newGroup] + : [newGroup, updatedNode], + size: 100, + }; + return { root: container }; + } + + // 父容器方向一致:直接插入 + if (parent.orientation === newOrientation) { + const newChildren = [...parent.children]; + const insertIndex = direction === 'right' || direction === 'down' ? index + 1 : index; + newChildren.splice(insertIndex, 0, newGroup); + + // 重新分配大小 + const sizePerChild = 100 / newChildren.length; + const resizedChildren = newChildren.map(child => updateNodeSize(child, sizePerChild)); + + return { + root: updateNode(prev.root, parent.id, { + ...parent, + children: resizedChildren, + }), + }; + } + + // 父容器方向不一致:创建新容器包裹当前节点和新节点 + const updatedNode = updateNodeSize(node, 50); + + const newContainer: SplitContainer = { + type: 'container', + id: `container-${Date.now()}`, + orientation: newOrientation, + children: direction === 'right' || direction === 'down' + ? [updatedNode, newGroup] + : [newGroup, updatedNode], + size: node.size, + }; + + const newChildren = [...parent.children]; + newChildren[index] = newContainer; + + return { + root: updateNode(prev.root, parent.id, { + ...parent, + children: newChildren, + }), + }; + }); + + setActiveGroupId(newGroupId); + }, [activeGroupId, getActiveGroup, findParent]); + + // 更新节点(递归)- 优化:只在真正需要更新时才创建新对象 + const updateNode = (node: SplitNode, targetId: string, newNode: SplitNode): SplitNode => { + if (node.id === targetId) { + return newNode; + } + if (node.type === 'container') { + // 递归更新子节点 + const newChildren = node.children.map(child => updateNode(child, targetId, newNode)); + + // 检查是否真的有子节点被更新了 + const hasChanged = newChildren.some((child, index) => child !== node.children[index]); + + // 如果没有变化,返回原节点(保持引用不变) + if (!hasChanged) { + return node; + } + + // 有变化才创建新对象 + return { + ...node, + children: newChildren, + }; + } + return node; + }; + + // 在组中拆分(创建新标签页) + const splitInGroup = useCallback(() => { + const activeGroup = getActiveGroup(); + if (!activeGroup) return; + + const activeTab = activeGroup.tabs.find(t => t.id === activeGroup.activeTabId); + if (!activeTab) return; + + const newTab: TerminalTab = { + id: `tab-${Date.now()}`, + title: activeTab.title, + serverId: activeTab.serverId, + serverName: activeTab.serverName, + isActive: true, + }; + + setLayout(prev => ({ + root: updateNode(prev.root, activeGroup.id, { + ...activeGroup, + tabs: [...activeGroup.tabs.map(t => ({ ...t, isActive: false })), newTab], + activeTabId: newTab.id, + }), + })); + }, [activeGroupId, getActiveGroup]); + + // 切换标签页 + const switchTab = useCallback((groupId: string, tabId: string) => { + setLayout(prev => { + const group = findGroup(prev.root, groupId); + if (!group) return prev; + + return { + root: updateNode(prev.root, groupId, { + ...group, + tabs: group.tabs.map(t => ({ ...t, isActive: t.id === tabId })), + activeTabId: tabId, + }), + }; + }); + setActiveGroupId(groupId); + }, [findGroup]); + + // 关闭标签页 + const closeTab = useCallback((groupId: string, tabId: string) => { + // 销毁 Terminal 实例 + const manager = TerminalInstanceManager.getInstance(); + manager.destroy(tabId); + console.log(`[useSplitView] 销毁 Terminal 实例: ${tabId}`); + + setLayout(prev => { + const group = findGroup(prev.root, groupId); + if (!group) return prev; + + if (group.tabs.length === 1) { + // 关闭整个组 + return closeGroup(prev, groupId); + } + + const newTabs = group.tabs.filter(t => t.id !== tabId); + const newActiveTabId = group.activeTabId === tabId ? newTabs[0].id : group.activeTabId; + + // 更新 isActive 状态 + const updatedTabs = newTabs.map(t => ({ + ...t, + isActive: t.id === newActiveTabId, + })); + + return { + root: updateNode(prev.root, groupId, { + ...group, + tabs: updatedTabs, + activeTabId: newActiveTabId, + }), + }; + }); + }, [findGroup]); + + // 关闭组 + const closeGroup = (prev: SplitLayout, groupId: string): SplitLayout => { + // 销毁组内所有 Terminal 实例 + const group = findGroup(prev.root, groupId); + if (group) { + const manager = TerminalInstanceManager.getInstance(); + group.tabs.forEach(tab => { + manager.destroy(tab.id); + console.log(`[useSplitView] 关闭组,销毁 Terminal 实例: ${tab.id}`); + }); + } + + const result = findParent(prev.root, groupId); + if (!result) return prev; + + const { parent } = result; + if (!parent) { + // 不能关闭根节点 + return prev; + } + + const newChildren = parent.children.filter(c => c.id !== groupId); + if (newChildren.length === 0) return prev; + + if (newChildren.length === 1) { + // 只剩一个子节点,提升它 + const child = newChildren[0]; + const grandParentResult = findParent(prev.root, parent.id); + + if (!grandParentResult || !grandParentResult.parent) { + // 父容器是根节点,提升子节点为新根 + return { root: { ...child, size: 100 } }; + } + + // 用子节点替换父容器 + const grandParent = grandParentResult.parent; + const newGrandChildren = grandParent.children.map(c => + c.id === parent.id ? { ...child, size: parent.size } : c + ); + + return { + root: updateNode(prev.root, grandParent.id, { + ...grandParent, + children: newGrandChildren, + }), + }; + } + + // 重新分配大小 + const sizePerChild = 100 / newChildren.length; + newChildren.forEach(child => { child.size = sizePerChild; }); + + return { + root: updateNode(prev.root, parent.id, { + ...parent, + children: newChildren, + }), + }; + }; + + // 调整分屏大小 + const resizeGroups = useCallback((nodeId: string, delta: number) => { + setLayout(prev => { + const result = findParent(prev.root, nodeId); + if (!result || !result.parent) return prev; + + const { parent, index } = result; + if (index >= parent.children.length - 1) return prev; + + const current = parent.children[index]; + const next = parent.children[index + 1]; + const totalSize = current.size + next.size; + + const newCurrentSize = Math.max(20, Math.min(totalSize - 20, current.size + delta)); + const newNextSize = totalSize - newCurrentSize; + + const newChildren = parent.children.map((child, i) => { + if (i === index) return { ...child, size: newCurrentSize }; + if (i === index + 1) return { ...child, size: newNextSize }; + return child; + }); + + return { + root: updateNode(prev.root, parent.id, { + ...parent, + children: newChildren, + }), + }; + }); + }, [findParent]); + + return { + layout, + activeGroupId, + splitUp: () => splitToDirection('up'), + splitDown: () => splitToDirection('down'), + splitLeft: () => splitToDirection('left'), + splitRight: () => splitToDirection('right'), + splitInGroup, + switchTab, + closeTab, + resizeGroups, + setActiveGroupId, + }; +}; diff --git a/frontend/src/components/Terminal/useTerminal.ts b/frontend/src/components/Terminal/useTerminal.ts deleted file mode 100644 index 6257df48..00000000 --- a/frontend/src/components/Terminal/useTerminal.ts +++ /dev/null @@ -1,226 +0,0 @@ -/** - * 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/SSHWindowManager.tsx b/frontend/src/pages/Resource/Server/List/components/SSHWindowManager.tsx index c0305cab..8ac593ae 100644 --- a/frontend/src/pages/Resource/Server/List/components/SSHWindowManager.tsx +++ b/frontend/src/pages/Resource/Server/List/components/SSHWindowManager.tsx @@ -1,14 +1,15 @@ /** * SSH 窗口管理器 - * 直接使用通用 Terminal 和 TerminalWindowManager + * 使用 TerminalSplitView 支持分屏功能 */ import React from 'react'; import { - TerminalWindowManager, - Terminal, + TerminalWindowManager, + TerminalSplitView, + type TerminalTab, type TerminalConnectionConfig, type TerminalAuditConfig, - type TerminalToolbarConfig + type TerminalToolbarConfig, } from '@/components/Terminal'; import type { ServerResponse } from '../types'; @@ -23,29 +24,37 @@ export const SSHWindowManager: React.FC = ({ onOpenWindow 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}`; + renderTerminal={(windowId, server, { onCloseReady, onStatusChange }) => { + // 创建初始 Tab + const initialTab: TerminalTab = { + id: `tab-${Date.now()}`, + title: `${server.serverName} - ${server.hostIp}`, + serverId: server.id, + serverName: server.serverName, + isActive: true, + }; - const connectionConfig: TerminalConnectionConfig = { - type: 'ssh', - wsUrl, - token: token || undefined, - autoReconnect: true, - reconnectInterval: 3000, - maxReconnectAttempts: 5, + // 连接配置函数 + const getConnectionConfig = (tab: TerminalTab): TerminalConnectionConfig => { + const token = localStorage.getItem('token'); + return { + type: 'ssh', + serverId: tab.serverId, + token: token || undefined, + autoReconnect: true, + reconnectInterval: 3000, + maxReconnectAttempts: 5, + }; }; // 审计配置 - const auditConfig: TerminalAuditConfig = { + const getAuditConfig = (): TerminalAuditConfig => ({ enabled: true, companyName: '链宇技术有限公司', - }; + }); // 工具栏配置 - const toolbarConfig: TerminalToolbarConfig = { + const getToolbarConfig = (): TerminalToolbarConfig => ({ show: true, showSearch: true, showClear: true, @@ -53,16 +62,14 @@ export const SSHWindowManager: React.FC = ({ onOpenWindow showFontSize: true, showStatus: true, showFontSizeLabel: true, - }; + }); return ( - ); }}