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 (