Compare commits

...

4 Commits

Author SHA1 Message Date
lincube
cb86ca10e7 0.6.8
小黑板数据持久化。
2026-03-19 16:27:16 +08:00
lincube
b3a74aa072 0.6.7.2
文档组件优化
2026-03-19 08:39:25 +08:00
lincube
b436bfa884 0.6.7.1
多平台适配
2026-03-19 02:02:07 +08:00
lincube
081abeb688 0 6 7
可移动存储组件
2026-03-19 00:17:21 +08:00
58 changed files with 3479 additions and 326 deletions

View File

@@ -0,0 +1,16 @@
# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY
version = 1
name = "LanMountainDesktop"
[setup]
script = ""
[[actions]]
name = "运行"
icon = "run"
command = "dotnet run --project 'C:\\Users\\USER693091\\Documents\\GitHub\\LanMountainDesktop\\LanMountainDesktop\\LanMountainDesktop.csproj"
[[actions]]
name = "构建"
icon = "tool"
command = "dotnet build 'C:\\Users\\USER693091\\Documents\\GitHub\\LanMountainDesktop\\LanMountainDesktop.slnx"

View File

@@ -4,6 +4,7 @@
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<IsPackable>false</IsPackable> <IsPackable>false</IsPackable>
<Version>1.0.0</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>

View File

@@ -0,0 +1,157 @@
using System;
using System.IO;
using LanMountainDesktop.Models;
using LanMountainDesktop.Services;
using Xunit;
namespace LanMountainDesktop.Tests;
public sealed class WhiteboardNotePersistenceServiceTests
{
[Fact]
public void SaveNote_ThenLoadNote_RoundTripsSnapshot()
{
using var sandbox = new WhiteboardNotePersistenceSandbox();
var service = sandbox.CreateService();
var snapshot = CreateSampleSnapshot();
service.SaveNote("DesktopWhiteboard", "whiteboard-1", snapshot, retentionDays: 15);
var loaded = service.LoadNote("DesktopWhiteboard", "whiteboard-1", retentionDays: 15);
Assert.Single(loaded.Strokes);
Assert.Equal(2, loaded.Strokes[0].Points.Count);
Assert.Equal("#FF112233", loaded.Strokes[0].Color);
Assert.True(loaded.SavedUtc > DateTimeOffset.MinValue);
}
[Fact]
public void LoadNote_RemovesExpiredSnapshot_WhenRetentionExceeded()
{
using var sandbox = new WhiteboardNotePersistenceSandbox();
var service = sandbox.CreateService();
service.SaveNote("DesktopWhiteboard", "expired-board", CreateSampleSnapshot(), retentionDays: 7);
sandbox.OverrideSavedTimestamp("DesktopWhiteboard", "expired-board", DateTimeOffset.UtcNow.AddDays(-10), retentionDays: 7);
var loaded = service.LoadNote("DesktopWhiteboard", "expired-board", retentionDays: 7);
Assert.Empty(loaded.Strokes);
Assert.False(sandbox.Exists("DesktopWhiteboard", "expired-board"));
}
[Fact]
public void DeleteExpiredNotesBatch_RemovesExpiredRows_AndKeepsFreshRows()
{
using var sandbox = new WhiteboardNotePersistenceSandbox();
var service = sandbox.CreateService();
service.SaveNote("DesktopWhiteboard", "expired-a", CreateSampleSnapshot(), retentionDays: 7);
service.SaveNote("DesktopWhiteboard", "expired-b", CreateSampleSnapshot(), retentionDays: 7);
service.SaveNote("DesktopWhiteboard", "fresh-c", CreateSampleSnapshot(), retentionDays: 15);
sandbox.OverrideSavedTimestamp("DesktopWhiteboard", "expired-a", DateTimeOffset.UtcNow.AddDays(-9), retentionDays: 7);
sandbox.OverrideSavedTimestamp("DesktopWhiteboard", "expired-b", DateTimeOffset.UtcNow.AddDays(-8), retentionDays: 7);
sandbox.OverrideSavedTimestamp("DesktopWhiteboard", "fresh-c", DateTimeOffset.UtcNow.AddDays(-2), retentionDays: 15);
var deletedCount = service.DeleteExpiredNotesBatch(batchSize: 10);
Assert.Equal(2, deletedCount);
Assert.False(sandbox.Exists("DesktopWhiteboard", "expired-a"));
Assert.False(sandbox.Exists("DesktopWhiteboard", "expired-b"));
Assert.True(sandbox.Exists("DesktopWhiteboard", "fresh-c"));
}
private static WhiteboardNoteSnapshot CreateSampleSnapshot()
{
return new WhiteboardNoteSnapshot
{
Strokes =
[
new WhiteboardStrokeSnapshot
{
Color = "#FF112233",
InkThickness = 3.5d,
IgnorePressure = true,
Points =
[
new WhiteboardStylusPointSnapshot { X = 12, Y = 34, Pressure = 0.4d, Width = 2, Height = 2 },
new WhiteboardStylusPointSnapshot { X = 48, Y = 64, Pressure = 0.7d, Width = 2, Height = 2 }
]
}
]
};
}
private sealed class WhiteboardNotePersistenceSandbox : IDisposable
{
private readonly string _directoryPath = Path.Combine(
Path.GetTempPath(),
"LanMountainDesktop.WhiteboardNoteTests",
Guid.NewGuid().ToString("N"));
private readonly string _databasePath;
public WhiteboardNotePersistenceSandbox()
{
Directory.CreateDirectory(_directoryPath);
_databasePath = Path.Combine(_directoryPath, "whiteboard-tests.db");
}
public WhiteboardNotePersistenceService CreateService()
{
return new WhiteboardNotePersistenceService(new AppDatabaseService(_databasePath));
}
public void OverrideSavedTimestamp(string componentId, string placementId, DateTimeOffset savedUtc, int retentionDays)
{
var expiresUtc = savedUtc.AddDays(WhiteboardNoteRetentionPolicy.NormalizeDays(retentionDays));
using var connection = new AppDatabaseService(_databasePath).OpenConnection();
using var command = connection.CreateCommand();
command.CommandText = """
UPDATE whiteboard_notes
SET saved_at_utc_ms = $savedAtUtcMs,
expires_at_utc_ms = $expiresAtUtcMs,
updated_at_utc_ms = $updatedAtUtcMs
WHERE component_id = $componentId
AND placement_id = $placementId;
""";
command.Parameters.AddWithValue("$savedAtUtcMs", savedUtc.ToUnixTimeMilliseconds());
command.Parameters.AddWithValue("$expiresAtUtcMs", expiresUtc.ToUnixTimeMilliseconds());
command.Parameters.AddWithValue("$updatedAtUtcMs", DateTimeOffset.UtcNow.ToUnixTimeMilliseconds());
command.Parameters.AddWithValue("$componentId", componentId);
command.Parameters.AddWithValue("$placementId", placementId);
command.ExecuteNonQuery();
}
public bool Exists(string componentId, string placementId)
{
using var connection = new AppDatabaseService(_databasePath).OpenConnection();
using var command = connection.CreateCommand();
command.CommandText = """
SELECT COUNT(1)
FROM whiteboard_notes
WHERE component_id = $componentId
AND placement_id = $placementId;
""";
command.Parameters.AddWithValue("$componentId", componentId);
command.Parameters.AddWithValue("$placementId", placementId);
return Convert.ToInt32(command.ExecuteScalar()) > 0;
}
public void Dispose()
{
try
{
if (Directory.Exists(_directoryPath))
{
Directory.Delete(_directoryPath, true);
}
}
catch
{
// Temporary test directories are best-effort cleanup.
}
}
}
}

View File

@@ -41,4 +41,5 @@ public static class BuiltInComponentIds
public const string DesktopBlackboardLandscape = "DesktopBlackboardLandscape"; public const string DesktopBlackboardLandscape = "DesktopBlackboardLandscape";
public const string DesktopBrowser = "DesktopBrowser"; public const string DesktopBrowser = "DesktopBrowser";
public const string DesktopOfficeRecentDocuments = "DesktopOfficeRecentDocuments"; public const string DesktopOfficeRecentDocuments = "DesktopOfficeRecentDocuments";
public const string DesktopRemovableStorage = "DesktopRemovableStorage";
} }

View File

@@ -336,6 +336,15 @@ public sealed class ComponentRegistry
MinHeightCells: 2, MinHeightCells: 2,
AllowStatusBarPlacement: false, AllowStatusBarPlacement: false,
AllowDesktopPlacement: true), AllowDesktopPlacement: true),
new DesktopComponentDefinition(
BuiltInComponentIds.DesktopRemovableStorage,
"Removable Storage",
"Storage",
"File",
MinWidthCells: 2,
MinHeightCells: 2,
AllowStatusBarPlacement: false,
AllowDesktopPlacement: true),
new DesktopComponentDefinition( new DesktopComponentDefinition(
BuiltInComponentIds.Date, BuiltInComponentIds.Date,
"Calendar", "Calendar",

View File

@@ -55,6 +55,10 @@
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" /> <PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.0" /> <PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.0" />
<PackageReference Include="MudTools.OfficeInterop" Version="2.0.8" />
<PackageReference Include="MudTools.OfficeInterop.Word" Version="2.0.8" />
<PackageReference Include="MudTools.OfficeInterop.Excel" Version="2.0.8" />
<PackageReference Include="MudTools.OfficeInterop.PowerPoint" Version="2.0.8" />
<PackageReference Include="PortAudioSharp2" Version="1.0.6" /> <PackageReference Include="PortAudioSharp2" Version="1.0.6" />
<PackageReference Include="MaterialColorUtilities" Version="0.3.0" /> <PackageReference Include="MaterialColorUtilities" Version="0.3.0" />

View File

@@ -7,7 +7,12 @@
"tray.menu.restart": "Restart App", "tray.menu.restart": "Restart App",
"tray.menu.exit": "Exit App", "tray.menu.exit": "Exit App",
"button.back_to_windows": "Back to Windows", "button.back_to_windows": "Back to Windows",
"button.back_to_platform": "Back to {0}",
"tooltip.back_to_windows": "Back to Windows", "tooltip.back_to_windows": "Back to Windows",
"tooltip.back_to_platform": "Back to {0}",
"platform.windows": "Windows",
"platform.linux": "Linux",
"platform.macos": "macOS",
"tooltip.open_settings": "Settings", "tooltip.open_settings": "Settings",
"settings.title": "Settings", "settings.title": "Settings",
"settings.shell.title": "Settings", "settings.shell.title": "Settings",
@@ -86,6 +91,8 @@
"settings.status_bar.description": "Choose which components appear on the top status bar.", "settings.status_bar.description": "Choose which components appear on the top status bar.",
"settings.status_bar.clock_header": "Clock Component", "settings.status_bar.clock_header": "Clock Component",
"settings.status_bar.clock_description": "Display a clock on the top status bar.", "settings.status_bar.clock_description": "Display a clock on the top status bar.",
"settings.status_bar.clock_transparent_background_label": "Transparent background",
"settings.status_bar.clock_transparent_background_desc": "Remove the capsule background and keep only the clock text.",
"settings.status_bar.spacing_header": "Component Spacing", "settings.status_bar.spacing_header": "Component Spacing",
"settings.status_bar.spacing_desc": "Adjust spacing between status bar components.", "settings.status_bar.spacing_desc": "Adjust spacing between status bar components.",
"settings.status_bar.spacing_mode_compact": "Compact", "settings.status_bar.spacing_mode_compact": "Compact",
@@ -403,6 +410,7 @@
"common.monet": "Monet", "common.monet": "Monet",
"desktop.page_index_format": "Desktop {0}", "desktop.page_index_format": "Desktop {0}",
"launcher.title": "App Launcher", "launcher.title": "App Launcher",
"launcher.folder": "Folder",
"launcher.subtitle": "Apps and folders from Windows Start Menu", "launcher.subtitle": "Apps and folders from Windows Start Menu",
"launcher.subtitle_linux": "Installed apps discovered from Linux desktop entries", "launcher.subtitle_linux": "Installed apps discovered from Linux desktop entries",
"launcher.empty": "No Start Menu entries found.", "launcher.empty": "No Start Menu entries found.",
@@ -588,6 +596,19 @@
"component.blackboard_landscape": "Blackboard (Landscape)", "component.blackboard_landscape": "Blackboard (Landscape)",
"component.browser": "Browser", "component.browser": "Browser",
"component.office_recent_documents": "Recent Documents", "component.office_recent_documents": "Recent Documents",
"whiteboard.settings.desc": "Each blackboard keeps its own note history and saves it independently.",
"whiteboard.settings.retention.title": "Note retention",
"whiteboard.settings.retention.desc": "Choose how long this blackboard should keep saved notes before expired data is removed automatically.",
"whiteboard.settings.retention.option": "{0} days",
"whiteboard.settings.instance_scope": "This retention setting is stored per blackboard component instance.",
"office_recent_documents.settings.desc": "Choose which Windows and Office sources this widget should scan for recent documents.",
"office_recent_documents.settings.sources_title": "Recent document sources",
"office_recent_documents.settings.sources_desc": "You can combine multiple sources. Registry selection also keeps the Office interop MRU fallback available.",
"office_recent_documents.settings.source.registry": "Office registry MRU",
"office_recent_documents.settings.source.recent_folders": "Windows Recent folders",
"office_recent_documents.settings.source.jump_lists": "Windows Jump Lists",
"office_recent_documents.settings.hint": "If you disable all sources, this widget will stay empty until at least one source is enabled again.",
"component.removable_storage": "Removable Storage",
"component.holiday_calendar": "Holiday Calendar", "component.holiday_calendar": "Holiday Calendar",
"component.study_environment": "Environment", "component.study_environment": "Environment",
"component.study_session_control": "Study Session Control", "component.study_session_control": "Study Session Control",
@@ -789,6 +810,20 @@
"study.environment.settings.show_display_db": "Show display dB", "study.environment.settings.show_display_db": "Show display dB",
"study.environment.settings.show_dbfs": "Show dBFS", "study.environment.settings.show_dbfs": "Show dBFS",
"study.environment.settings.hint": "At least one display mode must stay enabled.", "study.environment.settings.hint": "At least one display mode must stay enabled.",
"removable_storage.settings.desc": "Show a connected USB drive with quick open and eject actions.",
"removable_storage.settings.behavior_title": "Behavior",
"removable_storage.settings.behavior_desc": "The widget automatically watches for removable drives and switches to the newest inserted USB drive.",
"removable_storage.action.open": "Open",
"removable_storage.action.eject": "Eject",
"removable_storage.widget.default_name": "Removable Drive",
"removable_storage.widget.empty_title": "No device inserted",
"removable_storage.widget.empty_subtitle": "Insert a USB drive to show it here.",
"removable_storage.widget.empty_hint": "Buttons stay disabled until a removable device is inserted.",
"removable_storage.widget.ready": "Ready to open or eject.",
"removable_storage.widget.ejecting": "Ejecting drive...",
"removable_storage.widget.eject_failed": "Could not eject this drive. Close any files on it and try again.",
"removable_storage.widget.open_failed": "Failed to open this drive.",
"removable_storage.widget.refresh_failed": "Drive list refresh failed.",
"study.session_control.action.start": "Start Study Session", "study.session_control.action.start": "Start Study Session",
"study.session_control.action.stop": "Stop Study Session", "study.session_control.action.stop": "Stop Study Session",
"study.session_control.idle_hint": "Tap the right button to start", "study.session_control.idle_hint": "Tap the right button to start",

View File

@@ -7,7 +7,12 @@
"tray.menu.restart": "重启应用", "tray.menu.restart": "重启应用",
"tray.menu.exit": "退出应用", "tray.menu.exit": "退出应用",
"button.back_to_windows": "回到Windows", "button.back_to_windows": "回到Windows",
"button.back_to_platform": "回到{0}",
"tooltip.back_to_windows": "回到Windows", "tooltip.back_to_windows": "回到Windows",
"tooltip.back_to_platform": "回到{0}",
"platform.windows": "Windows",
"platform.linux": "Linux",
"platform.macos": "macOS",
"tooltip.open_settings": "设置", "tooltip.open_settings": "设置",
"settings.title": "设置", "settings.title": "设置",
"settings.shell.title": "设置", "settings.shell.title": "设置",
@@ -85,6 +90,8 @@
"settings.status_bar.description": "选择顶部状态栏显示的组件。", "settings.status_bar.description": "选择顶部状态栏显示的组件。",
"settings.status_bar.clock_header": "时间组件", "settings.status_bar.clock_header": "时间组件",
"settings.status_bar.clock_description": "在顶部状态栏显示时钟。", "settings.status_bar.clock_description": "在顶部状态栏显示时钟。",
"settings.status_bar.clock_transparent_background_label": "透明背景",
"settings.status_bar.clock_transparent_background_desc": "移除胶囊背景,仅保留时钟文字。",
"settings.status_bar.spacing_header": "组件间距", "settings.status_bar.spacing_header": "组件间距",
"settings.status_bar.spacing_desc": "调整状态栏组件之间的间距。", "settings.status_bar.spacing_desc": "调整状态栏组件之间的间距。",
"settings.status_bar.spacing_mode_compact": "紧凑", "settings.status_bar.spacing_mode_compact": "紧凑",
@@ -401,6 +408,7 @@
"common.monet": "莫奈", "common.monet": "莫奈",
"desktop.page_index_format": "桌面 {0}", "desktop.page_index_format": "桌面 {0}",
"launcher.title": "应用启动台", "launcher.title": "应用启动台",
"launcher.folder": "文件夹",
"launcher.subtitle": "按 Windows 开始菜单结构显示所有应用与文件夹", "launcher.subtitle": "按 Windows 开始菜单结构显示所有应用与文件夹",
"launcher.subtitle_linux": "显示从 Linux .desktop 条目扫描到的已安装应用", "launcher.subtitle_linux": "显示从 Linux .desktop 条目扫描到的已安装应用",
"launcher.empty": "未找到开始菜单条目。", "launcher.empty": "未找到开始菜单条目。",
@@ -586,6 +594,18 @@
"component.blackboard_landscape": "横向小黑板", "component.blackboard_landscape": "横向小黑板",
"component.browser": "浏览器", "component.browser": "浏览器",
"component.office_recent_documents": "最近文档", "component.office_recent_documents": "最近文档",
"whiteboard.settings.desc": "每个小黑板都会独立保存自己的笔记历史。",
"whiteboard.settings.retention.title": "笔记保留时间",
"whiteboard.settings.retention.desc": "选择这个小黑板在过期笔记被自动删除前,应当保留已保存笔记多久。",
"whiteboard.settings.retention.option": "{0} 天",
"whiteboard.settings.instance_scope": "这个保留时间设置会按每个小黑板组件实例单独存储。",
"office_recent_documents.settings.desc": "选择此小组件需要扫描的 Windows 和 Office 最近文档来源。",
"office_recent_documents.settings.sources_title": "最近文档来源",
"office_recent_documents.settings.sources_desc": "可以同时选择多个来源。勾选注册表来源时,还会保留 Office interop 的 MRU 回退。",
"office_recent_documents.settings.source.registry": "Office 注册表 MRU",
"office_recent_documents.settings.source.recent_folders": "Windows 最近文件夹",
"office_recent_documents.settings.source.jump_lists": "Windows 跳转列表",
"office_recent_documents.settings.hint": "如果关闭全部来源,此小组件会保持空白,直到再次至少启用一个来源。",
"component.holiday_calendar": "节假日日历", "component.holiday_calendar": "节假日日历",
"component.study_environment": "环境", "component.study_environment": "环境",
"component.study_session_control": "自习时段控制", "component.study_session_control": "自习时段控制",
@@ -782,6 +802,21 @@
"study.environment.value.unavailable": "--", "study.environment.value.unavailable": "--",
"study.environment.value.display_format": "{0:F1} dB", "study.environment.value.display_format": "{0:F1} dB",
"study.environment.value.dbfs_format": "{0:F1} dBFS", "study.environment.value.dbfs_format": "{0:F1} dBFS",
"component.removable_storage": "可移动存储",
"removable_storage.settings.desc": "在桌面上显示已连接的 U 盘,并提供打开与弹出操作。",
"removable_storage.settings.behavior_title": "行为",
"removable_storage.settings.behavior_desc": "组件会自动监听可移动存储设备,并优先显示最新插入的 U 盘。",
"removable_storage.action.open": "打开",
"removable_storage.action.eject": "弹出",
"removable_storage.widget.default_name": "可移动磁盘",
"removable_storage.widget.empty_title": "未插入设备",
"removable_storage.widget.empty_subtitle": "插入 U 盘后会自动显示在这里。",
"removable_storage.widget.empty_hint": "在插入可移动设备之前,底部按钮会保持置灰不可点击。",
"removable_storage.widget.ready": "已准备好,可直接打开或弹出。",
"removable_storage.widget.ejecting": "正在弹出设备...",
"removable_storage.widget.eject_failed": "无法弹出该设备,请先关闭正在占用它的文件后再试。",
"removable_storage.widget.open_failed": "打开该设备失败。",
"removable_storage.widget.refresh_failed": "刷新可移动存储列表失败。",
"study.environment.settings.title": "环境组件设置", "study.environment.settings.title": "环境组件设置",
"study.environment.settings.desc": "配置右侧实时噪音值显示内容。", "study.environment.settings.desc": "配置右侧实时噪音值显示内容。",
"study.environment.settings.show_display_db": "显示 display dB", "study.environment.settings.show_display_db": "显示 display dB",

View File

@@ -101,6 +101,8 @@ public sealed class AppSettingsSnapshot
public string ClockDisplayFormat { get; set; } = "HourMinuteSecond"; public string ClockDisplayFormat { get; set; } = "HourMinuteSecond";
public bool StatusBarClockTransparentBackground { get; set; }
public string StatusBarSpacingMode { get; set; } = "Relaxed"; public string StatusBarSpacingMode { get; set; } = "Relaxed";
public int StatusBarCustomSpacingPercent { get; set; } = 12; public int StatusBarCustomSpacingPercent { get; set; } = 12;

View File

@@ -58,12 +58,16 @@ public sealed class ComponentSettingsSnapshot
public int WeatherAutoRefreshIntervalMinutes { get; set; } = 12; public int WeatherAutoRefreshIntervalMinutes { get; set; } = 12;
public int WhiteboardNoteRetentionDays { get; set; } = 15;
public bool Stcn24ForumAutoRefreshEnabled { get; set; } = true; public bool Stcn24ForumAutoRefreshEnabled { get; set; } = true;
public int Stcn24ForumAutoRefreshIntervalMinutes { get; set; } = 20; public int Stcn24ForumAutoRefreshIntervalMinutes { get; set; } = 20;
public string Stcn24ForumSourceType { get; set; } = Stcn24ForumSourceTypes.LatestCreated; public string Stcn24ForumSourceType { get; set; } = Stcn24ForumSourceTypes.LatestCreated;
public List<string>? OfficeRecentDocumentsEnabledSources { get; set; }
public ComponentSettingsSnapshot Clone() public ComponentSettingsSnapshot Clone()
{ {
var clone = (ComponentSettingsSnapshot)MemberwiseClone(); var clone = (ComponentSettingsSnapshot)MemberwiseClone();
@@ -91,6 +95,9 @@ public sealed class ComponentSettingsSnapshot
clone.WorldClockTimeZoneIds = WorldClockTimeZoneIds is { Count: > 0 } clone.WorldClockTimeZoneIds = WorldClockTimeZoneIds is { Count: > 0 }
? new List<string>(WorldClockTimeZoneIds) ? new List<string>(WorldClockTimeZoneIds)
: []; : [];
clone.OfficeRecentDocumentsEnabledSources = OfficeRecentDocumentsEnabledSources is not null
? new List<string>(OfficeRecentDocumentsEnabledSources)
: null;
return clone; return clone;
} }

View File

@@ -0,0 +1,53 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace LanMountainDesktop.Models;
public static class OfficeRecentDocumentSourceTypes
{
public const string Registry = "registry";
public const string RecentFolders = "recent_folders";
public const string JumpLists = "jump_lists";
public static IReadOnlyList<string> SupportedValues { get; } =
[
Registry,
RecentFolders,
JumpLists
];
public static IReadOnlyList<string> DefaultValues => SupportedValues;
public static IReadOnlyList<string> NormalizeValues(IEnumerable<string>? values, bool useDefaultWhenEmpty)
{
if (values is null)
{
return useDefaultWhenEmpty ? DefaultValues : Array.Empty<string>();
}
var normalized = values
.Select(NormalizeValue)
.OfType<string>()
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
if (normalized.Length == 0 && useDefaultWhenEmpty)
{
return DefaultValues;
}
return normalized;
}
private static string? NormalizeValue(string? value)
{
return value?.Trim().ToLowerInvariant() switch
{
Registry => Registry,
RecentFolders => RecentFolders,
JumpLists => JumpLists,
_ => null
};
}
}

View File

@@ -0,0 +1,23 @@
namespace LanMountainDesktop.Models;
public static class WhiteboardNoteRetentionPolicy
{
public const int MinimumDays = 7;
public const int MaximumDays = 15;
public const int DefaultDays = MaximumDays;
public static int NormalizeDays(int days)
{
if (days < MinimumDays)
{
return MinimumDays;
}
if (days > MaximumDays)
{
return MaximumDays;
}
return days;
}
}

View File

@@ -0,0 +1,60 @@
using System;
using System.Collections.Generic;
namespace LanMountainDesktop.Models;
public sealed class WhiteboardNoteSnapshot
{
public int Version { get; set; } = 1;
public DateTimeOffset SavedUtc { get; set; }
public List<WhiteboardStrokeSnapshot> Strokes { get; set; } = [];
public WhiteboardNoteSnapshot Clone()
{
var clone = (WhiteboardNoteSnapshot)MemberwiseClone();
clone.Strokes = Strokes is { Count: > 0 }
? new List<WhiteboardStrokeSnapshot>(Strokes.ConvertAll(stroke => stroke?.Clone() ?? new WhiteboardStrokeSnapshot()))
: [];
return clone;
}
}
public sealed class WhiteboardStrokeSnapshot
{
public string Color { get; set; } = "#FF000000";
public double InkThickness { get; set; } = 2.5d;
public bool IgnorePressure { get; set; } = true;
public List<WhiteboardStylusPointSnapshot> Points { get; set; } = [];
public WhiteboardStrokeSnapshot Clone()
{
var clone = (WhiteboardStrokeSnapshot)MemberwiseClone();
clone.Points = Points is { Count: > 0 }
? new List<WhiteboardStylusPointSnapshot>(Points.ConvertAll(point => point?.Clone() ?? new WhiteboardStylusPointSnapshot()))
: [];
return clone;
}
}
public sealed class WhiteboardStylusPointSnapshot
{
public double X { get; set; }
public double Y { get; set; }
public double Pressure { get; set; } = 0.5d;
public double Width { get; set; }
public double Height { get; set; }
public WhiteboardStylusPointSnapshot Clone()
{
return (WhiteboardStylusPointSnapshot)MemberwiseClone();
}
}

View File

@@ -43,6 +43,7 @@ sealed class Program
var diagnostics = StartupDiagnosticsService.Run(args); var diagnostics = StartupDiagnosticsService.Run(args);
StartupDiagnosticsService.ShowLegacyExecutableWarningIfNeeded(diagnostics); StartupDiagnosticsService.ShowLegacyExecutableWarningIfNeeded(diagnostics);
ScheduleWhiteboardNoteStartupCleanup();
try try
{ {
@@ -88,6 +89,25 @@ sealed class Program
return builder; return builder;
} }
private static void ScheduleWhiteboardNoteStartupCleanup()
{
_ = Task.Run(() =>
{
try
{
var deletedCount = new WhiteboardNotePersistenceService().DeleteExpiredNotesBatch(batchSize: 512);
if (deletedCount > 0)
{
AppLogger.Info("Startup", $"Deleted {deletedCount} expired whiteboard notes during startup maintenance.");
}
}
catch (Exception ex)
{
AppLogger.Warn("Startup", "Failed to run whiteboard note startup maintenance.", ex);
}
});
}
private static SingleInstanceService AcquireSingleInstance(int? restartParentProcessId) private static SingleInstanceService AcquireSingleInstance(int? restartParentProcessId)
{ {
var singleInstance = SingleInstanceService.CreateDefault(); var singleInstance = SingleInstanceService.CreateDefault();

View File

@@ -29,6 +29,16 @@ public sealed class AppDatabaseService
_databasePath = Path.Combine(dataDirectory, "app.db"); _databasePath = Path.Combine(dataDirectory, "app.db");
} }
public AppDatabaseService(string databasePath)
{
if (string.IsNullOrWhiteSpace(databasePath))
{
throw new ArgumentException("Database path cannot be null or whitespace.", nameof(databasePath));
}
_databasePath = databasePath;
}
public SqliteConnection OpenConnection() public SqliteConnection OpenConnection()
{ {
var directory = Path.GetDirectoryName(_databasePath); var directory = Path.GetDirectoryName(_databasePath);

View File

@@ -106,6 +106,8 @@ public sealed class ComponentSettingsService : IComponentInstanceSettingsStore
public void DeleteForComponent(string componentId, string? placementId) public void DeleteForComponent(string componentId, string? placementId)
{ {
_ = new WhiteboardNotePersistenceService().DeleteNote(componentId, placementId);
if (_settingsService is not null) if (_settingsService is not null)
{ {
_settingsService.SaveSnapshot( _settingsService.SaveSnapshot(

View File

@@ -72,6 +72,18 @@ public static class DesktopComponentEditorRegistryFactory
[BuiltInComponentIds.DesktopStudyEnvironment] = new( [BuiltInComponentIds.DesktopStudyEnvironment] = new(
BuiltInComponentIds.DesktopStudyEnvironment, BuiltInComponentIds.DesktopStudyEnvironment,
context => new StudyEnvironmentComponentEditor(context)), context => new StudyEnvironmentComponentEditor(context)),
[BuiltInComponentIds.DesktopRemovableStorage] = new(
BuiltInComponentIds.DesktopRemovableStorage,
context => new RemovableStorageComponentEditor(context)),
[BuiltInComponentIds.DesktopWhiteboard] = new(
BuiltInComponentIds.DesktopWhiteboard,
context => new WhiteboardComponentEditor(context)),
[BuiltInComponentIds.DesktopBlackboardLandscape] = new(
BuiltInComponentIds.DesktopBlackboardLandscape,
context => new WhiteboardComponentEditor(context)),
[BuiltInComponentIds.DesktopOfficeRecentDocuments] = new(
BuiltInComponentIds.DesktopOfficeRecentDocuments,
context => new OfficeRecentDocumentsComponentEditor(context)),
[BuiltInComponentIds.DesktopWeather] = CreateWeatherRegistration(BuiltInComponentIds.DesktopWeather), [BuiltInComponentIds.DesktopWeather] = CreateWeatherRegistration(BuiltInComponentIds.DesktopWeather),
[BuiltInComponentIds.DesktopWeatherClock] = CreateWeatherRegistration(BuiltInComponentIds.DesktopWeatherClock), [BuiltInComponentIds.DesktopWeatherClock] = CreateWeatherRegistration(BuiltInComponentIds.DesktopWeatherClock),
[BuiltInComponentIds.DesktopHourlyWeather] = CreateWeatherRegistration(BuiltInComponentIds.DesktopHourlyWeather), [BuiltInComponentIds.DesktopHourlyWeather] = CreateWeatherRegistration(BuiltInComponentIds.DesktopHourlyWeather),

View File

@@ -0,0 +1,19 @@
using System;
using LanMountainDesktop.Models;
namespace LanMountainDesktop.Services;
public interface IWhiteboardNotePersistenceService
{
WhiteboardNoteSnapshot LoadNote(string componentId, string? placementId, int retentionDays);
void SaveNote(string componentId, string? placementId, WhiteboardNoteSnapshot snapshot, int retentionDays);
bool DeleteNote(string componentId, string? placementId);
bool TryDeleteExpiredNote(string componentId, string? placementId, int retentionDays);
bool IsExpired(WhiteboardNoteSnapshot snapshot, int retentionDays, DateTimeOffset? now = null);
DateTimeOffset? GetExpirationUtc(WhiteboardNoteSnapshot snapshot, int retentionDays);
}

View File

@@ -1,19 +1,25 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics; using System.Diagnostics;
using System.Globalization;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using System.Runtime.Versioning;
using System.Text; using System.Text;
using System.Text.Json; using System.Text.RegularExpressions;
using LanMountainDesktop.Services.Settings; using System.Threading;
using LanMountainDesktop.Models;
using Microsoft.Win32; using Microsoft.Win32;
using MudTools.OfficeInterop;
using MudTools.OfficeInterop.Excel;
using MudTools.OfficeInterop.Word;
namespace LanMountainDesktop.Services; namespace LanMountainDesktop.Services;
public interface IOfficeRecentDocumentsService public interface IOfficeRecentDocumentsService
{ {
List<OfficeRecentDocument> GetRecentDocuments(int maxCount = 20); List<OfficeRecentDocument> GetRecentDocuments(int maxCount = 20, IReadOnlyCollection<string>? enabledSources = null);
void OpenDocument(string filePath); void OpenDocument(string filePath);
} }
@@ -25,31 +31,67 @@ public sealed class OfficeRecentDocument
public DateTime LastModifiedTime { get; set; } public DateTime LastModifiedTime { get; set; }
public long FileSizeBytes { get; set; } public long FileSizeBytes { get; set; }
public string IconGlyph { get; set; } = string.Empty; public string IconGlyph { get; set; } = string.Empty;
internal DateTime? RecentAccessTime { get; set; }
internal int SourcePriority { get; set; }
internal int SourceOrder { get; set; }
} }
public sealed class OfficeRecentDocumentsService : IOfficeRecentDocumentsService public sealed class OfficeRecentDocumentsService : IOfficeRecentDocumentsService
{ {
private const string LogCategory = "OfficeRecentDocs";
private static readonly string[] OfficeExtensions = { ".doc", ".docx", ".dot", ".dotx", ".rtf" }; private static readonly string[] OfficeExtensions = { ".doc", ".docx", ".dot", ".dotx", ".rtf" };
private static readonly string[] ExcelExtensions = { ".xls", ".xlsx", ".xlsm", ".xlsb", ".csv" }; private static readonly string[] ExcelExtensions = { ".xls", ".xlsx", ".xlsm", ".xlsb", ".csv" };
private static readonly string[] PowerPointExtensions = { ".ppt", ".pptx", ".pptm", ".pps", ".ppsx" }; private static readonly string[] PowerPointExtensions = { ".ppt", ".pptx", ".pptm", ".pps", ".ppsx" };
private static readonly Regex OfficeFilePathRegex = new(
@"(?:[A-Z]:\\|\\\\)[^\x00-\x1F""<>|]+?\.(?:docx?|dotx?|rtf|xlsx?|xlsm|xlsb|csv|pptx?|pptm|ppsx?)",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly Regex OfficeMruTimestampRegex = new(
@"\[T(?<filetime>[0-9A-F]+)\]",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
public List<OfficeRecentDocument> GetRecentDocuments(int maxCount = 20) public List<OfficeRecentDocument> GetRecentDocuments(int maxCount = 20, IReadOnlyCollection<string>? enabledSources = null)
{ {
var documents = new List<OfficeRecentDocument>(); var documents = new List<OfficeRecentDocument>();
var normalizedSources = OfficeRecentDocumentSourceTypes.NormalizeValues(
enabledSources,
useDefaultWhenEmpty: enabledSources is null);
// 方法1: 从注册表读取Office最近文档最可靠 if (!OperatingSystem.IsWindows() || normalizedSources.Count == 0)
{
return documents;
}
var useRegistry = normalizedSources.Contains(OfficeRecentDocumentSourceTypes.Registry, StringComparer.OrdinalIgnoreCase);
var useRecentFolders = normalizedSources.Contains(OfficeRecentDocumentSourceTypes.RecentFolders, StringComparer.OrdinalIgnoreCase);
var useJumpLists = normalizedSources.Contains(OfficeRecentDocumentSourceTypes.JumpLists, StringComparer.OrdinalIgnoreCase);
if (useRegistry)
{
TryGetFromRegistry(documents); TryGetFromRegistry(documents);
}
// 方法2: 从Recent文件夹读取快捷方式备用 if (useRecentFolders)
{
TryGetFromRecentFolders(documents); TryGetFromRecentFolders(documents);
}
// 方法3: 从Windows Jump List读取(如果可用) if (useJumpLists)
TryGetFromJumpList(documents); {
TryGetFromJumpLists(documents);
}
if (useRegistry && documents.Count < maxCount)
{
TryGetFromMudToolsInterop(documents);
}
return documents return documents
.GroupBy(d => d.FilePath, StringComparer.OrdinalIgnoreCase) .GroupBy(d => d.FilePath, StringComparer.OrdinalIgnoreCase)
.Select(g => g.OrderByDescending(d => d.LastModifiedTime).First()) .Select(MergeDocuments)
.OrderByDescending(d => d.LastModifiedTime) .OrderByDescending(d => d.RecentAccessTime ?? DateTime.MinValue)
.ThenBy(d => d.SourcePriority)
.ThenBy(d => d.SourceOrder)
.ThenByDescending(d => d.LastModifiedTime)
.Take(maxCount) .Take(maxCount)
.ToList(); .ToList();
} }
@@ -63,261 +105,587 @@ public sealed class OfficeRecentDocumentsService : IOfficeRecentDocumentsService
FileName = filePath, FileName = filePath,
UseShellExecute = true UseShellExecute = true
}; };
Process.Start(startInfo); Process.Start(startInfo);
} }
catch (Exception ex)
{
AppLogger.Warn(LogCategory, $"Failed to open Office document '{filePath}'.", ex);
}
}
private static OfficeRecentDocument MergeDocuments(IGrouping<string, OfficeRecentDocument> group)
{
var preferred = group
.OrderByDescending(d => d.RecentAccessTime ?? DateTime.MinValue)
.ThenBy(d => d.SourcePriority)
.ThenBy(d => d.SourceOrder)
.ThenByDescending(d => d.LastModifiedTime)
.First();
return new OfficeRecentDocument
{
FileName = preferred.FileName,
FilePath = preferred.FilePath,
Extension = preferred.Extension,
LastModifiedTime = group.Max(d => d.LastModifiedTime),
FileSizeBytes = preferred.FileSizeBytes,
IconGlyph = preferred.IconGlyph,
RecentAccessTime = group
.Where(d => d.RecentAccessTime.HasValue)
.Select(d => d.RecentAccessTime)
.Max(),
SourcePriority = preferred.SourcePriority,
SourceOrder = preferred.SourceOrder
};
}
[SupportedOSPlatform("windows")]
private void TryGetFromMudToolsInterop(List<OfficeRecentDocument> documents)
{
try
{
RunOnStaThread(() =>
{
var sourceOrder = 0;
TryGetFromWordInterop(documents, ref sourceOrder);
TryGetFromExcelInterop(documents, ref sourceOrder);
});
}
catch (Exception ex)
{
AppLogger.Warn(LogCategory, "MudTools.OfficeInterop recent-document read failed.", ex);
}
}
[SupportedOSPlatform("windows")]
private void TryGetFromWordInterop(List<OfficeRecentDocument> documents, ref int sourceOrder)
{
if (!TryGetOfficeApplication("Word.Application", out var comObject, out var createdNew))
{
return;
}
object? application = null;
try
{
application = WordFactory.Connection(comObject!);
if (createdNew)
{
TrySetProperty(comObject, "Visible", false);
TrySetProperty(application, "DisplayAlerts", WdAlertLevel.wdAlertsNone);
}
AddInteropRecentFiles(documents, GetPropertyValue(application, "RecentFiles"), 0, ref sourceOrder);
}
catch (Exception ex)
{
AppLogger.Warn(LogCategory, "Failed to read Word recent files via MudTools.OfficeInterop.", ex);
}
finally
{
CleanupOfficeApplication(application, comObject, createdNew);
}
}
[SupportedOSPlatform("windows")]
private void TryGetFromExcelInterop(List<OfficeRecentDocument> documents, ref int sourceOrder)
{
if (!TryGetOfficeApplication("Excel.Application", out var comObject, out var createdNew))
{
return;
}
object? application = null;
try
{
application = ExcelFactory.Connection(comObject!);
if (createdNew)
{
TrySetProperty(comObject, "Visible", false);
TrySetProperty(application, "DisplayAlerts", false);
}
AddInteropRecentFiles(documents, GetPropertyValue(application, "RecentFiles"), 0, ref sourceOrder);
}
catch (Exception ex)
{
AppLogger.Warn(LogCategory, "Failed to read Excel recent files via MudTools.OfficeInterop.", ex);
}
finally
{
CleanupOfficeApplication(application, comObject, createdNew);
}
}
private void AddInteropRecentFiles(
List<OfficeRecentDocument> documents,
object? recentFiles,
int sourcePriority,
ref int sourceOrder)
{
if (recentFiles == null)
{
return;
}
var count = GetIntProperty(recentFiles, "Count");
var itemProperty = recentFiles.GetType().GetProperty("Item");
if (count <= 0 || itemProperty == null)
{
return;
}
for (var index = 1; index <= count; index++)
{
try
{
var recentFile = itemProperty.GetValue(recentFiles, new object[] { index });
var filePath = GetStringProperty(recentFile, "Path");
AddDocumentIfExists(documents, filePath, sourcePriority, sourceOrder++, null);
}
catch
{
// Ignore a single malformed MRU entry and keep processing the rest.
}
}
}
[SupportedOSPlatform("windows")]
private static bool TryGetOfficeApplication(string progId, out object? comObject, out bool createdNew)
{
comObject = null;
createdNew = false;
var applicationType = Type.GetTypeFromProgID(progId, throwOnError: false);
if (applicationType == null)
{
return false;
}
try
{
comObject = Activator.CreateInstance(applicationType);
createdNew = comObject != null;
return comObject != null;
}
catch
{
return false;
}
}
[SupportedOSPlatform("windows")]
private static void CleanupOfficeApplication(object? application, object? comObject, bool createdNew)
{
try
{
if (createdNew && application != null)
{
InvokeParameterlessMethod(application, "Quit");
}
}
catch
{
}
try
{
if (application is IDisposable disposable)
{
disposable.Dispose();
}
}
catch
{
}
ReleaseComObject(application);
if (!ReferenceEquals(application, comObject))
{
ReleaseComObject(comObject);
}
}
[SupportedOSPlatform("windows")]
private static void ReleaseComObject(object? instance)
{
if (instance == null || !Marshal.IsComObject(instance))
{
return;
}
try
{
Marshal.FinalReleaseComObject(instance);
}
catch catch
{ {
} }
} }
#pragma warning disable CA1416 // 平台兼容性警告 [SupportedOSPlatform("windows")]
private static void RunOnStaThread(Action action)
{
Exception? exception = null;
using var finished = new ManualResetEventSlim();
var thread = new Thread(() =>
{
try
{
action();
}
catch (Exception ex)
{
exception = ex;
}
finally
{
finished.Set();
}
});
thread.IsBackground = true;
thread.SetApartmentState(ApartmentState.STA);
thread.Start();
finished.Wait();
if (exception != null)
{
throw new InvalidOperationException("Failed to run Office interop on STA thread.", exception);
}
}
[SupportedOSPlatform("windows")]
private void TryGetFromRegistry(List<OfficeRecentDocument> documents) private void TryGetFromRegistry(List<OfficeRecentDocument> documents)
{ {
try try
{ {
// Word最近文档 using var officeRoot = Registry.CurrentUser.OpenSubKey(@"Software\Microsoft\Office");
TryGetFromOfficeRegistry(documents, @"Software\Microsoft\Office\Word\Reading Locations"); if (officeRoot == null)
TryGetFromOfficeRegistry(documents, @"Software\Microsoft\Office\16.0\Word\Reading Locations");
TryGetFromOfficeRegistry(documents, @"Software\Microsoft\Office\15.0\Word\Reading Locations");
// Excel最近文档
TryGetFromOfficeRegistry(documents, @"Software\Microsoft\Office\Excel\Reading Locations");
TryGetFromOfficeRegistry(documents, @"Software\Microsoft\Office\16.0\Excel\Reading Locations");
TryGetFromOfficeRegistry(documents, @"Software\Microsoft\Office\15.0\Excel\Reading Locations");
// PowerPoint最近文档
TryGetFromOfficeRegistry(documents, @"Software\Microsoft\Office\PowerPoint\Reading Locations");
TryGetFromOfficeRegistry(documents, @"Software\Microsoft\Office\16.0\PowerPoint\Reading Locations");
TryGetFromOfficeRegistry(documents, @"Software\Microsoft\Office\15.0\PowerPoint\Reading Locations");
// 通用Office最近文档
TryGetFromOfficeRegistry(documents, @"Software\Microsoft\Office\16.0\Common\Open Find\Microsoft Office Word");
TryGetFromOfficeRegistry(documents, @"Software\Microsoft\Office\16.0\Common\Open Find\Microsoft Office Excel");
TryGetFromOfficeRegistry(documents, @"Software\Microsoft\Office\16.0\Common\Open Find\Microsoft Office PowerPoint");
}
catch
{ {
// 忽略注册表访问错误 return;
}
var versions = officeRoot
.GetSubKeyNames()
.Where(IsOfficeVersionKey)
.OrderByDescending(ParseVersionKey)
.ToList();
var sourceOrder = 0;
foreach (var version in versions)
{
TryGetFromRegistryApp(documents, version, "Word", ref sourceOrder);
TryGetFromRegistryApp(documents, version, "Excel", ref sourceOrder);
TryGetFromRegistryApp(documents, version, "PowerPoint", ref sourceOrder);
}
}
catch (Exception ex)
{
AppLogger.Warn(LogCategory, "Failed to read Office MRU entries from the registry.", ex);
} }
} }
private void TryGetFromOfficeRegistry(List<OfficeRecentDocument> documents, string registryPath) [SupportedOSPlatform("windows")]
private void TryGetFromRegistryApp(List<OfficeRecentDocument> documents, string version, string appName, ref int sourceOrder)
{ {
try TryGetFromRegistryMruKey(documents, $@"Software\Microsoft\Office\{version}\{appName}\File MRU", ref sourceOrder);
using var userMruRoot = Registry.CurrentUser.OpenSubKey($@"Software\Microsoft\Office\{version}\{appName}\User MRU");
if (userMruRoot == null)
{
return;
}
foreach (var identityKey in userMruRoot.GetSubKeyNames())
{
TryGetFromRegistryMruKey(
documents,
$@"Software\Microsoft\Office\{version}\{appName}\User MRU\{identityKey}\File MRU",
ref sourceOrder);
}
}
[SupportedOSPlatform("windows")]
private void TryGetFromRegistryMruKey(List<OfficeRecentDocument> documents, string registryPath, ref int sourceOrder)
{ {
using var key = Registry.CurrentUser.OpenSubKey(registryPath); using var key = Registry.CurrentUser.OpenSubKey(registryPath);
if (key == null) return; if (key == null)
{
return;
}
foreach (var subKeyName in key.GetSubKeyNames()) var entries = key
.GetValueNames()
.Where(name => name.StartsWith("Item ", StringComparison.OrdinalIgnoreCase))
.Select(name => new
{ {
try Name = name,
{ Order = ParseMruItemOrder(name),
using var subKey = key.OpenSubKey(subKeyName); Value = key.GetValue(name) as string
if (subKey == null) continue; })
.Where(entry => !string.IsNullOrWhiteSpace(entry.Value))
.OrderBy(entry => entry.Order);
var filePath = subKey.GetValue("Path") as string; foreach (var entry in entries)
if (string.IsNullOrEmpty(filePath)) continue;
AddDocumentIfExists(documents, filePath);
}
catch
{ {
// 忽略单个子键访问错误 var (filePath, recentAccessTime) = ParseOfficeMruValue(entry.Value!);
AddDocumentIfExists(documents, filePath, 1, sourceOrder++, recentAccessTime);
} }
} }
}
catch
{
// 忽略注册表访问错误
}
}
#pragma warning restore CA1416 // 平台兼容性警告
private void TryGetFromRecentFolders(List<OfficeRecentDocument> documents) private void TryGetFromRecentFolders(List<OfficeRecentDocument> documents)
{ {
var recentPaths = GetRecentFolders();
foreach (var recentPath in recentPaths)
{
if (!Directory.Exists(recentPath))
{
continue;
}
try try
{ {
var files = Directory.GetFiles(recentPath, "*.lnk"); var linkFiles = GetRecentFolders()
foreach (var lnkPath in files) .Where(Directory.Exists)
.SelectMany(path => Directory.EnumerateFiles(path, "*.lnk"))
.Select(path => new FileInfo(path))
.OrderByDescending(info => info.LastWriteTimeUtc)
.ToList();
var sourceOrder = 0;
foreach (var linkFile in linkFiles)
{ {
var targetPath = GetShortcutTarget(lnkPath); var targetPath = GetShortcutTarget(linkFile.FullName);
if (string.IsNullOrEmpty(targetPath)) AddDocumentIfExists(documents, targetPath, 2, sourceOrder++, linkFile.LastWriteTime);
}
}
catch (Exception ex)
{ {
continue; AppLogger.Warn(LogCategory, "Failed to read Windows Recent shortcut folders.", ex);
}
} }
AddDocumentIfExists(documents, targetPath); private void TryGetFromJumpLists(List<OfficeRecentDocument> documents)
{
try
{
var jumpListFiles = GetJumpListFolders()
.Where(Directory.Exists)
.SelectMany(path => Directory.EnumerateFiles(path, "*.automaticDestinations-ms")
.Concat(Directory.EnumerateFiles(path, "*.customDestinations-ms")))
.Select(path => new FileInfo(path))
.OrderByDescending(info => info.LastWriteTimeUtc)
.ToList();
var sourceOrder = 0;
foreach (var jumpListFile in jumpListFiles)
{
TryParseJumpListFile(jumpListFile, documents, ref sourceOrder);
}
}
catch (Exception ex)
{
AppLogger.Warn(LogCategory, "Failed to read Windows Jump Lists for Office documents.", ex);
}
}
private void TryParseJumpListFile(FileInfo jumpListFile, List<OfficeRecentDocument> documents, ref int sourceOrder)
{
try
{
var bytes = File.ReadAllBytes(jumpListFile.FullName);
foreach (var filePath in ExtractPossiblePaths(bytes))
{
AddDocumentIfExists(documents, filePath, 3, sourceOrder++, jumpListFile.LastWriteTime);
} }
} }
catch catch
{ {
// 忽略文件夹访问错误 // Ignore a single Jump List file and keep scanning the rest.
}
}
private static IEnumerable<string> ExtractPossiblePaths(byte[] bytes)
{
var paths = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var text in new[]
{
Encoding.Unicode.GetString(bytes),
Encoding.Latin1.GetString(bytes)
})
{
foreach (Match match in OfficeFilePathRegex.Matches(text))
{
var normalizedPath = NormalizeFilePath(match.Value);
if (!string.IsNullOrWhiteSpace(normalizedPath))
{
paths.Add(normalizedPath);
} }
} }
} }
private void TryGetFromJumpList(List<OfficeRecentDocument> documents) return paths;
}
private void AddDocumentIfExists(
List<OfficeRecentDocument> documents,
string? filePath,
int sourcePriority,
int sourceOrder,
DateTime? recentAccessTime)
{ {
try try
{ {
// Windows Jump List存储在以下位置 var normalizedPath = NormalizeFilePath(filePath);
var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); if (string.IsNullOrWhiteSpace(normalizedPath))
var jumpListPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"Microsoft", "Windows", "Recent", "AutomaticDestinations");
if (!Directory.Exists(jumpListPath)) return;
// Office应用的Jump List文件
var officeJumpListFiles = new[]
{
"a7bd7a3f3d5a4c74.automaticDestinations-ms", // Word
"9b524fe3be704a4d.automaticDestinations-ms", // Excel
"d0063c4c7de64e5e.automaticDestinations-ms" // PowerPoint
};
foreach (var jumpFile in officeJumpListFiles)
{
var fullPath = Path.Combine(jumpListPath, jumpFile);
if (File.Exists(fullPath))
{
TryParseJumpListFile(fullPath, documents);
}
}
}
catch
{
// Jump List解析失败忽略
}
}
private void TryParseJumpListFile(string jumpListPath, List<OfficeRecentDocument> documents)
{
try
{
// Jump List文件是二进制格式这里使用简化的方法
// 读取文件并尝试提取文件路径
var bytes = File.ReadAllBytes(jumpListPath);
var text = Encoding.Unicode.GetString(bytes);
// 查找可能的文件路径(简化实现)
var possiblePaths = ExtractPossiblePaths(text);
foreach (var path in possiblePaths)
{
AddDocumentIfExists(documents, path);
}
}
catch
{
// Jump List解析失败忽略
}
}
private IEnumerable<string> ExtractPossiblePaths(string text)
{
var paths = new List<string>();
// 查找常见的文件路径模式
var patterns = new[]
{
@"[A-Z]:\\[^\x00-\x1F""<>|]*\.(docx?|xlsx?|pptx?|rtf|csv)",
@"\\\\[^\\]+\\[^\x00-\x1F""<>|]*\.(docx?|xlsx?|pptx?|rtf|csv)"
};
foreach (var pattern in patterns)
{
try
{
var matches = System.Text.RegularExpressions.Regex.Matches(text, pattern,
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
foreach (System.Text.RegularExpressions.Match match in matches)
{
var path = match.Value.Trim('\0', ' ', '"');
if (!string.IsNullOrEmpty(path))
{
paths.Add(path);
}
}
}
catch
{
// 忽略正则表达式错误
}
}
return paths.Distinct(StringComparer.OrdinalIgnoreCase);
}
private void AddDocumentIfExists(List<OfficeRecentDocument> documents, string filePath)
{
try
{
var extension = Path.GetExtension(filePath).ToLowerInvariant();
if (!IsOfficeFile(extension))
{ {
return; return;
} }
if (!File.Exists(filePath)) var extension = Path.GetExtension(normalizedPath).ToLowerInvariant();
if (!IsOfficeFile(extension) || !File.Exists(normalizedPath))
{ {
return; return;
} }
var fileInfo = new FileInfo(filePath); var fileInfo = new FileInfo(normalizedPath);
var doc = new OfficeRecentDocument documents.Add(new OfficeRecentDocument
{ {
FileName = Path.GetFileNameWithoutExtension(filePath), FileName = Path.GetFileNameWithoutExtension(normalizedPath),
FilePath = filePath, FilePath = normalizedPath,
Extension = extension, Extension = extension,
LastModifiedTime = fileInfo.LastWriteTime, LastModifiedTime = fileInfo.LastWriteTime,
FileSizeBytes = fileInfo.Length, FileSizeBytes = fileInfo.Length,
IconGlyph = GetIconGlyph(extension) IconGlyph = GetIconGlyph(extension),
}; RecentAccessTime = recentAccessTime,
SourcePriority = sourcePriority,
if (!documents.Any(d => string.Equals(d.FilePath, filePath, StringComparison.OrdinalIgnoreCase))) SourceOrder = sourceOrder
{ });
documents.Add(doc);
}
} }
catch catch
{ {
// 忽略单个文件处理错误 // Ignore a single file and keep processing the rest of the MRU list.
} }
} }
private static List<string> GetRecentFolders() private static IEnumerable<string> GetRecentFolders()
{ {
var folders = new List<string>();
var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
folders.Add(Path.Combine(appData, "Microsoft", "Word", "Recent"));
folders.Add(Path.Combine(appData, "Microsoft", "Excel", "Recent"));
folders.Add(Path.Combine(appData, "Microsoft", "PowerPoint", "Recent"));
// 添加Office 365路径
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
folders.Add(Path.Combine(localAppData, "Microsoft", "Office", "Word", "Recent"));
folders.Add(Path.Combine(localAppData, "Microsoft", "Office", "Excel", "Recent"));
folders.Add(Path.Combine(localAppData, "Microsoft", "Office", "PowerPoint", "Recent"));
return folders; return new[]
{
Path.Combine(appData, "Microsoft", "Windows", "Recent"),
Path.Combine(appData, "Microsoft", "Word", "Recent"),
Path.Combine(appData, "Microsoft", "Excel", "Recent"),
Path.Combine(appData, "Microsoft", "PowerPoint", "Recent"),
Path.Combine(localAppData, "Microsoft", "Office", "Word", "Recent"),
Path.Combine(localAppData, "Microsoft", "Office", "Excel", "Recent"),
Path.Combine(localAppData, "Microsoft", "Office", "PowerPoint", "Recent")
}.Distinct(StringComparer.OrdinalIgnoreCase);
}
private static IEnumerable<string> GetJumpListFolders()
{
var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
return new[]
{
Path.Combine(appData, "Microsoft", "Windows", "Recent", "AutomaticDestinations"),
Path.Combine(appData, "Microsoft", "Windows", "Recent", "CustomDestinations"),
Path.Combine(localAppData, "Microsoft", "Windows", "Recent", "AutomaticDestinations"),
Path.Combine(localAppData, "Microsoft", "Windows", "Recent", "CustomDestinations")
}.Distinct(StringComparer.OrdinalIgnoreCase);
}
private static bool IsOfficeVersionKey(string keyName)
{
return Version.TryParse(keyName, out _);
}
private static Version ParseVersionKey(string keyName)
{
return Version.TryParse(keyName, out var version) ? version : new Version(0, 0);
}
private static int ParseMruItemOrder(string valueName)
{
var numberText = valueName["Item ".Length..];
return int.TryParse(numberText, NumberStyles.Integer, CultureInfo.InvariantCulture, out var number)
? number
: int.MaxValue;
}
private static (string? FilePath, DateTime? RecentAccessTime) ParseOfficeMruValue(string rawValue)
{
var filePath = ExtractOfficeFilePath(rawValue);
DateTime? recentAccessTime = null;
var timestampMatch = OfficeMruTimestampRegex.Match(rawValue);
if (timestampMatch.Success &&
long.TryParse(timestampMatch.Groups["filetime"].Value, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var fileTime) &&
fileTime > 0)
{
try
{
recentAccessTime = DateTime.FromFileTimeUtc(fileTime).ToLocalTime();
}
catch
{
recentAccessTime = null;
}
}
return (filePath, recentAccessTime);
}
private static string? ExtractOfficeFilePath(string rawValue)
{
if (string.IsNullOrWhiteSpace(rawValue))
{
return null;
}
var markerIndex = rawValue.LastIndexOf('*');
var candidate = markerIndex >= 0
? rawValue[(markerIndex + 1)..]
: rawValue;
var normalizedCandidate = NormalizeFilePath(candidate);
if (!string.IsNullOrWhiteSpace(normalizedCandidate) && IsOfficeFile(Path.GetExtension(normalizedCandidate)))
{
return normalizedCandidate;
}
var match = OfficeFilePathRegex.Match(rawValue);
return match.Success ? NormalizeFilePath(match.Value) : null;
}
private static string? NormalizeFilePath(string? rawPath)
{
if (string.IsNullOrWhiteSpace(rawPath))
{
return null;
}
var candidate = rawPath.Trim('\0', ' ', '"');
candidate = Environment.ExpandEnvironmentVariables(candidate);
if (Uri.TryCreate(candidate, UriKind.Absolute, out var uri) && uri.IsFile)
{
candidate = uri.LocalPath;
}
candidate = candidate.Replace('/', '\\');
return string.IsNullOrWhiteSpace(candidate) ? null : candidate;
} }
private static bool IsOfficeFile(string extension) private static bool IsOfficeFile(string extension)
{ {
return OfficeExtensions.Contains(extension) || return OfficeExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase) ||
ExcelExtensions.Contains(extension) || ExcelExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase) ||
PowerPointExtensions.Contains(extension); PowerPointExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase);
} }
private static string GetIconGlyph(string extension) private static string GetIconGlyph(string extension)
@@ -335,4 +703,40 @@ public sealed class OfficeRecentDocumentsService : IOfficeRecentDocumentsService
{ {
return ShortcutHelper.GetShortcutTarget(lnkPath); return ShortcutHelper.GetShortcutTarget(lnkPath);
} }
private static object? GetPropertyValue(object? instance, string propertyName)
{
return instance?.GetType().GetProperty(propertyName)?.GetValue(instance);
}
private static string? GetStringProperty(object? instance, string propertyName)
{
return GetPropertyValue(instance, propertyName) as string;
}
private static int GetIntProperty(object instance, string propertyName)
{
var value = GetPropertyValue(instance, propertyName);
return value switch
{
int intValue => intValue,
short shortValue => shortValue,
long longValue => (int)longValue,
_ => 0
};
}
private static void TrySetProperty(object? instance, string propertyName, object value)
{
var property = instance?.GetType().GetProperty(propertyName);
if (property?.CanWrite == true)
{
property.SetValue(instance, value);
}
}
private static void InvokeParameterlessMethod(object instance, string methodName)
{
instance.GetType().GetMethod(methodName, Type.EmptyTypes)?.Invoke(instance, null);
}
} }

View File

@@ -0,0 +1,310 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Runtime.InteropServices;
namespace LanMountainDesktop.Services;
public sealed record RemovableStorageDrive(
string RootPath,
string DriveLetter,
string? VolumeLabel);
public interface IRemovableStorageService
{
IReadOnlyList<RemovableStorageDrive> GetConnectedDrives();
bool OpenDrive(string rootPath);
bool EjectDrive(string rootPath);
}
public sealed class RemovableStorageService : IRemovableStorageService
{
public IReadOnlyList<RemovableStorageDrive> GetConnectedDrives()
{
var drives = new List<RemovableStorageDrive>();
foreach (var drive in DriveInfo.GetDrives())
{
try
{
if (drive.DriveType != DriveType.Removable || !drive.IsReady)
{
continue;
}
var rootPath = NormalizeRootPath(drive.Name);
if (string.IsNullOrWhiteSpace(rootPath))
{
continue;
}
var driveLetter = rootPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
var volumeLabel = string.IsNullOrWhiteSpace(drive.VolumeLabel)
? null
: drive.VolumeLabel.Trim();
drives.Add(new RemovableStorageDrive(rootPath, driveLetter, volumeLabel));
}
catch (Exception ex)
{
AppLogger.Warn("RemovableStorage", $"Failed to inspect drive '{drive.Name}'.", ex);
}
}
return drives
.OrderBy(drive => drive.DriveLetter, StringComparer.OrdinalIgnoreCase)
.ToArray();
}
public bool OpenDrive(string rootPath)
{
var normalizedRootPath = NormalizeRootPath(rootPath);
if (string.IsNullOrWhiteSpace(normalizedRootPath))
{
return false;
}
try
{
Process.Start(new ProcessStartInfo
{
FileName = normalizedRootPath,
UseShellExecute = true
});
return true;
}
catch (Exception ex)
{
AppLogger.Warn("RemovableStorage", $"Failed to open drive '{normalizedRootPath}'.", ex);
return false;
}
}
public bool EjectDrive(string rootPath)
{
if (!OperatingSystem.IsWindows())
{
return false;
}
var normalizedRootPath = NormalizeRootPath(rootPath);
if (string.IsNullOrWhiteSpace(normalizedRootPath))
{
return false;
}
object? shellApplication = null;
object? computerFolder = null;
object? driveItem = null;
try
{
var shellType = Type.GetTypeFromProgID("Shell.Application");
if (shellType is null)
{
return false;
}
shellApplication = Activator.CreateInstance(shellType);
if (shellApplication is null)
{
return false;
}
computerFolder = shellType.InvokeMember(
"NameSpace",
BindingFlags.InvokeMethod,
binder: null,
target: shellApplication,
args: [17]);
if (computerFolder is null)
{
return false;
}
var driveToken = normalizedRootPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
driveItem = computerFolder.GetType().InvokeMember(
"ParseName",
BindingFlags.InvokeMethod,
binder: null,
target: computerFolder,
args: [driveToken]);
if (driveItem is null)
{
return false;
}
if (TryInvokeVerb(driveItem, "Eject"))
{
return true;
}
return TryInvokeLocalizedEjectVerb(driveItem);
}
catch (Exception ex)
{
AppLogger.Warn("RemovableStorage", $"Failed to eject drive '{normalizedRootPath}'.", ex);
return false;
}
finally
{
ReleaseComObject(driveItem);
ReleaseComObject(computerFolder);
ReleaseComObject(shellApplication);
}
}
private static bool TryInvokeLocalizedEjectVerb(object driveItem)
{
object? verbs = null;
try
{
verbs = driveItem.GetType().InvokeMember(
"Verbs",
BindingFlags.InvokeMethod,
binder: null,
target: driveItem,
args: null);
if (verbs is null)
{
return false;
}
var verbsType = verbs.GetType();
var countObject = verbsType.InvokeMember(
"Count",
BindingFlags.GetProperty,
binder: null,
target: verbs,
args: null);
var count = countObject is null
? 0
: Convert.ToInt32(countObject, CultureInfo.InvariantCulture);
for (var index = 0; index < count; index++)
{
object? verb = null;
try
{
verb = verbsType.InvokeMember(
"Item",
BindingFlags.InvokeMethod,
binder: null,
target: verbs,
args: [index]);
if (verb is null)
{
continue;
}
var verbNameObject = verb.GetType().InvokeMember(
"Name",
BindingFlags.GetProperty,
binder: null,
target: verb,
args: null);
var verbName = Convert.ToString(verbNameObject, CultureInfo.InvariantCulture);
if (!IsEjectVerbName(verbName))
{
continue;
}
verb.GetType().InvokeMember(
"DoIt",
BindingFlags.InvokeMethod,
binder: null,
target: verb,
args: null);
return true;
}
finally
{
ReleaseComObject(verb);
}
}
return false;
}
finally
{
ReleaseComObject(verbs);
}
}
private static bool TryInvokeVerb(object driveItem, string verbName)
{
try
{
driveItem.GetType().InvokeMember(
"InvokeVerb",
BindingFlags.InvokeMethod,
binder: null,
target: driveItem,
args: [verbName]);
return true;
}
catch
{
return false;
}
}
private static bool IsEjectVerbName(string? verbName)
{
if (string.IsNullOrWhiteSpace(verbName))
{
return false;
}
var normalized = string.Concat(
verbName
.Where(character => !char.IsWhiteSpace(character) && character != '&'))
.Trim();
return normalized.Contains("Eject", StringComparison.OrdinalIgnoreCase) ||
normalized.Contains("弹出", StringComparison.Ordinal) ||
normalized.Contains("安全删除", StringComparison.Ordinal) ||
normalized.Contains("卸载", StringComparison.Ordinal);
}
private static string NormalizeRootPath(string? rootPath)
{
if (string.IsNullOrWhiteSpace(rootPath))
{
return string.Empty;
}
var trimmed = rootPath.Trim();
if (trimmed.Length == 1 && char.IsLetter(trimmed[0]))
{
return string.Create(CultureInfo.InvariantCulture, $"{trimmed}:{Path.DirectorySeparatorChar}");
}
if (trimmed.Length == 2 && char.IsLetter(trimmed[0]) && trimmed[1] == ':')
{
return trimmed + Path.DirectorySeparatorChar;
}
var normalized = trimmed.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar);
var resolvedRoot = Path.GetPathRoot(normalized);
return string.IsNullOrWhiteSpace(resolvedRoot)
? normalized
: resolvedRoot;
}
private static void ReleaseComObject(object? value)
{
if (value is not null && Marshal.IsComObject(value))
{
Marshal.FinalReleaseComObject(value);
}
}
}

View File

@@ -29,6 +29,7 @@ public sealed record StatusBarSettingsState(
bool EnableDynamicTaskbarActions, bool EnableDynamicTaskbarActions,
string TaskbarLayoutMode, string TaskbarLayoutMode,
string ClockDisplayFormat, string ClockDisplayFormat,
bool ClockTransparentBackground,
string SpacingMode, string SpacingMode,
int CustomSpacingPercent); int CustomSpacingPercent);
public sealed record WeatherSettingsState( public sealed record WeatherSettingsState(

View File

@@ -361,6 +361,7 @@ internal sealed class StatusBarSettingsService : IStatusBarSettingsService
snapshot.EnableDynamicTaskbarActions, snapshot.EnableDynamicTaskbarActions,
snapshot.TaskbarLayoutMode, snapshot.TaskbarLayoutMode,
snapshot.ClockDisplayFormat, snapshot.ClockDisplayFormat,
snapshot.StatusBarClockTransparentBackground,
snapshot.StatusBarSpacingMode, snapshot.StatusBarSpacingMode,
snapshot.StatusBarCustomSpacingPercent); snapshot.StatusBarCustomSpacingPercent);
} }
@@ -373,6 +374,7 @@ internal sealed class StatusBarSettingsService : IStatusBarSettingsService
snapshot.EnableDynamicTaskbarActions = state.EnableDynamicTaskbarActions; snapshot.EnableDynamicTaskbarActions = state.EnableDynamicTaskbarActions;
snapshot.TaskbarLayoutMode = state.TaskbarLayoutMode; snapshot.TaskbarLayoutMode = state.TaskbarLayoutMode;
snapshot.ClockDisplayFormat = state.ClockDisplayFormat; snapshot.ClockDisplayFormat = state.ClockDisplayFormat;
snapshot.StatusBarClockTransparentBackground = state.ClockTransparentBackground;
snapshot.StatusBarSpacingMode = state.SpacingMode; snapshot.StatusBarSpacingMode = state.SpacingMode;
snapshot.StatusBarCustomSpacingPercent = state.CustomSpacingPercent; snapshot.StatusBarCustomSpacingPercent = state.CustomSpacingPercent;
_settingsService.SaveSnapshot( _settingsService.SaveSnapshot(
@@ -385,6 +387,7 @@ internal sealed class StatusBarSettingsService : IStatusBarSettingsService
nameof(AppSettingsSnapshot.EnableDynamicTaskbarActions), nameof(AppSettingsSnapshot.EnableDynamicTaskbarActions),
nameof(AppSettingsSnapshot.TaskbarLayoutMode), nameof(AppSettingsSnapshot.TaskbarLayoutMode),
nameof(AppSettingsSnapshot.ClockDisplayFormat), nameof(AppSettingsSnapshot.ClockDisplayFormat),
nameof(AppSettingsSnapshot.StatusBarClockTransparentBackground),
nameof(AppSettingsSnapshot.StatusBarSpacingMode), nameof(AppSettingsSnapshot.StatusBarSpacingMode),
nameof(AppSettingsSnapshot.StatusBarCustomSpacingPercent) nameof(AppSettingsSnapshot.StatusBarCustomSpacingPercent)
]); ]);

View File

@@ -0,0 +1,338 @@
using System;
using System.Text.Json;
using LanMountainDesktop.Models;
using Microsoft.Data.Sqlite;
namespace LanMountainDesktop.Services;
public sealed class WhiteboardNotePersistenceService : IWhiteboardNotePersistenceService
{
private const int DefaultCleanupBatchSize = 256;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true
};
private readonly object _schemaSyncRoot = new();
private readonly AppDatabaseService _databaseService;
private bool _schemaInitialized;
public WhiteboardNotePersistenceService(AppDatabaseService? databaseService = null)
{
_databaseService = databaseService ?? AppDatabaseServiceFactory.CreateDefault();
}
public WhiteboardNoteSnapshot LoadNote(string componentId, string? placementId, int retentionDays)
{
if (!TryNormalizeKeys(componentId, placementId, out var normalizedComponentId, out var normalizedPlacementId))
{
return new WhiteboardNoteSnapshot();
}
try
{
using var connection = OpenConnection();
DeleteExpiredInternal(
connection,
normalizedComponentId,
normalizedPlacementId,
WhiteboardNoteRetentionPolicy.NormalizeDays(retentionDays),
DateTimeOffset.UtcNow);
using var command = connection.CreateCommand();
command.CommandText = """
SELECT note_json, saved_at_utc_ms
FROM whiteboard_notes
WHERE component_id = $componentId
AND placement_id = $placementId
LIMIT 1;
""";
command.Parameters.AddWithValue("$componentId", normalizedComponentId);
command.Parameters.AddWithValue("$placementId", normalizedPlacementId);
using var reader = command.ExecuteReader();
if (!reader.Read() || reader.IsDBNull(0))
{
return new WhiteboardNoteSnapshot();
}
var json = reader.GetString(0);
if (string.IsNullOrWhiteSpace(json))
{
return new WhiteboardNoteSnapshot();
}
var snapshot = JsonSerializer.Deserialize<WhiteboardNoteSnapshot>(json, JsonOptions) ?? new WhiteboardNoteSnapshot();
if (!reader.IsDBNull(1))
{
snapshot.SavedUtc = DateTimeOffset.FromUnixTimeMilliseconds(reader.GetInt64(1));
}
if (IsExpired(snapshot, retentionDays))
{
DeleteNote(normalizedComponentId, normalizedPlacementId);
return new WhiteboardNoteSnapshot();
}
return snapshot.Clone();
}
catch
{
return new WhiteboardNoteSnapshot();
}
}
public void SaveNote(string componentId, string? placementId, WhiteboardNoteSnapshot snapshot, int retentionDays)
{
if (!TryNormalizeKeys(componentId, placementId, out var normalizedComponentId, out var normalizedPlacementId))
{
return;
}
try
{
var nowUtc = DateTimeOffset.UtcNow;
var persistedSnapshot = snapshot?.Clone() ?? new WhiteboardNoteSnapshot();
persistedSnapshot.SavedUtc = nowUtc;
var expiresUtc = GetExpirationUtc(persistedSnapshot, retentionDays) ?? nowUtc.AddDays(WhiteboardNoteRetentionPolicy.DefaultDays);
var json = JsonSerializer.Serialize(persistedSnapshot, JsonOptions);
using var connection = OpenConnection();
using var command = connection.CreateCommand();
command.CommandText = """
INSERT INTO whiteboard_notes(
component_id,
placement_id,
note_json,
saved_at_utc_ms,
expires_at_utc_ms,
updated_at_utc_ms)
VALUES(
$componentId,
$placementId,
$noteJson,
$savedAtUtcMs,
$expiresAtUtcMs,
$updatedAtUtcMs)
ON CONFLICT(component_id, placement_id) DO UPDATE SET
note_json = excluded.note_json,
saved_at_utc_ms = excluded.saved_at_utc_ms,
expires_at_utc_ms = excluded.expires_at_utc_ms,
updated_at_utc_ms = excluded.updated_at_utc_ms;
""";
command.Parameters.AddWithValue("$componentId", normalizedComponentId);
command.Parameters.AddWithValue("$placementId", normalizedPlacementId);
command.Parameters.AddWithValue("$noteJson", json);
command.Parameters.AddWithValue("$savedAtUtcMs", persistedSnapshot.SavedUtc.ToUnixTimeMilliseconds());
command.Parameters.AddWithValue("$expiresAtUtcMs", expiresUtc.ToUnixTimeMilliseconds());
command.Parameters.AddWithValue("$updatedAtUtcMs", nowUtc.ToUnixTimeMilliseconds());
command.ExecuteNonQuery();
}
catch
{
// Keep whiteboard usable even when persistence is unavailable.
}
}
public bool DeleteNote(string componentId, string? placementId)
{
if (!TryNormalizeKeys(componentId, placementId, out var normalizedComponentId, out var normalizedPlacementId))
{
return false;
}
try
{
using var connection = OpenConnection();
using var command = connection.CreateCommand();
command.CommandText = """
DELETE FROM whiteboard_notes
WHERE component_id = $componentId
AND placement_id = $placementId;
""";
command.Parameters.AddWithValue("$componentId", normalizedComponentId);
command.Parameters.AddWithValue("$placementId", normalizedPlacementId);
return command.ExecuteNonQuery() > 0;
}
catch
{
return false;
}
}
public bool TryDeleteExpiredNote(string componentId, string? placementId, int retentionDays)
{
if (!TryNormalizeKeys(componentId, placementId, out var normalizedComponentId, out var normalizedPlacementId))
{
return false;
}
try
{
using var connection = OpenConnection();
return DeleteExpiredInternal(
connection,
normalizedComponentId,
normalizedPlacementId,
WhiteboardNoteRetentionPolicy.NormalizeDays(retentionDays),
DateTimeOffset.UtcNow);
}
catch
{
return false;
}
}
public int DeleteExpiredNotesBatch(int batchSize = DefaultCleanupBatchSize, DateTimeOffset? now = null)
{
try
{
using var connection = OpenConnection();
using var command = connection.CreateCommand();
command.CommandText = """
DELETE FROM whiteboard_notes
WHERE rowid IN (
SELECT rowid
FROM whiteboard_notes
WHERE expires_at_utc_ms <= $nowUtcMs
ORDER BY expires_at_utc_ms ASC
LIMIT $batchSize
);
""";
command.Parameters.AddWithValue("$nowUtcMs", (now ?? DateTimeOffset.UtcNow).ToUnixTimeMilliseconds());
command.Parameters.AddWithValue("$batchSize", NormalizeBatchSize(batchSize));
return command.ExecuteNonQuery();
}
catch
{
return 0;
}
}
public bool IsExpired(WhiteboardNoteSnapshot snapshot, int retentionDays, DateTimeOffset? now = null)
{
if (snapshot is null)
{
return false;
}
var expirationUtc = GetExpirationUtc(snapshot, retentionDays);
if (!expirationUtc.HasValue)
{
return false;
}
return expirationUtc.Value <= (now ?? DateTimeOffset.UtcNow);
}
public DateTimeOffset? GetExpirationUtc(WhiteboardNoteSnapshot snapshot, int retentionDays)
{
if (snapshot is null || snapshot.SavedUtc == default)
{
return null;
}
return snapshot.SavedUtc.AddDays(WhiteboardNoteRetentionPolicy.NormalizeDays(retentionDays));
}
private SqliteConnection OpenConnection()
{
var connection = _databaseService.OpenConnection();
EnsureSchema(connection);
return connection;
}
private void EnsureSchema(SqliteConnection connection)
{
if (_schemaInitialized)
{
return;
}
lock (_schemaSyncRoot)
{
if (_schemaInitialized)
{
return;
}
using var command = connection.CreateCommand();
command.CommandText = """
CREATE TABLE IF NOT EXISTS whiteboard_notes (
component_id TEXT NOT NULL,
placement_id TEXT NOT NULL,
note_json TEXT NOT NULL,
saved_at_utc_ms INTEGER NOT NULL,
expires_at_utc_ms INTEGER NOT NULL,
updated_at_utc_ms INTEGER NOT NULL,
PRIMARY KEY (component_id, placement_id)
);
CREATE INDEX IF NOT EXISTS idx_whiteboard_notes_expires_at
ON whiteboard_notes(expires_at_utc_ms);
""";
command.ExecuteNonQuery();
_schemaInitialized = true;
}
}
private static bool DeleteExpiredInternal(
SqliteConnection connection,
string componentId,
string placementId,
int retentionDays,
DateTimeOffset nowUtc)
{
using var selectCommand = connection.CreateCommand();
selectCommand.CommandText = """
SELECT saved_at_utc_ms
FROM whiteboard_notes
WHERE component_id = $componentId
AND placement_id = $placementId
LIMIT 1;
""";
selectCommand.Parameters.AddWithValue("$componentId", componentId);
selectCommand.Parameters.AddWithValue("$placementId", placementId);
var scalar = selectCommand.ExecuteScalar();
if (scalar is not long savedAtUtcMs)
{
return false;
}
var savedUtc = DateTimeOffset.FromUnixTimeMilliseconds(savedAtUtcMs);
var expiresUtc = savedUtc.AddDays(WhiteboardNoteRetentionPolicy.NormalizeDays(retentionDays));
if (expiresUtc > nowUtc)
{
return false;
}
using var deleteCommand = connection.CreateCommand();
deleteCommand.CommandText = """
DELETE FROM whiteboard_notes
WHERE component_id = $componentId
AND placement_id = $placementId;
""";
deleteCommand.Parameters.AddWithValue("$componentId", componentId);
deleteCommand.Parameters.AddWithValue("$placementId", placementId);
return deleteCommand.ExecuteNonQuery() > 0;
}
private static bool TryNormalizeKeys(
string componentId,
string? placementId,
out string normalizedComponentId,
out string normalizedPlacementId)
{
normalizedComponentId = componentId?.Trim() ?? string.Empty;
normalizedPlacementId = placementId?.Trim() ?? string.Empty;
return !string.IsNullOrWhiteSpace(normalizedComponentId);
}
private static int NormalizeBatchSize(int batchSize)
{
return batchSize <= 0
? DefaultCleanupBatchSize
: Math.Clamp(batchSize, 1, 4096);
}
}

View File

@@ -1,5 +1,6 @@
<Styles xmlns="https://github.com/avaloniaui" <Styles xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"> xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:assists="clr-namespace:Material.Styles.Assists;assembly=Material.Styles">
<Style Selector="Window.component-editor-window"> <Style Selector="Window.component-editor-window">
<Setter Property="Background" Value="{DynamicResource EditorWindowBackgroundBrush}" /> <Setter Property="Background" Value="{DynamicResource EditorWindowBackgroundBrush}" />
</Style> </Style>
@@ -74,7 +75,21 @@
</Style> </Style>
<Style Selector="Window.component-editor-window RadioButton"> <Style Selector="Window.component-editor-window RadioButton">
<Setter Property="Theme" Value="{StaticResource MaterialRadioButton}" />
<Setter Property="Foreground" Value="{DynamicResource ComponentEditorPrimaryTextBrush}" /> <Setter Property="Foreground" Value="{DynamicResource ComponentEditorPrimaryTextBrush}" />
<Setter Property="Margin" Value="0,2" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="assists:SelectionControlAssist.Size" Value="20" />
<Setter Property="assists:SelectionControlAssist.Foreground" Value="{DynamicResource ComponentEditorSecondaryTextBrush}" />
<Setter Property="assists:SelectionControlAssist.InnerForeground" Value="{DynamicResource EditorPrimaryBrush}" />
</Style>
<Style Selector="Window.component-editor-window RadioButton:pointerover">
<Setter Property="assists:SelectionControlAssist.Foreground" Value="{DynamicResource EditorSelectOutlineStrongBrush}" />
</Style>
<Style Selector="Window.component-editor-window RadioButton:checked">
<Setter Property="assists:SelectionControlAssist.Foreground" Value="{DynamicResource EditorPrimaryBrush}" />
</Style> </Style>
<Style Selector="Window.component-editor-window ToggleSwitch"> <Style Selector="Window.component-editor-window ToggleSwitch">

View File

@@ -165,7 +165,13 @@
<Setter Property="RenderTransform" Value="scale(1.05)" /> <Setter Property="RenderTransform" Value="scale(1.05)" />
</Style> </Style>
<Style Selector="Border.glass-panel"> <!--
半透明表面样式类
注意:这些样式使用纯色半透明画刷模拟玻璃效果,并非真正的 Mica/Acrylic 模糊材质。
真正的 Mica/Acrylic 效果仅通过 WindowTransparencyLevel 在独立窗口上应用。
-->
<Style Selector="Border.surface-translucent-panel">
<Setter Property="Background" Value="{DynamicResource AdaptiveGlassPanelBackgroundBrush}" /> <Setter Property="Background" Value="{DynamicResource AdaptiveGlassPanelBackgroundBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource AdaptiveGlassPanelBorderBrush}" /> <Setter Property="BorderBrush" Value="{DynamicResource AdaptiveGlassPanelBorderBrush}" />
<Setter Property="BorderThickness" Value="1.2" /> <Setter Property="BorderThickness" Value="1.2" />
@@ -174,7 +180,7 @@
<Setter Property="BoxShadow" Value="0 4 12 #1A000000" /> <Setter Property="BoxShadow" Value="0 4 12 #1A000000" />
</Style> </Style>
<Style Selector="Border.glass-strong"> <Style Selector="Border.surface-translucent-strong">
<Setter Property="Background" Value="{DynamicResource AdaptiveGlassStrongBackgroundBrush}" /> <Setter Property="Background" Value="{DynamicResource AdaptiveGlassStrongBackgroundBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource AdaptiveGlassStrongBorderBrush}" /> <Setter Property="BorderBrush" Value="{DynamicResource AdaptiveGlassStrongBorderBrush}" />
<Setter Property="BorderThickness" Value="1.5" /> <Setter Property="BorderThickness" Value="1.5" />
@@ -183,7 +189,7 @@
<Setter Property="BoxShadow" Value="0 8 24 #26000000" /> <Setter Property="BoxShadow" Value="0 8 24 #26000000" />
</Style> </Style>
<Style Selector="Border.glass-island"> <Style Selector="Border.surface-translucent-island">
<Setter Property="Background" Value="{DynamicResource AdaptiveDockGlassBackgroundBrush}" /> <Setter Property="Background" Value="{DynamicResource AdaptiveDockGlassBackgroundBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource AdaptiveDockGlassBorderBrush}" /> <Setter Property="BorderBrush" Value="{DynamicResource AdaptiveDockGlassBorderBrush}" />
<Setter Property="BorderThickness" Value="1.5" /> <Setter Property="BorderThickness" Value="1.5" />
@@ -197,7 +203,7 @@
</Setter> </Setter>
</Style> </Style>
<Style Selector="Border.mica-strong"> <Style Selector="Border.surface-solid-strong">
<Setter Property="Background" Value="{DynamicResource AdaptiveGlassStrongBackgroundBrush}" /> <Setter Property="Background" Value="{DynamicResource AdaptiveGlassStrongBackgroundBrush}" />
<Setter Property="BorderThickness" Value="0" /> <Setter Property="BorderThickness" Value="0" />
<Setter Property="CornerRadius" Value="36" /> <Setter Property="CornerRadius" Value="36" />
@@ -205,11 +211,18 @@
<Setter Property="BoxShadow" Value="0 8 22 #2A000000" /> <Setter Property="BoxShadow" Value="0 8 22 #2A000000" />
</Style> </Style>
<Style Selector="Border.glass-overlay"> <Style Selector="Border.surface-translucent-overlay">
<Setter Property="Background" Value="{DynamicResource AdaptiveGlassOverlayBackgroundBrush}" /> <Setter Property="Background" Value="{DynamicResource AdaptiveGlassOverlayBackgroundBrush}" />
<Setter Property="BorderThickness" Value="0" /> <Setter Property="BorderThickness" Value="0" />
<Setter Property="CornerRadius" Value="0" /> <Setter Property="CornerRadius" Value="0" />
<Setter Property="Opacity" Value="{DynamicResource AdaptiveGlassOverlayOpacity}" /> <Setter Property="Opacity" Value="{DynamicResource AdaptiveGlassOverlayOpacity}" />
</Style> </Style>
<!-- 向后兼容的旧样式类(已弃用) -->
<Style Selector="Border.glass-panel" />
<Style Selector="Border.glass-strong" />
<Style Selector="Border.glass-island" />
<Style Selector="Border.mica-strong" />
<Style Selector="Border.glass-overlay" />
</Styles> </Styles>

View File

@@ -39,6 +39,9 @@ public sealed partial class StatusBarSettingsPageViewModel : ViewModelBase
[ObservableProperty] [ObservableProperty]
private SelectionOption _selectedClockFormat = new("HourMinuteSecond", "Hour:Minute:Second"); private SelectionOption _selectedClockFormat = new("HourMinuteSecond", "Hour:Minute:Second");
[ObservableProperty]
private bool _clockTransparentBackground;
[ObservableProperty] [ObservableProperty]
private SelectionOption _selectedSpacingMode = new("Relaxed", "Relaxed"); private SelectionOption _selectedSpacingMode = new("Relaxed", "Relaxed");
@@ -66,6 +69,12 @@ public sealed partial class StatusBarSettingsPageViewModel : ViewModelBase
[ObservableProperty] [ObservableProperty]
private string _clockFormatLabel = string.Empty; private string _clockFormatLabel = string.Empty;
[ObservableProperty]
private string _clockTransparentBackgroundLabel = string.Empty;
[ObservableProperty]
private string _clockTransparentBackgroundDescription = string.Empty;
[ObservableProperty] [ObservableProperty]
private string _spacingHeader = string.Empty; private string _spacingHeader = string.Empty;
@@ -88,6 +97,7 @@ public sealed partial class StatusBarSettingsPageViewModel : ViewModelBase
SelectedClockFormat = ClockFormats.FirstOrDefault(option => SelectedClockFormat = ClockFormats.FirstOrDefault(option =>
string.Equals(option.Value, clockFormat, StringComparison.OrdinalIgnoreCase)) string.Equals(option.Value, clockFormat, StringComparison.OrdinalIgnoreCase))
?? ClockFormats[1]; ?? ClockFormats[1];
ClockTransparentBackground = state.ClockTransparentBackground;
var spacingMode = NormalizeSpacingMode(state.SpacingMode); var spacingMode = NormalizeSpacingMode(state.SpacingMode);
SelectedSpacingMode = SpacingModes.FirstOrDefault(option => SelectedSpacingMode = SpacingModes.FirstOrDefault(option =>
@@ -117,6 +127,16 @@ public sealed partial class StatusBarSettingsPageViewModel : ViewModelBase
Save(); Save();
} }
partial void OnClockTransparentBackgroundChanged(bool value)
{
if (_isInitializing)
{
return;
}
Save();
}
partial void OnSelectedSpacingModeChanged(SelectionOption value) partial void OnSelectedSpacingModeChanged(SelectionOption value)
{ {
IsCustomSpacingVisible = string.Equals(value?.Value, "Custom", StringComparison.OrdinalIgnoreCase); IsCustomSpacingVisible = string.Equals(value?.Value, "Custom", StringComparison.OrdinalIgnoreCase);
@@ -163,6 +183,7 @@ public sealed partial class StatusBarSettingsPageViewModel : ViewModelBase
state.EnableDynamicTaskbarActions, state.EnableDynamicTaskbarActions,
state.TaskbarLayoutMode, state.TaskbarLayoutMode,
SelectedClockFormat.Value, SelectedClockFormat.Value,
ClockTransparentBackground,
NormalizeSpacingMode(SelectedSpacingMode.Value), NormalizeSpacingMode(SelectedSpacingMode.Value),
Math.Clamp(CustomSpacingPercent, 0, 30))); Math.Clamp(CustomSpacingPercent, 0, 30)));
} }
@@ -194,6 +215,8 @@ public sealed partial class StatusBarSettingsPageViewModel : ViewModelBase
ClockHeader = L("settings.status_bar.clock_header", "Clock Component"); ClockHeader = L("settings.status_bar.clock_header", "Clock Component");
ClockDescription = L("settings.status_bar.clock_description", "Display a clock on the top status bar."); ClockDescription = L("settings.status_bar.clock_description", "Display a clock on the top status bar.");
ClockFormatLabel = L("settings.status_bar.clock_format_label", "Clock format"); 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.");
SpacingHeader = L("settings.status_bar.spacing_header", "Component Spacing"); SpacingHeader = L("settings.status_bar.spacing_header", "Component Spacing");
SpacingDescription = L("settings.status_bar.spacing_desc", "Adjust spacing between status bar components."); SpacingDescription = L("settings.status_bar.spacing_desc", "Adjust spacing between status bar components.");
CustomSpacingLabel = L("settings.status_bar.spacing_custom_label", "Custom spacing (%)"); CustomSpacingLabel = L("settings.status_bar.spacing_custom_label", "Custom spacing (%)");

View File

@@ -241,6 +241,7 @@ public partial class ComponentEditorWindow : Window
"DataLine" => MaterialIconKind.ChartLine, "DataLine" => MaterialIconKind.ChartLine,
"Edit" => MaterialIconKind.Pencil, "Edit" => MaterialIconKind.Pencil,
"Calculator" => MaterialIconKind.Calculator, "Calculator" => MaterialIconKind.Calculator,
"Storage" => MaterialIconKind.UsbFlashDrive,
"Globe" => MaterialIconKind.Web, "Globe" => MaterialIconKind.Web,
"Play" => MaterialIconKind.Play, "Play" => MaterialIconKind.Play,
_ => MaterialIconKind.Settings _ => MaterialIconKind.Settings

View File

@@ -22,14 +22,17 @@
<StackPanel Spacing="12"> <StackPanel Spacing="12">
<TextBlock x:Name="ColorSchemeHeaderTextBlock" <TextBlock x:Name="ColorSchemeHeaderTextBlock"
Classes="component-editor-section-title" /> Classes="component-editor-section-title" />
<StackPanel Spacing="8"> <ComboBox x:Name="ColorSchemeComboBox"
<RadioButton x:Name="FollowSystemRadioButton" Classes="component-editor-select"
GroupName="ColorScheme" HorizontalAlignment="Stretch"
IsCheckedChanged="OnColorSchemeChanged" /> SelectionChanged="OnColorSchemeSelectionChanged">
<RadioButton x:Name="UseNativeRadioButton" <ComboBoxItem x:Name="FollowSystemColorSchemeItem"
GroupName="ColorScheme" Classes="component-editor-select-item"
IsCheckedChanged="OnColorSchemeChanged" /> Tag="follow_system" />
</StackPanel> <ComboBoxItem x:Name="UseNativeColorSchemeItem"
Classes="component-editor-select-item"
Tag="native" />
</ComboBox>
</StackPanel> </StackPanel>
</Border> </Border>

View File

@@ -68,40 +68,37 @@ public partial class ClassScheduleComponentEditor : ComponentEditorViewBase
var colorSchemeSource = snapshot.ColorSchemeSource; var colorSchemeSource = snapshot.ColorSchemeSource;
HeadlineTextBlock.Text = Context?.Definition.DisplayName ?? "Class Schedule"; HeadlineTextBlock.Text = Context?.Definition.DisplayName ?? "Class Schedule";
DescriptionTextBlock.Text = L("schedule.settings.desc", "导入 ClassIsland 的 CSES 课表文件并选择启用项。"); DescriptionTextBlock.Text = L(
"schedule.settings.desc",
"Import a ClassIsland CSES schedule file and choose which one to use.");
ColorSchemeHeaderTextBlock.Text = L("component.settings.color_scheme", "配色方案"); ColorSchemeHeaderTextBlock.Text = L("component.settings.color_scheme", "Color Scheme");
FollowSystemRadioButton.Content = L("component.color_scheme.follow_system", "跟随系统配色"); FollowSystemColorSchemeItem.Content = L("component.color_scheme.follow_system", "Follow system color scheme");
UseNativeRadioButton.Content = L("component.color_scheme.native", "使用组件自定义配色"); UseNativeColorSchemeItem.Content = L("component.color_scheme.native", "Use component custom color scheme");
AddScheduleButton.Content = L("schedule.settings.add", "添加课表"); AddScheduleButton.Content = L("schedule.settings.add", "Add Schedule");
EmptyStateTextBlock.Text = L("schedule.settings.empty", "暂无导入课表"); EmptyStateTextBlock.Text = L("schedule.settings.empty", "No imported schedules yet.");
_suppressEvents = true; _suppressEvents = true;
ColorSchemeComboBox.SelectedItem =
if (string.IsNullOrEmpty(colorSchemeSource) || string.IsNullOrEmpty(colorSchemeSource) ||
colorSchemeSource == ThemeAppearanceValues.ColorSchemeFollowSystem) string.Equals(colorSchemeSource, ThemeAppearanceValues.ColorSchemeFollowSystem, StringComparison.OrdinalIgnoreCase)
{ ? FollowSystemColorSchemeItem
FollowSystemRadioButton.IsChecked = true; : UseNativeColorSchemeItem;
}
else
{
UseNativeRadioButton.IsChecked = true;
}
_suppressEvents = false; _suppressEvents = false;
} }
private void OnColorSchemeChanged(object? sender, RoutedEventArgs e) private void OnColorSchemeSelectionChanged(object? sender, SelectionChangedEventArgs e)
{ {
_ = sender;
_ = e;
if (_suppressEvents) if (_suppressEvents)
{ {
return; return;
} }
var useNative = UseNativeRadioButton.IsChecked == true; var colorSchemeSource = ColorSchemeComboBox.SelectedItem is ComboBoxItem item && item.Tag is string tag
var colorSchemeSource = useNative ? tag
? ThemeAppearanceValues.ColorSchemeNative
: ThemeAppearanceValues.ColorSchemeFollowSystem; : ThemeAppearanceValues.ColorSchemeFollowSystem;
var snapshot = LoadSnapshot(); var snapshot = LoadSnapshot();
@@ -121,11 +118,11 @@ public partial class ClassScheduleComponentEditor : ComponentEditorViewBase
var files = await storageProvider.OpenFilePickerAsync(new FilePickerOpenOptions var files = await storageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
{ {
Title = L("schedule.settings.picker_title", "选择 ClassIsland 课表文件"), Title = L("schedule.settings.picker_title", "Choose ClassIsland schedule file"),
AllowMultiple = false, AllowMultiple = false,
FileTypeFilter = FileTypeFilter =
[ [
new FilePickerFileType(L("schedule.settings.picker_file_type", "ClassIsland CSES 课表")) new FilePickerFileType(L("schedule.settings.picker_file_type", "ClassIsland CSES Schedule"))
{ {
Patterns = ["*.cses", "*.yaml", "*.yml"] Patterns = ["*.cses", "*.yaml", "*.yml"]
} }
@@ -155,7 +152,7 @@ public partial class ClassScheduleComponentEditor : ComponentEditorViewBase
{ {
Id = Guid.NewGuid().ToString("N"), Id = Guid.NewGuid().ToString("N"),
DisplayName = Path.GetFileNameWithoutExtension(importedPath)?.Trim() DisplayName = Path.GetFileNameWithoutExtension(importedPath)?.Trim()
?? L("schedule.settings.unnamed", "未命名课表"), ?? L("schedule.settings.unnamed", "Untitled Schedule"),
FilePath = importedPath FilePath = importedPath
}); });
_activeScheduleId = _importedSchedules[^1].Id; _activeScheduleId = _importedSchedules[^1].Id;
@@ -219,7 +216,7 @@ public partial class ClassScheduleComponentEditor : ComponentEditorViewBase
var title = new TextBlock var title = new TextBlock
{ {
Text = string.IsNullOrWhiteSpace(item.DisplayName) Text = string.IsNullOrWhiteSpace(item.DisplayName)
? L("schedule.settings.unnamed", "未命名课表") ? L("schedule.settings.unnamed", "Untitled Schedule")
: item.DisplayName, : item.DisplayName,
FontWeight = FontWeight.SemiBold FontWeight = FontWeight.SemiBold
}; };
@@ -234,7 +231,7 @@ public partial class ClassScheduleComponentEditor : ComponentEditorViewBase
var deleteButton = new Button var deleteButton = new Button
{ {
Content = L("schedule.settings.delete", "删除"), Content = L("schedule.settings.delete", "Delete"),
Tag = item.Id, Tag = item.Id,
Padding = new Thickness(12, 8), Padding = new Thickness(12, 8),
HorizontalAlignment = HorizontalAlignment.Right HorizontalAlignment = HorizontalAlignment.Right

View File

@@ -0,0 +1,42 @@
<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"
mc:Ignorable="d"
x:Class="LanMountainDesktop.Views.ComponentEditors.OfficeRecentDocumentsComponentEditor">
<StackPanel Spacing="16">
<Border Classes="component-editor-hero-card"
Padding="24">
<StackPanel Spacing="8">
<TextBlock x:Name="HeadlineTextBlock"
Classes="component-editor-headline"
TextWrapping="Wrap" />
<TextBlock x:Name="DescriptionTextBlock"
Classes="component-editor-secondary-text"
TextWrapping="Wrap" />
</StackPanel>
</Border>
<Border Classes="component-editor-card"
Padding="20">
<StackPanel Spacing="12">
<TextBlock x:Name="SourcesHeaderTextBlock"
Classes="component-editor-section-title" />
<TextBlock x:Name="SourcesDescriptionTextBlock"
Classes="component-editor-secondary-text"
TextWrapping="Wrap" />
<CheckBox x:Name="RegistryCheckBox"
IsCheckedChanged="OnSourceSelectionChanged" />
<CheckBox x:Name="RecentFoldersCheckBox"
IsCheckedChanged="OnSourceSelectionChanged" />
<CheckBox x:Name="JumpListsCheckBox"
IsCheckedChanged="OnSourceSelectionChanged" />
</StackPanel>
</Border>
<TextBlock x:Name="HintTextBlock"
Classes="component-editor-secondary-text"
Margin="12,0"
TextWrapping="Wrap" />
</StackPanel>
</UserControl>

View File

@@ -0,0 +1,87 @@
using System;
using System.Linq;
using Avalonia.Interactivity;
using LanMountainDesktop.ComponentSystem;
using LanMountainDesktop.Models;
namespace LanMountainDesktop.Views.ComponentEditors;
public partial class OfficeRecentDocumentsComponentEditor : ComponentEditorViewBase
{
private bool _suppressEvents;
public OfficeRecentDocumentsComponentEditor()
: this(null)
{
}
public OfficeRecentDocumentsComponentEditor(DesktopComponentEditorContext? context)
: base(context)
{
InitializeComponent();
ApplyState();
}
private void ApplyState()
{
var snapshot = LoadSnapshot();
var enabledSources = OfficeRecentDocumentSourceTypes.NormalizeValues(
snapshot.OfficeRecentDocumentsEnabledSources,
useDefaultWhenEmpty: snapshot.OfficeRecentDocumentsEnabledSources is null);
HeadlineTextBlock.Text = Context?.Definition.DisplayName ?? L(
"component.office_recent_documents",
"Recent Documents");
DescriptionTextBlock.Text = L(
"office_recent_documents.settings.desc",
"Choose which Windows and Office sources this widget should scan for recent documents.");
SourcesHeaderTextBlock.Text = L(
"office_recent_documents.settings.sources_title",
"Recent document sources");
SourcesDescriptionTextBlock.Text = L(
"office_recent_documents.settings.sources_desc",
"You can combine multiple sources. Registry selection also keeps the Office interop MRU fallback available.");
RegistryCheckBox.Content = L(
"office_recent_documents.settings.source.registry",
"Office registry MRU");
RecentFoldersCheckBox.Content = L(
"office_recent_documents.settings.source.recent_folders",
"Windows Recent folders");
JumpListsCheckBox.Content = L(
"office_recent_documents.settings.source.jump_lists",
"Windows Jump Lists");
HintTextBlock.Text = L(
"office_recent_documents.settings.hint",
"If you disable all sources, this widget will stay empty until at least one source is enabled again.");
_suppressEvents = true;
RegistryCheckBox.IsChecked = enabledSources.Contains(OfficeRecentDocumentSourceTypes.Registry, StringComparer.OrdinalIgnoreCase);
RecentFoldersCheckBox.IsChecked = enabledSources.Contains(OfficeRecentDocumentSourceTypes.RecentFolders, StringComparer.OrdinalIgnoreCase);
JumpListsCheckBox.IsChecked = enabledSources.Contains(OfficeRecentDocumentSourceTypes.JumpLists, StringComparer.OrdinalIgnoreCase);
_suppressEvents = false;
}
private void OnSourceSelectionChanged(object? sender, RoutedEventArgs e)
{
_ = sender;
_ = e;
if (_suppressEvents)
{
return;
}
var selectedSources = new[]
{
RegistryCheckBox.IsChecked == true ? OfficeRecentDocumentSourceTypes.Registry : null,
RecentFoldersCheckBox.IsChecked == true ? OfficeRecentDocumentSourceTypes.RecentFolders : null,
JumpListsCheckBox.IsChecked == true ? OfficeRecentDocumentSourceTypes.JumpLists : null
}
.Where(static value => !string.IsNullOrWhiteSpace(value))
.Cast<string>()
.ToList();
var snapshot = LoadSnapshot();
snapshot.OfficeRecentDocumentsEnabledSources = selectedSources;
SaveSnapshot(snapshot, nameof(ComponentSettingsSnapshot.OfficeRecentDocumentsEnabledSources));
}
}

View File

@@ -0,0 +1,50 @@
<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"
mc:Ignorable="d"
x:Class="LanMountainDesktop.Views.ComponentEditors.RemovableStorageComponentEditor">
<StackPanel Spacing="16">
<Border Classes="component-editor-hero-card"
Padding="24">
<StackPanel Spacing="8">
<TextBlock x:Name="HeadlineTextBlock"
Classes="component-editor-headline"
TextWrapping="Wrap" />
<TextBlock x:Name="DescriptionTextBlock"
Classes="component-editor-secondary-text"
TextWrapping="Wrap" />
</StackPanel>
</Border>
<Border Classes="component-editor-card"
Padding="20">
<StackPanel Spacing="12">
<TextBlock x:Name="ColorSchemeHeaderTextBlock"
Classes="component-editor-section-title" />
<ComboBox x:Name="ColorSchemeComboBox"
Classes="component-editor-select"
HorizontalAlignment="Stretch"
SelectionChanged="OnColorSchemeSelectionChanged">
<ComboBoxItem x:Name="FollowSystemColorSchemeItem"
Classes="component-editor-select-item"
Tag="follow_system" />
<ComboBoxItem x:Name="UseNativeColorSchemeItem"
Classes="component-editor-select-item"
Tag="native" />
</ComboBox>
</StackPanel>
</Border>
<Border Classes="component-editor-card"
Padding="20">
<StackPanel Spacing="10">
<TextBlock x:Name="BehaviorHeaderTextBlock"
Classes="component-editor-section-title" />
<TextBlock x:Name="BehaviorTextBlock"
Classes="component-editor-secondary-text"
TextWrapping="Wrap" />
</StackPanel>
</Border>
</StackPanel>
</UserControl>

View File

@@ -0,0 +1,67 @@
using System;
using Avalonia.Controls;
using Avalonia.Interactivity;
using LanMountainDesktop.ComponentSystem;
using LanMountainDesktop.Models;
using LanMountainDesktop.Services;
namespace LanMountainDesktop.Views.ComponentEditors;
public partial class RemovableStorageComponentEditor : ComponentEditorViewBase
{
private bool _suppressEvents;
public RemovableStorageComponentEditor()
: this(null)
{
}
public RemovableStorageComponentEditor(DesktopComponentEditorContext? context)
: base(context)
{
InitializeComponent();
ApplyState();
}
private void ApplyState()
{
var snapshot = LoadSnapshot();
var colorSchemeSource = snapshot.ColorSchemeSource;
HeadlineTextBlock.Text = Context?.Definition.DisplayName ?? "Removable Storage";
DescriptionTextBlock.Text = L(
"removable_storage.settings.desc",
"Show a connected USB drive with quick open and eject actions.");
ColorSchemeHeaderTextBlock.Text = L("component.settings.color_scheme", "Color Scheme");
FollowSystemColorSchemeItem.Content = L("component.color_scheme.follow_system", "Follow system color scheme");
UseNativeColorSchemeItem.Content = L("component.color_scheme.native", "Use component custom color scheme");
BehaviorHeaderTextBlock.Text = L("removable_storage.settings.behavior_title", "Behavior");
BehaviorTextBlock.Text = L(
"removable_storage.settings.behavior_desc",
"The widget automatically watches for removable drives and switches to the newest inserted USB drive.");
_suppressEvents = true;
ColorSchemeComboBox.SelectedItem =
string.IsNullOrWhiteSpace(colorSchemeSource) ||
string.Equals(colorSchemeSource, ThemeAppearanceValues.ColorSchemeFollowSystem, StringComparison.OrdinalIgnoreCase)
? FollowSystemColorSchemeItem
: UseNativeColorSchemeItem;
_suppressEvents = false;
}
private void OnColorSchemeSelectionChanged(object? sender, SelectionChangedEventArgs e)
{
_ = sender;
_ = e;
if (_suppressEvents)
{
return;
}
var snapshot = LoadSnapshot();
snapshot.ColorSchemeSource = ColorSchemeComboBox.SelectedItem is ComboBoxItem item && item.Tag is string tag
? tag
: ThemeAppearanceValues.ColorSchemeFollowSystem;
SaveSnapshot(snapshot, nameof(ComponentSettingsSnapshot.ColorSchemeSource));
}
}

View File

@@ -6,7 +6,7 @@
mc:Ignorable="d" mc:Ignorable="d"
x:Class="LanMountainDesktop.Views.ComponentEditors.StudyEnvironmentComponentEditor"> x:Class="LanMountainDesktop.Views.ComponentEditors.StudyEnvironmentComponentEditor">
<StackPanel Spacing="16"> <StackPanel Spacing="16">
<Border Classes="component-editor-hero_card" <Border Classes="component-editor-hero-card"
Padding="24"> Padding="24">
<StackPanel Spacing="8"> <StackPanel Spacing="8">
<TextBlock x:Name="HeadlineTextBlock" <TextBlock x:Name="HeadlineTextBlock"
@@ -23,14 +23,17 @@
<StackPanel Spacing="12"> <StackPanel Spacing="12">
<TextBlock x:Name="ColorSchemeHeaderTextBlock" <TextBlock x:Name="ColorSchemeHeaderTextBlock"
Classes="component-editor-section-title" /> Classes="component-editor-section-title" />
<StackPanel Spacing="8"> <ComboBox x:Name="ColorSchemeComboBox"
<RadioButton x:Name="FollowSystemRadioButton" Classes="component-editor-select"
GroupName="ColorScheme" HorizontalAlignment="Stretch"
IsCheckedChanged="OnColorSchemeChanged" /> SelectionChanged="OnColorSchemeSelectionChanged">
<RadioButton x:Name="UseNativeRadioButton" <ComboBoxItem x:Name="FollowSystemColorSchemeItem"
GroupName="ColorScheme" Classes="component-editor-select-item"
IsCheckedChanged="OnColorSchemeChanged" /> Tag="follow_system" />
</StackPanel> <ComboBoxItem x:Name="UseNativeColorSchemeItem"
Classes="component-editor-select-item"
Tag="native" />
</ComboBox>
</StackPanel> </StackPanel>
</Border> </Border>
@@ -44,7 +47,7 @@
</StackPanel> </StackPanel>
</Border> </Border>
<Border Classes="component-editor_card" <Border Classes="component-editor-card"
Padding="20"> Padding="20">
<StackPanel Spacing="12"> <StackPanel Spacing="12">
<TextBlock x:Name="DbfsHeaderTextBlock" <TextBlock x:Name="DbfsHeaderTextBlock"

View File

@@ -1,3 +1,5 @@
using System;
using Avalonia.Controls;
using Avalonia.Interactivity; using Avalonia.Interactivity;
using LanMountainDesktop.ComponentSystem; using LanMountainDesktop.ComponentSystem;
using LanMountainDesktop.Models; using LanMountainDesktop.Models;
@@ -34,43 +36,40 @@ public partial class StudyEnvironmentComponentEditor : ComponentEditorViewBase
} }
HeadlineTextBlock.Text = Context?.Definition.DisplayName ?? "Study Environment"; HeadlineTextBlock.Text = Context?.Definition.DisplayName ?? "Study Environment";
DescriptionTextBlock.Text = L("study.environment.settings.desc", "配置右侧实时噪音值显示内容。"); DescriptionTextBlock.Text = L(
"study.environment.settings.desc",
"Configure the realtime audio level information shown on the right side.");
ColorSchemeHeaderTextBlock.Text = L("component.settings.color_scheme", "配色方案"); ColorSchemeHeaderTextBlock.Text = L("component.settings.color_scheme", "Color Scheme");
FollowSystemRadioButton.Content = L("component.color_scheme.follow_system", "跟随系统配色"); FollowSystemColorSchemeItem.Content = L("component.color_scheme.follow_system", "Follow system color scheme");
UseNativeRadioButton.Content = L("component.color_scheme.native", "使用组件自定义配色"); UseNativeColorSchemeItem.Content = L("component.color_scheme.native", "Use component custom color scheme");
DisplayDbToggleSwitch.Content = L("study.environment.settings.show_display_db", "显示 display dB"); DisplayDbToggleSwitch.Content = L("study.environment.settings.show_display_db", "Show display dB");
DbfsToggleSwitch.Content = L("study.environment.settings.show_dbfs", "显示 dBFS"); DbfsToggleSwitch.Content = L("study.environment.settings.show_dbfs", "Show dBFS");
HintTextBlock.Text = L("study.environment.settings.hint", "至少启用一种显示方式。"); HintTextBlock.Text = L("study.environment.settings.hint", "At least one display mode must stay enabled.");
_suppressEvents = true; _suppressEvents = true;
ColorSchemeComboBox.SelectedItem =
if (string.IsNullOrEmpty(colorSchemeSource) || string.IsNullOrEmpty(colorSchemeSource) ||
colorSchemeSource == ThemeAppearanceValues.ColorSchemeFollowSystem) string.Equals(colorSchemeSource, ThemeAppearanceValues.ColorSchemeFollowSystem, StringComparison.OrdinalIgnoreCase)
{ ? FollowSystemColorSchemeItem
FollowSystemRadioButton.IsChecked = true; : UseNativeColorSchemeItem;
}
else
{
UseNativeRadioButton.IsChecked = true;
}
DisplayDbToggleSwitch.IsChecked = showDisplayDb; DisplayDbToggleSwitch.IsChecked = showDisplayDb;
DbfsToggleSwitch.IsChecked = showDbfs; DbfsToggleSwitch.IsChecked = showDbfs;
_suppressEvents = false; _suppressEvents = false;
} }
private void OnColorSchemeChanged(object? sender, RoutedEventArgs e) private void OnColorSchemeSelectionChanged(object? sender, SelectionChangedEventArgs e)
{ {
_ = sender;
_ = e;
if (_suppressEvents) if (_suppressEvents)
{ {
return; return;
} }
var useNative = UseNativeRadioButton.IsChecked == true; var colorSchemeSource = ColorSchemeComboBox.SelectedItem is ComboBoxItem item && item.Tag is string tag
var colorSchemeSource = useNative ? tag
? ThemeAppearanceValues.ColorSchemeNative
: ThemeAppearanceValues.ColorSchemeFollowSystem; : ThemeAppearanceValues.ColorSchemeFollowSystem;
var snapshot = LoadSnapshot(); var snapshot = LoadSnapshot();

View File

@@ -0,0 +1,40 @@
<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"
mc:Ignorable="d"
x:Class="LanMountainDesktop.Views.ComponentEditors.WhiteboardComponentEditor">
<StackPanel Spacing="16">
<Border Classes="component-editor-hero-card"
Padding="24">
<StackPanel Spacing="8">
<TextBlock x:Name="HeadlineTextBlock"
Classes="component-editor-headline"
TextWrapping="Wrap" />
<TextBlock x:Name="DescriptionTextBlock"
Classes="component-editor-secondary-text"
TextWrapping="Wrap" />
</StackPanel>
</Border>
<Border Classes="component-editor-card"
Padding="20">
<StackPanel Spacing="12">
<TextBlock x:Name="RetentionHeaderTextBlock"
Classes="component-editor-section-title" />
<TextBlock x:Name="RetentionDescriptionTextBlock"
Classes="component-editor-secondary-text"
TextWrapping="Wrap" />
<ComboBox x:Name="RetentionComboBox"
Classes="component-editor-select"
HorizontalAlignment="Stretch"
SelectionChanged="OnRetentionSelectionChanged" />
</StackPanel>
</Border>
<TextBlock x:Name="InstanceHintTextBlock"
Classes="component-editor-secondary-text"
Margin="12,0"
TextWrapping="Wrap" />
</StackPanel>
</UserControl>

View File

@@ -0,0 +1,106 @@
using System;
using System.Linq;
using Avalonia.Controls;
using Avalonia.Interactivity;
using LanMountainDesktop.ComponentSystem;
using LanMountainDesktop.Models;
namespace LanMountainDesktop.Views.ComponentEditors;
public partial class WhiteboardComponentEditor : ComponentEditorViewBase
{
private bool _suppressEvents;
public WhiteboardComponentEditor()
: this(null)
{
}
public WhiteboardComponentEditor(DesktopComponentEditorContext? context)
: base(context)
{
InitializeComponent();
BuildRetentionOptions();
ApplyState();
}
private void BuildRetentionOptions()
{
RetentionComboBox.Items.Clear();
for (var days = WhiteboardNoteRetentionPolicy.MinimumDays; days <= WhiteboardNoteRetentionPolicy.MaximumDays; days++)
{
var item = new ComboBoxItem
{
Tag = days.ToString(),
Content = L(
"whiteboard.settings.retention.option",
"{0} days").Replace("{0}", days.ToString())
};
item.Classes.Add("component-editor-select-item");
RetentionComboBox.Items.Add(item);
}
}
private void ApplyState()
{
var snapshot = LoadSnapshot();
var retentionDays = NormalizeRetentionDays(snapshot.WhiteboardNoteRetentionDays);
HeadlineTextBlock.Text = Context?.Definition.DisplayName ?? "Blackboard";
DescriptionTextBlock.Text = L(
"whiteboard.settings.desc",
"Each blackboard keeps its own note history and saves it independently.");
RetentionHeaderTextBlock.Text = L(
"whiteboard.settings.retention.title",
"Note retention");
RetentionDescriptionTextBlock.Text = L(
"whiteboard.settings.retention.desc",
"Choose how long this blackboard should keep saved notes before expired data is removed automatically.");
InstanceHintTextBlock.Text = L(
"whiteboard.settings.instance_scope",
"This retention setting is stored per blackboard component instance.");
_suppressEvents = true;
RetentionComboBox.SelectedItem = RetentionComboBox.Items
.OfType<ComboBoxItem>()
.FirstOrDefault(item =>
item.Tag is string tag &&
int.TryParse(tag, out var days) &&
days == retentionDays);
_suppressEvents = false;
}
private void OnRetentionSelectionChanged(object? sender, SelectionChangedEventArgs e)
{
_ = sender;
_ = e;
if (_suppressEvents)
{
return;
}
var snapshot = LoadSnapshot();
snapshot.WhiteboardNoteRetentionDays = GetSelectedRetentionDays();
SaveSnapshot(snapshot, nameof(ComponentSettingsSnapshot.WhiteboardNoteRetentionDays));
}
private int GetSelectedRetentionDays()
{
if (RetentionComboBox.SelectedItem is ComboBoxItem item &&
item.Tag is string tag &&
int.TryParse(tag, out var days))
{
return NormalizeRetentionDays(days);
}
return WhiteboardNoteRetentionPolicy.DefaultDays;
}
private static int NormalizeRetentionDays(int days)
{
return WhiteboardNoteRetentionPolicy.NormalizeDays(
days <= 0
? WhiteboardNoteRetentionPolicy.DefaultDays
: days);
}
}

View File

@@ -38,7 +38,7 @@
<Grid Grid.Row="1" <Grid Grid.Row="1"
ColumnDefinitions="240,*" ColumnDefinitions="240,*"
ColumnSpacing="12"> ColumnSpacing="12">
<Border Classes="glass-panel" <Border Classes="surface-translucent-panel"
CornerRadius="24" CornerRadius="24"
Padding="10"> Padding="10">
<ListBox x:Name="CategoryListBox" <ListBox x:Name="CategoryListBox"
@@ -70,7 +70,7 @@
</Border> </Border>
<Border Grid.Column="1" <Border Grid.Column="1"
Classes="glass-strong" Classes="surface-translucent-strong"
CornerRadius="24" CornerRadius="24"
Padding="10"> Padding="10">
<ScrollViewer VerticalScrollBarVisibility="Auto" <ScrollViewer VerticalScrollBarVisibility="Auto"

View File

@@ -1,4 +1,4 @@
<UserControl xmlns="https://github.com/avaloniaui" <UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
@@ -8,7 +8,7 @@
x:Class="LanMountainDesktop.Views.Components.ClockWidget"> x:Class="LanMountainDesktop.Views.Components.ClockWidget">
<Border x:Name="RootBorder" <Border x:Name="RootBorder"
Classes="glass-panel" Classes="surface-translucent-panel"
Padding="0" Padding="0"
CornerRadius="24"> CornerRadius="24">
<StackPanel Orientation="Horizontal" <StackPanel Orientation="Horizontal"

View File

@@ -23,6 +23,8 @@ public partial class ClockWidget : UserControl, IDesktopComponentWidget, ITimeZo
private TimeZoneService? _timeZoneService; private TimeZoneService? _timeZoneService;
private ClockDisplayFormat _displayFormat = ClockDisplayFormat.HourMinuteSecond; private ClockDisplayFormat _displayFormat = ClockDisplayFormat.HourMinuteSecond;
private bool _transparentBackground;
private double _lastAppliedCellSize = 100;
public ClockWidget() public ClockWidget()
{ {
@@ -44,11 +46,32 @@ public partial class ClockWidget : UserControl, IDesktopComponentWidget, ITimeZo
} }
} }
public bool TransparentBackground
{
get => _transparentBackground;
set
{
if (_transparentBackground == value)
{
return;
}
_transparentBackground = value;
ApplyChrome();
ApplyCellSize(_lastAppliedCellSize);
}
}
public void SetDisplayFormat(ClockDisplayFormat format) public void SetDisplayFormat(ClockDisplayFormat format)
{ {
DisplayFormat = format; DisplayFormat = format;
} }
public void SetTransparentBackground(bool transparentBackground)
{
TransparentBackground = transparentBackground;
}
public void SetTimeZoneService(TimeZoneService timeZoneService) public void SetTimeZoneService(TimeZoneService timeZoneService)
{ {
ClearTimeZoneService(); ClearTimeZoneService();
@@ -101,6 +124,8 @@ public partial class ClockWidget : UserControl, IDesktopComponentWidget, ITimeZo
public void ApplyCellSize(double cellSize) public void ApplyCellSize(double cellSize)
{ {
_lastAppliedCellSize = cellSize;
// --- Class Island “满盈”风格算法 --- // --- Class Island “满盈”风格算法 ---
// 1. 计算组件高度:保持与任务栏核心比例一致 (0.74x) // 1. 计算组件高度:保持与任务栏核心比例一致 (0.74x)
@@ -130,7 +155,38 @@ public partial class ClockWidget : UserControl, IDesktopComponentWidget, ITimeZo
panel.Spacing = Math.Clamp(cellSize * 0.06, 2, 8); panel.Spacing = Math.Clamp(cellSize * 0.06, 2, 8);
} }
if (_transparentBackground)
{
RootBorder.MinWidth = 0;
RootBorder.Padding = new Thickness(Math.Clamp(cellSize * 0.06, 4, 10), 0);
return;
}
// 确保清除可能存在的固定 Padding由代码控制“紧密感” // 确保清除可能存在的固定 Padding由代码控制“紧密感”
RootBorder.MinWidth = cellSize * 2.2;
RootBorder.Padding = new Thickness(Math.Clamp(cellSize * 0.15, 12, 24), 0); 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);
}
} }

View File

@@ -444,6 +444,11 @@ public sealed class DesktopComponentRuntimeRegistry
"component.office_recent_documents", "component.office_recent_documents",
() => new OfficeRecentDocumentsWidget(), () => new OfficeRecentDocumentsWidget(),
cellSize => Math.Clamp(cellSize * 0.50, 10, 24)), cellSize => Math.Clamp(cellSize * 0.50, 10, 24)),
new DesktopComponentRuntimeRegistration(
BuiltInComponentIds.DesktopRemovableStorage,
"component.removable_storage",
() => new RemovableStorageWidget(),
cellSize => Math.Clamp(cellSize * 0.46, 12, 26)),
new DesktopComponentRuntimeRegistration( new DesktopComponentRuntimeRegistration(
BuiltInComponentIds.HolidayCalendar, BuiltInComponentIds.HolidayCalendar,
"component.holiday_calendar", "component.holiday_calendar",

View File

@@ -1,17 +1,23 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading.Tasks;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Input; using Avalonia.Input;
using LanMountainDesktop.ComponentSystem;
using LanMountainDesktop.Models;
using LanMountainDesktop.Services; using LanMountainDesktop.Services;
using LanMountainDesktop.Views.Components;
namespace LanMountainDesktop.Views.Components; namespace LanMountainDesktop.Views.Components;
public partial class OfficeRecentDocumentsWidget : UserControl, IDesktopComponentWidget, IDesktopPageVisibilityAwareComponentWidget public partial class OfficeRecentDocumentsWidget : UserControl, IDesktopComponentWidget, IDesktopPageVisibilityAwareComponentWidget, IComponentPlacementContextAware
{ {
private readonly IOfficeRecentDocumentsService _recentDocumentsService; private readonly IOfficeRecentDocumentsService _recentDocumentsService;
private readonly IComponentInstanceSettingsStore _componentSettingsStore = HostComponentSettingsStoreProvider.GetOrCreate();
private List<OfficeRecentDocument> _documents = new(); private List<OfficeRecentDocument> _documents = new();
private string _componentId = BuiltInComponentIds.DesktopOfficeRecentDocuments;
private string _placementId = string.Empty;
private IReadOnlyList<string> _enabledSources = OfficeRecentDocumentSourceTypes.DefaultValues;
private bool _isOnActivePage; private bool _isOnActivePage;
private bool _isEditMode; private bool _isEditMode;
private bool _isLoading; private bool _isLoading;
@@ -20,6 +26,7 @@ public partial class OfficeRecentDocumentsWidget : UserControl, IDesktopComponen
{ {
InitializeComponent(); InitializeComponent();
_recentDocumentsService = new OfficeRecentDocumentsService(); _recentDocumentsService = new OfficeRecentDocumentsService();
ReloadSettings();
} }
public void ApplyCellSize(double cellSize) public void ApplyCellSize(double cellSize)
@@ -44,27 +51,45 @@ public partial class OfficeRecentDocumentsWidget : UserControl, IDesktopComponen
} }
} }
private void LoadDocuments() public void SetComponentPlacementContext(string componentId, string? placementId)
{ {
_componentId = string.IsNullOrWhiteSpace(componentId)
? BuiltInComponentIds.DesktopOfficeRecentDocuments
: componentId.Trim();
_placementId = placementId?.Trim() ?? string.Empty;
ReloadSettings();
}
private async void LoadDocuments()
{
if (_isLoading)
{
return;
}
try try
{ {
_isLoading = true; _isLoading = true;
ReloadSettings();
StatusTextBlock.IsVisible = false; StatusTextBlock.IsVisible = false;
DocumentsItemsControl.ItemsSource = null;
_documents = _recentDocumentsService.GetRecentDocuments(20); var enabledSources = _enabledSources.ToArray();
_documents = await Task.Run(() => _recentDocumentsService.GetRecentDocuments(20, enabledSources));
if (_documents.Count == 0) if (_documents.Count == 0)
{ {
StatusTextBlock.Text = "暂无最近文档"; StatusTextBlock.Text = "\u6682\u65e0\u6700\u8fd1\u6587\u6863";
StatusTextBlock.IsVisible = true; StatusTextBlock.IsVisible = true;
return; return;
} }
UpdateDisplay(); UpdateDisplay();
} }
catch catch (Exception ex)
{ {
StatusTextBlock.Text = "加载失败"; AppLogger.Warn("OfficeRecentDocsWidget", "Failed to load recent Office documents.", ex);
StatusTextBlock.Text = "\u52a0\u8f7d\u5931\u8d25";
StatusTextBlock.IsVisible = true; StatusTextBlock.IsVisible = true;
} }
finally finally
@@ -73,6 +98,14 @@ public partial class OfficeRecentDocumentsWidget : UserControl, IDesktopComponen
} }
} }
private void ReloadSettings()
{
var snapshot = _componentSettingsStore.LoadForComponent(_componentId, _placementId);
_enabledSources = OfficeRecentDocumentSourceTypes.NormalizeValues(
snapshot.OfficeRecentDocumentsEnabledSources,
useDefaultWhenEmpty: snapshot.OfficeRecentDocumentsEnabledSources is null);
}
private void UpdateDisplay() private void UpdateDisplay()
{ {
var displayItems = _documents.Select(d => new OfficeRecentDocumentViewModel var displayItems = _documents.Select(d => new OfficeRecentDocumentViewModel
@@ -90,15 +123,29 @@ public partial class OfficeRecentDocumentsWidget : UserControl, IDesktopComponen
var span = DateTime.Now - dateTime; var span = DateTime.Now - dateTime;
if (span.TotalMinutes < 1) if (span.TotalMinutes < 1)
return "刚刚"; {
return "\u521a\u521a";
}
if (span.TotalMinutes < 60) if (span.TotalMinutes < 60)
return $"{(int)span.TotalMinutes} 分钟前"; {
return $"{(int)span.TotalMinutes} \u5206\u949f\u524d";
}
if (span.TotalHours < 24) if (span.TotalHours < 24)
return $"{(int)span.TotalHours} 小时前"; {
return $"{(int)span.TotalHours} \u5c0f\u65f6\u524d";
}
if (span.TotalDays < 7) if (span.TotalDays < 7)
return $"{(int)span.TotalDays} 天前"; {
return $"{(int)span.TotalDays} \u5929\u524d";
}
if (span.TotalDays < 30) if (span.TotalDays < 30)
return $"{(int)(span.TotalDays / 7)} 周前"; {
return $"{(int)(span.TotalDays / 7)} \u5468\u524d";
}
return dateTime.ToString("MM/dd"); return dateTime.ToString("MM/dd");
} }

View File

@@ -0,0 +1,119 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:fi="using:FluentIcons.Avalonia"
mc:Ignorable="d"
d:DesignWidth="280"
d:DesignHeight="280"
x:Class="LanMountainDesktop.Views.Components.RemovableStorageWidget">
<Border x:Name="RootBorder"
CornerRadius="28"
BorderThickness="1"
Padding="16"
ClipToBounds="True">
<Grid>
<Border x:Name="AccentOrb"
Width="132"
Height="132"
HorizontalAlignment="Right"
VerticalAlignment="Top"
Margin="0,-48,-48,0"
CornerRadius="66"
IsHitTestVisible="False" />
<Border x:Name="AccentGlow"
Height="76"
Margin="-18,0,-18,-34"
HorizontalAlignment="Stretch"
VerticalAlignment="Bottom"
CornerRadius="38"
Opacity="0.42"
IsHitTestVisible="False" />
<Grid x:Name="LayoutGrid"
RowDefinitions="Auto,*,Auto,Auto"
RowSpacing="10">
<Grid x:Name="HeaderGrid"
ColumnDefinitions="Auto,*"
ColumnSpacing="12">
<Border x:Name="IconBadge"
Width="44"
Height="44"
CornerRadius="22"
VerticalAlignment="Top">
<fi:FluentIcon x:Name="DriveIcon"
Icon="UsbStick"
IconVariant="Regular"
FontSize="24"
HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Border>
<StackPanel x:Name="HeaderTextStack"
Grid.Column="1"
Spacing="2"
VerticalAlignment="Center">
<TextBlock x:Name="DriveNameTextBlock"
FontWeight="SemiBold"
TextWrapping="NoWrap"
TextTrimming="CharacterEllipsis" />
<TextBlock x:Name="DriveDetailTextBlock"
TextWrapping="NoWrap"
TextTrimming="CharacterEllipsis" />
</StackPanel>
</Grid>
<TextBlock x:Name="StatusTextBlock"
Grid.Row="1"
VerticalAlignment="Center"
TextWrapping="Wrap" />
<Button x:Name="OpenButton"
Grid.Row="2"
Height="42"
Padding="14,0"
CornerRadius="999"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center"
Click="OnOpenClick">
<Grid ColumnDefinitions="Auto,*"
ColumnSpacing="8">
<fi:FluentIcon x:Name="OpenButtonIcon"
Icon="OpenFolder"
IconVariant="Regular"
FontSize="16"
VerticalAlignment="Center" />
<TextBlock x:Name="OpenButtonTextBlock"
Grid.Column="1"
FontWeight="SemiBold"
VerticalAlignment="Center" />
</Grid>
</Button>
<Button x:Name="EjectButton"
Grid.Row="3"
Height="42"
Padding="14,0"
CornerRadius="999"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center"
Click="OnEjectClick">
<Grid ColumnDefinitions="Auto,*"
ColumnSpacing="8">
<fi:FluentIcon x:Name="EjectButtonIcon"
Icon="ArrowEject"
IconVariant="Regular"
FontSize="16"
VerticalAlignment="Center" />
<TextBlock x:Name="EjectButtonTextBlock"
Grid.Column="1"
FontWeight="SemiBold"
VerticalAlignment="Center" />
</Grid>
</Button>
</Grid>
</Grid>
</Border>
</UserControl>

View File

@@ -0,0 +1,596 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Media;
using Avalonia.Styling;
using Avalonia.Threading;
using FluentIcons.Avalonia;
using LanMountainDesktop.ComponentSystem;
using LanMountainDesktop.Models;
using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Services;
using LanMountainDesktop.Services.Settings;
using LanMountainDesktop.Theme;
namespace LanMountainDesktop.Views.Components;
public partial class RemovableStorageWidget : UserControl, IDesktopComponentWidget, IDesktopPageVisibilityAwareComponentWidget, IComponentPlacementContextAware, IDisposable
{
private readonly record struct RemovableStoragePalette(
Color BackgroundFrom,
Color BackgroundTo,
Color Border,
Color AccentOrb,
Color AccentGlow,
Color IconBadgeBackground,
Color IconForeground,
Color PrimaryText,
Color SecondaryText,
Color StatusText,
Color Accent,
Color OnAccent,
Color SecondaryButtonBackground,
Color SecondaryButtonBorder,
Color SecondaryButtonForeground,
Color DisabledButtonBackground,
Color DisabledButtonBorder,
Color DisabledButtonForeground);
private readonly DispatcherTimer _pollTimer = new()
{
Interval = TimeSpan.FromSeconds(2)
};
private readonly IRemovableStorageService _removableStorageService = new RemovableStorageService();
private readonly LocalizationService _localizationService = new();
private ISettingsService _settingsService = HostSettingsFacadeProvider.GetOrCreate().Settings;
private IComponentInstanceSettingsStore _componentSettingsStore = HostComponentSettingsStoreProvider.GetOrCreate();
private IReadOnlyList<RemovableStorageDrive> _connectedDrives = Array.Empty<RemovableStorageDrive>();
private string _componentId = BuiltInComponentIds.DesktopRemovableStorage;
private string _placementId = string.Empty;
private string _languageCode = "zh-CN";
private string? _componentColorScheme;
private string _selectedDriveRootPath = string.Empty;
private string? _statusOverrideText;
private double _currentCellSize = 48;
private bool _isAttached;
private bool _isOnActivePage = true;
private bool _isRefreshing;
private bool _isDisposed;
public RemovableStorageWidget()
{
InitializeComponent();
_pollTimer.Tick += OnPollTimerTick;
AttachedToVisualTree += OnAttachedToVisualTree;
DetachedFromVisualTree += OnDetachedFromVisualTree;
SizeChanged += OnSizeChanged;
ActualThemeVariantChanged += OnActualThemeVariantChanged;
ApplyCellSize(_currentCellSize);
ReloadSettings();
ApplyVisualState();
}
public void ApplyCellSize(double cellSize)
{
_currentCellSize = Math.Max(1, cellSize);
ApplyLayoutMetrics();
}
public void SetDesktopPageContext(bool isOnActivePage, bool isEditMode)
{
_ = isEditMode;
var shouldRefresh = !_isOnActivePage && isOnActivePage;
_isOnActivePage = isOnActivePage;
UpdatePollingState();
if (shouldRefresh)
{
_ = RefreshDriveListAsync();
}
}
public void SetComponentPlacementContext(string componentId, string? placementId)
{
_componentId = string.IsNullOrWhiteSpace(componentId)
? BuiltInComponentIds.DesktopRemovableStorage
: componentId.Trim();
_placementId = placementId?.Trim() ?? string.Empty;
RefreshFromSettings();
}
public void RefreshFromSettings()
{
ReloadSettings();
ApplyVisualState();
}
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{
_ = sender;
_ = e;
_isAttached = true;
UpdatePollingState();
_ = RefreshDriveListAsync();
}
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{
_ = sender;
_ = e;
_isAttached = false;
UpdatePollingState();
}
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
{
_ = sender;
_ = e;
ApplyLayoutMetrics();
}
private void OnActualThemeVariantChanged(object? sender, EventArgs e)
{
_ = sender;
_ = e;
ApplyVisualState();
}
private async void OnPollTimerTick(object? sender, EventArgs e)
{
_ = sender;
_ = e;
await RefreshDriveListAsync();
}
private async void OnOpenClick(object? sender, RoutedEventArgs e)
{
_ = sender;
_ = e;
var drive = GetSelectedDrive();
if (drive is null)
{
return;
}
if (_removableStorageService.OpenDrive(drive.RootPath))
{
_statusOverrideText = L("removable_storage.widget.ready", "Ready to open or eject.");
ApplyVisualState();
return;
}
_statusOverrideText = L("removable_storage.widget.open_failed", "Failed to open this drive.");
ApplyVisualState();
await RefreshDriveListAsync();
}
private async void OnEjectClick(object? sender, RoutedEventArgs e)
{
_ = sender;
_ = e;
var drive = GetSelectedDrive();
if (drive is null)
{
return;
}
_statusOverrideText = L("removable_storage.widget.ejecting", "Ejecting drive...");
ApplyVisualState();
var ejected = _removableStorageService.EjectDrive(drive.RootPath);
_statusOverrideText = ejected
? L("removable_storage.widget.ejecting", "Ejecting drive...")
: L("removable_storage.widget.eject_failed", "Could not eject this drive. Close any files on it and try again.");
ApplyVisualState();
await RefreshDriveListAsync();
}
private async Task RefreshDriveListAsync()
{
if (_isDisposed || _isRefreshing)
{
return;
}
_isRefreshing = true;
try
{
var previousDriveRoots = new HashSet<string>(
_connectedDrives.Select(drive => drive.RootPath),
StringComparer.OrdinalIgnoreCase);
var latestDrives = await Task.Run(() => _removableStorageService.GetConnectedDrives());
if (_isDisposed)
{
return;
}
var newlyInsertedDrive = latestDrives.FirstOrDefault(drive => !previousDriveRoots.Contains(drive.RootPath));
_connectedDrives = latestDrives;
if (newlyInsertedDrive is not null)
{
_selectedDriveRootPath = newlyInsertedDrive.RootPath;
}
else if (string.IsNullOrWhiteSpace(_selectedDriveRootPath) ||
!_connectedDrives.Any(drive => string.Equals(drive.RootPath, _selectedDriveRootPath, StringComparison.OrdinalIgnoreCase)))
{
_selectedDriveRootPath = _connectedDrives.FirstOrDefault().RootPath ?? string.Empty;
}
if (_connectedDrives.Count == 0)
{
_selectedDriveRootPath = string.Empty;
_statusOverrideText = null;
}
else if (newlyInsertedDrive is not null)
{
_statusOverrideText = null;
}
ReloadSettings();
ApplyVisualState();
}
catch (Exception ex)
{
AppLogger.Warn("RemovableStorageWidget", "Failed to refresh removable storage widget.", ex);
_statusOverrideText = L("removable_storage.widget.refresh_failed", "Drive list refresh failed.");
ApplyVisualState();
}
finally
{
_isRefreshing = false;
}
}
private void ReloadSettings()
{
try
{
var appSettings = _settingsService.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
var componentSettings = _componentSettingsStore.LoadForComponent(_componentId, _placementId);
_languageCode = _localizationService.NormalizeLanguageCode(appSettings.LanguageCode);
_componentColorScheme = componentSettings.ColorSchemeSource;
}
catch
{
_languageCode = _localizationService.NormalizeLanguageCode(_languageCode);
}
}
private void ApplyVisualState()
{
var drive = GetSelectedDrive();
var hasDrive = drive is not null;
var palette = ResolvePalette();
RootBorder.Background = CreateGradientBrush(palette.BackgroundFrom, palette.BackgroundTo);
RootBorder.BorderBrush = CreateBrush(palette.Border);
AccentOrb.Background = CreateBrush(palette.AccentOrb);
AccentGlow.Background = CreateBrush(palette.AccentGlow);
IconBadge.Background = CreateBrush(palette.IconBadgeBackground);
DriveIcon.Foreground = CreateBrush(palette.IconForeground);
DriveNameTextBlock.Foreground = CreateBrush(palette.PrimaryText);
DriveDetailTextBlock.Foreground = CreateBrush(palette.SecondaryText);
StatusTextBlock.Foreground = CreateBrush(palette.StatusText);
if (hasDrive)
{
ApplyButtonPalette(
OpenButton,
OpenButtonIcon,
OpenButtonTextBlock,
palette.Accent,
palette.OnAccent,
palette.Accent);
ApplyButtonPalette(
EjectButton,
EjectButtonIcon,
EjectButtonTextBlock,
palette.SecondaryButtonBackground,
palette.SecondaryButtonForeground,
palette.SecondaryButtonBorder);
}
else
{
ApplyButtonPalette(
OpenButton,
OpenButtonIcon,
OpenButtonTextBlock,
palette.DisabledButtonBackground,
palette.DisabledButtonForeground,
palette.DisabledButtonBorder);
ApplyButtonPalette(
EjectButton,
EjectButtonIcon,
EjectButtonTextBlock,
palette.DisabledButtonBackground,
palette.DisabledButtonForeground,
palette.DisabledButtonBorder);
}
OpenButton.IsEnabled = hasDrive;
EjectButton.IsEnabled = hasDrive;
OpenButtonTextBlock.Text = L("removable_storage.action.open", "Open");
EjectButtonTextBlock.Text = L("removable_storage.action.eject", "Eject");
if (hasDrive)
{
var selectedDrive = drive!;
DriveNameTextBlock.Text = ResolveDriveName(selectedDrive);
DriveDetailTextBlock.Text = selectedDrive.DriveLetter;
StatusTextBlock.Text = _statusOverrideText ??
L("removable_storage.widget.ready", "Ready to open or eject.");
}
else
{
DriveNameTextBlock.Text = L("removable_storage.widget.empty_title", "No device inserted");
DriveDetailTextBlock.Text = L("removable_storage.widget.empty_subtitle", "Insert a USB drive to show it here.");
StatusTextBlock.Text = L("removable_storage.widget.empty_hint", "Buttons stay disabled until a removable device is inserted.");
}
ApplyLayoutMetrics();
}
private void ApplyLayoutMetrics()
{
var scale = ResolveScale();
var width = Bounds.Width > 1 ? Bounds.Width : _currentCellSize * 2;
var cornerRadius = Math.Clamp(_currentCellSize * 0.44, 18, 34);
RootBorder.CornerRadius = new CornerRadius(cornerRadius);
RootBorder.Padding = new Thickness(
Math.Clamp(16 * scale, 10, 24),
Math.Clamp(15 * scale, 10, 22),
Math.Clamp(16 * scale, 10, 24),
Math.Clamp(15 * scale, 10, 22));
LayoutGrid.RowSpacing = Math.Clamp(10 * scale, 8, 16);
HeaderGrid.ColumnSpacing = Math.Clamp(12 * scale, 8, 16);
HeaderTextStack.Spacing = Math.Clamp(2 * scale, 1, 4);
var badgeSize = Math.Clamp(44 * scale, 38, 60);
IconBadge.Width = badgeSize;
IconBadge.Height = badgeSize;
IconBadge.CornerRadius = new CornerRadius(badgeSize * 0.5);
DriveIcon.FontSize = Math.Clamp(24 * scale, 20, 32);
DriveNameTextBlock.FontSize = Math.Clamp(16 * scale, 13, 24);
DriveDetailTextBlock.FontSize = Math.Clamp(11.5 * scale, 10, 16);
StatusTextBlock.FontSize = Math.Clamp(12 * scale, 10, 17);
StatusTextBlock.MaxWidth = Math.Max(96, width - (RootBorder.Padding.Left + RootBorder.Padding.Right));
var buttonHeight = Math.Clamp(42 * scale, 38, 54);
var buttonPadding = Math.Clamp(14 * scale, 10, 20);
var buttonCornerRadius = Math.Clamp(buttonHeight * 0.5, 18, 999);
OpenButton.Height = buttonHeight;
OpenButton.Padding = new Thickness(buttonPadding, 0);
OpenButton.CornerRadius = new CornerRadius(buttonCornerRadius);
EjectButton.Height = buttonHeight;
EjectButton.Padding = new Thickness(buttonPadding, 0);
EjectButton.CornerRadius = new CornerRadius(buttonCornerRadius);
OpenButtonIcon.FontSize = Math.Clamp(16 * scale, 14, 20);
EjectButtonIcon.FontSize = Math.Clamp(16 * scale, 14, 20);
OpenButtonTextBlock.FontSize = Math.Clamp(13 * scale, 11.5, 18);
EjectButtonTextBlock.FontSize = Math.Clamp(13 * scale, 11.5, 18);
AccentOrb.Width = Math.Clamp(width * 0.44, 96, 176);
AccentOrb.Height = AccentOrb.Width;
AccentOrb.CornerRadius = new CornerRadius(AccentOrb.Width * 0.5);
AccentGlow.Height = Math.Clamp(76 * scale, 52, 110);
AccentGlow.CornerRadius = new CornerRadius(AccentGlow.Height * 0.5);
}
private RemovableStorageDrive? GetSelectedDrive()
{
if (_connectedDrives.Count == 0)
{
return null;
}
if (!string.IsNullOrWhiteSpace(_selectedDriveRootPath))
{
var selected = _connectedDrives.FirstOrDefault(drive =>
string.Equals(drive.RootPath, _selectedDriveRootPath, StringComparison.OrdinalIgnoreCase));
if (selected is not null)
{
return selected;
}
}
return _connectedDrives[0];
}
private string ResolveDriveName(RemovableStorageDrive drive)
{
return string.IsNullOrWhiteSpace(drive.VolumeLabel)
? L("removable_storage.widget.default_name", "Removable Drive")
: drive.VolumeLabel.Trim();
}
private RemovableStoragePalette ResolvePalette()
{
var useMonetColor = ComponentColorSchemeHelper.ShouldUseMonetColor(
_componentColorScheme,
ComponentColorSchemeHelper.GetCurrentGlobalThemeColorMode());
if (!useMonetColor)
{
var nativeAccent = Color.Parse("#FF65A8FF");
var nativeBackgroundFrom = Color.Parse("#FF10345F");
var nativeBackgroundTo = Color.Parse("#FF0D213E");
var nativePrimaryText = Color.Parse("#FFF4F8FF");
var nativeSecondaryText = Color.Parse("#C8D9F5FF");
var nativeDisabled = Color.Parse("#30465D7A");
return new RemovableStoragePalette(
nativeBackgroundFrom,
nativeBackgroundTo,
Color.Parse("#6A97D6FF"),
Color.Parse("#2F8BC5FF"),
Color.Parse("#4C79BFFF"),
Color.Parse("#335BAAFF"),
Color.Parse("#FFF5FAFF"),
nativePrimaryText,
nativeSecondaryText,
Color.Parse("#D8E7FFFF"),
nativeAccent,
ColorMath.EnsureContrast(Color.Parse("#FF071420"), nativeAccent, 4.5),
Color.Parse("#24FFFFFF"),
Color.Parse("#5A9ACDFF"),
nativePrimaryText,
nativeDisabled,
Color.Parse("#4D6782A0"),
Color.Parse("#8FA8BDD1"));
}
var surfaceRaised = ResolveThemeColor("AdaptiveSurfaceRaisedBrush", "#FF1A2332");
var surfaceOverlay = ResolveThemeColor("AdaptiveSurfaceOverlayBrush", "#FF111827");
var accent = ResolveThemeColor("AdaptiveAccentBrush", "#FF61A8FF");
var onAccent = ResolveThemeColor("AdaptiveOnAccentBrush", "#FFFFFFFF");
var primaryText = ResolveThemeColor("AdaptiveTextPrimaryBrush", "#FFF8FAFC");
var secondaryText = ResolveThemeColor("AdaptiveTextSecondaryBrush", "#FFD0D7E3");
var mutedText = ResolveThemeColor("AdaptiveTextMutedBrush", "#FFAFB8C7");
var disabledButtonBackground = ColorMath.WithAlpha(ColorMath.Blend(surfaceRaised, surfaceOverlay, 0.35), 0xD8);
var disabledButtonBorder = ColorMath.WithAlpha(ColorMath.Blend(surfaceRaised, accent, 0.18), 0x88);
var disabledButtonForeground = ColorMath.WithAlpha(primaryText, 0x88);
var backgroundFrom = ColorMath.Blend(surfaceRaised, accent, 0.18);
var backgroundTo = ColorMath.Blend(surfaceOverlay, surfaceRaised, 0.46);
var border = ColorMath.WithAlpha(ColorMath.Blend(accent, surfaceRaised, 0.38), 0xB8);
var iconBadgeBackground = ColorMath.Blend(surfaceRaised, accent, 0.28);
var iconForeground = ColorMath.EnsureContrast(accent, iconBadgeBackground, 3.0);
var secondaryButtonBackground = ColorMath.WithAlpha(ColorMath.Blend(surfaceRaised, accent, 0.10), 0xE6);
var secondaryButtonBorder = ColorMath.WithAlpha(ColorMath.Blend(accent, surfaceRaised, 0.46), 0xC6);
return new RemovableStoragePalette(
backgroundFrom,
backgroundTo,
border,
ColorMath.WithAlpha(accent, 0x28),
ColorMath.WithAlpha(ColorMath.Blend(accent, backgroundFrom, 0.26), 0x74),
iconBadgeBackground,
iconForeground,
primaryText,
secondaryText,
mutedText,
accent,
onAccent,
secondaryButtonBackground,
secondaryButtonBorder,
primaryText,
disabledButtonBackground,
disabledButtonBorder,
disabledButtonForeground);
}
private Color ResolveThemeColor(string resourceKey, string fallbackHex)
{
if (this.TryFindResource(resourceKey, out var resource))
{
if (resource is ISolidColorBrush solidBrush)
{
return solidBrush.Color;
}
if (resource is SolidColorBrush directSolidBrush)
{
return directSolidBrush.Color;
}
}
return Color.Parse(fallbackHex);
}
private double ResolveScale()
{
var cellScale = Math.Clamp(_currentCellSize / 48d, 0.72, 2.2);
var widthScale = Bounds.Width > 1 ? Math.Clamp(Bounds.Width / 220d, 0.72, 2.4) : 1;
var heightScale = Bounds.Height > 1 ? Math.Clamp(Bounds.Height / 220d, 0.72, 2.4) : 1;
return Math.Clamp(Math.Min(cellScale, Math.Min(widthScale, heightScale)), 0.72, 2.2);
}
private string L(string key, string fallback)
{
return _localizationService.GetString(_languageCode, key, fallback);
}
private static void ApplyButtonPalette(
Button button,
FluentIcon icon,
TextBlock textBlock,
Color background,
Color foreground,
Color border)
{
button.Background = CreateBrush(background);
button.BorderBrush = CreateBrush(border);
button.BorderThickness = new Thickness(1);
button.Foreground = CreateBrush(foreground);
icon.Foreground = CreateBrush(foreground);
textBlock.Foreground = CreateBrush(foreground);
}
private static IBrush CreateGradientBrush(Color from, Color to)
{
return new LinearGradientBrush
{
StartPoint = new RelativePoint(0, 0, RelativeUnit.Relative),
EndPoint = new RelativePoint(1, 1, RelativeUnit.Relative),
GradientStops = new GradientStops
{
new GradientStop(from, 0),
new GradientStop(to, 1)
}
};
}
private static SolidColorBrush CreateBrush(Color color)
{
return new(color);
}
private void UpdatePollingState()
{
if (_isAttached && _isOnActivePage)
{
if (!_pollTimer.IsEnabled)
{
_pollTimer.Start();
}
return;
}
_pollTimer.Stop();
}
public void Dispose()
{
if (_isDisposed)
{
return;
}
_isDisposed = true;
_pollTimer.Stop();
_pollTimer.Tick -= OnPollTimerTick;
AttachedToVisualTree -= OnAttachedToVisualTree;
DetachedFromVisualTree -= OnDetachedFromVisualTree;
SizeChanged -= OnSizeChanged;
ActualThemeVariantChanged -= OnActualThemeVariantChanged;
}
}

View File

@@ -7,7 +7,7 @@
d:DesignHeight="220" d:DesignHeight="220"
x:Class="LanMountainDesktop.Views.Components.StudyDeductionReasonsWidget"> x:Class="LanMountainDesktop.Views.Components.StudyDeductionReasonsWidget">
<Border x:Name="RootBorder" <Border x:Name="RootBorder"
Classes="glass-strong" Classes="surface-translucent-strong"
CornerRadius="22" CornerRadius="22"
Padding="12,10" Padding="12,10"
ClipToBounds="True"> ClipToBounds="True">

View File

@@ -7,7 +7,7 @@
d:DesignHeight="220" d:DesignHeight="220"
x:Class="LanMountainDesktop.Views.Components.StudyInterruptDensityWidget"> x:Class="LanMountainDesktop.Views.Components.StudyInterruptDensityWidget">
<Border x:Name="RootBorder" <Border x:Name="RootBorder"
Classes="glass-strong" Classes="surface-translucent-strong"
CornerRadius="22" CornerRadius="22"
Padding="14,10" Padding="14,10"
ClipToBounds="True"> ClipToBounds="True">

View File

@@ -1,4 +1,4 @@
<UserControl xmlns="https://github.com/avaloniaui" <UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
@@ -8,7 +8,7 @@
d:DesignHeight="320" d:DesignHeight="320"
x:Class="LanMountainDesktop.Views.Components.StudyNoiseCurveWidget"> x:Class="LanMountainDesktop.Views.Components.StudyNoiseCurveWidget">
<Border x:Name="RootBorder" <Border x:Name="RootBorder"
Classes="glass-strong" Classes="surface-translucent-strong"
CornerRadius="24" CornerRadius="24"
Padding="14,10" Padding="14,10"
ClipToBounds="True"> ClipToBounds="True">

View File

@@ -8,7 +8,7 @@
d:DesignHeight="320" d:DesignHeight="320"
x:Class="LanMountainDesktop.Views.Components.StudyNoiseDistributionWidget"> x:Class="LanMountainDesktop.Views.Components.StudyNoiseDistributionWidget">
<Border x:Name="RootBorder" <Border x:Name="RootBorder"
Classes="glass-strong" Classes="surface-translucent-strong"
CornerRadius="24" CornerRadius="24"
Padding="14,10" Padding="14,10"
ClipToBounds="True"> ClipToBounds="True">

View File

@@ -7,7 +7,7 @@
d:DesignHeight="360" d:DesignHeight="360"
x:Class="LanMountainDesktop.Views.Components.StudyScoreOverviewWidget"> x:Class="LanMountainDesktop.Views.Components.StudyScoreOverviewWidget">
<Border x:Name="RootBorder" <Border x:Name="RootBorder"
Classes="glass-strong" Classes="surface-translucent-strong"
CornerRadius="24" CornerRadius="24"
Padding="16,14" Padding="16,14"
ClipToBounds="True"> ClipToBounds="True">

View File

@@ -8,7 +8,7 @@
d:DesignHeight="150" d:DesignHeight="150"
x:Class="LanMountainDesktop.Views.Components.StudySessionControlWidget"> x:Class="LanMountainDesktop.Views.Components.StudySessionControlWidget">
<Border x:Name="RootBorder" <Border x:Name="RootBorder"
Classes="glass-strong" Classes="surface-translucent-strong"
CornerRadius="18" CornerRadius="18"
Padding="14,10" Padding="14,10"
ClipToBounds="True"> ClipToBounds="True">

View File

@@ -7,7 +7,7 @@
d:DesignHeight="220" d:DesignHeight="220"
x:Class="LanMountainDesktop.Views.Components.StudySessionHistoryWidget"> x:Class="LanMountainDesktop.Views.Components.StudySessionHistoryWidget">
<Border x:Name="RootBorder" <Border x:Name="RootBorder"
Classes="glass-strong" Classes="surface-translucent-strong"
CornerRadius="22" CornerRadius="22"
Padding="12,10" Padding="12,10"
ClipToBounds="True"> ClipToBounds="True">

View File

@@ -1,4 +1,5 @@
using System; using System;
using System.Collections.Generic;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Reflection; using System.Reflection;
@@ -9,13 +10,18 @@ using Avalonia.Interactivity;
using Avalonia.Media; using Avalonia.Media;
using Avalonia.Platform.Storage; using Avalonia.Platform.Storage;
using Avalonia.Styling; using Avalonia.Styling;
using Avalonia.Threading;
using DotNetCampus.Inking; using DotNetCampus.Inking;
using DotNetCampus.Inking.Primitive;
using FluentIcons.Avalonia; using FluentIcons.Avalonia;
using LanMountainDesktop.ComponentSystem;
using LanMountainDesktop.Models;
using LanMountainDesktop.Services;
using SkiaSharp; using SkiaSharp;
namespace LanMountainDesktop.Views.Components; namespace LanMountainDesktop.Views.Components;
public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IComponentPlacementContextAware, IDisposable
{ {
private enum WhiteboardToolMode private enum WhiteboardToolMode
{ {
@@ -24,11 +30,22 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget
} }
private static readonly PropertyInfo? StrokeColorProperty = typeof(SkiaStroke).GetProperty(nameof(SkiaStroke.Color)); private static readonly PropertyInfo? StrokeColorProperty = typeof(SkiaStroke).GetProperty(nameof(SkiaStroke.Color));
private static readonly PropertyInfo? StrokePointListProperty = typeof(SkiaStroke).GetProperty("PointList");
private readonly int _baseWidthCells; private readonly int _baseWidthCells;
private readonly IComponentInstanceSettingsStore _componentSettingsStore = HostComponentSettingsStoreProvider.GetOrCreate();
private readonly IWhiteboardNotePersistenceService _notePersistenceService = new WhiteboardNotePersistenceService();
private readonly DispatcherTimer _noteSaveTimer = new() { Interval = TimeSpan.FromMinutes(5) };
private double _currentCellSize = 48; private double _currentCellSize = 48;
private WhiteboardToolMode _toolMode = WhiteboardToolMode.Pen; private WhiteboardToolMode _toolMode = WhiteboardToolMode.Pen;
private bool? _isNightModeApplied; private bool? _isNightModeApplied;
private SKColor _currentInkColor = SKColors.Black; private SKColor _currentInkColor = SKColors.Black;
private string _componentId = BuiltInComponentIds.DesktopWhiteboard;
private string _placementId = string.Empty;
private int _noteRetentionDays = WhiteboardNoteRetentionPolicy.DefaultDays;
private bool _isApplyingPersistedSnapshot;
private bool _noteDirty;
private int _noteLoadRevision;
private bool _disposed;
public WhiteboardWidget() public WhiteboardWidget()
: this(baseWidthCells: 2) : this(baseWidthCells: 2)
@@ -43,21 +60,26 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget
DetachedFromVisualTree += OnDetachedFromVisualTree; DetachedFromVisualTree += OnDetachedFromVisualTree;
SizeChanged += OnSizeChanged; SizeChanged += OnSizeChanged;
ActualThemeVariantChanged += OnActualThemeVariantChanged; ActualThemeVariantChanged += OnActualThemeVariantChanged;
_noteSaveTimer.Tick += OnNoteSaveTimerTick;
ConfigureInkCanvas(); ConfigureInkCanvas();
ApplyCellSize(_currentCellSize); ApplyCellSize(_currentCellSize);
RefreshFromSettings();
ApplyThemeVisual(force: true); ApplyThemeVisual(force: true);
SetToolMode(WhiteboardToolMode.Pen); SetToolMode(WhiteboardToolMode.Pen);
} }
public int NoteRetentionDays => _noteRetentionDays;
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e) private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{ {
ApplyThemeVisual(force: true); ApplyThemeVisual(force: true);
SchedulePersistedNoteLoad();
} }
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e) private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{ {
// Keep all state in-memory for lightweight re-attach scenarios. PersistNoteImmediately();
} }
private void OnSizeChanged(object? sender, SizeChangedEventArgs e) private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
@@ -79,6 +101,9 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget
settings.EraserSize = new Size(20, 20); settings.EraserSize = new Size(20, 20);
settings.IsBitmapCacheEnabled = true; settings.IsBitmapCacheEnabled = true;
settings.MaxBitmapCacheSize = 2048; settings.MaxBitmapCacheSize = 2048;
InkCanvas.StrokeCollected += OnInkCanvasStrokeCollected;
InkCanvas.PointerReleased += OnInkCanvasPointerReleased;
InkCanvas.PointerCaptureLost += OnInkCanvasPointerCaptureLost;
} }
public void ApplyCellSize(double cellSize) public void ApplyCellSize(double cellSize)
@@ -134,6 +159,63 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget
RefreshToolButtonVisuals(); RefreshToolButtonVisuals();
} }
public void SetComponentPlacementContext(string componentId, string? placementId)
{
var nextComponentId = string.IsNullOrWhiteSpace(componentId)
? BuiltInComponentIds.DesktopWhiteboard
: componentId.Trim();
var nextPlacementId = placementId?.Trim() ?? string.Empty;
if (_noteDirty &&
HasValidPersistenceContext() &&
(string.Compare(_componentId, nextComponentId, StringComparison.OrdinalIgnoreCase) != 0 ||
string.Compare(_placementId, nextPlacementId, StringComparison.OrdinalIgnoreCase) != 0))
{
PersistNoteImmediately();
}
_componentId = nextComponentId;
_placementId = nextPlacementId;
RefreshFromSettings();
ClearAllStrokes();
SchedulePersistedNoteLoad();
}
public void RefreshFromSettings()
{
try
{
if (!HasValidPersistenceContext())
{
_noteRetentionDays = WhiteboardNoteRetentionPolicy.DefaultDays;
return;
}
var snapshot = _componentSettingsStore.LoadForComponent(_componentId, _placementId);
_noteRetentionDays = NormalizeRetentionDays(snapshot.WhiteboardNoteRetentionDays);
_notePersistenceService.TryDeleteExpiredNote(_componentId, _placementId, _noteRetentionDays);
}
catch
{
_noteRetentionDays = WhiteboardNoteRetentionPolicy.DefaultDays;
}
}
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
_noteSaveTimer.Stop();
_noteSaveTimer.Tick -= OnNoteSaveTimerTick;
InkCanvas.StrokeCollected -= OnInkCanvasStrokeCollected;
InkCanvas.PointerReleased -= OnInkCanvasPointerReleased;
InkCanvas.PointerCaptureLost -= OnInkCanvasPointerCaptureLost;
}
private void RecolorAllStrokes(SKColor targetColor) private void RecolorAllStrokes(SKColor targetColor)
{ {
for (var i = 0; i < InkCanvas.Strokes.Count; i++) for (var i = 0; i < InkCanvas.Strokes.Count; i++)
@@ -183,6 +265,14 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget
return false; return false;
} }
private static int NormalizeRetentionDays(int days)
{
return WhiteboardNoteRetentionPolicy.NormalizeDays(
days <= 0
? WhiteboardNoteRetentionPolicy.DefaultDays
: days);
}
private static double CalculateRelativeLuminance(Color color) private static double CalculateRelativeLuminance(Color color)
{ {
static double ToLinear(double channel) static double ToLinear(double channel)
@@ -267,25 +357,8 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget
private void OnClearButtonClick(object? sender, RoutedEventArgs e) private void OnClearButtonClick(object? sender, RoutedEventArgs e)
{ {
var strokeList = InkCanvas.Strokes.ToList(); ClearAllStrokes();
foreach (var stroke in strokeList) QueueNoteSave();
{
try
{
if (ReferenceEquals(stroke.InkCanvas, InkCanvas.AvaloniaSkiaInkCanvas))
{
InkCanvas.AvaloniaSkiaInkCanvas.RemoveStaticStroke(stroke);
}
}
catch
{
// Keep the widget alive even if one stroke removal fails.
}
}
InkCanvas.AvaloniaSkiaInkCanvas.UseBitmapCache(false);
InkCanvas.AvaloniaSkiaInkCanvas.InvalidateBitmapCache();
InkCanvas.InvalidateVisual();
} }
private async void OnExportButtonClick(object? sender, RoutedEventArgs e) private async void OnExportButtonClick(object? sender, RoutedEventArgs e)
@@ -358,4 +431,273 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget
svgCanvas.Flush(); svgCanvas.Flush();
} }
private void OnInkCanvasStrokeCollected(object? sender, DotNetCampus.Inking.Contexts.AvaloniaSkiaInkCanvasStrokeCollectedEventArgs e)
{
_ = sender;
_ = e;
QueueNoteSave();
}
private void OnInkCanvasPointerReleased(object? sender, Avalonia.Input.PointerReleasedEventArgs e)
{
_ = sender;
_ = e;
QueueNoteSave();
}
private void OnInkCanvasPointerCaptureLost(object? sender, Avalonia.Input.PointerCaptureLostEventArgs e)
{
_ = sender;
_ = e;
QueueNoteSave();
}
private void OnNoteSaveTimerTick(object? sender, EventArgs e)
{
_ = sender;
if (_disposed || _isApplyingPersistedSnapshot || !HasValidPersistenceContext())
{
_noteSaveTimer.Stop();
return;
}
if (!_noteDirty)
{
_noteSaveTimer.Stop();
return;
}
var noteSnapshot = BuildNoteSnapshot();
var componentId = _componentId;
var placementId = _placementId;
var retentionDays = _noteRetentionDays;
_noteDirty = false;
_noteSaveTimer.Stop();
_ = Task.Run(() => _notePersistenceService.SaveNote(componentId, placementId, noteSnapshot, retentionDays));
}
private void QueueNoteSave()
{
if (_disposed || _isApplyingPersistedSnapshot || !HasValidPersistenceContext())
{
return;
}
_noteDirty = true;
if (!_noteSaveTimer.IsEnabled)
{
_noteSaveTimer.Start();
}
}
private void PersistNoteImmediately()
{
if (_disposed || _isApplyingPersistedSnapshot || !HasValidPersistenceContext())
{
return;
}
if (!_noteDirty)
{
return;
}
_noteDirty = false;
_noteSaveTimer.Stop();
var noteSnapshot = BuildNoteSnapshot();
var componentId = _componentId;
var placementId = _placementId;
var retentionDays = _noteRetentionDays;
_ = Task.Run(() => _notePersistenceService.SaveNote(
componentId,
placementId,
noteSnapshot,
retentionDays));
}
private async void SchedulePersistedNoteLoad()
{
if (!HasValidPersistenceContext())
{
return;
}
var revision = ++_noteLoadRevision;
var componentId = _componentId;
var placementId = _placementId;
var retentionDays = _noteRetentionDays;
try
{
var noteSnapshot = await Task.Run(() => _notePersistenceService.LoadNote(componentId, placementId, retentionDays));
if (_disposed || revision != _noteLoadRevision ||
!string.Equals(_componentId, componentId, StringComparison.OrdinalIgnoreCase) ||
!string.Equals(_placementId, placementId, StringComparison.OrdinalIgnoreCase))
{
return;
}
Dispatcher.UIThread.Post(() =>
{
if (_disposed || revision != _noteLoadRevision)
{
return;
}
_isApplyingPersistedSnapshot = true;
try
{
ClearAllStrokes();
ApplyNoteSnapshot(noteSnapshot);
RecolorAllStrokes(_currentInkColor);
}
finally
{
_isApplyingPersistedSnapshot = false;
}
});
}
catch
{
// Best effort only. Whiteboard should stay usable if persistence is unavailable.
}
}
private WhiteboardNoteSnapshot BuildNoteSnapshot()
{
return new WhiteboardNoteSnapshot
{
Strokes = InkCanvas.Strokes
.Select(BuildStrokeSnapshot)
.Where(static stroke => stroke.Points.Count > 0)
.ToList()
};
}
private static WhiteboardStrokeSnapshot BuildStrokeSnapshot(SkiaStroke stroke)
{
var pointList = TryGetStrokePoints(stroke);
return new WhiteboardStrokeSnapshot
{
Color = ToHexColor(stroke.Color),
InkThickness = stroke.InkThickness,
IgnorePressure = stroke.IgnorePressure,
Points = pointList
.Select(static point => new WhiteboardStylusPointSnapshot
{
X = point.X,
Y = point.Y,
Pressure = point.Pressure,
Width = point.Width ?? 0,
Height = point.Height ?? 0
})
.ToList()
};
}
private void ApplyNoteSnapshot(WhiteboardNoteSnapshot snapshot)
{
if (snapshot.Strokes.Count == 0)
{
return;
}
var renderer = InkCanvas.AvaloniaSkiaInkCanvas.Settings.InkStrokeRenderer;
foreach (var strokeSnapshot in snapshot.Strokes)
{
var stylusPoints = strokeSnapshot.Points
.Select(ConvertStylusPoint)
.ToList();
if (stylusPoints.Count == 0)
{
continue;
}
var path = renderer.RenderInkToPath(stylusPoints, strokeSnapshot.InkThickness);
var staticStroke = SkiaStroke.CreateStaticStroke(
InkId.NewId(),
path,
new StylusPointListSpan(stylusPoints, 0, stylusPoints.Count),
ParseStrokeColor(strokeSnapshot.Color),
(float)strokeSnapshot.InkThickness,
strokeSnapshot.IgnorePressure,
renderer);
InkCanvas.AvaloniaSkiaInkCanvas.AddStaticStroke(staticStroke);
}
InkCanvas.AvaloniaSkiaInkCanvas.UpdateBitmapCache();
InkCanvas.InvalidateVisual();
}
private static InkStylusPoint ConvertStylusPoint(WhiteboardStylusPointSnapshot point)
{
return new InkStylusPoint(point.X, point.Y, (float)Math.Clamp(point.Pressure, 0f, 1f))
{
Width = point.Width > 0 ? point.Width : null,
Height = point.Height > 0 ? point.Height : null
};
}
private static SKColor ParseStrokeColor(string? value)
{
if (!string.IsNullOrWhiteSpace(value))
{
try
{
var color = Color.Parse(value);
return new SKColor(color.R, color.G, color.B, color.A);
}
catch
{
// Fall through to the default color.
}
}
return SKColors.Black;
}
private static string ToHexColor(SKColor color)
{
return $"#{color.Alpha:X2}{color.Red:X2}{color.Green:X2}{color.Blue:X2}";
}
private void ClearAllStrokes()
{
var strokeList = InkCanvas.Strokes.ToList();
foreach (var stroke in strokeList)
{
try
{
if (ReferenceEquals(stroke.InkCanvas, InkCanvas.AvaloniaSkiaInkCanvas))
{
InkCanvas.AvaloniaSkiaInkCanvas.RemoveStaticStroke(stroke);
}
}
catch
{
// Keep the widget alive even if one stroke removal fails.
}
}
InkCanvas.AvaloniaSkiaInkCanvas.UseBitmapCache(false);
InkCanvas.AvaloniaSkiaInkCanvas.InvalidateBitmapCache();
InkCanvas.InvalidateVisual();
}
private bool HasValidPersistenceContext()
{
return !string.IsNullOrWhiteSpace(_componentId) &&
!string.IsNullOrWhiteSpace(_placementId);
}
private static IReadOnlyList<InkStylusPoint> TryGetStrokePoints(SkiaStroke stroke)
{
if (StrokePointListProperty?.GetValue(stroke) is IReadOnlyList<InkStylusPoint> pointList)
{
return pointList;
}
return Array.Empty<InkStylusPoint>();
}
} }

View File

@@ -398,10 +398,12 @@ public partial class MainWindow
_clockDisplayFormat = snapshot.ClockDisplayFormat == "HourMinute" _clockDisplayFormat = snapshot.ClockDisplayFormat == "HourMinute"
? ClockDisplayFormat.HourMinute ? ClockDisplayFormat.HourMinute
: ClockDisplayFormat.HourMinuteSecond; : ClockDisplayFormat.HourMinuteSecond;
_statusBarClockTransparentBackground = snapshot.StatusBarClockTransparentBackground;
if (ClockWidget is not null) if (ClockWidget is not null)
{ {
ClockWidget.SetDisplayFormat(_clockDisplayFormat); ClockWidget.SetDisplayFormat(_clockDisplayFormat);
ClockWidget.SetTransparentBackground(_statusBarClockTransparentBackground);
} }
} }
@@ -413,6 +415,7 @@ public partial class MainWindow
if (ClockWidget is not null) if (ClockWidget is not null)
{ {
ClockWidget.IsVisible = showClock; ClockWidget.IsVisible = showClock;
ClockWidget.SetTransparentBackground(_statusBarClockTransparentBackground);
if (showClock) if (showClock)
{ {
ClockWidget.SetDisplayFormat(_clockDisplayFormat); ClockWidget.SetDisplayFormat(_clockDisplayFormat);

View File

@@ -3,8 +3,9 @@ using System.Globalization;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Input;
using Avalonia;
using Avalonia.Interactivity; using Avalonia.Interactivity;
using Avalonia.Media; using Avalonia.Media;
using Avalonia.Media.Imaging; using Avalonia.Media.Imaging;
@@ -102,7 +103,11 @@ public partial class MainWindow
private void ApplyLocalization() private void ApplyLocalization()
{ {
Title = L("app.title", "LanMountainDesktop"); Title = L("app.title", "LanMountainDesktop");
BackToWindowsTextBlock.Text = L("button.back_to_windows", "Back to Windows"); var platformName = OperatingSystem.IsWindows() ? "Windows"
: OperatingSystem.IsMacOS() ? "macOS"
: "Linux";
BackToWindowsTextBlock.Text = Lf("button.back_to_platform", "Back to {0}", platformName);
ToolTip.SetTip(BackToWindowsButton, Lf("tooltip.back_to_platform", "Back to {0}", platformName));
ComponentLibraryTitleTextBlock.Text = L("component_library.title", "Widgets"); ComponentLibraryTitleTextBlock.Text = L("component_library.title", "Widgets");
LauncherTitleTextBlock.Text = L("launcher.title", "App Launcher"); LauncherTitleTextBlock.Text = L("launcher.title", "App Launcher");
LauncherSubtitleTextBlock.Text = OperatingSystem.IsLinux() LauncherSubtitleTextBlock.Text = OperatingSystem.IsLinux()
@@ -547,6 +552,7 @@ public partial class MainWindow
EnableDynamicTaskbarActions = _enableDynamicTaskbarActions, EnableDynamicTaskbarActions = _enableDynamicTaskbarActions,
TaskbarLayoutMode = _taskbarLayoutMode, TaskbarLayoutMode = _taskbarLayoutMode,
ClockDisplayFormat = _clockDisplayFormat == ClockDisplayFormat.HourMinute ? "HourMinute" : "HourMinuteSecond", ClockDisplayFormat = _clockDisplayFormat == ClockDisplayFormat.HourMinute ? "HourMinute" : "HourMinuteSecond",
StatusBarClockTransparentBackground = _statusBarClockTransparentBackground,
StatusBarSpacingMode = _statusBarSpacingMode, StatusBarSpacingMode = _statusBarSpacingMode,
StatusBarCustomSpacingPercent = _statusBarCustomSpacingPercent StatusBarCustomSpacingPercent = _statusBarCustomSpacingPercent
}; };

View File

@@ -1,4 +1,4 @@
<Window xmlns="https://github.com/avaloniaui" <Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:LanMountainDesktop.ViewModels" xmlns:vm="using:LanMountainDesktop.ViewModels"
xmlns:ui="using:FluentAvalonia.UI.Controls" xmlns:ui="using:FluentAvalonia.UI.Controls"
@@ -158,7 +158,7 @@
<Border x:Name="LauncherPagePanel" <Border x:Name="LauncherPagePanel"
Grid.Column="1" Grid.Column="1"
Classes="glass-panel" Classes="surface-translucent-panel"
ClipToBounds="False" ClipToBounds="False"
CornerRadius="36" CornerRadius="36"
Padding="18"> Padding="18">
@@ -166,11 +166,9 @@
<StackPanel Spacing="4"> <StackPanel Spacing="4">
<TextBlock x:Name="LauncherTitleTextBlock" <TextBlock x:Name="LauncherTitleTextBlock"
FontSize="24" FontSize="24"
FontWeight="SemiBold" FontWeight="SemiBold" />
Text="App Launcher" />
<TextBlock x:Name="LauncherSubtitleTextBlock" <TextBlock x:Name="LauncherSubtitleTextBlock"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" />
Text="Apps and folders from Windows Start Menu." />
</StackPanel> </StackPanel>
<Grid Grid.Row="1" <Grid Grid.Row="1"
@@ -188,7 +186,7 @@
Opacity="{DynamicResource AdaptiveGlassOverlayOpacity}" Opacity="{DynamicResource AdaptiveGlassOverlayOpacity}"
PointerPressed="OnLauncherFolderOverlayPointerPressed"> PointerPressed="OnLauncherFolderOverlayPointerPressed">
<Border x:Name="LauncherFolderPanel" <Border x:Name="LauncherFolderPanel"
Classes="mica-strong" Classes="surface-solid-strong"
HorizontalAlignment="Center" HorizontalAlignment="Center"
VerticalAlignment="Center" VerticalAlignment="Center"
Margin="52" Margin="52"
@@ -216,8 +214,7 @@
Grid.Column="1" Grid.Column="1"
HorizontalAlignment="Center" HorizontalAlignment="Center"
VerticalAlignment="Center" VerticalAlignment="Center"
FontWeight="SemiBold" FontWeight="SemiBold" />
Text="Folder" />
<Button x:Name="LauncherFolderCloseButton" <Button x:Name="LauncherFolderCloseButton"
Grid.Column="2" Grid.Column="2"
Width="38" Width="38"
@@ -266,7 +263,7 @@
</Border> </Border>
<Border x:Name="BottomTaskbarContainer" <Border x:Name="BottomTaskbarContainer"
Classes="glass-island" Classes="surface-translucent-island"
Grid.Row="0" Grid.Row="0"
Grid.Column="0" Grid.Column="0"
Grid.ColumnSpan="1" Grid.ColumnSpan="1"
@@ -290,8 +287,7 @@
Background="Transparent" Background="Transparent"
BorderThickness="0" BorderThickness="0"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
Click="OnMinimizeClick" Click="OnMinimizeClick">
ToolTip.Tip="&#22238;&#21040;Windows">
<StackPanel Orientation="Horizontal" <StackPanel Orientation="Horizontal"
HorizontalAlignment="Center" HorizontalAlignment="Center"
VerticalAlignment="Center" VerticalAlignment="Center"
@@ -300,8 +296,7 @@
Icon="Window" Icon="Window"
IconVariant="Regular" /> IconVariant="Regular" />
<TextBlock x:Name="BackToWindowsTextBlock" <TextBlock x:Name="BackToWindowsTextBlock"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
Text="&#22238;&#21040;Windows" />
</StackPanel> </StackPanel>
</Button> </Button>
</Grid> </Grid>
@@ -436,7 +431,7 @@
<Border x:Name="ComponentLibraryWindow" <Border x:Name="ComponentLibraryWindow"
IsVisible="False" IsVisible="False"
Opacity="0" Opacity="0"
Classes="glass-strong" Classes="surface-translucent-strong"
HorizontalAlignment="Center" HorizontalAlignment="Center"
VerticalAlignment="Bottom" VerticalAlignment="Bottom"
Width="620" Width="620"
@@ -481,7 +476,7 @@
</Grid> </Grid>
<Border Grid.Row="1" <Border Grid.Row="1"
Classes="glass-panel" Classes="surface-translucent-panel"
CornerRadius="12" CornerRadius="12"
Padding="14"> Padding="14">
<Grid> <Grid>

View File

@@ -131,6 +131,7 @@ public partial class MainWindow : Window, ISettingsWindowAnchorProvider
private string _gridSpacingPreset = "Relaxed"; private string _gridSpacingPreset = "Relaxed";
private string _statusBarSpacingMode = "Relaxed"; private string _statusBarSpacingMode = "Relaxed";
private int _statusBarCustomSpacingPercent = 12; private int _statusBarCustomSpacingPercent = 12;
private bool _statusBarClockTransparentBackground;
private int _desktopEdgeInsetPercent = DefaultEdgeInsetPercent; private int _desktopEdgeInsetPercent = DefaultEdgeInsetPercent;
private string _taskbarLayoutMode = TaskbarLayoutBottomFullRowMacStyle; private string _taskbarLayoutMode = TaskbarLayoutBottomFullRowMacStyle;
private string _languageCode = "zh-CN"; private string _languageCode = "zh-CN";

View File

@@ -38,6 +38,20 @@
</ComboBox> </ComboBox>
</Grid> </Grid>
</ui:SettingsExpanderItem> </ui:SettingsExpanderItem>
<ui:SettingsExpanderItem>
<Grid ColumnDefinitions="*,Auto"
ColumnSpacing="16">
<StackPanel Spacing="2">
<TextBlock Text="{Binding ClockTransparentBackgroundLabel}" />
<TextBlock Text="{Binding ClockTransparentBackgroundDescription}"
Opacity="0.75"
TextWrapping="Wrap" />
</StackPanel>
<ToggleSwitch Grid.Column="1"
IsChecked="{Binding ClockTransparentBackground}"
VerticalAlignment="Center" />
</Grid>
</ui:SettingsExpanderItem>
</ui:SettingsExpander> </ui:SettingsExpander>
<Separator Classes="settings-separator" /> <Separator Classes="settings-separator" />

View File

@@ -5,6 +5,7 @@
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<Version>1.0.0</Version>
</PropertyGroup> </PropertyGroup>
</Project> </Project>