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:
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user