Files

1342 lines
52 KiB
C#

using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Input;
using Avalonia.Layout;
using Avalonia.Media.Imaging;
using Avalonia.Platform;
using Avalonia.Media;
using Avalonia.Platform.Storage;
using Avalonia.Styling;
namespace ExcelMatcher;
public sealed class MainWindow : Window
{
private const int DisplayCap = 5000;
private static class Ui
{
public static SolidColorBrush Solid(string hex) => new(Color.Parse(hex));
public static readonly FontFamily Mono = new("DejaVu Sans Mono");
public static readonly SolidColorBrush Bg = Solid("#07090F");
public static readonly SolidColorBrush Card = Solid("#0E1420");
public static readonly SolidColorBrush CardBorder = Solid("#22325A");
public static readonly SolidColorBrush Accent = Solid("#00E5FF");
public static readonly SolidColorBrush Magenta = Solid("#FF2DCB");
public static readonly SolidColorBrush Text = Solid("#DCE7FF");
public static readonly SolidColorBrush Muted = Solid("#7C8DB8");
public static readonly SolidColorBrush Danger = Solid("#FF3860");
public static readonly SolidColorBrush Success = Solid("#3EFF8B");
public static readonly SolidColorBrush HeaderDark = Solid("#0B1220");
public static readonly SolidColorBrush RowAlt = Solid("#101A2B");
public static readonly SolidColorBrush BtnHover = new(Color.FromArgb(0x33, 0x00, 0xE5, 0xFF));
public static readonly SolidColorBrush BtnHoverM = new(Color.FromArgb(0x2E, 0xFF, 0x2D, 0xCB));
public static readonly SolidColorBrush HoverTextOnAccent = Solid("#00131A");
public static readonly SolidColorBrush HoverTextOnMagenta = Solid("#1A0014");
public static readonly SolidColorBrush GlowStrong = new(Color.FromArgb(0x38, 0x00, 0xE5, 0xFF));
public static readonly SolidColorBrush GlowSoft = new(Color.FromArgb(0x24, 0x00, 0xE5, 0xFF));
public static readonly SolidColorBrush DisabledBg = Solid("#13161E");
public static readonly SolidColorBrush DisabledFg = Solid("#3E4A5F");
public static readonly SolidColorBrush DisabledBorder = Solid("#232A38");
public const double Radius = 6;
public static void Apply(bool dark)
{
Bg.Color = Color.Parse(dark ? "#07090F" : "#F2F6FC");
Card.Color = Color.Parse(dark ? "#0E1420" : "#FFFFFF");
CardBorder.Color = Color.Parse(dark ? "#22325A" : "#C8D4E8");
Accent.Color = Color.Parse(dark ? "#00E5FF" : "#0066CC");
Magenta.Color = Color.Parse(dark ? "#FF2DCB" : "#C2185B");
Text.Color = Color.Parse(dark ? "#DCE7FF" : "#162036");
Muted.Color = Color.Parse(dark ? "#7C8DB8" : "#5A6B8A");
Danger.Color = Color.Parse(dark ? "#FF3860" : "#C81E3A");
Success.Color = Color.Parse(dark ? "#3EFF8B" : "#168A4E");
HeaderDark.Color = Color.Parse(dark ? "#0B1220" : "#E7EDF7");
RowAlt.Color = Color.Parse(dark ? "#101A2B" : "#ECF1F9");
BtnHover.Color = dark ? Color.FromArgb(0x33, 0x00, 0xE5, 0xFF) : Color.FromArgb(0x12, 0x00, 0x66, 0xCC);
BtnHoverM.Color = dark ? Color.FromArgb(0x2E, 0xFF, 0x2D, 0xCB) : Color.FromArgb(0x12, 0xC2, 0x18, 0x5B);
HoverTextOnAccent.Color = Color.Parse(dark ? "#00131A" : "#FFFFFF");
HoverTextOnMagenta.Color = Color.Parse(dark ? "#1A0014" : "#FFFFFF");
GlowStrong.Color = dark ? Color.FromArgb(0x38, 0x00, 0xE5, 0xFF) : Color.FromArgb(0x16, 0x00, 0x66, 0xCC);
GlowSoft.Color = dark ? Color.FromArgb(0x24, 0x00, 0xE5, 0xFF) : Color.FromArgb(0x0E, 0x00, 0x66, 0xCC);
DisabledBg.Color = Color.Parse(dark ? "#13161E" : "#E7ECF4");
DisabledFg.Color = Color.Parse(dark ? "#3E4A5F" : "#97A3B8");
DisabledBorder.Color = Color.Parse(dark ? "#232A38" : "#D2DBE8");
}
}
private static Button StylePrimary(Button b)
{
b.Background = Ui.BtnHover;
b.Foreground = Ui.Accent;
b.BorderBrush = Ui.Accent;
b.BorderThickness = new Thickness(1.5);
b.CornerRadius = new CornerRadius(4);
b.Padding = new Thickness(22, 9);
b.FontWeight = FontWeight.Bold;
b.PointerEntered += (_, _) => { if (b.IsEnabled) { b.Background = Ui.Accent; b.Foreground = Ui.HoverTextOnAccent; } };
b.PointerExited += (_, _) => { if (b.IsEnabled) { b.Background = Ui.BtnHover; b.Foreground = Ui.Accent; } };
return b;
}
private static Button StyleSecondary(Button b)
{
b.Background = Ui.BtnHoverM;
b.Foreground = Ui.Magenta;
b.BorderBrush = Ui.Magenta;
b.BorderThickness = new Thickness(1.5);
b.CornerRadius = new CornerRadius(4);
b.Padding = new Thickness(16, 8);
b.FontWeight = FontWeight.SemiBold;
b.PointerEntered += (_, _) => { if (b.IsEnabled) { b.Background = Ui.Magenta; b.Foreground = Ui.HoverTextOnMagenta; } };
b.PointerExited += (_, _) => { if (b.IsEnabled) { b.Background = Ui.BtnHoverM; b.Foreground = Ui.Magenta; } };
return b;
}
private static void AddOption(Avalonia.Controls.Panel panel, Control c)
{
c.VerticalAlignment = VerticalAlignment.Center;
c.Margin = new Thickness(0, 2, 14, 2);
panel.Children.Add(c);
}
private static void AddDivider(Avalonia.Controls.Panel panel)
{
panel.Children.Add(new Border
{
Width = 1,
Height = 18,
Background = Ui.CardBorder,
Margin = new Thickness(4, 6, 10, 6),
VerticalAlignment = VerticalAlignment.Center
});
}
private readonly TextBox _tbA = new() { PlaceholderText = "Путь к файлу A (*.xls / *.xlsx)" };
private readonly Button _btnBrowseA = new() { Content = "Обзор…" };
private readonly ComboBox _cbA = new() { MinWidth = 190 };
private readonly StackPanel _colsA = new() { Orientation = Orientation.Vertical, Spacing = 2 };
private readonly TextBox _tbB = new() { PlaceholderText = "Путь к файлу B (*.xls / *.xlsx)" };
private readonly Button _btnBrowseB = new() { Content = "Обзор…" };
private readonly ComboBox _cbB = new() { MinWidth = 190 };
private readonly StackPanel _colsB = new() { Orientation = Orientation.Vertical, Spacing = 2 };
private readonly CheckBox _chkIgnore = new() { Content = "Игнорировать регистр и лишние пробелы", IsChecked = true };
private readonly RadioButton _rbMatches = new() { Content = "Только совпадения (есть в обеих)" };
private readonly RadioButton _rbOnlyA = new() { Content = "Только из A, которых нет в B" };
private readonly RadioButton _rbOnlyB = new() { Content = "Только из B, которых нет в A" };
private readonly RadioButton _rbBoth = new() { Content = "Совпадения + чего нет в A" };
private readonly RadioButton _rbExact = new() { Content = "Точное совпадение", IsChecked = true };
private readonly RadioButton _rbPartial = new() { Content = "Частичное (по фрагменту)" };
private readonly CheckBox _chkDedupe = new() { Content = "Удалять полностью одинаковые строки" };
private readonly CheckBox _chkCleanCols = new() { Content = "Объединять одноимённые колонки", IsChecked = true };
private readonly RadioButton _rbFuzzy = new() { Content = "Нечёткое (по сходству %)" };
private readonly Slider _slFuzzy = new()
{
Minimum = 50,
Maximum = 99,
Value = 85,
IsSnapToTickEnabled = true,
TickFrequency = 1,
Width = 150,
VerticalAlignment = VerticalAlignment.Center
};
private readonly TextBlock _lblFuzzy = new()
{
Text = "≥85%",
Width = 46,
VerticalAlignment = VerticalAlignment.Center,
FontWeight = FontWeight.SemiBold
};
private readonly CheckBox _chkExt = new() { Content = "Расширенная нормализация (даты/телефоны/пунктуация)" };
private readonly RadioButton _rbAnd = new() { Content = "И ключи (все поля)", IsChecked = true };
private readonly RadioButton _rbOr = new() { Content = "ИЛИ ключи (любое поле)" };
private readonly TextBox _tbSynonyms = new()
{
AcceptsReturn = true,
MinHeight = 58,
TextWrapping = TextWrapping.Wrap,
FontFamily = Ui.Mono,
FontSize = 12
};
private readonly Button _btnAutoMap = new() { Content = "Подобрать ключи A↔B" };
private readonly ProgressBar _progress = new() { IsIndeterminate = true, Height = 6, IsVisible = false };
private CancellationTokenSource? _cts;
private bool _running;
private readonly Button _btnRun = new() { Content = "Сопоставить" };
private readonly TextBlock _lblSummary = new() { FontWeight = FontWeight.Bold };
private readonly TextBox _tbFilter = new() { PlaceholderText = "Фильтр по всем колонкам…" };
private readonly ScrollViewer _resultScroll = new()
{
HorizontalScrollBarVisibility = ScrollBarVisibility.Auto,
VerticalScrollBarVisibility = ScrollBarVisibility.Auto
};
private readonly Button _btnSave = new() { Content = "Сохранить результат в Excel…" };
private readonly TextBlock _lblStatus = new() { TextWrapping = TextWrapping.Wrap };
private readonly TextBlock _lblCopyright = new()
{
Text = "© 2026 Dedicraf · Специально для Юрия Киселёва",
FontSize = 12,
Foreground = Ui.Muted
};
private readonly ToggleButton _themeToggle = new()
{
IsChecked = true,
Content = "ТЕМА: ТЁМНАЯ",
FontFamily = Ui.Mono,
FontSize = 12,
FontWeight = FontWeight.SemiBold,
Padding = new Thickness(14, 6, 14, 6),
Margin = new Thickness(0, 0, 10, 0),
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(999),
VerticalAlignment = VerticalAlignment.Center
};
private static readonly string[] _startArgs = Environment.GetCommandLineArgs();
private bool _isDark = Array.IndexOf(_startArgs, "--light") < 0;
private readonly List<LinearGradientBrush> _accentGradients = new();
private readonly List<Border> _groupCards = new();
private Border? _headerCard;
private List<TableData> _tablesA = new();
private List<TableData> _tablesB = new();
private List<CheckBox> _colCheckA = new();
private List<CheckBox> _colCheckB = new();
private string _loadedPathA = "";
private string _loadedPathB = "";
private MatchResult? _lastResult;
private MergeOutput? _merged;
private TableData? _mainTable;
private TableData? _secondaryTable;
private string _modeName = "";
private List<string> _viewHeaders = new();
private List<string[]> _viewData = new();
private bool _viewShowSource;
private List<string>? _viewSources;
private int _viewTotal;
private string _dedupeNote = "";
public MainWindow()
{
Title = "Сопоставлятор 3000";
try { using var ic = AssetLoader.Open(new Uri("avares://ExcelMatcher/Assets/app.ico")); Icon = new WindowIcon(new Bitmap(ic)); } catch { }
Width = 1280;
Height = 800;
MinWidth = 980;
MinHeight = 620;
FontFamily = new FontFamily("Inter");
FontSize = 14;
Background = Ui.Bg;
RequestedThemeVariant = _isDark ? ThemeVariant.Dark : ThemeVariant.Light;
Ui.Apply(_isDark);
if (!_isDark)
{
_themeToggle.IsChecked = false;
_themeToggle.Content = "ТЕМА: СВЕТЛАЯ";
}
_themeToggle.Background = Ui.BtnHover;
_themeToggle.Foreground = Ui.Accent;
_themeToggle.BorderBrush = Ui.Magenta;
_themeToggle.Click += (_, _) => ToggleTheme();
StylePrimary(_btnRun);
StyleSecondary(_btnBrowseA);
StyleSecondary(_btnBrowseB);
StylePrimary(_btnSave);
_lblSummary.TextTrimming = TextTrimming.CharacterEllipsis;
_lblSummary.Foreground = Ui.Muted;
_lblSummary.VerticalAlignment = VerticalAlignment.Center;
UpdateSaveButton();
_btnBrowseA.Click += (_, _) => _ = BrowseFile(_tbA, "Файл A (основная таблица)");
_btnBrowseB.Click += (_, _) => _ = BrowseFile(_tbB, "Файл B (таблица для поиска совпадений)");
_cbA.SelectionChanged += (_, _) => PopulateColumns(_tablesA, _cbA, _colsA, ref _colCheckA);
_cbB.SelectionChanged += (_, _) => PopulateColumns(_tablesB, _cbB, _colsB, ref _colCheckB);
_tbA.KeyDown += (_, e) => { if (e.Key == Key.Enter) { e.Handled = true; LoadFile(true, true); } };
_tbB.KeyDown += (_, e) => { if (e.Key == Key.Enter) { e.Handled = true; LoadFile(false, true); } };
_rbMatches.IsChecked = true;
foreach (var rb in new[] { _rbMatches, _rbOnlyA, _rbOnlyB, _rbBoth }) rb.GroupName = "mode";
foreach (var rb in new[] { _rbExact, _rbPartial, _rbFuzzy }) rb.GroupName = "matchkind";
foreach (var rb in new[] { _rbAnd, _rbOr }) rb.GroupName = "keylogic";
_slFuzzy.ValueChanged += (_, e) =>
{
if (e.NewValue > 0) _lblFuzzy.Text = $"≥{Math.Round(e.NewValue)}%";
};
_btnAutoMap.Click += (_, _) => AutoMapColumns();
_tbSynonyms.PlaceholderText = "1 строка = 1 синоним.\nПишут так = канон. форма\nнапр.: ООО Ромашка = Ромашка";
_progress.Margin = new Thickness(0, 8, 0, 2);
_tbFilter.TextChanged += (_, _) => ReRender();
EnableDrop(_tbA, true);
EnableDrop(_tbB, false);
_btnRun.Click += (_, _) =>
{
if (_running) _cts?.Cancel();
else _ = RunMatchingAsync();
};
_btnSave.Click += (_, _) => _ = SaveResult();
Content = BuildLayout();
}
private void ToggleTheme()
{
_isDark = _themeToggle.IsChecked == true;
Ui.Apply(_isDark);
RequestedThemeVariant = _isDark ? ThemeVariant.Dark : ThemeVariant.Light;
_themeToggle.Content = _isDark ? "ТЕМА: ТЁМНАЯ" : "ТЕМА: СВЕТЛАЯ";
RefreshAccents();
UpdateSaveButton();
if (_viewData.Count > 0) ReRender();
}
private void RefreshAccents()
{
foreach (var g in _accentGradients)
{
if (g.GradientStops.Count >= 2)
{
g.GradientStops[0].Color = Ui.Accent.Color;
g.GradientStops[1].Color = Ui.Magenta.Color;
}
}
if (_headerCard is not null)
_headerCard.BoxShadow = new BoxShadows(new BoxShadow { Blur = 24, Color = Ui.GlowStrong.Color });
foreach (var card in _groupCards)
card.BoxShadow = new BoxShadows(new BoxShadow { Blur = 20, Color = Ui.GlowSoft.Color });
}
private Control BuildLayout()
{
var root = new Grid();
root.RowDefinitions.Add(new RowDefinition(GridLength.Auto));
root.RowDefinitions.Add(new RowDefinition(GridLength.Auto));
root.RowDefinitions.Add(new RowDefinition(GridLength.Auto));
root.RowDefinitions.Add(new RowDefinition(GridLength.Star));
root.RowDefinitions.Add(new RowDefinition(GridLength.Auto));
root.Margin = new Thickness(14, 10, 14, 10);
var header = BuildHeader();
header.Margin = new Thickness(0, 0, 0, 12);
root.Children.Add(header);
Grid.SetRow(root.Children[^1], 0);
var top = BuildTopPanel();
top.Margin = new Thickness(0, 0, 0, 10);
root.Children.Add(top);
Grid.SetRow(root.Children[^1], 1);
var options = BuildOptionsPanel();
options.Margin = new Thickness(0, 0, 0, 10);
root.Children.Add(options);
Grid.SetRow(root.Children[^1], 2);
root.Children.Add(BuildResultsPanel());
Grid.SetRow(root.Children[^1], 3);
root.Children.Add(BuildBottomPanel());
Grid.SetRow(root.Children[^1], 4);
return root;
}
private Control BuildHeader()
{
var grad = new LinearGradientBrush
{
StartPoint = new RelativePoint(0, 0.5, RelativeUnit.Relative),
EndPoint = new RelativePoint(1, 0.5, RelativeUnit.Relative)
};
grad.GradientStops.Add(new GradientStop(Ui.Accent.Color, 0));
grad.GradientStops.Add(new GradientStop(Ui.Magenta.Color, 1));
_accentGradients.Add(grad);
var textStack = new StackPanel { Orientation = Orientation.Vertical, Spacing = 1 };
textStack.Children.Add(new TextBlock
{
Text = "// СИСТЕМА СОПОСТАВЛЕНИЯ ДАННЫХ",
FontFamily = Ui.Mono,
FontSize = 11,
FontWeight = FontWeight.SemiBold,
Foreground = Ui.Muted,
LetterSpacing = 2
});
textStack.Children.Add(new TextBlock
{
Text = "СОПОСТАВЛЯТОР 3000",
FontFamily = Ui.Mono,
FontSize = 25,
FontWeight = FontWeight.ExtraBold,
Foreground = Ui.Accent
});
textStack.Children.Add(new TextBlock
{
Text = "▸ сопоставление и совмещение данных из двух книг Excel",
FontFamily = Ui.Mono,
FontSize = 12,
Foreground = Ui.Magenta,
Margin = new Thickness(2, 3, 0, 0)
});
var pill = new Border
{
Background = Ui.BtnHover,
BorderBrush = Ui.Accent,
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(999),
Padding = new Thickness(14, 6, 14, 6),
VerticalAlignment = VerticalAlignment.Center,
Child = new TextBlock
{
Text = "v1.0 · CYBER-МОДУЛЬ",
Foreground = Ui.Accent,
FontFamily = Ui.Mono,
FontWeight = FontWeight.SemiBold,
FontSize = 12
}
};
var dock = new DockPanel { LastChildFill = true };
DockPanel.SetDock(_themeToggle, Dock.Right);
dock.Children.Add(_themeToggle);
DockPanel.SetDock(pill, Dock.Right);
dock.Children.Add(pill);
dock.Children.Add(textStack);
var stack = new StackPanel();
var accentBar = new Border
{
Height = 3,
Background = grad,
CornerRadius = new CornerRadius(0, 0, 6, 6),
Margin = new Thickness(-18, 0, -18, -15)
};
stack.Children.Add(dock);
stack.Children.Add(accentBar);
_headerCard = new Border
{
Background = Ui.HeaderDark,
BorderBrush = Ui.Accent,
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(6),
Padding = new Thickness(18, 15, 18, 18),
BoxShadow = new BoxShadows(new BoxShadow { Blur = 24, Color = Ui.GlowStrong.Color }),
Child = stack
};
return _headerCard;
}
private Border MakeGroup(string title, string badge, Control body)
{
var titleRow = new DockPanel { LastChildFill = true };
if (!string.IsNullOrEmpty(badge))
{
var badgeBorder = new Border
{
Background = Ui.BtnHoverM,
BorderBrush = Ui.Magenta,
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(4),
Padding = new Thickness(10, 3, 10, 3),
VerticalAlignment = VerticalAlignment.Center,
Child = new TextBlock
{
Text = badge,
FontWeight = FontWeight.Bold,
Foreground = Ui.Accent,
FontFamily = Ui.Mono,
FontSize = 12
}
};
DockPanel.SetDock(badgeBorder, Dock.Right);
titleRow.Children.Add(badgeBorder);
}
titleRow.Children.Add(new TextBlock
{
Text = title,
FontWeight = FontWeight.SemiBold,
Foreground = Ui.Text,
FontSize = 14,
VerticalAlignment = VerticalAlignment.Center
});
var stack = new StackPanel { Orientation = Orientation.Vertical, Spacing = 10 };
var grad = new LinearGradientBrush
{
StartPoint = new RelativePoint(0, 0.5, RelativeUnit.Relative),
EndPoint = new RelativePoint(1, 0.5, RelativeUnit.Relative)
};
grad.GradientStops.Add(new GradientStop(Ui.Accent.Color, 0));
grad.GradientStops.Add(new GradientStop(Ui.Magenta.Color, 1));
_accentGradients.Add(grad);
var accentBar = new Border
{
Height = 3,
Background = grad,
CornerRadius = new CornerRadius(3, 3, 0, 0),
Margin = new Thickness(-14, -15, -14, 0)
};
stack.Children.Add(accentBar);
stack.Children.Add(titleRow);
stack.Children.Add(body);
var card = new Border
{
Background = Ui.Card,
BorderBrush = Ui.CardBorder,
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(Ui.Radius),
Padding = new Thickness(14, 15, 14, 14),
BoxShadow = new BoxShadows(new BoxShadow { Blur = 20, Color = Ui.GlowSoft.Color }),
Child = stack
};
_groupCards.Add(card);
return card;
}
private Control BuildTopPanel()
{
var groupA = MakeGroup("Таблица A — основная", "A", BuildFilePanel(_tbA, _btnBrowseA, _cbA, _colsA));
var groupB = MakeGroup("Таблица B — для поиска совпадений", "B", BuildFilePanel(_tbB, _btnBrowseB, _cbB, _colsB));
var grid = new Grid
{
ColumnDefinitions = { new ColumnDefinition(GridLength.Star), new ColumnDefinition(GridLength.Star) }
};
grid.Children.Add(groupA);
grid.Children.Add(groupB);
Grid.SetColumn(groupB, 1);
groupA.Margin = new Thickness(0, 0, 7, 0);
groupB.Margin = new Thickness(7, 0, 0, 0);
return grid;
}
private Control BuildFilePanel(TextBox tb, Button browse, ComboBox cb, StackPanel colsPanel)
{
tb.MinWidth = 300;
var pathRow = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8 };
pathRow.Children.Add(tb);
pathRow.Children.Add(browse);
var sheetRow = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8 };
sheetRow.Children.Add(new TextBlock
{
Text = "Лист:",
Foreground = Ui.Muted,
VerticalAlignment = VerticalAlignment.Center
});
cb.MinWidth = 190;
cb.MaxWidth = 230;
sheetRow.Children.Add(cb);
var colLabel = new TextBlock
{
Text = "Ключи (выберите соответствующие поля в одинаковом порядке)",
Foreground = Ui.Muted,
FontWeight = FontWeight.SemiBold,
FontSize = 12
};
var colScroll = new ScrollViewer
{
MaxHeight = 118,
HorizontalScrollBarVisibility = ScrollBarVisibility.Auto,
VerticalScrollBarVisibility = ScrollBarVisibility.Auto,
Content = colsPanel
};
var stack = new StackPanel { Orientation = Orientation.Vertical, Spacing = 7 };
stack.Children.Add(pathRow);
stack.Children.Add(sheetRow);
stack.Children.Add(colLabel);
stack.Children.Add(colScroll);
return stack;
}
private Control BuildOptionsPanel()
{
_btnRun.IsDefault = true;
var modes = new WrapPanel { Orientation = Orientation.Horizontal };
foreach (var rb in new[] { _rbMatches, _rbOnlyA, _rbOnlyB, _rbBoth })
{
rb.VerticalAlignment = VerticalAlignment.Center;
rb.Margin = new Thickness(0, 2, 18, 2);
modes.Children.Add(rb);
}
var kindLabel = new TextBlock
{
Text = "Способ:",
Foreground = Ui.Muted,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(0, 2, 8, 2)
};
var thresholdLabel = new TextBlock
{
Text = "Порог сходства:",
Foreground = Ui.Muted,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(0, 2, 8, 2)
};
var matchKindRow = new WrapPanel { Orientation = Orientation.Horizontal };
matchKindRow.Children.Add(kindLabel);
AddOption(matchKindRow, _rbExact);
AddOption(matchKindRow, _rbPartial);
AddOption(matchKindRow, _rbFuzzy);
AddDivider(matchKindRow);
matchKindRow.Children.Add(thresholdLabel);
matchKindRow.Children.Add(_slFuzzy);
matchKindRow.Children.Add(_lblFuzzy);
AddDivider(matchKindRow);
AddOption(matchKindRow, _chkExt);
var keyRow = new WrapPanel { Orientation = Orientation.Horizontal };
AddOption(keyRow, _rbAnd);
AddOption(keyRow, _rbOr);
AddDivider(keyRow);
AddOption(keyRow, _chkIgnore);
AddDivider(keyRow);
AddOption(keyRow, _chkDedupe);
AddDivider(keyRow);
AddOption(keyRow, _chkCleanCols);
AddDivider(keyRow);
_btnAutoMap.Background = Ui.BtnHover;
_btnAutoMap.Foreground = Ui.Accent;
_btnAutoMap.BorderBrush = Ui.Accent;
_btnAutoMap.BorderThickness = new Thickness(1);
_btnAutoMap.CornerRadius = new CornerRadius(4);
_btnAutoMap.Padding = new Thickness(10, 6);
_btnAutoMap.FontWeight = FontWeight.SemiBold;
_btnAutoMap.VerticalAlignment = VerticalAlignment.Center;
keyRow.Children.Add(_btnAutoMap);
var synonymsLabel = new TextBlock
{
Text = "Синонимы и нормализация (по строке «как пишут = канон. форма»):",
Foreground = Ui.Muted,
FontSize = 12,
Margin = new Thickness(0, 4, 0, 2)
};
var bottomRow = new DockPanel { LastChildFill = false, Margin = new Thickness(0, 8, 0, 0) };
DockPanel.SetDock(_btnRun, Dock.Right);
bottomRow.Children.Add(_btnRun);
var body = new StackPanel { Orientation = Orientation.Vertical, Spacing = 2 };
body.Children.Add(modes);
body.Children.Add(matchKindRow);
body.Children.Add(keyRow);
body.Children.Add(synonymsLabel);
body.Children.Add(_tbSynonyms);
body.Children.Add(_progress);
body.Children.Add(bottomRow);
return MakeGroup("Режимы сопоставления", "Обработка", body);
}
private Control BuildResultsPanel()
{
_tbFilter.Width = 250;
_tbFilter.HorizontalAlignment = HorizontalAlignment.Right;
var filterRow = new DockPanel { LastChildFill = true, Margin = new Thickness(0, 0, 0, 4) };
_lblSummary.VerticalAlignment = VerticalAlignment.Center;
DockPanel.SetDock(_tbFilter, Dock.Right);
filterRow.Children.Add(_tbFilter);
filterRow.Children.Add(_lblSummary);
var dock = new DockPanel();
DockPanel.SetDock(filterRow, Dock.Top);
dock.Children.Add(filterRow);
dock.Children.Add(_resultScroll);
return MakeGroup("Результат", " ", dock);
}
private DockPanel BuildBottomPanel()
{
var dock = new DockPanel { LastChildFill = true, Margin = new Thickness(4, 8, 4, 0) };
var saveBlock = new DockPanel { LastChildFill = false };
DockPanel.SetDock(saveBlock, Dock.Right);
saveBlock.Children.Add(_btnSave);
DockPanel.SetDock(_lblCopyright, Dock.Left);
dock.Children.Add(saveBlock);
dock.Children.Add(_lblCopyright);
dock.Children.Add(_lblStatus);
_lblStatus.VerticalAlignment = VerticalAlignment.Center;
_lblCopyright.VerticalAlignment = VerticalAlignment.Center;
_btnSave.IsEnabled = false;
UpdateSaveButton();
return dock;
}
private void EnableDrop(TextBox tb, bool isMain)
{
DragDrop.SetAllowDrop(tb, true);
tb.AddHandler(DragDrop.DragOverEvent, (_, e) =>
{
e.DragEffects = DragDropEffects.Copy;
e.Handled = true;
});
tb.AddHandler(DragDrop.DropEvent, (_, e) =>
{
if (e.DataTransfer is { } dt && dt.TryGetFiles() is { } files)
{
var first = files.FirstOrDefault();
if (first is not null)
{
tb.Text = first.Path.LocalPath;
LoadFile(isMain, true);
}
}
e.Handled = true;
});
}
private async System.Threading.Tasks.Task BrowseFile(TextBox tb, string dialogTitle)
{
var top = TopLevel.GetTopLevel(this);
if (top is null) return;
var files = await top.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
{
Title = dialogTitle,
AllowMultiple = false,
FileTypeFilter = new[]
{
new FilePickerFileType("Excel") { Patterns = new[] { "*.xlsx", "*.xlsm", "*.xltx", "*.xls", "*.xlt" } },
FilePickerFileTypes.All
}
});
if (files.Count > 0)
{
tb.Text = files[0].Path.LocalPath;
LoadFile(tb == _tbA, true);
}
}
private void LoadFile(bool isMain, bool showErrors)
{
var tb = isMain ? _tbA : _tbB;
var path = (tb.Text ?? "").Trim();
var loadedPath = isMain ? _loadedPathA : _loadedPathB;
var loaded = isMain ? _tablesA : _tablesB;
if (string.IsNullOrEmpty(path))
{
if (showErrors) ShowError("Укажите путь к файлу Excel.");
return;
}
if (path == loadedPath && loaded.Count > 0)
{
if (showErrors) SetStatus($"Уже загружено: {Path.GetFileName(path)}.", false);
return;
}
if (!File.Exists(path))
{
if (showErrors) ShowError($"Файл не найден: {path}");
return;
}
try
{
var tables = ExcelReader.LoadWorkbook(path);
if (tables.Count == 0)
{
ShowError("В файле нет таблиц (листов).");
return;
}
if (isMain)
{
_tablesA = tables;
_loadedPathA = path;
FillSheetCombo(_cbA, tables);
}
else
{
_tablesB = tables;
_loadedPathB = path;
FillSheetCombo(_cbB, tables);
}
if (showErrors) SetStatus($"Загружено: {Path.GetFileName(path)} — листов: {tables.Count}.", false);
}
catch (Exception ex)
{
ShowError($"Не удалось открыть файл: {ex.Message}");
}
}
private void EnsureFilesLoaded()
{
if (_tablesA.Count == 0 || _tbA.Text?.Trim() != _loadedPathA) LoadFile(true, false);
if (_tablesB.Count == 0 || _tbB.Text?.Trim() != _loadedPathB) LoadFile(false, false);
}
private void FillSheetCombo(ComboBox cb, List<TableData> tables)
{
cb.ItemsSource = tables.Select(t => $"{t.SheetName} ({t.RowCount} строк)").ToList();
cb.SelectedIndex = 0;
}
private void PopulateColumns(List<TableData> tables, ComboBox cb, StackPanel colsPanel, ref List<CheckBox> checks)
{
checks = new List<CheckBox>();
colsPanel.Children.Clear();
if (tables.Count == 0 || cb.SelectedIndex < 0 || cb.SelectedIndex >= tables.Count) return;
var table = tables[cb.SelectedIndex];
for (int i = 0; i < table.Columns.Count; i++)
{
var chk = new CheckBox { Content = table.Columns[i], IsChecked = i == 0 };
checks.Add(chk);
colsPanel.Children.Add(chk);
}
}
private TableData? SelectedTable(List<TableData> tables, ComboBox cb) =>
tables.Count > 0 && cb.SelectedIndex >= 0 && cb.SelectedIndex < tables.Count
? tables[cb.SelectedIndex]
: null;
private MatchOptions BuildOptions(bool[] mainUsed, bool[] secondaryUsed, CancellationToken token)
{
var kind = _rbFuzzy.IsChecked ?? false
? MatchKind.Fuzzy
: (_rbPartial.IsChecked ?? false ? MatchKind.Partial : MatchKind.Exact);
return new MatchOptions
{
MainUsed = mainUsed,
SecondaryUsed = secondaryUsed,
IgnoreCaseAndSpaces = _chkIgnore.IsChecked ?? true,
ExtendedNormalize = _chkExt.IsChecked ?? false,
Kind = kind,
FuzzyThreshold = (int)Math.Round(_slFuzzy.Value),
UseOr = _rbOr.IsChecked ?? false,
Synonyms = ParseSynonyms(),
Token = token
};
}
private IReadOnlyDictionary<string, string>? ParseSynonyms()
{
Dictionary<string, string>? dict = null;
foreach (var raw in (_tbSynonyms.Text ?? "").Replace('\r', '\n').Split('\n'))
{
var line = raw.Trim();
if (line.Length == 0) continue;
var parts = line.Split(new[] { '=' }, 2);
if (parts.Length != 2) continue;
var k = parts[0].Trim();
var v = parts[1].Trim();
if (k.Length == 0 || v.Length == 0) continue;
dict ??= new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
dict[k] = v;
}
return dict;
}
private async System.Threading.Tasks.Task RunMatchingAsync()
{
EnsureFilesLoaded();
var main = SelectedTable(_tablesA, _cbA);
var sec = SelectedTable(_tablesB, _cbB);
if (main is null || sec is null)
{
ShowError("Загрузите обе таблицы (введите путь и нажмите Enter или используйте «Обзор…»).");
return;
}
var mainUsed = _colCheckA.Select(c => c.IsChecked ?? false).ToArray();
var secUsed = _colCheckB.Select(c => c.IsChecked ?? false).ToArray();
if (mainUsed.Length == 0 || secUsed.Length == 0)
{
ShowError("Не удалось получить список колонок. Перезагрузите файлы.");
return;
}
if (mainUsed.All(x => !x) || secUsed.All(x => !x))
{
ShowError("Отметьте хотя бы одну ключевую колонку в каждой таблице (по умолчанию отмечена первая).");
return;
}
if (mainUsed.Count(x => x) != secUsed.Count(x => x))
{
ShowError("Выберите одинаковое число ключевых колонок в обеих таблицах.");
return;
}
var cts = new CancellationTokenSource();
_cts = cts;
var token = cts.Token;
_running = true;
_btnRun.Content = "Отмена";
_progress.IsVisible = true;
SetStatus("Сопоставляю…", false);
try
{
var options = BuildOptions(mainUsed, secUsed, token);
bool dedupe = _chkDedupe.IsChecked ?? false;
bool cleanCols = _chkCleanCols.IsChecked ?? true;
var (result, merged, dMain, dSec, removedA, removedB) = await System.Threading.Tasks.Task.Run(() =>
{
TableData m = main;
TableData s = sec;
int rA = 0, rB = 0;
if (dedupe)
{
(m, rA) = TableMatcher.Dedupe(main, mainUsed, options.IgnoreCaseAndSpaces, options);
(s, rB) = TableMatcher.Dedupe(sec, secUsed, options.IgnoreCaseAndSpaces, options);
}
var res = TableMatcher.RunAll(m, s, options);
var mg = TableMatcher.Merge(m, s, options, cleanCols);
return (res, mg, m, s, rA, rB);
}, token);
_mainTable = dMain;
_secondaryTable = dSec;
_lastResult = result;
_merged = merged;
_dedupeNote = removedA + removedB > 0
? $" Дублей удалено: A={removedA}, B={removedB}."
: "";
ShowSelectedMode(dMain, dSec, result);
var ca = TableMatcher.KeyCoverage(dMain, mainUsed);
var cb = TableMatcher.KeyCoverage(dSec, secUsed);
SetStatus(BuildStats(dMain, dSec, result, ca, cb) + _dedupeNote, false);
_btnSave.IsEnabled = true;
UpdateSaveButton();
}
catch (OperationCanceledException)
{
SetStatus("Сопоставление отменено.", false);
}
catch (Exception ex)
{
_btnSave.IsEnabled = false;
UpdateSaveButton();
ShowError($"Ошибка при сопоставлении: {ex.Message}");
}
finally
{
_running = false;
_btnRun.Content = "Сопоставить";
_progress.IsVisible = false;
_cts = null;
}
}
private string BuildStats(TableData m, TableData s, MatchResult r, (int Empty, int Total) ca, (int Empty, int Total) cb)
{
double covA = ca.Total == 0 ? 100 : Math.Round((ca.Total - ca.Empty) * 100.0 / ca.Total, 1);
double covB = cb.Total == 0 ? 100 : Math.Round((cb.Total - cb.Empty) * 100.0 / cb.Total, 1);
var sb = new System.Text.StringBuilder();
sb.Append($"Всего: A={m.RowCount}, B={s.RowCount}. ");
sb.Append($"Совпадений: {r.Matches.Count} · Только в A: {r.OnlyMain.Count} · Только в B: {r.OnlySecondary.Count}.");
if (r.Pairs.Count > 0)
{
var countsPerMainRow = r.Pairs.GroupBy(p => p.RowA).Select(group => group.First().BCount).ToList();
double avg = Math.Round(countsPerMainRow.Average(), 2);
int max = countsPerMainRow.Max();
sb.Append($" Пары A↔B: {r.Pairs.Count}, ср. N→M: {avg.ToString(CultureInfo.InvariantCulture)}, макс: {max}.");
}
sb.Append($" Ключи заполнены: A={covA.ToString(CultureInfo.InvariantCulture)}%, B={covB.ToString(CultureInfo.InvariantCulture)}%.");
return sb.ToString();
}
private void AutoMapColumns()
{
var a = SelectedTable(_tablesA, _cbA);
var b = SelectedTable(_tablesB, _cbB);
if (a is null || b is null)
{
ShowError("Сначала загрузите таблицы A и B и выберите листы.");
return;
}
if (_colCheckA.Count == 0 || _colCheckB.Count == 0)
{
ShowError("Сначала выберите листы.");
return;
}
var bIdx = new List<int>();
for (int j = 0; j < _colCheckB.Count; j++)
{
if ((_colCheckB[j].IsChecked ?? false)) bIdx.Add(j);
}
if (bIdx.Count == 0) bIdx.Add(0);
foreach (var c in _colCheckA) c.IsChecked = false;
var mapped = new HashSet<int>();
foreach (var j in bIdx)
{
var bh = b.Columns.Count > j ? b.Columns[j] : "Колонка " + (j + 1);
var x = bh.ToLowerInvariant();
int best = -1;
double bestSim = 0;
for (int i = 0; i < a.Columns.Count; i++)
{
var y = a.Columns[i].ToLowerInvariant();
double sim = TableMatcher.Similarity(x, y);
if (x.Length > 0 && y.Length > 0 &&
(x.Contains(y, StringComparison.OrdinalIgnoreCase) || y.Contains(x, StringComparison.OrdinalIgnoreCase)))
sim = Math.Max(sim, 0.85);
if (sim > bestSim)
{
bestSim = sim;
best = i;
}
}
if (best >= 0 && bestSim >= 0.25)
{
_colCheckA[best].IsChecked = true;
mapped.Add(best);
}
}
if (mapped.Count == 0) _colCheckA[0].IsChecked = true;
SetStatus($"Подбор ключей: колонок B={bIdx.Count}, сопоставлено колонок A={mapped.Count}.", false);
}
private void ShowSelectedMode(TableData main, TableData sec, MatchResult result)
{
MatchMode mode;
if ((_rbMatches.IsChecked ?? false)) mode = MatchMode.KeepMatches;
else if ((_rbOnlyA.IsChecked ?? false)) mode = MatchMode.KeepOnlyMain;
else if ((_rbOnlyB.IsChecked ?? false)) mode = MatchMode.KeepOnlySecondary;
else mode = MatchMode.KeepBoth;
List<string> headers;
List<string[]> data = new();
List<string>? sources = null;
switch (mode)
{
case MatchMode.KeepMatches:
if (_merged is { Rows.Count: > 0 } && _merged.Columns.Count > 0)
{
headers = _merged.Columns;
data = _merged.Rows;
}
else
{
headers = main.Columns;
data = result.Matches;
}
break;
case MatchMode.KeepOnlyMain:
headers = main.Columns;
data = result.OnlyMain;
break;
case MatchMode.KeepOnlySecondary:
headers = sec.Columns;
data = result.OnlySecondary;
sources = data.Select(_ => "B").ToList();
break;
default:
headers = _merged?.Columns ?? new List<string>(main.Columns);
data = _merged is not null ? new List<string[]>(_merged.Rows) : new List<string[]>();
sources = new List<string>();
foreach (var _ in result.Pairs) sources.Add("A+B");
foreach (var r in result.OnlySecondary)
{
var row = new string[headers.Count];
Array.Fill(row, "");
if (_merged is not null)
{
for (int b = 0; b < _merged.SecondaryColumnMap.Length && b < r.Length; b++)
{
int destination = _merged.SecondaryColumnMap[b];
if (destination >= 0 && destination < row.Length) row[destination] = r[b];
}
}
else
{
for (int b = 0; b < r.Length && b < row.Length; b++) row[b] = r[b];
}
data.Add(row);
sources.Add("B");
}
break;
}
bool showSource = sources is not null;
string modeName = mode switch
{
MatchMode.KeepMatches => "Совпадения (совмещённые данные)",
MatchMode.KeepOnlyMain => "Строки A без совпадений в B",
MatchMode.KeepOnlySecondary => "Строки B без совпадений в A",
_ => "Совпадения + чего нет в A",
};
if (_rbPartial.IsChecked ?? false) modeName += " · частичное";
if (_rbFuzzy.IsChecked ?? false) modeName += " · нечёткое";
if (_rbOr.IsChecked ?? false) modeName += " · ИЛИ-ключи";
if (_chkExt.IsChecked ?? false) modeName += " · расшир.нормализация";
if (_chkDedupe.IsChecked ?? false) modeName += " · без дублей";
RenderTable(headers, data, showSource, sources, modeName);
SetStatus($"Совпадений: {result.Matches.Count} · Только в A: {result.OnlyMain.Count} · Только в B: {result.OnlySecondary.Count}{_dedupeNote}", false);
}
private void RenderTable(List<string> headers, List<string[]> data, bool showSource, List<string>? sources, string modeName)
{
_viewHeaders = headers;
_viewData = data;
_viewShowSource = showSource;
_viewSources = sources;
_viewTotal = data.Count;
_modeName = modeName;
ReRender();
}
private void ReRender()
{
string q = (_tbFilter.Text ?? "").Trim().ToLowerInvariant();
var tokens = q.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries).Distinct().ToArray();
var filtered = new List<string[]>();
List<string>? filteredSources = _viewSources is null ? null : new List<string>();
for (int i = 0; i < _viewData.Count; i++)
{
var row = _viewData[i];
bool ok = true;
foreach (var t in tokens)
{
bool hit = false;
for (int c = 0; c < row.Length; c++)
{
if ((row[c] ?? "").ToLowerInvariant().Contains(t)) { hit = true; break; }
}
if (!hit && _viewShowSource && _viewSources is not null && i < _viewSources.Count &&
_viewSources[i].ToLowerInvariant().Contains(t))
hit = true;
if (!hit) { ok = false; break; }
}
if (!ok) continue;
filtered.Add(row);
filteredSources?.Add(_viewSources is null ? "" : _viewSources[i]);
}
string highlight = q.Length == 0 || filtered.Count == _viewTotal ? "" : $" — показано {filtered.Count} из {_viewTotal}";
string cap = _viewTotal > DisplayCap ? $" (показаны первые {DisplayCap} из {_viewTotal})" : "";
_lblSummary.Text = $"{_modeName}: {_viewTotal} строк{highlight}{cap}";
_lblSummary.Foreground = Ui.Accent;
_lblSummary.FontWeight = FontWeight.SemiBold;
_lblSummary.TextAlignment = TextAlignment.Left;
_lblSummary.HorizontalAlignment = HorizontalAlignment.Left;
RenderGrid(_viewHeaders, filtered, _viewShowSource, filteredSources ?? _viewSources);
}
private void RenderGrid(List<string> headers, List<string[]> data, bool showSource, List<string>? sources)
{
var host = new StackPanel { Orientation = Orientation.Vertical };
int colCount = headers.Count;
var widths = new double[colCount];
for (int c = 0; c < colCount; c++)
{
int maxLen = headers[c].Length;
for (int i = 0; i < Math.Min(data.Count, 300); i++)
{
var row = data[i];
if (c < row.Length && row[c] is not null) maxLen = Math.Max(maxLen, row[c].Length);
}
widths[c] = Math.Clamp(maxLen * 8.2 + 16, 70, 340);
}
var header = new StackPanel { Orientation = Orientation.Horizontal, Background = Ui.HeaderDark };
if (showSource)
{
header.Children.Add(CellHeader("Источник", 80));
}
for (int c = 0; c < colCount; c++)
{
header.Children.Add(CellHeader(headers[c], widths[c]));
}
host.Children.Add(header);
var grad = new LinearGradientBrush
{
StartPoint = new RelativePoint(0, 0.5, RelativeUnit.Relative),
EndPoint = new RelativePoint(1, 0.5, RelativeUnit.Relative)
};
grad.GradientStops.Add(new GradientStop(Ui.Accent.Color, 0));
grad.GradientStops.Add(new GradientStop(Ui.Magenta.Color, 1));
host.Children.Add(new Border { Height = 2, Background = grad });
int shownCount = Math.Min(data.Count, DisplayCap);
for (int i = 0; i < shownCount; i++)
{
var rowPanel = new StackPanel { Orientation = Orientation.Horizontal };
if (i % 2 == 1) rowPanel.Background = Ui.RowAlt;
if (showSource && sources is not null)
{
rowPanel.Children.Add(CellText(i < sources.Count ? sources[i] : "", 80, true));
}
var row = data[i];
for (int c = 0; c < colCount; c++)
{
rowPanel.Children.Add(CellText(c < row.Length ? row[c] : "", widths[c], false));
}
host.Children.Add(rowPanel);
}
if (data.Count == 0)
{
bool filteredNothing = (_tbFilter.Text ?? "").Trim().Length > 0;
host.Children.Add(new TextBlock
{
Text = filteredNothing
? "Ничего не найдено по заданному фильтру (данные не менялись)."
: "Нет данных по выбранному критерию.",
Margin = new Thickness(6, 14, 0, 4),
Foreground = Ui.Muted
});
}
_resultScroll.Content = host;
}
private static TextBlock CellHeader(string text, double width) =>
new()
{
Text = text,
Width = width,
FontWeight = FontWeight.SemiBold,
Foreground = Ui.Accent,
TextTrimming = TextTrimming.CharacterEllipsis,
Padding = new Thickness(6, 8, 6, 8)
};
private static TextBlock CellText(string text, double width, bool bold)
{
var tb = new TextBlock
{
Text = string.IsNullOrEmpty(text) ? " " : text,
Width = width,
TextTrimming = TextTrimming.CharacterEllipsis,
Padding = new Thickness(6, 4, 6, 4),
Foreground = Ui.Text,
FontWeight = bold ? FontWeight.SemiBold : FontWeight.Normal
};
ToolTip.SetTip(tb, text);
return tb;
}
private async System.Threading.Tasks.Task SaveResult()
{
if (_lastResult is null || _mainTable is null || _secondaryTable is null)
{
SetStatus("Сначала выполните сопоставление.", true);
return;
}
var top = TopLevel.GetTopLevel(this);
if (top is null) return;
var file = await top.StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
{
Title = "Сохранить результат сопоставления",
SuggestedFileName = "результат_сопоставления.xlsx",
DefaultExtension = "xlsx",
ShowOverwritePrompt = true,
FileTypeChoices = new[]
{
new FilePickerFileType("Excel (.xlsx)") { Patterns = new[] { "*.xlsx" } }
}
});
if (file is null) return;
try
{
var path = file.Path.LocalPath;
ExcelExporter.ExportAll(path, _mainTable, _secondaryTable, _lastResult, _merged);
SetStatus($"Сохранено: {path}", false);
}
catch (Exception ex)
{
SetStatus($"Ошибка сохранения: {ex.Message}", true);
}
}
private void ShowError(string message)
{
_lblSummary.Text = message;
_lblSummary.FontWeight = FontWeight.SemiBold;
_lblSummary.Foreground = Ui.Danger;
SetStatus(message, true);
}
private void UpdateSaveButton()
{
_btnSave.Background = _btnSave.IsEnabled ? Ui.BtnHover : Ui.DisabledBg;
_btnSave.Foreground = _btnSave.IsEnabled ? Ui.Accent : Ui.DisabledFg;
_btnSave.BorderBrush = _btnSave.IsEnabled ? Ui.Accent : Ui.DisabledBorder;
_btnSave.BorderThickness = new Thickness(1.5);
}
private void SetStatus(string message, bool isError)
{
_lblStatus.Text = message;
_lblStatus.Foreground = isError ? Ui.Danger : Ui.Success;
}
}