From 8ae90f81b4db98f87b3c927a1779b7c39a81959e Mon Sep 17 00:00:00 2001 From: ZerkyLiu Date: Tue, 27 Jan 2026 13:43:10 +0100 Subject: [PATCH] =?UTF-8?q?=E4=B8=8A=E4=BC=A0=E6=96=87=E4=BB=B6=E8=87=B3?= =?UTF-8?q?=E3=80=8C/=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 同步最新发布版1.2.3 --- MainForm.cs | 500 +++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 493 insertions(+), 7 deletions(-) diff --git a/MainForm.cs b/MainForm.cs index 9c98b18..c31ee13 100644 --- a/MainForm.cs +++ b/MainForm.cs @@ -26,6 +26,7 @@ using System.Diagnostics; using System.Linq; using System.Reflection; using System.Runtime.InteropServices; +using System.Text.RegularExpressions; using System.Threading.Tasks; using System.Windows.Forms; @@ -39,6 +40,9 @@ namespace 善学教育积分卡汇率计算器 private static readonly string[] NtpServers = { "ntp.aliyun.com", "ntp.ntsc.ac.cn", "pool.ntp.org" }; private static readonly string RateTextHorizontal = BuildRateText(true); private static readonly string RateTextVertical = BuildRateText(false); + private const string CurrentVersion = "1.2.3"; + private const string ReleasePageUrl = "https://gittea.dev/ZerkyLiu/Shanxue-Education-Points-Coin-Converter/releases"; + private const string DownloadUrlTemplate = "https://gittea.dev/ZerkyLiu/Shanxue-Education-Points-Coin-Converter/releases/download/{0}/善学教育积分卡汇率计算器.exe"; private readonly Label _currencyLabel; private readonly Label _timeLabel; private readonly Label _timeSourceLabel; @@ -62,6 +66,8 @@ namespace 善学教育积分卡汇率计算器 private bool _usingUiAccessTopMost; private bool _suppressPointsTextChange; private readonly bool _forceFallbackTopMost; + private Form? _aboutDialog; + private Form? _floatingPrompt; private const string UiAccessRelaunchArg = "--uiaccess-relaunch"; private const string UiAccessResourceSuffix = ".dll.uiaccess.dll"; private const string IconResourceSuffix = ".ico.logo_256x256.ico"; @@ -691,16 +697,35 @@ namespace 善学教育积分卡汇率计算器 private void ShowAboutDialog() { + if (_aboutDialog != null && !_aboutDialog.IsDisposed) + { + _aboutDialog.Show(); + _aboutDialog.BringToFront(); + return; + } + var dialog = new Form { Text = "关于", - StartPosition = FormStartPosition.CenterParent, + StartPosition = FormStartPosition.Manual, FormBorderStyle = FormBorderStyle.FixedDialog, MaximizeBox = false, MinimizeBox = false, ShowInTaskbar = false, ClientSize = new Size(520, 420) }; + _aboutDialog = dialog; + dialog.FormClosed += (_, __) => _aboutDialog = null; + + var layout = new TableLayoutPanel + { + Dock = DockStyle.Fill, + ColumnCount = 1, + RowCount = 2 + }; + layout.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100F)); + layout.RowStyles.Add(new RowStyle(SizeType.Percent, 100F)); + layout.RowStyles.Add(new RowStyle(SizeType.AutoSize)); var textBox = new TextBox { @@ -720,7 +745,7 @@ namespace 善学教育积分卡汇率计算器 textBox.Text = string.Join(Environment.NewLine, new[] { "善学教育积分卡汇率计算器", - "版本号: 1.0.0", + $"版本号: {CurrentVersion}", "作者: ZerkyLiu", $"构建时间:{buildTime}", "运行环境:.NET 4.7", @@ -732,14 +757,454 @@ namespace 善学教育积分卡汇率计算器 "• 网络时间同步", "• 高精度阶梯换算", "• UIAccess 置顶支持", + "• 联网自动更新", "", "未来计划:", - "• 联网自动更新", "• 修复已知问题" }); - dialog.Controls.Add(textBox); - dialog.ShowDialog(this); + var updateButton = new Button + { + Text = "检查更新", + AutoSize = true, + Anchor = AnchorStyles.Right + }; + updateButton.Click += async (_, __) => await HandleUpdateCheckAsync(updateButton); + + var buttonPanel = new Panel + { + Dock = DockStyle.Fill, + Height = updateButton.Height + 12, + Padding = new Padding(0, 8, 0, 0) + }; + updateButton.Location = new Point(buttonPanel.Width - updateButton.Width, 0); + updateButton.Anchor = AnchorStyles.Top | AnchorStyles.Right; + buttonPanel.Controls.Add(updateButton); + buttonPanel.Resize += (_, __) => + { + updateButton.Location = new Point(buttonPanel.Width - updateButton.Width, 0); + }; + + layout.Controls.Add(textBox, 0, 0); + layout.Controls.Add(buttonPanel, 0, 1); + dialog.Controls.Add(layout); + CenterFormOnScreen(dialog, this); + dialog.Show(); + dialog.Activate(); + } + + private async Task HandleUpdateCheckAsync(Button button) + { + if (!button.Enabled) + { + return; + } + + var owner = button.FindForm() ?? this; + var originalText = button.Text; + button.Enabled = false; + button.Text = "检查更新中…"; + + try + { + var releaseInfo = await GetLatestReleaseInfoAsync(); + if (releaseInfo == null || string.IsNullOrEmpty(releaseInfo.Tag)) + { + await ShowNonModalMessageAsync(owner, "无法获取最新版本信息,请稍后重试。", "更新检查", false); + return; + } + + var latestVersion = NormalizeVersion(releaseInfo.Tag); + var currentVersion = NormalizeVersion(CurrentVersion); + if (CompareVersions(currentVersion, latestVersion) >= 0) + { + await ShowNonModalMessageAsync(owner, "已经是最新版本。", "更新检查", false); + return; + } + + var consent = await ShowNonModalConfirmAsync(owner, $"检测到新版本 {releaseInfo.Tag},是否下载?", "发现新版本"); + if (consent != DialogResult.Yes) + { + return; + } + + var downloadUrl = releaseInfo.DownloadUrl ?? string.Empty; + if (string.IsNullOrEmpty(downloadUrl)) + { + downloadUrl = string.Format(CultureInfo.InvariantCulture, DownloadUrlTemplate, Uri.EscapeDataString(releaseInfo.Tag)); + } + var targetPath = GetDownloadTargetPath(releaseInfo.Tag); + button.Text = "正在下载中…"; + var progressPercent = 0; + PaintEventHandler painter = (_, e) => + { + var rect = button.ClientRectangle; + var pad = 4; + var barH = 4; + var x = rect.X + pad; + var y = rect.Bottom - barH - pad; + var w = Math.Max(0, rect.Width - pad * 2); + using (var back = new SolidBrush(Color.FromArgb(220, 220, 220))) + { + e.Graphics.FillRectangle(back, x, y, w, barH); + } + var fillW = (int)Math.Round(w * progressPercent / 100.0); + using (var fill = new SolidBrush(Color.FromArgb(76, 175, 80))) + { + e.Graphics.FillRectangle(fill, x, y, fillW, barH); + } + }; + button.Paint += painter; + try + { + await DownloadFileAsync(downloadUrl, targetPath, new Progress(p => + { + progressPercent = Math.Max(0, Math.Min(100, p)); + button.Invalidate(); + })); + } + finally + { + button.Paint -= painter; + } + await ShowNonModalMessageAsync(owner, $"已下载到:{targetPath}", "下载完成", false); + Process.Start(new ProcessStartInfo + { + FileName = Path.GetDirectoryName(targetPath) ?? GetDownloadFolder(), + UseShellExecute = true + }); + } + catch (Exception ex) + { + await ShowNonModalMessageAsync(owner, $"更新失败:{ex.Message}", "更新失败", false); + } + finally + { + button.Text = originalText; + button.Enabled = true; + } + } + + private static void CenterFormOnScreen(Form dialog, Form reference) + { + var screen = Screen.FromControl(reference); + var area = screen.WorkingArea; + var x = area.X + (area.Width - dialog.Width) / 2; + var y = area.Y + (area.Height - dialog.Height) / 2; + dialog.Location = new Point(Math.Max(area.X, x), Math.Max(area.Y, y)); + } + + private async Task ShowNonModalMessageAsync(Form owner, string text, string title, bool yesNo) + { + var tcs = new TaskCompletionSource(); + var prompt = CreateFloatingPrompt(owner, title, text, yesNo, out var yesButton, out var noButton); + _floatingPrompt = prompt; + prompt.FormClosed += (_, __) => + { + _floatingPrompt = null; + tcs.TrySetResult(true); + }; + prompt.Show(); + CenterChildOnOwner(prompt, owner); + yesButton.Click += (_, __) => prompt.Close(); + if (yesNo) + { + noButton.Click += (_, __) => prompt.Close(); + } + await tcs.Task.ConfigureAwait(true); + } + + private async Task ShowNonModalConfirmAsync(Form owner, string text, string title) + { + var tcs = new TaskCompletionSource(); + var prompt = CreateFloatingPrompt(owner, title, text, true, out var yesButton, out var noButton); + _floatingPrompt = prompt; + prompt.FormClosed += (_, __) => + { + _floatingPrompt = null; + if (!tcs.Task.IsCompleted) + { + tcs.TrySetResult(DialogResult.No); + } + }; + prompt.Show(); + CenterChildOnOwner(prompt, owner); + yesButton.Click += (_, __) => + { + tcs.TrySetResult(DialogResult.Yes); + prompt.Close(); + }; + noButton.Click += (_, __) => + { + tcs.TrySetResult(DialogResult.No); + prompt.Close(); + }; + return await tcs.Task.ConfigureAwait(true); + } + + private static Form CreateFloatingPrompt(Form owner, string title, string text, bool yesNo, out Button yesButton, out Button noButton) + { + var f = new Form + { + Text = title, + FormBorderStyle = FormBorderStyle.FixedDialog, + StartPosition = FormStartPosition.Manual, + MaximizeBox = false, + MinimizeBox = false, + ShowInTaskbar = false, + ClientSize = new Size(380, 160) + }; + + var layout = new TableLayoutPanel + { + Dock = DockStyle.Fill, + ColumnCount = 1, + RowCount = 2 + }; + layout.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100F)); + layout.RowStyles.Add(new RowStyle(SizeType.Percent, 100F)); + layout.RowStyles.Add(new RowStyle(SizeType.AutoSize)); + + var label = new Label + { + Text = text, + Dock = DockStyle.Fill, + TextAlign = ContentAlignment.MiddleLeft, + AutoEllipsis = true + }; + + var panel = new FlowLayoutPanel + { + FlowDirection = FlowDirection.RightToLeft, + Dock = DockStyle.Fill, + Padding = new Padding(0, 8, 0, 0), + AutoSize = true + }; + + yesButton = new Button { Text = yesNo ? "是" : "确定", AutoSize = true }; + noButton = new Button { Text = "否", AutoSize = true }; + panel.Controls.Add(yesButton); + if (yesNo) + { + panel.Controls.Add(noButton); + } + + layout.Controls.Add(label, 0, 0); + layout.Controls.Add(panel, 0, 1); + f.Controls.Add(layout); + return f; + } + + private static void CenterChildOnOwner(Form child, Form owner) + { + var rect = owner.Bounds; + var x = rect.X + (rect.Width - child.Width) / 2; + var y = rect.Y + (rect.Height - child.Height) / 2; + child.Location = new Point(Math.Max(0, x), Math.Max(0, y)); + } + private static string GetDownloadFolder() + { + var userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + var path = string.IsNullOrWhiteSpace(userProfile) ? string.Empty : Path.Combine(userProfile, "Downloads"); + + if (string.IsNullOrWhiteSpace(path)) + { + path = Path.GetTempPath(); + } + + Directory.CreateDirectory(path); + return path; + } + + private static string GetDownloadTargetPath(string versionTag) + { + var folder = GetDownloadFolder(); + return Path.Combine(folder, $"善学教育积分卡汇率计算器_{versionTag}.exe"); + } + + private sealed class ReleaseInfo + { + public ReleaseInfo(string tag, string? downloadUrl) + { + Tag = tag; + DownloadUrl = downloadUrl; + } + + public string Tag { get; } + public string? DownloadUrl { get; } + } + + private async Task GetLatestReleaseInfoAsync() + { + ServicePointManager.SecurityProtocol |= SecurityProtocolType.Tls12 | SecurityProtocolType.Tls11 | SecurityProtocolType.Tls; + var result = await TryGetLatestReleaseInfoAsync(false); + if (result != null) + { + return result; + } + + return await TryGetLatestReleaseInfoAsync(true); + } + + private async Task TryGetLatestReleaseInfoAsync(bool useProxy) + { + try + { + using (var client = CreateWebClient(useProxy)) + { + var html = await client.DownloadStringTaskAsync(ReleasePageUrl); + var tag = ParseLatestReleaseTag(html) ?? string.Empty; + if (tag.Length == 0) + { + return null; + } + + var tagValue = tag; + var downloadUrl = ParseLatestDownloadUrl(html); + if (string.IsNullOrEmpty(downloadUrl)) + { + var tagUrl = BuildReleaseTagUrl(tagValue); + var tagHtml = await client.DownloadStringTaskAsync(tagUrl); + downloadUrl = ParseLatestDownloadUrl(tagHtml); + } + + return new ReleaseInfo(tagValue, downloadUrl); + } + } + catch + { + return null; + } + } + + private static string? ParseLatestReleaseTag(string html) + { + if (string.IsNullOrEmpty(html)) + { + return null; + } + + var match = Regex.Match(html, "/releases/tag/([^\"/<>]+)"); + if (!match.Success) + { + return null; + } + + return Uri.UnescapeDataString(match.Groups[1].Value); + } + + private static string? ParseLatestDownloadUrl(string html) + { + if (string.IsNullOrEmpty(html)) + { + return null; + } + + var hrefMatches = Regex.Matches(html, "href=\"([^\"]+/releases/download/[^\"]+)\"", RegexOptions.IgnoreCase); + foreach (Match match in hrefMatches) + { + var link = WebUtility.HtmlDecode(match.Groups[1].Value); + if (link.IndexOf(".exe", StringComparison.OrdinalIgnoreCase) < 0) + { + continue; + } + + return NormalizeDownloadUrl(link); + } + + var rawMatch = Regex.Match(html, "/releases/download/[^\"'<>\\s]+\\.exe", RegexOptions.IgnoreCase); + if (rawMatch.Success) + { + return NormalizeDownloadUrl(rawMatch.Value); + } + + return null; + } + + private static string BuildReleaseTagUrl(string tag) + { + var escaped = Uri.EscapeDataString(tag); + return $"{ReleasePageUrl}/tag/{escaped}"; + } + + private static string NormalizeDownloadUrl(string link) + { + var cleaned = Uri.UnescapeDataString(link); + if (cleaned.StartsWith("http", StringComparison.OrdinalIgnoreCase)) + { + return cleaned; + } + + var baseUri = new Uri(ReleasePageUrl); + var resolved = new Uri(baseUri, cleaned); + return resolved.ToString(); + } + + private static string NormalizeVersion(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return string.Empty; + } + + return value.Trim().TrimStart('v', 'V'); + } + + private static int CompareVersions(string left, string right) + { + if (Version.TryParse(left, out var leftVersion) && Version.TryParse(right, out var rightVersion)) + { + return leftVersion.CompareTo(rightVersion); + } + + return string.CompareOrdinal(left, right); + } + + private static async Task DownloadFileAsync(string url, string targetPath, IProgress? progress = null) + { + using (var client = CreateWebClient(true)) + { + var tcs = new TaskCompletionSource(); + Exception? error = null; + client.DownloadProgressChanged += (_, e) => + { + try + { + progress?.Report(e.ProgressPercentage); + } + catch + { + } + }; + client.DownloadFileCompleted += (_, e) => + { + error = e.Error; + tcs.TrySetResult(true); + }; + client.DownloadFileAsync(new Uri(url), targetPath); + await tcs.Task.ConfigureAwait(true); + if (error != null) + { + throw error; + } + } + } + + private static WebClient CreateWebClient(bool useProxy) + { + var client = new WebClient(); + if (useProxy) + { + var proxy = WebRequest.DefaultWebProxy; + if (proxy != null) + { + proxy.Credentials = CredentialCache.DefaultCredentials; + client.Proxy = proxy; + } + } + + client.Headers[HttpRequestHeader.UserAgent] = "Shanxue-Updater"; + return client; } private bool TryRelaunchWithUiAccess() @@ -966,8 +1431,29 @@ namespace 善学教育积分卡汇率计算器 return; } - TopMost = true; - SetWindowPos(Handle, HWND_TOPMOST, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE); + var target = GetActiveTopMostTarget(); + if (target == null || !target.IsHandleCreated) + { + return; + } + + SetWindowPos(target.Handle, HWND_TOPMOST, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE); + } + + private Form? GetActiveTopMostTarget() + { + var active = Form.ActiveForm; + if (active == null || active.IsDisposed || !active.Visible) + { + return this; + } + + if (active == this || active == _aboutDialog || active == _floatingPrompt) + { + return active; + } + + return this; } [UnmanagedFunctionPointer(CallingConvention.Winapi)]