百度热搜组件、凤凰新闻组件。
This commit is contained in:
lincube
2026-03-06 22:24:59 +08:00
parent 382d1baaf1
commit 1f509959a9
25 changed files with 3217 additions and 2 deletions

View File

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

View File

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

View 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>

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,196 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:fi="using:FluentIcons.Avalonia"
mc:Ignorable="d"
d:DesignWidth="640"
d:DesignHeight="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>

View 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();
}
}

View File

@@ -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.

View File

@@ -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(

View File

@@ -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)
{

View File

@@ -466,6 +466,12 @@
<TextBlock x:Name="SettingsNavLauncherTextBlock" Text="&#24212;&#29992;&#21551;&#21160;&#21488;" VerticalAlignment="Center" />
</StackPanel>
</ListBoxItem>
<ListBoxItem x:Name="SettingsNavPluginsItem" ToolTip.Tip="&#25554;&#20214;">
<StackPanel Orientation="Horizontal" Spacing="12">
<fi:SymbolIcon x:Name="SettingsNavPluginsIcon" Symbol="PuzzlePiece" IconVariant="Regular" />
<TextBlock x:Name="SettingsNavPluginsTextBlock" Text="&#25554;&#20214;" 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>

View File

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