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 _accentGradients = new(); private readonly List _groupCards = new(); private Border? _headerCard; private List _tablesA = new(); private List _tablesB = new(); private List _colCheckA = new(); private List _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 _viewHeaders = new(); private List _viewData = new(); private bool _viewShowSource; private List? _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 tables) { cb.ItemsSource = tables.Select(t => $"{t.SheetName} ({t.RowCount} строк)").ToList(); cb.SelectedIndex = 0; } private void PopulateColumns(List tables, ComboBox cb, StackPanel colsPanel, ref List checks) { checks = new List(); 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 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? ParseSynonyms() { Dictionary? 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(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(); 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(); 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 headers; List data = new(); List? 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(main.Columns); data = _merged is not null ? new List(_merged.Rows) : new List(); sources = new List(); 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 headers, List data, bool showSource, List? 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(); List? filteredSources = _viewSources is null ? null : new List(); 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 headers, List data, bool showSource, List? 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; } }