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:
Leonid Pershin
2026-07-25 18:44:28 +03:00
parent 358adbff21
commit 8718e8b6bf
36 changed files with 2921 additions and 97 deletions
@@ -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 ?? '—'}
+10 -1
View File
@@ -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) {