fead.文件管理组件加入

This commit is contained in:
lincube
2026-04-04 03:28:51 +08:00
parent 5d2449fa8f
commit 12a2f6729b
6 changed files with 756 additions and 1 deletions

View File

@@ -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";
}

View File

@@ -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)
};

View File

@@ -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
};
}
}

View File

@@ -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())
];
}

View File

@@ -0,0 +1,138 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:fi="using:FluentIcons.Avalonia"
mc:Ignorable="d"
d:DesignWidth="320"
d:DesignHeight="320"
x:Class="LanMountainDesktop.Views.Components.FileManagerWidget">
<Border x:Name="RootBorder"
Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
BorderThickness="1"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
Padding="12,10"
ClipToBounds="True">
<Grid RowDefinitions="Auto,Auto,*">
<!-- 导航栏 -->
<Grid Grid.Row="0"
ColumnDefinitions="Auto,Auto,*,Auto"
ColumnSpacing="6">
<!-- 返回按钮 -->
<Button x:Name="BackButton"
Grid.Column="0"
Width="32"
Height="32"
Padding="0"
CornerRadius="16"
Background="{DynamicResource AdaptiveSurfaceOverlayBrush}"
BorderThickness="0"
Click="OnBackButtonClick">
<fi:SymbolIcon Symbol="ArrowLeft"
FontSize="14"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
</Button>
<!-- 主页/盘符按钮 -->
<Button x:Name="HomeButton"
Grid.Column="1"
Width="32"
Height="32"
Padding="0"
CornerRadius="16"
Background="{DynamicResource AdaptiveSurfaceOverlayBrush}"
BorderThickness="0"
Click="OnHomeButtonClick">
<fi:SymbolIcon Symbol="Home"
FontSize="14"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
</Button>
<!-- 路径显示 -->
<Border Grid.Column="2"
Background="{DynamicResource AdaptiveSurfaceOverlayBrush}"
CornerRadius="{DynamicResource DesignCornerRadiusSm}"
Padding="10,0"
VerticalAlignment="Center"
Height="32">
<TextBlock x:Name="PathTextBlock"
VerticalAlignment="Center"
FontSize="13"
FontWeight="Medium"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
TextTrimming="CharacterEllipsis"
ToolTip.Tip="{Binding $self.Text}"
Text="此电脑" />
</Border>
<!-- 刷新按钮 -->
<Button x:Name="RefreshButton"
Grid.Column="3"
Width="32"
Height="32"
Padding="0"
CornerRadius="16"
Background="{DynamicResource AdaptiveSurfaceOverlayBrush}"
BorderThickness="0"
Click="OnRefreshButtonClick">
<fi:SymbolIcon Symbol="ArrowSync"
FontSize="14"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
</Button>
</Grid>
<!-- 分隔线 -->
<Border Grid.Row="1"
Height="1"
Margin="0,10"
Background="{DynamicResource AdaptiveDividerBrush}" />
<!-- 文件列表 -->
<ScrollViewer Grid.Row="2"
HorizontalScrollBarVisibility="Disabled"
VerticalScrollBarVisibility="Auto">
<ItemsControl x:Name="FileItemsControl">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel Orientation="Horizontal" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
</ItemsControl>
</ScrollViewer>
<!-- 空状态 -->
<StackPanel x:Name="EmptyStatePanel"
Grid.Row="2"
IsVisible="False"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Spacing="8">
<fi:SymbolIcon Symbol="FolderOpen"
FontSize="40"
Foreground="{DynamicResource AdaptiveTextMutedBrush}" />
<TextBlock x:Name="EmptyStateTextBlock"
Text="文件夹为空"
FontSize="13"
Foreground="{DynamicResource AdaptiveTextMutedBrush}" />
</StackPanel>
<!-- 错误状态 -->
<StackPanel x:Name="ErrorStatePanel"
Grid.Row="2"
IsVisible="False"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Spacing="8">
<fi:SymbolIcon Symbol="ErrorCircle"
FontSize="40"
Foreground="{DynamicResource AdaptiveErrorBrush}" />
<TextBlock x:Name="ErrorStateTextBlock"
Text="无法访问此文件夹"
FontSize="13"
Foreground="{DynamicResource AdaptiveErrorBrush}" />
</StackPanel>
</Grid>
</Border>
</UserControl>

View File

@@ -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<string> _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<FileSystemItem>();
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<FileSystemItem>();
// 添加子文件夹
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<FileSystemItem> 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]}";
}
}