Implement template export/import endpoints and enhance grid generation logic
Added new endpoints for exporting and importing grid configurations in TemplateEndpoints, allowing for better management of template data. Enhanced the GenerateGridCommandHandler to support seasonal layers in grid generation, improving scheduling accuracy during holiday periods. Updated related classes and records to accommodate these changes, ensuring a cohesive integration of new features. Improved documentation for clarity and maintainability.
This commit is contained in:
@@ -20,6 +20,10 @@ export const PLACEHOLDERS: PlaceholderSample[] = [
|
||||
{ token: 'next.year', sample: '1991' },
|
||||
{ token: 'next.genre', sample: 'Боевик' },
|
||||
{ token: 'next.time', sample: '21:30' },
|
||||
{ token: 'tonight.title', sample: 'Терминатор 2' },
|
||||
{ token: 'tonight.time', sample: '20:00' },
|
||||
{ token: 'tomorrow.title', sample: 'Чужие' },
|
||||
{ token: 'tomorrow.time', sample: '20:00' },
|
||||
{ token: 'time', sample: '21:24' },
|
||||
{ token: 'date', sample: '6 апреля' },
|
||||
{ token: 'weekday', sample: 'понедельник' },
|
||||
@@ -33,12 +37,29 @@ const TOKEN_PATTERN = /\{([a-zA-Z][a-zA-Z.]*)\}/g
|
||||
|
||||
const SAMPLES = new Map(PLACEHOLDERS.map((p) => [p.token, p.sample]))
|
||||
|
||||
/** Как строка будет выглядеть в кадре: подстановка образцами + схлопывание лишних пробелов. */
|
||||
/**
|
||||
* Как строка будет выглядеть в кадре. Зеркало серверного `BumperPlaceholders.Resolve`: пустые
|
||||
* значения схлопываются вместе с осиротевшими разделителями, а строка, где не подставился ни один
|
||||
* плейсхолдер, исчезает целиком — «ДАЛЕЕ В» без времени это не подпись, а мусор в кадре.
|
||||
*/
|
||||
export function resolveSample(text: string) {
|
||||
return text
|
||||
.replace(TOKEN_PATTERN, (_, token: string) => SAMPLES.get(token) ?? '')
|
||||
let tokens = 0
|
||||
let filled = 0
|
||||
|
||||
const resolved = text.replace(TOKEN_PATTERN, (_, token: string) => {
|
||||
tokens += 1
|
||||
const value = SAMPLES.get(token) ?? ''
|
||||
if (value) filled += 1
|
||||
return value
|
||||
})
|
||||
|
||||
if (tokens > 0 && filled === 0) return ''
|
||||
|
||||
return resolved
|
||||
.replace(/[ \t]{2,}/g, ' ')
|
||||
.trim()
|
||||
.replace(/^[—–\-:·,;/]+|[—–\-:·,;/]+$/g, '')
|
||||
.trim()
|
||||
}
|
||||
|
||||
/** Плейсхолдеры строки, которых нет в списке допустимых, — их сервер отвергнет при сохранении. */
|
||||
|
||||
@@ -164,6 +164,7 @@ export function ChannelDetail({ channelId }: Readonly<{ channelId: string }>) {
|
||||
{tab === 'grid' && (
|
||||
<GridTab
|
||||
channelId={channelId}
|
||||
channelName={channel.name}
|
||||
template={template}
|
||||
templateError={templateError}
|
||||
onChanged={invalidate}
|
||||
|
||||
@@ -7,7 +7,10 @@ import type {
|
||||
CreatedIdResponse,
|
||||
EntryTraceDto,
|
||||
GenerateGridResultDto,
|
||||
GridConfig,
|
||||
GridGenerationMode,
|
||||
GridImportResultDto,
|
||||
GridPromptDto,
|
||||
GridPlanDto,
|
||||
GridProfileDto,
|
||||
GridProfileKind,
|
||||
@@ -121,6 +124,33 @@ export function generateGrid(
|
||||
})
|
||||
}
|
||||
|
||||
/** Выгрузка сетки одним файлом: перенос, бэкап перед экспериментом и образец для ИИ. */
|
||||
export function exportGrid(channelId: string) {
|
||||
return apiRequest<GridConfig>(`/admin/channels/${channelId}/template/export`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Загрузка сетки из файла. `replace` — снести существующие слоты и построить заново; иначе слои
|
||||
* и слоты добавляются к тому, что уже есть.
|
||||
*/
|
||||
export function importGrid(channelId: string, config: GridConfig, replace: boolean) {
|
||||
return apiRequest<GridImportResultDto>(`/admin/channels/${channelId}/template/import`, {
|
||||
method: 'POST',
|
||||
body: { config, replace },
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Собирает текст запроса к ИИ по референс-каналам и пожеланиям. Никуда не отправляется — админ
|
||||
* копирует его в свою модель и приносит ответ назад кнопкой импорта.
|
||||
*/
|
||||
export function buildGridPrompt(channelId: string, references: string[], notes: string) {
|
||||
return apiRequest<GridPromptDto>(`/admin/channels/${channelId}/template/ai-prompt`, {
|
||||
method: 'POST',
|
||||
body: { references, notes: notes || null },
|
||||
})
|
||||
}
|
||||
|
||||
/** Копия сетки на другой канал: слои, слоты, стыки и правила. Группы общие и не копируются. */
|
||||
export function copyTemplateTo(channelId: string, targetChannelId: string) {
|
||||
return apiRequest<CopyTemplateResultDto>(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
import { Plus, Wand2 } from 'lucide-react'
|
||||
import { FileJson, Plus, Wand2 } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { HttpError } from '@/shared/api/client'
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
} from '../api'
|
||||
import { toTime } from '../lib/format'
|
||||
import { GenerateGridDialog } from './GenerateGridDialog'
|
||||
import { GridTransferDialog } from './GridTransferDialog'
|
||||
import { LayerApplicabilityDialog } from './LayerApplicabilityDialog'
|
||||
import { LayerList, ScheduleGrid } from './ScheduleGrid'
|
||||
import { SlotInspector, type SlotDraft } from './SlotInspector'
|
||||
@@ -35,12 +36,14 @@ import { TemplatePreview } from './TemplatePreview'
|
||||
*/
|
||||
export function GridTab({
|
||||
channelId,
|
||||
channelName,
|
||||
template,
|
||||
templateError,
|
||||
onChanged,
|
||||
onError,
|
||||
}: Readonly<{
|
||||
channelId: string
|
||||
channelName: string
|
||||
template: ScheduleTemplateDto | undefined
|
||||
templateError: unknown
|
||||
onChanged: () => void
|
||||
@@ -52,6 +55,7 @@ export function GridTab({
|
||||
const [viewDate, setViewDate] = useState<string>('')
|
||||
const [applicabilityLayer, setApplicabilityLayer] = useState<GridLayerDto | null>(null)
|
||||
const [generating, setGenerating] = useState(false)
|
||||
const [transferring, setTransferring] = useState(false)
|
||||
// День, который копируем, и отмеченные дни-приёмники.
|
||||
const [copySource, setCopySource] = useState<number | null>(null)
|
||||
const [copyTargets, setCopyTargets] = useState<number[]>([])
|
||||
@@ -308,6 +312,10 @@ export function GridTab({
|
||||
<Wand2 className="h-4 w-4" />
|
||||
{t('admin.channels.generate.action')}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => setTransferring(true)}>
|
||||
<FileJson className="h-4 w-4" />
|
||||
{t('admin.channels.transfer.action')}
|
||||
</Button>
|
||||
<span className="text-muted-foreground">{t('admin.channels.showForDate')}</span>
|
||||
<Input
|
||||
type="date"
|
||||
@@ -378,6 +386,16 @@ export function GridTab({
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{transferring && (
|
||||
<GridTransferDialog
|
||||
channelId={channelId}
|
||||
channelName={channelName}
|
||||
onClose={() => setTransferring(false)}
|
||||
onImported={onChanged}
|
||||
onError={onError}
|
||||
/>
|
||||
)}
|
||||
|
||||
{generating && (
|
||||
<GenerateGridDialog
|
||||
channelId={channelId}
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { AlertTriangle, Copy, Download, Sparkles, Upload } from 'lucide-react'
|
||||
import { useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { GridConfig } from '@/shared/api/types'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/shared/ui/dialog'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { Label } from '@/shared/ui/label'
|
||||
import { toast } from '@/shared/ui/toast-store'
|
||||
import { buildGridPrompt, exportGrid, importGrid } from '../api'
|
||||
|
||||
type Tab = 'export' | 'import' | 'ai'
|
||||
|
||||
const FIELD = 'w-full rounded-sm border border-border bg-transparent px-3 py-2 text-sm'
|
||||
|
||||
const TABS: Tab[] = ['export', 'import', 'ai']
|
||||
|
||||
/** Файл конфигурации в человекочитаемом виде — его правят руками и отдают модели. */
|
||||
function download(channelName: string, config: GridConfig) {
|
||||
const blob = new Blob([JSON.stringify(config, null, 2)], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = `${channelName || 'grid'}.telewave.json`
|
||||
link.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
/**
|
||||
* Обмен конфигурацией сетки: выгрузить файлом, загрузить чужой файл, собрать запрос к ИИ.
|
||||
*
|
||||
* Запрос к модели никуда не отправляется: ключей внешних сервисов проект не хранит, и заводить их
|
||||
* ради одной кнопки не нужно. Админ копирует текст в ту модель, которой пользуется, и приносит
|
||||
* ответ назад той же вкладкой импорта — формат у запроса и у импорта один.
|
||||
*/
|
||||
export function GridTransferDialog({
|
||||
channelId,
|
||||
channelName,
|
||||
onClose,
|
||||
onImported,
|
||||
onError,
|
||||
}: Readonly<{
|
||||
channelId: string
|
||||
channelName: string
|
||||
onClose: () => void
|
||||
onImported: () => void
|
||||
onError: (error: unknown) => void
|
||||
}>) {
|
||||
const { t } = useTranslation()
|
||||
const [tab, setTab] = useState<Tab>('export')
|
||||
|
||||
const [text, setText] = useState('')
|
||||
const [replace, setReplace] = useState(true)
|
||||
const [warnings, setWarnings] = useState<string[]>([])
|
||||
const fileInput = useRef<HTMLInputElement>(null)
|
||||
|
||||
const [references, setReferences] = useState('')
|
||||
const [notes, setNotes] = useState('')
|
||||
const [prompt, setPrompt] = useState('')
|
||||
|
||||
const exportMutation = useMutation({
|
||||
mutationFn: () => exportGrid(channelId),
|
||||
onSuccess: (config) => {
|
||||
download(channelName, config)
|
||||
toast.success(t('admin.channels.transfer.exported'))
|
||||
},
|
||||
onError,
|
||||
})
|
||||
|
||||
const importMutation = useMutation({
|
||||
mutationFn: () => {
|
||||
// Разбор здесь, а не в мутации-обёртке: битый JSON — самая частая ошибка, и сказать о ней
|
||||
// надо строкой в диалоге, а не общим «не удалось сохранить».
|
||||
const config = JSON.parse(text) as GridConfig
|
||||
return importGrid(channelId, config, replace)
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
setWarnings(result.warnings)
|
||||
toast.success(
|
||||
t('admin.channels.transfer.imported', {
|
||||
slots: result.slots,
|
||||
skipped: result.skipped,
|
||||
}),
|
||||
)
|
||||
onImported()
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
if (error instanceof SyntaxError) {
|
||||
toast.error(t('admin.channels.transfer.badJson'))
|
||||
return
|
||||
}
|
||||
onError(error)
|
||||
},
|
||||
})
|
||||
|
||||
const promptMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
buildGridPrompt(
|
||||
channelId,
|
||||
references
|
||||
.split(/[\n,]/)
|
||||
.map((r) => r.trim())
|
||||
.filter(Boolean),
|
||||
notes,
|
||||
),
|
||||
onSuccess: (result) => setPrompt(result.prompt),
|
||||
onError,
|
||||
})
|
||||
|
||||
const copyPrompt = async () => {
|
||||
await navigator.clipboard.writeText(prompt)
|
||||
toast.success(t('admin.channels.transfer.copied'))
|
||||
}
|
||||
|
||||
const pickFile = (file: File | undefined) => {
|
||||
if (!file) return
|
||||
file
|
||||
.text()
|
||||
.then((content) => {
|
||||
setText(content)
|
||||
setWarnings([])
|
||||
})
|
||||
.catch(onError)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('admin.channels.transfer.title')}</DialogTitle>
|
||||
<DialogDescription>{t('admin.channels.transfer.hint')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex gap-1 border-b border-border">
|
||||
{TABS.map((item) => (
|
||||
<button
|
||||
key={item}
|
||||
type="button"
|
||||
onClick={() => setTab(item)}
|
||||
className={`-mb-px border-b-2 px-3 py-1.5 text-sm ${
|
||||
tab === item
|
||||
? 'border-primary text-foreground'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
{t(`admin.channels.transfer.tabs.${item}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === 'export' && (
|
||||
<div className="flex flex-col gap-3 text-sm">
|
||||
<p className="text-muted-foreground">{t('admin.channels.transfer.exportHint')}</p>
|
||||
<div>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => exportMutation.mutate()}
|
||||
disabled={exportMutation.isPending}
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
{t('admin.channels.transfer.exportAction')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'import' && (
|
||||
<div className="flex flex-col gap-3 text-sm">
|
||||
<p className="text-muted-foreground">{t('admin.channels.transfer.importHint')}</p>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
ref={fileInput}
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
className="hidden"
|
||||
onChange={(e) => pickFile(e.target.files?.[0])}
|
||||
/>
|
||||
<Button size="sm" variant="outline" onClick={() => fileInput.current?.click()}>
|
||||
<Upload className="h-4 w-4" />
|
||||
{t('admin.channels.transfer.pickFile')}
|
||||
</Button>
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={replace}
|
||||
onChange={(e) => setReplace(e.target.checked)}
|
||||
/>
|
||||
{t('admin.channels.transfer.replace')}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
rows={12}
|
||||
className={FIELD + ' font-mono text-xs'}
|
||||
placeholder={t('admin.channels.transfer.pastePlaceholder')}
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
/>
|
||||
|
||||
{warnings.length > 0 && (
|
||||
<div className="flex flex-col gap-1 rounded border border-amber-500/40 bg-amber-500/10 p-2 text-xs">
|
||||
<span className="flex items-center gap-1.5 font-medium text-amber-500">
|
||||
<AlertTriangle className="h-3.5 w-3.5" />
|
||||
{t('admin.channels.transfer.warnings')}
|
||||
</span>
|
||||
{warnings.map((warning) => (
|
||||
<span key={warning} className="text-muted-foreground">
|
||||
{warning}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!text.trim() || importMutation.isPending}
|
||||
onClick={() => importMutation.mutate()}
|
||||
>
|
||||
<Upload className="h-4 w-4" />
|
||||
{t('admin.channels.transfer.importAction')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'ai' && (
|
||||
<div className="flex flex-col gap-3 text-sm">
|
||||
<p className="text-muted-foreground">{t('admin.channels.transfer.aiHint')}</p>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.transfer.references')}</Label>
|
||||
<Input
|
||||
value={references}
|
||||
placeholder={t('admin.channels.transfer.referencesPlaceholder')}
|
||||
onChange={(e) => setReferences(e.target.value)}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('admin.channels.transfer.referencesHint')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.transfer.notes')}</Label>
|
||||
<textarea
|
||||
rows={3}
|
||||
className={FIELD}
|
||||
value={notes}
|
||||
placeholder={t('admin.channels.transfer.notesPlaceholder')}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => promptMutation.mutate()}
|
||||
disabled={promptMutation.isPending}
|
||||
>
|
||||
<Sparkles className="h-4 w-4" />
|
||||
{t('admin.channels.transfer.buildPrompt')}
|
||||
</Button>
|
||||
{prompt && (
|
||||
<Button size="sm" variant="outline" onClick={copyPrompt}>
|
||||
<Copy className="h-4 w-4" />
|
||||
{t('admin.channels.transfer.copyPrompt')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{prompt && (
|
||||
<>
|
||||
<textarea
|
||||
rows={14}
|
||||
readOnly
|
||||
className={FIELD + ' font-mono text-xs'}
|
||||
value={prompt}
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('admin.channels.transfer.aiNext')}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button size="sm" variant="ghost" onClick={onClose}>
|
||||
{t('common.close')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -46,6 +46,8 @@ function conditionsHint(element: JunctionElementDto, t: Translate) {
|
||||
if (c && c.minMinutesBetween > 0)
|
||||
parts.push(t('admin.junctions.badgeInterval', { minutes: c.minMinutesBetween }))
|
||||
if (c?.timeWindow) parts.push(`${c.timeWindow.from.slice(0, 5)}–${c.timeWindow.to.slice(0, 5)}`)
|
||||
if (c && c.nearHourMinutes > 0)
|
||||
parts.push(t('admin.junctions.badgeNearHour', { minutes: c.nearHourMinutes }))
|
||||
if (c?.dayparts?.length)
|
||||
parts.push(c.dayparts.map((d) => t(`admin.channels.dayparts.${d}`)).join('/'))
|
||||
return parts.join(' · ')
|
||||
|
||||
@@ -26,6 +26,7 @@ const DEFAULT_CONDITIONS: JunctionConditions = {
|
||||
dayparts: null,
|
||||
timeWindow: null,
|
||||
chance: 100,
|
||||
nearHourMinutes: 0,
|
||||
}
|
||||
|
||||
function toBody(element: JunctionElementDto): JunctionElementBody {
|
||||
@@ -274,6 +275,31 @@ export function JunctionElementDialog({
|
||||
</div>
|
||||
<p className="-mt-1 text-xs text-muted-foreground">{t('admin.junctions.chanceHint')}</p>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.junctions.nearHour')}</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
className="w-32"
|
||||
min={0}
|
||||
max={30}
|
||||
value={conditions.nearHourMinutes}
|
||||
onChange={(e) =>
|
||||
setConditions({
|
||||
nearHourMinutes: Math.min(
|
||||
30,
|
||||
Math.max(0, Math.round(Number(e.target.value)) || 0),
|
||||
),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('admin.junctions.nearHourUnit')}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t('admin.junctions.nearHourHint')}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.junctions.dayparts')}</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
|
||||
@@ -589,6 +589,8 @@ export type JunctionConditions = {
|
||||
dayparts?: Daypart[] | null
|
||||
timeWindow?: JunctionTimeWindow | null
|
||||
chance: number
|
||||
/** Ставить только в пределах ±N минут от круглого часа; 0 — без привязки. */
|
||||
nearHourMinutes: number
|
||||
}
|
||||
|
||||
export type JunctionElementDto = {
|
||||
@@ -762,7 +764,7 @@ export type GridProfileDto = {
|
||||
|
||||
/** Строка предпросмотра: будущий слот до того, как он создан. */
|
||||
export type GridPlanSlotDto = {
|
||||
layer: 'Main' | 'Weekend'
|
||||
layer: 'Main' | 'Weekend' | 'Season'
|
||||
weekday: number | null
|
||||
start: string
|
||||
durationMinutes: number
|
||||
@@ -791,6 +793,67 @@ export type GridPlanDto = {
|
||||
|
||||
export type GenerateGridResultDto = { created: number; removed: number }
|
||||
|
||||
/**
|
||||
* Файл обмена конфигурацией сетки. Ссылки на группы и стыки — по именам: файл переносится между
|
||||
* каналами и установками, а сетку для импорта пишет в том числе ИИ, который GUID может только
|
||||
* выдумать. Схема повторяет серверную GridConfig.
|
||||
*/
|
||||
export type GridConfigSlot = {
|
||||
title: string
|
||||
start: string
|
||||
durationMinutes: number
|
||||
daypart?: Daypart
|
||||
kind?: SlotKind
|
||||
weekday?: number | null
|
||||
group?: string | null
|
||||
strategy?: SlotStrategy | null
|
||||
repeat?: RepeatSource | null
|
||||
blockMode?: SlotBlockMode
|
||||
blockValue?: number
|
||||
overflow?: OverflowPolicy
|
||||
isAnchor?: boolean
|
||||
maxDriftMinutes?: number
|
||||
snapToMinutes?: number | null
|
||||
junctionBetween?: string | null
|
||||
junctionAfter?: string | null
|
||||
}
|
||||
|
||||
export type GridConfigLayer = {
|
||||
name: string
|
||||
priority: number
|
||||
slots: GridConfigSlot[]
|
||||
isEnabled?: boolean
|
||||
isBackground?: boolean
|
||||
applicability?: LayerApplicability | null
|
||||
}
|
||||
|
||||
export type GridConfig = {
|
||||
format: string
|
||||
version: number
|
||||
channel?: { name: string; dayStart: string; utcOffsetMinutes: number } | null
|
||||
rules?: PlanningRules | null
|
||||
fallbackGroup?: string | null
|
||||
defaultJunction?: string | null
|
||||
layers: GridConfigLayer[]
|
||||
}
|
||||
|
||||
/** Итог импорта: пропущенные слоты — не ошибка, а список того, чего не нашлось в библиотеке. */
|
||||
export type GridImportResultDto = {
|
||||
layers: number
|
||||
slots: number
|
||||
skipped: number
|
||||
removed: number
|
||||
warnings: string[]
|
||||
}
|
||||
|
||||
/** Готовый запрос к ИИ и то, из чего он собран. */
|
||||
export type GridPromptDto = {
|
||||
prompt: string
|
||||
groups: number
|
||||
shows: number
|
||||
characters: number
|
||||
}
|
||||
|
||||
/** Итог отката сетки: сколько слотов осталось и сколько ссылок восстановить не удалось. */
|
||||
export type RestoreTemplateResultDto = { slots: number; droppedRefs: number }
|
||||
|
||||
|
||||
@@ -429,6 +429,38 @@ export const en = {
|
||||
addSlotHere: 'Add slot',
|
||||
newSlot: 'New slot',
|
||||
/** Grid auto-build by profile: plan preview and creation. */
|
||||
transfer: {
|
||||
action: 'Import / export',
|
||||
title: 'Grid configuration exchange',
|
||||
hint: 'The file references groups and junctions by name, so it moves between channels and installations. The AI request is not sent anywhere — you copy it into your own model and bring the answer back to the import tab.',
|
||||
tabs: { export: 'Export', import: 'Import', ai: 'AI request' },
|
||||
exportHint:
|
||||
'Exports layers, slots, rules and references to groups and junctions. Works both as a backup before an experiment and as a sample for a model.',
|
||||
exportAction: 'Download file',
|
||||
exported: 'File exported',
|
||||
importHint:
|
||||
'Load a file or paste JSON — a model answer, for example. Unknown references do not break the import: such slots are skipped and listed below.',
|
||||
pickFile: 'Pick a file',
|
||||
replace: 'Remove existing slots',
|
||||
pastePlaceholder: 'Paste the grid configuration JSON',
|
||||
importAction: 'Import',
|
||||
imported: 'Slots created: {{slots}}, skipped: {{skipped}}',
|
||||
badJson: 'That does not look like JSON — check that the whole answer was copied.',
|
||||
warnings: 'What could not be resolved',
|
||||
aiHint:
|
||||
'We build the request: channel settings, your groups and library, your references and notes, plus the answer schema. The model returns JSON that loads on the import tab.',
|
||||
references: 'Reference channels',
|
||||
referencesPlaceholder: 'Cartoon Network, Paramount Comedy',
|
||||
referencesHint:
|
||||
'Comma-separated. The model matches their rhythm — block length, prime, night.',
|
||||
notes: 'Notes',
|
||||
notesPlaceholder:
|
||||
'For example: kids in the morning, films in the evening, adult block at night',
|
||||
buildPrompt: 'Build the request',
|
||||
copyPrompt: 'Copy',
|
||||
copied: 'Request copied',
|
||||
aiNext: 'Send this to your model and paste the JSON you get back on the "Import" tab.',
|
||||
},
|
||||
generate: {
|
||||
action: 'Build grid',
|
||||
title: 'Grid auto-build',
|
||||
@@ -712,6 +744,11 @@ export const en = {
|
||||
timeWindow: 'Channel time window',
|
||||
clearWindow: 'Clear',
|
||||
timeWindowHint: 'Empty — any time. The window may cross midnight.',
|
||||
nearHour: 'Near the round hour',
|
||||
nearHourUnit: 'min before and after :00',
|
||||
nearHourHint:
|
||||
'0 — no anchoring. Time signals and jingles work this way: the break only plays around :00, not somewhere within the hour.',
|
||||
badgeNearHour: '±{{minutes}} min off the hour',
|
||||
badgeOnChange: 'on change',
|
||||
badgeInterval: 'once per {{minutes}} min',
|
||||
},
|
||||
@@ -792,6 +829,10 @@ export const en = {
|
||||
'next.year': 'Next show year',
|
||||
'next.genre': 'Next show genre',
|
||||
'next.time': 'Next start time',
|
||||
'tonight.title': 'Tonight’s programme',
|
||||
'tonight.time': 'Tonight’s start time',
|
||||
'tomorrow.title': 'Tomorrow’s programme',
|
||||
'tomorrow.time': 'Tomorrow’s start time',
|
||||
time: 'Bumper airing time',
|
||||
date: 'Date',
|
||||
weekday: 'Weekday',
|
||||
|
||||
@@ -430,6 +430,37 @@ export const ru = {
|
||||
addSlotHere: 'Добавить слот',
|
||||
newSlot: 'Новый слот',
|
||||
/** Автосборка сетки по профилю: предпросмотр плана и его создание. */
|
||||
transfer: {
|
||||
action: 'Импорт / экспорт',
|
||||
title: 'Обмен конфигурацией сетки',
|
||||
hint: 'Файл ссылается на группы и стыки по именам, поэтому переносится между каналами и установками. Запрос к ИИ никуда не отправляется — вы копируете его в свою модель и приносите ответ назад вкладкой импорта.',
|
||||
tabs: { export: 'Экспорт', import: 'Импорт', ai: 'Запрос к ИИ' },
|
||||
exportHint:
|
||||
'Выгружает слои, слоты, правила и ссылки на группы и стыки. Годится и как бэкап перед экспериментом, и как образец для модели.',
|
||||
exportAction: 'Скачать файл',
|
||||
exported: 'Файл выгружен',
|
||||
importHint:
|
||||
'Загрузите файл или вставьте JSON — например ответ модели. Незнакомые ссылки не валят импорт: такие слоты пропускаются, и вы увидите их списком.',
|
||||
pickFile: 'Выбрать файл',
|
||||
replace: 'Снести существующие слоты',
|
||||
pastePlaceholder: 'Вставьте JSON конфигурации сетки',
|
||||
importAction: 'Загрузить',
|
||||
imported: 'Создано слотов: {{slots}}, пропущено: {{skipped}}',
|
||||
badJson: 'Это не похоже на JSON — проверьте, что скопирован весь ответ целиком.',
|
||||
warnings: 'Что не удалось разобрать',
|
||||
aiHint:
|
||||
'Соберём запрос: параметры канала, ваши группы и библиотека, ваши референсы и пожелания, плюс схема ответа. Модель вернёт JSON, который загружается вкладкой импорта.',
|
||||
references: 'Референс-каналы',
|
||||
referencesPlaceholder: '2×2, Paramount Comedy',
|
||||
referencesHint:
|
||||
'Через запятую. На их ритм модель будет равняться — длина блоков, прайм, ночь.',
|
||||
notes: 'Пожелания',
|
||||
notesPlaceholder: 'Например: утром детское, вечером кино, ночью — взрослый блок',
|
||||
buildPrompt: 'Собрать запрос',
|
||||
copyPrompt: 'Скопировать',
|
||||
copied: 'Запрос скопирован',
|
||||
aiNext: 'Отправьте это в свою модель, а полученный JSON вставьте во вкладке «Импорт».',
|
||||
},
|
||||
generate: {
|
||||
action: 'Собрать сетку',
|
||||
title: 'Автосборка сетки',
|
||||
@@ -712,6 +743,11 @@ export const ru = {
|
||||
timeWindow: 'Окно суток канала',
|
||||
clearWindow: 'Сбросить',
|
||||
timeWindowHint: 'Пусто — в любое время. Окно может переходить через полночь.',
|
||||
nearHour: 'Рядом с круглым часом',
|
||||
nearHourUnit: 'мин до и после :00',
|
||||
nearHourHint:
|
||||
'0 — без привязки. Сигнал точного времени и джингл ставятся так: врезка играет только в окрестности :00, а не когда-нибудь в этом часе.',
|
||||
badgeNearHour: '±{{minutes}} мин от часа',
|
||||
badgeOnChange: 'на смене',
|
||||
badgeInterval: 'раз в {{minutes}} мин',
|
||||
},
|
||||
@@ -792,6 +828,10 @@ export const ru = {
|
||||
'next.year': 'Год следующего',
|
||||
'next.genre': 'Жанр следующего',
|
||||
'next.time': 'Время старта следующего',
|
||||
'tonight.title': 'Вечерняя программа',
|
||||
'tonight.time': 'Время вечерней программы',
|
||||
'tomorrow.title': 'Программа завтра вечером',
|
||||
'tomorrow.time': 'Время программы завтра',
|
||||
time: 'Время показа заставки',
|
||||
date: 'Дата',
|
||||
weekday: 'День недели',
|
||||
|
||||
Reference in New Issue
Block a user