diff --git a/LanMountainDesktop/ComponentSystem/BuiltInComponentIds.cs b/LanMountainDesktop/ComponentSystem/BuiltInComponentIds.cs index 11d04b1..022b5a6 100644 --- a/LanMountainDesktop/ComponentSystem/BuiltInComponentIds.cs +++ b/LanMountainDesktop/ComponentSystem/BuiltInComponentIds.cs @@ -45,4 +45,5 @@ public static class BuiltInComponentIds public const string DesktopRemovableStorage = "DesktopRemovableStorage"; public const string DesktopZhiJiaoHub = "DesktopZhiJiaoHub"; public const string DesktopFileManager = "DesktopFileManager"; + public const string DesktopNotificationBox = "DesktopNotificationBox"; } diff --git a/LanMountainDesktop/ComponentSystem/ComponentRegistry.cs b/LanMountainDesktop/ComponentSystem/ComponentRegistry.cs index 5234499..7a784b4 100644 --- a/LanMountainDesktop/ComponentSystem/ComponentRegistry.cs +++ b/LanMountainDesktop/ComponentSystem/ComponentRegistry.cs @@ -410,6 +410,16 @@ public sealed class ComponentRegistry MinHeightCells: 4, AllowStatusBarPlacement: false, AllowDesktopPlacement: true, + ResizeMode: DesktopComponentResizeMode.Free), + new DesktopComponentDefinition( + BuiltInComponentIds.DesktopNotificationBox, + "消息盒子", + "Inbox", + "Info", + MinWidthCells: 2, + MinHeightCells: 2, + AllowStatusBarPlacement: false, + AllowDesktopPlacement: true, ResizeMode: DesktopComponentResizeMode.Free) }; diff --git a/LanMountainDesktop/LanMountainDesktop.csproj b/LanMountainDesktop/LanMountainDesktop.csproj index 58fd65e..67a3347 100644 --- a/LanMountainDesktop/LanMountainDesktop.csproj +++ b/LanMountainDesktop/LanMountainDesktop.csproj @@ -76,6 +76,7 @@ + diff --git a/LanMountainDesktop/Models/AppSettingsSnapshot.cs b/LanMountainDesktop/Models/AppSettingsSnapshot.cs index f01fbd4..e3e0490 100644 --- a/LanMountainDesktop/Models/AppSettingsSnapshot.cs +++ b/LanMountainDesktop/Models/AppSettingsSnapshot.cs @@ -200,6 +200,35 @@ public sealed class AppSettingsSnapshot #endregion + #region Notification Box Settings (消息盒子全局设置) + + /// + /// 启用消息盒子功能(Windows通知监听) + /// + public bool NotificationBoxEnabled { get; set; } = true; + + /// + /// 隐私模式:开启后只显示"您有新的通知",不显示具体内容 + /// + public bool NotificationBoxPrivacyMode { get; set; } = false; + + /// + /// 被屏蔽的应用列表(不接收这些应用的通知) + /// + public List NotificationBoxBlockedApps { get; set; } = []; + + /// + /// 历史记录保留天数 + /// + public int NotificationBoxHistoryRetentionDays { get; set; } = 7; + + /// + /// 最大存储通知数量(防止内存无限增长) + /// + public int NotificationBoxMaxStoredCount { get; set; } = 500; + + #endregion + public AppSettingsSnapshot Clone() { var clone = (AppSettingsSnapshot)MemberwiseClone(); @@ -213,6 +242,9 @@ public sealed class AppSettingsSnapshot clone.DisabledPluginIds = DisabledPluginIds is { Count: > 0 } ? new List(DisabledPluginIds) : []; + clone.NotificationBoxBlockedApps = NotificationBoxBlockedApps is { Count: > 0 } + ? new List(NotificationBoxBlockedApps) + : []; return clone; } diff --git a/LanMountainDesktop/Models/ComponentSettingsSnapshot.cs b/LanMountainDesktop/Models/ComponentSettingsSnapshot.cs index 38461e8..0a2fd97 100644 --- a/LanMountainDesktop/Models/ComponentSettingsSnapshot.cs +++ b/LanMountainDesktop/Models/ComponentSettingsSnapshot.cs @@ -84,6 +84,45 @@ public sealed class ComponentSettingsSnapshot public int ZhiJiaoHubCurrentImageIndex { get; set; } = 0; + #region Notification Box Component Settings (消息盒子组件设置) + + /// + /// 组件内最大显示通知数量 + /// + public int NotificationBoxMaxDisplayCount { get; set; } = 50; + + /// + /// 排序方式:TimeDesc(时间倒序), TimeAsc(时间正序), AppGroup(按应用分组) + /// + public string NotificationBoxSortOrder { get; set; } = "TimeDesc"; + + /// + /// 是否显示应用图标 + /// + public bool NotificationBoxShowAppIcon { get; set; } = true; + + /// + /// 是否显示时间戳 + /// + public bool NotificationBoxShowTimestamp { get; set; } = true; + + /// + /// 时间格式:Relative(相对时间,如"5分钟前"), Absolute(绝对时间) + /// + public string NotificationBoxTimeFormat { get; set; } = "Relative"; + + /// + /// 是否按应用分组显示 + /// + public bool NotificationBoxGroupByApp { get; set; } = false; + + /// + /// 是否显示清除按钮 + /// + public bool NotificationBoxShowClearButton { get; set; } = true; + + #endregion + public ComponentSettingsSnapshot Clone() { var clone = (ComponentSettingsSnapshot)MemberwiseClone(); diff --git a/LanMountainDesktop/Models/NotificationItem.cs b/LanMountainDesktop/Models/NotificationItem.cs new file mode 100644 index 0000000..119b067 --- /dev/null +++ b/LanMountainDesktop/Models/NotificationItem.cs @@ -0,0 +1,54 @@ +using System; + +namespace LanMountainDesktop.Models; + +/// +/// 通知项数据模型 +/// +public sealed class NotificationItem +{ + /// + /// 唯一标识 + /// + public string Id { get; set; } = Guid.NewGuid().ToString(); + + /// + /// 应用ID(如 WeChat, Outlook 等) + /// + public string AppId { get; set; } = string.Empty; + + /// + /// 应用名称 + /// + public string AppName { get; set; } = string.Empty; + + /// + /// 应用图标路径或Base64 + /// + public string? AppIconPath { get; set; } + + /// + /// 通知标题 + /// + public string Title { get; set; } = string.Empty; + + /// + /// 通知内容 + /// + public string Content { get; set; } = string.Empty; + + /// + /// 接收时间 + /// + public DateTime ReceivedTime { get; set; } = DateTime.Now; + + /// + /// 是否已读 + /// + public bool IsRead { get; set; } = false; + + /// + /// 原始通知的额外数据(用于点击跳转) + /// + public string? LaunchArgs { get; set; } +} diff --git a/LanMountainDesktop/Services/DesktopComponentEditorRegistryFactory.cs b/LanMountainDesktop/Services/DesktopComponentEditorRegistryFactory.cs index ca72d46..6bd01be 100644 --- a/LanMountainDesktop/Services/DesktopComponentEditorRegistryFactory.cs +++ b/LanMountainDesktop/Services/DesktopComponentEditorRegistryFactory.cs @@ -267,6 +267,11 @@ public static class DesktopComponentEditorRegistryFactory BuiltInComponentIds.DesktopZhiJiaoHub, context => new ZhiJiaoHubComponentEditor(context), preferredWidth: 480d, + preferredHeight: 520d), + [BuiltInComponentIds.DesktopNotificationBox] = new( + BuiltInComponentIds.DesktopNotificationBox, + context => new NotificationBoxComponentEditor(context), + preferredWidth: 480d, preferredHeight: 520d) }; diff --git a/LanMountainDesktop/Services/LinuxNotificationListener.cs b/LanMountainDesktop/Services/LinuxNotificationListener.cs new file mode 100644 index 0000000..4c298b0 --- /dev/null +++ b/LanMountainDesktop/Services/LinuxNotificationListener.cs @@ -0,0 +1,216 @@ +using System; +using System.IO; +using System.Linq; +using System.Runtime.Versioning; +using System.Threading; +using System.Threading.Tasks; +using LanMountainDesktop.Models; + +namespace LanMountainDesktop.Services; + +/// +/// Linux平台通知监听器 - 通过DBus监听org.freedesktop.Notifications +/// +[SupportedOSPlatform("linux")] +internal sealed class LinuxNotificationListener : IDisposable +{ + private readonly NotificationListenerService _parent; + private CancellationTokenSource? _cts; + private bool _isRunning; + + public LinuxNotificationListener(NotificationListenerService parent) + { + _parent = parent; + } + + /// + /// 初始化并启动DBus监听 + /// + public async Task InitializeAsync() + { + try + { + // 检查DBus环境变量 + var dbusSessionBus = Environment.GetEnvironmentVariable("DBUS_SESSION_BUS_ADDRESS"); + if (string.IsNullOrEmpty(dbusSessionBus)) + { + Console.WriteLine("[NotificationBox] DBus Session Bus 环境变量未设置"); + return false; + } + + // 检查通知守护进程是否运行 + // 通过检查常见进程名 + var hasNotificationDaemon = await CheckNotificationDaemonAsync(); + if (!hasNotificationDaemon) + { + Console.WriteLine("[NotificationBox] 未检测到通知守护进程,消息盒子功能可能不可用"); + // 仍然返回true,因为守护进程可能在之后启动 + } + + _cts = new CancellationTokenSource(); + _ = StartListeningAsync(_cts.Token); + + Console.WriteLine("[NotificationBox] Linux通知监听已启动"); + return true; + } + catch (Exception ex) + { + Console.WriteLine($"[NotificationBox] Linux通知监听初始化失败: {ex.Message}"); + return false; + } + } + + private async Task CheckNotificationDaemonAsync() + { + try + { + // 检查常见通知守护进程 + var processNames = new[] { "gnome-shell", "kded5", "dunst", "mako", "swaync" }; + foreach (var name in processNames) + { + var psi = new System.Diagnostics.ProcessStartInfo + { + FileName = "pgrep", + Arguments = $"-x {name}", + RedirectStandardOutput = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + using var process = System.Diagnostics.Process.Start(psi); + if (process != null) + { + await process.WaitForExitAsync(); + if (process.ExitCode == 0) + { + return true; + } + } + } + return false; + } + catch + { + return false; + } + } + + private async Task StartListeningAsync(CancellationToken ct) + { + _isRunning = true; + + try + { + // 注意:Tmds.DBus.Protocol 是低层API + // 这里使用简化方案,实际生产环境需要完整的DBus信号订阅实现 + // 当前版本为框架实现,后续可以完善DBus监听逻辑 + + while (!ct.IsCancellationRequested && _isRunning) + { + try + { + await Task.Delay(1000, ct); + } + catch (OperationCanceledException) + { + break; + } + } + } + catch (Exception ex) + { + Console.WriteLine($"[NotificationBox] Linux通知监听异常: {ex.Message}"); + } + } + + /// + /// 处理接收到的通知(供DBus信号处理器调用) + /// + public void HandleNotification( + string appName, + uint replacesId, + string appIcon, + string summary, + string body, + string[] actions, + object hints, + int expireTimeout) + { + try + { + var notification = new NotificationItem + { + Id = Guid.NewGuid().ToString(), + AppId = appName.ToLowerInvariant().Replace(" ", ""), + AppName = appName, + Title = summary, + Content = StripHtmlTags(body), + ReceivedTime = DateTime.Now, + AppIconPath = ResolveIconPath(appIcon, appName) + }; + + _parent.AddNotification(notification); + } + catch (Exception ex) + { + Console.WriteLine($"[NotificationBox] 处理通知失败: {ex.Message}"); + } + } + + /// + /// 解析应用图标路径 + /// + private static string? ResolveIconPath(string iconName, string appName) + { + if (string.IsNullOrEmpty(iconName)) + { + return null; + } + + // 如果是绝对路径,直接使用 + if (File.Exists(iconName)) + { + return iconName; + } + + // 尝试从图标主题中查找 + var iconPaths = new[] + { + $"/usr/share/icons/hicolor/48x48/apps/{iconName}.png", + $"/usr/share/icons/hicolor/64x64/apps/{iconName}.png", + $"/usr/share/pixmaps/{iconName}.png", + $"/usr/share/pixmaps/{iconName}.svg", + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + $".local/share/icons/{iconName}.png") + }; + + return iconPaths.FirstOrDefault(File.Exists); + } + + /// + /// 去除HTML标签(通知内容可能包含HTML) + /// + private static string StripHtmlTags(string html) + { + if (string.IsNullOrEmpty(html)) + { + return string.Empty; + } + + // 简单的HTML标签去除 + var result = html; + result = System.Text.RegularExpressions.Regex.Replace(result, "<[^>]+>", ""); + result = result.Replace("<", "<"); + result = result.Replace(">", ">"); + result = result.Replace("&", "&"); + result = result.Replace(""", "\""); + return result.Trim(); + } + + public void Dispose() + { + _isRunning = false; + _cts?.Cancel(); + _cts?.Dispose(); + } +} diff --git a/LanMountainDesktop/Services/NotificationListenerService.cs b/LanMountainDesktop/Services/NotificationListenerService.cs new file mode 100644 index 0000000..3eee66a --- /dev/null +++ b/LanMountainDesktop/Services/NotificationListenerService.cs @@ -0,0 +1,201 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.InteropServices; +using System.Threading.Tasks; +using Avalonia.Threading; +using LanMountainDesktop.Models; +using LanMountainDesktop.PluginSdk; + +namespace LanMountainDesktop.Services; + +/// +/// 跨平台通知监听服务 +/// +public sealed class NotificationListenerService : IDisposable +{ + private readonly List _notifications = []; + private readonly object _lock = new(); + private readonly ISettingsService _settingsService; + + // 平台特定的监听器 + private LinuxNotificationListener? _linuxListener; + + public event EventHandler? NotificationReceived; + public event EventHandler? NotificationRemoved; + + public NotificationListenerService(ISettingsService settingsService) + { + _settingsService = settingsService; + } + + /// + /// 初始化并启动监听 + /// + public async Task InitializeAsync() + { + try + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + // Windows: 使用 UserNotificationListener (需要Windows SDK) + // 当前为模拟实现 + await InitializeWindowsAsync(); + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + // Linux: 使用 DBus + await InitializeLinuxAsync(); + } + else + { + // macOS 或其他平台:功能不可用 + Console.WriteLine("[NotificationBox] 当前平台不支持通知监听"); + } + } + catch (Exception ex) + { + Console.WriteLine($"[NotificationBox] 初始化失败: {ex.Message}"); + } + } + + private async Task InitializeWindowsAsync() + { + // Windows通知监听实现 + // 实际项目中需要添加Windows SDK引用并使用UserNotificationListener + // 由于需要UWP API,这里使用模拟实现 + await Task.CompletedTask; + Console.WriteLine("[NotificationBox] Windows通知监听已启动(模拟模式)"); + } + + private async Task InitializeLinuxAsync() + { + try + { + _linuxListener = new LinuxNotificationListener(this); + var success = await _linuxListener.InitializeAsync(); + + if (!success) + { + Console.WriteLine("[NotificationBox] Linux通知监听初始化失败,可能未运行通知守护进程"); + } + } + catch (Exception ex) + { + Console.WriteLine($"[NotificationBox] Linux通知监听异常: {ex.Message}"); + } + } + + /// + /// 添加通知(供平台监听器调用) + /// + public void AddNotification(NotificationItem notification) + { + var settings = _settingsService.LoadSnapshot(SettingsScope.App); + + // 检查全局开关 + if (!settings.NotificationBoxEnabled) + return; + + // 检查是否在屏蔽列表中 + if (settings.NotificationBoxBlockedApps.Contains(notification.AppId, StringComparer.OrdinalIgnoreCase)) + return; + + lock (_lock) + { + _notifications.Add(notification); + CleanupOldNotifications(settings); + } + + // 在UI线程触发事件 + Dispatcher.UIThread.InvokeAsync(() => + { + NotificationReceived?.Invoke(this, notification); + }); + } + + /// + /// 移除通知 + /// + public void RemoveNotification(string notificationId) + { + lock (_lock) + { + var notification = _notifications.FirstOrDefault(n => n.Id == notificationId); + if (notification != null) + { + _notifications.Remove(notification); + } + } + + NotificationRemoved?.Invoke(this, notificationId); + } + + private void CleanupOldNotifications(AppSettingsSnapshot settings) + { + // 按数量清理 + var maxCount = settings.NotificationBoxMaxStoredCount; + while (_notifications.Count > maxCount) + { + _notifications.RemoveAt(0); + } + + // 按时间清理 + var cutoffDate = DateTime.Now.AddDays(-settings.NotificationBoxHistoryRetentionDays); + _notifications.RemoveAll(n => n.ReceivedTime < cutoffDate); + } + + /// + /// 获取所有通知 + /// + public IReadOnlyList GetNotifications() + { + lock (_lock) + { + return _notifications.ToList().AsReadOnly(); + } + } + + /// + /// 清空所有通知 + /// + public void ClearAll() + { + lock (_lock) + { + _notifications.Clear(); + } + } + + /// + /// 标记通知为已读 + /// + public void MarkAsRead(string notificationId) + { + lock (_lock) + { + var notification = _notifications.FirstOrDefault(n => n.Id == notificationId); + if (notification != null) + { + notification.IsRead = true; + } + } + } + + /// + /// 获取未读通知数量 + /// + public int GetUnreadCount() + { + lock (_lock) + { + return _notifications.Count(n => !n.IsRead); + } + } + + public void Dispose() + { + _linuxListener?.Dispose(); + ClearAll(); + } +} diff --git a/LanMountainDesktop/ViewModels/NotificationBoxEditorViewModel.cs b/LanMountainDesktop/ViewModels/NotificationBoxEditorViewModel.cs new file mode 100644 index 0000000..b7aa137 --- /dev/null +++ b/LanMountainDesktop/ViewModels/NotificationBoxEditorViewModel.cs @@ -0,0 +1,115 @@ +using System; +using System.Collections.ObjectModel; +using System.Linq; +using CommunityToolkit.Mvvm.ComponentModel; +using LanMountainDesktop.ComponentSystem; +using LanMountainDesktop.Models; + +namespace LanMountainDesktop.ViewModels; + +public sealed partial class NotificationBoxEditorViewModel : ViewModelBase +{ + private readonly DesktopComponentEditorContext? _context; + private bool _isInitializing; + + public NotificationBoxEditorViewModel(DesktopComponentEditorContext? context) + { + _context = context; + + MaxDisplayCountOptions = new ObservableCollection + { + new("20", "20条"), + new("50", "50条"), + new("100", "100条"), + new("200", "200条") + }; + + SortOrderOptions = new ObservableCollection + { + new("TimeDesc", "最新优先"), + new("TimeAsc", "最早优先"), + new("AppGroup", "按应用分组") + }; + + TimeFormatOptions = new ObservableCollection + { + new("Relative", "相对时间(如:5分钟前)"), + new("Absolute", "绝对时间(如:14:30)") + }; + + LoadSettings(); + } + + private void LoadSettings() + { + var snapshot = _context?.ComponentSettingsAccessor.LoadSnapshot() + ?? new ComponentSettingsSnapshot(); + + _isInitializing = true; + + var countValue = snapshot.NotificationBoxMaxDisplayCount.ToString(); + SelectedMaxDisplayCount = MaxDisplayCountOptions.FirstOrDefault(o => o.Value == countValue) + ?? MaxDisplayCountOptions[1]; // 默认50 + + SelectedSortOrder = SortOrderOptions.FirstOrDefault(o => o.Value == snapshot.NotificationBoxSortOrder) + ?? SortOrderOptions[0]; + ShowAppIcon = snapshot.NotificationBoxShowAppIcon; + ShowTimestamp = snapshot.NotificationBoxShowTimestamp; + SelectedTimeFormat = TimeFormatOptions.FirstOrDefault(o => o.Value == snapshot.NotificationBoxTimeFormat) + ?? TimeFormatOptions[0]; + GroupByApp = snapshot.NotificationBoxGroupByApp; + ShowClearButton = snapshot.NotificationBoxShowClearButton; + + _isInitializing = false; + } + + private void SaveSettings() + { + if (_isInitializing || _context == null) return; + + var snapshot = _context.ComponentSettingsAccessor.LoadSnapshot(); + + snapshot.NotificationBoxMaxDisplayCount = int.TryParse(SelectedMaxDisplayCount?.Value, out var count) ? count : 50; + snapshot.NotificationBoxSortOrder = SelectedSortOrder?.Value ?? "TimeDesc"; + snapshot.NotificationBoxShowAppIcon = ShowAppIcon; + snapshot.NotificationBoxShowTimestamp = ShowTimestamp; + snapshot.NotificationBoxTimeFormat = SelectedTimeFormat?.Value ?? "Relative"; + snapshot.NotificationBoxGroupByApp = GroupByApp; + snapshot.NotificationBoxShowClearButton = ShowClearButton; + + _context.ComponentSettingsAccessor.SaveSnapshot(snapshot); + + _context.HostContext.RequestRefresh(); + } + + [ObservableProperty] private string _descriptionText = "配置此消息盒子组件的显示方式。这些设置仅作用于当前组件实例。"; + [ObservableProperty] private string _maxDisplayCountLabel = "最大显示数量"; + [ObservableProperty] private string _maxDisplayCountDescription = "组件中最多显示的通知条数"; + [ObservableProperty] private string _sortOrderLabel = "排序方式"; + [ObservableProperty] private string _displayOptionsLabel = "显示选项"; + [ObservableProperty] private string _showAppIconLabel = "显示应用图标"; + [ObservableProperty] private string _showTimestampLabel = "显示时间戳"; + [ObservableProperty] private string _groupByAppLabel = "按应用分组显示"; + [ObservableProperty] private string _showClearButtonLabel = "显示清空按钮"; + [ObservableProperty] private string _timeFormatLabel = "时间格式"; + + [ObservableProperty] private SelectionOption? _selectedMaxDisplayCount; + [ObservableProperty] private SelectionOption? _selectedSortOrder; + [ObservableProperty] private bool _showAppIcon = true; + [ObservableProperty] private bool _showTimestamp = true; + [ObservableProperty] private SelectionOption? _selectedTimeFormat; + [ObservableProperty] private bool _groupByApp = false; + [ObservableProperty] private bool _showClearButton = true; + + public ObservableCollection MaxDisplayCountOptions { get; } + public ObservableCollection SortOrderOptions { get; } + public ObservableCollection TimeFormatOptions { get; } + + partial void OnSelectedMaxDisplayCountChanged(SelectionOption? value) => SaveSettings(); + partial void OnSelectedSortOrderChanged(SelectionOption? value) => SaveSettings(); + partial void OnShowAppIconChanged(bool value) => SaveSettings(); + partial void OnShowTimestampChanged(bool value) => SaveSettings(); + partial void OnSelectedTimeFormatChanged(SelectionOption? value) => SaveSettings(); + partial void OnGroupByAppChanged(bool value) => SaveSettings(); + partial void OnShowClearButtonChanged(bool value) => SaveSettings(); +} diff --git a/LanMountainDesktop/Views/ComponentEditors/NotificationBoxComponentEditor.axaml b/LanMountainDesktop/Views/ComponentEditors/NotificationBoxComponentEditor.axaml new file mode 100644 index 0000000..52694b9 --- /dev/null +++ b/LanMountainDesktop/Views/ComponentEditors/NotificationBoxComponentEditor.axaml @@ -0,0 +1,88 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/LanMountainDesktop/Views/ComponentEditors/NotificationBoxComponentEditor.axaml.cs b/LanMountainDesktop/Views/ComponentEditors/NotificationBoxComponentEditor.axaml.cs new file mode 100644 index 0000000..8112330 --- /dev/null +++ b/LanMountainDesktop/Views/ComponentEditors/NotificationBoxComponentEditor.axaml.cs @@ -0,0 +1,15 @@ +using Avalonia.Controls; +using LanMountainDesktop.ComponentSystem; +using LanMountainDesktop.ViewModels; + +namespace LanMountainDesktop.Views.ComponentEditors; + +public partial class NotificationBoxComponentEditor : ComponentEditorViewBase +{ + public NotificationBoxComponentEditor(DesktopComponentEditorContext? context) + : base(context) + { + InitializeComponent(); + DataContext = new NotificationBoxEditorViewModel(context); + } +} diff --git a/LanMountainDesktop/Views/Components/DesktopComponentRuntimeRegistry.cs b/LanMountainDesktop/Views/Components/DesktopComponentRuntimeRegistry.cs index 885d521..f2f9e94 100644 --- a/LanMountainDesktop/Views/Components/DesktopComponentRuntimeRegistry.cs +++ b/LanMountainDesktop/Views/Components/DesktopComponentRuntimeRegistry.cs @@ -479,7 +479,11 @@ public sealed class DesktopComponentRuntimeRegistry new DesktopComponentRuntimeRegistration( BuiltInComponentIds.DesktopFileManager, "component.file_manager", - () => new FileManagerWidget()) + () => new FileManagerWidget()), + new DesktopComponentRuntimeRegistration( + BuiltInComponentIds.DesktopNotificationBox, + "component.notification_box", + () => new NotificationBoxWidget()) ]; } diff --git a/LanMountainDesktop/Views/Components/NotificationBoxWidget.axaml b/LanMountainDesktop/Views/Components/NotificationBoxWidget.axaml new file mode 100644 index 0000000..7f03a6f --- /dev/null +++ b/LanMountainDesktop/Views/Components/NotificationBoxWidget.axaml @@ -0,0 +1,92 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/LanMountainDesktop/Views/Components/NotificationBoxWidget.axaml.cs b/LanMountainDesktop/Views/Components/NotificationBoxWidget.axaml.cs new file mode 100644 index 0000000..fbf9851 --- /dev/null +++ b/LanMountainDesktop/Views/Components/NotificationBoxWidget.axaml.cs @@ -0,0 +1,529 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Media; +using Avalonia.Styling; +using Avalonia.Threading; +using LanMountainDesktop.ComponentSystem; +using LanMountainDesktop.Models; +using LanMountainDesktop.PluginSdk; +using LanMountainDesktop.Services; +using LanMountainDesktop.Services.Settings; + +namespace LanMountainDesktop.Views.Components; + +public partial class NotificationBoxWidget : UserControl, + IDesktopComponentWidget, + IComponentSettingsContextAware, + IComponentRuntimeContextAware +{ + private readonly List _notificationControls = []; + private NotificationListenerService? _notificationService; + private IComponentInstanceSettingsStore _componentSettings = null!; + private ISettingsService _appSettingsService = null!; + private AppSettingsSnapshot _appSettings = new(); + private ComponentSettingsSnapshot _componentSettingsSnapshot = new(); + private bool _isAttached; + private bool _isPrivacyMode; + private bool _isNightVisual; + private double _currentCellSize = 48d; + + public NotificationBoxWidget() + { + InitializeComponent(); + + AttachedToVisualTree += OnAttachedToVisualTree; + DetachedFromVisualTree += OnDetachedFromVisualTree; + ActualThemeVariantChanged += OnActualThemeVariantChanged; + + PointerPressed += (_, e) => + { + if (e.Source == NotificationListPanel) + { + ClearSelection(); + } + }; + } + + public void ApplyCellSize(double cellSize) + { + _currentCellSize = Math.Max(1, cellSize); + UpdateAdaptiveLayout(); + } + + public void SetComponentSettingsContext(DesktopComponentSettingsContext context) + { + _componentSettings = context.ComponentSettingsStore; + LoadSettings(); + RefreshUI(); + } + + public void SetComponentRuntimeContext(DesktopComponentRuntimeContext context) + { + _notificationService = NotificationListenerServiceProvider.GetOrCreate(_appSettingsService); + if (_notificationService != null) + { + _notificationService.NotificationReceived += OnNotificationReceived; + _notificationService.NotificationRemoved += OnNotificationRemoved; + } + } + + private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e) + { + _isAttached = true; + _isNightVisual = ResolveNightMode(); + LoadSettings(); + RefreshUI(); + UpdateAdaptiveLayout(); + } + + private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e) + { + _isAttached = false; + + if (_notificationService != null) + { + _notificationService.NotificationReceived -= OnNotificationReceived; + _notificationService.NotificationRemoved -= OnNotificationRemoved; + } + } + + private void OnActualThemeVariantChanged(object? sender, EventArgs e) + { + _isNightVisual = ResolveNightMode(); + UpdateAdaptiveLayout(); + } + + private bool ResolveNightMode() + { + if (ActualThemeVariant == ThemeVariant.Dark) + { + return true; + } + + if (ActualThemeVariant == ThemeVariant.Light) + { + return false; + } + + if (this.TryFindResource("AdaptiveSurfaceBaseBrush", out var value) && + value is ISolidColorBrush brush) + { + return CalculateRelativeLuminance(brush.Color) < 0.45; + } + + return true; + } + + private static double CalculateRelativeLuminance(Color color) + { + static double ToLinear(double channel) + { + return channel <= 0.03928 + ? channel / 12.92 + : Math.Pow((channel + 0.055) / 1.055, 2.4); + } + + var r = ToLinear(color.R / 255d); + var g = ToLinear(color.G / 255d); + var b = ToLinear(color.B / 255d); + return 0.2126 * r + 0.7152 * g + 0.0722 * b; + } + + private void LoadSettings() + { + var appSettingsFacade = HostSettingsFacadeProvider.GetOrCreate(); + _appSettingsService = appSettingsFacade.Settings; + _appSettings = _appSettingsService.LoadSnapshot(SettingsScope.App); + _isPrivacyMode = _appSettings.NotificationBoxPrivacyMode; + + _componentSettingsSnapshot = _componentSettings?.Load() + ?? new ComponentSettingsSnapshot(); + } + + private void RefreshUI() + { + if (!_isAttached) return; + + var hasNotifications = _notificationService?.GetNotifications().Count > 0; + PrivacyOverlay.IsVisible = _isPrivacyMode && hasNotifications && _notificationService?.GetUnreadCount() > 0; + NotificationListPanel.IsVisible = !PrivacyOverlay.IsVisible; + + var unreadCount = _notificationService?.GetUnreadCount() ?? 0; + UnreadBadge.IsVisible = unreadCount > 0; + UnreadCountText.Text = unreadCount.ToString(); + + ClearButton.IsVisible = _componentSettingsSnapshot.NotificationBoxShowClearButton + && hasNotifications; + + UpdateStatusText(); + RenderNotifications(); + } + + private void RenderNotifications() + { + NotificationListPanel.Children.Clear(); + _notificationControls.Clear(); + + if (_notificationService == null) + { + EmptyStateText.IsVisible = true; + EmptyStateText.Text = "通知服务未启动"; + return; + } + + var notifications = _notificationService.GetNotifications(); + + if (notifications.Count == 0) + { + EmptyStateText.IsVisible = true; + EmptyStateText.Text = "暂无通知"; + return; + } + + EmptyStateText.IsVisible = false; + + notifications = ApplySorting(notifications); + + var maxCount = _componentSettingsSnapshot.NotificationBoxMaxDisplayCount; + notifications = notifications.Take(maxCount).ToList(); + + if (_componentSettingsSnapshot.NotificationBoxGroupByApp) + { + RenderGroupedNotifications(notifications); + } + else + { + RenderFlatNotifications(notifications); + } + } + + private IReadOnlyList ApplySorting(IReadOnlyList notifications) + { + return _componentSettingsSnapshot.NotificationBoxSortOrder switch + { + "TimeAsc" => notifications.OrderBy(n => n.ReceivedTime).ToList(), + "AppGroup" => notifications.OrderBy(n => n.AppName).ThenByDescending(n => n.ReceivedTime).ToList(), + _ => notifications.OrderByDescending(n => n.ReceivedTime).ToList() + }; + } + + private void RenderFlatNotifications(IReadOnlyList notifications) + { + foreach (var notification in notifications) + { + var control = CreateNotificationControl(notification); + NotificationListPanel.Children.Add(control); + _notificationControls.Add(control); + } + } + + private void RenderGroupedNotifications(IReadOnlyList notifications) + { + var grouped = notifications.GroupBy(n => n.AppName).ToList(); + + foreach (var group in grouped) + { + var groupHeader = new TextBlock + { + Text = group.Key, + FontWeight = FontWeight.SemiBold, + FontSize = 11, + Foreground = new SolidColorBrush(Color.Parse("#8B95A5")), + Margin = new Thickness(0, 6, 0, 3) + }; + NotificationListPanel.Children.Add(groupHeader); + + foreach (var notification in group) + { + var control = CreateNotificationControl(notification); + NotificationListPanel.Children.Add(control); + _notificationControls.Add(control); + } + } + } + + private NotificationItemControl CreateNotificationControl(NotificationItem notification) + { + var control = new NotificationItemControl(notification, _componentSettingsSnapshot, _isNightVisual); + control.Clicked += OnNotificationClicked; + control.MarkAsRead += OnMarkAsRead; + return control; + } + + private void OnNotificationReceived(object? sender, NotificationItem notification) + { + Dispatcher.UIThread.InvokeAsync(() => + { + if (!_isAttached) return; + RefreshUI(); + }); + } + + private void OnNotificationRemoved(object? sender, string notificationId) + { + Dispatcher.UIThread.InvokeAsync(() => + { + if (!_isAttached) return; + RefreshUI(); + }); + } + + private void OnNotificationClicked(object? sender, NotificationItem notification) + { + } + + private void OnMarkAsRead(object? sender, NotificationItem notification) + { + _notificationService?.MarkAsRead(notification.Id); + RefreshUI(); + } + + private void OnClearButtonClick(object? sender, RoutedEventArgs e) + { + _notificationService?.ClearAll(); + RefreshUI(); + e.Handled = true; + } + + private void UpdateStatusText() + { + var total = _notificationService?.GetNotifications().Count ?? 0; + var max = _componentSettingsSnapshot.NotificationBoxMaxDisplayCount; + StatusTextBlock.Text = $"共 {total} 条" + (total > max ? $"(显{max})" : ""); + } + + private void ClearSelection() + { + foreach (var control in _notificationControls) + { + control.IsSelected = false; + } + } + + private void UpdateAdaptiveLayout() + { + var scale = Math.Clamp(_currentCellSize / 48.0, 0.7, 1.8); + var fontScale = Math.Clamp(scale, 0.8, 1.4); + + var cornerRadius = ResolveUnifiedMainRadiusValue(); + RootBorder.CornerRadius = new CornerRadius(cornerRadius); + CardBorder.CornerRadius = new CornerRadius(cornerRadius); + + CardBorder.Background = new SolidColorBrush(_isNightVisual ? Color.Parse("#1B2129") : Color.Parse("#FCFCFD")); + + HeaderTextBlock.FontSize = 15 * fontScale; + HeaderIcon.FontSize = 16 * fontScale; + UnreadCountText.FontSize = 11 * fontScale; + EmptyStateText.FontSize = 13 * fontScale; + StatusTextBlock.FontSize = 11 * fontScale; + + var padding = Math.Clamp(12 * scale, 8, 20); + var verticalPadding = Math.Clamp(10 * scale, 6, 16); + CardBorder.Padding = new Thickness(padding, verticalPadding); + + foreach (var control in _notificationControls) + { + control.UpdateTheme(_isNightVisual, fontScale); + } + } + + private static double ResolveUnifiedMainRadiusValue() => + HostAppearanceThemeProvider.GetOrCreate().GetCurrent().CornerRadiusTokens.Lg.TopLeft; +} + +public class NotificationItemControl : Border +{ + private readonly NotificationItem _item; + private readonly ComponentSettingsSnapshot _settings; + private bool _isPointerPressed; + private Point _pointerPressedPosition; + private bool _isNightVisual; + + public NotificationItemControl(NotificationItem item, ComponentSettingsSnapshot settings, bool isNightVisual) + { + _item = item; + _settings = settings; + _isNightVisual = isNightVisual; + + Background = _item.IsRead + ? new SolidColorBrush(isNightVisual ? Color.Parse("#2D3440") : Color.Parse("#F5F5F5")) + : new SolidColorBrush(isNightVisual ? Color.Parse("#3D4250") : Color.Parse("#FFFFFF")); + CornerRadius = new CornerRadius(6); + Padding = new Thickness(10, 6); + Cursor = new Cursor(StandardCursorType.Hand); + BorderBrush = _item.IsRead + ? new SolidColorBrush(Colors.Transparent) + : new SolidColorBrush(Color.Parse("#E24B2D")); + BorderThickness = _item.IsRead ? new Thickness(0) : new Thickness(2, 0, 0, 0); + + BuildUI(); + + PointerPressed += OnPointerPressed; + PointerReleased += OnPointerReleased; + } + + private void BuildUI() + { + var grid = new Grid { ColumnDefinitions = ColumnDefinitions.Parse("Auto,*,Auto") }; + + if (_settings.NotificationBoxShowAppIcon) + { + var iconBorder = new Border + { + Width = 28, + Height = 28, + CornerRadius = new CornerRadius(4), + Background = new SolidColorBrush(_isNightVisual ? Color.Parse("#4D5560") : Color.Parse("#E8EAED")), + Margin = new Thickness(0, 0, 8, 0) + }; + var iconText = new TextBlock + { + Text = _item.AppName.Length > 0 ? _item.AppName[0].ToString() : "?", + HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center, + VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center, + FontSize = 12, + FontWeight = FontWeight.SemiBold, + Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#E8EAED") : Color.Parse("#202327")) + }; + iconBorder.Child = iconText; + grid.Children.Add(iconBorder); + } + + var contentPanel = new StackPanel { Spacing = 1 }; + Grid.SetColumn(contentPanel, 1); + + var titleBlock = new TextBlock + { + Text = _item.Title, + FontWeight = FontWeight.SemiBold, + FontSize = 12, + TextTrimming = TextTrimming.CharacterEllipsis, + MaxLines = 1, + Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#E8EAED") : Color.Parse("#202327")) + }; + + var contentBlock = new TextBlock + { + Text = _item.Content, + FontSize = 11, + Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#A8B1C2") : Color.Parse("#5E6671")), + TextTrimming = TextTrimming.CharacterEllipsis, + MaxLines = 2, + TextWrapping = TextWrapping.Wrap + }; + + contentPanel.Children.Add(titleBlock); + if (!string.IsNullOrWhiteSpace(_item.Content)) + { + contentPanel.Children.Add(contentBlock); + } + grid.Children.Add(contentPanel); + + if (_settings.NotificationBoxShowTimestamp) + { + var timeText = _settings.NotificationBoxTimeFormat == "Relative" + ? GetRelativeTime(_item.ReceivedTime) + : _item.ReceivedTime.ToString("HH:mm"); + + var timeBlock = new TextBlock + { + Text = timeText, + FontSize = 10, + Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#8B95A5") : Color.Parse("#8B95A5")), + VerticalAlignment = Avalonia.Layout.VerticalAlignment.Top, + Margin = new Thickness(6, 0, 0, 0) + }; + Grid.SetColumn(timeBlock, 2); + grid.Children.Add(timeBlock); + } + + Child = grid; + } + + public void UpdateTheme(bool isNightVisual, double fontScale) + { + _isNightVisual = isNightVisual; + Background = _item.IsRead + ? new SolidColorBrush(isNightVisual ? Color.Parse("#2D3440") : Color.Parse("#F5F5F5")) + : new SolidColorBrush(isNightVisual ? Color.Parse("#3D4250") : Color.Parse("#FFFFFF")); + + if (Child is Grid grid) + { + foreach (var child in grid.Children) + { + if (child is StackPanel panel) + { + foreach (var textBlock in panel.Children.OfType()) + { + textBlock.FontSize *= fontScale; + } + } + } + } + } + + private void OnPointerPressed(object? sender, PointerPressedEventArgs e) + { + if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) + { + _isPointerPressed = true; + _pointerPressedPosition = e.GetPosition(this); + IsSelected = true; + e.Handled = true; + } + } + + private void OnPointerReleased(object? sender, PointerReleasedEventArgs e) + { + if (!_isPointerPressed) return; + + _isPointerPressed = false; + var releasePosition = e.GetPosition(this); + var distance = Math.Sqrt( + Math.Pow(releasePosition.X - _pointerPressedPosition.X, 2) + + Math.Pow(releasePosition.Y - _pointerPressedPosition.Y, 2)); + + if (distance < 5) + { + Clicked?.Invoke(this, _item); + MarkAsRead?.Invoke(this, _item); + } + + e.Handled = true; + } + + private static string GetRelativeTime(DateTime time) + { + var diff = DateTime.Now - time; + + if (diff.TotalMinutes < 1) return "刚刚"; + if (diff.TotalMinutes < 60) return $"{(int)diff.TotalMinutes}分前"; + if (diff.TotalHours < 24) return $"{(int)diff.TotalHours}小时前"; + return $"{(int)diff.TotalDays}天前"; + } + + public bool IsSelected { get; set; } + + public event EventHandler? Clicked; + public event EventHandler? MarkAsRead; +} + +public static class NotificationListenerServiceProvider +{ + private static NotificationListenerService? _instance; + + public static NotificationListenerService GetOrCreate(ISettingsService settingsService) + { + if (_instance == null) + { + _instance = new NotificationListenerService(settingsService); + _instance.InitializeAsync().ConfigureAwait(false); + } + return _instance; + } +}