新增STCN 24组件,优化应用启动台,允许用户隐藏应用启动台图标。优化组件拖动排放。
This commit is contained in:
lincube
2026-03-06 08:53:45 +08:00
parent 5d35e0d21c
commit de40471af6
37 changed files with 2949 additions and 142 deletions

View File

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