Compare commits

...

7 Commits

Author SHA1 Message Date
lincube
1c3cc76f21 fead.做了状态栏文字组件,支持了位置放置。 2026-04-03 13:14:20 +08:00
lincube
44b87ba12e fead.桌面组件 2026-04-03 11:42:00 +08:00
lincube
35976c3f3d fead.做桌面组件ing,智教hub加了rinshub 2026-04-03 01:17:47 +08:00
lincube
88bd92e40a fead.Hub组件支持双击打开图片,支持三指翻页退出应用 2026-04-02 21:12:06 +08:00
lincube
ff014717fa fix.修智教hub组件 2026-04-02 15:24:59 +08:00
lincube
964cef27ee 通知系统,自习系统,反正做了很多 2026-04-02 11:54:58 +08:00
lincube
2272d35c16 Revert "0.8.0.41"
This reverts commit d054257db2.
2026-04-01 09:30:55 +08:00
65 changed files with 7183 additions and 921 deletions

View File

@@ -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

View File

@@ -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
};
}

View 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]);
}
}

View File

@@ -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,18 @@
"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.components.title": "Components",
"settings.components.description": "Adjust component layout and corner design.",
"settings.components.grid_header": "Grid Settings",

View File

@@ -331,6 +331,18 @@
"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.components.title": "コンポーネント",
"settings.components.description": "コンポーネントのレイアウトとコーナーデザインを調整します。",
"settings.components.grid_header": "グリッド設定",

View File

@@ -377,6 +377,18 @@
"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.components.title": "컴포넌트",
"settings.components.description": "컴포넌트 레이아웃과 모서리 디자인을 조정합니다.",
"settings.components.grid_header": "그리드 설정",

View File

@@ -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,18 @@
"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.components.title": "组件",
"settings.components.description": "调整组件布局与圆角设计。",
"settings.components.grid_header": "网格设置",

View File

@@ -112,10 +112,22 @@ public sealed class AppSettingsSnapshot
public bool StatusBarClockTransparentBackground { get; set; }
public string ClockPosition { get; set; } = "Left"; // Left, Center, Right
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 StatusBarSpacingMode { get; set; } = "Relaxed";
public int StatusBarCustomSpacingPercent { get; set; } = 12;
public bool EnableThreeFingerSwipe { get; set; } = false;
public List<string> DisabledPluginIds { get; set; } = [];
#region Study Settings
@@ -150,6 +162,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();

View File

@@ -124,12 +124,14 @@ public static class ZhiJiaoHubSources
{
public const string ClassIsland = "classisland";
public const string Sectl = "sectl";
public const string RinLit = "rinlit";
public static string Normalize(string? value)
{
return value?.ToLowerInvariant() switch
{
"sectl" => Sectl,
"rinlit" => RinLit,
_ => ClassIsland
};
}

View 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())]
};
}
}

View 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;
}
}
}

View 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;
}
}
}

View File

@@ -322,6 +322,8 @@ public sealed record RecommendationApiOptions
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 RinLitHubRawUrlTemplate { get; init; } = "https://raw.githubusercontent.com/RinLit-233-shiroko/Rin-sHub/main/images/{0}";
}
public interface IRecommendationInfoService

View 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
{
}
}
}
}
}

View File

@@ -3247,6 +3247,7 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
var (owner, repo, path) = source switch
{
ZhiJiaoHubSources.Sectl => ("SECTL", "SECTL-hub", "docs/.vuepress/public/images"),
ZhiJiaoHubSources.RinLit => ("RinLit-233-shiroko", "Rin-sHub", "images"),
_ => ("ClassIsland", "classisland-hub", "images")
};

View File

@@ -41,8 +41,20 @@ public sealed record StatusBarSettingsState(
string TaskbarLayoutMode,
string ClockDisplayFormat,
bool ClockTransparentBackground,
string ClockPosition,
bool ShowTextCapsule,
string TextCapsuleContent,
string TextCapsulePosition,
bool TextCapsuleTransparentBackground,
string SpacingMode,
int CustomSpacingPercent);
public sealed record TextCapsuleSettingsState(
bool ShowTextCapsule,
string Content,
string Position,
bool TransparentBackground);
public sealed record WeatherSettingsState(
string LocationMode,
string LocationKey,
@@ -274,6 +286,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 +403,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; }

View File

@@ -386,6 +386,11 @@ internal sealed class StatusBarSettingsService : IStatusBarSettingsService
snapshot.TaskbarLayoutMode,
snapshot.ClockDisplayFormat,
snapshot.StatusBarClockTransparentBackground,
snapshot.ClockPosition,
snapshot.ShowTextCapsule,
snapshot.TextCapsuleContent,
snapshot.TextCapsulePosition,
snapshot.TextCapsuleTransparentBackground,
snapshot.StatusBarSpacingMode,
snapshot.StatusBarCustomSpacingPercent);
}
@@ -399,6 +404,11 @@ internal sealed class StatusBarSettingsService : IStatusBarSettingsService
snapshot.TaskbarLayoutMode = state.TaskbarLayoutMode;
snapshot.ClockDisplayFormat = state.ClockDisplayFormat;
snapshot.StatusBarClockTransparentBackground = state.ClockTransparentBackground;
snapshot.ClockPosition = state.ClockPosition;
snapshot.ShowTextCapsule = state.ShowTextCapsule;
snapshot.TextCapsuleContent = state.TextCapsuleContent;
snapshot.TextCapsulePosition = state.TextCapsulePosition;
snapshot.TextCapsuleTransparentBackground = state.TextCapsuleTransparentBackground;
snapshot.StatusBarSpacingMode = state.SpacingMode;
snapshot.StatusBarCustomSpacingPercent = state.CustomSpacingPercent;
_settingsService.SaveSnapshot(
@@ -412,12 +422,56 @@ internal sealed class StatusBarSettingsService : IStatusBarSettingsService
nameof(AppSettingsSnapshot.TaskbarLayoutMode),
nameof(AppSettingsSnapshot.ClockDisplayFormat),
nameof(AppSettingsSnapshot.StatusBarClockTransparentBackground),
nameof(AppSettingsSnapshot.ClockPosition),
nameof(AppSettingsSnapshot.ShowTextCapsule),
nameof(AppSettingsSnapshot.TextCapsuleContent),
nameof(AppSettingsSnapshot.TextCapsulePosition),
nameof(AppSettingsSnapshot.TextCapsuleTransparentBackground),
nameof(AppSettingsSnapshot.StatusBarSpacingMode),
nameof(AppSettingsSnapshot.StatusBarCustomSpacingPercent)
]);
}
}
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)
]);
}
}
internal sealed class WeatherProviderAdapter : IWeatherProvider, IWeatherInfoService, IDisposable
{
private readonly IWeatherDataService _weatherDataService = new XiaomiWeatherService();
@@ -1198,6 +1252,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 +1282,8 @@ internal sealed class SettingsFacadeService : ISettingsFacadeService, IDisposabl
public IStatusBarSettingsService StatusBar { get; }
public ITextCapsuleSettingsService TextCapsule { get; }
public IWeatherSettingsService Weather { get; }
public IRegionSettingsService Region { get; }

View File

@@ -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];

View File

@@ -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}");
}
}

View 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) { }
}

View File

@@ -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);
}
}

View 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"
};
}

View File

@@ -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()

View File

@@ -21,6 +21,8 @@ public sealed partial class StatusBarSettingsPageViewModel : ViewModelBase
_languageCode = _localizationService.NormalizeLanguageCode(_settingsFacade.Region.Get().LanguageCode);
ClockFormats = CreateClockFormats();
ClockPositions = CreateClockPositions();
TextCapsulePositions = CreateTextCapsulePositions();
SpacingModes = CreateSpacingModes();
RefreshLocalizedText();
@@ -31,6 +33,10 @@ public sealed partial class StatusBarSettingsPageViewModel : ViewModelBase
public IReadOnlyList<SelectionOption> ClockFormats { get; }
public IReadOnlyList<SelectionOption> ClockPositions { get; }
public IReadOnlyList<SelectionOption> TextCapsulePositions { get; }
public IReadOnlyList<SelectionOption> SpacingModes { get; }
[ObservableProperty]
@@ -42,6 +48,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 +84,36 @@ public sealed partial class StatusBarSettingsPageViewModel : ViewModelBase
[ObservableProperty]
private string _clockTransparentBackgroundDescription = string.Empty;
[ObservableProperty]
private string _clockPositionLabel = 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 _spacingHeader = string.Empty;
@@ -99,6 +138,20 @@ 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];
// 文字胶囊设置
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;
var spacingMode = NormalizeSpacingMode(state.SpacingMode);
SelectedSpacingMode = SpacingModes.FirstOrDefault(option =>
string.Equals(option.Value, spacingMode, StringComparison.OrdinalIgnoreCase))
@@ -137,6 +190,56 @@ public sealed partial class StatusBarSettingsPageViewModel : ViewModelBase
Save();
}
partial void OnSelectedClockPositionChanged(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 OnSelectedSpacingModeChanged(SelectionOption value)
{
IsCustomSpacingVisible = string.Equals(value?.Value, "Custom", StringComparison.OrdinalIgnoreCase);
@@ -184,6 +287,11 @@ public sealed partial class StatusBarSettingsPageViewModel : ViewModelBase
state.TaskbarLayoutMode,
SelectedClockFormat.Value,
ClockTransparentBackground,
SelectedClockPosition.Value,
ShowTextCapsule,
TextCapsuleContent ?? "**Hello** World!",
SelectedTextCapsulePosition?.Value ?? "Right",
TextCapsuleTransparentBackground,
NormalizeSpacingMode(SelectedSpacingMode.Value),
Math.Clamp(CustomSpacingPercent, 0, 30)));
}
@@ -197,6 +305,26 @@ 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> CreateSpacingModes()
{
return
@@ -217,6 +345,12 @@ 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");
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");
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 (%)");
@@ -232,6 +366,26 @@ 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 string L(string key, string fallback)
=> _localizationService.GetString(_languageCode, key, fallback);
}

View File

@@ -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));
}
}

View File

@@ -21,6 +21,9 @@
<ComboBoxItem x:Name="SectlItem"
Classes="component-editor-select-item"
Tag="sectl" />
<ComboBoxItem x:Name="RinLitItem"
Classes="component-editor-select-item"
Tag="rinlit" />
</ComboBox>
<TextBlock x:Name="SourceDescriptionTextBlock"
Classes="component-editor-secondary-text"

View File

@@ -29,10 +29,11 @@ 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 图库");
// 数据源描述
SourceDescriptionTextBlock.Text = L("zhijiaohub.settings.source_desc",
"选择图片来源。ClassIsland 图库包含 ClassIsland 社区的趣味瞬间SECTL 图库包含 SECTL 社区的内容。");
"选择图片来源。ClassIsland 图库包含 ClassIsland 社区的趣味瞬间SECTL 图库包含 SECTL 社区的内容Rin's 图库包含 Rin's 社区的内容。");
// 镜像加速源
MirrorSourceLabelTextBlock.Text = L("zhijiaohub.settings.mirror_source", "镜像加速");
@@ -65,6 +66,7 @@ public partial class ZhiJiaoHubComponentEditor : ComponentEditorViewBase
SourceComboBox.SelectedItem = source switch
{
ZhiJiaoHubSources.Sectl => SectlItem,
ZhiJiaoHubSources.RinLit => RinLitItem,
_ => ClassIslandItem
};

View File

@@ -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))

View File

@@ -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)

View File

@@ -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
{
// 静默处理通知发送失败
}
}
}

View File

@@ -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);
}

View File

@@ -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)
{

View File

@@ -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)

View 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>

View 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;
}

View File

@@ -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)

View 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>

View 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)
});
}
}

View File

@@ -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>

View File

@@ -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);
}
}
}

View File

@@ -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>

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -364,12 +364,295 @@ public partial class MainWindow
? ClockDisplayFormat.HourMinute
: ClockDisplayFormat.HourMinuteSecond;
_statusBarClockTransparentBackground = snapshot.StatusBarClockTransparentBackground;
_clockPosition = NormalizeClockPosition(snapshot.ClockPosition);
if (ClockWidget is not null)
_showTextCapsule = snapshot.ShowTextCapsule;
_textCapsuleContent = snapshot.TextCapsuleContent ?? "**Hello** World!";
_textCapsulePosition = NormalizeTextCapsulePosition(snapshot.TextCapsulePosition);
_textCapsuleTransparentBackground = snapshot.TextCapsuleTransparentBackground;
ApplyClockSettingsToAllWidgets();
ApplyTextCapsuleSettingsToAllWidgets();
}
private void ApplyClockSettingsToAllWidgets()
{
if (ClockWidgetLeft is not null)
{
ClockWidget.SetDisplayFormat(_clockDisplayFormat);
ClockWidget.SetTransparentBackground(_statusBarClockTransparentBackground);
ClockWidgetLeft.SetDisplayFormat(_clockDisplayFormat);
ClockWidgetLeft.SetTransparentBackground(_statusBarClockTransparentBackground);
}
if (ClockWidgetCenter is not null)
{
ClockWidgetCenter.SetDisplayFormat(_clockDisplayFormat);
ClockWidgetCenter.SetTransparentBackground(_statusBarClockTransparentBackground);
}
if (ClockWidgetRight is not null)
{
ClockWidgetRight.SetDisplayFormat(_clockDisplayFormat);
ClockWidgetRight.SetTransparentBackground(_statusBarClockTransparentBackground);
}
}
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 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"
};
}
/// <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 +660,113 @@ 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 (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;
}
}
}
}
@@ -394,6 +774,168 @@ 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;
}
}
}
/// <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()

View File

@@ -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)

View File

@@ -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();

View File

@@ -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>
@@ -262,13 +233,47 @@
Background="Transparent"
BorderThickness="0"
Padding="4">
<StackPanel x:Name="TopStatusComponentsPanel"
Orientation="Horizontal"
Spacing="6">
<comp:ClockWidget x:Name="ClockWidget"
IsVisible="False"
Margin="0" />
</StackPanel>
<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" />
</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" />
</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" />
</StackPanel>
</Grid>
</Border>
<Border x:Name="BottomTaskbarContainer"

View File

@@ -135,6 +135,11 @@ 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 bool _showTextCapsule;
private string _textCapsuleContent = "**Hello** World!";
private string _textCapsulePosition = "Right"; // Left, Center, Right
private bool _textCapsuleTransparentBackground;
private int _desktopEdgeInsetPercent = DefaultEdgeInsetPercent;
private string _taskbarLayoutMode = TaskbarLayoutBottomFullRowMacStyle;
private string _languageCode = "zh-CN";
@@ -238,9 +243,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 +293,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 +486,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 +628,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 +706,19 @@ 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);
var buttonMinWidth = Math.Clamp(taskbarCellHeight * 2.35, 100, 340);
@@ -737,7 +757,12 @@ 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);
}
}

View 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>

View 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();
}
}

View 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>

View 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);
}
}

View File

@@ -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" />

View File

@@ -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>

View File

@@ -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; }
}

View File

@@ -52,6 +52,78 @@
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: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>
<Separator Classes="settings-separator" />

View File

@@ -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>

View File

@@ -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;
}
}
}

View File

@@ -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
};
}

View 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>

View 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);

View 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>

View 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;
}
}

View 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 token18 * GlobalCornerRadiusScale。
- 入口LanMountainDesktop/Views/Components/DesktopComponentRuntimeRegistry.cs
- 默认 resolverComponentChromeCornerRadiusHelper.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.axamlRootBorder + 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. 如果你愿意,我可以直接开始改第一批基础代码,把内置组件和插件默认圆角先统一起来