mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-22 00:54:26 +08:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d35e0d21c | ||
|
|
e917a1e4af |
@@ -30,8 +30,9 @@ public static class BuiltInComponentIds
|
|||||||
public const string DesktopDailyPoetry = "DesktopDailyPoetry";
|
public const string DesktopDailyPoetry = "DesktopDailyPoetry";
|
||||||
public const string DesktopDailyArtwork = "DesktopDailyArtwork";
|
public const string DesktopDailyArtwork = "DesktopDailyArtwork";
|
||||||
public const string DesktopDailyWord = "DesktopDailyWord";
|
public const string DesktopDailyWord = "DesktopDailyWord";
|
||||||
public const string DesktopDailySentence = "DesktopDailySentence";
|
|
||||||
public const string DesktopCnrDailyNews = "DesktopCnrDailyNews";
|
public const string DesktopCnrDailyNews = "DesktopCnrDailyNews";
|
||||||
|
public const string DesktopBilibiliHotSearch = "DesktopBilibiliHotSearch";
|
||||||
|
public const string DesktopExchangeRateCalculator = "DesktopExchangeRateCalculator";
|
||||||
public const string DesktopWhiteboard = "DesktopWhiteboard";
|
public const string DesktopWhiteboard = "DesktopWhiteboard";
|
||||||
public const string DesktopBlackboardLandscape = "DesktopBlackboardLandscape";
|
public const string DesktopBlackboardLandscape = "DesktopBlackboardLandscape";
|
||||||
public const string DesktopBrowser = "DesktopBrowser";
|
public const string DesktopBrowser = "DesktopBrowser";
|
||||||
|
|||||||
@@ -234,15 +234,6 @@ public sealed class ComponentRegistry
|
|||||||
MinHeightCells: 2,
|
MinHeightCells: 2,
|
||||||
AllowStatusBarPlacement: false,
|
AllowStatusBarPlacement: false,
|
||||||
AllowDesktopPlacement: true),
|
AllowDesktopPlacement: true),
|
||||||
new DesktopComponentDefinition(
|
|
||||||
BuiltInComponentIds.DesktopDailySentence,
|
|
||||||
"Daily Sentence",
|
|
||||||
"TextQuote",
|
|
||||||
"Info",
|
|
||||||
MinWidthCells: 4,
|
|
||||||
MinHeightCells: 2,
|
|
||||||
AllowStatusBarPlacement: false,
|
|
||||||
AllowDesktopPlacement: true),
|
|
||||||
new DesktopComponentDefinition(
|
new DesktopComponentDefinition(
|
||||||
BuiltInComponentIds.DesktopCnrDailyNews,
|
BuiltInComponentIds.DesktopCnrDailyNews,
|
||||||
"CNR Daily News",
|
"CNR Daily News",
|
||||||
@@ -251,8 +242,25 @@ public sealed class ComponentRegistry
|
|||||||
MinWidthCells: 4,
|
MinWidthCells: 4,
|
||||||
MinHeightCells: 2,
|
MinHeightCells: 2,
|
||||||
AllowStatusBarPlacement: false,
|
AllowStatusBarPlacement: false,
|
||||||
AllowDesktopPlacement: true,
|
AllowDesktopPlacement: true),
|
||||||
ResizeMode: DesktopComponentResizeMode.Free),
|
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(
|
new DesktopComponentDefinition(
|
||||||
BuiltInComponentIds.DesktopWhiteboard,
|
BuiltInComponentIds.DesktopWhiteboard,
|
||||||
"Blackboard Portrait",
|
"Blackboard Portrait",
|
||||||
|
|||||||
@@ -265,6 +265,7 @@
|
|||||||
"component_category.board": "Board",
|
"component_category.board": "Board",
|
||||||
"component_category.media": "Media",
|
"component_category.media": "Media",
|
||||||
"component_category.info": "Info",
|
"component_category.info": "Info",
|
||||||
|
"component_category.calculator": "Calculator",
|
||||||
"component_category.study": "Study",
|
"component_category.study": "Study",
|
||||||
"component.date": "Calendar",
|
"component.date": "Calendar",
|
||||||
"component.month_calendar": "Month Calendar",
|
"component.month_calendar": "Month Calendar",
|
||||||
@@ -283,8 +284,9 @@
|
|||||||
"component.daily_poetry": "Daily Poetry",
|
"component.daily_poetry": "Daily Poetry",
|
||||||
"component.daily_artwork": "Daily Artwork",
|
"component.daily_artwork": "Daily Artwork",
|
||||||
"component.daily_word": "Daily Word",
|
"component.daily_word": "Daily Word",
|
||||||
"component.daily_sentence": "English Sentence",
|
|
||||||
"component.cnr_daily_news": "CNR Headlines",
|
"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.whiteboard": "Blackboard (Portrait)",
|
||||||
"component.blackboard_landscape": "Blackboard (Landscape)",
|
"component.blackboard_landscape": "Blackboard (Landscape)",
|
||||||
"component.browser": "Browser",
|
"component.browser": "Browser",
|
||||||
@@ -329,14 +331,6 @@
|
|||||||
"dailyword.widget.fallback_meaning": "Youdao dictionary is temporarily unavailable.",
|
"dailyword.widget.fallback_meaning": "Youdao dictionary is temporarily unavailable.",
|
||||||
"dailyword.widget.fallback_example": "Tap the refresh button and try again.",
|
"dailyword.widget.fallback_example": "Tap the refresh button and try again.",
|
||||||
"dailyword.widget.fallback_example_translation": "It will retry when network recovers.",
|
"dailyword.widget.fallback_example_translation": "It will retry when network recovers.",
|
||||||
"dailysentence.widget.loading": "Loading...",
|
|
||||||
"dailysentence.widget.loading_sentence": "Fetching daily sentence...",
|
|
||||||
"dailysentence.widget.loading_translation": "Fetching translation...",
|
|
||||||
"dailysentence.widget.loading_source": "Youdao Dictionary",
|
|
||||||
"dailysentence.widget.fetch_failed": "Sentence fetch failed",
|
|
||||||
"dailysentence.widget.fallback_sentence": "Daily sentence is temporarily unavailable.",
|
|
||||||
"dailysentence.widget.fallback_translation": "Tap refresh and try again.",
|
|
||||||
"dailysentence.widget.source_default": "Youdao Dictionary",
|
|
||||||
"cnrnews.widget.loading": "Loading...",
|
"cnrnews.widget.loading": "Loading...",
|
||||||
"cnrnews.widget.loading_title": "Fetching CNR headlines",
|
"cnrnews.widget.loading_title": "Fetching CNR headlines",
|
||||||
"cnrnews.widget.loading_subtitle": "Please wait",
|
"cnrnews.widget.loading_subtitle": "Please wait",
|
||||||
@@ -344,6 +338,28 @@
|
|||||||
"cnrnews.widget.fallback_title": "CNR news is temporarily unavailable",
|
"cnrnews.widget.fallback_title": "CNR news is temporarily unavailable",
|
||||||
"cnrnews.widget.fallback_subtitle": "Tap refresh and try again",
|
"cnrnews.widget.fallback_subtitle": "Tap refresh and try again",
|
||||||
"cnrnews.widget.hot_label": "Hot",
|
"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.title": "Daily Artwork Settings",
|
||||||
"artwork.settings.desc": "Switch the data source used by Daily Artwork.",
|
"artwork.settings.desc": "Switch the data source used by Daily Artwork.",
|
||||||
"artwork.settings.source_label": "Mirror Source",
|
"artwork.settings.source_label": "Mirror Source",
|
||||||
|
|||||||
@@ -265,6 +265,7 @@
|
|||||||
"component_category.board": "白板",
|
"component_category.board": "白板",
|
||||||
"component_category.media": "媒体",
|
"component_category.media": "媒体",
|
||||||
"component_category.info": "信息推荐",
|
"component_category.info": "信息推荐",
|
||||||
|
"component_category.calculator": "计算器",
|
||||||
"component_category.study": "自习",
|
"component_category.study": "自习",
|
||||||
"component.date": "日历",
|
"component.date": "日历",
|
||||||
"component.month_calendar": "月历",
|
"component.month_calendar": "月历",
|
||||||
@@ -283,8 +284,9 @@
|
|||||||
"component.daily_poetry": "每日诗词",
|
"component.daily_poetry": "每日诗词",
|
||||||
"component.daily_artwork": "每日名画",
|
"component.daily_artwork": "每日名画",
|
||||||
"component.daily_word": "每日单词",
|
"component.daily_word": "每日单词",
|
||||||
"component.daily_sentence": "英语句子",
|
|
||||||
"component.cnr_daily_news": "央广网头条",
|
"component.cnr_daily_news": "央广网头条",
|
||||||
|
"component.bilibili_hot_search": "B站热搜",
|
||||||
|
"component.exchange_rate_converter": "汇率换算",
|
||||||
"component.whiteboard": "竖向小黑板",
|
"component.whiteboard": "竖向小黑板",
|
||||||
"component.blackboard_landscape": "横向小黑板",
|
"component.blackboard_landscape": "横向小黑板",
|
||||||
"component.browser": "浏览器",
|
"component.browser": "浏览器",
|
||||||
@@ -329,14 +331,6 @@
|
|||||||
"dailyword.widget.fallback_meaning": "有道词典暂不可用",
|
"dailyword.widget.fallback_meaning": "有道词典暂不可用",
|
||||||
"dailyword.widget.fallback_example": "请点击右上角刷新重试",
|
"dailyword.widget.fallback_example": "请点击右上角刷新重试",
|
||||||
"dailyword.widget.fallback_example_translation": "网络恢复后将自动更新",
|
"dailyword.widget.fallback_example_translation": "网络恢复后将自动更新",
|
||||||
"dailysentence.widget.loading": "加载中...",
|
|
||||||
"dailysentence.widget.loading_sentence": "正在获取英语句子",
|
|
||||||
"dailysentence.widget.loading_translation": "正在获取句子译文",
|
|
||||||
"dailysentence.widget.loading_source": "有道词典",
|
|
||||||
"dailysentence.widget.fetch_failed": "英语句子获取失败",
|
|
||||||
"dailysentence.widget.fallback_sentence": "今日英语句子暂不可用",
|
|
||||||
"dailysentence.widget.fallback_translation": "请点击右上角刷新重试",
|
|
||||||
"dailysentence.widget.source_default": "有道词典",
|
|
||||||
"cnrnews.widget.loading": "加载中...",
|
"cnrnews.widget.loading": "加载中...",
|
||||||
"cnrnews.widget.loading_title": "正在获取新闻热点",
|
"cnrnews.widget.loading_title": "正在获取新闻热点",
|
||||||
"cnrnews.widget.loading_subtitle": "请稍候",
|
"cnrnews.widget.loading_subtitle": "请稍候",
|
||||||
@@ -344,6 +338,28 @@
|
|||||||
"cnrnews.widget.fallback_title": "央广网新闻暂不可用",
|
"cnrnews.widget.fallback_title": "央广网新闻暂不可用",
|
||||||
"cnrnews.widget.fallback_subtitle": "点击右上角稍后重试",
|
"cnrnews.widget.fallback_subtitle": "点击右上角稍后重试",
|
||||||
"cnrnews.widget.hot_label": "热点",
|
"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.title": "每日图片设置",
|
||||||
"artwork.settings.desc": "切换每日图片的数据源。",
|
"artwork.settings.desc": "切换每日图片的数据源。",
|
||||||
"artwork.settings.source_label": "镜像源",
|
"artwork.settings.source_label": "镜像源",
|
||||||
|
|||||||
@@ -98,6 +98,10 @@ public sealed class AppSettingsSnapshot
|
|||||||
];
|
];
|
||||||
public string WorldClockSecondHandMode { get; set; } = "Tick";
|
public string WorldClockSecondHandMode { get; set; } = "Tick";
|
||||||
|
|
||||||
|
public bool CnrDailyNewsAutoRotateEnabled { get; set; } = true;
|
||||||
|
|
||||||
|
public int CnrDailyNewsAutoRotateIntervalMinutes { get; set; } = 60;
|
||||||
|
|
||||||
public AppSettingsSnapshot Clone()
|
public AppSettingsSnapshot Clone()
|
||||||
{
|
{
|
||||||
var clone = (AppSettingsSnapshot)MemberwiseClone();
|
var clone = (AppSettingsSnapshot)MemberwiseClone();
|
||||||
|
|||||||
@@ -35,6 +35,23 @@ public sealed record DailyNewsSnapshot(
|
|||||||
IReadOnlyList<DailyNewsItemSnapshot> Items,
|
IReadOnlyList<DailyNewsItemSnapshot> Items,
|
||||||
DateTimeOffset FetchedAt);
|
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(
|
public sealed record DailyWordSnapshot(
|
||||||
string Provider,
|
string Provider,
|
||||||
string Word,
|
string Word,
|
||||||
@@ -45,3 +62,11 @@ public sealed record DailyWordSnapshot(
|
|||||||
string? ExampleTranslation,
|
string? ExampleTranslation,
|
||||||
string? SourceUrl,
|
string? SourceUrl,
|
||||||
DateTimeOffset FetchedAt);
|
DateTimeOffset FetchedAt);
|
||||||
|
|
||||||
|
public sealed record ExchangeRateSnapshot(
|
||||||
|
string Provider,
|
||||||
|
string Source,
|
||||||
|
string BaseCurrency,
|
||||||
|
string TargetCurrency,
|
||||||
|
decimal Rate,
|
||||||
|
DateTimeOffset FetchedAt);
|
||||||
|
|||||||
123
LanMountainDesktop/Services/CalculatorDataService.cs
Normal file
123
LanMountainDesktop/Services/CalculatorDataService.cs
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
17
LanMountainDesktop/Services/ICalculatorDataService.cs
Normal file
17
LanMountainDesktop/Services/ICalculatorDataService.cs
Normal 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 = ".";
|
||||||
|
}
|
||||||
@@ -20,10 +20,20 @@ public sealed record DailyNewsQuery(
|
|||||||
int? ItemCount = null,
|
int? ItemCount = null,
|
||||||
bool ForceRefresh = false);
|
bool ForceRefresh = false);
|
||||||
|
|
||||||
|
public sealed record BilibiliHotSearchQuery(
|
||||||
|
string? Locale = null,
|
||||||
|
int? ItemCount = null,
|
||||||
|
bool ForceRefresh = false);
|
||||||
|
|
||||||
public sealed record DailyWordQuery(
|
public sealed record DailyWordQuery(
|
||||||
string? Locale = null,
|
string? Locale = null,
|
||||||
bool ForceRefresh = false);
|
bool ForceRefresh = false);
|
||||||
|
|
||||||
|
public sealed record ExchangeRateQuery(
|
||||||
|
string? BaseCurrency = null,
|
||||||
|
string? TargetCurrency = null,
|
||||||
|
bool ForceRefresh = false);
|
||||||
|
|
||||||
public sealed record RecommendationQueryResult<T>(
|
public sealed record RecommendationQueryResult<T>(
|
||||||
bool Success,
|
bool Success,
|
||||||
T? Data,
|
T? Data,
|
||||||
@@ -66,10 +76,20 @@ public sealed record RecommendationApiOptions
|
|||||||
"https://news.cnr.cn/native/gd/rss.xml"
|
"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 YoudaoDictionaryApiTemplate { get; init; } = "https://dict.youdao.com/jsonapi?q={0}";
|
||||||
|
|
||||||
public string YoudaoDictionaryWordPageTemplate { get; init; } = "https://dict.youdao.com/w/eng/{0}/";
|
public string YoudaoDictionaryWordPageTemplate { get; init; } = "https://dict.youdao.com/w/eng/{0}/";
|
||||||
|
|
||||||
|
public string ExchangeRateApiTemplate { get; init; } = "https://open.er-api.com/v6/latest/{0}";
|
||||||
|
|
||||||
public IReadOnlyList<string> YoudaoDailyWordCandidates { get; init; } =
|
public IReadOnlyList<string> YoudaoDailyWordCandidates { get; init; } =
|
||||||
[
|
[
|
||||||
"illustrate",
|
"illustrate",
|
||||||
@@ -204,6 +224,8 @@ public sealed record RecommendationApiOptions
|
|||||||
public int DefaultArtworkCandidateCount { get; init; } = 50;
|
public int DefaultArtworkCandidateCount { get; init; } = 50;
|
||||||
|
|
||||||
public int DefaultDailyNewsCount { get; init; } = 2;
|
public int DefaultDailyNewsCount { get; init; } = 2;
|
||||||
|
|
||||||
|
public int DefaultBilibiliHotSearchCount { get; init; } = 5;
|
||||||
}
|
}
|
||||||
|
|
||||||
public interface IRecommendationInfoService
|
public interface IRecommendationInfoService
|
||||||
@@ -220,9 +242,17 @@ public interface IRecommendationInfoService
|
|||||||
DailyNewsQuery query,
|
DailyNewsQuery query,
|
||||||
CancellationToken cancellationToken = default);
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<RecommendationQueryResult<BilibiliHotSearchSnapshot>> GetBilibiliHotSearchAsync(
|
||||||
|
BilibiliHotSearchQuery query,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
Task<RecommendationQueryResult<DailyWordSnapshot>> GetDailyWordAsync(
|
Task<RecommendationQueryResult<DailyWordSnapshot>> GetDailyWordAsync(
|
||||||
DailyWordQuery query,
|
DailyWordQuery query,
|
||||||
CancellationToken cancellationToken = default);
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<RecommendationQueryResult<ExchangeRateSnapshot>> GetExchangeRateAsync(
|
||||||
|
ExchangeRateQuery query,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
void ClearCache();
|
void ClearCache();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
@@ -34,7 +34,13 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
|
|||||||
private sealed record DailyArtworkCacheEntry(DailyArtworkSnapshot Snapshot, DateTimeOffset ExpireAt);
|
private sealed record DailyArtworkCacheEntry(DailyArtworkSnapshot Snapshot, DateTimeOffset ExpireAt);
|
||||||
private sealed record DailyPoetryCacheEntry(DailyPoetrySnapshot Snapshot, DateTimeOffset ExpireAt);
|
private sealed record DailyPoetryCacheEntry(DailyPoetrySnapshot Snapshot, DateTimeOffset ExpireAt);
|
||||||
private sealed record DailyNewsCacheEntry(DailyNewsSnapshot Snapshot, DateTimeOffset ExpireAt);
|
private sealed record DailyNewsCacheEntry(DailyNewsSnapshot Snapshot, DateTimeOffset ExpireAt);
|
||||||
|
private sealed record BilibiliHotSearchCacheEntry(BilibiliHotSearchSnapshot Snapshot, DateTimeOffset ExpireAt);
|
||||||
private sealed record DailyWordCacheEntry(DailyWordSnapshot 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(
|
private sealed record ArtworkCandidate(
|
||||||
string Title,
|
string Title,
|
||||||
string? Artist,
|
string? Artist,
|
||||||
@@ -52,7 +58,11 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
|
|||||||
new(StringComparer.OrdinalIgnoreCase);
|
new(StringComparer.OrdinalIgnoreCase);
|
||||||
private DailyPoetryCacheEntry? _dailyPoetryCache;
|
private DailyPoetryCacheEntry? _dailyPoetryCache;
|
||||||
private DailyNewsCacheEntry? _dailyNewsCache;
|
private DailyNewsCacheEntry? _dailyNewsCache;
|
||||||
|
private BilibiliHotSearchCacheEntry? _bilibiliHotSearchCache;
|
||||||
private DailyWordCacheEntry? _dailyWordCache;
|
private DailyWordCacheEntry? _dailyWordCache;
|
||||||
|
private readonly Dictionary<string, ExchangeRateTableCacheEntry> _exchangeRateCacheByBaseCurrency =
|
||||||
|
new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
private int _dailyNewsRotationCursor;
|
||||||
|
|
||||||
static RecommendationDataService()
|
static RecommendationDataService()
|
||||||
{
|
{
|
||||||
@@ -94,7 +104,9 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
|
|||||||
_dailyArtworkCacheBySource.Clear();
|
_dailyArtworkCacheBySource.Clear();
|
||||||
_dailyPoetryCache = null;
|
_dailyPoetryCache = null;
|
||||||
_dailyNewsCache = null;
|
_dailyNewsCache = null;
|
||||||
|
_bilibiliHotSearchCache = null;
|
||||||
_dailyWordCache = null;
|
_dailyWordCache = null;
|
||||||
|
_exchangeRateCacheByBaseCurrency.Clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,10 +218,10 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
|
|||||||
"No CNR news items were returned.");
|
"No CNR news items were returned.");
|
||||||
}
|
}
|
||||||
|
|
||||||
var snapshot = new DailyNewsSnapshot(
|
var snapshot = new DailyNewsSnapshot(
|
||||||
Provider: "CNR",
|
Provider: "CNR",
|
||||||
Source: "央广网·头条",
|
Source: "央广网·头条",
|
||||||
Items: items.Take(targetCount).ToArray(),
|
Items: SelectDailyNewsItems(items, targetCount, normalizedQuery.ForceRefresh),
|
||||||
FetchedAt: DateTimeOffset.UtcNow);
|
FetchedAt: DateTimeOffset.UtcNow);
|
||||||
|
|
||||||
SetDailyNewsCache(snapshot);
|
SetDailyNewsCache(snapshot);
|
||||||
@@ -229,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(
|
public async Task<RecommendationQueryResult<DailyWordSnapshot>> GetDailyWordAsync(
|
||||||
DailyWordQuery query,
|
DailyWordQuery query,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
@@ -281,6 +340,63 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
|
|||||||
lastError?.Message ?? "No available daily word from Youdao.");
|
lastError?.Message ?? "No available daily word from Youdao.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<RecommendationQueryResult<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(
|
private async Task<RecommendationQueryResult<DailyArtworkSnapshot>> GetDailyArtworkFromOverseasSourceAsync(
|
||||||
string mirrorSource,
|
string mirrorSource,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
@@ -521,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)
|
private bool TryGetDailyWordFromCache(out DailyWordSnapshot snapshot)
|
||||||
{
|
{
|
||||||
lock (_cacheGate)
|
lock (_cacheGate)
|
||||||
@@ -546,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()
|
private List<string> BuildDailyWordCandidates()
|
||||||
{
|
{
|
||||||
var values = _options.YoudaoDailyWordCandidates ?? [];
|
var values = _options.YoudaoDailyWordCandidates ?? [];
|
||||||
@@ -723,7 +1208,7 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return string.Join(";", lines
|
return string.Join("; ", lines
|
||||||
.Where(line => !string.IsNullOrWhiteSpace(line))
|
.Where(line => !string.IsNullOrWhiteSpace(line))
|
||||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
.Take(3));
|
.Take(3));
|
||||||
@@ -1634,6 +2119,28 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
|
|||||||
return DateOnly.FromDateTime(now.Date);
|
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)
|
private static string Truncate(string? text, int maxLength)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(text))
|
if (string.IsNullOrEmpty(text))
|
||||||
@@ -1646,3 +2153,4 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
|
|||||||
: $"{text[..maxLength]}...";
|
: $"{text[..maxLength]}...";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
xmlns:fi="using:FluentIcons.Avalonia"
|
||||||
mc:Ignorable="d"
|
mc:Ignorable="d"
|
||||||
d:DesignWidth="640"
|
d:DesignWidth="640"
|
||||||
d:DesignHeight="320"
|
d:DesignHeight="320"
|
||||||
@@ -56,12 +57,12 @@
|
|||||||
Spacing="4"
|
Spacing="4"
|
||||||
HorizontalAlignment="Center"
|
HorizontalAlignment="Center"
|
||||||
VerticalAlignment="Center">
|
VerticalAlignment="Center">
|
||||||
<TextBlock x:Name="RefreshGlyphTextBlock"
|
<fi:SymbolIcon x:Name="RefreshGlyphIcon"
|
||||||
Text="↻"
|
Symbol="ArrowClockwise"
|
||||||
Foreground="#52575F"
|
IconVariant="Regular"
|
||||||
FontSize="19"
|
Foreground="#52575F"
|
||||||
FontWeight="SemiBold"
|
FontSize="19"
|
||||||
VerticalAlignment="Center" />
|
VerticalAlignment="Center" />
|
||||||
<TextBlock x:Name="RefreshLabelTextBlock"
|
<TextBlock x:Name="RefreshLabelTextBlock"
|
||||||
Text="换一换"
|
Text="换一换"
|
||||||
Foreground="#202327"
|
Foreground="#202327"
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget,
|
|||||||
private const double BaseCellSize = 48d;
|
private const double BaseCellSize = 48d;
|
||||||
private const int BaseWidthCells = 4;
|
private const int BaseWidthCells = 4;
|
||||||
private const int BaseHeightCells = 2;
|
private const int BaseHeightCells = 2;
|
||||||
|
private static readonly int[] SupportedAutoRotateIntervalsMinutes = [5, 10, 40, 60, 720, 1440];
|
||||||
|
|
||||||
private readonly DispatcherTimer _refreshTimer = new()
|
private readonly DispatcherTimer _refreshTimer = new()
|
||||||
{
|
{
|
||||||
@@ -85,6 +86,7 @@ public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget,
|
|||||||
private double _currentCellSize = BaseCellSize;
|
private double _currentCellSize = BaseCellSize;
|
||||||
private bool _isAttached;
|
private bool _isAttached;
|
||||||
private bool _isRefreshing;
|
private bool _isRefreshing;
|
||||||
|
private bool _autoRotateEnabled = true;
|
||||||
|
|
||||||
public CnrDailyNewsWidget()
|
public CnrDailyNewsWidget()
|
||||||
{
|
{
|
||||||
@@ -92,7 +94,6 @@ public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget,
|
|||||||
|
|
||||||
BrandPrimaryTextBlock.FontFamily = MiSansFontFamily;
|
BrandPrimaryTextBlock.FontFamily = MiSansFontFamily;
|
||||||
BrandSecondaryTextBlock.FontFamily = MiSansFontFamily;
|
BrandSecondaryTextBlock.FontFamily = MiSansFontFamily;
|
||||||
RefreshGlyphTextBlock.FontFamily = MiSansFontFamily;
|
|
||||||
RefreshLabelTextBlock.FontFamily = MiSansFontFamily;
|
RefreshLabelTextBlock.FontFamily = MiSansFontFamily;
|
||||||
News1TitleTextBlock.FontFamily = MiSansFontFamily;
|
News1TitleTextBlock.FontFamily = MiSansFontFamily;
|
||||||
News2TitleTextBlock.FontFamily = MiSansFontFamily;
|
News2TitleTextBlock.FontFamily = MiSansFontFamily;
|
||||||
@@ -106,6 +107,7 @@ public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget,
|
|||||||
|
|
||||||
ApplyCellSize(_currentCellSize);
|
ApplyCellSize(_currentCellSize);
|
||||||
UpdateLanguageCode();
|
UpdateLanguageCode();
|
||||||
|
ApplyAutoRotateSettings();
|
||||||
ApplyLoadingState();
|
ApplyLoadingState();
|
||||||
UpdateRefreshButtonState();
|
UpdateRefreshButtonState();
|
||||||
}
|
}
|
||||||
@@ -128,6 +130,7 @@ public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget,
|
|||||||
public void RefreshFromSettings()
|
public void RefreshFromSettings()
|
||||||
{
|
{
|
||||||
_recommendationService.ClearCache();
|
_recommendationService.ClearCache();
|
||||||
|
ApplyAutoRotateSettings();
|
||||||
if (_isAttached)
|
if (_isAttached)
|
||||||
{
|
{
|
||||||
_ = RefreshNewsAsync(forceRefresh: true);
|
_ = RefreshNewsAsync(forceRefresh: true);
|
||||||
@@ -137,8 +140,8 @@ public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget,
|
|||||||
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||||
{
|
{
|
||||||
_isAttached = true;
|
_isAttached = true;
|
||||||
|
ApplyAutoRotateSettings();
|
||||||
UpdateRefreshButtonState();
|
UpdateRefreshButtonState();
|
||||||
_refreshTimer.Start();
|
|
||||||
_ = RefreshNewsAsync(forceRefresh: false);
|
_ = RefreshNewsAsync(forceRefresh: false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,26 +158,6 @@ public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget,
|
|||||||
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
|
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
|
||||||
{
|
{
|
||||||
ApplyCellSize(_currentCellSize);
|
ApplyCellSize(_currentCellSize);
|
||||||
var desiredCount = ResolveDesiredNewsItemCount();
|
|
||||||
var previousRenderedCount = _renderedNewsCount;
|
|
||||||
if (_activeNewsItems.Count > 0 && desiredCount != previousRenderedCount)
|
|
||||||
{
|
|
||||||
RenderExtraNewsRows(_activeNewsItems.Take(desiredCount).Skip(2).ToArray());
|
|
||||||
UpdateNewsInteractionState();
|
|
||||||
}
|
|
||||||
|
|
||||||
var shouldFetchMoreItems = desiredCount > _activeNewsItems.Count;
|
|
||||||
var shouldReloadExpandedImages =
|
|
||||||
desiredCount > previousRenderedCount &&
|
|
||||||
desiredCount <= _activeNewsItems.Count;
|
|
||||||
|
|
||||||
if (_isAttached &&
|
|
||||||
!_isRefreshing &&
|
|
||||||
_activeNewsItems.Count > 0 &&
|
|
||||||
(shouldFetchMoreItems || shouldReloadExpandedImages))
|
|
||||||
{
|
|
||||||
_ = RefreshNewsAsync(forceRefresh: false);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async void OnRefreshButtonClick(object? sender, RoutedEventArgs e)
|
private async void OnRefreshButtonClick(object? sender, RoutedEventArgs e)
|
||||||
@@ -190,7 +173,7 @@ public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget,
|
|||||||
|
|
||||||
private async void OnRefreshTimerTick(object? sender, EventArgs e)
|
private async void OnRefreshTimerTick(object? sender, EventArgs e)
|
||||||
{
|
{
|
||||||
await RefreshNewsAsync(forceRefresh: false);
|
await RefreshNewsAsync(forceRefresh: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnNewsItem1PointerPressed(object? sender, PointerPressedEventArgs e)
|
private void OnNewsItem1PointerPressed(object? sender, PointerPressedEventArgs e)
|
||||||
@@ -290,10 +273,9 @@ public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget,
|
|||||||
|
|
||||||
private async Task ApplySnapshotAsync(DailyNewsSnapshot snapshot, CancellationToken cancellationToken)
|
private async Task ApplySnapshotAsync(DailyNewsSnapshot snapshot, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var desiredCount = ResolveDesiredNewsItemCount();
|
|
||||||
var items = snapshot.Items is null
|
var items = snapshot.Items is null
|
||||||
? []
|
? []
|
||||||
: snapshot.Items.Take(desiredCount).ToArray();
|
: snapshot.Items.Take(2).ToArray();
|
||||||
_activeNewsItems = items;
|
_activeNewsItems = items;
|
||||||
|
|
||||||
var item1 = items.Length > 0 ? items[0] : null;
|
var item1 = items.Length > 0 ? items[0] : null;
|
||||||
@@ -308,52 +290,27 @@ public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget,
|
|||||||
_newsUrls.Add(NormalizeHttpUrl(item.Url));
|
_newsUrls.Add(NormalizeHttpUrl(item.Url));
|
||||||
}
|
}
|
||||||
|
|
||||||
RenderExtraNewsRows(items.Skip(2).ToArray());
|
RenderExtraNewsRows([]);
|
||||||
UpdateNewsInteractionState();
|
UpdateNewsInteractionState();
|
||||||
|
|
||||||
StatusTextBlock.IsVisible = false;
|
StatusTextBlock.IsVisible = false;
|
||||||
UpdateAdaptiveLayout();
|
UpdateAdaptiveLayout();
|
||||||
|
|
||||||
var loadTasks = items
|
var loadTasks = new[]
|
||||||
.Select(item => TryDownloadBitmapAsync(item.ImageUrl, cancellationToken))
|
{
|
||||||
.ToArray();
|
TryDownloadBitmapAsync(item1?.ImageUrl, cancellationToken),
|
||||||
|
TryDownloadBitmapAsync(item2?.ImageUrl, cancellationToken)
|
||||||
|
};
|
||||||
var bitmaps = await Task.WhenAll(loadTasks);
|
var bitmaps = await Task.WhenAll(loadTasks);
|
||||||
if (cancellationToken.IsCancellationRequested || !_isAttached)
|
if (cancellationToken.IsCancellationRequested || !_isAttached)
|
||||||
{
|
{
|
||||||
foreach (var bitmap in bitmaps)
|
bitmaps[0]?.Dispose();
|
||||||
{
|
bitmaps[1]?.Dispose();
|
||||||
bitmap?.Dispose();
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var consumed = new bool[bitmaps.Length];
|
SetNewsBitmap(0, bitmaps[0]);
|
||||||
Bitmap? TakeBitmapAt(int index)
|
SetNewsBitmap(1, bitmaps[1]);
|
||||||
{
|
|
||||||
if (index < 0 || index >= bitmaps.Length)
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
consumed[index] = true;
|
|
||||||
return bitmaps[index];
|
|
||||||
}
|
|
||||||
|
|
||||||
SetNewsBitmap(0, TakeBitmapAt(0));
|
|
||||||
SetNewsBitmap(1, TakeBitmapAt(1));
|
|
||||||
|
|
||||||
for (var rowIndex = 0; rowIndex < _extraNewsRows.Count; rowIndex++)
|
|
||||||
{
|
|
||||||
SetExtraNewsBitmap(rowIndex, TakeBitmapAt(rowIndex + 2));
|
|
||||||
}
|
|
||||||
|
|
||||||
for (var i = 0; i < bitmaps.Length; i++)
|
|
||||||
{
|
|
||||||
if (!consumed[i])
|
|
||||||
{
|
|
||||||
bitmaps[i]?.Dispose();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ApplyLoadingState()
|
private void ApplyLoadingState()
|
||||||
@@ -389,24 +346,7 @@ public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget,
|
|||||||
|
|
||||||
private int ResolveDesiredNewsItemCount()
|
private int ResolveDesiredNewsItemCount()
|
||||||
{
|
{
|
||||||
var span = ResolveCurrentCellSpan();
|
return 2;
|
||||||
var baseEquivalentHeight = span.HeightCells * (double)BaseWidthCells / Math.Max(BaseWidthCells, span.WidthCells);
|
|
||||||
var effectiveHeightCells = (int)Math.Round(baseEquivalentHeight, MidpointRounding.AwayFromZero);
|
|
||||||
return Math.Clamp(Math.Max(BaseHeightCells, effectiveHeightCells), 2, 12);
|
|
||||||
}
|
|
||||||
|
|
||||||
private (int WidthCells, int HeightCells) ResolveCurrentCellSpan()
|
|
||||||
{
|
|
||||||
var pitch = Math.Max(1, _currentCellSize);
|
|
||||||
var totalWidth = Bounds.Width > 1 ? Bounds.Width : _currentCellSize * BaseWidthCells;
|
|
||||||
var totalHeight = Bounds.Height > 1 ? Bounds.Height : _currentCellSize * BaseHeightCells;
|
|
||||||
|
|
||||||
var normalizedWidth = totalWidth + Math.Clamp(pitch * 0.20, 6, 16);
|
|
||||||
var normalizedHeight = totalHeight + Math.Clamp(pitch * 0.18, 4, 12);
|
|
||||||
|
|
||||||
var widthCells = Math.Max(BaseWidthCells, (int)Math.Round(normalizedWidth / pitch, MidpointRounding.AwayFromZero));
|
|
||||||
var heightCells = Math.Max(BaseHeightCells, (int)Math.Round(normalizedHeight / pitch, MidpointRounding.AwayFromZero));
|
|
||||||
return (widthCells, heightCells);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void UpdateHotHeadlineText(string? title)
|
private void UpdateHotHeadlineText(string? title)
|
||||||
@@ -558,7 +498,7 @@ public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget,
|
|||||||
RefreshButton.Height = refreshHeight;
|
RefreshButton.Height = refreshHeight;
|
||||||
RefreshButton.Width = refreshWidth;
|
RefreshButton.Width = refreshWidth;
|
||||||
RefreshButton.CornerRadius = new CornerRadius(refreshHeight / 2d);
|
RefreshButton.CornerRadius = new CornerRadius(refreshHeight / 2d);
|
||||||
RefreshGlyphTextBlock.FontSize = Math.Clamp(19 * scale, 11, 24);
|
RefreshGlyphIcon.FontSize = Math.Clamp(19 * scale, 11, 24);
|
||||||
RefreshLabelTextBlock.FontSize = Math.Clamp(22 * scale, 11, 29);
|
RefreshLabelTextBlock.FontSize = Math.Clamp(22 * scale, 11, 29);
|
||||||
|
|
||||||
var imageWidth = Math.Clamp(totalWidth * 0.20, 60, 170);
|
var imageWidth = Math.Clamp(totalWidth * 0.20, 60, 170);
|
||||||
@@ -621,7 +561,7 @@ public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget,
|
|||||||
{
|
{
|
||||||
RefreshButton.IsEnabled = !_isRefreshing;
|
RefreshButton.IsEnabled = !_isRefreshing;
|
||||||
RefreshButton.Opacity = _isAttached ? 1.0 : 0.85;
|
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;
|
RefreshLabelTextBlock.Opacity = _isRefreshing ? 0.56 : 1.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -774,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()
|
private void CancelRefreshRequest()
|
||||||
{
|
{
|
||||||
var cts = Interlocked.Exchange(ref _refreshCts, null);
|
var cts = Interlocked.Exchange(ref _refreshCts, null);
|
||||||
|
|||||||
@@ -1,126 +0,0 @@
|
|||||||
<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.DailySentenceWidget">
|
|
||||||
|
|
||||||
<Border x:Name="RootBorder"
|
|
||||||
CornerRadius="34"
|
|
||||||
ClipToBounds="True"
|
|
||||||
BorderThickness="0"
|
|
||||||
Background="#6F7B8D">
|
|
||||||
<Grid>
|
|
||||||
<Image x:Name="BackgroundImage"
|
|
||||||
Stretch="UniformToFill" />
|
|
||||||
|
|
||||||
<Border x:Name="OverlayBorder">
|
|
||||||
<Border.Background>
|
|
||||||
<LinearGradientBrush StartPoint="0,0"
|
|
||||||
EndPoint="0,1">
|
|
||||||
<GradientStop Offset="0"
|
|
||||||
Color="#56000000" />
|
|
||||||
<GradientStop Offset="0.52"
|
|
||||||
Color="#7A000000" />
|
|
||||||
<GradientStop Offset="1"
|
|
||||||
Color="#8F000000" />
|
|
||||||
</LinearGradientBrush>
|
|
||||||
</Border.Background>
|
|
||||||
</Border>
|
|
||||||
|
|
||||||
<Grid x:Name="ContentGrid"
|
|
||||||
Margin="16,14,16,14"
|
|
||||||
RowDefinitions="Auto,*,Auto"
|
|
||||||
RowSpacing="8">
|
|
||||||
<Grid Grid.Row="0"
|
|
||||||
ColumnDefinitions="Auto,*,Auto"
|
|
||||||
ColumnSpacing="8">
|
|
||||||
<TextBlock x:Name="DayTextBlock"
|
|
||||||
Text="3"
|
|
||||||
Foreground="#F6F8FB"
|
|
||||||
FontSize="72"
|
|
||||||
FontWeight="Bold"
|
|
||||||
FontFeatures="tnum"
|
|
||||||
LineHeight="68"
|
|
||||||
VerticalAlignment="Top"
|
|
||||||
TextTrimming="CharacterEllipsis"
|
|
||||||
MaxLines="1" />
|
|
||||||
|
|
||||||
<TextBlock x:Name="MonthYearTextBlock"
|
|
||||||
Grid.Column="1"
|
|
||||||
Text="March 2026"
|
|
||||||
Foreground="#ECF0F6"
|
|
||||||
FontSize="44"
|
|
||||||
FontWeight="Medium"
|
|
||||||
VerticalAlignment="Center"
|
|
||||||
Margin="4,0,0,0"
|
|
||||||
TextTrimming="CharacterEllipsis"
|
|
||||||
MaxLines="1" />
|
|
||||||
|
|
||||||
<Button x:Name="RefreshButton"
|
|
||||||
Grid.Column="2"
|
|
||||||
Width="42"
|
|
||||||
Height="42"
|
|
||||||
HorizontalAlignment="Right"
|
|
||||||
VerticalAlignment="Top"
|
|
||||||
CornerRadius="21"
|
|
||||||
Background="#12FFFFFF"
|
|
||||||
BorderBrush="#3AFFFFFF"
|
|
||||||
BorderThickness="1"
|
|
||||||
Padding="0"
|
|
||||||
Focusable="False">
|
|
||||||
<fi:SymbolIcon x:Name="RefreshIcon"
|
|
||||||
Symbol="ArrowClockwise"
|
|
||||||
IconVariant="Regular"
|
|
||||||
FontSize="21"
|
|
||||||
Foreground="#F0F4FA" />
|
|
||||||
</Button>
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
<StackPanel Grid.Row="1"
|
|
||||||
VerticalAlignment="Center"
|
|
||||||
Spacing="8">
|
|
||||||
<TextBlock x:Name="SentenceTextBlock"
|
|
||||||
Text="Heard melodies are sweet, but those unheard are sweeter."
|
|
||||||
Foreground="#F7F9FC"
|
|
||||||
FontSize="58"
|
|
||||||
FontWeight="SemiBold"
|
|
||||||
LineHeight="60"
|
|
||||||
TextWrapping="Wrap"
|
|
||||||
TextTrimming="CharacterEllipsis"
|
|
||||||
MaxLines="3" />
|
|
||||||
|
|
||||||
<TextBlock x:Name="TranslationTextBlock"
|
|
||||||
Text="听见的旋律是美妙的,但听不见的会更美。"
|
|
||||||
Foreground="#DDE3EC"
|
|
||||||
FontSize="40"
|
|
||||||
FontWeight="Medium"
|
|
||||||
LineHeight="44"
|
|
||||||
TextWrapping="Wrap"
|
|
||||||
TextTrimming="CharacterEllipsis"
|
|
||||||
MaxLines="2" />
|
|
||||||
</StackPanel>
|
|
||||||
|
|
||||||
<TextBlock x:Name="SourceTextBlock"
|
|
||||||
Grid.Row="2"
|
|
||||||
Text="Youdao Dictionary"
|
|
||||||
Foreground="#C7CFDA"
|
|
||||||
FontSize="30"
|
|
||||||
FontWeight="Medium"
|
|
||||||
TextTrimming="CharacterEllipsis"
|
|
||||||
MaxLines="1" />
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
<TextBlock x:Name="StatusTextBlock"
|
|
||||||
IsVisible="False"
|
|
||||||
Text="Loading"
|
|
||||||
Foreground="#E7EDF6"
|
|
||||||
FontSize="16"
|
|
||||||
HorizontalAlignment="Center"
|
|
||||||
VerticalAlignment="Center" />
|
|
||||||
</Grid>
|
|
||||||
</Border>
|
|
||||||
</UserControl>
|
|
||||||
@@ -1,869 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Diagnostics;
|
|
||||||
using System.Globalization;
|
|
||||||
using System.IO;
|
|
||||||
using System.Net.Http;
|
|
||||||
using System.Text.RegularExpressions;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using Avalonia;
|
|
||||||
using Avalonia.Controls;
|
|
||||||
using Avalonia.Input;
|
|
||||||
using Avalonia.Interactivity;
|
|
||||||
using Avalonia.Media;
|
|
||||||
using Avalonia.Media.Imaging;
|
|
||||||
using Avalonia.Threading;
|
|
||||||
using LanMountainDesktop.Models;
|
|
||||||
using LanMountainDesktop.Services;
|
|
||||||
|
|
||||||
namespace LanMountainDesktop.Views.Components;
|
|
||||||
|
|
||||||
public partial class DailySentenceWidget : UserControl, IDesktopComponentWidget, IRecommendationInfoAwareComponentWidget
|
|
||||||
{
|
|
||||||
private static readonly Regex MultiWhitespaceRegex = new(@"\s+", RegexOptions.Compiled);
|
|
||||||
private static readonly FontFamily MiSansFontFamily = new("MiSans VF, avares://LanMountainDesktop/Assets/Fonts#MiSans");
|
|
||||||
private static readonly FontWeight[] HeadlineWeightCandidates = [FontWeight.Bold, FontWeight.SemiBold, FontWeight.Medium];
|
|
||||||
private static readonly FontWeight[] BodyWeightCandidates = [FontWeight.Medium, FontWeight.Normal];
|
|
||||||
private static readonly FontWeight[] MetaWeightCandidates = [FontWeight.Medium, FontWeight.Normal, FontWeight.Light];
|
|
||||||
private static readonly IRecommendationInfoService DefaultRecommendationService = new RecommendationDataService();
|
|
||||||
private static readonly HttpClient ImageHttpClient = new()
|
|
||||||
{
|
|
||||||
Timeout = TimeSpan.FromSeconds(8)
|
|
||||||
};
|
|
||||||
|
|
||||||
private const string BrowserUserAgent =
|
|
||||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0 Safari/537.36";
|
|
||||||
|
|
||||||
private const double BaseCellSize = 48d;
|
|
||||||
private const int BaseWidthCells = 4;
|
|
||||||
private const int BaseHeightCells = 2;
|
|
||||||
|
|
||||||
private readonly DispatcherTimer _refreshTimer = new()
|
|
||||||
{
|
|
||||||
Interval = TimeSpan.FromHours(6)
|
|
||||||
};
|
|
||||||
|
|
||||||
private readonly AppSettingsService _settingsService = new();
|
|
||||||
private readonly LocalizationService _localizationService = new();
|
|
||||||
|
|
||||||
private IRecommendationInfoService _recommendationService = DefaultRecommendationService;
|
|
||||||
private CancellationTokenSource? _refreshCts;
|
|
||||||
private Bitmap? _backgroundBitmap;
|
|
||||||
private string? _currentSourceUrl;
|
|
||||||
private string _languageCode = "zh-CN";
|
|
||||||
private double _currentCellSize = BaseCellSize;
|
|
||||||
private bool _isAttached;
|
|
||||||
private bool _isRefreshing;
|
|
||||||
|
|
||||||
public DailySentenceWidget()
|
|
||||||
{
|
|
||||||
InitializeComponent();
|
|
||||||
|
|
||||||
DayTextBlock.FontFamily = MiSansFontFamily;
|
|
||||||
MonthYearTextBlock.FontFamily = MiSansFontFamily;
|
|
||||||
SentenceTextBlock.FontFamily = MiSansFontFamily;
|
|
||||||
TranslationTextBlock.FontFamily = MiSansFontFamily;
|
|
||||||
SourceTextBlock.FontFamily = MiSansFontFamily;
|
|
||||||
StatusTextBlock.FontFamily = MiSansFontFamily;
|
|
||||||
|
|
||||||
_refreshTimer.Tick += OnRefreshTimerTick;
|
|
||||||
RefreshButton.Click += OnRefreshButtonClick;
|
|
||||||
SourceTextBlock.PointerPressed += OnSourceTextBlockPointerPressed;
|
|
||||||
AttachedToVisualTree += OnAttachedToVisualTree;
|
|
||||||
DetachedFromVisualTree += OnDetachedFromVisualTree;
|
|
||||||
SizeChanged += OnSizeChanged;
|
|
||||||
|
|
||||||
ApplyCellSize(_currentCellSize);
|
|
||||||
UpdateLanguageCode();
|
|
||||||
UpdateDateText();
|
|
||||||
ApplyLoadingState();
|
|
||||||
UpdateRefreshButtonState();
|
|
||||||
}
|
|
||||||
|
|
||||||
public void ApplyCellSize(double cellSize)
|
|
||||||
{
|
|
||||||
_currentCellSize = Math.Max(1, cellSize);
|
|
||||||
UpdateAdaptiveLayout();
|
|
||||||
}
|
|
||||||
|
|
||||||
public void SetRecommendationInfoService(IRecommendationInfoService recommendationInfoService)
|
|
||||||
{
|
|
||||||
_recommendationService = recommendationInfoService ?? DefaultRecommendationService;
|
|
||||||
if (_isAttached)
|
|
||||||
{
|
|
||||||
_ = RefreshSentenceAsync(forceRefresh: false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void RefreshFromSettings()
|
|
||||||
{
|
|
||||||
_recommendationService.ClearCache();
|
|
||||||
if (_isAttached)
|
|
||||||
{
|
|
||||||
_ = RefreshSentenceAsync(forceRefresh: true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
|
||||||
{
|
|
||||||
_isAttached = true;
|
|
||||||
UpdateRefreshButtonState();
|
|
||||||
_refreshTimer.Start();
|
|
||||||
_ = RefreshSentenceAsync(forceRefresh: false);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
|
||||||
{
|
|
||||||
_isAttached = false;
|
|
||||||
_refreshTimer.Stop();
|
|
||||||
CancelRefreshRequest();
|
|
||||||
DisposeBackgroundBitmap();
|
|
||||||
UpdateRefreshButtonState();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
|
|
||||||
{
|
|
||||||
ApplyCellSize(_currentCellSize);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async void OnRefreshButtonClick(object? sender, RoutedEventArgs e)
|
|
||||||
{
|
|
||||||
if (_isRefreshing)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await RefreshSentenceAsync(forceRefresh: true);
|
|
||||||
e.Handled = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async void OnRefreshTimerTick(object? sender, EventArgs e)
|
|
||||||
{
|
|
||||||
await RefreshSentenceAsync(forceRefresh: false);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnSourceTextBlockPointerPressed(object? sender, Avalonia.Input.PointerPressedEventArgs e)
|
|
||||||
{
|
|
||||||
if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
TryOpenSourceUrl();
|
|
||||||
e.Handled = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task RefreshSentenceAsync(bool forceRefresh)
|
|
||||||
{
|
|
||||||
if (!_isAttached || _isRefreshing)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_isRefreshing = true;
|
|
||||||
UpdateRefreshButtonState();
|
|
||||||
UpdateLanguageCode();
|
|
||||||
UpdateDateText();
|
|
||||||
|
|
||||||
var cts = new CancellationTokenSource();
|
|
||||||
var previous = Interlocked.Exchange(ref _refreshCts, cts);
|
|
||||||
previous?.Cancel();
|
|
||||||
previous?.Dispose();
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var sentenceQuery = new DailyWordQuery(
|
|
||||||
Locale: _languageCode,
|
|
||||||
ForceRefresh: forceRefresh);
|
|
||||||
var sentenceResult = await _recommendationService.GetDailyWordAsync(sentenceQuery, cts.Token);
|
|
||||||
if (!_isAttached || cts.IsCancellationRequested)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!sentenceResult.Success || sentenceResult.Data is null)
|
|
||||||
{
|
|
||||||
ApplyFailedState();
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
ApplySentenceSnapshot(sentenceResult.Data);
|
|
||||||
}
|
|
||||||
|
|
||||||
var artworkQuery = new DailyArtworkQuery(
|
|
||||||
Locale: _languageCode,
|
|
||||||
ForceRefresh: forceRefresh);
|
|
||||||
var artworkResult = await _recommendationService.GetDailyArtworkAsync(artworkQuery, cts.Token);
|
|
||||||
if (!_isAttached || cts.IsCancellationRequested)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (artworkResult.Success && artworkResult.Data is not null)
|
|
||||||
{
|
|
||||||
await ApplyBackgroundSnapshotAsync(artworkResult.Data, cts.Token);
|
|
||||||
}
|
|
||||||
else if (_backgroundBitmap is null)
|
|
||||||
{
|
|
||||||
BackgroundImage.Source = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (OperationCanceledException)
|
|
||||||
{
|
|
||||||
// Ignore canceled requests.
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
if (_isAttached && !cts.IsCancellationRequested)
|
|
||||||
{
|
|
||||||
ApplyFailedState();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
if (ReferenceEquals(_refreshCts, cts))
|
|
||||||
{
|
|
||||||
_refreshCts = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
cts.Dispose();
|
|
||||||
_isRefreshing = false;
|
|
||||||
UpdateRefreshButtonState();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ApplySentenceSnapshot(DailyWordSnapshot snapshot)
|
|
||||||
{
|
|
||||||
var sentence = NormalizeCompactText(snapshot.ExampleSentence);
|
|
||||||
if (string.IsNullOrWhiteSpace(sentence))
|
|
||||||
{
|
|
||||||
sentence = NormalizeCompactText(snapshot.Meaning);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(sentence))
|
|
||||||
{
|
|
||||||
sentence = L("dailysentence.widget.fallback_sentence", "No sentence available.");
|
|
||||||
}
|
|
||||||
|
|
||||||
var translation = NormalizeCompactText(snapshot.ExampleTranslation);
|
|
||||||
if (string.IsNullOrWhiteSpace(translation))
|
|
||||||
{
|
|
||||||
translation = NormalizeCompactText(snapshot.Meaning);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(translation))
|
|
||||||
{
|
|
||||||
translation = L("dailysentence.widget.fallback_translation", "Tap refresh and try again.");
|
|
||||||
}
|
|
||||||
|
|
||||||
var sourceWord = NormalizeCompactText(snapshot.Word);
|
|
||||||
if (string.IsNullOrWhiteSpace(sourceWord))
|
|
||||||
{
|
|
||||||
sourceWord = L("dailysentence.widget.source_default", "Youdao Dictionary");
|
|
||||||
}
|
|
||||||
|
|
||||||
SentenceTextBlock.Text = sentence;
|
|
||||||
TranslationTextBlock.Text = translation;
|
|
||||||
SourceTextBlock.Text = string.Equals(_languageCode, "zh-CN", StringComparison.OrdinalIgnoreCase)
|
|
||||||
? $"有道词典 · {sourceWord}"
|
|
||||||
: $"Youdao Dictionary · {sourceWord}";
|
|
||||||
_currentSourceUrl = NormalizeHttpUrl(snapshot.SourceUrl);
|
|
||||||
|
|
||||||
StatusTextBlock.IsVisible = false;
|
|
||||||
UpdateSourceInteractionState();
|
|
||||||
UpdateAdaptiveLayout();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task ApplyBackgroundSnapshotAsync(DailyArtworkSnapshot snapshot, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var bitmap = await TryLoadBackgroundBitmapAsync(snapshot.ImageUrl, snapshot.ThumbnailDataUrl, cancellationToken);
|
|
||||||
if (cancellationToken.IsCancellationRequested || !_isAttached)
|
|
||||||
{
|
|
||||||
bitmap?.Dispose();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
SetBackgroundBitmap(bitmap);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<Bitmap?> TryLoadBackgroundBitmapAsync(
|
|
||||||
string? imageUrl,
|
|
||||||
string? thumbnailDataUrl,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var normalizedUrl = NormalizeHttpUrl(imageUrl);
|
|
||||||
if (!string.IsNullOrWhiteSpace(normalizedUrl))
|
|
||||||
{
|
|
||||||
var remote = await TryDownloadBitmapAsync(normalizedUrl, cancellationToken);
|
|
||||||
if (remote is not null)
|
|
||||||
{
|
|
||||||
return remote;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return TryDecodeBitmapFromDataUrl(thumbnailDataUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<Bitmap?> TryDownloadBitmapAsync(string imageUrl, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using var request = new HttpRequestMessage(HttpMethod.Get, imageUrl);
|
|
||||||
request.Headers.TryAddWithoutValidation("User-Agent", BrowserUserAgent);
|
|
||||||
request.Headers.TryAddWithoutValidation("Accept", "image/avif,image/webp,image/apng,image/*,*/*;q=0.8");
|
|
||||||
|
|
||||||
using var response = await ImageHttpClient.SendAsync(
|
|
||||||
request,
|
|
||||||
HttpCompletionOption.ResponseHeadersRead,
|
|
||||||
cancellationToken);
|
|
||||||
if (!response.IsSuccessStatusCode)
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
|
|
||||||
var memory = new MemoryStream();
|
|
||||||
await stream.CopyToAsync(memory, cancellationToken);
|
|
||||||
memory.Position = 0;
|
|
||||||
return new Bitmap(memory);
|
|
||||||
}
|
|
||||||
catch (OperationCanceledException)
|
|
||||||
{
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Bitmap? TryDecodeBitmapFromDataUrl(string? dataUrl)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(dataUrl))
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
var trimmed = dataUrl.Trim();
|
|
||||||
var markerIndex = trimmed.IndexOf("base64,", StringComparison.OrdinalIgnoreCase);
|
|
||||||
if (markerIndex < 0 || markerIndex + 7 >= trimmed.Length)
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
var payload = trimmed[(markerIndex + 7)..];
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var bytes = Convert.FromBase64String(payload);
|
|
||||||
return new Bitmap(new MemoryStream(bytes));
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ApplyLoadingState()
|
|
||||||
{
|
|
||||||
_currentSourceUrl = null;
|
|
||||||
SentenceTextBlock.Text = L("dailysentence.widget.loading_sentence", "Loading sentence...");
|
|
||||||
TranslationTextBlock.Text = L("dailysentence.widget.loading_translation", "Loading translation...");
|
|
||||||
SourceTextBlock.Text = L("dailysentence.widget.loading_source", "Youdao Dictionary");
|
|
||||||
StatusTextBlock.Text = L("dailysentence.widget.loading", "Loading...");
|
|
||||||
StatusTextBlock.IsVisible = true;
|
|
||||||
UpdateSourceInteractionState();
|
|
||||||
UpdateAdaptiveLayout();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ApplyFailedState()
|
|
||||||
{
|
|
||||||
_currentSourceUrl = null;
|
|
||||||
SentenceTextBlock.Text = L("dailysentence.widget.fallback_sentence", "No sentence available.");
|
|
||||||
TranslationTextBlock.Text = L("dailysentence.widget.fallback_translation", "Tap refresh and try again.");
|
|
||||||
SourceTextBlock.Text = L("dailysentence.widget.source_default", "Youdao Dictionary");
|
|
||||||
StatusTextBlock.Text = L("dailysentence.widget.fetch_failed", "Sentence fetch failed");
|
|
||||||
StatusTextBlock.IsVisible = true;
|
|
||||||
UpdateSourceInteractionState();
|
|
||||||
UpdateAdaptiveLayout();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void UpdateAdaptiveLayout()
|
|
||||||
{
|
|
||||||
var scale = ResolveScale();
|
|
||||||
var totalWidth = Bounds.Width > 1 ? Bounds.Width : _currentCellSize * BaseWidthCells;
|
|
||||||
var totalHeight = Bounds.Height > 1 ? Bounds.Height : _currentCellSize * BaseHeightCells;
|
|
||||||
|
|
||||||
RootBorder.CornerRadius = new CornerRadius(Math.Clamp(34 * scale, 16, 52));
|
|
||||||
ContentGrid.Margin = new Thickness(
|
|
||||||
Math.Clamp(16 * scale, 8, 28),
|
|
||||||
Math.Clamp(14 * scale, 7, 24),
|
|
||||||
Math.Clamp(16 * scale, 8, 28),
|
|
||||||
Math.Clamp(14 * scale, 7, 24));
|
|
||||||
ContentGrid.RowSpacing = Math.Clamp(8 * scale, 4, 12);
|
|
||||||
|
|
||||||
var refreshSize = Math.Clamp(42 * scale, 24, 54);
|
|
||||||
RefreshButton.Width = refreshSize;
|
|
||||||
RefreshButton.Height = refreshSize;
|
|
||||||
RefreshButton.CornerRadius = new CornerRadius(refreshSize / 2d);
|
|
||||||
RefreshIcon.FontSize = Math.Clamp(21 * scale, 12, 28);
|
|
||||||
|
|
||||||
var innerWidth = Math.Max(100, totalWidth - ContentGrid.Margin.Left - ContentGrid.Margin.Right);
|
|
||||||
var innerHeight = Math.Max(56, totalHeight - ContentGrid.Margin.Top - ContentGrid.Margin.Bottom);
|
|
||||||
|
|
||||||
var topRowHeight = Math.Max(20, innerHeight * 0.22);
|
|
||||||
var bottomRowHeight = Math.Max(14, innerHeight * 0.14);
|
|
||||||
var middleHeight = Math.Max(24, innerHeight - topRowHeight - bottomRowHeight - ContentGrid.RowSpacing * 2);
|
|
||||||
|
|
||||||
var topTextWidth = Math.Max(76, innerWidth - refreshSize - ContentGrid.RowSpacing);
|
|
||||||
var dayWidth = Math.Max(20, topTextWidth * 0.16);
|
|
||||||
var monthYearWidth = Math.Max(48, topTextWidth - dayWidth - 6 * scale);
|
|
||||||
DayTextBlock.MaxWidth = dayWidth;
|
|
||||||
MonthYearTextBlock.MaxWidth = monthYearWidth;
|
|
||||||
|
|
||||||
var dayLayout = FitAdaptiveTextLayout(
|
|
||||||
DayTextBlock.Text,
|
|
||||||
dayWidth,
|
|
||||||
topRowHeight,
|
|
||||||
minLines: 1,
|
|
||||||
maxLines: 1,
|
|
||||||
minFontSize: Math.Clamp(26 * scale, 12, 44),
|
|
||||||
maxFontSize: Math.Clamp(72 * scale, 20, 96),
|
|
||||||
weightCandidates: HeadlineWeightCandidates,
|
|
||||||
lineHeightFactor: 0.94);
|
|
||||||
DayTextBlock.FontSize = dayLayout.FontSize;
|
|
||||||
DayTextBlock.FontWeight = dayLayout.Weight;
|
|
||||||
DayTextBlock.LineHeight = dayLayout.LineHeight;
|
|
||||||
|
|
||||||
var monthLayout = FitAdaptiveTextLayout(
|
|
||||||
MonthYearTextBlock.Text,
|
|
||||||
monthYearWidth,
|
|
||||||
topRowHeight,
|
|
||||||
minLines: 1,
|
|
||||||
maxLines: 1,
|
|
||||||
minFontSize: Math.Clamp(18 * scale, 9, 32),
|
|
||||||
maxFontSize: Math.Clamp(44 * scale, 14, 62),
|
|
||||||
weightCandidates: BodyWeightCandidates,
|
|
||||||
lineHeightFactor: 1.00);
|
|
||||||
MonthYearTextBlock.FontSize = monthLayout.FontSize;
|
|
||||||
MonthYearTextBlock.FontWeight = monthLayout.Weight;
|
|
||||||
MonthYearTextBlock.LineHeight = monthLayout.LineHeight;
|
|
||||||
|
|
||||||
var sentenceLineLimit = innerHeight < _currentCellSize * 1.78 ? 2 : 3;
|
|
||||||
var sentenceHeight = Math.Max(16, middleHeight * 0.66);
|
|
||||||
var translationHeight = Math.Max(14, middleHeight - sentenceHeight - Math.Clamp(8 * scale, 3, 12));
|
|
||||||
|
|
||||||
var sentenceLayout = FitAdaptiveTextLayout(
|
|
||||||
SentenceTextBlock.Text,
|
|
||||||
innerWidth,
|
|
||||||
sentenceHeight,
|
|
||||||
minLines: 1,
|
|
||||||
maxLines: sentenceLineLimit,
|
|
||||||
minFontSize: Math.Clamp(23 * scale, 10, 42),
|
|
||||||
maxFontSize: Math.Clamp(58 * scale, 18, 80),
|
|
||||||
weightCandidates: HeadlineWeightCandidates,
|
|
||||||
lineHeightFactor: 1.06);
|
|
||||||
SentenceTextBlock.MaxWidth = innerWidth;
|
|
||||||
SentenceTextBlock.MaxLines = sentenceLayout.MaxLines;
|
|
||||||
SentenceTextBlock.FontSize = sentenceLayout.FontSize;
|
|
||||||
SentenceTextBlock.FontWeight = sentenceLayout.Weight;
|
|
||||||
SentenceTextBlock.LineHeight = sentenceLayout.LineHeight;
|
|
||||||
|
|
||||||
var translationLayout = FitAdaptiveTextLayout(
|
|
||||||
TranslationTextBlock.Text,
|
|
||||||
innerWidth,
|
|
||||||
translationHeight,
|
|
||||||
minLines: 1,
|
|
||||||
maxLines: 2,
|
|
||||||
minFontSize: Math.Clamp(16 * scale, 8.5, 30),
|
|
||||||
maxFontSize: Math.Clamp(40 * scale, 12, 54),
|
|
||||||
weightCandidates: BodyWeightCandidates,
|
|
||||||
lineHeightFactor: 1.06);
|
|
||||||
TranslationTextBlock.MaxWidth = innerWidth;
|
|
||||||
TranslationTextBlock.MaxLines = translationLayout.MaxLines;
|
|
||||||
TranslationTextBlock.FontSize = translationLayout.FontSize;
|
|
||||||
TranslationTextBlock.FontWeight = translationLayout.Weight;
|
|
||||||
TranslationTextBlock.LineHeight = translationLayout.LineHeight;
|
|
||||||
|
|
||||||
var sourceLayout = FitAdaptiveTextLayout(
|
|
||||||
SourceTextBlock.Text,
|
|
||||||
innerWidth,
|
|
||||||
bottomRowHeight,
|
|
||||||
minLines: 1,
|
|
||||||
maxLines: 1,
|
|
||||||
minFontSize: Math.Clamp(14 * scale, 8, 26),
|
|
||||||
maxFontSize: Math.Clamp(30 * scale, 10, 40),
|
|
||||||
weightCandidates: MetaWeightCandidates,
|
|
||||||
lineHeightFactor: 1.02);
|
|
||||||
SourceTextBlock.MaxWidth = innerWidth;
|
|
||||||
SourceTextBlock.FontSize = sourceLayout.FontSize;
|
|
||||||
SourceTextBlock.FontWeight = sourceLayout.Weight;
|
|
||||||
SourceTextBlock.LineHeight = sourceLayout.LineHeight;
|
|
||||||
|
|
||||||
StatusTextBlock.FontSize = Math.Clamp(16 * scale, 9, 24);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void UpdateRefreshButtonState()
|
|
||||||
{
|
|
||||||
RefreshButton.IsEnabled = !_isRefreshing;
|
|
||||||
RefreshButton.Opacity = _isAttached ? 1.0 : 0.85;
|
|
||||||
RefreshIcon.Opacity = _isRefreshing ? 0.56 : 1.0;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void UpdateSourceInteractionState()
|
|
||||||
{
|
|
||||||
var enabled = !string.IsNullOrWhiteSpace(_currentSourceUrl);
|
|
||||||
SourceTextBlock.IsHitTestVisible = enabled;
|
|
||||||
SourceTextBlock.Cursor = enabled
|
|
||||||
? new Cursor(StandardCursorType.Hand)
|
|
||||||
: new Cursor(StandardCursorType.Arrow);
|
|
||||||
SourceTextBlock.Opacity = enabled ? 1.0 : 0.86;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void UpdateLanguageCode()
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var snapshot = _settingsService.Load();
|
|
||||||
_languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode);
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
_languageCode = "zh-CN";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void UpdateDateText()
|
|
||||||
{
|
|
||||||
var now = DateTime.Now;
|
|
||||||
var culture = ResolveCulture();
|
|
||||||
DayTextBlock.Text = now.Day.ToString(CultureInfo.InvariantCulture);
|
|
||||||
MonthYearTextBlock.Text = now.ToString("MMMM yyyy", culture);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void SetBackgroundBitmap(Bitmap? bitmap)
|
|
||||||
{
|
|
||||||
if (ReferenceEquals(BackgroundImage.Source, _backgroundBitmap))
|
|
||||||
{
|
|
||||||
BackgroundImage.Source = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
_backgroundBitmap?.Dispose();
|
|
||||||
_backgroundBitmap = bitmap;
|
|
||||||
BackgroundImage.Source = bitmap;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void DisposeBackgroundBitmap()
|
|
||||||
{
|
|
||||||
SetBackgroundBitmap(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void TryOpenSourceUrl()
|
|
||||||
{
|
|
||||||
var normalized = NormalizeHttpUrl(_currentSourceUrl);
|
|
||||||
if (string.IsNullOrWhiteSpace(normalized))
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var startInfo = new ProcessStartInfo
|
|
||||||
{
|
|
||||||
FileName = normalized,
|
|
||||||
UseShellExecute = true
|
|
||||||
};
|
|
||||||
Process.Start(startInfo);
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
// Ignore malformed URLs or shell launch failures.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void CancelRefreshRequest()
|
|
||||||
{
|
|
||||||
var cts = Interlocked.Exchange(ref _refreshCts, null);
|
|
||||||
if (cts is null)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
cts.Cancel();
|
|
||||||
cts.Dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
private string L(string key, string fallback)
|
|
||||||
{
|
|
||||||
return _localizationService.GetString(_languageCode, key, fallback);
|
|
||||||
}
|
|
||||||
|
|
||||||
private CultureInfo ResolveCulture()
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
return CultureInfo.GetCultureInfo(_languageCode);
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
return CultureInfo.InvariantCulture;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private double ResolveScale()
|
|
||||||
{
|
|
||||||
var cellScale = Math.Clamp(_currentCellSize / BaseCellSize, 0.56, 2.0);
|
|
||||||
var widthScale = Bounds.Width > 1
|
|
||||||
? Math.Clamp(Bounds.Width / Math.Max(1, _currentCellSize * BaseWidthCells), 0.56, 2.0)
|
|
||||||
: 1;
|
|
||||||
var heightScale = Bounds.Height > 1
|
|
||||||
? Math.Clamp(Bounds.Height / Math.Max(1, _currentCellSize * BaseHeightCells), 0.56, 2.0)
|
|
||||||
: 1;
|
|
||||||
return Math.Clamp(Math.Min(cellScale, Math.Min(widthScale, heightScale)), 0.56, 2.0);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string NormalizeCompactText(string? text)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(text))
|
|
||||||
{
|
|
||||||
return string.Empty;
|
|
||||||
}
|
|
||||||
|
|
||||||
return MultiWhitespaceRegex.Replace(text.Trim(), " ");
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string? NormalizeHttpUrl(string? rawUrl)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(rawUrl))
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
var candidate = rawUrl.Trim();
|
|
||||||
if (!Uri.TryCreate(candidate, UriKind.Absolute, out var uri))
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!string.Equals(uri.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) &&
|
|
||||||
!string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return uri.ToString();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static AdaptiveTextLayout FitAdaptiveTextLayout(
|
|
||||||
string? text,
|
|
||||||
double maxWidth,
|
|
||||||
double maxHeight,
|
|
||||||
int minLines,
|
|
||||||
int maxLines,
|
|
||||||
double minFontSize,
|
|
||||||
double maxFontSize,
|
|
||||||
FontWeight[] weightCandidates,
|
|
||||||
double lineHeightFactor)
|
|
||||||
{
|
|
||||||
var content = string.IsNullOrWhiteSpace(text) ? " " : text.Trim();
|
|
||||||
var safeMinLines = Math.Max(1, minLines);
|
|
||||||
var safeMaxLines = Math.Max(safeMinLines, maxLines);
|
|
||||||
var linesByHeight = ResolveMaxLinesByHeight(maxHeight, minFontSize, lineHeightFactor, safeMinLines, safeMaxLines);
|
|
||||||
|
|
||||||
var candidates = weightCandidates is { Length: > 0 }
|
|
||||||
? weightCandidates
|
|
||||||
: [FontWeight.Normal];
|
|
||||||
|
|
||||||
AdaptiveTextLayout? best = null;
|
|
||||||
foreach (var weight in candidates)
|
|
||||||
{
|
|
||||||
for (var lineLimit = linesByHeight; lineLimit >= safeMinLines; lineLimit--)
|
|
||||||
{
|
|
||||||
var fontSize = FitFontSize(
|
|
||||||
content,
|
|
||||||
maxWidth,
|
|
||||||
maxHeight,
|
|
||||||
lineLimit,
|
|
||||||
minFontSize,
|
|
||||||
maxFontSize,
|
|
||||||
weight,
|
|
||||||
lineHeightFactor);
|
|
||||||
var lineHeight = fontSize * lineHeightFactor;
|
|
||||||
var measuredSize = MeasureTextSize(content, fontSize, weight, Math.Max(1, maxWidth), lineHeight);
|
|
||||||
var measuredLineCount = Math.Max(1, (int)Math.Ceiling(measuredSize.Height / Math.Max(1, lineHeight)));
|
|
||||||
var overflowLines = Math.Max(0, measuredLineCount - lineLimit);
|
|
||||||
var overflowHeight = Math.Max(0, measuredSize.Height - maxHeight);
|
|
||||||
var overflowScore = overflowLines * 1000d + overflowHeight;
|
|
||||||
var fitsCompletely = overflowLines == 0 && overflowHeight <= 0.6;
|
|
||||||
var candidate = new AdaptiveTextLayout(fontSize, weight, lineLimit, lineHeight, overflowScore, fitsCompletely);
|
|
||||||
|
|
||||||
if (best is null || IsBetterAdaptiveTextCandidate(candidate, best.Value))
|
|
||||||
{
|
|
||||||
best = candidate;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (best is not null)
|
|
||||||
{
|
|
||||||
return best.Value;
|
|
||||||
}
|
|
||||||
|
|
||||||
var fallbackFontSize = Math.Max(6, minFontSize);
|
|
||||||
return new AdaptiveTextLayout(
|
|
||||||
fallbackFontSize,
|
|
||||||
FontWeight.Normal,
|
|
||||||
safeMinLines,
|
|
||||||
fallbackFontSize * lineHeightFactor,
|
|
||||||
double.MaxValue,
|
|
||||||
fitsCompletely: false);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static bool IsBetterAdaptiveTextCandidate(AdaptiveTextLayout candidate, AdaptiveTextLayout best)
|
|
||||||
{
|
|
||||||
if (candidate.FitsCompletely && !best.FitsCompletely)
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!candidate.FitsCompletely && best.FitsCompletely)
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (candidate.FitsCompletely && best.FitsCompletely)
|
|
||||||
{
|
|
||||||
if (candidate.FontSize > best.FontSize + 0.12)
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Math.Abs(candidate.FontSize - best.FontSize) <= 0.12 && candidate.MaxLines < best.MaxLines)
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (candidate.OverflowScore < best.OverflowScore - 0.2)
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Math.Abs(candidate.OverflowScore - best.OverflowScore) <= 0.2 &&
|
|
||||||
candidate.FontSize > best.FontSize + 0.12)
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Math.Abs(candidate.OverflowScore - best.OverflowScore) <= 0.2 &&
|
|
||||||
Math.Abs(candidate.FontSize - best.FontSize) <= 0.12 &&
|
|
||||||
candidate.MaxLines > best.MaxLines)
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static int ResolveMaxLinesByHeight(
|
|
||||||
double maxHeight,
|
|
||||||
double minFontSize,
|
|
||||||
double lineHeightFactor,
|
|
||||||
int minLines,
|
|
||||||
int maxLines)
|
|
||||||
{
|
|
||||||
var safeMinLines = Math.Max(1, minLines);
|
|
||||||
var safeMaxLines = Math.Max(safeMinLines, maxLines);
|
|
||||||
var lineHeight = Math.Max(1, Math.Max(6, minFontSize) * lineHeightFactor);
|
|
||||||
var maxHeightWithTolerance = Math.Max(1, maxHeight + 0.6);
|
|
||||||
var linesByHeight = (int)Math.Floor(maxHeightWithTolerance / lineHeight);
|
|
||||||
return Math.Clamp(linesByHeight, safeMinLines, safeMaxLines);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static double FitFontSize(
|
|
||||||
string? text,
|
|
||||||
double maxWidth,
|
|
||||||
double maxHeight,
|
|
||||||
int maxLines,
|
|
||||||
double minFontSize,
|
|
||||||
double maxFontSize,
|
|
||||||
FontWeight weight,
|
|
||||||
double lineHeightFactor)
|
|
||||||
{
|
|
||||||
var content = string.IsNullOrWhiteSpace(text) ? " " : text.Trim();
|
|
||||||
var min = Math.Max(6, minFontSize);
|
|
||||||
var max = Math.Max(min, maxFontSize);
|
|
||||||
var low = min;
|
|
||||||
var high = max;
|
|
||||||
var best = min;
|
|
||||||
|
|
||||||
for (var i = 0; i < 18; i++)
|
|
||||||
{
|
|
||||||
var candidate = (low + high) / 2d;
|
|
||||||
var lineHeight = candidate * lineHeightFactor;
|
|
||||||
var size = MeasureTextSize(content, candidate, weight, Math.Max(1, maxWidth), lineHeight);
|
|
||||||
var lineCount = Math.Max(1, (int)Math.Ceiling(size.Height / Math.Max(1, lineHeight)));
|
|
||||||
var fits = size.Height <= maxHeight + 0.6 && lineCount <= Math.Max(1, maxLines);
|
|
||||||
|
|
||||||
if (fits)
|
|
||||||
{
|
|
||||||
best = candidate;
|
|
||||||
low = candidate;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
high = candidate;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return best;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Size MeasureTextSize(string text, double fontSize, FontWeight weight, double maxWidth, double lineHeight)
|
|
||||||
{
|
|
||||||
var probe = new TextBlock
|
|
||||||
{
|
|
||||||
Text = text,
|
|
||||||
FontFamily = MiSansFontFamily,
|
|
||||||
FontSize = fontSize,
|
|
||||||
FontWeight = weight,
|
|
||||||
TextWrapping = TextWrapping.Wrap,
|
|
||||||
LineHeight = lineHeight
|
|
||||||
};
|
|
||||||
|
|
||||||
probe.Measure(new Size(Math.Max(1, maxWidth), double.PositiveInfinity));
|
|
||||||
return probe.DesiredSize;
|
|
||||||
}
|
|
||||||
|
|
||||||
private readonly struct AdaptiveTextLayout
|
|
||||||
{
|
|
||||||
public AdaptiveTextLayout(
|
|
||||||
double fontSize,
|
|
||||||
FontWeight weight,
|
|
||||||
int maxLines,
|
|
||||||
double lineHeight,
|
|
||||||
double overflowScore,
|
|
||||||
bool fitsCompletely)
|
|
||||||
{
|
|
||||||
FontSize = fontSize;
|
|
||||||
Weight = weight;
|
|
||||||
MaxLines = Math.Max(1, maxLines);
|
|
||||||
LineHeight = lineHeight;
|
|
||||||
OverflowScore = overflowScore;
|
|
||||||
FitsCompletely = fitsCompletely;
|
|
||||||
}
|
|
||||||
|
|
||||||
public double FontSize { get; }
|
|
||||||
|
|
||||||
public FontWeight Weight { get; }
|
|
||||||
|
|
||||||
public int MaxLines { get; }
|
|
||||||
|
|
||||||
public double LineHeight { get; }
|
|
||||||
|
|
||||||
public double OverflowScore { get; }
|
|
||||||
|
|
||||||
public bool FitsCompletely { get; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -41,7 +41,8 @@ public sealed class DesktopComponentRuntimeDescriptor
|
|||||||
double cellSize,
|
double cellSize,
|
||||||
TimeZoneService timeZoneService,
|
TimeZoneService timeZoneService,
|
||||||
IWeatherInfoService weatherInfoService,
|
IWeatherInfoService weatherInfoService,
|
||||||
IRecommendationInfoService recommendationInfoService)
|
IRecommendationInfoService recommendationInfoService,
|
||||||
|
ICalculatorDataService calculatorDataService)
|
||||||
{
|
{
|
||||||
var control = _controlFactory();
|
var control = _controlFactory();
|
||||||
if (control is IDesktopComponentWidget sizedComponent)
|
if (control is IDesktopComponentWidget sizedComponent)
|
||||||
@@ -64,6 +65,11 @@ public sealed class DesktopComponentRuntimeDescriptor
|
|||||||
recommendationInfoAwareComponent.SetRecommendationInfoService(recommendationInfoService);
|
recommendationInfoAwareComponent.SetRecommendationInfoService(recommendationInfoService);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (control is ICalculatorInfoAwareComponentWidget calculatorInfoAwareComponent)
|
||||||
|
{
|
||||||
|
calculatorInfoAwareComponent.SetCalculatorDataService(calculatorDataService);
|
||||||
|
}
|
||||||
|
|
||||||
return control;
|
return control;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,16 +240,21 @@ public sealed class DesktopComponentRuntimeRegistry
|
|||||||
"component.daily_word",
|
"component.daily_word",
|
||||||
() => new DailyWordWidget(),
|
() => new DailyWordWidget(),
|
||||||
cellSize => Math.Clamp(cellSize * 0.34, 14, 30)),
|
cellSize => Math.Clamp(cellSize * 0.34, 14, 30)),
|
||||||
new DesktopComponentRuntimeRegistration(
|
|
||||||
BuiltInComponentIds.DesktopDailySentence,
|
|
||||||
"component.daily_sentence",
|
|
||||||
() => new DailySentenceWidget(),
|
|
||||||
cellSize => Math.Clamp(cellSize * 0.34, 14, 30)),
|
|
||||||
new DesktopComponentRuntimeRegistration(
|
new DesktopComponentRuntimeRegistration(
|
||||||
BuiltInComponentIds.DesktopCnrDailyNews,
|
BuiltInComponentIds.DesktopCnrDailyNews,
|
||||||
"component.cnr_daily_news",
|
"component.cnr_daily_news",
|
||||||
() => new CnrDailyNewsWidget(),
|
() => new CnrDailyNewsWidget(),
|
||||||
cellSize => Math.Clamp(cellSize * 0.34, 14, 30)),
|
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(
|
new DesktopComponentRuntimeRegistration(
|
||||||
BuiltInComponentIds.DesktopWhiteboard,
|
BuiltInComponentIds.DesktopWhiteboard,
|
||||||
"component.whiteboard",
|
"component.whiteboard",
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,11 @@ public interface IRecommendationInfoAwareComponentWidget
|
|||||||
void SetRecommendationInfoService(IRecommendationInfoService recommendationInfoService);
|
void SetRecommendationInfoService(IRecommendationInfoService recommendationInfoService);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public interface ICalculatorInfoAwareComponentWidget
|
||||||
|
{
|
||||||
|
void SetCalculatorDataService(ICalculatorDataService calculatorDataService);
|
||||||
|
}
|
||||||
|
|
||||||
public interface IDesktopPageVisibilityAwareComponentWidget
|
public interface IDesktopPageVisibilityAwareComponentWidget
|
||||||
{
|
{
|
||||||
void SetDesktopPageContext(bool isOnActivePage, bool isEditMode);
|
void SetDesktopPageContext(bool isOnActivePage, bool isEditMode);
|
||||||
|
|||||||
@@ -725,9 +725,16 @@ public partial class MainWindow
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (placement.ComponentId == BuiltInComponentIds.DesktopCnrDailyNews)
|
||||||
|
{
|
||||||
|
OpenCnrDailyNewsComponentSettings();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (placement.ComponentId == BuiltInComponentIds.DesktopStudyEnvironment)
|
if (placement.ComponentId == BuiltInComponentIds.DesktopStudyEnvironment)
|
||||||
{
|
{
|
||||||
OpenStudyEnvironmentComponentSettings();
|
OpenStudyEnvironmentComponentSettings();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -827,6 +834,22 @@ public partial class MainWindow
|
|||||||
ComponentSettingsWindow.Opacity = 1;
|
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)
|
private void OnClassScheduleSettingsChanged(object? sender, EventArgs e)
|
||||||
{
|
{
|
||||||
if (_selectedDesktopComponentHost is null)
|
if (_selectedDesktopComponentHost is null)
|
||||||
@@ -931,6 +954,30 @@ public partial class MainWindow
|
|||||||
PersistSettings();
|
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()
|
private void CloseComponentSettingsWindow()
|
||||||
{
|
{
|
||||||
if (ComponentSettingsWindow is null)
|
if (ComponentSettingsWindow is null)
|
||||||
@@ -963,6 +1010,11 @@ public partial class MainWindow
|
|||||||
worldClockSettingsWindow.SettingsChanged -= OnWorldClockSettingsChanged;
|
worldClockSettingsWindow.SettingsChanged -= OnWorldClockSettingsChanged;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (ComponentSettingsContentHost?.Content is CnrDailyNewsSettingsWindow cnrDailyNewsSettingsWindow)
|
||||||
|
{
|
||||||
|
cnrDailyNewsSettingsWindow.SettingsChanged -= OnCnrDailyNewsSettingsChanged;
|
||||||
|
}
|
||||||
|
|
||||||
ComponentSettingsWindow.Opacity = 0;
|
ComponentSettingsWindow.Opacity = 0;
|
||||||
|
|
||||||
DispatcherTimer.RunOnce(() =>
|
DispatcherTimer.RunOnce(() =>
|
||||||
@@ -1366,14 +1418,30 @@ public partial class MainWindow
|
|||||||
new ComponentScaleRule(WidthUnit: 2, HeightUnit: 1, MinScale: 2));
|
new ComponentScaleRule(WidthUnit: 2, HeightUnit: 1, MinScale: 2));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (string.Equals(componentId, BuiltInComponentIds.DesktopDailySentence, StringComparison.OrdinalIgnoreCase))
|
if (string.Equals(componentId, BuiltInComponentIds.DesktopCnrDailyNews, StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
// Keep daily sentence widget at a 2:1 ratio: 4x2, 6x3, 8x4...
|
// Keep CNR widget at a 2:1 ratio: 4x2, 6x3, 8x4...
|
||||||
return SnapSpanToScaleRules(
|
return SnapSpanToScaleRules(
|
||||||
span,
|
span,
|
||||||
new ComponentScaleRule(WidthUnit: 2, HeightUnit: 1, MinScale: 2));
|
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))
|
if (string.Equals(componentId, BuiltInComponentIds.DesktopStudyNoiseCurve, StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
// Keep noise curve widget in a 2:1 ratio with minimum 4x2.
|
// Keep noise curve widget in a 2:1 ratio with minimum 4x2.
|
||||||
@@ -1611,7 +1679,8 @@ public partial class MainWindow
|
|||||||
_currentDesktopCellSize,
|
_currentDesktopCellSize,
|
||||||
_timeZoneService,
|
_timeZoneService,
|
||||||
_weatherDataService,
|
_weatherDataService,
|
||||||
_recommendationInfoService);
|
_recommendationInfoService,
|
||||||
|
_calculatorDataService);
|
||||||
component.Classes.Add(DesktopComponentClass);
|
component.Classes.Add(DesktopComponentClass);
|
||||||
return component;
|
return component;
|
||||||
}
|
}
|
||||||
@@ -2537,6 +2606,11 @@ public partial class MainWindow
|
|||||||
return Symbol.Apps;
|
return Symbol.Apps;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (string.Equals(categoryId, "Calculator", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return Symbol.Calculator;
|
||||||
|
}
|
||||||
|
|
||||||
if (string.Equals(categoryId, "Study", StringComparison.OrdinalIgnoreCase))
|
if (string.Equals(categoryId, "Study", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
return Symbol.Apps;
|
return Symbol.Apps;
|
||||||
@@ -2577,6 +2651,11 @@ public partial class MainWindow
|
|||||||
return L("component_category.info", "Info");
|
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))
|
if (string.Equals(categoryId, "Study", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
return L("component_category.study", "Study");
|
return L("component_category.study", "Study");
|
||||||
@@ -2745,7 +2824,8 @@ public partial class MainWindow
|
|||||||
renderCellSize,
|
renderCellSize,
|
||||||
_timeZoneService,
|
_timeZoneService,
|
||||||
_weatherDataService,
|
_weatherDataService,
|
||||||
_recommendationInfoService);
|
_recommendationInfoService,
|
||||||
|
_calculatorDataService);
|
||||||
|
|
||||||
var previewSurface = new Border
|
var previewSurface = new Border
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ public partial class MainWindow : Window
|
|||||||
private readonly GitHubReleaseUpdateService _releaseUpdateService = new("wwiinnddyy", "LanMountainDesktop");
|
private readonly GitHubReleaseUpdateService _releaseUpdateService = new("wwiinnddyy", "LanMountainDesktop");
|
||||||
private readonly IWeatherDataService _weatherDataService = new XiaomiWeatherService();
|
private readonly IWeatherDataService _weatherDataService = new XiaomiWeatherService();
|
||||||
private readonly IRecommendationInfoService _recommendationInfoService = new RecommendationDataService();
|
private readonly IRecommendationInfoService _recommendationInfoService = new RecommendationDataService();
|
||||||
|
private readonly ICalculatorDataService _calculatorDataService = new CalculatorDataService();
|
||||||
private readonly ComponentRegistry _componentRegistry = ComponentRegistry
|
private readonly ComponentRegistry _componentRegistry = ComponentRegistry
|
||||||
.CreateDefault()
|
.CreateDefault()
|
||||||
.RegisterExtensions(
|
.RegisterExtensions(
|
||||||
|
|||||||
Reference in New Issue
Block a user