diff --git a/CHANGELOG.md b/CHANGELOG.md
index 285bcd9..531cc75 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -26,7 +26,11 @@
## [0.8.3.1] - 2026-04-08
### 新增 (Added)
-- 初始化更新日志文档,为后续版本发布建立基础
+- ✨ **快捷方式组件**: 新增快捷方式组件,可在阑山桌面内便捷打开系统应用与文件
+ - 支持创建快捷方式,统一管理应用和文件
+ - 提供单击打开和双击打开两种交互模式
+ - 支持配置是否显示背景
+- 📝 初始化更新日志文档,为后续版本发布建立基础
### 变更 (Changed)
- 无
diff --git a/LanMountainDesktop/ComponentSystem/BuiltInComponentIds.cs b/LanMountainDesktop/ComponentSystem/BuiltInComponentIds.cs
index 022b5a6..6050563 100644
--- a/LanMountainDesktop/ComponentSystem/BuiltInComponentIds.cs
+++ b/LanMountainDesktop/ComponentSystem/BuiltInComponentIds.cs
@@ -46,4 +46,5 @@ public static class BuiltInComponentIds
public const string DesktopZhiJiaoHub = "DesktopZhiJiaoHub";
public const string DesktopFileManager = "DesktopFileManager";
public const string DesktopNotificationBox = "DesktopNotificationBox";
+ public const string DesktopShortcut = "DesktopShortcut";
}
diff --git a/LanMountainDesktop/ComponentSystem/ComponentRegistry.cs b/LanMountainDesktop/ComponentSystem/ComponentRegistry.cs
index 7a784b4..a62fa87 100644
--- a/LanMountainDesktop/ComponentSystem/ComponentRegistry.cs
+++ b/LanMountainDesktop/ComponentSystem/ComponentRegistry.cs
@@ -420,6 +420,16 @@ public sealed class ComponentRegistry
MinHeightCells: 2,
AllowStatusBarPlacement: false,
AllowDesktopPlacement: true,
+ ResizeMode: DesktopComponentResizeMode.Free),
+ new DesktopComponentDefinition(
+ BuiltInComponentIds.DesktopShortcut,
+ "快捷方式",
+ "App",
+ "Launcher",
+ MinWidthCells: 1,
+ MinHeightCells: 1,
+ AllowStatusBarPlacement: false,
+ AllowDesktopPlacement: true,
ResizeMode: DesktopComponentResizeMode.Free)
};
diff --git a/LanMountainDesktop/Models/ComponentSettingsSnapshot.cs b/LanMountainDesktop/Models/ComponentSettingsSnapshot.cs
index 0a2fd97..efb1d5b 100644
--- a/LanMountainDesktop/Models/ComponentSettingsSnapshot.cs
+++ b/LanMountainDesktop/Models/ComponentSettingsSnapshot.cs
@@ -123,6 +123,25 @@ public sealed class ComponentSettingsSnapshot
#endregion
+ #region Shortcut Component Settings (快捷方式组件设置)
+
+ ///
+ /// 快捷方式目标路径
+ ///
+ public string? ShortcutTargetPath { get; set; }
+
+ ///
+ /// 点击模式:Single(单击打开) 或 Double(双击打开)
+ ///
+ public string ShortcutClickMode { get; set; } = "Double";
+
+ ///
+ /// 是否透明背景
+ ///
+ public bool ShortcutTransparentBackground { get; set; } = false;
+
+ #endregion
+
public ComponentSettingsSnapshot Clone()
{
var clone = (ComponentSettingsSnapshot)MemberwiseClone();
diff --git a/LanMountainDesktop/Services/DesktopComponentEditorRegistryFactory.cs b/LanMountainDesktop/Services/DesktopComponentEditorRegistryFactory.cs
index 6bd01be..f2fd5b6 100644
--- a/LanMountainDesktop/Services/DesktopComponentEditorRegistryFactory.cs
+++ b/LanMountainDesktop/Services/DesktopComponentEditorRegistryFactory.cs
@@ -272,7 +272,12 @@ public static class DesktopComponentEditorRegistryFactory
BuiltInComponentIds.DesktopNotificationBox,
context => new NotificationBoxComponentEditor(context),
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))
diff --git a/LanMountainDesktop/Views/ComponentEditors/ShortcutComponentEditor.axaml b/LanMountainDesktop/Views/ComponentEditors/ShortcutComponentEditor.axaml
new file mode 100644
index 0000000..d448e99
--- /dev/null
+++ b/LanMountainDesktop/Views/ComponentEditors/ShortcutComponentEditor.axaml
@@ -0,0 +1,86 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/LanMountainDesktop/Views/ComponentEditors/ShortcutComponentEditor.axaml.cs b/LanMountainDesktop/Views/ComponentEditors/ShortcutComponentEditor.axaml.cs
new file mode 100644
index 0000000..d556483
--- /dev/null
+++ b/LanMountainDesktop/Views/ComponentEditors/ShortcutComponentEditor.axaml.cs
@@ -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));
+ }
+}
diff --git a/LanMountainDesktop/Views/Components/DesktopComponentRuntimeRegistry.cs b/LanMountainDesktop/Views/Components/DesktopComponentRuntimeRegistry.cs
index 908d775..0c6fd53 100644
--- a/LanMountainDesktop/Views/Components/DesktopComponentRuntimeRegistry.cs
+++ b/LanMountainDesktop/Views/Components/DesktopComponentRuntimeRegistry.cs
@@ -479,7 +479,11 @@ public sealed class DesktopComponentRuntimeRegistry
new DesktopComponentRuntimeRegistration(
BuiltInComponentIds.DesktopNotificationBox,
"component.notification_box",
- () => new NotificationBoxWidget())
+ () => new NotificationBoxWidget()),
+ new DesktopComponentRuntimeRegistration(
+ BuiltInComponentIds.DesktopShortcut,
+ "component.shortcut",
+ () => new ShortcutWidget())
];
}
diff --git a/LanMountainDesktop/Views/Components/ShortcutWidget.axaml b/LanMountainDesktop/Views/Components/ShortcutWidget.axaml
new file mode 100644
index 0000000..b8a2e54
--- /dev/null
+++ b/LanMountainDesktop/Views/Components/ShortcutWidget.axaml
@@ -0,0 +1,38 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/LanMountainDesktop/Views/Components/ShortcutWidget.axaml.cs b/LanMountainDesktop/Views/Components/ShortcutWidget.axaml.cs
new file mode 100644
index 0000000..83f5e31
--- /dev/null
+++ b/LanMountainDesktop/Views/Components/ShortcutWidget.axaml.cs
@@ -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 _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();
+ }
+}