增加SSH功能
This commit is contained in:
parent
cca2fd8c4a
commit
366935c575
@ -81,6 +81,7 @@
|
||||
"recharts": "^2.15.0",
|
||||
"rsuite": "^5.83.3",
|
||||
"uuid": "^13.0.0",
|
||||
"watermark-js-plus": "^1.6.3",
|
||||
"xterm": "^5.3.0",
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
|
||||
@ -218,6 +218,9 @@ importers:
|
||||
uuid:
|
||||
specifier: ^13.0.0
|
||||
version: 13.0.0
|
||||
watermark-js-plus:
|
||||
specifier: ^1.6.3
|
||||
version: 1.6.3
|
||||
xterm:
|
||||
specifier: ^5.3.0
|
||||
version: 5.3.0
|
||||
@ -4359,6 +4362,9 @@ packages:
|
||||
warning@4.0.3:
|
||||
resolution: {integrity: sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==}
|
||||
|
||||
watermark-js-plus@1.6.3:
|
||||
resolution: {integrity: sha512-iCLOGf70KacIwjGF9MDViYxQcRiVwOH7l42qDHLeE2HeUsQD1EQuUC9cKRG/4SErTUmdqV3yf5WnKk2dRARHPQ==}
|
||||
|
||||
whatwg-fetch@2.0.4:
|
||||
resolution: {integrity: sha512-dcQ1GWpOD/eEQ97k66aiEVpNnapVj90/+R+SXTPYGHpYBBypfKJEQjLrvMZ7YXbKm21gXd4NcuxUTjiv1YtLng==}
|
||||
|
||||
@ -9010,6 +9016,8 @@ snapshots:
|
||||
dependencies:
|
||||
loose-envify: 1.4.0
|
||||
|
||||
watermark-js-plus@1.6.3: {}
|
||||
|
||||
whatwg-fetch@2.0.4: {}
|
||||
|
||||
which@2.0.2:
|
||||
|
||||
@ -3,6 +3,7 @@ import { Terminal } from 'xterm';
|
||||
import { FitAddon } from '@xterm/addon-fit';
|
||||
import { WebLinksAddon } from '@xterm/addon-web-links';
|
||||
import { SearchAddon } from '@xterm/addon-search';
|
||||
import { Watermark } from 'watermark-js-plus';
|
||||
import 'xterm/css/xterm.css';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@ -30,32 +31,13 @@ const customStyles = `
|
||||
.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; // 断开完成后的回调
|
||||
onStatusChange?: (status: ConnectionStatus) => void; // 状态变化回调
|
||||
}
|
||||
|
||||
type ConnectionStatus = 'initializing' | 'connecting' | 'connected' | 'disconnecting' | 'disconnected' | 'error';
|
||||
@ -71,82 +53,111 @@ export const SSHTerminalContent: React.FC<SSHTerminalContentProps> = ({
|
||||
server,
|
||||
windowId,
|
||||
onCloseReady,
|
||||
onStatusChange,
|
||||
}) => {
|
||||
const terminalRef = useRef<HTMLDivElement>(null);
|
||||
const terminalInstanceRef = useRef<Terminal | null>(null);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const fitAddonRef = useRef<FitAddon | null>(null);
|
||||
const terminalContainerRef = useRef<HTMLDivElement>(null);
|
||||
const watermarkRef = useRef<Watermark | null>(null);
|
||||
const searchAddonRef = useRef<SearchAddon | null>(null);
|
||||
const onStatusChangeRef = useRef(onStatusChange);
|
||||
const isClosingRef = useRef<boolean>(false);
|
||||
|
||||
const [connectionStatus, setConnectionStatus] = useState<ConnectionStatus>('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]);
|
||||
|
||||
// 更新时间
|
||||
// 更新ref
|
||||
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
|
||||
}));
|
||||
};
|
||||
onStatusChangeRef.current = onStatusChange;
|
||||
}, [onStatusChange]);
|
||||
|
||||
updateTime();
|
||||
const timer = setInterval(updateTime, 1000);
|
||||
// 状态变化时通知父组件
|
||||
useEffect(() => {
|
||||
onStatusChangeRef.current?.(connectionStatus);
|
||||
}, [connectionStatus]);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
// 初始化水印
|
||||
useEffect(() => {
|
||||
if (!terminalContainerRef.current) return;
|
||||
|
||||
// 生成水印元素
|
||||
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>
|
||||
);
|
||||
// 从localStorage获取用户信息
|
||||
const userInfoStr = localStorage.getItem('userInfo');
|
||||
let username = '用户';
|
||||
if (userInfoStr) {
|
||||
try {
|
||||
const userInfo = JSON.parse(userInfoStr);
|
||||
username = userInfo.nickname || userInfo.username || '用户';
|
||||
} catch (e) {
|
||||
console.error('解析用户信息失败:', e);
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
};
|
||||
const now = new Date().toLocaleString('zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
|
||||
// 获取IP地址
|
||||
const serverIP = server.hostIp || '';
|
||||
|
||||
watermarkRef.current = new Watermark({
|
||||
content: `${username} · ${server.serverName}(${serverIP}) · ${now}`,
|
||||
width: 350,
|
||||
height: 200,
|
||||
rotate: -20,
|
||||
fontSize: '12px', // 减小字体
|
||||
fontColor: 'rgba(255, 255, 255, 0.15)',
|
||||
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
|
||||
zIndex: 1,
|
||||
parent: terminalContainerRef.current,
|
||||
globalAlpha: 0.5,
|
||||
});
|
||||
|
||||
watermarkRef.current.create();
|
||||
|
||||
return () => {
|
||||
watermarkRef.current?.destroy();
|
||||
};
|
||||
}, [server.serverName, server.hostIp]);
|
||||
|
||||
// 初始化终端和建立连接
|
||||
const initializeTerminalAndConnect = useCallback(() => {
|
||||
const initializeTerminalAndConnect = () => {
|
||||
if (!terminalRef.current) {
|
||||
console.error('❌ terminalRef.current 为 null,无法初始化');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('✅ 开始初始化SSH终端:', server.serverName);
|
||||
|
||||
// 清理旧的Terminal实例
|
||||
if (terminalInstanceRef.current) {
|
||||
console.log('🧹 清理旧的Terminal实例');
|
||||
try {
|
||||
terminalInstanceRef.current.dispose();
|
||||
terminalInstanceRef.current = null;
|
||||
} catch (e) {
|
||||
console.error('清理Terminal实例失败:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭旧的WebSocket连接
|
||||
if (wsRef.current) {
|
||||
console.log('🧹 关闭旧的WebSocket连接');
|
||||
try {
|
||||
wsRef.current.close(1000, 'Reinitializing');
|
||||
wsRef.current = null;
|
||||
} catch (e) {
|
||||
console.error('关闭WebSocket失败:', e);
|
||||
}
|
||||
}
|
||||
|
||||
setConnectionStatus('initializing');
|
||||
setErrorMessage('');
|
||||
|
||||
@ -211,7 +222,7 @@ export const SSHTerminalContent: React.FC<SSHTerminalContentProps> = ({
|
||||
setTimeout(() => {
|
||||
connectWebSocket();
|
||||
}, 300);
|
||||
}, [server.id]);
|
||||
};
|
||||
|
||||
const connectWebSocket = () => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) return;
|
||||
@ -241,27 +252,38 @@ export const SSHTerminalContent: React.FC<SSHTerminalContentProps> = ({
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log('🔗 WebSocket已连接:', wsUrl);
|
||||
console.log('📺 Terminal实例存在:', !!terminalInstanceRef.current);
|
||||
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);
|
||||
try {
|
||||
const msg: WebSocketMessage = JSON.parse(event.data);
|
||||
console.log('📦 解析后的消息:', msg);
|
||||
|
||||
switch (msg.type) {
|
||||
case 'output':
|
||||
if (msg.data) {
|
||||
terminalInstanceRef.current?.write(msg.data);
|
||||
console.log('📝 输出数据:', msg.data?.substring(0, 100));
|
||||
console.log('📺 Terminal实例:', !!terminalInstanceRef.current);
|
||||
if (msg.data && terminalInstanceRef.current) {
|
||||
terminalInstanceRef.current.write(msg.data);
|
||||
console.log('✅ 数据已写入终端');
|
||||
} else {
|
||||
console.warn('⚠️ 无法写入终端:', { hasData: !!msg.data, hasTerminal: !!terminalInstanceRef.current });
|
||||
}
|
||||
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') {
|
||||
@ -274,7 +296,7 @@ export const SSHTerminalContent: React.FC<SSHTerminalContentProps> = ({
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ 解析WebSocket消息失败:', error);
|
||||
console.error('❌ 解析WebSocket消息失败:', error, 'Raw data:', event.data);
|
||||
}
|
||||
};
|
||||
|
||||
@ -494,12 +516,22 @@ export const SSHTerminalContent: React.FC<SSHTerminalContentProps> = ({
|
||||
};
|
||||
}, [windowId, gracefulClose]);
|
||||
|
||||
// 组件首次挂载时初始化终端
|
||||
useEffect(() => {
|
||||
console.log('📦 组件挂载,开始初始化...');
|
||||
const timer = setTimeout(() => {
|
||||
initializeTerminalAndConnect();
|
||||
}, 50);
|
||||
|
||||
// 监听窗口大小变化(ResizeObserver)
|
||||
return () => {
|
||||
console.log('🧹 组件卸载,清理资源...');
|
||||
clearTimeout(timer);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []); // 空数组,只在首次挂载时运行
|
||||
|
||||
// 监听窗口大小变化
|
||||
useEffect(() => {
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
if (terminalRef.current) {
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
@ -508,26 +540,33 @@ export const SSHTerminalContent: React.FC<SSHTerminalContentProps> = ({
|
||||
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);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 组件卸载时清理资源
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
console.log('🧹 最终清理: 关闭WebSocket和Terminal');
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close(1000, 'Component unmounted');
|
||||
wsRef.current = null;
|
||||
}
|
||||
if (terminalInstanceRef.current) {
|
||||
terminalInstanceRef.current.dispose();
|
||||
terminalInstanceRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [initializeTerminalAndConnect]);
|
||||
}, []);
|
||||
|
||||
const getStatusBadge = () => {
|
||||
switch (connectionStatus) {
|
||||
@ -627,7 +666,7 @@ export const SSHTerminalContent: React.FC<SSHTerminalContentProps> = ({
|
||||
</div>
|
||||
|
||||
{/* 终端区域 */}
|
||||
<div className="flex-1 bg-[#1e1e1e] relative overflow-hidden">
|
||||
<div ref={terminalContainerRef} className="flex-1 bg-[#1e1e1e] relative overflow-hidden">
|
||||
{/* 搜索栏 */}
|
||||
{showSearch && (
|
||||
<div className="absolute top-2 right-2 z-10 flex items-center gap-1 bg-gray-900/95 border border-gray-700 rounded-lg px-3 py-2 shadow-lg">
|
||||
|
||||
@ -10,6 +10,7 @@ export interface SSHWindow {
|
||||
isMinimized: boolean;
|
||||
position: { x: number; y: number };
|
||||
size: { width: number; height: number };
|
||||
connectionStatus?: 'initializing' | 'connecting' | 'connected' | 'disconnecting' | 'disconnected' | 'error';
|
||||
}
|
||||
|
||||
interface SSHWindowManagerProps {
|
||||
@ -31,6 +32,7 @@ export const SSHWindowManager: React.FC<SSHWindowManagerProps> = ({ onOpenWindow
|
||||
isMinimized: false,
|
||||
position: { x: 100 + offset, y: 100 + offset },
|
||||
size: { width: 1200, height: 700 },
|
||||
connectionStatus: 'initializing',
|
||||
};
|
||||
|
||||
setWindows(prev => [...prev, newWindow]);
|
||||
@ -103,6 +105,33 @@ export const SSHWindowManager: React.FC<SSHWindowManagerProps> = ({ onOpenWindow
|
||||
setActiveWindowId(windowId);
|
||||
}, []);
|
||||
|
||||
// 更新窗口连接状态
|
||||
const updateWindowStatus = useCallback((windowId: string, status: SSHWindow['connectionStatus']) => {
|
||||
setWindows(prev =>
|
||||
prev.map(w =>
|
||||
w.id === windowId ? { ...w, connectionStatus: status } : w
|
||||
)
|
||||
);
|
||||
}, []);
|
||||
|
||||
// 获取状态对应的按钮样式
|
||||
const getButtonStyle = (status?: SSHWindow['connectionStatus']) => {
|
||||
switch (status) {
|
||||
case 'connected':
|
||||
return 'bg-green-600 hover:bg-green-700';
|
||||
case 'error':
|
||||
case 'disconnected':
|
||||
return 'bg-red-600 hover:bg-red-700';
|
||||
case 'connecting':
|
||||
case 'initializing':
|
||||
return 'bg-yellow-600 hover:bg-yellow-700';
|
||||
case 'disconnecting':
|
||||
return 'bg-orange-600 hover:bg-orange-700';
|
||||
default:
|
||||
return 'bg-blue-600 hover:bg-blue-700';
|
||||
}
|
||||
};
|
||||
|
||||
// 暴露openWindow方法给外部使用
|
||||
React.useEffect(() => {
|
||||
(window as any).__openSSHWindow = openWindow;
|
||||
@ -136,6 +165,7 @@ export const SSHWindowManager: React.FC<SSHWindowManagerProps> = ({ onOpenWindow
|
||||
server={window.server}
|
||||
windowId={window.id}
|
||||
onCloseReady={() => actuallyCloseWindow(window.id)}
|
||||
onStatusChange={(status) => updateWindowStatus(window.id, status)}
|
||||
/>
|
||||
</DraggableWindow>
|
||||
</div>
|
||||
@ -149,10 +179,19 @@ export const SSHWindowManager: React.FC<SSHWindowManagerProps> = ({ onOpenWindow
|
||||
.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"
|
||||
className={`flex items-center gap-3 px-4 py-3 ${getButtonStyle(window.connectionStatus)} text-white rounded-lg shadow-lg transition-all hover:scale-105 hover:shadow-xl relative`}
|
||||
onClick={() => restoreWindow(window.id)}
|
||||
title={`恢复: ${window.server.serverName}`}
|
||||
>
|
||||
{/* 状态指示灯 */}
|
||||
<div className="absolute top-2 right-2">
|
||||
<div className={`h-2 w-2 rounded-full ${
|
||||
window.connectionStatus === 'connected' ? 'bg-white animate-pulse' :
|
||||
window.connectionStatus === 'error' || window.connectionStatus === 'disconnected' ? 'bg-white/70' :
|
||||
'bg-white/50 animate-pulse'
|
||||
}`} />
|
||||
</div>
|
||||
|
||||
<Terminal className="h-5 w-5" />
|
||||
<div className="text-left">
|
||||
<div className="text-sm font-semibold">
|
||||
|
||||
Loading…
Reference in New Issue
Block a user