重写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">
<span>/</span>
<button
className="hover:text-blue-600 dark:hover:text-blue-400"
onClick={() => onPathChange('/')}
title="返回根目录"
>
/
</button>
{breadcrumbs.map((name, index) => (
<React.Fragment key={index}>
<ChevronRight className="h-4 w-4 flex-shrink-0" />
@ -236,7 +242,7 @@ export const ServerFilePanel: React.FC<ServerFilePanelProps> = ({
>
{/* 文件名 */}
<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)}
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 { Progress } from '@/components/ui/progress';
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 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> = ({
serverId,
@ -17,6 +28,24 @@ export const UploadPanel: React.FC<UploadPanelProps> = ({
const [uploadFiles, setUploadFiles] = useState<UploadFileItem[]>([]);
const [isDragging, setIsDragging] = useState(false);
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) => {
@ -34,14 +63,22 @@ export const UploadPanel: React.FC<UploadPanelProps> = ({
// 从列表移除文件
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));
};
// 上传单个文件
// 上传单个文件(异步上传+轮询)
const uploadSingleFile = async (item: UploadFileItem) => {
const remotePath = pathUtils.join(currentPath, item.file.name);
try {
// 1. 提交异步上传任务
setUploadFiles(prev =>
prev.map(f =>
f.id === item.id
@ -50,31 +87,20 @@ export const UploadPanel: React.FC<UploadPanelProps> = ({
)
);
await uploadFile(
serverId,
item.file,
remotePath,
false,
(progress) => {
setUploadFiles(prev =>
prev.map(f =>
f.id === item.id ? { ...f, progress } : f
)
);
}
);
const result = await submitAsyncUpload(serverId, item.file, remotePath, false);
const taskId = result.taskId;
// 2. 保存taskId
setUploadFiles(prev =>
prev.map(f =>
f.id === item.id
? { ...f, status: 'success', progress: 100 }
: f
f.id === item.id ? { ...f, taskId } : f
)
);
onUploadComplete();
// 3. 开始轮询查询状态
startPolling(item.id, taskId);
} catch (error: any) {
const errorMsg = error.response?.data?.message || '上传失败';
const errorMsg = error.response?.data?.message || '提交上传任务失败';
setUploadFiles(prev =>
prev.map(f =>
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 pendingFiles = uploadFiles.filter(f => f.status === 'pending');
@ -239,7 +325,7 @@ export const UploadPanel: React.FC<UploadPanelProps> = ({
</div>
<div className="flex items-center gap-2 mt-1">
<span className="text-xs text-gray-500">
{(item.file.size / 1024).toFixed(1)} KB
{formatFileSize(item.file.size)}
</span>
{item.status === 'uploading' && (
<span className="text-xs text-blue-600">

View File

@ -34,6 +34,45 @@ export interface FileUploadResult {
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 (
serverId: number,
@ -109,6 +148,60 @@ export const uploadFile = async (
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;
};
/**
* /
*/