diff --git a/LanMountainDesktop/ComponentSystem/BuiltInComponentIds.cs b/LanMountainDesktop/ComponentSystem/BuiltInComponentIds.cs index bd1c266..80f6f8a 100644 --- a/LanMountainDesktop/ComponentSystem/BuiltInComponentIds.cs +++ b/LanMountainDesktop/ComponentSystem/BuiltInComponentIds.cs @@ -30,8 +30,9 @@ public static class BuiltInComponentIds public const string DesktopDailyPoetry = "DesktopDailyPoetry"; public const string DesktopDailyArtwork = "DesktopDailyArtwork"; public const string DesktopDailyWord = "DesktopDailyWord"; - public const string DesktopDailySentence = "DesktopDailySentence"; public const string DesktopCnrDailyNews = "DesktopCnrDailyNews"; + public const string DesktopBilibiliHotSearch = "DesktopBilibiliHotSearch"; + public const string DesktopExchangeRateCalculator = "DesktopExchangeRateCalculator"; public const string DesktopWhiteboard = "DesktopWhiteboard"; public const string DesktopBlackboardLandscape = "DesktopBlackboardLandscape"; public const string DesktopBrowser = "DesktopBrowser"; diff --git a/LanMountainDesktop/ComponentSystem/ComponentRegistry.cs b/LanMountainDesktop/ComponentSystem/ComponentRegistry.cs index d0a7378..5a5a4a5 100644 --- a/LanMountainDesktop/ComponentSystem/ComponentRegistry.cs +++ b/LanMountainDesktop/ComponentSystem/ComponentRegistry.cs @@ -234,15 +234,6 @@ public sealed class ComponentRegistry MinHeightCells: 2, AllowStatusBarPlacement: false, AllowDesktopPlacement: true), - new DesktopComponentDefinition( - BuiltInComponentIds.DesktopDailySentence, - "Daily Sentence", - "TextQuote", - "Info", - MinWidthCells: 4, - MinHeightCells: 2, - AllowStatusBarPlacement: false, - AllowDesktopPlacement: true), new DesktopComponentDefinition( BuiltInComponentIds.DesktopCnrDailyNews, "CNR Daily News", @@ -252,6 +243,24 @@ public sealed class ComponentRegistry MinHeightCells: 2, AllowStatusBarPlacement: false, AllowDesktopPlacement: true), + new DesktopComponentDefinition( + BuiltInComponentIds.DesktopBilibiliHotSearch, + "Bilibili Hot Search", + "News", + "Info", + MinWidthCells: 4, + MinHeightCells: 2, + AllowStatusBarPlacement: false, + AllowDesktopPlacement: true), + new DesktopComponentDefinition( + BuiltInComponentIds.DesktopExchangeRateCalculator, + "Exchange Rate Converter", + "Calculator", + "Calculator", + MinWidthCells: 4, + MinHeightCells: 4, + AllowStatusBarPlacement: false, + AllowDesktopPlacement: true), new DesktopComponentDefinition( BuiltInComponentIds.DesktopWhiteboard, "Blackboard Portrait", diff --git a/LanMountainDesktop/Localization/en-US.json b/LanMountainDesktop/Localization/en-US.json index 13da5e4..62da323 100644 --- a/LanMountainDesktop/Localization/en-US.json +++ b/LanMountainDesktop/Localization/en-US.json @@ -265,6 +265,7 @@ "component_category.board": "Board", "component_category.media": "Media", "component_category.info": "Info", + "component_category.calculator": "Calculator", "component_category.study": "Study", "component.date": "Calendar", "component.month_calendar": "Month Calendar", @@ -283,8 +284,9 @@ "component.daily_poetry": "Daily Poetry", "component.daily_artwork": "Daily Artwork", "component.daily_word": "Daily Word", - "component.daily_sentence": "English Sentence", "component.cnr_daily_news": "CNR Headlines", + "component.bilibili_hot_search": "Bilibili Hot Search", + "component.exchange_rate_converter": "Exchange Rate Converter", "component.whiteboard": "Blackboard (Portrait)", "component.blackboard_landscape": "Blackboard (Landscape)", "component.browser": "Browser", @@ -329,25 +331,6 @@ "dailyword.widget.fallback_meaning": "Youdao dictionary is temporarily unavailable.", "dailyword.widget.fallback_example": "Tap the refresh button and try again.", "dailyword.widget.fallback_example_translation": "It will retry when network recovers.", - "dailysentence.widget.loading": "Loading...", - "dailysentence.widget.loading_sentence": "Fetching daily sentence...", - "dailysentence.widget.loading_translation": "Fetching translation...", - "dailysentence.widget.loading_source": "Youdao Dictionary", - "dailysentence.widget.fetch_failed": "Sentence fetch failed", - "dailysentence.widget.fallback_sentence": "Daily sentence is temporarily unavailable.", - "dailysentence.widget.fallback_translation": "Tap refresh and try again.", - "dailysentence.widget.source_default": "Youdao Dictionary", - "daily_sentence.settings.title": "Daily Sentence Settings", - "daily_sentence.settings.desc": "Configure auto-rotation and refresh interval.", - "daily_sentence.settings.auto_rotate_label": "Auto-rotation", - "daily_sentence.settings.auto_rotate_enabled": "Enable auto-rotation", - "daily_sentence.settings.frequency_label": "Rotation interval", - "daily_sentence.settings.frequency_5m": "5 minutes", - "daily_sentence.settings.frequency_10m": "10 minutes", - "daily_sentence.settings.frequency_40m": "40 minutes", - "daily_sentence.settings.frequency_1h": "1 hour", - "daily_sentence.settings.frequency_12h": "12 hours", - "daily_sentence.settings.frequency_24h": "24 hours", "cnrnews.widget.loading": "Loading...", "cnrnews.widget.loading_title": "Fetching CNR headlines", "cnrnews.widget.loading_subtitle": "Please wait", @@ -355,6 +338,17 @@ "cnrnews.widget.fallback_title": "CNR news is temporarily unavailable", "cnrnews.widget.fallback_subtitle": "Tap refresh and try again", "cnrnews.widget.hot_label": "Hot", + "bilihot.widget.brand": "bilibili hot search", + "bilihot.widget.top_right_label": "bilibili热搜", + "bilihot.widget.search_entry": "Search", + "bilihot.widget.search_placeholder": "Search trending topics", + "bilihot.widget.loading": "Loading...", + "bilihot.widget.loading_item": "Loading...", + "bilihot.widget.fetch_failed": "Hot search fetch failed", + "bilihot.widget.fallback_item": "No hot search data", + "bilihot.widget.more_hot": "More hot search", + "exchange.widget.loading": "Loading exchange rates...", + "exchange.widget.fetch_failed": "Exchange rate fetch failed", "cnrnews.settings.title": "CNR Settings", "cnrnews.settings.desc": "Configure auto-rotation and refresh interval.", "cnrnews.settings.auto_rotate_label": "Auto-rotation", diff --git a/LanMountainDesktop/Localization/zh-CN.json b/LanMountainDesktop/Localization/zh-CN.json index 7215fbf..d86c52b 100644 --- a/LanMountainDesktop/Localization/zh-CN.json +++ b/LanMountainDesktop/Localization/zh-CN.json @@ -265,6 +265,7 @@ "component_category.board": "白板", "component_category.media": "媒体", "component_category.info": "信息推荐", + "component_category.calculator": "计算器", "component_category.study": "自习", "component.date": "日历", "component.month_calendar": "月历", @@ -283,8 +284,9 @@ "component.daily_poetry": "每日诗词", "component.daily_artwork": "每日名画", "component.daily_word": "每日单词", - "component.daily_sentence": "英语句子", "component.cnr_daily_news": "央广网头条", + "component.bilibili_hot_search": "B站热搜", + "component.exchange_rate_converter": "汇率换算", "component.whiteboard": "竖向小黑板", "component.blackboard_landscape": "横向小黑板", "component.browser": "浏览器", @@ -329,25 +331,6 @@ "dailyword.widget.fallback_meaning": "有道词典暂不可用", "dailyword.widget.fallback_example": "请点击右上角刷新重试", "dailyword.widget.fallback_example_translation": "网络恢复后将自动更新", - "dailysentence.widget.loading": "加载中...", - "dailysentence.widget.loading_sentence": "正在获取英语句子", - "dailysentence.widget.loading_translation": "正在获取句子译文", - "dailysentence.widget.loading_source": "有道词典", - "dailysentence.widget.fetch_failed": "英语句子获取失败", - "dailysentence.widget.fallback_sentence": "今日英语句子暂不可用", - "dailysentence.widget.fallback_translation": "请点击右上角刷新重试", - "dailysentence.widget.source_default": "有道词典", - "daily_sentence.settings.title": "英语句子设置", - "daily_sentence.settings.desc": "配置自动轮换与刷新频率。", - "daily_sentence.settings.auto_rotate_label": "自动轮换", - "daily_sentence.settings.auto_rotate_enabled": "启用自动轮换", - "daily_sentence.settings.frequency_label": "轮换频率", - "daily_sentence.settings.frequency_5m": "5 分钟", - "daily_sentence.settings.frequency_10m": "10 分钟", - "daily_sentence.settings.frequency_40m": "40 分钟", - "daily_sentence.settings.frequency_1h": "1 小时", - "daily_sentence.settings.frequency_12h": "12 小时", - "daily_sentence.settings.frequency_24h": "24 小时", "cnrnews.widget.loading": "加载中...", "cnrnews.widget.loading_title": "正在获取新闻热点", "cnrnews.widget.loading_subtitle": "请稍候", @@ -355,6 +338,17 @@ "cnrnews.widget.fallback_title": "央广网新闻暂不可用", "cnrnews.widget.fallback_subtitle": "点击右上角稍后重试", "cnrnews.widget.hot_label": "热点", + "bilihot.widget.brand": "bilibili 热搜", + "bilihot.widget.top_right_label": "bilibili热搜", + "bilihot.widget.search_entry": "搜索", + "bilihot.widget.search_placeholder": "搜索热词", + "bilihot.widget.loading": "加载中...", + "bilihot.widget.loading_item": "加载中...", + "bilihot.widget.fetch_failed": "热搜获取失败", + "bilihot.widget.fallback_item": "暂无热搜", + "bilihot.widget.more_hot": "更多热搜", + "exchange.widget.loading": "正在加载汇率...", + "exchange.widget.fetch_failed": "汇率获取失败", "cnrnews.settings.title": "央广网设置", "cnrnews.settings.desc": "配置新闻自动轮换与刷新频率。", "cnrnews.settings.auto_rotate_label": "自动轮换", diff --git a/LanMountainDesktop/Models/AppSettingsSnapshot.cs b/LanMountainDesktop/Models/AppSettingsSnapshot.cs index 25279f3..835d01e 100644 --- a/LanMountainDesktop/Models/AppSettingsSnapshot.cs +++ b/LanMountainDesktop/Models/AppSettingsSnapshot.cs @@ -98,10 +98,6 @@ public sealed class AppSettingsSnapshot ]; public string WorldClockSecondHandMode { get; set; } = "Tick"; - public bool DailySentenceAutoRotateEnabled { get; set; } = true; - - public int DailySentenceAutoRotateIntervalMinutes { get; set; } = 60; - public bool CnrDailyNewsAutoRotateEnabled { get; set; } = true; public int CnrDailyNewsAutoRotateIntervalMinutes { get; set; } = 60; diff --git a/LanMountainDesktop/Models/RecommendationDataModels.cs b/LanMountainDesktop/Models/RecommendationDataModels.cs index 534bb6e..9506747 100644 --- a/LanMountainDesktop/Models/RecommendationDataModels.cs +++ b/LanMountainDesktop/Models/RecommendationDataModels.cs @@ -35,6 +35,23 @@ public sealed record DailyNewsSnapshot( IReadOnlyList Items, DateTimeOffset FetchedAt); +public sealed record BilibiliHotSearchItemSnapshot( + string Title, + string Keyword, + string Url, + long? HeatScore, + bool HasHotTag, + string? IconUrl); + +public sealed record BilibiliHotSearchSnapshot( + string Provider, + string Source, + string SearchPlaceholder, + string SearchUrl, + string MoreHotUrl, + IReadOnlyList Items, + DateTimeOffset FetchedAt); + public sealed record DailyWordSnapshot( string Provider, string Word, @@ -45,3 +62,11 @@ public sealed record DailyWordSnapshot( string? ExampleTranslation, string? SourceUrl, DateTimeOffset FetchedAt); + +public sealed record ExchangeRateSnapshot( + string Provider, + string Source, + string BaseCurrency, + string TargetCurrency, + decimal Rate, + DateTimeOffset FetchedAt); diff --git a/LanMountainDesktop/Services/CalculatorDataService.cs b/LanMountainDesktop/Services/CalculatorDataService.cs new file mode 100644 index 0000000..e18e941 --- /dev/null +++ b/LanMountainDesktop/Services/CalculatorDataService.cs @@ -0,0 +1,123 @@ +using System; +using System.Globalization; + +namespace LanMountainDesktop.Services; + +public sealed class CalculatorDataService : ICalculatorDataService +{ + private const int MaxInputLength = 18; + + public string ApplyInputToken(string currentInput, string token) + { + var normalized = NormalizeInput(currentInput); + if (string.IsNullOrWhiteSpace(token)) + { + return normalized; + } + + if (string.Equals(token, CalculatorInputTokens.Clear, StringComparison.OrdinalIgnoreCase)) + { + return "0"; + } + + if (string.Equals(token, CalculatorInputTokens.Backspace, StringComparison.OrdinalIgnoreCase)) + { + if (normalized.Length <= 1) + { + return "0"; + } + + var trimmed = normalized[..^1]; + if (trimmed is "-" or "" or "-0") + { + return "0"; + } + + return trimmed; + } + + if (string.Equals(token, CalculatorInputTokens.DecimalPoint, StringComparison.Ordinal)) + { + if (normalized.Contains('.', StringComparison.Ordinal)) + { + return normalized; + } + + if (normalized.Length >= MaxInputLength) + { + return normalized; + } + + return $"{normalized}."; + } + + if (token is "00") + { + if (normalized == "0") + { + return "0"; + } + + if (normalized.Length + 2 > MaxInputLength) + { + return normalized; + } + + return normalized + "00"; + } + + if (token.Length == 1 && char.IsDigit(token[0])) + { + if (normalized == "0") + { + return token; + } + + if (normalized.Length >= MaxInputLength) + { + return normalized; + } + + return normalized + token; + } + + return normalized; + } + + public decimal ParseAmountOrZero(string? inputText) + { + var normalized = NormalizeInput(inputText); + if (decimal.TryParse( + normalized, + NumberStyles.AllowLeadingSign | NumberStyles.AllowDecimalPoint, + CultureInfo.InvariantCulture, + out var amount)) + { + return amount; + } + + return 0m; + } + + public string FormatAmount(decimal amount, int maxFractionDigits = 4) + { + var safeDigits = Math.Clamp(maxFractionDigits, 0, 8); + var pattern = safeDigits == 0 ? "0" : $"0.{new string('#', safeDigits)}"; + return amount.ToString(pattern, CultureInfo.InvariantCulture); + } + + private static string NormalizeInput(string? input) + { + if (string.IsNullOrWhiteSpace(input)) + { + return "0"; + } + + var trimmed = input.Trim(); + return trimmed switch + { + "-" or "-0" => "0", + _ => trimmed + }; + } +} diff --git a/LanMountainDesktop/Services/ICalculatorDataService.cs b/LanMountainDesktop/Services/ICalculatorDataService.cs new file mode 100644 index 0000000..5ebd74b --- /dev/null +++ b/LanMountainDesktop/Services/ICalculatorDataService.cs @@ -0,0 +1,17 @@ +namespace LanMountainDesktop.Services; + +public interface ICalculatorDataService +{ + string ApplyInputToken(string currentInput, string token); + + decimal ParseAmountOrZero(string? inputText); + + string FormatAmount(decimal amount, int maxFractionDigits = 4); +} + +public static class CalculatorInputTokens +{ + public const string Clear = "AC"; + public const string Backspace = "BACK"; + public const string DecimalPoint = "."; +} diff --git a/LanMountainDesktop/Services/IRecommendationDataService.cs b/LanMountainDesktop/Services/IRecommendationDataService.cs index da44a96..ffbc917 100644 --- a/LanMountainDesktop/Services/IRecommendationDataService.cs +++ b/LanMountainDesktop/Services/IRecommendationDataService.cs @@ -20,10 +20,20 @@ public sealed record DailyNewsQuery( int? ItemCount = null, bool ForceRefresh = false); +public sealed record BilibiliHotSearchQuery( + string? Locale = null, + int? ItemCount = null, + bool ForceRefresh = false); + public sealed record DailyWordQuery( string? Locale = null, bool ForceRefresh = false); +public sealed record ExchangeRateQuery( + string? BaseCurrency = null, + string? TargetCurrency = null, + bool ForceRefresh = false); + public sealed record RecommendationQueryResult( bool Success, T? Data, @@ -66,10 +76,20 @@ public sealed record RecommendationApiOptions "https://news.cnr.cn/native/gd/rss.xml" ]; + public string BilibiliHotSearchApiTemplate { get; init; } = + "https://api.bilibili.com/x/web-interface/search/square?limit={0}"; + + public string BilibiliSearchDefaultApiUrl { get; init; } = + "https://api.bilibili.com/x/web-interface/search/default"; + + public string BilibiliSearchPageUrl { get; init; } = "https://search.bilibili.com/all"; + 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 ExchangeRateApiTemplate { get; init; } = "https://open.er-api.com/v6/latest/{0}"; + public IReadOnlyList YoudaoDailyWordCandidates { get; init; } = [ "illustrate", @@ -204,6 +224,8 @@ public sealed record RecommendationApiOptions public int DefaultArtworkCandidateCount { get; init; } = 50; public int DefaultDailyNewsCount { get; init; } = 2; + + public int DefaultBilibiliHotSearchCount { get; init; } = 5; } public interface IRecommendationInfoService @@ -220,9 +242,17 @@ public interface IRecommendationInfoService DailyNewsQuery query, CancellationToken cancellationToken = default); + Task> GetBilibiliHotSearchAsync( + BilibiliHotSearchQuery query, + CancellationToken cancellationToken = default); + Task> GetDailyWordAsync( DailyWordQuery query, CancellationToken cancellationToken = default); + Task> GetExchangeRateAsync( + ExchangeRateQuery query, + CancellationToken cancellationToken = default); + void ClearCache(); } diff --git a/LanMountainDesktop/Services/RecommendationDataService.cs b/LanMountainDesktop/Services/RecommendationDataService.cs index d8d9711..523e28e 100644 --- a/LanMountainDesktop/Services/RecommendationDataService.cs +++ b/LanMountainDesktop/Services/RecommendationDataService.cs @@ -34,7 +34,13 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis private sealed record DailyArtworkCacheEntry(DailyArtworkSnapshot Snapshot, DateTimeOffset ExpireAt); private sealed record DailyPoetryCacheEntry(DailyPoetrySnapshot Snapshot, DateTimeOffset ExpireAt); 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 ExchangeRateTableCacheEntry( + string BaseCurrency, + Dictionary Rates, + DateTimeOffset ExpireAt, + DateTimeOffset FetchedAt); private sealed record ArtworkCandidate( string Title, string? Artist, @@ -52,7 +58,10 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis new(StringComparer.OrdinalIgnoreCase); private DailyPoetryCacheEntry? _dailyPoetryCache; private DailyNewsCacheEntry? _dailyNewsCache; + private BilibiliHotSearchCacheEntry? _bilibiliHotSearchCache; private DailyWordCacheEntry? _dailyWordCache; + private readonly Dictionary _exchangeRateCacheByBaseCurrency = + new(StringComparer.OrdinalIgnoreCase); private int _dailyNewsRotationCursor; static RecommendationDataService() @@ -95,7 +104,9 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis _dailyArtworkCacheBySource.Clear(); _dailyPoetryCache = null; _dailyNewsCache = null; + _bilibiliHotSearchCache = null; _dailyWordCache = null; + _exchangeRateCacheByBaseCurrency.Clear(); } } @@ -230,6 +241,53 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis } } + public async Task> GetBilibiliHotSearchAsync( + BilibiliHotSearchQuery query, + CancellationToken cancellationToken = default) + { + var normalizedQuery = query ?? new BilibiliHotSearchQuery(); + var targetCount = normalizedQuery.ItemCount.HasValue + ? Math.Clamp(normalizedQuery.ItemCount.Value, 1, 20) + : Math.Clamp(_options.DefaultBilibiliHotSearchCount, 1, 20); + + if (!normalizedQuery.ForceRefresh && + TryGetBilibiliHotSearchFromCache(out var cached) && + cached.Items.Count >= targetCount) + { + var projectedSnapshot = cached with + { + Items = cached.Items.Take(targetCount).ToArray() + }; + return RecommendationQueryResult.Ok(projectedSnapshot); + } + + try + { + var snapshot = await FetchBilibiliHotSearchSnapshotAsync(targetCount, cancellationToken); + if (snapshot.Items.Count == 0) + { + return RecommendationQueryResult.Fail( + "upstream_empty_result", + "No Bilibili hot search items were returned."); + } + + SetBilibiliHotSearchCache(snapshot); + return RecommendationQueryResult.Ok(snapshot); + } + catch (OperationCanceledException) + { + throw; + } + catch (HttpRequestException ex) + { + return RecommendationQueryResult.Fail("upstream_network_error", ex.Message); + } + catch (Exception ex) + { + return RecommendationQueryResult.Fail("upstream_parse_error", ex.Message); + } + } + public async Task> GetDailyWordAsync( DailyWordQuery query, CancellationToken cancellationToken = default) @@ -282,6 +340,63 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis lastError?.Message ?? "No available daily word from Youdao."); } + public async Task> GetExchangeRateAsync( + ExchangeRateQuery query, + CancellationToken cancellationToken = default) + { + var normalizedQuery = query ?? new ExchangeRateQuery(); + var baseCurrency = NormalizeCurrencyCode(normalizedQuery.BaseCurrency, "USD"); + var targetCurrency = NormalizeCurrencyCode(normalizedQuery.TargetCurrency, "CNY"); + + if (string.Equals(baseCurrency, targetCurrency, StringComparison.OrdinalIgnoreCase)) + { + return RecommendationQueryResult.Ok( + new ExchangeRateSnapshot( + Provider: "open.er-api.com", + Source: "open.er-api.com", + BaseCurrency: baseCurrency, + TargetCurrency: targetCurrency, + Rate: 1m, + FetchedAt: DateTimeOffset.UtcNow)); + } + + if (!normalizedQuery.ForceRefresh && + TryGetExchangeRateTableFromCache(baseCurrency, out var cached) && + cached.Rates.TryGetValue(targetCurrency, out var cachedRate) && + cachedRate > 0) + { + return RecommendationQueryResult.Ok( + new ExchangeRateSnapshot( + Provider: "open.er-api.com", + Source: "open.er-api.com", + BaseCurrency: baseCurrency, + TargetCurrency: targetCurrency, + Rate: cachedRate, + FetchedAt: cached.FetchedAt)); + } + + try + { + var snapshot = await FetchExchangeRateSnapshotAsync( + baseCurrency, + targetCurrency, + cancellationToken); + return RecommendationQueryResult.Ok(snapshot); + } + catch (OperationCanceledException) + { + throw; + } + catch (HttpRequestException ex) + { + return RecommendationQueryResult.Fail("upstream_network_error", ex.Message); + } + catch (Exception ex) + { + return RecommendationQueryResult.Fail("upstream_parse_error", ex.Message); + } + } + private async Task> GetDailyArtworkFromOverseasSourceAsync( string mirrorSource, CancellationToken cancellationToken) @@ -522,6 +637,206 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis } } + private bool TryGetBilibiliHotSearchFromCache(out BilibiliHotSearchSnapshot snapshot) + { + lock (_cacheGate) + { + if (_bilibiliHotSearchCache is not null && _bilibiliHotSearchCache.ExpireAt > DateTimeOffset.UtcNow) + { + snapshot = _bilibiliHotSearchCache.Snapshot; + return true; + } + } + + snapshot = null!; + return false; + } + + private void SetBilibiliHotSearchCache(BilibiliHotSearchSnapshot snapshot) + { + lock (_cacheGate) + { + _bilibiliHotSearchCache = new BilibiliHotSearchCacheEntry( + snapshot, + DateTimeOffset.UtcNow.Add(_options.CacheDuration)); + } + } + + private async Task FetchBilibiliHotSearchSnapshotAsync( + int targetCount, + CancellationToken cancellationToken) + { + var safeCount = Math.Clamp(targetCount, 1, 20); + var requestUrl = string.Format( + CultureInfo.InvariantCulture, + _options.BilibiliHotSearchApiTemplate, + safeCount); + + using var request = new HttpRequestMessage(HttpMethod.Get, requestUrl); + request.Headers.TryAddWithoutValidation("User-Agent", UserAgent); + request.Headers.TryAddWithoutValidation("Accept", "application/json, text/plain, */*"); + + 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; + var responseCode = ReadString(root, "code"); + if (!string.Equals(responseCode, "0", StringComparison.Ordinal)) + { + throw new InvalidOperationException($"Bilibili API returned code={responseCode ?? "unknown"}"); + } + + var listNode = TryGetNode(root, "data", "trending", "list"); + if (!listNode.HasValue || listNode.Value.ValueKind != JsonValueKind.Array) + { + throw new InvalidOperationException("Bilibili hot search list is missing."); + } + + var items = new List(safeCount); + var seenKeywords = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var itemNode in listNode.Value.EnumerateArray()) + { + if (itemNode.ValueKind != JsonValueKind.Object) + { + continue; + } + + var title = NormalizeInlineText(ReadString(itemNode, "show_name") ?? ReadString(itemNode, "keyword")); + if (string.IsNullOrWhiteSpace(title)) + { + continue; + } + + var keyword = NormalizeInlineText(ReadString(itemNode, "keyword") ?? title); + if (string.IsNullOrWhiteSpace(keyword)) + { + keyword = title; + } + + if (!seenKeywords.Add(keyword)) + { + continue; + } + + long? heatScore = null; + var heatScoreText = ReadString(itemNode, "heat_score"); + if (long.TryParse(heatScoreText, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedHeatScore)) + { + heatScore = parsedHeatScore; + } + + var iconUrl = NormalizeHttpUrl(ReadString(itemNode, "icon")); + var targetUrl = ResolveBilibiliHotSearchTargetUrl(ReadString(itemNode, "uri"), keyword); + + items.Add(new BilibiliHotSearchItemSnapshot( + Title: title, + Keyword: keyword, + Url: targetUrl, + HeatScore: heatScore, + HasHotTag: !string.IsNullOrWhiteSpace(iconUrl), + IconUrl: iconUrl)); + + if (items.Count >= safeCount) + { + break; + } + } + + var searchPageUrl = BuildBilibiliSearchPageUrl(_options.BilibiliSearchPageUrl); + var searchPlaceholder = await TryFetchBilibiliSearchPlaceholderAsync(cancellationToken) + ?? items.FirstOrDefault()?.Title + ?? "bilibili hot search"; + + return new BilibiliHotSearchSnapshot( + Provider: "Bilibili", + Source: ReadString(root, "data", "trending", "title") ?? "bilibili热搜", + SearchPlaceholder: searchPlaceholder, + SearchUrl: searchPageUrl, + MoreHotUrl: searchPageUrl, + Items: items, + FetchedAt: DateTimeOffset.UtcNow); + } + + private async Task TryFetchBilibiliSearchPlaceholderAsync(CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(_options.BilibiliSearchDefaultApiUrl)) + { + return null; + } + + try + { + using var request = new HttpRequestMessage(HttpMethod.Get, _options.BilibiliSearchDefaultApiUrl); + request.Headers.TryAddWithoutValidation("User-Agent", UserAgent); + request.Headers.TryAddWithoutValidation("Accept", "application/json, text/plain, */*"); + using var response = await _httpClient.SendAsync(request, cancellationToken); + if (!response.IsSuccessStatusCode) + { + return null; + } + + var responseText = await response.Content.ReadAsStringAsync(cancellationToken); + using var document = JsonDocument.Parse(responseText); + var root = document.RootElement; + if (!string.Equals(ReadString(root, "code"), "0", StringComparison.Ordinal)) + { + return null; + } + + var placeholder = NormalizeInlineText( + ReadString(root, "data", "show_name") ?? + ReadString(root, "data", "name")); + + return string.IsNullOrWhiteSpace(placeholder) + ? null + : placeholder; + } + catch + { + return null; + } + } + + private string ResolveBilibiliHotSearchTargetUrl(string? rawUri, string keyword) + { + var normalizedDirectUrl = NormalizeHttpUrl(rawUri); + if (!string.IsNullOrWhiteSpace(normalizedDirectUrl)) + { + return normalizedDirectUrl; + } + + return BuildBilibiliSearchUrl(_options.BilibiliSearchPageUrl, keyword); + } + + private static string BuildBilibiliSearchPageUrl(string? baseSearchUrl) + { + var fallback = "https://search.bilibili.com/all"; + var candidate = string.IsNullOrWhiteSpace(baseSearchUrl) + ? fallback + : baseSearchUrl.Trim(); + var normalized = NormalizeHttpUrl(candidate); + return string.IsNullOrWhiteSpace(normalized) + ? fallback + : normalized; + } + + private static string BuildBilibiliSearchUrl(string? baseSearchUrl, string keyword) + { + var searchPage = BuildBilibiliSearchPageUrl(baseSearchUrl); + if (string.IsNullOrWhiteSpace(keyword)) + { + return searchPage; + } + + var separator = searchPage.Contains('?', StringComparison.Ordinal) ? "&" : "?"; + return $"{searchPage}{separator}keyword={Uri.EscapeDataString(keyword)}"; + } + private IReadOnlyList SelectDailyNewsItems( IReadOnlyList items, int targetCount, @@ -574,6 +889,148 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis } } + private bool TryGetExchangeRateTableFromCache(string baseCurrency, out ExchangeRateTableCacheEntry entry) + { + lock (_cacheGate) + { + if (_exchangeRateCacheByBaseCurrency.TryGetValue(baseCurrency, out var cached) && + cached.ExpireAt > DateTimeOffset.UtcNow) + { + entry = cached; + return true; + } + + _exchangeRateCacheByBaseCurrency.Remove(baseCurrency); + } + + entry = null!; + return false; + } + + private void SetExchangeRateTableCache(string baseCurrency, ExchangeRateTableCacheEntry entry) + { + lock (_cacheGate) + { + _exchangeRateCacheByBaseCurrency[baseCurrency] = entry; + } + } + + private async Task FetchExchangeRateSnapshotAsync( + string baseCurrency, + string targetCurrency, + CancellationToken cancellationToken) + { + var requestUrl = string.Format( + CultureInfo.InvariantCulture, + _options.ExchangeRateApiTemplate, + Uri.EscapeDataString(baseCurrency)); + using var request = new HttpRequestMessage(HttpMethod.Get, requestUrl); + request.Headers.TryAddWithoutValidation("User-Agent", UserAgent); + request.Headers.TryAddWithoutValidation("Accept", "application/json,text/plain,*/*"); + + 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("rates", out var ratesNode) || ratesNode.ValueKind != JsonValueKind.Object) + { + throw new InvalidOperationException("Exchange rate payload is missing rates."); + } + + var rates = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [baseCurrency] = 1m + }; + foreach (var property in ratesNode.EnumerateObject()) + { + var currency = NormalizeCurrencyCode(property.Name, string.Empty); + if (string.IsNullOrWhiteSpace(currency)) + { + continue; + } + + if (TryReadDecimalValue(property.Value, out var value) && value > 0) + { + rates[currency] = value; + } + } + + if (!rates.TryGetValue(targetCurrency, out var rate) || rate <= 0) + { + throw new InvalidOperationException($"Currency {targetCurrency} is not provided by upstream."); + } + + var fetchedAt = DateTimeOffset.UtcNow; + var cacheEntry = new ExchangeRateTableCacheEntry( + baseCurrency, + rates, + fetchedAt.Add(_options.CacheDuration), + fetchedAt); + SetExchangeRateTableCache(baseCurrency, cacheEntry); + return new ExchangeRateSnapshot( + Provider: "open.er-api.com", + Source: "open.er-api.com", + BaseCurrency: baseCurrency, + TargetCurrency: targetCurrency, + Rate: rate, + FetchedAt: fetchedAt); + } + + private static string NormalizeCurrencyCode(string? value, string fallback) + { + if (string.IsNullOrWhiteSpace(value)) + { + return fallback; + } + + var normalized = value.Trim().ToUpperInvariant(); + if (normalized.Length < 3) + { + return fallback; + } + + return normalized[..3]; + } + + private static bool TryReadDecimalValue(JsonElement element, out decimal value) + { + switch (element.ValueKind) + { + case JsonValueKind.Number: + if (element.TryGetDecimal(out value)) + { + return true; + } + + if (element.TryGetDouble(out var numeric)) + { + value = (decimal)numeric; + return true; + } + + break; + case JsonValueKind.String: + if (decimal.TryParse( + element.GetString(), + NumberStyles.Float, + CultureInfo.InvariantCulture, + out value)) + { + return true; + } + + break; + } + + value = 0m; + return false; + } + private List BuildDailyWordCandidates() { var values = _options.YoudaoDailyWordCandidates ?? []; @@ -1662,6 +2119,28 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis return DateOnly.FromDateTime(now.Date); } + 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 static string Truncate(string? text, int maxLength) { if (string.IsNullOrEmpty(text)) diff --git a/LanMountainDesktop/Views/Components/BilibiliHotSearchWidget.axaml b/LanMountainDesktop/Views/Components/BilibiliHotSearchWidget.axaml new file mode 100644 index 0000000..e6bf139 --- /dev/null +++ b/LanMountainDesktop/Views/Components/BilibiliHotSearchWidget.axaml @@ -0,0 +1,196 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/LanMountainDesktop/Views/Components/BilibiliHotSearchWidget.axaml.cs b/LanMountainDesktop/Views/Components/BilibiliHotSearchWidget.axaml.cs new file mode 100644 index 0000000..2cd867c --- /dev/null +++ b/LanMountainDesktop/Views/Components/BilibiliHotSearchWidget.axaml.cs @@ -0,0 +1,521 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Media; +using Avalonia.Threading; +using LanMountainDesktop.Models; +using LanMountainDesktop.Services; + +namespace LanMountainDesktop.Views.Components; + +public partial class BilibiliHotSearchWidget : 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 const double BaseCellSize = 48d; + private const int BaseWidthCells = 4; + private const int BaseHeightCells = 2; + private const int MaxDisplayItemCount = 4; + + private readonly DispatcherTimer _refreshTimer = new() + { + Interval = TimeSpan.FromMinutes(15) + }; + + private readonly AppSettingsService _settingsService = new(); + private readonly LocalizationService _localizationService = new(); + private readonly List _activeItems = []; + private readonly List _hotItemVisuals = []; + + private IRecommendationInfoService _recommendationService = DefaultRecommendationService; + private CancellationTokenSource? _refreshCts; + private string _languageCode = "zh-CN"; + private string? _searchPageUrl; + private double _currentCellSize = BaseCellSize; + private bool _isAttached; + private bool _isRefreshing; + + private sealed record HotItemVisual( + Border Host, + Grid RowGrid, + TextBlock IndexTextBlock, + TextBlock TitleTextBlock); + + public BilibiliHotSearchWidget() + { + InitializeComponent(); + + SearchEntryTextBlock.FontFamily = MiSansFontFamily; + TopRightTitleTextBlock.FontFamily = MiSansFontFamily; + HotItem1IndexTextBlock.FontFamily = MiSansFontFamily; + HotItem2IndexTextBlock.FontFamily = MiSansFontFamily; + HotItem3IndexTextBlock.FontFamily = MiSansFontFamily; + HotItem4IndexTextBlock.FontFamily = MiSansFontFamily; + HotItem1TextBlock.FontFamily = MiSansFontFamily; + HotItem2TextBlock.FontFamily = MiSansFontFamily; + HotItem3TextBlock.FontFamily = MiSansFontFamily; + HotItem4TextBlock.FontFamily = MiSansFontFamily; + StatusTextBlock.FontFamily = MiSansFontFamily; + + _hotItemVisuals.Add(new HotItemVisual(HotItem1Host, HotItem1Grid, HotItem1IndexTextBlock, HotItem1TextBlock)); + _hotItemVisuals.Add(new HotItemVisual(HotItem2Host, HotItem2Grid, HotItem2IndexTextBlock, HotItem2TextBlock)); + _hotItemVisuals.Add(new HotItemVisual(HotItem3Host, HotItem3Grid, HotItem3IndexTextBlock, HotItem3TextBlock)); + _hotItemVisuals.Add(new HotItemVisual(HotItem4Host, HotItem4Grid, HotItem4IndexTextBlock, HotItem4TextBlock)); + + _refreshTimer.Tick += OnRefreshTimerTick; + AttachedToVisualTree += OnAttachedToVisualTree; + DetachedFromVisualTree += OnDetachedFromVisualTree; + SizeChanged += OnSizeChanged; + + ApplyCellSize(_currentCellSize); + UpdateLanguageCode(); + ApplyLoadingState(); + } + + public void ApplyCellSize(double cellSize) + { + _currentCellSize = Math.Max(1, cellSize); + UpdateAdaptiveLayout(); + } + + public void SetRecommendationInfoService(IRecommendationInfoService recommendationInfoService) + { + _recommendationService = recommendationInfoService ?? DefaultRecommendationService; + if (_isAttached) + { + _ = RefreshHotSearchAsync(forceRefresh: false); + } + } + + public void RefreshFromSettings() + { + _recommendationService.ClearCache(); + if (_isAttached) + { + _ = RefreshHotSearchAsync(forceRefresh: true); + } + } + + private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e) + { + _isAttached = true; + _refreshTimer.Start(); + _ = RefreshHotSearchAsync(forceRefresh: false); + } + + private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e) + { + _isAttached = false; + _refreshTimer.Stop(); + CancelRefreshRequest(); + } + + private void OnSizeChanged(object? sender, SizeChangedEventArgs e) + { + ApplyCellSize(_currentCellSize); + } + + private async void OnRefreshTimerTick(object? sender, EventArgs e) + { + await RefreshHotSearchAsync(forceRefresh: false); + } + + private async Task RefreshHotSearchAsync(bool forceRefresh) + { + if (!_isAttached || _isRefreshing) + { + return; + } + + _isRefreshing = true; + UpdateLanguageCode(); + + var cts = new CancellationTokenSource(); + var previous = Interlocked.Exchange(ref _refreshCts, cts); + previous?.Cancel(); + previous?.Dispose(); + + try + { + var query = new BilibiliHotSearchQuery( + Locale: _languageCode, + ItemCount: MaxDisplayItemCount, + ForceRefresh: forceRefresh); + var result = await _recommendationService.GetBilibiliHotSearchAsync(query, cts.Token); + if (!_isAttached || cts.IsCancellationRequested) + { + return; + } + + if (!result.Success || result.Data is null) + { + ApplyFailedState(); + return; + } + + ApplySnapshot(result.Data); + } + catch (OperationCanceledException) + { + // Ignore canceled requests. + } + catch + { + if (_isAttached && !cts.IsCancellationRequested) + { + ApplyFailedState(); + } + } + finally + { + if (ReferenceEquals(_refreshCts, cts)) + { + _refreshCts = null; + } + + cts.Dispose(); + _isRefreshing = false; + } + } + + private void ApplySnapshot(BilibiliHotSearchSnapshot snapshot) + { + SearchEntryTextBlock.Text = ResolveSearchEntryText(snapshot.SearchPlaceholder); + TopRightTitleTextBlock.Text = L("bilihot.widget.top_right_label", "bilibili热搜"); + + _searchPageUrl = NormalizeHttpUrl(snapshot.SearchUrl) ?? BuildDefaultSearchPageUrl(); + + _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 fallbackText = L("bilihot.widget.fallback_item", "暂无热搜"); + for (var i = 0; i < _hotItemVisuals.Count; i++) + { + var visual = _hotItemVisuals[i]; + visual.Host.IsVisible = true; + visual.IndexTextBlock.Text = (i + 1).ToString(); + visual.TitleTextBlock.Text = i < _activeItems.Count + ? NormalizeCompactText(_activeItems[i].Title) + : fallbackText; + } + + StatusTextBlock.IsVisible = false; + UpdateInteractionState(); + UpdateAdaptiveLayout(); + } + + private void ApplyLoadingState() + { + SearchEntryTextBlock.Text = L("bilihot.widget.search_entry", "搜索"); + TopRightTitleTextBlock.Text = L("bilihot.widget.top_right_label", "bilibili热搜"); + _searchPageUrl = BuildDefaultSearchPageUrl(); + _activeItems.Clear(); + + var loadingText = L("bilihot.widget.loading_item", "加载中..."); + for (var i = 0; i < _hotItemVisuals.Count; i++) + { + var visual = _hotItemVisuals[i]; + visual.Host.IsVisible = true; + visual.IndexTextBlock.Text = (i + 1).ToString(); + visual.TitleTextBlock.Text = loadingText; + } + + StatusTextBlock.Text = L("bilihot.widget.loading", "加载中..."); + StatusTextBlock.IsVisible = true; + UpdateInteractionState(); + UpdateAdaptiveLayout(); + } + + private void ApplyFailedState() + { + SearchEntryTextBlock.Text = L("bilihot.widget.search_entry", "搜索"); + TopRightTitleTextBlock.Text = L("bilihot.widget.top_right_label", "bilibili热搜"); + _searchPageUrl = BuildDefaultSearchPageUrl(); + _activeItems.Clear(); + + var fallbackText = L("bilihot.widget.fallback_item", "暂无热搜"); + for (var i = 0; i < _hotItemVisuals.Count; i++) + { + var visual = _hotItemVisuals[i]; + visual.Host.IsVisible = true; + visual.IndexTextBlock.Text = (i + 1).ToString(); + visual.TitleTextBlock.Text = fallbackText; + } + + StatusTextBlock.Text = L("bilihot.widget.fetch_failed", "热搜获取失败"); + StatusTextBlock.IsVisible = true; + UpdateInteractionState(); + UpdateAdaptiveLayout(); + } + + private string ResolveSearchEntryText(string? placeholder) + { + var compact = NormalizeCompactText(placeholder); + if (string.IsNullOrWhiteSpace(compact)) + { + return L("bilihot.widget.search_entry", "搜索"); + } + + return compact; + } + + private void OnSearchBoxPointerPressed(object? sender, PointerPressedEventArgs e) + { + _ = sender; + if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) + { + return; + } + + TryOpenUrl(_searchPageUrl); + e.Handled = true; + } + + private void OnHotItemPointerPressed(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 void UpdateAdaptiveLayout() + { + var scale = ResolveScale(); + var softScale = Math.Clamp(scale, 0.84, 1.26); + var totalWidth = Bounds.Width > 1 ? Bounds.Width : _currentCellSize * BaseWidthCells; + var totalHeight = Bounds.Height > 1 ? Bounds.Height : _currentCellSize * BaseHeightCells; + + RootBorder.CornerRadius = new CornerRadius(Math.Clamp(34 * softScale, 16, 52)); + RootBorder.Padding = new Thickness(0); + + var horizontalPadding = Math.Clamp(16 * softScale, 8, 24); + var verticalPadding = Math.Clamp(14 * softScale, 7, 20); + CardBorder.CornerRadius = new CornerRadius(Math.Clamp(34 * softScale, 16, 52)); + CardBorder.Padding = new Thickness(horizontalPadding, verticalPadding, horizontalPadding, verticalPadding); + + var innerWidth = Math.Max(120, totalWidth - (horizontalPadding * 2d)); + var innerHeight = Math.Max(72, totalHeight - (verticalPadding * 2d)); + var rowSpacing = Math.Clamp(6 * softScale, 2, 9); + ContentGrid.RowSpacing = rowSpacing; + HeaderGrid.ColumnSpacing = Math.Clamp(10 * softScale, 6, 16); + + var availableRowsHeight = Math.Max(40, innerHeight - rowSpacing * 4d); + var minTopRowHeight = Math.Clamp(20 * softScale, 18, 34); + var topRowHeight = Math.Clamp(availableRowsHeight * 0.27, minTopRowHeight, 52); + var lineRowHeight = Math.Max(10, (availableRowsHeight - topRowHeight) / 4d); + var minLineRowHeight = Math.Clamp(13 * softScale, 11, 24); + if (lineRowHeight < minLineRowHeight) + { + lineRowHeight = minLineRowHeight; + topRowHeight = Math.Max(minTopRowHeight, availableRowsHeight - lineRowHeight * 4d); + lineRowHeight = Math.Max(10, (availableRowsHeight - topRowHeight) / 4d); + } + + if (ContentGrid.RowDefinitions.Count >= 5) + { + ContentGrid.RowDefinitions[0].Height = new GridLength(topRowHeight); + for (var i = 1; i <= 4; i++) + { + ContentGrid.RowDefinitions[i].Height = new GridLength(lineRowHeight); + } + } + + var searchBoxHeight = Math.Clamp(topRowHeight * 0.84, 20, 46); + SearchBoxBorder.Height = searchBoxHeight; + SearchBoxBorder.Width = Math.Clamp(innerWidth * 0.30, 80, 180); + SearchBoxBorder.CornerRadius = new CornerRadius(searchBoxHeight / 2d); + SearchBoxBorder.Padding = new Thickness( + Math.Clamp(searchBoxHeight * 0.24, 5, 10), + 0, + Math.Clamp(searchBoxHeight * 0.24, 5, 10), + 0); + SearchGlyphIcon.FontSize = Math.Clamp(searchBoxHeight * 0.45, 10, 20); + SearchEntryTextBlock.FontSize = Math.Clamp(searchBoxHeight * 0.44, 10, 18); + + TopRightTitleTextBlock.MaxWidth = Math.Max(80, innerWidth - SearchBoxBorder.Width - HeaderGrid.ColumnSpacing); + TopRightTitleTextBlock.FontSize = Math.Clamp(topRowHeight * 0.46, 11, 22); + + var lineColumnGap = Math.Clamp(lineRowHeight * 0.34, 5, 12); + var indexWidth = Math.Clamp(lineRowHeight * 1.02, 16, 28); + var indexFont = Math.Clamp(lineRowHeight * 0.50, 10, 16); + var itemFont = Math.Clamp(lineRowHeight * 0.62, 12, 24); + var rowPadding = Math.Clamp(lineRowHeight * 0.08, 1, 4); + var itemTextWidth = Math.Max(56, innerWidth - indexWidth - lineColumnGap); + + foreach (var visual in _hotItemVisuals) + { + visual.RowGrid.ColumnSpacing = lineColumnGap; + if (visual.RowGrid.ColumnDefinitions.Count > 0) + { + visual.RowGrid.ColumnDefinitions[0].Width = new GridLength(indexWidth, GridUnitType.Pixel); + } + + visual.Host.Padding = new Thickness(0, rowPadding, 0, rowPadding); + visual.IndexTextBlock.FontSize = indexFont; + visual.IndexTextBlock.MaxWidth = indexWidth; + visual.IndexTextBlock.HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Right; + visual.IndexTextBlock.TextAlignment = TextAlignment.Right; + visual.TitleTextBlock.FontSize = itemFont; + visual.TitleTextBlock.MaxWidth = itemTextWidth; + visual.TitleTextBlock.TextAlignment = TextAlignment.Left; + } + + StatusTextBlock.FontSize = Math.Clamp(itemFont, 10, 20); + } + + private void UpdateInteractionState() + { + for (var i = 0; i < _hotItemVisuals.Count; i++) + { + var visual = _hotItemVisuals[i]; + var enabled = i < _activeItems.Count && !string.IsNullOrWhiteSpace(_activeItems[i].Url); + visual.Host.IsHitTestVisible = enabled; + visual.Host.Opacity = enabled ? 1.0 : 0.68; + visual.Host.Cursor = enabled + ? new Cursor(StandardCursorType.Hand) + : new Cursor(StandardCursorType.Arrow); + } + + var searchEnabled = !string.IsNullOrWhiteSpace(_searchPageUrl); + SearchBoxBorder.IsHitTestVisible = searchEnabled; + SearchBoxBorder.Opacity = searchEnabled ? 1.0 : 0.72; + SearchBoxBorder.Cursor = searchEnabled + ? new Cursor(StandardCursorType.Hand) + : new Cursor(StandardCursorType.Arrow); + } + + private void UpdateLanguageCode() + { + try + { + var snapshot = _settingsService.Load(); + _languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode); + } + catch + { + _languageCode = "zh-CN"; + } + } + + private static string NormalizeCompactText(string? text) + { + if (string.IsNullOrWhiteSpace(text)) + { + return string.Empty; + } + + return MultiWhitespaceRegex.Replace(text.Trim(), " "); + } + + private static string BuildDefaultSearchPageUrl() + { + return "https://search.bilibili.com/all"; + } + + 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.72, 2.8); + } + + private string L(string key, string fallback) + { + return _localizationService.GetString(_languageCode, key, fallback); + } + + private void CancelRefreshRequest() + { + var cts = Interlocked.Exchange(ref _refreshCts, null); + if (cts is null) + { + return; + } + + cts.Cancel(); + cts.Dispose(); + } +} diff --git a/LanMountainDesktop/Views/Components/DailySentenceSettingsWindow.axaml b/LanMountainDesktop/Views/Components/DailySentenceSettingsWindow.axaml deleted file mode 100644 index 7023916..0000000 --- a/LanMountainDesktop/Views/Components/DailySentenceSettingsWindow.axaml +++ /dev/null @@ -1,87 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/LanMountainDesktop/Views/Components/DailySentenceSettingsWindow.axaml.cs b/LanMountainDesktop/Views/Components/DailySentenceSettingsWindow.axaml.cs deleted file mode 100644 index 8ab4ce1..0000000 --- a/LanMountainDesktop/Views/Components/DailySentenceSettingsWindow.axaml.cs +++ /dev/null @@ -1,137 +0,0 @@ -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 DailySentenceSettingsWindow : UserControl -{ - private static readonly int[] SupportedIntervals = [5, 10, 40, 60, 720, 1440]; - - private readonly AppSettingsService _appSettingsService = new(); - private readonly LocalizationService _localizationService = new(); - private bool _suppressEvents; - private string _languageCode = "zh-CN"; - - public event EventHandler? SettingsChanged; - - public DailySentenceSettingsWindow() - { - InitializeComponent(); - LoadState(); - ApplyLocalization(); - } - - private void LoadState() - { - var snapshot = _appSettingsService.Load(); - _languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode); - - var enabled = snapshot.DailySentenceAutoRotateEnabled; - var interval = NormalizeInterval(snapshot.DailySentenceAutoRotateIntervalMinutes); - - _suppressEvents = true; - AutoRotateCheckBox.IsChecked = enabled; - SelectInterval(interval); - FrequencyCardBorder.IsVisible = enabled; - _suppressEvents = false; - } - - private void ApplyLocalization() - { - TitleTextBlock.Text = L("daily_sentence.settings.title", "Daily sentence settings"); - DescriptionTextBlock.Text = L("daily_sentence.settings.desc", "Configure auto-rotation and refresh interval."); - AutoRotateLabelTextBlock.Text = L("daily_sentence.settings.auto_rotate_label", "Auto-rotation"); - AutoRotateCheckBox.Content = L("daily_sentence.settings.auto_rotate_enabled", "Enable auto-rotation"); - FrequencyLabelTextBlock.Text = L("daily_sentence.settings.frequency_label", "Rotation interval"); - Frequency5mItem.Content = L("daily_sentence.settings.frequency_5m", "5 min"); - Frequency10mItem.Content = L("daily_sentence.settings.frequency_10m", "10 min"); - Frequency40mItem.Content = L("daily_sentence.settings.frequency_40m", "40 min"); - Frequency1hItem.Content = L("daily_sentence.settings.frequency_1h", "1 hour"); - Frequency12hItem.Content = L("daily_sentence.settings.frequency_12h", "12 hours"); - Frequency24hItem.Content = L("daily_sentence.settings.frequency_24h", "24 hours"); - } - - private void OnAutoRotateChanged(object? sender, RoutedEventArgs e) - { - _ = sender; - _ = e; - if (_suppressEvents) - { - return; - } - - var enabled = AutoRotateCheckBox.IsChecked == true; - FrequencyCardBorder.IsVisible = enabled; - SaveState(); - } - - private void OnFrequencySelectionChanged(object? sender, SelectionChangedEventArgs e) - { - _ = sender; - _ = e; - if (_suppressEvents) - { - return; - } - - SaveState(); - } - - private void SaveState() - { - var snapshot = _appSettingsService.Load(); - snapshot.DailySentenceAutoRotateEnabled = AutoRotateCheckBox.IsChecked == true; - snapshot.DailySentenceAutoRotateIntervalMinutes = GetSelectedInterval(); - _appSettingsService.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 60; - } - - private void SelectInterval(int intervalMinutes) - { - var selected = FrequencyComboBox.Items - .OfType() - .FirstOrDefault(item => - item.Tag is string tagText && - int.TryParse(tagText, out var minutes) && - minutes == intervalMinutes); - FrequencyComboBox.SelectedItem = selected ?? FrequencyComboBox.Items.OfType().FirstOrDefault(); - } - - private static int NormalizeInterval(int minutes) - { - if (minutes <= 0) - { - return 60; - } - - if (SupportedIntervals.Contains(minutes)) - { - return minutes; - } - - return SupportedIntervals - .OrderBy(value => Math.Abs(value - minutes)) - .FirstOrDefault(60); - } - - private string L(string key, string fallback) - { - return _localizationService.GetString(_languageCode, key, fallback); - } -} diff --git a/LanMountainDesktop/Views/Components/DailySentenceWidget.axaml b/LanMountainDesktop/Views/Components/DailySentenceWidget.axaml deleted file mode 100644 index 7539432..0000000 --- a/LanMountainDesktop/Views/Components/DailySentenceWidget.axaml +++ /dev/null @@ -1,128 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/LanMountainDesktop/Views/Components/DailySentenceWidget.axaml.cs b/LanMountainDesktop/Views/Components/DailySentenceWidget.axaml.cs deleted file mode 100644 index 32bec2d..0000000 --- a/LanMountainDesktop/Views/Components/DailySentenceWidget.axaml.cs +++ /dev/null @@ -1,986 +0,0 @@ -using System; -using System.Diagnostics; -using System.Globalization; -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 DailySentenceWidget : 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 FontWeight[] HeadlineWeightCandidates = [FontWeight.Bold, FontWeight.SemiBold, FontWeight.Medium]; - private static readonly FontWeight[] BodyWeightCandidates = [FontWeight.Medium, FontWeight.Normal]; - private static readonly FontWeight[] MetaWeightCandidates = [FontWeight.Medium, FontWeight.Normal, FontWeight.Light]; - private static readonly IRecommendationInfoService DefaultRecommendationService = new RecommendationDataService(); - private static readonly HttpClient ImageHttpClient = new() - { - Timeout = TimeSpan.FromSeconds(8) - }; - - private const string BrowserUserAgent = - "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 = 2; - private static readonly int[] SupportedAutoRotateIntervalsMinutes = [5, 10, 40, 60, 720, 1440]; - - private readonly DispatcherTimer _refreshTimer = new() - { - Interval = TimeSpan.FromHours(6) - }; - - private readonly AppSettingsService _settingsService = new(); - private readonly LocalizationService _localizationService = new(); - - private IRecommendationInfoService _recommendationService = DefaultRecommendationService; - private CancellationTokenSource? _refreshCts; - private Bitmap? _backgroundBitmap; - private string? _currentSourceUrl; - private string _languageCode = "zh-CN"; - private double _currentCellSize = BaseCellSize; - private bool _isAttached; - private bool _isRefreshing; - private bool _autoRotateEnabled = true; - - public DailySentenceWidget() - { - InitializeComponent(); - - DayTextBlock.FontFamily = MiSansFontFamily; - MonthYearTextBlock.FontFamily = MiSansFontFamily; - SentenceTextBlock.FontFamily = MiSansFontFamily; - TranslationTextBlock.FontFamily = MiSansFontFamily; - SourceTextBlock.FontFamily = MiSansFontFamily; - StatusTextBlock.FontFamily = MiSansFontFamily; - - _refreshTimer.Tick += OnRefreshTimerTick; - RefreshButton.Click += OnRefreshButtonClick; - SourceTextBlock.PointerPressed += OnSourceTextBlockPointerPressed; - AttachedToVisualTree += OnAttachedToVisualTree; - DetachedFromVisualTree += OnDetachedFromVisualTree; - SizeChanged += OnSizeChanged; - - ApplyCellSize(_currentCellSize); - UpdateLanguageCode(); - ApplyAutoRotateSettings(); - UpdateDateText(); - ApplyLoadingState(); - UpdateRefreshButtonState(); - } - - public void ApplyCellSize(double cellSize) - { - _currentCellSize = Math.Max(1, cellSize); - UpdateAdaptiveLayout(); - } - - public void SetRecommendationInfoService(IRecommendationInfoService recommendationInfoService) - { - _recommendationService = recommendationInfoService ?? DefaultRecommendationService; - if (_isAttached) - { - _ = RefreshSentenceAsync(forceRefresh: false); - } - } - - public void RefreshFromSettings() - { - _recommendationService.ClearCache(); - ApplyAutoRotateSettings(); - if (_isAttached) - { - _ = RefreshSentenceAsync(forceRefresh: true); - } - } - - private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e) - { - _isAttached = true; - ApplyAutoRotateSettings(); - UpdateRefreshButtonState(); - _ = RefreshSentenceAsync(forceRefresh: false); - } - - private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e) - { - _isAttached = false; - _refreshTimer.Stop(); - CancelRefreshRequest(); - DisposeBackgroundBitmap(); - UpdateRefreshButtonState(); - } - - private void OnSizeChanged(object? sender, SizeChangedEventArgs e) - { - ApplyCellSize(_currentCellSize); - } - - private async void OnRefreshButtonClick(object? sender, RoutedEventArgs e) - { - if (_isRefreshing) - { - return; - } - - await RefreshSentenceAsync(forceRefresh: true); - e.Handled = true; - } - - private async void OnRefreshTimerTick(object? sender, EventArgs e) - { - await RefreshSentenceAsync(forceRefresh: true); - } - - private void OnSourceTextBlockPointerPressed(object? sender, Avalonia.Input.PointerPressedEventArgs e) - { - if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) - { - return; - } - - TryOpenSourceUrl(); - e.Handled = true; - } - - private async Task RefreshSentenceAsync(bool forceRefresh) - { - if (!_isAttached || _isRefreshing) - { - return; - } - - _isRefreshing = true; - UpdateRefreshButtonState(); - UpdateLanguageCode(); - UpdateDateText(); - - var cts = new CancellationTokenSource(); - var previous = Interlocked.Exchange(ref _refreshCts, cts); - previous?.Cancel(); - previous?.Dispose(); - - try - { - var sentenceQuery = new DailyWordQuery( - Locale: _languageCode, - ForceRefresh: forceRefresh); - var sentenceResult = await _recommendationService.GetDailyWordAsync(sentenceQuery, cts.Token); - if (!_isAttached || cts.IsCancellationRequested) - { - return; - } - - if (!sentenceResult.Success || sentenceResult.Data is null) - { - ApplyFailedState(); - } - else - { - ApplySentenceSnapshot(sentenceResult.Data); - } - - var artworkQuery = new DailyArtworkQuery( - Locale: _languageCode, - ForceRefresh: forceRefresh); - var artworkResult = await _recommendationService.GetDailyArtworkAsync(artworkQuery, cts.Token); - if (!_isAttached || cts.IsCancellationRequested) - { - return; - } - - if (artworkResult.Success && artworkResult.Data is not null) - { - await ApplyBackgroundSnapshotAsync(artworkResult.Data, cts.Token); - } - else if (_backgroundBitmap is null) - { - BackgroundImage.Source = null; - } - } - catch (OperationCanceledException) - { - // Ignore canceled requests. - } - catch - { - if (_isAttached && !cts.IsCancellationRequested) - { - ApplyFailedState(); - } - } - finally - { - if (ReferenceEquals(_refreshCts, cts)) - { - _refreshCts = null; - } - - cts.Dispose(); - _isRefreshing = false; - UpdateRefreshButtonState(); - } - } - - private void ApplySentenceSnapshot(DailyWordSnapshot snapshot) - { - var sentence = NormalizeCompactText(snapshot.ExampleSentence); - if (string.IsNullOrWhiteSpace(sentence)) - { - sentence = NormalizeCompactText(snapshot.Meaning); - } - - if (string.IsNullOrWhiteSpace(sentence)) - { - sentence = L("dailysentence.widget.fallback_sentence", "No sentence available."); - } - - var translation = NormalizeCompactText(snapshot.ExampleTranslation); - if (string.IsNullOrWhiteSpace(translation)) - { - translation = NormalizeCompactText(snapshot.Meaning); - } - - if (string.IsNullOrWhiteSpace(translation)) - { - translation = L("dailysentence.widget.fallback_translation", "Tap refresh and try again."); - } - - var sourceWord = NormalizeCompactText(snapshot.Word); - if (string.IsNullOrWhiteSpace(sourceWord)) - { - sourceWord = L("dailysentence.widget.source_default", "Youdao Dictionary"); - } - - SentenceTextBlock.Text = sentence; - TranslationTextBlock.Text = translation; - SourceTextBlock.Text = string.Equals(_languageCode, "zh-CN", StringComparison.OrdinalIgnoreCase) - ? $"有道词典 · {sourceWord}" - : $"Youdao Dictionary · {sourceWord}"; - _currentSourceUrl = NormalizeHttpUrl(snapshot.SourceUrl); - - StatusTextBlock.IsVisible = false; - UpdateSourceInteractionState(); - UpdateAdaptiveLayout(); - } - - private async Task ApplyBackgroundSnapshotAsync(DailyArtworkSnapshot snapshot, CancellationToken cancellationToken) - { - var bitmap = await TryLoadBackgroundBitmapAsync(snapshot.ImageUrl, snapshot.ThumbnailDataUrl, cancellationToken); - if (cancellationToken.IsCancellationRequested || !_isAttached) - { - bitmap?.Dispose(); - return; - } - - SetBackgroundBitmap(bitmap); - } - - private static async Task TryLoadBackgroundBitmapAsync( - string? imageUrl, - string? thumbnailDataUrl, - CancellationToken cancellationToken) - { - var normalizedUrl = NormalizeHttpUrl(imageUrl); - if (!string.IsNullOrWhiteSpace(normalizedUrl)) - { - var remote = await TryDownloadBitmapAsync(normalizedUrl, cancellationToken); - if (remote is not null) - { - return remote; - } - } - - return TryDecodeBitmapFromDataUrl(thumbnailDataUrl); - } - - private static async Task TryDownloadBitmapAsync(string imageUrl, CancellationToken cancellationToken) - { - try - { - using var request = new HttpRequestMessage(HttpMethod.Get, imageUrl); - request.Headers.TryAddWithoutValidation("User-Agent", BrowserUserAgent); - request.Headers.TryAddWithoutValidation("Accept", "image/avif,image/webp,image/apng,image/*,*/*;q=0.8"); - - using var response = await ImageHttpClient.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 static Bitmap? TryDecodeBitmapFromDataUrl(string? dataUrl) - { - if (string.IsNullOrWhiteSpace(dataUrl)) - { - return null; - } - - var trimmed = dataUrl.Trim(); - var markerIndex = trimmed.IndexOf("base64,", StringComparison.OrdinalIgnoreCase); - if (markerIndex < 0 || markerIndex + 7 >= trimmed.Length) - { - return null; - } - - var payload = trimmed[(markerIndex + 7)..]; - try - { - var bytes = Convert.FromBase64String(payload); - return new Bitmap(new MemoryStream(bytes)); - } - catch - { - return null; - } - } - - private void ApplyLoadingState() - { - _currentSourceUrl = null; - SentenceTextBlock.Text = L("dailysentence.widget.loading_sentence", "Loading sentence..."); - TranslationTextBlock.Text = L("dailysentence.widget.loading_translation", "Loading translation..."); - SourceTextBlock.Text = L("dailysentence.widget.loading_source", "Youdao Dictionary"); - StatusTextBlock.Text = L("dailysentence.widget.loading", "Loading..."); - StatusTextBlock.IsVisible = true; - UpdateSourceInteractionState(); - UpdateAdaptiveLayout(); - } - - private void ApplyFailedState() - { - _currentSourceUrl = null; - SentenceTextBlock.Text = L("dailysentence.widget.fallback_sentence", "No sentence available."); - TranslationTextBlock.Text = L("dailysentence.widget.fallback_translation", "Tap refresh and try again."); - SourceTextBlock.Text = L("dailysentence.widget.source_default", "Youdao Dictionary"); - StatusTextBlock.Text = L("dailysentence.widget.fetch_failed", "Sentence fetch failed"); - StatusTextBlock.IsVisible = true; - UpdateSourceInteractionState(); - UpdateAdaptiveLayout(); - } - - private void UpdateAdaptiveLayout() - { - var scale = ResolveScale(); - var totalWidth = Bounds.Width > 1 ? Bounds.Width : _currentCellSize * BaseWidthCells; - var totalHeight = Bounds.Height > 1 ? Bounds.Height : _currentCellSize * BaseHeightCells; - - RootBorder.CornerRadius = new CornerRadius(Math.Clamp(34 * scale, 16, 52)); - ContentGrid.Margin = new Thickness( - Math.Clamp(16 * scale, 8, 28), - Math.Clamp(14 * scale, 7, 24), - Math.Clamp(16 * scale, 8, 28), - Math.Clamp(14 * scale, 7, 24)); - ContentGrid.RowSpacing = Math.Clamp(8 * scale, 4, 12); - HeaderGrid.ColumnSpacing = Math.Clamp(8 * scale, 4, 14); - - var refreshSize = Math.Clamp(42 * scale, 24, 54); - RefreshButton.Width = refreshSize; - RefreshButton.Height = refreshSize; - RefreshButton.CornerRadius = new CornerRadius(refreshSize / 2d); - RefreshIcon.FontSize = Math.Clamp(21 * scale, 12, 28); - - var innerWidth = Math.Max(100, totalWidth - ContentGrid.Margin.Left - ContentGrid.Margin.Right); - var innerHeight = Math.Max(56, totalHeight - ContentGrid.Margin.Top - ContentGrid.Margin.Bottom); - var rowSpacingTotal = ContentGrid.RowSpacing * 2d; - var availableRowsHeight = Math.Max(1, innerHeight - rowSpacingTotal); - - var topRowHeight = Math.Clamp(innerHeight * 0.24, 18, 98); - var bottomRowHeight = Math.Clamp(innerHeight * 0.15, 11, 54); - var minTopRowHeight = Math.Max(Math.Clamp(14 * scale, 10, 28), refreshSize * 0.60); - var minBottomRowHeight = Math.Clamp(10 * scale, 7, 22); - var minMiddleRowHeight = Math.Clamp(22 * scale, 14, 46); - - if (topRowHeight + bottomRowHeight + minMiddleRowHeight > availableRowsHeight) - { - var scaling = availableRowsHeight / Math.Max(1, topRowHeight + bottomRowHeight + minMiddleRowHeight); - topRowHeight = Math.Max(minTopRowHeight, topRowHeight * scaling); - bottomRowHeight = Math.Max(minBottomRowHeight, bottomRowHeight * scaling); - } - - var middleHeight = Math.Max( - minMiddleRowHeight, - availableRowsHeight - topRowHeight - bottomRowHeight); - - if (ContentGrid.RowDefinitions.Count >= 3) - { - ContentGrid.RowDefinitions[0].Height = new GridLength(topRowHeight, GridUnitType.Pixel); - ContentGrid.RowDefinitions[1].Height = new GridLength(middleHeight, GridUnitType.Pixel); - ContentGrid.RowDefinitions[2].Height = new GridLength(bottomRowHeight, GridUnitType.Pixel); - } - - var topTextWidth = Math.Max(76, innerWidth - refreshSize - ContentGrid.RowSpacing); - var dayWidth = Math.Clamp(topTextWidth * 0.18, 20, Math.Max(24, topTextWidth * 0.32)); - var monthYearWidth = Math.Max(48, topTextWidth - dayWidth - Math.Clamp(6 * scale, 2, 12)); - DayTextBlock.MaxWidth = dayWidth; - MonthYearTextBlock.MaxWidth = monthYearWidth; - - var dayLayout = FitAdaptiveTextLayout( - DayTextBlock.Text, - dayWidth, - topRowHeight, - minLines: 1, - maxLines: 1, - minFontSize: Math.Clamp(26 * scale, 12, 44), - maxFontSize: Math.Clamp(72 * scale, 20, 96), - weightCandidates: HeadlineWeightCandidates, - lineHeightFactor: 0.94); - DayTextBlock.FontSize = dayLayout.FontSize; - DayTextBlock.FontWeight = dayLayout.Weight; - DayTextBlock.LineHeight = dayLayout.LineHeight; - - var monthLayout = FitAdaptiveTextLayout( - MonthYearTextBlock.Text, - monthYearWidth, - topRowHeight, - minLines: 1, - maxLines: 1, - minFontSize: Math.Clamp(18 * scale, 9, 32), - maxFontSize: Math.Clamp(44 * scale, 14, 62), - weightCandidates: BodyWeightCandidates, - lineHeightFactor: 1.00); - MonthYearTextBlock.FontSize = monthLayout.FontSize; - MonthYearTextBlock.FontWeight = monthLayout.Weight; - MonthYearTextBlock.LineHeight = monthLayout.LineHeight; - - var sentenceTextDemand = Math.Clamp(NormalizeCompactText(SentenceTextBlock.Text).Length, 12, 360); - var translationTextDemand = Math.Clamp(NormalizeCompactText(TranslationTextBlock.Text).Length, 8, 260); - - var sentenceMinLines = innerHeight >= _currentCellSize * 1.78 ? 2 : 1; - var sentenceMaxLines = innerHeight >= _currentCellSize * 2.7 - ? 4 - : innerHeight >= _currentCellSize * 1.92 - ? 3 - : 2; - var translationMaxLines = innerHeight >= _currentCellSize * 2.35 ? 3 : 2; - - var sentenceMinFont = Math.Clamp(23 * scale, 10, 42); - var translationMinFont = Math.Clamp(16 * scale, 8.5, 30); - var sentenceMinHeight = sentenceMinFont * 1.06 * sentenceMinLines; - var translationMinHeight = translationMinFont * 1.06; - var bodyGap = Math.Clamp(8 * scale, 3, 12); - SentenceStack.Spacing = bodyGap; - var minBodyHeight = sentenceMinHeight + translationMinHeight + bodyGap; - - double sentenceHeight; - double translationHeight; - if (middleHeight <= minBodyHeight + 0.6) - { - var compression = middleHeight / Math.Max(1, minBodyHeight); - sentenceHeight = Math.Max(10, sentenceMinHeight * compression); - translationHeight = Math.Max(8, translationMinHeight * compression); - } - else - { - var extraHeight = middleHeight - minBodyHeight; - var sentenceWeight = sentenceTextDemand + 16d; - var translationWeight = translationTextDemand + 8d; - var totalWeight = Math.Max(1d, sentenceWeight + translationWeight); - sentenceHeight = sentenceMinHeight + extraHeight * (sentenceWeight / totalWeight); - translationHeight = translationMinHeight + extraHeight * (translationWeight / totalWeight); - } - - var sentenceLayout = FitAdaptiveTextLayout( - SentenceTextBlock.Text, - innerWidth, - sentenceHeight, - minLines: sentenceMinLines, - maxLines: sentenceMaxLines, - minFontSize: sentenceMinFont, - maxFontSize: Math.Clamp(58 * scale, 18, 80), - weightCandidates: HeadlineWeightCandidates, - lineHeightFactor: 1.06); - SentenceTextBlock.MaxWidth = innerWidth; - SentenceTextBlock.MaxLines = sentenceLayout.MaxLines; - SentenceTextBlock.FontSize = sentenceLayout.FontSize; - SentenceTextBlock.FontWeight = sentenceLayout.Weight; - SentenceTextBlock.LineHeight = sentenceLayout.LineHeight; - - var translationLayout = FitAdaptiveTextLayout( - TranslationTextBlock.Text, - innerWidth, - translationHeight, - minLines: 1, - maxLines: translationMaxLines, - minFontSize: translationMinFont, - maxFontSize: Math.Clamp(40 * scale, 12, 54), - weightCandidates: BodyWeightCandidates, - lineHeightFactor: 1.06); - TranslationTextBlock.MaxWidth = innerWidth; - TranslationTextBlock.MaxLines = translationLayout.MaxLines; - TranslationTextBlock.FontSize = translationLayout.FontSize; - TranslationTextBlock.FontWeight = translationLayout.Weight; - TranslationTextBlock.LineHeight = translationLayout.LineHeight; - SentenceTextBlock.MinHeight = Math.Max(0, sentenceLayout.LineHeight * Math.Min(sentenceLayout.MaxLines, Math.Max(1, sentenceMinLines))); - TranslationTextBlock.MinHeight = Math.Max(0, translationLayout.LineHeight); - - var sourceLayout = FitAdaptiveTextLayout( - SourceTextBlock.Text, - innerWidth, - bottomRowHeight, - minLines: 1, - maxLines: 1, - minFontSize: Math.Clamp(14 * scale, 8, 26), - maxFontSize: Math.Clamp(30 * scale, 10, 40), - weightCandidates: MetaWeightCandidates, - lineHeightFactor: 1.02); - SourceTextBlock.MaxWidth = innerWidth; - SourceTextBlock.FontSize = sourceLayout.FontSize; - SourceTextBlock.FontWeight = sourceLayout.Weight; - SourceTextBlock.LineHeight = sourceLayout.LineHeight; - - StatusTextBlock.FontSize = Math.Clamp(16 * scale, 9, 24); - } - - private void UpdateRefreshButtonState() - { - RefreshButton.IsEnabled = !_isRefreshing; - RefreshButton.Opacity = _isAttached ? 1.0 : 0.85; - RefreshIcon.Opacity = _isRefreshing ? 0.56 : 1.0; - } - - private void UpdateSourceInteractionState() - { - var enabled = !string.IsNullOrWhiteSpace(_currentSourceUrl); - SourceTextBlock.IsHitTestVisible = enabled; - SourceTextBlock.Cursor = enabled - ? new Cursor(StandardCursorType.Hand) - : new Cursor(StandardCursorType.Arrow); - SourceTextBlock.Opacity = enabled ? 1.0 : 0.86; - } - - private void UpdateLanguageCode() - { - try - { - var snapshot = _settingsService.Load(); - _languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode); - } - catch - { - _languageCode = "zh-CN"; - } - } - - private void ApplyAutoRotateSettings() - { - var enabled = true; - var intervalMinutes = 60; - - try - { - var snapshot = _settingsService.Load(); - enabled = snapshot.DailySentenceAutoRotateEnabled; - intervalMinutes = NormalizeAutoRotateIntervalMinutes(snapshot.DailySentenceAutoRotateIntervalMinutes); - } - catch - { - // Keep fallback defaults. - } - - _autoRotateEnabled = enabled; - _refreshTimer.Interval = TimeSpan.FromMinutes(intervalMinutes); - - if (!_isAttached) - { - return; - } - - if (_autoRotateEnabled) - { - if (!_refreshTimer.IsEnabled) - { - _refreshTimer.Start(); - } - } - else if (_refreshTimer.IsEnabled) - { - _refreshTimer.Stop(); - } - } - - private static int NormalizeAutoRotateIntervalMinutes(int minutes) - { - if (minutes <= 0) - { - return 60; - } - - if (SupportedAutoRotateIntervalsMinutes.Contains(minutes)) - { - return minutes; - } - - return SupportedAutoRotateIntervalsMinutes - .OrderBy(value => Math.Abs(value - minutes)) - .FirstOrDefault(60); - } - - private void UpdateDateText() - { - var now = DateTime.Now; - var culture = ResolveCulture(); - DayTextBlock.Text = now.Day.ToString(CultureInfo.InvariantCulture); - MonthYearTextBlock.Text = now.ToString("MMMM yyyy", culture); - } - - private void SetBackgroundBitmap(Bitmap? bitmap) - { - if (ReferenceEquals(BackgroundImage.Source, _backgroundBitmap)) - { - BackgroundImage.Source = null; - } - - _backgroundBitmap?.Dispose(); - _backgroundBitmap = bitmap; - BackgroundImage.Source = bitmap; - } - - private void DisposeBackgroundBitmap() - { - SetBackgroundBitmap(null); - } - - private void TryOpenSourceUrl() - { - var normalized = NormalizeHttpUrl(_currentSourceUrl); - if (string.IsNullOrWhiteSpace(normalized)) - { - return; - } - - try - { - var startInfo = new ProcessStartInfo - { - FileName = normalized, - UseShellExecute = true - }; - Process.Start(startInfo); - } - catch - { - // Ignore malformed URLs or shell launch failures. - } - } - - private void CancelRefreshRequest() - { - var cts = Interlocked.Exchange(ref _refreshCts, null); - if (cts is null) - { - return; - } - - cts.Cancel(); - cts.Dispose(); - } - - private string L(string key, string fallback) - { - return _localizationService.GetString(_languageCode, key, fallback); - } - - private CultureInfo ResolveCulture() - { - try - { - return CultureInfo.GetCultureInfo(_languageCode); - } - catch - { - return CultureInfo.InvariantCulture; - } - } - - private double ResolveScale() - { - var cellScale = Math.Clamp(_currentCellSize / BaseCellSize, 0.56, 2.0); - var widthScale = Bounds.Width > 1 - ? Math.Clamp(Bounds.Width / Math.Max(1, _currentCellSize * BaseWidthCells), 0.56, 2.0) - : 1; - var heightScale = Bounds.Height > 1 - ? Math.Clamp(Bounds.Height / Math.Max(1, _currentCellSize * BaseHeightCells), 0.56, 2.0) - : 1; - return Math.Clamp(Math.Min(cellScale, Math.Min(widthScale, heightScale)), 0.56, 2.0); - } - - private static string NormalizeCompactText(string? text) - { - if (string.IsNullOrWhiteSpace(text)) - { - return string.Empty; - } - - return MultiWhitespaceRegex.Replace(text.Trim(), " "); - } - - 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 static AdaptiveTextLayout FitAdaptiveTextLayout( - string? text, - double maxWidth, - double maxHeight, - int minLines, - int maxLines, - double minFontSize, - double maxFontSize, - FontWeight[] weightCandidates, - double lineHeightFactor) - { - var content = string.IsNullOrWhiteSpace(text) ? " " : text.Trim(); - var safeMinLines = Math.Max(1, minLines); - var safeMaxLines = Math.Max(safeMinLines, maxLines); - var linesByHeight = ResolveMaxLinesByHeight(maxHeight, minFontSize, lineHeightFactor, safeMinLines, safeMaxLines); - - var candidates = weightCandidates is { Length: > 0 } - ? weightCandidates - : [FontWeight.Normal]; - - AdaptiveTextLayout? best = null; - foreach (var weight in candidates) - { - for (var lineLimit = linesByHeight; lineLimit >= safeMinLines; lineLimit--) - { - var fontSize = FitFontSize( - content, - maxWidth, - maxHeight, - lineLimit, - minFontSize, - maxFontSize, - weight, - lineHeightFactor); - var lineHeight = fontSize * lineHeightFactor; - var measuredSize = MeasureTextSize(content, fontSize, weight, Math.Max(1, maxWidth), lineHeight); - var measuredLineCount = Math.Max(1, (int)Math.Ceiling(measuredSize.Height / Math.Max(1, lineHeight))); - var overflowLines = Math.Max(0, measuredLineCount - lineLimit); - var overflowHeight = Math.Max(0, measuredSize.Height - maxHeight); - var overflowScore = overflowLines * 1000d + overflowHeight; - var fitsCompletely = overflowLines == 0 && overflowHeight <= 0.6; - var candidate = new AdaptiveTextLayout(fontSize, weight, lineLimit, lineHeight, overflowScore, fitsCompletely); - - if (best is null || IsBetterAdaptiveTextCandidate(candidate, best.Value)) - { - best = candidate; - } - } - } - - if (best is not null) - { - return best.Value; - } - - var fallbackFontSize = Math.Max(6, minFontSize); - return new AdaptiveTextLayout( - fallbackFontSize, - FontWeight.Normal, - safeMinLines, - fallbackFontSize * lineHeightFactor, - double.MaxValue, - fitsCompletely: false); - } - - private static bool IsBetterAdaptiveTextCandidate(AdaptiveTextLayout candidate, AdaptiveTextLayout best) - { - if (candidate.FitsCompletely && !best.FitsCompletely) - { - return true; - } - - if (!candidate.FitsCompletely && best.FitsCompletely) - { - return false; - } - - if (candidate.FitsCompletely && best.FitsCompletely) - { - if (candidate.FontSize > best.FontSize + 0.12) - { - return true; - } - - if (Math.Abs(candidate.FontSize - best.FontSize) <= 0.12 && candidate.MaxLines < best.MaxLines) - { - return true; - } - - return false; - } - - if (candidate.OverflowScore < best.OverflowScore - 0.2) - { - return true; - } - - if (Math.Abs(candidate.OverflowScore - best.OverflowScore) <= 0.2 && - candidate.FontSize > best.FontSize + 0.12) - { - return true; - } - - if (Math.Abs(candidate.OverflowScore - best.OverflowScore) <= 0.2 && - Math.Abs(candidate.FontSize - best.FontSize) <= 0.12 && - candidate.MaxLines > best.MaxLines) - { - return true; - } - - return false; - } - - private static int ResolveMaxLinesByHeight( - double maxHeight, - double minFontSize, - double lineHeightFactor, - int minLines, - int maxLines) - { - var safeMinLines = Math.Max(1, minLines); - var safeMaxLines = Math.Max(safeMinLines, maxLines); - var lineHeight = Math.Max(1, Math.Max(6, minFontSize) * lineHeightFactor); - var maxHeightWithTolerance = Math.Max(1, maxHeight + 0.6); - var linesByHeight = (int)Math.Floor(maxHeightWithTolerance / lineHeight); - return Math.Clamp(linesByHeight, safeMinLines, safeMaxLines); - } - - private static double FitFontSize( - string? text, - double maxWidth, - double maxHeight, - int maxLines, - double minFontSize, - double maxFontSize, - FontWeight weight, - double lineHeightFactor) - { - var content = string.IsNullOrWhiteSpace(text) ? " " : text.Trim(); - var min = Math.Max(6, minFontSize); - var max = Math.Max(min, maxFontSize); - var low = min; - var high = max; - var best = min; - - for (var i = 0; i < 18; i++) - { - var candidate = (low + high) / 2d; - var lineHeight = candidate * lineHeightFactor; - var size = MeasureTextSize(content, candidate, weight, Math.Max(1, maxWidth), lineHeight); - var lineCount = Math.Max(1, (int)Math.Ceiling(size.Height / Math.Max(1, lineHeight))); - var fits = size.Height <= maxHeight + 0.6 && lineCount <= Math.Max(1, maxLines); - - if (fits) - { - best = candidate; - low = candidate; - } - else - { - high = candidate; - } - } - - return best; - } - - private static Size MeasureTextSize(string text, double fontSize, FontWeight weight, double maxWidth, double lineHeight) - { - var probe = new TextBlock - { - Text = text, - FontFamily = MiSansFontFamily, - FontSize = fontSize, - FontWeight = weight, - TextWrapping = TextWrapping.Wrap, - LineHeight = lineHeight - }; - - probe.Measure(new Size(Math.Max(1, maxWidth), double.PositiveInfinity)); - return probe.DesiredSize; - } - - private readonly struct AdaptiveTextLayout - { - public AdaptiveTextLayout( - double fontSize, - FontWeight weight, - int maxLines, - double lineHeight, - double overflowScore, - bool fitsCompletely) - { - FontSize = fontSize; - Weight = weight; - MaxLines = Math.Max(1, maxLines); - LineHeight = lineHeight; - OverflowScore = overflowScore; - FitsCompletely = fitsCompletely; - } - - public double FontSize { get; } - - public FontWeight Weight { get; } - - public int MaxLines { get; } - - public double LineHeight { get; } - - public double OverflowScore { get; } - - public bool FitsCompletely { get; } - } -} diff --git a/LanMountainDesktop/Views/Components/DesktopComponentRuntimeRegistry.cs b/LanMountainDesktop/Views/Components/DesktopComponentRuntimeRegistry.cs index b0e5e68..c8d7a12 100644 --- a/LanMountainDesktop/Views/Components/DesktopComponentRuntimeRegistry.cs +++ b/LanMountainDesktop/Views/Components/DesktopComponentRuntimeRegistry.cs @@ -41,7 +41,8 @@ public sealed class DesktopComponentRuntimeDescriptor double cellSize, TimeZoneService timeZoneService, IWeatherInfoService weatherInfoService, - IRecommendationInfoService recommendationInfoService) + IRecommendationInfoService recommendationInfoService, + ICalculatorDataService calculatorDataService) { var control = _controlFactory(); if (control is IDesktopComponentWidget sizedComponent) @@ -64,6 +65,11 @@ public sealed class DesktopComponentRuntimeDescriptor recommendationInfoAwareComponent.SetRecommendationInfoService(recommendationInfoService); } + if (control is ICalculatorInfoAwareComponentWidget calculatorInfoAwareComponent) + { + calculatorInfoAwareComponent.SetCalculatorDataService(calculatorDataService); + } + return control; } @@ -234,16 +240,21 @@ public sealed class DesktopComponentRuntimeRegistry "component.daily_word", () => new DailyWordWidget(), cellSize => Math.Clamp(cellSize * 0.34, 14, 30)), - new DesktopComponentRuntimeRegistration( - BuiltInComponentIds.DesktopDailySentence, - "component.daily_sentence", - () => new DailySentenceWidget(), - cellSize => Math.Clamp(cellSize * 0.34, 14, 30)), new DesktopComponentRuntimeRegistration( BuiltInComponentIds.DesktopCnrDailyNews, "component.cnr_daily_news", () => new CnrDailyNewsWidget(), cellSize => Math.Clamp(cellSize * 0.34, 14, 30)), + new DesktopComponentRuntimeRegistration( + BuiltInComponentIds.DesktopBilibiliHotSearch, + "component.bilibili_hot_search", + () => new BilibiliHotSearchWidget(), + cellSize => Math.Clamp(cellSize * 0.34, 14, 30)), + new DesktopComponentRuntimeRegistration( + BuiltInComponentIds.DesktopExchangeRateCalculator, + "component.exchange_rate_converter", + () => new ExchangeRateCalculatorWidget(), + cellSize => Math.Clamp(cellSize * 0.28, 12, 26)), new DesktopComponentRuntimeRegistration( BuiltInComponentIds.DesktopWhiteboard, "component.whiteboard", diff --git a/LanMountainDesktop/Views/Components/ExchangeRateCalculatorWidget.axaml b/LanMountainDesktop/Views/Components/ExchangeRateCalculatorWidget.axaml new file mode 100644 index 0000000..348a189 --- /dev/null +++ b/LanMountainDesktop/Views/Components/ExchangeRateCalculatorWidget.axaml @@ -0,0 +1,194 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +