mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-20 23:54:26 +08:00
0.7.7
橘鸦新闻
This commit is contained in:
1
LanMountainDesktop/Assets/bilibili.svg
Normal file
1
LanMountainDesktop/Assets/bilibili.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Bilibili</title><path d="M17.813 4.653h.854c1.51.054 2.769.578 3.773 1.574 1.004.995 1.524 2.249 1.56 3.76v7.36c-.036 1.51-.556 2.769-1.56 3.773s-2.262 1.524-3.773 1.56H5.333c-1.51-.036-2.769-.556-3.773-1.56S.036 18.858 0 17.347v-7.36c.036-1.511.556-2.765 1.56-3.76 1.004-.996 2.262-1.52 3.773-1.574h.774l-1.174-1.12a1.234 1.234 0 0 1-.373-.906c0-.356.124-.658.373-.907l.027-.027c.267-.249.573-.373.92-.373.347 0 .653.124.92.373L9.653 4.44c.071.071.134.142.187.213h4.267a.836.836 0 0 1 .16-.213l2.853-2.747c.267-.249.573-.373.92-.373.347 0 .662.151.929.4.267.249.391.551.391.907 0 .355-.124.657-.373.906zM5.333 7.24c-.746.018-1.373.276-1.88.773-.506.498-.769 1.13-.786 1.894v7.52c.017.764.28 1.395.786 1.893.507.498 1.134.756 1.88.773h13.334c.746-.017 1.373-.275 1.88-.773.506-.498.769-1.129.786-1.893v-7.52c-.017-.765-.28-1.396-.786-1.894-.507-.497-1.134-.755-1.88-.773zM8 11.107c.373 0 .684.124.933.373.25.249.383.569.4.96v1.173c-.017.391-.15.711-.4.96-.249.25-.56.374-.933.374s-.684-.125-.933-.374c-.25-.249-.383-.569-.4-.96V12.44c0-.373.129-.689.386-.947.258-.257.574-.386.947-.386zm8 0c.373 0 .684.124.933.373.25.249.383.569.4.96v1.173c-.017.391-.15.711-.4.96-.249.25-.56.374-.933.374s-.684-.125-.933-.374c-.25-.249-.383-.569-.4-.96V12.44c.017-.391.15-.711.4-.96.249-.249.56-.373.933-.373Z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
BIN
LanMountainDesktop/Assets/juya_avatar.jpg
Normal file
BIN
LanMountainDesktop/Assets/juya_avatar.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 64 KiB |
1
LanMountainDesktop/Assets/wechat.svg
Normal file
1
LanMountainDesktop/Assets/wechat.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>WeChat</title><path d="M8.691 2.188C3.891 2.188 0 5.476 0 9.53c0 2.212 1.17 4.203 3.002 5.55a.59.59 0 0 1 .213.665l-.39 1.48c-.019.07-.048.141-.048.213 0 .163.13.295.29.295a.326.326 0 0 0 .167-.054l1.903-1.114a.864.864 0 0 1 .717-.098 10.16 10.16 0 0 0 2.837.403c.276 0 .543-.027.811-.05-.857-2.578.157-4.972 1.932-6.446 1.703-1.415 3.882-1.98 5.853-1.838-.576-3.583-4.196-6.348-8.596-6.348zM5.785 5.991c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178A1.17 1.17 0 0 1 4.623 7.17c0-.651.52-1.18 1.162-1.18zm5.813 0c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178 1.17 1.17 0 0 1-1.162-1.178c0-.651.52-1.18 1.162-1.18zm5.34 2.867c-1.797-.052-3.746.512-5.28 1.786-1.72 1.428-2.687 3.72-1.78 6.22.942 2.453 3.666 4.229 6.884 4.229.826 0 1.622-.12 2.361-.336a.722.722 0 0 1 .598.082l1.584.926a.272.272 0 0 0 .14.047c.134 0 .24-.111.24-.247 0-.06-.023-.12-.038-.177l-.327-1.233a.582.582 0 0 1-.023-.156.49.49 0 0 1 .201-.398C23.024 18.48 24 16.82 24 14.98c0-3.21-2.931-5.837-6.656-6.088V8.89c-.135-.01-.27-.027-.407-.03zm-2.53 3.274c.535 0 .969.44.969.982a.976.976 0 0 1-.969.983.976.976 0 0 1-.969-.983c0-.542.434-.982.97-.982zm4.844 0c.535 0 .969.44.969.982a.976.976 0 0 1-.969.983.976.976 0 0 1-.969-.983c0-.542.434-.982.969-.982z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -33,6 +33,7 @@ public static class BuiltInComponentIds
|
||||
public const string DesktopDailyWord2x2 = "DesktopDailyWord2x2";
|
||||
public const string DesktopCnrDailyNews = "DesktopCnrDailyNews";
|
||||
public const string DesktopIfengNews = "DesktopIfengNews";
|
||||
public const string DesktopJuyaNews = "DesktopJuyaNews";
|
||||
public const string DesktopBilibiliHotSearch = "DesktopBilibiliHotSearch";
|
||||
public const string DesktopBaiduHotSearch = "DesktopBaiduHotSearch";
|
||||
public const string DesktopStcn24Forum = "DesktopStcn24Forum";
|
||||
|
||||
@@ -261,6 +261,16 @@ public sealed class ComponentRegistry
|
||||
MinHeightCells: 4,
|
||||
AllowStatusBarPlacement: false,
|
||||
AllowDesktopPlacement: true),
|
||||
new DesktopComponentDefinition(
|
||||
BuiltInComponentIds.DesktopJuyaNews,
|
||||
"橘鸦早报",
|
||||
"News",
|
||||
"Info",
|
||||
MinWidthCells: 4,
|
||||
MinHeightCells: 4,
|
||||
AllowStatusBarPlacement: false,
|
||||
AllowDesktopPlacement: true,
|
||||
ResizeMode: DesktopComponentResizeMode.Free),
|
||||
new DesktopComponentDefinition(
|
||||
BuiltInComponentIds.DesktopBilibiliHotSearch,
|
||||
"Bilibili Hot Search",
|
||||
|
||||
105
LanMountainDesktop/Views/Components/DailyNewsView.axaml
Normal file
105
LanMountainDesktop/Views/Components/DailyNewsView.axaml
Normal file
@@ -0,0 +1,105 @@
|
||||
<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"
|
||||
x:Class="LanMountainDesktop.Views.Components.DailyNewsView">
|
||||
|
||||
<UserControl.Styles>
|
||||
<Style Selector="Button.link-button">
|
||||
<Setter Property="Background" Value="Transparent"/>
|
||||
<Setter Property="BorderThickness" Value="0"/>
|
||||
<Setter Property="Padding" Value="4"/>
|
||||
<Setter Property="Cursor" Value="Hand"/>
|
||||
</Style>
|
||||
</UserControl.Styles>
|
||||
|
||||
<StackPanel x:Name="RootStackPanel" Spacing="16">
|
||||
<Border x:Name="CoverImageBorder"
|
||||
CornerRadius="12"
|
||||
ClipToBounds="True"
|
||||
Background="#f8f5ec"
|
||||
PointerPressed="OnCoverImagePointerPressed"
|
||||
Cursor="Hand">
|
||||
<Image x:Name="CoverImage"
|
||||
Stretch="UniformToFill"/>
|
||||
</Border>
|
||||
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<TextBlock x:Name="DateTextBlock"
|
||||
Grid.Column="0"
|
||||
FontSize="20"
|
||||
FontWeight="Bold"
|
||||
Foreground="#bb5649"
|
||||
VerticalAlignment="Center"/>
|
||||
|
||||
<StackPanel Grid.Column="1"
|
||||
Orientation="Horizontal"
|
||||
Spacing="8"
|
||||
VerticalAlignment="Center">
|
||||
<Button x:Name="BilibiliButton"
|
||||
Classes="link-button"
|
||||
Width="32"
|
||||
Height="32"
|
||||
Padding="0"
|
||||
CornerRadius="16"
|
||||
Background="#FB7299"
|
||||
Cursor="Hand"
|
||||
Click="OnBilibiliButtonClick"
|
||||
ToolTip.Tip="观看视频版">
|
||||
<Path Stretch="Uniform"
|
||||
Width="18"
|
||||
Height="18"
|
||||
Fill="White"
|
||||
Data="M17.813 4.653h.854c1.51.054 2.769.578 3.773 1.574 1.004.995 1.524 2.249 1.56 3.76v7.36c-.036 1.51-.556 2.769-1.56 3.773s-2.262 1.524-3.773 1.56H5.333c-1.51-.036-2.769-.556-3.773-1.56S.036 18.858 0 17.347v-7.36c.036-1.511.556-2.765 1.56-3.76 1.004-.996 2.262-1.52 3.773-1.574h.774l-1.174-1.12a1.234 1.234 0 0 1-.373-.906c0-.356.124-.658.373-.907l.027-.027c.267-.249.573-.373.92-.373.347 0 .653.124.92.373L9.653 4.44c.071.071.134.142.187.213h4.267a.836.836 0 0 1 .16-.213l2.853-2.747c.267-.249.573-.373.92-.373.347 0 .662.151.929.4.267.249.391.551.391.907 0 .355-.124.657-.373.906zM5.333 7.24c-.746.018-1.373.276-1.88.773-.506.498-.769 1.13-.786 1.894v7.52c.017.764.28 1.395.786 1.893.507.498 1.134.756 1.88.773h13.334c.746-.017 1.373-.275 1.88-.773.506-.498.769-1.129.786-1.893v-7.52c-.017-.765-.28-1.396-.786-1.894-.507-.497-1.134-.755-1.88-.773zM8 11.107c.373 0 .684.124.933.373.25.249.383.569.4.96v1.173c-.017.391-.15.711-.4.96-.249.25-.56.374-.933.374s-.684-.125-.933-.374c-.25-.249-.383-.569-.4-.96V12.44c0-.373.129-.689.386-.947.258-.257.574-.386.947-.386zm8 0c.373 0 .684.124.933.373.25.249.383.569.4.96v1.173c-.017.391-.15.711-.4.96-.249.25-.56.374-.933.374s-.684-.125-.933-.374c-.25-.249-.383-.569-.4-.96V12.44c.017-.391.15-.711.4-.96.249-.249.56-.373.933-.373Z"/>
|
||||
</Button>
|
||||
|
||||
<Button x:Name="WechatButton"
|
||||
Classes="link-button"
|
||||
Width="32"
|
||||
Height="32"
|
||||
Padding="0"
|
||||
CornerRadius="16"
|
||||
Background="#07C160"
|
||||
Cursor="Hand"
|
||||
Click="OnWechatButtonClick"
|
||||
ToolTip.Tip="阅读原文">
|
||||
<Path Stretch="Uniform"
|
||||
Width="18"
|
||||
Height="18"
|
||||
Fill="White"
|
||||
Data="M8.691 2.188C3.891 2.188 0 5.476 0 9.53c0 2.212 1.17 4.203 3.002 5.55a.59.59 0 0 1 .213.665l-.39 1.48c-.019.07-.048.141-.048.213 0 .163.13.295.29.295a.326.326 0 0 0 .167-.054l1.903-1.114a.864.864 0 0 1 .717-.098 10.16 10.16 0 0 0 2.837.403c.276 0 .543-.027.811-.05-.857-2.578.157-4.972 1.932-6.446 1.703-1.415 3.882-1.98 5.853-1.838-.576-3.583-4.196-6.348-8.596-6.348zM5.785 5.991c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178A1.17 1.17 0 0 1 4.623 7.17c0-.651.52-1.18 1.162-1.18zm5.813 0c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178 1.17 1.17 0 0 1-1.162-1.178c0-.651.52-1.18 1.162-1.18zm5.34 2.867c-1.797-.052-3.746.512-5.28 1.786-1.72 1.428-2.687 3.72-1.78 6.22.942 2.453 3.666 4.229 6.884 4.229.826 0 1.622-.12 2.361-.336a.722.722 0 0 1 .598.082l1.584.926a.272.272 0 0 0 .14.047c.134 0 .24-.111.24-.247 0-.06-.023-.12-.038-.177l-.327-1.233a.582.582 0 0 1-.023-.156.49.49 0 0 1 .201-.398C23.024 18.48 24 16.82 24 14.98c0-3.21-2.931-5.837-6.656-6.088V8.89c-.135-.01-.27-.027-.407-.03zm-2.53 3.274c.535 0 .969.44.969.982a.976.976 0 0 1-.969.983.976.976 0 0 1-.969-.983c0-.542.434-.982.97-.982zm4.844 0c.535 0 .969.44.969.982a.976.976 0 0 1-.969.983.976.976 0 0 1-.969-.983c0-.542.434-.982.969-.982z"/>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<Border x:Name="OverviewBorder"
|
||||
Background="#f8f5ec"
|
||||
CornerRadius="8"
|
||||
Padding="12"
|
||||
Margin="0,0,0,8">
|
||||
<StackPanel x:Name="OverviewStackPanel" Spacing="12"/>
|
||||
</Border>
|
||||
|
||||
<Button x:Name="ShowMoreButton"
|
||||
Content="展开更多新闻 ▼"
|
||||
FontSize="14"
|
||||
Padding="16,8"
|
||||
CornerRadius="8"
|
||||
Background="Transparent"
|
||||
BorderBrush="#bb5649"
|
||||
BorderThickness="1"
|
||||
Foreground="#bb5649"
|
||||
Cursor="Hand"
|
||||
Click="OnShowMoreButtonClick"/>
|
||||
|
||||
<StackPanel x:Name="DetailedNewsStackPanel"
|
||||
Spacing="16"
|
||||
IsVisible="False"/>
|
||||
|
||||
<Border x:Name="DateSeparatorBorder"
|
||||
Height="1"
|
||||
Background="#e6e6e6"
|
||||
Margin="0,8,0,0"/>
|
||||
</StackPanel>
|
||||
</UserControl>
|
||||
526
LanMountainDesktop/Views/Components/DailyNewsView.axaml.cs
Normal file
526
LanMountainDesktop/Views/Components/DailyNewsView.axaml.cs
Normal file
@@ -0,0 +1,526 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
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;
|
||||
|
||||
namespace LanMountainDesktop.Views.Components;
|
||||
|
||||
public partial class DailyNewsView : UserControl
|
||||
{
|
||||
private static readonly HttpClient HttpClient = new()
|
||||
{
|
||||
Timeout = TimeSpan.FromSeconds(10)
|
||||
};
|
||||
|
||||
private readonly JuyaDailyNews _news;
|
||||
private Bitmap? _coverBitmap;
|
||||
private bool _isNightMode;
|
||||
private bool _isExpanded;
|
||||
|
||||
public event EventHandler? CoverImageClicked;
|
||||
public event EventHandler<string>? NewsItemClicked;
|
||||
|
||||
public DailyNewsView(JuyaDailyNews news, bool isNightMode)
|
||||
{
|
||||
InitializeComponent();
|
||||
_news = news;
|
||||
_isNightMode = isNightMode;
|
||||
|
||||
var dateStr = news.Date.ToString("yyyy年M月d日");
|
||||
var dayOfWeek = news.Date.ToString("dddd");
|
||||
DateTextBlock.Text = $"{dateStr} {dayOfWeek}";
|
||||
|
||||
_ = LoadCoverImageAsync(news.CoverImageUrl);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(news.BilibiliUrl))
|
||||
{
|
||||
BilibiliButton.IsVisible = false;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(news.IssueUrl))
|
||||
{
|
||||
WechatButton.IsVisible = false;
|
||||
}
|
||||
|
||||
if (news.OverviewCategories.Any())
|
||||
{
|
||||
foreach (var category in news.OverviewCategories)
|
||||
{
|
||||
var categoryPanel = new StackPanel { Spacing = 6 };
|
||||
|
||||
var categoryHeader = new TextBlock
|
||||
{
|
||||
Text = $"{category.Icon} {category.Name}",
|
||||
FontSize = 15,
|
||||
FontWeight = FontWeight.SemiBold,
|
||||
Foreground = new SolidColorBrush(isNightMode ? Color.Parse("#d4736a") : Color.Parse("#bb5649"))
|
||||
};
|
||||
categoryPanel.Children.Add(categoryHeader);
|
||||
|
||||
foreach (var item in category.Items)
|
||||
{
|
||||
var itemPanel = new StackPanel
|
||||
{
|
||||
Orientation = Avalonia.Layout.Orientation.Horizontal,
|
||||
Spacing = 4
|
||||
};
|
||||
|
||||
var bulletText = new TextBlock
|
||||
{
|
||||
Text = "•",
|
||||
FontSize = 13,
|
||||
Foreground = new SolidColorBrush(isNightMode ? Color.Parse("#9a9590") : Color.Parse("#757575"))
|
||||
};
|
||||
itemPanel.Children.Add(bulletText);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(item.Url))
|
||||
{
|
||||
var linkButton = new HyperlinkButton
|
||||
{
|
||||
Content = item.Title,
|
||||
NavigateUri = new Uri(item.Url),
|
||||
FontSize = 13,
|
||||
Foreground = new SolidColorBrush(isNightMode ? Color.Parse("#9a9590") : Color.Parse("#757575")),
|
||||
Padding = new Thickness(0)
|
||||
};
|
||||
itemPanel.Children.Add(linkButton);
|
||||
}
|
||||
else
|
||||
{
|
||||
var titleText = new TextBlock
|
||||
{
|
||||
Text = item.Title,
|
||||
FontSize = 13,
|
||||
TextWrapping = TextWrapping.Wrap,
|
||||
Foreground = new SolidColorBrush(isNightMode ? Color.Parse("#9a9590") : Color.Parse("#757575"))
|
||||
};
|
||||
itemPanel.Children.Add(titleText);
|
||||
}
|
||||
|
||||
if (item.Number.HasValue)
|
||||
{
|
||||
var numberText = new TextBlock
|
||||
{
|
||||
Text = $"#{item.Number}",
|
||||
FontSize = 11,
|
||||
Foreground = new SolidColorBrush(isNightMode ? Color.Parse("#d4736a") : Color.Parse("#bb5649")),
|
||||
FontWeight = FontWeight.SemiBold,
|
||||
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center
|
||||
};
|
||||
itemPanel.Children.Add(numberText);
|
||||
}
|
||||
|
||||
categoryPanel.Children.Add(itemPanel);
|
||||
}
|
||||
|
||||
OverviewStackPanel.Children.Add(categoryPanel);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
OverviewBorder.IsVisible = false;
|
||||
}
|
||||
|
||||
if (!news.DetailedNews.Any())
|
||||
{
|
||||
ShowMoreButton.IsVisible = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var detailedItem in news.DetailedNews)
|
||||
{
|
||||
var newsPanel = CreateDetailedNewsPanel(detailedItem, isNightMode);
|
||||
DetailedNewsStackPanel.Children.Add(newsPanel);
|
||||
}
|
||||
}
|
||||
|
||||
ApplyNightMode(isNightMode);
|
||||
}
|
||||
|
||||
private Border CreateDetailedNewsPanel(JuyaDetailedNewsItem detailedItem, bool isNightMode)
|
||||
{
|
||||
var primaryColor = isNightMode ? "#d4736a" : "#bb5649";
|
||||
var textColor = isNightMode ? "#e8e4e0" : "#34495e";
|
||||
var secondaryTextColor = isNightMode ? "#9a9590" : "#757575";
|
||||
|
||||
var mainBorder = new Border
|
||||
{
|
||||
Background = Brushes.Transparent,
|
||||
BorderBrush = new SolidColorBrush(Color.Parse("#e6e6e6")),
|
||||
BorderThickness = new Thickness(0, 0, 0, 1),
|
||||
Padding = new Thickness(0, 0, 0, 16)
|
||||
};
|
||||
|
||||
var mainStack = new StackPanel { Spacing = 12 };
|
||||
mainBorder.Child = mainStack;
|
||||
|
||||
var headerPanel = new StackPanel
|
||||
{
|
||||
Orientation = Avalonia.Layout.Orientation.Horizontal,
|
||||
Spacing = 8
|
||||
};
|
||||
|
||||
if (detailedItem.Number > 0)
|
||||
{
|
||||
var numberBadge = new Border
|
||||
{
|
||||
Background = new SolidColorBrush(Color.Parse(primaryColor)),
|
||||
CornerRadius = new CornerRadius(4),
|
||||
Padding = new Thickness(6, 2),
|
||||
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center
|
||||
};
|
||||
var numberText = new TextBlock
|
||||
{
|
||||
Text = $"#{detailedItem.Number}",
|
||||
FontSize = 12,
|
||||
FontWeight = FontWeight.Bold,
|
||||
Foreground = Brushes.White
|
||||
};
|
||||
numberBadge.Child = numberText;
|
||||
headerPanel.Children.Add(numberBadge);
|
||||
}
|
||||
|
||||
var titleText = new TextBlock
|
||||
{
|
||||
Text = detailedItem.Title,
|
||||
FontSize = 16,
|
||||
FontWeight = FontWeight.SemiBold,
|
||||
Foreground = new SolidColorBrush(Color.Parse(textColor)),
|
||||
TextWrapping = TextWrapping.Wrap,
|
||||
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center
|
||||
};
|
||||
headerPanel.Children.Add(titleText);
|
||||
mainStack.Children.Add(headerPanel);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(detailedItem.BodyText))
|
||||
{
|
||||
var bodyText = new TextBlock
|
||||
{
|
||||
Text = detailedItem.BodyText,
|
||||
FontSize = 14,
|
||||
LineHeight = 22,
|
||||
TextWrapping = TextWrapping.Wrap,
|
||||
Foreground = new SolidColorBrush(Color.Parse(textColor))
|
||||
};
|
||||
mainStack.Children.Add(bodyText);
|
||||
}
|
||||
|
||||
if (detailedItem.RelatedLinks.Any())
|
||||
{
|
||||
var linksPanel = new StackPanel { Spacing = 4 };
|
||||
|
||||
var linksHeader = new TextBlock
|
||||
{
|
||||
Text = "相关链接:",
|
||||
FontSize = 12,
|
||||
Foreground = new SolidColorBrush(Color.Parse(secondaryTextColor))
|
||||
};
|
||||
linksPanel.Children.Add(linksHeader);
|
||||
|
||||
foreach (var link in detailedItem.RelatedLinks.Take(3))
|
||||
{
|
||||
var linkButton = new HyperlinkButton
|
||||
{
|
||||
Content = link.Length > 50 ? link.Substring(0, 50) + "..." : link,
|
||||
NavigateUri = new Uri(link),
|
||||
FontSize = 12,
|
||||
Foreground = new SolidColorBrush(Color.Parse(primaryColor))
|
||||
};
|
||||
linksPanel.Children.Add(linkButton);
|
||||
}
|
||||
|
||||
mainStack.Children.Add(linksPanel);
|
||||
}
|
||||
|
||||
return mainBorder;
|
||||
}
|
||||
|
||||
private void OnShowMoreButtonClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
_isExpanded = !_isExpanded;
|
||||
DetailedNewsStackPanel.IsVisible = _isExpanded;
|
||||
ShowMoreButton.Content = _isExpanded ? "收起新闻 ▲" : "展开更多新闻 ▼";
|
||||
}
|
||||
|
||||
private void OnBilibiliButtonClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(_news.BilibiliUrl))
|
||||
{
|
||||
TryOpenUrl(_news.BilibiliUrl);
|
||||
}
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private void OnWechatButtonClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(_news.IssueUrl))
|
||||
{
|
||||
TryOpenUrl(_news.IssueUrl);
|
||||
}
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private static void TryOpenUrl(string? url)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(url))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = url,
|
||||
UseShellExecute = true
|
||||
};
|
||||
Process.Start(startInfo);
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadCoverImageAsync(string? imageUrl)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(imageUrl))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var response = await HttpClient.GetAsync(imageUrl);
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
await using var stream = await response.Content.ReadAsStreamAsync();
|
||||
var bitmap = new Bitmap(stream);
|
||||
_coverBitmap = bitmap;
|
||||
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
CoverImage.Source = bitmap;
|
||||
});
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private void OnCoverImagePointerPressed(object? sender, PointerPressedEventArgs e)
|
||||
{
|
||||
if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
|
||||
{
|
||||
CoverImageClicked?.Invoke(this, EventArgs.Empty);
|
||||
e.Handled = true;
|
||||
}
|
||||
}
|
||||
|
||||
public void ApplyNightMode(bool isNightMode)
|
||||
{
|
||||
_isNightMode = isNightMode;
|
||||
var primaryColor = isNightMode ? "#d4736a" : "#bb5649";
|
||||
var textColor = isNightMode ? "#e8e4e0" : "#34495e";
|
||||
var secondaryTextColor = isNightMode ? "#9a9590" : "#757575";
|
||||
var separatorColor = isNightMode ? "#3d3a3a" : "#e6e6e6";
|
||||
var coverBgColor = isNightMode ? "#3d3a3a" : "#f8f5ec";
|
||||
var overviewBgColor = isNightMode ? "#3d3a3a" : "#f8f5ec";
|
||||
|
||||
DateTextBlock.Foreground = new SolidColorBrush(Color.Parse(primaryColor));
|
||||
DateSeparatorBorder.Background = new SolidColorBrush(Color.Parse(separatorColor));
|
||||
CoverImageBorder.Background = new SolidColorBrush(Color.Parse(coverBgColor));
|
||||
OverviewBorder.Background = new SolidColorBrush(Color.Parse(overviewBgColor));
|
||||
|
||||
ShowMoreButton.BorderBrush = new SolidColorBrush(Color.Parse(primaryColor));
|
||||
ShowMoreButton.Foreground = new SolidColorBrush(Color.Parse(primaryColor));
|
||||
|
||||
foreach (var child in OverviewStackPanel.Children)
|
||||
{
|
||||
if (child is StackPanel categoryPanel && categoryPanel.Children.Count > 0)
|
||||
{
|
||||
if (categoryPanel.Children[0] is TextBlock categoryHeader)
|
||||
{
|
||||
categoryHeader.Foreground = new SolidColorBrush(Color.Parse(primaryColor));
|
||||
}
|
||||
|
||||
for (int i = 1; i < categoryPanel.Children.Count; i++)
|
||||
{
|
||||
if (categoryPanel.Children[i] is StackPanel itemPanel)
|
||||
{
|
||||
foreach (var itemChild in itemPanel.Children)
|
||||
{
|
||||
if (itemChild is TextBlock textBlock)
|
||||
{
|
||||
if (textBlock.Text.StartsWith("#"))
|
||||
{
|
||||
textBlock.Foreground = new SolidColorBrush(Color.Parse(primaryColor));
|
||||
}
|
||||
else
|
||||
{
|
||||
textBlock.Foreground = new SolidColorBrush(Color.Parse(secondaryTextColor));
|
||||
}
|
||||
}
|
||||
else if (itemChild is HyperlinkButton linkBtn)
|
||||
{
|
||||
linkBtn.Foreground = new SolidColorBrush(Color.Parse(secondaryTextColor));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var child in DetailedNewsStackPanel.Children)
|
||||
{
|
||||
if (child is Border mainBorder && mainBorder.Child is StackPanel mainStack)
|
||||
{
|
||||
mainBorder.BorderBrush = new SolidColorBrush(Color.Parse(separatorColor));
|
||||
|
||||
foreach (var stackChild in mainStack.Children)
|
||||
{
|
||||
if (stackChild is StackPanel headerPanel)
|
||||
{
|
||||
foreach (var headerChild in headerPanel.Children)
|
||||
{
|
||||
if (headerChild is Border numberBadge && numberBadge.Child is TextBlock numberText)
|
||||
{
|
||||
numberBadge.Background = new SolidColorBrush(Color.Parse(primaryColor));
|
||||
}
|
||||
else if (headerChild is TextBlock titleText)
|
||||
{
|
||||
titleText.Foreground = new SolidColorBrush(Color.Parse(textColor));
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (stackChild is TextBlock bodyText)
|
||||
{
|
||||
bodyText.Foreground = new SolidColorBrush(Color.Parse(textColor));
|
||||
}
|
||||
else if (stackChild is StackPanel linksPanel)
|
||||
{
|
||||
foreach (var linkChild in linksPanel.Children)
|
||||
{
|
||||
if (linkChild is TextBlock linksHeader)
|
||||
{
|
||||
linksHeader.Foreground = new SolidColorBrush(Color.Parse(secondaryTextColor));
|
||||
}
|
||||
else if (linkChild is HyperlinkButton linkButton)
|
||||
{
|
||||
linkButton.Foreground = new SolidColorBrush(Color.Parse(primaryColor));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void UpdateLayout(double scale, double availableWidth)
|
||||
{
|
||||
var coverHeight = availableWidth * 9 / 16;
|
||||
CoverImageBorder.Width = availableWidth;
|
||||
CoverImageBorder.Height = coverHeight;
|
||||
|
||||
DateTextBlock.FontSize = Math.Clamp(20 * scale, 16, 26);
|
||||
|
||||
ShowMoreButton.FontSize = Math.Clamp(14 * scale, 12, 16);
|
||||
|
||||
var buttonSize = Math.Clamp(32 * scale, 24, 40);
|
||||
BilibiliButton.Width = buttonSize;
|
||||
BilibiliButton.Height = buttonSize;
|
||||
BilibiliButton.CornerRadius = new CornerRadius(buttonSize / 2);
|
||||
|
||||
WechatButton.Width = buttonSize;
|
||||
WechatButton.Height = buttonSize;
|
||||
WechatButton.CornerRadius = new CornerRadius(buttonSize / 2);
|
||||
|
||||
foreach (var child in OverviewStackPanel.Children)
|
||||
{
|
||||
if (child is StackPanel categoryPanel && categoryPanel.Children.Count > 0)
|
||||
{
|
||||
if (categoryPanel.Children[0] is TextBlock categoryHeader)
|
||||
{
|
||||
categoryHeader.FontSize = Math.Clamp(15 * scale, 13, 18);
|
||||
}
|
||||
|
||||
for (int i = 1; i < categoryPanel.Children.Count; i++)
|
||||
{
|
||||
if (categoryPanel.Children[i] is StackPanel itemPanel)
|
||||
{
|
||||
foreach (var itemChild in itemPanel.Children)
|
||||
{
|
||||
if (itemChild is TextBlock textBlock)
|
||||
{
|
||||
textBlock.FontSize = Math.Clamp(13 * scale, 11, 15);
|
||||
}
|
||||
else if (itemChild is HyperlinkButton linkBtn)
|
||||
{
|
||||
linkBtn.FontSize = Math.Clamp(13 * scale, 11, 15);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var child in DetailedNewsStackPanel.Children)
|
||||
{
|
||||
if (child is Border mainBorder && mainBorder.Child is StackPanel mainStack)
|
||||
{
|
||||
foreach (var stackChild in mainStack.Children)
|
||||
{
|
||||
if (stackChild is StackPanel headerPanel)
|
||||
{
|
||||
foreach (var headerChild in headerPanel.Children)
|
||||
{
|
||||
if (headerChild is Border numberBadge && numberBadge.Child is TextBlock numberText)
|
||||
{
|
||||
numberText.FontSize = Math.Clamp(12 * scale, 10, 14);
|
||||
}
|
||||
else if (headerChild is TextBlock titleText)
|
||||
{
|
||||
titleText.FontSize = Math.Clamp(16 * scale, 14, 20);
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (stackChild is TextBlock bodyText)
|
||||
{
|
||||
bodyText.FontSize = Math.Clamp(14 * scale, 12, 16);
|
||||
bodyText.LineHeight = 22 * scale;
|
||||
}
|
||||
else if (stackChild is StackPanel linksPanel)
|
||||
{
|
||||
foreach (var linkChild in linksPanel.Children)
|
||||
{
|
||||
if (linkChild is TextBlock linksHeader)
|
||||
{
|
||||
linksHeader.FontSize = Math.Clamp(12 * scale, 10, 14);
|
||||
}
|
||||
else if (linkChild is HyperlinkButton linkButton)
|
||||
{
|
||||
linkButton.FontSize = Math.Clamp(12 * scale, 10, 14);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
base.OnDetachedFromVisualTree(e);
|
||||
_coverBitmap?.Dispose();
|
||||
_coverBitmap = null;
|
||||
}
|
||||
}
|
||||
@@ -428,6 +428,10 @@ public sealed class DesktopComponentRuntimeRegistry
|
||||
BuiltInComponentIds.DesktopIfengNews,
|
||||
"component.ifeng_news",
|
||||
() => new IfengNewsWidget()),
|
||||
new DesktopComponentRuntimeRegistration(
|
||||
BuiltInComponentIds.DesktopJuyaNews,
|
||||
"component.juya_news",
|
||||
() => new JuyaNewsWidget()),
|
||||
new DesktopComponentRuntimeRegistration(
|
||||
BuiltInComponentIds.DesktopBilibiliHotSearch,
|
||||
"component.bilibili_hot_search",
|
||||
|
||||
104
LanMountainDesktop/Views/Components/JuyaNewsWidget.axaml
Normal file
104
LanMountainDesktop/Views/Components/JuyaNewsWidget.axaml
Normal file
@@ -0,0 +1,104 @@
|
||||
<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.JuyaNewsWidget">
|
||||
|
||||
<Border x:Name="RootBorder"
|
||||
CornerRadius="24"
|
||||
Background="Transparent"
|
||||
ClipToBounds="True"
|
||||
BorderThickness="0"
|
||||
Padding="0">
|
||||
<Grid>
|
||||
<Border x:Name="CardBorder"
|
||||
Background="#fefefe"
|
||||
CornerRadius="24"
|
||||
BorderBrush="Transparent"
|
||||
BorderThickness="0"
|
||||
Padding="16,14,16,14">
|
||||
<Grid x:Name="ContentGrid"
|
||||
RowDefinitions="Auto,*">
|
||||
|
||||
<!-- Header -->
|
||||
<Grid x:Name="HeaderGrid"
|
||||
Grid.Row="0"
|
||||
ColumnDefinitions="*,Auto"
|
||||
ColumnSpacing="10"
|
||||
Margin="0,0,0,12">
|
||||
<StackPanel Orientation="Horizontal" VerticalAlignment="Center" Spacing="10">
|
||||
<Border x:Name="AvatarBorder"
|
||||
Width="36"
|
||||
Height="36"
|
||||
CornerRadius="18"
|
||||
ClipToBounds="True"
|
||||
Background="#f8f5ec">
|
||||
<Image x:Name="AvatarImage"
|
||||
Source="avares://LanMountainDesktop/Assets/juya_avatar.jpg"
|
||||
Stretch="UniformToFill"/>
|
||||
</Border>
|
||||
<TextBlock x:Name="BrandTextBlock"
|
||||
Text="橘鸦Juya"
|
||||
Foreground="#bb5649"
|
||||
FontSize="20"
|
||||
FontWeight="Bold"
|
||||
VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
|
||||
<Button x:Name="RefreshButton"
|
||||
Grid.Column="1"
|
||||
Padding="8,4"
|
||||
CornerRadius="8"
|
||||
Background="Transparent"
|
||||
BorderBrush="#bb5649"
|
||||
BorderThickness="1"
|
||||
Foreground="#bb5649"
|
||||
Focusable="False"
|
||||
ToolTip.Tip="刷新新闻"
|
||||
Click="OnRefreshButtonClick">
|
||||
<StackPanel Orientation="Horizontal" Spacing="4">
|
||||
<fi:SymbolIcon Symbol="ArrowSync"
|
||||
IconVariant="Regular"
|
||||
FontSize="14"
|
||||
Foreground="#bb5649" />
|
||||
<TextBlock Text="刷新"
|
||||
FontSize="13"
|
||||
VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</Grid>
|
||||
|
||||
<!-- 滚动内容区 -->
|
||||
<ScrollViewer x:Name="ContentScrollViewer"
|
||||
Grid.Row="1"
|
||||
VerticalScrollBarVisibility="Auto"
|
||||
ScrollChanged="OnScrollChanged">
|
||||
<StackPanel x:Name="NewsStackPanel" Spacing="16">
|
||||
|
||||
<!-- 加载提示 -->
|
||||
<TextBlock x:Name="LoadingTextBlock"
|
||||
Text="正在加载..."
|
||||
Foreground="#757575"
|
||||
FontSize="14"
|
||||
HorizontalAlignment="Center"
|
||||
IsVisible="False" />
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<TextBlock x:Name="StatusTextBlock"
|
||||
IsVisible="False"
|
||||
Text="Loading"
|
||||
Foreground="#757575"
|
||||
FontSize="16"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</Border>
|
||||
</UserControl>
|
||||
756
LanMountainDesktop/Views/Components/JuyaNewsWidget.axaml.cs
Normal file
756
LanMountainDesktop/Views/Components/JuyaNewsWidget.axaml.cs
Normal file
@@ -0,0 +1,756 @@
|
||||
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 System.Xml.Linq;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Media.Imaging;
|
||||
using Avalonia.Styling;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.Services;
|
||||
|
||||
namespace LanMountainDesktop.Views.Components;
|
||||
|
||||
public partial class JuyaNewsWidget : UserControl, IDesktopComponentWidget
|
||||
{
|
||||
private static readonly FontFamily MiSansFontFamily = new("MiSans VF, avares://LanMountainDesktop/Assets/Fonts#MiSans");
|
||||
private static readonly HttpClient HttpClient = new()
|
||||
{
|
||||
Timeout = TimeSpan.FromSeconds(15)
|
||||
};
|
||||
|
||||
private const string RssUrl = "https://imjuya.github.io/juya-ai-daily/rss.xml";
|
||||
private const double BaseCellSize = 48d;
|
||||
private const int BaseWidthCells = 4;
|
||||
private const int BaseHeightCells = 4;
|
||||
private const int InitialLoadDays = 3;
|
||||
private const int LoadMoreDays = 3;
|
||||
private const int MaxCachedDays = 30;
|
||||
|
||||
private readonly Dictionary<DateTime, JuyaDailyNews> _cachedNews = new();
|
||||
private readonly List<DateTime> _loadedDates = new();
|
||||
private readonly List<DailyNewsView> _dailyViews = new();
|
||||
|
||||
private double _currentCellSize = BaseCellSize;
|
||||
private bool _isAttached;
|
||||
private bool _isLoading;
|
||||
private bool _isNightVisual;
|
||||
private DateTime _earliestLoadedDate = DateTime.Today;
|
||||
|
||||
public JuyaNewsWidget()
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
BrandTextBlock.FontFamily = MiSansFontFamily;
|
||||
LoadingTextBlock.FontFamily = MiSansFontFamily;
|
||||
StatusTextBlock.FontFamily = MiSansFontFamily;
|
||||
|
||||
AttachedToVisualTree += OnAttachedToVisualTree;
|
||||
DetachedFromVisualTree += OnDetachedFromVisualTree;
|
||||
SizeChanged += OnSizeChanged;
|
||||
ActualThemeVariantChanged += OnActualThemeVariantChanged;
|
||||
|
||||
ApplyCellSize(_currentCellSize);
|
||||
ApplyLoadingState();
|
||||
}
|
||||
|
||||
public void ApplyCellSize(double cellSize)
|
||||
{
|
||||
_currentCellSize = Math.Max(1, cellSize);
|
||||
UpdateAdaptiveLayout();
|
||||
}
|
||||
|
||||
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
_isAttached = true;
|
||||
_ = LoadInitialNewsAsync();
|
||||
}
|
||||
|
||||
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
_isAttached = false;
|
||||
}
|
||||
|
||||
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
|
||||
{
|
||||
ApplyCellSize(_currentCellSize);
|
||||
}
|
||||
|
||||
private void OnActualThemeVariantChanged(object? sender, EventArgs e)
|
||||
{
|
||||
_isNightVisual = ResolveNightMode();
|
||||
UpdateAdaptiveLayout();
|
||||
}
|
||||
|
||||
private bool ResolveNightMode()
|
||||
{
|
||||
if (ActualThemeVariant == ThemeVariant.Dark)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (ActualThemeVariant == ThemeVariant.Light)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.TryFindResource("AdaptiveSurfaceBaseBrush", out var value) &&
|
||||
value is ISolidColorBrush brush)
|
||||
{
|
||||
return CalculateRelativeLuminance(brush.Color) < 0.45;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static double CalculateRelativeLuminance(Color color)
|
||||
{
|
||||
static double ToLinear(double channel)
|
||||
{
|
||||
return channel <= 0.03928
|
||||
? channel / 12.92
|
||||
: Math.Pow((channel + 0.055) / 1.055, 2.4);
|
||||
}
|
||||
|
||||
var r = ToLinear(color.R / 255d);
|
||||
var g = ToLinear(color.G / 255d);
|
||||
var b = ToLinear(color.B / 255d);
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
}
|
||||
|
||||
private void ApplyNightModeVisual()
|
||||
{
|
||||
// 卡片背景
|
||||
CardBorder.Background = new SolidColorBrush(_isNightVisual ? Color.Parse("#2d2a2a") : Color.Parse("#fefefe"));
|
||||
|
||||
// 品牌标题
|
||||
BrandTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#d4736a") : Color.Parse("#bb5649"));
|
||||
|
||||
// 刷新按钮
|
||||
RefreshButton.BorderBrush = new SolidColorBrush(_isNightVisual ? Color.Parse("#d4736a") : Color.Parse("#bb5649"));
|
||||
RefreshButton.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#d4736a") : Color.Parse("#bb5649"));
|
||||
|
||||
// 头像背景
|
||||
AvatarBorder.Background = new SolidColorBrush(_isNightVisual ? Color.Parse("#3d3a3a") : Color.Parse("#f8f5ec"));
|
||||
|
||||
// 状态文字
|
||||
StatusTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#9a9590") : Color.Parse("#757575"));
|
||||
LoadingTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#9a9590") : Color.Parse("#757575"));
|
||||
|
||||
// 更新所有日期视图的样式
|
||||
foreach (var view in _dailyViews)
|
||||
{
|
||||
view.ApplyNightMode(_isNightVisual);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadInitialNewsAsync()
|
||||
{
|
||||
if (!_isAttached || _isLoading)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_isLoading = true;
|
||||
LoadingTextBlock.IsVisible = true;
|
||||
StatusTextBlock.IsVisible = false;
|
||||
|
||||
try
|
||||
{
|
||||
// 解析RSS获取所有新闻
|
||||
var allNews = await FetchJuyaNewsAsync();
|
||||
|
||||
if (!_isAttached)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// 缓存新闻数据
|
||||
foreach (var news in allNews)
|
||||
{
|
||||
_cachedNews[news.Date.Date] = news;
|
||||
}
|
||||
|
||||
// 加载最近几天的新闻
|
||||
var today = DateTime.Today;
|
||||
var datesToLoad = Enumerable.Range(0, InitialLoadDays)
|
||||
.Select(i => today.AddDays(-i))
|
||||
.Where(d => _cachedNews.ContainsKey(d))
|
||||
.OrderByDescending(d => d)
|
||||
.ToList();
|
||||
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
if (!_isAttached) return;
|
||||
|
||||
NewsStackPanel.Children.Clear();
|
||||
_dailyViews.Clear();
|
||||
_loadedDates.Clear();
|
||||
|
||||
foreach (var date in datesToLoad)
|
||||
{
|
||||
AddDailyNewsToView(_cachedNews[date]);
|
||||
_loadedDates.Add(date);
|
||||
}
|
||||
|
||||
if (_loadedDates.Any())
|
||||
{
|
||||
_earliestLoadedDate = _loadedDates.Min();
|
||||
}
|
||||
|
||||
LoadingTextBlock.IsVisible = false;
|
||||
StatusTextBlock.IsVisible = false;
|
||||
UpdateAdaptiveLayout();
|
||||
});
|
||||
}
|
||||
catch
|
||||
{
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
if (!_isAttached) return;
|
||||
StatusTextBlock.Text = "加载失败";
|
||||
StatusTextBlock.IsVisible = true;
|
||||
LoadingTextBlock.IsVisible = false;
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<List<JuyaDailyNews>> FetchJuyaNewsAsync()
|
||||
{
|
||||
var result = new List<JuyaDailyNews>();
|
||||
|
||||
try
|
||||
{
|
||||
// 使用字节数组获取内容,确保正确解码 UTF-8
|
||||
var response = await HttpClient.GetByteArrayAsync(RssUrl);
|
||||
var rssContent = System.Text.Encoding.UTF8.GetString(response);
|
||||
var doc = XDocument.Parse(rssContent);
|
||||
|
||||
var contentNs = XNamespace.Get("http://purl.org/rss/1.0/modules/content/");
|
||||
|
||||
var items = doc.Descendants("item");
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
var title = item.Element("title")?.Value ?? "";
|
||||
var link = item.Element("link")?.Value ?? "";
|
||||
var pubDate = item.Element("pubDate")?.Value ?? "";
|
||||
var contentEncoded = item.Element(contentNs + "encoded")?.Value ?? "";
|
||||
|
||||
// 解析日期
|
||||
if (!DateTime.TryParse(pubDate, out var date))
|
||||
{
|
||||
date = DateTime.Today;
|
||||
}
|
||||
|
||||
// 提取封面图URL
|
||||
var coverImageUrl = ExtractCoverImageUrl(contentEncoded);
|
||||
|
||||
// 提取视频链接
|
||||
var (bilibiliUrl, youtubeUrl) = ExtractVideoUrls(contentEncoded);
|
||||
|
||||
// 解析概览(简短列表)
|
||||
var overviewCategories = ParseOverview(contentEncoded);
|
||||
|
||||
// 解析详细内容
|
||||
var detailedNews = ParseDetailedNews(contentEncoded);
|
||||
|
||||
var news = new JuyaDailyNews(
|
||||
Date: date,
|
||||
Title: title,
|
||||
CoverImageUrl: coverImageUrl,
|
||||
IssueUrl: link,
|
||||
BilibiliUrl: bilibiliUrl,
|
||||
YoutubeUrl: youtubeUrl,
|
||||
OverviewCategories: overviewCategories,
|
||||
DetailedNews: detailedNews,
|
||||
FetchedAt: DateTimeOffset.Now
|
||||
);
|
||||
|
||||
result.Add(news);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 返回空列表
|
||||
}
|
||||
|
||||
return result.OrderByDescending(n => n.Date).ToList();
|
||||
}
|
||||
|
||||
private static string ExtractCoverImageUrl(string content)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
var match = Regex.Match(content, @"<img[^>]+src=[""']([^""']+)[""']", RegexOptions.IgnoreCase);
|
||||
return match.Success ? match.Groups[1].Value : "";
|
||||
}
|
||||
|
||||
private static (string bilibili, string youtube) ExtractVideoUrls(string content)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
{
|
||||
return ("", "");
|
||||
}
|
||||
|
||||
string bilibiliUrl = "";
|
||||
string youtubeUrl = "";
|
||||
|
||||
var bilibiliMatch = Regex.Match(content, @"<a[^>]+href=[""'](https?://(?:www\.)?bilibili\.com/[^""']+)[""'][^>]*>", RegexOptions.IgnoreCase);
|
||||
if (bilibiliMatch.Success)
|
||||
{
|
||||
bilibiliUrl = bilibiliMatch.Groups[1].Value;
|
||||
}
|
||||
|
||||
var youtubeMatch = Regex.Match(content, @"<a[^>]+href=[""'](https?://(?:www\.)?(?:youtube\.com|youtu\.be)/[^""']+)[""'][^>]*>", RegexOptions.IgnoreCase);
|
||||
if (youtubeMatch.Success)
|
||||
{
|
||||
youtubeUrl = youtubeMatch.Groups[1].Value;
|
||||
}
|
||||
|
||||
return (bilibiliUrl, youtubeUrl);
|
||||
}
|
||||
|
||||
private static List<JuyaOverviewCategory> ParseOverview(string content)
|
||||
{
|
||||
var categories = new List<JuyaOverviewCategory>();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
{
|
||||
return categories;
|
||||
}
|
||||
|
||||
var categoryIcons = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["要闻"] = "📌",
|
||||
["开发生态"] = "💻",
|
||||
["产品应用"] = "📱",
|
||||
["产品发布"] = "🚀",
|
||||
["模型发布"] = "🤖",
|
||||
["行业动态"] = "📈",
|
||||
["技术与洞察"] = "🔍",
|
||||
["学术研究"] = "📚",
|
||||
["研究"] = "🔬",
|
||||
["开源"] = "🔓",
|
||||
["投资"] = "💰",
|
||||
["融资"] = "💵",
|
||||
["商业"] = "💼",
|
||||
["市场"] = "📊",
|
||||
["AI绘画"] = "🎨",
|
||||
["设计"] = "✏️",
|
||||
["创意"] = "💡",
|
||||
["前瞻与传闻"] = "🔮",
|
||||
["趋势"] = "📉",
|
||||
["预测"] = "🔭",
|
||||
["政策"] = "📋",
|
||||
["法规"] = "⚖️",
|
||||
["监管"] = "🛡️",
|
||||
["硬件"] = "🔧",
|
||||
["芯片"] = "🖥️",
|
||||
["基础设施"] = "🏗️",
|
||||
["其他"] = "•",
|
||||
["要点"] = "📋",
|
||||
["摘要"] = "📝"
|
||||
};
|
||||
|
||||
var overviewMatch = Regex.Match(content, @"<h2>\s*概览\s*</h2>(.*?)(?:<hr>|$)", RegexOptions.Singleline | RegexOptions.IgnoreCase);
|
||||
|
||||
if (!overviewMatch.Success)
|
||||
{
|
||||
return categories;
|
||||
}
|
||||
|
||||
var overviewContent = overviewMatch.Groups[1].Value;
|
||||
|
||||
var h3Matches = Regex.Matches(overviewContent, @"<h3>([^<]+)</h3>\s*<ul>(.*?)</ul>", RegexOptions.Singleline | RegexOptions.IgnoreCase);
|
||||
|
||||
foreach (Match match in h3Matches)
|
||||
{
|
||||
var categoryName = match.Groups[1].Value.Trim();
|
||||
var listContent = match.Groups[2].Value;
|
||||
|
||||
var icon = categoryIcons.GetValueOrDefault(categoryName, "•");
|
||||
|
||||
var items = new List<JuyaOverviewItem>();
|
||||
var itemMatches = Regex.Matches(listContent, @"<li>(.*?)</li>", RegexOptions.Singleline | RegexOptions.IgnoreCase);
|
||||
|
||||
foreach (Match itemMatch in itemMatches)
|
||||
{
|
||||
var itemText = itemMatch.Groups[1].Value;
|
||||
|
||||
string itemTitle;
|
||||
string itemUrl;
|
||||
int? number = null;
|
||||
|
||||
var linkMatch = Regex.Match(itemText, @"<a[^>]+href=[""']([^""']+)[""'][^>]*>(.*?)</a>", RegexOptions.Singleline | RegexOptions.IgnoreCase);
|
||||
|
||||
if (linkMatch.Success)
|
||||
{
|
||||
itemUrl = linkMatch.Groups[1].Value;
|
||||
var linkText = Regex.Replace(linkMatch.Groups[2].Value, @"<[^>]+>", "").Trim();
|
||||
|
||||
var beforeLink = itemText.Substring(0, itemText.IndexOf("<a", StringComparison.OrdinalIgnoreCase));
|
||||
itemTitle = Regex.Replace(beforeLink, @"<[^>]+>", "").Trim();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(itemTitle))
|
||||
{
|
||||
itemTitle = linkText;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
itemTitle = Regex.Replace(itemText, @"<[^>]+>", "").Trim();
|
||||
itemUrl = "";
|
||||
}
|
||||
|
||||
var numberMatch = Regex.Match(itemText, @"<code>\s*#(\d+)\s*</code>|#(\d+)");
|
||||
if (numberMatch.Success)
|
||||
{
|
||||
number = int.Parse(numberMatch.Groups[1].Success ? numberMatch.Groups[1].Value : numberMatch.Groups[2].Value);
|
||||
}
|
||||
|
||||
itemTitle = Regex.Replace(itemTitle, @"^\s*#\d+\s*", "").Trim();
|
||||
itemTitle = Regex.Replace(itemTitle, @"[→↗\s]+$", "").Trim();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(itemTitle) && itemTitle.Length > 1)
|
||||
{
|
||||
items.Add(new JuyaOverviewItem(itemTitle, itemUrl, number));
|
||||
}
|
||||
}
|
||||
|
||||
if (items.Any())
|
||||
{
|
||||
categories.Add(new JuyaOverviewCategory(categoryName, icon, items));
|
||||
}
|
||||
}
|
||||
|
||||
return categories;
|
||||
}
|
||||
|
||||
private static List<JuyaDetailedNewsItem> ParseDetailedNews(string content)
|
||||
{
|
||||
var newsItems = new List<JuyaDetailedNewsItem>();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
{
|
||||
return newsItems;
|
||||
}
|
||||
|
||||
var detailedMatch = Regex.Match(content, @"<hr>(.*)$", RegexOptions.Singleline | RegexOptions.IgnoreCase);
|
||||
if (!detailedMatch.Success)
|
||||
{
|
||||
return newsItems;
|
||||
}
|
||||
|
||||
var detailedContent = detailedMatch.Groups[1].Value;
|
||||
|
||||
var newsMatches = Regex.Matches(detailedContent, @"<h2>(.*?)</h2>(.*?)(?=<h2>|<hr>|$)", RegexOptions.Singleline | RegexOptions.IgnoreCase);
|
||||
|
||||
foreach (Match match in newsMatches)
|
||||
{
|
||||
var headerContent = match.Groups[1].Value;
|
||||
var bodyContent = match.Groups[2].Value;
|
||||
|
||||
var numberMatch = Regex.Match(headerContent, @"<code>\s*#(\d+)\s*</code>");
|
||||
if (!numberMatch.Success)
|
||||
{
|
||||
numberMatch = Regex.Match(headerContent, @"#(\d+)");
|
||||
}
|
||||
|
||||
int? number = numberMatch.Success ? int.Parse(numberMatch.Groups[1].Value) : null;
|
||||
|
||||
string title;
|
||||
var linkMatch = Regex.Match(headerContent, @"<a[^>]*>(.*?)</a>", RegexOptions.Singleline | RegexOptions.IgnoreCase);
|
||||
if (linkMatch.Success)
|
||||
{
|
||||
title = Regex.Replace(linkMatch.Groups[1].Value, @"<[^>]+>", "").Trim();
|
||||
}
|
||||
else
|
||||
{
|
||||
title = Regex.Replace(headerContent, @"<code>.*?</code>", "", RegexOptions.Singleline | RegexOptions.IgnoreCase);
|
||||
title = Regex.Replace(title, @"<[^>]+>", "").Trim();
|
||||
title = Regex.Replace(title, @"#\d+", "").Trim();
|
||||
}
|
||||
|
||||
var bodyText = ExtractBodyText(bodyContent);
|
||||
|
||||
var relatedLinks = new List<string>();
|
||||
var linkMatches = Regex.Matches(bodyContent, @"<a[^>]+href=[""']([^""']+)[""'][^>]*>", RegexOptions.IgnoreCase);
|
||||
foreach (Match linkMatch2 in linkMatches)
|
||||
{
|
||||
var url = linkMatch2.Groups[1].Value;
|
||||
if (!string.IsNullOrWhiteSpace(url) && !relatedLinks.Contains(url))
|
||||
{
|
||||
relatedLinks.Add(url);
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(title) && !string.IsNullOrWhiteSpace(bodyText))
|
||||
{
|
||||
newsItems.Add(new JuyaDetailedNewsItem(title, number ?? 0, bodyText, relatedLinks));
|
||||
}
|
||||
}
|
||||
|
||||
return newsItems;
|
||||
}
|
||||
|
||||
private static string ExtractBodyText(string htmlContent)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(htmlContent))
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
// 提取 blockquote 内容
|
||||
var blockquoteMatch = Regex.Match(htmlContent, @"<blockquote>(.*?)</blockquote>", RegexOptions.Singleline | RegexOptions.IgnoreCase);
|
||||
if (blockquoteMatch.Success)
|
||||
{
|
||||
var text = blockquoteMatch.Groups[1].Value;
|
||||
// 移除 <p> 标签但保留内容
|
||||
text = Regex.Replace(text, @"<p>(.*?)</p>", "$1\n\n", RegexOptions.Singleline | RegexOptions.IgnoreCase);
|
||||
// 移除其他 HTML 标签
|
||||
text = Regex.Replace(text, @"<[^>]+>", "");
|
||||
// 清理多余空白
|
||||
text = Regex.Replace(text, @"\n{3,}", "\n\n");
|
||||
return text.Trim();
|
||||
}
|
||||
|
||||
// 如果没有 blockquote,提取所有 <p> 标签内容
|
||||
var paragraphs = Regex.Matches(htmlContent, @"<p>(.*?)</p>", RegexOptions.Singleline | RegexOptions.IgnoreCase);
|
||||
if (paragraphs.Count > 0)
|
||||
{
|
||||
var text = string.Join("\n\n", paragraphs.Cast<Match>().Select(m =>
|
||||
Regex.Replace(m.Groups[1].Value, @"<[^>]+>", "").Trim()));
|
||||
return text.Trim();
|
||||
}
|
||||
|
||||
// 最后尝试直接移除所有 HTML 标签
|
||||
return Regex.Replace(htmlContent, @"<[^>]+>", "").Trim();
|
||||
}
|
||||
|
||||
private void AddDailyNewsToView(JuyaDailyNews news)
|
||||
{
|
||||
var view = new DailyNewsView(news, _isNightVisual);
|
||||
view.CoverImageClicked += (s, e) => TryOpenUrl(news.IssueUrl);
|
||||
view.NewsItemClicked += (s, url) => TryOpenUrl(url);
|
||||
NewsStackPanel.Children.Add(view);
|
||||
_dailyViews.Add(view);
|
||||
}
|
||||
|
||||
private async void OnScrollChanged(object? sender, ScrollChangedEventArgs e)
|
||||
{
|
||||
if (_isLoading || !_isAttached)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var scrollViewer = (ScrollViewer)sender!;
|
||||
|
||||
var offset = scrollViewer.Offset;
|
||||
var extent = scrollViewer.Extent;
|
||||
var viewport = scrollViewer.Viewport;
|
||||
|
||||
if (offset.Y >= extent.Height - viewport.Height - 200)
|
||||
{
|
||||
await LoadMoreNewsAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadMoreNewsAsync()
|
||||
{
|
||||
if (_isLoading || !_isAttached)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var nextDates = Enumerable.Range(1, LoadMoreDays)
|
||||
.Select(i => _earliestLoadedDate.AddDays(-i))
|
||||
.Where(d => _cachedNews.ContainsKey(d) && !_loadedDates.Contains(d))
|
||||
.ToList();
|
||||
|
||||
if (!nextDates.Any())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_isLoading = true;
|
||||
LoadingTextBlock.IsVisible = true;
|
||||
|
||||
try
|
||||
{
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
if (!_isAttached) return;
|
||||
|
||||
foreach (var date in nextDates.OrderByDescending(d => d))
|
||||
{
|
||||
AddDailyNewsToView(_cachedNews[date]);
|
||||
_loadedDates.Add(date);
|
||||
}
|
||||
|
||||
_earliestLoadedDate = _loadedDates.Min();
|
||||
LoadingTextBlock.IsVisible = false;
|
||||
UpdateAdaptiveLayout();
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async void OnRefreshButtonClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
e.Handled = true;
|
||||
|
||||
if (_isLoading)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_cachedNews.Clear();
|
||||
_loadedDates.Clear();
|
||||
_dailyViews.Clear();
|
||||
NewsStackPanel.Children.Clear();
|
||||
_earliestLoadedDate = DateTime.Today;
|
||||
|
||||
await LoadInitialNewsAsync();
|
||||
}
|
||||
|
||||
private void TryOpenUrl(string? url)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(url))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = url,
|
||||
UseShellExecute = true
|
||||
};
|
||||
Process.Start(startInfo);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 忽略错误
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyLoadingState()
|
||||
{
|
||||
StatusTextBlock.Text = "加载中...";
|
||||
StatusTextBlock.IsVisible = true;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
var unifiedMainRectangle = ResolveUnifiedMainRectangle();
|
||||
RootBorder.CornerRadius = unifiedMainRectangle;
|
||||
CardBorder.CornerRadius = unifiedMainRectangle;
|
||||
|
||||
var horizontalPadding = Math.Clamp(16 * softScale, 10, 24);
|
||||
var verticalPadding = Math.Clamp(14 * softScale, 8, 20);
|
||||
CardBorder.Padding = new Thickness(horizontalPadding, verticalPadding, horizontalPadding, verticalPadding);
|
||||
|
||||
var headerHeight = Math.Clamp(40 * softScale, 28, 56);
|
||||
HeaderGrid.Height = headerHeight;
|
||||
|
||||
BrandTextBlock.FontSize = Math.Clamp(20 * softScale, 14, 26);
|
||||
|
||||
var avatarSize = Math.Clamp(36 * softScale, 24, 48);
|
||||
AvatarBorder.Width = avatarSize;
|
||||
AvatarBorder.Height = avatarSize;
|
||||
AvatarBorder.CornerRadius = new CornerRadius(avatarSize / 2);
|
||||
|
||||
var buttonFontSize = Math.Clamp(13 * softScale, 10, 16);
|
||||
RefreshButton.FontSize = buttonFontSize;
|
||||
RefreshButton.Padding = new Thickness(
|
||||
Math.Clamp(8 * softScale, 6, 12),
|
||||
Math.Clamp(4 * softScale, 2, 6)
|
||||
);
|
||||
|
||||
StatusTextBlock.FontSize = Math.Clamp(16 * softScale, 12, 22);
|
||||
LoadingTextBlock.FontSize = Math.Clamp(14 * softScale, 11, 18);
|
||||
|
||||
foreach (var view in _dailyViews)
|
||||
{
|
||||
view.UpdateLayout(softScale, totalWidth - horizontalPadding * 2);
|
||||
}
|
||||
|
||||
ApplyNightModeVisual();
|
||||
}
|
||||
|
||||
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 CornerRadius ResolveUnifiedMainRectangle() => new(ResolveUnifiedMainRadiusValue());
|
||||
|
||||
private static double ResolveUnifiedMainRadiusValue() =>
|
||||
HostAppearanceThemeProvider.GetOrCreate().GetCurrent().CornerRadiusTokens.Lg.TopLeft;
|
||||
}
|
||||
|
||||
// 数据模型
|
||||
public sealed record JuyaDailyNews(
|
||||
DateTime Date,
|
||||
string Title,
|
||||
string CoverImageUrl,
|
||||
string IssueUrl,
|
||||
string BilibiliUrl,
|
||||
string YoutubeUrl,
|
||||
IReadOnlyList<JuyaOverviewCategory> OverviewCategories,
|
||||
IReadOnlyList<JuyaDetailedNewsItem> DetailedNews,
|
||||
DateTimeOffset FetchedAt);
|
||||
|
||||
public sealed record JuyaOverviewCategory(
|
||||
string Name,
|
||||
string Icon,
|
||||
IReadOnlyList<JuyaOverviewItem> Items);
|
||||
|
||||
public sealed record JuyaOverviewItem(
|
||||
string Title,
|
||||
string Url,
|
||||
int? Number);
|
||||
|
||||
public sealed record JuyaDetailedNewsItem(
|
||||
string Title,
|
||||
int Number,
|
||||
string BodyText,
|
||||
IReadOnlyList<string> RelatedLinks);
|
||||
152
README.md
152
README.md
@@ -1,53 +1,133 @@
|
||||
# LanMountainDesktop
|
||||
# 阑山桌面 / LanMountainDesktop
|
||||
|
||||
`LanMountainDesktop` is the authoritative host repository for the desktop app and the host-side Plugin SDK.
|
||||
> 你的桌面,不止一面
|
||||
|
||||
## Repository Ownership
|
||||
[](https://dotnet.microsoft.com/)
|
||||
[](https://avaloniaui.net/)
|
||||
[](LICENSE)
|
||||
|
||||
This repository owns:
|
||||
> [!IMPORTANT]
|
||||
> **温馨提示**:本项目有部分成分由**氛围编程 (Vibe Coding)** 方式编写。
|
||||
>
|
||||
> 如果您对此类项目有固有的排斥感,请无视此项目,谢谢。
|
||||
|
||||
- `LanMountainDesktop/`: desktop host app and plugin runtime
|
||||
- `LanMountainDesktop.PluginSdk/`: canonical plugin API baseline (`4.0.0`)
|
||||
- `LanMountainDesktop.Shared.Contracts/`: shared host/plugin contract types
|
||||
- `LanMountainDesktop.Appearance/`: host appearance and radius token generation
|
||||
- `LanMountainDesktop.Settings.Core/`: host settings primitives
|
||||
- `LanMountainDesktop.Tests/`: host and SDK tests
|
||||
## 简介
|
||||
|
||||
This repository does not own:
|
||||
**阑山桌面**是一个跨平台桌面环境增强工具,面向需要高频查看信息、追求桌面效率与个性化体验的用户。
|
||||
|
||||
- plugin market metadata or developer portal content
|
||||
- official sample plugin release source
|
||||
- independent ecosystem documentation hub
|
||||
基于 Avalonia UI 和 .NET 10 构建,支持 Windows、Linux、macOS 三大平台。
|
||||
|
||||
## Ecosystem Boundaries
|
||||

|
||||

|
||||

|
||||
|
||||
- Host and SDK source of truth: `LanMountainDesktop` (this repo)
|
||||
- Plugin market and developer materials: standalone `LanAirApp` repo
|
||||
- Official sample plugin source of truth: standalone `LanMountainDesktop.SamplePlugin` repo
|
||||
- `ClassIsland`: reference-only project, not part of build or release flow
|
||||
## 核心特性
|
||||
|
||||
## Plugin SDK v4 Baseline
|
||||
### 📊 信息聚合
|
||||
- 课程表、日历、天气、新闻、热搜
|
||||
- 所有信息一目了然,无需频繁切换窗口
|
||||
|
||||
- API baseline: `4.0.0`
|
||||
- Manifest file: `plugin.json`
|
||||
- Package extension: `.laapp`
|
||||
- Entry model: `Initialize(HostBuilderContext, IServiceCollection)`
|
||||
- Appearance model: `IPluginAppearanceContext`, `PluginAppearanceSnapshot`, `PluginCornerRadiusTokens`, `PluginCornerRadiusPreset`
|
||||
- Component registration model: `AddPluginDesktopComponent<TControl>(PluginDesktopComponentOptions options)`
|
||||
### 🎯 效率工具
|
||||
- 自习环境监测、计时器、知识卡片
|
||||
- 最近文档、浏览器快捷入口
|
||||
- 常用工具组件一键触达
|
||||
|
||||
## Plugin Package Surfaces
|
||||
### 🎨 个性化桌面
|
||||
- 自由布局,随心所欲摆放组件
|
||||
- 多页桌面,工作学习场景分离
|
||||
- 主题切换、玻璃效果、圆角风格
|
||||
|
||||
- `LanMountainDesktop.PluginSdk`: official plugin SDK package (includes `buildTransitive` default `.laapp` packaging targets)
|
||||
- `LanMountainDesktop.Shared.Contracts`: shared contract package for host/plugin boundaries
|
||||
- `LanMountainDesktop.PluginTemplate`: official `dotnet new` template package (`shortName`: `lmd-plugin`)
|
||||
### 🔌 插件生态
|
||||
- 通过 `.laapp` 插件扩展功能
|
||||
- 官方 Plugin SDK 支持自定义组件
|
||||
- 设置页、组件、集成功能一站式接入
|
||||
|
||||
Use `scripts/Pack-PluginPackages.ps1` to generate local-feed packages for CI or workspace integration tests.
|
||||
## 为谁而设计
|
||||
|
||||
## Workspace Market Resolution
|
||||
| 用户类型 | 典型场景 |
|
||||
|---------|---------|
|
||||
| 🎓 学生用户 | 课程表、自习监测、计时、天气和日常信息聚合 |
|
||||
| 💼 办公用户 | 日历、资讯、最近文档、常用工具入口 |
|
||||
| 🎨 效率爱好者 | 自由布局、主题切换、插件扩展 |
|
||||
| 🇨🇳 中文用户 | 本地化界面、农历和节假日等本地语境支持 |
|
||||
|
||||
For local market debugging, the host resolves workspace files from the sibling repository path (`..\\LanAirApp`) instead of reading the in-repo mirror folder.
|
||||
## 快速开始
|
||||
|
||||
### 环境要求
|
||||
- .NET SDK 10
|
||||
|
||||
### 构建与运行
|
||||
|
||||
```bash
|
||||
# 还原依赖
|
||||
dotnet restore
|
||||
|
||||
# 构建项目
|
||||
dotnet build LanMountainDesktop.slnx -c Debug
|
||||
|
||||
# 运行桌面宿主
|
||||
dotnet run --project LanMountainDesktop/LanMountainDesktop.csproj
|
||||
```
|
||||
|
||||
### 运行测试
|
||||
|
||||
```bash
|
||||
dotnet test LanMountainDesktop.slnx -c Debug
|
||||
```
|
||||
|
||||
## 插件开发
|
||||
|
||||
阑山桌面支持通过 Plugin SDK 开发自定义插件:
|
||||
|
||||
```bash
|
||||
# 安装插件模板
|
||||
dotnet new install LanMountainDesktop.PluginTemplate
|
||||
|
||||
# 创建新插件
|
||||
dotnet new lmd-plugin -n MyPlugin
|
||||
```
|
||||
|
||||
- **Plugin SDK**: `LanMountainDesktop.PluginSdk` (API 4.0.0)
|
||||
- **共享契约**: `LanMountainDesktop.Shared.Contracts`
|
||||
- **迁移指南**: [PLUGIN_SDK_V4_MIGRATION.md](docs/PLUGIN_SDK_V4_MIGRATION.md)
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
LanMountainDesktop/
|
||||
├── LanMountainDesktop/ # 桌面宿主应用
|
||||
├── LanMountainDesktop.PluginSdk/ # 官方插件 SDK
|
||||
├── LanMountainDesktop.Shared.Contracts/ # 宿主与插件共享契约
|
||||
├── LanMountainDesktop.Appearance/ # 主题与外观基础设施
|
||||
├── LanMountainDesktop.Settings.Core/# 设置持久化基础设施
|
||||
└── LanMountainDesktop.Tests/ # 测试项目
|
||||
```
|
||||
|
||||
## 生态边界
|
||||
|
||||
| 项目 | 职责 |
|
||||
|-----|------|
|
||||
| **本仓库** | 桌面宿主、插件运行时、Plugin SDK、共享契约 |
|
||||
| [LanAirApp](https://github.com/yourorg/LanAirApp) | 插件市场元数据、开发者生态材料 |
|
||||
| [LanMountainDesktop.SamplePlugin](https://github.com/yourorg/LanMountainDesktop.SamplePlugin) | 官方示例插件 |
|
||||
|
||||
## 文档索引
|
||||
|
||||
- [产品定位](docs/PRODUCT.md) - 产品愿景与目标用户
|
||||
- [架构说明](docs/ARCHITECTURE.md) - 仓库结构与运行时主线
|
||||
- [开发指南](docs/DEVELOPMENT.md) - 构建、测试、调试
|
||||
- [视觉规范](docs/VISUAL_SPEC.md) - 主题、颜色、玻璃层级
|
||||
- [圆角规范](docs/CORNER_RADIUS_SPEC.md) - 圆角层级与动态规则
|
||||
- [贡献指南](docs/CONTRIBUTING.md) - PR、spec、文档协作规则
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **UI 框架**: [Avalonia UI](https://avaloniaui.net/)
|
||||
- **开发平台**: [.NET 10](https://dotnet.microsoft.com/)
|
||||
- **支持平台**: Windows 10+, Linux, macOS
|
||||
|
||||
## 许可证
|
||||
|
||||
[MIT](LICENSE)
|
||||
|
||||
See:
|
||||
|
||||
- `docs/ECOSYSTEM_BOUNDARIES.md`
|
||||
- `docs/PLUGIN_SDK_V4_MIGRATION.md`
|
||||
|
||||
556
docs/JUYA_NEWS_DESIGN.md
Normal file
556
docs/JUYA_NEWS_DESIGN.md
Normal file
@@ -0,0 +1,556 @@
|
||||
# 橘鸦新闻组件 UI 设计文档
|
||||
|
||||
## 1. 数据源分析
|
||||
|
||||
### RSS 结构
|
||||
```xml
|
||||
<item>
|
||||
<title>2026-03-23</title> <!-- 日期作为标题 -->
|
||||
<link>https://imjuya.github.io/juya-ai-daily/issue-37/</link>
|
||||
<description>AI 早报 2026-03-23 视频版...</description>
|
||||
<content:encoded>
|
||||
<![CDATA[
|
||||
<img src="封面图片URL" alt=""> <!-- 每日封面图 -->
|
||||
<h1>AI 早报 2026-03-23</h1>
|
||||
<p><strong>视频版</strong>: B站链接 | YouTube链接</p>
|
||||
<h2>要闻</h2>
|
||||
<ul>
|
||||
<li>微信正式推出ClawBot插件... #1</li>
|
||||
</ul>
|
||||
<h2>开发者</h2>
|
||||
<ul>
|
||||
<li>Claude Code 测试新功能... #2</li>
|
||||
</ul>
|
||||
...更多分类
|
||||
]]>
|
||||
</content:encoded>
|
||||
<pubDate>Mon, 23 Mar 2026 00:34:38 +0000</pubDate>
|
||||
</item>
|
||||
```
|
||||
|
||||
### 推送时间规律
|
||||
- **推送时间**: 每天凌晨 00:30 - 02:00 (UTC+0)
|
||||
- **北京时间**: 每天上午 08:30 - 10:00
|
||||
- **历史数据**: RSS包含约30天的历史数据(从2026-02-18开始)
|
||||
- **更新频率**: 每日一期,一期多条新闻
|
||||
|
||||
### 内容结构
|
||||
每期早报包含:
|
||||
1. **封面图片** - 每日独特的封面图
|
||||
2. **视频版链接** - B站和YouTube双平台
|
||||
3. **要闻** - 2-3条重要新闻
|
||||
4. **开发者** - 技术相关动态
|
||||
5. **产品发布** - 新产品/功能
|
||||
6. **模型发布** - AI模型更新
|
||||
7. **其他分类** - 投资、开源、研究等
|
||||
|
||||
---
|
||||
|
||||
## 2. 设计理念
|
||||
|
||||
### 品牌调性
|
||||
- **橘鸦官网风格**: 柔和、温暖、阅读友好
|
||||
- **主色调**: 砖红色/陶土色 (#bb5649) - 来自官网
|
||||
- **背景色**: 米白色/奶油色 (#fefefe, #f8f5ec) - 柔和不刺眼
|
||||
- **文字色**: 深灰蓝 (#34495e) - 温和专业
|
||||
- **视觉风格**: 简洁优雅、阅读舒适、温暖亲切
|
||||
|
||||
### 设计关键词
|
||||
- 柔和温暖
|
||||
- 阅读友好
|
||||
- 优雅简洁
|
||||
- 舒适护眼
|
||||
- **垂直连续滚动** ← 核心交互
|
||||
|
||||
---
|
||||
|
||||
## 3. 色彩方案 (参考橘鸦官网)
|
||||
|
||||
### 官网色彩提取
|
||||
```
|
||||
官网主色 (砖红/陶土): #bb5649
|
||||
官网文字: #34495e
|
||||
官网背景: #fefefe
|
||||
官网次要背景: #f8f5ec (米黄/奶油)
|
||||
官网引用块背景: rgba(192,91,77,.05)
|
||||
官网引用块边框: rgba(192,91,77,.3)
|
||||
官网链接悬停: #bb5649
|
||||
官网元信息: #757575
|
||||
```
|
||||
|
||||
### 日间模式 (Light Mode) - 柔和风格
|
||||
| 元素 | 颜色 | 用途 |
|
||||
|-----|------|------|
|
||||
| 卡片背景 | #fefefe | 主卡片底色 (官网背景色) |
|
||||
| 卡片边框 | #e6e6e6 | 细微边框 |
|
||||
| 品牌标题 | #bb5649 | "橘鸦" 文字 (官网主色) |
|
||||
| 日期标题 | #bb5649 | 日期大标题 |
|
||||
| 新闻标题 | #34495e | 新闻条目文字 |
|
||||
| 分类标签 | #bb5649 | 要闻/开发者等 |
|
||||
| 时间戳 | #757575 | 发布时间 |
|
||||
| 悬停背景 | rgba(192,91,77,.05) | 条目悬停效果 |
|
||||
| 分隔线 | #e6e6e6 | 日期分隔 |
|
||||
| 加载提示 | #757575 | 加载更多提示 |
|
||||
|
||||
### 夜间模式 (Dark Mode) - 柔和暗色
|
||||
| 元素 | 颜色 | 用途 |
|
||||
|-----|------|------|
|
||||
| 卡片背景 | #2d2a2a | 深暖灰 |
|
||||
| 卡片边框 | #3d3a3a | 细微边框 |
|
||||
| 品牌标题 | #d4736a | 柔和砖红 |
|
||||
| 日期标题 | #d4736a | 日期大标题 |
|
||||
| 新闻标题 | #e8e4e0 | 新闻条目文字 |
|
||||
| 分类标签 | #d4736a | 要闻/开发者等 |
|
||||
| 时间戳 | #9a9590 | 次要信息 |
|
||||
| 悬停背景 | rgba(212,115,106,.1) | 条目悬停效果 |
|
||||
| 分隔线 | #3d3a3a | 日期分隔 |
|
||||
| 加载提示 | #9a9590 | 加载更多提示 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 布局设计
|
||||
|
||||
### 组件尺寸
|
||||
- **默认尺寸**: 4格宽 x 4格高
|
||||
- **最小尺寸**: 4格宽 x 4格高
|
||||
- **滚动方向**: 垂直滚动
|
||||
|
||||
### 垂直连续滚动布局
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ 🧱 橘鸦 · AI早报 [🔗 官网] │ ← Header (固定或随滚动)
|
||||
├─────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌───────────────────────────────────┐ │
|
||||
│ │ 📰 封面图 2026-03-23 │ │ ← 今天的新闻
|
||||
│ │ │ │
|
||||
│ └───────────────────────────────────┘ │
|
||||
│ │
|
||||
│ # 2026年3月23日 星期一 │ ← 日期大标题
|
||||
│ │
|
||||
│ ## 📌 要闻 │
|
||||
│ • 微信正式推出ClawBot插件... │
|
||||
│ • OpenAI发布GPT-5.4预览版... │
|
||||
│ │
|
||||
│ ## 💻 开发者 │
|
||||
│ • Claude Code测试新功能... │
|
||||
│ • 阶跃星辰推出StepPlan... │
|
||||
│ │
|
||||
│ 📺 视频版: B站 | YouTube │
|
||||
│ │
|
||||
│ ───────────────────────────────────── │ ← 日期分隔线
|
||||
│ │
|
||||
│ ┌───────────────────────────────────┐ │
|
||||
│ │ 📰 封面图 2026-03-22 │ │ ← 昨天的新闻
|
||||
│ │ │ │ (往下滑动显示)
|
||||
│ └───────────────────────────────────┘ │
|
||||
│ │
|
||||
│ # 2026年3月22日 星期日 │
|
||||
│ │
|
||||
│ ## 📌 要闻 │
|
||||
│ • OpenAI发布GPT-5.4... │
|
||||
│ • Google推出新功能... │
|
||||
│ │
|
||||
│ ## 💻 开发者 │
|
||||
│ • Anthropic更新Claude... │
|
||||
│ │
|
||||
│ 📺 视频版: B站 | YouTube │
|
||||
│ │
|
||||
│ ───────────────────────────────────── │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────┐ │ ← 前天的新闻
|
||||
│ │ 📰 封面图 2026-03-21 │ │ (继续往下滑动)
|
||||
│ │ │ │
|
||||
│ └───────────────────────────────────┘ │
|
||||
│ │
|
||||
│ # 2026年3月21日 星期六 │
|
||||
│ │
|
||||
│ ... │
|
||||
│ │
|
||||
│ ───────────────────────────────────── │
|
||||
│ │
|
||||
│ 正在加载更多... ↓ │ ← 加载提示
|
||||
│ │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 日期分隔设计
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ │
|
||||
│ ─────────── 3月22日 星期日 ─────────── │ ← 日期分隔条
|
||||
│ │
|
||||
│ [昨天的新闻内容] │
|
||||
│ │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 单期新闻结构
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ │
|
||||
│ [封面图 - 16:9 比例] │
|
||||
│ │
|
||||
│ # 2026年3月23日 星期一 │ ← 日期大标题
|
||||
│ │
|
||||
│ ## 📌 要闻 │ ← 分类标题
|
||||
│ • 新闻条目1 │
|
||||
│ • 新闻条目2 │
|
||||
│ │
|
||||
│ ## 💻 开发者 │
|
||||
│ • 新闻条目3 │
|
||||
│ • 新闻条目4 │
|
||||
│ │
|
||||
│ ## 🚀 产品发布 │
|
||||
│ • 新闻条目5 │
|
||||
│ │
|
||||
│ 📺 视频版: [B站] [YouTube] │ ← 视频链接
|
||||
│ │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 字体规范
|
||||
|
||||
### 字体族
|
||||
```xml
|
||||
FontFamily="MiSans VF, avares://LanMountainDesktop/Assets/Fonts#MiSans"
|
||||
```
|
||||
|
||||
### 字号规范
|
||||
|
||||
| 元素 | 字号 | 字重 | 说明 |
|
||||
|-----|------|------|------|
|
||||
| 品牌标题 | 20px | SemiBold | 顶部固定标题 |
|
||||
| 日期大标题 | 22px | Bold | 每期日期 |
|
||||
| 分类标题 | 16px | SemiBold | 要闻/开发者等 |
|
||||
| 新闻条目 | 14px | Regular | 主要阅读内容 |
|
||||
| 视频链接 | 13px | Regular | 底部视频入口 |
|
||||
| 加载提示 | 13px | Regular | 加载更多 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 核心交互: 垂直连续滚动
|
||||
|
||||
### 滚动行为
|
||||
```
|
||||
用户往下滑动
|
||||
↓
|
||||
显示今天的新闻内容
|
||||
↓
|
||||
继续往下滑动
|
||||
↓
|
||||
显示日期分隔线
|
||||
↓
|
||||
显示昨天的新闻内容
|
||||
↓
|
||||
继续往下滑动
|
||||
↓
|
||||
显示前天的新闻内容
|
||||
↓
|
||||
...
|
||||
↓
|
||||
到达已加载内容的底部
|
||||
↓
|
||||
显示"正在加载更多..."
|
||||
↓
|
||||
自动加载更早的新闻
|
||||
```
|
||||
|
||||
### 加载策略
|
||||
```csharp
|
||||
// 初始加载: 最近3天的新闻
|
||||
// 滚动到底部: 自动加载接下来3天
|
||||
// 最大加载: 30天历史数据
|
||||
// 内存管理: 只保留可视区域 ±3 天的数据
|
||||
```
|
||||
|
||||
### 滚动位置记忆
|
||||
```csharp
|
||||
// 记录用户当前滚动位置
|
||||
// 切换主题/刷新时不重置位置
|
||||
// 下次打开组件时恢复到上次位置
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 交互设计
|
||||
|
||||
### 悬停效果
|
||||
```
|
||||
新闻条目悬停:
|
||||
- 背景色: 透明 → rgba(192,91,77,.05)
|
||||
- 过渡时间: 200ms
|
||||
- 光标: Hand cursor
|
||||
```
|
||||
|
||||
### 点击效果
|
||||
```
|
||||
新闻条目点击:
|
||||
- 打开浏览器跳转原文链接
|
||||
- 轻微缩放: scale(0.98)
|
||||
- 过渡时间: 100ms
|
||||
```
|
||||
|
||||
### 封面图点击
|
||||
```
|
||||
封面图点击:
|
||||
- 打开当期官网页面
|
||||
- 轻微放大效果
|
||||
```
|
||||
|
||||
### 日期标题点击
|
||||
```
|
||||
日期标题点击:
|
||||
- 展开/收起该期新闻
|
||||
- 箭头图标旋转动画
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 动画效果
|
||||
|
||||
### 滚动动画
|
||||
```
|
||||
内容跟随滚动:
|
||||
- 自然滚动,无额外动画
|
||||
- 保持流畅 60fps
|
||||
```
|
||||
|
||||
### 加载动画
|
||||
```
|
||||
新内容加载:
|
||||
- 淡入: opacity 0 → 1 (300ms)
|
||||
- 缓动: ease-out
|
||||
```
|
||||
|
||||
### 日期分隔线动画
|
||||
```
|
||||
日期分隔线进入视口:
|
||||
- 轻微放大: scale(0.95) → scale(1)
|
||||
- 透明度: 0.5 → 1
|
||||
- 时长: 200ms
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 响应式适配
|
||||
|
||||
### 缩放规则
|
||||
```csharp
|
||||
scale = Math.Clamp(currentCellSize / 48, 0.56, 2.0)
|
||||
|
||||
字体缩放: baseFontSize * scale
|
||||
间距缩放: baseSpacing * scale
|
||||
```
|
||||
|
||||
### 最小尺寸保障
|
||||
```
|
||||
最小字体: 11px
|
||||
最小间距: 8px
|
||||
最小触摸区域: 44px
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. 代码结构预览
|
||||
|
||||
### XAML 结构
|
||||
```xml
|
||||
<UserControl>
|
||||
<Border x:Name="RootBorder" CornerRadius="24" Background="#fefefe">
|
||||
<Grid RowDefinitions="Auto,*">
|
||||
|
||||
<!-- Header (固定) -->
|
||||
<Grid Grid.Row="0" ColumnDefinitions="*,Auto" Margin="16">
|
||||
<TextBlock Text="🧱 橘鸦 · AI早报"
|
||||
Foreground="#bb5649" FontSize="20"/>
|
||||
<Button x:Name="OfficialWebsiteButton" Grid.Column="1"
|
||||
Content="🔗 官网" Click="OnOfficialWebsiteClick"
|
||||
Background="Transparent" Foreground="#bb5649"/>
|
||||
</Grid>
|
||||
|
||||
<!-- 滚动内容区 -->
|
||||
<ScrollViewer Grid.Row="1" x:Name="ContentScrollViewer"
|
||||
VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel x:Name="NewsStackPanel">
|
||||
|
||||
<!-- 今天的新闻 -->
|
||||
<local:DailyNewsView Date="2026-03-23"
|
||||
CoverImageUrl="..."
|
||||
Categories="..."/>
|
||||
|
||||
<!-- 日期分隔线 -->
|
||||
<local:DateSeparator Date="2026-03-22"/>
|
||||
|
||||
<!-- 昨天的新闻 -->
|
||||
<local:DailyNewsView Date="2026-03-22"
|
||||
CoverImageUrl="..."
|
||||
Categories="..."/>
|
||||
|
||||
<!-- 更多历史新闻... -->
|
||||
|
||||
<!-- 加载提示 -->
|
||||
<TextBlock x:Name="LoadingMoreText"
|
||||
Text="正在加载更多... ↓"
|
||||
HorizontalAlignment="Center"
|
||||
Margin="0,20"/>
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
</Grid>
|
||||
</Border>
|
||||
</UserControl>
|
||||
```
|
||||
|
||||
### DailyNewsView 组件
|
||||
```xml
|
||||
<!-- 单期新闻视图 -->
|
||||
<Border x:Class="DailyNewsView" Margin="0,0,0,24">
|
||||
<StackPanel>
|
||||
<!-- 封面图 -->
|
||||
<Border CornerRadius="12" ClipToBounds="True"
|
||||
PointerPressed="OnCoverImageClick" Cursor="Hand">
|
||||
<Image Source="{Binding CoverImageUrl}" Stretch="UniformToFill"/>
|
||||
</Border>
|
||||
|
||||
<!-- 日期大标题 -->
|
||||
<TextBlock Text="{Binding FormattedDate}"
|
||||
FontSize="22" FontWeight="Bold"
|
||||
Foreground="#bb5649" Margin="0,16,0,12"/>
|
||||
|
||||
<!-- 分类列表 -->
|
||||
<ItemsControl ItemsSource="{Binding Categories}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<StackPanel Margin="0,0,0,12">
|
||||
<TextBlock Text="{Binding IconAndName}"
|
||||
FontSize="16" FontWeight="SemiBold"
|
||||
Foreground="#bb5649"/>
|
||||
<ItemsControl ItemsSource="{Binding Items}"/>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
<!-- 视频链接 -->
|
||||
<StackPanel Orientation="Horizontal" Margin="0,8,0,0">
|
||||
<TextBlock Text="📺 视频版:" Foreground="#757575"/>
|
||||
<HyperlinkButton Content="B站" NavigateUri="{Binding BilibiliUrl}"/>
|
||||
<TextBlock Text="|" Foreground="#757575" Margin="4,0"/>
|
||||
<HyperlinkButton Content="YouTube" NavigateUri="{Binding YoutubeUrl}"/>
|
||||
</StackPanel>
|
||||
|
||||
</StackPanel>
|
||||
</Border>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. 数据模型
|
||||
|
||||
```csharp
|
||||
// 每日早报数据
|
||||
public sealed record JuyaDailyNews(
|
||||
DateTime Date,
|
||||
string Title,
|
||||
string CoverImageUrl,
|
||||
string IssueUrl,
|
||||
string BilibiliUrl,
|
||||
string YoutubeUrl,
|
||||
IReadOnlyList<JuyaNewsCategory> Categories,
|
||||
DateTimeOffset FetchedAt);
|
||||
|
||||
// 新闻分类
|
||||
public sealed record JuyaNewsCategory(
|
||||
string Name,
|
||||
string Icon,
|
||||
IReadOnlyList<JuyaNewsItem> Items);
|
||||
|
||||
// 单条新闻
|
||||
public sealed record JuyaNewsItem(
|
||||
string Title,
|
||||
string Url,
|
||||
int? Number);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. 与现有组件对比
|
||||
|
||||
| 特性 | CnrDailyNews | IfengNews | **JuyaNews (建议)** |
|
||||
|-----|--------------|-----------|---------------------|
|
||||
| 浏览方式 | 静态展示 | 静态展示 | **垂直连续滚动** |
|
||||
| 历史查看 | 不支持 | 不支持 | **下滑自动加载** |
|
||||
| 交互方式 | 点击刷新 | 点击刷新 | **滚动浏览** |
|
||||
| 内容组织 | 平铺 | 平铺 | **按日期分组** |
|
||||
|
||||
---
|
||||
|
||||
## 13. 设计亮点
|
||||
|
||||
1. **垂直滚动**: 像社交媒体一样自然浏览
|
||||
2. **连续阅读**: 今天→昨天→前天,无缝衔接
|
||||
3. **日期分隔**: 清晰的日期标识,不会混淆
|
||||
4. **自动加载**: 滑到底部自动加载更多历史
|
||||
5. **柔和色彩**: 砖红色 + 米白色,阅读舒适
|
||||
6. **主题适配**: 日间/夜间模式都柔和护眼
|
||||
|
||||
---
|
||||
|
||||
## 14. 实现建议
|
||||
|
||||
### 滚动加载实现
|
||||
```csharp
|
||||
public partial class JuyaNewsWidget : UserControl
|
||||
{
|
||||
private readonly List<JuyaDailyNews> _loadedNews = new();
|
||||
private DateTime _earliestLoadedDate;
|
||||
private bool _isLoadingMore;
|
||||
|
||||
private void OnScrollChanged(object? sender, ScrollChangedEventArgs e)
|
||||
{
|
||||
var scrollViewer = (ScrollViewer)sender!;
|
||||
|
||||
// 检测是否滚动到底部
|
||||
if (scrollViewer.VerticalOffset >= scrollViewer.ScrollableHeight - 100)
|
||||
{
|
||||
LoadMoreNews();
|
||||
}
|
||||
}
|
||||
|
||||
private async void LoadMoreNews()
|
||||
{
|
||||
if (_isLoadingMore) return;
|
||||
_isLoadingMore = true;
|
||||
|
||||
// 加载接下来3天的新闻
|
||||
var nextBatch = await FetchNewsBatch(_earliestLoadedDate.AddDays(-1), 3);
|
||||
|
||||
foreach (var news in nextBatch)
|
||||
{
|
||||
AddNewsToView(news);
|
||||
_loadedNews.Add(news);
|
||||
}
|
||||
|
||||
_earliestLoadedDate = nextBatch.Last().Date;
|
||||
_isLoadingMore = false;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 内存优化
|
||||
```csharp
|
||||
// 只保留可视区域附近的新闻
|
||||
// 远离可视区域的新闻释放图片资源
|
||||
// 保留文字内容,图片按需加载
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*设计版本: v4.0*
|
||||
*更新日期: 2026-03-24*
|
||||
*更新内容: 改为垂直连续滚动浏览模式*
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
### 产品一句话
|
||||
|
||||
阑山桌面是一个可编排的桌面信息与交互空间,面向需要高频查看信息、追求桌面效率与个性化体验的用户。
|
||||
阑山桌面——你的桌面,不止一面。
|
||||
|
||||
### 产品定位
|
||||
|
||||
|
||||
Reference in New Issue
Block a user