1342 lines
52 KiB
C#
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;
|
|
}
|
|
}
|