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/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. 如果你愿意,我可以直接开始改第一批基础代码,把内置组件和插件默认圆角先统一起来