重写ssh前端组件,通用化
This commit is contained in:
parent
f6abb48333
commit
1fd72bf007
71
frontend/src/components/Terminal/SplitDivider.tsx
Normal file
71
frontend/src/components/Terminal/SplitDivider.tsx
Normal file
@ -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<SplitDividerProps> = ({ 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 (
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
${orientation === 'horizontal' ? 'w-1 cursor-col-resize' : 'h-1 cursor-row-resize'}
|
||||||
|
${isDragging ? 'bg-blue-500' : 'bg-gray-300 dark:bg-gray-700 hover:bg-blue-400'}
|
||||||
|
transition-colors
|
||||||
|
relative
|
||||||
|
group
|
||||||
|
`}
|
||||||
|
onMouseDown={handleMouseDown}
|
||||||
|
>
|
||||||
|
{/* 拖动热区 - 增加可拖动区域 */}
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
absolute
|
||||||
|
${orientation === 'horizontal'
|
||||||
|
? '-left-1 -right-1 top-0 bottom-0'
|
||||||
|
: 'left-0 right-0 -top-1 -bottom-1'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -2,17 +2,18 @@
|
|||||||
* Terminal 组件 - 通用终端
|
* Terminal 组件 - 通用终端
|
||||||
* 支持 SSH、K8s Pod、Docker Container 等多种场景
|
* 支持 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 'xterm/css/xterm.css';
|
||||||
import styles from './index.module.less';
|
import styles from './index.module.less';
|
||||||
import { useTerminal } from './useTerminal';
|
|
||||||
import { TerminalToolbar } from './TerminalToolbar';
|
import { TerminalToolbar } from './TerminalToolbar';
|
||||||
import type { TerminalProps, TerminalToolbarConfig } from './types';
|
import type { TerminalProps, TerminalToolbarConfig } from './types';
|
||||||
import { TERMINAL_THEMES, getThemeByName } from './themes';
|
import { TERMINAL_THEMES, getThemeByName } from './themes';
|
||||||
import { Loader2, XCircle, ChevronUp, ChevronDown, X } from 'lucide-react';
|
import { Loader2, XCircle, ChevronUp, ChevronDown, X } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import { message } from 'antd';
|
import { message } from 'antd';
|
||||||
|
import { TerminalInstanceManager } from './core/TerminalInstanceManager';
|
||||||
|
import type { ConnectionStatus } from './strategies/BaseConnectionStrategy';
|
||||||
|
|
||||||
export const Terminal: React.FC<TerminalProps> = ({
|
export const Terminal: React.FC<TerminalProps> = ({
|
||||||
id,
|
id,
|
||||||
@ -20,37 +21,25 @@ export const Terminal: React.FC<TerminalProps> = ({
|
|||||||
display,
|
display,
|
||||||
audit,
|
audit,
|
||||||
toolbar,
|
toolbar,
|
||||||
|
isActive,
|
||||||
onStatusChange,
|
onStatusChange,
|
||||||
onCloseReady,
|
onCloseReady,
|
||||||
onError,
|
onError,
|
||||||
|
onSplitUp,
|
||||||
|
onSplitDown,
|
||||||
|
onSplitLeft,
|
||||||
|
onSplitRight,
|
||||||
|
onSplitInGroup,
|
||||||
}) => {
|
}) => {
|
||||||
|
const terminalRef = useRef<HTMLDivElement>(null);
|
||||||
|
const instanceRef = useRef<ReturnType<TerminalInstanceManager['getOrCreate']> | null>(null);
|
||||||
|
|
||||||
const [fontSize, setFontSize] = useState(display?.fontSize ?? 14);
|
const [fontSize, setFontSize] = useState(display?.fontSize ?? 14);
|
||||||
const [showSearch, setShowSearch] = useState(false);
|
const [showSearch, setShowSearch] = useState(false);
|
||||||
const [currentTheme, setCurrentTheme] = useState('dark');
|
const [currentTheme, setCurrentTheme] = useState('dark');
|
||||||
const [auditShown, setAuditShown] = useState(false);
|
const [auditShown, setAuditShown] = useState(false);
|
||||||
|
const [connectionStatus, setConnectionStatus] = useState<ConnectionStatus>('disconnected'); // 初始状态,会被实例状态覆盖
|
||||||
const {
|
const [errorMessage, setErrorMessage] = useState<string>('');
|
||||||
terminalRef,
|
|
||||||
terminalInstance,
|
|
||||||
fitAddon,
|
|
||||||
searchAddon,
|
|
||||||
connectionStatus,
|
|
||||||
errorMessage,
|
|
||||||
initializeTerminal,
|
|
||||||
connectWebSocket,
|
|
||||||
sendInput,
|
|
||||||
sendResize,
|
|
||||||
cleanup,
|
|
||||||
} = useTerminal({
|
|
||||||
connection,
|
|
||||||
display: {
|
|
||||||
...display,
|
|
||||||
fontSize,
|
|
||||||
theme: getThemeByName(currentTheme).theme,
|
|
||||||
},
|
|
||||||
onStatusChange,
|
|
||||||
onError,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 默认工具栏配置
|
// 默认工具栏配置
|
||||||
const toolbarConfig: TerminalToolbarConfig = {
|
const toolbarConfig: TerminalToolbarConfig = {
|
||||||
@ -64,37 +53,72 @@ export const Terminal: React.FC<TerminalProps> = ({
|
|||||||
extraActions: toolbar?.extraActions,
|
extraActions: toolbar?.extraActions,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 初始化
|
// 初始化 Terminal 实例
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const terminal = initializeTerminal();
|
console.log(`[Terminal ${id}] 组件挂载 - 使用实例管理器`);
|
||||||
if (!terminal) return;
|
|
||||||
|
|
||||||
// 监听终端输入
|
const manager = TerminalInstanceManager.getInstance();
|
||||||
terminal.onData((data) => {
|
|
||||||
sendInput(data);
|
// 获取或创建实例(Manager 内部会处理连接策略的创建和复用)
|
||||||
|
const instance = manager.getOrCreate(id, {
|
||||||
|
id,
|
||||||
|
connectionConfig: connection, // 传配置,不传策略实例
|
||||||
|
display: {
|
||||||
|
...display,
|
||||||
|
fontSize,
|
||||||
|
theme: getThemeByName(currentTheme).theme,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// 监听终端尺寸变化
|
instanceRef.current = instance;
|
||||||
terminal.onResize((size) => {
|
|
||||||
sendResize(size.rows, size.cols);
|
// 立即同步当前状态(避免初始状态不一致导致闪烁)
|
||||||
|
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(() => {
|
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);
|
}, 300);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
console.log(`[Terminal ${id}] 组件卸载 - 只unmount,不销毁实例`);
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
cleanup();
|
unsubscribe();
|
||||||
|
instance.unmount();
|
||||||
|
// 注意:不调用 manager.destroy(),实例会被复用
|
||||||
};
|
};
|
||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
// 监听窗口大小变化,自动调整终端尺寸
|
// 监听窗口大小变化,自动调整终端尺寸
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleResize = () => {
|
const handleResize = () => {
|
||||||
|
const fitAddon = instanceRef.current?.getFitAddon();
|
||||||
if (fitAddon) {
|
if (fitAddon) {
|
||||||
// 延迟执行以确保窗口大小已经更新
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
fitAddon.fit();
|
fitAddon.fit();
|
||||||
}, 100);
|
}, 100);
|
||||||
@ -106,43 +130,45 @@ export const Terminal: React.FC<TerminalProps> = ({
|
|||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('resize', handleResize);
|
window.removeEventListener('resize', handleResize);
|
||||||
};
|
};
|
||||||
}, [fitAddon]);
|
}, []);
|
||||||
|
|
||||||
// 显示审计警告(只显示一次)
|
// 显示审计警告(只显示一次)
|
||||||
useEffect(() => {
|
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 companyName = audit.companyName || '';
|
||||||
const customMessage = audit.message;
|
const customMessage = audit.message;
|
||||||
|
|
||||||
if (customMessage) {
|
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 {
|
} else {
|
||||||
terminalInstance.writeln('\r\n\x1b[33m┌─────────────────────────────────────────────────────────────\x1b[0m');
|
instance.writeln('\r\n\x1b[33m┌─────────────────────────────────────────────────────────────\x1b[0m');
|
||||||
terminalInstance.writeln(`\x1b[33m│ ⚠️ ${companyName} - 安全提示\x1b[0m`);
|
instance.writeln(`\x1b[33m│ ⚠️ ${companyName} - 安全提示\x1b[0m`);
|
||||||
terminalInstance.writeln('\x1b[33m│ 本次会话将被全程审计记录\x1b[0m');
|
instance.writeln('\x1b[33m│ 本次会话将被全程审计记录\x1b[0m');
|
||||||
terminalInstance.writeln('\x1b[33m│ • 所有操作命令、输入、输出都将被完整记录\x1b[0m');
|
instance.writeln('\x1b[33m│ • 所有操作命令、输入、输出都将被完整记录\x1b[0m');
|
||||||
terminalInstance.writeln('\x1b[33m│ • 审计日志用于安全审查、故障排查和合规要求\x1b[0m');
|
instance.writeln('\x1b[33m│ • 审计日志用于安全审查、故障排查和合规要求\x1b[0m');
|
||||||
terminalInstance.writeln('\x1b[33m│ • 请规范操作,遵守企业信息安全管理制度\x1b[0m');
|
instance.writeln('\x1b[33m│ • 请规范操作,遵守企业信息安全管理制度\x1b[0m');
|
||||||
terminalInstance.writeln('\x1b[33m└─────────────────────────────────────────────────────────────\x1b[0m\r\n');
|
instance.writeln('\x1b[33m└─────────────────────────────────────────────────────────────\x1b[0m\r\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
setAuditShown(true);
|
setAuditShown(true);
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
fitAddon?.fit();
|
instance.getFitAddon()?.fit();
|
||||||
}, 100);
|
}, 100);
|
||||||
}
|
}
|
||||||
}, [connectionStatus, audit, terminalInstance, fitAddon, auditShown]);
|
}, [connectionStatus, audit, auditShown]);
|
||||||
|
|
||||||
// 重连处理
|
// 重连处理
|
||||||
const handleReconnect = useCallback(() => {
|
const handleReconnect = useCallback(() => {
|
||||||
cleanup();
|
if (instanceRef.current) {
|
||||||
setAuditShown(false); // 重置审计警告标记,重连后重新显示
|
instanceRef.current.disconnect();
|
||||||
setTimeout(() => {
|
setAuditShown(false); // 重置审计警告标记,重连后重新显示
|
||||||
initializeTerminal();
|
setTimeout(() => {
|
||||||
connectWebSocket();
|
instanceRef.current?.connect();
|
||||||
}, 100);
|
}, 100);
|
||||||
}, [cleanup, initializeTerminal, connectWebSocket]);
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
// 搜索
|
// 搜索
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
@ -160,41 +186,44 @@ export const Terminal: React.FC<TerminalProps> = ({
|
|||||||
|
|
||||||
const handleSearchChange = useCallback((value: string) => {
|
const handleSearchChange = useCallback((value: string) => {
|
||||||
setSearchQuery(value);
|
setSearchQuery(value);
|
||||||
if (searchAddon && terminalInstance) {
|
const searchAddon = instanceRef.current?.getSearchAddon();
|
||||||
|
if (searchAddon) {
|
||||||
searchAddon.findNext(value, { incremental: true });
|
searchAddon.findNext(value, { incremental: true });
|
||||||
}
|
}
|
||||||
}, [searchAddon, terminalInstance]);
|
}, []);
|
||||||
|
|
||||||
const handleSearchNext = useCallback(() => {
|
const handleSearchNext = useCallback(() => {
|
||||||
|
const searchAddon = instanceRef.current?.getSearchAddon();
|
||||||
if (searchAddon && searchQuery) {
|
if (searchAddon && searchQuery) {
|
||||||
searchAddon.findNext(searchQuery);
|
searchAddon.findNext(searchQuery);
|
||||||
}
|
}
|
||||||
}, [searchAddon, searchQuery]);
|
}, [searchQuery]);
|
||||||
|
|
||||||
const handleSearchPrev = useCallback(() => {
|
const handleSearchPrev = useCallback(() => {
|
||||||
|
const searchAddon = instanceRef.current?.getSearchAddon();
|
||||||
if (searchAddon && searchQuery) {
|
if (searchAddon && searchQuery) {
|
||||||
searchAddon.findPrevious(searchQuery);
|
searchAddon.findPrevious(searchQuery);
|
||||||
}
|
}
|
||||||
}, [searchAddon, searchQuery]);
|
}, [searchQuery]);
|
||||||
|
|
||||||
const handleCloseSearch = useCallback(() => {
|
const handleCloseSearch = useCallback(() => {
|
||||||
setShowSearch(false);
|
setShowSearch(false);
|
||||||
setSearchQuery('');
|
setSearchQuery('');
|
||||||
searchAddon?.clearDecorations();
|
instanceRef.current?.getSearchAddon()?.clearDecorations();
|
||||||
}, [searchAddon]);
|
}, []);
|
||||||
|
|
||||||
// 清屏
|
// 清屏
|
||||||
const handleClear = useCallback(() => {
|
const handleClear = useCallback(() => {
|
||||||
if (terminalInstance) {
|
if (instanceRef.current) {
|
||||||
terminalInstance.clear();
|
instanceRef.current.clear();
|
||||||
message.success('终端已清屏');
|
message.success('终端已清屏');
|
||||||
}
|
}
|
||||||
}, [terminalInstance]);
|
}, []);
|
||||||
|
|
||||||
// 复制
|
// 复制
|
||||||
const handleCopy = useCallback(() => {
|
const handleCopy = useCallback(() => {
|
||||||
if (terminalInstance) {
|
if (instanceRef.current) {
|
||||||
const selection = terminalInstance.getSelection();
|
const selection = instanceRef.current.getSelection();
|
||||||
if (selection) {
|
if (selection) {
|
||||||
navigator.clipboard.writeText(selection);
|
navigator.clipboard.writeText(selection);
|
||||||
message.success('已复制到剪贴板');
|
message.success('已复制到剪贴板');
|
||||||
@ -202,37 +231,35 @@ export const Terminal: React.FC<TerminalProps> = ({
|
|||||||
message.warning('请先选中要复制的内容');
|
message.warning('请先选中要复制的内容');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [terminalInstance]);
|
}, []);
|
||||||
|
|
||||||
// 放大字体
|
// 放大字体
|
||||||
const handleZoomIn = useCallback(() => {
|
const handleZoomIn = useCallback(() => {
|
||||||
const newSize = Math.min(fontSize + 2, 32);
|
const newSize = Math.min(fontSize + 2, 32);
|
||||||
setFontSize(newSize);
|
setFontSize(newSize);
|
||||||
if (terminalInstance) {
|
if (instanceRef.current) {
|
||||||
terminalInstance.options.fontSize = newSize;
|
instanceRef.current.updateDisplay({ fontSize: newSize });
|
||||||
setTimeout(() => fitAddon?.fit(), 50);
|
|
||||||
}
|
}
|
||||||
}, [fontSize, terminalInstance, fitAddon]);
|
}, [fontSize]);
|
||||||
|
|
||||||
// 缩小字体
|
// 缩小字体
|
||||||
const handleZoomOut = useCallback(() => {
|
const handleZoomOut = useCallback(() => {
|
||||||
const newSize = Math.max(fontSize - 2, 10);
|
const newSize = Math.max(fontSize - 2, 10);
|
||||||
setFontSize(newSize);
|
setFontSize(newSize);
|
||||||
if (terminalInstance) {
|
if (instanceRef.current) {
|
||||||
terminalInstance.options.fontSize = newSize;
|
instanceRef.current.updateDisplay({ fontSize: newSize });
|
||||||
setTimeout(() => fitAddon?.fit(), 50);
|
|
||||||
}
|
}
|
||||||
}, [fontSize, terminalInstance, fitAddon]);
|
}, [fontSize]);
|
||||||
|
|
||||||
// 切换主题
|
// 切换主题
|
||||||
const handleThemeChange = useCallback((themeName: string) => {
|
const handleThemeChange = useCallback((themeName: string) => {
|
||||||
setCurrentTheme(themeName);
|
setCurrentTheme(themeName);
|
||||||
if (terminalInstance) {
|
if (instanceRef.current) {
|
||||||
const theme = getThemeByName(themeName);
|
const theme = getThemeByName(themeName);
|
||||||
terminalInstance.options.theme = theme.theme;
|
instanceRef.current.updateDisplay({ theme: theme.theme });
|
||||||
message.success(`已切换到 ${theme.label} 主题`);
|
message.success(`已切换到 ${theme.label} 主题`);
|
||||||
}
|
}
|
||||||
}, [terminalInstance]);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.terminalWrapper}>
|
<div className={styles.terminalWrapper}>
|
||||||
@ -250,6 +277,11 @@ export const Terminal: React.FC<TerminalProps> = ({
|
|||||||
onZoomOut={handleZoomOut}
|
onZoomOut={handleZoomOut}
|
||||||
onReconnect={handleReconnect}
|
onReconnect={handleReconnect}
|
||||||
onThemeChange={handleThemeChange}
|
onThemeChange={handleThemeChange}
|
||||||
|
onSplitUp={onSplitUp}
|
||||||
|
onSplitDown={onSplitDown}
|
||||||
|
onSplitLeft={onSplitLeft}
|
||||||
|
onSplitRight={onSplitRight}
|
||||||
|
onSplitInGroup={onSplitInGroup}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 终端区域 */}
|
{/* 终端区域 */}
|
||||||
|
|||||||
256
frontend/src/components/Terminal/TerminalSplitView.tsx
Normal file
256
frontend/src/components/Terminal/TerminalSplitView.tsx
Normal file
@ -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<SplitNodeRendererProps> = ({
|
||||||
|
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<string, TerminalConnectionConfig> = {};
|
||||||
|
node.tabs.forEach(tab => {
|
||||||
|
connectionConfigs[tab.id] = getConnectionConfig(tab);
|
||||||
|
});
|
||||||
|
const auditConfig = getAuditConfig();
|
||||||
|
const toolbarConfig = getToolbarConfig();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full w-full flex flex-col bg-white dark:bg-gray-900">
|
||||||
|
{/* Tab栏 */}
|
||||||
|
<div className="flex-shrink-0 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="flex items-center px-2 py-1 gap-1 overflow-x-auto">
|
||||||
|
{node.tabs.map((tab) => (
|
||||||
|
<div
|
||||||
|
key={tab.id}
|
||||||
|
className={`flex items-center gap-1 px-3 py-1.5 rounded-t cursor-pointer transition-colors ${
|
||||||
|
tab.isActive
|
||||||
|
? 'bg-gray-100 dark:bg-gray-800 border-b-2 border-blue-500'
|
||||||
|
: 'hover:bg-gray-50 dark:hover:bg-gray-800/50'
|
||||||
|
}`}
|
||||||
|
onClick={() => onTabClick(node.id, tab.id)}
|
||||||
|
>
|
||||||
|
<span className="text-xs truncate max-w-[150px]" title={tab.title}>
|
||||||
|
{tab.serverName}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
className="hover:bg-gray-300 dark:hover:bg-gray-600 rounded p-0.5"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onTabClose(node.id, tab.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
className="flex items-center justify-center w-8 h-8 hover:bg-gray-200 dark:hover:bg-gray-700 rounded"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onNewTab();
|
||||||
|
}}
|
||||||
|
title="新建终端 (Cmd+T)"
|
||||||
|
>
|
||||||
|
<Plus className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 终端内容 */}
|
||||||
|
<div className="flex-1 overflow-hidden relative">
|
||||||
|
{node.tabs.map((tab) => (
|
||||||
|
<div
|
||||||
|
key={tab.id}
|
||||||
|
className="absolute inset-0"
|
||||||
|
style={{ display: tab.isActive ? 'block' : 'none' }}
|
||||||
|
>
|
||||||
|
<Terminal
|
||||||
|
id={tab.id}
|
||||||
|
connection={connectionConfigs[tab.id]}
|
||||||
|
audit={auditConfig}
|
||||||
|
toolbar={toolbarConfig}
|
||||||
|
isActive={tab.isActive}
|
||||||
|
onSplitUp={onSplitUp}
|
||||||
|
onSplitDown={onSplitDown}
|
||||||
|
onSplitLeft={onSplitLeft}
|
||||||
|
onSplitRight={onSplitRight}
|
||||||
|
onSplitInGroup={onNewTab}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 渲染容器(内部节点)- 递归渲染子节点
|
||||||
|
const isHorizontal = node.orientation === 'horizontal';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`h-full w-full flex ${isHorizontal ? 'flex-row' : 'flex-col'}`}>
|
||||||
|
{node.children.map((child, index) => (
|
||||||
|
<React.Fragment key={child.id}>
|
||||||
|
<div
|
||||||
|
key={child.id}
|
||||||
|
className="flex-shrink-0 flex-grow-0"
|
||||||
|
style={{
|
||||||
|
[isHorizontal ? 'width' : 'height']: `${child.size}%`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SplitNodeRendererComponent
|
||||||
|
key={child.id}
|
||||||
|
node={child}
|
||||||
|
activeGroupId={activeGroupId}
|
||||||
|
onTabClick={onTabClick}
|
||||||
|
onTabClose={onTabClose}
|
||||||
|
onNewTab={onNewTab}
|
||||||
|
onSplitUp={onSplitUp}
|
||||||
|
onSplitDown={onSplitDown}
|
||||||
|
onSplitLeft={onSplitLeft}
|
||||||
|
onSplitRight={onSplitRight}
|
||||||
|
onFocus={onFocus}
|
||||||
|
onResize={onResize}
|
||||||
|
getConnectionConfig={getConnectionConfig}
|
||||||
|
getAuditConfig={getAuditConfig}
|
||||||
|
getToolbarConfig={getToolbarConfig}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 分隔条 - 不在最后一个节点之后显示 */}
|
||||||
|
{index < node.children.length - 1 && (
|
||||||
|
<SplitDivider
|
||||||
|
orientation={node.orientation}
|
||||||
|
onResize={(delta) => onResize(child.id, delta)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 使用 React.memo 优化,避免不必要的重新渲染
|
||||||
|
const SplitNodeRenderer = React.memo(SplitNodeRendererComponent);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Terminal 分屏视图 - 主组件
|
||||||
|
* 管理分屏状态并渲染分屏树
|
||||||
|
*/
|
||||||
|
export const TerminalSplitView: React.FC<TerminalSplitViewProps> = ({
|
||||||
|
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 (
|
||||||
|
<div className="terminal-split-container h-full w-full">
|
||||||
|
<SplitNodeRenderer
|
||||||
|
node={layout.root}
|
||||||
|
activeGroupId={activeGroupId}
|
||||||
|
onTabClick={switchTab}
|
||||||
|
onTabClose={closeTab}
|
||||||
|
onNewTab={splitInGroup}
|
||||||
|
onSplitUp={splitUp}
|
||||||
|
onSplitDown={splitDown}
|
||||||
|
onSplitLeft={splitLeft}
|
||||||
|
onSplitRight={splitRight}
|
||||||
|
onFocus={setActiveGroupId}
|
||||||
|
onResize={resizeGroups}
|
||||||
|
getConnectionConfig={getConnectionConfig}
|
||||||
|
getAuditConfig={getAuditConfig}
|
||||||
|
getToolbarConfig={getToolbarConfig}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -5,7 +5,14 @@ import React from 'react';
|
|||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
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 { ConnectionStatus, TerminalToolbarConfig } from './types';
|
||||||
import type { TerminalTheme } from './themes';
|
import type { TerminalTheme } from './themes';
|
||||||
import styles from './index.module.less';
|
import styles from './index.module.less';
|
||||||
@ -23,6 +30,12 @@ interface TerminalToolbarProps {
|
|||||||
onZoomOut?: () => void;
|
onZoomOut?: () => void;
|
||||||
onReconnect?: () => void;
|
onReconnect?: () => void;
|
||||||
onThemeChange?: (themeName: string) => void;
|
onThemeChange?: (themeName: string) => void;
|
||||||
|
// 分屏操作
|
||||||
|
onSplitUp?: () => void;
|
||||||
|
onSplitDown?: () => void;
|
||||||
|
onSplitLeft?: () => void;
|
||||||
|
onSplitRight?: () => void;
|
||||||
|
onSplitInGroup?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||||
@ -38,6 +51,11 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
|||||||
onZoomOut,
|
onZoomOut,
|
||||||
onReconnect,
|
onReconnect,
|
||||||
onThemeChange,
|
onThemeChange,
|
||||||
|
onSplitUp,
|
||||||
|
onSplitDown,
|
||||||
|
onSplitLeft,
|
||||||
|
onSplitRight,
|
||||||
|
onSplitInGroup,
|
||||||
}) => {
|
}) => {
|
||||||
if (!config.show) return null;
|
if (!config.show) return null;
|
||||||
|
|
||||||
@ -145,6 +163,63 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
|||||||
</Select>
|
</Select>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{/* 分屏菜单 */}
|
||||||
|
{(onSplitUp || onSplitDown || onSplitLeft || onSplitRight || onSplitInGroup) && (
|
||||||
|
<>
|
||||||
|
<div className="h-4 w-px bg-gray-300 dark:bg-gray-600 mx-1" />
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 px-2 relative z-50"
|
||||||
|
title="分屏"
|
||||||
|
>
|
||||||
|
<SplitSquareVertical className="h-3.5 w-3.5 mr-1" />
|
||||||
|
<ChevronDown className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="z-[9999]">
|
||||||
|
{onSplitUp && (
|
||||||
|
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); onSplitUp(); }}>
|
||||||
|
<SplitSquareHorizontal className="h-4 w-4 mr-2" />
|
||||||
|
向上拆分
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
{onSplitDown && (
|
||||||
|
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); onSplitDown(); }}>
|
||||||
|
<SplitSquareHorizontal className="h-4 w-4 mr-2" />
|
||||||
|
向下拆分
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
{(onSplitUp || onSplitDown) && (onSplitLeft || onSplitRight) && (
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
)}
|
||||||
|
{onSplitLeft && (
|
||||||
|
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); onSplitLeft(); }}>
|
||||||
|
<SplitSquareVertical className="h-4 w-4 mr-2" />
|
||||||
|
向左拆分
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
{onSplitRight && (
|
||||||
|
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); onSplitRight(); }}>
|
||||||
|
<SplitSquareVertical className="h-4 w-4 mr-2" />
|
||||||
|
向右拆分
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
{onSplitInGroup && (
|
||||||
|
<>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); onSplitInGroup(); }}>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
在组中拆分
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{(connectionStatus === 'disconnected' || connectionStatus === 'error') && (
|
{(connectionStatus === 'disconnected' || connectionStatus === 'error') && (
|
||||||
<>
|
<>
|
||||||
<div className="h-4 w-px bg-gray-300 dark:bg-gray-600 mx-1" />
|
<div className="h-4 w-px bg-gray-300 dark:bg-gray-600 mx-1" />
|
||||||
|
|||||||
317
frontend/src/components/Terminal/core/TerminalInstance.ts
Normal file
317
frontend/src/components/Terminal/core/TerminalInstance.ts
Normal file
@ -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<StateChangeCallback> = 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<void> {
|
||||||
|
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<TerminalDisplayConfig>): 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
157
frontend/src/components/Terminal/core/TerminalInstanceManager.ts
Normal file
157
frontend/src/components/Terminal/core/TerminalInstanceManager.ts
Normal file
@ -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<string, TerminalInstance> = new Map();
|
||||||
|
private connectionStrategies: Map<string, BaseConnectionStrategy> = 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
11
frontend/src/components/Terminal/core/index.ts
Normal file
11
frontend/src/components/Terminal/core/index.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
/**
|
||||||
|
* Terminal Core - 核心层导出
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { TerminalInstance } from './TerminalInstance';
|
||||||
|
export { TerminalInstanceManager } from './TerminalInstanceManager';
|
||||||
|
export type {
|
||||||
|
TerminalInstanceConfig,
|
||||||
|
TerminalInstanceCreateConfig,
|
||||||
|
TerminalDisplayConfig
|
||||||
|
} from './TerminalInstance';
|
||||||
@ -3,9 +3,21 @@
|
|||||||
* 通用终端组件,支持 SSH、K8s Pod、Docker Container 等多种场景
|
* 通用终端组件,支持 SSH、K8s Pod、Docker Container 等多种场景
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// UI Components
|
||||||
export { Terminal } from './Terminal';
|
export { Terminal } from './Terminal';
|
||||||
export { useTerminal } from './useTerminal';
|
|
||||||
export { TerminalWindowManager } from './TerminalWindowManager';
|
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 { TERMINAL_THEMES, getThemeByName } from './themes';
|
||||||
export type { TerminalWindow } from './TerminalWindowManager';
|
export type { TerminalWindow } from './TerminalWindowManager';
|
||||||
export type { TerminalTheme } from './themes';
|
export type { TerminalTheme } from './themes';
|
||||||
@ -20,3 +32,13 @@ export type {
|
|||||||
TerminalToolbarConfig,
|
TerminalToolbarConfig,
|
||||||
TerminalProps,
|
TerminalProps,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
export type {
|
||||||
|
SplitDirection,
|
||||||
|
LayoutOrientation,
|
||||||
|
TerminalTab,
|
||||||
|
EditorGroup as EditorGroupType,
|
||||||
|
SplitContainer,
|
||||||
|
SplitNode,
|
||||||
|
SplitLayout,
|
||||||
|
SplitAction,
|
||||||
|
} from './types';
|
||||||
|
|||||||
@ -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<StatusChangeCallback> = new Set();
|
||||||
|
protected messageListeners: Set<MessageCallback> = new Set();
|
||||||
|
protected errorListeners: Set<ErrorCallback> = new Set();
|
||||||
|
|
||||||
|
constructor(protected config: ConnectionConfig) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 建立连接
|
||||||
|
*/
|
||||||
|
abstract connect(): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 断开连接
|
||||||
|
*/
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
14
frontend/src/components/Terminal/strategies/index.ts
Normal file
14
frontend/src/components/Terminal/strategies/index.ts
Normal file
@ -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';
|
||||||
@ -7,6 +7,86 @@ export type TerminalType = 'ssh' | 'k8s-pod' | 'docker-container';
|
|||||||
|
|
||||||
export type ConnectionStatus = 'connecting' | 'connected' | 'reconnecting' | 'disconnected' | 'error';
|
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 消息格式 - 接收
|
* WebSocket 消息格式 - 接收
|
||||||
*/
|
*/
|
||||||
@ -38,9 +118,9 @@ export interface TerminalSendMessage {
|
|||||||
export interface TerminalConnectionConfig {
|
export interface TerminalConnectionConfig {
|
||||||
/** 连接类型 */
|
/** 连接类型 */
|
||||||
type: TerminalType;
|
type: TerminalType;
|
||||||
/** WebSocket URL */
|
/** 服务器ID(仅SSH需要) */
|
||||||
wsUrl: string;
|
serverId?: string | number;
|
||||||
/** 认证 Token */
|
/** 认证token */
|
||||||
token?: string;
|
token?: string;
|
||||||
/** 连接超时时间(毫秒) */
|
/** 连接超时时间(毫秒) */
|
||||||
timeout?: number;
|
timeout?: number;
|
||||||
@ -109,6 +189,16 @@ export interface TerminalToolbarConfig {
|
|||||||
showFontSizeLabel?: boolean;
|
showFontSizeLabel?: boolean;
|
||||||
/** 自定义额外操作 */
|
/** 自定义额外操作 */
|
||||||
extraActions?: React.ReactNode;
|
extraActions?: React.ReactNode;
|
||||||
|
/** 分屏操作 - 向上拆分 */
|
||||||
|
onSplitUp?: () => void;
|
||||||
|
/** 分屏操作 - 向下拆分 */
|
||||||
|
onSplitDown?: () => void;
|
||||||
|
/** 分屏操作 - 向左拆分 */
|
||||||
|
onSplitLeft?: () => void;
|
||||||
|
/** 分屏操作 - 向右拆分 */
|
||||||
|
onSplitRight?: () => void;
|
||||||
|
/** 分屏操作 - 在组中拆分 */
|
||||||
|
onSplitInGroup?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -125,10 +215,18 @@ export interface TerminalProps {
|
|||||||
audit?: TerminalAuditConfig;
|
audit?: TerminalAuditConfig;
|
||||||
/** 工具栏配置 */
|
/** 工具栏配置 */
|
||||||
toolbar?: TerminalToolbarConfig;
|
toolbar?: TerminalToolbarConfig;
|
||||||
|
/** 是否为激活状态(用于Tab切换时重新focus) */
|
||||||
|
isActive?: boolean;
|
||||||
/** 连接状态变化回调 */
|
/** 连接状态变化回调 */
|
||||||
onStatusChange?: (status: ConnectionStatus) => void;
|
onStatusChange?: (status: ConnectionStatus) => void;
|
||||||
/** 关闭就绪回调 */
|
/** 关闭就绪回调 */
|
||||||
onCloseReady?: () => void;
|
onCloseReady?: () => void;
|
||||||
/** 错误回调 */
|
/** 错误回调 */
|
||||||
onError?: (error: string) => void;
|
onError?: (error: string) => void;
|
||||||
|
/** 分屏回调 */
|
||||||
|
onSplitUp?: () => void;
|
||||||
|
onSplitDown?: () => void;
|
||||||
|
onSplitLeft?: () => void;
|
||||||
|
onSplitRight?: () => void;
|
||||||
|
onSplitInGroup?: () => void;
|
||||||
}
|
}
|
||||||
|
|||||||
384
frontend/src/components/Terminal/useSplitView.ts
Normal file
384
frontend/src/components/Terminal/useSplitView.ts
Normal file
@ -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<SplitLayout>(() => ({
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@ -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<HTMLDivElement>(null);
|
|
||||||
const terminalInstanceRef = useRef<XTerm | null>(null);
|
|
||||||
const wsRef = useRef<WebSocket | null>(null);
|
|
||||||
const fitAddonRef = useRef<FitAddon | null>(null);
|
|
||||||
const searchAddonRef = useRef<SearchAddon | null>(null);
|
|
||||||
|
|
||||||
const [connectionStatus, setConnectionStatus] = useState<ConnectionStatus>('disconnected');
|
|
||||||
const [errorMessage, setErrorMessage] = useState<string>('');
|
|
||||||
|
|
||||||
// 更新状态并触发回调
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@ -1,14 +1,15 @@
|
|||||||
/**
|
/**
|
||||||
* SSH 窗口管理器
|
* SSH 窗口管理器
|
||||||
* 直接使用通用 Terminal 和 TerminalWindowManager
|
* 使用 TerminalSplitView 支持分屏功能
|
||||||
*/
|
*/
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import {
|
import {
|
||||||
TerminalWindowManager,
|
TerminalWindowManager,
|
||||||
Terminal,
|
TerminalSplitView,
|
||||||
|
type TerminalTab,
|
||||||
type TerminalConnectionConfig,
|
type TerminalConnectionConfig,
|
||||||
type TerminalAuditConfig,
|
type TerminalAuditConfig,
|
||||||
type TerminalToolbarConfig
|
type TerminalToolbarConfig,
|
||||||
} from '@/components/Terminal';
|
} from '@/components/Terminal';
|
||||||
import type { ServerResponse } from '../types';
|
import type { ServerResponse } from '../types';
|
||||||
|
|
||||||
@ -23,29 +24,37 @@ export const SSHWindowManager: React.FC<SSHWindowManagerProps> = ({ onOpenWindow
|
|||||||
getWindowTitle={(server) => `SSH终端 - ${server.serverName} (${server.hostIp})`}
|
getWindowTitle={(server) => `SSH终端 - ${server.serverName} (${server.hostIp})`}
|
||||||
getWindowSubtitle={(server) => server.serverName}
|
getWindowSubtitle={(server) => server.serverName}
|
||||||
getResourceId={(server) => server.id}
|
getResourceId={(server) => server.id}
|
||||||
renderTerminal={(windowId, server, callbacks) => {
|
renderTerminal={(windowId, server, { onCloseReady, onStatusChange }) => {
|
||||||
// SSH 连接配置
|
// 创建初始 Tab
|
||||||
const token = localStorage.getItem('token');
|
const initialTab: TerminalTab = {
|
||||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
id: `tab-${Date.now()}`,
|
||||||
const wsUrl = `${protocol}//${window.location.host}/api/v1/server-ssh/connect/${server.id}?token=${token}`;
|
title: `${server.serverName} - ${server.hostIp}`,
|
||||||
|
serverId: server.id,
|
||||||
|
serverName: server.serverName,
|
||||||
|
isActive: true,
|
||||||
|
};
|
||||||
|
|
||||||
const connectionConfig: TerminalConnectionConfig = {
|
// 连接配置函数
|
||||||
type: 'ssh',
|
const getConnectionConfig = (tab: TerminalTab): TerminalConnectionConfig => {
|
||||||
wsUrl,
|
const token = localStorage.getItem('token');
|
||||||
token: token || undefined,
|
return {
|
||||||
autoReconnect: true,
|
type: 'ssh',
|
||||||
reconnectInterval: 3000,
|
serverId: tab.serverId,
|
||||||
maxReconnectAttempts: 5,
|
token: token || undefined,
|
||||||
|
autoReconnect: true,
|
||||||
|
reconnectInterval: 3000,
|
||||||
|
maxReconnectAttempts: 5,
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
// 审计配置
|
// 审计配置
|
||||||
const auditConfig: TerminalAuditConfig = {
|
const getAuditConfig = (): TerminalAuditConfig => ({
|
||||||
enabled: true,
|
enabled: true,
|
||||||
companyName: '链宇技术有限公司',
|
companyName: '链宇技术有限公司',
|
||||||
};
|
});
|
||||||
|
|
||||||
// 工具栏配置
|
// 工具栏配置
|
||||||
const toolbarConfig: TerminalToolbarConfig = {
|
const getToolbarConfig = (): TerminalToolbarConfig => ({
|
||||||
show: true,
|
show: true,
|
||||||
showSearch: true,
|
showSearch: true,
|
||||||
showClear: true,
|
showClear: true,
|
||||||
@ -53,16 +62,14 @@ export const SSHWindowManager: React.FC<SSHWindowManagerProps> = ({ onOpenWindow
|
|||||||
showFontSize: true,
|
showFontSize: true,
|
||||||
showStatus: true,
|
showStatus: true,
|
||||||
showFontSizeLabel: true,
|
showFontSizeLabel: true,
|
||||||
};
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Terminal
|
<TerminalSplitView
|
||||||
id={windowId}
|
initialTab={initialTab}
|
||||||
connection={connectionConfig}
|
getConnectionConfig={getConnectionConfig}
|
||||||
audit={auditConfig}
|
getAuditConfig={getAuditConfig}
|
||||||
toolbar={toolbarConfig}
|
getToolbarConfig={getToolbarConfig}
|
||||||
onStatusChange={callbacks.onStatusChange}
|
|
||||||
onCloseReady={callbacks.onCloseReady}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user