feat.加入快捷方式组件

This commit is contained in:
lincube
2026-04-08 02:09:17 +08:00
parent d30af21317
commit e69bbf8b19
10 changed files with 688 additions and 3 deletions

View File

@@ -26,7 +26,11 @@
## [0.8.3.1] - 2026-04-08 ## [0.8.3.1] - 2026-04-08
### 新增 (Added) ### 新增 (Added)
- 初始化更新日志文档,为后续版本发布建立基础 - **快捷方式组件**: 新增快捷方式组件,可在阑山桌面内便捷打开系统应用与文件
- 支持创建快捷方式,统一管理应用和文件
- 提供单击打开和双击打开两种交互模式
- 支持配置是否显示背景
- 📝 初始化更新日志文档,为后续版本发布建立基础
### 变更 (Changed) ### 变更 (Changed)
- -

View File

@@ -46,4 +46,5 @@ public static class BuiltInComponentIds
public const string DesktopZhiJiaoHub = "DesktopZhiJiaoHub"; public const string DesktopZhiJiaoHub = "DesktopZhiJiaoHub";
public const string DesktopFileManager = "DesktopFileManager"; public const string DesktopFileManager = "DesktopFileManager";
public const string DesktopNotificationBox = "DesktopNotificationBox"; public const string DesktopNotificationBox = "DesktopNotificationBox";
public const string DesktopShortcut = "DesktopShortcut";
} }

View File

@@ -420,6 +420,16 @@ public sealed class ComponentRegistry
MinHeightCells: 2, MinHeightCells: 2,
AllowStatusBarPlacement: false, AllowStatusBarPlacement: false,
AllowDesktopPlacement: true, AllowDesktopPlacement: true,
ResizeMode: DesktopComponentResizeMode.Free),
new DesktopComponentDefinition(
BuiltInComponentIds.DesktopShortcut,
"快捷方式",
"App",
"Launcher",
MinWidthCells: 1,
MinHeightCells: 1,
AllowStatusBarPlacement: false,
AllowDesktopPlacement: true,
ResizeMode: DesktopComponentResizeMode.Free) ResizeMode: DesktopComponentResizeMode.Free)
}; };

View File

@@ -123,6 +123,25 @@ public sealed class ComponentSettingsSnapshot
#endregion #endregion
#region Shortcut Component Settings ()
/// <summary>
/// 快捷方式目标路径
/// </summary>
public string? ShortcutTargetPath { get; set; }
/// <summary>
/// 点击模式Single(单击打开) 或 Double(双击打开)
/// </summary>
public string ShortcutClickMode { get; set; } = "Double";
/// <summary>
/// 是否透明背景
/// </summary>
public bool ShortcutTransparentBackground { get; set; } = false;
#endregion
public ComponentSettingsSnapshot Clone() public ComponentSettingsSnapshot Clone()
{ {
var clone = (ComponentSettingsSnapshot)MemberwiseClone(); var clone = (ComponentSettingsSnapshot)MemberwiseClone();

View File

@@ -272,7 +272,12 @@ public static class DesktopComponentEditorRegistryFactory
BuiltInComponentIds.DesktopNotificationBox, BuiltInComponentIds.DesktopNotificationBox,
context => new NotificationBoxComponentEditor(context), context => new NotificationBoxComponentEditor(context),
preferredWidth: 480d, preferredWidth: 480d,
preferredHeight: 520d) preferredHeight: 520d),
[BuiltInComponentIds.DesktopShortcut] = new(
BuiltInComponentIds.DesktopShortcut,
context => new ShortcutComponentEditor(context),
preferredWidth: 420d,
preferredHeight: 400d)
}; };
foreach (var componentId in GetBuiltInDesktopComponentIds(componentRegistry)) foreach (var componentId in GetBuiltInDesktopComponentIds(componentRegistry))

View File

@@ -0,0 +1,86 @@
<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:ui="using:FluentAvalonia.UI.Controls"
xmlns:fi="using:FluentIcons.Avalonia.Fluent"
mc:Ignorable="d"
d:DesignWidth="360"
d:DesignHeight="400"
x:Class="LanMountainDesktop.Views.ComponentEditors.ShortcutComponentEditor">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Spacing="16" Margin="20">
<TextBlock x:Name="HeadlineTextBlock"
FontSize="18"
FontWeight="SemiBold" />
<TextBlock x:Name="DescriptionTextBlock"
Opacity="0.75"
TextWrapping="Wrap" />
<ui:SettingsExpander x:Name="TargetPathExpander">
<ui:SettingsExpander.IconSource>
<fi:SymbolIconSource Symbol="Folder" />
</ui:SettingsExpander.IconSource>
<ui:SettingsExpander.Footer>
<Button x:Name="BrowseButton"
Content="浏览..."
Click="OnBrowseClick" />
</ui:SettingsExpander.Footer>
<ui:SettingsExpanderItem>
<TextBox x:Name="TargetPathTextBox"
IsReadOnly="True"
Watermark="未选择目标"
MinWidth="200" />
</ui:SettingsExpanderItem>
<ui:SettingsExpanderItem>
<Button x:Name="ClearButton"
Content="清除"
Click="OnClearClick"
HorizontalAlignment="Stretch" />
</ui:SettingsExpanderItem>
</ui:SettingsExpander>
<ui:SettingsExpander>
<ui:SettingsExpander.IconSource>
<fi:SymbolIconSource Symbol="CursorClick" />
</ui:SettingsExpander.IconSource>
<ui:SettingsExpanderItem>
<StackPanel Orientation="Horizontal" Spacing="16">
<RadioButton x:Name="SingleClickRadio"
Content="单击打开"
GroupName="ClickModeGroup" />
<RadioButton x:Name="DoubleClickRadio"
Content="双击打开"
GroupName="ClickModeGroup"
IsChecked="True" />
</StackPanel>
</ui:SettingsExpanderItem>
</ui:SettingsExpander>
<ui:SettingsExpander>
<ui:SettingsExpander.IconSource>
<fi:SymbolIconSource Symbol="ColorBackground" />
</ui:SettingsExpander.IconSource>
<ui:SettingsExpander.Footer>
<ToggleSwitch x:Name="BackgroundToggle"
OnContent=""
OffContent=""
IsChecked="True" />
</ui:SettingsExpander.Footer>
<ui:SettingsExpanderItem>
<StackPanel Spacing="2">
<TextBlock x:Name="BackgroundLabel" />
<TextBlock x:Name="BackgroundDescription"
Opacity="0.75"
TextWrapping="Wrap" />
</StackPanel>
</ui:SettingsExpanderItem>
</ui:SettingsExpander>
</StackPanel>
</ScrollViewer>
</UserControl>

View File

@@ -0,0 +1,149 @@
using System;
using System.IO;
using System.Linq;
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Platform.Storage;
using LanMountainDesktop.ComponentSystem;
using LanMountainDesktop.Models;
using LanMountainDesktop.Services;
namespace LanMountainDesktop.Views.ComponentEditors;
public partial class ShortcutComponentEditor : ComponentEditorViewBase
{
private bool _suppressEvents;
public ShortcutComponentEditor()
: this(null)
{
}
public ShortcutComponentEditor(DesktopComponentEditorContext? context)
: base(context)
{
InitializeComponent();
ApplyLocalizedText();
ApplyState();
AttachEventHandlers();
}
private void ApplyLocalizedText()
{
HeadlineTextBlock.Text = Context?.Definition.DisplayName ?? "快捷方式";
DescriptionTextBlock.Text = L(
"shortcut.settings.desc",
"配置快捷方式的目标路径和打开方式。");
BackgroundLabel.Text = L("shortcut.settings.show_background", "显示背景");
BackgroundDescription.Text = L(
"shortcut.settings.show_background.desc",
"关闭后组件背景将变为透明。");
}
private void ApplyState()
{
var snapshot = LoadSnapshot();
var targetPath = snapshot.ShortcutTargetPath;
var clickMode = snapshot.ShortcutClickMode;
var transparentBackground = snapshot.ShortcutTransparentBackground;
_suppressEvents = true;
TargetPathTextBox.Text = targetPath ?? string.Empty;
SingleClickRadio.IsChecked = string.Equals(clickMode, "Single", StringComparison.OrdinalIgnoreCase);
DoubleClickRadio.IsChecked = !SingleClickRadio.IsChecked;
BackgroundToggle.IsChecked = !transparentBackground;
_suppressEvents = false;
}
private void AttachEventHandlers()
{
BackgroundToggle.IsCheckedChanged += OnBackgroundToggleChanged;
SingleClickRadio.IsCheckedChanged += OnClickModeChanged;
DoubleClickRadio.IsCheckedChanged += OnClickModeChanged;
}
private async void OnBrowseClick(object? sender, RoutedEventArgs e)
{
var topLevel = TopLevel.GetTopLevel(this);
if (topLevel?.StorageProvider is not { } storageProvider)
{
return;
}
var options = new FilePickerOpenOptions
{
Title = L("shortcut.settings.picker_title", "选择目标文件或文件夹"),
AllowMultiple = false,
FileTypeFilter =
[
new FilePickerFileType(L("shortcut.settings.picker_type.executable", "可执行文件"))
{
Patterns = ["*.exe", "*.lnk", "*.bat", "*.cmd"]
},
new FilePickerFileType(L("shortcut.settings.picker_type.all", "所有文件"))
{
Patterns = ["*.*"]
}
]
};
var files = await storageProvider.OpenFilePickerAsync(options);
var file = files.FirstOrDefault();
var localPath = file?.TryGetLocalPath();
if (string.IsNullOrWhiteSpace(localPath))
{
var folderOptions = new FolderPickerOpenOptions
{
Title = L("shortcut.settings.picker_title_folder", "选择目标文件夹"),
AllowMultiple = false
};
var folders = await storageProvider.OpenFolderPickerAsync(folderOptions);
localPath = folders.FirstOrDefault()?.TryGetLocalPath();
}
if (!string.IsNullOrWhiteSpace(localPath))
{
TargetPathTextBox.Text = localPath;
var snapshot = LoadSnapshot();
snapshot.ShortcutTargetPath = localPath;
SaveSnapshot(snapshot, nameof(ComponentSettingsSnapshot.ShortcutTargetPath));
}
}
private void OnClearClick(object? sender, RoutedEventArgs e)
{
TargetPathTextBox.Text = string.Empty;
var snapshot = LoadSnapshot();
snapshot.ShortcutTargetPath = null;
SaveSnapshot(snapshot, nameof(ComponentSettingsSnapshot.ShortcutTargetPath));
}
private void OnClickModeChanged(object? sender, RoutedEventArgs e)
{
if (_suppressEvents)
{
return;
}
var clickMode = SingleClickRadio.IsChecked == true ? "Single" : "Double";
var snapshot = LoadSnapshot();
snapshot.ShortcutClickMode = clickMode;
SaveSnapshot(snapshot, nameof(ComponentSettingsSnapshot.ShortcutClickMode));
}
private void OnBackgroundToggleChanged(object? sender, RoutedEventArgs e)
{
if (_suppressEvents)
{
return;
}
var transparentBackground = BackgroundToggle.IsChecked != true;
var snapshot = LoadSnapshot();
snapshot.ShortcutTransparentBackground = transparentBackground;
SaveSnapshot(snapshot, nameof(ComponentSettingsSnapshot.ShortcutTransparentBackground));
}
}

View File

@@ -479,7 +479,11 @@ public sealed class DesktopComponentRuntimeRegistry
new DesktopComponentRuntimeRegistration( new DesktopComponentRuntimeRegistration(
BuiltInComponentIds.DesktopNotificationBox, BuiltInComponentIds.DesktopNotificationBox,
"component.notification_box", "component.notification_box",
() => new NotificationBoxWidget()) () => new NotificationBoxWidget()),
new DesktopComponentRuntimeRegistration(
BuiltInComponentIds.DesktopShortcut,
"component.shortcut",
() => new ShortcutWidget())
]; ];
} }

View File

@@ -0,0 +1,38 @@
<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"
mc:Ignorable="d"
d:DesignWidth="96"
d:DesignHeight="96"
x:Class="LanMountainDesktop.Views.Components.ShortcutWidget">
<Border x:Name="RootBorder"
Classes="glass-panel"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
ClipToBounds="True">
<Grid RowDefinitions="*,Auto"
x:Name="ContentGrid">
<Border x:Name="IconHost"
Grid.Row="0"
HorizontalAlignment="Center"
VerticalAlignment="Center">
<Image x:Name="IconImage"
Stretch="Uniform" />
</Border>
<TextBlock x:Name="NameTextBlock"
Grid.Row="1"
HorizontalAlignment="Center"
VerticalAlignment="Center"
TextAlignment="Center"
TextTrimming="CharacterEllipsis"
MaxLines="2"
TextWrapping="Wrap"
Margin="4,0,4,4"
FontSize="11" />
</Grid>
</Border>
</UserControl>

View File

@@ -0,0 +1,369 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Runtime.InteropServices;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Media;
using Avalonia.Media.Imaging;
using FluentIcons.Avalonia;
using LanMountainDesktop.ComponentSystem;
using LanMountainDesktop.Models;
using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Services;
namespace LanMountainDesktop.Views.Components;
public partial class ShortcutWidget : UserControl, IDesktopComponentWidget, IComponentPlacementContextAware, IDisposable
{
private string _componentId = BuiltInComponentIds.DesktopShortcut;
private string _placementId = string.Empty;
private string? _targetPath;
private string _clickMode = "Double";
private bool _transparentBackground;
private double _currentCellSize = 48;
private bool _isDisposed;
private const double TapMovementThreshold = 10;
private const long TapTimeThresholdMs = 500;
private readonly Dictionary<int, PointerGestureState> _gestureStates = new();
private record PointerGestureState(
Point StartPosition,
long StartTime
);
public ShortcutWidget()
{
InitializeComponent();
DoubleTapped += OnDoubleTapped;
UpdateDisplay();
}
public void SetComponentPlacementContext(string componentId, string? placementId)
{
_componentId = string.IsNullOrWhiteSpace(componentId)
? BuiltInComponentIds.DesktopShortcut
: componentId.Trim();
_placementId = placementId?.Trim() ?? string.Empty;
}
public void ApplySettings(ComponentSettingsSnapshot snapshot)
{
_targetPath = snapshot.ShortcutTargetPath;
_clickMode = string.Equals(snapshot.ShortcutClickMode, "Single", StringComparison.OrdinalIgnoreCase)
? "Single"
: "Double";
_transparentBackground = snapshot.ShortcutTransparentBackground;
UpdateDisplay();
ApplyChrome();
}
public void ApplyCellSize(double cellSize)
{
_currentCellSize = cellSize;
var iconSize = Math.Clamp(cellSize * 0.5, 24, 64);
IconImage.Width = iconSize;
IconImage.Height = iconSize;
var fontSize = Math.Clamp(cellSize * 0.18, 10, 16);
NameTextBlock.FontSize = fontSize;
}
private void UpdateDisplay()
{
if (string.IsNullOrWhiteSpace(_targetPath))
{
ShowEmptyState();
return;
}
try
{
var name = GetDisplayName(_targetPath);
NameTextBlock.Text = name;
NameTextBlock.Foreground = this.FindResource("AdaptiveTextPrimaryBrush") as IBrush ?? new SolidColorBrush(Colors.White);
LoadIcon(_targetPath);
}
catch
{
ShowEmptyState();
}
}
private void ShowEmptyState()
{
NameTextBlock.Text = "添加快捷方式";
NameTextBlock.Foreground = this.FindResource("AdaptiveTextSecondaryBrush") as IBrush ?? new SolidColorBrush(Colors.Gray);
var iconBrush = this.FindResource("AdaptiveTextSecondaryBrush") as IBrush ?? new SolidColorBrush(Colors.Gray);
IconImage.Source = null;
var iconHostContent = new SymbolIcon
{
Symbol = FluentIcons.Common.Symbol.Add,
FontSize = 32,
Foreground = iconBrush,
HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center,
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center
};
IconHost.Child = iconHostContent;
}
private static string GetDisplayName(string path)
{
if (string.IsNullOrWhiteSpace(path))
{
return "快捷方式";
}
try
{
if (Directory.Exists(path))
{
return Path.GetFileName(path.TrimEnd('\\', '/'));
}
var fileName = Path.GetFileNameWithoutExtension(path);
return string.IsNullOrWhiteSpace(fileName) ? path : fileName;
}
catch
{
return path;
}
}
private void LoadIcon(string path)
{
byte[]? pngBytes = null;
try
{
if (OperatingSystem.IsWindows())
{
if (Directory.Exists(path))
{
pngBytes = WindowsIconService.TryGetSystemFolderIconPngBytes();
}
else if (File.Exists(path))
{
pngBytes = WindowsIconService.TryGetIconPngBytes(path);
}
}
else if (OperatingSystem.IsLinux())
{
if (Directory.Exists(path))
{
pngBytes = LinuxIconService.TryGetSystemFolderIconPngBytes();
}
else if (File.Exists(path))
{
pngBytes = LinuxIconService.TryGetIconPngBytes(path);
}
}
else if (OperatingSystem.IsMacOS())
{
if (Directory.Exists(path))
{
pngBytes = MacIconService.TryGetSystemFolderIconPngBytes();
}
else if (File.Exists(path))
{
pngBytes = MacIconService.TryGetIconPngBytes(path);
}
}
}
catch
{
pngBytes = null;
}
if (pngBytes is not null)
{
try
{
using var stream = new MemoryStream(pngBytes);
IconImage.Source = new Bitmap(stream);
IconHost.Child = IconImage;
return;
}
catch
{
}
}
LoadFallbackIcon(path);
}
private void LoadFallbackIcon(string path)
{
var symbol = Directory.Exists(path)
? FluentIcons.Common.Symbol.Folder
: FluentIcons.Common.Symbol.Document;
var iconBrush = this.FindResource("AdaptiveAccentBrush") as IBrush ?? new SolidColorBrush(Colors.DodgerBlue);
IconImage.Source = null;
var iconHostContent = new SymbolIcon
{
Symbol = symbol,
FontSize = 32,
Foreground = iconBrush,
HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center,
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center
};
IconHost.Child = iconHostContent;
}
private void ApplyChrome()
{
if (_transparentBackground)
{
RootBorder.Classes.Remove("glass-panel");
RootBorder.Background = Brushes.Transparent;
RootBorder.BorderBrush = Brushes.Transparent;
RootBorder.BorderThickness = new Thickness(0);
return;
}
if (!RootBorder.Classes.Contains("glass-panel"))
{
RootBorder.Classes.Add("glass-panel");
}
RootBorder.ClearValue(Border.BackgroundProperty);
RootBorder.ClearValue(Border.BorderBrushProperty);
RootBorder.ClearValue(Border.BorderThicknessProperty);
}
protected override void OnPointerPressed(PointerPressedEventArgs e)
{
base.OnPointerPressed(e);
if (string.IsNullOrWhiteSpace(_targetPath))
{
return;
}
var pointer = e.GetCurrentPoint(this);
var pointerId = e.Pointer.Id;
var position = pointer.Position;
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
_gestureStates[pointerId] = new PointerGestureState(position, timestamp);
e.Pointer.Capture(this);
}
protected override void OnPointerMoved(PointerEventArgs e)
{
base.OnPointerMoved(e);
var pointerId = e.Pointer.Id;
if (!_gestureStates.TryGetValue(pointerId, out var state))
{
return;
}
var currentPoint = e.GetCurrentPoint(this);
var distance = Math.Sqrt(
Math.Pow(currentPoint.Position.X - state.StartPosition.X, 2) +
Math.Pow(currentPoint.Position.Y - state.StartPosition.Y, 2)
);
if (distance > TapMovementThreshold)
{
_gestureStates.Remove(pointerId);
e.Pointer.Capture(null);
}
}
protected override void OnPointerReleased(PointerReleasedEventArgs e)
{
base.OnPointerReleased(e);
var pointerId = e.Pointer.Id;
if (!_gestureStates.Remove(pointerId, out var state))
{
return;
}
e.Pointer.Capture(null);
var currentPoint = e.GetCurrentPoint(this);
var distance = Math.Sqrt(
Math.Pow(currentPoint.Position.X - state.StartPosition.X, 2) +
Math.Pow(currentPoint.Position.Y - state.StartPosition.Y, 2)
);
var elapsed = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - state.StartTime;
if (distance > TapMovementThreshold || elapsed > TapTimeThresholdMs)
{
return;
}
if (_clickMode == "Single")
{
OpenTarget();
}
}
private void OnDoubleTapped(object? sender, TappedEventArgs e)
{
if (string.IsNullOrWhiteSpace(_targetPath))
{
return;
}
if (_clickMode == "Double")
{
OpenTarget();
}
}
private void OpenTarget()
{
if (string.IsNullOrWhiteSpace(_targetPath))
{
return;
}
try
{
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
Process.Start(new ProcessStartInfo(_targetPath)
{
UseShellExecute = true
});
}
else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
{
Process.Start("xdg-open", _targetPath);
}
else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
{
Process.Start("open", _targetPath);
}
}
catch (Exception ex)
{
AppLogger.Warn("ShortcutWidget", $"Failed to open target: {_targetPath}", ex);
}
}
public void Dispose()
{
if (_isDisposed)
{
return;
}
_isDisposed = true;
_gestureStates.Clear();
}
}