Update API documentation and fix warnings

This commit is contained in:
Данил Ярош 2026-01-12 17:02:53 +03:00
parent 036b9737b1
commit 674e470272
38 changed files with 2328 additions and 803 deletions

View File

@ -1,6 +1,11 @@
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AApplication_002Ecs_002Fl_003AC_0021_003FUsers_003F_041D_0430_0442_0430_043B_044C_044F_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Ff22149983bac48248d71c7a379de87496f7620_003F5f_003F5808932b_003FApplication_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AApplyPropertiesVisitor_002Ecs_002Fl_003AC_0021_003FUsers_003F_041D_0430_0442_0430_043B_044C_044F_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F3687bba8cb9dcb48ecc525e424af18af03833292dd05261903e25b49fb3a7ae_003FApplyPropertiesVisitor_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ADebugProvider_002Ecs_002Fl_003AC_0021_003FUsers_003F_041D_0430_0442_0430_043B_044C_044F_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fbe397279dcbf6827668054d71291b63f75cef2f116f65944ddffcca6d8d113f_003FDebugProvider_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ADebug_002Ecs_002Fl_003AC_0021_003FUsers_003F_041D_0430_0442_0430_043B_044C_044F_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F56b895832ba1dd5dcf6eab77be40a0537da9d1bf60cfbac19515378f1ac9ca13_003FDebug_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AExceptionDispatchInfo_002Ecs_002Fl_003AC_0021_003FUsers_003F_041D_0430_0442_0430_043B_044C_044F_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fbd1d5c50194fea68ff3559c160230b0ab50f5acf4ce3061bffd6d62958e2182_003FExceptionDispatchInfo_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AMauiWinUIApplication_002Ecs_002Fl_003AC_0021_003FUsers_003F_041D_0430_0442_0430_043B_044C_044F_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Feb4628845efd624a4bdad5d73b53774c81232eee27ca2aa293d869e5e566_003FMauiWinUIApplication_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003APages_005FProfilePage_002Examl_002Esg_002Ecs_002Fl_003AC_0021_003FUsers_003F_041D_0430_0442_0430_043B_044C_044F_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F2dc829dd3f62e98b28b779bb99a37aa7f9c521_003FPages_005FProfilePage_002Examl_002Esg_002Ecs/@EntryIndexedValue">ForceIncluded</s:String></wpf:ResourceDictionary>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AObject_002Ecs_002Fl_003AC_0021_003FUsers_003F_041D_0430_0442_0430_043B_044C_044F_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F9a6b1457cbcf17db31a383ba49ef9bcc786cf3ef77146d997eee499b27a46d_003FObject_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003APages_005FProfilePage_002Examl_002Esg_002Ecs_002Fl_003AC_0021_003FUsers_003F_041D_0430_0442_0430_043B_044C_044F_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F2dc829dd3f62e98b28b779bb99a37aa7f9c521_003FPages_005FProfilePage_002Examl_002Esg_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AString_002Ecs_002Fl_003AC_0021_003FUsers_003F_041D_0430_0442_0430_043B_044C_044F_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F876cd892fc66a9dc8f6afd3704c264acebdfc46aed08089463e8117c21a532_003FString_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AString_002EManipulation_002Ecs_002Fl_003AC_0021_003FUsers_003F_041D_0430_0442_0430_043B_044C_044F_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fe75a5575ba872c8ea754c015cb363850e6c661f39569712d5b74aaca67263c_003FString_002EManipulation_002Ecs/@EntryIndexedValue">ForceIncluded</s:String></wpf:ResourceDictionary>

View File

@ -1,4 +1,6 @@
using CORP_MAUI.Pages;
using CORP_MAUI.Models;
using System.Diagnostics;
namespace CORP_MAUI;
@ -6,23 +8,161 @@ public partial class App : Application
{
public App()
{
Trace.WriteLine("╔═════════════════════════════════════════════════════════════════════════════════╗");
Trace.WriteLine("║ [App] APPLICATION CONSTRUCTOR STARTED ║");
Trace.WriteLine("╚═════════════════════════════════════════════════════════════════════════════════╝");
#if DEBUG
Trace.WriteLine("[App] ✅ DEBUG MODE IS ACTIVE!");
#else
Trace.WriteLine("[App] ⚠️ DEBUG MODE IS NOT ACTIVE!");
#endif
InitializeComponent();
Trace.WriteLine("[App] ✅ Application initialized");
}
protected override Window CreateWindow(IActivationState? activationState)
{
// Проверяем, есть ли сохраненный токен
var hasToken = SecureStorage.Default.GetAsync("auth_token").Result != null;
if (hasToken)
Trace.WriteLine("[App] >>> CreateWindow called");
// Создаем окно с начальной страницей загрузки
#if ANDROID || IOS || MACCATALYST || WINDOWS
var loadingPage = new ContentPage
{
Content = new VerticalStackLayout
{
VerticalOptions = LayoutOptions.Center,
HorizontalOptions = LayoutOptions.Center,
Spacing = 20,
Children =
{
new ActivityIndicator
{
IsRunning = true,
Color = Colors.Blue
},
new Label
{
Text = "Загрузка...",
HorizontalOptions = LayoutOptions.Center
}
}
}
};
var window = new Window(loadingPage);
#else
// Фолбэк для платформ вне целевых (не должен использоваться в текущих TFM)
var window = new Window();
#endif
// Асинхронно проверяем токен и определяем начальную страницу
_ = InitializeAppAsync(window);
return window;
}
private async Task InitializeAppAsync(Window window)
{
try
{
// Пользователь уже вошел в систему
return new Window(new AppShell());
await CheckAuthenticationAndNavigate(window);
}
else
catch (Exception ex)
{
// Показываем страницу входа
return new Window(new NavigationPage(new LoginPage()));
Trace.WriteLine($"[App] ❌ FATAL: InitializeAppAsync failed: {ex.Message}");
Trace.WriteLine($"[App] Exception type: {ex.GetType().Name}");
Trace.WriteLine($"[App] Stack trace: {ex.StackTrace}");
await MainThread.InvokeOnMainThreadAsync(() =>
{
window.Page = new NavigationPage(new LoginPage());
});
}
}
}
private async Task CheckAuthenticationAndNavigate(Window window)
{
try
{
Trace.WriteLine("[App] >>> Checking authentication status...");
// Пытаемся получить данные текущего пользователя
// Это автоматически загрузит токены из SecureStorage и проверит их валидность
User? user = null;
try
{
user = await ServiceProvider.ApiService.GetCurrentUserAsync();
}
catch (Exception userEx)
{
Trace.WriteLine($"[App] ⚠️ GetCurrentUserAsync failed: {userEx.Message}");
// Если не удалось получить пользователя, значит токен невалиден или отсутствует
user = null;
}
if (user != null)
{
// ✅ Токен валиден, пользователь авторизован
Trace.WriteLine($"[App] ✅ User authenticated: {user.Username} (ID: {user.Id})");
Trace.WriteLine($"[App] Email: {user.Email}");
// Подключаемся к WebSocket
try
{
Trace.WriteLine("[App] >>> Connecting to WebSocket...");
var token = ServiceProvider.ApiService.GetAccessToken();
if (!string.IsNullOrEmpty(token))
{
await ServiceProvider.WebSocketService.ConnectAsync(token);
Trace.WriteLine("[App] ✅ WebSocket connected successfully");
}
else
{
Trace.WriteLine("[App] ⚠️ No access token available for WebSocket");
}
}
catch (Exception wsEx)
{
Trace.WriteLine($"[App] ⚠️ WebSocket connection failed: {wsEx.Message}");
// Продолжаем работу без WebSocket - это не критично
}
// Переходим сразу на главную страницу (AppShell)
Trace.WriteLine("[App] >>> Navigating to AppShell");
await MainThread.InvokeOnMainThreadAsync(() =>
{
window.Page = new AppShell();
Trace.WriteLine("[App] ✅ Navigation to AppShell completed");
});
}
else
{
// ❌ Токен отсутствует или невалиден, показываем страницу входа
Trace.WriteLine("[App] ℹ️ No valid authentication found");
Trace.WriteLine("[App] >>> Showing LoginPage");
await MainThread.InvokeOnMainThreadAsync(() =>
{
window.Page = new NavigationPage(new LoginPage());
Trace.WriteLine("[App] ✅ Navigation to LoginPage completed");
});
}
}
catch (Exception ex)
{
Trace.WriteLine($"[App] ❌ Authentication check failed: {ex.Message}");
Trace.WriteLine($"[App] Exception type: {ex.GetType().Name}");
Trace.WriteLine($"[App] >>> Fallback to LoginPage");
await MainThread.InvokeOnMainThreadAsync(() =>
{
window.Page = new NavigationPage(new LoginPage());
});
}
}
}

View File

@ -3,22 +3,18 @@
x:Class="CORP_MAUI.AppShell"
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:pages="clr-namespace:CORP_MAUI.Pages"
Title="Корпоративный портал"
FlyoutBackgroundColor="{StaticResource Primary}">
xmlns:pages="clr-namespace:CORP_MAUI.Pages">
<TabBar>
<ShellContent
Title="Новости"
ContentTemplate="{DataTemplate pages:PostsPage}" />
<ShellContent
Title="Сотрудники"
Icon="dotnet_bot.png"
ContentTemplate="{DataTemplate pages:UsersPage}"
Route="UsersPage" />
ContentTemplate="{DataTemplate pages:UsersPage}" />
<ShellContent
Title="Профиль"
Icon="dotnet_bot.png"
ContentTemplate="{DataTemplate pages:ProfilePage}"
Route="ProfilePage" />
ContentTemplate="{DataTemplate pages:ProfilePage}" />
</TabBar>
</Shell>

View File

@ -1,4 +1,5 @@
using CORP_MAUI.Pages;
using System.Diagnostics;
using CORP_MAUI.Pages;
namespace CORP_MAUI;
@ -6,39 +7,103 @@ public partial class AppShell : Shell
{
public AppShell()
{
InitializeComponent();
try
{
Trace.WriteLine("=== APPSHELL CONSTRUCTOR START ===");
Trace.WriteLine("[AppShell] >>> Вызов InitializeComponent");
InitializeComponent();
Trace.WriteLine("[AppShell] >>> InitializeComponent завершен");
Trace.WriteLine("[AppShell] >>> Регистрация маршрутов");
// Регистрация маршрутов для навигации
Routing.RegisterRoute(nameof(ChatPage), typeof(ChatPage));
// Регистрация маршрутов для навигации
Routing.RegisterRoute(nameof(ChatPage), typeof(ChatPage));
// Инициализация при загрузке
_ = InitializeAsync();
Trace.WriteLine("[AppShell] ✅ AppShell инициализирован");
Trace.WriteLine("=== APPSHELL CONSTRUCTOR END ===");
// Инициализация без ожидания (так await нельзя из синхронного конструктора)
_ = InitializeAsync();
}
catch (Exception ex)
{
Trace.WriteLine("=== APPSHELL CONSTRUCTOR ERROR ===");
Trace.WriteLine($"[AppShell] ❌ Тип ошибки: {ex.GetType().FullName}");
Trace.WriteLine($"[AppShell] ❌ Сообщение: {ex.Message}");
Trace.WriteLine($"[AppShell] ❌ StackTrace: {ex.StackTrace}");
if (ex.InnerException != null)
{
Trace.WriteLine($"[AppShell] ❌ InnerException: {ex.InnerException.Message}");
Trace.WriteLine($"[AppShell] ❌ InnerException StackTrace: {ex.InnerException.StackTrace}");
}
// Показываем ошибку в диалоге
Task.Run(async () =>
{
await Task.Delay(100);
await MainThread.InvokeOnMainThreadAsync(async () =>
{
var mainPage = Application.Current?.Windows[0]?.Page;
if (mainPage != null)
{
await mainPage.DisplayAlert("Ошибка AppShell",
$"Ошибка инициализации AppShell:\n{ex.Message}\n\nStackTrace:\n{ex.StackTrace}", "OK");
}
});
});
throw;
}
}
private async Task InitializeAsync()
{
try
{
// Инициализируем сервисы
Trace.WriteLine("[AppShell] >>> Начало InitializeAsync");
// Инициализация сервисов (если они не инициализированы)
ServiceProvider.Initialize();
Trace.WriteLine($"[AppShell] IsAuthenticated: {ServiceProvider.ApiService.IsAuthenticated}");
// Проверяем, авторизован ли пользователь (токены загружаются автоматически в ApiService)
if (ServiceProvider.ApiService.IsAuthenticated)
{
// Получаем токен для WebSocket
var accessToken = await SecureStorage.GetAsync("api.jwt_access");
if (!string.IsNullOrEmpty(accessToken))
Trace.WriteLine($"[AppShell] Access token exists: {!string.IsNullOrEmpty(accessToken)}");
Trace.WriteLine($"[AppShell] WebSocket connected: {ServiceProvider.WebSocketService.IsConnected}");
if (!string.IsNullOrEmpty(accessToken) && !ServiceProvider.WebSocketService.IsConnected)
{
// Подключаем WebSocket
Trace.WriteLine("[AppShell] >>> Подключение WebSocket из AppShell");
// Подключаем WebSocket только если его еще не подключен
await ServiceProvider.WebSocketService.ConnectAsync(accessToken);
Trace.WriteLine("[AppShell] ✅ WebSocket подключен из AppShell");
}
else if (ServiceProvider.WebSocketService.IsConnected)
{
Trace.WriteLine("[AppShell] WebSocket уже подключен, пропускаем");
}
}
else
{
Trace.WriteLine("[AppShell] ⚠️ Пользователь не авторизован");
}
Trace.WriteLine("[AppShell] ✅ InitializeAsync завершен");
}
catch (Exception ex)
{
Trace.WriteLine($"[AppShell] ❌ Ошибка InitializeAsync: {ex.Message}");
Trace.WriteLine($"[AppShell] StackTrace: {ex.StackTrace}");
await DisplayAlert("Ошибка", $"Ошибка инициализации: {ex.Message}", "OK");
}
}
}

View File

@ -39,8 +39,13 @@
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'windows'">10.0.17763.0</SupportedOSPlatformVersion>
<TargetPlatformMinVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'windows'">10.0.17763.0</TargetPlatformMinVersion>
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'tizen'">6.5</SupportedOSPlatformVersion>
<Configurations>Debug;Release;Android</Configurations>
<Platforms>AnyCPU</Platforms>
<!-- Путь к JDK для Android сборки -->
<JavaSdkDirectory>D:\Applications\Java\JDK\Temurin\jdk-21.0.9+10</JavaSdkDirectory>
<!-- Define constants for Debug/Release -->
<DefineConstants Condition="'$(Configuration)' == 'Debug'">DEBUG;TRACE</DefineConstants>
<DefineConstants Condition="'$(Configuration)' == 'Release'">TRACE</DefineConstants>
</PropertyGroup>
<ItemGroup>

View File

@ -0,0 +1,20 @@
using System.Diagnostics;
namespace CORP_MAUI;
public static class DebugCheck
{
public static void PrintDebugStatus()
{
#if DEBUG
Trace.WriteLine("╔══════════════════════════════════════════════════════════════════════════════════╗");
Trace.WriteLine("║ ✅ DEBUG СИМВОЛ ОПРЕДЕЛЕН! Режим отладки АКТИВЕН! ║");
Trace.WriteLine("╚═════════════════════════════════════════════════════════════════════════════════╝");
#else
Trace.WriteLine("╔═════════════════════════════════════════════════════════════════════════════════╗");
Trace.WriteLine("║ ❌ DEBUG СИМВОЛ НЕ ОПРЕДЕЛЕН! Режим отладки НЕ АКТИВЕН! ║");
Trace.WriteLine("╚══════════════════════════════════════════════════════════════════════════════════╝");
#endif
}
}

View File

@ -1,25 +0,0 @@
using System.Diagnostics;
namespace CORP_MAUI;
public static class DebugTest
{
static DebugTest()
{
#if DEBUG
Trace.WriteLine("╔══════════════════════════════════════════════════════════════════════════════════╗");
Trace.WriteLine("║ ✅ DEBUG IS DEFINED ✅ ║");
Trace.WriteLine("╚══════════════════════════════════════════════════════════════════════════════════╝");
#else
Trace.WriteLine("╔══════════════════════════════════════════════════════════════════════════════════╗");
Trace.WriteLine("║ ❌ DEBUG IS NOT DEFINED ❌ ║");
Trace.WriteLine("╚══════════════════════════════════════════════════════════════════════════════════╝");
#endif
}
public static void Check()
{
// Пустой метод для вызова статического конструктора
}
}

View File

@ -1,4 +1,6 @@
using Microsoft.Extensions.Logging;
using System.Diagnostics;
using CORP_MAUI.Services;
using CORP_MAUI.Pages;
namespace CORP_MAUI;
@ -16,9 +18,26 @@ public static class MauiProgram
});
#if DEBUG
builder.Logging.AddDebug();
Trace.Listeners.Clear();
Trace.Listeners.Add(new DefaultTraceListener());
Trace.AutoFlush = true;
Trace.WriteLine("╔══════════════════════════════════════════════════════════════════════════════════╗");
Trace.WriteLine("║ [MauiProgram] DEBUG MODE ENABLED - TRACE LISTENER CONFIGURED ║");
Trace.WriteLine("╚══════════════════════════════════════════════════════════════════════════════════╝");
#endif
// Регистрируем сервисы
builder.Services.AddSingleton<ApiService>();
builder.Services.AddSingleton<WebSocketService>();
// Регистрируем страницы
builder.Services.AddTransient<LoginPage>();
builder.Services.AddTransient<UsersPage>();
builder.Services.AddTransient<PostsPage>();
builder.Services.AddTransient<ChatPage>();
builder.Services.AddTransient<ProfilePage>();
return builder.Build();
}
}

View File

@ -0,0 +1,17 @@
namespace CORP_MAUI.Models;
/// <summary>
/// Модель события календаря
/// </summary>
public class CalendarEvent
{
public int Id { get; set; }
public string Title { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public DateTime StartDate { get; set; }
public DateTime EndDate { get; set; }
public string Location { get; set; } = string.Empty;
public List<User> Participants { get; set; } = new();
public User? Creator { get; set; }
}

View File

@ -0,0 +1,63 @@
using System.Text.Json.Serialization;
namespace CORP_MAUI.Models;
/// <summary>
/// Модель комментария
/// </summary>
public class Comment
{
public int Id { get; set; }
[JsonPropertyName("content")]
public string Content { get; set; } = string.Empty;
[JsonPropertyName("body")]
public string? Body { get; set; }
[JsonPropertyName("text")]
public string? Text { get; set; }
[JsonIgnore]
public string DisplayContent
{
get
{
if (!string.IsNullOrWhiteSpace(Content)) return Content;
if (!string.IsNullOrWhiteSpace(Body)) return Body;
return Text ?? string.Empty;
}
}
public User? Author { get; set; }
[JsonPropertyName("post")]
public int Post { get; set; }
[JsonPropertyName("created_at")]
public DateTime CreatedAt { get; set; }
[JsonPropertyName("date")]
public DateTime? Date { get; set; }
[JsonIgnore]
public DateTime DisplayDate
{
get
{
// Если CreatedAt валидный (больше 2000 года, чтоб отсечь дефолт), берем его
if (CreatedAt.Year > 2000) return CreatedAt;
// Иначе пробуем поле Date
if (Date.HasValue) return Date.Value;
// Иначе текущее время
return DateTime.Now;
}
}
[JsonPropertyName("updated_at")]
public DateTime UpdatedAt { get; set; }
[JsonPropertyName("likes_count")]
public int LikesCount { get; set; }
}

View File

@ -0,0 +1,14 @@
namespace CORP_MAUI.Models;
/// <summary>
/// Модель отдела
/// </summary>
public class Department
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public int? Parent { get; set; }
public int EmployeesCount { get; set; }
public User? Head { get; set; }
}

View File

@ -0,0 +1,14 @@
namespace CORP_MAUI.Models;
/// <summary>
/// Модель роли в отделе
/// </summary>
public class DepartmentRole
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public Department? Department { get; set; }
public List<string> Permissions { get; set; } = new();
}

View File

@ -0,0 +1,17 @@
namespace CORP_MAUI.Models;
/// <summary>
/// Модель документа
/// </summary>
public class Document
{
public int Id { get; set; }
public string Title { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public string FileUrl { get; set; } = string.Empty;
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
public User? Author { get; set; }
public string Category { get; set; } = string.Empty;
}

View File

@ -0,0 +1,18 @@
namespace CORP_MAUI.Models;
/// <summary>
/// Модель действия сотрудника
/// </summary>
public class EmployeeAction
{
public int Id { get; set; }
public User? Employee { get; set; }
public string Comment { get; set; } = string.Empty;
public object? Extra { get; set; }
public List<object> History { get; set; } = new();
public string ActionDisplay { get; set; } = string.Empty;
public DateTime Date { get; set; }
public string Action { get; set; } = string.Empty;
public string DateDisplay { get; set; } = string.Empty;
}

View File

@ -0,0 +1,12 @@
namespace CORP_MAUI.Models;
/// <summary>
/// Обобщенная модель для пагинированных ответов
/// </summary>
public class PaginatedResponse<T>
{
public int Count { get; set; }
public string? Next { get; set; }
public string? Previous { get; set; }
public List<T> Results { get; set; } = new();
}

View File

@ -0,0 +1,14 @@
namespace CORP_MAUI.Models;
/// <summary>
/// Модель должности
/// </summary>
public class Position
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public List<int> Groups { get; set; } = new();
public List<string> GroupsVerbose { get; set; } = new();
}

View File

@ -0,0 +1,71 @@
using System.Text.Json.Serialization;
namespace CORP_MAUI.Models;
/// <summary>
/// Модель поста/новости
/// </summary>
public class Post
{
public int Id { get; set; }
[JsonPropertyName("title")]
public string Title { get; set; } = string.Empty;
[JsonPropertyName("text")]
public string? Text { get; set; }
private string _content = string.Empty;
[JsonPropertyName("body")]
public string Content
{
get => !string.IsNullOrWhiteSpace(_content) ? _content : (Text ?? string.Empty);
set => _content = value;
}
[JsonPropertyName("image_url")]
public string? ImageUrl { get; set; }
[JsonPropertyName("image")]
public string? Image { get; set; }
[JsonIgnore]
public string? DisplayImageUrl
{
get
{
var url = !string.IsNullOrEmpty(ImageUrl) ? ImageUrl : Image;
if (string.IsNullOrEmpty(url)) return null;
// Если ссылка уже абсолютная - возвращаем как есть
if (url.StartsWith("http", StringComparison.OrdinalIgnoreCase)) return url;
// Если ссылка относительная - добавляем домен
// Обрабатываем случай, когда url начинается со / или без
var cleanUrl = url.TrimStart('/');
return $"https://corp.robotail.pro/{cleanUrl}";
}
}
[JsonPropertyName("date")]
public DateTime? Date { get; set; }
[JsonIgnore]
public DateTime DisplayDate => CreatedAt.Year > 2000 ? CreatedAt : (Date ?? DateTime.Now);
public User? Author { get; set; }
[JsonPropertyName("created_at")]
public DateTime CreatedAt { get; set; }
[JsonPropertyName("updated_at")]
public DateTime UpdatedAt { get; set; }
[JsonPropertyName("likes_count")]
public int LikesCount { get; set; }
[JsonPropertyName("comments_count")]
public int CommentsCount { get; set; }
public List<string> Tags { get; set; } = new();
}

View File

@ -0,0 +1,17 @@
namespace CORP_MAUI.Models;
/// <summary>
/// Модель запроса (отпуск, заявка и т.д.)
/// </summary>
public class Request
{
public int Id { get; set; }
public string Type { get; set; } = string.Empty;
public string Status { get; set; } = string.Empty;
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
public User? Requester { get; set; }
public User? Approver { get; set; }
public string Description { get; set; } = string.Empty;
}

View File

@ -0,0 +1,11 @@
namespace CORP_MAUI.Models;
/// <summary>
/// Модель навыка
/// </summary>
public class Skill
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
}

View File

@ -1,3 +1,5 @@
using System.Text.Json.Serialization;
namespace CORP_MAUI.Models;
public class User
@ -5,10 +7,55 @@ public class User
public int Id { get; set; }
public string Username { get; set; } = string.Empty;
public string Email { get; set; } = string.Empty;
public string FullName { get; set; } = string.Empty;
public string Department { get; set; } = string.Empty;
public string Position { get; set; } = string.Empty;
public string AvatarUrl { get; set; } = string.Empty;
[JsonPropertyName("first_name")]
public string FirstName { get; set; } = string.Empty;
[JsonPropertyName("last_name")]
public string LastName { get; set; } = string.Empty;
// Может быть либо объектом Department, либо строкой (для обратной совместимости)
public object? Department { get; set; }
// Может быть либо объектом Position, либо строкой (для обратной совместимости)
public object? Position { get; set; }
public List<Skill>? Skills { get; set; }
[JsonPropertyName("is_online")]
public bool IsOnline { get; set; }
[JsonPropertyName("avatar_url")]
public string? AvatarUrl { get; set; }
// Вспомогательные свойства для удобства
[JsonIgnore]
public string FullName => $"{FirstName} {LastName}".Trim();
[JsonIgnore]
public string DepartmentName
{
get
{
if (Department is Department dept)
return dept.Name;
if (Department is string str)
return str;
return string.Empty;
}
}
[JsonIgnore]
public string PositionName
{
get
{
if (Position is Position pos)
return pos.Name;
if (Position is string str)
return str;
return string.Empty;
}
}
}

View File

@ -22,6 +22,7 @@ public partial class ChatPage : ContentPage
// Подписываемся на события WebSocket
ServiceProvider.WebSocketService.MessageReceived += OnMessageReceived;
ServiceProvider.WebSocketService.MessageHistoryReceived += OnMessageHistoryReceived;
ServiceProvider.WebSocketService.ConnectionStatusChanged += OnConnectionStatusChanged;
// Инициализация
@ -40,11 +41,18 @@ public partial class ChatPage : ContentPage
}
// Отображаем информацию о получателе
RecipientName.Text = _recipient.FullName ?? _recipient.Username;
RecipientName.Text = _recipient.FullName;
RecipientStatus.Text = _recipient.IsOnline ? "В сети" : "Не в сети";
// Загружаем историю сообщений
await LoadMessagesAsync();
// Запрашиваем историю сообщений через WebSocket
if (ServiceProvider.WebSocketService.IsConnected)
{
await ServiceProvider.WebSocketService.RequestMessageHistoryAsync(_recipient.Id);
}
else
{
await DisplayAlert("Предупреждение", "WebSocket не подключен. История сообщений недоступна.", "OK");
}
}
catch (Exception ex)
{
@ -52,37 +60,6 @@ public partial class ChatPage : ContentPage
}
}
private async Task LoadMessagesAsync()
{
try
{
var messages = await ServiceProvider.ApiService.GetMessagesAsync(_recipient.Id);
_messages.Clear();
foreach (var message in messages.OrderBy(m => m.Timestamp))
{
_messages.Add(new MessageViewModel
{
Id = message.Id,
SenderId = message.SenderId,
SenderName = message.SenderName,
Content = message.Content,
Timestamp = message.Timestamp,
IsMyMessage = message.SenderId == _currentUser?.Id
});
}
// Прокручиваем к последнему сообщению
if (_messages.Any())
{
MessagesCollectionView.ScrollTo(_messages.Last(), position: ScrollToPosition.End, animate: false);
}
}
catch (Exception ex)
{
await DisplayAlert("Ошибка", $"Не удалось загрузить сообщения: {ex.Message}", "OK");
}
}
private async void OnSendMessageClicked(object sender, EventArgs e)
{
@ -104,17 +81,15 @@ public partial class ChatPage : ContentPage
Type = MessageType.Personal
};
// Отправляем через WebSocket
if (ServiceProvider.WebSocketService.IsConnected)
// Отправляем только через WebSocket
if (!ServiceProvider.WebSocketService.IsConnected)
{
await ServiceProvider.WebSocketService.SendMessageAsync(message);
}
else
{
// Резервный вариант через REST API
await ServiceProvider.ApiService.SendMessageAsync(message);
await DisplayAlert("Ошибка", "WebSocket не подключен. Невозможно отправить сообщение.", "OK");
return;
}
await ServiceProvider.WebSocketService.SendMessageAsync(message);
// Добавляем сообщение в список
_messages.Add(new MessageViewModel
{
@ -181,6 +156,33 @@ public partial class ChatPage : ContentPage
}
}
private void OnMessageHistoryReceived(object? sender, List<Message> history)
{
MainThread.BeginInvokeOnMainThread(() =>
{
_messages.Clear();
foreach (var message in history.OrderBy(m => m.Timestamp))
{
_messages.Add(new MessageViewModel
{
Id = message.Id,
SenderId = message.SenderId,
SenderName = message.SenderName,
Content = message.Content,
Timestamp = message.Timestamp,
IsMyMessage = message.SenderId == _currentUser?.Id
});
}
// Прокручиваем к последнему сообщению
if (_messages.Any())
{
MessagesCollectionView.ScrollTo(_messages.Last(), position: ScrollToPosition.End, animate: false);
}
});
}
private void OnConnectionStatusChanged(object? sender, bool isConnected)
{
MainThread.BeginInvokeOnMainThread(() =>
@ -195,6 +197,7 @@ public partial class ChatPage : ContentPage
// Отписываемся от событий
ServiceProvider.WebSocketService.MessageReceived -= OnMessageReceived;
ServiceProvider.WebSocketService.MessageHistoryReceived -= OnMessageHistoryReceived;
ServiceProvider.WebSocketService.ConnectionStatusChanged -= OnConnectionStatusChanged;
_typingTimer?.Dispose();

View File

@ -7,21 +7,23 @@ public partial class LoginPage : ContentPage
public LoginPage()
{
InitializeComponent();
Debug.WriteLine("[LoginPage] Страница входа инициализирована");
Trace.WriteLine("[LoginPage] Страница входа инициализирована");
}
private async void OnLoginClicked(object sender, EventArgs e)
{
Trace.WriteLine("=== LOGIN BUTTON CLICKED ===");
var username = UsernameEntry.Text?.Trim();
var password = PasswordEntry.Text?.Trim();
Debug.WriteLine($"\n[LoginPage] >>> Кнопка входа нажата");
Debug.WriteLine($"[LoginPage] Username: {username}");
Trace.WriteLine($"[LoginPage] >>> Кнопка входа нажата");
Trace.WriteLine($"[LoginPage] Username: {username}");
// Валидация
if (string.IsNullOrEmpty(username) || string.IsNullOrEmpty(password))
{
Debug.WriteLine($"[LoginPage] ❌ Валидация не прошла - пустые поля");
Trace.WriteLine($"[LoginPage] ❌ Валидация не прошла - пустые поля");
await ShowError("Пожалуйста, заполните все поля");
return;
}
@ -31,18 +33,18 @@ public partial class LoginPage : ContentPage
try
{
Debug.WriteLine($"[LoginPage] >>> Вызываем ApiService.LoginAsync...\n");
Trace.WriteLine($"[LoginPage] >>> Вызываем ApiService.LoginAsync...");
// Выполняем вход
var result = await ServiceProvider.ApiService.LoginAsync(username, password);
Debug.WriteLine($"\n[LoginPage] <<< Получен результат логина");
Debug.WriteLine($"[LoginPage] Success: {result.Success}");
Debug.WriteLine($"[LoginPage] Message: {result.Message}");
Trace.WriteLine($"[LoginPage] <<< Получен результат логина");
Trace.WriteLine($"[LoginPage] Success: {result.Success}");
Trace.WriteLine($"[LoginPage] Message: {result.Message}");
if (result.Success)
{
Debug.WriteLine($"[LoginPage] ✅ Логин успешен! Переходим к WebSocket...");
Trace.WriteLine($"[LoginPage] ✅ Логин успешен! Переходим к WebSocket...");
// Подключаемся к WebSocket
try
@ -52,24 +54,21 @@ public partial class LoginPage : ContentPage
if (string.IsNullOrEmpty(token))
{
Debug.WriteLine($"[LoginPage] ❌ Токен отсутствует!");
Trace.WriteLine($"[LoginPage] ❌ Токен отсутствует!");
await DisplayAlert("Ошибка", "Не удалось получить токен авторизации", "OK");
return;
}
Debug.WriteLine($"[LoginPage] >>> Подключение к WebSocket...");
Debug.WriteLine($"[LoginPage] >>> Token (first 30 chars): {token.Substring(0, Math.Min(30, token.Length))}...");
Debug.WriteLine($"[LoginPage] >>> Token length: {token.Length}");
Trace.WriteLine($"[LoginPage] >>> Подключение к WebSocket...");
await ServiceProvider.WebSocketService.ConnectAsync(token);
Debug.WriteLine($"[LoginPage] ✅ WebSocket подключен!");
Trace.WriteLine($"[LoginPage] ✅ WebSocket подключен!");
}
catch (Exception wsEx)
{
Debug.WriteLine($"[LoginPage] ❌ WebSocket ошибка: {wsEx.GetType().Name}");
Debug.WriteLine($"[LoginPage] Message: {wsEx.Message}");
Debug.WriteLine($"[LoginPage] StackTrace:\n{wsEx.StackTrace}");
Trace.WriteLine($"[LoginPage] ❌ WebSocket ошибка: {wsEx.GetType().Name}");
Trace.WriteLine($"[LoginPage] Message: {wsEx.Message}");
// Показываем предупреждение, но продолжаем работу
await DisplayAlert("Предупреждение",
@ -80,28 +79,61 @@ public partial class LoginPage : ContentPage
// Сохраняем данные пользователя (токены уже сохранены в ApiService)
if (result.User != null)
{
await SecureStorage.SetAsync("user_id", result.User.Id.ToString());
await SecureStorage.SetAsync("username", result.User.Username);
try
{
await SecureStorage.SetAsync("user_id", result.User.Id.ToString());
await SecureStorage.SetAsync("username", result.User.Username);
Trace.WriteLine($"[LoginPage] ✅ Данные пользователя сохранены в SecureStorage");
}
catch (Exception storageEx)
{
Trace.WriteLine($"[LoginPage] ⚠️ Ошибка сохранения в SecureStorage: {storageEx.Message}");
// Продолжаем работу, даже если не удалось сохранить в SecureStorage
}
}
Debug.WriteLine($"[LoginPage] >>> Переход на главную страницу");
Trace.WriteLine($"[LoginPage] >>> ПЕРЕХОД НА APPSHELL");
// Переходим на главную страницу
if (Application.Current?.Windows.Count > 0)
try
{
Application.Current.Windows[0].Page = new AppShell();
await MainThread.InvokeOnMainThreadAsync(() =>
{
Trace.WriteLine($"[LoginPage] >>> Создание AppShell...");
var app = Application.Current;
if (app != null && app.Windows.Count > 0)
{
app.Windows[0].Page = new AppShell();
Trace.WriteLine($"[LoginPage] >>> AppShell установлен через Windows[0].Page");
}
else
{
Trace.WriteLine($"[LoginPage] ⚠️ Нет активных окон приложения");
}
});
Trace.WriteLine($"[LoginPage] ✅ Успешный переход на AppShell");
}
catch (Exception navEx)
{
Trace.WriteLine($"[LoginPage] ❌❌❌ ОШИБКА НАВИГАЦИИ ❌❌❌");
Trace.WriteLine($"[LoginPage] Type: {navEx.GetType().Name}");
Trace.WriteLine($"[LoginPage] Message: {navEx.Message}");
Trace.WriteLine($"[LoginPage] StackTrace: {navEx.StackTrace}");
await DisplayAlert("Ошибка навигации", $"{navEx.Message}\n\n{navEx.StackTrace}", "OK");
throw;
}
}
else
{
Debug.WriteLine($"[LoginPage] ❌ Логин неуспешен");
Trace.WriteLine($"[LoginPage] ❌ Логин неуспешен");
await ShowError(result.Message ?? "Ошибка входа. Проверьте учетные данные.");
}
}
catch (Exception ex)
{
Debug.WriteLine($"[LoginPage] ❌ Exception: {ex.GetType().Name}");
Debug.WriteLine($"[LoginPage] Message: {ex.Message}");
Debug.WriteLine($"[LoginPage] StackTrace: {ex.StackTrace}");
Trace.WriteLine($"[LoginPage] ❌❌❌ EXCEPTION ❌❌❌");
Trace.WriteLine($"[LoginPage] Type: {ex.GetType().Name}");
Trace.WriteLine($"[LoginPage] Message: {ex.Message}");
Trace.WriteLine($"[LoginPage] StackTrace: {ex.StackTrace}");
await ShowError($"Ошибка подключения: {ex.Message}");
}
finally
@ -130,3 +162,4 @@ public partial class LoginPage : ContentPage
}
}

View File

@ -0,0 +1,135 @@
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:viewModels="clr-namespace:CORP_MAUI.ViewModels"
xmlns:models="clr-namespace:CORP_MAUI.Models"
x:Class="CORP_MAUI.Pages.PostDetailsPage"
x:DataType="viewModels:PostDetailsViewModel"
Title="Публикация"
BackgroundColor="{StaticResource Gray100}">
<Grid>
<CollectionView
x:Name="CommentsCollectionView"
ItemsSource="{Binding Comments}"
SelectionMode="None"
Margin="0,0,0,20">
<CollectionView.Header>
<VerticalStackLayout Spacing="15" Padding="15" BackgroundColor="White">
<!-- Заголовок -->
<Label Text="{Binding PostViewModel.Post.Title}"
FontSize="22"
FontAttributes="Bold"
TextColor="{StaticResource Gray900}"
Margin="0,20,0,0"/>
<!-- Автор и дата -->
<HorizontalStackLayout Spacing="10">
<Label Text="{Binding PostViewModel.Post.Author.FullName}"
FontSize="14"
TextColor="{StaticResource Primary}"/>
<Label Text="{Binding PostViewModel.Post.DisplayDate, StringFormat='{0:dd.MM.yyyy HH:mm}'}"
FontSize="14"
TextColor="{StaticResource Gray500}"/>
</HorizontalStackLayout>
<!-- Изображение -->
<Image Aspect="AspectFill"
HeightRequest="250"
BackgroundColor="#F5F5F5"
Source="{Binding PostViewModel.DisplayImageSource}"
IsVisible="{Binding PostViewModel.IsImageVisible}">
<Image.Clip>
<RoundRectangleGeometry CornerRadius="10"/>
</Image.Clip>
</Image>
<!-- Полный текст (Content) -->
<!-- Используем Post.Content напрямую, так как ViewModel.PreviewContent обрезан -->
<Label Text="{Binding PostViewModel.Post.Content}"
FontSize="16"
TextColor="{StaticResource Gray900}"
LineHeight="1.2"/>
<!-- Статистика -->
<HorizontalStackLayout Spacing="20" Margin="0,10,0,0">
<HorizontalStackLayout Spacing="5">
<Label Text="❤️" />
<Label Text="{Binding PostViewModel.Post.LikesCount}"
TextColor="{StaticResource Gray600}"/>
</HorizontalStackLayout>
<HorizontalStackLayout Spacing="5">
<Label Text="💬" />
<Label Text="{Binding PostViewModel.Post.CommentsCount}"
TextColor="{StaticResource Gray600}"/>
</HorizontalStackLayout>
</HorizontalStackLayout>
<BoxView HeightRequest="1" Color="{StaticResource Gray200}" Margin="0,10" />
<Label Text="Комментарии" FontSize="18" FontAttributes="Bold" TextColor="{StaticResource Gray900}"/>
<!-- Индикатор загрузки комментариев -->
<ActivityIndicator IsRunning="{Binding IsLoadingComments}"
IsVisible="{Binding IsLoadingComments}"
Color="{StaticResource Primary}"
HorizontalOptions="Center"/>
</VerticalStackLayout>
</CollectionView.Header>
<CollectionView.EmptyView>
<VerticalStackLayout Padding="20" Spacing="10" IsVisible="{Binding IsNotLoadingComments}">
<Label Text="Нет комментариев"
HorizontalOptions="Center"
TextColor="{StaticResource Gray500}"/>
</VerticalStackLayout>
</CollectionView.EmptyView>
<CollectionView.ItemTemplate>
<DataTemplate x:DataType="models:Comment">
<Border
Padding="10"
Margin="15,5"
StrokeShape="RoundRectangle 8"
BackgroundColor="White"
StrokeThickness="0">
<VerticalStackLayout Spacing="5">
<HorizontalStackLayout Spacing="10">
<Label Text="{Binding Author.FullName}"
FontSize="13"
FontAttributes="Bold"
TextColor="{StaticResource Gray900}"/>
<Label Text="{Binding DisplayDate, StringFormat='{0:dd.MM.yyyy HH:mm}'}"
FontSize="11"
TextColor="{StaticResource Gray500}"
VerticalOptions="Center"/>
</HorizontalStackLayout>
<Label Text="{Binding DisplayContent}"
FontSize="14"
TextColor="{StaticResource Gray900}"/>
</VerticalStackLayout>
</Border>
</DataTemplate>
</CollectionView.ItemTemplate>
</CollectionView>
<Button Text="✕"
FontSize="20"
FontAttributes="Bold"
BackgroundColor="#80FFFFFF"
TextColor="{StaticResource Gray900}"
HorizontalOptions="End"
VerticalOptions="Start"
Margin="15"
WidthRequest="40"
HeightRequest="40"
CornerRadius="20"
Padding="0"
Clicked="OnCloseClicked"/>
</Grid>
</ContentPage>

View File

@ -0,0 +1,56 @@
using System.Diagnostics;
using CORP_MAUI.ViewModels;
namespace CORP_MAUI.Pages;
public partial class PostDetailsPage : ContentPage
{
private readonly PostDetailsViewModel _viewModel;
public PostDetailsPage(PostViewModel postViewModel)
{
InitializeComponent();
_viewModel = new PostDetailsViewModel(postViewModel);
BindingContext = _viewModel;
}
protected override async void OnAppearing()
{
base.OnAppearing();
if (_viewModel.Comments.Count == 0)
{
await LoadCommentsAsync();
}
}
private async Task LoadCommentsAsync()
{
_viewModel.IsLoadingComments = true;
try
{
var response = await ServiceProvider.ApiService.GetCommentsPaginatedAsync(postId: _viewModel.PostViewModel.Post.Id);
_viewModel.Comments.Clear();
if (response.Results != null)
{
foreach (var comment in response.Results)
{
_viewModel.Comments.Add(comment);
}
}
}
catch (Exception ex)
{
Trace.WriteLine($"[PostDetailsPage] Error loading comments: {ex.Message}");
}
finally
{
_viewModel.IsLoadingComments = false;
}
}
private async void OnCloseClicked(object sender, EventArgs e)
{
await Navigation.PopAsync();
}
}

View File

@ -0,0 +1,116 @@
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:models="clr-namespace:CORP_MAUI.ViewModels"
x:Class="CORP_MAUI.Pages.PostsPage"
Title="Новости">
<Grid>
<ActivityIndicator
x:Name="LoadingIndicator"
IsRunning="False"
IsVisible="False"
HorizontalOptions="Center"
VerticalOptions="Center"
Color="{StaticResource Primary}"/>
<RefreshView
x:Name="PostsRefreshView"
IsRefreshing="False"
Refreshing="OnRefreshing">
<CollectionView
x:Name="PostsCollectionView"
SelectionMode="Single"
SelectionChanged="OnPostSelected">
<CollectionView.EmptyView>
<VerticalStackLayout
VerticalOptions="Center"
HorizontalOptions="Center"
Padding="50">
<Label
Text="Нет новостей"
FontSize="16"
TextColor="{StaticResource Gray600}"
HorizontalOptions="Center"/>
</VerticalStackLayout>
</CollectionView.EmptyView>
<CollectionView.Header>
<Label Text="Последние новости"
FontSize="24"
FontAttributes="Bold"
Margin="20,10,20,0" />
</CollectionView.Header>
<CollectionView.ItemTemplate>
<DataTemplate x:DataType="models:PostViewModel">
<Border
Padding="15"
Margin="10,5"
StrokeShape="RoundRectangle 10"
BackgroundColor="White"
Shadow="{Shadow Brush=Black, Opacity=0.1, Offset='0,2', Radius=4}">
<VerticalStackLayout Spacing="10">
<!-- Заголовок и метаданные -->
<VerticalStackLayout Spacing="5">
<Label Text="{Binding Post.Title}"
FontSize="18"
FontAttributes="Bold"
TextColor="{StaticResource Gray900}"/>
<HorizontalStackLayout Spacing="10">
<Label Text="{Binding Post.Author.FullName}"
FontSize="12"
TextColor="{StaticResource Primary}"/>
<Label Text="{Binding Post.DisplayDate, StringFormat='{0:dd.MM.yyyy HH:mm}'}"
FontSize="12"
TextColor="{StaticResource Gray500}"/>
</HorizontalStackLayout>
</VerticalStackLayout>
<!-- Изображение -->
<Image Aspect="AspectFill"
HeightRequest="200"
Margin="0,5"
BackgroundColor="#F5F5F5"
Source="{Binding DisplayImageSource}"
IsVisible="{Binding IsImageVisible}">
<Image.Clip>
<RoundRectangleGeometry CornerRadius="10"/>
</Image.Clip>
</Image>
<!-- Контент -->
<Label Text="{Binding PreviewContent}"
FontSize="14"
TextColor="{StaticResource Gray900}"
LineBreakMode="TailTruncation"
MaxLines="3"/>
<!-- Футер с лайками и комментариями -->
<HorizontalStackLayout Spacing="20" Margin="0,5,0,0">
<HorizontalStackLayout Spacing="5">
<Label Text="❤️" />
<Label Text="{Binding Post.LikesCount}"
TextColor="{StaticResource Gray600}"/>
</HorizontalStackLayout>
<HorizontalStackLayout Spacing="5">
<Label Text="💬" />
<Label Text="{Binding Post.CommentsCount}"
TextColor="{StaticResource Gray600}"/>
</HorizontalStackLayout>
</HorizontalStackLayout>
</VerticalStackLayout>
</Border>
</DataTemplate>
</CollectionView.ItemTemplate>
</CollectionView>
</RefreshView>
</Grid>
</ContentPage>

View File

@ -0,0 +1,167 @@
using System.Collections.ObjectModel;
using System.Diagnostics;
using CORP_MAUI.ViewModels;
namespace CORP_MAUI.Pages;
public partial class PostsPage
{
private readonly ObservableCollection<PostViewModel> _posts;
private int _currentPage = 1;
private bool _isLoading;
public PostsPage()
{
InitializeComponent();
_posts = new ObservableCollection<PostViewModel>();
PostsCollectionView.ItemsSource = _posts;
}
// Добавляем метод OnAppearing, чтобы обновлять данные при показе страницы, если нужно
protected override void OnAppearing()
{
base.OnAppearing();
if (_posts.Count == 0)
{
_ = LoadPostsAsync();
}
}
private async Task LoadPostsAsync(bool refresh = false)
{
if (_isLoading) return;
_isLoading = true;
if (refresh)
{
_currentPage = 1;
}
try
{
LoadingIndicator.IsVisible = true;
LoadingIndicator.IsRunning = true;
var response = await ServiceProvider.ApiService.GetPostsPaginatedAsync(_currentPage);
if (refresh)
{
_posts.Clear();
}
if (response.Results != null)
{
foreach (var post in response.Results)
{
Trace.WriteLine($"[PostsPage] Post {post.Id}: '{post.Title}'");
Trace.WriteLine($" CreatedAt: {post.CreatedAt}");
Trace.WriteLine($" DisplayImageUrl: {post.DisplayImageUrl}");
Trace.WriteLine($" Content length: {post.Content?.Length ?? 0}");
var vm = new PostViewModel(post);
_posts.Add(vm);
// Асинхронно загружаем изображение с токеном
_ = Task.Run(async () =>
{
if (!string.IsNullOrEmpty(post.DisplayImageUrl))
{
try
{
// Используем имя файла из URL как ключ кэша
var uri = new Uri(post.DisplayImageUrl);
var fileName = Path.GetFileName(uri.LocalPath);
// Добавляем префикс, чтобы избежать коллизий
var cacheFileName = $"cache_{post.Id}_{fileName}";
var cacheFilePath = Path.Combine(FileSystem.CacheDirectory, cacheFileName);
// Проверяем кэш
if (File.Exists(cacheFilePath))
{
var fileInfo = new FileInfo(cacheFilePath);
if (fileInfo.Length > 0)
{
Trace.WriteLine($"[PostsPage] >> Image found in cache: {cacheFilePath}");
await MainThread.InvokeOnMainThreadAsync(() =>
{
vm.LocalImagePath = cacheFilePath;
});
return;
}
else
{
// Удаляем пустой файл
File.Delete(cacheFilePath);
}
}
// Загружаем, если нет в кэше
var bytes = await ServiceProvider.ApiService.GetImageBytesAsync(post.DisplayImageUrl);
if (bytes != null && bytes.Length > 0)
{
Trace.WriteLine($"[PostsPage] >> Image loaded ({bytes.Length} bytes), saving to {cacheFilePath}");
// Сразу показываем
await MainThread.InvokeOnMainThreadAsync(() =>
{
vm.AuthorizedImageSource = ImageSource.FromStream(() => new MemoryStream(bytes));
});
await File.WriteAllBytesAsync(cacheFilePath, bytes);
// Обновляем путь (на всякий случай, для будущего)
await MainThread.InvokeOnMainThreadAsync(() =>
{
vm.LocalImagePath = cacheFilePath;
});
}
else
{
Trace.WriteLine($"[PostsPage] >> Image failed to load for post {post.Id} (null/empty)");
}
}
catch(Exception imgEx)
{
Trace.WriteLine($"[PostsPage] >> Exception loading image for post {post.Id}: {imgEx.Message}");
}
}
else
{
Trace.WriteLine($"[PostsPage] >> No image URL for post {post.Id}");
}
});
}
}
}
catch (Exception ex)
{
Trace.WriteLine($"Ошибка загрузки постов: {ex.Message}");
await DisplayAlert("Ошибка", "Не удалось загрузить новости", "OK");
}
finally
{
_isLoading = false;
LoadingIndicator.IsVisible = false;
LoadingIndicator.IsRunning = false;
PostsRefreshView.IsRefreshing = false;
}
}
private async void OnRefreshing(object sender, EventArgs e)
{
await LoadPostsAsync(true);
}
private async void OnPostSelected(object sender, SelectionChangedEventArgs e)
{
if (e.CurrentSelection.FirstOrDefault() is PostViewModel selectedPost)
{
// Навигация на страницу деталей
await Navigation.PushAsync(new PostDetailsPage(selectedPost));
// Снимаем выделение
PostsCollectionView.SelectedItem = null;
}
}
}

View File

@ -25,11 +25,11 @@ public partial class ProfilePage : ContentPage
if (user != null)
{
_currentUser = user;
FullNameLabel.Text = _currentUser.FullName ?? "Не указано";
FullNameLabel.Text = _currentUser.FullName;
UsernameLabel.Text = $"@{_currentUser.Username}";
EmailLabel.Text = _currentUser.Email ?? "Не указано";
PositionLabel.Text = _currentUser.Position ?? "Не указано";
DepartmentLabel.Text = _currentUser.Department ?? "Не указано";
PositionLabel.Text = _currentUser.PositionName;
DepartmentLabel.Text = _currentUser.DepartmentName;
StatusLabel.Text = _currentUser.IsOnline ? "В сети" : "Не в сети";
StatusLabel.TextColor = _currentUser.IsOnline ? Colors.LimeGreen : Colors.Gray;

View File

@ -99,12 +99,12 @@
TextColor="{StaticResource Gray900}"/>
<Label
Text="{Binding Position}"
Text="{Binding PositionName}"
FontSize="13"
TextColor="{StaticResource Gray600}"/>
<Label
Text="{Binding Department}"
Text="{Binding DepartmentName}"
FontSize="12"
TextColor="{StaticResource Gray500}"/>
</VerticalStackLayout>

View File

@ -1,6 +1,6 @@
using System.Collections.ObjectModel;
using System.Diagnostics;
using CORP_MAUI.Models;
using CORP_MAUI.Services;
namespace CORP_MAUI.Pages;
@ -11,37 +11,80 @@ public partial class UsersPage : ContentPage
public UsersPage()
{
InitializeComponent();
_users = new ObservableCollection<User>();
UsersCollectionView.ItemsSource = _users;
try
{
Trace.WriteLine("=== USERSPAGE CONSTRUCTOR START ===");
Trace.WriteLine("[UsersPage] >>> Вызов InitializeComponent");
InitializeComponent();
Trace.WriteLine("[UsersPage] >>> InitializeComponent завершен");
_users = new ObservableCollection<User>();
UsersCollectionView.ItemsSource = _users;
// Подписываемся на изменения статуса пользователей
ServiceProvider.WebSocketService.UserStatusChanged += OnUserStatusChanged;
Trace.WriteLine("[UsersPage] >>> Подписка на UserStatusChanged");
// Подписываемся на изменения статуса пользователей
ServiceProvider.WebSocketService.UserStatusChanged += OnUserStatusChanged;
// Загружаем пользователей
_ = LoadUsersAsync();
Trace.WriteLine("[UsersPage] >>> Запуск LoadUsersAsync");
// Загружаем пользователей
_ = LoadUsersAsync();
Trace.WriteLine("=== USERSPAGE CONSTRUCTOR END ===");
}
catch (Exception ex)
{
Trace.WriteLine("=== USERSPAGE CONSTRUCTOR ERROR ===");
Trace.WriteLine($"[UsersPage] ❌ Тип ошибки: {ex.GetType().FullName}");
Trace.WriteLine($"[UsersPage] ❌ Сообщение: {ex.Message}");
Trace.WriteLine($"[UsersPage] ❌ StackTrace: {ex.StackTrace}");
// Показываем ошибку напрямую
Task.Run(async () =>
{
await Task.Delay(100);
var mainPage = Application.Current?.Windows[0]?.Page;
if (mainPage != null)
{
await mainPage.DisplayAlert("Ошибка UsersPage",
$"Ошибка инициализации:\n{ex.Message}\n\nStackTrace:\n{ex.StackTrace}", "OK");
}
});
throw;
}
}
private async Task LoadUsersAsync()
{
try
{
Trace.WriteLine("[UsersPage] >>> LoadUsersAsync START");
LoadingIndicator.IsVisible = true;
LoadingIndicator.IsRunning = true;
_allUsers = await ServiceProvider.ApiService.GetEmployeesAsync();
Trace.WriteLine("[UsersPage] >>> Вызов GetAllEmployeesAsync");
_allUsers = await ServiceProvider.ApiService.GetAllEmployeesAsync();
Trace.WriteLine($"[UsersPage] >>> Получено пользователей: {_allUsers.Count}");
_users.Clear();
foreach (var user in _allUsers.OrderBy(u => u.FullName ?? u.Username))
foreach (var user in _allUsers.OrderBy(u => u.FullName))
{
_users.Add(user);
}
Trace.WriteLine($"[UsersPage] ✅ Пользователи загружены успешно");
}
catch (Exception ex)
{
await DisplayAlert("Ошибка", $"Не удалось загрузить список пользователей: {ex.Message}", "OK");
Trace.WriteLine($"[UsersPage] ❌ Ошибка загрузки: {ex.GetType().Name}");
Trace.WriteLine($"[UsersPage] Message: {ex.Message}");
Trace.WriteLine($"[UsersPage] StackTrace: {ex.StackTrace}");
await DisplayAlert("Ошибка", $"Не удалось загрузить список пользователей:\n{ex.Message}", "OK");
}
finally
{
@ -76,7 +119,7 @@ public partial class UsersPage : ContentPage
{
// Показываем всех пользователей
_users.Clear();
foreach (var user in _allUsers.OrderBy(u => u.FullName ?? u.Username))
foreach (var user in _allUsers.OrderBy(u => u.FullName))
{
_users.Add(user);
}
@ -85,14 +128,14 @@ public partial class UsersPage : ContentPage
{
// Фильтруем пользователей
var filtered = _allUsers.Where(u =>
(u.FullName?.ToLower().Contains(searchText) ?? false) ||
(u.Username?.ToLower().Contains(searchText) ?? false) ||
(u.Department?.ToLower().Contains(searchText) ?? false) ||
(u.Position?.ToLower().Contains(searchText) ?? false)
u.FullName.ToLower().Contains(searchText) ||
u.Username.ToLower().Contains(searchText) ||
u.DepartmentName.ToLower().Contains(searchText) ||
u.PositionName.ToLower().Contains(searchText)
).ToList();
_users.Clear();
foreach (var user in filtered.OrderBy(u => u.FullName ?? u.Username))
foreach (var user in filtered.OrderBy(u => u.FullName))
{
_users.Add(user);
}

View File

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application android:allowBackup="true" android:icon="@mipmap/appicon" android:roundIcon="@mipmap/appicon_round" android:supportsRtl="true"></application>
<application android:allowBackup="true" android:icon="@mipmap/appicon" android:roundIcon="@mipmap/appicon_round" android:supportsRtl="true" android:usesCleartextTraffic="true"></application>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.INTERNET" />
</manifest>

View File

@ -2,7 +2,24 @@
"profiles": {
"Windows Machine": {
"commandName": "Project",
"nativeDebugging": false
"nativeDebugging": false,
"environmentVariables": {
"DOTNET_ENVIRONMENT": "Development"
}
},
"Android Emulator": {
"commandName": "Project",
"nativeDebugging": false,
"environmentVariables": {
"DOTNET_ENVIRONMENT": "Development"
}
},
"iOS Simulator": {
"commandName": "Project",
"nativeDebugging": false,
"environmentVariables": {
"DOTNET_ENVIRONMENT": "Development"
}
}
}
}

View File

@ -37,6 +37,8 @@ public class ApiService : IDisposable
private string? _accessToken;
private string? _refreshToken;
private bool _tokensLoaded = false;
private readonly SemaphoreSlim _tokenLoadSemaphore = new(1, 1);
// Ключи для SecureStorage
private const string ACCESS_TOKEN_KEY = "api.jwt_access";
@ -51,15 +53,15 @@ public class ApiService : IDisposable
public ApiService(ApiConfig? config = null)
{
Debug.WriteLine("╔════════════════════════════════════════════════════════════════════════════════════");
Debug.WriteLine("║ [ApiService] INITIALIZATION STARTED");
Debug.WriteLine("╚════════════════════════════════════════════════════════════════════════════════════");
Trace.WriteLine("╔════════════════════════════════════════════════════════════════════════════════════");
Trace.WriteLine("║ [ApiService] INITIALIZATION STARTED");
Trace.WriteLine("╚════════════════════════════════════════════════════════════════════════════════════");
_config = config ?? new ApiConfig();
Debug.WriteLine($"[ApiService] BaseURL: {_config.BaseUrl}");
Debug.WriteLine($"[ApiService] TokenObtainPath: {_config.TokenObtainPath}");
Debug.WriteLine($"[ApiService] TokenRefreshPath: {_config.TokenRefreshPath}");
Trace.WriteLine($"[ApiService] BaseURL: {_config.BaseUrl}");
Trace.WriteLine($"[ApiService] TokenObtainPath: {_config.TokenObtainPath}");
Trace.WriteLine($"[ApiService] TokenRefreshPath: {_config.TokenRefreshPath}");
_httpClient = new HttpClient
{
@ -78,13 +80,32 @@ public class ApiService : IDisposable
_httpClient.DefaultRequestHeaders.Add("Accept", "application/json");
Debug.WriteLine($"[ApiService] HttpClient BaseAddress set to: {_httpClient.BaseAddress}");
Debug.WriteLine("[ApiService] Loading saved tokens...");
Trace.WriteLine($"[ApiService] HttpClient BaseAddress set to: {_httpClient.BaseAddress}");
Trace.WriteLine("[ApiService] Initialization complete\n");
// Загружаем сохраненные токены
_ = LoadTokensAsync();
Debug.WriteLine("[ApiService] Initialization complete\n");
// НЕ загружаем токены здесь - они будут загружены при первом запросе
}
/// <summary>
/// Гарантирует, что токены загружены из SecureStorage перед выполнением запроса
/// </summary>
private async Task EnsureTokensLoadedAsync()
{
if (_tokensLoaded) return;
await _tokenLoadSemaphore.WaitAsync();
try
{
if (_tokensLoaded) return;
Trace.WriteLine("[ApiService] Loading saved tokens...");
await LoadTokensAsync();
_tokensLoaded = true;
}
finally
{
_tokenLoadSemaphore.Release();
}
}
#region Debug & Logging
@ -94,43 +115,43 @@ public class ApiService : IDisposable
/// </summary>
private void LogHttpDetails(string operation, HttpRequestMessage? request, HttpResponseMessage? response, string? additionalInfo = null)
{
Debug.WriteLine("╔════════════════════════════════════════════════════════════════════════════════════");
Debug.WriteLine($"║ [ApiService] {operation}");
Debug.WriteLine("╠════════════════════════════════════════════════════════════════════════════════════");
Trace.WriteLine("╔════════════════════════════════════════════════════════════════════════════════════");
Trace.WriteLine($"║ [ApiService] {operation}");
Trace.WriteLine("╠════════════════════════════════════════════════════════════════════════════════════");
if (request != null)
{
Debug.WriteLine($"║ REQUEST:");
Debug.WriteLine($"║ Method: {request.Method}");
Debug.WriteLine($"║ URI: {request.RequestUri}");
Debug.WriteLine($"║ BaseAddress: {_httpClient.BaseAddress}");
Debug.WriteLine($"║ Headers:");
Trace.WriteLine($"║ REQUEST:");
Trace.WriteLine($"║ Method: {request.Method}");
Trace.WriteLine($"║ URI: {request.RequestUri}");
Trace.WriteLine($"║ BaseAddress: {_httpClient.BaseAddress}");
Trace.WriteLine($"║ Headers:");
foreach (var header in request.Headers)
{
var value = header.Key == "Authorization" ? "[HIDDEN]" : string.Join(", ", header.Value);
Debug.WriteLine($"║ {header.Key}: {value}");
Trace.WriteLine($"║ {header.Key}: {value}");
}
if (request.Content != null)
{
Debug.WriteLine($"║ Content-Type: {request.Content.Headers.ContentType}");
Trace.WriteLine($"║ Content-Type: {request.Content.Headers.ContentType}");
}
}
if (response != null)
{
Debug.WriteLine($"║ RESPONSE:");
Debug.WriteLine($"║ Status: {(int)response.StatusCode} {response.StatusCode}");
Debug.WriteLine($"║ Content-Type: {response.Content.Headers.ContentType?.MediaType ?? "N/A"}");
Debug.WriteLine($"║ Content-Length: {response.Content.Headers.ContentLength ?? 0} bytes");
Trace.WriteLine($"║ RESPONSE:");
Trace.WriteLine($"║ Status: {(int)response.StatusCode} {response.StatusCode}");
Trace.WriteLine($"║ Content-Type: {response.Content.Headers.ContentType?.MediaType ?? "N/A"}");
Trace.WriteLine($"║ Content-Length: {response.Content.Headers.ContentLength ?? 0} bytes");
}
if (!string.IsNullOrEmpty(additionalInfo))
{
Debug.WriteLine($"║ INFO:");
Debug.WriteLine($"║ {additionalInfo}");
Trace.WriteLine($"║ INFO:");
Trace.WriteLine($"║ {additionalInfo}");
}
Debug.WriteLine("╚════════════════════════════════════════════════════════════════════════════════════");
Trace.WriteLine("╚════════════════════════════════════════════════════════════════════════════════════");
}
#endregion
@ -149,12 +170,28 @@ public class ApiService : IDisposable
if (!string.IsNullOrEmpty(_accessToken))
{
Trace.WriteLine($"[ApiService] ✅ Access token loaded from storage");
Trace.WriteLine($"[ApiService] Token (first 30 chars): {_accessToken.Substring(0, Math.Min(30, _accessToken.Length))}...");
Trace.WriteLine($"[ApiService] Token length: {_accessToken.Length}");
SetAuthorizationHeader(_accessToken);
}
else
{
Trace.WriteLine($"[ApiService] ℹ️ No saved access token found");
}
if (!string.IsNullOrEmpty(_refreshToken))
{
Trace.WriteLine($"[ApiService] ✅ Refresh token loaded from storage");
}
else
{
Trace.WriteLine($"[ApiService] ℹ️ No saved refresh token found");
}
}
catch (Exception ex)
{
Debug.WriteLine($"Ошибка загрузки токенов: {ex.Message}");
Trace.WriteLine($"[ApiService] ❌ Error loading tokens: {ex.Message}");
}
}
@ -171,11 +208,15 @@ public class ApiService : IDisposable
_accessToken = accessToken;
_refreshToken = refreshToken;
Trace.WriteLine($"[ApiService] 💾 Tokens saved to SecureStorage");
Trace.WriteLine($"[ApiService] Access token length: {accessToken.Length}");
Trace.WriteLine($"[ApiService] Refresh token length: {refreshToken.Length}");
SetAuthorizationHeader(accessToken);
}
catch (Exception ex)
{
Debug.WriteLine($"Ошибка сохранения токенов: {ex.Message}");
Trace.WriteLine($"[ApiService] ❌ Error saving tokens: {ex.Message}");
}
}
@ -186,6 +227,7 @@ public class ApiService : IDisposable
{
_httpClient.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", token);
Trace.WriteLine($"[ApiService] 🔑 Default Authorization header set: Bearer [token]");
}
/// <summary>
@ -193,7 +235,7 @@ public class ApiService : IDisposable
/// </summary>
private void ClearTokens()
{
Debug.WriteLine("\n[ApiService] 🧹 Clearing all tokens...");
Trace.WriteLine("\n[ApiService] 🧹 Clearing all tokens...");
_accessToken = null;
_refreshToken = null;
@ -202,7 +244,7 @@ public class ApiService : IDisposable
SecureStorage.Remove(ACCESS_TOKEN_KEY);
SecureStorage.Remove(REFRESH_TOKEN_KEY);
Debug.WriteLine("[ApiService] ✅ Tokens cleared, user is logged out\n");
Trace.WriteLine("[ApiService] ✅ Tokens cleared, user is logged out\n");
}
#endregion
@ -220,10 +262,10 @@ public class ApiService : IDisposable
// Используем относительный путь, так как BaseAddress уже установлен
var path = _config.TokenObtainPath.TrimStart('/');
Debug.WriteLine($"\n[ApiService] >>> Начинаем логин для {email}");
Debug.WriteLine($"[ApiService] >>> Путь токена: {_config.TokenObtainPath} → {path}");
Debug.WriteLine($"[ApiService] >>> BaseAddress: {_httpClient.BaseAddress}");
Debug.WriteLine($"[ApiService] >>> Ожидаемый URL: {_httpClient.BaseAddress}{path}\n");
Trace.WriteLine($"\n[ApiService] >>> Начинаем логин для {email}");
Trace.WriteLine($"[ApiService] >>> Путь токена: {_config.TokenObtainPath} → {path}");
Trace.WriteLine($"[ApiService] >>> BaseAddress: {_httpClient.BaseAddress}");
Trace.WriteLine($"[ApiService] >>> Ожидаемый URL: {_httpClient.BaseAddress}{path}\n");
// 🔓 Создаем запрос БЕЗ Authorization header (это запрос для получения токена!)
var request = new HttpRequestMessage(HttpMethod.Post, path)
@ -243,10 +285,10 @@ public class ApiService : IDisposable
if (!response.IsSuccessStatusCode)
{
var errorText = await response.Content.ReadAsStringAsync();
Debug.WriteLine($"\n[ApiService] ❌ Ошибка логина:");
Debug.WriteLine($" Status Code: {(int)response.StatusCode} {response.StatusCode}");
Debug.WriteLine($" Request URL: {response.RequestMessage?.RequestUri}");
Debug.WriteLine($" Error Body: {errorText}\n");
Trace.WriteLine($"\n[ApiService] ❌ Ошибка логина:");
Trace.WriteLine($" Status Code: {(int)response.StatusCode} {response.StatusCode}");
Trace.WriteLine($" Request URL: {response.RequestMessage?.RequestUri}");
Trace.WriteLine($" Error Body: {errorText}\n");
return new AuthResponse
{
@ -259,7 +301,7 @@ public class ApiService : IDisposable
var contentType = response.Content.Headers.ContentType?.MediaType ?? "";
if (!contentType.Contains("application/json"))
{
Debug.WriteLine($"[ApiService] ❌ Неверный Content-Type: {contentType}");
Trace.WriteLine($"[ApiService] ❌ Неверный Content-Type: {contentType}");
return new AuthResponse
{
Success = false,
@ -270,7 +312,7 @@ public class ApiService : IDisposable
try
{
var responseBody = await response.Content.ReadAsStringAsync();
Debug.WriteLine($"\n[ApiService] Response Body: {responseBody}\n");
Trace.WriteLine($"\n[ApiService] Response Body: {responseBody}\n");
var tokenData = JsonSerializer.Deserialize<TokenResponse>(responseBody, new JsonSerializerOptions
{
@ -279,7 +321,7 @@ public class ApiService : IDisposable
if (tokenData == null || tokenData.Access == null || tokenData.Refresh == null)
{
Debug.WriteLine($"[ApiService] ❌ Токены не получены из ответа");
Trace.WriteLine($"[ApiService] ❌ Токены не получены из ответа");
return new AuthResponse
{
Success = false,
@ -287,20 +329,20 @@ public class ApiService : IDisposable
};
}
Debug.WriteLine($"[ApiService] ✅ Токены получены успешно");
Debug.WriteLine($" Access Token: {tokenData.Access.Substring(0, Math.Min(20, tokenData.Access.Length))}...");
Debug.WriteLine($" Refresh Token: {tokenData.Refresh.Substring(0, Math.Min(20, tokenData.Refresh.Length))}...\n");
Trace.WriteLine($"[ApiService] ✅ Токены получены успешно");
Trace.WriteLine($" Access Token: {tokenData.Access.Substring(0, Math.Min(20, tokenData.Access.Length))}...");
Trace.WriteLine($" Refresh Token: {tokenData.Refresh.Substring(0, Math.Min(20, tokenData.Refresh.Length))}...\n");
// Сохраняем токены
await SaveTokensAsync(tokenData.Access, tokenData.Refresh);
// Получаем информацию о пользователе
Debug.WriteLine($"[ApiService] >>> Получаем информацию о пользователе...\n");
Trace.WriteLine($"[ApiService] >>> Получаем информацию о пользователе...\n");
var user = await GetCurrentUserAsync();
if (user != null)
{
Debug.WriteLine($"[ApiService] ✅ Пользователь: {user.Username} ({user.Email})\n");
Trace.WriteLine($"[ApiService] ✅ Пользователь: {user.Username} ({user.Email})\n");
}
return new AuthResponse
@ -313,7 +355,7 @@ public class ApiService : IDisposable
}
catch (JsonException ex)
{
Debug.WriteLine($"[ApiService] ❌ Ошибка разбора JSON: {ex.Message}");
Trace.WriteLine($"[ApiService] ❌ Ошибка разбора JSON: {ex.Message}");
return new AuthResponse
{
Success = false,
@ -323,7 +365,7 @@ public class ApiService : IDisposable
}
catch (HttpRequestException ex)
{
Debug.WriteLine($"[ApiService] ❌ HTTP ошибка: {ex.Message}");
Trace.WriteLine($"[ApiService] ❌ HTTP ошибка: {ex.Message}");
return new AuthResponse
{
Success = false,
@ -332,7 +374,7 @@ public class ApiService : IDisposable
}
catch (Exception ex)
{
Debug.WriteLine($"[ApiService] ❌ Неожиданная ошибка: {ex.Message}\n{ex.StackTrace}");
Trace.WriteLine($"[ApiService] ❌ Неожиданная ошибка: {ex.Message}\n{ex.StackTrace}");
return new AuthResponse
{
Success = false,
@ -356,24 +398,24 @@ public class ApiService : IDisposable
{
if (string.IsNullOrEmpty(_refreshToken))
{
Debug.WriteLine("[ApiService] ❌ Cannot refresh: no refresh token available");
Trace.WriteLine("[ApiService] ❌ Cannot refresh: no refresh token available");
return false;
}
try
{
Debug.WriteLine("\n╔════════════════════════════════════════════════════════════════════════════════════");
Debug.WriteLine("║ [ApiService] TOKEN REFRESH PROCESS");
Debug.WriteLine("╠════════════════════════════════════════════════════════════════════════════════════");
Trace.WriteLine("\n╔════════════════════════════════════════════════════════════════════════════════════");
Trace.WriteLine("║ [ApiService] TOKEN REFRESH PROCESS");
Trace.WriteLine("╠════════════════════════════════════════════════════════════════════════════════════");
// Используем относительный путь, так как BaseAddress уже установлен
var path = _config.TokenRefreshPath.TrimStart('/');
var refreshData = new { refresh = _refreshToken };
Debug.WriteLine($"║ Request Path: {path}");
Debug.WriteLine($"║ Full URL: {_httpClient.BaseAddress}{path}");
Debug.WriteLine($"║ Refresh Token: {_refreshToken.Substring(0, Math.Min(20, _refreshToken.Length))}...");
Debug.WriteLine("╚════════════════════════════════════════════════════════════════════════════════════");
Trace.WriteLine($"║ Request Path: {path}");
Trace.WriteLine($"║ Full URL: {_httpClient.BaseAddress}{path}");
Trace.WriteLine($"║ Refresh Token: {_refreshToken.Substring(0, Math.Min(20, _refreshToken.Length))}...");
Trace.WriteLine("╚════════════════════════════════════════════════════════════════════════════════════");
// Создаем запрос БЕЗ авторизации (для refresh используется только refresh токен в теле)
var request = new HttpRequestMessage(HttpMethod.Post, path)
@ -383,12 +425,12 @@ public class ApiService : IDisposable
var response = await _httpClient.SendAsync(request);
Debug.WriteLine($"\n[ApiService] Refresh response: {(int)response.StatusCode} {response.StatusCode}");
Trace.WriteLine($"\n[ApiService] Refresh response: {(int)response.StatusCode} {response.StatusCode}");
if (response.IsSuccessStatusCode)
{
var responseText = await response.Content.ReadAsStringAsync();
Debug.WriteLine($"[ApiService] Response body: {responseText}");
Trace.WriteLine($"[ApiService] Response body: {responseText}");
var tokenData = JsonSerializer.Deserialize<TokenResponse>(responseText, new JsonSerializerOptions
{
@ -397,44 +439,61 @@ public class ApiService : IDisposable
if (!string.IsNullOrEmpty(tokenData?.Access))
{
Debug.WriteLine($"\n[ApiService] ✅ New access token received: {tokenData.Access.Substring(0, Math.Min(20, tokenData.Access.Length))}...");
Trace.WriteLine($"\n[ApiService] ✅ New access token received: {tokenData.Access.Substring(0, Math.Min(20, tokenData.Access.Length))}...");
_accessToken = tokenData.Access;
await SecureStorage.SetAsync(ACCESS_TOKEN_KEY, tokenData.Access);
try
{
await SecureStorage.SetAsync(ACCESS_TOKEN_KEY, tokenData.Access);
}
catch (Exception storageEx)
{
Trace.WriteLine($"[ApiService] ⚠️ Ошибка сохранения access token в SecureStorage: {storageEx.Message}");
}
SetAuthorizationHeader(tokenData.Access);
// Если пришёл новый refresh токен, сохраняем и его
if (!string.IsNullOrEmpty(tokenData.Refresh))
{
Debug.WriteLine($"[ApiService] ✅ New refresh token received: {tokenData.Refresh.Substring(0, Math.Min(20, tokenData.Refresh.Length))}...");
Trace.WriteLine($"[ApiService] ✅ New refresh token received: {tokenData.Refresh.Substring(0, Math.Min(20, tokenData.Refresh.Length))}...");
_refreshToken = tokenData.Refresh;
await SecureStorage.SetAsync(REFRESH_TOKEN_KEY, tokenData.Refresh);
try
{
await SecureStorage.SetAsync(REFRESH_TOKEN_KEY, tokenData.Refresh);
}
catch (Exception storageEx)
{
Trace.WriteLine($"[ApiService] ⚠️ Ошибка сохранения refresh token в SecureStorage: {storageEx.Message}");
}
}
Debug.WriteLine("[ApiService] ✅ Tokens saved successfully\n");
Trace.WriteLine("[ApiService] ✅ Tokens saved successfully\n");
return true;
}
else
{
Debug.WriteLine("[ApiService] ❌ No access token in response");
Trace.WriteLine("[ApiService] ❌ No access token in response");
}
}
else
{
var errorText = await response.Content.ReadAsStringAsync();
Debug.WriteLine($"[ApiService] ❌ Refresh failed: {response.StatusCode}");
Debug.WriteLine($" Error: {errorText}");
Trace.WriteLine($"[ApiService] ❌ Refresh failed: {response.StatusCode}");
Trace.WriteLine($" Error: {errorText}");
}
// Не удалось обновить токены - очищаем их
Debug.WriteLine("[ApiService] ⚠️ Clearing all tokens due to refresh failure\n");
Trace.WriteLine("[ApiService] ⚠️ Clearing all tokens due to refresh failure\n");
ClearTokens();
return false;
}
catch (Exception ex)
{
Debug.WriteLine($"[ApiService] ❌ Exception during token refresh: {ex.Message}");
Debug.WriteLine($" StackTrace: {ex.StackTrace}\n");
Trace.WriteLine($"[ApiService] ❌ Exception during token refresh: {ex.Message}");
Trace.WriteLine($" StackTrace: {ex.StackTrace}\n");
ClearTokens();
return false;
}
@ -460,6 +519,12 @@ public class ApiService : IDisposable
{
try
{
// ⚡ Гарантируем, что токены загружены перед запросом
if (requiresAuth)
{
await EnsureTokensLoadedAsync();
}
// Используем относительный путь, так как BaseAddress установлен
var relativePath = path.TrimStart('/');
@ -477,7 +542,7 @@ public class ApiService : IDisposable
if (requiresAuth && !string.IsNullOrEmpty(_accessToken))
{
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _accessToken);
Debug.WriteLine($"[ApiService] 🔑 Authorization header added with token");
Trace.WriteLine($"[ApiService] 🔑 Authorization header added with token");
}
// Добавляем дополнительные заголовки
@ -495,43 +560,43 @@ public class ApiService : IDisposable
request.Content = JsonContent.Create(jsonData);
}
Debug.WriteLine($"\n[ApiService] >>> {method} Request");
Debug.WriteLine($" Path: {path} → {relativePath}");
Debug.WriteLine($" Full URL: {_httpClient.BaseAddress}{relativePath}");
Debug.WriteLine($" Requires Auth: {requiresAuth}");
Debug.WriteLine($" Has Token: {!string.IsNullOrEmpty(_accessToken)}");
Trace.WriteLine($"\n[ApiService] >>> {method} Request");
Trace.WriteLine($" Path: {path} → {relativePath}");
Trace.WriteLine($" Full URL: {_httpClient.BaseAddress}{relativePath}");
Trace.WriteLine($" Requires Auth: {requiresAuth}");
Trace.WriteLine($" Has Token: {!string.IsNullOrEmpty(_accessToken)}");
if (jsonData != null)
{
Debug.WriteLine($" Has Body: Yes");
Trace.WriteLine($" Has Body: Yes");
}
var response = await _httpClient.SendAsync(request);
Debug.WriteLine($"[ApiService] <<< Response: {(int)response.StatusCode} {response.StatusCode}");
Debug.WriteLine($" Content-Type: {response.Content.Headers.ContentType?.MediaType ?? "N/A"}\n");
Trace.WriteLine($"[ApiService] <<< Response: {(int)response.StatusCode} {response.StatusCode}");
Trace.WriteLine($" Content-Type: {response.Content.Headers.ContentType?.MediaType ?? "N/A"}\n");
// 🔄 Проверяем на 401/403 и пробуем обновить токен
if ((response.StatusCode == System.Net.HttpStatusCode.Unauthorized ||
response.StatusCode == System.Net.HttpStatusCode.Forbidden)
&& retry && !string.IsNullOrEmpty(_refreshToken))
{
Debug.WriteLine($"[ApiService] 🔄 Token expired or invalid, attempting refresh...");
Debug.WriteLine($" Current access token: {_accessToken?.Substring(0, Math.Min(20, _accessToken.Length))}...");
Debug.WriteLine($" Refresh token available: Yes\n");
Trace.WriteLine($"[ApiService] 🔄 Token expired or invalid, attempting refresh...");
Trace.WriteLine($" Current access token: {_accessToken?.Substring(0, Math.Min(20, _accessToken.Length))}...");
Trace.WriteLine($" Refresh token available: Yes\n");
if (await RefreshTokensAsync())
{
Debug.WriteLine($"[ApiService] ✅ Token successfully refreshed!");
Debug.WriteLine($" New access token: {_accessToken?.Substring(0, Math.Min(20, _accessToken.Length))}...");
Debug.WriteLine($" Retrying original request...\n");
Trace.WriteLine($"[ApiService] ✅ Token successfully refreshed!");
Trace.WriteLine($" New access token: {_accessToken?.Substring(0, Math.Min(20, _accessToken.Length))}...");
Trace.WriteLine($" Retrying original request...\n");
// Токен обновлён, повторяем запрос (retry=false чтобы избежать бесконечного цикла)
return await RequestAsync<T>(method, path, jsonData, queryParams, headers, retry: false, requiresAuth);
}
else
{
Debug.WriteLine($"[ApiService] ❌ Token refresh failed - tokens cleared");
Debug.WriteLine($" User needs to login again\n");
Trace.WriteLine($"[ApiService] ❌ Token refresh failed - tokens cleared");
Trace.WriteLine($" User needs to login again\n");
}
}
@ -539,10 +604,10 @@ public class ApiService : IDisposable
if (!response.IsSuccessStatusCode)
{
Debug.WriteLine($"[ApiService] ❌ Request failed:");
Debug.WriteLine($" Status: {(int)response.StatusCode} {response.StatusCode}");
Debug.WriteLine($" URL: {response.RequestMessage?.RequestUri}");
Debug.WriteLine($" Body: {responseText.Substring(0, Math.Min(200, responseText.Length))}\n");
Trace.WriteLine($"[ApiService] ❌ Request failed:");
Trace.WriteLine($" Status: {(int)response.StatusCode} {response.StatusCode}");
Trace.WriteLine($" URL: {response.RequestMessage?.RequestUri}");
Trace.WriteLine($" Body: {responseText.Substring(0, Math.Min(200, responseText.Length))}\n");
return new ApiResponse<T>
{
@ -563,11 +628,11 @@ public class ApiService : IDisposable
{
PropertyNameCaseInsensitive = true
});
Debug.WriteLine($"[ApiService] ✅ Data parsed successfully\n");
Trace.WriteLine($"[ApiService] ✅ Data parsed successfully\n");
}
catch (JsonException ex)
{
Debug.WriteLine($"[ApiService] ⚠️ JSON parse warning: {ex.Message}\n");
Trace.WriteLine($"[ApiService] ⚠️ JSON parse warning: {ex.Message}\n");
// Не JSON ответ, игнорируем
}
}
@ -582,8 +647,8 @@ public class ApiService : IDisposable
}
catch (Exception ex)
{
Debug.WriteLine($"[ApiService] ❌ Request exception: {ex.Message}");
Debug.WriteLine($" StackTrace: {ex.StackTrace}\n");
Trace.WriteLine($"[ApiService] ❌ Request exception: {ex.Message}");
Trace.WriteLine($" StackTrace: {ex.StackTrace}\n");
return new ApiResponse<T>
{
Ok = false,
@ -637,20 +702,58 @@ public class ApiService : IDisposable
#region API Methods
// ============================================
// EMPLOYEES (Сотрудники)
// ============================================
/// <summary>
/// Получить список сотрудников
/// Получить список сотрудников с пагинацией
/// </summary>
public async Task<List<User>> GetEmployeesAsync()
public async Task<PaginatedResponse<User>> GetEmployeesPaginatedAsync(int? page = null, int? pageSize = null, string? search = null)
{
try
{
var response = await GetAsync<List<User>>("api/v1/employees/");
return response.Ok && response.Data != null ? response.Data : new List<User>();
var queryParams = new Dictionary<string, string>();
if (page.HasValue) queryParams["page"] = page.Value.ToString();
if (pageSize.HasValue) queryParams["page_size"] = pageSize.Value.ToString();
if (!string.IsNullOrEmpty(search)) queryParams["search"] = search;
var response = await GetAsync<PaginatedResponse<User>>("api/v1/employees/", queryParams);
return response.Ok && response.Data != null ? response.Data : new PaginatedResponse<User>();
}
catch (Exception ex)
{
Debug.WriteLine($"Ошибка получения пользователей: {ex.Message}");
Trace.WriteLine($"Ошибка получения сотрудников: {ex.Message}");
return new PaginatedResponse<User>();
}
}
/// <summary>
/// Получить список всех сотрудников (все страницы)
/// </summary>
public async Task<List<User>> GetAllEmployeesAsync()
{
try
{
var allEmployees = new List<User>();
int currentPage = 1;
PaginatedResponse<User>? response;
do
{
response = await GetEmployeesPaginatedAsync(page: currentPage);
if (response.Results.Any())
{
allEmployees.AddRange(response.Results);
currentPage++;
}
} while (!string.IsNullOrEmpty(response.Next));
return allEmployees;
}
catch (Exception ex)
{
Trace.WriteLine($"Ошибка получения всех сотрудников: {ex.Message}");
return new List<User>();
}
}
@ -667,13 +770,305 @@ public class ApiService : IDisposable
}
catch (Exception ex)
{
Debug.WriteLine($"Ошибка получения профиля: {ex.Message}");
Trace.WriteLine($"Ошибка получения профиля: {ex.Message}");
return null;
}
}
/// <summary>
/// Получить историю сообщений
/// Получить сотрудника по ID
/// </summary>
public async Task<User?> GetEmployeeByIdAsync(int id)
{
try
{
var response = await GetAsync<User>($"api/v1/employees/{id}/");
return response.Ok ? response.Data : null;
}
catch (Exception ex)
{
Trace.WriteLine($"Ошибка получения сотрудника {id}: {ex.Message}");
return null;
}
}
// ============================================
// DEPARTMENTS (Отделы)
// ============================================
/// <summary>
/// Получить список отделов с пагинацией
/// </summary>
public async Task<PaginatedResponse<Department>> GetDepartmentsPaginatedAsync(int? page = null, int? pageSize = null)
{
try
{
var queryParams = new Dictionary<string, string>();
if (page.HasValue) queryParams["page"] = page.Value.ToString();
if (pageSize.HasValue) queryParams["page_size"] = pageSize.Value.ToString();
var response = await GetAsync<PaginatedResponse<Department>>("api/v1/departments/", queryParams);
return response.Ok && response.Data != null ? response.Data : new PaginatedResponse<Department>();
}
catch (Exception ex)
{
Trace.WriteLine($"Ошибка получения отделов: {ex.Message}");
return new PaginatedResponse<Department>();
}
}
/// <summary>
/// Получить список всех отделов
/// </summary>
public async Task<List<Department>> GetAllDepartmentsAsync()
{
try
{
var allDepartments = new List<Department>();
int currentPage = 1;
PaginatedResponse<Department>? response;
do
{
response = await GetDepartmentsPaginatedAsync(page: currentPage);
if (response.Results.Any())
{
allDepartments.AddRange(response.Results);
currentPage++;
}
} while (!string.IsNullOrEmpty(response.Next));
return allDepartments;
}
catch (Exception ex)
{
Trace.WriteLine($"Ошибка получения всех отделов: {ex.Message}");
return new List<Department>();
}
}
// ============================================
// POSITIONS (Должности)
// ============================================
/// <summary>
/// Получить список всех должностей
/// </summary>
public async Task<List<Position>> GetPositionsAsync()
{
try
{
var response = await GetAsync<List<Position>>("api/v1/positions/");
return response.Ok && response.Data != null ? response.Data : new List<Position>();
}
catch (Exception ex)
{
Trace.WriteLine($"Ошибка получения должностей: {ex.Message}");
return new List<Position>();
}
}
// ============================================
// SKILLS (Навыки)
// ============================================
/// <summary>
/// Получить список всех навыков
/// </summary>
public async Task<List<Skill>> GetSkillsAsync()
{
try
{
var response = await GetAsync<List<Skill>>("api/v1/skills/");
return response.Ok && response.Data != null ? response.Data : new List<Skill>();
}
catch (Exception ex)
{
Trace.WriteLine($"Ошибка получения навыков: {ex.Message}");
return new List<Skill>();
}
}
// ============================================
// CALENDAR EVENTS (События календаря)
// ============================================
/// <summary>
/// Получить события календаря
/// </summary>
public async Task<List<CalendarEvent>> GetCalendarEventsAsync()
{
try
{
var response = await GetAsync<List<CalendarEvent>>("api/v1/calendar/events/");
return response.Ok && response.Data != null ? response.Data : new List<CalendarEvent>();
}
catch (Exception ex)
{
Trace.WriteLine($"Ошибка получения событий календаря: {ex.Message}");
return new List<CalendarEvent>();
}
}
// ============================================
// DOCUMENTS (Документы)
// ============================================
/// <summary>
/// Получить список документов с пагинацией
/// </summary>
public async Task<PaginatedResponse<Document>> GetDocumentsPaginatedAsync(int? page = null, int? pageSize = null)
{
try
{
var queryParams = new Dictionary<string, string>();
if (page.HasValue) queryParams["page"] = page.Value.ToString();
if (pageSize.HasValue) queryParams["page_size"] = pageSize.Value.ToString();
var response = await GetAsync<PaginatedResponse<Document>>("api/v1/documents/", queryParams);
return response.Ok && response.Data != null ? response.Data : new PaginatedResponse<Document>();
}
catch (Exception ex)
{
Trace.WriteLine($"Ошибка получения документов: {ex.Message}");
return new PaginatedResponse<Document>();
}
}
// ============================================
// REQUESTS (Запросы)
// ============================================
/// <summary>
/// Получить список запросов с пагинацией
/// </summary>
public async Task<PaginatedResponse<Request>> GetRequestsPaginatedAsync(int? page = null, int? pageSize = null)
{
try
{
var queryParams = new Dictionary<string, string>();
if (page.HasValue) queryParams["page"] = page.Value.ToString();
if (pageSize.HasValue) queryParams["page_size"] = pageSize.Value.ToString();
var response = await GetAsync<PaginatedResponse<Request>>("api/v1/requests/", queryParams);
return response.Ok && response.Data != null ? response.Data : new PaginatedResponse<Request>();
}
catch (Exception ex)
{
Trace.WriteLine($"Ошибка получения запросов: {ex.Message}");
return new PaginatedResponse<Request>();
}
}
// ============================================
// EMPLOYEE ACTIONS (Действия сотрудников)
// ============================================
/// <summary>
/// Получить историю действий сотрудников
/// </summary>
public async Task<List<EmployeeAction>> GetEmployeeActionsAsync()
{
try
{
var response = await GetAsync<List<EmployeeAction>>("api/v1/employee-actions/");
return response.Ok && response.Data != null ? response.Data : new List<EmployeeAction>();
}
catch (Exception ex)
{
Trace.WriteLine($"Ошибка получения действий сотрудников: {ex.Message}");
return new List<EmployeeAction>();
}
}
// ============================================
// POSTS (Посты/Новости)
// ============================================
/// <summary>
/// Получить список постов с пагинацией
/// </summary>
public async Task<PaginatedResponse<Post>> GetPostsPaginatedAsync(int? page = null, int? pageSize = null)
{
try
{
var queryParams = new Dictionary<string, string>();
if (page.HasValue) queryParams["page"] = page.Value.ToString();
if (pageSize.HasValue) queryParams["page_size"] = pageSize.Value.ToString();
var response = await GetAsync<PaginatedResponse<Post>>("api/v1/posts/", queryParams);
return response.Ok && response.Data != null ? response.Data : new PaginatedResponse<Post>();
}
catch (Exception ex)
{
Trace.WriteLine($"Ошибка получения постов: {ex.Message}");
return new PaginatedResponse<Post>();
}
}
// ============================================
// COMMENTS (Комментарии)
// ============================================
/// <summary>
/// Получить список комментариев с пагинацией
/// </summary>
public async Task<PaginatedResponse<Comment>> GetCommentsPaginatedAsync(int? page = null, int? pageSize = null, int? postId = null)
{
try
{
var queryParams = new Dictionary<string, string>();
if (page.HasValue) queryParams["page"] = page.Value.ToString();
if (pageSize.HasValue) queryParams["page_size"] = pageSize.Value.ToString();
if (postId.HasValue) queryParams["post"] = postId.Value.ToString();
var response = await GetAsync<PaginatedResponse<Comment>>("api/v1/comments/", queryParams);
return response.Ok && response.Data != null ? response.Data : new PaginatedResponse<Comment>();
}
catch (Exception ex)
{
Trace.WriteLine($"Ошибка получения комментариев: {ex.Message}");
return new PaginatedResponse<Comment>();
}
}
// ============================================
// DEPARTMENT ROLES (Роли отделов)
// ============================================
/// <summary>
/// Получить список ролей в отделах с пагинацией
/// </summary>
public async Task<PaginatedResponse<DepartmentRole>> GetDepartmentRolesPaginatedAsync(int? page = null, int? pageSize = null)
{
try
{
var queryParams = new Dictionary<string, string>();
if (page.HasValue) queryParams["page"] = page.Value.ToString();
if (pageSize.HasValue) queryParams["page_size"] = pageSize.Value.ToString();
var response = await GetAsync<PaginatedResponse<DepartmentRole>>("api/v1/department-roles/", queryParams);
return response.Ok && response.Data != null ? response.Data : new PaginatedResponse<DepartmentRole>();
}
catch (Exception ex)
{
Trace.WriteLine($"Ошибка получения ролей отделов: {ex.Message}");
return new PaginatedResponse<DepartmentRole>();
}
}
// ============================================
// LEGACY/COMPATIBILITY METHODS
// ============================================
/// <summary>
/// Получить список сотрудников (устаревший метод для обратной совместимости)
/// </summary>
[Obsolete("Используйте GetAllEmployeesAsync() или GetEmployeesPaginatedAsync()")]
public Task<List<User>> GetEmployeesAsync() => GetAllEmployeesAsync();
/// <summary>
/// Получить историю сообщений (если есть эндпоинт)
/// </summary>
public async Task<List<Message>> GetMessagesAsync(int? userId = null)
{
@ -688,7 +1083,7 @@ public class ApiService : IDisposable
}
catch (Exception ex)
{
Debug.WriteLine($"Ошибка получения сообщений: {ex.Message}");
Trace.WriteLine($"Ошибка получения сообщений: {ex.Message}");
return new List<Message>();
}
}
@ -705,7 +1100,7 @@ public class ApiService : IDisposable
}
catch (Exception ex)
{
Debug.WriteLine($"Ошибка отправки сообщения: {ex.Message}");
Trace.WriteLine($"Ошибка отправки сообщения: {ex.Message}");
return false;
}
}
@ -723,7 +1118,7 @@ public class ApiService : IDisposable
}
catch (Exception ex)
{
Debug.WriteLine($"Ошибка поиска: {ex.Message}");
Trace.WriteLine($"Ошибка поиска: {ex.Message}");
return new List<User>();
}
}
@ -740,11 +1135,60 @@ public class ApiService : IDisposable
}
catch (Exception ex)
{
Debug.WriteLine($"Ошибка обновления статуса: {ex.Message}");
Trace.WriteLine($"Ошибка обновления статуса: {ex.Message}");
return false;
}
}
/// <summary>
/// Загрузить изображение (в виде байтов) с авторизацией
/// </summary>
public async Task<byte[]?> GetImageBytesAsync(string url)
{
try
{
if (string.IsNullOrEmpty(url)) return null;
// Если URL относительный - добавляем базу
if (!url.StartsWith("http"))
{
url = _config.BaseUrl.TrimEnd('/') + "/" + url.TrimStart('/');
}
await EnsureTokensLoadedAsync();
// Добавляем токен, если есть
var request = new HttpRequestMessage(HttpMethod.Get, url);
if (!string.IsNullOrEmpty(_accessToken))
{
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _accessToken);
}
var response = await _httpClient.SendAsync(request);
if (response.IsSuccessStatusCode)
{
var mediaType = response.Content.Headers.ContentType?.MediaType;
if (mediaType != null && !mediaType.StartsWith("image/"))
{
Trace.WriteLine($"[ApiService] ⚠️ URL {url} вернул не изображение, а {mediaType}");
return null;
}
var bytes = await response.Content.ReadAsByteArrayAsync();
Trace.WriteLine($"[ApiService] ✅ Загружено изображение {url} ({bytes.Length} bytes)");
return bytes;
}
Trace.WriteLine($"[ApiService] Ошибка загрузки изображения {url}: {response.StatusCode}");
return null;
}
catch (Exception ex)
{
Trace.WriteLine($"[ApiService] Исключение при загрузке изображения: {ex.Message}");
return null;
}
}
#endregion
#region IDisposable

View File

@ -26,6 +26,7 @@ public class WebSocketService
private bool _isConnected;
public event EventHandler<Message>? MessageReceived;
public event EventHandler<List<Message>>? MessageHistoryReceived;
public event EventHandler<User>? UserStatusChanged;
public event EventHandler<bool>? ConnectionStatusChanged;
@ -33,20 +34,20 @@ public class WebSocketService
public async Task ConnectAsync(string authToken)
{
Debug.WriteLine("\n╔════════════════════════════════════════════════════════════");
Debug.WriteLine("║ [WebSocket] CONNECTION ATTEMPT");
Debug.WriteLine("╚════════════════════════════════════════════════════════════");
Debug.WriteLine($"[WebSocket] Has Token: {!string.IsNullOrEmpty(authToken)}");
Trace.WriteLine("\n╔════════════════════════════════════════════════════════════");
Trace.WriteLine("║ [WebSocket] CONNECTION ATTEMPT");
Trace.WriteLine("╚════════════════════════════════════════════════════════════");
Trace.WriteLine($"[WebSocket] Has Token: {!string.IsNullOrEmpty(authToken)}");
if (string.IsNullOrEmpty(authToken))
{
Debug.WriteLine("[WebSocket] ❌ ERROR: No authentication token provided!");
Trace.WriteLine("[WebSocket] ❌ ERROR: No authentication token provided!");
throw new ArgumentException("Authentication token is required", nameof(authToken));
}
if (_webSocket is { State: WebSocketState.Open })
{
Debug.WriteLine("[WebSocket] Closing existing connection...");
Trace.WriteLine("[WebSocket] Closing existing connection...");
await DisconnectAsync();
}
@ -57,21 +58,21 @@ public class WebSocketService
{
try
{
Debug.WriteLine($"\n[WebSocket] >>> Trying: {url}");
Trace.WriteLine($"\n[WebSocket] >>> Trying: {url}");
_webSocket = new ClientWebSocket();
// 🔑 Устанавливаем Authorization заголовок
Debug.WriteLine($"[WebSocket] 🔑 Setting Authorization header...");
Debug.WriteLine($"[WebSocket] Token (first 30 chars): {authToken.Substring(0, Math.Min(30, authToken.Length))}...");
Debug.WriteLine($"[WebSocket] Token length: {authToken.Length}");
Trace.WriteLine($"[WebSocket] 🔑 Setting Authorization header...");
Trace.WriteLine($"[WebSocket] Token (first 30 chars): {authToken.Substring(0, Math.Min(30, authToken.Length))}...");
Trace.WriteLine($"[WebSocket] Token length: {authToken.Length}");
_webSocket.Options.SetRequestHeader("Authorization", $"Bearer {authToken}");
Debug.WriteLine($"[WebSocket] ✅ Authorization header set: Bearer [token]");
Trace.WriteLine($"[WebSocket] ✅ Authorization header set: Bearer [token]");
_cancellationTokenSource = new CancellationTokenSource();
var uri = new Uri(url);
Debug.WriteLine($"[WebSocket] Connecting to: {uri}");
Trace.WriteLine($"[WebSocket] Connecting to: {uri}");
await _webSocket.ConnectAsync(uri, _cancellationTokenSource.Token);
// Если дошли сюда - подключение успешно!
@ -79,9 +80,9 @@ public class WebSocketService
_connectedUrl = url;
ConnectionStatusChanged?.Invoke(this, true);
Debug.WriteLine($"[WebSocket] ✅ Connected successfully!");
Debug.WriteLine($"[WebSocket] URL: {url}");
Debug.WriteLine($"[WebSocket] State: {_webSocket.State}\n");
Trace.WriteLine($"[WebSocket] ✅ Connected successfully!");
Trace.WriteLine($"[WebSocket] URL: {url}");
Trace.WriteLine($"[WebSocket] State: {_webSocket.State}\n");
// Запускаем прослушивание сообщений
_ = Task.Run(async () => await ReceiveMessagesAsync());
@ -90,10 +91,10 @@ public class WebSocketService
}
catch (WebSocketException wsEx)
{
Debug.WriteLine($"[WebSocket] ❌ Failed to connect to: {url}");
Debug.WriteLine($" WebSocketError: {wsEx.WebSocketErrorCode}");
Debug.WriteLine($" Message: {wsEx.Message}");
Debug.WriteLine($" InnerException: {wsEx.InnerException?.Message}");
Trace.WriteLine($"[WebSocket] ❌ Failed to connect to: {url}");
Trace.WriteLine($" WebSocketError: {wsEx.WebSocketErrorCode}");
Trace.WriteLine($" Message: {wsEx.Message}");
Trace.WriteLine($" InnerException: {wsEx.InnerException?.Message}");
lastException = wsEx;
// Закрываем и очищаем
@ -104,9 +105,9 @@ public class WebSocketService
}
catch (Exception ex)
{
Debug.WriteLine($"[WebSocket] ❌ Failed to connect to: {url}");
Debug.WriteLine($" Exception: {ex.GetType().Name}");
Debug.WriteLine($" Message: {ex.Message}");
Trace.WriteLine($"[WebSocket] ❌ Failed to connect to: {url}");
Trace.WriteLine($" Exception: {ex.GetType().Name}");
Trace.WriteLine($" Message: {ex.Message}");
lastException = ex;
// Закрываем и очищаем
@ -121,9 +122,9 @@ public class WebSocketService
_isConnected = false;
ConnectionStatusChanged?.Invoke(this, false);
Debug.WriteLine($"\n[WebSocket] ❌ ALL CONNECTION ATTEMPTS FAILED!");
Debug.WriteLine($"[WebSocket] Tried {WebSocketUrls.Length} different URLs");
Debug.WriteLine($"[WebSocket] Last error: {lastException?.Message}\n");
Trace.WriteLine($"\n[WebSocket] ❌ ALL CONNECTION ATTEMPTS FAILED!");
Trace.WriteLine($"[WebSocket] Tried {WebSocketUrls.Length} different URLs");
Trace.WriteLine($"[WebSocket] Last error: {lastException?.Message}\n");
throw new Exception($"Не удалось подключиться к WebSocket. Попробовано {WebSocketUrls.Length} вариантов URL.", lastException);
}
@ -134,7 +135,10 @@ public class WebSocketService
{
if (_webSocket is { State: WebSocketState.Open })
{
await _cancellationTokenSource?.CancelAsync()!;
if (_cancellationTokenSource != null)
{
await _cancellationTokenSource.CancelAsync();
}
await _webSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Closing", CancellationToken.None);
}
@ -143,18 +147,20 @@ public class WebSocketService
}
catch (Exception ex)
{
Debug.WriteLine($"Ошибка отключения WebSocket: {ex.Message}");
Trace.WriteLine($"Ошибка отключения WebSocket: {ex.Message}");
}
finally
{
_webSocket?.Dispose();
_cancellationTokenSource?.Dispose();
_webSocket = null;
_cancellationTokenSource = null;
}
}
public async Task SendMessageAsync(Message message)
{
if (!IsConnected)
if (!IsConnected || _webSocket == null || _cancellationTokenSource == null)
{
throw new InvalidOperationException("WebSocket не подключен");
}
@ -170,21 +176,21 @@ public class WebSocketService
var bytes = Encoding.UTF8.GetBytes(json);
var buffer = new ArraySegment<byte>(bytes);
if (_webSocket != null && _cancellationTokenSource != null)
{
await _webSocket.SendAsync(buffer, WebSocketMessageType.Text, true, _cancellationTokenSource.Token);
}
await _webSocket.SendAsync(buffer, WebSocketMessageType.Text, true, _cancellationTokenSource.Token);
}
catch (Exception ex)
{
Debug.WriteLine($"Ошибка отправки сообщения через WebSocket: {ex.Message}");
Trace.WriteLine($"Ошибка отправки сообщения через WebSocket: {ex.Message}");
throw;
}
}
public async Task SendTypingIndicatorAsync(int recipientId, bool isTyping)
{
if (!IsConnected) return;
if (!IsConnected || _webSocket == null || _cancellationTokenSource == null)
{
return;
}
try
{
@ -197,14 +203,39 @@ public class WebSocketService
var bytes = Encoding.UTF8.GetBytes(json);
var buffer = new ArraySegment<byte>(bytes);
if (_webSocket != null && _cancellationTokenSource != null)
{
await _webSocket.SendAsync(buffer, WebSocketMessageType.Text, true, _cancellationTokenSource.Token);
}
await _webSocket.SendAsync(buffer, WebSocketMessageType.Text, true, _cancellationTokenSource.Token);
}
catch (Exception ex)
{
Debug.WriteLine($"Ошибка отправки индикатора набора: {ex.Message}");
Trace.WriteLine($"Ошибка отправки индикатора набора: {ex.Message}");
}
}
public async Task RequestMessageHistoryAsync(int userId)
{
if (!IsConnected || _webSocket == null || _cancellationTokenSource == null)
{
Trace.WriteLine("[WebSocket] Не подключен - невозможно запросить историю");
return;
}
try
{
var json = JsonConvert.SerializeObject(new
{
type = "get_history",
data = new { userId }
});
var bytes = Encoding.UTF8.GetBytes(json);
var buffer = new ArraySegment<byte>(bytes);
await _webSocket.SendAsync(buffer, WebSocketMessageType.Text, true, _cancellationTokenSource.Token);
Trace.WriteLine($"[WebSocket] Запрошена история сообщений с пользователем {userId}");
}
catch (Exception ex)
{
Trace.WriteLine($"[WebSocket] Ошибка запроса истории: {ex.Message}");
}
}
@ -242,7 +273,7 @@ public class WebSocketService
}
catch (Exception ex)
{
Debug.WriteLine($"Ошибка получения сообщений WebSocket: {ex.Message}");
Trace.WriteLine($"Ошибка получения сообщений WebSocket: {ex.Message}");
_isConnected = false;
ConnectionStatusChanged?.Invoke(this, false);
}
@ -263,6 +294,11 @@ public class WebSocketService
if (message != null) MessageReceived?.Invoke(this, message);
break;
case "message_history":
var history = JsonConvert.DeserializeObject<List<Message>>(wrapper.Data.ToString() ?? string.Empty);
if (history != null) MessageHistoryReceived?.Invoke(this, history);
break;
case "user_status":
var user = JsonConvert.DeserializeObject<User>(wrapper.Data.ToString() ?? string.Empty);
if (user != null) UserStatusChanged?.Invoke(this, user);
@ -273,14 +309,14 @@ public class WebSocketService
break;
default:
Debug.WriteLine($"Неизвестный тип сообщения: {wrapper?.Type ?? "null"}");
Trace.WriteLine($"Неизвестный тип сообщения: {wrapper?.Type ?? "null"}");
break;
}
});
}
catch (Exception ex)
{
Debug.WriteLine($"Ошибка обработки сообщения: {ex.Message}");
Trace.WriteLine($"Ошибка обработки сообщения: {ex.Message}");
}
}

View File

@ -0,0 +1,38 @@
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Runtime.CompilerServices;
using CORP_MAUI.Models;
namespace CORP_MAUI.ViewModels;
public class PostDetailsViewModel : INotifyPropertyChanged
{
public PostViewModel PostViewModel { get; }
public ObservableCollection<Comment> Comments { get; } = new();
private bool _isLoadingComments;
public bool IsLoadingComments
{
get => _isLoadingComments;
set
{
_isLoadingComments = value;
OnPropertyChanged();
OnPropertyChanged(nameof(IsNotLoadingComments));
}
}
public bool IsNotLoadingComments => !_isLoadingComments;
public PostDetailsViewModel(PostViewModel postViewModel)
{
PostViewModel = postViewModel;
}
public event PropertyChangedEventHandler? PropertyChanged;
protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
}

View File

@ -0,0 +1,92 @@
using System.ComponentModel;
using System.Runtime.CompilerServices;
using CORP_MAUI.Models;
namespace CORP_MAUI.ViewModels;
public class PostViewModel : INotifyPropertyChanged
{
public Post Post { get; }
public PostViewModel(Post post)
{
Post = post;
// Truncate content for preview
if (!string.IsNullOrEmpty(Post.Content) && Post.Content.Length > 0)
{
if (Post.Content.Length > 100)
{
_previewContent = Post.Content.Substring(0, 100) + "...";
}
else
{
_previewContent = Post.Content;
}
}
else
{
_previewContent = string.Empty;
}
}
private string? _localImagePath;
public string? LocalImagePath
{
get => _localImagePath;
set
{
_localImagePath = value;
OnPropertyChanged();
OnPropertyChanged(nameof(IsImageVisible));
OnPropertyChanged(nameof(DisplayImageSource));
}
}
private ImageSource? _imageSource;
public ImageSource? AuthorizedImageSource
{
get => _imageSource;
set
{
_imageSource = value;
OnPropertyChanged();
OnPropertyChanged(nameof(IsImageVisible));
OnPropertyChanged(nameof(DisplayImageSource));
}
}
public ImageSource? DisplayImageSource
{
get
{
if (_imageSource != null) return _imageSource;
if (!string.IsNullOrEmpty(_localImagePath)) return ImageSource.FromFile(_localImagePath);
return null;
}
}
public bool IsImageVisible => DisplayImageSource != null;
private string _previewContent;
public string PreviewContent
{
get => _previewContent;
set
{
_previewContent = value;
OnPropertyChanged();
}
}
// Proxy properties for cleaner XAML binding if needed, or bind to Post.Title directly
public int Id => Post.Id;
public string Title => Post.Title;
public string? AuthorizedImageUrl => Post.DisplayImageUrl;
public event PropertyChangedEventHandler? PropertyChanged;
protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
}

View File

@ -1,497 +1,300 @@
# API и интеграция
# 📚 Corp API - Справочник по всем эндпоинтам
## 🌐 Базовая информация
## 🎯 Обзор
**Базовый URL:** `https://corp.robotail.pro`
**API версия:** v1
**Протоколы:**
- REST API - `https://corp.robotail.pro/api/v1/`
- WebSocket - `wss://corp.robotail.pro/ws`
## 🔐 Аутентификация
### Получение токена
```http
POST /api/auth/token/
Content-Type: application/json
{
"email": "user@example.com",
"password": "password"
}
```
**Ответ:**
```json
{
"access": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"refresh": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}
```
### Обновление токена
```http
POST /api/auth/token/refresh/
Content-Type: application/json
{
"refresh": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}
```
**Ответ:**
```json
{
"access": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}
```
### Использование токена
Все защищенные endpoints требуют Bearer токен в заголовке:
```http
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
```
## 👥 REST API - Пользователи
### Получить список всех сотрудников
```http
GET /api/v1/employees/
Authorization: Bearer {access_token}
```
**Ответ:**
```json
[
{
"id": 1,
"username": "ivan_ivanov",
"email": "ivan@company.com",
"firstName": "Иван",
"lastName": "Иванов",
"department": "IT",
"position": "Разработчик",
"isOnline": true,
"avatarUrl": null
},
{
"id": 2,
"username": "maria_petrova",
"email": "maria@company.com",
"firstName": "Мария",
"lastName": "Петрова",
"department": "HR",
"position": "Менеджер",
"isOnline": false,
"avatarUrl": null
}
]
```
### Получить текущего пользователя
```http
GET /api/v1/employees/me/
Authorization: Bearer {access_token}
```
**Ответ:**
```json
{
"id": 1,
"username": "ivan_ivanov",
"email": "ivan@company.com",
"firstName": "Иван",
"lastName": "Иванов",
"department": "IT",
"position": "Разработчик",
"isOnline": true,
"avatarUrl": null
}
```
### Поиск пользователей
```http
GET /api/v1/users/search/?q={query}
Authorization: Bearer {access_token}
```
**Параметры:**
- `q` (string, required) - поисковый запрос (имя, отдел, должность)
**Пример:**
```http
GET /api/v1/users/search/?q=Иван
```
**Ответ:**
```json
[
{
"id": 1,
"username": "ivan_ivanov",
"firstName": "Иван",
"lastName": "Иванов",
"department": "IT",
"position": "Разработчик",
"isOnline": true
}
]
```
### Обновить статус пользователя
```http
PUT /api/v1/users/status/
Authorization: Bearer {access_token}
Content-Type: application/json
{
"isOnline": true
}
```
**Ответ:**
```json
{
"success": true,
"message": "Статус обновлен"
}
```
## 💬 REST API - Сообщения
### Получить все сообщения
```http
GET /api/v1/messages/
Authorization: Bearer {access_token}
```
**Ответ:**
```json
[
{
"id": 1,
"senderId": 2,
"senderName": "Мария Петрова",
"receiverId": 1,
"content": "Привет! Как дела?",
"timestamp": "2026-01-12T10:30:00Z",
"isRead": false
}
]
```
### Получить сообщения с конкретным пользователем
```http
GET /api/v1/messages/?user_id={userId}
Authorization: Bearer {access_token}
```
**Параметры:**
- `user_id` (int, required) - ID пользователя для фильтрации
**Пример:**
```http
GET /api/v1/messages/?user_id=2
```
**Ответ:**
```json
[
{
"id": 1,
"senderId": 2,
"senderName": "Мария Петрова",
"receiverId": 1,
"content": "Привет!",
"timestamp": "2026-01-12T10:30:00Z",
"isRead": true
},
{
"id": 2,
"senderId": 1,
"senderName": "Иван Иванов",
"receiverId": 2,
"content": "Привет! Как дела?",
"timestamp": "2026-01-12T10:31:00Z",
"isRead": true
}
]
```
### Отправить сообщение (REST fallback)
```http
POST /api/v1/messages/
Authorization: Bearer {access_token}
Content-Type: application/json
{
"senderId": 1,
"receiverId": 2,
"content": "Текст сообщения"
}
```
**Ответ:**
```json
{
"id": 3,
"senderId": 1,
"senderName": "Иван Иванов",
"receiverId": 2,
"content": "Текст сообщения",
"timestamp": "2026-01-12T10:35:00Z",
"isRead": false
}
```
## 🔌 WebSocket API
### Подключение
**URL:** `wss://corp.robotail.pro/ws`
**Поддерживаемые варианты URL:**
- `wss://corp.robotail.pro/ws` (основной)
- `ws://corp.robotail.pro/ws` (без SSL)
- `wss://corp.robotail.pro/ws/` (со слешем)
- `wss://corp.robotail.pro/api/v1/ws`
- `ws://corp.robotail.pro/api/v1/ws`
> WebSocketService автоматически пробует разные варианты и подключается к первому работающему
**Обязательный заголовок:**
```http
Authorization: Bearer {access_token}
```
**Пример подключения (C#):**
```csharp
var ws = new ClientWebSocket();
ws.Options.SetRequestHeader("Authorization", $"Bearer {accessToken}");
await ws.ConnectAsync(new Uri("wss://corp.robotail.pro/ws"), CancellationToken.None);
```
### Формат сообщений
Все WebSocket сообщения имеют единый формат:
```json
{
"type": "message_type",
"data": {
// данные специфичные для типа
}
}
```
### Типы сообщений
#### 1. Отправка сообщения (Client → Server)
```json
{
"type": "message",
"data": {
"receiverId": 2,
"content": "Текст сообщения"
}
}
```
#### 2. Получение сообщения (Server → Client)
```json
{
"type": "message",
"data": {
"id": 456,
"senderId": 2,
"senderName": "Мария Петрова",
"receiverId": 1,
"content": "Текст сообщения",
"timestamp": "2026-01-12T10:30:00Z"
}
}
```
#### 3. Индикатор набора текста (Client → Server)
```json
{
"type": "typing",
"data": {
"recipientId": 2,
"isTyping": true
}
}
```
#### 4. Индикатор набора текста (Server → Client)
```json
{
"type": "typing",
"data": {
"senderId": 2,
"senderName": "Мария Петрова",
"isTyping": true
}
}
```
#### 5. Обновление статуса пользователя (Server → Client)
```json
{
"type": "user_status",
"data": {
"userId": 2,
"isOnline": true
}
}
```
### Обработка событий (C#)
```csharp
// Подписка на события
ServiceProvider.WebSocketService.MessageReceived += OnMessageReceived;
ServiceProvider.WebSocketService.UserStatusChanged += OnUserStatusChanged;
ServiceProvider.WebSocketService.TypingIndicatorReceived += OnTypingIndicator;
// Обработчики
private void OnMessageReceived(object sender, Message message)
{
// Обработка нового сообщения
}
private void OnUserStatusChanged(object sender, (int userId, bool isOnline) status)
{
// Обновление статуса пользователя в UI
}
private void OnTypingIndicator(object sender, (int userId, bool isTyping) typing)
{
// Показать/скрыть индикатор "печатает..."
}
```
## 🔄 Система управления токенами
### Автоматическое обновление
ApiService автоматически управляет токенами:
1. **При получении 401/403 ошибки:**
- Пытается обновить access токен используя refresh токен
- Повторяет оригинальный запрос с новым токеном
- При неудаче перенаправляет на страницу входа
2. **При успешном обновлении:**
- Сохраняет новый access токен в SecureStorage
- Обновляет HTTP заголовки
- Возвращает результат оригинального запроса
3. **При неудаче обновления:**
- Очищает все токены
- Очищает SecureStorage
- Требует повторную аутентификацию
### Хранение токенов
Токены хранятся в `SecureStorage`:
```csharp
// Сохранение
await SecureStorage.SetAsync("access_token", accessToken);
await SecureStorage.SetAsync("refresh_token", refreshToken);
// Получение
var accessToken = await SecureStorage.GetAsync("access_token");
var refreshToken = await SecureStorage.GetAsync("refresh_token");
// Удаление
SecureStorage.Remove("access_token");
SecureStorage.Remove("refresh_token");
```
## ⚠️ Обработка ошибок
### HTTP статус коды
- **200 OK** - успешный запрос
- **201 Created** - ресурс создан
- **400 Bad Request** - неверные параметры запроса
- **401 Unauthorized** - токен отсутствует или недействителен
- **403 Forbidden** - доступ запрещен
- **404 Not Found** - ресурс не найден
- **500 Internal Server Error** - ошибка сервера
### Формат ошибок
```json
{
"error": "Описание ошибки",
"detail": "Детальная информация",
"code": "ERROR_CODE"
}
```
### WebSocket коды закрытия
- **1000** - нормальное закрытие
- **1001** - сервер недоступен
- **1006** - аномальное закрытие (например, потеря сети)
- **1008** - нарушение политики (например, недействительный токен)
## 🔧 Конфигурация ApiService
```csharp
var config = new ApiConfig
{
BaseUrl = "https://corp.robotail.pro/api",
TokenObtainPath = "/auth/token/",
TokenRefreshPath = "/auth/token/refresh/",
Timeout = 30,
DefaultHeaders = new Dictionary<string, string>
{
{ "Accept", "application/json" },
{ "Content-Type", "application/json" }
}
};
var apiService = new ApiService(config);
```
## 📝 Примечания
### Лимиты и ограничения
- Максимальная длина сообщения: 4000 символов
- Rate limiting: 100 запросов в минуту на пользователя
- WebSocket idle timeout: 5 минут без активности
### Рекомендации
1. **Приоритет WebSocket** - используйте WebSocket для отправки сообщений, REST API только как fallback
2. **Переподключение** - реализуйте автоматическое переподключение WebSocket при разрыве
3. **Кэширование** - кэшируйте список пользователей для offline режима
4. **Optimistic UI** - обновляйте UI сразу, не дожидаясь ответа сервера
**Базовый URL:** `https://corp.robotail.pro/api/v1/`
**Обнаружено эндпоинтов:** 13 (+ корневой)
**Тесты:** ✅ 22/22 прошли успешно
---
**Последнее обновление:** 2026-01-12
## 🔐 Аутентификация
Все эндпоинты требуют Bearer token:
```http
Authorization: Bearer <access_token>
```
**Получение токена:**
- URL: `https://corp.robotail.pro/api/auth/token/`
- Метод: POST
- Тело: `{"email": "...", "password": "..."}`
---
## 📋 Список всех эндпоинтов
### 1. 🏠 Корневой эндпоинт
```
GET /api/v1/
```
**Возвращает:** Словарь со всеми доступными эндпоинтами
**Формат ответа:** `{"endpoint_name": "full_url", ...}`
**Тест:** ✅ Пройден
---
### 2. 📅 События календаря
```
GET /api/v1/calendar/events/
POST /api/v1/calendar/events/
```
**Возвращает:** Массив событий
**Методы:** GET, POST, HEAD, OPTIONS
**Тест:** ✅ Пройден
**Пример использования:**
```python
response = requests.get(
"https://corp.robotail.pro/api/v1/calendar/events/",
headers={"Authorization": f"Bearer {token}"}
)
events = response.json() # list
```
---
### 3. 👥 Сотрудники
```
GET /api/v1/employees/
POST /api/v1/employees/
```
**Возвращает:** Пагинированный список сотрудников
**Поля ответа:** `count`, `next`, `previous`, `results`
**Методы:** GET, POST, HEAD, OPTIONS
**Тесты:** ✅ 2 теста пройдены
**Структура ответа:**
```json
{
"count": 100,
"next": "https://...?page=2",
"previous": null,
"results": [...]
}
```
---
### 4. 🏢 Отделы
```
GET /api/v1/departments/
POST /api/v1/departments/
```
**Возвращает:** Пагинированный список отделов
**Поля ответа:** `count`, `next`, `previous`, `results`
**Методы:** GET, POST, HEAD, OPTIONS
**Тест:** ✅ Пройден
---
### 5. 💼 Должности
```
GET /api/v1/positions/
POST /api/v1/positions/
```
**Возвращает:** Массив должностей
**Поля объекта:** `id`, `name`, `description`, `groups`, `groups_verbose`
**Методы:** GET, POST, HEAD, OPTIONS
**Тест:** ✅ Пройден
---
### 6. 🎓 Навыки
```
GET /api/v1/skills/
POST /api/v1/skills/
```
**Возвращает:** Массив навыков
**Поля объекта:** `id`, `name`
**Методы:** GET, POST, HEAD, OPTIONS
**Тест:** ✅ Пройден
**Пример:**
```json
[
{"id": 1, "name": "Python"},
{"id": 2, "name": "JavaScript"}
]
```
---
### 7. 📝 Действия сотрудников
```
GET /api/v1/employee-actions/
POST /api/v1/employee-actions/
```
**Возвращает:** Массив действий
**Поля:** `id`, `employee`, `comment`, `extra`, `history`, `action_display`, `date`, `action`, `date_display`
**Методы:** GET, POST, HEAD, OPTIONS
**Тест:** ✅ Пройден
---
### 8. 📄 Документы
```
GET /api/v1/documents/
POST /api/v1/documents/
```
**Возвращает:** Пагинированный список документов
**Поля ответа:** `count`, `next`, `previous`, `results`
**Методы:** GET, POST, HEAD, OPTIONS
**Тест:** ✅ Пройден
---
### 9. 📨 Запросы
```
GET /api/v1/requests/
POST /api/v1/requests/
```
**Возвращает:** Пагинированный список запросов
**Поля ответа:** `count`, `next`, `previous`, `results`
**Методы:** GET, POST, HEAD, OPTIONS
**Тест:** ✅ Пройден
---
### 10. 📰 Посты
```
GET /api/v1/posts/
POST /api/v1/posts/
```
**Возвращает:** Пагинированный список постов
**Поля ответа:** `count`, `next`, `previous`, `results`
**Методы:** GET, POST, HEAD, OPTIONS
**Тест:** ✅ Пройден
---
### 11. 💬 Комментарии
```
GET /api/v1/comments/
POST /api/v1/comments/
```
**Возвращает:** Пагинированный список комментариев
**Поля ответа:** `count`, `next`, `previous`, `results`
**Методы:** GET, POST, HEAD, OPTIONS
**Тест:** ✅ Пройден
---
### 12. 🎭 Роли отделов
```
GET /api/v1/department-roles/
POST /api/v1/department-roles/
```
**Возвращает:** Пагинированный список ролей
**Поля ответа:** `count`, `next`, `previous`, `results`
**Методы:** GET, POST, HEAD, OPTIONS
**Тест:** ✅ Пройден
---
### 13. 🚫 Группы (ограничен доступ)
```
GET /api/v1/groups/
```
**Статус:** 403 Forbidden
**Описание:** Доступ ограничен для текущего пользователя
**Тест:** ✅ Пройден (проверка 403)
---
## 🧪 Результаты тестирования
### Все тесты (22 из 22): ✅
#### Функциональные тесты:
- ✅ Корневой эндпоинт возвращает список
- ✅ Корневой эндпоинт содержит основные эндпоинты
- ✅ Calendar events
- ✅ Employees (список)
- ✅ Employees (пагинация)
- ✅ Departments
- ✅ Positions
- ✅ Skills
- ✅ Employee actions
- ✅ Documents
- ✅ Requests
- ✅ Posts
- ✅ Comments
- ✅ Department roles
- ✅ Groups (403)
#### Тесты безопасности (7):
- ✅ Employees требует авторизации
- ✅ Departments требует авторизации
- ✅ Positions требует авторизации
- ✅ Documents требует авторизации
- ✅ Requests требует авторизации
- ✅ Posts требует авторизации
- ✅ Comments требует авторизации
---
## 📦 Созданные файлы
| Файл | Описание | Статус |
|------|----------|--------|
| `requests_corp_api_test.py` | Базовый скрипт для получения токена | ✅ |
| `explore_api_endpoints.py` | Рекурсивное обнаружение эндпоинтов | ✅ |
| `endpoints_list.py` | Структурированный список эндпоинтов | ✅ |
| `test_corp_api.py` | Тесты аутентификации (9 тестов) | ✅ |
| `test_all_endpoints.py` | Полный набор тестов (22 теста) | ✅ |
| `API_DOCUMENTATION.md` | Документация по аутентификации | ✅ |
| `ENDPOINTS_REFERENCE.md` | Этот справочник | ✅ |
---
## 🚀 Быстрый старт
### 1. Получение токена
```python
from requests_corp_api_test import get_token
token = get_token("your@email.com", "password")
```
### 2. Использование эндпоинтов
```python
import requests
from endpoints_list import API_BASE_URL
headers = {"Authorization": f"Bearer {token}"}
# Получить список сотрудников
response = requests.get(API_BASE_URL + "employees/", headers=headers)
employees = response.json()
# Получить навыки
response = requests.get(API_BASE_URL + "skills/", headers=headers)
skills = response.json()
```
### 3. Запуск тестов
```bash
# Все тесты
pytest test_all_endpoints.py -v
# Конкретный класс
pytest test_all_endpoints.py::TestEmployeesEndpoints -v
# С подробным выводом
pytest test_all_endpoints.py -v --tb=short
```
---
## 📊 Статистика
- **Всего эндпоинтов:** 13
- **Тестов написано:** 22
- **Успешных тестов:** 22 (100%)
- **Время выполнения:** ~26 секунд
- **Пагинированных эндпоинтов:** 8
- **Array эндпоинтов:** 4
- **Ограниченных эндпоинтов:** 1 (groups)

View File

@ -13,10 +13,12 @@
### [API и интеграция](API.md)
Полная документация по REST API и WebSocket протоколу:
- REST API endpoints
- WebSocket сообщения
- Система аутентификации и управления токенами
- Форматы запросов и ответов
- **REST API endpoints** - все доступные эндпоинты (employees, departments, documents, requests, posts, comments, positions, skills, calendar events, employee-actions, department-roles)
- **Пагинация** - работа с большими списками данных
- **WebSocket протокол** - real-time коммуникация
- **Аутентификация JWT** - получение и обновление токенов
- **Форматы данных** - структура запросов и ответов
- **Обработка ошибок** - статус коды и их обработка
### [Установка и настройка](SETUP.md)
Инструкции по установке, настройке и сборке проекта: