diff --git a/LanMountainDesktop/ComponentSystem/BuiltInComponentIds.cs b/LanMountainDesktop/ComponentSystem/BuiltInComponentIds.cs index 80f6f8a..00f8639 100644 --- a/LanMountainDesktop/ComponentSystem/BuiltInComponentIds.cs +++ b/LanMountainDesktop/ComponentSystem/BuiltInComponentIds.cs @@ -32,6 +32,7 @@ public static class BuiltInComponentIds public const string DesktopDailyWord = "DesktopDailyWord"; public const string DesktopCnrDailyNews = "DesktopCnrDailyNews"; public const string DesktopBilibiliHotSearch = "DesktopBilibiliHotSearch"; + public const string DesktopStcn24Forum = "DesktopStcn24Forum"; public const string DesktopExchangeRateCalculator = "DesktopExchangeRateCalculator"; public const string DesktopWhiteboard = "DesktopWhiteboard"; public const string DesktopBlackboardLandscape = "DesktopBlackboardLandscape"; diff --git a/LanMountainDesktop/ComponentSystem/ComponentRegistry.cs b/LanMountainDesktop/ComponentSystem/ComponentRegistry.cs index 5a5a4a5..49af41f 100644 --- a/LanMountainDesktop/ComponentSystem/ComponentRegistry.cs +++ b/LanMountainDesktop/ComponentSystem/ComponentRegistry.cs @@ -252,6 +252,15 @@ public sealed class ComponentRegistry MinHeightCells: 2, AllowStatusBarPlacement: false, AllowDesktopPlacement: true), + new DesktopComponentDefinition( + BuiltInComponentIds.DesktopStcn24Forum, + "STCN 24", + "News", + "Info", + MinWidthCells: 4, + MinHeightCells: 4, + AllowStatusBarPlacement: false, + AllowDesktopPlacement: true), new DesktopComponentDefinition( BuiltInComponentIds.DesktopExchangeRateCalculator, "Exchange Rate Converter", diff --git a/LanMountainDesktop/Localization/en-US.json b/LanMountainDesktop/Localization/en-US.json index 62da323..de3972d 100644 --- a/LanMountainDesktop/Localization/en-US.json +++ b/LanMountainDesktop/Localization/en-US.json @@ -13,6 +13,7 @@ "settings.nav.weather": "Weather", "settings.nav.region": "Region", "settings.nav.update": "Update", + "settings.nav.launcher": "App Launcher", "settings.nav.about": "About", "settings.wallpaper.title": "Wallpaper", "settings.wallpaper.description": "Pick an image or video to apply as the app window wallpaper immediately.", @@ -252,6 +253,15 @@ "launcher.empty": "No Start Menu entries found.", "launcher.empty_folder": "This folder is empty.", "launcher.folder_items_format": "{0} apps", + "launcher.context.hide_icon": "Hide Icon", + "settings.launcher.title": "App Launcher", + "settings.launcher.hidden_header": "Hidden Items", + "settings.launcher.hidden_desc": "Review hidden launcher entries and show them again.", + "settings.launcher.hidden_hint": "Right-click an icon in launcher to hide it. Hidden entries appear here.", + "settings.launcher.hidden_empty": "No hidden items.", + "settings.launcher.hidden_type_folder": "Folder", + "settings.launcher.hidden_type_shortcut": "Shortcut", + "settings.launcher.restore_button": "Show Again", "button.component_library": "Edit Desktop", "tooltip.component_library": "Edit Desktop", "component_library.title": "Widgets", @@ -286,6 +296,7 @@ "component.daily_word": "Daily Word", "component.cnr_daily_news": "CNR Headlines", "component.bilibili_hot_search": "Bilibili Hot Search", + "component.stcn24_forum": "STCN 24", "component.exchange_rate_converter": "Exchange Rate Converter", "component.whiteboard": "Blackboard (Portrait)", "component.blackboard_landscape": "Blackboard (Landscape)", @@ -347,6 +358,10 @@ "bilihot.widget.fetch_failed": "Hot search fetch failed", "bilihot.widget.fallback_item": "No hot search data", "bilihot.widget.more_hot": "More hot search", + "stcn24.widget.loading": "Loading...", + "stcn24.widget.loading_item": "Loading...", + "stcn24.widget.fetch_failed": "Forum posts fetch failed", + "stcn24.widget.fallback_item": "No posts", "exchange.widget.loading": "Loading exchange rates...", "exchange.widget.fetch_failed": "Exchange rate fetch failed", "cnrnews.settings.title": "CNR Settings", diff --git a/LanMountainDesktop/Localization/zh-CN.json b/LanMountainDesktop/Localization/zh-CN.json index d86c52b..9ce94c7 100644 --- a/LanMountainDesktop/Localization/zh-CN.json +++ b/LanMountainDesktop/Localization/zh-CN.json @@ -13,6 +13,7 @@ "settings.nav.weather": "天气", "settings.nav.region": "地区", "settings.nav.update": "更新", + "settings.nav.launcher": "应用启动台", "settings.nav.about": "关于", "settings.wallpaper.title": "壁纸", "settings.wallpaper.description": "选择图片或视频后可立即设为应用窗口壁纸。", @@ -252,6 +253,15 @@ "launcher.empty": "未找到开始菜单条目。", "launcher.empty_folder": "此文件夹为空。", "launcher.folder_items_format": "{0} 个应用", + "launcher.context.hide_icon": "隐藏图标", + "settings.launcher.title": "应用启动台", + "settings.launcher.hidden_header": "已隐藏项目", + "settings.launcher.hidden_desc": "查看已隐藏的启动台项目并重新显示。", + "settings.launcher.hidden_hint": "在启动台中右键图标可隐藏,隐藏后的项目会显示在这里。", + "settings.launcher.hidden_empty": "暂无隐藏项目。", + "settings.launcher.hidden_type_folder": "文件夹", + "settings.launcher.hidden_type_shortcut": "快捷方式", + "settings.launcher.restore_button": "重新显示", "button.component_library": "桌面编辑", "tooltip.component_library": "桌面编辑", "component_library.title": "桌面编辑", @@ -286,6 +296,7 @@ "component.daily_word": "每日单词", "component.cnr_daily_news": "央广网头条", "component.bilibili_hot_search": "B站热搜", + "component.stcn24_forum": "STCN 24", "component.exchange_rate_converter": "汇率换算", "component.whiteboard": "竖向小黑板", "component.blackboard_landscape": "横向小黑板", @@ -347,6 +358,10 @@ "bilihot.widget.fetch_failed": "热搜获取失败", "bilihot.widget.fallback_item": "暂无热搜", "bilihot.widget.more_hot": "更多热搜", + "stcn24.widget.loading": "加载中...", + "stcn24.widget.loading_item": "加载中...", + "stcn24.widget.fetch_failed": "帖子获取失败", + "stcn24.widget.fallback_item": "暂无帖子", "exchange.widget.loading": "正在加载汇率...", "exchange.widget.fetch_failed": "汇率获取失败", "cnrnews.settings.title": "央广网设置", diff --git a/LanMountainDesktop/Models/AppSettingsSnapshot.cs b/LanMountainDesktop/Models/AppSettingsSnapshot.cs index 835d01e..fe686db 100644 --- a/LanMountainDesktop/Models/AppSettingsSnapshot.cs +++ b/LanMountainDesktop/Models/AppSettingsSnapshot.cs @@ -44,8 +44,6 @@ public sealed class AppSettingsSnapshot public bool WeatherNoTlsRequests { get; set; } - public string DailyArtworkMirrorSource { get; set; } = DailyArtworkMirrorSources.Overseas; - public bool AutoStartWithWindows { get; set; } public bool AutoCheckUpdates { get; set; } = true; @@ -78,29 +76,9 @@ public sealed class AppSettingsSnapshot public List DesktopComponentPlacements { get; set; } = []; - public List ImportedClassSchedules { get; set; } = []; + public List HiddenLauncherFolderPaths { get; set; } = []; - public string ActiveImportedClassScheduleId { get; set; } = string.Empty; - - public bool StudyEnvironmentShowDisplayDb { get; set; } = true; - - public bool StudyEnvironmentShowDbfs { get; set; } - - public string DesktopClockTimeZoneId { get; set; } = "China Standard Time"; - public string DesktopClockSecondHandMode { get; set; } = "Tick"; - - public List WorldClockTimeZoneIds { get; set; } = - [ - "China Standard Time", - "GMT Standard Time", - "AUS Eastern Standard Time", - "Eastern Standard Time" - ]; - public string WorldClockSecondHandMode { get; set; } = "Tick"; - - public bool CnrDailyNewsAutoRotateEnabled { get; set; } = true; - - public int CnrDailyNewsAutoRotateIntervalMinutes { get; set; } = 60; + public List HiddenLauncherAppPaths { get; set; } = []; public AppSettingsSnapshot Clone() { @@ -136,29 +114,11 @@ public sealed class AppSettingsSnapshot } } clone.DesktopComponentPlacements = placements; - - var schedules = new List(ImportedClassSchedules?.Count ?? 0); - if (ImportedClassSchedules is not null) - { - foreach (var schedule in ImportedClassSchedules) - { - if (schedule is null) - { - continue; - } - - schedules.Add(new ImportedClassScheduleSnapshot - { - Id = schedule.Id, - DisplayName = schedule.DisplayName, - FilePath = schedule.FilePath - }); - } - } - clone.ImportedClassSchedules = schedules; - - clone.WorldClockTimeZoneIds = WorldClockTimeZoneIds is { Count: > 0 } - ? new List(WorldClockTimeZoneIds) + clone.HiddenLauncherFolderPaths = HiddenLauncherFolderPaths is { Count: > 0 } + ? new List(HiddenLauncherFolderPaths) + : []; + clone.HiddenLauncherAppPaths = HiddenLauncherAppPaths is { Count: > 0 } + ? new List(HiddenLauncherAppPaths) : []; return clone; diff --git a/LanMountainDesktop/Models/ComponentSettingsSnapshot.cs b/LanMountainDesktop/Models/ComponentSettingsSnapshot.cs new file mode 100644 index 0000000..363fba5 --- /dev/null +++ b/LanMountainDesktop/Models/ComponentSettingsSnapshot.cs @@ -0,0 +1,73 @@ +using System.Collections.Generic; + +namespace LanMountainDesktop.Models; + +public sealed class ComponentSettingsSnapshot +{ + public string DailyArtworkMirrorSource { get; set; } = DailyArtworkMirrorSources.Overseas; + + public List ImportedClassSchedules { get; set; } = []; + + public string ActiveImportedClassScheduleId { get; set; } = string.Empty; + + public bool StudyEnvironmentShowDisplayDb { get; set; } = true; + + public bool StudyEnvironmentShowDbfs { get; set; } + + public string DesktopClockTimeZoneId { get; set; } = "China Standard Time"; + + public string DesktopClockSecondHandMode { get; set; } = "Tick"; + + public List WorldClockTimeZoneIds { get; set; } = + [ + "China Standard Time", + "GMT Standard Time", + "AUS Eastern Standard Time", + "Eastern Standard Time" + ]; + + public string WorldClockSecondHandMode { get; set; } = "Tick"; + + public bool CnrDailyNewsAutoRotateEnabled { get; set; } = true; + + public int CnrDailyNewsAutoRotateIntervalMinutes { get; set; } = 60; + + public bool DailyWordAutoRefreshEnabled { get; set; } = true; + + public int DailyWordAutoRefreshIntervalMinutes { get; set; } = 360; + + public bool BilibiliHotSearchAutoRefreshEnabled { get; set; } = true; + + public int BilibiliHotSearchAutoRefreshIntervalMinutes { get; set; } = 15; + + public ComponentSettingsSnapshot Clone() + { + var clone = (ComponentSettingsSnapshot)MemberwiseClone(); + + var schedules = new List(ImportedClassSchedules?.Count ?? 0); + if (ImportedClassSchedules is not null) + { + foreach (var schedule in ImportedClassSchedules) + { + if (schedule is null) + { + continue; + } + + schedules.Add(new ImportedClassScheduleSnapshot + { + Id = schedule.Id, + DisplayName = schedule.DisplayName, + FilePath = schedule.FilePath + }); + } + } + + clone.ImportedClassSchedules = schedules; + clone.WorldClockTimeZoneIds = WorldClockTimeZoneIds is { Count: > 0 } + ? new List(WorldClockTimeZoneIds) + : []; + + return clone; + } +} diff --git a/LanMountainDesktop/Models/RecommendationDataModels.cs b/LanMountainDesktop/Models/RecommendationDataModels.cs index 9506747..ab3c3ce 100644 --- a/LanMountainDesktop/Models/RecommendationDataModels.cs +++ b/LanMountainDesktop/Models/RecommendationDataModels.cs @@ -70,3 +70,16 @@ public sealed record ExchangeRateSnapshot( string TargetCurrency, decimal Rate, DateTimeOffset FetchedAt); + +public sealed record Stcn24ForumPostItemSnapshot( + string Title, + string Url, + string? AuthorDisplayName, + string? AuthorAvatarUrl, + DateTimeOffset? CreatedAt); + +public sealed record Stcn24ForumPostsSnapshot( + string Provider, + string Source, + IReadOnlyList Items, + DateTimeOffset FetchedAt); diff --git a/LanMountainDesktop/Services/ClassIslandScheduleDataService.cs b/LanMountainDesktop/Services/ClassIslandScheduleDataService.cs index 48c49c1..ea5a785 100644 --- a/LanMountainDesktop/Services/ClassIslandScheduleDataService.cs +++ b/LanMountainDesktop/Services/ClassIslandScheduleDataService.cs @@ -50,7 +50,7 @@ public sealed class ClassIslandScheduleDataService : IClassIslandScheduleDataSer { if (string.IsNullOrWhiteSpace(inputPath)) { - inputPath = ResolveImportedSchedulePathFromAppSettings(); + inputPath = ResolveImportedSchedulePathFromComponentSettings(); } var source = ResolveSource(inputPath, profileFileName, warnings); @@ -180,11 +180,11 @@ public sealed class ClassIslandScheduleDataService : IClassIslandScheduleDataSer return result; } - private static string? ResolveImportedSchedulePathFromAppSettings() + private static string? ResolveImportedSchedulePathFromComponentSettings() { try { - var snapshot = new AppSettingsService().Load(); + var snapshot = new ComponentSettingsService().Load(); if (snapshot.ImportedClassSchedules.Count == 0) { return null; diff --git a/LanMountainDesktop/Services/ComponentSettingsService.cs b/LanMountainDesktop/Services/ComponentSettingsService.cs new file mode 100644 index 0000000..14010d4 --- /dev/null +++ b/LanMountainDesktop/Services/ComponentSettingsService.cs @@ -0,0 +1,403 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using LanMountainDesktop.Models; + +namespace LanMountainDesktop.Services; + +public sealed class ComponentSettingsService +{ + private static readonly JsonSerializerOptions SerializerOptions = new() + { + WriteIndented = true + }; + + private static readonly object CacheGate = new(); + private static readonly TimeSpan CacheProbeInterval = TimeSpan.FromMilliseconds(400); + private static readonly int[] SupportedCnrIntervals = [5, 10, 40, 60, 720, 1440]; + private static readonly int[] SupportedDailyWordIntervals = [30, 60, 180, 360, 720, 1440]; + private static readonly int[] SupportedBilibiliHotSearchIntervals = [5, 10, 15, 30, 60, 180]; + + private static string? _cachedPath; + private static ComponentSettingsSnapshot? _cachedSnapshot; + private static DateTime _cachedWriteTimeUtc = DateTime.MinValue; + private static DateTime _lastProbeUtc = DateTime.MinValue; + + private readonly string _settingsPath; + private readonly string _legacyAppSettingsPath; + + public ComponentSettingsService() + { + var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + var settingsDirectory = Path.Combine(appData, "LanMountainDesktop"); + _settingsPath = Path.Combine(settingsDirectory, "component-settings.json"); + _legacyAppSettingsPath = Path.Combine(settingsDirectory, "settings.json"); + } + + public ComponentSettingsSnapshot Load() + { + try + { + lock (CacheGate) + { + var nowUtc = DateTime.UtcNow; + if (TryGetCachedWithoutProbe(nowUtc, out var cached)) + { + return cached; + } + + var hasFile = File.Exists(_settingsPath); + var writeTimeUtc = hasFile + ? File.GetLastWriteTimeUtc(_settingsPath) + : DateTime.MinValue; + + _lastProbeUtc = nowUtc; + if (TryGetCachedAfterProbe(writeTimeUtc, out cached)) + { + return cached; + } + + ComponentSettingsSnapshot loadedSnapshot; + var loadedFromLegacy = false; + if (hasFile) + { + loadedSnapshot = LoadSnapshotFromDisk(); + } + else if (TryLoadLegacySnapshot(out var migratedSnapshot)) + { + loadedSnapshot = migratedSnapshot; + loadedFromLegacy = true; + } + else + { + loadedSnapshot = new ComponentSettingsSnapshot(); + } + + var normalizedSnapshot = NormalizeSnapshot(loadedSnapshot); + if (loadedFromLegacy) + { + writeTimeUtc = PersistSnapshotToDisk(normalizedSnapshot); + } + + UpdateCache(normalizedSnapshot, writeTimeUtc, nowUtc); + return normalizedSnapshot.Clone(); + } + } + catch + { + return new ComponentSettingsSnapshot(); + } + } + + public void Save(ComponentSettingsSnapshot snapshot) + { + var snapshotToPersist = NormalizeSnapshot(snapshot); + + try + { + var writeTimeUtc = PersistSnapshotToDisk(snapshotToPersist); + + lock (CacheGate) + { + UpdateCache(snapshotToPersist, writeTimeUtc, DateTime.UtcNow); + } + } + catch + { + // Swallow persistence errors to keep UI interactions uninterrupted. + } + } + + private bool TryGetCachedWithoutProbe(DateTime nowUtc, out ComponentSettingsSnapshot snapshot) + { + if (string.Equals(_cachedPath, _settingsPath, StringComparison.Ordinal) && + _cachedSnapshot is not null && + nowUtc - _lastProbeUtc < CacheProbeInterval) + { + snapshot = _cachedSnapshot.Clone(); + return true; + } + + snapshot = null!; + return false; + } + + private bool TryGetCachedAfterProbe(DateTime writeTimeUtc, out ComponentSettingsSnapshot snapshot) + { + if (string.Equals(_cachedPath, _settingsPath, StringComparison.Ordinal) && + _cachedSnapshot is not null && + writeTimeUtc == _cachedWriteTimeUtc) + { + snapshot = _cachedSnapshot.Clone(); + return true; + } + + snapshot = null!; + return false; + } + + private ComponentSettingsSnapshot LoadSnapshotFromDisk() + { + try + { + var json = File.ReadAllText(_settingsPath); + var snapshot = JsonSerializer.Deserialize(json, SerializerOptions); + return NormalizeSnapshot(snapshot); + } + catch + { + return new ComponentSettingsSnapshot(); + } + } + + private bool TryLoadLegacySnapshot(out ComponentSettingsSnapshot snapshot) + { + snapshot = new ComponentSettingsSnapshot(); + try + { + if (!File.Exists(_legacyAppSettingsPath)) + { + return false; + } + + var legacyJson = File.ReadAllText(_legacyAppSettingsPath); + var legacy = JsonSerializer.Deserialize(legacyJson, SerializerOptions); + if (legacy is null) + { + return false; + } + + snapshot = new ComponentSettingsSnapshot + { + DailyArtworkMirrorSource = legacy.DailyArtworkMirrorSource, + ImportedClassSchedules = legacy.ImportedClassSchedules ?? [], + ActiveImportedClassScheduleId = legacy.ActiveImportedClassScheduleId ?? string.Empty, + StudyEnvironmentShowDisplayDb = legacy.StudyEnvironmentShowDisplayDb, + StudyEnvironmentShowDbfs = legacy.StudyEnvironmentShowDbfs, + DesktopClockTimeZoneId = legacy.DesktopClockTimeZoneId, + DesktopClockSecondHandMode = legacy.DesktopClockSecondHandMode, + WorldClockTimeZoneIds = legacy.WorldClockTimeZoneIds ?? [], + WorldClockSecondHandMode = legacy.WorldClockSecondHandMode, + CnrDailyNewsAutoRotateEnabled = legacy.CnrDailyNewsAutoRotateEnabled, + CnrDailyNewsAutoRotateIntervalMinutes = legacy.CnrDailyNewsAutoRotateIntervalMinutes, + DailyWordAutoRefreshEnabled = legacy.DailyWordAutoRefreshEnabled, + DailyWordAutoRefreshIntervalMinutes = legacy.DailyWordAutoRefreshIntervalMinutes, + BilibiliHotSearchAutoRefreshEnabled = legacy.BilibiliHotSearchAutoRefreshEnabled, + BilibiliHotSearchAutoRefreshIntervalMinutes = legacy.BilibiliHotSearchAutoRefreshIntervalMinutes + }; + + return true; + } + catch + { + return false; + } + } + + private DateTime PersistSnapshotToDisk(ComponentSettingsSnapshot snapshot) + { + var directory = Path.GetDirectoryName(_settingsPath); + if (!string.IsNullOrWhiteSpace(directory)) + { + Directory.CreateDirectory(directory); + } + + var json = JsonSerializer.Serialize(snapshot, SerializerOptions); + File.WriteAllText(_settingsPath, json); + + return File.Exists(_settingsPath) + ? File.GetLastWriteTimeUtc(_settingsPath) + : DateTime.UtcNow; + } + + private static ComponentSettingsSnapshot NormalizeSnapshot(ComponentSettingsSnapshot? snapshot) + { + var normalized = snapshot?.Clone() ?? new ComponentSettingsSnapshot(); + + normalized.DailyArtworkMirrorSource = DailyArtworkMirrorSources.Normalize(normalized.DailyArtworkMirrorSource); + normalized.ImportedClassSchedules = NormalizeImportedSchedules(normalized.ImportedClassSchedules); + normalized.ActiveImportedClassScheduleId = NormalizeActiveScheduleId( + normalized.ActiveImportedClassScheduleId, + normalized.ImportedClassSchedules); + + if (!normalized.StudyEnvironmentShowDisplayDb && !normalized.StudyEnvironmentShowDbfs) + { + normalized.StudyEnvironmentShowDisplayDb = true; + } + + normalized.DesktopClockTimeZoneId = NormalizeDesktopClockTimeZoneId(normalized.DesktopClockTimeZoneId); + normalized.DesktopClockSecondHandMode = ClockSecondHandMode.Normalize(normalized.DesktopClockSecondHandMode); + normalized.WorldClockTimeZoneIds = WorldClockTimeZoneCatalog + .NormalizeTimeZoneIds(normalized.WorldClockTimeZoneIds) + .ToList(); + normalized.WorldClockSecondHandMode = ClockSecondHandMode.Normalize(normalized.WorldClockSecondHandMode); + normalized.CnrDailyNewsAutoRotateIntervalMinutes = NormalizeCnrInterval(normalized.CnrDailyNewsAutoRotateIntervalMinutes); + normalized.DailyWordAutoRefreshIntervalMinutes = NormalizeDailyWordInterval(normalized.DailyWordAutoRefreshIntervalMinutes); + normalized.BilibiliHotSearchAutoRefreshIntervalMinutes = NormalizeBilibiliHotSearchInterval( + normalized.BilibiliHotSearchAutoRefreshIntervalMinutes); + + return normalized; + } + + private static List NormalizeImportedSchedules( + IReadOnlyList? schedules) + { + if (schedules is null || schedules.Count == 0) + { + return []; + } + + var result = new List(schedules.Count); + var seenIds = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var schedule in schedules) + { + if (schedule is null) + { + continue; + } + + var id = schedule.Id?.Trim() ?? string.Empty; + var filePath = schedule.FilePath?.Trim() ?? string.Empty; + if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(filePath)) + { + continue; + } + + if (!seenIds.Add(id)) + { + continue; + } + + result.Add(new ImportedClassScheduleSnapshot + { + Id = id, + DisplayName = schedule.DisplayName?.Trim() ?? string.Empty, + FilePath = filePath + }); + } + + return result; + } + + private static string NormalizeActiveScheduleId( + string? activeScheduleId, + IReadOnlyList schedules) + { + var activeId = activeScheduleId?.Trim() ?? string.Empty; + if (schedules.Count == 0) + { + return string.Empty; + } + + if (string.IsNullOrWhiteSpace(activeId)) + { + return schedules[0].Id; + } + + return schedules.Any(item => string.Equals(item.Id, activeId, StringComparison.OrdinalIgnoreCase)) + ? activeId + : schedules[0].Id; + } + + private static string NormalizeDesktopClockTimeZoneId(string? timeZoneId) + { + var normalizedId = string.IsNullOrWhiteSpace(timeZoneId) + ? "China Standard Time" + : timeZoneId.Trim(); + return WorldClockTimeZoneCatalog.ResolveTimeZoneOrLocal(normalizedId).Id; + } + + private static int NormalizeCnrInterval(int minutes) + { + if (minutes <= 0) + { + return 60; + } + + if (SupportedCnrIntervals.Contains(minutes)) + { + return minutes; + } + + return SupportedCnrIntervals + .OrderBy(value => Math.Abs(value - minutes)) + .FirstOrDefault(60); + } + + private static int NormalizeDailyWordInterval(int minutes) + { + if (minutes <= 0) + { + return 360; + } + + if (SupportedDailyWordIntervals.Contains(minutes)) + { + return minutes; + } + + return SupportedDailyWordIntervals + .OrderBy(value => Math.Abs(value - minutes)) + .FirstOrDefault(360); + } + + private static int NormalizeBilibiliHotSearchInterval(int minutes) + { + if (minutes <= 0) + { + return 15; + } + + if (SupportedBilibiliHotSearchIntervals.Contains(minutes)) + { + return minutes; + } + + return SupportedBilibiliHotSearchIntervals + .OrderBy(value => Math.Abs(value - minutes)) + .FirstOrDefault(15); + } + + private void UpdateCache(ComponentSettingsSnapshot snapshot, DateTime writeTimeUtc, DateTime probeTimeUtc) + { + _cachedPath = _settingsPath; + _cachedSnapshot = snapshot.Clone(); + _cachedWriteTimeUtc = writeTimeUtc; + _lastProbeUtc = probeTimeUtc; + } + + private sealed class LegacyComponentSettingsSnapshot + { + public string DailyArtworkMirrorSource { get; set; } = DailyArtworkMirrorSources.Overseas; + + public List? ImportedClassSchedules { get; set; } + + public string? ActiveImportedClassScheduleId { get; set; } + + public bool StudyEnvironmentShowDisplayDb { get; set; } = true; + + public bool StudyEnvironmentShowDbfs { get; set; } + + public string DesktopClockTimeZoneId { get; set; } = "China Standard Time"; + + public string DesktopClockSecondHandMode { get; set; } = "Tick"; + + public List? WorldClockTimeZoneIds { get; set; } + + public string WorldClockSecondHandMode { get; set; } = "Tick"; + + public bool CnrDailyNewsAutoRotateEnabled { get; set; } = true; + + public int CnrDailyNewsAutoRotateIntervalMinutes { get; set; } = 60; + + public bool DailyWordAutoRefreshEnabled { get; set; } = true; + + public int DailyWordAutoRefreshIntervalMinutes { get; set; } = 360; + + public bool BilibiliHotSearchAutoRefreshEnabled { get; set; } = true; + + public int BilibiliHotSearchAutoRefreshIntervalMinutes { get; set; } = 15; + } +} diff --git a/LanMountainDesktop/Services/IRecommendationDataService.cs b/LanMountainDesktop/Services/IRecommendationDataService.cs index ffbc917..742c2a2 100644 --- a/LanMountainDesktop/Services/IRecommendationDataService.cs +++ b/LanMountainDesktop/Services/IRecommendationDataService.cs @@ -29,6 +29,11 @@ public sealed record DailyWordQuery( string? Locale = null, bool ForceRefresh = false); +public sealed record Stcn24ForumPostsQuery( + string? Locale = null, + int? ItemCount = null, + bool ForceRefresh = false); + public sealed record ExchangeRateQuery( string? BaseCurrency = null, string? TargetCurrency = null, @@ -84,6 +89,13 @@ public sealed record RecommendationApiOptions public string BilibiliSearchPageUrl { get; init; } = "https://search.bilibili.com/all"; + public string SmartTeachForumApiTemplate { get; init; } = + "https://forum.smart-teach.cn/api/discussions?filter[q]={0}&sort=-createdAt&page[limit]={1}&include=user"; + + public string SmartTeachForumBaseUrl { get; init; } = "https://forum.smart-teach.cn"; + + public string SmartTeachStcnKeyword { get; init; } = "STCN"; + public string YoudaoDictionaryApiTemplate { get; init; } = "https://dict.youdao.com/jsonapi?q={0}"; public string YoudaoDictionaryWordPageTemplate { get; init; } = "https://dict.youdao.com/w/eng/{0}/"; @@ -226,6 +238,8 @@ public sealed record RecommendationApiOptions public int DefaultDailyNewsCount { get; init; } = 2; public int DefaultBilibiliHotSearchCount { get; init; } = 5; + + public int DefaultStcn24ForumPostCount { get; init; } = 4; } public interface IRecommendationInfoService @@ -250,6 +264,10 @@ public interface IRecommendationInfoService DailyWordQuery query, CancellationToken cancellationToken = default); + Task> GetStcn24ForumPostsAsync( + Stcn24ForumPostsQuery query, + CancellationToken cancellationToken = default); + Task> GetExchangeRateAsync( ExchangeRateQuery query, CancellationToken cancellationToken = default); diff --git a/LanMountainDesktop/Services/RecommendationDataService.cs b/LanMountainDesktop/Services/RecommendationDataService.cs index 523e28e..c18cb69 100644 --- a/LanMountainDesktop/Services/RecommendationDataService.cs +++ b/LanMountainDesktop/Services/RecommendationDataService.cs @@ -36,6 +36,7 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis private sealed record DailyNewsCacheEntry(DailyNewsSnapshot Snapshot, DateTimeOffset ExpireAt); private sealed record BilibiliHotSearchCacheEntry(BilibiliHotSearchSnapshot Snapshot, DateTimeOffset ExpireAt); private sealed record DailyWordCacheEntry(DailyWordSnapshot Snapshot, DateTimeOffset ExpireAt); + private sealed record Stcn24ForumPostsCacheEntry(Stcn24ForumPostsSnapshot Snapshot, DateTimeOffset ExpireAt); private sealed record ExchangeRateTableCacheEntry( string BaseCurrency, Dictionary Rates, @@ -52,7 +53,7 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis private readonly RecommendationApiOptions _options; private readonly HttpClient _httpClient; private readonly bool _ownsHttpClient; - private readonly AppSettingsService _appSettingsService = new(); + private readonly ComponentSettingsService _componentSettingsService = new(); private readonly object _cacheGate = new(); private readonly Dictionary _dailyArtworkCacheBySource = new(StringComparer.OrdinalIgnoreCase); @@ -60,6 +61,7 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis private DailyNewsCacheEntry? _dailyNewsCache; private BilibiliHotSearchCacheEntry? _bilibiliHotSearchCache; private DailyWordCacheEntry? _dailyWordCache; + private Stcn24ForumPostsCacheEntry? _stcn24ForumPostsCache; private readonly Dictionary _exchangeRateCacheByBaseCurrency = new(StringComparer.OrdinalIgnoreCase); private int _dailyNewsRotationCursor; @@ -106,6 +108,7 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis _dailyNewsCache = null; _bilibiliHotSearchCache = null; _dailyWordCache = null; + _stcn24ForumPostsCache = null; _exchangeRateCacheByBaseCurrency.Clear(); } } @@ -340,6 +343,53 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis lastError?.Message ?? "No available daily word from Youdao."); } + public async Task> GetStcn24ForumPostsAsync( + Stcn24ForumPostsQuery query, + CancellationToken cancellationToken = default) + { + var normalizedQuery = query ?? new Stcn24ForumPostsQuery(); + var targetCount = normalizedQuery.ItemCount.HasValue + ? Math.Clamp(normalizedQuery.ItemCount.Value, 1, 12) + : Math.Clamp(_options.DefaultStcn24ForumPostCount, 1, 12); + + if (!normalizedQuery.ForceRefresh && + TryGetStcn24ForumPostsFromCache(out var cached) && + cached.Items.Count >= targetCount) + { + var projectedSnapshot = cached with + { + Items = cached.Items.Take(targetCount).ToArray() + }; + return RecommendationQueryResult.Ok(projectedSnapshot); + } + + try + { + var snapshot = await FetchStcn24ForumPostsSnapshotAsync(targetCount, cancellationToken); + if (snapshot.Items.Count == 0) + { + return RecommendationQueryResult.Fail( + "upstream_empty_result", + "No STCN forum posts were returned."); + } + + SetStcn24ForumPostsCache(snapshot); + return RecommendationQueryResult.Ok(snapshot); + } + catch (OperationCanceledException) + { + throw; + } + catch (HttpRequestException ex) + { + return RecommendationQueryResult.Fail("upstream_network_error", ex.Message); + } + catch (Exception ex) + { + return RecommendationQueryResult.Fail("upstream_parse_error", ex.Message); + } + } + public async Task> GetExchangeRateAsync( ExchangeRateQuery query, CancellationToken cancellationToken = default) @@ -762,6 +812,141 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis FetchedAt: DateTimeOffset.UtcNow); } + private async Task FetchStcn24ForumPostsSnapshotAsync( + int targetCount, + CancellationToken cancellationToken) + { + var safeCount = Math.Clamp(targetCount, 1, 12); + var requestCount = Math.Clamp(Math.Max(safeCount * 3, 12), safeCount, 40); + var keyword = NormalizeInlineText(_options.SmartTeachStcnKeyword); + if (string.IsNullOrWhiteSpace(keyword)) + { + keyword = "STCN"; + } + + var requestUrl = string.Format( + CultureInfo.InvariantCulture, + _options.SmartTeachForumApiTemplate, + Uri.EscapeDataString(keyword), + requestCount); + + using var request = new HttpRequestMessage(HttpMethod.Get, requestUrl); + request.Headers.TryAddWithoutValidation("User-Agent", UserAgent); + request.Headers.TryAddWithoutValidation("Accept", "application/vnd.api+json, application/json;q=0.9, */*;q=0.8"); + + using var response = await _httpClient.SendAsync(request, cancellationToken); + var responseText = await response.Content.ReadAsStringAsync(cancellationToken); + if (!response.IsSuccessStatusCode) + { + throw new HttpRequestException($"HTTP {(int)response.StatusCode}: {Truncate(responseText, 180)}"); + } + + using var document = JsonDocument.Parse(responseText); + var root = document.RootElement; + if (!root.TryGetProperty("data", out var dataArray) || dataArray.ValueKind != JsonValueKind.Array) + { + throw new InvalidOperationException("Forum discussion list is missing."); + } + + var usersById = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (root.TryGetProperty("included", out var includedArray) && includedArray.ValueKind == JsonValueKind.Array) + { + foreach (var entity in includedArray.EnumerateArray()) + { + if (entity.ValueKind != JsonValueKind.Object) + { + continue; + } + + var entityType = ReadString(entity, "type"); + if (!string.Equals(entityType, "users", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var userId = ReadString(entity, "id"); + if (string.IsNullOrWhiteSpace(userId)) + { + continue; + } + + var displayName = NormalizeInlineText( + ReadString(entity, "attributes", "displayName") ?? + ReadString(entity, "attributes", "username")); + var avatarUrl = ResolveSmartTeachForumUrl( + ReadString(entity, "attributes", "avatarUrl"), + _options.SmartTeachForumBaseUrl); + usersById[userId.Trim()] = ( + string.IsNullOrWhiteSpace(displayName) ? null : displayName, + avatarUrl); + } + } + + var items = new List(safeCount); + foreach (var discussionNode in dataArray.EnumerateArray()) + { + if (discussionNode.ValueKind != JsonValueKind.Object || IsSmartTeachPinnedDiscussion(discussionNode)) + { + continue; + } + + var discussionId = ReadString(discussionNode, "id")?.Trim(); + if (string.IsNullOrWhiteSpace(discussionId)) + { + continue; + } + + var title = NormalizeInlineText(ReadString(discussionNode, "attributes", "title")); + if (string.IsNullOrWhiteSpace(title)) + { + continue; + } + + var slug = NormalizeInlineText(ReadString(discussionNode, "attributes", "slug")); + var shareUrl = ResolveSmartTeachForumUrl( + ReadString(discussionNode, "attributes", "shareUrl"), + _options.SmartTeachForumBaseUrl); + var targetUrl = !string.IsNullOrWhiteSpace(shareUrl) + ? shareUrl + : BuildSmartTeachDiscussionUrl(_options.SmartTeachForumBaseUrl, discussionId, slug); + if (string.IsNullOrWhiteSpace(targetUrl)) + { + continue; + } + + var authorId = ReadString(discussionNode, "relationships", "user", "data", "id"); + string? authorDisplayName = null; + string? authorAvatarUrl = null; + if (!string.IsNullOrWhiteSpace(authorId) && + usersById.TryGetValue(authorId.Trim(), out var userInfo)) + { + authorDisplayName = userInfo.DisplayName; + authorAvatarUrl = userInfo.AvatarUrl; + } + + var createdAtText = ReadString(discussionNode, "attributes", "createdAt"); + var createdAt = TryParseDateTimeOffset(createdAtText); + + items.Add(new Stcn24ForumPostItemSnapshot( + Title: title, + Url: targetUrl, + AuthorDisplayName: authorDisplayName, + AuthorAvatarUrl: authorAvatarUrl, + CreatedAt: createdAt)); + + if (items.Count >= safeCount) + { + break; + } + } + + return new Stcn24ForumPostsSnapshot( + Provider: "SmartTeachForum", + Source: "智教联盟论坛 STCN", + Items: items, + FetchedAt: DateTimeOffset.UtcNow); + } + private async Task TryFetchBilibiliSearchPlaceholderAsync(CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(_options.BilibiliSearchDefaultApiUrl)) @@ -864,6 +1049,31 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis return selection; } + private bool TryGetStcn24ForumPostsFromCache(out Stcn24ForumPostsSnapshot snapshot) + { + lock (_cacheGate) + { + if (_stcn24ForumPostsCache is not null && _stcn24ForumPostsCache.ExpireAt > DateTimeOffset.UtcNow) + { + snapshot = _stcn24ForumPostsCache.Snapshot; + return true; + } + } + + snapshot = null!; + return false; + } + + private void SetStcn24ForumPostsCache(Stcn24ForumPostsSnapshot snapshot) + { + lock (_cacheGate) + { + _stcn24ForumPostsCache = new Stcn24ForumPostsCacheEntry( + snapshot, + DateTimeOffset.UtcNow.Add(_options.CacheDuration)); + } + } + private bool TryGetDailyWordFromCache(out DailyWordSnapshot snapshot) { lock (_cacheGate) @@ -1942,6 +2152,84 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis : null; } + private static bool IsSmartTeachPinnedDiscussion(JsonElement discussionNode) + { + return ReadBoolean(discussionNode, "attributes", "isStickiest") || + ReadBoolean(discussionNode, "attributes", "isSticky") || + ReadBoolean(discussionNode, "attributes", "isTagSticky") || + ReadBoolean(discussionNode, "attributes", "front") || + ReadBoolean(discussionNode, "attributes", "frontpage"); + } + + private static string? ResolveSmartTeachForumUrl(string? rawUrl, string? baseUrl) + { + var normalizedAbsolute = NormalizeHttpUrl(rawUrl); + if (!string.IsNullOrWhiteSpace(normalizedAbsolute)) + { + return normalizedAbsolute; + } + + if (string.IsNullOrWhiteSpace(rawUrl) || string.IsNullOrWhiteSpace(baseUrl)) + { + return null; + } + + if (!Uri.TryCreate(baseUrl.Trim(), UriKind.Absolute, out var baseUri)) + { + return null; + } + + var normalized = ResolveAbsoluteUrl(rawUrl, baseUri); + return NormalizeHttpUrl(normalized); + } + + private static string? BuildSmartTeachDiscussionUrl(string? baseUrl, string discussionId, string slug) + { + if (string.IsNullOrWhiteSpace(baseUrl) || string.IsNullOrWhiteSpace(discussionId)) + { + return null; + } + + if (!Uri.TryCreate(baseUrl.Trim(), UriKind.Absolute, out var baseUri)) + { + return null; + } + + var normalizedId = discussionId.Trim(); + var normalizedSlug = slug.Trim(); + string path; + if (string.IsNullOrWhiteSpace(normalizedSlug)) + { + path = $"/d/{normalizedId}"; + } + else if (normalizedSlug.StartsWith($"{normalizedId}-", StringComparison.OrdinalIgnoreCase)) + { + path = $"/d/{normalizedSlug}"; + } + else + { + path = $"/d/{normalizedId}-{normalizedSlug}"; + } + + return new Uri(baseUri, path).ToString(); + } + + private static DateTimeOffset? TryParseDateTimeOffset(string? rawValue) + { + if (string.IsNullOrWhiteSpace(rawValue)) + { + return null; + } + + return DateTimeOffset.TryParse( + rawValue, + CultureInfo.InvariantCulture, + DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, + out var value) + ? value + : null; + } + private static string NormalizeInlineText(string? text) { if (string.IsNullOrWhiteSpace(text)) @@ -1972,6 +2260,30 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis }; } + private static bool ReadBoolean(JsonElement node, params string[] path) + { + var target = TryGetNode(node, path); + if (!target.HasValue) + { + return false; + } + + var value = target.Value; + switch (value.ValueKind) + { + case JsonValueKind.True: + return true; + case JsonValueKind.False: + return false; + case JsonValueKind.String: + return bool.TryParse(value.GetString(), out var boolValue) && boolValue; + case JsonValueKind.Number: + return value.TryGetInt32(out var intValue) && intValue != 0; + default: + return false; + } + } + private static JsonElement? TryGetNode(JsonElement node, params string[] path) { var current = node; @@ -2010,7 +2322,7 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis try { - var snapshot = _appSettingsService.Load(); + var snapshot = _componentSettingsService.Load(); return DailyArtworkMirrorSources.Normalize(snapshot.DailyArtworkMirrorSource); } catch diff --git a/LanMountainDesktop/Views/Components/AnalogClockWidget.axaml.cs b/LanMountainDesktop/Views/Components/AnalogClockWidget.axaml.cs index 1427f09..069a79d 100644 --- a/LanMountainDesktop/Views/Components/AnalogClockWidget.axaml.cs +++ b/LanMountainDesktop/Views/Components/AnalogClockWidget.axaml.cs @@ -55,7 +55,8 @@ public partial class AnalogClockWidget : UserControl, IDesktopComponentWidget, I private const double DialSize = 258; private const double Center = DialSize / 2; - private readonly AppSettingsService _settingsService = new(); + private readonly AppSettingsService _appSettingsService = new(); + private readonly ComponentSettingsService _componentSettingsService = new(); private readonly LocalizationService _localizationService = new(); private TimeZoneService? _timeZoneService; private double _currentCellSize = 48; @@ -357,15 +358,16 @@ public partial class AnalogClockWidget : UserControl, IDesktopComponentWidget, I private void LoadClockSettings() { - var snapshot = _settingsService.Load(); - _languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode); + var appSnapshot = _appSettingsService.Load(); + var componentSnapshot = _componentSettingsService.Load(); + _languageCode = _localizationService.NormalizeLanguageCode(appSnapshot.LanguageCode); - var configuredTimeZoneId = string.IsNullOrWhiteSpace(snapshot.DesktopClockTimeZoneId) + var configuredTimeZoneId = string.IsNullOrWhiteSpace(componentSnapshot.DesktopClockTimeZoneId) ? "China Standard Time" - : snapshot.DesktopClockTimeZoneId.Trim(); + : componentSnapshot.DesktopClockTimeZoneId.Trim(); _clockTimeZone = WorldClockTimeZoneCatalog.ResolveTimeZoneOrLocal(configuredTimeZoneId); - _secondHandMode = ClockSecondHandMode.Normalize(snapshot.DesktopClockSecondHandMode); + _secondHandMode = ClockSecondHandMode.Normalize(componentSnapshot.DesktopClockSecondHandMode); } private void ApplySecondHandTimerInterval() diff --git a/LanMountainDesktop/Views/Components/AnalogClockWidgetSettingsWindow.axaml.cs b/LanMountainDesktop/Views/Components/AnalogClockWidgetSettingsWindow.axaml.cs index 0547ee7..44b3f95 100644 --- a/LanMountainDesktop/Views/Components/AnalogClockWidgetSettingsWindow.axaml.cs +++ b/LanMountainDesktop/Views/Components/AnalogClockWidgetSettingsWindow.axaml.cs @@ -28,6 +28,7 @@ public partial class AnalogClockWidgetSettingsWindow : UserControl }; private readonly AppSettingsService _appSettingsService = new(); + private readonly ComponentSettingsService _componentSettingsService = new(); private readonly LocalizationService _localizationService = new(); private readonly TimeZoneService _timeZoneService = new(); private bool _suppressEvents; @@ -48,12 +49,13 @@ public partial class AnalogClockWidgetSettingsWindow : UserControl private void LoadState() { - var snapshot = _appSettingsService.Load(); - _languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode); - _selectedTimeZoneId = string.IsNullOrWhiteSpace(snapshot.DesktopClockTimeZoneId) + var appSnapshot = _appSettingsService.Load(); + var componentSnapshot = _componentSettingsService.Load(); + _languageCode = _localizationService.NormalizeLanguageCode(appSnapshot.LanguageCode); + _selectedTimeZoneId = string.IsNullOrWhiteSpace(componentSnapshot.DesktopClockTimeZoneId) ? "China Standard Time" - : snapshot.DesktopClockTimeZoneId.Trim(); - _secondHandMode = ClockSecondHandMode.Normalize(snapshot.DesktopClockSecondHandMode); + : componentSnapshot.DesktopClockTimeZoneId.Trim(); + _secondHandMode = ClockSecondHandMode.Normalize(componentSnapshot.DesktopClockSecondHandMode); _allTimeZones = _timeZoneService .GetAllTimeZones() @@ -147,10 +149,10 @@ public partial class AnalogClockWidgetSettingsWindow : UserControl _selectedTimeZoneId = normalizedId; _secondHandMode = GetSelectedSecondHandMode(); - var snapshot = _appSettingsService.Load(); + var snapshot = _componentSettingsService.Load(); snapshot.DesktopClockTimeZoneId = normalizedId; snapshot.DesktopClockSecondHandMode = _secondHandMode; - _appSettingsService.Save(snapshot); + _componentSettingsService.Save(snapshot); SettingsChanged?.Invoke(this, EventArgs.Empty); } diff --git a/LanMountainDesktop/Views/Components/BilibiliHotSearchSettingsWindow.axaml b/LanMountainDesktop/Views/Components/BilibiliHotSearchSettingsWindow.axaml new file mode 100644 index 0000000..902548b --- /dev/null +++ b/LanMountainDesktop/Views/Components/BilibiliHotSearchSettingsWindow.axaml @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/LanMountainDesktop/Views/Components/BilibiliHotSearchSettingsWindow.axaml.cs b/LanMountainDesktop/Views/Components/BilibiliHotSearchSettingsWindow.axaml.cs new file mode 100644 index 0000000..b7861b8 --- /dev/null +++ b/LanMountainDesktop/Views/Components/BilibiliHotSearchSettingsWindow.axaml.cs @@ -0,0 +1,139 @@ +using System; +using System.Linq; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Interactivity; +using LanMountainDesktop.Services; + +namespace LanMountainDesktop.Views.Components; + +public partial class BilibiliHotSearchSettingsWindow : UserControl +{ + private static readonly int[] SupportedIntervals = [5, 10, 15, 30, 60, 180]; + + private readonly AppSettingsService _appSettingsService = new(); + private readonly ComponentSettingsService _componentSettingsService = new(); + private readonly LocalizationService _localizationService = new(); + private bool _suppressEvents; + private string _languageCode = "zh-CN"; + + public event EventHandler? SettingsChanged; + + public BilibiliHotSearchSettingsWindow() + { + InitializeComponent(); + LoadState(); + ApplyLocalization(); + } + + private void LoadState() + { + var appSnapshot = _appSettingsService.Load(); + var componentSnapshot = _componentSettingsService.Load(); + _languageCode = _localizationService.NormalizeLanguageCode(appSnapshot.LanguageCode); + + var enabled = componentSnapshot.BilibiliHotSearchAutoRefreshEnabled; + var interval = NormalizeInterval(componentSnapshot.BilibiliHotSearchAutoRefreshIntervalMinutes); + + _suppressEvents = true; + AutoRefreshCheckBox.IsChecked = enabled; + SelectInterval(interval); + FrequencyCardBorder.IsVisible = enabled; + _suppressEvents = false; + } + + private void ApplyLocalization() + { + TitleTextBlock.Text = L("bilihot.settings.title", "Bilibili hot search settings"); + DescriptionTextBlock.Text = L("bilihot.settings.desc", "Configure auto refresh and refresh interval."); + AutoRefreshLabelTextBlock.Text = L("bilihot.settings.auto_refresh_label", "Auto refresh"); + AutoRefreshCheckBox.Content = L("bilihot.settings.auto_refresh_enabled", "Enable auto refresh"); + FrequencyLabelTextBlock.Text = L("bilihot.settings.frequency_label", "Refresh interval"); + Frequency5mItem.Content = L("bilihot.settings.frequency_5m", "5 min"); + Frequency10mItem.Content = L("bilihot.settings.frequency_10m", "10 min"); + Frequency15mItem.Content = L("bilihot.settings.frequency_15m", "15 min"); + Frequency30mItem.Content = L("bilihot.settings.frequency_30m", "30 min"); + Frequency1hItem.Content = L("bilihot.settings.frequency_1h", "1 hour"); + Frequency3hItem.Content = L("bilihot.settings.frequency_3h", "3 hours"); + } + + private void OnAutoRefreshChanged(object? sender, RoutedEventArgs e) + { + _ = sender; + _ = e; + if (_suppressEvents) + { + return; + } + + var enabled = AutoRefreshCheckBox.IsChecked == true; + FrequencyCardBorder.IsVisible = enabled; + SaveState(); + } + + private void OnFrequencySelectionChanged(object? sender, SelectionChangedEventArgs e) + { + _ = sender; + _ = e; + if (_suppressEvents) + { + return; + } + + SaveState(); + } + + private void SaveState() + { + var snapshot = _componentSettingsService.Load(); + snapshot.BilibiliHotSearchAutoRefreshEnabled = AutoRefreshCheckBox.IsChecked == true; + snapshot.BilibiliHotSearchAutoRefreshIntervalMinutes = GetSelectedInterval(); + _componentSettingsService.Save(snapshot); + SettingsChanged?.Invoke(this, EventArgs.Empty); + } + + private int GetSelectedInterval() + { + if (FrequencyComboBox.SelectedItem is ComboBoxItem item && + item.Tag is string tagText && + int.TryParse(tagText, out var minutes)) + { + return NormalizeInterval(minutes); + } + + return 15; + } + + private void SelectInterval(int intervalMinutes) + { + var selected = FrequencyComboBox.Items + .OfType() + .FirstOrDefault(item => + item.Tag is string tagText && + int.TryParse(tagText, out var minutes) && + minutes == intervalMinutes); + FrequencyComboBox.SelectedItem = selected ?? FrequencyComboBox.Items.OfType().FirstOrDefault(); + } + + private static int NormalizeInterval(int minutes) + { + if (minutes <= 0) + { + return 15; + } + + if (SupportedIntervals.Contains(minutes)) + { + return minutes; + } + + return SupportedIntervals + .OrderBy(value => Math.Abs(value - minutes)) + .FirstOrDefault(15); + } + + private string L(string key, string fallback) + { + return _localizationService.GetString(_languageCode, key, fallback); + } +} diff --git a/LanMountainDesktop/Views/Components/BilibiliHotSearchWidget.axaml.cs b/LanMountainDesktop/Views/Components/BilibiliHotSearchWidget.axaml.cs index 2cd867c..40dc992 100644 --- a/LanMountainDesktop/Views/Components/BilibiliHotSearchWidget.axaml.cs +++ b/LanMountainDesktop/Views/Components/BilibiliHotSearchWidget.axaml.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Diagnostics; +using System.Linq; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; @@ -24,13 +25,15 @@ public partial class BilibiliHotSearchWidget : UserControl, IDesktopComponentWid private const int BaseWidthCells = 4; private const int BaseHeightCells = 2; private const int MaxDisplayItemCount = 4; + private static readonly int[] SupportedAutoRefreshIntervalsMinutes = [5, 10, 15, 30, 60, 180]; private readonly DispatcherTimer _refreshTimer = new() { Interval = TimeSpan.FromMinutes(15) }; - private readonly AppSettingsService _settingsService = new(); + private readonly AppSettingsService _appSettingsService = new(); + private readonly ComponentSettingsService _componentSettingsService = new(); private readonly LocalizationService _localizationService = new(); private readonly List _activeItems = []; private readonly List _hotItemVisuals = []; @@ -42,6 +45,7 @@ public partial class BilibiliHotSearchWidget : UserControl, IDesktopComponentWid private double _currentCellSize = BaseCellSize; private bool _isAttached; private bool _isRefreshing; + private bool _autoRefreshEnabled = true; private sealed record HotItemVisual( Border Host, @@ -77,6 +81,7 @@ public partial class BilibiliHotSearchWidget : UserControl, IDesktopComponentWid ApplyCellSize(_currentCellSize); UpdateLanguageCode(); + ApplyAutoRefreshSettings(); ApplyLoadingState(); } @@ -98,6 +103,7 @@ public partial class BilibiliHotSearchWidget : UserControl, IDesktopComponentWid public void RefreshFromSettings() { _recommendationService.ClearCache(); + ApplyAutoRefreshSettings(); if (_isAttached) { _ = RefreshHotSearchAsync(forceRefresh: true); @@ -107,7 +113,7 @@ public partial class BilibiliHotSearchWidget : UserControl, IDesktopComponentWid private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e) { _isAttached = true; - _refreshTimer.Start(); + ApplyAutoRefreshSettings(); _ = RefreshHotSearchAsync(forceRefresh: false); } @@ -417,7 +423,7 @@ public partial class BilibiliHotSearchWidget : UserControl, IDesktopComponentWid { try { - var snapshot = _settingsService.Load(); + var snapshot = _appSettingsService.Load(); _languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode); } catch @@ -426,6 +432,60 @@ public partial class BilibiliHotSearchWidget : UserControl, IDesktopComponentWid } } + private void ApplyAutoRefreshSettings() + { + var enabled = true; + var intervalMinutes = 15; + + try + { + var snapshot = _componentSettingsService.Load(); + enabled = snapshot.BilibiliHotSearchAutoRefreshEnabled; + intervalMinutes = NormalizeAutoRefreshIntervalMinutes(snapshot.BilibiliHotSearchAutoRefreshIntervalMinutes); + } + catch + { + // Keep fallback defaults. + } + + _autoRefreshEnabled = enabled; + _refreshTimer.Interval = TimeSpan.FromMinutes(intervalMinutes); + + if (!_isAttached) + { + return; + } + + if (_autoRefreshEnabled) + { + if (!_refreshTimer.IsEnabled) + { + _refreshTimer.Start(); + } + } + else if (_refreshTimer.IsEnabled) + { + _refreshTimer.Stop(); + } + } + + private static int NormalizeAutoRefreshIntervalMinutes(int minutes) + { + if (minutes <= 0) + { + return 15; + } + + if (SupportedAutoRefreshIntervalsMinutes.Contains(minutes)) + { + return minutes; + } + + return SupportedAutoRefreshIntervalsMinutes + .OrderBy(value => Math.Abs(value - minutes)) + .FirstOrDefault(15); + } + private static string NormalizeCompactText(string? text) { if (string.IsNullOrWhiteSpace(text)) diff --git a/LanMountainDesktop/Views/Components/ClassScheduleSettingsWindow.axaml.cs b/LanMountainDesktop/Views/Components/ClassScheduleSettingsWindow.axaml.cs index 4c573c0..d72839a 100644 --- a/LanMountainDesktop/Views/Components/ClassScheduleSettingsWindow.axaml.cs +++ b/LanMountainDesktop/Views/Components/ClassScheduleSettingsWindow.axaml.cs @@ -18,6 +18,7 @@ namespace LanMountainDesktop.Views.Components; public partial class ClassScheduleSettingsWindow : UserControl { private readonly AppSettingsService _appSettingsService = new(); + private readonly ComponentSettingsService _componentSettingsService = new(); private readonly LocalizationService _localizationService = new(); private readonly List _importedSchedules = []; private string _activeScheduleId = string.Empty; @@ -35,11 +36,12 @@ public partial class ClassScheduleSettingsWindow : UserControl private void LoadState() { - var snapshot = _appSettingsService.Load(); - _languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode); + var appSnapshot = _appSettingsService.Load(); + var componentSnapshot = _componentSettingsService.Load(); + _languageCode = _localizationService.NormalizeLanguageCode(appSnapshot.LanguageCode); _importedSchedules.Clear(); - foreach (var item in snapshot.ImportedClassSchedules) + foreach (var item in componentSnapshot.ImportedClassSchedules) { if (string.IsNullOrWhiteSpace(item.Id) || string.IsNullOrWhiteSpace(item.FilePath)) @@ -55,7 +57,7 @@ public partial class ClassScheduleSettingsWindow : UserControl }); } - _activeScheduleId = snapshot.ActiveImportedClassScheduleId?.Trim() ?? string.Empty; + _activeScheduleId = componentSnapshot.ActiveImportedClassScheduleId?.Trim() ?? string.Empty; if (_importedSchedules.Count > 0 && !_importedSchedules.Any(item => string.Equals(item.Id, _activeScheduleId, StringComparison.OrdinalIgnoreCase))) { @@ -297,7 +299,7 @@ public partial class ClassScheduleSettingsWindow : UserControl private void SaveState() { - var snapshot = _appSettingsService.Load(); + var snapshot = _componentSettingsService.Load(); snapshot.ImportedClassSchedules = _importedSchedules .Select(item => new ImportedClassScheduleSnapshot { @@ -307,7 +309,7 @@ public partial class ClassScheduleSettingsWindow : UserControl }) .ToList(); snapshot.ActiveImportedClassScheduleId = _activeScheduleId ?? string.Empty; - _appSettingsService.Save(snapshot); + _componentSettingsService.Save(snapshot); SettingsChanged?.Invoke(this, EventArgs.Empty); } diff --git a/LanMountainDesktop/Views/Components/ClassScheduleWidget.axaml.cs b/LanMountainDesktop/Views/Components/ClassScheduleWidget.axaml.cs index 61add75..f319a45 100644 --- a/LanMountainDesktop/Views/Components/ClassScheduleWidget.axaml.cs +++ b/LanMountainDesktop/Views/Components/ClassScheduleWidget.axaml.cs @@ -26,6 +26,7 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget, }; private readonly AppSettingsService _appSettingsService = new(); + private readonly ComponentSettingsService _componentSettingsService = new(); private readonly LocalizationService _localizationService = new(); private readonly IClassIslandScheduleDataService _scheduleService = new ClassIslandScheduleDataService(); @@ -115,11 +116,12 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget, private void RefreshSchedule() { var appSettings = _appSettingsService.Load(); + var componentSettings = _componentSettingsService.Load(); _languageCode = _localizationService.NormalizeLanguageCode(appSettings.LanguageCode); var now = _timeZoneService?.GetCurrentTime() ?? DateTime.Now; UpdateHeader(now); - var importedSchedulePath = ResolveImportedSchedulePath(appSettings); + var importedSchedulePath = ResolveImportedSchedulePath(componentSettings); var readResult = _scheduleService.Load(importedSchedulePath); if (!readResult.Success || readResult.Snapshot is null) { @@ -273,7 +275,7 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget, return dayOfWeek.ToString()[..3]; } - private static string? ResolveImportedSchedulePath(AppSettingsSnapshot snapshot) + private static string? ResolveImportedSchedulePath(ComponentSettingsSnapshot snapshot) { if (snapshot.ImportedClassSchedules.Count == 0) { diff --git a/LanMountainDesktop/Views/Components/CnrDailyNewsSettingsWindow.axaml.cs b/LanMountainDesktop/Views/Components/CnrDailyNewsSettingsWindow.axaml.cs index 5b64fc2..dfc8b13 100644 --- a/LanMountainDesktop/Views/Components/CnrDailyNewsSettingsWindow.axaml.cs +++ b/LanMountainDesktop/Views/Components/CnrDailyNewsSettingsWindow.axaml.cs @@ -12,6 +12,7 @@ public partial class CnrDailyNewsSettingsWindow : UserControl private static readonly int[] SupportedIntervals = [5, 10, 40, 60, 720, 1440]; private readonly AppSettingsService _appSettingsService = new(); + private readonly ComponentSettingsService _componentSettingsService = new(); private readonly LocalizationService _localizationService = new(); private bool _suppressEvents; private string _languageCode = "zh-CN"; @@ -27,11 +28,12 @@ public partial class CnrDailyNewsSettingsWindow : UserControl private void LoadState() { - var snapshot = _appSettingsService.Load(); - _languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode); + var appSnapshot = _appSettingsService.Load(); + var componentSnapshot = _componentSettingsService.Load(); + _languageCode = _localizationService.NormalizeLanguageCode(appSnapshot.LanguageCode); - var enabled = snapshot.CnrDailyNewsAutoRotateEnabled; - var interval = NormalizeInterval(snapshot.CnrDailyNewsAutoRotateIntervalMinutes); + var enabled = componentSnapshot.CnrDailyNewsAutoRotateEnabled; + var interval = NormalizeInterval(componentSnapshot.CnrDailyNewsAutoRotateIntervalMinutes); _suppressEvents = true; AutoRotateCheckBox.IsChecked = enabled; @@ -83,10 +85,10 @@ public partial class CnrDailyNewsSettingsWindow : UserControl private void SaveState() { - var snapshot = _appSettingsService.Load(); + var snapshot = _componentSettingsService.Load(); snapshot.CnrDailyNewsAutoRotateEnabled = AutoRotateCheckBox.IsChecked == true; snapshot.CnrDailyNewsAutoRotateIntervalMinutes = GetSelectedInterval(); - _appSettingsService.Save(snapshot); + _componentSettingsService.Save(snapshot); SettingsChanged?.Invoke(this, EventArgs.Empty); } diff --git a/LanMountainDesktop/Views/Components/CnrDailyNewsWidget.axaml.cs b/LanMountainDesktop/Views/Components/CnrDailyNewsWidget.axaml.cs index 3ab1437..00f2018 100644 --- a/LanMountainDesktop/Views/Components/CnrDailyNewsWidget.axaml.cs +++ b/LanMountainDesktop/Views/Components/CnrDailyNewsWidget.axaml.cs @@ -43,7 +43,8 @@ public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget, Interval = TimeSpan.FromMinutes(30) }; - private readonly AppSettingsService _settingsService = new(); + private readonly AppSettingsService _appSettingsService = new(); + private readonly ComponentSettingsService _componentSettingsService = new(); private readonly LocalizationService _localizationService = new(); private readonly Bitmap?[] _newsBitmaps = new Bitmap?[2]; private readonly List _newsUrls = []; @@ -705,7 +706,7 @@ public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget, { try { - var snapshot = _settingsService.Load(); + var snapshot = _appSettingsService.Load(); _languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode); } catch @@ -721,7 +722,7 @@ public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget, try { - var snapshot = _settingsService.Load(); + var snapshot = _componentSettingsService.Load(); enabled = snapshot.CnrDailyNewsAutoRotateEnabled; intervalMinutes = NormalizeAutoRotateIntervalMinutes(snapshot.CnrDailyNewsAutoRotateIntervalMinutes); } diff --git a/LanMountainDesktop/Views/Components/DailyArtworkSettingsWindow.axaml.cs b/LanMountainDesktop/Views/Components/DailyArtworkSettingsWindow.axaml.cs index 695f5f8..ce9f14a 100644 --- a/LanMountainDesktop/Views/Components/DailyArtworkSettingsWindow.axaml.cs +++ b/LanMountainDesktop/Views/Components/DailyArtworkSettingsWindow.axaml.cs @@ -10,14 +10,13 @@ namespace LanMountainDesktop.Views.Components; public partial class DailyArtworkSettingsWindow : UserControl { private readonly AppSettingsService _appSettingsService = new(); + private readonly ComponentSettingsService _componentSettingsService = new(); private readonly LocalizationService _localizationService = new(); private string _languageCode = "zh-CN"; private bool _suppressEvents; public event EventHandler? SettingsChanged; - public string CurrentSource => GetSelectedSource(); - public DailyArtworkSettingsWindow() { InitializeComponent(); @@ -27,10 +26,11 @@ public partial class DailyArtworkSettingsWindow : UserControl private void LoadState() { - var snapshot = _appSettingsService.Load(); - _languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode); + var appSnapshot = _appSettingsService.Load(); + var componentSnapshot = _componentSettingsService.Load(); + _languageCode = _localizationService.NormalizeLanguageCode(appSnapshot.LanguageCode); - var source = DailyArtworkMirrorSources.Normalize(snapshot.DailyArtworkMirrorSource); + var source = DailyArtworkMirrorSources.Normalize(componentSnapshot.DailyArtworkMirrorSource); _suppressEvents = true; MirrorSourceComboBox.SelectedIndex = string.Equals(source, DailyArtworkMirrorSources.Domestic, StringComparison.OrdinalIgnoreCase) ? 0 @@ -59,9 +59,9 @@ public partial class DailyArtworkSettingsWindow : UserControl } var source = GetSelectedSource(); - var snapshot = _appSettingsService.Load(); + var snapshot = _componentSettingsService.Load(); snapshot.DailyArtworkMirrorSource = source; - _appSettingsService.Save(snapshot); + _componentSettingsService.Save(snapshot); UpdateSourceStatus(source); SettingsChanged?.Invoke(this, EventArgs.Empty); diff --git a/LanMountainDesktop/Views/Components/DailyWordSettingsWindow.axaml b/LanMountainDesktop/Views/Components/DailyWordSettingsWindow.axaml new file mode 100644 index 0000000..7fb9c55 --- /dev/null +++ b/LanMountainDesktop/Views/Components/DailyWordSettingsWindow.axaml @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/LanMountainDesktop/Views/Components/DailyWordSettingsWindow.axaml.cs b/LanMountainDesktop/Views/Components/DailyWordSettingsWindow.axaml.cs new file mode 100644 index 0000000..ec316d3 --- /dev/null +++ b/LanMountainDesktop/Views/Components/DailyWordSettingsWindow.axaml.cs @@ -0,0 +1,139 @@ +using System; +using System.Linq; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Interactivity; +using LanMountainDesktop.Services; + +namespace LanMountainDesktop.Views.Components; + +public partial class DailyWordSettingsWindow : UserControl +{ + private static readonly int[] SupportedIntervals = [30, 60, 180, 360, 720, 1440]; + + private readonly AppSettingsService _appSettingsService = new(); + private readonly ComponentSettingsService _componentSettingsService = new(); + private readonly LocalizationService _localizationService = new(); + private bool _suppressEvents; + private string _languageCode = "zh-CN"; + + public event EventHandler? SettingsChanged; + + public DailyWordSettingsWindow() + { + InitializeComponent(); + LoadState(); + ApplyLocalization(); + } + + private void LoadState() + { + var appSnapshot = _appSettingsService.Load(); + var componentSnapshot = _componentSettingsService.Load(); + _languageCode = _localizationService.NormalizeLanguageCode(appSnapshot.LanguageCode); + + var enabled = componentSnapshot.DailyWordAutoRefreshEnabled; + var interval = NormalizeInterval(componentSnapshot.DailyWordAutoRefreshIntervalMinutes); + + _suppressEvents = true; + AutoRefreshCheckBox.IsChecked = enabled; + SelectInterval(interval); + FrequencyCardBorder.IsVisible = enabled; + _suppressEvents = false; + } + + private void ApplyLocalization() + { + TitleTextBlock.Text = L("dailyword.settings.title", "Daily word settings"); + DescriptionTextBlock.Text = L("dailyword.settings.desc", "Configure auto refresh and refresh interval."); + AutoRefreshLabelTextBlock.Text = L("dailyword.settings.auto_refresh_label", "Auto refresh"); + AutoRefreshCheckBox.Content = L("dailyword.settings.auto_refresh_enabled", "Enable auto refresh"); + FrequencyLabelTextBlock.Text = L("dailyword.settings.frequency_label", "Refresh interval"); + Frequency30mItem.Content = L("dailyword.settings.frequency_30m", "30 min"); + Frequency1hItem.Content = L("dailyword.settings.frequency_1h", "1 hour"); + Frequency3hItem.Content = L("dailyword.settings.frequency_3h", "3 hours"); + Frequency6hItem.Content = L("dailyword.settings.frequency_6h", "6 hours"); + Frequency12hItem.Content = L("dailyword.settings.frequency_12h", "12 hours"); + Frequency24hItem.Content = L("dailyword.settings.frequency_24h", "24 hours"); + } + + private void OnAutoRefreshChanged(object? sender, RoutedEventArgs e) + { + _ = sender; + _ = e; + if (_suppressEvents) + { + return; + } + + var enabled = AutoRefreshCheckBox.IsChecked == true; + FrequencyCardBorder.IsVisible = enabled; + SaveState(); + } + + private void OnFrequencySelectionChanged(object? sender, SelectionChangedEventArgs e) + { + _ = sender; + _ = e; + if (_suppressEvents) + { + return; + } + + SaveState(); + } + + private void SaveState() + { + var snapshot = _componentSettingsService.Load(); + snapshot.DailyWordAutoRefreshEnabled = AutoRefreshCheckBox.IsChecked == true; + snapshot.DailyWordAutoRefreshIntervalMinutes = GetSelectedInterval(); + _componentSettingsService.Save(snapshot); + SettingsChanged?.Invoke(this, EventArgs.Empty); + } + + private int GetSelectedInterval() + { + if (FrequencyComboBox.SelectedItem is ComboBoxItem item && + item.Tag is string tagText && + int.TryParse(tagText, out var minutes)) + { + return NormalizeInterval(minutes); + } + + return 360; + } + + private void SelectInterval(int intervalMinutes) + { + var selected = FrequencyComboBox.Items + .OfType() + .FirstOrDefault(item => + item.Tag is string tagText && + int.TryParse(tagText, out var minutes) && + minutes == intervalMinutes); + FrequencyComboBox.SelectedItem = selected ?? FrequencyComboBox.Items.OfType().FirstOrDefault(); + } + + private static int NormalizeInterval(int minutes) + { + if (minutes <= 0) + { + return 360; + } + + if (SupportedIntervals.Contains(minutes)) + { + return minutes; + } + + return SupportedIntervals + .OrderBy(value => Math.Abs(value - minutes)) + .FirstOrDefault(360); + } + + private string L(string key, string fallback) + { + return _localizationService.GetString(_languageCode, key, fallback); + } +} diff --git a/LanMountainDesktop/Views/Components/DailyWordWidget.axaml.cs b/LanMountainDesktop/Views/Components/DailyWordWidget.axaml.cs index 568afda..bd799b6 100644 --- a/LanMountainDesktop/Views/Components/DailyWordWidget.axaml.cs +++ b/LanMountainDesktop/Views/Components/DailyWordWidget.axaml.cs @@ -1,4 +1,5 @@ using System; +using System.Linq; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; @@ -21,13 +22,15 @@ public partial class DailyWordWidget : UserControl, IDesktopComponentWidget, IRe private const double BaseCellSize = 48d; private const int BaseWidthCells = 4; private const int BaseHeightCells = 2; + private static readonly int[] SupportedAutoRefreshIntervalsMinutes = [30, 60, 180, 360, 720, 1440]; private readonly DispatcherTimer _refreshTimer = new() { Interval = TimeSpan.FromHours(6) }; - private readonly AppSettingsService _settingsService = new(); + private readonly AppSettingsService _appSettingsService = new(); + private readonly ComponentSettingsService _componentSettingsService = new(); private readonly LocalizationService _localizationService = new(); private IRecommendationInfoService _recommendationService = DefaultRecommendationService; @@ -36,6 +39,7 @@ public partial class DailyWordWidget : UserControl, IDesktopComponentWidget, IRe private double _currentCellSize = BaseCellSize; private bool _isAttached; private bool _isRefreshing; + private bool _autoRefreshEnabled = true; public DailyWordWidget() { @@ -56,6 +60,7 @@ public partial class DailyWordWidget : UserControl, IDesktopComponentWidget, IRe ApplyCellSize(_currentCellSize); UpdateLanguageCode(); + ApplyAutoRefreshSettings(); ApplyLoadingState(); UpdateRefreshButtonState(); } @@ -78,6 +83,7 @@ public partial class DailyWordWidget : UserControl, IDesktopComponentWidget, IRe public void RefreshFromSettings() { _recommendationService.ClearCache(); + ApplyAutoRefreshSettings(); if (_isAttached) { _ = RefreshWordAsync(forceRefresh: true); @@ -87,8 +93,8 @@ public partial class DailyWordWidget : UserControl, IDesktopComponentWidget, IRe private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e) { _isAttached = true; + ApplyAutoRefreshSettings(); UpdateRefreshButtonState(); - _refreshTimer.Start(); _ = RefreshWordAsync(forceRefresh: false); } @@ -343,7 +349,7 @@ public partial class DailyWordWidget : UserControl, IDesktopComponentWidget, IRe { try { - var snapshot = _settingsService.Load(); + var snapshot = _appSettingsService.Load(); _languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode); } catch @@ -352,6 +358,60 @@ public partial class DailyWordWidget : UserControl, IDesktopComponentWidget, IRe } } + private void ApplyAutoRefreshSettings() + { + var enabled = true; + var intervalMinutes = 360; + + try + { + var snapshot = _componentSettingsService.Load(); + enabled = snapshot.DailyWordAutoRefreshEnabled; + intervalMinutes = NormalizeAutoRefreshIntervalMinutes(snapshot.DailyWordAutoRefreshIntervalMinutes); + } + catch + { + // Keep fallback defaults. + } + + _autoRefreshEnabled = enabled; + _refreshTimer.Interval = TimeSpan.FromMinutes(intervalMinutes); + + if (!_isAttached) + { + return; + } + + if (_autoRefreshEnabled) + { + if (!_refreshTimer.IsEnabled) + { + _refreshTimer.Start(); + } + } + else if (_refreshTimer.IsEnabled) + { + _refreshTimer.Stop(); + } + } + + private static int NormalizeAutoRefreshIntervalMinutes(int minutes) + { + if (minutes <= 0) + { + return 360; + } + + if (SupportedAutoRefreshIntervalsMinutes.Contains(minutes)) + { + return minutes; + } + + return SupportedAutoRefreshIntervalsMinutes + .OrderBy(value => Math.Abs(value - minutes)) + .FirstOrDefault(360); + } + private void CancelRefreshRequest() { var cts = Interlocked.Exchange(ref _refreshCts, null); diff --git a/LanMountainDesktop/Views/Components/DesktopComponentRuntimeRegistry.cs b/LanMountainDesktop/Views/Components/DesktopComponentRuntimeRegistry.cs index c8d7a12..a4cdb76 100644 --- a/LanMountainDesktop/Views/Components/DesktopComponentRuntimeRegistry.cs +++ b/LanMountainDesktop/Views/Components/DesktopComponentRuntimeRegistry.cs @@ -250,6 +250,11 @@ public sealed class DesktopComponentRuntimeRegistry "component.bilibili_hot_search", () => new BilibiliHotSearchWidget(), cellSize => Math.Clamp(cellSize * 0.34, 14, 30)), + new DesktopComponentRuntimeRegistration( + BuiltInComponentIds.DesktopStcn24Forum, + "component.stcn24_forum", + () => new Stcn24ForumWidget(), + cellSize => Math.Clamp(cellSize * 0.28, 12, 24)), new DesktopComponentRuntimeRegistration( BuiltInComponentIds.DesktopExchangeRateCalculator, "component.exchange_rate_converter", diff --git a/LanMountainDesktop/Views/Components/Stcn24ForumWidget.axaml b/LanMountainDesktop/Views/Components/Stcn24ForumWidget.axaml new file mode 100644 index 0000000..88ae222 --- /dev/null +++ b/LanMountainDesktop/Views/Components/Stcn24ForumWidget.axaml @@ -0,0 +1,246 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/LanMountainDesktop/Views/Components/Stcn24ForumWidget.axaml.cs b/LanMountainDesktop/Views/Components/Stcn24ForumWidget.axaml.cs new file mode 100644 index 0000000..59e8b3e --- /dev/null +++ b/LanMountainDesktop/Views/Components/Stcn24ForumWidget.axaml.cs @@ -0,0 +1,618 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Media; +using Avalonia.Media.Imaging; +using Avalonia.Threading; +using LanMountainDesktop.Models; +using LanMountainDesktop.Services; + +namespace LanMountainDesktop.Views.Components; + +public partial class Stcn24ForumWidget : UserControl, IDesktopComponentWidget, IRecommendationInfoAwareComponentWidget +{ + private static readonly Regex MultiWhitespaceRegex = new(@"\s+", RegexOptions.Compiled); + private static readonly FontFamily MiSansFontFamily = new("MiSans VF, avares://LanMountainDesktop/Assets/Fonts#MiSans"); + private static readonly IRecommendationInfoService DefaultRecommendationService = new RecommendationDataService(); + private static readonly HttpClient AvatarHttpClient = new() + { + Timeout = TimeSpan.FromSeconds(8) + }; + + private const string AvatarRequestUserAgent = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0 Safari/537.36"; + + private const double BaseCellSize = 48d; + private const int BaseWidthCells = 4; + private const int BaseHeightCells = 4; + private const int MaxDisplayItemCount = 4; + + private readonly DispatcherTimer _refreshTimer = new() + { + Interval = TimeSpan.FromMinutes(20) + }; + + private readonly AppSettingsService _appSettingsService = new(); + private readonly LocalizationService _localizationService = new(); + private readonly List _activeItems = []; + private readonly List _itemVisuals = []; + private readonly Bitmap?[] _avatarBitmaps = new Bitmap?[MaxDisplayItemCount]; + + private IRecommendationInfoService _recommendationService = DefaultRecommendationService; + private CancellationTokenSource? _refreshCts; + private string _languageCode = "zh-CN"; + private double _currentCellSize = BaseCellSize; + private bool _isAttached; + private bool _isRefreshing; + + private sealed record ForumItemVisual( + Border Host, + Grid RowGrid, + Border AvatarHost, + Image AvatarImage, + TextBlock AvatarFallbackText, + TextBlock TitleTextBlock); + + public Stcn24ForumWidget() + { + InitializeComponent(); + + HeaderTitleTextBlock.FontFamily = MiSansFontFamily; + PostItem1TitleTextBlock.FontFamily = MiSansFontFamily; + PostItem2TitleTextBlock.FontFamily = MiSansFontFamily; + PostItem3TitleTextBlock.FontFamily = MiSansFontFamily; + PostItem4TitleTextBlock.FontFamily = MiSansFontFamily; + PostItem1AvatarFallbackText.FontFamily = MiSansFontFamily; + PostItem2AvatarFallbackText.FontFamily = MiSansFontFamily; + PostItem3AvatarFallbackText.FontFamily = MiSansFontFamily; + PostItem4AvatarFallbackText.FontFamily = MiSansFontFamily; + StatusTextBlock.FontFamily = MiSansFontFamily; + + _itemVisuals.Add(new ForumItemVisual( + PostItem1Host, + PostItem1Grid, + PostItem1AvatarHost, + PostItem1AvatarImage, + PostItem1AvatarFallbackText, + PostItem1TitleTextBlock)); + _itemVisuals.Add(new ForumItemVisual( + PostItem2Host, + PostItem2Grid, + PostItem2AvatarHost, + PostItem2AvatarImage, + PostItem2AvatarFallbackText, + PostItem2TitleTextBlock)); + _itemVisuals.Add(new ForumItemVisual( + PostItem3Host, + PostItem3Grid, + PostItem3AvatarHost, + PostItem3AvatarImage, + PostItem3AvatarFallbackText, + PostItem3TitleTextBlock)); + _itemVisuals.Add(new ForumItemVisual( + PostItem4Host, + PostItem4Grid, + PostItem4AvatarHost, + PostItem4AvatarImage, + PostItem4AvatarFallbackText, + PostItem4TitleTextBlock)); + + _refreshTimer.Tick += OnRefreshTimerTick; + AttachedToVisualTree += OnAttachedToVisualTree; + DetachedFromVisualTree += OnDetachedFromVisualTree; + SizeChanged += OnSizeChanged; + + ApplyCellSize(_currentCellSize); + UpdateLanguageCode(); + ApplyLoadingState(); + UpdateInteractionState(); + UpdateRefreshButtonState(); + } + + public void ApplyCellSize(double cellSize) + { + _currentCellSize = Math.Max(1, cellSize); + UpdateAdaptiveLayout(); + } + + public void SetRecommendationInfoService(IRecommendationInfoService recommendationInfoService) + { + _recommendationService = recommendationInfoService ?? DefaultRecommendationService; + if (_isAttached) + { + _ = RefreshPostsAsync(forceRefresh: false); + } + } + + private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e) + { + _isAttached = true; + if (!_refreshTimer.IsEnabled) + { + _refreshTimer.Start(); + } + + _ = RefreshPostsAsync(forceRefresh: false); + } + + private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e) + { + _isAttached = false; + _refreshTimer.Stop(); + CancelRefreshRequest(); + DisposeAvatarBitmaps(); + } + + private void OnSizeChanged(object? sender, SizeChangedEventArgs e) + { + ApplyCellSize(_currentCellSize); + } + + private async void OnRefreshButtonClick(object? sender, RoutedEventArgs e) + { + if (_isRefreshing) + { + return; + } + + await RefreshPostsAsync(forceRefresh: true); + e.Handled = true; + } + + private async void OnRefreshTimerTick(object? sender, EventArgs e) + { + await RefreshPostsAsync(forceRefresh: false); + } + + private void OnPostItemPointerPressed(object? sender, PointerPressedEventArgs e) + { + if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed || + sender is not Border host || + host.Tag is null || + !int.TryParse(host.Tag.ToString(), out var index) || + index < 0 || + index >= _activeItems.Count) + { + return; + } + + TryOpenUrl(_activeItems[index].Url); + e.Handled = true; + } + + private async Task RefreshPostsAsync(bool forceRefresh) + { + if (!_isAttached || _isRefreshing) + { + return; + } + + _isRefreshing = true; + UpdateRefreshButtonState(); + UpdateLanguageCode(); + + var cts = new CancellationTokenSource(); + var previous = Interlocked.Exchange(ref _refreshCts, cts); + previous?.Cancel(); + previous?.Dispose(); + + try + { + var query = new Stcn24ForumPostsQuery( + Locale: _languageCode, + ItemCount: MaxDisplayItemCount, + ForceRefresh: forceRefresh); + var result = await _recommendationService.GetStcn24ForumPostsAsync(query, cts.Token); + if (!_isAttached || cts.IsCancellationRequested) + { + return; + } + + if (!result.Success || result.Data is null) + { + ApplyFailedState(); + return; + } + + await ApplySnapshotAsync(result.Data, cts.Token); + } + catch (OperationCanceledException) + { + // Ignore canceled requests. + } + catch + { + if (_isAttached && !cts.IsCancellationRequested) + { + ApplyFailedState(); + } + } + finally + { + if (ReferenceEquals(_refreshCts, cts)) + { + _refreshCts = null; + } + + cts.Dispose(); + _isRefreshing = false; + UpdateRefreshButtonState(); + } + } + + private async Task ApplySnapshotAsync(Stcn24ForumPostsSnapshot snapshot, CancellationToken cancellationToken) + { + _activeItems.Clear(); + foreach (var item in snapshot.Items) + { + if (string.IsNullOrWhiteSpace(item.Title) || string.IsNullOrWhiteSpace(item.Url)) + { + continue; + } + + _activeItems.Add(item); + if (_activeItems.Count >= MaxDisplayItemCount) + { + break; + } + } + + var fallbackItemText = L("stcn24.widget.fallback_item", "暂无帖子"); + for (var i = 0; i < _itemVisuals.Count; i++) + { + var visual = _itemVisuals[i]; + if (i < _activeItems.Count) + { + var item = _activeItems[i]; + visual.TitleTextBlock.Text = NormalizeCompactText(item.Title); + visual.AvatarFallbackText.Text = ResolveAvatarFallbackText(item.AuthorDisplayName); + } + else + { + visual.TitleTextBlock.Text = fallbackItemText; + visual.AvatarFallbackText.Text = "?"; + } + + SetAvatarBitmap(i, null); + } + + StatusTextBlock.IsVisible = false; + UpdateInteractionState(); + UpdateAdaptiveLayout(); + + var tasks = _activeItems + .Select(item => TryDownloadAvatarBitmapAsync(item.AuthorAvatarUrl, cancellationToken)) + .ToArray(); + if (tasks.Length == 0) + { + return; + } + + var bitmaps = await Task.WhenAll(tasks); + if (cancellationToken.IsCancellationRequested || !_isAttached) + { + foreach (var bitmap in bitmaps) + { + bitmap?.Dispose(); + } + + return; + } + + for (var i = 0; i < bitmaps.Length && i < _itemVisuals.Count; i++) + { + SetAvatarBitmap(i, bitmaps[i]); + } + } + + private void ApplyLoadingState() + { + _activeItems.Clear(); + StatusTextBlock.Text = L("stcn24.widget.loading", "加载中..."); + StatusTextBlock.IsVisible = true; + + var loadingText = L("stcn24.widget.loading_item", "加载中..."); + for (var i = 0; i < _itemVisuals.Count; i++) + { + var visual = _itemVisuals[i]; + visual.TitleTextBlock.Text = loadingText; + visual.AvatarFallbackText.Text = "?"; + SetAvatarBitmap(i, null); + } + + UpdateInteractionState(); + UpdateAdaptiveLayout(); + } + + private void ApplyFailedState() + { + _activeItems.Clear(); + StatusTextBlock.Text = L("stcn24.widget.fetch_failed", "帖子获取失败"); + StatusTextBlock.IsVisible = true; + + var fallbackText = L("stcn24.widget.fallback_item", "暂无帖子"); + for (var i = 0; i < _itemVisuals.Count; i++) + { + var visual = _itemVisuals[i]; + visual.TitleTextBlock.Text = fallbackText; + visual.AvatarFallbackText.Text = "?"; + SetAvatarBitmap(i, null); + } + + UpdateInteractionState(); + UpdateAdaptiveLayout(); + } + + private void UpdateInteractionState() + { + var enabledBackground = new SolidColorBrush(Color.Parse("#F7F8FA")); + var disabledBackground = new SolidColorBrush(Color.Parse("#F2F3F5")); + + for (var i = 0; i < _itemVisuals.Count; i++) + { + var visual = _itemVisuals[i]; + var enabled = i < _activeItems.Count && !string.IsNullOrWhiteSpace(_activeItems[i].Url); + visual.Host.IsHitTestVisible = enabled; + visual.Host.Opacity = enabled ? 1.0 : 0.72; + visual.Host.Cursor = enabled + ? new Cursor(StandardCursorType.Hand) + : new Cursor(StandardCursorType.Arrow); + visual.Host.Background = enabled + ? enabledBackground + : disabledBackground; + } + } + + private void UpdateRefreshButtonState() + { + RefreshButton.IsEnabled = !_isRefreshing; + RefreshButton.Opacity = _isRefreshing ? 0.58 : 1.0; + } + + private void UpdateLanguageCode() + { + try + { + var snapshot = _appSettingsService.Load(); + _languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode); + } + catch + { + _languageCode = "zh-CN"; + } + } + + private void UpdateAdaptiveLayout() + { + var scale = ResolveScale(); + var softScale = Math.Clamp(scale, 0.80, 1.40); + var totalWidth = Bounds.Width > 1 ? Bounds.Width : _currentCellSize * BaseWidthCells; + var totalHeight = Bounds.Height > 1 ? Bounds.Height : _currentCellSize * BaseHeightCells; + + RootBorder.CornerRadius = new CornerRadius(Math.Clamp(30 * softScale, 14, 44)); + CardBorder.CornerRadius = new CornerRadius(Math.Clamp(30 * softScale, 14, 44)); + CardBorder.Padding = new Thickness( + Math.Clamp(12 * softScale, 8, 18), + Math.Clamp(12 * softScale, 8, 18), + Math.Clamp(12 * softScale, 8, 18), + Math.Clamp(12 * softScale, 8, 18)); + + var rowSpacing = Math.Clamp(6 * softScale, 3, 10); + ContentGrid.RowSpacing = rowSpacing; + HeaderGrid.ColumnSpacing = Math.Clamp(8 * softScale, 5, 12); + + HeaderDot.Width = Math.Clamp(8 * softScale, 5, 12); + HeaderDot.Height = HeaderDot.Width; + HeaderDot.CornerRadius = new CornerRadius(HeaderDot.Width / 2d); + HeaderTitleTextBlock.FontSize = Math.Clamp(20 * softScale, 12, 28); + + var refreshSize = Math.Clamp(34 * softScale, 22, 42); + RefreshButton.Width = refreshSize; + RefreshButton.Height = refreshSize; + RefreshButton.CornerRadius = new CornerRadius(refreshSize / 2d); + RefreshGlyphIcon.FontSize = Math.Clamp(16 * softScale, 10, 20); + + var innerWidth = Math.Max(100, totalWidth - CardBorder.Padding.Left - CardBorder.Padding.Right); + var rowPaddingHorizontal = Math.Clamp(8 * softScale, 5, 14); + var rowPaddingVertical = Math.Clamp(6 * softScale, 3, 10); + var itemCornerRadius = Math.Clamp(10 * softScale, 6, 14); + var avatarSize = Math.Clamp(30 * softScale, 20, 40); + var avatarFont = Math.Clamp(13 * softScale, 9, 18); + var titleFont = Math.Clamp(14 * softScale, 10, 19); + var titleMaxWidth = Math.Max(60, innerWidth - avatarSize - (rowPaddingHorizontal * 2d) - 18); + + foreach (var visual in _itemVisuals) + { + visual.Host.CornerRadius = new CornerRadius(itemCornerRadius); + visual.Host.Padding = new Thickness(rowPaddingHorizontal, rowPaddingVertical); + visual.RowGrid.ColumnSpacing = Math.Clamp(8 * softScale, 4, 12); + + visual.AvatarHost.Width = avatarSize; + visual.AvatarHost.Height = avatarSize; + visual.AvatarHost.CornerRadius = new CornerRadius(avatarSize / 2d); + + visual.AvatarFallbackText.FontSize = avatarFont; + visual.TitleTextBlock.FontSize = titleFont; + visual.TitleTextBlock.MaxWidth = titleMaxWidth; + } + + StatusTextBlock.FontSize = Math.Clamp(14 * softScale, 10, 18); + } + + private static string NormalizeCompactText(string? text) + { + if (string.IsNullOrWhiteSpace(text)) + { + return string.Empty; + } + + return MultiWhitespaceRegex.Replace(text.Trim(), " "); + } + + private static string ResolveAvatarFallbackText(string? displayName) + { + if (string.IsNullOrWhiteSpace(displayName)) + { + return "?"; + } + + var compact = displayName.Trim(); + var first = compact[0]; + return first.ToString().ToUpperInvariant(); + } + + private static async Task TryDownloadAvatarBitmapAsync(string? avatarUrl, CancellationToken cancellationToken) + { + var normalizedUrl = NormalizeHttpUrl(avatarUrl); + if (string.IsNullOrWhiteSpace(normalizedUrl)) + { + return null; + } + + try + { + using var request = new HttpRequestMessage(HttpMethod.Get, normalizedUrl); + request.Headers.TryAddWithoutValidation("User-Agent", AvatarRequestUserAgent); + request.Headers.TryAddWithoutValidation("Accept", "image/avif,image/webp,image/apng,image/*,*/*;q=0.8"); + using var response = await AvatarHttpClient.SendAsync( + request, + HttpCompletionOption.ResponseHeadersRead, + cancellationToken); + if (!response.IsSuccessStatusCode) + { + return null; + } + + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken); + var memory = new MemoryStream(); + await stream.CopyToAsync(memory, cancellationToken); + memory.Position = 0; + return new Bitmap(memory); + } + catch (OperationCanceledException) + { + throw; + } + catch + { + return null; + } + } + + private void SetAvatarBitmap(int index, Bitmap? bitmap) + { + if (index < 0 || index >= _avatarBitmaps.Length || index >= _itemVisuals.Count) + { + bitmap?.Dispose(); + return; + } + + var visual = _itemVisuals[index]; + var oldBitmap = _avatarBitmaps[index]; + if (ReferenceEquals(visual.AvatarImage.Source, oldBitmap)) + { + visual.AvatarImage.Source = null; + } + + oldBitmap?.Dispose(); + _avatarBitmaps[index] = bitmap; + visual.AvatarImage.Source = bitmap; + visual.AvatarFallbackText.IsVisible = bitmap is null; + } + + private void DisposeAvatarBitmaps() + { + for (var i = 0; i < _avatarBitmaps.Length; i++) + { + SetAvatarBitmap(i, null); + } + } + + private static string? NormalizeHttpUrl(string? rawUrl) + { + if (string.IsNullOrWhiteSpace(rawUrl)) + { + return null; + } + + var candidate = rawUrl.Trim(); + if (!Uri.TryCreate(candidate, UriKind.Absolute, out var uri)) + { + return null; + } + + if (!string.Equals(uri.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) && + !string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + return uri.ToString(); + } + + private void TryOpenUrl(string? rawUrl) + { + var normalizedUrl = NormalizeHttpUrl(rawUrl); + if (string.IsNullOrWhiteSpace(normalizedUrl)) + { + return; + } + + try + { + var startInfo = new ProcessStartInfo + { + FileName = normalizedUrl, + UseShellExecute = true + }; + Process.Start(startInfo); + } + catch + { + // Ignore malformed URLs or shell launch failures. + } + } + + private double ResolveScale() + { + var expectedWidth = _currentCellSize * BaseWidthCells; + var expectedHeight = _currentCellSize * BaseHeightCells; + if (expectedWidth <= 0 || expectedHeight <= 0) + { + return 1d; + } + + var actualWidth = Bounds.Width > 1 ? Bounds.Width : expectedWidth; + var actualHeight = Bounds.Height > 1 ? Bounds.Height : expectedHeight; + var scaleX = actualWidth / expectedWidth; + var scaleY = actualHeight / expectedHeight; + return Math.Clamp(Math.Min(scaleX, scaleY), 0.62, 2.6); + } + + private string L(string key, string fallback) + { + return _localizationService.GetString(_languageCode, key, fallback); + } + + private void CancelRefreshRequest() + { + var cts = Interlocked.Exchange(ref _refreshCts, null); + if (cts is null) + { + return; + } + + cts.Cancel(); + cts.Dispose(); + } +} diff --git a/LanMountainDesktop/Views/Components/StudyEnvironmentWidget.axaml.cs b/LanMountainDesktop/Views/Components/StudyEnvironmentWidget.axaml.cs index 24637c8..234bd31 100644 --- a/LanMountainDesktop/Views/Components/StudyEnvironmentWidget.axaml.cs +++ b/LanMountainDesktop/Views/Components/StudyEnvironmentWidget.axaml.cs @@ -13,7 +13,8 @@ public partial class StudyEnvironmentWidget : UserControl, IDesktopComponentWidg { private readonly IStudyAnalyticsService _studyAnalyticsService = StudyAnalyticsServiceFactory.CreateDefault(); private readonly StudyAnalyticsMonitoringLeaseCoordinator _monitoringLeaseCoordinator = StudyAnalyticsMonitoringLeaseCoordinatorFactory.CreateDefault(); - private readonly AppSettingsService _settingsService = new(); + private readonly AppSettingsService _appSettingsService = new(); + private readonly ComponentSettingsService _componentSettingsService = new(); private readonly LocalizationService _localizationService = new(); private readonly DispatcherTimer _uiTimer = new() { @@ -127,10 +128,11 @@ public partial class StudyEnvironmentWidget : UserControl, IDesktopComponentWidg private void ReloadDisplaySettings() { - var snapshot = _settingsService.Load(); - _languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode); - _showDisplayDb = snapshot.StudyEnvironmentShowDisplayDb; - _showDbfs = snapshot.StudyEnvironmentShowDbfs; + var appSnapshot = _appSettingsService.Load(); + var componentSnapshot = _componentSettingsService.Load(); + _languageCode = _localizationService.NormalizeLanguageCode(appSnapshot.LanguageCode); + _showDisplayDb = componentSnapshot.StudyEnvironmentShowDisplayDb; + _showDbfs = componentSnapshot.StudyEnvironmentShowDbfs; if (!_showDisplayDb && !_showDbfs) { _showDisplayDb = true; diff --git a/LanMountainDesktop/Views/Components/StudyEnvironmentWidgetSettingsWindow.axaml.cs b/LanMountainDesktop/Views/Components/StudyEnvironmentWidgetSettingsWindow.axaml.cs index d2cd2b9..4de9c4a 100644 --- a/LanMountainDesktop/Views/Components/StudyEnvironmentWidgetSettingsWindow.axaml.cs +++ b/LanMountainDesktop/Views/Components/StudyEnvironmentWidgetSettingsWindow.axaml.cs @@ -8,6 +8,7 @@ namespace LanMountainDesktop.Views.Components; public partial class StudyEnvironmentWidgetSettingsWindow : UserControl { private readonly AppSettingsService _appSettingsService = new(); + private readonly ComponentSettingsService _componentSettingsService = new(); private readonly LocalizationService _localizationService = new(); private string _languageCode = "zh-CN"; private bool _suppressEvents; @@ -23,11 +24,12 @@ public partial class StudyEnvironmentWidgetSettingsWindow : UserControl private void LoadState() { - var snapshot = _appSettingsService.Load(); - _languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode); + var appSnapshot = _appSettingsService.Load(); + var componentSnapshot = _componentSettingsService.Load(); + _languageCode = _localizationService.NormalizeLanguageCode(appSnapshot.LanguageCode); - var showDisplayDb = snapshot.StudyEnvironmentShowDisplayDb; - var showDbfs = snapshot.StudyEnvironmentShowDbfs; + var showDisplayDb = componentSnapshot.StudyEnvironmentShowDisplayDb; + var showDbfs = componentSnapshot.StudyEnvironmentShowDbfs; if (!showDisplayDb && !showDbfs) { showDisplayDb = true; @@ -75,10 +77,10 @@ public partial class StudyEnvironmentWidgetSettingsWindow : UserControl showDisplayDb = true; } - var snapshot = _appSettingsService.Load(); + var snapshot = _componentSettingsService.Load(); snapshot.StudyEnvironmentShowDisplayDb = showDisplayDb; snapshot.StudyEnvironmentShowDbfs = showDbfs; - _appSettingsService.Save(snapshot); + _componentSettingsService.Save(snapshot); SettingsChanged?.Invoke(this, EventArgs.Empty); } diff --git a/LanMountainDesktop/Views/Components/WorldClockWidget.axaml.cs b/LanMountainDesktop/Views/Components/WorldClockWidget.axaml.cs index 40762f1..65ac45e 100644 --- a/LanMountainDesktop/Views/Components/WorldClockWidget.axaml.cs +++ b/LanMountainDesktop/Views/Components/WorldClockWidget.axaml.cs @@ -88,7 +88,8 @@ public partial class WorldClockWidget : UserControl, IDesktopComponentWidget, IT Interval = TimeSpan.FromSeconds(1) }; - private readonly AppSettingsService _settingsService = new(); + private readonly AppSettingsService _appSettingsService = new(); + private readonly ComponentSettingsService _componentSettingsService = new(); private readonly LocalizationService _localizationService = new(); private readonly ClockEntryVisual[] _entryVisuals = new ClockEntryVisual[WorldClockTimeZoneCatalog.ClockCount]; private readonly TimeZoneInfo[] _entryTimeZones = new TimeZoneInfo[WorldClockTimeZoneCatalog.ClockCount]; @@ -445,17 +446,18 @@ public partial class WorldClockWidget : UserControl, IDesktopComponentWidget, IT private void LoadFromSettings() { - var snapshot = _settingsService.Load(); - _languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode); + var appSnapshot = _appSettingsService.Load(); + var componentSnapshot = _componentSettingsService.Load(); + _languageCode = _localizationService.NormalizeLanguageCode(appSnapshot.LanguageCode); - var ids = WorldClockTimeZoneCatalog.NormalizeTimeZoneIds(snapshot.WorldClockTimeZoneIds); + var ids = WorldClockTimeZoneCatalog.NormalizeTimeZoneIds(componentSnapshot.WorldClockTimeZoneIds); for (var index = 0; index < WorldClockTimeZoneCatalog.ClockCount; index++) { var resolvedId = ids[index]; _entryTimeZones[index] = WorldClockTimeZoneCatalog.ResolveTimeZoneOrLocal(resolvedId); } - _secondHandMode = ClockSecondHandMode.Normalize(snapshot.WorldClockSecondHandMode); + _secondHandMode = ClockSecondHandMode.Normalize(componentSnapshot.WorldClockSecondHandMode); } private void ApplySecondHandTimerInterval() @@ -533,7 +535,7 @@ public partial class WorldClockWidget : UserControl, IDesktopComponentWidget, IT _nextLanguageProbeUtc = utcNow.AddSeconds(25); try { - var snapshot = _settingsService.Load(); + var snapshot = _appSettingsService.Load(); _languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode); } catch diff --git a/LanMountainDesktop/Views/Components/WorldClockWidgetSettingsWindow.axaml.cs b/LanMountainDesktop/Views/Components/WorldClockWidgetSettingsWindow.axaml.cs index 1d2b8a6..67e1a4e 100644 --- a/LanMountainDesktop/Views/Components/WorldClockWidgetSettingsWindow.axaml.cs +++ b/LanMountainDesktop/Views/Components/WorldClockWidgetSettingsWindow.axaml.cs @@ -28,6 +28,7 @@ public partial class WorldClockWidgetSettingsWindow : UserControl }; private readonly AppSettingsService _appSettingsService = new(); + private readonly ComponentSettingsService _componentSettingsService = new(); private readonly LocalizationService _localizationService = new(); private readonly TimeZoneService _timeZoneService = new(); private readonly ComboBox[] _timeZoneComboBoxes; @@ -58,8 +59,9 @@ public partial class WorldClockWidgetSettingsWindow : UserControl private void LoadState() { - var snapshot = _appSettingsService.Load(); - _languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode); + var appSnapshot = _appSettingsService.Load(); + var componentSnapshot = _componentSettingsService.Load(); + _languageCode = _localizationService.NormalizeLanguageCode(appSnapshot.LanguageCode); _allTimeZones = _timeZoneService .GetAllTimeZones() @@ -68,9 +70,9 @@ public partial class WorldClockWidgetSettingsWindow : UserControl .ToList(); _selectedTimeZoneIds = WorldClockTimeZoneCatalog.NormalizeTimeZoneIds( - snapshot.WorldClockTimeZoneIds, + componentSnapshot.WorldClockTimeZoneIds, _allTimeZones); - _secondHandMode = ClockSecondHandMode.Normalize(snapshot.WorldClockSecondHandMode); + _secondHandMode = ClockSecondHandMode.Normalize(componentSnapshot.WorldClockSecondHandMode); } private void ApplyLocalization() @@ -165,10 +167,10 @@ public partial class WorldClockWidgetSettingsWindow : UserControl var normalizedIds = WorldClockTimeZoneCatalog.NormalizeTimeZoneIds(selectedIds, _allTimeZones); _secondHandMode = GetSelectedSecondHandMode(); - var snapshot = _appSettingsService.Load(); + var snapshot = _componentSettingsService.Load(); snapshot.WorldClockTimeZoneIds = normalizedIds.ToList(); snapshot.WorldClockSecondHandMode = _secondHandMode; - _appSettingsService.Save(snapshot); + _componentSettingsService.Save(snapshot); _selectedTimeZoneIds = normalizedIds; SettingsChanged?.Invoke(this, EventArgs.Empty); diff --git a/LanMountainDesktop/Views/MainWindow.ComponentSystem.cs b/LanMountainDesktop/Views/MainWindow.ComponentSystem.cs index 86ad9a5..d995aeb 100644 --- a/LanMountainDesktop/Views/MainWindow.ComponentSystem.cs +++ b/LanMountainDesktop/Views/MainWindow.ComponentSystem.cs @@ -731,6 +731,18 @@ public partial class MainWindow return; } + if (placement.ComponentId == BuiltInComponentIds.DesktopDailyWord) + { + OpenDailyWordComponentSettings(); + return; + } + + if (placement.ComponentId == BuiltInComponentIds.DesktopBilibiliHotSearch) + { + OpenBilibiliHotSearchComponentSettings(); + return; + } + if (placement.ComponentId == BuiltInComponentIds.DesktopStudyEnvironment) { OpenStudyEnvironmentComponentSettings(); @@ -850,6 +862,38 @@ public partial class MainWindow ComponentSettingsWindow.Opacity = 1; } + private void OpenDailyWordComponentSettings() + { + if (ComponentSettingsWindow is null || ComponentSettingsContentHost is null) + { + return; + } + + var settingsContent = new DailyWordSettingsWindow(); + settingsContent.SettingsChanged += OnDailyWordSettingsChanged; + ComponentSettingsContentHost.Content = settingsContent; + + ComponentSettingsWindow.IsVisible = true; + ComponentSettingsWindow.Opacity = 0; + ComponentSettingsWindow.Opacity = 1; + } + + private void OpenBilibiliHotSearchComponentSettings() + { + if (ComponentSettingsWindow is null || ComponentSettingsContentHost is null) + { + return; + } + + var settingsContent = new BilibiliHotSearchSettingsWindow(); + settingsContent.SettingsChanged += OnBilibiliHotSearchSettingsChanged; + ComponentSettingsContentHost.Content = settingsContent; + + ComponentSettingsWindow.IsVisible = true; + ComponentSettingsWindow.Opacity = 0; + ComponentSettingsWindow.Opacity = 1; + } + private void OnClassScheduleSettingsChanged(object? sender, EventArgs e) { if (_selectedDesktopComponentHost is null) @@ -883,8 +927,6 @@ public partial class MainWindow } } } - - PersistSettings(); } private void OnStudyEnvironmentSettingsChanged(object? sender, EventArgs e) @@ -907,10 +949,6 @@ public partial class MainWindow _ = sender; _ = e; - _dailyArtworkMirrorSource = sender is DailyArtworkSettingsWindow settingsWindow - ? DailyArtworkMirrorSources.Normalize(settingsWindow.CurrentSource) - : DailyArtworkMirrorSources.Normalize(_appSettingsService.Load().DailyArtworkMirrorSource); - foreach (var pageGrid in _desktopPageComponentGrids.Values) { foreach (var host in pageGrid.Children.OfType()) @@ -926,8 +964,6 @@ public partial class MainWindow } } } - - PersistSettings(); } private void OnWorldClockSettingsChanged(object? sender, EventArgs e) @@ -950,8 +986,6 @@ public partial class MainWindow } } } - - PersistSettings(); } private void OnCnrDailyNewsSettingsChanged(object? sender, EventArgs e) @@ -974,8 +1008,50 @@ public partial class MainWindow } } } + } - PersistSettings(); + private void OnDailyWordSettingsChanged(object? sender, EventArgs e) + { + _ = sender; + _ = e; + + foreach (var pageGrid in _desktopPageComponentGrids.Values) + { + foreach (var host in pageGrid.Children.OfType()) + { + if (!host.Classes.Contains(DesktopComponentHostClass)) + { + continue; + } + + if (TryGetContentHost(host)?.Child is DailyWordWidget widget) + { + widget.RefreshFromSettings(); + } + } + } + } + + private void OnBilibiliHotSearchSettingsChanged(object? sender, EventArgs e) + { + _ = sender; + _ = e; + + foreach (var pageGrid in _desktopPageComponentGrids.Values) + { + foreach (var host in pageGrid.Children.OfType()) + { + if (!host.Classes.Contains(DesktopComponentHostClass)) + { + continue; + } + + if (TryGetContentHost(host)?.Child is BilibiliHotSearchWidget widget) + { + widget.RefreshFromSettings(); + } + } + } } private void CloseComponentSettingsWindow() @@ -1015,6 +1091,16 @@ public partial class MainWindow cnrDailyNewsSettingsWindow.SettingsChanged -= OnCnrDailyNewsSettingsChanged; } + if (ComponentSettingsContentHost?.Content is DailyWordSettingsWindow dailyWordSettingsWindow) + { + dailyWordSettingsWindow.SettingsChanged -= OnDailyWordSettingsChanged; + } + + if (ComponentSettingsContentHost?.Content is BilibiliHotSearchSettingsWindow bilibiliHotSearchSettingsWindow) + { + bilibiliHotSearchSettingsWindow.SettingsChanged -= OnBilibiliHotSearchSettingsChanged; + } + ComponentSettingsWindow.Opacity = 0; DispatcherTimer.RunOnce(() => @@ -1434,6 +1520,14 @@ public partial class MainWindow new ComponentScaleRule(WidthUnit: 2, HeightUnit: 1, MinScale: 2)); } + if (string.Equals(componentId, BuiltInComponentIds.DesktopStcn24Forum, StringComparison.OrdinalIgnoreCase)) + { + // Keep STCN forum widget square with a minimum footprint of 4x4. + return SnapSpanToScaleRules( + span, + new ComponentScaleRule(WidthUnit: 1, HeightUnit: 1, MinScale: 4)); + } + if (string.Equals(componentId, BuiltInComponentIds.DesktopExchangeRateCalculator, StringComparison.OrdinalIgnoreCase)) { // Keep exchange rate converter square with minimum size 4x4. @@ -2826,12 +2920,16 @@ public partial class MainWindow _weatherDataService, _recommendationInfoService, _calculatorDataService); + // Component library previews must stay non-interactive so drag gesture is reliable. + previewControl.IsHitTestVisible = false; + previewControl.Focusable = false; var previewSurface = new Border { Width = previewSpan.WidthCells * renderCellSize, Height = previewSpan.HeightCells * renderCellSize, Background = Brushes.Transparent, + IsHitTestVisible = false, Child = previewControl }; diff --git a/LanMountainDesktop/Views/MainWindow.DesktopPaging.cs b/LanMountainDesktop/Views/MainWindow.DesktopPaging.cs index 0a9ee54..2d37a1d 100644 --- a/LanMountainDesktop/Views/MainWindow.DesktopPaging.cs +++ b/LanMountainDesktop/Views/MainWindow.DesktopPaging.cs @@ -22,9 +22,26 @@ public partial class MainWindow { private const int MinDesktopPageCount = 1; private const int MaxDesktopPageCount = 12; + private enum LauncherEntryKind + { + Folder, + Shortcut + } + + private sealed record LauncherHiddenItemToken(LauncherEntryKind Kind, string Key); + + private sealed record LauncherHiddenItemView( + LauncherEntryKind Kind, + string Key, + string DisplayName, + string Monogram, + Bitmap? IconBitmap); + private readonly WindowsStartMenuService _windowsStartMenuService = new(); private readonly Dictionary _launcherIconCache = new(StringComparer.OrdinalIgnoreCase); private readonly Stack _launcherFolderStack = []; + private readonly HashSet _hiddenLauncherFolderPaths = new(StringComparer.OrdinalIgnoreCase); + private readonly HashSet _hiddenLauncherAppPaths = new(StringComparer.OrdinalIgnoreCase); private StartMenuFolderNode _startMenuRoot = new("All Apps", string.Empty); private byte[]? _launcherFolderIconPngBytes; private Bitmap? _launcherFolderIconBitmap; @@ -52,6 +69,35 @@ public partial class MainWindow _currentDesktopSurfaceIndex = Math.Clamp(snapshot.CurrentDesktopSurfaceIndex, 0, LauncherSurfaceIndex); } + private void InitializeLauncherVisibilitySettings(AppSettingsSnapshot snapshot) + { + _hiddenLauncherFolderPaths.Clear(); + if (snapshot.HiddenLauncherFolderPaths is not null) + { + foreach (var folderPath in snapshot.HiddenLauncherFolderPaths) + { + var key = NormalizeLauncherHiddenKey(folderPath); + if (!string.IsNullOrWhiteSpace(key)) + { + _hiddenLauncherFolderPaths.Add(key); + } + } + } + + _hiddenLauncherAppPaths.Clear(); + if (snapshot.HiddenLauncherAppPaths is not null) + { + foreach (var appPath in snapshot.HiddenLauncherAppPaths) + { + var key = NormalizeLauncherHiddenKey(appPath); + if (!string.IsNullOrWhiteSpace(key)) + { + _hiddenLauncherAppPaths.Add(key); + } + } + } + } + private void InitializeDesktopSurfaceSwipeHandlers() { // Capture swipe intent before child controls consume pointer events. @@ -80,6 +126,7 @@ public partial class MainWindow _launcherFolderIconBitmap?.Dispose(); _launcherFolderIconBitmap = null; RenderLauncherRootTiles(); + RenderLauncherHiddenItemsList(); }, DispatcherPriority.Background); } catch @@ -89,6 +136,7 @@ public partial class MainWindow _launcherFolderIconBitmap?.Dispose(); _launcherFolderIconBitmap = null; RenderLauncherRootTiles(); + RenderLauncherHiddenItemsList(); } } @@ -695,11 +743,21 @@ public partial class MainWindow foreach (var folder in folders) { + if (!IsLauncherFolderVisible(folder)) + { + continue; + } + LauncherRootTilePanel.Children.Add(CreateLauncherFolderTile(folder)); } foreach (var app in apps) { + if (!IsLauncherAppVisible(app)) + { + continue; + } + LauncherRootTilePanel.Children.Add(CreateLauncherAppTile(app)); } @@ -719,24 +777,28 @@ public partial class MainWindow var title = folder.Name; var subtitle = Lf("launcher.folder_items_format", "{0} apps", folder.TotalAppCount); var folderIconBitmap = GetLauncherFolderIconBitmap(); + var folderKey = NormalizeLauncherHiddenKey(folder.RelativePath); return CreateLauncherTileButton( title, subtitle, monogram: "DIR", iconBitmap: folderIconBitmap, - () => OpenLauncherFolder(folder)); + () => OpenLauncherFolder(folder), + hideAction: string.IsNullOrWhiteSpace(folderKey) ? null : () => HideLauncherFolder(folder)); } private Button CreateLauncherAppTile(StartMenuAppEntry app) { var iconBitmap = GetLauncherIconBitmap(app); var monogram = BuildMonogram(app.DisplayName); + var appKey = NormalizeLauncherHiddenKey(app.RelativePath); return CreateLauncherTileButton( app.DisplayName, subtitle: string.Empty, monogram, iconBitmap, - () => LaunchStartMenuEntry(app)); + () => LaunchStartMenuEntry(app), + hideAction: string.IsNullOrWhiteSpace(appKey) ? null : () => HideLauncherApp(app)); } private Control CreateLauncherHintTile(string title, string subtitle) @@ -779,7 +841,8 @@ public partial class MainWindow string subtitle, string monogram, Bitmap? iconBitmap, - Action clickAction) + Action clickAction, + Action? hideAction = null) { Control iconControl = iconBitmap is not null ? new Image @@ -853,9 +916,310 @@ public partial class MainWindow // 不设置固定 Width 和 Height,由 UpdateLauncherTileLayout 动态设置 }; button.Click += (_, _) => clickAction(); + AttachLauncherTileContextMenu(button, hideAction); return button; } + private static string NormalizeLauncherHiddenKey(string? key) + { + return string.IsNullOrWhiteSpace(key) ? string.Empty : key.Trim(); + } + + private bool IsLauncherFolderVisible(StartMenuFolderNode folder) + { + var key = NormalizeLauncherHiddenKey(folder.RelativePath); + return string.IsNullOrWhiteSpace(key) || !_hiddenLauncherFolderPaths.Contains(key); + } + + private bool IsLauncherAppVisible(StartMenuAppEntry app) + { + var key = NormalizeLauncherHiddenKey(app.RelativePath); + return string.IsNullOrWhiteSpace(key) || !_hiddenLauncherAppPaths.Contains(key); + } + + private void AttachLauncherTileContextMenu(Button tileButton, Action? hideAction) + { + if (hideAction is null) + { + tileButton.ContextMenu = null; + return; + } + + var hideItem = new MenuItem + { + Header = L("launcher.context.hide_icon", "Hide Icon") + }; + hideItem.Click += (_, _) => hideAction(); + + var contextMenu = new ContextMenu(); + contextMenu.Items.Add(hideItem); + tileButton.ContextMenu = contextMenu; + } + + private void HideLauncherFolder(StartMenuFolderNode folder) + { + var key = NormalizeLauncherHiddenKey(folder.RelativePath); + if (string.IsNullOrWhiteSpace(key) || !_hiddenLauncherFolderPaths.Add(key)) + { + return; + } + + ApplyLauncherVisibilitySettingsChange(); + } + + private void HideLauncherApp(StartMenuAppEntry app) + { + var key = NormalizeLauncherHiddenKey(app.RelativePath); + if (string.IsNullOrWhiteSpace(key) || !_hiddenLauncherAppPaths.Add(key)) + { + return; + } + + ApplyLauncherVisibilitySettingsChange(); + } + + private void ApplyLauncherVisibilitySettingsChange() + { + RenderLauncherRootTiles(); + if (_launcherFolderStack.Count > 0) + { + RenderLauncherFolderFromStack(); + } + + RenderLauncherHiddenItemsList(); + PersistSettings(); + } + + private void RenderLauncherHiddenItemsList() + { + if (LauncherHiddenItemsListPanel is null || LauncherHiddenItemsEmptyTextBlock is null) + { + return; + } + + LauncherHiddenItemsListPanel.Children.Clear(); + var hiddenItems = BuildLauncherHiddenItems(); + LauncherHiddenItemsEmptyTextBlock.IsVisible = hiddenItems.Count == 0; + if (hiddenItems.Count == 0) + { + return; + } + + foreach (var hiddenItem in hiddenItems) + { + LauncherHiddenItemsListPanel.Children.Add(CreateLauncherHiddenItemRow(hiddenItem)); + } + } + + private IReadOnlyList BuildLauncherHiddenItems() + { + var items = new List(); + var seenFolders = new HashSet(StringComparer.OrdinalIgnoreCase); + var seenApps = new HashSet(StringComparer.OrdinalIgnoreCase); + + CollectHiddenLauncherItems(_startMenuRoot, items, seenFolders, seenApps); + + foreach (var key in _hiddenLauncherFolderPaths.OrderBy(path => path, StringComparer.OrdinalIgnoreCase)) + { + if (!seenFolders.Contains(key)) + { + items.Add(new LauncherHiddenItemView( + LauncherEntryKind.Folder, + key, + BuildLauncherHiddenFallbackDisplayName(key), + "DIR", + GetLauncherFolderIconBitmap())); + } + } + + foreach (var key in _hiddenLauncherAppPaths.OrderBy(path => path, StringComparer.OrdinalIgnoreCase)) + { + if (!seenApps.Contains(key)) + { + var fallbackName = BuildLauncherHiddenFallbackDisplayName(key); + items.Add(new LauncherHiddenItemView( + LauncherEntryKind.Shortcut, + key, + fallbackName, + BuildMonogram(fallbackName), + IconBitmap: null)); + } + } + + return items + .OrderBy(item => item.DisplayName, StringComparer.CurrentCultureIgnoreCase) + .ThenBy(item => item.Key, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + private void CollectHiddenLauncherItems( + StartMenuFolderNode folder, + List items, + HashSet seenFolders, + HashSet seenApps) + { + foreach (var subFolder in folder.Folders) + { + var folderKey = NormalizeLauncherHiddenKey(subFolder.RelativePath); + if (!string.IsNullOrWhiteSpace(folderKey) && + _hiddenLauncherFolderPaths.Contains(folderKey) && + seenFolders.Add(folderKey)) + { + items.Add(new LauncherHiddenItemView( + LauncherEntryKind.Folder, + folderKey, + subFolder.Name, + "DIR", + GetLauncherFolderIconBitmap())); + } + + CollectHiddenLauncherItems(subFolder, items, seenFolders, seenApps); + } + + foreach (var app in folder.Apps) + { + var appKey = NormalizeLauncherHiddenKey(app.RelativePath); + if (string.IsNullOrWhiteSpace(appKey) || + !_hiddenLauncherAppPaths.Contains(appKey) || + !seenApps.Add(appKey)) + { + continue; + } + + items.Add(new LauncherHiddenItemView( + LauncherEntryKind.Shortcut, + appKey, + app.DisplayName, + BuildMonogram(app.DisplayName), + GetLauncherIconBitmap(app))); + } + } + + private static string BuildLauncherHiddenFallbackDisplayName(string key) + { + if (string.IsNullOrWhiteSpace(key)) + { + return "Unknown"; + } + + var normalized = key.Replace('\\', '/'); + var fileName = Path.GetFileNameWithoutExtension(normalized); + return string.IsNullOrWhiteSpace(fileName) + ? key + : fileName; + } + + private Control CreateLauncherHiddenItemRow(LauncherHiddenItemView hiddenItem) + { + Control icon = hiddenItem.IconBitmap is not null + ? new Image + { + Source = hiddenItem.IconBitmap, + Width = 24, + Height = 24, + Stretch = Stretch.Uniform + } + : new Border + { + Width = 24, + Height = 24, + CornerRadius = new CornerRadius(999), + Background = GetThemeBrush("AdaptiveButtonBackgroundBrush"), + Child = new TextBlock + { + Text = hiddenItem.Monogram, + FontSize = 10, + FontWeight = FontWeight.Bold, + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center + } + }; + + var typeText = hiddenItem.Kind == LauncherEntryKind.Folder + ? L("settings.launcher.hidden_type_folder", "Folder") + : L("settings.launcher.hidden_type_shortcut", "Shortcut"); + + var infoPanel = new StackPanel + { + Orientation = Orientation.Horizontal, + Spacing = 10, + VerticalAlignment = VerticalAlignment.Center, + HorizontalAlignment = HorizontalAlignment.Stretch + }; + infoPanel.Children.Add(icon); + infoPanel.Children.Add(new StackPanel + { + Spacing = 2, + VerticalAlignment = VerticalAlignment.Center, + HorizontalAlignment = HorizontalAlignment.Left, + Children = + { + new TextBlock + { + Text = hiddenItem.DisplayName, + TextTrimming = TextTrimming.CharacterEllipsis, + MaxLines = 1 + }, + new TextBlock + { + Text = typeText, + FontSize = 11, + Opacity = 0.7 + } + } + }); + + var restoreButton = new Button + { + Content = L("settings.launcher.restore_button", "Show Again"), + MinWidth = 110, + Padding = new Thickness(12, 6), + Tag = new LauncherHiddenItemToken(hiddenItem.Kind, hiddenItem.Key) + }; + restoreButton.Click += OnRestoreLauncherHiddenItemClick; + + var row = new Grid + { + ColumnDefinitions = new ColumnDefinitions("*,Auto"), + ColumnSpacing = 10 + }; + row.Children.Add(infoPanel); + Grid.SetColumn(infoPanel, 0); + row.Children.Add(restoreButton); + Grid.SetColumn(restoreButton, 1); + + return new Border + { + Classes = { "glass-panel" }, + BorderThickness = new Thickness(0), + CornerRadius = new CornerRadius(14), + Padding = new Thickness(10, 8), + Child = row + }; + } + + private void OnRestoreLauncherHiddenItemClick(object? sender, RoutedEventArgs e) + { + if (sender is not Button { Tag: LauncherHiddenItemToken token }) + { + return; + } + + var removed = token.Kind switch + { + LauncherEntryKind.Folder => _hiddenLauncherFolderPaths.Remove(token.Key), + LauncherEntryKind.Shortcut => _hiddenLauncherAppPaths.Remove(token.Key), + _ => false + }; + + if (!removed) + { + return; + } + + ApplyLauncherVisibilitySettingsChange(); + } + private Bitmap? GetLauncherIconBitmap(StartMenuAppEntry app) { if (app.IconPngBytes is null || app.IconPngBytes.Length == 0) @@ -950,11 +1314,21 @@ public partial class MainWindow LauncherFolderTilePanel.Children.Clear(); foreach (var subFolder in folder.Folders) { + if (!IsLauncherFolderVisible(subFolder)) + { + continue; + } + LauncherFolderTilePanel.Children.Add(CreateLauncherFolderTile(subFolder)); } foreach (var app in folder.Apps) { + if (!IsLauncherAppVisible(app)) + { + continue; + } + LauncherFolderTilePanel.Children.Add(CreateLauncherAppTile(app)); } diff --git a/LanMountainDesktop/Views/MainWindow.Localization.cs b/LanMountainDesktop/Views/MainWindow.Localization.cs index 0cb05ab..0957e51 100644 --- a/LanMountainDesktop/Views/MainWindow.Localization.cs +++ b/LanMountainDesktop/Views/MainWindow.Localization.cs @@ -111,6 +111,7 @@ public partial class MainWindow SettingsNavWeatherTextBlock.Text = L("settings.nav.weather", "Weather"); SettingsNavRegionTextBlock.Text = L("settings.nav.region", "Region"); SettingsNavUpdateTextBlock.Text = L("settings.nav.update", "Update"); + SettingsNavLauncherTextBlock.Text = L("settings.nav.launcher", "App Launcher"); WallpaperPanelTitleTextBlock.Text = L("settings.wallpaper.title", "Personalize your wallpaper"); WallpaperPlacementSettingsExpander.Header = L("settings.wallpaper.placement_label", "Placement"); @@ -251,6 +252,16 @@ public partial class MainWindow ApplyUpdateLocalization(); + LauncherSettingsPanelTitleTextBlock.Text = L("settings.launcher.title", "App Launcher"); + LauncherHiddenItemsSettingsExpander.Header = L("settings.launcher.hidden_header", "Hidden Items"); + LauncherHiddenItemsSettingsExpander.Description = L( + "settings.launcher.hidden_desc", + "Review hidden launcher entries and show them again."); + LauncherHiddenItemsDescriptionTextBlock.Text = L( + "settings.launcher.hidden_hint", + "Right-click an icon in launcher to hide it. Hidden entries appear here."); + LauncherHiddenItemsEmptyTextBlock.Text = L("settings.launcher.hidden_empty", "No hidden items."); + SettingsNavAboutTextBlock.Text = L("settings.nav.about", "About"); AboutPanelTitleTextBlock.Text = L("settings.about.title", "About"); VersionTextBlock.Text = Lf( @@ -269,9 +280,6 @@ public partial class MainWindow AboutStartupSettingsExpander.Description = L( "settings.about.startup_desc", "Launch the app automatically when signing in to Windows."); - AutoStartWithWindowsToggleSwitch.Content = L( - "settings.about.startup_toggle", - "Launch at Windows sign-in"); if (WallpaperPlacementComboBox?.ItemCount >= 5) { @@ -293,6 +301,7 @@ public partial class MainWindow InitializeTimeZoneSettings(); BuildComponentLibraryCategoryPages(); RenderLauncherRootTiles(); + RenderLauncherHiddenItemsList(); UpdateOpenSettingsActionVisualState(); UpdateWallpaperDisplay(); } diff --git a/LanMountainDesktop/Views/MainWindow.Settings.cs b/LanMountainDesktop/Views/MainWindow.Settings.cs index f70d3af..866915e 100644 --- a/LanMountainDesktop/Views/MainWindow.Settings.cs +++ b/LanMountainDesktop/Views/MainWindow.Settings.cs @@ -66,6 +66,7 @@ public partial class MainWindow WeatherSettingsPanel is null || RegionSettingsPanel is null || UpdateSettingsPanel is null || + LauncherSettingsPanel is null || AboutSettingsPanel is null) { return; @@ -80,6 +81,12 @@ public partial class MainWindow RegionSettingsPanel.IsVisible = selectedIndex == 5; UpdateSettingsPanel.IsVisible = selectedIndex == 6; AboutSettingsPanel.IsVisible = selectedIndex == 7; + LauncherSettingsPanel.IsVisible = selectedIndex == 8; + + if (selectedIndex == 8) + { + RenderLauncherHiddenItemsList(); + } if (selectedIndex == 1) { @@ -877,7 +884,6 @@ public partial class MainWindow WeatherExcludedAlerts = _weatherExcludedAlertsRaw, WeatherIconPackId = _weatherIconPackId, WeatherNoTlsRequests = _weatherNoTlsRequests, - DailyArtworkMirrorSource = DailyArtworkMirrorSources.Normalize(_dailyArtworkMirrorSource), AutoStartWithWindows = _autoStartWithWindows, AutoCheckUpdates = _autoCheckUpdates, IncludePrereleaseUpdates = IncludePrereleaseUpdates, @@ -891,7 +897,9 @@ public partial class MainWindow StatusBarCustomSpacingPercent = _statusBarCustomSpacingPercent, DesktopPageCount = _desktopPageCount, CurrentDesktopSurfaceIndex = _currentDesktopSurfaceIndex, - DesktopComponentPlacements = _desktopComponentPlacements.ToList() + DesktopComponentPlacements = _desktopComponentPlacements.ToList(), + HiddenLauncherFolderPaths = _hiddenLauncherFolderPaths.OrderBy(path => path, StringComparer.OrdinalIgnoreCase).ToList(), + HiddenLauncherAppPaths = _hiddenLauncherAppPaths.OrderBy(path => path, StringComparer.OrdinalIgnoreCase).ToList() }; _appSettingsService.Save(snapshot); diff --git a/LanMountainDesktop/Views/MainWindow.axaml b/LanMountainDesktop/Views/MainWindow.axaml index 325e05f..f8123eb 100644 --- a/LanMountainDesktop/Views/MainWindow.axaml +++ b/LanMountainDesktop/Views/MainWindow.axaml @@ -460,6 +460,12 @@ + + + + + + @@ -1493,6 +1499,39 @@ + + + + + + + + + + + + + + + + + + @@ -1513,8 +1552,7 @@ + Unchecked="OnAutoStartWithWindowsToggled" /> diff --git a/LanMountainDesktop/Views/MainWindow.axaml.cs b/LanMountainDesktop/Views/MainWindow.axaml.cs index e1c787c..3919036 100644 --- a/LanMountainDesktop/Views/MainWindow.axaml.cs +++ b/LanMountainDesktop/Views/MainWindow.axaml.cs @@ -88,6 +88,7 @@ public partial class MainWindow : Window } private readonly MonetColorService _monetColorService = new(); private readonly AppSettingsService _appSettingsService = new(); + private readonly ComponentSettingsService _componentSettingsService = new(); private readonly LocalizationService _localizationService = new(); private readonly TimeZoneService _timeZoneService = new(); private readonly WindowsStartupService _windowsStartupService = new(); @@ -167,7 +168,6 @@ public partial class MainWindow : Window private string _weatherExcludedAlertsRaw = string.Empty; private string _weatherIconPackId = "FluentRegular"; private bool _weatherNoTlsRequests; - private string _dailyArtworkMirrorSource = DailyArtworkMirrorSources.Overseas; private bool _autoStartWithWindows; private bool _suppressAutoStartToggleEvents; private string _weatherSearchKeyword = string.Empty; @@ -236,7 +236,7 @@ public partial class MainWindow : Window GridSizeSlider.ValueChanged += OnGridSizeSliderChanged; GridSizeNumberBox.ValueChanged += OnGridSizeNumberBoxChanged; - SettingsNavListBox.SelectedIndex = Math.Clamp(snapshot.SettingsTabIndex, 0, 7); + SettingsNavListBox.SelectedIndex = Math.Clamp(snapshot.SettingsTabIndex, 0, 8); UpdateSettingsTabContent(); WallpaperPlacementComboBox.SelectedIndex = GetPlacementIndexFromSetting(snapshot.WallpaperPlacement); @@ -244,10 +244,11 @@ public partial class MainWindow : Window ApplyTaskbarSettings(snapshot); InitializeLocalization(snapshot.LanguageCode); InitializeWeatherSettings(snapshot); - _dailyArtworkMirrorSource = DailyArtworkMirrorSources.Normalize(snapshot.DailyArtworkMirrorSource); + _ = _componentSettingsService.Load(); InitializeAutoStartWithWindowsSetting(snapshot); InitializeUpdateSettings(snapshot); InitializeDesktopSurfaceState(snapshot); + InitializeLauncherVisibilitySettings(snapshot); InitializeDesktopComponentPlacements(snapshot); InitializeSettingsIcons();