diff --git a/GSS2/ViewModels/AmineContent/SampleCalibrationViewModel.cs b/GSS2/ViewModels/AmineContent/SampleCalibrationViewModel.cs index 6fbb1ac..c3fbdf6 100644 --- a/GSS2/ViewModels/AmineContent/SampleCalibrationViewModel.cs +++ b/GSS2/ViewModels/AmineContent/SampleCalibrationViewModel.cs @@ -1,3 +1,4 @@ +using System.Data; using System.Collections.ObjectModel; using Microsoft.EntityFrameworkCore; @@ -8,9 +9,10 @@ using Avalonia.Threading; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; -using GSS2.Core; using GSS2.Core.Analysis.AmineContent; -using GSS2.Core.Analysis.AmineContent.Database.Calibration; +using GSS2.Core.Analysis.AmineContent.Database; +using GSS2.Core.Extensions; + using GSS2.UI.Core.ViewModels; using GSS2.UI.Core.Services; @@ -18,294 +20,711 @@ namespace GSS2.ViewModels.AmineContent; public partial class SampleCalibrationViewModel : ViewModelBase { + /// + /// Внутренняя модель элементов меню + /// + public partial class MenuItemViewModel : ViewModelBase + { + [ObservableProperty] public partial int Id { get; set; } + [ObservableProperty] public partial string SampleName { get; set; } + + public MenuItemViewModel(int id, string sampleName) + { + Id = id; + SampleName = sampleName; + } + } + + /// + /// Внутренняя модель элементов выпадающего списка марок + /// + public partial class BrandComboBoxItemViewModel : ViewModelBase + { + [ObservableProperty] public partial int Id { get; set; } + [ObservableProperty] public partial string BrandName { get; set; } + [ObservableProperty] public partial string MixtureName { get; set; } + + public BrandComboBoxItemViewModel(int id, string brandName, string mixtureName) + { + Id = id; + BrandName = brandName; + MixtureName = mixtureName; + + } + } + + /// + /// Логгер + /// private readonly ILogger _logger; - private readonly CalibrationContext _context; - private readonly ImageCapturerService _imageCapturer; - private readonly ImageStorageService _imageStorage; + /// + /// Контекст базы данных + /// + private readonly Context _context; + /// + /// Сервис для получения изображений с камеры + /// + private readonly IImageCapturerService _imageCapturer; + /// + /// ViewLocator для получения других View по их моделям + /// private readonly DependencyInjectionViewLocator _viewLocator; + /// + /// Сервис навигации между экранами + /// private readonly NavigationService _navigation; + /// + /// Задача съёмки изображений + /// + private Task? _сaptureImagesTask = null; + /// + /// Токен отмены задачи съёмки изображений + /// private CancellationTokenSource? _captureImagesTaskCts = null; + /// + /// выбранного элемента + /// + private int _selectedBrandId = 0; + /// + /// Данные выбранного элемента + /// + private string? _selectedImageData = null; + /// + /// Данные выбранного элемента + /// + private List _selectedFeaturesData = new(); + + /// + /// Меню открыто + /// [ObservableProperty] public partial bool MenuOpened { get; set; } = false; - [ObservableProperty] public partial ObservableCollection Records { get; set; } + /// + /// Элементы меню + /// + [ObservableProperty] public partial ObservableCollection MenuItems { get; set; } = new(); + /// + /// Текущий выбранный элемент меню. Равен -1 если элемент не выбран + /// + [ObservableProperty] public partial int MenuSelectedIndex { get; set; } = -1; - [ObservableProperty] public partial SampleRecord? SelectedRecord { get; set; } - [ObservableProperty] public partial SampleRecord EditingRecord { get; set; } + /// + /// выбранного элемента + /// + [ObservableProperty] public partial int SelectedId { get; set; } = 0; + /// + /// выбранного элемента + /// + [ObservableProperty] public partial string SelectedUuid { get; set; } = Guid.NewGuid().ToString(); + /// + /// выбранного элемента + /// + [ObservableProperty] public partial string SelectedSampleName { get; set; } = ""; + /// + /// выбранного элемента + /// + [ObservableProperty] public partial string SelectedSampleComment { get; set; } = ""; + /// + /// выбранного элемента + /// + [ObservableProperty] public partial DateTime SelectedSamplingDateTime { get; set; } = DateTime.UtcNow; + /// + /// выбранного элемента + /// + [ObservableProperty] public partial string SelectedSamplingPlace { get; set; } = ""; + /// + /// выбранного элемента + /// + [ObservableProperty] public partial double SelectedMixtureActualRate { get; set; } = -1; + /// + /// выбранного элемента + /// + [ObservableProperty] public partial double SelectedMeasuredContent { get; set; } = -1; + /// + /// Представление выбранного элемента + /// + [ObservableProperty] public partial AsyncImageRecordViewModel SelectedImage { get; set; } = new(); + /// + /// Список элементов выпадающего списка марок + /// + [ObservableProperty] public partial ObservableCollection BrandComboBoxItems { get; set; } = new(); + /// + /// Текущий выбранный элемент выпадающего списка марок. Равен -1 если элемент не выбран + /// + [ObservableProperty] public partial int BrandComboBoxSelectedIndex { get; set; } = -1; + + /// + /// Текущий экран главной секции + /// [ObservableProperty] public partial int MainSectionCurrentIndex { get; set; } = 0; + /// + /// Можно ли переходить на следующий экран главной секции + /// [ObservableProperty] public partial bool MainSectionCanScrollBackward { get; set; } = false; - [ObservableProperty] public partial bool MainSectionCanScrollForward { get; set; } = true; - [ObservableProperty] public partial bool MainSectionIsScrolling { get; set; } = true; + /// + /// Можно ли переходить на предыдущий экран главной секции + /// + [ObservableProperty] public partial bool MainSectionCanScrollForward { get; set; } = false; + /// + /// Происходит ли переход между экранами главной секции + /// + [ObservableProperty] public partial bool MainSectionIsScrolling { get; set; } = false; + + /// + /// Текущий экран информационной секции + /// [ObservableProperty] public partial int InfoSectionCurrentIndex { get; set; } = 0; + /// + /// Можно ли переходить на следующий экран информационной секции + /// [ObservableProperty] public partial bool InfoSectionCanScrollBackward { get; set; } = false; - [ObservableProperty] public partial bool InfoSectionCanScrollForward { get; set; } = true; - [ObservableProperty] public partial bool InfoSectionIsScrolling { get; set; } = true; - - [ObservableProperty] public partial Task? CaptureImagesTask { get; set; } = null; + /// + /// Можно ли переходить на предыдущий экран информационной секции + /// + [ObservableProperty] public partial bool InfoSectionCanScrollForward { get; set; } = false; + /// + /// Происходит ли переход между экранами информационной секции + /// + [ObservableProperty] public partial bool InfoSectionIsScrolling { get; set; } = false; + /// + /// Проводится ли съёмка изображений + /// [ObservableProperty] public partial bool ImagesIsCapturing { get; set; } = false; + /// + /// Прогресс съёмки изображений + /// [ObservableProperty] public partial string ImagesCapturingProgress { get; set; } = ""; - [ObservableProperty] public partial AsyncImageRecordViewModel? Image { get; set; } = null; - + /// + /// Можно ли производить перемещение между экранами + /// + [ObservableProperty] public partial bool CanScroll { get; set; } public SampleCalibrationViewModel( ILogger logger, - CalibrationContext context, - ImageCapturerService imageCapturer, - ImageStorageService imageStorage, + Context context, + IImageCapturerService imageCapturer, DependencyInjectionViewLocator viewLocator, NavigationService navigation ) { + // Устанавливаем readonly поля _logger = logger; _context = context; _imageCapturer = imageCapturer; - _imageStorage = imageStorage; _viewLocator = viewLocator; _navigation = navigation; _logger.LogInformation("Инициализация"); - // Получаем записи из базы данных - Records = new ObservableCollection(_context.SampleRecords); + // Получаем записи меню из базы данных + MenuItems = new ObservableCollection(_context.SampleRecords.Select(r => new MenuItemViewModel(r.Id, r.SampleName))); + if (MenuItems.Count() > 0) // Если записи есть, + MenuSelectedIndex = 0; // Выбираем первую запись + else // Если записей нет, + MenuSelectedIndex = -1; // Устанавливаем, что запись не выбрана - // Берём первую запись - SelectedRecord = Records.FirstOrDefault(); - // Если записей нет, то создаём пустую - // на самом деле это излишне, потому что при изменении SelectedRecord - // вызовется OnSelectedRecordChanged который сделает то же самое, но сделано это для избавления от предупреждений - if (SelectedRecord is null) - EditingRecord = new() + // Получаем записи выпадающего списка марок из базы данных + BrandComboBoxItems = new ObservableCollection(_context.BrandRecords.Select(r => new BrandComboBoxItemViewModel(r.Id, r.BrandName, r.MixtureName))); + // Подписываемся на изменения базы данных для отслеживания изменений списка марок + _context.ChangeTracker.StateChanged += (_, ea) => + { + // Если запись не интересует нас, то ничего не делаем + if (ea.Entry.Entity is not BrandRecord brandRecord) + return; + + // Если запись добавлена + if (ea.NewState is EntityState.Added) { - SampleName = "", - SampleComment = "", - SampleBrand = "", - SampleMass = -1.0, - SamplingDateTime = DateTime.UtcNow, - SamplingPlace = "", - MixtureName = "", - MixtureAmineContent = -1.0, - MixtureNormalRate = -1.0, - MixtureActualRate = -1.0, - MeasuredContent = -1.0, - Image = "", - VectorRecords = new() - }; - else - EditingRecord = CopyRecord(SelectedRecord); + // Добавляем запись + BrandComboBoxItems.Add(new BrandComboBoxItemViewModel(brandRecord.Id, brandRecord.BrandName, brandRecord.MixtureName)); + } + // Если запись удалена + else if (ea.NewState is EntityState.Deleted) + { + // Ищем удалённую запись в выпадающем списке марок + var deletedComboBoxItem = BrandComboBoxItems.FirstOrDefault(r => r.Id == brandRecord.Id); + // Если запись не найдена в выпадающем списке марок, то логгируем как ошибку + if (deletedComboBoxItem is null) + { + _logger.LogError("Не удалось найти элемент с Id({}) в списке {}", brandRecord.Id, nameof(BrandComboBoxItems)); + return; + } + // Берём индекс удалённого элемента в выпадающем списке марок + var deletedComboBoxIndex = BrandComboBoxItems.IndexOf(deletedComboBoxItem); + // Если текущий индекс равен удаляемому, то устанавливаем, что элемент не выбран + if (BrandComboBoxSelectedIndex == deletedComboBoxIndex) + BrandComboBoxSelectedIndex = -1; + // Удаляем элемент из выпадающего списка марок + BrandComboBoxItems.RemoveAt(deletedComboBoxIndex); + } + // Если запись изменилась + else if (ea.NewState is EntityState.Modified) + { + // Ищем изменённую запись в выпадающем списке марок + var editedComboBoxItem = BrandComboBoxItems.FirstOrDefault(r => r.Id == brandRecord.Id); + // Если запись не найдена в выпадающем списке марок, то логгируем как ошибку + if (editedComboBoxItem is null) + { + _logger.LogError("Не удалось найти элемент с Id({}) в списке {}", brandRecord.Id, nameof(BrandComboBoxItems)); + return; + } + // Обновляем элемент из выпадающего списка марок + editedComboBoxItem.BrandName = brandRecord.BrandName; + editedComboBoxItem.MixtureName = brandRecord.MixtureName; + } + }; + + // Подписываемся на изменения свойств для обновления CanScroll + PropertyChanged += (_, ea) => + { + if (ea.PropertyName == nameof(MenuOpened) || + ea.PropertyName == nameof(MainSectionIsScrolling) || + ea.PropertyName == nameof(InfoSectionIsScrolling)) + UpdateCanScroll(); + }; _logger.LogInformation("Инициализировано"); } /// - /// Вызывается при изменении .
- /// Копирует в или издаёт пустую . + /// Вызывается при изменении ///
- partial void OnSelectedRecordChanged(SampleRecord? value) + partial void OnMenuSelectedIndexChanged(int value) { - // Если выбранная запись отсутствует (в базе данных нет записей или создана новая запись) - // то создаём пустую запись - if (SelectedRecord is null) + // Если значение выходит за верхнюю границу списка элементов меню, то логгируем как ошибку + if (value >= MenuItems.Count()) + _logger.LogError("Значение {}({}) больше количества элементов {}({})", nameof(MenuSelectedIndex), MenuSelectedIndex, nameof(MenuItems), MenuItems.Count()); + // Если значение больше или равно 0, то значит выбран один из элементов меню + if (value >= 0 && value < MenuItems.Count()) { - EditingRecord = new() + _logger.LogTrace("Выбран элемент из списка: {}", value); + // Находим запись в базе данных + var selectedRecord = _context.SampleRecords.Find(MenuItems[value].Id); + // Если запись найдена, то заполняем поля из базы данных + if (selectedRecord is not null) { - SampleName = "", - SampleComment = "", - SampleBrand = "", - SampleMass = -1.0, - SamplingDateTime = DateTime.UtcNow, - SamplingPlace = "", - MixtureName = "", - MixtureAmineContent = -1.0, - MixtureNormalRate = -1.0, - MixtureActualRate = -1.0, - MeasuredContent = -1.0, - Image = "", - VectorRecords = new() - }; + // Заполняем простые поля + SelectedId = selectedRecord.Id; + SelectedUuid = selectedRecord.Uuid; + SelectedSampleName = selectedRecord.SampleName; + SelectedSampleComment = selectedRecord.SampleComment; + SelectedSamplingDateTime = selectedRecord.SamplingDateTime; + SelectedSamplingPlace = selectedRecord.SamplingPlace; + SelectedMixtureActualRate = selectedRecord.MixtureActualRate; + SelectedMeasuredContent = selectedRecord.MeasuredContent; + _selectedBrandId = selectedRecord.BrandId; + + // Ищем изображение в базе данных + var imageRecord = _context.ImageRecords.Find(selectedRecord.ImageId); + // Если изображение не найдено в базе данных, то логгируем как ошибку + if (imageRecord is null) + _logger.LogError("Не удалось найти элемент с Id({}) в таблице {}", selectedRecord.ImageId, nameof(_context.ImageRecords)); + // В любом случае заполняем изображение, оно заполнится null если запись не найдена + _selectedImageData = imageRecord?.Base64; + SelectedImage.Data = _selectedImageData; + + // Ищем марку в базе данных + var brandRecord = _context.BrandRecords.Find(_selectedBrandId); + // Если марка не найдена в базе данных + if (brandRecord is null) + { + // Логгируем как ошибку + _logger.LogError("Не удалось найти элемент с Id({}) в таблице {}", _selectedBrandId, nameof(_context.BrandRecords)); + // Устанавливаем, что элемент не выбран + BrandComboBoxSelectedIndex = -1; + } + // Если марка найдена в базе данных + else + { + // Ищем марку в выпадающем списке марок + var brandComboBoxItem = BrandComboBoxItems.FirstOrDefault(r => r.Id == brandRecord.Id); + // Если марка не найдена в выпадающем списке марок, то логгируем как ошибку + if (brandComboBoxItem is null) + _logger.LogError("Не удалось найти элемент с Id({}) в списке {}", brandRecord.Id, nameof(BrandComboBoxItems)); + // Если марка найдена в выпадающем списке марок, выбираем её + else + BrandComboBoxSelectedIndex = BrandComboBoxItems.IndexOf(brandComboBoxItem); + } + + // Ищем все признаки в базе данных + _selectedFeaturesData = _context.FeatureRecords + .Where(r => r.SampleRecordId == SelectedId) + .Select(r => r.Values.ToArray()) + .ToList(); + + return; + } + // Если значение не найдено в базе данных + else + { + // Логгируем как ошибку + _logger.LogError("Не удалось найти элемент с Id({}) в таблице {}", MenuItems[value].Id, nameof(_context.BrandRecords)); + var nonExistingIndex = MenuSelectedIndex; // Сохраняем несуществующий индекс + // Устанавливаем, что запись не выбрана, этот метод вызовется повторно и заполнит поля пустыми значениями + MenuSelectedIndex = -1; + // Удаляем несуществующий элемент из списка + MenuItems.RemoveAt(nonExistingIndex); + return; + } } + + // Если элемент не выбран, заполняем поля пустыми значениями + _logger.LogTrace("Выбран новый элемент"); + SelectedId = 0; + SelectedUuid = Guid.NewGuid().ToString(); + SelectedSampleName = ""; + SelectedSampleComment = ""; + SelectedSamplingDateTime = DateTime.UtcNow; + SelectedSamplingPlace = ""; + SelectedMixtureActualRate = -1; + SelectedMeasuredContent = -1; + SelectedImage.Data = null; + BrandComboBoxSelectedIndex = -1; + } + + /// + /// Вызывается при изменении + /// + partial void OnBrandComboBoxSelectedIndexChanged(int value) + { + if (value >= 0 && value < BrandComboBoxItems.Count()) + _selectedBrandId = BrandComboBoxItems[value].Id; + } + + /// + /// Обновить значение + /// + private void UpdateCanScroll() + { + CanScroll = !MenuOpened; + CanScroll &= !MainSectionIsScrolling; + CanScroll &= !InfoSectionIsScrolling; + CanScroll &= !ImagesIsCapturing; + } + + // Команды управления меню + /// + /// Открыть меню + /// + [RelayCommand] private void OpenMenu() => MenuOpened = true; + /// + /// Закрыть меню + /// + [RelayCommand] private void CloseMenu() => MenuOpened = false; + /// + /// Переключить видимость меню + /// + [RelayCommand] private void ToggleMenu() => MenuOpened = !MenuOpened; + + /// + /// Переключить главную секцию информация <-> изображения + /// + [RelayCommand] private void SwitchMainSection() => MainSectionCurrentIndex = MainSectionCurrentIndex == 0 ? 1 : 0; + /// + /// Переключить информационную секцию на следующую + /// + [RelayCommand] private void InfoSectionPrevious() => InfoSectionCurrentIndex--; + /// + /// Переключить информационную секцию на предыдущую + /// + [RelayCommand] private void InfoSectionNext() => InfoSectionCurrentIndex++; + + /// + /// Создать новую запись + /// + [RelayCommand] + private void CreateRecord() + { + _logger.LogInformation("Создание новой записи"); + // Если уже создана новая запись, то MenuSelectedIndex может быть равен -1 и + // при простом присвоении MenuSelectedIndex = -1 событие OnMenuSelectedIndexChanged не вызовется, + // потому что событие срабатывает только при изменении значения, а значение было -1 и привилось ему -1, + // поэтому если SelectedRecord != -1, то нужно явно вызвать метод OnMenuSelectedIndexChanged + if (MenuSelectedIndex != -1) + MenuSelectedIndex = -1; else + OnMenuSelectedIndexChanged(-1); + } + + /// + /// Удалить выбранную запись из базы данных или очистить значения, если запись не выбрана + /// + [RelayCommand] + private void DeleteRecord() + { + // TODO: Добавить определение и отображение количества затрагиваемых, записей которые будут повреждены при удалении этой записи + try { - // Иначе копируем запись, отвязывая её от базы данных - EditingRecord = CopyRecord(SelectedRecord); + _logger.LogTrace("Удаление записи: {}={}, {}={}", nameof(SampleRecord.Id), SelectedId, nameof(SampleRecord.SampleName), SelectedSampleName); + // Если запись не выбрана, то просто создаём новую запись + if (MenuSelectedIndex < 0) + { + _logger.LogTrace("Запись не выбрана, создаём новую"); + CreateRecord(); + return; + } + + // Создаём модель для экрана подтверждения + var confirmViewModel = new DeleteConfirmViewModel(_navigation) + { + Question = "Вы уверены, что хотите удалить запись?" + }; + + // Создаём коллбек который вызовется при экрана подтверждения + void OnConfirmCloses(object? sender, bool result) + { + // Если пользователь отменил удаление, то выходим из коллбека + if (!result) + { + _logger.LogTrace("Пользователь отменил удаление"); + return; + } + + // Если запись не выбрана, то просто создаём новую запись + if (MenuSelectedIndex < 0) + { + _logger.LogTrace("Запись не выбрана, создаём новую"); + CreateRecord(); + return; + } + + // Сохраняем индекс удалённый записи и находим её в базе данных + var deletedMenuIndex = MenuSelectedIndex; + var deletedRecordId = SelectedId; + var deletedRecord = _context.SampleRecords.Find(deletedRecordId); + // Создаём новую запись + CreateRecord(); + + // Удаляем запись из списка + MenuItems.RemoveAt(deletedMenuIndex); + // Если удалённая запись не найдена в базе данных, то логгируем как ошибку + if (deletedRecord is null) + { + _logger.LogError("Не удалось найти элемент с Id({}) в таблице {}", deletedRecordId, nameof(_context.BrandRecords)); + return; + } + + // Удаляем запись из базы данных и сохраняем изменения в базе данных + _context.SampleRecords.Remove(deletedRecord); + _context.SaveChanges(); + _logger.LogTrace("Запись удалена"); + + // Отписываемся от события закрытия экрана подтверждения + confirmViewModel.Closes -= OnConfirmCloses; + } + + // Создаём View экрана подтверждения + var confirmView = _viewLocator.Build(confirmViewModel); + // Если View не найден, то логгируем как ошибку + if (confirmView is null) + { + _logger.LogError("Не удалось найти View для экрана подтверждения"); + return; + } + + _logger.LogTrace("Задаём вопрос"); + // Подписываемся на событие закрытия экрана подтверждения + confirmViewModel.Closes += OnConfirmCloses; + // Преходим на экран подтверждения, при закрытии он сам вернёт экран обратно на текущий + _navigation.NavigateTo(confirmView); + } + catch (Exception ex) + { + _logger.LogError(ex, "Исключение при удалении записи"); } } /// - /// Вызывается при изменении .
- /// Изменяет изображением из . + /// Сохранить текущие значения в новую запись или перезаписать значения в выдранной записи ///
- partial void OnEditingRecordChanged(SampleRecord value) - { - var imagePath = _imageStorage.GetFullPath(value.Image, "calibration-sample"); - if (!File.Exists(imagePath)) - { - Image = null; - return; - } - - if (Image is null) - Image = new AsyncImageRecordViewModel(imagePath); - else - Image.Path = imagePath; - } - - // Просто команды управления меню - [RelayCommand] private void OpenMenu() => MenuOpened = true; - [RelayCommand] private void CloseMenu() => MenuOpened = false; - [RelayCommand] private void ToggleMenu() => MenuOpened = !MenuOpened; - - // Команда создания новой записи - // вызывает цепочку действий по изменению EditingRecord и Images - [RelayCommand] - private void CreateRecord() - { - // Если уже создана новая запись, то SelectedRecord == null и - // при простом присвоении SelectedRecord = null событие SelectedRecordChanged не вызовется, - // потому что событие срабатывает только при изменении значения, а значение было null и присвоилось ему null, - // поэтому если SelectedRecord == null, то нужно явно вызвать метод OnSelectedRecordChanged - if (SelectedRecord is not null) - SelectedRecord = null; - else - OnSelectedRecordChanged(null); - } - - // Команда удаления записи, создаёт новую запись и удаляет старую из списка и базы данных - [RelayCommand] - private void DeleteRecord() - { - var confirmViewModel = new DeleteConfirmViewModel(_navigation) - { - Question = "Вы уверены, что хотиите удаить запись?" - }; - if (SelectedRecord is null) - { - CreateRecord(); - return; - } - confirmViewModel.Closes += (_, ea) => - { - if (ea != true) - return; - - // Сохраняем текущую запись - var deletedRecord = SelectedRecord; - // Создаём новую запись - CreateRecord(); - - // Если запись на была сохранена в базу данных, то ничего не делаем - if (deletedRecord is null) - return; - - // Если запись была в без данных, то - // она была и в Records, удаляем её из Records - Records.Remove(deletedRecord); - // Удаляем запись из базы данных и сохраняем изменения - // TODO: Подумать над тем, что возможно стоит не удалять запись полностью, а добавить флаг удаления, - // но тогда нужно будет сохранять и изображения этой записи, как в базе данных, так и на диске, - // тогда удалённые записи будут заполнять диск - // TODO: Подумать над тем, чтобы сразу удалять изображения из хранилища - _context.SampleRecords.Remove(deletedRecord); - _context.SaveChanges(); - }; - - var convfirmView = _viewLocator.Build(confirmViewModel); - if (convfirmView is not null) - _navigation.NavigateTo(convfirmView); - } - - // // Команда сохранения записи, сохраняет новую запись или изменения в отредактированной [RelayCommand] private void SaveRecord() { - // Если это новая запись - if (SelectedRecord is null) + try { - // Копируем её - var newRecord = CopyRecord(EditingRecord); - - // Для каждого вектора - for (int i = 0; i < newRecord.VectorRecords.Count(); i++) + _logger.LogTrace("Сохранение записи: {}={}, {}={}", nameof(SampleRecord.Id), SelectedId, nameof(SampleRecord.SampleName), SelectedSampleName); + // Если запись не выбрана + if (MenuSelectedIndex < 0) { - var vector = newRecord.VectorRecords[i]; + // Создаём запись изображения + _logger.LogTrace("Создаём новую запись изображения"); + // Заполняем запись изображения + var imageRecord = new ImageRecord(base64: _selectedImageData ?? ""); + // Добавляем запись изображения в базу данных + var attachedImageRecord = _context.ImageRecords.Attach(imageRecord); + // Сохраняем изменения в базе данных + _context.SaveChanges(); + _logger.LogTrace("Запись изображения сохранена"); - // Добавляем вектор в базу - var attachedRecord = _context.VectorRecords.Add(vector); - // Удаляем отвязанный от базы данных вектор из записи - newRecord.VectorRecords.RemoveAt(i); - // Добавляем привязанный к базе данных вектор в запись - newRecord.VectorRecords.Insert(i, attachedRecord.Entity); - } - - { - // То добавляем запись в базу + _logger.LogTrace("Создаём новую запись"); + // Заполняем запись + var newRecord = new SampleRecord( + uuid: SelectedUuid, + sampleName: SelectedSampleName, + sampleComment: SelectedSampleComment, + samplingDateTime: SelectedSamplingDateTime, + brandId: _selectedBrandId, + samplingPlace: SelectedSamplingPlace, + mixtureActualRate: SelectedMixtureActualRate, + measuredContent: SelectedMeasuredContent, + imageId: attachedImageRecord.Entity.Id, + featureIds: [] + ); + // Добавляем запись в базу данных var attachedRecord = _context.SampleRecords.Add(newRecord); - // Заменяем текущую запись на привязанную к базе данных - newRecord = attachedRecord.Entity; + // Сохраняем изменения в базе данных + _context.SaveChanges(); + _logger.LogTrace("Запись сохранена"); + + _logger.LogTrace("Создаём новые записи признаков"); + // Заполняем записи признаков и добавляем их в базу данных + var attachedFeatureRecords = new List(); + foreach (var feature in _selectedFeaturesData) + { + var featureRecord = new FeatureRecord( + values: feature, + sampleRecordId: attachedRecord.Entity.Id + ); + var attachedFeatureRecord = _context.FeatureRecords.Attach(featureRecord); + attachedFeatureRecords.Add(attachedFeatureRecord.Entity); + } + // Сохраняем изменения в базе данных + _context.SaveChanges(); + _logger.LogTrace("Записи признаков сохранены"); + + _logger.LogTrace("Заполняем признаки в сохранённой записи"); + // Добавляем идентификаторы признаков в запись + attachedRecord.Entity.FeatureIds.AddRange(attachedFeatureRecords.Select(r => r.Id)); + // Сохраняем изменения в базе данных + _context.SaveChanges(); + _logger.LogTrace("Сохранение записи завершено"); + + // Добавляем новый элемент в меню + MenuItems.Add(new MenuItemViewModel(attachedRecord.Entity.Id, attachedRecord.Entity.SampleName)); + // Выбираем последний элемент из меню + MenuSelectedIndex = MenuItems.Count() - 1; } - - // Сохраняем изменения в базе данных - _context.SaveChanges(); - // Добавляем запись в список - Records.Add(newRecord); - // Выбираем запись - SelectedRecord = newRecord; - } - // Если эта запись есть в базе - else - { - // Переносим данные из редактируемой записи в выбранную - SelectedRecord.SampleName = EditingRecord.SampleName; - SelectedRecord.SampleComment = EditingRecord.SampleComment; - SelectedRecord.SampleBrand = EditingRecord.SampleBrand; - SelectedRecord.SampleMass = EditingRecord.SampleMass; - SelectedRecord.SamplingDateTime = EditingRecord.SamplingDateTime; - SelectedRecord.SamplingPlace = EditingRecord.SamplingPlace; - SelectedRecord.MixtureName = EditingRecord.MixtureName; - SelectedRecord.MixtureAmineContent = EditingRecord.MixtureAmineContent; - SelectedRecord.MixtureNormalRate = EditingRecord.MixtureNormalRate; - SelectedRecord.MixtureActualRate = EditingRecord.MixtureActualRate; - SelectedRecord.MeasuredContent = EditingRecord.MeasuredContent; - SelectedRecord.Image = EditingRecord.Image; - SelectedRecord.VectorRecords = EditingRecord.VectorRecords; - - // Для каждого вектора в выбранной записи - for (int i = 0; i < SelectedRecord.VectorRecords.Count(); i++) + // Если запись выбрана + else { - var vector = SelectedRecord.VectorRecords[i]; - // Если вектор есть в базе данных, то пропускаем его - if (_context.Entry(vector).State is not EntityState.Detached) - continue; + // Находим запись в базе данных + var attachedRecord = _context.SampleRecords.Find(SelectedId); + // Если запись не найдена, то логгируем как ошибку + if (attachedRecord is null) + { + _logger.LogError("Не удалось найти элемент с Id({}) в таблице {}", SelectedId, nameof(_context.SampleRecords)); + return; + } - // Если вектора нет в базе данных, то добавляем его в базу - var attachedRecord = _context.VectorRecords.Add(vector); - // Удаляем отвязанный от базы данных вектор из записи - SelectedRecord.VectorRecords.RemoveAt(i); - // Добавляем привязанный вектор к записи - SelectedRecord.VectorRecords.Insert(i, attachedRecord.Entity); + _logger.LogTrace("Записываем изменения в базу данных"); + + // Ищем запись изображения в базе данных + var attachedImageRecord = _context.ImageRecords.Find(attachedRecord.ImageId); + // Если запись не найдена + if (attachedImageRecord is null) + { + // Логгируем как ошибку + _logger.LogError("Не удалось найти элемент с Id({}) в таблице {}", attachedRecord.ImageId, nameof(_context.ImageRecords)); + _logger.LogTrace("Создаём новую запись изображения"); + // Заполняем запись изображения + var imageRecord = new ImageRecord(base64: _selectedImageData ?? ""); + // Добавляем запись изображения в базу данных + attachedImageRecord = _context.ImageRecords.Attach(imageRecord).Entity; + // Сохраняем изменения в базе данных + _context.SaveChanges(); + _logger.LogTrace("Запись изображения сохранена"); + } + + // Ищем записи признаков в базе данных + var attachedFeatureRecords = _context.FeatureRecords.Where(r => r.SampleRecordId == SelectedId); + // Если количество признаков в базе данных больше чем текущее + if (attachedFeatureRecords.Count() > _selectedFeaturesData.Count()) + { + // Находим количество признаков которое нужно удалить + var featuresRecordsToRemove = attachedFeatureRecords.Count() - _selectedFeaturesData.Count(); + // Логгируем + _logger.LogTrace("Количество записей признаков в базе данных больше чем текущее, удаление лишних записей: {} ({} - {})", featuresRecordsToRemove, attachedFeatureRecords.Count(), _selectedFeaturesData.Count()); + // Удаляем необходимое количество признаков из конца + attachedFeatureRecords.TakeLast(featuresRecordsToRemove); + } + + // Заполняем все имеющиеся признаки в базе данных новыми значениями + foreach (var (i, attachedFeatureRecord) in attachedFeatureRecords.Enumerate()) + attachedFeatureRecord.Values = _selectedFeaturesData[i]; + + // Если количество признаков в безе данных меньше чем текущее + if (attachedFeatureRecords.Count() < _selectedFeaturesData.Count()) + { + // Находим количество признаков которое нужно добавить + var featuresRecordsToAdd = _selectedFeaturesData.Count() - attachedFeatureRecords.Count(); + // Логгируем + _logger.LogTrace("Количество записей признаков в базе данных меньше чем текущее, добавление недостающих записей: {} ({} - {})", featuresRecordsToAdd, _selectedFeaturesData.Count(), attachedFeatureRecords.Count()); + // Заполняем записи недостающих признаков и добавляем их в базу данных + for (var i = attachedFeatureRecords.Count(); i < _selectedFeaturesData.Count(); i++) + { + var featureRecord = new FeatureRecord( + values: _selectedFeaturesData[i], + sampleRecordId: attachedRecord.Id + ); + var attachedFeatureRecord = _context.FeatureRecords.Attach(featureRecord); + attachedFeatureRecords.Append(attachedFeatureRecord.Entity); + } + } + + _context.SaveChanges(); + _logger.LogTrace("Записи признаков сохранена"); + + // Если в данных есть изменения, то записываем их + if (attachedRecord.SampleName != SelectedSampleName) + attachedRecord.SampleName = SelectedSampleName; + if (attachedRecord.SampleComment != SelectedSampleComment) + attachedRecord.SampleComment = SelectedSampleComment; + if (attachedRecord.SamplingDateTime != SelectedSamplingDateTime) + attachedRecord.SamplingDateTime = SelectedSamplingDateTime; + if (attachedRecord.BrandId != _selectedBrandId) + attachedRecord.BrandId = _selectedBrandId; + if (attachedRecord.SamplingPlace != SelectedSamplingPlace) + attachedRecord.SamplingPlace = SelectedSamplingPlace; + if (attachedRecord.MixtureActualRate != SelectedMixtureActualRate) + attachedRecord.MixtureActualRate = SelectedMixtureActualRate; + if (attachedRecord.MeasuredContent != SelectedMeasuredContent) + attachedRecord.MeasuredContent = SelectedMeasuredContent; + if (attachedRecord.ImageId != attachedImageRecord.Id) + attachedRecord.ImageId = attachedImageRecord.Id; + // Для признаков особый случай, они в любом случае будут перезаписаны, потому что так быстрее + attachedRecord.FeatureIds = attachedFeatureRecords.Select(r => r.Id).ToList(); + + // Сохраняем изменения в базе данных + _context.SaveChanges(); + _logger.LogTrace("Запись сохранена"); + + // Записываем изменения в меню + _logger.LogTrace("Записываем изменения в меню"); + MenuItems[MenuSelectedIndex].SampleName = SelectedSampleName; } - - // Сохраняем изменения в базе данных - _context.SaveChanges(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Исключение при сохранении записи"); } } - // Команда изменения основной секции (информация <-> изображения) - [RelayCommand] private void SwitchMainSection() => MainSectionCurrentIndex = MainSectionCurrentIndex == 0 ? 1 : 0; - - // Команда навигации по информации - [RelayCommand] private void InfoSectionPrevious() => InfoSectionCurrentIndex--; - [RelayCommand] private void InfoSectionNext() => InfoSectionCurrentIndex++; - - // Команда съёмки изображений + /// + /// Снять изображения + /// [RelayCommand] private void CaptureImage() { - // Если съёмка уже начата, то ничего не делаем - if (CaptureImagesTask is not null) + // Если съёмка уже начата, то логгируем как ошибку + if (_сaptureImagesTask is not null) + { + _logger.LogError("Задача съёмки уже запущена"); return; + } // Создаём токен отмены задачи _captureImagesTaskCts = new CancellationTokenSource(); @@ -316,8 +735,8 @@ public partial class SampleCalibrationViewModel : ViewModelBase // а изменения данных в интерфейсе могут быть вызваны только из потока в котором интерфейс был создан _ = Dispatcher.UIThread.InvokeAsync(() => ImagesCapturingProgress = $"Получение изображений: 0 / {ImageCapturerService.IlluminatorIntensities.Count()}"); - // Запускаем получение изображений асинхронно - CaptureImagesTask = Task.Run(async () => + // Запускаем задачу получения изображений + _сaptureImagesTask = Task.Run(async () => { // Список полученных изображений var imagesData = new List>(); @@ -327,12 +746,12 @@ public partial class SampleCalibrationViewModel : ViewModelBase // а изменения данных в интерфейсе могут быть вызваны только из потока в котором интерфейс был создан _ = Dispatcher.UIThread.InvokeAsync(() => ImagesIsCapturing = true); - // Получаем изображения + // Создаём задачу получения изображений var captureTask = _imageCapturer.CaptureImages(async (data) => { // Распаковываем данные var (i, visible, uv365, uv254, image) = data; - // Выбрасываем исключение если задача отменена + // Выбрасываем исключение, если задача отменена cancellationToken.ThrowIfCancellationRequested(); // Обновляем данные в интерфейсе @@ -340,142 +759,179 @@ public partial class SampleCalibrationViewModel : ViewModelBase // а изменения данных в интерфейсе могут быть вызваны только из потока в котором интерфейс был создан _ = Dispatcher.UIThread.InvokeAsync(() => ImagesCapturingProgress = $"Получение изображений: {i + 1} / {ImageCapturerService.IlluminatorIntensities.Count()}"); + // Создаём задачу сохранения изображения var imagesDataTask = Task.Run(() => { - // Сохраняем png изображение во временную папку + // Выбрасываем исключение, если задача отменена + cancellationToken.ThrowIfCancellationRequested(); + // Сохраняем PNG изображение во временную папку var path = Path.Join(Path.GetTempPath(), $"gss2-calibration-sample-{visible}-{uv365}-{uv254}.png"); image.ImWrite(path); // Освобождаем память изображения - image.Release(); + image.Dispose(); // Добавляем данные изображения в список return new ImageData(path, visible, uv365, uv254); - }); + }, cancellationToken); + // Добавляем задачу сохранения изображения в список imagesData.Add(imagesDataTask); }, cancellationToken); - // Обрабатываем изображения - var postTask = Task.WhenAll([captureTask, .. imagesData]).ContinueWith(async t => + // Создаём задачу обработки изображений, + // эта задача начнётся только после того как завершится + // задача съёмки изображений и все задачи сохранения изображений + var processTask = Task.WhenAll([captureTask, .. imagesData]).ContinueWith(async t => { + // Выбрасываем исключение, если задача отменена + cancellationToken.ThrowIfCancellationRequested(); + // Обновляем данные в интерфейсе // Dispatcher.UIThread.Invoke нужен потому-что этот метод может (и скорее всего будет) вызван в другом потоке, // а изменения данных в интерфейсе могут быть вызваны только из потока в котором интерфейс был создан _ = Dispatcher.UIThread.InvokeAsync(() => ImagesCapturingProgress = $"Обработка изображений"); - // Проверяем исключения задачи получения изображений - if (t.Exception is not null) + + // Если во время выполнения задачи съёмки изображений произошло исключение, + // то логгируем все исключения и выходим + if (captureTask.Exception is not null) { - _logger.LogError(t.Exception, "Исключение во время получения изображений"); - foreach (var ex in t.Exception.InnerExceptions ?? []) - _logger.LogError(ex, "Исключение во время получения изображений"); + _logger.LogError(captureTask.Exception, "Исключение во время получения изображения"); + foreach (var ex in captureTask.Exception.InnerExceptions ?? []) + _logger.LogError(ex, "Исключение во время получения изображения"); return; } - // Проверяем исключения задач сохранения изображений + + // Если во время выполнения задач сохранения изображений произошло исключение, + // то логгируем все исключения и выходим + bool anyImageDataException = false; foreach (var imageData in imagesData) + { if (imageData.Exception is not null) { - _logger.LogError(imageData.Exception, "Исключение во время получения изображения"); + _logger.LogError(imageData.Exception, "Исключение во время сохранения изображения"); foreach (var ex in imageData.Exception.InnerExceptions ?? []) - _logger.LogError(ex, "Исключение во время получения изображения"); - return; + _logger.LogError(ex, "Исключение во время сохранения изображения"); + anyImageDataException = true; } + } + if (anyImageDataException) + return; + + // Если во время выполнения произошло исключение, + // то логгируем все исключения и выходим + if (t.Exception is not null) + { + _logger.LogError(t.Exception, "Исключение во время обработки изображений"); + foreach (var ex in t.Exception.InnerExceptions ?? []) + _logger.LogError(ex, "Исключение во время обработки изображений"); + return; + } // Вычисляем вектор признаков var result = AnalyzerService.GetFeatureVectors(imagesData.Select(i => i.Result)); - // Если вектор признаков вычислен - if (result is not null) + // Если вектор признаков не вычислен, то логгируем как ошибку + if (result is null) { - // Распаковываем результат - var (avgImage, roi, features) = result.Value; - _logger.LogInformation($"Длинна вектора признаков: {features.Count()}"); - - // Обрабатываем среднее изображение - avgImage = new OpenCvSharp.Mat(avgImage, roi); - OpenCvSharp.Cv2.Resize( - avgImage, - avgImage, - new OpenCvSharp.Size( - AnalyzerService.ANALYSIS_IMAGE_SIZE, - AnalyzerService.ANALYSIS_IMAGE_SIZE - ) - ); - - // Кодируем и сохраняем среднее изображение - OpenCvSharp.Cv2.ImEncode(".png", avgImage, out var avgData); - string avgPath; - using (var stream = new MemoryStream(avgData)) - avgPath = await _imageStorage.SaveAsync(stream, ".png", "calibration-sample"); - - // Заполняем поля в редактируемой записи - EditingRecord.Image = avgPath; - EditingRecord.VectorRecords = features.Select(f => new VectorRecord { Values = f }).ToList(); - - // Обновляем данные в редактируемой записи - // Так же Dispatcher.UIThread.Invoke нужен потому-что этот метод может (и скорее всего будет) вызван в другом потоке, - // а изменения данных в интерфейсе могут быть вызваны только из потока в котором интерфейс был создан - _ = Dispatcher.UIThread.InvokeAsync(() => OnEditingRecordChanged(EditingRecord)); - } - else - { - _logger.LogError("Ошибка при вычислении векторов признаков"); + _logger.LogError("Не удалось вычислить вектор признаков"); + return; } + + // Распаковываем результат + var (avgImage, roi, features) = result.Value; + + // Сохраняем вектор признаков + _selectedFeaturesData = features; + + // Обрабатываем среднее изображение + avgImage = new OpenCvSharp.Mat(avgImage, roi); + OpenCvSharp.Cv2.Resize( + avgImage, + avgImage, + new OpenCvSharp.Size( + AnalyzerService.ANALYSIS_IMAGE_SIZE, + AnalyzerService.ANALYSIS_IMAGE_SIZE + ) + ); + + // Кодируем среднее изображение + OpenCvSharp.Cv2.ImEncode(".png", avgImage, out var avgData); + _selectedImageData = Convert.ToBase64String(avgData); + + // Очищаем память среднего изображения + avgImage.Dispose(); + + // Обновляем данные в интерфейсе + // Dispatcher.UIThread.Invoke нужен потому-что этот метод может (и скорее всего будет) вызван в другом потоке, + // а изменения данных в интерфейсе могут быть вызваны только из потока в котором интерфейс был создан + _ = Dispatcher.UIThread.InvokeAsync(() => SelectedImage.Data = _selectedImageData); }, cancellationToken); - // Сбрасываем состояние задачи съёмки изображений - _ = Task.WhenAll([captureTask, .. imagesData, postTask]).ContinueWith(t => + // Создаём задачу которая выполнится после завершения всех других + _ = Task.WhenAll([captureTask, .. imagesData, processTask]).ContinueWith(t => { + // Если во время выполнения задачи съёмки изображений произошло исключение, + // то логгируем все исключения и выходим + if (captureTask.Exception is not null) + { + _logger.LogError(captureTask.Exception, "Исключение во время получения изображения"); + foreach (var ex in captureTask.Exception.InnerExceptions ?? []) + _logger.LogError(ex, "Исключение во время получения изображения"); + return; + } + + // Если во время выполнения задач сохранения изображений произошло исключение, + // то логгируем все исключения и выходим + bool anyImageDataException = false; + foreach (var imageData in imagesData) + { + if (imageData.Exception is not null) + { + _logger.LogError(imageData.Exception, "Исключение во время сохранения изображения"); + foreach (var ex in imageData.Exception.InnerExceptions ?? []) + _logger.LogError(ex, "Исключение во время сохранения изображения"); + anyImageDataException = true; + } + } + if (anyImageDataException) + return; + + // Если во время выполнения задачи обработки изображений произошло исключение, + // то логгируем все исключения и выходим + if (processTask.Exception is not null) + { + _logger.LogError(processTask.Exception, "Исключение во время обработки изображений"); + foreach (var ex in processTask.Exception.InnerExceptions ?? []) + _logger.LogError(ex, "Исключение во время обработки изображений"); + return; + } + + // Если во время выполнения произошло исключение, + // то логгируем все исключения и выходим + if (t.Exception is not null) + { + _logger.LogError(t.Exception, "Исключение во время обработки изображений"); + foreach (var ex in t.Exception.InnerExceptions ?? []) + _logger.LogError(ex, "Исключение во время обработки изображений"); + return; + } + // Обновляем данные в интерфейсе // Dispatcher.UIThread.Invoke нужен потому-что этот метод может (и скорее всего будет) вызван в другом потоке, // а изменения данных в интерфейсе могут быть вызваны только из потока в котором интерфейс был создан _ = Dispatcher.UIThread.InvokeAsync(() => { + // Сбрасываем состояние задачи съёмки изображений ImagesIsCapturing = false; ImagesCapturingProgress = ""; - CaptureImagesTask = null; + _сaptureImagesTask = null; }); }); }); } - // Команда отмены задачи съёмки изображений + /// + /// Отменить задачу съёмки изображения + /// [RelayCommand] private void CancelImageCapturing() => _captureImagesTaskCts?.Cancel(); - /// - /// Копирует , отвязывая её от базы данных - /// - /// Скопированная - private static SampleRecord CopyRecord(SampleRecord source) - { - return new SampleRecord - { - Id = source.Id, - SampleName = source.SampleName, - SampleComment = source.SampleComment, - SampleBrand = source.SampleBrand, - SampleMass = source.SampleMass, - SamplingDateTime = source.SamplingDateTime, - SamplingPlace = source.SamplingPlace, - MixtureName = source.MixtureName, - MixtureAmineContent = source.MixtureAmineContent, - MixtureNormalRate = source.MixtureNormalRate, - MixtureActualRate = source.MixtureActualRate, - MeasuredContent = source.MeasuredContent, - Image = source.Image, - VectorRecords = source.VectorRecords - .Select(CopyRecord) - .ToList() - }; - } - /// - /// Копирует , отвязывая её от базы данных - /// - /// Скопированная - private static VectorRecord CopyRecord(VectorRecord source) - { - return new VectorRecord - { - Id = source.Id, - Values = (float[])source.Values.Clone() - }; - } } diff --git a/GSS2/Views/AmineContent/SampleCalibrationView.axaml b/GSS2/Views/AmineContent/SampleCalibrationView.axaml index d779c3f..c28e83a 100644 --- a/GSS2/Views/AmineContent/SampleCalibrationView.axaml +++ b/GSS2/Views/AmineContent/SampleCalibrationView.axaml @@ -1,107 +1,53 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + xmlns:vm="using:GSS2.ViewModels.AmineContent" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns="https://github.com/avaloniaui" +> - + + + + + + + + + 5 - + - - - - - + 5 - + Orientation="Vertical" + VerticalAlignment="Stretch" + > - - - - - - + @@ -109,135 +55,257 @@ + 5 + + + + + + + 5 + - - + Orientation="Horizontal" + > - - + + + + + + + + + 5 - - - - - - - - + + + + + + - - - - + 5 - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + + - - - - - - - - - - - + 5 - - + + - - - - - - - - - - - - - - - - - - - - - - - - - - + 5 - - + + + + - - - - - + + + + + + + + + + + + - - - @@ -342,22 +454,37 @@ - - - - - - - - - + + + + + + + + - + - +