mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-22 00:54:26 +08:00
Compare commits
11 Commits
v0.8.0.41
...
5d2449fa8f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d2449fa8f | ||
|
|
00339f0ed0 | ||
|
|
021c7ff245 | ||
|
|
675096b6c4 | ||
|
|
1c3cc76f21 | ||
|
|
44b87ba12e | ||
|
|
35976c3f3d | ||
|
|
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()
|
||||
@@ -143,6 +149,11 @@ public partial class App : Application
|
||||
LinuxDesktopEntryInstaller.EnsureInstalled();
|
||||
DesktopBootstrap.InitializeApplication(this, InitializeDesktopShell);
|
||||
|
||||
if (!Design.IsDesignMode && OperatingSystem.IsWindows())
|
||||
{
|
||||
FusedDesktopManagerServiceFactory.GetOrCreate().Initialize();
|
||||
}
|
||||
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
}
|
||||
|
||||
@@ -213,12 +224,59 @@ 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);
|
||||
// 切换进入编辑模式,隐藏常态零散的小部件
|
||||
FusedDesktopManagerServiceFactory.GetOrCreate().EnterEditMode();
|
||||
|
||||
// 确保透明覆盖层窗口存在并显示
|
||||
EnsureTransparentOverlayWindow();
|
||||
|
||||
// 打开融合桌面组件库窗口
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
// 确保覆盖层窗口已显示(组件要渲染在上面,必须先 Show)
|
||||
if (_transparentOverlayWindow is not null && !_transparentOverlayWindow.IsVisible)
|
||||
{
|
||||
_transparentOverlayWindow.Show();
|
||||
}
|
||||
|
||||
var window = new FusedDesktopComponentLibraryWindow();
|
||||
|
||||
if (_transparentOverlayWindow is not null)
|
||||
{
|
||||
window.SetOverlayWindow(_transparentOverlayWindow);
|
||||
}
|
||||
|
||||
// 当组件库关闭时,退出编辑态
|
||||
window.Closed += (s, ev) =>
|
||||
{
|
||||
if (_transparentOverlayWindow is not null)
|
||||
{
|
||||
// 触发画布保存,并隐藏画布
|
||||
_transparentOverlayWindow.SaveLayoutAndHide();
|
||||
}
|
||||
|
||||
// 让管理器根据已存储的最新快照重建生成所有实体小组件
|
||||
FusedDesktopManagerServiceFactory.GetOrCreate().ExitEditMode();
|
||||
};
|
||||
|
||||
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 +458,11 @@ public partial class App : Application
|
||||
_localizationService);
|
||||
}
|
||||
|
||||
private void EnsureNotificationService()
|
||||
{
|
||||
_notificationService ??= new NotificationService(_appearanceThemeService);
|
||||
}
|
||||
|
||||
private void StartWeatherLocationRefreshIfNeeded()
|
||||
{
|
||||
EnsureWeatherLocationRefreshService();
|
||||
@@ -482,6 +545,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 +588,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 +950,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 +960,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",
|
||||
@@ -379,6 +388,41 @@
|
||||
"settings.status_bar.clock_format_label": "Clock format",
|
||||
"settings.status_bar.clock_format.hm": "Hour:Minute",
|
||||
"settings.status_bar.clock_format.hms": "Hour:Minute:Second",
|
||||
"settings.status_bar.clock_position_label": "Clock position",
|
||||
"settings.status_bar.clock_position.left": "Left",
|
||||
"settings.status_bar.clock_position.center": "Center",
|
||||
"settings.status_bar.clock_position.right": "Right",
|
||||
"settings.status_bar.text_capsule_header": "Text Capsule",
|
||||
"settings.status_bar.text_capsule_description": "Display custom text on the status bar with Markdown support.",
|
||||
"settings.status_bar.text_capsule_position_label": "Text capsule position",
|
||||
"settings.status_bar.text_capsule_position.left": "Left",
|
||||
"settings.status_bar.text_capsule_position.center": "Center",
|
||||
"settings.status_bar.text_capsule_position.right": "Right",
|
||||
"settings.status_bar.text_capsule_content_label": "Text content (Markdown supported)",
|
||||
"settings.status_bar.text_capsule_transparent_background_label": "Transparent background",
|
||||
"settings.status_bar.network_speed_header": "Network Speed",
|
||||
"settings.status_bar.network_speed_description": "Display real-time network upload and download speed on the status bar.",
|
||||
"settings.status_bar.network_speed_position_label": "Network speed position",
|
||||
"settings.status_bar.network_speed_position.left": "Left",
|
||||
"settings.status_bar.network_speed_position.center": "Center",
|
||||
"settings.status_bar.network_speed_position.right": "Right",
|
||||
"settings.status_bar.network_speed_mode_label": "Display mode",
|
||||
"settings.status_bar.network_speed_mode.both": "Upload + Download",
|
||||
"settings.status_bar.network_speed_mode.upload": "Upload only",
|
||||
"settings.status_bar.network_speed_mode.download": "Download only",
|
||||
"settings.status_bar.network_speed_transparent_background_label": "Transparent background",
|
||||
"settings.status_bar.show_network_type_icon_label": "Show network type icon",
|
||||
"settings.status_bar.shadow_header": "Status Bar Shadow",
|
||||
"settings.status_bar.shadow_desc": "Add shadow effect to the status bar for better visibility of transparent components.",
|
||||
"settings.status_bar.shadow_enabled_label": "Enable shadow",
|
||||
"settings.status_bar.shadow_color_label": "Shadow color",
|
||||
"settings.status_bar.shadow_opacity_label": "Shadow opacity",
|
||||
"settings.status_bar.theme_header": "Status Bar Theme",
|
||||
"settings.status_bar.theme_desc": "Set the theme mode for the status bar independently.",
|
||||
"settings.status_bar.theme_mode_label": "Theme mode",
|
||||
"settings.status_bar.theme_mode.follow_global": "Follow Global",
|
||||
"settings.status_bar.theme_mode.dark": "Dark",
|
||||
"settings.status_bar.theme_mode.light": "Light",
|
||||
"settings.components.title": "Components",
|
||||
"settings.components.description": "Adjust component layout and corner design.",
|
||||
"settings.components.grid_header": "Grid Settings",
|
||||
@@ -1031,7 +1075,9 @@
|
||||
"zhijiaohub.settings.source": "Image Source",
|
||||
"zhijiaohub.settings.classisland": "ClassIsland Gallery",
|
||||
"zhijiaohub.settings.sectl": "SECTL Gallery",
|
||||
"zhijiaohub.settings.source_desc": "Select the image source. ClassIsland Gallery contains fun moments from the ClassIsland community, SECTL Gallery contains content from the SECTL community.",
|
||||
"zhijiaohub.settings.rinlit": "Rin's Gallery",
|
||||
"zhijiaohub.settings.jiangtokoto": "Jiangtokoto Memes",
|
||||
"zhijiaohub.settings.source_desc": "Select the image source. ClassIsland Gallery contains fun moments from the ClassIsland community, SECTL Gallery contains content from the SECTL community, Rin's Gallery contains content from Rin's community, Jiangtokoto Memes contains rich meme resources.",
|
||||
"zhijiaohub.settings.mirror_source": "Mirror Acceleration",
|
||||
"zhijiaohub.settings.mirror_direct": "Direct (GitHub)",
|
||||
"zhijiaohub.settings.mirror_ghproxy": "Mirror Acceleration (Recommended)",
|
||||
|
||||
@@ -331,6 +331,41 @@
|
||||
"settings.status_bar.clock_format_label": "時計の形式",
|
||||
"settings.status_bar.clock_format.hm": "時:分",
|
||||
"settings.status_bar.clock_format.hms": "時:分:秒",
|
||||
"settings.status_bar.clock_position_label": "時計の位置",
|
||||
"settings.status_bar.clock_position.left": "左",
|
||||
"settings.status_bar.clock_position.center": "中央",
|
||||
"settings.status_bar.clock_position.right": "右",
|
||||
"settings.status_bar.text_capsule_header": "テキストカプセル",
|
||||
"settings.status_bar.text_capsule_description": "ステータスバーにMarkdown形式のカスタムテキストを表示します。",
|
||||
"settings.status_bar.text_capsule_position_label": "テキストカプセルの位置",
|
||||
"settings.status_bar.text_capsule_position.left": "左",
|
||||
"settings.status_bar.text_capsule_position.center": "中央",
|
||||
"settings.status_bar.text_capsule_position.right": "右",
|
||||
"settings.status_bar.text_capsule_content_label": "テキスト内容(Markdown対応)",
|
||||
"settings.status_bar.text_capsule_transparent_background_label": "透明な背景",
|
||||
"settings.status_bar.network_speed_header": "ネットワーク速度",
|
||||
"settings.status_bar.network_speed_description": "ステータスバーにリアルタイムのネットワーク速度を表示します。",
|
||||
"settings.status_bar.network_speed_position_label": "ネットワーク速度の位置",
|
||||
"settings.status_bar.network_speed_position.left": "左",
|
||||
"settings.status_bar.network_speed_position.center": "中央",
|
||||
"settings.status_bar.network_speed_position.right": "右",
|
||||
"settings.status_bar.network_speed_mode_label": "表示モード",
|
||||
"settings.status_bar.network_speed_mode.both": "アップロード + ダウンロード",
|
||||
"settings.status_bar.network_speed_mode.upload": "アップロードのみ",
|
||||
"settings.status_bar.network_speed_mode.download": "ダウンロードのみ",
|
||||
"settings.status_bar.network_speed_transparent_background_label": "透明な背景",
|
||||
"settings.status_bar.show_network_type_icon_label": "ネットワークタイプアイコンを表示",
|
||||
"settings.status_bar.shadow_header": "ステータスバーの影",
|
||||
"settings.status_bar.shadow_desc": "透明なコンポーネントの視認性を高めるために、ステータスバーに影効果を追加します。",
|
||||
"settings.status_bar.shadow_enabled_label": "影を有効にする",
|
||||
"settings.status_bar.shadow_color_label": "影の色",
|
||||
"settings.status_bar.shadow_opacity_label": "影の不透明度",
|
||||
"settings.status_bar.theme_header": "ステータスバーのテーマ",
|
||||
"settings.status_bar.theme_desc": "ステータスバーのテーマモードを独立して設定します。",
|
||||
"settings.status_bar.theme_mode_label": "テーマモード",
|
||||
"settings.status_bar.theme_mode.follow_global": "グローバルに従う",
|
||||
"settings.status_bar.theme_mode.dark": "ダーク",
|
||||
"settings.status_bar.theme_mode.light": "ライト",
|
||||
"settings.components.title": "コンポーネント",
|
||||
"settings.components.description": "コンポーネントのレイアウトとコーナーデザインを調整します。",
|
||||
"settings.components.grid_header": "グリッド設定",
|
||||
|
||||
@@ -377,6 +377,41 @@
|
||||
"settings.status_bar.clock_format_label": "시계 형식",
|
||||
"settings.status_bar.clock_format.hm": "시:분",
|
||||
"settings.status_bar.clock_format.hms": "시:분:초",
|
||||
"settings.status_bar.clock_position_label": "시계 위치",
|
||||
"settings.status_bar.clock_position.left": "왼쪽",
|
||||
"settings.status_bar.clock_position.center": "가욍데",
|
||||
"settings.status_bar.clock_position.right": "오른쪽",
|
||||
"settings.status_bar.text_capsule_header": "텍스트 캡슐",
|
||||
"settings.status_bar.text_capsule_description": "Markdown 형식의 사용자 정의 텍스트를 상태 표시줄에 표시합니다.",
|
||||
"settings.status_bar.text_capsule_position_label": "텍스트 캡슐 위치",
|
||||
"settings.status_bar.text_capsule_position.left": "왼쪽",
|
||||
"settings.status_bar.text_capsule_position.center": "가욍데",
|
||||
"settings.status_bar.text_capsule_position.right": "오른쪽",
|
||||
"settings.status_bar.text_capsule_content_label": "텍스트 내용 (Markdown 지원)",
|
||||
"settings.status_bar.text_capsule_transparent_background_label": "투명 배경",
|
||||
"settings.status_bar.network_speed_header": "네트워크 속도",
|
||||
"settings.status_bar.network_speed_description": "상태 표시줄에 실시간 네트워크 속도를 표시합니다.",
|
||||
"settings.status_bar.network_speed_position_label": "네트워크 속도 위치",
|
||||
"settings.status_bar.network_speed_position.left": "왼쪽",
|
||||
"settings.status_bar.network_speed_position.center": "가욍데",
|
||||
"settings.status_bar.network_speed_position.right": "오른쪽",
|
||||
"settings.status_bar.network_speed_mode_label": "표시 모드",
|
||||
"settings.status_bar.network_speed_mode.both": "업로드 + 다운로드",
|
||||
"settings.status_bar.network_speed_mode.upload": "업로드만",
|
||||
"settings.status_bar.network_speed_mode.download": "다운로드만",
|
||||
"settings.status_bar.network_speed_transparent_background_label": "투명 배경",
|
||||
"settings.status_bar.show_network_type_icon_label": "네트워크 유형 아이콘 표시",
|
||||
"settings.status_bar.shadow_header": "상태 표시줄 그림자",
|
||||
"settings.status_bar.shadow_desc": "투명한 구성 요소의 가시성을 높이기 위해 상태 표시줄에 그림자 효과를 추가합니다.",
|
||||
"settings.status_bar.shadow_enabled_label": "그림자 활성화",
|
||||
"settings.status_bar.shadow_color_label": "그림자 색상",
|
||||
"settings.status_bar.shadow_opacity_label": "그림자 불투명도",
|
||||
"settings.status_bar.theme_header": "상태 표시줄 테마",
|
||||
"settings.status_bar.theme_desc": "상태 표시줄의 테마 모드를 독립적으로 설정합니다.",
|
||||
"settings.status_bar.theme_mode_label": "테마 모드",
|
||||
"settings.status_bar.theme_mode.follow_global": "전역 따르기",
|
||||
"settings.status_bar.theme_mode.dark": "다크",
|
||||
"settings.status_bar.theme_mode.light": "라이트",
|
||||
"settings.components.title": "컴포넌트",
|
||||
"settings.components.description": "컴포넌트 레이아웃과 모서리 디자인을 조정합니다.",
|
||||
"settings.components.grid_header": "그리드 설정",
|
||||
|
||||
@@ -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": "加载中...",
|
||||
@@ -374,6 +383,41 @@
|
||||
"settings.status_bar.clock_format_label": "时钟格式",
|
||||
"settings.status_bar.clock_format.hm": "时:分",
|
||||
"settings.status_bar.clock_format.hms": "时:分:秒",
|
||||
"settings.status_bar.clock_position_label": "时钟位置",
|
||||
"settings.status_bar.clock_position.left": "靠左",
|
||||
"settings.status_bar.clock_position.center": "居中",
|
||||
"settings.status_bar.clock_position.right": "靠右",
|
||||
"settings.status_bar.text_capsule_header": "文字胶囊",
|
||||
"settings.status_bar.text_capsule_description": "在状态栏显示自定义文字,支持 Markdown 格式。",
|
||||
"settings.status_bar.text_capsule_position_label": "文字胶囊位置",
|
||||
"settings.status_bar.text_capsule_position.left": "靠左",
|
||||
"settings.status_bar.text_capsule_position.center": "居中",
|
||||
"settings.status_bar.text_capsule_position.right": "靠右",
|
||||
"settings.status_bar.text_capsule_content_label": "文字内容(支持 Markdown)",
|
||||
"settings.status_bar.text_capsule_transparent_background_label": "透明背景",
|
||||
"settings.status_bar.network_speed_header": "网速显示",
|
||||
"settings.status_bar.network_speed_description": "在状态栏显示实时网络上传和下载速度。",
|
||||
"settings.status_bar.network_speed_position_label": "网速显示位置",
|
||||
"settings.status_bar.network_speed_position.left": "靠左",
|
||||
"settings.status_bar.network_speed_position.center": "居中",
|
||||
"settings.status_bar.network_speed_position.right": "靠右",
|
||||
"settings.status_bar.network_speed_mode_label": "显示模式",
|
||||
"settings.status_bar.network_speed_mode.both": "上传 + 下载",
|
||||
"settings.status_bar.network_speed_mode.upload": "仅上传",
|
||||
"settings.status_bar.network_speed_mode.download": "仅下载",
|
||||
"settings.status_bar.network_speed_transparent_background_label": "透明背景",
|
||||
"settings.status_bar.show_network_type_icon_label": "显示网络类型图标",
|
||||
"settings.status_bar.shadow_header": "状态栏阴影",
|
||||
"settings.status_bar.shadow_desc": "为状态栏添加阴影效果,使透明背景的组件更清晰。",
|
||||
"settings.status_bar.shadow_enabled_label": "启用阴影",
|
||||
"settings.status_bar.shadow_color_label": "阴影颜色",
|
||||
"settings.status_bar.shadow_opacity_label": "阴影透明度",
|
||||
"settings.status_bar.theme_header": "状态栏主题",
|
||||
"settings.status_bar.theme_desc": "独立设置状态栏的主题模式。",
|
||||
"settings.status_bar.theme_mode_label": "主题模式",
|
||||
"settings.status_bar.theme_mode.follow_global": "跟随全局",
|
||||
"settings.status_bar.theme_mode.dark": "暗色",
|
||||
"settings.status_bar.theme_mode.light": "浅色",
|
||||
"settings.components.title": "组件",
|
||||
"settings.components.description": "调整组件布局与圆角设计。",
|
||||
"settings.components.grid_header": "网格设置",
|
||||
@@ -1025,7 +1069,9 @@
|
||||
"zhijiaohub.settings.source": "图片源",
|
||||
"zhijiaohub.settings.classisland": "ClassIsland 图库",
|
||||
"zhijiaohub.settings.sectl": "SECTL 图库",
|
||||
"zhijiaohub.settings.source_desc": "选择图片来源。ClassIsland 图库包含 ClassIsland 社区的趣味瞬间,SECTL 图库包含 SECTL 社区的内容。",
|
||||
"zhijiaohub.settings.rinlit": "Rin's 图库",
|
||||
"zhijiaohub.settings.jiangtokoto": "Jiangtokoto 表情包",
|
||||
"zhijiaohub.settings.source_desc": "选择图片来源。ClassIsland 图库包含 ClassIsland 社区的趣味瞬间,SECTL 图库包含 SECTL 社区的内容,Rin's 图库包含 Rin's 社区的内容,Jiangtokoto 表情包包含丰富的表情包资源。",
|
||||
"zhijiaohub.settings.mirror_source": "镜像加速",
|
||||
"zhijiaohub.settings.mirror_direct": "直连(GitHub)",
|
||||
"zhijiaohub.settings.mirror_ghproxy": "镜像加速(推荐)",
|
||||
|
||||
@@ -112,10 +112,44 @@ public sealed class AppSettingsSnapshot
|
||||
|
||||
public bool StatusBarClockTransparentBackground { get; set; }
|
||||
|
||||
public string ClockPosition { get; set; } = "Left"; // Left, Center, Right
|
||||
|
||||
public string ClockFontSize { get; set; } = "Medium"; // Small, Medium, Large
|
||||
|
||||
public bool ShowTextCapsule { get; set; } = false;
|
||||
|
||||
public string TextCapsuleContent { get; set; } = "**Hello** World!";
|
||||
|
||||
public string TextCapsulePosition { get; set; } = "Right"; // Left, Center, Right
|
||||
|
||||
public bool TextCapsuleTransparentBackground { get; set; } = false;
|
||||
|
||||
public string TextCapsuleFontSize { get; set; } = "Medium"; // Small, Medium, Large
|
||||
|
||||
public bool ShowNetworkSpeed { get; set; } = false;
|
||||
|
||||
public string NetworkSpeedPosition { get; set; } = "Right"; // Left, Center, Right
|
||||
|
||||
public string NetworkSpeedDisplayMode { get; set; } = "Both"; // Upload, Download, Both
|
||||
|
||||
public bool NetworkSpeedTransparentBackground { get; set; } = false;
|
||||
|
||||
public bool ShowNetworkTypeIcon { get; set; } = false;
|
||||
|
||||
public string NetworkSpeedFontSize { get; set; } = "Medium"; // Small, Medium, Large
|
||||
|
||||
public string StatusBarSpacingMode { get; set; } = "Relaxed";
|
||||
|
||||
public bool StatusBarShadowEnabled { get; set; } = false;
|
||||
|
||||
public string StatusBarShadowColor { get; set; } = "#000000";
|
||||
|
||||
public double StatusBarShadowOpacity { get; set; } = 0.3;
|
||||
|
||||
public int StatusBarCustomSpacingPercent { get; set; } = 12;
|
||||
|
||||
public bool EnableThreeFingerSwipe { get; set; } = false;
|
||||
|
||||
public List<string> DisabledPluginIds { get; set; } = [];
|
||||
|
||||
#region Study Settings
|
||||
@@ -150,6 +184,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();
|
||||
|
||||
@@ -124,15 +124,83 @@ public static class ZhiJiaoHubSources
|
||||
{
|
||||
public const string ClassIsland = "classisland";
|
||||
public const string Sectl = "sectl";
|
||||
public const string RinLit = "rinlit";
|
||||
public const string Jiangtokoto = "jiangtokoto";
|
||||
|
||||
public static string Normalize(string? value)
|
||||
{
|
||||
return value?.ToLowerInvariant() switch
|
||||
{
|
||||
"sectl" => Sectl,
|
||||
"rinlit" => RinLit,
|
||||
"jiangtokoto" => Jiangtokoto,
|
||||
_ => ClassIsland
|
||||
};
|
||||
}
|
||||
|
||||
public static string GetDisplayName(string source)
|
||||
{
|
||||
return source?.ToLowerInvariant() switch
|
||||
{
|
||||
Sectl => "SECTL 图库",
|
||||
RinLit => "Rin's 图库",
|
||||
Jiangtokoto => "Jiangtokoto 表情包",
|
||||
_ => "ClassIsland 图库"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 智教Hub数据源配置
|
||||
public sealed class ZhiJiaoHubSourceConfig
|
||||
{
|
||||
public string Owner { get; init; } = string.Empty;
|
||||
public string Repo { get; init; } = string.Empty;
|
||||
public string Path { get; init; } = string.Empty;
|
||||
public string DisplayName { get; init; } = string.Empty;
|
||||
public bool UseJsonIndex { get; init; } = false;
|
||||
public string? JsonIndexPath { get; init; } = null;
|
||||
public string ApiUrl => $"https://api.github.com/repos/{Owner}/{Repo}/contents/{Path}";
|
||||
public string RawUrlTemplate => $"https://raw.githubusercontent.com/{Owner}/{Repo}/main/{Path}/{{0}}";
|
||||
public string? JsonIndexUrl => JsonIndexPath != null
|
||||
? $"https://raw.githubusercontent.com/{Owner}/{Repo}/main/{JsonIndexPath}"
|
||||
: null;
|
||||
|
||||
public static ZhiJiaoHubSourceConfig GetConfig(string source)
|
||||
{
|
||||
return source?.ToLowerInvariant() switch
|
||||
{
|
||||
ZhiJiaoHubSources.Sectl => new ZhiJiaoHubSourceConfig
|
||||
{
|
||||
Owner = "SECTL",
|
||||
Repo = "SECTL-hub",
|
||||
Path = "docs/.vuepress/public/images",
|
||||
DisplayName = "SECTL 图库"
|
||||
},
|
||||
ZhiJiaoHubSources.RinLit => new ZhiJiaoHubSourceConfig
|
||||
{
|
||||
Owner = "RinLit-233-shiroko",
|
||||
Repo = "Rin-sHub",
|
||||
Path = "updates/images",
|
||||
DisplayName = "Rin's 图库",
|
||||
UseJsonIndex = true,
|
||||
JsonIndexPath = "updates/images.json"
|
||||
},
|
||||
ZhiJiaoHubSources.Jiangtokoto => new ZhiJiaoHubSourceConfig
|
||||
{
|
||||
Owner = "unDefFtr",
|
||||
Repo = "jiangtokoto-images",
|
||||
Path = "images",
|
||||
DisplayName = "Jiangtokoto 表情包"
|
||||
},
|
||||
_ => new ZhiJiaoHubSourceConfig
|
||||
{
|
||||
Owner = "ClassIsland",
|
||||
Repo = "classisland-hub",
|
||||
Path = "images",
|
||||
DisplayName = "ClassIsland 图库"
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 智教Hub镜像加速源常量
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
195
LanMountainDesktop/Services/FusedDesktopManagerService.cs
Normal file
195
LanMountainDesktop/Services/FusedDesktopManagerService.cs
Normal file
@@ -0,0 +1,195 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using LanMountainDesktop.ComponentSystem;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
using LanMountainDesktop.Services.Settings;
|
||||
using LanMountainDesktop.Views;
|
||||
using LanMountainDesktop.Views.Components;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 融合桌面中央管理器服务接口
|
||||
/// </summary>
|
||||
public interface IFusedDesktopManagerService
|
||||
{
|
||||
void Initialize();
|
||||
void EnterEditMode();
|
||||
void ExitEditMode();
|
||||
void ReloadWidgets();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 融合桌面中央管理器服务实现。用于管理常态下的各个小窗口实体。
|
||||
/// </summary>
|
||||
internal sealed class FusedDesktopManagerService : IFusedDesktopManagerService
|
||||
{
|
||||
private readonly IFusedDesktopLayoutService _layoutService;
|
||||
private readonly ISettingsFacadeService _settingsFacade;
|
||||
private readonly Dictionary<string, DesktopWidgetWindow> _widgetWindows = [];
|
||||
|
||||
// 基础服务依赖
|
||||
private readonly IWeatherInfoService _weatherDataService;
|
||||
private readonly TimeZoneService _timeZoneService;
|
||||
private readonly IRecommendationInfoService _recommendationInfoService = new RecommendationDataService();
|
||||
private readonly ICalculatorDataService _calculatorDataService = new CalculatorDataService();
|
||||
|
||||
private ComponentRegistry? _componentRegistry;
|
||||
private DesktopComponentRuntimeRegistry? _componentRuntimeRegistry;
|
||||
private bool _isEditMode;
|
||||
|
||||
private const double DefaultCellSize = 100;
|
||||
|
||||
public FusedDesktopManagerService(
|
||||
IFusedDesktopLayoutService layoutService,
|
||||
ISettingsFacadeService settingsFacade)
|
||||
{
|
||||
_layoutService = layoutService;
|
||||
_settingsFacade = settingsFacade;
|
||||
|
||||
_weatherDataService = _settingsFacade.Weather.GetWeatherInfoService();
|
||||
_timeZoneService = _settingsFacade.Region.GetTimeZoneService();
|
||||
}
|
||||
|
||||
public void Initialize()
|
||||
{
|
||||
if (!OperatingSystem.IsWindows()) return;
|
||||
|
||||
EnsureRegistries();
|
||||
ReloadWidgets();
|
||||
}
|
||||
|
||||
private void EnsureRegistries()
|
||||
{
|
||||
if (_componentRuntimeRegistry is not null) return;
|
||||
|
||||
var pluginRuntimeService = (Application.Current as App)?.PluginRuntimeService;
|
||||
_componentRegistry = DesktopComponentRegistryFactory.Create(pluginRuntimeService);
|
||||
_componentRuntimeRegistry = DesktopComponentRegistryFactory.CreateRuntimeRegistry(
|
||||
_componentRegistry,
|
||||
pluginRuntimeService,
|
||||
_settingsFacade);
|
||||
}
|
||||
|
||||
public void EnterEditMode()
|
||||
{
|
||||
if (_isEditMode) return;
|
||||
_isEditMode = true;
|
||||
|
||||
// 隐藏所有底层小窗口
|
||||
foreach (var window in _widgetWindows.Values)
|
||||
{
|
||||
window.Hide();
|
||||
}
|
||||
}
|
||||
|
||||
public void ExitEditMode()
|
||||
{
|
||||
if (!_isEditMode) return;
|
||||
_isEditMode = false;
|
||||
|
||||
// 编辑完成,重新加载布局(可能已发生更改)并显示
|
||||
ReloadWidgets();
|
||||
}
|
||||
|
||||
public void ReloadWidgets()
|
||||
{
|
||||
if (_isEditMode) return; // 编辑模式下不渲染小窗口
|
||||
|
||||
var layout = _layoutService.Load();
|
||||
var existingIds = new HashSet<string>(_widgetWindows.Keys);
|
||||
|
||||
foreach (var placement in layout.ComponentPlacements)
|
||||
{
|
||||
existingIds.Remove(placement.PlacementId);
|
||||
|
||||
if (_widgetWindows.TryGetValue(placement.PlacementId, out var existingWindow))
|
||||
{
|
||||
// 已存在,可能只更新位置或尺寸
|
||||
existingWindow.Position = new Avalonia.PixelPoint((int)placement.X, (int)placement.Y);
|
||||
if (existingWindow.IsVisible == false)
|
||||
{
|
||||
existingWindow.Show();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// 新组件,生成窗口
|
||||
try
|
||||
{
|
||||
var window = CreateWidgetWindow(placement);
|
||||
if (window != null)
|
||||
{
|
||||
_widgetWindows[placement.PlacementId] = window;
|
||||
window.Show();
|
||||
window.Position = new Avalonia.PixelPoint((int)placement.X, (int)placement.Y);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("FusedDesktopMgr", $"Failed to render tiny window for {placement.ComponentId}", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 移除被删除的组件
|
||||
foreach (var id in existingIds)
|
||||
{
|
||||
if (_widgetWindows.Remove(id, out var windowToRemove))
|
||||
{
|
||||
windowToRemove.Close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private DesktopWidgetWindow? CreateWidgetWindow(FusedDesktopComponentPlacementSnapshot placement)
|
||||
{
|
||||
EnsureRegistries();
|
||||
if (_componentRuntimeRegistry is null || !_componentRuntimeRegistry.TryGetDescriptor(placement.ComponentId, out var descriptor))
|
||||
{
|
||||
AppLogger.Warn("FusedDesktopMgr", $"Unknown component: {placement.ComponentId}");
|
||||
return null;
|
||||
}
|
||||
|
||||
var control = descriptor.CreateControl(
|
||||
DefaultCellSize,
|
||||
_timeZoneService,
|
||||
_weatherDataService,
|
||||
_recommendationInfoService,
|
||||
_calculatorDataService,
|
||||
_settingsFacade,
|
||||
placement.PlacementId);
|
||||
|
||||
// 将组件包装到一个具有准确宽高的容器内(如果组件自身没有设置宽度)
|
||||
control.Width = placement.Width;
|
||||
control.Height = placement.Height;
|
||||
|
||||
var window = new DesktopWidgetWindow(control);
|
||||
return window;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 工厂
|
||||
/// </summary>
|
||||
public static class FusedDesktopManagerServiceFactory
|
||||
{
|
||||
private static IFusedDesktopManagerService? _instance;
|
||||
private static readonly object _lock = new();
|
||||
|
||||
public static IFusedDesktopManagerService GetOrCreate()
|
||||
{
|
||||
if (_instance is not null) return _instance;
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
var layoutService = FusedDesktopLayoutServiceProvider.GetOrCreate();
|
||||
var settings = HostSettingsFacadeProvider.GetOrCreate();
|
||||
_instance ??= new FusedDesktopManagerService(layoutService, settings);
|
||||
return _instance;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -317,11 +317,15 @@ public sealed record RecommendationApiOptions
|
||||
|
||||
public string ClassIslandHubApiUrl { get; init; } = "https://api.github.com/repos/ClassIsland/classisland-hub/contents/images";
|
||||
|
||||
public string SectlHubApiUrl { get; init; } = "https://api.github.com/repos/SECTL/SECTL-hub/contents/images";
|
||||
public string SectlHubApiUrl { get; init; } = "https://api.github.com/repos/SECTL/SECTL-hub/contents/docs/.vuepress/public/images";
|
||||
|
||||
public string RinLitHubApiUrl { get; init; } = "https://api.github.com/repos/RinLit-233-shiroko/Rin-sHub/contents/images";
|
||||
|
||||
public string ClassIslandHubRawUrlTemplate { get; init; } = "https://raw.githubusercontent.com/ClassIsland/classisland-hub/main/images/{0}";
|
||||
|
||||
public string SectlHubRawUrlTemplate { get; init; } = "https://raw.githubusercontent.com/SECTL/SECTL-hub/main/images/{0}";
|
||||
public string SectlHubRawUrlTemplate { get; init; } = "https://raw.githubusercontent.com/SECTL/SECTL-hub/main/docs/.vuepress/public/images/{0}";
|
||||
|
||||
public string RinLitHubRawUrlTemplate { get; init; } = "https://raw.githubusercontent.com/RinLit-233-shiroko/Rin-sHub/main/images/{0}";
|
||||
}
|
||||
|
||||
public interface IRecommendationInfoService
|
||||
|
||||
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
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3244,34 +3244,38 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
|
||||
|
||||
private async Task<ZhiJiaoHubSnapshot> FetchZhiJiaoHubSnapshotAsync(string source, string mirrorSource, CancellationToken cancellationToken)
|
||||
{
|
||||
var (owner, repo, path) = source switch
|
||||
{
|
||||
ZhiJiaoHubSources.Sectl => ("SECTL", "SECTL-hub", "docs/.vuepress/public/images"),
|
||||
_ => ("ClassIsland", "classisland-hub", "images")
|
||||
};
|
||||
|
||||
var contentsUrl = $"https://api.github.com/repos/{owner}/{repo}/contents/{path}";
|
||||
|
||||
// 如果使用镜像加速,代理 GitHub API 请求
|
||||
if (string.Equals(mirrorSource, ZhiJiaoHubMirrorSources.GhProxy, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
contentsUrl = ZhiJiaoHubMirrorSources.GhProxyBaseUrl.TrimEnd('/') + "/" + contentsUrl;
|
||||
}
|
||||
var config = ZhiJiaoHubSourceConfig.GetConfig(source);
|
||||
|
||||
try
|
||||
{
|
||||
var images = await FetchImagesFromContentsApi(owner, repo, path, contentsUrl, mirrorSource, cancellationToken);
|
||||
List<ZhiJiaoHubImageItem> images;
|
||||
|
||||
// 如果使用JSON索引模式(Rin's Hub)
|
||||
if (config.UseJsonIndex && !string.IsNullOrEmpty(config.JsonIndexUrl))
|
||||
{
|
||||
images = await FetchImagesFromJsonIndex(config, mirrorSource, cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
// 标准模式(ClassIsland/SECTL)
|
||||
var contentsUrl = config.ApiUrl;
|
||||
|
||||
if (string.Equals(mirrorSource, ZhiJiaoHubMirrorSources.GhProxy, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
contentsUrl = ZhiJiaoHubMirrorSources.GhProxyBaseUrl.TrimEnd('/') + "/" + contentsUrl;
|
||||
}
|
||||
|
||||
images = await FetchImagesFromContentsApi(config, contentsUrl, mirrorSource, cancellationToken);
|
||||
}
|
||||
|
||||
if (images.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("未找到图片文件");
|
||||
throw new InvalidOperationException($"在 {config.DisplayName} 中未找到图片文件");
|
||||
}
|
||||
|
||||
// 随机打乱图片顺序
|
||||
var random = new Random();
|
||||
var shuffled = images.OrderBy(_ => random.Next()).ToList();
|
||||
|
||||
// 重新设置索引
|
||||
for (int i = 0; i < shuffled.Count; i++)
|
||||
{
|
||||
var item = shuffled[i];
|
||||
@@ -3286,11 +3290,15 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new HttpRequestException($"获取图片列表失败: {ex.Message}");
|
||||
throw new HttpRequestException($"从 {config.DisplayName} 获取图片列表失败: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<List<ZhiJiaoHubImageItem>> FetchImagesFromContentsApi(string owner, string repo, string path, string contentsUrl, string mirrorSource, CancellationToken cancellationToken)
|
||||
private async Task<List<ZhiJiaoHubImageItem>> FetchImagesFromContentsApi(
|
||||
ZhiJiaoHubSourceConfig config,
|
||||
string contentsUrl,
|
||||
string mirrorSource,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var images = new List<ZhiJiaoHubImageItem>();
|
||||
|
||||
@@ -3308,7 +3316,17 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
|
||||
{
|
||||
throw new HttpRequestException("GitHub API 速率限制,请稍后重试");
|
||||
}
|
||||
throw new HttpRequestException($"API 返回错误: {(int)response.StatusCode} - {Truncate(errorText, 200)}");
|
||||
|
||||
if ((int)response.StatusCode == 404)
|
||||
{
|
||||
throw new HttpRequestException(
|
||||
$"在 {config.DisplayName} 中找不到图片目录。请检查仓库结构和路径配置。\n" +
|
||||
$"仓库: {config.Owner}/{config.Repo}\n" +
|
||||
$"路径: {config.Path}");
|
||||
}
|
||||
|
||||
throw new HttpRequestException(
|
||||
$"从 {config.DisplayName} 获取数据失败: {(int)response.StatusCode} - {Truncate(errorText, 200)}");
|
||||
}
|
||||
|
||||
var responseText = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
@@ -3320,9 +3338,9 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
|
||||
if (root.ValueKind == JsonValueKind.Object && root.TryGetProperty("message", out var messageNode))
|
||||
{
|
||||
var errorMessage = messageNode.GetString();
|
||||
throw new InvalidOperationException($"GitHub API 错误: {errorMessage}");
|
||||
throw new InvalidOperationException($"GitHub API 错误 ({config.DisplayName}): {errorMessage}");
|
||||
}
|
||||
throw new InvalidOperationException("Invalid response format from GitHub API.");
|
||||
throw new InvalidOperationException($"从 {config.DisplayName} 返回的数据格式无效");
|
||||
}
|
||||
|
||||
int index = 0;
|
||||
@@ -3342,18 +3360,15 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
|
||||
continue;
|
||||
}
|
||||
|
||||
// 只处理图片文件
|
||||
var extension = Path.GetExtension(name).ToLowerInvariant();
|
||||
if (extension != ".png" && extension != ".jpg" && extension != ".jpeg" && extension != ".gif" && extension != ".webp")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// 解码文件名
|
||||
var decodedName = Uri.UnescapeDataString(name);
|
||||
decodedName = Path.GetFileNameWithoutExtension(decodedName);
|
||||
|
||||
// 构造图片 URL
|
||||
string imageUrl;
|
||||
if (!string.IsNullOrWhiteSpace(downloadUrl))
|
||||
{
|
||||
@@ -3361,10 +3376,12 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
|
||||
}
|
||||
else
|
||||
{
|
||||
imageUrl = $"https://raw.githubusercontent.com/{owner}/{repo}/main/{path}/{Uri.EscapeDataString(name)}";
|
||||
imageUrl = string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
config.RawUrlTemplate,
|
||||
Uri.EscapeDataString(name));
|
||||
}
|
||||
|
||||
// 应用镜像加速到图片 URL
|
||||
imageUrl = ZhiJiaoHubMirrorSources.ApplyMirror(imageUrl, mirrorSource);
|
||||
|
||||
images.Add(new ZhiJiaoHubImageItem(decodedName, imageUrl, index));
|
||||
@@ -3374,6 +3391,85 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
|
||||
return images;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 从JSON索引文件获取图片列表(Rin's Hub专用)
|
||||
/// </summary>
|
||||
private async Task<List<ZhiJiaoHubImageItem>> FetchImagesFromJsonIndex(
|
||||
ZhiJiaoHubSourceConfig config,
|
||||
string mirrorSource,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var images = new List<ZhiJiaoHubImageItem>();
|
||||
|
||||
// 下载JSON索引文件
|
||||
var jsonUrl = config.JsonIndexUrl!;
|
||||
if (string.Equals(mirrorSource, ZhiJiaoHubMirrorSources.GhProxy, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
jsonUrl = ZhiJiaoHubMirrorSources.GhProxyBaseUrl.TrimEnd('/') + "/" + jsonUrl;
|
||||
}
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, jsonUrl);
|
||||
request.Headers.TryAddWithoutValidation("User-Agent", "LanMountainDesktop/1.0");
|
||||
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var jsonText = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
using var document = JsonDocument.Parse(jsonText);
|
||||
var root = document.RootElement;
|
||||
|
||||
// 解析 hub_items 数组
|
||||
if (!root.TryGetProperty("hub_items", out var hubItems) || hubItems.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
throw new InvalidOperationException($"JSON索引文件格式无效:缺少 hub_items 数组");
|
||||
}
|
||||
|
||||
int index = 0;
|
||||
foreach (var item in hubItems.EnumerateArray())
|
||||
{
|
||||
// 获取图片路径
|
||||
if (!item.TryGetProperty("image", out var imageProp))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var imagePath = imageProp.GetString();
|
||||
if (string.IsNullOrWhiteSpace(imagePath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// 获取标题(用于显示名称)
|
||||
string title = string.Empty;
|
||||
if (item.TryGetProperty("title", out var titleProp))
|
||||
{
|
||||
title = titleProp.GetString() ?? string.Empty;
|
||||
}
|
||||
|
||||
// 如果没有标题,使用文件名
|
||||
if (string.IsNullOrWhiteSpace(title))
|
||||
{
|
||||
title = Path.GetFileNameWithoutExtension(imagePath);
|
||||
}
|
||||
|
||||
// 构建完整的图片URL
|
||||
// imagePath 格式如: "Discord/姐姐好香.png"
|
||||
// 需要拼接为: https://raw.githubusercontent.com/.../updates/images/Discord/姐姐好香.png
|
||||
// 并对路径中的每个部分进行URL编码
|
||||
var pathParts = imagePath.Split('/');
|
||||
var encodedPath = string.Join("/", pathParts.Select(part => Uri.EscapeDataString(part)));
|
||||
var imageUrl = $"https://raw.githubusercontent.com/{config.Owner}/{config.Repo}/main/{config.Path}/{encodedPath}";
|
||||
|
||||
// 应用镜像加速
|
||||
imageUrl = ZhiJiaoHubMirrorSources.ApplyMirror(imageUrl, mirrorSource);
|
||||
|
||||
images.Add(new ZhiJiaoHubImageItem(title, imageUrl, index));
|
||||
index++;
|
||||
}
|
||||
|
||||
return images;
|
||||
}
|
||||
|
||||
private bool TryGetZhiJiaoHubFromCache(string cacheKey, out ZhiJiaoHubSnapshot snapshot)
|
||||
{
|
||||
lock (_cacheGate)
|
||||
|
||||
@@ -41,8 +41,31 @@ public sealed record StatusBarSettingsState(
|
||||
string TaskbarLayoutMode,
|
||||
string ClockDisplayFormat,
|
||||
bool ClockTransparentBackground,
|
||||
string ClockPosition,
|
||||
string ClockFontSize,
|
||||
bool ShowTextCapsule,
|
||||
string TextCapsuleContent,
|
||||
string TextCapsulePosition,
|
||||
bool TextCapsuleTransparentBackground,
|
||||
string TextCapsuleFontSize,
|
||||
bool ShowNetworkSpeed,
|
||||
string NetworkSpeedPosition,
|
||||
string NetworkSpeedDisplayMode,
|
||||
bool NetworkSpeedTransparentBackground,
|
||||
bool ShowNetworkTypeIcon,
|
||||
string NetworkSpeedFontSize,
|
||||
string SpacingMode,
|
||||
int CustomSpacingPercent);
|
||||
int CustomSpacingPercent,
|
||||
bool ShadowEnabled,
|
||||
string ShadowColor,
|
||||
double ShadowOpacity);
|
||||
|
||||
public sealed record TextCapsuleSettingsState(
|
||||
bool ShowTextCapsule,
|
||||
string Content,
|
||||
string Position,
|
||||
bool TransparentBackground);
|
||||
|
||||
public sealed record WeatherSettingsState(
|
||||
string LocationMode,
|
||||
string LocationKey,
|
||||
@@ -274,6 +297,12 @@ public interface IStatusBarSettingsService
|
||||
void Save(StatusBarSettingsState state);
|
||||
}
|
||||
|
||||
public interface ITextCapsuleSettingsService
|
||||
{
|
||||
TextCapsuleSettingsState Get();
|
||||
void Save(TextCapsuleSettingsState state);
|
||||
}
|
||||
|
||||
public interface IWeatherProvider
|
||||
{
|
||||
Task<WeatherQueryResult<IReadOnlyList<WeatherLocation>>> SearchLocationsAsync(
|
||||
@@ -385,6 +414,7 @@ public interface ISettingsFacadeService
|
||||
IWallpaperMediaService WallpaperMedia { get; }
|
||||
IThemeAppearanceService Theme { get; }
|
||||
IStatusBarSettingsService StatusBar { get; }
|
||||
ITextCapsuleSettingsService TextCapsule { get; }
|
||||
IWeatherSettingsService Weather { get; }
|
||||
IRegionSettingsService Region { get; }
|
||||
IPrivacySettingsService Privacy { get; }
|
||||
|
||||
@@ -386,8 +386,24 @@ internal sealed class StatusBarSettingsService : IStatusBarSettingsService
|
||||
snapshot.TaskbarLayoutMode,
|
||||
snapshot.ClockDisplayFormat,
|
||||
snapshot.StatusBarClockTransparentBackground,
|
||||
snapshot.ClockPosition,
|
||||
snapshot.ClockFontSize,
|
||||
snapshot.ShowTextCapsule,
|
||||
snapshot.TextCapsuleContent,
|
||||
snapshot.TextCapsulePosition,
|
||||
snapshot.TextCapsuleTransparentBackground,
|
||||
snapshot.TextCapsuleFontSize,
|
||||
snapshot.ShowNetworkSpeed,
|
||||
snapshot.NetworkSpeedPosition,
|
||||
snapshot.NetworkSpeedDisplayMode,
|
||||
snapshot.NetworkSpeedTransparentBackground,
|
||||
snapshot.ShowNetworkTypeIcon,
|
||||
snapshot.NetworkSpeedFontSize,
|
||||
snapshot.StatusBarSpacingMode,
|
||||
snapshot.StatusBarCustomSpacingPercent);
|
||||
snapshot.StatusBarCustomSpacingPercent,
|
||||
snapshot.StatusBarShadowEnabled,
|
||||
snapshot.StatusBarShadowColor,
|
||||
snapshot.StatusBarShadowOpacity);
|
||||
}
|
||||
|
||||
public void Save(StatusBarSettingsState state)
|
||||
@@ -399,8 +415,24 @@ internal sealed class StatusBarSettingsService : IStatusBarSettingsService
|
||||
snapshot.TaskbarLayoutMode = state.TaskbarLayoutMode;
|
||||
snapshot.ClockDisplayFormat = state.ClockDisplayFormat;
|
||||
snapshot.StatusBarClockTransparentBackground = state.ClockTransparentBackground;
|
||||
snapshot.ClockPosition = state.ClockPosition;
|
||||
snapshot.ClockFontSize = state.ClockFontSize;
|
||||
snapshot.ShowTextCapsule = state.ShowTextCapsule;
|
||||
snapshot.TextCapsuleContent = state.TextCapsuleContent;
|
||||
snapshot.TextCapsulePosition = state.TextCapsulePosition;
|
||||
snapshot.TextCapsuleTransparentBackground = state.TextCapsuleTransparentBackground;
|
||||
snapshot.TextCapsuleFontSize = state.TextCapsuleFontSize;
|
||||
snapshot.ShowNetworkSpeed = state.ShowNetworkSpeed;
|
||||
snapshot.NetworkSpeedPosition = state.NetworkSpeedPosition;
|
||||
snapshot.NetworkSpeedDisplayMode = state.NetworkSpeedDisplayMode;
|
||||
snapshot.NetworkSpeedTransparentBackground = state.NetworkSpeedTransparentBackground;
|
||||
snapshot.ShowNetworkTypeIcon = state.ShowNetworkTypeIcon;
|
||||
snapshot.NetworkSpeedFontSize = state.NetworkSpeedFontSize;
|
||||
snapshot.StatusBarSpacingMode = state.SpacingMode;
|
||||
snapshot.StatusBarCustomSpacingPercent = state.CustomSpacingPercent;
|
||||
snapshot.StatusBarShadowEnabled = state.ShadowEnabled;
|
||||
snapshot.StatusBarShadowColor = state.ShadowColor;
|
||||
snapshot.StatusBarShadowOpacity = state.ShadowOpacity;
|
||||
_settingsService.SaveSnapshot(
|
||||
SettingsScope.App,
|
||||
snapshot,
|
||||
@@ -412,8 +444,63 @@ internal sealed class StatusBarSettingsService : IStatusBarSettingsService
|
||||
nameof(AppSettingsSnapshot.TaskbarLayoutMode),
|
||||
nameof(AppSettingsSnapshot.ClockDisplayFormat),
|
||||
nameof(AppSettingsSnapshot.StatusBarClockTransparentBackground),
|
||||
nameof(AppSettingsSnapshot.ClockPosition),
|
||||
nameof(AppSettingsSnapshot.ClockFontSize),
|
||||
nameof(AppSettingsSnapshot.ShowTextCapsule),
|
||||
nameof(AppSettingsSnapshot.TextCapsuleContent),
|
||||
nameof(AppSettingsSnapshot.TextCapsulePosition),
|
||||
nameof(AppSettingsSnapshot.TextCapsuleTransparentBackground),
|
||||
nameof(AppSettingsSnapshot.TextCapsuleFontSize),
|
||||
nameof(AppSettingsSnapshot.ShowNetworkSpeed),
|
||||
nameof(AppSettingsSnapshot.NetworkSpeedPosition),
|
||||
nameof(AppSettingsSnapshot.NetworkSpeedDisplayMode),
|
||||
nameof(AppSettingsSnapshot.NetworkSpeedTransparentBackground),
|
||||
nameof(AppSettingsSnapshot.ShowNetworkTypeIcon),
|
||||
nameof(AppSettingsSnapshot.NetworkSpeedFontSize),
|
||||
nameof(AppSettingsSnapshot.StatusBarSpacingMode),
|
||||
nameof(AppSettingsSnapshot.StatusBarCustomSpacingPercent)
|
||||
nameof(AppSettingsSnapshot.StatusBarCustomSpacingPercent),
|
||||
nameof(AppSettingsSnapshot.StatusBarShadowEnabled),
|
||||
nameof(AppSettingsSnapshot.StatusBarShadowColor),
|
||||
nameof(AppSettingsSnapshot.StatusBarShadowOpacity)
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class TextCapsuleSettingsService : ITextCapsuleSettingsService
|
||||
{
|
||||
private readonly ISettingsService _settingsService;
|
||||
|
||||
public TextCapsuleSettingsService(ISettingsService settingsService)
|
||||
{
|
||||
_settingsService = settingsService ?? throw new ArgumentNullException(nameof(settingsService));
|
||||
}
|
||||
|
||||
public TextCapsuleSettingsState Get()
|
||||
{
|
||||
var snapshot = _settingsService.Load();
|
||||
return new TextCapsuleSettingsState(
|
||||
snapshot.ShowTextCapsule,
|
||||
snapshot.TextCapsuleContent,
|
||||
snapshot.TextCapsulePosition,
|
||||
snapshot.TextCapsuleTransparentBackground);
|
||||
}
|
||||
|
||||
public void Save(TextCapsuleSettingsState state)
|
||||
{
|
||||
var snapshot = _settingsService.Load();
|
||||
snapshot.ShowTextCapsule = state.ShowTextCapsule;
|
||||
snapshot.TextCapsuleContent = state.Content;
|
||||
snapshot.TextCapsulePosition = state.Position;
|
||||
snapshot.TextCapsuleTransparentBackground = state.TransparentBackground;
|
||||
_settingsService.SaveSnapshot(
|
||||
SettingsScope.App,
|
||||
snapshot,
|
||||
changedKeys:
|
||||
[
|
||||
nameof(AppSettingsSnapshot.ShowTextCapsule),
|
||||
nameof(AppSettingsSnapshot.TextCapsuleContent),
|
||||
nameof(AppSettingsSnapshot.TextCapsulePosition),
|
||||
nameof(AppSettingsSnapshot.TextCapsuleTransparentBackground)
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1198,6 +1285,7 @@ internal sealed class SettingsFacadeService : ISettingsFacadeService, IDisposabl
|
||||
WallpaperMedia = new WallpaperMediaService();
|
||||
Theme = new ThemeAppearanceService(Settings);
|
||||
StatusBar = new StatusBarSettingsService(Settings);
|
||||
TextCapsule = new TextCapsuleSettingsService(Settings);
|
||||
_weatherSettingsService = new WeatherSettingsService(Settings);
|
||||
Weather = _weatherSettingsService;
|
||||
Region = new RegionSettingsService(Settings);
|
||||
@@ -1227,6 +1315,8 @@ internal sealed class SettingsFacadeService : ISettingsFacadeService, IDisposabl
|
||||
|
||||
public IStatusBarSettingsService StatusBar { get; }
|
||||
|
||||
public ITextCapsuleSettingsService TextCapsule { get; }
|
||||
|
||||
public IWeatherSettingsService Weather { get; }
|
||||
|
||||
public IRegionSettingsService Region { get; }
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
379
LanMountainDesktop/Services/WindowPassthroughService.cs
Normal file
379
LanMountainDesktop/Services/WindowPassthroughService.cs
Normal file
@@ -0,0 +1,379 @@
|
||||
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();
|
||||
|
||||
// 记录每个窗口的屏幕原点(窗口左上角的屏幕坐标),用于将 WM_NCHITTEST 屏幕坐标转成窗口相对坐标
|
||||
private static readonly Dictionary<IntPtr, Point> _windowScreenOrigins = 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] = [];
|
||||
UpdateWindowScreenOrigin(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);
|
||||
_windowScreenOrigins.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)
|
||||
{
|
||||
// WM_NCHITTEST 的鼠标坐标在 lParam(低16位=X,高16位=Y),且为屏幕坐标
|
||||
var screenX = (short)(lParam.ToInt64() & 0xFFFF);
|
||||
var screenY = (short)((lParam.ToInt64() >> 16) & 0xFFFF);
|
||||
|
||||
lock (_staticLock)
|
||||
{
|
||||
if (_interactiveRegions.TryGetValue(hWnd, out var regions) && regions.Count > 0)
|
||||
{
|
||||
// 将屏幕坐标转为窗口相对坐标(_interactiveRegions 存的是窗口内坐标)
|
||||
_windowScreenOrigins.TryGetValue(hWnd, out var origin);
|
||||
var clientX = screenX - origin.X;
|
||||
var clientY = screenY - origin.Y;
|
||||
var point = new Point(clientX, clientY);
|
||||
|
||||
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;
|
||||
// 同步刷新屏幕原点(DPI 缩放可能影响坐标,每次更新区域时一并刷新)
|
||||
UpdateWindowScreenOrigin(handle);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新指定窗口的屏幕左上角坐标缓存(用于将 WM_NCHITTEST 屏幕坐标转为窗口相对坐标)
|
||||
/// </summary>
|
||||
private static void UpdateWindowScreenOrigin(IntPtr handle)
|
||||
{
|
||||
if (GetWindowRect(handle, out var rect))
|
||||
{
|
||||
_windowScreenOrigins[handle] = new Point(rect.Left, rect.Top);
|
||||
}
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct RECT { public int Left, Top, Right, Bottom; }
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern bool GetWindowRect(IntPtr hWnd, out RECT lpRect);
|
||||
|
||||
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,6 +1,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Avalonia.Media;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using LanMountainDesktop.ComponentSystem;
|
||||
using LanMountainDesktop.Services;
|
||||
@@ -21,6 +22,12 @@ public sealed partial class StatusBarSettingsPageViewModel : ViewModelBase
|
||||
_languageCode = _localizationService.NormalizeLanguageCode(_settingsFacade.Region.Get().LanguageCode);
|
||||
|
||||
ClockFormats = CreateClockFormats();
|
||||
ClockPositions = CreateClockPositions();
|
||||
ClockFontSizes = CreateFontSizes();
|
||||
TextCapsulePositions = CreateTextCapsulePositions();
|
||||
NetworkSpeedPositions = CreateNetworkSpeedPositions();
|
||||
NetworkSpeedDisplayModes = CreateNetworkSpeedDisplayModes();
|
||||
NetworkSpeedFontSizes = CreateFontSizes();
|
||||
SpacingModes = CreateSpacingModes();
|
||||
RefreshLocalizedText();
|
||||
|
||||
@@ -31,8 +38,20 @@ public sealed partial class StatusBarSettingsPageViewModel : ViewModelBase
|
||||
|
||||
public IReadOnlyList<SelectionOption> ClockFormats { get; }
|
||||
|
||||
public IReadOnlyList<SelectionOption> ClockPositions { get; }
|
||||
|
||||
public IReadOnlyList<SelectionOption> TextCapsulePositions { get; }
|
||||
|
||||
public IReadOnlyList<SelectionOption> NetworkSpeedPositions { get; }
|
||||
|
||||
public IReadOnlyList<SelectionOption> NetworkSpeedDisplayModes { get; }
|
||||
|
||||
public IReadOnlyList<SelectionOption> SpacingModes { get; }
|
||||
|
||||
public IReadOnlyList<SelectionOption> ClockFontSizes { get; }
|
||||
|
||||
public IReadOnlyList<SelectionOption> NetworkSpeedFontSizes { get; }
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _showClock = true;
|
||||
|
||||
@@ -42,6 +61,9 @@ public sealed partial class StatusBarSettingsPageViewModel : ViewModelBase
|
||||
[ObservableProperty]
|
||||
private bool _clockTransparentBackground;
|
||||
|
||||
[ObservableProperty]
|
||||
private SelectionOption _selectedClockPosition = new("Left", "Left");
|
||||
|
||||
[ObservableProperty]
|
||||
private SelectionOption _selectedSpacingMode = new("Relaxed", "Relaxed");
|
||||
|
||||
@@ -75,6 +97,81 @@ public sealed partial class StatusBarSettingsPageViewModel : ViewModelBase
|
||||
[ObservableProperty]
|
||||
private string _clockTransparentBackgroundDescription = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _clockPositionLabel = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private SelectionOption _selectedClockFontSize = new("Medium", "Medium");
|
||||
|
||||
[ObservableProperty]
|
||||
private string _clockFontSizeLabel = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _textCapsuleHeader = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _textCapsuleDescription = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _showTextCapsule;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _textCapsuleContent = "**Hello** World!";
|
||||
|
||||
[ObservableProperty]
|
||||
private SelectionOption _selectedTextCapsulePosition = new("Right", "Right");
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _textCapsuleTransparentBackground;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _textCapsulePositionLabel = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _textCapsuleContentLabel = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _textCapsuleTransparentBackgroundLabel = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _networkSpeedHeader = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _networkSpeedDescription = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _showNetworkSpeed;
|
||||
|
||||
[ObservableProperty]
|
||||
private SelectionOption _selectedNetworkSpeedPosition = new("Right", "Right");
|
||||
|
||||
[ObservableProperty]
|
||||
private SelectionOption _selectedNetworkSpeedDisplayMode = new("Both", "Both");
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _networkSpeedTransparentBackground;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _networkSpeedPositionLabel = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _networkSpeedDisplayModeLabel = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _networkSpeedTransparentBackgroundLabel = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _showNetworkTypeIcon;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _showNetworkTypeIconLabel = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private SelectionOption _selectedNetworkSpeedFontSize = new("Medium", "Medium");
|
||||
|
||||
[ObservableProperty]
|
||||
private string _networkSpeedFontSizeLabel = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _spacingHeader = string.Empty;
|
||||
|
||||
@@ -84,6 +181,32 @@ public sealed partial class StatusBarSettingsPageViewModel : ViewModelBase
|
||||
[ObservableProperty]
|
||||
private string _customSpacingLabel = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _statusBarShadowEnabled;
|
||||
|
||||
[ObservableProperty]
|
||||
private Color _statusBarShadowColor = Colors.Black;
|
||||
|
||||
[ObservableProperty]
|
||||
private double _statusBarShadowOpacity = 30;
|
||||
|
||||
public IBrush StatusBarShadowColorBrush => new SolidColorBrush(StatusBarShadowColor);
|
||||
|
||||
[ObservableProperty]
|
||||
private string _statusBarShadowHeader = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _statusBarShadowDescription = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _statusBarShadowEnabledLabel = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _statusBarShadowColorLabel = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _statusBarShadowOpacityLabel = string.Empty;
|
||||
|
||||
public void Load()
|
||||
{
|
||||
var state = _settingsFacade.StatusBar.Get();
|
||||
@@ -99,12 +222,59 @@ public sealed partial class StatusBarSettingsPageViewModel : ViewModelBase
|
||||
?? ClockFormats[1];
|
||||
ClockTransparentBackground = state.ClockTransparentBackground;
|
||||
|
||||
var clockPosition = NormalizeClockPosition(state.ClockPosition);
|
||||
SelectedClockPosition = ClockPositions.FirstOrDefault(option =>
|
||||
string.Equals(option.Value, clockPosition, StringComparison.OrdinalIgnoreCase))
|
||||
?? ClockPositions[0];
|
||||
|
||||
// 时钟字体大小设置
|
||||
var clockFontSize = NormalizeFontSize(state.ClockFontSize);
|
||||
SelectedClockFontSize = ClockFontSizes.FirstOrDefault(option =>
|
||||
string.Equals(option.Value, clockFontSize, StringComparison.OrdinalIgnoreCase))
|
||||
?? ClockFontSizes[1]; // 默认中等
|
||||
|
||||
// 文字胶囊设置
|
||||
ShowTextCapsule = state.ShowTextCapsule;
|
||||
TextCapsuleContent = state.TextCapsuleContent ?? "**Hello** World!";
|
||||
var textCapsulePosition = NormalizeTextCapsulePosition(state.TextCapsulePosition);
|
||||
SelectedTextCapsulePosition = TextCapsulePositions.FirstOrDefault(option =>
|
||||
string.Equals(option.Value, textCapsulePosition, StringComparison.OrdinalIgnoreCase))
|
||||
?? TextCapsulePositions[2]; // 默认靠右
|
||||
TextCapsuleTransparentBackground = state.TextCapsuleTransparentBackground;
|
||||
|
||||
// 网速设置
|
||||
ShowNetworkSpeed = state.ShowNetworkSpeed;
|
||||
var networkSpeedPosition = NormalizeNetworkSpeedPosition(state.NetworkSpeedPosition);
|
||||
SelectedNetworkSpeedPosition = NetworkSpeedPositions.FirstOrDefault(option =>
|
||||
string.Equals(option.Value, networkSpeedPosition, StringComparison.OrdinalIgnoreCase))
|
||||
?? NetworkSpeedPositions[2]; // 默认靠右
|
||||
var networkSpeedDisplayMode = NormalizeNetworkSpeedDisplayMode(state.NetworkSpeedDisplayMode);
|
||||
SelectedNetworkSpeedDisplayMode = NetworkSpeedDisplayModes.FirstOrDefault(option =>
|
||||
string.Equals(option.Value, networkSpeedDisplayMode, StringComparison.OrdinalIgnoreCase))
|
||||
?? NetworkSpeedDisplayModes[0]; // 默认双向
|
||||
NetworkSpeedTransparentBackground = state.NetworkSpeedTransparentBackground;
|
||||
ShowNetworkTypeIcon = state.ShowNetworkTypeIcon;
|
||||
|
||||
// 网速字体大小设置
|
||||
var networkSpeedFontSize = NormalizeFontSize(state.NetworkSpeedFontSize);
|
||||
SelectedNetworkSpeedFontSize = NetworkSpeedFontSizes.FirstOrDefault(option =>
|
||||
string.Equals(option.Value, networkSpeedFontSize, StringComparison.OrdinalIgnoreCase))
|
||||
?? NetworkSpeedFontSizes[1]; // 默认中等
|
||||
|
||||
var spacingMode = NormalizeSpacingMode(state.SpacingMode);
|
||||
SelectedSpacingMode = SpacingModes.FirstOrDefault(option =>
|
||||
string.Equals(option.Value, spacingMode, StringComparison.OrdinalIgnoreCase))
|
||||
?? SpacingModes[1];
|
||||
CustomSpacingPercent = Math.Clamp(state.CustomSpacingPercent, 0, 30);
|
||||
IsCustomSpacingVisible = string.Equals(SelectedSpacingMode.Value, "Custom", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
// 状态栏阴影设置
|
||||
StatusBarShadowEnabled = state.ShadowEnabled;
|
||||
if (Color.TryParse(state.ShadowColor, out var shadowColor))
|
||||
{
|
||||
StatusBarShadowColor = shadowColor;
|
||||
}
|
||||
StatusBarShadowOpacity = Math.Clamp(state.ShadowOpacity * 100, 0, 100);
|
||||
}
|
||||
|
||||
partial void OnShowClockChanged(bool value)
|
||||
@@ -137,6 +307,126 @@ public sealed partial class StatusBarSettingsPageViewModel : ViewModelBase
|
||||
Save();
|
||||
}
|
||||
|
||||
partial void OnSelectedClockPositionChanged(SelectionOption value)
|
||||
{
|
||||
if (_isInitializing || value is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Save();
|
||||
}
|
||||
|
||||
partial void OnSelectedClockFontSizeChanged(SelectionOption value)
|
||||
{
|
||||
if (_isInitializing || value is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Save();
|
||||
}
|
||||
|
||||
partial void OnShowTextCapsuleChanged(bool value)
|
||||
{
|
||||
if (_isInitializing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Save();
|
||||
}
|
||||
|
||||
partial void OnTextCapsuleContentChanged(string value)
|
||||
{
|
||||
if (_isInitializing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Save();
|
||||
}
|
||||
|
||||
partial void OnSelectedTextCapsulePositionChanged(SelectionOption value)
|
||||
{
|
||||
if (_isInitializing || value is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Save();
|
||||
}
|
||||
|
||||
partial void OnTextCapsuleTransparentBackgroundChanged(bool value)
|
||||
{
|
||||
if (_isInitializing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Save();
|
||||
}
|
||||
|
||||
partial void OnShowNetworkSpeedChanged(bool value)
|
||||
{
|
||||
if (_isInitializing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Save();
|
||||
}
|
||||
|
||||
partial void OnSelectedNetworkSpeedPositionChanged(SelectionOption value)
|
||||
{
|
||||
if (_isInitializing || value is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Save();
|
||||
}
|
||||
|
||||
partial void OnSelectedNetworkSpeedDisplayModeChanged(SelectionOption value)
|
||||
{
|
||||
if (_isInitializing || value is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Save();
|
||||
}
|
||||
|
||||
partial void OnNetworkSpeedTransparentBackgroundChanged(bool value)
|
||||
{
|
||||
if (_isInitializing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Save();
|
||||
}
|
||||
|
||||
partial void OnShowNetworkTypeIconChanged(bool value)
|
||||
{
|
||||
if (_isInitializing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Save();
|
||||
}
|
||||
|
||||
partial void OnSelectedNetworkSpeedFontSizeChanged(SelectionOption value)
|
||||
{
|
||||
if (_isInitializing || value is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Save();
|
||||
}
|
||||
|
||||
partial void OnSelectedSpacingModeChanged(SelectionOption value)
|
||||
{
|
||||
IsCustomSpacingVisible = string.Equals(value?.Value, "Custom", StringComparison.OrdinalIgnoreCase);
|
||||
@@ -165,6 +455,37 @@ public sealed partial class StatusBarSettingsPageViewModel : ViewModelBase
|
||||
Save();
|
||||
}
|
||||
|
||||
partial void OnStatusBarShadowEnabledChanged(bool value)
|
||||
{
|
||||
if (_isInitializing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Save();
|
||||
}
|
||||
|
||||
partial void OnStatusBarShadowColorChanged(Color value)
|
||||
{
|
||||
OnPropertyChanged(nameof(StatusBarShadowColorBrush));
|
||||
if (_isInitializing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Save();
|
||||
}
|
||||
|
||||
partial void OnStatusBarShadowOpacityChanged(double value)
|
||||
{
|
||||
if (_isInitializing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Save();
|
||||
}
|
||||
|
||||
private void Save()
|
||||
{
|
||||
var state = _settingsFacade.StatusBar.Get();
|
||||
@@ -184,8 +505,24 @@ public sealed partial class StatusBarSettingsPageViewModel : ViewModelBase
|
||||
state.TaskbarLayoutMode,
|
||||
SelectedClockFormat.Value,
|
||||
ClockTransparentBackground,
|
||||
SelectedClockPosition.Value,
|
||||
SelectedClockFontSize?.Value ?? "Medium",
|
||||
ShowTextCapsule,
|
||||
TextCapsuleContent ?? "**Hello** World!",
|
||||
SelectedTextCapsulePosition?.Value ?? "Right",
|
||||
TextCapsuleTransparentBackground,
|
||||
"Medium", // TextCapsuleFontSize - 暂时使用默认值
|
||||
ShowNetworkSpeed,
|
||||
SelectedNetworkSpeedPosition?.Value ?? "Right",
|
||||
SelectedNetworkSpeedDisplayMode?.Value ?? "Both",
|
||||
NetworkSpeedTransparentBackground,
|
||||
ShowNetworkTypeIcon,
|
||||
SelectedNetworkSpeedFontSize?.Value ?? "Medium",
|
||||
NormalizeSpacingMode(SelectedSpacingMode.Value),
|
||||
Math.Clamp(CustomSpacingPercent, 0, 30)));
|
||||
Math.Clamp(CustomSpacingPercent, 0, 30),
|
||||
StatusBarShadowEnabled,
|
||||
StatusBarShadowColor.ToString(),
|
||||
StatusBarShadowOpacity / 100.0));
|
||||
}
|
||||
|
||||
private IReadOnlyList<SelectionOption> CreateClockFormats()
|
||||
@@ -197,6 +534,46 @@ public sealed partial class StatusBarSettingsPageViewModel : ViewModelBase
|
||||
];
|
||||
}
|
||||
|
||||
private IReadOnlyList<SelectionOption> CreateClockPositions()
|
||||
{
|
||||
return
|
||||
[
|
||||
new SelectionOption("Left", L("settings.status_bar.clock_position.left", "Left")),
|
||||
new SelectionOption("Center", L("settings.status_bar.clock_position.center", "Center")),
|
||||
new SelectionOption("Right", L("settings.status_bar.clock_position.right", "Right"))
|
||||
];
|
||||
}
|
||||
|
||||
private IReadOnlyList<SelectionOption> CreateTextCapsulePositions()
|
||||
{
|
||||
return
|
||||
[
|
||||
new SelectionOption("Left", L("settings.status_bar.text_capsule_position.left", "Left")),
|
||||
new SelectionOption("Center", L("settings.status_bar.text_capsule_position.center", "Center")),
|
||||
new SelectionOption("Right", L("settings.status_bar.text_capsule_position.right", "Right"))
|
||||
];
|
||||
}
|
||||
|
||||
private IReadOnlyList<SelectionOption> CreateNetworkSpeedPositions()
|
||||
{
|
||||
return
|
||||
[
|
||||
new SelectionOption("Left", L("settings.status_bar.network_speed_position.left", "Left")),
|
||||
new SelectionOption("Center", L("settings.status_bar.network_speed_position.center", "Center")),
|
||||
new SelectionOption("Right", L("settings.status_bar.network_speed_position.right", "Right"))
|
||||
];
|
||||
}
|
||||
|
||||
private IReadOnlyList<SelectionOption> CreateNetworkSpeedDisplayModes()
|
||||
{
|
||||
return
|
||||
[
|
||||
new SelectionOption("Both", L("settings.status_bar.network_speed_mode.both", "Upload + Download")),
|
||||
new SelectionOption("Upload", L("settings.status_bar.network_speed_mode.upload", "Upload only")),
|
||||
new SelectionOption("Download", L("settings.status_bar.network_speed_mode.download", "Download only"))
|
||||
];
|
||||
}
|
||||
|
||||
private IReadOnlyList<SelectionOption> CreateSpacingModes()
|
||||
{
|
||||
return
|
||||
@@ -217,9 +594,28 @@ public sealed partial class StatusBarSettingsPageViewModel : ViewModelBase
|
||||
ClockFormatLabel = L("settings.status_bar.clock_format_label", "Clock format");
|
||||
ClockTransparentBackgroundLabel = L("settings.status_bar.clock_transparent_background_label", "Transparent background");
|
||||
ClockTransparentBackgroundDescription = L("settings.status_bar.clock_transparent_background_desc", "Remove the capsule background and keep only the clock text.");
|
||||
ClockPositionLabel = L("settings.status_bar.clock_position_label", "Clock position");
|
||||
ClockFontSizeLabel = L("settings.status_bar.clock_font_size_label", "Font size");
|
||||
TextCapsuleHeader = L("settings.status_bar.text_capsule_header", "Text Capsule");
|
||||
TextCapsuleDescription = L("settings.status_bar.text_capsule_description", "Display custom text with Markdown support on the status bar.");
|
||||
TextCapsulePositionLabel = L("settings.status_bar.text_capsule_position_label", "Text capsule position");
|
||||
TextCapsuleContentLabel = L("settings.status_bar.text_capsule_content_label", "Text content (Markdown supported)");
|
||||
TextCapsuleTransparentBackgroundLabel = L("settings.status_bar.text_capsule_transparent_background_label", "Transparent background");
|
||||
NetworkSpeedHeader = L("settings.status_bar.network_speed_header", "Network Speed");
|
||||
NetworkSpeedDescription = L("settings.status_bar.network_speed_description", "Display real-time network upload and download speed.");
|
||||
NetworkSpeedPositionLabel = L("settings.status_bar.network_speed_position_label", "Network speed position");
|
||||
NetworkSpeedDisplayModeLabel = L("settings.status_bar.network_speed_mode_label", "Display mode");
|
||||
NetworkSpeedTransparentBackgroundLabel = L("settings.status_bar.network_speed_transparent_background_label", "Transparent background");
|
||||
ShowNetworkTypeIconLabel = L("settings.status_bar.show_network_type_icon_label", "Show network type icon");
|
||||
NetworkSpeedFontSizeLabel = L("settings.status_bar.network_speed_font_size_label", "Font size");
|
||||
SpacingHeader = L("settings.status_bar.spacing_header", "Component Spacing");
|
||||
SpacingDescription = L("settings.status_bar.spacing_desc", "Adjust spacing between status bar components.");
|
||||
CustomSpacingLabel = L("settings.status_bar.spacing_custom_label", "Custom spacing (%)");
|
||||
StatusBarShadowHeader = L("settings.status_bar.shadow_header", "Status Bar Shadow");
|
||||
StatusBarShadowDescription = L("settings.status_bar.shadow_desc", "Add shadow effect to the status bar for better visibility.");
|
||||
StatusBarShadowEnabledLabel = L("settings.status_bar.shadow_enabled_label", "Enable shadow");
|
||||
StatusBarShadowColorLabel = L("settings.status_bar.shadow_color_label", "Shadow color");
|
||||
StatusBarShadowOpacityLabel = L("settings.status_bar.shadow_opacity_label", "Shadow opacity");
|
||||
}
|
||||
|
||||
private string NormalizeSpacingMode(string? value)
|
||||
@@ -232,6 +628,66 @@ public sealed partial class StatusBarSettingsPageViewModel : ViewModelBase
|
||||
};
|
||||
}
|
||||
|
||||
private static string NormalizeClockPosition(string? value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
_ when string.Equals(value, "Center", StringComparison.OrdinalIgnoreCase) => "Center",
|
||||
_ when string.Equals(value, "Right", StringComparison.OrdinalIgnoreCase) => "Right",
|
||||
_ => "Left"
|
||||
};
|
||||
}
|
||||
|
||||
private static string NormalizeTextCapsulePosition(string? value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
_ when string.Equals(value, "Left", StringComparison.OrdinalIgnoreCase) => "Left",
|
||||
_ when string.Equals(value, "Center", StringComparison.OrdinalIgnoreCase) => "Center",
|
||||
_ => "Right"
|
||||
};
|
||||
}
|
||||
|
||||
private static string NormalizeNetworkSpeedPosition(string? value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
_ when string.Equals(value, "Left", StringComparison.OrdinalIgnoreCase) => "Left",
|
||||
_ when string.Equals(value, "Center", StringComparison.OrdinalIgnoreCase) => "Center",
|
||||
_ => "Right"
|
||||
};
|
||||
}
|
||||
|
||||
private static string NormalizeNetworkSpeedDisplayMode(string? value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
_ when string.Equals(value, "Upload", StringComparison.OrdinalIgnoreCase) => "Upload",
|
||||
_ when string.Equals(value, "Download", StringComparison.OrdinalIgnoreCase) => "Download",
|
||||
_ => "Both"
|
||||
};
|
||||
}
|
||||
|
||||
private static string NormalizeFontSize(string? value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
_ when string.Equals(value, "Small", StringComparison.OrdinalIgnoreCase) => "Small",
|
||||
_ when string.Equals(value, "Large", StringComparison.OrdinalIgnoreCase) => "Large",
|
||||
_ => "Medium"
|
||||
};
|
||||
}
|
||||
|
||||
private IReadOnlyList<SelectionOption> CreateFontSizes()
|
||||
{
|
||||
return
|
||||
[
|
||||
new SelectionOption("Small", L("settings.status_bar.font_size.small", "Small")),
|
||||
new SelectionOption("Medium", L("settings.status_bar.font_size.medium", "Medium")),
|
||||
new SelectionOption("Large", L("settings.status_bar.font_size.large", "Large"))
|
||||
];
|
||||
}
|
||||
|
||||
private string L(string key, string fallback)
|
||||
=> _localizationService.GetString(_languageCode, key, fallback);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,12 @@
|
||||
<ComboBoxItem x:Name="SectlItem"
|
||||
Classes="component-editor-select-item"
|
||||
Tag="sectl" />
|
||||
<ComboBoxItem x:Name="RinLitItem"
|
||||
Classes="component-editor-select-item"
|
||||
Tag="rinlit" />
|
||||
<ComboBoxItem x:Name="JiangtokotoItem"
|
||||
Classes="component-editor-select-item"
|
||||
Tag="jiangtokoto" />
|
||||
</ComboBox>
|
||||
<TextBlock x:Name="SourceDescriptionTextBlock"
|
||||
Classes="component-editor-secondary-text"
|
||||
|
||||
@@ -29,10 +29,12 @@ public partial class ZhiJiaoHubComponentEditor : ComponentEditorViewBase
|
||||
SourceLabelTextBlock.Text = L("zhijiaohub.settings.source", "图片源");
|
||||
ClassIslandItem.Content = L("zhijiaohub.settings.classisland", "ClassIsland 图库");
|
||||
SectlItem.Content = L("zhijiaohub.settings.sectl", "SECTL 图库");
|
||||
RinLitItem.Content = L("zhijiaohub.settings.rinlit", "Rin's 图库");
|
||||
JiangtokotoItem.Content = L("zhijiaohub.settings.jiangtokoto", "Jiangtokoto 表情包");
|
||||
|
||||
// 数据源描述
|
||||
SourceDescriptionTextBlock.Text = L("zhijiaohub.settings.source_desc",
|
||||
"选择图片来源。ClassIsland 图库包含 ClassIsland 社区的趣味瞬间,SECTL 图库包含 SECTL 社区的内容。");
|
||||
"选择图片来源。ClassIsland 图库包含 ClassIsland 社区的趣味瞬间,SECTL 图库包含 SECTL 社区的内容,Rin's 图库包含 Rin's 社区的内容,Jiangtokoto 表情包包含丰富的表情包资源。");
|
||||
|
||||
// 镜像加速源
|
||||
MirrorSourceLabelTextBlock.Text = L("zhijiaohub.settings.mirror_source", "镜像加速");
|
||||
@@ -65,6 +67,8 @@ public partial class ZhiJiaoHubComponentEditor : ComponentEditorViewBase
|
||||
SourceComboBox.SelectedItem = source switch
|
||||
{
|
||||
ZhiJiaoHubSources.Sectl => SectlItem,
|
||||
ZhiJiaoHubSources.RinLit => RinLitItem,
|
||||
ZhiJiaoHubSources.Jiangtokoto => JiangtokotoItem,
|
||||
_ => ClassIslandItem
|
||||
};
|
||||
|
||||
|
||||
@@ -220,7 +220,7 @@ public partial class ComponentLibraryWindow : Window
|
||||
|
||||
if (string.Equals(categoryId, "Info", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return Symbol.Apps;
|
||||
return Symbol.Info;
|
||||
}
|
||||
|
||||
if (string.Equals(categoryId, "Calculator", StringComparison.OrdinalIgnoreCase))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
@@ -25,6 +25,7 @@ public partial class ClockWidget : UserControl, IDesktopComponentWidget, ITimeZo
|
||||
private ClockDisplayFormat _displayFormat = ClockDisplayFormat.HourMinuteSecond;
|
||||
private bool _transparentBackground;
|
||||
private double _lastAppliedCellSize = 100;
|
||||
private string _fontSize = "Medium"; // Small, Medium, Large
|
||||
|
||||
public ClockWidget()
|
||||
{
|
||||
@@ -72,6 +73,21 @@ public partial class ClockWidget : UserControl, IDesktopComponentWidget, ITimeZo
|
||||
TransparentBackground = transparentBackground;
|
||||
}
|
||||
|
||||
public string WidgetFontSize
|
||||
{
|
||||
get => _fontSize;
|
||||
set
|
||||
{
|
||||
_fontSize = value;
|
||||
ApplyCellSize(_lastAppliedCellSize);
|
||||
}
|
||||
}
|
||||
|
||||
public void SetFontSize(string fontSize)
|
||||
{
|
||||
WidgetFontSize = fontSize;
|
||||
}
|
||||
|
||||
public void SetTimeZoneService(TimeZoneService timeZoneService)
|
||||
{
|
||||
ClearTimeZoneService();
|
||||
@@ -138,7 +154,14 @@ public partial class ClockWidget : UserControl, IDesktopComponentWidget, ITimeZo
|
||||
|
||||
// 3. 核心:满盈字阶 (Filled Typography)
|
||||
// 使主时间文字占据容器高度的 ~68%,产生饱满的视觉张力
|
||||
var mainFontSize = targetHeight * 0.68;
|
||||
// 根据字体大小设置调整基础大小
|
||||
var fontSizeMultiplier = _fontSize switch
|
||||
{
|
||||
"Small" => 0.55,
|
||||
"Large" => 0.85,
|
||||
_ => 0.68 // Medium (default)
|
||||
};
|
||||
var mainFontSize = targetHeight * fontSizeMultiplier;
|
||||
MainTimeTextBlock.FontSize = mainFontSize;
|
||||
MainTimeTextBlock.FontWeight = FontWeight.SemiBold;
|
||||
|
||||
|
||||
72
LanMountainDesktop/Views/Components/NetworkSpeedWidget.axaml
Normal file
72
LanMountainDesktop/Views/Components/NetworkSpeedWidget.axaml
Normal file
@@ -0,0 +1,72 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:fi="using:FluentIcons.Avalonia"
|
||||
mc:Ignorable="d"
|
||||
d:DesignWidth="160"
|
||||
d:DesignHeight="48"
|
||||
x:Class="LanMountainDesktop.Views.Components.NetworkSpeedWidget">
|
||||
|
||||
<Border x:Name="RootBorder"
|
||||
Classes="surface-translucent-panel"
|
||||
Padding="0"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusComponent}">
|
||||
<StackPanel Orientation="Horizontal"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Margin="12,0">
|
||||
<!-- 上传速度 -->
|
||||
<StackPanel x:Name="UploadPanel"
|
||||
Orientation="Horizontal"
|
||||
VerticalAlignment="Center">
|
||||
<TextBlock Text="↑"
|
||||
FontSize="12"
|
||||
Opacity="0.7"
|
||||
VerticalAlignment="Center"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"/>
|
||||
<TextBlock x:Name="UploadSpeedTextBlock"
|
||||
FontSize="14"
|
||||
FontWeight="SemiBold"
|
||||
Margin="2,0,0,0"
|
||||
VerticalAlignment="Center"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- 分隔符 -->
|
||||
<Rectangle x:Name="Separator"
|
||||
Width="1"
|
||||
Height="16"
|
||||
Margin="8,0"
|
||||
Opacity="0.3"
|
||||
Fill="{DynamicResource AdaptiveTextSecondaryBrush}"/>
|
||||
|
||||
<!-- 下载速度 -->
|
||||
<StackPanel x:Name="DownloadPanel"
|
||||
Orientation="Horizontal"
|
||||
VerticalAlignment="Center">
|
||||
<TextBlock Text="↓"
|
||||
FontSize="12"
|
||||
Opacity="0.7"
|
||||
VerticalAlignment="Center"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"/>
|
||||
<TextBlock x:Name="DownloadSpeedTextBlock"
|
||||
FontSize="14"
|
||||
FontWeight="SemiBold"
|
||||
Margin="2,0,0,0"
|
||||
VerticalAlignment="Center"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- 网络类型图标 -->
|
||||
<fi:SymbolIcon x:Name="NetworkTypeIcon"
|
||||
Symbol="Globe"
|
||||
FontSize="14"
|
||||
Margin="8,0,0,0"
|
||||
Opacity="0.8"
|
||||
IsVisible="False"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
</UserControl>
|
||||
451
LanMountainDesktop/Views/Components/NetworkSpeedWidget.axaml.cs
Normal file
451
LanMountainDesktop/Views/Components/NetworkSpeedWidget.axaml.cs
Normal file
@@ -0,0 +1,451 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Net.NetworkInformation;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Threading;
|
||||
using FluentIcons.Avalonia;
|
||||
using FluentIcons.Common;
|
||||
using LanMountainDesktop.Services;
|
||||
using Symbol = FluentIcons.Common.Symbol;
|
||||
|
||||
namespace LanMountainDesktop.Views.Components;
|
||||
|
||||
public partial class NetworkSpeedWidget : UserControl, IDesktopComponentWidget
|
||||
{
|
||||
private readonly DispatcherTimer _timer = new();
|
||||
private readonly DispatcherTimer _networkTypeTimer = new();
|
||||
private NetworkInterface? _selectedInterface;
|
||||
private long _lastBytesReceived;
|
||||
private long _lastBytesSent;
|
||||
private bool _isFirstUpdate = true;
|
||||
private double _lastAppliedCellSize = 100;
|
||||
private bool _transparentBackground;
|
||||
private string _displayMode = "Both"; // "Upload", "Download", "Both"
|
||||
private bool _showNetworkTypeIcon;
|
||||
private string _fontSize = "Medium"; // Small, Medium, Large
|
||||
|
||||
public NetworkSpeedWidget()
|
||||
{
|
||||
InitializeComponent();
|
||||
SetupTimer();
|
||||
SelectBestInterface();
|
||||
UpdateDisplayMode();
|
||||
UpdateNetworkTypeIcon();
|
||||
}
|
||||
|
||||
public string DisplayMode
|
||||
{
|
||||
get => _displayMode;
|
||||
set
|
||||
{
|
||||
if (_displayMode == value) return;
|
||||
_displayMode = value;
|
||||
UpdateDisplayMode();
|
||||
}
|
||||
}
|
||||
|
||||
public bool TransparentBackground
|
||||
{
|
||||
get => _transparentBackground;
|
||||
set
|
||||
{
|
||||
if (_transparentBackground == value) return;
|
||||
_transparentBackground = value;
|
||||
ApplyChrome();
|
||||
ApplyCellSize(_lastAppliedCellSize);
|
||||
}
|
||||
}
|
||||
|
||||
public bool ShowNetworkTypeIcon
|
||||
{
|
||||
get => _showNetworkTypeIcon;
|
||||
set
|
||||
{
|
||||
if (_showNetworkTypeIcon == value) return;
|
||||
_showNetworkTypeIcon = value;
|
||||
UpdateNetworkTypeIcon();
|
||||
}
|
||||
}
|
||||
|
||||
public void SetDisplayMode(string mode)
|
||||
{
|
||||
DisplayMode = mode;
|
||||
}
|
||||
|
||||
public void SetTransparentBackground(bool transparent)
|
||||
{
|
||||
TransparentBackground = transparent;
|
||||
}
|
||||
|
||||
public void SetShowNetworkTypeIcon(bool show)
|
||||
{
|
||||
ShowNetworkTypeIcon = show;
|
||||
}
|
||||
|
||||
public string WidgetFontSize
|
||||
{
|
||||
get => _fontSize;
|
||||
set
|
||||
{
|
||||
_fontSize = value;
|
||||
ApplyCellSize(_lastAppliedCellSize);
|
||||
}
|
||||
}
|
||||
|
||||
public void SetFontSize(string fontSize)
|
||||
{
|
||||
WidgetFontSize = fontSize;
|
||||
}
|
||||
|
||||
private void SetupTimer()
|
||||
{
|
||||
// 网速更新定时器(每秒)
|
||||
_timer.Interval = TimeSpan.FromSeconds(1);
|
||||
_timer.Tick += (_, _) => UpdateSpeed();
|
||||
_timer.Start();
|
||||
|
||||
// 网络类型检测定时器(每500ms,满足响应延迟要求)
|
||||
_networkTypeTimer.Interval = TimeSpan.FromMilliseconds(500);
|
||||
_networkTypeTimer.Tick += (_, _) => UpdateNetworkTypeIcon();
|
||||
_networkTypeTimer.Start();
|
||||
}
|
||||
|
||||
private void SelectBestInterface()
|
||||
{
|
||||
try
|
||||
{
|
||||
var interfaces = NetworkInterface.GetAllNetworkInterfaces()
|
||||
.Where(ni => ni.OperationalStatus == OperationalStatus.Up)
|
||||
.Where(ni => ni.NetworkInterfaceType != NetworkInterfaceType.Loopback)
|
||||
.Where(ni => ni.NetworkInterfaceType != NetworkInterfaceType.Tunnel)
|
||||
.Where(ni => !ni.Description.Contains("Virtual", StringComparison.OrdinalIgnoreCase))
|
||||
.Where(ni => !ni.Description.Contains("VPN", StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
|
||||
// 优先选择有流量的物理网卡
|
||||
_selectedInterface = interfaces
|
||||
.OrderByDescending(ni => ni.GetIPv4Statistics().BytesReceived + ni.GetIPv4Statistics().BytesSent)
|
||||
.FirstOrDefault();
|
||||
|
||||
// 如果没有找到,选择第一个活动的非虚拟网卡
|
||||
_selectedInterface ??= interfaces.FirstOrDefault();
|
||||
|
||||
if (_selectedInterface != null)
|
||||
{
|
||||
var stats = _selectedInterface.GetIPv4Statistics();
|
||||
_lastBytesReceived = stats.BytesReceived;
|
||||
_lastBytesSent = stats.BytesSent;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 忽略错误,下次重试
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateSpeed()
|
||||
{
|
||||
try
|
||||
{
|
||||
// 如果当前网卡不可用,尝试重新选择
|
||||
if (_selectedInterface == null ||
|
||||
_selectedInterface.OperationalStatus != OperationalStatus.Up)
|
||||
{
|
||||
SelectBestInterface();
|
||||
}
|
||||
|
||||
if (_selectedInterface == null)
|
||||
{
|
||||
UploadSpeedTextBlock.Text = "--";
|
||||
DownloadSpeedTextBlock.Text = "--";
|
||||
return;
|
||||
}
|
||||
|
||||
var stats = _selectedInterface.GetIPv4Statistics();
|
||||
var currentBytesReceived = stats.BytesReceived;
|
||||
var currentBytesSent = stats.BytesSent;
|
||||
|
||||
if (_isFirstUpdate)
|
||||
{
|
||||
_lastBytesReceived = currentBytesReceived;
|
||||
_lastBytesSent = currentBytesSent;
|
||||
_isFirstUpdate = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// 计算速度(每秒字节数)
|
||||
var downloadBytes = currentBytesReceived - _lastBytesReceived;
|
||||
var uploadBytes = currentBytesSent - _lastBytesSent;
|
||||
|
||||
// 处理计数器重置的情况
|
||||
if (downloadBytes < 0) downloadBytes = 0;
|
||||
if (uploadBytes < 0) uploadBytes = 0;
|
||||
|
||||
UploadSpeedTextBlock.Text = FormatSpeed(uploadBytes);
|
||||
DownloadSpeedTextBlock.Text = FormatSpeed(downloadBytes);
|
||||
|
||||
_lastBytesReceived = currentBytesReceived;
|
||||
_lastBytesSent = currentBytesSent;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 错误时显示 --
|
||||
UploadSpeedTextBlock.Text = "--";
|
||||
DownloadSpeedTextBlock.Text = "--";
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateNetworkTypeIcon()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!_showNetworkTypeIcon || NetworkTypeIcon == null)
|
||||
{
|
||||
if (NetworkTypeIcon != null)
|
||||
NetworkTypeIcon.IsVisible = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取当前活动的网络接口
|
||||
var activeInterface = GetActiveNetworkInterface();
|
||||
|
||||
if (activeInterface == null)
|
||||
{
|
||||
// 无网络连接
|
||||
NetworkTypeIcon.Symbol = Symbol.DismissCircle;
|
||||
NetworkTypeIcon.IsVisible = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// 根据网络类型设置图标
|
||||
switch (activeInterface.NetworkInterfaceType)
|
||||
{
|
||||
case NetworkInterfaceType.Wireless80211:
|
||||
// WiFi
|
||||
NetworkTypeIcon.Symbol = Symbol.WiFi;
|
||||
break;
|
||||
|
||||
case NetworkInterfaceType.Ethernet:
|
||||
// 有线网络 - 检查是否是移动网络热点
|
||||
if (IsLikelyMobileHotspot(activeInterface))
|
||||
{
|
||||
NetworkTypeIcon.Symbol = Symbol.Phone;
|
||||
}
|
||||
else
|
||||
{
|
||||
NetworkTypeIcon.Symbol = Symbol.PlugConnected;
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
// 其他类型,尝试根据描述判断
|
||||
var symbol = GetSymbolFromDescription(activeInterface.Description);
|
||||
NetworkTypeIcon.Symbol = symbol;
|
||||
break;
|
||||
}
|
||||
|
||||
NetworkTypeIcon.IsVisible = true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 错误时隐藏图标
|
||||
if (NetworkTypeIcon != null)
|
||||
NetworkTypeIcon.IsVisible = false;
|
||||
}
|
||||
}
|
||||
|
||||
private NetworkInterface? GetActiveNetworkInterface()
|
||||
{
|
||||
try
|
||||
{
|
||||
// 优先使用当前选中的网卡
|
||||
if (_selectedInterface != null &&
|
||||
_selectedInterface.OperationalStatus == OperationalStatus.Up)
|
||||
{
|
||||
return _selectedInterface;
|
||||
}
|
||||
|
||||
// 否则查找最佳网卡
|
||||
var interfaces = NetworkInterface.GetAllNetworkInterfaces()
|
||||
.Where(ni => ni.OperationalStatus == OperationalStatus.Up)
|
||||
.Where(ni => ni.NetworkInterfaceType != NetworkInterfaceType.Loopback)
|
||||
.Where(ni => ni.NetworkInterfaceType != NetworkInterfaceType.Tunnel)
|
||||
.ToList();
|
||||
|
||||
// 优先返回有流量的网卡
|
||||
return interfaces
|
||||
.OrderByDescending(ni => ni.GetIPv4Statistics().BytesReceived + ni.GetIPv4Statistics().BytesSent)
|
||||
.FirstOrDefault();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsLikelyMobileHotspot(NetworkInterface ni)
|
||||
{
|
||||
// 通过描述判断是否是移动热点
|
||||
var desc = ni.Description.ToLowerInvariant();
|
||||
return desc.Contains("mobile") ||
|
||||
desc.Contains("cellular") ||
|
||||
desc.Contains("phone") ||
|
||||
desc.Contains("tether");
|
||||
}
|
||||
|
||||
private static Symbol GetSymbolFromDescription(string description)
|
||||
{
|
||||
var desc = description.ToLowerInvariant();
|
||||
|
||||
if (desc.Contains("wifi") || desc.Contains("wi-fi") || desc.Contains("wireless"))
|
||||
return Symbol.WiFi;
|
||||
|
||||
if (desc.Contains("ethernet") || desc.Contains("lan") || desc.Contains("wired"))
|
||||
return Symbol.PlugConnected;
|
||||
|
||||
if (desc.Contains("cellular") || desc.Contains("mobile") || desc.Contains("lte") || desc.Contains("5g") || desc.Contains("4g"))
|
||||
return Symbol.Phone;
|
||||
|
||||
if (desc.Contains("bluetooth"))
|
||||
return Symbol.Bluetooth;
|
||||
|
||||
// 默认使用 Globe 图标
|
||||
return Symbol.Globe;
|
||||
}
|
||||
|
||||
private static string FormatSpeed(long bytesPerSecond)
|
||||
{
|
||||
// 根据数值大小决定显示格式,始终保持3个字符宽度
|
||||
// 例如: 1.23, 12.3, 123
|
||||
return bytesPerSecond switch
|
||||
{
|
||||
>= 1024 * 1024 * 1024 => FormatWithThreeDigits(bytesPerSecond / (1024.0 * 1024.0 * 1024.0), "G"),
|
||||
>= 1024 * 1024 => FormatWithThreeDigits(bytesPerSecond / (1024.0 * 1024.0), "M"),
|
||||
>= 1024 => FormatWithThreeDigits(bytesPerSecond / 1024.0, "K"),
|
||||
_ => FormatWithThreeDigits(bytesPerSecond, "B")
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 格式化数字,始终保持3个有效数字的显示宽度
|
||||
/// </summary>
|
||||
private static string FormatWithThreeDigits(double value, string unit)
|
||||
{
|
||||
// 根据数值大小决定小数位数,确保总宽度一致
|
||||
// < 10: 显示两位小数 (如 1.23)
|
||||
// 10-99: 显示一位小数 (如 12.3)
|
||||
// >= 100: 显示整数 (如 123)
|
||||
string formatted = value switch
|
||||
{
|
||||
< 10 => $"{value:F2}",
|
||||
< 100 => $"{value:F1}",
|
||||
_ => $"{value:F0}"
|
||||
};
|
||||
|
||||
return formatted + unit;
|
||||
}
|
||||
|
||||
private void UpdateDisplayMode()
|
||||
{
|
||||
switch (_displayMode)
|
||||
{
|
||||
case "Upload":
|
||||
UploadPanel.IsVisible = true;
|
||||
DownloadPanel.IsVisible = false;
|
||||
Separator.IsVisible = false;
|
||||
break;
|
||||
case "Download":
|
||||
UploadPanel.IsVisible = false;
|
||||
DownloadPanel.IsVisible = true;
|
||||
Separator.IsVisible = false;
|
||||
break;
|
||||
case "Both":
|
||||
default:
|
||||
UploadPanel.IsVisible = true;
|
||||
DownloadPanel.IsVisible = true;
|
||||
Separator.IsVisible = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
public void ApplyCellSize(double cellSize)
|
||||
{
|
||||
_lastAppliedCellSize = cellSize;
|
||||
|
||||
// 计算组件高度:保持与任务栏核心比例一致 (0.74x)
|
||||
var targetHeight = Math.Clamp(cellSize * 0.74, 34, 74);
|
||||
RootBorder.Height = targetHeight;
|
||||
|
||||
// 主矩形统一到主题主档圆角
|
||||
RootBorder.CornerRadius = ResolveUnifiedMainRectangle();
|
||||
RootBorder.VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center;
|
||||
|
||||
// 根据单元格大小和字体大小设置调整字体大小
|
||||
var fontSizeMultiplier = _fontSize switch
|
||||
{
|
||||
"Small" => 0.32,
|
||||
"Large" => 0.48,
|
||||
_ => 0.4 // Medium (default)
|
||||
};
|
||||
var fontSize = Math.Clamp(targetHeight * fontSizeMultiplier, 11, 22);
|
||||
UploadSpeedTextBlock.FontSize = fontSize;
|
||||
DownloadSpeedTextBlock.FontSize = fontSize;
|
||||
|
||||
// 调整图标大小
|
||||
if (NetworkTypeIcon != null)
|
||||
{
|
||||
NetworkTypeIcon.FontSize = Math.Clamp(targetHeight * 0.35, 10, 18);
|
||||
}
|
||||
|
||||
// 设置最小和最大宽度
|
||||
RootBorder.MinWidth = cellSize * 1.5;
|
||||
RootBorder.MaxWidth = cellSize * 5;
|
||||
|
||||
if (_transparentBackground)
|
||||
{
|
||||
RootBorder.MinWidth = 0;
|
||||
RootBorder.Padding = new Thickness(Math.Clamp(cellSize * 0.06, 4, 10), 0);
|
||||
return;
|
||||
}
|
||||
|
||||
// 确保清除可能存在的固定 Padding,由代码控制"紧密感"
|
||||
RootBorder.Padding = new Thickness(Math.Clamp(cellSize * 0.15, 12, 24), 0);
|
||||
}
|
||||
|
||||
private void ApplyChrome()
|
||||
{
|
||||
if (_transparentBackground)
|
||||
{
|
||||
RootBorder.Classes.Remove("glass-panel");
|
||||
RootBorder.Background = Brushes.Transparent;
|
||||
RootBorder.BorderBrush = Brushes.Transparent;
|
||||
RootBorder.BorderThickness = new Thickness(0);
|
||||
RootBorder.BoxShadow = default;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!RootBorder.Classes.Contains("glass-panel"))
|
||||
{
|
||||
RootBorder.Classes.Add("glass-panel");
|
||||
}
|
||||
|
||||
RootBorder.ClearValue(Border.BackgroundProperty);
|
||||
RootBorder.ClearValue(Border.BorderBrushProperty);
|
||||
RootBorder.ClearValue(Border.BorderThicknessProperty);
|
||||
RootBorder.ClearValue(Border.BoxShadowProperty);
|
||||
}
|
||||
|
||||
private CornerRadius ResolveUnifiedMainRectangle() => new(ResolveUnifiedMainRadiusValue());
|
||||
|
||||
private static double ResolveUnifiedMainRadiusValue() =>
|
||||
HostAppearanceThemeProvider.GetOrCreate().GetCurrent().CornerRadiusTokens.Lg.TopLeft;
|
||||
|
||||
protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
base.OnDetachedFromVisualTree(e);
|
||||
_timer?.Stop();
|
||||
_networkTypeTimer?.Stop();
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
22
LanMountainDesktop/Views/Components/TextCapsuleWidget.axaml
Normal file
22
LanMountainDesktop/Views/Components/TextCapsuleWidget.axaml
Normal file
@@ -0,0 +1,22 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:md="clr-namespace:Markdown.Avalonia;assembly=Markdown.Avalonia"
|
||||
mc:Ignorable="d"
|
||||
d:DesignWidth="200"
|
||||
d:DesignHeight="48"
|
||||
x:Class="LanMountainDesktop.Views.Components.TextCapsuleWidget">
|
||||
|
||||
<Border x:Name="RootBorder"
|
||||
Classes="surface-translucent-panel"
|
||||
Padding="0"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusComponent}">
|
||||
<md:MarkdownScrollViewer x:Name="MarkdownViewer"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Margin="12,6"
|
||||
MaxWidth="400" />
|
||||
</Border>
|
||||
|
||||
</UserControl>
|
||||
167
LanMountainDesktop/Views/Components/TextCapsuleWidget.axaml.cs
Normal file
167
LanMountainDesktop/Views/Components/TextCapsuleWidget.axaml.cs
Normal file
@@ -0,0 +1,167 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.Services;
|
||||
using Markdown.Avalonia;
|
||||
|
||||
namespace LanMountainDesktop.Views.Components;
|
||||
|
||||
public partial class TextCapsuleWidget : UserControl, IDesktopComponentWidget
|
||||
{
|
||||
private string _text = string.Empty;
|
||||
private bool _transparentBackground;
|
||||
private double _lastAppliedCellSize = 100;
|
||||
private CancellationTokenSource? _debounceCts;
|
||||
|
||||
public TextCapsuleWidget()
|
||||
{
|
||||
InitializeComponent();
|
||||
UpdateDisplay();
|
||||
}
|
||||
|
||||
public string Text
|
||||
{
|
||||
get => _text;
|
||||
set
|
||||
{
|
||||
if (_text == value)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_text = value;
|
||||
DebouncedUpdateDisplay();
|
||||
}
|
||||
}
|
||||
|
||||
public bool TransparentBackground
|
||||
{
|
||||
get => _transparentBackground;
|
||||
set
|
||||
{
|
||||
if (_transparentBackground == value)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_transparentBackground = value;
|
||||
ApplyChrome();
|
||||
ApplyCellSize(_lastAppliedCellSize);
|
||||
}
|
||||
}
|
||||
|
||||
public void SetText(string text)
|
||||
{
|
||||
Text = text;
|
||||
}
|
||||
|
||||
public void SetTransparentBackground(bool transparentBackground)
|
||||
{
|
||||
TransparentBackground = transparentBackground;
|
||||
}
|
||||
|
||||
private void DebouncedUpdateDisplay()
|
||||
{
|
||||
// 取消之前的延迟任务
|
||||
_debounceCts?.Cancel();
|
||||
_debounceCts?.Dispose();
|
||||
_debounceCts = new CancellationTokenSource();
|
||||
|
||||
var token = _debounceCts.Token;
|
||||
|
||||
// 延迟 150ms 后更新显示,避免频繁输入时过度渲染
|
||||
Dispatcher.UIThread.Post(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await System.Threading.Tasks.Task.Delay(150, token);
|
||||
if (!token.IsCancellationRequested)
|
||||
{
|
||||
UpdateDisplay();
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// 忽略取消异常
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void UpdateDisplay()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_text))
|
||||
{
|
||||
MarkdownViewer.Markdown = "*Empty*";
|
||||
return;
|
||||
}
|
||||
|
||||
// 使用 Markdown 引擎渲染文本
|
||||
MarkdownViewer.Markdown = _text;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// 错误处理:显示错误信息而不是崩溃
|
||||
MarkdownViewer.Markdown = $"*Error: {ex.Message}*";
|
||||
}
|
||||
}
|
||||
|
||||
public void ApplyCellSize(double cellSize)
|
||||
{
|
||||
_lastAppliedCellSize = cellSize;
|
||||
|
||||
// 计算组件高度:保持与任务栏核心比例一致 (0.74x)
|
||||
var targetHeight = Math.Clamp(cellSize * 0.74, 34, 74);
|
||||
RootBorder.Height = targetHeight;
|
||||
|
||||
// 主矩形统一到主题主档圆角
|
||||
RootBorder.CornerRadius = ResolveUnifiedMainRectangle();
|
||||
RootBorder.VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center;
|
||||
|
||||
// 设置最小和最大宽度
|
||||
RootBorder.MinWidth = cellSize * 1.5;
|
||||
RootBorder.MaxWidth = cellSize * 6;
|
||||
|
||||
if (_transparentBackground)
|
||||
{
|
||||
RootBorder.MinWidth = 0;
|
||||
RootBorder.Padding = new Thickness(Math.Clamp(cellSize * 0.06, 4, 10), 0);
|
||||
return;
|
||||
}
|
||||
|
||||
// 确保清除可能存在的固定 Padding,由代码控制"紧密感"
|
||||
RootBorder.Padding = new Thickness(Math.Clamp(cellSize * 0.15, 12, 24), 0);
|
||||
}
|
||||
|
||||
private void ApplyChrome()
|
||||
{
|
||||
if (_transparentBackground)
|
||||
{
|
||||
RootBorder.Classes.Remove("glass-panel");
|
||||
RootBorder.Background = Brushes.Transparent;
|
||||
RootBorder.BorderBrush = Brushes.Transparent;
|
||||
RootBorder.BorderThickness = new Thickness(0);
|
||||
RootBorder.BoxShadow = default;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!RootBorder.Classes.Contains("glass-panel"))
|
||||
{
|
||||
RootBorder.Classes.Add("glass-panel");
|
||||
}
|
||||
|
||||
RootBorder.ClearValue(Border.BackgroundProperty);
|
||||
RootBorder.ClearValue(Border.BorderBrushProperty);
|
||||
RootBorder.ClearValue(Border.BorderThicknessProperty);
|
||||
RootBorder.ClearValue(Border.BoxShadowProperty);
|
||||
}
|
||||
|
||||
private CornerRadius ResolveUnifiedMainRectangle() => new(ResolveUnifiedMainRadiusValue());
|
||||
|
||||
private static double ResolveUnifiedMainRadiusValue() =>
|
||||
HostAppearanceThemeProvider.GetOrCreate().GetCurrent().CornerRadiusTokens.Lg.TopLeft;
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
23
LanMountainDesktop/Views/DesktopWidgetWindow.axaml
Normal file
23
LanMountainDesktop/Views/DesktopWidgetWindow.axaml
Normal file
@@ -0,0 +1,23 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
mc:Ignorable="d"
|
||||
x:Class="LanMountainDesktop.Views.DesktopWidgetWindow"
|
||||
Title="Desktop Component"
|
||||
ShowInTaskbar="False"
|
||||
SystemDecorations="None"
|
||||
Background="Transparent"
|
||||
Topmost="False"
|
||||
SizeToContent="WidthAndHeight"
|
||||
TransparencyLevelHint="Transparent"
|
||||
RenderOptions.BitmapInterpolationMode="HighQuality"
|
||||
CanResize="False">
|
||||
|
||||
<Border x:Name="ComponentContainer"
|
||||
Background="Transparent"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
|
||||
ClipToBounds="True">
|
||||
<!-- Component control will be injected here -->
|
||||
</Border>
|
||||
</Window>
|
||||
61
LanMountainDesktop/Views/DesktopWidgetWindow.axaml.cs
Normal file
61
LanMountainDesktop/Views/DesktopWidgetWindow.axaml.cs
Normal file
@@ -0,0 +1,61 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using LanMountainDesktop.Services;
|
||||
using Avalonia.Threading;
|
||||
|
||||
namespace LanMountainDesktop.Views;
|
||||
|
||||
/// <summary>
|
||||
/// 表示一个独立的组件挂载窗口。它不含有任何自己的边窗,仅仅负责包裹组件并将自身植入系统最底层。
|
||||
/// </summary>
|
||||
public partial class DesktopWidgetWindow : Window
|
||||
{
|
||||
private readonly IWindowBottomMostService _bottomMostService = WindowBottomMostServiceFactory.GetOrCreate();
|
||||
private readonly IRegionPassthroughService _regionPassthroughService = RegionPassthroughServiceFactory.GetOrCreate();
|
||||
|
||||
public DesktopWidgetWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
public DesktopWidgetWindow(Control componentContent) : this()
|
||||
{
|
||||
ComponentContainer.Child = componentContent;
|
||||
}
|
||||
|
||||
protected override void OnOpened(EventArgs e)
|
||||
{
|
||||
base.OnOpened(e);
|
||||
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
// 通过现有的置底服务将独立的小窗口锁定到底层
|
||||
_bottomMostService.SetupBottomMost(this);
|
||||
_bottomMostService.SendToBottom(this);
|
||||
|
||||
// 当窗口展示完毕且有了尺寸后,更新可交互区域,使得整个组件都能被点击
|
||||
Dispatcher.UIThread.Post(UpdateInteractiveRegion, DispatcherPriority.Render);
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnSizeChanged(SizeChangedEventArgs e)
|
||||
{
|
||||
base.OnSizeChanged(e);
|
||||
|
||||
if (OperatingSystem.IsWindows() && IsVisible)
|
||||
{
|
||||
UpdateInteractiveRegion();
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateInteractiveRegion()
|
||||
{
|
||||
// 既然是一个完全紧贴在组件身上的小窗,它的全部都是可交互的
|
||||
_regionPassthroughService.SetInteractiveRegions(this, new List<Rect>
|
||||
{
|
||||
new(0, 0, Bounds.Width, Bounds.Height)
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:LanMountainDesktop.ViewModels"
|
||||
xmlns:fi="using:FluentIcons.Avalonia"
|
||||
x:Class="LanMountainDesktop.Views.FusedDesktopComponentLibraryControl"
|
||||
x:DataType="vm:ComponentLibraryWindowViewModel">
|
||||
|
||||
<Grid ColumnDefinitions="240,*"
|
||||
ColumnSpacing="12"
|
||||
Margin="0">
|
||||
<!-- 分类列表 (左侧) -->
|
||||
<Border Classes="surface-translucent-panel"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusLg}"
|
||||
Padding="10">
|
||||
<Grid RowDefinitions="Auto,*">
|
||||
<TextBox x:Name="SearchBox"
|
||||
Watermark="搜索组件..."
|
||||
Margin="0,0,0,12"
|
||||
Classes="clear"
|
||||
Background="{DynamicResource AdaptiveSurfaceLowBrush}"
|
||||
CornerRadius="12"
|
||||
Padding="12,8">
|
||||
<TextBox.InnerLeftContent>
|
||||
<fi:SymbolIcon Symbol="Search" FontSize="16" Margin="10,0,0,0" Opacity="0.5" />
|
||||
</TextBox.InnerLeftContent>
|
||||
</TextBox>
|
||||
|
||||
<ListBox x:Name="CategoryListBox"
|
||||
Grid.Row="1"
|
||||
Background="Transparent"
|
||||
BorderThickness="0"
|
||||
SelectionChanged="OnCategorySelectionChanged"
|
||||
ItemsSource="{Binding Categories}">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:ComponentLibraryCategoryViewModel">
|
||||
<Border Padding="10"
|
||||
Margin="0,0,0,6"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusSm}"
|
||||
Background="{DynamicResource AdaptiveNavItemBackgroundBrush}">
|
||||
<Grid ColumnDefinitions="Auto,*"
|
||||
ColumnSpacing="8">
|
||||
<fi:SymbolIcon Symbol="{Binding Icon}"
|
||||
IconVariant="Regular"
|
||||
FontSize="16" />
|
||||
<TextBlock Grid.Column="1"
|
||||
VerticalAlignment="Center"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
|
||||
Text="{Binding Title}" />
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- 组件网格 (右侧) -->
|
||||
<Border Grid.Column="1"
|
||||
Classes="surface-translucent-strong"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusLg}"
|
||||
Padding="10">
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto"
|
||||
HorizontalScrollBarVisibility="Disabled">
|
||||
<ItemsControl x:Name="ComponentItemsControl"
|
||||
ItemsSource="{Binding Components}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<WrapPanel Orientation="Horizontal" />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:ComponentLibraryItemViewModel">
|
||||
<Border Width="240"
|
||||
Height="220"
|
||||
Margin="6"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
|
||||
Padding="10"
|
||||
Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
|
||||
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
|
||||
BorderThickness="1">
|
||||
<Grid RowDefinitions="*,Auto,Auto"
|
||||
RowSpacing="8">
|
||||
<!-- 预览区域 -->
|
||||
<Border CornerRadius="{DynamicResource DesignCornerRadiusSm}"
|
||||
Background="{DynamicResource AdaptiveGlassPanelBackgroundBrush}"
|
||||
BorderThickness="1"
|
||||
BorderBrush="{DynamicResource AdaptiveGlassPanelBorderBrush}"
|
||||
Padding="8">
|
||||
<Grid>
|
||||
<Image Source="{Binding PreviewBitmap}"
|
||||
Stretch="Uniform"
|
||||
HorizontalAlignment="Stretch"
|
||||
VerticalAlignment="Stretch"
|
||||
RenderOptions.BitmapInterpolationMode="HighQuality"
|
||||
IsVisible="{Binding IsPreviewReady}" />
|
||||
|
||||
<!-- 加载中状态 -->
|
||||
<Border IsVisible="{Binding IsPreviewPending}"
|
||||
Background="{DynamicResource AdaptiveSurfaceBaseBrush}">
|
||||
<StackPanel HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Spacing="8">
|
||||
<ProgressBar Width="96"
|
||||
IsIndeterminate="True" />
|
||||
<TextBlock HorizontalAlignment="Center"
|
||||
TextAlignment="Center"
|
||||
FontSize="12"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
|
||||
Text="{Binding PreviewStatusText}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- 失败状态 -->
|
||||
<Border IsVisible="{Binding IsPreviewFailed}"
|
||||
Background="{DynamicResource AdaptiveSurfaceBaseBrush}">
|
||||
<StackPanel HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Spacing="8">
|
||||
<TextBlock HorizontalAlignment="Center"
|
||||
TextAlignment="Center"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
|
||||
Text="{Binding PreviewStatusText}" />
|
||||
<TextBlock HorizontalAlignment="Center"
|
||||
TextAlignment="Center"
|
||||
FontSize="12"
|
||||
TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
|
||||
Text="{Binding PreviewErrorMessage}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- 组件名称 -->
|
||||
<TextBlock Grid.Row="1"
|
||||
HorizontalAlignment="Center"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
|
||||
Text="{Binding DisplayName}" />
|
||||
|
||||
<!-- 添加按钮 -->
|
||||
<Button Grid.Row="2"
|
||||
HorizontalAlignment="Center"
|
||||
Padding="12,6"
|
||||
Tag="{Binding ComponentId}"
|
||||
Click="OnAddComponentClick">
|
||||
<TextBlock Text="添加到桌面" />
|
||||
</Button>
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,190 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using FluentIcons.Common;
|
||||
using LanMountainDesktop.ComponentSystem;
|
||||
using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.Services.Settings;
|
||||
using LanMountainDesktop.ViewModels;
|
||||
using LanMountainDesktop.Views.Components;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
|
||||
namespace LanMountainDesktop.Views;
|
||||
|
||||
public partial class FusedDesktopComponentLibraryControl : UserControl
|
||||
{
|
||||
public event EventHandler<string>? AddComponentRequested;
|
||||
|
||||
private readonly ComponentLibraryWindowViewModel _viewModel = new();
|
||||
private List<DesktopComponentDefinition> _allDefinitions = new();
|
||||
|
||||
private ComponentRegistry? _componentRegistry;
|
||||
private DesktopComponentRuntimeRegistry? _componentRuntimeRegistry;
|
||||
private readonly ISettingsFacadeService _settingsFacade = HostSettingsFacadeProvider.GetOrCreate();
|
||||
private readonly IWeatherInfoService _weatherDataService;
|
||||
private readonly TimeZoneService _timeZoneService;
|
||||
private readonly IRecommendationInfoService _recommendationInfoService = new RecommendationDataService();
|
||||
private readonly ICalculatorDataService _calculatorDataService = new CalculatorDataService();
|
||||
|
||||
public FusedDesktopComponentLibraryControl()
|
||||
{
|
||||
InitializeComponent();
|
||||
DataContext = _viewModel;
|
||||
|
||||
_weatherDataService = _settingsFacade.Weather.GetWeatherInfoService();
|
||||
_timeZoneService = _settingsFacade.Region.GetTimeZoneService();
|
||||
|
||||
LoadRegistry();
|
||||
LoadCategories();
|
||||
SearchBox.KeyUp += (s, e) => FilterComponents();
|
||||
|
||||
// 默认选择第一个分类
|
||||
if (_viewModel.Categories.Count > 0)
|
||||
{
|
||||
CategoryListBox.SelectedIndex = 0;
|
||||
}
|
||||
}
|
||||
|
||||
private void LoadRegistry()
|
||||
{
|
||||
var pluginRuntimeService = (Application.Current as App)?.PluginRuntimeService;
|
||||
_componentRegistry = DesktopComponentRegistryFactory.Create(pluginRuntimeService);
|
||||
_componentRuntimeRegistry = DesktopComponentRegistryFactory.CreateRuntimeRegistry(
|
||||
_componentRegistry,
|
||||
pluginRuntimeService,
|
||||
_settingsFacade);
|
||||
|
||||
_allDefinitions = _componentRegistry.GetAll()
|
||||
.Where(d => d.AllowDesktopPlacement)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private void LoadCategories()
|
||||
{
|
||||
_viewModel.Categories.Clear();
|
||||
_viewModel.Components.Clear();
|
||||
|
||||
// 添加"全部组件"分类
|
||||
_viewModel.Categories.Add(new ComponentLibraryCategoryViewModel(
|
||||
"all",
|
||||
"全部组件",
|
||||
Symbol.Apps,
|
||||
Array.Empty<ComponentLibraryItemViewModel>()));
|
||||
|
||||
var categoryMap = new Dictionary<string, (string Display, Symbol Icon)>
|
||||
{
|
||||
{ "clock", ("时钟", Symbol.Clock) },
|
||||
{ "date", ("日历", Symbol.CalendarDate) },
|
||||
{ "weather", ("天气", Symbol.WeatherSunny) },
|
||||
{ "board", ("画板", Symbol.Edit) },
|
||||
{ "media", ("媒体", Symbol.Play) },
|
||||
{ "info", ("资讯", Symbol.News) },
|
||||
{ "calculator", ("工具", Symbol.Calculator) },
|
||||
{ "study", ("学习", Symbol.Hourglass) },
|
||||
{ "file", ("文件", Symbol.Folder) }
|
||||
};
|
||||
|
||||
var usedCategories = _allDefinitions
|
||||
.Select(d => d.Category)
|
||||
.Distinct()
|
||||
.Where(c => !string.IsNullOrEmpty(c));
|
||||
|
||||
foreach (var cat in usedCategories)
|
||||
{
|
||||
if (categoryMap.TryGetValue(cat.ToLower(), out var info))
|
||||
{
|
||||
var categoryComponents = _allDefinitions
|
||||
.Where(d => string.Equals(d.Category, cat, StringComparison.OrdinalIgnoreCase))
|
||||
.OrderBy(d => d.DisplayName)
|
||||
.Select(d => CreateComponentItem(d))
|
||||
.ToArray();
|
||||
|
||||
_viewModel.Categories.Add(new ComponentLibraryCategoryViewModel(
|
||||
cat,
|
||||
info.Display,
|
||||
info.Icon,
|
||||
categoryComponents));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private ComponentLibraryItemViewModel CreateComponentItem(DesktopComponentDefinition definition)
|
||||
{
|
||||
var previewKey = ComponentPreviewKey.ForComponentType(
|
||||
definition.Id,
|
||||
definition.MinWidthCells,
|
||||
definition.MinHeightCells);
|
||||
|
||||
var mainWindow = (Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.MainWindow as MainWindow;
|
||||
ComponentPreviewImageEntry? previewEntry = null;
|
||||
|
||||
if (mainWindow is not null)
|
||||
{
|
||||
previewEntry = mainWindow.GetPreviewEntry(previewKey);
|
||||
}
|
||||
|
||||
var item = new ComponentLibraryItemViewModel(
|
||||
definition.Id,
|
||||
definition.DisplayName,
|
||||
previewKey,
|
||||
"正在加载预览...",
|
||||
"预览不可用",
|
||||
previewEntry);
|
||||
|
||||
if (mainWindow is not null && (previewEntry is null || previewEntry.State == ComponentPreviewImageState.Pending))
|
||||
{
|
||||
mainWindow.RequestDetachedLibraryPreview(previewKey);
|
||||
}
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
public void UpdatePreviewImage(ComponentPreviewImageEntry entry)
|
||||
{
|
||||
foreach (var category in _viewModel.Categories)
|
||||
{
|
||||
foreach (var component in category.Components)
|
||||
{
|
||||
if (component.PreviewKey.Equals(entry.Key))
|
||||
{
|
||||
component.UpdatePreviewImageEntry(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void OnCategorySelectionChanged(object? sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
FilterComponents();
|
||||
}
|
||||
|
||||
private void FilterComponents()
|
||||
{
|
||||
var selectedCategory = (CategoryListBox.SelectedItem as ComponentLibraryCategoryViewModel)?.Id;
|
||||
var searchText = SearchBox.Text?.ToLower() ?? "";
|
||||
|
||||
var filtered = _allDefinitions.Where(d =>
|
||||
{
|
||||
var matchesCategory = selectedCategory == "all" || string.Equals(d.Category, selectedCategory, StringComparison.OrdinalIgnoreCase);
|
||||
var matchesSearch = string.IsNullOrEmpty(searchText) || d.DisplayName.ToLower().Contains(searchText) || d.Id.ToLower().Contains(searchText);
|
||||
return matchesCategory && matchesSearch;
|
||||
});
|
||||
|
||||
_viewModel.Components.Clear();
|
||||
foreach (var def in filtered)
|
||||
{
|
||||
_viewModel.Components.Add(CreateComponentItem(def));
|
||||
}
|
||||
}
|
||||
|
||||
private void OnAddComponentClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is Button button && button.Tag is string componentId)
|
||||
{
|
||||
AddComponentRequested?.Invoke(this, componentId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:controls="using:LanMountainDesktop.Views"
|
||||
xmlns:fi="using:FluentIcons.Avalonia"
|
||||
x:Class="LanMountainDesktop.Views.FusedDesktopComponentLibraryWindow"
|
||||
Width="860" Height="620"
|
||||
MinWidth="600" MinHeight="500"
|
||||
WindowStartupLocation="CenterScreen"
|
||||
SystemDecorations="Full"
|
||||
ExtendClientAreaToDecorationsHint="True"
|
||||
ExtendClientAreaChromeHints="NoChrome"
|
||||
ExtendClientAreaTitleBarHeightHint="-1"
|
||||
Background="Transparent"
|
||||
TransparencyLevelHint="Mica"
|
||||
Title="融合桌面组件库">
|
||||
|
||||
<Panel>
|
||||
<!-- 背景磨砂效果 -->
|
||||
<Border Background="{DynamicResource AdaptiveSurfaceLowBrush}"
|
||||
Opacity="0.85" />
|
||||
|
||||
<Grid RowDefinitions="Auto,*">
|
||||
<!-- 自定义标题栏 -->
|
||||
<Border Background="Transparent"
|
||||
IsHitTestVisible="True"
|
||||
Padding="20,16">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel Spacing="6" VerticalAlignment="Center">
|
||||
<TextBlock Text="融合桌面组件库"
|
||||
FontWeight="SemiBold"
|
||||
FontSize="20"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||
<TextBlock Text="将精美组件放置在您的系统桌面上(负一屏)"
|
||||
Opacity="0.6"
|
||||
FontSize="13"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||
</StackPanel>
|
||||
|
||||
<Button Grid.Column="1"
|
||||
Classes="accent"
|
||||
Width="36" Height="36"
|
||||
Padding="0"
|
||||
CornerRadius="18"
|
||||
BorderThickness="0"
|
||||
Background="{DynamicResource AdaptiveButtonHoverBackgroundBrush}"
|
||||
Click="OnCloseClick">
|
||||
<fi:SymbolIcon Symbol="Dismiss" FontSize="18" />
|
||||
</Button>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- 组件库控件 -->
|
||||
<controls:FusedDesktopComponentLibraryControl x:Name="LibraryControl"
|
||||
Grid.Row="1" />
|
||||
</Grid>
|
||||
</Panel>
|
||||
</Window>
|
||||
@@ -0,0 +1,117 @@
|
||||
using System;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using LanMountainDesktop.ComponentSystem;
|
||||
using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.Services.Settings;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
|
||||
namespace LanMountainDesktop.Views;
|
||||
|
||||
/// <summary>
|
||||
/// 融合桌面组件库窗口 - 专门用于添加组件到系统桌面(负一屏)
|
||||
///
|
||||
/// 注意:此窗口只能添加组件到融合桌面,不能添加到阑山桌面
|
||||
/// </summary>
|
||||
public partial class FusedDesktopComponentLibraryWindow : Window
|
||||
{
|
||||
private readonly IFusedDesktopLayoutService _layoutService = FusedDesktopLayoutServiceProvider.GetOrCreate();
|
||||
private readonly ISettingsFacadeService _settingsFacade = HostSettingsFacadeProvider.GetOrCreate();
|
||||
private TransparentOverlayWindow? _overlayWindow;
|
||||
|
||||
// 与 TransparentOverlayWindow 保持一致的默认 cellSize
|
||||
private const double DefaultCellSize = 100;
|
||||
|
||||
public FusedDesktopComponentLibraryWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
LibraryControl.AddComponentRequested += OnAddComponentRequested;
|
||||
|
||||
var mainWindow = (Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.MainWindow as MainWindow;
|
||||
mainWindow?.RegisterFusedLibraryWindow(this);
|
||||
}
|
||||
|
||||
/// <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 (componentWidth, componentHeight) = ResolveComponentSize(componentId);
|
||||
|
||||
// 取覆盖层画布的中心点,减去组件半尺寸,使组件出现在屏幕正中央
|
||||
var overlayBounds = _overlayWindow.Bounds;
|
||||
var centerX = overlayBounds.Width / 2.0 - componentWidth / 2.0;
|
||||
var centerY = overlayBounds.Height / 2.0 - componentHeight / 2.0;
|
||||
|
||||
// 边界保护:确保组件不超出屏幕边界
|
||||
centerX = Math.Max(0, Math.Min(centerX, overlayBounds.Width - componentWidth));
|
||||
centerY = Math.Max(0, Math.Min(centerY, overlayBounds.Height - componentHeight));
|
||||
|
||||
_overlayWindow.AddComponent(componentId, centerX, centerY, componentWidth, componentHeight);
|
||||
|
||||
AppLogger.Info("FusedDesktopLibrary",
|
||||
$"Added component '{componentId}' at center ({centerX:F0}, {centerY:F0}) size ({componentWidth}x{componentHeight}).");
|
||||
|
||||
// 关闭窗口
|
||||
Close();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 解析组件的默认像素尺寸(基于组件定义的 MinCells * DefaultCellSize)
|
||||
/// </summary>
|
||||
private (double Width, double Height) ResolveComponentSize(string componentId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var pluginRuntimeService = (Application.Current as App)?.PluginRuntimeService;
|
||||
var registry = DesktopComponentRegistryFactory.Create(pluginRuntimeService);
|
||||
if (registry.TryGetDefinition(componentId, out var definition))
|
||||
{
|
||||
var w = Math.Max(1, definition.MinWidthCells) * DefaultCellSize;
|
||||
var h = Math.Max(1, definition.MinHeightCells) * DefaultCellSize;
|
||||
return (w, h);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("FusedDesktopLibrary", $"Failed to resolve component size for '{componentId}'.", ex);
|
||||
}
|
||||
|
||||
// 回退为 2×2 格子的默认尺寸
|
||||
return (DefaultCellSize * 2, DefaultCellSize * 2);
|
||||
}
|
||||
|
||||
private void OnCloseClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
Close();
|
||||
}
|
||||
|
||||
protected override void OnClosed(EventArgs e)
|
||||
{
|
||||
base.OnClosed(e);
|
||||
var mainWindow = (Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.MainWindow as MainWindow;
|
||||
mainWindow?.UnregisterFusedLibraryWindow(this);
|
||||
}
|
||||
|
||||
public void UpdatePreviewImage(ComponentPreviewImageEntry entry)
|
||||
{
|
||||
LibraryControl.UpdatePreviewImage(entry);
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ public partial class MainWindow
|
||||
private readonly IComponentPreviewImageService _componentPreviewImageService = new ComponentPreviewImageService();
|
||||
private readonly Dictionary<ComponentPreviewKey, List<ComponentLibraryPreviewVisualTarget>> _componentLibraryPreviewVisualTargets = new(ComponentPreviewKeyComparer.Instance);
|
||||
private bool _componentLibraryPreviewWarmupStarted;
|
||||
private FusedDesktopComponentLibraryWindow? _fusedLibraryWindow;
|
||||
|
||||
private sealed record ComponentLibraryPreviewVisualTarget(Image Image, Control Fallback);
|
||||
|
||||
@@ -519,6 +520,7 @@ public partial class MainWindow
|
||||
{
|
||||
ApplyPreviewEntryToEmbeddedVisuals(entry.Key);
|
||||
_detachedComponentLibraryWindow?.UpdatePreviewImage(entry);
|
||||
_fusedLibraryWindow?.UpdatePreviewImage(entry);
|
||||
|
||||
if (entry.Key.Kind == ComponentPreviewKeyKind.PlacementInstance)
|
||||
{
|
||||
@@ -597,4 +599,30 @@ public partial class MainWindow
|
||||
action: "DetachedLibraryRender",
|
||||
forceRefresh: false);
|
||||
}
|
||||
|
||||
// FusedDesktop 支持
|
||||
|
||||
public void RegisterFusedLibraryWindow(FusedDesktopComponentLibraryWindow window)
|
||||
{
|
||||
_fusedLibraryWindow = window;
|
||||
}
|
||||
|
||||
public void UnregisterFusedLibraryWindow(FusedDesktopComponentLibraryWindow window)
|
||||
{
|
||||
if (ReferenceEquals(_fusedLibraryWindow, window))
|
||||
{
|
||||
_fusedLibraryWindow = null;
|
||||
}
|
||||
}
|
||||
|
||||
public ComponentPreviewImageEntry? GetPreviewEntry(ComponentPreviewKey key)
|
||||
{
|
||||
return ResolvePreviewEntry(key);
|
||||
}
|
||||
|
||||
public void RequestDetachedLibraryPreview(ComponentPreviewKey key)
|
||||
{
|
||||
RequestDetachedLibraryPreviewWarm(key);
|
||||
RequestDetachedLibraryPreviewRender(key);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -364,12 +364,407 @@ public partial class MainWindow
|
||||
? ClockDisplayFormat.HourMinute
|
||||
: ClockDisplayFormat.HourMinuteSecond;
|
||||
_statusBarClockTransparentBackground = snapshot.StatusBarClockTransparentBackground;
|
||||
_clockPosition = NormalizeClockPosition(snapshot.ClockPosition);
|
||||
_clockFontSize = NormalizeFontSize(snapshot.ClockFontSize);
|
||||
|
||||
if (ClockWidget is not null)
|
||||
_showTextCapsule = snapshot.ShowTextCapsule;
|
||||
_textCapsuleContent = snapshot.TextCapsuleContent ?? "**Hello** World!";
|
||||
_textCapsulePosition = NormalizeTextCapsulePosition(snapshot.TextCapsulePosition);
|
||||
_textCapsuleTransparentBackground = snapshot.TextCapsuleTransparentBackground;
|
||||
_textCapsuleFontSize = NormalizeFontSize(snapshot.TextCapsuleFontSize);
|
||||
|
||||
_showNetworkSpeed = snapshot.ShowNetworkSpeed;
|
||||
_networkSpeedPosition = NormalizeNetworkSpeedPosition(snapshot.NetworkSpeedPosition);
|
||||
_networkSpeedDisplayMode = NormalizeNetworkSpeedDisplayMode(snapshot.NetworkSpeedDisplayMode);
|
||||
_networkSpeedTransparentBackground = snapshot.NetworkSpeedTransparentBackground;
|
||||
_showNetworkTypeIcon = snapshot.ShowNetworkTypeIcon;
|
||||
_networkSpeedFontSize = NormalizeFontSize(snapshot.NetworkSpeedFontSize);
|
||||
|
||||
_statusBarShadowEnabled = snapshot.StatusBarShadowEnabled;
|
||||
_statusBarShadowColor = snapshot.StatusBarShadowColor ?? "#000000";
|
||||
_statusBarShadowOpacity = snapshot.StatusBarShadowOpacity;
|
||||
|
||||
ApplyClockSettingsToAllWidgets();
|
||||
ApplyTextCapsuleSettingsToAllWidgets();
|
||||
ApplyNetworkSpeedSettingsToAllWidgets();
|
||||
ApplyStatusBarShadow();
|
||||
}
|
||||
|
||||
private void ApplyClockSettingsToAllWidgets()
|
||||
{
|
||||
if (ClockWidgetLeft is not null)
|
||||
{
|
||||
ClockWidget.SetDisplayFormat(_clockDisplayFormat);
|
||||
ClockWidget.SetTransparentBackground(_statusBarClockTransparentBackground);
|
||||
ClockWidgetLeft.SetDisplayFormat(_clockDisplayFormat);
|
||||
ClockWidgetLeft.SetTransparentBackground(_statusBarClockTransparentBackground);
|
||||
ClockWidgetLeft.SetFontSize(_clockFontSize);
|
||||
}
|
||||
if (ClockWidgetCenter is not null)
|
||||
{
|
||||
ClockWidgetCenter.SetDisplayFormat(_clockDisplayFormat);
|
||||
ClockWidgetCenter.SetTransparentBackground(_statusBarClockTransparentBackground);
|
||||
ClockWidgetCenter.SetFontSize(_clockFontSize);
|
||||
}
|
||||
if (ClockWidgetRight is not null)
|
||||
{
|
||||
ClockWidgetRight.SetDisplayFormat(_clockDisplayFormat);
|
||||
ClockWidgetRight.SetTransparentBackground(_statusBarClockTransparentBackground);
|
||||
ClockWidgetRight.SetFontSize(_clockFontSize);
|
||||
}
|
||||
}
|
||||
|
||||
private static string NormalizeClockPosition(string? value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
_ when string.Equals(value, "Center", StringComparison.OrdinalIgnoreCase) => "Center",
|
||||
_ when string.Equals(value, "Right", StringComparison.OrdinalIgnoreCase) => "Right",
|
||||
_ => "Left"
|
||||
};
|
||||
}
|
||||
|
||||
private static string NormalizeFontSize(string? value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
_ when string.Equals(value, "Small", StringComparison.OrdinalIgnoreCase) => "Small",
|
||||
_ when string.Equals(value, "Large", StringComparison.OrdinalIgnoreCase) => "Large",
|
||||
_ => "Medium"
|
||||
};
|
||||
}
|
||||
|
||||
private void ApplyTextCapsuleSettingsToAllWidgets()
|
||||
{
|
||||
if (TextCapsuleWidgetLeft is not null)
|
||||
{
|
||||
TextCapsuleWidgetLeft.SetText(_textCapsuleContent);
|
||||
TextCapsuleWidgetLeft.SetTransparentBackground(_textCapsuleTransparentBackground);
|
||||
}
|
||||
if (TextCapsuleWidgetCenter is not null)
|
||||
{
|
||||
TextCapsuleWidgetCenter.SetText(_textCapsuleContent);
|
||||
TextCapsuleWidgetCenter.SetTransparentBackground(_textCapsuleTransparentBackground);
|
||||
}
|
||||
if (TextCapsuleWidgetRight is not null)
|
||||
{
|
||||
TextCapsuleWidgetRight.SetText(_textCapsuleContent);
|
||||
TextCapsuleWidgetRight.SetTransparentBackground(_textCapsuleTransparentBackground);
|
||||
}
|
||||
}
|
||||
|
||||
private static string NormalizeTextCapsulePosition(string? value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
_ when string.Equals(value, "Center", StringComparison.OrdinalIgnoreCase) => "Center",
|
||||
_ when string.Equals(value, "Left", StringComparison.OrdinalIgnoreCase) => "Left",
|
||||
_ => "Right"
|
||||
};
|
||||
}
|
||||
|
||||
private void ApplyNetworkSpeedSettingsToAllWidgets()
|
||||
{
|
||||
if (NetworkSpeedWidgetLeft is not null)
|
||||
{
|
||||
NetworkSpeedWidgetLeft.SetDisplayMode(_networkSpeedDisplayMode);
|
||||
NetworkSpeedWidgetLeft.SetTransparentBackground(_networkSpeedTransparentBackground);
|
||||
NetworkSpeedWidgetLeft.SetShowNetworkTypeIcon(_showNetworkTypeIcon);
|
||||
NetworkSpeedWidgetLeft.SetFontSize(_networkSpeedFontSize);
|
||||
}
|
||||
if (NetworkSpeedWidgetCenter is not null)
|
||||
{
|
||||
NetworkSpeedWidgetCenter.SetDisplayMode(_networkSpeedDisplayMode);
|
||||
NetworkSpeedWidgetCenter.SetTransparentBackground(_networkSpeedTransparentBackground);
|
||||
NetworkSpeedWidgetCenter.SetShowNetworkTypeIcon(_showNetworkTypeIcon);
|
||||
NetworkSpeedWidgetCenter.SetFontSize(_networkSpeedFontSize);
|
||||
}
|
||||
if (NetworkSpeedWidgetRight is not null)
|
||||
{
|
||||
NetworkSpeedWidgetRight.SetDisplayMode(_networkSpeedDisplayMode);
|
||||
NetworkSpeedWidgetRight.SetTransparentBackground(_networkSpeedTransparentBackground);
|
||||
NetworkSpeedWidgetRight.SetShowNetworkTypeIcon(_showNetworkTypeIcon);
|
||||
NetworkSpeedWidgetRight.SetFontSize(_networkSpeedFontSize);
|
||||
}
|
||||
}
|
||||
|
||||
private static string NormalizeNetworkSpeedPosition(string? value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
_ when string.Equals(value, "Left", StringComparison.OrdinalIgnoreCase) => "Left",
|
||||
_ when string.Equals(value, "Center", StringComparison.OrdinalIgnoreCase) => "Center",
|
||||
_ => "Right"
|
||||
};
|
||||
}
|
||||
|
||||
private static string NormalizeNetworkSpeedDisplayMode(string? value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
_ when string.Equals(value, "Upload", StringComparison.OrdinalIgnoreCase) => "Upload",
|
||||
_ when string.Equals(value, "Download", StringComparison.OrdinalIgnoreCase) => "Download",
|
||||
_ => "Both"
|
||||
};
|
||||
}
|
||||
|
||||
private void ApplyStatusBarShadow()
|
||||
{
|
||||
if (StatusBarOverlay is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_statusBarShadowEnabled)
|
||||
{
|
||||
if (Color.TryParse(_statusBarShadowColor, out var shadowColor))
|
||||
{
|
||||
var opacity = Math.Clamp(_statusBarShadowOpacity, 0, 1);
|
||||
|
||||
StatusBarOverlay.IsVisible = true;
|
||||
|
||||
var gradientBrush = new LinearGradientBrush
|
||||
{
|
||||
StartPoint = new RelativePoint(0, 0, RelativeUnit.Relative),
|
||||
EndPoint = new RelativePoint(0, 1, RelativeUnit.Relative)
|
||||
};
|
||||
|
||||
var alpha1 = (byte)(shadowColor.A * opacity * 0.8);
|
||||
var alpha2 = (byte)(shadowColor.A * opacity * 0.4);
|
||||
var color1 = Color.FromArgb(alpha1, shadowColor.R, shadowColor.G, shadowColor.B);
|
||||
var color2 = Color.FromArgb(alpha2, shadowColor.R, shadowColor.G, shadowColor.B);
|
||||
|
||||
gradientBrush.GradientStops.Add(new GradientStop(color1, 0.0));
|
||||
gradientBrush.GradientStops.Add(new GradientStop(color2, 0.3));
|
||||
gradientBrush.GradientStops.Add(new GradientStop(Colors.Transparent, 1.0));
|
||||
|
||||
StatusBarOverlay.Background = gradientBrush;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
StatusBarOverlay.IsVisible = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 检测状态栏组件是否会发生碰撞
|
||||
/// </summary>
|
||||
private bool WouldComponentsCollide()
|
||||
{
|
||||
if (TopStatusBarHost is null)
|
||||
return false;
|
||||
|
||||
// 获取各区域当前占用的宽度
|
||||
var leftWidth = GetLeftPanelOccupiedWidth();
|
||||
var centerWidth = GetCenterPanelOccupiedWidth();
|
||||
var rightWidth = GetRightPanelOccupiedWidth();
|
||||
|
||||
// 获取状态栏总宽度
|
||||
var totalWidth = TopStatusBarHost.Bounds.Width;
|
||||
if (totalWidth <= 0)
|
||||
return false;
|
||||
|
||||
// 计算中间区域的实际位置
|
||||
// 左列是 *, 中列是 Auto, 右列是 *
|
||||
// 中间区域居中显示
|
||||
var centerLeft = (totalWidth - centerWidth) / 2;
|
||||
var centerRight = centerLeft + centerWidth;
|
||||
|
||||
// 安全间距(像素)
|
||||
const double safetyMargin = 20;
|
||||
|
||||
// 检测左侧组件是否会与中间区域碰撞
|
||||
// 左侧组件右边界 = leftWidth
|
||||
// 中间区域左边界 = centerLeft
|
||||
if (leftWidth + safetyMargin > centerLeft)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// 检测右侧组件是否会与中间区域碰撞
|
||||
// 右侧组件左边界 = totalWidth - rightWidth
|
||||
// 中间区域右边界 = centerRight
|
||||
if (totalWidth - rightWidth - safetyMargin < centerRight)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// 检测中间区域是否会与左右两侧碰撞(中间区域过宽)
|
||||
if (centerLeft < leftWidth + safetyMargin ||
|
||||
centerRight > totalWidth - rightWidth - safetyMargin)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取左侧面板占用的宽度(包括间距)
|
||||
/// </summary>
|
||||
private double GetLeftPanelOccupiedWidth()
|
||||
{
|
||||
if (TopStatusLeftPanel is null)
|
||||
return 0;
|
||||
|
||||
var spacing = TopStatusLeftPanel.Spacing;
|
||||
var width = 0.0;
|
||||
var visibleCount = 0;
|
||||
|
||||
foreach (var child in TopStatusLeftPanel.Children)
|
||||
{
|
||||
if (child is Control control && control.IsVisible)
|
||||
{
|
||||
width += control.Bounds.Width;
|
||||
visibleCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// 添加间距
|
||||
if (visibleCount > 1)
|
||||
{
|
||||
width += spacing * (visibleCount - 1);
|
||||
}
|
||||
|
||||
return width;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取中间面板占用的宽度(包括间距)
|
||||
/// </summary>
|
||||
private double GetCenterPanelOccupiedWidth()
|
||||
{
|
||||
if (TopStatusCenterPanel is null)
|
||||
return 0;
|
||||
|
||||
var spacing = TopStatusCenterPanel.Spacing;
|
||||
var width = 0.0;
|
||||
var visibleCount = 0;
|
||||
|
||||
foreach (var child in TopStatusCenterPanel.Children)
|
||||
{
|
||||
if (child is Control control && control.IsVisible)
|
||||
{
|
||||
width += control.Bounds.Width;
|
||||
visibleCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// 添加间距
|
||||
if (visibleCount > 1)
|
||||
{
|
||||
width += spacing * (visibleCount - 1);
|
||||
}
|
||||
|
||||
return width;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取右侧面板占用的宽度(包括间距)
|
||||
/// </summary>
|
||||
private double GetRightPanelOccupiedWidth()
|
||||
{
|
||||
if (TopStatusRightPanel is null)
|
||||
return 0;
|
||||
|
||||
var spacing = TopStatusRightPanel.Spacing;
|
||||
var width = 0.0;
|
||||
var visibleCount = 0;
|
||||
|
||||
foreach (var child in TopStatusRightPanel.Children)
|
||||
{
|
||||
if (child is Control control && control.IsVisible)
|
||||
{
|
||||
width += control.Bounds.Width;
|
||||
visibleCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// 添加间距
|
||||
if (visibleCount > 1)
|
||||
{
|
||||
width += spacing * (visibleCount - 1);
|
||||
}
|
||||
|
||||
return width;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 检查是否可以在指定位置添加组件
|
||||
/// </summary>
|
||||
private bool CanAddComponentAtPosition(string position)
|
||||
{
|
||||
// 先临时显示组件以计算宽度
|
||||
var wouldCollide = WouldComponentsCollide();
|
||||
if (!wouldCollide)
|
||||
return true;
|
||||
|
||||
// 如果会发生碰撞,检查是否是因为目标位置导致的
|
||||
// 获取当前各区域宽度
|
||||
var leftWidth = GetLeftPanelOccupiedWidth();
|
||||
var centerWidth = GetCenterPanelOccupiedWidth();
|
||||
var rightWidth = GetRightPanelOccupiedWidth();
|
||||
|
||||
// 估算新组件的宽度(基于当前单元格大小)
|
||||
var estimatedNewComponentWidth = _currentDesktopCellSize > 0 ? _currentDesktopCellSize * 2 : 120;
|
||||
|
||||
// 根据目标位置检查添加后是否会碰撞
|
||||
return position switch
|
||||
{
|
||||
"Left" => CanAddToLeft(leftWidth, centerWidth, rightWidth, estimatedNewComponentWidth),
|
||||
"Center" => CanAddToCenter(leftWidth, centerWidth, rightWidth, estimatedNewComponentWidth),
|
||||
"Right" => CanAddToRight(leftWidth, centerWidth, rightWidth, estimatedNewComponentWidth),
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
|
||||
private bool CanAddToLeft(double leftWidth, double centerWidth, double rightWidth, double newWidth)
|
||||
{
|
||||
if (TopStatusBarHost is null)
|
||||
return false;
|
||||
|
||||
var totalWidth = TopStatusBarHost.Bounds.Width;
|
||||
if (totalWidth <= 0)
|
||||
return true;
|
||||
|
||||
var newLeftWidth = leftWidth + newWidth + TopStatusLeftPanel?.Spacing ?? 6;
|
||||
var centerLeft = (totalWidth - centerWidth) / 2;
|
||||
|
||||
const double safetyMargin = 20;
|
||||
return newLeftWidth + safetyMargin <= centerLeft;
|
||||
}
|
||||
|
||||
private bool CanAddToCenter(double leftWidth, double centerWidth, double rightWidth, double newWidth)
|
||||
{
|
||||
if (TopStatusBarHost is null)
|
||||
return false;
|
||||
|
||||
var totalWidth = TopStatusBarHost.Bounds.Width;
|
||||
if (totalWidth <= 0)
|
||||
return true;
|
||||
|
||||
var newCenterWidth = centerWidth + newWidth + TopStatusCenterPanel?.Spacing ?? 6;
|
||||
var centerLeft = (totalWidth - newCenterWidth) / 2;
|
||||
var centerRight = centerLeft + newCenterWidth;
|
||||
|
||||
const double safetyMargin = 20;
|
||||
return centerLeft >= leftWidth + safetyMargin &&
|
||||
centerRight <= totalWidth - rightWidth - safetyMargin;
|
||||
}
|
||||
|
||||
private bool CanAddToRight(double leftWidth, double centerWidth, double rightWidth, double newWidth)
|
||||
{
|
||||
if (TopStatusBarHost is null)
|
||||
return false;
|
||||
|
||||
var totalWidth = TopStatusBarHost.Bounds.Width;
|
||||
if (totalWidth <= 0)
|
||||
return true;
|
||||
|
||||
var newRightWidth = rightWidth + newWidth + TopStatusRightPanel?.Spacing ?? 6;
|
||||
var centerRight = (totalWidth + centerWidth) / 2;
|
||||
|
||||
const double safetyMargin = 20;
|
||||
return totalWidth - newRightWidth - safetyMargin >= centerRight;
|
||||
}
|
||||
|
||||
private void ApplyTopStatusComponentVisibility()
|
||||
@@ -377,16 +772,168 @@ public partial class MainWindow
|
||||
var showClock = _topStatusComponentIds.Contains(BuiltInComponentIds.Clock);
|
||||
var hasVisibleTopStatusComponent = false;
|
||||
|
||||
if (ClockWidget is not null)
|
||||
// 先隐藏所有时钟控件
|
||||
if (ClockWidgetLeft is not null)
|
||||
ClockWidgetLeft.IsVisible = false;
|
||||
if (ClockWidgetCenter is not null)
|
||||
ClockWidgetCenter.IsVisible = false;
|
||||
if (ClockWidgetRight is not null)
|
||||
ClockWidgetRight.IsVisible = false;
|
||||
|
||||
// 先隐藏所有文字胶囊控件
|
||||
if (TextCapsuleWidgetLeft is not null)
|
||||
TextCapsuleWidgetLeft.IsVisible = false;
|
||||
if (TextCapsuleWidgetCenter is not null)
|
||||
TextCapsuleWidgetCenter.IsVisible = false;
|
||||
if (TextCapsuleWidgetRight is not null)
|
||||
TextCapsuleWidgetRight.IsVisible = false;
|
||||
|
||||
// 先隐藏所有网速控件
|
||||
if (NetworkSpeedWidgetLeft is not null)
|
||||
NetworkSpeedWidgetLeft.IsVisible = false;
|
||||
if (NetworkSpeedWidgetCenter is not null)
|
||||
NetworkSpeedWidgetCenter.IsVisible = false;
|
||||
if (NetworkSpeedWidgetRight is not null)
|
||||
NetworkSpeedWidgetRight.IsVisible = false;
|
||||
|
||||
// 根据位置设置显示对应的时钟控件(带碰撞检测)
|
||||
if (showClock)
|
||||
{
|
||||
ClockWidget.IsVisible = showClock;
|
||||
ClockWidget.SetTransparentBackground(_statusBarClockTransparentBackground);
|
||||
if (showClock)
|
||||
var targetPosition = _clockPosition;
|
||||
var canAdd = CanAddComponentAtPosition(targetPosition);
|
||||
|
||||
if (canAdd)
|
||||
{
|
||||
ClockWidget.SetDisplayFormat(_clockDisplayFormat);
|
||||
var columnSpan = _clockDisplayFormat == ClockDisplayFormat.HourMinute ? 2 : 3;
|
||||
Grid.SetColumnSpan(ClockWidget, columnSpan);
|
||||
hasVisibleTopStatusComponent = true;
|
||||
var targetClock = targetPosition switch
|
||||
{
|
||||
"Center" => ClockWidgetCenter,
|
||||
"Right" => ClockWidgetRight,
|
||||
_ => ClockWidgetLeft
|
||||
};
|
||||
|
||||
if (targetClock is not null)
|
||||
{
|
||||
targetClock.IsVisible = true;
|
||||
targetClock.SetTransparentBackground(_statusBarClockTransparentBackground);
|
||||
targetClock.SetDisplayFormat(_clockDisplayFormat);
|
||||
hasVisibleTopStatusComponent = true;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// 如果目标位置无法添加,尝试其他位置
|
||||
var alternativePosition = FindAlternativePosition(targetPosition);
|
||||
if (alternativePosition is not null)
|
||||
{
|
||||
var targetClock = alternativePosition switch
|
||||
{
|
||||
"Center" => ClockWidgetCenter,
|
||||
"Right" => ClockWidgetRight,
|
||||
_ => ClockWidgetLeft
|
||||
};
|
||||
|
||||
if (targetClock is not null)
|
||||
{
|
||||
targetClock.IsVisible = true;
|
||||
targetClock.SetTransparentBackground(_statusBarClockTransparentBackground);
|
||||
targetClock.SetDisplayFormat(_clockDisplayFormat);
|
||||
hasVisibleTopStatusComponent = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 根据位置设置显示对应的文字胶囊控件(带碰撞检测)
|
||||
if (_showTextCapsule)
|
||||
{
|
||||
var targetPosition = _textCapsulePosition;
|
||||
var canAdd = CanAddComponentAtPosition(targetPosition);
|
||||
|
||||
if (canAdd)
|
||||
{
|
||||
var targetTextCapsule = targetPosition switch
|
||||
{
|
||||
"Left" => TextCapsuleWidgetLeft,
|
||||
"Center" => TextCapsuleWidgetCenter,
|
||||
_ => TextCapsuleWidgetRight
|
||||
};
|
||||
|
||||
if (targetTextCapsule is not null)
|
||||
{
|
||||
targetTextCapsule.IsVisible = true;
|
||||
targetTextCapsule.SetTransparentBackground(_textCapsuleTransparentBackground);
|
||||
targetTextCapsule.SetText(_textCapsuleContent);
|
||||
hasVisibleTopStatusComponent = true;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// 如果目标位置无法添加,尝试其他位置
|
||||
var alternativePosition = FindAlternativePosition(targetPosition);
|
||||
if (alternativePosition is not null)
|
||||
{
|
||||
var targetTextCapsule = alternativePosition switch
|
||||
{
|
||||
"Left" => TextCapsuleWidgetLeft,
|
||||
"Center" => TextCapsuleWidgetCenter,
|
||||
_ => TextCapsuleWidgetRight
|
||||
};
|
||||
|
||||
if (targetTextCapsule is not null)
|
||||
{
|
||||
targetTextCapsule.IsVisible = true;
|
||||
targetTextCapsule.SetTransparentBackground(_textCapsuleTransparentBackground);
|
||||
targetTextCapsule.SetText(_textCapsuleContent);
|
||||
hasVisibleTopStatusComponent = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 根据位置设置显示对应的网速控件(带碰撞检测)
|
||||
if (_showNetworkSpeed)
|
||||
{
|
||||
var targetPosition = _networkSpeedPosition;
|
||||
var canAdd = CanAddComponentAtPosition(targetPosition);
|
||||
|
||||
if (canAdd)
|
||||
{
|
||||
var targetNetworkSpeed = targetPosition switch
|
||||
{
|
||||
"Left" => NetworkSpeedWidgetLeft,
|
||||
"Center" => NetworkSpeedWidgetCenter,
|
||||
_ => NetworkSpeedWidgetRight
|
||||
};
|
||||
|
||||
if (targetNetworkSpeed is not null)
|
||||
{
|
||||
targetNetworkSpeed.IsVisible = true;
|
||||
targetNetworkSpeed.SetTransparentBackground(_networkSpeedTransparentBackground);
|
||||
targetNetworkSpeed.SetDisplayMode(_networkSpeedDisplayMode);
|
||||
hasVisibleTopStatusComponent = true;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// 如果目标位置无法添加,尝试其他位置
|
||||
var alternativePosition = FindAlternativePosition(targetPosition);
|
||||
if (alternativePosition is not null)
|
||||
{
|
||||
var targetNetworkSpeed = alternativePosition switch
|
||||
{
|
||||
"Left" => NetworkSpeedWidgetLeft,
|
||||
"Center" => NetworkSpeedWidgetCenter,
|
||||
_ => NetworkSpeedWidgetRight
|
||||
};
|
||||
|
||||
if (targetNetworkSpeed is not null)
|
||||
{
|
||||
targetNetworkSpeed.IsVisible = true;
|
||||
targetNetworkSpeed.SetTransparentBackground(_networkSpeedTransparentBackground);
|
||||
targetNetworkSpeed.SetDisplayMode(_networkSpeedDisplayMode);
|
||||
hasVisibleTopStatusComponent = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -394,6 +941,244 @@ public partial class MainWindow
|
||||
{
|
||||
TopStatusBarHost.IsVisible = hasVisibleTopStatusComponent;
|
||||
}
|
||||
|
||||
// 延迟检查碰撞并调整
|
||||
Dispatcher.UIThread.Post(async () =>
|
||||
{
|
||||
await System.Threading.Tasks.Task.Delay(50);
|
||||
AdjustComponentsIfColliding();
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 当组件发生碰撞时,自动调整位置
|
||||
/// </summary>
|
||||
private void AdjustComponentsIfColliding()
|
||||
{
|
||||
if (!WouldComponentsCollide())
|
||||
return;
|
||||
|
||||
// 获取当前可见的组件
|
||||
var leftComponents = GetVisibleLeftComponents();
|
||||
var centerComponents = GetVisibleCenterComponents();
|
||||
var rightComponents = GetVisibleRightComponents();
|
||||
|
||||
// 优先保留时钟,调整文字胶囊位置
|
||||
if (TextCapsuleWidgetLeft?.IsVisible == true && WouldComponentsCollide())
|
||||
{
|
||||
// 尝试将左侧文字胶囊移到中间
|
||||
if (CanAddComponentAtPosition("Center"))
|
||||
{
|
||||
TextCapsuleWidgetLeft.IsVisible = false;
|
||||
TextCapsuleWidgetCenter!.IsVisible = true;
|
||||
TextCapsuleWidgetCenter.SetTransparentBackground(_textCapsuleTransparentBackground);
|
||||
TextCapsuleWidgetCenter.SetText(_textCapsuleContent);
|
||||
}
|
||||
// 或者移到右侧
|
||||
else if (CanAddComponentAtPosition("Right"))
|
||||
{
|
||||
TextCapsuleWidgetLeft.IsVisible = false;
|
||||
TextCapsuleWidgetRight!.IsVisible = true;
|
||||
TextCapsuleWidgetRight.SetTransparentBackground(_textCapsuleTransparentBackground);
|
||||
TextCapsuleWidgetRight.SetText(_textCapsuleContent);
|
||||
}
|
||||
// 如果都无法添加,则隐藏文字胶囊
|
||||
else
|
||||
{
|
||||
TextCapsuleWidgetLeft.IsVisible = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (TextCapsuleWidgetRight?.IsVisible == true && WouldComponentsCollide())
|
||||
{
|
||||
// 尝试将右侧文字胶囊移到中间
|
||||
if (CanAddComponentAtPosition("Center"))
|
||||
{
|
||||
TextCapsuleWidgetRight.IsVisible = false;
|
||||
TextCapsuleWidgetCenter!.IsVisible = true;
|
||||
TextCapsuleWidgetCenter.SetTransparentBackground(_textCapsuleTransparentBackground);
|
||||
TextCapsuleWidgetCenter.SetText(_textCapsuleContent);
|
||||
}
|
||||
// 或者移到左侧
|
||||
else if (CanAddComponentAtPosition("Left"))
|
||||
{
|
||||
TextCapsuleWidgetRight.IsVisible = false;
|
||||
TextCapsuleWidgetLeft!.IsVisible = true;
|
||||
TextCapsuleWidgetLeft.SetTransparentBackground(_textCapsuleTransparentBackground);
|
||||
TextCapsuleWidgetLeft.SetText(_textCapsuleContent);
|
||||
}
|
||||
// 如果都无法添加,则隐藏文字胶囊
|
||||
else
|
||||
{
|
||||
TextCapsuleWidgetRight.IsVisible = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (TextCapsuleWidgetCenter?.IsVisible == true && WouldComponentsCollide())
|
||||
{
|
||||
// 尝试将中间文字胶囊移到左侧
|
||||
if (CanAddComponentAtPosition("Left"))
|
||||
{
|
||||
TextCapsuleWidgetCenter.IsVisible = false;
|
||||
TextCapsuleWidgetLeft!.IsVisible = true;
|
||||
TextCapsuleWidgetLeft.SetTransparentBackground(_textCapsuleTransparentBackground);
|
||||
TextCapsuleWidgetLeft.SetText(_textCapsuleContent);
|
||||
}
|
||||
// 或者移到右侧
|
||||
else if (CanAddComponentAtPosition("Right"))
|
||||
{
|
||||
TextCapsuleWidgetCenter.IsVisible = false;
|
||||
TextCapsuleWidgetRight!.IsVisible = true;
|
||||
TextCapsuleWidgetRight.SetTransparentBackground(_textCapsuleTransparentBackground);
|
||||
TextCapsuleWidgetRight.SetText(_textCapsuleContent);
|
||||
}
|
||||
// 如果都无法添加,则隐藏文字胶囊
|
||||
else
|
||||
{
|
||||
TextCapsuleWidgetCenter.IsVisible = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 调整网速组件位置(优先级:时钟 > 文字胶囊 > 网速)
|
||||
if (NetworkSpeedWidgetLeft?.IsVisible == true && WouldComponentsCollide())
|
||||
{
|
||||
// 尝试将左侧网速移到中间
|
||||
if (CanAddComponentAtPosition("Center"))
|
||||
{
|
||||
NetworkSpeedWidgetLeft.IsVisible = false;
|
||||
NetworkSpeedWidgetCenter!.IsVisible = true;
|
||||
NetworkSpeedWidgetCenter.SetTransparentBackground(_networkSpeedTransparentBackground);
|
||||
NetworkSpeedWidgetCenter.SetDisplayMode(_networkSpeedDisplayMode);
|
||||
}
|
||||
// 或者移到右侧
|
||||
else if (CanAddComponentAtPosition("Right"))
|
||||
{
|
||||
NetworkSpeedWidgetLeft.IsVisible = false;
|
||||
NetworkSpeedWidgetRight!.IsVisible = true;
|
||||
NetworkSpeedWidgetRight.SetTransparentBackground(_networkSpeedTransparentBackground);
|
||||
NetworkSpeedWidgetRight.SetDisplayMode(_networkSpeedDisplayMode);
|
||||
}
|
||||
// 如果都无法添加,则隐藏网速
|
||||
else
|
||||
{
|
||||
NetworkSpeedWidgetLeft.IsVisible = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (NetworkSpeedWidgetRight?.IsVisible == true && WouldComponentsCollide())
|
||||
{
|
||||
// 尝试将右侧网速移到中间
|
||||
if (CanAddComponentAtPosition("Center"))
|
||||
{
|
||||
NetworkSpeedWidgetRight.IsVisible = false;
|
||||
NetworkSpeedWidgetCenter!.IsVisible = true;
|
||||
NetworkSpeedWidgetCenter.SetTransparentBackground(_networkSpeedTransparentBackground);
|
||||
NetworkSpeedWidgetCenter.SetDisplayMode(_networkSpeedDisplayMode);
|
||||
}
|
||||
// 或者移到左侧
|
||||
else if (CanAddComponentAtPosition("Left"))
|
||||
{
|
||||
NetworkSpeedWidgetRight.IsVisible = false;
|
||||
NetworkSpeedWidgetLeft!.IsVisible = true;
|
||||
NetworkSpeedWidgetLeft.SetTransparentBackground(_networkSpeedTransparentBackground);
|
||||
NetworkSpeedWidgetLeft.SetDisplayMode(_networkSpeedDisplayMode);
|
||||
}
|
||||
// 如果都无法添加,则隐藏网速
|
||||
else
|
||||
{
|
||||
NetworkSpeedWidgetRight.IsVisible = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (NetworkSpeedWidgetCenter?.IsVisible == true && WouldComponentsCollide())
|
||||
{
|
||||
// 尝试将中间网速移到左侧
|
||||
if (CanAddComponentAtPosition("Left"))
|
||||
{
|
||||
NetworkSpeedWidgetCenter.IsVisible = false;
|
||||
NetworkSpeedWidgetLeft!.IsVisible = true;
|
||||
NetworkSpeedWidgetLeft.SetTransparentBackground(_networkSpeedTransparentBackground);
|
||||
NetworkSpeedWidgetLeft.SetDisplayMode(_networkSpeedDisplayMode);
|
||||
}
|
||||
// 或者移到右侧
|
||||
else if (CanAddComponentAtPosition("Right"))
|
||||
{
|
||||
NetworkSpeedWidgetCenter.IsVisible = false;
|
||||
NetworkSpeedWidgetRight!.IsVisible = true;
|
||||
NetworkSpeedWidgetRight.SetTransparentBackground(_networkSpeedTransparentBackground);
|
||||
NetworkSpeedWidgetRight.SetDisplayMode(_networkSpeedDisplayMode);
|
||||
}
|
||||
// 如果都无法添加,则隐藏网速
|
||||
else
|
||||
{
|
||||
NetworkSpeedWidgetCenter.IsVisible = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 查找可用的替代位置
|
||||
/// </summary>
|
||||
private string? FindAlternativePosition(string originalPosition)
|
||||
{
|
||||
// 尝试所有可能的位置
|
||||
var positions = new[] { "Left", "Center", "Right" };
|
||||
foreach (var position in positions)
|
||||
{
|
||||
if (position != originalPosition && CanAddComponentAtPosition(position))
|
||||
{
|
||||
return position;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取左侧可见组件列表
|
||||
/// </summary>
|
||||
private List<Control> GetVisibleLeftComponents()
|
||||
{
|
||||
var result = new List<Control>();
|
||||
if (TopStatusLeftPanel is null) return result;
|
||||
|
||||
foreach (var child in TopStatusLeftPanel.Children)
|
||||
{
|
||||
if (child is Control control && control.IsVisible)
|
||||
result.Add(control);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取中间可见组件列表
|
||||
/// </summary>
|
||||
private List<Control> GetVisibleCenterComponents()
|
||||
{
|
||||
var result = new List<Control>();
|
||||
if (TopStatusCenterPanel is null) return result;
|
||||
|
||||
foreach (var child in TopStatusCenterPanel.Children)
|
||||
{
|
||||
if (child is Control control && control.IsVisible)
|
||||
result.Add(control);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取右侧可见组件列表
|
||||
/// </summary>
|
||||
private List<Control> GetVisibleRightComponents()
|
||||
{
|
||||
var result = new List<Control>();
|
||||
if (TopStatusRightPanel is null) return result;
|
||||
|
||||
foreach (var child in TopStatusRightPanel.Children)
|
||||
{
|
||||
if (child is Control control && control.IsVisible)
|
||||
result.Add(control);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private TaskbarContext GetCurrentTaskbarContext()
|
||||
|
||||
@@ -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;
|
||||
@@ -264,12 +269,6 @@ public partial class MainWindow
|
||||
LauncherPagePanel.MaxWidth = pageWidth - launcherMargin * 2;
|
||||
LauncherPagePanel.MaxHeight = pageHeight - launcherMargin * 2;
|
||||
|
||||
if (LauncherFolderPanel is not null)
|
||||
{
|
||||
LauncherFolderPanel.MaxWidth = Math.Max(320, pageWidth - 96);
|
||||
LauncherFolderPanel.MaxHeight = Math.Max(220, pageHeight - 96);
|
||||
}
|
||||
|
||||
// 更新启动台图标布局
|
||||
UpdateLauncherTileLayout();
|
||||
|
||||
@@ -326,19 +325,6 @@ public partial class MainWindow
|
||||
}
|
||||
}
|
||||
|
||||
// 同样更新文件夹视图的图标尺寸
|
||||
if (LauncherFolderTilePanel is not null)
|
||||
{
|
||||
LauncherFolderTilePanel.Width = availableWidth;
|
||||
foreach (var child in LauncherFolderTilePanel.Children)
|
||||
{
|
||||
if (child is Button button)
|
||||
{
|
||||
button.Width = tileWidth;
|
||||
button.Height = tileHeight;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ClampSurfaceIndex()
|
||||
@@ -515,6 +501,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 +554,7 @@ public partial class MainWindow
|
||||
return;
|
||||
}
|
||||
|
||||
if (!e.GetCurrentPoint(DesktopPagesViewport).Properties.IsLeftButtonPressed)
|
||||
if (!isLeftButtonPressed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -582,8 +611,12 @@ public partial class MainWindow
|
||||
|
||||
foreach (var node in button.GetSelfAndVisualAncestors())
|
||||
{
|
||||
if (node is WrapPanel panel &&
|
||||
(panel.Name == "LauncherRootTilePanel" || panel.Name == "LauncherFolderTilePanel"))
|
||||
if (node is WrapPanel panel && panel.Name == "LauncherRootTilePanel")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (node is Grid grid && grid.Name == "LauncherFolderGridPanel")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
@@ -671,8 +704,7 @@ public partial class MainWindow
|
||||
return false;
|
||||
}
|
||||
|
||||
return scrollViewer.Name == "LauncherRootScrollViewer" ||
|
||||
scrollViewer.Name == "LauncherFolderScrollViewer";
|
||||
return scrollViewer.Name == "LauncherRootScrollViewer";
|
||||
}
|
||||
|
||||
private bool TryGetPointerPositionInDesktopViewport(PointerEventArgs e, out Point point)
|
||||
@@ -776,6 +808,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 +820,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 +842,8 @@ public partial class MainWindow
|
||||
|
||||
_isDesktopSwipeActive = false;
|
||||
_isDesktopSwipeDirectionLocked = false;
|
||||
_isThreeFingerOrRightDragSwipeActive = false;
|
||||
_activePointerIds.Clear();
|
||||
_desktopSwipeVelocityX = 0;
|
||||
_desktopSwipeLastTimestamp = 0;
|
||||
if (wasDirectionLocked)
|
||||
@@ -819,8 +861,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 +895,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(
|
||||
@@ -1482,18 +1545,17 @@ public partial class MainWindow
|
||||
LauncherFolderOverlay.IsVisible = false;
|
||||
}
|
||||
|
||||
if (LauncherFolderTilePanel is not null)
|
||||
if (LauncherFolderGridPanel is not null)
|
||||
{
|
||||
LauncherFolderTilePanel.Children.Clear();
|
||||
LauncherFolderGridPanel.Children.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
private void RenderLauncherFolderFromStack()
|
||||
{
|
||||
if (LauncherFolderOverlay is null ||
|
||||
LauncherFolderTilePanel is null ||
|
||||
LauncherFolderTitleTextBlock is null ||
|
||||
LauncherFolderBackButton is null)
|
||||
LauncherFolderGridPanel is null ||
|
||||
LauncherFolderTitleTextBlock is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -1508,38 +1570,230 @@ public partial class MainWindow
|
||||
var folder = _launcherFolderStack.Peek();
|
||||
LauncherFolderOverlay.IsVisible = true;
|
||||
LauncherFolderTitleTextBlock.Text = folder.Name;
|
||||
LauncherFolderBackButton.IsVisible = _launcherFolderStack.Count > 1;
|
||||
|
||||
LauncherFolderTilePanel.Children.Clear();
|
||||
foreach (var subFolder in folder.Folders)
|
||||
LauncherFolderGridPanel.Children.Clear();
|
||||
|
||||
const int maxCols = 4;
|
||||
const int maxRows = 3;
|
||||
const int maxItems = maxCols * maxRows;
|
||||
|
||||
var visibleFolders = folder.Folders.Where(IsLauncherFolderVisible).ToList();
|
||||
var visibleApps = folder.Apps.Where(IsLauncherAppVisible).ToList();
|
||||
|
||||
if (visibleFolders.Count == 0 && visibleApps.Count == 0)
|
||||
{
|
||||
if (!IsLauncherFolderVisible(subFolder))
|
||||
LauncherFolderGridPanel.Children.Add(CreateLauncherFolderGridHintCell(
|
||||
L("launcher.empty_folder", "This folder is empty.")));
|
||||
return;
|
||||
}
|
||||
|
||||
var allItems = new List<(StartMenuFolderNode? Folder, StartMenuAppEntry? App)>();
|
||||
foreach (var f in visibleFolders)
|
||||
{
|
||||
allItems.Add((f, null));
|
||||
}
|
||||
foreach (var a in visibleApps)
|
||||
{
|
||||
allItems.Add((null, a));
|
||||
}
|
||||
|
||||
var displayCount = Math.Min(allItems.Count, maxItems);
|
||||
for (var i = 0; i < displayCount; i++)
|
||||
{
|
||||
var col = i % maxCols;
|
||||
var row = i / maxCols;
|
||||
var (itemFolder, itemApp) = allItems[i];
|
||||
|
||||
Control cell;
|
||||
if (itemFolder is not null)
|
||||
{
|
||||
var capturedFolder = itemFolder;
|
||||
cell = CreateLauncherFolderGridTile(itemFolder.Name, GetLauncherFolderIconBitmap(), () => OpenLauncherFolder(capturedFolder));
|
||||
}
|
||||
else if (itemApp is not null)
|
||||
{
|
||||
var capturedApp = itemApp;
|
||||
cell = CreateLauncherFolderGridTile(capturedApp, () => LaunchStartMenuEntry(capturedApp));
|
||||
}
|
||||
else
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
LauncherFolderTilePanel.Children.Add(CreateLauncherFolderTile(subFolder));
|
||||
Grid.SetColumn(cell, col);
|
||||
Grid.SetRow(cell, row);
|
||||
LauncherFolderGridPanel.Children.Add(cell);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var app in folder.Apps)
|
||||
{
|
||||
if (!IsLauncherAppVisible(app))
|
||||
private Button CreateLauncherFolderGridTile(StartMenuAppEntry app, Action clickAction)
|
||||
{
|
||||
var iconBitmap = GetLauncherIconBitmap(app);
|
||||
var monogram = BuildMonogram(app.DisplayName);
|
||||
|
||||
Control iconControl = iconBitmap is not null
|
||||
? new Image
|
||||
{
|
||||
continue;
|
||||
Source = iconBitmap,
|
||||
Width = 32,
|
||||
Height = 32,
|
||||
Stretch = Stretch.Uniform
|
||||
}
|
||||
: new Border
|
||||
{
|
||||
Width = 32,
|
||||
Height = 32,
|
||||
CornerRadius = new CornerRadius(8),
|
||||
Background = GetThemeBrush("AdaptiveButtonBackgroundBrush"),
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
Child = new TextBlock
|
||||
{
|
||||
Text = monogram,
|
||||
FontSize = 13,
|
||||
FontWeight = FontWeight.Bold,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
VerticalAlignment = VerticalAlignment.Center
|
||||
}
|
||||
};
|
||||
|
||||
var content = new StackPanel
|
||||
{
|
||||
Spacing = 6,
|
||||
HorizontalAlignment = HorizontalAlignment.Stretch,
|
||||
VerticalAlignment = VerticalAlignment.Center
|
||||
};
|
||||
content.Children.Add(iconControl);
|
||||
content.Children.Add(new TextBlock
|
||||
{
|
||||
Text = app.DisplayName,
|
||||
TextTrimming = TextTrimming.CharacterEllipsis,
|
||||
MaxLines = 2,
|
||||
TextAlignment = TextAlignment.Center,
|
||||
FontSize = 11,
|
||||
HorizontalAlignment = HorizontalAlignment.Stretch
|
||||
});
|
||||
|
||||
var button = new Button
|
||||
{
|
||||
Classes = { "glass-panel" },
|
||||
HorizontalAlignment = HorizontalAlignment.Stretch,
|
||||
VerticalAlignment = VerticalAlignment.Stretch,
|
||||
BorderThickness = new Thickness(0),
|
||||
CornerRadius = new CornerRadius(12),
|
||||
Padding = new Thickness(8, 8, 8, 6),
|
||||
Content = content
|
||||
};
|
||||
button.Click += (_, _) =>
|
||||
{
|
||||
if (_isComponentLibraryOpen)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
LauncherFolderTilePanel.Children.Add(CreateLauncherAppTile(app));
|
||||
}
|
||||
clickAction();
|
||||
};
|
||||
return button;
|
||||
}
|
||||
|
||||
if (LauncherFolderTilePanel.Children.Count == 0)
|
||||
private Button CreateLauncherFolderGridTile(string folderName, Bitmap? iconBitmap, Action clickAction)
|
||||
{
|
||||
var monogram = "DIR";
|
||||
|
||||
Control iconControl = iconBitmap is not null
|
||||
? new Image
|
||||
{
|
||||
Source = iconBitmap,
|
||||
Width = 32,
|
||||
Height = 32,
|
||||
Stretch = Stretch.Uniform
|
||||
}
|
||||
: new Border
|
||||
{
|
||||
Width = 32,
|
||||
Height = 32,
|
||||
CornerRadius = new CornerRadius(8),
|
||||
Background = GetThemeBrush("AdaptiveButtonBackgroundBrush"),
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
Child = new TextBlock
|
||||
{
|
||||
Text = monogram,
|
||||
FontSize = 11,
|
||||
FontWeight = FontWeight.Bold,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
VerticalAlignment = VerticalAlignment.Center
|
||||
}
|
||||
};
|
||||
|
||||
var content = new StackPanel
|
||||
{
|
||||
LauncherFolderTilePanel.Children.Add(CreateLauncherHintTile(
|
||||
L("launcher.empty_folder", "This folder is empty."),
|
||||
string.Empty));
|
||||
}
|
||||
Spacing = 6,
|
||||
HorizontalAlignment = HorizontalAlignment.Stretch,
|
||||
VerticalAlignment = VerticalAlignment.Center
|
||||
};
|
||||
content.Children.Add(iconControl);
|
||||
content.Children.Add(new TextBlock
|
||||
{
|
||||
Text = folderName,
|
||||
TextTrimming = TextTrimming.CharacterEllipsis,
|
||||
MaxLines = 2,
|
||||
TextAlignment = TextAlignment.Center,
|
||||
FontSize = 11,
|
||||
HorizontalAlignment = HorizontalAlignment.Stretch
|
||||
});
|
||||
|
||||
// 在图标渲染完成后,应用布局计算
|
||||
Dispatcher.UIThread.Post(() => UpdateLauncherTileLayout(), DispatcherPriority.Background);
|
||||
var button = new Button
|
||||
{
|
||||
Classes = { "glass-panel" },
|
||||
HorizontalAlignment = HorizontalAlignment.Stretch,
|
||||
VerticalAlignment = VerticalAlignment.Stretch,
|
||||
BorderThickness = new Thickness(0),
|
||||
CornerRadius = new CornerRadius(12),
|
||||
Padding = new Thickness(8, 8, 8, 6),
|
||||
Content = content
|
||||
};
|
||||
button.Click += (_, _) =>
|
||||
{
|
||||
if (_isComponentLibraryOpen)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
clickAction();
|
||||
};
|
||||
return button;
|
||||
}
|
||||
|
||||
private Control CreateLauncherFolderGridHintCell(string message)
|
||||
{
|
||||
return CreateLauncherFolderGridHintCell(message, 0, 0);
|
||||
}
|
||||
|
||||
private Control CreateLauncherFolderGridHintCell(string message, int col, int row)
|
||||
{
|
||||
var textBlock = new TextBlock
|
||||
{
|
||||
Text = message,
|
||||
FontSize = 12,
|
||||
FontWeight = FontWeight.SemiBold,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
Opacity = 0.6
|
||||
};
|
||||
|
||||
var cell = new Border
|
||||
{
|
||||
Classes = { "glass-panel" },
|
||||
HorizontalAlignment = HorizontalAlignment.Stretch,
|
||||
VerticalAlignment = VerticalAlignment.Stretch,
|
||||
CornerRadius = new CornerRadius(12),
|
||||
Child = textBlock
|
||||
};
|
||||
|
||||
Grid.SetColumn(cell, col);
|
||||
Grid.SetRow(cell, row);
|
||||
return cell;
|
||||
}
|
||||
|
||||
private static string BuildMonogram(string text)
|
||||
@@ -1610,18 +1864,6 @@ public partial class MainWindow
|
||||
}
|
||||
}
|
||||
|
||||
private void OnLauncherFolderBackClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_launcherFolderStack.Count <= 1)
|
||||
{
|
||||
CloseLauncherFolderOverlay();
|
||||
return;
|
||||
}
|
||||
|
||||
_launcherFolderStack.Pop();
|
||||
RenderLauncherFolderFromStack();
|
||||
}
|
||||
|
||||
private void OnLauncherFolderOverlayPointerPressed(object? sender, PointerPressedEventArgs e)
|
||||
{
|
||||
if (LauncherFolderPanel is null)
|
||||
@@ -1642,11 +1884,6 @@ public partial class MainWindow
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private void OnLauncherFolderCloseClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
CloseLauncherFolderOverlay();
|
||||
}
|
||||
|
||||
private void DisposeLauncherResources()
|
||||
{
|
||||
foreach (var bitmap in _launcherIconCache.Values)
|
||||
|
||||
@@ -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();
|
||||
@@ -644,8 +650,24 @@ public partial class MainWindow
|
||||
TaskbarLayoutMode = _taskbarLayoutMode,
|
||||
ClockDisplayFormat = _clockDisplayFormat == ClockDisplayFormat.HourMinute ? "HourMinute" : "HourMinuteSecond",
|
||||
StatusBarClockTransparentBackground = _statusBarClockTransparentBackground,
|
||||
ClockPosition = _clockPosition,
|
||||
ClockFontSize = _clockFontSize,
|
||||
ShowTextCapsule = _showTextCapsule,
|
||||
TextCapsuleContent = _textCapsuleContent,
|
||||
TextCapsulePosition = _textCapsulePosition,
|
||||
TextCapsuleTransparentBackground = _textCapsuleTransparentBackground,
|
||||
TextCapsuleFontSize = _textCapsuleFontSize,
|
||||
ShowNetworkSpeed = _showNetworkSpeed,
|
||||
NetworkSpeedPosition = _networkSpeedPosition,
|
||||
NetworkSpeedDisplayMode = _networkSpeedDisplayMode,
|
||||
NetworkSpeedTransparentBackground = _networkSpeedTransparentBackground,
|
||||
ShowNetworkTypeIcon = _showNetworkTypeIcon,
|
||||
NetworkSpeedFontSize = _networkSpeedFontSize,
|
||||
StatusBarSpacingMode = _statusBarSpacingMode,
|
||||
StatusBarCustomSpacingPercent = _statusBarCustomSpacingPercent,
|
||||
StatusBarShadowEnabled = _statusBarShadowEnabled,
|
||||
StatusBarShadowColor = _statusBarShadowColor,
|
||||
StatusBarShadowOpacity = _statusBarShadowOpacity,
|
||||
DisabledPluginIds = existingSnapshot.DisabledPluginIds,
|
||||
StudyFrameMs = existingSnapshot.StudyFrameMs,
|
||||
StudyScoreThresholdDbfs = existingSnapshot.StudyScoreThresholdDbfs,
|
||||
|
||||
@@ -189,50 +189,21 @@
|
||||
Classes="surface-solid-strong"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Margin="52"
|
||||
MaxWidth="760"
|
||||
MaxHeight="520"
|
||||
CornerRadius="36"
|
||||
Padding="14">
|
||||
<Border.RenderTransform>
|
||||
<TranslateTransform Y="42" />
|
||||
</Border.RenderTransform>
|
||||
<Grid RowDefinitions="Auto,*"
|
||||
RowSpacing="10">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto"
|
||||
ColumnSpacing="8">
|
||||
<Button x:Name="LauncherFolderBackButton"
|
||||
Grid.Column="0"
|
||||
Width="38"
|
||||
Height="34"
|
||||
Padding="0"
|
||||
Click="OnLauncherFolderBackClick">
|
||||
<fi:FluentIcon Icon="ArrowLeft"
|
||||
IconVariant="Regular" />
|
||||
</Button>
|
||||
<TextBlock x:Name="LauncherFolderTitleTextBlock"
|
||||
Grid.Column="1"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
FontWeight="SemiBold" />
|
||||
<Button x:Name="LauncherFolderCloseButton"
|
||||
Grid.Column="2"
|
||||
Width="38"
|
||||
Height="34"
|
||||
Padding="0"
|
||||
Click="OnLauncherFolderCloseClick">
|
||||
<fi:FluentIcon Icon="Dismiss"
|
||||
IconVariant="Regular" />
|
||||
</Button>
|
||||
</Grid>
|
||||
Width="464"
|
||||
Height="384"
|
||||
CornerRadius="24"
|
||||
Padding="16,14,16,12">
|
||||
<Grid RowDefinitions="Auto,*">
|
||||
<TextBlock x:Name="LauncherFolderTitleTextBlock"
|
||||
FontSize="15"
|
||||
FontWeight="SemiBold"
|
||||
HorizontalAlignment="Center"
|
||||
Margin="0,0,0,10" />
|
||||
|
||||
<ScrollViewer x:Name="LauncherFolderScrollViewer"
|
||||
Grid.Row="1"
|
||||
VerticalScrollBarVisibility="Auto"
|
||||
HorizontalScrollBarVisibility="Disabled">
|
||||
<WrapPanel x:Name="LauncherFolderTilePanel"
|
||||
Orientation="Horizontal" />
|
||||
</ScrollViewer>
|
||||
<Grid x:Name="LauncherFolderGridPanel"
|
||||
Grid.Row="1"
|
||||
ColumnDefinitions="*,*,*,*"
|
||||
RowDefinitions="*,*,*" />
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
@@ -255,20 +226,84 @@
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- 状态栏阴影层 - macOS 风格的完整阴影带 -->
|
||||
<Border x:Name="StatusBarOverlay"
|
||||
Grid.Row="0"
|
||||
Grid.Column="0"
|
||||
Grid.ColumnSpan="1"
|
||||
IsVisible="False"
|
||||
HorizontalAlignment="Stretch"
|
||||
VerticalAlignment="Bottom"
|
||||
Height="24"
|
||||
ZIndex="0"
|
||||
Margin="0,0,0,-24">
|
||||
<Border.Background>
|
||||
<LinearGradientBrush StartPoint="0%,0%" EndPoint="0%,100%">
|
||||
<GradientStop Color="#CC000000" Offset="0.0" />
|
||||
<GradientStop Color="#66000000" Offset="0.3" />
|
||||
<GradientStop Color="#00000000" Offset="1.0" />
|
||||
</LinearGradientBrush>
|
||||
</Border.Background>
|
||||
</Border>
|
||||
|
||||
<Border x:Name="TopStatusBarHost"
|
||||
Grid.Row="0"
|
||||
Grid.Column="0"
|
||||
Grid.ColumnSpan="1"
|
||||
Background="Transparent"
|
||||
BorderThickness="0"
|
||||
Padding="4">
|
||||
<StackPanel x:Name="TopStatusComponentsPanel"
|
||||
Orientation="Horizontal"
|
||||
Spacing="6">
|
||||
<comp:ClockWidget x:Name="ClockWidget"
|
||||
IsVisible="False"
|
||||
Margin="0" />
|
||||
</StackPanel>
|
||||
Padding="4"
|
||||
ZIndex="2">
|
||||
<Grid ColumnDefinitions="*,Auto,*">
|
||||
<!-- 左侧状态栏组件 -->
|
||||
<StackPanel x:Name="TopStatusLeftPanel"
|
||||
Grid.Column="0"
|
||||
Orientation="Horizontal"
|
||||
Spacing="6"
|
||||
HorizontalAlignment="Left">
|
||||
<comp:ClockWidget x:Name="ClockWidgetLeft"
|
||||
IsVisible="False"
|
||||
Margin="0" />
|
||||
<comp:TextCapsuleWidget x:Name="TextCapsuleWidgetLeft"
|
||||
IsVisible="False"
|
||||
Margin="0" />
|
||||
<comp:NetworkSpeedWidget x:Name="NetworkSpeedWidgetLeft"
|
||||
IsVisible="False"
|
||||
Margin="0" />
|
||||
</StackPanel>
|
||||
<!-- 中间状态栏组件 -->
|
||||
<StackPanel x:Name="TopStatusCenterPanel"
|
||||
Grid.Column="1"
|
||||
Orientation="Horizontal"
|
||||
Spacing="6"
|
||||
HorizontalAlignment="Center">
|
||||
<comp:ClockWidget x:Name="ClockWidgetCenter"
|
||||
IsVisible="False"
|
||||
Margin="0" />
|
||||
<comp:TextCapsuleWidget x:Name="TextCapsuleWidgetCenter"
|
||||
IsVisible="False"
|
||||
Margin="0" />
|
||||
<comp:NetworkSpeedWidget x:Name="NetworkSpeedWidgetCenter"
|
||||
IsVisible="False"
|
||||
Margin="0" />
|
||||
</StackPanel>
|
||||
<!-- 右侧状态栏组件 -->
|
||||
<StackPanel x:Name="TopStatusRightPanel"
|
||||
Grid.Column="2"
|
||||
Orientation="Horizontal"
|
||||
Spacing="6"
|
||||
HorizontalAlignment="Right">
|
||||
<comp:ClockWidget x:Name="ClockWidgetRight"
|
||||
IsVisible="False"
|
||||
Margin="0" />
|
||||
<comp:TextCapsuleWidget x:Name="TextCapsuleWidgetRight"
|
||||
IsVisible="False"
|
||||
Margin="0" />
|
||||
<comp:NetworkSpeedWidget x:Name="NetworkSpeedWidgetRight"
|
||||
IsVisible="False"
|
||||
Margin="0" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Border x:Name="BottomTaskbarContainer"
|
||||
|
||||
@@ -135,6 +135,22 @@ public partial class MainWindow : Window, ISettingsWindowAnchorProvider
|
||||
private string _statusBarSpacingMode = "Relaxed";
|
||||
private int _statusBarCustomSpacingPercent = 12;
|
||||
private bool _statusBarClockTransparentBackground;
|
||||
private string _clockPosition = "Left"; // Left, Center, Right
|
||||
private string _clockFontSize = "Medium"; // Small, Medium, Large
|
||||
private bool _showTextCapsule;
|
||||
private string _textCapsuleContent = "**Hello** World!";
|
||||
private string _textCapsulePosition = "Right"; // Left, Center, Right
|
||||
private bool _textCapsuleTransparentBackground;
|
||||
private string _textCapsuleFontSize = "Medium"; // Small, Medium, Large
|
||||
private bool _showNetworkSpeed;
|
||||
private string _networkSpeedPosition = "Right"; // Left, Center, Right
|
||||
private string _networkSpeedDisplayMode = "Both"; // Upload, Download, Both
|
||||
private bool _networkSpeedTransparentBackground;
|
||||
private bool _showNetworkTypeIcon;
|
||||
private string _networkSpeedFontSize = "Medium"; // Small, Medium, Large
|
||||
private bool _statusBarShadowEnabled;
|
||||
private string _statusBarShadowColor = "#000000";
|
||||
private double _statusBarShadowOpacity = 0.3;
|
||||
private int _desktopEdgeInsetPercent = DefaultEdgeInsetPercent;
|
||||
private string _taskbarLayoutMode = TaskbarLayoutBottomFullRowMacStyle;
|
||||
private string _languageCode = "zh-CN";
|
||||
@@ -238,9 +254,9 @@ public partial class MainWindow : Window, ISettingsWindowAnchorProvider
|
||||
TaskbarProfileButton.IsEnabled = false;
|
||||
TaskbarProfilePopup.IsOpen = false;
|
||||
|
||||
ClockWidget.IsVisible = true;
|
||||
ClockWidget.SetDisplayFormat(ClockDisplayFormat.HourMinute);
|
||||
ClockWidget.SetTransparentBackground(false);
|
||||
ClockWidgetLeft.IsVisible = true;
|
||||
ClockWidgetLeft.SetDisplayFormat(ClockDisplayFormat.HourMinute);
|
||||
ClockWidgetLeft.SetTransparentBackground(false);
|
||||
|
||||
ConfigureDesignTimeDesktopGrid();
|
||||
PopulateDesignTimeDesktopSurface();
|
||||
@@ -288,7 +304,7 @@ public partial class MainWindow : Window, ISettingsWindowAnchorProvider
|
||||
DesktopPagesHost.ColumnDefinitions.Clear();
|
||||
DesktopPagesHost.ColumnDefinitions.Add(new ColumnDefinition(new GridLength(1, GridUnitType.Star)));
|
||||
|
||||
ClockWidget.ApplyCellSize(72);
|
||||
ClockWidgetLeft.ApplyCellSize(72);
|
||||
}
|
||||
|
||||
private void PopulateDesignTimeDesktopSurface()
|
||||
@@ -481,7 +497,9 @@ public partial class MainWindow : Window, ISettingsWindowAnchorProvider
|
||||
RebuildDesktopGrid();
|
||||
LoadLauncherEntriesAsync();
|
||||
InitializeTimeZoneSettings();
|
||||
ClockWidget.SetTimeZoneService(_timeZoneService);
|
||||
ClockWidgetLeft.SetTimeZoneService(_timeZoneService);
|
||||
ClockWidgetCenter.SetTimeZoneService(_timeZoneService);
|
||||
ClockWidgetRight.SetTimeZoneService(_timeZoneService);
|
||||
|
||||
_suppressSettingsPersistence = false;
|
||||
PersistSettings();
|
||||
@@ -621,7 +639,9 @@ public partial class MainWindow : Window, ISettingsWindowAnchorProvider
|
||||
|
||||
private void ApplyDesktopStatusBarComponentSpacing()
|
||||
{
|
||||
ApplyStatusBarComponentSpacingForPanel(TopStatusComponentsPanel, _currentDesktopCellSize);
|
||||
ApplyStatusBarComponentSpacingForPanel(TopStatusLeftPanel, _currentDesktopCellSize);
|
||||
ApplyStatusBarComponentSpacingForPanel(TopStatusCenterPanel, _currentDesktopCellSize);
|
||||
ApplyStatusBarComponentSpacingForPanel(TopStatusRightPanel, _currentDesktopCellSize);
|
||||
}
|
||||
|
||||
private int ResolveStatusBarSpacingPercent()
|
||||
@@ -697,8 +717,26 @@ public partial class MainWindow : Window, ISettingsWindowAnchorProvider
|
||||
ApplyUnifiedMainRectangleChrome();
|
||||
BottomTaskbarContainer.Padding = new Thickness(Math.Clamp(taskbarCellHeight * 0.16, 6, 14));
|
||||
|
||||
ClockWidget.Margin = new Thickness(0);
|
||||
ClockWidget.ApplyCellSize(cellSize);
|
||||
ClockWidgetLeft.Margin = new Thickness(0);
|
||||
ClockWidgetLeft.ApplyCellSize(cellSize);
|
||||
ClockWidgetCenter.Margin = new Thickness(0);
|
||||
ClockWidgetCenter.ApplyCellSize(cellSize);
|
||||
ClockWidgetRight.Margin = new Thickness(0);
|
||||
ClockWidgetRight.ApplyCellSize(cellSize);
|
||||
|
||||
TextCapsuleWidgetLeft.Margin = new Thickness(0);
|
||||
TextCapsuleWidgetLeft.ApplyCellSize(cellSize);
|
||||
TextCapsuleWidgetCenter.Margin = new Thickness(0);
|
||||
TextCapsuleWidgetCenter.ApplyCellSize(cellSize);
|
||||
TextCapsuleWidgetRight.Margin = new Thickness(0);
|
||||
TextCapsuleWidgetRight.ApplyCellSize(cellSize);
|
||||
|
||||
NetworkSpeedWidgetLeft.Margin = new Thickness(0);
|
||||
NetworkSpeedWidgetLeft.ApplyCellSize(cellSize);
|
||||
NetworkSpeedWidgetCenter.Margin = new Thickness(0);
|
||||
NetworkSpeedWidgetCenter.ApplyCellSize(cellSize);
|
||||
NetworkSpeedWidgetRight.Margin = new Thickness(0);
|
||||
NetworkSpeedWidgetRight.ApplyCellSize(cellSize);
|
||||
|
||||
var buttonMinWidth = Math.Clamp(taskbarCellHeight * 2.35, 100, 340);
|
||||
|
||||
@@ -737,7 +775,15 @@ public partial class MainWindow : Window, ISettingsWindowAnchorProvider
|
||||
|
||||
if (_currentDesktopCellSize > 0)
|
||||
{
|
||||
ClockWidget.ApplyCellSize(_currentDesktopCellSize);
|
||||
ClockWidgetLeft.ApplyCellSize(_currentDesktopCellSize);
|
||||
ClockWidgetCenter.ApplyCellSize(_currentDesktopCellSize);
|
||||
ClockWidgetRight.ApplyCellSize(_currentDesktopCellSize);
|
||||
TextCapsuleWidgetLeft.ApplyCellSize(_currentDesktopCellSize);
|
||||
TextCapsuleWidgetCenter.ApplyCellSize(_currentDesktopCellSize);
|
||||
TextCapsuleWidgetRight.ApplyCellSize(_currentDesktopCellSize);
|
||||
NetworkSpeedWidgetLeft.ApplyCellSize(_currentDesktopCellSize);
|
||||
NetworkSpeedWidgetCenter.ApplyCellSize(_currentDesktopCellSize);
|
||||
NetworkSpeedWidgetRight.ApplyCellSize(_currentDesktopCellSize);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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; }
|
||||
}
|
||||
@@ -52,6 +52,182 @@
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</ui:SettingsExpanderItem>
|
||||
<ui:SettingsExpanderItem>
|
||||
<Grid ColumnDefinitions="Auto,*"
|
||||
ColumnSpacing="16">
|
||||
<TextBlock Text="{Binding ClockPositionLabel}"
|
||||
VerticalAlignment="Center" />
|
||||
<ComboBox Grid.Column="1"
|
||||
Width="220"
|
||||
IsEnabled="{Binding ShowClock}"
|
||||
ItemsSource="{Binding ClockPositions}"
|
||||
SelectedItem="{Binding SelectedClockPosition}">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:SelectionOption">
|
||||
<TextBlock Text="{Binding Label}" />
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
</Grid>
|
||||
</ui:SettingsExpanderItem>
|
||||
<ui:SettingsExpanderItem>
|
||||
<Grid ColumnDefinitions="Auto,*"
|
||||
ColumnSpacing="16">
|
||||
<TextBlock Text="{Binding ClockFontSizeLabel}"
|
||||
VerticalAlignment="Center" />
|
||||
<ComboBox Grid.Column="1"
|
||||
Width="220"
|
||||
IsEnabled="{Binding ShowClock}"
|
||||
ItemsSource="{Binding ClockFontSizes}"
|
||||
SelectedItem="{Binding SelectedClockFontSize}">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:SelectionOption">
|
||||
<TextBlock Text="{Binding Label}" />
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
</Grid>
|
||||
</ui:SettingsExpanderItem>
|
||||
</ui:SettingsExpander>
|
||||
|
||||
<ui:SettingsExpander Header="{Binding TextCapsuleHeader}"
|
||||
Description="{Binding TextCapsuleDescription}">
|
||||
<ui:SettingsExpander.IconSource>
|
||||
<fi:SymbolIconSource Symbol="TextQuote" />
|
||||
</ui:SettingsExpander.IconSource>
|
||||
<ui:SettingsExpander.Footer>
|
||||
<ToggleSwitch IsChecked="{Binding ShowTextCapsule}" />
|
||||
</ui:SettingsExpander.Footer>
|
||||
<ui:SettingsExpanderItem>
|
||||
<Grid ColumnDefinitions="Auto,*"
|
||||
ColumnSpacing="16">
|
||||
<TextBlock Text="{Binding TextCapsuleContentLabel}"
|
||||
VerticalAlignment="Top"
|
||||
Margin="0,8,0,0" />
|
||||
<TextBox Grid.Column="1"
|
||||
AcceptsReturn="True"
|
||||
TextWrapping="Wrap"
|
||||
Height="100"
|
||||
IsEnabled="{Binding ShowTextCapsule}"
|
||||
Text="{Binding TextCapsuleContent}"
|
||||
Watermark="Enter Markdown text..." />
|
||||
</Grid>
|
||||
</ui:SettingsExpanderItem>
|
||||
<ui:SettingsExpanderItem>
|
||||
<Grid ColumnDefinitions="Auto,*"
|
||||
ColumnSpacing="16">
|
||||
<TextBlock Text="{Binding TextCapsulePositionLabel}"
|
||||
VerticalAlignment="Center" />
|
||||
<ComboBox Grid.Column="1"
|
||||
Width="220"
|
||||
IsEnabled="{Binding ShowTextCapsule}"
|
||||
ItemsSource="{Binding TextCapsulePositions}"
|
||||
SelectedItem="{Binding SelectedTextCapsulePosition}">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:SelectionOption">
|
||||
<TextBlock Text="{Binding Label}" />
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
</Grid>
|
||||
</ui:SettingsExpanderItem>
|
||||
<ui:SettingsExpanderItem>
|
||||
<Grid ColumnDefinitions="*,Auto"
|
||||
ColumnSpacing="16">
|
||||
<TextBlock Text="{Binding TextCapsuleTransparentBackgroundLabel}"
|
||||
VerticalAlignment="Center" />
|
||||
<ToggleSwitch Grid.Column="1"
|
||||
IsChecked="{Binding TextCapsuleTransparentBackground}"
|
||||
IsEnabled="{Binding ShowTextCapsule}"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</ui:SettingsExpanderItem>
|
||||
</ui:SettingsExpander>
|
||||
|
||||
<ui:SettingsExpander Header="{Binding NetworkSpeedHeader}"
|
||||
Description="{Binding NetworkSpeedDescription}">
|
||||
<ui:SettingsExpander.IconSource>
|
||||
<fi:SymbolIconSource Symbol="ArrowBidirectionalUpDown" />
|
||||
</ui:SettingsExpander.IconSource>
|
||||
<ui:SettingsExpander.Footer>
|
||||
<ToggleSwitch IsChecked="{Binding ShowNetworkSpeed}" />
|
||||
</ui:SettingsExpander.Footer>
|
||||
<ui:SettingsExpanderItem>
|
||||
<Grid ColumnDefinitions="Auto,*"
|
||||
ColumnSpacing="16">
|
||||
<TextBlock Text="{Binding NetworkSpeedPositionLabel}"
|
||||
VerticalAlignment="Center" />
|
||||
<ComboBox Grid.Column="1"
|
||||
Width="220"
|
||||
IsEnabled="{Binding ShowNetworkSpeed}"
|
||||
ItemsSource="{Binding NetworkSpeedPositions}"
|
||||
SelectedItem="{Binding SelectedNetworkSpeedPosition}">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:SelectionOption">
|
||||
<TextBlock Text="{Binding Label}" />
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
</Grid>
|
||||
</ui:SettingsExpanderItem>
|
||||
<ui:SettingsExpanderItem>
|
||||
<Grid ColumnDefinitions="Auto,*"
|
||||
ColumnSpacing="16">
|
||||
<TextBlock Text="{Binding NetworkSpeedDisplayModeLabel}"
|
||||
VerticalAlignment="Center" />
|
||||
<ComboBox Grid.Column="1"
|
||||
Width="220"
|
||||
IsEnabled="{Binding ShowNetworkSpeed}"
|
||||
ItemsSource="{Binding NetworkSpeedDisplayModes}"
|
||||
SelectedItem="{Binding SelectedNetworkSpeedDisplayMode}">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:SelectionOption">
|
||||
<TextBlock Text="{Binding Label}" />
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
</Grid>
|
||||
</ui:SettingsExpanderItem>
|
||||
<ui:SettingsExpanderItem>
|
||||
<Grid ColumnDefinitions="*,Auto"
|
||||
ColumnSpacing="16">
|
||||
<TextBlock Text="{Binding NetworkSpeedTransparentBackgroundLabel}"
|
||||
VerticalAlignment="Center" />
|
||||
<ToggleSwitch Grid.Column="1"
|
||||
IsChecked="{Binding NetworkSpeedTransparentBackground}"
|
||||
IsEnabled="{Binding ShowNetworkSpeed}"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</ui:SettingsExpanderItem>
|
||||
<ui:SettingsExpanderItem>
|
||||
<Grid ColumnDefinitions="*,Auto"
|
||||
ColumnSpacing="16">
|
||||
<TextBlock Text="{Binding ShowNetworkTypeIconLabel}"
|
||||
VerticalAlignment="Center" />
|
||||
<ToggleSwitch Grid.Column="1"
|
||||
IsChecked="{Binding ShowNetworkTypeIcon}"
|
||||
IsEnabled="{Binding ShowNetworkSpeed}"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</ui:SettingsExpanderItem>
|
||||
<ui:SettingsExpanderItem>
|
||||
<Grid ColumnDefinitions="Auto,*"
|
||||
ColumnSpacing="16">
|
||||
<TextBlock Text="{Binding NetworkSpeedFontSizeLabel}"
|
||||
VerticalAlignment="Center" />
|
||||
<ComboBox Grid.Column="1"
|
||||
Width="220"
|
||||
IsEnabled="{Binding ShowNetworkSpeed}"
|
||||
ItemsSource="{Binding NetworkSpeedFontSizes}"
|
||||
SelectedItem="{Binding SelectedNetworkSpeedFontSize}">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:SelectionOption">
|
||||
<TextBlock Text="{Binding Label}" />
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
</Grid>
|
||||
</ui:SettingsExpanderItem>
|
||||
</ui:SettingsExpander>
|
||||
|
||||
<Separator Classes="settings-separator" />
|
||||
@@ -92,6 +268,55 @@
|
||||
</Grid>
|
||||
</ui:SettingsExpanderItem>
|
||||
</ui:SettingsExpander>
|
||||
|
||||
<Separator Classes="settings-separator" />
|
||||
|
||||
<controls:IconText Icon="Square"
|
||||
Text="{Binding StatusBarShadowHeader}"
|
||||
Margin="0,0,0,4" />
|
||||
|
||||
<ui:SettingsExpander Header="{Binding StatusBarShadowHeader}"
|
||||
Description="{Binding StatusBarShadowDescription}">
|
||||
<ui:SettingsExpander.IconSource>
|
||||
<fi:SymbolIconSource Symbol="Square" />
|
||||
</ui:SettingsExpander.IconSource>
|
||||
<ui:SettingsExpander.Footer>
|
||||
<ToggleSwitch IsChecked="{Binding StatusBarShadowEnabled}" />
|
||||
</ui:SettingsExpander.Footer>
|
||||
<ui:SettingsExpanderItem>
|
||||
<Grid ColumnDefinitions="Auto,*"
|
||||
ColumnSpacing="16">
|
||||
<TextBlock Text="{Binding StatusBarShadowColorLabel}"
|
||||
VerticalAlignment="Center" />
|
||||
<Button Grid.Column="1"
|
||||
HorizontalAlignment="Right"
|
||||
IsEnabled="{Binding StatusBarShadowEnabled}">
|
||||
<Border Width="32"
|
||||
Height="32"
|
||||
CornerRadius="4"
|
||||
Background="{Binding StatusBarShadowColorBrush}" />
|
||||
<Button.Flyout>
|
||||
<Flyout Placement="BottomEdgeAlignedRight">
|
||||
<ColorPicker Color="{Binding StatusBarShadowColor}" />
|
||||
</Flyout>
|
||||
</Button.Flyout>
|
||||
</Button>
|
||||
</Grid>
|
||||
</ui:SettingsExpanderItem>
|
||||
<ui:SettingsExpanderItem>
|
||||
<Grid ColumnDefinitions="Auto,*"
|
||||
ColumnSpacing="16">
|
||||
<TextBlock Text="{Binding StatusBarShadowOpacityLabel}"
|
||||
VerticalAlignment="Center" />
|
||||
<Slider Grid.Column="1"
|
||||
Minimum="0"
|
||||
Maximum="100"
|
||||
TickFrequency="10"
|
||||
IsEnabled="{Binding StatusBarShadowEnabled}"
|
||||
Value="{Binding StatusBarShadowOpacity}" />
|
||||
</Grid>
|
||||
</ui:SettingsExpanderItem>
|
||||
</ui:SettingsExpander>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</UserControl>
|
||||
|
||||
@@ -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);
|
||||
23
LanMountainDesktop/Views/TransparentOverlayWindow.axaml
Normal file
23
LanMountainDesktop/Views/TransparentOverlayWindow.axaml
Normal file
@@ -0,0 +1,23 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
x:Class="LanMountainDesktop.Views.TransparentOverlayWindow"
|
||||
SystemDecorations="None"
|
||||
CanResize="False"
|
||||
ShowInTaskbar="False"
|
||||
ExtendClientAreaToDecorationsHint="True"
|
||||
ExtendClientAreaChromeHints="NoChrome"
|
||||
Background="Transparent"
|
||||
Title="LanMountainDesktop Fused Desktop">
|
||||
<!--
|
||||
融合桌面(负一屏)- 在系统桌面上显示组件
|
||||
|
||||
特性:
|
||||
- 窗口置底(在桌面图标层显示)
|
||||
- 区域级穿透(组件区域可交互,其他区域穿透)
|
||||
- 组件可自由拖拽摆放
|
||||
- 三指/右键左滑回到阗山桌面第一页
|
||||
-->
|
||||
<Canvas x:Name="ComponentCanvas">
|
||||
<!-- 组件将动态添加到这里 -->
|
||||
</Canvas>
|
||||
</Window>
|
||||
749
LanMountainDesktop/Views/TransparentOverlayWindow.axaml.cs
Normal file
749
LanMountainDesktop/Views/TransparentOverlayWindow.axaml.cs
Normal file
@@ -0,0 +1,749 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Primitives;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Media;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.Services.Settings;
|
||||
using LanMountainDesktop.ComponentSystem;
|
||||
using LanMountainDesktop.Views.Components;
|
||||
|
||||
namespace LanMountainDesktop.Views;
|
||||
|
||||
/// <summary>
|
||||
/// 透明覆盖层窗口 - 作为"负一屏"显示在 Windows 桌面上
|
||||
/// 支持在系统桌面上自由摆放组件
|
||||
/// </summary>
|
||||
public partial class TransparentOverlayWindow : Window
|
||||
{
|
||||
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 ComponentRegistry? _componentRegistry;
|
||||
private DesktopComponentRuntimeRegistry? _componentRuntimeRegistry;
|
||||
|
||||
// 基础服务
|
||||
private readonly IWeatherInfoService _weatherDataService;
|
||||
private readonly TimeZoneService _timeZoneService;
|
||||
private readonly IRecommendationInfoService _recommendationInfoService = new RecommendationDataService();
|
||||
private readonly ICalculatorDataService _calculatorDataService = new CalculatorDataService();
|
||||
|
||||
// 渲染参数
|
||||
private const double DefaultCellSize = 100;
|
||||
private double _currentDesktopCellSize;
|
||||
|
||||
// 拖拽与缩放状态
|
||||
private bool _isDragging;
|
||||
private bool _isResizing;
|
||||
private string? _interactionPlacementId;
|
||||
private Point _interactionStartPoint;
|
||||
private double _interactionOriginalX;
|
||||
private double _interactionOriginalY;
|
||||
private double _interactionOriginalWidth;
|
||||
private double _interactionOriginalHeight;
|
||||
private Border? _interactionHost;
|
||||
|
||||
// 选中状态
|
||||
private Border? _selectedHost;
|
||||
|
||||
public event EventHandler? RestoreMainWindowRequested;
|
||||
|
||||
public TransparentOverlayWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
var facade = HostSettingsFacadeProvider.GetOrCreate();
|
||||
_weatherDataService = facade.Weather.GetWeatherInfoService();
|
||||
_timeZoneService = facade.Region.GetTimeZoneService();
|
||||
_settingsFacade = facade;
|
||||
}
|
||||
|
||||
private readonly ISettingsFacadeService _settingsFacade;
|
||||
|
||||
public void SaveLayoutAndHide()
|
||||
{
|
||||
SaveLayout();
|
||||
Hide();
|
||||
|
||||
// Remove all components so that next time we open it builds fresh from snapshot
|
||||
if (Content is Canvas canvas)
|
||||
{
|
||||
canvas.Children.Clear();
|
||||
}
|
||||
_componentHosts.Clear();
|
||||
}
|
||||
|
||||
protected override void OnOpened(EventArgs e)
|
||||
{
|
||||
base.OnOpened(e);
|
||||
|
||||
if (Screens.Primary is { } primaryScreen)
|
||||
{
|
||||
// 避开系统任务栏
|
||||
var workArea = primaryScreen.WorkingArea;
|
||||
var scaling = primaryScreen.Scaling;
|
||||
Position = new PixelPoint(workArea.X, workArea.Y);
|
||||
Width = workArea.Width / scaling;
|
||||
Height = workArea.Height / scaling;
|
||||
|
||||
// 基于设置计算单元格尺寸
|
||||
var appSnapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
|
||||
var shortCells = Math.Clamp(appSnapshot.GridShortSideCells > 0 ? appSnapshot.GridShortSideCells : 12, 6, 96);
|
||||
_currentDesktopCellSize = Height / shortCells;
|
||||
}
|
||||
else
|
||||
{
|
||||
_currentDesktopCellSize = DefaultCellSize;
|
||||
}
|
||||
|
||||
if (Content is Canvas canvas)
|
||||
{
|
||||
// 保证透明区域也能被抓取事件
|
||||
canvas.Background = new SolidColorBrush(Color.FromArgb(1, 0, 0, 0));
|
||||
}
|
||||
|
||||
// 确保注册表已初始化
|
||||
EnsureRegistries();
|
||||
|
||||
// 加载布局并渲染
|
||||
_layout = _layoutService.Load();
|
||||
RenderAllComponents();
|
||||
|
||||
AppLogger.Info("TransparentOverlay", $"Opened with {_layout.ComponentPlacements.Count} components.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 确保组件运行时注册表已初始化
|
||||
/// </summary>
|
||||
private void EnsureRegistries()
|
||||
{
|
||||
if (_componentRuntimeRegistry is not null) return;
|
||||
|
||||
var pluginRuntimeService = (Application.Current as App)?.PluginRuntimeService;
|
||||
_componentRegistry = DesktopComponentRegistryFactory.Create(pluginRuntimeService);
|
||||
_componentRuntimeRegistry = DesktopComponentRegistryFactory.CreateRuntimeRegistry(
|
||||
_componentRegistry,
|
||||
pluginRuntimeService,
|
||||
_settingsFacade);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 渲染所有布局中的组件
|
||||
/// </summary>
|
||||
private void RenderAllComponents()
|
||||
{
|
||||
if (Content is not Canvas canvas) return;
|
||||
|
||||
canvas.Children.Clear();
|
||||
_componentHosts.Clear();
|
||||
_selectedHost = null;
|
||||
|
||||
foreach (var placement in _layout.ComponentPlacements)
|
||||
{
|
||||
try
|
||||
{
|
||||
RenderComponentInternal(placement);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("TransparentOverlay", $"Failed to render component {placement.ComponentId}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
UpdateInteractiveRegions();
|
||||
}
|
||||
|
||||
protected override void OnClosed(EventArgs e)
|
||||
{
|
||||
SaveLayout();
|
||||
base.OnClosed(e);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新可交互区域
|
||||
/// </summary>
|
||||
private void UpdateInteractiveRegions()
|
||||
{
|
||||
// 编辑模式下不再需要底层穿透功能计算,这里留空或移除
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存布局
|
||||
/// </summary>
|
||||
private void SaveLayout()
|
||||
{
|
||||
_layoutService.Save(_layout);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 添加组件(供外部调用)
|
||||
/// </summary>
|
||||
public void AddComponent(string componentId, double x, double y, double? width = null, double? height = null)
|
||||
{
|
||||
EnsureRegistries();
|
||||
|
||||
if (_componentRegistry == null || !_componentRegistry.TryGetDefinition(componentId, out var definition))
|
||||
{
|
||||
AppLogger.Warn("TransparentOverlay", $"Cannot add unknown component: {componentId}");
|
||||
return;
|
||||
}
|
||||
|
||||
var finalWidth = width ?? (definition.MinWidthCells * _currentDesktopCellSize);
|
||||
var finalHeight = height ?? (definition.MinHeightCells * _currentDesktopCellSize);
|
||||
|
||||
// 对齐网格
|
||||
x = Math.Round(x / _currentDesktopCellSize) * _currentDesktopCellSize;
|
||||
y = Math.Round(y / _currentDesktopCellSize) * _currentDesktopCellSize;
|
||||
finalWidth = Math.Round(finalWidth / _currentDesktopCellSize) * _currentDesktopCellSize;
|
||||
finalHeight = Math.Round(finalHeight / _currentDesktopCellSize) * _currentDesktopCellSize;
|
||||
|
||||
var placementId = Guid.NewGuid().ToString("N");
|
||||
var placement = new FusedDesktopComponentPlacementSnapshot
|
||||
{
|
||||
PlacementId = placementId,
|
||||
ComponentId = componentId,
|
||||
X = x,
|
||||
Y = y,
|
||||
Width = finalWidth,
|
||||
Height = finalHeight,
|
||||
ZIndex = _layout.ComponentPlacements.Count
|
||||
};
|
||||
|
||||
_layout.ComponentPlacements.Add(placement);
|
||||
|
||||
// 立即渲染
|
||||
try
|
||||
{
|
||||
RenderComponentInternal(placement);
|
||||
UpdateInteractiveRegions();
|
||||
SaveLayout();
|
||||
AppLogger.Info("TransparentOverlay", $"Added component: {componentId} at ({x}, {y}) size ({finalWidth}x{finalHeight})");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("TransparentOverlay", $"Failed to add component {componentId}", ex);
|
||||
_layout.ComponentPlacements.Remove(placement);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 内部渲染单个组件
|
||||
/// </summary>
|
||||
private void RenderComponentInternal(FusedDesktopComponentPlacementSnapshot placement)
|
||||
{
|
||||
if (_componentRuntimeRegistry is null || !_componentRuntimeRegistry.TryGetDescriptor(placement.ComponentId, out var descriptor))
|
||||
{
|
||||
AppLogger.Warn("TransparentOverlay", $"Unknown component: {placement.ComponentId}");
|
||||
return;
|
||||
}
|
||||
|
||||
var control = descriptor.CreateControl(
|
||||
_currentDesktopCellSize,
|
||||
_timeZoneService,
|
||||
_weatherDataService,
|
||||
_recommendationInfoService,
|
||||
_calculatorDataService,
|
||||
_settingsFacade,
|
||||
placement.PlacementId);
|
||||
|
||||
RenderComponent(placement.PlacementId, control, placement.X, placement.Y, placement.Width, placement.Height);
|
||||
}
|
||||
|
||||
/// <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 grid = new Grid();
|
||||
grid.Children.Add(component);
|
||||
|
||||
var resizeHandle = new Border
|
||||
{
|
||||
Width = 24,
|
||||
Height = 24,
|
||||
Background = new Avalonia.Media.SolidColorBrush(Avalonia.Media.Color.Parse("#3B82F6")),
|
||||
CornerRadius = new Avalonia.CornerRadius(12),
|
||||
HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Right,
|
||||
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Bottom,
|
||||
Margin = new Avalonia.Thickness(0, 0, -12, -12),
|
||||
Cursor = new Avalonia.Input.Cursor(Avalonia.Input.StandardCursorType.BottomRightCorner),
|
||||
Tag = "desktop-component-resize-handle",
|
||||
IsVisible = false
|
||||
};
|
||||
grid.Children.Add(resizeHandle);
|
||||
|
||||
var host = new Border
|
||||
{
|
||||
Tag = placementId,
|
||||
Width = width,
|
||||
Height = height,
|
||||
Background = Avalonia.Media.Brushes.Transparent,
|
||||
CornerRadius = new Avalonia.CornerRadius(12),
|
||||
ClipToBounds = false, // 允许把手溢出
|
||||
BorderBrush = Avalonia.Media.Brushes.Transparent,
|
||||
BorderThickness = new Avalonia.Thickness(3),
|
||||
Child = grid,
|
||||
Classes = { "desktop-component-host" }
|
||||
};
|
||||
|
||||
Canvas.SetLeft(host, x);
|
||||
Canvas.SetTop(host, y);
|
||||
|
||||
host.PointerPressed += OnComponentPointerPressed;
|
||||
host.PointerMoved += OnInteractionPointerMoved;
|
||||
host.PointerReleased += OnInteractionPointerReleased;
|
||||
|
||||
// 右键上下文菜单(删除组件)
|
||||
host.ContextRequested += OnComponentContextRequested;
|
||||
|
||||
if (Content is Canvas canvas)
|
||||
{
|
||||
canvas.Children.Add(host);
|
||||
}
|
||||
|
||||
_componentHosts[placementId] = host;
|
||||
UpdateInteractiveRegions();
|
||||
}
|
||||
|
||||
// 组件右键上下文菜单(删除)
|
||||
private void OnComponentContextRequested(object? sender, ContextRequestedEventArgs e)
|
||||
{
|
||||
if (sender is not Border host || host.Tag is not string placementId) return;
|
||||
|
||||
// 构建上下文菜单
|
||||
var deleteItem = new MenuItem
|
||||
{
|
||||
Header = "移除组件",
|
||||
Icon = new Avalonia.Controls.TextBlock { Text = "🗑" }
|
||||
};
|
||||
deleteItem.Click += (_, _) =>
|
||||
{
|
||||
RemoveComponent(placementId);
|
||||
AppLogger.Info("TransparentOverlay", $"Component removed via context menu: {placementId}");
|
||||
};
|
||||
|
||||
var menu = new ContextMenu
|
||||
{
|
||||
Items = { deleteItem }
|
||||
};
|
||||
|
||||
// 显示在当前控件上
|
||||
menu.Open(host);
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
// 取消选中
|
||||
private void OnCanvasPointerPressed(object? sender, PointerPressedEventArgs e)
|
||||
{
|
||||
DeselectComponent();
|
||||
}
|
||||
|
||||
// 选中组件
|
||||
private void SelectComponent(Border host)
|
||||
{
|
||||
if (_selectedHost == host) return;
|
||||
DeselectComponent();
|
||||
|
||||
_selectedHost = host;
|
||||
|
||||
// 渲染选中边框和把手
|
||||
host.BorderBrush = new Avalonia.Media.SolidColorBrush(Avalonia.Media.Color.Parse("#3B82F6"));
|
||||
host.Classes.Add("desktop-component-host-selected");
|
||||
|
||||
if (host.Child is Grid grid)
|
||||
{
|
||||
foreach (var child in grid.Children)
|
||||
{
|
||||
if (child is Control c && c.Tag is string tg && tg == "desktop-component-resize-handle")
|
||||
{
|
||||
c.IsVisible = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void DeselectComponent()
|
||||
{
|
||||
if (_selectedHost != null)
|
||||
{
|
||||
_selectedHost.BorderBrush = Avalonia.Media.Brushes.Transparent;
|
||||
_selectedHost.Classes.Remove("desktop-component-host-selected");
|
||||
|
||||
if (_selectedHost.Child is Grid grid)
|
||||
{
|
||||
foreach (var child in grid.Children)
|
||||
{
|
||||
if (child is Control c && c.Tag is string tg && tg == "desktop-component-resize-handle")
|
||||
{
|
||||
c.IsVisible = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_selectedHost = null;
|
||||
}
|
||||
|
||||
// 组件拖拽与缩放处理
|
||||
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;
|
||||
|
||||
SelectComponent(host);
|
||||
|
||||
_interactionPlacementId = placementId;
|
||||
_interactionHost = host;
|
||||
_interactionStartPoint = e.GetPosition(this);
|
||||
|
||||
// 这里必须用未吸附的原始屏幕位置计算 delta
|
||||
_interactionOriginalX = Canvas.GetLeft(host);
|
||||
_interactionOriginalY = Canvas.GetTop(host);
|
||||
_interactionOriginalWidth = host.Width;
|
||||
_interactionOriginalHeight = host.Height;
|
||||
|
||||
if (e.Source is Control sourceControl && sourceControl.Tag is string tag && tag == "desktop-component-resize-handle")
|
||||
{
|
||||
_isResizing = true;
|
||||
_isDragging = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
_isDragging = true;
|
||||
_isResizing = false;
|
||||
}
|
||||
|
||||
e.Pointer.Capture(host);
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private void OnInteractionPointerMoved(object? sender, PointerEventArgs e)
|
||||
{
|
||||
if ((!_isDragging && !_isResizing) || _interactionHost is null) return;
|
||||
|
||||
var currentPoint = e.GetPosition(this);
|
||||
var deltaX = currentPoint.X - _interactionStartPoint.X;
|
||||
var deltaY = currentPoint.Y - _interactionStartPoint.Y;
|
||||
|
||||
if (_isDragging)
|
||||
{
|
||||
var rawX = _interactionOriginalX + deltaX;
|
||||
var rawY = _interactionOriginalY + deltaY;
|
||||
|
||||
var snapX = Math.Round(rawX / _currentDesktopCellSize) * _currentDesktopCellSize;
|
||||
var snapY = Math.Round(rawY / _currentDesktopCellSize) * _currentDesktopCellSize;
|
||||
|
||||
Canvas.SetLeft(_interactionHost, snapX);
|
||||
Canvas.SetTop(_interactionHost, snapY);
|
||||
}
|
||||
else if (_isResizing)
|
||||
{
|
||||
var rawWidth = _interactionOriginalWidth + deltaX;
|
||||
var rawHeight = _interactionOriginalHeight + deltaY;
|
||||
|
||||
var snapWidth = Math.Round(rawWidth / _currentDesktopCellSize) * _currentDesktopCellSize;
|
||||
var snapHeight = Math.Round(rawHeight / _currentDesktopCellSize) * _currentDesktopCellSize;
|
||||
|
||||
// 防溢出与极小值保护
|
||||
snapWidth = Math.Max(_currentDesktopCellSize, snapWidth);
|
||||
snapHeight = Math.Max(_currentDesktopCellSize, snapHeight);
|
||||
|
||||
_interactionHost.Width = snapWidth;
|
||||
_interactionHost.Height = snapHeight;
|
||||
}
|
||||
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private void OnInteractionPointerReleased(object? sender, PointerReleasedEventArgs e)
|
||||
{
|
||||
if ((!_isDragging && !_isResizing) || _interactionHost is null || _interactionPlacementId is null)
|
||||
{
|
||||
_isDragging = false;
|
||||
_isResizing = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// 更新布局中的位置与尺寸
|
||||
var placement = _layout.ComponentPlacements.Find(p => p.PlacementId == _interactionPlacementId);
|
||||
if (placement is not null)
|
||||
{
|
||||
placement.X = Canvas.GetLeft(_interactionHost);
|
||||
placement.Y = Canvas.GetTop(_interactionHost);
|
||||
placement.Width = _interactionHost.Width;
|
||||
placement.Height = _interactionHost.Height;
|
||||
}
|
||||
|
||||
UpdateInteractiveRegions();
|
||||
SaveLayout();
|
||||
|
||||
_isDragging = false;
|
||||
_isResizing = false;
|
||||
_interactionPlacementId = null;
|
||||
_interactionHost = 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