重写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 { Input } from '@/components/ui/input';
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 { browseDirectory, createDirectory, removeFile, pathUtils, type RemoteFileInfo } from '@/services/fileService';
import { message } from 'antd';
@ -20,6 +30,8 @@ export const ServerFilePanel: React.FC<ServerFilePanelProps> = ({
const [loading, setLoading] = useState(false);
const [showNewFolder, setShowNewFolder] = useState(false);
const [newFolderName, setNewFolderName] = useState('');
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [pendingDeleteFile, setPendingDeleteFile] = useState<RemoteFileInfo | null>(null);
// 加载文件列表
const loadFiles = async () => {
@ -81,24 +93,34 @@ export const ServerFilePanel: React.FC<ServerFilePanelProps> = ({
}
};
// 删除文件/目录
const handleDelete = async (file: RemoteFileInfo) => {
const confirmed = window.confirm(
file.isDirectory
? `确定删除目录 "${file.name}" 及其所有内容吗?`
: `确定删除文件 "${file.name}" 吗?`
);
if (!confirmed) return;
// 请求删除文件/目录(弹出确认框)
const handleDelete = (file: RemoteFileInfo) => {
setPendingDeleteFile(file);
setDeleteConfirmOpen(true);
};
// 确认删除
const handleConfirmDelete = async () => {
setDeleteConfirmOpen(false);
if (!pendingDeleteFile) return;
try {
await removeFile(serverId, file.path, file.isDirectory);
await removeFile(serverId, pendingDeleteFile.path, pendingDeleteFile.isDirectory);
message.success('删除成功');
loadFiles();
} catch (error: any) {
message.error(error.response?.data?.message || '删除失败');
} finally {
setPendingDeleteFile(null);
}
};
// 取消删除
const handleCancelDelete = () => {
setDeleteConfirmOpen(false);
setPendingDeleteFile(null);
};
// 面包屑导航
const breadcrumbs = currentPath.split('/').filter(Boolean);
@ -299,6 +321,36 @@ export const ServerFilePanel: React.FC<ServerFilePanelProps> = ({
)}
</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>
);
};

View File

@ -4,8 +4,18 @@
import React, { useState, useRef, useEffect } from 'react';
import { Button } from '@/components/ui/button';
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 { submitAsyncUpload, getUploadTaskStatus, cancelUploadTask, pathUtils } from '@/services/fileService';
import { submitAsyncUpload, getUploadTaskStatus, cancelUploadTask, pathUtils, browseDirectory } from '@/services/fileService';
import type { UploadTaskStatus } from '@/services/fileService';
import { message } from 'antd';
import type { UploadPanelProps } from './types';
@ -27,6 +37,11 @@ export const UploadPanel: React.FC<UploadPanelProps> = ({
}) => {
const [uploadFiles, setUploadFiles] = useState<UploadFileItem[]>([]);
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 pollIntervalsRef = useRef<Map<string, NodeJS.Timeout>>(new Map()); // 保存所有轮询定时器
@ -61,22 +76,82 @@ export const UploadPanel: React.FC<UploadPanelProps> = ({
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);
if (interval) {
clearInterval(interval);
pollIntervalsRef.current.delete(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);
// 如果不是覆盖模式且未跳过检查,先检查文件是否已存在
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 {
// 1. 提交异步上传任务
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;
// 2. 保存taskId
@ -101,6 +176,8 @@ export const UploadPanel: React.FC<UploadPanelProps> = ({
startPolling(item.id, taskId);
} catch (error: any) {
const errorMsg = error.response?.data?.message || '提交上传任务失败';
// 直接显示后端返回的错误信息
setUploadFiles(prev =>
prev.map(f =>
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 pollInterval = setInterval(async () => {
@ -143,6 +246,8 @@ export const UploadPanel: React.FC<UploadPanelProps> = ({
clearInterval(pollInterval);
pollIntervalsRef.current.delete(fileId);
const errorMsg = taskInfo.errorMessage || '上传失败';
// 直接显示后端返回的错误信息
setUploadFiles(prev =>
prev.map(f =>
f.id === fileId
@ -150,7 +255,7 @@ export const UploadPanel: React.FC<UploadPanelProps> = ({
: f
)
);
message.error(`${taskInfo.fileName}: ${errorMsg}`);
message.error(`上传失败: ${errorMsg}`);
} else if (taskInfo.status === 'CANCELLED') {
clearInterval(pollInterval);
pollIntervalsRef.current.delete(fileId);
@ -181,9 +286,21 @@ export const UploadPanel: React.FC<UploadPanelProps> = ({
}
};
// 清空列表
// 请求清空列表(弹出确认框)
const handleClearList = () => {
setClearConfirmOpen(true);
};
// 确认清空列表
const handleConfirmClear = () => {
setClearConfirmOpen(false);
setUploadFiles([]);
message.success('列表已清空');
};
// 取消清空
const handleCancelClear = () => {
setClearConfirmOpen(false);
};
// 拖拽事件处理
@ -311,16 +428,22 @@ export const UploadPanel: React.FC<UploadPanelProps> = ({
{item.status === 'error' && (
<AlertCircle className="h-4 w-4 text-red-500" />
)}
{item.status === 'pending' && (
{/* 所有状态都显示X按钮 */}
<Button
size="sm"
variant="ghost"
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" />
</Button>
)}
</div>
</div>
<div className="flex items-center gap-2 mt-1">
@ -353,6 +476,66 @@ export const UploadPanel: React.FC<UploadPanelProps> = ({
</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>
);
};

View File

@ -16,9 +16,10 @@ const AlertDialogOverlay = React.forwardRef<
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
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
)}
style={{ zIndex: 1100 }}
{...props}
ref={ref}
/>
@ -34,9 +35,10 @@ const AlertDialogContent = React.forwardRef<
<AlertDialogPrimitive.Content
ref={ref}
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
)}
style={{ zIndex: 1101 }}
{...props}
/>
</AlertDialogPortal>