From 1215f13e669c62839a9931b7541f2e24784304c8 Mon Sep 17 00:00:00 2001 From: Danil Iarosh Date: Fri, 23 Jan 2026 17:02:16 +0300 Subject: [PATCH] Refactor employee and department pages: migrate to CollectionView, improve navigation and avatar handling, enhance desktop layout responsiveness, and update user model for avatar compatibility --- .../.idea/dictionaries/project.xml | 7 + CORP-MAUI/Controls/DesktopLayout.xaml.cs | 142 +++++--- CORP-MAUI/Models/User.cs | 58 ++-- CORP-MAUI/Pages/DepartmentsPage.xaml | 83 ++++- CORP-MAUI/Pages/DepartmentsPage.xaml.cs | 313 +++++++----------- CORP-MAUI/Pages/EmployeeProfilePage.xaml.cs | 78 +++-- CORP-MAUI/Pages/EmployeesPage.xaml | 26 +- CORP-MAUI/Pages/EmployeesPage.xaml.cs | 111 +++++-- CORP-MAUI/Resources/Styles/AppStyles.xaml | 2 - CORP-MAUI/Services/ApiService.cs | 33 +- CORP-MAUI/Services/WebSocketService.cs | 19 +- 11 files changed, 530 insertions(+), 342 deletions(-) create mode 100644 .idea/.idea.CORP-MAUI/.idea/dictionaries/project.xml diff --git a/.idea/.idea.CORP-MAUI/.idea/dictionaries/project.xml b/.idea/.idea.CORP-MAUI/.idea/dictionaries/project.xml new file mode 100644 index 0000000..902d194 --- /dev/null +++ b/.idea/.idea.CORP-MAUI/.idea/dictionaries/project.xml @@ -0,0 +1,7 @@ + + + + robotail + + + \ No newline at end of file diff --git a/CORP-MAUI/Controls/DesktopLayout.xaml.cs b/CORP-MAUI/Controls/DesktopLayout.xaml.cs index 7bfae25..0c82a6c 100644 --- a/CORP-MAUI/Controls/DesktopLayout.xaml.cs +++ b/CORP-MAUI/Controls/DesktopLayout.xaml.cs @@ -93,35 +93,48 @@ public partial class DesktopLayout : ContentView header.Children.Add(addButton); // Контейнер для контента - var content = new VerticalStackLayout - { - Spacing = 15 - }; - content.Children.Add(header); - + // Вместо простого VerticalStackLayout используем Grid, чтобы выделить header и scrollable area + var root = new Grid + { + RowDefinitions = new RowDefinitionCollection + { + new RowDefinition { Height = GridLength.Auto }, + new RowDefinition { Height = new GridLength(1, GridUnitType.Star) } + }, + VerticalOptions = LayoutOptions.Fill, + HorizontalOptions = LayoutOptions.Fill + }; + root.Add(header); + Grid.SetRow(header, 0); + // Здесь будет ScrollView с постами (загружается асинхронно) var postsContainer = new VerticalStackLayout - { - Spacing = 15 - }; + { + Spacing = 15, + HorizontalOptions = LayoutOptions.Fill + }; // Placeholder пока загружаются посты var loadingIndicator = new ActivityIndicator - { - IsRunning = true, - Color = Color.FromArgb("#0D6EFD"), - HorizontalOptions = LayoutOptions.Center, - Margin = new Thickness(0, 50) - }; + { + IsRunning = true, + Color = Color.FromArgb("#0D6EFD"), + HorizontalOptions = LayoutOptions.Center, + Margin = new Thickness(0, 50) + }; postsContainer.Children.Add(loadingIndicator); - var scrollView = new ScrollView { Content = postsContainer }; - content.Children.Add(scrollView); + // Корректно настраиваем ScrollView: Fill и layout-constraints + var scrollView = new ScrollView { Content = postsContainer, VerticalOptions = LayoutOptions.Fill, HorizontalOptions = LayoutOptions.Fill }; + + // Помещаем scrollView в фиксированную ячейку Grid + root.Add(scrollView); + Grid.SetRow(scrollView, 1); // Загружаем посты асинхронно _ = LoadPostsAsync(postsContainer); - return content; + return root; } private async Task LoadPostsAsync(VerticalStackLayout container) @@ -150,6 +163,13 @@ public partial class DesktopLayout : ContentView foreach (var post in posts) { var postCard = CreatePostCard(post); + // Даем карточке разумные ограничения по ширине — чтобы не становилась слишком большой + if (postCard is View v) + { + v.HorizontalOptions = LayoutOptions.Fill; + v.WidthRequest = double.NaN; // позволяет занять доступную ширину + v.MaximumWidthRequest = 900; // не растягивать на весь экран + } container.Children.Add(postCard); } }); @@ -184,7 +204,8 @@ public partial class DesktopLayout : ContentView Opacity = 0.1f, Offset = new Point(0, 2), Radius = 8 - } + }, + HorizontalOptions = LayoutOptions.Fill }; var content = new VerticalStackLayout { Spacing = 12 }; @@ -241,7 +262,8 @@ public partial class DesktopLayout : ContentView { Source = post.DisplayImageUrl, Aspect = Aspect.AspectFill, - HeightRequest = 300 + HeightRequest = 300, + HorizontalOptions = LayoutOptions.Fill }; imageContainer.Content = postImage; content.Children.Add(imageContainer); @@ -272,7 +294,8 @@ public partial class DesktopLayout : ContentView { Text = textContent, TextColor = Color.FromArgb("#333333"), - LineBreakMode = LineBreakMode.WordWrap + LineBreakMode = LineBreakMode.WordWrap, + HorizontalOptions = LayoutOptions.FillAndExpand }); if (isTruncated) @@ -505,7 +528,8 @@ public partial class DesktopLayout : ContentView var container = new VerticalStackLayout { Spacing = 10 }; container.Children.Add(new ActivityIndicator { IsRunning = true, Color = Color.FromArgb("#0D6EFD"), HorizontalOptions = LayoutOptions.Center, Margin = new Thickness(0, 50) }); - content.Children.Add(new ScrollView { Content = container }); + // Устанавливаем FillAndExpand чтобы контейнер занял пространство внутри MainContent + content.Children.Add(new ScrollView { Content = container, VerticalOptions = LayoutOptions.Fill, HorizontalOptions = LayoutOptions.Fill }); SetContent(content); _ = LoadEmployeesAsync(container); @@ -536,7 +560,8 @@ public partial class DesktopLayout : ContentView StrokeShape = new Microsoft.Maui.Controls.Shapes.RoundRectangle { CornerRadius = 8 }, Stroke = Colors.Transparent, Padding = new Thickness(15), - Margin = new Thickness(0, 0, 0, 5) + Margin = new Thickness(0, 0, 0, 5), + HorizontalOptions = LayoutOptions.Fill }; var row = new HorizontalStackLayout { Spacing = 15 }; @@ -577,7 +602,14 @@ public partial class DesktopLayout : ContentView try { System.Diagnostics.Debug.WriteLine($"[DesktopLayout] >>> Открытие профиля сотрудника ID: {employeeId}"); - await Shell.Current.Navigation.PushAsync(new CORP_MAUI.Pages.EmployeeProfilePage(employeeId)); + var nav = ResolveNavigation(); + if (nav != null) + { + await nav.PushAsync(new CORP_MAUI.Pages.EmployeeProfilePage(employeeId)); + return; + } + + System.Diagnostics.Debug.WriteLine($"[DesktopLayout] ❌ Не удалось найти навигацию для открытия профиля {employeeId}"); } catch (Exception ex) { @@ -641,7 +673,7 @@ public partial class DesktopLayout : ContentView headerRow.Children.Add(searchBox); // Контейнер для сетки отделов с прокруткой - var gridContainer = new VerticalStackLayout { Spacing = 12 }; + var gridContainer = new VerticalStackLayout { Spacing = 12, VerticalOptions = LayoutOptions.Fill, HorizontalOptions = LayoutOptions.Fill }; // Добавляем ActivityIndicator пока загружается var activityIndicator = new ActivityIndicator @@ -654,6 +686,7 @@ public partial class DesktopLayout : ContentView }; gridContainer.Children.Add(activityIndicator); + // Используем ScrollView вокруг контейнера (и обеспечиваем FillAndExpand у scrollView) var scrollView = new ScrollView { Content = gridContainer, @@ -663,15 +696,15 @@ public partial class DesktopLayout : ContentView // Основной контейнер var content = new Grid - { - RowDefinitions = new RowDefinitionCollection - { - new RowDefinition { Height = GridLength.Auto }, - new RowDefinition { Height = new GridLength(1, GridUnitType.Star) } - }, - VerticalOptions = LayoutOptions.Fill, - HorizontalOptions = LayoutOptions.Fill - }; + { + RowDefinitions = new RowDefinitionCollection + { + new RowDefinition { Height = GridLength.Auto }, + new RowDefinition { Height = new GridLength(1, GridUnitType.Star) } + }, + VerticalOptions = LayoutOptions.Fill, + HorizontalOptions = LayoutOptions.Fill + }; Grid.SetRow(headerRow, 0); content.Children.Add(headerRow); @@ -707,7 +740,7 @@ public partial class DesktopLayout : ContentView countLabel.Text = $"Всего отделов: {departments.Count}"; System.Diagnostics.Debug.WriteLine($"[DesktopLayout] Создание {departments.Count} карточек отделов..."); - // Создаём Grid с 2 колонками + // Для корректной прокрутки и размеров используем Grid с равномерными колонками (Star) и поместим его в ScrollView var grid = new Grid { ColumnDefinitions = new ColumnDefinitionCollection @@ -740,6 +773,8 @@ public partial class DesktopLayout : ContentView System.Diagnostics.Debug.WriteLine($"[DesktopLayout] ✅ Добавлена карточка {i + 1} в позицию [{row}, {col}]"); } + // Оборачиваем grid в контейнер, даём ему FillAndExpand чтобы прокрутка работала корректно + grid.HorizontalOptions = LayoutOptions.Fill; container.Children.Add(grid); System.Diagnostics.Debug.WriteLine($"[DesktopLayout] ✅ Grid создан с {grid.RowDefinitions.Count} строками"); @@ -818,7 +853,7 @@ public partial class DesktopLayout : ContentView }); // Аватарки сотрудников (серые кружки, которые потом заменятся на реальные) - var avatarsRow = new HorizontalStackLayout { Spacing = -8 }; + var avatarsRow = new HorizontalStackLayout { Spacing = -8, HorizontalOptions = LayoutOptions.Start }; // Сохраняем ссылку на контейнер аватарок в свойстве отдела для последующего обновления dept.AvatarsContainer = avatarsRow; @@ -849,7 +884,14 @@ public partial class DesktopLayout : ContentView try { System.Diagnostics.Debug.WriteLine($"[DesktopLayout] >>> Открытие отдела ID: {deptId}"); - await Shell.Current.Navigation.PushAsync(new CORP_MAUI.Pages.DepartmentDetailsPage(deptId)); + var nav = ResolveNavigation(); + if (nav != null) + { + await nav.PushAsync(new CORP_MAUI.Pages.DepartmentDetailsPage(deptId)); + return; + } + + System.Diagnostics.Debug.WriteLine($"[DesktopLayout] ❌ Не удалось найти навигацию для открытия отдела {deptId}"); } catch (Exception ex) { @@ -1186,4 +1228,30 @@ public partial class DesktopLayout : ContentView return header; } + + private INavigation? ResolveNavigation() + { + // Попробуем найти Page, обойдя родителей + Element? el = this; + while (el != null) + { + if (el is Page p) + return p.Navigation; + el = el.Parent; + } + + // Application.Current.MainPage может быть Shell или NavigationPage + if (Application.Current?.MainPage?.Navigation != null) + return Application.Current.MainPage.Navigation; + + // Shell.Current.Navigation + if (Shell.Current?.Navigation != null) + return Shell.Current.Navigation; + + // Попробуем взять первую открытую Window + if (Application.Current?.Windows != null && Application.Current.Windows.Count > 0) + return Application.Current.Windows[0].Page?.Navigation; + + return null; + } } diff --git a/CORP-MAUI/Models/User.cs b/CORP-MAUI/Models/User.cs index 34fa239..2e7e85f 100644 --- a/CORP-MAUI/Models/User.cs +++ b/CORP-MAUI/Models/User.cs @@ -41,6 +41,21 @@ public class User : INotifyPropertyChanged [JsonPropertyName("avatar")] public string? Avatar { get; set; } + + // Backwards-compatible property: some code expects PhotoUrl + [JsonIgnore] + public string? PhotoUrl + { + get => AvatarUrl ?? Avatar; + set + { + if (value == AvatarUrl) return; + AvatarUrl = value; + OnPropertyChanged(nameof(AvatarUrl)); + OnPropertyChanged(nameof(DisplayAvatarUrl)); + OnPropertyChanged(nameof(DisplayAvatarSource)); + } + } // INotifyPropertyChanged implementation public event PropertyChangedEventHandler? PropertyChanged; @@ -88,36 +103,29 @@ public class User : INotifyPropertyChanged { get { - // Сначала проверяем Avatar (может содержать полный URL или data:image) + // Если явно указана Avatar (base64 or url) if (!string.IsNullOrEmpty(Avatar)) { - // Если это data:image - не возвращаем как URL (будет обработано отдельно) if (Avatar.StartsWith("data:image")) return null; - - // Если это полный URL + if (Avatar.StartsWith("http", StringComparison.OrdinalIgnoreCase)) return Avatar; - - // Если это относительный путь + if (!Avatar.StartsWith("data:")) { string cleanUrl = Avatar.TrimStart('/'); - return $"https://corp.robotail.pro/{cleanUrl}"; + return ServiceProvider.ApiService.GetFullImageUrl(cleanUrl); } } - if (string.IsNullOrEmpty(AvatarUrl)) return null; - - // Handle protocol-relative URLs - if (AvatarUrl.StartsWith("//")) - return $"https:{AvatarUrl}"; + // Используем AvatarUrl (обычно относительный или абсолютный) + if (!string.IsNullOrEmpty(AvatarUrl)) + { + return ServiceProvider.ApiService.GetFullImageUrl(AvatarUrl); + } - if (AvatarUrl.StartsWith("http", StringComparison.OrdinalIgnoreCase)) - return AvatarUrl; - - string cleanPath = AvatarUrl.TrimStart('/'); - return $"https://corp.robotail.pro/{cleanPath}"; + return null; } } @@ -131,7 +139,20 @@ public class User : INotifyPropertyChanged /// Путь к файлу кэшированного аватара /// [JsonIgnore] - public string? CachedAvatarFilePath { get; set; } + private string? _cachedAvatarFilePath; + + [JsonIgnore] + public string? CachedAvatarFilePath + { + get => _cachedAvatarFilePath; + set + { + if (_cachedAvatarFilePath == value) return; + _cachedAvatarFilePath = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(DisplayAvatarSource)); + } + } /// /// Флаг, что аватар был успешно загружен @@ -214,7 +235,6 @@ public class User : INotifyPropertyChanged } // Если нет в кэше - возвращаем placeholder - // Аватар будет загружен асинхронно через LoadUserAvatarAsync Trace.WriteLine($"[User.DisplayAvatarSource] {FullName}: Not in cache, returning placeholder"); } } diff --git a/CORP-MAUI/Pages/DepartmentsPage.xaml b/CORP-MAUI/Pages/DepartmentsPage.xaml index 1a2a4de..613554f 100644 --- a/CORP-MAUI/Pages/DepartmentsPage.xaml +++ b/CORP-MAUI/Pages/DepartmentsPage.xaml @@ -80,26 +80,79 @@ - + - - - - - + + + + + + + + + + + + + + + + + diff --git a/CORP-MAUI/Pages/DepartmentsPage.xaml.cs b/CORP-MAUI/Pages/DepartmentsPage.xaml.cs index f8b5d5c..97e2823 100644 --- a/CORP-MAUI/Pages/DepartmentsPage.xaml.cs +++ b/CORP-MAUI/Pages/DepartmentsPage.xaml.cs @@ -15,6 +15,8 @@ public partial class DepartmentsPage : ContentPage { Trace.WriteLine("=== DEPARTMENTSPAGE CONSTRUCTOR START ==="); InitializeComponent(); + // Привязываем пустой источник — будет заполнен при загрузке + DepartmentsCollectionView.ItemsSource = new List(); _ = LoadDepartmentsAsync(); Trace.WriteLine("=== DEPARTMENTSPAGE CONSTRUCTOR END ==="); } @@ -33,7 +35,14 @@ public partial class DepartmentsPage : ContentPage LoadingIndicator.IsRunning = true; _allDepartments = await ServiceProvider.ApiService.GetAllDepartmentsAsync(); - RenderDepartments(_allDepartments.OrderBy(d => d.Name).ToList()); + + var ordered = _allDepartments.OrderBy(d => d.Name).ToList(); + + // Устанавливаем ItemsSource CollectionView + DepartmentsCollectionView.ItemsSource = ordered; + + // Асинхронно предзагружаем аватары руководителей + _ = PreloadHeadsAvatarsAsync(ordered); } catch (Exception ex) { @@ -47,215 +56,27 @@ public partial class DepartmentsPage : ContentPage } } - private void RenderDepartments(List departments) - { - DepartmentsFlexLayout.Children.Clear(); - - if (departments.Count == 0) - { - var emptyLabel = new Label - { - Text = "Нет отделов", - HorizontalOptions = LayoutOptions.Center, - VerticalOptions = LayoutOptions.Center, - Margin = new Thickness(20) - }; - emptyLabel.SetAppThemeColor(Label.TextColorProperty, Color.FromArgb("#1F1F1F"), Color.FromArgb("#E1E1E1")); - DepartmentsFlexLayout.Children.Add(emptyLabel); - return; - } - - double screenWidth = DeviceDisplay.MainDisplayInfo.Width / DeviceDisplay.MainDisplayInfo.Density; - // Для мобильных устройств (< 600px) - одна карточка в ряд, иначе две - double cardWidth = screenWidth < 600 ? screenWidth - 30 : (screenWidth / 2) - 25; - - foreach (var department in departments) - { - var card = CreateDepartmentCard(department, cardWidth); - DepartmentsFlexLayout.Children.Add(card); - } - - _ = LoadDepartmentAvatarsAsync(departments); - } - - private Border CreateDepartmentCard(Department department, double cardWidth) - { - var avatarStack = new HorizontalStackLayout - { - Spacing = -8, - Margin = new Thickness(0, 5, 0, 0), - HeightRequest = 32, - MinimumHeightRequest = 32 - }; - - var card = new Border - { - StrokeShape = new RoundRectangle { CornerRadius = 16 }, - BackgroundColor = Colors.White, - Stroke = Colors.Transparent, - Padding = new Thickness(16), - Margin = new Thickness(5), - WidthRequest = cardWidth, - HeightRequest = 150, - MaximumHeightRequest = 150, - Content = new VerticalStackLayout - { - Spacing = 6, - Children = - { - new Label - { - Text = department.Name, - FontSize = 15, - FontAttributes = FontAttributes.Bold, - TextColor = Color.FromArgb("#1F1F1F"), - LineBreakMode = LineBreakMode.TailTruncation, - MaxLines = 1 - }, - new Label - { - Text = department.DisplayDescription, - FontSize = 12, - TextColor = Color.FromArgb("#6B6B6B"), - LineBreakMode = LineBreakMode.TailTruncation, - MaxLines = 2 - }, - new HorizontalStackLayout - { - Spacing = 5, - Margin = new Thickness(0, 3, 0, 0), - Children = - { - CreateIconWithText("employees.png", department.EmployeesCount.ToString()), - new Label - { - Text = "•", - FontSize = 12, - TextColor = Color.FromArgb("#999999"), - VerticalOptions = LayoutOptions.Center - }, - CreateIconWithText("profile.png", department.Head?.FullName ?? "Не назначен") - } - }, - avatarStack - } - } - }; - - card.Shadow = new Shadow - { - Brush = new SolidColorBrush(Color.FromArgb("#40000000")), - Offset = new Point(0, 2), - Radius = 8, - Opacity = 0.15f - }; - - var tapGesture = new TapGestureRecognizer(); - tapGesture.Tapped += async (_, _) => - { - try - { - await Navigation.PushAsync(new DepartmentDetailsPage(department.Id)); - } - catch - { - await DisplayAlert("Ошибка", "Не удалось открыть страницу отдела", "OK"); - } - }; - card.GestureRecognizers.Add(tapGesture); - card.ClassId = department.Id.ToString(); - - return card; - } - - private HorizontalStackLayout CreateIconWithText(string iconSource, string text) - { - var image = new Image - { - Source = iconSource, - WidthRequest = 14, - HeightRequest = 14, - VerticalOptions = LayoutOptions.Center - }; - - var iconBehavior = new IconTintColorBehavior - { - TintColor = Color.FromArgb("#007AFF") - }; - image.Behaviors.Add(iconBehavior); - - return new HorizontalStackLayout - { - Spacing = 4, - Children = - { - image, - new Label - { - Text = text, - FontSize = 12, - TextColor = Color.FromArgb("#6B6B6B"), - VerticalOptions = LayoutOptions.Center, - LineBreakMode = LineBreakMode.TailTruncation - } - } - }; - } - - private async Task LoadDepartmentAvatarsAsync(List departments) + private async Task PreloadHeadsAvatarsAsync(List departments) { try { foreach (var dept in departments) { - if (dept.Head?.Id == null) - continue; - - var card = DepartmentsFlexLayout.Children - .OfType() - .FirstOrDefault(b => b.ClassId == dept.Id.ToString()); - - if (card?.Content is VerticalStackLayout vstack) + if (dept.Head?.Id == null) continue; + try { - var avatarStack = vstack.Children.OfType().LastOrDefault(); - if (avatarStack != null) - { - try - { - var user = await ServiceProvider.ApiService.GetUserByIdAsync(dept.Head.Id); - if (!string.IsNullOrEmpty(user?.AvatarUrl)) - { - MainThread.BeginInvokeOnMainThread(() => - { - avatarStack.Children.Clear(); - var avatarBorder = new Border - { - StrokeShape = new RoundRectangle { CornerRadius = 15 }, - WidthRequest = 30, - HeightRequest = 30, - StrokeThickness = 2, - Stroke = Colors.White, - Content = new Image - { - Source = user.AvatarUrl, - Aspect = Aspect.AspectFill - } - }; - avatarStack.Children.Add(avatarBorder); - }); - } - } - catch (Exception) - { - // Игнорируем ошибки загрузки отдельных аватаров - } - } + await ServiceProvider.ApiService.LoadUserAvatarAsync(dept.Head); + // После загрузки DisplayAvatarSource уведомит UI через INotifyPropertyChanged + } + catch (Exception ex) + { + Trace.WriteLine($"[DepartmentsPage] ⚠️ Ошибка загрузки аватара для {dept.Name}: {ex.Message}"); } } } catch (Exception ex) { - Trace.WriteLine($"[DepartmentsPage] ⚠️ Ошибка загрузки аватаров: {ex.Message}"); + Trace.WriteLine($"[DepartmentsPage] ⚠️ PreloadHeadsAvatarsAsync error: {ex.Message}"); } } @@ -282,6 +103,98 @@ public partial class DepartmentsPage : ContentPage ).ToList(); } - RenderDepartments(filtered.OrderBy(d => d.Name).ToList()); + DepartmentsCollectionView.ItemsSource = filtered.OrderBy(d => d.Name).ToList(); + } + + /// + /// Попытаться извлечь Department из sender или TappedEventArgs + /// + private static Department? GetDepartmentFromTap(object? sender, TappedEventArgs? e) + { + // Сначала пробуем Parameter из TappedEventArgs + if (e?.Parameter is Department deptFromParam) + return deptFromParam; + + // Затем из sender + if (sender is TapGestureRecognizer tr) + { + if (tr.BindingContext is Department deptFromTr) return deptFromTr; + if (tr.Parent is BindableObject parent && parent.BindingContext is Department deptFromParent) return deptFromParent; + } + + if (sender is BindableObject bo && bo.BindingContext is Department deptFromBo) + { + return deptFromBo; + } + + return null; + } + + private async void OnDepartmentCardTapped(object sender, TappedEventArgs e) + { + try + { + var dept = GetDepartmentFromTap(sender, e); + if (dept == null) + { + Trace.WriteLine("[DepartmentsPage] ⚠️ Не удалось определить отдел при нажатии"); + return; + } + + Trace.WriteLine($"[DepartmentsPage] >>> Открытие отдела: {dept.Id} - {dept.Name}"); + + if (Shell.Current?.Navigation != null) + { + await Shell.Current.Navigation.PushAsync(new DepartmentDetailsPage(dept.Id)); + } + else + { + await Navigation.PushAsync(new DepartmentDetailsPage(dept.Id)); + } + } + catch (Exception ex) + { + Trace.WriteLine($"[DepartmentsPage] ❌ Navigation error: {ex.Message}"); + try + { + await Shell.Current.GoToAsync($"{nameof(DepartmentDetailsPage)}?id={(sender as BindableObject)?.BindingContext}"); + } + catch + { + await DisplayAlert("Ошибка", "Не удалось открыть страницу отдела", "OK"); + } + } + } + + // Обработчик выбора в CollectionView: открывает страницу отдела и снимает выбор + private async void DepartmentsCollectionView_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + try + { + var selected = e.CurrentSelection?.FirstOrDefault() as Department; + if (selected == null) return; + + Trace.WriteLine($"[DepartmentsPage] >>> SelectionChanged -> открываем отдел: {selected.Name}"); + if (Shell.Current?.Navigation != null) + { + await Shell.Current.Navigation.PushAsync(new DepartmentDetailsPage(selected.Id)); + } + else + { + await Navigation.PushAsync(new DepartmentDetailsPage(selected.Id)); + } + } + catch (Exception ex) + { + Trace.WriteLine($"[DepartmentsPage] ❌ Ошибка в SelectionChanged: {ex.Message}"); + await DisplayAlert("Ошибка", "Не удалось открыть страницу отдела", "OK"); + } + finally + { + if (sender is CollectionView cv) + { + cv.SelectedItem = null; + } + } } } diff --git a/CORP-MAUI/Pages/EmployeeProfilePage.xaml.cs b/CORP-MAUI/Pages/EmployeeProfilePage.xaml.cs index 0fc63cf..49b4aa8 100644 --- a/CORP-MAUI/Pages/EmployeeProfilePage.xaml.cs +++ b/CORP-MAUI/Pages/EmployeeProfilePage.xaml.cs @@ -3,7 +3,7 @@ using CORP_MAUI.Models; namespace CORP_MAUI.Pages; -public partial class EmployeeProfilePage : ContentPage +public partial class EmployeeProfilePage { private User? _user; private readonly int _userId; @@ -13,14 +13,14 @@ public partial class EmployeeProfilePage : ContentPage try { Trace.WriteLine($"=== EMPLOYEEPROFILEPAGE CONSTRUCTOR START (UserID: {userId}) ==="); - + InitializeComponent(); - + _userId = userId; - + // Загружаем данные _ = LoadUserProfileAsync(); - + Trace.WriteLine("=== EMPLOYEEPROFILEPAGE CONSTRUCTOR END ==="); } catch (Exception ex) @@ -29,13 +29,14 @@ public partial class EmployeeProfilePage : ContentPage Trace.WriteLine($"[EmployeeProfilePage] ❌ Тип ошибки: {ex.GetType().FullName}"); Trace.WriteLine($"[EmployeeProfilePage] ❌ Сообщение: {ex.Message}"); Trace.WriteLine($"[EmployeeProfilePage] ❌ StackTrace: {ex.StackTrace}"); - + Task.Run(async () => { await Task.Delay(100); - if (Application.Current?.Windows.Count > 0) + var app = Application.Current; + if (app != null && app.Windows.Count > 0) { - Page? mainPage = Application.Current.Windows[0]?.Page; + Page? mainPage = app.Windows[0].Page; if (mainPage != null) { await mainPage.DisplayAlert("Ошибка EmployeeProfilePage", @@ -50,9 +51,47 @@ public partial class EmployeeProfilePage : ContentPage /// /// Конструктор для передачи готового объекта пользователя /// - public EmployeeProfilePage(User user) : this(user.Id) + public EmployeeProfilePage(User user) { - _user = user; + try + { + ArgumentNullException.ThrowIfNull(user); + + Trace.WriteLine($"=== EMPLOYEEPROFILEPAGE CONSTRUCTOR START (User object: {user.FullName}) ==="); + + InitializeComponent(); + + _user = user; + _userId = user.Id; + + // Загружаем данные (будет использован переданный объект, если он полный) + _ = LoadUserProfileAsync(); + + Trace.WriteLine("=== EMPLOYEEPROFILEPAGE CONSTRUCTOR END ==="); + } + catch (Exception ex) + { + Trace.WriteLine("=== EMPLOYEEPROFILEPAGE CONSTRUCTOR ERROR ==="); + Trace.WriteLine($"[EmployeeProfilePage] ❌ Тип ошибки: {ex.GetType().FullName}"); + Trace.WriteLine($"[EmployeeProfilePage] ❌ Сообщение: {ex.Message}"); + Trace.WriteLine($"[EmployeeProfilePage] ❌ StackTrace: {ex.StackTrace}"); + + Task.Run(async () => + { + await Task.Delay(100); + var app = Application.Current; + if (app != null && app.Windows.Count > 0) + { + Page? mainPage = app.Windows[0].Page; + if (mainPage != null) + { + await mainPage.DisplayAlert("Ошибка EmployeeProfilePage", + $"Ошибка инициализации:\n{ex.Message}", "OK"); + } + } + }); + throw; + } } private async Task LoadUserProfileAsync() @@ -60,7 +99,7 @@ public partial class EmployeeProfilePage : ContentPage try { Trace.WriteLine($"[EmployeeProfilePage] >>> LoadUserProfileAsync START (UserID: {_userId})"); - + LoadingIndicator.IsVisible = true; LoadingIndicator.IsRunning = true; @@ -80,15 +119,15 @@ public partial class EmployeeProfilePage : ContentPage } Trace.WriteLine($"[EmployeeProfilePage] ✅ Профиль загружен: {_user.FullName}"); - + // Предзагружаем аватар пользователя Trace.WriteLine($"[EmployeeProfilePage] >>> Загрузка аватара, URL: {_user.DisplayAvatarUrl}"); await ServiceProvider.ApiService.LoadUserAvatarAsync(_user); Trace.WriteLine($"[EmployeeProfilePage] IsAvatarLoaded: {_user.IsAvatarLoaded}, CachedPath: {_user.CachedAvatarFilePath}"); - + // Заполняем основную информацию FullNameLabel.Text = _user.FullName; - PositionLabel.Text = _user.PositionName ?? "Должность не указана"; + PositionLabel.Text = _user.PositionName; AvatarImage.Source = _user.DisplayAvatarSource; Trace.WriteLine($"[EmployeeProfilePage] ✅ Аватар установлен"); OnlineIndicator.IsVisible = _user.IsOnline; @@ -147,7 +186,7 @@ public partial class EmployeeProfilePage : ContentPage } Trace.WriteLine($"[EmployeeProfilePage] >>> Навыков: {_user.Skills.Count}"); } - + Trace.WriteLine($"[EmployeeProfilePage] ✅ Профиль отображен успешно"); } catch (Exception ex) @@ -155,7 +194,7 @@ public partial class EmployeeProfilePage : ContentPage Trace.WriteLine($"[EmployeeProfilePage] ❌ Ошибка загрузки: {ex.GetType().Name}"); Trace.WriteLine($"[EmployeeProfilePage] Message: {ex.Message}"); Trace.WriteLine($"[EmployeeProfilePage] StackTrace: {ex.StackTrace}"); - + await DisplayAlert("Ошибка", $"Не удалось загрузить профиль сотрудника:\n{ex.Message}", "OK"); } finally @@ -171,10 +210,10 @@ public partial class EmployeeProfilePage : ContentPage return; Trace.WriteLine($"[EmployeeProfilePage] >>> Открытие чата с: {_user.FullName}"); - + try { - await Navigation.PushAsync(new ChatPage(_user)); + await Shell.Current.Navigation.PushAsync(new ChatPage(_user)); } catch (Exception ex) { @@ -185,7 +224,6 @@ public partial class EmployeeProfilePage : ContentPage private async void OnBackTapped(object sender, TappedEventArgs e) { - await Navigation.PopAsync(); + await Shell.Current.Navigation.PopAsync(); } } - diff --git a/CORP-MAUI/Pages/EmployeesPage.xaml b/CORP-MAUI/Pages/EmployeesPage.xaml index e1c6876..8a5364c 100644 --- a/CORP-MAUI/Pages/EmployeesPage.xaml +++ b/CORP-MAUI/Pages/EmployeesPage.xaml @@ -89,8 +89,12 @@ + SelectionMode="Single" + SelectionChanged="EmployeesCollectionView_SelectionChanged" + ItemsLayout="VerticalList" + BackgroundColor="Transparent" + VerticalOptions="FillAndExpand" + Margin="0"> @@ -109,22 +113,26 @@ + Margin="6,4"> - + + Style="{StaticResource AvatarMedium}" + WidthRequest="50" + HeightRequest="50"> + Aspect="AspectFill" + WidthRequest="48" + HeightRequest="48"/> + Margin="8,0"> private async void OnChatIconTapped(object sender, TappedEventArgs e) { - if (e.Parameter is User selectedEmployee) + var selectedEmployee = GetUserFromTap(sender, e); + if (selectedEmployee == null) { - Trace.WriteLine($"[EmployeesPage] >>> Открытие чата с: {selectedEmployee.FullName}"); - - try - { - await Navigation.PushAsync(new ChatPage(selectedEmployee)); - } - catch (Exception ex) - { - Trace.WriteLine($"[EmployeesPage] ❌ Ошибка перехода к чату: {ex.Message}"); - await DisplayAlert("Ошибка", "Не удалось открыть чат", "OK"); - } + Trace.WriteLine("[EmployeesPage] ⚠️ Не удалось определить выбранного сотрудника в OnChatIconTapped"); + return; + } + + Trace.WriteLine($"[EmployeesPage] >>> Открытие чата с: {selectedEmployee.FullName}"); + try + { + await Shell.Current.Navigation.PushAsync(new ChatPage(selectedEmployee)); + } + catch (Exception ex) + { + Trace.WriteLine($"[EmployeesPage] ❌ Ошибка перехода к чату: {ex.Message}"); + await DisplayAlert("Ошибка", "Не удалось открыть чат", "OK"); } } @@ -206,7 +237,7 @@ public partial class EmployeesPage : ContentPage { try { - Trace.WriteLine("[EmployeesPage] >>> Начинаем предзагрузку аватаров..."); + Trace.WriteLine("[EmployeesPage] >>> Начинаем предзагрузку ават��ров..."); await ServiceProvider.ApiService.PreloadUserAvatarsAsync(_allEmployees); Trace.WriteLine("[EmployeesPage] ✅ Аватары предзагружены"); } @@ -215,5 +246,33 @@ public partial class EmployeesPage : ContentPage Trace.WriteLine($"[EmployeesPage] ⚠️ Ошибка предзагрузки аватаров: {ex.Message}"); } } -} + /// + /// SelectionChanged handler: откроет профиль выбранного сотрудника (вызывается при SelectionMode=Single) + /// + private async void EmployeesCollectionView_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + try + { + var selected = e.CurrentSelection?.FirstOrDefault() as User; + if (selected == null) + return; + + Trace.WriteLine($"[EmployeesPage] >>> SelectionChanged -> открываем профиль: {selected.FullName}"); + await Shell.Current.Navigation.PushAsync(new EmployeeProfilePage(selected)); + } + catch (Exception ex) + { + Trace.WriteLine($"[EmployeesPage] ❌ Ошибка в SelectionChanged: {ex.Message}"); + await DisplayAlert("Ошибка", "Не удалось открыть профиль сотрудника", "OK"); + } + finally + { + // Снять выбор, чтобы пользователь мог снова выбрать тот же элемент + if (sender is CollectionView cv) + { + cv.SelectedItem = null; + } + } + } +} diff --git a/CORP-MAUI/Resources/Styles/AppStyles.xaml b/CORP-MAUI/Resources/Styles/AppStyles.xaml index fabd763..4fb572d 100644 --- a/CORP-MAUI/Resources/Styles/AppStyles.xaml +++ b/CORP-MAUI/Resources/Styles/AppStyles.xaml @@ -184,7 +184,6 @@ - @@ -193,7 +192,6 @@ - diff --git a/CORP-MAUI/Services/ApiService.cs b/CORP-MAUI/Services/ApiService.cs index 7d15b59..1718d02 100644 --- a/CORP-MAUI/Services/ApiService.cs +++ b/CORP-MAUI/Services/ApiService.cs @@ -783,6 +783,25 @@ public class ApiService : IDisposable } } + /// + /// Вернуть полный URL изображения, если передан относительный путь — добавить BaseUrl + /// + public string? GetFullImageUrl(string? url) + { + if (string.IsNullOrEmpty(url)) return null; + + // Если это data-uri — не трактуем как URL + if (url.StartsWith("data:", StringComparison.OrdinalIgnoreCase)) return null; + + // Protocol-relative + if (url.StartsWith("//")) return "https:" + url; + + if (url.StartsWith("http", StringComparison.OrdinalIgnoreCase)) return url; + + // Относительный путь — приклеиваем базовый URL + return _config?.BaseUrl?.TrimEnd('/') + "/" + url.TrimStart('/'); + } + /// /// Загрузить аватарки для списка пользователей /// @@ -792,10 +811,7 @@ public class ApiService : IDisposable foreach (var user in users) { - if (!string.IsNullOrEmpty(user.AvatarUrl) && !user.AvatarUrl.StartsWith("http")) - { - user.AvatarUrl = $"{_config.BaseUrl.TrimEnd('/')}/{user.AvatarUrl.TrimStart('/')}"; - } + user.AvatarUrl = GetFullImageUrl(user.AvatarUrl); } return Task.CompletedTask; } @@ -874,6 +890,14 @@ public class ApiService : IDisposable } } + /// + /// Получить текущего пользователя (alias для GetCurrentUserAsync) + /// + public async Task GetUserAsync() + { + return await GetCurrentUserAsync(); + } + /// /// Получить сотрудника по ID /// @@ -1450,6 +1474,7 @@ public class ApiService : IDisposable } } + /// /// Предзагрузить аватары для списка пользователей /// diff --git a/CORP-MAUI/Services/WebSocketService.cs b/CORP-MAUI/Services/WebSocketService.cs index fe6b710..3c69bf2 100644 --- a/CORP-MAUI/Services/WebSocketService.cs +++ b/CORP-MAUI/Services/WebSocketService.cs @@ -3,6 +3,7 @@ using System.Net.WebSockets; using System.Text; using CORP_MAUI.Models; using Newtonsoft.Json; +using System.Threading; namespace CORP_MAUI.Services; @@ -11,14 +12,10 @@ public class WebSocketService private ClientWebSocket? _webSocket; // Возможные варианты WebSocket URL (будем пробовать по порядку) - private static readonly string[] WebSocketUrls = + private static readonly string[] WebSocketUrls = new string[] { "wss://corp.robotail.pro/ws", - "ws://corp.robotail.pro/ws", - "wss://corp.robotail.pro/ws/", - "ws://corp.robotail.pro/ws/", - "wss://corp.robotail.pro/api/ws", - "wss://corp.robotail.pro/api/v1/ws/chat/" + "ws://corp.robotail.pro/ws" }; private string? _connectedUrl; @@ -30,7 +27,8 @@ public class WebSocketService public event EventHandler? UserStatusChanged; public event EventHandler? ConnectionStatusChanged; - public bool IsConnected => _isConnected && _webSocket?.State == WebSocketState.Open; + // Сделаем выражение полностью булевым (защита от null) + public bool IsConnected => _isConnected && (_webSocket?.State == WebSocketState.Open); public async Task ConnectAsync(string authToken) { @@ -139,10 +137,9 @@ public class WebSocketService { if (_webSocket is { State: WebSocketState.Open }) { - if (_cancellationTokenSource != null) - { - await _cancellationTokenSource.CancelAsync(); - } + // Отмена токена — не асинхронный вызов + _cancellationTokenSource?.Cancel(); + await _webSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Closing", CancellationToken.None); }