Enhance channel management: add support for preferred weight multipliers and preferred hours in channel shows, update related data models and API endpoints, and implement validation for new properties. Refactor scheduling logic to utilize preferred hours for weight adjustments during show planning.
This commit is contained in:
@@ -18,6 +18,7 @@ import type {
|
||||
BumperTextVariantDto,
|
||||
BumperTrigger,
|
||||
ChannelShowDto,
|
||||
HourWindow,
|
||||
OverrideMode,
|
||||
ScheduleEntryDto,
|
||||
} from '@/shared/api/types'
|
||||
@@ -381,6 +382,13 @@ function cssColor(value: string): string {
|
||||
return v
|
||||
}
|
||||
|
||||
/** Ограничивает вероятность появления заставки диапазоном 0..1 (пустой ввод → 0). */
|
||||
function clampChance(value: string): number {
|
||||
const n = Number(value)
|
||||
if (Number.isNaN(n)) return 0
|
||||
return Math.min(1, Math.max(0, n))
|
||||
}
|
||||
|
||||
function BumperCard({
|
||||
channel,
|
||||
onSaved,
|
||||
@@ -461,6 +469,9 @@ function BumperCard({
|
||||
<SelectContent>
|
||||
<SelectItem value="Rotation">{t('admin.channels.bumperSelectionRotation')}</SelectItem>
|
||||
<SelectItem value="Random">{t('admin.channels.bumperSelectionRandom')}</SelectItem>
|
||||
<SelectItem value="WeightedRandom">
|
||||
{t('admin.channels.bumperSelectionWeighted')}
|
||||
</SelectItem>
|
||||
<SelectItem value="AlwaysFirst">
|
||||
{t('admin.channels.bumperSelectionAlwaysFirst')}
|
||||
</SelectItem>
|
||||
@@ -489,6 +500,34 @@ function BumperCard({
|
||||
onChange={(e) => setField('minIntervalMinutes', Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.bumperShowChangeChance')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={bumper.showChangeChance}
|
||||
onChange={(e) => setField('showChangeChance', clampChance(e.target.value))}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('admin.channels.bumperShowChangeChanceHint')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.bumperEpisodeChangeChance')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={bumper.episodeChangeChance}
|
||||
onChange={(e) => setField('episodeChangeChance', clampChance(e.target.value))}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('admin.channels.bumperEpisodeChangeChanceHint')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button size="sm" disabled={save.isPending} onClick={() => save.mutate()}>
|
||||
@@ -737,6 +776,7 @@ function BumperVariantEditor({
|
||||
line1: variant.line1,
|
||||
line2: variant.line2,
|
||||
trigger: variant.trigger,
|
||||
weight: variant.weight,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
@@ -748,6 +788,7 @@ function BumperVariantEditor({
|
||||
line1: variant.line1,
|
||||
line2: variant.line2,
|
||||
trigger: variant.trigger,
|
||||
weight: variant.weight,
|
||||
})
|
||||
}, [variant])
|
||||
|
||||
@@ -805,6 +846,19 @@ function BumperVariantEditor({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.bumperVariantWeight')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={1000}
|
||||
value={form.weight}
|
||||
onChange={(e) => set('weight', Math.max(0, Math.round(Number(e.target.value)) || 0))}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('admin.channels.bumperVariantWeightHint')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
@@ -1188,64 +1242,176 @@ function ChannelShowRow({
|
||||
const [blockMode, setBlockMode] = useState<BlockMode>(row.blockMode)
|
||||
const [blockValue, setBlockValue] = useState(row.blockValue)
|
||||
const [isEnabled, setIsEnabled] = useState(row.isEnabled)
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [multiplier, setMultiplier] = useState(row.preferredWeightMultiplier)
|
||||
const [hours, setHours] = useState<HourWindow[]>(row.preferredHours)
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () =>
|
||||
updateChannelShow(channelId, row.id, { weight, blockMode, blockValue, isEnabled }),
|
||||
updateChannelShow(channelId, row.id, {
|
||||
weight,
|
||||
blockMode,
|
||||
blockValue,
|
||||
isEnabled,
|
||||
preferredWeightMultiplier: multiplier,
|
||||
preferredHours: hours.filter((h) => h.startHour < h.endHour),
|
||||
}),
|
||||
onSuccess: onChanged,
|
||||
onError,
|
||||
})
|
||||
|
||||
const addHour = () => setHours((h) => [...h, { startHour: 18, endHour: 23 }])
|
||||
const setHour = (i: number, patch: Partial<HourWindow>) =>
|
||||
setHours((h) => h.map((w, idx) => (idx === i ? { ...w, ...patch } : w)))
|
||||
const removeHour = (i: number) => setHours((h) => h.filter((_, idx) => idx !== i))
|
||||
|
||||
return (
|
||||
<tr className="border-b border-border last:border-0">
|
||||
<td className="py-2">{row.showName}</td>
|
||||
<td className="py-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={weight}
|
||||
onChange={(e) => setWeight(Number(e.target.value))}
|
||||
className="h-8 w-16"
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<Select value={blockMode} onValueChange={(v) => setBlockMode(v as BlockMode)}>
|
||||
<SelectTrigger className="h-8 w-36 whitespace-nowrap">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Count">{t('admin.channels.blockCount')}</SelectItem>
|
||||
<SelectItem value="Duration">{t('admin.channels.blockDuration')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<>
|
||||
<tr className="border-b border-border last:border-0">
|
||||
<td className="py-2">{row.showName}</td>
|
||||
<td className="py-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={blockValue}
|
||||
onChange={(e) => setBlockValue(Number(e.target.value))}
|
||||
value={weight}
|
||||
onChange={(e) => setWeight(Number(e.target.value))}
|
||||
className="h-8 w-16"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isEnabled}
|
||||
onChange={(e) => setIsEnabled(e.target.checked)}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2">
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="outline" disabled={save.isPending} onClick={() => save.mutate()}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
<RemoveButton
|
||||
onClick={() => removeChannelShow(channelId, row.id).then(onChanged).catch(onError)}
|
||||
</td>
|
||||
<td className="py-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<Select value={blockMode} onValueChange={(v) => setBlockMode(v as BlockMode)}>
|
||||
<SelectTrigger className="h-8 w-36 whitespace-nowrap">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Count">{t('admin.channels.blockCount')}</SelectItem>
|
||||
<SelectItem value="Duration">{t('admin.channels.blockDuration')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={blockValue}
|
||||
onChange={(e) => setBlockValue(Number(e.target.value))}
|
||||
className="h-8 w-16"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isEnabled}
|
||||
onChange={(e) => setIsEnabled(e.target.checked)}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</td>
|
||||
<td className="py-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="whitespace-nowrap"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
>
|
||||
{t('admin.channels.preferredHours')}
|
||||
{hours.length > 0 ? ` (${hours.length})` : ''}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" disabled={save.isPending} onClick={() => save.mutate()}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
<RemoveButton
|
||||
onClick={() => removeChannelShow(channelId, row.id).then(onChanged).catch(onError)}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{expanded && (
|
||||
<tr className="border-b border-border last:border-0">
|
||||
<td colSpan={5} className="bg-muted/30 py-3">
|
||||
<div className="flex flex-col gap-3 pl-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="whitespace-nowrap">
|
||||
{t('admin.channels.preferredMultiplier')}
|
||||
</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
value={multiplier}
|
||||
onChange={(e) => setMultiplier(Math.max(1, Math.round(Number(e.target.value)) || 1))}
|
||||
className="h-8 w-20"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('admin.channels.preferredHoursHint')}
|
||||
</span>
|
||||
</div>
|
||||
{hours.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground">{t('admin.channels.preferredNone')}</p>
|
||||
)}
|
||||
{hours.map((w, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<HourSelect
|
||||
value={w.startHour}
|
||||
from={0}
|
||||
to={23}
|
||||
onChange={(v) => setHour(i, { startHour: v })}
|
||||
/>
|
||||
<span className="text-muted-foreground">—</span>
|
||||
<HourSelect
|
||||
value={w.endHour}
|
||||
from={1}
|
||||
to={24}
|
||||
onChange={(v) => setHour(i, { endHour: v })}
|
||||
/>
|
||||
{w.startHour >= w.endHour && (
|
||||
<span className="text-xs text-destructive">
|
||||
{t('admin.channels.preferredBadRange')}
|
||||
</span>
|
||||
)}
|
||||
<Button size="sm" variant="ghost" onClick={() => removeHour(i)}>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<div>
|
||||
<Button size="sm" variant="outline" onClick={addHour}>
|
||||
{t('admin.channels.preferredAddWindow')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** Выпадающий выбор часа суток (значения from..to включительно), формат «HH:00». */
|
||||
function HourSelect({
|
||||
value,
|
||||
from,
|
||||
to,
|
||||
onChange,
|
||||
}: {
|
||||
value: number
|
||||
from: number
|
||||
to: number
|
||||
onChange: (v: number) => void
|
||||
}) {
|
||||
const options = Array.from({ length: to - from + 1 }, (_, i) => from + i)
|
||||
return (
|
||||
<Select value={String(value)} onValueChange={(v) => onChange(Number(v))}>
|
||||
<SelectTrigger className="h-8 w-24">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((h) => (
|
||||
<SelectItem key={h} value={String(h)}>
|
||||
{String(h).padStart(2, '0')}:00
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1386,7 +1552,18 @@ function SchedulePreview({ entries }: { entries: ScheduleEntryDto[] }) {
|
||||
{e.kind === 'Ad' ? (
|
||||
<Badge variant="muted">{t('air.ad')}</Badge>
|
||||
) : e.kind === 'Bumper' ? (
|
||||
<Badge variant="muted">{t('air.bumper')}</Badge>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<Badge variant="muted" className="shrink-0">
|
||||
{t('air.bumper')}
|
||||
</Badge>
|
||||
{(e.bumperName || e.bumperText) && (
|
||||
<span className="min-w-0 truncate text-muted-foreground">
|
||||
{e.bumperName}
|
||||
{e.bumperName && e.bumperText ? ' · ' : ''}
|
||||
{e.bumperText}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<span>
|
||||
{e.showName ?? '—'}
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
ChannelDto,
|
||||
ChannelSummaryDto,
|
||||
CreatedIdResponse,
|
||||
HourWindow,
|
||||
OverrideMode,
|
||||
ScheduleEntryDto,
|
||||
} from '@/shared/api/types'
|
||||
@@ -52,7 +53,14 @@ export function addChannelShow(id: string, body: ChannelShowBody) {
|
||||
export function updateChannelShow(
|
||||
id: string,
|
||||
channelShowId: string,
|
||||
body: { weight: number; blockMode: BlockMode; blockValue: number; isEnabled: boolean },
|
||||
body: {
|
||||
weight: number
|
||||
blockMode: BlockMode
|
||||
blockValue: number
|
||||
isEnabled: boolean
|
||||
preferredWeightMultiplier: number
|
||||
preferredHours: HourWindow[]
|
||||
},
|
||||
) {
|
||||
return apiRequest<void>(`/admin/channels/${id}/shows/${channelShowId}`, { method: 'PUT', body })
|
||||
}
|
||||
@@ -147,6 +155,7 @@ export type BumperVariantBody = {
|
||||
line1: string
|
||||
line2: string
|
||||
trigger: BumperTrigger
|
||||
weight: number
|
||||
}
|
||||
|
||||
export function addBumperVariant(id: string, templateId: string, name: string) {
|
||||
|
||||
@@ -127,7 +127,7 @@ export type AdInsertion = 'BetweenBlocks' | 'BetweenEpisodes'
|
||||
export type OverrideMode = 'Exclusive' | 'Boost'
|
||||
export type ScheduleEntryKind = 'Program' | 'Ad' | 'Bumper'
|
||||
export type BumperFont = 'Sans' | 'Serif'
|
||||
export type BumperSelection = 'Rotation' | 'Random' | 'AlwaysFirst'
|
||||
export type BumperSelection = 'Rotation' | 'Random' | 'AlwaysFirst' | 'WeightedRandom'
|
||||
export type BumperTextKind = 'NowNext' | 'Free'
|
||||
export type BumperTrigger = 'OnShowChange' | 'BetweenEpisodes' | 'Both'
|
||||
|
||||
@@ -136,6 +136,10 @@ export type BumperSettings = {
|
||||
font: BumperFont
|
||||
minIntervalMinutes: number
|
||||
selection: BumperSelection
|
||||
/** Вероятность заставки на смене шоу (0..1). */
|
||||
showChangeChance: number
|
||||
/** Вероятность заставки между блоками одного шоу (0..1). */
|
||||
episodeChangeChance: number
|
||||
}
|
||||
|
||||
/** Подблок (текст-вариант): свой текст + правило показа поверх стиля/звука блока. */
|
||||
@@ -149,6 +153,8 @@ export type BumperTextVariantDto = {
|
||||
line1: string
|
||||
line2: string
|
||||
trigger: BumperTrigger
|
||||
/** Вес при стратегии выбора «случайно взвешенный» (0 — не выбирается). */
|
||||
weight: number
|
||||
}
|
||||
|
||||
export type BumperTemplateDto = {
|
||||
@@ -173,6 +179,9 @@ export type ChannelSummaryDto = {
|
||||
isEnabled: boolean
|
||||
}
|
||||
|
||||
/** Окно предпочтительных часов [startHour, endHour) суток (UTC). */
|
||||
export type HourWindow = { startHour: number; endHour: number }
|
||||
|
||||
export type ChannelShowDto = {
|
||||
id: string
|
||||
showId: string
|
||||
@@ -182,6 +191,9 @@ export type ChannelShowDto = {
|
||||
blockValue: number
|
||||
isEnabled: boolean
|
||||
nextEpisodeIndex: number
|
||||
/** Во сколько раз усиливать вес в предпочтительные часы (1 — без буста). */
|
||||
preferredWeightMultiplier: number
|
||||
preferredHours: HourWindow[]
|
||||
}
|
||||
|
||||
export type ChannelAdDto = {
|
||||
@@ -227,6 +239,9 @@ export type ScheduleEntryDto = {
|
||||
showName: string | null
|
||||
episodeIndex: number | null
|
||||
seasonEpisode: string | null
|
||||
/** Для заставок: имя подблока и его текст — для метки в расписании. */
|
||||
bumperName: string | null
|
||||
bumperText: string | null
|
||||
}
|
||||
|
||||
// ── Публичный эфир ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -203,8 +203,13 @@ const resources = {
|
||||
bumperSelection: 'Выбор блока',
|
||||
bumperSelectionRotation: 'По кругу',
|
||||
bumperSelectionRandom: 'Случайно',
|
||||
bumperSelectionWeighted: 'Случайно взвешенный',
|
||||
bumperSelectionAlwaysFirst: 'Всегда первый',
|
||||
bumperMinInterval: 'Мин. интервал, мин',
|
||||
bumperShowChangeChance: 'Вероятность на смене шоу',
|
||||
bumperShowChangeChanceHint: '0..1: 1 — на каждой смене, 0 — никогда',
|
||||
bumperEpisodeChangeChance: 'Вероятность между сериями',
|
||||
bumperEpisodeChangeChanceHint: '0..1: напр. 0.3 — примерно в 30% переходов между сериями',
|
||||
bumperFont: 'Шрифт',
|
||||
bumperFontSans: 'Гротеск',
|
||||
bumperFontSerif: 'Антиква',
|
||||
@@ -234,6 +239,8 @@ const resources = {
|
||||
bumperTriggerOnShowChange: 'При смене шоу',
|
||||
bumperTriggerBetweenEpisodes: 'Между сериями',
|
||||
bumperTriggerBoth: 'Оба',
|
||||
bumperVariantWeight: 'Вес',
|
||||
bumperVariantWeightHint: 'Для стратегии «случайно взвешенный»: чем больше — тем чаще (0 — не выбирается)',
|
||||
bumperDefault: 'по умолчанию',
|
||||
bumperSeconds: 'с',
|
||||
bumperDefaultDuration: '≈8 с (джингл)',
|
||||
@@ -262,6 +269,13 @@ const resources = {
|
||||
blockDuration: 'По времени',
|
||||
episodes: 'серий',
|
||||
minutes: 'минут',
|
||||
preferredHours: 'Часы',
|
||||
preferredMultiplier: 'Множитель веса',
|
||||
preferredHoursHint:
|
||||
'В выбранные часы вес шоу умножается — оно чаще попадает в эфир. Время в UTC.',
|
||||
preferredNone: 'Окна не заданы — предпочтений по времени нет.',
|
||||
preferredAddWindow: 'Добавить окно',
|
||||
preferredBadRange: 'начало ≥ конца',
|
||||
ads: 'Реклама',
|
||||
pickAd: 'Выберите ролик',
|
||||
noAds: 'Пул рекламы пуст',
|
||||
@@ -523,8 +537,13 @@ const resources = {
|
||||
bumperSelection: 'Block selection',
|
||||
bumperSelectionRotation: 'Rotation',
|
||||
bumperSelectionRandom: 'Random',
|
||||
bumperSelectionWeighted: 'Weighted random',
|
||||
bumperSelectionAlwaysFirst: 'Always first',
|
||||
bumperMinInterval: 'Min interval, min',
|
||||
bumperShowChangeChance: 'Chance on show change',
|
||||
bumperShowChangeChanceHint: '0..1: 1 — every change, 0 — never',
|
||||
bumperEpisodeChangeChance: 'Chance between episodes',
|
||||
bumperEpisodeChangeChanceHint: '0..1: e.g. 0.3 — about 30% of same-show transitions',
|
||||
bumperFont: 'Font',
|
||||
bumperFontSans: 'Sans',
|
||||
bumperFontSerif: 'Serif',
|
||||
@@ -554,6 +573,8 @@ const resources = {
|
||||
bumperTriggerOnShowChange: 'Show change',
|
||||
bumperTriggerBetweenEpisodes: 'Between episodes',
|
||||
bumperTriggerBoth: 'Both',
|
||||
bumperVariantWeight: 'Weight',
|
||||
bumperVariantWeightHint: 'For the “weighted random” strategy: higher = more often (0 — never picked)',
|
||||
bumperDefault: 'default',
|
||||
bumperSeconds: 's',
|
||||
bumperDefaultDuration: '≈8 s (jingle)',
|
||||
@@ -582,6 +603,13 @@ const resources = {
|
||||
blockDuration: 'By time',
|
||||
episodes: 'episodes',
|
||||
minutes: 'minutes',
|
||||
preferredHours: 'Hours',
|
||||
preferredMultiplier: 'Weight multiplier',
|
||||
preferredHoursHint:
|
||||
'During the selected hours the show’s weight is multiplied, so it airs more often. Times are UTC.',
|
||||
preferredNone: 'No windows set — no time preference.',
|
||||
preferredAddWindow: 'Add window',
|
||||
preferredBadRange: 'start ≥ end',
|
||||
ads: 'Ads',
|
||||
pickAd: 'Pick an ad',
|
||||
noAds: 'Ad pool is empty',
|
||||
|
||||
Reference in New Issue
Block a user