橘鸦新闻
This commit is contained in:
lincube
2026-03-24 09:33:56 +08:00
parent 798124e500
commit af2e7b4f2f
13 changed files with 2181 additions and 37 deletions

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

View 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

View File

@@ -33,6 +33,7 @@ public static class BuiltInComponentIds
public const string DesktopDailyWord2x2 = "DesktopDailyWord2x2"; public const string DesktopDailyWord2x2 = "DesktopDailyWord2x2";
public const string DesktopCnrDailyNews = "DesktopCnrDailyNews"; public const string DesktopCnrDailyNews = "DesktopCnrDailyNews";
public const string DesktopIfengNews = "DesktopIfengNews"; public const string DesktopIfengNews = "DesktopIfengNews";
public const string DesktopJuyaNews = "DesktopJuyaNews";
public const string DesktopBilibiliHotSearch = "DesktopBilibiliHotSearch"; public const string DesktopBilibiliHotSearch = "DesktopBilibiliHotSearch";
public const string DesktopBaiduHotSearch = "DesktopBaiduHotSearch"; public const string DesktopBaiduHotSearch = "DesktopBaiduHotSearch";
public const string DesktopStcn24Forum = "DesktopStcn24Forum"; public const string DesktopStcn24Forum = "DesktopStcn24Forum";

View File

@@ -261,6 +261,16 @@ public sealed class ComponentRegistry
MinHeightCells: 4, MinHeightCells: 4,
AllowStatusBarPlacement: false, AllowStatusBarPlacement: false,
AllowDesktopPlacement: true), AllowDesktopPlacement: true),
new DesktopComponentDefinition(
BuiltInComponentIds.DesktopJuyaNews,
"橘鸦早报",
"News",
"Info",
MinWidthCells: 4,
MinHeightCells: 4,
AllowStatusBarPlacement: false,
AllowDesktopPlacement: true,
ResizeMode: DesktopComponentResizeMode.Free),
new DesktopComponentDefinition( new DesktopComponentDefinition(
BuiltInComponentIds.DesktopBilibiliHotSearch, BuiltInComponentIds.DesktopBilibiliHotSearch,
"Bilibili Hot Search", "Bilibili Hot Search",

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

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

View File

@@ -428,6 +428,10 @@ public sealed class DesktopComponentRuntimeRegistry
BuiltInComponentIds.DesktopIfengNews, BuiltInComponentIds.DesktopIfengNews,
"component.ifeng_news", "component.ifeng_news",
() => new IfengNewsWidget()), () => new IfengNewsWidget()),
new DesktopComponentRuntimeRegistration(
BuiltInComponentIds.DesktopJuyaNews,
"component.juya_news",
() => new JuyaNewsWidget()),
new DesktopComponentRuntimeRegistration( new DesktopComponentRuntimeRegistration(
BuiltInComponentIds.DesktopBilibiliHotSearch, BuiltInComponentIds.DesktopBilibiliHotSearch,
"component.bilibili_hot_search", "component.bilibili_hot_search",

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

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

@@ -1,53 +1,133 @@
# LanMountainDesktop # 阑山桌面 / LanMountainDesktop
`LanMountainDesktop` is the authoritative host repository for the desktop app and the host-side Plugin SDK. > 你的桌面,不止一面
## Repository Ownership [![.NET 10](https://img.shields.io/badge/.NET-10-512BD4)](https://dotnet.microsoft.com/)
[![Avalonia UI](https://img.shields.io/badge/Avalonia%20UI-11.2-blue)](https://avaloniaui.net/)
[![License](https://img.shields.io/badge/License-MIT-green.svg)](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 基于 Avalonia UI 和 .NET 10 构建,支持 Windows、Linux、macOS 三大平台。
- official sample plugin release source
- independent ecosystem documentation hub
## Ecosystem Boundaries ![Platform](https://img.shields.io/badge/Windows-✓-0078D4)
![Platform](https://img.shields.io/badge/Linux-✓-FCC624?logo=linux&logoColor=black)
![Platform](https://img.shields.io/badge/macOS-✓-000000?logo=apple)
- 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 - 通过 `.laapp` 插件扩展功能
- `LanMountainDesktop.PluginTemplate`: official `dotnet new` template package (`shortName`: `lmd-plugin`) - 官方 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
View 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*
*更新内容: 改为垂直连续滚动浏览模式*

View File

@@ -4,7 +4,7 @@
### 产品一句话 ### 产品一句话
阑山桌面是一个可编排的桌面信息与交互空间,面向需要高频查看信息、追求桌面效率与个性化体验的用户 阑山桌面——你的桌面,不止一面
### 产品定位 ### 产品定位