Add news feature with CRUD operations and real-time notifications
- 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:
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
+19
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+773
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+42
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+29
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
Клиент только слушает; группировка по пользователю происходит на сервере при подключении, по
|
Клиент только слушает; группировка по пользователю происходит на сервере при подключении, по
|
||||||
|
|||||||
@@ -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 напрямую.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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 обновляется
|
||||||
|
без перезагрузки.
|
||||||
|
|
||||||
## Скрипты
|
## Скрипты
|
||||||
|
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ PnvPanel **не заменяет** Xray/3x-ui — он оркестрирует
|
|||||||
- Telegram-бот: ссылка на сайт, просмотр конфигов, привязка/регистрация через Telegram и passwordless-вход.
|
- Telegram-бот: ссылка на сайт, просмотр конфигов, привязка/регистрация через Telegram и passwordless-вход.
|
||||||
- Светлая/тёмная тема сайта.
|
- Светлая/тёмная тема сайта.
|
||||||
- Страница инструкций по подключению + каталог приложений по ОС (админ ведёт, юзер видит сгруппировано).
|
- Страница инструкций по подключению + каталог приложений по ОС (админ ведёт, юзер видит сгруппировано).
|
||||||
|
- Лента новостей: админ публикует Markdown-посты, все пользователи видят их живой лентой (SignalR).
|
||||||
- Единый Docker-образ (фронт+бек) + PostgreSQL в docker-compose.
|
- Единый Docker-образ (фронт+бек) + PostgreSQL в docker-compose.
|
||||||
|
|
||||||
**Не реализовано:**
|
**Не реализовано:**
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Generated
+881
-2
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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' })
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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}`)
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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])
|
||||||
|
|
||||||
|
|||||||
@@ -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'
|
||||||
Reference in New Issue
Block a user