|
|
|
|
using AuroraDesk.Presentation.ViewModels.Base;
|
|
|
|
|
using AuroraDesk.Presentation.Services;
|
|
|
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
|
using ReactiveUI;
|
|
|
|
|
using System;
|
|
|
|
|
using System.Collections.Generic;
|
|
|
|
|
using System.Collections.ObjectModel;
|
|
|
|
|
using System.IO;
|
|
|
|
|
using System.Linq;
|
|
|
|
|
using System.Reactive;
|
|
|
|
|
using System.Threading;
|
|
|
|
|
using System.Threading.Channels;
|
|
|
|
|
using System.Threading.Tasks;
|
|
|
|
|
using Avalonia.Controls;
|
|
|
|
|
using Avalonia.Controls.ApplicationLifetimes;
|
|
|
|
|
using Avalonia.Media.Imaging;
|
|
|
|
|
using Avalonia.Platform.Storage;
|
|
|
|
|
using Avalonia.Threading;
|
|
|
|
|
|
|
|
|
|
namespace AuroraDesk.Presentation.ViewModels.Pages;
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// 图片浏览页面 ViewModel
|
|
|
|
|
/// 参考 IconsPageViewModel 实现真正的按需加载
|
|
|
|
|
///
|
|
|
|
|
/// 核心改进:
|
|
|
|
|
/// 1. 初始仅加载前 30 个 ImageMetadata
|
|
|
|
|
/// 2. 滚动接近底部时再加载下一批(每次 50 个)
|
|
|
|
|
/// 3. 只创建当前需要显示的元数据对象
|
|
|
|
|
/// 4. 对 10 万张图片,内存占用显著降低
|
|
|
|
|
/// </summary>
|
|
|
|
|
public class ImageGalleryPageViewModel : RoutableViewModel
|
|
|
|
|
{
|
|
|
|
|
private string _selectedDirectory = string.Empty;
|
|
|
|
|
private ObservableCollection<ImageMetadata> _images = new();
|
|
|
|
|
private ImageMetadata? _selectedImage;
|
|
|
|
|
private bool _isLoading;
|
|
|
|
|
private int _totalImageCount;
|
|
|
|
|
private string _statusMessage = "请选择一个包含图片的目录";
|
|
|
|
|
private CancellationTokenSource? _loadingCancellationTokenSource;
|
|
|
|
|
private bool _isLoadingMore; // 是否正在加载更多
|
|
|
|
|
|
|
|
|
|
private readonly ILogger<ImageGalleryPageViewModel>? _logger;
|
|
|
|
|
|
|
|
|
|
// 数据层:只保存文件路径列表(List<string>),不加载实际图片对象
|
|
|
|
|
private List<string> _allFilePaths = new();
|
|
|
|
|
|
|
|
|
|
// 已加载的元数据数量(跟踪已加载数量)
|
|
|
|
|
private int _loadedMetadataCount = 0;
|
|
|
|
|
|
|
|
|
|
// 支持的图片格式
|
|
|
|
|
private static readonly string[] SupportedImageExtensions =
|
|
|
|
|
{
|
|
|
|
|
".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".ico"
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 初始加载数量
|
|
|
|
|
private const int InitialBatchSize = 30;
|
|
|
|
|
|
|
|
|
|
// 每次加载更多时的批次大小
|
|
|
|
|
private const int IncrementalBatchSize = 50;
|
|
|
|
|
|
|
|
|
|
public ObservableCollection<ImageMetadata> Images
|
|
|
|
|
{
|
|
|
|
|
get => _images;
|
|
|
|
|
set => this.RaiseAndSetIfChanged(ref _images, value);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public ImageMetadata? 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 string StatusMessage
|
|
|
|
|
{
|
|
|
|
|
get => _statusMessage;
|
|
|
|
|
set => this.RaiseAndSetIfChanged(ref _statusMessage, value);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 命令
|
|
|
|
|
public ReactiveCommand<Unit, Unit> SelectDirectoryCommand { get; }
|
|
|
|
|
public ReactiveCommand<ImageMetadata, Unit> SelectImageCommand { get; }
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// 构造函数
|
|
|
|
|
/// </summary>
|
|
|
|
|
public ImageGalleryPageViewModel(
|
|
|
|
|
IScreen hostScreen,
|
|
|
|
|
ILogger<ImageGalleryPageViewModel>? logger = null)
|
|
|
|
|
: base(hostScreen, "ImageGallery")
|
|
|
|
|
{
|
|
|
|
|
_logger = logger;
|
|
|
|
|
|
|
|
|
|
// 创建命令
|
|
|
|
|
SelectDirectoryCommand = ReactiveCommand.CreateFromTask(SelectDirectoryAsync);
|
|
|
|
|
SelectImageCommand = ReactiveCommand.Create<ImageMetadata>(SelectImage);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <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>
|
|
|
|
|
/// 从目录加载图片(数据加载层)
|
|
|
|
|
/// 1. 扫描所有图片文件路径
|
|
|
|
|
/// 2. 初始仅加载前 30 个 ImageMetadata
|
|
|
|
|
/// 3. 后续通过 LoadMoreImagesAsync 按需加载
|
|
|
|
|
/// </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;
|
|
|
|
|
_allFilePaths.Clear();
|
|
|
|
|
_loadedMetadataCount = 0;
|
|
|
|
|
TotalImageCount = 0;
|
|
|
|
|
|
|
|
|
|
_logger?.LogInformation("开始从目录加载图片: {Directory}", directoryPath);
|
|
|
|
|
|
|
|
|
|
if (!Directory.Exists(directoryPath))
|
|
|
|
|
{
|
|
|
|
|
StatusMessage = "目录不存在";
|
|
|
|
|
IsLoading = false;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 第一步:异步扫描目录,收集所有图片路径
|
|
|
|
|
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 UpdateStatusAsync($"已找到 {_allFilePaths.Count} 个图片文件...", _allFilePaths.Count);
|
|
|
|
|
await Task.Yield();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
catch (OperationCanceledException)
|
|
|
|
|
{
|
|
|
|
|
_logger?.LogInformation("文件扫描已取消");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}, cancellationToken);
|
|
|
|
|
|
|
|
|
|
if (cancellationToken.IsCancellationRequested)
|
|
|
|
|
{
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 对文件路径按文件名排序(自然排序)
|
|
|
|
|
_allFilePaths.Sort(NaturalStringComparer.Instance);
|
|
|
|
|
|
|
|
|
|
TotalImageCount = _allFilePaths.Count;
|
|
|
|
|
_logger?.LogInformation("找到 {Count} 张图片", TotalImageCount);
|
|
|
|
|
|
|
|
|
|
if (TotalImageCount == 0)
|
|
|
|
|
{
|
|
|
|
|
await UpdateStatusAsync("未找到图片文件", isLoading: false);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 第二步:初始仅加载前 30 个 ImageMetadata(真正的按需加载)
|
|
|
|
|
await LoadMoreImagesAsync(InitialBatchSize);
|
|
|
|
|
|
|
|
|
|
_logger?.LogInformation("图片加载完成,初始加载: {Count} 张,总数: {Total}", _loadedMetadataCount, TotalImageCount);
|
|
|
|
|
|
|
|
|
|
// 初始加载完成后,延迟检查是否需要继续加载更多
|
|
|
|
|
// 等待UI布局完成后再检查,确保有足够内容填满视口
|
|
|
|
|
_ = Task.Run(async () =>
|
|
|
|
|
{
|
|
|
|
|
await Task.Delay(500);
|
|
|
|
|
if (_loadedMetadataCount < _allFilePaths.Count && !_isLoadingMore)
|
|
|
|
|
{
|
|
|
|
|
// 再次检查并加载更多,确保有足够内容填满视口
|
|
|
|
|
await LoadMoreImagesAsync(IncrementalBatchSize);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
catch (OperationCanceledException)
|
|
|
|
|
{
|
|
|
|
|
_logger?.LogInformation("图片加载已取消");
|
|
|
|
|
await UpdateStatusAsync("加载已取消", isLoading: false);
|
|
|
|
|
}
|
|
|
|
|
catch (Exception ex)
|
|
|
|
|
{
|
|
|
|
|
_logger?.LogError(ex, "加载图片时发生错误");
|
|
|
|
|
await UpdateStatusAsync($"加载图片失败: {ex.Message}", isLoading: false);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// 加载更多图片(按需加载)
|
|
|
|
|
/// 参考 IconsPageViewModel 的流式加载策略
|
|
|
|
|
/// </summary>
|
|
|
|
|
public async Task LoadMoreImagesAsync(int count = IncrementalBatchSize)
|
|
|
|
|
{
|
|
|
|
|
if (_isLoadingMore || _loadedMetadataCount >= _allFilePaths.Count)
|
|
|
|
|
return;
|
|
|
|
|
|
|
|
|
|
_isLoadingMore = true;
|
|
|
|
|
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
// 计算需要加载的文件路径范围
|
|
|
|
|
var startIndex = _loadedMetadataCount;
|
|
|
|
|
var endIndex = Math.Min(_loadedMetadataCount + count, _allFilePaths.Count);
|
|
|
|
|
var filePathsToLoad = _allFilePaths.Skip(startIndex).Take(endIndex - startIndex).ToList();
|
|
|
|
|
|
|
|
|
|
if (filePathsToLoad.Count == 0)
|
|
|
|
|
return;
|
|
|
|
|
|
|
|
|
|
_logger?.LogInformation("开始加载更多图片: {Start} - {End} (共 {Count} 张)",
|
|
|
|
|
startIndex, endIndex, filePathsToLoad.Count);
|
|
|
|
|
|
|
|
|
|
// 在后台线程创建元数据
|
|
|
|
|
var metadataList = await CreateImageMetadataAsync(filePathsToLoad, CancellationToken.None);
|
|
|
|
|
|
|
|
|
|
// 在 UI 线程批量添加
|
|
|
|
|
await Dispatcher.UIThread.InvokeAsync(() =>
|
|
|
|
|
{
|
|
|
|
|
var wasEmpty = _images.Count == 0;
|
|
|
|
|
|
|
|
|
|
foreach (var metadata in metadataList)
|
|
|
|
|
{
|
|
|
|
|
_images.Add(metadata);
|
|
|
|
|
// 触发缩略图加载(按需加载)
|
|
|
|
|
metadata.EnsureThumbnailLoaded();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 只在第一次加载时触发 PropertyChanged
|
|
|
|
|
if (wasEmpty)
|
|
|
|
|
{
|
|
|
|
|
this.RaisePropertyChanged(nameof(Images));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_loadedMetadataCount = _images.Count;
|
|
|
|
|
IsLoading = false;
|
|
|
|
|
StatusMessage = $"已加载 {_loadedMetadataCount} / {TotalImageCount} 张图片";
|
|
|
|
|
}, DispatcherPriority.Background);
|
|
|
|
|
|
|
|
|
|
_logger?.LogInformation("加载更多图片完成: 已加载 {Loaded} / {Total}", _loadedMetadataCount, TotalImageCount);
|
|
|
|
|
}
|
|
|
|
|
catch (Exception ex)
|
|
|
|
|
{
|
|
|
|
|
_logger?.LogError(ex, "加载更多图片时发生错误");
|
|
|
|
|
}
|
|
|
|
|
finally
|
|
|
|
|
{
|
|
|
|
|
_isLoadingMore = false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// 异步枚举图片文件(流式处理)
|
|
|
|
|
/// </summary>
|
|
|
|
|
private async IAsyncEnumerable<string> EnumerateImageFilesAsync(
|
|
|
|
|
string directoryPath,
|
|
|
|
|
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
|
|
|
|
{
|
|
|
|
|
var channel = Channel.CreateUnbounded<string>();
|
|
|
|
|
var writer = channel.Writer;
|
|
|
|
|
|
|
|
|
|
_ = Task.Run(async () =>
|
|
|
|
|
{
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
var dirQueue = new Queue<string>();
|
|
|
|
|
dirQueue.Enqueue(directoryPath);
|
|
|
|
|
|
|
|
|
|
while (dirQueue.Count > 0 && !cancellationToken.IsCancellationRequested)
|
|
|
|
|
{
|
|
|
|
|
var currentDir = dirQueue.Dequeue();
|
|
|
|
|
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
// 枚举当前目录的文件
|
|
|
|
|
foreach (var file in Directory.EnumerateFiles(currentDir, "*.*", SearchOption.TopDirectoryOnly))
|
|
|
|
|
{
|
|
|
|
|
if (cancellationToken.IsCancellationRequested)
|
|
|
|
|
return;
|
|
|
|
|
|
|
|
|
|
var ext = Path.GetExtension(file).ToLowerInvariant();
|
|
|
|
|
if (SupportedImageExtensions.Contains(ext))
|
|
|
|
|
{
|
|
|
|
|
await writer.WriteAsync(file, cancellationToken);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 枚举子目录(递归)
|
|
|
|
|
foreach (var subDir in Directory.EnumerateDirectories(currentDir, "*", SearchOption.TopDirectoryOnly))
|
|
|
|
|
{
|
|
|
|
|
if (cancellationToken.IsCancellationRequested)
|
|
|
|
|
return;
|
|
|
|
|
|
|
|
|
|
dirQueue.Enqueue(subDir);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
catch (UnauthorizedAccessException)
|
|
|
|
|
{
|
|
|
|
|
_logger?.LogWarning("无权限访问目录: {Directory}", currentDir);
|
|
|
|
|
}
|
|
|
|
|
catch (DirectoryNotFoundException)
|
|
|
|
|
{
|
|
|
|
|
// 跳过不存在的目录
|
|
|
|
|
}
|
|
|
|
|
catch (Exception ex)
|
|
|
|
|
{
|
|
|
|
|
_logger?.LogWarning(ex, "处理目录时出错: {Directory}", currentDir);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
finally
|
|
|
|
|
{
|
|
|
|
|
writer.Complete();
|
|
|
|
|
}
|
|
|
|
|
}, cancellationToken);
|
|
|
|
|
|
|
|
|
|
// 流式返回找到的文件
|
|
|
|
|
await foreach (var filePath in channel.Reader.ReadAllAsync(cancellationToken))
|
|
|
|
|
{
|
|
|
|
|
yield return filePath;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// 创建图片元数据(轻量数据结构)
|
|
|
|
|
/// </summary>
|
|
|
|
|
private async Task<List<ImageMetadata>> CreateImageMetadataAsync(
|
|
|
|
|
IEnumerable<string> filePaths,
|
|
|
|
|
CancellationToken cancellationToken)
|
|
|
|
|
{
|
|
|
|
|
var filePathsList = filePaths.ToList();
|
|
|
|
|
var metadataList = new List<ImageMetadata>(filePathsList.Count);
|
|
|
|
|
|
|
|
|
|
await Task.Run(() =>
|
|
|
|
|
{
|
|
|
|
|
foreach (var filePath in filePathsList)
|
|
|
|
|
{
|
|
|
|
|
if (cancellationToken.IsCancellationRequested)
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
var fileInfo = new FileInfo(filePath);
|
|
|
|
|
if (fileInfo.Exists)
|
|
|
|
|
{
|
|
|
|
|
var metadata = new ImageMetadata
|
|
|
|
|
{
|
|
|
|
|
FilePath = filePath,
|
|
|
|
|
FileName = Path.GetFileName(filePath),
|
|
|
|
|
FileSize = fileInfo.Length,
|
|
|
|
|
LastModified = fileInfo.LastWriteTime
|
|
|
|
|
};
|
|
|
|
|
metadataList.Add(metadata);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
catch
|
|
|
|
|
{
|
|
|
|
|
// 忽略文件信息获取失败
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}, cancellationToken);
|
|
|
|
|
|
|
|
|
|
return metadataList;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void SelectImage(ImageMetadata image)
|
|
|
|
|
{
|
|
|
|
|
if (image == null) return;
|
|
|
|
|
|
|
|
|
|
SelectedImage = image;
|
|
|
|
|
_logger?.LogInformation("选择图片: {FileName}", image.FileName);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// 清理资源(取消正在进行的加载任务)
|
|
|
|
|
/// </summary>
|
|
|
|
|
public void CancelLoading()
|
|
|
|
|
{
|
|
|
|
|
_loadingCancellationTokenSource?.Cancel();
|
|
|
|
|
_loadingCancellationTokenSource?.Dispose();
|
|
|
|
|
_loadingCancellationTokenSource = null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// 更新状态消息(UI 线程安全)
|
|
|
|
|
/// </summary>
|
|
|
|
|
private async Task UpdateStatusAsync(
|
|
|
|
|
string message,
|
|
|
|
|
int? totalCount = null,
|
|
|
|
|
bool? isLoading = null)
|
|
|
|
|
{
|
|
|
|
|
await Dispatcher.UIThread.InvokeAsync(() =>
|
|
|
|
|
{
|
|
|
|
|
StatusMessage = message;
|
|
|
|
|
if (totalCount.HasValue)
|
|
|
|
|
TotalImageCount = totalCount.Value;
|
|
|
|
|
if (isLoading.HasValue)
|
|
|
|
|
IsLoading = isLoading.Value;
|
|
|
|
|
}, DispatcherPriority.Background);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// 图片元数据(轻量数据结构)
|
|
|
|
|
/// 按照 images.md:只包含文件名、大小、修改时间
|
|
|
|
|
/// </summary>
|
|
|
|
|
public class ImageMetadata : ReactiveObject
|
|
|
|
|
{
|
|
|
|
|
private string _filePath = string.Empty;
|
|
|
|
|
private string _fileName = string.Empty;
|
|
|
|
|
private long _fileSize;
|
|
|
|
|
private DateTime _lastModified;
|
|
|
|
|
private Bitmap? _thumbnailSource;
|
|
|
|
|
private bool _isLoadingThumbnail;
|
|
|
|
|
|
|
|
|
|
// 缩略图缓存服务(单例)
|
|
|
|
|
private static readonly ThumbnailCacheService _thumbnailCache = ThumbnailCacheService.Instance;
|
|
|
|
|
|
|
|
|
|
public string FilePath
|
|
|
|
|
{
|
|
|
|
|
get => _filePath;
|
|
|
|
|
set => this.RaiseAndSetIfChanged(ref _filePath, value);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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>
|
|
|
|
|
/// 缩略图源(异步加载)
|
|
|
|
|
/// 按照 images.md:后台线程池按需生成缩略图
|
|
|
|
|
/// </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>
|
|
|
|
|
/// 确保缩略图已加载(按需加载)
|
|
|
|
|
/// 按照 images.md:优先渲染可见项的缩略图
|
|
|
|
|
/// </summary>
|
|
|
|
|
public void EnsureThumbnailLoaded()
|
|
|
|
|
{
|
|
|
|
|
if (_thumbnailSource != null || _isLoadingThumbnail || string.IsNullOrEmpty(_filePath))
|
|
|
|
|
return;
|
|
|
|
|
|
|
|
|
|
_ = LoadThumbnailAsync();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// 异步加载缩略图(使用 ThumbnailCacheService)
|
|
|
|
|
/// 按照 images.md:支持内存LRU缓存和磁盘缓存
|
|
|
|
|
/// </summary>
|
|
|
|
|
private async Task LoadThumbnailAsync()
|
|
|
|
|
{
|
|
|
|
|
if (string.IsNullOrEmpty(_filePath) || !File.Exists(_filePath))
|
|
|
|
|
{
|
|
|
|
|
ThumbnailSource = null;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
IsLoadingThumbnail = true;
|
|
|
|
|
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
// 使用 ThumbnailCacheService 加载缩略图(自动处理内存+磁盘缓存)
|
|
|
|
|
var thumbnail = await _thumbnailCache.GetThumbnailAsync(_filePath);
|
|
|
|
|
|
|
|
|
|
// 在UI线程更新
|
|
|
|
|
await Dispatcher.UIThread.InvokeAsync(() =>
|
|
|
|
|
{
|
|
|
|
|
ThumbnailSource = thumbnail;
|
|
|
|
|
IsLoadingThumbnail = false;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
catch
|
|
|
|
|
{
|
|
|
|
|
await Dispatcher.UIThread.InvokeAsync(() =>
|
|
|
|
|
{
|
|
|
|
|
ThumbnailSource = null;
|
|
|
|
|
IsLoadingThumbnail = false;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <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";
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// 自然字符串比较器(支持数字文件名排序)
|
|
|
|
|
/// </summary>
|
|
|
|
|
internal class NaturalStringComparer : IComparer<string>
|
|
|
|
|
{
|
|
|
|
|
public static readonly NaturalStringComparer Instance = new();
|
|
|
|
|
|
|
|
|
|
public int Compare(string? x, string? y)
|
|
|
|
|
{
|
|
|
|
|
if (x == null && y == null) return 0;
|
|
|
|
|
if (x == null) return -1;
|
|
|
|
|
if (y == null) return 1;
|
|
|
|
|
|
|
|
|
|
var fileNameX = Path.GetFileName(x);
|
|
|
|
|
var fileNameY = Path.GetFileName(y);
|
|
|
|
|
|
|
|
|
|
var fileNameCompare = CompareNatural(fileNameX, fileNameY);
|
|
|
|
|
if (fileNameCompare != 0)
|
|
|
|
|
return fileNameCompare;
|
|
|
|
|
|
|
|
|
|
return string.Compare(x, y, StringComparison.OrdinalIgnoreCase);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static int CompareNatural(string? x, string? y)
|
|
|
|
|
{
|
|
|
|
|
if (x == null && y == null) return 0;
|
|
|
|
|
if (x == null) return -1;
|
|
|
|
|
if (y == null) return 1;
|
|
|
|
|
|
|
|
|
|
int i = 0, j = 0;
|
|
|
|
|
|
|
|
|
|
while (i < x.Length && j < y.Length)
|
|
|
|
|
{
|
|
|
|
|
if (char.IsDigit(x[i]) && char.IsDigit(y[j]))
|
|
|
|
|
{
|
|
|
|
|
int numX = 0, numY = 0;
|
|
|
|
|
|
|
|
|
|
while (i < x.Length && char.IsDigit(x[i]))
|
|
|
|
|
{
|
|
|
|
|
numX = numX * 10 + (x[i] - '0');
|
|
|
|
|
i++;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
while (j < y.Length && char.IsDigit(y[j]))
|
|
|
|
|
{
|
|
|
|
|
numY = numY * 10 + (y[j] - '0');
|
|
|
|
|
j++;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (numX != numY)
|
|
|
|
|
return numX.CompareTo(numY);
|
|
|
|
|
}
|
|
|
|
|
else
|
|
|
|
|
{
|
|
|
|
|
int charCompare = char.ToLowerInvariant(x[i]).CompareTo(char.ToLowerInvariant(y[j]));
|
|
|
|
|
if (charCompare != 0)
|
|
|
|
|
return charCompare;
|
|
|
|
|
|
|
|
|
|
i++;
|
|
|
|
|
j++;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return x.Length.CompareTo(y.Length);
|
|
|
|
|
}
|
|
|
|
|
}
|