重写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 [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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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':
|
||||||
|
|||||||
@ -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';
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 监听状态变化
|
// 监听状态变化
|
||||||
|
|||||||
@ -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;
|
||||||
/** 连接超时时间(毫秒) */
|
/** 连接超时时间(毫秒) */
|
||||||
|
|||||||
@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
|
|||||||
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