重写ssh前端组件,通用化
This commit is contained in:
parent
308a5587cc
commit
ba7663ebd6
77
frontend/src/components/FileManager/FileManager.tsx
Normal file
77
frontend/src/components/FileManager/FileManager.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
298
frontend/src/components/FileManager/ServerFilePanel.tsx
Normal file
298
frontend/src/components/FileManager/ServerFilePanel.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
272
frontend/src/components/FileManager/UploadPanel.tsx
Normal file
272
frontend/src/components/FileManager/UploadPanel.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
1
frontend/src/components/FileManager/index.ts
Normal file
1
frontend/src/components/FileManager/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { FileManager } from './FileManager';
|
||||
34
frontend/src/components/FileManager/types.ts
Normal file
34
frontend/src/components/FileManager/types.ts
Normal 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;
|
||||
}
|
||||
@ -43,7 +43,10 @@ export const Terminal: React.FC<TerminalProps> = ({
|
||||
const [auditShown, setAuditShown] = useState(false);
|
||||
const [connectionStatus, setConnectionStatus] = useState<ConnectionStatus>('disconnected'); // 初始状态,会被实例状态覆盖
|
||||
const [errorMessage, setErrorMessage] = useState<string>('');
|
||||
const [connectedTime, setConnectedTime] = useState<Date | null>(null);
|
||||
const [showFileManager, setShowFileManager] = useState(false);
|
||||
const [fileManagerMinimized, setFileManagerMinimized] = useState(false);
|
||||
const [fileManagerActive, setFileManagerActive] = useState(false);
|
||||
|
||||
// 默认工具栏配置
|
||||
const toolbarConfig: TerminalToolbarConfig = {
|
||||
@ -90,6 +93,14 @@ export const Terminal: React.FC<TerminalProps> = ({
|
||||
const unsubscribe = instance.onStateChange((state) => {
|
||||
setConnectionStatus(state.status);
|
||||
setErrorMessage(state.errorMessage || '');
|
||||
|
||||
// 记录连接成功时间
|
||||
if (state.status === 'connected' && !connectedTime) {
|
||||
setConnectedTime(new Date());
|
||||
} else if (state.status === 'disconnected' || state.status === 'error') {
|
||||
setConnectedTime(null);
|
||||
}
|
||||
|
||||
onStatusChange?.(state.status);
|
||||
if (state.errorMessage) {
|
||||
onError?.(state.errorMessage);
|
||||
@ -274,14 +285,43 @@ export const Terminal: React.FC<TerminalProps> = ({
|
||||
return;
|
||||
}
|
||||
setShowFileManager(true);
|
||||
setFileManagerMinimized(false);
|
||||
setFileManagerActive(true);
|
||||
}, [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 (
|
||||
<div ref={wrapperRef} className={styles.terminalWrapper}>
|
||||
{/* 工具栏 */}
|
||||
<TerminalToolbar
|
||||
config={toolbarConfig}
|
||||
connectionStatus={connectionStatus}
|
||||
serverName={connection.serverName}
|
||||
hostIp={connection.hostIp}
|
||||
connectedTime={connectedTime}
|
||||
fontSize={fontSize}
|
||||
currentTheme={currentTheme}
|
||||
themes={TERMINAL_THEMES}
|
||||
@ -400,13 +440,29 @@ export const Terminal: React.FC<TerminalProps> = ({
|
||||
)}
|
||||
</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
|
||||
serverId={Number(connection.serverId)}
|
||||
serverName={`服务器 ${connection.serverId}`}
|
||||
open={showFileManager}
|
||||
onClose={() => setShowFileManager(false)}
|
||||
onClose={handleFileManagerClose}
|
||||
onMinimize={handleFileManagerMinimize}
|
||||
isActive={fileManagerActive}
|
||||
onFocus={handleFileManagerFocus}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -6,7 +6,7 @@ import React from 'react';
|
||||
import { Terminal } from './Terminal';
|
||||
import { TerminalSplitDivider } from './TerminalSplitDivider';
|
||||
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
|
||||
@ -27,6 +27,7 @@ export interface TerminalSplitNodeProps {
|
||||
getAuditConfig: () => TerminalAuditConfig;
|
||||
getToolbarConfig: () => TerminalToolbarConfig;
|
||||
hasMultipleGroups?: boolean; // 是否有多个分屏组
|
||||
onTerminalStatusChange?: (tabId: string, status: ConnectionStatus) => void; // Terminal状态变化回调
|
||||
}
|
||||
|
||||
/**
|
||||
@ -50,6 +51,7 @@ const TerminalSplitNodeComponent: React.FC<TerminalSplitNodeProps> = ({
|
||||
getAuditConfig,
|
||||
getToolbarConfig,
|
||||
hasMultipleGroups = false,
|
||||
onTerminalStatusChange,
|
||||
}) => {
|
||||
// 渲染 Group 节点(包含 Tabs 和 Terminal)
|
||||
if (node.type === 'group') {
|
||||
@ -126,6 +128,7 @@ const TerminalSplitNodeComponent: React.FC<TerminalSplitNodeProps> = ({
|
||||
toolbar={toolbarConfig}
|
||||
isActive={tab.isActive}
|
||||
compact={hasMultipleGroups}
|
||||
onStatusChange={(status) => onTerminalStatusChange?.(tab.id, status)}
|
||||
onSplitUp={onSplitUp}
|
||||
onSplitDown={onSplitDown}
|
||||
onSplitLeft={onSplitLeft}
|
||||
@ -169,6 +172,7 @@ const TerminalSplitNodeComponent: React.FC<TerminalSplitNodeProps> = ({
|
||||
getAuditConfig={getAuditConfig}
|
||||
getToolbarConfig={getToolbarConfig}
|
||||
hasMultipleGroups={hasMultipleGroups}
|
||||
onTerminalStatusChange={onTerminalStatusChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@ -2,10 +2,10 @@
|
||||
* Terminal 分屏视图 - 主组件
|
||||
* 管理分屏状态、键盘快捷键,并委托给 TerminalSplitNode 进行递归渲染
|
||||
*/
|
||||
import React, { useEffect } from 'react';
|
||||
import React, { useRef, useEffect } from 'react';
|
||||
import { useSplitView } from './useSplitView';
|
||||
import { TerminalSplitNode } from './TerminalSplitNode';
|
||||
import type { TerminalTab, TerminalConnectionConfig, TerminalAuditConfig, TerminalToolbarConfig, SplitNode } from './types';
|
||||
import type { TerminalTab, TerminalConnectionConfig, TerminalAuditConfig, TerminalToolbarConfig, SplitNode, ConnectionStatus } from './types';
|
||||
|
||||
// 对外暴露的主组件Props
|
||||
export interface TerminalSplitViewProps {
|
||||
@ -15,6 +15,7 @@ export interface TerminalSplitViewProps {
|
||||
getToolbarConfig: () => TerminalToolbarConfig;
|
||||
onCloseAllReady?: (closeAllFn: () => void) => void; // 暴露closeAll函数给外部
|
||||
onWindowClose?: () => void; // 最后一个Tab关闭时,通知关闭整个窗口
|
||||
onStatusChange?: (status: ConnectionStatus) => void; // 当前激活Tab的连接状态变化
|
||||
}
|
||||
|
||||
/**
|
||||
@ -32,6 +33,7 @@ export const TerminalSplitView: React.FC<TerminalSplitViewProps> = ({
|
||||
getToolbarConfig,
|
||||
onCloseAllReady,
|
||||
onWindowClose,
|
||||
onStatusChange,
|
||||
}) => {
|
||||
const {
|
||||
layout,
|
||||
@ -48,6 +50,62 @@ export const TerminalSplitView: React.FC<TerminalSplitViewProps> = ({
|
||||
setActiveGroupId,
|
||||
} = 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方法给外部
|
||||
useEffect(() => {
|
||||
if (onCloseAllReady) {
|
||||
@ -110,6 +168,7 @@ export const TerminalSplitView: React.FC<TerminalSplitViewProps> = ({
|
||||
getAuditConfig={getAuditConfig}
|
||||
getToolbarConfig={getToolbarConfig}
|
||||
hasMultipleGroups={hasMultipleGroups}
|
||||
onTerminalStatusChange={handleTerminalStatusChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -20,6 +20,9 @@ import styles from './index.module.less';
|
||||
interface TerminalToolbarProps {
|
||||
config: TerminalToolbarConfig;
|
||||
connectionStatus: ConnectionStatus;
|
||||
serverName?: string;
|
||||
hostIp?: string;
|
||||
connectedTime?: Date | null;
|
||||
fontSize: number;
|
||||
currentTheme?: string;
|
||||
themes?: TerminalTheme[];
|
||||
@ -30,7 +33,7 @@ interface TerminalToolbarProps {
|
||||
onZoomIn?: () => void;
|
||||
onZoomOut?: () => void;
|
||||
onReconnect?: () => void;
|
||||
onThemeChange?: (themeName: string) => void;
|
||||
onThemeChange?: (theme: string) => void;
|
||||
onFileManager?: () => void;
|
||||
// 分屏操作
|
||||
onSplitUp?: () => void;
|
||||
@ -43,6 +46,9 @@ interface TerminalToolbarProps {
|
||||
export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
config,
|
||||
connectionStatus,
|
||||
serverName,
|
||||
hostIp,
|
||||
connectedTime,
|
||||
fontSize,
|
||||
currentTheme,
|
||||
themes = [],
|
||||
@ -61,8 +67,35 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
onSplitRight,
|
||||
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;
|
||||
|
||||
// 计算连接时长
|
||||
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 = () => {
|
||||
// 紧凑模式:只显示状态圆点
|
||||
if (compact) {
|
||||
@ -70,7 +103,7 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
case 'connecting':
|
||||
return <div className="h-2 w-2 rounded-full bg-yellow-500 animate-pulse" title="连接中" />;
|
||||
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':
|
||||
return <div className="h-2 w-2 rounded-full bg-orange-500 animate-pulse" title="重连中" />;
|
||||
case 'error':
|
||||
@ -85,7 +118,25 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
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>;
|
||||
// 已连接状态:显示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':
|
||||
return <Badge variant="outline" className="bg-orange-100 text-orange-700"><Loader2 className="mr-1 h-3 w-3 animate-spin" />重连中</Badge>;
|
||||
case 'error':
|
||||
|
||||
@ -125,9 +125,10 @@ export function TerminalWindowManager<TResource = any>({
|
||||
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);
|
||||
// 延迟100ms等待DOM更新完成
|
||||
setTimeout(() => {
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
}, 100);
|
||||
}, []);
|
||||
|
||||
// 激活窗口
|
||||
@ -148,14 +149,15 @@ export function TerminalWindowManager<TResource = any>({
|
||||
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';
|
||||
return 'bg-green-600 hover:bg-green-700'; // 绿色:已连接
|
||||
case 'connecting':
|
||||
return 'bg-yellow-600 hover:bg-yellow-700';
|
||||
return 'bg-yellow-600 hover:bg-yellow-700'; // 黄色:正在连接
|
||||
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:
|
||||
return 'bg-blue-600 hover:bg-blue-700';
|
||||
}
|
||||
|
||||
@ -55,6 +55,7 @@ export class TerminalInstance {
|
||||
private stateListeners: Set<StateChangeCallback> = new Set();
|
||||
private unsubscribers: Array<() => void> = [];
|
||||
private auditShown: boolean = false;
|
||||
private firstOutputReceived: boolean = false; // 追踪是否已接收首次output
|
||||
|
||||
constructor(private config: TerminalInstanceConfig) {
|
||||
// 初始化 XTerm
|
||||
@ -313,6 +314,19 @@ export class TerminalInstance {
|
||||
// 监听消息接收
|
||||
const unsubMessage = this.connectionStrategy.onMessage((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);
|
||||
}
|
||||
});
|
||||
|
||||
// 监听状态变化
|
||||
|
||||
@ -120,6 +120,10 @@ export interface TerminalConnectionConfig {
|
||||
type: TerminalType;
|
||||
/** 服务器ID(仅SSH需要) */
|
||||
serverId?: string | number;
|
||||
/** 服务器名称 */
|
||||
serverName?: string;
|
||||
/** 服务器IP地址 */
|
||||
hostIp?: string;
|
||||
/** 认证token */
|
||||
token?: string;
|
||||
/** 连接超时时间(毫秒) */
|
||||
|
||||
@ -10,6 +10,7 @@ import {
|
||||
type TerminalConnectionConfig,
|
||||
type TerminalAuditConfig,
|
||||
type TerminalToolbarConfig,
|
||||
type ConnectionStatus,
|
||||
} from '@/components/Terminal';
|
||||
import type { ServerResponse } from '../types';
|
||||
|
||||
@ -24,6 +25,7 @@ interface SSHTerminalSplitViewWrapperProps {
|
||||
getAuditConfig: () => TerminalAuditConfig;
|
||||
getToolbarConfig: () => TerminalToolbarConfig;
|
||||
onCloseReady: () => void;
|
||||
onStatusChange?: (status: ConnectionStatus) => void;
|
||||
}
|
||||
|
||||
const SSHTerminalSplitViewWrapper: React.FC<SSHTerminalSplitViewWrapperProps> = ({
|
||||
@ -33,8 +35,20 @@ const SSHTerminalSplitViewWrapper: React.FC<SSHTerminalSplitViewWrapperProps> =
|
||||
getAuditConfig,
|
||||
getToolbarConfig,
|
||||
onCloseReady,
|
||||
onStatusChange,
|
||||
}) => {
|
||||
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对象
|
||||
useEffect(() => {
|
||||
@ -46,7 +60,7 @@ const SSHTerminalSplitViewWrapper: React.FC<SSHTerminalSplitViewWrapperProps> =
|
||||
closeAllRef.current();
|
||||
}
|
||||
// 再通知窗口可以关闭
|
||||
onCloseReady();
|
||||
onCloseReadyRef.current();
|
||||
};
|
||||
console.log(`[SSHTerminalSplitViewWrapper] 注册优雅关闭方法: ${closeMethodName}`);
|
||||
|
||||
@ -54,7 +68,16 @@ const SSHTerminalSplitViewWrapper: React.FC<SSHTerminalSplitViewWrapperProps> =
|
||||
delete (window as any)[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 (
|
||||
<TerminalSplitView
|
||||
@ -66,7 +89,8 @@ const SSHTerminalSplitViewWrapper: React.FC<SSHTerminalSplitViewWrapperProps> =
|
||||
// 保存closeAll函数到ref
|
||||
closeAllRef.current = closeAllFn;
|
||||
}}
|
||||
onWindowClose={onCloseReady}
|
||||
onWindowClose={stableOnWindowClose}
|
||||
onStatusChange={stableOnStatusChange}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@ -98,6 +122,8 @@ export const SSHWindowManager: React.FC<SSHWindowManagerProps> = ({ onOpenWindow
|
||||
return {
|
||||
type: 'ssh',
|
||||
serverId: tab.serverId,
|
||||
serverName: server.serverName,
|
||||
hostIp: server.hostIp,
|
||||
token: token || undefined,
|
||||
autoReconnect: true,
|
||||
reconnectInterval: 3000,
|
||||
@ -130,6 +156,7 @@ export const SSHWindowManager: React.FC<SSHWindowManagerProps> = ({ onOpenWindow
|
||||
getAuditConfig={getAuditConfig}
|
||||
getToolbarConfig={getToolbarConfig}
|
||||
onCloseReady={onCloseReady}
|
||||
onStatusChange={onStatusChange}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
|
||||
155
frontend/src/services/fileService.ts
Normal file
155
frontend/src/services/fileService.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user