fead.消息盒子组件

This commit is contained in:
lincube
2026-04-07 00:49:33 +08:00
parent 0662565dca
commit 5fa2031ad6
15 changed files with 1403 additions and 1 deletions

View File

@@ -45,4 +45,5 @@ public static class BuiltInComponentIds
public const string DesktopRemovableStorage = "DesktopRemovableStorage"; public const string DesktopRemovableStorage = "DesktopRemovableStorage";
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";
} }

View File

@@ -410,6 +410,16 @@ public sealed class ComponentRegistry
MinHeightCells: 4, MinHeightCells: 4,
AllowStatusBarPlacement: false, AllowStatusBarPlacement: false,
AllowDesktopPlacement: true, AllowDesktopPlacement: true,
ResizeMode: DesktopComponentResizeMode.Free),
new DesktopComponentDefinition(
BuiltInComponentIds.DesktopNotificationBox,
"消息盒子",
"Inbox",
"Info",
MinWidthCells: 2,
MinHeightCells: 2,
AllowStatusBarPlacement: false,
AllowDesktopPlacement: true,
ResizeMode: DesktopComponentResizeMode.Free) ResizeMode: DesktopComponentResizeMode.Free)
}; };

View File

@@ -76,6 +76,7 @@
<PackageReference Include="WebView.Avalonia" Version="11.0.0.1" /> <PackageReference Include="WebView.Avalonia" Version="11.0.0.1" />
<PackageReference Include="WebView.Avalonia.Desktop" Version="11.0.0.1" /> <PackageReference Include="WebView.Avalonia.Desktop" Version="11.0.0.1" />
<PackageReference Include="YamlDotNet" Version="16.3.0" /> <PackageReference Include="YamlDotNet" Version="16.3.0" />
<PackageReference Include="Tmds.DBus.Protocol" Version="0.22.0" />
</ItemGroup> </ItemGroup>
<Target Name="CopyPluginsInstallHelperToOutput" AfterTargets="Build"> <Target Name="CopyPluginsInstallHelperToOutput" AfterTargets="Build">

View File

@@ -200,6 +200,35 @@ public sealed class AppSettingsSnapshot
#endregion #endregion
#region Notification Box Settings ()
/// <summary>
/// 启用消息盒子功能Windows通知监听
/// </summary>
public bool NotificationBoxEnabled { get; set; } = true;
/// <summary>
/// 隐私模式:开启后只显示"您有新的通知",不显示具体内容
/// </summary>
public bool NotificationBoxPrivacyMode { get; set; } = false;
/// <summary>
/// 被屏蔽的应用列表(不接收这些应用的通知)
/// </summary>
public List<string> NotificationBoxBlockedApps { get; set; } = [];
/// <summary>
/// 历史记录保留天数
/// </summary>
public int NotificationBoxHistoryRetentionDays { get; set; } = 7;
/// <summary>
/// 最大存储通知数量(防止内存无限增长)
/// </summary>
public int NotificationBoxMaxStoredCount { get; set; } = 500;
#endregion
public AppSettingsSnapshot Clone() public AppSettingsSnapshot Clone()
{ {
var clone = (AppSettingsSnapshot)MemberwiseClone(); var clone = (AppSettingsSnapshot)MemberwiseClone();
@@ -213,6 +242,9 @@ public sealed class AppSettingsSnapshot
clone.DisabledPluginIds = DisabledPluginIds is { Count: > 0 } clone.DisabledPluginIds = DisabledPluginIds is { Count: > 0 }
? new List<string>(DisabledPluginIds) ? new List<string>(DisabledPluginIds)
: []; : [];
clone.NotificationBoxBlockedApps = NotificationBoxBlockedApps is { Count: > 0 }
? new List<string>(NotificationBoxBlockedApps)
: [];
return clone; return clone;
} }

View File

@@ -84,6 +84,45 @@ public sealed class ComponentSettingsSnapshot
public int ZhiJiaoHubCurrentImageIndex { get; set; } = 0; public int ZhiJiaoHubCurrentImageIndex { get; set; } = 0;
#region Notification Box Component Settings ()
/// <summary>
/// 组件内最大显示通知数量
/// </summary>
public int NotificationBoxMaxDisplayCount { get; set; } = 50;
/// <summary>
/// 排序方式TimeDesc(时间倒序), TimeAsc(时间正序), AppGroup(按应用分组)
/// </summary>
public string NotificationBoxSortOrder { get; set; } = "TimeDesc";
/// <summary>
/// 是否显示应用图标
/// </summary>
public bool NotificationBoxShowAppIcon { get; set; } = true;
/// <summary>
/// 是否显示时间戳
/// </summary>
public bool NotificationBoxShowTimestamp { get; set; } = true;
/// <summary>
/// 时间格式Relative(相对时间,如"5分钟前"), Absolute(绝对时间)
/// </summary>
public string NotificationBoxTimeFormat { get; set; } = "Relative";
/// <summary>
/// 是否按应用分组显示
/// </summary>
public bool NotificationBoxGroupByApp { get; set; } = false;
/// <summary>
/// 是否显示清除按钮
/// </summary>
public bool NotificationBoxShowClearButton { get; set; } = true;
#endregion
public ComponentSettingsSnapshot Clone() public ComponentSettingsSnapshot Clone()
{ {
var clone = (ComponentSettingsSnapshot)MemberwiseClone(); var clone = (ComponentSettingsSnapshot)MemberwiseClone();

View File

@@ -0,0 +1,54 @@
using System;
namespace LanMountainDesktop.Models;
/// <summary>
/// 通知项数据模型
/// </summary>
public sealed class NotificationItem
{
/// <summary>
/// 唯一标识
/// </summary>
public string Id { get; set; } = Guid.NewGuid().ToString();
/// <summary>
/// 应用ID如 WeChat, Outlook 等)
/// </summary>
public string AppId { get; set; } = string.Empty;
/// <summary>
/// 应用名称
/// </summary>
public string AppName { get; set; } = string.Empty;
/// <summary>
/// 应用图标路径或Base64
/// </summary>
public string? AppIconPath { get; set; }
/// <summary>
/// 通知标题
/// </summary>
public string Title { get; set; } = string.Empty;
/// <summary>
/// 通知内容
/// </summary>
public string Content { get; set; } = string.Empty;
/// <summary>
/// 接收时间
/// </summary>
public DateTime ReceivedTime { get; set; } = DateTime.Now;
/// <summary>
/// 是否已读
/// </summary>
public bool IsRead { get; set; } = false;
/// <summary>
/// 原始通知的额外数据(用于点击跳转)
/// </summary>
public string? LaunchArgs { get; set; }
}

View File

@@ -267,6 +267,11 @@ public static class DesktopComponentEditorRegistryFactory
BuiltInComponentIds.DesktopZhiJiaoHub, BuiltInComponentIds.DesktopZhiJiaoHub,
context => new ZhiJiaoHubComponentEditor(context), context => new ZhiJiaoHubComponentEditor(context),
preferredWidth: 480d, preferredWidth: 480d,
preferredHeight: 520d),
[BuiltInComponentIds.DesktopNotificationBox] = new(
BuiltInComponentIds.DesktopNotificationBox,
context => new NotificationBoxComponentEditor(context),
preferredWidth: 480d,
preferredHeight: 520d) preferredHeight: 520d)
}; };

View File

@@ -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;
/// <summary>
/// Linux平台通知监听器 - 通过DBus监听org.freedesktop.Notifications
/// </summary>
[SupportedOSPlatform("linux")]
internal sealed class LinuxNotificationListener : IDisposable
{
private readonly NotificationListenerService _parent;
private CancellationTokenSource? _cts;
private bool _isRunning;
public LinuxNotificationListener(NotificationListenerService parent)
{
_parent = parent;
}
/// <summary>
/// 初始化并启动DBus监听
/// </summary>
public async Task<bool> 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<bool> 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}");
}
}
/// <summary>
/// 处理接收到的通知供DBus信号处理器调用
/// </summary>
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}");
}
}
/// <summary>
/// 解析应用图标路径
/// </summary>
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);
}
/// <summary>
/// 去除HTML标签通知内容可能包含HTML
/// </summary>
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("&lt;", "<");
result = result.Replace("&gt;", ">");
result = result.Replace("&amp;", "&");
result = result.Replace("&quot;", "\"");
return result.Trim();
}
public void Dispose()
{
_isRunning = false;
_cts?.Cancel();
_cts?.Dispose();
}
}

View File

@@ -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;
/// <summary>
/// 跨平台通知监听服务
/// </summary>
public sealed class NotificationListenerService : IDisposable
{
private readonly List<NotificationItem> _notifications = [];
private readonly object _lock = new();
private readonly ISettingsService _settingsService;
// 平台特定的监听器
private LinuxNotificationListener? _linuxListener;
public event EventHandler<NotificationItem>? NotificationReceived;
public event EventHandler<string>? NotificationRemoved;
public NotificationListenerService(ISettingsService settingsService)
{
_settingsService = settingsService;
}
/// <summary>
/// 初始化并启动监听
/// </summary>
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}");
}
}
/// <summary>
/// 添加通知(供平台监听器调用)
/// </summary>
public void AddNotification(NotificationItem notification)
{
var settings = _settingsService.LoadSnapshot<AppSettingsSnapshot>(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);
});
}
/// <summary>
/// 移除通知
/// </summary>
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);
}
/// <summary>
/// 获取所有通知
/// </summary>
public IReadOnlyList<NotificationItem> GetNotifications()
{
lock (_lock)
{
return _notifications.ToList().AsReadOnly();
}
}
/// <summary>
/// 清空所有通知
/// </summary>
public void ClearAll()
{
lock (_lock)
{
_notifications.Clear();
}
}
/// <summary>
/// 标记通知为已读
/// </summary>
public void MarkAsRead(string notificationId)
{
lock (_lock)
{
var notification = _notifications.FirstOrDefault(n => n.Id == notificationId);
if (notification != null)
{
notification.IsRead = true;
}
}
}
/// <summary>
/// 获取未读通知数量
/// </summary>
public int GetUnreadCount()
{
lock (_lock)
{
return _notifications.Count(n => !n.IsRead);
}
}
public void Dispose()
{
_linuxListener?.Dispose();
ClearAll();
}
}

View File

@@ -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<SelectionOption>
{
new("20", "20条"),
new("50", "50条"),
new("100", "100条"),
new("200", "200条")
};
SortOrderOptions = new ObservableCollection<SelectionOption>
{
new("TimeDesc", "最新优先"),
new("TimeAsc", "最早优先"),
new("AppGroup", "按应用分组")
};
TimeFormatOptions = new ObservableCollection<SelectionOption>
{
new("Relative", "相对时间5分钟前"),
new("Absolute", "绝对时间14:30")
};
LoadSettings();
}
private void LoadSettings()
{
var snapshot = _context?.ComponentSettingsAccessor.LoadSnapshot<ComponentSettingsSnapshot>()
?? 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<ComponentSettingsSnapshot>();
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<SelectionOption> MaxDisplayCountOptions { get; }
public ObservableCollection<SelectionOption> SortOrderOptions { get; }
public ObservableCollection<SelectionOption> 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();
}

View File

@@ -0,0 +1,88 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:LanMountainDesktop.ViewModels"
x:Class="LanMountainDesktop.Views.ComponentEditors.NotificationBoxComponentEditor"
x:DataType="vm:NotificationBoxEditorViewModel">
<StackPanel Spacing="16">
<!-- 说明卡片 -->
<Border Classes="component-editor-card" Padding="20">
<TextBlock Text="{Binding DescriptionText}"
Classes="component-editor-secondary-text"
TextWrapping="Wrap" />
</Border>
<!-- 最大显示数量 -->
<Border Classes="component-editor-card" Padding="20">
<StackPanel Spacing="12">
<TextBlock Text="{Binding MaxDisplayCountLabel}"
Classes="component-editor-section-title" />
<TextBlock Text="{Binding MaxDisplayCountDescription}"
Classes="component-editor-secondary-text" />
<ComboBox ItemsSource="{Binding MaxDisplayCountOptions}"
SelectedItem="{Binding SelectedMaxDisplayCount}"
HorizontalAlignment="Stretch">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:SelectionOption">
<TextBlock Text="{Binding Label}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</StackPanel>
</Border>
<!-- 排序方式 -->
<Border Classes="component-editor-card" Padding="20">
<StackPanel Spacing="12">
<TextBlock Text="{Binding SortOrderLabel}"
Classes="component-editor-section-title" />
<ComboBox ItemsSource="{Binding SortOrderOptions}"
SelectedItem="{Binding SelectedSortOrder}"
HorizontalAlignment="Stretch">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:SelectionOption">
<TextBlock Text="{Binding Label}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</StackPanel>
</Border>
<!-- 显示选项 -->
<Border Classes="component-editor-card" Padding="20">
<StackPanel Spacing="16">
<TextBlock Text="{Binding DisplayOptionsLabel}"
Classes="component-editor-section-title" />
<CheckBox IsChecked="{Binding ShowAppIcon}"
Content="{Binding ShowAppIconLabel}" />
<CheckBox IsChecked="{Binding ShowTimestamp}"
Content="{Binding ShowTimestampLabel}" />
<CheckBox IsChecked="{Binding GroupByApp}"
Content="{Binding GroupByAppLabel}" />
<CheckBox IsChecked="{Binding ShowClearButton}"
Content="{Binding ShowClearButtonLabel}" />
</StackPanel>
</Border>
<!-- 时间格式 -->
<Border Classes="component-editor-card" Padding="20">
<StackPanel Spacing="12">
<TextBlock Text="{Binding TimeFormatLabel}"
Classes="component-editor-section-title" />
<ComboBox ItemsSource="{Binding TimeFormatOptions}"
SelectedItem="{Binding SelectedTimeFormat}"
HorizontalAlignment="Stretch">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:SelectionOption">
<TextBlock Text="{Binding Label}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</StackPanel>
</Border>
</StackPanel>
</UserControl>

View File

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

View File

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

View File

@@ -0,0 +1,92 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:fi="using:FluentIcons.Avalonia.Fluent"
xmlns:symbol="using:FluentIcons.Common"
x:Class="LanMountainDesktop.Views.Components.NotificationBoxWidget">
<Border x:Name="RootBorder"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
Background="Transparent"
ClipToBounds="True">
<Grid>
<!-- 主卡片 -->
<Border x:Name="CardBorder"
Background="#FCFCFD"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
Padding="12,10">
<Grid RowDefinitions="Auto,*,Auto">
<!-- 头部 -->
<Grid Grid.Row="0" ColumnDefinitions="*,Auto">
<StackPanel Orientation="Horizontal" Spacing="6" VerticalAlignment="Center">
<fi:SymbolIcon x:Name="HeaderIcon" Symbol="{x:Static symbol:Symbol.MailInbox}" FontSize="16" />
<TextBlock x:Name="HeaderTextBlock"
Text="消息盒子"
FontSize="15"
FontWeight="SemiBold" />
<Border x:Name="UnreadBadge"
Background="#E24B2D"
CornerRadius="8"
Padding="5,2"
IsVisible="False">
<TextBlock x:Name="UnreadCountText"
Foreground="White"
FontSize="11"
FontWeight="Bold" />
</Border>
</StackPanel>
<Button x:Name="ClearButton"
Grid.Column="1"
IsVisible="False"
Click="OnClearButtonClick"
Padding="6"
Background="Transparent"
BorderThickness="0">
<fi:SymbolIcon Symbol="{x:Static symbol:Symbol.Delete}" FontSize="14" />
</Button>
</Grid>
<!-- 通知列表 -->
<ScrollViewer Grid.Row="1"
Margin="0,8,0,0"
VerticalScrollBarVisibility="Auto">
<StackPanel x:Name="NotificationListPanel" Spacing="6" />
</ScrollViewer>
<!-- 空状态 -->
<TextBlock x:Name="EmptyStateText"
Grid.Row="1"
Text="暂无通知"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Foreground="#8B95A5"
FontSize="13"
IsVisible="False" />
<!-- 隐私模式遮罩 -->
<Border x:Name="PrivacyOverlay"
Grid.Row="1"
Background="{DynamicResource SolidBackgroundFillColorBaseBrush}"
CornerRadius="{DynamicResource DesignCornerRadiusSm}"
IsVisible="False">
<StackPanel HorizontalAlignment="Center"
VerticalAlignment="Center"
Spacing="6">
<fi:SymbolIcon Symbol="{x:Static symbol:Symbol.EyeOff}" FontSize="24" Foreground="#8B95A5" />
<TextBlock Text="您有新的通知"
Foreground="#8B95A5"
FontSize="12" />
</StackPanel>
</Border>
<!-- 底部状态 -->
<TextBlock x:Name="StatusTextBlock"
Grid.Row="2"
FontSize="11"
Foreground="#8B95A5"
Margin="0,6,0,0" />
</Grid>
</Border>
</Grid>
</Border>
</UserControl>

View File

@@ -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<NotificationItemControl> _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<AppSettingsSnapshot>(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<NotificationItem> ApplySorting(IReadOnlyList<NotificationItem> 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<NotificationItem> notifications)
{
foreach (var notification in notifications)
{
var control = CreateNotificationControl(notification);
NotificationListPanel.Children.Add(control);
_notificationControls.Add(control);
}
}
private void RenderGroupedNotifications(IReadOnlyList<NotificationItem> 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>())
{
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<NotificationItem>? Clicked;
public event EventHandler<NotificationItem>? 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;
}
}