Compare commits

...

3 Commits

Author SHA1 Message Date
lincube
5d35e0d21c 0.4.4
bilibili热搜组件
2026-03-06 00:29:40 +08:00
lincube
e917a1e4af 0.4.3fixed 2026-03-05 21:21:03 +08:00
lincube
b8643a2959 0.4.3
新增英语句子组件。优化央广网新闻组件,优化每日单词组件
2026-03-05 20:17:28 +08:00
24 changed files with 2755 additions and 98 deletions

View File

@@ -31,6 +31,8 @@ public static class BuiltInComponentIds
public const string DesktopDailyArtwork = "DesktopDailyArtwork";
public const string DesktopDailyWord = "DesktopDailyWord";
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";

View File

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

View File

@@ -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",
@@ -284,6 +285,8 @@
"component.daily_artwork": "Daily Artwork",
"component.daily_word": "Daily Word",
"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",
@@ -334,6 +337,29 @@
"cnrnews.widget.fetch_failed": "News fetch failed",
"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",
"cnrnews.settings.auto_rotate_enabled": "Enable auto-rotation",
"cnrnews.settings.frequency_label": "Rotation interval",
"cnrnews.settings.frequency_5m": "5 minutes",
"cnrnews.settings.frequency_10m": "10 minutes",
"cnrnews.settings.frequency_40m": "40 minutes",
"cnrnews.settings.frequency_1h": "1 hour",
"cnrnews.settings.frequency_12h": "12 hours",
"cnrnews.settings.frequency_24h": "24 hours",
"artwork.settings.title": "Daily Artwork Settings",
"artwork.settings.desc": "Switch the data source used by Daily Artwork.",
"artwork.settings.source_label": "Mirror Source",

View File

@@ -265,6 +265,7 @@
"component_category.board": "白板",
"component_category.media": "媒体",
"component_category.info": "信息推荐",
"component_category.calculator": "计算器",
"component_category.study": "自习",
"component.date": "日历",
"component.month_calendar": "月历",
@@ -284,6 +285,8 @@
"component.daily_artwork": "每日名画",
"component.daily_word": "每日单词",
"component.cnr_daily_news": "央广网头条",
"component.bilibili_hot_search": "B站热搜",
"component.exchange_rate_converter": "汇率换算",
"component.whiteboard": "竖向小黑板",
"component.blackboard_landscape": "横向小黑板",
"component.browser": "浏览器",
@@ -334,6 +337,29 @@
"cnrnews.widget.fetch_failed": "新闻获取失败",
"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": "自动轮换",
"cnrnews.settings.auto_rotate_enabled": "启用自动轮换",
"cnrnews.settings.frequency_label": "轮换频率",
"cnrnews.settings.frequency_5m": "5 分钟",
"cnrnews.settings.frequency_10m": "10 分钟",
"cnrnews.settings.frequency_40m": "40 分钟",
"cnrnews.settings.frequency_1h": "1 小时",
"cnrnews.settings.frequency_12h": "12 小时",
"cnrnews.settings.frequency_24h": "24 小时",
"artwork.settings.title": "每日图片设置",
"artwork.settings.desc": "切换每日图片的数据源。",
"artwork.settings.source_label": "镜像源",

View File

@@ -98,6 +98,10 @@ public sealed class AppSettingsSnapshot
];
public string WorldClockSecondHandMode { get; set; } = "Tick";
public bool CnrDailyNewsAutoRotateEnabled { get; set; } = true;
public int CnrDailyNewsAutoRotateIntervalMinutes { get; set; } = 60;
public AppSettingsSnapshot Clone()
{
var clone = (AppSettingsSnapshot)MemberwiseClone();

View File

@@ -35,6 +35,23 @@ public sealed record DailyNewsSnapshot(
IReadOnlyList<DailyNewsItemSnapshot> 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<BilibiliHotSearchItemSnapshot> 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);

View File

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

View File

@@ -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 = ".";
}

View File

@@ -17,12 +17,23 @@ public sealed record DailyPoetryQuery(
public sealed record DailyNewsQuery(
string? Locale = null,
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<T>(
bool Success,
T? Data,
@@ -65,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<string> YoudaoDailyWordCandidates { get; init; } =
[
"illustrate",
@@ -203,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
@@ -219,9 +242,17 @@ public interface IRecommendationInfoService
DailyNewsQuery query,
CancellationToken cancellationToken = default);
Task<RecommendationQueryResult<BilibiliHotSearchSnapshot>> GetBilibiliHotSearchAsync(
BilibiliHotSearchQuery query,
CancellationToken cancellationToken = default);
Task<RecommendationQueryResult<DailyWordSnapshot>> GetDailyWordAsync(
DailyWordQuery query,
CancellationToken cancellationToken = default);
Task<RecommendationQueryResult<ExchangeRateSnapshot>> GetExchangeRateAsync(
ExchangeRateQuery query,
CancellationToken cancellationToken = default);
void ClearCache();
}

View File

@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
@@ -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<string, decimal> Rates,
DateTimeOffset ExpireAt,
DateTimeOffset FetchedAt);
private sealed record ArtworkCandidate(
string Title,
string? Artist,
@@ -52,7 +58,11 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
new(StringComparer.OrdinalIgnoreCase);
private DailyPoetryCacheEntry? _dailyPoetryCache;
private DailyNewsCacheEntry? _dailyNewsCache;
private BilibiliHotSearchCacheEntry? _bilibiliHotSearchCache;
private DailyWordCacheEntry? _dailyWordCache;
private readonly Dictionary<string, ExchangeRateTableCacheEntry> _exchangeRateCacheByBaseCurrency =
new(StringComparer.OrdinalIgnoreCase);
private int _dailyNewsRotationCursor;
static RecommendationDataService()
{
@@ -94,7 +104,9 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
_dailyArtworkCacheBySource.Clear();
_dailyPoetryCache = null;
_dailyNewsCache = null;
_bilibiliHotSearchCache = null;
_dailyWordCache = null;
_exchangeRateCacheByBaseCurrency.Clear();
}
}
@@ -181,14 +193,24 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
CancellationToken cancellationToken = default)
{
var normalizedQuery = query ?? new DailyNewsQuery();
if (!normalizedQuery.ForceRefresh && TryGetDailyNewsFromCache(out var cached))
var targetCount = normalizedQuery.ItemCount.HasValue
? Math.Clamp(normalizedQuery.ItemCount.Value, 1, 12)
: Math.Clamp(_options.DefaultDailyNewsCount, 1, 12);
if (!normalizedQuery.ForceRefresh &&
TryGetDailyNewsFromCache(out var cached) &&
cached.Items.Count >= targetCount)
{
return RecommendationQueryResult<DailyNewsSnapshot>.Ok(cached);
var projectedSnapshot = cached with
{
Items = cached.Items.Take(targetCount).ToArray()
};
return RecommendationQueryResult<DailyNewsSnapshot>.Ok(projectedSnapshot);
}
try
{
var items = await FetchCnrDailyNewsItemsAsync(cancellationToken);
var items = await FetchCnrDailyNewsItemsAsync(targetCount, cancellationToken);
if (items.Count == 0)
{
return RecommendationQueryResult<DailyNewsSnapshot>.Fail(
@@ -196,11 +218,10 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
"No CNR news items were returned.");
}
var targetCount = Math.Clamp(_options.DefaultDailyNewsCount, 1, 4);
var snapshot = new DailyNewsSnapshot(
var snapshot = new DailyNewsSnapshot(
Provider: "CNR",
Source: "央广网·头条",
Items: items.Take(targetCount).ToArray(),
Items: SelectDailyNewsItems(items, targetCount, normalizedQuery.ForceRefresh),
FetchedAt: DateTimeOffset.UtcNow);
SetDailyNewsCache(snapshot);
@@ -220,6 +241,53 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
}
}
public async Task<RecommendationQueryResult<BilibiliHotSearchSnapshot>> 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<BilibiliHotSearchSnapshot>.Ok(projectedSnapshot);
}
try
{
var snapshot = await FetchBilibiliHotSearchSnapshotAsync(targetCount, cancellationToken);
if (snapshot.Items.Count == 0)
{
return RecommendationQueryResult<BilibiliHotSearchSnapshot>.Fail(
"upstream_empty_result",
"No Bilibili hot search items were returned.");
}
SetBilibiliHotSearchCache(snapshot);
return RecommendationQueryResult<BilibiliHotSearchSnapshot>.Ok(snapshot);
}
catch (OperationCanceledException)
{
throw;
}
catch (HttpRequestException ex)
{
return RecommendationQueryResult<BilibiliHotSearchSnapshot>.Fail("upstream_network_error", ex.Message);
}
catch (Exception ex)
{
return RecommendationQueryResult<BilibiliHotSearchSnapshot>.Fail("upstream_parse_error", ex.Message);
}
}
public async Task<RecommendationQueryResult<DailyWordSnapshot>> GetDailyWordAsync(
DailyWordQuery query,
CancellationToken cancellationToken = default)
@@ -272,6 +340,63 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
lastError?.Message ?? "No available daily word from Youdao.");
}
public async Task<RecommendationQueryResult<ExchangeRateSnapshot>> 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<ExchangeRateSnapshot>.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<ExchangeRateSnapshot>.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<ExchangeRateSnapshot>.Ok(snapshot);
}
catch (OperationCanceledException)
{
throw;
}
catch (HttpRequestException ex)
{
return RecommendationQueryResult<ExchangeRateSnapshot>.Fail("upstream_network_error", ex.Message);
}
catch (Exception ex)
{
return RecommendationQueryResult<ExchangeRateSnapshot>.Fail("upstream_parse_error", ex.Message);
}
}
private async Task<RecommendationQueryResult<DailyArtworkSnapshot>> GetDailyArtworkFromOverseasSourceAsync(
string mirrorSource,
CancellationToken cancellationToken)
@@ -512,6 +637,233 @@ 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<BilibiliHotSearchSnapshot> 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<BilibiliHotSearchItemSnapshot>(safeCount);
var seenKeywords = new HashSet<string>(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<string?> 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<DailyNewsItemSnapshot> SelectDailyNewsItems(
IReadOnlyList<DailyNewsItemSnapshot> items,
int targetCount,
bool forceRefresh)
{
if (items.Count == 0 || targetCount <= 0)
{
return [];
}
var safeCount = Math.Min(targetCount, items.Count);
if (!forceRefresh || items.Count <= safeCount)
{
return items.Take(safeCount).ToArray();
}
var cursor = Math.Abs(Interlocked.Increment(ref _dailyNewsRotationCursor) - 1);
var startIndex = cursor % items.Count;
var selection = new List<DailyNewsItemSnapshot>(safeCount);
for (var i = 0; i < safeCount; i++)
{
selection.Add(items[(startIndex + i) % items.Count]);
}
return selection;
}
private bool TryGetDailyWordFromCache(out DailyWordSnapshot snapshot)
{
lock (_cacheGate)
@@ -537,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<ExchangeRateSnapshot> 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<string, decimal>(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<string> BuildDailyWordCandidates()
{
var values = _options.YoudaoDailyWordCandidates ?? [];
@@ -714,7 +1208,7 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
return null;
}
return string.Join("", lines
return string.Join("; ", lines
.Where(line => !string.IsNullOrWhiteSpace(line))
.Distinct(StringComparer.OrdinalIgnoreCase)
.Take(3));
@@ -837,7 +1331,9 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
return null;
}
private async Task<List<DailyNewsItemSnapshot>> FetchCnrDailyNewsItemsAsync(CancellationToken cancellationToken)
private async Task<List<DailyNewsItemSnapshot>> FetchCnrDailyNewsItemsAsync(
int requestedItemCount,
CancellationToken cancellationToken)
{
var requestUrl = string.IsNullOrWhiteSpace(_options.CnrDailyNewsListUrl)
? "https://www.cnr.cn/newscenter/native/gd/"
@@ -848,7 +1344,7 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
}
var html = await FetchHtmlWithCnrEncodingAsync(requestUrl, cancellationToken);
var targetCount = Math.Clamp(_options.DefaultDailyNewsCount, 1, 4);
var targetCount = Math.Clamp(requestedItemCount, 1, 12);
var candidateLimit = Math.Max(8, targetCount * 3);
var htmlCandidates = ParseCnrDailyNewsFromListPage(
html,
@@ -1623,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))
@@ -1635,3 +2153,4 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
: $"{text[..maxLength]}...";
}
}

View File

@@ -0,0 +1,196 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:fi="using:FluentIcons.Avalonia"
mc:Ignorable="d"
d:DesignWidth="640"
d:DesignHeight="320"
x:Class="LanMountainDesktop.Views.Components.BilibiliHotSearchWidget">
<Border x:Name="RootBorder"
CornerRadius="34"
Background="Transparent"
ClipToBounds="True"
BorderThickness="0"
Padding="0">
<Grid>
<Border x:Name="CardBorder"
Background="#FCFCFD"
CornerRadius="34"
BorderBrush="Transparent"
BorderThickness="0"
Padding="16,14,16,14">
<Grid x:Name="ContentGrid"
RowDefinitions="Auto,Auto,Auto,Auto,Auto"
RowSpacing="6">
<Grid x:Name="HeaderGrid"
Grid.Row="0"
ColumnDefinitions="Auto,*"
ColumnSpacing="10">
<Border x:Name="SearchBoxBorder"
Height="38"
CornerRadius="19"
Background="#F1F2F4"
BorderBrush="Transparent"
BorderThickness="0"
Padding="10,0"
HorizontalAlignment="Left"
PointerPressed="OnSearchBoxPointerPressed">
<Grid ColumnDefinitions="Auto,Auto"
ColumnSpacing="6"
VerticalAlignment="Center">
<fi:SymbolIcon x:Name="SearchGlyphIcon"
Symbol="Search"
IconVariant="Regular"
Foreground="#7A8088"
FontSize="17"
VerticalAlignment="Center" />
<TextBlock x:Name="SearchEntryTextBlock"
Grid.Column="1"
Text="Search"
Foreground="#7A8088"
FontSize="18"
FontWeight="Medium"
VerticalAlignment="Center"
MaxLines="1"
TextTrimming="CharacterEllipsis" />
</Grid>
</Border>
<TextBlock x:Name="TopRightTitleTextBlock"
Grid.Column="1"
Text="bilibili热搜"
Foreground="#F44C9F"
FontSize="24"
FontWeight="Bold"
HorizontalAlignment="Right"
VerticalAlignment="Center"
MaxLines="1"
TextTrimming="CharacterEllipsis" />
</Grid>
<Border x:Name="HotItem1Host"
Grid.Row="1"
Tag="0"
Background="Transparent"
Padding="0,2"
PointerPressed="OnHotItemPointerPressed">
<Grid x:Name="HotItem1Grid"
ColumnDefinitions="Auto,*"
ColumnSpacing="8">
<TextBlock x:Name="HotItem1IndexTextBlock"
Text="1"
Foreground="#F44C9F"
FontSize="18"
FontWeight="Bold"
VerticalAlignment="Center"
HorizontalAlignment="Left" />
<TextBlock x:Name="HotItem1TextBlock"
Grid.Column="1"
Text="Trending Topic"
Foreground="#202327"
FontSize="28"
FontWeight="SemiBold"
TextTrimming="CharacterEllipsis"
MaxLines="1"
VerticalAlignment="Center" />
</Grid>
</Border>
<Border x:Name="HotItem2Host"
Grid.Row="2"
Tag="1"
Background="Transparent"
Padding="0,2"
PointerPressed="OnHotItemPointerPressed">
<Grid x:Name="HotItem2Grid"
ColumnDefinitions="Auto,*"
ColumnSpacing="8">
<TextBlock x:Name="HotItem2IndexTextBlock"
Text="2"
Foreground="#F44C9F"
FontSize="18"
FontWeight="Bold"
VerticalAlignment="Center"
HorizontalAlignment="Left" />
<TextBlock x:Name="HotItem2TextBlock"
Grid.Column="1"
Text="Trending Topic"
Foreground="#202327"
FontSize="28"
FontWeight="SemiBold"
TextTrimming="CharacterEllipsis"
MaxLines="1"
VerticalAlignment="Center" />
</Grid>
</Border>
<Border x:Name="HotItem3Host"
Grid.Row="3"
Tag="2"
Background="Transparent"
Padding="0,2"
PointerPressed="OnHotItemPointerPressed">
<Grid x:Name="HotItem3Grid"
ColumnDefinitions="Auto,*"
ColumnSpacing="8">
<TextBlock x:Name="HotItem3IndexTextBlock"
Text="3"
Foreground="#F44C9F"
FontSize="18"
FontWeight="Bold"
VerticalAlignment="Center"
HorizontalAlignment="Left" />
<TextBlock x:Name="HotItem3TextBlock"
Grid.Column="1"
Text="Trending Topic"
Foreground="#202327"
FontSize="28"
FontWeight="SemiBold"
TextTrimming="CharacterEllipsis"
MaxLines="1"
VerticalAlignment="Center" />
</Grid>
</Border>
<Border x:Name="HotItem4Host"
Grid.Row="4"
Tag="3"
Background="Transparent"
Padding="0,2"
PointerPressed="OnHotItemPointerPressed">
<Grid x:Name="HotItem4Grid"
ColumnDefinitions="Auto,*"
ColumnSpacing="8">
<TextBlock x:Name="HotItem4IndexTextBlock"
Text="4"
Foreground="#F44C9F"
FontSize="18"
FontWeight="Bold"
VerticalAlignment="Center"
HorizontalAlignment="Left" />
<TextBlock x:Name="HotItem4TextBlock"
Grid.Column="1"
Text="Trending Topic"
Foreground="#202327"
FontSize="28"
FontWeight="SemiBold"
TextTrimming="CharacterEllipsis"
MaxLines="1"
VerticalAlignment="Center" />
</Grid>
</Border>
</Grid>
</Border>
<TextBlock x:Name="StatusTextBlock"
IsVisible="False"
Text="Loading"
Foreground="#6A6F77"
FontSize="16"
HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Grid>
</Border>
</UserControl>

View File

@@ -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<BilibiliHotSearchItemSnapshot> _activeItems = [];
private readonly List<HotItemVisual> _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();
}
}

View File

@@ -0,0 +1,87 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d"
d:DesignWidth="420"
d:DesignHeight="300"
x:Class="LanMountainDesktop.Views.Components.CnrDailyNewsSettingsWindow">
<Border Background="{DynamicResource AdaptiveBackgroundBrush}"
Padding="16">
<Grid RowDefinitions="Auto,Auto,*"
RowSpacing="10">
<TextBlock x:Name="TitleTextBlock"
Text="CNR news settings"
FontSize="18"
FontWeight="SemiBold"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
<TextBlock x:Name="DescriptionTextBlock"
Grid.Row="1"
Text="Configure auto-rotation and refresh interval."
FontSize="12"
TextWrapping="Wrap"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" />
<ScrollViewer Grid.Row="2"
HorizontalScrollBarVisibility="Disabled"
VerticalScrollBarVisibility="Auto">
<StackPanel Spacing="10"
Margin="0,0,6,0">
<Border Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
BorderThickness="1"
CornerRadius="12"
Padding="12">
<StackPanel Spacing="6">
<TextBlock x:Name="AutoRotateLabelTextBlock"
Text="Auto-rotation"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
<CheckBox x:Name="AutoRotateCheckBox"
Content="Enable auto-rotation"
Checked="OnAutoRotateChanged"
Unchecked="OnAutoRotateChanged" />
</StackPanel>
</Border>
<Border x:Name="FrequencyCardBorder"
Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
BorderThickness="1"
CornerRadius="12"
Padding="12"
IsVisible="False">
<StackPanel Spacing="6">
<TextBlock x:Name="FrequencyLabelTextBlock"
Text="Rotation interval"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
<ComboBox x:Name="FrequencyComboBox"
HorizontalAlignment="Stretch"
MinWidth="0"
SelectionChanged="OnFrequencySelectionChanged">
<ComboBoxItem x:Name="Frequency5mItem"
Tag="5"
Content="5 min" />
<ComboBoxItem x:Name="Frequency10mItem"
Tag="10"
Content="10 min" />
<ComboBoxItem x:Name="Frequency40mItem"
Tag="40"
Content="40 min" />
<ComboBoxItem x:Name="Frequency1hItem"
Tag="60"
Content="1 hour" />
<ComboBoxItem x:Name="Frequency12hItem"
Tag="720"
Content="12 hours" />
<ComboBoxItem x:Name="Frequency24hItem"
Tag="1440"
Content="24 hours" />
</ComboBox>
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
</Grid>
</Border>
</UserControl>

View File

@@ -0,0 +1,137 @@
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 CnrDailyNewsSettingsWindow : 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 CnrDailyNewsSettingsWindow()
{
InitializeComponent();
LoadState();
ApplyLocalization();
}
private void LoadState()
{
var snapshot = _appSettingsService.Load();
_languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode);
var enabled = snapshot.CnrDailyNewsAutoRotateEnabled;
var interval = NormalizeInterval(snapshot.CnrDailyNewsAutoRotateIntervalMinutes);
_suppressEvents = true;
AutoRotateCheckBox.IsChecked = enabled;
SelectInterval(interval);
FrequencyCardBorder.IsVisible = enabled;
_suppressEvents = false;
}
private void ApplyLocalization()
{
TitleTextBlock.Text = L("cnrnews.settings.title", "CNR news settings");
DescriptionTextBlock.Text = L("cnrnews.settings.desc", "Configure auto-rotation and refresh interval.");
AutoRotateLabelTextBlock.Text = L("cnrnews.settings.auto_rotate_label", "Auto-rotation");
AutoRotateCheckBox.Content = L("cnrnews.settings.auto_rotate_enabled", "Enable auto-rotation");
FrequencyLabelTextBlock.Text = L("cnrnews.settings.frequency_label", "Rotation interval");
Frequency5mItem.Content = L("cnrnews.settings.frequency_5m", "5 min");
Frequency10mItem.Content = L("cnrnews.settings.frequency_10m", "10 min");
Frequency40mItem.Content = L("cnrnews.settings.frequency_40m", "40 min");
Frequency1hItem.Content = L("cnrnews.settings.frequency_1h", "1 hour");
Frequency12hItem.Content = L("cnrnews.settings.frequency_12h", "12 hours");
Frequency24hItem.Content = L("cnrnews.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.CnrDailyNewsAutoRotateEnabled = AutoRotateCheckBox.IsChecked == true;
snapshot.CnrDailyNewsAutoRotateIntervalMinutes = 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<ComboBoxItem>()
.FirstOrDefault(item =>
item.Tag is string tagText &&
int.TryParse(tagText, out var minutes) &&
minutes == intervalMinutes);
FrequencyComboBox.SelectedItem = selected ?? FrequencyComboBox.Items.OfType<ComboBoxItem>().FirstOrDefault();
}
private static int NormalizeInterval(int minutes)
{
if (minutes <= 0)
{
return 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);
}
}

View File

@@ -2,6 +2,7 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:fi="using:FluentIcons.Avalonia"
mc:Ignorable="d"
d:DesignWidth="640"
d:DesignHeight="320"
@@ -9,17 +10,19 @@
<Border x:Name="RootBorder"
CornerRadius="34"
Background="#D5D5D5"
Background="Transparent"
ClipToBounds="True"
BorderThickness="0"
Padding="16,12,16,12">
Padding="0">
<Grid>
<Border x:Name="CardBorder"
Background="#F9F9F9"
CornerRadius="24"
Background="#FCFCFD"
CornerRadius="34"
BorderBrush="Transparent"
BorderThickness="0"
Padding="16,14,16,14">
<Grid RowDefinitions="Auto,Auto,Auto"
RowSpacing="10">
<Grid RowDefinitions="Auto,Auto,Auto,Auto"
RowSpacing="8">
<Grid Grid.Row="0"
ColumnDefinitions="*,Auto"
ColumnSpacing="10">
@@ -54,12 +57,12 @@
Spacing="4"
HorizontalAlignment="Center"
VerticalAlignment="Center">
<TextBlock x:Name="RefreshGlyphTextBlock"
Text="&#8635;"
Foreground="#52575F"
FontSize="19"
FontWeight="SemiBold"
VerticalAlignment="Center" />
<fi:SymbolIcon x:Name="RefreshGlyphIcon"
Symbol="ArrowClockwise"
IconVariant="Regular"
Foreground="#52575F"
FontSize="19"
VerticalAlignment="Center" />
<TextBlock x:Name="RefreshLabelTextBlock"
Text="&#25442;&#19968;&#25442;"
Foreground="#202327"
@@ -75,25 +78,16 @@
ColumnDefinitions="*,Auto"
ColumnSpacing="12"
PointerPressed="OnNewsItem1PointerPressed">
<Grid ColumnDefinitions="Auto,*"
ColumnSpacing="4"
VerticalAlignment="Center">
<TextBlock x:Name="News1PrefixTextBlock"
Text="&#28909;&#28857; |"
Foreground="#D6272E"
FontSize="25"
FontWeight="SemiBold"
VerticalAlignment="Top" />
<TextBlock x:Name="News1TitleTextBlock"
Grid.Column="1"
Text="Headline"
Foreground="#202327"
FontSize="25"
FontWeight="SemiBold"
TextWrapping="Wrap"
TextTrimming="CharacterEllipsis"
MaxLines="2" />
</Grid>
<TextBlock x:Name="News1TitleTextBlock"
Text="Headline"
Foreground="#202327"
FontSize="21"
FontWeight="SemiBold"
TextWrapping="Wrap"
TextTrimming="CharacterEllipsis"
MaxLines="2"
VerticalAlignment="Top"
LineHeight="24" />
<Border x:Name="News1ImageHost"
Grid.Column="1"
@@ -115,12 +109,13 @@
<TextBlock x:Name="News2TitleTextBlock"
Text="Headline"
Foreground="#202327"
FontSize="25"
FontSize="21"
FontWeight="SemiBold"
TextWrapping="Wrap"
TextTrimming="CharacterEllipsis"
MaxLines="2"
VerticalAlignment="Center" />
VerticalAlignment="Top"
LineHeight="24" />
<Border x:Name="News2ImageHost"
Grid.Column="1"
@@ -133,6 +128,11 @@
Stretch="UniformToFill" />
</Border>
</Grid>
<StackPanel x:Name="ExtraNewsItemsPanel"
Grid.Row="3"
Spacing="6"
IsVisible="False" />
</Grid>
</Border>

View File

@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
@@ -8,6 +9,7 @@ using System.Threading;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Documents;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Media;
@@ -34,6 +36,7 @@ public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget,
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()
{
@@ -43,7 +46,39 @@ public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget,
private readonly AppSettingsService _settingsService = new();
private readonly LocalizationService _localizationService = new();
private readonly Bitmap?[] _newsBitmaps = new Bitmap?[2];
private readonly string?[] _newsUrls = new string?[2];
private readonly List<string?> _newsUrls = [];
private readonly List<ExtraNewsRowVisual> _extraNewsRows = [];
private IReadOnlyList<DailyNewsItemSnapshot> _activeNewsItems = [];
private int _renderedNewsCount = 2;
private sealed class ExtraNewsRowVisual
{
public ExtraNewsRowVisual(
Grid rootGrid,
TextBlock titleTextBlock,
Border imageHost,
Image imageControl,
int newsIndex)
{
RootGrid = rootGrid;
TitleTextBlock = titleTextBlock;
ImageHost = imageHost;
ImageControl = imageControl;
NewsIndex = newsIndex;
}
public Grid RootGrid { get; }
public TextBlock TitleTextBlock { get; }
public Border ImageHost { get; }
public Image ImageControl { get; }
public int NewsIndex { get; }
public Bitmap? Bitmap { get; set; }
}
private IRecommendationInfoService _recommendationService = DefaultRecommendationService;
private CancellationTokenSource? _refreshCts;
@@ -51,6 +86,7 @@ public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget,
private double _currentCellSize = BaseCellSize;
private bool _isAttached;
private bool _isRefreshing;
private bool _autoRotateEnabled = true;
public CnrDailyNewsWidget()
{
@@ -58,9 +94,7 @@ public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget,
BrandPrimaryTextBlock.FontFamily = MiSansFontFamily;
BrandSecondaryTextBlock.FontFamily = MiSansFontFamily;
RefreshGlyphTextBlock.FontFamily = MiSansFontFamily;
RefreshLabelTextBlock.FontFamily = MiSansFontFamily;
News1PrefixTextBlock.FontFamily = MiSansFontFamily;
News1TitleTextBlock.FontFamily = MiSansFontFamily;
News2TitleTextBlock.FontFamily = MiSansFontFamily;
StatusTextBlock.FontFamily = MiSansFontFamily;
@@ -73,6 +107,7 @@ public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget,
ApplyCellSize(_currentCellSize);
UpdateLanguageCode();
ApplyAutoRotateSettings();
ApplyLoadingState();
UpdateRefreshButtonState();
}
@@ -95,6 +130,7 @@ public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget,
public void RefreshFromSettings()
{
_recommendationService.ClearCache();
ApplyAutoRotateSettings();
if (_isAttached)
{
_ = RefreshNewsAsync(forceRefresh: true);
@@ -104,8 +140,8 @@ public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget,
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{
_isAttached = true;
ApplyAutoRotateSettings();
UpdateRefreshButtonState();
_refreshTimer.Start();
_ = RefreshNewsAsync(forceRefresh: false);
}
@@ -115,6 +151,7 @@ public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget,
_refreshTimer.Stop();
CancelRefreshRequest();
DisposeNewsBitmaps();
ClearExtraNewsRows();
UpdateRefreshButtonState();
}
@@ -136,7 +173,7 @@ public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget,
private async void OnRefreshTimerTick(object? sender, EventArgs e)
{
await RefreshNewsAsync(forceRefresh: false);
await RefreshNewsAsync(forceRefresh: true);
}
private void OnNewsItem1PointerPressed(object? sender, PointerPressedEventArgs e)
@@ -161,6 +198,19 @@ public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget,
e.Handled = true;
}
private void OnExtraNewsItemPointerPressed(object? sender, PointerPressedEventArgs e)
{
if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed ||
sender is not Control control ||
control.Tag is not int index)
{
return;
}
TryOpenNewsUrl(index);
e.Handled = true;
}
private async Task RefreshNewsAsync(bool forceRefresh)
{
if (!_isAttached || _isRefreshing)
@@ -181,6 +231,7 @@ public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget,
{
var query = new DailyNewsQuery(
Locale: _languageCode,
ItemCount: ResolveDesiredNewsItemCount(),
ForceRefresh: forceRefresh);
var result = await _recommendationService.GetDailyNewsAsync(query, cts.Token);
if (!_isAttached || cts.IsCancellationRequested)
@@ -225,16 +276,21 @@ public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget,
var items = snapshot.Items is null
? []
: snapshot.Items.Take(2).ToArray();
_activeNewsItems = items;
var item1 = items.Length > 0 ? items[0] : null;
var item2 = items.Length > 1 ? items[1] : null;
News1PrefixTextBlock.IsVisible = item1 is not null;
News1TitleTextBlock.Text = NormalizeCompactText(item1?.Title);
UpdateHotHeadlineText(item1?.Title);
News2TitleTextBlock.Text = NormalizeCompactText(item2?.Title);
_newsUrls[0] = NormalizeHttpUrl(item1?.Url);
_newsUrls[1] = NormalizeHttpUrl(item2?.Url);
_newsUrls.Clear();
foreach (var item in items)
{
_newsUrls.Add(NormalizeHttpUrl(item.Url));
}
RenderExtraNewsRows([]);
UpdateNewsInteractionState();
StatusTextBlock.IsVisible = false;
@@ -259,53 +315,181 @@ public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget,
private void ApplyLoadingState()
{
_newsUrls[0] = null;
_newsUrls[1] = null;
News1PrefixTextBlock.IsVisible = true;
News1TitleTextBlock.Text = L("cnrnews.widget.loading_title", "正在获取新闻热点");
News2TitleTextBlock.Text = L("cnrnews.widget.loading_subtitle", "请稍候");
StatusTextBlock.Text = L("cnrnews.widget.loading", "加载中...");
_activeNewsItems = [];
_newsUrls.Clear();
UpdateHotHeadlineText(L("cnrnews.widget.loading_title", "Loading headlines"));
News2TitleTextBlock.Text = L("cnrnews.widget.loading_subtitle", "Please wait");
StatusTextBlock.Text = L("cnrnews.widget.loading", "Loading...");
StatusTextBlock.IsVisible = true;
SetNewsBitmap(0, null);
SetNewsBitmap(1, null);
RenderExtraNewsRows([]);
UpdateNewsInteractionState();
UpdateAdaptiveLayout();
}
private void ApplyFailedState()
{
_newsUrls[0] = null;
_newsUrls[1] = null;
News1PrefixTextBlock.IsVisible = false;
News1TitleTextBlock.Text = L("cnrnews.widget.fallback_title", "央广网新闻暂不可用");
News2TitleTextBlock.Text = L("cnrnews.widget.fallback_subtitle", "点击右上角稍后重试");
StatusTextBlock.Text = L("cnrnews.widget.fetch_failed", "新闻获取失败");
_activeNewsItems = [];
_newsUrls.Clear();
News1TitleTextBlock.Inlines = null;
News1TitleTextBlock.Text = L("cnrnews.widget.fallback_title", "CNR news is temporarily unavailable");
News2TitleTextBlock.Text = L("cnrnews.widget.fallback_subtitle", "Tap refresh and try again");
StatusTextBlock.Text = L("cnrnews.widget.fetch_failed", "News fetch failed");
StatusTextBlock.IsVisible = true;
SetNewsBitmap(0, null);
SetNewsBitmap(1, null);
RenderExtraNewsRows([]);
UpdateNewsInteractionState();
UpdateAdaptiveLayout();
}
private int ResolveDesiredNewsItemCount()
{
return 2;
}
private void UpdateHotHeadlineText(string? title)
{
var normalizedTitle = NormalizeCompactText(title);
var hotLabel = L("cnrnews.widget.hot_label", "Hot");
if (News1TitleTextBlock.Inlines is null)
{
News1TitleTextBlock.Text = $"{hotLabel} | {normalizedTitle}";
return;
}
News1TitleTextBlock.Inlines.Clear();
News1TitleTextBlock.Inlines.Add(new Run($"{hotLabel} | ")
{
Foreground = new SolidColorBrush(Color.Parse("#D6272E")),
FontWeight = FontWeight.SemiBold
});
News1TitleTextBlock.Inlines.Add(new Run(normalizedTitle)
{
Foreground = new SolidColorBrush(Color.Parse("#202327")),
FontWeight = FontWeight.SemiBold
});
}
private void RenderExtraNewsRows(IReadOnlyList<DailyNewsItemSnapshot> extraItems)
{
ClearExtraNewsRows();
if (extraItems.Count == 0)
{
ExtraNewsItemsPanel.IsVisible = false;
_renderedNewsCount = 2;
return;
}
for (var i = 0; i < extraItems.Count; i++)
{
var item = extraItems[i];
var itemIndex = i + 2;
var rowGrid = new Grid
{
ColumnSpacing = 12,
Tag = itemIndex,
Cursor = new Cursor(StandardCursorType.Hand),
IsHitTestVisible = true
};
rowGrid.ColumnDefinitions.Add(new ColumnDefinition(new GridLength(1, GridUnitType.Star)));
rowGrid.ColumnDefinitions.Add(new ColumnDefinition(GridLength.Auto));
rowGrid.PointerPressed += OnExtraNewsItemPointerPressed;
var textBlock = new TextBlock
{
Text = NormalizeCompactText(item.Title),
Foreground = new SolidColorBrush(Color.Parse("#202327")),
FontFamily = MiSansFontFamily,
FontWeight = FontWeight.SemiBold,
TextWrapping = TextWrapping.Wrap,
TextTrimming = TextTrimming.CharacterEllipsis,
MaxLines = 2,
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Top,
IsHitTestVisible = false
};
var imageHost = new Border
{
Width = 160,
Height = 90,
CornerRadius = new CornerRadius(16),
ClipToBounds = true,
Background = new SolidColorBrush(Color.Parse("#E6E6E6")),
IsHitTestVisible = false
};
var image = new Image
{
Stretch = Stretch.UniformToFill,
IsHitTestVisible = false
};
imageHost.Child = image;
Grid.SetColumn(imageHost, 1);
rowGrid.Children.Add(textBlock);
rowGrid.Children.Add(imageHost);
ExtraNewsItemsPanel.Children.Add(rowGrid);
_extraNewsRows.Add(new ExtraNewsRowVisual(rowGrid, textBlock, imageHost, image, itemIndex));
}
ExtraNewsItemsPanel.IsVisible = true;
_renderedNewsCount = 2 + extraItems.Count;
}
private void ClearExtraNewsRows()
{
foreach (var row in _extraNewsRows)
{
row.RootGrid.PointerPressed -= OnExtraNewsItemPointerPressed;
if (ReferenceEquals(row.ImageControl.Source, row.Bitmap))
{
row.ImageControl.Source = null;
}
row.Bitmap?.Dispose();
row.Bitmap = null;
}
_extraNewsRows.Clear();
ExtraNewsItemsPanel.Children.Clear();
}
private void SetExtraNewsBitmap(int rowIndex, Bitmap? bitmap)
{
if (rowIndex < 0 || rowIndex >= _extraNewsRows.Count)
{
bitmap?.Dispose();
return;
}
var row = _extraNewsRows[rowIndex];
if (ReferenceEquals(row.ImageControl.Source, row.Bitmap))
{
row.ImageControl.Source = null;
}
row.Bitmap?.Dispose();
row.Bitmap = bitmap;
row.ImageControl.Source = bitmap;
}
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));
RootBorder.Padding = new Thickness(
Math.Clamp(16 * scale, 8, 28),
Math.Clamp(12 * scale, 6, 20),
Math.Clamp(16 * scale, 8, 28),
Math.Clamp(12 * scale, 6, 20));
RootBorder.Padding = new Thickness(0);
CardBorder.CornerRadius = new CornerRadius(Math.Clamp(24 * scale, 12, 36));
CardBorder.CornerRadius = new CornerRadius(Math.Clamp(34 * scale, 16, 52));
CardBorder.Padding = new Thickness(
Math.Clamp(16 * scale, 8, 24),
Math.Clamp(14 * scale, 7, 22),
Math.Clamp(16 * scale, 8, 24),
Math.Clamp(14 * scale, 7, 22));
var headlineFont = Math.Clamp(28 * scale, 13, 36);
var headlineFont = Math.Clamp(24 * scale, 12, 34);
BrandPrimaryTextBlock.FontSize = headlineFont;
BrandSecondaryTextBlock.FontSize = headlineFont;
@@ -314,10 +498,10 @@ public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget,
RefreshButton.Height = refreshHeight;
RefreshButton.Width = refreshWidth;
RefreshButton.CornerRadius = new CornerRadius(refreshHeight / 2d);
RefreshGlyphTextBlock.FontSize = Math.Clamp(19 * scale, 11, 26);
RefreshLabelTextBlock.FontSize = Math.Clamp(25 * scale, 12, 32);
RefreshGlyphIcon.FontSize = Math.Clamp(19 * scale, 11, 24);
RefreshLabelTextBlock.FontSize = Math.Clamp(22 * scale, 11, 29);
var imageWidth = Math.Clamp(totalWidth * 0.23, 68, 170);
var imageWidth = Math.Clamp(totalWidth * 0.20, 60, 170);
var imageHeight = Math.Clamp(imageWidth * 0.56, 38, 94);
News1ImageHost.Width = imageWidth;
News1ImageHost.Height = imageHeight;
@@ -332,38 +516,75 @@ public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget,
NewsItem1Grid.ColumnDefinitions[1].Width = new GridLength(imageWidth);
NewsItem2Grid.ColumnDefinitions[1].Width = new GridLength(imageWidth);
var availableTextWidth = Math.Max(72, totalWidth - RootBorder.Padding.Left - RootBorder.Padding.Right - imageWidth - columnGap - Math.Clamp(24 * scale, 12, 36));
var availableTextWidth = Math.Max(
84,
totalWidth - imageWidth - columnGap - Math.Clamp(20 * scale, 10, 32));
News1TitleTextBlock.MaxWidth = availableTextWidth;
News2TitleTextBlock.MaxWidth = availableTextWidth;
var newsFont = Math.Clamp(25 * scale, 11, 32);
News1PrefixTextBlock.FontSize = newsFont;
var newsFont = Math.Clamp(21 * scale, 10.5, 28);
News1TitleTextBlock.FontSize = newsFont;
News2TitleTextBlock.FontSize = newsFont;
var mainNewsLineHeight = newsFont * 1.14;
News1TitleTextBlock.LineHeight = mainNewsLineHeight;
News2TitleTextBlock.LineHeight = mainNewsLineHeight;
var mainNewsMinHeight = mainNewsLineHeight * 2;
News1TitleTextBlock.MinHeight = mainNewsMinHeight;
News2TitleTextBlock.MinHeight = mainNewsMinHeight;
StatusTextBlock.FontSize = Math.Clamp(16 * scale, 9, 24);
News1TitleTextBlock.MaxLines = 2;
News2TitleTextBlock.MaxLines = 2;
var compactLayout = totalHeight < _currentCellSize * 1.7;
News1TitleTextBlock.MaxLines = compactLayout ? 1 : 2;
News2TitleTextBlock.MaxLines = compactLayout ? 1 : 2;
foreach (var row in _extraNewsRows)
{
row.RootGrid.ColumnSpacing = columnGap;
if (row.RootGrid.ColumnDefinitions.Count > 1)
{
row.RootGrid.ColumnDefinitions[1].Width = new GridLength(imageWidth);
}
row.ImageHost.Width = imageWidth;
row.ImageHost.Height = imageHeight;
row.ImageHost.CornerRadius = new CornerRadius(Math.Clamp(16 * scale, 8, 22));
row.TitleTextBlock.MaxWidth = availableTextWidth;
row.TitleTextBlock.FontSize = Math.Clamp(19 * scale, 10, 25);
row.TitleTextBlock.LineHeight = row.TitleTextBlock.FontSize * 1.12;
row.TitleTextBlock.MinHeight = row.TitleTextBlock.LineHeight * 2;
row.TitleTextBlock.MaxLines = 2;
}
ExtraNewsItemsPanel.Spacing = Math.Clamp(6 * scale, 3, 10);
}
private void UpdateRefreshButtonState()
{
RefreshButton.IsEnabled = !_isRefreshing;
RefreshButton.Opacity = _isAttached ? 1.0 : 0.85;
RefreshGlyphTextBlock.Opacity = _isRefreshing ? 0.56 : 1.0;
RefreshGlyphIcon.Opacity = _isRefreshing ? 0.56 : 1.0;
RefreshLabelTextBlock.Opacity = _isRefreshing ? 0.56 : 1.0;
}
private void UpdateNewsInteractionState()
{
var item1Enabled = !string.IsNullOrWhiteSpace(_newsUrls[0]);
var item2Enabled = !string.IsNullOrWhiteSpace(_newsUrls[1]);
var item1Enabled = _newsUrls.Count > 0 && !string.IsNullOrWhiteSpace(_newsUrls[0]);
var item2Enabled = _newsUrls.Count > 1 && !string.IsNullOrWhiteSpace(_newsUrls[1]);
NewsItem1Grid.IsHitTestVisible = item1Enabled;
NewsItem2Grid.IsHitTestVisible = item2Enabled;
NewsItem1Grid.Opacity = item1Enabled ? 1.0 : 0.72;
NewsItem2Grid.Opacity = item2Enabled ? 1.0 : 0.72;
foreach (var row in _extraNewsRows)
{
var index = row.NewsIndex;
var enabled = index >= 0 && index < _newsUrls.Count && !string.IsNullOrWhiteSpace(_newsUrls[index]);
row.RootGrid.IsHitTestVisible = enabled;
row.RootGrid.Opacity = enabled ? 1.0 : 0.72;
row.RootGrid.Cursor = enabled
? new Cursor(StandardCursorType.Hand)
: new Cursor(StandardCursorType.Arrow);
}
}
private static async Task<Bitmap?> TryDownloadBitmapAsync(string? imageUrl, CancellationToken cancellationToken)
@@ -406,7 +627,7 @@ public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget,
private void TryOpenNewsUrl(int index)
{
if (index < 0 || index >= _newsUrls.Length)
if (index < 0 || index >= _newsUrls.Count)
{
return;
}
@@ -493,6 +714,60 @@ public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget,
}
}
private void ApplyAutoRotateSettings()
{
var enabled = true;
var intervalMinutes = 60;
try
{
var snapshot = _settingsService.Load();
enabled = snapshot.CnrDailyNewsAutoRotateEnabled;
intervalMinutes = NormalizeAutoRotateIntervalMinutes(snapshot.CnrDailyNewsAutoRotateIntervalMinutes);
}
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 CancelRefreshRequest()
{
var cts = Interlocked.Exchange(ref _refreshCts, null);
@@ -532,3 +807,4 @@ public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget,
return MultiWhitespaceRegex.Replace(text.Trim(), " ");
}
}

View File

@@ -11,14 +11,16 @@
<Border x:Name="RootBorder"
CornerRadius="34"
Background="#D5D5D5"
Background="Transparent"
ClipToBounds="True"
BorderThickness="0"
Padding="16,12,16,12">
Padding="0">
<Grid>
<Border x:Name="CardBorder"
Background="#FBFAF8"
CornerRadius="24"
Background="#FCFBFA"
CornerRadius="34"
BorderBrush="Transparent"
BorderThickness="0"
Padding="16,14,16,14">
<Grid>
<Grid IsHitTestVisible="False">

View File

@@ -223,13 +223,9 @@ public partial class DailyWordWidget : UserControl, IDesktopComponentWidget, IRe
var totalHeight = Bounds.Height > 1 ? Bounds.Height : _currentCellSize * BaseHeightCells;
RootBorder.CornerRadius = new CornerRadius(Math.Clamp(34 * scale, 16, 52));
RootBorder.Padding = new Thickness(
Math.Clamp(16 * scale, 8, 26),
Math.Clamp(12 * scale, 6, 20),
Math.Clamp(16 * scale, 8, 26),
Math.Clamp(12 * scale, 6, 20));
RootBorder.Padding = new Thickness(0);
CardBorder.CornerRadius = new CornerRadius(Math.Clamp(24 * scale, 12, 36));
CardBorder.CornerRadius = new CornerRadius(Math.Clamp(34 * scale, 16, 52));
CardBorder.Padding = new Thickness(
Math.Clamp(16 * scale, 8, 24),
Math.Clamp(14 * scale, 7, 22),

View File

@@ -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;
}
@@ -239,6 +245,16 @@ public sealed class DesktopComponentRuntimeRegistry
"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",

View File

@@ -0,0 +1,194 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d"
d:DesignWidth="320"
d:DesignHeight="320"
x:Class="LanMountainDesktop.Views.Components.ExchangeRateCalculatorWidget">
<UserControl.Styles>
<Style Selector="Button">
<Setter Property="CornerRadius" Value="16" />
<Setter Property="Background" Value="#F8F9FB" />
<Setter Property="BorderBrush" Value="#00000000" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="FontSize" Value="26" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="Foreground" Value="#111723" />
<Setter Property="Padding" Value="0" />
</Style>
</UserControl.Styles>
<Border x:Name="RootBorder"
CornerRadius="34"
ClipToBounds="True"
Padding="12"
Background="#ECEDEF">
<Viewbox Stretch="Uniform">
<Grid x:Name="LayoutRoot"
Width="304"
Height="304"
RowDefinitions="Auto,Auto,*"
RowSpacing="8">
<Grid Grid.Row="0"
ColumnDefinitions="*,62"
RowDefinitions="Auto,Auto"
RowSpacing="8"
ColumnSpacing="8">
<Border x:Name="FromCurrencyRowBorder"
Grid.Row="0"
Grid.Column="0"
CornerRadius="16"
Background="#F8F9FB"
Padding="12,8"
PointerPressed="OnFromCurrencyRowPointerPressed">
<Grid ColumnDefinitions="Auto,Auto,*"
ColumnSpacing="8">
<StackPanel Orientation="Vertical"
Spacing="1"
VerticalAlignment="Center">
<TextBlock x:Name="FromCurrencyCodeTextBlock"
Text="USD"
FontSize="18"
FontWeight="SemiBold"
Foreground="#121722" />
<TextBlock x:Name="FromCurrencyNameTextBlock"
Text="美元"
FontSize="13"
Foreground="#6C7382" />
</StackPanel>
<TextBlock Grid.Column="1"
Text=">"
FontSize="18"
Foreground="#A3A9B6"
VerticalAlignment="Center" />
<TextBlock x:Name="InputAmountTextBlock"
Grid.Column="2"
Text="100"
FontSize="42"
FontWeight="Bold"
Foreground="#F08D20"
TextAlignment="Right"
VerticalAlignment="Center"
HorizontalAlignment="Right"
MaxLines="1"
TextTrimming="CharacterEllipsis" />
</Grid>
</Border>
<Border x:Name="ToCurrencyRowBorder"
Grid.Row="1"
Grid.Column="0"
CornerRadius="16"
Background="#F8F9FB"
Padding="12,8"
PointerPressed="OnToCurrencyRowPointerPressed">
<Grid ColumnDefinitions="Auto,Auto,*"
ColumnSpacing="8">
<StackPanel Orientation="Vertical"
Spacing="1"
VerticalAlignment="Center">
<TextBlock x:Name="ToCurrencyCodeTextBlock"
Text="CNY"
FontSize="18"
FontWeight="SemiBold"
Foreground="#121722" />
<TextBlock x:Name="ToCurrencyNameTextBlock"
Text="人民币"
FontSize="13"
Foreground="#6C7382" />
</StackPanel>
<TextBlock Grid.Column="1"
Text=">"
FontSize="18"
Foreground="#A3A9B6"
VerticalAlignment="Center" />
<TextBlock x:Name="ConvertedAmountTextBlock"
Grid.Column="2"
Text="0"
FontSize="42"
FontWeight="Bold"
Foreground="#0F1622"
TextAlignment="Right"
VerticalAlignment="Center"
HorizontalAlignment="Right"
MaxLines="1"
TextTrimming="CharacterEllipsis" />
</Grid>
</Border>
<Button x:Name="SwapCurrencyButton"
Grid.Row="0"
Grid.RowSpan="2"
Grid.Column="1"
CornerRadius="16"
Background="#F8F9FB"
BorderBrush="#00000000"
BorderThickness="0"
FontSize="30"
Content="⇅"
Click="OnSwapCurrencyButtonClick" />
</Grid>
<TextBlock x:Name="RateTextBlock"
Grid.Row="1"
Text="1 USD = 0 CNY"
FontSize="14"
Foreground="#646D7D"
Margin="4,0,0,0"
MaxLines="1"
TextTrimming="CharacterEllipsis" />
<Grid Grid.Row="2"
ColumnDefinitions="*,*,*,84"
RowDefinitions="*,*,*,*"
RowSpacing="8"
ColumnSpacing="8">
<Button Grid.Row="0" Grid.Column="0" Content="7" Tag="7" Click="OnInputButtonClick" />
<Button Grid.Row="0" Grid.Column="1" Content="8" Tag="8" Click="OnInputButtonClick" />
<Button Grid.Row="0" Grid.Column="2" Content="9" Tag="9" Click="OnInputButtonClick" />
<Button Grid.Row="1" Grid.Column="0" Content="4" Tag="4" Click="OnInputButtonClick" />
<Button Grid.Row="1" Grid.Column="1" Content="5" Tag="5" Click="OnInputButtonClick" />
<Button Grid.Row="1" Grid.Column="2" Content="6" Tag="6" Click="OnInputButtonClick" />
<Button Grid.Row="2" Grid.Column="0" Content="1" Tag="1" Click="OnInputButtonClick" />
<Button Grid.Row="2" Grid.Column="1" Content="2" Tag="2" Click="OnInputButtonClick" />
<Button Grid.Row="2" Grid.Column="2" Content="3" Tag="3" Click="OnInputButtonClick" />
<Button Grid.Row="3" Grid.Column="0" Content="00" Tag="00" Click="OnInputButtonClick" />
<Button Grid.Row="3" Grid.Column="1" Content="0" Tag="0" Click="OnInputButtonClick" />
<Button Grid.Row="3" Grid.Column="2" Content="." Tag="." Click="OnInputButtonClick" />
<Button x:Name="ClearButton"
Grid.Row="0"
Grid.RowSpan="2"
Grid.Column="3"
Content="AC"
Background="#D9DDE4"
Tag="AC"
Click="OnInputButtonClick" />
<Button x:Name="BackspaceButton"
Grid.Row="2"
Grid.RowSpan="2"
Grid.Column="3"
Content="⌫"
Background="#D9DDE4"
Tag="BACK"
Click="OnInputButtonClick" />
</Grid>
<TextBlock x:Name="StatusTextBlock"
Grid.RowSpan="3"
IsVisible="False"
Text="Loading"
Foreground="#5E6677"
FontSize="15"
HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Grid>
</Viewbox>
</Border>
</UserControl>

View File

@@ -0,0 +1,347 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Media;
using Avalonia.Threading;
using LanMountainDesktop.Services;
namespace LanMountainDesktop.Views.Components;
public partial class ExchangeRateCalculatorWidget : UserControl, IDesktopComponentWidget, IRecommendationInfoAwareComponentWidget, ICalculatorInfoAwareComponentWidget
{
private sealed record CurrencyItem(string Code, string ZhName, string EnName);
private static readonly FontFamily MiSansFontFamily = new("MiSans VF, avares://LanMountainDesktop/Assets/Fonts#MiSans");
private static readonly CurrencyItem[] CurrencyItems =
[
new("USD", "美元", "US Dollar"),
new("CNY", "人民币", "Chinese Yuan"),
new("EUR", "欧元", "Euro"),
new("JPY", "日元", "Japanese Yen"),
new("HKD", "港币", "Hong Kong Dollar"),
new("GBP", "英镑", "British Pound")
];
private static readonly IRecommendationInfoService DefaultRecommendationService = new RecommendationDataService();
private static readonly ICalculatorDataService DefaultCalculatorService = new CalculatorDataService();
private readonly DispatcherTimer _refreshTimer = new()
{
Interval = TimeSpan.FromMinutes(30)
};
private readonly AppSettingsService _settingsService = new();
private readonly LocalizationService _localizationService = new();
private IRecommendationInfoService _recommendationService = DefaultRecommendationService;
private ICalculatorDataService _calculatorDataService = DefaultCalculatorService;
private string _languageCode = "zh-CN";
private string _fromCurrency = "USD";
private string _toCurrency = "CNY";
private string _inputText = "100";
private decimal _currentRate = 0m;
private CancellationTokenSource? _refreshCts;
private double _currentCellSize = 48d;
private bool _isAttached;
private bool _isRefreshing;
public ExchangeRateCalculatorWidget()
{
InitializeComponent();
FromCurrencyCodeTextBlock.FontFamily = MiSansFontFamily;
FromCurrencyNameTextBlock.FontFamily = MiSansFontFamily;
ToCurrencyCodeTextBlock.FontFamily = MiSansFontFamily;
ToCurrencyNameTextBlock.FontFamily = MiSansFontFamily;
InputAmountTextBlock.FontFamily = MiSansFontFamily;
ConvertedAmountTextBlock.FontFamily = MiSansFontFamily;
RateTextBlock.FontFamily = MiSansFontFamily;
StatusTextBlock.FontFamily = MiSansFontFamily;
AttachedToVisualTree += OnAttachedToVisualTree;
DetachedFromVisualTree += OnDetachedFromVisualTree;
SizeChanged += OnSizeChanged;
_refreshTimer.Tick += OnRefreshTimerTick;
ApplyCellSize(_currentCellSize);
UpdateLanguageCode();
UpdateCurrencyLabels();
UpdateAmounts();
ApplyLoadingState();
}
public void ApplyCellSize(double cellSize)
{
_currentCellSize = Math.Max(1, cellSize);
var scale = ResolveScale();
RootBorder.CornerRadius = new CornerRadius(Math.Clamp(34 * scale, 14, 48));
RootBorder.Padding = new Thickness(Math.Clamp(12 * scale, 6, 18));
}
public void SetRecommendationInfoService(IRecommendationInfoService recommendationInfoService)
{
_recommendationService = recommendationInfoService ?? DefaultRecommendationService;
if (_isAttached)
{
_ = RefreshExchangeRateAsync(forceRefresh: false);
}
}
public void SetCalculatorDataService(ICalculatorDataService calculatorDataService)
{
_calculatorDataService = calculatorDataService ?? DefaultCalculatorService;
UpdateAmounts();
}
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{
_isAttached = true;
_refreshTimer.Start();
_ = RefreshExchangeRateAsync(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 RefreshExchangeRateAsync(forceRefresh: false);
}
private async void OnSwapCurrencyButtonClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
{
_ = sender;
var from = _fromCurrency;
_fromCurrency = _toCurrency;
_toCurrency = from;
UpdateCurrencyLabels();
await RefreshExchangeRateAsync(forceRefresh: false);
}
private async void OnFromCurrencyRowPointerPressed(object? sender, PointerPressedEventArgs e)
{
_ = sender;
if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
{
return;
}
_fromCurrency = GetNextCurrencyCode(_fromCurrency, _toCurrency);
UpdateCurrencyLabels();
await RefreshExchangeRateAsync(forceRefresh: false);
e.Handled = true;
}
private async void OnToCurrencyRowPointerPressed(object? sender, PointerPressedEventArgs e)
{
_ = sender;
if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
{
return;
}
_toCurrency = GetNextCurrencyCode(_toCurrency, _fromCurrency);
UpdateCurrencyLabels();
await RefreshExchangeRateAsync(forceRefresh: false);
e.Handled = true;
}
private void OnInputButtonClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
{
if (sender is not Button button || button.Tag is null)
{
return;
}
var token = button.Tag.ToString() ?? string.Empty;
_inputText = _calculatorDataService.ApplyInputToken(_inputText, token);
UpdateAmounts();
e.Handled = true;
}
private async Task RefreshExchangeRateAsync(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 ExchangeRateQuery(
BaseCurrency: _fromCurrency,
TargetCurrency: _toCurrency,
ForceRefresh: forceRefresh);
var result = await _recommendationService.GetExchangeRateAsync(query, cts.Token);
if (!_isAttached || cts.IsCancellationRequested)
{
return;
}
if (!result.Success || result.Data is null)
{
ApplyFailedState();
return;
}
_currentRate = result.Data.Rate;
StatusTextBlock.IsVisible = false;
UpdateAmounts();
}
catch (OperationCanceledException)
{
// Ignore canceled requests.
}
catch
{
if (_isAttached && !cts.IsCancellationRequested)
{
ApplyFailedState();
}
}
finally
{
if (ReferenceEquals(_refreshCts, cts))
{
_refreshCts = null;
}
cts.Dispose();
_isRefreshing = false;
}
}
private void UpdateCurrencyLabels()
{
var from = ResolveCurrency(_fromCurrency);
var to = ResolveCurrency(_toCurrency);
FromCurrencyCodeTextBlock.Text = from.Code;
FromCurrencyNameTextBlock.Text = IsZh() ? from.ZhName : from.EnName;
ToCurrencyCodeTextBlock.Text = to.Code;
ToCurrencyNameTextBlock.Text = IsZh() ? to.ZhName : to.EnName;
}
private void UpdateAmounts()
{
var amount = _calculatorDataService.ParseAmountOrZero(_inputText);
var converted = amount * Math.Max(0m, _currentRate);
InputAmountTextBlock.Text = _inputText;
ConvertedAmountTextBlock.Text = _calculatorDataService.FormatAmount(converted, maxFractionDigits: 4);
RateTextBlock.Text = string.Format(
CultureInfo.InvariantCulture,
"1 {0} = {1} {2}",
_fromCurrency,
_calculatorDataService.FormatAmount(_currentRate, maxFractionDigits: 6),
_toCurrency);
}
private void ApplyLoadingState()
{
StatusTextBlock.Text = L("exchange.widget.loading", "正在加载汇率...");
StatusTextBlock.IsVisible = true;
}
private void ApplyFailedState()
{
StatusTextBlock.Text = L("exchange.widget.fetch_failed", "汇率获取失败");
StatusTextBlock.IsVisible = true;
UpdateAmounts();
}
private void UpdateLanguageCode()
{
try
{
var snapshot = _settingsService.Load();
_languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode);
}
catch
{
_languageCode = "zh-CN";
}
}
private string GetNextCurrencyCode(string current, string avoid)
{
var currentIndex = Array.FindIndex(
CurrencyItems,
item => string.Equals(item.Code, current, StringComparison.OrdinalIgnoreCase));
if (currentIndex < 0)
{
currentIndex = 0;
}
for (var step = 1; step <= CurrencyItems.Length; step++)
{
var next = CurrencyItems[(currentIndex + step) % CurrencyItems.Length].Code;
if (!string.Equals(next, avoid, StringComparison.OrdinalIgnoreCase))
{
return next;
}
}
return current;
}
private static CurrencyItem ResolveCurrency(string code)
{
return CurrencyItems.FirstOrDefault(item =>
string.Equals(item.Code, code, StringComparison.OrdinalIgnoreCase))
?? CurrencyItems[0];
}
private bool IsZh()
{
return string.Equals(_languageCode, "zh-CN", StringComparison.OrdinalIgnoreCase);
}
private string L(string key, string fallback)
{
return _localizationService.GetString(_languageCode, key, fallback);
}
private double ResolveScale()
{
var cellScale = Math.Clamp(_currentCellSize / 48d, 0.72, 1.8);
var widthScale = Bounds.Width > 1 ? Math.Clamp(Bounds.Width / 304d, 0.72, 2.0) : 1;
var heightScale = Bounds.Height > 1 ? Math.Clamp(Bounds.Height / 304d, 0.72, 2.0) : 1;
return Math.Clamp(Math.Min(cellScale, Math.Min(widthScale, heightScale)), 0.72, 1.95);
}
private void CancelRefreshRequest()
{
var cts = Interlocked.Exchange(ref _refreshCts, null);
if (cts is null)
{
return;
}
cts.Cancel();
cts.Dispose();
}
}

View File

@@ -23,6 +23,11 @@ public interface IRecommendationInfoAwareComponentWidget
void SetRecommendationInfoService(IRecommendationInfoService recommendationInfoService);
}
public interface ICalculatorInfoAwareComponentWidget
{
void SetCalculatorDataService(ICalculatorDataService calculatorDataService);
}
public interface IDesktopPageVisibilityAwareComponentWidget
{
void SetDesktopPageContext(bool isOnActivePage, bool isEditMode);

View File

@@ -725,9 +725,16 @@ public partial class MainWindow
return;
}
if (placement.ComponentId == BuiltInComponentIds.DesktopCnrDailyNews)
{
OpenCnrDailyNewsComponentSettings();
return;
}
if (placement.ComponentId == BuiltInComponentIds.DesktopStudyEnvironment)
{
OpenStudyEnvironmentComponentSettings();
return;
}
}
@@ -827,6 +834,22 @@ public partial class MainWindow
ComponentSettingsWindow.Opacity = 1;
}
private void OpenCnrDailyNewsComponentSettings()
{
if (ComponentSettingsWindow is null || ComponentSettingsContentHost is null)
{
return;
}
var settingsContent = new CnrDailyNewsSettingsWindow();
settingsContent.SettingsChanged += OnCnrDailyNewsSettingsChanged;
ComponentSettingsContentHost.Content = settingsContent;
ComponentSettingsWindow.IsVisible = true;
ComponentSettingsWindow.Opacity = 0;
ComponentSettingsWindow.Opacity = 1;
}
private void OnClassScheduleSettingsChanged(object? sender, EventArgs e)
{
if (_selectedDesktopComponentHost is null)
@@ -931,6 +954,30 @@ public partial class MainWindow
PersistSettings();
}
private void OnCnrDailyNewsSettingsChanged(object? sender, EventArgs e)
{
_ = sender;
_ = e;
foreach (var pageGrid in _desktopPageComponentGrids.Values)
{
foreach (var host in pageGrid.Children.OfType<Border>())
{
if (!host.Classes.Contains(DesktopComponentHostClass))
{
continue;
}
if (TryGetContentHost(host)?.Child is CnrDailyNewsWidget widget)
{
widget.RefreshFromSettings();
}
}
}
PersistSettings();
}
private void CloseComponentSettingsWindow()
{
if (ComponentSettingsWindow is null)
@@ -963,6 +1010,11 @@ public partial class MainWindow
worldClockSettingsWindow.SettingsChanged -= OnWorldClockSettingsChanged;
}
if (ComponentSettingsContentHost?.Content is CnrDailyNewsSettingsWindow cnrDailyNewsSettingsWindow)
{
cnrDailyNewsSettingsWindow.SettingsChanged -= OnCnrDailyNewsSettingsChanged;
}
ComponentSettingsWindow.Opacity = 0;
DispatcherTimer.RunOnce(() =>
@@ -1366,6 +1418,30 @@ public partial class MainWindow
new ComponentScaleRule(WidthUnit: 2, HeightUnit: 1, MinScale: 2));
}
if (string.Equals(componentId, BuiltInComponentIds.DesktopCnrDailyNews, StringComparison.OrdinalIgnoreCase))
{
// Keep CNR widget at a 2:1 ratio: 4x2, 6x3, 8x4...
return SnapSpanToScaleRules(
span,
new ComponentScaleRule(WidthUnit: 2, HeightUnit: 1, MinScale: 2));
}
if (string.Equals(componentId, BuiltInComponentIds.DesktopBilibiliHotSearch, StringComparison.OrdinalIgnoreCase))
{
// Keep Bilibili hot search widget at a 2:1 ratio: 4x2, 6x3, 8x4...
return SnapSpanToScaleRules(
span,
new ComponentScaleRule(WidthUnit: 2, HeightUnit: 1, MinScale: 2));
}
if (string.Equals(componentId, BuiltInComponentIds.DesktopExchangeRateCalculator, StringComparison.OrdinalIgnoreCase))
{
// Keep exchange rate converter square with minimum size 4x4.
return SnapSpanToScaleRules(
span,
new ComponentScaleRule(WidthUnit: 1, HeightUnit: 1, MinScale: 4));
}
if (string.Equals(componentId, BuiltInComponentIds.DesktopStudyNoiseCurve, StringComparison.OrdinalIgnoreCase))
{
// Keep noise curve widget in a 2:1 ratio with minimum 4x2.
@@ -1603,7 +1679,8 @@ public partial class MainWindow
_currentDesktopCellSize,
_timeZoneService,
_weatherDataService,
_recommendationInfoService);
_recommendationInfoService,
_calculatorDataService);
component.Classes.Add(DesktopComponentClass);
return component;
}
@@ -2529,6 +2606,11 @@ public partial class MainWindow
return Symbol.Apps;
}
if (string.Equals(categoryId, "Calculator", StringComparison.OrdinalIgnoreCase))
{
return Symbol.Calculator;
}
if (string.Equals(categoryId, "Study", StringComparison.OrdinalIgnoreCase))
{
return Symbol.Apps;
@@ -2569,6 +2651,11 @@ public partial class MainWindow
return L("component_category.info", "Info");
}
if (string.Equals(categoryId, "Calculator", StringComparison.OrdinalIgnoreCase))
{
return L("component_category.calculator", "Calculator");
}
if (string.Equals(categoryId, "Study", StringComparison.OrdinalIgnoreCase))
{
return L("component_category.study", "Study");
@@ -2737,7 +2824,8 @@ public partial class MainWindow
renderCellSize,
_timeZoneService,
_weatherDataService,
_recommendationInfoService);
_recommendationInfoService,
_calculatorDataService);
var previewSurface = new Border
{

View File

@@ -94,6 +94,7 @@ public partial class MainWindow : Window
private readonly GitHubReleaseUpdateService _releaseUpdateService = new("wwiinnddyy", "LanMountainDesktop");
private readonly IWeatherDataService _weatherDataService = new XiaomiWeatherService();
private readonly IRecommendationInfoService _recommendationInfoService = new RecommendationDataService();
private readonly ICalculatorDataService _calculatorDataService = new CalculatorDataService();
private readonly ComponentRegistry _componentRegistry = ComponentRegistry
.CreateDefault()
.RegisterExtensions(