Add instructions management functionality and update related components
CI / Backend (build + test) (push) Successful in 1m17s
CI / Frontend (lint + typecheck + build) (push) Successful in 32s

- 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:
Leonid Pershin
2026-07-14 22:20:10 +03:00
parent 8c53fcded2
commit bef3880593
52 changed files with 2303 additions and 24 deletions
@@ -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>>;
@@ -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)));
}
}
@@ -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>;
@@ -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>>;
@@ -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));
}
}
@@ -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>>;
@@ -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));
}
}
@@ -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);
}
}