mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-21 08:04:26 +08:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88bd92e40a | ||
|
|
ff014717fa | ||
|
|
964cef27ee | ||
|
|
2272d35c16 |
@@ -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;
|
||||
@@ -66,6 +67,7 @@ public partial class App : Application
|
||||
private NativeMenuItem? _trayExitMenuItem;
|
||||
private PluginRuntimeService? _pluginRuntimeService;
|
||||
private MainWindow? _mainWindow;
|
||||
private TransparentOverlayWindow? _transparentOverlayWindow;
|
||||
private bool _mainWindowClosed;
|
||||
private bool _uiUnhandledExceptionHooked;
|
||||
private DesktopShellHost? _desktopShellHost;
|
||||
@@ -73,6 +75,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 +91,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 +133,7 @@ public partial class App : Application
|
||||
ApplyCurrentCultureFromSettings();
|
||||
EnsureSettingsWindowService();
|
||||
EnsureWeatherLocationRefreshService();
|
||||
EnsureNotificationService();
|
||||
}
|
||||
|
||||
public override void OnFrameworkInitializationCompleted()
|
||||
@@ -213,12 +219,37 @@ public partial class App : Application
|
||||
{
|
||||
_ = sender;
|
||||
_ = e;
|
||||
if (_mainWindow is null)
|
||||
|
||||
// 仅在 Windows 上支持融合桌面功能
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
AppLogger.Warn("FusedDesktop", "Fused desktop is only supported on Windows.");
|
||||
return;
|
||||
}
|
||||
|
||||
_detachedComponentLibraryWindowService.Open(_mainWindow);
|
||||
|
||||
// 确保透明覆盖层窗口存在
|
||||
EnsureTransparentOverlayWindow();
|
||||
|
||||
// 打开融合桌面组件库窗口
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var window = new FusedDesktopComponentLibraryWindow();
|
||||
|
||||
if (_transparentOverlayWindow is not null)
|
||||
{
|
||||
window.SetOverlayWindow(_transparentOverlayWindow);
|
||||
}
|
||||
|
||||
window.Show();
|
||||
window.Activate();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("FusedDesktop", "Failed to open fused desktop component library.", ex);
|
||||
}
|
||||
}, DispatcherPriority.Send);
|
||||
}
|
||||
|
||||
private void DisableAvaloniaDataAnnotationValidation()
|
||||
@@ -400,6 +431,11 @@ public partial class App : Application
|
||||
_localizationService);
|
||||
}
|
||||
|
||||
private void EnsureNotificationService()
|
||||
{
|
||||
_notificationService ??= new NotificationService(_appearanceThemeService);
|
||||
}
|
||||
|
||||
private void StartWeatherLocationRefreshIfNeeded()
|
||||
{
|
||||
EnsureWeatherLocationRefreshService();
|
||||
@@ -482,6 +518,12 @@ public partial class App : Application
|
||||
|
||||
try
|
||||
{
|
||||
// 先隐藏透明覆盖层窗口
|
||||
if (_transparentOverlayWindow is not null && _transparentOverlayWindow.IsVisible)
|
||||
{
|
||||
_transparentOverlayWindow.Hide();
|
||||
}
|
||||
|
||||
var mainWindow = GetOrCreateMainWindow(desktop, source);
|
||||
mainWindow.ShowInTaskbar = true;
|
||||
|
||||
@@ -519,6 +561,18 @@ public partial class App : Application
|
||||
}
|
||||
}, DispatcherPriority.Send);
|
||||
}
|
||||
|
||||
private void EnsureTransparentOverlayWindow()
|
||||
{
|
||||
if (_transparentOverlayWindow is null)
|
||||
{
|
||||
_transparentOverlayWindow = new TransparentOverlayWindow();
|
||||
_transparentOverlayWindow.RestoreMainWindowRequested += (s, e) =>
|
||||
{
|
||||
RestoreOrCreateMainWindow(showSingleInstanceNotice: false, source: "TransparentOverlay");
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
internal void PrepareForShutdown(bool isRestart, string source)
|
||||
{
|
||||
@@ -869,7 +923,7 @@ public partial class App : Application
|
||||
SetDesktopShellState(DesktopShellState.ForegroundDesktop, "MainWindowRestored");
|
||||
}
|
||||
|
||||
private void HideMainWindowToTray(MainWindow mainWindow, string source)
|
||||
internal void HideMainWindowToTray(MainWindow mainWindow, string source)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -879,6 +933,15 @@ public partial class App : Application
|
||||
AppLogger.Info(
|
||||
"DesktopShell",
|
||||
$"Main window hidden to tray. Source='{source}'; WindowState='{mainWindow.WindowState}'.");
|
||||
|
||||
// 检查三指滑动功能是否启用
|
||||
var appSnapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
|
||||
if (appSnapshot.EnableThreeFingerSwipe)
|
||||
{
|
||||
// 显示透明覆盖层窗口
|
||||
EnsureTransparentOverlayWindow();
|
||||
_transparentOverlayWindow?.Show();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 345 B |
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
234
LanMountainDesktop/Controls/SmoothBorder.cs
Normal file
234
LanMountainDesktop/Controls/SmoothBorder.cs
Normal file
@@ -0,0 +1,234 @@
|
||||
using System;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Platform;
|
||||
|
||||
namespace LanMountainDesktop.Controls;
|
||||
|
||||
/// <summary>
|
||||
/// A Decorator that renders a border with continuous "Squircle" corners (super-ellipse).
|
||||
/// Ported and adapted from SeiWoLauncherPro for Avalonia 11.
|
||||
/// </summary>
|
||||
public class SmoothBorder : Decorator
|
||||
{
|
||||
public static readonly StyledProperty<IBrush?> BackgroundProperty =
|
||||
Border.BackgroundProperty.AddOwner<SmoothBorder>();
|
||||
|
||||
public static readonly StyledProperty<IBrush?> BorderBrushProperty =
|
||||
Border.BorderBrushProperty.AddOwner<SmoothBorder>();
|
||||
|
||||
public static readonly StyledProperty<Thickness> BorderThicknessProperty =
|
||||
Border.BorderThicknessProperty.AddOwner<SmoothBorder>();
|
||||
|
||||
public static readonly StyledProperty<CornerRadius> CornerRadiusProperty =
|
||||
Border.CornerRadiusProperty.AddOwner<SmoothBorder>();
|
||||
|
||||
public static readonly StyledProperty<double> SmoothnessProperty =
|
||||
AvaloniaProperty.Register<SmoothBorder, double>(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<SmoothBorder>(BackgroundProperty, BorderBrushProperty, BorderThicknessProperty, CornerRadiusProperty, SmoothnessProperty);
|
||||
AffectsMeasure<SmoothBorder>(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]);
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
"tray.tooltip": "LanMountainDesktop",
|
||||
"tray.menu.show_desktop": "Open Desktop",
|
||||
"tray.menu.settings": "Settings",
|
||||
"tray.menu.component_library": "Component Library",
|
||||
"tray.menu.component_library": "Fused Desktop Settings",
|
||||
"tray.menu.restart": "Restart App",
|
||||
"tray.menu.exit": "Exit App",
|
||||
"button.back_to_windows": "Back to Windows",
|
||||
@@ -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",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"tray.tooltip": "LanMountainDesktop",
|
||||
"tray.menu.show_desktop": "打开桌面",
|
||||
"tray.menu.settings": "设置",
|
||||
"tray.menu.component_library": "独立组件库",
|
||||
"tray.menu.component_library": "融合桌面设置",
|
||||
"tray.menu.restart": "重启应用",
|
||||
"tray.menu.exit": "退出应用",
|
||||
"button.back_to_windows": "回到Windows",
|
||||
@@ -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": "加载中...",
|
||||
|
||||
@@ -116,6 +116,8 @@ public sealed class AppSettingsSnapshot
|
||||
|
||||
public int StatusBarCustomSpacingPercent { get; set; } = 12;
|
||||
|
||||
public bool EnableThreeFingerSwipe { get; set; } = false;
|
||||
|
||||
public List<string> DisabledPluginIds { get; set; } = [];
|
||||
|
||||
#region Study Settings
|
||||
@@ -150,6 +152,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();
|
||||
|
||||
96
LanMountainDesktop/Models/FusedDesktopLayoutSnapshot.cs
Normal file
96
LanMountainDesktop/Models/FusedDesktopLayoutSnapshot.cs
Normal file
@@ -0,0 +1,96 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace LanMountainDesktop.Models;
|
||||
|
||||
/// <summary>
|
||||
/// 融合桌面组件放置快照 - 用于在系统桌面(负一屏)上放置组件
|
||||
/// </summary>
|
||||
public sealed class FusedDesktopComponentPlacementSnapshot
|
||||
{
|
||||
/// <summary>
|
||||
/// 放置实例ID(唯一标识)
|
||||
/// </summary>
|
||||
public string PlacementId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 组件类型ID
|
||||
/// </summary>
|
||||
public string ComponentId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// X 坐标(像素,相对于屏幕左上角)
|
||||
/// </summary>
|
||||
public double X { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Y 坐标(像素,相对于屏幕左上角)
|
||||
/// </summary>
|
||||
public double Y { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 宽度(像素)
|
||||
/// </summary>
|
||||
public double Width { get; set; } = 200;
|
||||
|
||||
/// <summary>
|
||||
/// 高度(像素)
|
||||
/// </summary>
|
||||
public double Height { get; set; } = 200;
|
||||
|
||||
/// <summary>
|
||||
/// Z-Index(用于控制组件层叠顺序)
|
||||
/// </summary>
|
||||
public int ZIndex { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否锁定位置(锁定后不可拖动)
|
||||
/// </summary>
|
||||
public bool IsLocked { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 创建深拷贝
|
||||
/// </summary>
|
||||
public FusedDesktopComponentPlacementSnapshot Clone()
|
||||
{
|
||||
return new FusedDesktopComponentPlacementSnapshot
|
||||
{
|
||||
PlacementId = PlacementId,
|
||||
ComponentId = ComponentId,
|
||||
X = X,
|
||||
Y = Y,
|
||||
Width = Width,
|
||||
Height = Height,
|
||||
ZIndex = ZIndex,
|
||||
IsLocked = IsLocked
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 融合桌面布局快照 - 包含所有在系统桌面上显示的组件
|
||||
/// </summary>
|
||||
public sealed class FusedDesktopLayoutSnapshot
|
||||
{
|
||||
/// <summary>
|
||||
/// 是否启用融合桌面功能
|
||||
/// </summary>
|
||||
public bool IsEnabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 组件放置列表
|
||||
/// </summary>
|
||||
public List<FusedDesktopComponentPlacementSnapshot> ComponentPlacements { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 创建深拷贝
|
||||
/// </summary>
|
||||
public FusedDesktopLayoutSnapshot Clone()
|
||||
{
|
||||
return new FusedDesktopLayoutSnapshot
|
||||
{
|
||||
IsEnabled = IsEnabled,
|
||||
ComponentPlacements = [.. ComponentPlacements.ConvertAll(p => p.Clone())]
|
||||
};
|
||||
}
|
||||
}
|
||||
173
LanMountainDesktop/Services/FusedDesktopLayoutService.cs
Normal file
173
LanMountainDesktop/Services/FusedDesktopLayoutService.cs
Normal file
@@ -0,0 +1,173 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using LanMountainDesktop.Models;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 融合桌面布局存储服务接口
|
||||
/// </summary>
|
||||
public interface IFusedDesktopLayoutService
|
||||
{
|
||||
/// <summary>
|
||||
/// 加载融合桌面布局
|
||||
/// </summary>
|
||||
FusedDesktopLayoutSnapshot Load();
|
||||
|
||||
/// <summary>
|
||||
/// 保存融合桌面布局
|
||||
/// </summary>
|
||||
void Save(FusedDesktopLayoutSnapshot snapshot);
|
||||
|
||||
/// <summary>
|
||||
/// 添加组件放置
|
||||
/// </summary>
|
||||
void AddComponentPlacement(FusedDesktopComponentPlacementSnapshot placement);
|
||||
|
||||
/// <summary>
|
||||
/// 更新组件放置
|
||||
/// </summary>
|
||||
void UpdateComponentPlacement(FusedDesktopComponentPlacementSnapshot placement);
|
||||
|
||||
/// <summary>
|
||||
/// 移除组件放置
|
||||
/// </summary>
|
||||
void RemoveComponentPlacement(string placementId);
|
||||
|
||||
/// <summary>
|
||||
/// 清除所有组件放置
|
||||
/// </summary>
|
||||
void ClearAllPlacements();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 融合桌面布局存储服务实现
|
||||
/// </summary>
|
||||
internal sealed class FusedDesktopLayoutService : IFusedDesktopLayoutService
|
||||
{
|
||||
private static readonly string ConfigFilePath = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
"LanMountainDesktop",
|
||||
"fused_desktop_layout.json");
|
||||
|
||||
private readonly object _lock = new();
|
||||
private FusedDesktopLayoutSnapshot? _cachedSnapshot;
|
||||
|
||||
public FusedDesktopLayoutSnapshot Load()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_cachedSnapshot is not null)
|
||||
{
|
||||
return _cachedSnapshot.Clone();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (!File.Exists(ConfigFilePath))
|
||||
{
|
||||
_cachedSnapshot = new FusedDesktopLayoutSnapshot();
|
||||
return _cachedSnapshot.Clone();
|
||||
}
|
||||
|
||||
var json = File.ReadAllText(ConfigFilePath);
|
||||
var snapshot = JsonSerializer.Deserialize<FusedDesktopLayoutSnapshot>(json, JsonOptions);
|
||||
_cachedSnapshot = snapshot ?? new FusedDesktopLayoutSnapshot();
|
||||
return _cachedSnapshot.Clone();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("FusedDesktopLayout", "Failed to load fused desktop layout.", ex);
|
||||
_cachedSnapshot = new FusedDesktopLayoutSnapshot();
|
||||
return _cachedSnapshot.Clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Save(FusedDesktopLayoutSnapshot snapshot)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
try
|
||||
{
|
||||
_cachedSnapshot = snapshot.Clone();
|
||||
|
||||
var directory = Path.GetDirectoryName(ConfigFilePath);
|
||||
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
var json = JsonSerializer.Serialize(snapshot, JsonOptions);
|
||||
File.WriteAllText(ConfigFilePath, json);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("FusedDesktopLayout", "Failed to save fused desktop layout.", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void AddComponentPlacement(FusedDesktopComponentPlacementSnapshot placement)
|
||||
{
|
||||
var snapshot = Load();
|
||||
snapshot.ComponentPlacements.Add(placement);
|
||||
Save(snapshot);
|
||||
}
|
||||
|
||||
public void UpdateComponentPlacement(FusedDesktopComponentPlacementSnapshot placement)
|
||||
{
|
||||
var snapshot = Load();
|
||||
var index = snapshot.ComponentPlacements.FindIndex(p => p.PlacementId == placement.PlacementId);
|
||||
if (index >= 0)
|
||||
{
|
||||
snapshot.ComponentPlacements[index] = placement;
|
||||
Save(snapshot);
|
||||
}
|
||||
}
|
||||
|
||||
public void RemoveComponentPlacement(string placementId)
|
||||
{
|
||||
var snapshot = Load();
|
||||
snapshot.ComponentPlacements.RemoveAll(p => p.PlacementId == placementId);
|
||||
Save(snapshot);
|
||||
}
|
||||
|
||||
public void ClearAllPlacements()
|
||||
{
|
||||
var snapshot = Load();
|
||||
snapshot.ComponentPlacements.Clear();
|
||||
Save(snapshot);
|
||||
}
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 融合桌面布局服务提供者
|
||||
/// </summary>
|
||||
public static class FusedDesktopLayoutServiceProvider
|
||||
{
|
||||
private static IFusedDesktopLayoutService? _instance;
|
||||
private static readonly object _lock = new();
|
||||
|
||||
public static IFusedDesktopLayoutService GetOrCreate()
|
||||
{
|
||||
if (_instance is not null)
|
||||
{
|
||||
return _instance;
|
||||
}
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
_instance ??= new FusedDesktopLayoutService();
|
||||
return _instance;
|
||||
}
|
||||
}
|
||||
}
|
||||
503
LanMountainDesktop/Services/NotificationService.cs
Normal file
503
LanMountainDesktop/Services/NotificationService.cs
Normal file
@@ -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);
|
||||
|
||||
/// <summary>
|
||||
/// Indicates whether this notification should be shown as a dialog (center position)
|
||||
/// or as a toast notification (other positions)
|
||||
/// </summary>
|
||||
public bool IsDialogNotification => Position == NotificationPosition.Center;
|
||||
}
|
||||
|
||||
public interface INotificationService
|
||||
{
|
||||
void Show(NotificationContent content);
|
||||
|
||||
Task<ContentDialogResult> 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<ContentDialogResult> ShowDialogInfoAsync(string title, string? message = null,
|
||||
string? primaryButtonText = "确定", string? closeButtonText = "取消");
|
||||
|
||||
Task<ContentDialogResult> ShowDialogSuccessAsync(string title, string? message = null,
|
||||
string? primaryButtonText = "确定", string? closeButtonText = "取消");
|
||||
|
||||
Task<ContentDialogResult> ShowDialogWarningAsync(string title, string? message = null,
|
||||
string? primaryButtonText = "确定", string? closeButtonText = "取消");
|
||||
|
||||
Task<ContentDialogResult> 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<ContentDialogResult> ShowDialogAsync(NotificationContent content)
|
||||
{
|
||||
// 检查通知开关是否启用
|
||||
if (!IsNotificationEnabled())
|
||||
{
|
||||
return ContentDialogResult.None; // 通知已禁用,不显示
|
||||
}
|
||||
|
||||
return await Dispatcher.UIThread.InvokeAsync(() => ShowDialogCoreAsync(content));
|
||||
}
|
||||
|
||||
private async Task<ContentDialogResult> 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<AppSettingsSnapshot>(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<ContentDialogResult> 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<ContentDialogResult> 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<ContentDialogResult> 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<ContentDialogResult> 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<NotificationPosition, List<NotificationWindow>> _windowsByPosition = new();
|
||||
private const double Margin = 12;
|
||||
private const double Spacing = 6;
|
||||
|
||||
private NotificationWindowManager()
|
||||
{
|
||||
foreach (var position in Enum.GetValues<NotificationPosition>())
|
||||
{
|
||||
_windowsByPosition[position] = new List<NotificationWindow>();
|
||||
}
|
||||
}
|
||||
|
||||
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<AppSettingsSnapshot>(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
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -320,9 +320,17 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService
|
||||
return false;
|
||||
}
|
||||
|
||||
// 如果找不到报告,尝试重新从数据库加载
|
||||
if (!TryFindSessionReportLocked(sessionId, out var report))
|
||||
{
|
||||
return false;
|
||||
// 重新加载历史数据
|
||||
RestoreSessionHistoryFromDatabaseLocked();
|
||||
|
||||
// 再次尝试查找
|
||||
if (!TryFindSessionReportLocked(sessionId, out report))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
_selectedSessionReportId = report.SessionId;
|
||||
@@ -356,9 +364,17 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService
|
||||
{
|
||||
ThrowIfDisposedLocked();
|
||||
var index = FindSessionReportIndexLocked(sessionId);
|
||||
|
||||
// 如果找不到报告,尝试重新从数据库加载
|
||||
if (index < 0)
|
||||
{
|
||||
return false;
|
||||
RestoreSessionHistoryFromDatabaseLocked();
|
||||
index = FindSessionReportIndexLocked(sessionId);
|
||||
|
||||
if (index < 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
var updated = _sessionHistory[index] with { Label = normalizedLabel };
|
||||
@@ -389,9 +405,17 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService
|
||||
{
|
||||
ThrowIfDisposedLocked();
|
||||
var index = FindSessionReportIndexLocked(sessionId);
|
||||
|
||||
// 如果找不到报告,尝试重新从数据库加载
|
||||
if (index < 0)
|
||||
{
|
||||
return false;
|
||||
RestoreSessionHistoryFromDatabaseLocked();
|
||||
index = FindSessionReportIndexLocked(sessionId);
|
||||
|
||||
if (index < 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
var removed = _sessionHistory[index];
|
||||
|
||||
@@ -17,10 +17,17 @@ public sealed class StudyDataStore
|
||||
};
|
||||
|
||||
private readonly AppDatabaseService _databaseService;
|
||||
private readonly Action<string>? _logger;
|
||||
|
||||
public StudyDataStore(AppDatabaseService? databaseService = null)
|
||||
public StudyDataStore(AppDatabaseService? databaseService = null, Action<string>? logger = null)
|
||||
{
|
||||
_databaseService = databaseService ?? AppDatabaseServiceFactory.CreateDefault();
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
private void Log(string message)
|
||||
{
|
||||
_logger?.Invoke($"[StudyDataStore] {message}");
|
||||
}
|
||||
|
||||
public IReadOnlyList<StudySessionReport> LoadSessionReports(int limit = 120)
|
||||
@@ -61,17 +68,25 @@ public sealed class StudyDataStore
|
||||
continue;
|
||||
}
|
||||
|
||||
var report = JsonSerializer.Deserialize<StudySessionReport>(json, JsonOptions);
|
||||
if (report is not null)
|
||||
try
|
||||
{
|
||||
reports.Add(report);
|
||||
var report = JsonSerializer.Deserialize<StudySessionReport>(json, JsonOptions);
|
||||
if (report is not null)
|
||||
{
|
||||
reports.Add(report);
|
||||
}
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
Log($"Failed to deserialize session report: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
return reports;
|
||||
}
|
||||
catch
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log($"Failed to load session reports: {ex.Message}");
|
||||
return Array.Empty<StudySessionReport>();
|
||||
}
|
||||
}
|
||||
@@ -99,20 +114,28 @@ public sealed class StudyDataStore
|
||||
var json = command.ExecuteScalar() as string;
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
Log($"Session report not found for id: {sessionId}");
|
||||
return false;
|
||||
}
|
||||
|
||||
var parsed = JsonSerializer.Deserialize<StudySessionReport>(json, JsonOptions);
|
||||
if (parsed is null)
|
||||
{
|
||||
Log($"Failed to deserialize session report for id: {sessionId}");
|
||||
return false;
|
||||
}
|
||||
|
||||
report = parsed;
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
catch (JsonException ex)
|
||||
{
|
||||
Log($"JSON deserialization error for session {sessionId}: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log($"Failed to get session report {sessionId}: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -138,9 +161,9 @@ public sealed class StudyDataStore
|
||||
|
||||
transaction.Commit();
|
||||
}
|
||||
catch
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Keep runtime resilient when persistence is unavailable.
|
||||
Log($"Failed to replace session reports: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,8 +185,9 @@ public sealed class StudyDataStore
|
||||
? null
|
||||
: value.Trim();
|
||||
}
|
||||
catch
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log($"Failed to get selected session report id: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -192,9 +216,9 @@ public sealed class StudyDataStore
|
||||
upsertCommand.Parameters.AddWithValue("$value", sessionId.Trim());
|
||||
upsertCommand.ExecuteNonQuery();
|
||||
}
|
||||
catch
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Keep runtime resilient when persistence is unavailable.
|
||||
Log($"Failed to set selected session report id: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -271,9 +295,9 @@ public sealed class StudyDataStore
|
||||
|
||||
transaction.Commit();
|
||||
}
|
||||
catch
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Keep runtime resilient when persistence is unavailable.
|
||||
Log($"Failed to append noise slice: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -365,8 +389,9 @@ public sealed class StudyDataStore
|
||||
|
||||
return entries;
|
||||
}
|
||||
catch
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log($"Failed to load noise slice timeline: {ex.Message}");
|
||||
return Array.Empty<NoiseSliceTimelineEntry>();
|
||||
}
|
||||
}
|
||||
@@ -389,9 +414,9 @@ public sealed class StudyDataStore
|
||||
|
||||
command.ExecuteNonQuery();
|
||||
}
|
||||
catch
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Keep runtime resilient when persistence is unavailable.
|
||||
Log($"Failed to clear noise slice timeline: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
350
LanMountainDesktop/Services/WindowPassthroughService.cs
Normal file
350
LanMountainDesktop/Services/WindowPassthroughService.cs
Normal file
@@ -0,0 +1,350 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.Runtime.InteropServices;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 窗口置底服务接口
|
||||
/// </summary>
|
||||
public interface IWindowBottomMostService
|
||||
{
|
||||
void SetupBottomMost(Window window);
|
||||
void SendToBottom(Window window);
|
||||
bool IsBottomMostSupported { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 区域级穿透服务接口 - 使用 WM_NCHITTEST 实现
|
||||
/// </summary>
|
||||
public interface IRegionPassthroughService
|
||||
{
|
||||
/// <summary>
|
||||
/// 设置窗口的可交互区域
|
||||
/// </summary>
|
||||
void SetInteractiveRegions(Window window, IReadOnlyList<Rect> interactiveRegions);
|
||||
|
||||
/// <summary>
|
||||
/// 清除所有可交互区域
|
||||
/// </summary>
|
||||
void ClearInteractiveRegions(Window window);
|
||||
|
||||
/// <summary>
|
||||
/// 获取当前平台是否支持区域级穿透
|
||||
/// </summary>
|
||||
bool IsRegionPassthroughSupported { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 窗口置底服务工厂
|
||||
/// </summary>
|
||||
public static class WindowBottomMostServiceFactory
|
||||
{
|
||||
private static IWindowBottomMostService? _instance;
|
||||
private static readonly object _lock = new();
|
||||
|
||||
public static IWindowBottomMostService GetOrCreate()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _instance ??= OperatingSystem.IsWindows()
|
||||
? new WindowsWindowBottomMostService()
|
||||
: new NullWindowBottomMostService();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 区域级穿透服务工厂
|
||||
/// </summary>
|
||||
public static class RegionPassthroughServiceFactory
|
||||
{
|
||||
private static IRegionPassthroughService? _instance;
|
||||
private static readonly object _lock = new();
|
||||
|
||||
public static IRegionPassthroughService GetOrCreate()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _instance ??= OperatingSystem.IsWindows()
|
||||
? new WindowsRegionPassthroughService()
|
||||
: new NullRegionPassthroughService();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Windows 平台窗口置底服务
|
||||
/// </summary>
|
||||
internal sealed class WindowsWindowBottomMostService : IWindowBottomMostService
|
||||
{
|
||||
private const int GWL_EXSTYLE = -20;
|
||||
private const int GWL_HWNDPARENT = -8;
|
||||
private const int GWLP_WNDPROC = -4;
|
||||
private const int WS_EX_TOOLWINDOW = 0x00000080;
|
||||
private const int WS_EX_APPWINDOW = 0x00040000;
|
||||
private const int WS_EX_NOACTIVATE = 0x08000000;
|
||||
private const int WS_EX_LAYERED = 0x00080000;
|
||||
private const uint SWP_NOSIZE = 0x0001;
|
||||
private const uint SWP_NOMOVE = 0x0002;
|
||||
private const uint SWP_NOACTIVATE = 0x0010;
|
||||
private const int WM_WINDOWPOSCHANGING = 0x0046;
|
||||
private const int WM_NCHITTEST = 0x0084;
|
||||
private const int HTTRANSPARENT = -1;
|
||||
private const int HTCLIENT = 1;
|
||||
|
||||
private static readonly IntPtr HWND_BOTTOM = new(1);
|
||||
private static readonly Dictionary<IntPtr, bool> _bottomMostWindows = new();
|
||||
private static readonly Dictionary<IntPtr, IntPtr> _originalWndProcs = new();
|
||||
private static readonly Dictionary<IntPtr, List<Rect>> _interactiveRegions = new();
|
||||
private static readonly object _staticLock = new();
|
||||
|
||||
public bool IsBottomMostSupported => true;
|
||||
|
||||
public void SetupBottomMost(Window window)
|
||||
{
|
||||
if (!OperatingSystem.IsWindows()) return;
|
||||
|
||||
window.Opened += (s, e) =>
|
||||
{
|
||||
var handle = GetWindowHandle(window);
|
||||
if (handle == IntPtr.Zero) return;
|
||||
|
||||
// 设置扩展样式
|
||||
var exStyle = GetWindowLong(handle, GWL_EXSTYLE);
|
||||
exStyle = (exStyle | WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE | WS_EX_LAYERED) & ~WS_EX_APPWINDOW;
|
||||
SetWindowLong(handle, GWL_EXSTYLE, exStyle);
|
||||
|
||||
// 设置为桌面子窗口
|
||||
SetAsDesktopChild(handle);
|
||||
|
||||
// 注册置底状态
|
||||
lock (_staticLock)
|
||||
{
|
||||
_bottomMostWindows[handle] = true;
|
||||
_interactiveRegions[handle] = [];
|
||||
}
|
||||
|
||||
// 注入消息钩子
|
||||
InstallMessageHook(handle);
|
||||
|
||||
// 初始置底
|
||||
SendToBottomInternal(handle);
|
||||
|
||||
AppLogger.Info("WindowBottomMost", $"Window setup as bottom-most: {handle}");
|
||||
};
|
||||
|
||||
window.Closed += (s, e) =>
|
||||
{
|
||||
var handle = GetWindowHandle(window);
|
||||
if (handle != IntPtr.Zero)
|
||||
{
|
||||
lock (_staticLock)
|
||||
{
|
||||
_bottomMostWindows.Remove(handle);
|
||||
_originalWndProcs.Remove(handle);
|
||||
_interactiveRegions.Remove(handle);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public void SendToBottom(Window window)
|
||||
{
|
||||
var handle = GetWindowHandle(window);
|
||||
if (handle != IntPtr.Zero) SendToBottomInternal(handle);
|
||||
}
|
||||
|
||||
private static IntPtr GetWindowHandle(Window window)
|
||||
{
|
||||
try { return window.TryGetPlatformHandle()?.Handle ?? IntPtr.Zero; }
|
||||
catch { return IntPtr.Zero; }
|
||||
}
|
||||
|
||||
private static void SendToBottomInternal(IntPtr handle)
|
||||
{
|
||||
SetWindowPos(handle, HWND_BOTTOM, 0, 0, 0, 0, SWP_NOSIZE | SWP_NOMOVE | SWP_NOACTIVATE);
|
||||
}
|
||||
|
||||
private static void SetAsDesktopChild(IntPtr handle)
|
||||
{
|
||||
var windowHandles = new ArrayList();
|
||||
EnumWindows(EnumWindowsCallback, windowHandles);
|
||||
foreach (IntPtr h in windowHandles)
|
||||
{
|
||||
var hDefView = FindWindowEx(h, IntPtr.Zero, "SHELLDLL_DefView", null);
|
||||
if (hDefView != IntPtr.Zero)
|
||||
{
|
||||
SetWindowLong(handle, GWL_HWNDPARENT, hDefView.ToInt32());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool EnumWindowsCallback(IntPtr handle, ArrayList handles)
|
||||
{
|
||||
handles.Add(handle);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void InstallMessageHook(IntPtr handle)
|
||||
{
|
||||
var originalWndProc = GetWindowLongPtr(handle, GWLP_WNDPROC);
|
||||
if (originalWndProc == IntPtr.Zero) return;
|
||||
|
||||
lock (_staticLock)
|
||||
{
|
||||
_originalWndProcs[handle] = originalWndProc;
|
||||
}
|
||||
|
||||
SetWindowLongPtr(handle, GWLP_WNDPROC, Marshal.GetFunctionPointerForDelegate<WndProcDelegate>(SubclassWndProc));
|
||||
}
|
||||
|
||||
private static IntPtr SubclassWndProc(IntPtr hWnd, int msg, IntPtr wParam, IntPtr lParam)
|
||||
{
|
||||
// 处理 WM_WINDOWPOSCHANGING - 保持置底
|
||||
if (msg == WM_WINDOWPOSCHANGING)
|
||||
{
|
||||
lock (_staticLock)
|
||||
{
|
||||
if (_bottomMostWindows.TryGetValue(hWnd, out var isBottomMost) && isBottomMost)
|
||||
{
|
||||
SendToBottomInternal(hWnd);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 处理 WM_NCHITTEST - 区域级穿透
|
||||
if (msg == WM_NCHITTEST)
|
||||
{
|
||||
// 从 lParam 解析坐标(低字为 X,高字为 Y)
|
||||
var x = (short)(wParam.ToInt32() & 0xFFFF);
|
||||
var y = (short)((wParam.ToInt32() >> 16) & 0xFFFF);
|
||||
var point = new Point(x, y);
|
||||
|
||||
lock (_staticLock)
|
||||
{
|
||||
if (_interactiveRegions.TryGetValue(hWnd, out var regions))
|
||||
{
|
||||
foreach (var region in regions)
|
||||
{
|
||||
if (region.Contains(point))
|
||||
{
|
||||
// 在可交互区域内,返回 HTCLIENT
|
||||
return (IntPtr)HTCLIENT;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 不在可交互区域内,返回 HTTRANSPARENT 让事件穿透
|
||||
return (IntPtr)HTTRANSPARENT;
|
||||
}
|
||||
|
||||
// 调用原始窗口过程
|
||||
IntPtr originalWndProc;
|
||||
lock (_staticLock)
|
||||
{
|
||||
if (!_originalWndProcs.TryGetValue(hWnd, out originalWndProc))
|
||||
{
|
||||
return DefWindowProc(hWnd, msg, wParam, lParam);
|
||||
}
|
||||
}
|
||||
|
||||
return CallWindowProc(originalWndProc, hWnd, msg, wParam, lParam);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 设置窗口的可交互区域(供 WindowsRegionPassthroughService 调用)
|
||||
/// </summary>
|
||||
internal static void SetInteractiveRegionsInternal(IntPtr handle, List<Rect> regions)
|
||||
{
|
||||
lock (_staticLock)
|
||||
{
|
||||
_interactiveRegions[handle] = regions;
|
||||
}
|
||||
}
|
||||
|
||||
private delegate IntPtr WndProcDelegate(IntPtr hWnd, int msg, IntPtr wParam, IntPtr lParam);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
private static extern int GetWindowLong(IntPtr hWnd, int nIndex);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern int SetWindowLong(IntPtr hWnd, int nIndex, int dwNewLong);
|
||||
|
||||
[DllImport("user32.dll", EntryPoint = "GetWindowLongPtr")]
|
||||
private static extern IntPtr GetWindowLongPtr(IntPtr hWnd, int nIndex);
|
||||
|
||||
[DllImport("user32.dll", EntryPoint = "SetWindowLongPtr")]
|
||||
private static extern IntPtr SetWindowLongPtr(IntPtr hWnd, int nIndex, IntPtr dwNewLong);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter, int x, int y, int cx, int cy, uint flags);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
private static extern IntPtr FindWindowEx(IntPtr hParent, IntPtr hChildAfter, string? lpszClass, string? lpszWindow);
|
||||
|
||||
[DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool EnumWindows(EnumWindowsProc lpEnumFunc, ArrayList lParam);
|
||||
|
||||
private delegate bool EnumWindowsProc(IntPtr handle, ArrayList handles);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern IntPtr CallWindowProc(IntPtr lpPrevWndFunc, IntPtr hWnd, int Msg, IntPtr wParam, IntPtr lParam);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern IntPtr DefWindowProc(IntPtr hWnd, int uMsg, IntPtr wParam, IntPtr lParam);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Windows 平台区域级穿透服务 - 使用 WM_NCHITTEST
|
||||
/// </summary>
|
||||
internal sealed class WindowsRegionPassthroughService : IRegionPassthroughService
|
||||
{
|
||||
public bool IsRegionPassthroughSupported => true;
|
||||
|
||||
public void SetInteractiveRegions(Window window, IReadOnlyList<Rect> interactiveRegions)
|
||||
{
|
||||
var handle = GetWindowHandle(window);
|
||||
if (handle == IntPtr.Zero) return;
|
||||
|
||||
WindowsWindowBottomMostService.SetInteractiveRegionsInternal(handle, new List<Rect>(interactiveRegions));
|
||||
AppLogger.Info("RegionPassthrough", $"Set {interactiveRegions.Count} interactive regions.");
|
||||
}
|
||||
|
||||
public void ClearInteractiveRegions(Window window)
|
||||
{
|
||||
var handle = GetWindowHandle(window);
|
||||
if (handle == IntPtr.Zero) return;
|
||||
|
||||
WindowsWindowBottomMostService.SetInteractiveRegionsInternal(handle, []);
|
||||
}
|
||||
|
||||
private static IntPtr GetWindowHandle(Window window)
|
||||
{
|
||||
try { return window.TryGetPlatformHandle()?.Handle ?? IntPtr.Zero; }
|
||||
catch { return IntPtr.Zero; }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 空实现
|
||||
/// </summary>
|
||||
internal sealed class NullWindowBottomMostService : IWindowBottomMostService
|
||||
{
|
||||
public bool IsBottomMostSupported => false;
|
||||
public void SetupBottomMost(Window window) { }
|
||||
public void SendToBottom(Window window) { }
|
||||
}
|
||||
|
||||
internal sealed class NullRegionPassthroughService : IRegionPassthroughService
|
||||
{
|
||||
public bool IsRegionPassthroughSupported => false;
|
||||
public void SetInteractiveRegions(Window window, IReadOnlyList<Rect> interactiveRegions) { }
|
||||
public void ClearInteractiveRegions(Window window) { }
|
||||
}
|
||||
@@ -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<AppSettingsSnapshot>(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<AppSettingsSnapshot>(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<SelectionOption> 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<SelectionOption> 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<SelectionOption> 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<SelectionOption> Positions { get; }
|
||||
public ObservableCollection<SelectionOption> Durations { get; }
|
||||
public ObservableCollection<SelectionOption> TestPositions { get; }
|
||||
public ObservableCollection<SelectionOption> 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<NotificationPosition>(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<NotificationSeverity>(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);
|
||||
}
|
||||
}
|
||||
38
LanMountainDesktop/ViewModels/NotificationViewModel.cs
Normal file
38
LanMountainDesktop/ViewModels/NotificationViewModel.cs
Normal file
@@ -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"
|
||||
};
|
||||
}
|
||||
@@ -164,14 +164,18 @@ public sealed class TimeZoneOption
|
||||
public string Label { get; }
|
||||
}
|
||||
|
||||
public sealed partial class GeneralSettingsPageViewModel : ViewModelBase
|
||||
{
|
||||
private readonly ISettingsFacadeService _settingsFacade;
|
||||
private readonly TimeZoneService _timeZoneService;
|
||||
private readonly LocalizationService _localizationService = new();
|
||||
private readonly string _startupRenderMode;
|
||||
private string _languageCode;
|
||||
private bool _isInitializing;
|
||||
public sealed partial class GeneralSettingsPageViewModel : ViewModelBase, IDisposable
|
||||
{
|
||||
private readonly ISettingsFacadeService _settingsFacade;
|
||||
private readonly TimeZoneService _timeZoneService;
|
||||
private readonly LocalizationService _localizationService = new();
|
||||
private readonly string _startupRenderMode;
|
||||
private string _languageCode;
|
||||
private bool _isInitializing;
|
||||
private bool _disposed;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _enableThreeFingerSwipe;
|
||||
|
||||
public GeneralSettingsPageViewModel(ISettingsFacadeService settingsFacade)
|
||||
{
|
||||
@@ -200,9 +204,65 @@ public sealed partial class GeneralSettingsPageViewModel : ViewModelBase
|
||||
SelectedRenderMode = RenderModes.FirstOrDefault(option =>
|
||||
string.Equals(option.Value, normalizedRenderMode, StringComparison.OrdinalIgnoreCase))
|
||||
?? RenderModes[0];
|
||||
EnableThreeFingerSwipe = appSnapshot.EnableThreeFingerSwipe;
|
||||
_isInitializing = false;
|
||||
|
||||
RefreshPreview();
|
||||
|
||||
// 监听设置变更,防止被意外重置
|
||||
_settingsFacade.Settings.Changed += OnSettingsChanged;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_settingsFacade.Settings.Changed -= OnSettingsChanged;
|
||||
_disposed = true;
|
||||
}
|
||||
|
||||
private void OnSettingsChanged(object? sender, SettingsChangedEvent e)
|
||||
{
|
||||
if (e.Scope != SettingsScope.App)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var changedKeys = e.ChangedKeys?.ToArray();
|
||||
if (changedKeys is null || changedKeys.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果是其他设置变更,重新加载我们的设置
|
||||
_isInitializing = true;
|
||||
try
|
||||
{
|
||||
var appSnapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
|
||||
EnableThreeFingerSwipe = appSnapshot.EnableThreeFingerSwipe;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isInitializing = false;
|
||||
}
|
||||
}
|
||||
|
||||
partial void OnEnableThreeFingerSwipeChanged(bool value)
|
||||
{
|
||||
if (_isInitializing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var appSnapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
|
||||
appSnapshot.EnableThreeFingerSwipe = value;
|
||||
_settingsFacade.Settings.SaveSnapshot(
|
||||
SettingsScope.App,
|
||||
appSnapshot,
|
||||
changedKeys: [nameof(AppSettingsSnapshot.EnableThreeFingerSwipe)]);
|
||||
}
|
||||
|
||||
public event Action? RestartRequested;
|
||||
@@ -2328,11 +2388,12 @@ 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)
|
||||
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);
|
||||
|
||||
RefreshLocalizedText();
|
||||
@@ -2361,6 +2422,21 @@ public sealed partial class StudySettingsPageViewModel : ViewModelBase
|
||||
}
|
||||
}
|
||||
|
||||
private void SaveMasterSwitch()
|
||||
{
|
||||
try
|
||||
{
|
||||
var appSnapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
|
||||
appSnapshot.StudyEnabled = StudyEnabled;
|
||||
_settingsFacade.Settings.SaveSnapshot(SettingsScope.App, appSnapshot,
|
||||
changedKeys: [nameof(AppSettingsSnapshot.StudyEnabled)]);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// 静默处理错误,避免影响用户体验
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Properties - Noise Monitoring
|
||||
@@ -2400,6 +2476,13 @@ 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)
|
||||
@@ -2410,6 +2493,13 @@ public sealed partial class StudySettingsPageViewModel : ViewModelBase
|
||||
|
||||
partial void OnSamplingRateMsChanged(int value)
|
||||
{
|
||||
// 输入验证:限制在合理范围内
|
||||
if (value < 20 || value > 200)
|
||||
{
|
||||
SamplingRateMs = Math.Clamp(value, 20, 200);
|
||||
return;
|
||||
}
|
||||
|
||||
UpdateSamplingRateText();
|
||||
if (!_isInitializing)
|
||||
{
|
||||
@@ -2427,6 +2517,24 @@ public sealed partial class StudySettingsPageViewModel : ViewModelBase
|
||||
NoiseSensitivityValueText = $"{NoiseSensitivityDbfs:F0} dBFS";
|
||||
}
|
||||
|
||||
private void SaveNoiseSettings()
|
||||
{
|
||||
try
|
||||
{
|
||||
var appSnapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(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,6 +2613,13 @@ 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)
|
||||
{
|
||||
@@ -2514,6 +2629,13 @@ public sealed partial class StudySettingsPageViewModel : ViewModelBase
|
||||
|
||||
partial void OnBreakDurationMinutesChanged(int value)
|
||||
{
|
||||
// 输入验证
|
||||
if (value < 1 || value > 30)
|
||||
{
|
||||
BreakDurationMinutes = Math.Clamp(value, 1, 30);
|
||||
return;
|
||||
}
|
||||
|
||||
UpdateBreakDurationText();
|
||||
if (!_isInitializing)
|
||||
{
|
||||
@@ -2523,6 +2645,13 @@ public sealed partial class StudySettingsPageViewModel : ViewModelBase
|
||||
|
||||
partial void OnLongBreakDurationMinutesChanged(int value)
|
||||
{
|
||||
// 输入验证
|
||||
if (value < 5 || value > 60)
|
||||
{
|
||||
LongBreakDurationMinutes = Math.Clamp(value, 5, 60);
|
||||
return;
|
||||
}
|
||||
|
||||
UpdateLongBreakDurationText();
|
||||
if (!_isInitializing)
|
||||
{
|
||||
@@ -2532,6 +2661,13 @@ public sealed partial class StudySettingsPageViewModel : ViewModelBase
|
||||
|
||||
partial void OnSessionsBeforeLongBreakChanged(int value)
|
||||
{
|
||||
// 输入验证
|
||||
if (value < 2 || value > 8)
|
||||
{
|
||||
SessionsBeforeLongBreak = Math.Clamp(value, 2, 8);
|
||||
return;
|
||||
}
|
||||
|
||||
UpdateSessionsBeforeLongBreakText();
|
||||
if (!_isInitializing)
|
||||
{
|
||||
@@ -2557,22 +2693,53 @@ public sealed partial class StudySettingsPageViewModel : ViewModelBase
|
||||
|
||||
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 SaveTimerSettings()
|
||||
{
|
||||
try
|
||||
{
|
||||
var appSnapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(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
|
||||
@@ -2613,12 +2780,36 @@ public sealed partial class StudySettingsPageViewModel : ViewModelBase
|
||||
|
||||
partial void OnMaxInterruptsPerMinuteChanged(int value)
|
||||
{
|
||||
// 输入验证
|
||||
if (value < 3 || value > 20)
|
||||
{
|
||||
MaxInterruptsPerMinute = Math.Clamp(value, 3, 20);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_isInitializing)
|
||||
{
|
||||
SaveAlertSettings();
|
||||
}
|
||||
}
|
||||
|
||||
private void SaveAlertSettings()
|
||||
{
|
||||
try
|
||||
{
|
||||
var appSnapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
|
||||
appSnapshot.StudyNoiseAlertEnabled = NoiseAlertEnabled;
|
||||
appSnapshot.StudyMaxInterruptsPerMinute = MaxInterruptsPerMinute;
|
||||
_settingsFacade.Settings.SaveSnapshot(SettingsScope.App, appSnapshot,
|
||||
changedKeys: [nameof(AppSettingsSnapshot.StudyNoiseAlertEnabled), nameof(AppSettingsSnapshot.StudyMaxInterruptsPerMinute)]);
|
||||
UpdateStudyAnalyticsConfig();
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// 静默处理错误
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Properties - Display
|
||||
@@ -2672,6 +2863,13 @@ public sealed partial class StudySettingsPageViewModel : ViewModelBase
|
||||
|
||||
partial void OnBaselineDbChanged(double value)
|
||||
{
|
||||
// 输入验证
|
||||
if (value < 20 || value > 90)
|
||||
{
|
||||
BaselineDb = Math.Clamp(value, 20, 90);
|
||||
return;
|
||||
}
|
||||
|
||||
UpdateBaselineDbText();
|
||||
if (!_isInitializing)
|
||||
{
|
||||
@@ -2681,6 +2879,13 @@ public sealed partial class StudySettingsPageViewModel : ViewModelBase
|
||||
|
||||
partial void OnAvgWindowSecChanged(int value)
|
||||
{
|
||||
// 输入验证
|
||||
if (value < 1 || value > 8)
|
||||
{
|
||||
AvgWindowSec = Math.Clamp(value, 1, 8);
|
||||
return;
|
||||
}
|
||||
|
||||
UpdateAvgWindowSecText();
|
||||
if (!_isInitializing)
|
||||
{
|
||||
@@ -2700,106 +2905,86 @@ public sealed partial class StudySettingsPageViewModel : ViewModelBase
|
||||
|
||||
private void UpdateAvgWindowSecText()
|
||||
{
|
||||
AvgWindowSecValueText = $"{AvgWindowSec} 秒";
|
||||
}
|
||||
|
||||
private void LoadSettings()
|
||||
{
|
||||
var appSnapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
|
||||
|
||||
// Master switch
|
||||
StudyEnabled = appSnapshot.StudyEnabled;
|
||||
|
||||
// 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;
|
||||
|
||||
// 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;
|
||||
|
||||
UpdateSamplingRateText();
|
||||
UpdateSensitivityText();
|
||||
UpdateThresholdText();
|
||||
UpdateFocusDurationText();
|
||||
UpdateBreakDurationText();
|
||||
UpdateLongBreakDurationText();
|
||||
UpdateSessionsBeforeLongBreakText();
|
||||
UpdateBaselineDbText();
|
||||
UpdateAvgWindowSecText();
|
||||
}
|
||||
|
||||
private void SaveMasterSwitch()
|
||||
{
|
||||
var appSnapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
|
||||
appSnapshot.StudyEnabled = StudyEnabled;
|
||||
_settingsFacade.Settings.SaveSnapshot(SettingsScope.App, appSnapshot,
|
||||
changedKeys: [nameof(AppSettingsSnapshot.StudyEnabled)]);
|
||||
}
|
||||
|
||||
private void SaveNoiseSettings()
|
||||
{
|
||||
var appSnapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(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<AppSettingsSnapshot>(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<AppSettingsSnapshot>(SettingsScope.App);
|
||||
appSnapshot.StudyNoiseAlertEnabled = NoiseAlertEnabled;
|
||||
appSnapshot.StudyMaxInterruptsPerMinute = MaxInterruptsPerMinute;
|
||||
_settingsFacade.Settings.SaveSnapshot(SettingsScope.App, appSnapshot,
|
||||
changedKeys: [nameof(AppSettingsSnapshot.StudyNoiseAlertEnabled), nameof(AppSettingsSnapshot.StudyMaxInterruptsPerMinute)]);
|
||||
UpdateStudyAnalyticsConfig();
|
||||
var unit = L("common.unit.seconds", "秒");
|
||||
AvgWindowSecValueText = $"{AvgWindowSec} {unit}";
|
||||
}
|
||||
|
||||
private void SaveDisplaySettings()
|
||||
{
|
||||
var appSnapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(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();
|
||||
try
|
||||
{
|
||||
var appSnapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(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()
|
||||
{
|
||||
try
|
||||
{
|
||||
var appSnapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
|
||||
|
||||
// Master switch - 确保正确加载保存的值
|
||||
StudyEnabled = appSnapshot.StudyEnabled;
|
||||
|
||||
// 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;
|
||||
|
||||
// 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;
|
||||
|
||||
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()
|
||||
|
||||
@@ -1,130 +0,0 @@
|
||||
using System.ComponentModel;
|
||||
using System.Runtime.CompilerServices;
|
||||
using Avalonia.Media.Imaging;
|
||||
using Avalonia.Platform;
|
||||
using FluentIcons.Common;
|
||||
|
||||
namespace LanMountainDesktop.ViewModels;
|
||||
|
||||
public sealed class SuperMiningSettingsPageViewModel : INotifyPropertyChanged
|
||||
{
|
||||
private double _hashRate = 125.6;
|
||||
private string _coinsMined = "0.08923";
|
||||
private int _poolConnections = 98;
|
||||
private double _miningProgress;
|
||||
private string _miningStatus = "正在挖矿中...";
|
||||
private bool _showAprilFoolsHint;
|
||||
private Bitmap? _qrCodeImage;
|
||||
|
||||
public event PropertyChangedEventHandler? PropertyChanged;
|
||||
|
||||
public Symbol ActionSymbol => Symbol.ArrowDownload;
|
||||
|
||||
public double HashRate
|
||||
{
|
||||
get => _hashRate;
|
||||
set
|
||||
{
|
||||
if (Math.Abs(_hashRate - value) > 0.01)
|
||||
{
|
||||
_hashRate = value;
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public string CoinsMined
|
||||
{
|
||||
get => _coinsMined;
|
||||
set
|
||||
{
|
||||
if (_coinsMined != value)
|
||||
{
|
||||
_coinsMined = value;
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public int PoolConnections
|
||||
{
|
||||
get => _poolConnections;
|
||||
set
|
||||
{
|
||||
if (_poolConnections != value)
|
||||
{
|
||||
_poolConnections = value;
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public double MiningProgress
|
||||
{
|
||||
get => _miningProgress;
|
||||
set
|
||||
{
|
||||
if (Math.Abs(_miningProgress - value) > 0.1)
|
||||
{
|
||||
_miningProgress = value;
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public string MiningStatus
|
||||
{
|
||||
get => _miningStatus;
|
||||
set
|
||||
{
|
||||
if (_miningStatus != value)
|
||||
{
|
||||
_miningStatus = value;
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public bool ShowAprilFoolsHint
|
||||
{
|
||||
get => _showAprilFoolsHint;
|
||||
set
|
||||
{
|
||||
if (_showAprilFoolsHint != value)
|
||||
{
|
||||
_showAprilFoolsHint = value;
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public Bitmap? QrCodeImage
|
||||
{
|
||||
get => _qrCodeImage;
|
||||
set
|
||||
{
|
||||
if (_qrCodeImage != value)
|
||||
{
|
||||
_qrCodeImage = value;
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void LoadQrCodeImage()
|
||||
{
|
||||
try
|
||||
{
|
||||
var assets = AssetLoader.Open(new System.Uri("avares://LanMountainDesktop/Assets/mining_qrcode.png"));
|
||||
QrCodeImage = new Bitmap(assets);
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private void OnPropertyChanged([CallerMemberName] string? propertyName = null)
|
||||
{
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
|
||||
}
|
||||
}
|
||||
@@ -44,12 +44,18 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget,
|
||||
private TimeZoneService? _timeZoneService;
|
||||
private double _currentCellSize = 48;
|
||||
private IReadOnlyList<CourseItemViewModel> _courseItems = Array.Empty<CourseItemViewModel>();
|
||||
private IReadOnlyList<CourseItemViewModel> _lastRenderedItems = Array.Empty<CourseItemViewModel>();
|
||||
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<AppSettingsSnapshot>(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<CourseItemViewModel>();
|
||||
UpdateHeader(now);
|
||||
ShowStatus(L("schedule.widget.no_source", "未读取到 ClassIsland 课表"));
|
||||
RenderScheduleItems();
|
||||
var newItems = Array.Empty<CourseItemViewModel>();
|
||||
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<CourseItemViewModel>();
|
||||
UpdateHeader(now);
|
||||
ShowStatus(L("schedule.widget.no_class_today", "今天没有课程"));
|
||||
RenderScheduleItems();
|
||||
var newItems = Array.Empty<CourseItemViewModel>();
|
||||
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<CourseItemViewModel>();
|
||||
UpdateHeader(now);
|
||||
ShowStatus(L("schedule.widget.layout_missing", "课表时间布局缺失"));
|
||||
RenderScheduleItems();
|
||||
var newItems = Array.Empty<CourseItemViewModel>();
|
||||
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<CourseItemViewModel> oldItems, IReadOnlyList<CourseItemViewModel> 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<CourseItemViewModel> 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)
|
||||
|
||||
@@ -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
|
||||
{
|
||||
// 静默处理通知发送失败
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,12 +187,6 @@ public partial class StudyScoreOverviewWidget : UserControl, IDesktopComponentWi
|
||||
return;
|
||||
}
|
||||
|
||||
if (snapshot.DataMode == StudyDataMode.SessionReport && snapshot.LastSessionReport is not null)
|
||||
{
|
||||
ApplySessionReportMode(snapshot, panelColor);
|
||||
return;
|
||||
}
|
||||
|
||||
ApplyRealtimeMode(snapshot, realtimeScore, panelColor);
|
||||
}
|
||||
|
||||
|
||||
@@ -169,15 +169,6 @@ public partial class StudySessionControlWidget : UserControl, IDesktopComponentW
|
||||
private void OnActionButtonClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||
{
|
||||
var snapshot = _studyAnalyticsService.GetSnapshot();
|
||||
var isReportViewing = snapshot.DataMode == StudyDataMode.SessionReport && snapshot.LastSessionReport is not null;
|
||||
if (isReportViewing)
|
||||
{
|
||||
_studyAnalyticsService.ClearLastSessionReport();
|
||||
_transientMessage = null;
|
||||
RefreshVisual();
|
||||
return;
|
||||
}
|
||||
|
||||
var isRunning = snapshot.Session.State == StudySessionRuntimeState.Running;
|
||||
|
||||
var success = isRunning
|
||||
@@ -221,17 +212,6 @@ public partial class StudySessionControlWidget : UserControl, IDesktopComponentW
|
||||
_transientMessage = null;
|
||||
}
|
||||
|
||||
var isReportViewing = snapshot.DataMode == StudyDataMode.SessionReport && snapshot.LastSessionReport is not null;
|
||||
if (isReportViewing)
|
||||
{
|
||||
PrimaryTextBlock.Text = L("study.session_control.report_preview", "Preview Report");
|
||||
SecondaryTextBlock.Text = _transientMessage ?? L("study.session_control.report_confirm_hint", "Tap right button to confirm");
|
||||
ActionIcon.Kind = MaterialIconKind.Check;
|
||||
ApplyActionBadgeStyle(panelColor, Color.Parse("#FF34D399"));
|
||||
ApplyTransientWarningTintIfNeeded(panelColor);
|
||||
return;
|
||||
}
|
||||
|
||||
var isRunning = snapshot.Session.State == StudySessionRuntimeState.Running;
|
||||
if (isRunning)
|
||||
{
|
||||
|
||||
@@ -386,24 +386,39 @@ public partial class StudySessionHistoryWidget : UserControl, IDesktopComponentW
|
||||
{
|
||||
CloseDialog();
|
||||
|
||||
_loadingSessionId = sessionId;
|
||||
SetTransientStatus(L("study.session_history.loading", "Loading data..."), 4);
|
||||
if (_currentSnapshot is not null)
|
||||
{
|
||||
RenderSnapshot(_currentSnapshot);
|
||||
}
|
||||
|
||||
if (_studyAnalyticsService.SelectSessionReport(sessionId))
|
||||
// 直接从服务获取报告数据
|
||||
var snapshot = _studyAnalyticsService.GetSnapshot();
|
||||
var entry = FindHistoryEntry(snapshot.SessionHistory, sessionId);
|
||||
|
||||
if (entry is null)
|
||||
{
|
||||
SetTransientStatus(L("study.session_history.select_failed", "Unable to find session"));
|
||||
return;
|
||||
}
|
||||
|
||||
_loadingSessionId = null;
|
||||
SetTransientStatus(L("study.session_history.select_failed", "Unable to switch session"));
|
||||
if (_currentSnapshot is not null)
|
||||
// 加载完整的报告数据
|
||||
if (!_studyAnalyticsService.SelectSessionReport(sessionId))
|
||||
{
|
||||
RenderSnapshot(_currentSnapshot);
|
||||
SetTransientStatus(L("study.session_history.select_failed", "Unable to load session"));
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取完整报告
|
||||
snapshot = _studyAnalyticsService.GetSnapshot();
|
||||
var report = snapshot.LastSessionReport;
|
||||
|
||||
if (report is null)
|
||||
{
|
||||
SetTransientStatus(L("study.session_history.select_failed", "Unable to load session data"));
|
||||
return;
|
||||
}
|
||||
|
||||
// 打开报告详情窗口
|
||||
var window = new StudySessionReportWindow(report);
|
||||
window.Show();
|
||||
|
||||
// 清除选中状态,不保持联动模式
|
||||
_studyAnalyticsService.ClearLastSessionReport();
|
||||
}
|
||||
|
||||
private void ShowRenameDialog(string sessionId, string label)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
@@ -47,6 +48,10 @@ public partial class ZhiJiaoHubWidget : UserControl,
|
||||
private bool _autoRefreshEnabled = true;
|
||||
private int _pendingImageIndex = 0;
|
||||
|
||||
private string _lastLoadedSource = string.Empty;
|
||||
private bool _lastLoadedAutoRefreshEnabled = true;
|
||||
private int _lastLoadedRefreshIntervalMinutes = 30;
|
||||
|
||||
private IReadOnlyList<ZhiJiaoHubHybridImageItem> _images = [];
|
||||
private int _currentImageIndex = 0;
|
||||
|
||||
@@ -59,6 +64,8 @@ public partial class ZhiJiaoHubWidget : UserControl,
|
||||
private double _dragOffset;
|
||||
private int _lastSwipeDirection = 0;
|
||||
private bool _isInErrorState;
|
||||
private DateTime _lastClickTime = DateTime.MinValue;
|
||||
private const int DoubleClickThresholdMs = 300;
|
||||
|
||||
private static readonly HttpClient ImageHttpClient = new(new HttpClientHandler
|
||||
{
|
||||
@@ -147,11 +154,39 @@ public partial class ZhiJiaoHubWidget : UserControl,
|
||||
_placementId = context.PlacementId ?? string.Empty;
|
||||
_componentSettingsAccessor = context.ComponentSettingsAccessor;
|
||||
|
||||
LoadSettings();
|
||||
|
||||
if (_isAttached)
|
||||
try
|
||||
{
|
||||
_ = InitializeAsync();
|
||||
var snapshot = _componentSettingsAccessor?.LoadSnapshot<ComponentSettingsSnapshot>();
|
||||
LoadSettings();
|
||||
|
||||
if (_isAttached)
|
||||
{
|
||||
if (snapshot is not null && NeedsReinitialization(snapshot))
|
||||
{
|
||||
_ = InitializeAsync();
|
||||
}
|
||||
else if (_images.Count > 0)
|
||||
{
|
||||
_pendingImageIndex = snapshot?.ZhiJiaoHubCurrentImageIndex ?? 0;
|
||||
_currentImageIndex = Math.Clamp(_pendingImageIndex, 0, Math.Max(0, _images.Count - 1));
|
||||
_pendingImageIndex = 0;
|
||||
if (TryDisplayCachedImage(_currentImageIndex))
|
||||
{
|
||||
UpdateIndicators();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_ = InitializeAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (_isAttached)
|
||||
{
|
||||
_ = InitializeAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,11 +198,28 @@ public partial class ZhiJiaoHubWidget : UserControl,
|
||||
|
||||
public void RefreshFromSettings()
|
||||
{
|
||||
LoadSettings();
|
||||
UpdateTimers();
|
||||
if (_isAttached)
|
||||
try
|
||||
{
|
||||
var snapshot = _componentSettingsAccessor?.LoadSnapshot<ComponentSettingsSnapshot>();
|
||||
if (snapshot is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
LoadSettings();
|
||||
UpdateTimers();
|
||||
|
||||
if (_isAttached && NeedsReinitialization(snapshot))
|
||||
{
|
||||
_ = InitializeAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
_pendingImageIndex = snapshot.ZhiJiaoHubCurrentImageIndex;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
_ = InitializeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,6 +244,24 @@ public partial class ZhiJiaoHubWidget : UserControl,
|
||||
}
|
||||
}
|
||||
|
||||
private bool NeedsReinitialization(ComponentSettingsSnapshot snapshot)
|
||||
{
|
||||
var newSource = ZhiJiaoHubSources.Normalize(snapshot.ZhiJiaoHubSource);
|
||||
var newAutoRefreshEnabled = snapshot.ZhiJiaoHubAutoRefreshEnabled;
|
||||
var newRefreshIntervalMinutes = Math.Clamp(snapshot.ZhiJiaoHubAutoRefreshIntervalMinutes, 5, 1440);
|
||||
|
||||
return newSource != _lastLoadedSource ||
|
||||
newAutoRefreshEnabled != _lastLoadedAutoRefreshEnabled ||
|
||||
newRefreshIntervalMinutes != _lastLoadedRefreshIntervalMinutes;
|
||||
}
|
||||
|
||||
private void UpdateLastLoadedSettings(ComponentSettingsSnapshot snapshot)
|
||||
{
|
||||
_lastLoadedSource = ZhiJiaoHubSources.Normalize(snapshot.ZhiJiaoHubSource);
|
||||
_lastLoadedAutoRefreshEnabled = snapshot.ZhiJiaoHubAutoRefreshEnabled;
|
||||
_lastLoadedRefreshIntervalMinutes = Math.Clamp(snapshot.ZhiJiaoHubAutoRefreshIntervalMinutes, 5, 1440);
|
||||
}
|
||||
|
||||
private void SaveCurrentImageIndex()
|
||||
{
|
||||
try
|
||||
@@ -259,6 +329,12 @@ public partial class ZhiJiaoHubWidget : UserControl,
|
||||
_currentImageIndex = Math.Clamp(_pendingImageIndex, 0, Math.Max(0, _images.Count - 1));
|
||||
_pendingImageIndex = 0;
|
||||
|
||||
var snapshot = _componentSettingsAccessor?.LoadSnapshot<ComponentSettingsSnapshot>();
|
||||
if (snapshot is not null)
|
||||
{
|
||||
UpdateLastLoadedSettings(snapshot);
|
||||
}
|
||||
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
UpdateIndicators();
|
||||
@@ -606,6 +682,19 @@ public partial class ZhiJiaoHubWidget : UserControl,
|
||||
return;
|
||||
}
|
||||
|
||||
var currentTime = DateTime.Now;
|
||||
var timeSinceLastClick = (currentTime - _lastClickTime).TotalMilliseconds;
|
||||
|
||||
if (timeSinceLastClick < DoubleClickThresholdMs)
|
||||
{
|
||||
_lastClickTime = DateTime.MinValue;
|
||||
_ = OpenCurrentImageAsync();
|
||||
e.Handled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
_lastClickTime = currentTime;
|
||||
|
||||
if (_images.Count <= 1)
|
||||
{
|
||||
return;
|
||||
@@ -616,6 +705,81 @@ public partial class ZhiJiaoHubWidget : UserControl,
|
||||
_dragOffset = 0;
|
||||
}
|
||||
|
||||
private async Task OpenCurrentImageAsync()
|
||||
{
|
||||
if (_images.Count == 0 || _currentImageIndex < 0 || _currentImageIndex >= _images.Count)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var imageItem = _images[_currentImageIndex];
|
||||
|
||||
try
|
||||
{
|
||||
string? filePath = null;
|
||||
|
||||
if (imageItem.IsCached && !string.IsNullOrEmpty(imageItem.LocalPath) && File.Exists(imageItem.LocalPath))
|
||||
{
|
||||
filePath = imageItem.LocalPath;
|
||||
}
|
||||
else
|
||||
{
|
||||
filePath = await DownloadImageToTempAsync(imageItem);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(filePath) && File.Exists(filePath))
|
||||
{
|
||||
try
|
||||
{
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = filePath,
|
||||
UseShellExecute = true
|
||||
};
|
||||
Process.Start(startInfo);
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string?> DownloadImageToTempAsync(ZhiJiaoHubHybridImageItem imageItem)
|
||||
{
|
||||
try
|
||||
{
|
||||
var imageUrl = imageItem.RemoteUrl;
|
||||
if (string.Equals(_mirrorSource, ZhiJiaoHubMirrorSources.GhProxy, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
imageUrl = ZhiJiaoHubMirrorSources.GhProxyBaseUrl.TrimEnd('/') + "/" + imageItem.RemoteUrl;
|
||||
}
|
||||
|
||||
using var response = await ImageHttpClient.GetAsync(imageUrl);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var fileExtension = Path.GetExtension(new Uri(imageUrl).AbsolutePath);
|
||||
if (string.IsNullOrEmpty(fileExtension))
|
||||
{
|
||||
fileExtension = ".jpg";
|
||||
}
|
||||
|
||||
var tempPath = Path.Combine(Path.GetTempPath(), $"LanMountain_ZhiJiaoHub_{Guid.NewGuid():N}{fileExtension}");
|
||||
await using var fileStream = File.OpenWrite(tempPath);
|
||||
var contentStream = await response.Content.ReadAsStreamAsync();
|
||||
await contentStream.CopyToAsync(fileStream);
|
||||
|
||||
return tempPath;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnPointerMoved(object? sender, PointerEventArgs e)
|
||||
{
|
||||
if (!_isDragging || _images.Count <= 1)
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
x:Class="LanMountainDesktop.Views.FusedDesktopComponentLibraryControl"
|
||||
Width="400" Height="500">
|
||||
<!--
|
||||
融合桌面组件库 - 专门用于添加组件到系统桌面(负一屏)
|
||||
注意:此窗口只能添加组件到融合桌面,不能添加到阑山桌面
|
||||
-->
|
||||
<Grid RowDefinitions="Auto,*">
|
||||
<!-- 标题栏 -->
|
||||
<Border Background="{DynamicResource SystemControlBackgroundChromeMediumBrush}"
|
||||
Padding="16,12">
|
||||
<StackPanel>
|
||||
<TextBlock Text="融合桌面组件"
|
||||
FontWeight="SemiBold"
|
||||
FontSize="16" />
|
||||
<TextBlock Text="选择组件添加到系统桌面"
|
||||
Opacity="0.7"
|
||||
FontSize="12"
|
||||
Margin="0,4,0,0" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- 组件列表 -->
|
||||
<ScrollViewer Grid.Row="1"
|
||||
Padding="12">
|
||||
<WrapPanel x:Name="ComponentPanel" Orientation="Horizontal" />
|
||||
</ScrollViewer>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,83 @@
|
||||
using System;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Media;
|
||||
using LanMountainDesktop.ComponentSystem;
|
||||
using LanMountainDesktop.Services;
|
||||
|
||||
namespace LanMountainDesktop.Views;
|
||||
|
||||
/// <summary>
|
||||
/// 融合桌面组件库控件 - 专门用于添加组件到系统桌面(负一屏)
|
||||
/// </summary>
|
||||
public partial class FusedDesktopComponentLibraryControl : UserControl
|
||||
{
|
||||
/// <summary>
|
||||
/// 添加组件到融合桌面事件
|
||||
/// </summary>
|
||||
public event EventHandler<string>? AddComponentRequested;
|
||||
|
||||
public FusedDesktopComponentLibraryControl()
|
||||
{
|
||||
InitializeComponent();
|
||||
LoadComponents();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 加载可用组件列表
|
||||
/// </summary>
|
||||
private void LoadComponents()
|
||||
{
|
||||
var registry = ComponentRegistry.CreateDefault();
|
||||
|
||||
foreach (var definition in registry.GetAll())
|
||||
{
|
||||
if (!definition.AllowDesktopPlacement)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var button = new Button
|
||||
{
|
||||
Width = 100,
|
||||
Height = 100,
|
||||
Margin = new Thickness(4),
|
||||
Padding = new Thickness(8),
|
||||
CornerRadius = new CornerRadius(12),
|
||||
HorizontalAlignment = HorizontalAlignment.Left,
|
||||
VerticalAlignment = VerticalAlignment.Top,
|
||||
Tag = definition.Id
|
||||
};
|
||||
|
||||
var textBlock = new TextBlock
|
||||
{
|
||||
Text = definition.DisplayName,
|
||||
FontSize = 11,
|
||||
TextAlignment = TextAlignment.Center,
|
||||
TextTrimming = TextTrimming.CharacterEllipsis,
|
||||
MaxLines = 2,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
VerticalAlignment = VerticalAlignment.Center
|
||||
};
|
||||
|
||||
button.Content = textBlock;
|
||||
button.Click += OnAddComponentClick;
|
||||
|
||||
ComponentPanel.Children.Add(button);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 添加组件按钮点击
|
||||
/// </summary>
|
||||
private void OnAddComponentClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is Button button && button.Tag is string componentId)
|
||||
{
|
||||
AddComponentRequested?.Invoke(this, componentId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:controls="using:LanMountainDesktop.Views"
|
||||
x:Class="LanMountainDesktop.Views.FusedDesktopComponentLibraryWindow"
|
||||
Width="420" Height="560"
|
||||
MinWidth="380" MinHeight="400"
|
||||
WindowStartupLocation="CenterScreen"
|
||||
CanResize="True"
|
||||
Title="融合桌面组件">
|
||||
<Grid RowDefinitions="Auto,*">
|
||||
<!-- 标题栏 -->
|
||||
<Border Background="{DynamicResource SystemControlBackgroundChromeMediumBrush}"
|
||||
BorderBrush="{DynamicResource SystemControlForegroundBaseMediumLowBrush}"
|
||||
BorderThickness="0,0,0,1"
|
||||
Padding="16,12">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel>
|
||||
<TextBlock Text="融合桌面设置"
|
||||
FontWeight="SemiBold"
|
||||
FontSize="18" />
|
||||
<TextBlock Text="选择组件添加到系统桌面(负一屏)"
|
||||
Opacity="0.7"
|
||||
FontSize="12"
|
||||
Margin="0,4,0,0" />
|
||||
</StackPanel>
|
||||
<Button Grid.Column="1"
|
||||
VerticalAlignment="Center"
|
||||
CornerRadius="20"
|
||||
Padding="8"
|
||||
Click="OnCloseClick">
|
||||
<TextBlock Text="✕"
|
||||
FontSize="16" />
|
||||
</Button>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- 组件库控件 -->
|
||||
<controls:FusedDesktopComponentLibraryControl x:Name="LibraryControl"
|
||||
Grid.Row="1" />
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -0,0 +1,61 @@
|
||||
using System;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using LanMountainDesktop.Services;
|
||||
|
||||
namespace LanMountainDesktop.Views;
|
||||
|
||||
/// <summary>
|
||||
/// 融合桌面组件库窗口 - 专门用于添加组件到系统桌面(负一屏)
|
||||
///
|
||||
/// 注意:此窗口只能添加组件到融合桌面,不能添加到阑山桌面
|
||||
/// </summary>
|
||||
public partial class FusedDesktopComponentLibraryWindow : Window
|
||||
{
|
||||
private readonly IFusedDesktopLayoutService _layoutService = FusedDesktopLayoutServiceProvider.GetOrCreate();
|
||||
private TransparentOverlayWindow? _overlayWindow;
|
||||
|
||||
public FusedDesktopComponentLibraryWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
LibraryControl.AddComponentRequested += OnAddComponentRequested;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 设置透明覆盖层窗口引用
|
||||
/// </summary>
|
||||
public void SetOverlayWindow(TransparentOverlayWindow overlayWindow)
|
||||
{
|
||||
_overlayWindow = overlayWindow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 添加组件请求处理
|
||||
/// </summary>
|
||||
private void OnAddComponentRequested(object? sender, string componentId)
|
||||
{
|
||||
if (_overlayWindow is null)
|
||||
{
|
||||
AppLogger.Warn("FusedDesktopLibrary", "Overlay window is not set.");
|
||||
return;
|
||||
}
|
||||
|
||||
// 在屏幕中央添加组件
|
||||
var screenBounds = _overlayWindow.Bounds;
|
||||
var x = screenBounds.Width / 2 - 100; // 居中
|
||||
var y = screenBounds.Height / 2 - 100;
|
||||
|
||||
_overlayWindow.AddComponent(componentId, x, y, 200, 200);
|
||||
|
||||
AppLogger.Info("FusedDesktopLibrary", $"Added component {componentId} to fused desktop.");
|
||||
|
||||
// 关闭窗口
|
||||
Close();
|
||||
}
|
||||
|
||||
private void OnCloseClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
Close();
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
@@ -16,6 +16,7 @@ using Avalonia.Threading;
|
||||
using Avalonia.VisualTree;
|
||||
using FluentAvalonia.UI.Controls;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.Theme;
|
||||
|
||||
@@ -73,6 +74,10 @@ public partial class MainWindow
|
||||
private int? _desktopPageContextSettlingTargetIndex;
|
||||
private int _desktopPageContextSettleRevision;
|
||||
|
||||
// 三指滑动/右键拖动相关
|
||||
private bool _isThreeFingerOrRightDragSwipeActive;
|
||||
private readonly HashSet<int> _activePointerIds = [];
|
||||
|
||||
private int LauncherSurfaceIndex => Math.Max(MinDesktopPageCount, _desktopPageCount);
|
||||
|
||||
private int TotalSurfaceCount => LauncherSurfaceIndex + 1;
|
||||
@@ -515,6 +520,49 @@ public partial class MainWindow
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查三指滑动功能是否启用
|
||||
var appSnapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
|
||||
var isThreeFingerSwipeEnabled = appSnapshot.EnableThreeFingerSwipe;
|
||||
|
||||
var currentPoint = e.GetCurrentPoint(DesktopPagesViewport);
|
||||
var pointerId = e.Pointer?.Id ?? 0;
|
||||
var isRightButtonPressed = currentPoint.Properties.IsRightButtonPressed;
|
||||
var isLeftButtonPressed = currentPoint.Properties.IsLeftButtonPressed;
|
||||
|
||||
// 处理三指滑动/右键拖动模式
|
||||
if (isThreeFingerSwipeEnabled)
|
||||
{
|
||||
// 跟踪活跃指针
|
||||
if (isLeftButtonPressed || isRightButtonPressed)
|
||||
{
|
||||
_activePointerIds.Add(pointerId);
|
||||
}
|
||||
|
||||
// 判断是否是三指滑动或右键拖动
|
||||
var isThreeFinger = _activePointerIds.Count >= 3;
|
||||
var isRightDrag = isRightButtonPressed;
|
||||
|
||||
if (isThreeFinger || isRightDrag)
|
||||
{
|
||||
// 三指/右键拖动模式:跳过所有组件交互检查,直接开始滑动
|
||||
ClearDesktopPageContextSettle(refreshContext: false);
|
||||
_isThreeFingerOrRightDragSwipeActive = true;
|
||||
_isDesktopSwipeActive = true;
|
||||
_isDesktopSwipeDirectionLocked = false;
|
||||
_desktopSwipeStartPoint = pointerInViewport;
|
||||
_desktopSwipeCurrentPoint = _desktopSwipeStartPoint;
|
||||
_desktopSwipeLastPoint = _desktopSwipeStartPoint;
|
||||
_desktopSwipeVelocityX = 0;
|
||||
_desktopSwipeLastTimestamp = Stopwatch.GetTimestamp();
|
||||
_desktopSwipeBaseOffset = -_currentDesktopSurfaceIndex * _desktopSurfacePageWidth;
|
||||
|
||||
// 标记事件已处理,防止组件响应
|
||||
e.Handled = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 原有单指滑动逻辑
|
||||
if (IsInteractivePointerSource(e.Source))
|
||||
{
|
||||
return;
|
||||
@@ -525,7 +573,7 @@ public partial class MainWindow
|
||||
return;
|
||||
}
|
||||
|
||||
if (!e.GetCurrentPoint(DesktopPagesViewport).Properties.IsLeftButtonPressed)
|
||||
if (!isLeftButtonPressed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -776,6 +824,10 @@ public partial class MainWindow
|
||||
|
||||
private void OnDesktopPagesPointerReleased(object? sender, PointerReleasedEventArgs e)
|
||||
{
|
||||
// 清理活跃指针
|
||||
var pointerId = e.Pointer?.Id ?? 0;
|
||||
_activePointerIds.Remove(pointerId);
|
||||
|
||||
if (EndDesktopSwipeInteraction(e.Pointer))
|
||||
{
|
||||
e.Handled = true;
|
||||
@@ -784,6 +836,10 @@ public partial class MainWindow
|
||||
|
||||
private void OnDesktopPagesPointerCaptureLost(object? sender, PointerCaptureLostEventArgs e)
|
||||
{
|
||||
// 清理活跃指针
|
||||
var pointerId = e.Pointer?.Id ?? 0;
|
||||
_activePointerIds.Remove(pointerId);
|
||||
|
||||
EndDesktopSwipeInteraction(e.Pointer);
|
||||
}
|
||||
|
||||
@@ -802,6 +858,8 @@ public partial class MainWindow
|
||||
|
||||
_isDesktopSwipeActive = false;
|
||||
_isDesktopSwipeDirectionLocked = false;
|
||||
_isThreeFingerOrRightDragSwipeActive = false;
|
||||
_activePointerIds.Clear();
|
||||
_desktopSwipeVelocityX = 0;
|
||||
_desktopSwipeLastTimestamp = 0;
|
||||
if (wasDirectionLocked)
|
||||
@@ -819,8 +877,12 @@ public partial class MainWindow
|
||||
}
|
||||
|
||||
var wasDirectionLocked = _isDesktopSwipeDirectionLocked;
|
||||
var wasThreeFingerOrRightDrag = _isThreeFingerOrRightDragSwipeActive;
|
||||
_isDesktopSwipeActive = false;
|
||||
_isDesktopSwipeDirectionLocked = false;
|
||||
_isThreeFingerOrRightDragSwipeActive = false;
|
||||
_activePointerIds.Clear();
|
||||
|
||||
if (pointer?.Captured == DesktopPagesViewport)
|
||||
{
|
||||
pointer.Capture(null);
|
||||
@@ -849,6 +911,23 @@ public partial class MainWindow
|
||||
var hasDistanceIntent = absDeltaX >= distanceThreshold && absDeltaX > absDeltaY * 1.05;
|
||||
var hasVelocityIntent = Math.Abs(_desktopSwipeVelocityX) >= velocityThreshold;
|
||||
|
||||
// 检查:三指/右键拖动 && 在第一页 && 向右滑动
|
||||
if (wasThreeFingerOrRightDrag &&
|
||||
_currentDesktopSurfaceIndex == 0 &&
|
||||
deltaX > 0 && // 向右滑动
|
||||
(hasDistanceIntent || hasVelocityIntent))
|
||||
{
|
||||
// 最小化到 Windows 桌面
|
||||
if (Application.Current is App app)
|
||||
{
|
||||
app.HideMainWindowToTray(this, "ThreeFingerOrRightDragSwipe");
|
||||
}
|
||||
|
||||
ApplyDesktopSurfaceOffset();
|
||||
_desktopSwipeVelocityX = 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (projectedTargetIndex == _currentDesktopSurfaceIndex && (hasDistanceIntent || hasVelocityIntent))
|
||||
{
|
||||
projectedTargetIndex = Math.Clamp(
|
||||
|
||||
@@ -38,6 +38,12 @@ public partial class MainWindow
|
||||
return;
|
||||
}
|
||||
|
||||
// 组件实例范围的设置变更不应触发整个桌面重新加载(比如翻页保存图片索引)
|
||||
if (e.Scope == SettingsScope.ComponentInstance)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.Scope == SettingsScope.App && e.ChangedKeys is { Count: > 0 })
|
||||
{
|
||||
var changedKeys = e.ChangedKeys.ToArray();
|
||||
|
||||
74
LanMountainDesktop/Views/NotificationDialogWindow.axaml
Normal file
74
LanMountainDesktop/Views/NotificationDialogWindow.axaml
Normal file
@@ -0,0 +1,74 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:fi="using:FluentIcons.Avalonia"
|
||||
xmlns:vm="using:LanMountainDesktop.Views"
|
||||
x:Class="LanMountainDesktop.Views.NotificationDialogWindow"
|
||||
x:DataType="vm:NotificationDialogViewModel"
|
||||
SystemDecorations="None"
|
||||
Background="Transparent"
|
||||
ShowInTaskbar="False"
|
||||
Topmost="True"
|
||||
CanResize="False"
|
||||
SizeToContent="WidthAndHeight"
|
||||
TransparencyLevelHint="Transparent"
|
||||
ExtendClientAreaToDecorationsHint="True"
|
||||
ExtendClientAreaChromeHints="NoChrome"
|
||||
ExtendClientAreaTitleBarHeightHint="-1">
|
||||
|
||||
<Border x:Name="DialogCard"
|
||||
Background="#E8EAED"
|
||||
CornerRadius="28"
|
||||
Padding="24,20"
|
||||
MinWidth="320"
|
||||
MaxWidth="480">
|
||||
<StackPanel Spacing="16">
|
||||
<!-- Header with icon and title -->
|
||||
<Grid ColumnDefinitions="Auto,*" ColumnSpacing="12">
|
||||
<Border Grid.Column="0"
|
||||
Width="40"
|
||||
Height="40"
|
||||
CornerRadius="20"
|
||||
Background="{Binding SeverityBackground}"
|
||||
VerticalAlignment="Center">
|
||||
<fi:SymbolIcon Symbol="{Binding SeverityIcon}"
|
||||
FontSize="20"
|
||||
Foreground="White"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center" />
|
||||
</Border>
|
||||
<TextBlock Grid.Column="1"
|
||||
Text="{Binding Title}"
|
||||
FontSize="18"
|
||||
FontWeight="SemiBold"
|
||||
TextWrapping="Wrap"
|
||||
VerticalAlignment="Center"
|
||||
Foreground="{DynamicResource SystemControlForegroundBaseHighBrush}" />
|
||||
</Grid>
|
||||
|
||||
<!-- Message content -->
|
||||
<TextBlock Text="{Binding Message}"
|
||||
FontSize="14"
|
||||
TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource SystemControlForegroundBaseMediumBrush}"
|
||||
IsVisible="{Binding Message, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
||||
|
||||
<!-- Action buttons -->
|
||||
<StackPanel Orientation="Horizontal"
|
||||
Spacing="8"
|
||||
HorizontalAlignment="Right"
|
||||
IsVisible="{Binding HasButtons}">
|
||||
<Button Content="{Binding SecondaryButtonText}"
|
||||
Command="{Binding SecondaryCommand}"
|
||||
CornerRadius="20"
|
||||
Padding="20,10"
|
||||
IsVisible="{Binding SecondaryButtonText, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
||||
<Button Content="{Binding PrimaryButtonText}"
|
||||
Command="{Binding PrimaryCommand}"
|
||||
Classes="accent"
|
||||
CornerRadius="20"
|
||||
Padding="20,10"
|
||||
IsVisible="{Binding PrimaryButtonText, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Window>
|
||||
181
LanMountainDesktop/Views/NotificationDialogWindow.axaml.cs
Normal file
181
LanMountainDesktop/Views/NotificationDialogWindow.axaml.cs
Normal file
@@ -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<bool>? 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>();
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
96
LanMountainDesktop/Views/NotificationWindow.axaml
Normal file
96
LanMountainDesktop/Views/NotificationWindow.axaml
Normal file
@@ -0,0 +1,96 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:LanMountainDesktop.ViewModels"
|
||||
xmlns:fi="using:FluentIcons.Avalonia"
|
||||
xmlns:controls="using:LanMountainDesktop.Controls"
|
||||
x:Class="LanMountainDesktop.Views.NotificationWindow"
|
||||
x:DataType="vm:NotificationViewModel"
|
||||
SystemDecorations="None"
|
||||
Background="Transparent"
|
||||
ShowInTaskbar="False"
|
||||
Topmost="True"
|
||||
CanResize="False"
|
||||
SizeToContent="WidthAndHeight"
|
||||
TransparencyLevelHint="Transparent"
|
||||
ExtendClientAreaToDecorationsHint="True"
|
||||
ExtendClientAreaChromeHints="NoChrome"
|
||||
ExtendClientAreaTitleBarHeightHint="-1">
|
||||
|
||||
<Window.Styles>
|
||||
<Style Selector="Border.notification-card">
|
||||
<Setter Property="Background" Value="{DynamicResource AdaptiveGlassPanelBackgroundBrush}" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource AdaptiveGlassPanelBorderBrush}" />
|
||||
<Setter Property="BorderThickness" Value="1.2" />
|
||||
<Setter Property="CornerRadius" Value="18" />
|
||||
<Setter Property="Padding" Value="16,12" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="TextBlock.notification-title">
|
||||
<Setter Property="FontSize" Value="14" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||
<Setter Property="TextWrapping" Value="Wrap" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="TextBlock.notification-message">
|
||||
<Setter Property="FontSize" Value="13" />
|
||||
<Setter Property="FontWeight" Value="Regular" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource AdaptiveTextSecondaryBrush}" />
|
||||
<Setter Property="TextWrapping" Value="Wrap" />
|
||||
<Setter Property="MaxWidth" Value="260" />
|
||||
<Setter Property="Margin" Value="0,2,0,0" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Border.notification-severity-indicator">
|
||||
<Setter Property="Width" Value="4" />
|
||||
<Setter Property="CornerRadius" Value="2" />
|
||||
<Setter Property="Margin" Value="0,4,12,4" />
|
||||
<Setter Property="VerticalAlignment" Value="Stretch" />
|
||||
</Style>
|
||||
</Window.Styles>
|
||||
|
||||
<Border Margin="12"
|
||||
Classes="notification-card"
|
||||
x:Name="CardBorder"
|
||||
PointerPressed="OnCardPointerPressed"
|
||||
PointerEntered="OnCardPointerEntered"
|
||||
PointerExited="OnCardPointerExited"
|
||||
Cursor="Hand">
|
||||
<Grid ColumnDefinitions="Auto,Auto,*" ColumnSpacing="0">
|
||||
<Border Grid.Column="0"
|
||||
Classes="notification-severity-indicator"
|
||||
x:Name="SeverityIndicator"
|
||||
Background="#FF3B82F6" />
|
||||
|
||||
<Border Grid.Column="1"
|
||||
Width="40"
|
||||
Height="40"
|
||||
VerticalAlignment="Center"
|
||||
x:Name="IconContainer">
|
||||
<Panel>
|
||||
<Image x:Name="IconImage"
|
||||
Stretch="Uniform"
|
||||
Source="{Binding Icon}"
|
||||
IsVisible="{Binding Icon, Converter={x:Static ObjectConverters.IsNotNull}}" />
|
||||
<fi:SymbolIcon x:Name="DefaultIcon"
|
||||
FontSize="28"
|
||||
Symbol="Info"
|
||||
IsVisible="{Binding Icon, Converter={x:Static ObjectConverters.IsNull}}"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||
</Panel>
|
||||
</Border>
|
||||
|
||||
<StackPanel Grid.Column="2"
|
||||
Margin="12,0,0,0"
|
||||
VerticalAlignment="Center"
|
||||
Spacing="2">
|
||||
<TextBlock Text="{Binding Title}"
|
||||
Classes="notification-title" />
|
||||
<TextBlock Text="{Binding Message}"
|
||||
Classes="notification-message"
|
||||
IsVisible="{Binding Message, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Window>
|
||||
|
||||
240
LanMountainDesktop/Views/NotificationWindow.axaml.cs
Normal file
240
LanMountainDesktop/Views/NotificationWindow.axaml.cs
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,13 @@
|
||||
Text="{Binding BasicHeader}"
|
||||
Margin="0,0,0,4" />
|
||||
|
||||
<ui:SettingsExpander Header="启用三指滑动"
|
||||
Description="使用三根手指或鼠标右键拖动自由滑动页面,在第一页向右滑动可回到 Windows 桌面">
|
||||
<ui:SettingsExpander.Footer>
|
||||
<ToggleSwitch IsChecked="{Binding EnableThreeFingerSwipe}" />
|
||||
</ui:SettingsExpander.Footer>
|
||||
</ui:SettingsExpander>
|
||||
|
||||
<ui:SettingsExpander Header="{Binding LanguageHeader}">
|
||||
<ui:SettingsExpander.IconSource>
|
||||
<fi:SymbolIconSource Symbol="Settings" />
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:LanMountainDesktop.ViewModels"
|
||||
xmlns:controls="using:LanMountainDesktop.Controls"
|
||||
xmlns:ui="using:FluentAvalonia.UI.Controls"
|
||||
xmlns:fi="using:FluentIcons.Avalonia.Fluent"
|
||||
x:Class="LanMountainDesktop.Views.SettingsPages.NotificationSettingsPage"
|
||||
x:DataType="vm:NotificationSettingsPageViewModel">
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel Classes="settings-page-container settings-page-animated">
|
||||
|
||||
<controls:IconText Icon="Alert"
|
||||
Text="{Binding NotificationHeader}"
|
||||
Margin="0,0,0,4" />
|
||||
|
||||
<ui:SettingsExpander Header="{Binding EnableNotificationHeader}"
|
||||
Description="{Binding EnableNotificationDescription}">
|
||||
<ui:SettingsExpander.IconSource>
|
||||
<fi:SymbolIconSource Symbol="Alert" />
|
||||
</ui:SettingsExpander.IconSource>
|
||||
<ui:SettingsExpander.Footer>
|
||||
<ToggleSwitch IsChecked="{Binding IsNotificationEnabled}" />
|
||||
</ui:SettingsExpander.Footer>
|
||||
</ui:SettingsExpander>
|
||||
|
||||
<Separator Classes="settings-separator" />
|
||||
|
||||
<controls:IconText Icon="Alert"
|
||||
Text="{Binding BehaviorHeader}"
|
||||
Margin="0,0,0,4" />
|
||||
|
||||
<ui:SettingsExpander Header="{Binding HoverPauseHeader}"
|
||||
Description="{Binding HoverPauseDescription}">
|
||||
<ui:SettingsExpander.IconSource>
|
||||
<fi:SymbolIconSource Symbol="CursorHover" />
|
||||
</ui:SettingsExpander.IconSource>
|
||||
<ui:SettingsExpander.Footer>
|
||||
<ToggleSwitch IsChecked="{Binding IsHoverPauseEnabled}" />
|
||||
</ui:SettingsExpander.Footer>
|
||||
</ui:SettingsExpander>
|
||||
|
||||
<ui:SettingsExpander Header="{Binding ClickCloseHeader}"
|
||||
Description="{Binding ClickCloseDescription}">
|
||||
<ui:SettingsExpander.IconSource>
|
||||
<fi:SymbolIconSource Symbol="CursorClick" />
|
||||
</ui:SettingsExpander.IconSource>
|
||||
<ui:SettingsExpander.Footer>
|
||||
<ToggleSwitch IsChecked="{Binding IsClickCloseEnabled}" />
|
||||
</ui:SettingsExpander.Footer>
|
||||
</ui:SettingsExpander>
|
||||
|
||||
<ui:SettingsExpander Header="{Binding MaxNotificationsHeader}"
|
||||
Description="{Binding MaxNotificationsDescription}">
|
||||
<ui:SettingsExpander.IconSource>
|
||||
<fi:SymbolIconSource Symbol="NumberSymbol" />
|
||||
</ui:SettingsExpander.IconSource>
|
||||
<ui:SettingsExpander.Footer>
|
||||
<ui:NumberBox Value="{Binding MaxNotificationsPerPosition}"
|
||||
Minimum="1"
|
||||
Maximum="10"
|
||||
Width="100"
|
||||
SpinButtonPlacementMode="Inline" />
|
||||
</ui:SettingsExpander.Footer>
|
||||
</ui:SettingsExpander>
|
||||
|
||||
<Separator Classes="settings-separator" />
|
||||
|
||||
<controls:IconText Icon="Beaker"
|
||||
Text="{Binding TestHeader}"
|
||||
Margin="0,0,0,4" />
|
||||
|
||||
<ui:SettingsExpander Header="{Binding TestNotificationHeader}"
|
||||
Description="{Binding TestNotificationDescription}">
|
||||
<ui:SettingsExpander.IconSource>
|
||||
<fi:SymbolIconSource Symbol="Beaker" />
|
||||
</ui:SettingsExpander.IconSource>
|
||||
<ui:SettingsExpander.Footer>
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<ComboBox Width="120"
|
||||
ItemsSource="{Binding TestPositions}"
|
||||
SelectedItem="{Binding SelectedTestPosition}">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:SelectionOption">
|
||||
<TextBlock Text="{Binding Label}" />
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
<ComboBox Width="100"
|
||||
ItemsSource="{Binding TestSeverities}"
|
||||
SelectedItem="{Binding SelectedTestSeverity}">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:SelectionOption">
|
||||
<TextBlock Text="{Binding Label}" />
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
<ui:NumberBox Width="100"
|
||||
Minimum="1"
|
||||
Maximum="30"
|
||||
SpinButtonPlacementMode="Inline"
|
||||
Value="{Binding TestDurationSeconds}" />
|
||||
<Button Command="{Binding SendTestCommand}"
|
||||
Classes="accent">
|
||||
<fi:SymbolIcon Symbol="Send" FontSize="16" />
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</ui:SettingsExpander.Footer>
|
||||
</ui:SettingsExpander>
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</UserControl>
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -1,271 +0,0 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:LanMountainDesktop.ViewModels"
|
||||
xmlns:controls="using:LanMountainDesktop.Controls"
|
||||
xmlns:ui="using:FluentAvalonia.UI.Controls"
|
||||
xmlns:fi="using:FluentIcons.Avalonia.Fluent"
|
||||
x:Class="LanMountainDesktop.Views.SettingsPages.SuperMiningSettingsPage"
|
||||
x:DataType="vm:SuperMiningSettingsPageViewModel">
|
||||
<UserControl.Styles>
|
||||
<Style Selector="Border.mining-hero-card">
|
||||
<Setter Property="Background" Value="{DynamicResource AdaptiveSurfaceRaisedBrush}" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource AdaptiveGlassPanelBorderBrush}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="CornerRadius" Value="24" />
|
||||
<Setter Property="ClipToBounds" Value="True" />
|
||||
<Setter Property="Margin" Value="0,0,0,18" />
|
||||
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Border.mining-stats-card">
|
||||
<Setter Property="Background" Value="{DynamicResource AdaptiveSurfaceRaisedBrush}" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource AdaptiveGlassPanelBorderBrush}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="CornerRadius" Value="16" />
|
||||
<Setter Property="Padding" Value="16" />
|
||||
<Setter Property="Margin" Value="0,0,0,12" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="TextBlock.mining-title">
|
||||
<Setter Property="FontSize" Value="24" />
|
||||
<Setter Property="FontWeight" Value="Bold" />
|
||||
<Setter Property="HorizontalAlignment" Value="Center" />
|
||||
<Setter Property="Margin" Value="0,0,0,8" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="TextBlock.mining-subtitle">
|
||||
<Setter Property="FontSize" Value="14" />
|
||||
<Setter Property="Opacity" Value="0.7" />
|
||||
<Setter Property="HorizontalAlignment" Value="Center" />
|
||||
<Setter Property="TextWrapping" Value="Wrap" />
|
||||
<Setter Property="TextAlignment" Value="Center" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="TextBlock.mining-stats-value">
|
||||
<Setter Property="FontSize" Value="28" />
|
||||
<Setter Property="FontWeight" Value="Bold" />
|
||||
<Setter Property="Foreground" Value="#4CAF50" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="TextBlock.mining-stats-label">
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
<Setter Property="Opacity" Value="0.7" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Border.qr-container">
|
||||
<Setter Property="Background" Value="White" />
|
||||
<Setter Property="CornerRadius" Value="12" />
|
||||
<Setter Property="Padding" Value="16" />
|
||||
<Setter Property="HorizontalAlignment" Value="Center" />
|
||||
<Setter Property="Margin" Value="0,16,0,16" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="ProgressBar.mining-progress">
|
||||
<Setter Property="Height" Value="8" />
|
||||
<Setter Property="CornerRadius" Value="4" />
|
||||
<Setter Property="Margin" Value="0,8,0,0" />
|
||||
<Setter Property="Foreground" Value="#4CAF50" />
|
||||
</Style>
|
||||
</UserControl.Styles>
|
||||
|
||||
<ScrollViewer HorizontalScrollBarVisibility="Disabled"
|
||||
VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel Margin="0,12,0,24"
|
||||
Spacing="0">
|
||||
|
||||
<Border Classes="mining-hero-card"
|
||||
Padding="24">
|
||||
<StackPanel Spacing="16"
|
||||
HorizontalAlignment="Center">
|
||||
<Grid HorizontalAlignment="Center">
|
||||
<fi:FluentIcon Icon="Savings"
|
||||
IconVariant="Filled"
|
||||
FontSize="64"
|
||||
Foreground="#FFD700" />
|
||||
</Grid>
|
||||
|
||||
<TextBlock Classes="mining-title"
|
||||
Text="超级挖矿" />
|
||||
|
||||
<TextBlock Classes="mining-subtitle"
|
||||
Text="开启您的虚拟货币挖矿之旅,轻松获得丰厚收益!" />
|
||||
|
||||
<Grid ColumnDefinitions="*,*,*"
|
||||
Margin="0,8,0,0"
|
||||
ColumnSpacing="12">
|
||||
<Border Classes="mining-stats-card"
|
||||
Grid.Column="0">
|
||||
<StackPanel HorizontalAlignment="Center">
|
||||
<TextBlock Classes="mining-stats-value"
|
||||
Text="{Binding HashRate}"
|
||||
HorizontalAlignment="Center" />
|
||||
<TextBlock Classes="mining-stats-label"
|
||||
Text="算力 MH/s"
|
||||
HorizontalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Classes="mining-stats-card"
|
||||
Grid.Column="1">
|
||||
<StackPanel HorizontalAlignment="Center">
|
||||
<TextBlock Classes="mining-stats-value"
|
||||
Text="{Binding CoinsMined}"
|
||||
HorizontalAlignment="Center" />
|
||||
<TextBlock Classes="mining-stats-label"
|
||||
Text="已挖掘"
|
||||
HorizontalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Classes="mining-stats-card"
|
||||
Grid.Column="2">
|
||||
<StackPanel HorizontalAlignment="Center">
|
||||
<TextBlock Classes="mining-stats-value"
|
||||
Text="{Binding PoolConnections}"
|
||||
HorizontalAlignment="Center" />
|
||||
<TextBlock Classes="mining-stats-label"
|
||||
Text="矿池连接"
|
||||
HorizontalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
|
||||
<ProgressBar Classes="mining-progress"
|
||||
Value="{Binding MiningProgress}"
|
||||
Maximum="100" />
|
||||
|
||||
<TextBlock Text="{Binding MiningStatus}"
|
||||
FontSize="12"
|
||||
Opacity="0.7"
|
||||
HorizontalAlignment="Center"
|
||||
Margin="0,4,0,0" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<ui:SettingsExpander Header="绑定钱包"
|
||||
Description="扫描下方二维码绑定您的钱包地址"
|
||||
IsExpanded="True">
|
||||
<ui:SettingsExpander.IconSource>
|
||||
<fi:SymbolIconSource Symbol="QrCode" />
|
||||
</ui:SettingsExpander.IconSource>
|
||||
<ui:SettingsExpanderItem>
|
||||
<StackPanel HorizontalAlignment="Center"
|
||||
Spacing="12">
|
||||
<Border Classes="qr-container">
|
||||
<Image Source="{Binding QrCodeImage}"
|
||||
Width="200"
|
||||
Height="200"
|
||||
Stretch="Uniform" />
|
||||
</Border>
|
||||
|
||||
<TextBlock Text="请扫码绑定后开始获得虚拟币"
|
||||
FontSize="14"
|
||||
FontWeight="SemiBold"
|
||||
HorizontalAlignment="Center"
|
||||
TextWrapping="Wrap"
|
||||
TextAlignment="Center" />
|
||||
|
||||
<TextBlock Text="支持主流钱包:MetaMask、Trust Wallet、imToken等"
|
||||
FontSize="12"
|
||||
Opacity="0.6"
|
||||
HorizontalAlignment="Center"
|
||||
TextWrapping="Wrap"
|
||||
TextAlignment="Center" />
|
||||
</StackPanel>
|
||||
</ui:SettingsExpanderItem>
|
||||
</ui:SettingsExpander>
|
||||
|
||||
<ui:SettingsExpander Header="挖矿设置"
|
||||
Description="配置您的挖矿参数">
|
||||
<ui:SettingsExpander.IconSource>
|
||||
<fi:SymbolIconSource Symbol="Settings" />
|
||||
</ui:SettingsExpander.IconSource>
|
||||
<ui:SettingsExpanderItem>
|
||||
<Grid ColumnDefinitions="Auto,*"
|
||||
ColumnSpacing="16"
|
||||
RowDefinitions="Auto,Auto,Auto"
|
||||
RowSpacing="12">
|
||||
<TextBlock Grid.Row="0"
|
||||
FontWeight="SemiBold"
|
||||
Text="挖矿算法:" />
|
||||
<TextBlock Grid.Row="0"
|
||||
Grid.Column="1"
|
||||
Opacity="0.82"
|
||||
Text="Ethash (优化版)" />
|
||||
|
||||
<TextBlock Grid.Row="1"
|
||||
FontWeight="SemiBold"
|
||||
Text="矿池地址:" />
|
||||
<TextBlock Grid.Row="1"
|
||||
Grid.Column="1"
|
||||
Opacity="0.82"
|
||||
Text="stratum+tcp://mine.lanmountain.cn:3333" />
|
||||
|
||||
<TextBlock Grid.Row="2"
|
||||
FontWeight="SemiBold"
|
||||
Text="手续费率:" />
|
||||
<TextBlock Grid.Row="2"
|
||||
Grid.Column="1"
|
||||
Opacity="0.82"
|
||||
Text="0.1% (超低费率)" />
|
||||
</Grid>
|
||||
</ui:SettingsExpanderItem>
|
||||
</ui:SettingsExpander>
|
||||
|
||||
<ui:SettingsExpander Header="收益统计"
|
||||
Description="查看您的挖矿收益">
|
||||
<ui:SettingsExpander.IconSource>
|
||||
<fi:SymbolIconSource Symbol="DataTrending" />
|
||||
</ui:SettingsExpander.IconSource>
|
||||
<ui:SettingsExpanderItem>
|
||||
<Grid ColumnDefinitions="Auto,*"
|
||||
ColumnSpacing="16"
|
||||
RowDefinitions="Auto,Auto,Auto,Auto"
|
||||
RowSpacing="12">
|
||||
<TextBlock Grid.Row="0"
|
||||
FontWeight="SemiBold"
|
||||
Text="今日收益:" />
|
||||
<TextBlock Grid.Row="0"
|
||||
Grid.Column="1"
|
||||
Foreground="#4CAF50"
|
||||
FontWeight="Bold"
|
||||
Text="+0.00234 ETH" />
|
||||
|
||||
<TextBlock Grid.Row="1"
|
||||
FontWeight="SemiBold"
|
||||
Text="本周收益:" />
|
||||
<TextBlock Grid.Row="1"
|
||||
Grid.Column="1"
|
||||
Foreground="#4CAF50"
|
||||
FontWeight="Bold"
|
||||
Text="+0.01678 ETH" />
|
||||
|
||||
<TextBlock Grid.Row="2"
|
||||
FontWeight="SemiBold"
|
||||
Text="总收益:" />
|
||||
<TextBlock Grid.Row="2"
|
||||
Grid.Column="1"
|
||||
Foreground="#4CAF50"
|
||||
FontWeight="Bold"
|
||||
Text="+0.08923 ETH" />
|
||||
|
||||
<TextBlock Grid.Row="3"
|
||||
FontWeight="SemiBold"
|
||||
Text="预计下次支付:" />
|
||||
<TextBlock Grid.Row="3"
|
||||
Grid.Column="1"
|
||||
Opacity="0.82"
|
||||
Text="约 12 小时后" />
|
||||
</Grid>
|
||||
</ui:SettingsExpanderItem>
|
||||
</ui:SettingsExpander>
|
||||
|
||||
<ui:InfoBar Title="愚人节快乐!"
|
||||
Message="这只是一个玩笑功能,没有真实的挖矿行为。感谢您使用阑山桌面!"
|
||||
Severity="Informational"
|
||||
IsOpen="{Binding ShowAprilFoolsHint}"
|
||||
IsClosable="False"
|
||||
Margin="0,12,0,0" />
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</UserControl>
|
||||
@@ -1,89 +0,0 @@
|
||||
using System;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
using LanMountainDesktop.ViewModels;
|
||||
|
||||
namespace LanMountainDesktop.Views.SettingsPages;
|
||||
|
||||
[SettingsPageInfo(
|
||||
"super-mining",
|
||||
"超级挖矿",
|
||||
SettingsPageCategory.About,
|
||||
IconKey = "Savings",
|
||||
SortOrder = 35,
|
||||
TitleLocalizationKey = "settings.supermining.title",
|
||||
DescriptionLocalizationKey = "settings.supermining.description",
|
||||
HidePageTitle = true)]
|
||||
public partial class SuperMiningSettingsPage : SettingsPageBase
|
||||
{
|
||||
private readonly DispatcherTimer _updateTimer;
|
||||
private readonly Random _random = new();
|
||||
private int _tickCount;
|
||||
|
||||
public SuperMiningSettingsPage()
|
||||
: this(new SuperMiningSettingsPageViewModel())
|
||||
{
|
||||
}
|
||||
|
||||
public SuperMiningSettingsPage(SuperMiningSettingsPageViewModel viewModel)
|
||||
{
|
||||
ViewModel = viewModel;
|
||||
DataContext = ViewModel;
|
||||
InitializeComponent();
|
||||
|
||||
ViewModel.LoadQrCodeImage();
|
||||
|
||||
_updateTimer = new DispatcherTimer
|
||||
{
|
||||
Interval = TimeSpan.FromSeconds(1)
|
||||
};
|
||||
_updateTimer.Tick += OnUpdateTimerTick;
|
||||
|
||||
Unloaded += OnUnloaded;
|
||||
}
|
||||
|
||||
public SuperMiningSettingsPageViewModel ViewModel { get; }
|
||||
|
||||
public override void OnNavigatedTo(object? parameter)
|
||||
{
|
||||
base.OnNavigatedTo(parameter);
|
||||
_updateTimer.Start();
|
||||
}
|
||||
|
||||
private void OnUnloaded(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||
{
|
||||
_updateTimer.Stop();
|
||||
}
|
||||
|
||||
private void OnUpdateTimerTick(object? sender, EventArgs e)
|
||||
{
|
||||
_tickCount++;
|
||||
|
||||
ViewModel.HashRate = 125.6 + _random.NextDouble() * 10 - 5;
|
||||
ViewModel.MiningProgress = (ViewModel.MiningProgress + 1) % 100;
|
||||
|
||||
if (_tickCount % 5 == 0)
|
||||
{
|
||||
var baseCoins = 0.08923;
|
||||
var increment = _random.NextDouble() * 0.00001;
|
||||
ViewModel.CoinsMined = (baseCoins + increment).ToString("F5");
|
||||
}
|
||||
|
||||
ViewModel.PoolConnections = _random.Next(95, 100);
|
||||
|
||||
var statuses = new[]
|
||||
{
|
||||
"正在挖矿中...",
|
||||
"矿池连接稳定",
|
||||
"正在提交份额...",
|
||||
"算力优化中...",
|
||||
"收益计算中..."
|
||||
};
|
||||
ViewModel.MiningStatus = statuses[_tickCount % statuses.Length];
|
||||
|
||||
if (DateTime.Now.Month == 4 && DateTime.Now.Day == 1)
|
||||
{
|
||||
ViewModel.ShowAprilFoolsHint = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -734,7 +734,8 @@ public partial class SettingsWindow : Window, ISettingsPageHostContext
|
||||
"Info" => Symbol.Info,
|
||||
"ArrowSync" => Symbol.ArrowSync,
|
||||
"Hourglass" => Symbol.Hourglass,
|
||||
"Savings" => Symbol.Savings,
|
||||
"Alert" => Symbol.Alert, // 铃铛图标
|
||||
"Bell" => Symbol.Alert, // Bell也映射到Alert图标
|
||||
_ => Symbol.Settings
|
||||
};
|
||||
}
|
||||
|
||||
148
LanMountainDesktop/Views/StudySessionReportWindow.axaml
Normal file
148
LanMountainDesktop/Views/StudySessionReportWindow.axaml
Normal file
@@ -0,0 +1,148 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:fi="using:FluentIcons.Avalonia"
|
||||
x:Class="LanMountainDesktop.Views.StudySessionReportWindow"
|
||||
x:CompileBindings="False"
|
||||
SystemDecorations="None"
|
||||
Background="Transparent"
|
||||
ShowInTaskbar="False"
|
||||
Topmost="True"
|
||||
CanResize="False"
|
||||
Width="800"
|
||||
Height="600"
|
||||
TransparencyLevelHint="Transparent"
|
||||
ExtendClientAreaToDecorationsHint="True"
|
||||
ExtendClientAreaChromeHints="NoChrome"
|
||||
ExtendClientAreaTitleBarHeightHint="-1"
|
||||
WindowStartupLocation="CenterOwner">
|
||||
|
||||
<Border x:Name="RootBorder"
|
||||
Background="#E8EAED"
|
||||
CornerRadius="20"
|
||||
Padding="0">
|
||||
<Grid RowDefinitions="Auto,*">
|
||||
<!-- Header -->
|
||||
<Border Grid.Row="0"
|
||||
Background="#F5F5F5"
|
||||
CornerRadius="20,20,0,0"
|
||||
Padding="24,16"
|
||||
BorderBrush="#DDDDDD"
|
||||
BorderThickness="0,0,0,1">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
<fi:SymbolIcon Grid.Column="0"
|
||||
Symbol="Hourglass"
|
||||
FontSize="24"
|
||||
Foreground="#333333"
|
||||
Margin="0,0,12,0" />
|
||||
<StackPanel Grid.Column="1" Spacing="4">
|
||||
<TextBlock x:Name="TitleTextBlock"
|
||||
FontSize="18"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="#333333" />
|
||||
<TextBlock x:Name="SubtitleTextBlock"
|
||||
FontSize="13"
|
||||
Foreground="#666666" />
|
||||
</StackPanel>
|
||||
<Button Grid.Column="2"
|
||||
x:Name="CloseButton"
|
||||
Width="32"
|
||||
Height="32"
|
||||
CornerRadius="16"
|
||||
Background="Transparent"
|
||||
BorderBrush="Transparent"
|
||||
BorderThickness="0"
|
||||
Padding="0"
|
||||
HorizontalAlignment="Right"
|
||||
VerticalAlignment="Center">
|
||||
<fi:SymbolIcon Symbol="Dismiss"
|
||||
FontSize="16"
|
||||
Foreground="#666666" />
|
||||
</Button>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- Content -->
|
||||
<ScrollViewer Grid.Row="1"
|
||||
Background="#FAFAFA"
|
||||
Padding="24,20"
|
||||
VerticalScrollBarVisibility="Auto"
|
||||
HorizontalScrollBarVisibility="Disabled">
|
||||
<StackPanel Spacing="20">
|
||||
<!-- Summary Cards -->
|
||||
<Grid ColumnDefinitions="*,*,*,*" ColumnSpacing="12">
|
||||
<Border x:Name="AvgScoreCard"
|
||||
Background="White"
|
||||
CornerRadius="12"
|
||||
Padding="16"
|
||||
BorderBrush="#E0E0E0"
|
||||
BorderThickness="1">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Text="平均分数" FontSize="12" Foreground="#888888" />
|
||||
<TextBlock x:Name="AvgScoreTextBlock" FontSize="24" FontWeight="Bold" Foreground="#333333" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<Border Grid.Column="1"
|
||||
Background="White"
|
||||
CornerRadius="12"
|
||||
Padding="16"
|
||||
BorderBrush="#E0E0E0"
|
||||
BorderThickness="1">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Text="最高分数" FontSize="12" Foreground="#888888" />
|
||||
<TextBlock x:Name="MaxScoreTextBlock" FontSize="24" FontWeight="Bold" Foreground="#333333" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<Border Grid.Column="2"
|
||||
Background="White"
|
||||
CornerRadius="12"
|
||||
Padding="16"
|
||||
BorderBrush="#E0E0E0"
|
||||
BorderThickness="1">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Text="最低分数" FontSize="12" Foreground="#888888" />
|
||||
<TextBlock x:Name="MinScoreTextBlock" FontSize="24" FontWeight="Bold" Foreground="#333333" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<Border Grid.Column="3"
|
||||
Background="White"
|
||||
CornerRadius="12"
|
||||
Padding="16"
|
||||
BorderBrush="#E0E0E0"
|
||||
BorderThickness="1">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Text="打断次数" FontSize="12" Foreground="#888888" />
|
||||
<TextBlock x:Name="InterruptCountTextBlock" FontSize="24" FontWeight="Bold" Foreground="#333333" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
|
||||
<!-- Detail Data Table -->
|
||||
<Border Background="White"
|
||||
CornerRadius="12"
|
||||
Padding="16"
|
||||
BorderBrush="#E0E0E0"
|
||||
BorderThickness="1">
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock Text="详细数据" FontSize="16" FontWeight="SemiBold" Foreground="#333333" />
|
||||
<DataGrid x:Name="DetailDataGrid"
|
||||
AutoGenerateColumns="False"
|
||||
IsReadOnly="True"
|
||||
GridLinesVisibility="All"
|
||||
BorderThickness="1"
|
||||
BorderBrush="#E0E0E0"
|
||||
CornerRadius="8"
|
||||
MaxHeight="400">
|
||||
<DataGrid.Columns>
|
||||
<DataGridTextColumn Header="时间段" Width="*" Binding="{Binding TimeRange, Mode=OneWay}" />
|
||||
<DataGridTextColumn Header="平均分贝" Width="Auto" Binding="{Binding AvgDb, Mode=OneWay, StringFormat={}{0:F1}}" />
|
||||
<DataGridTextColumn Header="分数" Width="Auto" Binding="{Binding Score, Mode=OneWay, StringFormat={}{0:F1}}" />
|
||||
<DataGridTextColumn Header="打断次数" Width="Auto" Binding="{Binding SegmentCount, Mode=OneWay}" />
|
||||
</DataGrid.Columns>
|
||||
</DataGrid>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Window>
|
||||
95
LanMountainDesktop/Views/StudySessionReportWindow.axaml.cs
Normal file
95
LanMountainDesktop/Views/StudySessionReportWindow.axaml.cs
Normal file
@@ -0,0 +1,95 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using LanMountainDesktop.Models;
|
||||
|
||||
namespace LanMountainDesktop.Views;
|
||||
|
||||
public partial class StudySessionReportWindow : Window
|
||||
{
|
||||
private StudySessionReport? _report;
|
||||
|
||||
public StudySessionReportWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
CloseButton.Click += OnCloseButtonClick;
|
||||
}
|
||||
|
||||
public StudySessionReportWindow(StudySessionReport report) : this()
|
||||
{
|
||||
LoadReport(report);
|
||||
}
|
||||
|
||||
public void LoadReport(StudySessionReport report)
|
||||
{
|
||||
_report = report;
|
||||
|
||||
// 设置标题
|
||||
TitleTextBlock.Text = string.IsNullOrWhiteSpace(report.Label)
|
||||
? "自习报告"
|
||||
: report.Label;
|
||||
SubtitleTextBlock.Text = string.Format(
|
||||
CultureInfo.CurrentCulture,
|
||||
"{0:yyyy-MM-dd HH:mm} - {1:HH:mm} ({2})",
|
||||
report.StartedAt.ToLocalTime(),
|
||||
report.EndedAt.ToLocalTime(),
|
||||
FormatDuration(report.Duration));
|
||||
|
||||
// 设置汇总数据
|
||||
AvgScoreTextBlock.Text = report.Metrics.AvgScore.ToString("F1", CultureInfo.CurrentCulture);
|
||||
MaxScoreTextBlock.Text = report.Metrics.MaxScore.ToString("F1", CultureInfo.CurrentCulture);
|
||||
MinScoreTextBlock.Text = report.Metrics.MinScore.ToString("F1", CultureInfo.CurrentCulture);
|
||||
InterruptCountTextBlock.Text = report.Metrics.TotalSegmentCount.ToString(CultureInfo.CurrentCulture);
|
||||
|
||||
// 构建详细数据表
|
||||
BuildDetailDataTable(report);
|
||||
}
|
||||
|
||||
private void BuildDetailDataTable(StudySessionReport report)
|
||||
{
|
||||
var items = new ObservableCollection<DetailDataRow>();
|
||||
|
||||
foreach (var slice in report.Slices)
|
||||
{
|
||||
items.Add(new DetailDataRow(
|
||||
TimeRange: $"{slice.StartAt.ToLocalTime():HH:mm} - {slice.EndAt.ToLocalTime():HH:mm}",
|
||||
AvgDb: slice.Display.AvgDb,
|
||||
Score: slice.Score,
|
||||
SegmentCount: slice.Raw.SegmentCount));
|
||||
}
|
||||
|
||||
DetailDataGrid.ItemsSource = items;
|
||||
}
|
||||
|
||||
private void OnCloseButtonClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
Close();
|
||||
}
|
||||
|
||||
private static string FormatDuration(TimeSpan duration)
|
||||
{
|
||||
if (duration.TotalHours >= 1)
|
||||
{
|
||||
return string.Format(
|
||||
CultureInfo.CurrentCulture,
|
||||
"{0}小时{1}分钟",
|
||||
(int)duration.TotalHours,
|
||||
duration.Minutes);
|
||||
}
|
||||
|
||||
return string.Format(
|
||||
CultureInfo.CurrentCulture,
|
||||
"{0}分钟",
|
||||
duration.Minutes);
|
||||
}
|
||||
}
|
||||
|
||||
public record DetailDataRow(
|
||||
string TimeRange,
|
||||
double AvgDb,
|
||||
double Score,
|
||||
int SegmentCount);
|
||||
24
LanMountainDesktop/Views/TransparentOverlayWindow.axaml
Normal file
24
LanMountainDesktop/Views/TransparentOverlayWindow.axaml
Normal file
@@ -0,0 +1,24 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
x:Class="LanMountainDesktop.Views.TransparentOverlayWindow"
|
||||
WindowState="FullScreen"
|
||||
SystemDecorations="None"
|
||||
CanResize="False"
|
||||
ShowInTaskbar="False"
|
||||
ExtendClientAreaToDecorationsHint="True"
|
||||
ExtendClientAreaChromeHints="NoChrome"
|
||||
Background="Transparent"
|
||||
Title="LanMountainDesktop Fused Desktop">
|
||||
<!--
|
||||
融合桌面(负一屏)- 在系统桌面上显示组件
|
||||
|
||||
特性:
|
||||
- 窗口置底(在桌面图标层显示)
|
||||
- 区域级穿透(组件区域可交互,其他区域穿透)
|
||||
- 组件可自由拖拽摆放
|
||||
- 三指/右键左滑回到阗山桌面第一页
|
||||
-->
|
||||
<Canvas x:Name="ComponentCanvas">
|
||||
<!-- 组件将动态添加到这里 -->
|
||||
</Canvas>
|
||||
</Window>
|
||||
467
LanMountainDesktop/Views/TransparentOverlayWindow.axaml.cs
Normal file
467
LanMountainDesktop/Views/TransparentOverlayWindow.axaml.cs
Normal file
@@ -0,0 +1,467 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Media;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.Services.Settings;
|
||||
|
||||
namespace LanMountainDesktop.Views;
|
||||
|
||||
/// <summary>
|
||||
/// 透明覆盖层窗口 - 作为"负一屏"显示在 Windows 桌面上
|
||||
/// 支持在系统桌面上自由摆放组件
|
||||
/// </summary>
|
||||
public partial class TransparentOverlayWindow : Window
|
||||
{
|
||||
private readonly ISettingsFacadeService _settingsFacade = HostSettingsFacadeProvider.GetOrCreate();
|
||||
private readonly IWindowBottomMostService _bottomMostService = WindowBottomMostServiceFactory.GetOrCreate();
|
||||
private readonly IRegionPassthroughService _regionPassthroughService = RegionPassthroughServiceFactory.GetOrCreate();
|
||||
private readonly IFusedDesktopLayoutService _layoutService = FusedDesktopLayoutServiceProvider.GetOrCreate();
|
||||
|
||||
// 滑动状态
|
||||
private bool _isSwipeActive;
|
||||
private bool _isSwipeDirectionLocked;
|
||||
private Point _swipeStartPoint;
|
||||
private Point _swipeCurrentPoint;
|
||||
private Point _swipeLastPoint;
|
||||
private double _swipeVelocityX;
|
||||
private long _swipeLastTimestamp;
|
||||
|
||||
// 三指/右键拖动状态
|
||||
private bool _isThreeFingerOrRightDragSwipeActive;
|
||||
private readonly HashSet<int> _activePointerIds = [];
|
||||
|
||||
// 组件管理
|
||||
private readonly Dictionary<string, Border> _componentHosts = [];
|
||||
private readonly List<Rect> _interactiveRegions = [];
|
||||
private FusedDesktopLayoutSnapshot _layout = new();
|
||||
|
||||
// 拖拽状态
|
||||
private bool _isDragging;
|
||||
private string? _draggingPlacementId;
|
||||
private Point _dragStartPoint;
|
||||
private Border? _draggingHost;
|
||||
|
||||
public event EventHandler? RestoreMainWindowRequested;
|
||||
|
||||
public TransparentOverlayWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
// 仅在 Windows 上启用置底功能
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
_bottomMostService.SetupBottomMost(this);
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnOpened(EventArgs e)
|
||||
{
|
||||
base.OnOpened(e);
|
||||
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
_bottomMostService.SendToBottom(this);
|
||||
}
|
||||
|
||||
// 加载布局
|
||||
_layout = _layoutService.Load();
|
||||
|
||||
// TODO: 渲染组件(需要从 MainWindow 获取组件注册表)
|
||||
|
||||
AppLogger.Info("TransparentOverlay", "Transparent overlay window opened.");
|
||||
}
|
||||
|
||||
protected override void OnClosed(EventArgs e)
|
||||
{
|
||||
SaveLayout();
|
||||
base.OnClosed(e);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新可交互区域
|
||||
/// </summary>
|
||||
private void UpdateInteractiveRegions()
|
||||
{
|
||||
_interactiveRegions.Clear();
|
||||
|
||||
foreach (var host in _componentHosts.Values)
|
||||
{
|
||||
var x = Canvas.GetLeft(host);
|
||||
var y = Canvas.GetTop(host);
|
||||
_interactiveRegions.Add(new Rect(x, y, host.Width, host.Height));
|
||||
}
|
||||
|
||||
_regionPassthroughService.SetInteractiveRegions(this, _interactiveRegions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存布局
|
||||
/// </summary>
|
||||
private void SaveLayout()
|
||||
{
|
||||
_layoutService.Save(_layout);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 添加组件(供外部调用)
|
||||
/// </summary>
|
||||
public void AddComponent(string componentId, double x, double y, double width = 200, double height = 200)
|
||||
{
|
||||
var placementId = Guid.NewGuid().ToString("N");
|
||||
var placement = new FusedDesktopComponentPlacementSnapshot
|
||||
{
|
||||
PlacementId = placementId,
|
||||
ComponentId = componentId,
|
||||
X = x,
|
||||
Y = y,
|
||||
Width = width,
|
||||
Height = height,
|
||||
ZIndex = _layout.ComponentPlacements.Count
|
||||
};
|
||||
|
||||
_layout.ComponentPlacements.Add(placement);
|
||||
UpdateInteractiveRegions();
|
||||
SaveLayout();
|
||||
|
||||
AppLogger.Info("TransparentOverlay", $"Added component: {componentId} at ({x}, {y})");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 移除组件
|
||||
/// </summary>
|
||||
public void RemoveComponent(string placementId)
|
||||
{
|
||||
if (_componentHosts.TryGetValue(placementId, out var host))
|
||||
{
|
||||
if (Content is Canvas canvas)
|
||||
{
|
||||
canvas.Children.Remove(host);
|
||||
}
|
||||
_componentHosts.Remove(placementId);
|
||||
}
|
||||
|
||||
_layout.ComponentPlacements.RemoveAll(p => p.PlacementId == placementId);
|
||||
UpdateInteractiveRegions();
|
||||
SaveLayout();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 渲染组件(从外部传入控件)
|
||||
/// </summary>
|
||||
public void RenderComponent(string placementId, Control component, double x, double y, double width, double height)
|
||||
{
|
||||
var host = new Border
|
||||
{
|
||||
Tag = placementId,
|
||||
Width = width,
|
||||
Height = height,
|
||||
Background = Brushes.Transparent,
|
||||
CornerRadius = new CornerRadius(12),
|
||||
ClipToBounds = true,
|
||||
Child = component
|
||||
};
|
||||
|
||||
Canvas.SetLeft(host, x);
|
||||
Canvas.SetTop(host, y);
|
||||
|
||||
// 添加拖拽支持
|
||||
host.PointerPressed += OnComponentPointerPressed;
|
||||
host.PointerMoved += OnComponentPointerMoved;
|
||||
host.PointerReleased += OnComponentPointerReleased;
|
||||
|
||||
if (Content is Canvas canvas)
|
||||
{
|
||||
canvas.Children.Add(host);
|
||||
}
|
||||
|
||||
_componentHosts[placementId] = host;
|
||||
UpdateInteractiveRegions();
|
||||
}
|
||||
|
||||
// 组件拖拽处理
|
||||
private void OnComponentPointerPressed(object? sender, PointerPressedEventArgs e)
|
||||
{
|
||||
if (sender is not Border host || host.Tag is not string placementId) return;
|
||||
|
||||
var point = e.GetCurrentPoint(this);
|
||||
if (!point.Properties.IsLeftButtonPressed) return;
|
||||
|
||||
_isDragging = true;
|
||||
_draggingPlacementId = placementId;
|
||||
_draggingHost = host;
|
||||
_dragStartPoint = e.GetPosition(this);
|
||||
|
||||
e.Pointer.Capture(host);
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private void OnComponentPointerMoved(object? sender, PointerEventArgs e)
|
||||
{
|
||||
if (!_isDragging || _draggingHost is null) return;
|
||||
|
||||
var currentPoint = e.GetPosition(this);
|
||||
var deltaX = currentPoint.X - _dragStartPoint.X;
|
||||
var deltaY = currentPoint.Y - _dragStartPoint.Y;
|
||||
|
||||
var currentX = Canvas.GetLeft(_draggingHost);
|
||||
var currentY = Canvas.GetTop(_draggingHost);
|
||||
|
||||
Canvas.SetLeft(_draggingHost, currentX + deltaX);
|
||||
Canvas.SetTop(_draggingHost, currentY + deltaY);
|
||||
|
||||
_dragStartPoint = currentPoint;
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private void OnComponentPointerReleased(object? sender, PointerReleasedEventArgs e)
|
||||
{
|
||||
if (!_isDragging || _draggingHost is null || _draggingPlacementId is null)
|
||||
{
|
||||
_isDragging = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// 更新布局中的位置
|
||||
var placement = _layout.ComponentPlacements.Find(p => p.PlacementId == _draggingPlacementId);
|
||||
if (placement is not null)
|
||||
{
|
||||
placement.X = Canvas.GetLeft(_draggingHost);
|
||||
placement.Y = Canvas.GetTop(_draggingHost);
|
||||
}
|
||||
|
||||
UpdateInteractiveRegions();
|
||||
SaveLayout();
|
||||
|
||||
_isDragging = false;
|
||||
_draggingPlacementId = null;
|
||||
_draggingHost = null;
|
||||
|
||||
e.Pointer.Capture(null);
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
// 三指滑动处理
|
||||
protected override void OnPointerPressed(PointerPressedEventArgs e)
|
||||
{
|
||||
var appSnapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
|
||||
if (!appSnapshot.EnableThreeFingerSwipe)
|
||||
{
|
||||
base.OnPointerPressed(e);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!TryGetPointerPosition(e, out var pointerPos))
|
||||
{
|
||||
base.OnPointerPressed(e);
|
||||
return;
|
||||
}
|
||||
|
||||
var currentPoint = e.GetCurrentPoint(this);
|
||||
var pointerId = e.Pointer?.Id ?? 0;
|
||||
var isRightButtonPressed = currentPoint.Properties.IsRightButtonPressed;
|
||||
var isLeftButtonPressed = currentPoint.Properties.IsLeftButtonPressed;
|
||||
|
||||
if (isLeftButtonPressed || isRightButtonPressed)
|
||||
{
|
||||
_activePointerIds.Add(pointerId);
|
||||
}
|
||||
|
||||
var isThreeFinger = _activePointerIds.Count >= 3;
|
||||
var isRightDrag = isRightButtonPressed;
|
||||
|
||||
if (isThreeFinger || isRightDrag)
|
||||
{
|
||||
_isSwipeActive = true;
|
||||
_isThreeFingerOrRightDragSwipeActive = true;
|
||||
_isSwipeDirectionLocked = false;
|
||||
_swipeStartPoint = pointerPos;
|
||||
_swipeCurrentPoint = pointerPos;
|
||||
_swipeLastPoint = pointerPos;
|
||||
_swipeVelocityX = 0;
|
||||
_swipeLastTimestamp = Stopwatch.GetTimestamp();
|
||||
e.Handled = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
base.OnPointerPressed(e);
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnPointerMoved(PointerEventArgs e)
|
||||
{
|
||||
if (!_isSwipeActive)
|
||||
{
|
||||
base.OnPointerMoved(e);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!TryGetPointerPosition(e, out var pointerPos))
|
||||
{
|
||||
base.OnPointerMoved(e);
|
||||
return;
|
||||
}
|
||||
|
||||
_swipeCurrentPoint = pointerPos;
|
||||
UpdateSwipeVelocity(pointerPos);
|
||||
|
||||
var deltaX = _swipeCurrentPoint.X - _swipeStartPoint.X;
|
||||
var deltaY = _swipeCurrentPoint.Y - _swipeStartPoint.Y;
|
||||
|
||||
if (!_isSwipeDirectionLocked)
|
||||
{
|
||||
const double activationThreshold = 14;
|
||||
const double horizontalBias = 1.15;
|
||||
var absDeltaX = Math.Abs(deltaX);
|
||||
var absDeltaY = Math.Abs(deltaY);
|
||||
|
||||
if (absDeltaY >= activationThreshold && absDeltaY > absDeltaX * horizontalBias)
|
||||
{
|
||||
CancelSwipeInteraction(e.Pointer);
|
||||
base.OnPointerMoved(e);
|
||||
return;
|
||||
}
|
||||
|
||||
if (absDeltaX < activationThreshold || absDeltaX <= absDeltaY * horizontalBias)
|
||||
{
|
||||
base.OnPointerMoved(e);
|
||||
return;
|
||||
}
|
||||
|
||||
_isSwipeDirectionLocked = true;
|
||||
if (e.Pointer?.Captured != this)
|
||||
{
|
||||
e.Pointer?.Capture(this);
|
||||
}
|
||||
}
|
||||
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
protected override void OnPointerReleased(PointerReleasedEventArgs e)
|
||||
{
|
||||
var pointerId = e.Pointer?.Id ?? 0;
|
||||
_activePointerIds.Remove(pointerId);
|
||||
|
||||
if (_isSwipeActive)
|
||||
{
|
||||
if (EndSwipeInteraction(e.Pointer))
|
||||
{
|
||||
e.Handled = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
base.OnPointerReleased(e);
|
||||
}
|
||||
|
||||
protected override void OnPointerCaptureLost(PointerCaptureLostEventArgs e)
|
||||
{
|
||||
var pointerId = e.Pointer?.Id ?? 0;
|
||||
_activePointerIds.Remove(pointerId);
|
||||
|
||||
if (_isSwipeActive)
|
||||
{
|
||||
EndSwipeInteraction(e.Pointer);
|
||||
}
|
||||
|
||||
base.OnPointerCaptureLost(e);
|
||||
}
|
||||
|
||||
private bool TryGetPointerPosition(PointerEventArgs e, out Point point)
|
||||
{
|
||||
try
|
||||
{
|
||||
point = e.GetPosition(this);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
point = default;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateSwipeVelocity(Point currentPoint)
|
||||
{
|
||||
var now = Stopwatch.GetTimestamp();
|
||||
var elapsed = Stopwatch.GetElapsedTime(_swipeLastTimestamp, now).TotalSeconds;
|
||||
|
||||
if (elapsed > 0)
|
||||
{
|
||||
var dx = currentPoint.X - _swipeLastPoint.X;
|
||||
_swipeVelocityX = dx / elapsed;
|
||||
}
|
||||
|
||||
_swipeLastPoint = currentPoint;
|
||||
_swipeLastTimestamp = now;
|
||||
}
|
||||
|
||||
private void CancelSwipeInteraction(IPointer? pointer)
|
||||
{
|
||||
if (!_isSwipeActive) return;
|
||||
|
||||
if (pointer?.Captured == this)
|
||||
{
|
||||
pointer?.Capture(null);
|
||||
}
|
||||
|
||||
_isSwipeActive = false;
|
||||
_isSwipeDirectionLocked = false;
|
||||
_isThreeFingerOrRightDragSwipeActive = false;
|
||||
_activePointerIds.Clear();
|
||||
_swipeVelocityX = 0;
|
||||
_swipeLastTimestamp = 0;
|
||||
}
|
||||
|
||||
private bool EndSwipeInteraction(IPointer? pointer)
|
||||
{
|
||||
if (!_isSwipeActive) return false;
|
||||
|
||||
var wasDirectionLocked = _isSwipeDirectionLocked;
|
||||
var wasThreeFingerOrRightDrag = _isThreeFingerOrRightDragSwipeActive;
|
||||
|
||||
_isSwipeActive = false;
|
||||
_isSwipeDirectionLocked = false;
|
||||
_isThreeFingerOrRightDragSwipeActive = false;
|
||||
_activePointerIds.Clear();
|
||||
|
||||
if (pointer?.Captured == this)
|
||||
{
|
||||
pointer?.Capture(null);
|
||||
}
|
||||
|
||||
_swipeLastTimestamp = 0;
|
||||
|
||||
if (!wasDirectionLocked)
|
||||
{
|
||||
_swipeVelocityX = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
var deltaX = _swipeCurrentPoint.X - _swipeStartPoint.X;
|
||||
var deltaY = _swipeCurrentPoint.Y - _swipeStartPoint.Y;
|
||||
var absDeltaX = Math.Abs(deltaX);
|
||||
var distanceThreshold = Math.Max(48, this.Bounds.Width * 0.14);
|
||||
var velocityThreshold = Math.Max(860, this.Bounds.Width * 1.08);
|
||||
var hasDistanceIntent = absDeltaX >= distanceThreshold && absDeltaX > Math.Abs(deltaY) * 1.05;
|
||||
var hasVelocityIntent = Math.Abs(_swipeVelocityX) >= velocityThreshold;
|
||||
|
||||
// 向左滑动回到第一页
|
||||
if (wasThreeFingerOrRightDrag && deltaX < 0 && (hasDistanceIntent || hasVelocityIntent))
|
||||
{
|
||||
RestoreMainWindowRequested?.Invoke(this, EventArgs.Empty);
|
||||
_swipeVelocityX = 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
_swipeVelocityX = 0;
|
||||
return hasDistanceIntent || hasVelocityIntent;
|
||||
}
|
||||
}
|
||||
304
docs/component-corner-radius-unification-plan.txt
Normal file
304
docs/component-corner-radius-unification-plan.txt
Normal file
@@ -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. 如果你愿意,我可以直接开始改第一批基础代码,把内置组件和插件默认圆角先统一起来
|
||||
Reference in New Issue
Block a user