增加SSH功能
This commit is contained in:
parent
b69c3f58c5
commit
598ab2503d
@ -55,6 +55,9 @@
|
|||||||
"@tisoap/react-flow-smart-edge": "^4.0.1",
|
"@tisoap/react-flow-smart-edge": "^4.0.1",
|
||||||
"@types/recharts": "^1.8.29",
|
"@types/recharts": "^1.8.29",
|
||||||
"@types/uuid": "^10.0.0",
|
"@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",
|
"@xyflow/react": "^12.8.6",
|
||||||
"ajv": "^8.17.1",
|
"ajv": "^8.17.1",
|
||||||
"ajv-formats": "^3.0.1",
|
"ajv-formats": "^3.0.1",
|
||||||
@ -78,6 +81,7 @@
|
|||||||
"recharts": "^2.15.0",
|
"recharts": "^2.15.0",
|
||||||
"rsuite": "^5.83.3",
|
"rsuite": "^5.83.3",
|
||||||
"uuid": "^13.0.0",
|
"uuid": "^13.0.0",
|
||||||
|
"xterm": "^5.3.0",
|
||||||
"zod": "^3.24.1"
|
"zod": "^3.24.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@ -140,6 +140,15 @@ importers:
|
|||||||
'@types/uuid':
|
'@types/uuid':
|
||||||
specifier: ^10.0.0
|
specifier: ^10.0.0
|
||||||
version: 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':
|
'@xyflow/react':
|
||||||
specifier: ^12.8.6
|
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)
|
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:
|
uuid:
|
||||||
specifier: ^13.0.0
|
specifier: ^13.0.0
|
||||||
version: 13.0.0
|
version: 13.0.0
|
||||||
|
xterm:
|
||||||
|
specifier: ^5.3.0
|
||||||
|
version: 5.3.0
|
||||||
zod:
|
zod:
|
||||||
specifier: ^3.24.1
|
specifier: ^3.24.1
|
||||||
version: 3.24.1
|
version: 3.24.1
|
||||||
@ -2314,6 +2326,24 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
vite: ^4.2.0 || ^5.0.0 || ^6.0.0
|
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':
|
'@xyflow/react@12.9.0':
|
||||||
resolution: {integrity: sha512-bt37E8Wf2HQ7hHQaMSnOw4UEWQqWlNwzfgF9tjix5Fu9Pn/ph3wbexSS/wbWnTkv0vhgMVyphQLfFWIuCe59hQ==}
|
resolution: {integrity: sha512-bt37E8Wf2HQ7hHQaMSnOw4UEWQqWlNwzfgF9tjix5Fu9Pn/ph3wbexSS/wbWnTkv0vhgMVyphQLfFWIuCe59hQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@ -4352,6 +4382,10 @@ packages:
|
|||||||
wrappy@1.0.2:
|
wrappy@1.0.2:
|
||||||
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
|
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:
|
yallist@3.1.1:
|
||||||
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
|
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
|
||||||
|
|
||||||
@ -6746,6 +6780,20 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- 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)':
|
'@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:
|
dependencies:
|
||||||
'@xyflow/system': 0.0.71
|
'@xyflow/system': 0.0.71
|
||||||
@ -8984,6 +9032,8 @@ snapshots:
|
|||||||
|
|
||||||
wrappy@1.0.2: {}
|
wrappy@1.0.2: {}
|
||||||
|
|
||||||
|
xterm@5.3.0: {}
|
||||||
|
|
||||||
yallist@3.1.1: {}
|
yallist@3.1.1: {}
|
||||||
|
|
||||||
yaml@1.10.2: {}
|
yaml@1.10.2: {}
|
||||||
|
|||||||
212
frontend/src/components/ui/draggable-window.tsx
Normal file
212
frontend/src/components/ui/draggable-window.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -10,6 +10,7 @@ import {
|
|||||||
Trash2,
|
Trash2,
|
||||||
Loader2,
|
Loader2,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
|
Terminal,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
@ -30,11 +31,12 @@ interface ServerCardProps {
|
|||||||
onTest: (server: ServerResponse) => void;
|
onTest: (server: ServerResponse) => void;
|
||||||
onEdit: (server: ServerResponse) => void;
|
onEdit: (server: ServerResponse) => void;
|
||||||
onDelete: (server: ServerResponse) => void;
|
onDelete: (server: ServerResponse) => void;
|
||||||
|
onSSHConnect: (server: ServerResponse) => void;
|
||||||
isTesting?: boolean;
|
isTesting?: boolean;
|
||||||
getOsIcon: (osType?: string) => React.ReactNode;
|
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 [expanded, setExpanded] = React.useState(false);
|
||||||
|
|
||||||
const formatTime = (time?: string) => {
|
const formatTime = (time?: string) => {
|
||||||
@ -155,6 +157,20 @@ export const ServerCard: React.FC<ServerCardProps> = ({ server, onTest, onEdit,
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5">
|
<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>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -40,6 +40,7 @@ import { CategoryManageDialog } from './components/CategoryManageDialog';
|
|||||||
import { ServerEditDialog } from './components/ServerEditDialog';
|
import { ServerEditDialog } from './components/ServerEditDialog';
|
||||||
import { ServerCard } from './components/ServerCard';
|
import { ServerCard } from './components/ServerCard';
|
||||||
import { ServerTable } from './components/ServerTable';
|
import { ServerTable } from './components/ServerTable';
|
||||||
|
import { SSHWindowManager } from './components/SSHWindowManager';
|
||||||
|
|
||||||
const ServerList: React.FC = () => {
|
const ServerList: React.FC = () => {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
@ -204,6 +205,20 @@ const ServerList: React.FC = () => {
|
|||||||
setDeleteDialogOpen(true);
|
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 () => {
|
const confirmDelete = async () => {
|
||||||
if (!serverToDelete) return;
|
if (!serverToDelete) return;
|
||||||
await deleteServer(serverToDelete.id);
|
await deleteServer(serverToDelete.id);
|
||||||
@ -513,6 +528,7 @@ const ServerList: React.FC = () => {
|
|||||||
onTest={handleTestConnection}
|
onTest={handleTestConnection}
|
||||||
onEdit={handleEdit}
|
onEdit={handleEdit}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
|
onSSHConnect={handleSSHConnect}
|
||||||
isTesting={testingServerId === server.id}
|
isTesting={testingServerId === server.id}
|
||||||
getOsIcon={getOsIcon}
|
getOsIcon={getOsIcon}
|
||||||
/>
|
/>
|
||||||
@ -592,6 +608,9 @@ const ServerList: React.FC = () => {
|
|||||||
variant="destructive"
|
variant="destructive"
|
||||||
confirmText="确定"
|
confirmText="确定"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* SSH多窗口管理器 */}
|
||||||
|
<SSHWindowManager />
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -70,6 +70,7 @@ export default defineConfig(({ mode }) => {
|
|||||||
'/api': {
|
'/api': {
|
||||||
target: proxyTarget,
|
target: proxyTarget,
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
|
ws: true, // 支持WebSocket代理
|
||||||
// 打印代理信息
|
// 打印代理信息
|
||||||
configure: (proxy, _options) => {
|
configure: (proxy, _options) => {
|
||||||
proxy.on('error', (err, _req, _res) => {
|
proxy.on('error', (err, _req, _res) => {
|
||||||
@ -78,6 +79,9 @@ export default defineConfig(({ mode }) => {
|
|||||||
proxy.on('proxyReq', (proxyReq, req, _res) => {
|
proxy.on('proxyReq', (proxyReq, req, _res) => {
|
||||||
console.log('📡 代理请求:', req.method, req.url, '→', proxyTarget);
|
console.log('📡 代理请求:', req.method, req.url, '→', proxyTarget);
|
||||||
});
|
});
|
||||||
|
proxy.on('proxyReqWs', (proxyReq, req, socket, options, head) => {
|
||||||
|
console.log('🔌 WebSocket代理:', req.url, '→', proxyTarget);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user