Add restore functionality for template management and enhance related components
Implemented a new endpoint for restoring templates to their last applied state, allowing users to revert changes made since the last application. Updated the ScheduleTemplate class to include a snapshot of the last applied state, enabling rollback capabilities. Enhanced the frontend with a restore button in the ChannelDetail component, providing users with a confirmation prompt before executing the restore action. Localization updates were made to support new UI strings in both English and Russian, improving the overall user experience in template management.
This commit is contained in:
@@ -11,6 +11,7 @@ using TeleWave.Application.Programming.Templates.DeleteSlot;
|
||||
using TeleWave.Application.Programming.Templates.Generate;
|
||||
using TeleWave.Application.Programming.Templates.GetTemplate;
|
||||
using TeleWave.Application.Programming.Templates.Layers;
|
||||
using TeleWave.Application.Programming.Templates.Restore;
|
||||
using TeleWave.Application.Programming.Templates.UpdateSlot;
|
||||
using TeleWave.Application.Programming.Templates.Validate;
|
||||
using TeleWave.Infrastructure.Identity;
|
||||
@@ -44,6 +45,10 @@ public static class TemplateEndpoints
|
||||
admin
|
||||
.MapPost("/channels/{channelId:guid}/template/apply", ApplyTemplate)
|
||||
.Produces<ApplyResultDto>();
|
||||
// Откат правок к состоянию, по которому идёт эфир. Ленту не трогает — она и так по нему.
|
||||
admin
|
||||
.MapPost("/channels/{channelId:guid}/template/restore", RestoreTemplate)
|
||||
.Produces<RestoreTemplateResultDto>();
|
||||
// Предпросмотр — тот же генератор, но без записи и без продвижения курсоров.
|
||||
admin
|
||||
.MapGet("/channels/{channelId:guid}/template/preview", PreviewTemplate)
|
||||
@@ -137,6 +142,16 @@ public static class TemplateEndpoints
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> RestoreTemplate(
|
||||
Guid channelId,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(new RestoreTemplateCommand(channelId), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> PreviewTemplate(
|
||||
Guid channelId,
|
||||
ISender sender,
|
||||
|
||||
@@ -161,7 +161,9 @@ public sealed class GridScheduleGenerator(
|
||||
|
||||
await SaveCursorsAsync(result.Cursors, cancellationToken);
|
||||
|
||||
template.MarkApplied();
|
||||
// Снимок вместе с отметкой: применённое состояние — единственная корректная точка отката,
|
||||
// и снимать его надо ровно тогда, когда по нему собрана лента.
|
||||
template.MarkApplied(TemplateSnapshot.Capture(template).ToJson());
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
|
||||
+1
@@ -93,6 +93,7 @@ public sealed class GetChannelTemplateQueryHandler(IAppDbContext dbContext)
|
||||
template.Revision,
|
||||
template.AppliedRevision,
|
||||
template.HasPendingChanges,
|
||||
template.AppliedSnapshotJson is not null,
|
||||
channel.UtcOffsetMinutes,
|
||||
channel.DayStartTime,
|
||||
layers
|
||||
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Programming.Templates.Restore;
|
||||
|
||||
/// <summary>
|
||||
/// Откат правок сетки к тому состоянию, по которому идёт эфир. Возвращает шаблон к снимку,
|
||||
/// сделанному при последнем применении, — правки слоёв, слотов и правил после него отменяются.
|
||||
/// </summary>
|
||||
public sealed record RestoreTemplateCommand(Guid ChannelId)
|
||||
: ICommand<Result<RestoreTemplateResultDto>>;
|
||||
|
||||
/// <param name="Slots">Сколько слотов в сетке после отката.</param>
|
||||
/// <param name="DroppedRefs">
|
||||
/// Ссылок, которые восстановить не удалось: группа или стык из снимка успели удалить. Такие слоты
|
||||
/// возвращаются без них — иначе откат уронил бы вся операцию из-за одной удалённой группы.
|
||||
/// </param>
|
||||
public sealed record RestoreTemplateResultDto(int Slots, int DroppedRefs);
|
||||
+182
@@ -0,0 +1,182 @@
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Broadcast;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
using TeleWave.Domain.Programming;
|
||||
|
||||
namespace TeleWave.Application.Programming.Templates.Restore;
|
||||
|
||||
/// <summary>
|
||||
/// Возвращает шаблон к снимку последнего применения. Слои и слоты правятся на месте по
|
||||
/// идентификаторам из снимка: у пережившего откат слота сохраняется состояние (курсор сериала),
|
||||
/// и после отката сериал не начинается заново. Слот, который успели удалить, вернётся новым —
|
||||
/// его состояние ушло вместе с ним, и сделать с этим ничего нельзя.
|
||||
///
|
||||
/// Эфир не трогается: он и так идёт по этому состоянию, а шаблон после отката снова ему равен.
|
||||
/// </summary>
|
||||
public sealed class RestoreTemplateCommandHandler(IAppDbContext dbContext)
|
||||
: ICommandHandler<RestoreTemplateCommand, Result<RestoreTemplateResultDto>>
|
||||
{
|
||||
public async Task<Result<RestoreTemplateResultDto>> Handle(
|
||||
RestoreTemplateCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var template = await dbContext
|
||||
.ScheduleTemplates.Include(t => t.Layers)
|
||||
.ThenInclude(l => l.Slots)
|
||||
.AsSplitQuery()
|
||||
.FirstOrDefaultAsync(t => t.ChannelId == command.ChannelId, cancellationToken);
|
||||
if (template is null)
|
||||
return Result.Failure<RestoreTemplateResultDto>(ChannelErrors.TemplateNotFound);
|
||||
|
||||
if (TemplateSnapshot.FromJson(template.AppliedSnapshotJson) is not { } snapshot)
|
||||
return Result.Failure<RestoreTemplateResultDto>(TemplateErrors.NothingToRestore);
|
||||
|
||||
var refs = await LoadAliveRefsAsync(snapshot, cancellationToken);
|
||||
|
||||
// Слои, которых в снимке нет, — заведены после применения. Фоновый не трогаем: он есть
|
||||
// в любом шаблоне и удалению не подлежит.
|
||||
foreach (var layer in template.Layers.ToList())
|
||||
if (!layer.IsBackground && snapshot.Layers.All(l => l.Id != layer.Id))
|
||||
template.RemoveLayer(layer.Id);
|
||||
|
||||
foreach (var layerSnapshot in snapshot.Layers)
|
||||
RestoreLayer(template, layerSnapshot, refs);
|
||||
|
||||
template.Rename(snapshot.Name);
|
||||
template.SetFallbackGroup(refs.GroupOrNull(snapshot.FallbackGroupId));
|
||||
template.SetDefaultJunction(refs.JunctionOrNull(snapshot.DefaultJunctionId));
|
||||
template.SetRules(snapshot.RulesJson);
|
||||
|
||||
// Шаблон снова равен эфирному состоянию — отметка «изменено» гаснет, снимок остаётся тем же.
|
||||
template.MarkApplied();
|
||||
|
||||
return Result.Success(
|
||||
new RestoreTemplateResultDto(template.Layers.Sum(l => l.Slots.Count), refs.Dropped)
|
||||
);
|
||||
}
|
||||
|
||||
private static void RestoreLayer(
|
||||
ScheduleTemplate template,
|
||||
LayerSnapshot snapshot,
|
||||
AliveRefs refs
|
||||
)
|
||||
{
|
||||
var layer =
|
||||
template.FindLayer(snapshot.Id)
|
||||
?? (snapshot.IsBackground ? template.Background : null)
|
||||
?? template.AddLayer(snapshot.Name, snapshot.Priority);
|
||||
|
||||
layer.Update(
|
||||
snapshot.Name,
|
||||
snapshot.Priority,
|
||||
snapshot.ApplicabilityJson,
|
||||
snapshot.IsEnabled
|
||||
);
|
||||
|
||||
foreach (var slot in layer.Slots.ToList())
|
||||
if (snapshot.Slots.All(s => s.Id != slot.Id))
|
||||
layer.RemoveSlot(slot.Id);
|
||||
|
||||
foreach (var slotSnapshot in snapshot.Slots)
|
||||
RestoreSlot(layer, slotSnapshot, refs);
|
||||
}
|
||||
|
||||
private static void RestoreSlot(GridLayer layer, SlotSnapshot snapshot, AliveRefs refs)
|
||||
{
|
||||
var slot =
|
||||
layer.FindSlot(snapshot.Id)
|
||||
?? layer.AddSlot(
|
||||
snapshot.Title,
|
||||
snapshot.TargetStart,
|
||||
snapshot.TargetDurationMinutes,
|
||||
snapshot.Daypart,
|
||||
snapshot.SlotKind,
|
||||
snapshot.Weekday
|
||||
);
|
||||
|
||||
slot.UpdateTiming(
|
||||
snapshot.Weekday,
|
||||
snapshot.TargetStart,
|
||||
snapshot.TargetDurationMinutes,
|
||||
snapshot.Daypart,
|
||||
snapshot.IsAnchor,
|
||||
snapshot.MaxDriftMinutes,
|
||||
snapshot.SnapToMinutes
|
||||
);
|
||||
slot.UpdateContent(
|
||||
new SlotContent(
|
||||
snapshot.Title,
|
||||
snapshot.SlotKind,
|
||||
refs.GroupOrNull(snapshot.GroupId),
|
||||
snapshot.StrategyJson,
|
||||
snapshot.RepeatSourceJson,
|
||||
snapshot.BlockMode,
|
||||
snapshot.BlockValue,
|
||||
snapshot.OverflowPolicy,
|
||||
refs.JunctionOrNull(snapshot.JunctionBetweenId),
|
||||
refs.JunctionOrNull(snapshot.JunctionAfterId)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Какие группы и стыки из снимка ещё существуют. Ссылку на удалённое восстанавливать нельзя —
|
||||
/// внешний ключ не даст сохранить, и откат упал бы целиком из-за одной прибранной группы.
|
||||
/// </summary>
|
||||
private async Task<AliveRefs> LoadAliveRefsAsync(
|
||||
TemplateSnapshot snapshot,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var slots = snapshot.Layers.SelectMany(l => l.Slots).ToList();
|
||||
|
||||
var groupIds = slots
|
||||
.Select(s => s.GroupId)
|
||||
.Append(snapshot.FallbackGroupId)
|
||||
.OfType<Guid>()
|
||||
.Distinct()
|
||||
.ToList();
|
||||
var junctionIds = slots
|
||||
.SelectMany(s => new[] { s.JunctionBetweenId, s.JunctionAfterId })
|
||||
.Append(snapshot.DefaultJunctionId)
|
||||
.OfType<Guid>()
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
var groups = await dbContext
|
||||
.Groups.AsNoTracking()
|
||||
.Where(g => groupIds.Contains(g.Id))
|
||||
.Select(g => g.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
var junctions = await dbContext
|
||||
.JunctionTemplates.AsNoTracking()
|
||||
.Where(j => junctionIds.Contains(j.Id))
|
||||
.Select(j => j.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return new AliveRefs([.. groups], [.. junctions]);
|
||||
}
|
||||
|
||||
private sealed class AliveRefs(HashSet<Guid> groups, HashSet<Guid> junctions)
|
||||
{
|
||||
public int Dropped { get; private set; }
|
||||
|
||||
public Guid? GroupOrNull(Guid? id) => Keep(id, groups);
|
||||
|
||||
public Guid? JunctionOrNull(Guid? id) => Keep(id, junctions);
|
||||
|
||||
private Guid? Keep(Guid? id, HashSet<Guid> alive)
|
||||
{
|
||||
if (id is not { } value)
|
||||
return null;
|
||||
if (alive.Contains(value))
|
||||
return value;
|
||||
|
||||
Dropped++;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,8 @@ public sealed record ScheduleTemplateDto(
|
||||
int AppliedRevision,
|
||||
/// <summary>Есть ли правки правил, ещё не применённые к эфиру.</summary>
|
||||
bool HasPendingChanges,
|
||||
/// <summary>Есть ли к чему откатываться — снимок появляется при первом применении.</summary>
|
||||
bool CanRestore,
|
||||
/// <summary>Время канала — сетка задаётся в нём, а не в UTC.</summary>
|
||||
int UtcOffsetMinutes,
|
||||
TimeOnly DayStartTime,
|
||||
|
||||
@@ -59,6 +59,11 @@ public static class TemplateErrors
|
||||
"Врезке нужна группа, откуда брать ролики."
|
||||
);
|
||||
|
||||
public static readonly Error NothingToRestore = Error.Validation(
|
||||
"Templates.NothingToRestore",
|
||||
"Откатывать не к чему: сетка ещё ни разу не применялась."
|
||||
);
|
||||
|
||||
public static readonly Error NoGroupsToGenerate = Error.Validation(
|
||||
"Templates.NoGroupsToGenerate",
|
||||
"Собирать сетку не из чего: нет ни одной группы с готовым медиа."
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using TeleWave.Domain.Programming;
|
||||
|
||||
namespace TeleWave.Application.Programming.Templates;
|
||||
|
||||
/// <summary>Слот в снимке. Идентификатор сохраняется — по нему откат правит слот на месте.</summary>
|
||||
public sealed record SlotSnapshot(
|
||||
Guid Id,
|
||||
int? Weekday,
|
||||
TimeOnly TargetStart,
|
||||
int TargetDurationMinutes,
|
||||
string Title,
|
||||
Daypart Daypart,
|
||||
SlotKind SlotKind,
|
||||
Guid? GroupId,
|
||||
string? StrategyJson,
|
||||
string? RepeatSourceJson,
|
||||
SlotBlockMode BlockMode,
|
||||
int BlockValue,
|
||||
OverflowPolicy OverflowPolicy,
|
||||
bool IsAnchor,
|
||||
int MaxDriftMinutes,
|
||||
int? SnapToMinutes,
|
||||
Guid? JunctionBetweenId,
|
||||
Guid? JunctionAfterId
|
||||
);
|
||||
|
||||
public sealed record LayerSnapshot(
|
||||
Guid Id,
|
||||
string Name,
|
||||
int Priority,
|
||||
string? ApplicabilityJson,
|
||||
bool IsEnabled,
|
||||
bool IsBackground,
|
||||
IReadOnlyList<SlotSnapshot> Slots
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Слепок шаблона: слои, слоты и настройки на момент применения. Снимается при каждом применении —
|
||||
/// это ровно то, по чему идёт эфир, и потому корректная точка отката.
|
||||
///
|
||||
/// Хранится JSON-строкой в самом шаблоне, а не отдельной таблицей: снимок читается только целиком
|
||||
/// и только при откате, и своя таблица со слоями и слотами задвоила бы всю схему сетки — включая
|
||||
/// внешние ключи на группы, которые к моменту отката могут быть уже удалены.
|
||||
/// </summary>
|
||||
public sealed record TemplateSnapshot(
|
||||
string Name,
|
||||
Guid? FallbackGroupId,
|
||||
Guid? DefaultJunctionId,
|
||||
string? RulesJson,
|
||||
IReadOnlyList<LayerSnapshot> Layers
|
||||
)
|
||||
{
|
||||
private static readonly JsonSerializerOptions Options = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
Converters = { new JsonStringEnumConverter() },
|
||||
};
|
||||
|
||||
public static TemplateSnapshot Capture(ScheduleTemplate template) =>
|
||||
new(
|
||||
template.Name,
|
||||
template.FallbackGroupId,
|
||||
template.DefaultJunctionId,
|
||||
template.RulesJson,
|
||||
[
|
||||
.. template.Layers.Select(layer => new LayerSnapshot(
|
||||
layer.Id,
|
||||
layer.Name,
|
||||
layer.Priority,
|
||||
layer.ApplicabilityJson,
|
||||
layer.IsEnabled,
|
||||
layer.IsBackground,
|
||||
[
|
||||
.. layer.Slots.Select(slot => new SlotSnapshot(
|
||||
slot.Id,
|
||||
slot.Weekday,
|
||||
slot.TargetStart,
|
||||
slot.TargetDurationMinutes,
|
||||
slot.Title,
|
||||
slot.Daypart,
|
||||
slot.SlotKind,
|
||||
slot.GroupId,
|
||||
slot.StrategyJson,
|
||||
slot.RepeatSourceJson,
|
||||
slot.BlockMode,
|
||||
slot.BlockValue,
|
||||
slot.OverflowPolicy,
|
||||
slot.IsAnchor,
|
||||
slot.MaxDriftMinutes,
|
||||
slot.SnapToMinutes,
|
||||
slot.JunctionBetweenId,
|
||||
slot.JunctionAfterId
|
||||
)),
|
||||
]
|
||||
)),
|
||||
]
|
||||
);
|
||||
|
||||
public string ToJson() => JsonSerializer.Serialize(this, Options);
|
||||
|
||||
/// <summary>Разбирает снимок. Повреждённый JSON — как отсутствие снимка: откатывать не к чему.</summary>
|
||||
public static TemplateSnapshot? FromJson(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<TemplateSnapshot>(json, Options);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,14 @@ public class ScheduleTemplate
|
||||
/// <summary>Ревизия, по которой собрано текущее будущее расписание.</summary>
|
||||
public int AppliedRevision { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Состояние шаблона на момент последнего применения (JSON) — то, по чему идёт эфир прямо
|
||||
/// сейчас. Нужно, чтобы правки можно было откатить: слои и слоты правятся на месте, и без
|
||||
/// снимка «как было» взять неоткуда. Домен его не разбирает — схема живёт в Application, как
|
||||
/// у правил и стратегий.
|
||||
/// </summary>
|
||||
public string? AppliedSnapshotJson { get; private set; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; private set; }
|
||||
|
||||
public IReadOnlyList<GridLayer> Layers => _layers;
|
||||
@@ -80,8 +88,16 @@ public class ScheduleTemplate
|
||||
/// <summary>Отметить, что правила изменились — эфир пойдёт по старым до применения.</summary>
|
||||
public void MarkChanged() => Revision++;
|
||||
|
||||
/// <summary>Отметить, что хвост пересобран по текущей ревизии.</summary>
|
||||
public void MarkApplied() => AppliedRevision = Revision;
|
||||
/// <summary>
|
||||
/// Отметить, что хвост пересобран по текущей ревизии. Снимок — состояние, по которому собран
|
||||
/// эфир; он же станет точкой отката для последующих правок.
|
||||
/// </summary>
|
||||
public void MarkApplied(string? snapshotJson = null)
|
||||
{
|
||||
AppliedRevision = Revision;
|
||||
if (!string.IsNullOrWhiteSpace(snapshotJson))
|
||||
AppliedSnapshotJson = snapshotJson;
|
||||
}
|
||||
|
||||
public GridLayer? FindLayer(Guid layerId) => _layers.FirstOrDefault(l => l.Id == layerId);
|
||||
|
||||
|
||||
Generated
+1410
File diff suppressed because it is too large
Load Diff
+27
@@ -0,0 +1,27 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace TeleWave.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class TemplateAppliedSnapshot : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "AppliedSnapshotJson",
|
||||
table: "ScheduleTemplates",
|
||||
type: "jsonb",
|
||||
nullable: true
|
||||
);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(name: "AppliedSnapshotJson", table: "ScheduleTemplates");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -944,6 +944,9 @@ namespace TeleWave.Infrastructure.Migrations
|
||||
b.Property<int>("AppliedRevision")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("AppliedSnapshotJson")
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<Guid>("ChannelId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
|
||||
+1
@@ -11,6 +11,7 @@ public class ScheduleTemplateConfiguration : IEntityTypeConfiguration<ScheduleTe
|
||||
{
|
||||
builder.Property(x => x.Name).IsRequired().HasMaxLength(256);
|
||||
builder.Property(x => x.RulesJson).HasColumnType("jsonb");
|
||||
builder.Property(x => x.AppliedSnapshotJson).HasColumnType("jsonb");
|
||||
builder.HasIndex(x => x.ChannelId);
|
||||
|
||||
builder
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Programming.Templates;
|
||||
using TeleWave.Application.Programming.Templates.Restore;
|
||||
using TeleWave.Application.Tests.Support;
|
||||
using TeleWave.Domain.Broadcast;
|
||||
using TeleWave.Domain.Programming;
|
||||
using Xunit;
|
||||
|
||||
namespace TeleWave.Application.Tests.Programming;
|
||||
|
||||
/// <summary>
|
||||
/// Откат правок сетки к применённому состоянию: что вернулось, что удалилось и что при этом
|
||||
/// сохранило свой идентификатор — от него зависит, переживёт ли откат курсор сериала.
|
||||
/// </summary>
|
||||
public class RestoreTemplateTests
|
||||
{
|
||||
private static readonly DateTimeOffset T0 = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
|
||||
/// <summary>Канал с применённой сеткой: один слот и снятый по нему снимок.</summary>
|
||||
private static async Task<(Guid ChannelId, Guid SlotId)> SeedAppliedAsync(
|
||||
TestDb fixture,
|
||||
Guid? groupId = null
|
||||
)
|
||||
{
|
||||
var channel = Channel.Create("Первый", "first", T0);
|
||||
var template = ScheduleTemplate.Create(channel.Id, "Сетка");
|
||||
channel.SetTemplate(template.Id);
|
||||
|
||||
var layer = template.AddLayer("Основная сетка", 10);
|
||||
var slot = layer.AddSlot("Вечернее кино", new TimeOnly(20, 0), 120);
|
||||
slot.UpdateContent(
|
||||
new SlotContent(
|
||||
"Вечернее кино",
|
||||
SlotKind.Content,
|
||||
groupId,
|
||||
null,
|
||||
null,
|
||||
SlotBlockMode.FillSlot,
|
||||
1,
|
||||
OverflowPolicy.ContinueNext
|
||||
)
|
||||
);
|
||||
|
||||
// Применение снимает слепок — именно к нему потом откатываемся.
|
||||
template.MarkApplied(TemplateSnapshot.Capture(template).ToJson());
|
||||
|
||||
await using var seed = fixture.New();
|
||||
seed.Channels.Add(channel);
|
||||
seed.ScheduleTemplates.Add(template);
|
||||
await seed.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
return (channel.Id, slot.Id);
|
||||
}
|
||||
|
||||
private static async Task<ScheduleTemplate> LoadAsync(TestDb fixture)
|
||||
{
|
||||
await using var db = fixture.New();
|
||||
return await db
|
||||
.ScheduleTemplates.AsNoTracking()
|
||||
.Include(t => t.Layers)
|
||||
.ThenInclude(l => l.Slots)
|
||||
.AsSplitQuery()
|
||||
.FirstAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
private static async Task<RestoreTemplateResultDto> RestoreAsync(TestDb fixture, Guid channelId)
|
||||
{
|
||||
await using var db = fixture.New();
|
||||
var result = await new RestoreTemplateCommandHandler(db).Handle(
|
||||
new RestoreTemplateCommand(channelId),
|
||||
CancellationToken.None
|
||||
);
|
||||
Assert.True(result.IsSuccess);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
return result.Value;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RevertsEditedSlot_KeepingItsIdentity()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
var (channelId, slotId) = await SeedAppliedAsync(fixture);
|
||||
|
||||
await using (var db = fixture.New())
|
||||
{
|
||||
var template = await db
|
||||
.ScheduleTemplates.Include(t => t.Layers)
|
||||
.ThenInclude(l => l.Slots)
|
||||
.FirstAsync(CancellationToken.None);
|
||||
var slot = template.Layers.SelectMany(l => l.Slots).Single();
|
||||
slot.UpdateTiming(3, new TimeOnly(8, 0), 30, Daypart.Morning, false, 5, null);
|
||||
template.MarkChanged();
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
var result = await RestoreAsync(fixture, channelId);
|
||||
|
||||
Assert.Equal(1, result.Slots);
|
||||
var restored = (await LoadAsync(fixture)).Layers.SelectMany(l => l.Slots).Single();
|
||||
Assert.Equal(new TimeOnly(20, 0), restored.TargetStart);
|
||||
Assert.Equal(120, restored.TargetDurationMinutes);
|
||||
Assert.Null(restored.Weekday);
|
||||
// Тот же слот, а не копия: с его идентификатором связано состояние (курсор сериала).
|
||||
Assert.Equal(slotId, restored.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RemovesWhatAppearedAfterApply_AndBringsBackWhatWasDeleted()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
var (channelId, slotId) = await SeedAppliedAsync(fixture);
|
||||
|
||||
await using (var db = fixture.New())
|
||||
{
|
||||
var template = await db
|
||||
.ScheduleTemplates.Include(t => t.Layers)
|
||||
.ThenInclude(l => l.Slots)
|
||||
.FirstAsync(CancellationToken.None);
|
||||
var layer = template.Layers.Single(l => !l.IsBackground);
|
||||
layer.RemoveSlot(slotId);
|
||||
layer.AddSlot("Лишний", new TimeOnly(10, 0), 60);
|
||||
template.AddLayer("Лишний слой", 20);
|
||||
template.MarkChanged();
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
await RestoreAsync(fixture, channelId);
|
||||
|
||||
var restored = await LoadAsync(fixture);
|
||||
var slot = Assert.Single(restored.Layers.SelectMany(l => l.Slots));
|
||||
Assert.Equal("Вечернее кино", slot.Title);
|
||||
Assert.DoesNotContain(restored.Layers, l => l.Name == "Лишний слой");
|
||||
// Удалённый слот возвращается новым: его состояние ушло вместе с ним, вернуть нечего.
|
||||
Assert.NotEqual(slotId, slot.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ClearsPendingFlag()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
var (channelId, _) = await SeedAppliedAsync(fixture);
|
||||
|
||||
await using (var db = fixture.New())
|
||||
{
|
||||
var template = await db.ScheduleTemplates.FirstAsync(CancellationToken.None);
|
||||
template.SetRules("{\"maxBreakMinutesPerHour\":10}");
|
||||
template.MarkChanged();
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
Assert.True(template.HasPendingChanges);
|
||||
}
|
||||
|
||||
await RestoreAsync(fixture, channelId);
|
||||
|
||||
var restored = await LoadAsync(fixture);
|
||||
Assert.False(restored.HasPendingChanges);
|
||||
Assert.Null(restored.RulesJson);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DropsReferenceToGroupDeletedAfterApply()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
var group = Group.Create("Кино");
|
||||
Guid channelId;
|
||||
|
||||
await using (var seed = fixture.New())
|
||||
{
|
||||
seed.Groups.Add(group);
|
||||
await seed.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
(channelId, _) = await SeedAppliedAsync(fixture, group.Id);
|
||||
|
||||
// Слот удалили, следом удалили и группу — ссылка в снимке осталась висеть.
|
||||
await using (var db = fixture.New())
|
||||
{
|
||||
var template = await db
|
||||
.ScheduleTemplates.Include(t => t.Layers)
|
||||
.ThenInclude(l => l.Slots)
|
||||
.FirstAsync(CancellationToken.None);
|
||||
var layer = template.Layers.Single(l => !l.IsBackground);
|
||||
layer.RemoveSlot(layer.Slots[0].Id);
|
||||
db.Groups.Remove(await db.Groups.FirstAsync(CancellationToken.None));
|
||||
template.MarkChanged();
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
var result = await RestoreAsync(fixture, channelId);
|
||||
|
||||
// Откат не падает из-за прибранной группы: слот возвращается без неё, а потеря сосчитана.
|
||||
Assert.Equal(1, result.DroppedRefs);
|
||||
var slot = (await LoadAsync(fixture)).Layers.SelectMany(l => l.Slots).Single();
|
||||
Assert.Null(slot.GroupId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Fails_WhenTemplateWasNeverApplied()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
var channel = Channel.Create("Первый", "first", T0);
|
||||
var template = ScheduleTemplate.Create(channel.Id, "Сетка");
|
||||
channel.SetTemplate(template.Id);
|
||||
|
||||
await using (var seed = fixture.New())
|
||||
{
|
||||
seed.Channels.Add(channel);
|
||||
seed.ScheduleTemplates.Add(template);
|
||||
await seed.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
await using var db = fixture.New();
|
||||
var result = await new RestoreTemplateCommandHandler(db).Handle(
|
||||
new RestoreTemplateCommand(channel.Id),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
Assert.Equal(TemplateErrors.NothingToRestore, result.Error);
|
||||
}
|
||||
}
|
||||
@@ -822,6 +822,21 @@ seed = hash(channelId, date, slotId, occurrenceInDay)
|
||||
Регулируется по силе, по умолчанию выключен: попадает точно в цель для канала «как в 96-м», но
|
||||
переборщить очень легко.
|
||||
|
||||
### 6.6.1. Откат правок
|
||||
|
||||
У плашки «правила изменены» две кнопки: применить и **сбросить**. Слои и слоты правятся на месте,
|
||||
поэтому «как было» взять неоткуда — при каждом применении шаблон снимается слепком
|
||||
(`ScheduleTemplate.AppliedSnapshotJson`, схема `TemplateSnapshot` в Application). Это ровно то
|
||||
состояние, по которому собрана лента, и потому единственная корректная точка отката.
|
||||
|
||||
Откат правит слои и слоты **на месте по идентификаторам из снимка**: у пережившего откат слота
|
||||
сохраняется `SlotState`, и сериал не начинается заново. Удалённый после применения слот вернётся
|
||||
новым — его состояние ушло вместе с ним. Ссылка на группу или стык, удалённые после применения,
|
||||
не восстанавливается (внешний ключ), слот возвращается без неё, потеря считается и показывается.
|
||||
|
||||
Эфир при откате не трогается: он и так идёт по этому состоянию, а шаблон после отката снова ему
|
||||
равен — `Revision` приравнивается к `AppliedRevision`, плашка гаснет.
|
||||
|
||||
### 6.9. Автосборка сетки по профилю
|
||||
|
||||
Собрать неделю руками — это несколько десятков слотов, и до первого эфира новый канал не доживает.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { ChevronLeft, Send } from 'lucide-react'
|
||||
import { ChevronLeft, Send, Undo2 } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { listAllMedia } from '@/features/admin/media/api'
|
||||
@@ -11,7 +11,13 @@ import { Badge } from '@/shared/ui/badge'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Card, CardContent } from '@/shared/ui/card'
|
||||
import { toast } from '@/shared/ui/toast-store'
|
||||
import { applyChannelTemplate, getChannel, getChannelTemplate, getSchedule } from './api'
|
||||
import {
|
||||
applyChannelTemplate,
|
||||
getChannel,
|
||||
getChannelTemplate,
|
||||
getSchedule,
|
||||
restoreChannelTemplate,
|
||||
} from './api'
|
||||
import { ApplyDialog } from './components/ApplyDialog'
|
||||
import { BumperCard } from './components/BumperCard'
|
||||
import { EntryTraceDialog } from './components/EntryTraceDialog'
|
||||
@@ -70,6 +76,18 @@ export function ChannelDetail({ channelId }: Readonly<{ channelId: string }>) {
|
||||
onError,
|
||||
})
|
||||
|
||||
const restoreMutation = useMutation({
|
||||
mutationFn: () => restoreChannelTemplate(channelId),
|
||||
onSuccess: (result) => {
|
||||
toast.success(t('admin.channels.restored', { count: result.slots }))
|
||||
// Ссылка на удалённую группу или стык вернуться не может — говорим об этом прямо.
|
||||
if (result.droppedRefs > 0)
|
||||
toast.error(t('admin.channels.restoreDropped', { count: result.droppedRefs }))
|
||||
invalidate()
|
||||
},
|
||||
onError,
|
||||
})
|
||||
|
||||
if (isLoading || !channel) return <p className="text-muted-foreground">{t('common.loading')}</p>
|
||||
|
||||
return (
|
||||
@@ -89,13 +107,31 @@ export function ChannelDetail({ channelId }: Readonly<{ channelId: string }>) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Правка правил эфира не двигает — применение отдельной кнопкой. */}
|
||||
{/* Правка правил эфира не двигает — применение отдельной кнопкой. Рядом откат: правки
|
||||
сетки правятся на месте, и без этой кнопки вернуть «как в эфире» было бы нечем. */}
|
||||
{template?.hasPendingChanges && (
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 rounded-md border border-amber-500/50 bg-amber-500/10 px-4 py-2 text-sm">
|
||||
<span>{t('admin.channels.pendingChanges')}</span>
|
||||
<Button size="sm" disabled={applyMutation.isPending} onClick={() => setApplyOpen(true)}>
|
||||
<Send className="h-4 w-4" /> {t('admin.channels.apply')}
|
||||
</Button>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{/* Кнопки отката нет, пока сетку ни разу не применяли: возвращаться некуда. */}
|
||||
{template.canRestore && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={restoreMutation.isPending}
|
||||
onClick={() => {
|
||||
// Откат необратим — он стирает всё, сделанное с последнего применения.
|
||||
if (!window.confirm(t('admin.channels.restoreConfirm'))) return
|
||||
restoreMutation.mutate()
|
||||
}}
|
||||
>
|
||||
<Undo2 className="h-4 w-4" /> {t('admin.channels.restore')}
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" disabled={applyMutation.isPending} onClick={() => setApplyOpen(true)}>
|
||||
<Send className="h-4 w-4" /> {t('admin.channels.apply')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import type {
|
||||
JunctionTemplateDto,
|
||||
LayerApplicability,
|
||||
PlanningRules,
|
||||
RestoreTemplateResultDto,
|
||||
ScheduleDiffDto,
|
||||
ScheduleEntryDto,
|
||||
SchedulePreviewDto,
|
||||
@@ -84,6 +85,16 @@ export function applyChannelTemplate(channelId: string) {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Откат правок сетки к состоянию, по которому идёт эфир: восстанавливает слои, слоты и правила
|
||||
* из снимка последнего применения. Ленту не трогает — она и так по этому состоянию.
|
||||
*/
|
||||
export function restoreChannelTemplate(channelId: string) {
|
||||
return apiRequest<RestoreTemplateResultDto>(`/admin/channels/${channelId}/template/restore`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
/** Проверки сетки по правилам — считаются по шаблону, без прогона генератора. */
|
||||
export function getTemplateIssues(channelId: string) {
|
||||
return apiRequest<TemplateIssueDto[]>(`/admin/channels/${channelId}/template/issues`)
|
||||
|
||||
@@ -120,37 +120,35 @@ export function SlotInspector({
|
||||
// Отдельным окном, а не панелью под сеткой: форма длинная, и раньше она уезжала за нижний край,
|
||||
// а сетка — то, с чем её сверяют, — уходила из виду.
|
||||
<Dialog open onOpenChange={(next) => !next && onClose()}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogContent className="flex max-h-[90vh] w-[calc(100%-2rem)] max-w-4xl flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{draft.slot ? t('admin.channels.editSlot') : t('admin.channels.newSlot')}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex max-h-[65vh] flex-col gap-3 overflow-y-auto pr-1 text-sm">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="grid min-h-0 flex-1 gap-3 overflow-y-auto pr-1 text-sm sm:grid-cols-2">
|
||||
<div className="flex flex-col gap-1.5 sm:col-span-2">
|
||||
<Label>{t('admin.channels.slotTitle')}</Label>
|
||||
<Input value={body.title} onChange={(e) => patch({ title: e.target.value })} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.slotStart')}</Label>
|
||||
<Input
|
||||
type="time"
|
||||
value={body.targetStart.slice(0, 5)}
|
||||
onChange={(e) => patch({ targetStart: `${e.target.value}:00` })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.slotDuration')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={body.targetDurationMinutes}
|
||||
onChange={(e) => patch({ targetDurationMinutes: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.slotStart')}</Label>
|
||||
<Input
|
||||
type="time"
|
||||
value={body.targetStart.slice(0, 5)}
|
||||
onChange={(e) => patch({ targetStart: `${e.target.value}:00` })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.slotDuration')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={body.targetDurationMinutes}
|
||||
onChange={(e) => patch({ targetDurationMinutes: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
@@ -171,49 +169,47 @@ export function SlotInspector({
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.daypart')}</Label>
|
||||
<select
|
||||
className="h-9 rounded-md border border-border bg-transparent px-2"
|
||||
value={body.daypart}
|
||||
onChange={(e) => patch({ daypart: e.target.value as Daypart })}
|
||||
>
|
||||
{DAYPARTS.map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{t(`admin.channels.dayparts.${value}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.slotKind')}</Label>
|
||||
<select
|
||||
className="h-9 rounded-md border border-border bg-transparent px-2"
|
||||
value={body.slotKind}
|
||||
onChange={(e) => {
|
||||
const slotKind = e.target.value as SlotKind
|
||||
patch({
|
||||
slotKind,
|
||||
// Повтору нужен источник, конец вещания не берёт контент вовсе.
|
||||
repeatSource:
|
||||
slotKind === 'Repeat'
|
||||
? (body.repeatSource ?? {
|
||||
daysAgo: 1,
|
||||
time: '20:00:00',
|
||||
durationMinutes: 90,
|
||||
})
|
||||
: null,
|
||||
})
|
||||
}}
|
||||
>
|
||||
{SLOT_KINDS.map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{t(`admin.channels.slotKinds.${value}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.daypart')}</Label>
|
||||
<select
|
||||
className="h-9 rounded-md border border-border bg-transparent px-2"
|
||||
value={body.daypart}
|
||||
onChange={(e) => patch({ daypart: e.target.value as Daypart })}
|
||||
>
|
||||
{DAYPARTS.map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{t(`admin.channels.dayparts.${value}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.slotKind')}</Label>
|
||||
<select
|
||||
className="h-9 rounded-md border border-border bg-transparent px-2"
|
||||
value={body.slotKind}
|
||||
onChange={(e) => {
|
||||
const slotKind = e.target.value as SlotKind
|
||||
patch({
|
||||
slotKind,
|
||||
// Повтору нужен источник, конец вещания не берёт контент вовсе.
|
||||
repeatSource:
|
||||
slotKind === 'Repeat'
|
||||
? (body.repeatSource ?? {
|
||||
daysAgo: 1,
|
||||
time: '20:00:00',
|
||||
durationMinutes: 90,
|
||||
})
|
||||
: null,
|
||||
})
|
||||
}}
|
||||
>
|
||||
{SLOT_KINDS.map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{t(`admin.channels.slotKinds.${value}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{isContent && (
|
||||
@@ -280,33 +276,31 @@ export function SlotInspector({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.blockMode')}</Label>
|
||||
<select
|
||||
className="h-9 rounded-md border border-border bg-transparent px-2"
|
||||
value={body.blockMode}
|
||||
onChange={(e) => patch({ blockMode: e.target.value as SlotBlockMode })}
|
||||
>
|
||||
{BLOCK_MODES.map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{t(`admin.channels.blockModes.${value}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{body.blockMode !== 'FillSlot' && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.blockValue')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={body.blockValue}
|
||||
onChange={(e) => patch({ blockValue: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.blockMode')}</Label>
|
||||
<select
|
||||
className="h-9 rounded-md border border-border bg-transparent px-2"
|
||||
value={body.blockMode}
|
||||
onChange={(e) => patch({ blockMode: e.target.value as SlotBlockMode })}
|
||||
>
|
||||
{BLOCK_MODES.map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{t(`admin.channels.blockModes.${value}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{body.blockMode !== 'FillSlot' && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.blockValue')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={body.blockValue}
|
||||
onChange={(e) => patch({ blockValue: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.overflow')}</Label>
|
||||
@@ -327,7 +321,7 @@ export function SlotInspector({
|
||||
)}
|
||||
|
||||
{body.slotKind === 'Repeat' && body.repeatSource && (
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div className="grid grid-cols-3 gap-2 sm:col-span-2">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.repeatDaysAgo')}</Label>
|
||||
<Input
|
||||
@@ -370,7 +364,7 @@ export function SlotInspector({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div className="flex flex-wrap items-end gap-3 sm:col-span-2">
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -408,40 +402,40 @@ export function SlotInspector({
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t('admin.channels.anchorHint')}</p>
|
||||
<p className="text-xs text-muted-foreground sm:col-span-2">
|
||||
{t('admin.channels.anchorHint')}
|
||||
</p>
|
||||
|
||||
{/* Стыки: внутри слота — между единицами, после слота — на переходе к следующему. */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.junctionBetween')}</Label>
|
||||
<select
|
||||
className="h-9 rounded-md border border-border bg-transparent px-2"
|
||||
value={body.junctionBetweenId ?? ''}
|
||||
onChange={(e) => patch({ junctionBetweenId: e.target.value || null })}
|
||||
>
|
||||
<option value="">{t('admin.channels.junctionDefault')}</option>
|
||||
{(junctions ?? []).map((junction) => (
|
||||
<option key={junction.id} value={junction.id}>
|
||||
{junction.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.junctionAfter')}</Label>
|
||||
<select
|
||||
className="h-9 rounded-md border border-border bg-transparent px-2"
|
||||
value={body.junctionAfterId ?? ''}
|
||||
onChange={(e) => patch({ junctionAfterId: e.target.value || null })}
|
||||
>
|
||||
<option value="">{t('admin.channels.junctionDefault')}</option>
|
||||
{(junctions ?? []).map((junction) => (
|
||||
<option key={junction.id} value={junction.id}>
|
||||
{junction.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.junctionBetween')}</Label>
|
||||
<select
|
||||
className="h-9 rounded-md border border-border bg-transparent px-2"
|
||||
value={body.junctionBetweenId ?? ''}
|
||||
onChange={(e) => patch({ junctionBetweenId: e.target.value || null })}
|
||||
>
|
||||
<option value="">{t('admin.channels.junctionDefault')}</option>
|
||||
{(junctions ?? []).map((junction) => (
|
||||
<option key={junction.id} value={junction.id}>
|
||||
{junction.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.junctionAfter')}</Label>
|
||||
<select
|
||||
className="h-9 rounded-md border border-border bg-transparent px-2"
|
||||
value={body.junctionAfterId ?? ''}
|
||||
onChange={(e) => patch({ junctionAfterId: e.target.value || null })}
|
||||
>
|
||||
<option value="">{t('admin.channels.junctionDefault')}</option>
|
||||
{(junctions ?? []).map((junction) => (
|
||||
<option key={junction.id} value={junction.id}>
|
||||
{junction.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -620,6 +620,8 @@ export type ScheduleTemplateDto = {
|
||||
appliedRevision: number
|
||||
/** Есть ли правки правил, не применённые к эфиру. */
|
||||
hasPendingChanges: boolean
|
||||
/** Есть ли к чему откатываться — снимок появляется при первом применении. */
|
||||
canRestore: boolean
|
||||
utcOffsetMinutes: number
|
||||
dayStartTime: string
|
||||
layers: GridLayerDto[]
|
||||
@@ -738,6 +740,9 @@ export type GridPlanDto = {
|
||||
|
||||
export type GenerateGridResultDto = { created: number; removed: number }
|
||||
|
||||
/** Итог отката сетки: сколько слотов осталось и сколько ссылок восстановить не удалось. */
|
||||
export type RestoreTemplateResultDto = { slots: number; droppedRefs: number }
|
||||
|
||||
/** Проверки сетки по правилам, до генерации (см. 5.1). */
|
||||
type TemplateIssueKind =
|
||||
| 'GroupEmpty'
|
||||
|
||||
@@ -628,6 +628,12 @@ export const en = {
|
||||
pickBumperTemplate: 'pick a block',
|
||||
minutesShort: ' min',
|
||||
pendingChanges: 'Rules changed — the air still follows the old ones.',
|
||||
restore: 'Discard changes',
|
||||
restoreConfirm:
|
||||
'Roll the grid back to what is on air? Everything changed since the last apply will be lost.',
|
||||
restored: 'The grid is back to the on-air state: {{count}} slots.',
|
||||
restoreDropped:
|
||||
'References not restored: {{count}} — the group or junction was deleted after the apply.',
|
||||
apply: 'Apply',
|
||||
applied: 'Air rebuilt, entries: {{count}}',
|
||||
weekdays: {
|
||||
|
||||
@@ -633,6 +633,12 @@ export const ru = {
|
||||
pickBumperTemplate: 'выберите блок',
|
||||
minutesShort: ' мин',
|
||||
pendingChanges: 'Правила изменены — эфир идёт по старым.',
|
||||
restore: 'Сбросить изменения',
|
||||
restoreConfirm:
|
||||
'Вернуть сетку к тому состоянию, по которому идёт эфир? Всё, что изменено после последнего применения, будет потеряно.',
|
||||
restored: 'Сетка возвращена к эфирной: слотов — {{count}}.',
|
||||
restoreDropped:
|
||||
'Ссылок не восстановлено: {{count}} — группа или стык были удалены после применения.',
|
||||
apply: 'Применить',
|
||||
applied: 'Эфир пересобран, записей: {{count}}',
|
||||
weekdays: {
|
||||
|
||||
Reference in New Issue
Block a user