Add instructions management functionality and update related components
- Introduced new endpoints for managing instruction intros and tabs, allowing admins to create, update, and delete instructional content. - Enhanced the FactoryResetCommandHandler to include the seeding of instruction data during a factory reset. - Updated the database schema to include InstructionIntro and InstructionTab entities, with corresponding migrations. - Improved frontend routing and components to support the new instructions section, including a dedicated page for displaying instructions and tabs. - Enhanced API documentation to reflect the new instruction management features and their expected request/response formats. - Added localization support for the new instructions functionality in both Russian and English.
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
using PnvPanel.Application.Common.Messaging;
|
||||
using PnvPanel.Application.Common.Models;
|
||||
using PnvPanel.Application.Instructions;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Instructions;
|
||||
|
||||
public sealed record CreateInstructionTabCommand(string Title, string Body, int SortOrder)
|
||||
: ICommand<Result<InstructionTabDto>>;
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
using PnvPanel.Application.Common.Interfaces;
|
||||
using PnvPanel.Application.Common.Messaging;
|
||||
using PnvPanel.Application.Common.Models;
|
||||
using PnvPanel.Application.Instructions;
|
||||
using PnvPanel.Domain.Instructions;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Instructions;
|
||||
|
||||
public sealed class CreateInstructionTabCommandHandler(IAppDbContext dbContext)
|
||||
: ICommandHandler<CreateInstructionTabCommand, Result<InstructionTabDto>>
|
||||
{
|
||||
public Task<Result<InstructionTabDto>> Handle(
|
||||
CreateInstructionTabCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var tab = InstructionTab.Create(command.Title, command.Body, command.SortOrder);
|
||||
dbContext.InstructionTabs.Add(tab);
|
||||
|
||||
return Task.FromResult(Result.Success(InstructionTabDto.FromDomain(tab)));
|
||||
}
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Instructions;
|
||||
|
||||
public sealed class CreateInstructionTabCommandValidator
|
||||
: AbstractValidator<CreateInstructionTabCommand>
|
||||
{
|
||||
public CreateInstructionTabCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.Title).NotEmpty().MaximumLength(100);
|
||||
RuleFor(x => x.Body).NotEmpty().MaximumLength(20000);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
using PnvPanel.Application.Common.Messaging;
|
||||
using PnvPanel.Application.Common.Models;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Instructions;
|
||||
|
||||
public sealed record DeleteInstructionTabCommand(Guid TabId) : ICommand<Result>;
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PnvPanel.Application.Common.Interfaces;
|
||||
using PnvPanel.Application.Common.Messaging;
|
||||
using PnvPanel.Application.Common.Models;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Instructions;
|
||||
|
||||
public sealed class DeleteInstructionTabCommandHandler(IAppDbContext dbContext)
|
||||
: ICommandHandler<DeleteInstructionTabCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(
|
||||
DeleteInstructionTabCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var tab = await dbContext.InstructionTabs.FirstOrDefaultAsync(
|
||||
t => t.Id == command.TabId,
|
||||
cancellationToken
|
||||
);
|
||||
if (tab is null)
|
||||
return Result.Failure(InstructionErrors.TabNotFound);
|
||||
|
||||
dbContext.InstructionTabs.Remove(tab);
|
||||
return Result.Success();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using PnvPanel.Application.Common.Models;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Instructions;
|
||||
|
||||
public static class InstructionErrors
|
||||
{
|
||||
public static readonly Error TabNotFound = Error.NotFound(
|
||||
"Instructions.TabNotFound",
|
||||
"Вкладка инструкций не найдена."
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using PnvPanel.Application.Common.Messaging;
|
||||
using PnvPanel.Application.Common.Models;
|
||||
using PnvPanel.Application.Instructions;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Instructions;
|
||||
|
||||
public sealed record UpdateInstructionIntroCommand(string Body)
|
||||
: ICommand<Result<InstructionIntroDto>>;
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PnvPanel.Application.Common.Interfaces;
|
||||
using PnvPanel.Application.Common.Messaging;
|
||||
using PnvPanel.Application.Common.Models;
|
||||
using PnvPanel.Application.Instructions;
|
||||
using PnvPanel.Domain.Instructions;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Instructions;
|
||||
|
||||
public sealed class UpdateInstructionIntroCommandHandler(IAppDbContext dbContext)
|
||||
: ICommandHandler<UpdateInstructionIntroCommand, Result<InstructionIntroDto>>
|
||||
{
|
||||
public async Task<Result<InstructionIntroDto>> Handle(
|
||||
UpdateInstructionIntroCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var intro = await dbContext.InstructionIntros.FirstOrDefaultAsync(cancellationToken);
|
||||
if (intro is null)
|
||||
{
|
||||
intro = InstructionIntro.Create(command.Body);
|
||||
dbContext.InstructionIntros.Add(intro);
|
||||
}
|
||||
else
|
||||
{
|
||||
intro.Update(command.Body);
|
||||
}
|
||||
|
||||
return Result.Success(InstructionIntroDto.FromDomain(intro));
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Instructions;
|
||||
|
||||
public sealed class UpdateInstructionIntroCommandValidator
|
||||
: AbstractValidator<UpdateInstructionIntroCommand>
|
||||
{
|
||||
public UpdateInstructionIntroCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.Body).NotEmpty().MaximumLength(20000);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using PnvPanel.Application.Common.Messaging;
|
||||
using PnvPanel.Application.Common.Models;
|
||||
using PnvPanel.Application.Instructions;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Instructions;
|
||||
|
||||
public sealed record UpdateInstructionTabCommand(
|
||||
Guid TabId,
|
||||
string Title,
|
||||
string Body,
|
||||
int SortOrder
|
||||
) : ICommand<Result<InstructionTabDto>>;
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PnvPanel.Application.Common.Interfaces;
|
||||
using PnvPanel.Application.Common.Messaging;
|
||||
using PnvPanel.Application.Common.Models;
|
||||
using PnvPanel.Application.Instructions;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Instructions;
|
||||
|
||||
public sealed class UpdateInstructionTabCommandHandler(IAppDbContext dbContext)
|
||||
: ICommandHandler<UpdateInstructionTabCommand, Result<InstructionTabDto>>
|
||||
{
|
||||
public async Task<Result<InstructionTabDto>> Handle(
|
||||
UpdateInstructionTabCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var tab = await dbContext.InstructionTabs.FirstOrDefaultAsync(
|
||||
t => t.Id == command.TabId,
|
||||
cancellationToken
|
||||
);
|
||||
if (tab is null)
|
||||
return Result.Failure<InstructionTabDto>(InstructionErrors.TabNotFound);
|
||||
|
||||
tab.Update(command.Title, command.Body, command.SortOrder);
|
||||
|
||||
return Result.Success(InstructionTabDto.FromDomain(tab));
|
||||
}
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Instructions;
|
||||
|
||||
public sealed class UpdateInstructionTabCommandValidator
|
||||
: AbstractValidator<UpdateInstructionTabCommand>
|
||||
{
|
||||
public UpdateInstructionTabCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.Title).NotEmpty().MaximumLength(100);
|
||||
RuleFor(x => x.Body).NotEmpty().MaximumLength(20000);
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ public sealed class FactoryResetCommandHandler(
|
||||
IXuiPanelGateway gateway,
|
||||
IFileStorage fileStorage,
|
||||
IClientAppCatalogSeeder catalogSeeder,
|
||||
IInstructionIntroSeeder instructionIntroSeeder,
|
||||
ICurrentUser currentUser
|
||||
) : ICommandHandler<FactoryResetCommand, Result>
|
||||
{
|
||||
@@ -44,8 +45,9 @@ public sealed class FactoryResetCommandHandler(
|
||||
foreach (var role in roles.Where(r => !r.IsSystem))
|
||||
await roleService.DeleteRoleAsync(role.Id, cancellationToken);
|
||||
|
||||
// ClientApps уже пуста (удалена в WipeApplicationData + сохранена выше) — пересеиваем.
|
||||
// ClientApps/InstructionIntros уже пусты (удалены в WipeApplicationData + сохранено выше) — пересеиваем.
|
||||
await catalogSeeder.SeedIfEmptyAsync(cancellationToken);
|
||||
await instructionIntroSeeder.SeedIfEmptyAsync(cancellationToken);
|
||||
|
||||
// Финальная запись — уже после очистки самого журнала, чтобы отметить факт сброса.
|
||||
dbContext.AuditLogs.Add(
|
||||
@@ -116,6 +118,8 @@ public sealed class FactoryResetCommandHandler(
|
||||
dbContext.TelegramLinkTokens.RemoveRange(dbContext.TelegramLinkTokens);
|
||||
dbContext.NewsPosts.RemoveRange(dbContext.NewsPosts);
|
||||
dbContext.ClientApps.RemoveRange(dbContext.ClientApps);
|
||||
dbContext.InstructionIntros.RemoveRange(dbContext.InstructionIntros);
|
||||
dbContext.InstructionTabs.RemoveRange(dbContext.InstructionTabs);
|
||||
dbContext.AuditLogs.RemoveRange(dbContext.AuditLogs);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ using PnvPanel.Domain.Apps;
|
||||
using PnvPanel.Domain.Audit;
|
||||
using PnvPanel.Domain.Configs;
|
||||
using PnvPanel.Domain.Inbounds;
|
||||
using PnvPanel.Domain.Instructions;
|
||||
using PnvPanel.Domain.News;
|
||||
using PnvPanel.Domain.Nodes;
|
||||
using PnvPanel.Domain.Support;
|
||||
@@ -40,6 +41,10 @@ public interface IAppDbContext
|
||||
|
||||
DbSet<TicketAttachment> TicketAttachments { get; }
|
||||
|
||||
DbSet<InstructionIntro> InstructionIntros { get; }
|
||||
|
||||
DbSet<InstructionTab> InstructionTabs { get; }
|
||||
|
||||
/// <summary>Нужен для advisory-lock при проверке квоты конфигов (см. CreateVpnConfigCommandHandler).</summary>
|
||||
DatabaseFacade Database { get; }
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace PnvPanel.Application.Common.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Сидинг дефолтного вводного текста страницы инструкций. Идемпотентно — не трогает таблицу, если
|
||||
/// в ней уже есть строка (используется и при старте, и после полного сброса панели).
|
||||
/// </summary>
|
||||
public interface IInstructionIntroSeeder
|
||||
{
|
||||
Task SeedIfEmptyAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using PnvPanel.Application.Common.Messaging;
|
||||
using PnvPanel.Application.Common.Models;
|
||||
|
||||
namespace PnvPanel.Application.Instructions;
|
||||
|
||||
public sealed record GetInstructionIntroQuery
|
||||
: IQuery<Result<InstructionIntroDto>>,
|
||||
IRequiresActivation;
|
||||
@@ -0,0 +1,27 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PnvPanel.Application.Common.Interfaces;
|
||||
using PnvPanel.Application.Common.Messaging;
|
||||
using PnvPanel.Application.Common.Models;
|
||||
|
||||
namespace PnvPanel.Application.Instructions;
|
||||
|
||||
public sealed class GetInstructionIntroQueryHandler(IAppDbContext dbContext)
|
||||
: IQueryHandler<GetInstructionIntroQuery, Result<InstructionIntroDto>>
|
||||
{
|
||||
public async Task<Result<InstructionIntroDto>> Handle(
|
||||
GetInstructionIntroQuery query,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var intro = await dbContext
|
||||
.InstructionIntros.AsNoTracking()
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
// Ещё не сидировано/не сохранено ни разу — пустой текст, а не ошибка (страница не должна падать).
|
||||
return Result.Success(
|
||||
intro is null
|
||||
? new InstructionIntroDto(Guid.Empty, string.Empty, DateTimeOffset.MinValue)
|
||||
: InstructionIntroDto.FromDomain(intro)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using PnvPanel.Domain.Instructions;
|
||||
|
||||
namespace PnvPanel.Application.Instructions;
|
||||
|
||||
public sealed record InstructionIntroDto(Guid Id, string Body, DateTimeOffset UpdatedAt)
|
||||
{
|
||||
public static InstructionIntroDto FromDomain(InstructionIntro intro) =>
|
||||
new(intro.Id, intro.Body, intro.UpdatedAt);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using PnvPanel.Domain.Instructions;
|
||||
|
||||
namespace PnvPanel.Application.Instructions;
|
||||
|
||||
/// <summary>Один DTO на пользовательскую страницу и админку — у вкладки нет полей, скрытых от юзера
|
||||
/// (нет статуса черновик/опубликовано, см. InstructionTab).</summary>
|
||||
public sealed record InstructionTabDto(
|
||||
Guid Id,
|
||||
string Title,
|
||||
string Body,
|
||||
int SortOrder,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset? UpdatedAt
|
||||
)
|
||||
{
|
||||
public static InstructionTabDto FromDomain(InstructionTab tab) =>
|
||||
new(tab.Id, tab.Title, tab.Body, tab.SortOrder, tab.CreatedAt, tab.UpdatedAt);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using PnvPanel.Application.Common.Messaging;
|
||||
using PnvPanel.Application.Common.Models;
|
||||
|
||||
namespace PnvPanel.Application.Instructions;
|
||||
|
||||
public sealed record ListInstructionTabsQuery
|
||||
: IQuery<Result<IReadOnlyList<InstructionTabDto>>>,
|
||||
IRequiresActivation;
|
||||
@@ -0,0 +1,31 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PnvPanel.Application.Common.Interfaces;
|
||||
using PnvPanel.Application.Common.Messaging;
|
||||
using PnvPanel.Application.Common.Models;
|
||||
|
||||
namespace PnvPanel.Application.Instructions;
|
||||
|
||||
public sealed class ListInstructionTabsQueryHandler(IAppDbContext dbContext)
|
||||
: IQueryHandler<ListInstructionTabsQuery, Result<IReadOnlyList<InstructionTabDto>>>
|
||||
{
|
||||
public async Task<Result<IReadOnlyList<InstructionTabDto>>> Handle(
|
||||
ListInstructionTabsQuery query,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var tabs = await dbContext
|
||||
.InstructionTabs.AsNoTracking()
|
||||
.OrderBy(t => t.SortOrder)
|
||||
.Select(t => new InstructionTabDto(
|
||||
t.Id,
|
||||
t.Title,
|
||||
t.Body,
|
||||
t.SortOrder,
|
||||
t.CreatedAt,
|
||||
t.UpdatedAt
|
||||
))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return Result.Success<IReadOnlyList<InstructionTabDto>>(tabs);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user