Refactor employee and department pages: migrate to CollectionView, improve navigation and avatar handling, enhance desktop layout responsiveness, and update user model for avatar compatibility

This commit is contained in:
Данил Ярош 2026-01-23 17:02:16 +03:00
parent c1de303c22
commit 1215f13e66
11 changed files with 530 additions and 342 deletions

View File

@ -0,0 +1,7 @@
<component name="ProjectDictionaryState">
<dictionary name="project">
<words>
<w>robotail</w>
</words>
</dictionary>
</component>

View File

@ -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;
}
}

View File

@ -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
/// Путь к файлу кэшированного аватара
/// </summary>
[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));
}
}
/// <summary>
/// Флаг, что аватар был успешно загружен
@ -214,7 +235,6 @@ public class User : INotifyPropertyChanged
}
// Если нет в кэше - возвращаем placeholder
// Аватар будет загружен асинхронно через LoadUserAvatarAsync
Trace.WriteLine($"[User.DisplayAvatarSource] {FullName}: Not in cache, returning placeholder");
}
}

View File

@ -80,26 +80,79 @@
</Border>
</Grid>
<!-- Список отделов -->
<!-- Список отделов (CollectionView вместо FlexLayout) -->
<RefreshView
Grid.Row="2"
x:Name="RefreshView"
IsRefreshing="False"
Refreshing="OnRefreshing">
<ScrollView
Orientation="Vertical"
HorizontalScrollBarVisibility="Never">
<FlexLayout
x:Name="DepartmentsFlexLayout"
Direction="Row"
Wrap="Wrap"
JustifyContent="Start"
AlignItems="Start"
AlignContent="Start"
Padding="10,5">
</FlexLayout>
</ScrollView>
<CollectionView
x:Name="DepartmentsCollectionView"
SelectionMode="Single"
SelectionChanged="DepartmentsCollectionView_SelectionChanged"
ItemsLayout="VerticalList"
BackgroundColor="Transparent"
Margin="10,5"
VerticalOptions="FillAndExpand">
<CollectionView.ItemTemplate>
<DataTemplate x:DataType="models:Department">
<Border
StrokeShape="RoundRectangle 16"
BackgroundColor="White"
Stroke="Transparent"
Padding="12"
Margin="5">
<Border.GestureRecognizers>
<TapGestureRecognizer Tapped="OnDepartmentCardTapped" CommandParameter="{Binding .}"/>
</Border.GestureRecognizers>
<VerticalStackLayout Spacing="6">
<Label
Text="{Binding Name}"
FontSize="14"
FontAttributes="Bold"
TextColor="#1F1F1F"
LineBreakMode="TailTruncation"
MaxLines="1"/>
<Label
Text="{Binding DisplayDescription}"
FontSize="12"
TextColor="#6B6B6B"
LineBreakMode="TailTruncation"
MaxLines="2"/>
<HorizontalStackLayout Spacing="5" Margin="0,3,0,0">
<HorizontalStackLayout Spacing="4">
<Image Source="employees.png" WidthRequest="14" HeightRequest="14" VerticalOptions="Center">
<Image.Behaviors>
<toolkit:IconTintColorBehavior TintColor="{StaticResource Primary}"/>
</Image.Behaviors>
</Image>
<Label Text="{Binding EmployeesCount}" FontSize="12" TextColor="#6B6B6B" VerticalOptions="Center"/>
</HorizontalStackLayout>
<Label Text="•" FontSize="12" TextColor="#999999" VerticalOptions="Center"/>
<HorizontalStackLayout Spacing="4" VerticalOptions="Center">
<Image Source="profile.png" WidthRequest="14" HeightRequest="14" VerticalOptions="Center"/>
<Label Text="{Binding Head.FullName}" FontSize="12" TextColor="#6B6B6B" VerticalOptions="Center" LineBreakMode="TailTruncation"/>
</HorizontalStackLayout>
</HorizontalStackLayout>
<!-- Аватары руководителя (если есть) -->
<HorizontalStackLayout Spacing="-8" Margin="0,5,0,0" HeightRequest="28">
<Image Source="{Binding Head.DisplayAvatarSource}" WidthRequest="28" HeightRequest="28" Aspect="AspectFill"/>
</HorizontalStackLayout>
</VerticalStackLayout>
</Border>
</DataTemplate>
</CollectionView.ItemTemplate>
</CollectionView>
</RefreshView>
<!-- Индикатор загрузки -->

View File

@ -15,6 +15,8 @@ public partial class DepartmentsPage : ContentPage
{
Trace.WriteLine("=== DEPARTMENTSPAGE CONSTRUCTOR START ===");
InitializeComponent();
// Привязываем пустой источник — будет заполнен при загрузке
DepartmentsCollectionView.ItemsSource = new List<Department>();
_ = 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<Department> 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<Department> departments)
private async Task PreloadHeadsAvatarsAsync(List<Department> departments)
{
try
{
foreach (var dept in departments)
{
if (dept.Head?.Id == null)
continue;
var card = DepartmentsFlexLayout.Children
.OfType<Border>()
.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<HorizontalStackLayout>().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();
}
/// <summary>
/// Попытаться извлечь Department из sender или TappedEventArgs
/// </summary>
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;
}
}
}
}

View File

@ -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
/// <summary>
/// Конструктор для передачи готового объекта пользователя
/// </summary>
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();
}
}

View File

@ -89,8 +89,12 @@
<CollectionView
x:Name="EmployeesCollectionView"
SelectionMode="None"
BackgroundColor="Transparent">
SelectionMode="Single"
SelectionChanged="EmployeesCollectionView_SelectionChanged"
ItemsLayout="VerticalList"
BackgroundColor="Transparent"
VerticalOptions="FillAndExpand"
Margin="0">
<CollectionView.EmptyView>
<VerticalStackLayout Style="{StaticResource EmptyStateContainer}">
@ -109,22 +113,26 @@
<DataTemplate x:DataType="models:User">
<Border
Style="{StaticResource CardBorder}"
Margin="10,5">
Margin="6,4">
<Border.GestureRecognizers>
<TapGestureRecognizer Tapped="OnEmployeeCardTapped" CommandParameter="{Binding .}"/>
</Border.GestureRecognizers>
<Grid ColumnDefinitions="60,*,50">
<Grid ColumnDefinitions="50,*,44">
<!-- Аватар -->
<Border
Grid.Column="0"
Style="{StaticResource AvatarMedium}">
Style="{StaticResource AvatarMedium}"
WidthRequest="50"
HeightRequest="50">
<Grid>
<Image
Source="{Binding DisplayAvatarSource}"
Aspect="AspectFill"/>
Aspect="AspectFill"
WidthRequest="48"
HeightRequest="48"/>
<!-- Индикатор онлайн статуса -->
<Border
Style="{StaticResource OnlineIndicatorMedium}"
@ -137,7 +145,7 @@
Grid.Column="1"
VerticalOptions="Center"
Spacing="3"
Margin="10,0">
Margin="8,0">
<Label
Text="{Binding FullName}"
@ -157,7 +165,9 @@
<Border
Grid.Column="2"
Style="{StaticResource IconButton}"
VerticalOptions="Center">
VerticalOptions="Center"
WidthRequest="44"
HeightRequest="44">
<Border.GestureRecognizers>
<TapGestureRecognizer Tapped="OnChatIconTapped" CommandParameter="{Binding .}"/>
</Border.GestureRecognizers>

View File

@ -1,6 +1,7 @@
using System.Collections.ObjectModel;
using System.Diagnostics;
using CORP_MAUI.Models;
using Microsoft.Maui.Controls; // для TapGestureRecognizer и BindableObject
namespace CORP_MAUI.Pages;
@ -102,25 +103,52 @@ public partial class EmployeesPage : ContentPage
await LoadEmployeesAsync();
}
/// <summary>
/// Попытаться извлечь User из события нажатия (поддерживает CommandParameter, TapGestureRecognizer и BindingContext)
/// </summary>
private static User? GetUserFromTap(object? sender, TappedEventArgs? e)
{
// Сначала пробуем Parameter из TappedEventArgs
if (e?.Parameter is User userFromParam)
return userFromParam;
// Если sender - это TapGestureRecognizer
if (sender is TapGestureRecognizer tr)
{
if (tr.BindingContext is User userFromTr) return userFromTr;
if (tr.Parent is BindableObject parent && parent.BindingContext is User userFromParent) return userFromParent;
}
// Если sender - это элемент с BindingContext (например Border)
if (sender is BindableObject bo && bo.BindingContext is User userFromBo)
{
return userFromBo;
}
return null;
}
/// <summary>
/// Обработчик клика по карточке сотрудника (открытие профиля)
/// </summary>
private async void OnEmployeeCardTapped(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 EmployeeProfilePage(selectedEmployee));
}
catch (Exception ex)
{
Trace.WriteLine($"[EmployeesPage] ❌ Ошибка перехода к профилю: {ex.Message}");
await DisplayAlert("Ошибка", "Не удалось открыть профиль сотрудника", "OK");
}
Trace.WriteLine("[EmployeesPage] ⚠️ Не удалось определить выбранного сотрудника в OnEmployeeCardTapped");
return;
}
Trace.WriteLine($"[EmployeesPage] >>> Открытие профиля: {selectedEmployee.FullName}");
try
{
await Shell.Current.Navigation.PushAsync(new EmployeeProfilePage(selectedEmployee));
}
catch (Exception ex)
{
Trace.WriteLine($"[EmployeesPage] ❌ Ошибка перехода к профилю: {ex.Message}");
await DisplayAlert("Ошибка", "Не удалось открыть профиль сотрудника", "OK");
}
}
@ -129,19 +157,22 @@ public partial class EmployeesPage : ContentPage
/// </summary>
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] >>> Начинаем предзагрузку ават<EFBFBD><EFBFBD>ров...");
await ServiceProvider.ApiService.PreloadUserAvatarsAsync(_allEmployees);
Trace.WriteLine("[EmployeesPage] ✅ Аватары предзагружены");
}
@ -215,5 +246,33 @@ public partial class EmployeesPage : ContentPage
Trace.WriteLine($"[EmployeesPage] ⚠️ Ошибка предзагрузки аватаров: {ex.Message}");
}
}
}
/// <summary>
/// SelectionChanged handler: откроет профиль выбранного сотрудника (вызывается при SelectionMode=Single)
/// </summary>
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;
}
}
}
}

View File

@ -184,7 +184,6 @@
<Setter Property="TextColor" Value="White"/>
<Setter Property="FontSize" Value="16"/>
<Setter Property="FontAttributes" Value="Bold"/>
<Setter Property="CornerRadius" Value="10"/>
<Setter Property="HeightRequest" Value="50"/>
</Style>
@ -193,7 +192,6 @@
<Setter Property="BackgroundColor" Value="{StaticResource Gray200}"/>
<Setter Property="TextColor" Value="{StaticResource Gray900}"/>
<Setter Property="FontSize" Value="16"/>
<Setter Property="CornerRadius" Value="10"/>
<Setter Property="HeightRequest" Value="50"/>
</Style>

View File

@ -783,6 +783,25 @@ public class ApiService : IDisposable
}
}
/// <summary>
/// Вернуть полный URL изображения, если передан относительный путь — добавить BaseUrl
/// </summary>
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('/');
}
/// <summary>
/// Загрузить аватарки для списка пользователей
/// </summary>
@ -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
}
}
/// <summary>
/// Получить текущего пользователя (alias для GetCurrentUserAsync)
/// </summary>
public async Task<User?> GetUserAsync()
{
return await GetCurrentUserAsync();
}
/// <summary>
/// Получить сотрудника по ID
/// </summary>
@ -1450,6 +1474,7 @@ public class ApiService : IDisposable
}
}
/// <summary>
/// Предзагрузить аватары для списка пользователей
/// </summary>

View File

@ -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<User>? UserStatusChanged;
public event EventHandler<bool>? 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);
}