Compare commits

...

1 Commits

Author SHA1 Message Date
lincube
de40471af6 0.4.5
新增STCN 24组件,优化应用启动台,允许用户隐藏应用启动台图标。优化组件拖动排放。
2026-03-06 08:53:45 +08:00
37 changed files with 2949 additions and 142 deletions

View File

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

View File

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

View File

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

View File

@@ -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": "央广网设置",

View File

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

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -460,6 +460,12 @@
<TextBlock x:Name="SettingsNavAboutTextBlock" Text="&#20851;&#20110;" VerticalAlignment="Center" /> <TextBlock x:Name="SettingsNavAboutTextBlock" Text="&#20851;&#20110;" VerticalAlignment="Center" />
</StackPanel> </StackPanel>
</ListBoxItem> </ListBoxItem>
<ListBoxItem x:Name="SettingsNavLauncherItem" ToolTip.Tip="&#24212;&#29992;&#21551;&#21160;&#21488;">
<StackPanel Orientation="Horizontal" Spacing="12">
<fi:FluentIcon x:Name="SettingsNavLauncherIcon" Icon="Apps" IconVariant="Regular" />
<TextBlock x:Name="SettingsNavLauncherTextBlock" Text="&#24212;&#29992;&#21551;&#21160;&#21488;" 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>

View File

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