From 1f509959a9c84be4a91821b5ba13d3c648134602 Mon Sep 17 00:00:00 2001 From: lincube Date: Fri, 6 Mar 2026 22:24:59 +0800 Subject: [PATCH] 0.4.8 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 百度热搜组件、凤凰新闻组件。 --- .../ComponentSystem/BuiltInComponentIds.cs | 2 + .../ComponentSystem/ComponentRegistry.cs | 18 + LanMountainDesktop/Localization/en-US.json | 37 + LanMountainDesktop/Localization/zh-CN.json | 37 + .../Models/BaiduHotSearchSourceTypes.cs | 19 + .../Models/ComponentSettingsSnapshot.cs | 12 + .../Models/IfengNewsChannelTypes.cs | 32 + .../Models/RecommendationDataModels.cs | 12 + .../Services/ComponentSettingsService.cs | 33 + .../Services/IRecommendationDataService.cs | 52 ++ .../Services/RecommendationDataService.cs | 585 ++++++++++++++++ .../BaiduHotSearchSettingsWindow.axaml | 110 +++ .../BaiduHotSearchSettingsWindow.axaml.cs | 193 ++++++ .../Components/BaiduHotSearchWidget.axaml | 189 +++++ .../Components/BaiduHotSearchWidget.axaml.cs | 558 +++++++++++++++ .../DesktopComponentRuntimeRegistry.cs | 10 + .../Components/IfengNewsSettingsWindow.axaml | 113 +++ .../IfengNewsSettingsWindow.axaml.cs | 194 ++++++ .../Views/Components/IfengNewsWidget.axaml | 196 ++++++ .../Views/Components/IfengNewsWidget.axaml.cs | 647 ++++++++++++++++++ .../Views/MainWindow.ComponentSystem.cs | 114 +++ .../Views/MainWindow.Localization.cs | 13 + .../Views/MainWindow.Settings.cs | 4 +- LanMountainDesktop/Views/MainWindow.axaml | 37 + LanMountainDesktop/Views/MainWindow.axaml.cs | 2 +- 25 files changed, 3217 insertions(+), 2 deletions(-) create mode 100644 LanMountainDesktop/Models/BaiduHotSearchSourceTypes.cs create mode 100644 LanMountainDesktop/Models/IfengNewsChannelTypes.cs create mode 100644 LanMountainDesktop/Views/Components/BaiduHotSearchSettingsWindow.axaml create mode 100644 LanMountainDesktop/Views/Components/BaiduHotSearchSettingsWindow.axaml.cs create mode 100644 LanMountainDesktop/Views/Components/BaiduHotSearchWidget.axaml create mode 100644 LanMountainDesktop/Views/Components/BaiduHotSearchWidget.axaml.cs create mode 100644 LanMountainDesktop/Views/Components/IfengNewsSettingsWindow.axaml create mode 100644 LanMountainDesktop/Views/Components/IfengNewsSettingsWindow.axaml.cs create mode 100644 LanMountainDesktop/Views/Components/IfengNewsWidget.axaml create mode 100644 LanMountainDesktop/Views/Components/IfengNewsWidget.axaml.cs diff --git a/LanMountainDesktop/ComponentSystem/BuiltInComponentIds.cs b/LanMountainDesktop/ComponentSystem/BuiltInComponentIds.cs index 6c3ea96..b5dd046 100644 --- a/LanMountainDesktop/ComponentSystem/BuiltInComponentIds.cs +++ b/LanMountainDesktop/ComponentSystem/BuiltInComponentIds.cs @@ -32,7 +32,9 @@ public static class BuiltInComponentIds public const string DesktopDailyWord = "DesktopDailyWord"; public const string DesktopDailyWord2x2 = "DesktopDailyWord2x2"; public const string DesktopCnrDailyNews = "DesktopCnrDailyNews"; + public const string DesktopIfengNews = "DesktopIfengNews"; public const string DesktopBilibiliHotSearch = "DesktopBilibiliHotSearch"; + public const string DesktopBaiduHotSearch = "DesktopBaiduHotSearch"; public const string DesktopStcn24Forum = "DesktopStcn24Forum"; public const string DesktopExchangeRateCalculator = "DesktopExchangeRateCalculator"; public const string DesktopWhiteboard = "DesktopWhiteboard"; diff --git a/LanMountainDesktop/ComponentSystem/ComponentRegistry.cs b/LanMountainDesktop/ComponentSystem/ComponentRegistry.cs index 89d291f..9bc1c40 100644 --- a/LanMountainDesktop/ComponentSystem/ComponentRegistry.cs +++ b/LanMountainDesktop/ComponentSystem/ComponentRegistry.cs @@ -252,6 +252,15 @@ public sealed class ComponentRegistry MinHeightCells: 2, AllowStatusBarPlacement: false, AllowDesktopPlacement: true), + new DesktopComponentDefinition( + BuiltInComponentIds.DesktopIfengNews, + "iFeng News", + "News", + "Info", + MinWidthCells: 4, + MinHeightCells: 4, + AllowStatusBarPlacement: false, + AllowDesktopPlacement: true), new DesktopComponentDefinition( BuiltInComponentIds.DesktopBilibiliHotSearch, "Bilibili Hot Search", @@ -261,6 +270,15 @@ public sealed class ComponentRegistry MinHeightCells: 2, AllowStatusBarPlacement: false, AllowDesktopPlacement: true), + new DesktopComponentDefinition( + BuiltInComponentIds.DesktopBaiduHotSearch, + "Baidu Hot Search", + "News", + "Info", + MinWidthCells: 4, + MinHeightCells: 2, + AllowStatusBarPlacement: false, + AllowDesktopPlacement: true), new DesktopComponentDefinition( BuiltInComponentIds.DesktopStcn24Forum, "STCN 24", diff --git a/LanMountainDesktop/Localization/en-US.json b/LanMountainDesktop/Localization/en-US.json index 6488123..adfd7c3 100644 --- a/LanMountainDesktop/Localization/en-US.json +++ b/LanMountainDesktop/Localization/en-US.json @@ -14,6 +14,7 @@ "settings.nav.region": "Region", "settings.nav.update": "Update", "settings.nav.launcher": "App Launcher", + "settings.nav.plugins": "Plugins", "settings.nav.about": "About", "settings.wallpaper.title": "Wallpaper", "settings.wallpaper.description": "Pick an image or video to apply as the app window wallpaper immediately.", @@ -263,6 +264,11 @@ "settings.launcher.hidden_type_folder": "Folder", "settings.launcher.hidden_type_shortcut": "Shortcut", "settings.launcher.restore_button": "Show Again", + "settings.plugins.title": "Plugins", + "settings.plugins.runtime_header": "Plugin Runtime", + "settings.plugins.runtime_desc": "Manage plugin loading and backend isolation.", + "settings.plugins.runtime_hint": "This page will host installed plugin management, permission review, and sandboxed backend runtime controls.", + "settings.plugins.runtime_status": "Plugin management UI is not connected yet. Next step is wiring the loader, permissions, and worker isolation state into this panel.", "button.component_library": "Edit Desktop", "tooltip.component_library": "Edit Desktop", "component_library.title": "Widgets", @@ -297,7 +303,9 @@ "component.daily_word": "Daily Word", "component.daily_word_2x2": "Daily Word 2x2", "component.cnr_daily_news": "CNR Headlines", + "component.ifeng_news": "iFeng News", "component.bilibili_hot_search": "Bilibili Hot Search", + "component.baidu_hot_search": "Baidu Hot Search", "component.stcn24_forum": "STCN 24", "component.exchange_rate_converter": "Exchange Rate Converter", "component.whiteboard": "Blackboard (Portrait)", @@ -361,6 +369,18 @@ "bilihot.widget.fetch_failed": "Hot search fetch failed", "bilihot.widget.fallback_item": "No hot search data", "bilihot.widget.more_hot": "More hot search", + "baiduhot.widget.brand": "Baidu Hot Search", + "baiduhot.widget.loading": "Loading...", + "baiduhot.widget.loading_item": "Loading...", + "baiduhot.widget.fetch_failed": "Hot search fetch failed", + "baiduhot.widget.fallback_item": "No hot search data", + "baiduhot.widget.refresh_tooltip": "Refresh", + "ifeng.widget.brand": "iFeng News", + "ifeng.widget.loading": "Loading...", + "ifeng.widget.loading_item": "Loading...", + "ifeng.widget.fetch_failed": "News fetch failed", + "ifeng.widget.fallback_item": "No news data", + "ifeng.widget.refresh_tooltip": "Refresh", "dailyword.settings.title": "Daily word settings", "dailyword.settings.desc": "Configure auto refresh and refresh interval.", "dailyword.settings.auto_refresh_label": "Auto refresh", @@ -371,6 +391,23 @@ "bilihot.settings.auto_refresh_label": "Auto refresh", "bilihot.settings.auto_refresh_enabled": "Enable auto refresh", "bilihot.settings.frequency_label": "Refresh interval", + "baiduhot.settings.title": "Baidu hot search settings", + "baiduhot.settings.desc": "Configure source, auto refresh and refresh interval.", + "baiduhot.settings.source_label": "Data source", + "baiduhot.settings.source_official": "Official Source", + "baiduhot.settings.source_rss": "Third-party RSS", + "baiduhot.settings.auto_refresh_label": "Auto refresh", + "baiduhot.settings.auto_refresh_enabled": "Enable auto refresh", + "baiduhot.settings.frequency_label": "Refresh interval", + "ifeng.settings.title": "iFeng news settings", + "ifeng.settings.desc": "Configure channel, auto refresh and refresh interval.", + "ifeng.settings.channel_label": "News channel", + "ifeng.settings.channel_comprehensive": "Comprehensive", + "ifeng.settings.channel_mainland": "China Mainland", + "ifeng.settings.channel_taiwan": "Taiwan", + "ifeng.settings.auto_refresh_label": "Auto refresh", + "ifeng.settings.auto_refresh_enabled": "Enable auto refresh", + "ifeng.settings.frequency_label": "Refresh interval", "refresh.frequency.5m": "5 minutes", "refresh.frequency.10m": "10 minutes", "refresh.frequency.12m": "12 minutes", diff --git a/LanMountainDesktop/Localization/zh-CN.json b/LanMountainDesktop/Localization/zh-CN.json index 627bf0a..d3f546b 100644 --- a/LanMountainDesktop/Localization/zh-CN.json +++ b/LanMountainDesktop/Localization/zh-CN.json @@ -14,6 +14,7 @@ "settings.nav.region": "地区", "settings.nav.update": "更新", "settings.nav.launcher": "应用启动台", + "settings.nav.plugins": "插件", "settings.nav.about": "关于", "settings.wallpaper.title": "壁纸", "settings.wallpaper.description": "选择图片或视频后可立即设为应用窗口壁纸。", @@ -263,6 +264,11 @@ "settings.launcher.hidden_type_folder": "文件夹", "settings.launcher.hidden_type_shortcut": "快捷方式", "settings.launcher.restore_button": "重新显示", + "settings.plugins.title": "插件", + "settings.plugins.runtime_header": "插件运行时", + "settings.plugins.runtime_desc": "管理插件加载与后端隔离运行。", + "settings.plugins.runtime_hint": "这里将承载已安装插件、权限审查和沙盒后端运行时控制。", + "settings.plugins.runtime_status": "插件管理界面尚未接入实际数据。下一步是把加载器、权限和 worker 隔离状态接到这里。", "button.component_library": "桌面编辑", "tooltip.component_library": "桌面编辑", "component_library.title": "桌面编辑", @@ -297,7 +303,9 @@ "component.daily_word": "每日单词", "component.daily_word_2x2": "每日单词 2x2", "component.cnr_daily_news": "央广网头条", + "component.ifeng_news": "凤凰网新闻", "component.bilibili_hot_search": "B站热搜", + "component.baidu_hot_search": "百度热搜", "component.stcn24_forum": "STCN 24", "component.exchange_rate_converter": "汇率换算", "component.whiteboard": "竖向小黑板", @@ -361,6 +369,18 @@ "bilihot.widget.fetch_failed": "热搜获取失败", "bilihot.widget.fallback_item": "暂无热搜", "bilihot.widget.more_hot": "更多热搜", + "baiduhot.widget.brand": "百度热搜", + "baiduhot.widget.loading": "加载中...", + "baiduhot.widget.loading_item": "加载中...", + "baiduhot.widget.fetch_failed": "热搜获取失败", + "baiduhot.widget.fallback_item": "暂无热搜", + "baiduhot.widget.refresh_tooltip": "刷新", + "ifeng.widget.brand": "凤凰网新闻", + "ifeng.widget.loading": "加载中...", + "ifeng.widget.loading_item": "加载中...", + "ifeng.widget.fetch_failed": "新闻获取失败", + "ifeng.widget.fallback_item": "暂无新闻", + "ifeng.widget.refresh_tooltip": "刷新", "dailyword.settings.title": "每日单词设置", "dailyword.settings.desc": "配置自动刷新开关与刷新频率。", "dailyword.settings.auto_refresh_label": "自动刷新", @@ -371,6 +391,23 @@ "bilihot.settings.auto_refresh_label": "自动刷新", "bilihot.settings.auto_refresh_enabled": "启用自动刷新", "bilihot.settings.frequency_label": "刷新频率", + "baiduhot.settings.title": "百度热搜设置", + "baiduhot.settings.desc": "配置数据源、自动刷新开关与刷新频率。", + "baiduhot.settings.source_label": "数据源", + "baiduhot.settings.source_official": "百度官方源", + "baiduhot.settings.source_rss": "第三方 RSS 源", + "baiduhot.settings.auto_refresh_label": "自动刷新", + "baiduhot.settings.auto_refresh_enabled": "启用自动刷新", + "baiduhot.settings.frequency_label": "刷新频率", + "ifeng.settings.title": "凤凰网新闻设置", + "ifeng.settings.desc": "配置频道、自动刷新开关与刷新频率。", + "ifeng.settings.channel_label": "新闻频道", + "ifeng.settings.channel_comprehensive": "综合", + "ifeng.settings.channel_mainland": "中国大陆", + "ifeng.settings.channel_taiwan": "台湾", + "ifeng.settings.auto_refresh_label": "自动刷新", + "ifeng.settings.auto_refresh_enabled": "启用自动刷新", + "ifeng.settings.frequency_label": "刷新频率", "refresh.frequency.5m": "5 分钟", "refresh.frequency.10m": "10 分钟", "refresh.frequency.12m": "12 分钟", diff --git a/LanMountainDesktop/Models/BaiduHotSearchSourceTypes.cs b/LanMountainDesktop/Models/BaiduHotSearchSourceTypes.cs new file mode 100644 index 0000000..c8b0ce1 --- /dev/null +++ b/LanMountainDesktop/Models/BaiduHotSearchSourceTypes.cs @@ -0,0 +1,19 @@ +using System; + +namespace LanMountainDesktop.Models; + +public static class BaiduHotSearchSourceTypes +{ + public const string Official = "Official"; + public const string ThirdPartyRss = "ThirdPartyRss"; + + public static string Normalize(string? sourceType) + { + if (string.Equals(sourceType, ThirdPartyRss, StringComparison.OrdinalIgnoreCase)) + { + return ThirdPartyRss; + } + + return Official; + } +} diff --git a/LanMountainDesktop/Models/ComponentSettingsSnapshot.cs b/LanMountainDesktop/Models/ComponentSettingsSnapshot.cs index c22d93e..f5cae91 100644 --- a/LanMountainDesktop/Models/ComponentSettingsSnapshot.cs +++ b/LanMountainDesktop/Models/ComponentSettingsSnapshot.cs @@ -32,6 +32,12 @@ public sealed class ComponentSettingsSnapshot public int CnrDailyNewsAutoRotateIntervalMinutes { get; set; } = 60; + public bool IfengNewsAutoRefreshEnabled { get; set; } = true; + + public int IfengNewsAutoRefreshIntervalMinutes { get; set; } = 20; + + public string IfengNewsChannelType { get; set; } = IfengNewsChannelTypes.Comprehensive; + public bool DailyWordAutoRefreshEnabled { get; set; } = true; public int DailyWordAutoRefreshIntervalMinutes { get; set; } = 360; @@ -40,6 +46,12 @@ public sealed class ComponentSettingsSnapshot public int BilibiliHotSearchAutoRefreshIntervalMinutes { get; set; } = 15; + public bool BaiduHotSearchAutoRefreshEnabled { get; set; } = true; + + public int BaiduHotSearchAutoRefreshIntervalMinutes { get; set; } = 15; + + public string BaiduHotSearchSourceType { get; set; } = BaiduHotSearchSourceTypes.Official; + public bool WeatherAutoRefreshEnabled { get; set; } = true; public int WeatherAutoRefreshIntervalMinutes { get; set; } = 12; diff --git a/LanMountainDesktop/Models/IfengNewsChannelTypes.cs b/LanMountainDesktop/Models/IfengNewsChannelTypes.cs new file mode 100644 index 0000000..6645aa9 --- /dev/null +++ b/LanMountainDesktop/Models/IfengNewsChannelTypes.cs @@ -0,0 +1,32 @@ +using System; +using System.Collections.Generic; + +namespace LanMountainDesktop.Models; + +public static class IfengNewsChannelTypes +{ + public const string Comprehensive = "Comprehensive"; + public const string Mainland = "Mainland"; + public const string Taiwan = "Taiwan"; + + public static IReadOnlyList SupportedValues { get; } = + [ + Comprehensive, + Mainland, + Taiwan + ]; + + public static string Normalize(string? value) + { + var candidate = value?.Trim() ?? string.Empty; + foreach (var supported in SupportedValues) + { + if (string.Equals(candidate, supported, StringComparison.OrdinalIgnoreCase)) + { + return supported; + } + } + + return Comprehensive; + } +} diff --git a/LanMountainDesktop/Models/RecommendationDataModels.cs b/LanMountainDesktop/Models/RecommendationDataModels.cs index ab3c3ce..006bfcb 100644 --- a/LanMountainDesktop/Models/RecommendationDataModels.cs +++ b/LanMountainDesktop/Models/RecommendationDataModels.cs @@ -52,6 +52,18 @@ public sealed record BilibiliHotSearchSnapshot( IReadOnlyList Items, DateTimeOffset FetchedAt); +public sealed record BaiduHotSearchItemSnapshot( + string Title, + string Url, + long? HeatScore); + +public sealed record BaiduHotSearchSnapshot( + string Provider, + string Source, + string BoardUrl, + IReadOnlyList Items, + DateTimeOffset FetchedAt); + public sealed record DailyWordSnapshot( string Provider, string Word, diff --git a/LanMountainDesktop/Services/ComponentSettingsService.cs b/LanMountainDesktop/Services/ComponentSettingsService.cs index 787664c..199730c 100644 --- a/LanMountainDesktop/Services/ComponentSettingsService.cs +++ b/LanMountainDesktop/Services/ComponentSettingsService.cs @@ -179,10 +179,16 @@ public sealed class ComponentSettingsService WorldClockSecondHandMode = legacy.WorldClockSecondHandMode, CnrDailyNewsAutoRotateEnabled = legacy.CnrDailyNewsAutoRotateEnabled, CnrDailyNewsAutoRotateIntervalMinutes = legacy.CnrDailyNewsAutoRotateIntervalMinutes, + IfengNewsAutoRefreshEnabled = legacy.IfengNewsAutoRefreshEnabled, + IfengNewsAutoRefreshIntervalMinutes = legacy.IfengNewsAutoRefreshIntervalMinutes, + IfengNewsChannelType = legacy.IfengNewsChannelType, DailyWordAutoRefreshEnabled = legacy.DailyWordAutoRefreshEnabled, DailyWordAutoRefreshIntervalMinutes = legacy.DailyWordAutoRefreshIntervalMinutes, BilibiliHotSearchAutoRefreshEnabled = legacy.BilibiliHotSearchAutoRefreshEnabled, BilibiliHotSearchAutoRefreshIntervalMinutes = legacy.BilibiliHotSearchAutoRefreshIntervalMinutes, + BaiduHotSearchAutoRefreshEnabled = legacy.BaiduHotSearchAutoRefreshEnabled, + BaiduHotSearchAutoRefreshIntervalMinutes = legacy.BaiduHotSearchAutoRefreshIntervalMinutes, + BaiduHotSearchSourceType = legacy.BaiduHotSearchSourceType, WeatherAutoRefreshEnabled = legacy.WeatherAutoRefreshEnabled, WeatherAutoRefreshIntervalMinutes = legacy.WeatherAutoRefreshIntervalMinutes, Stcn24ForumAutoRefreshEnabled = legacy.Stcn24ForumAutoRefreshEnabled, @@ -236,9 +242,14 @@ public sealed class ComponentSettingsService .ToList(); normalized.WorldClockSecondHandMode = ClockSecondHandMode.Normalize(normalized.WorldClockSecondHandMode); normalized.CnrDailyNewsAutoRotateIntervalMinutes = NormalizeCnrInterval(normalized.CnrDailyNewsAutoRotateIntervalMinutes); + normalized.IfengNewsAutoRefreshIntervalMinutes = NormalizeIfengNewsInterval(normalized.IfengNewsAutoRefreshIntervalMinutes); + normalized.IfengNewsChannelType = IfengNewsChannelTypes.Normalize(normalized.IfengNewsChannelType); normalized.DailyWordAutoRefreshIntervalMinutes = NormalizeDailyWordInterval(normalized.DailyWordAutoRefreshIntervalMinutes); normalized.BilibiliHotSearchAutoRefreshIntervalMinutes = NormalizeBilibiliHotSearchInterval( normalized.BilibiliHotSearchAutoRefreshIntervalMinutes); + normalized.BaiduHotSearchAutoRefreshIntervalMinutes = NormalizeBaiduHotSearchInterval( + normalized.BaiduHotSearchAutoRefreshIntervalMinutes); + normalized.BaiduHotSearchSourceType = BaiduHotSearchSourceTypes.Normalize(normalized.BaiduHotSearchSourceType); normalized.WeatherAutoRefreshIntervalMinutes = NormalizeWeatherInterval(normalized.WeatherAutoRefreshIntervalMinutes); normalized.Stcn24ForumAutoRefreshIntervalMinutes = NormalizeStcn24ForumInterval(normalized.Stcn24ForumAutoRefreshIntervalMinutes); normalized.Stcn24ForumSourceType = Stcn24ForumSourceTypes.Normalize(normalized.Stcn24ForumSourceType); @@ -324,11 +335,21 @@ public sealed class ComponentSettingsService return RefreshIntervalCatalog.Normalize(minutes, 360); } + private static int NormalizeIfengNewsInterval(int minutes) + { + return RefreshIntervalCatalog.Normalize(minutes, 20); + } + private static int NormalizeBilibiliHotSearchInterval(int minutes) { return RefreshIntervalCatalog.Normalize(minutes, 15); } + private static int NormalizeBaiduHotSearchInterval(int minutes) + { + return RefreshIntervalCatalog.Normalize(minutes, 15); + } + private static int NormalizeWeatherInterval(int minutes) { return RefreshIntervalCatalog.Normalize(minutes, 12); @@ -371,6 +392,12 @@ public sealed class ComponentSettingsService public int CnrDailyNewsAutoRotateIntervalMinutes { get; set; } = 60; + public bool IfengNewsAutoRefreshEnabled { get; set; } = true; + + public int IfengNewsAutoRefreshIntervalMinutes { get; set; } = 20; + + public string IfengNewsChannelType { get; set; } = IfengNewsChannelTypes.Comprehensive; + public bool DailyWordAutoRefreshEnabled { get; set; } = true; public int DailyWordAutoRefreshIntervalMinutes { get; set; } = 360; @@ -379,6 +406,12 @@ public sealed class ComponentSettingsService public int BilibiliHotSearchAutoRefreshIntervalMinutes { get; set; } = 15; + public bool BaiduHotSearchAutoRefreshEnabled { get; set; } = true; + + public int BaiduHotSearchAutoRefreshIntervalMinutes { get; set; } = 15; + + public string BaiduHotSearchSourceType { get; set; } = BaiduHotSearchSourceTypes.Official; + public bool WeatherAutoRefreshEnabled { get; set; } = true; public int WeatherAutoRefreshIntervalMinutes { get; set; } = 12; diff --git a/LanMountainDesktop/Services/IRecommendationDataService.cs b/LanMountainDesktop/Services/IRecommendationDataService.cs index 0ac629a..95afc9a 100644 --- a/LanMountainDesktop/Services/IRecommendationDataService.cs +++ b/LanMountainDesktop/Services/IRecommendationDataService.cs @@ -20,11 +20,23 @@ public sealed record DailyNewsQuery( int? ItemCount = null, bool ForceRefresh = false); +public sealed record IfengNewsQuery( + string? Locale = null, + int? ItemCount = null, + string? ChannelType = null, + bool ForceRefresh = false); + public sealed record BilibiliHotSearchQuery( string? Locale = null, int? ItemCount = null, bool ForceRefresh = false); +public sealed record BaiduHotSearchQuery( + string? Locale = null, + int? ItemCount = null, + string? SourceType = null, + bool ForceRefresh = false); + public sealed record DailyWordQuery( string? Locale = null, bool ForceRefresh = false); @@ -82,6 +94,30 @@ public sealed record RecommendationApiOptions "https://news.cnr.cn/native/gd/rss.xml" ]; + public IReadOnlyList IfengNewsComprehensiveRssFeedUrls { get; init; } = + [ + "https://rss.injahow.cn/ifeng/news", + "https://rsshub.shuaizheng.org/ifeng/news" + ]; + + public IReadOnlyList IfengNewsMainlandRssFeedUrls { get; init; } = + [ + "https://rss.injahow.cn/ifeng/news/shanklist/3-35197-/", + "https://rsshub.shuaizheng.org/ifeng/news/shanklist/3-35197-/" + ]; + + public IReadOnlyList IfengNewsTaiwanRssFeedUrls { get; init; } = + [ + "https://rss.injahow.cn/ifeng/news/shanklist/3-35199-/", + "https://rsshub.shuaizheng.org/ifeng/news/shanklist/3-35199-/" + ]; + + public string IfengNewsComprehensiveListPageUrl { get; init; } = "https://news.ifeng.com/"; + + public string IfengNewsMainlandListPageUrl { get; init; } = "https://news.ifeng.com/shanklist/3-35197-/"; + + public string IfengNewsTaiwanListPageUrl { get; init; } = "https://news.ifeng.com/shanklist/3-35199-/"; + public string BilibiliHotSearchApiTemplate { get; init; } = "https://api.bilibili.com/x/web-interface/search/square?limit={0}"; @@ -90,6 +126,10 @@ public sealed record RecommendationApiOptions public string BilibiliSearchPageUrl { get; init; } = "https://search.bilibili.com/all"; + public string BaiduHotSearchRssFeedUrl { get; init; } = "https://rss.aishort.top/?type=baidu"; + + public string BaiduHotSearchBoardUrl { get; init; } = "https://top.baidu.com/board?tab=realtime"; + public string SmartTeachForumApiTemplate { get; init; } = "https://forum.smart-teach.cn/api/discussions?filter[q]={0}&sort=-createdAt&page[limit]={1}&include=user"; @@ -238,8 +278,12 @@ public sealed record RecommendationApiOptions public int DefaultDailyNewsCount { get; init; } = 2; + public int DefaultIfengNewsCount { get; init; } = 4; + public int DefaultBilibiliHotSearchCount { get; init; } = 5; + public int DefaultBaiduHotSearchCount { get; init; } = 4; + public int DefaultStcn24ForumPostCount { get; init; } = 4; } @@ -257,10 +301,18 @@ public interface IRecommendationInfoService DailyNewsQuery query, CancellationToken cancellationToken = default); + Task> GetIfengNewsAsync( + IfengNewsQuery query, + CancellationToken cancellationToken = default); + Task> GetBilibiliHotSearchAsync( BilibiliHotSearchQuery query, CancellationToken cancellationToken = default); + Task> GetBaiduHotSearchAsync( + BaiduHotSearchQuery query, + CancellationToken cancellationToken = default); + Task> GetDailyWordAsync( DailyWordQuery query, CancellationToken cancellationToken = default); diff --git a/LanMountainDesktop/Services/RecommendationDataService.cs b/LanMountainDesktop/Services/RecommendationDataService.cs index 673ce87..c3d12a3 100644 --- a/LanMountainDesktop/Services/RecommendationDataService.cs +++ b/LanMountainDesktop/Services/RecommendationDataService.cs @@ -29,12 +29,23 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis private static readonly Regex RssDescriptionImageRegex = new( "]+src=\"(?[^\"]+)\"", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.Singleline); + private static readonly Regex BaiduHotSearchHeatRegex = new( + "^(?.+?)\\s*热度[::]\\s*(?\\d+)\\s*$", + RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.Singleline); + private static readonly Regex BaiduTopBoardDataRegex = new( + "", + RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.Singleline); + private static readonly Regex IfengNewsStreamRegex = new( + "\"newsstream\"\\s*:\\s*(?\\[.*?\\])\\s*,\\s*\"cooperation\"", + RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.Singleline); private static readonly Regex HtmlTagRegex = new("<.*?>", RegexOptions.Compiled | RegexOptions.Singleline); private sealed record DailyArtworkCacheEntry(DailyArtworkSnapshot Snapshot, DateTimeOffset ExpireAt); private sealed record DailyPoetryCacheEntry(DailyPoetrySnapshot Snapshot, DateTimeOffset ExpireAt); private sealed record DailyNewsCacheEntry(DailyNewsSnapshot Snapshot, DateTimeOffset ExpireAt); + private sealed record IfengNewsCacheEntry(DailyNewsSnapshot Snapshot, DateTimeOffset ExpireAt); private sealed record BilibiliHotSearchCacheEntry(BilibiliHotSearchSnapshot Snapshot, DateTimeOffset ExpireAt); + private sealed record BaiduHotSearchCacheEntry(BaiduHotSearchSnapshot Snapshot, DateTimeOffset ExpireAt); private sealed record DailyWordCacheEntry(DailyWordSnapshot Snapshot, DateTimeOffset ExpireAt); private sealed record Stcn24ForumPostsCacheEntry(Stcn24ForumPostsSnapshot Snapshot, DateTimeOffset ExpireAt); private sealed record ExchangeRateTableCacheEntry( @@ -59,7 +70,11 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis new(StringComparer.OrdinalIgnoreCase); private DailyPoetryCacheEntry? _dailyPoetryCache; private DailyNewsCacheEntry? _dailyNewsCache; + private readonly Dictionary _ifengNewsCacheByChannel = + new(StringComparer.OrdinalIgnoreCase); private BilibiliHotSearchCacheEntry? _bilibiliHotSearchCache; + private readonly Dictionary _baiduHotSearchCacheBySource = + new(StringComparer.OrdinalIgnoreCase); private DailyWordCacheEntry? _dailyWordCache; private readonly Dictionary _stcn24ForumPostsCacheBySource = new(StringComparer.OrdinalIgnoreCase); @@ -107,7 +122,9 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis _dailyArtworkCacheBySource.Clear(); _dailyPoetryCache = null; _dailyNewsCache = null; + _ifengNewsCacheByChannel.Clear(); _bilibiliHotSearchCache = null; + _baiduHotSearchCacheBySource.Clear(); _dailyWordCache = null; _stcn24ForumPostsCacheBySource.Clear(); _exchangeRateCacheByBaseCurrency.Clear(); @@ -245,6 +262,54 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis } } + public async Task> GetIfengNewsAsync( + IfengNewsQuery query, + CancellationToken cancellationToken = default) + { + var normalizedQuery = query ?? new IfengNewsQuery(); + var channelType = IfengNewsChannelTypes.Normalize(normalizedQuery.ChannelType); + var targetCount = normalizedQuery.ItemCount.HasValue + ? Math.Clamp(normalizedQuery.ItemCount.Value, 1, 12) + : Math.Clamp(_options.DefaultIfengNewsCount, 1, 12); + + if (!normalizedQuery.ForceRefresh && + TryGetIfengNewsFromCache(channelType, out var cached) && + cached.Items.Count >= targetCount) + { + var projectedSnapshot = cached with + { + Items = cached.Items.Take(targetCount).ToArray() + }; + return RecommendationQueryResult.Ok(projectedSnapshot); + } + + try + { + var snapshot = await FetchIfengNewsSnapshotAsync(targetCount, channelType, cancellationToken); + if (snapshot.Items.Count == 0) + { + return RecommendationQueryResult.Fail( + "upstream_empty_result", + "No ifeng news items were returned."); + } + + SetIfengNewsCache(channelType, snapshot); + return RecommendationQueryResult.Ok(snapshot); + } + catch (OperationCanceledException) + { + throw; + } + catch (HttpRequestException ex) + { + return RecommendationQueryResult.Fail("upstream_network_error", ex.Message); + } + catch (Exception ex) + { + return RecommendationQueryResult.Fail("upstream_parse_error", ex.Message); + } + } + public async Task> GetBilibiliHotSearchAsync( BilibiliHotSearchQuery query, CancellationToken cancellationToken = default) @@ -292,6 +357,54 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis } } + public async Task> GetBaiduHotSearchAsync( + BaiduHotSearchQuery query, + CancellationToken cancellationToken = default) + { + var normalizedQuery = query ?? new BaiduHotSearchQuery(); + var sourceType = BaiduHotSearchSourceTypes.Normalize(normalizedQuery.SourceType); + var targetCount = normalizedQuery.ItemCount.HasValue + ? Math.Clamp(normalizedQuery.ItemCount.Value, 1, 20) + : Math.Clamp(_options.DefaultBaiduHotSearchCount, 1, 20); + + if (!normalizedQuery.ForceRefresh && + TryGetBaiduHotSearchFromCache(sourceType, out var cached) && + cached.Items.Count >= targetCount) + { + var projectedSnapshot = cached with + { + Items = cached.Items.Take(targetCount).ToArray() + }; + return RecommendationQueryResult.Ok(projectedSnapshot); + } + + try + { + var snapshot = await FetchBaiduHotSearchSnapshotAsync(targetCount, sourceType, cancellationToken); + if (snapshot.Items.Count == 0) + { + return RecommendationQueryResult.Fail( + "upstream_empty_result", + "No Baidu hot search items were returned."); + } + + SetBaiduHotSearchCache(sourceType, snapshot); + return RecommendationQueryResult.Ok(snapshot); + } + catch (OperationCanceledException) + { + throw; + } + catch (HttpRequestException ex) + { + return RecommendationQueryResult.Fail("upstream_network_error", ex.Message); + } + catch (Exception ex) + { + return RecommendationQueryResult.Fail("upstream_parse_error", ex.Message); + } + } + public async Task> GetDailyWordAsync( DailyWordQuery query, CancellationToken cancellationToken = default) @@ -689,6 +802,240 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis } } + private bool TryGetIfengNewsFromCache(string channelType, out DailyNewsSnapshot snapshot) + { + var normalizedChannelType = IfengNewsChannelTypes.Normalize(channelType); + lock (_cacheGate) + { + if (_ifengNewsCacheByChannel.TryGetValue(normalizedChannelType, out var cacheEntry) && + cacheEntry.ExpireAt > DateTimeOffset.UtcNow) + { + snapshot = cacheEntry.Snapshot; + return true; + } + } + + snapshot = null!; + return false; + } + + private void SetIfengNewsCache(string channelType, DailyNewsSnapshot snapshot) + { + var normalizedChannelType = IfengNewsChannelTypes.Normalize(channelType); + lock (_cacheGate) + { + _ifengNewsCacheByChannel[normalizedChannelType] = new IfengNewsCacheEntry( + snapshot, + DateTimeOffset.UtcNow.Add(_options.CacheDuration)); + } + } + + private async Task FetchIfengNewsSnapshotAsync( + int targetCount, + string channelType, + CancellationToken cancellationToken) + { + var safeCount = Math.Clamp(targetCount, 1, 12); + var normalizedChannelType = IfengNewsChannelTypes.Normalize(channelType); + var candidateLimit = Math.Max(8, safeCount * 3); + + var rssCandidates = new List(); + foreach (var rssUrl in ResolveIfengNewsRssFeedUrls(normalizedChannelType)) + { + var rssItems = await TryFetchRssNewsItemsAsync(rssUrl, candidateLimit, cancellationToken); + if (rssItems.Count == 0) + { + continue; + } + + rssCandidates = rssItems; + break; + } + + var htmlCandidates = await TryFetchIfengNewsItemsFromHtmlStreamAsync( + ResolveIfengNewsListPageUrl(normalizedChannelType), + candidateLimit, + cancellationToken); + var candidates = rssCandidates.Count > 0 + ? SupplementRssItemsWithHtmlFallback(rssCandidates, htmlCandidates) + : htmlCandidates; + if (candidates.Count == 0) + { + return new DailyNewsSnapshot( + Provider: "ifeng", + Source: ResolveIfengNewsSourceLabel(normalizedChannelType), + Items: [], + FetchedAt: DateTimeOffset.UtcNow); + } + + var hydrateCount = Math.Min(candidates.Count, Math.Max(safeCount * 2, 6)); + for (var i = 0; i < hydrateCount; i++) + { + var candidate = candidates[i]; + if (!string.IsNullOrWhiteSpace(candidate.ImageUrl)) + { + continue; + } + + var coverImage = await TryFetchArticleCoverImageAsync(candidate.Url, cancellationToken); + if (!string.IsNullOrWhiteSpace(coverImage)) + { + candidates[i] = candidate with { ImageUrl = coverImage }; + } + } + + var ordered = candidates + .OrderByDescending(item => TryParseDateTimeOffset(item.PublishTime) ?? DateTimeOffset.MinValue) + .ThenByDescending(item => item.Title, StringComparer.OrdinalIgnoreCase) + .Take(safeCount) + .ToArray(); + + return new DailyNewsSnapshot( + Provider: "ifeng", + Source: ResolveIfengNewsSourceLabel(normalizedChannelType), + Items: ordered, + FetchedAt: DateTimeOffset.UtcNow); + } + + private async Task> TryFetchIfengNewsItemsFromHtmlStreamAsync( + string listPageUrl, + int maxItems, + CancellationToken cancellationToken) + { + try + { + var html = await FetchTextWithCnrEncodingAsync( + listPageUrl, + "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + cancellationToken); + var streamMatch = IfengNewsStreamRegex.Match(html); + if (!streamMatch.Success) + { + return []; + } + + using var document = JsonDocument.Parse(streamMatch.Groups["json"].Value); + if (document.RootElement.ValueKind != JsonValueKind.Array) + { + return []; + } + + var results = new List(); + var seenUrls = new HashSet(StringComparer.OrdinalIgnoreCase); + var limit = Math.Max(1, maxItems); + foreach (var node in document.RootElement.EnumerateArray()) + { + if (node.ValueKind != JsonValueKind.Object) + { + continue; + } + + var title = NormalizeInlineText(ReadString(node, "title")); + if (string.IsNullOrWhiteSpace(title)) + { + continue; + } + + var link = NormalizeHttpUrl(ReadString(node, "url")); + if (string.IsNullOrWhiteSpace(link) || !seenUrls.Add(link)) + { + continue; + } + + var imageUrl = TryExtractIfengThumbnailUrl(node); + var publishTime = NormalizeInlineText(ReadString(node, "newsTime")); + + results.Add(new DailyNewsItemSnapshot( + Title: title, + Summary: null, + Url: link, + ImageUrl: imageUrl, + PublishTime: string.IsNullOrWhiteSpace(publishTime) ? null : publishTime)); + if (results.Count >= limit) + { + break; + } + } + + return results; + } + catch + { + return []; + } + } + + private static string? TryExtractIfengThumbnailUrl(JsonElement node) + { + var imagesNode = TryGetNode(node, "thumbnails", "image"); + if (imagesNode.HasValue && imagesNode.Value.ValueKind == JsonValueKind.Array) + { + string? candidate = null; + foreach (var imageNode in imagesNode.Value.EnumerateArray()) + { + if (imageNode.ValueKind != JsonValueKind.Object) + { + continue; + } + + var url = NormalizeHttpUrl(ReadString(imageNode, "url")); + if (string.IsNullOrWhiteSpace(url)) + { + continue; + } + + candidate = url; + } + + if (!string.IsNullOrWhiteSpace(candidate)) + { + return candidate; + } + } + + return null; + } + + private IReadOnlyList ResolveIfengNewsRssFeedUrls(string channelType) + { + var normalizedChannelType = IfengNewsChannelTypes.Normalize(channelType); + return normalizedChannelType switch + { + IfengNewsChannelTypes.Mainland => _options.IfengNewsMainlandRssFeedUrls, + IfengNewsChannelTypes.Taiwan => _options.IfengNewsTaiwanRssFeedUrls, + _ => _options.IfengNewsComprehensiveRssFeedUrls + }; + } + + private string ResolveIfengNewsListPageUrl(string channelType) + { + var normalizedChannelType = IfengNewsChannelTypes.Normalize(channelType); + var url = normalizedChannelType switch + { + IfengNewsChannelTypes.Mainland => _options.IfengNewsMainlandListPageUrl, + IfengNewsChannelTypes.Taiwan => _options.IfengNewsTaiwanListPageUrl, + _ => _options.IfengNewsComprehensiveListPageUrl + }; + + return NormalizeHttpUrl(url) + ?? (normalizedChannelType switch + { + IfengNewsChannelTypes.Mainland => "https://news.ifeng.com/shanklist/3-35197-/", + IfengNewsChannelTypes.Taiwan => "https://news.ifeng.com/shanklist/3-35199-/", + _ => "https://news.ifeng.com/" + }); + } + + private static string ResolveIfengNewsSourceLabel(string channelType) + { + return IfengNewsChannelTypes.Normalize(channelType) switch + { + IfengNewsChannelTypes.Mainland => "凤凰网资讯 · 中国大陆", + IfengNewsChannelTypes.Taiwan => "凤凰网资讯 · 台湾", + _ => "凤凰网资讯 · 综合" + }; + } + private bool TryGetBilibiliHotSearchFromCache(out BilibiliHotSearchSnapshot snapshot) { lock (_cacheGate) @@ -714,6 +1061,215 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis } } + private bool TryGetBaiduHotSearchFromCache(string sourceType, out BaiduHotSearchSnapshot snapshot) + { + var normalizedSourceType = BaiduHotSearchSourceTypes.Normalize(sourceType); + lock (_cacheGate) + { + if (_baiduHotSearchCacheBySource.TryGetValue(normalizedSourceType, out var cacheEntry) && + cacheEntry.ExpireAt > DateTimeOffset.UtcNow) + { + snapshot = cacheEntry.Snapshot; + return true; + } + } + + snapshot = null!; + return false; + } + + private void SetBaiduHotSearchCache(string sourceType, BaiduHotSearchSnapshot snapshot) + { + var normalizedSourceType = BaiduHotSearchSourceTypes.Normalize(sourceType); + lock (_cacheGate) + { + _baiduHotSearchCacheBySource[normalizedSourceType] = new BaiduHotSearchCacheEntry( + snapshot, + DateTimeOffset.UtcNow.Add(_options.CacheDuration)); + } + } + + private async Task FetchBaiduHotSearchSnapshotAsync( + int targetCount, + string sourceType, + CancellationToken cancellationToken) + { + var safeCount = Math.Clamp(targetCount, 1, 20); + var normalizedSourceType = BaiduHotSearchSourceTypes.Normalize(sourceType); + var boardUrl = NormalizeHttpUrl(_options.BaiduHotSearchBoardUrl) + ?? "https://top.baidu.com/board?tab=realtime"; + + var items = string.Equals( + normalizedSourceType, + BaiduHotSearchSourceTypes.ThirdPartyRss, + StringComparison.OrdinalIgnoreCase) + ? await FetchBaiduHotSearchItemsFromThirdPartyRssAsync(safeCount, cancellationToken) + : await FetchBaiduHotSearchItemsFromOfficialSourceAsync(safeCount, boardUrl, cancellationToken); + + return new BaiduHotSearchSnapshot( + Provider: "Baidu", + Source: ResolveBaiduHotSearchSourceLabel(normalizedSourceType), + BoardUrl: boardUrl, + Items: items, + FetchedAt: DateTimeOffset.UtcNow); + } + + private async Task> FetchBaiduHotSearchItemsFromOfficialSourceAsync( + int targetCount, + string boardUrl, + CancellationToken cancellationToken) + { + var html = await FetchTextWithCnrEncodingAsync( + boardUrl, + "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + cancellationToken); + + var sDataMatch = BaiduTopBoardDataRegex.Match(html); + if (!sDataMatch.Success) + { + return []; + } + + using var document = JsonDocument.Parse(sDataMatch.Groups["json"].Value); + var root = document.RootElement; + var dataNode = TryGetNode(root, "data"); + if (!dataNode.HasValue || dataNode.Value.ValueKind != JsonValueKind.Object) + { + return []; + } + + var cardsNode = TryGetNode(dataNode.Value, "cards"); + if (!cardsNode.HasValue || cardsNode.Value.ValueKind != JsonValueKind.Array) + { + return []; + } + + JsonElement? hotListNode = null; + foreach (var cardNode in cardsNode.Value.EnumerateArray()) + { + if (cardNode.ValueKind != JsonValueKind.Object) + { + continue; + } + + var component = ReadString(cardNode, "component"); + if (!string.Equals(component, "hotList", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (cardNode.TryGetProperty("content", out var contentNode) && + contentNode.ValueKind == JsonValueKind.Array) + { + hotListNode = contentNode; + break; + } + } + + if (!hotListNode.HasValue) + { + return []; + } + + var items = new List(targetCount); + var seenTitles = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var itemNode in hotListNode.Value.EnumerateArray()) + { + if (itemNode.ValueKind != JsonValueKind.Object) + { + continue; + } + + var title = NormalizeInlineText( + ReadString(itemNode, "word") ?? + ReadString(itemNode, "query")); + if (string.IsNullOrWhiteSpace(title) || !seenTitles.Add(title)) + { + continue; + } + + var targetUrl = NormalizeHttpUrl( + ReadString(itemNode, "rawUrl") ?? + ReadString(itemNode, "url")); + if (string.IsNullOrWhiteSpace(targetUrl)) + { + continue; + } + + long? heatScore = null; + var heatScoreText = ReadString(itemNode, "hotScore"); + if (long.TryParse(heatScoreText, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedHeatScore)) + { + heatScore = parsedHeatScore; + } + + items.Add(new BaiduHotSearchItemSnapshot( + Title: title, + Url: targetUrl, + HeatScore: heatScore)); + if (items.Count >= targetCount) + { + break; + } + } + + return items; + } + + private async Task> FetchBaiduHotSearchItemsFromThirdPartyRssAsync( + int targetCount, + CancellationToken cancellationToken) + { + var requestUrl = string.IsNullOrWhiteSpace(_options.BaiduHotSearchRssFeedUrl) + ? "https://rss.aishort.top/?type=baidu" + : _options.BaiduHotSearchRssFeedUrl.Trim(); + + var rssItems = await TryFetchRssNewsItemsAsync( + requestUrl, + Math.Max(targetCount * 3, 12), + cancellationToken); + + var items = new List(targetCount); + var seenTitles = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var rssItem in rssItems + .OrderByDescending(item => TryParseDateTimeOffset(item.PublishTime) ?? DateTimeOffset.MinValue)) + { + var (title, heatScore) = ParseBaiduHotSearchTitle(rssItem.Title); + if (string.IsNullOrWhiteSpace(title) || !seenTitles.Add(title)) + { + continue; + } + + var targetUrl = NormalizeHttpUrl(rssItem.Url); + if (string.IsNullOrWhiteSpace(targetUrl)) + { + continue; + } + + items.Add(new BaiduHotSearchItemSnapshot( + Title: title, + Url: targetUrl, + HeatScore: heatScore)); + + if (items.Count >= targetCount) + { + break; + } + } + + return items; + } + + private static string ResolveBaiduHotSearchSourceLabel(string sourceType) + { + return string.Equals( + BaiduHotSearchSourceTypes.Normalize(sourceType), + BaiduHotSearchSourceTypes.ThirdPartyRss, + StringComparison.OrdinalIgnoreCase) + ? "百度热搜 · 第三方RSS" + : "百度热搜 · 官方"; + } + private async Task FetchBilibiliHotSearchSnapshotAsync( int targetCount, CancellationToken cancellationToken) @@ -2375,6 +2931,35 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis : null; } + private static (string Title, long? HeatScore) ParseBaiduHotSearchTitle(string? rawTitle) + { + var normalized = NormalizeInlineText(rawTitle); + if (string.IsNullOrWhiteSpace(normalized)) + { + return (string.Empty, null); + } + + var match = BaiduHotSearchHeatRegex.Match(normalized); + if (!match.Success) + { + return (normalized, null); + } + + var title = NormalizeInlineText(match.Groups["keyword"].Value); + if (string.IsNullOrWhiteSpace(title)) + { + title = normalized; + } + + var heatScoreText = match.Groups["heat"].Value; + if (long.TryParse(heatScoreText, NumberStyles.Integer, CultureInfo.InvariantCulture, out var heatScore)) + { + return (title, heatScore); + } + + return (title, null); + } + private static string NormalizeInlineText(string? text) { if (string.IsNullOrWhiteSpace(text)) diff --git a/LanMountainDesktop/Views/Components/BaiduHotSearchSettingsWindow.axaml b/LanMountainDesktop/Views/Components/BaiduHotSearchSettingsWindow.axaml new file mode 100644 index 0000000..a4b2a75 --- /dev/null +++ b/LanMountainDesktop/Views/Components/BaiduHotSearchSettingsWindow.axaml @@ -0,0 +1,110 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/LanMountainDesktop/Views/Components/BaiduHotSearchSettingsWindow.axaml.cs b/LanMountainDesktop/Views/Components/BaiduHotSearchSettingsWindow.axaml.cs new file mode 100644 index 0000000..c73a27f --- /dev/null +++ b/LanMountainDesktop/Views/Components/BaiduHotSearchSettingsWindow.axaml.cs @@ -0,0 +1,193 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Interactivity; +using LanMountainDesktop.Models; +using LanMountainDesktop.Services; + +namespace LanMountainDesktop.Views.Components; + +public partial class BaiduHotSearchSettingsWindow : UserControl +{ + private static readonly IReadOnlyList SupportedIntervals = RefreshIntervalCatalog.SupportedIntervalsMinutes; + + private readonly AppSettingsService _appSettingsService = new(); + private readonly ComponentSettingsService _componentSettingsService = new(); + private readonly LocalizationService _localizationService = new(); + private bool _suppressEvents; + private string _languageCode = "zh-CN"; + + public event EventHandler? SettingsChanged; + + public BaiduHotSearchSettingsWindow() + { + InitializeComponent(); + InitializeFrequencyOptions(); + LoadState(); + ApplyLocalization(); + } + + private void LoadState() + { + var appSnapshot = _appSettingsService.Load(); + var componentSnapshot = _componentSettingsService.Load(); + _languageCode = _localizationService.NormalizeLanguageCode(appSnapshot.LanguageCode); + + var sourceType = BaiduHotSearchSourceTypes.Normalize(componentSnapshot.BaiduHotSearchSourceType); + var enabled = componentSnapshot.BaiduHotSearchAutoRefreshEnabled; + var interval = NormalizeInterval(componentSnapshot.BaiduHotSearchAutoRefreshIntervalMinutes); + + _suppressEvents = true; + SelectSourceType(sourceType); + AutoRefreshCheckBox.IsChecked = enabled; + SelectInterval(interval); + FrequencyCardBorder.IsVisible = enabled; + _suppressEvents = false; + } + + private void ApplyLocalization() + { + TitleTextBlock.Text = L("baiduhot.settings.title", "Baidu hot search settings"); + DescriptionTextBlock.Text = L("baiduhot.settings.desc", "Configure source, auto refresh and refresh interval."); + SourceLabelTextBlock.Text = L("baiduhot.settings.source_label", "Data source"); + SourceOfficialItem.Content = L("baiduhot.settings.source_official", "Official Source"); + SourceThirdPartyRssItem.Content = L("baiduhot.settings.source_rss", "Third-party RSS"); + AutoRefreshLabelTextBlock.Text = L("baiduhot.settings.auto_refresh_label", "Auto refresh"); + AutoRefreshCheckBox.Content = L("baiduhot.settings.auto_refresh_enabled", "Enable auto refresh"); + FrequencyLabelTextBlock.Text = L("baiduhot.settings.frequency_label", "Refresh interval"); + ApplyFrequencyLocalization(); + } + + private void OnSourceSelectionChanged(object? sender, SelectionChangedEventArgs e) + { + _ = sender; + _ = e; + if (_suppressEvents) + { + return; + } + + SaveState(); + } + + private void OnAutoRefreshChanged(object? sender, RoutedEventArgs e) + { + _ = sender; + _ = e; + if (_suppressEvents) + { + return; + } + + var enabled = AutoRefreshCheckBox.IsChecked == true; + FrequencyCardBorder.IsVisible = enabled; + SaveState(); + } + + private void OnFrequencySelectionChanged(object? sender, SelectionChangedEventArgs e) + { + _ = sender; + _ = e; + if (_suppressEvents) + { + return; + } + + SaveState(); + } + + private void SaveState() + { + var snapshot = _componentSettingsService.Load(); + snapshot.BaiduHotSearchSourceType = GetSelectedSourceType(); + snapshot.BaiduHotSearchAutoRefreshEnabled = AutoRefreshCheckBox.IsChecked == true; + snapshot.BaiduHotSearchAutoRefreshIntervalMinutes = GetSelectedInterval(); + _componentSettingsService.Save(snapshot); + SettingsChanged?.Invoke(this, EventArgs.Empty); + } + + private string GetSelectedSourceType() + { + if (SourceComboBox.SelectedItem is ComboBoxItem item && + item.Tag is string sourceTag) + { + return BaiduHotSearchSourceTypes.Normalize(sourceTag); + } + + return BaiduHotSearchSourceTypes.Official; + } + + private int GetSelectedInterval() + { + if (FrequencyComboBox.SelectedItem is ComboBoxItem item && + item.Tag is string tagText && + int.TryParse(tagText, out var minutes)) + { + return NormalizeInterval(minutes); + } + + return 15; + } + + private void SelectSourceType(string sourceType) + { + var normalizedSourceType = BaiduHotSearchSourceTypes.Normalize(sourceType); + var selected = SourceComboBox.Items + .OfType() + .FirstOrDefault(item => + item.Tag is string sourceTag && + string.Equals(BaiduHotSearchSourceTypes.Normalize(sourceTag), normalizedSourceType, StringComparison.OrdinalIgnoreCase)); + SourceComboBox.SelectedItem = selected ?? SourceComboBox.Items.OfType().FirstOrDefault(); + } + + private void SelectInterval(int intervalMinutes) + { + var selected = FrequencyComboBox.Items + .OfType() + .FirstOrDefault(item => + item.Tag is string tagText && + int.TryParse(tagText, out var minutes) && + minutes == intervalMinutes); + FrequencyComboBox.SelectedItem = selected ?? FrequencyComboBox.Items.OfType().FirstOrDefault(); + } + + private static int NormalizeInterval(int minutes) + { + return RefreshIntervalCatalog.Normalize(minutes, 15); + } + + private void InitializeFrequencyOptions() + { + FrequencyComboBox.Items.Clear(); + foreach (var minutes in SupportedIntervals) + { + FrequencyComboBox.Items.Add(new ComboBoxItem + { + Tag = minutes.ToString(), + Content = RefreshIntervalCatalog.ToEnglishFallbackLabel(minutes) + }); + } + } + + private void ApplyFrequencyLocalization() + { + foreach (var item in FrequencyComboBox.Items.OfType()) + { + if (item.Tag is not string tagText || + !int.TryParse(tagText, out var minutes)) + { + continue; + } + + var key = $"refresh.frequency.{RefreshIntervalCatalog.ToLocalizationKeySuffix(minutes)}"; + item.Content = L(key, RefreshIntervalCatalog.ToEnglishFallbackLabel(minutes)); + } + } + + private string L(string key, string fallback) + { + return _localizationService.GetString(_languageCode, key, fallback); + } +} diff --git a/LanMountainDesktop/Views/Components/BaiduHotSearchWidget.axaml b/LanMountainDesktop/Views/Components/BaiduHotSearchWidget.axaml new file mode 100644 index 0000000..4803322 --- /dev/null +++ b/LanMountainDesktop/Views/Components/BaiduHotSearchWidget.axaml @@ -0,0 +1,189 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/LanMountainDesktop/Views/Components/BaiduHotSearchWidget.axaml.cs b/LanMountainDesktop/Views/Components/BaiduHotSearchWidget.axaml.cs new file mode 100644 index 0000000..94fa505 --- /dev/null +++ b/LanMountainDesktop/Views/Components/BaiduHotSearchWidget.axaml.cs @@ -0,0 +1,558 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Layout; +using Avalonia.Media; +using Avalonia.Threading; +using LanMountainDesktop.Models; +using LanMountainDesktop.Services; + +namespace LanMountainDesktop.Views.Components; + +public partial class BaiduHotSearchWidget : 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 static readonly IReadOnlyList SupportedAutoRefreshIntervalsMinutes = RefreshIntervalCatalog.SupportedIntervalsMinutes; + + private readonly DispatcherTimer _refreshTimer = new() + { + Interval = TimeSpan.FromMinutes(15) + }; + + private readonly AppSettingsService _appSettingsService = new(); + private readonly ComponentSettingsService _componentSettingsService = new(); + private readonly LocalizationService _localizationService = new(); + private readonly List _activeItems = []; + private readonly List _hotItemVisuals = []; + + private IRecommendationInfoService _recommendationService = DefaultRecommendationService; + private CancellationTokenSource? _refreshCts; + private string _languageCode = "zh-CN"; + private double _currentCellSize = BaseCellSize; + private bool _isAttached; + private bool _isRefreshing; + private bool _autoRefreshEnabled = true; + private string _sourceType = BaiduHotSearchSourceTypes.Official; + + private sealed record HotItemVisual( + Border Host, + Grid RowGrid, + TextBlock IndexTextBlock, + TextBlock TitleTextBlock); + + public BaiduHotSearchWidget() + { + InitializeComponent(); + + BrandTextBlock.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(); + ApplyAutoRefreshSettings(); + ApplyLoadingState(); + UpdateRefreshButtonState(); + } + + 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(); + ApplyAutoRefreshSettings(); + if (_isAttached) + { + _ = RefreshHotSearchAsync(forceRefresh: true); + } + } + + private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e) + { + _isAttached = true; + ApplyAutoRefreshSettings(); + UpdateRefreshButtonState(); + _ = RefreshHotSearchAsync(forceRefresh: false); + } + + private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e) + { + _isAttached = false; + _refreshTimer.Stop(); + CancelRefreshRequest(); + UpdateRefreshButtonState(); + } + + private void OnSizeChanged(object? sender, SizeChangedEventArgs e) + { + ApplyCellSize(_currentCellSize); + } + + private async void OnRefreshTimerTick(object? sender, EventArgs e) + { + await RefreshHotSearchAsync(forceRefresh: true); + } + + private async void OnRefreshButtonClick(object? sender, RoutedEventArgs e) + { + _ = sender; + await RefreshHotSearchAsync(forceRefresh: true); + e.Handled = true; + } + + private async Task RefreshHotSearchAsync(bool forceRefresh) + { + if (!_isAttached || _isRefreshing) + { + return; + } + + _isRefreshing = true; + UpdateLanguageCode(); + UpdateRefreshButtonState(); + + var cts = new CancellationTokenSource(); + var previous = Interlocked.Exchange(ref _refreshCts, cts); + previous?.Cancel(); + previous?.Dispose(); + + try + { + var query = new BaiduHotSearchQuery( + Locale: _languageCode, + ItemCount: MaxDisplayItemCount, + SourceType: _sourceType, + ForceRefresh: forceRefresh); + var result = await _recommendationService.GetBaiduHotSearchAsync(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; + UpdateRefreshButtonState(); + } + } + + private void ApplySnapshot(BaiduHotSearchSnapshot snapshot) + { + BrandTextBlock.Text = L("baiduhot.widget.brand", "百度热搜"); + ToolTip.SetTip(RefreshButton, L("baiduhot.widget.refresh_tooltip", "刷新")); + + _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("baiduhot.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() + { + BrandTextBlock.Text = L("baiduhot.widget.brand", "百度热搜"); + ToolTip.SetTip(RefreshButton, L("baiduhot.widget.refresh_tooltip", "刷新")); + _activeItems.Clear(); + + var loadingText = L("baiduhot.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("baiduhot.widget.loading", "加载中..."); + StatusTextBlock.IsVisible = true; + UpdateInteractionState(); + UpdateAdaptiveLayout(); + } + + private void ApplyFailedState() + { + BrandTextBlock.Text = L("baiduhot.widget.brand", "百度热搜"); + ToolTip.SetTip(RefreshButton, L("baiduhot.widget.refresh_tooltip", "刷新")); + _activeItems.Clear(); + + var fallbackText = L("baiduhot.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("baiduhot.widget.fetch_failed", "热搜获取失败"); + StatusTextBlock.IsVisible = true; + UpdateInteractionState(); + UpdateAdaptiveLayout(); + } + + 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(22 * softScale, 18, 34); + var topRowHeight = Math.Clamp(availableRowsHeight * 0.30, minTopRowHeight, 54); + 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); + } + } + + BrandTextBlock.FontSize = Math.Clamp(topRowHeight * 0.48, 12, 24); + BrandTextBlock.MaxWidth = Math.Max(80, innerWidth - Math.Clamp(topRowHeight * 0.84, 20, 46)); + + var refreshButtonSize = Math.Clamp(topRowHeight * 0.84, 20, 46); + RefreshButton.Width = refreshButtonSize; + RefreshButton.Height = refreshButtonSize; + RefreshButton.CornerRadius = new CornerRadius(refreshButtonSize / 2d); + RefreshGlyphIcon.FontSize = Math.Clamp(refreshButtonSize * 0.46, 10, 20); + + 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.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); + } + } + + private void UpdateRefreshButtonState() + { + var enabled = _isAttached && !_isRefreshing; + RefreshButton.IsEnabled = enabled; + RefreshButton.Opacity = enabled ? 1.0 : 0.65; + } + + private void UpdateLanguageCode() + { + try + { + var snapshot = _appSettingsService.Load(); + _languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode); + } + catch + { + _languageCode = "zh-CN"; + } + } + + private void ApplyAutoRefreshSettings() + { + var enabled = true; + var intervalMinutes = 15; + var sourceType = BaiduHotSearchSourceTypes.Official; + + try + { + var snapshot = _componentSettingsService.Load(); + enabled = snapshot.BaiduHotSearchAutoRefreshEnabled; + intervalMinutes = NormalizeAutoRefreshIntervalMinutes(snapshot.BaiduHotSearchAutoRefreshIntervalMinutes); + sourceType = BaiduHotSearchSourceTypes.Normalize(snapshot.BaiduHotSearchSourceType); + } + catch + { + // Keep fallback defaults. + } + + _autoRefreshEnabled = enabled; + _sourceType = sourceType; + _refreshTimer.Interval = TimeSpan.FromMinutes(intervalMinutes); + + if (!_isAttached) + { + return; + } + + if (_autoRefreshEnabled) + { + if (!_refreshTimer.IsEnabled) + { + _refreshTimer.Start(); + } + } + else if (_refreshTimer.IsEnabled) + { + _refreshTimer.Stop(); + } + } + + private static int NormalizeAutoRefreshIntervalMinutes(int minutes) + { + if (minutes <= 0) + { + return 15; + } + + if (SupportedAutoRefreshIntervalsMinutes.Contains(minutes)) + { + return minutes; + } + + return SupportedAutoRefreshIntervalsMinutes + .OrderBy(value => Math.Abs(value - minutes)) + .FirstOrDefault(15); + } + + 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 void TryOpenUrl(string? rawUrl) + { + var normalizedUrl = NormalizeHttpUrl(rawUrl); + if (string.IsNullOrWhiteSpace(normalizedUrl)) + { + return; + } + + try + { + var startInfo = new ProcessStartInfo + { + FileName = normalizedUrl, + UseShellExecute = true + }; + Process.Start(startInfo); + } + catch + { + // Ignore malformed URLs or shell launch failures. + } + } + + private double ResolveScale() + { + var expectedWidth = _currentCellSize * BaseWidthCells; + var expectedHeight = _currentCellSize * BaseHeightCells; + if (expectedWidth <= 0 || expectedHeight <= 0) + { + return 1d; + } + + var actualWidth = Bounds.Width > 1 ? Bounds.Width : expectedWidth; + var actualHeight = Bounds.Height > 1 ? Bounds.Height : expectedHeight; + var scaleX = actualWidth / expectedWidth; + var scaleY = actualHeight / expectedHeight; + return Math.Clamp(Math.Min(scaleX, scaleY), 0.72, 2.8); + } + + private string L(string key, string fallback) + { + return _localizationService.GetString(_languageCode, key, fallback); + } + + private void CancelRefreshRequest() + { + var cts = Interlocked.Exchange(ref _refreshCts, null); + if (cts is null) + { + return; + } + + cts.Cancel(); + cts.Dispose(); + } +} diff --git a/LanMountainDesktop/Views/Components/DesktopComponentRuntimeRegistry.cs b/LanMountainDesktop/Views/Components/DesktopComponentRuntimeRegistry.cs index 17c8cb1..48fbbd0 100644 --- a/LanMountainDesktop/Views/Components/DesktopComponentRuntimeRegistry.cs +++ b/LanMountainDesktop/Views/Components/DesktopComponentRuntimeRegistry.cs @@ -250,11 +250,21 @@ public sealed class DesktopComponentRuntimeRegistry "component.cnr_daily_news", () => new CnrDailyNewsWidget(), cellSize => Math.Clamp(cellSize * 0.34, 14, 30)), + new DesktopComponentRuntimeRegistration( + BuiltInComponentIds.DesktopIfengNews, + "component.ifeng_news", + () => new IfengNewsWidget(), + cellSize => Math.Clamp(cellSize * 0.30, 12, 24)), new DesktopComponentRuntimeRegistration( BuiltInComponentIds.DesktopBilibiliHotSearch, "component.bilibili_hot_search", () => new BilibiliHotSearchWidget(), cellSize => Math.Clamp(cellSize * 0.34, 14, 30)), + new DesktopComponentRuntimeRegistration( + BuiltInComponentIds.DesktopBaiduHotSearch, + "component.baidu_hot_search", + () => new BaiduHotSearchWidget(), + cellSize => Math.Clamp(cellSize * 0.34, 14, 30)), new DesktopComponentRuntimeRegistration( BuiltInComponentIds.DesktopStcn24Forum, "component.stcn24_forum", diff --git a/LanMountainDesktop/Views/Components/IfengNewsSettingsWindow.axaml b/LanMountainDesktop/Views/Components/IfengNewsSettingsWindow.axaml new file mode 100644 index 0000000..c18e226 --- /dev/null +++ b/LanMountainDesktop/Views/Components/IfengNewsSettingsWindow.axaml @@ -0,0 +1,113 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/LanMountainDesktop/Views/Components/IfengNewsSettingsWindow.axaml.cs b/LanMountainDesktop/Views/Components/IfengNewsSettingsWindow.axaml.cs new file mode 100644 index 0000000..a58acc3 --- /dev/null +++ b/LanMountainDesktop/Views/Components/IfengNewsSettingsWindow.axaml.cs @@ -0,0 +1,194 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Interactivity; +using LanMountainDesktop.Models; +using LanMountainDesktop.Services; + +namespace LanMountainDesktop.Views.Components; + +public partial class IfengNewsSettingsWindow : UserControl +{ + private static readonly IReadOnlyList SupportedIntervals = RefreshIntervalCatalog.SupportedIntervalsMinutes; + + private readonly AppSettingsService _appSettingsService = new(); + private readonly ComponentSettingsService _componentSettingsService = new(); + private readonly LocalizationService _localizationService = new(); + private bool _suppressEvents; + private string _languageCode = "zh-CN"; + + public event EventHandler? SettingsChanged; + + public IfengNewsSettingsWindow() + { + InitializeComponent(); + InitializeFrequencyOptions(); + LoadState(); + ApplyLocalization(); + } + + private void LoadState() + { + var appSnapshot = _appSettingsService.Load(); + var componentSnapshot = _componentSettingsService.Load(); + _languageCode = _localizationService.NormalizeLanguageCode(appSnapshot.LanguageCode); + + var channelType = IfengNewsChannelTypes.Normalize(componentSnapshot.IfengNewsChannelType); + var enabled = componentSnapshot.IfengNewsAutoRefreshEnabled; + var interval = NormalizeInterval(componentSnapshot.IfengNewsAutoRefreshIntervalMinutes); + + _suppressEvents = true; + SelectChannelType(channelType); + AutoRefreshCheckBox.IsChecked = enabled; + SelectInterval(interval); + FrequencyCardBorder.IsVisible = enabled; + _suppressEvents = false; + } + + private void ApplyLocalization() + { + TitleTextBlock.Text = L("ifeng.settings.title", "iFeng news settings"); + DescriptionTextBlock.Text = L("ifeng.settings.desc", "Configure channel, auto refresh and refresh interval."); + ChannelLabelTextBlock.Text = L("ifeng.settings.channel_label", "News channel"); + ChannelComprehensiveItem.Content = L("ifeng.settings.channel_comprehensive", "Comprehensive"); + ChannelMainlandItem.Content = L("ifeng.settings.channel_mainland", "China Mainland"); + ChannelTaiwanItem.Content = L("ifeng.settings.channel_taiwan", "Taiwan"); + AutoRefreshLabelTextBlock.Text = L("ifeng.settings.auto_refresh_label", "Auto refresh"); + AutoRefreshCheckBox.Content = L("ifeng.settings.auto_refresh_enabled", "Enable auto refresh"); + FrequencyLabelTextBlock.Text = L("ifeng.settings.frequency_label", "Refresh interval"); + ApplyFrequencyLocalization(); + } + + private void OnChannelSelectionChanged(object? sender, SelectionChangedEventArgs e) + { + _ = sender; + _ = e; + if (_suppressEvents) + { + return; + } + + SaveState(); + } + + private void OnAutoRefreshChanged(object? sender, RoutedEventArgs e) + { + _ = sender; + _ = e; + if (_suppressEvents) + { + return; + } + + var enabled = AutoRefreshCheckBox.IsChecked == true; + FrequencyCardBorder.IsVisible = enabled; + SaveState(); + } + + private void OnFrequencySelectionChanged(object? sender, SelectionChangedEventArgs e) + { + _ = sender; + _ = e; + if (_suppressEvents) + { + return; + } + + SaveState(); + } + + private void SaveState() + { + var snapshot = _componentSettingsService.Load(); + snapshot.IfengNewsChannelType = GetSelectedChannelType(); + snapshot.IfengNewsAutoRefreshEnabled = AutoRefreshCheckBox.IsChecked == true; + snapshot.IfengNewsAutoRefreshIntervalMinutes = GetSelectedInterval(); + _componentSettingsService.Save(snapshot); + SettingsChanged?.Invoke(this, EventArgs.Empty); + } + + private string GetSelectedChannelType() + { + if (ChannelComboBox.SelectedItem is ComboBoxItem item && + item.Tag is string channelTag) + { + return IfengNewsChannelTypes.Normalize(channelTag); + } + + return IfengNewsChannelTypes.Comprehensive; + } + + private int GetSelectedInterval() + { + if (FrequencyComboBox.SelectedItem is ComboBoxItem item && + item.Tag is string tagText && + int.TryParse(tagText, out var minutes)) + { + return NormalizeInterval(minutes); + } + + return 20; + } + + private void SelectChannelType(string channelType) + { + var normalizedChannelType = IfengNewsChannelTypes.Normalize(channelType); + var selected = ChannelComboBox.Items + .OfType() + .FirstOrDefault(item => + item.Tag is string channelTag && + string.Equals(IfengNewsChannelTypes.Normalize(channelTag), normalizedChannelType, StringComparison.OrdinalIgnoreCase)); + ChannelComboBox.SelectedItem = selected ?? ChannelComboBox.Items.OfType().FirstOrDefault(); + } + + private void SelectInterval(int intervalMinutes) + { + var selected = FrequencyComboBox.Items + .OfType() + .FirstOrDefault(item => + item.Tag is string tagText && + int.TryParse(tagText, out var minutes) && + minutes == intervalMinutes); + FrequencyComboBox.SelectedItem = selected ?? FrequencyComboBox.Items.OfType().FirstOrDefault(); + } + + private static int NormalizeInterval(int minutes) + { + return RefreshIntervalCatalog.Normalize(minutes, 20); + } + + private void InitializeFrequencyOptions() + { + FrequencyComboBox.Items.Clear(); + foreach (var minutes in SupportedIntervals) + { + FrequencyComboBox.Items.Add(new ComboBoxItem + { + Tag = minutes.ToString(), + Content = RefreshIntervalCatalog.ToEnglishFallbackLabel(minutes) + }); + } + } + + private void ApplyFrequencyLocalization() + { + foreach (var item in FrequencyComboBox.Items.OfType()) + { + if (item.Tag is not string tagText || + !int.TryParse(tagText, out var minutes)) + { + continue; + } + + var key = $"refresh.frequency.{RefreshIntervalCatalog.ToLocalizationKeySuffix(minutes)}"; + item.Content = L(key, RefreshIntervalCatalog.ToEnglishFallbackLabel(minutes)); + } + } + + private string L(string key, string fallback) + { + return _localizationService.GetString(_languageCode, key, fallback); + } +} diff --git a/LanMountainDesktop/Views/Components/IfengNewsWidget.axaml b/LanMountainDesktop/Views/Components/IfengNewsWidget.axaml new file mode 100644 index 0000000..95bd708 --- /dev/null +++ b/LanMountainDesktop/Views/Components/IfengNewsWidget.axaml @@ -0,0 +1,196 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/LanMountainDesktop/Views/Components/IfengNewsWidget.axaml.cs b/LanMountainDesktop/Views/Components/IfengNewsWidget.axaml.cs new file mode 100644 index 0000000..31184ee --- /dev/null +++ b/LanMountainDesktop/Views/Components/IfengNewsWidget.axaml.cs @@ -0,0 +1,647 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Media; +using Avalonia.Media.Imaging; +using Avalonia.Threading; +using LanMountainDesktop.Models; +using LanMountainDesktop.Services; + +namespace LanMountainDesktop.Views.Components; + +public partial class IfengNewsWidget : 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 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 = 4; + private const int MaxDisplayItemCount = 4; + private static readonly IReadOnlyList SupportedAutoRefreshIntervalsMinutes = RefreshIntervalCatalog.SupportedIntervalsMinutes; + + private readonly DispatcherTimer _refreshTimer = new() + { + Interval = TimeSpan.FromMinutes(20) + }; + + private readonly AppSettingsService _appSettingsService = new(); + private readonly ComponentSettingsService _componentSettingsService = new(); + private readonly LocalizationService _localizationService = new(); + private readonly List _activeItems = []; + private readonly List _itemVisuals = []; + private readonly Bitmap?[] _newsBitmaps = new Bitmap?[MaxDisplayItemCount]; + + private IRecommendationInfoService _recommendationService = DefaultRecommendationService; + private CancellationTokenSource? _refreshCts; + private string _languageCode = "zh-CN"; + private string _channelType = IfengNewsChannelTypes.Comprehensive; + private double _currentCellSize = BaseCellSize; + private bool _isAttached; + private bool _isRefreshing; + private bool _autoRefreshEnabled = true; + + private sealed record NewsItemVisual( + Border Host, + Grid RowGrid, + TextBlock TitleTextBlock, + Border ImageHost, + Image ImageControl); + + public IfengNewsWidget() + { + InitializeComponent(); + + BrandTextBlock.FontFamily = MiSansFontFamily; + NewsItem1TextBlock.FontFamily = MiSansFontFamily; + NewsItem2TextBlock.FontFamily = MiSansFontFamily; + NewsItem3TextBlock.FontFamily = MiSansFontFamily; + NewsItem4TextBlock.FontFamily = MiSansFontFamily; + StatusTextBlock.FontFamily = MiSansFontFamily; + + _itemVisuals.Add(new NewsItemVisual(NewsItem1Host, NewsItem1Grid, NewsItem1TextBlock, NewsItem1ImageHost, NewsItem1Image)); + _itemVisuals.Add(new NewsItemVisual(NewsItem2Host, NewsItem2Grid, NewsItem2TextBlock, NewsItem2ImageHost, NewsItem2Image)); + _itemVisuals.Add(new NewsItemVisual(NewsItem3Host, NewsItem3Grid, NewsItem3TextBlock, NewsItem3ImageHost, NewsItem3Image)); + _itemVisuals.Add(new NewsItemVisual(NewsItem4Host, NewsItem4Grid, NewsItem4TextBlock, NewsItem4ImageHost, NewsItem4Image)); + + _refreshTimer.Tick += OnRefreshTimerTick; + AttachedToVisualTree += OnAttachedToVisualTree; + DetachedFromVisualTree += OnDetachedFromVisualTree; + SizeChanged += OnSizeChanged; + + ApplyCellSize(_currentCellSize); + UpdateLanguageCode(); + ApplyAutoRefreshSettings(); + ApplyLoadingState(); + UpdateRefreshButtonState(); + } + + public void ApplyCellSize(double cellSize) + { + _currentCellSize = Math.Max(1, cellSize); + UpdateAdaptiveLayout(); + } + + public void SetRecommendationInfoService(IRecommendationInfoService recommendationInfoService) + { + _recommendationService = recommendationInfoService ?? DefaultRecommendationService; + if (_isAttached) + { + _ = RefreshNewsAsync(forceRefresh: false); + } + } + + public void RefreshFromSettings() + { + _recommendationService.ClearCache(); + ApplyAutoRefreshSettings(); + if (_isAttached) + { + _ = RefreshNewsAsync(forceRefresh: true); + } + } + + private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e) + { + _isAttached = true; + ApplyAutoRefreshSettings(); + UpdateRefreshButtonState(); + _ = RefreshNewsAsync(forceRefresh: false); + } + + private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e) + { + _isAttached = false; + _refreshTimer.Stop(); + CancelRefreshRequest(); + DisposeNewsBitmaps(); + UpdateRefreshButtonState(); + } + + private void OnSizeChanged(object? sender, SizeChangedEventArgs e) + { + ApplyCellSize(_currentCellSize); + } + + private async void OnRefreshTimerTick(object? sender, EventArgs e) + { + await RefreshNewsAsync(forceRefresh: true); + } + + private async void OnRefreshButtonClick(object? sender, RoutedEventArgs e) + { + _ = sender; + await RefreshNewsAsync(forceRefresh: true); + e.Handled = true; + } + + private void OnNewsItemPointerPressed(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 async Task RefreshNewsAsync(bool forceRefresh) + { + if (!_isAttached || _isRefreshing) + { + return; + } + + _isRefreshing = true; + UpdateLanguageCode(); + UpdateRefreshButtonState(); + + var cts = new CancellationTokenSource(); + var previous = Interlocked.Exchange(ref _refreshCts, cts); + previous?.Cancel(); + previous?.Dispose(); + + try + { + var query = new IfengNewsQuery( + Locale: _languageCode, + ItemCount: MaxDisplayItemCount, + ChannelType: _channelType, + ForceRefresh: forceRefresh); + var result = await _recommendationService.GetIfengNewsAsync(query, cts.Token); + if (!_isAttached || cts.IsCancellationRequested) + { + return; + } + + if (!result.Success || result.Data is null) + { + ApplyFailedState(); + return; + } + + await ApplySnapshotAsync(result.Data, cts.Token); + } + catch (OperationCanceledException) + { + // Ignore canceled requests. + } + catch + { + if (_isAttached && !cts.IsCancellationRequested) + { + ApplyFailedState(); + } + } + finally + { + if (ReferenceEquals(_refreshCts, cts)) + { + _refreshCts = null; + } + + cts.Dispose(); + _isRefreshing = false; + UpdateRefreshButtonState(); + } + } + + private async Task ApplySnapshotAsync(DailyNewsSnapshot snapshot, CancellationToken cancellationToken) + { + BrandTextBlock.Text = L("ifeng.widget.brand", "凤凰网新闻"); + ToolTip.SetTip(RefreshButton, L("ifeng.widget.refresh_tooltip", "刷新")); + + _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("ifeng.widget.fallback_item", "暂无新闻"); + for (var i = 0; i < _itemVisuals.Count; i++) + { + var visual = _itemVisuals[i]; + visual.Host.IsVisible = true; + visual.TitleTextBlock.Text = i < _activeItems.Count + ? NormalizeCompactText(_activeItems[i].Title) + : fallbackText; + SetNewsBitmap(i, null); + } + + StatusTextBlock.IsVisible = false; + UpdateInteractionState(); + UpdateAdaptiveLayout(); + + var tasks = Enumerable.Range(0, MaxDisplayItemCount) + .Select(index => TryDownloadBitmapAsync( + index < _activeItems.Count ? _activeItems[index].ImageUrl : null, + cancellationToken)) + .ToArray(); + var bitmaps = await Task.WhenAll(tasks); + if (cancellationToken.IsCancellationRequested || !_isAttached) + { + foreach (var bitmap in bitmaps) + { + bitmap?.Dispose(); + } + + return; + } + + for (var i = 0; i < bitmaps.Length; i++) + { + SetNewsBitmap(i, bitmaps[i]); + } + } + + private void ApplyLoadingState() + { + BrandTextBlock.Text = L("ifeng.widget.brand", "凤凰网新闻"); + ToolTip.SetTip(RefreshButton, L("ifeng.widget.refresh_tooltip", "刷新")); + + _activeItems.Clear(); + var loadingText = L("ifeng.widget.loading_item", "加载中..."); + for (var i = 0; i < _itemVisuals.Count; i++) + { + var visual = _itemVisuals[i]; + visual.Host.IsVisible = true; + visual.TitleTextBlock.Text = loadingText; + SetNewsBitmap(i, null); + } + + StatusTextBlock.Text = L("ifeng.widget.loading", "加载中..."); + StatusTextBlock.IsVisible = true; + UpdateInteractionState(); + UpdateAdaptiveLayout(); + } + + private void ApplyFailedState() + { + BrandTextBlock.Text = L("ifeng.widget.brand", "凤凰网新闻"); + ToolTip.SetTip(RefreshButton, L("ifeng.widget.refresh_tooltip", "刷新")); + + _activeItems.Clear(); + var fallbackText = L("ifeng.widget.fallback_item", "暂无新闻"); + for (var i = 0; i < _itemVisuals.Count; i++) + { + var visual = _itemVisuals[i]; + visual.Host.IsVisible = true; + visual.TitleTextBlock.Text = fallbackText; + SetNewsBitmap(i, null); + } + + StatusTextBlock.Text = L("ifeng.widget.fetch_failed", "新闻获取失败"); + StatusTextBlock.IsVisible = true; + UpdateInteractionState(); + UpdateAdaptiveLayout(); + } + + private void UpdateAdaptiveLayout() + { + var scale = ResolveScale(); + var softScale = Math.Clamp(scale, 0.80, 1.32); + var totalWidth = Bounds.Width > 1 ? Bounds.Width : _currentCellSize * BaseWidthCells; + var totalHeight = Bounds.Height > 1 ? Bounds.Height : _currentCellSize * BaseHeightCells; + + RootBorder.CornerRadius = new CornerRadius(Math.Clamp(32 * softScale, 16, 46)); + CardBorder.CornerRadius = new CornerRadius(Math.Clamp(32 * softScale, 16, 46)); + + var horizontalPadding = Math.Clamp(14 * softScale, 8, 20); + var verticalPadding = Math.Clamp(14 * softScale, 8, 20); + CardBorder.Padding = new Thickness(horizontalPadding, verticalPadding, horizontalPadding, verticalPadding); + + var rowSpacing = Math.Clamp(8 * softScale, 4, 12); + ContentGrid.RowSpacing = rowSpacing; + HeaderGrid.ColumnSpacing = Math.Clamp(10 * softScale, 6, 16); + + var innerWidth = Math.Max(150, totalWidth - horizontalPadding * 2d); + var innerHeight = Math.Max(160, totalHeight - verticalPadding * 2d); + var availableRowsHeight = Math.Max(120, innerHeight - rowSpacing * 4d); + var headerHeight = Math.Clamp(availableRowsHeight * 0.16, 24, 54); + var itemHeight = Math.Max(32, (availableRowsHeight - headerHeight) / 4d); + + if (ContentGrid.RowDefinitions.Count >= 5) + { + ContentGrid.RowDefinitions[0].Height = new GridLength(headerHeight); + for (var i = 1; i <= 4; i++) + { + ContentGrid.RowDefinitions[i].Height = new GridLength(itemHeight); + } + } + + BrandTextBlock.FontSize = Math.Clamp(headerHeight * 0.62, 14, 30); + + var refreshSize = Math.Clamp(headerHeight * 0.84, 22, 44); + RefreshButton.Width = refreshSize; + RefreshButton.Height = refreshSize; + RefreshButton.CornerRadius = new CornerRadius(refreshSize / 2d); + RefreshGlyphIcon.FontSize = Math.Clamp(refreshSize * 0.44, 10, 20); + + var imageWidth = Math.Clamp(innerWidth * 0.27, 82, 176); + var imageHeight = Math.Clamp(imageWidth * 0.56, 46, 98); + var columnGap = Math.Clamp(itemHeight * 0.20, 6, 14); + var rowPadding = Math.Clamp(itemHeight * 0.08, 1, 5); + var textWidth = Math.Max(84, innerWidth - imageWidth - columnGap); + var titleFont = Math.Clamp(itemHeight * 0.32, 12, 24); + + foreach (var visual in _itemVisuals) + { + visual.Host.Padding = new Thickness(0, rowPadding, 0, rowPadding); + visual.RowGrid.ColumnSpacing = columnGap; + if (visual.RowGrid.ColumnDefinitions.Count > 1) + { + visual.RowGrid.ColumnDefinitions[1].Width = new GridLength(imageWidth); + } + + visual.ImageHost.Width = imageWidth; + visual.ImageHost.Height = imageHeight; + visual.ImageHost.CornerRadius = new CornerRadius(Math.Clamp(imageHeight * 0.15, 8, 16)); + + visual.TitleTextBlock.MaxWidth = textWidth; + visual.TitleTextBlock.FontSize = titleFont; + visual.TitleTextBlock.LineHeight = titleFont * 1.12; + visual.TitleTextBlock.MinHeight = visual.TitleTextBlock.LineHeight * 2; + visual.TitleTextBlock.MaxLines = 2; + } + + StatusTextBlock.FontSize = Math.Clamp(titleFont, 10, 20); + } + + private void UpdateInteractionState() + { + for (var i = 0; i < _itemVisuals.Count; i++) + { + var visual = _itemVisuals[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); + } + } + + private void UpdateRefreshButtonState() + { + var enabled = _isAttached && !_isRefreshing; + RefreshButton.IsEnabled = enabled; + RefreshButton.Opacity = enabled ? 1.0 : 0.65; + } + + private void UpdateLanguageCode() + { + try + { + var snapshot = _appSettingsService.Load(); + _languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode); + } + catch + { + _languageCode = "zh-CN"; + } + } + + private void ApplyAutoRefreshSettings() + { + var enabled = true; + var intervalMinutes = 20; + var channelType = IfengNewsChannelTypes.Comprehensive; + + try + { + var snapshot = _componentSettingsService.Load(); + enabled = snapshot.IfengNewsAutoRefreshEnabled; + intervalMinutes = NormalizeAutoRefreshIntervalMinutes(snapshot.IfengNewsAutoRefreshIntervalMinutes); + channelType = IfengNewsChannelTypes.Normalize(snapshot.IfengNewsChannelType); + } + catch + { + // Keep fallback defaults. + } + + _autoRefreshEnabled = enabled; + _channelType = channelType; + _refreshTimer.Interval = TimeSpan.FromMinutes(intervalMinutes); + + if (!_isAttached) + { + return; + } + + if (_autoRefreshEnabled) + { + if (!_refreshTimer.IsEnabled) + { + _refreshTimer.Start(); + } + } + else if (_refreshTimer.IsEnabled) + { + _refreshTimer.Stop(); + } + } + + private static int NormalizeAutoRefreshIntervalMinutes(int minutes) + { + if (minutes <= 0) + { + return 20; + } + + if (SupportedAutoRefreshIntervalsMinutes.Contains(minutes)) + { + return minutes; + } + + return SupportedAutoRefreshIntervalsMinutes + .OrderBy(value => Math.Abs(value - minutes)) + .FirstOrDefault(20); + } + + private static async Task TryDownloadBitmapAsync(string? imageUrl, CancellationToken cancellationToken) + { + var normalizedUrl = NormalizeHttpUrl(imageUrl); + if (string.IsNullOrWhiteSpace(normalizedUrl)) + { + return null; + } + + try + { + using var request = new HttpRequestMessage(HttpMethod.Get, normalizedUrl); + 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 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 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 SetNewsBitmap(int index, Bitmap? bitmap) + { + if (index < 0 || index >= _newsBitmaps.Length) + { + bitmap?.Dispose(); + return; + } + + var visual = _itemVisuals[index]; + var oldBitmap = _newsBitmaps[index]; + if (ReferenceEquals(visual.ImageControl.Source, oldBitmap)) + { + visual.ImageControl.Source = null; + } + + oldBitmap?.Dispose(); + _newsBitmaps[index] = bitmap; + visual.ImageControl.Source = bitmap; + } + + private void DisposeNewsBitmaps() + { + for (var i = 0; i < _newsBitmaps.Length; i++) + { + SetNewsBitmap(i, null); + } + } + + 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.4); + } + + private static string NormalizeCompactText(string? text) + { + if (string.IsNullOrWhiteSpace(text)) + { + return string.Empty; + } + + return MultiWhitespaceRegex.Replace(text.Trim(), " "); + } + + private string L(string key, string fallback) + { + return _localizationService.GetString(_languageCode, key, fallback); + } + + private void CancelRefreshRequest() + { + var cts = Interlocked.Exchange(ref _refreshCts, null); + if (cts is null) + { + return; + } + + cts.Cancel(); + cts.Dispose(); + } +} diff --git a/LanMountainDesktop/Views/MainWindow.ComponentSystem.cs b/LanMountainDesktop/Views/MainWindow.ComponentSystem.cs index d39394c..2e5379b 100644 --- a/LanMountainDesktop/Views/MainWindow.ComponentSystem.cs +++ b/LanMountainDesktop/Views/MainWindow.ComponentSystem.cs @@ -754,6 +754,12 @@ public partial class MainWindow return; } + if (placement.ComponentId == BuiltInComponentIds.DesktopIfengNews) + { + OpenIfengNewsComponentSettings(); + return; + } + if (placement.ComponentId == BuiltInComponentIds.DesktopDailyWord || placement.ComponentId == BuiltInComponentIds.DesktopDailyWord2x2) { @@ -767,6 +773,12 @@ public partial class MainWindow return; } + if (placement.ComponentId == BuiltInComponentIds.DesktopBaiduHotSearch) + { + OpenBaiduHotSearchComponentSettings(); + return; + } + if (placement.ComponentId == BuiltInComponentIds.DesktopStcn24Forum) { OpenStcn24ForumComponentSettings(); @@ -917,6 +929,22 @@ public partial class MainWindow ComponentSettingsWindow.Opacity = 1; } + private void OpenIfengNewsComponentSettings() + { + if (ComponentSettingsWindow is null || ComponentSettingsContentHost is null) + { + return; + } + + var settingsContent = new IfengNewsSettingsWindow(); + settingsContent.SettingsChanged += OnIfengNewsSettingsChanged; + ComponentSettingsContentHost.Content = settingsContent; + + ComponentSettingsWindow.IsVisible = true; + ComponentSettingsWindow.Opacity = 0; + ComponentSettingsWindow.Opacity = 1; + } + private void OpenDailyWordComponentSettings() { if (ComponentSettingsWindow is null || ComponentSettingsContentHost is null) @@ -949,6 +977,22 @@ public partial class MainWindow ComponentSettingsWindow.Opacity = 1; } + private void OpenBaiduHotSearchComponentSettings() + { + if (ComponentSettingsWindow is null || ComponentSettingsContentHost is null) + { + return; + } + + var settingsContent = new BaiduHotSearchSettingsWindow(); + settingsContent.SettingsChanged += OnBaiduHotSearchSettingsChanged; + ComponentSettingsContentHost.Content = settingsContent; + + ComponentSettingsWindow.IsVisible = true; + ComponentSettingsWindow.Opacity = 0; + ComponentSettingsWindow.Opacity = 1; + } + private void OpenStcn24ForumComponentSettings() { if (ComponentSettingsWindow is null || ComponentSettingsContentHost is null) @@ -1118,6 +1162,28 @@ public partial class MainWindow } } + private void OnIfengNewsSettingsChanged(object? sender, EventArgs e) + { + _ = sender; + _ = e; + + foreach (var pageGrid in _desktopPageComponentGrids.Values) + { + foreach (var host in pageGrid.Children.OfType()) + { + if (!host.Classes.Contains(DesktopComponentHostClass)) + { + continue; + } + + if (TryGetContentHost(host)?.Child is IfengNewsWidget widget) + { + widget.RefreshFromSettings(); + } + } + } + } + private void OnDailyWordSettingsChanged(object? sender, EventArgs e) { _ = sender; @@ -1167,6 +1233,28 @@ public partial class MainWindow } } + private void OnBaiduHotSearchSettingsChanged(object? sender, EventArgs e) + { + _ = sender; + _ = e; + + foreach (var pageGrid in _desktopPageComponentGrids.Values) + { + foreach (var host in pageGrid.Children.OfType()) + { + if (!host.Classes.Contains(DesktopComponentHostClass)) + { + continue; + } + + if (TryGetContentHost(host)?.Child is BaiduHotSearchWidget widget) + { + widget.RefreshFromSettings(); + } + } + } + } + private void OnStcn24ForumSettingsChanged(object? sender, EventArgs e) { _ = sender; @@ -1231,6 +1319,11 @@ public partial class MainWindow cnrDailyNewsSettingsWindow.SettingsChanged -= OnCnrDailyNewsSettingsChanged; } + if (ComponentSettingsContentHost?.Content is IfengNewsSettingsWindow ifengNewsSettingsWindow) + { + ifengNewsSettingsWindow.SettingsChanged -= OnIfengNewsSettingsChanged; + } + if (ComponentSettingsContentHost?.Content is DailyWordSettingsWindow dailyWordSettingsWindow) { dailyWordSettingsWindow.SettingsChanged -= OnDailyWordSettingsChanged; @@ -1241,6 +1334,11 @@ public partial class MainWindow bilibiliHotSearchSettingsWindow.SettingsChanged -= OnBilibiliHotSearchSettingsChanged; } + if (ComponentSettingsContentHost?.Content is BaiduHotSearchSettingsWindow baiduHotSearchSettingsWindow) + { + baiduHotSearchSettingsWindow.SettingsChanged -= OnBaiduHotSearchSettingsChanged; + } + if (ComponentSettingsContentHost?.Content is Stcn24ForumSettingsWindow stcn24ForumSettingsWindow) { stcn24ForumSettingsWindow.SettingsChanged -= OnStcn24ForumSettingsChanged; @@ -1657,6 +1755,14 @@ public partial class MainWindow new ComponentScaleRule(WidthUnit: 2, HeightUnit: 1, MinScale: 2)); } + if (string.Equals(componentId, BuiltInComponentIds.DesktopIfengNews, StringComparison.OrdinalIgnoreCase)) + { + // Keep iFeng news widget square with a minimum footprint of 4x4. + return SnapSpanToScaleRules( + span, + new ComponentScaleRule(WidthUnit: 1, HeightUnit: 1, MinScale: 4)); + } + if (string.Equals(componentId, BuiltInComponentIds.DesktopBilibiliHotSearch, StringComparison.OrdinalIgnoreCase)) { // Keep Bilibili hot search widget at a 2:1 ratio: 4x2, 6x3, 8x4... @@ -1665,6 +1771,14 @@ public partial class MainWindow new ComponentScaleRule(WidthUnit: 2, HeightUnit: 1, MinScale: 2)); } + if (string.Equals(componentId, BuiltInComponentIds.DesktopBaiduHotSearch, StringComparison.OrdinalIgnoreCase)) + { + // Keep Baidu 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.DesktopStcn24Forum, StringComparison.OrdinalIgnoreCase)) { // Keep STCN forum widget square with a minimum footprint of 4x4. diff --git a/LanMountainDesktop/Views/MainWindow.Localization.cs b/LanMountainDesktop/Views/MainWindow.Localization.cs index 69683cf..bcffdf8 100644 --- a/LanMountainDesktop/Views/MainWindow.Localization.cs +++ b/LanMountainDesktop/Views/MainWindow.Localization.cs @@ -112,6 +112,7 @@ public partial class MainWindow SettingsNavRegionTextBlock.Text = L("settings.nav.region", "Region"); SettingsNavUpdateTextBlock.Text = L("settings.nav.update", "Update"); SettingsNavLauncherTextBlock.Text = L("settings.nav.launcher", "App Launcher"); + SettingsNavPluginsTextBlock.Text = L("settings.nav.plugins", "Plugins"); WallpaperPanelTitleTextBlock.Text = L("settings.wallpaper.title", "Personalize your wallpaper"); WallpaperPlacementSettingsExpander.Header = L("settings.wallpaper.placement_label", "Placement"); @@ -262,6 +263,18 @@ public partial class MainWindow "Right-click an icon in launcher to hide it. Hidden entries appear here."); LauncherHiddenItemsEmptyTextBlock.Text = L("settings.launcher.hidden_empty", "No hidden items."); + PluginSettingsPanelTitleTextBlock.Text = L("settings.plugins.title", "Plugins"); + PluginSystemSettingsExpander.Header = L("settings.plugins.runtime_header", "Plugin Runtime"); + PluginSystemSettingsExpander.Description = L( + "settings.plugins.runtime_desc", + "Manage plugin loading and backend isolation."); + PluginSystemDescriptionTextBlock.Text = L( + "settings.plugins.runtime_hint", + "This page will host installed plugin management, permission review, and sandboxed backend runtime controls."); + PluginSystemStatusTextBlock.Text = L( + "settings.plugins.runtime_status", + "Plugin management UI is not connected yet. Next step is wiring the loader, permissions, and worker isolation state into this panel."); + SettingsNavAboutTextBlock.Text = L("settings.nav.about", "About"); AboutPanelTitleTextBlock.Text = L("settings.about.title", "About"); VersionTextBlock.Text = Lf( diff --git a/LanMountainDesktop/Views/MainWindow.Settings.cs b/LanMountainDesktop/Views/MainWindow.Settings.cs index 866915e..187477a 100644 --- a/LanMountainDesktop/Views/MainWindow.Settings.cs +++ b/LanMountainDesktop/Views/MainWindow.Settings.cs @@ -67,7 +67,8 @@ public partial class MainWindow RegionSettingsPanel is null || UpdateSettingsPanel is null || LauncherSettingsPanel is null || - AboutSettingsPanel is null) + AboutSettingsPanel is null || + PluginSettingsPanel is null) { return; } @@ -82,6 +83,7 @@ public partial class MainWindow UpdateSettingsPanel.IsVisible = selectedIndex == 6; AboutSettingsPanel.IsVisible = selectedIndex == 7; LauncherSettingsPanel.IsVisible = selectedIndex == 8; + PluginSettingsPanel.IsVisible = selectedIndex == 9; if (selectedIndex == 8) { diff --git a/LanMountainDesktop/Views/MainWindow.axaml b/LanMountainDesktop/Views/MainWindow.axaml index f8123eb..4f91513 100644 --- a/LanMountainDesktop/Views/MainWindow.axaml +++ b/LanMountainDesktop/Views/MainWindow.axaml @@ -466,6 +466,12 @@ + + + + + + @@ -1557,6 +1563,37 @@ + + + + + + + + + + + + + + + + + diff --git a/LanMountainDesktop/Views/MainWindow.axaml.cs b/LanMountainDesktop/Views/MainWindow.axaml.cs index 3919036..4b1768b 100644 --- a/LanMountainDesktop/Views/MainWindow.axaml.cs +++ b/LanMountainDesktop/Views/MainWindow.axaml.cs @@ -236,7 +236,7 @@ public partial class MainWindow : Window GridSizeSlider.ValueChanged += OnGridSizeSliderChanged; GridSizeNumberBox.ValueChanged += OnGridSizeNumberBoxChanged; - SettingsNavListBox.SelectedIndex = Math.Clamp(snapshot.SettingsTabIndex, 0, 8); + SettingsNavListBox.SelectedIndex = Math.Clamp(snapshot.SettingsTabIndex, 0, 9); UpdateSettingsTabContent(); WallpaperPlacementComboBox.SelectedIndex = GetPlacementIndexFromSetting(snapshot.WallpaperPlacement);