Refactor ChannelDetail component to improve template copying functionality and update translations. Change variable names for clarity, ensuring the source channel is correctly referenced when copying templates. Enhance user confirmation prompts and update related UI elements for better user experience.
This commit is contained in:
+312
-306
@@ -1,306 +1,312 @@
|
|||||||
using LiteCqrs;
|
using LiteCqrs;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using TeleWave.Application.Broadcast;
|
using TeleWave.Application.Broadcast;
|
||||||
using TeleWave.Application.Common.Interfaces;
|
using TeleWave.Application.Common.Interfaces;
|
||||||
using TeleWave.Application.Common.Models;
|
using TeleWave.Application.Common.Models;
|
||||||
using TeleWave.Domain.Library;
|
using TeleWave.Domain.Library;
|
||||||
using TeleWave.Domain.Programming;
|
using TeleWave.Domain.Programming;
|
||||||
|
|
||||||
namespace TeleWave.Application.Programming.Templates.Validate;
|
namespace TeleWave.Application.Programming.Templates.Validate;
|
||||||
|
|
||||||
public sealed class ValidateTemplateQueryHandler(IAppDbContext dbContext)
|
public sealed class ValidateTemplateQueryHandler(IAppDbContext dbContext)
|
||||||
: IQueryHandler<ValidateTemplateQuery, Result<IReadOnlyList<TemplateIssueDto>>>
|
: IQueryHandler<ValidateTemplateQuery, Result<IReadOnlyList<TemplateIssueDto>>>
|
||||||
{
|
{
|
||||||
private const int MinutesInDay = 24 * 60;
|
private const int MinutesInDay = 24 * 60;
|
||||||
|
|
||||||
public async Task<Result<IReadOnlyList<TemplateIssueDto>>> Handle(
|
public async Task<Result<IReadOnlyList<TemplateIssueDto>>> Handle(
|
||||||
ValidateTemplateQuery query,
|
ValidateTemplateQuery query,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var channel = await dbContext
|
var channel = await dbContext
|
||||||
.Channels.AsNoTracking()
|
.Channels.AsNoTracking()
|
||||||
.FirstOrDefaultAsync(c => c.Id == query.ChannelId, cancellationToken);
|
.FirstOrDefaultAsync(c => c.Id == query.ChannelId, cancellationToken);
|
||||||
if (channel is null)
|
if (channel is null)
|
||||||
return Result.Failure<IReadOnlyList<TemplateIssueDto>>(ChannelErrors.NotFound);
|
return Result.Failure<IReadOnlyList<TemplateIssueDto>>(ChannelErrors.NotFound);
|
||||||
|
|
||||||
var template = await dbContext
|
var template = await dbContext
|
||||||
.ScheduleTemplates.AsNoTracking()
|
.ScheduleTemplates.AsNoTracking()
|
||||||
.Include(t => t.Layers)
|
.Include(t => t.Layers)
|
||||||
.ThenInclude(l => l.Slots)
|
.ThenInclude(l => l.Slots)
|
||||||
.FirstOrDefaultAsync(t => t.ChannelId == channel.Id, cancellationToken);
|
.FirstOrDefaultAsync(t => t.ChannelId == channel.Id, cancellationToken);
|
||||||
if (template is null)
|
if (template is null)
|
||||||
return Result.Failure<IReadOnlyList<TemplateIssueDto>>(ChannelErrors.TemplateNotFound);
|
return Result.Failure<IReadOnlyList<TemplateIssueDto>>(ChannelErrors.TemplateNotFound);
|
||||||
|
|
||||||
var layers = template.Layers.Where(l => l.IsEnabled).ToList();
|
var layers = template.Layers.Where(l => l.IsEnabled).ToList();
|
||||||
var groupIds = layers
|
var groupIds = layers
|
||||||
.SelectMany(l => l.Slots)
|
.SelectMany(l => l.Slots)
|
||||||
.Select(s => s.GroupId)
|
.Select(s => s.GroupId)
|
||||||
.Where(id => id is not null)
|
.Where(id => id is not null)
|
||||||
.Select(id => id!.Value)
|
.Select(id => id!.Value)
|
||||||
.Distinct()
|
.Distinct()
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
var groups = await dbContext
|
var groups = await dbContext
|
||||||
.Groups.AsNoTracking()
|
.Groups.AsNoTracking()
|
||||||
.Where(g => groupIds.Contains(g.Id))
|
.Where(g => groupIds.Contains(g.Id))
|
||||||
.Select(g => new GroupStats(g.Id, g.Name, g.ItemCount))
|
.Select(g => new GroupStats(g.Id, g.Name, g.ItemCount))
|
||||||
.ToDictionaryAsync(g => g.Id, cancellationToken);
|
.ToDictionaryAsync(g => g.Id, cancellationToken);
|
||||||
|
|
||||||
var rules = PlanningRules.FromJson(template.RulesJson);
|
var rules = PlanningRules.FromJson(template.RulesJson);
|
||||||
var strictest = await LoadStrictestAudienceAsync(groupIds, cancellationToken);
|
var strictest = await LoadStrictestAudienceAsync(groupIds, cancellationToken);
|
||||||
var dayStart = channel.DayStartTime;
|
var dayStart = channel.DayStartTime;
|
||||||
|
|
||||||
var issues = new List<TemplateIssueDto>();
|
var issues = new List<TemplateIssueDto>();
|
||||||
|
|
||||||
foreach (var layer in layers)
|
foreach (var layer in layers)
|
||||||
{
|
{
|
||||||
issues.AddRange(FindOverlaps(layer, dayStart));
|
issues.AddRange(FindOverlaps(layer, dayStart));
|
||||||
|
|
||||||
foreach (var slot in layer.Slots.Where(s => s.SlotKind == SlotKind.Content))
|
foreach (var slot in layer.Slots.Where(s => s.SlotKind == SlotKind.Content))
|
||||||
issues.AddRange(CheckSlot(layer, slot, groups, strictest, rules));
|
issues.AddRange(CheckSlot(layer, slot, groups, strictest, rules));
|
||||||
}
|
}
|
||||||
|
|
||||||
issues.AddRange(FindGaps(layers, dayStart));
|
issues.AddRange(FindGaps(layers, dayStart));
|
||||||
|
|
||||||
return Result.Success<IReadOnlyList<TemplateIssueDto>>(issues);
|
return Result.Success<IReadOnlyList<TemplateIssueDto>>(issues);
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed record GroupStats(Guid Id, string Name, int ItemCount);
|
private sealed record GroupStats(Guid Id, string Name, int ItemCount);
|
||||||
|
|
||||||
private static IEnumerable<TemplateIssueDto> CheckSlot(
|
private static IEnumerable<TemplateIssueDto> CheckSlot(
|
||||||
GridLayer layer,
|
GridLayer layer,
|
||||||
Slot slot,
|
Slot slot,
|
||||||
IReadOnlyDictionary<Guid, GroupStats> groups,
|
IReadOnlyDictionary<Guid, GroupStats> groups,
|
||||||
IReadOnlyDictionary<Guid, ShowAudience> strictest,
|
IReadOnlyDictionary<Guid, ShowAudience> strictest,
|
||||||
PlanningRules? rules
|
PlanningRules? rules
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
if (slot.GroupId is not { } groupId || !groups.TryGetValue(groupId, out var group))
|
if (slot.GroupId is not { } groupId || !groups.TryGetValue(groupId, out var group))
|
||||||
{
|
{
|
||||||
yield return new TemplateIssueDto(
|
yield return new TemplateIssueDto(
|
||||||
TemplateIssueKind.GroupMissing,
|
TemplateIssueKind.GroupMissing,
|
||||||
TemplateIssueSeverity.Error,
|
TemplateIssueSeverity.Error,
|
||||||
layer.Id,
|
layer.Id,
|
||||||
slot.Id,
|
slot.Id,
|
||||||
$"У слота «{slot.Title}» не выбрана группа — место закроет фон."
|
$"У слота «{slot.Title}» не выбрана группа — место закроет фон."
|
||||||
);
|
);
|
||||||
yield break;
|
yield break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (group.ItemCount == 0)
|
if (group.ItemCount == 0)
|
||||||
{
|
{
|
||||||
yield return new TemplateIssueDto(
|
yield return new TemplateIssueDto(
|
||||||
TemplateIssueKind.GroupEmpty,
|
TemplateIssueKind.GroupEmpty,
|
||||||
TemplateIssueSeverity.Error,
|
TemplateIssueSeverity.Error,
|
||||||
layer.Id,
|
layer.Id,
|
||||||
slot.Id,
|
slot.Id,
|
||||||
$"Группа «{group.Name}» пуста — слот «{slot.Title}» заполнит фон."
|
$"Группа «{group.Name}» пуста — слот «{slot.Title}» заполнит фон."
|
||||||
);
|
);
|
||||||
yield break;
|
yield break;
|
||||||
}
|
}
|
||||||
|
|
||||||
var perWeek = OccurrencesPerWeek(slot);
|
var perWeek = OccurrencesPerWeek(slot);
|
||||||
if (group.ItemCount < perWeek)
|
if (group.ItemCount < perWeek)
|
||||||
yield return new TemplateIssueDto(
|
yield return new TemplateIssueDto(
|
||||||
TemplateIssueKind.GroupTooSmall,
|
TemplateIssueKind.GroupTooSmall,
|
||||||
TemplateIssueSeverity.Warning,
|
TemplateIssueSeverity.Warning,
|
||||||
layer.Id,
|
layer.Id,
|
||||||
slot.Id,
|
slot.Id,
|
||||||
$"В группе «{group.Name}» {group.ItemCount} позиций при {perWeek} выходах в неделю — "
|
$"В группе «{group.Name}» {group.ItemCount} позиций при {perWeek} выходах в неделю — "
|
||||||
+ "повторы пойдут чаще, чем раз в неделю."
|
+ "повторы пойдут чаще, чем раз в неделю."
|
||||||
);
|
);
|
||||||
|
|
||||||
// Остывание считается по числу выходов: за N дней слот выйдет N × (выходов в день) раз,
|
// Остывание считается по числу выходов: за N дней слот выйдет N × (выходов в день) раз,
|
||||||
// и если это больше состава группы, отсекать будет некого.
|
// и если это больше состава группы, отсекать будет некого.
|
||||||
var strategy = SlotStrategy.FromJson(slot.StrategyJson);
|
var strategy = SlotStrategy.FromJson(slot.StrategyJson);
|
||||||
if (
|
if (
|
||||||
strategy is { Type: SlotStrategyType.RandomWithCooldown, CooldownDays: > 0 }
|
strategy is { Type: SlotStrategyType.RandomWithCooldown, CooldownDays: > 0 }
|
||||||
&& strategy.CooldownDays * perWeek / 7.0 > group.ItemCount
|
&& strategy.CooldownDays * perWeek / 7.0 > group.ItemCount
|
||||||
)
|
)
|
||||||
yield return new TemplateIssueDto(
|
yield return new TemplateIssueDto(
|
||||||
TemplateIssueKind.CooldownUnreachable,
|
TemplateIssueKind.CooldownUnreachable,
|
||||||
TemplateIssueSeverity.Warning,
|
TemplateIssueSeverity.Warning,
|
||||||
layer.Id,
|
layer.Id,
|
||||||
slot.Id,
|
slot.Id,
|
||||||
$"Остывание {strategy.CooldownDays} дней невыполнимо при {group.ItemCount} позициях "
|
$"Остывание {strategy.CooldownDays} дней невыполнимо при {group.ItemCount} позициях "
|
||||||
+ $"в группе «{group.Name}»."
|
+ $"в группе «{group.Name}»."
|
||||||
);
|
);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
rules?.AudienceAt(slot.TargetStart) is { } maxAudience
|
rules?.AudienceAt(slot.TargetStart) is { } maxAudience
|
||||||
&& strictest.TryGetValue(groupId, out var groupAudience)
|
&& strictest.TryGetValue(groupId, out var groupAudience)
|
||||||
&& groupAudience > maxAudience
|
&& groupAudience > maxAudience
|
||||||
)
|
)
|
||||||
yield return new TemplateIssueDto(
|
yield return new TemplateIssueDto(
|
||||||
TemplateIssueKind.AudienceConflict,
|
TemplateIssueKind.AudienceConflict,
|
||||||
TemplateIssueSeverity.Warning,
|
TemplateIssueSeverity.Warning,
|
||||||
layer.Id,
|
layer.Id,
|
||||||
slot.Id,
|
slot.Id,
|
||||||
$"В группе «{group.Name}» есть контент категории «{groupAudience}», а слот "
|
$"В группе «{group.Name}» есть контент категории «{groupAudience}», а слот "
|
||||||
+ $"«{slot.Title}» стоит во времени не строже «{maxAudience}»."
|
+ $"«{slot.Title}» стоит во времени не строже «{maxAudience}»."
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Сколько раз слот выходит в неделю: без дня недели — каждый день.</summary>
|
/// <summary>Сколько раз слот выходит в неделю: без дня недели — каждый день.</summary>
|
||||||
private static int OccurrencesPerWeek(Slot slot) => slot.Weekday is null ? 7 : 1;
|
private static int OccurrencesPerWeek(Slot slot) => slot.Weekday is null ? 7 : 1;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Пересечения слотов внутри одного слоя. Внутри слоя приоритетов нет, поэтому сыграет первый
|
/// Пересечения слотов внутри одного слоя. Внутри слоя приоритетов нет, поэтому сыграет первый
|
||||||
/// по времени, а второй молча пропадёт — это стоит показать до генерации.
|
/// по времени, а второй молча пропадёт — это стоит показать до генерации.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static IEnumerable<TemplateIssueDto> FindOverlaps(GridLayer layer, TimeOnly dayStart)
|
private static IEnumerable<TemplateIssueDto> FindOverlaps(GridLayer layer, TimeOnly dayStart)
|
||||||
{
|
{
|
||||||
var slots = layer.Slots.OrderBy(s => OffsetInDay(s.TargetStart, dayStart)).ToList();
|
var slots = layer.Slots.OrderBy(s => OffsetInDay(s.TargetStart, dayStart)).ToList();
|
||||||
|
|
||||||
for (var i = 0; i < slots.Count; i++)
|
for (var i = 0; i < slots.Count; i++)
|
||||||
for (var j = i + 1; j < slots.Count; j++)
|
for (var j = i + 1; j < slots.Count; j++)
|
||||||
{
|
{
|
||||||
var a = slots[i];
|
var a = slots[i];
|
||||||
var b = slots[j];
|
var b = slots[j];
|
||||||
if (!SameDays(a, b))
|
if (!SameDays(a, b))
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
var aFrom = OffsetInDay(a.TargetStart, dayStart);
|
var aFrom = OffsetInDay(a.TargetStart, dayStart);
|
||||||
var bFrom = OffsetInDay(b.TargetStart, dayStart);
|
var bFrom = OffsetInDay(b.TargetStart, dayStart);
|
||||||
if (aFrom < bFrom + b.TargetDurationMinutes && bFrom < aFrom + a.TargetDurationMinutes)
|
if (aFrom < bFrom + b.TargetDurationMinutes && bFrom < aFrom + a.TargetDurationMinutes)
|
||||||
yield return new TemplateIssueDto(
|
yield return new TemplateIssueDto(
|
||||||
TemplateIssueKind.SlotOverlap,
|
TemplateIssueKind.SlotOverlap,
|
||||||
TemplateIssueSeverity.Warning,
|
TemplateIssueSeverity.Warning,
|
||||||
layer.Id,
|
layer.Id,
|
||||||
b.Id,
|
b.Id,
|
||||||
$"«{a.Title}» и «{b.Title}» пересекаются в слое «{layer.Name}»."
|
$"«{a.Title}» и «{b.Title}» пересекаются в слое «{layer.Name}»."
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Слот без дня недели идёт каждый день, поэтому пересекается с любым.</summary>
|
/// <summary>Слот без дня недели идёт каждый день, поэтому пересекается с любым.</summary>
|
||||||
private static bool SameDays(Slot a, Slot b) =>
|
private static bool SameDays(Slot a, Slot b) =>
|
||||||
a.Weekday is null || b.Weekday is null || a.Weekday == b.Weekday;
|
a.Weekday is null || b.Weekday is null || a.Weekday == b.Weekday;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Интервалы вещательных суток, не покрытые ни одним слотом. Проверяется по каждому дню недели:
|
/// Интервалы вещательных суток, не покрытые ни одним слотом. Проверяется по каждому дню недели:
|
||||||
/// дыра во вторник ночью не видна, если смотреть на неделю целиком.
|
/// дыра во вторник ночью не видна, если смотреть на неделю целиком.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static IEnumerable<TemplateIssueDto> FindGaps(
|
private static IEnumerable<TemplateIssueDto> FindGaps(
|
||||||
IReadOnlyList<GridLayer> layers,
|
IReadOnlyList<GridLayer> layers,
|
||||||
TimeOnly dayStart
|
TimeOnly dayStart
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
foreach (var weekday in new[] { 1, 2, 3, 4, 5, 6, 0 })
|
foreach (var weekday in new[] { 1, 2, 3, 4, 5, 6, 0 })
|
||||||
{
|
{
|
||||||
var intervals = layers
|
var intervals = layers
|
||||||
.SelectMany(l => l.Slots)
|
.SelectMany(l => l.Slots)
|
||||||
.Where(s => s.Weekday is null || s.Weekday == weekday)
|
.Where(s => s.Weekday is null || s.Weekday == weekday)
|
||||||
.Select(s =>
|
.Select(s =>
|
||||||
{
|
{
|
||||||
var from = OffsetInDay(s.TargetStart, dayStart);
|
var from = OffsetInDay(s.TargetStart, dayStart);
|
||||||
return (From: from, To: from + s.TargetDurationMinutes);
|
return (From: from, To: from + s.TargetDurationMinutes);
|
||||||
})
|
})
|
||||||
.OrderBy(i => i.From)
|
.OrderBy(i => i.From)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
var cursor = 0;
|
var cursor = 0;
|
||||||
foreach (var interval in intervals)
|
foreach (var interval in intervals)
|
||||||
{
|
{
|
||||||
if (interval.From > cursor)
|
if (interval.From > cursor)
|
||||||
yield return Gap(weekday, cursor, interval.From, dayStart);
|
yield return Gap(weekday, cursor, interval.From, dayStart);
|
||||||
cursor = Math.Max(cursor, Math.Min(interval.To, MinutesInDay));
|
cursor = Math.Max(cursor, Math.Min(interval.To, MinutesInDay));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cursor < MinutesInDay)
|
if (cursor < MinutesInDay)
|
||||||
yield return Gap(weekday, cursor, MinutesInDay, dayStart);
|
yield return Gap(weekday, cursor, MinutesInDay, dayStart);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static TemplateIssueDto Gap(int weekday, int from, int to, TimeOnly dayStart)
|
private static TemplateIssueDto Gap(int weekday, int from, int to, TimeOnly dayStart)
|
||||||
{
|
{
|
||||||
var day = new[] { "вс", "пн", "вт", "ср", "чт", "пт", "сб" }[weekday];
|
var day = new[] { "вс", "пн", "вт", "ср", "чт", "пт", "сб" }[weekday];
|
||||||
return new TemplateIssueDto(
|
// Сутки целиком: «00:00–00:00» читалось бы как пустой интервал, а это ровно наоборот.
|
||||||
TemplateIssueKind.GridGap,
|
var interval =
|
||||||
TemplateIssueSeverity.Error,
|
to - from >= MinutesInDay
|
||||||
null,
|
? "весь день"
|
||||||
null,
|
: $"{Clock(from, dayStart)}–{Clock(to, dayStart)}";
|
||||||
$"Не покрыто: {day} {Clock(from, dayStart)}–{Clock(to, dayStart)}."
|
|
||||||
);
|
return new TemplateIssueDto(
|
||||||
}
|
TemplateIssueKind.GridGap,
|
||||||
|
TemplateIssueSeverity.Error,
|
||||||
private static string Clock(int offsetMinutes, TimeOnly dayStart)
|
null,
|
||||||
{
|
null,
|
||||||
var minutes = ((int)dayStart.ToTimeSpan().TotalMinutes + offsetMinutes) % MinutesInDay;
|
$"Не покрыто: {day} {interval}."
|
||||||
return $"{minutes / 60:00}:{minutes % 60:00}";
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static int OffsetInDay(TimeOnly time, TimeOnly dayStart)
|
private static string Clock(int offsetMinutes, TimeOnly dayStart)
|
||||||
{
|
{
|
||||||
var diff = (int)(time.ToTimeSpan() - dayStart.ToTimeSpan()).TotalMinutes;
|
var minutes = ((int)dayStart.ToTimeSpan().TotalMinutes + offsetMinutes) % MinutesInDay;
|
||||||
return diff >= 0 ? diff : diff + MinutesInDay;
|
return $"{minutes / 60:00}:{minutes % 60:00}";
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
private static int OffsetInDay(TimeOnly time, TimeOnly dayStart)
|
||||||
/// Строжайшая категория среди позиций каждой группы: именно она конфликтует с детским временем.
|
{
|
||||||
/// Коллекция берётся по строжайшей части — франшиза идёт целиком.
|
var diff = (int)(time.ToTimeSpan() - dayStart.ToTimeSpan()).TotalMinutes;
|
||||||
/// </summary>
|
return diff >= 0 ? diff : diff + MinutesInDay;
|
||||||
private async Task<Dictionary<Guid, ShowAudience>> LoadStrictestAudienceAsync(
|
}
|
||||||
IReadOnlyCollection<Guid> groupIds,
|
|
||||||
CancellationToken cancellationToken
|
/// <summary>
|
||||||
)
|
/// Строжайшая категория среди позиций каждой группы: именно она конфликтует с детским временем.
|
||||||
{
|
/// Коллекция берётся по строжайшей части — франшиза идёт целиком.
|
||||||
if (groupIds.Count == 0)
|
/// </summary>
|
||||||
return [];
|
private async Task<Dictionary<Guid, ShowAudience>> LoadStrictestAudienceAsync(
|
||||||
|
IReadOnlyCollection<Guid> groupIds,
|
||||||
var items = await dbContext
|
CancellationToken cancellationToken
|
||||||
.GroupItems.AsNoTracking()
|
)
|
||||||
.Where(i => groupIds.Contains(i.GroupId))
|
{
|
||||||
.Select(i => new
|
if (groupIds.Count == 0)
|
||||||
{
|
return [];
|
||||||
i.GroupId,
|
|
||||||
i.ElementKind,
|
var items = await dbContext
|
||||||
i.ElementId,
|
.GroupItems.AsNoTracking()
|
||||||
})
|
.Where(i => groupIds.Contains(i.GroupId))
|
||||||
.ToListAsync(cancellationToken);
|
.Select(i => new
|
||||||
|
{
|
||||||
var showIds = items
|
i.GroupId,
|
||||||
.Where(i => i.ElementKind == GroupElementKind.Show)
|
i.ElementKind,
|
||||||
.Select(i => i.ElementId)
|
i.ElementId,
|
||||||
.ToList();
|
})
|
||||||
var collectionIds = items
|
.ToListAsync(cancellationToken);
|
||||||
.Where(i => i.ElementKind == GroupElementKind.Collection)
|
|
||||||
.Select(i => i.ElementId)
|
var showIds = items
|
||||||
.ToList();
|
.Where(i => i.ElementKind == GroupElementKind.Show)
|
||||||
|
.Select(i => i.ElementId)
|
||||||
var partsByCollection = await dbContext
|
.ToList();
|
||||||
.CollectionItems.AsNoTracking()
|
var collectionIds = items
|
||||||
.Where(i => collectionIds.Contains(i.CollectionId))
|
.Where(i => i.ElementKind == GroupElementKind.Collection)
|
||||||
.Select(i => new { i.CollectionId, i.ShowId })
|
.Select(i => i.ElementId)
|
||||||
.ToListAsync(cancellationToken);
|
.ToList();
|
||||||
|
|
||||||
var audiences = await dbContext
|
var partsByCollection = await dbContext
|
||||||
.Shows.AsNoTracking()
|
.CollectionItems.AsNoTracking()
|
||||||
.Where(s => showIds.Contains(s.Id) || partsByCollection.Select(p => p.ShowId).Contains(s.Id))
|
.Where(i => collectionIds.Contains(i.CollectionId))
|
||||||
.Select(s => new { s.Id, s.Audience })
|
.Select(i => new { i.CollectionId, i.ShowId })
|
||||||
.ToDictionaryAsync(s => s.Id, s => s.Audience, cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
var result = new Dictionary<Guid, ShowAudience>();
|
var audiences = await dbContext
|
||||||
foreach (var item in items)
|
.Shows.AsNoTracking()
|
||||||
{
|
.Where(s => showIds.Contains(s.Id) || partsByCollection.Select(p => p.ShowId).Contains(s.Id))
|
||||||
var candidates =
|
.Select(s => new { s.Id, s.Audience })
|
||||||
item.ElementKind == GroupElementKind.Show
|
.ToDictionaryAsync(s => s.Id, s => s.Audience, cancellationToken);
|
||||||
? [item.ElementId]
|
|
||||||
: partsByCollection
|
var result = new Dictionary<Guid, ShowAudience>();
|
||||||
.Where(p => p.CollectionId == item.ElementId)
|
foreach (var item in items)
|
||||||
.Select(p => p.ShowId)
|
{
|
||||||
.ToList();
|
var candidates =
|
||||||
|
item.ElementKind == GroupElementKind.Show
|
||||||
foreach (var showId in candidates)
|
? [item.ElementId]
|
||||||
{
|
: partsByCollection
|
||||||
if (!audiences.TryGetValue(showId, out var audience))
|
.Where(p => p.CollectionId == item.ElementId)
|
||||||
continue;
|
.Select(p => p.ShowId)
|
||||||
if (!result.TryGetValue(item.GroupId, out var current) || audience > current)
|
.ToList();
|
||||||
result[item.GroupId] = audience;
|
|
||||||
}
|
foreach (var showId in candidates)
|
||||||
}
|
{
|
||||||
|
if (!audiences.TryGetValue(showId, out var audience))
|
||||||
return result;
|
continue;
|
||||||
}
|
if (!result.TryGetValue(item.GroupId, out var current) || audience > current)
|
||||||
}
|
result[item.GroupId] = audience;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export function ChannelDetail({ channelId }: { channelId: string }) {
|
|||||||
const [copyTargets, setCopyTargets] = useState<number[]>([])
|
const [copyTargets, setCopyTargets] = useState<number[]>([])
|
||||||
const [applyOpen, setApplyOpen] = useState(false)
|
const [applyOpen, setApplyOpen] = useState(false)
|
||||||
const [traceEntryId, setTraceEntryId] = useState<string | null>(null)
|
const [traceEntryId, setTraceEntryId] = useState<string | null>(null)
|
||||||
const [copyToChannel, setCopyToChannel] = useState('')
|
const [copyFromChannel, setCopyFromChannel] = useState('')
|
||||||
const [tab, setTab] = useState<ChannelTab>('settings')
|
const [tab, setTab] = useState<ChannelTab>('settings')
|
||||||
|
|
||||||
const { data: channel, isLoading } = useQuery({
|
const { data: channel, isLoading } = useQuery({
|
||||||
@@ -163,9 +163,9 @@ export function ChannelDetail({ channelId }: { channelId: string }) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const copyTemplateMutation = useMutation({
|
const copyTemplateMutation = useMutation({
|
||||||
mutationFn: (targetChannelId: string) => copyTemplateTo(channelId, targetChannelId),
|
mutationFn: (sourceChannelId: string) => copyTemplateTo(sourceChannelId, channelId),
|
||||||
onSuccess: (result) => {
|
onSuccess: (result) => {
|
||||||
setCopyToChannel('')
|
setCopyFromChannel('')
|
||||||
toast.success(
|
toast.success(
|
||||||
t('admin.channels.templateCopied', { layers: result.layers, slots: result.slots }),
|
t('admin.channels.templateCopied', { layers: result.layers, slots: result.slots }),
|
||||||
)
|
)
|
||||||
@@ -345,10 +345,10 @@ export function ChannelDetail({ channelId }: { channelId: string }) {
|
|||||||
</span>
|
</span>
|
||||||
<select
|
<select
|
||||||
className="h-8 rounded-md border border-border bg-transparent px-2 text-sm"
|
className="h-8 rounded-md border border-border bg-transparent px-2 text-sm"
|
||||||
value={copyToChannel}
|
value={copyFromChannel}
|
||||||
onChange={(e) => setCopyToChannel(e.target.value)}
|
onChange={(e) => setCopyFromChannel(e.target.value)}
|
||||||
>
|
>
|
||||||
<option value="">{t('admin.channels.pickTargetChannel')}</option>
|
<option value="">{t('admin.channels.pickSourceChannel')}</option>
|
||||||
{(channels ?? [])
|
{(channels ?? [])
|
||||||
.filter((c) => c.id !== channelId)
|
.filter((c) => c.id !== channelId)
|
||||||
.map((c) => (
|
.map((c) => (
|
||||||
@@ -360,10 +360,14 @@ export function ChannelDetail({ channelId }: { channelId: string }) {
|
|||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
disabled={!copyToChannel || copyTemplateMutation.isPending}
|
disabled={!copyFromChannel || copyTemplateMutation.isPending}
|
||||||
onClick={() => copyTemplateMutation.mutate(copyToChannel)}
|
onClick={() => {
|
||||||
|
// Замена своей сетки — необратимая правка, поэтому спрашиваем перед ней.
|
||||||
|
if (!window.confirm(t('admin.channels.copyTemplateConfirm'))) return
|
||||||
|
copyTemplateMutation.mutate(copyFromChannel)
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{t('admin.channels.copy')}
|
{t('admin.channels.copyHere')}
|
||||||
</Button>
|
</Button>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{t('admin.channels.copyTemplateHint')}
|
{t('admin.channels.copyTemplateHint')}
|
||||||
|
|||||||
@@ -470,10 +470,13 @@ const resources = {
|
|||||||
diffSummary: 'Затронет {{total}} записей, изменятся {{changed}}',
|
diffSummary: 'Затронет {{total}} записей, изменятся {{changed}}',
|
||||||
diffSoon: 'В ближайшие сутки изменится записей: {{count}}',
|
diffSoon: 'В ближайшие сутки изменится записей: {{count}}',
|
||||||
diffNoChanges: 'Эфир не изменится',
|
diffNoChanges: 'Эфир не изменится',
|
||||||
copyTemplate: 'Копировать сетку',
|
copyTemplate: 'Скопировать сетку с канала',
|
||||||
pickTargetChannel: 'Выберите канал',
|
pickSourceChannel: 'Выберите канал-источник',
|
||||||
|
copyHere: 'Скопировать сюда',
|
||||||
copyTemplateHint:
|
copyTemplateHint:
|
||||||
'Слои, слоты, стыки и правила уедут на выбранный канал, его прежняя сетка заменится. Группы общие и не копируются.',
|
'Слои, слоты, стыки и правила выбранного канала заменят сетку этого канала. Группы общие и не копируются.',
|
||||||
|
copyTemplateConfirm:
|
||||||
|
'Текущая сетка этого канала будет заменена сеткой выбранного канала. Продолжить?',
|
||||||
templateCopied: 'Скопировано: слоёв {{layers}}, слотов {{slots}}',
|
templateCopied: 'Скопировано: слоёв {{layers}}, слотов {{slots}}',
|
||||||
copyDroppedBumpers: 'Врезок без блока заставки: {{count}} — донастройте руками',
|
copyDroppedBumpers: 'Врезок без блока заставки: {{count}} — донастройте руками',
|
||||||
postChecks: 'Пост-проверки',
|
postChecks: 'Пост-проверки',
|
||||||
@@ -1174,10 +1177,13 @@ const resources = {
|
|||||||
diffSummary: 'Affects {{total}} entries, {{changed}} will change',
|
diffSummary: 'Affects {{total}} entries, {{changed}} will change',
|
||||||
diffSoon: 'Entries changing within 24 hours: {{count}}',
|
diffSoon: 'Entries changing within 24 hours: {{count}}',
|
||||||
diffNoChanges: 'The air will not change',
|
diffNoChanges: 'The air will not change',
|
||||||
copyTemplate: 'Copy grid',
|
copyTemplate: 'Copy a grid from a channel',
|
||||||
pickTargetChannel: 'Pick a channel',
|
pickSourceChannel: 'Pick the source channel',
|
||||||
|
copyHere: 'Copy here',
|
||||||
copyTemplateHint:
|
copyTemplateHint:
|
||||||
'Layers, slots, junctions and rules move to the chosen channel, replacing its grid. Groups are shared and not copied.',
|
'Layers, slots, junctions and rules of the chosen channel replace this channel grid. Groups are shared and not copied.',
|
||||||
|
copyTemplateConfirm:
|
||||||
|
'This channel grid will be replaced with the chosen channel grid. Continue?',
|
||||||
templateCopied: 'Copied: {{layers}} layers, {{slots}} slots',
|
templateCopied: 'Copied: {{layers}} layers, {{slots}} slots',
|
||||||
copyDroppedBumpers: 'Breaks left without a bumper block: {{count}} — set them up by hand',
|
copyDroppedBumpers: 'Breaks left without a bumper block: {{count}} — set them up by hand',
|
||||||
postChecks: 'Post-checks',
|
postChecks: 'Post-checks',
|
||||||
|
|||||||
Reference in New Issue
Block a user