From 12a2f6729b5de17a78f26f87250e0265fb103b73 Mon Sep 17 00:00:00 2001 From: lincube Date: Sat, 4 Apr 2026 03:28:51 +0800 Subject: [PATCH] =?UTF-8?q?fead.=E6=96=87=E4=BB=B6=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E7=BB=84=E4=BB=B6=E5=8A=A0=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ComponentSystem/BuiltInComponentIds.cs | 1 + .../ComponentSystem/ComponentRegistry.cs | 10 + LanMountainDesktop/Models/FileSystemItem.cs | 87 +++ .../DesktopComponentRuntimeRegistry.cs | 6 +- .../Views/Components/FileManagerWidget.axaml | 138 +++++ .../Components/FileManagerWidget.axaml.cs | 515 ++++++++++++++++++ 6 files changed, 756 insertions(+), 1 deletion(-) create mode 100644 LanMountainDesktop/Models/FileSystemItem.cs create mode 100644 LanMountainDesktop/Views/Components/FileManagerWidget.axaml create mode 100644 LanMountainDesktop/Views/Components/FileManagerWidget.axaml.cs diff --git a/LanMountainDesktop/ComponentSystem/BuiltInComponentIds.cs b/LanMountainDesktop/ComponentSystem/BuiltInComponentIds.cs index 8a8633f..11d04b1 100644 --- a/LanMountainDesktop/ComponentSystem/BuiltInComponentIds.cs +++ b/LanMountainDesktop/ComponentSystem/BuiltInComponentIds.cs @@ -44,4 +44,5 @@ public static class BuiltInComponentIds public const string DesktopOfficeRecentDocuments = "DesktopOfficeRecentDocuments"; public const string DesktopRemovableStorage = "DesktopRemovableStorage"; public const string DesktopZhiJiaoHub = "DesktopZhiJiaoHub"; + public const string DesktopFileManager = "DesktopFileManager"; } diff --git a/LanMountainDesktop/ComponentSystem/ComponentRegistry.cs b/LanMountainDesktop/ComponentSystem/ComponentRegistry.cs index a8a9022..5234499 100644 --- a/LanMountainDesktop/ComponentSystem/ComponentRegistry.cs +++ b/LanMountainDesktop/ComponentSystem/ComponentRegistry.cs @@ -400,6 +400,16 @@ public sealed class ComponentRegistry MinHeightCells: 2, AllowStatusBarPlacement: false, AllowDesktopPlacement: true, + ResizeMode: DesktopComponentResizeMode.Free), + new DesktopComponentDefinition( + BuiltInComponentIds.DesktopFileManager, + "文件管理", + "Folder", + "File", + MinWidthCells: 4, + MinHeightCells: 4, + AllowStatusBarPlacement: false, + AllowDesktopPlacement: true, ResizeMode: DesktopComponentResizeMode.Free) }; diff --git a/LanMountainDesktop/Models/FileSystemItem.cs b/LanMountainDesktop/Models/FileSystemItem.cs new file mode 100644 index 0000000..2a57cb8 --- /dev/null +++ b/LanMountainDesktop/Models/FileSystemItem.cs @@ -0,0 +1,87 @@ +using System; +using System.IO; + +namespace LanMountainDesktop.Models; + +public enum FileSystemItemType +{ + Drive, + Directory, + File +} + +public sealed class FileSystemItem +{ + public string Name { get; init; } = string.Empty; + public string FullPath { get; init; } = string.Empty; + public FileSystemItemType ItemType { get; init; } + public long? Size { get; init; } + public DateTime? LastModified { get; init; } + public string? Extension { get; init; } + + public bool IsDirectory => ItemType == FileSystemItemType.Directory || ItemType == FileSystemItemType.Drive; + + public static FileSystemItem FromDriveInfo(DriveInfo drive) + { + string name; + long? size = null; + + try + { + var volumeLabel = drive.VolumeLabel; + name = string.IsNullOrWhiteSpace(volumeLabel) + ? $"{drive.Name.TrimEnd('\\', '/')}" + : $"{volumeLabel} ({drive.Name.TrimEnd('\\', '/').ToUpperInvariant()})"; + } + catch + { + name = $"{drive.Name.TrimEnd('\\', '/')}"; + } + + try + { + var totalSize = drive.TotalSize; + size = totalSize > 0 ? totalSize : null; + } + catch + { + size = null; + } + + return new FileSystemItem + { + Name = name, + FullPath = drive.Name, + ItemType = FileSystemItemType.Drive, + Size = size, + LastModified = null, + Extension = null + }; + } + + public static FileSystemItem FromDirectoryInfo(DirectoryInfo directory) + { + return new FileSystemItem + { + Name = directory.Name, + FullPath = directory.FullName, + ItemType = FileSystemItemType.Directory, + Size = null, + LastModified = directory.LastWriteTime, + Extension = null + }; + } + + public static FileSystemItem FromFileInfo(FileInfo file) + { + return new FileSystemItem + { + Name = file.Name, + FullPath = file.FullName, + ItemType = FileSystemItemType.File, + Size = file.Length, + LastModified = file.LastWriteTime, + Extension = file.Extension + }; + } +} diff --git a/LanMountainDesktop/Views/Components/DesktopComponentRuntimeRegistry.cs b/LanMountainDesktop/Views/Components/DesktopComponentRuntimeRegistry.cs index 0fcc47e..885d521 100644 --- a/LanMountainDesktop/Views/Components/DesktopComponentRuntimeRegistry.cs +++ b/LanMountainDesktop/Views/Components/DesktopComponentRuntimeRegistry.cs @@ -475,7 +475,11 @@ public sealed class DesktopComponentRuntimeRegistry new DesktopComponentRuntimeRegistration( BuiltInComponentIds.DesktopZhiJiaoHub, "component.zhijiao_hub", - () => new ZhiJiaoHubWidget()) + () => new ZhiJiaoHubWidget()), + new DesktopComponentRuntimeRegistration( + BuiltInComponentIds.DesktopFileManager, + "component.file_manager", + () => new FileManagerWidget()) ]; } diff --git a/LanMountainDesktop/Views/Components/FileManagerWidget.axaml b/LanMountainDesktop/Views/Components/FileManagerWidget.axaml new file mode 100644 index 0000000..7f1f06a --- /dev/null +++ b/LanMountainDesktop/Views/Components/FileManagerWidget.axaml @@ -0,0 +1,138 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/LanMountainDesktop/Views/Components/FileManagerWidget.axaml.cs b/LanMountainDesktop/Views/Components/FileManagerWidget.axaml.cs new file mode 100644 index 0000000..29090d0 --- /dev/null +++ b/LanMountainDesktop/Views/Components/FileManagerWidget.axaml.cs @@ -0,0 +1,515 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Media; +using FluentIcons.Avalonia; +using LanMountainDesktop.ComponentSystem; +using LanMountainDesktop.Models; +using LanMountainDesktop.PluginSdk; +using LanMountainDesktop.Services; + +namespace LanMountainDesktop.Views.Components; + +public partial class FileManagerWidget : UserControl, + IDesktopComponentWidget, + IDesktopPageVisibilityAwareComponentWidget, + IComponentPlacementContextAware, + IDisposable +{ + private readonly List _navigationHistory = new(); + private int _currentHistoryIndex = -1; + private string _currentPath = string.Empty; + private string _componentId = BuiltInComponentIds.DesktopFileManager; + private string _placementId = string.Empty; + private double _currentCellSize = 48; + private bool _isOnActivePage; + private bool _isEditMode; + private bool _isAttached; + private bool _isDisposed; + + public FileManagerWidget() + { + InitializeComponent(); + + AttachedToVisualTree += OnAttachedToVisualTree; + DetachedFromVisualTree += OnDetachedFromVisualTree; + SizeChanged += OnSizeChanged; + + ApplyCellSize(_currentCellSize); + NavigateToDrives(); + } + + public void ApplyCellSize(double cellSize) + { + _currentCellSize = Math.Max(1, cellSize); + + var mainRectangleCornerRadius = ComponentChromeCornerRadiusHelper.ResolveMainRectangleRadius(); + RootBorder.CornerRadius = mainRectangleCornerRadius; + RootBorder.Padding = new Thickness( + Math.Clamp(_currentCellSize * 0.25, 10, 20), + Math.Clamp(_currentCellSize * 0.20, 8, 16)); + + ApplyLayoutMetrics(); + } + + public void SetDesktopPageContext(bool isOnActivePage, bool isEditMode) + { + _isOnActivePage = isOnActivePage; + _isEditMode = isEditMode; + + if (_isOnActivePage && _isAttached && !string.IsNullOrEmpty(_currentPath)) + { + RefreshCurrentDirectory(); + } + } + + public void SetComponentPlacementContext(string componentId, string? placementId) + { + _componentId = string.IsNullOrWhiteSpace(componentId) + ? BuiltInComponentIds.DesktopFileManager + : componentId.Trim(); + _placementId = placementId?.Trim() ?? string.Empty; + } + + public void Dispose() + { + if (_isDisposed) + { + return; + } + + _isDisposed = true; + + AttachedToVisualTree -= OnAttachedToVisualTree; + DetachedFromVisualTree -= OnDetachedFromVisualTree; + SizeChanged -= OnSizeChanged; + } + + private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e) + { + _ = sender; + _ = e; + _isAttached = true; + + if (_isOnActivePage) + { + RefreshCurrentDirectory(); + } + } + + private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e) + { + _ = sender; + _ = e; + _isAttached = false; + } + + private void OnSizeChanged(object? sender, SizeChangedEventArgs e) + { + _ = sender; + _ = e; + ApplyLayoutMetrics(); + } + + private void ApplyLayoutMetrics() + { + var scale = ResolveScale(); + var width = Bounds.Width > 1 ? Bounds.Width : _currentCellSize * 4; + + var buttonSize = Math.Clamp(32 * scale, 28, 40); + var iconSize = Math.Clamp(14 * scale, 12, 18); + var pathFontSize = Math.Clamp(13 * scale, 11, 16); + + BackButton.Width = buttonSize; + BackButton.Height = buttonSize; + BackButton.CornerRadius = new CornerRadius(buttonSize / 2); + + HomeButton.Width = buttonSize; + HomeButton.Height = buttonSize; + HomeButton.CornerRadius = new CornerRadius(buttonSize / 2); + + RefreshButton.Width = buttonSize; + RefreshButton.Height = buttonSize; + RefreshButton.CornerRadius = new CornerRadius(buttonSize / 2); + + PathTextBlock.FontSize = pathFontSize; + + if (BackButton.Content is SymbolIcon backIcon) + { + backIcon.FontSize = iconSize; + } + + if (HomeButton.Content is SymbolIcon homeIcon) + { + homeIcon.FontSize = iconSize; + } + + if (RefreshButton.Content is SymbolIcon refreshIcon) + { + refreshIcon.FontSize = iconSize; + } + } + + private double ResolveScale() + { + var cellScale = Math.Clamp(_currentCellSize / 48d, 0.72, 2.2); + var widthScale = Bounds.Width > 1 ? Math.Clamp(Bounds.Width / 280d, 0.72, 2.4) : 1; + var heightScale = Bounds.Height > 1 ? Math.Clamp(Bounds.Height / 280d, 0.72, 2.4) : 1; + return Math.Clamp(Math.Min(cellScale, Math.Min(widthScale, heightScale)), 0.72, 2.2); + } + + private void OnBackButtonClick(object? sender, RoutedEventArgs e) + { + _ = sender; + _ = e; + + if (_currentHistoryIndex > 0) + { + _currentHistoryIndex--; + var path = _navigationHistory[_currentHistoryIndex]; + LoadDirectory(path, addToHistory: false); + } + else if (_currentHistoryIndex == 0 && _navigationHistory.Count > 0) + { + NavigateToDrives(); + } + } + + private void OnHomeButtonClick(object? sender, RoutedEventArgs e) + { + _ = sender; + _ = e; + NavigateToDrives(); + } + + private void OnRefreshButtonClick(object? sender, RoutedEventArgs e) + { + _ = sender; + _ = e; + RefreshCurrentDirectory(); + } + + private void OnItemPointerPressed(object? sender, PointerPressedEventArgs e) + { + _ = e; + + if (sender is not Border border || border.DataContext is not FileSystemItem item) + { + return; + } + + if (item.IsDirectory) + { + LoadDirectory(item.FullPath, addToHistory: true); + } + else + { + OpenFile(item.FullPath); + } + } + + private void NavigateToDrives() + { + _navigationHistory.Clear(); + _currentHistoryIndex = -1; + _currentPath = string.Empty; + + try + { + var drives = new List(); + + foreach (var drive in DriveInfo.GetDrives()) + { + try + { + if (!drive.IsReady) + { + continue; + } + + var item = FileSystemItem.FromDriveInfo(drive); + drives.Add(item); + } + catch (Exception ex) + { + AppLogger.Warn("FileManagerWidget", $"Failed to access drive: {drive?.Name}", ex); + } + } + + RenderFileItems(drives); + PathTextBlock.Text = "此电脑"; + + UpdateEmptyState(drives.Count == 0, "没有可用的驱动器"); + ErrorStatePanel.IsVisible = false; + } + catch (Exception ex) + { + AppLogger.Warn("FileManagerWidget", "Failed to load drives.", ex); + ShowError("无法加载驱动器列表"); + } + } + + private void LoadDirectory(string path, bool addToHistory) + { + if (string.IsNullOrWhiteSpace(path)) + { + NavigateToDrives(); + return; + } + + try + { + var directoryInfo = new DirectoryInfo(path); + + if (!directoryInfo.Exists) + { + ShowError("文件夹不存在"); + return; + } + + var items = new List(); + + // 添加子文件夹 + try + { + var directories = directoryInfo.GetDirectories() + .Where(d => (d.Attributes & FileAttributes.Hidden) == 0) + .OrderBy(d => d.Name) + .Select(FileSystemItem.FromDirectoryInfo); + items.AddRange(directories); + } + catch (UnauthorizedAccessException) + { + // 忽略无权限访问的文件夹 + } + + // 添加文件 + try + { + var files = directoryInfo.GetFiles() + .Where(f => (f.Attributes & FileAttributes.Hidden) == 0) + .OrderBy(f => f.Name) + .Select(FileSystemItem.FromFileInfo); + items.AddRange(files); + } + catch (UnauthorizedAccessException) + { + // 忽略无权限访问的文件 + } + + RenderFileItems(items); + _currentPath = path; + PathTextBlock.Text = FormatPathForDisplay(path); + + if (addToHistory) + { + // 移除当前位置之后的历史记录 + if (_currentHistoryIndex < _navigationHistory.Count - 1) + { + _navigationHistory.RemoveRange(_currentHistoryIndex + 1, _navigationHistory.Count - _currentHistoryIndex - 1); + } + + _navigationHistory.Add(path); + _currentHistoryIndex = _navigationHistory.Count - 1; + } + + UpdateEmptyState(items.Count == 0, "文件夹为空"); + ErrorStatePanel.IsVisible = false; + } + catch (UnauthorizedAccessException) + { + ShowError("没有权限访问此文件夹"); + } + catch (Exception ex) + { + AppLogger.Warn("FileManagerWidget", $"Failed to load directory: {path}", ex); + ShowError("无法加载文件夹内容"); + } + } + + private void RenderFileItems(List items) + { + FileItemsControl.ItemsSource = null; + FileItemsControl.Items.Clear(); + + foreach (var item in items) + { + var itemControl = CreateFileItemControl(item); + FileItemsControl.Items.Add(itemControl); + } + } + + private Control CreateFileItemControl(FileSystemItem item) + { + var scale = ResolveScale(); + var itemWidth = Math.Clamp(72 * scale, 64, 96); + var itemHeight = Math.Clamp(80 * scale, 72, 108); + var iconSize = Math.Clamp(32 * scale, 24, 40); + var fontSize = Math.Clamp(11 * scale, 10, 14); + + // 根据类型选择图标 + var symbol = item.ItemType switch + { + FileSystemItemType.Drive => FluentIcons.Common.Symbol.HardDrive, + FileSystemItemType.Directory => FluentIcons.Common.Symbol.Folder, + _ => FluentIcons.Common.Symbol.Document + }; + + var iconBrush = item.ItemType == FileSystemItemType.File + ? this.FindResource("AdaptiveTextSecondaryBrush") as IBrush ?? new SolidColorBrush(Colors.Gray) + : this.FindResource("AdaptiveAccentBrush") as IBrush ?? new SolidColorBrush(Colors.DodgerBlue); + + var textBrush = this.FindResource("AdaptiveTextPrimaryBrush") as IBrush ?? new SolidColorBrush(Colors.White); + + var border = new Border + { + Width = itemWidth, + Height = itemHeight, + Margin = new Thickness(4), + CornerRadius = new CornerRadius(8), + Background = new SolidColorBrush(Colors.Transparent), + Cursor = new Cursor(StandardCursorType.Hand), + DataContext = item + }; + + var grid = new Grid + { + RowDefinitions = new RowDefinitions("*,Auto"), + Margin = new Thickness(4) + }; + + // 图标 + var icon = new SymbolIcon + { + Symbol = symbol, + FontSize = iconSize, + Foreground = iconBrush, + HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center, + VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center + }; + + // 名称 + var textBlock = new TextBlock + { + Text = item.Name, + FontSize = fontSize, + TextAlignment = TextAlignment.Center, + TextTrimming = TextTrimming.CharacterEllipsis, + MaxLines = 2, + TextWrapping = TextWrapping.Wrap, + Foreground = textBrush + }; + + grid.Children.Add(icon); + Grid.SetRow(icon, 0); + + grid.Children.Add(textBlock); + Grid.SetRow(textBlock, 1); + + border.Child = grid; + + // 添加提示 + ToolTip.SetTip(border, item.Name); + + // 添加点击事件 + border.PointerPressed += OnItemPointerPressed; + + return border; + } + + private void RefreshCurrentDirectory() + { + if (string.IsNullOrEmpty(_currentPath)) + { + NavigateToDrives(); + } + else + { + LoadDirectory(_currentPath, addToHistory: false); + } + } + + private void UpdateEmptyState(bool isEmpty, string message) + { + EmptyStatePanel.IsVisible = isEmpty; + EmptyStateTextBlock.Text = message; + FileItemsControl.IsVisible = !isEmpty; + } + + private void ShowError(string message) + { + ErrorStatePanel.IsVisible = true; + ErrorStateTextBlock.Text = message; + FileItemsControl.IsVisible = false; + EmptyStatePanel.IsVisible = false; + } + + private static void OpenFile(string filePath) + { + try + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + Process.Start(new ProcessStartInfo(filePath) + { + UseShellExecute = true + }); + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + Process.Start("xdg-open", filePath); + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + Process.Start("open", filePath); + } + } + catch (Exception ex) + { + AppLogger.Warn("FileManagerWidget", $"Failed to open file: {filePath}", ex); + } + } + + private static string FormatPathForDisplay(string path) + { + if (string.IsNullOrWhiteSpace(path)) + { + return "此电脑"; + } + + // 如果是驱动器根目录,显示驱动器名称 + if (path.Length <= 3 && path.EndsWith(":\\", StringComparison.OrdinalIgnoreCase)) + { + try + { + var driveInfo = new DriveInfo(path.Substring(0, 1)); + if (!string.IsNullOrWhiteSpace(driveInfo.VolumeLabel)) + { + return $"{driveInfo.VolumeLabel} ({path.Substring(0, 2)})"; + } + } + catch + { + // 忽略错误,返回默认格式 + } + return path; + } + + // 智能路径截断:保留根目录和最后两级 + var parts = path.Split(new[] { '\\', '/' }, StringSplitOptions.RemoveEmptyEntries); + if (parts.Length <= 3) + { + return path; + } + + // 格式:根目录\...\父文件夹\当前文件夹 + return $"{parts[0]}\\...\\{parts[^2]}\\{parts[^1]}"; + } +}