mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-23 18:04:26 +08:00
0.4.5
新增STCN 24组件,优化应用启动台,允许用户隐藏应用启动台图标。优化组件拖动排放。
This commit is contained in:
@@ -50,7 +50,7 @@ public sealed class ClassIslandScheduleDataService : IClassIslandScheduleDataSer
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(inputPath))
|
||||
{
|
||||
inputPath = ResolveImportedSchedulePathFromAppSettings();
|
||||
inputPath = ResolveImportedSchedulePathFromComponentSettings();
|
||||
}
|
||||
|
||||
var source = ResolveSource(inputPath, profileFileName, warnings);
|
||||
@@ -180,11 +180,11 @@ public sealed class ClassIslandScheduleDataService : IClassIslandScheduleDataSer
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string? ResolveImportedSchedulePathFromAppSettings()
|
||||
private static string? ResolveImportedSchedulePathFromComponentSettings()
|
||||
{
|
||||
try
|
||||
{
|
||||
var snapshot = new AppSettingsService().Load();
|
||||
var snapshot = new ComponentSettingsService().Load();
|
||||
if (snapshot.ImportedClassSchedules.Count == 0)
|
||||
{
|
||||
return null;
|
||||
|
||||
403
LanMountainDesktop/Services/ComponentSettingsService.cs
Normal file
403
LanMountainDesktop/Services/ComponentSettingsService.cs
Normal file
@@ -0,0 +1,403 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using LanMountainDesktop.Models;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
public sealed class ComponentSettingsService
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new()
|
||||
{
|
||||
WriteIndented = true
|
||||
};
|
||||
|
||||
private static readonly object CacheGate = new();
|
||||
private static readonly TimeSpan CacheProbeInterval = TimeSpan.FromMilliseconds(400);
|
||||
private static readonly int[] SupportedCnrIntervals = [5, 10, 40, 60, 720, 1440];
|
||||
private static readonly int[] SupportedDailyWordIntervals = [30, 60, 180, 360, 720, 1440];
|
||||
private static readonly int[] SupportedBilibiliHotSearchIntervals = [5, 10, 15, 30, 60, 180];
|
||||
|
||||
private static string? _cachedPath;
|
||||
private static ComponentSettingsSnapshot? _cachedSnapshot;
|
||||
private static DateTime _cachedWriteTimeUtc = DateTime.MinValue;
|
||||
private static DateTime _lastProbeUtc = DateTime.MinValue;
|
||||
|
||||
private readonly string _settingsPath;
|
||||
private readonly string _legacyAppSettingsPath;
|
||||
|
||||
public ComponentSettingsService()
|
||||
{
|
||||
var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
var settingsDirectory = Path.Combine(appData, "LanMountainDesktop");
|
||||
_settingsPath = Path.Combine(settingsDirectory, "component-settings.json");
|
||||
_legacyAppSettingsPath = Path.Combine(settingsDirectory, "settings.json");
|
||||
}
|
||||
|
||||
public ComponentSettingsSnapshot Load()
|
||||
{
|
||||
try
|
||||
{
|
||||
lock (CacheGate)
|
||||
{
|
||||
var nowUtc = DateTime.UtcNow;
|
||||
if (TryGetCachedWithoutProbe(nowUtc, out var cached))
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
var hasFile = File.Exists(_settingsPath);
|
||||
var writeTimeUtc = hasFile
|
||||
? File.GetLastWriteTimeUtc(_settingsPath)
|
||||
: DateTime.MinValue;
|
||||
|
||||
_lastProbeUtc = nowUtc;
|
||||
if (TryGetCachedAfterProbe(writeTimeUtc, out cached))
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
ComponentSettingsSnapshot loadedSnapshot;
|
||||
var loadedFromLegacy = false;
|
||||
if (hasFile)
|
||||
{
|
||||
loadedSnapshot = LoadSnapshotFromDisk();
|
||||
}
|
||||
else if (TryLoadLegacySnapshot(out var migratedSnapshot))
|
||||
{
|
||||
loadedSnapshot = migratedSnapshot;
|
||||
loadedFromLegacy = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
loadedSnapshot = new ComponentSettingsSnapshot();
|
||||
}
|
||||
|
||||
var normalizedSnapshot = NormalizeSnapshot(loadedSnapshot);
|
||||
if (loadedFromLegacy)
|
||||
{
|
||||
writeTimeUtc = PersistSnapshotToDisk(normalizedSnapshot);
|
||||
}
|
||||
|
||||
UpdateCache(normalizedSnapshot, writeTimeUtc, nowUtc);
|
||||
return normalizedSnapshot.Clone();
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new ComponentSettingsSnapshot();
|
||||
}
|
||||
}
|
||||
|
||||
public void Save(ComponentSettingsSnapshot snapshot)
|
||||
{
|
||||
var snapshotToPersist = NormalizeSnapshot(snapshot);
|
||||
|
||||
try
|
||||
{
|
||||
var writeTimeUtc = PersistSnapshotToDisk(snapshotToPersist);
|
||||
|
||||
lock (CacheGate)
|
||||
{
|
||||
UpdateCache(snapshotToPersist, writeTimeUtc, DateTime.UtcNow);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Swallow persistence errors to keep UI interactions uninterrupted.
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryGetCachedWithoutProbe(DateTime nowUtc, out ComponentSettingsSnapshot snapshot)
|
||||
{
|
||||
if (string.Equals(_cachedPath, _settingsPath, StringComparison.Ordinal) &&
|
||||
_cachedSnapshot is not null &&
|
||||
nowUtc - _lastProbeUtc < CacheProbeInterval)
|
||||
{
|
||||
snapshot = _cachedSnapshot.Clone();
|
||||
return true;
|
||||
}
|
||||
|
||||
snapshot = null!;
|
||||
return false;
|
||||
}
|
||||
|
||||
private bool TryGetCachedAfterProbe(DateTime writeTimeUtc, out ComponentSettingsSnapshot snapshot)
|
||||
{
|
||||
if (string.Equals(_cachedPath, _settingsPath, StringComparison.Ordinal) &&
|
||||
_cachedSnapshot is not null &&
|
||||
writeTimeUtc == _cachedWriteTimeUtc)
|
||||
{
|
||||
snapshot = _cachedSnapshot.Clone();
|
||||
return true;
|
||||
}
|
||||
|
||||
snapshot = null!;
|
||||
return false;
|
||||
}
|
||||
|
||||
private ComponentSettingsSnapshot LoadSnapshotFromDisk()
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(_settingsPath);
|
||||
var snapshot = JsonSerializer.Deserialize<ComponentSettingsSnapshot>(json, SerializerOptions);
|
||||
return NormalizeSnapshot(snapshot);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new ComponentSettingsSnapshot();
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryLoadLegacySnapshot(out ComponentSettingsSnapshot snapshot)
|
||||
{
|
||||
snapshot = new ComponentSettingsSnapshot();
|
||||
try
|
||||
{
|
||||
if (!File.Exists(_legacyAppSettingsPath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var legacyJson = File.ReadAllText(_legacyAppSettingsPath);
|
||||
var legacy = JsonSerializer.Deserialize<LegacyComponentSettingsSnapshot>(legacyJson, SerializerOptions);
|
||||
if (legacy is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
snapshot = new ComponentSettingsSnapshot
|
||||
{
|
||||
DailyArtworkMirrorSource = legacy.DailyArtworkMirrorSource,
|
||||
ImportedClassSchedules = legacy.ImportedClassSchedules ?? [],
|
||||
ActiveImportedClassScheduleId = legacy.ActiveImportedClassScheduleId ?? string.Empty,
|
||||
StudyEnvironmentShowDisplayDb = legacy.StudyEnvironmentShowDisplayDb,
|
||||
StudyEnvironmentShowDbfs = legacy.StudyEnvironmentShowDbfs,
|
||||
DesktopClockTimeZoneId = legacy.DesktopClockTimeZoneId,
|
||||
DesktopClockSecondHandMode = legacy.DesktopClockSecondHandMode,
|
||||
WorldClockTimeZoneIds = legacy.WorldClockTimeZoneIds ?? [],
|
||||
WorldClockSecondHandMode = legacy.WorldClockSecondHandMode,
|
||||
CnrDailyNewsAutoRotateEnabled = legacy.CnrDailyNewsAutoRotateEnabled,
|
||||
CnrDailyNewsAutoRotateIntervalMinutes = legacy.CnrDailyNewsAutoRotateIntervalMinutes,
|
||||
DailyWordAutoRefreshEnabled = legacy.DailyWordAutoRefreshEnabled,
|
||||
DailyWordAutoRefreshIntervalMinutes = legacy.DailyWordAutoRefreshIntervalMinutes,
|
||||
BilibiliHotSearchAutoRefreshEnabled = legacy.BilibiliHotSearchAutoRefreshEnabled,
|
||||
BilibiliHotSearchAutoRefreshIntervalMinutes = legacy.BilibiliHotSearchAutoRefreshIntervalMinutes
|
||||
};
|
||||
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private DateTime PersistSnapshotToDisk(ComponentSettingsSnapshot snapshot)
|
||||
{
|
||||
var directory = Path.GetDirectoryName(_settingsPath);
|
||||
if (!string.IsNullOrWhiteSpace(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
var json = JsonSerializer.Serialize(snapshot, SerializerOptions);
|
||||
File.WriteAllText(_settingsPath, json);
|
||||
|
||||
return File.Exists(_settingsPath)
|
||||
? File.GetLastWriteTimeUtc(_settingsPath)
|
||||
: DateTime.UtcNow;
|
||||
}
|
||||
|
||||
private static ComponentSettingsSnapshot NormalizeSnapshot(ComponentSettingsSnapshot? snapshot)
|
||||
{
|
||||
var normalized = snapshot?.Clone() ?? new ComponentSettingsSnapshot();
|
||||
|
||||
normalized.DailyArtworkMirrorSource = DailyArtworkMirrorSources.Normalize(normalized.DailyArtworkMirrorSource);
|
||||
normalized.ImportedClassSchedules = NormalizeImportedSchedules(normalized.ImportedClassSchedules);
|
||||
normalized.ActiveImportedClassScheduleId = NormalizeActiveScheduleId(
|
||||
normalized.ActiveImportedClassScheduleId,
|
||||
normalized.ImportedClassSchedules);
|
||||
|
||||
if (!normalized.StudyEnvironmentShowDisplayDb && !normalized.StudyEnvironmentShowDbfs)
|
||||
{
|
||||
normalized.StudyEnvironmentShowDisplayDb = true;
|
||||
}
|
||||
|
||||
normalized.DesktopClockTimeZoneId = NormalizeDesktopClockTimeZoneId(normalized.DesktopClockTimeZoneId);
|
||||
normalized.DesktopClockSecondHandMode = ClockSecondHandMode.Normalize(normalized.DesktopClockSecondHandMode);
|
||||
normalized.WorldClockTimeZoneIds = WorldClockTimeZoneCatalog
|
||||
.NormalizeTimeZoneIds(normalized.WorldClockTimeZoneIds)
|
||||
.ToList();
|
||||
normalized.WorldClockSecondHandMode = ClockSecondHandMode.Normalize(normalized.WorldClockSecondHandMode);
|
||||
normalized.CnrDailyNewsAutoRotateIntervalMinutes = NormalizeCnrInterval(normalized.CnrDailyNewsAutoRotateIntervalMinutes);
|
||||
normalized.DailyWordAutoRefreshIntervalMinutes = NormalizeDailyWordInterval(normalized.DailyWordAutoRefreshIntervalMinutes);
|
||||
normalized.BilibiliHotSearchAutoRefreshIntervalMinutes = NormalizeBilibiliHotSearchInterval(
|
||||
normalized.BilibiliHotSearchAutoRefreshIntervalMinutes);
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static List<ImportedClassScheduleSnapshot> NormalizeImportedSchedules(
|
||||
IReadOnlyList<ImportedClassScheduleSnapshot>? schedules)
|
||||
{
|
||||
if (schedules is null || schedules.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var result = new List<ImportedClassScheduleSnapshot>(schedules.Count);
|
||||
var seenIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var schedule in schedules)
|
||||
{
|
||||
if (schedule is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var id = schedule.Id?.Trim() ?? string.Empty;
|
||||
var filePath = schedule.FilePath?.Trim() ?? string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(filePath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!seenIds.Add(id))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
result.Add(new ImportedClassScheduleSnapshot
|
||||
{
|
||||
Id = id,
|
||||
DisplayName = schedule.DisplayName?.Trim() ?? string.Empty,
|
||||
FilePath = filePath
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string NormalizeActiveScheduleId(
|
||||
string? activeScheduleId,
|
||||
IReadOnlyList<ImportedClassScheduleSnapshot> schedules)
|
||||
{
|
||||
var activeId = activeScheduleId?.Trim() ?? string.Empty;
|
||||
if (schedules.Count == 0)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(activeId))
|
||||
{
|
||||
return schedules[0].Id;
|
||||
}
|
||||
|
||||
return schedules.Any(item => string.Equals(item.Id, activeId, StringComparison.OrdinalIgnoreCase))
|
||||
? activeId
|
||||
: schedules[0].Id;
|
||||
}
|
||||
|
||||
private static string NormalizeDesktopClockTimeZoneId(string? timeZoneId)
|
||||
{
|
||||
var normalizedId = string.IsNullOrWhiteSpace(timeZoneId)
|
||||
? "China Standard Time"
|
||||
: timeZoneId.Trim();
|
||||
return WorldClockTimeZoneCatalog.ResolveTimeZoneOrLocal(normalizedId).Id;
|
||||
}
|
||||
|
||||
private static int NormalizeCnrInterval(int minutes)
|
||||
{
|
||||
if (minutes <= 0)
|
||||
{
|
||||
return 60;
|
||||
}
|
||||
|
||||
if (SupportedCnrIntervals.Contains(minutes))
|
||||
{
|
||||
return minutes;
|
||||
}
|
||||
|
||||
return SupportedCnrIntervals
|
||||
.OrderBy(value => Math.Abs(value - minutes))
|
||||
.FirstOrDefault(60);
|
||||
}
|
||||
|
||||
private static int NormalizeDailyWordInterval(int minutes)
|
||||
{
|
||||
if (minutes <= 0)
|
||||
{
|
||||
return 360;
|
||||
}
|
||||
|
||||
if (SupportedDailyWordIntervals.Contains(minutes))
|
||||
{
|
||||
return minutes;
|
||||
}
|
||||
|
||||
return SupportedDailyWordIntervals
|
||||
.OrderBy(value => Math.Abs(value - minutes))
|
||||
.FirstOrDefault(360);
|
||||
}
|
||||
|
||||
private static int NormalizeBilibiliHotSearchInterval(int minutes)
|
||||
{
|
||||
if (minutes <= 0)
|
||||
{
|
||||
return 15;
|
||||
}
|
||||
|
||||
if (SupportedBilibiliHotSearchIntervals.Contains(minutes))
|
||||
{
|
||||
return minutes;
|
||||
}
|
||||
|
||||
return SupportedBilibiliHotSearchIntervals
|
||||
.OrderBy(value => Math.Abs(value - minutes))
|
||||
.FirstOrDefault(15);
|
||||
}
|
||||
|
||||
private void UpdateCache(ComponentSettingsSnapshot snapshot, DateTime writeTimeUtc, DateTime probeTimeUtc)
|
||||
{
|
||||
_cachedPath = _settingsPath;
|
||||
_cachedSnapshot = snapshot.Clone();
|
||||
_cachedWriteTimeUtc = writeTimeUtc;
|
||||
_lastProbeUtc = probeTimeUtc;
|
||||
}
|
||||
|
||||
private sealed class LegacyComponentSettingsSnapshot
|
||||
{
|
||||
public string DailyArtworkMirrorSource { get; set; } = DailyArtworkMirrorSources.Overseas;
|
||||
|
||||
public List<ImportedClassScheduleSnapshot>? ImportedClassSchedules { get; set; }
|
||||
|
||||
public string? ActiveImportedClassScheduleId { get; set; }
|
||||
|
||||
public bool StudyEnvironmentShowDisplayDb { get; set; } = true;
|
||||
|
||||
public bool StudyEnvironmentShowDbfs { get; set; }
|
||||
|
||||
public string DesktopClockTimeZoneId { get; set; } = "China Standard Time";
|
||||
|
||||
public string DesktopClockSecondHandMode { get; set; } = "Tick";
|
||||
|
||||
public List<string>? WorldClockTimeZoneIds { get; set; }
|
||||
|
||||
public string WorldClockSecondHandMode { get; set; } = "Tick";
|
||||
|
||||
public bool CnrDailyNewsAutoRotateEnabled { get; set; } = true;
|
||||
|
||||
public int CnrDailyNewsAutoRotateIntervalMinutes { get; set; } = 60;
|
||||
|
||||
public bool DailyWordAutoRefreshEnabled { get; set; } = true;
|
||||
|
||||
public int DailyWordAutoRefreshIntervalMinutes { get; set; } = 360;
|
||||
|
||||
public bool BilibiliHotSearchAutoRefreshEnabled { get; set; } = true;
|
||||
|
||||
public int BilibiliHotSearchAutoRefreshIntervalMinutes { get; set; } = 15;
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,11 @@ public sealed record DailyWordQuery(
|
||||
string? Locale = null,
|
||||
bool ForceRefresh = false);
|
||||
|
||||
public sealed record Stcn24ForumPostsQuery(
|
||||
string? Locale = null,
|
||||
int? ItemCount = null,
|
||||
bool ForceRefresh = false);
|
||||
|
||||
public sealed record ExchangeRateQuery(
|
||||
string? BaseCurrency = null,
|
||||
string? TargetCurrency = null,
|
||||
@@ -84,6 +89,13 @@ public sealed record RecommendationApiOptions
|
||||
|
||||
public string BilibiliSearchPageUrl { get; init; } = "https://search.bilibili.com/all";
|
||||
|
||||
public string SmartTeachForumApiTemplate { get; init; } =
|
||||
"https://forum.smart-teach.cn/api/discussions?filter[q]={0}&sort=-createdAt&page[limit]={1}&include=user";
|
||||
|
||||
public string SmartTeachForumBaseUrl { get; init; } = "https://forum.smart-teach.cn";
|
||||
|
||||
public string SmartTeachStcnKeyword { get; init; } = "STCN";
|
||||
|
||||
public string YoudaoDictionaryApiTemplate { get; init; } = "https://dict.youdao.com/jsonapi?q={0}";
|
||||
|
||||
public string YoudaoDictionaryWordPageTemplate { get; init; } = "https://dict.youdao.com/w/eng/{0}/";
|
||||
@@ -226,6 +238,8 @@ public sealed record RecommendationApiOptions
|
||||
public int DefaultDailyNewsCount { get; init; } = 2;
|
||||
|
||||
public int DefaultBilibiliHotSearchCount { get; init; } = 5;
|
||||
|
||||
public int DefaultStcn24ForumPostCount { get; init; } = 4;
|
||||
}
|
||||
|
||||
public interface IRecommendationInfoService
|
||||
@@ -250,6 +264,10 @@ public interface IRecommendationInfoService
|
||||
DailyWordQuery query,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<RecommendationQueryResult<Stcn24ForumPostsSnapshot>> GetStcn24ForumPostsAsync(
|
||||
Stcn24ForumPostsQuery query,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<RecommendationQueryResult<ExchangeRateSnapshot>> GetExchangeRateAsync(
|
||||
ExchangeRateQuery query,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
@@ -36,6 +36,7 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
|
||||
private sealed record DailyNewsCacheEntry(DailyNewsSnapshot Snapshot, DateTimeOffset ExpireAt);
|
||||
private sealed record BilibiliHotSearchCacheEntry(BilibiliHotSearchSnapshot Snapshot, DateTimeOffset ExpireAt);
|
||||
private sealed record DailyWordCacheEntry(DailyWordSnapshot Snapshot, DateTimeOffset ExpireAt);
|
||||
private sealed record Stcn24ForumPostsCacheEntry(Stcn24ForumPostsSnapshot Snapshot, DateTimeOffset ExpireAt);
|
||||
private sealed record ExchangeRateTableCacheEntry(
|
||||
string BaseCurrency,
|
||||
Dictionary<string, decimal> Rates,
|
||||
@@ -52,7 +53,7 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
|
||||
private readonly RecommendationApiOptions _options;
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly bool _ownsHttpClient;
|
||||
private readonly AppSettingsService _appSettingsService = new();
|
||||
private readonly ComponentSettingsService _componentSettingsService = new();
|
||||
private readonly object _cacheGate = new();
|
||||
private readonly Dictionary<string, DailyArtworkCacheEntry> _dailyArtworkCacheBySource =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
@@ -60,6 +61,7 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
|
||||
private DailyNewsCacheEntry? _dailyNewsCache;
|
||||
private BilibiliHotSearchCacheEntry? _bilibiliHotSearchCache;
|
||||
private DailyWordCacheEntry? _dailyWordCache;
|
||||
private Stcn24ForumPostsCacheEntry? _stcn24ForumPostsCache;
|
||||
private readonly Dictionary<string, ExchangeRateTableCacheEntry> _exchangeRateCacheByBaseCurrency =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
private int _dailyNewsRotationCursor;
|
||||
@@ -106,6 +108,7 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
|
||||
_dailyNewsCache = null;
|
||||
_bilibiliHotSearchCache = null;
|
||||
_dailyWordCache = null;
|
||||
_stcn24ForumPostsCache = null;
|
||||
_exchangeRateCacheByBaseCurrency.Clear();
|
||||
}
|
||||
}
|
||||
@@ -340,6 +343,53 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
|
||||
lastError?.Message ?? "No available daily word from Youdao.");
|
||||
}
|
||||
|
||||
public async Task<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(
|
||||
ExchangeRateQuery query,
|
||||
CancellationToken cancellationToken = default)
|
||||
@@ -762,6 +812,141 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
|
||||
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)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_options.BilibiliSearchDefaultApiUrl))
|
||||
@@ -864,6 +1049,31 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
|
||||
return selection;
|
||||
}
|
||||
|
||||
private bool TryGetStcn24ForumPostsFromCache(out Stcn24ForumPostsSnapshot snapshot)
|
||||
{
|
||||
lock (_cacheGate)
|
||||
{
|
||||
if (_stcn24ForumPostsCache is not null && _stcn24ForumPostsCache.ExpireAt > DateTimeOffset.UtcNow)
|
||||
{
|
||||
snapshot = _stcn24ForumPostsCache.Snapshot;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
snapshot = null!;
|
||||
return false;
|
||||
}
|
||||
|
||||
private void SetStcn24ForumPostsCache(Stcn24ForumPostsSnapshot snapshot)
|
||||
{
|
||||
lock (_cacheGate)
|
||||
{
|
||||
_stcn24ForumPostsCache = new Stcn24ForumPostsCacheEntry(
|
||||
snapshot,
|
||||
DateTimeOffset.UtcNow.Add(_options.CacheDuration));
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryGetDailyWordFromCache(out DailyWordSnapshot snapshot)
|
||||
{
|
||||
lock (_cacheGate)
|
||||
@@ -1942,6 +2152,84 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
|
||||
: null;
|
||||
}
|
||||
|
||||
private static bool IsSmartTeachPinnedDiscussion(JsonElement discussionNode)
|
||||
{
|
||||
return ReadBoolean(discussionNode, "attributes", "isStickiest") ||
|
||||
ReadBoolean(discussionNode, "attributes", "isSticky") ||
|
||||
ReadBoolean(discussionNode, "attributes", "isTagSticky") ||
|
||||
ReadBoolean(discussionNode, "attributes", "front") ||
|
||||
ReadBoolean(discussionNode, "attributes", "frontpage");
|
||||
}
|
||||
|
||||
private static string? ResolveSmartTeachForumUrl(string? rawUrl, string? baseUrl)
|
||||
{
|
||||
var normalizedAbsolute = NormalizeHttpUrl(rawUrl);
|
||||
if (!string.IsNullOrWhiteSpace(normalizedAbsolute))
|
||||
{
|
||||
return normalizedAbsolute;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(rawUrl) || string.IsNullOrWhiteSpace(baseUrl))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!Uri.TryCreate(baseUrl.Trim(), UriKind.Absolute, out var baseUri))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var normalized = ResolveAbsoluteUrl(rawUrl, baseUri);
|
||||
return NormalizeHttpUrl(normalized);
|
||||
}
|
||||
|
||||
private static string? BuildSmartTeachDiscussionUrl(string? baseUrl, string discussionId, string slug)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(baseUrl) || string.IsNullOrWhiteSpace(discussionId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!Uri.TryCreate(baseUrl.Trim(), UriKind.Absolute, out var baseUri))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var normalizedId = discussionId.Trim();
|
||||
var normalizedSlug = slug.Trim();
|
||||
string path;
|
||||
if (string.IsNullOrWhiteSpace(normalizedSlug))
|
||||
{
|
||||
path = $"/d/{normalizedId}";
|
||||
}
|
||||
else if (normalizedSlug.StartsWith($"{normalizedId}-", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
path = $"/d/{normalizedSlug}";
|
||||
}
|
||||
else
|
||||
{
|
||||
path = $"/d/{normalizedId}-{normalizedSlug}";
|
||||
}
|
||||
|
||||
return new Uri(baseUri, path).ToString();
|
||||
}
|
||||
|
||||
private static DateTimeOffset? TryParseDateTimeOffset(string? rawValue)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rawValue))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return DateTimeOffset.TryParse(
|
||||
rawValue,
|
||||
CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal,
|
||||
out var value)
|
||||
? value
|
||||
: null;
|
||||
}
|
||||
|
||||
private static string NormalizeInlineText(string? text)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
@@ -1972,6 +2260,30 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
|
||||
};
|
||||
}
|
||||
|
||||
private static bool ReadBoolean(JsonElement node, params string[] path)
|
||||
{
|
||||
var target = TryGetNode(node, path);
|
||||
if (!target.HasValue)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var value = target.Value;
|
||||
switch (value.ValueKind)
|
||||
{
|
||||
case JsonValueKind.True:
|
||||
return true;
|
||||
case JsonValueKind.False:
|
||||
return false;
|
||||
case JsonValueKind.String:
|
||||
return bool.TryParse(value.GetString(), out var boolValue) && boolValue;
|
||||
case JsonValueKind.Number:
|
||||
return value.TryGetInt32(out var intValue) && intValue != 0;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static JsonElement? TryGetNode(JsonElement node, params string[] path)
|
||||
{
|
||||
var current = node;
|
||||
@@ -2010,7 +2322,7 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
|
||||
|
||||
try
|
||||
{
|
||||
var snapshot = _appSettingsService.Load();
|
||||
var snapshot = _componentSettingsService.Load();
|
||||
return DailyArtworkMirrorSources.Normalize(snapshot.DailyArtworkMirrorSource);
|
||||
}
|
||||
catch
|
||||
|
||||
Reference in New Issue
Block a user