diff --git a/LanMountainDesktop/App.axaml.cs b/LanMountainDesktop/App.axaml.cs
index 8ebf6b0..7f268fd 100644
--- a/LanMountainDesktop/App.axaml.cs
+++ b/LanMountainDesktop/App.axaml.cs
@@ -54,6 +54,7 @@ public partial class App : Application
private ISettingsPageRegistry? _settingsPageRegistry;
private ISettingsWindowService? _settingsWindowService;
private WeatherLocationRefreshService? _weatherLocationRefreshService;
+ private INotificationService? _notificationService;
private bool _exitCleanupCompleted;
private DesktopShellState _desktopShellState = DesktopShellState.ForegroundDesktop;
private ShutdownIntent _shutdownIntent;
@@ -73,6 +74,8 @@ public partial class App : Application
internal static SingleInstanceService? CurrentSingleInstanceService { get; set; }
internal static IHostApplicationLifecycle? CurrentHostApplicationLifecycle =>
(Current as App)?._hostApplicationLifecycle;
+ internal static INotificationService? CurrentNotificationService =>
+ (Current as App)?._notificationService;
// 隐私政策查看事件
public static event Action? CurrentPrivacyPolicyViewRequested;
@@ -87,6 +90,7 @@ public partial class App : Application
public ISettingsFacadeService SettingsFacade => _settingsFacade;
public IHostApplicationLifecycle HostApplicationLifecycle => _hostApplicationLifecycle;
internal ISettingsWindowService? SettingsWindowService => _settingsWindowService;
+ internal INotificationService? NotificationService => _notificationService;
internal void OpenIndependentSettingsModule(string source, string? pageTag = null)
{
@@ -128,6 +132,7 @@ public partial class App : Application
ApplyCurrentCultureFromSettings();
EnsureSettingsWindowService();
EnsureWeatherLocationRefreshService();
+ EnsureNotificationService();
}
public override void OnFrameworkInitializationCompleted()
@@ -400,6 +405,11 @@ public partial class App : Application
_localizationService);
}
+ private void EnsureNotificationService()
+ {
+ _notificationService ??= new NotificationService(_appearanceThemeService);
+ }
+
private void StartWeatherLocationRefreshIfNeeded()
{
EnsureWeatherLocationRefreshService();
diff --git a/LanMountainDesktop/Controls/SettingsOptionCard.axaml.cs b/LanMountainDesktop/Controls/SettingsOptionCard.axaml.cs
index 60b02ea..b939744 100644
--- a/LanMountainDesktop/Controls/SettingsOptionCard.axaml.cs
+++ b/LanMountainDesktop/Controls/SettingsOptionCard.axaml.cs
@@ -107,6 +107,8 @@ public partial class SettingsOptionCard : UserControl
"PuzzlePiece" => Symbol.PuzzlePiece,
"Info" => Symbol.Info,
"ArrowSync" => Symbol.ArrowSync,
+ "Alert" => Symbol.Alert,
+ "Bell" => Symbol.Alert, // Bell也映射到Alert图标
_ => Symbol.Settings
};
}
diff --git a/LanMountainDesktop/Controls/SmoothBorder.cs b/LanMountainDesktop/Controls/SmoothBorder.cs
new file mode 100644
index 0000000..5446d6d
--- /dev/null
+++ b/LanMountainDesktop/Controls/SmoothBorder.cs
@@ -0,0 +1,234 @@
+using System;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Media;
+using Avalonia.Platform;
+
+namespace LanMountainDesktop.Controls;
+
+///
+/// A Decorator that renders a border with continuous "Squircle" corners (super-ellipse).
+/// Ported and adapted from SeiWoLauncherPro for Avalonia 11.
+///
+public class SmoothBorder : Decorator
+{
+ public static readonly StyledProperty BackgroundProperty =
+ Border.BackgroundProperty.AddOwner();
+
+ public static readonly StyledProperty BorderBrushProperty =
+ Border.BorderBrushProperty.AddOwner();
+
+ public static readonly StyledProperty BorderThicknessProperty =
+ Border.BorderThicknessProperty.AddOwner();
+
+ public static readonly StyledProperty CornerRadiusProperty =
+ Border.CornerRadiusProperty.AddOwner();
+
+ public static readonly StyledProperty SmoothnessProperty =
+ AvaloniaProperty.Register(nameof(Smoothness), 0.6);
+
+ public IBrush? Background
+ {
+ get => GetValue(BackgroundProperty);
+ set => SetValue(BackgroundProperty, value);
+ }
+
+ public IBrush? BorderBrush
+ {
+ get => GetValue(BorderBrushProperty);
+ set => SetValue(BorderBrushProperty, value);
+ }
+
+ public Thickness BorderThickness
+ {
+ get => GetValue(BorderThicknessProperty);
+ set => SetValue(BorderThicknessProperty, value);
+ }
+
+ public CornerRadius CornerRadius
+ {
+ get => GetValue(CornerRadiusProperty);
+ set => SetValue(CornerRadiusProperty, value);
+ }
+
+ public double Smoothness
+ {
+ get => GetValue(SmoothnessProperty);
+ set => SetValue(SmoothnessProperty, value);
+ }
+
+ static SmoothBorder()
+ {
+ AffectsRender(BackgroundProperty, BorderBrushProperty, BorderThicknessProperty, CornerRadiusProperty, SmoothnessProperty);
+ AffectsMeasure(BorderThicknessProperty);
+ }
+
+ protected override Size MeasureOverride(Size constraint)
+ {
+ var padding = BorderThickness;
+ if (Child != null)
+ {
+ Child.Measure(constraint.Deflate(padding));
+ return Child.DesiredSize.Inflate(padding);
+ }
+ return new Size(padding.Left + padding.Right, padding.Top + padding.Bottom);
+ }
+
+ protected override Size ArrangeOverride(Size finalSize)
+ {
+ if (Child != null)
+ {
+ var padding = BorderThickness;
+ Child.Arrange(new Rect(finalSize).Deflate(padding));
+ Child.Clip = CreateSquircle(new Rect(0, 0, finalSize.Width - padding.Left - padding.Right, finalSize.Height - padding.Top - padding.Bottom), CornerRadius, Smoothness);
+ }
+ return finalSize;
+ }
+
+ public override void Render(DrawingContext context)
+ {
+ var rect = new Rect(Bounds.Size);
+ if (rect.Width <= 0 || rect.Height <= 0) return;
+
+ var geometry = CreateSquircle(rect, CornerRadius, Smoothness);
+
+ if (Background != null)
+ {
+ context.DrawGeometry(Background, null, geometry);
+ }
+
+ if (BorderBrush != null && BorderThickness != default)
+ {
+ // Simple implementation for uniform thickness
+ var pen = new Pen(BorderBrush, BorderThickness.Left);
+ context.DrawGeometry(null, pen, geometry);
+ }
+
+ // Apply clipping to children if needed
+ // Note: In Avalonia 11, we usually set Clip property on the child or use a Clip content property.
+ }
+
+ private static Geometry CreateSquircle(Rect rect, CornerRadius radius, double smoothness)
+ {
+ smoothness = Math.Clamp(smoothness, 0, 1);
+ var geometry = new StreamGeometry();
+ using (var ctx = geometry.Open())
+ {
+ // Top-left starting point
+ double pTL = radius.TopLeft * (1 + smoothness);
+ ctx.BeginFigure(new Point(rect.Left + pTL, rect.Top), true);
+
+ // Top-right corner
+ DrawCorner(ctx, rect, radius.TopRight, smoothness, Corner.TopRight);
+ // Bottom-right corner
+ DrawCorner(ctx, rect, radius.BottomRight, smoothness, Corner.BottomRight);
+ // Bottom-left corner
+ DrawCorner(ctx, rect, radius.BottomLeft, smoothness, Corner.BottomLeft);
+ // Top-left corner (closing)
+ DrawCorner(ctx, rect, radius.TopLeft, smoothness, Corner.TopLeft);
+
+ ctx.EndFigure(true);
+ }
+ return geometry;
+ }
+
+ private enum Corner { TopRight, BottomRight, BottomLeft, TopLeft }
+
+ private static void DrawCorner(StreamGeometryContext ctx, Rect rect, double radius, double smoothness, Corner corner)
+ {
+ if (radius <= 0)
+ {
+ Point pt = corner switch {
+ Corner.TopRight => rect.TopRight,
+ Corner.BottomRight => rect.BottomRight,
+ Corner.BottomLeft => rect.BottomLeft,
+ Corner.TopLeft => rect.TopLeft,
+ _ => default
+ };
+ ctx.LineTo(pt);
+ return;
+ }
+
+ double p = radius * (1 + smoothness);
+ double theta = 45 * smoothness;
+ double radTheta = theta * (Math.PI / 180.0);
+ double radBeta = (90 * (1 - smoothness)) * (Math.PI / 180.0);
+
+ double c = radius * Math.Tan(radTheta / 2) * Math.Cos(radTheta);
+ double d = radius * Math.Tan(radTheta / 2) * Math.Sin(radTheta);
+ double arcSeg = Math.Sin(radBeta / 2) * radius * Math.Sqrt(2);
+
+ double b = (p - arcSeg - c - d) / 3;
+ double a = 2 * b;
+
+ // Points relative to corner
+ Point[] points = corner switch
+ {
+ Corner.TopRight => new[] {
+ new Point(rect.Right - (p - a - b - c), rect.Top + d),
+ new Point(rect.Right - (p - a), rect.Top),
+ new Point(rect.Right - (p - a - b), rect.Top),
+ new Point(rect.Right, rect.Top + p),
+ new Point(rect.Right, rect.Top + p - a - b),
+ new Point(rect.Right, rect.Top + p - a)
+ },
+ Corner.BottomRight => new[] {
+ new Point(rect.Right - d, rect.Bottom - (p - a - b - c)),
+ new Point(rect.Right, rect.Bottom - (p - a)),
+ new Point(rect.Right, rect.Bottom - (p - a - b)),
+ new Point(rect.Right - p, rect.Bottom),
+ new Point(rect.Right - (p - a - b), rect.Bottom),
+ new Point(rect.Right - (p - a), rect.Bottom)
+ },
+ Corner.BottomLeft => new[] {
+ new Point(rect.Left + (p - a - b - c), rect.Bottom - d),
+ new Point(rect.Left + (p - a), rect.Bottom),
+ new Point(rect.Left + (p - a - b), rect.Bottom),
+ new Point(rect.Left, rect.Bottom - p),
+ new Point(rect.Left, rect.Bottom - (p - a - b)),
+ new Point(rect.Left, rect.Bottom - (p - a))
+ },
+ Corner.TopLeft => new[] {
+ new Point(rect.Left + d, rect.Top + (p - a - b - c)),
+ new Point(rect.Left, rect.Top + (p - a)),
+ new Point(rect.Left, rect.Top + (p - a - b)),
+ new Point(rect.Left + p, rect.Top),
+ new Point(rect.Left + (p - a - b), rect.Top),
+ new Point(rect.Left + (p - a), rect.Top)
+ },
+ _ => throw new ArgumentOutOfRangeException()
+ };
+
+ // 1. Line to start of segment
+ ctx.LineTo(corner switch {
+ Corner.TopRight => new Point(rect.Right - p, rect.Top),
+ Corner.BottomRight => new Point(rect.Right, rect.Bottom - p),
+ Corner.BottomLeft => new Point(rect.Left + p, rect.Bottom),
+ Corner.TopLeft => new Point(rect.Left, rect.Top + p),
+ _ => default
+ });
+
+ // 2. First Bezier
+ ctx.CubicBezierTo(points[1], points[2], points[0]);
+
+ // 3. Arc
+ double startAngle = corner switch {
+ Corner.TopRight => 270, Corner.BottomRight => 0, Corner.BottomLeft => 90, Corner.TopLeft => 180, _ => 0
+ };
+ double arcEndAngle = startAngle + 90 - theta;
+ double endRad = arcEndAngle * (Math.PI / 180.0);
+ Point center = corner switch {
+ Corner.TopRight => new Point(rect.Right - radius, rect.Top + radius),
+ Corner.BottomRight => new Point(rect.Right - radius, rect.Bottom - radius),
+ Corner.BottomLeft => new Point(rect.Left + radius, rect.Bottom - radius),
+ Corner.TopLeft => new Point(rect.Left + radius, rect.Top + radius),
+ _ => default
+ };
+ Point arcEnd = new Point(center.X + radius * Math.Cos(endRad), center.Y + radius * Math.Sin(endRad));
+
+ ctx.ArcTo(arcEnd, new Size(radius, radius), 0, false, SweepDirection.Clockwise);
+
+ // 4. Second Bezier
+ ctx.CubicBezierTo(points[4], points[5], points[3]);
+ }
+}
diff --git a/LanMountainDesktop/Localization/en-US.json b/LanMountainDesktop/Localization/en-US.json
index 186d835..21d25ef 100644
--- a/LanMountainDesktop/Localization/en-US.json
+++ b/LanMountainDesktop/Localization/en-US.json
@@ -251,6 +251,15 @@
"settings.study.avg_window_label": "Averaging Window",
"settings.study.avg_window_desc": "Time window for smoothing noise display. Larger values make display more stable but slower to respond.",
"settings.study.footer_hint": "These settings affect the behavior of study environment monitoring components.",
+ "common.unit.minutes": "minutes",
+ "common.unit.seconds": "seconds",
+ "common.unit.times": "times",
+ "common.error.save_failed": "Failed to save settings, please try again later",
+ "common.error.load_failed": "Failed to load settings, please try again later",
+ "study.alert.noise_interrupt_title": "Noise Interrupt Alert",
+ "study.alert.noise_interrupt_message": "Current interrupt density: {0}/min\nExceeds threshold: {1}/min",
+ "study.alert.severe_interrupt_title": "Severe Noise Interference",
+ "study.alert.severe_interrupt_message": "Environment is too noisy, severely affecting learning efficiency\nCurrent interrupt density: {0}/min\nSuggestion: Find a quieter study environment",
"settings.weather.location_header": "Weather Location",
"settings.weather.location_desc": "Set the location used by weather widgets.",
"settings.weather.location_placeholder": "e.g. Beijing",
diff --git a/LanMountainDesktop/Localization/zh-CN.json b/LanMountainDesktop/Localization/zh-CN.json
index 2452692..6de73be 100644
--- a/LanMountainDesktop/Localization/zh-CN.json
+++ b/LanMountainDesktop/Localization/zh-CN.json
@@ -254,6 +254,15 @@
"settings.study.avg_window_label": "平均时间窗",
"settings.study.avg_window_desc": "噪音平滑显示的时间窗口,较大的值会使显示更稳定但响应更慢。",
"settings.study.footer_hint": "这些设置将影响自习环境监测组件的行为。",
+ "common.unit.minutes": "分钟",
+ "common.unit.seconds": "秒",
+ "common.unit.times": "次",
+ "common.error.save_failed": "设置保存失败,请稍后重试",
+ "common.error.load_failed": "设置加载失败,请稍后重试",
+ "study.alert.noise_interrupt_title": "噪音打断提醒",
+ "study.alert.noise_interrupt_message": "当前打断密度: {0}次/分钟\n已超过阈值: {1}次/分钟",
+ "study.alert.severe_interrupt_title": "严重噪音干扰",
+ "study.alert.severe_interrupt_message": "环境噪音过于嘈杂,严重影响学习效率\n当前打断密度: {0}次/分钟\n建议:寻找更安静的学习环境",
"weather.widget.location_not_configured": "尚未配置天气位置",
"weather.widget.configure_hint": "请前往 设置 > 天气 完成配置",
"weather.widget.loading": "加载中...",
diff --git a/LanMountainDesktop/Models/AppSettingsSnapshot.cs b/LanMountainDesktop/Models/AppSettingsSnapshot.cs
index 78de6c8..2835b5d 100644
--- a/LanMountainDesktop/Models/AppSettingsSnapshot.cs
+++ b/LanMountainDesktop/Models/AppSettingsSnapshot.cs
@@ -150,6 +150,22 @@ public sealed class AppSettingsSnapshot
#endregion
+ #region Notification Settings
+
+ public bool NotificationEnabled { get; set; } = true;
+
+ public string NotificationDefaultPosition { get; set; } = "TopRight";
+
+ public int NotificationDurationSeconds { get; set; } = 4;
+
+ public bool NotificationHoverPauseEnabled { get; set; } = true;
+
+ public bool NotificationClickCloseEnabled { get; set; } = true;
+
+ public int NotificationMaxPerPosition { get; set; } = 5;
+
+ #endregion
+
public AppSettingsSnapshot Clone()
{
var clone = (AppSettingsSnapshot)MemberwiseClone();
diff --git a/LanMountainDesktop/Services/NotificationService.cs b/LanMountainDesktop/Services/NotificationService.cs
new file mode 100644
index 0000000..8c67ee2
--- /dev/null
+++ b/LanMountainDesktop/Services/NotificationService.cs
@@ -0,0 +1,503 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Threading.Tasks;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Media.Imaging;
+using Avalonia.Platform;
+using Avalonia.Threading;
+using FluentAvalonia.UI.Controls;
+using LanMountainDesktop.Models;
+using LanMountainDesktop.PluginSdk;
+using LanMountainDesktop.Services.Settings;
+using LanMountainDesktop.ViewModels;
+using LanMountainDesktop.Views;
+
+namespace LanMountainDesktop.Services;
+
+public enum NotificationPosition
+{
+ TopLeft = 0,
+ TopRight = 1,
+ TopCenter = 2,
+ BottomLeft = 3,
+ BottomRight = 4,
+ BottomCenter = 5,
+ Center = 6
+}
+
+public enum NotificationSeverity
+{
+ Info = 0,
+ Success = 1,
+ Warning = 2,
+ Error = 3
+}
+
+public readonly record struct NotificationContent(
+ string Title,
+ string? Message = null,
+ Stream? IconStream = null,
+ string? IconPath = null,
+ Bitmap? IconBitmap = null,
+ NotificationSeverity Severity = NotificationSeverity.Info,
+ NotificationPosition Position = NotificationPosition.TopRight,
+ TimeSpan? Duration = null,
+ Action? OnClick = null,
+ string? PrimaryButtonText = null,
+ string? SecondaryButtonText = null,
+ string? CloseButtonText = null,
+ Action? OnPrimaryButtonClick = null,
+ Action? OnSecondaryButtonClick = null)
+{
+ public TimeSpan EffectiveDuration => Duration ?? TimeSpan.FromSeconds(4);
+
+ ///
+ /// Indicates whether this notification should be shown as a dialog (center position)
+ /// or as a toast notification (other positions)
+ ///
+ public bool IsDialogNotification => Position == NotificationPosition.Center;
+}
+
+public interface INotificationService
+{
+ void Show(NotificationContent content);
+
+ Task ShowDialogAsync(NotificationContent content);
+
+ void ShowInfo(string title, string? message = null,
+ NotificationPosition position = NotificationPosition.TopRight);
+
+ void ShowSuccess(string title, string? message = null,
+ NotificationPosition position = NotificationPosition.TopRight);
+
+ void ShowWarning(string title, string? message = null,
+ NotificationPosition position = NotificationPosition.TopRight);
+
+ void ShowError(string title, string? message = null,
+ NotificationPosition position = NotificationPosition.TopRight);
+
+ Task ShowDialogInfoAsync(string title, string? message = null,
+ string? primaryButtonText = "确定", string? closeButtonText = "取消");
+
+ Task ShowDialogSuccessAsync(string title, string? message = null,
+ string? primaryButtonText = "确定", string? closeButtonText = "取消");
+
+ Task ShowDialogWarningAsync(string title, string? message = null,
+ string? primaryButtonText = "确定", string? closeButtonText = "取消");
+
+ Task ShowDialogErrorAsync(string title, string? message = null,
+ string? primaryButtonText = "确定", string? closeButtonText = "取消");
+}
+
+internal sealed class NotificationService : INotificationService
+{
+ private readonly IAppearanceThemeService? _appearanceThemeService;
+ private readonly NotificationWindowManager _windowManager;
+
+ public NotificationService(IAppearanceThemeService? appearanceThemeService = null)
+ {
+ _appearanceThemeService = appearanceThemeService;
+ _windowManager = NotificationWindowManager.Instance;
+ }
+
+ public void Show(NotificationContent content)
+ {
+ // 检查通知开关是否启用
+ if (!IsNotificationEnabled())
+ {
+ return; // 通知已禁用,不显示
+ }
+
+ // If it's a dialog notification (center position), show as dialog window
+ if (content.IsDialogNotification)
+ {
+ Dispatcher.UIThread.Post(() => ShowDialogWindow(content), DispatcherPriority.Normal);
+ return;
+ }
+
+ // Otherwise, show as toast notification
+ Dispatcher.UIThread.Post(() => ShowCore(content), DispatcherPriority.Normal);
+ }
+
+ private void ShowDialogWindow(NotificationContent content)
+ {
+ var window = new NotificationDialogWindow();
+ window.Initialize(content, _appearanceThemeService);
+
+ Screen? screen = null;
+ if (Avalonia.Application.Current?.ApplicationLifetime is Avalonia.Controls.ApplicationLifetimes.IClassicDesktopStyleApplicationLifetime desktop)
+ {
+ screen = desktop.MainWindow?.Screens?.Primary;
+ }
+ var workingArea = screen?.WorkingArea ?? new PixelRect(0, 0, 1920, 1080);
+
+ window.Measure(Size.Infinity);
+ var windowWidth = window.DesiredSize.Width > 0 ? window.DesiredSize.Width : 400;
+ var windowHeight = window.DesiredSize.Height > 0 ? window.DesiredSize.Height : 200;
+
+ var centerX = workingArea.X + (workingArea.Width - (int)Math.Round(windowWidth)) / 2;
+ var centerY = workingArea.Y + (workingArea.Height - (int)Math.Round(windowHeight)) / 2;
+ window.Position = new PixelPoint(centerX, centerY);
+
+ window.Show();
+
+ _ = Task.Run(async () =>
+ {
+ if (window.CompletionSource is not null)
+ {
+ await window.CompletionSource.Task;
+ }
+ });
+ }
+
+ public async Task ShowDialogAsync(NotificationContent content)
+ {
+ // 检查通知开关是否启用
+ if (!IsNotificationEnabled())
+ {
+ return ContentDialogResult.None; // 通知已禁用,不显示
+ }
+
+ return await Dispatcher.UIThread.InvokeAsync(() => ShowDialogCoreAsync(content));
+ }
+
+ private async Task ShowDialogCoreAsync(NotificationContent content)
+ {
+ // Get the main window as the dialog host
+ var mainWindow = GetMainWindow();
+ if (mainWindow is null)
+ {
+ AppLogger.Warn("Notification", "Cannot show dialog notification: main window not found");
+ return ContentDialogResult.None;
+ }
+
+ var dialog = new ContentDialog
+ {
+ Title = content.Title,
+ Content = content.Message ?? string.Empty,
+ PrimaryButtonText = content.PrimaryButtonText,
+ SecondaryButtonText = content.SecondaryButtonText,
+ CloseButtonText = content.CloseButtonText,
+ DefaultButton = !string.IsNullOrEmpty(content.PrimaryButtonText) ? ContentDialogButton.Primary :
+ !string.IsNullOrEmpty(content.SecondaryButtonText) ? ContentDialogButton.Secondary :
+ ContentDialogButton.Close
+ };
+
+ var result = await dialog.ShowAsync(mainWindow);
+
+ // Execute callbacks based on result
+ switch (result)
+ {
+ case ContentDialogResult.Primary:
+ content.OnPrimaryButtonClick?.Invoke();
+ break;
+ case ContentDialogResult.Secondary:
+ content.OnSecondaryButtonClick?.Invoke();
+ break;
+ }
+
+ return result;
+ }
+
+ private static bool IsNotificationEnabled()
+ {
+ try
+ {
+ // 从全局设置服务中读取通知开关状态
+ var settingsFacade = HostSettingsFacadeProvider.GetOrCreate();
+ var snapshot = settingsFacade.Settings.LoadSnapshot(PluginSdk.SettingsScope.App);
+ return snapshot.NotificationEnabled;
+ }
+ catch
+ {
+ // 如果读取失败,默认启用通知
+ return true;
+ }
+ }
+
+ private static Window? GetMainWindow()
+ {
+ if (Application.Current?.ApplicationLifetime is Avalonia.Controls.ApplicationLifetimes.IClassicDesktopStyleApplicationLifetime desktop)
+ {
+ return desktop.MainWindow;
+ }
+ return null;
+ }
+
+ private void ShowCore(NotificationContent content)
+ {
+ var viewModel = new NotificationViewModel
+ {
+ Title = content.Title,
+ Message = content.Message,
+ Severity = content.Severity,
+ Position = content.Position,
+ Duration = content.EffectiveDuration,
+ OnClick = content.OnClick
+ };
+
+ if (content.IconBitmap is not null)
+ {
+ viewModel.Icon = content.IconBitmap;
+ }
+ else if (content.IconStream is not null)
+ {
+ viewModel.Icon = new Bitmap(content.IconStream);
+ }
+ else if (!string.IsNullOrEmpty(content.IconPath))
+ {
+ try
+ {
+ viewModel.Icon = new Bitmap(content.IconPath);
+ }
+ catch
+ {
+ AppLogger.Warn("Notification", $"Failed to load icon from path: {content.IconPath}");
+ }
+ }
+
+ _windowManager.ShowNotification(viewModel, _appearanceThemeService);
+ }
+
+ public void ShowInfo(string title, string? message = null,
+ NotificationPosition position = NotificationPosition.TopRight)
+ {
+ Show(new NotificationContent(title, message, Severity: NotificationSeverity.Info, Position: position));
+ }
+
+ public void ShowSuccess(string title, string? message = null,
+ NotificationPosition position = NotificationPosition.TopRight)
+ {
+ Show(new NotificationContent(title, message, Severity: NotificationSeverity.Success, Position: position));
+ }
+
+ public void ShowWarning(string title, string? message = null,
+ NotificationPosition position = NotificationPosition.TopRight)
+ {
+ Show(new NotificationContent(title, message, Severity: NotificationSeverity.Warning, Position: position));
+ }
+
+ public void ShowError(string title, string? message = null,
+ NotificationPosition position = NotificationPosition.TopRight)
+ {
+ Show(new NotificationContent(title, message, Severity: NotificationSeverity.Error, Position: position));
+ }
+
+ public Task ShowDialogInfoAsync(string title, string? message = null,
+ string? primaryButtonText = "确定", string? closeButtonText = "取消")
+ {
+ return ShowDialogAsync(new NotificationContent(
+ title,
+ message,
+ Severity: NotificationSeverity.Info,
+ Position: NotificationPosition.Center,
+ PrimaryButtonText: primaryButtonText,
+ CloseButtonText: closeButtonText));
+ }
+
+ public Task ShowDialogSuccessAsync(string title, string? message = null,
+ string? primaryButtonText = "确定", string? closeButtonText = "取消")
+ {
+ return ShowDialogAsync(new NotificationContent(
+ title,
+ message,
+ Severity: NotificationSeverity.Success,
+ Position: NotificationPosition.Center,
+ PrimaryButtonText: primaryButtonText,
+ CloseButtonText: closeButtonText));
+ }
+
+ public Task ShowDialogWarningAsync(string title, string? message = null,
+ string? primaryButtonText = "确定", string? closeButtonText = "取消")
+ {
+ return ShowDialogAsync(new NotificationContent(
+ title,
+ message,
+ Severity: NotificationSeverity.Warning,
+ Position: NotificationPosition.Center,
+ PrimaryButtonText: primaryButtonText,
+ CloseButtonText: closeButtonText));
+ }
+
+ public Task ShowDialogErrorAsync(string title, string? message = null,
+ string? primaryButtonText = "确定", string? closeButtonText = "取消")
+ {
+ return ShowDialogAsync(new NotificationContent(
+ title,
+ message,
+ Severity: NotificationSeverity.Error,
+ Position: NotificationPosition.Center,
+ PrimaryButtonText: primaryButtonText,
+ CloseButtonText: closeButtonText));
+ }
+}
+
+internal sealed class NotificationWindowManager
+{
+ private static NotificationWindowManager? _instance;
+ public static NotificationWindowManager Instance => _instance ??= new NotificationWindowManager();
+
+ private readonly Dictionary> _windowsByPosition = new();
+ private const double Margin = 12;
+ private const double Spacing = 6;
+
+ private NotificationWindowManager()
+ {
+ foreach (var position in Enum.GetValues())
+ {
+ _windowsByPosition[position] = new List();
+ }
+ }
+
+ public void ShowNotification(NotificationViewModel viewModel, IAppearanceThemeService? themeService)
+ {
+ var position = viewModel.Position;
+ var windows = _windowsByPosition[position];
+
+ // 从设置中读取最大通知数量
+ var maxNotifications = GetMaxNotificationsPerPosition();
+
+ if (windows.Count >= maxNotifications)
+ {
+ var oldestWindow = windows[0];
+ windows.RemoveAt(0);
+ oldestWindow.Close();
+ }
+
+ var window = new NotificationWindow();
+ window.Initialize(viewModel, themeService);
+ window.Closed += OnWindowClosed;
+
+ windows.Add(window);
+ UpdateWindowPositions(position);
+
+ window.ShowWithAnimationAsync();
+ }
+
+ private void OnWindowClosed(object? sender, EventArgs e)
+ {
+ if (sender is not NotificationWindow window) return;
+
+ var position = window.NotificationPositionValue;
+ var windows = _windowsByPosition.GetValueOrDefault(position);
+ if (windows is null) return;
+
+ windows.Remove(window);
+ window.Closed -= OnWindowClosed;
+
+ UpdateWindowPositions(position);
+ }
+
+ private static int GetMaxNotificationsPerPosition()
+ {
+ try
+ {
+ // 从全局设置服务中读取最大通知数量
+ var settingsFacade = HostSettingsFacadeProvider.GetOrCreate();
+ var snapshot = settingsFacade.Settings.LoadSnapshot(PluginSdk.SettingsScope.App);
+ return snapshot.NotificationMaxPerPosition > 0 ? snapshot.NotificationMaxPerPosition : 5;
+ }
+ catch
+ {
+ // 如果读取失败,返回默认值
+ return 5;
+ }
+ }
+
+ private void UpdateWindowPositions(NotificationPosition position)
+ {
+ var windows = _windowsByPosition.GetValueOrDefault(position);
+ if (windows is null || windows.Count == 0) return;
+
+ var screen = GetPrimaryScreen();
+ var workingArea = screen?.WorkingArea ?? new PixelRect(0, 0, 1920, 1080);
+ var scale = 1d;
+
+ for (var i = 0; i < windows.Count; i++)
+ {
+ var window = windows[i];
+ var targetPosition = CalculateWindowPosition(window, position, workingArea, scale, i);
+ window.Position = targetPosition;
+ }
+ }
+
+ private PixelPoint CalculateWindowPosition(
+ NotificationWindow window,
+ NotificationPosition position,
+ PixelRect workingArea,
+ double scale,
+ int stackIndex)
+ {
+ window.Measure(Size.Infinity);
+ var windowWidth = window.DesiredSize.Width > 0 ? window.DesiredSize.Width : 320;
+ var windowHeight = window.DesiredSize.Height > 0 ? window.DesiredSize.Height : 80;
+
+ var margin = (int)Math.Round(Margin * scale);
+ var spacing = (int)Math.Round(Spacing * scale);
+ var stackedOffset = stackIndex * ((int)Math.Round(windowHeight) + spacing);
+
+ return position switch
+ {
+ NotificationPosition.TopLeft => new PixelPoint(
+ workingArea.X + margin,
+ workingArea.Y + margin + stackedOffset),
+
+ NotificationPosition.TopRight => new PixelPoint(
+ workingArea.Right - (int)Math.Round(windowWidth) - margin,
+ workingArea.Y + margin + stackedOffset),
+
+ NotificationPosition.TopCenter => new PixelPoint(
+ workingArea.X + (workingArea.Width - (int)Math.Round(windowWidth)) / 2,
+ workingArea.Y + margin + stackedOffset),
+
+ NotificationPosition.BottomLeft => new PixelPoint(
+ workingArea.X + margin,
+ workingArea.Bottom - (int)Math.Round(windowHeight) - margin - stackedOffset),
+
+ NotificationPosition.BottomRight => new PixelPoint(
+ workingArea.Right - (int)Math.Round(windowWidth) - margin,
+ workingArea.Bottom - (int)Math.Round(windowHeight) - margin - stackedOffset),
+
+ NotificationPosition.BottomCenter => new PixelPoint(
+ workingArea.X + (workingArea.Width - (int)Math.Round(windowWidth)) / 2,
+ workingArea.Bottom - (int)Math.Round(windowHeight) - margin - stackedOffset),
+
+ NotificationPosition.Center => new PixelPoint(
+ workingArea.X + (workingArea.Width - (int)Math.Round(windowWidth)) / 2,
+ workingArea.Y + (workingArea.Height - (int)Math.Round(windowHeight)) / 2),
+
+ _ => new PixelPoint(
+ workingArea.Right - (int)Math.Round(windowWidth) - margin,
+ workingArea.Y + margin + stackedOffset)
+ };
+ }
+
+ private static Screen? GetPrimaryScreen()
+ {
+ if (Avalonia.Application.Current?.ApplicationLifetime is Avalonia.Controls.ApplicationLifetimes.IClassicDesktopStyleApplicationLifetime desktop)
+ {
+ return desktop.MainWindow?.Screens?.Primary;
+ }
+ return null;
+ }
+
+ public void ApplyThemeToAllWindows(AppearanceThemeSnapshot snapshot)
+ {
+ foreach (var windows in _windowsByPosition.Values)
+ {
+ foreach (var window in windows.ToList())
+ {
+ try
+ {
+ window.RequestedThemeVariant = snapshot.IsNightMode ? Avalonia.Styling.ThemeVariant.Dark : Avalonia.Styling.ThemeVariant.Light;
+ }
+ catch
+ {
+ }
+ }
+ }
+ }
+}
diff --git a/LanMountainDesktop/ViewModels/NotificationSettingsPageViewModel.cs b/LanMountainDesktop/ViewModels/NotificationSettingsPageViewModel.cs
new file mode 100644
index 0000000..7a0464c
--- /dev/null
+++ b/LanMountainDesktop/ViewModels/NotificationSettingsPageViewModel.cs
@@ -0,0 +1,192 @@
+using System;
+using System.Collections.ObjectModel;
+using System.Linq;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using LanMountainDesktop.Models;
+using LanMountainDesktop.PluginSdk;
+using LanMountainDesktop.Services;
+using LanMountainDesktop.Services.Settings;
+
+namespace LanMountainDesktop.ViewModels;
+
+public sealed partial class NotificationSettingsPageViewModel : ViewModelBase
+{
+ private readonly ISettingsFacadeService _settingsFacade;
+ private bool _isInitializing;
+
+ public NotificationSettingsPageViewModel(ISettingsFacadeService settingsFacade)
+ {
+ _settingsFacade = settingsFacade ?? throw new ArgumentNullException(nameof(settingsFacade));
+
+ Positions = CreatePositionOptions();
+ Durations = CreateDurationOptions();
+ TestPositions = CreatePositionOptions();
+ TestSeverities = CreateSeverityOptions();
+
+ LoadSettings();
+
+ // Initialize test selections
+ SelectedTestPosition = TestPositions[1]; // TopRight
+ SelectedTestSeverity = TestSeverities[0]; // Info
+ TestDurationSeconds = 4; // Default 4 seconds
+ }
+
+ private void LoadSettings()
+ {
+ var snapshot = _settingsFacade.Settings.LoadSnapshot(SettingsScope.App);
+
+ _isInitializing = true;
+
+ IsNotificationEnabled = snapshot.NotificationEnabled;
+ IsHoverPauseEnabled = snapshot.NotificationHoverPauseEnabled;
+ IsClickCloseEnabled = snapshot.NotificationClickCloseEnabled;
+ MaxNotificationsPerPosition = snapshot.NotificationMaxPerPosition;
+
+ SelectedPosition = Positions.FirstOrDefault(p =>
+ string.Equals(p.Value, snapshot.NotificationDefaultPosition, StringComparison.OrdinalIgnoreCase))
+ ?? Positions[1];
+
+ SelectedDuration = Durations.FirstOrDefault(d =>
+ int.TryParse(d.Value, out var seconds) && seconds == snapshot.NotificationDurationSeconds)
+ ?? Durations[1];
+
+ _isInitializing = false;
+ }
+
+ private void SaveSettings()
+ {
+ if (_isInitializing) return;
+
+ var snapshot = _settingsFacade.Settings.LoadSnapshot(SettingsScope.App);
+
+ snapshot.NotificationEnabled = IsNotificationEnabled;
+ snapshot.NotificationDefaultPosition = SelectedPosition?.Value ?? "TopRight";
+ snapshot.NotificationDurationSeconds = int.TryParse(SelectedDuration?.Value, out var seconds) ? seconds : 4;
+ snapshot.NotificationHoverPauseEnabled = IsHoverPauseEnabled;
+ snapshot.NotificationClickCloseEnabled = IsClickCloseEnabled;
+ snapshot.NotificationMaxPerPosition = MaxNotificationsPerPosition;
+
+ _settingsFacade.Settings.SaveSnapshot(
+ SettingsScope.App,
+ snapshot,
+ changedKeys:
+ [
+ nameof(AppSettingsSnapshot.NotificationEnabled),
+ nameof(AppSettingsSnapshot.NotificationDefaultPosition),
+ nameof(AppSettingsSnapshot.NotificationDurationSeconds),
+ nameof(AppSettingsSnapshot.NotificationHoverPauseEnabled),
+ nameof(AppSettingsSnapshot.NotificationClickCloseEnabled),
+ nameof(AppSettingsSnapshot.NotificationMaxPerPosition)
+ ]);
+ }
+
+ private static ObservableCollection CreatePositionOptions()
+ {
+ return
+ [
+ new SelectionOption("TopLeft", "左上角"),
+ new SelectionOption("TopRight", "右上角"),
+ new SelectionOption("TopCenter", "正上方"),
+ new SelectionOption("BottomLeft", "左下角"),
+ new SelectionOption("BottomRight", "右下角"),
+ new SelectionOption("BottomCenter", "正下方"),
+ new SelectionOption("Center", "正中央")
+ ];
+ }
+
+ private static ObservableCollection CreateDurationOptions()
+ {
+ return
+ [
+ new SelectionOption("2", "2 秒"),
+ new SelectionOption("4", "4 秒"),
+ new SelectionOption("6", "6 秒"),
+ new SelectionOption("8", "8 秒"),
+ new SelectionOption("10", "10 秒")
+ ];
+ }
+
+ private static ObservableCollection CreateSeverityOptions()
+ {
+ return
+ [
+ new SelectionOption("Info", "信息"),
+ new SelectionOption("Success", "成功"),
+ new SelectionOption("Warning", "警告"),
+ new SelectionOption("Error", "错误")
+ ];
+ }
+
+ [ObservableProperty] private string _notificationHeader = "通知";
+ [ObservableProperty] private string _enableNotificationHeader = "启用通知";
+ [ObservableProperty] private string _enableNotificationDescription = "开启或关闭全局通知功能";
+ [ObservableProperty] private string _defaultPositionHeader = "默认位置";
+ [ObservableProperty] private string _defaultPositionDescription = "通知弹出的默认位置";
+ [ObservableProperty] private string _durationHeader = "显示时长";
+ [ObservableProperty] private string _durationDescription = "通知自动关闭的时间";
+ [ObservableProperty] private string _behaviorHeader = "行为";
+ [ObservableProperty] private string _hoverPauseHeader = "悬停暂停";
+ [ObservableProperty] private string _hoverPauseDescription = "鼠标悬停时暂停自动关闭计时";
+ [ObservableProperty] private string _clickCloseHeader = "点击关闭";
+ [ObservableProperty] private string _clickCloseDescription = "点击通知后关闭";
+ [ObservableProperty] private string _maxNotificationsHeader = "最大数量";
+ [ObservableProperty] private string _maxNotificationsDescription = "每个位置最多显示的通知数量";
+ [ObservableProperty] private string _testHeader = "测试";
+ [ObservableProperty] private string _testNotificationHeader = "测试通知";
+ [ObservableProperty] private string _testNotificationDescription = "选择位置和类型,发送测试通知";
+ [ObservableProperty] private string _sendTestButtonText = "发送";
+
+ [ObservableProperty] private bool _isNotificationEnabled = true;
+ [ObservableProperty] private bool _isHoverPauseEnabled = true;
+ [ObservableProperty] private bool _isClickCloseEnabled = true;
+ [ObservableProperty] private int _maxNotificationsPerPosition = 5;
+
+ [ObservableProperty] private SelectionOption? _selectedPosition;
+ [ObservableProperty] private SelectionOption? _selectedDuration;
+ [ObservableProperty] private SelectionOption? _selectedTestPosition;
+ [ObservableProperty] private SelectionOption? _selectedTestSeverity;
+ [ObservableProperty] private int _testDurationSeconds = 4;
+
+ public ObservableCollection Positions { get; }
+ public ObservableCollection Durations { get; }
+ public ObservableCollection TestPositions { get; }
+ public ObservableCollection TestSeverities { get; }
+
+ partial void OnIsNotificationEnabledChanged(bool value) => SaveSettings();
+ partial void OnIsHoverPauseEnabledChanged(bool value) => SaveSettings();
+ partial void OnIsClickCloseEnabledChanged(bool value) => SaveSettings();
+ partial void OnMaxNotificationsPerPositionChanged(int value) => SaveSettings();
+ partial void OnSelectedPositionChanged(SelectionOption? value) => SaveSettings();
+ partial void OnSelectedDurationChanged(SelectionOption? value) => SaveSettings();
+
+ [RelayCommand]
+ private void SendTest()
+ {
+ if (SelectedTestPosition is null || SelectedTestSeverity is null)
+ return;
+
+ var position = Enum.Parse(SelectedTestPosition.Value);
+ var severity = SelectedTestSeverity.Value;
+
+ var (title, message) = severity! switch
+ {
+ "Info" => ("测试通知", "这是一条信息类型的通知"),
+ "Success" => ("操作成功", "任务已完成"),
+ "Warning" => ("警告提示", "请注意检查"),
+ "Error" => ("错误报告", "操作失败,请重试"),
+ _ => ("测试通知", "这是一条测试通知")
+ };
+
+ // Create notification content with specified duration
+ var content = new NotificationContent(
+ Title: title,
+ Message: message,
+ Severity: Enum.Parse(severity),
+ Position: position,
+ Duration: TimeSpan.FromSeconds(TestDurationSeconds));
+
+ // Use Show method which will automatically route to dialog or toast based on position
+ App.CurrentNotificationService?.Show(content);
+ }
+}
diff --git a/LanMountainDesktop/ViewModels/NotificationViewModel.cs b/LanMountainDesktop/ViewModels/NotificationViewModel.cs
new file mode 100644
index 0000000..edc8a9a
--- /dev/null
+++ b/LanMountainDesktop/ViewModels/NotificationViewModel.cs
@@ -0,0 +1,38 @@
+using System;
+using Avalonia.Media.Imaging;
+using CommunityToolkit.Mvvm.ComponentModel;
+using LanMountainDesktop.Services;
+
+namespace LanMountainDesktop.ViewModels;
+
+public partial class NotificationViewModel : ViewModelBase
+{
+ [ObservableProperty] private string _title = string.Empty;
+ [ObservableProperty] private string? _message;
+ [ObservableProperty] private Bitmap? _icon;
+ [ObservableProperty] private NotificationSeverity _severity;
+ [ObservableProperty] private NotificationPosition _position;
+ [ObservableProperty] private bool _isClosing;
+
+ public TimeSpan Duration { get; set; } = TimeSpan.FromSeconds(4);
+ public Action? OnClick { get; set; }
+ public Guid Id { get; } = Guid.NewGuid();
+
+ public string SeverityIcon =>
+ Severity switch
+ {
+ NotificationSeverity.Success => "CheckmarkCircle",
+ NotificationSeverity.Warning => "Warning",
+ NotificationSeverity.Error => "DismissCircle",
+ _ => "Info"
+ };
+
+ public string SeverityColorResource =>
+ Severity switch
+ {
+ NotificationSeverity.Success => "SystemFillColorSuccessBrush",
+ NotificationSeverity.Warning => "SystemFillColorCautionBrush",
+ NotificationSeverity.Error => "SystemFillColorCriticalBrush",
+ _ => "SystemFillColorAttentionBrush"
+ };
+}
diff --git a/LanMountainDesktop/ViewModels/SettingsViewModels.cs b/LanMountainDesktop/ViewModels/SettingsViewModels.cs
index 01ff0b5..ef7015e 100644
--- a/LanMountainDesktop/ViewModels/SettingsViewModels.cs
+++ b/LanMountainDesktop/ViewModels/SettingsViewModels.cs
@@ -2328,13 +2328,27 @@ public sealed partial class StudySettingsPageViewModel : ViewModelBase
private readonly LocalizationService _localizationService = new();
private readonly string _languageCode;
private bool _isInitializing;
- private readonly IStudyAnalyticsService _studyAnalyticsService = StudyAnalyticsServiceFactory.CreateDefault();
+ private readonly IStudyAnalyticsService _studyAnalyticsService;
- public StudySettingsPageViewModel(ISettingsFacadeService settingsFacade)
+ // 防抖计时器
+ private System.Timers.Timer? _noiseSettingsDebounceTimer;
+ private System.Timers.Timer? _timerSettingsDebounceTimer;
+ private System.Timers.Timer? _alertSettingsDebounceTimer;
+ private System.Timers.Timer? _displaySettingsDebounceTimer;
+ private bool _hasPendingNoiseSave;
+ private bool _hasPendingTimerSave;
+ private bool _hasPendingAlertSave;
+ private bool _hasPendingDisplaySave;
+
+ public StudySettingsPageViewModel(ISettingsFacadeService settingsFacade, IStudyAnalyticsService? studyAnalyticsService = null)
{
_settingsFacade = settingsFacade ?? throw new ArgumentNullException(nameof(settingsFacade));
+ _studyAnalyticsService = studyAnalyticsService ?? StudyAnalyticsServiceFactory.CreateDefault();
_languageCode = _localizationService.NormalizeLanguageCode(_settingsFacade.Region.Get().LanguageCode);
+ // 初始化防抖计时器
+ InitializeDebounceTimers();
+
RefreshLocalizedText();
_isInitializing = true;
@@ -2342,6 +2356,21 @@ public sealed partial class StudySettingsPageViewModel : ViewModelBase
_isInitializing = false;
}
+ private void InitializeDebounceTimers()
+ {
+ _noiseSettingsDebounceTimer = new System.Timers.Timer(500) { AutoReset = false };
+ _noiseSettingsDebounceTimer.Elapsed += async (s, e) => await SaveNoiseSettingsDebounced();
+
+ _timerSettingsDebounceTimer = new System.Timers.Timer(500) { AutoReset = false };
+ _timerSettingsDebounceTimer.Elapsed += async (s, e) => await SaveTimerSettingsDebounced();
+
+ _alertSettingsDebounceTimer = new System.Timers.Timer(500) { AutoReset = false };
+ _alertSettingsDebounceTimer.Elapsed += async (s, e) => await SaveAlertSettingsDebounced();
+
+ _displaySettingsDebounceTimer = new System.Timers.Timer(500) { AutoReset = false };
+ _displaySettingsDebounceTimer.Elapsed += async (s, e) => await SaveDisplaySettingsDebounced();
+ }
+
#region Properties - Master Switch
[ObservableProperty]
@@ -2361,6 +2390,21 @@ public sealed partial class StudySettingsPageViewModel : ViewModelBase
}
}
+ private void SaveMasterSwitch()
+ {
+ try
+ {
+ var appSnapshot = _settingsFacade.Settings.LoadSnapshot(SettingsScope.App);
+ appSnapshot.StudyEnabled = StudyEnabled;
+ _settingsFacade.Settings.SaveSnapshot(SettingsScope.App, appSnapshot,
+ changedKeys: [nameof(AppSettingsSnapshot.StudyEnabled)]);
+ }
+ catch (Exception)
+ {
+ // 静默处理错误,避免影响用户体验
+ }
+ }
+
#endregion
#region Properties - Noise Monitoring
@@ -2400,20 +2444,34 @@ public sealed partial class StudySettingsPageViewModel : ViewModelBase
partial void OnNoiseSensitivityDbfsChanged(double value)
{
+ // 输入验证:限制在合理范围内
+ if (value < -70 || value > -35)
+ {
+ NoiseSensitivityDbfs = Math.Clamp(value, -70, -35);
+ return;
+ }
+
UpdateSensitivityText();
UpdateThresholdText();
if (!_isInitializing)
{
- SaveNoiseSettings();
+ DebounceNoiseSettingsSave();
}
}
partial void OnSamplingRateMsChanged(int value)
{
+ // 输入验证:限制在合理范围内
+ if (value < 20 || value > 200)
+ {
+ SamplingRateMs = Math.Clamp(value, 20, 200);
+ return;
+ }
+
UpdateSamplingRateText();
if (!_isInitializing)
{
- SaveNoiseSettings();
+ DebounceNoiseSettingsSave();
}
}
@@ -2427,6 +2485,34 @@ public sealed partial class StudySettingsPageViewModel : ViewModelBase
NoiseSensitivityValueText = $"{NoiseSensitivityDbfs:F0} dBFS";
}
+ private void DebounceNoiseSettingsSave()
+ {
+ _hasPendingNoiseSave = true;
+ _noiseSettingsDebounceTimer?.Stop();
+ _noiseSettingsDebounceTimer?.Start();
+ }
+
+ private async Task SaveNoiseSettingsDebounced()
+ {
+ if (!_hasPendingNoiseSave) return;
+ _hasPendingNoiseSave = false;
+
+ try
+ {
+ var appSnapshot = _settingsFacade.Settings.LoadSnapshot(SettingsScope.App);
+ appSnapshot.StudyFrameMs = SamplingRateMs;
+ appSnapshot.StudyScoreThresholdDbfs = NoiseSensitivityDbfs;
+ _settingsFacade.Settings.SaveSnapshot(SettingsScope.App, appSnapshot,
+ changedKeys: [nameof(AppSettingsSnapshot.StudyFrameMs), nameof(AppSettingsSnapshot.StudyScoreThresholdDbfs)]);
+ UpdateThresholdText();
+ UpdateStudyAnalyticsConfig();
+ }
+ catch (Exception)
+ {
+ // 静默处理错误
+ }
+ }
+
#endregion
#region Properties - Focus Timer
@@ -2505,37 +2591,65 @@ public sealed partial class StudySettingsPageViewModel : ViewModelBase
partial void OnFocusDurationMinutesChanged(int value)
{
+ // 输入验证
+ if (value < 5 || value > 90)
+ {
+ FocusDurationMinutes = Math.Clamp(value, 5, 90);
+ return;
+ }
+
UpdateFocusDurationText();
if (!_isInitializing)
{
- SaveTimerSettings();
+ DebounceTimerSettingsSave();
}
}
partial void OnBreakDurationMinutesChanged(int value)
{
+ // 输入验证
+ if (value < 1 || value > 30)
+ {
+ BreakDurationMinutes = Math.Clamp(value, 1, 30);
+ return;
+ }
+
UpdateBreakDurationText();
if (!_isInitializing)
{
- SaveTimerSettings();
+ DebounceTimerSettingsSave();
}
}
partial void OnLongBreakDurationMinutesChanged(int value)
{
+ // 输入验证
+ if (value < 5 || value > 60)
+ {
+ LongBreakDurationMinutes = Math.Clamp(value, 5, 60);
+ return;
+ }
+
UpdateLongBreakDurationText();
if (!_isInitializing)
{
- SaveTimerSettings();
+ DebounceTimerSettingsSave();
}
}
partial void OnSessionsBeforeLongBreakChanged(int value)
{
+ // 输入验证
+ if (value < 2 || value > 8)
+ {
+ SessionsBeforeLongBreak = Math.Clamp(value, 2, 8);
+ return;
+ }
+
UpdateSessionsBeforeLongBreakText();
if (!_isInitializing)
{
- SaveTimerSettings();
+ DebounceTimerSettingsSave();
}
}
@@ -2543,7 +2657,7 @@ public sealed partial class StudySettingsPageViewModel : ViewModelBase
{
if (!_isInitializing)
{
- SaveTimerSettings();
+ DebounceTimerSettingsSave();
}
}
@@ -2551,28 +2665,69 @@ public sealed partial class StudySettingsPageViewModel : ViewModelBase
{
if (!_isInitializing)
{
- SaveTimerSettings();
+ DebounceTimerSettingsSave();
}
}
private void UpdateFocusDurationText()
{
- FocusDurationValueText = $"{FocusDurationMinutes} 分钟";
+ var unit = L("common.unit.minutes", "分钟");
+ FocusDurationValueText = $"{FocusDurationMinutes} {unit}";
}
private void UpdateBreakDurationText()
{
- BreakDurationValueText = $"{BreakDurationMinutes} 分钟";
+ var unit = L("common.unit.minutes", "分钟");
+ BreakDurationValueText = $"{BreakDurationMinutes} {unit}";
}
private void UpdateLongBreakDurationText()
{
- LongBreakDurationValueText = $"{LongBreakDurationMinutes} 分钟";
+ var unit = L("common.unit.minutes", "分钟");
+ LongBreakDurationValueText = $"{LongBreakDurationMinutes} {unit}";
}
private void UpdateSessionsBeforeLongBreakText()
{
- SessionsBeforeLongBreakValueText = $"{SessionsBeforeLongBreak} 次";
+ var unit = L("common.unit.times", "次");
+ SessionsBeforeLongBreakValueText = $"{SessionsBeforeLongBreak} {unit}";
+ }
+
+ private void DebounceTimerSettingsSave()
+ {
+ _hasPendingTimerSave = true;
+ _timerSettingsDebounceTimer?.Stop();
+ _timerSettingsDebounceTimer?.Start();
+ }
+
+ private async Task SaveTimerSettingsDebounced()
+ {
+ if (!_hasPendingTimerSave) return;
+ _hasPendingTimerSave = false;
+
+ try
+ {
+ var appSnapshot = _settingsFacade.Settings.LoadSnapshot(SettingsScope.App);
+ appSnapshot.StudyFocusDurationMinutes = FocusDurationMinutes;
+ appSnapshot.StudyBreakDurationMinutes = BreakDurationMinutes;
+ appSnapshot.StudyLongBreakDurationMinutes = LongBreakDurationMinutes;
+ appSnapshot.StudySessionsBeforeLongBreak = SessionsBeforeLongBreak;
+ appSnapshot.StudyAutoStartBreak = AutoStartBreak;
+ appSnapshot.StudyAutoStartFocus = AutoStartFocus;
+ _settingsFacade.Settings.SaveSnapshot(SettingsScope.App, appSnapshot,
+ changedKeys: [
+ nameof(AppSettingsSnapshot.StudyFocusDurationMinutes),
+ nameof(AppSettingsSnapshot.StudyBreakDurationMinutes),
+ nameof(AppSettingsSnapshot.StudyLongBreakDurationMinutes),
+ nameof(AppSettingsSnapshot.StudySessionsBeforeLongBreak),
+ nameof(AppSettingsSnapshot.StudyAutoStartBreak),
+ nameof(AppSettingsSnapshot.StudyAutoStartFocus)
+ ]);
+ }
+ catch (Exception)
+ {
+ // 静默处理错误
+ }
}
#endregion
@@ -2607,15 +2762,49 @@ public sealed partial class StudySettingsPageViewModel : ViewModelBase
{
if (!_isInitializing)
{
- SaveAlertSettings();
+ DebounceAlertSettingsSave();
}
}
partial void OnMaxInterruptsPerMinuteChanged(int value)
{
+ // 输入验证
+ if (value < 3 || value > 20)
+ {
+ MaxInterruptsPerMinute = Math.Clamp(value, 3, 20);
+ return;
+ }
+
if (!_isInitializing)
{
- SaveAlertSettings();
+ DebounceAlertSettingsSave();
+ }
+ }
+
+ private void DebounceAlertSettingsSave()
+ {
+ _hasPendingAlertSave = true;
+ _alertSettingsDebounceTimer?.Stop();
+ _alertSettingsDebounceTimer?.Start();
+ }
+
+ private async Task SaveAlertSettingsDebounced()
+ {
+ if (!_hasPendingAlertSave) return;
+ _hasPendingAlertSave = false;
+
+ try
+ {
+ var appSnapshot = _settingsFacade.Settings.LoadSnapshot(SettingsScope.App);
+ appSnapshot.StudyNoiseAlertEnabled = NoiseAlertEnabled;
+ appSnapshot.StudyMaxInterruptsPerMinute = MaxInterruptsPerMinute;
+ _settingsFacade.Settings.SaveSnapshot(SettingsScope.App, appSnapshot,
+ changedKeys: [nameof(AppSettingsSnapshot.StudyNoiseAlertEnabled), nameof(AppSettingsSnapshot.StudyMaxInterruptsPerMinute)]);
+ UpdateStudyAnalyticsConfig();
+ }
+ catch (Exception)
+ {
+ // 静默处理错误
}
}
@@ -2666,25 +2855,39 @@ public sealed partial class StudySettingsPageViewModel : ViewModelBase
{
if (!_isInitializing)
{
- SaveDisplaySettings();
+ DebounceDisplaySettingsSave();
}
}
partial void OnBaselineDbChanged(double value)
{
+ // 输入验证
+ if (value < 20 || value > 90)
+ {
+ BaselineDb = Math.Clamp(value, 20, 90);
+ return;
+ }
+
UpdateBaselineDbText();
if (!_isInitializing)
{
- SaveDisplaySettings();
+ DebounceDisplaySettingsSave();
}
}
partial void OnAvgWindowSecChanged(int value)
{
+ // 输入验证
+ if (value < 1 || value > 8)
+ {
+ AvgWindowSec = Math.Clamp(value, 1, 8);
+ return;
+ }
+
UpdateAvgWindowSecText();
if (!_isInitializing)
{
- SaveDisplaySettings();
+ DebounceDisplaySettingsSave();
}
}
@@ -2700,106 +2903,96 @@ public sealed partial class StudySettingsPageViewModel : ViewModelBase
private void UpdateAvgWindowSecText()
{
- AvgWindowSecValueText = $"{AvgWindowSec} 秒";
+ var unit = L("common.unit.seconds", "秒");
+ AvgWindowSecValueText = $"{AvgWindowSec} {unit}";
+ }
+
+ private void DebounceDisplaySettingsSave()
+ {
+ _hasPendingDisplaySave = true;
+ _displaySettingsDebounceTimer?.Stop();
+ _displaySettingsDebounceTimer?.Start();
+ }
+
+ private async Task SaveDisplaySettingsDebounced()
+ {
+ if (!_hasPendingDisplaySave) return;
+ _hasPendingDisplaySave = false;
+
+ try
+ {
+ var appSnapshot = _settingsFacade.Settings.LoadSnapshot(SettingsScope.App);
+ appSnapshot.StudyShowRealtimeDb = ShowRealtimeDb;
+ appSnapshot.StudyBaselineDb = BaselineDb;
+ appSnapshot.StudyAvgWindowSec = AvgWindowSec;
+ _settingsFacade.Settings.SaveSnapshot(SettingsScope.App, appSnapshot,
+ changedKeys: [nameof(AppSettingsSnapshot.StudyShowRealtimeDb), nameof(AppSettingsSnapshot.StudyBaselineDb), nameof(AppSettingsSnapshot.StudyAvgWindowSec)]);
+ UpdateStudyAnalyticsConfig();
+ }
+ catch (Exception)
+ {
+ // 静默处理错误
+ }
}
private void LoadSettings()
{
- var appSnapshot = _settingsFacade.Settings.LoadSnapshot(SettingsScope.App);
+ try
+ {
+ var appSnapshot = _settingsFacade.Settings.LoadSnapshot(SettingsScope.App);
- // Master switch
- StudyEnabled = appSnapshot.StudyEnabled;
+ // Master switch - 确保正确加载保存的值
+ StudyEnabled = appSnapshot.StudyEnabled;
- // Noise settings
- SamplingRateMs = appSnapshot.StudyFrameMs is > 0 ? appSnapshot.StudyFrameMs.Value : 50;
- NoiseSensitivityDbfs = appSnapshot.StudyScoreThresholdDbfs ?? -50;
+ // Noise settings
+ SamplingRateMs = appSnapshot.StudyFrameMs is > 0 ? appSnapshot.StudyFrameMs.Value : 50;
+ NoiseSensitivityDbfs = appSnapshot.StudyScoreThresholdDbfs ?? -50;
- // Timer settings
- FocusDurationMinutes = appSnapshot.StudyFocusDurationMinutes is > 0 ? appSnapshot.StudyFocusDurationMinutes.Value : 25;
- BreakDurationMinutes = appSnapshot.StudyBreakDurationMinutes is > 0 ? appSnapshot.StudyBreakDurationMinutes.Value : 5;
- LongBreakDurationMinutes = appSnapshot.StudyLongBreakDurationMinutes is > 0 ? appSnapshot.StudyLongBreakDurationMinutes.Value : 15;
- SessionsBeforeLongBreak = appSnapshot.StudySessionsBeforeLongBreak is > 0 ? appSnapshot.StudySessionsBeforeLongBreak.Value : 4;
- AutoStartBreak = appSnapshot.StudyAutoStartBreak ?? false;
- AutoStartFocus = appSnapshot.StudyAutoStartFocus ?? false;
+ // Timer settings
+ FocusDurationMinutes = appSnapshot.StudyFocusDurationMinutes is > 0 ? appSnapshot.StudyFocusDurationMinutes.Value : 25;
+ BreakDurationMinutes = appSnapshot.StudyBreakDurationMinutes is > 0 ? appSnapshot.StudyBreakDurationMinutes.Value : 5;
+ LongBreakDurationMinutes = appSnapshot.StudyLongBreakDurationMinutes is > 0 ? appSnapshot.StudyLongBreakDurationMinutes.Value : 15;
+ SessionsBeforeLongBreak = appSnapshot.StudySessionsBeforeLongBreak is > 0 ? appSnapshot.StudySessionsBeforeLongBreak.Value : 4;
+ AutoStartBreak = appSnapshot.StudyAutoStartBreak ?? false;
+ AutoStartFocus = appSnapshot.StudyAutoStartFocus ?? false;
- // Alert settings
- NoiseAlertEnabled = appSnapshot.StudyNoiseAlertEnabled ?? false;
- MaxInterruptsPerMinute = appSnapshot.StudyMaxInterruptsPerMinute is > 0 ? appSnapshot.StudyMaxInterruptsPerMinute.Value : 6;
+ // Alert settings
+ NoiseAlertEnabled = appSnapshot.StudyNoiseAlertEnabled ?? false;
+ MaxInterruptsPerMinute = appSnapshot.StudyMaxInterruptsPerMinute is > 0 ? appSnapshot.StudyMaxInterruptsPerMinute.Value : 6;
- // Display settings
- ShowRealtimeDb = appSnapshot.StudyShowRealtimeDb ?? true;
- BaselineDb = appSnapshot.StudyBaselineDb ?? 45;
- AvgWindowSec = appSnapshot.StudyAvgWindowSec ?? 1;
+ // Display settings
+ ShowRealtimeDb = appSnapshot.StudyShowRealtimeDb ?? true;
+ BaselineDb = appSnapshot.StudyBaselineDb ?? 45;
+ AvgWindowSec = appSnapshot.StudyAvgWindowSec ?? 1;
- UpdateSamplingRateText();
- UpdateSensitivityText();
- UpdateThresholdText();
- UpdateFocusDurationText();
- UpdateBreakDurationText();
- UpdateLongBreakDurationText();
- UpdateSessionsBeforeLongBreakText();
- UpdateBaselineDbText();
- UpdateAvgWindowSecText();
- }
-
- private void SaveMasterSwitch()
- {
- var appSnapshot = _settingsFacade.Settings.LoadSnapshot(SettingsScope.App);
- appSnapshot.StudyEnabled = StudyEnabled;
- _settingsFacade.Settings.SaveSnapshot(SettingsScope.App, appSnapshot,
- changedKeys: [nameof(AppSettingsSnapshot.StudyEnabled)]);
- }
-
- private void SaveNoiseSettings()
- {
- var appSnapshot = _settingsFacade.Settings.LoadSnapshot(SettingsScope.App);
- appSnapshot.StudyFrameMs = SamplingRateMs;
- appSnapshot.StudyScoreThresholdDbfs = NoiseSensitivityDbfs;
- _settingsFacade.Settings.SaveSnapshot(SettingsScope.App, appSnapshot,
- changedKeys: [nameof(AppSettingsSnapshot.StudyFrameMs), nameof(AppSettingsSnapshot.StudyScoreThresholdDbfs)]);
- UpdateThresholdText();
- UpdateStudyAnalyticsConfig();
- }
-
- private void SaveTimerSettings()
- {
- var appSnapshot = _settingsFacade.Settings.LoadSnapshot(SettingsScope.App);
- appSnapshot.StudyFocusDurationMinutes = FocusDurationMinutes;
- appSnapshot.StudyBreakDurationMinutes = BreakDurationMinutes;
- appSnapshot.StudyLongBreakDurationMinutes = LongBreakDurationMinutes;
- appSnapshot.StudySessionsBeforeLongBreak = SessionsBeforeLongBreak;
- appSnapshot.StudyAutoStartBreak = AutoStartBreak;
- appSnapshot.StudyAutoStartFocus = AutoStartFocus;
- _settingsFacade.Settings.SaveSnapshot(SettingsScope.App, appSnapshot,
- changedKeys: [
- nameof(AppSettingsSnapshot.StudyFocusDurationMinutes),
- nameof(AppSettingsSnapshot.StudyBreakDurationMinutes),
- nameof(AppSettingsSnapshot.StudyLongBreakDurationMinutes),
- nameof(AppSettingsSnapshot.StudySessionsBeforeLongBreak),
- nameof(AppSettingsSnapshot.StudyAutoStartBreak),
- nameof(AppSettingsSnapshot.StudyAutoStartFocus)
- ]);
- }
-
- private void SaveAlertSettings()
- {
- var appSnapshot = _settingsFacade.Settings.LoadSnapshot(SettingsScope.App);
- appSnapshot.StudyNoiseAlertEnabled = NoiseAlertEnabled;
- appSnapshot.StudyMaxInterruptsPerMinute = MaxInterruptsPerMinute;
- _settingsFacade.Settings.SaveSnapshot(SettingsScope.App, appSnapshot,
- changedKeys: [nameof(AppSettingsSnapshot.StudyNoiseAlertEnabled), nameof(AppSettingsSnapshot.StudyMaxInterruptsPerMinute)]);
- UpdateStudyAnalyticsConfig();
- }
-
- private void SaveDisplaySettings()
- {
- var appSnapshot = _settingsFacade.Settings.LoadSnapshot(SettingsScope.App);
- appSnapshot.StudyShowRealtimeDb = ShowRealtimeDb;
- appSnapshot.StudyBaselineDb = BaselineDb;
- appSnapshot.StudyAvgWindowSec = AvgWindowSec;
- _settingsFacade.Settings.SaveSnapshot(SettingsScope.App, appSnapshot,
- changedKeys: [nameof(AppSettingsSnapshot.StudyShowRealtimeDb), nameof(AppSettingsSnapshot.StudyBaselineDb), nameof(AppSettingsSnapshot.StudyAvgWindowSec)]);
- UpdateStudyAnalyticsConfig();
+ UpdateSamplingRateText();
+ UpdateSensitivityText();
+ UpdateThresholdText();
+ UpdateFocusDurationText();
+ UpdateBreakDurationText();
+ UpdateLongBreakDurationText();
+ UpdateSessionsBeforeLongBreakText();
+ UpdateBaselineDbText();
+ UpdateAvgWindowSecText();
+ }
+ catch (Exception)
+ {
+ // 加载失败时使用默认值
+ StudyEnabled = true;
+ SamplingRateMs = 50;
+ NoiseSensitivityDbfs = -50;
+ FocusDurationMinutes = 25;
+ BreakDurationMinutes = 5;
+ LongBreakDurationMinutes = 15;
+ SessionsBeforeLongBreak = 4;
+ AutoStartBreak = false;
+ AutoStartFocus = false;
+ NoiseAlertEnabled = false;
+ MaxInterruptsPerMinute = 6;
+ ShowRealtimeDb = true;
+ BaselineDb = 45;
+ AvgWindowSec = 1;
+ }
}
private void UpdateStudyAnalyticsConfig()
diff --git a/LanMountainDesktop/Views/Components/ClassScheduleWidget.axaml.cs b/LanMountainDesktop/Views/Components/ClassScheduleWidget.axaml.cs
index d32d540..714042a 100644
--- a/LanMountainDesktop/Views/Components/ClassScheduleWidget.axaml.cs
+++ b/LanMountainDesktop/Views/Components/ClassScheduleWidget.axaml.cs
@@ -44,12 +44,18 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget,
private TimeZoneService? _timeZoneService;
private double _currentCellSize = 48;
private IReadOnlyList _courseItems = Array.Empty();
+ private IReadOnlyList _lastRenderedItems = Array.Empty();
private bool _isNightVisual = true;
private string _languageCode = "zh-CN";
private string _componentId = BuiltInComponentIds.DesktopClassSchedule;
private string _placementId = string.Empty;
private string? _componentColorScheme;
+ private ClassIslandScheduleReadResult? _cachedScheduleResult;
+ private string? _lastLoadedSchedulePath;
+ private DateTime _lastScheduleLoadTime = DateTime.MinValue;
+ private static readonly TimeSpan ScheduleCacheDuration = TimeSpan.FromMinutes(5);
+
public ClassScheduleWidget()
{
InitializeComponent();
@@ -118,6 +124,7 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget,
private void OnTimeZoneChanged(object? sender, EventArgs e)
{
+ InvalidateScheduleCache();
RefreshSchedule();
}
@@ -156,14 +163,21 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget,
{
var now = _timeZoneService?.GetCurrentTime() ?? DateTime.Now;
var currentDate = DateOnly.FromDateTime(now);
-
+
var previousCourseIndex = _lastCurrentCourseIndex;
-
- RefreshSchedule();
-
+
+ if (ShouldRefreshOnTimerTick(now, currentDate))
+ {
+ RefreshSchedule();
+ }
+ else
+ {
+ UpdateCurrentCourseState(now);
+ }
+
var newCurrentCourseIndex = FindCurrentCourseIndex();
_lastCurrentCourseIndex = newCurrentCourseIndex;
-
+
if (previousCourseIndex != newCurrentCourseIndex && newCurrentCourseIndex >= 0)
{
if (_isUserScrolling)
@@ -172,13 +186,68 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget,
}
ScrollToCurrentCourse(newCurrentCourseIndex);
}
-
+
if (_lastRefreshDate != currentDate && currentDate > _lastRefreshDate)
{
_lastRefreshDate = currentDate;
}
}
+ private bool ShouldRefreshOnTimerTick(DateTime now, DateOnly currentDate)
+ {
+ if (_lastRefreshDate != currentDate)
+ {
+ return true;
+ }
+
+ if (_courseItems.Count == 0)
+ {
+ return true;
+ }
+
+ foreach (var item in _courseItems)
+ {
+ if (item.IsCurrent)
+ {
+ var currentTime = now.TimeOfDay;
+ if (currentTime.TotalSeconds < 30 || currentTime.TotalSeconds > 86970)
+ {
+ return true;
+ }
+ break;
+ }
+ }
+
+ return false;
+ }
+
+ private void UpdateCurrentCourseState(DateTime now)
+ {
+ bool needsRender = false;
+ for (var i = 0; i < _courseItems.Count; i++)
+ {
+ var item = _courseItems[i];
+ var timeParts = item.TimeRange.Split('-');
+ if (timeParts.Length != 2) continue;
+
+ if (TimeSpan.TryParse(timeParts[0].Trim(), out var startTime) &&
+ TimeSpan.TryParse(timeParts[1].Trim(), out var endTime))
+ {
+ var shouldBeCurrent = now.TimeOfDay >= startTime && now.TimeOfDay <= endTime;
+ if (shouldBeCurrent != item.IsCurrent)
+ {
+ needsRender = true;
+ break;
+ }
+ }
+ }
+
+ if (needsRender)
+ {
+ RefreshSchedule();
+ }
+ }
+
private int FindCurrentCourseIndex()
{
for (var i = 0; i < _courseItems.Count; i++)
@@ -198,7 +267,6 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget,
return;
}
- // 确保在UI线程执行
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
{
if (courseIndex >= CourseListPanel.Children.Count)
@@ -215,19 +283,18 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget,
var bounds = targetChild.Bounds;
var scrollViewerHeight = ContentScrollViewer.Bounds.Height;
var contentHeight = CourseListPanel.Bounds.Height;
-
- // 计算滚动位置,使当前课程居中显示
+
var targetOffset = bounds.Position.Y - (scrollViewerHeight / 2) + (bounds.Height / 2);
-
- // 确保不超出边界
+
targetOffset = Math.Max(0, Math.Min(targetOffset, contentHeight - scrollViewerHeight));
-
+
ContentScrollViewer.Offset = new Vector(0, targetOffset);
}, Avalonia.Threading.DispatcherPriority.Loaded);
}
public void RefreshFromSettings()
{
+ InvalidateScheduleCache();
RefreshSchedule();
}
@@ -237,9 +304,46 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget,
? BuiltInComponentIds.DesktopClassSchedule
: componentId.Trim();
_placementId = placementId?.Trim() ?? string.Empty;
+ InvalidateScheduleCache();
RefreshSchedule();
}
+ private void InvalidateScheduleCache()
+ {
+ _cachedScheduleResult = null;
+ _lastLoadedSchedulePath = null;
+ _lastScheduleLoadTime = DateTime.MinValue;
+ }
+
+ private ClassIslandScheduleReadResult LoadScheduleWithCache(
+ string? path,
+ DateOnly? semesterStartDate,
+ int semesterWeekCycle)
+ {
+ if (!string.IsNullOrEmpty(path) &&
+ _cachedScheduleResult != null &&
+ _lastLoadedSchedulePath == path &&
+ (DateTime.Now - _lastScheduleLoadTime) < ScheduleCacheDuration)
+ {
+ return _cachedScheduleResult;
+ }
+
+ var result = _scheduleService.Load(
+ path,
+ profileFileName: null,
+ semesterStartDate: semesterStartDate,
+ semesterWeekCycle: semesterWeekCycle);
+
+ if (result.Success)
+ {
+ _cachedScheduleResult = result;
+ _lastLoadedSchedulePath = path;
+ _lastScheduleLoadTime = DateTime.Now;
+ }
+
+ return result;
+ }
+
private void RefreshSchedule()
{
var appSettings = _settingsService.LoadSnapshot(SettingsScope.App);
@@ -253,22 +357,26 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget,
var today = DateOnly.FromDateTime(now);
var importedSchedulePath = ResolveImportedSchedulePath(componentSettings);
- var readResult = _scheduleService.Load(
+ var readResult = LoadScheduleWithCache(
importedSchedulePath,
- profileFileName: null,
- semesterStartDate: componentSettings.SemesterStartDate,
- semesterWeekCycle: componentSettings.SemesterWeekCycle);
+ componentSettings.SemesterStartDate,
+ componentSettings.SemesterWeekCycle);
+
if (!readResult.Success || readResult.Snapshot is null)
{
- _courseItems = Array.Empty();
- UpdateHeader(now);
- ShowStatus(L("schedule.widget.no_source", "未读取到 ClassIsland 课表"));
- RenderScheduleItems();
+ var newItems = Array.Empty();
+ if (!IsDataEqual(_courseItems, newItems))
+ {
+ _courseItems = newItems;
+ UpdateHeader(now);
+ ShowStatus(L("schedule.widget.no_source", "未读取到 ClassIsland 课表"));
+ RenderScheduleItems();
+ }
return;
}
var snapshot = readResult.Snapshot;
-
+
if (!_scheduleService.TryResolveClassPlanForDate(snapshot, today, out var resolvedClassPlan))
{
var nextDay = today.AddDays(1);
@@ -279,27 +387,35 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget,
}
else
{
- _courseItems = Array.Empty();
- UpdateHeader(now);
- ShowStatus(L("schedule.widget.no_class_today", "今天没有课程"));
- RenderScheduleItems();
+ var newItems = Array.Empty();
+ if (!IsDataEqual(_courseItems, newItems))
+ {
+ _courseItems = newItems;
+ UpdateHeader(now);
+ ShowStatus(L("schedule.widget.no_class_today", "今天没有课程"));
+ RenderScheduleItems();
+ }
return;
}
}
if (!snapshot.TimeLayouts.TryGetValue(resolvedClassPlan.ClassPlan.TimeLayoutId, out var layout))
{
- _courseItems = Array.Empty();
- UpdateHeader(now);
- ShowStatus(L("schedule.widget.layout_missing", "课表时间布局缺失"));
- RenderScheduleItems();
+ var newItems = Array.Empty();
+ if (!IsDataEqual(_courseItems, newItems))
+ {
+ _courseItems = newItems;
+ UpdateHeader(now);
+ ShowStatus(L("schedule.widget.layout_missing", "课表时间布局缺失"));
+ RenderScheduleItems();
+ }
return;
}
var adjustedNow = today == DateOnly.FromDateTime(now) ? now : DateTime.Today.AddHours(8);
- _courseItems = BuildCourseItemViewModels(snapshot, resolvedClassPlan.ClassPlan, layout, adjustedNow);
-
- if (_courseItems.Count == 0)
+ var newCourseItems = BuildCourseItemViewModels(snapshot, resolvedClassPlan.ClassPlan, layout, adjustedNow);
+
+ if (newCourseItems.Count == 0)
{
var nextDay = today.AddDays(1);
if (_scheduleService.TryResolveClassPlanForDate(snapshot, nextDay, out var nextDayClassPlan) &&
@@ -307,33 +423,75 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget,
{
today = nextDay;
adjustedNow = DateTime.Today.AddHours(8);
- _courseItems = BuildCourseItemViewModels(snapshot, nextDayClassPlan.ClassPlan, nextLayout, adjustedNow);
+ newCourseItems = BuildCourseItemViewModels(snapshot, nextDayClassPlan.ClassPlan, nextLayout, adjustedNow);
}
}
UpdateHeader(today.ToDateTime(TimeOnly.MinValue));
-
- if (_courseItems.Count == 0)
+
+ if (newCourseItems.Count == 0)
{
- ShowStatus(L("schedule.widget.no_class_today", "今天没有课程"));
+ if (!IsDataEqual(_courseItems, newCourseItems))
+ {
+ _courseItems = newCourseItems;
+ ShowStatus(L("schedule.widget.no_class_today", "今天没有课程"));
+ RenderScheduleItems();
+ }
}
else
{
- var currentIndex = FindCurrentCourseIndex();
- _lastCurrentCourseIndex = currentIndex;
- HideStatus();
-
- // 初始化时自动跳转到当前课程
- if (currentIndex >= 0)
+ var dataChanged = !IsDataEqual(_courseItems, newCourseItems);
+ if (dataChanged)
{
- Avalonia.Threading.Dispatcher.UIThread.Post(() =>
+ _courseItems = newCourseItems;
+ var currentIndex = FindCurrentCourseIndex();
+ _lastCurrentCourseIndex = currentIndex;
+ HideStatus();
+
+ if (currentIndex >= 0)
{
- ScrollToCurrentCourse(currentIndex);
- }, Avalonia.Threading.DispatcherPriority.Loaded);
+ Avalonia.Threading.Dispatcher.UIThread.Post(() =>
+ {
+ ScrollToCurrentCourse(currentIndex);
+ }, Avalonia.Threading.DispatcherPriority.Loaded);
+ }
+
+ RenderScheduleItems();
+ }
+ else
+ {
+ var currentIndex = FindCurrentCourseIndex();
+ if (currentIndex != _lastCurrentCourseIndex)
+ {
+ _lastCurrentCourseIndex = currentIndex;
+ IncrementalUpdateCurrentCourseHighlight(currentIndex);
+ }
+ }
+ }
+ }
+
+ private static bool IsDataEqual(IReadOnlyList oldItems, IReadOnlyList newItems)
+ {
+ if (oldItems.Count != newItems.Count)
+ {
+ return false;
+ }
+
+ for (var i = 0; i < oldItems.Count; i++)
+ {
+ var oldItem = oldItems[i];
+ var newItem = newItems[i];
+
+ if (oldItem.Name != newItem.Name ||
+ oldItem.TimeRange != newItem.TimeRange ||
+ oldItem.Detail != newItem.Detail ||
+ oldItem.IsCurrent != newItem.IsCurrent)
+ {
+ return false;
}
}
- RenderScheduleItems();
+ return true;
}
private IReadOnlyList BuildCourseItemViewModels(
@@ -487,13 +645,35 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget,
private void RenderScheduleItems()
{
- CourseListPanel.Children.Clear();
ClassCountTextBlock.Text = FormatClassCount(_courseItems.Count);
+
if (_courseItems.Count == 0)
{
+ if (CourseListPanel.Children.Count > 0)
+ {
+ CourseListPanel.Children.Clear();
+ }
return;
}
+ var needsFullRebuild = CourseListPanel.Children.Count != _courseItems.Count;
+
+ if (needsFullRebuild)
+ {
+ RebuildAllItems();
+ }
+ else
+ {
+ IncrementalUpdateItems();
+ }
+
+ _lastRenderedItems = _courseItems.ToList();
+ }
+
+ private void RebuildAllItems()
+ {
+ CourseListPanel.Children.Clear();
+
var useMonetColor = ComponentColorSchemeHelper.ShouldUseMonetColor(
_componentColorScheme,
ComponentColorSchemeHelper.GetCurrentGlobalThemeColorMode());
@@ -508,7 +688,6 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget,
Math.Clamp(4 * scale, 2, 8),
Math.Clamp(4 * scale, 2, 8),
Math.Clamp(4 * scale, 2, 8));
- var maxVisibleItems = ResolveMaxVisibleItems(scale);
var primaryBrush = CreateBrush(_isNightVisual ? "#F9FBFF" : "#151821");
var secondaryBrush = CreateBrush(_isNightVisual ? "#848B99" : "#667084");
@@ -520,74 +699,173 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget,
for (var i = 0; i < _courseItems.Count; i++)
{
var item = _courseItems[i];
- var bulletBrush = item.IsCurrent ? currentBrush : normalBulletBrush;
+ var itemControls = CreateSingleItemControl(
+ item,
+ scale,
+ bulletSize,
+ courseNameSize,
+ secondarySize,
+ lineSpacing,
+ itemPadding,
+ primaryBrush,
+ secondaryBrush,
+ item.IsCurrent ? currentBrush : normalBulletBrush);
- var bullet = new Border
- {
- Width = bulletSize,
- Height = bulletSize,
- CornerRadius = new CornerRadius(bulletSize * 0.5),
- Background = bulletBrush,
- VerticalAlignment = Avalonia.Layout.VerticalAlignment.Top,
- Margin = new Thickness(0, Math.Clamp(8 * scale, 2, 12), 0, 0)
- };
-
- var titleText = new TextBlock
- {
- Text = item.Name,
- FontSize = courseNameSize,
- FontWeight = ToVariableWeight(Lerp(620, 780, Math.Clamp((scale - 0.60) / 1.2, 0, 1))),
- Foreground = primaryBrush,
- TextTrimming = TextTrimming.CharacterEllipsis,
- TextWrapping = TextWrapping.NoWrap
- };
-
- var timeText = new TextBlock
- {
- Text = item.TimeRange,
- FontSize = secondarySize,
- FontWeight = ToVariableWeight(Lerp(520, 680, Math.Clamp((scale - 0.60) / 1.2, 0, 1))),
- Foreground = secondaryBrush,
- TextTrimming = TextTrimming.CharacterEllipsis,
- TextWrapping = TextWrapping.NoWrap
- };
-
- var detailText = new TextBlock
- {
- Text = item.Detail,
- FontSize = secondarySize,
- FontWeight = ToVariableWeight(Lerp(500, 640, Math.Clamp((scale - 0.60) / 1.2, 0, 1))),
- Foreground = secondaryBrush,
- TextTrimming = TextTrimming.CharacterEllipsis,
- TextWrapping = TextWrapping.NoWrap
- };
-
- var textStack = new StackPanel
- {
- Spacing = lineSpacing,
- Children = { titleText, timeText, detailText }
- };
-
- var itemGrid = new Grid
- {
- ColumnDefinitions = new ColumnDefinitions("Auto,*"),
- ColumnSpacing = Math.Clamp(10 * scale, 4, 14)
- };
- itemGrid.Children.Add(bullet);
- itemGrid.Children.Add(textStack);
- Grid.SetColumn(textStack, 1);
-
- var itemBorder = new Border
- {
- Padding = itemPadding,
- Background = Brushes.Transparent,
- Child = itemGrid
- };
-
- CourseListPanel.Children.Add(itemBorder);
+ CourseListPanel.Children.Add(itemControls);
}
}
+ private void IncrementalUpdateItems()
+ {
+ var useMonetColor = ComponentColorSchemeHelper.ShouldUseMonetColor(
+ _componentColorScheme,
+ ComponentColorSchemeHelper.GetCurrentGlobalThemeColorMode());
+
+ var currentBrush = useMonetColor
+ ? CreateBrush("#FF4FC3F7")
+ : CreateBrush("#FF4D5A");
+ var normalBulletBrush = CreateBrush(_isNightVisual ? "#B8BEC9" : "#9AA3B2");
+
+ for (var i = 0; i < _courseItems.Count && i < CourseListPanel.Children.Count; i++)
+ {
+ var item = _courseItems[i];
+ var existingBorder = CourseListPanel.Children[i] as Border;
+ if (existingBorder == null) continue;
+
+ var existingGrid = existingBorder.Child as Grid;
+ if (existingGrid == null || existingGrid.Children.Count < 2) continue;
+
+ var bulletBorder = existingGrid.Children[0] as Border;
+ var textStack = existingGrid.Children[1] as StackPanel;
+ if (bulletBorder == null || textStack == null || textStack.Children.Count < 3) continue;
+
+ var newBulletBrush = item.IsCurrent ? currentBrush : normalBulletBrush;
+ bulletBorder.Background = newBulletBrush;
+
+ var titleText = textStack.Children[0] as TextBlock;
+ var timeText = textStack.Children[1] as TextBlock;
+ var detailText = textStack.Children[2] as TextBlock;
+
+ if (titleText != null && titleText.Text != item.Name)
+ {
+ titleText.Text = item.Name;
+ }
+
+ if (timeText != null && timeText.Text != item.TimeRange)
+ {
+ timeText.Text = item.TimeRange;
+ }
+
+ if (detailText != null && detailText.Text != item.Detail)
+ {
+ detailText.Text = item.Detail;
+ }
+ }
+ }
+
+ private void IncrementalUpdateCurrentCourseHighlight(int currentCourseIndex)
+ {
+ var useMonetColor = ComponentColorSchemeHelper.ShouldUseMonetColor(
+ _componentColorScheme,
+ ComponentColorSchemeHelper.GetCurrentGlobalThemeColorMode());
+
+ var currentBrush = useMonetColor
+ ? CreateBrush("#FF4FC3F7")
+ : CreateBrush("#FF4D5A");
+ var normalBulletBrush = CreateBrush(_isNightVisual ? "#B8BEC9" : "#9AA3B2");
+
+ for (var i = 0; i < CourseListPanel.Children.Count; i++)
+ {
+ var border = CourseListPanel.Children[i] as Border;
+ if (border == null) continue;
+
+ var grid = border.Child as Grid;
+ if (grid == null || grid.Children.Count < 2) continue;
+
+ var bulletBorder = grid.Children[0] as Border;
+ if (bulletBorder == null) continue;
+
+ bulletBorder.Background = i == currentCourseIndex ? currentBrush : normalBulletBrush;
+ }
+ }
+
+ private Border CreateSingleItemControl(
+ CourseItemViewModel item,
+ double scale,
+ double bulletSize,
+ double courseNameSize,
+ double secondarySize,
+ double lineSpacing,
+ Thickness itemPadding,
+ IBrush primaryBrush,
+ IBrush secondaryBrush,
+ IBrush bulletBrush)
+ {
+ var bullet = new Border
+ {
+ Width = bulletSize,
+ Height = bulletSize,
+ CornerRadius = new CornerRadius(bulletSize * 0.5),
+ Background = bulletBrush,
+ VerticalAlignment = Avalonia.Layout.VerticalAlignment.Top,
+ Margin = new Thickness(0, Math.Clamp(8 * scale, 2, 12), 0, 0)
+ };
+
+ var titleText = new TextBlock
+ {
+ Text = item.Name,
+ FontSize = courseNameSize,
+ FontWeight = ToVariableWeight(Lerp(620, 780, Math.Clamp((scale - 0.60) / 1.2, 0, 1))),
+ Foreground = primaryBrush,
+ TextTrimming = TextTrimming.CharacterEllipsis,
+ TextWrapping = TextWrapping.NoWrap
+ };
+
+ var timeText = new TextBlock
+ {
+ Text = item.TimeRange,
+ FontSize = secondarySize,
+ FontWeight = ToVariableWeight(Lerp(520, 680, Math.Clamp((scale - 0.60) / 1.2, 0, 1))),
+ Foreground = secondaryBrush,
+ TextTrimming = TextTrimming.CharacterEllipsis,
+ TextWrapping = TextWrapping.NoWrap
+ };
+
+ var detailText = new TextBlock
+ {
+ Text = item.Detail,
+ FontSize = secondarySize,
+ FontWeight = ToVariableWeight(Lerp(500, 640, Math.Clamp((scale - 0.60) / 1.2, 0, 1))),
+ Foreground = secondaryBrush,
+ TextTrimming = TextTrimming.CharacterEllipsis,
+ TextWrapping = TextWrapping.NoWrap
+ };
+
+ var textStack = new StackPanel
+ {
+ Spacing = lineSpacing,
+ Children = { titleText, timeText, detailText }
+ };
+
+ var itemGrid = new Grid
+ {
+ ColumnDefinitions = new ColumnDefinitions("Auto,*"),
+ ColumnSpacing = Math.Clamp(10 * scale, 4, 14)
+ };
+ itemGrid.Children.Add(bullet);
+ itemGrid.Children.Add(textStack);
+ Grid.SetColumn(textStack, 1);
+
+ var itemBorder = new Border
+ {
+ Padding = itemPadding,
+ Background = Brushes.Transparent,
+ Child = itemGrid
+ };
+
+ return itemBorder;
+ }
+
private int ResolveMaxVisibleItems(double scale)
{
var height = Bounds.Height > 1 ? Bounds.Height : _currentCellSize * 4;
@@ -702,7 +980,7 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget,
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;
+ return 0.2126 * r + 0.7155 * g + 0.0722 * b;
}
private static FontWeight ToVariableWeight(double value)
diff --git a/LanMountainDesktop/Views/Components/StudyInterruptDensityWidget.axaml.cs b/LanMountainDesktop/Views/Components/StudyInterruptDensityWidget.axaml.cs
index ad615bc..2bcdae0 100644
--- a/LanMountainDesktop/Views/Components/StudyInterruptDensityWidget.axaml.cs
+++ b/LanMountainDesktop/Views/Components/StudyInterruptDensityWidget.axaml.cs
@@ -55,6 +55,11 @@ public partial class StudyInterruptDensityWidget : UserControl, IDesktopComponen
private string _languageCode = "zh-CN";
private IDisposable? _monitoringLease;
+ // 通知相关字段
+ private DateTime _lastAlertTime = DateTime.MinValue;
+ private readonly TimeSpan _alertCooldown = TimeSpan.FromMinutes(2); // 2分钟冷却时间
+ private DensityLevelKind _lastLevelKind = DensityLevelKind.Calm;
+
private enum DensityLevelKind
{
Calm = 0,
@@ -227,6 +232,9 @@ public partial class StudyInterruptDensityWidget : UserControl, IDesktopComponen
CultureInfo.InvariantCulture,
L("study.interrupt_density.threshold_format", "Threshold {0:F1}/min"),
m.ThresholdPerMin);
+
+ // 检查并发送通知
+ CheckAndSendAlert(m, snapshot.Config);
}
private void ApplyLocalizedLabels()
@@ -687,4 +695,75 @@ public partial class StudyInterruptDensityWidget : UserControl, IDesktopComponen
{
return _localizationService.GetString(_languageCode, key, fallback);
}
+
+ private void CheckAndSendAlert(InterruptDensityMetrics metrics, StudyAnalyticsConfig config)
+ {
+ // 检查提醒开关是否启用
+ if (!config.AlertSoundEnabled)
+ {
+ _lastLevelKind = metrics.LevelKind;
+ return;
+ }
+
+ // 只在级别变化时发送通知
+ if (metrics.LevelKind == _lastLevelKind)
+ {
+ return;
+ }
+
+ // 检查冷却时间
+ if (DateTime.Now - _lastAlertTime < _alertCooldown)
+ {
+ _lastLevelKind = metrics.LevelKind;
+ return;
+ }
+
+ // 只在严重级别时发送通知
+ if (metrics.LevelKind != DensityLevelKind.Severe)
+ {
+ _lastLevelKind = metrics.LevelKind;
+ return;
+ }
+
+ _lastAlertTime = DateTime.Now;
+ _lastLevelKind = metrics.LevelKind;
+
+ // 发送通知
+ try
+ {
+ var densityStr = metrics.DensityPerMin.ToString("F1");
+ var thresholdStr = metrics.ThresholdPerMin.ToString("F1");
+
+ // 判断是否需要显示在正中央(过于吵闹)
+ var isSevere = metrics.DensityPerMin > metrics.ThresholdPerMin * 1.5;
+
+ if (isSevere)
+ {
+ // 严重干扰:显示在正中央
+ var title = L("study.alert.severe_interrupt_title", "严重噪音干扰");
+ var message = string.Format(
+ CultureInfo.CurrentCulture,
+ L("study.alert.severe_interrupt_message", "环境噪音过于嘈杂,严重影响学习效率\n当前打断密度: {0}次/分钟\n建议:寻找更安静的学习环境"),
+ densityStr);
+
+ App.CurrentNotificationService?.ShowWarning(title, message, NotificationPosition.Center);
+ }
+ else
+ {
+ // 一般提醒:显示在右上角
+ var title = L("study.alert.noise_interrupt_title", "噪音打断提醒");
+ var message = string.Format(
+ CultureInfo.CurrentCulture,
+ L("study.alert.noise_interrupt_message", "当前打断密度: {0}次/分钟\n已超过阈值: {1}次/分钟"),
+ densityStr,
+ thresholdStr);
+
+ App.CurrentNotificationService?.ShowWarning(title, message, NotificationPosition.TopRight);
+ }
+ }
+ catch
+ {
+ // 静默处理通知发送失败
+ }
+ }
}
diff --git a/LanMountainDesktop/Views/NotificationDialogWindow.axaml b/LanMountainDesktop/Views/NotificationDialogWindow.axaml
new file mode 100644
index 0000000..b33c307
--- /dev/null
+++ b/LanMountainDesktop/Views/NotificationDialogWindow.axaml
@@ -0,0 +1,74 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/LanMountainDesktop/Views/NotificationDialogWindow.axaml.cs b/LanMountainDesktop/Views/NotificationDialogWindow.axaml.cs
new file mode 100644
index 0000000..e7bde2b
--- /dev/null
+++ b/LanMountainDesktop/Views/NotificationDialogWindow.axaml.cs
@@ -0,0 +1,181 @@
+using System;
+using System.Threading.Tasks;
+using Avalonia;
+using Avalonia.Animation;
+using Avalonia.Animation.Easings;
+using Avalonia.Controls;
+using Avalonia.Media;
+using Avalonia.Styling;
+using Avalonia.Threading;
+using CommunityToolkit.Mvvm.ComponentModel;
+using FluentIcons.Avalonia;
+using FluentIcons.Avalonia.Fluent;
+using LanMountainDesktop.Services;
+
+namespace LanMountainDesktop.Views;
+
+public partial class NotificationDialogWindow : Window
+{
+ private NotificationDialogViewModel? _viewModel;
+ private DispatcherTimer? _autoCloseTimer;
+ private bool _isClosing;
+
+ public TaskCompletionSource? CompletionSource { get; private set; }
+
+ public NotificationDialogWindow()
+ {
+ InitializeComponent();
+ }
+
+ public void Initialize(NotificationContent content, IAppearanceThemeService? themeService = null)
+ {
+ _viewModel = new NotificationDialogViewModel(content, this);
+ DataContext = _viewModel;
+
+ CompletionSource = new TaskCompletionSource();
+
+ bool isNightMode = false;
+ if (themeService is not null)
+ {
+ var snapshot = themeService.GetCurrent();
+ isNightMode = snapshot.IsNightMode;
+ RequestedThemeVariant = isNightMode ? ThemeVariant.Dark : ThemeVariant.Light;
+ }
+
+ if (DialogCard is not null)
+ {
+ DialogCard.Background = isNightMode
+ ? new SolidColorBrush(Color.Parse("#FF2D2D2D"))
+ : new SolidColorBrush(Color.Parse("#FFF8F9FA"));
+ }
+
+ if (!HasButtons(content) && content.Duration.HasValue)
+ {
+ _autoCloseTimer = new DispatcherTimer
+ {
+ Interval = content.Duration.Value
+ };
+ _autoCloseTimer.Tick += OnAutoCloseTimerTick;
+ _autoCloseTimer.Start();
+ }
+ }
+
+ private static bool HasButtons(NotificationContent content)
+ {
+ return !string.IsNullOrEmpty(content.PrimaryButtonText) ||
+ !string.IsNullOrEmpty(content.SecondaryButtonText);
+ }
+
+ private void OnAutoCloseTimerTick(object? sender, EventArgs e)
+ {
+ _autoCloseTimer?.Stop();
+ _ = CloseWithResultAsync(false);
+ }
+
+ public void OnPrimaryButtonClick()
+ {
+ _ = CloseWithResultAsync(true);
+ }
+
+ public void OnSecondaryButtonClick()
+ {
+ _ = CloseWithResultAsync(false);
+ }
+
+ private async Task CloseWithResultAsync(bool result)
+ {
+ if (_isClosing) return;
+ _isClosing = true;
+
+ _autoCloseTimer?.Stop();
+
+ if (DialogCard is not null)
+ {
+ DialogCard.RenderTransform = new ScaleTransform(1, 1);
+ DialogCard.Opacity = 1;
+
+ var animation = new Animation
+ {
+ Duration = TimeSpan.FromMilliseconds(200),
+ Easing = new QuadraticEaseOut(),
+ Children =
+ {
+ new KeyFrame
+ {
+ Cue = new Cue(0d),
+ Setters =
+ {
+ new Setter(OpacityProperty, 1d),
+ new Setter(ScaleTransform.ScaleXProperty, 1d),
+ new Setter(ScaleTransform.ScaleYProperty, 1d)
+ }
+ },
+ new KeyFrame
+ {
+ Cue = new Cue(1d),
+ Setters =
+ {
+ new Setter(OpacityProperty, 0d),
+ new Setter(ScaleTransform.ScaleXProperty, 0.9d),
+ new Setter(ScaleTransform.ScaleYProperty, 0.9d)
+ }
+ }
+ }
+ };
+
+ await animation.RunAsync(DialogCard);
+ }
+
+ CompletionSource?.TrySetResult(result);
+ Close();
+ }
+}
+
+public partial class NotificationDialogViewModel : ObservableObject
+{
+ private readonly NotificationDialogWindow _window;
+ private readonly NotificationContent _content;
+
+ [ObservableProperty] private string _title = string.Empty;
+ [ObservableProperty] private string? _message;
+ [ObservableProperty] private string? _primaryButtonText;
+ [ObservableProperty] private string? _secondaryButtonText;
+ [ObservableProperty] private bool _hasButtons;
+ [ObservableProperty] private string _severityIcon = "Info";
+ [ObservableProperty] private IBrush? _severityBackground;
+
+ public NotificationDialogViewModel(NotificationContent content, NotificationDialogWindow window)
+ {
+ _window = window;
+ _content = content;
+
+ Title = content.Title;
+ Message = content.Message;
+ PrimaryButtonText = content.PrimaryButtonText;
+ SecondaryButtonText = content.SecondaryButtonText;
+ HasButtons = !string.IsNullOrEmpty(content.PrimaryButtonText) ||
+ !string.IsNullOrEmpty(content.SecondaryButtonText);
+
+ (SeverityIcon, SeverityBackground) = content.Severity switch
+ {
+ NotificationSeverity.Success => ("CheckmarkCircle", new SolidColorBrush(Color.Parse("#FF10B981"))),
+ NotificationSeverity.Warning => ("Warning", new SolidColorBrush(Color.Parse("#FFF59E0B"))),
+ NotificationSeverity.Error => ("ErrorCircle", new SolidColorBrush(Color.Parse("#FFEF4444"))),
+ _ => ("Info", new SolidColorBrush(Color.Parse("#FF3B82F6")))
+ };
+ }
+
+ [CommunityToolkit.Mvvm.Input.RelayCommand]
+ private void Primary()
+ {
+ _content.OnPrimaryButtonClick?.Invoke();
+ _window.OnPrimaryButtonClick();
+ }
+
+ [CommunityToolkit.Mvvm.Input.RelayCommand]
+ private void Secondary()
+ {
+ _content.OnSecondaryButtonClick?.Invoke();
+ _window.OnSecondaryButtonClick();
+ }
+}
diff --git a/LanMountainDesktop/Views/NotificationWindow.axaml b/LanMountainDesktop/Views/NotificationWindow.axaml
new file mode 100644
index 0000000..a804c8e
--- /dev/null
+++ b/LanMountainDesktop/Views/NotificationWindow.axaml
@@ -0,0 +1,96 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/LanMountainDesktop/Views/NotificationWindow.axaml.cs b/LanMountainDesktop/Views/NotificationWindow.axaml.cs
new file mode 100644
index 0000000..2e0ec64
--- /dev/null
+++ b/LanMountainDesktop/Views/NotificationWindow.axaml.cs
@@ -0,0 +1,240 @@
+using System;
+using System.Threading.Tasks;
+using Avalonia;
+using Avalonia.Animation;
+using Avalonia.Animation.Easings;
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Media;
+using Avalonia.Styling;
+using Avalonia.Threading;
+using LanMountainDesktop.Services;
+using LanMountainDesktop.Theme;
+using LanMountainDesktop.ViewModels;
+
+namespace LanMountainDesktop.Views;
+
+public partial class NotificationWindow : Window
+{
+ private NotificationViewModel? _viewModel;
+ private DispatcherTimer? _autoCloseTimer;
+ private bool _isClosing;
+ private TimeSpan _remainingDuration;
+
+ public Guid NotificationId => _viewModel?.Id ?? Guid.Empty;
+ public NotificationPosition NotificationPositionValue => _viewModel?.Position ?? NotificationPosition.TopRight;
+
+ public NotificationWindow()
+ {
+ InitializeComponent();
+ _remainingDuration = TimeSpan.FromSeconds(4);
+ }
+
+ public void Initialize(NotificationViewModel viewModel, IAppearanceThemeService? themeService = null)
+ {
+ _viewModel = viewModel;
+ DataContext = viewModel;
+
+ _remainingDuration = viewModel.Duration;
+
+ ApplyTheme(themeService);
+ ApplySeverityColor();
+ }
+
+ private void ApplyTheme(IAppearanceThemeService? themeService)
+ {
+ if (themeService is null) return;
+
+ var snapshot = themeService.GetCurrent();
+ RequestedThemeVariant = snapshot.IsNightMode ? ThemeVariant.Dark : ThemeVariant.Light;
+
+ // Apply glass effect resources directly to window resources
+ // This ensures the notification card has proper background/border colors
+ var context = CreateThemeContext(snapshot);
+ GlassEffectService.ApplyGlassResources(Resources, context);
+
+ // IMPORTANT: Do NOT call ApplyWindowMaterial for notification windows!
+ // ApplyWindowMaterial sets Background to White when MaterialMode is "None",
+ // which causes the white border around the notification card.
+ // Notification windows must always have transparent background.
+ Background = Brushes.Transparent;
+ TransparencyLevelHint = [WindowTransparencyLevel.Transparent];
+ }
+
+ private ThemeColorContext CreateThemeContext(AppearanceThemeSnapshot snapshot)
+ {
+ // Create theme context for glass effect resources
+ // Note: IsLightBackground and IsLightNavBackground are derived from IsNightMode
+ // UseNeutralSurfaces is determined by ThemeColorMode
+ var useNeutralSurfaces = snapshot.ThemeColorMode == "Neutral";
+ var monetColors = snapshot.WallpaperSeedCandidates;
+
+ return new ThemeColorContext(
+ AccentColor: snapshot.AccentColor,
+ IsLightBackground: !snapshot.IsNightMode,
+ IsLightNavBackground: !snapshot.IsNightMode,
+ IsNightMode: snapshot.IsNightMode,
+ MonetPalette: snapshot.MonetPalette,
+ MonetColors: monetColors,
+ UseNeutralSurfaces: useNeutralSurfaces,
+ SystemMaterialMode: snapshot.SystemMaterialMode);
+ }
+
+ private void ApplySeverityColor()
+ {
+ if (_viewModel is null) return;
+
+ if (this.TryFindResource(_viewModel.SeverityColorResource, out var resource) && resource is IBrush brush)
+ {
+ SeverityIndicator.Background = brush;
+ }
+ else
+ {
+ // Fallback for custom theme compatibility
+ var severityColor = _viewModel.Severity switch
+ {
+ NotificationSeverity.Success => Color.Parse("#FF10B981"),
+ NotificationSeverity.Warning => Color.Parse("#FFF59E0B"),
+ NotificationSeverity.Error => Color.Parse("#FFEF4444"),
+ _ => Color.Parse("#FF3B82F6")
+ };
+ SeverityIndicator.Background = new SolidColorBrush(severityColor);
+ }
+ }
+
+ public void StartAutoCloseTimer()
+ {
+ _autoCloseTimer = new DispatcherTimer
+ {
+ Interval = _remainingDuration
+ };
+ _autoCloseTimer.Tick += OnAutoCloseTimerTick;
+ _autoCloseTimer.Start();
+ }
+
+ private void OnAutoCloseTimerTick(object? sender, EventArgs e)
+ {
+ _autoCloseTimer?.Stop();
+ Dispatcher.UIThread.Post(() => _ = CloseWithAnimationAsync());
+ }
+
+ private void OnCardPointerPressed(object? sender, PointerPressedEventArgs e)
+ {
+ if (_viewModel?.OnClick is not null)
+ {
+ _viewModel.OnClick.Invoke();
+ }
+ _ = CloseWithAnimationAsync();
+ }
+
+ private void OnCardPointerEntered(object? sender, PointerEventArgs e)
+ {
+ _autoCloseTimer?.Stop();
+ CardBorder.Opacity = 0.95;
+ }
+
+ private void OnCardPointerExited(object? sender, PointerEventArgs e)
+ {
+ CardBorder.Opacity = 1;
+ StartAutoCloseTimer();
+ }
+
+ public async Task CloseWithAnimationAsync()
+ {
+ if (_isClosing) return;
+ _isClosing = true;
+
+ _autoCloseTimer?.Stop();
+
+ if (_viewModel is not null)
+ {
+ _viewModel.IsClosing = true;
+ }
+
+ CardBorder.RenderTransform = new ScaleTransform(1, 1);
+ CardBorder.Opacity = 1;
+
+ var animation = new Animation
+ {
+ Duration = TimeSpan.FromMilliseconds(200),
+ Easing = new QuadraticEaseOut(),
+ Children =
+ {
+ new KeyFrame
+ {
+ Cue = new Cue(0d),
+ Setters =
+ {
+ new Setter(OpacityProperty, 1d),
+ new Setter(ScaleTransform.ScaleXProperty, 1d),
+ new Setter(ScaleTransform.ScaleYProperty, 1d)
+ }
+ },
+ new KeyFrame
+ {
+ Cue = new Cue(1d),
+ Setters =
+ {
+ new Setter(OpacityProperty, 0d),
+ new Setter(ScaleTransform.ScaleXProperty, 0.9d),
+ new Setter(ScaleTransform.ScaleYProperty, 0.9d)
+ }
+ }
+ }
+ };
+
+ await animation.RunAsync(CardBorder);
+
+ Close();
+ }
+
+ public async Task ShowWithAnimationAsync()
+ {
+ // Show window first (material should already be applied in Initialize)
+ Show();
+
+ // Ensure render transform is set before animation
+ CardBorder.RenderTransform = new ScaleTransform(0.85, 0.85);
+ CardBorder.Opacity = 0;
+
+ var animation = new Animation
+ {
+ Duration = TimeSpan.FromMilliseconds(250),
+ Easing = new QuadraticEaseOut(),
+ FillMode = FillMode.Forward,
+ Children =
+ {
+ new KeyFrame
+ {
+ Cue = new Cue(0d),
+ Setters =
+ {
+ new Setter(OpacityProperty, 0d),
+ new Setter(ScaleTransform.ScaleXProperty, 0.85d),
+ new Setter(ScaleTransform.ScaleYProperty, 0.85d)
+ }
+ },
+ new KeyFrame
+ {
+ Cue = new Cue(1d),
+ Setters =
+ {
+ new Setter(OpacityProperty, 1d),
+ new Setter(ScaleTransform.ScaleXProperty, 1d),
+ new Setter(ScaleTransform.ScaleYProperty, 1d)
+ }
+ }
+ }
+ };
+
+ await animation.RunAsync(CardBorder);
+
+ StartAutoCloseTimer();
+ }
+
+ protected override void OnClosing(WindowClosingEventArgs e)
+ {
+ _autoCloseTimer?.Stop();
+ base.OnClosing(e);
+ }
+}
diff --git a/LanMountainDesktop/Views/SettingsPages/NotificationSettingsPage.axaml b/LanMountainDesktop/Views/SettingsPages/NotificationSettingsPage.axaml
new file mode 100644
index 0000000..f6d01d8
--- /dev/null
+++ b/LanMountainDesktop/Views/SettingsPages/NotificationSettingsPage.axaml
@@ -0,0 +1,112 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/LanMountainDesktop/Views/SettingsPages/NotificationSettingsPage.axaml.cs b/LanMountainDesktop/Views/SettingsPages/NotificationSettingsPage.axaml.cs
new file mode 100644
index 0000000..7630367
--- /dev/null
+++ b/LanMountainDesktop/Views/SettingsPages/NotificationSettingsPage.axaml.cs
@@ -0,0 +1,30 @@
+using LanMountainDesktop.PluginSdk;
+using LanMountainDesktop.Services.Settings;
+using LanMountainDesktop.ViewModels;
+
+namespace LanMountainDesktop.Views.SettingsPages;
+
+[SettingsPageInfo(
+ "notifications",
+ "通知",
+ SettingsPageCategory.Components,
+ IconKey = "Bell",
+ SortOrder = 5,
+ TitleLocalizationKey = "settings.notifications.title",
+ DescriptionLocalizationKey = "settings.notifications.description")]
+public partial class NotificationSettingsPage : SettingsPageBase
+{
+ public NotificationSettingsPage()
+ : this(new NotificationSettingsPageViewModel(HostSettingsFacadeProvider.GetOrCreate()))
+ {
+ }
+
+ public NotificationSettingsPage(NotificationSettingsPageViewModel viewModel)
+ {
+ ViewModel = viewModel;
+ DataContext = ViewModel;
+ InitializeComponent();
+ }
+
+ public NotificationSettingsPageViewModel ViewModel { get; }
+}
diff --git a/LanMountainDesktop/Views/SettingsWindow.axaml.cs b/LanMountainDesktop/Views/SettingsWindow.axaml.cs
index 8f35357..6721481 100644
--- a/LanMountainDesktop/Views/SettingsWindow.axaml.cs
+++ b/LanMountainDesktop/Views/SettingsWindow.axaml.cs
@@ -734,6 +734,8 @@ public partial class SettingsWindow : Window, ISettingsPageHostContext
"Info" => Symbol.Info,
"ArrowSync" => Symbol.ArrowSync,
"Hourglass" => Symbol.Hourglass,
+ "Alert" => Symbol.Alert, // 铃铛图标
+ "Bell" => Symbol.Alert, // Bell也映射到Alert图标
_ => Symbol.Settings
};
}
diff --git a/docs/component-corner-radius-unification-plan.txt b/docs/component-corner-radius-unification-plan.txt
new file mode 100644
index 0000000..ea0f89f
--- /dev/null
+++ b/docs/component-corner-radius-unification-plan.txt
@@ -0,0 +1,304 @@
+LanMountainDesktop 组件圆角统一方案
+
+一、我对项目的理解
+- 这是一个基于 .NET 10 + Avalonia UI 的跨平台桌面宿主项目。
+- 核心形态是“组件化桌面信息看板”:组件可放置到桌面,可编辑、可缩放,既支持自由缩放,也支持等比例缩放。
+- 当前圆角体系本身并不是空白:项目已经有统一 token 和全局圆角倍率。
+ - 圆角 token 生成:LanMountainDesktop.Appearance/AppearanceCornerRadiusTokenFactory.cs
+ - 动态注入资源:LanMountainDesktop/Services/AppearanceThemeService.cs
+ - 基础 token 资源:LanMountainDesktop/Styles/GlassModule.axaml
+ - 宿主组件圆角助手:LanMountainDesktop/Views/Components/ComponentChromeCornerRadiusHelper.cs
+ - 插件圆角上下文:LanMountainDesktop.PluginSdk/PluginAppearanceContext.cs
+
+二、为什么现在“设置里同样是 1.0,但每个组件看起来不一样”
+根因不是一个,而是几层逻辑叠加造成的。
+
+1. 内置组件和插件组件默认算法不同
+- 内置组件宿主默认走 Component token:18 * GlobalCornerRadiusScale。
+ - 入口:LanMountainDesktop/Views/Components/DesktopComponentRuntimeRegistry.cs
+ - 默认 resolver:ComponentChromeCornerRadiusHelper.ResolveMainRectangleRadiusValue(...)
+- 插件组件默认走基于 cellSize 的动态算法:
+ - LanMountainDesktop.PluginSdk/PluginDesktopComponentRegistration.cs
+ - 当前默认逻辑约等于:Math.Clamp(cellSize * 0.22, 8, 18) * scale
+- 这意味着:同样设置为 1.0,内置组件趋向固定圆角,插件组件趋向“随格子大小变化”。
+- 结果:用户感知到“同样设置值,但不同组件圆角不一样”。
+
+2. 可见外壳并没有真正统一由宿主接管
+- MainWindow.ComponentSystem.cs 里宿主会给 host/contentHost 设置圆角。
+- 但大量组件内部还有自己的 RootBorder / CardBorder / 内层卡片,并且也各自设置圆角。
+- 一旦“宿主外壳 + 组件根层 + 组件内部卡片”同时存在,用户最终看到的是最内层那个可见边界,而不一定是宿主设的边界。
+- 典型现象:
+ - 某些组件外层 Border 是透明的,真正可见的是里面第二层 CardBorder。
+ - 某些组件直接在代码里写死 new CornerRadius(...)。
+
+3. 组件内部仍有硬编码圆角
+- 例如:
+ - DailyNewsView.axaml.cs 有 new CornerRadius(4)
+ - CnrDailyNewsWidget.axaml 有 CornerRadius="21"
+ - DesktopComponentFailureView.cs 有 12 / 18 / 999 等固定值
+- 这些值本身不一定错,但如果它们承担的是“组件主外轮廓”,就会破坏统一性。
+
+4. 等比例缩放组件大量使用 Viewbox,视觉边界和内容边界不是一回事
+- 例如 LunarCalendarWidget.axaml、DateWidget.axaml、MonthCalendarWidget.axaml、AnalogClockWidget.axaml 等都使用 Viewbox Stretch="Uniform"。
+- 这类组件通常是:外层 Border 固定圆角,内部设计稿按 300x300 或类似基准缩放。
+- 如果另一些组件是自由布局、自由撑开、非 Viewbox 驱动,那么即便外层半径数值相同,视觉上也会显得不一样。
+- 原因是:内容密度、留白、边缘贴合程度不同,会显著影响人眼对圆角大小的判断。
+
+5. 宿主的 visualInset 也在影响观感
+- MainWindow.ComponentSystem.cs 里的 GetDesktopComponentVisualInset(...) 会根据组件宽高格子数改变内缩量。
+- 宿主目前是“命中范围/编辑范围一套,实际可见内容再缩进去一层”。
+- 当不同尺寸组件有不同 inset,而组件自己又有独立圆角时,视觉上就更容易出现“同 18 看起来不像同 18”的问题。
+
+三、统一方案的核心原则
+原则只有一句:
+“组件主外轮廓的圆角,只能有一个最终权威来源。”
+
+建议把圆角分成三层:
+1. 组件外壳圆角(主轮廓)
+2. 组件内部区域圆角(二级卡片)
+3. 微元素圆角(按钮、标签、图片卡片、chip)
+
+其中,只有第 1 层决定“这个组件作为桌面卡片整体看起来有多圆”。
+
+四、推荐的统一规则
+
+A. 统一“组件主外壳圆角”
+推荐规则:
+- 所有桌面组件,不区分内置/插件,不区分自由缩放/等比缩放,默认主外壳统一使用:
+ OuterRadius = CornerRadiusTokens.Component
+- 也就是当前 token 体系里的 Component 档。
+- 在 1.0 设置下,就是 18。
+- 这个值只受全局圆角倍率影响,不再受 cellSize 直接影响。
+
+推荐最终规则:
+- 标准情况:
+ OuterRadius = tokens.Component
+- 极小组件兜底(仅防止尺寸过小导致圆角挤爆):
+ OuterRadius = Min(tokens.Component, Min(actualWidth, actualHeight) * 0.18)
+- 但这个“极小兜底”只在组件物理尺寸不够时触发;正常组件应保持完全一致。
+
+这条规则的意义:
+- 用户调到 1.0,所有组件都会首先落在同一个视觉档位。
+- 只有非常小的组件才会被动缩小圆角,避免失真。
+- 这样既统一,又不会在边缘尺寸下破版。
+
+B. 插件默认算法必须改成和宿主一致
+当前插件默认算法是按 cellSize 算的,这是造成不一致的最直接原因之一。
+
+建议修改:
+- 把 PluginDesktopComponentRegistration.cs 里的默认逻辑,从:
+ appearance.ResolveScaledCornerRadius(Math.Clamp(cellSize * 0.22, 8, 18), 8, 18)
+- 改为:
+ appearance.ResolveCornerRadius(PluginCornerRadiusPreset.Component)
+
+含义:
+- 插件如果没有特别声明,就跟内置组件一样,默认使用 Component 档主外壳圆角。
+- 只有插件作者明确声明特殊需求时,才允许自定义 resolver。
+
+C. 宿主要成为“唯一外壳提供者”
+这是最重要的一条。
+
+建议新增统一壳层,例如:
+- DesktopComponentShell
+或
+- DesktopComponentChrome
+
+职责:
+- 负责组件真正可见的最外层背景、边框、裁剪、主圆角
+- 负责统一 padding / glass / border / shadow
+- 负责选中态、拖拽态、编辑态的视觉装饰
+- 组件内容只负责“内容”,不再负责“卡片主轮廓”
+
+理想结构:
+- Host Border / Shell = 真正的可见外轮廓
+- Component Root = 尽量透明,不再自己承担主卡片圆角
+- Inner Sections = 使用 Sm / Xs / Micro 等 token
+
+也就是说:
+- 主外轮廓只在壳层定义一次
+- 组件内部不再重复定义一个同等级 RootBorder 当作主卡片
+
+D. 统一内部层级,不要所有层都用 Component
+建议把当前 token 真正分层使用:
+- Component:桌面组件主外壳
+- Sm:内部小卡片、图片区、内容区块
+- Xs:按钮、输入框、chip、小容器
+- Micro:极小 badge / 标签
+- Island / Xl / Lg:只给岛状栏位、大面板、设置窗口,不用于普通桌面组件
+
+落地约束:
+- 桌面组件主根层禁止再写 DesignCornerRadiusLg / Xl / Island
+- 组件主根层禁止使用硬编码 21 / 16 / 24 / 30 之类值
+- 子卡片允许用 Sm / Xs,但不能与主壳争夺“主轮廓”角色
+
+E. 等比例缩放与自由缩放分别处理,但外圆角规则相同
+1)等比例缩放组件(Viewbox)
+- 外壳圆角固定由宿主提供,不参与 Viewbox 缩放。
+- Viewbox 只缩放内部设计稿。
+- 外壳与内部设计稿之间保留统一“安全留白”。
+
+建议:
+- SafeInset = Max(8, OuterRadius * 0.45)
+- 对所有 Viewbox 类组件,外层容器 padding 使用统一公式,而不是每个组件自己猜。
+
+2)自由缩放组件
+- 外壳圆角仍固定由宿主提供。
+- 自由缩放只影响内容布局、字号、图表密度、行数和间距,不影响主外壳半径。
+- 内容内部若要变化,优先变化:padding、gap、字号、图标尺寸,而不是外圆角。
+
+这样做的结果:
+- 缩放方式不同,但最外层的“卡片家族感”一致。
+- 用户调的是“整体风格圆角”,不是“每个组件自己的数学公式”。
+
+F. 把 visualInset 从“影响圆角观感”变成“只影响编辑/命中逻辑”
+当前 GetDesktopComponentVisualInset(...) 会让不同大小组件看起来边界不同。
+
+建议二选一:
+1. 更推荐:
+- 让宿主 shell 成为真实可见边界
+- visualInset 只服务拖拽/选中/吸附逻辑,不再改变真实可见主卡片的边界层次
+
+2. 如果暂时不重构:
+- 把 visualInset 改成固定档位,而不是随 widthCells / heightCells 持续变化
+- 例如统一为 6 或 8 的 token 化 inset
+
+目标:
+- 组件整体轮廓不要再因为跨度不同而“看起来圆角不同”
+
+五、具体改造建议(按代码位置)
+
+1. 插件默认圆角统一
+文件:LanMountainDesktop.PluginSdk/PluginDesktopComponentRegistration.cs
+建议:
+- 默认从“基于 cellSize 动态计算”改成“直接取 Component preset”。
+
+2. 宿主外壳统一
+文件:LanMountainDesktop/Views/MainWindow.ComponentSystem.cs
+重点方法:
+- CreateDesktopComponentHost(...)
+- GetComponentCornerRadius(...)
+- GetDesktopComponentVisualInset(...)
+
+建议:
+- 让 contentHost 或新建 shell 成为真实可见主边界
+- 背景、边框、裁剪、圆角统一放在 shell
+- host 仅保留命中、拖拽、选中装饰
+- visualInset 不再影响真实主卡片外观,最多影响编辑态附加层
+
+3. 统一运行时默认 resolver
+文件:LanMountainDesktop/Views/Components/DesktopComponentRuntimeRegistry.cs
+建议:
+- 保留 DefaultCornerRadiusResolver 走 Component token
+- 新增统一 chrome metrics 输出,供组件内部使用:
+ - OuterRadius
+ - InnerRadiusSm
+ - InnerRadiusXs
+ - SafeInset
+ - GlobalCornerRadiusScale
+
+4. 扩展 chrome 上下文
+文件:
+- LanMountainDesktop.Host.Abstractions/ComponentChromeContext.cs
+- LanMountainDesktop/Services/IComponentLibraryService.cs
+- LanMountainDesktop.PluginSdk/PluginAppearanceSnapshot.cs(如需要)
+
+建议新增字段:
+- ResolvedOuterCornerRadius
+- SafeInset
+- ChromePadding
+- VisualDensity 或 SizeClass(可选)
+
+目的:
+- 组件不要自己猜“我应该用多少主圆角”
+- 它只消费宿主已算好的结果
+
+5. 清理组件内部主轮廓重复定义
+重点检查目录:
+- LanMountainDesktop/Views/Components/
+
+优先整改对象:
+- CnrDailyNewsWidget.axaml(RootBorder + CardBorder 双层主轮廓)
+- BrowserWidget.axaml.cs(内部多层主卡片都取主圆角)
+- DailyNewsView.axaml / .cs(有硬编码子元素圆角)
+- DesktopComponentFailureView.cs(硬编码 12 / 18 / 999)
+- 使用 Viewbox 的日期、月历、农历、计时器、录音等组件
+
+整改原则:
+- 组件根层如果已经处于宿主 shell 内,应尽量透明化
+- 只保留内部结构性圆角,不再重复承担主卡片角色
+
+六、我建议采用的“统一视觉规范”
+
+1. 桌面组件主外壳
+- 统一:Component = 18 * scale
+- 默认 1.0 时全部按 18 展示
+- 仅极小尺寸时做 min(actualShortSide * 0.18) 兜底
+
+2. 内部板块
+- 统一:Sm = 14 * scale
+- 用于图片区、内嵌卡片、列表块、概览块
+
+3. 交互控件
+- 统一:Xs = 12 * scale
+- 用于普通按钮、输入框、小标签
+
+4. 胶囊按钮 / 圆按钮
+- 不走主卡片规则
+- 仍允许 half-height(例如 32 高就 16 半径)
+- 这是合法特例,因为它们不是“桌面组件主外轮廓”
+
+5. 大面板 / 岛 / 设置窗口
+- 保持 Lg / Xl / Island
+- 不要向桌面普通组件借用这些档位
+
+七、建议的落地步骤
+
+第一阶段:统一规则,不大改组件
+- 修改插件默认圆角算法,使插件先与内置组件对齐
+- 明确“主外壳 = Component token”这个规范
+- 先把所有失败态、默认态、插件兜底视图改成统一档位
+- 清理明显硬编码的主轮廓圆角
+
+第二阶段:建立统一 shell
+- 抽出 DesktopComponentShell / DesktopComponentChrome
+- 宿主统一负责外壳、背景、裁剪、边框、选中态
+- 组件内部改成内容优先,外轮廓透明化
+
+第三阶段:分批迁移内置组件
+推荐顺序:
+- 新闻资讯类
+- 日期/日历/时钟类
+- 天气类
+- 浏览器/复杂卡片类
+- 失败态/占位态/插件样例
+
+第四阶段:插件规范升级
+- 在 SDK 文档里新增说明:
+ - 插件主外壳不要自行写死圆角
+ - 默认使用宿主 Component preset
+ - 若必须特殊化,说明理由并走显式 CornerRadiusResolver
+
+八、验收标准
+
+改完之后,至少要满足这 5 条:
+1. 设置里圆角倍率调成 1.0 时,所有组件主外轮廓处于同一视觉档位。
+2. 内置组件与插件组件默认情况下看起来是一套语言。
+3. 等比例缩放与自由缩放组件虽然内容布局不同,但卡片外壳风格一致。
+4. 组件内部还可以有小圆角层级,但不会再和主外壳打架。
+5. 用户只需要理解“全局圆角倍率”,不需要理解不同组件背后的不同公式。
+
+九、我对你这个项目最推荐的最终结论
+最推荐的方案不是“继续微调每个组件自己的圆角公式”,而是:
+
+- 用宿主统一接管组件主外壳
+- 插件默认改成和宿主同一算法
+- 把圆角分成 主外壳 / 内部区块 / 微元素 三层
+- 让缩放影响内容,不再影响主外壳圆角
+
+一句话总结:
+“统一的不是每个 Border 的数字,而是组件最外层轮廓的控制权。”
+
+十、如果你要我继续往下做,最值得优先做的 3 件事
+1. 我先帮你列一份“当前所有组件圆角不统一点位清单(按文件逐个标出来)”
+2. 我再给你出一版“可直接改代码的重构方案”,包括新增 DesktopComponentShell 的接口设计
+3. 如果你愿意,我可以直接开始改第一批基础代码,把内置组件和插件默认圆角先统一起来