Add news feature with CRUD operations and real-time notifications
CI / Backend (build + test) (push) Successful in 1m18s
CI / Frontend (lint + typecheck + build) (push) Successful in 32s

- Implemented news management functionality, allowing admins to create, read, update, and delete news posts.
- Introduced a new SignalR event for broadcasting news updates to all connected clients.
- Updated API documentation to include new endpoints for news management.
- Enhanced frontend with a dedicated news page and admin interface for managing news posts.
- Added necessary localization for news-related terms in both Russian and English.
This commit is contained in:
Leonid Pershin
2026-07-03 15:28:33 +03:00
parent bea2b5fcf7
commit b6637a1c03
47 changed files with 2523 additions and 9 deletions
@@ -0,0 +1,52 @@
using PnvPanel.Api.Common;
using PnvPanel.Application.Admin.News;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
using PnvPanel.Application.News;
using PnvPanel.Infrastructure.Identity;
namespace PnvPanel.Api.Endpoints;
public static class AdminNewsEndpoints
{
public static IEndpointRouteBuilder MapAdminNewsEndpoints(this IEndpointRouteBuilder app)
{
var admin = app.MapGroup("/api/admin/news")
.WithTags("Admin.News")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin.MapGet("", ListAdminNews).Produces<PagedList<NewsPostDto>>();
admin.MapPost("", CreatePost).Produces<NewsPostDto>();
admin.MapPut("/{id:guid}", UpdatePost).Produces<NewsPostDto>();
admin.MapDelete("/{id:guid}", DeletePost).Produces(StatusCodes.Status204NoContent);
return app;
}
private static async Task<IResult> ListAdminNews(int page, int pageSize, ISender sender, CancellationToken cancellationToken)
{
var result = await sender.Send(new ListAdminNewsQuery(page, pageSize), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> CreatePost(CreatePostCommand command, ISender sender, CancellationToken cancellationToken)
{
var result = await sender.Send(command, cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> UpdatePost(Guid id, UpdatePostBody body, ISender sender, CancellationToken cancellationToken)
{
var command = new UpdatePostCommand(id, body.Title, body.Body);
var result = await sender.Send(command, cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> DeletePost(Guid id, ISender sender, CancellationToken cancellationToken)
{
var result = await sender.Send(new DeletePostCommand(id), cancellationToken);
return result.ToHttpResult();
}
}
public sealed record UpdatePostBody(string Title, string Body);
@@ -0,0 +1,24 @@
using PnvPanel.Api.Common;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
using PnvPanel.Application.News;
namespace PnvPanel.Api.Endpoints;
public static class NewsEndpoints
{
public static IEndpointRouteBuilder MapNewsEndpoints(this IEndpointRouteBuilder app)
{
app.MapGet("/api/news", ListNews)
.WithTags("News")
.RequireAuthorization()
.Produces<PagedList<NewsPostDto>>();
return app;
}
private static async Task<IResult> ListNews(int page, int pageSize, ISender sender, CancellationToken cancellationToken)
{
var result = await sender.Send(new ListNewsQuery(page, pageSize), cancellationToken);
return result.ToHttpResult();
}
}
@@ -48,4 +48,12 @@ internal sealed class SignalRRealtimeNotifier(IHubContext<PanelHub> hubContext)
new { userId }, new { userId },
cancellationToken); cancellationToken);
} }
public Task NotifyNewsPublishedAsync(Guid postId, string title, DateTimeOffset createdAt, CancellationToken cancellationToken)
{
return hubContext.Clients.All.SendAsync(
"newsPublished",
new { id = postId, title, createdAt },
cancellationToken);
}
} }
+2
View File
@@ -151,9 +151,11 @@ app.MapInboundEndpoints();
app.MapConfigEndpoints(); app.MapConfigEndpoints();
app.MapSubscriptionEndpoints(); app.MapSubscriptionEndpoints();
app.MapAppEndpoints(); app.MapAppEndpoints();
app.MapNewsEndpoints();
app.MapAdminUserEndpoints(); app.MapAdminUserEndpoints();
app.MapAdminStatsEndpoints(); app.MapAdminStatsEndpoints();
app.MapAdminAppEndpoints(); app.MapAdminAppEndpoints();
app.MapAdminNewsEndpoints();
app.MapTelegramEndpoints(); app.MapTelegramEndpoints();
app.MapHub<PanelHub>("/hubs/panel"); app.MapHub<PanelHub>("/hubs/panel");
@@ -0,0 +1,7 @@
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
using PnvPanel.Application.News;
namespace PnvPanel.Application.Admin.News;
public sealed record CreatePostCommand(string Title, string Body) : ICommand<Result<NewsPostDto>>;
@@ -0,0 +1,21 @@
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
using PnvPanel.Application.News;
using PnvPanel.Domain.News;
namespace PnvPanel.Application.Admin.News;
public sealed class CreatePostCommandHandler(IAppDbContext dbContext, IRealtimeNotifier notifier)
: ICommandHandler<CreatePostCommand, Result<NewsPostDto>>
{
public async Task<Result<NewsPostDto>> Handle(CreatePostCommand command, CancellationToken cancellationToken)
{
var post = NewsPost.Create(command.Title, command.Body);
dbContext.NewsPosts.Add(post);
await notifier.NotifyNewsPublishedAsync(post.Id, post.Title, post.CreatedAt, cancellationToken);
return Result.Success(NewsPostDto.FromDomain(post));
}
}
@@ -0,0 +1,12 @@
using FluentValidation;
namespace PnvPanel.Application.Admin.News;
public sealed class CreatePostCommandValidator : AbstractValidator<CreatePostCommand>
{
public CreatePostCommandValidator()
{
RuleFor(x => x.Title).NotEmpty().MaximumLength(200);
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.News;
public sealed record DeletePostCommand(Guid PostId) : ICommand<Result>;
@@ -0,0 +1,19 @@
using Microsoft.EntityFrameworkCore;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.News;
public sealed class DeletePostCommandHandler(IAppDbContext dbContext) : ICommandHandler<DeletePostCommand, Result>
{
public async Task<Result> Handle(DeletePostCommand command, CancellationToken cancellationToken)
{
var post = await dbContext.NewsPosts.FirstOrDefaultAsync(p => p.Id == command.PostId, cancellationToken);
if (post is null)
return Result.Failure(NewsErrors.NotFound);
dbContext.NewsPosts.Remove(post);
return Result.Success();
}
}
@@ -0,0 +1,7 @@
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
using PnvPanel.Application.News;
namespace PnvPanel.Application.Admin.News;
public sealed record ListAdminNewsQuery(int Page, int PageSize) : IQuery<Result<PagedList<NewsPostDto>>>;
@@ -0,0 +1,23 @@
using Microsoft.EntityFrameworkCore;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
using PnvPanel.Application.News;
namespace PnvPanel.Application.Admin.News;
public sealed class ListAdminNewsQueryHandler(IAppDbContext dbContext) : IQueryHandler<ListAdminNewsQuery, Result<PagedList<NewsPostDto>>>
{
public async Task<Result<PagedList<NewsPostDto>>> Handle(ListAdminNewsQuery query, CancellationToken cancellationToken)
{
var page = query.Page <= 0 ? 1 : query.Page;
var pageSize = query.PageSize is <= 0 or > 100 ? 20 : query.PageSize;
var result = await dbContext.NewsPosts.AsNoTracking()
.OrderByDescending(p => p.CreatedAt)
.Select(p => new NewsPostDto(p.Id, p.Title, p.Body, p.CreatedAt, p.UpdatedAt))
.ToPagedListAsync(page, pageSize, cancellationToken);
return Result.Success(result);
}
}
@@ -0,0 +1,8 @@
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.News;
public static class NewsErrors
{
public static readonly Error NotFound = Error.NotFound("News.NotFound", "Новость не найдена.");
}
@@ -0,0 +1,7 @@
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
using PnvPanel.Application.News;
namespace PnvPanel.Application.Admin.News;
public sealed record UpdatePostCommand(Guid PostId, string Title, string Body) : ICommand<Result<NewsPostDto>>;
@@ -0,0 +1,21 @@
using Microsoft.EntityFrameworkCore;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
using PnvPanel.Application.News;
namespace PnvPanel.Application.Admin.News;
public sealed class UpdatePostCommandHandler(IAppDbContext dbContext) : ICommandHandler<UpdatePostCommand, Result<NewsPostDto>>
{
public async Task<Result<NewsPostDto>> Handle(UpdatePostCommand command, CancellationToken cancellationToken)
{
var post = await dbContext.NewsPosts.FirstOrDefaultAsync(p => p.Id == command.PostId, cancellationToken);
if (post is null)
return Result.Failure<NewsPostDto>(NewsErrors.NotFound);
post.Update(command.Title, command.Body);
return Result.Success(NewsPostDto.FromDomain(post));
}
}
@@ -0,0 +1,12 @@
using FluentValidation;
namespace PnvPanel.Application.Admin.News;
public sealed class UpdatePostCommandValidator : AbstractValidator<UpdatePostCommand>
{
public UpdatePostCommandValidator()
{
RuleFor(x => x.Title).NotEmpty().MaximumLength(200);
RuleFor(x => x.Body).NotEmpty().MaximumLength(20000);
}
}
@@ -5,6 +5,7 @@ using PnvPanel.Domain.Apps;
using PnvPanel.Domain.Audit; using PnvPanel.Domain.Audit;
using PnvPanel.Domain.Configs; using PnvPanel.Domain.Configs;
using PnvPanel.Domain.Inbounds; using PnvPanel.Domain.Inbounds;
using PnvPanel.Domain.News;
using PnvPanel.Domain.Nodes; using PnvPanel.Domain.Nodes;
using PnvPanel.Domain.Telegram; using PnvPanel.Domain.Telegram;
@@ -30,6 +31,8 @@ public interface IAppDbContext
DbSet<ClientApp> ClientApps { get; } DbSet<ClientApp> ClientApps { get; }
DbSet<NewsPost> NewsPosts { get; }
/// <summary>Нужен для advisory-lock при проверке квоты конфигов (см. CreateVpnConfigCommandHandler).</summary> /// <summary>Нужен для advisory-lock при проверке квоты конфигов (см. CreateVpnConfigCommandHandler).</summary>
DatabaseFacade Database { get; } DatabaseFacade Database { get; }
@@ -21,4 +21,7 @@ public interface IRealtimeNotifier
Guid requestId, Guid userId, string userName, string? comment, DateTimeOffset createdAt, CancellationToken cancellationToken); Guid requestId, Guid userId, string userName, string? comment, DateTimeOffset createdAt, CancellationToken cancellationToken);
Task NotifyUserActivatedAsync(Guid userId, CancellationToken cancellationToken); Task NotifyUserActivatedAsync(Guid userId, CancellationToken cancellationToken);
/// <summary>Единственное широковещательное событие (всем подключенным клиентам), а не по группе.</summary>
Task NotifyNewsPublishedAsync(Guid postId, string title, DateTimeOffset createdAt, CancellationToken cancellationToken);
} }
@@ -0,0 +1,6 @@
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.News;
public sealed record ListNewsQuery(int Page, int PageSize) : IQuery<Result<PagedList<NewsPostDto>>>;
@@ -0,0 +1,22 @@
using Microsoft.EntityFrameworkCore;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.News;
public sealed class ListNewsQueryHandler(IAppDbContext dbContext) : IQueryHandler<ListNewsQuery, Result<PagedList<NewsPostDto>>>
{
public async Task<Result<PagedList<NewsPostDto>>> Handle(ListNewsQuery query, CancellationToken cancellationToken)
{
var page = query.Page <= 0 ? 1 : query.Page;
var pageSize = query.PageSize is <= 0 or > 100 ? 20 : query.PageSize;
var result = await dbContext.NewsPosts.AsNoTracking()
.OrderByDescending(p => p.CreatedAt)
.Select(p => new NewsPostDto(p.Id, p.Title, p.Body, p.CreatedAt, p.UpdatedAt))
.ToPagedListAsync(page, pageSize, cancellationToken);
return Result.Success(result);
}
}
@@ -0,0 +1,8 @@
using PnvPanel.Domain.News;
namespace PnvPanel.Application.News;
public sealed record NewsPostDto(Guid Id, string Title, string Body, DateTimeOffset CreatedAt, DateTimeOffset? UpdatedAt)
{
public static NewsPostDto FromDomain(NewsPost post) => new(post.Id, post.Title, post.Body, post.CreatedAt, post.UpdatedAt);
}
@@ -0,0 +1,34 @@
using PnvPanel.Domain.Common;
namespace PnvPanel.Domain.News;
/// <summary>Новость, которую публикует админ. Публикация мгновенная — нет статуса черновик/опубликовано.</summary>
public sealed class NewsPost : Entity
{
public string Title { get; private set; } = string.Empty;
public string Body { get; private set; } = string.Empty;
public DateTimeOffset CreatedAt { get; private set; }
public DateTimeOffset? UpdatedAt { get; private set; }
private NewsPost()
{
}
public static NewsPost Create(string title, string body)
{
return new NewsPost
{
Id = Guid.NewGuid(),
Title = title,
Body = body,
CreatedAt = DateTimeOffset.UtcNow,
};
}
public void Update(string title, string body)
{
Title = title;
Body = body;
UpdatedAt = DateTimeOffset.UtcNow;
}
}
@@ -6,6 +6,7 @@ using PnvPanel.Domain.Apps;
using PnvPanel.Domain.Audit; using PnvPanel.Domain.Audit;
using PnvPanel.Domain.Configs; using PnvPanel.Domain.Configs;
using PnvPanel.Domain.Inbounds; using PnvPanel.Domain.Inbounds;
using PnvPanel.Domain.News;
using PnvPanel.Domain.Nodes; using PnvPanel.Domain.Nodes;
using PnvPanel.Domain.Telegram; using PnvPanel.Domain.Telegram;
using PnvPanel.Infrastructure.Identity; using PnvPanel.Infrastructure.Identity;
@@ -39,6 +40,8 @@ public class AppDbContext(DbContextOptions<AppDbContext> options)
public DbSet<ClientApp> ClientApps => Set<ClientApp>(); public DbSet<ClientApp> ClientApps => Set<ClientApp>();
public DbSet<NewsPost> NewsPosts => Set<NewsPost>();
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
{ {
base.OnModelCreating(modelBuilder); base.OnModelCreating(modelBuilder);
@@ -0,0 +1,19 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using PnvPanel.Domain.News;
namespace PnvPanel.Infrastructure.Persistence.Configurations;
public class NewsPostConfiguration : IEntityTypeConfiguration<NewsPost>
{
public void Configure(EntityTypeBuilder<NewsPost> builder)
{
builder.ToTable("NewsPosts");
builder.HasKey(x => x.Id);
builder.Property(x => x.Title).IsRequired().HasMaxLength(200);
builder.Property(x => x.Body).IsRequired().HasMaxLength(20000);
builder.HasIndex(x => x.CreatedAt);
}
}
@@ -0,0 +1,773 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using PnvPanel.Infrastructure.Persistence;
#nullable disable
namespace PnvPanel.Infrastructure.Persistence.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260703114324_AddNewsPosts")]
partial class AddNewsPosts
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.9")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<Guid>("RoleId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("ProviderKey")
.HasColumnType("text");
b.Property<string>("ProviderDisplayName")
.HasColumnType("text");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
{
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.Property<Guid>("RoleId")
.HasColumnType("uuid");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
{
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("Name")
.HasColumnType("text");
b.Property<string>("Value")
.HasColumnType("text");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens", (string)null);
});
modelBuilder.Entity("PnvPanel.Domain.Activation.ActivationRequest", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Comment")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset?>("DecidedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("DecidedBy")
.HasColumnType("uuid");
b.Property<string>("RejectionReason")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("UserId", "Status");
b.ToTable("ActivationRequests", (string)null);
});
modelBuilder.Entity("PnvPanel.Domain.Apps.ClientApp", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Description")
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<string>("DownloadUrl")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("IconUrl")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<bool>("IsEnabled")
.HasColumnType("boolean");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("OperatingSystem")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<int>("SortOrder")
.HasColumnType("integer");
b.HasKey("Id");
b.ToTable("ClientApps", (string)null);
});
modelBuilder.Entity("PnvPanel.Domain.Audit.AuditLog", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<string>("Action")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<Guid?>("ActorId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Metadata")
.HasColumnType("jsonb");
b.Property<string>("Source")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("TargetId")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("TargetType")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.HasKey("Id");
b.HasIndex("CreatedAt");
b.ToTable("AuditLogs", (string)null);
});
modelBuilder.Entity("PnvPanel.Domain.Configs.TrafficSample", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<Guid>("ConfigId")
.HasColumnType("uuid");
b.Property<long>("DownBytes")
.HasColumnType("bigint");
b.Property<DateTimeOffset>("Timestamp")
.HasColumnType("timestamp with time zone");
b.Property<long>("UpBytes")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("ConfigId", "Timestamp");
b.ToTable("TrafficSamples", (string)null);
});
modelBuilder.Entity("PnvPanel.Domain.Configs.VpnConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("ClientEmail")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("ClientExternalId")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset?>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("InboundId")
.HasColumnType("uuid");
b.Property<string>("Label")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<DateTimeOffset?>("LastSyncAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Protocol")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("SubscriptionToken")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<long>("UsedDownBytes")
.HasColumnType("bigint");
b.Property<long>("UsedUpBytes")
.HasColumnType("bigint");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("InboundId");
b.HasIndex("SubscriptionToken")
.IsUnique();
b.HasIndex("UserId", "Status");
b.ToTable("VpnConfigs", (string)null);
});
modelBuilder.Entity("PnvPanel.Domain.Inbounds.Inbound", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.PrimitiveCollection<Guid[]>("AllowedRoleIds")
.IsRequired()
.HasColumnType("uuid[]");
b.Property<string>("DisplayName")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<bool>("IsPublished")
.HasColumnType("boolean");
b.Property<DateTimeOffset?>("LastSyncAt")
.HasColumnType("timestamp with time zone");
b.Property<int?>("MaxClients")
.HasColumnType("integer");
b.Property<Guid>("NodeId")
.HasColumnType("uuid");
b.Property<int>("Port")
.HasColumnType("integer");
b.Property<string>("Protocol")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("Remark")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("RemoteInboundId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.HasKey("Id");
b.HasIndex("NodeId", "RemoteInboundId")
.IsUnique();
b.ToTable("Inbounds", (string)null);
});
modelBuilder.Entity("PnvPanel.Domain.News.NewsPost", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Body")
.IsRequired()
.HasMaxLength(20000)
.HasColumnType("character varying(20000)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<DateTimeOffset?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("CreatedAt");
b.ToTable("NewsPosts", (string)null);
});
modelBuilder.Entity("PnvPanel.Domain.Nodes.Node", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("BaseAddress")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("IsEnabled")
.HasColumnType("boolean");
b.Property<DateTimeOffset?>("LastSyncAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Location")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.HasKey("Id");
b.ToTable("Nodes", (string)null);
});
modelBuilder.Entity("PnvPanel.Domain.Telegram.TelegramLinkToken", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset?>("ConsumedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Token")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("Token")
.IsUnique();
b.ToTable("TelegramLinkTokens", (string)null);
});
modelBuilder.Entity("PnvPanel.Domain.Telegram.TelegramLoginRequest", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Context")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<Guid?>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.ToTable("TelegramLoginRequests", (string)null);
});
modelBuilder.Entity("PnvPanel.Infrastructure.Identity.AppRole", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<bool>("IsSystem")
.HasColumnType("boolean");
b.Property<int>("MaxConfigs")
.HasColumnType("integer");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("RoleNameIndex");
b.ToTable("AspNetRoles", (string)null);
});
modelBuilder.Entity("PnvPanel.Infrastructure.Identity.AppUser", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<int>("AccessFailedCount")
.HasColumnType("integer");
b.Property<DateTimeOffset?>("ActivatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("ActivatedBy")
.HasColumnType("uuid");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<bool>("EmailConfirmed")
.HasColumnType("boolean");
b.Property<bool>("IsActivated")
.HasColumnType("boolean");
b.Property<bool>("IsBlocked")
.HasColumnType("boolean");
b.Property<bool>("LockoutEnabled")
.HasColumnType("boolean");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("timestamp with time zone");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("PasswordHash")
.HasColumnType("text");
b.Property<string>("PhoneNumber")
.HasColumnType("text");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("boolean");
b.Property<string>("SecurityStamp")
.HasColumnType("text");
b.Property<string>("SubscriptionToken")
.IsRequired()
.HasColumnType("text");
b.Property<DateTimeOffset?>("TelegramLinkedAt")
.HasColumnType("timestamp with time zone");
b.Property<long?>("TelegramUserId")
.HasColumnType("bigint");
b.Property<string>("TelegramUsername")
.HasColumnType("text");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("boolean");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex");
b.HasIndex("SubscriptionToken")
.IsUnique();
b.HasIndex("TelegramUserId")
.IsUnique();
b.ToTable("AspNetUsers", (string)null);
});
modelBuilder.Entity("PnvPanel.Infrastructure.Identity.RefreshToken", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("ReplacedByTokenHash")
.HasColumnType("text");
b.Property<DateTimeOffset?>("RevokedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("TokenHash")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("TokenHash")
.IsUnique();
b.HasIndex("UserId");
b.ToTable("RefreshTokens", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
{
b.HasOne("PnvPanel.Infrastructure.Identity.AppRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
{
b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
{
b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
{
b.HasOne("PnvPanel.Infrastructure.Identity.AppRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
{
b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("PnvPanel.Domain.Nodes.Node", b =>
{
b.OwnsOne("PnvPanel.Domain.Nodes.NodeCredentials", "Credentials", b1 =>
{
b1.Property<Guid>("NodeId")
.HasColumnType("uuid");
b1.Property<string>("ProtectedPassword")
.IsRequired()
.HasColumnType("text")
.HasColumnName("CredentialsProtectedPassword");
b1.Property<string>("Username")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("CredentialsUsername");
b1.HasKey("NodeId");
b1.ToTable("Nodes");
b1.WithOwner()
.HasForeignKey("NodeId");
});
b.Navigation("Credentials")
.IsRequired();
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,42 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PnvPanel.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddNewsPosts : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "NewsPosts",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Title = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
Body = table.Column<string>(type: "character varying(20000)", maxLength: 20000, nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_NewsPosts", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_NewsPosts_CreatedAt",
table: "NewsPosts",
column: "CreatedAt");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "NewsPosts");
}
}
}
@@ -394,6 +394,35 @@ namespace PnvPanel.Infrastructure.Persistence.Migrations
b.ToTable("Inbounds", (string)null); b.ToTable("Inbounds", (string)null);
}); });
modelBuilder.Entity("PnvPanel.Domain.News.NewsPost", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Body")
.IsRequired()
.HasMaxLength(20000)
.HasColumnType("character varying(20000)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<DateTimeOffset?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("CreatedAt");
b.ToTable("NewsPosts", (string)null);
});
modelBuilder.Entity("PnvPanel.Domain.Nodes.Node", b => modelBuilder.Entity("PnvPanel.Domain.Nodes.Node", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
+15
View File
@@ -101,6 +101,20 @@ status, createdAt }`. `expiresAt` всегда `null` (лимиты по сро
``` ```
Значение `OsPlatform` в C#/JSON`IOS` (не `iOS`). Значение `OsPlatform` в C#/JSON`IOS` (не `iOS`).
## News — лента новостей
| Метод | Путь | Роль | Тело запроса | Тело ответа |
| ------ | ------------------------ | ----- | ------------------------ | ------------- |
| GET | `/api/news` | user | query: `page, pageSize` | `PagedList<NewsPostDto>` |
| GET | `/api/admin/news` | admin | query: `page, pageSize` | `PagedList<NewsPostDto>` |
| POST | `/api/admin/news` | admin | `{ title, body }` | `NewsPostDto` |
| PUT | `/api/admin/news/{id}` | admin | `{ title, body }` | `NewsPostDto` |
| DELETE | `/api/admin/news/{id}` | admin | — | `204 No Content` |
Нет черновиков/отложенной публикации — `POST` сразу видна всем аутентифицированным пользователям
и триггерит SignalR-событие `newsPublished` (см. ниже). `NewsPostDto`:
`{ id, title, body, createdAt, updatedAt }`.
## Activation (пользователь) ## Activation (пользователь)
| Метод | Путь | Роль | Тело запроса | Тело ответа | | Метод | Путь | Роль | Тело запроса | Тело ответа |
@@ -190,6 +204,7 @@ totalConfigs, activeConfigs, totalUsedUpBytes, totalUsedDownBytes }` — счи
| `nodeStatusChanged` | `{ nodeId, status, lastSyncAt }` | `admins` | | `nodeStatusChanged` | `{ nodeId, status, lastSyncAt }` | `admins` |
| `activationRequested` | `{ requestId, userId, userName, comment, createdAt }` | `admins` | | `activationRequested` | `{ requestId, userId, userName, comment, createdAt }` | `admins` |
| `userActivated` | `{ userId }` | владельцу | | `userActivated` | `{ userId }` | владельцу |
| `newsPublished` | `{ id, title, createdAt }` | все (broadcast) |
### Client → Server ### Client → Server
Клиент только слушает; группировка по пользователю происходит на сервере при подключении, по Клиент только слушает; группировка по пользователю происходит на сервере при подключении, по
+3 -1
View File
@@ -189,7 +189,9 @@ POST /api/configs
(события нод/системы/активации) — пользователь при подключении добавляется в свою `user:{userId}` (события нод/системы/активации) — пользователь при подключении добавляется в свою `user:{userId}`
и, если он админ, дополнительно в `admins`. и, если он админ, дополнительно в `admins`.
- **События сервер→клиент**: `configTrafficUpdated`, `configStatusChanged`, `nodeStatusChanged`, - **События сервер→клиент**: `configTrafficUpdated`, `configStatusChanged`, `nodeStatusChanged`,
`activationRequested`, `userActivated` — точные payload'ы см. [api-design.md](api-design.md#signalr--hub-hubspanel). `activationRequested`, `userActivated`, `newsPublished` — точные payload'ы см.
[api-design.md](api-design.md#signalr--hub-hubspanel). `newsPublished` — единственное
широковещательное событие (`Clients.All`), а не по группе — новости видны всем без исключения.
- Пуш выполняет `SignalRRealtimeNotifier` (порт `IRealtimeNotifier`, реализация в `Api/Hubs/`), - Пуш выполняет `SignalRRealtimeNotifier` (порт `IRealtimeNotifier`, реализация в `Api/Hubs/`),
вызываемый из хендлеров и фоновых сервисов — Application-слой не зависит от SignalR напрямую. вызываемый из хендлеров и фоновых сервисов — Application-слой не зависит от SignalR напрямую.
+4 -1
View File
@@ -12,6 +12,7 @@ backend/
Common/ # Entity (единственный базовый класс — без AggregateRoot/IDomainEvent) Common/ # Entity (единственный базовый класс — без AggregateRoot/IDomainEvent)
Activation/ # ActivationRequest, ActivationStatus Activation/ # ActivationRequest, ActivationStatus
Apps/ # ClientApp, OsPlatform Apps/ # ClientApp, OsPlatform
News/ # NewsPost
Audit/ # AuditLog, AuditSource Audit/ # AuditLog, AuditSource
Configs/ # VpnConfig, ConfigStatus, TrafficSample Configs/ # VpnConfig, ConfigStatus, TrafficSample
Inbounds/ # Inbound, VpnProtocol Inbounds/ # Inbound, VpnProtocol
@@ -29,6 +30,7 @@ backend/
Admin/ Admin/
Activation/ # ListActivationRequestsQuery, Approve/RejectActivationCommand Activation/ # ListActivationRequestsQuery, Approve/RejectActivationCommand
Apps/ # CRUD ClientApp Apps/ # CRUD ClientApp
News/ # CRUD NewsPost
Audit/ # ListAuditLogsQuery Audit/ # ListAuditLogsQuery
Inbounds/ # ListInbounds, PublishInbound Inbounds/ # ListInbounds, PublishInbound
Nodes/ # RegisterNode, UpdateNode, DeleteNode, SyncNode, ProbeNode, ListNodes Nodes/ # RegisterNode, UpdateNode, DeleteNode, SyncNode, ProbeNode, ListNodes
@@ -37,6 +39,7 @@ backend/
Users/ # ListUsers, BlockUser/UnblockUser, ChangeUserRole, ResetUserPassword, Users/ # ListUsers, BlockUser/UnblockUser, ChangeUserRole, ResetUserPassword,
# ForceRevokeConfig, GetUserConfigs # ForceRevokeConfig, GetUserConfigs
Apps/ # ListAppsQuery (по ОС, для юзера) Apps/ # ListAppsQuery (по ОС, для юзера)
News/ # ListNewsQuery (пагинировано, для юзера)
Auth/ Auth/
ChangePassword/, DeleteMyAccount/, Login/, Logout/, Me/, Refresh/, Register/ ChangePassword/, DeleteMyAccount/, Login/, Logout/, Me/, Refresh/, Register/
Configs/ Configs/
@@ -59,7 +62,7 @@ backend/
Telegram/ # TelegramNotifier, TelegramOptions Telegram/ # TelegramNotifier, TelegramOptions
DependencyInjection.cs # AddInfrastructure(...) DependencyInjection.cs # AddInfrastructure(...)
PnvPanel.Api/ PnvPanel.Api/
Endpoints/ # 12 файлов, см. backend-conventions.md ниже и api-design.md Endpoints/ # 14 файлов, см. backend-conventions.md ниже и api-design.md
Hubs/ # PanelHub, SignalRRealtimeNotifier (реализация IRealtimeNotifier — здесь, Hubs/ # PanelHub, SignalRRealtimeNotifier (реализация IRealtimeNotifier — здесь,
# не в Infrastructure, т.к. нужен IHubContext<PanelHub>) # не в Infrastructure, т.к. нужен IHubContext<PanelHub>)
Telegram/ # TelegramBotHostedService, PnvBotUpdateHandler, TelegramNotifier Telegram/ # TelegramBotHostedService, PnvBotUpdateHandler, TelegramNotifier
+16
View File
@@ -21,6 +21,7 @@ AppUser (Identity) [+ IsActivated, IsBlocked, TelegramUserId, SubscriptionToken
VpnConfig ─*─ TrafficSample (история трафика; пишется TrafficSyncService) VpnConfig ─*─ TrafficSample (история трафика; пишется TrafficSyncService)
AuditLog (append-only журнал действий; ссылается на ActorId/TargetId) AuditLog (append-only журнал действий; ссылается на ActorId/TargetId)
ClientApp (каталог приложений-клиентов; группируется по OperatingSystem) ClientApp (каталог приложений-клиентов; группируется по OperatingSystem)
NewsPost (лента новостей; публикуется админом, видна всем аутентифицированным пользователям)
``` ```
## Сущности ## Сущности
@@ -143,6 +144,21 @@ ClientApp (каталог приложений-клиен
Управляется админом (CRUD). Пользователю отдаётся только `IsEnabled`, сгруппировано по `OperatingSystem`. Управляется админом (CRUD). Пользователю отдаётся только `IsEnabled`, сгруппировано по `OperatingSystem`.
Стартовый набор сидируется из [`seed/client-apps.json`](../seed/client-apps.json), если таблица пуста. Стартовый набор сидируется из [`seed/client-apps.json`](../seed/client-apps.json), если таблица пуста.
### NewsPost — новости для пользователей
Публикуются админом немедленно, видны всем залогиненным пользователям в хронологической ленте.
| Поле | Тип | Заметки |
| ----------- | ----------------- | ------------------------------------------------------- |
| `Id` | `Guid` | PK |
| `Title` | `string` | Заголовок |
| `Body` | `string` | Markdown-текст |
| `CreatedAt` | `DateTimeOffset` | Момент публикации (= момент создания, нет черновиков) |
| `UpdatedAt` | `DateTimeOffset?` | Момент последней правки (опц.) |
Нет статуса черновик/запланировано — публикация мгновенная. Нет видимости по ролям — доступно
всем аутентифицированным пользователям. Realtime-оповещение о новом посте — `newsPublished`
(SignalR, широковещательно всем подключенным клиентам), см. [architecture.md](architecture.md#realtime-signalr).
### AuditLog — журнал действий ### AuditLog — журнал действий
Аудит значимых действий (прежде всего админских) для расследований и прозрачности. Аудит значимых действий (прежде всего админских) для расследований и прозрачности.
+12 -5
View File
@@ -23,6 +23,7 @@ SPA на **React 19 + Vite + TypeScript**. Общается с бэком по R
| Формы | react-hook-form + zod | | Формы | react-hook-form + zod |
| Realtime | @microsoft/signalr | | Realtime | @microsoft/signalr |
| QR-коды | qrcode.react (рендерит QR из готовой строки на клиенте) | | QR-коды | qrcode.react (рендерит QR из готовой строки на клиенте) |
| Markdown | react-markdown + remark-gfm (лента новостей; без rehype-raw — сырой HTML не рендерится) |
| Типы API | openapi-typescript (`pnpm gen:api`) — генерирует `schema.gen.ts` для сверки; фичи импортируют руками написанный `shared/api/types.ts` | | Типы API | openapi-typescript (`pnpm gen:api`) — генерирует `schema.gen.ts` для сверки; фичи импортируют руками написанный `shared/api/types.ts` |
| i18n | react-i18next (RU + EN) | | i18n | react-i18next (RU + EN) |
| Линт | oxlint (не ESLint) | | Линт | oxlint (не ESLint) |
@@ -44,19 +45,20 @@ frontend/
index.css # Tailwind v4 (@import), без отдельной styles/-папки index.css # Tailwind v4 (@import), без отдельной styles/-папки
routes/ # файловый роутинг TanStack Router routes/ # файловый роутинг TanStack Router
__root.tsx # шапка (лого, нав, переключатель языка/темы), Outlet __root.tsx # шапка (лого, нав, переключатель языка/темы), Outlet
index.tsx, login.tsx, register.tsx, dashboard.tsx, instructions.tsx, settings.tsx index.tsx, login.tsx, register.tsx, dashboard.tsx, instructions.tsx, news.tsx, settings.tsx
admin.tsx # layout админки (вкладки) + Outlet admin.tsx # layout админки (вкладки) + Outlet
admin/ admin/
index.tsx, activation.tsx, users.tsx, roles.tsx, nodes.tsx, apps.tsx, audit.tsx index.tsx, activation.tsx, users.tsx, roles.tsx, nodes.tsx, apps.tsx, news.tsx, audit.tsx
features/ features/
auth/ # api.ts, store.ts (zustand), guards.ts, LoginForm.tsx, RegisterForm.tsx auth/ # api.ts, store.ts (zustand), guards.ts, LoginForm.tsx, RegisterForm.tsx
activation/ # api.ts, ActivationGate.tsx (экран "запросить активацию") activation/ # api.ts, ActivationGate.tsx (экран "запросить активацию")
configs/ # api.ts, ConfigCard.tsx, CreateConfigDialog.tsx, SubscriptionCard.tsx configs/ # api.ts, ConfigCard.tsx, CreateConfigDialog.tsx, SubscriptionCard.tsx
apps/ # api.ts, AppsCatalog.tsx (для /instructions) apps/ # api.ts, AppsCatalog.tsx (для /instructions)
news/ # api.ts, NewsFeed.tsx (для /news)
telegram/ # api.ts, TelegramLoginButton.tsx telegram/ # api.ts, TelegramLoginButton.tsx
settings/ # ChangePasswordForm.tsx, TelegramLinkCard.tsx, DeleteAccountSection.tsx settings/ # ChangePasswordForm.tsx, TelegramLinkCard.tsx, DeleteAccountSection.tsx
admin/ admin/
users/, roles/, activation/, nodes/, inbounds/, apps/, audit/, stats/ # api.ts + диалоги CRUD в каждой users/, roles/, activation/, nodes/, inbounds/, apps/, news/, audit/, stats/ # api.ts + диалоги CRUD в каждой
theme/ theme/
ThemeProvider.tsx # React Context + localStorage (`pnv-theme`), НЕ zustand ThemeProvider.tsx # React Context + localStorage (`pnv-theme`), НЕ zustand
shared/ shared/
@@ -69,7 +71,7 @@ frontend/
realtime/ realtime/
connection.ts, RealtimeProvider.tsx connection.ts, RealtimeProvider.tsx
ui/ ui/
button.tsx, input.tsx, label.tsx, card.tsx, dialog.tsx, select.tsx, badge.tsx, button.tsx, input.tsx, textarea.tsx, label.tsx, card.tsx, dialog.tsx, select.tsx, badge.tsx,
progress.tsx, toast-store.tsx, toaster.tsx progress.tsx, toast-store.tsx, toaster.tsx
index.html index.html
vite.config.ts vite.config.ts
@@ -93,6 +95,10 @@ frontend/
- **Страница инструкций** (`/instructions`): статичные шаги + каталог приложений (`GET /api/apps`), - **Страница инструкций** (`/instructions`): статичные шаги + каталог приложений (`GET /api/apps`),
сгруппированный по ОС и показан вкладками (по одной ОС за раз); клик по приложению открывает сгруппированный по ОС и показан вкладками (по одной ОС за раз); клик по приложению открывает
ссылку на скачивание. ссылку на скачивание.
- **Лента новостей** (`/news`): хронологический список постов админа (заголовок + Markdown-тело,
рендерится через `react-markdown` + `remark-gfm`), пагинация (`GET /api/news`), живое обновление
по SignalR (`newsPublished`, широковещательно всем). Админка (`/admin/news`): CRUD, обычный
`<textarea>` с переключателем предпросмотра Markdown вместо WYSIWYG-редактора.
- **Настройки** (`/settings`): смена пароля, привязка/отвязка Telegram (`TelegramLinkCard`), - **Настройки** (`/settings`): смена пароля, привязка/отвязка Telegram (`TelegramLinkCard`),
удаление аккаунта с подтверждением (`DeleteAccountSection`). удаление аккаунта с подтверждением (`DeleteAccountSection`).
- **Админка** (`/admin/*`): вкладки — обзор (карточки статистики, без графиков), запросы активации, - **Админка** (`/admin/*`): вкладки — обзор (карточки статистики, без графиков), запросы активации,
@@ -132,7 +138,8 @@ frontend/
- Одно SignalR-подключение к `/hubs/panel` с JWT (`RealtimeProvider`, `shared/realtime/connection.ts`). - Одно SignalR-подключение к `/hubs/panel` с JWT (`RealtimeProvider`, `shared/realtime/connection.ts`).
- Обработчики `configTrafficUpdated`/`configStatusChanged`/`nodeStatusChanged`/`activationRequested`/ - Обработчики `configTrafficUpdated`/`configStatusChanged`/`nodeStatusChanged`/`activationRequested`/
`userActivated` точечно инвалидируют/обновляют кэш TanStack Query — UI обновляется без перезагрузки. `userActivated`/`newsPublished` точечно инвалидируют/обновляют кэш TanStack Query — UI обновляется
без перезагрузки.
## Скрипты ## Скрипты
+1
View File
@@ -110,6 +110,7 @@ PnvPanel **не заменяет** Xray/3x-ui — он оркестрирует
- Telegram-бот: ссылка на сайт, просмотр конфигов, привязка/регистрация через Telegram и passwordless-вход. - Telegram-бот: ссылка на сайт, просмотр конфигов, привязка/регистрация через Telegram и passwordless-вход.
- Светлая/тёмная тема сайта. - Светлая/тёмная тема сайта.
- Страница инструкций по подключению + каталог приложений по ОС (админ ведёт, юзер видит сгруппировано). - Страница инструкций по подключению + каталог приложений по ОС (админ ведёт, юзер видит сгруппировано).
- Лента новостей: админ публикует Markdown-посты, все пользователи видят их живой лентой (SignalR).
- Единый Docker-образ (фронт+бек) + PostgreSQL в docker-compose. - Единый Docker-образ (фронт+бек) + PostgreSQL в docker-compose.
**Не реализовано:** **Не реализовано:**
+2
View File
@@ -34,7 +34,9 @@
"react-dom": "^19.2.7", "react-dom": "^19.2.7",
"react-hook-form": "^7.80.0", "react-hook-form": "^7.80.0",
"react-i18next": "^17.0.8", "react-i18next": "^17.0.8",
"react-markdown": "^10.1.0",
"recharts": "^3.9.1", "recharts": "^3.9.1",
"remark-gfm": "^4.0.1",
"tailwind-merge": "^3.6.0", "tailwind-merge": "^3.6.0",
"zod": "^4.4.3", "zod": "^4.4.3",
"zustand": "^5.0.14" "zustand": "^5.0.14"
+881 -2
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,92 @@
import { useState } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { toast } from '@/shared/ui/toast-store'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/shared/ui/dialog'
import { Button } from '@/shared/ui/button'
import { Input } from '@/shared/ui/input'
import { Label } from '@/shared/ui/label'
import { Textarea } from '@/shared/ui/textarea'
import { HttpError } from '@/shared/api/client'
import type { NewsPostDto } from '@/shared/api/types'
import { createPost, updatePost } from './api'
export function NewsFormDialog({
post,
open,
onOpenChange,
}: {
post?: NewsPostDto
open?: boolean
onOpenChange?: (open: boolean) => void
}) {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [internalOpen, setInternalOpen] = useState(false)
const [title, setTitle] = useState(post?.title ?? '')
const [body, setBody] = useState(post?.body ?? '')
const [previewMode, setPreviewMode] = useState(false)
const isControlled = open !== undefined
const dialogOpen = isControlled ? open : internalOpen
const setDialogOpen = isControlled ? onOpenChange! : setInternalOpen
const mutation = useMutation({
mutationFn: () => (post ? updatePost(post.id, title.trim(), body.trim()) : createPost(title.trim(), body.trim())),
onSuccess: async () => {
toast.success(post ? t('admin.news.updated') : t('admin.news.created'))
await queryClient.invalidateQueries({ queryKey: ['admin-news'] })
setDialogOpen(false)
},
onError: (error) => toast.error(error instanceof HttpError ? error.detail : t('auth.genericError')),
})
const canSubmit = title.trim() && body.trim()
return (
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
{!isControlled && (
<DialogTrigger asChild>
<Button size="sm">{t('admin.news.create')}</Button>
</DialogTrigger>
)}
<DialogContent>
<DialogHeader>
<DialogTitle>{post ? post.title : t('admin.news.create')}</DialogTitle>
</DialogHeader>
<form
className="flex flex-col gap-4"
onSubmit={(e) => {
e.preventDefault()
if (canSubmit) mutation.mutate()
}}
>
<div className="flex flex-col gap-1.5">
<Label htmlFor="newsTitle">{t('admin.news.title')}</Label>
<Input id="newsTitle" value={title} onChange={(e) => setTitle(e.target.value)} required />
</div>
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between">
<Label htmlFor="newsBody">{t('admin.news.body')}</Label>
<Button type="button" size="sm" variant="ghost" onClick={() => setPreviewMode((v) => !v)}>
{t('admin.news.preview')}
</Button>
</div>
{previewMode ? (
<div className="flex min-h-32 flex-col gap-2 rounded-md border border-border px-3 py-2 text-sm [&_a]:text-primary [&_a]:hover:underline [&_li]:ml-4 [&_ul]:list-disc [&_ol]:list-decimal">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{body || t('admin.news.body')}</ReactMarkdown>
</div>
) : (
<Textarea id="newsBody" value={body} onChange={(e) => setBody(e.target.value)} required />
)}
</div>
<Button type="submit" disabled={!canSubmit || mutation.isPending}>
{post ? t('admin.roles.save') : t('admin.news.create')}
</Button>
</form>
</DialogContent>
</Dialog>
)
}
+18
View File
@@ -0,0 +1,18 @@
import { apiRequest } from '@/shared/api/client'
import type { NewsPostDto, PagedList } from '@/shared/api/types'
export function listAdminNews(page: number, pageSize: number) {
return apiRequest<PagedList<NewsPostDto>>(`/admin/news?page=${page}&pageSize=${pageSize}`)
}
export function createPost(title: string, body: string) {
return apiRequest<NewsPostDto>('/admin/news', { method: 'POST', body: { title, body } })
}
export function updatePost(id: string, title: string, body: string) {
return apiRequest<NewsPostDto>(`/admin/news/${id}`, { method: 'PUT', body: { title, body } })
}
export function deletePost(id: string) {
return apiRequest<void>(`/admin/news/${id}`, { method: 'DELETE' })
}
+67
View File
@@ -0,0 +1,67 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { Button } from '@/shared/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/ui/card'
import { listNews } from './api'
const PAGE_SIZE = 20
export function NewsFeed() {
const { t } = useTranslation()
const [page, setPage] = useState(1)
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ['news', page],
queryFn: () => listNews(page, PAGE_SIZE),
})
return (
<div className="flex flex-col gap-4">
{isLoading && <p className="text-sm text-muted-foreground"></p>}
{isError && (
<div className="flex items-center gap-2">
<p className="text-sm text-muted-foreground">{t('auth.genericError')}</p>
<Button variant="outline" size="sm" onClick={() => void refetch()}>
{t('activation.retry')}
</Button>
</div>
)}
{data?.items.length === 0 && <p className="text-sm text-muted-foreground">{t('news.empty')}</p>}
{data && data.items.length > 0 && (
<>
<div className="flex flex-col gap-4">
{data.items.map((post) => (
<Card key={post.id}>
<CardHeader>
<CardTitle className="text-base">{post.title}</CardTitle>
<p className="text-xs text-muted-foreground">{new Date(post.createdAt).toLocaleString()}</p>
</CardHeader>
<CardContent className="flex flex-col gap-2 text-sm [&_a]:text-primary [&_a]:hover:underline [&_li]:ml-4 [&_ul]:list-disc [&_ol]:list-decimal">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{post.body}</ReactMarkdown>
</CardContent>
</Card>
))}
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">{t('admin.users.total', { count: data.total })}</span>
<div className="flex gap-2">
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
{t('admin.prev')}
</Button>
<Button variant="outline" size="sm" disabled={page * PAGE_SIZE >= data.total} onClick={() => setPage((p) => p + 1)}>
{t('admin.next')}
</Button>
</div>
</div>
</>
)}
</div>
)
}
+6
View File
@@ -0,0 +1,6 @@
import { apiRequest } from '@/shared/api/client'
import type { NewsPostDto, PagedList } from '@/shared/api/types'
export function listNews(page: number, pageSize: number) {
return apiRequest<PagedList<NewsPostDto>>(`/news?page=${page}&pageSize=${pageSize}`)
}
+42
View File
@@ -11,6 +11,7 @@
import { Route as rootRouteImport } from './routes/__root' import { Route as rootRouteImport } from './routes/__root'
import { Route as SettingsRouteImport } from './routes/settings' import { Route as SettingsRouteImport } from './routes/settings'
import { Route as RegisterRouteImport } from './routes/register' import { Route as RegisterRouteImport } from './routes/register'
import { Route as NewsRouteImport } from './routes/news'
import { Route as LoginRouteImport } from './routes/login' import { Route as LoginRouteImport } from './routes/login'
import { Route as InstructionsRouteImport } from './routes/instructions' import { Route as InstructionsRouteImport } from './routes/instructions'
import { Route as DashboardRouteImport } from './routes/dashboard' import { Route as DashboardRouteImport } from './routes/dashboard'
@@ -20,6 +21,7 @@ import { Route as AdminIndexRouteImport } from './routes/admin/index'
import { Route as AdminUsersRouteImport } from './routes/admin/users' import { Route as AdminUsersRouteImport } from './routes/admin/users'
import { Route as AdminRolesRouteImport } from './routes/admin/roles' import { Route as AdminRolesRouteImport } from './routes/admin/roles'
import { Route as AdminNodesRouteImport } from './routes/admin/nodes' import { Route as AdminNodesRouteImport } from './routes/admin/nodes'
import { Route as AdminNewsRouteImport } from './routes/admin/news'
import { Route as AdminAuditRouteImport } from './routes/admin/audit' import { Route as AdminAuditRouteImport } from './routes/admin/audit'
import { Route as AdminAppsRouteImport } from './routes/admin/apps' import { Route as AdminAppsRouteImport } from './routes/admin/apps'
import { Route as AdminActivationRouteImport } from './routes/admin/activation' import { Route as AdminActivationRouteImport } from './routes/admin/activation'
@@ -34,6 +36,11 @@ const RegisterRoute = RegisterRouteImport.update({
path: '/register', path: '/register',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const NewsRoute = NewsRouteImport.update({
id: '/news',
path: '/news',
getParentRoute: () => rootRouteImport,
} as any)
const LoginRoute = LoginRouteImport.update({ const LoginRoute = LoginRouteImport.update({
id: '/login', id: '/login',
path: '/login', path: '/login',
@@ -79,6 +86,11 @@ const AdminNodesRoute = AdminNodesRouteImport.update({
path: '/nodes', path: '/nodes',
getParentRoute: () => AdminRoute, getParentRoute: () => AdminRoute,
} as any) } as any)
const AdminNewsRoute = AdminNewsRouteImport.update({
id: '/news',
path: '/news',
getParentRoute: () => AdminRoute,
} as any)
const AdminAuditRoute = AdminAuditRouteImport.update({ const AdminAuditRoute = AdminAuditRouteImport.update({
id: '/audit', id: '/audit',
path: '/audit', path: '/audit',
@@ -101,11 +113,13 @@ export interface FileRoutesByFullPath {
'/dashboard': typeof DashboardRoute '/dashboard': typeof DashboardRoute
'/instructions': typeof InstructionsRoute '/instructions': typeof InstructionsRoute
'/login': typeof LoginRoute '/login': typeof LoginRoute
'/news': typeof NewsRoute
'/register': typeof RegisterRoute '/register': typeof RegisterRoute
'/settings': typeof SettingsRoute '/settings': typeof SettingsRoute
'/admin/activation': typeof AdminActivationRoute '/admin/activation': typeof AdminActivationRoute
'/admin/apps': typeof AdminAppsRoute '/admin/apps': typeof AdminAppsRoute
'/admin/audit': typeof AdminAuditRoute '/admin/audit': typeof AdminAuditRoute
'/admin/news': typeof AdminNewsRoute
'/admin/nodes': typeof AdminNodesRoute '/admin/nodes': typeof AdminNodesRoute
'/admin/roles': typeof AdminRolesRoute '/admin/roles': typeof AdminRolesRoute
'/admin/users': typeof AdminUsersRoute '/admin/users': typeof AdminUsersRoute
@@ -116,11 +130,13 @@ export interface FileRoutesByTo {
'/dashboard': typeof DashboardRoute '/dashboard': typeof DashboardRoute
'/instructions': typeof InstructionsRoute '/instructions': typeof InstructionsRoute
'/login': typeof LoginRoute '/login': typeof LoginRoute
'/news': typeof NewsRoute
'/register': typeof RegisterRoute '/register': typeof RegisterRoute
'/settings': typeof SettingsRoute '/settings': typeof SettingsRoute
'/admin/activation': typeof AdminActivationRoute '/admin/activation': typeof AdminActivationRoute
'/admin/apps': typeof AdminAppsRoute '/admin/apps': typeof AdminAppsRoute
'/admin/audit': typeof AdminAuditRoute '/admin/audit': typeof AdminAuditRoute
'/admin/news': typeof AdminNewsRoute
'/admin/nodes': typeof AdminNodesRoute '/admin/nodes': typeof AdminNodesRoute
'/admin/roles': typeof AdminRolesRoute '/admin/roles': typeof AdminRolesRoute
'/admin/users': typeof AdminUsersRoute '/admin/users': typeof AdminUsersRoute
@@ -133,11 +149,13 @@ export interface FileRoutesById {
'/dashboard': typeof DashboardRoute '/dashboard': typeof DashboardRoute
'/instructions': typeof InstructionsRoute '/instructions': typeof InstructionsRoute
'/login': typeof LoginRoute '/login': typeof LoginRoute
'/news': typeof NewsRoute
'/register': typeof RegisterRoute '/register': typeof RegisterRoute
'/settings': typeof SettingsRoute '/settings': typeof SettingsRoute
'/admin/activation': typeof AdminActivationRoute '/admin/activation': typeof AdminActivationRoute
'/admin/apps': typeof AdminAppsRoute '/admin/apps': typeof AdminAppsRoute
'/admin/audit': typeof AdminAuditRoute '/admin/audit': typeof AdminAuditRoute
'/admin/news': typeof AdminNewsRoute
'/admin/nodes': typeof AdminNodesRoute '/admin/nodes': typeof AdminNodesRoute
'/admin/roles': typeof AdminRolesRoute '/admin/roles': typeof AdminRolesRoute
'/admin/users': typeof AdminUsersRoute '/admin/users': typeof AdminUsersRoute
@@ -151,11 +169,13 @@ export interface FileRouteTypes {
| '/dashboard' | '/dashboard'
| '/instructions' | '/instructions'
| '/login' | '/login'
| '/news'
| '/register' | '/register'
| '/settings' | '/settings'
| '/admin/activation' | '/admin/activation'
| '/admin/apps' | '/admin/apps'
| '/admin/audit' | '/admin/audit'
| '/admin/news'
| '/admin/nodes' | '/admin/nodes'
| '/admin/roles' | '/admin/roles'
| '/admin/users' | '/admin/users'
@@ -166,11 +186,13 @@ export interface FileRouteTypes {
| '/dashboard' | '/dashboard'
| '/instructions' | '/instructions'
| '/login' | '/login'
| '/news'
| '/register' | '/register'
| '/settings' | '/settings'
| '/admin/activation' | '/admin/activation'
| '/admin/apps' | '/admin/apps'
| '/admin/audit' | '/admin/audit'
| '/admin/news'
| '/admin/nodes' | '/admin/nodes'
| '/admin/roles' | '/admin/roles'
| '/admin/users' | '/admin/users'
@@ -182,11 +204,13 @@ export interface FileRouteTypes {
| '/dashboard' | '/dashboard'
| '/instructions' | '/instructions'
| '/login' | '/login'
| '/news'
| '/register' | '/register'
| '/settings' | '/settings'
| '/admin/activation' | '/admin/activation'
| '/admin/apps' | '/admin/apps'
| '/admin/audit' | '/admin/audit'
| '/admin/news'
| '/admin/nodes' | '/admin/nodes'
| '/admin/roles' | '/admin/roles'
| '/admin/users' | '/admin/users'
@@ -199,6 +223,7 @@ export interface RootRouteChildren {
DashboardRoute: typeof DashboardRoute DashboardRoute: typeof DashboardRoute
InstructionsRoute: typeof InstructionsRoute InstructionsRoute: typeof InstructionsRoute
LoginRoute: typeof LoginRoute LoginRoute: typeof LoginRoute
NewsRoute: typeof NewsRoute
RegisterRoute: typeof RegisterRoute RegisterRoute: typeof RegisterRoute
SettingsRoute: typeof SettingsRoute SettingsRoute: typeof SettingsRoute
} }
@@ -219,6 +244,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof RegisterRouteImport preLoaderRoute: typeof RegisterRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/news': {
id: '/news'
path: '/news'
fullPath: '/news'
preLoaderRoute: typeof NewsRouteImport
parentRoute: typeof rootRouteImport
}
'/login': { '/login': {
id: '/login' id: '/login'
path: '/login' path: '/login'
@@ -282,6 +314,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AdminNodesRouteImport preLoaderRoute: typeof AdminNodesRouteImport
parentRoute: typeof AdminRoute parentRoute: typeof AdminRoute
} }
'/admin/news': {
id: '/admin/news'
path: '/news'
fullPath: '/admin/news'
preLoaderRoute: typeof AdminNewsRouteImport
parentRoute: typeof AdminRoute
}
'/admin/audit': { '/admin/audit': {
id: '/admin/audit' id: '/admin/audit'
path: '/audit' path: '/audit'
@@ -310,6 +349,7 @@ interface AdminRouteChildren {
AdminActivationRoute: typeof AdminActivationRoute AdminActivationRoute: typeof AdminActivationRoute
AdminAppsRoute: typeof AdminAppsRoute AdminAppsRoute: typeof AdminAppsRoute
AdminAuditRoute: typeof AdminAuditRoute AdminAuditRoute: typeof AdminAuditRoute
AdminNewsRoute: typeof AdminNewsRoute
AdminNodesRoute: typeof AdminNodesRoute AdminNodesRoute: typeof AdminNodesRoute
AdminRolesRoute: typeof AdminRolesRoute AdminRolesRoute: typeof AdminRolesRoute
AdminUsersRoute: typeof AdminUsersRoute AdminUsersRoute: typeof AdminUsersRoute
@@ -320,6 +360,7 @@ const AdminRouteChildren: AdminRouteChildren = {
AdminActivationRoute: AdminActivationRoute, AdminActivationRoute: AdminActivationRoute,
AdminAppsRoute: AdminAppsRoute, AdminAppsRoute: AdminAppsRoute,
AdminAuditRoute: AdminAuditRoute, AdminAuditRoute: AdminAuditRoute,
AdminNewsRoute: AdminNewsRoute,
AdminNodesRoute: AdminNodesRoute, AdminNodesRoute: AdminNodesRoute,
AdminRolesRoute: AdminRolesRoute, AdminRolesRoute: AdminRolesRoute,
AdminUsersRoute: AdminUsersRoute, AdminUsersRoute: AdminUsersRoute,
@@ -334,6 +375,7 @@ const rootRouteChildren: RootRouteChildren = {
DashboardRoute: DashboardRoute, DashboardRoute: DashboardRoute,
InstructionsRoute: InstructionsRoute, InstructionsRoute: InstructionsRoute,
LoginRoute: LoginRoute, LoginRoute: LoginRoute,
NewsRoute: NewsRoute,
RegisterRoute: RegisterRoute, RegisterRoute: RegisterRoute,
SettingsRoute: SettingsRoute, SettingsRoute: SettingsRoute,
} }
+3
View File
@@ -47,6 +47,9 @@ function RootLayout() {
<Link to="/instructions" className="text-muted-foreground hover:text-foreground"> <Link to="/instructions" className="text-muted-foreground hover:text-foreground">
{t('nav.instructions')} {t('nav.instructions')}
</Link> </Link>
<Link to="/news" className="text-muted-foreground hover:text-foreground">
{t('nav.news')}
</Link>
<Link to="/settings" className="text-muted-foreground hover:text-foreground"> <Link to="/settings" className="text-muted-foreground hover:text-foreground">
{t('nav.settings')} {t('nav.settings')}
</Link> </Link>
+1
View File
@@ -12,6 +12,7 @@ const TABS = [
{ to: '/admin/roles', key: 'roles' }, { to: '/admin/roles', key: 'roles' },
{ to: '/admin/nodes', key: 'nodes' }, { to: '/admin/nodes', key: 'nodes' },
{ to: '/admin/apps', key: 'apps' }, { to: '/admin/apps', key: 'apps' },
{ to: '/admin/news', key: 'news' },
{ to: '/admin/audit', key: 'audit' }, { to: '/admin/audit', key: 'audit' },
] as const ] as const
+99
View File
@@ -0,0 +1,99 @@
import { useState } from 'react'
import { createFileRoute } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { toast } from '@/shared/ui/toast-store'
import { Button } from '@/shared/ui/button'
import { listAdminNews, deletePost } from '@/features/admin/news/api'
import { NewsFormDialog } from '@/features/admin/news/NewsFormDialog'
import type { NewsPostDto } from '@/shared/api/types'
export const Route = createFileRoute('/admin/news')({ component: AdminNewsPage })
const PAGE_SIZE = 20
function AdminNewsPage() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [page, setPage] = useState(1)
const [editing, setEditing] = useState<NewsPostDto | null>(null)
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ['admin-news', page],
queryFn: () => listAdminNews(page, PAGE_SIZE),
})
const deleteMutation = useMutation({
mutationFn: deletePost,
onSuccess: async () => {
toast.success(t('admin.news.deleted'))
await queryClient.invalidateQueries({ queryKey: ['admin-news'] })
},
onError: () => toast.error(t('auth.genericError')),
})
return (
<div className="flex flex-col gap-4">
<div className="flex justify-end">
<NewsFormDialog />
</div>
{isLoading && <p className="text-sm text-muted-foreground"></p>}
{isError && (
<div className="flex items-center gap-2">
<p className="text-sm text-muted-foreground">{t('auth.genericError')}</p>
<Button variant="outline" size="sm" onClick={() => void refetch()}>
{t('activation.retry')}
</Button>
</div>
)}
{data?.items.length === 0 && <p className="text-sm text-muted-foreground">{t('admin.news.empty')}</p>}
{data && data.items.length > 0 && (
<>
<div className="flex flex-col gap-2">
{data.items.map((post) => (
<div key={post.id} className="flex items-center justify-between rounded-md border border-border px-3 py-2 text-sm">
<div className="flex flex-col">
<span>{post.title}</span>
<span className="text-xs text-muted-foreground">{new Date(post.createdAt).toLocaleString()}</span>
</div>
<div className="flex gap-2">
<Button size="sm" variant="outline" onClick={() => setEditing(post)}>
{t('admin.roles.edit')}
</Button>
<Button
size="sm"
variant="ghost"
disabled={deleteMutation.isPending}
onClick={() => {
if (confirm(t('admin.news.confirmDelete'))) deleteMutation.mutate(post.id)
}}
>
{t('admin.roles.delete')}
</Button>
</div>
</div>
))}
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">{t('admin.users.total', { count: data.total })}</span>
<div className="flex gap-2">
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
{t('admin.prev')}
</Button>
<Button variant="outline" size="sm" disabled={page * PAGE_SIZE >= data.total} onClick={() => setPage((p) => p + 1)}>
{t('admin.next')}
</Button>
</div>
</div>
</>
)}
{editing && <NewsFormDialog post={editing} open={!!editing} onOpenChange={(open) => !open && setEditing(null)} />}
</div>
)
}
+22
View File
@@ -0,0 +1,22 @@
import { createFileRoute } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import { useRequireAuth } from '@/features/auth/guards'
import { NewsFeed } from '@/features/news/NewsFeed'
export const Route = createFileRoute('/news')({ component: NewsPage })
function NewsPage() {
const { t } = useTranslation()
const { isReady } = useRequireAuth()
if (!isReady) return null
return (
<div className="mx-auto flex w-full max-w-4xl flex-col gap-8 px-6 py-10">
<div>
<h1 className="text-2xl font-semibold tracking-tight">{t('news.title')}</h1>
</div>
<NewsFeed />
</div>
)
}
+9
View File
@@ -89,6 +89,15 @@ export type ClientAppDto = {
/** GET /api/apps — сгруппировано по ОС на бэкенде; отсутствующая ОС значит "нет приложений". */ /** GET /api/apps — сгруппировано по ОС на бэкенде; отсутствующая ОС значит "нет приложений". */
export type AppsByOs = Partial<Record<OsPlatform, ClientAppDto[]>> export type AppsByOs = Partial<Record<OsPlatform, ClientAppDto[]>>
/** Один DTO на пользовательскую ленту и админку — у новости нет полей, скрытых от юзера. */
export type NewsPostDto = {
id: string
title: string
body: string
createdAt: string
updatedAt: string | null
}
export type LinkTokenResponse = { export type LinkTokenResponse = {
deepLink: string | null deepLink: string | null
expiresAt: string expiresAt: string
+36
View File
@@ -48,6 +48,7 @@ const resources = {
nav: { nav: {
dashboard: 'Мои конфиги', dashboard: 'Мои конфиги',
instructions: 'Инструкции', instructions: 'Инструкции',
news: 'Новости',
settings: 'Настройки', settings: 'Настройки',
admin: 'Админка', admin: 'Админка',
logout: 'Выйти', logout: 'Выйти',
@@ -114,6 +115,11 @@ const resources = {
}, },
}, },
news: {
title: 'Новости',
empty: 'Пока нет новостей.',
},
settings: { settings: {
changePassword: 'Сменить пароль', changePassword: 'Сменить пароль',
currentPassword: 'Текущий пароль', currentPassword: 'Текущий пароль',
@@ -150,6 +156,7 @@ const resources = {
roles: 'Роли', roles: 'Роли',
nodes: 'Ноды', nodes: 'Ноды',
apps: 'Приложения', apps: 'Приложения',
news: 'Новости',
audit: 'Аудит', audit: 'Аудит',
}, },
users: { users: {
@@ -251,6 +258,17 @@ const resources = {
deleted: 'Приложение удалено.', deleted: 'Приложение удалено.',
confirmDelete: 'Удалить приложение из каталога?', confirmDelete: 'Удалить приложение из каталога?',
}, },
news: {
create: 'Добавить новость',
title: 'Заголовок',
body: 'Текст (Markdown)',
preview: 'Предпросмотр',
empty: 'Новостей пока нет.',
created: 'Новость опубликована.',
updated: 'Новость обновлена.',
deleted: 'Новость удалена.',
confirmDelete: 'Удалить новость?',
},
audit: { audit: {
time: 'Время', time: 'Время',
action: 'Действие', action: 'Действие',
@@ -317,6 +335,7 @@ const resources = {
nav: { nav: {
dashboard: 'My configs', dashboard: 'My configs',
instructions: 'Instructions', instructions: 'Instructions',
news: 'News',
settings: 'Settings', settings: 'Settings',
admin: 'Admin', admin: 'Admin',
logout: 'Log out', logout: 'Log out',
@@ -383,6 +402,11 @@ const resources = {
}, },
}, },
news: {
title: 'News',
empty: 'No news yet.',
},
settings: { settings: {
changePassword: 'Change password', changePassword: 'Change password',
currentPassword: 'Current password', currentPassword: 'Current password',
@@ -419,6 +443,7 @@ const resources = {
roles: 'Roles', roles: 'Roles',
nodes: 'Nodes', nodes: 'Nodes',
apps: 'Apps', apps: 'Apps',
news: 'News',
audit: 'Audit', audit: 'Audit',
}, },
users: { users: {
@@ -520,6 +545,17 @@ const resources = {
deleted: 'App deleted.', deleted: 'App deleted.',
confirmDelete: 'Remove this app from the catalog?', confirmDelete: 'Remove this app from the catalog?',
}, },
news: {
create: 'Add post',
title: 'Title',
body: 'Body (Markdown)',
preview: 'Preview',
empty: 'No news yet.',
created: 'Post published.',
updated: 'Post updated.',
deleted: 'Post deleted.',
confirmDelete: 'Delete this post?',
},
audit: { audit: {
time: 'Time', time: 'Time',
action: 'Action', action: 'Action',
@@ -7,6 +7,7 @@ import { getConnection, startConnection, stopConnection } from './connection'
type ConfigTrafficUpdated = { configId: string; usedUpBytes: number; usedDownBytes: number } type ConfigTrafficUpdated = { configId: string; usedUpBytes: number; usedDownBytes: number }
type ConfigStatusChanged = { configId: string; status: ConfigStatus } type ConfigStatusChanged = { configId: string; status: ConfigStatus }
type UserActivated = { userId: string } type UserActivated = { userId: string }
type NewsPublished = { id: string; title: string; createdAt: string }
/** Живые обновления по SignalR: точечно патчит кэш TanStack Query вместо инвалидации всего списка. */ /** Живые обновления по SignalR: точечно патчит кэш TanStack Query вместо инвалидации всего списка. */
export function RealtimeProvider({ children }: { children: React.ReactNode }) { export function RealtimeProvider({ children }: { children: React.ReactNode }) {
@@ -49,9 +50,14 @@ export function RealtimeProvider({ children }: { children: React.ReactNode }) {
void queryClient.invalidateQueries({ queryKey: ['me-poll'] }) void queryClient.invalidateQueries({ queryKey: ['me-poll'] })
} }
const onNewsPublished = (_payload: NewsPublished) => {
void queryClient.invalidateQueries({ queryKey: ['news'] })
}
connection.on('configTrafficUpdated', onTrafficUpdated) connection.on('configTrafficUpdated', onTrafficUpdated)
connection.on('configStatusChanged', onStatusChanged) connection.on('configStatusChanged', onStatusChanged)
connection.on('userActivated', onUserActivated) connection.on('userActivated', onUserActivated)
connection.on('newsPublished', onNewsPublished)
void startConnection() void startConnection()
@@ -59,6 +65,7 @@ export function RealtimeProvider({ children }: { children: React.ReactNode }) {
connection.off('configTrafficUpdated', onTrafficUpdated) connection.off('configTrafficUpdated', onTrafficUpdated)
connection.off('configStatusChanged', onStatusChanged) connection.off('configStatusChanged', onStatusChanged)
connection.off('userActivated', onUserActivated) connection.off('userActivated', onUserActivated)
connection.off('newsPublished', onNewsPublished)
} }
}, [user, queryClient]) }, [user, queryClient])
+16
View File
@@ -0,0 +1,16 @@
import { type TextareaHTMLAttributes, forwardRef } from 'react'
import { cn } from '@/shared/lib/cn'
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaHTMLAttributes<HTMLTextAreaElement>>(
({ className, ...props }, ref) => (
<textarea
className={cn(
'flex min-h-32 w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm outline-none transition-colors placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-primary/50 disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
ref={ref}
{...props}
/>
),
)
Textarea.displayName = 'Textarea'