重写ssh前端组件,通用化

This commit is contained in:
dengqichen 2025-12-07 02:21:22 +08:00
parent 04f1cbd251
commit bb6985341f
3 changed files with 212 additions and 27 deletions

View File

@ -144,7 +144,13 @@ export const ServerFilePanel: React.FC<ServerFilePanelProps> = ({
{/* 路径导航 */} {/* 路径导航 */}
<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"> <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> <button
className="hover:text-blue-600 dark:hover:text-blue-400"
onClick={() => onPathChange('/')}
title="返回根目录"
>
/
</button>
{breadcrumbs.map((name, index) => ( {breadcrumbs.map((name, index) => (
<React.Fragment key={index}> <React.Fragment key={index}>
<ChevronRight className="h-4 w-4 flex-shrink-0" /> <ChevronRight className="h-4 w-4 flex-shrink-0" />
@ -236,7 +242,7 @@ export const ServerFilePanel: React.FC<ServerFilePanelProps> = ({
> >
{/* 文件名 */} {/* 文件名 */}
<div <div
className="flex-1 min-w-[150px] flex items-center gap-2 cursor-pointer" className="flex-1 min-w-[150px] flex items-center gap-2 cursor-pointer select-none"
onDoubleClick={() => handleEnterDirectory(file)} onDoubleClick={() => handleEnterDirectory(file)}
title={file.isSymlink ? `链接到: ${file.linkTarget || '未知'}` : file.name} title={file.isSymlink ? `链接到: ${file.linkTarget || '未知'}` : file.name}
> >

View File

@ -1,13 +1,24 @@
/** /**
* * - +
*/ */
import React, { useState, useRef } from 'react'; import React, { useState, useRef, useEffect } from 'react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Progress } from '@/components/ui/progress'; import { Progress } from '@/components/ui/progress';
import { Upload, X, File as FileIcon, CheckCircle, AlertCircle } from 'lucide-react'; import { Upload, X, File as FileIcon, CheckCircle, AlertCircle } from 'lucide-react';
import { uploadFile, pathUtils } from '@/services/fileService'; import { submitAsyncUpload, getUploadTaskStatus, cancelUploadTask, pathUtils } from '@/services/fileService';
import type { UploadTaskStatus } from '@/services/fileService';
import { message } from 'antd'; import { message } from 'antd';
import type { UploadPanelProps, UploadFileItem } from './types'; import type { UploadPanelProps } from './types';
// 本地扩展的上传文件项类型
interface UploadFileItem {
file: File;
id: string;
status: 'pending' | 'uploading' | 'success' | 'error';
progress: number;
taskId?: string; // 异步上传任务ID
errorMessage?: string;
}
export const UploadPanel: React.FC<UploadPanelProps> = ({ export const UploadPanel: React.FC<UploadPanelProps> = ({
serverId, serverId,
@ -17,6 +28,24 @@ export const UploadPanel: React.FC<UploadPanelProps> = ({
const [uploadFiles, setUploadFiles] = useState<UploadFileItem[]>([]); const [uploadFiles, setUploadFiles] = useState<UploadFileItem[]>([]);
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const pollIntervalsRef = useRef<Map<string, NodeJS.Timeout>>(new Map()); // 保存所有轮询定时器
// 组件卸载时清理所有定时器
useEffect(() => {
return () => {
pollIntervalsRef.current.forEach(interval => clearInterval(interval));
pollIntervalsRef.current.clear();
};
}, []);
// 格式化文件大小
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return (bytes / Math.pow(k, i)).toFixed(2) + ' ' + sizes[i];
};
// 添加文件到上传列表 // 添加文件到上传列表
const addFiles = (files: FileList | null) => { const addFiles = (files: FileList | null) => {
@ -34,14 +63,22 @@ export const UploadPanel: React.FC<UploadPanelProps> = ({
// 从列表移除文件 // 从列表移除文件
const removeFile = (id: string) => { const removeFile = (id: string) => {
// 停止轮询
const interval = pollIntervalsRef.current.get(id);
if (interval) {
clearInterval(interval);
pollIntervalsRef.current.delete(id);
}
// 移除文件
setUploadFiles(prev => prev.filter(f => f.id !== id)); setUploadFiles(prev => prev.filter(f => f.id !== id));
}; };
// 上传单个文件 // 上传单个文件(异步上传+轮询)
const uploadSingleFile = async (item: UploadFileItem) => { const uploadSingleFile = async (item: UploadFileItem) => {
const remotePath = pathUtils.join(currentPath, item.file.name); const remotePath = pathUtils.join(currentPath, item.file.name);
try { try {
// 1. 提交异步上传任务
setUploadFiles(prev => setUploadFiles(prev =>
prev.map(f => prev.map(f =>
f.id === item.id f.id === item.id
@ -50,31 +87,20 @@ export const UploadPanel: React.FC<UploadPanelProps> = ({
) )
); );
await uploadFile( const result = await submitAsyncUpload(serverId, item.file, remotePath, false);
serverId, const taskId = result.taskId;
item.file,
remotePath,
false,
(progress) => {
setUploadFiles(prev =>
prev.map(f =>
f.id === item.id ? { ...f, progress } : f
)
);
}
);
// 2. 保存taskId
setUploadFiles(prev => setUploadFiles(prev =>
prev.map(f => prev.map(f =>
f.id === item.id f.id === item.id ? { ...f, taskId } : f
? { ...f, status: 'success', progress: 100 }
: f
) )
); );
onUploadComplete(); // 3. 开始轮询查询状态
startPolling(item.id, taskId);
} catch (error: any) { } catch (error: any) {
const errorMsg = error.response?.data?.message || '上传失败'; const errorMsg = error.response?.data?.message || '提交上传任务失败';
setUploadFiles(prev => setUploadFiles(prev =>
prev.map(f => prev.map(f =>
f.id === item.id f.id === item.id
@ -86,6 +112,66 @@ export const UploadPanel: React.FC<UploadPanelProps> = ({
} }
}; };
// 轮询查询上传状态
const startPolling = (fileId: string, taskId: string) => {
const pollInterval = setInterval(async () => {
try {
const taskInfo = await getUploadTaskStatus(serverId, taskId);
// 更新进度
setUploadFiles(prev =>
prev.map(f =>
f.id === fileId
? { ...f, progress: taskInfo.progress }
: f
)
);
// 检查是否完成
if (taskInfo.status === 'SUCCESS') {
clearInterval(pollInterval);
pollIntervalsRef.current.delete(fileId);
setUploadFiles(prev =>
prev.map(f =>
f.id === fileId
? { ...f, status: 'success', progress: 100 }
: f
)
);
onUploadComplete();
} else if (taskInfo.status === 'FAILED') {
clearInterval(pollInterval);
pollIntervalsRef.current.delete(fileId);
const errorMsg = taskInfo.errorMessage || '上传失败';
setUploadFiles(prev =>
prev.map(f =>
f.id === fileId
? { ...f, status: 'error', errorMessage: errorMsg }
: f
)
);
message.error(`${taskInfo.fileName}: ${errorMsg}`);
} else if (taskInfo.status === 'CANCELLED') {
clearInterval(pollInterval);
pollIntervalsRef.current.delete(fileId);
setUploadFiles(prev =>
prev.map(f =>
f.id === fileId
? { ...f, status: 'error', errorMessage: '已取消' }
: f
)
);
}
} catch (error) {
// 查询失败,继续轮询(可能是网络问题)
console.error('轮询查询失败:', error);
}
}, 2000); // 每2秒查询一次
// 保存定时器到ref
pollIntervalsRef.current.set(fileId, pollInterval);
};
// 上传所有待上传文件 // 上传所有待上传文件
const handleUploadAll = async () => { const handleUploadAll = async () => {
const pendingFiles = uploadFiles.filter(f => f.status === 'pending'); const pendingFiles = uploadFiles.filter(f => f.status === 'pending');
@ -239,7 +325,7 @@ export const UploadPanel: React.FC<UploadPanelProps> = ({
</div> </div>
<div className="flex items-center gap-2 mt-1"> <div className="flex items-center gap-2 mt-1">
<span className="text-xs text-gray-500"> <span className="text-xs text-gray-500">
{(item.file.size / 1024).toFixed(1)} KB {formatFileSize(item.file.size)}
</span> </span>
{item.status === 'uploading' && ( {item.status === 'uploading' && (
<span className="text-xs text-blue-600"> <span className="text-xs text-blue-600">

View File

@ -34,6 +34,45 @@ export interface FileUploadResult {
errorMessage: string | null; errorMessage: string | null;
} }
// 异步上传相关类型
export type UploadTaskStatus = 'PENDING' | 'UPLOADING' | 'SUCCESS' | 'FAILED' | 'CANCELLED';
export interface AsyncUploadResponse {
taskId: string;
message: string;
}
export interface UploadTaskInfo {
taskId: string;
fileName: string;
fileSize: number;
fileSizeFormatted: string;
uploadedSize: number;
uploadedSizeFormatted: string;
progress: number;
status: UploadTaskStatus;
metadata?: {
serverId: number;
remotePath: string;
overwrite: boolean;
};
errorMessage?: string;
createTime?: string;
updateTime?: string;
}
export interface UploadProgressMessage {
taskId: string;
status: UploadTaskStatus;
progress: number;
uploaded: number;
total: number;
uploadedFormatted: string;
totalFormatted: string;
errorMessage?: string;
timestamp: number;
}
/** /**
* *
*/ */
@ -73,7 +112,7 @@ export const removeFile = async (
}; };
/** /**
* *
*/ */
export const uploadFile = async ( export const uploadFile = async (
serverId: number, serverId: number,
@ -109,6 +148,60 @@ export const uploadFile = async (
return response; // request拦截器已经提取了data字段 return response; // request拦截器已经提取了data字段
}; };
/**
*
*/
export const submitAsyncUpload = async (
serverId: number,
file: File,
remotePath: string,
overwrite?: boolean
): Promise<AsyncUploadResponse> => {
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-async`,
formData,
{
headers: {
'Content-Type': 'multipart/form-data',
},
}
);
return response;
};
/**
*
*/
export const getUploadTaskStatus = async (
serverId: number,
taskId: string
): Promise<UploadTaskInfo> => {
const response = await request.get(
`${API_BASE}/${serverId}/files/upload-task/${taskId}`
);
return response;
};
/**
*
*/
export const cancelUploadTask = async (
serverId: number,
taskId: string
): Promise<{ cancelled: boolean }> => {
const response = await request.delete(
`${API_BASE}/${serverId}/files/upload-task/${taskId}`
);
return response;
};
/** /**
* / * /
*/ */