Add user avatar preloading functionality and image caching service
This commit is contained in:
parent
e50b799992
commit
d7c2f74cc9
@ -1,5 +1,6 @@
|
||||
using CORP_MAUI.Pages;
|
||||
using CORP_MAUI.Pages;
|
||||
using CORP_MAUI.Models;
|
||||
using CORP_MAUI.Services;
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace CORP_MAUI;
|
||||
@ -20,6 +21,9 @@ public partial class App : Application
|
||||
|
||||
InitializeComponent();
|
||||
|
||||
// Очищаем устаревший кэш изображений
|
||||
ImageCacheService.Instance.CleanExpiredCache();
|
||||
|
||||
Trace.WriteLine("[App] ✅ Application initialized");
|
||||
}
|
||||
|
||||
|
||||
@ -49,7 +49,24 @@ public class User
|
||||
{
|
||||
get
|
||||
{
|
||||
if (!string.IsNullOrEmpty(Avatar)) return Avatar;
|
||||
// Сначала проверяем Avatar (может содержать полный URL или data:image)
|
||||
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}";
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(AvatarUrl)) return null;
|
||||
|
||||
@ -60,40 +77,93 @@ public class User
|
||||
if (AvatarUrl.StartsWith("http", StringComparison.OrdinalIgnoreCase))
|
||||
return AvatarUrl;
|
||||
|
||||
string cleanUrl = AvatarUrl.TrimStart('/');
|
||||
return $"https://corp.robotail.pro/{cleanUrl}";
|
||||
string cleanPath = AvatarUrl.TrimStart('/');
|
||||
return $"https://corp.robotail.pro/{cleanPath}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Универсальное свойство для отображения аватара (поддерживает Base64 и URL)
|
||||
/// Кэшированный ImageSource аватара (не создаётся каждый раз)
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
private ImageSource? _cachedImageSource;
|
||||
|
||||
/// <summary>
|
||||
/// Путь к файлу кэшированного аватара
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public string? CachedAvatarFilePath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Флаг, что аватар был успешно загружен
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public bool IsAvatarLoaded { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Установить путь к файлу кэшированного аватара
|
||||
/// </summary>
|
||||
public void SetCachedAvatarFilePath(string filePath)
|
||||
{
|
||||
CachedAvatarFilePath = filePath;
|
||||
_cachedImageSource = ImageSource.FromFile(filePath);
|
||||
IsAvatarLoaded = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Сбросить кэш ImageSource
|
||||
/// </summary>
|
||||
public void ResetImageSourceCache()
|
||||
{
|
||||
_cachedImageSource = null;
|
||||
IsAvatarLoaded = false;
|
||||
CachedAvatarFilePath = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Универсальное свойство для отображения аватара
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public ImageSource? DisplayAvatarSource
|
||||
{
|
||||
get
|
||||
{
|
||||
// Если аватар уже загружен - возвращаем кэшированный
|
||||
if (IsAvatarLoaded && _cachedImageSource != null)
|
||||
{
|
||||
return _cachedImageSource;
|
||||
}
|
||||
|
||||
// Проверяем путь к файлу
|
||||
if (!string.IsNullOrEmpty(CachedAvatarFilePath) && File.Exists(CachedAvatarFilePath))
|
||||
{
|
||||
_cachedImageSource = ImageSource.FromFile(CachedAvatarFilePath);
|
||||
IsAvatarLoaded = true;
|
||||
return _cachedImageSource;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// 1. Проверяем, есть ли Base64 изображение в Avatar
|
||||
if (!string.IsNullOrEmpty(Avatar) && Avatar.StartsWith("data:image"))
|
||||
{
|
||||
// Извлекаем Base64 данные из строки "data:image/jpeg;base64,..."
|
||||
string base64Data = Avatar;
|
||||
int commaIndex = base64Data.IndexOf(',');
|
||||
if (commaIndex > 0)
|
||||
{
|
||||
base64Data = base64Data.Substring(commaIndex + 1);
|
||||
byte[] imageBytes = Convert.FromBase64String(base64Data);
|
||||
return ImageSource.FromStream(() => new MemoryStream(imageBytes));
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Если есть URL аватара
|
||||
// Проверяем глобальный файловый кэш
|
||||
string? url = DisplayAvatarUrl;
|
||||
if (!string.IsNullOrEmpty(url))
|
||||
{
|
||||
return ImageSource.FromUri(new Uri(url));
|
||||
string? cachedPath = Services.ImageCacheService.Instance.GetCachedFilePath(url);
|
||||
if (!string.IsNullOrEmpty(cachedPath) && File.Exists(cachedPath))
|
||||
{
|
||||
CachedAvatarFilePath = cachedPath;
|
||||
_cachedImageSource = ImageSource.FromFile(cachedPath);
|
||||
IsAvatarLoaded = true;
|
||||
return _cachedImageSource;
|
||||
}
|
||||
|
||||
// Если нет в кэше - используем UriImageSource
|
||||
return new UriImageSource
|
||||
{
|
||||
Uri = new Uri(url),
|
||||
CachingEnabled = true,
|
||||
CacheValidity = TimeSpan.FromDays(7)
|
||||
};
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
@ -101,7 +171,7 @@ public class User
|
||||
Trace.WriteLine($"[User] ⚠️ Ошибка загрузки аватара для {FullName}: {ex.Message}");
|
||||
}
|
||||
|
||||
// 3. Возвращаем placeholder
|
||||
// Возвращаем placeholder
|
||||
return ImageSource.FromFile("dotnet_bot.png");
|
||||
}
|
||||
}
|
||||
|
||||
@ -36,6 +36,13 @@ public partial class ChatPage : ContentPage
|
||||
{
|
||||
try
|
||||
{
|
||||
// Загружаем аватар получателя если еще не загружен
|
||||
if (!_recipient.IsAvatarLoaded)
|
||||
{
|
||||
await ServiceProvider.ApiService.LoadUserAvatarAsync(_recipient);
|
||||
RecipientAvatar.Source = _recipient.DisplayAvatarSource;
|
||||
}
|
||||
|
||||
// Получаем текущего пользователя
|
||||
User? user = await ServiceProvider.ApiService.GetCurrentUserAsync();
|
||||
if (user != null)
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Diagnostics;
|
||||
using CORP_MAUI.Models;
|
||||
namespace CORP_MAUI.Pages;
|
||||
@ -73,6 +73,9 @@ public partial class DepartmentDetailsPage : ContentPage
|
||||
HeadSection.IsVisible = true;
|
||||
HeadNameLabel.Text = _department.Head.FullName;
|
||||
HeadPositionLabel.Text = _department.Head.PositionName ?? "Должность не указана";
|
||||
|
||||
// Загружаем аватар руководителя
|
||||
await ServiceProvider.ApiService.LoadUserAvatarAsync(_department.Head);
|
||||
HeadAvatarImage.Source = _department.Head.DisplayAvatarSource;
|
||||
}
|
||||
Trace.WriteLine($"[DepartmentDetailsPage] >>> Вызов GetDepartmentEmployeesAsync({_departmentId})");
|
||||
@ -96,6 +99,9 @@ public partial class DepartmentDetailsPage : ContentPage
|
||||
}
|
||||
}
|
||||
Trace.WriteLine($"[DepartmentDetailsPage] ✅ Данные загружены успешно. Команда: {_teamMembers.Count} человек");
|
||||
|
||||
// Предзагружаем аватары в фоне
|
||||
_ = PreloadAvatarsAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@ -153,4 +159,39 @@ public partial class DepartmentDetailsPage : ContentPage
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task PreloadAvatarsAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
Trace.WriteLine("[DepartmentDetailsPage] >>> Начинаем предзагрузку аватаров...");
|
||||
|
||||
// Загружаем аватар руководителя
|
||||
if (_department?.Head != null)
|
||||
{
|
||||
await ServiceProvider.ApiService.LoadUserAvatarAsync(_department.Head);
|
||||
}
|
||||
|
||||
// Загружаем аватары команды
|
||||
await ServiceProvider.ApiService.PreloadUserAvatarsAsync(_teamMembers);
|
||||
|
||||
// Обновляем UI
|
||||
await MainThread.InvokeOnMainThreadAsync(() =>
|
||||
{
|
||||
TeamCollectionView.ItemsSource = null;
|
||||
TeamCollectionView.ItemsSource = _teamMembers;
|
||||
|
||||
if (_department?.Head != null)
|
||||
{
|
||||
HeadAvatarImage.Source = _department.Head.DisplayAvatarSource;
|
||||
}
|
||||
});
|
||||
|
||||
Trace.WriteLine("[DepartmentDetailsPage] ✅ Аватары предзагружены");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.WriteLine($"[DepartmentDetailsPage] ⚠️ Ошибка предзагрузки аватаров: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -81,6 +81,9 @@ public partial class EmployeeProfilePage : ContentPage
|
||||
|
||||
Trace.WriteLine($"[EmployeeProfilePage] ✅ Профиль загружен: {_user.FullName}");
|
||||
|
||||
// Предзагружаем аватар пользователя
|
||||
await ServiceProvider.ApiService.LoadUserAvatarAsync(_user);
|
||||
|
||||
// Заполняем основную информацию
|
||||
FullNameLabel.Text = _user.FullName;
|
||||
PositionLabel.Text = _user.PositionName ?? "Должность не указана";
|
||||
|
||||
@ -77,6 +77,9 @@ public partial class EmployeesPage : ContentPage
|
||||
}
|
||||
|
||||
Trace.WriteLine($"[EmployeesPage] ✅ Сотрудники загружены успешно");
|
||||
|
||||
// Предзагружаем аватары в фоне
|
||||
_ = PreloadAvatarsAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@ -198,5 +201,28 @@ public partial class EmployeesPage : ContentPage
|
||||
base.OnDisappearing();
|
||||
ServiceProvider.WebSocketService.UserStatusChanged -= OnUserStatusChanged;
|
||||
}
|
||||
|
||||
private async Task PreloadAvatarsAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
Trace.WriteLine("[EmployeesPage] >>> Начинаем предзагрузку аватаров...");
|
||||
await ServiceProvider.ApiService.PreloadUserAvatarsAsync(_allEmployees);
|
||||
|
||||
// Обновляем UI - уведомляем об изменении коллекции
|
||||
await MainThread.InvokeOnMainThreadAsync(() =>
|
||||
{
|
||||
// CollectionView автоматически обновится, т.к. ImageSource уже установлен в объектах
|
||||
EmployeesCollectionView.ItemsSource = null;
|
||||
EmployeesCollectionView.ItemsSource = _employees;
|
||||
});
|
||||
|
||||
Trace.WriteLine("[EmployeesPage] ✅ Аватары предзагружены");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.WriteLine($"[EmployeesPage] ⚠️ Ошибка предзагрузки аватаров: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -13,6 +13,9 @@ public partial class PostDetailsPage : ContentPage
|
||||
InitializeComponent();
|
||||
_viewModel = new PostDetailsViewModel(postViewModel);
|
||||
BindingContext = _viewModel;
|
||||
|
||||
// Предзагружаем аватар автора поста
|
||||
_ = PreloadPostAuthorAvatarAsync();
|
||||
}
|
||||
|
||||
protected override async void OnAppearing()
|
||||
@ -38,6 +41,9 @@ public partial class PostDetailsPage : ContentPage
|
||||
{
|
||||
_viewModel.Comments.Add(comment);
|
||||
}
|
||||
|
||||
// Предзагружаем аватары авторов комментариев
|
||||
_ = PreloadCommentAuthorsAvatarsAsync();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
@ -49,6 +55,50 @@ public partial class PostDetailsPage : ContentPage
|
||||
_viewModel.IsLoadingComments = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task PreloadPostAuthorAvatarAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var author = _viewModel.PostViewModel.Post.Author;
|
||||
if (author != null)
|
||||
{
|
||||
await ServiceProvider.ApiService.LoadUserAvatarAsync(author);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.WriteLine($"[PostDetailsPage] Ошибка предзагрузки аватара автора: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task PreloadCommentAuthorsAvatarsAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var authors = _viewModel.Comments
|
||||
.Where(c => c.Author != null)
|
||||
.Select(c => c.Author!)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
if (authors.Count > 0)
|
||||
{
|
||||
await ServiceProvider.ApiService.PreloadUserAvatarsAsync(authors);
|
||||
|
||||
// Обновляем UI - переустанавливаем ItemsSource
|
||||
await MainThread.InvokeOnMainThreadAsync(() =>
|
||||
{
|
||||
CommentsCollectionView.ItemsSource = null;
|
||||
CommentsCollectionView.ItemsSource = _viewModel.Comments;
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.WriteLine($"[PostDetailsPage] Ошибка предзагрузки аватаров комментаторов: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private async void OnCloseClicked(object sender, EventArgs e)
|
||||
{
|
||||
|
||||
@ -40,8 +40,9 @@ public partial class PostsPage
|
||||
try
|
||||
{
|
||||
var user = await ServiceProvider.ApiService.GetCurrentUserAsync();
|
||||
if (user != null && !string.IsNullOrEmpty(user.Avatar))
|
||||
if (user != null)
|
||||
{
|
||||
await ServiceProvider.ApiService.LoadUserAvatarAsync(user);
|
||||
UserAvatarImage.Source = user.DisplayAvatarSource;
|
||||
}
|
||||
}
|
||||
@ -217,6 +218,9 @@ public partial class PostsPage
|
||||
|
||||
// Применяем фильтр поиска
|
||||
ApplyFilter();
|
||||
|
||||
// Предзагружаем аватары авторов постов в фоне
|
||||
_ = PreloadPostAuthorsAvatarsAsync(viewModels);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
@ -232,6 +236,34 @@ public partial class PostsPage
|
||||
PostsRefreshView.IsRefreshing = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task PreloadPostAuthorsAvatarsAsync(IEnumerable<PostViewModel> posts)
|
||||
{
|
||||
try
|
||||
{
|
||||
var authors = posts
|
||||
.Where(p => p.Post.Author != null)
|
||||
.Select(p => p.Post.Author!)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
if (authors.Count > 0)
|
||||
{
|
||||
await ServiceProvider.ApiService.PreloadUserAvatarsAsync(authors);
|
||||
|
||||
// Обновляем UI - переустанавливаем ItemsSource
|
||||
await MainThread.InvokeOnMainThreadAsync(() =>
|
||||
{
|
||||
PostsCollectionView.ItemsSource = null;
|
||||
PostsCollectionView.ItemsSource = _posts;
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.WriteLine($"[PostsPage] Ошибка предзагрузки аватаров авторов: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private async void OnRefreshing(object sender, EventArgs e)
|
||||
{
|
||||
|
||||
@ -1393,6 +1393,77 @@ public class ApiService : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Предзагрузить аватары для списка пользователей
|
||||
/// </summary>
|
||||
public async Task PreloadUserAvatarsAsync(IEnumerable<User> users)
|
||||
{
|
||||
var userList = users.ToList();
|
||||
var tasks = new List<Task>();
|
||||
|
||||
foreach (var user in userList)
|
||||
{
|
||||
// Пропускаем, если аватар уже загружен
|
||||
if (user.IsAvatarLoaded)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string? url = user.DisplayAvatarUrl;
|
||||
if (string.IsNullOrEmpty(url))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Проверяем файловый кэш
|
||||
string? cachedPath = ImageCacheService.Instance.GetCachedFilePath(url);
|
||||
if (!string.IsNullOrEmpty(cachedPath) && File.Exists(cachedPath))
|
||||
{
|
||||
// Устанавливаем путь к файлу
|
||||
user.SetCachedAvatarFilePath(cachedPath);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Добавляем в очередь загрузки
|
||||
tasks.Add(LoadUserAvatarAsync(user));
|
||||
}
|
||||
}
|
||||
|
||||
if (tasks.Count > 0)
|
||||
{
|
||||
Trace.WriteLine($"[ApiService] Загрузка {tasks.Count} аватаров...");
|
||||
await Task.WhenAll(tasks);
|
||||
Trace.WriteLine($"[ApiService] ✅ Аватары загружены");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Загрузить аватар для одного пользователя (файловый кэш)
|
||||
/// </summary>
|
||||
public async Task LoadUserAvatarAsync(User user)
|
||||
{
|
||||
try
|
||||
{
|
||||
string? url = user.DisplayAvatarUrl;
|
||||
if (string.IsNullOrEmpty(url))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Загружаем через файловый кэш
|
||||
string? filePath = await ImageCacheService.Instance.GetOrLoadImageFileAsync(url, GetImageBytesAsync);
|
||||
if (!string.IsNullOrEmpty(filePath) && File.Exists(filePath))
|
||||
{
|
||||
user.SetCachedAvatarFilePath(filePath);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.WriteLine($"[ApiService] Ошибка предзагрузки аватара для {user.FullName}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#endregion
|
||||
|
||||
#region IDisposable
|
||||
|
||||
185
CORP-MAUI/Services/ImageCacheService.cs
Normal file
185
CORP-MAUI/Services/ImageCacheService.cs
Normal file
@ -0,0 +1,185 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace CORP_MAUI.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Сервис для кэширования изображений на диске (файловый кэш)
|
||||
/// </summary>
|
||||
public class ImageCacheService
|
||||
{
|
||||
private static ImageCacheService? _instance;
|
||||
public static ImageCacheService Instance => _instance ??= new ImageCacheService();
|
||||
|
||||
// Кэш путей к файлам (URL -> FilePath)
|
||||
private readonly ConcurrentDictionary<string, string> _pathCache = new();
|
||||
|
||||
// Путь к папке кэша на диске
|
||||
private readonly string _diskCachePath;
|
||||
|
||||
// Максимальное время жизни кэша
|
||||
private readonly TimeSpan _cacheExpiry = TimeSpan.FromDays(7);
|
||||
|
||||
private ImageCacheService()
|
||||
{
|
||||
_diskCachePath = Path.Combine(FileSystem.CacheDirectory, "avatars");
|
||||
|
||||
// Создаём папку кэша, если не существует
|
||||
if (!Directory.Exists(_diskCachePath))
|
||||
{
|
||||
Directory.CreateDirectory(_diskCachePath);
|
||||
}
|
||||
|
||||
Trace.WriteLine($"[ImageCacheService] Initialized. Cache path: {_diskCachePath}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Получить путь к файлу из кэша или загрузить изображение
|
||||
/// </summary>
|
||||
public async Task<string?> GetOrLoadImageFileAsync(string? url, Func<string, Task<byte[]?>>? loader = null)
|
||||
{
|
||||
if (string.IsNullOrEmpty(url)) return null;
|
||||
|
||||
// 1. Проверяем кэш путей
|
||||
if (_pathCache.TryGetValue(url, out string? cachedPath) && File.Exists(cachedPath))
|
||||
{
|
||||
return cachedPath;
|
||||
}
|
||||
|
||||
// 2. Проверяем файл на диске
|
||||
string filePath = GetCacheFilePath(url);
|
||||
if (File.Exists(filePath))
|
||||
{
|
||||
var fileInfo = new FileInfo(filePath);
|
||||
if (fileInfo.Length > 0 && fileInfo.LastWriteTime > DateTime.Now - _cacheExpiry)
|
||||
{
|
||||
_pathCache[url] = filePath;
|
||||
return filePath;
|
||||
}
|
||||
try { File.Delete(filePath); } catch { }
|
||||
}
|
||||
|
||||
// 3. Загружаем изображение
|
||||
if (loader != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
byte[]? imageBytes = await loader(url);
|
||||
if (imageBytes != null && imageBytes.Length > 0)
|
||||
{
|
||||
await File.WriteAllBytesAsync(filePath, imageBytes);
|
||||
_pathCache[url] = filePath;
|
||||
return filePath;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.WriteLine($"[ImageCacheService] Ошибка загрузки {url}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Получить путь к кэшированному файлу (если существует)
|
||||
/// </summary>
|
||||
public string? GetCachedFilePath(string? url)
|
||||
{
|
||||
if (string.IsNullOrEmpty(url)) return null;
|
||||
|
||||
if (_pathCache.TryGetValue(url, out string? cachedPath) && File.Exists(cachedPath))
|
||||
{
|
||||
return cachedPath;
|
||||
}
|
||||
|
||||
string filePath = GetCacheFilePath(url);
|
||||
if (File.Exists(filePath))
|
||||
{
|
||||
var fileInfo = new FileInfo(filePath);
|
||||
if (fileInfo.Length > 0)
|
||||
{
|
||||
_pathCache[url] = filePath;
|
||||
return filePath;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Проверить, есть ли изображение в кэше
|
||||
/// </summary>
|
||||
public bool IsCached(string? url)
|
||||
{
|
||||
if (string.IsNullOrEmpty(url)) return false;
|
||||
|
||||
if (_pathCache.TryGetValue(url, out string? path) && File.Exists(path))
|
||||
return true;
|
||||
|
||||
string filePath = GetCacheFilePath(url);
|
||||
return File.Exists(filePath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Получить путь к файлу кэша для URL
|
||||
/// </summary>
|
||||
private string GetCacheFilePath(string url)
|
||||
{
|
||||
using var md5 = System.Security.Cryptography.MD5.Create();
|
||||
byte[] hashBytes = md5.ComputeHash(System.Text.Encoding.UTF8.GetBytes(url));
|
||||
string hash = BitConverter.ToString(hashBytes).Replace("-", "").ToLowerInvariant();
|
||||
return Path.Combine(_diskCachePath, $"{hash}.jpg");
|
||||
}
|
||||
|
||||
// Для обратной совместимости
|
||||
public string? GetFromCache(string? url) => GetCachedFilePath(url);
|
||||
|
||||
/// <summary>
|
||||
/// Очистить устаревший кэш
|
||||
/// </summary>
|
||||
public void CleanExpiredCache()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!Directory.Exists(_diskCachePath)) return;
|
||||
|
||||
var expiredFiles = Directory.GetFiles(_diskCachePath)
|
||||
.Where(f => File.GetLastWriteTime(f) < DateTime.Now - _cacheExpiry)
|
||||
.ToList();
|
||||
|
||||
foreach (var file in expiredFiles)
|
||||
{
|
||||
try { File.Delete(file); } catch { }
|
||||
}
|
||||
|
||||
if (expiredFiles.Count > 0)
|
||||
{
|
||||
Trace.WriteLine($"[ImageCacheService] Удалено {expiredFiles.Count} устаревших файлов");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.WriteLine($"[ImageCacheService] Ошибка очистки: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Очистить весь кэш
|
||||
/// </summary>
|
||||
public void ClearCache()
|
||||
{
|
||||
_pathCache.Clear();
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(_diskCachePath))
|
||||
{
|
||||
foreach (var file in Directory.GetFiles(_diskCachePath))
|
||||
{
|
||||
try { File.Delete(file); } catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
@ -62,6 +62,9 @@ public class WebSocketService
|
||||
|
||||
_webSocket = new ClientWebSocket();
|
||||
|
||||
// Настройка для Android - увеличиваем таймаут и буферы
|
||||
_webSocket.Options.KeepAliveInterval = TimeSpan.FromSeconds(30);
|
||||
|
||||
// 🔑 Устанавливаем Authorization заголовок
|
||||
Trace.WriteLine($"[WebSocket] 🔑 Setting Authorization header...");
|
||||
Trace.WriteLine($"[WebSocket] Token (first 30 chars): {authToken.Substring(0, Math.Min(30, authToken.Length))}...");
|
||||
@ -70,6 +73,7 @@ public class WebSocketService
|
||||
Trace.WriteLine($"[WebSocket] ✅ Authorization header set: Bearer [token]");
|
||||
|
||||
_cancellationTokenSource = new CancellationTokenSource();
|
||||
_cancellationTokenSource.CancelAfter(TimeSpan.FromSeconds(15)); // Таймаут подключения
|
||||
|
||||
Uri uri = new Uri(url);
|
||||
Trace.WriteLine($"[WebSocket] Connecting to: {uri}");
|
||||
|
||||
@ -12,7 +12,6 @@ public class ProfileViewModel : INotifyPropertyChanged
|
||||
{
|
||||
private User? _user;
|
||||
private bool _isLoading;
|
||||
private string? _localImagePath;
|
||||
|
||||
public ProfileViewModel()
|
||||
{
|
||||
@ -67,53 +66,10 @@ public class ProfileViewModel : INotifyPropertyChanged
|
||||
{
|
||||
get
|
||||
{
|
||||
// Сначала проверяем, есть ли Base64 изображение в User.Avatar
|
||||
if (_user != null && !string.IsNullOrEmpty(_user.Avatar))
|
||||
// Используем DisplayAvatarSource из модели User (работает с файловым кэшем)
|
||||
if (_user != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Проверяем, это Base64 строка?
|
||||
if (_user.Avatar.StartsWith("data:image"))
|
||||
{
|
||||
// Извлекаем Base64 данные из строки "data:image/jpeg;base64,..."
|
||||
string base64Data = _user.Avatar;
|
||||
int commaIndex = base64Data.IndexOf(',');
|
||||
if (commaIndex > 0)
|
||||
{
|
||||
base64Data = base64Data.Substring(commaIndex + 1);
|
||||
byte[] imageBytes = Convert.FromBase64String(base64Data);
|
||||
return ImageSource.FromStream(() => new MemoryStream(imageBytes));
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.WriteLine($"[ProfileViewModel] ⚠️ Ошибка декодирования Base64 аватара: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// Если есть локальный файл кеша
|
||||
if (!string.IsNullOrEmpty(_localImagePath) && File.Exists(_localImagePath))
|
||||
{
|
||||
try
|
||||
{
|
||||
FileInfo fileInfo = new FileInfo(_localImagePath);
|
||||
if (fileInfo.Length > 0)
|
||||
{
|
||||
return ImageSource.FromStream(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
return new FileStream(_localImagePath, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
return _user.DisplayAvatarSource;
|
||||
}
|
||||
|
||||
// Default placeholder
|
||||
@ -179,112 +135,23 @@ public class ProfileViewModel : INotifyPropertyChanged
|
||||
|
||||
private async Task LoadAvatarAsync()
|
||||
{
|
||||
// Если аватар уже в виде Base64 в User.Avatar, ничего не делаем
|
||||
if (_user != null && !string.IsNullOrEmpty(_user.Avatar) && _user.Avatar.StartsWith("data:image"))
|
||||
if (_user == null)
|
||||
{
|
||||
Trace.WriteLine("[ProfileViewModel] ✅ Аватар уже загружен как Base64, пропускаем загрузку");
|
||||
return;
|
||||
}
|
||||
|
||||
if (_user == null || string.IsNullOrEmpty(_user.DisplayAvatarUrl))
|
||||
{
|
||||
Trace.WriteLine("[ProfileViewModel] Нет URL аватара для загрузки");
|
||||
Trace.WriteLine("[ProfileViewModel] Нет пользователя для загрузки аватара");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
string url = _user.DisplayAvatarUrl;
|
||||
Trace.WriteLine($"[ProfileViewModel] Загрузка аватара с URL: {url}");
|
||||
|
||||
// Generate cache filename - безопасное создание Uri
|
||||
Uri? uri = null;
|
||||
try
|
||||
{
|
||||
uri = new Uri(url);
|
||||
}
|
||||
catch (UriFormatException uriEx)
|
||||
{
|
||||
Trace.WriteLine($"[ProfileViewModel] ⚠️ Некорректный URL аватара: {uriEx.Message}");
|
||||
return;
|
||||
}
|
||||
|
||||
string extension = Path.GetExtension(uri.LocalPath);
|
||||
if (string.IsNullOrEmpty(extension)) extension = ".jpg";
|
||||
|
||||
string fileName = Path.GetFileNameWithoutExtension(uri.LocalPath);
|
||||
if (string.IsNullOrEmpty(fileName)) fileName = $"avatar_{_user.Id}";
|
||||
|
||||
// Заменяем все недопустимые символы
|
||||
fileName = Path.GetInvalidFileNameChars().Aggregate(fileName, (current, c) => current.Replace(c, '_'));
|
||||
|
||||
string cacheFileName = $"user_{_user.Id}_{fileName}{extension}";
|
||||
string cacheFilePath = Path.Combine(FileSystem.CacheDirectory, cacheFileName);
|
||||
|
||||
Trace.WriteLine($"[ProfileViewModel] Путь кеша: {cacheFilePath}");
|
||||
|
||||
// Check cache
|
||||
if (File.Exists(cacheFilePath))
|
||||
{
|
||||
FileInfo fileInfo = new FileInfo(cacheFilePath);
|
||||
if (fileInfo.Length > 0)
|
||||
{
|
||||
Trace.WriteLine($"[ProfileViewModel] ✅ Аватар найден в кеше ({fileInfo.Length} bytes)");
|
||||
_localImagePath = cacheFilePath;
|
||||
OnPropertyChanged(nameof(DisplayImageSource));
|
||||
return;
|
||||
}
|
||||
else
|
||||
{
|
||||
Trace.WriteLine($"[ProfileViewModel] ⚠️ Файл кеша пуст, удаляем");
|
||||
try { File.Delete(cacheFilePath); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
// Download
|
||||
Trace.WriteLine($"[ProfileViewModel] Загрузка аватара с сервера...");
|
||||
byte[]? bytes = await ServiceProvider.ApiService.GetImageBytesAsync(url);
|
||||
|
||||
if (bytes is { Length: > 0 })
|
||||
{
|
||||
Trace.WriteLine($"[ProfileViewModel] Получено {bytes.Length} bytes");
|
||||
|
||||
// Simple signature check
|
||||
bool isImage = false;
|
||||
if (bytes.Length > 2)
|
||||
{
|
||||
if (bytes[0] == 0xFF && bytes[1] == 0xD8) isImage = true; // JPEG
|
||||
else if (bytes[0] == 0x89 && bytes[1] == 0x50) isImage = true; // PNG
|
||||
else isImage = true; // Relaxed check
|
||||
|
||||
if (bytes[0] == '<') isImage = false; // HTML check
|
||||
}
|
||||
|
||||
if (isImage)
|
||||
{
|
||||
Trace.WriteLine($"[ProfileViewModel] ✅ Данные похожи на изображение, сохраняем в кеш");
|
||||
await File.WriteAllBytesAsync(cacheFilePath, bytes);
|
||||
_localImagePath = cacheFilePath;
|
||||
OnPropertyChanged(nameof(DisplayImageSource));
|
||||
Trace.WriteLine($"[ProfileViewModel] ✅ Аватар сохранён и отображён");
|
||||
}
|
||||
else
|
||||
{
|
||||
Trace.WriteLine($"[ProfileViewModel] ⚠️ Данные не похожи на изображение");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Trace.WriteLine($"[ProfileViewModel] ⚠️ Не удалось загрузить данные аватара (пустой ответ)");
|
||||
}
|
||||
// Используем централизованный метод загрузки аватара
|
||||
await ServiceProvider.ApiService.LoadUserAvatarAsync(_user);
|
||||
OnPropertyChanged(nameof(DisplayImageSource));
|
||||
Trace.WriteLine("[ProfileViewModel] ✅ Аватар загружен");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.WriteLine($"[ProfileViewModel] ❌ Ошибка загрузки аватара:");
|
||||
Trace.WriteLine($" Type: {ex.GetType().Name}");
|
||||
Trace.WriteLine($" Message: {ex.Message}");
|
||||
Trace.WriteLine($" StackTrace: {ex.StackTrace}");
|
||||
// Не пробрасываем исключение дальше - ошибка загрузки аватара не критична
|
||||
Trace.WriteLine($"[ProfileViewModel] ❌ Ошибка загрузки аватара: {ex.Message}");
|
||||
// Ошибка загрузки аватара не критична
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user