From cfbda4ca05727b3e341db966d3defa9565de9613 Mon Sep 17 00:00:00 2001 From: Toutsu Date: Tue, 28 Apr 2026 10:22:12 +0300 Subject: [PATCH] fix: move campaign templates to dedicated tab --- .gitea/workflows/deploy.yml | 2 +- Directory.Build.props | 2 +- README.md | 11 +- compose.yaml | 4 +- .../Components/Layout/NavMenu.razor | 11 +- .../Components/Pages/CampaignTemplates.razor | 402 ++++++++++++++++++ .../Components/Pages/GroupDetails.razor | 162 +------ src/GmRelay.Web/wwwroot/app.css | 30 +- .../Web/CampaignTemplatesNavigationTests.cs | 50 +++ 9 files changed, 510 insertions(+), 164 deletions(-) create mode 100644 src/GmRelay.Web/Components/Pages/CampaignTemplates.razor create mode 100644 tests/GmRelay.Bot.Tests/Web/CampaignTemplatesNavigationTests.cs diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 1210d33..541760c 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -6,7 +6,7 @@ on: - main env: - VERSION: 1.8.0 + VERSION: 1.8.1 jobs: # ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами) diff --git a/Directory.Build.props b/Directory.Build.props index 33e7108..f7f5d8c 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - 1.8.0 + 1.8.1 net10.0 preview enable diff --git a/README.md b/README.md index c09580a..6dc73ad 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Проект разработан с упором на производительность, архитектуру Vertical Slice, Native AOT (для бота) и удобство развертывания с использованием .NET Aspire. -**Текущая версия:** `v1.8.0`. +**Текущая версия:** `v1.8.1`. --- @@ -26,7 +26,7 @@ - **🔐 Авторизация через Telegram**: Безопасный вход с использованием Telegram Login Widget (HMAC-SHA256 валидация). - **📝 Удобное редактирование**: Веб-интерфейс для детального редактирования сессий, изменения дат, названий и статусов. - **🤝 Co-GM и делегирование**: Owner группы назначает помощников по Telegram ID, а co-GM получает доступ к управлению расписанием в Telegram и Web Dashboard. -- **📋 Шаблоны кампаний**: Owner и co-GM сохраняют типовые параметры кампании и создают новый повторяющийся batch из шаблона за один шаг. +- **📋 Шаблоны кампаний**: Owner и co-GM управляют типовыми параметрами кампаний в отдельной вкладке `Шаблоны`, а на странице группы запускают новый повторяющийся batch из выбранного шаблона. - **🧩 Bulk-операции для Batch Sessions**: ГМ может обновить общий title/link, перенести всю пачку на фиксированный шаг и клонировать batch на следующую неделю или месяц. - **🔕 Режим уведомлений batch**: Для каждой пачки можно выбрать `В группе и в личку` или `Только в группе`. - **⬆️ Управление очередью**: Веб-интерфейс показывает заполненность, лист ожидания и позволяет ГМу поднять первого игрока из очереди. @@ -157,9 +157,10 @@ Owner или co-GM нажимает кнопку `⏰ Перенести` у н Дедлайн должен быть в будущем и раньше первого предложенного времени. Участники выбирают один вариант кнопкой в Telegram, могут изменить голос до дедлайна и видят текущие результаты в сообщении голосования. По дедлайну бот выбирает вариант с наибольшим числом голосов, переносит сессию, сбрасывает RSVP и обновляет batch-сообщение. Если голосов нет или есть ничья, перенос отклоняется, а время сессии остаётся прежним. -### Bulk-операции в Web Dashboard -На странице группы Web Dashboard показывает отдельный блок для каждой пачки игр. Owner и co-GM могут: -- сохранить шаблон кампании с названием, ссылкой, количеством игр, интервалом, лимитом мест и режимом уведомлений; +### Шаблоны и bulk-операции в Web Dashboard +Вкладка `Шаблоны` в левом меню вынесена отдельно от страницы группы. Owner и co-GM выбирают группу, сохраняют шаблон кампании с названием, ссылкой, количеством игр, интервалом, лимитом мест и режимом уведомлений, а также удаляют устаревшие шаблоны. + +На странице группы Web Dashboard показывает только применение сохранённых шаблонов и отдельный блок для каждой пачки игр. Owner и co-GM могут: - создать новый batch из шаблона, выбрав только первую дату расписания; - обновить общий `title` и `link` сразу у всех сессий batch; - выбрать режим уведомлений: дублировать важные сообщения игрокам в личку или оставить только групповые уведомления; diff --git a/compose.yaml b/compose.yaml index b1e63f7..82cec72 100644 --- a/compose.yaml +++ b/compose.yaml @@ -17,7 +17,7 @@ services: retries: 10 bot: - image: git.codeanddice.ru/toutsu/gmrelay-bot:1.8.0 + image: git.codeanddice.ru/toutsu/gmrelay-bot:1.8.1 restart: always depends_on: db: @@ -29,7 +29,7 @@ services: - gmrelay web: - image: git.codeanddice.ru/toutsu/gmrelay-web:1.8.0 + image: git.codeanddice.ru/toutsu/gmrelay-web:1.8.1 restart: always depends_on: db: diff --git a/src/GmRelay.Web/Components/Layout/NavMenu.razor b/src/GmRelay.Web/Components/Layout/NavMenu.razor index 63feebb..dc1b9ea 100644 --- a/src/GmRelay.Web/Components/Layout/NavMenu.razor +++ b/src/GmRelay.Web/Components/Layout/NavMenu.razor @@ -25,6 +25,15 @@ Панель управления + + + + + + + + Шаблоны + diff --git a/src/GmRelay.Web/Components/Pages/CampaignTemplates.razor b/src/GmRelay.Web/Components/Pages/CampaignTemplates.razor new file mode 100644 index 0000000..ce6b05d --- /dev/null +++ b/src/GmRelay.Web/Components/Pages/CampaignTemplates.razor @@ -0,0 +1,402 @@ +@page "/templates" +@using GmRelay.Web.Services +@using GmRelay.Shared.Domain +@using Microsoft.AspNetCore.Authorization +@using Microsoft.AspNetCore.Components.Authorization +@attribute [Authorize] +@inject AuthorizedSessionService SessionService +@inject AuthenticationStateProvider AuthStateProvider +@inject NavigationManager Navigation + +Шаблоны кампаний — GM-Relay + +
+ + + + + @if (!string.IsNullOrEmpty(errorMessage)) + { +
+ ⚠️ @errorMessage +
+ } + + @if (!string.IsNullOrEmpty(successMessage)) + { +
+ ✅ @successMessage +
+ } + + @if (groups is null) + { +
+
+
+
+
+ } + else if (groups.Count == 0) + { +
+
+
🤖
+
Нет доступных групп
+

Добавьте бота GM-Relay в группу Telegram, чтобы создать первый шаблон кампании.

+
+
+ } + else + { +
+
+
+

Группа для шаблонов

+

@(SelectedGroup?.Name ?? "Выберите группу")

+
+ @if (SelectedGroup is not null) + { + + @FormatRole(SelectedGroup.ManagerRole) + + } +
+ +
+ + @if (SelectedGroup is not null) + { + Открыть группу → + } +
+
+ +
+
+
+

Новый шаблон

+

Эти параметры будут использоваться при запуске batch из группы.

+
+ Template +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+

Сохранённые шаблоны

+

@campaignTemplateModels.Count для выбранной группы

+
+ @campaignTemplateModels.Count +
+ + @if (campaignTemplates is null) + { +
+
+ } + else if (campaignTemplateModels.Count == 0) + { +
+
Шаблонов пока нет
+

Создайте первый шаблон для выбранной группы.

+
+ } + else + { +
+ @foreach (var template in campaignTemplateModels) + { +
+
+

@template.Name

+

@FormatTemplateSummary(template)

+
+
+ @FormatLocalMoscow(template.UpdatedAt.ToMoscow()) + +
+
+ } +
+ } +
+ } +
+ +@code { + private List? groups; + private List? campaignTemplates; + private List campaignTemplateModels = []; + private Guid selectedGroupId; + private Guid? deletingTemplateId; + private bool isCreatingTemplate; + private long telegramId; + private string? errorMessage; + private string? successMessage; + private CampaignTemplateEditModel templateModel = new(); + + private WebGameGroup? SelectedGroup => groups?.FirstOrDefault(group => group.Id == selectedGroupId); + + protected override async Task OnInitializedAsync() + { + var authState = await AuthStateProvider.GetAuthenticationStateAsync(); + if (!authState.User.TryGetTelegramId(out telegramId)) + { + Navigation.NavigateTo("/access-denied"); + return; + } + + groups = await SessionService.GetGroupsForGmAsync(telegramId); + selectedGroupId = groups.FirstOrDefault()?.Id ?? Guid.Empty; + + if (selectedGroupId != Guid.Empty) + { + await LoadTemplates(); + } + } + + private async Task OnSelectedGroupChanged(ChangeEventArgs args) + { + if (!Guid.TryParse(args.Value?.ToString(), out var groupId)) + { + return; + } + + selectedGroupId = groupId; + errorMessage = null; + successMessage = null; + await LoadTemplates(); + } + + private async Task LoadTemplates() + { + campaignTemplates = null; + campaignTemplateModels = []; + + var templates = await SessionService.GetCampaignTemplatesForGmAsync(selectedGroupId, telegramId); + if (templates is null) + { + Navigation.NavigateTo("/access-denied"); + return; + } + + campaignTemplates = templates; + RebuildCampaignTemplateModels(); + } + + private async Task CreateCampaignTemplate() + { + errorMessage = null; + successMessage = null; + + if (selectedGroupId == Guid.Empty) + { + errorMessage = "Выберите группу для шаблона."; + return; + } + + if (!ValidateCampaignTemplate(templateModel)) + { + errorMessage = "Шаблон должен иметь название, ссылку, 1-52 игр, шаг 1-365 дней и положительный лимит мест, если он указан."; + return; + } + + isCreatingTemplate = true; + + try + { + await SessionService.CreateCampaignTemplateForGmAsync( + selectedGroupId, + telegramId, + new CreateCampaignTemplateRequest( + templateModel.Name, + templateModel.Title, + templateModel.JoinLink, + templateModel.SessionCount, + templateModel.IntervalDays, + templateModel.MaxPlayers, + SessionNotificationModeExtensions.FromDatabaseValue(templateModel.NotificationMode))); + + templateModel = new(); + successMessage = "Шаблон кампании сохранён."; + await LoadTemplates(); + } + catch (SessionAccessDeniedException) + { + Navigation.NavigateTo("/access-denied"); + } + catch (Exception ex) + { + errorMessage = "Не удалось сохранить шаблон: " + ex.Message; + } + finally + { + isCreatingTemplate = false; + } + } + + private async Task DeleteCampaignTemplate(CampaignTemplateManagementModel template) + { + errorMessage = null; + successMessage = null; + deletingTemplateId = template.Id; + + try + { + await SessionService.DeleteCampaignTemplateForGmAsync(template.Id, telegramId); + successMessage = "Шаблон кампании удалён."; + await LoadTemplates(); + } + catch (SessionAccessDeniedException) + { + Navigation.NavigateTo("/access-denied"); + } + catch (Exception ex) + { + errorMessage = "Не удалось удалить шаблон: " + ex.Message; + } + finally + { + deletingTemplateId = null; + } + } + + private void RebuildCampaignTemplateModels() + { + campaignTemplateModels = campaignTemplates? + .OrderByDescending(template => template.UpdatedAt) + .ThenBy(template => template.Name) + .Select(template => new CampaignTemplateManagementModel + { + Id = template.Id, + Name = template.Name, + Title = template.Title, + JoinLink = template.JoinLink, + SessionCount = template.SessionCount, + IntervalDays = template.IntervalDays, + MaxPlayers = template.MaxPlayers, + NotificationMode = template.NotificationMode, + UpdatedAt = template.UpdatedAt + }) + .ToList() ?? []; + } + + private static bool ValidateCampaignTemplate(CampaignTemplateEditModel template) + { + template.Name = template.Name.Trim(); + template.Title = template.Title.Trim(); + template.JoinLink = template.JoinLink.Trim(); + + if (template.MaxPlayers.HasValue && template.MaxPlayers.Value <= 0) + { + return false; + } + + return template.Name.Length > 0 && + template.Title.Length > 0 && + template.JoinLink.Length > 0 && + template.SessionCount is >= 1 and <= 52 && + template.IntervalDays is >= 1 and <= 365; + } + + private static string FormatTemplateSummary(CampaignTemplateManagementModel template) + { + var seats = template.MaxPlayers.HasValue + ? $"{template.MaxPlayers.Value.ToString(System.Globalization.CultureInfo.InvariantCulture)} мест" + : "без лимита"; + + return $"{template.Title} · {template.SessionCount} игр · каждые {template.IntervalDays} дн. · {seats} · {FormatNotificationMode(template.NotificationMode)}"; + } + + private static string FormatNotificationMode(string notificationMode) => + SessionNotificationModeExtensions.FromDatabaseValue(notificationMode) switch + { + SessionNotificationMode.GroupOnly => "только группа", + _ => "группа и личка" + }; + + private static string FormatRole(string role) => + GroupManagerRoleExtensions.FromDatabaseValue(role).ToDisplayName(); + + private static string FormatLocalMoscow(DateTime localMoscow) => + localMoscow.ToString("d MMMM yyyy, HH:mm", System.Globalization.CultureInfo.GetCultureInfo("ru-RU")); + + private sealed class CampaignTemplateEditModel + { + public string Name { get; set; } = ""; + public string Title { get; set; } = ""; + public string JoinLink { get; set; } = ""; + public int SessionCount { get; set; } = 6; + public int IntervalDays { get; set; } = 7; + public int? MaxPlayers { get; set; } + public string NotificationMode { get; set; } = SessionNotificationModeExtensions.GroupAndDirectValue; + } + + private sealed class CampaignTemplateManagementModel + { + public Guid Id { get; init; } + public string Name { get; init; } = ""; + public string Title { get; init; } = ""; + public string JoinLink { get; init; } = ""; + public int SessionCount { get; init; } + public int IntervalDays { get; init; } + public int? MaxPlayers { get; init; } + public string NotificationMode { get; init; } = SessionNotificationModeExtensions.GroupAndDirectValue; + public DateTime UpdatedAt { get; init; } + } +} diff --git a/src/GmRelay.Web/Components/Pages/GroupDetails.razor b/src/GmRelay.Web/Components/Pages/GroupDetails.razor index 836b84a..46d6ff6 100644 --- a/src/GmRelay.Web/Components/Pages/GroupDetails.razor +++ b/src/GmRelay.Web/Components/Pages/GroupDetails.razor @@ -90,53 +90,20 @@
-

Шаблоны кампаний

-

@campaignTemplateModels.Count сохранённых

+

Применить шаблон

+

@campaignTemplateModels.Count доступных для этой группы

- Template + ⚙️ Управлять шаблонами
- -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
+ @if (campaignTemplateModels.Count == 0) + { +
+
Шаблонов пока нет
+

Создайте шаблоны в отдельной вкладке, а здесь запускайте из них новые batch-расписания.

- - - - - @if (campaignTemplateModels.Count > 0) + } + else {
@foreach (var template in campaignTemplateModels) @@ -151,9 +118,6 @@ -
} @@ -347,15 +311,12 @@ private Guid? promotingSessionId; private Guid? processingBatchId; private Guid? processingTemplateId; - private Guid? deletingTemplateId; private long? removingCoGmId; private bool isAddingCoGm; - private bool isCreatingTemplate; private long telegramId; private string? errorMessage; private string? successMessage; private CoGmEditModel coGmModel = new(); - private CampaignTemplateEditModel campaignTemplateModel = new(); protected override async Task OnInitializedAsync() { @@ -588,51 +549,6 @@ } } - private async Task CreateCampaignTemplate() - { - errorMessage = null; - successMessage = null; - - if (!ValidateCampaignTemplate(campaignTemplateModel)) - { - errorMessage = "Шаблон должен иметь название, ссылку, 1-52 игр, шаг 1-365 дней и положительный лимит мест, если он указан."; - return; - } - - isCreatingTemplate = true; - - try - { - await SessionService.CreateCampaignTemplateForGmAsync( - GroupId, - telegramId, - new CreateCampaignTemplateRequest( - campaignTemplateModel.Name, - campaignTemplateModel.Title, - campaignTemplateModel.JoinLink, - campaignTemplateModel.SessionCount, - campaignTemplateModel.IntervalDays, - campaignTemplateModel.MaxPlayers, - SessionNotificationModeExtensions.FromDatabaseValue(campaignTemplateModel.NotificationMode))); - - campaignTemplateModel = new(); - successMessage = "Шаблон кампании сохранён."; - await LoadSessions(); - } - catch (SessionAccessDeniedException) - { - Navigation.NavigateTo("/access-denied"); - } - catch (Exception ex) - { - errorMessage = "Не удалось сохранить шаблон: " + ex.Message; - } - finally - { - isCreatingTemplate = false; - } - } - private async Task CreateBatchFromTemplate(CampaignTemplateUsageModel template) { errorMessage = null; @@ -666,32 +582,6 @@ } } - private async Task DeleteCampaignTemplate(CampaignTemplateUsageModel template) - { - errorMessage = null; - successMessage = null; - deletingTemplateId = template.Id; - - try - { - await SessionService.DeleteCampaignTemplateForGmAsync(template.Id, telegramId); - successMessage = "Шаблон кампании удалён."; - await LoadSessions(); - } - catch (SessionAccessDeniedException) - { - Navigation.NavigateTo("/access-denied"); - } - catch (Exception ex) - { - errorMessage = "Не удалось удалить шаблон: " + ex.Message; - } - finally - { - deletingTemplateId = null; - } - } - private void RebuildBatchModels() { batchModels = sessions? @@ -746,28 +636,9 @@ return batch.Title.Length > 0 && batch.JoinLink.Length > 0; } - private static bool ValidateCampaignTemplate(CampaignTemplateEditModel template) - { - template.Name = template.Name.Trim(); - template.Title = template.Title.Trim(); - template.JoinLink = template.JoinLink.Trim(); - - if (template.MaxPlayers.HasValue && template.MaxPlayers.Value <= 0) - { - return false; - } - - return template.Name.Length > 0 && - template.Title.Length > 0 && - template.JoinLink.Length > 0 && - template.SessionCount is >= 1 and <= 52 && - template.IntervalDays is >= 1 and <= 365; - } - private bool IsBatchBusy(BatchBulkEditModel batch) => processingBatchId == batch.BatchId; - private bool IsTemplateBusy(CampaignTemplateUsageModel template) => - processingTemplateId == template.Id || deletingTemplateId == template.Id; + private bool IsTemplateBusy(CampaignTemplateUsageModel template) => processingTemplateId == template.Id; private string CurrentUserRole => groupManagement?.Managers.FirstOrDefault(manager => manager.TelegramId == telegramId)?.Role @@ -864,17 +735,6 @@ public string CloneInterval { get; set; } = "week"; } - private sealed class CampaignTemplateEditModel - { - public string Name { get; set; } = ""; - public string Title { get; set; } = ""; - public string JoinLink { get; set; } = ""; - public int SessionCount { get; set; } = 6; - public int IntervalDays { get; set; } = 7; - public int? MaxPlayers { get; set; } - public string NotificationMode { get; set; } = SessionNotificationModeExtensions.GroupAndDirectValue; - } - private sealed class CampaignTemplateUsageModel { public Guid Id { get; init; } diff --git a/src/GmRelay.Web/wwwroot/app.css b/src/GmRelay.Web/wwwroot/app.css index 26e3ea5..a8e9c63 100644 --- a/src/GmRelay.Web/wwwroot/app.css +++ b/src/GmRelay.Web/wwwroot/app.css @@ -1,5 +1,5 @@ /* ============================================ - GM-Relay Design System v1.8.0 + GM-Relay Design System v1.8.1 Dark RPG Dashboard Theme ============================================ */ @@ -662,11 +662,34 @@ select option { .campaign-template-actions { display: grid; - grid-template-columns: minmax(190px, 1fr) auto auto; + grid-template-columns: minmax(190px, 1fr) auto; gap: 0.75rem; align-items: center; } +.template-group-selector { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 0.75rem; + align-items: center; +} + +.template-management-row { + grid-template-columns: minmax(0, 1fr) auto; +} + +.template-management-actions { + display: flex; + align-items: center; + gap: 0.75rem; + flex-wrap: wrap; + justify-content: flex-end; +} + +.empty-state-compact { + padding: 1.5rem 1rem; +} + /* === Animations === */ @keyframes fadeIn { from { opacity: 0; transform: translateY(8px); } @@ -890,7 +913,8 @@ select option { .batch-clone-row, .campaign-template-fields, .campaign-template-row, - .campaign-template-actions { + .campaign-template-actions, + .template-group-selector { grid-template-columns: 1fr; } diff --git a/tests/GmRelay.Bot.Tests/Web/CampaignTemplatesNavigationTests.cs b/tests/GmRelay.Bot.Tests/Web/CampaignTemplatesNavigationTests.cs new file mode 100644 index 0000000..653c2b9 --- /dev/null +++ b/tests/GmRelay.Bot.Tests/Web/CampaignTemplatesNavigationTests.cs @@ -0,0 +1,50 @@ +namespace GmRelay.Bot.Tests.Web; + +public sealed class CampaignTemplatesNavigationTests +{ + [Fact] + public async Task NavMenu_ShouldExposeTemplatesTab() + { + var navMenu = await File.ReadAllTextAsync(FindRepositoryFile("src/GmRelay.Web/Components/Layout/NavMenu.razor")); + + Assert.Contains("href=\"templates\"", navMenu, StringComparison.Ordinal); + Assert.Contains("Шаблоны", navMenu, StringComparison.Ordinal); + } + + [Fact] + public async Task GroupDetails_ShouldApplyTemplatesWithoutManagingThem() + { + var groupDetails = await File.ReadAllTextAsync(FindRepositoryFile("src/GmRelay.Web/Components/Pages/GroupDetails.razor")); + + Assert.Contains("CreateBatchFromTemplate", groupDetails, StringComparison.Ordinal); + Assert.DoesNotContain("OnValidSubmit=\"CreateCampaignTemplate\"", groupDetails, StringComparison.Ordinal); + Assert.DoesNotContain("DeleteCampaignTemplate", groupDetails, StringComparison.Ordinal); + } + + [Fact] + public async Task CampaignTemplatesPage_ShouldOwnTemplateManagement() + { + var templatesPage = await File.ReadAllTextAsync(FindRepositoryFile("src/GmRelay.Web/Components/Pages/CampaignTemplates.razor")); + + Assert.Contains("@page \"/templates\"", templatesPage, StringComparison.Ordinal); + Assert.Contains("OnValidSubmit=\"CreateCampaignTemplate\"", templatesPage, StringComparison.Ordinal); + Assert.Contains("DeleteCampaignTemplate", templatesPage, StringComparison.Ordinal); + } + + private static string FindRepositoryFile(string relativePath) + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null) + { + var candidate = Path.Combine(directory.FullName, relativePath); + if (File.Exists(candidate)) + { + return candidate; + } + + directory = directory.Parent; + } + + throw new FileNotFoundException($"Could not locate repository file '{relativePath}'."); + } +}