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:
parent
c1de303c22
commit
1215f13e66
7
.idea/.idea.CORP-MAUI/.idea/dictionaries/project.xml
generated
Normal file
7
.idea/.idea.CORP-MAUI/.idea/dictionaries/project.xml
generated
Normal file
@ -0,0 +1,7 @@
|
||||
<component name="ProjectDictionaryState">
|
||||
<dictionary name="project">
|
||||
<words>
|
||||
<w>robotail</w>
|
||||
</words>
|
||||
</dictionary>
|
||||
</component>
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
<!-- Индикатор загрузки -->
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user