众所周知,JavaScript在浏览器环境中出于安全考虑,不允许直接访问用户的本地文件系统,包括打开本地文件资源管理器。这种限制是为了防止潜在的安全威胁,比如恶意网站试图获取用户的敏感文件信息。
因此,在纯web端中不能通过js调用文件资源管理器的方式来实现可视化FTP文件夹,经发散思路,可通过编写组件,前后端结合的方式来实现。
下面先简单了解下什么是FTP:
FTP(文件传输协议)是一种用于在网络上传输文件的标准协议。它允许用户在不同计算机系统之间传输文件,通常用于上传文件到服务器或从服务器下载文件到本地计算机。FTP通过客户端-服务器模式工作,客户端向服务器发送请求以执行文件操作,如上传、下载、删除或重命名文件等。
FTP的常用方法,可参考之前写的一篇文章:FTP客户端操作指南-CSDN博客,新的方法会在本篇文章的末尾进行补充。
话不多说,先看效果图:
下面开始编写FolderShown组件:
1、在appsettings.json文件中配置FTP的连接属性
"NasFtp": {"Address": "ftp://xx.xx.xx.xx", //NasFTP地址"IP": "xx.xx.xx.xx", //IP地址"Port": 2121, //端口"LoginName": "admin", //登录名"Password": "123" //登录密码
},
写个对应的实体类,用来读取配置绑定到对象中。
/// <summary>
/// NasFtp配置的实体类
/// </summary>
public class NasFtp
{/// <summary>/// Ftp地址/// </summary>public string Address { get; set; }/// <summary>/// IP地址/// </summary>public string IP { get; set; }/// <summary>/// 端口/// </summary>public string Port { get; set; }/// <summary>/// 登录名/// </summary>public string LoginName { get; set; }/// <summary>/// 密码/// </summary>public string Password { get; set; }
}
通过下面的IConfiguration扩展类中的方法来读取FTP配置。
public class ConfigurationExtension
{private static IConfiguration Configuration { get; set; }public ConfigurationExtension(IConfiguration configuration){Configuration = configuration;}/// <summary>/// 获取配置文件 /// </summary>/// <param name="key">eg: WeChat:Token</param>/// <returns></returns>public static string GetConfig(string key){return Configuration[key];}/// <summary>/// 获取配置节点并转换成指定类型/// </summary>/// <typeparam name="T">节点类型</typeparam>/// <param name="key">节点路径</param>/// <returns>节点类型实例</returns>public static T Get<T>(string key){return Configuration.GetSection(key).Get<T>();}
}
2、 写一个页面内容的实体类
public class FolderModel
{public FolderModel(string name, bool isFolder){Name = name;IsFolder = isFolder;}/// <summary>/// 名称/// </summary>public string Name { get; set; }/// <summary>/// true-文件夹;false-文件/// </summary>public bool IsFolder { get; set; }/// <summary>/// 类型/// </summary>public string Type { get { return IsFolder ? "文件夹" : (Path.GetExtension(Name) + "文件"); } }/// <summary>/// 双击文件夹时,用来控制显示转圈圈/// </summary>public bool HideLoading { get; set; } = true;
}
3、编写FolderShown.razor页面
注意:进入子文件夹时绑定的是双击事件ondblclick(这样也更符合文件资源管理器中的操作)。因为在创建一个FTP请求的CreateRequest方法中设置的KeepAlive属性为false(命令执行完毕之后关闭连接),关键是关闭上一个命令的连接需要一点点时间(1s左右)。如果用onclick单击事件,第二次点击(连点两次)过快会报连接错误,当然KeepAlive属性为true能解决这个,但会带来连接等资源上的问题(由GC回收,会有延时),需谨慎处理。
@if (IsLoading)
{<SkeletonTree />
}
else
{<div class="d-flex flex-column folder-shown-div"><div class="d-flex path-text-div"><div class="back-div"><Button Size="Size.ExtraSmall" Color="Color.Primary" IsAsync="true" OnClick="@OnClickToBack">返回</Button></div><div class="display-div"><Display @bind-Value="@ShowPath"></Display></div></div><div class="d-flex operation-div"><div class="add-folder-div" style="margin-right:16px;"><PopConfirmButton Placement="Placement.Bottom"ConfirmIcon="fa-solid fa-triangle-exclamation text-info"Color="Color.Primary"Size="Size.Small"ConfirmButtonColor="Color.Info"Text="新建文件夹" OnConfirm="() => OnClickToAddFolder()"><BodyTemplate><div class="d-flex align-items-center"><span class="me-2">名称: </span><BootstrapInput TValue="string" @bind-Value="@NewFolderName" style="width:11rem;" /></div></BodyTemplate></PopConfirmButton></div><div class="upload-div" style="margin-right:16px;"><ButtonUpload TValue="string" BrowserButtonText="上传文件" ShowProgress="true" Size="Size.Small" ShowUploadFileList="false" OnChange="@OnClickToUpload"></ButtonUpload></div></div><div class="path-content-div">@if(FolderModelList == null || FolderModelList.Count <= 0){<div>此文件夹为空</div>}else{@for(int i = 0; i < FolderModelList.Count; i++){FolderModel item = FolderModelList[i]; <div class="d-flex item-div" style="background-color:@(i % 2 == 0 ? "#F4F6FA" : "#fff");" @ondblclick="() => OnClickInFolder(item)"><div class="d-flex item-name-div"><div style="margin-right:5px;display:@(item.HideLoading ? "none" : "")"><Spinner Size="Size.Small"></Spinner></div>@if (item.IsFolder){<i class="fa-solid fa-folder"></i>}else{<i class="fa-solid fa-file"></i>}@item.Name</div><div class="item-type-div"><span>@item.Type</span></div><div class="d-flex downlaod-div">@if(item.IsFolder == false){<Button TooltipText="下载文件" TooltipPlacement="Placement.Bottom" TooltipTrigger="hover" ButtonStyle="ButtonStyle.Circle" Size="Size.Small" IsAsync="true" IsBlock="true" Icon="fa-solid fa-file-arrow-down" Color="Color.None" OnClick="async () => await OnClickToDownload(item)"></Button>}</div></div>}}</div></div>
}
4、编写FolderShown.razor.cs代码
public partial class FolderShown
{//弹窗提示用,替换为自己的[Inject][NotNull]private MessageBox? _MsgBox { get; set; }//bootstrap blazor的下载服务[Inject][NotNull]private DownloadService _downloadService { get; set; }//进来的默认路径,用于展示时隔离根目录下的文件夹[Parameter][NotNull]public string FolderPath { get; set; }//进来的根目录下的文件夹名称[Parameter][NotNull]public string RemotePath { get; set; }//是否FTP路径[Parameter][NotNull]public bool IsFtp { get; set; }private bool IsLoading { get; set; } = true;//展示的路径private string ShowPath { get; set; }//路径下的所有文件和文件夹private List<FolderModel> FolderModelList { get; set; } = new List<FolderModel>();//Ftp连接对象private FtpClient FtpClient { get; set; } = null;//新建文件夹的名称private string NewFolderName { get; set; }protected override async Task OnAfterRenderAsync(bool firstRender){try{await base.OnAfterRenderAsync(firstRender);if (firstRender){//非FTP的待实现if (IsFtp){if (FtpClient == null){//读取NasFtp配置NasFtp nasFtp = ConfigurationExtension.Get<NasFtp>("NasFtp");//连接FTPFtpClient = new FtpClient(nasFtp.IP, nasFtp.LoginName, nasFtp.Password, nasFtp.Port);}//读取传进来的路径下的文件和文件夹OnLoadDataFromFtp();}IsLoading = false;ShowPath = FolderPath;await InvokeAsync(StateHasChanged);}}catch (Exception ex){_MsgBox.Show(ex.Message, AlertTypes.Error);}finally{IsLoading = false;}}private void OnLoadDataFromFtp(){if (string.IsNullOrWhiteSpace(FolderPath)){return;}//根目录文件夹不存在,则自动创建一个,防止报错FtpClient.MakeDirectoryIfNotExit(RemotePath);GetFolderModelList(RemotePath);}//获取文件和文件夹,并转化为FolderModel实体,和排序private void GetFolderModelList(string remotePath = "/"){FtpClient.RemotePath = remotePath;List<string> detailList = FtpClient.GetFileList("");FolderModelList = detailList.Where(d => d != "#recycle").Select(d =>{bool isFolder = string.IsNullOrWhiteSpace(Path.GetExtension(d));return new FolderModel(d, isFolder);}).OrderByDescending(t => t.IsFolder).ThenBy(t => t.Name).ToList();}/// <summary>/// 进入文件夹/// </summary>/// <param name="item"></param>/// <returns></returns>private void OnClickInFolder(FolderModel item){try{if (item.IsFolder){item.HideLoading = false;StateHasChanged();//使用后台Thread,不然双击时的加载转圈圈不会出来Thread thread = new Thread(() =>{InvokeAsync(() =>{try{string remotePath = ShowPath.Replace(FolderPath, "") + "/" + RemotePath + "/" + item.Name;GetFolderModelList(remotePath);ShowPath = ShowPath.TrimEnd('/') + "/" + item.Name;item.HideLoading = true;StateHasChanged();}catch (Exception ex){_MsgBox.Show(ex.Message, AlertTypes.Error);}});});thread.IsBackground = true;thread.Start();}}catch (Exception ex){_MsgBox.Show(ex.Message, AlertTypes.Error);}}/// <summary>/// 返回上一级,并控制不能返回到根目录下/// </summary>/// <returns></returns>private async Task OnClickToBack(){try{var ss = ShowPath.Replace(FolderPath, "").TrimStart('/').Split('/').ToList();ss.RemoveAt(ss.Count - 1);string remotePath = "/" + string.Join("/", ss);GetFolderModelList("/" + RemotePath + remotePath);ShowPath = FolderPath + remotePath;await InvokeAsync(StateHasChanged);}catch (Exception ex){_MsgBox.Show(ex.Message, AlertTypes.Error);}}/// <summary>/// 在当前路径下新建文件夹/// </summary>/// <returns></returns>private Task OnClickToAddFolder(){try{if (string.IsNullOrWhiteSpace(NewFolderName)){_MsgBox.Show("文件夹名称为空!", "", AlertTypes.Error, 2000);return Task.CompletedTask;}char[] specialChars = new char[] { '\\', '/', ':', '?', '"', '<', '>', '|' };var nameChars = NewFolderName.ToCharArray();if (nameChars.Intersect(specialChars).Count() > 0){_MsgBox.Show(@"文件夹名称不能包含下列任何字符:\/:*?<>|", "", AlertTypes.Error, 2000);return Task.CompletedTask;}FtpClient.MakeDirectoryIfNotExit(NewFolderName);GetFolderModelList(RemotePath + "/" + ShowPath.Replace(FolderPath, ""));StateHasChanged();_MsgBox.Show($"新建文件夹【{NewFolderName}】成功!", "", AlertTypes.Success, 2000);}catch (Exception ex){_MsgBox.Show(ex.Message, AlertTypes.Error);}return Task.CompletedTask;}/// <summary>/// 通过流上传单个文件/// </summary>/// <returns></returns>private async Task OnClickToUpload(UploadFile file){try{using var stream = new MemoryStream();await file.File.OpenReadStream(file.File.Size).CopyToAsync(stream);stream.Position = 0;FtpClient.UploadStream(ShowPath, stream, file.GetFileName());GetFolderModelList(RemotePath + "/" + ShowPath.Replace(FolderPath, ""));await InvokeAsync(StateHasChanged);_MsgBox.Show($"文件【{file.GetFileName()}】上传成功!", "", AlertTypes.Success, 2000);}catch (Exception ex){_MsgBox.Show(ex.Message, AlertTypes.Error);}}/// <summary>/// 通过流下载单个文件,后台下载/// </summary>/// <returns></returns>private async Task OnClickToDownload(FolderModel item){try{await Task.Delay(1);var thread = new Thread(async () => await DownloadFile(item));thread.IsBackground = true;thread.Start();}catch (Exception ex){_MsgBox.Show(ex.Message, AlertTypes.Error);}}/// <summary>/// 下载单个文件/// </summary>/// <param name="item"></param>/// <returns></returns>private async Task DownloadFile(FolderModel item){try{//FTP先获取文件的字节流,在转为内存流byte[] bytes = FtpClient.DownloadStream(ShowPath, item.Name);using Stream stream = new MemoryStream(bytes);stream.Position = 0;DownloadOption downloadOption = new DownloadOption();downloadOption.FileName = item.Name;downloadOption.FileStream = stream;await _downloadService.DownloadFromStreamAsync(downloadOption);_MsgBox.Show($"下载【{item.Name}】文件成功!", "", AlertTypes.Success, 2000);}catch (Exception ex){_MsgBox.Show(ex.Message, AlertTypes.Error);}}
}
5、编写FolderShown.razor.css样式
.folder-shown-div {position: relative;width: 100%;height: 600px;
}.path-text-div {width: 100%;align-items: center;
}.back-div {width: 50px;
}.display-div {width: calc(100% - 50px);
}.operation-div {margin-top: 5px;
}.path-content-div {width: 100%;height: calc(100% - 80px);padding: 5px;margin-top: 5px;overflow-y: auto;
}.item-div {align-items: center;justify-content: flex-start;margin-bottom: 0.5rem;height: 24px;
}.item-name-div {align-items: center;width: 300px;
}.item-name-div i {margin-right: 0.5rem;}.item-type-div {width: 70px;
}.downlaod-div {width: 30px;padding-left: 5px;justify-content: center;
}
6、FolderShown组件的调用
[Inject]
[NotNull]
private DialogService _DialogService { get; set; }//点击按钮打开弹窗组件
private async Task ClickToOpenFtp()
{string ftpUrl = ConfigurationExtension.GetConfig("NasFtp:Address");await _DialogService.Show(new DialogOption(){Title = $"文件浏览",ShowCloseButton = false,Size = Size.Medium,BodyTemplate = BootstrapDynamicComponent.CreateComponent<FolderShown>(new Dictionary<string, object?>{[nameof(FolderShown.FolderPath)] = ftpUrl + "/" + name,[nameof(FolderShown.RemotePath)] = name,[nameof(FolderShown.IsFtp)] = true}).Render()});
}
7、FTP方法补充
下面对FtpClient类的使用做些补充说明:
a、FtpClient初始化时,有个【是否允许SSL】的形参enableSsl,如果有使用SSL加密则需传true。
ServicePointManager.ServerCertificateValidationCallback +=
(sender, cert, chain, sslPolicyErrors) => true;
同时通过上面一行代码设置跳过证书验证,不然整个功能都用不了。
#region 构造函数/// <summary>/// 创建FTP工具/// <para>/// 默认不使用SSL,使用二进制传输方式,使用被动模式。FTP有两种使用模式:主动和被动。/// 主动模式要求客户端和服务器端同时打开并且监听一个端口以建立连接。/// 在这种情况下,客户端由于安装了防火墙会产生一些问题。/// 所以,创立了被动模式。/// 被动模式只要求服务器端产生一个监听相应端口的进程,这样就可以绕过客户端安装了防火墙的问题。/// </para>/// </summary>/// <param name="host">主机名称</param>/// <param name="userId">用户名</param>/// <param name="password">密码</param>public FtpClient(string host, string userId, string password): this(host, userId, password, 2121, null, true, true, true){ServicePointManager.ServerCertificateValidationCallback +=
(sender, cert, chain, sslPolicyErrors) => true;}/// <summary>/// 创建FTP工具/// </summary>/// <param name="host">主机名称</param>/// <param name="userId">用户名</param>/// <param name="password">密码</param>/// <param name="port">端口</param>/// <param name="enableSsl">允许Ssl</param>/// <param name="proxy">代理</param>/// <param name="useBinary">允许二进制</param>/// <param name="usePassive">允许被动模式</param>public FtpClient(string host, string userId, string password, int port, IWebProxy proxy, bool enableSsl, bool useBinary, bool usePassive){this.userId = userId;this.password = password;if (host.ToLower().StartsWith("ftp://")){this.host = host;}else{this.host = "ftp://" + host;}this.port = port;this.proxy = proxy;this.enableSsl = enableSsl;this.useBinary = useBinary;this.usePassive = usePassive;ServicePointManager.ServerCertificateValidationCallback +=
(sender, cert, chain, sslPolicyErrors) => true;}#endregion
b、FTP上传文件流
/// <summary>
/// 上传文件流
/// </summary>
/// <param name="path"></param>
/// <param name="stream"></param>
/// <param name="remoteFileName"></param>
public void UploadStream(string path, Stream stream, string remoteFileName)
{string url = path + "/" + remoteFileName;byte[] buffer = new byte[stream.Length];int count = stream.Read(buffer, 0, buffer.Length);FtpWebRequest request = CreateRequest(url, WebRequestMethods.Ftp.UploadFile);using Stream rs = request.GetRequestStream();rs.Write(buffer, 0, count);
}
c、FTP下载文件字节流
注意:不能直接return response.GetResponseStream();这个流在外部无法访问到,需要先转化为字节流,再转为Stream流。
/// <summary>
/// 下载文件字节流
/// </summary>
/// <param name="serverPath"></param>
/// <param name="serverName"></param>
/// <param name="byteLength"></param>
/// <returns></returns>
public byte[] DownloadStream(string serverPath, string serverName)
{string url = serverPath + "/" + serverName;FtpWebRequest request = CreateRequest(url, WebRequestMethods.Ftp.DownloadFile);request.ContentOffset = 0;using (FtpWebResponse response = (FtpWebResponse)request.GetResponse()){byte[] buffer = new byte[response.ContentLength];//不能直接return response.GetResponseStream();这个流在外部无法访问,需转化为字节流,再转为stream流using Stream responseStream = response.GetResponseStream();int count = responseStream.Read(buffer, 0, buffer.Length);return buffer;}
}
d、注意获取获取文件和文件夹的编码,用System.Text.Encoding.UTF8。
8、扩展
删除、重命名等功能可按需自行添加 ,放到行操作里面(最后面加图标按钮)即可。