重写ssh前端组件,通用化

This commit is contained in:
dengqichen 2025-12-07 16:38:14 +08:00
parent b8412314a8
commit 7309d05bcb
3 changed files with 265 additions and 28 deletions

View File

@ -5,6 +5,16 @@ import React, { useEffect, useState } from 'react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { ChevronRight, Folder, File, Home, ArrowLeft, Plus, Trash2, RefreshCw, Link } from 'lucide-react'; import { ChevronRight, Folder, File, Home, ArrowLeft, Plus, Trash2, RefreshCw, Link } from 'lucide-react';
import { browseDirectory, createDirectory, removeFile, pathUtils, type RemoteFileInfo } from '@/services/fileService'; import { browseDirectory, createDirectory, removeFile, pathUtils, type RemoteFileInfo } from '@/services/fileService';
import { message } from 'antd'; import { message } from 'antd';
@ -20,6 +30,8 @@ export const ServerFilePanel: React.FC<ServerFilePanelProps> = ({
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [showNewFolder, setShowNewFolder] = useState(false); const [showNewFolder, setShowNewFolder] = useState(false);
const [newFolderName, setNewFolderName] = useState(''); const [newFolderName, setNewFolderName] = useState('');
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [pendingDeleteFile, setPendingDeleteFile] = useState<RemoteFileInfo | null>(null);
// 加载文件列表 // 加载文件列表
const loadFiles = async () => { const loadFiles = async () => {
@ -81,24 +93,34 @@ export const ServerFilePanel: React.FC<ServerFilePanelProps> = ({
} }
}; };
// 删除文件/目录 // 请求删除文件/目录(弹出确认框)
const handleDelete = async (file: RemoteFileInfo) => { const handleDelete = (file: RemoteFileInfo) => {
const confirmed = window.confirm( setPendingDeleteFile(file);
file.isDirectory setDeleteConfirmOpen(true);
? `确定删除目录 "${file.name}" 及其所有内容吗?` };
: `确定删除文件 "${file.name}" 吗?`
); // 确认删除
if (!confirmed) return; const handleConfirmDelete = async () => {
setDeleteConfirmOpen(false);
if (!pendingDeleteFile) return;
try { try {
await removeFile(serverId, file.path, file.isDirectory); await removeFile(serverId, pendingDeleteFile.path, pendingDeleteFile.isDirectory);
message.success('删除成功'); message.success('删除成功');
loadFiles(); loadFiles();
} catch (error: any) { } catch (error: any) {
message.error(error.response?.data?.message || '删除失败'); message.error(error.response?.data?.message || '删除失败');
} finally {
setPendingDeleteFile(null);
} }
}; };
// 取消删除
const handleCancelDelete = () => {
setDeleteConfirmOpen(false);
setPendingDeleteFile(null);
};
// 面包屑导航 // 面包屑导航
const breadcrumbs = currentPath.split('/').filter(Boolean); const breadcrumbs = currentPath.split('/').filter(Boolean);
@ -299,6 +321,36 @@ export const ServerFilePanel: React.FC<ServerFilePanelProps> = ({
)} )}
</div> </div>
</div> </div>
{/* 删除确认对话框 */}
<AlertDialog open={deleteConfirmOpen} onOpenChange={setDeleteConfirmOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
{pendingDeleteFile?.isDirectory ? (
<>
<span className="font-semibold text-foreground">"{pendingDeleteFile.name}"</span>
<br />
<span className="text-red-600"></span>
</>
) : (
<>
<span className="font-semibold text-foreground">"{pendingDeleteFile?.name}"</span>
</>
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={handleCancelDelete}>
</AlertDialogCancel>
<AlertDialogAction onClick={handleConfirmDelete} className="bg-red-600 hover:bg-red-700">
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div> </div>
); );
}; };

View File

@ -4,8 +4,18 @@
import React, { useState, useRef, useEffect } 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 {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Upload, X, File as FileIcon, CheckCircle, AlertCircle } from 'lucide-react'; import { Upload, X, File as FileIcon, CheckCircle, AlertCircle } from 'lucide-react';
import { submitAsyncUpload, getUploadTaskStatus, cancelUploadTask, pathUtils } from '@/services/fileService'; import { submitAsyncUpload, getUploadTaskStatus, cancelUploadTask, pathUtils, browseDirectory } from '@/services/fileService';
import type { UploadTaskStatus } from '@/services/fileService'; import type { UploadTaskStatus } from '@/services/fileService';
import { message } from 'antd'; import { message } from 'antd';
import type { UploadPanelProps } from './types'; import type { UploadPanelProps } from './types';
@ -27,6 +37,11 @@ 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 [confirmDialogOpen, setConfirmDialogOpen] = useState(false);
const [pendingOverwriteItem, setPendingOverwriteItem] = useState<UploadFileItem | null>(null);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
const [clearConfirmOpen, setClearConfirmOpen] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const pollIntervalsRef = useRef<Map<string, NodeJS.Timeout>>(new Map()); // 保存所有轮询定时器 const pollIntervalsRef = useRef<Map<string, NodeJS.Timeout>>(new Map()); // 保存所有轮询定时器
@ -61,22 +76,82 @@ export const UploadPanel: React.FC<UploadPanelProps> = ({
setUploadFiles(prev => [...prev, ...newFiles]); setUploadFiles(prev => [...prev, ...newFiles]);
}; };
// 从列表移除文件 // 请求删除文件(弹出确认框)
const removeFile = (id: string) => { const requestRemoveFile = (id: string) => {
const item = uploadFiles.find(f => f.id === id);
// 正在上传的需要确认
if (item?.status === 'uploading') {
setPendingDeleteId(id);
setDeleteConfirmOpen(true);
} else {
// 其他状态直接删除
doRemoveFile(id);
}
};
// 实际执行删除文件或取消上传
const doRemoveFile = async (id: string) => {
const item = uploadFiles.find(f => f.id === id);
// 如果正在上传,先取消任务
if (item?.status === 'uploading' && item.taskId) {
try {
await cancelUploadTask(serverId, item.taskId);
message.success('上传已取消');
} catch (error) {
console.error('取消上传失败:', error);
}
}
// 停止轮询 // 停止轮询
const interval = pollIntervalsRef.current.get(id); const interval = pollIntervalsRef.current.get(id);
if (interval) { if (interval) {
clearInterval(interval); clearInterval(interval);
pollIntervalsRef.current.delete(id); pollIntervalsRef.current.delete(id);
} }
// 移除文件 // 移除文件
setUploadFiles(prev => prev.filter(f => f.id !== id)); setUploadFiles(prev => prev.filter(f => f.id !== id));
}; };
// 确认删除
const handleConfirmDelete = async () => {
setDeleteConfirmOpen(false);
if (pendingDeleteId) {
await doRemoveFile(pendingDeleteId);
setPendingDeleteId(null);
}
};
// 取消删除
const handleCancelDelete = () => {
setDeleteConfirmOpen(false);
setPendingDeleteId(null);
};
// 上传单个文件(异步上传+轮询) // 上传单个文件(异步上传+轮询)
const uploadSingleFile = async (item: UploadFileItem) => { const uploadSingleFile = async (item: UploadFileItem, overwrite: boolean = false, skipCheck: boolean = false) => {
const remotePath = pathUtils.join(currentPath, item.file.name); const remotePath = pathUtils.join(currentPath, item.file.name);
// 如果不是覆盖模式且未跳过检查,先检查文件是否已存在
if (!overwrite && !skipCheck) {
try {
// 使用browseDirectory API检查文件是否存在
const files = await browseDirectory(serverId, currentPath);
const fileExists = files.some(f => f.name === item.file.name && !f.isDirectory);
if (fileExists) {
// 文件已存在,弹出确认对话框
setPendingOverwriteItem(item);
setConfirmDialogOpen(true);
return; // 暂停上传,等待用户决定
}
} catch (error) {
console.log('检查文件是否存在时出错:', error);
// 检查失败,继续上传
}
}
try { try {
// 1. 提交异步上传任务 // 1. 提交异步上传任务
setUploadFiles(prev => setUploadFiles(prev =>
@ -87,7 +162,7 @@ export const UploadPanel: React.FC<UploadPanelProps> = ({
) )
); );
const result = await submitAsyncUpload(serverId, item.file, remotePath, false); const result = await submitAsyncUpload(serverId, item.file, remotePath, overwrite);
const taskId = result.taskId; const taskId = result.taskId;
// 2. 保存taskId // 2. 保存taskId
@ -101,6 +176,8 @@ export const UploadPanel: React.FC<UploadPanelProps> = ({
startPolling(item.id, taskId); 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
@ -112,6 +189,32 @@ export const UploadPanel: React.FC<UploadPanelProps> = ({
} }
}; };
// 处理用户确认覆盖
const handleConfirmOverwrite = async () => {
setConfirmDialogOpen(false);
if (pendingOverwriteItem) {
// 以覆盖模式重新上传,跳过检查
await uploadSingleFile(pendingOverwriteItem, true, true);
setPendingOverwriteItem(null);
}
};
// 处理用户取消覆盖
const handleCancelOverwrite = () => {
setConfirmDialogOpen(false);
if (pendingOverwriteItem) {
// 标记为错误状态
setUploadFiles(prev =>
prev.map(f =>
f.id === pendingOverwriteItem.id
? { ...f, status: 'error', errorMessage: '用户取消覆盖' }
: f
)
);
setPendingOverwriteItem(null);
}
};
// 轮询查询上传状态 // 轮询查询上传状态
const startPolling = (fileId: string, taskId: string) => { const startPolling = (fileId: string, taskId: string) => {
const pollInterval = setInterval(async () => { const pollInterval = setInterval(async () => {
@ -143,6 +246,8 @@ export const UploadPanel: React.FC<UploadPanelProps> = ({
clearInterval(pollInterval); clearInterval(pollInterval);
pollIntervalsRef.current.delete(fileId); pollIntervalsRef.current.delete(fileId);
const errorMsg = taskInfo.errorMessage || '上传失败'; const errorMsg = taskInfo.errorMessage || '上传失败';
// 直接显示后端返回的错误信息
setUploadFiles(prev => setUploadFiles(prev =>
prev.map(f => prev.map(f =>
f.id === fileId f.id === fileId
@ -150,7 +255,7 @@ export const UploadPanel: React.FC<UploadPanelProps> = ({
: f : f
) )
); );
message.error(`${taskInfo.fileName}: ${errorMsg}`); message.error(`上传失败: ${errorMsg}`);
} else if (taskInfo.status === 'CANCELLED') { } else if (taskInfo.status === 'CANCELLED') {
clearInterval(pollInterval); clearInterval(pollInterval);
pollIntervalsRef.current.delete(fileId); pollIntervalsRef.current.delete(fileId);
@ -181,9 +286,21 @@ export const UploadPanel: React.FC<UploadPanelProps> = ({
} }
}; };
// 清空列表 // 请求清空列表(弹出确认框)
const handleClearList = () => { const handleClearList = () => {
setClearConfirmOpen(true);
};
// 确认清空列表
const handleConfirmClear = () => {
setClearConfirmOpen(false);
setUploadFiles([]); setUploadFiles([]);
message.success('列表已清空');
};
// 取消清空
const handleCancelClear = () => {
setClearConfirmOpen(false);
}; };
// 拖拽事件处理 // 拖拽事件处理
@ -311,16 +428,22 @@ export const UploadPanel: React.FC<UploadPanelProps> = ({
{item.status === 'error' && ( {item.status === 'error' && (
<AlertCircle className="h-4 w-4 text-red-500" /> <AlertCircle className="h-4 w-4 text-red-500" />
)} )}
{item.status === 'pending' && ( {/* 所有状态都显示X按钮 */}
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"
className="h-6 w-6 p-0" className="h-6 w-6 p-0"
onClick={() => removeFile(item.id)} onClick={() => requestRemoveFile(item.id)}
title={
item.status === 'uploading'
? '取消上传'
: item.status === 'pending'
? '删除'
: '删除记录'
}
> >
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</Button> </Button>
)}
</div> </div>
</div> </div>
<div className="flex items-center gap-2 mt-1"> <div className="flex items-center gap-2 mt-1">
@ -353,6 +476,66 @@ export const UploadPanel: React.FC<UploadPanelProps> = ({
</div> </div>
)} )}
</div> </div>
{/* 覆盖确认对话框 */}
<AlertDialog open={confirmDialogOpen} onOpenChange={setConfirmDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
<span className="font-semibold text-foreground">{pendingOverwriteItem?.file.name}</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={handleCancelOverwrite}>
</AlertDialogCancel>
<AlertDialogAction onClick={handleConfirmOverwrite}>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* 删除确认对话框 */}
<AlertDialog open={deleteConfirmOpen} onOpenChange={setDeleteConfirmOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={handleCancelDelete}>
</AlertDialogCancel>
<AlertDialogAction onClick={handleConfirmDelete}>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* 清空列表确认对话框 */}
<AlertDialog open={clearConfirmOpen} onOpenChange={setClearConfirmOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={handleCancelClear}>
</AlertDialogCancel>
<AlertDialogAction onClick={handleConfirmClear}>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div> </div>
); );
}; };

View File

@ -16,9 +16,10 @@ const AlertDialogOverlay = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay <AlertDialogPrimitive.Overlay
className={cn( className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", "fixed inset-0 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className className
)} )}
style={{ zIndex: 1100 }}
{...props} {...props}
ref={ref} ref={ref}
/> />
@ -34,9 +35,10 @@ const AlertDialogContent = React.forwardRef<
<AlertDialogPrimitive.Content <AlertDialogPrimitive.Content
ref={ref} ref={ref}
className={cn( className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg", "fixed left-[50%] top-[50%] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className className
)} )}
style={{ zIndex: 1101 }}
{...props} {...props}
/> />
</AlertDialogPortal> </AlertDialogPortal>