You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
861 lines
31 KiB
861 lines
31 KiB
|
1 month ago
|
using AuroraDesk.Presentation.ViewModels.Base;
|
||
|
|
using Microsoft.Extensions.Logging;
|
||
|
|
using ReactiveUI;
|
||
|
|
using System;
|
||
|
|
using System.Collections.Concurrent;
|
||
|
|
using System.Collections.Generic;
|
||
|
|
using System.Collections.ObjectModel;
|
||
|
|
using System.IO;
|
||
|
|
using System.Linq;
|
||
|
|
using System.Reactive;
|
||
|
|
using System.Reactive.Linq;
|
||
|
|
using System.Threading;
|
||
|
|
using System.Threading.Tasks;
|
||
|
|
using Avalonia.Controls;
|
||
|
|
using Avalonia.Controls.ApplicationLifetimes;
|
||
|
|
using Avalonia.Media.Imaging;
|
||
|
|
using Avalonia.Platform.Storage;
|
||
|
|
using Avalonia.Threading;
|
||
|
|
using SixLabors.ImageSharp;
|
||
|
|
using SixLabors.ImageSharp.Processing;
|
||
|
|
|
||
|
|
namespace AuroraDesk.Presentation.ViewModels.Pages;
|
||
|
|
|
||
|
|
/// <summary>
|
||
|
|
/// 图片浏览页面 ViewModel
|
||
|
|
/// 支持百万级图片,实现真正的虚拟化和按需加载(类似 Windows 11)
|
||
|
|
/// 核心策略:
|
||
|
|
/// 1. 只保存文件路径列表(最小内存占用)
|
||
|
|
/// 2. 只创建可见区域的项目(虚拟化窗口)
|
||
|
|
/// 3. 缩略图按需加载(只在可见时加载)
|
||
|
|
/// 4. 后台异步处理,不阻塞UI
|
||
|
|
/// </summary>
|
||
|
|
public class ImageGalleryPageViewModel : RoutableViewModel
|
||
|
|
{
|
||
|
|
private string _selectedDirectory = string.Empty;
|
||
|
|
private ObservableCollection<ImageItem> _images = new();
|
||
|
|
private ImageItem? _selectedImage;
|
||
|
|
private bool _isLoading;
|
||
|
|
private int _totalImageCount;
|
||
|
|
private int _loadedImageCount;
|
||
|
|
private string _statusMessage = "请选择一个包含图片的目录";
|
||
|
|
private CancellationTokenSource? _loadingCancellationTokenSource;
|
||
|
|
|
||
|
|
// 虚拟化核心:只保存文件路径列表(非常小的内存占用)
|
||
|
|
private List<string> _allFilePaths = new();
|
||
|
|
|
||
|
|
// 虚拟化窗口:当前可见区域的前后各多加载的项目数
|
||
|
|
private const int VirtualizationWindowSize = 500; // 前后各500个,共1000个
|
||
|
|
|
||
|
|
// 当前虚拟化窗口的起始索引
|
||
|
|
private int _virtualizationWindowStart = 0;
|
||
|
|
|
||
|
|
private readonly ILogger<ImageGalleryPageViewModel>? _logger;
|
||
|
|
|
||
|
|
// 支持的图片格式
|
||
|
|
private static readonly string[] SupportedImageExtensions =
|
||
|
|
{
|
||
|
|
".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".ico", ".svg"
|
||
|
|
};
|
||
|
|
|
||
|
|
public ObservableCollection<ImageItem> Images
|
||
|
|
{
|
||
|
|
get => _images;
|
||
|
|
set => this.RaiseAndSetIfChanged(ref _images, value);
|
||
|
|
}
|
||
|
|
|
||
|
|
public ImageItem? SelectedImage
|
||
|
|
{
|
||
|
|
get => _selectedImage;
|
||
|
|
set => this.RaiseAndSetIfChanged(ref _selectedImage, value);
|
||
|
|
}
|
||
|
|
|
||
|
|
public bool IsLoading
|
||
|
|
{
|
||
|
|
get => _isLoading;
|
||
|
|
set => this.RaiseAndSetIfChanged(ref _isLoading, value);
|
||
|
|
}
|
||
|
|
|
||
|
|
public string SelectedDirectory
|
||
|
|
{
|
||
|
|
get => _selectedDirectory;
|
||
|
|
set => this.RaiseAndSetIfChanged(ref _selectedDirectory, value);
|
||
|
|
}
|
||
|
|
|
||
|
|
public int TotalImageCount
|
||
|
|
{
|
||
|
|
get => _totalImageCount;
|
||
|
|
set => this.RaiseAndSetIfChanged(ref _totalImageCount, value);
|
||
|
|
}
|
||
|
|
|
||
|
|
public int LoadedImageCount
|
||
|
|
{
|
||
|
|
get => _loadedImageCount;
|
||
|
|
set => this.RaiseAndSetIfChanged(ref _loadedImageCount, value);
|
||
|
|
}
|
||
|
|
|
||
|
|
public string StatusMessage
|
||
|
|
{
|
||
|
|
get => _statusMessage;
|
||
|
|
set => this.RaiseAndSetIfChanged(ref _statusMessage, value);
|
||
|
|
}
|
||
|
|
|
||
|
|
// 命令
|
||
|
|
public ReactiveCommand<Unit, Unit> SelectDirectoryCommand { get; }
|
||
|
|
public ReactiveCommand<ImageItem, Unit> SelectImageCommand { get; }
|
||
|
|
|
||
|
|
/// <summary>
|
||
|
|
/// 构造函数
|
||
|
|
/// </summary>
|
||
|
|
/// <param name="hostScreen">宿主 Screen</param>
|
||
|
|
/// <param name="logger">日志记录器</param>
|
||
|
|
public ImageGalleryPageViewModel(
|
||
|
|
IScreen hostScreen,
|
||
|
|
ILogger<ImageGalleryPageViewModel>? logger = null)
|
||
|
|
: base(hostScreen, "ImageGallery")
|
||
|
|
{
|
||
|
|
_logger = logger;
|
||
|
|
|
||
|
|
SelectDirectoryCommand = ReactiveCommand.CreateFromTask(SelectDirectoryAsync);
|
||
|
|
SelectImageCommand = ReactiveCommand.Create<ImageItem>(SelectImage);
|
||
|
|
|
||
|
|
_logger?.LogInformation("ImageGalleryPageViewModel 已创建");
|
||
|
|
}
|
||
|
|
|
||
|
|
/// <summary>
|
||
|
|
/// 选择目录并加载图片
|
||
|
|
/// </summary>
|
||
|
|
private async Task SelectDirectoryAsync()
|
||
|
|
{
|
||
|
|
try
|
||
|
|
{
|
||
|
|
// 获取顶级窗口用于显示对话框
|
||
|
|
var app = Avalonia.Application.Current;
|
||
|
|
TopLevel? topLevel = null;
|
||
|
|
|
||
|
|
if (app?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||
|
|
{
|
||
|
|
topLevel = desktop.MainWindow;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (topLevel?.StorageProvider == null)
|
||
|
|
{
|
||
|
|
_logger?.LogWarning("无法获取 StorageProvider");
|
||
|
|
StatusMessage = "无法打开文件对话框";
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
// 打开文件夹选择对话框
|
||
|
|
var folders = await topLevel.StorageProvider.OpenFolderPickerAsync(
|
||
|
|
new FolderPickerOpenOptions
|
||
|
|
{
|
||
|
|
Title = "选择包含图片的文件夹",
|
||
|
|
AllowMultiple = false
|
||
|
|
});
|
||
|
|
|
||
|
|
if (folders.Count > 0 && folders[0].TryGetLocalPath() is { } path)
|
||
|
|
{
|
||
|
|
SelectedDirectory = path;
|
||
|
|
await LoadImagesFromDirectoryAsync(path);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
catch (Exception ex)
|
||
|
|
{
|
||
|
|
_logger?.LogError(ex, "选择目录时发生错误");
|
||
|
|
StatusMessage = $"选择目录失败: {ex.Message}";
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// <summary>
|
||
|
|
/// 从目录加载图片(真正的虚拟化加载,类似 Windows 11)
|
||
|
|
/// 核心策略:
|
||
|
|
/// 1. 只保存文件路径列表(最小内存占用)
|
||
|
|
/// 2. 只创建虚拟化窗口内的项目(初始窗口大小:前1000个)
|
||
|
|
/// 3. 缩略图按需加载(延迟加载,避免同时加载太多)
|
||
|
|
/// </summary>
|
||
|
|
private async Task LoadImagesFromDirectoryAsync(string directoryPath)
|
||
|
|
{
|
||
|
|
// 取消之前的加载任务
|
||
|
|
_loadingCancellationTokenSource?.Cancel();
|
||
|
|
_loadingCancellationTokenSource?.Dispose();
|
||
|
|
_loadingCancellationTokenSource = new CancellationTokenSource();
|
||
|
|
var cancellationToken = _loadingCancellationTokenSource.Token;
|
||
|
|
|
||
|
|
try
|
||
|
|
{
|
||
|
|
IsLoading = true;
|
||
|
|
StatusMessage = "正在扫描图片文件...";
|
||
|
|
Images.Clear();
|
||
|
|
SelectedImage = null;
|
||
|
|
LoadedImageCount = 0;
|
||
|
|
_allFilePaths.Clear();
|
||
|
|
_virtualizationWindowStart = 0;
|
||
|
|
|
||
|
|
// 清除之前的缩略图缓存,释放内存
|
||
|
|
ImageItem.ClearThumbnailCache();
|
||
|
|
TotalImageCount = 0;
|
||
|
|
|
||
|
|
_logger?.LogInformation("开始从目录加载图片: {Directory}", directoryPath);
|
||
|
|
|
||
|
|
if (!Directory.Exists(directoryPath))
|
||
|
|
{
|
||
|
|
StatusMessage = "目录不存在";
|
||
|
|
IsLoading = false;
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
// 第一步:快速收集所有文件路径(只保存路径字符串,内存占用极小)
|
||
|
|
await Dispatcher.UIThread.InvokeAsync(() =>
|
||
|
|
{
|
||
|
|
StatusMessage = "正在扫描图片文件...";
|
||
|
|
}, DispatcherPriority.Background);
|
||
|
|
|
||
|
|
await Task.Run(async () =>
|
||
|
|
{
|
||
|
|
try
|
||
|
|
{
|
||
|
|
// 使用异步枚举器,流式处理文件
|
||
|
|
await foreach (var filePath in EnumerateImageFilesAsync(directoryPath, cancellationToken))
|
||
|
|
{
|
||
|
|
if (cancellationToken.IsCancellationRequested)
|
||
|
|
break;
|
||
|
|
|
||
|
|
_allFilePaths.Add(filePath);
|
||
|
|
|
||
|
|
// 每找到1000个文件就更新一次UI(不阻塞)
|
||
|
|
if (_allFilePaths.Count % 1000 == 0)
|
||
|
|
{
|
||
|
|
await Dispatcher.UIThread.InvokeAsync(() =>
|
||
|
|
{
|
||
|
|
TotalImageCount = _allFilePaths.Count;
|
||
|
|
StatusMessage = $"已找到 {TotalImageCount} 个图片文件...";
|
||
|
|
}, DispatcherPriority.Background);
|
||
|
|
|
||
|
|
// 让UI有时间响应
|
||
|
|
await Task.Yield();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
catch (OperationCanceledException)
|
||
|
|
{
|
||
|
|
_logger?.LogInformation("文件扫描已取消");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
}, cancellationToken);
|
||
|
|
|
||
|
|
if (cancellationToken.IsCancellationRequested)
|
||
|
|
{
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
TotalImageCount = _allFilePaths.Count;
|
||
|
|
_logger?.LogInformation("找到 {Count} 张图片,准备虚拟化加载", TotalImageCount);
|
||
|
|
|
||
|
|
if (TotalImageCount == 0)
|
||
|
|
{
|
||
|
|
await Dispatcher.UIThread.InvokeAsync(() =>
|
||
|
|
{
|
||
|
|
StatusMessage = "未找到图片文件";
|
||
|
|
IsLoading = false;
|
||
|
|
}, DispatcherPriority.Background);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
// 第二步:只创建虚拟化窗口内的项目(初始窗口:前1000个)
|
||
|
|
// 这是关键优化:类似 Windows 11,只创建可见区域的项目,而不是所有项目
|
||
|
|
await Dispatcher.UIThread.InvokeAsync(() =>
|
||
|
|
{
|
||
|
|
StatusMessage = $"正在准备图片...";
|
||
|
|
}, DispatcherPriority.Background);
|
||
|
|
|
||
|
|
// 创建初始虚拟化窗口的项目(前 VirtualizationWindowSize * 2 个)
|
||
|
|
var initialWindowSize = Math.Min(VirtualizationWindowSize * 2, TotalImageCount);
|
||
|
|
var initialFilePaths = _allFilePaths.Take(initialWindowSize).ToList();
|
||
|
|
|
||
|
|
// 在后台线程批量创建 ImageItem(异步获取文件信息,避免阻塞)
|
||
|
|
var initialItems = await Task.Run(async () =>
|
||
|
|
{
|
||
|
|
var items = new List<ImageItem>();
|
||
|
|
var semaphore = new SemaphoreSlim(Environment.ProcessorCount * 2); // 限制并发数
|
||
|
|
var tasks = new List<Task>();
|
||
|
|
|
||
|
|
foreach (var filePath in initialFilePaths)
|
||
|
|
{
|
||
|
|
if (cancellationToken.IsCancellationRequested)
|
||
|
|
break;
|
||
|
|
|
||
|
|
await semaphore.WaitAsync(cancellationToken);
|
||
|
|
var task = Task.Run(() =>
|
||
|
|
{
|
||
|
|
try
|
||
|
|
{
|
||
|
|
var fileName = Path.GetFileName(filePath);
|
||
|
|
var item = new ImageItem
|
||
|
|
{
|
||
|
|
FilePath = filePath,
|
||
|
|
FileName = fileName,
|
||
|
|
};
|
||
|
|
|
||
|
|
// 获取文件信息(同步操作,因为 FileInfo 是同步的)
|
||
|
|
try
|
||
|
|
{
|
||
|
|
var fileInfo = new FileInfo(filePath);
|
||
|
|
if (fileInfo.Exists)
|
||
|
|
{
|
||
|
|
item.FileSize = fileInfo.Length;
|
||
|
|
item.LastModified = fileInfo.LastWriteTime;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
catch
|
||
|
|
{
|
||
|
|
// 忽略文件信息获取失败
|
||
|
|
}
|
||
|
|
|
||
|
|
lock (items)
|
||
|
|
{
|
||
|
|
items.Add(item);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
finally
|
||
|
|
{
|
||
|
|
semaphore.Release();
|
||
|
|
}
|
||
|
|
}, cancellationToken);
|
||
|
|
|
||
|
|
tasks.Add(task);
|
||
|
|
}
|
||
|
|
|
||
|
|
await Task.WhenAll(tasks);
|
||
|
|
return items.OrderBy(x => _allFilePaths.IndexOf(x.FilePath)).ToList();
|
||
|
|
}, cancellationToken);
|
||
|
|
|
||
|
|
// 立即添加到UI并显示
|
||
|
|
await Dispatcher.UIThread.InvokeAsync(() =>
|
||
|
|
{
|
||
|
|
_logger?.LogInformation("开始添加虚拟化窗口项目,数量: {Count}", initialItems.Count);
|
||
|
|
|
||
|
|
foreach (var item in initialItems)
|
||
|
|
{
|
||
|
|
_images.Add(item);
|
||
|
|
// 关键优化:对于初始可见窗口内的项目,触发缩略图加载
|
||
|
|
// 这样用户可以看到缩略图逐步加载,体验更好
|
||
|
|
item.EnsureThumbnailLoaded();
|
||
|
|
}
|
||
|
|
|
||
|
|
LoadedImageCount = _images.Count;
|
||
|
|
_isLoading = false;
|
||
|
|
this.RaisePropertyChanged(nameof(IsLoading));
|
||
|
|
|
||
|
|
_logger?.LogInformation("虚拟化窗口已加载,集合大小: {Count},总文件数: {Total}", _images.Count, TotalImageCount);
|
||
|
|
|
||
|
|
StatusMessage = $"已显示 {LoadedImageCount} / {TotalImageCount} 张图片(虚拟化显示,支持百万级图片)";
|
||
|
|
}, DispatcherPriority.Normal);
|
||
|
|
|
||
|
|
_logger?.LogInformation("图片加载完成,虚拟化窗口大小: {WindowSize},总文件数: {Total}", initialItems.Count, TotalImageCount);
|
||
|
|
}
|
||
|
|
catch (OperationCanceledException)
|
||
|
|
{
|
||
|
|
_logger?.LogInformation("图片加载已取消");
|
||
|
|
await Dispatcher.UIThread.InvokeAsync(() =>
|
||
|
|
{
|
||
|
|
StatusMessage = "加载已取消";
|
||
|
|
IsLoading = false;
|
||
|
|
}, DispatcherPriority.Background);
|
||
|
|
}
|
||
|
|
catch (Exception ex)
|
||
|
|
{
|
||
|
|
_logger?.LogError(ex, "加载图片时发生错误");
|
||
|
|
await Dispatcher.UIThread.InvokeAsync(() =>
|
||
|
|
{
|
||
|
|
StatusMessage = $"加载图片失败: {ex.Message}";
|
||
|
|
IsLoading = false;
|
||
|
|
}, DispatcherPriority.Background);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// <summary>
|
||
|
|
/// 根据滚动位置更新虚拟化窗口(动态加载/卸载项目)
|
||
|
|
/// 当用户滚动到接近窗口边界时,自动加载更多项目
|
||
|
|
/// </summary>
|
||
|
|
/// <param name="visibleStartIndex">当前可见区域的起始索引</param>
|
||
|
|
/// <param name="visibleEndIndex">当前可见区域的结束索引</param>
|
||
|
|
public async Task UpdateVirtualizationWindowAsync(int visibleStartIndex, int visibleEndIndex)
|
||
|
|
{
|
||
|
|
if (_allFilePaths.Count == 0 || IsLoading)
|
||
|
|
return;
|
||
|
|
|
||
|
|
// 计算新的虚拟化窗口范围
|
||
|
|
var newWindowStart = Math.Max(0, visibleStartIndex - VirtualizationWindowSize);
|
||
|
|
var newWindowEnd = Math.Min(_allFilePaths.Count, visibleEndIndex + VirtualizationWindowSize);
|
||
|
|
|
||
|
|
// 如果窗口没有显著变化,不需要更新
|
||
|
|
if (newWindowStart >= _virtualizationWindowStart && newWindowEnd <= _virtualizationWindowStart + _images.Count)
|
||
|
|
{
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
_logger?.LogDebug("更新虚拟化窗口: {Start} - {End} (可见: {VisibleStart} - {VisibleEnd})",
|
||
|
|
newWindowStart, newWindowEnd, visibleStartIndex, visibleEndIndex);
|
||
|
|
|
||
|
|
try
|
||
|
|
{
|
||
|
|
// 计算需要移除的项目(超出窗口范围的项目)
|
||
|
|
var itemsToRemove = new List<ImageItem>();
|
||
|
|
for (int i = _images.Count - 1; i >= 0; i--)
|
||
|
|
{
|
||
|
|
var item = _images[i];
|
||
|
|
var index = _allFilePaths.IndexOf(item.FilePath);
|
||
|
|
if (index < newWindowStart || index >= newWindowEnd)
|
||
|
|
{
|
||
|
|
itemsToRemove.Add(item);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// 计算需要添加的项目(在新窗口内但不在当前集合中的)
|
||
|
|
var currentIndices = new HashSet<int>(_images.Select(x => _allFilePaths.IndexOf(x.FilePath)));
|
||
|
|
var itemsToAdd = new List<string>();
|
||
|
|
for (int i = newWindowStart; i < newWindowEnd; i++)
|
||
|
|
{
|
||
|
|
if (!currentIndices.Contains(i))
|
||
|
|
{
|
||
|
|
itemsToAdd.Add(_allFilePaths[i]);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// 在UI线程更新集合
|
||
|
|
await Dispatcher.UIThread.InvokeAsync(() =>
|
||
|
|
{
|
||
|
|
// 移除超出窗口的项目
|
||
|
|
foreach (var item in itemsToRemove)
|
||
|
|
{
|
||
|
|
_images.Remove(item);
|
||
|
|
}
|
||
|
|
|
||
|
|
// 添加新窗口内的项目(按索引顺序插入)
|
||
|
|
var newItems = itemsToAdd.Select(filePath =>
|
||
|
|
{
|
||
|
|
var fileName = Path.GetFileName(filePath);
|
||
|
|
var item = new ImageItem
|
||
|
|
{
|
||
|
|
FilePath = filePath,
|
||
|
|
FileName = fileName,
|
||
|
|
};
|
||
|
|
|
||
|
|
// 异步获取文件信息
|
||
|
|
_ = Task.Run(async () =>
|
||
|
|
{
|
||
|
|
try
|
||
|
|
{
|
||
|
|
var fileInfo = new FileInfo(filePath);
|
||
|
|
if (fileInfo.Exists)
|
||
|
|
{
|
||
|
|
await Dispatcher.UIThread.InvokeAsync(() =>
|
||
|
|
{
|
||
|
|
item.FileSize = fileInfo.Length;
|
||
|
|
item.LastModified = fileInfo.LastWriteTime;
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
catch
|
||
|
|
{
|
||
|
|
// 忽略失败
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
return item;
|
||
|
|
}).ToList();
|
||
|
|
|
||
|
|
// 按索引排序后插入
|
||
|
|
foreach (var item in newItems.OrderBy(x => _allFilePaths.IndexOf(x.FilePath)))
|
||
|
|
{
|
||
|
|
var index = _allFilePaths.IndexOf(item.FilePath);
|
||
|
|
var insertIndex = _images.TakeWhile(i => _allFilePaths.IndexOf(i.FilePath) < index).Count();
|
||
|
|
_images.Insert(insertIndex, item);
|
||
|
|
|
||
|
|
// 对于新加载到窗口内的项目,触发缩略图加载
|
||
|
|
// 如果项目在可见区域内,会立即加载;否则会在进入可见区域时加载
|
||
|
|
item.EnsureThumbnailLoaded();
|
||
|
|
}
|
||
|
|
|
||
|
|
_virtualizationWindowStart = newWindowStart;
|
||
|
|
LoadedImageCount = _images.Count;
|
||
|
|
|
||
|
|
StatusMessage = $"已加载 {LoadedImageCount} / {TotalImageCount} 张图片(虚拟化显示)";
|
||
|
|
}, DispatcherPriority.Background);
|
||
|
|
}
|
||
|
|
catch (Exception ex)
|
||
|
|
{
|
||
|
|
_logger?.LogWarning(ex, "更新虚拟化窗口时出错");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// <summary>
|
||
|
|
/// 异步枚举图片文件(流式处理,不等待全部完成)
|
||
|
|
/// </summary>
|
||
|
|
private async IAsyncEnumerable<string> EnumerateImageFilesAsync(
|
||
|
|
string directoryPath,
|
||
|
|
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||
|
|
{
|
||
|
|
var dirQueue = new Queue<string>();
|
||
|
|
var fileQueue = new Queue<string>();
|
||
|
|
dirQueue.Enqueue(directoryPath);
|
||
|
|
|
||
|
|
// 在后台线程持续扫描目录和文件
|
||
|
|
var scanTask = Task.Run(() =>
|
||
|
|
{
|
||
|
|
while ((dirQueue.Count > 0 || fileQueue.Count > 0) && !cancellationToken.IsCancellationRequested)
|
||
|
|
{
|
||
|
|
string? currentDir = null;
|
||
|
|
lock (dirQueue)
|
||
|
|
{
|
||
|
|
if (dirQueue.Count > 0)
|
||
|
|
{
|
||
|
|
currentDir = dirQueue.Dequeue();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (currentDir == null)
|
||
|
|
{
|
||
|
|
Thread.Sleep(10); // 等待新目录
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
try
|
||
|
|
{
|
||
|
|
// 枚举当前目录的文件
|
||
|
|
var files = new List<string>();
|
||
|
|
foreach (var filePath in Directory.EnumerateFiles(currentDir, "*.*", SearchOption.TopDirectoryOnly))
|
||
|
|
{
|
||
|
|
if (cancellationToken.IsCancellationRequested)
|
||
|
|
return;
|
||
|
|
|
||
|
|
var extension = Path.GetExtension(filePath).ToLowerInvariant();
|
||
|
|
if (Array.IndexOf(SupportedImageExtensions, extension) >= 0)
|
||
|
|
{
|
||
|
|
files.Add(filePath);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// 将找到的文件添加到文件队列
|
||
|
|
lock (fileQueue)
|
||
|
|
{
|
||
|
|
foreach (var file in files)
|
||
|
|
{
|
||
|
|
fileQueue.Enqueue(file);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// 枚举子目录
|
||
|
|
var dirs = new List<string>();
|
||
|
|
foreach (var subDir in Directory.EnumerateDirectories(currentDir, "*", SearchOption.TopDirectoryOnly))
|
||
|
|
{
|
||
|
|
if (cancellationToken.IsCancellationRequested)
|
||
|
|
return;
|
||
|
|
|
||
|
|
dirs.Add(subDir);
|
||
|
|
}
|
||
|
|
|
||
|
|
// 将子目录添加到目录队列
|
||
|
|
lock (dirQueue)
|
||
|
|
{
|
||
|
|
foreach (var dir in dirs)
|
||
|
|
{
|
||
|
|
dirQueue.Enqueue(dir);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
catch (UnauthorizedAccessException)
|
||
|
|
{
|
||
|
|
// 跳过无权限访问的目录
|
||
|
|
_logger?.LogWarning("无权限访问目录: {Directory}", currentDir);
|
||
|
|
}
|
||
|
|
catch (DirectoryNotFoundException)
|
||
|
|
{
|
||
|
|
// 跳过不存在的目录
|
||
|
|
}
|
||
|
|
catch (Exception ex)
|
||
|
|
{
|
||
|
|
_logger?.LogWarning(ex, "处理目录时出错: {Directory}", currentDir);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}, cancellationToken);
|
||
|
|
|
||
|
|
// 流式返回找到的文件
|
||
|
|
while (!scanTask.IsCompleted || fileQueue.Count > 0)
|
||
|
|
{
|
||
|
|
if (cancellationToken.IsCancellationRequested)
|
||
|
|
yield break;
|
||
|
|
|
||
|
|
string? filePath = null;
|
||
|
|
lock (fileQueue)
|
||
|
|
{
|
||
|
|
if (fileQueue.Count > 0)
|
||
|
|
{
|
||
|
|
filePath = fileQueue.Dequeue();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (filePath != null)
|
||
|
|
{
|
||
|
|
yield return filePath;
|
||
|
|
}
|
||
|
|
else
|
||
|
|
{
|
||
|
|
// 没有文件时稍等片刻
|
||
|
|
await Task.Delay(10, cancellationToken);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private void SelectImage(ImageItem image)
|
||
|
|
{
|
||
|
|
if (image == null) return;
|
||
|
|
|
||
|
|
SelectedImage = image;
|
||
|
|
_logger?.LogInformation("选择图片: {FileName}", image.FileName);
|
||
|
|
}
|
||
|
|
|
||
|
|
/// <summary>
|
||
|
|
/// 清理资源(取消正在进行的加载任务)
|
||
|
|
/// </summary>
|
||
|
|
public void CancelLoading()
|
||
|
|
{
|
||
|
|
_loadingCancellationTokenSource?.Cancel();
|
||
|
|
_loadingCancellationTokenSource?.Dispose();
|
||
|
|
_loadingCancellationTokenSource = null;
|
||
|
|
}
|
||
|
|
|
||
|
|
/// <summary>
|
||
|
|
/// 清除缩略图缓存(在切换目录时调用,释放内存)
|
||
|
|
/// </summary>
|
||
|
|
public static void ClearThumbnailCache()
|
||
|
|
{
|
||
|
|
ImageItem.ClearThumbnailCache();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// <summary>
|
||
|
|
/// 图片项模型
|
||
|
|
/// </summary>
|
||
|
|
public class ImageItem : ReactiveObject
|
||
|
|
{
|
||
|
|
// 缩略图缓存(线程安全,静态共享)
|
||
|
|
private static readonly ConcurrentDictionary<string, Bitmap?> _thumbnailCache = new();
|
||
|
|
|
||
|
|
// 缩略图大小(与UI中的184x184一致)
|
||
|
|
private const int ThumbnailSize = 184;
|
||
|
|
|
||
|
|
private string _filePath = string.Empty;
|
||
|
|
private string _fileName = string.Empty;
|
||
|
|
private long _fileSize;
|
||
|
|
private DateTime _lastModified;
|
||
|
|
private Bitmap? _thumbnailSource;
|
||
|
|
private bool _isLoadingThumbnail;
|
||
|
|
|
||
|
|
public string FilePath
|
||
|
|
{
|
||
|
|
get => _filePath;
|
||
|
|
set
|
||
|
|
{
|
||
|
|
var oldValue = _filePath;
|
||
|
|
this.RaiseAndSetIfChanged(ref _filePath, value);
|
||
|
|
// 注意:不再自动触发缩略图加载,而是通过 EnsureThumbnailLoadedAsync 手动触发
|
||
|
|
// 这样可以实现真正的按需加载:只在项目可见时才加载缩略图
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// <summary>
|
||
|
|
/// 确保缩略图已加载(按需加载,类似 Windows 11)
|
||
|
|
/// 应该在项目进入可见区域时调用此方法
|
||
|
|
/// </summary>
|
||
|
|
public void EnsureThumbnailLoaded()
|
||
|
|
{
|
||
|
|
// 如果已经加载或正在加载,则不重复加载
|
||
|
|
if (_thumbnailSource != null || _isLoadingThumbnail || string.IsNullOrEmpty(_filePath))
|
||
|
|
return;
|
||
|
|
|
||
|
|
// 异步加载缩略图(不阻塞)
|
||
|
|
_ = DelayedLoadThumbnailAsync();
|
||
|
|
}
|
||
|
|
|
||
|
|
/// <summary>
|
||
|
|
/// 延迟加载缩略图(避免同时触发大量加载)
|
||
|
|
/// </summary>
|
||
|
|
private async Task DelayedLoadThumbnailAsync()
|
||
|
|
{
|
||
|
|
// 延迟 50-200ms,让UI有时间渲染,并且避免同时加载太多缩略图
|
||
|
|
await Task.Delay(new Random().Next(50, 200));
|
||
|
|
|
||
|
|
// 如果 FilePath 没有改变,才加载缩略图
|
||
|
|
if (!string.IsNullOrEmpty(_filePath) && _thumbnailSource == null && !_isLoadingThumbnail)
|
||
|
|
{
|
||
|
|
await LoadThumbnailAsync();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
public string FileName
|
||
|
|
{
|
||
|
|
get => _fileName;
|
||
|
|
set => this.RaiseAndSetIfChanged(ref _fileName, value);
|
||
|
|
}
|
||
|
|
|
||
|
|
public long FileSize
|
||
|
|
{
|
||
|
|
get => _fileSize;
|
||
|
|
set => this.RaiseAndSetIfChanged(ref _fileSize, value);
|
||
|
|
}
|
||
|
|
|
||
|
|
public DateTime LastModified
|
||
|
|
{
|
||
|
|
get => _lastModified;
|
||
|
|
set => this.RaiseAndSetIfChanged(ref _lastModified, value);
|
||
|
|
}
|
||
|
|
|
||
|
|
/// <summary>
|
||
|
|
/// 缩略图源(异步加载,类似 Windows 11)
|
||
|
|
/// </summary>
|
||
|
|
public Bitmap? ThumbnailSource
|
||
|
|
{
|
||
|
|
get => _thumbnailSource;
|
||
|
|
private set => this.RaiseAndSetIfChanged(ref _thumbnailSource, value);
|
||
|
|
}
|
||
|
|
|
||
|
|
/// <summary>
|
||
|
|
/// 是否正在加载缩略图
|
||
|
|
/// </summary>
|
||
|
|
public bool IsLoadingThumbnail
|
||
|
|
{
|
||
|
|
get => _isLoadingThumbnail;
|
||
|
|
private set => this.RaiseAndSetIfChanged(ref _isLoadingThumbnail, value);
|
||
|
|
}
|
||
|
|
|
||
|
|
/// <summary>
|
||
|
|
/// 异步加载缩略图(184x184,类似 Windows 11)
|
||
|
|
/// </summary>
|
||
|
|
private async Task LoadThumbnailAsync()
|
||
|
|
{
|
||
|
|
if (string.IsNullOrEmpty(_filePath) || !File.Exists(_filePath))
|
||
|
|
{
|
||
|
|
ThumbnailSource = null;
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
IsLoadingThumbnail = true;
|
||
|
|
|
||
|
|
try
|
||
|
|
{
|
||
|
|
// 在后台线程加载缩略图
|
||
|
|
var thumbnail = await Task.Run(() => GetThumbnailFromConverterAsync(_filePath));
|
||
|
|
|
||
|
|
// 在UI线程更新
|
||
|
|
await Dispatcher.UIThread.InvokeAsync(() =>
|
||
|
|
{
|
||
|
|
ThumbnailSource = thumbnail;
|
||
|
|
IsLoadingThumbnail = false;
|
||
|
|
});
|
||
|
|
}
|
||
|
|
catch
|
||
|
|
{
|
||
|
|
await Dispatcher.UIThread.InvokeAsync(() =>
|
||
|
|
{
|
||
|
|
ThumbnailSource = null;
|
||
|
|
IsLoadingThumbnail = false;
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// <summary>
|
||
|
|
/// 使用 ImageSharp 生成真正的缩略图(184x184,类似 Windows 11)
|
||
|
|
/// 支持缓存,避免重复生成
|
||
|
|
/// </summary>
|
||
|
|
private Bitmap? GetThumbnailFromConverterAsync(string filePath)
|
||
|
|
{
|
||
|
|
// 检查缓存
|
||
|
|
if (_thumbnailCache.TryGetValue(filePath, out var cached))
|
||
|
|
{
|
||
|
|
return cached;
|
||
|
|
}
|
||
|
|
|
||
|
|
try
|
||
|
|
{
|
||
|
|
// 使用 ImageSharp 加载并生成缩略图
|
||
|
|
using var image = SixLabors.ImageSharp.Image.Load(filePath);
|
||
|
|
|
||
|
|
// 计算缩略图尺寸(保持宽高比)
|
||
|
|
var sourceWidth = image.Width;
|
||
|
|
var sourceHeight = image.Height;
|
||
|
|
|
||
|
|
int thumbnailWidth, thumbnailHeight;
|
||
|
|
|
||
|
|
if (sourceWidth <= ThumbnailSize && sourceHeight <= ThumbnailSize)
|
||
|
|
{
|
||
|
|
// 如果原图小于缩略图尺寸,直接使用原图尺寸
|
||
|
|
thumbnailWidth = sourceWidth;
|
||
|
|
thumbnailHeight = sourceHeight;
|
||
|
|
}
|
||
|
|
else
|
||
|
|
{
|
||
|
|
// 计算缩放比例,保持宽高比
|
||
|
|
var scale = Math.Min(
|
||
|
|
(double)ThumbnailSize / sourceWidth,
|
||
|
|
(double)ThumbnailSize / sourceHeight);
|
||
|
|
|
||
|
|
thumbnailWidth = (int)(sourceWidth * scale);
|
||
|
|
thumbnailHeight = (int)(sourceHeight * scale);
|
||
|
|
}
|
||
|
|
|
||
|
|
// 生成缩略图
|
||
|
|
image.Mutate(x => x.Resize(new ResizeOptions
|
||
|
|
{
|
||
|
|
Size = new Size(thumbnailWidth, thumbnailHeight),
|
||
|
|
Mode = ResizeMode.Max, // 保持宽高比,确保不超过指定尺寸
|
||
|
|
Sampler = KnownResamplers.Lanczos3 // 高质量缩放算法
|
||
|
|
}));
|
||
|
|
|
||
|
|
// 将 ImageSharp 图像转换为 Avalonia Bitmap
|
||
|
|
using var memoryStream = new MemoryStream();
|
||
|
|
image.SaveAsPng(memoryStream); // 保存为 PNG 格式
|
||
|
|
memoryStream.Position = 0;
|
||
|
|
|
||
|
|
var bitmap = new Bitmap(memoryStream);
|
||
|
|
|
||
|
|
// 缓存缩略图
|
||
|
|
_thumbnailCache.TryAdd(filePath, bitmap);
|
||
|
|
|
||
|
|
return bitmap;
|
||
|
|
}
|
||
|
|
catch
|
||
|
|
{
|
||
|
|
// 加载失败,缓存 null 避免重复尝试
|
||
|
|
_thumbnailCache.TryAdd(filePath, null);
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// <summary>
|
||
|
|
/// 清除缩略图缓存(静态方法,用于清理资源)
|
||
|
|
/// </summary>
|
||
|
|
public static void ClearThumbnailCache()
|
||
|
|
{
|
||
|
|
foreach (var bitmap in _thumbnailCache.Values)
|
||
|
|
{
|
||
|
|
bitmap?.Dispose();
|
||
|
|
}
|
||
|
|
_thumbnailCache.Clear();
|
||
|
|
}
|
||
|
|
|
||
|
|
/// <summary>
|
||
|
|
/// 格式化文件大小
|
||
|
|
/// </summary>
|
||
|
|
public string FormattedFileSize
|
||
|
|
{
|
||
|
|
get
|
||
|
|
{
|
||
|
|
if (_fileSize < 1024) return $"{_fileSize} B";
|
||
|
|
if (_fileSize < 1024 * 1024) return $"{_fileSize / 1024.0:F2} KB";
|
||
|
|
return $"{_fileSize / (1024.0 * 1024.0):F2} MB";
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|