Implement template export/import endpoints and enhance grid generation logic
ci / build-backend (push) Successful in 1m31s
ci / build-frontend (push) Successful in 1m5s
ci / tests (push) Successful in 3m54s
ci / sonar (push) Successful in 4m40s

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:
Leonid Pershin
2026-07-28 02:15:04 +03:00
parent 7bee84c548
commit f7e7b5f7c3
45 changed files with 2929 additions and 242 deletions
@@ -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">