/
tsps
/
lk
Обзор
Документация
Войти
/
tsps
/
lk
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
master
src/components/UploadAudioModal.tsx
274 строки
9 KB
Василий Петров
Загрузка аудио с поллингом
01 апр 2026, 15:03
01 апр 2026, 15:03
c90f57a
Код
Авторство
О чём код?
import { useState, useEffect } from 'react'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, } from '@/components/ui/command'; import { Popover, PopoverContent, PopoverTrigger, } from '@/components/ui/popover'; import { CaseEntity } from '@/types/case_entities'; import { useCaseEntities } from '@/hooks/useCaseEntities'; import { apiFetch } from '@/lib/api/client'; import { useAudioTranscriptionJobsStore } from '@/store/audioTranscriptionJobsStore'; import { useAuthStore } from '@/store/authStore'; import { Check, ChevronsUpDown, Loader2 } from 'lucide-react'; import { cn } from '@/lib/utils'; interface UploadAudioModalProps { open: boolean; onOpenChange: (open: boolean) => void; onSuccess: () => void; } const MAX_FILE_SIZE = 500 * 1024 * 1024; // 500 MB export function UploadAudioModal({ open, onOpenChange, onSuccess, }: UploadAudioModalProps) { const { addActiveJob } = useAudioTranscriptionJobsStore(); const { judge } = useAuthStore(); const [file, setFile] = useState<File | null>(null); const [fileError, setFileError] = useState<string | null>(null); const [selectedCase, setSelectedCase] = useState<CaseEntity | null>(null); const [submitting, setSubmitting] = useState(false); const [formError, setFormError] = useState<string | null>(null); const [caseSearchOpen, setCaseSearchOpen] = useState(false); const { case_entities, loading: casesLoading, search, setSearch, hasMore, goToPage, page, } = useCaseEntities(); // Автоподстановка суда и судьи из профиля const courtId = judge?.current_court_id; const judgeId = judge?.id; // Блокировка формы, если в профиле нет обязательных данных const isProfileReady = Boolean(courtId && judgeId); // Сброс формы при закрытии useEffect(() => { if (!open) { setFile(null); setFileError(null); setSelectedCase(null); setSubmitting(false); setFormError(null); setSearch(''); setCaseSearchOpen(false); } }, [open, setSearch]); const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { const selected = e.target.files?.[0]; if (!selected) { setFile(null); setFileError(null); return; } if (!selected.type.startsWith('audio/')) { setFileError('Только аудиофайлы (MP3, WAV, OGG и т.д.)'); setFile(null); return; } if (selected.size > MAX_FILE_SIZE) { setFileError('Размер файла не должен превышать 500 МБ'); setFile(null); return; } setFile(selected); setFileError(null); }; const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setFormError(null); if (!file || !selectedCase) { setFormError('Выберите файл и дело'); return; } setSubmitting(true); try { const formData = new FormData(); formData.append('file', file); formData.append('case_entity_id', String(selectedCase.id)); formData.append('court_id', String(courtId)); formData.append('judge_id', String(judgeId)); const data = await apiFetch('/audio-recordings', { method: 'POST', body: formData, }); addActiveJob(data.id, 'created'); onSuccess(); onOpenChange(false); } catch (err) { setFormError(err instanceof Error ? err.message : 'Ошибка загрузки'); } finally { setSubmitting(false); } }; return ( <Dialog open={open} onOpenChange={onOpenChange}> <DialogContent className="sm:max-w-[600px] max-h-[90vh] overflow-y-auto"> <form onSubmit={handleSubmit}> <DialogHeader> <DialogTitle>Загрузить аудиозапись</DialogTitle> <DialogDescription> Выберите аудиофайл и укажите реквизиты дела </DialogDescription> </DialogHeader> <div className="grid gap-4 py-4"> {/* Файл */} <div className="grid gap-2"> <Label htmlFor="audio-file">Аудиофайл</Label> <Input id="audio-file" type="file" accept="audio/*" onChange={handleFileChange} disabled={submitting} /> {file && ( <p className="text-xs text-muted-foreground"> {file.name} • {(file.size / 1024 / 1024).toFixed(1)} МБ </p> )} {fileError && ( <p className="text-xs text-destructive">{fileError}</p> )} </div> {/* Дело (Combobox с поиском) */} <div className="grid gap-2"> <Label>Дело</Label> <Popover open={caseSearchOpen} onOpenChange={setCaseSearchOpen}> <PopoverTrigger asChild> <Button variant="outline" role="combobox" aria-expanded={caseSearchOpen} className={cn( 'w-full justify-between', !selectedCase && 'text-muted-foreground' )} disabled={submitting} > {selectedCase ? `Дело № ${selectedCase.number}` : 'Найти дело...'} <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> </Button> </PopoverTrigger> <PopoverContent className="w-[--radix-popover-trigger-width] p-0" align="start"> <Command> <CommandInput placeholder="Поиск по номеру дела..." value={search} onValueChange={setSearch} /> <CommandList className="max-h-[300px]"> <CommandEmpty> {casesLoading ? 'Загрузка...' : 'Дела не найдены'} </CommandEmpty> <CommandGroup> {case_entities.map((item) => ( <CommandItem key={item.id} value={item.number} onSelect={() => { setSelectedCase(item); setCaseSearchOpen(false); }} > <Check className={cn( 'mr-2 h-4 w-4', selectedCase?.id === item.id ? 'opacity-100' : 'opacity-0' )} /> № {item.number} </CommandItem> ))} </CommandGroup> {hasMore && ( <div className="p-2 text-center"> <Button variant="ghost" size="sm" onClick={(e) => { e.preventDefault(); goToPage(page + 1); }} disabled={casesLoading} > Показать ещё </Button> </div> )} </CommandList> </Command> </PopoverContent> </Popover> </div> {formError && ( <p className="text-sm text-destructive">{formError}</p> )} </div> <DialogFooter> <Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={submitting} > Отмена </Button> <Button type="submit" disabled={submitting || !file || !selectedCase}> {submitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} Загрузить </Button> </DialogFooter> </form> </DialogContent> </Dialog> ); }