重写ssh前端组件,通用化
This commit is contained in:
parent
04f1cbd251
commit
bb6985341f
@ -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}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 重命名文件/目录
|
* 重命名文件/目录
|
||||||
*/
|
*/
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user