更新UI
This commit is contained in:
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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}";
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user