mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-20 23:54:26 +08:00
fead.消息盒子组件
This commit is contained in:
@@ -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";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
54
LanMountainDesktop/Models/NotificationItem.cs
Normal file
54
LanMountainDesktop/Models/NotificationItem.cs
Normal 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; }
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
216
LanMountainDesktop/Services/LinuxNotificationListener.cs
Normal file
216
LanMountainDesktop/Services/LinuxNotificationListener.cs
Normal 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("<", "<");
|
||||||
|
result = result.Replace(">", ">");
|
||||||
|
result = result.Replace("&", "&");
|
||||||
|
result = result.Replace(""", "\"");
|
||||||
|
return result.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_isRunning = false;
|
||||||
|
_cts?.Cancel();
|
||||||
|
_cts?.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
201
LanMountainDesktop/Services/NotificationListenerService.cs
Normal file
201
LanMountainDesktop/Services/NotificationListenerService.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
115
LanMountainDesktop/ViewModels/NotificationBoxEditorViewModel.cs
Normal file
115
LanMountainDesktop/ViewModels/NotificationBoxEditorViewModel.cs
Normal 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();
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user