mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-22 00:54:26 +08:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de40471af6 |
@@ -32,6 +32,7 @@ public static class BuiltInComponentIds
|
|||||||
public const string DesktopDailyWord = "DesktopDailyWord";
|
public const string DesktopDailyWord = "DesktopDailyWord";
|
||||||
public const string DesktopCnrDailyNews = "DesktopCnrDailyNews";
|
public const string DesktopCnrDailyNews = "DesktopCnrDailyNews";
|
||||||
public const string DesktopBilibiliHotSearch = "DesktopBilibiliHotSearch";
|
public const string DesktopBilibiliHotSearch = "DesktopBilibiliHotSearch";
|
||||||
|
public const string DesktopStcn24Forum = "DesktopStcn24Forum";
|
||||||
public const string DesktopExchangeRateCalculator = "DesktopExchangeRateCalculator";
|
public const string DesktopExchangeRateCalculator = "DesktopExchangeRateCalculator";
|
||||||
public const string DesktopWhiteboard = "DesktopWhiteboard";
|
public const string DesktopWhiteboard = "DesktopWhiteboard";
|
||||||
public const string DesktopBlackboardLandscape = "DesktopBlackboardLandscape";
|
public const string DesktopBlackboardLandscape = "DesktopBlackboardLandscape";
|
||||||
|
|||||||
@@ -252,6 +252,15 @@ public sealed class ComponentRegistry
|
|||||||
MinHeightCells: 2,
|
MinHeightCells: 2,
|
||||||
AllowStatusBarPlacement: false,
|
AllowStatusBarPlacement: false,
|
||||||
AllowDesktopPlacement: true),
|
AllowDesktopPlacement: true),
|
||||||
|
new DesktopComponentDefinition(
|
||||||
|
BuiltInComponentIds.DesktopStcn24Forum,
|
||||||
|
"STCN 24",
|
||||||
|
"News",
|
||||||
|
"Info",
|
||||||
|
MinWidthCells: 4,
|
||||||
|
MinHeightCells: 4,
|
||||||
|
AllowStatusBarPlacement: false,
|
||||||
|
AllowDesktopPlacement: true),
|
||||||
new DesktopComponentDefinition(
|
new DesktopComponentDefinition(
|
||||||
BuiltInComponentIds.DesktopExchangeRateCalculator,
|
BuiltInComponentIds.DesktopExchangeRateCalculator,
|
||||||
"Exchange Rate Converter",
|
"Exchange Rate Converter",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
"settings.nav.weather": "Weather",
|
"settings.nav.weather": "Weather",
|
||||||
"settings.nav.region": "Region",
|
"settings.nav.region": "Region",
|
||||||
"settings.nav.update": "Update",
|
"settings.nav.update": "Update",
|
||||||
|
"settings.nav.launcher": "App Launcher",
|
||||||
"settings.nav.about": "About",
|
"settings.nav.about": "About",
|
||||||
"settings.wallpaper.title": "Wallpaper",
|
"settings.wallpaper.title": "Wallpaper",
|
||||||
"settings.wallpaper.description": "Pick an image or video to apply as the app window wallpaper immediately.",
|
"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": "No Start Menu entries found.",
|
||||||
"launcher.empty_folder": "This folder is empty.",
|
"launcher.empty_folder": "This folder is empty.",
|
||||||
"launcher.folder_items_format": "{0} apps",
|
"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",
|
"button.component_library": "Edit Desktop",
|
||||||
"tooltip.component_library": "Edit Desktop",
|
"tooltip.component_library": "Edit Desktop",
|
||||||
"component_library.title": "Widgets",
|
"component_library.title": "Widgets",
|
||||||
@@ -286,6 +296,7 @@
|
|||||||
"component.daily_word": "Daily Word",
|
"component.daily_word": "Daily Word",
|
||||||
"component.cnr_daily_news": "CNR Headlines",
|
"component.cnr_daily_news": "CNR Headlines",
|
||||||
"component.bilibili_hot_search": "Bilibili Hot Search",
|
"component.bilibili_hot_search": "Bilibili Hot Search",
|
||||||
|
"component.stcn24_forum": "STCN 24",
|
||||||
"component.exchange_rate_converter": "Exchange Rate Converter",
|
"component.exchange_rate_converter": "Exchange Rate Converter",
|
||||||
"component.whiteboard": "Blackboard (Portrait)",
|
"component.whiteboard": "Blackboard (Portrait)",
|
||||||
"component.blackboard_landscape": "Blackboard (Landscape)",
|
"component.blackboard_landscape": "Blackboard (Landscape)",
|
||||||
@@ -347,6 +358,10 @@
|
|||||||
"bilihot.widget.fetch_failed": "Hot search fetch failed",
|
"bilihot.widget.fetch_failed": "Hot search fetch failed",
|
||||||
"bilihot.widget.fallback_item": "No hot search data",
|
"bilihot.widget.fallback_item": "No hot search data",
|
||||||
"bilihot.widget.more_hot": "More hot search",
|
"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.loading": "Loading exchange rates...",
|
||||||
"exchange.widget.fetch_failed": "Exchange rate fetch failed",
|
"exchange.widget.fetch_failed": "Exchange rate fetch failed",
|
||||||
"cnrnews.settings.title": "CNR Settings",
|
"cnrnews.settings.title": "CNR Settings",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
"settings.nav.weather": "天气",
|
"settings.nav.weather": "天气",
|
||||||
"settings.nav.region": "地区",
|
"settings.nav.region": "地区",
|
||||||
"settings.nav.update": "更新",
|
"settings.nav.update": "更新",
|
||||||
|
"settings.nav.launcher": "应用启动台",
|
||||||
"settings.nav.about": "关于",
|
"settings.nav.about": "关于",
|
||||||
"settings.wallpaper.title": "壁纸",
|
"settings.wallpaper.title": "壁纸",
|
||||||
"settings.wallpaper.description": "选择图片或视频后可立即设为应用窗口壁纸。",
|
"settings.wallpaper.description": "选择图片或视频后可立即设为应用窗口壁纸。",
|
||||||
@@ -252,6 +253,15 @@
|
|||||||
"launcher.empty": "未找到开始菜单条目。",
|
"launcher.empty": "未找到开始菜单条目。",
|
||||||
"launcher.empty_folder": "此文件夹为空。",
|
"launcher.empty_folder": "此文件夹为空。",
|
||||||
"launcher.folder_items_format": "{0} 个应用",
|
"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": "桌面编辑",
|
"button.component_library": "桌面编辑",
|
||||||
"tooltip.component_library": "桌面编辑",
|
"tooltip.component_library": "桌面编辑",
|
||||||
"component_library.title": "桌面编辑",
|
"component_library.title": "桌面编辑",
|
||||||
@@ -286,6 +296,7 @@
|
|||||||
"component.daily_word": "每日单词",
|
"component.daily_word": "每日单词",
|
||||||
"component.cnr_daily_news": "央广网头条",
|
"component.cnr_daily_news": "央广网头条",
|
||||||
"component.bilibili_hot_search": "B站热搜",
|
"component.bilibili_hot_search": "B站热搜",
|
||||||
|
"component.stcn24_forum": "STCN 24",
|
||||||
"component.exchange_rate_converter": "汇率换算",
|
"component.exchange_rate_converter": "汇率换算",
|
||||||
"component.whiteboard": "竖向小黑板",
|
"component.whiteboard": "竖向小黑板",
|
||||||
"component.blackboard_landscape": "横向小黑板",
|
"component.blackboard_landscape": "横向小黑板",
|
||||||
@@ -347,6 +358,10 @@
|
|||||||
"bilihot.widget.fetch_failed": "热搜获取失败",
|
"bilihot.widget.fetch_failed": "热搜获取失败",
|
||||||
"bilihot.widget.fallback_item": "暂无热搜",
|
"bilihot.widget.fallback_item": "暂无热搜",
|
||||||
"bilihot.widget.more_hot": "更多热搜",
|
"bilihot.widget.more_hot": "更多热搜",
|
||||||
|
"stcn24.widget.loading": "加载中...",
|
||||||
|
"stcn24.widget.loading_item": "加载中...",
|
||||||
|
"stcn24.widget.fetch_failed": "帖子获取失败",
|
||||||
|
"stcn24.widget.fallback_item": "暂无帖子",
|
||||||
"exchange.widget.loading": "正在加载汇率...",
|
"exchange.widget.loading": "正在加载汇率...",
|
||||||
"exchange.widget.fetch_failed": "汇率获取失败",
|
"exchange.widget.fetch_failed": "汇率获取失败",
|
||||||
"cnrnews.settings.title": "央广网设置",
|
"cnrnews.settings.title": "央广网设置",
|
||||||
|
|||||||
@@ -44,8 +44,6 @@ public sealed class AppSettingsSnapshot
|
|||||||
|
|
||||||
public bool WeatherNoTlsRequests { get; set; }
|
public bool WeatherNoTlsRequests { get; set; }
|
||||||
|
|
||||||
public string DailyArtworkMirrorSource { get; set; } = DailyArtworkMirrorSources.Overseas;
|
|
||||||
|
|
||||||
public bool AutoStartWithWindows { get; set; }
|
public bool AutoStartWithWindows { get; set; }
|
||||||
|
|
||||||
public bool AutoCheckUpdates { get; set; } = true;
|
public bool AutoCheckUpdates { get; set; } = true;
|
||||||
@@ -78,29 +76,9 @@ public sealed class AppSettingsSnapshot
|
|||||||
|
|
||||||
public List<DesktopComponentPlacementSnapshot> DesktopComponentPlacements { get; set; } = [];
|
public List<DesktopComponentPlacementSnapshot> DesktopComponentPlacements { get; set; } = [];
|
||||||
|
|
||||||
public List<ImportedClassScheduleSnapshot> ImportedClassSchedules { get; set; } = [];
|
public List<string> HiddenLauncherFolderPaths { get; set; } = [];
|
||||||
|
|
||||||
public string ActiveImportedClassScheduleId { get; set; } = string.Empty;
|
public List<string> HiddenLauncherAppPaths { 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<string> 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 AppSettingsSnapshot Clone()
|
public AppSettingsSnapshot Clone()
|
||||||
{
|
{
|
||||||
@@ -136,29 +114,11 @@ public sealed class AppSettingsSnapshot
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
clone.DesktopComponentPlacements = placements;
|
clone.DesktopComponentPlacements = placements;
|
||||||
|
clone.HiddenLauncherFolderPaths = HiddenLauncherFolderPaths is { Count: > 0 }
|
||||||
var schedules = new List<ImportedClassScheduleSnapshot>(ImportedClassSchedules?.Count ?? 0);
|
? new List<string>(HiddenLauncherFolderPaths)
|
||||||
if (ImportedClassSchedules is not null)
|
: [];
|
||||||
{
|
clone.HiddenLauncherAppPaths = HiddenLauncherAppPaths is { Count: > 0 }
|
||||||
foreach (var schedule in ImportedClassSchedules)
|
? new List<string>(HiddenLauncherAppPaths)
|
||||||
{
|
|
||||||
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<string>(WorldClockTimeZoneIds)
|
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
return clone;
|
return clone;
|
||||||
|
|||||||
73
LanMountainDesktop/Models/ComponentSettingsSnapshot.cs
Normal file
73
LanMountainDesktop/Models/ComponentSettingsSnapshot.cs
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Models;
|
||||||
|
|
||||||
|
public sealed class ComponentSettingsSnapshot
|
||||||
|
{
|
||||||
|
public string DailyArtworkMirrorSource { get; set; } = DailyArtworkMirrorSources.Overseas;
|
||||||
|
|
||||||
|
public List<ImportedClassScheduleSnapshot> 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<string> 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<ImportedClassScheduleSnapshot>(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<string>(WorldClockTimeZoneIds)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return clone;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -70,3 +70,16 @@ public sealed record ExchangeRateSnapshot(
|
|||||||
string TargetCurrency,
|
string TargetCurrency,
|
||||||
decimal Rate,
|
decimal Rate,
|
||||||
DateTimeOffset FetchedAt);
|
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<Stcn24ForumPostItemSnapshot> Items,
|
||||||
|
DateTimeOffset FetchedAt);
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ public sealed class ClassIslandScheduleDataService : IClassIslandScheduleDataSer
|
|||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(inputPath))
|
if (string.IsNullOrWhiteSpace(inputPath))
|
||||||
{
|
{
|
||||||
inputPath = ResolveImportedSchedulePathFromAppSettings();
|
inputPath = ResolveImportedSchedulePathFromComponentSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
var source = ResolveSource(inputPath, profileFileName, warnings);
|
var source = ResolveSource(inputPath, profileFileName, warnings);
|
||||||
@@ -180,11 +180,11 @@ public sealed class ClassIslandScheduleDataService : IClassIslandScheduleDataSer
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string? ResolveImportedSchedulePathFromAppSettings()
|
private static string? ResolveImportedSchedulePathFromComponentSettings()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var snapshot = new AppSettingsService().Load();
|
var snapshot = new ComponentSettingsService().Load();
|
||||||
if (snapshot.ImportedClassSchedules.Count == 0)
|
if (snapshot.ImportedClassSchedules.Count == 0)
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
403
LanMountainDesktop/Services/ComponentSettingsService.cs
Normal file
403
LanMountainDesktop/Services/ComponentSettingsService.cs
Normal file
@@ -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<ComponentSettingsSnapshot>(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<LegacyComponentSettingsSnapshot>(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<ImportedClassScheduleSnapshot> NormalizeImportedSchedules(
|
||||||
|
IReadOnlyList<ImportedClassScheduleSnapshot>? schedules)
|
||||||
|
{
|
||||||
|
if (schedules is null || schedules.Count == 0)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = new List<ImportedClassScheduleSnapshot>(schedules.Count);
|
||||||
|
var seenIds = new HashSet<string>(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<ImportedClassScheduleSnapshot> 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<ImportedClassScheduleSnapshot>? 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<string>? 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,6 +29,11 @@ public sealed record DailyWordQuery(
|
|||||||
string? Locale = null,
|
string? Locale = null,
|
||||||
bool ForceRefresh = false);
|
bool ForceRefresh = false);
|
||||||
|
|
||||||
|
public sealed record Stcn24ForumPostsQuery(
|
||||||
|
string? Locale = null,
|
||||||
|
int? ItemCount = null,
|
||||||
|
bool ForceRefresh = false);
|
||||||
|
|
||||||
public sealed record ExchangeRateQuery(
|
public sealed record ExchangeRateQuery(
|
||||||
string? BaseCurrency = null,
|
string? BaseCurrency = null,
|
||||||
string? TargetCurrency = null,
|
string? TargetCurrency = null,
|
||||||
@@ -84,6 +89,13 @@ public sealed record RecommendationApiOptions
|
|||||||
|
|
||||||
public string BilibiliSearchPageUrl { get; init; } = "https://search.bilibili.com/all";
|
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 YoudaoDictionaryApiTemplate { get; init; } = "https://dict.youdao.com/jsonapi?q={0}";
|
||||||
|
|
||||||
public string YoudaoDictionaryWordPageTemplate { get; init; } = "https://dict.youdao.com/w/eng/{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 DefaultDailyNewsCount { get; init; } = 2;
|
||||||
|
|
||||||
public int DefaultBilibiliHotSearchCount { get; init; } = 5;
|
public int DefaultBilibiliHotSearchCount { get; init; } = 5;
|
||||||
|
|
||||||
|
public int DefaultStcn24ForumPostCount { get; init; } = 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
public interface IRecommendationInfoService
|
public interface IRecommendationInfoService
|
||||||
@@ -250,6 +264,10 @@ public interface IRecommendationInfoService
|
|||||||
DailyWordQuery query,
|
DailyWordQuery query,
|
||||||
CancellationToken cancellationToken = default);
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<RecommendationQueryResult<Stcn24ForumPostsSnapshot>> GetStcn24ForumPostsAsync(
|
||||||
|
Stcn24ForumPostsQuery query,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
Task<RecommendationQueryResult<ExchangeRateSnapshot>> GetExchangeRateAsync(
|
Task<RecommendationQueryResult<ExchangeRateSnapshot>> GetExchangeRateAsync(
|
||||||
ExchangeRateQuery query,
|
ExchangeRateQuery query,
|
||||||
CancellationToken cancellationToken = default);
|
CancellationToken cancellationToken = default);
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
|
|||||||
private sealed record DailyNewsCacheEntry(DailyNewsSnapshot Snapshot, DateTimeOffset ExpireAt);
|
private sealed record DailyNewsCacheEntry(DailyNewsSnapshot Snapshot, DateTimeOffset ExpireAt);
|
||||||
private sealed record BilibiliHotSearchCacheEntry(BilibiliHotSearchSnapshot Snapshot, DateTimeOffset ExpireAt);
|
private sealed record BilibiliHotSearchCacheEntry(BilibiliHotSearchSnapshot Snapshot, DateTimeOffset ExpireAt);
|
||||||
private sealed record DailyWordCacheEntry(DailyWordSnapshot Snapshot, DateTimeOffset ExpireAt);
|
private sealed record DailyWordCacheEntry(DailyWordSnapshot Snapshot, DateTimeOffset ExpireAt);
|
||||||
|
private sealed record Stcn24ForumPostsCacheEntry(Stcn24ForumPostsSnapshot Snapshot, DateTimeOffset ExpireAt);
|
||||||
private sealed record ExchangeRateTableCacheEntry(
|
private sealed record ExchangeRateTableCacheEntry(
|
||||||
string BaseCurrency,
|
string BaseCurrency,
|
||||||
Dictionary<string, decimal> Rates,
|
Dictionary<string, decimal> Rates,
|
||||||
@@ -52,7 +53,7 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
|
|||||||
private readonly RecommendationApiOptions _options;
|
private readonly RecommendationApiOptions _options;
|
||||||
private readonly HttpClient _httpClient;
|
private readonly HttpClient _httpClient;
|
||||||
private readonly bool _ownsHttpClient;
|
private readonly bool _ownsHttpClient;
|
||||||
private readonly AppSettingsService _appSettingsService = new();
|
private readonly ComponentSettingsService _componentSettingsService = new();
|
||||||
private readonly object _cacheGate = new();
|
private readonly object _cacheGate = new();
|
||||||
private readonly Dictionary<string, DailyArtworkCacheEntry> _dailyArtworkCacheBySource =
|
private readonly Dictionary<string, DailyArtworkCacheEntry> _dailyArtworkCacheBySource =
|
||||||
new(StringComparer.OrdinalIgnoreCase);
|
new(StringComparer.OrdinalIgnoreCase);
|
||||||
@@ -60,6 +61,7 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
|
|||||||
private DailyNewsCacheEntry? _dailyNewsCache;
|
private DailyNewsCacheEntry? _dailyNewsCache;
|
||||||
private BilibiliHotSearchCacheEntry? _bilibiliHotSearchCache;
|
private BilibiliHotSearchCacheEntry? _bilibiliHotSearchCache;
|
||||||
private DailyWordCacheEntry? _dailyWordCache;
|
private DailyWordCacheEntry? _dailyWordCache;
|
||||||
|
private Stcn24ForumPostsCacheEntry? _stcn24ForumPostsCache;
|
||||||
private readonly Dictionary<string, ExchangeRateTableCacheEntry> _exchangeRateCacheByBaseCurrency =
|
private readonly Dictionary<string, ExchangeRateTableCacheEntry> _exchangeRateCacheByBaseCurrency =
|
||||||
new(StringComparer.OrdinalIgnoreCase);
|
new(StringComparer.OrdinalIgnoreCase);
|
||||||
private int _dailyNewsRotationCursor;
|
private int _dailyNewsRotationCursor;
|
||||||
@@ -106,6 +108,7 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
|
|||||||
_dailyNewsCache = null;
|
_dailyNewsCache = null;
|
||||||
_bilibiliHotSearchCache = null;
|
_bilibiliHotSearchCache = null;
|
||||||
_dailyWordCache = null;
|
_dailyWordCache = null;
|
||||||
|
_stcn24ForumPostsCache = null;
|
||||||
_exchangeRateCacheByBaseCurrency.Clear();
|
_exchangeRateCacheByBaseCurrency.Clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -340,6 +343,53 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
|
|||||||
lastError?.Message ?? "No available daily word from Youdao.");
|
lastError?.Message ?? "No available daily word from Youdao.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<RecommendationQueryResult<Stcn24ForumPostsSnapshot>> 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<Stcn24ForumPostsSnapshot>.Ok(projectedSnapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var snapshot = await FetchStcn24ForumPostsSnapshotAsync(targetCount, cancellationToken);
|
||||||
|
if (snapshot.Items.Count == 0)
|
||||||
|
{
|
||||||
|
return RecommendationQueryResult<Stcn24ForumPostsSnapshot>.Fail(
|
||||||
|
"upstream_empty_result",
|
||||||
|
"No STCN forum posts were returned.");
|
||||||
|
}
|
||||||
|
|
||||||
|
SetStcn24ForumPostsCache(snapshot);
|
||||||
|
return RecommendationQueryResult<Stcn24ForumPostsSnapshot>.Ok(snapshot);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (HttpRequestException ex)
|
||||||
|
{
|
||||||
|
return RecommendationQueryResult<Stcn24ForumPostsSnapshot>.Fail("upstream_network_error", ex.Message);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return RecommendationQueryResult<Stcn24ForumPostsSnapshot>.Fail("upstream_parse_error", ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<RecommendationQueryResult<ExchangeRateSnapshot>> GetExchangeRateAsync(
|
public async Task<RecommendationQueryResult<ExchangeRateSnapshot>> GetExchangeRateAsync(
|
||||||
ExchangeRateQuery query,
|
ExchangeRateQuery query,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
@@ -762,6 +812,141 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
|
|||||||
FetchedAt: DateTimeOffset.UtcNow);
|
FetchedAt: DateTimeOffset.UtcNow);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<Stcn24ForumPostsSnapshot> 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<string, (string? DisplayName, string? AvatarUrl)>(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<Stcn24ForumPostItemSnapshot>(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<string?> TryFetchBilibiliSearchPlaceholderAsync(CancellationToken cancellationToken)
|
private async Task<string?> TryFetchBilibiliSearchPlaceholderAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(_options.BilibiliSearchDefaultApiUrl))
|
if (string.IsNullOrWhiteSpace(_options.BilibiliSearchDefaultApiUrl))
|
||||||
@@ -864,6 +1049,31 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
|
|||||||
return selection;
|
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)
|
private bool TryGetDailyWordFromCache(out DailyWordSnapshot snapshot)
|
||||||
{
|
{
|
||||||
lock (_cacheGate)
|
lock (_cacheGate)
|
||||||
@@ -1942,6 +2152,84 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
|
|||||||
: null;
|
: 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)
|
private static string NormalizeInlineText(string? text)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(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)
|
private static JsonElement? TryGetNode(JsonElement node, params string[] path)
|
||||||
{
|
{
|
||||||
var current = node;
|
var current = node;
|
||||||
@@ -2010,7 +2322,7 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var snapshot = _appSettingsService.Load();
|
var snapshot = _componentSettingsService.Load();
|
||||||
return DailyArtworkMirrorSources.Normalize(snapshot.DailyArtworkMirrorSource);
|
return DailyArtworkMirrorSources.Normalize(snapshot.DailyArtworkMirrorSource);
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
|
|||||||
@@ -55,7 +55,8 @@ public partial class AnalogClockWidget : UserControl, IDesktopComponentWidget, I
|
|||||||
private const double DialSize = 258;
|
private const double DialSize = 258;
|
||||||
private const double Center = DialSize / 2;
|
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 readonly LocalizationService _localizationService = new();
|
||||||
private TimeZoneService? _timeZoneService;
|
private TimeZoneService? _timeZoneService;
|
||||||
private double _currentCellSize = 48;
|
private double _currentCellSize = 48;
|
||||||
@@ -357,15 +358,16 @@ public partial class AnalogClockWidget : UserControl, IDesktopComponentWidget, I
|
|||||||
|
|
||||||
private void LoadClockSettings()
|
private void LoadClockSettings()
|
||||||
{
|
{
|
||||||
var snapshot = _settingsService.Load();
|
var appSnapshot = _appSettingsService.Load();
|
||||||
_languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode);
|
var componentSnapshot = _componentSettingsService.Load();
|
||||||
|
_languageCode = _localizationService.NormalizeLanguageCode(appSnapshot.LanguageCode);
|
||||||
|
|
||||||
var configuredTimeZoneId = string.IsNullOrWhiteSpace(snapshot.DesktopClockTimeZoneId)
|
var configuredTimeZoneId = string.IsNullOrWhiteSpace(componentSnapshot.DesktopClockTimeZoneId)
|
||||||
? "China Standard Time"
|
? "China Standard Time"
|
||||||
: snapshot.DesktopClockTimeZoneId.Trim();
|
: componentSnapshot.DesktopClockTimeZoneId.Trim();
|
||||||
|
|
||||||
_clockTimeZone = WorldClockTimeZoneCatalog.ResolveTimeZoneOrLocal(configuredTimeZoneId);
|
_clockTimeZone = WorldClockTimeZoneCatalog.ResolveTimeZoneOrLocal(configuredTimeZoneId);
|
||||||
_secondHandMode = ClockSecondHandMode.Normalize(snapshot.DesktopClockSecondHandMode);
|
_secondHandMode = ClockSecondHandMode.Normalize(componentSnapshot.DesktopClockSecondHandMode);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ApplySecondHandTimerInterval()
|
private void ApplySecondHandTimerInterval()
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ public partial class AnalogClockWidgetSettingsWindow : UserControl
|
|||||||
};
|
};
|
||||||
|
|
||||||
private readonly AppSettingsService _appSettingsService = new();
|
private readonly AppSettingsService _appSettingsService = new();
|
||||||
|
private readonly ComponentSettingsService _componentSettingsService = new();
|
||||||
private readonly LocalizationService _localizationService = new();
|
private readonly LocalizationService _localizationService = new();
|
||||||
private readonly TimeZoneService _timeZoneService = new();
|
private readonly TimeZoneService _timeZoneService = new();
|
||||||
private bool _suppressEvents;
|
private bool _suppressEvents;
|
||||||
@@ -48,12 +49,13 @@ public partial class AnalogClockWidgetSettingsWindow : UserControl
|
|||||||
|
|
||||||
private void LoadState()
|
private void LoadState()
|
||||||
{
|
{
|
||||||
var snapshot = _appSettingsService.Load();
|
var appSnapshot = _appSettingsService.Load();
|
||||||
_languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode);
|
var componentSnapshot = _componentSettingsService.Load();
|
||||||
_selectedTimeZoneId = string.IsNullOrWhiteSpace(snapshot.DesktopClockTimeZoneId)
|
_languageCode = _localizationService.NormalizeLanguageCode(appSnapshot.LanguageCode);
|
||||||
|
_selectedTimeZoneId = string.IsNullOrWhiteSpace(componentSnapshot.DesktopClockTimeZoneId)
|
||||||
? "China Standard Time"
|
? "China Standard Time"
|
||||||
: snapshot.DesktopClockTimeZoneId.Trim();
|
: componentSnapshot.DesktopClockTimeZoneId.Trim();
|
||||||
_secondHandMode = ClockSecondHandMode.Normalize(snapshot.DesktopClockSecondHandMode);
|
_secondHandMode = ClockSecondHandMode.Normalize(componentSnapshot.DesktopClockSecondHandMode);
|
||||||
|
|
||||||
_allTimeZones = _timeZoneService
|
_allTimeZones = _timeZoneService
|
||||||
.GetAllTimeZones()
|
.GetAllTimeZones()
|
||||||
@@ -147,10 +149,10 @@ public partial class AnalogClockWidgetSettingsWindow : UserControl
|
|||||||
_selectedTimeZoneId = normalizedId;
|
_selectedTimeZoneId = normalizedId;
|
||||||
_secondHandMode = GetSelectedSecondHandMode();
|
_secondHandMode = GetSelectedSecondHandMode();
|
||||||
|
|
||||||
var snapshot = _appSettingsService.Load();
|
var snapshot = _componentSettingsService.Load();
|
||||||
snapshot.DesktopClockTimeZoneId = normalizedId;
|
snapshot.DesktopClockTimeZoneId = normalizedId;
|
||||||
snapshot.DesktopClockSecondHandMode = _secondHandMode;
|
snapshot.DesktopClockSecondHandMode = _secondHandMode;
|
||||||
_appSettingsService.Save(snapshot);
|
_componentSettingsService.Save(snapshot);
|
||||||
|
|
||||||
SettingsChanged?.Invoke(this, EventArgs.Empty);
|
SettingsChanged?.Invoke(this, EventArgs.Empty);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
mc:Ignorable="d"
|
||||||
|
d:DesignWidth="420"
|
||||||
|
d:DesignHeight="300"
|
||||||
|
x:Class="LanMountainDesktop.Views.Components.BilibiliHotSearchSettingsWindow">
|
||||||
|
<Border Background="{DynamicResource AdaptiveBackgroundBrush}"
|
||||||
|
Padding="16">
|
||||||
|
<Grid RowDefinitions="Auto,Auto,*"
|
||||||
|
RowSpacing="10">
|
||||||
|
<TextBlock x:Name="TitleTextBlock"
|
||||||
|
Text="Bilibili hot search settings"
|
||||||
|
FontSize="18"
|
||||||
|
FontWeight="SemiBold"
|
||||||
|
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||||
|
|
||||||
|
<TextBlock x:Name="DescriptionTextBlock"
|
||||||
|
Grid.Row="1"
|
||||||
|
Text="Configure auto refresh and refresh interval."
|
||||||
|
FontSize="12"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" />
|
||||||
|
|
||||||
|
<ScrollViewer Grid.Row="2"
|
||||||
|
HorizontalScrollBarVisibility="Disabled"
|
||||||
|
VerticalScrollBarVisibility="Auto">
|
||||||
|
<StackPanel Spacing="10"
|
||||||
|
Margin="0,0,6,0">
|
||||||
|
<Border Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
|
||||||
|
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
|
||||||
|
BorderThickness="1"
|
||||||
|
CornerRadius="12"
|
||||||
|
Padding="12">
|
||||||
|
<StackPanel Spacing="6">
|
||||||
|
<TextBlock x:Name="AutoRefreshLabelTextBlock"
|
||||||
|
Text="Auto refresh"
|
||||||
|
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||||
|
<CheckBox x:Name="AutoRefreshCheckBox"
|
||||||
|
Content="Enable auto refresh"
|
||||||
|
Checked="OnAutoRefreshChanged"
|
||||||
|
Unchecked="OnAutoRefreshChanged" />
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<Border x:Name="FrequencyCardBorder"
|
||||||
|
Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
|
||||||
|
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
|
||||||
|
BorderThickness="1"
|
||||||
|
CornerRadius="12"
|
||||||
|
Padding="12"
|
||||||
|
IsVisible="False">
|
||||||
|
<StackPanel Spacing="6">
|
||||||
|
<TextBlock x:Name="FrequencyLabelTextBlock"
|
||||||
|
Text="Refresh interval"
|
||||||
|
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||||
|
<ComboBox x:Name="FrequencyComboBox"
|
||||||
|
HorizontalAlignment="Stretch"
|
||||||
|
MinWidth="0"
|
||||||
|
SelectionChanged="OnFrequencySelectionChanged">
|
||||||
|
<ComboBoxItem x:Name="Frequency5mItem"
|
||||||
|
Tag="5"
|
||||||
|
Content="5 min" />
|
||||||
|
<ComboBoxItem x:Name="Frequency10mItem"
|
||||||
|
Tag="10"
|
||||||
|
Content="10 min" />
|
||||||
|
<ComboBoxItem x:Name="Frequency15mItem"
|
||||||
|
Tag="15"
|
||||||
|
Content="15 min" />
|
||||||
|
<ComboBoxItem x:Name="Frequency30mItem"
|
||||||
|
Tag="30"
|
||||||
|
Content="30 min" />
|
||||||
|
<ComboBoxItem x:Name="Frequency1hItem"
|
||||||
|
Tag="60"
|
||||||
|
Content="1 hour" />
|
||||||
|
<ComboBoxItem x:Name="Frequency3hItem"
|
||||||
|
Tag="180"
|
||||||
|
Content="3 hours" />
|
||||||
|
</ComboBox>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</StackPanel>
|
||||||
|
</ScrollViewer>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</UserControl>
|
||||||
@@ -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<ComboBoxItem>()
|
||||||
|
.FirstOrDefault(item =>
|
||||||
|
item.Tag is string tagText &&
|
||||||
|
int.TryParse(tagText, out var minutes) &&
|
||||||
|
minutes == intervalMinutes);
|
||||||
|
FrequencyComboBox.SelectedItem = selected ?? FrequencyComboBox.Items.OfType<ComboBoxItem>().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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
|
using System.Linq;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
@@ -24,13 +25,15 @@ public partial class BilibiliHotSearchWidget : UserControl, IDesktopComponentWid
|
|||||||
private const int BaseWidthCells = 4;
|
private const int BaseWidthCells = 4;
|
||||||
private const int BaseHeightCells = 2;
|
private const int BaseHeightCells = 2;
|
||||||
private const int MaxDisplayItemCount = 4;
|
private const int MaxDisplayItemCount = 4;
|
||||||
|
private static readonly int[] SupportedAutoRefreshIntervalsMinutes = [5, 10, 15, 30, 60, 180];
|
||||||
|
|
||||||
private readonly DispatcherTimer _refreshTimer = new()
|
private readonly DispatcherTimer _refreshTimer = new()
|
||||||
{
|
{
|
||||||
Interval = TimeSpan.FromMinutes(15)
|
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 LocalizationService _localizationService = new();
|
||||||
private readonly List<BilibiliHotSearchItemSnapshot> _activeItems = [];
|
private readonly List<BilibiliHotSearchItemSnapshot> _activeItems = [];
|
||||||
private readonly List<HotItemVisual> _hotItemVisuals = [];
|
private readonly List<HotItemVisual> _hotItemVisuals = [];
|
||||||
@@ -42,6 +45,7 @@ public partial class BilibiliHotSearchWidget : UserControl, IDesktopComponentWid
|
|||||||
private double _currentCellSize = BaseCellSize;
|
private double _currentCellSize = BaseCellSize;
|
||||||
private bool _isAttached;
|
private bool _isAttached;
|
||||||
private bool _isRefreshing;
|
private bool _isRefreshing;
|
||||||
|
private bool _autoRefreshEnabled = true;
|
||||||
|
|
||||||
private sealed record HotItemVisual(
|
private sealed record HotItemVisual(
|
||||||
Border Host,
|
Border Host,
|
||||||
@@ -77,6 +81,7 @@ public partial class BilibiliHotSearchWidget : UserControl, IDesktopComponentWid
|
|||||||
|
|
||||||
ApplyCellSize(_currentCellSize);
|
ApplyCellSize(_currentCellSize);
|
||||||
UpdateLanguageCode();
|
UpdateLanguageCode();
|
||||||
|
ApplyAutoRefreshSettings();
|
||||||
ApplyLoadingState();
|
ApplyLoadingState();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,6 +103,7 @@ public partial class BilibiliHotSearchWidget : UserControl, IDesktopComponentWid
|
|||||||
public void RefreshFromSettings()
|
public void RefreshFromSettings()
|
||||||
{
|
{
|
||||||
_recommendationService.ClearCache();
|
_recommendationService.ClearCache();
|
||||||
|
ApplyAutoRefreshSettings();
|
||||||
if (_isAttached)
|
if (_isAttached)
|
||||||
{
|
{
|
||||||
_ = RefreshHotSearchAsync(forceRefresh: true);
|
_ = RefreshHotSearchAsync(forceRefresh: true);
|
||||||
@@ -107,7 +113,7 @@ public partial class BilibiliHotSearchWidget : UserControl, IDesktopComponentWid
|
|||||||
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||||
{
|
{
|
||||||
_isAttached = true;
|
_isAttached = true;
|
||||||
_refreshTimer.Start();
|
ApplyAutoRefreshSettings();
|
||||||
_ = RefreshHotSearchAsync(forceRefresh: false);
|
_ = RefreshHotSearchAsync(forceRefresh: false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -417,7 +423,7 @@ public partial class BilibiliHotSearchWidget : UserControl, IDesktopComponentWid
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var snapshot = _settingsService.Load();
|
var snapshot = _appSettingsService.Load();
|
||||||
_languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode);
|
_languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode);
|
||||||
}
|
}
|
||||||
catch
|
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)
|
private static string NormalizeCompactText(string? text)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(text))
|
if (string.IsNullOrWhiteSpace(text))
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ namespace LanMountainDesktop.Views.Components;
|
|||||||
public partial class ClassScheduleSettingsWindow : UserControl
|
public partial class ClassScheduleSettingsWindow : UserControl
|
||||||
{
|
{
|
||||||
private readonly AppSettingsService _appSettingsService = new();
|
private readonly AppSettingsService _appSettingsService = new();
|
||||||
|
private readonly ComponentSettingsService _componentSettingsService = new();
|
||||||
private readonly LocalizationService _localizationService = new();
|
private readonly LocalizationService _localizationService = new();
|
||||||
private readonly List<ImportedClassScheduleSnapshot> _importedSchedules = [];
|
private readonly List<ImportedClassScheduleSnapshot> _importedSchedules = [];
|
||||||
private string _activeScheduleId = string.Empty;
|
private string _activeScheduleId = string.Empty;
|
||||||
@@ -35,11 +36,12 @@ public partial class ClassScheduleSettingsWindow : UserControl
|
|||||||
|
|
||||||
private void LoadState()
|
private void LoadState()
|
||||||
{
|
{
|
||||||
var snapshot = _appSettingsService.Load();
|
var appSnapshot = _appSettingsService.Load();
|
||||||
_languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode);
|
var componentSnapshot = _componentSettingsService.Load();
|
||||||
|
_languageCode = _localizationService.NormalizeLanguageCode(appSnapshot.LanguageCode);
|
||||||
|
|
||||||
_importedSchedules.Clear();
|
_importedSchedules.Clear();
|
||||||
foreach (var item in snapshot.ImportedClassSchedules)
|
foreach (var item in componentSnapshot.ImportedClassSchedules)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(item.Id) ||
|
if (string.IsNullOrWhiteSpace(item.Id) ||
|
||||||
string.IsNullOrWhiteSpace(item.FilePath))
|
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 &&
|
if (_importedSchedules.Count > 0 &&
|
||||||
!_importedSchedules.Any(item => string.Equals(item.Id, _activeScheduleId, StringComparison.OrdinalIgnoreCase)))
|
!_importedSchedules.Any(item => string.Equals(item.Id, _activeScheduleId, StringComparison.OrdinalIgnoreCase)))
|
||||||
{
|
{
|
||||||
@@ -297,7 +299,7 @@ public partial class ClassScheduleSettingsWindow : UserControl
|
|||||||
|
|
||||||
private void SaveState()
|
private void SaveState()
|
||||||
{
|
{
|
||||||
var snapshot = _appSettingsService.Load();
|
var snapshot = _componentSettingsService.Load();
|
||||||
snapshot.ImportedClassSchedules = _importedSchedules
|
snapshot.ImportedClassSchedules = _importedSchedules
|
||||||
.Select(item => new ImportedClassScheduleSnapshot
|
.Select(item => new ImportedClassScheduleSnapshot
|
||||||
{
|
{
|
||||||
@@ -307,7 +309,7 @@ public partial class ClassScheduleSettingsWindow : UserControl
|
|||||||
})
|
})
|
||||||
.ToList();
|
.ToList();
|
||||||
snapshot.ActiveImportedClassScheduleId = _activeScheduleId ?? string.Empty;
|
snapshot.ActiveImportedClassScheduleId = _activeScheduleId ?? string.Empty;
|
||||||
_appSettingsService.Save(snapshot);
|
_componentSettingsService.Save(snapshot);
|
||||||
SettingsChanged?.Invoke(this, EventArgs.Empty);
|
SettingsChanged?.Invoke(this, EventArgs.Empty);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget,
|
|||||||
};
|
};
|
||||||
|
|
||||||
private readonly AppSettingsService _appSettingsService = new();
|
private readonly AppSettingsService _appSettingsService = new();
|
||||||
|
private readonly ComponentSettingsService _componentSettingsService = new();
|
||||||
private readonly LocalizationService _localizationService = new();
|
private readonly LocalizationService _localizationService = new();
|
||||||
private readonly IClassIslandScheduleDataService _scheduleService = new ClassIslandScheduleDataService();
|
private readonly IClassIslandScheduleDataService _scheduleService = new ClassIslandScheduleDataService();
|
||||||
|
|
||||||
@@ -115,11 +116,12 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget,
|
|||||||
private void RefreshSchedule()
|
private void RefreshSchedule()
|
||||||
{
|
{
|
||||||
var appSettings = _appSettingsService.Load();
|
var appSettings = _appSettingsService.Load();
|
||||||
|
var componentSettings = _componentSettingsService.Load();
|
||||||
_languageCode = _localizationService.NormalizeLanguageCode(appSettings.LanguageCode);
|
_languageCode = _localizationService.NormalizeLanguageCode(appSettings.LanguageCode);
|
||||||
var now = _timeZoneService?.GetCurrentTime() ?? DateTime.Now;
|
var now = _timeZoneService?.GetCurrentTime() ?? DateTime.Now;
|
||||||
UpdateHeader(now);
|
UpdateHeader(now);
|
||||||
|
|
||||||
var importedSchedulePath = ResolveImportedSchedulePath(appSettings);
|
var importedSchedulePath = ResolveImportedSchedulePath(componentSettings);
|
||||||
var readResult = _scheduleService.Load(importedSchedulePath);
|
var readResult = _scheduleService.Load(importedSchedulePath);
|
||||||
if (!readResult.Success || readResult.Snapshot is null)
|
if (!readResult.Success || readResult.Snapshot is null)
|
||||||
{
|
{
|
||||||
@@ -273,7 +275,7 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget,
|
|||||||
return dayOfWeek.ToString()[..3];
|
return dayOfWeek.ToString()[..3];
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string? ResolveImportedSchedulePath(AppSettingsSnapshot snapshot)
|
private static string? ResolveImportedSchedulePath(ComponentSettingsSnapshot snapshot)
|
||||||
{
|
{
|
||||||
if (snapshot.ImportedClassSchedules.Count == 0)
|
if (snapshot.ImportedClassSchedules.Count == 0)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ public partial class CnrDailyNewsSettingsWindow : UserControl
|
|||||||
private static readonly int[] SupportedIntervals = [5, 10, 40, 60, 720, 1440];
|
private static readonly int[] SupportedIntervals = [5, 10, 40, 60, 720, 1440];
|
||||||
|
|
||||||
private readonly AppSettingsService _appSettingsService = new();
|
private readonly AppSettingsService _appSettingsService = new();
|
||||||
|
private readonly ComponentSettingsService _componentSettingsService = new();
|
||||||
private readonly LocalizationService _localizationService = new();
|
private readonly LocalizationService _localizationService = new();
|
||||||
private bool _suppressEvents;
|
private bool _suppressEvents;
|
||||||
private string _languageCode = "zh-CN";
|
private string _languageCode = "zh-CN";
|
||||||
@@ -27,11 +28,12 @@ public partial class CnrDailyNewsSettingsWindow : UserControl
|
|||||||
|
|
||||||
private void LoadState()
|
private void LoadState()
|
||||||
{
|
{
|
||||||
var snapshot = _appSettingsService.Load();
|
var appSnapshot = _appSettingsService.Load();
|
||||||
_languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode);
|
var componentSnapshot = _componentSettingsService.Load();
|
||||||
|
_languageCode = _localizationService.NormalizeLanguageCode(appSnapshot.LanguageCode);
|
||||||
|
|
||||||
var enabled = snapshot.CnrDailyNewsAutoRotateEnabled;
|
var enabled = componentSnapshot.CnrDailyNewsAutoRotateEnabled;
|
||||||
var interval = NormalizeInterval(snapshot.CnrDailyNewsAutoRotateIntervalMinutes);
|
var interval = NormalizeInterval(componentSnapshot.CnrDailyNewsAutoRotateIntervalMinutes);
|
||||||
|
|
||||||
_suppressEvents = true;
|
_suppressEvents = true;
|
||||||
AutoRotateCheckBox.IsChecked = enabled;
|
AutoRotateCheckBox.IsChecked = enabled;
|
||||||
@@ -83,10 +85,10 @@ public partial class CnrDailyNewsSettingsWindow : UserControl
|
|||||||
|
|
||||||
private void SaveState()
|
private void SaveState()
|
||||||
{
|
{
|
||||||
var snapshot = _appSettingsService.Load();
|
var snapshot = _componentSettingsService.Load();
|
||||||
snapshot.CnrDailyNewsAutoRotateEnabled = AutoRotateCheckBox.IsChecked == true;
|
snapshot.CnrDailyNewsAutoRotateEnabled = AutoRotateCheckBox.IsChecked == true;
|
||||||
snapshot.CnrDailyNewsAutoRotateIntervalMinutes = GetSelectedInterval();
|
snapshot.CnrDailyNewsAutoRotateIntervalMinutes = GetSelectedInterval();
|
||||||
_appSettingsService.Save(snapshot);
|
_componentSettingsService.Save(snapshot);
|
||||||
SettingsChanged?.Invoke(this, EventArgs.Empty);
|
SettingsChanged?.Invoke(this, EventArgs.Empty);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,8 @@ public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget,
|
|||||||
Interval = TimeSpan.FromMinutes(30)
|
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 LocalizationService _localizationService = new();
|
||||||
private readonly Bitmap?[] _newsBitmaps = new Bitmap?[2];
|
private readonly Bitmap?[] _newsBitmaps = new Bitmap?[2];
|
||||||
private readonly List<string?> _newsUrls = [];
|
private readonly List<string?> _newsUrls = [];
|
||||||
@@ -705,7 +706,7 @@ public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget,
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var snapshot = _settingsService.Load();
|
var snapshot = _appSettingsService.Load();
|
||||||
_languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode);
|
_languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode);
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
@@ -721,7 +722,7 @@ public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget,
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var snapshot = _settingsService.Load();
|
var snapshot = _componentSettingsService.Load();
|
||||||
enabled = snapshot.CnrDailyNewsAutoRotateEnabled;
|
enabled = snapshot.CnrDailyNewsAutoRotateEnabled;
|
||||||
intervalMinutes = NormalizeAutoRotateIntervalMinutes(snapshot.CnrDailyNewsAutoRotateIntervalMinutes);
|
intervalMinutes = NormalizeAutoRotateIntervalMinutes(snapshot.CnrDailyNewsAutoRotateIntervalMinutes);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,14 +10,13 @@ namespace LanMountainDesktop.Views.Components;
|
|||||||
public partial class DailyArtworkSettingsWindow : UserControl
|
public partial class DailyArtworkSettingsWindow : UserControl
|
||||||
{
|
{
|
||||||
private readonly AppSettingsService _appSettingsService = new();
|
private readonly AppSettingsService _appSettingsService = new();
|
||||||
|
private readonly ComponentSettingsService _componentSettingsService = new();
|
||||||
private readonly LocalizationService _localizationService = new();
|
private readonly LocalizationService _localizationService = new();
|
||||||
private string _languageCode = "zh-CN";
|
private string _languageCode = "zh-CN";
|
||||||
private bool _suppressEvents;
|
private bool _suppressEvents;
|
||||||
|
|
||||||
public event EventHandler? SettingsChanged;
|
public event EventHandler? SettingsChanged;
|
||||||
|
|
||||||
public string CurrentSource => GetSelectedSource();
|
|
||||||
|
|
||||||
public DailyArtworkSettingsWindow()
|
public DailyArtworkSettingsWindow()
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
@@ -27,10 +26,11 @@ public partial class DailyArtworkSettingsWindow : UserControl
|
|||||||
|
|
||||||
private void LoadState()
|
private void LoadState()
|
||||||
{
|
{
|
||||||
var snapshot = _appSettingsService.Load();
|
var appSnapshot = _appSettingsService.Load();
|
||||||
_languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode);
|
var componentSnapshot = _componentSettingsService.Load();
|
||||||
|
_languageCode = _localizationService.NormalizeLanguageCode(appSnapshot.LanguageCode);
|
||||||
|
|
||||||
var source = DailyArtworkMirrorSources.Normalize(snapshot.DailyArtworkMirrorSource);
|
var source = DailyArtworkMirrorSources.Normalize(componentSnapshot.DailyArtworkMirrorSource);
|
||||||
_suppressEvents = true;
|
_suppressEvents = true;
|
||||||
MirrorSourceComboBox.SelectedIndex = string.Equals(source, DailyArtworkMirrorSources.Domestic, StringComparison.OrdinalIgnoreCase)
|
MirrorSourceComboBox.SelectedIndex = string.Equals(source, DailyArtworkMirrorSources.Domestic, StringComparison.OrdinalIgnoreCase)
|
||||||
? 0
|
? 0
|
||||||
@@ -59,9 +59,9 @@ public partial class DailyArtworkSettingsWindow : UserControl
|
|||||||
}
|
}
|
||||||
|
|
||||||
var source = GetSelectedSource();
|
var source = GetSelectedSource();
|
||||||
var snapshot = _appSettingsService.Load();
|
var snapshot = _componentSettingsService.Load();
|
||||||
snapshot.DailyArtworkMirrorSource = source;
|
snapshot.DailyArtworkMirrorSource = source;
|
||||||
_appSettingsService.Save(snapshot);
|
_componentSettingsService.Save(snapshot);
|
||||||
|
|
||||||
UpdateSourceStatus(source);
|
UpdateSourceStatus(source);
|
||||||
SettingsChanged?.Invoke(this, EventArgs.Empty);
|
SettingsChanged?.Invoke(this, EventArgs.Empty);
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
mc:Ignorable="d"
|
||||||
|
d:DesignWidth="420"
|
||||||
|
d:DesignHeight="300"
|
||||||
|
x:Class="LanMountainDesktop.Views.Components.DailyWordSettingsWindow">
|
||||||
|
<Border Background="{DynamicResource AdaptiveBackgroundBrush}"
|
||||||
|
Padding="16">
|
||||||
|
<Grid RowDefinitions="Auto,Auto,*"
|
||||||
|
RowSpacing="10">
|
||||||
|
<TextBlock x:Name="TitleTextBlock"
|
||||||
|
Text="Daily word settings"
|
||||||
|
FontSize="18"
|
||||||
|
FontWeight="SemiBold"
|
||||||
|
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||||
|
|
||||||
|
<TextBlock x:Name="DescriptionTextBlock"
|
||||||
|
Grid.Row="1"
|
||||||
|
Text="Configure auto refresh and refresh interval."
|
||||||
|
FontSize="12"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" />
|
||||||
|
|
||||||
|
<ScrollViewer Grid.Row="2"
|
||||||
|
HorizontalScrollBarVisibility="Disabled"
|
||||||
|
VerticalScrollBarVisibility="Auto">
|
||||||
|
<StackPanel Spacing="10"
|
||||||
|
Margin="0,0,6,0">
|
||||||
|
<Border Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
|
||||||
|
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
|
||||||
|
BorderThickness="1"
|
||||||
|
CornerRadius="12"
|
||||||
|
Padding="12">
|
||||||
|
<StackPanel Spacing="6">
|
||||||
|
<TextBlock x:Name="AutoRefreshLabelTextBlock"
|
||||||
|
Text="Auto refresh"
|
||||||
|
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||||
|
<CheckBox x:Name="AutoRefreshCheckBox"
|
||||||
|
Content="Enable auto refresh"
|
||||||
|
Checked="OnAutoRefreshChanged"
|
||||||
|
Unchecked="OnAutoRefreshChanged" />
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<Border x:Name="FrequencyCardBorder"
|
||||||
|
Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
|
||||||
|
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
|
||||||
|
BorderThickness="1"
|
||||||
|
CornerRadius="12"
|
||||||
|
Padding="12"
|
||||||
|
IsVisible="False">
|
||||||
|
<StackPanel Spacing="6">
|
||||||
|
<TextBlock x:Name="FrequencyLabelTextBlock"
|
||||||
|
Text="Refresh interval"
|
||||||
|
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||||
|
<ComboBox x:Name="FrequencyComboBox"
|
||||||
|
HorizontalAlignment="Stretch"
|
||||||
|
MinWidth="0"
|
||||||
|
SelectionChanged="OnFrequencySelectionChanged">
|
||||||
|
<ComboBoxItem x:Name="Frequency30mItem"
|
||||||
|
Tag="30"
|
||||||
|
Content="30 min" />
|
||||||
|
<ComboBoxItem x:Name="Frequency1hItem"
|
||||||
|
Tag="60"
|
||||||
|
Content="1 hour" />
|
||||||
|
<ComboBoxItem x:Name="Frequency3hItem"
|
||||||
|
Tag="180"
|
||||||
|
Content="3 hours" />
|
||||||
|
<ComboBoxItem x:Name="Frequency6hItem"
|
||||||
|
Tag="360"
|
||||||
|
Content="6 hours" />
|
||||||
|
<ComboBoxItem x:Name="Frequency12hItem"
|
||||||
|
Tag="720"
|
||||||
|
Content="12 hours" />
|
||||||
|
<ComboBoxItem x:Name="Frequency24hItem"
|
||||||
|
Tag="1440"
|
||||||
|
Content="24 hours" />
|
||||||
|
</ComboBox>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</StackPanel>
|
||||||
|
</ScrollViewer>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</UserControl>
|
||||||
@@ -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<ComboBoxItem>()
|
||||||
|
.FirstOrDefault(item =>
|
||||||
|
item.Tag is string tagText &&
|
||||||
|
int.TryParse(tagText, out var minutes) &&
|
||||||
|
minutes == intervalMinutes);
|
||||||
|
FrequencyComboBox.SelectedItem = selected ?? FrequencyComboBox.Items.OfType<ComboBoxItem>().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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Linq;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
@@ -21,13 +22,15 @@ public partial class DailyWordWidget : UserControl, IDesktopComponentWidget, IRe
|
|||||||
private const double BaseCellSize = 48d;
|
private const double BaseCellSize = 48d;
|
||||||
private const int BaseWidthCells = 4;
|
private const int BaseWidthCells = 4;
|
||||||
private const int BaseHeightCells = 2;
|
private const int BaseHeightCells = 2;
|
||||||
|
private static readonly int[] SupportedAutoRefreshIntervalsMinutes = [30, 60, 180, 360, 720, 1440];
|
||||||
|
|
||||||
private readonly DispatcherTimer _refreshTimer = new()
|
private readonly DispatcherTimer _refreshTimer = new()
|
||||||
{
|
{
|
||||||
Interval = TimeSpan.FromHours(6)
|
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 readonly LocalizationService _localizationService = new();
|
||||||
|
|
||||||
private IRecommendationInfoService _recommendationService = DefaultRecommendationService;
|
private IRecommendationInfoService _recommendationService = DefaultRecommendationService;
|
||||||
@@ -36,6 +39,7 @@ public partial class DailyWordWidget : UserControl, IDesktopComponentWidget, IRe
|
|||||||
private double _currentCellSize = BaseCellSize;
|
private double _currentCellSize = BaseCellSize;
|
||||||
private bool _isAttached;
|
private bool _isAttached;
|
||||||
private bool _isRefreshing;
|
private bool _isRefreshing;
|
||||||
|
private bool _autoRefreshEnabled = true;
|
||||||
|
|
||||||
public DailyWordWidget()
|
public DailyWordWidget()
|
||||||
{
|
{
|
||||||
@@ -56,6 +60,7 @@ public partial class DailyWordWidget : UserControl, IDesktopComponentWidget, IRe
|
|||||||
|
|
||||||
ApplyCellSize(_currentCellSize);
|
ApplyCellSize(_currentCellSize);
|
||||||
UpdateLanguageCode();
|
UpdateLanguageCode();
|
||||||
|
ApplyAutoRefreshSettings();
|
||||||
ApplyLoadingState();
|
ApplyLoadingState();
|
||||||
UpdateRefreshButtonState();
|
UpdateRefreshButtonState();
|
||||||
}
|
}
|
||||||
@@ -78,6 +83,7 @@ public partial class DailyWordWidget : UserControl, IDesktopComponentWidget, IRe
|
|||||||
public void RefreshFromSettings()
|
public void RefreshFromSettings()
|
||||||
{
|
{
|
||||||
_recommendationService.ClearCache();
|
_recommendationService.ClearCache();
|
||||||
|
ApplyAutoRefreshSettings();
|
||||||
if (_isAttached)
|
if (_isAttached)
|
||||||
{
|
{
|
||||||
_ = RefreshWordAsync(forceRefresh: true);
|
_ = RefreshWordAsync(forceRefresh: true);
|
||||||
@@ -87,8 +93,8 @@ public partial class DailyWordWidget : UserControl, IDesktopComponentWidget, IRe
|
|||||||
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||||
{
|
{
|
||||||
_isAttached = true;
|
_isAttached = true;
|
||||||
|
ApplyAutoRefreshSettings();
|
||||||
UpdateRefreshButtonState();
|
UpdateRefreshButtonState();
|
||||||
_refreshTimer.Start();
|
|
||||||
_ = RefreshWordAsync(forceRefresh: false);
|
_ = RefreshWordAsync(forceRefresh: false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -343,7 +349,7 @@ public partial class DailyWordWidget : UserControl, IDesktopComponentWidget, IRe
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var snapshot = _settingsService.Load();
|
var snapshot = _appSettingsService.Load();
|
||||||
_languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode);
|
_languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode);
|
||||||
}
|
}
|
||||||
catch
|
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()
|
private void CancelRefreshRequest()
|
||||||
{
|
{
|
||||||
var cts = Interlocked.Exchange(ref _refreshCts, null);
|
var cts = Interlocked.Exchange(ref _refreshCts, null);
|
||||||
|
|||||||
@@ -250,6 +250,11 @@ public sealed class DesktopComponentRuntimeRegistry
|
|||||||
"component.bilibili_hot_search",
|
"component.bilibili_hot_search",
|
||||||
() => new BilibiliHotSearchWidget(),
|
() => new BilibiliHotSearchWidget(),
|
||||||
cellSize => Math.Clamp(cellSize * 0.34, 14, 30)),
|
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(
|
new DesktopComponentRuntimeRegistration(
|
||||||
BuiltInComponentIds.DesktopExchangeRateCalculator,
|
BuiltInComponentIds.DesktopExchangeRateCalculator,
|
||||||
"component.exchange_rate_converter",
|
"component.exchange_rate_converter",
|
||||||
|
|||||||
246
LanMountainDesktop/Views/Components/Stcn24ForumWidget.axaml
Normal file
246
LanMountainDesktop/Views/Components/Stcn24ForumWidget.axaml
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
xmlns:fi="using:FluentIcons.Avalonia"
|
||||||
|
mc:Ignorable="d"
|
||||||
|
d:DesignWidth="320"
|
||||||
|
d:DesignHeight="320"
|
||||||
|
x:Class="LanMountainDesktop.Views.Components.Stcn24ForumWidget">
|
||||||
|
|
||||||
|
<Border x:Name="RootBorder"
|
||||||
|
CornerRadius="28"
|
||||||
|
Background="Transparent"
|
||||||
|
ClipToBounds="True"
|
||||||
|
BorderThickness="0"
|
||||||
|
Padding="0">
|
||||||
|
<Grid>
|
||||||
|
<Border x:Name="CardBorder"
|
||||||
|
Background="#FCFCFD"
|
||||||
|
CornerRadius="28"
|
||||||
|
BorderBrush="Transparent"
|
||||||
|
BorderThickness="0"
|
||||||
|
Padding="12,12,12,12">
|
||||||
|
<Grid x:Name="ContentGrid"
|
||||||
|
RowDefinitions="Auto,Auto,Auto,Auto,Auto"
|
||||||
|
RowSpacing="6">
|
||||||
|
<Grid x:Name="HeaderGrid"
|
||||||
|
Grid.Row="0"
|
||||||
|
ColumnDefinitions="*,Auto"
|
||||||
|
ColumnSpacing="8">
|
||||||
|
<StackPanel Orientation="Horizontal"
|
||||||
|
Spacing="8"
|
||||||
|
VerticalAlignment="Center">
|
||||||
|
<Border x:Name="HeaderDot"
|
||||||
|
Width="8"
|
||||||
|
Height="8"
|
||||||
|
CornerRadius="4"
|
||||||
|
Background="#FF4D4F"
|
||||||
|
VerticalAlignment="Center" />
|
||||||
|
<TextBlock x:Name="HeaderTitleTextBlock"
|
||||||
|
Text="STCN 24"
|
||||||
|
Foreground="#202327"
|
||||||
|
FontSize="20"
|
||||||
|
FontWeight="Bold"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
MaxLines="1"
|
||||||
|
TextTrimming="CharacterEllipsis" />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<Button x:Name="RefreshButton"
|
||||||
|
Grid.Column="1"
|
||||||
|
Width="34"
|
||||||
|
Height="34"
|
||||||
|
CornerRadius="17"
|
||||||
|
Background="#EFF1F5"
|
||||||
|
BorderBrush="Transparent"
|
||||||
|
BorderThickness="0"
|
||||||
|
Padding="0"
|
||||||
|
Focusable="False"
|
||||||
|
Click="OnRefreshButtonClick">
|
||||||
|
<fi:SymbolIcon x:Name="RefreshGlyphIcon"
|
||||||
|
Symbol="ArrowClockwise"
|
||||||
|
IconVariant="Regular"
|
||||||
|
Foreground="#5E6671"
|
||||||
|
FontSize="16"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
VerticalAlignment="Center" />
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Border x:Name="PostItem1Host"
|
||||||
|
Grid.Row="1"
|
||||||
|
Tag="0"
|
||||||
|
Background="#F7F8FA"
|
||||||
|
CornerRadius="10"
|
||||||
|
Padding="8,6"
|
||||||
|
PointerPressed="OnPostItemPointerPressed">
|
||||||
|
<Grid x:Name="PostItem1Grid"
|
||||||
|
ColumnDefinitions="Auto,*"
|
||||||
|
ColumnSpacing="8">
|
||||||
|
<Border x:Name="PostItem1AvatarHost"
|
||||||
|
Width="30"
|
||||||
|
Height="30"
|
||||||
|
CornerRadius="15"
|
||||||
|
Background="#E7EBF4"
|
||||||
|
ClipToBounds="True">
|
||||||
|
<Grid>
|
||||||
|
<Image x:Name="PostItem1AvatarImage"
|
||||||
|
Stretch="UniformToFill" />
|
||||||
|
<TextBlock x:Name="PostItem1AvatarFallbackText"
|
||||||
|
Text="?"
|
||||||
|
Foreground="#4A5466"
|
||||||
|
FontSize="13"
|
||||||
|
FontWeight="SemiBold"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
VerticalAlignment="Center" />
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<TextBlock x:Name="PostItem1TitleTextBlock"
|
||||||
|
Grid.Column="1"
|
||||||
|
Text="Loading..."
|
||||||
|
Foreground="#202327"
|
||||||
|
FontSize="14"
|
||||||
|
FontWeight="SemiBold"
|
||||||
|
MaxLines="1"
|
||||||
|
TextTrimming="CharacterEllipsis"
|
||||||
|
VerticalAlignment="Center" />
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<Border x:Name="PostItem2Host"
|
||||||
|
Grid.Row="2"
|
||||||
|
Tag="1"
|
||||||
|
Background="#F7F8FA"
|
||||||
|
CornerRadius="10"
|
||||||
|
Padding="8,6"
|
||||||
|
PointerPressed="OnPostItemPointerPressed">
|
||||||
|
<Grid x:Name="PostItem2Grid"
|
||||||
|
ColumnDefinitions="Auto,*"
|
||||||
|
ColumnSpacing="8">
|
||||||
|
<Border x:Name="PostItem2AvatarHost"
|
||||||
|
Width="30"
|
||||||
|
Height="30"
|
||||||
|
CornerRadius="15"
|
||||||
|
Background="#E7EBF4"
|
||||||
|
ClipToBounds="True">
|
||||||
|
<Grid>
|
||||||
|
<Image x:Name="PostItem2AvatarImage"
|
||||||
|
Stretch="UniformToFill" />
|
||||||
|
<TextBlock x:Name="PostItem2AvatarFallbackText"
|
||||||
|
Text="?"
|
||||||
|
Foreground="#4A5466"
|
||||||
|
FontSize="13"
|
||||||
|
FontWeight="SemiBold"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
VerticalAlignment="Center" />
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<TextBlock x:Name="PostItem2TitleTextBlock"
|
||||||
|
Grid.Column="1"
|
||||||
|
Text="Loading..."
|
||||||
|
Foreground="#202327"
|
||||||
|
FontSize="14"
|
||||||
|
FontWeight="SemiBold"
|
||||||
|
MaxLines="1"
|
||||||
|
TextTrimming="CharacterEllipsis"
|
||||||
|
VerticalAlignment="Center" />
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<Border x:Name="PostItem3Host"
|
||||||
|
Grid.Row="3"
|
||||||
|
Tag="2"
|
||||||
|
Background="#F7F8FA"
|
||||||
|
CornerRadius="10"
|
||||||
|
Padding="8,6"
|
||||||
|
PointerPressed="OnPostItemPointerPressed">
|
||||||
|
<Grid x:Name="PostItem3Grid"
|
||||||
|
ColumnDefinitions="Auto,*"
|
||||||
|
ColumnSpacing="8">
|
||||||
|
<Border x:Name="PostItem3AvatarHost"
|
||||||
|
Width="30"
|
||||||
|
Height="30"
|
||||||
|
CornerRadius="15"
|
||||||
|
Background="#E7EBF4"
|
||||||
|
ClipToBounds="True">
|
||||||
|
<Grid>
|
||||||
|
<Image x:Name="PostItem3AvatarImage"
|
||||||
|
Stretch="UniformToFill" />
|
||||||
|
<TextBlock x:Name="PostItem3AvatarFallbackText"
|
||||||
|
Text="?"
|
||||||
|
Foreground="#4A5466"
|
||||||
|
FontSize="13"
|
||||||
|
FontWeight="SemiBold"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
VerticalAlignment="Center" />
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<TextBlock x:Name="PostItem3TitleTextBlock"
|
||||||
|
Grid.Column="1"
|
||||||
|
Text="Loading..."
|
||||||
|
Foreground="#202327"
|
||||||
|
FontSize="14"
|
||||||
|
FontWeight="SemiBold"
|
||||||
|
MaxLines="1"
|
||||||
|
TextTrimming="CharacterEllipsis"
|
||||||
|
VerticalAlignment="Center" />
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<Border x:Name="PostItem4Host"
|
||||||
|
Grid.Row="4"
|
||||||
|
Tag="3"
|
||||||
|
Background="#F7F8FA"
|
||||||
|
CornerRadius="10"
|
||||||
|
Padding="8,6"
|
||||||
|
PointerPressed="OnPostItemPointerPressed">
|
||||||
|
<Grid x:Name="PostItem4Grid"
|
||||||
|
ColumnDefinitions="Auto,*"
|
||||||
|
ColumnSpacing="8">
|
||||||
|
<Border x:Name="PostItem4AvatarHost"
|
||||||
|
Width="30"
|
||||||
|
Height="30"
|
||||||
|
CornerRadius="15"
|
||||||
|
Background="#E7EBF4"
|
||||||
|
ClipToBounds="True">
|
||||||
|
<Grid>
|
||||||
|
<Image x:Name="PostItem4AvatarImage"
|
||||||
|
Stretch="UniformToFill" />
|
||||||
|
<TextBlock x:Name="PostItem4AvatarFallbackText"
|
||||||
|
Text="?"
|
||||||
|
Foreground="#4A5466"
|
||||||
|
FontSize="13"
|
||||||
|
FontWeight="SemiBold"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
VerticalAlignment="Center" />
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<TextBlock x:Name="PostItem4TitleTextBlock"
|
||||||
|
Grid.Column="1"
|
||||||
|
Text="Loading..."
|
||||||
|
Foreground="#202327"
|
||||||
|
FontSize="14"
|
||||||
|
FontWeight="SemiBold"
|
||||||
|
MaxLines="1"
|
||||||
|
TextTrimming="CharacterEllipsis"
|
||||||
|
VerticalAlignment="Center" />
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<TextBlock x:Name="StatusTextBlock"
|
||||||
|
IsVisible="False"
|
||||||
|
Text="Loading..."
|
||||||
|
Foreground="#6A6F77"
|
||||||
|
FontSize="14"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
VerticalAlignment="Center" />
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</UserControl>
|
||||||
618
LanMountainDesktop/Views/Components/Stcn24ForumWidget.axaml.cs
Normal file
618
LanMountainDesktop/Views/Components/Stcn24ForumWidget.axaml.cs
Normal file
@@ -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<Stcn24ForumPostItemSnapshot> _activeItems = [];
|
||||||
|
private readonly List<ForumItemVisual> _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<Bitmap?> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,7 +13,8 @@ public partial class StudyEnvironmentWidget : UserControl, IDesktopComponentWidg
|
|||||||
{
|
{
|
||||||
private readonly IStudyAnalyticsService _studyAnalyticsService = StudyAnalyticsServiceFactory.CreateDefault();
|
private readonly IStudyAnalyticsService _studyAnalyticsService = StudyAnalyticsServiceFactory.CreateDefault();
|
||||||
private readonly StudyAnalyticsMonitoringLeaseCoordinator _monitoringLeaseCoordinator = StudyAnalyticsMonitoringLeaseCoordinatorFactory.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 LocalizationService _localizationService = new();
|
||||||
private readonly DispatcherTimer _uiTimer = new()
|
private readonly DispatcherTimer _uiTimer = new()
|
||||||
{
|
{
|
||||||
@@ -127,10 +128,11 @@ public partial class StudyEnvironmentWidget : UserControl, IDesktopComponentWidg
|
|||||||
|
|
||||||
private void ReloadDisplaySettings()
|
private void ReloadDisplaySettings()
|
||||||
{
|
{
|
||||||
var snapshot = _settingsService.Load();
|
var appSnapshot = _appSettingsService.Load();
|
||||||
_languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode);
|
var componentSnapshot = _componentSettingsService.Load();
|
||||||
_showDisplayDb = snapshot.StudyEnvironmentShowDisplayDb;
|
_languageCode = _localizationService.NormalizeLanguageCode(appSnapshot.LanguageCode);
|
||||||
_showDbfs = snapshot.StudyEnvironmentShowDbfs;
|
_showDisplayDb = componentSnapshot.StudyEnvironmentShowDisplayDb;
|
||||||
|
_showDbfs = componentSnapshot.StudyEnvironmentShowDbfs;
|
||||||
if (!_showDisplayDb && !_showDbfs)
|
if (!_showDisplayDb && !_showDbfs)
|
||||||
{
|
{
|
||||||
_showDisplayDb = true;
|
_showDisplayDb = true;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ namespace LanMountainDesktop.Views.Components;
|
|||||||
public partial class StudyEnvironmentWidgetSettingsWindow : UserControl
|
public partial class StudyEnvironmentWidgetSettingsWindow : UserControl
|
||||||
{
|
{
|
||||||
private readonly AppSettingsService _appSettingsService = new();
|
private readonly AppSettingsService _appSettingsService = new();
|
||||||
|
private readonly ComponentSettingsService _componentSettingsService = new();
|
||||||
private readonly LocalizationService _localizationService = new();
|
private readonly LocalizationService _localizationService = new();
|
||||||
private string _languageCode = "zh-CN";
|
private string _languageCode = "zh-CN";
|
||||||
private bool _suppressEvents;
|
private bool _suppressEvents;
|
||||||
@@ -23,11 +24,12 @@ public partial class StudyEnvironmentWidgetSettingsWindow : UserControl
|
|||||||
|
|
||||||
private void LoadState()
|
private void LoadState()
|
||||||
{
|
{
|
||||||
var snapshot = _appSettingsService.Load();
|
var appSnapshot = _appSettingsService.Load();
|
||||||
_languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode);
|
var componentSnapshot = _componentSettingsService.Load();
|
||||||
|
_languageCode = _localizationService.NormalizeLanguageCode(appSnapshot.LanguageCode);
|
||||||
|
|
||||||
var showDisplayDb = snapshot.StudyEnvironmentShowDisplayDb;
|
var showDisplayDb = componentSnapshot.StudyEnvironmentShowDisplayDb;
|
||||||
var showDbfs = snapshot.StudyEnvironmentShowDbfs;
|
var showDbfs = componentSnapshot.StudyEnvironmentShowDbfs;
|
||||||
if (!showDisplayDb && !showDbfs)
|
if (!showDisplayDb && !showDbfs)
|
||||||
{
|
{
|
||||||
showDisplayDb = true;
|
showDisplayDb = true;
|
||||||
@@ -75,10 +77,10 @@ public partial class StudyEnvironmentWidgetSettingsWindow : UserControl
|
|||||||
showDisplayDb = true;
|
showDisplayDb = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
var snapshot = _appSettingsService.Load();
|
var snapshot = _componentSettingsService.Load();
|
||||||
snapshot.StudyEnvironmentShowDisplayDb = showDisplayDb;
|
snapshot.StudyEnvironmentShowDisplayDb = showDisplayDb;
|
||||||
snapshot.StudyEnvironmentShowDbfs = showDbfs;
|
snapshot.StudyEnvironmentShowDbfs = showDbfs;
|
||||||
_appSettingsService.Save(snapshot);
|
_componentSettingsService.Save(snapshot);
|
||||||
|
|
||||||
SettingsChanged?.Invoke(this, EventArgs.Empty);
|
SettingsChanged?.Invoke(this, EventArgs.Empty);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,7 +88,8 @@ public partial class WorldClockWidget : UserControl, IDesktopComponentWidget, IT
|
|||||||
Interval = TimeSpan.FromSeconds(1)
|
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 LocalizationService _localizationService = new();
|
||||||
private readonly ClockEntryVisual[] _entryVisuals = new ClockEntryVisual[WorldClockTimeZoneCatalog.ClockCount];
|
private readonly ClockEntryVisual[] _entryVisuals = new ClockEntryVisual[WorldClockTimeZoneCatalog.ClockCount];
|
||||||
private readonly TimeZoneInfo[] _entryTimeZones = new TimeZoneInfo[WorldClockTimeZoneCatalog.ClockCount];
|
private readonly TimeZoneInfo[] _entryTimeZones = new TimeZoneInfo[WorldClockTimeZoneCatalog.ClockCount];
|
||||||
@@ -445,17 +446,18 @@ public partial class WorldClockWidget : UserControl, IDesktopComponentWidget, IT
|
|||||||
|
|
||||||
private void LoadFromSettings()
|
private void LoadFromSettings()
|
||||||
{
|
{
|
||||||
var snapshot = _settingsService.Load();
|
var appSnapshot = _appSettingsService.Load();
|
||||||
_languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode);
|
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++)
|
for (var index = 0; index < WorldClockTimeZoneCatalog.ClockCount; index++)
|
||||||
{
|
{
|
||||||
var resolvedId = ids[index];
|
var resolvedId = ids[index];
|
||||||
_entryTimeZones[index] = WorldClockTimeZoneCatalog.ResolveTimeZoneOrLocal(resolvedId);
|
_entryTimeZones[index] = WorldClockTimeZoneCatalog.ResolveTimeZoneOrLocal(resolvedId);
|
||||||
}
|
}
|
||||||
|
|
||||||
_secondHandMode = ClockSecondHandMode.Normalize(snapshot.WorldClockSecondHandMode);
|
_secondHandMode = ClockSecondHandMode.Normalize(componentSnapshot.WorldClockSecondHandMode);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ApplySecondHandTimerInterval()
|
private void ApplySecondHandTimerInterval()
|
||||||
@@ -533,7 +535,7 @@ public partial class WorldClockWidget : UserControl, IDesktopComponentWidget, IT
|
|||||||
_nextLanguageProbeUtc = utcNow.AddSeconds(25);
|
_nextLanguageProbeUtc = utcNow.AddSeconds(25);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var snapshot = _settingsService.Load();
|
var snapshot = _appSettingsService.Load();
|
||||||
_languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode);
|
_languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode);
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ public partial class WorldClockWidgetSettingsWindow : UserControl
|
|||||||
};
|
};
|
||||||
|
|
||||||
private readonly AppSettingsService _appSettingsService = new();
|
private readonly AppSettingsService _appSettingsService = new();
|
||||||
|
private readonly ComponentSettingsService _componentSettingsService = new();
|
||||||
private readonly LocalizationService _localizationService = new();
|
private readonly LocalizationService _localizationService = new();
|
||||||
private readonly TimeZoneService _timeZoneService = new();
|
private readonly TimeZoneService _timeZoneService = new();
|
||||||
private readonly ComboBox[] _timeZoneComboBoxes;
|
private readonly ComboBox[] _timeZoneComboBoxes;
|
||||||
@@ -58,8 +59,9 @@ public partial class WorldClockWidgetSettingsWindow : UserControl
|
|||||||
|
|
||||||
private void LoadState()
|
private void LoadState()
|
||||||
{
|
{
|
||||||
var snapshot = _appSettingsService.Load();
|
var appSnapshot = _appSettingsService.Load();
|
||||||
_languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode);
|
var componentSnapshot = _componentSettingsService.Load();
|
||||||
|
_languageCode = _localizationService.NormalizeLanguageCode(appSnapshot.LanguageCode);
|
||||||
|
|
||||||
_allTimeZones = _timeZoneService
|
_allTimeZones = _timeZoneService
|
||||||
.GetAllTimeZones()
|
.GetAllTimeZones()
|
||||||
@@ -68,9 +70,9 @@ public partial class WorldClockWidgetSettingsWindow : UserControl
|
|||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
_selectedTimeZoneIds = WorldClockTimeZoneCatalog.NormalizeTimeZoneIds(
|
_selectedTimeZoneIds = WorldClockTimeZoneCatalog.NormalizeTimeZoneIds(
|
||||||
snapshot.WorldClockTimeZoneIds,
|
componentSnapshot.WorldClockTimeZoneIds,
|
||||||
_allTimeZones);
|
_allTimeZones);
|
||||||
_secondHandMode = ClockSecondHandMode.Normalize(snapshot.WorldClockSecondHandMode);
|
_secondHandMode = ClockSecondHandMode.Normalize(componentSnapshot.WorldClockSecondHandMode);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ApplyLocalization()
|
private void ApplyLocalization()
|
||||||
@@ -165,10 +167,10 @@ public partial class WorldClockWidgetSettingsWindow : UserControl
|
|||||||
var normalizedIds = WorldClockTimeZoneCatalog.NormalizeTimeZoneIds(selectedIds, _allTimeZones);
|
var normalizedIds = WorldClockTimeZoneCatalog.NormalizeTimeZoneIds(selectedIds, _allTimeZones);
|
||||||
_secondHandMode = GetSelectedSecondHandMode();
|
_secondHandMode = GetSelectedSecondHandMode();
|
||||||
|
|
||||||
var snapshot = _appSettingsService.Load();
|
var snapshot = _componentSettingsService.Load();
|
||||||
snapshot.WorldClockTimeZoneIds = normalizedIds.ToList();
|
snapshot.WorldClockTimeZoneIds = normalizedIds.ToList();
|
||||||
snapshot.WorldClockSecondHandMode = _secondHandMode;
|
snapshot.WorldClockSecondHandMode = _secondHandMode;
|
||||||
_appSettingsService.Save(snapshot);
|
_componentSettingsService.Save(snapshot);
|
||||||
|
|
||||||
_selectedTimeZoneIds = normalizedIds;
|
_selectedTimeZoneIds = normalizedIds;
|
||||||
SettingsChanged?.Invoke(this, EventArgs.Empty);
|
SettingsChanged?.Invoke(this, EventArgs.Empty);
|
||||||
|
|||||||
@@ -731,6 +731,18 @@ public partial class MainWindow
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (placement.ComponentId == BuiltInComponentIds.DesktopDailyWord)
|
||||||
|
{
|
||||||
|
OpenDailyWordComponentSettings();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (placement.ComponentId == BuiltInComponentIds.DesktopBilibiliHotSearch)
|
||||||
|
{
|
||||||
|
OpenBilibiliHotSearchComponentSettings();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (placement.ComponentId == BuiltInComponentIds.DesktopStudyEnvironment)
|
if (placement.ComponentId == BuiltInComponentIds.DesktopStudyEnvironment)
|
||||||
{
|
{
|
||||||
OpenStudyEnvironmentComponentSettings();
|
OpenStudyEnvironmentComponentSettings();
|
||||||
@@ -850,6 +862,38 @@ public partial class MainWindow
|
|||||||
ComponentSettingsWindow.Opacity = 1;
|
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)
|
private void OnClassScheduleSettingsChanged(object? sender, EventArgs e)
|
||||||
{
|
{
|
||||||
if (_selectedDesktopComponentHost is null)
|
if (_selectedDesktopComponentHost is null)
|
||||||
@@ -883,8 +927,6 @@ public partial class MainWindow
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
PersistSettings();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnStudyEnvironmentSettingsChanged(object? sender, EventArgs e)
|
private void OnStudyEnvironmentSettingsChanged(object? sender, EventArgs e)
|
||||||
@@ -907,10 +949,6 @@ public partial class MainWindow
|
|||||||
_ = sender;
|
_ = sender;
|
||||||
_ = e;
|
_ = e;
|
||||||
|
|
||||||
_dailyArtworkMirrorSource = sender is DailyArtworkSettingsWindow settingsWindow
|
|
||||||
? DailyArtworkMirrorSources.Normalize(settingsWindow.CurrentSource)
|
|
||||||
: DailyArtworkMirrorSources.Normalize(_appSettingsService.Load().DailyArtworkMirrorSource);
|
|
||||||
|
|
||||||
foreach (var pageGrid in _desktopPageComponentGrids.Values)
|
foreach (var pageGrid in _desktopPageComponentGrids.Values)
|
||||||
{
|
{
|
||||||
foreach (var host in pageGrid.Children.OfType<Border>())
|
foreach (var host in pageGrid.Children.OfType<Border>())
|
||||||
@@ -926,8 +964,6 @@ public partial class MainWindow
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
PersistSettings();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnWorldClockSettingsChanged(object? sender, EventArgs e)
|
private void OnWorldClockSettingsChanged(object? sender, EventArgs e)
|
||||||
@@ -950,8 +986,6 @@ public partial class MainWindow
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
PersistSettings();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnCnrDailyNewsSettingsChanged(object? sender, EventArgs e)
|
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<Border>())
|
||||||
|
{
|
||||||
|
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<Border>())
|
||||||
|
{
|
||||||
|
if (!host.Classes.Contains(DesktopComponentHostClass))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (TryGetContentHost(host)?.Child is BilibiliHotSearchWidget widget)
|
||||||
|
{
|
||||||
|
widget.RefreshFromSettings();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void CloseComponentSettingsWindow()
|
private void CloseComponentSettingsWindow()
|
||||||
@@ -1015,6 +1091,16 @@ public partial class MainWindow
|
|||||||
cnrDailyNewsSettingsWindow.SettingsChanged -= OnCnrDailyNewsSettingsChanged;
|
cnrDailyNewsSettingsWindow.SettingsChanged -= OnCnrDailyNewsSettingsChanged;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (ComponentSettingsContentHost?.Content is DailyWordSettingsWindow dailyWordSettingsWindow)
|
||||||
|
{
|
||||||
|
dailyWordSettingsWindow.SettingsChanged -= OnDailyWordSettingsChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ComponentSettingsContentHost?.Content is BilibiliHotSearchSettingsWindow bilibiliHotSearchSettingsWindow)
|
||||||
|
{
|
||||||
|
bilibiliHotSearchSettingsWindow.SettingsChanged -= OnBilibiliHotSearchSettingsChanged;
|
||||||
|
}
|
||||||
|
|
||||||
ComponentSettingsWindow.Opacity = 0;
|
ComponentSettingsWindow.Opacity = 0;
|
||||||
|
|
||||||
DispatcherTimer.RunOnce(() =>
|
DispatcherTimer.RunOnce(() =>
|
||||||
@@ -1434,6 +1520,14 @@ public partial class MainWindow
|
|||||||
new ComponentScaleRule(WidthUnit: 2, HeightUnit: 1, MinScale: 2));
|
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))
|
if (string.Equals(componentId, BuiltInComponentIds.DesktopExchangeRateCalculator, StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
// Keep exchange rate converter square with minimum size 4x4.
|
// Keep exchange rate converter square with minimum size 4x4.
|
||||||
@@ -2826,12 +2920,16 @@ public partial class MainWindow
|
|||||||
_weatherDataService,
|
_weatherDataService,
|
||||||
_recommendationInfoService,
|
_recommendationInfoService,
|
||||||
_calculatorDataService);
|
_calculatorDataService);
|
||||||
|
// Component library previews must stay non-interactive so drag gesture is reliable.
|
||||||
|
previewControl.IsHitTestVisible = false;
|
||||||
|
previewControl.Focusable = false;
|
||||||
|
|
||||||
var previewSurface = new Border
|
var previewSurface = new Border
|
||||||
{
|
{
|
||||||
Width = previewSpan.WidthCells * renderCellSize,
|
Width = previewSpan.WidthCells * renderCellSize,
|
||||||
Height = previewSpan.HeightCells * renderCellSize,
|
Height = previewSpan.HeightCells * renderCellSize,
|
||||||
Background = Brushes.Transparent,
|
Background = Brushes.Transparent,
|
||||||
|
IsHitTestVisible = false,
|
||||||
Child = previewControl
|
Child = previewControl
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -22,9 +22,26 @@ public partial class MainWindow
|
|||||||
{
|
{
|
||||||
private const int MinDesktopPageCount = 1;
|
private const int MinDesktopPageCount = 1;
|
||||||
private const int MaxDesktopPageCount = 12;
|
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 WindowsStartMenuService _windowsStartMenuService = new();
|
||||||
private readonly Dictionary<string, Bitmap> _launcherIconCache = new(StringComparer.OrdinalIgnoreCase);
|
private readonly Dictionary<string, Bitmap> _launcherIconCache = new(StringComparer.OrdinalIgnoreCase);
|
||||||
private readonly Stack<StartMenuFolderNode> _launcherFolderStack = [];
|
private readonly Stack<StartMenuFolderNode> _launcherFolderStack = [];
|
||||||
|
private readonly HashSet<string> _hiddenLauncherFolderPaths = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
private readonly HashSet<string> _hiddenLauncherAppPaths = new(StringComparer.OrdinalIgnoreCase);
|
||||||
private StartMenuFolderNode _startMenuRoot = new("All Apps", string.Empty);
|
private StartMenuFolderNode _startMenuRoot = new("All Apps", string.Empty);
|
||||||
private byte[]? _launcherFolderIconPngBytes;
|
private byte[]? _launcherFolderIconPngBytes;
|
||||||
private Bitmap? _launcherFolderIconBitmap;
|
private Bitmap? _launcherFolderIconBitmap;
|
||||||
@@ -52,6 +69,35 @@ public partial class MainWindow
|
|||||||
_currentDesktopSurfaceIndex = Math.Clamp(snapshot.CurrentDesktopSurfaceIndex, 0, LauncherSurfaceIndex);
|
_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()
|
private void InitializeDesktopSurfaceSwipeHandlers()
|
||||||
{
|
{
|
||||||
// Capture swipe intent before child controls consume pointer events.
|
// Capture swipe intent before child controls consume pointer events.
|
||||||
@@ -80,6 +126,7 @@ public partial class MainWindow
|
|||||||
_launcherFolderIconBitmap?.Dispose();
|
_launcherFolderIconBitmap?.Dispose();
|
||||||
_launcherFolderIconBitmap = null;
|
_launcherFolderIconBitmap = null;
|
||||||
RenderLauncherRootTiles();
|
RenderLauncherRootTiles();
|
||||||
|
RenderLauncherHiddenItemsList();
|
||||||
}, DispatcherPriority.Background);
|
}, DispatcherPriority.Background);
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
@@ -89,6 +136,7 @@ public partial class MainWindow
|
|||||||
_launcherFolderIconBitmap?.Dispose();
|
_launcherFolderIconBitmap?.Dispose();
|
||||||
_launcherFolderIconBitmap = null;
|
_launcherFolderIconBitmap = null;
|
||||||
RenderLauncherRootTiles();
|
RenderLauncherRootTiles();
|
||||||
|
RenderLauncherHiddenItemsList();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -695,11 +743,21 @@ public partial class MainWindow
|
|||||||
|
|
||||||
foreach (var folder in folders)
|
foreach (var folder in folders)
|
||||||
{
|
{
|
||||||
|
if (!IsLauncherFolderVisible(folder))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
LauncherRootTilePanel.Children.Add(CreateLauncherFolderTile(folder));
|
LauncherRootTilePanel.Children.Add(CreateLauncherFolderTile(folder));
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var app in apps)
|
foreach (var app in apps)
|
||||||
{
|
{
|
||||||
|
if (!IsLauncherAppVisible(app))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
LauncherRootTilePanel.Children.Add(CreateLauncherAppTile(app));
|
LauncherRootTilePanel.Children.Add(CreateLauncherAppTile(app));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -719,24 +777,28 @@ public partial class MainWindow
|
|||||||
var title = folder.Name;
|
var title = folder.Name;
|
||||||
var subtitle = Lf("launcher.folder_items_format", "{0} apps", folder.TotalAppCount);
|
var subtitle = Lf("launcher.folder_items_format", "{0} apps", folder.TotalAppCount);
|
||||||
var folderIconBitmap = GetLauncherFolderIconBitmap();
|
var folderIconBitmap = GetLauncherFolderIconBitmap();
|
||||||
|
var folderKey = NormalizeLauncherHiddenKey(folder.RelativePath);
|
||||||
return CreateLauncherTileButton(
|
return CreateLauncherTileButton(
|
||||||
title,
|
title,
|
||||||
subtitle,
|
subtitle,
|
||||||
monogram: "DIR",
|
monogram: "DIR",
|
||||||
iconBitmap: folderIconBitmap,
|
iconBitmap: folderIconBitmap,
|
||||||
() => OpenLauncherFolder(folder));
|
() => OpenLauncherFolder(folder),
|
||||||
|
hideAction: string.IsNullOrWhiteSpace(folderKey) ? null : () => HideLauncherFolder(folder));
|
||||||
}
|
}
|
||||||
|
|
||||||
private Button CreateLauncherAppTile(StartMenuAppEntry app)
|
private Button CreateLauncherAppTile(StartMenuAppEntry app)
|
||||||
{
|
{
|
||||||
var iconBitmap = GetLauncherIconBitmap(app);
|
var iconBitmap = GetLauncherIconBitmap(app);
|
||||||
var monogram = BuildMonogram(app.DisplayName);
|
var monogram = BuildMonogram(app.DisplayName);
|
||||||
|
var appKey = NormalizeLauncherHiddenKey(app.RelativePath);
|
||||||
return CreateLauncherTileButton(
|
return CreateLauncherTileButton(
|
||||||
app.DisplayName,
|
app.DisplayName,
|
||||||
subtitle: string.Empty,
|
subtitle: string.Empty,
|
||||||
monogram,
|
monogram,
|
||||||
iconBitmap,
|
iconBitmap,
|
||||||
() => LaunchStartMenuEntry(app));
|
() => LaunchStartMenuEntry(app),
|
||||||
|
hideAction: string.IsNullOrWhiteSpace(appKey) ? null : () => HideLauncherApp(app));
|
||||||
}
|
}
|
||||||
|
|
||||||
private Control CreateLauncherHintTile(string title, string subtitle)
|
private Control CreateLauncherHintTile(string title, string subtitle)
|
||||||
@@ -779,7 +841,8 @@ public partial class MainWindow
|
|||||||
string subtitle,
|
string subtitle,
|
||||||
string monogram,
|
string monogram,
|
||||||
Bitmap? iconBitmap,
|
Bitmap? iconBitmap,
|
||||||
Action clickAction)
|
Action clickAction,
|
||||||
|
Action? hideAction = null)
|
||||||
{
|
{
|
||||||
Control iconControl = iconBitmap is not null
|
Control iconControl = iconBitmap is not null
|
||||||
? new Image
|
? new Image
|
||||||
@@ -853,9 +916,310 @@ public partial class MainWindow
|
|||||||
// 不设置固定 Width 和 Height,由 UpdateLauncherTileLayout 动态设置
|
// 不设置固定 Width 和 Height,由 UpdateLauncherTileLayout 动态设置
|
||||||
};
|
};
|
||||||
button.Click += (_, _) => clickAction();
|
button.Click += (_, _) => clickAction();
|
||||||
|
AttachLauncherTileContextMenu(button, hideAction);
|
||||||
return button;
|
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<LauncherHiddenItemView> BuildLauncherHiddenItems()
|
||||||
|
{
|
||||||
|
var items = new List<LauncherHiddenItemView>();
|
||||||
|
var seenFolders = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
var seenApps = new HashSet<string>(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<LauncherHiddenItemView> items,
|
||||||
|
HashSet<string> seenFolders,
|
||||||
|
HashSet<string> 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)
|
private Bitmap? GetLauncherIconBitmap(StartMenuAppEntry app)
|
||||||
{
|
{
|
||||||
if (app.IconPngBytes is null || app.IconPngBytes.Length == 0)
|
if (app.IconPngBytes is null || app.IconPngBytes.Length == 0)
|
||||||
@@ -950,11 +1314,21 @@ public partial class MainWindow
|
|||||||
LauncherFolderTilePanel.Children.Clear();
|
LauncherFolderTilePanel.Children.Clear();
|
||||||
foreach (var subFolder in folder.Folders)
|
foreach (var subFolder in folder.Folders)
|
||||||
{
|
{
|
||||||
|
if (!IsLauncherFolderVisible(subFolder))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
LauncherFolderTilePanel.Children.Add(CreateLauncherFolderTile(subFolder));
|
LauncherFolderTilePanel.Children.Add(CreateLauncherFolderTile(subFolder));
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var app in folder.Apps)
|
foreach (var app in folder.Apps)
|
||||||
{
|
{
|
||||||
|
if (!IsLauncherAppVisible(app))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
LauncherFolderTilePanel.Children.Add(CreateLauncherAppTile(app));
|
LauncherFolderTilePanel.Children.Add(CreateLauncherAppTile(app));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -111,6 +111,7 @@ public partial class MainWindow
|
|||||||
SettingsNavWeatherTextBlock.Text = L("settings.nav.weather", "Weather");
|
SettingsNavWeatherTextBlock.Text = L("settings.nav.weather", "Weather");
|
||||||
SettingsNavRegionTextBlock.Text = L("settings.nav.region", "Region");
|
SettingsNavRegionTextBlock.Text = L("settings.nav.region", "Region");
|
||||||
SettingsNavUpdateTextBlock.Text = L("settings.nav.update", "Update");
|
SettingsNavUpdateTextBlock.Text = L("settings.nav.update", "Update");
|
||||||
|
SettingsNavLauncherTextBlock.Text = L("settings.nav.launcher", "App Launcher");
|
||||||
|
|
||||||
WallpaperPanelTitleTextBlock.Text = L("settings.wallpaper.title", "Personalize your wallpaper");
|
WallpaperPanelTitleTextBlock.Text = L("settings.wallpaper.title", "Personalize your wallpaper");
|
||||||
WallpaperPlacementSettingsExpander.Header = L("settings.wallpaper.placement_label", "Placement");
|
WallpaperPlacementSettingsExpander.Header = L("settings.wallpaper.placement_label", "Placement");
|
||||||
@@ -251,6 +252,16 @@ public partial class MainWindow
|
|||||||
|
|
||||||
ApplyUpdateLocalization();
|
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");
|
SettingsNavAboutTextBlock.Text = L("settings.nav.about", "About");
|
||||||
AboutPanelTitleTextBlock.Text = L("settings.about.title", "About");
|
AboutPanelTitleTextBlock.Text = L("settings.about.title", "About");
|
||||||
VersionTextBlock.Text = Lf(
|
VersionTextBlock.Text = Lf(
|
||||||
@@ -269,9 +280,6 @@ public partial class MainWindow
|
|||||||
AboutStartupSettingsExpander.Description = L(
|
AboutStartupSettingsExpander.Description = L(
|
||||||
"settings.about.startup_desc",
|
"settings.about.startup_desc",
|
||||||
"Launch the app automatically when signing in to Windows.");
|
"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)
|
if (WallpaperPlacementComboBox?.ItemCount >= 5)
|
||||||
{
|
{
|
||||||
@@ -293,6 +301,7 @@ public partial class MainWindow
|
|||||||
InitializeTimeZoneSettings();
|
InitializeTimeZoneSettings();
|
||||||
BuildComponentLibraryCategoryPages();
|
BuildComponentLibraryCategoryPages();
|
||||||
RenderLauncherRootTiles();
|
RenderLauncherRootTiles();
|
||||||
|
RenderLauncherHiddenItemsList();
|
||||||
UpdateOpenSettingsActionVisualState();
|
UpdateOpenSettingsActionVisualState();
|
||||||
UpdateWallpaperDisplay();
|
UpdateWallpaperDisplay();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ public partial class MainWindow
|
|||||||
WeatherSettingsPanel is null ||
|
WeatherSettingsPanel is null ||
|
||||||
RegionSettingsPanel is null ||
|
RegionSettingsPanel is null ||
|
||||||
UpdateSettingsPanel is null ||
|
UpdateSettingsPanel is null ||
|
||||||
|
LauncherSettingsPanel is null ||
|
||||||
AboutSettingsPanel is null)
|
AboutSettingsPanel is null)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
@@ -80,6 +81,12 @@ public partial class MainWindow
|
|||||||
RegionSettingsPanel.IsVisible = selectedIndex == 5;
|
RegionSettingsPanel.IsVisible = selectedIndex == 5;
|
||||||
UpdateSettingsPanel.IsVisible = selectedIndex == 6;
|
UpdateSettingsPanel.IsVisible = selectedIndex == 6;
|
||||||
AboutSettingsPanel.IsVisible = selectedIndex == 7;
|
AboutSettingsPanel.IsVisible = selectedIndex == 7;
|
||||||
|
LauncherSettingsPanel.IsVisible = selectedIndex == 8;
|
||||||
|
|
||||||
|
if (selectedIndex == 8)
|
||||||
|
{
|
||||||
|
RenderLauncherHiddenItemsList();
|
||||||
|
}
|
||||||
|
|
||||||
if (selectedIndex == 1)
|
if (selectedIndex == 1)
|
||||||
{
|
{
|
||||||
@@ -877,7 +884,6 @@ public partial class MainWindow
|
|||||||
WeatherExcludedAlerts = _weatherExcludedAlertsRaw,
|
WeatherExcludedAlerts = _weatherExcludedAlertsRaw,
|
||||||
WeatherIconPackId = _weatherIconPackId,
|
WeatherIconPackId = _weatherIconPackId,
|
||||||
WeatherNoTlsRequests = _weatherNoTlsRequests,
|
WeatherNoTlsRequests = _weatherNoTlsRequests,
|
||||||
DailyArtworkMirrorSource = DailyArtworkMirrorSources.Normalize(_dailyArtworkMirrorSource),
|
|
||||||
AutoStartWithWindows = _autoStartWithWindows,
|
AutoStartWithWindows = _autoStartWithWindows,
|
||||||
AutoCheckUpdates = _autoCheckUpdates,
|
AutoCheckUpdates = _autoCheckUpdates,
|
||||||
IncludePrereleaseUpdates = IncludePrereleaseUpdates,
|
IncludePrereleaseUpdates = IncludePrereleaseUpdates,
|
||||||
@@ -891,7 +897,9 @@ public partial class MainWindow
|
|||||||
StatusBarCustomSpacingPercent = _statusBarCustomSpacingPercent,
|
StatusBarCustomSpacingPercent = _statusBarCustomSpacingPercent,
|
||||||
DesktopPageCount = _desktopPageCount,
|
DesktopPageCount = _desktopPageCount,
|
||||||
CurrentDesktopSurfaceIndex = _currentDesktopSurfaceIndex,
|
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);
|
_appSettingsService.Save(snapshot);
|
||||||
|
|||||||
@@ -460,6 +460,12 @@
|
|||||||
<TextBlock x:Name="SettingsNavAboutTextBlock" Text="关于" VerticalAlignment="Center" />
|
<TextBlock x:Name="SettingsNavAboutTextBlock" Text="关于" VerticalAlignment="Center" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</ListBoxItem>
|
</ListBoxItem>
|
||||||
|
<ListBoxItem x:Name="SettingsNavLauncherItem" ToolTip.Tip="应用启动台">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="12">
|
||||||
|
<fi:FluentIcon x:Name="SettingsNavLauncherIcon" Icon="Apps" IconVariant="Regular" />
|
||||||
|
<TextBlock x:Name="SettingsNavLauncherTextBlock" Text="应用启动台" VerticalAlignment="Center" />
|
||||||
|
</StackPanel>
|
||||||
|
</ListBoxItem>
|
||||||
</ListBox>
|
</ListBox>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
@@ -1493,6 +1499,39 @@
|
|||||||
</Border>
|
</Border>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
|
<StackPanel x:Name="LauncherSettingsPanel" IsVisible="False" Spacing="16">
|
||||||
|
<TextBlock x:Name="LauncherSettingsPanelTitleTextBlock"
|
||||||
|
FontSize="24"
|
||||||
|
FontWeight="SemiBold"
|
||||||
|
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
|
||||||
|
Text="App Launcher" />
|
||||||
|
|
||||||
|
<Border Classes="settings-expander-shell">
|
||||||
|
<ui:SettingsExpander x:Name="LauncherHiddenItemsSettingsExpander"
|
||||||
|
Header="Hidden Items"
|
||||||
|
Description="Review hidden launcher entries and show them again."
|
||||||
|
IsExpanded="True">
|
||||||
|
<ui:SettingsExpander.Footer>
|
||||||
|
<StackPanel Spacing="10">
|
||||||
|
<TextBlock x:Name="LauncherHiddenItemsDescriptionTextBlock"
|
||||||
|
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
|
||||||
|
Text="Right-click an icon in launcher to hide it. Hidden entries appear here." />
|
||||||
|
<TextBlock x:Name="LauncherHiddenItemsEmptyTextBlock"
|
||||||
|
IsVisible="False"
|
||||||
|
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
|
||||||
|
Text="No hidden items." />
|
||||||
|
<ScrollViewer MaxHeight="420"
|
||||||
|
VerticalScrollBarVisibility="Auto"
|
||||||
|
HorizontalScrollBarVisibility="Disabled">
|
||||||
|
<StackPanel x:Name="LauncherHiddenItemsListPanel"
|
||||||
|
Spacing="8" />
|
||||||
|
</ScrollViewer>
|
||||||
|
</StackPanel>
|
||||||
|
</ui:SettingsExpander.Footer>
|
||||||
|
</ui:SettingsExpander>
|
||||||
|
</Border>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
<StackPanel x:Name="AboutSettingsPanel" IsVisible="False" Spacing="20">
|
<StackPanel x:Name="AboutSettingsPanel" IsVisible="False" Spacing="20">
|
||||||
<TextBlock x:Name="AboutPanelTitleTextBlock" FontSize="24" FontWeight="SemiBold" Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" Text="About" />
|
<TextBlock x:Name="AboutPanelTitleTextBlock" FontSize="24" FontWeight="SemiBold" Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" Text="About" />
|
||||||
<Border Background="{DynamicResource AdaptiveSurfaceRaisedBrush}" CornerRadius="{DynamicResource DesignCornerRadiusMd}" Padding="20">
|
<Border Background="{DynamicResource AdaptiveSurfaceRaisedBrush}" CornerRadius="{DynamicResource DesignCornerRadiusMd}" Padding="20">
|
||||||
@@ -1513,8 +1552,7 @@
|
|||||||
<ui:SettingsExpander.Footer>
|
<ui:SettingsExpander.Footer>
|
||||||
<ToggleSwitch x:Name="AutoStartWithWindowsToggleSwitch"
|
<ToggleSwitch x:Name="AutoStartWithWindowsToggleSwitch"
|
||||||
Checked="OnAutoStartWithWindowsToggled"
|
Checked="OnAutoStartWithWindowsToggled"
|
||||||
Unchecked="OnAutoStartWithWindowsToggled"
|
Unchecked="OnAutoStartWithWindowsToggled" />
|
||||||
Content="Launch at Windows sign-in" />
|
|
||||||
</ui:SettingsExpander.Footer>
|
</ui:SettingsExpander.Footer>
|
||||||
</ui:SettingsExpander>
|
</ui:SettingsExpander>
|
||||||
</Border>
|
</Border>
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ public partial class MainWindow : Window
|
|||||||
}
|
}
|
||||||
private readonly MonetColorService _monetColorService = new();
|
private readonly MonetColorService _monetColorService = new();
|
||||||
private readonly AppSettingsService _appSettingsService = new();
|
private readonly AppSettingsService _appSettingsService = new();
|
||||||
|
private readonly ComponentSettingsService _componentSettingsService = new();
|
||||||
private readonly LocalizationService _localizationService = new();
|
private readonly LocalizationService _localizationService = new();
|
||||||
private readonly TimeZoneService _timeZoneService = new();
|
private readonly TimeZoneService _timeZoneService = new();
|
||||||
private readonly WindowsStartupService _windowsStartupService = new();
|
private readonly WindowsStartupService _windowsStartupService = new();
|
||||||
@@ -167,7 +168,6 @@ public partial class MainWindow : Window
|
|||||||
private string _weatherExcludedAlertsRaw = string.Empty;
|
private string _weatherExcludedAlertsRaw = string.Empty;
|
||||||
private string _weatherIconPackId = "FluentRegular";
|
private string _weatherIconPackId = "FluentRegular";
|
||||||
private bool _weatherNoTlsRequests;
|
private bool _weatherNoTlsRequests;
|
||||||
private string _dailyArtworkMirrorSource = DailyArtworkMirrorSources.Overseas;
|
|
||||||
private bool _autoStartWithWindows;
|
private bool _autoStartWithWindows;
|
||||||
private bool _suppressAutoStartToggleEvents;
|
private bool _suppressAutoStartToggleEvents;
|
||||||
private string _weatherSearchKeyword = string.Empty;
|
private string _weatherSearchKeyword = string.Empty;
|
||||||
@@ -236,7 +236,7 @@ public partial class MainWindow : Window
|
|||||||
GridSizeSlider.ValueChanged += OnGridSizeSliderChanged;
|
GridSizeSlider.ValueChanged += OnGridSizeSliderChanged;
|
||||||
GridSizeNumberBox.ValueChanged += OnGridSizeNumberBoxChanged;
|
GridSizeNumberBox.ValueChanged += OnGridSizeNumberBoxChanged;
|
||||||
|
|
||||||
SettingsNavListBox.SelectedIndex = Math.Clamp(snapshot.SettingsTabIndex, 0, 7);
|
SettingsNavListBox.SelectedIndex = Math.Clamp(snapshot.SettingsTabIndex, 0, 8);
|
||||||
UpdateSettingsTabContent();
|
UpdateSettingsTabContent();
|
||||||
|
|
||||||
WallpaperPlacementComboBox.SelectedIndex = GetPlacementIndexFromSetting(snapshot.WallpaperPlacement);
|
WallpaperPlacementComboBox.SelectedIndex = GetPlacementIndexFromSetting(snapshot.WallpaperPlacement);
|
||||||
@@ -244,10 +244,11 @@ public partial class MainWindow : Window
|
|||||||
ApplyTaskbarSettings(snapshot);
|
ApplyTaskbarSettings(snapshot);
|
||||||
InitializeLocalization(snapshot.LanguageCode);
|
InitializeLocalization(snapshot.LanguageCode);
|
||||||
InitializeWeatherSettings(snapshot);
|
InitializeWeatherSettings(snapshot);
|
||||||
_dailyArtworkMirrorSource = DailyArtworkMirrorSources.Normalize(snapshot.DailyArtworkMirrorSource);
|
_ = _componentSettingsService.Load();
|
||||||
InitializeAutoStartWithWindowsSetting(snapshot);
|
InitializeAutoStartWithWindowsSetting(snapshot);
|
||||||
InitializeUpdateSettings(snapshot);
|
InitializeUpdateSettings(snapshot);
|
||||||
InitializeDesktopSurfaceState(snapshot);
|
InitializeDesktopSurfaceState(snapshot);
|
||||||
|
InitializeLauncherVisibilitySettings(snapshot);
|
||||||
InitializeDesktopComponentPlacements(snapshot);
|
InitializeDesktopComponentPlacements(snapshot);
|
||||||
InitializeSettingsIcons();
|
InitializeSettingsIcons();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user