Время прочтения: 10 мин.
Однажды HR предложили мне в качестве тестового задания сделать проводник на вебе. Примерное описание ТЗ содержится в заголовке. Задание меня заинтересовало.
Непродолжительный поиск в гугле ничего похожего не дал. Тем интереснее.
Для работы с файлами нам достаточно стандартных классов:
(System.IO) DriveInfo- Предоставляет информацию о дисках. Так мы узнаем какие диски подключены, их имена, емкость и свободное пространство
(System.IO) DirectoryInfo — Предоставляет информацию о папках. получение информации о вложенных папках и файлах
(System.IO) FileInfo — Предоставляет информацию о файлах. получение размера
Первое с чего предлагаю начать – создадим модельку которая смотрит подключенные диски, чтобы смотреть по каким путям нам вообще ходить, показать какие диски есть и отобразить их размеры:
Для этого в папке Models создал папку FilesModels и в ней DisksModel.cs и вставляем код:
public class DisksModel
{
public decimal TotalFreeSpace { get; }
public decimal TotalSize { get; }
public List<DriveInfo> Disks { get; }
public DisksModel()
{
Disks = DriveInfo.GetDrives().Where(r => r.IsReady).Where(r => r.DriveType == DriveType.Fixed).ToList();
TotalSize = Disks.Sum(r => r.TotalSize);
TotalFreeSpace = Disks.Sum(r => r.TotalFreeSpace);
}
}
В конструкторе мы получаем информацию по имеющимся дискам, выбираем только готовые к работе и не берем флэшки.
Также дополнительно, чтобы возвращать размерность в нужных единицах измерения заодно вставил статичный класс.
public static class DiskMetods
{
public static decimal ToKB(this decimal size, int decimals = 2)
{
return Math.Round(size / 1024, decimals);
}
public static decimal ToMB(this decimal size, int decimals = 2)
{
return Math.Round(size.ToKB(0) / 1024, decimals);
}
public static decimal ToGB(this decimal size, int decimals = 2)
{
return Math.Round(size.ToMB(0) / 1024, decimals);
}
public static decimal ToTB(this decimal size, int decimals = 2)
{
return Math.Round(size.ToGB(0) / 1024, decimals);
}
public static decimal ToKB(this long size, int decimals = 2)
{
return Math.Round((decimal)size / 1024, decimals);
}
public static decimal ToMB(this long size, int decimals = 2)
{
return Math.Round(size.ToKB(0) / 1024, decimals);
}
public static decimal ToGB(this long size, int decimals = 2)
{
return Math.Round(size.ToMB(0) / 1024, decimals);
}
public static decimal ToTB(this long size, int decimals = 2)
{
return Math.Round(size.ToGB(0) / 1024, decimals);
}
}
Создаем контролер для работы с файлами в папке FilesControllers создаем FilesController.cs
Указываем какую страницу открывать и в нем же статично получаем информацию о дисках для дальнейшего использования.
public static DisksModel disk = new DisksModel();
[HttpGet]
public IActionResult Files()
{
return View("~/Views/FilesViews/Files.cshtml");
}
Далее в папке Views создаем папку FilesViews и страницу Files.cshtml.
И создаём там таблицы, в которых будут отображаться информация о дисках на компьютере заодно в прогресс баре указывается сколько места заполнено. И как раз используется информация из статического класса в контроллере и переводим в гигабайты за счет ранее написанных методов.
<div id="diskInfo">
<div id="tableDisk">
<table onclick="VieUnvie()" style="cursor: pointer">
<tr>
<td>
<img src="~/img/hd_disk_harddisk_162.png" style="height:45px;width:45px" />
</td>
<td>
<table>
<tr>
<td>
Все пространство дисков
</td>
</tr>
<tr>
<td>
<progress value="@(WebFileManager.Controllers.FilesController.disk.TotalSize.ToGB(0) - WebFileManager.Controllers.FilesController.disk.TotalFreeSpace.ToGB(0))" max="@WebFileManager.Controllers.FilesController.disk.TotalSize.ToGB(0)"></progress>
</td>
</tr>
<tr>
<td>
@WebFileManager.Controllers.FilesController.disk.TotalFreeSpace.ToGB() ГБ свободно из @WebFileManager.Controllers.FilesController.disk.TotalSize.ToGB() ГБ
</td>
</tr>
</table>
</td>
</tr>
</table>
<table id="allDisk" style="display:none; cursor: pointer">
@{foreach (DriveInfo driveInfo in WebFileManager.Controllers.FilesController.disk.Disks)
{
<tr class="DiskRow" onclick="getFolder(null,'@(driveInfo.Name+"\\")', null, false)">
<td width="45px">
</td>
<td>
<img src="~/img/hd_disk_harddisk_162.png" style="height:45px;width:45px" />
</td>
<td>
<table>
<tr>
<td>
локальный диск (@driveInfo.Name.Replace("\\", ""))
</td>
</tr>
<tr>
<td>
<progress value="@(driveInfo.TotalSize.ToGB(0) - driveInfo.TotalFreeSpace.ToGB(0))" max="@driveInfo.TotalSize.ToGB(0)"></progress>
</td>
</tr>
<tr>
<td>
@driveInfo.TotalFreeSpace.ToGB() ГБ свободно из @driveInfo.TotalSize.ToGB() ГБ
</td>
</tr>
</table>
</td>
</tr>
}
}
</table>
</div>
</div>
Добавим скрипт украшательства ради для сворачивания/разворачивания дисков в/из общего объема, а onclick=»getFolder(null,’@(driveInfo.Name+»\\» встретится чуть позже.
function VieUnvie() {
var table = document.getElementById("allDisk");
if (table.style.display == "none") {
table.style.display = "block";
}
else {
table.style.display = "none";
}
}
Прописываем еще одну кнопку в _Layout.cshtml
<li class="nav-item">
<a class="nav-link text-dark" asp-area="" asp-controller="Files" asp-action="Files">Проводник</a>
</li>
Получим:
Теперь необходимо отобразить размеры файлов и папок. В этом есть проблема, т.к. в DirectoryInfo нет информации о размере, и, как я понял, тот же проводник windows просто каждый раз считает размеры файлов в папке, дальше идет по вложенным папкам и потом возвращает конечную сумму файлов (например, когда вы нажимаете свойство папки, какое-то время идет подсчет размера на ваших глазах и время подсчета зависит в первую очередь от количества файлов внутри). Так же поступим и мы.
Понадобится модель для хранения информации. Предлагаю такую:
public class FolderModel
{
public DirectoryInfo ThisDirectoryInfo { get; set; }
public FileInfo[] Files { get; }
public List<FolderModel> Folders { get; }
public decimal Size { get; } = 0;
public FolderModel() { }
public FolderModel(string path):this(new DirectoryInfo(path)) { }
public FolderModel(DirectoryInfo directoryInfo)
{
ThisDirectoryInfo = directoryInfo;
Files = directoryInfo.GetFiles();
Folders = ThisDirectoryInfo.GetDirectories().Where(r => !r.Attributes.HasFlag(FileAttributes.System) & !r.Attributes.HasFlag(FileAttributes.Hidden)).ToArray().GetFolderModels();
Size += Files.Sum(r => r.Length) + Folders.Sum(r => r.Size);
}
public static List<FolderModel> GetFolderModels()
{
List<FolderModel> folderModels = new List<FolderModel>();
foreach (var el in WebFileManager.Controllers.FilesController.disk.Disks)
{
folderModels.Add(new FolderModel(el.Name));
}
//folderModels.Add(new FolderModel("C:\\"));
return folderModels;
}
}
Мы получили массив файлов в папке с их размерами, рекурсивно ходим по вложенным папкам возвращая их размер (т.е. размер файлов в них), и конечную сумму размеров.
Дополнительные функции, одна из которых понадобится для получения нашей модели по указанному пути:
public static class FolderModelMetods
{
/// <summary>
/// возвращает FolderModel по массиву DirectoryInfo
/// </summary>
/// <param name="directoryInfos"></param>
/// <returns></returns>
public static List<FolderModel> GetFolderModels(this DirectoryInfo[] directoryInfos)
{
List<FolderModel> folderModels = new List<FolderModel>();
foreach(DirectoryInfo directoryInfo in directoryInfos)
{
try
{
folderModels.Add(new FolderModel(directoryInfo));
}
catch
{
}
}
return folderModels;
}
/// <summary>
/// возвращает FolderModel по указанному пути
/// </summary>
/// <param name="folderModels"> откуда ищет</param>
/// <param name="fullPath">путь по которому брать модель</param>
/// <returns></returns>
public static FolderModel GetCurentFolderModel(this List<FolderModel> folderModels, string fullPath)
{
string[] pathEls = fullPath.Split('\\').Where(r=>r!="").ToArray();
FolderModel folderModel = folderModels.FirstOrDefault(r => r.ThisDirectoryInfo.Name.Replace("\\", "") == pathEls[0]);
for (int i = 1; i < pathEls.Length; i++)
{
folderModel = folderModel.Folders.FirstOrDefault(r => r.ThisDirectoryInfo.Name.Trim() == pathEls[i].Trim());
}
return folderModel;
}
}
Здесь стоит трай катч. Это был быстрый способ для обхода ошибок связанных с чтением системных закрытых файлов и папок. Теперь вопрос: как это представить и что выводить на экран. Проводник windows обычно выводит имя, дату изменения, тип и размер файлов, значит и мы выведем такое. Первое, что пришло в голову — написать отдельную модель.
public class FolderViewModel
{
public string CurentPath { get; set; }
public string Name { get; set; }
public string Type { get; set; }
public decimal Size { get; set; }
public DateTime ChangeDate { get; set; }
}
В этой модели мы как раз реализуем все то что хотим увидеть. Ну и заодно сразу добавим методы в статичном классе.
public static class FolderViewModelM
{
public static List<FolderViewModel> GetFolderViewModel(this List<FolderModel> folderModels, string path, string folderName, string SortName, string SortType)
{
List<FolderViewModel> folderViewModels;
if(folderName==null) { folderViewModels = folderModels.GetFolderViewModel((path).Replace("\\\\", "\\")); }
else { folderViewModels = folderModels.GetFolderViewModel((path + "\\" + folderName).Replace("\\\\", "\\")); }
if (SortName != null)
{
switch (SortName)
{
case "Name":
if (SortType == "Asc") { folderViewModels = folderViewModels.OrderBy(r => r.Name).ToList(); }
else { folderViewModels = folderViewModels.OrderByDescending(r => r.Name).ToList(); }
break;
case "Type":
if (SortType == "Asc") { folderViewModels = folderViewModels.OrderBy(r => r.Type).ToList(); }
else { folderViewModels = folderViewModels.OrderByDescending(r => r.Type).ToList(); }
break;
case "Size":
if (SortType == "Asc") { folderViewModels = folderViewModels.OrderBy(r => r.Size).ToList(); }
else { folderViewModels = folderViewModels.OrderByDescending(r => r.Size).ToList(); }
break;
case "ChangeDate":
if (SortType == "Asc") { folderViewModels = folderViewModels.OrderBy(r => r.ChangeDate).ToList(); }
else { folderViewModels = folderViewModels.OrderByDescending(r => r.ChangeDate).ToList(); }
break;
}
}
return folderViewModels;
}
public static List<FolderViewModel> GetFolderViewModel(this List<FolderModel> folderModels, string fullPath)
{
FolderModel folderModel = folderModels.GetCurentFolderModel(fullPath);
return folderModel.ToFolderViewModel();
}
public static List<FolderViewModel> ToFolderViewModel(this FolderModel folderModel)
{
List<FolderViewModel> folderViewModels = new List<FolderViewModel>();
foreach (var folder in folderModel.Folders)
{
folderViewModels.Add(new FolderViewModel()
{
Name = folder.ThisDirectoryInfo.Name,
ChangeDate = folder.ThisDirectoryInfo.LastWriteTime,
Type = "Folder",
Size = folder.Size.ToMB(),
CurentPath = folderModel.ThisDirectoryInfo.FullName
});
}
foreach (var file in folderModel.Files)
{
folderViewModels.Add(new FolderViewModel()
{
Name = file.Name,
ChangeDate = file.LastWriteTime,
Type = "File",
Size = file.Length.ToMB(),
CurentPath = folderModel.ThisDirectoryInfo.FullName
});
}
return folderViewModels;
}
public static string ToJson(this List<FolderViewModel> folderViewModels)
{
return JsonSerializer.Serialize(folderViewModels);
}
}
Мы реализуем перевод в json для передачи на страницу и несколько методов перевода из предыдущей модели в нашу новую. Также в одном из методов реализована сортировка.
Надо придумать представление. Самым простым будет использование все той же таблицы. Сначала в наше ранее используемое представление (в то, где мы рисовали диски) записываем:
@using WebFileManager.Models.FilesModels;
@using System.IO;
Указываем, какую используем модель и дополнительно пространство. Теперь сделаем саму таблицу.
<div id="FileBrowser" style="width:100%; height:auto">
<div id="PanelPath" style="width:100%">
<div id="PanelPathButtons">
<button id="left" onclick="Back()"><img src="~/img/left.png" width="20px" height="10px" /></button>
<button id="right" onclick="Next()"><img src="~/img/right.png" width="20px" height="10px" /></button>
</div>
<div id="PathNow">@WebFileManager.Controllers.FilesController.folderModels[0].ThisDirectoryInfo.FullName</div>
</div>
<div id="FileManager">
<div id="PathView" class="FileManager" style="width:30%;">
fff
</div>
<div id="FilesView" class="FileManager" style="width: 70%; ">
<table id="FilesAndFolders">
<tr>
<th onclick="Sort(this)" style="cursor: pointer"> Name </th>
<th onclick="Sort(this)" style="cursor: pointer">Change date</th>
<th onclick="Sort(this)" style="cursor: pointer"> Type </th>
<th onclick="Sort(this)" style="cursor: pointer"> Size </th>
</tr>
@{
foreach (var folder in WebFileManager.Controllers.FilesController.folderModels[0].Folders)
{
<tr class="FileFolderRow">
<td>
<img src="~/img/folder.png" /> @folder.ThisDirectoryInfo.Name
</td>
<td>
@folder.ThisDirectoryInfo.LastWriteTime
</td>
<td>
Folder
</td>
<td>
@folder.Size.ToMB() MB
</td>
</tr>
}
foreach (var file in WebFileManager.Controllers.FilesController.folderModels[0].Files)
{
<tr class="FileFolderRow">
<td>
<img src="~/img/file.png" />@file.Name
</td>
<td>
File
</td>
<td>
@file.Length.ToMB() MB
</td>
</tr>
}
}
<tr>
<td></td>
</tr>
</table>
</div>
</div>
</div>
При открытии страницы сначала показываем содержимое первого диска из списка. Также делаем кнопки назад и вперед, место, где указываем путь.
Также функции:
function Back() {
getFolder(null, $('#PathNow')[0].innerText, null, true);
$("#right").prop("disabled", false)
}
Соответственно возврат назад:
function getFolder(FN, PN, Sor, BK) {
$.ajax({
url: '@Url.Action("NewFolder", "Files")',
data:{
folderName: FN
, pathNow: PN
, orderBy: Sor
, back:BK
}
, type: 'POST'
, success: function (data) {
$(".FileFolderRow").remove();
GetFileFolderRow(data);
pathNow: $('#PathNow')[0].innerText = data[0]["CurentPath"];
setSes("maxPath", data[0]["CurentPath"]);
}
})
}
Получение представления по указанному пути — как раз та функция, которая встречалась еще в отображении дисков на странице.
function GetFileFolderRow(data) {
for (var i = 0; i < data.length; i++) {
let tr = document.createElement("tr"),
td0 = document.createElement("td"),
td1 = document.createElement("td"),
td2 = document.createElement("td"),
td3 = document.createElement("td"),
img = document.createElement("img");
if (data[i]["Type"] == "File") { img.src = "/img/file.png" }
else { img.src = "/img/folder.png" }
td0.appendChild(img);
td0.appendChild(document.createTextNode(data[i]["Name"]));
td1.innerText = data[i]["ChangeDate"];
td2.innerText = data[i]["Type"];
td3.innerText = data[i]["Size"] + " MB";
tr.className = "FileFolderRow";
tr.append(td0);
tr.append(td1);
tr.append(td2);
tr.append(td3);
$("#FilesAndFolders").append(tr);
}
$(dbClickRow())
}
Записываем в таблицу полученную модель.
И функция для нажатия на строку в таблице. Если папка, то пытается пройти дальше, а на файл просто отвечает, что не может его открыть.
function dbClickRow () {
$(".FileFolderRow").dblclick(function (e) {
var newFolder = this.getElementsByTagName("td")[0].innerText.trim();
if (this.getElementsByTagName("td")[2].innerText == "Folder") {
getFolder(newFolder, $('#PathNow')[0].innerText, null, false)
}
else {
alert("i can't open files ");
}
})
Добавляем совсем короткий скрипт для сортировки, который по сути так же делает запрос на получение страницы и заодно передает по какому полю сортировать.
function Sort(e) {
getFolder(null, $('#PathNow')[0].innerText, e.innerText, false);
}
Вспомним про метод, возвращающей модель для представления с сортировкой. Он определяет, по какому полю сортировать. Соответственно где-то надо хранить для пользователей их значения. Самым очевидным способом является сессия. Поэтому в startup.cs добавляем services.AddMvc() и app.UseSession(), выглядит примерно так:
Осталось реализовать в контролере как будем возвращать страницы.
В fileController добавляем:
public static List<FolderModel> folderModels = FolderModel.GetFolderModels();
Сам код:
[HttpPost]
public ContentResult NewFolder(string folderName, string pathNow, string orderBy=null, bool back=false)
{
if (orderBy != null)
{
if (HttpContext.Session.GetString("OrderBy") == orderBy)
{
if (HttpContext.Session.GetString("SortType") == "Asc") { HttpContext.Session.SetString("SortType", "Desc"); }
else { HttpContext.Session.SetString("SortType", "Asc"); }
}
else
{
HttpContext.Session.SetString("OrderBy", orderBy);
HttpContext.Session.SetString("SortType", "Asc");
}
}
return Content(folderModels.GetFolderViewModel(!back ? pathNow: pathNow.Substring(0, pathNow.LastIndexOf("\\")), folderName, HttpContext.Session.GetString("OrderBy"), HttpContext.Session.GetString("SortType")).ToJson(), "application/json");
}
Если запрос приходит с указанием сортировки, то пишем в сессию по какому полю, а если такое поле уже указанно, то меняем порядок и отправляем модель.
Надеюсь, кому-то пригодится данный материал, также каждый желающий может аргументированно провести автора лицом по коду.
Весь проект лежит тут github.
Спасибо за внимание.