增加SSH功能

This commit is contained in:
dengqichen 2025-12-05 16:47:02 +08:00
parent b69c3f58c5
commit 598ab2503d
10 changed files with 1304 additions and 493 deletions

View File

@ -55,6 +55,9 @@
"@tisoap/react-flow-smart-edge": "^4.0.1",
"@types/recharts": "^1.8.29",
"@types/uuid": "^10.0.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-search": "^0.15.0",
"@xterm/addon-web-links": "^0.11.0",
"@xyflow/react": "^12.8.6",
"ajv": "^8.17.1",
"ajv-formats": "^3.0.1",
@ -78,6 +81,7 @@
"recharts": "^2.15.0",
"rsuite": "^5.83.3",
"uuid": "^13.0.0",
"xterm": "^5.3.0",
"zod": "^3.24.1"
},
"devDependencies": {

View File

@ -140,6 +140,15 @@ importers:
'@types/uuid':
specifier: ^10.0.0
version: 10.0.0
'@xterm/addon-fit':
specifier: ^0.10.0
version: 0.10.0(@xterm/xterm@5.5.0)
'@xterm/addon-search':
specifier: ^0.15.0
version: 0.15.0(@xterm/xterm@5.5.0)
'@xterm/addon-web-links':
specifier: ^0.11.0
version: 0.11.0(@xterm/xterm@5.5.0)
'@xyflow/react':
specifier: ^12.8.6
version: 12.9.0(@types/react@18.3.18)(immer@10.1.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@ -209,6 +218,9 @@ importers:
uuid:
specifier: ^13.0.0
version: 13.0.0
xterm:
specifier: ^5.3.0
version: 5.3.0
zod:
specifier: ^3.24.1
version: 3.24.1
@ -2314,6 +2326,24 @@ packages:
peerDependencies:
vite: ^4.2.0 || ^5.0.0 || ^6.0.0
'@xterm/addon-fit@0.10.0':
resolution: {integrity: sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==}
peerDependencies:
'@xterm/xterm': ^5.0.0
'@xterm/addon-search@0.15.0':
resolution: {integrity: sha512-ZBZKLQ+EuKE83CqCmSSz5y1tx+aNOCUaA7dm6emgOX+8J9H1FWXZyrKfzjwzV+V14TV3xToz1goIeRhXBS5qjg==}
peerDependencies:
'@xterm/xterm': ^5.0.0
'@xterm/addon-web-links@0.11.0':
resolution: {integrity: sha512-nIHQ38pQI+a5kXnRaTgwqSHnX7KE6+4SVoceompgHL26unAxdfP6IPqUTSYPQgSwM56hsElfoNrrW5V7BUED/Q==}
peerDependencies:
'@xterm/xterm': ^5.0.0
'@xterm/xterm@5.5.0':
resolution: {integrity: sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==}
'@xyflow/react@12.9.0':
resolution: {integrity: sha512-bt37E8Wf2HQ7hHQaMSnOw4UEWQqWlNwzfgF9tjix5Fu9Pn/ph3wbexSS/wbWnTkv0vhgMVyphQLfFWIuCe59hQ==}
peerDependencies:
@ -4352,6 +4382,10 @@ packages:
wrappy@1.0.2:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
xterm@5.3.0:
resolution: {integrity: sha512-8QqjlekLUFTrU6x7xck1MsPzPA571K5zNqWm0M0oroYEWVOptZ0+ubQSkQ3uxIEhcIHRujJy6emDWX4A7qyFzg==}
deprecated: This package is now deprecated. Move to @xterm/xterm instead.
yallist@3.1.1:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
@ -6746,6 +6780,20 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@xterm/addon-fit@0.10.0(@xterm/xterm@5.5.0)':
dependencies:
'@xterm/xterm': 5.5.0
'@xterm/addon-search@0.15.0(@xterm/xterm@5.5.0)':
dependencies:
'@xterm/xterm': 5.5.0
'@xterm/addon-web-links@0.11.0(@xterm/xterm@5.5.0)':
dependencies:
'@xterm/xterm': 5.5.0
'@xterm/xterm@5.5.0': {}
'@xyflow/react@12.9.0(@types/react@18.3.18)(immer@10.1.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@xyflow/system': 0.0.71
@ -8984,6 +9032,8 @@ snapshots:
wrappy@1.0.2: {}
xterm@5.3.0: {}
yallist@3.1.1: {}
yaml@1.10.2: {}

View File

@ -0,0 +1,212 @@
import React, { useRef, useState, useEffect } from 'react';
import { X, Minus, Maximize2, Minimize2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
interface DraggableWindowProps {
id: string;
title: string;
children: React.ReactNode;
onClose: () => void;
onMinimize: () => void;
isActive: boolean;
onFocus: () => void;
initialPosition?: { x: number; y: number };
initialSize?: { width: number; height: number };
}
export const DraggableWindow: React.FC<DraggableWindowProps> = ({
id,
title,
children,
onClose,
onMinimize,
isActive,
onFocus,
initialPosition = { x: 100, y: 100 },
initialSize = { width: 1200, height: 700 },
}) => {
const windowRef = useRef<HTMLDivElement>(null);
const headerRef = useRef<HTMLDivElement>(null);
const [isDragging, setIsDragging] = useState(false);
const [isResizing, setIsResizing] = useState(false);
const [position, setPosition] = useState(initialPosition);
const [size, setSize] = useState(initialSize);
const [isMaximized, setIsMaximized] = useState(false);
const [savedState, setSavedState] = useState({ position: initialPosition, size: initialSize });
const dragStartRef = useRef({ x: 0, y: 0 });
const resizeStartRef = useRef({ x: 0, y: 0, width: 0, height: 0 });
// 拖动开始
const handleDragStart = (e: React.MouseEvent) => {
if (isMaximized) return;
setIsDragging(true);
dragStartRef.current = {
x: e.clientX - position.x,
y: e.clientY - position.y,
};
onFocus();
e.preventDefault();
};
// 拖动中
useEffect(() => {
if (!isDragging) return;
const handleMouseMove = (e: MouseEvent) => {
setPosition({
x: e.clientX - dragStartRef.current.x,
y: e.clientY - dragStartRef.current.y,
});
};
const handleMouseUp = () => {
setIsDragging(false);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
}, [isDragging]);
// 调整大小开始
const handleResizeStart = (e: React.MouseEvent) => {
if (isMaximized) return;
setIsResizing(true);
resizeStartRef.current = {
x: e.clientX,
y: e.clientY,
width: size.width,
height: size.height,
};
onFocus();
e.preventDefault();
e.stopPropagation();
};
// 调整大小中
useEffect(() => {
if (!isResizing) return;
const handleMouseMove = (e: MouseEvent) => {
const deltaX = e.clientX - resizeStartRef.current.x;
const deltaY = e.clientY - resizeStartRef.current.y;
setSize({
width: Math.max(600, resizeStartRef.current.width + deltaX),
height: Math.max(400, resizeStartRef.current.height + deltaY),
});
};
const handleMouseUp = () => {
setIsResizing(false);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
}, [isResizing]);
// 最大化/还原
const toggleMaximize = () => {
if (isMaximized) {
setPosition(savedState.position);
setSize(savedState.size);
} else {
setSavedState({ position, size });
setPosition({ x: 0, y: 0 });
setSize({ width: window.innerWidth, height: window.innerHeight });
}
setIsMaximized(!isMaximized);
};
// 双击标题栏最大化
const handleHeaderDoubleClick = () => {
toggleMaximize();
};
const zIndex = isActive ? 1000 : 999;
return (
<div
ref={windowRef}
className="fixed bg-white dark:bg-gray-900 border border-gray-300 dark:border-gray-700 rounded-lg shadow-2xl flex flex-col overflow-hidden"
style={{
left: `${position.x}px`,
top: `${position.y}px`,
width: `${size.width}px`,
height: `${size.height}px`,
zIndex,
transition: isMaximized ? 'all 0.2s ease' : 'none',
}}
onMouseDown={onFocus}
>
{/* 窗口标题栏 */}
<div
ref={headerRef}
className={`flex items-center justify-between px-4 py-3 border-b border-gray-200 dark:border-gray-700 cursor-move select-none ${
isActive ? 'bg-blue-50 dark:bg-blue-900/20' : 'bg-gray-50 dark:bg-gray-800'
}`}
onMouseDown={handleDragStart}
onDoubleClick={handleHeaderDoubleClick}
>
<div className="flex-1 font-semibold text-gray-900 dark:text-gray-100">
{title}
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
variant="ghost"
className="h-8 w-8 p-0"
onClick={onMinimize}
>
<Minus className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="ghost"
className="h-8 w-8 p-0"
onClick={toggleMaximize}
>
{isMaximized ? (
<Minimize2 className="h-4 w-4" />
) : (
<Maximize2 className="h-4 w-4" />
)}
</Button>
<Button
size="sm"
variant="ghost"
className="h-8 w-8 p-0 hover:bg-red-100 hover:text-red-600"
onClick={onClose}
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
{/* 窗口内容 */}
<div className="flex-1 overflow-hidden">
{children}
</div>
{/* 调整大小手柄 */}
{!isMaximized && (
<div
className="absolute bottom-0 right-0 w-4 h-4 cursor-nwse-resize"
onMouseDown={handleResizeStart}
style={{
background: 'linear-gradient(135deg, transparent 50%, #999 50%)',
}}
/>
)}
</div>
);
};

View File

@ -0,0 +1,709 @@
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 '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;
}
/* 水印样式 */
.terminal-watermark {
position: absolute;
inset: 0;
pointer-events: none;
user-select: none;
z-index: 1;
overflow: hidden;
}
.watermark-item {
position: absolute;
font-size: 14px;
font-weight: 500;
color: rgba(255, 255, 255, 0.03);
white-space: nowrap;
transform: rotate(-20deg);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
`;
interface SSHTerminalContentProps {
server: ServerResponse;
windowId: string;
onCloseReady?: () => void; // 断开完成后的回调
}
type ConnectionStatus = 'initializing' | 'connecting' | 'connected' | 'disconnecting' | 'disconnected' | 'error';
interface WebSocketMessage {
type: 'output' | 'error' | 'status';
data?: string;
message?: string;
status?: ConnectionStatus;
}
export const SSHTerminalContent: React.FC<SSHTerminalContentProps> = ({
server,
windowId,
onCloseReady,
}) => {
const terminalRef = useRef<HTMLDivElement>(null);
const terminalInstanceRef = useRef<Terminal | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const fitAddonRef = useRef<FitAddon | null>(null);
const [connectionStatus, setConnectionStatus] = useState<ConnectionStatus>('initializing');
const [errorMessage, setErrorMessage] = useState<string>('');
const isClosingRef = useRef<boolean>(false);
const [fontSize, setFontSize] = useState<number>(14);
const [showSearch, setShowSearch] = useState<boolean>(false);
const [searchKeyword, setSearchKeyword] = useState<string>('');
const searchAddonRef = useRef<SearchAddon | null>(null);
const [currentTime, setCurrentTime] = useState<string>('');
// 生成水印内容
const getWatermarkText = useCallback(() => {
const username = localStorage.getItem('username') || 'User';
return `${username} · ${server.serverName} · ${currentTime}`;
}, [server.serverName, currentTime]);
// 更新时间
useEffect(() => {
const updateTime = () => {
const now = new Date();
setCurrentTime(now.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
}));
};
updateTime();
const timer = setInterval(updateTime, 1000);
return () => clearInterval(timer);
}, []);
// 生成水印元素
const renderWatermark = () => {
const watermarkText = getWatermarkText();
const items = [];
const rows = 8;
const cols = 4;
for (let i = 0; i < rows; i++) {
for (let j = 0; j < cols; j++) {
items.push(
<div
key={`${i}-${j}`}
className="watermark-item"
style={{
top: `${i * 150 + 50}px`,
left: `${j * 400 + 50}px`,
}}
>
{watermarkText}
</div>
);
}
}
return items;
};
// 初始化终端和建立连接
const initializeTerminalAndConnect = useCallback(() => {
if (!terminalRef.current) {
console.error('❌ terminalRef.current 为 null无法初始化');
return;
}
console.log('✅ 开始初始化SSH终端:', server.serverName);
setConnectionStatus('initializing');
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: data,
}));
}
});
// 延迟连接
setTimeout(() => {
connectWebSocket();
}, 300);
}, [server.id]);
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 = () => {
terminalInstanceRef.current?.writeln('\x1b[32m✓ WebSocket连接已建立\x1b[0m');
terminalInstanceRef.current?.writeln('\x1b[36m正在建立SSH会话...\x1b[0m\r\n');
};
ws.onmessage = (event) => {
try {
const msg: WebSocketMessage = JSON.parse(event.data);
switch (msg.type) {
case 'output':
if (msg.data) {
terminalInstanceRef.current?.write(msg.data);
}
break;
case 'error':
terminalInstanceRef.current?.writeln(`\r\n\x1b[31m错误: ${msg.message}\x1b[0m\r\n`);
message.error(msg.message || '连接错误');
break;
case 'status':
if (msg.status) {
setConnectionStatus(msg.status);
if (msg.status === 'connected') {
terminalInstanceRef.current?.writeln('\x1b[32m✓ SSH会话已建立\x1b[0m\r\n');
setTimeout(() => {
fitAddonRef.current?.fit();
}, 100);
}
}
break;
}
} catch (error) {
console.error('❌ 解析WebSocket消息失败:', error);
}
};
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;
setConnectionStatus('disconnecting');
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(() => {
const timer = setTimeout(() => {
initializeTerminalAndConnect();
}, 50);
// 监听窗口大小变化ResizeObserver
let resizeObserver: ResizeObserver | null = null;
if (terminalRef.current) {
resizeObserver = new ResizeObserver(() => {
fitAddonRef.current?.fit();
});
resizeObserver.observe(terminalRef.current);
}
// 监听全局resize事件用于窗口恢复时触发
const handleWindowResize = () => {
fitAddonRef.current?.fit();
};
window.addEventListener('resize', handleWindowResize);
return () => {
clearTimeout(timer);
window.removeEventListener('resize', handleWindowResize);
if (resizeObserver && terminalRef.current) {
resizeObserver.unobserve(terminalRef.current);
}
if (wsRef.current) {
wsRef.current.close(1000, 'Component unmounted');
}
if (terminalInstanceRef.current) {
terminalInstanceRef.current.dispose();
}
};
}, [initializeTerminalAndConnect]);
const getStatusBadge = () => {
switch (connectionStatus) {
case 'initializing':
return <Badge variant="outline" className="bg-blue-100 text-blue-700"><Loader2 className="mr-1 h-3 w-3 animate-spin" /></Badge>;
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 'disconnecting':
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 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 === 'initializing' && (
<div className="absolute inset-0 flex flex-col items-center justify-center text-gray-400 bg-[#1e1e1e]">
<Loader2 className="h-12 w-12 animate-spin mb-4" />
<p className="text-lg">SSH终端...</p>
</div>
)}
{connectionStatus === 'disconnecting' && (
<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,492 +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 'xterm/css/xterm.css';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Loader2, XCircle } from 'lucide-react';
import { message } from 'antd';
import type { ServerResponse } from '../types';
// 添加自定义样式
const customStyles = `
/* 自定义滚动条样式 */
.terminal-container .xterm-viewport::-webkit-scrollbar {
width: 8px;
}
.terminal-container .xterm-viewport::-webkit-scrollbar-track {
background: #2d2d2d;
border-radius: 4px;
}
.terminal-container .xterm-viewport::-webkit-scrollbar-thumb {
background: #555;
border-radius: 4px;
}
.terminal-container .xterm-viewport::-webkit-scrollbar-thumb:hover {
background: #666;
}
/* 可调整大小的Dialog */
.resizable-dialog {
resize: both;
overflow: hidden;
min-width: 600px;
min-height: 400px;
max-width: 98vw;
max-height: 95vh;
}
`;
interface SSHTerminalDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
server: ServerResponse;
}
type ConnectionStatus = 'initializing' | 'connecting' | 'connected' | 'disconnecting' | 'disconnected' | 'error';
interface WebSocketMessage {
type: 'output' | 'error' | 'status';
data?: string;
message?: string;
status?: ConnectionStatus;
}
export const SSHTerminalDialog: React.FC<SSHTerminalDialogProps> = ({
open,
onOpenChange,
server,
}) => {
const terminalRef = useRef<HTMLDivElement>(null);
const terminalInstanceRef = useRef<Terminal | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const fitAddonRef = useRef<FitAddon | null>(null);
const [connectionStatus, setConnectionStatus] = useState<ConnectionStatus>('initializing');
const [errorMessage, setErrorMessage] = useState<string>('');
const reconnectTimerRef = useRef<NodeJS.Timeout | null>(null);
const isClosingRef = useRef<boolean>(false);
const dialogContentRef = useRef<HTMLDivElement>(null);
// 初始化终端和建立连接
const initializeTerminalAndConnect = useCallback(() => {
console.log('🚀 准备初始化SSH终端, terminalRef.current:', terminalRef.current);
if (!terminalRef.current) {
console.error('❌ terminalRef.current 为 null无法初始化');
return;
}
console.log('✅ 开始初始化SSH终端');
setConnectionStatus('initializing');
setErrorMessage('');
// 1. 初始化终端
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();
terminal.loadAddon(fitAddon);
terminal.loadAddon(webLinksAddon);
terminal.open(terminalRef.current);
terminalInstanceRef.current = terminal;
fitAddonRef.current = fitAddon;
// 延迟fit确保容器已完全渲染
setTimeout(() => {
fitAddon.fit();
console.log('📐 终端尺寸已调整:', terminal.cols, 'x', terminal.rows);
}, 100);
// 2. 监听终端输入
terminal.onData((data) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
console.log('\u2328\ufe0f \u53d1\u9001\u8f93\u5165:', data.replace(/\r/g, '\\r').replace(/\n/g, '\\n'));
wsRef.current.send(JSON.stringify({
type: 'input',
data: data,
}));
} else {
console.warn('\u26a0\ufe0f WebSocket\u672a\u8fde\u63a5\uff0c\u65e0\u6cd5\u53d1\u9001\u8f93\u5165');
}
});
// 3. 延迟一点时间再连接让loading状态显示出来
setTimeout(() => {
connectWebSocket();
}, 300);
}, [server.id]);
useEffect(() => {
if (!open) return;
// 延迟执行,确保 DOM 已经挂载
const timer = setTimeout(() => {
initializeTerminalAndConnect();
}, 50);
// 窗口大小调整
const handleResize = () => {
fitAddonRef.current?.fit();
console.log('📐 窗口resize触发终端尺寸调整');
};
window.addEventListener('resize', handleResize);
// 监听Dialog内容区域大小变化用户拖动resize
let resizeObserver: ResizeObserver | null = null;
if (terminalRef.current) {
resizeObserver = new ResizeObserver(() => {
fitAddonRef.current?.fit();
console.log('📐 Dialog大小变化触发终端尺寸调整');
});
resizeObserver.observe(terminalRef.current);
}
return () => {
clearTimeout(timer);
window.removeEventListener('resize', handleResize);
if (resizeObserver && terminalRef.current) {
resizeObserver.unobserve(terminalRef.current);
resizeObserver.disconnect();
}
// 清理时直接关闭,不显示断开中状态
if (wsRef.current) {
wsRef.current.close(1000, 'Component unmounted');
wsRef.current = null;
}
if (terminalInstanceRef.current) {
terminalInstanceRef.current.dispose();
terminalInstanceRef.current = null;
}
if (reconnectTimerRef.current) {
clearTimeout(reconnectTimerRef.current);
}
isClosingRef.current = false;
};
}, [open, initializeTerminalAndConnect]);
const connectWebSocket = () => {
if (wsRef.current?.readyState === WebSocket.OPEN) return;
console.log('\ud83d\udd0c 开始建立WebSocket连接');
setConnectionStatus('connecting');
setErrorMessage('');
// 获取token
const token = localStorage.getItem('token');
if (!token) {
const errorMsg = '认证失败:未登录或登录已过期';
console.error('❌', errorMsg);
setConnectionStatus('error');
setErrorMessage(errorMsg);
message.error(errorMsg);
return;
}
// 获取WebSocket URL - 根据当前协议自动判断并携带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 ws = new WebSocket(wsUrl);
wsRef.current = ws;
ws.onopen = () => {
console.log('\u2705 WebSocket\u8fde\u63a5\u5df2\u5efa\u7acb');
// \u6ce8\u610f\uff1a\u8fd9\u91cc\u4e0d\u76f4\u63a5\u8bbe\u7f6e\u4e3aconnected\uff0c\u7b49\u5f85\u540e\u7aef\u53d1\u9001status:connected
terminalInstanceRef.current?.writeln('\x1b[32m✓ WebSocket连接已建立\x1b[0m');
terminalInstanceRef.current?.writeln('\x1b[36m正在建立SSH会话...\x1b[0m\r\n');
};
ws.onmessage = (event) => {
console.log('📥 收到WebSocket消息:', event.data.substring(0, 200));
try {
const msg: WebSocketMessage = JSON.parse(event.data);
console.log('📦 解析后的消息:', msg.type, msg);
switch (msg.type) {
case 'output':
if (msg.data) {
console.log('📝 输出数据长度:', msg.data.length);
terminalInstanceRef.current?.write(msg.data);
}
break;
case 'error':
console.error('❌ SSH错误:', msg.message);
terminalInstanceRef.current?.writeln(`\r\n\x1b[31m错误: ${msg.message}\x1b[0m\r\n`);
message.error(msg.message || '连接错误');
break;
case 'status':
console.log('🔄 状态变更:', msg.status);
if (msg.status) {
setConnectionStatus(msg.status);
if (msg.status === 'connected') {
terminalInstanceRef.current?.writeln('\x1b[32m✓ SSH会话已建立\x1b[0m\r\n');
// 连接成功后重新fit确保尺寸正确
setTimeout(() => {
fitAddonRef.current?.fit();
console.log('📐 连接成功后重新调整终端尺寸');
}, 100);
} else if (msg.status === 'disconnected') {
terminalInstanceRef.current?.writeln('\r\n\x1b[33m连接已断开\x1b[0m');
}
}
break;
}
} catch (error) {
console.error('❌ 解析WebSocket消息失败:', error, '原始数据:', event.data);
}
};
ws.onerror = (error) => {
console.error('❌ WebSocket错误:', error);
const errorMsg = 'WebSocket连接失败请检查后端服务';
setConnectionStatus('error');
setErrorMessage(errorMsg);
terminalInstanceRef.current?.writeln('\r\n\x1b[31m✗ ' + errorMsg + '\x1b[0m');
message.error(errorMsg);
};
ws.onclose = (event) => {
console.log('🔌 WebSocket连接已关闭, code:', event.code, 'reason:', event.reason);
wsRef.current = null;
if (isClosingRef.current) {
// 用户主动关闭,直接关闭弹窗
handleConnectionClosed();
} else {
// 非主动关闭,显示断开状态
setConnectionStatus('disconnected');
if (!event.wasClean) {
const errorMsg = event.reason || '连接异常断开';
setErrorMessage(errorMsg);
terminalInstanceRef.current?.writeln('\r\n\x1b[31m✗ ' + errorMsg + '\x1b[0m');
} else {
terminalInstanceRef.current?.writeln('\r\n\x1b[33m连接已正常关闭\x1b[0m');
}
}
};
};
const disconnectWebSocket = (showDisconnecting: boolean = false) => {
if (wsRef.current) {
console.log('🔌 主动关闭WebSocket连接');
if (showDisconnecting) {
setConnectionStatus('disconnecting');
terminalInstanceRef.current?.writeln('\r\n\x1b[33m正在断开连接...\x1b[0m');
}
if (wsRef.current.readyState === WebSocket.OPEN) {
wsRef.current.close(1000, 'User closed');
} else {
// 如果连接已经断开,直接清理
wsRef.current = null;
if (isClosingRef.current) {
handleConnectionClosed();
}
}
} else if (isClosingRef.current) {
// 没有WebSocket连接直接关闭
handleConnectionClosed();
}
};
const handleConnectionClosed = () => {
console.log('✅ 连接已完全关闭,关闭弹窗');
isClosingRef.current = false;
// 延迟一点关闭,让用户看到断开完成
setTimeout(() => {
// 关闭弹窗并重置所有状态
setConnectionStatus('initializing');
setErrorMessage('');
onOpenChange(false);
}, 100);
};
const handleDialogClose = (open: boolean) => {
if (!open && connectionStatus !== 'disconnected') {
// 用户点击X或ESC关闭需要先断开连接
console.log('🚪 用户关闭弹窗,开始断开连接');
isClosingRef.current = true;
disconnectWebSocket(true);
} else if (!open) {
// 已经断开,直接关闭
onOpenChange(false);
} else {
// 打开弹窗
onOpenChange(true);
}
};
const handleReconnect = () => {
console.log('🔄 用户重新连接');
isClosingRef.current = false;
disconnectWebSocket();
if (terminalInstanceRef.current) {
terminalInstanceRef.current.clear();
terminalInstanceRef.current.dispose();
terminalInstanceRef.current = null;
}
// 重新初始化
initializeTerminalAndConnect();
};
const getStatusBadge = () => {
switch (connectionStatus) {
case 'initializing':
return (
<Badge variant="outline" className="bg-blue-100 text-blue-700 dark:bg-blue-500/10 dark:text-blue-300">
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
</Badge>
);
case 'connecting':
return (
<Badge variant="outline" className="bg-yellow-100 text-yellow-700 dark:bg-yellow-500/10 dark:text-yellow-300">
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
</Badge>
);
case 'connected':
return (
<Badge variant="outline" className="bg-emerald-100 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-300">
<div className="mr-1 h-2 w-2 rounded-full bg-emerald-500 animate-pulse" />
</Badge>
);
case 'error':
return (
<Badge variant="outline" className="bg-red-100 text-red-700 dark:bg-red-500/10 dark:text-red-300">
<XCircle className="mr-1 h-3 w-3" />
</Badge>
);
case 'disconnecting':
return (
<Badge variant="outline" className="bg-orange-100 text-orange-700 dark:bg-orange-500/10 dark:text-orange-300">
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
</Badge>
);
case 'disconnected':
return (
<Badge variant="outline" className="bg-gray-100 text-gray-700 dark:bg-gray-500/10 dark:text-gray-300">
<XCircle className="mr-1 h-3 w-3" />
</Badge>
);
}
};
return (
<>
{/* 注入自定义样式 */}
<style>{customStyles}</style>
<Dialog open={open} onOpenChange={handleDialogClose}>
<DialogContent className="resizable-dialog max-w-[95vw] h-[85vh] flex flex-col p-0">
<DialogHeader className="px-6 py-4 border-b">
<div className="flex items-start justify-between pr-8">
<div className="flex-1">
<DialogTitle>SSH终端</DialogTitle>
<DialogDescription className="mt-1">
{server.serverName} ({server.hostIp})
</DialogDescription>
</div>
<div className="flex items-center gap-2 mt-0.5">
{getStatusBadge()}
{(connectionStatus === 'disconnected' || connectionStatus === 'error') && (
<Button
size="sm"
variant="outline"
onClick={handleReconnect}
>
</Button>
)}
</div>
</div>
</DialogHeader>
<div className="flex-1 bg-[#1e1e1e] overflow-hidden relative">
{/* 终端容器 - 始终渲染确保fit能正确计算尺寸 */}
<div
ref={terminalRef}
className="w-full h-full p-2 terminal-container"
/>
{/* Loading 状态 - 覆盖在终端上方 */}
{connectionStatus === 'initializing' && (
<div className="absolute inset-0 flex flex-col items-center justify-center text-gray-400 bg-[#1e1e1e]">
<Loader2 className="h-12 w-12 animate-spin mb-4" />
<p className="text-lg">SSH终端...</p>
</div>
)}
{/* 断开中状态 - 覆盖在终端上方 */}
{connectionStatus === 'disconnecting' && (
<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>
</DialogContent>
</Dialog>
</>
);
};

View File

@ -0,0 +1,171 @@
import React, { useState, useCallback } from 'react';
import { Terminal } from 'lucide-react';
import { DraggableWindow } from '@/components/ui/draggable-window';
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 };
}
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 },
};
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);
}, []);
// 暴露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)}
/>
</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) => (
<button
key={window.id}
className="flex items-center gap-3 px-4 py-3 bg-blue-600 hover:bg-blue-700 text-white rounded-lg shadow-lg transition-all hover:scale-105 hover:shadow-xl"
onClick={() => restoreWindow(window.id)}
title={`恢复: ${window.server.serverName}`}
>
<Terminal className="h-5 w-5" />
<div className="text-left">
<div className="text-sm font-semibold">
{window.server.serverName}
</div>
<div className="text-xs opacity-90">
{window.server.hostIp}
</div>
</div>
</button>
))}
</div>
)}
</>
);
};

View File

@ -10,6 +10,7 @@ import {
Trash2,
Loader2,
ChevronDown,
Terminal,
} from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
@ -30,11 +31,12 @@ interface ServerCardProps {
onTest: (server: ServerResponse) => void;
onEdit: (server: ServerResponse) => void;
onDelete: (server: ServerResponse) => void;
onSSHConnect: (server: ServerResponse) => void;
isTesting?: boolean;
getOsIcon: (osType?: string) => React.ReactNode;
}
export const ServerCard: React.FC<ServerCardProps> = ({ server, onTest, onEdit, onDelete, isTesting, getOsIcon }) => {
export const ServerCard: React.FC<ServerCardProps> = ({ server, onTest, onEdit, onDelete, onSSHConnect, isTesting, getOsIcon }) => {
const [expanded, setExpanded] = React.useState(false);
const formatTime = (time?: string) => {
@ -155,6 +157,20 @@ export const ServerCard: React.FC<ServerCardProps> = ({ server, onTest, onEdit,
)}
</div>
<div className="flex items-center gap-1.5">
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 text-primary"
onClick={() => onSSHConnect(server)}
>
<Terminal className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>SSH连接</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button

View File

@ -0,0 +1,118 @@
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>
</>
);
};

View File

@ -40,6 +40,7 @@ import { CategoryManageDialog } from './components/CategoryManageDialog';
import { ServerEditDialog } from './components/ServerEditDialog';
import { ServerCard } from './components/ServerCard';
import { ServerTable } from './components/ServerTable';
import { SSHWindowManager } from './components/SSHWindowManager';
const ServerList: React.FC = () => {
const { toast } = useToast();
@ -204,6 +205,20 @@ const ServerList: React.FC = () => {
setDeleteDialogOpen(true);
};
// SSH连接 - 使用多窗口系统
const handleSSHConnect = (server: ServerResponse) => {
// 调用全局方法打开新的SSH窗口
if ((window as any).__openSSHWindow) {
(window as any).__openSSHWindow(server);
} else {
toast({
title: "系统错误",
description: "SSH窗口管理器未初始化",
variant: "destructive",
});
}
};
const confirmDelete = async () => {
if (!serverToDelete) return;
await deleteServer(serverToDelete.id);
@ -513,6 +528,7 @@ const ServerList: React.FC = () => {
onTest={handleTestConnection}
onEdit={handleEdit}
onDelete={handleDelete}
onSSHConnect={handleSSHConnect}
isTesting={testingServerId === server.id}
getOsIcon={getOsIcon}
/>
@ -592,6 +608,9 @@ const ServerList: React.FC = () => {
variant="destructive"
confirmText="确定"
/>
{/* SSH多窗口管理器 */}
<SSHWindowManager />
</TooltipProvider>
);
};

View File

@ -70,6 +70,7 @@ export default defineConfig(({ mode }) => {
'/api': {
target: proxyTarget,
changeOrigin: true,
ws: true, // 支持WebSocket代理
// 打印代理信息
configure: (proxy, _options) => {
proxy.on('error', (err, _req, _res) => {
@ -78,6 +79,9 @@ export default defineConfig(({ mode }) => {
proxy.on('proxyReq', (proxyReq, req, _res) => {
console.log('📡 代理请求:', req.method, req.url, '→', proxyTarget);
});
proxy.on('proxyReqWs', (proxyReq, req, socket, options, head) => {
console.log('🔌 WebSocket代理:', req.url, '→', proxyTarget);
});
}
}
},