fead.Hub组件支持双击打开图片,支持三指翻页退出应用

This commit is contained in:
lincube
2026-04-02 21:12:06 +08:00
parent ff014717fa
commit 88bd92e40a
26 changed files with 1991 additions and 383 deletions

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

@@ -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,350 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using Avalonia;
using Avalonia.Controls;
namespace LanMountainDesktop.Services;
/// <summary>
/// 窗口置底服务接口
/// </summary>
public interface IWindowBottomMostService
{
void SetupBottomMost(Window window);
void SendToBottom(Window window);
bool IsBottomMostSupported { get; }
}
/// <summary>
/// 区域级穿透服务接口 - 使用 WM_NCHITTEST 实现
/// </summary>
public interface IRegionPassthroughService
{
/// <summary>
/// 设置窗口的可交互区域
/// </summary>
void SetInteractiveRegions(Window window, IReadOnlyList<Rect> interactiveRegions);
/// <summary>
/// 清除所有可交互区域
/// </summary>
void ClearInteractiveRegions(Window window);
/// <summary>
/// 获取当前平台是否支持区域级穿透
/// </summary>
bool IsRegionPassthroughSupported { get; }
}
/// <summary>
/// 窗口置底服务工厂
/// </summary>
public static class WindowBottomMostServiceFactory
{
private static IWindowBottomMostService? _instance;
private static readonly object _lock = new();
public static IWindowBottomMostService GetOrCreate()
{
lock (_lock)
{
return _instance ??= OperatingSystem.IsWindows()
? new WindowsWindowBottomMostService()
: new NullWindowBottomMostService();
}
}
}
/// <summary>
/// 区域级穿透服务工厂
/// </summary>
public static class RegionPassthroughServiceFactory
{
private static IRegionPassthroughService? _instance;
private static readonly object _lock = new();
public static IRegionPassthroughService GetOrCreate()
{
lock (_lock)
{
return _instance ??= OperatingSystem.IsWindows()
? new WindowsRegionPassthroughService()
: new NullRegionPassthroughService();
}
}
}
/// <summary>
/// Windows 平台窗口置底服务
/// </summary>
internal sealed class WindowsWindowBottomMostService : IWindowBottomMostService
{
private const int GWL_EXSTYLE = -20;
private const int GWL_HWNDPARENT = -8;
private const int GWLP_WNDPROC = -4;
private const int WS_EX_TOOLWINDOW = 0x00000080;
private const int WS_EX_APPWINDOW = 0x00040000;
private const int WS_EX_NOACTIVATE = 0x08000000;
private const int WS_EX_LAYERED = 0x00080000;
private const uint SWP_NOSIZE = 0x0001;
private const uint SWP_NOMOVE = 0x0002;
private const uint SWP_NOACTIVATE = 0x0010;
private const int WM_WINDOWPOSCHANGING = 0x0046;
private const int WM_NCHITTEST = 0x0084;
private const int HTTRANSPARENT = -1;
private const int HTCLIENT = 1;
private static readonly IntPtr HWND_BOTTOM = new(1);
private static readonly Dictionary<IntPtr, bool> _bottomMostWindows = new();
private static readonly Dictionary<IntPtr, IntPtr> _originalWndProcs = new();
private static readonly Dictionary<IntPtr, List<Rect>> _interactiveRegions = new();
private static readonly object _staticLock = new();
public bool IsBottomMostSupported => true;
public void SetupBottomMost(Window window)
{
if (!OperatingSystem.IsWindows()) return;
window.Opened += (s, e) =>
{
var handle = GetWindowHandle(window);
if (handle == IntPtr.Zero) return;
// 设置扩展样式
var exStyle = GetWindowLong(handle, GWL_EXSTYLE);
exStyle = (exStyle | WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE | WS_EX_LAYERED) & ~WS_EX_APPWINDOW;
SetWindowLong(handle, GWL_EXSTYLE, exStyle);
// 设置为桌面子窗口
SetAsDesktopChild(handle);
// 注册置底状态
lock (_staticLock)
{
_bottomMostWindows[handle] = true;
_interactiveRegions[handle] = [];
}
// 注入消息钩子
InstallMessageHook(handle);
// 初始置底
SendToBottomInternal(handle);
AppLogger.Info("WindowBottomMost", $"Window setup as bottom-most: {handle}");
};
window.Closed += (s, e) =>
{
var handle = GetWindowHandle(window);
if (handle != IntPtr.Zero)
{
lock (_staticLock)
{
_bottomMostWindows.Remove(handle);
_originalWndProcs.Remove(handle);
_interactiveRegions.Remove(handle);
}
}
};
}
public void SendToBottom(Window window)
{
var handle = GetWindowHandle(window);
if (handle != IntPtr.Zero) SendToBottomInternal(handle);
}
private static IntPtr GetWindowHandle(Window window)
{
try { return window.TryGetPlatformHandle()?.Handle ?? IntPtr.Zero; }
catch { return IntPtr.Zero; }
}
private static void SendToBottomInternal(IntPtr handle)
{
SetWindowPos(handle, HWND_BOTTOM, 0, 0, 0, 0, SWP_NOSIZE | SWP_NOMOVE | SWP_NOACTIVATE);
}
private static void SetAsDesktopChild(IntPtr handle)
{
var windowHandles = new ArrayList();
EnumWindows(EnumWindowsCallback, windowHandles);
foreach (IntPtr h in windowHandles)
{
var hDefView = FindWindowEx(h, IntPtr.Zero, "SHELLDLL_DefView", null);
if (hDefView != IntPtr.Zero)
{
SetWindowLong(handle, GWL_HWNDPARENT, hDefView.ToInt32());
break;
}
}
}
private static bool EnumWindowsCallback(IntPtr handle, ArrayList handles)
{
handles.Add(handle);
return true;
}
private static void InstallMessageHook(IntPtr handle)
{
var originalWndProc = GetWindowLongPtr(handle, GWLP_WNDPROC);
if (originalWndProc == IntPtr.Zero) return;
lock (_staticLock)
{
_originalWndProcs[handle] = originalWndProc;
}
SetWindowLongPtr(handle, GWLP_WNDPROC, Marshal.GetFunctionPointerForDelegate<WndProcDelegate>(SubclassWndProc));
}
private static IntPtr SubclassWndProc(IntPtr hWnd, int msg, IntPtr wParam, IntPtr lParam)
{
// 处理 WM_WINDOWPOSCHANGING - 保持置底
if (msg == WM_WINDOWPOSCHANGING)
{
lock (_staticLock)
{
if (_bottomMostWindows.TryGetValue(hWnd, out var isBottomMost) && isBottomMost)
{
SendToBottomInternal(hWnd);
}
}
}
// 处理 WM_NCHITTEST - 区域级穿透
if (msg == WM_NCHITTEST)
{
// 从 lParam 解析坐标(低字为 X高字为 Y
var x = (short)(wParam.ToInt32() & 0xFFFF);
var y = (short)((wParam.ToInt32() >> 16) & 0xFFFF);
var point = new Point(x, y);
lock (_staticLock)
{
if (_interactiveRegions.TryGetValue(hWnd, out var regions))
{
foreach (var region in regions)
{
if (region.Contains(point))
{
// 在可交互区域内,返回 HTCLIENT
return (IntPtr)HTCLIENT;
}
}
}
}
// 不在可交互区域内,返回 HTTRANSPARENT 让事件穿透
return (IntPtr)HTTRANSPARENT;
}
// 调用原始窗口过程
IntPtr originalWndProc;
lock (_staticLock)
{
if (!_originalWndProcs.TryGetValue(hWnd, out originalWndProc))
{
return DefWindowProc(hWnd, msg, wParam, lParam);
}
}
return CallWindowProc(originalWndProc, hWnd, msg, wParam, lParam);
}
/// <summary>
/// 设置窗口的可交互区域(供 WindowsRegionPassthroughService 调用)
/// </summary>
internal static void SetInteractiveRegionsInternal(IntPtr handle, List<Rect> regions)
{
lock (_staticLock)
{
_interactiveRegions[handle] = regions;
}
}
private delegate IntPtr WndProcDelegate(IntPtr hWnd, int msg, IntPtr wParam, IntPtr lParam);
[DllImport("user32.dll", SetLastError = true)]
private static extern int GetWindowLong(IntPtr hWnd, int nIndex);
[DllImport("user32.dll")]
private static extern int SetWindowLong(IntPtr hWnd, int nIndex, int dwNewLong);
[DllImport("user32.dll", EntryPoint = "GetWindowLongPtr")]
private static extern IntPtr GetWindowLongPtr(IntPtr hWnd, int nIndex);
[DllImport("user32.dll", EntryPoint = "SetWindowLongPtr")]
private static extern IntPtr SetWindowLongPtr(IntPtr hWnd, int nIndex, IntPtr dwNewLong);
[DllImport("user32.dll")]
private static extern bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter, int x, int y, int cx, int cy, uint flags);
[DllImport("user32.dll", SetLastError = true)]
private static extern IntPtr FindWindowEx(IntPtr hParent, IntPtr hChildAfter, string? lpszClass, string? lpszWindow);
[DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool EnumWindows(EnumWindowsProc lpEnumFunc, ArrayList lParam);
private delegate bool EnumWindowsProc(IntPtr handle, ArrayList handles);
[DllImport("user32.dll")]
private static extern IntPtr CallWindowProc(IntPtr lpPrevWndFunc, IntPtr hWnd, int Msg, IntPtr wParam, IntPtr lParam);
[DllImport("user32.dll")]
private static extern IntPtr DefWindowProc(IntPtr hWnd, int uMsg, IntPtr wParam, IntPtr lParam);
}
/// <summary>
/// Windows 平台区域级穿透服务 - 使用 WM_NCHITTEST
/// </summary>
internal sealed class WindowsRegionPassthroughService : IRegionPassthroughService
{
public bool IsRegionPassthroughSupported => true;
public void SetInteractiveRegions(Window window, IReadOnlyList<Rect> interactiveRegions)
{
var handle = GetWindowHandle(window);
if (handle == IntPtr.Zero) return;
WindowsWindowBottomMostService.SetInteractiveRegionsInternal(handle, new List<Rect>(interactiveRegions));
AppLogger.Info("RegionPassthrough", $"Set {interactiveRegions.Count} interactive regions.");
}
public void ClearInteractiveRegions(Window window)
{
var handle = GetWindowHandle(window);
if (handle == IntPtr.Zero) return;
WindowsWindowBottomMostService.SetInteractiveRegionsInternal(handle, []);
}
private static IntPtr GetWindowHandle(Window window)
{
try { return window.TryGetPlatformHandle()?.Handle ?? IntPtr.Zero; }
catch { return IntPtr.Zero; }
}
}
/// <summary>
/// 空实现
/// </summary>
internal sealed class NullWindowBottomMostService : IWindowBottomMostService
{
public bool IsBottomMostSupported => false;
public void SetupBottomMost(Window window) { }
public void SendToBottom(Window window) { }
}
internal sealed class NullRegionPassthroughService : IRegionPassthroughService
{
public bool IsRegionPassthroughSupported => false;
public void SetInteractiveRegions(Window window, IReadOnlyList<Rect> interactiveRegions) { }
public void ClearInteractiveRegions(Window window) { }
}