This commit is contained in:
QWQLwToo
2026-07-12 09:12:12 +08:00
committed by admin_gitea
parent d44200fde1
commit 5e4355700f
43 changed files with 2797 additions and 709 deletions
@@ -0,0 +1,476 @@
using System.Text.Json;
using System.Text.RegularExpressions;
namespace YMhut.Box.Core.Updates;
public sealed record UpdateNoticeDocument(
string Version,
string Build,
string Channel,
string Title,
string Summary,
DateTimeOffset? PublishedAt,
bool Mandatory,
IReadOnlyList<UpdateNoticeCategory> Categories,
IReadOnlyList<UpdateNoticeSection> Sections,
IReadOnlyList<UpdateNoticeSection> History,
string RawMarkdown,
string RawText)
{
public bool HasStructuredContent => Sections.Count > 0 || History.Count > 0;
}
public sealed record UpdateNoticeCategory(
string Id,
string Name,
string Icon);
public sealed record UpdateNoticeSection(
string Id,
string Title,
string Icon,
IReadOnlyList<UpdateNoticeItem> Items);
public sealed record UpdateNoticeItem(
string Title,
string Body,
string Kind = "",
string Tag = "");
public static class UpdateNoticeDocumentBuilder
{
public static UpdateNoticeDocument FromJson(JsonElement root, JsonElement latest)
{
var version = FirstNonEmpty(
GetString(root, "latestVersion"),
GetString(latest, "version"),
GetString(latest, "app_version"),
GetString(latest, "appVersion"),
GetString(root, "version"),
GetString(root, "app_version"));
var build = FirstNonEmpty(GetString(latest, "build"), GetString(latest, "build_number"), GetString(root, "build"));
var channel = FirstNonEmpty(GetString(latest, "channel"), GetString(root, "channel"), "stable");
var title = FirstNonEmpty(GetString(latest, "title"), GetString(root, "title"), version);
var message = FirstNonEmpty(
GetString(latest, "message_md"),
GetString(latest, "messageMarkdown"),
GetString(latest, "message"),
GetString(latest, "description"),
GetString(root, "message_md"),
GetString(root, "messageMarkdown"),
GetString(root, "message"),
GetString(root, "home_notes"));
var rawMarkdown = FirstNonEmpty(
GetString(latest, "release_notes_md"),
GetString(latest, "releaseNotesMarkdown"),
GetString(latest, "changelog_md"),
GetString(latest, "latestNotesMarkdown"),
GetString(latest, "latest_notes_md"),
GetString(root, "release_notes_md"),
GetString(root, "releaseNotesMarkdown"),
GetString(root, "changelog_md"),
GetString(root, "latestNotesMarkdown"),
GetString(root, "latest_notes_md"));
var rawText = FirstNonEmpty(
GetString(latest, "release_notes"),
GetString(latest, "releaseNotes"),
GetString(latest, "changelog"),
GetString(root, "release_notes"),
GetString(root, "releaseNotes"),
GetString(root, "changelog"));
var categories = ParseCategories(latest, root);
var sections = ParseDictionarySections(latest, root, "update_notes", categories);
if (sections.Count == 0 && !string.IsNullOrWhiteSpace(rawMarkdown))
{
sections = ParseMarkdownSections(rawMarkdown, categories);
}
if (sections.Count == 0 && !string.IsNullOrWhiteSpace(rawText))
{
sections = [new UpdateNoticeSection("updates", "更新内容", "\uE8D4", SplitPlainText(rawText).ToArray())];
}
if (sections.Count == 0 && !string.IsNullOrWhiteSpace(message))
{
sections = [new UpdateNoticeSection("summary", "公告摘要", "\uE789", SplitPlainText(message).ToArray())];
}
return new UpdateNoticeDocument(
version,
build,
channel,
title,
FirstParagraph(message, rawMarkdown, rawText),
TryDate(FirstNonEmpty(
GetString(latest, "published_at"),
GetString(latest, "release_date"),
GetString(root, "published_at"),
GetString(root, "last_updated"))),
GetBoolean(latest, "mandatory") || GetBoolean(latest, "force_update"),
categories,
sections,
ParseHistory(latest, root),
rawMarkdown,
rawText);
}
public static UpdateNoticeDocument FromText(
string version,
string build,
string channel,
string title,
string message,
string releaseNotes,
string markdown,
DateTimeOffset? publishedAt,
bool mandatory)
{
var sections = !string.IsNullOrWhiteSpace(markdown)
? ParseMarkdownSections(markdown, [])
: [new UpdateNoticeSection("updates", "更新内容", "\uE8D4", SplitPlainText(releaseNotes).ToArray())];
if (sections.Count == 0)
{
sections = [new UpdateNoticeSection("summary", "公告摘要", "\uE789", SplitPlainText(message).ToArray())];
}
return new UpdateNoticeDocument(
version,
build,
channel,
title,
FirstParagraph(message, markdown, releaseNotes),
publishedAt,
mandatory,
[],
sections,
[],
markdown,
releaseNotes);
}
private static IReadOnlyList<UpdateNoticeCategory> ParseCategories(params JsonElement[] roots)
{
foreach (var root in roots)
{
if (root.ValueKind != JsonValueKind.Object ||
!root.TryGetProperty("category_list", out var value) ||
value.ValueKind != JsonValueKind.Array)
{
continue;
}
var categories = value.EnumerateArray()
.Where(item => item.ValueKind == JsonValueKind.Object)
.Select(item => new UpdateNoticeCategory(
FirstNonEmpty(GetString(item, "id"), Slug(GetString(item, "name"))),
FirstNonEmpty(GetString(item, "name"), GetString(item, "id")),
IconFor(FirstNonEmpty(GetString(item, "icon"), GetString(item, "id"), GetString(item, "name")))))
.Where(item => !string.IsNullOrWhiteSpace(item.Name))
.ToArray();
if (categories.Length > 0)
{
return categories;
}
}
return [];
}
private static IReadOnlyList<UpdateNoticeSection> ParseDictionarySections(
JsonElement latest,
JsonElement root,
string fieldName,
IReadOnlyList<UpdateNoticeCategory> categories)
{
var source = TryGetObject(latest, fieldName) ?? TryGetObject(root, fieldName);
if (source is null)
{
return [];
}
return source.Value.EnumerateObject()
.Select(property =>
{
var category = MatchCategory(property.Name, categories);
return new UpdateNoticeSection(
category?.Id ?? Slug(property.Name),
property.Name,
category?.Icon ?? IconFor(property.Name),
SplitPlainText(ElementToText(property.Value), property.Name).ToArray());
})
.Where(section => section.Items.Count > 0)
.ToArray();
}
private static IReadOnlyList<UpdateNoticeSection> ParseMarkdownSections(
string markdown,
IReadOnlyList<UpdateNoticeCategory> categories)
{
var sections = new List<UpdateNoticeSection>();
var title = "更新内容";
var lines = new List<string>();
foreach (var raw in NormalizeLines(markdown))
{
var line = raw.TrimEnd();
var heading = Regex.Match(line, @"^\s{0,3}#{1,3}\s+(?<title>.+)$");
if (heading.Success)
{
AddSection();
title = CleanInline(heading.Groups["title"].Value);
lines.Clear();
continue;
}
lines.Add(line);
}
AddSection();
return sections;
void AddSection()
{
var items = ParseMarkdownItems(lines, title).ToArray();
if (items.Length == 0)
{
return;
}
var category = MatchCategory(title, categories);
sections.Add(new UpdateNoticeSection(
category?.Id ?? Slug(title),
title,
category?.Icon ?? IconFor(title),
items));
}
}
private static IEnumerable<UpdateNoticeItem> ParseMarkdownItems(IReadOnlyList<string> lines, string sectionTitle)
{
var buffer = new List<string>();
foreach (var line in lines)
{
if (string.IsNullOrWhiteSpace(line))
{
Flush();
continue;
}
var list = Regex.Match(line.Trim(), @"^(\d+[\.)]|[-*+])\s+(?<text>.+)$");
if (list.Success)
{
Flush();
yield return ItemFromText(CleanInline(list.Groups["text"].Value), sectionTitle);
continue;
}
if (!line.TrimStart().StartsWith('|'))
{
buffer.Add(line.Trim());
}
}
foreach (var item in Flush())
{
yield return item;
}
IEnumerable<UpdateNoticeItem> Flush()
{
if (buffer.Count == 0)
{
yield break;
}
var text = CleanInline(string.Join(" ", buffer));
buffer.Clear();
if (!string.IsNullOrWhiteSpace(text))
{
foreach (var item in SplitPlainText(text, sectionTitle))
{
yield return item;
}
}
}
}
private static IReadOnlyList<UpdateNoticeSection> ParseHistory(JsonElement latest, JsonElement root)
{
var source = TryGetObject(latest, "last_update_notes") ?? TryGetObject(root, "last_update_notes");
if (source is null)
{
return [];
}
var items = source.Value.EnumerateObject()
.Select(property => ItemFromText(ElementToText(property.Value), property.Name))
.Where(item => !string.IsNullOrWhiteSpace(item.Body) || !string.IsNullOrWhiteSpace(item.Title))
.ToArray();
return items.Length == 0 ? [] : [new UpdateNoticeSection("history", "历史版本", "\uE81C", items)];
}
private static IEnumerable<UpdateNoticeItem> SplitPlainText(string text, string fallbackTitle = "")
{
foreach (var part in Regex.Split(text ?? string.Empty, @"(?<=[。!?;;.!?])\s+|[\r\n]+|(?<=)|(?<=;)"))
{
var clean = CleanInline(part).Trim(' ', '-', '*', '•');
if (string.IsNullOrWhiteSpace(clean))
{
continue;
}
yield return ItemFromText(clean, fallbackTitle);
}
}
private static UpdateNoticeItem ItemFromText(string text, string fallbackTitle)
{
var clean = CleanInline(text);
var parts = clean.Split(['', ':'], 2, StringSplitOptions.TrimEntries);
if (parts.Length == 2 && parts[0].Length is > 0 and <= 28)
{
return new UpdateNoticeItem(parts[0], parts[1], KindFor(parts[0]), TagFor(parts[0]));
}
return new UpdateNoticeItem(string.IsNullOrWhiteSpace(fallbackTitle) ? "更新项" : fallbackTitle, clean, KindFor(clean), TagFor(clean));
}
private static UpdateNoticeCategory? MatchCategory(string title, IReadOnlyList<UpdateNoticeCategory> categories)
{
var slug = Slug(title);
return categories.FirstOrDefault(category =>
string.Equals(category.Id, slug, StringComparison.OrdinalIgnoreCase) ||
title.Contains(category.Name, StringComparison.OrdinalIgnoreCase));
}
private static string FirstParagraph(params string[] values)
=> CleanInline(values
.SelectMany(NormalizeLines)
.Select(line => line.Trim())
.FirstOrDefault(line => !string.IsNullOrWhiteSpace(line) && !line.StartsWith('#') && !Regex.IsMatch(line, @"^(\d+[\.)]|[-*+])\s+")) ?? string.Empty);
private static IEnumerable<string> NormalizeLines(string value)
=> (value ?? string.Empty).Replace("\r\n", "\n", StringComparison.Ordinal).Split('\n');
private static string CleanInline(string value)
=> Regex.Replace(value ?? string.Empty, @"\*\*(?<text>.+?)\*\*|`(?<code>.+?)`|\[(?<link>[^\]]+)\]\([^)]+\)", match =>
{
if (match.Groups["text"].Success)
{
return match.Groups["text"].Value;
}
if (match.Groups["code"].Success)
{
return match.Groups["code"].Value;
}
return match.Groups["link"].Success ? match.Groups["link"].Value : match.Value;
}).Trim();
private static string KindFor(string text)
{
if (Regex.IsMatch(text, "修复|解决|失败|错误|异常|fix|bug", RegexOptions.IgnoreCase))
{
return "fix";
}
if (Regex.IsMatch(text, "新增|支持|增加|add|new", RegexOptions.IgnoreCase))
{
return "new";
}
if (Regex.IsMatch(text, "优化|调整|体验|重构|改善|improve|optimize", RegexOptions.IgnoreCase))
{
return "improve";
}
return "change";
}
private static string TagFor(string text)
=> KindFor(text) switch
{
"fix" => "修复",
"new" => "新增",
"improve" => "优化",
_ => "调整"
};
private static string IconFor(string value)
{
if (Regex.IsMatch(value, "修复|稳定|安全|shield|fix|bug", RegexOptions.IgnoreCase))
{
return "\uE83D";
}
if (Regex.IsMatch(value, "导航|交互|route|navigation", RegexOptions.IgnoreCase))
{
return "\uE8AB";
}
if (Regex.IsMatch(value, "工具|能力|新增|new|tool", RegexOptions.IgnoreCase))
{
return "\uE90F";
}
if (Regex.IsMatch(value, "历史|last|history", RegexOptions.IgnoreCase))
{
return "\uE81C";
}
return "\uE8D4";
}
private static string Slug(string value)
{
var clean = Regex.Replace(value ?? string.Empty, @"[^\p{L}\p{N}]+", "-").Trim('-');
return string.IsNullOrWhiteSpace(clean) ? "updates" : clean.ToLowerInvariant();
}
private static JsonElement? TryGetObject(JsonElement root, string name)
=> root.ValueKind == JsonValueKind.Object &&
root.TryGetProperty(name, out var value) &&
value.ValueKind == JsonValueKind.Object
? value
: null;
private static string ElementToText(JsonElement element)
=> element.ValueKind switch
{
JsonValueKind.String => element.GetString() ?? string.Empty,
JsonValueKind.Number or JsonValueKind.True or JsonValueKind.False => element.ToString(),
_ => string.Empty
};
private static string GetString(JsonElement root, string name)
=> root.ValueKind == JsonValueKind.Object &&
root.TryGetProperty(name, out var value)
? value.ValueKind switch
{
JsonValueKind.String => value.GetString() ?? string.Empty,
JsonValueKind.Number => value.GetRawText(),
JsonValueKind.True => "true",
JsonValueKind.False => "false",
_ => string.Empty
}
: string.Empty;
private static bool GetBoolean(JsonElement root, string name)
=> root.ValueKind == JsonValueKind.Object &&
root.TryGetProperty(name, out var value) &&
value.ValueKind switch
{
JsonValueKind.True => true,
JsonValueKind.False => false,
JsonValueKind.String => bool.TryParse(value.GetString(), out var parsed) && parsed,
_ => false
};
private static DateTimeOffset? TryDate(string value)
=> DateTimeOffset.TryParse(value, out var date) ? date : null;
private static string FirstNonEmpty(params string[] values)
=> values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value)) ?? string.Empty;
}
@@ -0,0 +1,79 @@
using System.Text.Json;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using YMhut.Box.Core.Updates;
namespace YMhut.Box.Tests;
[TestClass]
public sealed class UpdateNoticeDocumentTests
{
[TestMethod]
public void ReleaseNotesMarkdownBuildsReadableSections()
{
using var json = JsonDocument.Parse("""
{
"app_version": "2.0.7",
"build": "10",
"channel": "stable",
"title": "YMhut Box 2.0.7.10",
"release_notes_md": "## 新增能力\n\n- 新增结构化更新日志。\n- 支持分类和历史版本。\n\n## 修复优化\n\n- 修复长文本挤在一起的问题。"
}
""");
var document = UpdateNoticeDocumentBuilder.FromJson(json.RootElement, json.RootElement);
Assert.AreEqual("2.0.7", document.Version);
Assert.HasCount(2, document.Sections);
Assert.AreEqual("新增能力", document.Sections[0].Title);
Assert.HasCount(2, document.Sections[0].Items);
Assert.AreEqual("修复优化", document.Sections[1].Title);
}
[TestMethod]
public void UpdateNotesAndCategoriesProduceStructuredCards()
{
using var json = JsonDocument.Parse("""
{
"app_version": "2.0.7",
"category_list": [
{ "id": "shell", "name": "壳层体验", "icon": "monitor" },
{ "id": "stability", "name": "稳定性", "icon": "shield" }
],
"update_notes": {
"壳层体验": "更新日志改为结构化卡片。",
"稳定性": "修复公告弹窗拥挤。"
},
"last_update_notes": {
"v2.0.6": "上一版优化工具结果展示。"
}
}
""");
var document = UpdateNoticeDocumentBuilder.FromJson(json.RootElement, json.RootElement);
Assert.HasCount(2, document.Categories);
Assert.HasCount(2, document.Sections);
Assert.HasCount(1, document.History);
Assert.AreEqual("壳层体验", document.Sections[0].Title);
Assert.AreEqual("稳定性", document.Sections[1].Title);
Assert.AreEqual("历史版本", document.History[0].Title);
}
[TestMethod]
public void PlainReleaseNotesSplitIntoMultipleItems()
{
using var json = JsonDocument.Parse("""
{
"app_version": "2.0.7",
"release_notes": "修复更新日志过于拥挤;新增卡片式展示;优化弹窗滚动。"
}
""");
var document = UpdateNoticeDocumentBuilder.FromJson(json.RootElement, json.RootElement);
Assert.HasCount(1, document.Sections);
Assert.IsGreaterThanOrEqualTo(document.Sections[0].Items.Count, 3);
Assert.IsTrue(document.Sections[0].Items.Any(item => item.Kind == "fix"));
Assert.IsTrue(document.Sections[0].Items.Any(item => item.Kind == "new"));
}
}
+2 -2
View File
@@ -138,8 +138,8 @@ internal sealed class AppShell : Grid
{
IsBackButtonVisible = NavigationViewBackButtonVisible.Collapsed,
IsSettingsVisible = false,
PaneDisplayMode = NavigationViewPaneDisplayMode.Left,
IsPaneOpen = true,
PaneDisplayMode = NavigationViewPaneDisplayMode.LeftCompact,
IsPaneOpen = false,
CompactPaneLength = 56,
OpenPaneLength = 260,
SelectionFollowsFocus = NavigationViewSelectionFollowsFocus.Disabled,
+3 -3
View File
@@ -535,8 +535,8 @@ public sealed partial class MainWindow : Window, IShellNavigationHost
ToolTipService.SetToolTip(ThemeToggleButton, "切换主题");
ConfigureTopButtonFeedback();
ApplyLanguage();
RootNavigation.PaneDisplayMode = NavigationViewPaneDisplayMode.Left;
RootNavigation.IsPaneOpen = true;
RootNavigation.PaneDisplayMode = NavigationViewPaneDisplayMode.LeftCompact;
RootNavigation.IsPaneOpen = false;
RootNavigation.SelectedItem = HomeNavItem;
RefreshPluginNavigation();
}
@@ -1961,7 +1961,7 @@ public sealed partial class MainWindow : Window, IShellNavigationHost
: phone
? NavigationViewPaneDisplayMode.LeftMinimal
: NavigationViewPaneDisplayMode.LeftCompact;
RootNavigation.IsPaneOpen = wide;
RootNavigation.IsPaneOpen = false;
RootNavigation.CompactPaneLength = 56;
RootNavigation.OpenPaneLength = wide ? 260 : 228;
@@ -140,6 +140,7 @@ public sealed class AppInstallerUpdateService(
return null;
}
var noticeDocument = UpdateNoticeDocumentBuilder.FromJson(root, latest);
var build = FirstNonEmpty(GetString(latest, "build"), GetString(latest, "build_number"), GetString(root, "build"));
var download = FirstNonEmpty(
GetString(installer, "url"),
@@ -209,7 +210,8 @@ public sealed class AppInstallerUpdateService(
GetString(package, "updateTime"),
GetString(package, "updateDate"))),
messageMarkdown,
releaseNotesMarkdown);
releaseNotesMarkdown,
noticeDocument);
}
catch
{
@@ -27,7 +27,8 @@ public sealed record RemoteUpdateInfo(
long SizeBytes,
DateTimeOffset? PublishedAt,
string MessageMarkdown = "",
string ReleaseNotesMarkdown = "")
string ReleaseNotesMarkdown = "",
UpdateNoticeDocument? NoticeDocument = null)
{
public string EffectiveVersion => UpdateVersionComparer.NormalizeVersion(Version, Build);
public string NormalizedVersion => EffectiveVersion;
@@ -35,6 +36,16 @@ public sealed record RemoteUpdateInfo(
public bool HasMarkdownNotes => !string.IsNullOrWhiteSpace(MessageMarkdown) || !string.IsNullOrWhiteSpace(ReleaseNotesMarkdown);
public string DisplayMessage => string.IsNullOrWhiteSpace(MessageMarkdown) ? Message : MessageMarkdown;
public string DisplayReleaseNotes => string.IsNullOrWhiteSpace(ReleaseNotesMarkdown) ? ReleaseNotes : ReleaseNotesMarkdown;
public UpdateNoticeDocument Notice => NoticeDocument ?? UpdateNoticeDocumentBuilder.FromText(
Version,
Build,
Channel,
Title,
DisplayMessage,
ReleaseNotes,
ReleaseNotesMarkdown,
PublishedAt,
Mandatory);
public int CompareToCurrent(string currentVersion) => UpdateVersionComparer.Compare(Version, Build, currentVersion);
+208
View File
@@ -0,0 +1,208 @@
using Microsoft.UI.Text;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Automation;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Media;
using YMhut.Box.Core.Updates;
namespace YMhut.Box.WinUI;
internal enum UpdateNoticeRenderMode
{
CompactDialog,
FullDialog,
UpdatePrompt
}
internal static class UpdateNoticeRenderer
{
public static UIElement Render(UpdateNoticeDocument document, UpdateNoticeRenderMode mode, string? currentVersion = null)
{
var root = new StackPanel { Spacing = 14 };
root.Children.Add(BuildHero(document, mode, currentVersion));
var body = new Grid { ColumnSpacing = 14 };
body.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(mode == UpdateNoticeRenderMode.CompactDialog ? 0 : 172) });
body.ColumnDefinitions.Add(new ColumnDefinition());
if (mode != UpdateNoticeRenderMode.CompactDialog)
{
body.Children.Add(BuildCategoryRail(document));
}
var sections = new StackPanel { Spacing = 12 };
foreach (var section in document.Sections)
{
sections.Children.Add(BuildSection(section));
}
foreach (var section in document.History)
{
sections.Children.Add(BuildSection(section, compact: true));
}
if (sections.Children.Count == 0)
{
sections.Children.Add(ModernUi.Card(
ModernUi.Text(AppLocalizer.T("暂无更新日志。", "No update notes."), 14, foreground: ModernUi.TextSecondary),
new Thickness(14),
radius: 8,
background: ModernUi.SurfaceAlt));
}
Grid.SetColumn(sections, 1);
body.Children.Add(sections);
root.Children.Add(body);
if (!string.IsNullOrWhiteSpace(document.RawMarkdown) && mode == UpdateNoticeRenderMode.FullDialog)
{
var expander = new Expander
{
Header = AppLocalizer.T("原始 Markdown", "Raw Markdown"),
Content = MarkdownRenderHelper.Render(document.RawMarkdown)
};
root.Children.Add(expander);
}
return root;
}
private static UIElement BuildHero(UpdateNoticeDocument document, UpdateNoticeRenderMode mode, string? currentVersion)
{
var grid = new Grid { ColumnSpacing = 16, RowSpacing = 10 };
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
grid.ColumnDefinitions.Add(new ColumnDefinition());
grid.Children.Add(ModernUi.IconTile("\uE8D4", 46, ModernUi.AccentSoft, ModernUi.Accent, 20));
var title = string.IsNullOrWhiteSpace(document.Title)
? AppLocalizer.T("更新日志", "Update notes")
: AppLocalizer.SanitizeSensitiveText(document.Title, 120);
var summary = string.IsNullOrWhiteSpace(document.Summary)
? AppLocalizer.T("本次更新内容已经按分类整理。", "This update is organized by category.")
: AppLocalizer.SanitizeSensitiveText(document.Summary, mode == UpdateNoticeRenderMode.UpdatePrompt ? 180 : 260);
var text = new StackPanel
{
Spacing = 7,
Children =
{
ModernUi.Text(title, mode == UpdateNoticeRenderMode.UpdatePrompt ? 19 : 20, FontWeights.SemiBold, maxLines: 2),
ModernUi.Text(summary, 13.5, foreground: ModernUi.TextSecondary, maxLines: mode == UpdateNoticeRenderMode.UpdatePrompt ? 3 : 4),
ModernUi.BadgeRow(BuildMetaBadges(document, currentVersion), itemWidth: 132, itemHeight: 28, maxHeight: 68)
}
};
Grid.SetColumn(text, 1);
grid.Children.Add(text);
return ModernUi.Card(grid, new Thickness(16), radius: 8, background: ModernUi.Surface);
}
private static IEnumerable<UIElement> BuildMetaBadges(UpdateNoticeDocument document, string? currentVersion)
{
yield return ModernUi.SmallBadge(
AppLocalizer.T($"版本 {DisplayVersion(document)}", $"Version {DisplayVersion(document)}"),
ModernUi.Accent,
ModernUi.AccentSoft);
if (!string.IsNullOrWhiteSpace(currentVersion))
{
yield return ModernUi.SmallBadge(
AppLocalizer.T($"当前 {currentVersion}", $"Current {currentVersion}"),
ModernUi.TextSecondary,
ModernUi.SurfaceAlt);
}
yield return ModernUi.SmallBadge(
string.IsNullOrWhiteSpace(document.Channel) ? "stable" : document.Channel,
ModernUi.TextSecondary,
ModernUi.SurfaceAlt);
yield return ModernUi.SmallBadge(
document.PublishedAt?.ToLocalTime().ToString("yyyy-MM-dd") ?? AppLocalizer.T("日期未知", "Date unknown"),
ModernUi.TextSecondary,
ModernUi.SurfaceAlt);
if (document.Mandatory)
{
yield return ModernUi.SmallBadge(AppLocalizer.T("强制更新", "Mandatory"), ModernUi.Danger, ModernUi.SurfaceAlt);
}
}
private static UIElement BuildCategoryRail(UpdateNoticeDocument document)
{
var panel = new StackPanel { Spacing = 8 };
panel.Children.Add(ModernUi.Text(AppLocalizer.T("分区", "Sections"), 12, FontWeights.SemiBold, ModernUi.TextSecondary, maxLines: 1));
foreach (var section in document.Sections.Concat(document.History).Take(10))
{
var row = new Grid { ColumnSpacing = 8 };
row.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
row.ColumnDefinitions.Add(new ColumnDefinition());
row.Children.Add(ModernUi.IconTile(string.IsNullOrWhiteSpace(section.Icon) ? "\uE8D4" : section.Icon, 28, ModernUi.SurfaceAlt, ModernUi.Accent, 12));
var label = ModernUi.Text(section.Title, 12.5, FontWeights.SemiBold, ModernUi.TextPrimary, maxLines: 2);
Grid.SetColumn(label, 1);
row.Children.Add(label);
panel.Children.Add(ModernUi.Card(row, new Thickness(8), radius: 8, background: ModernUi.SurfaceAlt));
}
return panel;
}
private static UIElement BuildSection(UpdateNoticeSection section, bool compact = false)
{
var panel = new StackPanel { Spacing = 10 };
var header = new Grid { ColumnSpacing = 10 };
header.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
header.ColumnDefinitions.Add(new ColumnDefinition());
header.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
header.Children.Add(ModernUi.IconTile(string.IsNullOrWhiteSpace(section.Icon) ? "\uE8D4" : section.Icon, 34, ModernUi.AccentSoft, ModernUi.Accent, 15));
var title = ModernUi.Text(section.Title, 16, FontWeights.SemiBold, maxLines: 2);
Grid.SetColumn(title, 1);
header.Children.Add(title);
var count = ModernUi.SmallBadge(AppLocalizer.T($"{section.Items.Count} 项", $"{section.Items.Count} items"), ModernUi.TextSecondary, ModernUi.SurfaceAlt);
Grid.SetColumn(count, 2);
header.Children.Add(count);
panel.Children.Add(header);
foreach (var item in section.Items.Take(compact ? 8 : 60))
{
panel.Children.Add(BuildItem(item, compact));
}
return ModernUi.Card(panel, new Thickness(14), radius: 8, background: ModernUi.Surface);
}
private static UIElement BuildItem(UpdateNoticeItem item, bool compact)
{
var grid = new Grid { ColumnSpacing = 10 };
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
grid.ColumnDefinitions.Add(new ColumnDefinition());
var badge = ModernUi.SmallBadge(string.IsNullOrWhiteSpace(item.Tag) ? AppLocalizer.T("更新", "Change") : item.Tag, BrushFor(item.Kind), ModernUi.SurfaceAlt);
Grid.SetColumn(badge, 0);
grid.Children.Add(badge);
var text = new StackPanel { Spacing = 2 };
if (!string.IsNullOrWhiteSpace(item.Title) &&
!string.Equals(item.Title, item.Body, StringComparison.OrdinalIgnoreCase))
{
text.Children.Add(ModernUi.Text(AppLocalizer.SanitizeSensitiveText(item.Title, 80), 13.5, FontWeights.SemiBold, maxLines: 2));
}
text.Children.Add(ModernUi.Text(AppLocalizer.SanitizeSensitiveText(item.Body, compact ? 160 : 360), 13, foreground: ModernUi.TextSecondary, maxLines: compact ? 3 : 5));
Grid.SetColumn(text, 1);
grid.Children.Add(text);
AutomationProperties.SetName(grid, $"{item.Tag} {item.Title} {item.Body}");
return ModernUi.Card(grid, new Thickness(10), radius: 8, background: ModernUi.SurfaceAlt);
}
private static Brush BrushFor(string kind)
=> kind switch
{
"fix" => ModernUi.Danger,
"new" => ModernUi.Success,
"improve" => ModernUi.Accent,
_ => ModernUi.TextSecondary
};
private static string DisplayVersion(UpdateNoticeDocument document)
=> string.IsNullOrWhiteSpace(document.Build)
? document.Version
: $"{document.Version}.{document.Build}";
}
+3 -19
View File
@@ -1,4 +1,4 @@
using System.Diagnostics;
using System.Diagnostics;
using Microsoft.UI.Text;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
@@ -783,26 +783,10 @@ public sealed class AboutPage : Page
private async Task ShowUpdateDialogAsync(RemoteUpdateInfo info, string current)
{
var content = new StackPanel
{
Spacing = 10,
Children =
{
ModernUi.Text(string.IsNullOrWhiteSpace(info.Title) ? AppLocalizer.T("发现新版本", "New version available") : AppLocalizer.SanitizeSensitiveText(info.Title, 120), 18, FontWeights.SemiBold),
ModernUi.Text(string.IsNullOrWhiteSpace(info.DisplayMessage) ? AppLocalizer.T("远程发布信息未提供摘要。", "No release summary was provided.") : AppLocalizer.SanitizeSensitiveText(info.DisplayMessage, 300), 14, foreground: ModernUi.TextSecondary),
BuildUpdateLine(AppLocalizer.T("最新版本", "Latest"), info.DisplayVersion),
BuildUpdateLine(AppLocalizer.T("当前版本", "Current"), current),
BuildUpdateLine(AppLocalizer.T("发布通道", "Channel"), string.IsNullOrWhiteSpace(info.Channel) ? "-" : info.Channel),
BuildUpdateLine(AppLocalizer.T("发布时间", "Published"), info.PublishedAt?.ToLocalTime().ToString("yyyy-MM-dd HH:mm") ?? "-"),
BuildUpdateLine(AppLocalizer.T("强制更新", "Mandatory"), info.Mandatory ? AppLocalizer.T("是", "Yes") : AppLocalizer.T("否", "No")),
MarkdownRenderHelper.Render(string.IsNullOrWhiteSpace(info.DisplayReleaseNotes) ? AppLocalizer.T("暂无发布说明。", "No release notes.") : info.DisplayReleaseNotes)
}
};
var dialog = new ContentDialog
{
Title = AppLocalizer.T("软件更新", "Software update"),
Content = content,
Content = ModernUi.GutterScroll(UpdateNoticeRenderer.Render(info.Notice, UpdateNoticeRenderMode.UpdatePrompt, current), 620),
PrimaryButtonText = AppLocalizer.T("立即更新", "Update now"),
SecondaryButtonText = AppLocalizer.T("稍后", "Later"),
CloseButtonText = AppLocalizer.T("取消", "Cancel"),
@@ -824,7 +808,6 @@ public sealed class AboutPage : Page
SetCheckingState(false);
await ShowDownloadDialogAsync(info);
}
private async Task ShowDownloadDialogAsync(RemoteUpdateInfo info)
{
await StartDownloadUpdateAsync(info);
@@ -963,3 +946,4 @@ public sealed class AboutPage : Page
}
}
+18 -19
View File
@@ -295,16 +295,15 @@ public sealed class HomePage : Page
? AppLocalizer.T("完整公告", "Full announcement")
: VersionAnnouncementTitle(info, relation);
var content = info is null
? AppLocalizer.T("欢迎使用 YMhut Box。新版 WinUI 首页已对齐旧版的公告、搜索和工具工作台体验。", "Welcome to YMhut Box. The WinUI home page now aligns with the classic announcement, search, and dashboard experience.")
: CombineAnnouncement(info, current, relation);
var meta = info is null
? AppLocalizer.T("本地公告", "Local announcement")
: VersionAnnouncementMeta(info, current, relation, includeTime: true);
? MarkdownRenderHelper.Render(
AppLocalizer.T("欢迎使用 YMhut Box。新版 WinUI 首页已对齐旧版的公告、搜索和工具工作台体验。", "Welcome to YMhut Box. The WinUI home page now aligns with the classic announcement, search, and dashboard experience."),
AppLocalizer.T("本地公告", "Local announcement"))
: UpdateNoticeRenderer.Render(info.Notice, UpdateNoticeRenderMode.FullDialog, current);
var dialog = new ContentDialog
{
Title = title,
Content = ModernUi.GutterScroll(MarkdownRenderHelper.Render(content, meta), 520),
Content = ModernUi.GutterScroll(content, 620),
CloseButtonText = AppLocalizer.T("关闭", "Close"),
XamlRoot = XamlRoot
};
@@ -314,27 +313,26 @@ public sealed class HomePage : Page
private async Task ShowUpdateNotesDialogAsync()
{
await EnsureAnnouncementLoadedAsync();
var notes = _announcementInfo is null || string.IsNullOrWhiteSpace(_announcementInfo.DisplayReleaseNotes)
? AppLocalizer.T("暂无远程更新日志。", "No remote update notes are available.")
: _announcementInfo.DisplayReleaseNotes;
var current = _versionService.GetCurrent().Version;
var document = _announcementInfo?.Notice ?? UpdateNoticeDocumentBuilder.FromText(
current,
string.Empty,
"local",
AppLocalizer.T("更新日志", "Update notes"),
AppLocalizer.T("暂无远程更新日志。", "No remote update notes are available."),
string.Empty,
string.Empty,
null,
false);
var dialog = new ContentDialog
{
Title = AppLocalizer.T("更新日志", "Update notes"),
Content = ModernUi.GutterScroll(
MarkdownRenderHelper.Render(
notes,
_announcementInfo is null ? null : VersionAnnouncementMeta(
_announcementInfo,
_versionService.GetCurrent().Version,
_announcementInfo.CompareToCurrent(_versionService.GetCurrent().Version),
includeTime: false)),
560),
Content = ModernUi.GutterScroll(UpdateNoticeRenderer.Render(document, UpdateNoticeRenderMode.CompactDialog, current), 620),
CloseButtonText = AppLocalizer.T("关闭", "Close"),
XamlRoot = XamlRoot
};
await dialog.ShowAsync();
}
private async Task EnsureAnnouncementLoadedAsync()
{
if (!_announcementLoaded)
@@ -1204,3 +1202,4 @@ public sealed class HomePage : Page
}
}