增加SSH功能

This commit is contained in:
dengqichen 2025-12-05 17:22:20 +08:00
parent cca2fd8c4a
commit 366935c575
4 changed files with 170 additions and 83 deletions

View File

@ -81,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",
"watermark-js-plus": "^1.6.3",
"xterm": "^5.3.0", "xterm": "^5.3.0",
"zod": "^3.24.1" "zod": "^3.24.1"
}, },

View File

@ -218,6 +218,9 @@ importers:
uuid: uuid:
specifier: ^13.0.0 specifier: ^13.0.0
version: 13.0.0 version: 13.0.0
watermark-js-plus:
specifier: ^1.6.3
version: 1.6.3
xterm: xterm:
specifier: ^5.3.0 specifier: ^5.3.0
version: 5.3.0 version: 5.3.0
@ -4359,6 +4362,9 @@ packages:
warning@4.0.3: warning@4.0.3:
resolution: {integrity: sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==} resolution: {integrity: sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==}
watermark-js-plus@1.6.3:
resolution: {integrity: sha512-iCLOGf70KacIwjGF9MDViYxQcRiVwOH7l42qDHLeE2HeUsQD1EQuUC9cKRG/4SErTUmdqV3yf5WnKk2dRARHPQ==}
whatwg-fetch@2.0.4: whatwg-fetch@2.0.4:
resolution: {integrity: sha512-dcQ1GWpOD/eEQ97k66aiEVpNnapVj90/+R+SXTPYGHpYBBypfKJEQjLrvMZ7YXbKm21gXd4NcuxUTjiv1YtLng==} resolution: {integrity: sha512-dcQ1GWpOD/eEQ97k66aiEVpNnapVj90/+R+SXTPYGHpYBBypfKJEQjLrvMZ7YXbKm21gXd4NcuxUTjiv1YtLng==}
@ -9010,6 +9016,8 @@ snapshots:
dependencies: dependencies:
loose-envify: 1.4.0 loose-envify: 1.4.0
watermark-js-plus@1.6.3: {}
whatwg-fetch@2.0.4: {} whatwg-fetch@2.0.4: {}
which@2.0.2: which@2.0.2:

View File

@ -3,6 +3,7 @@ import { Terminal } from 'xterm';
import { FitAddon } from '@xterm/addon-fit'; import { FitAddon } from '@xterm/addon-fit';
import { WebLinksAddon } from '@xterm/addon-web-links'; import { WebLinksAddon } from '@xterm/addon-web-links';
import { SearchAddon } from '@xterm/addon-search'; import { SearchAddon } from '@xterm/addon-search';
import { Watermark } from 'watermark-js-plus';
import 'xterm/css/xterm.css'; import 'xterm/css/xterm.css';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@ -30,32 +31,13 @@ const customStyles = `
.ssh-terminal-content .xterm-viewport::-webkit-scrollbar-thumb:hover { .ssh-terminal-content .xterm-viewport::-webkit-scrollbar-thumb:hover {
background: #666; 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 { interface SSHTerminalContentProps {
server: ServerResponse; server: ServerResponse;
windowId: string; windowId: string;
onCloseReady?: () => void; // 断开完成后的回调 onCloseReady?: () => void; // 断开完成后的回调
onStatusChange?: (status: ConnectionStatus) => void; // 状态变化回调
} }
type ConnectionStatus = 'initializing' | 'connecting' | 'connected' | 'disconnecting' | 'disconnected' | 'error'; type ConnectionStatus = 'initializing' | 'connecting' | 'connected' | 'disconnecting' | 'disconnected' | 'error';
@ -71,82 +53,111 @@ export const SSHTerminalContent: React.FC<SSHTerminalContentProps> = ({
server, server,
windowId, windowId,
onCloseReady, onCloseReady,
onStatusChange,
}) => { }) => {
const terminalRef = useRef<HTMLDivElement>(null); const terminalRef = useRef<HTMLDivElement>(null);
const terminalInstanceRef = useRef<Terminal | null>(null); const terminalInstanceRef = useRef<Terminal | null>(null);
const wsRef = useRef<WebSocket | null>(null); const wsRef = useRef<WebSocket | null>(null);
const fitAddonRef = useRef<FitAddon | 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 [connectionStatus, setConnectionStatus] = useState<ConnectionStatus>('initializing');
const [errorMessage, setErrorMessage] = useState<string>(''); const [errorMessage, setErrorMessage] = useState<string>('');
const isClosingRef = useRef<boolean>(false);
const [fontSize, setFontSize] = useState<number>(14); const [fontSize, setFontSize] = useState<number>(14);
const [showSearch, setShowSearch] = useState<boolean>(false); const [showSearch, setShowSearch] = useState<boolean>(false);
const [searchKeyword, setSearchKeyword] = useState<string>(''); const [searchKeyword, setSearchKeyword] = useState<string>('');
const searchAddonRef = useRef<SearchAddon | null>(null);
const [currentTime, setCurrentTime] = useState<string>(''); // 更新ref
// 生成水印内容
const getWatermarkText = useCallback(() => {
const username = localStorage.getItem('username') || 'User';
return `${username} · ${server.serverName} · ${currentTime}`;
}, [server.serverName, currentTime]);
// 更新时间
useEffect(() => { useEffect(() => {
const updateTime = () => { onStatusChangeRef.current = onStatusChange;
const now = new Date(); }, [onStatusChange]);
setCurrentTime(now.toLocaleString('zh-CN', {
year: 'numeric', // 状态变化时通知父组件
month: '2-digit', useEffect(() => {
day: '2-digit', onStatusChangeRef.current?.(connectionStatus);
hour: '2-digit', }, [connectionStatus]);
minute: '2-digit',
second: '2-digit',
hour12: false
}));
};
updateTime();
const timer = setInterval(updateTime, 1000);
return () => clearInterval(timer);
}, []);
// 生成水印元素 // 初始化水印
const renderWatermark = () => { useEffect(() => {
const watermarkText = getWatermarkText(); if (!terminalContainerRef.current) return;
const items = [];
const rows = 8; // 从localStorage获取用户信息
const cols = 4; const userInfoStr = localStorage.getItem('userInfo');
let username = '用户';
for (let i = 0; i < rows; i++) { if (userInfoStr) {
for (let j = 0; j < cols; j++) { try {
items.push( const userInfo = JSON.parse(userInfoStr);
<div username = userInfo.nickname || userInfo.username || '用户';
key={`${i}-${j}`} } catch (e) {
className="watermark-item" console.error('解析用户信息失败:', e);
style={{
top: `${i * 150 + 50}px`,
left: `${j * 400 + 50}px`,
}}
>
{watermarkText}
</div>
);
} }
} }
const now = new Date().toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
return items; // 获取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) { if (!terminalRef.current) {
console.error('❌ terminalRef.current 为 null无法初始化'); console.error('❌ terminalRef.current 为 null无法初始化');
return; return;
} }
console.log('✅ 开始初始化SSH终端:', server.serverName); 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'); setConnectionStatus('initializing');
setErrorMessage(''); setErrorMessage('');
@ -211,7 +222,7 @@ export const SSHTerminalContent: React.FC<SSHTerminalContentProps> = ({
setTimeout(() => { setTimeout(() => {
connectWebSocket(); connectWebSocket();
}, 300); }, 300);
}, [server.id]); };
const connectWebSocket = () => { const connectWebSocket = () => {
if (wsRef.current?.readyState === WebSocket.OPEN) return; if (wsRef.current?.readyState === WebSocket.OPEN) return;
@ -241,27 +252,38 @@ export const SSHTerminalContent: React.FC<SSHTerminalContentProps> = ({
wsRef.current = ws; wsRef.current = ws;
ws.onopen = () => { ws.onopen = () => {
console.log('🔗 WebSocket已连接:', wsUrl);
console.log('📺 Terminal实例存在:', !!terminalInstanceRef.current);
terminalInstanceRef.current?.writeln('\x1b[32m✓ WebSocket连接已建立\x1b[0m'); terminalInstanceRef.current?.writeln('\x1b[32m✓ WebSocket连接已建立\x1b[0m');
terminalInstanceRef.current?.writeln('\x1b[36m正在建立SSH会话...\x1b[0m\r\n'); terminalInstanceRef.current?.writeln('\x1b[36m正在建立SSH会话...\x1b[0m\r\n');
}; };
ws.onmessage = (event) => { ws.onmessage = (event) => {
console.log('📨 收到WebSocket消息:', event.data);
try { try {
const msg: WebSocketMessage = JSON.parse(event.data); const msg: WebSocketMessage = JSON.parse(event.data);
console.log('📦 解析后的消息:', msg);
switch (msg.type) { switch (msg.type) {
case 'output': case 'output':
if (msg.data) { console.log('📝 输出数据:', msg.data?.substring(0, 100));
terminalInstanceRef.current?.write(msg.data); 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; break;
case 'error': case 'error':
console.error('❌ SSH错误:', msg.message);
terminalInstanceRef.current?.writeln(`\r\n\x1b[31m错误: ${msg.message}\x1b[0m\r\n`); terminalInstanceRef.current?.writeln(`\r\n\x1b[31m错误: ${msg.message}\x1b[0m\r\n`);
message.error(msg.message || '连接错误'); message.error(msg.message || '连接错误');
break; break;
case 'status': case 'status':
console.log('📊 状态变化:', msg.status);
if (msg.status) { if (msg.status) {
setConnectionStatus(msg.status); setConnectionStatus(msg.status);
if (msg.status === 'connected') { if (msg.status === 'connected') {
@ -274,7 +296,7 @@ export const SSHTerminalContent: React.FC<SSHTerminalContentProps> = ({
break; break;
} }
} catch (error) { } 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]); }, [windowId, gracefulClose]);
// 组件首次挂载时初始化终端
useEffect(() => { useEffect(() => {
console.log('📦 组件挂载,开始初始化...');
const timer = setTimeout(() => { const timer = setTimeout(() => {
initializeTerminalAndConnect(); initializeTerminalAndConnect();
}, 50); }, 50);
// 监听窗口大小变化ResizeObserver return () => {
console.log('🧹 组件卸载,清理资源...');
clearTimeout(timer);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // 空数组,只在首次挂载时运行
// 监听窗口大小变化
useEffect(() => {
let resizeObserver: ResizeObserver | null = null; let resizeObserver: ResizeObserver | null = null;
if (terminalRef.current) { if (terminalRef.current) {
resizeObserver = new ResizeObserver(() => { resizeObserver = new ResizeObserver(() => {
@ -508,26 +540,33 @@ export const SSHTerminalContent: React.FC<SSHTerminalContentProps> = ({
resizeObserver.observe(terminalRef.current); resizeObserver.observe(terminalRef.current);
} }
// 监听全局resize事件用于窗口恢复时触发
const handleWindowResize = () => { const handleWindowResize = () => {
fitAddonRef.current?.fit(); fitAddonRef.current?.fit();
}; };
window.addEventListener('resize', handleWindowResize); window.addEventListener('resize', handleWindowResize);
return () => { return () => {
clearTimeout(timer);
window.removeEventListener('resize', handleWindowResize); window.removeEventListener('resize', handleWindowResize);
if (resizeObserver && terminalRef.current) { if (resizeObserver && terminalRef.current) {
resizeObserver.unobserve(terminalRef.current); resizeObserver.unobserve(terminalRef.current);
} }
};
}, []);
// 组件卸载时清理资源
useEffect(() => {
return () => {
console.log('🧹 最终清理: 关闭WebSocket和Terminal');
if (wsRef.current) { if (wsRef.current) {
wsRef.current.close(1000, 'Component unmounted'); wsRef.current.close(1000, 'Component unmounted');
wsRef.current = null;
} }
if (terminalInstanceRef.current) { if (terminalInstanceRef.current) {
terminalInstanceRef.current.dispose(); terminalInstanceRef.current.dispose();
terminalInstanceRef.current = null;
} }
}; };
}, [initializeTerminalAndConnect]); }, []);
const getStatusBadge = () => { const getStatusBadge = () => {
switch (connectionStatus) { switch (connectionStatus) {
@ -627,7 +666,7 @@ export const SSHTerminalContent: React.FC<SSHTerminalContentProps> = ({
</div> </div>
{/* 终端区域 */} {/* 终端区域 */}
<div className="flex-1 bg-[#1e1e1e] relative overflow-hidden"> <div ref={terminalContainerRef} className="flex-1 bg-[#1e1e1e] relative overflow-hidden">
{/* 搜索栏 */} {/* 搜索栏 */}
{showSearch && ( {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"> <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">

View File

@ -10,6 +10,7 @@ export interface SSHWindow {
isMinimized: boolean; isMinimized: boolean;
position: { x: number; y: number }; position: { x: number; y: number };
size: { width: number; height: number }; size: { width: number; height: number };
connectionStatus?: 'initializing' | 'connecting' | 'connected' | 'disconnecting' | 'disconnected' | 'error';
} }
interface SSHWindowManagerProps { interface SSHWindowManagerProps {
@ -31,6 +32,7 @@ export const SSHWindowManager: React.FC<SSHWindowManagerProps> = ({ onOpenWindow
isMinimized: false, isMinimized: false,
position: { x: 100 + offset, y: 100 + offset }, position: { x: 100 + offset, y: 100 + offset },
size: { width: 1200, height: 700 }, size: { width: 1200, height: 700 },
connectionStatus: 'initializing',
}; };
setWindows(prev => [...prev, newWindow]); setWindows(prev => [...prev, newWindow]);
@ -103,6 +105,33 @@ export const SSHWindowManager: React.FC<SSHWindowManagerProps> = ({ onOpenWindow
setActiveWindowId(windowId); 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方法给外部使用 // 暴露openWindow方法给外部使用
React.useEffect(() => { React.useEffect(() => {
(window as any).__openSSHWindow = openWindow; (window as any).__openSSHWindow = openWindow;
@ -136,6 +165,7 @@ export const SSHWindowManager: React.FC<SSHWindowManagerProps> = ({ onOpenWindow
server={window.server} server={window.server}
windowId={window.id} windowId={window.id}
onCloseReady={() => actuallyCloseWindow(window.id)} onCloseReady={() => actuallyCloseWindow(window.id)}
onStatusChange={(status) => updateWindowStatus(window.id, status)}
/> />
</DraggableWindow> </DraggableWindow>
</div> </div>
@ -149,10 +179,19 @@ export const SSHWindowManager: React.FC<SSHWindowManagerProps> = ({ onOpenWindow
.map((window) => ( .map((window) => (
<button <button
key={window.id} 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)} onClick={() => restoreWindow(window.id)}
title={`恢复: ${window.server.serverName}`} 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" /> <Terminal className="h-5 w-5" />
<div className="text-left"> <div className="text-left">
<div className="text-sm font-semibold"> <div className="text-sm font-semibold">