Merge pull request 'Migrate desktop app to Avalonia and publish cross-platform releases' (#1) from codex/avalonia-cross-platform into master
Build and Release / Publish Windows, Linux and macOS (push) Successful in 3m24s

Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
2026-07-19 09:50:00 +08:00 Unverified
17 changed files with 1051 additions and 40 deletions
+47 -14
View File
@@ -11,7 +11,7 @@ permissions:
jobs: jobs:
release: release:
name: Publish Windows x64 name: Publish Windows, Linux and macOS
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
@@ -23,7 +23,8 @@ jobs:
with: with:
dotnet-version: "8.0.x" dotnet-version: "8.0.x"
- name: Build Windows executable - name: Read release metadata
id: metadata
shell: bash shell: bash
env: env:
TAG_NAME: ${{ gitea.ref_name }} TAG_NAME: ${{ gitea.ref_name }}
@@ -36,20 +37,51 @@ jobs:
exit 1 exit 1
fi fi
dotnet publish ./kcsj.csproj \ prerelease=false
--configuration Release \ if [[ "${version}" == *-* ]]; then
--runtime win-x64 \ prerelease=true
--self-contained true \ fi
--output ./publish \
-p:EnableWindowsTargeting=true \
-p:PublishSingleFile=true \
-p:EnableCompressionInSingleFile=true \
-p:IncludeNativeLibrariesForSelfExtract=true \
-p:DebugType=None \
-p:Version="${version}"
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 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. # Gitea's release action is written in Go and is compiled by the runner.
- name: Set up Go for the release action - name: Set up Go for the release action
@@ -61,6 +93,7 @@ jobs:
uses: https://gitea.com/actions/release-action@main uses: https://gitea.com/actions/release-action@main
with: with:
title: ${{ gitea.ref_name }} title: ${{ gitea.ref_name }}
pre_release: ${{ steps.metadata.outputs.prerelease }}
files: |- files: |-
dist/* dist/*
api_key: ${{ secrets.GITEA_TOKEN }} api_key: ${{ secrets.GITEA_TOKEN }}
+8
View File
@@ -0,0 +1,8 @@
<Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="kcsj.App"
RequestedThemeVariant="Default">
<Application.Styles>
<FluentTheme />
</Application.Styles>
</Application>
+24
View File
@@ -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();
}
}
+13 -14
View File
@@ -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)
{ {
/// <summary> BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
/// The main entry point for the application.
/// </summary>
[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());
}
} }
public static AppBuilder BuildAvaloniaApp() =>
AppBuilder.Configure<App>()
.UsePlatformDetect()
.WithInterFont()
.LogToTrace();
} }
+55
View File
@@ -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、英文逗号或中文逗号分隔。空行以及以 `#``//` 开头的行会被忽略。
+4 -4
View File
@@ -71,10 +71,10 @@ namespace kcsj.Services
return result; return result;
} }
CheckKnownPoints(knownPoints, result); CheckKnownPoints(knownPoints!, result);
CheckObservations(observations, result); CheckObservations(observations!, result);
CheckNetwork(knownPoints, observations, result); CheckNetwork(knownPoints!, observations!, result);
CheckRedundancy(knownPoints, observations, result); CheckRedundancy(knownPoints!, observations!, result);
return result; return result;
} }
+150
View File
@@ -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<KnownPoint> ReadKnownPointsFile(string filePath) =>
ParseKnownPoints(File.ReadLines(filePath, Encoding.UTF8), Path.GetFileName(filePath));
public static List<Observation> ReadObservationsFile(string filePath) =>
ParseObservations(File.ReadLines(filePath, Encoding.UTF8), Path.GetFileName(filePath));
public static List<KnownPoint> ParseKnownPointsText(string text) =>
ParseKnownPoints(SplitText(text), "手动输入");
public static List<Observation> ParseObservationsText(string text) =>
ParseObservations(SplitText(text), "手动输入");
public static string BuildKnownPointsPreview(IEnumerable<KnownPoint> 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<Observation> 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<KnownPoint> ParseKnownPoints(IEnumerable<string> lines, string sourceName)
{
List<KnownPoint> result = [];
HashSet<string> 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<Observation> ParseObservations(IEnumerable<string> lines, string sourceName)
{
List<Observation> 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);
}
+1 -1
View File
@@ -8,7 +8,7 @@ namespace kcsj.Services
{ {
public static class LogService public static class LogService
{ {
public static event Action<string> OnLog; public static event Action<string>? OnLog;
public static void AddLog(string message) public static void AddLog(string message)
{ {
string time = DateTime.Now.ToString("HH:mm:ss"); string time = DateTime.Now.ToString("HH:mm:ss");
+31
View File
@@ -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);
}
}
+108
View File
@@ -0,0 +1,108 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="kcsj.Views.DataInputWindow"
Title="数据输入"
Width="900"
Height="700"
MinWidth="720"
MinHeight="580"
WindowStartupLocation="CenterOwner">
<DockPanel Margin="24">
<Grid DockPanel.Dock="Bottom" ColumnDefinitions="*,Auto" Margin="0,18,0,0">
<TextBlock x:Name="InputStatusText"
VerticalAlignment="Center"
Foreground="{DynamicResource SystemControlForegroundBaseMediumBrush}"
Text="请选择文件或切换到手动输入。" />
<Button Grid.Column="1" MinWidth="92" Content="关闭" Click="Cancel_Click" />
</Grid>
<TabControl x:Name="DataTabs">
<TabItem Header="从文件导入">
<Grid Margin="18" RowDefinitions="Auto,Auto,Auto,*" RowSpacing="14">
<Grid ColumnDefinitions="120,*,Auto" ColumnSpacing="10">
<TextBlock VerticalAlignment="Center" Text="已知点文件" />
<TextBox x:Name="KnownPathTextBox" Grid.Column="1" IsReadOnly="True" PlaceholderText="请选择 TXT 文件" />
<Button Grid.Column="2" Content="浏览…" Click="BrowseKnownFile_Click" />
</Grid>
<Grid Grid.Row="1" ColumnDefinitions="120,*,Auto" ColumnSpacing="10">
<TextBlock VerticalAlignment="Center" Text="观测数据文件" />
<TextBox x:Name="ObservationPathTextBox" Grid.Column="1" IsReadOnly="True" PlaceholderText="请选择 TXT 文件" />
<Button Grid.Column="2" Content="浏览…" Click="BrowseObservationFile_Click" />
</Grid>
<StackPanel Grid.Row="2" Orientation="Horizontal" Spacing="10">
<Button MinWidth="110" Content="预览数据" Click="PreviewFiles_Click" />
<Button MinWidth="110" Content="导入数据" Click="ImportFiles_Click" />
</StackPanel>
<Grid Grid.Row="3" ColumnDefinitions="*,*" ColumnSpacing="14">
<Grid RowDefinitions="Auto,*">
<TextBlock Margin="0,0,0,8" FontWeight="SemiBold" Text="已知点预览" />
<TextBox x:Name="KnownPreviewTextBox"
Grid.Row="1"
IsReadOnly="True"
AcceptsReturn="True"
TextWrapping="NoWrap"
ScrollViewer.HorizontalScrollBarVisibility="Auto"
ScrollViewer.VerticalScrollBarVisibility="Auto"
FontFamily="fonts:Inter#Inter" />
</Grid>
<Grid Grid.Column="1" RowDefinitions="Auto,*">
<TextBlock Margin="0,0,0,8" FontWeight="SemiBold" Text="观测数据预览" />
<TextBox x:Name="ObservationPreviewTextBox"
Grid.Row="1"
IsReadOnly="True"
AcceptsReturn="True"
TextWrapping="NoWrap"
ScrollViewer.HorizontalScrollBarVisibility="Auto"
ScrollViewer.VerticalScrollBarVisibility="Auto"
FontFamily="fonts:Inter#Inter" />
</Grid>
</Grid>
</Grid>
</TabItem>
<TabItem Header="手动输入">
<Grid Margin="18" RowDefinitions="Auto,*,Auto" RowSpacing="14">
<Border Padding="12"
Background="{DynamicResource SystemControlBackgroundBaseLowBrush}"
CornerRadius="6">
<TextBlock TextWrapping="Wrap"
Text="每行一条数据,可使用空格、Tab 或逗号分隔;空行以及以 # 或 // 开头的行会被忽略。" />
</Border>
<Grid Grid.Row="1" ColumnDefinitions="*,*" ColumnSpacing="14">
<Grid RowDefinitions="Auto,*">
<TextBlock Margin="0,0,0,8" FontWeight="SemiBold" Text="已知点(点名 高程)" />
<TextBox x:Name="ManualKnownTextBox"
Grid.Row="1"
AcceptsReturn="True"
TextWrapping="NoWrap"
ScrollViewer.HorizontalScrollBarVisibility="Auto"
ScrollViewer.VerticalScrollBarVisibility="Auto"
PlaceholderText="例如:&#10;BM1 100.0000"
FontFamily="fonts:Inter#Inter" />
</Grid>
<Grid Grid.Column="1" RowDefinitions="Auto,*">
<TextBlock Margin="0,0,0,8" FontWeight="SemiBold" Text="观测数据(起点 终点 高差 距离)" />
<TextBox x:Name="ManualObservationTextBox"
Grid.Row="1"
AcceptsReturn="True"
TextWrapping="NoWrap"
ScrollViewer.HorizontalScrollBarVisibility="Auto"
ScrollViewer.VerticalScrollBarVisibility="Auto"
PlaceholderText="例如:&#10;BM1 P1 1.2345 2.5"
FontFamily="fonts:Inter#Inter" />
</Grid>
</Grid>
<StackPanel Grid.Row="2" Orientation="Horizontal" Spacing="10">
<Button MinWidth="110" Content="检查并预览" Click="PreviewManual_Click" />
<Button MinWidth="110" Content="保存数据" Click="SaveManual_Click" />
</StackPanel>
</Grid>
</TabItem>
</TabControl>
</DockPanel>
</Window>
+182
View File
@@ -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<TabControl>("DataTabs")!;
private TextBlock StatusControl => this.FindControl<TextBlock>("InputStatusText")!;
private TextBox KnownPathControl => this.FindControl<TextBox>("KnownPathTextBox")!;
private TextBox ObservationPathControl => this.FindControl<TextBox>("ObservationPathTextBox")!;
private TextBox KnownPreviewControl => this.FindControl<TextBox>("KnownPreviewTextBox")!;
private TextBox ObservationPreviewControl => this.FindControl<TextBox>("ObservationPreviewTextBox")!;
private TextBox ManualKnownControl => this.FindControl<TextBox>("ManualKnownTextBox")!;
private TextBox ManualObservationControl => this.FindControl<TextBox>("ManualObservationTextBox")!;
private List<KnownPoint> _fileKnownPoints = [];
private List<Observation> _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<string?> PickTextFileAsync(string title)
{
IReadOnlyList<IStorageFile> 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<KnownPoint> knownPoints, List<Observation> 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<KnownPoint> knownPoints, List<Observation> 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<KnownPoint> KnownPoints, List<Observation> Observations) ParseManualData() =>
(
InputParser.ParseKnownPointsText(ManualKnownControl.Text ?? string.Empty),
InputParser.ParseObservationsText(ManualObservationControl.Text ?? string.Empty)
);
private void Cancel_Click(object? sender, RoutedEventArgs e) => Close(false);
}
+73
View File
@@ -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<bool>(owner);
public static Task<bool> ConfirmAsync(Window owner, string title, string message) =>
new DialogWindow(title, message, true).ShowDialog<bool>(owner);
}
+70
View File
@@ -0,0 +1,70 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="kcsj.Views.MainWindow"
Title="水准网平差程序"
Width="980"
Height="680"
MinWidth="780"
MinHeight="560"
WindowStartupLocation="CenterScreen">
<DockPanel>
<Menu DockPanel.Dock="Top">
<MenuItem Header="数据管理">
<MenuItem Header="从文件导入" Click="OpenFileInput_Click" />
<MenuItem Header="手动输入" Click="OpenManualInput_Click" />
<Separator />
<MenuItem Header="删除当前数据" Click="ClearData_Click" />
<MenuItem Header="数据检查" Click="CheckData_Click" />
</MenuItem>
<MenuItem Header="开始平差" Click="RunAdjustment_Click" />
<MenuItem Header="查看结果" Click="ShowResult_Click" />
<MenuItem Header="输出报告" Click="ExportReport_Click" />
<MenuItem Header="关于" Click="About_Click" />
</Menu>
<Border DockPanel.Dock="Bottom"
Padding="12,7"
Background="{DynamicResource SystemControlBackgroundBaseLowBrush}">
<TextBlock x:Name="DataStatusText" Text="当前未加载数据" />
</Border>
<Grid Margin="28" ColumnDefinitions="220,24,*" RowDefinitions="Auto,*">
<StackPanel Grid.ColumnSpan="3" Margin="0,0,0,24">
<TextBlock Text="水准网平差程序" FontSize="28" FontWeight="SemiBold" />
<TextBlock Margin="0,6,0,0"
Text="基于 Avalonia 的跨平台水准测量间接平差工具"
Foreground="{DynamicResource SystemControlForegroundBaseMediumBrush}" />
</StackPanel>
<StackPanel Grid.Row="1" Grid.Column="0" Spacing="12">
<Button Height="52" Content="数据输入" Click="OpenDataInput_Click" />
<Button Height="52" Content="数据检查" Click="CheckData_Click" />
<Button Height="52" Content="开始平差" Click="RunAdjustment_Click" />
<Button Height="52" Content="查看结果" Click="ShowResult_Click" />
<Button Height="52" Content="输出报告" Click="ExportReport_Click" />
</StackPanel>
<Border Grid.Row="1"
Grid.Column="2"
Padding="20"
BorderThickness="1"
BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
CornerRadius="8">
<Grid RowDefinitions="Auto,*">
<Grid ColumnDefinitions="*,Auto" Margin="0,0,0,12">
<TextBlock Text="运行日志" FontSize="18" FontWeight="SemiBold" VerticalAlignment="Center" />
<Button Grid.Column="1" Content="清除日志" Click="ClearLog_Click" />
</Grid>
<TextBox x:Name="LogTextBox"
Grid.Row="1"
IsReadOnly="True"
AcceptsReturn="True"
TextWrapping="Wrap"
ScrollViewer.VerticalScrollBarVisibility="Auto"
ScrollViewer.HorizontalScrollBarVisibility="Disabled"
FontFamily="fonts:Inter#Inter" />
</Grid>
</Border>
</Grid>
</DockPanel>
</Window>
+221
View File
@@ -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<TextBox>("LogTextBox")!;
private TextBlock DataStatusControl => this.FindControl<TextBlock>("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<bool>(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<bool> 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<LeastSquaresResult?> 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<IStorageFolder> 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}") : "无法计算";
}
+24
View File
@@ -0,0 +1,24 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="kcsj.Views.ResultWindow"
Title="平差结果"
Width="860"
Height="600"
MinWidth="680"
MinHeight="440"
WindowStartupLocation="CenterOwner">
<Grid Margin="20" RowDefinitions="*,Auto" RowSpacing="14">
<TextBox x:Name="ResultTextBox"
IsReadOnly="True"
AcceptsReturn="True"
TextWrapping="NoWrap"
ScrollViewer.HorizontalScrollBarVisibility="Auto"
ScrollViewer.VerticalScrollBarVisibility="Auto"
FontFamily="fonts:Inter#Inter" />
<Button Grid.Row="1"
HorizontalAlignment="Right"
MinWidth="92"
Content="关闭"
Click="Close_Click" />
</Grid>
</Window>
+23
View File
@@ -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<TextBox>("ResultTextBox")!.Text = Report.BuildResultText(result);
}
private void Close_Click(object? sender, RoutedEventArgs e) => Close();
}
+14 -4
View File
@@ -1,11 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<OutputType>WinExe</OutputType> <OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework> <TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<UseWindowsForms>true</UseWindowsForms>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<BuiltInComInteropSupport>true</BuiltInComInteropSupport>
</PropertyGroup> </PropertyGroup>
<ItemGroup>
<Compile Remove="Forms\**\*.cs" />
<EmbeddedResource Remove="Forms\**\*.resx" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Avalonia" Version="12.1.0" />
<PackageReference Include="Avalonia.Desktop" Version="12.1.0" />
<PackageReference Include="Avalonia.Fonts.Inter" Version="12.1.0" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="12.1.0" />
</ItemGroup>
</Project> </Project>