diff --git a/LanMountainDesktop/Assets/bilibili.svg b/LanMountainDesktop/Assets/bilibili.svg new file mode 100644 index 0000000..6c114c7 --- /dev/null +++ b/LanMountainDesktop/Assets/bilibili.svg @@ -0,0 +1 @@ +Bilibili \ No newline at end of file diff --git a/LanMountainDesktop/Assets/juya_avatar.jpg b/LanMountainDesktop/Assets/juya_avatar.jpg new file mode 100644 index 0000000..da95680 Binary files /dev/null and b/LanMountainDesktop/Assets/juya_avatar.jpg differ diff --git a/LanMountainDesktop/Assets/wechat.svg b/LanMountainDesktop/Assets/wechat.svg new file mode 100644 index 0000000..c3eb6c4 --- /dev/null +++ b/LanMountainDesktop/Assets/wechat.svg @@ -0,0 +1 @@ +WeChat \ No newline at end of file diff --git a/LanMountainDesktop/ComponentSystem/BuiltInComponentIds.cs b/LanMountainDesktop/ComponentSystem/BuiltInComponentIds.cs index 825c60f..dbc10d8 100644 --- a/LanMountainDesktop/ComponentSystem/BuiltInComponentIds.cs +++ b/LanMountainDesktop/ComponentSystem/BuiltInComponentIds.cs @@ -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"; diff --git a/LanMountainDesktop/ComponentSystem/ComponentRegistry.cs b/LanMountainDesktop/ComponentSystem/ComponentRegistry.cs index 52c9c02..b06c996 100644 --- a/LanMountainDesktop/ComponentSystem/ComponentRegistry.cs +++ b/LanMountainDesktop/ComponentSystem/ComponentRegistry.cs @@ -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", diff --git a/LanMountainDesktop/Views/Components/DailyNewsView.axaml b/LanMountainDesktop/Views/Components/DailyNewsView.axaml new file mode 100644 index 0000000..9fccee7 --- /dev/null +++ b/LanMountainDesktop/Views/Components/DailyNewsView.axaml @@ -0,0 +1,105 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/LanMountainDesktop/Views/Components/JuyaNewsWidget.axaml.cs b/LanMountainDesktop/Views/Components/JuyaNewsWidget.axaml.cs new file mode 100644 index 0000000..328066b --- /dev/null +++ b/LanMountainDesktop/Views/Components/JuyaNewsWidget.axaml.cs @@ -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 _cachedNews = new(); + private readonly List _loadedDates = new(); + private readonly List _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> FetchJuyaNewsAsync() + { + var result = new List(); + + 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, @"]+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, @"]+href=[""'](https?://(?:www\.)?bilibili\.com/[^""']+)[""'][^>]*>", RegexOptions.IgnoreCase); + if (bilibiliMatch.Success) + { + bilibiliUrl = bilibiliMatch.Groups[1].Value; + } + + var youtubeMatch = Regex.Match(content, @"]+href=[""'](https?://(?:www\.)?(?:youtube\.com|youtu\.be)/[^""']+)[""'][^>]*>", RegexOptions.IgnoreCase); + if (youtubeMatch.Success) + { + youtubeUrl = youtubeMatch.Groups[1].Value; + } + + return (bilibiliUrl, youtubeUrl); + } + + private static List ParseOverview(string content) + { + var categories = new List(); + + if (string.IsNullOrWhiteSpace(content)) + { + return categories; + } + + var categoryIcons = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["要闻"] = "📌", + ["开发生态"] = "💻", + ["产品应用"] = "📱", + ["产品发布"] = "🚀", + ["模型发布"] = "🤖", + ["行业动态"] = "📈", + ["技术与洞察"] = "🔍", + ["学术研究"] = "📚", + ["研究"] = "🔬", + ["开源"] = "🔓", + ["投资"] = "💰", + ["融资"] = "💵", + ["商业"] = "💼", + ["市场"] = "📊", + ["AI绘画"] = "🎨", + ["设计"] = "✏️", + ["创意"] = "💡", + ["前瞻与传闻"] = "🔮", + ["趋势"] = "📉", + ["预测"] = "🔭", + ["政策"] = "📋", + ["法规"] = "⚖️", + ["监管"] = "🛡️", + ["硬件"] = "🔧", + ["芯片"] = "🖥️", + ["基础设施"] = "🏗️", + ["其他"] = "•", + ["要点"] = "📋", + ["摘要"] = "📝" + }; + + var overviewMatch = Regex.Match(content, @"

\s*概览\s*

(.*?)(?:
|$)", RegexOptions.Singleline | RegexOptions.IgnoreCase); + + if (!overviewMatch.Success) + { + return categories; + } + + var overviewContent = overviewMatch.Groups[1].Value; + + var h3Matches = Regex.Matches(overviewContent, @"

([^<]+)

\s*
    (.*?)
", 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(); + var itemMatches = Regex.Matches(listContent, @"
  • (.*?)
  • ", 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, @"]+href=[""']([^""']+)[""'][^>]*>(.*?)", 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("]+>", "").Trim(); + + if (string.IsNullOrWhiteSpace(itemTitle)) + { + itemTitle = linkText; + } + } + else + { + itemTitle = Regex.Replace(itemText, @"<[^>]+>", "").Trim(); + itemUrl = ""; + } + + var numberMatch = Regex.Match(itemText, @"\s*#(\d+)\s*|#(\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 ParseDetailedNews(string content) + { + var newsItems = new List(); + + if (string.IsNullOrWhiteSpace(content)) + { + return newsItems; + } + + var detailedMatch = Regex.Match(content, @"
    (.*)$", RegexOptions.Singleline | RegexOptions.IgnoreCase); + if (!detailedMatch.Success) + { + return newsItems; + } + + var detailedContent = detailedMatch.Groups[1].Value; + + var newsMatches = Regex.Matches(detailedContent, @"

    (.*?)

    (.*?)(?=

    |
    |$)", 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, @"\s*#(\d+)\s*"); + 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, @"]*>(.*?)", RegexOptions.Singleline | RegexOptions.IgnoreCase); + if (linkMatch.Success) + { + title = Regex.Replace(linkMatch.Groups[1].Value, @"<[^>]+>", "").Trim(); + } + else + { + title = Regex.Replace(headerContent, @".*?", "", RegexOptions.Singleline | RegexOptions.IgnoreCase); + title = Regex.Replace(title, @"<[^>]+>", "").Trim(); + title = Regex.Replace(title, @"#\d+", "").Trim(); + } + + var bodyText = ExtractBodyText(bodyContent); + + var relatedLinks = new List(); + var linkMatches = Regex.Matches(bodyContent, @"]+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, @"
    (.*?)
    ", RegexOptions.Singleline | RegexOptions.IgnoreCase); + if (blockquoteMatch.Success) + { + var text = blockquoteMatch.Groups[1].Value; + // 移除

    标签但保留内容 + text = Regex.Replace(text, @"

    (.*?)

    ", "$1\n\n", RegexOptions.Singleline | RegexOptions.IgnoreCase); + // 移除其他 HTML 标签 + text = Regex.Replace(text, @"<[^>]+>", ""); + // 清理多余空白 + text = Regex.Replace(text, @"\n{3,}", "\n\n"); + return text.Trim(); + } + + // 如果没有 blockquote,提取所有

    标签内容 + var paragraphs = Regex.Matches(htmlContent, @"

    (.*?)

    ", RegexOptions.Singleline | RegexOptions.IgnoreCase); + if (paragraphs.Count > 0) + { + var text = string.Join("\n\n", paragraphs.Cast().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 OverviewCategories, + IReadOnlyList DetailedNews, + DateTimeOffset FetchedAt); + +public sealed record JuyaOverviewCategory( + string Name, + string Icon, + IReadOnlyList Items); + +public sealed record JuyaOverviewItem( + string Title, + string Url, + int? Number); + +public sealed record JuyaDetailedNewsItem( + string Title, + int Number, + string BodyText, + IReadOnlyList RelatedLinks); diff --git a/README.md b/README.md index 89d8176..47ff11b 100644 --- a/README.md +++ b/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 +[![.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 -- official sample plugin release source -- independent ecosystem documentation hub +基于 Avalonia UI 和 .NET 10 构建,支持 Windows、Linux、macOS 三大平台。 -## 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(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` diff --git a/docs/JUYA_NEWS_DESIGN.md b/docs/JUYA_NEWS_DESIGN.md new file mode 100644 index 0000000..a0b0b7d --- /dev/null +++ b/docs/JUYA_NEWS_DESIGN.md @@ -0,0 +1,556 @@ +# 橘鸦新闻组件 UI 设计文档 + +## 1. 数据源分析 + +### RSS 结构 +```xml + + 2026-03-23 + https://imjuya.github.io/juya-ai-daily/issue-37/ + AI 早报 2026-03-23 视频版... + + +

    AI 早报 2026-03-23

    +

    视频版: B站链接 | YouTube链接

    +

    要闻

    +
      +
    • 微信正式推出ClawBot插件... #1
    • +
    +

    开发者

    +
      +
    • Claude Code 测试新功能... #2
    • +
    + ...更多分类 + ]]> +
    + Mon, 23 Mar 2026 00:34:38 +0000 +
    +``` + +### 推送时间规律 +- **推送时间**: 每天凌晨 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 + + + + + + + +