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..d706939
--- /dev/null
+++ b/Views/DataInputWindow.axaml
@@ -0,0 +1,108 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Views/DataInputWindow.axaml.cs b/Views/DataInputWindow.axaml.cs
new file mode 100644
index 0000000..2fa1c1b
--- /dev/null
+++ b/Views/DataInputWindow.axaml.cs
@@ -0,0 +1,182 @@
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+using Avalonia.Markup.Xaml;
+using Avalonia.Platform.Storage;
+using kcsj.Models;
+using kcsj.Services;
+
+namespace kcsj.Views;
+
+public partial class DataInputWindow : Window
+{
+ private static readonly FilePickerFileType TextFileType = new("文本文件")
+ {
+ Patterns = ["*.txt"],
+ MimeTypes = ["text/plain"]
+ };
+
+ private TabControl TabsControl => this.FindControl("DataTabs")!;
+ private TextBlock StatusControl => this.FindControl("InputStatusText")!;
+ private TextBox KnownPathControl => this.FindControl("KnownPathTextBox")!;
+ private TextBox ObservationPathControl => this.FindControl("ObservationPathTextBox")!;
+ private TextBox KnownPreviewControl => this.FindControl("KnownPreviewTextBox")!;
+ private TextBox ObservationPreviewControl => this.FindControl("ObservationPreviewTextBox")!;
+ private TextBox ManualKnownControl => this.FindControl("ManualKnownTextBox")!;
+ private TextBox ManualObservationControl => this.FindControl("ManualObservationTextBox")!;
+
+ private List _fileKnownPoints = [];
+ private List _fileObservations = [];
+
+ public DataInputWindow()
+ : this(0)
+ {
+ }
+
+ public DataInputWindow(int selectedTab)
+ {
+ AvaloniaXamlLoader.Load(this);
+ TabsControl.SelectedIndex = Math.Clamp(selectedTab, 0, 1);
+ }
+
+ private async void BrowseKnownFile_Click(object? sender, RoutedEventArgs e)
+ {
+ string? path = await PickTextFileAsync("选择已知点高程文件");
+ if (path is not null)
+ {
+ KnownPathControl.Text = path;
+ _fileKnownPoints = [];
+ }
+ }
+
+ private async void BrowseObservationFile_Click(object? sender, RoutedEventArgs e)
+ {
+ string? path = await PickTextFileAsync("选择观测数据文件");
+ if (path is not null)
+ {
+ ObservationPathControl.Text = path;
+ _fileObservations = [];
+ }
+ }
+
+ private async Task PickTextFileAsync(string title)
+ {
+ IReadOnlyList files = await StorageProvider.OpenFilePickerAsync(
+ new FilePickerOpenOptions
+ {
+ Title = title,
+ AllowMultiple = false,
+ FileTypeFilter = [TextFileType, FilePickerFileTypes.All]
+ });
+
+ return files.FirstOrDefault()?.Path.LocalPath;
+ }
+
+ private async void PreviewFiles_Click(object? sender, RoutedEventArgs e)
+ {
+ try
+ {
+ LoadFileData();
+ ShowFilePreview();
+ StatusControl.Text = $"预览完成:{_fileKnownPoints.Count} 个已知点,{_fileObservations.Count} 条观测。";
+ LogService.AddLog("文件数据预览成功。");
+ }
+ catch (Exception exception)
+ {
+ ClearFilePreview();
+ LogService.AddLog("数据预览失败:" + exception.Message);
+ await DialogWindow.ShowMessageAsync(this, "数据预览失败", exception.Message);
+ }
+ }
+
+ private async void ImportFiles_Click(object? sender, RoutedEventArgs e)
+ {
+ try
+ {
+ LoadFileData();
+ DataStore.SetData(_fileKnownPoints, _fileObservations, "文件导入");
+ LogService.AddLog("文件数据导入成功。");
+ Close(true);
+ }
+ catch (Exception exception)
+ {
+ LogService.AddLog("导入失败:" + exception.Message);
+ await DialogWindow.ShowMessageAsync(this, "导入失败", exception.Message);
+ }
+ }
+
+ private async void PreviewManual_Click(object? sender, RoutedEventArgs e)
+ {
+ try
+ {
+ (List knownPoints, List observations) = ParseManualData();
+ StatusControl.Text = $"手动输入检查通过:{knownPoints.Count} 个已知点,{observations.Count} 条观测。";
+ await DialogWindow.ShowMessageAsync(
+ this,
+ "手动输入预览",
+ InputParser.BuildKnownPointsPreview(knownPoints) + "\n" +
+ InputParser.BuildObservationsPreview(observations));
+ }
+ catch (Exception exception)
+ {
+ LogService.AddLog("手动输入检查失败:" + exception.Message);
+ await DialogWindow.ShowMessageAsync(this, "输入错误", exception.Message);
+ }
+ }
+
+ private async void SaveManual_Click(object? sender, RoutedEventArgs e)
+ {
+ try
+ {
+ (List knownPoints, List observations) = ParseManualData();
+ DataStore.SetData(knownPoints, observations, "手动输入");
+ LogService.AddLog("手动输入数据已保存。");
+ Close(true);
+ }
+ catch (Exception exception)
+ {
+ LogService.AddLog("手动输入数据时发生错误:" + exception.Message);
+ await DialogWindow.ShowMessageAsync(this, "输入错误", exception.Message);
+ }
+ }
+
+ private void LoadFileData()
+ {
+ string knownPath = KnownPathControl.Text?.Trim() ?? string.Empty;
+ string observationPath = ObservationPathControl.Text?.Trim() ?? string.Empty;
+
+ if (string.IsNullOrWhiteSpace(knownPath))
+ {
+ throw new InvalidOperationException("请先选择已知点高程文件。");
+ }
+
+ if (string.IsNullOrWhiteSpace(observationPath))
+ {
+ throw new InvalidOperationException("请先选择观测数据文件。");
+ }
+
+ _fileKnownPoints = InputParser.ReadKnownPointsFile(knownPath);
+ _fileObservations = InputParser.ReadObservationsFile(observationPath);
+ }
+
+ private void ShowFilePreview()
+ {
+ KnownPreviewControl.Text = InputParser.BuildKnownPointsPreview(_fileKnownPoints);
+ ObservationPreviewControl.Text = InputParser.BuildObservationsPreview(_fileObservations);
+ }
+
+ private void ClearFilePreview()
+ {
+ _fileKnownPoints = [];
+ _fileObservations = [];
+ KnownPreviewControl.Text = string.Empty;
+ ObservationPreviewControl.Text = string.Empty;
+ }
+
+ private (List KnownPoints, List Observations) ParseManualData() =>
+ (
+ InputParser.ParseKnownPointsText(ManualKnownControl.Text ?? string.Empty),
+ InputParser.ParseObservationsText(ManualObservationControl.Text ?? string.Empty)
+ );
+
+ private void Cancel_Click(object? sender, RoutedEventArgs e) => Close(false);
+}
diff --git a/Views/DialogWindow.cs b/Views/DialogWindow.cs
new file mode 100644
index 0000000..61d8cd0
--- /dev/null
+++ b/Views/DialogWindow.cs
@@ -0,0 +1,73 @@
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Layout;
+using Avalonia.Media;
+
+namespace kcsj.Views;
+
+public sealed class DialogWindow : Window
+{
+ private DialogWindow(string title, string message, bool showCancel)
+ {
+ Title = title;
+ CanResize = false;
+ SizeToContent = SizeToContent.WidthAndHeight;
+ WindowStartupLocation = WindowStartupLocation.CenterOwner;
+ MinWidth = 360;
+ MaxWidth = 560;
+
+ TextBlock messageText = new()
+ {
+ Text = message,
+ TextWrapping = TextWrapping.Wrap,
+ MaxWidth = 500,
+ FontSize = 15
+ };
+
+ Button confirmButton = new()
+ {
+ Content = "确定",
+ MinWidth = 88,
+ HorizontalContentAlignment = HorizontalAlignment.Center
+ };
+ confirmButton.Click += (_, _) => Close(true);
+
+ StackPanel buttons = new()
+ {
+ Orientation = Orientation.Horizontal,
+ HorizontalAlignment = HorizontalAlignment.Right,
+ Spacing = 10
+ };
+
+ if (showCancel)
+ {
+ Button cancelButton = new()
+ {
+ Content = "取消",
+ MinWidth = 88,
+ HorizontalContentAlignment = HorizontalAlignment.Center
+ };
+ cancelButton.Click += (_, _) => Close(false);
+ buttons.Children.Add(cancelButton);
+ }
+
+ buttons.Children.Add(confirmButton);
+
+ StackPanel content = new() { Spacing = 24 };
+ content.Children.Add(messageText);
+ content.Children.Add(buttons);
+
+ Content = new Border
+ {
+ Padding = new Thickness(24),
+ Background = Brushes.Transparent,
+ Child = content
+ };
+ }
+
+ public static Task ShowMessageAsync(Window owner, string title, string message) =>
+ new DialogWindow(title, message, false).ShowDialog(owner);
+
+ public static Task ConfirmAsync(Window owner, string title, string message) =>
+ new DialogWindow(title, message, true).ShowDialog(owner);
+}
diff --git a/Views/MainWindow.axaml b/Views/MainWindow.axaml
new file mode 100644
index 0000000..225223c
--- /dev/null
+++ b/Views/MainWindow.axaml
@@ -0,0 +1,70 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Views/MainWindow.axaml.cs b/Views/MainWindow.axaml.cs
new file mode 100644
index 0000000..25b6b1e
--- /dev/null
+++ b/Views/MainWindow.axaml.cs
@@ -0,0 +1,221 @@
+using System.Text;
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+using Avalonia.Markup.Xaml;
+using Avalonia.Platform.Storage;
+using Avalonia.Threading;
+using kcsj.Models;
+using kcsj.Services;
+
+namespace kcsj.Views;
+
+public partial class MainWindow : Window
+{
+ private TextBox LogControl => this.FindControl("LogTextBox")!;
+ private TextBlock DataStatusControl => this.FindControl("DataStatusText")!;
+ private LeastSquaresResult? _lastResult;
+
+ public MainWindow()
+ {
+ AvaloniaXamlLoader.Load(this);
+ LogService.OnLog += ShowLog;
+ Closed += (_, _) => LogService.OnLog -= ShowLog;
+ LogService.AddLog("程序启动成功。当前界面框架:Avalonia。");
+ UpdateDataStatus();
+ }
+
+ private async void OpenDataInput_Click(object? sender, RoutedEventArgs e) =>
+ await OpenDataInputAsync(0);
+
+ private async void OpenFileInput_Click(object? sender, RoutedEventArgs e) =>
+ await OpenDataInputAsync(0);
+
+ private async void OpenManualInput_Click(object? sender, RoutedEventArgs e) =>
+ await OpenDataInputAsync(1);
+
+ private async Task OpenDataInputAsync(int selectedTab)
+ {
+ DataInputWindow window = new(selectedTab);
+ bool saved = await window.ShowDialog(this);
+ if (saved)
+ {
+ _lastResult = null;
+ UpdateDataStatus();
+ }
+ }
+
+ private async void CheckData_Click(object? sender, RoutedEventArgs e)
+ {
+ DataCheckResult result = DataCheck.Check(DataStore.KnownPoints, DataStore.Observations);
+ LogService.AddLog(result.ToLogText());
+
+ string message = result.IsValid
+ ? "数据检查通过,可以进行平差计算。"
+ : "数据检查未通过,请查看日志信息。";
+ await DialogWindow.ShowMessageAsync(this, "数据检查", message);
+ }
+
+ private async void RunAdjustment_Click(object? sender, RoutedEventArgs e) =>
+ await RunAdjustmentAsync();
+
+ private async Task RunAdjustmentAsync()
+ {
+ try
+ {
+ if (!DataStore.HasData)
+ {
+ await DialogWindow.ShowMessageAsync(this, "开始平差", "请先导入或输入已知点和观测数据。");
+ return false;
+ }
+
+ _lastResult = LeastSquaresAdjustmentService.Adjust(
+ DataStore.KnownPoints,
+ DataStore.Observations);
+
+ foreach ((string pointName, double elevation) in _lastResult.UnknownElevations)
+ {
+ double error = _lastResult.UnknownElevationErrors[pointName];
+ LogService.AddLog(
+ $"{pointName} 平差高程 = {elevation:F4} m,中误差 = ±{FormatValue(error, 4)} m");
+ }
+
+ foreach (ObservationAdjustmentResult item in _lastResult.ObservationResults)
+ {
+ LogService.AddLog(
+ $"第{item.Index}段 {item.FromPoint}->{item.ToPoint}:" +
+ $"v = {item.Residual:F6},平差后高差 = {item.AdjustedHeightDiff:F6}," +
+ $"中误差 = ±{FormatValue(item.AdjustedHeightDiffError, 6)} m");
+ }
+
+ await DialogWindow.ShowMessageAsync(this, "开始平差", "间接平差计算完成。");
+ return true;
+ }
+ catch (Exception exception)
+ {
+ LogService.AddLog("平差失败:" + exception.Message);
+ await DialogWindow.ShowMessageAsync(this, "平差失败", exception.Message);
+ return false;
+ }
+ }
+
+ private async void ShowResult_Click(object? sender, RoutedEventArgs e)
+ {
+ LeastSquaresResult? result = await GetResultOrRunAdjustmentAsync();
+ if (result is not null)
+ {
+ await new ResultWindow(result).ShowDialog(this);
+ }
+ }
+
+ private async Task GetResultOrRunAdjustmentAsync()
+ {
+ if (_lastResult is not null)
+ {
+ return _lastResult;
+ }
+
+ bool shouldRun = await DialogWindow.ConfirmAsync(
+ this,
+ "平差结果",
+ "当前还没有平差结果,是否立即进行平差计算?");
+
+ return shouldRun && await RunAdjustmentAsync() ? _lastResult : null;
+ }
+
+ private async void ExportReport_Click(object? sender, RoutedEventArgs e)
+ {
+ LeastSquaresResult? result = await GetResultOrRunAdjustmentAsync();
+ if (result is null)
+ {
+ return;
+ }
+
+ IReadOnlyList folders = await StorageProvider.OpenFolderPickerAsync(
+ new FolderPickerOpenOptions
+ {
+ Title = "选择成果文件和报告文件的输出目录",
+ AllowMultiple = false
+ });
+
+ string? outputFolder = folders.FirstOrDefault()?.Path.LocalPath;
+ if (string.IsNullOrWhiteSpace(outputFolder))
+ {
+ return;
+ }
+
+ try
+ {
+ string heightPath = Path.Combine(outputFolder, "height.txt");
+ string surveyPath = Path.Combine(outputFolder, "survey.txt");
+ string reportPath = Path.Combine(outputFolder, "report.txt");
+
+ await File.WriteAllTextAsync(heightPath, Report.BuildHeightText(result), Encoding.UTF8);
+ await File.WriteAllTextAsync(surveyPath, Report.BuildSurveyText(result), Encoding.UTF8);
+ await File.WriteAllTextAsync(
+ reportPath,
+ Report.BuildReportText(DataStore.KnownPoints, DataStore.Observations, result),
+ Encoding.UTF8);
+
+ LogService.AddLog($"高程成果文件已输出:{heightPath}");
+ LogService.AddLog($"高差成果文件已输出:{surveyPath}");
+ LogService.AddLog($"平差报告已输出:{reportPath}");
+
+ bool shouldOpen = await DialogWindow.ConfirmAsync(
+ this,
+ "输出报告",
+ "输出完成,是否打开输出目录?");
+ if (shouldOpen)
+ {
+ PlatformLauncher.OpenFolder(outputFolder);
+ }
+ }
+ catch (Exception exception)
+ {
+ LogService.AddLog("输出报告失败:" + exception.Message);
+ await DialogWindow.ShowMessageAsync(this, "输出报告失败", exception.Message);
+ }
+ }
+
+ private async void ClearData_Click(object? sender, RoutedEventArgs e)
+ {
+ bool confirmed = await DialogWindow.ConfirmAsync(this, "二次确认", "确定要清空当前数据吗?");
+ if (!confirmed)
+ {
+ LogService.AddLog("用户取消清空数据。");
+ return;
+ }
+
+ DataStore.Clear();
+ _lastResult = null;
+ UpdateDataStatus();
+ }
+
+ private async void About_Click(object? sender, RoutedEventArgs e) =>
+ await DialogWindow.ShowMessageAsync(
+ this,
+ "关于",
+ "水准测量间接平差程序\n使用 Avalonia 构建,支持 Windows、macOS 和 Linux。\n运行时:.NET 8");
+
+ private void ClearLog_Click(object? sender, RoutedEventArgs e) => LogControl.Text = string.Empty;
+
+ private void ShowLog(string message)
+ {
+ Dispatcher.UIThread.Post(() =>
+ {
+ LogControl.Text = string.IsNullOrEmpty(LogControl.Text)
+ ? message
+ : LogControl.Text + Environment.NewLine + message;
+ LogControl.CaretIndex = LogControl.Text?.Length ?? 0;
+ });
+ }
+
+ private void UpdateDataStatus()
+ {
+ DataStatusControl.Text = DataStore.HasData
+ ? $"数据来源:{DataStore.DataSource} 已知点:{DataStore.KnownPoints.Count} 观测:{DataStore.Observations.Count}"
+ : "当前未加载数据";
+ }
+
+ private static string FormatValue(double value, int digits) =>
+ double.IsFinite(value) ? value.ToString($"F{digits}") : "无法计算";
+}
diff --git a/Views/ResultWindow.axaml b/Views/ResultWindow.axaml
new file mode 100644
index 0000000..f6d30f4
--- /dev/null
+++ b/Views/ResultWindow.axaml
@@ -0,0 +1,24 @@
+
+
+
+
+
+
diff --git a/Views/ResultWindow.axaml.cs b/Views/ResultWindow.axaml.cs
new file mode 100644
index 0000000..40b942e
--- /dev/null
+++ b/Views/ResultWindow.axaml.cs
@@ -0,0 +1,23 @@
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+using Avalonia.Markup.Xaml;
+using kcsj.Models;
+using kcsj.Services;
+
+namespace kcsj.Views;
+
+public partial class ResultWindow : Window
+{
+ public ResultWindow()
+ {
+ AvaloniaXamlLoader.Load(this);
+ }
+
+ public ResultWindow(LeastSquaresResult result)
+ : this()
+ {
+ this.FindControl("ResultTextBox")!.Text = Report.BuildResultText(result);
+ }
+
+ private void Close_Click(object? sender, RoutedEventArgs e) => Close();
+}
diff --git a/kcsj.csproj b/kcsj.csproj
index 663fdb8..700efc9 100644
--- a/kcsj.csproj
+++ b/kcsj.csproj
@@ -1,11 +1,21 @@
-
-
+
WinExe
- net8.0-windows
+ net8.0
enable
- true
enable
+ true
-
\ No newline at end of file
+
+
+
+
+
+
+
+
+
+
+
+