mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-20 23:54:26 +08:00
0.4.8
百度热搜组件、凤凰新闻组件。
This commit is contained in:
@@ -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";
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 分钟",
|
||||
|
||||
19
LanMountainDesktop/Models/BaiduHotSearchSourceTypes.cs
Normal file
19
LanMountainDesktop/Models/BaiduHotSearchSourceTypes.cs
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
32
LanMountainDesktop/Models/IfengNewsChannelTypes.cs
Normal file
32
LanMountainDesktop/Models/IfengNewsChannelTypes.cs
Normal file
@@ -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<string> 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;
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,18 @@ public sealed record BilibiliHotSearchSnapshot(
|
||||
IReadOnlyList<BilibiliHotSearchItemSnapshot> Items,
|
||||
DateTimeOffset FetchedAt);
|
||||
|
||||
public sealed record BaiduHotSearchItemSnapshot(
|
||||
string Title,
|
||||
string Url,
|
||||
long? HeatScore);
|
||||
|
||||
public sealed record BaiduHotSearchSnapshot(
|
||||
string Provider,
|
||||
string Source,
|
||||
string BoardUrl,
|
||||
IReadOnlyList<BaiduHotSearchItemSnapshot> Items,
|
||||
DateTimeOffset FetchedAt);
|
||||
|
||||
public sealed record DailyWordSnapshot(
|
||||
string Provider,
|
||||
string Word,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string> IfengNewsComprehensiveRssFeedUrls { get; init; } =
|
||||
[
|
||||
"https://rss.injahow.cn/ifeng/news",
|
||||
"https://rsshub.shuaizheng.org/ifeng/news"
|
||||
];
|
||||
|
||||
public IReadOnlyList<string> IfengNewsMainlandRssFeedUrls { get; init; } =
|
||||
[
|
||||
"https://rss.injahow.cn/ifeng/news/shanklist/3-35197-/",
|
||||
"https://rsshub.shuaizheng.org/ifeng/news/shanklist/3-35197-/"
|
||||
];
|
||||
|
||||
public IReadOnlyList<string> 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<RecommendationQueryResult<DailyNewsSnapshot>> GetIfengNewsAsync(
|
||||
IfengNewsQuery query,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<RecommendationQueryResult<BilibiliHotSearchSnapshot>> GetBilibiliHotSearchAsync(
|
||||
BilibiliHotSearchQuery query,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<RecommendationQueryResult<BaiduHotSearchSnapshot>> GetBaiduHotSearchAsync(
|
||||
BaiduHotSearchQuery query,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<RecommendationQueryResult<DailyWordSnapshot>> GetDailyWordAsync(
|
||||
DailyWordQuery query,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
@@ -29,12 +29,23 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
|
||||
private static readonly Regex RssDescriptionImageRegex = new(
|
||||
"<img[^>]+src=\"(?<url>[^\"]+)\"",
|
||||
RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.Singleline);
|
||||
private static readonly Regex BaiduHotSearchHeatRegex = new(
|
||||
"^(?<keyword>.+?)\\s*热度[::]\\s*(?<heat>\\d+)\\s*$",
|
||||
RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.Singleline);
|
||||
private static readonly Regex BaiduTopBoardDataRegex = new(
|
||||
"<!--\\s*s-data:(?<json>\\{.*?\\})\\s*-->",
|
||||
RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.Singleline);
|
||||
private static readonly Regex IfengNewsStreamRegex = new(
|
||||
"\"newsstream\"\\s*:\\s*(?<json>\\[.*?\\])\\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<string, IfengNewsCacheEntry> _ifengNewsCacheByChannel =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
private BilibiliHotSearchCacheEntry? _bilibiliHotSearchCache;
|
||||
private readonly Dictionary<string, BaiduHotSearchCacheEntry> _baiduHotSearchCacheBySource =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
private DailyWordCacheEntry? _dailyWordCache;
|
||||
private readonly Dictionary<string, Stcn24ForumPostsCacheEntry> _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<RecommendationQueryResult<DailyNewsSnapshot>> 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<DailyNewsSnapshot>.Ok(projectedSnapshot);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var snapshot = await FetchIfengNewsSnapshotAsync(targetCount, channelType, cancellationToken);
|
||||
if (snapshot.Items.Count == 0)
|
||||
{
|
||||
return RecommendationQueryResult<DailyNewsSnapshot>.Fail(
|
||||
"upstream_empty_result",
|
||||
"No ifeng news items were returned.");
|
||||
}
|
||||
|
||||
SetIfengNewsCache(channelType, snapshot);
|
||||
return RecommendationQueryResult<DailyNewsSnapshot>.Ok(snapshot);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
return RecommendationQueryResult<DailyNewsSnapshot>.Fail("upstream_network_error", ex.Message);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return RecommendationQueryResult<DailyNewsSnapshot>.Fail("upstream_parse_error", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<RecommendationQueryResult<BilibiliHotSearchSnapshot>> GetBilibiliHotSearchAsync(
|
||||
BilibiliHotSearchQuery query,
|
||||
CancellationToken cancellationToken = default)
|
||||
@@ -292,6 +357,54 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<RecommendationQueryResult<BaiduHotSearchSnapshot>> 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<BaiduHotSearchSnapshot>.Ok(projectedSnapshot);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var snapshot = await FetchBaiduHotSearchSnapshotAsync(targetCount, sourceType, cancellationToken);
|
||||
if (snapshot.Items.Count == 0)
|
||||
{
|
||||
return RecommendationQueryResult<BaiduHotSearchSnapshot>.Fail(
|
||||
"upstream_empty_result",
|
||||
"No Baidu hot search items were returned.");
|
||||
}
|
||||
|
||||
SetBaiduHotSearchCache(sourceType, snapshot);
|
||||
return RecommendationQueryResult<BaiduHotSearchSnapshot>.Ok(snapshot);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
return RecommendationQueryResult<BaiduHotSearchSnapshot>.Fail("upstream_network_error", ex.Message);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return RecommendationQueryResult<BaiduHotSearchSnapshot>.Fail("upstream_parse_error", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<RecommendationQueryResult<DailyWordSnapshot>> 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<DailyNewsSnapshot> 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<DailyNewsItemSnapshot>();
|
||||
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<List<DailyNewsItemSnapshot>> 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<DailyNewsItemSnapshot>();
|
||||
var seenUrls = new HashSet<string>(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<string> 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<BaiduHotSearchSnapshot> 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<IReadOnlyList<BaiduHotSearchItemSnapshot>> 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<BaiduHotSearchItemSnapshot>(targetCount);
|
||||
var seenTitles = new HashSet<string>(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<IReadOnlyList<BaiduHotSearchItemSnapshot>> 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<BaiduHotSearchItemSnapshot>(targetCount);
|
||||
var seenTitles = new HashSet<string>(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<BilibiliHotSearchSnapshot> 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))
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
mc:Ignorable="d"
|
||||
d:DesignWidth="420"
|
||||
d:DesignHeight="300"
|
||||
x:Class="LanMountainDesktop.Views.Components.BaiduHotSearchSettingsWindow">
|
||||
<Border Background="{DynamicResource AdaptiveBackgroundBrush}"
|
||||
Padding="16">
|
||||
<Grid RowDefinitions="Auto,Auto,*"
|
||||
RowSpacing="10">
|
||||
<TextBlock x:Name="TitleTextBlock"
|
||||
Text="Baidu hot search settings"
|
||||
FontSize="18"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||
|
||||
<TextBlock x:Name="DescriptionTextBlock"
|
||||
Grid.Row="1"
|
||||
Text="Configure source, auto refresh and refresh interval."
|
||||
FontSize="12"
|
||||
TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" />
|
||||
|
||||
<ScrollViewer Grid.Row="2"
|
||||
HorizontalScrollBarVisibility="Disabled"
|
||||
VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel Spacing="10"
|
||||
Margin="0,0,6,0">
|
||||
<Border Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
|
||||
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="12"
|
||||
Padding="12">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock x:Name="SourceLabelTextBlock"
|
||||
Text="Data source"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||
<ComboBox x:Name="SourceComboBox"
|
||||
HorizontalAlignment="Stretch"
|
||||
MinWidth="0"
|
||||
SelectionChanged="OnSourceSelectionChanged">
|
||||
<ComboBoxItem x:Name="SourceOfficialItem"
|
||||
Tag="Official"
|
||||
Content="Official Source" />
|
||||
<ComboBoxItem x:Name="SourceThirdPartyRssItem"
|
||||
Tag="ThirdPartyRss"
|
||||
Content="Third-party RSS" />
|
||||
</ComboBox>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
|
||||
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="12"
|
||||
Padding="12">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock x:Name="AutoRefreshLabelTextBlock"
|
||||
Text="Auto refresh"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||
<CheckBox x:Name="AutoRefreshCheckBox"
|
||||
Content="Enable auto refresh"
|
||||
Checked="OnAutoRefreshChanged"
|
||||
Unchecked="OnAutoRefreshChanged" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border x:Name="FrequencyCardBorder"
|
||||
Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
|
||||
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="12"
|
||||
Padding="12"
|
||||
IsVisible="False">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock x:Name="FrequencyLabelTextBlock"
|
||||
Text="Refresh interval"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||
<ComboBox x:Name="FrequencyComboBox"
|
||||
HorizontalAlignment="Stretch"
|
||||
MinWidth="0"
|
||||
SelectionChanged="OnFrequencySelectionChanged">
|
||||
<ComboBoxItem x:Name="Frequency5mItem"
|
||||
Tag="5"
|
||||
Content="5 min" />
|
||||
<ComboBoxItem x:Name="Frequency10mItem"
|
||||
Tag="10"
|
||||
Content="10 min" />
|
||||
<ComboBoxItem x:Name="Frequency15mItem"
|
||||
Tag="15"
|
||||
Content="15 min" />
|
||||
<ComboBoxItem x:Name="Frequency30mItem"
|
||||
Tag="30"
|
||||
Content="30 min" />
|
||||
<ComboBoxItem x:Name="Frequency1hItem"
|
||||
Tag="60"
|
||||
Content="1 hour" />
|
||||
<ComboBoxItem x:Name="Frequency3hItem"
|
||||
Tag="180"
|
||||
Content="3 hours" />
|
||||
</ComboBox>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</Grid>
|
||||
</Border>
|
||||
</UserControl>
|
||||
@@ -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<int> 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<ComboBoxItem>()
|
||||
.FirstOrDefault(item =>
|
||||
item.Tag is string sourceTag &&
|
||||
string.Equals(BaiduHotSearchSourceTypes.Normalize(sourceTag), normalizedSourceType, StringComparison.OrdinalIgnoreCase));
|
||||
SourceComboBox.SelectedItem = selected ?? SourceComboBox.Items.OfType<ComboBoxItem>().FirstOrDefault();
|
||||
}
|
||||
|
||||
private void SelectInterval(int intervalMinutes)
|
||||
{
|
||||
var selected = FrequencyComboBox.Items
|
||||
.OfType<ComboBoxItem>()
|
||||
.FirstOrDefault(item =>
|
||||
item.Tag is string tagText &&
|
||||
int.TryParse(tagText, out var minutes) &&
|
||||
minutes == intervalMinutes);
|
||||
FrequencyComboBox.SelectedItem = selected ?? FrequencyComboBox.Items.OfType<ComboBoxItem>().FirstOrDefault();
|
||||
}
|
||||
|
||||
private static int NormalizeInterval(int minutes)
|
||||
{
|
||||
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<ComboBoxItem>())
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
189
LanMountainDesktop/Views/Components/BaiduHotSearchWidget.axaml
Normal file
189
LanMountainDesktop/Views/Components/BaiduHotSearchWidget.axaml
Normal file
@@ -0,0 +1,189 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:fi="using:FluentIcons.Avalonia"
|
||||
mc:Ignorable="d"
|
||||
d:DesignWidth="640"
|
||||
d:DesignHeight="320"
|
||||
x:Class="LanMountainDesktop.Views.Components.BaiduHotSearchWidget">
|
||||
|
||||
<Border x:Name="RootBorder"
|
||||
CornerRadius="34"
|
||||
Background="Transparent"
|
||||
ClipToBounds="True"
|
||||
BorderThickness="0"
|
||||
Padding="0">
|
||||
<Grid>
|
||||
<Border x:Name="CardBorder"
|
||||
Background="#FCFCFD"
|
||||
CornerRadius="34"
|
||||
BorderBrush="Transparent"
|
||||
BorderThickness="0"
|
||||
Padding="16,14,16,14">
|
||||
<Grid x:Name="ContentGrid"
|
||||
RowDefinitions="Auto,Auto,Auto,Auto,Auto"
|
||||
RowSpacing="6">
|
||||
<Grid x:Name="HeaderGrid"
|
||||
Grid.Row="0"
|
||||
ColumnDefinitions="*,Auto"
|
||||
ColumnSpacing="10">
|
||||
<TextBlock x:Name="BrandTextBlock"
|
||||
Text="百度热搜"
|
||||
Foreground="#2932E1"
|
||||
FontSize="24"
|
||||
FontWeight="Bold"
|
||||
VerticalAlignment="Center"
|
||||
MaxLines="1"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
|
||||
<Button x:Name="RefreshButton"
|
||||
Grid.Column="1"
|
||||
Width="34"
|
||||
Height="34"
|
||||
CornerRadius="17"
|
||||
Background="#EFF1F5"
|
||||
BorderBrush="Transparent"
|
||||
BorderThickness="0"
|
||||
Padding="0"
|
||||
Focusable="False"
|
||||
ToolTip.Tip="刷新"
|
||||
Click="OnRefreshButtonClick">
|
||||
<fi:SymbolIcon x:Name="RefreshGlyphIcon"
|
||||
Symbol="ArrowClockwise"
|
||||
IconVariant="Regular"
|
||||
Foreground="#5E6671"
|
||||
FontSize="16"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center" />
|
||||
</Button>
|
||||
</Grid>
|
||||
|
||||
<Border x:Name="HotItem1Host"
|
||||
Grid.Row="1"
|
||||
Tag="0"
|
||||
Background="Transparent"
|
||||
Padding="0,2"
|
||||
PointerPressed="OnHotItemPointerPressed">
|
||||
<Grid x:Name="HotItem1Grid"
|
||||
ColumnDefinitions="Auto,*"
|
||||
ColumnSpacing="8">
|
||||
<TextBlock x:Name="HotItem1IndexTextBlock"
|
||||
Text="1"
|
||||
Foreground="#2932E1"
|
||||
FontSize="18"
|
||||
FontWeight="Bold"
|
||||
VerticalAlignment="Center"
|
||||
HorizontalAlignment="Right"
|
||||
TextAlignment="Right" />
|
||||
<TextBlock x:Name="HotItem1TextBlock"
|
||||
Grid.Column="1"
|
||||
Text="热搜内容"
|
||||
Foreground="#202327"
|
||||
FontSize="28"
|
||||
FontWeight="SemiBold"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
MaxLines="1"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Border x:Name="HotItem2Host"
|
||||
Grid.Row="2"
|
||||
Tag="1"
|
||||
Background="Transparent"
|
||||
Padding="0,2"
|
||||
PointerPressed="OnHotItemPointerPressed">
|
||||
<Grid x:Name="HotItem2Grid"
|
||||
ColumnDefinitions="Auto,*"
|
||||
ColumnSpacing="8">
|
||||
<TextBlock x:Name="HotItem2IndexTextBlock"
|
||||
Text="2"
|
||||
Foreground="#2932E1"
|
||||
FontSize="18"
|
||||
FontWeight="Bold"
|
||||
VerticalAlignment="Center"
|
||||
HorizontalAlignment="Right"
|
||||
TextAlignment="Right" />
|
||||
<TextBlock x:Name="HotItem2TextBlock"
|
||||
Grid.Column="1"
|
||||
Text="热搜内容"
|
||||
Foreground="#202327"
|
||||
FontSize="28"
|
||||
FontWeight="SemiBold"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
MaxLines="1"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Border x:Name="HotItem3Host"
|
||||
Grid.Row="3"
|
||||
Tag="2"
|
||||
Background="Transparent"
|
||||
Padding="0,2"
|
||||
PointerPressed="OnHotItemPointerPressed">
|
||||
<Grid x:Name="HotItem3Grid"
|
||||
ColumnDefinitions="Auto,*"
|
||||
ColumnSpacing="8">
|
||||
<TextBlock x:Name="HotItem3IndexTextBlock"
|
||||
Text="3"
|
||||
Foreground="#2932E1"
|
||||
FontSize="18"
|
||||
FontWeight="Bold"
|
||||
VerticalAlignment="Center"
|
||||
HorizontalAlignment="Right"
|
||||
TextAlignment="Right" />
|
||||
<TextBlock x:Name="HotItem3TextBlock"
|
||||
Grid.Column="1"
|
||||
Text="热搜内容"
|
||||
Foreground="#202327"
|
||||
FontSize="28"
|
||||
FontWeight="SemiBold"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
MaxLines="1"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Border x:Name="HotItem4Host"
|
||||
Grid.Row="4"
|
||||
Tag="3"
|
||||
Background="Transparent"
|
||||
Padding="0,2"
|
||||
PointerPressed="OnHotItemPointerPressed">
|
||||
<Grid x:Name="HotItem4Grid"
|
||||
ColumnDefinitions="Auto,*"
|
||||
ColumnSpacing="8">
|
||||
<TextBlock x:Name="HotItem4IndexTextBlock"
|
||||
Text="4"
|
||||
Foreground="#2932E1"
|
||||
FontSize="18"
|
||||
FontWeight="Bold"
|
||||
VerticalAlignment="Center"
|
||||
HorizontalAlignment="Right"
|
||||
TextAlignment="Right" />
|
||||
<TextBlock x:Name="HotItem4TextBlock"
|
||||
Grid.Column="1"
|
||||
Text="热搜内容"
|
||||
Foreground="#202327"
|
||||
FontSize="28"
|
||||
FontWeight="SemiBold"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
MaxLines="1"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<TextBlock x:Name="StatusTextBlock"
|
||||
IsVisible="False"
|
||||
Text="Loading"
|
||||
Foreground="#6A6F77"
|
||||
FontSize="16"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</Border>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,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<int> 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<BaiduHotSearchItemSnapshot> _activeItems = [];
|
||||
private readonly List<HotItemVisual> _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();
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
mc:Ignorable="d"
|
||||
d:DesignWidth="420"
|
||||
d:DesignHeight="320"
|
||||
x:Class="LanMountainDesktop.Views.Components.IfengNewsSettingsWindow">
|
||||
<Border Background="{DynamicResource AdaptiveBackgroundBrush}"
|
||||
Padding="16">
|
||||
<Grid RowDefinitions="Auto,Auto,*"
|
||||
RowSpacing="10">
|
||||
<TextBlock x:Name="TitleTextBlock"
|
||||
Text="iFeng news settings"
|
||||
FontSize="18"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||
|
||||
<TextBlock x:Name="DescriptionTextBlock"
|
||||
Grid.Row="1"
|
||||
Text="Configure channel, auto refresh and refresh interval."
|
||||
FontSize="12"
|
||||
TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" />
|
||||
|
||||
<ScrollViewer Grid.Row="2"
|
||||
HorizontalScrollBarVisibility="Disabled"
|
||||
VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel Spacing="10"
|
||||
Margin="0,0,6,0">
|
||||
<Border Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
|
||||
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="12"
|
||||
Padding="12">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock x:Name="ChannelLabelTextBlock"
|
||||
Text="News channel"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||
<ComboBox x:Name="ChannelComboBox"
|
||||
HorizontalAlignment="Stretch"
|
||||
MinWidth="0"
|
||||
SelectionChanged="OnChannelSelectionChanged">
|
||||
<ComboBoxItem x:Name="ChannelComprehensiveItem"
|
||||
Tag="Comprehensive"
|
||||
Content="Comprehensive" />
|
||||
<ComboBoxItem x:Name="ChannelMainlandItem"
|
||||
Tag="Mainland"
|
||||
Content="China Mainland" />
|
||||
<ComboBoxItem x:Name="ChannelTaiwanItem"
|
||||
Tag="Taiwan"
|
||||
Content="Taiwan" />
|
||||
</ComboBox>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
|
||||
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="12"
|
||||
Padding="12">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock x:Name="AutoRefreshLabelTextBlock"
|
||||
Text="Auto refresh"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||
<CheckBox x:Name="AutoRefreshCheckBox"
|
||||
Content="Enable auto refresh"
|
||||
Checked="OnAutoRefreshChanged"
|
||||
Unchecked="OnAutoRefreshChanged" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border x:Name="FrequencyCardBorder"
|
||||
Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
|
||||
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="12"
|
||||
Padding="12"
|
||||
IsVisible="False">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock x:Name="FrequencyLabelTextBlock"
|
||||
Text="Refresh interval"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||
<ComboBox x:Name="FrequencyComboBox"
|
||||
HorizontalAlignment="Stretch"
|
||||
MinWidth="0"
|
||||
SelectionChanged="OnFrequencySelectionChanged">
|
||||
<ComboBoxItem x:Name="Frequency5mItem"
|
||||
Tag="5"
|
||||
Content="5 min" />
|
||||
<ComboBoxItem x:Name="Frequency10mItem"
|
||||
Tag="10"
|
||||
Content="10 min" />
|
||||
<ComboBoxItem x:Name="Frequency15mItem"
|
||||
Tag="15"
|
||||
Content="15 min" />
|
||||
<ComboBoxItem x:Name="Frequency20mItem"
|
||||
Tag="20"
|
||||
Content="20 min" />
|
||||
<ComboBoxItem x:Name="Frequency30mItem"
|
||||
Tag="30"
|
||||
Content="30 min" />
|
||||
<ComboBoxItem x:Name="Frequency1hItem"
|
||||
Tag="60"
|
||||
Content="1 hour" />
|
||||
</ComboBox>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</Grid>
|
||||
</Border>
|
||||
</UserControl>
|
||||
@@ -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<int> 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<ComboBoxItem>()
|
||||
.FirstOrDefault(item =>
|
||||
item.Tag is string channelTag &&
|
||||
string.Equals(IfengNewsChannelTypes.Normalize(channelTag), normalizedChannelType, StringComparison.OrdinalIgnoreCase));
|
||||
ChannelComboBox.SelectedItem = selected ?? ChannelComboBox.Items.OfType<ComboBoxItem>().FirstOrDefault();
|
||||
}
|
||||
|
||||
private void SelectInterval(int intervalMinutes)
|
||||
{
|
||||
var selected = FrequencyComboBox.Items
|
||||
.OfType<ComboBoxItem>()
|
||||
.FirstOrDefault(item =>
|
||||
item.Tag is string tagText &&
|
||||
int.TryParse(tagText, out var minutes) &&
|
||||
minutes == intervalMinutes);
|
||||
FrequencyComboBox.SelectedItem = selected ?? FrequencyComboBox.Items.OfType<ComboBoxItem>().FirstOrDefault();
|
||||
}
|
||||
|
||||
private static int NormalizeInterval(int minutes)
|
||||
{
|
||||
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<ComboBoxItem>())
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
196
LanMountainDesktop/Views/Components/IfengNewsWidget.axaml
Normal file
196
LanMountainDesktop/Views/Components/IfengNewsWidget.axaml
Normal file
@@ -0,0 +1,196 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:fi="using:FluentIcons.Avalonia"
|
||||
mc:Ignorable="d"
|
||||
d:DesignWidth="640"
|
||||
d:DesignHeight="640"
|
||||
x:Class="LanMountainDesktop.Views.Components.IfengNewsWidget">
|
||||
|
||||
<Border x:Name="RootBorder"
|
||||
CornerRadius="32"
|
||||
Background="Transparent"
|
||||
ClipToBounds="True"
|
||||
BorderThickness="0"
|
||||
Padding="0">
|
||||
<Grid>
|
||||
<Border x:Name="CardBorder"
|
||||
Background="#FCFCFD"
|
||||
CornerRadius="32"
|
||||
BorderBrush="Transparent"
|
||||
BorderThickness="0"
|
||||
Padding="14,14,14,14">
|
||||
<Grid x:Name="ContentGrid"
|
||||
RowDefinitions="Auto,Auto,Auto,Auto,Auto"
|
||||
RowSpacing="8">
|
||||
<Grid x:Name="HeaderGrid"
|
||||
Grid.Row="0"
|
||||
ColumnDefinitions="*,Auto"
|
||||
ColumnSpacing="10">
|
||||
<TextBlock x:Name="BrandTextBlock"
|
||||
Text="凤凰网新闻"
|
||||
Foreground="#E24B2D"
|
||||
FontSize="28"
|
||||
FontWeight="Bold"
|
||||
VerticalAlignment="Center"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
|
||||
<Button x:Name="RefreshButton"
|
||||
Grid.Column="1"
|
||||
Width="36"
|
||||
Height="36"
|
||||
CornerRadius="18"
|
||||
Background="#EFF1F5"
|
||||
BorderBrush="Transparent"
|
||||
BorderThickness="0"
|
||||
Padding="0"
|
||||
Focusable="False"
|
||||
ToolTip.Tip="刷新"
|
||||
Click="OnRefreshButtonClick">
|
||||
<fi:SymbolIcon x:Name="RefreshGlyphIcon"
|
||||
Symbol="ArrowClockwise"
|
||||
IconVariant="Regular"
|
||||
Foreground="#5E6671"
|
||||
FontSize="16"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center" />
|
||||
</Button>
|
||||
</Grid>
|
||||
|
||||
<Border x:Name="NewsItem1Host"
|
||||
Grid.Row="1"
|
||||
Tag="0"
|
||||
Background="Transparent"
|
||||
Padding="0,2"
|
||||
PointerPressed="OnNewsItemPointerPressed">
|
||||
<Grid x:Name="NewsItem1Grid"
|
||||
ColumnDefinitions="*,Auto"
|
||||
ColumnSpacing="10">
|
||||
<TextBlock x:Name="NewsItem1TextBlock"
|
||||
Text="新闻标题"
|
||||
Foreground="#202327"
|
||||
FontSize="22"
|
||||
FontWeight="SemiBold"
|
||||
TextWrapping="Wrap"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
MaxLines="2"
|
||||
VerticalAlignment="Top" />
|
||||
<Border x:Name="NewsItem1ImageHost"
|
||||
Grid.Column="1"
|
||||
Width="148"
|
||||
Height="84"
|
||||
CornerRadius="12"
|
||||
ClipToBounds="True"
|
||||
Background="#E6E8EC">
|
||||
<Image x:Name="NewsItem1Image"
|
||||
Stretch="UniformToFill" />
|
||||
</Border>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Border x:Name="NewsItem2Host"
|
||||
Grid.Row="2"
|
||||
Tag="1"
|
||||
Background="Transparent"
|
||||
Padding="0,2"
|
||||
PointerPressed="OnNewsItemPointerPressed">
|
||||
<Grid x:Name="NewsItem2Grid"
|
||||
ColumnDefinitions="*,Auto"
|
||||
ColumnSpacing="10">
|
||||
<TextBlock x:Name="NewsItem2TextBlock"
|
||||
Text="新闻标题"
|
||||
Foreground="#202327"
|
||||
FontSize="22"
|
||||
FontWeight="SemiBold"
|
||||
TextWrapping="Wrap"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
MaxLines="2"
|
||||
VerticalAlignment="Top" />
|
||||
<Border x:Name="NewsItem2ImageHost"
|
||||
Grid.Column="1"
|
||||
Width="148"
|
||||
Height="84"
|
||||
CornerRadius="12"
|
||||
ClipToBounds="True"
|
||||
Background="#E6E8EC">
|
||||
<Image x:Name="NewsItem2Image"
|
||||
Stretch="UniformToFill" />
|
||||
</Border>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Border x:Name="NewsItem3Host"
|
||||
Grid.Row="3"
|
||||
Tag="2"
|
||||
Background="Transparent"
|
||||
Padding="0,2"
|
||||
PointerPressed="OnNewsItemPointerPressed">
|
||||
<Grid x:Name="NewsItem3Grid"
|
||||
ColumnDefinitions="*,Auto"
|
||||
ColumnSpacing="10">
|
||||
<TextBlock x:Name="NewsItem3TextBlock"
|
||||
Text="新闻标题"
|
||||
Foreground="#202327"
|
||||
FontSize="22"
|
||||
FontWeight="SemiBold"
|
||||
TextWrapping="Wrap"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
MaxLines="2"
|
||||
VerticalAlignment="Top" />
|
||||
<Border x:Name="NewsItem3ImageHost"
|
||||
Grid.Column="1"
|
||||
Width="148"
|
||||
Height="84"
|
||||
CornerRadius="12"
|
||||
ClipToBounds="True"
|
||||
Background="#E6E8EC">
|
||||
<Image x:Name="NewsItem3Image"
|
||||
Stretch="UniformToFill" />
|
||||
</Border>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Border x:Name="NewsItem4Host"
|
||||
Grid.Row="4"
|
||||
Tag="3"
|
||||
Background="Transparent"
|
||||
Padding="0,2"
|
||||
PointerPressed="OnNewsItemPointerPressed">
|
||||
<Grid x:Name="NewsItem4Grid"
|
||||
ColumnDefinitions="*,Auto"
|
||||
ColumnSpacing="10">
|
||||
<TextBlock x:Name="NewsItem4TextBlock"
|
||||
Text="新闻标题"
|
||||
Foreground="#202327"
|
||||
FontSize="22"
|
||||
FontWeight="SemiBold"
|
||||
TextWrapping="Wrap"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
MaxLines="2"
|
||||
VerticalAlignment="Top" />
|
||||
<Border x:Name="NewsItem4ImageHost"
|
||||
Grid.Column="1"
|
||||
Width="148"
|
||||
Height="84"
|
||||
CornerRadius="12"
|
||||
ClipToBounds="True"
|
||||
Background="#E6E8EC">
|
||||
<Image x:Name="NewsItem4Image"
|
||||
Stretch="UniformToFill" />
|
||||
</Border>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<TextBlock x:Name="StatusTextBlock"
|
||||
IsVisible="False"
|
||||
Text="Loading"
|
||||
Foreground="#6A6F77"
|
||||
FontSize="16"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</Border>
|
||||
</UserControl>
|
||||
647
LanMountainDesktop/Views/Components/IfengNewsWidget.axaml.cs
Normal file
647
LanMountainDesktop/Views/Components/IfengNewsWidget.axaml.cs
Normal file
@@ -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<int> 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<DailyNewsItemSnapshot> _activeItems = [];
|
||||
private readonly List<NewsItemVisual> _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<Bitmap?> 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();
|
||||
}
|
||||
}
|
||||
@@ -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<Border>())
|
||||
{
|
||||
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<Border>())
|
||||
{
|
||||
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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -466,6 +466,12 @@
|
||||
<TextBlock x:Name="SettingsNavLauncherTextBlock" Text="应用启动台" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</ListBoxItem>
|
||||
<ListBoxItem x:Name="SettingsNavPluginsItem" ToolTip.Tip="插件">
|
||||
<StackPanel Orientation="Horizontal" Spacing="12">
|
||||
<fi:SymbolIcon x:Name="SettingsNavPluginsIcon" Symbol="PuzzlePiece" IconVariant="Regular" />
|
||||
<TextBlock x:Name="SettingsNavPluginsTextBlock" Text="插件" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</ListBoxItem>
|
||||
</ListBox>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
@@ -1557,6 +1563,37 @@
|
||||
</ui:SettingsExpander>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel x:Name="PluginSettingsPanel" IsVisible="False" Spacing="16">
|
||||
<TextBlock x:Name="PluginSettingsPanelTitleTextBlock"
|
||||
FontSize="24"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
|
||||
Text="Plugins" />
|
||||
|
||||
<Border Classes="settings-expander-shell">
|
||||
<ui:SettingsExpander x:Name="PluginSystemSettingsExpander"
|
||||
Header="Plugin Runtime"
|
||||
Description="Manage plugin loading and backend isolation."
|
||||
IsExpanded="True">
|
||||
<ui:SettingsExpander.Footer>
|
||||
<StackPanel Spacing="10">
|
||||
<TextBlock x:Name="PluginSystemDescriptionTextBlock"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
|
||||
Text="This page will host installed plugin management, permission review, and sandboxed backend runtime controls." />
|
||||
<Border Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusSm}"
|
||||
Padding="14">
|
||||
<TextBlock x:Name="PluginSystemStatusTextBlock"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
|
||||
TextWrapping="Wrap"
|
||||
Text="Plugin management UI is not connected yet. Next step is wiring the loader, permissions, and worker isolation state into this panel." />
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</ui:SettingsExpander.Footer>
|
||||
</ui:SettingsExpander>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user