547 lines
19 KiB
C#
547 lines
19 KiB
C#
using System.Collections.ObjectModel;
|
||
using System.ComponentModel;
|
||
using System.IO;
|
||
using System.Runtime.CompilerServices;
|
||
using System.Windows.Input;
|
||
using Melskin.Utilities;
|
||
using Microsoft.Win32;
|
||
|
||
|
||
namespace Melskin.Controls;
|
||
|
||
/// <summary>
|
||
/// 上传状态枚举,用于表示文件上传过程中的不同状态。该枚举包含三个可能的值:Pending(待处理)、Success(成功)和Error(错误)。
|
||
/// Pending 表示文件正在等待被上传;Success 表示文件已经成功上传;而 Error 则表示在尝试上传文件时发生了错误。
|
||
/// </summary>
|
||
public enum UploadStatus
|
||
{
|
||
/// <summary>
|
||
/// 表示文件或文件夹正处于待上传状态。当此枚举成员被设置时,意味着相关联的文件或文件夹已经准备好但还未开始实际的上传过程。
|
||
/// </summary>
|
||
Pending,
|
||
|
||
/// <summary>
|
||
/// 表示文件已成功上传。当此枚举成员被设置时,意味着所选文件或文件夹已经顺利完成上传过程。
|
||
/// </summary>
|
||
Success,
|
||
|
||
/// <summary>
|
||
/// 表示在尝试上传文件时发生了错误。当此枚举成员被设置时,表示文件上传过程中遇到了问题,未能成功完成。
|
||
/// </summary>
|
||
Error
|
||
}
|
||
|
||
/// <summary>
|
||
/// 上传文件项类,用于表示单个待上传的文件。此类实现了INotifyPropertyChanged接口,以便在属性更改时通知UI进行更新。
|
||
/// 每个实例包含文件路径、文件名及当前上传状态等信息。通过构造函数初始化时,会自动设置文件名,并将上传状态设为Pending(待处理)。
|
||
/// </summary>
|
||
public class UploadFileItem : INotifyPropertyChanged
|
||
{
|
||
private string? filePath;
|
||
|
||
/// <summary>
|
||
/// 获取或设置当前文件项的完整路径。
|
||
/// </summary>
|
||
public string? FilePath
|
||
{
|
||
get => filePath;
|
||
set => SetField(ref filePath, value);
|
||
}
|
||
|
||
private string? fileName;
|
||
|
||
/// <summary>
|
||
/// 获取或设置当前文件项的文件名。
|
||
/// </summary>
|
||
public string? FileName
|
||
{
|
||
get => fileName;
|
||
set => SetField(ref fileName, value);
|
||
}
|
||
|
||
private UploadStatus status;
|
||
|
||
/// <summary>
|
||
/// 获取或设置当前文件项的上传状态。状态可以是Pending(待处理)、Success(成功)或Error(错误)。
|
||
/// </summary>
|
||
public UploadStatus Status
|
||
{
|
||
get => status;
|
||
set => SetField(ref status, value);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 上传文件项类,用于表示单个待上传的文件。此类实现了INotifyPropertyChanged接口,以便在属性更改时通知UI进行更新。
|
||
/// 每个实例包含文件路径、文件名及当前上传状态等信息。通过构造函数初始化时,会自动设置文件名,并将上传状态设为Pending(待处理)。
|
||
/// </summary>
|
||
public UploadFileItem(string? filePath)
|
||
{
|
||
FilePath = filePath;
|
||
FileName = Path.GetFileName(filePath);
|
||
Status = UploadStatus.Pending;
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
public event PropertyChangedEventHandler? PropertyChanged;
|
||
|
||
/// <summary>
|
||
/// 当属性值更改时调用此方法,以通知UI进行相应的更新。
|
||
/// 该方法会触发PropertyChanged事件,并传递更改的属性名称作为参数。
|
||
/// </summary>
|
||
/// <param name="propertyName">可选参数,指定触发属性更改事件的属性名。如果未提供,则默认使用调用者成员名称。</param>
|
||
protected void OnPropertyChanged([CallerMemberName] string? propertyName = null)
|
||
{
|
||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
|
||
}
|
||
|
||
/// <summary>
|
||
/// 设置指定字段的值,并在值更改时触发属性更改通知。
|
||
/// 如果新值与旧值相同,则不会进行任何操作也不会触发通知。
|
||
/// </summary>
|
||
/// <typeparam name="T">字段的数据类型。</typeparam>
|
||
/// <param name="field">要设置值的字段。</param>
|
||
/// <param name="value">要赋予字段的新值。</param>
|
||
/// <param name="propertyName">可选参数,指定触发属性更改事件的属性名。如果未提供,则默认使用调用者成员名称。</param>
|
||
/// <returns>如果值被成功设置且不等于旧值返回true;否则返回false。</returns>
|
||
protected bool SetField<T>(ref T field, T value, [CallerMemberName] string? propertyName = null)
|
||
{
|
||
if (EqualityComparer<T>.Default.Equals(field, value)) return false;
|
||
field = value;
|
||
if (propertyName != null) OnPropertyChanged(propertyName);
|
||
return true;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 上传区域控件,用于实现文件或文件夹的拖拽上传功能。支持设置可接受的上传内容类型(仅文件或仅文件夹)、是否允许多选、文件过滤器以及主提示文字等属性。
|
||
/// 控件还提供了对拖放事件的支持,允许用户通过拖拽文件到该区域内来完成上传操作,并且能够显示已选择的文件列表。
|
||
/// </summary>
|
||
[TemplateVisualState(Name = "Normal", GroupName = "CommonStates")]
|
||
[TemplateVisualState(Name = "Disabled", GroupName = "CommonStates")]
|
||
[TemplateVisualState(Name = "DragOver", GroupName = "DragDropStates")]
|
||
[TemplateVisualState(Name = "DragLeave", GroupName = "DragDropStates")]
|
||
public class UploadArea : Control
|
||
{
|
||
private bool isDragOver;
|
||
|
||
#region Commands
|
||
|
||
/// <summary>
|
||
/// 定义一个命令,用于从文件列表中移除指定的文件项。
|
||
/// </summary>
|
||
public ICommand RemoveItemCommand { get; }
|
||
|
||
/// <summary>
|
||
/// 获取用于触发选择文件或文件夹操作的命令。此命令绑定到控件,允许用户通过点击来选择文件或文件夹。
|
||
/// </summary>
|
||
public ICommand SelectCommand { get; }
|
||
#endregion
|
||
|
||
static UploadArea()
|
||
{
|
||
DefaultStyleKeyProperty.OverrideMetadata(typeof(UploadArea), new FrameworkPropertyMetadata(typeof(UploadArea)));
|
||
}
|
||
|
||
/// <summary>
|
||
/// 上传区域控件,允许用户通过点击或拖拽方式上传文件或文件夹。支持设置接受的文件类型、是否允许多选以及提示文本等属性。
|
||
/// </summary>
|
||
public UploadArea()
|
||
{
|
||
FileList = [];
|
||
RemoveItemCommand = new RelayCommand(p => FileList.Remove((p as UploadFileItem)!));
|
||
SelectCommand = new RelayCommand<object>(_ => OnSelectTriggered(), _ => IsEnabled);
|
||
}
|
||
|
||
#region Dependency Properties
|
||
|
||
/// <summary>
|
||
/// 定义上传内容的类型:仅文件 或 仅文件夹。
|
||
/// </summary>
|
||
public enum UploadContentType
|
||
{
|
||
/// <summary>
|
||
/// 仅文件
|
||
/// </summary>
|
||
FilesOnly,
|
||
|
||
/// <summary>
|
||
/// 指示上传内容类型仅限文件夹。当此枚举成员被设置时,用户只能选择文件夹进行上传。
|
||
/// </summary>
|
||
FoldersOnly
|
||
}
|
||
|
||
/// <summary>
|
||
/// 描述
|
||
/// </summary>
|
||
public string Description
|
||
{
|
||
get { return (string)GetValue(DescriptionProperty); }
|
||
set { SetValue(DescriptionProperty, value); }
|
||
}
|
||
/// <summary>
|
||
/// 定义描述依赖属性,用于提供上传区域的附加说明信息。
|
||
/// </summary>
|
||
public static readonly DependencyProperty DescriptionProperty =
|
||
DependencyProperty.Register(nameof(Description), typeof(string), typeof(UploadArea), new PropertyMetadata("请选择合适的格式的文件上传"));
|
||
|
||
|
||
/// <summary>
|
||
/// 定义上传内容的类型依赖属性,用于指定可以上传的内容是仅文件还是仅文件夹。
|
||
/// </summary>
|
||
public static readonly DependencyProperty ModeProperty =
|
||
DependencyProperty.Register(nameof(Mode), typeof(UploadContentType), typeof(UploadArea), new PropertyMetadata(UploadContentType.FilesOnly));
|
||
|
||
/// <summary>
|
||
/// 获取或设置上传的类型(文件/文件夹)。
|
||
/// 结合 'Multiple' 属性可实现四种模式。
|
||
/// </summary>
|
||
public UploadContentType Mode
|
||
{
|
||
get => (UploadContentType)GetValue(ModeProperty);
|
||
set => SetValue(ModeProperty, value);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 获取或设置是否允许选择多个文件或文件夹。
|
||
/// </summary>
|
||
public static readonly DependencyProperty MultipleProperty =
|
||
DependencyProperty.Register(nameof(Multiple), typeof(bool), typeof(UploadArea), new PropertyMetadata(false));
|
||
|
||
/// <summary>
|
||
/// 获取或设置是否允许多个文件或文件夹的选择。
|
||
/// </summary>
|
||
public bool Multiple
|
||
{
|
||
get => (bool)GetValue(MultipleProperty);
|
||
set => SetValue(MultipleProperty, value);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 获取或设置文件选择对话框的文件类型过滤器。
|
||
/// 例如: "Image Files|*.jpg;*.png|All Files|*.*"
|
||
/// </summary>
|
||
public static readonly DependencyProperty AcceptProperty =
|
||
DependencyProperty.Register(nameof(Accept), typeof(string), typeof(UploadArea), new PropertyMetadata("All files (*.*)|*.*"));
|
||
|
||
/// <summary>
|
||
/// 获取或设置文件选择对话框中允许选择的文件类型过滤器。
|
||
/// 该属性用于定义用户在打开文件对话框时可以看到和选择的文件类型。支持通过指定文件扩展名来过滤显示的文件。
|
||
/// </summary>
|
||
public string Accept
|
||
{
|
||
get => (string)GetValue(AcceptProperty);
|
||
set => SetValue(AcceptProperty, value);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 上传区域的主提示文字。用户可直接设置。
|
||
/// </summary>
|
||
public static readonly DependencyProperty HintTextProperty =
|
||
DependencyProperty.Register(nameof(HintText), typeof(string), typeof(UploadArea), new PropertyMetadata("点击或拖拽到此区域"));
|
||
|
||
/// <summary>
|
||
/// 获取或设置当用户未与控件交互时显示的提示文本。
|
||
/// </summary>
|
||
public string HintText
|
||
{
|
||
get => (string)GetValue(HintTextProperty);
|
||
set => SetValue(HintTextProperty, value);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 已选择的文件/文件夹列表。
|
||
/// </summary>
|
||
public static readonly DependencyProperty FileListProperty =
|
||
DependencyProperty.Register(nameof(FileList), typeof(ObservableCollection<UploadFileItem>), typeof(UploadArea), new PropertyMetadata(null));
|
||
|
||
/// <summary>
|
||
/// 获取或设置当前上传区域中的文件项列表。
|
||
/// </summary>
|
||
public ObservableCollection<UploadFileItem> FileList
|
||
{
|
||
get => (ObservableCollection<UploadFileItem>)GetValue(FileListProperty);
|
||
set => SetValue(FileListProperty, value);
|
||
}
|
||
#endregion
|
||
|
||
#region Overrides (Drag & Drop, Visual State)
|
||
|
||
/// <inheritdoc />
|
||
public override void OnApplyTemplate()
|
||
{
|
||
base.OnApplyTemplate();
|
||
UpdateVisualState(false);
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
protected override void OnDragEnter(DragEventArgs e)
|
||
{
|
||
base.OnDragEnter(e);
|
||
if (IsEnabled && e.Data.GetDataPresent(DataFormats.FileDrop))
|
||
{
|
||
isDragOver = true;
|
||
UpdateVisualState(true);
|
||
}
|
||
e.Handled = true;
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
protected override void OnDragLeave(DragEventArgs e)
|
||
{
|
||
base.OnDragLeave(e);
|
||
|
||
// 鼠标离开控件,取消高亮
|
||
if (isDragOver)
|
||
{
|
||
isDragOver = false;
|
||
UpdateVisualState(true);
|
||
}
|
||
e.Handled = true;
|
||
}
|
||
//protected override void OnDragLeave(DragEventArgs e)
|
||
//{
|
||
// base.OnDragLeave(e);
|
||
// isDragOver = false;
|
||
// UpdateVisualState(true);
|
||
// e.Handled = true;
|
||
//}
|
||
|
||
/// <inheritdoc />
|
||
protected override void OnDrop(DragEventArgs e)
|
||
{
|
||
base.OnDrop(e);
|
||
|
||
if (IsEnabled && e.Data.GetDataPresent(DataFormats.FileDrop))
|
||
{
|
||
if (e.Data.GetData(DataFormats.FileDrop) is string[] paths)
|
||
{
|
||
// Drop 的时候再次校验,防止漏网之鱼
|
||
if (CheckPathsValidity(paths))
|
||
{
|
||
AddPaths(paths);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 拖放完成,取消高亮
|
||
isDragOver = false;
|
||
UpdateVisualState(true);
|
||
e.Handled = true;
|
||
}
|
||
/// <summary>
|
||
/// 解析 Accept 字符串(如 "Image|*.jpg;*.png")为后缀名集合
|
||
/// </summary>
|
||
private HashSet<string> GetAllowedExtensions()
|
||
{
|
||
var extensions = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||
if (string.IsNullOrWhiteSpace(Accept)) return extensions;
|
||
|
||
var parts = Accept.Split('|');
|
||
foreach (var part in parts)
|
||
{
|
||
if (part.Contains('*'))
|
||
{
|
||
var patterns = part.Split(';');
|
||
foreach (var pattern in patterns)
|
||
{
|
||
var ext = Path.GetExtension(pattern.Trim());
|
||
if (!string.IsNullOrEmpty(ext) && ext != ".*")
|
||
{
|
||
extensions.Add(ext);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return extensions;
|
||
}
|
||
//protected override void OnDrop(DragEventArgs e)
|
||
//{
|
||
// base.OnDrop(e);
|
||
// if (IsEnabled && e.Data.GetDataPresent(DataFormats.FileDrop))
|
||
// {
|
||
// if (e.Data.GetData(DataFormats.FileDrop) is string[] paths) AddPaths(paths);
|
||
// }
|
||
// isDragOver = false;
|
||
// UpdateVisualState(true);
|
||
// e.Handled = true;
|
||
//}
|
||
//protected override void OnDragOver(DragEventArgs e)
|
||
//{
|
||
// base.OnDragOver(e);
|
||
// e.Effects = IsEnabled && e.Data.GetDataPresent(DataFormats.FileDrop) ? DragDropEffects.Copy : DragDropEffects.None;
|
||
// e.Handled = true;
|
||
//}
|
||
|
||
/// <inheritdoc />
|
||
/// <inheritdoc />
|
||
protected override void OnDragOver(DragEventArgs e)
|
||
{
|
||
base.OnDragOver(e);
|
||
|
||
// 默认行为:如果不符合要求,显示“禁止”图标
|
||
e.Effects = DragDropEffects.None;
|
||
bool isValid = false;
|
||
|
||
// 1. 检查数据是否存在
|
||
if (IsEnabled && e.Data.GetDataPresent(DataFormats.FileDrop))
|
||
{
|
||
if (e.Data.GetData(DataFormats.FileDrop) is string[] paths && paths.Length > 0)
|
||
{
|
||
// 2. 调用上面的校验逻辑
|
||
isValid = CheckPathsValidity(paths);
|
||
}
|
||
}
|
||
|
||
if (isValid)
|
||
{
|
||
e.Effects = DragDropEffects.Copy;
|
||
}
|
||
|
||
// =========================================================
|
||
// 关键修复:防止动画频繁重置
|
||
// 只有当 isDragOver 的状态发生改变时,才调用 UpdateVisualState
|
||
// =========================================================
|
||
if (isDragOver != isValid)
|
||
{
|
||
isDragOver = isValid; // 更新状态
|
||
UpdateVisualState(true); // 触发变色或恢复
|
||
}
|
||
|
||
e.Handled = true;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 核心校验逻辑:判断拖入的文件是否符合 Mode 和 Accept 的要求
|
||
/// </summary>
|
||
private bool CheckPathsValidity(string[] paths)
|
||
{
|
||
// 1. 如果是“仅文件夹”模式
|
||
if (Mode == UploadContentType.FoldersOnly)
|
||
{
|
||
// 只要有一个不是文件夹,就视为无效(或者你可以改为剔除无效项)
|
||
return paths.All(Directory.Exists);
|
||
}
|
||
|
||
// 2. 如果是“仅文件”模式
|
||
if (Mode == UploadContentType.FilesOnly)
|
||
{
|
||
var allowedExtensions = GetAllowedExtensions();
|
||
bool allowAll = allowedExtensions.Count == 0;
|
||
|
||
foreach (var path in paths)
|
||
{
|
||
// 如果包含了文件夹,直接不允许
|
||
if (Directory.Exists(path)) return false;
|
||
// 如果路径不存在,不允许
|
||
if (!File.Exists(path)) return false;
|
||
|
||
// 检查后缀名
|
||
if (!allowAll)
|
||
{
|
||
var ext = Path.GetExtension(path);
|
||
// 后缀名为空或者不在允许列表中 -> 无效
|
||
if (string.IsNullOrEmpty(ext) || !allowedExtensions.Contains(ext))
|
||
{
|
||
return false;
|
||
}
|
||
}
|
||
}
|
||
return true;
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region Core Logic
|
||
|
||
private void OnSelectTriggered()
|
||
{
|
||
switch (Mode)
|
||
{
|
||
case UploadContentType.FilesOnly:
|
||
SelectFiles();
|
||
break;
|
||
case UploadContentType.FoldersOnly:
|
||
SelectFolders();
|
||
break;
|
||
default:
|
||
throw new ArgumentOutOfRangeException();
|
||
}
|
||
}
|
||
|
||
private void SelectFiles()
|
||
{
|
||
var dialog = new OpenFileDialog
|
||
{
|
||
Multiselect = this.Multiple,
|
||
Title = this.Multiple ? "选择一个或多个文件" : "选择一个文件",
|
||
Filter = this.Accept
|
||
};
|
||
|
||
if (dialog.ShowDialog() == true)
|
||
{
|
||
AddPaths(dialog.FileNames);
|
||
}
|
||
}
|
||
|
||
private void SelectFolders()
|
||
{
|
||
// 注意:VistaFolderBrowserDialog 不是 .NET Core/5+ 的标准部分。
|
||
// 您可能需要使用第三方库或针对不同平台进行实现。
|
||
var dialog = new VistaFolderBrowserDialog
|
||
{
|
||
Title = this.Multiple ? "选择一个或多个文件夹" : "选择一个文件夹",
|
||
Multiselect = this.Multiple
|
||
};
|
||
|
||
if (dialog.ShowDialog() && dialog.SelectedPaths != null)
|
||
{
|
||
AddPaths(dialog.SelectedPaths);
|
||
}
|
||
}
|
||
|
||
private void AddPaths(string?[] paths)
|
||
{
|
||
FileList ??= [];
|
||
|
||
// 如果不允许选择多个,则先清空列表
|
||
if (!this.Multiple)
|
||
{
|
||
FileList.Clear();
|
||
}
|
||
|
||
foreach (var path in paths)
|
||
{
|
||
// 根据文件/文件夹模式进行过滤
|
||
var isFile = File.Exists(path);
|
||
var isDirectory = Directory.Exists(path);
|
||
|
||
if ((Mode == UploadContentType.FilesOnly && isFile) ||
|
||
(Mode == UploadContentType.FoldersOnly && isDirectory))
|
||
{
|
||
if (!FileList.Any(item => item?.FilePath != null && item.FilePath.Equals(path, StringComparison.OrdinalIgnoreCase)))
|
||
{
|
||
FileList.Add(new UploadFileItem(path));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private void UpdateVisualState(bool useTransitions)
|
||
{
|
||
VisualStateManager.GoToState(this, IsEnabled ? "Normal" : "Disabled", useTransitions);
|
||
|
||
if (IsEnabled && isDragOver)
|
||
{
|
||
VisualStateManager.GoToState(this, "DragOver", useTransitions);
|
||
}
|
||
else
|
||
{
|
||
VisualStateManager.GoToState(this, "DragLeave", useTransitions);
|
||
}
|
||
}
|
||
#endregion
|
||
}
|