重写ssh前端组件,通用化

This commit is contained in:
dengqichen 2025-12-06 22:12:34 +08:00
parent f6abb48333
commit 1fd72bf007
15 changed files with 1889 additions and 342 deletions

View 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>
);
};

View File

@ -2,17 +2,18 @@
* Terminal - * Terminal -
* SSHK8s PodDocker Container * SSHK8s PodDocker 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) {
instanceRef.current.disconnect();
setAuditShown(false); // 重置审计警告标记,重连后重新显示 setAuditShown(false); // 重置审计警告标记,重连后重新显示
setTimeout(() => { setTimeout(() => {
initializeTerminal(); instanceRef.current?.connect();
connectWebSocket();
}, 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}
/> />
{/* 终端区域 */} {/* 终端区域 */}

View 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>
);
};

View File

@ -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" />

View 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));
}
}

View 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());
}
}

View File

@ -0,0 +1,11 @@
/**
* Terminal Core -
*/
export { TerminalInstance } from './TerminalInstance';
export { TerminalInstanceManager } from './TerminalInstanceManager';
export type {
TerminalInstanceConfig,
TerminalInstanceCreateConfig,
TerminalDisplayConfig
} from './TerminalInstance';

View File

@ -3,9 +3,21 @@
* SSHK8s PodDocker Container * SSHK8s PodDocker 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';

View File

@ -0,0 +1,113 @@
/**
*
*
* SSHK8S PodDocker 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();
}
}

View File

@ -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();
}
}

View 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';

View File

@ -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;
} }

View 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,
};
};

View File

@ -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,
};
};

View File

@ -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 = { // 连接配置函数
const getConnectionConfig = (tab: TerminalTab): TerminalConnectionConfig => {
const token = localStorage.getItem('token');
return {
type: 'ssh', type: 'ssh',
wsUrl, serverId: tab.serverId,
token: token || undefined, token: token || undefined,
autoReconnect: true, autoReconnect: true,
reconnectInterval: 3000, reconnectInterval: 3000,
maxReconnectAttempts: 5, maxReconnectAttempts: 5,
}; };
// 审计配置
const auditConfig: TerminalAuditConfig = {
enabled: true,
companyName: '链宇技术有限公司',
}; };
// 审计配置
const getAuditConfig = (): TerminalAuditConfig => ({
enabled: true,
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}
/> />
); );
}} }}