From 1afe01aed8760b0ad703aab6c69b4d69702fa255 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Mon, 27 Jul 2026 23:04:15 +0300 Subject: [PATCH] Refactor BumperPlaceholders and BumperFacts for improved logic and readability Updated the BumperPlaceholders class to streamline token extraction from texts, enhancing performance and clarity. Modified BumperFacts to initialize slot titles with an empty array instead of a dictionary for better consistency. Renamed methods in BumperResolver for clarity, and refactored GridScheduleGenerator to simplify return logic. Additionally, improved the BumperLinesEditor component by implementing a keyed list for better state management and user experience. --- .../Broadcast/Bumpers/BumperPlaceholders.cs | 11 +- .../Programming/Planning/BumperFacts.cs | 2 +- .../Programming/Planning/BumperResolver.cs | 4 +- .../Planning/GridScheduleGenerator.cs | 14 +- .../Templates/Generate/GridPlanner.cs | 44 +++---- .../Programming/Planning/JunctionFiller.cs | 18 ++- .../Programming/Planning/SchedulePlanner.cs | 13 +- .../Media/FileSystemStorageInspector.cs | 2 +- .../Broadcast/BumperPreviewTests.cs | 4 +- .../bumpers/components/BumperLinesEditor.tsx | 122 +++++++++++------- .../junctions/components/JunctionChain.tsx | 19 +-- .../features/admin/storage/StoragePanel.tsx | 19 +-- 12 files changed, 159 insertions(+), 113 deletions(-) diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperPlaceholders.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperPlaceholders.cs index f681b43..76079d3 100644 --- a/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperPlaceholders.cs +++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperPlaceholders.cs @@ -95,11 +95,12 @@ public static partial class BumperPlaceholders /// Какие плейсхолдеры встречаются в текстах — по ним планировщик решает, что грузить. public static IReadOnlySet TokensIn(IEnumerable texts) { - var used = new HashSet(StringComparer.Ordinal); - foreach (var text in texts.Where(t => !string.IsNullOrWhiteSpace(t))) - foreach (Match match in TokenPattern().Matches(text)) - used.Add(match.Groups[1].Value); - return used; + var matches = texts + .Where(t => !string.IsNullOrWhiteSpace(t)) + .SelectMany(text => TokenPattern().Matches(text)) + .Select(match => match.Groups[1].Value); + + return new HashSet(matches, StringComparer.Ordinal); } /// Плейсхолдеры текста, которых нет в списке допустимых. diff --git a/backend/src/TeleWave.Application/Programming/Planning/BumperFacts.cs b/backend/src/TeleWave.Application/Programming/Planning/BumperFacts.cs index ff4bda2..d997967 100644 --- a/backend/src/TeleWave.Application/Programming/Planning/BumperFacts.cs +++ b/backend/src/TeleWave.Application/Programming/Planning/BumperFacts.cs @@ -59,7 +59,7 @@ internal sealed class BumperFacts var slotTitles = tokens.Contains("slot") ? await LoadSlotTitlesAsync(dbContext, items, cancellationToken) - : new Dictionary(); + : []; return new BumperFacts(items, channel, shows, slotTitles); } diff --git a/backend/src/TeleWave.Application/Programming/Planning/BumperResolver.cs b/backend/src/TeleWave.Application/Programming/Planning/BumperResolver.cs index 05097a0..abc176e 100644 --- a/backend/src/TeleWave.Application/Programming/Planning/BumperResolver.cs +++ b/backend/src/TeleWave.Application/Programming/Planning/BumperResolver.cs @@ -100,7 +100,7 @@ public sealed class BumperResolver( variant.Id, linesJson, posterShowId, - Signature(template, variant.Id, linesJson, posterShowId) + ComputeSignature(template, variant.Id, linesJson, posterShowId) ) ) ); @@ -210,7 +210,7 @@ public sealed class BumperResolver( /// и пары шоу в ней нет намеренно: одинаковая заставка на трёх каналах рендерится один раз, а /// {channel} в тексте разводит их сам собой. /// - private static string Signature( + private static string ComputeSignature( BumperTemplate template, Guid variantId, string linesJson, diff --git a/backend/src/TeleWave.Application/Programming/Planning/GridScheduleGenerator.cs b/backend/src/TeleWave.Application/Programming/Planning/GridScheduleGenerator.cs index 283e497..9324c45 100644 --- a/backend/src/TeleWave.Application/Programming/Planning/GridScheduleGenerator.cs +++ b/backend/src/TeleWave.Application/Programming/Planning/GridScheduleGenerator.cs @@ -447,13 +447,13 @@ public sealed class GridScheduleGenerator( ); } - return elements.Count == 0 - ? null - : new PlanningJunction( - id, - elements, - template.MaxTotalSeconds is { } seconds ? TimeSpan.FromSeconds(seconds) : null - ); + if (elements.Count == 0) + return null; + + var cap = template.MaxTotalSeconds is { } seconds + ? TimeSpan.FromSeconds(seconds) + : (TimeSpan?)null; + return new PlanningJunction(id, elements, cap); } /// diff --git a/backend/src/TeleWave.Application/Programming/Templates/Generate/GridPlanner.cs b/backend/src/TeleWave.Application/Programming/Templates/Generate/GridPlanner.cs index 5e93f43..c43804b 100644 --- a/backend/src/TeleWave.Application/Programming/Templates/Generate/GridPlanner.cs +++ b/backend/src/TeleWave.Application/Programming/Templates/Generate/GridPlanner.cs @@ -360,9 +360,28 @@ public sealed class GridPlanner( PlanRun Run ) { + /// + /// Длина слота и число единиц в нём. Блок из N единиц занимает ровно столько, сколько эти + /// единицы идут, — округление до пяти минут; хвост короче минимального слота приклеивается + /// к текущему, чтобы в сетке не появлялось огрызков. + /// + private (int Duration, int Units) Measure(GroupCandidate group, int available) + { + var unit = UnitMinutes(group); + + // Полоса, которая меряется временем (марафон, ночь), берёт свой кусок целиком. + if (Band.UnitsPerBlock <= 0 || unit == 0) + return (Fit(Band.BlockMinutes > 0 ? Band.BlockMinutes : available, available), 0); + + var duration = Fit(Math.Max(1, Band.UnitsPerBlock) * unit, available); + // Число единиц — по фактической длине слота: полоса задаёт замысел, а сколько влезло, + // решает контент. + return (duration, Math.Max(1, (int)Math.Round(duration / (double)unit))); + } + public PlannedSlot Content(GroupCandidate group, int available) { - var (duration, units) = Measure(Band, group, available); + var (duration, units) = Measure(group, available); Run.Reserve(group, units, duration, Target.AiringsPerWeek); return Build( @@ -442,29 +461,6 @@ public sealed class GridPlanner( private static int RepeatMinutes(GridBand band) => band.BlockMinutes > 0 ? band.BlockMinutes : 120; - /// - /// Длина слота и число единиц в нём. Блок из N единиц занимает ровно столько, сколько эти - /// единицы идут, — округление до пяти минут; хвост короче минимального слота приклеивается - /// к текущему, чтобы в сетке не появлялось огрызков. - /// - private static (int Duration, int Units) Measure( - GridBand band, - GroupCandidate group, - int available - ) - { - var unit = UnitMinutes(group); - - // Полоса, которая меряется временем (марафон, ночь), берёт свой кусок целиком. - if (band.UnitsPerBlock <= 0 || unit == 0) - return (Fit(band.BlockMinutes > 0 ? band.BlockMinutes : available, available), 0); - - var duration = Fit(Math.Max(1, band.UnitsPerBlock) * unit, available); - // Число единиц — по фактической длине слота: полоса задаёт замысел, а сколько влезло, - // решает контент. - return (duration, Math.Max(1, (int)Math.Round(duration / (double)unit))); - } - /// /// Длина слота: желаемая, но не больше свободного места. Остаток короче минимального слота /// приклеивается к текущему — огрызок в сетке читается как ошибка, а не как решение. Остаток diff --git a/backend/src/TeleWave.Domain/Programming/Planning/JunctionFiller.cs b/backend/src/TeleWave.Domain/Programming/Planning/JunctionFiller.cs index ffc6b7f..8c63277 100644 --- a/backend/src/TeleWave.Domain/Programming/Planning/JunctionFiller.cs +++ b/backend/src/TeleWave.Domain/Programming/Planning/JunctionFiller.cs @@ -63,6 +63,18 @@ public sealed record JunctionInsert( TimeSpan Duration ); +/// +/// Накопители прогона, нужные раскладке стыка: история врезок (интервалы и ротация единиц), +/// жребий и сама собираемая лента. Принадлежат прогону целиком, а не отдельному стыку, поэтому +/// едут одним параметром — плоским списком сигнатура перестаёт +/// читаться. +/// +public sealed record JunctionRun( + JunctionHistory History, + IRandomSource Random, + List Items +); + /// /// Раскладка врезок стыка: реклама, промо, заставка, заполнитель. /// @@ -80,15 +92,15 @@ public static class JunctionFiller DateTimeOffset cursor, DateTimeOffset limit, JunctionPlacement placement, - JunctionHistory history, - IRandomSource random, - List items, + JunctionRun run, PlanTrace? trace ) { if (junction is null || junction.Elements.Count == 0) return cursor; + var (history, random, items) = run; + var eligible = junction .Elements.Where(e => Passes(e, cursor, placement, history, random)) .ToList(); diff --git a/backend/src/TeleWave.Domain/Programming/Planning/SchedulePlanner.cs b/backend/src/TeleWave.Domain/Programming/Planning/SchedulePlanner.cs index a172067..ab2c865 100644 --- a/backend/src/TeleWave.Domain/Programming/Planning/SchedulePlanner.cs +++ b/backend/src/TeleWave.Domain/Programming/Planning/SchedulePlanner.cs @@ -31,6 +31,11 @@ public static class SchedulePlanner public List Warnings { get; } = []; public JunctionHistory Junctions { get; } = new(); + private JunctionRun? _junctionRun; + + /// Накопители, которые нужны раскладке стыка: история, жребий и сама лента. + public JunctionRun JunctionRun => _junctionRun ??= new(Junctions, Random, Items); + /// Смещение времени канала — врезки со своим окном суток считают его по нему. public TimeSpan ChannelOffset { get; } = TimeSpan.FromMinutes(input.UtcOffsetMinutes); @@ -260,9 +265,7 @@ public static class SchedulePlanner ElementChanged: run.PreviousShowId != unit.ShowId, run.ChannelOffset ), - run.Junctions, - run.Random, - run.Items, + run.JunctionRun, slotTrace ); @@ -294,9 +297,7 @@ public static class SchedulePlanner ElementChanged: true, run.ChannelOffset ), - run.Junctions, - run.Random, - run.Items, + run.JunctionRun, slotTrace ); diff --git a/backend/src/TeleWave.Infrastructure/Media/FileSystemStorageInspector.cs b/backend/src/TeleWave.Infrastructure/Media/FileSystemStorageInspector.cs index c25da67..01368b2 100644 --- a/backend/src/TeleWave.Infrastructure/Media/FileSystemStorageInspector.cs +++ b/backend/src/TeleWave.Infrastructure/Media/FileSystemStorageInspector.cs @@ -152,7 +152,7 @@ public sealed class FileSystemStorageInspector( return new StorageAreaUsage(StorageArea.Other, measurement.Bytes, measurement.Files); } - private StorageAreaUsage Area( + private static StorageAreaUsage Area( StorageArea area, string directory, CancellationToken cancellationToken diff --git a/backend/tests/TeleWave.Application.Tests/Broadcast/BumperPreviewTests.cs b/backend/tests/TeleWave.Application.Tests/Broadcast/BumperPreviewTests.cs index 8ee6890..12a44b5 100644 --- a/backend/tests/TeleWave.Application.Tests/Broadcast/BumperPreviewTests.cs +++ b/backend/tests/TeleWave.Application.Tests/Broadcast/BumperPreviewTests.cs @@ -171,7 +171,7 @@ public class BumperPreviewTests .Handle(new RenderBumperPreviewCommand(template.Id, null), CancellationToken.None); Assert.True(result.IsSuccess); - var spec = Specs(renderer).First(); + var spec = Specs(renderer)[0]; Assert.Equal("/data/images/bg.png", spec.BackgroundFile); // Канала нет — образцы заглушечные, но кадр всё равно собирается. Assert.Equal("Первое шоу", spec.Lines[1].Text); @@ -201,7 +201,7 @@ public class BumperPreviewTests .Handle(new RenderBumperPreviewCommand(template.Id, null), CancellationToken.None); // Жанра у образца нет — строка схлопнулась, а не оставила дыру в кадре. - var spec = Specs(renderer).First(); + var spec = Specs(renderer)[0]; Assert.Single(spec.Lines); } } diff --git a/frontend/src/features/admin/bumpers/components/BumperLinesEditor.tsx b/frontend/src/features/admin/bumpers/components/BumperLinesEditor.tsx index e2c26e1..df6ca28 100644 --- a/frontend/src/features/admin/bumpers/components/BumperLinesEditor.tsx +++ b/frontend/src/features/admin/bumpers/components/BumperLinesEditor.tsx @@ -5,6 +5,7 @@ import type { BumperLineColor, BumperLineDto, BumperLineStyle } from '@/shared/a import { Button } from '@/shared/ui/button' import { Input } from '@/shared/ui/input' import { cn } from '@/shared/lib/cn' +import { useKeyedList } from '@/shared/lib/keyed-list' import { hasVolatileToken, PLACEHOLDERS, resolveSample, unknownTokens } from '../placeholders' const STYLES: BumperLineStyle[] = ['Label', 'Title', 'Caption'] @@ -44,45 +45,71 @@ function presets(t: (key: string) => string): { key: string; lines: BumperLineDt * Строки заставки: порядок перетаскиванием, палитра плейсхолдеров под фокусированным полем, * пресеты кнопкой. Ошибка ввода (незнакомый плейсхолдер) видна сразу — сервер её всё равно * отвергнет, но узнавать об этом при сохранении неудобно. + * + * Список свой, со стабильными ключами строк (см. useKeyedList), а наружу уезжает только значение: + * при индексных ключах удаление строки из середины уводило бы фокус в соседнюю. Пересев на другой + * подблок, редактор пересоздаётся по `key` — поэтому props читаются только на первом рендере. */ export function BumperLinesEditor({ - lines, + lines: initial, onChange, }: Readonly<{ lines: BumperLineDto[] onChange: (lines: BumperLineDto[]) => void }>) { const { t } = useTranslation() - const focused = useRef(null) - const inputs = useRef<(HTMLInputElement | null)[]>([]) - const dragged = useRef(null) + const { rows, reset, add, remove, patch } = useKeyedList(initial) + const focused = useRef(null) + const inputs = useRef(new Map()) + const dragged = useRef(null) - const patch = (index: number, part: Partial) => - onChange(lines.map((line, i) => (i === index ? { ...line, ...part } : line))) - - const add = () => { - const line: BumperLineDto = { style: 'Title', color: 'Text', text: '' } - onChange([...lines, line].slice(0, MAX_LINES)) + const setLine = (key: string, part: Partial) => { + patch(key, (line) => ({ ...line, ...part })) + // Правка уезжает наверх сразу: сохраняет подблок родитель, у него же лежит остальная форма. + onChange(rows.map((row) => (row.key === key ? { ...row.value, ...part } : row.value))) } - const remove = (index: number) => onChange(lines.filter((_, i) => i !== index)) + const addLine = () => { + if (rows.length >= MAX_LINES) return + const line: BumperLineDto = { style: 'Title', color: 'Text', text: '' } + add(line) + onChange([...rows.map((row) => row.value), line]) + } - const move = (from: number, to: number) => { - if (from === to) return - const next = [...lines] - const [line] = next.splice(from, 1) - next.splice(to, 0, line) - onChange(next) + const removeLine = (key: string) => { + remove(key) + onChange(rows.filter((row) => row.key !== key).map((row) => row.value)) + } + + const applyPreset = (preset: BumperLineDto[]) => { + reset(preset) + onChange(preset) + } + + const move = (fromKey: string, toKey: string) => { + if (fromKey === toKey) return + const next = [...rows] + const from = next.findIndex((row) => row.key === fromKey) + const to = next.findIndex((row) => row.key === toKey) + if (from < 0 || to < 0) return + + const [row] = next.splice(from, 1) + next.splice(to, 0, row) + const values = next.map((r) => r.value) + reset(values) + onChange(values) } /** Вставка плейсхолдера в позицию курсора — иначе его пришлось бы допечатывать руками. */ const insert = (token: string) => { - const index = focused.current ?? lines.length - 1 - if (index < 0) return - const input = inputs.current[index] - const text = lines[index].text - const at = input?.selectionStart ?? text.length - patch(index, { text: `${text.slice(0, at)}{${token}}${text.slice(at)}` }) + const key = focused.current ?? rows.at(-1)?.key + const row = rows.find((r) => r.key === key) + if (!key || !row) return + + const input = inputs.current.get(key) + const at = input?.selectionStart ?? row.value.text.length + const text = row.value.text + setLine(key, { text: `${text.slice(0, at)}{${token}}${text.slice(at)}` }) requestAnimationFrame(() => { input?.focus() const caret = at + token.length + 2 @@ -101,25 +128,25 @@ export function BumperLinesEditor({ key={preset.key} size="sm" variant="outline" - onClick={() => onChange(preset.lines)} + onClick={() => applyPreset(preset.lines)} > {t(`admin.bumpers.preset_${preset.key}`)} ))} - {lines.map((line, index) => { + {rows.map(({ key, value: line }) => { const unknown = unknownTokens(line.text) return (
{ - dragged.current = index + dragged.current = key }} onDragOver={(e) => e.preventDefault()} onDrop={() => { - if (dragged.current !== null) move(dragged.current, index) + if (dragged.current !== null) move(dragged.current, key) dragged.current = null }} className="flex flex-wrap items-center gap-2 rounded-md border border-border bg-background/40 p-2" @@ -128,7 +155,7 @@ export function BumperLinesEditor({ patch(index, { color: e.target.value as BumperLineColor })} + onChange={(e) => setLine(key, { color: e.target.value as BumperLineColor })} > {COLORS.map((color) => (