重写ssh前端组件,通用化
This commit is contained in:
parent
9325166132
commit
980c827feb
252
frontend/src/components/Terminal/Terminal.tsx
Normal file
252
frontend/src/components/Terminal/Terminal.tsx
Normal file
@ -0,0 +1,252 @@
|
||||
/**
|
||||
* Terminal 组件 - 通用终端
|
||||
* 支持 SSH、K8s Pod、Docker Container 等多种场景
|
||||
*/
|
||||
import React, { useEffect, useCallback, useState } from 'react';
|
||||
import 'xterm/css/xterm.css';
|
||||
import styles from './index.module.less';
|
||||
import { useTerminal } from './useTerminal';
|
||||
import { TerminalToolbar } from './TerminalToolbar';
|
||||
import type { TerminalProps, TerminalToolbarConfig } from './types';
|
||||
import { Loader2, XCircle } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { message } from 'antd';
|
||||
|
||||
export const Terminal: React.FC<TerminalProps> = ({
|
||||
id,
|
||||
connection,
|
||||
display,
|
||||
audit,
|
||||
toolbar,
|
||||
onStatusChange,
|
||||
onCloseReady,
|
||||
onError,
|
||||
}) => {
|
||||
const [fontSize, setFontSize] = useState(display?.fontSize ?? 14);
|
||||
const [showSearch, setShowSearch] = useState(false);
|
||||
|
||||
const {
|
||||
terminalRef,
|
||||
terminalInstance,
|
||||
fitAddon,
|
||||
searchAddon,
|
||||
connectionStatus,
|
||||
errorMessage,
|
||||
initializeTerminal,
|
||||
connectWebSocket,
|
||||
sendInput,
|
||||
sendResize,
|
||||
cleanup,
|
||||
} = useTerminal({
|
||||
connection,
|
||||
display: { ...display, fontSize },
|
||||
onStatusChange,
|
||||
onError,
|
||||
});
|
||||
|
||||
// 默认工具栏配置
|
||||
const toolbarConfig: TerminalToolbarConfig = {
|
||||
show: toolbar?.show ?? true,
|
||||
showSearch: toolbar?.showSearch ?? true,
|
||||
showClear: toolbar?.showClear ?? true,
|
||||
showCopy: toolbar?.showCopy ?? true,
|
||||
showFontSize: toolbar?.showFontSize ?? true,
|
||||
showStatus: toolbar?.showStatus ?? true,
|
||||
showFontSizeLabel: toolbar?.showFontSizeLabel ?? true,
|
||||
extraActions: toolbar?.extraActions,
|
||||
};
|
||||
|
||||
// 初始化
|
||||
useEffect(() => {
|
||||
const terminal = initializeTerminal();
|
||||
if (!terminal) return;
|
||||
|
||||
// 监听终端输入
|
||||
terminal.onData((data) => {
|
||||
sendInput(data);
|
||||
});
|
||||
|
||||
// 监听终端尺寸变化
|
||||
terminal.onResize((size) => {
|
||||
sendResize(size.rows, size.cols);
|
||||
});
|
||||
|
||||
// 延迟连接
|
||||
const timer = setTimeout(() => {
|
||||
connectWebSocket();
|
||||
}, 300);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
cleanup();
|
||||
};
|
||||
}, [id]);
|
||||
|
||||
// 显示审计警告
|
||||
useEffect(() => {
|
||||
if (connectionStatus === 'connected' && audit?.enabled && terminalInstance) {
|
||||
const companyName = audit.companyName || '';
|
||||
const customMessage = audit.message;
|
||||
|
||||
if (customMessage) {
|
||||
terminalInstance.writeln(`\r\n\x1b[33m${customMessage}\x1b[0m\r\n`);
|
||||
} else {
|
||||
terminalInstance.writeln('\r\n\x1b[33m┌─────────────────────────────────────────────────────────────\x1b[0m');
|
||||
terminalInstance.writeln(`\x1b[33m│ ⚠️ ${companyName} - 安全提示\x1b[0m`);
|
||||
terminalInstance.writeln('\x1b[33m│ 本次会话将被全程审计记录\x1b[0m');
|
||||
terminalInstance.writeln('\x1b[33m│ • 所有操作命令、输入、输出都将被完整记录\x1b[0m');
|
||||
terminalInstance.writeln('\x1b[33m│ • 审计日志用于安全审查、故障排查和合规要求\x1b[0m');
|
||||
terminalInstance.writeln('\x1b[33m│ • 请规范操作,遵守企业信息安全管理制度\x1b[0m');
|
||||
terminalInstance.writeln('\x1b[33m└─────────────────────────────────────────────────────────────\x1b[0m\r\n');
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
fitAddon?.fit();
|
||||
}, 100);
|
||||
}
|
||||
}, [connectionStatus, audit, terminalInstance, fitAddon]);
|
||||
|
||||
// 重连处理
|
||||
const handleReconnect = useCallback(() => {
|
||||
cleanup();
|
||||
setTimeout(() => {
|
||||
initializeTerminal();
|
||||
connectWebSocket();
|
||||
}, 100);
|
||||
}, [cleanup, initializeTerminal, connectWebSocket]);
|
||||
|
||||
// 搜索
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
const handleSearch = useCallback(() => {
|
||||
setShowSearch(!showSearch);
|
||||
if (!showSearch) {
|
||||
// 打开搜索时聚焦输入框
|
||||
setTimeout(() => {
|
||||
const input = document.querySelector('.terminal-search-input') as HTMLInputElement;
|
||||
input?.focus();
|
||||
}, 100);
|
||||
}
|
||||
}, [showSearch]);
|
||||
|
||||
const handleSearchChange = useCallback((value: string) => {
|
||||
setSearchQuery(value);
|
||||
if (searchAddon && terminalInstance) {
|
||||
searchAddon.findNext(value, { incremental: true });
|
||||
}
|
||||
}, [searchAddon, terminalInstance]);
|
||||
|
||||
const handleSearchNext = useCallback(() => {
|
||||
if (searchAddon && searchQuery) {
|
||||
searchAddon.findNext(searchQuery);
|
||||
}
|
||||
}, [searchAddon, searchQuery]);
|
||||
|
||||
const handleSearchPrev = useCallback(() => {
|
||||
if (searchAddon && searchQuery) {
|
||||
searchAddon.findPrevious(searchQuery);
|
||||
}
|
||||
}, [searchAddon, searchQuery]);
|
||||
|
||||
const handleCloseSearch = useCallback(() => {
|
||||
setShowSearch(false);
|
||||
setSearchQuery('');
|
||||
searchAddon?.clearDecorations();
|
||||
}, [searchAddon]);
|
||||
|
||||
// 清屏
|
||||
const handleClear = useCallback(() => {
|
||||
if (terminalInstance) {
|
||||
terminalInstance.clear();
|
||||
message.success('终端已清屏');
|
||||
}
|
||||
}, [terminalInstance]);
|
||||
|
||||
// 复制
|
||||
const handleCopy = useCallback(() => {
|
||||
if (terminalInstance) {
|
||||
const selection = terminalInstance.getSelection();
|
||||
if (selection) {
|
||||
navigator.clipboard.writeText(selection);
|
||||
message.success('已复制到剪贴板');
|
||||
} else {
|
||||
message.warning('请先选中要复制的内容');
|
||||
}
|
||||
}
|
||||
}, [terminalInstance]);
|
||||
|
||||
// 放大字体
|
||||
const handleZoomIn = useCallback(() => {
|
||||
const newSize = Math.min(fontSize + 2, 32);
|
||||
setFontSize(newSize);
|
||||
if (terminalInstance) {
|
||||
terminalInstance.options.fontSize = newSize;
|
||||
setTimeout(() => fitAddon?.fit(), 50);
|
||||
}
|
||||
}, [fontSize, terminalInstance, fitAddon]);
|
||||
|
||||
// 缩小字体
|
||||
const handleZoomOut = useCallback(() => {
|
||||
const newSize = Math.max(fontSize - 2, 10);
|
||||
setFontSize(newSize);
|
||||
if (terminalInstance) {
|
||||
terminalInstance.options.fontSize = newSize;
|
||||
setTimeout(() => fitAddon?.fit(), 50);
|
||||
}
|
||||
}, [fontSize, terminalInstance, fitAddon]);
|
||||
|
||||
return (
|
||||
<div className={styles.terminalWrapper}>
|
||||
{/* 工具栏 */}
|
||||
<TerminalToolbar
|
||||
config={toolbarConfig}
|
||||
connectionStatus={connectionStatus}
|
||||
fontSize={fontSize}
|
||||
onSearch={handleSearch}
|
||||
onClear={handleClear}
|
||||
onCopy={handleCopy}
|
||||
onZoomIn={handleZoomIn}
|
||||
onZoomOut={handleZoomOut}
|
||||
onReconnect={handleReconnect}
|
||||
/>
|
||||
|
||||
{/* 终端区域 */}
|
||||
<div className={styles.container}>
|
||||
<div
|
||||
ref={terminalRef}
|
||||
className={styles.content}
|
||||
/>
|
||||
|
||||
{/* 连接中 */}
|
||||
{connectionStatus === 'connecting' && (
|
||||
<div className={`${styles.statusOverlay} ${styles.connecting}`}>
|
||||
<Loader2 className={`${styles.statusIcon} animate-spin`} />
|
||||
<p className={styles.statusText}>正在连接...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 重连中 */}
|
||||
{connectionStatus === 'reconnecting' && (
|
||||
<div className={`${styles.statusOverlay} ${styles.reconnecting}`}>
|
||||
<Loader2 className={`${styles.statusIcon} animate-spin`} />
|
||||
<p className={styles.statusText}>连接中断,正在重连...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 错误 */}
|
||||
{connectionStatus === 'error' && (
|
||||
<div className={`${styles.statusOverlay} ${styles.error}`}>
|
||||
<XCircle className={styles.statusIcon} />
|
||||
<p className={styles.statusText}>连接失败</p>
|
||||
{errorMessage && (
|
||||
<p className={styles.statusDetail}>{errorMessage}</p>
|
||||
)}
|
||||
<Button onClick={handleReconnect} variant="outline">
|
||||
重新连接
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
139
frontend/src/components/Terminal/TerminalToolbar.tsx
Normal file
139
frontend/src/components/Terminal/TerminalToolbar.tsx
Normal file
@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Terminal 工具栏组件
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Search, Trash2, Copy, ZoomIn, ZoomOut, RotateCcw, Loader2, XCircle } from 'lucide-react';
|
||||
import type { ConnectionStatus, TerminalToolbarConfig } from './types';
|
||||
import styles from './index.module.less';
|
||||
|
||||
interface TerminalToolbarProps {
|
||||
config: TerminalToolbarConfig;
|
||||
connectionStatus: ConnectionStatus;
|
||||
fontSize: number;
|
||||
onSearch?: () => void;
|
||||
onClear?: () => void;
|
||||
onCopy?: () => void;
|
||||
onZoomIn?: () => void;
|
||||
onZoomOut?: () => void;
|
||||
onReconnect?: () => void;
|
||||
}
|
||||
|
||||
export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
config,
|
||||
connectionStatus,
|
||||
fontSize,
|
||||
onSearch,
|
||||
onClear,
|
||||
onCopy,
|
||||
onZoomIn,
|
||||
onZoomOut,
|
||||
onReconnect,
|
||||
}) => {
|
||||
if (!config.show) return null;
|
||||
|
||||
const getStatusBadge = () => {
|
||||
switch (connectionStatus) {
|
||||
case 'connecting':
|
||||
return <Badge variant="outline" className="bg-yellow-100 text-yellow-700"><Loader2 className="mr-1 h-3 w-3 animate-spin" />连接中</Badge>;
|
||||
case 'connected':
|
||||
return <Badge variant="outline" className="bg-emerald-100 text-emerald-700"><div className="mr-1 h-2 w-2 rounded-full bg-emerald-500 animate-pulse" />已连接</Badge>;
|
||||
case 'reconnecting':
|
||||
return <Badge variant="outline" className="bg-orange-100 text-orange-700"><Loader2 className="mr-1 h-3 w-3 animate-spin" />重连中</Badge>;
|
||||
case 'error':
|
||||
return <Badge variant="outline" className="bg-red-100 text-red-700"><XCircle className="mr-1 h-3 w-3" />连接失败</Badge>;
|
||||
case 'disconnected':
|
||||
return <Badge variant="outline" className="bg-gray-100 text-gray-700"><XCircle className="mr-1 h-3 w-3" />已断开</Badge>;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.toolbar}>
|
||||
{/* 左侧:状态指示器 */}
|
||||
<div className={styles.left}>
|
||||
{config.showStatus && getStatusBadge()}
|
||||
{config.showFontSizeLabel && (
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
字体 {fontSize}px
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 右侧:所有操作按钮 */}
|
||||
<div className={styles.right}>
|
||||
{config.showSearch && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 px-2"
|
||||
onClick={onSearch}
|
||||
title="搜索 (Ctrl+F)"
|
||||
>
|
||||
<Search className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
{config.showClear && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 px-2"
|
||||
onClick={onClear}
|
||||
title="清屏 (Ctrl+L)"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
{config.showCopy && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 px-2"
|
||||
onClick={onCopy}
|
||||
title="复制选中 (Ctrl+C)"
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
{config.showFontSize && (
|
||||
<>
|
||||
<div className="h-4 w-px bg-gray-300 dark:bg-gray-600 mx-1" />
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 px-2"
|
||||
onClick={onZoomOut}
|
||||
title="缩小字体"
|
||||
>
|
||||
<ZoomOut className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 px-2"
|
||||
onClick={onZoomIn}
|
||||
title="放大字体"
|
||||
>
|
||||
<ZoomIn className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{(connectionStatus === 'disconnected' || connectionStatus === 'error') && (
|
||||
<>
|
||||
<div className="h-4 w-px bg-gray-300 dark:bg-gray-600 mx-1" />
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7"
|
||||
onClick={onReconnect}
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5 mr-1" />
|
||||
重连
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{config.extraActions}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
253
frontend/src/components/Terminal/TerminalWindowManager.tsx
Normal file
253
frontend/src/components/Terminal/TerminalWindowManager.tsx
Normal file
@ -0,0 +1,253 @@
|
||||
/**
|
||||
* Terminal 窗口管理器 - 通用组件
|
||||
* 管理多个终端窗口:拖拽、最小化、层叠等
|
||||
* 支持 SSH、K8s Pod、Docker Container 等多种终端类型
|
||||
*/
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { Terminal } from 'lucide-react';
|
||||
import { DraggableWindow } from '@/components/ui/draggable-window';
|
||||
import type { ConnectionStatus, TerminalType } from './types';
|
||||
|
||||
/**
|
||||
* 终端窗口接口(泛型)
|
||||
*/
|
||||
export interface TerminalWindow<TResource = any> {
|
||||
id: string;
|
||||
resource: TResource;
|
||||
isMinimized: boolean;
|
||||
position: { x: number; y: number };
|
||||
size: { width: number; height: number };
|
||||
connectionStatus?: ConnectionStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* 窗口管理器 Props
|
||||
*/
|
||||
interface TerminalWindowManagerProps<TResource = any> {
|
||||
/** 终端类型 */
|
||||
type: TerminalType;
|
||||
/** 获取窗口标题 */
|
||||
getWindowTitle: (resource: TResource) => string;
|
||||
/** 获取窗口副标题 */
|
||||
getWindowSubtitle: (resource: TResource) => string;
|
||||
/** 获取资源ID */
|
||||
getResourceId: (resource: TResource) => string | number;
|
||||
/** 渲染终端内容 */
|
||||
renderTerminal: (windowId: string, resource: TResource, callbacks: {
|
||||
onCloseReady: () => void;
|
||||
onStatusChange: (status: ConnectionStatus) => void;
|
||||
}) => React.ReactNode;
|
||||
/** 窗口打开回调 */
|
||||
onOpenWindow?: (windowId: string) => void;
|
||||
}
|
||||
|
||||
export function TerminalWindowManager<TResource = any>({
|
||||
type,
|
||||
getWindowTitle,
|
||||
getWindowSubtitle,
|
||||
getResourceId,
|
||||
renderTerminal,
|
||||
onOpenWindow,
|
||||
}: TerminalWindowManagerProps<TResource>) {
|
||||
const [windows, setWindows] = useState<TerminalWindow<TResource>[]>([]);
|
||||
const [activeWindowId, setActiveWindowId] = useState<string | null>(null);
|
||||
|
||||
// 打开新窗口
|
||||
const openWindow = useCallback((resource: TResource) => {
|
||||
const resourceId = getResourceId(resource);
|
||||
const windowId = `${type}-${resourceId}-${Date.now()}`;
|
||||
const offset = windows.length * 30;
|
||||
|
||||
const newWindow: TerminalWindow<TResource> = {
|
||||
id: windowId,
|
||||
resource,
|
||||
isMinimized: false,
|
||||
position: { x: 100 + offset, y: 100 + offset },
|
||||
size: { width: 1200, height: 700 },
|
||||
connectionStatus: 'connecting',
|
||||
};
|
||||
|
||||
setWindows(prev => [...prev, newWindow]);
|
||||
setActiveWindowId(windowId);
|
||||
onOpenWindow?.(windowId);
|
||||
|
||||
console.log(`✅ 打开${type.toUpperCase()}窗口: ${getWindowTitle(resource)} (${windowId})`);
|
||||
}, [windows.length, type, getWindowTitle, getResourceId, onOpenWindow]);
|
||||
|
||||
// 真正关闭窗口
|
||||
const actuallyCloseWindow = useCallback((windowId: string) => {
|
||||
console.log(`❌ 真正关闭窗口: ${windowId}`);
|
||||
setWindows(prev => prev.filter(w => w.id !== windowId));
|
||||
if (activeWindowId === windowId) {
|
||||
setActiveWindowId(null);
|
||||
}
|
||||
}, [activeWindowId]);
|
||||
|
||||
// 关闭窗口(优雅关闭)
|
||||
const closeWindow = useCallback((windowId: string) => {
|
||||
console.log(`🚪 准备关闭窗口: ${windowId}`);
|
||||
|
||||
// 调用优雅关闭方法
|
||||
const closeMethod = (window as any)[`__closeSSH_${windowId}`];
|
||||
if (closeMethod && typeof closeMethod === 'function') {
|
||||
console.log('✅ 调用优雅关闭方法');
|
||||
closeMethod();
|
||||
} else {
|
||||
console.warn('⚠️ 未找到优雅关闭方法,直接关闭窗口');
|
||||
actuallyCloseWindow(windowId);
|
||||
}
|
||||
}, [actuallyCloseWindow]);
|
||||
|
||||
// 最小化窗口
|
||||
const minimizeWindow = useCallback((windowId: string) => {
|
||||
setWindows(prev =>
|
||||
prev.map(w =>
|
||||
w.id === windowId ? { ...w, isMinimized: true } : w
|
||||
)
|
||||
);
|
||||
console.log(`➖ 最小化窗口: ${windowId}`);
|
||||
}, []);
|
||||
|
||||
// 恢复窗口
|
||||
const restoreWindow = useCallback((windowId: string) => {
|
||||
setWindows(prev =>
|
||||
prev.map(w =>
|
||||
w.id === windowId ? { ...w, isMinimized: false } : w
|
||||
)
|
||||
);
|
||||
setActiveWindowId(windowId);
|
||||
console.log(`⬆️ 恢复窗口: ${windowId}`);
|
||||
|
||||
// 触发resize事件,确保终端正确调整尺寸
|
||||
setTimeout(() => window.dispatchEvent(new Event('resize')), 50);
|
||||
setTimeout(() => window.dispatchEvent(new Event('resize')), 150);
|
||||
setTimeout(() => window.dispatchEvent(new Event('resize')), 300);
|
||||
}, []);
|
||||
|
||||
// 激活窗口
|
||||
const focusWindow = useCallback((windowId: string) => {
|
||||
setActiveWindowId(windowId);
|
||||
}, []);
|
||||
|
||||
// 更新窗口连接状态
|
||||
const updateWindowStatus = useCallback((windowId: string, status: ConnectionStatus) => {
|
||||
setWindows(prev =>
|
||||
prev.map(w =>
|
||||
w.id === windowId ? { ...w, connectionStatus: status } : w
|
||||
)
|
||||
);
|
||||
}, []);
|
||||
|
||||
// 获取状态对应的按钮样式
|
||||
const getButtonStyle = (status?: ConnectionStatus) => {
|
||||
switch (status) {
|
||||
case 'connected':
|
||||
return 'bg-green-600 hover:bg-green-700';
|
||||
case 'error':
|
||||
case 'disconnected':
|
||||
return 'bg-red-600 hover:bg-red-700';
|
||||
case 'connecting':
|
||||
return 'bg-yellow-600 hover:bg-yellow-700';
|
||||
case 'reconnecting':
|
||||
return 'bg-orange-600 hover:bg-orange-700';
|
||||
default:
|
||||
return 'bg-blue-600 hover:bg-blue-700';
|
||||
}
|
||||
};
|
||||
|
||||
// 暴露 openWindow 方法给外部使用
|
||||
React.useEffect(() => {
|
||||
// 处理缩写词:ssh -> SSH, k8s -> K8s 等
|
||||
const typeMap: Record<string, string> = {
|
||||
'ssh': 'SSH',
|
||||
'k8s-pod': 'K8sPod',
|
||||
'docker-container': 'DockerContainer',
|
||||
};
|
||||
const typeName = typeMap[type] || type.charAt(0).toUpperCase() + type.slice(1);
|
||||
const globalKey = `__open${typeName}Window`;
|
||||
(window as any)[globalKey] = openWindow;
|
||||
|
||||
console.log(`✅ 注册全局方法: ${globalKey}`);
|
||||
|
||||
return () => {
|
||||
delete (window as any)[globalKey];
|
||||
};
|
||||
}, [openWindow, type]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 渲染所有窗口 */}
|
||||
{windows.map(win => (
|
||||
<div
|
||||
key={win.id}
|
||||
style={{
|
||||
visibility: win.isMinimized ? 'hidden' : 'visible',
|
||||
pointerEvents: win.isMinimized ? 'none' : 'auto',
|
||||
}}
|
||||
>
|
||||
<DraggableWindow
|
||||
id={win.id}
|
||||
title={getWindowTitle(win.resource)}
|
||||
onClose={() => closeWindow(win.id)}
|
||||
onMinimize={() => minimizeWindow(win.id)}
|
||||
isActive={activeWindowId === win.id}
|
||||
onFocus={() => focusWindow(win.id)}
|
||||
initialPosition={win.position}
|
||||
initialSize={win.size}
|
||||
>
|
||||
{renderTerminal(win.id, win.resource, {
|
||||
onCloseReady: () => actuallyCloseWindow(win.id),
|
||||
onStatusChange: (status) => updateWindowStatus(win.id, status),
|
||||
})}
|
||||
</DraggableWindow>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 最小化的窗口悬浮按钮 */}
|
||||
{windows.filter(w => w.isMinimized).length > 0 && (
|
||||
<div className="fixed bottom-4 right-4 flex flex-col-reverse gap-2 z-[1001]">
|
||||
{windows
|
||||
.filter(w => w.isMinimized)
|
||||
.map((win) => {
|
||||
// 计算同一资源的窗口数量
|
||||
const resourceId = getResourceId(win.resource);
|
||||
const sameResourceWindows = windows.filter(w => getResourceId(w.resource) === resourceId);
|
||||
const needsIndex = sameResourceWindows.length > 1;
|
||||
const windowIndex = sameResourceWindows.findIndex(w => w.id === win.id) + 1;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={win.id}
|
||||
className={`flex items-center gap-3 px-4 py-3 ${getButtonStyle(win.connectionStatus)} text-white rounded-lg shadow-lg transition-all hover:scale-105 hover:shadow-xl relative`}
|
||||
onClick={() => restoreWindow(win.id)}
|
||||
title={`恢复: ${getWindowSubtitle(win.resource)}${needsIndex ? ` #${windowIndex}` : ''}`}
|
||||
>
|
||||
{/* 状态指示灯 */}
|
||||
<div className="absolute top-2 right-2">
|
||||
<div className={`h-2 w-2 rounded-full ${
|
||||
win.connectionStatus === 'connected' ? 'bg-white animate-pulse' :
|
||||
win.connectionStatus === 'error' || win.connectionStatus === 'disconnected' ? 'bg-white/70' :
|
||||
'bg-white/50 animate-pulse'
|
||||
}`} />
|
||||
</div>
|
||||
|
||||
<Terminal className="h-5 w-5" />
|
||||
<div className="text-left">
|
||||
<div className="text-sm font-semibold flex items-center gap-2">
|
||||
<span>{getWindowSubtitle(win.resource)}</span>
|
||||
{needsIndex && (
|
||||
<span className="text-xs opacity-75">#{windowIndex}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs opacity-90">
|
||||
{type.toUpperCase()}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
185
frontend/src/components/Terminal/index.module.less
Normal file
185
frontend/src/components/Terminal/index.module.less
Normal file
@ -0,0 +1,185 @@
|
||||
/* Terminal 组件样式 - 通用终端样式 */
|
||||
|
||||
.terminalWrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background-color: #0a0a0a;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.5rem 1rem;
|
||||
background: #f9fafb;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
min-height: 48px;
|
||||
}
|
||||
|
||||
:global(.dark) .toolbar {
|
||||
background: #1f2937;
|
||||
border-bottom: 1px solid #374151;
|
||||
}
|
||||
|
||||
.left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.container {
|
||||
flex: 1;
|
||||
background-color: #1e1e1e;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.content {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
/* xterm.js 滚动条样式优化 */
|
||||
.content :global(.xterm) {
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.content :global(.xterm-screen) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.content :global(.xterm-viewport) {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(255, 255, 255, 0.3) transparent;
|
||||
}
|
||||
|
||||
.content :global(.xterm-viewport::-webkit-scrollbar) {
|
||||
width: 10px;
|
||||
}
|
||||
|
||||
.content :global(.xterm-viewport::-webkit-scrollbar-track) {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.content :global(.xterm-viewport::-webkit-scrollbar-thumb) {
|
||||
background-color: rgba(255, 255, 255, 0.3);
|
||||
border-radius: 5px;
|
||||
border: 2px solid transparent;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
.content :global(.xterm-viewport::-webkit-scrollbar-thumb:hover) {
|
||||
background-color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.statusOverlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: rgba(30, 30, 30, 0.95);
|
||||
backdrop-filter: blur(4px);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.connecting {
|
||||
color: #facc15;
|
||||
}
|
||||
|
||||
.reconnecting {
|
||||
color: #fb923c;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.statusIcon {
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.statusText {
|
||||
font-size: 1.125rem;
|
||||
line-height: 1.75rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.statusDetail {
|
||||
font-size: 0.875rem;
|
||||
color: #9ca3af;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.searchBar {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.5rem;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
background-color: rgba(17, 24, 39, 0.95);
|
||||
border: 1px solid #374151;
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.searchInput {
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: #fff;
|
||||
width: 200px;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.searchInput::placeholder {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.searchButton {
|
||||
padding: 0.25rem;
|
||||
cursor: pointer;
|
||||
color: #9ca3af;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.searchButton:hover {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* 响应式适配 */
|
||||
@media (max-width: 768px) {
|
||||
.toolbar {
|
||||
padding: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
.searchBar {
|
||||
top: 0.25rem;
|
||||
right: 0.25rem;
|
||||
}
|
||||
|
||||
.searchInput {
|
||||
width: 150px;
|
||||
}
|
||||
}
|
||||
20
frontend/src/components/Terminal/index.ts
Normal file
20
frontend/src/components/Terminal/index.ts
Normal file
@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Terminal 组件入口
|
||||
* 通用终端组件,支持 SSH、K8s Pod、Docker Container 等多种场景
|
||||
*/
|
||||
|
||||
export { Terminal } from './Terminal';
|
||||
export { useTerminal } from './useTerminal';
|
||||
export { TerminalWindowManager } from './TerminalWindowManager';
|
||||
export type { TerminalWindow } from './TerminalWindowManager';
|
||||
export type {
|
||||
TerminalType,
|
||||
ConnectionStatus,
|
||||
TerminalReceiveMessage,
|
||||
TerminalSendMessage,
|
||||
TerminalConnectionConfig,
|
||||
TerminalDisplayConfig,
|
||||
TerminalAuditConfig,
|
||||
TerminalToolbarConfig,
|
||||
TerminalProps,
|
||||
} from './types';
|
||||
134
frontend/src/components/Terminal/types.ts
Normal file
134
frontend/src/components/Terminal/types.ts
Normal file
@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Terminal 组件类型定义
|
||||
* 支持 SSH、K8s Pod 等多种终端场景
|
||||
*/
|
||||
|
||||
export type TerminalType = 'ssh' | 'k8s-pod' | 'docker-container';
|
||||
|
||||
export type ConnectionStatus = 'connecting' | 'connected' | 'reconnecting' | 'disconnected' | 'error';
|
||||
|
||||
/**
|
||||
* WebSocket 消息格式 - 接收
|
||||
*/
|
||||
export interface TerminalReceiveMessage {
|
||||
type: 'output' | 'status' | 'error';
|
||||
data: string | { response: { type: string; data: string } };
|
||||
timestamp?: number;
|
||||
metadata?: Record<string, any> | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* WebSocket 消息格式 - 发送
|
||||
*/
|
||||
export interface TerminalSendMessage {
|
||||
type: 'input' | 'resize';
|
||||
data: {
|
||||
request: {
|
||||
type: 'input' | 'resize';
|
||||
command?: string;
|
||||
rows?: number;
|
||||
cols?: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminal 连接配置
|
||||
*/
|
||||
export interface TerminalConnectionConfig {
|
||||
/** 连接类型 */
|
||||
type: TerminalType;
|
||||
/** WebSocket URL */
|
||||
wsUrl: string;
|
||||
/** 认证 Token */
|
||||
token?: string;
|
||||
/** 连接超时时间(毫秒) */
|
||||
timeout?: number;
|
||||
/** 是否自动重连 */
|
||||
autoReconnect?: boolean;
|
||||
/** 重连间隔(毫秒) */
|
||||
reconnectInterval?: number;
|
||||
/** 最大重连次数 */
|
||||
maxReconnectAttempts?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminal 显示配置
|
||||
*/
|
||||
export interface TerminalDisplayConfig {
|
||||
/** 字体大小 */
|
||||
fontSize?: number;
|
||||
/** 字体系列 */
|
||||
fontFamily?: string;
|
||||
/** 行数 */
|
||||
rows?: number;
|
||||
/** 列数 */
|
||||
cols?: number;
|
||||
/** 滚动缓冲区大小 */
|
||||
scrollback?: number;
|
||||
/** 光标闪烁 */
|
||||
cursorBlink?: boolean;
|
||||
/** 主题配置 */
|
||||
theme?: {
|
||||
background?: string;
|
||||
foreground?: string;
|
||||
cursor?: string;
|
||||
[key: string]: string | undefined;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminal 审计配置
|
||||
*/
|
||||
export interface TerminalAuditConfig {
|
||||
/** 是否启用审计 */
|
||||
enabled: boolean;
|
||||
/** 审计提示信息 */
|
||||
message?: string;
|
||||
/** 公司名称 */
|
||||
companyName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminal 工具栏配置
|
||||
*/
|
||||
export interface TerminalToolbarConfig {
|
||||
/** 是否显示工具栏 */
|
||||
show?: boolean;
|
||||
/** 是否显示搜索按钮 */
|
||||
showSearch?: boolean;
|
||||
/** 是否显示清屏按钮 */
|
||||
showClear?: boolean;
|
||||
/** 是否显示复制按钮 */
|
||||
showCopy?: boolean;
|
||||
/** 是否显示字体缩放按钮 */
|
||||
showFontSize?: boolean;
|
||||
/** 是否显示状态徽章 */
|
||||
showStatus?: boolean;
|
||||
/** 是否显示字体大小 */
|
||||
showFontSizeLabel?: boolean;
|
||||
/** 自定义额外操作 */
|
||||
extraActions?: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminal 组件 Props
|
||||
*/
|
||||
export interface TerminalProps {
|
||||
/** 唯一标识符 */
|
||||
id: string;
|
||||
/** 连接配置 */
|
||||
connection: TerminalConnectionConfig;
|
||||
/** 显示配置 */
|
||||
display?: TerminalDisplayConfig;
|
||||
/** 审计配置 */
|
||||
audit?: TerminalAuditConfig;
|
||||
/** 工具栏配置 */
|
||||
toolbar?: TerminalToolbarConfig;
|
||||
/** 连接状态变化回调 */
|
||||
onStatusChange?: (status: ConnectionStatus) => void;
|
||||
/** 关闭就绪回调 */
|
||||
onCloseReady?: () => void;
|
||||
/** 错误回调 */
|
||||
onError?: (error: string) => void;
|
||||
}
|
||||
226
frontend/src/components/Terminal/useTerminal.ts
Normal file
226
frontend/src/components/Terminal/useTerminal.ts
Normal file
@ -0,0 +1,226 @@
|
||||
/**
|
||||
* Terminal Hook - 终端核心逻辑
|
||||
*/
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { Terminal as XTerm } from 'xterm';
|
||||
import { FitAddon } from '@xterm/addon-fit';
|
||||
import { WebLinksAddon } from '@xterm/addon-web-links';
|
||||
import { SearchAddon } from '@xterm/addon-search';
|
||||
import type {
|
||||
ConnectionStatus,
|
||||
TerminalConnectionConfig,
|
||||
TerminalDisplayConfig,
|
||||
TerminalReceiveMessage,
|
||||
} from './types';
|
||||
|
||||
interface UseTerminalOptions {
|
||||
connection: TerminalConnectionConfig;
|
||||
display?: TerminalDisplayConfig;
|
||||
onStatusChange?: (status: ConnectionStatus) => void;
|
||||
onError?: (error: string) => void;
|
||||
}
|
||||
|
||||
export const useTerminal = (options: UseTerminalOptions) => {
|
||||
const { connection, display, onStatusChange, onError } = options;
|
||||
|
||||
const terminalRef = useRef<HTMLDivElement>(null);
|
||||
const terminalInstanceRef = useRef<XTerm | null>(null);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const fitAddonRef = useRef<FitAddon | null>(null);
|
||||
const searchAddonRef = useRef<SearchAddon | null>(null);
|
||||
|
||||
const [connectionStatus, setConnectionStatus] = useState<ConnectionStatus>('disconnected');
|
||||
const [errorMessage, setErrorMessage] = useState<string>('');
|
||||
|
||||
// 更新状态并触发回调
|
||||
const updateStatus = useCallback((status: ConnectionStatus) => {
|
||||
setConnectionStatus(status);
|
||||
onStatusChange?.(status);
|
||||
}, [onStatusChange]);
|
||||
|
||||
// 初始化终端
|
||||
const initializeTerminal = useCallback(() => {
|
||||
if (!terminalRef.current) return null;
|
||||
|
||||
const terminal = new XTerm({
|
||||
cursorBlink: display?.cursorBlink ?? true,
|
||||
fontSize: display?.fontSize ?? 14,
|
||||
fontFamily: display?.fontFamily ?? 'Consolas, "Courier New", monospace',
|
||||
theme: display?.theme ?? {
|
||||
background: '#1e1e1e',
|
||||
foreground: '#d4d4d4',
|
||||
cursor: '#d4d4d4',
|
||||
black: '#000000',
|
||||
red: '#cd3131',
|
||||
green: '#0dbc79',
|
||||
yellow: '#e5e510',
|
||||
blue: '#2472c8',
|
||||
magenta: '#bc3fbc',
|
||||
cyan: '#11a8cd',
|
||||
white: '#e5e5e5',
|
||||
brightBlack: '#666666',
|
||||
brightRed: '#f14c4c',
|
||||
brightGreen: '#23d18b',
|
||||
brightYellow: '#f5f543',
|
||||
brightBlue: '#3b8eea',
|
||||
brightMagenta: '#d670d6',
|
||||
brightCyan: '#29b8db',
|
||||
brightWhite: '#e5e5e5',
|
||||
},
|
||||
rows: display?.rows ?? 30,
|
||||
cols: display?.cols ?? 100,
|
||||
scrollback: display?.scrollback ?? 10000,
|
||||
});
|
||||
|
||||
const fitAddon = new FitAddon();
|
||||
const webLinksAddon = new WebLinksAddon();
|
||||
const searchAddon = new SearchAddon();
|
||||
|
||||
terminal.loadAddon(fitAddon);
|
||||
terminal.loadAddon(webLinksAddon);
|
||||
terminal.loadAddon(searchAddon);
|
||||
|
||||
terminal.open(terminalRef.current);
|
||||
|
||||
terminalInstanceRef.current = terminal;
|
||||
fitAddonRef.current = fitAddon;
|
||||
searchAddonRef.current = searchAddon;
|
||||
|
||||
setTimeout(() => {
|
||||
fitAddon.fit();
|
||||
}, 100);
|
||||
|
||||
return terminal;
|
||||
}, [display]);
|
||||
|
||||
// 连接 WebSocket
|
||||
const connectWebSocket = useCallback(() => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) return;
|
||||
|
||||
updateStatus('connecting');
|
||||
setErrorMessage('');
|
||||
|
||||
const ws = new WebSocket(connection.wsUrl);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log('🔗 WebSocket connected:', connection.wsUrl);
|
||||
|
||||
// 发送初始终端尺寸
|
||||
if (terminalInstanceRef.current) {
|
||||
const terminal = terminalInstanceRef.current;
|
||||
ws.send(JSON.stringify({
|
||||
type: 'resize',
|
||||
data: {
|
||||
request: {
|
||||
type: 'resize',
|
||||
rows: terminal.rows,
|
||||
cols: terminal.cols,
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const msg: TerminalReceiveMessage = JSON.parse(event.data);
|
||||
|
||||
// 提取实际数据:处理后端 response 包装格式
|
||||
const actualData = typeof msg.data === 'string'
|
||||
? msg.data
|
||||
: msg.data?.response?.data || '';
|
||||
|
||||
switch (msg.type) {
|
||||
case 'output':
|
||||
if (actualData && terminalInstanceRef.current) {
|
||||
terminalInstanceRef.current.write(actualData);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
setErrorMessage(actualData);
|
||||
updateStatus('error');
|
||||
onError?.(actualData);
|
||||
terminalInstanceRef.current?.writeln(`\r\n\x1b[31m错误: ${actualData}\x1b[0m\r\n`);
|
||||
break;
|
||||
|
||||
case 'status':
|
||||
updateStatus(actualData as ConnectionStatus);
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to parse WebSocket message:', error);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
updateStatus('error');
|
||||
setErrorMessage('WebSocket 连接错误');
|
||||
onError?.('WebSocket 连接错误');
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
if (connectionStatus === 'connected') {
|
||||
updateStatus('disconnected');
|
||||
}
|
||||
};
|
||||
}, [connection.wsUrl, connectionStatus, updateStatus, onError]);
|
||||
|
||||
// 发送输入
|
||||
const sendInput = useCallback((data: string) => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify({
|
||||
type: 'input',
|
||||
data: {
|
||||
request: {
|
||||
type: 'input',
|
||||
command: data,
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 发送尺寸变化
|
||||
const sendResize = useCallback((rows: number, cols: number) => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify({
|
||||
type: 'resize',
|
||||
data: {
|
||||
request: {
|
||||
type: 'resize',
|
||||
rows,
|
||||
cols,
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 清理资源
|
||||
const cleanup = useCallback(() => {
|
||||
if (terminalInstanceRef.current) {
|
||||
terminalInstanceRef.current.dispose();
|
||||
terminalInstanceRef.current = null;
|
||||
}
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close();
|
||||
wsRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
terminalRef,
|
||||
terminalInstance: terminalInstanceRef.current,
|
||||
fitAddon: fitAddonRef.current,
|
||||
searchAddon: searchAddonRef.current,
|
||||
connectionStatus,
|
||||
errorMessage,
|
||||
initializeTerminal,
|
||||
connectWebSocket,
|
||||
sendInput,
|
||||
sendResize,
|
||||
cleanup,
|
||||
};
|
||||
};
|
||||
@ -1,792 +0,0 @@
|
||||
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { Terminal } from 'xterm';
|
||||
import { FitAddon } from '@xterm/addon-fit';
|
||||
import { WebLinksAddon } from '@xterm/addon-web-links';
|
||||
import { SearchAddon } from '@xterm/addon-search';
|
||||
import { Watermark } from 'watermark-js-plus';
|
||||
import 'xterm/css/xterm.css';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Loader2, XCircle, Trash2, Copy, ZoomIn, ZoomOut, RotateCcw, Search, ChevronUp, ChevronDown, X } from 'lucide-react';
|
||||
import { message } from 'antd';
|
||||
import type { ServerResponse } from '../types';
|
||||
|
||||
// 自定义样式
|
||||
const customStyles = `
|
||||
.ssh-terminal-content .xterm-viewport::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.ssh-terminal-content .xterm-viewport::-webkit-scrollbar-track {
|
||||
background: #2d2d2d;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.ssh-terminal-content .xterm-viewport::-webkit-scrollbar-thumb {
|
||||
background: #555;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.ssh-terminal-content .xterm-viewport::-webkit-scrollbar-thumb:hover {
|
||||
background: #666;
|
||||
}
|
||||
`;
|
||||
|
||||
interface SSHTerminalContentProps {
|
||||
server: ServerResponse;
|
||||
windowId: string;
|
||||
onCloseReady?: () => void; // 断开完成后的回调
|
||||
onStatusChange?: (status: ConnectionStatus) => void; // 状态变化回调
|
||||
}
|
||||
|
||||
type ConnectionStatus = 'connecting' | 'connected' | 'reconnecting' | 'disconnected' | 'error';
|
||||
|
||||
interface WebSocketMessage {
|
||||
type: 'output' | 'input' | 'status' | 'error';
|
||||
data: string | { response: { type: string; data: string } }; // 支持字符串或 response 包装格式
|
||||
timestamp?: number;
|
||||
metadata?: Record<string, any> | null;
|
||||
}
|
||||
|
||||
export const SSHTerminalContent: React.FC<SSHTerminalContentProps> = ({
|
||||
server,
|
||||
windowId,
|
||||
onCloseReady,
|
||||
onStatusChange,
|
||||
}) => {
|
||||
const terminalRef = useRef<HTMLDivElement>(null);
|
||||
const terminalInstanceRef = useRef<Terminal | null>(null);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const fitAddonRef = useRef<FitAddon | null>(null);
|
||||
const terminalContainerRef = useRef<HTMLDivElement>(null);
|
||||
const watermarkRef = useRef<Watermark | null>(null);
|
||||
const searchAddonRef = useRef<SearchAddon | null>(null);
|
||||
const onStatusChangeRef = useRef(onStatusChange);
|
||||
const isClosingRef = useRef<boolean>(false);
|
||||
|
||||
const [connectionStatus, setConnectionStatus] = useState<ConnectionStatus>('connecting');
|
||||
const [errorMessage, setErrorMessage] = useState<string>('');
|
||||
const [fontSize, setFontSize] = useState<number>(14);
|
||||
const [showSearch, setShowSearch] = useState<boolean>(false);
|
||||
const [searchKeyword, setSearchKeyword] = useState<string>('');
|
||||
|
||||
// 更新ref
|
||||
useEffect(() => {
|
||||
onStatusChangeRef.current = onStatusChange;
|
||||
}, [onStatusChange]);
|
||||
|
||||
// 状态变化时通知父组件
|
||||
useEffect(() => {
|
||||
onStatusChangeRef.current?.(connectionStatus);
|
||||
}, [connectionStatus]);
|
||||
|
||||
// 初始化水印
|
||||
useEffect(() => {
|
||||
if (!terminalContainerRef.current) return;
|
||||
|
||||
// 从localStorage获取用户信息
|
||||
const userInfoStr = localStorage.getItem('userInfo');
|
||||
let username = '用户';
|
||||
if (userInfoStr) {
|
||||
try {
|
||||
const userInfo = JSON.parse(userInfoStr);
|
||||
username = userInfo.nickname || userInfo.username || '用户';
|
||||
} catch (e) {
|
||||
console.error('解析用户信息失败:', e);
|
||||
}
|
||||
}
|
||||
|
||||
const now = new Date().toLocaleString('zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
|
||||
// 获取IP地址
|
||||
const serverIP = server.hostIp || '';
|
||||
|
||||
watermarkRef.current = new Watermark({
|
||||
content: `${username} · ${server.serverName}(${serverIP}) · ${now}`,
|
||||
width: 350,
|
||||
height: 200,
|
||||
rotate: -20,
|
||||
fontSize: '12px', // 减小字体
|
||||
fontColor: 'rgba(255, 255, 255, 0.15)',
|
||||
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
|
||||
zIndex: 1,
|
||||
parent: terminalContainerRef.current,
|
||||
globalAlpha: 0.5,
|
||||
});
|
||||
|
||||
watermarkRef.current.create();
|
||||
|
||||
return () => {
|
||||
watermarkRef.current?.destroy();
|
||||
};
|
||||
}, [server.serverName, server.hostIp]);
|
||||
|
||||
// 初始化终端和建立连接
|
||||
const initializeTerminalAndConnect = () => {
|
||||
if (!terminalRef.current) {
|
||||
console.error('❌ terminalRef.current 为 null,无法初始化');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('✅ 开始初始化SSH终端:', server.serverName);
|
||||
|
||||
// 清理旧的Terminal实例
|
||||
if (terminalInstanceRef.current) {
|
||||
console.log('🧹 清理旧的Terminal实例');
|
||||
try {
|
||||
terminalInstanceRef.current.dispose();
|
||||
terminalInstanceRef.current = null;
|
||||
} catch (e) {
|
||||
console.error('清理Terminal实例失败:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭旧的WebSocket连接
|
||||
if (wsRef.current) {
|
||||
console.log('🧹 关闭旧的WebSocket连接');
|
||||
try {
|
||||
wsRef.current.close(1000, 'Reinitializing');
|
||||
wsRef.current = null;
|
||||
} catch (e) {
|
||||
console.error('关闭WebSocket失败:', e);
|
||||
}
|
||||
}
|
||||
|
||||
setConnectionStatus('connecting');
|
||||
setErrorMessage('');
|
||||
|
||||
const terminal = new Terminal({
|
||||
cursorBlink: true,
|
||||
fontSize: 14,
|
||||
fontFamily: 'Consolas, "Courier New", monospace',
|
||||
theme: {
|
||||
background: '#1e1e1e',
|
||||
foreground: '#d4d4d4',
|
||||
cursor: '#d4d4d4',
|
||||
black: '#000000',
|
||||
red: '#cd3131',
|
||||
green: '#0dbc79',
|
||||
yellow: '#e5e510',
|
||||
blue: '#2472c8',
|
||||
magenta: '#bc3fbc',
|
||||
cyan: '#11a8cd',
|
||||
white: '#e5e5e5',
|
||||
brightBlack: '#666666',
|
||||
brightRed: '#f14c4c',
|
||||
brightGreen: '#23d18b',
|
||||
brightYellow: '#f5f543',
|
||||
brightBlue: '#3b8eea',
|
||||
brightMagenta: '#d670d6',
|
||||
brightCyan: '#29b8db',
|
||||
brightWhite: '#e5e5e5',
|
||||
},
|
||||
rows: 30,
|
||||
cols: 100,
|
||||
});
|
||||
|
||||
const fitAddon = new FitAddon();
|
||||
const webLinksAddon = new WebLinksAddon();
|
||||
const searchAddon = new SearchAddon();
|
||||
|
||||
terminal.loadAddon(fitAddon);
|
||||
terminal.loadAddon(webLinksAddon);
|
||||
terminal.loadAddon(searchAddon);
|
||||
|
||||
searchAddonRef.current = searchAddon;
|
||||
terminal.open(terminalRef.current);
|
||||
|
||||
terminalInstanceRef.current = terminal;
|
||||
fitAddonRef.current = fitAddon;
|
||||
|
||||
setTimeout(() => {
|
||||
fitAddon.fit();
|
||||
}, 100);
|
||||
|
||||
// 监听终端输入
|
||||
terminal.onData((data) => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify({
|
||||
type: 'input',
|
||||
data: {
|
||||
request: {
|
||||
type: 'input',
|
||||
command: data,
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
// 监听终端尺寸变化
|
||||
terminal.onResize((size) => {
|
||||
console.log('📐 终端尺寸变化:', size);
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify({
|
||||
type: 'resize',
|
||||
data: {
|
||||
request: {
|
||||
type: 'resize',
|
||||
rows: size.rows,
|
||||
cols: size.cols,
|
||||
}
|
||||
}
|
||||
}));
|
||||
console.log('📤 已发送尺寸调整消息:', size);
|
||||
}
|
||||
});
|
||||
|
||||
// 延迟连接
|
||||
setTimeout(() => {
|
||||
connectWebSocket();
|
||||
}, 300);
|
||||
};
|
||||
|
||||
const connectWebSocket = () => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) return;
|
||||
|
||||
setConnectionStatus('connecting');
|
||||
setErrorMessage('');
|
||||
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) {
|
||||
const errorMsg = '身份认证失败';
|
||||
const tips = '您的登录状态已过期,请重新登录系统';
|
||||
setConnectionStatus('error');
|
||||
setErrorMessage(errorMsg);
|
||||
terminalInstanceRef.current?.writeln(`\r\n\x1b[31m✗ ${errorMsg}\x1b[0m`);
|
||||
terminalInstanceRef.current?.writeln(`\x1b[33m💡 ${tips}\x1b[0m`);
|
||||
message.error({
|
||||
content: errorMsg,
|
||||
duration: 4,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${protocol}//${window.location.host}/api/v1/server-ssh/connect/${server.id}?token=${token}`;
|
||||
|
||||
const ws = new WebSocket(wsUrl);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log('🔗 WebSocket已连接:', wsUrl);
|
||||
console.log('📺 Terminal实例存在:', !!terminalInstanceRef.current);
|
||||
|
||||
// 发送初始终端尺寸
|
||||
if (terminalInstanceRef.current) {
|
||||
const terminal = terminalInstanceRef.current;
|
||||
ws.send(JSON.stringify({
|
||||
type: 'resize',
|
||||
data: {
|
||||
request: {
|
||||
type: 'resize',
|
||||
rows: terminal.rows,
|
||||
cols: terminal.cols,
|
||||
}
|
||||
}
|
||||
}));
|
||||
console.log('📤 已发送初始终端尺寸:', { rows: terminal.rows, cols: terminal.cols });
|
||||
}
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
console.log('📨 收到WebSocket消息:', event.data);
|
||||
try {
|
||||
const msg: WebSocketMessage = JSON.parse(event.data);
|
||||
console.log('📦 解析后的消息:', msg);
|
||||
|
||||
// 提取实际数据:处理后端 response 包装格式
|
||||
const actualData = typeof msg.data === 'string'
|
||||
? msg.data
|
||||
: msg.data?.response?.data || '';
|
||||
|
||||
switch (msg.type) {
|
||||
case 'output':
|
||||
console.log('📝 输出数据:', actualData?.substring(0, 100));
|
||||
console.log('📺 Terminal实例:', !!terminalInstanceRef.current);
|
||||
if (actualData && terminalInstanceRef.current) {
|
||||
terminalInstanceRef.current.write(actualData);
|
||||
console.log('✅ 数据已写入终端');
|
||||
} else {
|
||||
console.warn('⚠️ 无法写入终端:', { hasData: !!actualData, hasTerminal: !!terminalInstanceRef.current });
|
||||
}
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
console.error('❌ SSH错误:', actualData);
|
||||
terminalInstanceRef.current?.writeln(`\r\n\x1b[31m错误: ${actualData}\x1b[0m\r\n`);
|
||||
message.error(actualData || '连接错误');
|
||||
break;
|
||||
|
||||
case 'status':
|
||||
console.log('📊 状态变化:', actualData);
|
||||
setConnectionStatus(actualData as ConnectionStatus);
|
||||
if (actualData === 'connected') {
|
||||
// 显示审计警告
|
||||
terminalInstanceRef.current?.writeln('\r\n\x1b[33m┌─────────────────────────────────────────────────────────────\x1b[0m');
|
||||
terminalInstanceRef.current?.writeln('\x1b[33m│ ⚠️ 链宇技术有限公司 - 安全提示\x1b[0m');
|
||||
terminalInstanceRef.current?.writeln('\x1b[33m│ 本次SSH会话将被全程审计记录\x1b[0m');
|
||||
terminalInstanceRef.current?.writeln('\x1b[33m│ • 所有操作命令、输入、输出都将被完整记录\x1b[0m');
|
||||
terminalInstanceRef.current?.writeln('\x1b[33m│ • 审计日志用于安全审查、故障排查和合规要求\x1b[0m');
|
||||
terminalInstanceRef.current?.writeln('\x1b[33m│ • 请规范操作,遵守企业信息安全管理制度\x1b[0m');
|
||||
terminalInstanceRef.current?.writeln('\x1b[33m└─────────────────────────────────────────────────────────────\x1b[0m\r\n');
|
||||
setTimeout(() => {
|
||||
fitAddonRef.current?.fit();
|
||||
}, 100);
|
||||
}
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ 解析WebSocket消息失败:', error, 'Raw data:', event.data);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
const errorMsg = '无法连接到SSH服务';
|
||||
const tips = '可能原因:后端服务未启动、网络不通或防火墙限制';
|
||||
setConnectionStatus('error');
|
||||
setErrorMessage(errorMsg);
|
||||
terminalInstanceRef.current?.writeln(`\r\n\x1b[31m✗ ${errorMsg}\x1b[0m`);
|
||||
terminalInstanceRef.current?.writeln(`\x1b[33m💡 ${tips}\x1b[0m`);
|
||||
message.error({
|
||||
content: errorMsg,
|
||||
duration: 4,
|
||||
});
|
||||
};
|
||||
|
||||
ws.onclose = (event) => {
|
||||
console.log('🔌 WebSocket关闭, code:', event.code, 'isClosing:', isClosingRef.current);
|
||||
wsRef.current = null;
|
||||
|
||||
if (isClosingRef.current) {
|
||||
// 用户主动关闭,通知父组件可以关闭窗口了
|
||||
setConnectionStatus('disconnected');
|
||||
console.log('✅ 断开完成,通知父组件');
|
||||
setTimeout(() => {
|
||||
onCloseReady?.();
|
||||
}, 100);
|
||||
} else {
|
||||
// 非主动关闭
|
||||
setConnectionStatus('disconnected');
|
||||
if (!event.wasClean) {
|
||||
let errorMsg = '';
|
||||
let tips = '';
|
||||
|
||||
// 根据关闭代码提供更有针对性的提示
|
||||
switch (event.code) {
|
||||
case 1006:
|
||||
errorMsg = '连接意外中断';
|
||||
tips = '可能是网络波动或服务器重启,请尝试重新连接';
|
||||
break;
|
||||
case 1008:
|
||||
errorMsg = '连接被拒绝';
|
||||
tips = '服务器拒绝了您的连接请求,请检查权限配置';
|
||||
break;
|
||||
case 1011:
|
||||
errorMsg = '服务器遇到错误';
|
||||
tips = '后端服务出现异常,请联系管理员';
|
||||
break;
|
||||
case 4001:
|
||||
errorMsg = '认证失败';
|
||||
tips = '登录凭证已过期,请重新登录';
|
||||
break;
|
||||
case 4004:
|
||||
errorMsg = '服务器未找到';
|
||||
tips = '目标服务器信息不存在或已被删除';
|
||||
break;
|
||||
default:
|
||||
errorMsg = event.reason || '连接已断开';
|
||||
tips = event.code ? `错误代码: ${event.code}` : '请检查网络连接后重试';
|
||||
}
|
||||
|
||||
setErrorMessage(errorMsg);
|
||||
terminalInstanceRef.current?.writeln(`\r\n\x1b[31m✗ ${errorMsg}\x1b[0m`);
|
||||
if (tips) {
|
||||
terminalInstanceRef.current?.writeln(`\x1b[33m💡 ${tips}\x1b[0m`);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
// 优雅关闭连接
|
||||
const gracefulClose = useCallback(() => {
|
||||
console.log('🚪 开始优雅关闭 SSH连接');
|
||||
isClosingRef.current = true;
|
||||
terminalInstanceRef.current?.writeln('\r\n\x1b[33m正在断开连接...\x1b[0m');
|
||||
|
||||
if (wsRef.current) {
|
||||
if (wsRef.current.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.close(1000, 'User closed');
|
||||
} else {
|
||||
// 连接已经断开,直接通知父组件
|
||||
wsRef.current = null;
|
||||
setTimeout(() => {
|
||||
onCloseReady?.();
|
||||
}, 100);
|
||||
}
|
||||
} else {
|
||||
// 没有WebSocket连接,直接通知父组件
|
||||
setTimeout(() => {
|
||||
onCloseReady?.();
|
||||
}, 100);
|
||||
}
|
||||
}, [onCloseReady]);
|
||||
|
||||
const handleReconnect = () => {
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close();
|
||||
wsRef.current = null;
|
||||
}
|
||||
if (terminalInstanceRef.current) {
|
||||
terminalInstanceRef.current.clear();
|
||||
}
|
||||
connectWebSocket();
|
||||
};
|
||||
|
||||
// 快捷操作:清屏
|
||||
const handleClear = () => {
|
||||
terminalInstanceRef.current?.clear();
|
||||
message.success('终端已清屏');
|
||||
};
|
||||
|
||||
// 快捷操作:复制选中文本
|
||||
const handleCopy = async () => {
|
||||
const selection = terminalInstanceRef.current?.getSelection();
|
||||
if (selection) {
|
||||
await navigator.clipboard.writeText(selection);
|
||||
message.success('已复制到剪贴板');
|
||||
} else {
|
||||
message.warning('请先选中要复制的文本');
|
||||
}
|
||||
};
|
||||
|
||||
// 快捷操作:缩小字体
|
||||
const handleZoomOut = () => {
|
||||
const newSize = Math.max(fontSize - 2, 10);
|
||||
setFontSize(newSize);
|
||||
if (terminalInstanceRef.current) {
|
||||
terminalInstanceRef.current.options.fontSize = newSize;
|
||||
fitAddonRef.current?.fit();
|
||||
}
|
||||
};
|
||||
|
||||
// 快捷操作:放大字体
|
||||
const handleZoomIn = () => {
|
||||
const newSize = Math.min(fontSize + 2, 24);
|
||||
setFontSize(newSize);
|
||||
if (terminalInstanceRef.current) {
|
||||
terminalInstanceRef.current.options.fontSize = newSize;
|
||||
fitAddonRef.current?.fit();
|
||||
}
|
||||
};
|
||||
|
||||
// 搜索功能:切换显示
|
||||
const toggleSearch = () => {
|
||||
setShowSearch(prev => !prev);
|
||||
if (!showSearch) {
|
||||
// 打开搜索框时聚焦
|
||||
setTimeout(() => {
|
||||
document.getElementById('ssh-search-input')?.focus();
|
||||
}, 100);
|
||||
} else {
|
||||
// 关闭搜索框时清除高亮
|
||||
setSearchKeyword('');
|
||||
}
|
||||
};
|
||||
|
||||
// 搜索功能:查找下一个
|
||||
const handleSearchNext = () => {
|
||||
if (searchKeyword && searchAddonRef.current) {
|
||||
const found = searchAddonRef.current.findNext(searchKeyword, {
|
||||
caseSensitive: false,
|
||||
wholeWord: false,
|
||||
regex: false,
|
||||
});
|
||||
if (!found) {
|
||||
message.info('已到达最后一个结果');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 搜索功能:查找上一个
|
||||
const handleSearchPrev = () => {
|
||||
if (searchKeyword && searchAddonRef.current) {
|
||||
const found = searchAddonRef.current.findPrevious(searchKeyword, {
|
||||
caseSensitive: false,
|
||||
wholeWord: false,
|
||||
regex: false,
|
||||
});
|
||||
if (!found) {
|
||||
message.info('已到达第一个结果');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 搜索功能:处理搜索输入
|
||||
const handleSearchChange = (value: string) => {
|
||||
setSearchKeyword(value);
|
||||
if (value && searchAddonRef.current) {
|
||||
searchAddonRef.current.findNext(value, {
|
||||
caseSensitive: false,
|
||||
wholeWord: false,
|
||||
regex: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 搜索功能:处理回车键
|
||||
const handleSearchKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
if (e.shiftKey) {
|
||||
handleSearchPrev();
|
||||
} else {
|
||||
handleSearchNext();
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
toggleSearch();
|
||||
}
|
||||
};
|
||||
|
||||
// 暴露关闭方法给父组件
|
||||
useEffect(() => {
|
||||
(window as any)[`__closeSSH_${windowId}`] = gracefulClose;
|
||||
return () => {
|
||||
delete (window as any)[`__closeSSH_${windowId}`];
|
||||
};
|
||||
}, [windowId, gracefulClose]);
|
||||
|
||||
// 组件首次挂载时初始化终端
|
||||
useEffect(() => {
|
||||
console.log('📦 组件挂载,开始初始化...');
|
||||
const timer = setTimeout(() => {
|
||||
initializeTerminalAndConnect();
|
||||
}, 50);
|
||||
|
||||
return () => {
|
||||
console.log('🧹 组件卸载,清理资源...');
|
||||
clearTimeout(timer);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []); // 空数组,只在首次挂载时运行
|
||||
|
||||
// 监听窗口大小变化
|
||||
useEffect(() => {
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
if (terminalRef.current) {
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
fitAddonRef.current?.fit();
|
||||
});
|
||||
resizeObserver.observe(terminalRef.current);
|
||||
}
|
||||
|
||||
const handleWindowResize = () => {
|
||||
fitAddonRef.current?.fit();
|
||||
};
|
||||
window.addEventListener('resize', handleWindowResize);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleWindowResize);
|
||||
if (resizeObserver && terminalRef.current) {
|
||||
resizeObserver.unobserve(terminalRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 组件卸载时清理资源
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
console.log('🧹 最终清理: 关闭WebSocket和Terminal');
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close(1000, 'Component unmounted');
|
||||
wsRef.current = null;
|
||||
}
|
||||
if (terminalInstanceRef.current) {
|
||||
terminalInstanceRef.current.dispose();
|
||||
terminalInstanceRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const getStatusBadge = () => {
|
||||
switch (connectionStatus) {
|
||||
case 'connecting':
|
||||
return <Badge variant="outline" className="bg-yellow-100 text-yellow-700"><Loader2 className="mr-1 h-3 w-3 animate-spin" />连接中</Badge>;
|
||||
case 'connected':
|
||||
return <Badge variant="outline" className="bg-emerald-100 text-emerald-700"><div className="mr-1 h-2 w-2 rounded-full bg-emerald-500 animate-pulse" />已连接</Badge>;
|
||||
case 'reconnecting':
|
||||
return <Badge variant="outline" className="bg-orange-100 text-orange-700"><Loader2 className="mr-1 h-3 w-3 animate-spin" />重连中</Badge>;
|
||||
case 'error':
|
||||
return <Badge variant="outline" className="bg-red-100 text-red-700"><XCircle className="mr-1 h-3 w-3" />连接失败</Badge>;
|
||||
case 'disconnected':
|
||||
return <Badge variant="outline" className="bg-gray-100 text-gray-700"><XCircle className="mr-1 h-3 w-3" />已断开</Badge>;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>{customStyles}</style>
|
||||
|
||||
<div className="flex flex-col h-full">
|
||||
{/* 工具栏 - 快捷操作 */}
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b bg-gray-50 dark:bg-gray-800/50">
|
||||
{/* 左侧:状态指示器 */}
|
||||
<div className="flex items-center gap-2">
|
||||
{getStatusBadge()}
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
字体 {fontSize}px
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 右侧:所有操作按钮 */}
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 px-2"
|
||||
onClick={toggleSearch}
|
||||
title="搜索 (Ctrl+F)"
|
||||
>
|
||||
<Search className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 px-2"
|
||||
onClick={handleClear}
|
||||
title="清屏 (Ctrl+L)"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 px-2"
|
||||
onClick={handleCopy}
|
||||
title="复制选中 (Ctrl+C)"
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<div className="h-4 w-px bg-gray-300 dark:bg-gray-600 mx-1" />
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 px-2"
|
||||
onClick={handleZoomOut}
|
||||
title="缩小字体"
|
||||
>
|
||||
<ZoomOut className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 px-2"
|
||||
onClick={handleZoomIn}
|
||||
title="放大字体"
|
||||
>
|
||||
<ZoomIn className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
{(connectionStatus === 'disconnected' || connectionStatus === 'error') && (
|
||||
<>
|
||||
<div className="h-4 w-px bg-gray-300 dark:bg-gray-600 mx-1" />
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7"
|
||||
onClick={handleReconnect}
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5 mr-1" />
|
||||
重连
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 终端区域 */}
|
||||
<div ref={terminalContainerRef} className="flex-1 bg-[#1e1e1e] relative overflow-hidden">
|
||||
{/* 搜索栏 */}
|
||||
{showSearch && (
|
||||
<div className="absolute top-2 right-2 z-10 flex items-center gap-1 bg-gray-900/95 border border-gray-700 rounded-lg px-3 py-2 shadow-lg">
|
||||
<Search className="h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
id="ssh-search-input"
|
||||
type="text"
|
||||
placeholder="搜索终端内容..."
|
||||
value={searchKeyword}
|
||||
onChange={(e) => handleSearchChange(e.target.value)}
|
||||
onKeyDown={handleSearchKeyDown}
|
||||
className="h-7 w-48 bg-gray-800 border-gray-700 text-white placeholder:text-gray-500 focus:border-blue-500"
|
||||
/>
|
||||
<div className="flex items-center gap-0.5 ml-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 p-0 hover:bg-gray-800"
|
||||
onClick={handleSearchPrev}
|
||||
title="上一个 (Shift+Enter)"
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 p-0 hover:bg-gray-800"
|
||||
onClick={handleSearchNext}
|
||||
title="下一个 (Enter)"
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 p-0 hover:bg-gray-800"
|
||||
onClick={toggleSearch}
|
||||
title="关闭 (Esc)"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={terminalRef}
|
||||
className="w-full h-full p-2 ssh-terminal-content"
|
||||
/>
|
||||
|
||||
{connectionStatus === 'connecting' && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center text-yellow-400 bg-[#1e1e1e]">
|
||||
<Loader2 className="h-12 w-12 animate-spin mb-4" />
|
||||
<p className="text-lg">正在连接SSH...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{connectionStatus === 'reconnecting' && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center text-orange-400 bg-[#1e1e1e]">
|
||||
<Loader2 className="h-12 w-12 animate-spin mb-4" />
|
||||
<p className="text-lg">连接中断,正在重连...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{connectionStatus === 'error' && errorMessage && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center text-red-400 bg-[#1e1e1e]">
|
||||
<XCircle className="h-12 w-12 mb-4" />
|
||||
<p className="text-lg font-semibold mb-2">连接失败</p>
|
||||
<p className="text-sm text-gray-400">{errorMessage}</p>
|
||||
<Button className="mt-6" variant="outline" onClick={handleReconnect}>
|
||||
重试连接
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -1,9 +1,77 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { Terminal } from 'lucide-react';
|
||||
import { DraggableWindow } from '@/components/ui/draggable-window';
|
||||
import { SSHTerminalContent } from './SSHTerminalContent';
|
||||
/**
|
||||
* SSH 窗口管理器
|
||||
* 直接使用通用 Terminal 和 TerminalWindowManager
|
||||
*/
|
||||
import React from 'react';
|
||||
import {
|
||||
TerminalWindowManager,
|
||||
Terminal,
|
||||
type TerminalConnectionConfig,
|
||||
type TerminalAuditConfig,
|
||||
type TerminalToolbarConfig
|
||||
} from '@/components/Terminal';
|
||||
import type { ServerResponse } from '../types';
|
||||
|
||||
interface SSHWindowManagerProps {
|
||||
onOpenWindow?: (windowId: string) => void;
|
||||
}
|
||||
|
||||
export const SSHWindowManager: React.FC<SSHWindowManagerProps> = ({ onOpenWindow }) => {
|
||||
return (
|
||||
<TerminalWindowManager<ServerResponse>
|
||||
type="ssh"
|
||||
getWindowTitle={(server) => `SSH终端 - ${server.serverName} (${server.hostIp})`}
|
||||
getWindowSubtitle={(server) => server.serverName}
|
||||
getResourceId={(server) => server.id}
|
||||
renderTerminal={(windowId, server, callbacks) => {
|
||||
// SSH 连接配置
|
||||
const token = localStorage.getItem('token');
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${protocol}//${window.location.host}/api/v1/server-ssh/connect/${server.id}?token=${token}`;
|
||||
|
||||
const connectionConfig: TerminalConnectionConfig = {
|
||||
type: 'ssh',
|
||||
wsUrl,
|
||||
token: token || undefined,
|
||||
autoReconnect: true,
|
||||
reconnectInterval: 3000,
|
||||
maxReconnectAttempts: 5,
|
||||
};
|
||||
|
||||
// 审计配置
|
||||
const auditConfig: TerminalAuditConfig = {
|
||||
enabled: true,
|
||||
companyName: '链宇技术有限公司',
|
||||
};
|
||||
|
||||
// 工具栏配置
|
||||
const toolbarConfig: TerminalToolbarConfig = {
|
||||
show: true,
|
||||
showSearch: true,
|
||||
showClear: true,
|
||||
showCopy: true,
|
||||
showFontSize: true,
|
||||
showStatus: true,
|
||||
showFontSizeLabel: true,
|
||||
};
|
||||
|
||||
return (
|
||||
<Terminal
|
||||
id={windowId}
|
||||
connection={connectionConfig}
|
||||
audit={auditConfig}
|
||||
toolbar={toolbarConfig}
|
||||
onStatusChange={callbacks.onStatusChange}
|
||||
onCloseReady={callbacks.onCloseReady}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
onOpenWindow={onOpenWindow}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// 保留类型定义以兼容现有代码
|
||||
export interface SSHWindow {
|
||||
id: string;
|
||||
server: ServerResponse;
|
||||
@ -12,208 +80,3 @@ export interface SSHWindow {
|
||||
size: { width: number; height: number };
|
||||
connectionStatus?: 'connecting' | 'connected' | 'reconnecting' | 'disconnected' | 'error';
|
||||
}
|
||||
|
||||
interface SSHWindowManagerProps {
|
||||
onOpenWindow?: (windowId: string) => void;
|
||||
}
|
||||
|
||||
export const SSHWindowManager: React.FC<SSHWindowManagerProps> = ({ onOpenWindow }) => {
|
||||
const [windows, setWindows] = useState<SSHWindow[]>([]);
|
||||
const [activeWindowId, setActiveWindowId] = useState<string | null>(null);
|
||||
|
||||
// 打开新窗口
|
||||
const openWindow = useCallback((server: ServerResponse) => {
|
||||
const windowId = `ssh-${server.id}-${Date.now()}`;
|
||||
const offset = windows.length * 30;
|
||||
|
||||
const newWindow: SSHWindow = {
|
||||
id: windowId,
|
||||
server,
|
||||
isMinimized: false,
|
||||
position: { x: 100 + offset, y: 100 + offset },
|
||||
size: { width: 1200, height: 700 },
|
||||
connectionStatus: 'connecting',
|
||||
};
|
||||
|
||||
setWindows(prev => [...prev, newWindow]);
|
||||
setActiveWindowId(windowId);
|
||||
onOpenWindow?.(windowId);
|
||||
|
||||
console.log(`✅ 打开SSH窗口: ${server.serverName} (${windowId})`);
|
||||
}, [windows.length, onOpenWindow]);
|
||||
|
||||
// 真正关闭窗口(在连接断开后调用)
|
||||
const actuallyCloseWindow = useCallback((windowId: string) => {
|
||||
console.log(`❌ 真正关闭SSH窗口: ${windowId}`);
|
||||
setWindows(prev => prev.filter(w => w.id !== windowId));
|
||||
if (activeWindowId === windowId) {
|
||||
setActiveWindowId(null);
|
||||
}
|
||||
}, [activeWindowId]);
|
||||
|
||||
// 关闭窗口
|
||||
const closeWindow = useCallback((windowId: string) => {
|
||||
console.log(`🚪 准备关闭SSH窗口: ${windowId}`);
|
||||
|
||||
// 调用gracefulClose方法优雅断开连接
|
||||
const closeMethod = (window as any)[`__closeSSH_${windowId}`];
|
||||
if (closeMethod && typeof closeMethod === 'function') {
|
||||
console.log('✅ 调用优雅关闭方法');
|
||||
closeMethod();
|
||||
// 等待回调后再关闭窗口(通过onCloseReady)
|
||||
} else {
|
||||
// 如果没有找到关闭方法,直接关闭
|
||||
console.warn('⚠️ 未找到优雅关闭方法,直接关闭窗口');
|
||||
actuallyCloseWindow(windowId);
|
||||
}
|
||||
}, [actuallyCloseWindow]);
|
||||
|
||||
// 最小化窗口
|
||||
const minimizeWindow = useCallback((windowId: string) => {
|
||||
setWindows(prev =>
|
||||
prev.map(w =>
|
||||
w.id === windowId ? { ...w, isMinimized: true } : w
|
||||
)
|
||||
);
|
||||
console.log(`➖ 最小化窗口: ${windowId}`);
|
||||
}, []);
|
||||
|
||||
// 恢复窗口
|
||||
const restoreWindow = useCallback((windowId: string) => {
|
||||
setWindows(prev =>
|
||||
prev.map(w =>
|
||||
w.id === windowId ? { ...w, isMinimized: false } : w
|
||||
)
|
||||
);
|
||||
setActiveWindowId(windowId);
|
||||
console.log(`⬆️ 恢复窗口: ${windowId}`);
|
||||
|
||||
// 多次触发resize事件,确保终端正确调整尺寸
|
||||
setTimeout(() => {
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
}, 50);
|
||||
setTimeout(() => {
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
}, 150);
|
||||
setTimeout(() => {
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
}, 300);
|
||||
}, []);
|
||||
|
||||
// 激活窗口
|
||||
const focusWindow = useCallback((windowId: string) => {
|
||||
setActiveWindowId(windowId);
|
||||
}, []);
|
||||
|
||||
// 更新窗口连接状态
|
||||
const updateWindowStatus = useCallback((windowId: string, status: SSHWindow['connectionStatus']) => {
|
||||
setWindows(prev =>
|
||||
prev.map(w =>
|
||||
w.id === windowId ? { ...w, connectionStatus: status } : w
|
||||
)
|
||||
);
|
||||
}, []);
|
||||
|
||||
// 获取状态对应的按钮样式
|
||||
const getButtonStyle = (status?: SSHWindow['connectionStatus']) => {
|
||||
switch (status) {
|
||||
case 'connected':
|
||||
return 'bg-green-600 hover:bg-green-700';
|
||||
case 'error':
|
||||
case 'disconnected':
|
||||
return 'bg-red-600 hover:bg-red-700';
|
||||
case 'connecting':
|
||||
return 'bg-yellow-600 hover:bg-yellow-700';
|
||||
case 'reconnecting':
|
||||
return 'bg-orange-600 hover:bg-orange-700';
|
||||
default:
|
||||
return 'bg-blue-600 hover:bg-blue-700';
|
||||
}
|
||||
};
|
||||
|
||||
// 暴露openWindow方法给外部使用
|
||||
React.useEffect(() => {
|
||||
(window as any).__openSSHWindow = openWindow;
|
||||
return () => {
|
||||
delete (window as any).__openSSHWindow;
|
||||
};
|
||||
}, [openWindow]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 渲染所有窗口 - 最小化时隐藏但保持渲染,SSH连接和尺寸 */}
|
||||
{windows.map(window => (
|
||||
<div
|
||||
key={window.id}
|
||||
style={{
|
||||
visibility: window.isMinimized ? 'hidden' : 'visible',
|
||||
pointerEvents: window.isMinimized ? 'none' : 'auto',
|
||||
}}
|
||||
>
|
||||
<DraggableWindow
|
||||
id={window.id}
|
||||
title={`SSH终端 - ${window.server.serverName} (${window.server.hostIp})`}
|
||||
onClose={() => closeWindow(window.id)}
|
||||
onMinimize={() => minimizeWindow(window.id)}
|
||||
isActive={activeWindowId === window.id}
|
||||
onFocus={() => focusWindow(window.id)}
|
||||
initialPosition={window.position}
|
||||
initialSize={window.size}
|
||||
>
|
||||
<SSHTerminalContent
|
||||
server={window.server}
|
||||
windowId={window.id}
|
||||
onCloseReady={() => actuallyCloseWindow(window.id)}
|
||||
onStatusChange={(status) => updateWindowStatus(window.id, status)}
|
||||
/>
|
||||
</DraggableWindow>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 最小化的窗口悬浮按钮 */}
|
||||
{windows.filter(w => w.isMinimized).length > 0 && (
|
||||
<div className="fixed bottom-4 right-4 flex flex-col-reverse gap-2 z-[1001]">
|
||||
{windows
|
||||
.filter(w => w.isMinimized)
|
||||
.map((window) => {
|
||||
// 计算同一服务器的窗口数量和当前窗口序号
|
||||
const sameServerWindows = windows.filter(w => w.server.id === window.server.id);
|
||||
const needsIndex = sameServerWindows.length > 1;
|
||||
const windowIndex = sameServerWindows.findIndex(w => w.id === window.id) + 1;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={window.id}
|
||||
className={`flex items-center gap-3 px-4 py-3 ${getButtonStyle(window.connectionStatus)} text-white rounded-lg shadow-lg transition-all hover:scale-105 hover:shadow-xl relative`}
|
||||
onClick={() => restoreWindow(window.id)}
|
||||
title={`恢复: ${window.server.serverName}${needsIndex ? ` #${windowIndex}` : ''}`}
|
||||
>
|
||||
{/* 状态指示灯 */}
|
||||
<div className="absolute top-2 right-2">
|
||||
<div className={`h-2 w-2 rounded-full ${
|
||||
window.connectionStatus === 'connected' ? 'bg-white animate-pulse' :
|
||||
window.connectionStatus === 'error' || window.connectionStatus === 'disconnected' ? 'bg-white/70' :
|
||||
'bg-white/50 animate-pulse'
|
||||
}`} />
|
||||
</div>
|
||||
|
||||
<Terminal className="h-5 w-5" />
|
||||
<div className="text-left">
|
||||
<div className="text-sm font-semibold flex items-center gap-2">
|
||||
<span>{window.server.serverName}</span>
|
||||
{needsIndex && (
|
||||
<span className="text-xs opacity-75">#{windowIndex}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs opacity-90">
|
||||
{window.server.hostIp}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,118 +0,0 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { Terminal } from 'lucide-react';
|
||||
import { DraggableWindow } from './DraggableWindow';
|
||||
import { SSHTerminalContent } from './SSHTerminalContent';
|
||||
import type { ServerResponse } from '../types';
|
||||
|
||||
export interface SSHWindow {
|
||||
id: string;
|
||||
server: ServerResponse;
|
||||
isMinimized: boolean;
|
||||
position: { x: number; y: number };
|
||||
size: { width: number; height: number };
|
||||
}
|
||||
|
||||
export const WindowManager: React.FC = () => {
|
||||
const [windows, setWindows] = useState<SSHWindow[]>([]);
|
||||
const [activeWindowId, setActiveWindowId] = useState<string | null>(null);
|
||||
|
||||
// 打开新窗口
|
||||
const openWindow = useCallback((server: ServerResponse) => {
|
||||
const windowId = `ssh-${server.id}-${Date.now()}`;
|
||||
const offset = windows.length * 30;
|
||||
|
||||
setWindows(prev => [
|
||||
...prev,
|
||||
{
|
||||
id: windowId,
|
||||
server,
|
||||
isMinimized: false,
|
||||
position: { x: 100 + offset, y: 100 + offset },
|
||||
size: { width: 1200, height: 700 },
|
||||
},
|
||||
]);
|
||||
setActiveWindowId(windowId);
|
||||
}, [windows.length]);
|
||||
|
||||
// 关闭窗口
|
||||
const closeWindow = useCallback((windowId: string) => {
|
||||
setWindows(prev => prev.filter(w => w.id !== windowId));
|
||||
if (activeWindowId === windowId) {
|
||||
setActiveWindowId(null);
|
||||
}
|
||||
}, [activeWindowId]);
|
||||
|
||||
// 最小化窗口
|
||||
const minimizeWindow = useCallback((windowId: string) => {
|
||||
setWindows(prev =>
|
||||
prev.map(w =>
|
||||
w.id === windowId ? { ...w, isMinimized: true } : w
|
||||
)
|
||||
);
|
||||
}, []);
|
||||
|
||||
// 恢复窗口
|
||||
const restoreWindow = useCallback((windowId: string) => {
|
||||
setWindows(prev =>
|
||||
prev.map(w =>
|
||||
w.id === windowId ? { ...w, isMinimized: false } : w
|
||||
)
|
||||
);
|
||||
setActiveWindowId(windowId);
|
||||
}, []);
|
||||
|
||||
// 激活窗口
|
||||
const focusWindow = useCallback((windowId: string) => {
|
||||
setActiveWindowId(windowId);
|
||||
}, []);
|
||||
|
||||
// 暴露openWindow方法给外部使用
|
||||
React.useEffect(() => {
|
||||
(window as any).__openSSHWindow = openWindow;
|
||||
return () => {
|
||||
delete (window as any).__openSSHWindow;
|
||||
};
|
||||
}, [openWindow]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 渲染所有窗口 */}
|
||||
{windows.map(window => (
|
||||
!window.isMinimized && (
|
||||
<DraggableWindow
|
||||
key={window.id}
|
||||
id={window.id}
|
||||
title={`SSH终端 - ${window.server.serverName} (${window.server.hostIp})`}
|
||||
onClose={() => closeWindow(window.id)}
|
||||
onMinimize={() => minimizeWindow(window.id)}
|
||||
isActive={activeWindowId === window.id}
|
||||
onFocus={() => focusWindow(window.id)}
|
||||
initialPosition={window.position}
|
||||
initialSize={window.size}
|
||||
>
|
||||
<SSHTerminalContent server={window.server} windowId={window.id} />
|
||||
</DraggableWindow>
|
||||
)
|
||||
))}
|
||||
|
||||
{/* 最小化的窗口悬浮按钮 */}
|
||||
<div className="fixed bottom-4 right-4 flex flex-col-reverse gap-2 z-[1001]">
|
||||
{windows
|
||||
.filter(w => w.isMinimized)
|
||||
.map((window, index) => (
|
||||
<button
|
||||
key={window.id}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg shadow-lg transition-all hover:scale-105"
|
||||
onClick={() => restoreWindow(window.id)}
|
||||
title={`恢复: ${window.server.serverName}`}
|
||||
>
|
||||
<Terminal className="h-4 w-4" />
|
||||
<span className="text-sm font-medium">
|
||||
{window.server.serverName}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user