重写ssh前端组件,通用化

This commit is contained in:
dengqichen 2025-12-07 02:01:45 +08:00
parent 308a5587cc
commit ba7663ebd6
14 changed files with 1074 additions and 20 deletions

View File

@ -0,0 +1,77 @@
/**
* SSH文件管理器
*
*/
import React, { useState } from 'react';
import { DraggableWindow } from '@/components/ui/draggable-window';
import { ServerFilePanel } from './ServerFilePanel';
import { UploadPanel } from './UploadPanel';
import type { FileManagerProps } from './types';
export const FileManager: React.FC<FileManagerProps> = ({
serverId,
serverName,
open,
onClose,
onMinimize,
isActive,
onFocus,
}) => {
const [currentPath, setCurrentPath] = useState<string>('/');
const [refreshTrigger, setRefreshTrigger] = useState(0);
// 上传完成后刷新服务器文件列表
const handleUploadComplete = () => {
setRefreshTrigger(prev => prev + 1);
};
if (!open) return null;
// 计算屏幕中心位置
const windowWidth = 1100;
const windowHeight = 650;
const centerX = Math.max(0, (window.innerWidth - windowWidth) / 2);
const centerY = Math.max(0, (window.innerHeight - windowHeight) / 2);
return (
<DraggableWindow
id={`file-manager-${serverId}`}
title={`文件管理 - ${serverName}`}
onClose={onClose}
onMinimize={onMinimize}
isActive={isActive}
onFocus={onFocus}
initialPosition={{ x: centerX, y: centerY }}
initialSize={{ width: windowWidth, height: windowHeight }}
>
<div className="h-full flex gap-4 p-2">
{/* 左侧:服务器文件浏览 - 占2/3宽度 */}
<div className="flex-[2] flex flex-col border rounded-lg overflow-hidden">
<div className="bg-gray-100 dark:bg-gray-800 px-4 py-2 border-b flex-shrink-0">
<h3 className="font-semibold"></h3>
</div>
<div className="flex-1 overflow-hidden">
<ServerFilePanel
serverId={serverId}
currentPath={currentPath}
onPathChange={setCurrentPath}
refreshTrigger={refreshTrigger}
/>
</div>
</div>
{/* 右侧:文件上传 - 占1/3宽度 */}
<div className="flex-1 flex flex-col border rounded-lg overflow-hidden">
<div className="bg-gray-100 dark:bg-gray-800 px-4 py-2 border-b">
<h3 className="font-semibold"></h3>
</div>
<UploadPanel
serverId={serverId}
currentPath={currentPath}
onUploadComplete={handleUploadComplete}
/>
</div>
</div>
</DraggableWindow>
);
};

View File

@ -0,0 +1,298 @@
/**
*
*/
import React, { useEffect, useState } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Skeleton } from '@/components/ui/skeleton';
import { ChevronRight, Folder, File, Home, ArrowLeft, Plus, Trash2, RefreshCw, Link } from 'lucide-react';
import { browseDirectory, createDirectory, removeFile, pathUtils, type RemoteFileInfo } from '@/services/fileService';
import { message } from 'antd';
import type { ServerFilePanelProps } from './types';
export const ServerFilePanel: React.FC<ServerFilePanelProps> = ({
serverId,
currentPath,
onPathChange,
refreshTrigger,
}) => {
const [files, setFiles] = useState<RemoteFileInfo[]>([]);
const [loading, setLoading] = useState(false);
const [showNewFolder, setShowNewFolder] = useState(false);
const [newFolderName, setNewFolderName] = useState('');
// 加载文件列表
const loadFiles = async () => {
setLoading(true);
try {
const data = await browseDirectory(serverId, currentPath);
// 排序:目录在前,文件在后
const sorted = data.sort((a, b) => {
if (a.isDirectory === b.isDirectory) {
return a.name.localeCompare(b.name);
}
return a.isDirectory ? -1 : 1;
});
setFiles(sorted);
} catch (error: any) {
message.error(error.response?.data?.message || '加载文件列表失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
loadFiles();
}, [serverId, currentPath, refreshTrigger]);
// 进入目录
const handleEnterDirectory = (file: RemoteFileInfo) => {
if (file.isDirectory) {
onPathChange(file.path);
}
};
// 返回上级目录
const handleGoBack = () => {
const parent = pathUtils.getParent(currentPath);
onPathChange(parent);
};
// 回到Home
const handleGoHome = () => {
onPathChange('/');
};
// 创建新文件夹
const handleCreateFolder = async () => {
if (!newFolderName.trim()) {
message.warning('请输入文件夹名称');
return;
}
try {
const newPath = pathUtils.join(currentPath, newFolderName);
await createDirectory(serverId, newPath);
message.success('创建成功');
setShowNewFolder(false);
setNewFolderName('');
loadFiles();
} catch (error: any) {
message.error(error.response?.data?.message || '创建失败');
}
};
// 删除文件/目录
const handleDelete = async (file: RemoteFileInfo) => {
const confirmed = window.confirm(
file.isDirectory
? `确定删除目录 "${file.name}" 及其所有内容吗?`
: `确定删除文件 "${file.name}" 吗?`
);
if (!confirmed) return;
try {
await removeFile(serverId, file.path, file.isDirectory);
message.success('删除成功');
loadFiles();
} catch (error: any) {
message.error(error.response?.data?.message || '删除失败');
}
};
// 面包屑导航
const breadcrumbs = currentPath.split('/').filter(Boolean);
return (
<div className="h-full flex flex-col">
{/* 工具栏 */}
<div className="flex items-center gap-2 p-2 border-b flex-shrink-0">
<Button
size="sm"
variant="ghost"
onClick={handleGoHome}
disabled={currentPath === '/'}
title="回到根目录"
>
<Home className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={handleGoBack}
disabled={currentPath === '/'}
title="返回上级"
>
<ArrowLeft className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => setShowNewFolder(true)}
title="新建文件夹"
>
<Plus className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={loadFiles}
title="刷新"
>
<RefreshCw className="h-4 w-4" />
</Button>
</div>
{/* 路径导航 */}
<div className="flex items-center gap-1 px-3 py-2 text-sm text-gray-600 dark:text-gray-400 border-b overflow-x-auto flex-shrink-0">
<span>/</span>
{breadcrumbs.map((name, index) => (
<React.Fragment key={index}>
<ChevronRight className="h-4 w-4 flex-shrink-0" />
<button
className="hover:text-blue-600 dark:hover:text-blue-400"
onClick={() => {
const path = '/' + breadcrumbs.slice(0, index + 1).join('/');
onPathChange(path);
}}
>
{name}
</button>
</React.Fragment>
))}
</div>
{/* 新建文件夹输入框 */}
{showNewFolder && (
<div className="flex items-center gap-2 p-2 bg-blue-50 dark:bg-blue-900/20 border-b flex-shrink-0">
<Input
placeholder="文件夹名称"
value={newFolderName}
onChange={(e) => setNewFolderName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleCreateFolder();
if (e.key === 'Escape') {
setShowNewFolder(false);
setNewFolderName('');
}
}}
autoFocus
className="flex-1"
/>
<Button size="sm" onClick={handleCreateFolder}>
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => {
setShowNewFolder(false);
setNewFolderName('');
}}
>
</Button>
</div>
)}
{/* 文件列表 */}
<div className="flex-1 overflow-y-auto" style={{ minHeight: 0 }}>
{/* 表头 */}
{!loading && files.length > 0 && (
<div className="sticky top-0 bg-gray-50 dark:bg-gray-900 border-b px-2 py-1 flex items-center gap-2 text-xs font-medium text-gray-600 dark:text-gray-400 z-10">
<div className="flex-1 min-w-[150px]"></div>
<div className="w-20 text-right flex-shrink-0"></div>
<div className="w-24 flex-shrink-0"></div>
<div className="w-16 flex-shrink-0"></div>
<div className="w-28 flex-shrink-0"></div>
<div className="w-8 flex-shrink-0"></div>
</div>
)}
<div className="p-2 min-h-[200px]">
{loading ? (
<div className="space-y-2">
{/* 骨架屏 - 模拟文件列表 */}
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="flex items-center gap-2 px-2 py-1.5">
<Skeleton className="h-4 w-4 rounded flex-shrink-0" />
<Skeleton className="h-4 flex-1 min-w-[150px]" />
<Skeleton className="h-4 w-20 flex-shrink-0" />
<Skeleton className="h-4 w-24 flex-shrink-0" />
<Skeleton className="h-4 w-16 flex-shrink-0" />
<Skeleton className="h-4 w-28 flex-shrink-0" />
</div>
))}
</div>
) : files.length === 0 ? (
<div className="flex items-center justify-center h-full text-gray-500">
</div>
) : (
<div className="space-y-1">
{files.map((file) => (
<div
key={file.path}
className="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-gray-100 dark:hover:bg-gray-800 group"
>
{/* 文件名 */}
<div
className="flex-1 min-w-[150px] flex items-center gap-2 cursor-pointer"
onDoubleClick={() => handleEnterDirectory(file)}
title={file.isSymlink ? `链接到: ${file.linkTarget || '未知'}` : file.name}
>
{file.isDirectory ? (
<Folder className="h-4 w-4 text-blue-500 flex-shrink-0" />
) : (
<File className="h-4 w-4 text-gray-500 flex-shrink-0" />
)}
<span className="text-sm truncate flex items-center gap-1">
{file.name}
{file.isSymlink && (
<Link className="h-3 w-3 text-cyan-500 flex-shrink-0" />
)}
</span>
</div>
{/* 文件大小 */}
<span className="text-xs text-gray-500 w-20 text-right flex-shrink-0">
{file.sizeFormatted}
</span>
{/* 权限 */}
<span className="text-xs text-gray-500 font-mono w-24 flex-shrink-0">
{file.permissions}
</span>
{/* 所有者:组 */}
<span className="text-xs text-gray-500 w-16 flex-shrink-0 truncate" title={`${file.owner}:${file.group}`}>
{file.owner}
</span>
{/* 修改时间 */}
<span className="text-xs text-gray-500 w-28 flex-shrink-0">
{new Date(file.modifyTime).toLocaleDateString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})}
</span>
{/* 删除按钮 */}
<Button
size="sm"
variant="ghost"
className="h-6 w-8 p-0 opacity-0 group-hover:opacity-100 flex-shrink-0"
onClick={() => handleDelete(file)}
>
<Trash2 className="h-3.5 w-3.5 text-red-500" />
</Button>
</div>
))}
</div>
)}
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,272 @@
/**
*
*/
import React, { useState, useRef } from 'react';
import { Button } from '@/components/ui/button';
import { Progress } from '@/components/ui/progress';
import { Upload, X, File as FileIcon, CheckCircle, AlertCircle } from 'lucide-react';
import { uploadFile, pathUtils } from '@/services/fileService';
import { message } from 'antd';
import type { UploadPanelProps, UploadFileItem } from './types';
export const UploadPanel: React.FC<UploadPanelProps> = ({
serverId,
currentPath,
onUploadComplete,
}) => {
const [uploadFiles, setUploadFiles] = useState<UploadFileItem[]>([]);
const [isDragging, setIsDragging] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// 添加文件到上传列表
const addFiles = (files: FileList | null) => {
if (!files || files.length === 0) return;
const newFiles: UploadFileItem[] = Array.from(files).map(file => ({
file,
id: `${file.name}-${Date.now()}-${Math.random()}`,
status: 'pending',
progress: 0,
}));
setUploadFiles(prev => [...prev, ...newFiles]);
};
// 从列表移除文件
const removeFile = (id: string) => {
setUploadFiles(prev => prev.filter(f => f.id !== id));
};
// 上传单个文件
const uploadSingleFile = async (item: UploadFileItem) => {
const remotePath = pathUtils.join(currentPath, item.file.name);
try {
setUploadFiles(prev =>
prev.map(f =>
f.id === item.id
? { ...f, status: 'uploading', progress: 0 }
: f
)
);
await uploadFile(
serverId,
item.file,
remotePath,
false,
(progress) => {
setUploadFiles(prev =>
prev.map(f =>
f.id === item.id ? { ...f, progress } : f
)
);
}
);
setUploadFiles(prev =>
prev.map(f =>
f.id === item.id
? { ...f, status: 'success', progress: 100 }
: f
)
);
onUploadComplete();
} catch (error: any) {
const errorMsg = error.response?.data?.message || '上传失败';
setUploadFiles(prev =>
prev.map(f =>
f.id === item.id
? { ...f, status: 'error', errorMessage: errorMsg }
: f
)
);
message.error(`${item.file.name}: ${errorMsg}`);
}
};
// 上传所有待上传文件
const handleUploadAll = async () => {
const pendingFiles = uploadFiles.filter(f => f.status === 'pending');
for (const file of pendingFiles) {
await uploadSingleFile(file);
}
};
// 清空列表
const handleClearList = () => {
setUploadFiles([]);
};
// 拖拽事件处理
const handleDragEnter = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(true);
};
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
const files = e.dataTransfer.files;
addFiles(files);
};
// 文件选择
const handleFileSelect = () => {
fileInputRef.current?.click();
};
const handleFileInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
addFiles(e.target.files);
// 重置input允许选择相同文件
e.target.value = '';
};
const pendingCount = uploadFiles.filter(f => f.status === 'pending').length;
const uploadingCount = uploadFiles.filter(f => f.status === 'uploading').length;
return (
<div className="flex-1 flex flex-col overflow-hidden">
{/* 工具栏 */}
<div className="flex items-center gap-2 p-2 border-b">
<Button
size="sm"
onClick={handleFileSelect}
disabled={uploadingCount > 0}
>
<Upload className="h-4 w-4 mr-1" />
</Button>
<Button
size="sm"
onClick={handleUploadAll}
disabled={pendingCount === 0 || uploadingCount > 0}
>
({pendingCount})
</Button>
<Button
size="sm"
variant="ghost"
onClick={handleClearList}
disabled={uploadFiles.length === 0 || uploadingCount > 0}
>
</Button>
</div>
{/* 隐藏的文件输入 */}
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={handleFileInputChange}
/>
{/* 拖拽上传区域 */}
<div
className={`flex-1 overflow-y-auto ${
uploadFiles.length === 0 ? 'flex items-center justify-center' : 'p-2'
}`}
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{uploadFiles.length === 0 ? (
<div
className={`w-full max-w-md p-12 border-2 border-dashed rounded-lg text-center transition-colors ${
isDragging
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-300 dark:border-gray-700'
}`}
>
<Upload className="h-12 w-12 mx-auto mb-4 text-gray-400" />
<p className="text-lg font-medium mb-2"></p>
<p className="text-sm text-gray-500 mb-4"></p>
<p className="text-xs text-gray-400">
: {currentPath}
</p>
</div>
) : (
<div className="space-y-2">
{uploadFiles.map(item => (
<div
key={item.id}
className="p-3 border rounded-lg bg-white dark:bg-gray-900"
>
<div className="flex items-start gap-2">
<FileIcon className="h-5 w-5 text-gray-500 flex-shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium truncate">
{item.file.name}
</span>
<div className="flex items-center gap-2 flex-shrink-0">
{item.status === 'success' && (
<CheckCircle className="h-4 w-4 text-green-500" />
)}
{item.status === 'error' && (
<AlertCircle className="h-4 w-4 text-red-500" />
)}
{item.status === 'pending' && (
<Button
size="sm"
variant="ghost"
className="h-6 w-6 p-0"
onClick={() => removeFile(item.id)}
>
<X className="h-4 w-4" />
</Button>
)}
</div>
</div>
<div className="flex items-center gap-2 mt-1">
<span className="text-xs text-gray-500">
{(item.file.size / 1024).toFixed(1)} KB
</span>
{item.status === 'uploading' && (
<span className="text-xs text-blue-600">
{item.progress}%
</span>
)}
{item.status === 'success' && (
<span className="text-xs text-green-600">
</span>
)}
{item.status === 'error' && (
<span className="text-xs text-red-600">
{item.errorMessage}
</span>
)}
</div>
{item.status === 'uploading' && (
<Progress value={item.progress} className="mt-2 h-1" />
)}
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
};

View File

@ -0,0 +1 @@
export { FileManager } from './FileManager';

View File

@ -0,0 +1,34 @@
/**
* FileManager
*/
export interface UploadPanelProps {
serverId: number;
currentPath: string;
onUploadComplete: () => void;
}
export interface UploadFileItem {
file: File;
id: string;
status: 'pending' | 'uploading' | 'success' | 'error';
progress: number;
errorMessage?: string;
}
export interface ServerFilePanelProps {
serverId: number;
currentPath: string;
onPathChange: (path: string) => void;
refreshTrigger: number;
}
export interface FileManagerProps {
serverId: number;
serverName: string;
open: boolean;
onClose: () => void;
onMinimize: () => void;
isActive: boolean;
onFocus: () => void;
}

View File

@ -43,7 +43,10 @@ export const Terminal: React.FC<TerminalProps> = ({
const [auditShown, setAuditShown] = useState(false); const [auditShown, setAuditShown] = useState(false);
const [connectionStatus, setConnectionStatus] = useState<ConnectionStatus>('disconnected'); // 初始状态,会被实例状态覆盖 const [connectionStatus, setConnectionStatus] = useState<ConnectionStatus>('disconnected'); // 初始状态,会被实例状态覆盖
const [errorMessage, setErrorMessage] = useState<string>(''); const [errorMessage, setErrorMessage] = useState<string>('');
const [connectedTime, setConnectedTime] = useState<Date | null>(null);
const [showFileManager, setShowFileManager] = useState(false); const [showFileManager, setShowFileManager] = useState(false);
const [fileManagerMinimized, setFileManagerMinimized] = useState(false);
const [fileManagerActive, setFileManagerActive] = useState(false);
// 默认工具栏配置 // 默认工具栏配置
const toolbarConfig: TerminalToolbarConfig = { const toolbarConfig: TerminalToolbarConfig = {
@ -90,6 +93,14 @@ export const Terminal: React.FC<TerminalProps> = ({
const unsubscribe = instance.onStateChange((state) => { const unsubscribe = instance.onStateChange((state) => {
setConnectionStatus(state.status); setConnectionStatus(state.status);
setErrorMessage(state.errorMessage || ''); setErrorMessage(state.errorMessage || '');
// 记录连接成功时间
if (state.status === 'connected' && !connectedTime) {
setConnectedTime(new Date());
} else if (state.status === 'disconnected' || state.status === 'error') {
setConnectedTime(null);
}
onStatusChange?.(state.status); onStatusChange?.(state.status);
if (state.errorMessage) { if (state.errorMessage) {
onError?.(state.errorMessage); onError?.(state.errorMessage);
@ -274,14 +285,43 @@ export const Terminal: React.FC<TerminalProps> = ({
return; return;
} }
setShowFileManager(true); setShowFileManager(true);
setFileManagerMinimized(false);
setFileManagerActive(true);
}, [connection.type]); }, [connection.type]);
// 最小化文件管理器
const handleFileManagerMinimize = useCallback(() => {
setFileManagerMinimized(true);
setFileManagerActive(false);
}, []);
// 恢复文件管理器
const handleFileManagerRestore = useCallback(() => {
setFileManagerMinimized(false);
setFileManagerActive(true);
}, []);
// 激活文件管理器
const handleFileManagerFocus = useCallback(() => {
setFileManagerActive(true);
}, []);
// 关闭文件管理器
const handleFileManagerClose = useCallback(() => {
setShowFileManager(false);
setFileManagerMinimized(false);
setFileManagerActive(false);
}, []);
return ( return (
<div ref={wrapperRef} className={styles.terminalWrapper}> <div ref={wrapperRef} className={styles.terminalWrapper}>
{/* 工具栏 */} {/* 工具栏 */}
<TerminalToolbar <TerminalToolbar
config={toolbarConfig} config={toolbarConfig}
connectionStatus={connectionStatus} connectionStatus={connectionStatus}
serverName={connection.serverName}
hostIp={connection.hostIp}
connectedTime={connectedTime}
fontSize={fontSize} fontSize={fontSize}
currentTheme={currentTheme} currentTheme={currentTheme}
themes={TERMINAL_THEMES} themes={TERMINAL_THEMES}
@ -400,13 +440,29 @@ export const Terminal: React.FC<TerminalProps> = ({
)} )}
</div> </div>
{/* 最小化窗口状态栏 */}
{connection.type === 'ssh' && showFileManager && fileManagerMinimized && (
<div className="fixed bottom-4 left-4 z-[9999]">
<Button
variant="outline"
className="shadow-lg"
onClick={handleFileManagerRestore}
>
📁 - {`服务器 ${connection.serverId}`}
</Button>
</div>
)}
{/* 文件管理器 */} {/* 文件管理器 */}
{connection.type === 'ssh' && showFileManager && connection.serverId && ( {connection.type === 'ssh' && showFileManager && connection.serverId && !fileManagerMinimized && (
<FileManager <FileManager
serverId={Number(connection.serverId)} serverId={Number(connection.serverId)}
serverName={`服务器 ${connection.serverId}`} serverName={`服务器 ${connection.serverId}`}
open={showFileManager} open={showFileManager}
onClose={() => setShowFileManager(false)} onClose={handleFileManagerClose}
onMinimize={handleFileManagerMinimize}
isActive={fileManagerActive}
onFocus={handleFileManagerFocus}
/> />
)} )}
</div> </div>

View File

@ -6,7 +6,7 @@ import React from 'react';
import { Terminal } from './Terminal'; import { Terminal } from './Terminal';
import { TerminalSplitDivider } from './TerminalSplitDivider'; import { TerminalSplitDivider } from './TerminalSplitDivider';
import { X, Plus } from 'lucide-react'; import { X, Plus } from 'lucide-react';
import type { SplitNode, TerminalTab, TerminalConnectionConfig, TerminalAuditConfig, TerminalToolbarConfig } from './types'; import type { SplitNode, TerminalTab, TerminalConnectionConfig, TerminalAuditConfig, TerminalToolbarConfig, ConnectionStatus } from './types';
/** /**
* Props * Props
@ -27,6 +27,7 @@ export interface TerminalSplitNodeProps {
getAuditConfig: () => TerminalAuditConfig; getAuditConfig: () => TerminalAuditConfig;
getToolbarConfig: () => TerminalToolbarConfig; getToolbarConfig: () => TerminalToolbarConfig;
hasMultipleGroups?: boolean; // 是否有多个分屏组 hasMultipleGroups?: boolean; // 是否有多个分屏组
onTerminalStatusChange?: (tabId: string, status: ConnectionStatus) => void; // Terminal状态变化回调
} }
/** /**
@ -50,6 +51,7 @@ const TerminalSplitNodeComponent: React.FC<TerminalSplitNodeProps> = ({
getAuditConfig, getAuditConfig,
getToolbarConfig, getToolbarConfig,
hasMultipleGroups = false, hasMultipleGroups = false,
onTerminalStatusChange,
}) => { }) => {
// 渲染 Group 节点(包含 Tabs 和 Terminal // 渲染 Group 节点(包含 Tabs 和 Terminal
if (node.type === 'group') { if (node.type === 'group') {
@ -126,6 +128,7 @@ const TerminalSplitNodeComponent: React.FC<TerminalSplitNodeProps> = ({
toolbar={toolbarConfig} toolbar={toolbarConfig}
isActive={tab.isActive} isActive={tab.isActive}
compact={hasMultipleGroups} compact={hasMultipleGroups}
onStatusChange={(status) => onTerminalStatusChange?.(tab.id, status)}
onSplitUp={onSplitUp} onSplitUp={onSplitUp}
onSplitDown={onSplitDown} onSplitDown={onSplitDown}
onSplitLeft={onSplitLeft} onSplitLeft={onSplitLeft}
@ -169,6 +172,7 @@ const TerminalSplitNodeComponent: React.FC<TerminalSplitNodeProps> = ({
getAuditConfig={getAuditConfig} getAuditConfig={getAuditConfig}
getToolbarConfig={getToolbarConfig} getToolbarConfig={getToolbarConfig}
hasMultipleGroups={hasMultipleGroups} hasMultipleGroups={hasMultipleGroups}
onTerminalStatusChange={onTerminalStatusChange}
/> />
</div> </div>

View File

@ -2,10 +2,10 @@
* Terminal - * Terminal -
* TerminalSplitNode * TerminalSplitNode
*/ */
import React, { useEffect } from 'react'; import React, { useRef, useEffect } from 'react';
import { useSplitView } from './useSplitView'; import { useSplitView } from './useSplitView';
import { TerminalSplitNode } from './TerminalSplitNode'; import { TerminalSplitNode } from './TerminalSplitNode';
import type { TerminalTab, TerminalConnectionConfig, TerminalAuditConfig, TerminalToolbarConfig, SplitNode } from './types'; import type { TerminalTab, TerminalConnectionConfig, TerminalAuditConfig, TerminalToolbarConfig, SplitNode, ConnectionStatus } from './types';
// 对外暴露的主组件Props // 对外暴露的主组件Props
export interface TerminalSplitViewProps { export interface TerminalSplitViewProps {
@ -15,6 +15,7 @@ export interface TerminalSplitViewProps {
getToolbarConfig: () => TerminalToolbarConfig; getToolbarConfig: () => TerminalToolbarConfig;
onCloseAllReady?: (closeAllFn: () => void) => void; // 暴露closeAll函数给外部 onCloseAllReady?: (closeAllFn: () => void) => void; // 暴露closeAll函数给外部
onWindowClose?: () => void; // 最后一个Tab关闭时通知关闭整个窗口 onWindowClose?: () => void; // 最后一个Tab关闭时通知关闭整个窗口
onStatusChange?: (status: ConnectionStatus) => void; // 当前激活Tab的连接状态变化
} }
/** /**
@ -32,6 +33,7 @@ export const TerminalSplitView: React.FC<TerminalSplitViewProps> = ({
getToolbarConfig, getToolbarConfig,
onCloseAllReady, onCloseAllReady,
onWindowClose, onWindowClose,
onStatusChange,
}) => { }) => {
const { const {
layout, layout,
@ -48,6 +50,62 @@ export const TerminalSplitView: React.FC<TerminalSplitViewProps> = ({
setActiveGroupId, setActiveGroupId,
} = useSplitView({ initialTab, onWindowClose }); } = useSplitView({ initialTab, onWindowClose });
// 使用useRef追踪所有Tab的连接状态避免无限循环
const tabStatusesRef = useRef<Map<string, ConnectionStatus>>(new Map());
const lastReportedStatusRef = useRef<{tabId: string | null, status: ConnectionStatus | null}>({ tabId: null, status: null });
const onStatusChangeRef = useRef(onStatusChange);
// 保持onStatusChange引用最新
useEffect(() => {
onStatusChangeRef.current = onStatusChange;
}, [onStatusChange]);
// 找到当前激活的Tab ID (不使用useCallback直接定义)
const findActiveTabId = (node: SplitNode, targetGroupId: string): string | null => {
if (node.type === 'group' && node.id === targetGroupId) {
const activeTab = node.tabs.find(tab => tab.isActive);
return activeTab?.id || null;
}
if (node.type === 'container') {
for (const child of node.children) {
const result = findActiveTabId(child, targetGroupId);
if (result) return result;
}
}
return null;
};
// 当Terminal状态变化时的回调 - 使用稳定的引用
const handleTerminalStatusChange = React.useCallback((tabId: string, status: ConnectionStatus) => {
// 更新状态映射
tabStatusesRef.current.set(tabId, status);
// 使用ref获取最新的layout和activeGroupId避免依赖导致重建
const activeTabId = findActiveTabId(layout.root, activeGroupId);
if (activeTabId === tabId && onStatusChangeRef.current) {
// 只有状态真正变化时才报告
const lastReported = lastReportedStatusRef.current;
if (lastReported.tabId !== tabId || lastReported.status !== status) {
lastReportedStatusRef.current = { tabId, status };
onStatusChangeRef.current(status);
}
}
}, []); // 空依赖,永远不重新创建
// 当激活Tab变化时报告新激活Tab的状态
useEffect(() => {
const activeTabId = findActiveTabId(layout.root, activeGroupId);
if (activeTabId && onStatusChangeRef.current) {
const status = tabStatusesRef.current.get(activeTabId) || 'connecting';
const lastReported = lastReportedStatusRef.current;
// 只有Tab或状态变化时才报告
if (lastReported.tabId !== activeTabId || lastReported.status !== status) {
lastReportedStatusRef.current = { tabId: activeTabId, status };
onStatusChangeRef.current(status);
}
}
}, [activeGroupId, layout.root]);
// 暴露closeAll方法给外部 // 暴露closeAll方法给外部
useEffect(() => { useEffect(() => {
if (onCloseAllReady) { if (onCloseAllReady) {
@ -110,6 +168,7 @@ export const TerminalSplitView: React.FC<TerminalSplitViewProps> = ({
getAuditConfig={getAuditConfig} getAuditConfig={getAuditConfig}
getToolbarConfig={getToolbarConfig} getToolbarConfig={getToolbarConfig}
hasMultipleGroups={hasMultipleGroups} hasMultipleGroups={hasMultipleGroups}
onTerminalStatusChange={handleTerminalStatusChange}
/> />
</div> </div>
); );

View File

@ -20,6 +20,9 @@ import styles from './index.module.less';
interface TerminalToolbarProps { interface TerminalToolbarProps {
config: TerminalToolbarConfig; config: TerminalToolbarConfig;
connectionStatus: ConnectionStatus; connectionStatus: ConnectionStatus;
serverName?: string;
hostIp?: string;
connectedTime?: Date | null;
fontSize: number; fontSize: number;
currentTheme?: string; currentTheme?: string;
themes?: TerminalTheme[]; themes?: TerminalTheme[];
@ -30,7 +33,7 @@ interface TerminalToolbarProps {
onZoomIn?: () => void; onZoomIn?: () => void;
onZoomOut?: () => void; onZoomOut?: () => void;
onReconnect?: () => void; onReconnect?: () => void;
onThemeChange?: (themeName: string) => void; onThemeChange?: (theme: string) => void;
onFileManager?: () => void; onFileManager?: () => void;
// 分屏操作 // 分屏操作
onSplitUp?: () => void; onSplitUp?: () => void;
@ -43,6 +46,9 @@ interface TerminalToolbarProps {
export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
config, config,
connectionStatus, connectionStatus,
serverName,
hostIp,
connectedTime,
fontSize, fontSize,
currentTheme, currentTheme,
themes = [], themes = [],
@ -61,8 +67,35 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
onSplitRight, onSplitRight,
onSplitInGroup, onSplitInGroup,
}) => { }) => {
const [, forceUpdate] = React.useState(0);
// 定期更新连接时长显示
React.useEffect(() => {
if (connectionStatus === 'connected' && connectedTime) {
const timer = setInterval(() => {
forceUpdate(prev => prev + 1);
}, 1000); // 每秒更新一次
return () => clearInterval(timer);
}
}, [connectionStatus, connectedTime]);
if (!config.show) return null; if (!config.show) return null;
// 计算连接时长
const getConnectionDuration = () => {
if (!connectedTime) return '';
const now = new Date();
const diff = Math.floor((now.getTime() - connectedTime.getTime()) / 1000); // 秒
const hours = Math.floor(diff / 3600);
const minutes = Math.floor((diff % 3600) / 60);
const seconds = diff % 60;
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
}
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
};
const getStatusBadge = () => { const getStatusBadge = () => {
// 紧凑模式:只显示状态圆点 // 紧凑模式:只显示状态圆点
if (compact) { if (compact) {
@ -70,7 +103,7 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
case 'connecting': case 'connecting':
return <div className="h-2 w-2 rounded-full bg-yellow-500 animate-pulse" title="连接中" />; return <div className="h-2 w-2 rounded-full bg-yellow-500 animate-pulse" title="连接中" />;
case 'connected': case 'connected':
return <div className="h-2 w-2 rounded-full bg-emerald-500 animate-pulse" title="已连接" />; return <div className="h-2 w-2 rounded-full bg-green-500 animate-pulse" title={`已连接 ${hostIp ? `- ${hostIp}` : ''}`} />;
case 'reconnecting': case 'reconnecting':
return <div className="h-2 w-2 rounded-full bg-orange-500 animate-pulse" title="重连中" />; return <div className="h-2 w-2 rounded-full bg-orange-500 animate-pulse" title="重连中" />;
case 'error': case 'error':
@ -85,7 +118,25 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
case 'connecting': case 'connecting':
return <Badge variant="outline" className="bg-yellow-100 text-yellow-700"><Loader2 className="mr-1 h-3 w-3 animate-spin" /></Badge>; return <Badge variant="outline" className="bg-yellow-100 text-yellow-700"><Loader2 className="mr-1 h-3 w-3 animate-spin" /></Badge>;
case 'connected': 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>; // 已连接状态显示IP和连接时长
return (
<Badge variant="outline" className="bg-green-100 text-green-700 flex items-center gap-1.5">
<div className="h-2 w-2 rounded-full bg-green-500 animate-pulse" />
<span></span>
{hostIp && (
<>
<span className="text-gray-400">|</span>
<span className="font-mono text-xs">{hostIp}</span>
</>
)}
{connectedTime && (
<>
<span className="text-gray-400">|</span>
<span className="font-mono text-xs">{getConnectionDuration()}</span>
</>
)}
</Badge>
);
case 'reconnecting': case 'reconnecting':
return <Badge variant="outline" className="bg-orange-100 text-orange-700"><Loader2 className="mr-1 h-3 w-3 animate-spin" /></Badge>; return <Badge variant="outline" className="bg-orange-100 text-orange-700"><Loader2 className="mr-1 h-3 w-3 animate-spin" /></Badge>;
case 'error': case 'error':

View File

@ -125,9 +125,10 @@ export function TerminalWindowManager<TResource = any>({
console.log(`⬆️ 恢复窗口: ${windowId}`); console.log(`⬆️ 恢复窗口: ${windowId}`);
// 触发resize事件确保终端正确调整尺寸 // 触发resize事件确保终端正确调整尺寸
setTimeout(() => window.dispatchEvent(new Event('resize')), 50); // 延迟100ms等待DOM更新完成
setTimeout(() => window.dispatchEvent(new Event('resize')), 150); setTimeout(() => {
setTimeout(() => window.dispatchEvent(new Event('resize')), 300); window.dispatchEvent(new Event('resize'));
}, 100);
}, []); }, []);
// 激活窗口 // 激活窗口
@ -148,14 +149,15 @@ export function TerminalWindowManager<TResource = any>({
const getButtonStyle = (status?: ConnectionStatus) => { const getButtonStyle = (status?: ConnectionStatus) => {
switch (status) { switch (status) {
case 'connected': case 'connected':
return 'bg-green-600 hover:bg-green-700'; return 'bg-green-600 hover:bg-green-700'; // 绿色:已连接
case 'error':
case 'disconnected':
return 'bg-red-600 hover:bg-red-700';
case 'connecting': case 'connecting':
return 'bg-yellow-600 hover:bg-yellow-700'; return 'bg-yellow-600 hover:bg-yellow-700'; // 黄色:正在连接
case 'reconnecting': case 'reconnecting':
return 'bg-orange-600 hover:bg-orange-700'; return 'bg-orange-600 hover:bg-orange-700'; // 橙色:重连中
case 'error':
return 'bg-red-600 hover:bg-red-700'; // 红色:连接失败
case 'disconnected':
return 'bg-yellow-600 hover:bg-yellow-700'; // 黄色:已断开
default: default:
return 'bg-blue-600 hover:bg-blue-700'; return 'bg-blue-600 hover:bg-blue-700';
} }

View File

@ -55,6 +55,7 @@ export class TerminalInstance {
private stateListeners: Set<StateChangeCallback> = new Set(); private stateListeners: Set<StateChangeCallback> = new Set();
private unsubscribers: Array<() => void> = []; private unsubscribers: Array<() => void> = [];
private auditShown: boolean = false; private auditShown: boolean = false;
private firstOutputReceived: boolean = false; // 追踪是否已接收首次output
constructor(private config: TerminalInstanceConfig) { constructor(private config: TerminalInstanceConfig) {
// 初始化 XTerm // 初始化 XTerm
@ -313,6 +314,19 @@ export class TerminalInstance {
// 监听消息接收 // 监听消息接收
const unsubMessage = this.connectionStrategy.onMessage((data) => { const unsubMessage = this.connectionStrategy.onMessage((data) => {
this.xterm.write(data); this.xterm.write(data);
// 首次接收output时触发resize以正确适应窗口大小
if (!this.firstOutputReceived && data && data.length > 0) {
this.firstOutputReceived = true;
// 延迟一点确保DOM已渲染
setTimeout(() => {
this.fitAddon.fit();
// 发送终端尺寸给后端
const cols = this.xterm.cols;
const rows = this.xterm.rows;
this.connectionStrategy.resize(cols, rows);
}, 50);
}
}); });
// 监听状态变化 // 监听状态变化

View File

@ -120,6 +120,10 @@ export interface TerminalConnectionConfig {
type: TerminalType; type: TerminalType;
/** 服务器ID仅SSH需要 */ /** 服务器ID仅SSH需要 */
serverId?: string | number; serverId?: string | number;
/** 服务器名称 */
serverName?: string;
/** 服务器IP地址 */
hostIp?: string;
/** 认证token */ /** 认证token */
token?: string; token?: string;
/** 连接超时时间(毫秒) */ /** 连接超时时间(毫秒) */

View File

@ -10,6 +10,7 @@ import {
type TerminalConnectionConfig, type TerminalConnectionConfig,
type TerminalAuditConfig, type TerminalAuditConfig,
type TerminalToolbarConfig, type TerminalToolbarConfig,
type ConnectionStatus,
} from '@/components/Terminal'; } from '@/components/Terminal';
import type { ServerResponse } from '../types'; import type { ServerResponse } from '../types';
@ -24,6 +25,7 @@ interface SSHTerminalSplitViewWrapperProps {
getAuditConfig: () => TerminalAuditConfig; getAuditConfig: () => TerminalAuditConfig;
getToolbarConfig: () => TerminalToolbarConfig; getToolbarConfig: () => TerminalToolbarConfig;
onCloseReady: () => void; onCloseReady: () => void;
onStatusChange?: (status: ConnectionStatus) => void;
} }
const SSHTerminalSplitViewWrapper: React.FC<SSHTerminalSplitViewWrapperProps> = ({ const SSHTerminalSplitViewWrapper: React.FC<SSHTerminalSplitViewWrapperProps> = ({
@ -33,8 +35,20 @@ const SSHTerminalSplitViewWrapper: React.FC<SSHTerminalSplitViewWrapperProps> =
getAuditConfig, getAuditConfig,
getToolbarConfig, getToolbarConfig,
onCloseReady, onCloseReady,
onStatusChange,
}) => { }) => {
const closeAllRef = useRef<(() => void) | null>(null); const closeAllRef = useRef<(() => void) | null>(null);
const onCloseReadyRef = useRef(onCloseReady);
const onStatusChangeRef = useRef(onStatusChange);
// 保持最新的回调引用
useEffect(() => {
onCloseReadyRef.current = onCloseReady;
}, [onCloseReady]);
useEffect(() => {
onStatusChangeRef.current = onStatusChange;
}, [onStatusChange]);
// 注册优雅关闭方法到window对象 // 注册优雅关闭方法到window对象
useEffect(() => { useEffect(() => {
@ -46,7 +60,7 @@ const SSHTerminalSplitViewWrapper: React.FC<SSHTerminalSplitViewWrapperProps> =
closeAllRef.current(); closeAllRef.current();
} }
// 再通知窗口可以关闭 // 再通知窗口可以关闭
onCloseReady(); onCloseReadyRef.current();
}; };
console.log(`[SSHTerminalSplitViewWrapper] 注册优雅关闭方法: ${closeMethodName}`); console.log(`[SSHTerminalSplitViewWrapper] 注册优雅关闭方法: ${closeMethodName}`);
@ -54,7 +68,16 @@ const SSHTerminalSplitViewWrapper: React.FC<SSHTerminalSplitViewWrapperProps> =
delete (window as any)[closeMethodName]; delete (window as any)[closeMethodName];
console.log(`[SSHTerminalSplitViewWrapper] 注销优雅关闭方法: ${closeMethodName}`); console.log(`[SSHTerminalSplitViewWrapper] 注销优雅关闭方法: ${closeMethodName}`);
}; };
}, [windowId, onCloseReady]); }, [windowId]); // 只依赖windowId不依赖onCloseReady
// 使用稳定的回调引用
const stableOnWindowClose = React.useCallback(() => {
onCloseReadyRef.current();
}, []);
const stableOnStatusChange = React.useCallback((status: ConnectionStatus) => {
onStatusChangeRef.current?.(status);
}, []);
return ( return (
<TerminalSplitView <TerminalSplitView
@ -66,7 +89,8 @@ const SSHTerminalSplitViewWrapper: React.FC<SSHTerminalSplitViewWrapperProps> =
// 保存closeAll函数到ref // 保存closeAll函数到ref
closeAllRef.current = closeAllFn; closeAllRef.current = closeAllFn;
}} }}
onWindowClose={onCloseReady} onWindowClose={stableOnWindowClose}
onStatusChange={stableOnStatusChange}
/> />
); );
}; };
@ -98,6 +122,8 @@ export const SSHWindowManager: React.FC<SSHWindowManagerProps> = ({ onOpenWindow
return { return {
type: 'ssh', type: 'ssh',
serverId: tab.serverId, serverId: tab.serverId,
serverName: server.serverName,
hostIp: server.hostIp,
token: token || undefined, token: token || undefined,
autoReconnect: true, autoReconnect: true,
reconnectInterval: 3000, reconnectInterval: 3000,
@ -130,6 +156,7 @@ export const SSHWindowManager: React.FC<SSHWindowManagerProps> = ({ onOpenWindow
getAuditConfig={getAuditConfig} getAuditConfig={getAuditConfig}
getToolbarConfig={getToolbarConfig} getToolbarConfig={getToolbarConfig}
onCloseReady={onCloseReady} onCloseReady={onCloseReady}
onStatusChange={onStatusChange}
/> />
); );
}} }}

View File

@ -0,0 +1,155 @@
/**
* SSH文件管理API服务
*/
import request from '@/utils/request';
const API_BASE = '/api/v1/server-ssh';
export interface RemoteFileInfo {
name: string;
path: string;
isDirectory: boolean;
size: number | null;
sizeFormatted: string;
permissions: string;
permissionsOctal?: string;
modifyTime: string;
owner: string;
ownerUid?: number;
group: string;
groupGid?: number;
extension: string | null;
isSymlink: boolean;
linkTarget: string | null;
}
export interface FileUploadResult {
fileName: string;
remotePath: string;
fileSize: number;
fileSizeFormatted: string;
uploadTime: string;
permissions: string;
success: boolean;
errorMessage: string | null;
}
/**
*
*/
export const browseDirectory = async (
serverId: number,
path?: string
): Promise<RemoteFileInfo[]> => {
const response = await request.get(`${API_BASE}/${serverId}/files/browse`, {
params: { path },
});
return response; // request拦截器已经提取了data字段
};
/**
*
*/
export const createDirectory = async (
serverId: number,
path: string
): Promise<void> => {
await request.post(`${API_BASE}/${serverId}/files/mkdir`, null, {
params: { path },
});
};
/**
* /
*/
export const removeFile = async (
serverId: number,
path: string,
recursive?: boolean
): Promise<void> => {
await request.delete(`${API_BASE}/${serverId}/files/remove`, {
params: { path, recursive },
});
};
/**
*
*/
export const uploadFile = async (
serverId: number,
file: File,
remotePath: string,
overwrite?: boolean,
onProgress?: (progress: number) => void
): Promise<FileUploadResult> => {
const formData = new FormData();
formData.append('file', file);
formData.append('remotePath', remotePath);
if (overwrite !== undefined) {
formData.append('overwrite', String(overwrite));
}
const response = await request.post(
`${API_BASE}/${serverId}/files/upload`,
formData,
{
headers: {
'Content-Type': 'multipart/form-data',
},
onUploadProgress: (progressEvent) => {
if (onProgress && progressEvent.total) {
const progress = Math.round(
(progressEvent.loaded * 100) / progressEvent.total
);
onProgress(progress);
}
},
}
);
return response; // request拦截器已经提取了data字段
};
/**
* /
*/
export const renameFile = async (
serverId: number,
oldPath: string,
newPath: string
): Promise<void> => {
await request.put(`${API_BASE}/${serverId}/files/rename`, null, {
params: { oldPath, newPath },
});
};
/**
*
*/
export const pathUtils = {
/**
*
*/
join(basePath: string, fileName: string): string {
if (!basePath) return fileName;
return basePath.endsWith('/')
? basePath + fileName
: basePath + '/' + fileName;
},
/**
*
*/
getParent(path: string): string {
if (!path || path === '/') return '/';
const lastSlash = path.lastIndexOf('/');
return lastSlash <= 0 ? '/' : path.substring(0, lastSlash);
},
/**
*
*/
getFileName(path: string): string {
const lastSlash = path.lastIndexOf('/');
return lastSlash === -1 ? path : path.substring(lastSlash + 1);
},
};