Add maintenance management features: implement maintenance endpoints in the API, enhance media upload handling to skip duplicate files, and update frontend routes and translations for maintenance operations.

This commit is contained in:
Leonid Pershin
2026-07-24 19:52:56 +03:00
parent 6e7db4a6a9
commit 4202c51a5b
17 changed files with 411 additions and 5 deletions
@@ -0,0 +1,132 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { AlertTriangle } from 'lucide-react'
import { HttpError } from '@/shared/api/client'
import { Button } from '@/shared/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
import { toast } from '@/shared/ui/toast-store'
import { listShows } from '@/features/admin/shows/api'
import { clearAllMedia, deleteAllShows, deleteShowMedia } from './api'
export function MaintenancePanel() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [showId, setShowId] = useState('')
const { data: shows } = useQuery({ queryKey: ['admin', 'shows'], queryFn: listShows })
const onError = (error: unknown) =>
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
// Затрагиваются медиа/шоу/каналы — сбрасываем все связанные кэши.
const invalidateAll = () => {
for (const key of [['admin', 'media'], ['admin', 'shows'], ['admin', 'channels']])
void queryClient.invalidateQueries({ queryKey: key })
}
const clearMedia = useMutation({
mutationFn: clearAllMedia,
onSuccess: (count) => {
toast.success(t('admin.maintenance.doneCount', { count }))
invalidateAll()
},
onError,
})
const clearShows = useMutation({
mutationFn: deleteAllShows,
onSuccess: (count) => {
toast.success(t('admin.maintenance.doneCount', { count }))
invalidateAll()
},
onError,
})
const clearShowMedia = useMutation({
mutationFn: () => deleteShowMedia(showId),
onSuccess: (count) => {
setShowId('')
toast.success(t('admin.maintenance.doneCount', { count }))
invalidateAll()
},
onError,
})
const confirmed = (message: string, run: () => void) => {
if (window.confirm(message)) run()
}
return (
<div className="flex flex-col gap-4">
<div className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-red-500" />
<h2 className="crt-glow text-xl font-semibold">{t('admin.maintenance.title')}</h2>
</div>
<p className="text-sm text-muted-foreground">{t('admin.maintenance.warning')}</p>
<Card>
<CardHeader>
<CardTitle>{t('admin.maintenance.clearMedia')}</CardTitle>
<CardDescription>{t('admin.maintenance.clearMediaHint')}</CardDescription>
</CardHeader>
<CardContent>
<Button
variant="destructive"
disabled={clearMedia.isPending}
onClick={() => confirmed(t('admin.maintenance.confirmClearMedia'), () => clearMedia.mutate())}
>
{t('admin.maintenance.clearMedia')}
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{t('admin.maintenance.clearShowMedia')}</CardTitle>
<CardDescription>{t('admin.maintenance.clearShowMediaHint')}</CardDescription>
</CardHeader>
<CardContent className="flex flex-wrap items-center gap-2">
<Select value={showId} onValueChange={setShowId}>
<SelectTrigger className="max-w-xs">
<SelectValue placeholder={t('admin.maintenance.pickShow')} />
</SelectTrigger>
<SelectContent>
{shows?.map((s) => (
<SelectItem key={s.id} value={s.id}>
{s.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant="destructive"
disabled={!showId || clearShowMedia.isPending}
onClick={() =>
confirmed(t('admin.maintenance.confirmClearShowMedia'), () => clearShowMedia.mutate())
}
>
{t('admin.maintenance.clearShowMedia')}
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{t('admin.maintenance.deleteShows')}</CardTitle>
<CardDescription>{t('admin.maintenance.deleteShowsHint')}</CardDescription>
</CardHeader>
<CardContent>
<Button
variant="destructive"
disabled={clearShows.isPending}
onClick={() => confirmed(t('admin.maintenance.confirmDeleteShows'), () => clearShows.mutate())}
>
{t('admin.maintenance.deleteShows')}
</Button>
</CardContent>
</Card>
</div>
)
}
@@ -0,0 +1,13 @@
import { apiRequest } from '@/shared/api/client'
export function clearAllMedia() {
return apiRequest<number>('/admin/maintenance/clear-media', { method: 'POST' })
}
export function deleteAllShows() {
return apiRequest<number>('/admin/maintenance/clear-shows', { method: 'POST' })
}
export function deleteShowMedia(showId: string) {
return apiRequest<number>(`/admin/maintenance/shows/${showId}/clear-media`, { method: 'POST' })
}
@@ -64,15 +64,31 @@ export function MediaPanel() {
const deleteMutation = useMutation({ mutationFn: deleteMedia, onSuccess: invalidate, onError })
// Массовая загрузка: файлы отправляются по одному (обработка всё равно в очереди по одному),
// прогресс — «текущий/всего · %». Список обновляется после каждого файла.
// прогресс — «текущий/всего · %». Дубликаты по имени пропускаются заранее (сервер тоже отклонит).
const handleFiles = async (files: FileList) => {
const list = Array.from(files)
let existing = new Set<string>()
try {
const all = await listMedia({
page: 1,
pageSize: 1000,
statuses: ['Pending', 'Processing', 'Ready'],
})
existing = new Set(all.items.map((a) => a.originalFileName))
} catch {
// Не удалось получить список — положимся на серверную проверку дубликатов.
}
const toUpload = list.filter((f) => !existing.has(f.name))
const skipped = list.length - toUpload.length
let uploaded = 0
for (let i = 0; i < list.length; i++) {
setUpload({ current: i + 1, total: list.length, percent: 0 })
for (let i = 0; i < toUpload.length; i++) {
setUpload({ current: i + 1, total: toUpload.length, percent: 0 })
try {
await uploadMedia(list[i], (percent) =>
setUpload({ current: i + 1, total: list.length, percent }),
await uploadMedia(toUpload[i], (percent) =>
setUpload({ current: i + 1, total: toUpload.length, percent }),
)
uploaded++
invalidate()
@@ -83,6 +99,7 @@ export function MediaPanel() {
setUpload(null)
if (fileInput.current) fileInput.current.value = ''
if (uploaded > 0) toast.success(t('admin.media.uploadedCount', { count: uploaded }))
if (skipped > 0) toast.message(t('admin.media.skippedDuplicates', { count: skipped }))
}
return (
+21
View File
@@ -17,6 +17,7 @@ import { Route as RegisterRouteImport } from './routes/register'
import { Route as SettingsRouteImport } from './routes/settings'
import { Route as AdminIndexRouteImport } from './routes/admin/index'
import { Route as AdminChannelsRouteImport } from './routes/admin/channels'
import { Route as AdminMaintenanceRouteImport } from './routes/admin/maintenance'
import { Route as AdminMediaRouteImport } from './routes/admin/media'
import { Route as AdminRolesRouteImport } from './routes/admin/roles'
import { Route as AdminShowsRouteImport } from './routes/admin/shows'
@@ -66,6 +67,11 @@ const AdminChannelsRoute = AdminChannelsRouteImport.update({
path: '/channels',
getParentRoute: () => AdminRoute,
} as any)
const AdminMaintenanceRoute = AdminMaintenanceRouteImport.update({
id: '/maintenance',
path: '/maintenance',
getParentRoute: () => AdminRoute,
} as any)
const AdminMediaRoute = AdminMediaRouteImport.update({
id: '/media',
path: '/media',
@@ -115,6 +121,7 @@ export interface FileRoutesByFullPath {
'/register': typeof RegisterRoute
'/settings': typeof SettingsRoute
'/admin/channels': typeof AdminChannelsRouteWithChildren
'/admin/maintenance': typeof AdminMaintenanceRoute
'/admin/media': typeof AdminMediaRoute
'/admin/roles': typeof AdminRolesRoute
'/admin/shows': typeof AdminShowsRouteWithChildren
@@ -131,6 +138,7 @@ export interface FileRoutesByTo {
'/login': typeof LoginRoute
'/register': typeof RegisterRoute
'/settings': typeof SettingsRoute
'/admin/maintenance': typeof AdminMaintenanceRoute
'/admin/media': typeof AdminMediaRoute
'/admin/roles': typeof AdminRolesRoute
'/admin/users': typeof AdminUsersRoute
@@ -149,6 +157,7 @@ export interface FileRoutesById {
'/register': typeof RegisterRoute
'/settings': typeof SettingsRoute
'/admin/channels': typeof AdminChannelsRouteWithChildren
'/admin/maintenance': typeof AdminMaintenanceRoute
'/admin/media': typeof AdminMediaRoute
'/admin/roles': typeof AdminRolesRoute
'/admin/shows': typeof AdminShowsRouteWithChildren
@@ -169,6 +178,7 @@ export interface FileRouteTypes {
| '/register'
| '/settings'
| '/admin/channels'
| '/admin/maintenance'
| '/admin/media'
| '/admin/roles'
| '/admin/shows'
@@ -185,6 +195,7 @@ export interface FileRouteTypes {
| '/login'
| '/register'
| '/settings'
| '/admin/maintenance'
| '/admin/media'
| '/admin/roles'
| '/admin/users'
@@ -202,6 +213,7 @@ export interface FileRouteTypes {
| '/register'
| '/settings'
| '/admin/channels'
| '/admin/maintenance'
| '/admin/media'
| '/admin/roles'
| '/admin/shows'
@@ -280,6 +292,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AdminChannelsRouteImport
parentRoute: typeof AdminRoute
}
'/admin/maintenance': {
id: '/admin/maintenance'
path: '/maintenance'
fullPath: '/admin/maintenance'
preLoaderRoute: typeof AdminMaintenanceRouteImport
parentRoute: typeof AdminRoute
}
'/admin/media': {
id: '/admin/media'
path: '/media'
@@ -369,6 +388,7 @@ const AdminShowsRouteWithChildren = AdminShowsRoute._addFileChildren(
interface AdminRouteChildren {
AdminChannelsRoute: typeof AdminChannelsRouteWithChildren
AdminMaintenanceRoute: typeof AdminMaintenanceRoute
AdminMediaRoute: typeof AdminMediaRoute
AdminRolesRoute: typeof AdminRolesRoute
AdminShowsRoute: typeof AdminShowsRouteWithChildren
@@ -378,6 +398,7 @@ interface AdminRouteChildren {
const AdminRouteChildren: AdminRouteChildren = {
AdminChannelsRoute: AdminChannelsRouteWithChildren,
AdminMaintenanceRoute: AdminMaintenanceRoute,
AdminMediaRoute: AdminMediaRoute,
AdminRolesRoute: AdminRolesRoute,
AdminShowsRoute: AdminShowsRouteWithChildren,
+7
View File
@@ -50,6 +50,13 @@ function AdminLayout() {
>
{t('admin.users.title')}
</Link>
<Link
to="/admin/maintenance"
className={cn('pb-2 uppercase tracking-wide text-muted-foreground hover:text-foreground')}
activeProps={{ className: 'border-b-2 border-primary text-primary' }}
>
{t('admin.maintenance.title')}
</Link>
</nav>
<Outlet />
</div>
@@ -0,0 +1,4 @@
import { createFileRoute } from '@tanstack/react-router'
import { MaintenancePanel } from '@/features/admin/maintenance/MaintenancePanel'
export const Route = createFileRoute('/admin/maintenance')({ component: MaintenancePanel })
+32
View File
@@ -106,6 +106,7 @@ const resources = {
upload: 'Загрузить',
uploaded: 'Файл загружен, идёт обработка',
uploadedCount: 'Загружено файлов: {{count}}',
skippedDuplicates: 'Пропущено дубликатов: {{count}}',
filterActive: 'Активные',
filterAll: 'Все',
name: 'Файл',
@@ -176,6 +177,21 @@ const resources = {
schedule: 'Расписание (12 ч)',
noSchedule: 'Расписание ещё не построено — нажмите «Пересобрать»',
},
maintenance: {
title: 'Обслуживание',
warning: 'Операции необратимы — удаляют данные и файлы навсегда.',
clearMedia: 'Очистить все медиа',
clearMediaHint: 'Удалит все загруженные файлы и их нарезку, а также расписание каналов.',
confirmClearMedia: 'Удалить ВСЕ медиа-файлы безвозвратно?',
clearShowMedia: 'Удалить медиа шоу',
clearShowMediaHint: 'Удалит файлы всех серий выбранного шоу и очистит его серии.',
confirmClearShowMedia: 'Удалить все медиа выбранного шоу безвозвратно?',
pickShow: 'Выберите шоу',
deleteShows: 'Удалить все шоу',
deleteShowsHint: 'Удалит все шоу и их серии. Медиа-файлы останутся в библиотеке.',
confirmDeleteShows: 'Удалить ВСЕ шоу безвозвратно?',
doneCount: 'Удалено: {{count}}',
},
},
},
},
@@ -283,6 +299,7 @@ const resources = {
upload: 'Upload',
uploaded: 'File uploaded, processing started',
uploadedCount: 'Uploaded files: {{count}}',
skippedDuplicates: 'Skipped duplicates: {{count}}',
filterActive: 'Active',
filterAll: 'All',
name: 'File',
@@ -353,6 +370,21 @@ const resources = {
schedule: 'Schedule (12h)',
noSchedule: 'Schedule not built yet — click “Rebuild”',
},
maintenance: {
title: 'Maintenance',
warning: 'These actions are irreversible — data and files are deleted permanently.',
clearMedia: 'Clear all media',
clearMediaHint: 'Deletes all uploaded files and their segments, plus channel schedules.',
confirmClearMedia: 'Permanently delete ALL media files?',
clearShowMedia: 'Delete show media',
clearShowMediaHint: "Deletes files of the selected show's episodes and clears its episodes.",
confirmClearShowMedia: 'Permanently delete all media of the selected show?',
pickShow: 'Pick a show',
deleteShows: 'Delete all shows',
deleteShowsHint: 'Deletes all shows and their episodes. Media files stay in the library.',
confirmDeleteShows: 'Permanently delete ALL shows?',
doneCount: 'Deleted: {{count}}',
},
},
},
},