重写ssh前端组件,通用化

This commit is contained in:
dengqichen 2025-12-06 18:53:40 +08:00
parent 9325166132
commit 980c827feb
10 changed files with 1281 additions and 1119 deletions

View File

@ -0,0 +1,252 @@
/**
* Terminal -
* SSHK8s PodDocker Container
*/
import React, { useEffect, useCallback, useState } from 'react';
import 'xterm/css/xterm.css';
import styles from './index.module.less';
import { useTerminal } from './useTerminal';
import { TerminalToolbar } from './TerminalToolbar';
import type { TerminalProps, TerminalToolbarConfig } from './types';
import { Loader2, XCircle } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { message } from 'antd';
export const Terminal: React.FC<TerminalProps> = ({
id,
connection,
display,
audit,
toolbar,
onStatusChange,
onCloseReady,
onError,
}) => {
const [fontSize, setFontSize] = useState(display?.fontSize ?? 14);
const [showSearch, setShowSearch] = useState(false);
const {
terminalRef,
terminalInstance,
fitAddon,
searchAddon,
connectionStatus,
errorMessage,
initializeTerminal,
connectWebSocket,
sendInput,
sendResize,
cleanup,
} = useTerminal({
connection,
display: { ...display, fontSize },
onStatusChange,
onError,
});
// 默认工具栏配置
const toolbarConfig: TerminalToolbarConfig = {
show: toolbar?.show ?? true,
showSearch: toolbar?.showSearch ?? true,
showClear: toolbar?.showClear ?? true,
showCopy: toolbar?.showCopy ?? true,
showFontSize: toolbar?.showFontSize ?? true,
showStatus: toolbar?.showStatus ?? true,
showFontSizeLabel: toolbar?.showFontSizeLabel ?? true,
extraActions: toolbar?.extraActions,
};
// 初始化
useEffect(() => {
const terminal = initializeTerminal();
if (!terminal) return;
// 监听终端输入
terminal.onData((data) => {
sendInput(data);
});
// 监听终端尺寸变化
terminal.onResize((size) => {
sendResize(size.rows, size.cols);
});
// 延迟连接
const timer = setTimeout(() => {
connectWebSocket();
}, 300);
return () => {
clearTimeout(timer);
cleanup();
};
}, [id]);
// 显示审计警告
useEffect(() => {
if (connectionStatus === 'connected' && audit?.enabled && terminalInstance) {
const companyName = audit.companyName || '';
const customMessage = audit.message;
if (customMessage) {
terminalInstance.writeln(`\r\n\x1b[33m${customMessage}\x1b[0m\r\n`);
} else {
terminalInstance.writeln('\r\n\x1b[33m┌─────────────────────────────────────────────────────────────\x1b[0m');
terminalInstance.writeln(`\x1b[33m│ ⚠️ ${companyName} - 安全提示\x1b[0m`);
terminalInstance.writeln('\x1b[33m│ 本次会话将被全程审计记录\x1b[0m');
terminalInstance.writeln('\x1b[33m│ • 所有操作命令、输入、输出都将被完整记录\x1b[0m');
terminalInstance.writeln('\x1b[33m│ • 审计日志用于安全审查、故障排查和合规要求\x1b[0m');
terminalInstance.writeln('\x1b[33m│ • 请规范操作,遵守企业信息安全管理制度\x1b[0m');
terminalInstance.writeln('\x1b[33m└─────────────────────────────────────────────────────────────\x1b[0m\r\n');
}
setTimeout(() => {
fitAddon?.fit();
}, 100);
}
}, [connectionStatus, audit, terminalInstance, fitAddon]);
// 重连处理
const handleReconnect = useCallback(() => {
cleanup();
setTimeout(() => {
initializeTerminal();
connectWebSocket();
}, 100);
}, [cleanup, initializeTerminal, connectWebSocket]);
// 搜索
const [searchQuery, setSearchQuery] = useState('');
const handleSearch = useCallback(() => {
setShowSearch(!showSearch);
if (!showSearch) {
// 打开搜索时聚焦输入框
setTimeout(() => {
const input = document.querySelector('.terminal-search-input') as HTMLInputElement;
input?.focus();
}, 100);
}
}, [showSearch]);
const handleSearchChange = useCallback((value: string) => {
setSearchQuery(value);
if (searchAddon && terminalInstance) {
searchAddon.findNext(value, { incremental: true });
}
}, [searchAddon, terminalInstance]);
const handleSearchNext = useCallback(() => {
if (searchAddon && searchQuery) {
searchAddon.findNext(searchQuery);
}
}, [searchAddon, searchQuery]);
const handleSearchPrev = useCallback(() => {
if (searchAddon && searchQuery) {
searchAddon.findPrevious(searchQuery);
}
}, [searchAddon, searchQuery]);
const handleCloseSearch = useCallback(() => {
setShowSearch(false);
setSearchQuery('');
searchAddon?.clearDecorations();
}, [searchAddon]);
// 清屏
const handleClear = useCallback(() => {
if (terminalInstance) {
terminalInstance.clear();
message.success('终端已清屏');
}
}, [terminalInstance]);
// 复制
const handleCopy = useCallback(() => {
if (terminalInstance) {
const selection = terminalInstance.getSelection();
if (selection) {
navigator.clipboard.writeText(selection);
message.success('已复制到剪贴板');
} else {
message.warning('请先选中要复制的内容');
}
}
}, [terminalInstance]);
// 放大字体
const handleZoomIn = useCallback(() => {
const newSize = Math.min(fontSize + 2, 32);
setFontSize(newSize);
if (terminalInstance) {
terminalInstance.options.fontSize = newSize;
setTimeout(() => fitAddon?.fit(), 50);
}
}, [fontSize, terminalInstance, fitAddon]);
// 缩小字体
const handleZoomOut = useCallback(() => {
const newSize = Math.max(fontSize - 2, 10);
setFontSize(newSize);
if (terminalInstance) {
terminalInstance.options.fontSize = newSize;
setTimeout(() => fitAddon?.fit(), 50);
}
}, [fontSize, terminalInstance, fitAddon]);
return (
<div className={styles.terminalWrapper}>
{/* 工具栏 */}
<TerminalToolbar
config={toolbarConfig}
connectionStatus={connectionStatus}
fontSize={fontSize}
onSearch={handleSearch}
onClear={handleClear}
onCopy={handleCopy}
onZoomIn={handleZoomIn}
onZoomOut={handleZoomOut}
onReconnect={handleReconnect}
/>
{/* 终端区域 */}
<div className={styles.container}>
<div
ref={terminalRef}
className={styles.content}
/>
{/* 连接中 */}
{connectionStatus === 'connecting' && (
<div className={`${styles.statusOverlay} ${styles.connecting}`}>
<Loader2 className={`${styles.statusIcon} animate-spin`} />
<p className={styles.statusText}>...</p>
</div>
)}
{/* 重连中 */}
{connectionStatus === 'reconnecting' && (
<div className={`${styles.statusOverlay} ${styles.reconnecting}`}>
<Loader2 className={`${styles.statusIcon} animate-spin`} />
<p className={styles.statusText}>...</p>
</div>
)}
{/* 错误 */}
{connectionStatus === 'error' && (
<div className={`${styles.statusOverlay} ${styles.error}`}>
<XCircle className={styles.statusIcon} />
<p className={styles.statusText}></p>
{errorMessage && (
<p className={styles.statusDetail}>{errorMessage}</p>
)}
<Button onClick={handleReconnect} variant="outline">
</Button>
</div>
)}
</div>
</div>
);
};

View File

@ -0,0 +1,139 @@
/**
* Terminal
*/
import React from 'react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Search, Trash2, Copy, ZoomIn, ZoomOut, RotateCcw, Loader2, XCircle } from 'lucide-react';
import type { ConnectionStatus, TerminalToolbarConfig } from './types';
import styles from './index.module.less';
interface TerminalToolbarProps {
config: TerminalToolbarConfig;
connectionStatus: ConnectionStatus;
fontSize: number;
onSearch?: () => void;
onClear?: () => void;
onCopy?: () => void;
onZoomIn?: () => void;
onZoomOut?: () => void;
onReconnect?: () => void;
}
export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
config,
connectionStatus,
fontSize,
onSearch,
onClear,
onCopy,
onZoomIn,
onZoomOut,
onReconnect,
}) => {
if (!config.show) return null;
const getStatusBadge = () => {
switch (connectionStatus) {
case 'connecting':
return <Badge variant="outline" className="bg-yellow-100 text-yellow-700"><Loader2 className="mr-1 h-3 w-3 animate-spin" /></Badge>;
case 'connected':
return <Badge variant="outline" className="bg-emerald-100 text-emerald-700"><div className="mr-1 h-2 w-2 rounded-full bg-emerald-500 animate-pulse" /></Badge>;
case 'reconnecting':
return <Badge variant="outline" className="bg-orange-100 text-orange-700"><Loader2 className="mr-1 h-3 w-3 animate-spin" /></Badge>;
case 'error':
return <Badge variant="outline" className="bg-red-100 text-red-700"><XCircle className="mr-1 h-3 w-3" /></Badge>;
case 'disconnected':
return <Badge variant="outline" className="bg-gray-100 text-gray-700"><XCircle className="mr-1 h-3 w-3" /></Badge>;
}
};
return (
<div className={styles.toolbar}>
{/* 左侧:状态指示器 */}
<div className={styles.left}>
{config.showStatus && getStatusBadge()}
{config.showFontSizeLabel && (
<span className="text-xs text-gray-500 dark:text-gray-400">
{fontSize}px
</span>
)}
</div>
{/* 右侧:所有操作按钮 */}
<div className={styles.right}>
{config.showSearch && (
<Button
size="sm"
variant="ghost"
className="h-7 px-2"
onClick={onSearch}
title="搜索 (Ctrl+F)"
>
<Search className="h-3.5 w-3.5" />
</Button>
)}
{config.showClear && (
<Button
size="sm"
variant="ghost"
className="h-7 px-2"
onClick={onClear}
title="清屏 (Ctrl+L)"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
{config.showCopy && (
<Button
size="sm"
variant="ghost"
className="h-7 px-2"
onClick={onCopy}
title="复制选中 (Ctrl+C)"
>
<Copy className="h-3.5 w-3.5" />
</Button>
)}
{config.showFontSize && (
<>
<div className="h-4 w-px bg-gray-300 dark:bg-gray-600 mx-1" />
<Button
size="sm"
variant="ghost"
className="h-7 px-2"
onClick={onZoomOut}
title="缩小字体"
>
<ZoomOut className="h-3.5 w-3.5" />
</Button>
<Button
size="sm"
variant="ghost"
className="h-7 px-2"
onClick={onZoomIn}
title="放大字体"
>
<ZoomIn className="h-3.5 w-3.5" />
</Button>
</>
)}
{(connectionStatus === 'disconnected' || connectionStatus === 'error') && (
<>
<div className="h-4 w-px bg-gray-300 dark:bg-gray-600 mx-1" />
<Button
size="sm"
variant="outline"
className="h-7"
onClick={onReconnect}
>
<RotateCcw className="h-3.5 w-3.5 mr-1" />
</Button>
</>
)}
{config.extraActions}
</div>
</div>
);
};

View File

@ -0,0 +1,253 @@
/**
* Terminal -
*
* SSHK8s PodDocker Container
*/
import React, { useState, useCallback } from 'react';
import { Terminal } from 'lucide-react';
import { DraggableWindow } from '@/components/ui/draggable-window';
import type { ConnectionStatus, TerminalType } from './types';
/**
*
*/
export interface TerminalWindow<TResource = any> {
id: string;
resource: TResource;
isMinimized: boolean;
position: { x: number; y: number };
size: { width: number; height: number };
connectionStatus?: ConnectionStatus;
}
/**
* Props
*/
interface TerminalWindowManagerProps<TResource = any> {
/** 终端类型 */
type: TerminalType;
/** 获取窗口标题 */
getWindowTitle: (resource: TResource) => string;
/** 获取窗口副标题 */
getWindowSubtitle: (resource: TResource) => string;
/** 获取资源ID */
getResourceId: (resource: TResource) => string | number;
/** 渲染终端内容 */
renderTerminal: (windowId: string, resource: TResource, callbacks: {
onCloseReady: () => void;
onStatusChange: (status: ConnectionStatus) => void;
}) => React.ReactNode;
/** 窗口打开回调 */
onOpenWindow?: (windowId: string) => void;
}
export function TerminalWindowManager<TResource = any>({
type,
getWindowTitle,
getWindowSubtitle,
getResourceId,
renderTerminal,
onOpenWindow,
}: TerminalWindowManagerProps<TResource>) {
const [windows, setWindows] = useState<TerminalWindow<TResource>[]>([]);
const [activeWindowId, setActiveWindowId] = useState<string | null>(null);
// 打开新窗口
const openWindow = useCallback((resource: TResource) => {
const resourceId = getResourceId(resource);
const windowId = `${type}-${resourceId}-${Date.now()}`;
const offset = windows.length * 30;
const newWindow: TerminalWindow<TResource> = {
id: windowId,
resource,
isMinimized: false,
position: { x: 100 + offset, y: 100 + offset },
size: { width: 1200, height: 700 },
connectionStatus: 'connecting',
};
setWindows(prev => [...prev, newWindow]);
setActiveWindowId(windowId);
onOpenWindow?.(windowId);
console.log(`✅ 打开${type.toUpperCase()}窗口: ${getWindowTitle(resource)} (${windowId})`);
}, [windows.length, type, getWindowTitle, getResourceId, onOpenWindow]);
// 真正关闭窗口
const actuallyCloseWindow = useCallback((windowId: string) => {
console.log(`❌ 真正关闭窗口: ${windowId}`);
setWindows(prev => prev.filter(w => w.id !== windowId));
if (activeWindowId === windowId) {
setActiveWindowId(null);
}
}, [activeWindowId]);
// 关闭窗口(优雅关闭)
const closeWindow = useCallback((windowId: string) => {
console.log(`🚪 准备关闭窗口: ${windowId}`);
// 调用优雅关闭方法
const closeMethod = (window as any)[`__closeSSH_${windowId}`];
if (closeMethod && typeof closeMethod === 'function') {
console.log('✅ 调用优雅关闭方法');
closeMethod();
} else {
console.warn('⚠️ 未找到优雅关闭方法,直接关闭窗口');
actuallyCloseWindow(windowId);
}
}, [actuallyCloseWindow]);
// 最小化窗口
const minimizeWindow = useCallback((windowId: string) => {
setWindows(prev =>
prev.map(w =>
w.id === windowId ? { ...w, isMinimized: true } : w
)
);
console.log(` 最小化窗口: ${windowId}`);
}, []);
// 恢复窗口
const restoreWindow = useCallback((windowId: string) => {
setWindows(prev =>
prev.map(w =>
w.id === windowId ? { ...w, isMinimized: false } : w
)
);
setActiveWindowId(windowId);
console.log(`⬆️ 恢复窗口: ${windowId}`);
// 触发resize事件确保终端正确调整尺寸
setTimeout(() => window.dispatchEvent(new Event('resize')), 50);
setTimeout(() => window.dispatchEvent(new Event('resize')), 150);
setTimeout(() => window.dispatchEvent(new Event('resize')), 300);
}, []);
// 激活窗口
const focusWindow = useCallback((windowId: string) => {
setActiveWindowId(windowId);
}, []);
// 更新窗口连接状态
const updateWindowStatus = useCallback((windowId: string, status: ConnectionStatus) => {
setWindows(prev =>
prev.map(w =>
w.id === windowId ? { ...w, connectionStatus: status } : w
)
);
}, []);
// 获取状态对应的按钮样式
const getButtonStyle = (status?: ConnectionStatus) => {
switch (status) {
case 'connected':
return 'bg-green-600 hover:bg-green-700';
case 'error':
case 'disconnected':
return 'bg-red-600 hover:bg-red-700';
case 'connecting':
return 'bg-yellow-600 hover:bg-yellow-700';
case 'reconnecting':
return 'bg-orange-600 hover:bg-orange-700';
default:
return 'bg-blue-600 hover:bg-blue-700';
}
};
// 暴露 openWindow 方法给外部使用
React.useEffect(() => {
// 处理缩写词ssh -> SSH, k8s -> K8s 等
const typeMap: Record<string, string> = {
'ssh': 'SSH',
'k8s-pod': 'K8sPod',
'docker-container': 'DockerContainer',
};
const typeName = typeMap[type] || type.charAt(0).toUpperCase() + type.slice(1);
const globalKey = `__open${typeName}Window`;
(window as any)[globalKey] = openWindow;
console.log(`✅ 注册全局方法: ${globalKey}`);
return () => {
delete (window as any)[globalKey];
};
}, [openWindow, type]);
return (
<>
{/* 渲染所有窗口 */}
{windows.map(win => (
<div
key={win.id}
style={{
visibility: win.isMinimized ? 'hidden' : 'visible',
pointerEvents: win.isMinimized ? 'none' : 'auto',
}}
>
<DraggableWindow
id={win.id}
title={getWindowTitle(win.resource)}
onClose={() => closeWindow(win.id)}
onMinimize={() => minimizeWindow(win.id)}
isActive={activeWindowId === win.id}
onFocus={() => focusWindow(win.id)}
initialPosition={win.position}
initialSize={win.size}
>
{renderTerminal(win.id, win.resource, {
onCloseReady: () => actuallyCloseWindow(win.id),
onStatusChange: (status) => updateWindowStatus(win.id, status),
})}
</DraggableWindow>
</div>
))}
{/* 最小化的窗口悬浮按钮 */}
{windows.filter(w => w.isMinimized).length > 0 && (
<div className="fixed bottom-4 right-4 flex flex-col-reverse gap-2 z-[1001]">
{windows
.filter(w => w.isMinimized)
.map((win) => {
// 计算同一资源的窗口数量
const resourceId = getResourceId(win.resource);
const sameResourceWindows = windows.filter(w => getResourceId(w.resource) === resourceId);
const needsIndex = sameResourceWindows.length > 1;
const windowIndex = sameResourceWindows.findIndex(w => w.id === win.id) + 1;
return (
<button
key={win.id}
className={`flex items-center gap-3 px-4 py-3 ${getButtonStyle(win.connectionStatus)} text-white rounded-lg shadow-lg transition-all hover:scale-105 hover:shadow-xl relative`}
onClick={() => restoreWindow(win.id)}
title={`恢复: ${getWindowSubtitle(win.resource)}${needsIndex ? ` #${windowIndex}` : ''}`}
>
{/* 状态指示灯 */}
<div className="absolute top-2 right-2">
<div className={`h-2 w-2 rounded-full ${
win.connectionStatus === 'connected' ? 'bg-white animate-pulse' :
win.connectionStatus === 'error' || win.connectionStatus === 'disconnected' ? 'bg-white/70' :
'bg-white/50 animate-pulse'
}`} />
</div>
<Terminal className="h-5 w-5" />
<div className="text-left">
<div className="text-sm font-semibold flex items-center gap-2">
<span>{getWindowSubtitle(win.resource)}</span>
{needsIndex && (
<span className="text-xs opacity-75">#{windowIndex}</span>
)}
</div>
<div className="text-xs opacity-90">
{type.toUpperCase()}
</div>
</div>
</button>
);
})}
</div>
)}
</>
);
}

View File

@ -0,0 +1,185 @@
/* Terminal 组件样式 - 通用终端样式 */
.terminalWrapper {
display: flex;
flex-direction: column;
height: 100%;
background-color: #0a0a0a;
}
.toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 1rem;
background: #f9fafb;
border-bottom: 1px solid #e5e7eb;
min-height: 48px;
}
:global(.dark) .toolbar {
background: #1f2937;
border-bottom: 1px solid #374151;
}
.left {
display: flex;
align-items: center;
gap: 0.75rem;
}
.right {
display: flex;
align-items: center;
gap: 0.5rem;
}
.container {
flex: 1;
background-color: #1e1e1e;
position: relative;
overflow: hidden;
}
.content {
width: 100%;
height: 100%;
padding: 0.5rem;
}
/* xterm.js 滚动条样式优化 */
.content :global(.xterm) {
height: 100%;
padding: 0;
}
.content :global(.xterm-screen) {
padding: 0;
}
.content :global(.xterm-viewport) {
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.3) transparent;
}
.content :global(.xterm-viewport::-webkit-scrollbar) {
width: 10px;
}
.content :global(.xterm-viewport::-webkit-scrollbar-track) {
background: transparent;
}
.content :global(.xterm-viewport::-webkit-scrollbar-thumb) {
background-color: rgba(255, 255, 255, 0.3);
border-radius: 5px;
border: 2px solid transparent;
background-clip: padding-box;
}
.content :global(.xterm-viewport::-webkit-scrollbar-thumb:hover) {
background-color: rgba(255, 255, 255, 0.5);
}
.statusOverlay {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background-color: rgba(30, 30, 30, 0.95);
backdrop-filter: blur(4px);
z-index: 10;
}
.connecting {
color: #facc15;
}
.reconnecting {
color: #fb923c;
}
.error {
color: #ef4444;
}
.statusIcon {
width: 3rem;
height: 3rem;
margin-bottom: 1rem;
}
.statusText {
font-size: 1.125rem;
line-height: 1.75rem;
margin-bottom: 0.5rem;
}
.statusDetail {
font-size: 0.875rem;
color: #9ca3af;
margin-bottom: 1rem;
}
.searchBar {
position: absolute;
top: 0.5rem;
right: 0.5rem;
z-index: 20;
display: flex;
align-items: center;
gap: 0.25rem;
background-color: rgba(17, 24, 39, 0.95);
border: 1px solid #374151;
border-radius: 0.5rem;
padding: 0.5rem 0.75rem;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
}
.searchInput {
background: transparent;
border: none;
outline: none;
color: #fff;
width: 200px;
font-size: 0.875rem;
}
.searchInput::placeholder {
color: #9ca3af;
}
.searchButton {
padding: 0.25rem;
cursor: pointer;
color: #9ca3af;
transition: color 0.2s;
}
.searchButton:hover {
color: #fff;
}
/* 响应式适配 */
@media (max-width: 768px) {
.toolbar {
padding: 0.5rem;
flex-wrap: wrap;
gap: 0.5rem;
}
.content {
padding: 0.25rem;
}
.searchBar {
top: 0.25rem;
right: 0.25rem;
}
.searchInput {
width: 150px;
}
}

View File

@ -0,0 +1,20 @@
/**
* Terminal
* SSHK8s PodDocker Container
*/
export { Terminal } from './Terminal';
export { useTerminal } from './useTerminal';
export { TerminalWindowManager } from './TerminalWindowManager';
export type { TerminalWindow } from './TerminalWindowManager';
export type {
TerminalType,
ConnectionStatus,
TerminalReceiveMessage,
TerminalSendMessage,
TerminalConnectionConfig,
TerminalDisplayConfig,
TerminalAuditConfig,
TerminalToolbarConfig,
TerminalProps,
} from './types';

View File

@ -0,0 +1,134 @@
/**
* Terminal
* SSHK8s Pod
*/
export type TerminalType = 'ssh' | 'k8s-pod' | 'docker-container';
export type ConnectionStatus = 'connecting' | 'connected' | 'reconnecting' | 'disconnected' | 'error';
/**
* WebSocket -
*/
export interface TerminalReceiveMessage {
type: 'output' | 'status' | 'error';
data: string | { response: { type: string; data: string } };
timestamp?: number;
metadata?: Record<string, any> | null;
}
/**
* WebSocket -
*/
export interface TerminalSendMessage {
type: 'input' | 'resize';
data: {
request: {
type: 'input' | 'resize';
command?: string;
rows?: number;
cols?: number;
};
};
}
/**
* Terminal
*/
export interface TerminalConnectionConfig {
/** 连接类型 */
type: TerminalType;
/** WebSocket URL */
wsUrl: string;
/** 认证 Token */
token?: string;
/** 连接超时时间(毫秒) */
timeout?: number;
/** 是否自动重连 */
autoReconnect?: boolean;
/** 重连间隔(毫秒) */
reconnectInterval?: number;
/** 最大重连次数 */
maxReconnectAttempts?: number;
}
/**
* Terminal
*/
export interface TerminalDisplayConfig {
/** 字体大小 */
fontSize?: number;
/** 字体系列 */
fontFamily?: string;
/** 行数 */
rows?: number;
/** 列数 */
cols?: number;
/** 滚动缓冲区大小 */
scrollback?: number;
/** 光标闪烁 */
cursorBlink?: boolean;
/** 主题配置 */
theme?: {
background?: string;
foreground?: string;
cursor?: string;
[key: string]: string | undefined;
};
}
/**
* Terminal
*/
export interface TerminalAuditConfig {
/** 是否启用审计 */
enabled: boolean;
/** 审计提示信息 */
message?: string;
/** 公司名称 */
companyName?: string;
}
/**
* Terminal
*/
export interface TerminalToolbarConfig {
/** 是否显示工具栏 */
show?: boolean;
/** 是否显示搜索按钮 */
showSearch?: boolean;
/** 是否显示清屏按钮 */
showClear?: boolean;
/** 是否显示复制按钮 */
showCopy?: boolean;
/** 是否显示字体缩放按钮 */
showFontSize?: boolean;
/** 是否显示状态徽章 */
showStatus?: boolean;
/** 是否显示字体大小 */
showFontSizeLabel?: boolean;
/** 自定义额外操作 */
extraActions?: React.ReactNode;
}
/**
* Terminal Props
*/
export interface TerminalProps {
/** 唯一标识符 */
id: string;
/** 连接配置 */
connection: TerminalConnectionConfig;
/** 显示配置 */
display?: TerminalDisplayConfig;
/** 审计配置 */
audit?: TerminalAuditConfig;
/** 工具栏配置 */
toolbar?: TerminalToolbarConfig;
/** 连接状态变化回调 */
onStatusChange?: (status: ConnectionStatus) => void;
/** 关闭就绪回调 */
onCloseReady?: () => void;
/** 错误回调 */
onError?: (error: string) => void;
}

View File

@ -0,0 +1,226 @@
/**
* Terminal Hook -
*/
import { useEffect, useRef, useState, useCallback } from 'react';
import { Terminal as XTerm } from 'xterm';
import { FitAddon } from '@xterm/addon-fit';
import { WebLinksAddon } from '@xterm/addon-web-links';
import { SearchAddon } from '@xterm/addon-search';
import type {
ConnectionStatus,
TerminalConnectionConfig,
TerminalDisplayConfig,
TerminalReceiveMessage,
} from './types';
interface UseTerminalOptions {
connection: TerminalConnectionConfig;
display?: TerminalDisplayConfig;
onStatusChange?: (status: ConnectionStatus) => void;
onError?: (error: string) => void;
}
export const useTerminal = (options: UseTerminalOptions) => {
const { connection, display, onStatusChange, onError } = options;
const terminalRef = useRef<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,792 +0,0 @@
import React, { useEffect, useRef, useState, useCallback } from 'react';
import { Terminal } from 'xterm';
import { FitAddon } from '@xterm/addon-fit';
import { WebLinksAddon } from '@xterm/addon-web-links';
import { SearchAddon } from '@xterm/addon-search';
import { Watermark } from 'watermark-js-plus';
import 'xterm/css/xterm.css';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Loader2, XCircle, Trash2, Copy, ZoomIn, ZoomOut, RotateCcw, Search, ChevronUp, ChevronDown, X } from 'lucide-react';
import { message } from 'antd';
import type { ServerResponse } from '../types';
// 自定义样式
const customStyles = `
.ssh-terminal-content .xterm-viewport::-webkit-scrollbar {
width: 8px;
}
.ssh-terminal-content .xterm-viewport::-webkit-scrollbar-track {
background: #2d2d2d;
border-radius: 4px;
}
.ssh-terminal-content .xterm-viewport::-webkit-scrollbar-thumb {
background: #555;
border-radius: 4px;
}
.ssh-terminal-content .xterm-viewport::-webkit-scrollbar-thumb:hover {
background: #666;
}
`;
interface SSHTerminalContentProps {
server: ServerResponse;
windowId: string;
onCloseReady?: () => void; // 断开完成后的回调
onStatusChange?: (status: ConnectionStatus) => void; // 状态变化回调
}
type ConnectionStatus = 'connecting' | 'connected' | 'reconnecting' | 'disconnected' | 'error';
interface WebSocketMessage {
type: 'output' | 'input' | 'status' | 'error';
data: string | { response: { type: string; data: string } }; // 支持字符串或 response 包装格式
timestamp?: number;
metadata?: Record<string, any> | null;
}
export const SSHTerminalContent: React.FC<SSHTerminalContentProps> = ({
server,
windowId,
onCloseReady,
onStatusChange,
}) => {
const terminalRef = useRef<HTMLDivElement>(null);
const terminalInstanceRef = useRef<Terminal | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const fitAddonRef = useRef<FitAddon | null>(null);
const terminalContainerRef = useRef<HTMLDivElement>(null);
const watermarkRef = useRef<Watermark | null>(null);
const searchAddonRef = useRef<SearchAddon | null>(null);
const onStatusChangeRef = useRef(onStatusChange);
const isClosingRef = useRef<boolean>(false);
const [connectionStatus, setConnectionStatus] = useState<ConnectionStatus>('connecting');
const [errorMessage, setErrorMessage] = useState<string>('');
const [fontSize, setFontSize] = useState<number>(14);
const [showSearch, setShowSearch] = useState<boolean>(false);
const [searchKeyword, setSearchKeyword] = useState<string>('');
// 更新ref
useEffect(() => {
onStatusChangeRef.current = onStatusChange;
}, [onStatusChange]);
// 状态变化时通知父组件
useEffect(() => {
onStatusChangeRef.current?.(connectionStatus);
}, [connectionStatus]);
// 初始化水印
useEffect(() => {
if (!terminalContainerRef.current) return;
// 从localStorage获取用户信息
const userInfoStr = localStorage.getItem('userInfo');
let username = '用户';
if (userInfoStr) {
try {
const userInfo = JSON.parse(userInfoStr);
username = userInfo.nickname || userInfo.username || '用户';
} catch (e) {
console.error('解析用户信息失败:', e);
}
}
const now = new Date().toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
// 获取IP地址
const serverIP = server.hostIp || '';
watermarkRef.current = new Watermark({
content: `${username} · ${server.serverName}(${serverIP}) · ${now}`,
width: 350,
height: 200,
rotate: -20,
fontSize: '12px', // 减小字体
fontColor: 'rgba(255, 255, 255, 0.15)',
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
zIndex: 1,
parent: terminalContainerRef.current,
globalAlpha: 0.5,
});
watermarkRef.current.create();
return () => {
watermarkRef.current?.destroy();
};
}, [server.serverName, server.hostIp]);
// 初始化终端和建立连接
const initializeTerminalAndConnect = () => {
if (!terminalRef.current) {
console.error('❌ terminalRef.current 为 null无法初始化');
return;
}
console.log('✅ 开始初始化SSH终端:', server.serverName);
// 清理旧的Terminal实例
if (terminalInstanceRef.current) {
console.log('🧹 清理旧的Terminal实例');
try {
terminalInstanceRef.current.dispose();
terminalInstanceRef.current = null;
} catch (e) {
console.error('清理Terminal实例失败:', e);
}
}
// 关闭旧的WebSocket连接
if (wsRef.current) {
console.log('🧹 关闭旧的WebSocket连接');
try {
wsRef.current.close(1000, 'Reinitializing');
wsRef.current = null;
} catch (e) {
console.error('关闭WebSocket失败:', e);
}
}
setConnectionStatus('connecting');
setErrorMessage('');
const terminal = new Terminal({
cursorBlink: true,
fontSize: 14,
fontFamily: 'Consolas, "Courier New", monospace',
theme: {
background: '#1e1e1e',
foreground: '#d4d4d4',
cursor: '#d4d4d4',
black: '#000000',
red: '#cd3131',
green: '#0dbc79',
yellow: '#e5e510',
blue: '#2472c8',
magenta: '#bc3fbc',
cyan: '#11a8cd',
white: '#e5e5e5',
brightBlack: '#666666',
brightRed: '#f14c4c',
brightGreen: '#23d18b',
brightYellow: '#f5f543',
brightBlue: '#3b8eea',
brightMagenta: '#d670d6',
brightCyan: '#29b8db',
brightWhite: '#e5e5e5',
},
rows: 30,
cols: 100,
});
const fitAddon = new FitAddon();
const webLinksAddon = new WebLinksAddon();
const searchAddon = new SearchAddon();
terminal.loadAddon(fitAddon);
terminal.loadAddon(webLinksAddon);
terminal.loadAddon(searchAddon);
searchAddonRef.current = searchAddon;
terminal.open(terminalRef.current);
terminalInstanceRef.current = terminal;
fitAddonRef.current = fitAddon;
setTimeout(() => {
fitAddon.fit();
}, 100);
// 监听终端输入
terminal.onData((data) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({
type: 'input',
data: {
request: {
type: 'input',
command: data,
}
}
}));
}
});
// 监听终端尺寸变化
terminal.onResize((size) => {
console.log('📐 终端尺寸变化:', size);
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({
type: 'resize',
data: {
request: {
type: 'resize',
rows: size.rows,
cols: size.cols,
}
}
}));
console.log('📤 已发送尺寸调整消息:', size);
}
});
// 延迟连接
setTimeout(() => {
connectWebSocket();
}, 300);
};
const connectWebSocket = () => {
if (wsRef.current?.readyState === WebSocket.OPEN) return;
setConnectionStatus('connecting');
setErrorMessage('');
const token = localStorage.getItem('token');
if (!token) {
const errorMsg = '身份认证失败';
const tips = '您的登录状态已过期,请重新登录系统';
setConnectionStatus('error');
setErrorMessage(errorMsg);
terminalInstanceRef.current?.writeln(`\r\n\x1b[31m✗ ${errorMsg}\x1b[0m`);
terminalInstanceRef.current?.writeln(`\x1b[33m💡 ${tips}\x1b[0m`);
message.error({
content: errorMsg,
duration: 4,
});
return;
}
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/api/v1/server-ssh/connect/${server.id}?token=${token}`;
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
ws.onopen = () => {
console.log('🔗 WebSocket已连接:', wsUrl);
console.log('📺 Terminal实例存在:', !!terminalInstanceRef.current);
// 发送初始终端尺寸
if (terminalInstanceRef.current) {
const terminal = terminalInstanceRef.current;
ws.send(JSON.stringify({
type: 'resize',
data: {
request: {
type: 'resize',
rows: terminal.rows,
cols: terminal.cols,
}
}
}));
console.log('📤 已发送初始终端尺寸:', { rows: terminal.rows, cols: terminal.cols });
}
};
ws.onmessage = (event) => {
console.log('📨 收到WebSocket消息:', event.data);
try {
const msg: WebSocketMessage = JSON.parse(event.data);
console.log('📦 解析后的消息:', msg);
// 提取实际数据:处理后端 response 包装格式
const actualData = typeof msg.data === 'string'
? msg.data
: msg.data?.response?.data || '';
switch (msg.type) {
case 'output':
console.log('📝 输出数据:', actualData?.substring(0, 100));
console.log('📺 Terminal实例:', !!terminalInstanceRef.current);
if (actualData && terminalInstanceRef.current) {
terminalInstanceRef.current.write(actualData);
console.log('✅ 数据已写入终端');
} else {
console.warn('⚠️ 无法写入终端:', { hasData: !!actualData, hasTerminal: !!terminalInstanceRef.current });
}
break;
case 'error':
console.error('❌ SSH错误:', actualData);
terminalInstanceRef.current?.writeln(`\r\n\x1b[31m错误: ${actualData}\x1b[0m\r\n`);
message.error(actualData || '连接错误');
break;
case 'status':
console.log('📊 状态变化:', actualData);
setConnectionStatus(actualData as ConnectionStatus);
if (actualData === 'connected') {
// 显示审计警告
terminalInstanceRef.current?.writeln('\r\n\x1b[33m┌─────────────────────────────────────────────────────────────\x1b[0m');
terminalInstanceRef.current?.writeln('\x1b[33m│ ⚠️ 链宇技术有限公司 - 安全提示\x1b[0m');
terminalInstanceRef.current?.writeln('\x1b[33m│ 本次SSH会话将被全程审计记录\x1b[0m');
terminalInstanceRef.current?.writeln('\x1b[33m│ • 所有操作命令、输入、输出都将被完整记录\x1b[0m');
terminalInstanceRef.current?.writeln('\x1b[33m│ • 审计日志用于安全审查、故障排查和合规要求\x1b[0m');
terminalInstanceRef.current?.writeln('\x1b[33m│ • 请规范操作,遵守企业信息安全管理制度\x1b[0m');
terminalInstanceRef.current?.writeln('\x1b[33m└─────────────────────────────────────────────────────────────\x1b[0m\r\n');
setTimeout(() => {
fitAddonRef.current?.fit();
}, 100);
}
break;
}
} catch (error) {
console.error('❌ 解析WebSocket消息失败:', error, 'Raw data:', event.data);
}
};
ws.onerror = () => {
const errorMsg = '无法连接到SSH服务';
const tips = '可能原因:后端服务未启动、网络不通或防火墙限制';
setConnectionStatus('error');
setErrorMessage(errorMsg);
terminalInstanceRef.current?.writeln(`\r\n\x1b[31m✗ ${errorMsg}\x1b[0m`);
terminalInstanceRef.current?.writeln(`\x1b[33m💡 ${tips}\x1b[0m`);
message.error({
content: errorMsg,
duration: 4,
});
};
ws.onclose = (event) => {
console.log('🔌 WebSocket关闭, code:', event.code, 'isClosing:', isClosingRef.current);
wsRef.current = null;
if (isClosingRef.current) {
// 用户主动关闭,通知父组件可以关闭窗口了
setConnectionStatus('disconnected');
console.log('✅ 断开完成,通知父组件');
setTimeout(() => {
onCloseReady?.();
}, 100);
} else {
// 非主动关闭
setConnectionStatus('disconnected');
if (!event.wasClean) {
let errorMsg = '';
let tips = '';
// 根据关闭代码提供更有针对性的提示
switch (event.code) {
case 1006:
errorMsg = '连接意外中断';
tips = '可能是网络波动或服务器重启,请尝试重新连接';
break;
case 1008:
errorMsg = '连接被拒绝';
tips = '服务器拒绝了您的连接请求,请检查权限配置';
break;
case 1011:
errorMsg = '服务器遇到错误';
tips = '后端服务出现异常,请联系管理员';
break;
case 4001:
errorMsg = '认证失败';
tips = '登录凭证已过期,请重新登录';
break;
case 4004:
errorMsg = '服务器未找到';
tips = '目标服务器信息不存在或已被删除';
break;
default:
errorMsg = event.reason || '连接已断开';
tips = event.code ? `错误代码: ${event.code}` : '请检查网络连接后重试';
}
setErrorMessage(errorMsg);
terminalInstanceRef.current?.writeln(`\r\n\x1b[31m✗ ${errorMsg}\x1b[0m`);
if (tips) {
terminalInstanceRef.current?.writeln(`\x1b[33m💡 ${tips}\x1b[0m`);
}
}
}
};
};
// 优雅关闭连接
const gracefulClose = useCallback(() => {
console.log('🚪 开始优雅关闭 SSH连接');
isClosingRef.current = true;
terminalInstanceRef.current?.writeln('\r\n\x1b[33m正在断开连接...\x1b[0m');
if (wsRef.current) {
if (wsRef.current.readyState === WebSocket.OPEN) {
wsRef.current.close(1000, 'User closed');
} else {
// 连接已经断开,直接通知父组件
wsRef.current = null;
setTimeout(() => {
onCloseReady?.();
}, 100);
}
} else {
// 没有WebSocket连接直接通知父组件
setTimeout(() => {
onCloseReady?.();
}, 100);
}
}, [onCloseReady]);
const handleReconnect = () => {
if (wsRef.current) {
wsRef.current.close();
wsRef.current = null;
}
if (terminalInstanceRef.current) {
terminalInstanceRef.current.clear();
}
connectWebSocket();
};
// 快捷操作:清屏
const handleClear = () => {
terminalInstanceRef.current?.clear();
message.success('终端已清屏');
};
// 快捷操作:复制选中文本
const handleCopy = async () => {
const selection = terminalInstanceRef.current?.getSelection();
if (selection) {
await navigator.clipboard.writeText(selection);
message.success('已复制到剪贴板');
} else {
message.warning('请先选中要复制的文本');
}
};
// 快捷操作:缩小字体
const handleZoomOut = () => {
const newSize = Math.max(fontSize - 2, 10);
setFontSize(newSize);
if (terminalInstanceRef.current) {
terminalInstanceRef.current.options.fontSize = newSize;
fitAddonRef.current?.fit();
}
};
// 快捷操作:放大字体
const handleZoomIn = () => {
const newSize = Math.min(fontSize + 2, 24);
setFontSize(newSize);
if (terminalInstanceRef.current) {
terminalInstanceRef.current.options.fontSize = newSize;
fitAddonRef.current?.fit();
}
};
// 搜索功能:切换显示
const toggleSearch = () => {
setShowSearch(prev => !prev);
if (!showSearch) {
// 打开搜索框时聚焦
setTimeout(() => {
document.getElementById('ssh-search-input')?.focus();
}, 100);
} else {
// 关闭搜索框时清除高亮
setSearchKeyword('');
}
};
// 搜索功能:查找下一个
const handleSearchNext = () => {
if (searchKeyword && searchAddonRef.current) {
const found = searchAddonRef.current.findNext(searchKeyword, {
caseSensitive: false,
wholeWord: false,
regex: false,
});
if (!found) {
message.info('已到达最后一个结果');
}
}
};
// 搜索功能:查找上一个
const handleSearchPrev = () => {
if (searchKeyword && searchAddonRef.current) {
const found = searchAddonRef.current.findPrevious(searchKeyword, {
caseSensitive: false,
wholeWord: false,
regex: false,
});
if (!found) {
message.info('已到达第一个结果');
}
}
};
// 搜索功能:处理搜索输入
const handleSearchChange = (value: string) => {
setSearchKeyword(value);
if (value && searchAddonRef.current) {
searchAddonRef.current.findNext(value, {
caseSensitive: false,
wholeWord: false,
regex: false,
});
}
};
// 搜索功能:处理回车键
const handleSearchKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
if (e.shiftKey) {
handleSearchPrev();
} else {
handleSearchNext();
}
} else if (e.key === 'Escape') {
toggleSearch();
}
};
// 暴露关闭方法给父组件
useEffect(() => {
(window as any)[`__closeSSH_${windowId}`] = gracefulClose;
return () => {
delete (window as any)[`__closeSSH_${windowId}`];
};
}, [windowId, gracefulClose]);
// 组件首次挂载时初始化终端
useEffect(() => {
console.log('📦 组件挂载,开始初始化...');
const timer = setTimeout(() => {
initializeTerminalAndConnect();
}, 50);
return () => {
console.log('🧹 组件卸载,清理资源...');
clearTimeout(timer);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // 空数组,只在首次挂载时运行
// 监听窗口大小变化
useEffect(() => {
let resizeObserver: ResizeObserver | null = null;
if (terminalRef.current) {
resizeObserver = new ResizeObserver(() => {
fitAddonRef.current?.fit();
});
resizeObserver.observe(terminalRef.current);
}
const handleWindowResize = () => {
fitAddonRef.current?.fit();
};
window.addEventListener('resize', handleWindowResize);
return () => {
window.removeEventListener('resize', handleWindowResize);
if (resizeObserver && terminalRef.current) {
resizeObserver.unobserve(terminalRef.current);
}
};
}, []);
// 组件卸载时清理资源
useEffect(() => {
return () => {
console.log('🧹 最终清理: 关闭WebSocket和Terminal');
if (wsRef.current) {
wsRef.current.close(1000, 'Component unmounted');
wsRef.current = null;
}
if (terminalInstanceRef.current) {
terminalInstanceRef.current.dispose();
terminalInstanceRef.current = null;
}
};
}, []);
const getStatusBadge = () => {
switch (connectionStatus) {
case 'connecting':
return <Badge variant="outline" className="bg-yellow-100 text-yellow-700"><Loader2 className="mr-1 h-3 w-3 animate-spin" /></Badge>;
case 'connected':
return <Badge variant="outline" className="bg-emerald-100 text-emerald-700"><div className="mr-1 h-2 w-2 rounded-full bg-emerald-500 animate-pulse" /></Badge>;
case 'reconnecting':
return <Badge variant="outline" className="bg-orange-100 text-orange-700"><Loader2 className="mr-1 h-3 w-3 animate-spin" /></Badge>;
case 'error':
return <Badge variant="outline" className="bg-red-100 text-red-700"><XCircle className="mr-1 h-3 w-3" /></Badge>;
case 'disconnected':
return <Badge variant="outline" className="bg-gray-100 text-gray-700"><XCircle className="mr-1 h-3 w-3" /></Badge>;
}
};
return (
<>
<style>{customStyles}</style>
<div className="flex flex-col h-full">
{/* 工具栏 - 快捷操作 */}
<div className="flex items-center justify-between px-3 py-2 border-b bg-gray-50 dark:bg-gray-800/50">
{/* 左侧:状态指示器 */}
<div className="flex items-center gap-2">
{getStatusBadge()}
<span className="text-xs text-gray-500 dark:text-gray-400">
{fontSize}px
</span>
</div>
{/* 右侧:所有操作按钮 */}
<div className="flex items-center gap-1">
<Button
size="sm"
variant="ghost"
className="h-7 px-2"
onClick={toggleSearch}
title="搜索 (Ctrl+F)"
>
<Search className="h-3.5 w-3.5" />
</Button>
<Button
size="sm"
variant="ghost"
className="h-7 px-2"
onClick={handleClear}
title="清屏 (Ctrl+L)"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
<Button
size="sm"
variant="ghost"
className="h-7 px-2"
onClick={handleCopy}
title="复制选中 (Ctrl+C)"
>
<Copy className="h-3.5 w-3.5" />
</Button>
<div className="h-4 w-px bg-gray-300 dark:bg-gray-600 mx-1" />
<Button
size="sm"
variant="ghost"
className="h-7 px-2"
onClick={handleZoomOut}
title="缩小字体"
>
<ZoomOut className="h-3.5 w-3.5" />
</Button>
<Button
size="sm"
variant="ghost"
className="h-7 px-2"
onClick={handleZoomIn}
title="放大字体"
>
<ZoomIn className="h-3.5 w-3.5" />
</Button>
{(connectionStatus === 'disconnected' || connectionStatus === 'error') && (
<>
<div className="h-4 w-px bg-gray-300 dark:bg-gray-600 mx-1" />
<Button
size="sm"
variant="outline"
className="h-7"
onClick={handleReconnect}
>
<RotateCcw className="h-3.5 w-3.5 mr-1" />
</Button>
</>
)}
</div>
</div>
{/* 终端区域 */}
<div ref={terminalContainerRef} className="flex-1 bg-[#1e1e1e] relative overflow-hidden">
{/* 搜索栏 */}
{showSearch && (
<div className="absolute top-2 right-2 z-10 flex items-center gap-1 bg-gray-900/95 border border-gray-700 rounded-lg px-3 py-2 shadow-lg">
<Search className="h-4 w-4 text-gray-400" />
<Input
id="ssh-search-input"
type="text"
placeholder="搜索终端内容..."
value={searchKeyword}
onChange={(e) => handleSearchChange(e.target.value)}
onKeyDown={handleSearchKeyDown}
className="h-7 w-48 bg-gray-800 border-gray-700 text-white placeholder:text-gray-500 focus:border-blue-500"
/>
<div className="flex items-center gap-0.5 ml-1">
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0 hover:bg-gray-800"
onClick={handleSearchPrev}
title="上一个 (Shift+Enter)"
>
<ChevronUp className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0 hover:bg-gray-800"
onClick={handleSearchNext}
title="下一个 (Enter)"
>
<ChevronDown className="h-4 w-4" />
</Button>
</div>
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0 hover:bg-gray-800"
onClick={toggleSearch}
title="关闭 (Esc)"
>
<X className="h-4 w-4" />
</Button>
</div>
)}
<div
ref={terminalRef}
className="w-full h-full p-2 ssh-terminal-content"
/>
{connectionStatus === 'connecting' && (
<div className="absolute inset-0 flex flex-col items-center justify-center text-yellow-400 bg-[#1e1e1e]">
<Loader2 className="h-12 w-12 animate-spin mb-4" />
<p className="text-lg">SSH...</p>
</div>
)}
{connectionStatus === 'reconnecting' && (
<div className="absolute inset-0 flex flex-col items-center justify-center text-orange-400 bg-[#1e1e1e]">
<Loader2 className="h-12 w-12 animate-spin mb-4" />
<p className="text-lg">...</p>
</div>
)}
{connectionStatus === 'error' && errorMessage && (
<div className="absolute inset-0 flex flex-col items-center justify-center text-red-400 bg-[#1e1e1e]">
<XCircle className="h-12 w-12 mb-4" />
<p className="text-lg font-semibold mb-2"></p>
<p className="text-sm text-gray-400">{errorMessage}</p>
<Button className="mt-6" variant="outline" onClick={handleReconnect}>
</Button>
</div>
)}
</div>
</div>
</>
);
};

View File

@ -1,9 +1,77 @@
import React, { useState, useCallback } from 'react'; /**
import { Terminal } from 'lucide-react'; * SSH
import { DraggableWindow } from '@/components/ui/draggable-window'; * 使 Terminal TerminalWindowManager
import { SSHTerminalContent } from './SSHTerminalContent'; */
import React from 'react';
import {
TerminalWindowManager,
Terminal,
type TerminalConnectionConfig,
type TerminalAuditConfig,
type TerminalToolbarConfig
} from '@/components/Terminal';
import type { ServerResponse } from '../types'; import type { ServerResponse } from '../types';
interface SSHWindowManagerProps {
onOpenWindow?: (windowId: string) => void;
}
export const SSHWindowManager: React.FC<SSHWindowManagerProps> = ({ onOpenWindow }) => {
return (
<TerminalWindowManager<ServerResponse>
type="ssh"
getWindowTitle={(server) => `SSH终端 - ${server.serverName} (${server.hostIp})`}
getWindowSubtitle={(server) => server.serverName}
getResourceId={(server) => server.id}
renderTerminal={(windowId, server, callbacks) => {
// SSH 连接配置
const token = localStorage.getItem('token');
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/api/v1/server-ssh/connect/${server.id}?token=${token}`;
const connectionConfig: TerminalConnectionConfig = {
type: 'ssh',
wsUrl,
token: token || undefined,
autoReconnect: true,
reconnectInterval: 3000,
maxReconnectAttempts: 5,
};
// 审计配置
const auditConfig: TerminalAuditConfig = {
enabled: true,
companyName: '链宇技术有限公司',
};
// 工具栏配置
const toolbarConfig: TerminalToolbarConfig = {
show: true,
showSearch: true,
showClear: true,
showCopy: true,
showFontSize: true,
showStatus: true,
showFontSizeLabel: true,
};
return (
<Terminal
id={windowId}
connection={connectionConfig}
audit={auditConfig}
toolbar={toolbarConfig}
onStatusChange={callbacks.onStatusChange}
onCloseReady={callbacks.onCloseReady}
/>
);
}}
onOpenWindow={onOpenWindow}
/>
);
};
// 保留类型定义以兼容现有代码
export interface SSHWindow { export interface SSHWindow {
id: string; id: string;
server: ServerResponse; server: ServerResponse;
@ -12,208 +80,3 @@ export interface SSHWindow {
size: { width: number; height: number }; size: { width: number; height: number };
connectionStatus?: 'connecting' | 'connected' | 'reconnecting' | 'disconnected' | 'error'; connectionStatus?: 'connecting' | 'connected' | 'reconnecting' | 'disconnected' | 'error';
} }
interface SSHWindowManagerProps {
onOpenWindow?: (windowId: string) => void;
}
export const SSHWindowManager: React.FC<SSHWindowManagerProps> = ({ onOpenWindow }) => {
const [windows, setWindows] = useState<SSHWindow[]>([]);
const [activeWindowId, setActiveWindowId] = useState<string | null>(null);
// 打开新窗口
const openWindow = useCallback((server: ServerResponse) => {
const windowId = `ssh-${server.id}-${Date.now()}`;
const offset = windows.length * 30;
const newWindow: SSHWindow = {
id: windowId,
server,
isMinimized: false,
position: { x: 100 + offset, y: 100 + offset },
size: { width: 1200, height: 700 },
connectionStatus: 'connecting',
};
setWindows(prev => [...prev, newWindow]);
setActiveWindowId(windowId);
onOpenWindow?.(windowId);
console.log(`✅ 打开SSH窗口: ${server.serverName} (${windowId})`);
}, [windows.length, onOpenWindow]);
// 真正关闭窗口(在连接断开后调用)
const actuallyCloseWindow = useCallback((windowId: string) => {
console.log(`❌ 真正关闭SSH窗口: ${windowId}`);
setWindows(prev => prev.filter(w => w.id !== windowId));
if (activeWindowId === windowId) {
setActiveWindowId(null);
}
}, [activeWindowId]);
// 关闭窗口
const closeWindow = useCallback((windowId: string) => {
console.log(`🚪 准备关闭SSH窗口: ${windowId}`);
// 调用gracefulClose方法优雅断开连接
const closeMethod = (window as any)[`__closeSSH_${windowId}`];
if (closeMethod && typeof closeMethod === 'function') {
console.log('✅ 调用优雅关闭方法');
closeMethod();
// 等待回调后再关闭窗口通过onCloseReady
} else {
// 如果没有找到关闭方法,直接关闭
console.warn('⚠️ 未找到优雅关闭方法,直接关闭窗口');
actuallyCloseWindow(windowId);
}
}, [actuallyCloseWindow]);
// 最小化窗口
const minimizeWindow = useCallback((windowId: string) => {
setWindows(prev =>
prev.map(w =>
w.id === windowId ? { ...w, isMinimized: true } : w
)
);
console.log(` 最小化窗口: ${windowId}`);
}, []);
// 恢复窗口
const restoreWindow = useCallback((windowId: string) => {
setWindows(prev =>
prev.map(w =>
w.id === windowId ? { ...w, isMinimized: false } : w
)
);
setActiveWindowId(windowId);
console.log(`⬆️ 恢复窗口: ${windowId}`);
// 多次触发resize事件确保终端正确调整尺寸
setTimeout(() => {
window.dispatchEvent(new Event('resize'));
}, 50);
setTimeout(() => {
window.dispatchEvent(new Event('resize'));
}, 150);
setTimeout(() => {
window.dispatchEvent(new Event('resize'));
}, 300);
}, []);
// 激活窗口
const focusWindow = useCallback((windowId: string) => {
setActiveWindowId(windowId);
}, []);
// 更新窗口连接状态
const updateWindowStatus = useCallback((windowId: string, status: SSHWindow['connectionStatus']) => {
setWindows(prev =>
prev.map(w =>
w.id === windowId ? { ...w, connectionStatus: status } : w
)
);
}, []);
// 获取状态对应的按钮样式
const getButtonStyle = (status?: SSHWindow['connectionStatus']) => {
switch (status) {
case 'connected':
return 'bg-green-600 hover:bg-green-700';
case 'error':
case 'disconnected':
return 'bg-red-600 hover:bg-red-700';
case 'connecting':
return 'bg-yellow-600 hover:bg-yellow-700';
case 'reconnecting':
return 'bg-orange-600 hover:bg-orange-700';
default:
return 'bg-blue-600 hover:bg-blue-700';
}
};
// 暴露openWindow方法给外部使用
React.useEffect(() => {
(window as any).__openSSHWindow = openWindow;
return () => {
delete (window as any).__openSSHWindow;
};
}, [openWindow]);
return (
<>
{/* 渲染所有窗口 - 最小化时隐藏但保持渲染SSH连接和尺寸 */}
{windows.map(window => (
<div
key={window.id}
style={{
visibility: window.isMinimized ? 'hidden' : 'visible',
pointerEvents: window.isMinimized ? 'none' : 'auto',
}}
>
<DraggableWindow
id={window.id}
title={`SSH终端 - ${window.server.serverName} (${window.server.hostIp})`}
onClose={() => closeWindow(window.id)}
onMinimize={() => minimizeWindow(window.id)}
isActive={activeWindowId === window.id}
onFocus={() => focusWindow(window.id)}
initialPosition={window.position}
initialSize={window.size}
>
<SSHTerminalContent
server={window.server}
windowId={window.id}
onCloseReady={() => actuallyCloseWindow(window.id)}
onStatusChange={(status) => updateWindowStatus(window.id, status)}
/>
</DraggableWindow>
</div>
))}
{/* 最小化的窗口悬浮按钮 */}
{windows.filter(w => w.isMinimized).length > 0 && (
<div className="fixed bottom-4 right-4 flex flex-col-reverse gap-2 z-[1001]">
{windows
.filter(w => w.isMinimized)
.map((window) => {
// 计算同一服务器的窗口数量和当前窗口序号
const sameServerWindows = windows.filter(w => w.server.id === window.server.id);
const needsIndex = sameServerWindows.length > 1;
const windowIndex = sameServerWindows.findIndex(w => w.id === window.id) + 1;
return (
<button
key={window.id}
className={`flex items-center gap-3 px-4 py-3 ${getButtonStyle(window.connectionStatus)} text-white rounded-lg shadow-lg transition-all hover:scale-105 hover:shadow-xl relative`}
onClick={() => restoreWindow(window.id)}
title={`恢复: ${window.server.serverName}${needsIndex ? ` #${windowIndex}` : ''}`}
>
{/* 状态指示灯 */}
<div className="absolute top-2 right-2">
<div className={`h-2 w-2 rounded-full ${
window.connectionStatus === 'connected' ? 'bg-white animate-pulse' :
window.connectionStatus === 'error' || window.connectionStatus === 'disconnected' ? 'bg-white/70' :
'bg-white/50 animate-pulse'
}`} />
</div>
<Terminal className="h-5 w-5" />
<div className="text-left">
<div className="text-sm font-semibold flex items-center gap-2">
<span>{window.server.serverName}</span>
{needsIndex && (
<span className="text-xs opacity-75">#{windowIndex}</span>
)}
</div>
<div className="text-xs opacity-90">
{window.server.hostIp}
</div>
</div>
</button>
);
})}
</div>
)}
</>
);
};

View File

@ -1,118 +0,0 @@
import React, { useState, useCallback } from 'react';
import { Terminal } from 'lucide-react';
import { DraggableWindow } from './DraggableWindow';
import { SSHTerminalContent } from './SSHTerminalContent';
import type { ServerResponse } from '../types';
export interface SSHWindow {
id: string;
server: ServerResponse;
isMinimized: boolean;
position: { x: number; y: number };
size: { width: number; height: number };
}
export const WindowManager: React.FC = () => {
const [windows, setWindows] = useState<SSHWindow[]>([]);
const [activeWindowId, setActiveWindowId] = useState<string | null>(null);
// 打开新窗口
const openWindow = useCallback((server: ServerResponse) => {
const windowId = `ssh-${server.id}-${Date.now()}`;
const offset = windows.length * 30;
setWindows(prev => [
...prev,
{
id: windowId,
server,
isMinimized: false,
position: { x: 100 + offset, y: 100 + offset },
size: { width: 1200, height: 700 },
},
]);
setActiveWindowId(windowId);
}, [windows.length]);
// 关闭窗口
const closeWindow = useCallback((windowId: string) => {
setWindows(prev => prev.filter(w => w.id !== windowId));
if (activeWindowId === windowId) {
setActiveWindowId(null);
}
}, [activeWindowId]);
// 最小化窗口
const minimizeWindow = useCallback((windowId: string) => {
setWindows(prev =>
prev.map(w =>
w.id === windowId ? { ...w, isMinimized: true } : w
)
);
}, []);
// 恢复窗口
const restoreWindow = useCallback((windowId: string) => {
setWindows(prev =>
prev.map(w =>
w.id === windowId ? { ...w, isMinimized: false } : w
)
);
setActiveWindowId(windowId);
}, []);
// 激活窗口
const focusWindow = useCallback((windowId: string) => {
setActiveWindowId(windowId);
}, []);
// 暴露openWindow方法给外部使用
React.useEffect(() => {
(window as any).__openSSHWindow = openWindow;
return () => {
delete (window as any).__openSSHWindow;
};
}, [openWindow]);
return (
<>
{/* 渲染所有窗口 */}
{windows.map(window => (
!window.isMinimized && (
<DraggableWindow
key={window.id}
id={window.id}
title={`SSH终端 - ${window.server.serverName} (${window.server.hostIp})`}
onClose={() => closeWindow(window.id)}
onMinimize={() => minimizeWindow(window.id)}
isActive={activeWindowId === window.id}
onFocus={() => focusWindow(window.id)}
initialPosition={window.position}
initialSize={window.size}
>
<SSHTerminalContent server={window.server} windowId={window.id} />
</DraggableWindow>
)
))}
{/* 最小化的窗口悬浮按钮 */}
<div className="fixed bottom-4 right-4 flex flex-col-reverse gap-2 z-[1001]">
{windows
.filter(w => w.isMinimized)
.map((window, index) => (
<button
key={window.id}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg shadow-lg transition-all hover:scale-105"
onClick={() => restoreWindow(window.id)}
title={`恢复: ${window.server.serverName}`}
>
<Terminal className="h-4 w-4" />
<span className="text-sm font-medium">
{window.server.serverName}
</span>
</button>
))}
</div>
</>
);
};