From f61e298871788f212ee7afd3ad1640c2f97a896d Mon Sep 17 00:00:00 2001 From: biss Date: Sun, 19 Jul 2026 08:51:41 +0800 Subject: [PATCH] Migrate desktop app to Avalonia and publish cross-platform releases --- .gitea/workflows/release.yml | 61 ++++++--- App.axaml | 8 ++ App.axaml.cs | 24 ++++ Program.cs | 29 +++-- README.md | 55 ++++++++ Services/DataCheck.cs | 10 +- Services/InputParser.cs | 150 ++++++++++++++++++++++ Services/LogService.cs | 2 +- Services/PlatformLauncher.cs | 31 +++++ Views/DataInputWindow.axaml | 108 ++++++++++++++++ Views/DataInputWindow.axaml.cs | 182 +++++++++++++++++++++++++++ Views/DialogWindow.cs | 73 +++++++++++ Views/MainWindow.axaml | 70 +++++++++++ Views/MainWindow.axaml.cs | 221 +++++++++++++++++++++++++++++++++ Views/ResultWindow.axaml | 24 ++++ Views/ResultWindow.axaml.cs | 23 ++++ kcsj.csproj | 20 ++- 17 files changed, 1051 insertions(+), 40 deletions(-) create mode 100644 App.axaml create mode 100644 App.axaml.cs create mode 100644 README.md create mode 100644 Services/InputParser.cs create mode 100644 Services/PlatformLauncher.cs create mode 100644 Views/DataInputWindow.axaml create mode 100644 Views/DataInputWindow.axaml.cs create mode 100644 Views/DialogWindow.cs create mode 100644 Views/MainWindow.axaml create mode 100644 Views/MainWindow.axaml.cs create mode 100644 Views/ResultWindow.axaml create mode 100644 Views/ResultWindow.axaml.cs diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 56d79d9..f3ceaab 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -11,7 +11,7 @@ permissions: jobs: release: - name: Publish Windows x64 + name: Publish Windows, Linux and macOS runs-on: ubuntu-latest steps: @@ -23,7 +23,8 @@ jobs: with: dotnet-version: "8.0.x" - - name: Build Windows executable + - name: Read release metadata + id: metadata shell: bash env: TAG_NAME: ${{ gitea.ref_name }} @@ -36,20 +37,51 @@ jobs: exit 1 fi - dotnet publish ./kcsj.csproj \ - --configuration Release \ - --runtime win-x64 \ - --self-contained true \ - --output ./publish \ - -p:EnableWindowsTargeting=true \ - -p:PublishSingleFile=true \ - -p:EnableCompressionInSingleFile=true \ - -p:IncludeNativeLibrariesForSelfExtract=true \ - -p:DebugType=None \ - -p:Version="${version}" + prerelease=false + if [[ "${version}" == *-* ]]; then + prerelease=true + fi + echo "version=${version}" >> "${GITEA_OUTPUT}" + echo "prerelease=${prerelease}" >> "${GITEA_OUTPUT}" + + - name: Publish all desktop platforms + shell: bash + env: + TAG_NAME: ${{ gitea.ref_name }} + VERSION: ${{ steps.metadata.outputs.version }} + run: | + set -euo pipefail + + runtimes=(win-x64 linux-x64 osx-x64) mkdir -p ./dist - mv ./publish/kcsj.exe "./dist/kcsj-${TAG_NAME}-win-x64.exe" + + for runtime in "${runtimes[@]}"; do + publish_dir="./publish/${runtime}" + + dotnet publish ./kcsj.csproj \ + --configuration Release \ + --runtime "${runtime}" \ + --self-contained true \ + --output "${publish_dir}" \ + -p:PublishSingleFile=true \ + -p:EnableCompressionInSingleFile=true \ + -p:IncludeNativeLibrariesForSelfExtract=true \ + -p:DebugType=None \ + -p:Version="${VERSION}" + + if [[ "${runtime}" == "win-x64" ]]; then + cp "${publish_dir}/kcsj.exe" "./dist/kcsj-${TAG_NAME}-${runtime}.exe" + else + tar -czf "./dist/kcsj-${TAG_NAME}-${runtime}.tar.gz" \ + -C "${publish_dir}" . + fi + done + + ( + cd ./dist + sha256sum kcsj-* > SHA256SUMS.txt + ) # Gitea's release action is written in Go and is compiled by the runner. - name: Set up Go for the release action @@ -61,6 +93,7 @@ jobs: uses: https://gitea.com/actions/release-action@main with: title: ${{ gitea.ref_name }} + pre_release: ${{ steps.metadata.outputs.prerelease }} files: |- dist/* api_key: ${{ secrets.GITEA_TOKEN }} diff --git a/App.axaml b/App.axaml new file mode 100644 index 0000000..80182e2 --- /dev/null +++ b/App.axaml @@ -0,0 +1,8 @@ + + + + + diff --git a/App.axaml.cs b/App.axaml.cs new file mode 100644 index 0000000..8428bf3 --- /dev/null +++ b/App.axaml.cs @@ -0,0 +1,24 @@ +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Markup.Xaml; +using kcsj.Views; + +namespace kcsj; + +public partial class App : Application +{ + public override void Initialize() + { + AvaloniaXamlLoader.Load(this); + } + + public override void OnFrameworkInitializationCompleted() + { + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + desktop.MainWindow = new MainWindow(); + } + + base.OnFrameworkInitializationCompleted(); + } +} diff --git a/Program.cs b/Program.cs index 063dbe4..5854f87 100644 --- a/Program.cs +++ b/Program.cs @@ -1,19 +1,18 @@ -using kcsj.Forms; +using Avalonia; -namespace kcsj +namespace kcsj; + +internal static class Program { - internal static class Program + [STAThread] + public static void Main(string[] args) { - /// - /// The main entry point for the application. - /// - [STAThread] - static void Main() - { - // To customize application configuration such as set high DPI settings or default font, - // see https://aka.ms/applicationconfiguration. - ApplicationConfiguration.Initialize(); - Application.Run(new Forms.MainForm()); - } + BuildAvaloniaApp().StartWithClassicDesktopLifetime(args); } -} \ No newline at end of file + + public static AppBuilder BuildAvaloniaApp() => + AppBuilder.Configure() + .UsePlatformDetect() + .WithInterFont() + .LogToTrace(); +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..568af67 --- /dev/null +++ b/README.md @@ -0,0 +1,55 @@ +# 水准网平差程序 + +基于 .NET 8 与 Avalonia 12 的跨平台桌面应用,可在 Windows、macOS 和 Linux 上运行。 + +## 运行 + +```powershell +dotnet run --project kcsj.csproj +``` + +## 发布 + +以下命令生成依赖目标计算机 .NET 8 运行时的发布包: + +```powershell +dotnet publish kcsj.csproj -c Release -r win-x64 --self-contained false +dotnet publish kcsj.csproj -c Release -r linux-x64 --self-contained false +dotnet publish kcsj.csproj -c Release -r osx-x64 --self-contained false +``` + +如需不依赖目标计算机预装 .NET,将 `--self-contained false` 改为 `--self-contained true`。 + +## 自动发布 + +推送以 `v` 开头的版本标签会触发 Gitea Actions,自动生成 Windows、Linux 和 macOS 的 x64 自包含发布包及 SHA-256 校验文件。 + +正式版本使用不带预发布后缀的标签: + +```powershell +git tag v1.2.3 +git push origin v1.2.3 +``` + +包含 `-` 后缀的 SemVer 标签会自动创建 Pre-release: + +```powershell +git tag v1.3.0-rc.1 +git push origin v1.3.0-rc.1 +``` + +## 输入格式 + +已知点文件每行包含点名和高程: + +```text +BM1 100.0000 +``` + +观测文件每行包含起点、终点、高差和距离: + +```text +BM1 P1 1.2345 2.5 +``` + +字段可以使用空格、Tab、英文逗号或中文逗号分隔。空行以及以 `#` 或 `//` 开头的行会被忽略。 diff --git a/Services/DataCheck.cs b/Services/DataCheck.cs index 3c920e2..4f9bd22 100644 --- a/Services/DataCheck.cs +++ b/Services/DataCheck.cs @@ -71,10 +71,10 @@ namespace kcsj.Services return result; } - CheckKnownPoints(knownPoints, result); - CheckObservations(observations, result); - CheckNetwork(knownPoints, observations, result); - CheckRedundancy(knownPoints, observations, result); + CheckKnownPoints(knownPoints!, result); + CheckObservations(observations!, result); + CheckNetwork(knownPoints!, observations!, result); + CheckRedundancy(knownPoints!, observations!, result); return result; } @@ -293,4 +293,4 @@ namespace kcsj.Services } } } -} \ No newline at end of file +} diff --git a/Services/InputParser.cs b/Services/InputParser.cs new file mode 100644 index 0000000..19b5e8b --- /dev/null +++ b/Services/InputParser.cs @@ -0,0 +1,150 @@ +using System.Globalization; +using System.Text; +using kcsj.Models; + +namespace kcsj.Services; + +public static class InputParser +{ + private static readonly char[] Separators = [' ', '\t', ',', ',']; + + public static List ReadKnownPointsFile(string filePath) => + ParseKnownPoints(File.ReadLines(filePath, Encoding.UTF8), Path.GetFileName(filePath)); + + public static List ReadObservationsFile(string filePath) => + ParseObservations(File.ReadLines(filePath, Encoding.UTF8), Path.GetFileName(filePath)); + + public static List ParseKnownPointsText(string text) => + ParseKnownPoints(SplitText(text), "手动输入"); + + public static List ParseObservationsText(string text) => + ParseObservations(SplitText(text), "手动输入"); + + public static string BuildKnownPointsPreview(IEnumerable points) + { + StringBuilder text = new("点名\t高程(m)\n"); + foreach (KnownPoint point in points) + { + text.AppendLine($"{point.Name}\t{point.Elevation:F4}"); + } + + return text.ToString(); + } + + public static string BuildObservationsPreview(IEnumerable observations) + { + StringBuilder text = new("起点\t终点\t高差(m)\t距离(km)\n"); + foreach (Observation observation in observations) + { + text.AppendLine( + $"{observation.FromPoint}\t{observation.ToPoint}\t" + + $"{observation.HeightDiff:F6}\t{observation.Distance:F4}"); + } + + return text.ToString(); + } + + private static List ParseKnownPoints(IEnumerable lines, string sourceName) + { + List result = []; + HashSet names = new(StringComparer.OrdinalIgnoreCase); + int lineNumber = 0; + + foreach (string rawLine in lines) + { + lineNumber++; + string line = rawLine.Trim(); + if (ShouldSkip(line)) + { + continue; + } + + string[] parts = SplitLine(line); + if (parts.Length != 2) + { + throw new FormatException($"{sourceName} 第 {lineNumber} 行格式错误,应为:点名 高程"); + } + + string name = parts[0].Trim(); + if (!names.Add(name)) + { + throw new FormatException($"{sourceName} 第 {lineNumber} 行点名重复:{name}"); + } + + if (!TryParseNumber(parts[1], out double elevation)) + { + throw new FormatException($"{sourceName} 第 {lineNumber} 行高程不是有效数字"); + } + + result.Add(new KnownPoint(name, elevation)); + } + + if (result.Count == 0) + { + throw new FormatException($"{sourceName} 中没有有效的已知点数据"); + } + + return result; + } + + private static List ParseObservations(IEnumerable lines, string sourceName) + { + List result = []; + int lineNumber = 0; + + foreach (string rawLine in lines) + { + lineNumber++; + string line = rawLine.Trim(); + if (ShouldSkip(line)) + { + continue; + } + + string[] parts = SplitLine(line); + if (parts.Length != 4) + { + throw new FormatException($"{sourceName} 第 {lineNumber} 行格式错误,应为:起点 终点 高差 距离"); + } + + string from = parts[0].Trim(); + string to = parts[1].Trim(); + if (string.Equals(from, to, StringComparison.OrdinalIgnoreCase)) + { + throw new FormatException($"{sourceName} 第 {lineNumber} 行起点和终点不能相同"); + } + + if (!TryParseNumber(parts[2], out double heightDiff)) + { + throw new FormatException($"{sourceName} 第 {lineNumber} 行高差不是有效数字"); + } + + if (!TryParseNumber(parts[3], out double distance) || distance <= 0) + { + throw new FormatException($"{sourceName} 第 {lineNumber} 行距离必须是大于 0 的数字"); + } + + result.Add(new Observation(from, to, heightDiff, distance)); + } + + if (result.Count == 0) + { + throw new FormatException($"{sourceName} 中没有有效的观测数据"); + } + + return result; + } + + private static string[] SplitText(string text) => + text.Replace("\r\n", "\n", StringComparison.Ordinal).Replace('\r', '\n').Split('\n'); + + private static string[] SplitLine(string line) => + line.Split(Separators, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + private static bool ShouldSkip(string line) => + string.IsNullOrWhiteSpace(line) || line.StartsWith('#') || line.StartsWith("//", StringComparison.Ordinal); + + private static bool TryParseNumber(string text, out double value) => + double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out value) || + double.TryParse(text, NumberStyles.Float, CultureInfo.CurrentCulture, out value); +} diff --git a/Services/LogService.cs b/Services/LogService.cs index cb38d88..f964722 100644 --- a/Services/LogService.cs +++ b/Services/LogService.cs @@ -8,7 +8,7 @@ namespace kcsj.Services { public static class LogService { - public static event Action OnLog; + public static event Action? OnLog; public static void AddLog(string message) { string time = DateTime.Now.ToString("HH:mm:ss"); diff --git a/Services/PlatformLauncher.cs b/Services/PlatformLauncher.cs new file mode 100644 index 0000000..7db34bd --- /dev/null +++ b/Services/PlatformLauncher.cs @@ -0,0 +1,31 @@ +using System.Diagnostics; + +namespace kcsj.Services; + +public static class PlatformLauncher +{ + public static void OpenFolder(string folderPath) + { + ProcessStartInfo startInfo; + + if (OperatingSystem.IsWindows()) + { + startInfo = new ProcessStartInfo + { + FileName = folderPath, + UseShellExecute = true + }; + } + else + { + startInfo = new ProcessStartInfo + { + FileName = OperatingSystem.IsMacOS() ? "open" : "xdg-open", + UseShellExecute = false + }; + startInfo.ArgumentList.Add(folderPath); + } + + Process.Start(startInfo); + } +} diff --git a/Views/DataInputWindow.axaml b/Views/DataInputWindow.axaml new file mode 100644 index 0000000..e5e9095 --- /dev/null +++ b/Views/DataInputWindow.axaml @@ -0,0 +1,108 @@ + + + + +