Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5082dd4fcf | |||
| cfbda4ca05 |
@@ -6,7 +6,7 @@ on:
|
||||
- main
|
||||
|
||||
env:
|
||||
VERSION: 1.8.0
|
||||
VERSION: 1.8.2
|
||||
|
||||
jobs:
|
||||
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<Version>1.8.0</Version>
|
||||
<Version>1.8.2</Version>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
Проект разработан с упором на производительность, архитектуру Vertical Slice, Native AOT (для бота) и удобство развертывания с использованием .NET Aspire.
|
||||
|
||||
**Текущая версия:** `v1.8.0`.
|
||||
**Текущая версия:** `v1.8.2`.
|
||||
|
||||
---
|
||||
|
||||
@@ -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;
|
||||
- выбрать режим уведомлений: дублировать важные сообщения игрокам в личку или оставить только групповые уведомления;
|
||||
|
||||
+2
-2
@@ -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.2
|
||||
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.2
|
||||
restart: always
|
||||
depends_on:
|
||||
db:
|
||||
|
||||
@@ -25,6 +25,15 @@
|
||||
</svg>
|
||||
Панель управления
|
||||
</NavLink>
|
||||
<NavLink class="nav-item" href="templates" @onclick="CloseMenu">
|
||||
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="4" width="18" height="16" rx="2"/>
|
||||
<path d="M7 8h10"/>
|
||||
<path d="M7 12h6"/>
|
||||
<path d="M7 16h8"/>
|
||||
</svg>
|
||||
Шаблоны
|
||||
</NavLink>
|
||||
</div>
|
||||
|
||||
<div class="nav-footer">
|
||||
@@ -47,7 +56,7 @@
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="nav-version">v1.3.0</div>
|
||||
<div class="nav-version">v1.8.2</div>
|
||||
</div>
|
||||
</Authorized>
|
||||
<NotAuthorized>
|
||||
|
||||
@@ -56,13 +56,17 @@
|
||||
.nav-section {
|
||||
padding: 0 0.75rem;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
/* === Nav Items === */
|
||||
.nav-item {
|
||||
.nav-section ::deep .nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
width: 100%;
|
||||
padding: 0.625rem 0.875rem;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-secondary);
|
||||
@@ -70,16 +74,16 @@
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
transition: all var(--transition-normal);
|
||||
margin-bottom: 0.125rem;
|
||||
white-space: nowrap;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
.nav-section ::deep .nav-item:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.nav-item.active,
|
||||
.nav-item ::deep a.active {
|
||||
.nav-section ::deep .nav-item.active {
|
||||
background: rgba(124, 58, 237, 0.15);
|
||||
color: var(--accent-primary);
|
||||
border: 1px solid rgba(124, 58, 237, 0.2);
|
||||
|
||||
@@ -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
|
||||
|
||||
<PageTitle>Шаблоны кампаний — GM-Relay</PageTitle>
|
||||
|
||||
<div class="page-container">
|
||||
<ul class="gm-breadcrumb animate-fade-in">
|
||||
<li><a href="/">Главная</a></li>
|
||||
<li class="active">Шаблоны кампаний</li>
|
||||
</ul>
|
||||
|
||||
<div class="page-header animate-fade-in">
|
||||
<h2>📋 Шаблоны кампаний</h2>
|
||||
<p>Сохраняйте типовые кампании один раз и применяйте их на странице нужной группы.</p>
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrEmpty(errorMessage))
|
||||
{
|
||||
<div class="gm-alert gm-alert-danger" style="margin-bottom: 1rem;">
|
||||
⚠️ @errorMessage
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrEmpty(successMessage))
|
||||
{
|
||||
<div class="gm-alert gm-alert-success" style="margin-bottom: 1rem;">
|
||||
✅ @successMessage
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (groups is null)
|
||||
{
|
||||
<div class="glass-card" style="padding: 2rem;">
|
||||
<div class="skeleton skeleton-text" style="width: 80%; margin-bottom: 1rem;"></div>
|
||||
<div class="skeleton skeleton-text" style="width: 60%; margin-bottom: 0.75rem;"></div>
|
||||
<div class="skeleton skeleton-text" style="width: 70%;"></div>
|
||||
</div>
|
||||
}
|
||||
else if (groups.Count == 0)
|
||||
{
|
||||
<div class="glass-card">
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-icon">🤖</div>
|
||||
<div class="empty-state-title">Нет доступных групп</div>
|
||||
<p class="empty-state-text">Добавьте бота GM-Relay в группу Telegram, чтобы создать первый шаблон кампании.</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="glass-card campaign-template-panel animate-slide-up">
|
||||
<div class="batch-bulk-header">
|
||||
<div>
|
||||
<h3>Группа для шаблонов</h3>
|
||||
<p>@(SelectedGroup?.Name ?? "Выберите группу")</p>
|
||||
</div>
|
||||
@if (SelectedGroup is not null)
|
||||
{
|
||||
<span class="status-badge @(SelectedGroup.ManagerRole == GroupManagerRoleExtensions.OwnerValue ? "status-success" : "status-info")">
|
||||
@FormatRole(SelectedGroup.ManagerRole)
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="template-group-selector">
|
||||
<select value="@selectedGroupId" @onchange="OnSelectedGroupChanged" class="gm-form-control">
|
||||
@foreach (var group in groups)
|
||||
{
|
||||
<option value="@group.Id">@group.Name</option>
|
||||
}
|
||||
</select>
|
||||
@if (SelectedGroup is not null)
|
||||
{
|
||||
<a href="/group/@SelectedGroup.Id" class="btn-gm btn-gm-outline">Открыть группу →</a>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="glass-card campaign-template-panel animate-slide-up">
|
||||
<div class="batch-bulk-header">
|
||||
<div>
|
||||
<h3>Новый шаблон</h3>
|
||||
<p>Эти параметры будут использоваться при запуске batch из группы.</p>
|
||||
</div>
|
||||
<span class="status-badge status-info">Template</span>
|
||||
</div>
|
||||
|
||||
<EditForm Model="@templateModel" OnValidSubmit="CreateCampaignTemplate">
|
||||
<div class="campaign-template-fields">
|
||||
<div class="gm-form-group">
|
||||
<label class="gm-form-label">Название шаблона</label>
|
||||
<InputText @bind-Value="templateModel.Name" class="gm-form-control" />
|
||||
</div>
|
||||
<div class="gm-form-group">
|
||||
<label class="gm-form-label">Название кампании</label>
|
||||
<InputText @bind-Value="templateModel.Title" class="gm-form-control" />
|
||||
</div>
|
||||
<div class="gm-form-group">
|
||||
<label class="gm-form-label">Ссылка</label>
|
||||
<InputText @bind-Value="templateModel.JoinLink" class="gm-form-control" />
|
||||
</div>
|
||||
<div class="gm-form-group">
|
||||
<label class="gm-form-label">Игр</label>
|
||||
<InputNumber @bind-Value="templateModel.SessionCount" class="gm-form-control" min="1" max="52" />
|
||||
</div>
|
||||
<div class="gm-form-group">
|
||||
<label class="gm-form-label">Интервал, дней</label>
|
||||
<InputNumber @bind-Value="templateModel.IntervalDays" class="gm-form-control" min="1" max="365" />
|
||||
</div>
|
||||
<div class="gm-form-group">
|
||||
<label class="gm-form-label">Мест</label>
|
||||
<InputNumber @bind-Value="templateModel.MaxPlayers" class="gm-form-control" min="1" />
|
||||
</div>
|
||||
<div class="gm-form-group">
|
||||
<label class="gm-form-label">Уведомления</label>
|
||||
<select @bind="templateModel.NotificationMode" class="gm-form-control">
|
||||
<option value="@SessionNotificationModeExtensions.GroupAndDirectValue">В группе и в личку</option>
|
||||
<option value="@SessionNotificationModeExtensions.GroupOnlyValue">Только в группе</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn-gm btn-gm-primary" disabled="@isCreatingTemplate">
|
||||
@(isCreatingTemplate ? "⏳ Сохраняем..." : "💾 Сохранить шаблон")
|
||||
</button>
|
||||
</EditForm>
|
||||
</div>
|
||||
|
||||
<div class="glass-card campaign-template-panel animate-slide-up">
|
||||
<div class="batch-bulk-header">
|
||||
<div>
|
||||
<h3>Сохранённые шаблоны</h3>
|
||||
<p>@campaignTemplateModels.Count для выбранной группы</p>
|
||||
</div>
|
||||
<span class="status-badge status-info">@campaignTemplateModels.Count</span>
|
||||
</div>
|
||||
|
||||
@if (campaignTemplates is null)
|
||||
{
|
||||
<div class="skeleton skeleton-text" style="width: 70%; margin-bottom: 0.75rem;"></div>
|
||||
<div class="skeleton skeleton-text" style="width: 55%;"></div>
|
||||
}
|
||||
else if (campaignTemplateModels.Count == 0)
|
||||
{
|
||||
<div class="empty-state empty-state-compact">
|
||||
<div class="empty-state-title">Шаблонов пока нет</div>
|
||||
<p class="empty-state-text">Создайте первый шаблон для выбранной группы.</p>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="campaign-template-list">
|
||||
@foreach (var template in campaignTemplateModels)
|
||||
{
|
||||
<div class="campaign-template-row template-management-row">
|
||||
<div class="campaign-template-info">
|
||||
<h3>@template.Name</h3>
|
||||
<p>@FormatTemplateSummary(template)</p>
|
||||
</div>
|
||||
<div class="template-management-actions">
|
||||
<span class="status-badge status-neutral">@FormatLocalMoscow(template.UpdatedAt.ToMoscow())</span>
|
||||
<button type="button" class="btn-gm btn-gm-danger" disabled="@(deletingTemplateId == template.Id)" @onclick="() => DeleteCampaignTemplate(template)">
|
||||
@(deletingTemplateId == template.Id ? "⏳ Удаляем..." : "Удалить")
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private List<WebGameGroup>? groups;
|
||||
private List<WebCampaignTemplate>? campaignTemplates;
|
||||
private List<CampaignTemplateManagementModel> 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; }
|
||||
}
|
||||
}
|
||||
@@ -90,53 +90,20 @@
|
||||
<div class="glass-card campaign-template-panel animate-slide-up">
|
||||
<div class="batch-bulk-header">
|
||||
<div>
|
||||
<h3>Шаблоны кампаний</h3>
|
||||
<p>@campaignTemplateModels.Count сохранённых</p>
|
||||
<h3>Применить шаблон</h3>
|
||||
<p>@campaignTemplateModels.Count доступных для этой группы</p>
|
||||
</div>
|
||||
<span class="status-badge status-info">Template</span>
|
||||
<a href="/templates" class="btn-gm btn-gm-outline">⚙️ Управлять шаблонами</a>
|
||||
</div>
|
||||
|
||||
<EditForm Model="@campaignTemplateModel" OnValidSubmit="CreateCampaignTemplate">
|
||||
<div class="campaign-template-fields">
|
||||
<div class="gm-form-group">
|
||||
<label class="gm-form-label">Название шаблона</label>
|
||||
<InputText @bind-Value="campaignTemplateModel.Name" class="gm-form-control" />
|
||||
@if (campaignTemplateModels.Count == 0)
|
||||
{
|
||||
<div class="empty-state empty-state-compact">
|
||||
<div class="empty-state-title">Шаблонов пока нет</div>
|
||||
<p class="empty-state-text">Создайте шаблоны в отдельной вкладке, а здесь запускайте из них новые batch-расписания.</p>
|
||||
</div>
|
||||
<div class="gm-form-group">
|
||||
<label class="gm-form-label">Название кампании</label>
|
||||
<InputText @bind-Value="campaignTemplateModel.Title" class="gm-form-control" />
|
||||
</div>
|
||||
<div class="gm-form-group">
|
||||
<label class="gm-form-label">Ссылка</label>
|
||||
<InputText @bind-Value="campaignTemplateModel.JoinLink" class="gm-form-control" />
|
||||
</div>
|
||||
<div class="gm-form-group">
|
||||
<label class="gm-form-label">Игр</label>
|
||||
<InputNumber @bind-Value="campaignTemplateModel.SessionCount" class="gm-form-control" min="1" max="52" />
|
||||
</div>
|
||||
<div class="gm-form-group">
|
||||
<label class="gm-form-label">Интервал, дней</label>
|
||||
<InputNumber @bind-Value="campaignTemplateModel.IntervalDays" class="gm-form-control" min="1" max="365" />
|
||||
</div>
|
||||
<div class="gm-form-group">
|
||||
<label class="gm-form-label">Мест</label>
|
||||
<InputNumber @bind-Value="campaignTemplateModel.MaxPlayers" class="gm-form-control" min="1" />
|
||||
</div>
|
||||
<div class="gm-form-group">
|
||||
<label class="gm-form-label">Уведомления</label>
|
||||
<select @bind="campaignTemplateModel.NotificationMode" class="gm-form-control">
|
||||
<option value="@SessionNotificationModeExtensions.GroupAndDirectValue">В группе и в личку</option>
|
||||
<option value="@SessionNotificationModeExtensions.GroupOnlyValue">Только в группе</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn-gm btn-gm-primary" disabled="@isCreatingTemplate">
|
||||
@(isCreatingTemplate ? "⏳ Сохраняем..." : "💾 Сохранить шаблон")
|
||||
</button>
|
||||
</EditForm>
|
||||
|
||||
@if (campaignTemplateModels.Count > 0)
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="campaign-template-list">
|
||||
@foreach (var template in campaignTemplateModels)
|
||||
@@ -151,9 +118,6 @@
|
||||
<button type="button" class="btn-gm btn-gm-success" disabled="@IsTemplateBusy(template)" @onclick="() => CreateBatchFromTemplate(template)">
|
||||
@(processingTemplateId == template.Id ? "⏳ Создаём..." : "📅 Создать batch")
|
||||
</button>
|
||||
<button type="button" class="btn-gm btn-gm-danger" disabled="@IsTemplateBusy(template)" @onclick="() => DeleteCampaignTemplate(template)">
|
||||
@(deletingTemplateId == template.Id ? "⏳ Удаляем..." : "Удалить")
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* ============================================
|
||||
GM-Relay Design System v1.8.0
|
||||
GM-Relay Design System v1.8.2
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
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 NavMenuStyles_ShouldStyleNavLinkAnchorsAsStackedRows()
|
||||
{
|
||||
var navCss = await File.ReadAllTextAsync(FindRepositoryFile("src/GmRelay.Web/Components/Layout/NavMenu.razor.css"));
|
||||
|
||||
Assert.Contains("::deep .nav-item", navCss, StringComparison.Ordinal);
|
||||
Assert.Matches(
|
||||
@"\.nav-section\s*\{[^}]*display:\s*flex;[^}]*flex-direction:\s*column;[^}]*gap:\s*0\.25rem;",
|
||||
navCss);
|
||||
Assert.Matches(
|
||||
@"::deep\s+\.nav-item\s*\{[^}]*display:\s*flex;[^}]*width:\s*100%;",
|
||||
navCss);
|
||||
}
|
||||
|
||||
[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}'.");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user