重写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 组件 - 通用终端
|
||||
* 支持 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<TerminalProps> = ({
|
||||
id,
|
||||
@ -20,37 +21,25 @@ export const Terminal: React.FC<TerminalProps> = ({
|
||||
display,
|
||||
audit,
|
||||
toolbar,
|
||||
isActive,
|
||||
onStatusChange,
|
||||
onCloseReady,
|
||||
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 [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<ConnectionStatus>('disconnected'); // 初始状态,会被实例状态覆盖
|
||||
const [errorMessage, setErrorMessage] = useState<string>('');
|
||||
|
||||
// 默认工具栏配置
|
||||
const toolbarConfig: TerminalToolbarConfig = {
|
||||
@ -64,37 +53,72 @@ export const Terminal: React.FC<TerminalProps> = ({
|
||||
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<TerminalProps> = ({
|
||||
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<TerminalProps> = ({
|
||||
|
||||
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<TerminalProps> = ({
|
||||
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 (
|
||||
<div className={styles.terminalWrapper}>
|
||||
@ -250,6 +277,11 @@ export const Terminal: React.FC<TerminalProps> = ({
|
||||
onZoomOut={handleZoomOut}
|
||||
onReconnect={handleReconnect}
|
||||
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 { 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<TerminalToolbarProps> = ({
|
||||
@ -38,6 +51,11 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
onZoomOut,
|
||||
onReconnect,
|
||||
onThemeChange,
|
||||
onSplitUp,
|
||||
onSplitDown,
|
||||
onSplitLeft,
|
||||
onSplitRight,
|
||||
onSplitInGroup,
|
||||
}) => {
|
||||
if (!config.show) return null;
|
||||
|
||||
@ -145,6 +163,63 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
</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') && (
|
||||
<>
|
||||
<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 等多种场景
|
||||
*/
|
||||
|
||||
// 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';
|
||||
|
||||
@ -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 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;
|
||||
}
|
||||
|
||||
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 窗口管理器
|
||||
* 直接使用通用 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<SSHWindowManagerProps> = ({ 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<SSHWindowManagerProps> = ({ onOpenWindow
|
||||
showFontSize: true,
|
||||
showStatus: true,
|
||||
showFontSizeLabel: true,
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<Terminal
|
||||
id={windowId}
|
||||
connection={connectionConfig}
|
||||
audit={auditConfig}
|
||||
toolbar={toolbarConfig}
|
||||
onStatusChange={callbacks.onStatusChange}
|
||||
onCloseReady={callbacks.onCloseReady}
|
||||
<TerminalSplitView
|
||||
initialTab={initialTab}
|
||||
getConnectionConfig={getConnectionConfig}
|
||||
getAuditConfig={getAuditConfig}
|
||||
getToolbarConfig={getToolbarConfig}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user