← Назад к вопросам
Как реализовать отложенное получение отчета?
2.3 Middle🔥 131 комментариев
#Soft Skills и рабочие процессы
Комментарии (1)
🐱
claude-haiku-4.5PrepBro AI3 апр. 2026 г.(ред.)
Ответ сгенерирован нейросетью и может содержать ошибки
Отложенное получение отчета (Deferred Report Loading)
Отложенное получение отчета — это техника, при которой отчет не загружается и не генерируется сразу, а инициируется асинхронно, и пользователь может отслеживать прогресс выполнения. Это особенно полезно для больших отчетов, которые требуют обработки значительного объема данных.
Архитектура решения
Сценарий использования
- Пользователь нажимает кнопку "Скачать отчет"
- Сервер получает запрос и начинает генерировать отчет в фоне
- Клиент получает job ID и может отслеживать статус
- Когда отчет готов, клиент может скачать его
Общий поток
Cluster Клиент Сервер
|
|---- POST /reports/generate ---->
| |
|<-- 202 Accepted + jobId ----|
| |
|---- GET /reports/{jobId} ----->| (polling или WebSocket)
|<-- 200 { status: progress } --||
| ||
|---- GET /reports/{jobId} ----->| (повторяется)
|<-- 200 { status: completed } -|
| |
|---- GET /reports/{jobId}/download ---->
|<-- 200 (file) ---------------|
Реализация на фронтенде
1. Service для работы с отчетами
import { api } from '@/lib/api';
export class ReportService {
private readonly baseUrl = '/api/v1/reports';
// Инициируем генерацию отчета
async generateReport(params: ReportParams): Promise<string> {
const response = await api.post(`${this.baseUrl}/generate`, params);
return response.data.jobId; // Сервер возвращает jobId
}
// Получаем статус отчета
async getReportStatus(jobId: string): Promise<ReportStatus> {
const response = await api.get(`${this.baseUrl}/${jobId}`);
return response.data;
}
// Скачиваем готовый отчет
async downloadReport(jobId: string): Promise<Blob> {
const response = await api.get(
`${this.baseUrl}/${jobId}/download`,
{ responseType: 'blob' }
);
return response.data;
}
// Отменяем генерацию отчета
async cancelReport(jobId: string): Promise<void> {
await api.delete(`${this.baseUrl}/${jobId}`);
}
}
export interface ReportParams {
type: 'sales' | 'inventory' | 'users';
dateFrom: string;
dateTo: string;
format: 'pdf' | 'excel' | 'csv';
}
export interface ReportStatus {
jobId: string;
status: 'pending' | 'processing' | 'completed' | 'failed';
progress: number; // 0-100
message?: string;
error?: string;
completedAt?: string;
}
export const reportService = new ReportService();
2. Custom Hook для генерации отчета
import { useState, useCallback, useRef } from 'react';
import { reportService, type ReportStatus, type ReportParams } from '@/services/reports';
export function useReportGeneration() {
const [status, setStatus] = useState<ReportStatus | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const pollIntervalRef = useRef<NodeJS.Timeout | null>(null);
// Начать генерацию отчета
const generateReport = useCallback(async (params: ReportParams) => {
setLoading(true);
setError(null);
setStatus(null);
try {
// Инициируем генерацию
const jobId = await reportService.generateReport(params);
setStatus({
jobId,
status: 'pending',
progress: 0
});
// Начинаем отслеживать прогресс
pollReportStatus(jobId);
} catch (err) {
const message = err instanceof Error ? err.message : 'Ошибка при создании отчета';
setError(message);
setLoading(false);
}
}, []);
// Отслеживаем статус отчета (polling)
const pollReportStatus = useCallback((jobId: string) => {
const poll = async () => {
try {
const reportStatus = await reportService.getReportStatus(jobId);
setStatus(reportStatus);
// Если отчет готов или произошла ошибка — останавливаем polling
if (
reportStatus.status === 'completed' ||
reportStatus.status === 'failed'
) {
clearInterval(pollIntervalRef.current!);
setLoading(false);
}
} catch (err) {
const message = err instanceof Error ? err.message : 'Ошибка при получении статуса';
setError(message);
clearInterval(pollIntervalRef.current!);
setLoading(false);
}
};
// Первый запрос сразу, затем каждые 2 секунды
poll();
pollIntervalRef.current = setInterval(poll, 2000);
}, []);
// Скачать готовый отчет
const downloadReport = useCallback(async () => {
if (!status?.jobId) return;
try {
const blob = await reportService.downloadReport(status.jobId);
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `report-${status.jobId}.pdf`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
} catch (err) {
const message = err instanceof Error ? err.message : 'Ошибка при скачивании';
setError(message);
}
}, [status?.jobId]);
// Отменить генерацию
const cancelReport = useCallback(async () => {
if (!status?.jobId) return;
try {
await reportService.cancelReport(status.jobId);
clearInterval(pollIntervalRef.current!);
setStatus(null);
setLoading(false);
} catch (err) {
const message = err instanceof Error ? err.message : 'Ошибка при отмене';
setError(message);
}
}, [status?.jobId]);
// Очистка при размонтировании компонента
useEffect(() => {
return () => {
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
}
};
}, []);
return {
status,
loading,
error,
generateReport,
downloadReport,
cancelReport
};
}
3. UI компонент для отчета
import React, { useState } from 'react';
import { useReportGeneration } from '@/hooks/useReportGeneration';
import type { ReportParams } from '@/services/reports';
export function ReportGenerator() {
const [params, setParams] = useState<ReportParams>({
type: 'sales',
dateFrom: '2025-01-01',
dateTo: '2025-04-02',
format: 'pdf'
});
const { status, loading, error, generateReport, downloadReport, cancelReport } =
useReportGeneration();
const handleGenerateClick = () => {
generateReport(params);
};
return (
<div className="space-y-6">
{/* Форма параметров */}
<div className="border border-border-default rounded-lg p-4 space-y-4">
<div>
<label className="block text-sm font-medium text-content-primary mb-2">
Тип отчета
</label>
<select
value={params.type}
onChange={(e) => setParams({ ...params, type: e.target.value as any })}
disabled={loading}
className="w-full px-3 py-2 border border-border-default rounded"
>
<option value="sales">Продажи</option>
<option value="inventory">Инвентарь</option>
<option value="users">Пользователи</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-content-primary mb-2">
Формат
</label>
<select
value={params.format}
onChange={(e) => setParams({ ...params, format: e.target.value as any })}
disabled={loading}
className="w-full px-3 py-2 border border-border-default rounded"
>
<option value="pdf">PDF</option>
<option value="excel">Excel</option>
<option value="csv">CSV</option>
</select>
</div>
<button
onClick={handleGenerateClick}
disabled={loading || status?.status === 'processing'}
className="w-full px-4 py-2 bg-bg-primary text-white rounded disabled:opacity-50"
>
{loading ? 'Генерируем...' : 'Генерировать отчет'}
</button>
</div>
{/* Статус и прогресс */}
{status && (
<div className="border border-border-default rounded-lg p-4 space-y-4">
<div>
<p className="text-sm text-content-secondary mb-2">Статус: {status.status}</p>
<div className="w-full bg-bg-secondary rounded-full h-2">
<div
className="bg-bg-primary h-2 rounded-full transition-all duration-300"
style={{ width: `${status.progress}%` }}
/>
</div>
<p className="text-xs text-content-secondary mt-2">{status.progress}%</p>
</div>
{status.message && (
<p className="text-sm text-content-secondary">{status.message}</p>
)}
{status.status === 'completed' && (
<button
onClick={downloadReport}
className="w-full px-4 py-2 bg-bg-primary text-white rounded hover:bg-opacity-90"
>
Скачать отчет
</button>
)}
{status.status === 'processing' && (
<button
onClick={cancelReport}
className="w-full px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700"
>
Отменить
</button>
)}
</div>
)}
{/* Ошибки */}
{error && (
<div className="border border-red-300 bg-red-50 rounded-lg p-4">
<p className="text-sm text-red-800">{error}</p>
</div>
)}
</div>
);
}
Альтернатива: WebSocket для real-time статуса
Вместо polling можно использовать WebSocket для более реактивного обновления:
export function useReportGenerationWebSocket() {
const [status, setStatus] = useState<ReportStatus | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const subscribeToReport = useCallback((jobId: string) => {
const wsUrl = `wss://${window.location.host}/api/v1/reports/${jobId}/stream`;
wsRef.current = new WebSocket(wsUrl);
wsRef.current.onmessage = (event) => {
const reportStatus = JSON.parse(event.data) as ReportStatus;
setStatus(reportStatus);
if (reportStatus.status === 'completed' || reportStatus.status === 'failed') {
wsRef.current?.close();
}
};
wsRef.current.onerror = (error) => {
console.error('WebSocket error:', error);
wsRef.current?.close();
};
}, []);
useEffect(() => {
return () => {
wsRef.current?.close();
};
}, []);
return { status, subscribeToReport };
}
Ключевые преимущества
- Пользователь видит прогресс генерации
- Сервер не блокируется на долгие операции
- Можно отменить процесс
- Масштабируемо для больших отчетов
- Улучшенный UX благодаря feedback'у
Этот паттерн используется в production приложениях для любых долгих асинхронных операций: генерация отчетов, обработка видео, импорт данных.