generation report

This commit is contained in:
root
2025-09-22 16:26:38 +05:00
parent 13b613e191
commit 0f5d0dc806
10 changed files with 2046 additions and 10 deletions
+153
View File
@@ -0,0 +1,153 @@
# Функциональность отчётов
## Обзор
В приложение добавлена полная функциональность для работы с отчётами согласно техническому заданию. Система позволяет генерировать, просматривать и скачивать отчёты в форматах PDF и DOCX.
## Структура файлов
### Сервисы
- `src/service/ReportService.js` - Основной сервис для работы с API отчётов
### Компоненты
- `src/views/reports/ReportManager.vue` - Главная страница управления отчётами
- `src/views/reports/ReportGenerator.vue` - Страница создания нового отчёта
- `src/views/reports/ReportHistory.vue` - Страница истории отчётов
### Конфигурация
- `src/config/api.js` - Обновлён с эндпоинтами для отчётов
- `src/router/index.js` - Добавлены маршруты для страниц отчётов
- `src/layout/AppMenu.vue` - Добавлен раздел "Отчёты" в меню
## Функциональность
### 1. Генерация отчётов
- Создание отчётов с настраиваемыми параметрами
- Поддержка форматов PDF и DOCX
- Валидация формы с отображением ошибок
- Автоматическое скачивание сгенерированного файла
### 2. История отчётов
- Просмотр всех созданных отчётов
- Пагинация и сортировка
- Фильтрация по формату, дате и тексту
- Статистика по отчётам
### 3. Скачивание отчётов
- Скачивание отчётов из истории
- Автоматическое определение имени файла
- Обработка ошибок при скачивании
## API Endpoints
### Генерация отчёта
```
POST /api/parser/report/generate
```
**Параметры:**
- `reportTitle` (string) - Заголовок отчёта
- `authorName` (string) - Имя автора
- `companyName` (string) - Название компании
- `startDate` (string) - Дата начала (ISO 8601)
- `endDate` (string) - Дата окончания (ISO 8601)
- `format` (string) - Формат файла (PDF/DOCX)
### История отчётов
```
GET /api/parser/report/history?page=0&size=20&sort=createdAt,desc
```
**Параметры:**
- `page` (int) - Номер страницы
- `size` (int) - Размер страницы
- `sort` (string) - Поле и направление сортировки
### Скачивание отчёта
```
GET /api/parser/report/history/{id}
```
## Использование
### 1. Создание отчёта
1. Перейдите в раздел "Отчёты" → "Создать отчёт"
2. Заполните обязательные поля:
- Заголовок отчёта
- Автор
- Компания
- Период (дата начала и окончания)
- Формат файла
3. Нажмите "Сгенерировать отчёт"
4. Файл автоматически скачается
### 2. Просмотр истории
1. Перейдите в раздел "Отчёты" → "История отчётов"
2. Используйте фильтры для поиска нужных отчётов
3. Сортируйте по любому столбцу
4. Скачивайте отчёты кнопкой "Скачать"
### 3. Управление отчётами
1. Перейдите в раздел "Отчёты" → "Управление отчётами"
2. Здесь доступны все функции в одном интерфейсе
## Технические детали
### Валидация
- Все обязательные поля проверяются перед отправкой
- Проверка корректности дат (дата окончания должна быть позже даты начала)
- Отображение ошибок валидации под соответствующими полями
### Обработка ошибок
- Обработка HTTP ошибок (400, 401, 403, 404, 500)
- Пользовательские сообщения об ошибках
- Логирование ошибок в консоль
### UI/UX
- Использование PrimeVue компонентов
- Адаптивный дизайн
- Индикаторы загрузки
- Toast уведомления
- Подтверждающие диалоги
## Зависимости
Функциональность использует существующие зависимости проекта:
- Vue 3
- PrimeVue 4
- Vue Router 4
## Безопасность
- Все запросы требуют авторизации (Bearer token)
- Токен извлекается из localStorage
- Обработка ошибок авторизации
## Расширения
Для будущих версий можно добавить:
- Удаление отчётов
- Редактирование отчётов
- Предварительный просмотр отчётов
- Шаблоны отчётов
- Планировщик генерации отчётов
- Уведомления о готовности отчётов
+6 -1
View File
@@ -21,7 +21,12 @@ export const API_CONFIG = {
MONGO_HEALTH: '/api/parser/health/mongodb',
SCHEDULER_HEALTH: '/api/parser/health/scheduler',
SYSTEM_HEALTH: '/api/parser/health/system',
PARSERS_HEALTH: '/api/parser/health/parsers'
PARSERS_HEALTH: '/api/parser/health/parsers',
// ReportController - Генерация и управление отчётами
REPORT_GENERATE: '/api/parser/report/generate',
REPORT_HISTORY: '/api/parser/report/history',
REPORT_DOWNLOAD: '/api/parser/report/history'
}
};
+15 -7
View File
@@ -4,13 +4,13 @@ import { ref } from 'vue';
import AppMenuItem from './AppMenuItem.vue';
const model = ref([
{
label: 'Home',
items: [
// { label: 'Dashboard', icon: 'pi pi-fw pi-home', to: '/' },
{ label: 'New Dashboard', icon: 'pi pi-fw pi-home', to: '/dashboard-new' }
]
},
// {
// label: 'Home',
// items: [
// // { label: 'Dashboard', icon: 'pi pi-fw pi-home', to: '/' },
// { label: 'New Dashboard', icon: 'pi pi-fw pi-home', to: '/dashboard-new' }
// ]
// },
{
label: 'Новости',
items: [
@@ -18,6 +18,14 @@ const model = ref([
{ label: 'Статистика', icon: 'pi pi-fw pi-chart-bar', to: '/news/dashboard' }
]
},
{
label: 'Отчёты',
items: [
{ label: 'Управление отчётами', icon: 'pi pi-file-pdf', to: '/reports' },
{ label: 'Создать отчёт', icon: 'pi pi-plus-circle', to: '/reports/generator' },
{ label: 'История отчётов', icon: 'pi pi-history', to: '/reports/history' }
]
},
{
label: 'Admin',
items: [
+2 -2
View File
@@ -68,14 +68,14 @@ async function onLogout() {
<div class="layout-topbar-menu hidden lg:block">
<div class="layout-topbar-menu-content">
<button type="button" class="layout-topbar-action">
<!-- <button type="button" class="layout-topbar-action">
<i class="pi pi-calendar"></i>
<span>Calendar</span>
</button>
<button type="button" class="layout-topbar-action">
<i class="pi pi-inbox"></i>
<span>Messages</span>
</button>
</button> -->
<button type="button" class="layout-topbar-action" @click="onLogout">
<i class="pi pi-sign-out"></i>
<span>Logout</span>
+18
View File
@@ -149,6 +149,24 @@ const router = createRouter({
name: 'news-detail',
component: () => import('@/views/news/NewsDetail.vue'),
meta: { requiresAuth: true }
},
{
path: '/reports',
name: 'reports',
component: () => import('@/views/reports/ReportManager.vue'),
meta: { requiresAuth: true }
},
{
path: '/reports/generator',
name: 'reports-generator',
component: () => import('@/views/reports/ReportGenerator.vue'),
meta: { requiresAuth: true }
},
{
path: '/reports/history',
name: 'reports-history',
component: () => import('@/views/reports/ReportHistory.vue'),
meta: { requiresAuth: true }
}
]
},
+185
View File
@@ -0,0 +1,185 @@
import { API_CONFIG, DEFAULT_REQUEST_CONFIG } from '@/config/api.js';
class ReportService {
constructor() {
this.baseURL = API_CONFIG.BASE_URL;
this.endpoints = {
GENERATE: '/api/parser/report/generate',
HISTORY: '/api/parser/report/history',
DOWNLOAD: '/api/parser/report/history'
};
}
/**
* Генерирует новый отчёт
* @param {Object} reportData - Данные для генерации отчёта
* @param {string} reportData.reportTitle - Заголовок отчёта
* @param {string} reportData.authorName - Имя автора
* @param {string} reportData.companyName - Название компании
* @param {string} reportData.startDate - Дата начала периода (ISO 8601)
* @param {string} reportData.endDate - Дата окончания периода (ISO 8601)
* @param {string} reportData.format - Формат файла (PDF или DOCX)
* @returns {Promise<Blob>} - Файл отчёта
*/
async generateReport(reportData) {
try {
const response = await fetch(`${this.baseURL}${this.endpoints.GENERATE}`, {
method: 'POST',
headers: {
...DEFAULT_REQUEST_CONFIG.headers,
Authorization: `Bearer ${this.getAuthToken()}`
},
body: JSON.stringify(reportData)
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`HTTP error! status: ${response.status}, message: ${errorText}`);
}
return await response.blob();
} catch (error) {
console.error('Ошибка при генерации отчёта:', error);
throw error;
}
}
/**
* Получает историю отчётов с пагинацией
* @param {number} page - Номер страницы (начиная с 0)
* @param {number} size - Количество элементов на странице
* @param {string} sort - Поле и направление сортировки
* @returns {Promise<Object>} - Объект с данными пагинации и списком отчётов
*/
async getReportHistory(page = 0, size = 20, sort = 'createdAt,desc') {
try {
const params = new URLSearchParams({
page: page.toString(),
size: size.toString(),
sort: sort
});
const response = await fetch(`${this.baseURL}${this.endpoints.HISTORY}?${params}`, {
method: 'GET',
headers: {
...DEFAULT_REQUEST_CONFIG.headers,
Authorization: `Bearer ${this.getAuthToken()}`
}
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`HTTP error! status: ${response.status}, message: ${errorText}`);
}
return await response.json();
} catch (error) {
console.error('Ошибка при получении истории отчётов:', error);
throw error;
}
}
/**
* Скачивает отчёт по ID
* @param {string} reportId - ID отчёта
* @returns {Promise<Blob>} - Файл отчёта
*/
async downloadReport(reportId) {
try {
const response = await fetch(`${this.baseURL}${this.endpoints.DOWNLOAD}/${reportId}`, {
method: 'GET',
headers: {
Authorization: `Bearer ${this.getAuthToken()}`
}
});
if (response.status === 404) {
throw new Error('Отчёт не найден');
}
if (!response.ok) {
const errorText = await response.text();
throw new Error(`HTTP error! status: ${response.status}, message: ${errorText}`);
}
return await response.blob();
} catch (error) {
console.error('Ошибка при скачивании отчёта:', error);
throw error;
}
}
/**
* Скачивает файл в браузере
* @param {Blob} blob - Файл для скачивания
* @param {string} filename - Имя файла
*/
downloadFile(blob, filename) {
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
}
/**
* Извлекает имя файла из заголовка Content-Disposition
* @param {Response} response - HTTP ответ
* @returns {string} - Имя файла
*/
getFilenameFromResponse(response) {
const contentDisposition = response.headers.get('Content-Disposition');
if (contentDisposition) {
const filenameMatch = contentDisposition.match(/filename="?([^"]+)"?/);
return filenameMatch ? filenameMatch[1] : 'report.pdf';
}
return 'report.pdf';
}
/**
* Форматирует размер файла в читаемый вид
* @param {number} bytes - Размер в байтах
* @returns {string} - Отформатированный размер
*/
formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
/**
* Получает токен авторизации из localStorage
* @returns {string|null} - Токен авторизации
*/
getAuthToken() {
return localStorage.getItem('access_token');
}
/**
* Обрабатывает ошибки API
* @param {Error} error - Ошибка
* @returns {string} - Сообщение об ошибке для пользователя
*/
handleError(error) {
if (error.message.includes('404')) {
return 'Отчёт не найден';
} else if (error.message.includes('400')) {
return 'Неверные данные запроса';
} else if (error.message.includes('401')) {
return 'Необходима авторизация';
} else if (error.message.includes('403')) {
return 'Доступ запрещён';
} else if (error.message.includes('500')) {
return 'Внутренняя ошибка сервера';
} else {
return 'Произошла ошибка: ' + error.message;
}
}
}
export default new ReportService();
+322
View File
@@ -0,0 +1,322 @@
<template>
<div class="grid">
<div class="col-12">
<div class="card">
<h5>Генерация отчёта</h5>
<p>Заполните форму для создания нового отчёта на основе собранных данных.</p>
</div>
</div>
<div class="col-12 lg:col-8">
<div class="card">
<form @submit.prevent="handleGenerate" class="p-fluid">
<div class="grid">
<div class="col-12">
<div class="field">
<label for="reportTitle">Заголовок отчёта *</label>
<InputText id="reportTitle" v-model="formData.reportTitle" :class="{ 'p-invalid': errors.reportTitle }" placeholder="Введите заголовок отчёта" size="large" />
<small v-if="errors.reportTitle" class="p-error">{{ errors.reportTitle }}</small>
</div>
</div>
<div class="col-12 md:col-6">
<div class="field">
<label for="authorName">Автор *</label>
<InputText id="authorName" v-model="formData.authorName" :class="{ 'p-invalid': errors.authorName }" placeholder="Введите имя автора" />
<small v-if="errors.authorName" class="p-error">{{ errors.authorName }}</small>
</div>
</div>
<div class="col-12 md:col-6">
<div class="field">
<label for="companyName">Компания *</label>
<InputText id="companyName" v-model="formData.companyName" :class="{ 'p-invalid': errors.companyName }" placeholder="Введите название компании" />
<small v-if="errors.companyName" class="p-error">{{ errors.companyName }}</small>
</div>
</div>
<div class="col-12 md:col-6">
<div class="field">
<label for="startDate">Дата начала *</label>
<Calendar id="startDate" v-model="formData.startDate" :class="{ 'p-invalid': errors.startDate }" dateFormat="dd.mm.yy" showTime hourFormat="24" placeholder="Выберите дату начала" :maxDate="formData.endDate" />
<small v-if="errors.startDate" class="p-error">{{ errors.startDate }}</small>
</div>
</div>
<div class="col-12 md:col-6">
<div class="field">
<label for="endDate">Дата окончания *</label>
<Calendar id="endDate" v-model="formData.endDate" :class="{ 'p-invalid': errors.endDate }" dateFormat="dd.mm.yy" showTime hourFormat="24" placeholder="Выберите дату окончания" :minDate="formData.startDate" />
<small v-if="errors.endDate" class="p-error">{{ errors.endDate }}</small>
</div>
</div>
<div class="col-12 md:col-6">
<div class="field">
<label for="format">Формат файла *</label>
<Dropdown id="format" v-model="formData.format" :options="formatOptions" optionLabel="label" optionValue="value" placeholder="Выберите формат" />
</div>
</div>
<div class="col-12 md:col-6">
<div class="field">
<label for="template">Шаблон отчёта</label>
<Dropdown id="template" v-model="formData.template" :options="templateOptions" optionLabel="label" optionValue="value" placeholder="Выберите шаблон" />
</div>
</div>
<div class="col-12">
<div class="field">
<label for="description">Описание отчёта</label>
<Textarea id="description" v-model="formData.description" rows="3" placeholder="Введите краткое описание отчёта (необязательно)" />
</div>
</div>
<div class="col-12">
<div class="flex gap-2">
<Button type="submit" label="Сгенерировать отчёт" icon="pi pi-file-pdf" :loading="loading" :disabled="loading" size="large" />
<Button type="button" label="Очистить форму" icon="pi pi-refresh" severity="secondary" @click="resetForm" :disabled="loading" />
</div>
</div>
</div>
</form>
</div>
</div>
<div class="col-12 lg:col-4">
<div class="card">
<h6>Информация о генерации</h6>
<div class="flex flex-column gap-3">
<div class="flex align-items-center gap-2">
<i class="pi pi-info-circle text-blue-500"></i>
<span class="text-sm">Отчёт будет создан на основе данных за выбранный период</span>
</div>
<div class="flex align-items-center gap-2">
<i class="pi pi-clock text-orange-500"></i>
<span class="text-sm">Время генерации: 1-3 минуты</span>
</div>
<div class="flex align-items-center gap-2">
<i class="pi pi-download text-green-500"></i>
<span class="text-sm">Файл будет автоматически скачан</span>
</div>
</div>
</div>
<div class="card mt-3">
<h6>Поддерживаемые форматы</h6>
<div class="flex flex-column gap-2">
<div class="flex align-items-center gap-2">
<i class="pi pi-file-pdf text-red-500"></i>
<span class="text-sm">PDF - для печати и просмотра</span>
</div>
<div class="flex align-items-center gap-2">
<i class="pi pi-file-word text-blue-500"></i>
<span class="text-sm">DOCX - для редактирования</span>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import ReportService from '@/service/ReportService.js';
import { useToast } from 'primevue/usetoast';
import { onMounted, reactive, ref } from 'vue';
import { useRouter } from 'vue-router';
const router = useRouter();
const toast = useToast();
// Реактивные данные
const loading = ref(false);
// Данные формы
const formData = reactive({
reportTitle: '',
authorName: '',
companyName: '',
startDate: null,
endDate: null,
format: 'PDF',
template: 'standard',
description: ''
});
// Ошибки валидации
const errors = reactive({
reportTitle: null,
authorName: null,
companyName: null,
startDate: null,
endDate: null
});
// Опции
const formatOptions = [
{ label: 'PDF', value: 'PDF' },
{ label: 'DOCX', value: 'DOCX' }
];
const templateOptions = [
{ label: 'Стандартный', value: 'standard' },
{ label: 'Краткий', value: 'brief' },
{ label: 'Подробный', value: 'detailed' }
];
// Методы
const validateForm = () => {
// Очистка предыдущих ошибок
Object.keys(errors).forEach((key) => (errors[key] = null));
let isValid = true;
if (!formData.reportTitle.trim()) {
errors.reportTitle = 'Заголовок отчёта обязателен';
isValid = false;
}
if (!formData.authorName.trim()) {
errors.authorName = 'Имя автора обязательно';
isValid = false;
}
if (!formData.companyName.trim()) {
errors.companyName = 'Название компании обязательно';
isValid = false;
}
if (!formData.startDate) {
errors.startDate = 'Дата начала обязательна';
isValid = false;
}
if (!formData.endDate) {
errors.endDate = 'Дата окончания обязательна';
isValid = false;
}
if (formData.startDate && formData.endDate && formData.startDate >= formData.endDate) {
errors.endDate = 'Дата окончания должна быть позже даты начала';
isValid = false;
}
return isValid;
};
const handleGenerate = async () => {
if (!validateForm()) {
toast.add({
severity: 'error',
summary: 'Ошибка валидации',
detail: 'Пожалуйста, исправьте ошибки в форме',
life: 3000
});
return;
}
loading.value = true;
try {
// Подготовка данных для API
const reportData = {
reportTitle: formData.reportTitle.trim(),
authorName: formData.authorName.trim(),
companyName: formData.companyName.trim(),
startDate: formData.startDate.toISOString(),
endDate: formData.endDate.toISOString(),
format: formData.format
};
const blob = await ReportService.generateReport(reportData);
// Создание имени файла
const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, '-');
const filename = `report_${timestamp}.${formData.format.toLowerCase()}`;
// Скачивание файла
ReportService.downloadFile(blob, filename);
toast.add({
severity: 'success',
summary: 'Успешно',
detail: 'Отчёт сгенерирован и скачан',
life: 3000
});
// Переход к истории отчётов
setTimeout(() => {
router.push('/reports/history');
}, 2000);
} catch (error) {
console.error('Ошибка генерации отчёта:', error);
toast.add({
severity: 'error',
summary: 'Ошибка',
detail: ReportService.handleError(error),
life: 5000
});
} finally {
loading.value = false;
}
};
const resetForm = () => {
Object.assign(formData, {
reportTitle: '',
authorName: '',
companyName: '',
startDate: null,
endDate: null,
format: 'PDF',
template: 'standard',
description: ''
});
Object.keys(errors).forEach((key) => (errors[key] = null));
};
// Инициализация
onMounted(() => {
// Установка дат по умолчанию (последние 7 дней)
const today = new Date();
const weekAgo = new Date(today.getTime() - 7 * 24 * 60 * 60 * 1000);
formData.endDate = today;
formData.startDate = weekAgo;
});
</script>
<style scoped>
.p-fluid .field {
margin-bottom: 1rem;
}
.card {
margin-bottom: 1rem;
}
.text-sm {
font-size: 0.875rem;
}
.flex {
display: flex;
}
.flex-column {
flex-direction: column;
}
.gap-2 {
gap: 0.5rem;
}
.gap-3 {
gap: 0.75rem;
}
.align-items-center {
align-items: center;
}
</style>
+498
View File
@@ -0,0 +1,498 @@
<template>
<div class="grid">
<div class="col-12">
<div class="card">
<div class="flex justify-content-between align-items-center">
<div>
<h5>История отчётов</h5>
<p>Просмотр и скачивание ранее созданных отчётов</p>
</div>
<div class="flex gap-2">
<Button icon="pi pi-refresh" @click="loadReports" :loading="loading" severity="secondary" text v-tooltip.top="'Обновить список'" />
<Button label="Создать отчёт" icon="pi pi-plus" @click="goToGenerator" severity="success" />
</div>
</div>
</div>
</div>
<!-- Фильтры -->
<div class="col-12">
<div class="card">
<h6>Фильтры</h6>
<div class="grid">
<div class="col-12 md:col-3">
<div class="field">
<label for="search">Поиск</label>
<InputText id="search" v-model="filters.search" placeholder="Поиск по заголовку, автору или компании" @input="onSearch" />
</div>
</div>
<div class="col-12 md:col-2">
<div class="field">
<label for="format">Формат</label>
<Dropdown id="format" v-model="filters.format" :options="formatOptions" optionLabel="label" optionValue="value" placeholder="Все форматы" @change="applyFilters" />
</div>
</div>
<div class="col-12 md:col-2">
<div class="field">
<label for="dateFrom">Дата с</label>
<Calendar id="dateFrom" v-model="filters.dateFrom" dateFormat="dd.mm.yy" placeholder="Выберите дату" @date-select="applyFilters" />
</div>
</div>
<div class="col-12 md:col-2">
<div class="field">
<label for="dateTo">Дата по</label>
<Calendar id="dateTo" v-model="filters.dateTo" dateFormat="dd.mm.yy" placeholder="Выберите дату" @date-select="applyFilters" />
</div>
</div>
<div class="col-12 md:col-3">
<div class="field">
<label>&nbsp;</label>
<div class="flex gap-2">
<Button label="Применить" icon="pi pi-filter" @click="applyFilters" severity="info" />
<Button label="Сбросить" icon="pi pi-times" @click="resetFilters" severity="secondary" text />
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Статистика -->
<div class="col-12">
<div class="grid">
<div class="col-12 md:col-3">
<div class="card text-center">
<div class="text-2xl font-bold text-blue-500">{{ totalReports }}</div>
<div class="text-sm text-600">Всего отчётов</div>
</div>
</div>
<div class="col-12 md:col-3">
<div class="card text-center">
<div class="text-2xl font-bold text-green-500">{{ thisMonthReports }}</div>
<div class="text-sm text-600">За этот месяц</div>
</div>
</div>
<div class="col-12 md:col-3">
<div class="card text-center">
<div class="text-2xl font-bold text-orange-500">{{ thisWeekReports }}</div>
<div class="text-sm text-600">За эту неделю</div>
</div>
</div>
<div class="col-12 md:col-3">
<div class="card text-center">
<div class="text-2xl font-bold text-purple-500">{{ totalSize }}</div>
<div class="text-sm text-600">Общий размер</div>
</div>
</div>
</div>
</div>
<!-- Таблица отчётов -->
<div class="col-12">
<div class="card">
<DataTable
:value="reports"
:loading="loading"
:paginator="true"
:rows="pageSize"
:totalRecords="totalElements"
:lazy="true"
@page="onPageChange"
@sort="onSort"
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport RowsPerPageDropdown"
:rowsPerPageOptions="[10, 20, 50]"
currentPageReportTemplate="Показано {first} - {last} из {totalRecords} записей"
emptyMessage="Отчёты не найдены"
responsiveLayout="scroll"
:sortField="sortField"
:sortOrder="sortOrder"
removableSort
>
<Column field="reportTitle" header="Заголовок" sortable>
<template #body="{ data }">
<div class="flex flex-column">
<div class="font-semibold">{{ data.reportTitle }}</div>
<div class="text-sm text-600" v-if="data.description">{{ data.description }}</div>
</div>
</template>
</Column>
<Column field="authorName" header="Автор" sortable>
<template #body="{ data }">
<div class="flex align-items-center gap-2">
<i class="pi pi-user text-blue-500"></i>
<span>{{ data.authorName }}</span>
</div>
</template>
</Column>
<Column field="companyName" header="Компания" sortable>
<template #body="{ data }">
<div class="flex align-items-center gap-2">
<i class="pi pi-building text-green-500"></i>
<span>{{ data.companyName }}</span>
</div>
</template>
</Column>
<Column field="startDate" header="Период" sortable>
<template #body="{ data }">
<div class="flex flex-column">
<div class="text-sm">{{ formatDate(data.startDate) }}</div>
<div class="text-sm text-600">по {{ formatDate(data.endDate) }}</div>
</div>
</template>
</Column>
<Column field="format" header="Формат" sortable>
<template #body="{ data }">
<Tag :value="data.format" :severity="data.format === 'PDF' ? 'danger' : 'info'" :icon="data.format === 'PDF' ? 'pi pi-file-pdf' : 'pi pi-file-word'" />
</template>
</Column>
<Column field="fileSize" header="Размер" sortable>
<template #body="{ data }">
<div class="flex align-items-center gap-2">
<i class="pi pi-database text-orange-500"></i>
<span>{{ formatFileSize(data.fileSize) }}</span>
</div>
</template>
</Column>
<Column field="createdAt" header="Создан" sortable>
<template #body="{ data }">
<div class="flex flex-column">
<div class="text-sm">{{ formatDateTime(data.createdAt) }}</div>
<div class="text-xs text-600">{{ getTimeAgo(data.createdAt) }}</div>
</div>
</template>
</Column>
<Column header="Действия" :exportable="false" style="min-width: 120px">
<template #body="{ data }">
<div class="flex gap-1">
<Button icon="pi pi-download" @click="handleDownload(data.id)" severity="success" text rounded :loading="downloadingReports.includes(data.id)" v-tooltip.top="'Скачать отчёт'" />
<Button icon="pi pi-eye" @click="viewReport(data)" severity="info" text rounded v-tooltip.top="'Просмотреть детали'" />
<Button icon="pi pi-trash" @click="confirmDelete(data)" severity="danger" text rounded v-tooltip.top="'Удалить отчёт'" />
</div>
</template>
</Column>
</DataTable>
</div>
</div>
</div>
<!-- Диалог просмотра отчёта -->
<Dialog v-model:visible="showReportDialog" :header="selectedReport?.reportTitle" :modal="true" :style="{ width: '50vw' }" :maximizable="true">
<div v-if="selectedReport" class="flex flex-column gap-3">
<div class="grid">
<div class="col-6"><strong>Автор:</strong> {{ selectedReport.authorName }}</div>
<div class="col-6"><strong>Компания:</strong> {{ selectedReport.companyName }}</div>
<div class="col-6"><strong>Период:</strong> {{ formatDate(selectedReport.startDate) }} - {{ formatDate(selectedReport.endDate) }}</div>
<div class="col-6">
<strong>Формат:</strong>
<Tag :value="selectedReport.format" :severity="selectedReport.format === 'PDF' ? 'danger' : 'info'" />
</div>
<div class="col-6"><strong>Размер:</strong> {{ formatFileSize(selectedReport.fileSize) }}</div>
<div class="col-6"><strong>Создан:</strong> {{ formatDateTime(selectedReport.createdAt) }}</div>
</div>
</div>
<template #footer>
<Button label="Скачать" icon="pi pi-download" @click="handleDownload(selectedReport?.id)" severity="success" />
<Button label="Закрыть" icon="pi pi-times" @click="showReportDialog = false" severity="secondary" text />
</template>
</Dialog>
<!-- Диалог подтверждения удаления -->
<Dialog v-model:visible="showDeleteDialog" :header="'Удаление отчёта'" :modal="true" :style="{ width: '25vw' }">
<div class="flex align-items-center gap-3">
<i class="pi pi-exclamation-triangle text-orange-500 text-2xl"></i>
<span>Вы уверены, что хотите удалить этот отчёт? Это действие нельзя отменить.</span>
</div>
<template #footer>
<Button label="Отмена" icon="pi pi-times" @click="showDeleteDialog = false" severity="secondary" text />
<Button label="Удалить" icon="pi pi-trash" @click="handleDelete" severity="danger" />
</template>
</Dialog>
</template>
<script setup>
import ReportService from '@/service/ReportService.js';
import { useConfirm } from 'primevue/useconfirm';
import { useToast } from 'primevue/usetoast';
import { computed, onMounted, reactive, ref } from 'vue';
import { useRouter } from 'vue-router';
const router = useRouter();
const toast = useToast();
const confirm = useConfirm();
// Реактивные данные
const loading = ref(false);
const reports = ref([]);
const totalElements = ref(0);
const pageSize = ref(20);
const currentPage = ref(0);
const downloadingReports = ref([]);
const showReportDialog = ref(false);
const showDeleteDialog = ref(false);
const selectedReport = ref(null);
const reportToDelete = ref(null);
// Фильтры
const filters = reactive({
search: '',
format: null,
dateFrom: null,
dateTo: null
});
// Сортировка
const sortField = ref('createdAt');
const sortOrder = ref(-1);
// Опции
const formatOptions = [
{ label: 'Все форматы', value: null },
{ label: 'PDF', value: 'PDF' },
{ label: 'DOCX', value: 'DOCX' }
];
// Вычисляемые свойства
const totalReports = computed(() => totalElements.value);
const thisMonthReports = computed(() => {
const now = new Date();
const thisMonth = now.getMonth();
const thisYear = now.getFullYear();
return reports.value.filter((report) => {
const reportDate = new Date(report.createdAt);
return reportDate.getMonth() === thisMonth && reportDate.getFullYear() === thisYear;
}).length;
});
const thisWeekReports = computed(() => {
const now = new Date();
const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
return reports.value.filter((report) => {
const reportDate = new Date(report.createdAt);
return reportDate >= weekAgo;
}).length;
});
const totalSize = computed(() => {
const totalBytes = reports.value.reduce((sum, report) => sum + (report.fileSize || 0), 0);
return ReportService.formatFileSize(totalBytes);
});
// Методы
const loadReports = async (page = 0) => {
loading.value = true;
try {
const sort = `${sortField.value},${sortOrder.value === 1 ? 'asc' : 'desc'}`;
const data = await ReportService.getReportHistory(page, pageSize.value, sort);
reports.value = data.content || [];
totalElements.value = data.totalElements || 0;
currentPage.value = page;
} catch (error) {
console.error('Ошибка загрузки истории:', error);
toast.add({
severity: 'error',
summary: 'Ошибка',
detail: ReportService.handleError(error),
life: 5000
});
} finally {
loading.value = false;
}
};
const onPageChange = (event) => {
loadReports(event.page);
};
const onSort = (event) => {
sortField.value = event.sortField;
sortOrder.value = event.sortOrder;
loadReports(currentPage.value);
};
const onSearch = () => {
// Debounce поиска
clearTimeout(searchTimeout.value);
searchTimeout.value = setTimeout(() => {
applyFilters();
}, 500);
};
const searchTimeout = ref(null);
const applyFilters = () => {
// Здесь можно добавить логику фильтрации на клиенте
// или передать параметры на сервер
loadReports(0);
};
const resetFilters = () => {
Object.assign(filters, {
search: '',
format: null,
dateFrom: null,
dateTo: null
});
loadReports(0);
};
const goToGenerator = () => {
router.push('/reports/generator');
};
const handleDownload = async (reportId) => {
downloadingReports.value.push(reportId);
try {
const blob = await ReportService.downloadReport(reportId);
// Получение имени файла из истории
const report = reports.value.find((r) => r.id === reportId);
const filename = report ? report.filename : `report_${reportId}.pdf`;
ReportService.downloadFile(blob, filename);
toast.add({
severity: 'success',
summary: 'Успешно',
detail: 'Отчёт скачан',
life: 3000
});
} catch (error) {
console.error('Ошибка скачивания:', error);
toast.add({
severity: 'error',
summary: 'Ошибка',
detail: ReportService.handleError(error),
life: 5000
});
} finally {
downloadingReports.value = downloadingReports.value.filter((id) => id !== reportId);
}
};
const viewReport = (report) => {
selectedReport.value = report;
showReportDialog.value = true;
};
const confirmDelete = (report) => {
reportToDelete.value = report;
showDeleteDialog.value = true;
};
const handleDelete = async () => {
// Здесь должна быть логика удаления отчёта
// Пока что просто закрываем диалог
showDeleteDialog.value = false;
toast.add({
severity: 'info',
summary: 'Информация',
detail: 'Функция удаления будет добавлена в следующих версиях',
life: 3000
});
};
const formatDate = (dateString) => {
return new Date(dateString).toLocaleDateString('ru-RU');
};
const formatDateTime = (dateString) => {
return new Date(dateString).toLocaleString('ru-RU');
};
const formatFileSize = (bytes) => {
return ReportService.formatFileSize(bytes);
};
const getTimeAgo = (dateString) => {
const now = new Date();
const date = new Date(dateString);
const diffInHours = Math.floor((now - date) / (1000 * 60 * 60));
if (diffInHours < 1) return 'только что';
if (diffInHours < 24) return `${diffInHours} ч. назад`;
const diffInDays = Math.floor(diffInHours / 24);
if (diffInDays < 7) return `${diffInDays} дн. назад`;
const diffInWeeks = Math.floor(diffInDays / 7);
if (diffInWeeks < 4) return `${diffInWeeks} нед. назад`;
const diffInMonths = Math.floor(diffInDays / 30);
return `${diffInMonths} мес. назад`;
};
// Инициализация
onMounted(() => {
loadReports();
});
</script>
<style scoped>
.p-fluid .field {
margin-bottom: 1rem;
}
.card {
margin-bottom: 1rem;
}
.text-600 {
color: #6b7280;
}
.font-semibold {
font-weight: 600;
}
.text-sm {
font-size: 0.875rem;
}
.text-xs {
font-size: 0.75rem;
}
.flex {
display: flex;
}
.flex-column {
flex-direction: column;
}
.gap-1 {
gap: 0.25rem;
}
.gap-2 {
gap: 0.5rem;
}
.gap-3 {
gap: 0.75rem;
}
.align-items-center {
align-items: center;
}
.justify-content-between {
justify-content: space-between;
}
.text-center {
text-align: center;
}
</style>
+383
View File
@@ -0,0 +1,383 @@
<template>
<div class="grid">
<div class="col-12">
<div class="card">
<h5>Генерация отчётов</h5>
<p>Создайте новый отчёт на основе собранных данных за выбранный период.</p>
</div>
</div>
<!-- Форма генерации отчёта -->
<div class="col-12 lg:col-6">
<div class="card">
<h5>Создать новый отчёт</h5>
<form @submit.prevent="handleGenerate" class="p-fluid">
<div class="field">
<label for="reportTitle">Заголовок отчёта *</label>
<InputText id="reportTitle" v-model="formData.reportTitle" :class="{ 'p-invalid': errors.reportTitle }" placeholder="Введите заголовок отчёта" />
<small v-if="errors.reportTitle" class="p-error">{{ errors.reportTitle }}</small>
</div>
<div class="field">
<label for="authorName">Автор *</label>
<InputText id="authorName" v-model="formData.authorName" :class="{ 'p-invalid': errors.authorName }" placeholder="Введите имя автора" />
<small v-if="errors.authorName" class="p-error">{{ errors.authorName }}</small>
</div>
<div class="field">
<label for="companyName">Компания *</label>
<InputText id="companyName" v-model="formData.companyName" :class="{ 'p-invalid': errors.companyName }" placeholder="Введите название компании" />
<small v-if="errors.companyName" class="p-error">{{ errors.companyName }}</small>
</div>
<div class="field">
<label for="startDate">Дата начала *</label>
<Calendar id="startDate" v-model="formData.startDate" :class="{ 'p-invalid': errors.startDate }" dateFormat="dd.mm.yy" showTime hourFormat="24" placeholder="Выберите дату начала" />
<small v-if="errors.startDate" class="p-error">{{ errors.startDate }}</small>
</div>
<div class="field">
<label for="endDate">Дата окончания *</label>
<Calendar id="endDate" v-model="formData.endDate" :class="{ 'p-invalid': errors.endDate }" dateFormat="dd.mm.yy" showTime hourFormat="24" placeholder="Выберите дату окончания" />
<small v-if="errors.endDate" class="p-error">{{ errors.endDate }}</small>
</div>
<div class="field">
<label for="format">Формат файла *</label>
<Dropdown id="format" v-model="formData.format" :options="formatOptions" optionLabel="label" optionValue="value" placeholder="Выберите формат" />
</div>
<div class="field">
<Button type="submit" label="Сгенерировать отчёт" icon="pi pi-file-pdf" :loading="loading" :disabled="loading" class="w-full" />
</div>
</form>
</div>
</div>
<!-- Статистика -->
<div class="col-12 lg:col-6">
<div class="card">
<h5>Статистика отчётов</h5>
<div class="grid">
<div class="col-6">
<div class="text-center">
<div class="text-2xl font-bold text-blue-500">{{ totalReports }}</div>
<div class="text-sm text-600">Всего отчётов</div>
</div>
</div>
<div class="col-6">
<div class="text-center">
<div class="text-2xl font-bold text-green-500">{{ thisMonthReports }}</div>
<div class="text-sm text-600">За этот месяц</div>
</div>
</div>
</div>
</div>
</div>
<!-- История отчётов -->
<div class="col-12">
<div class="card">
<div class="flex justify-content-between align-items-center mb-3">
<h5>История отчётов</h5>
<Button icon="pi pi-refresh" @click="loadReports" :loading="loading" severity="secondary" text />
</div>
<DataTable
:value="reports"
:loading="loading"
:paginator="true"
:rows="pageSize"
:totalRecords="totalElements"
:lazy="true"
@page="onPageChange"
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport RowsPerPageDropdown"
:rowsPerPageOptions="[10, 20, 50]"
currentPageReportTemplate="Показано {first} - {last} из {totalRecords} записей"
emptyMessage="Отчёты не найдены"
responsiveLayout="scroll"
>
<Column field="reportTitle" header="Заголовок" sortable>
<template #body="{ data }">
<div class="font-semibold">{{ data.reportTitle }}</div>
</template>
</Column>
<Column field="authorName" header="Автор" sortable></Column>
<Column field="companyName" header="Компания" sortable></Column>
<Column field="startDate" header="Период" sortable>
<template #body="{ data }">
<div>{{ formatDate(data.startDate) }} - {{ formatDate(data.endDate) }}</div>
</template>
</Column>
<Column field="format" header="Формат" sortable>
<template #body="{ data }">
<Tag :value="data.format" :severity="data.format === 'PDF' ? 'danger' : 'info'" />
</template>
</Column>
<Column field="fileSize" header="Размер" sortable>
<template #body="{ data }">
{{ formatFileSize(data.fileSize) }}
</template>
</Column>
<Column field="createdAt" header="Создан" sortable>
<template #body="{ data }">
{{ formatDateTime(data.createdAt) }}
</template>
</Column>
<Column header="Действия" :exportable="false">
<template #body="{ data }">
<Button icon="pi pi-download" @click="handleDownload(data.id)" severity="success" text rounded :loading="downloadingReports.includes(data.id)" />
</template>
</Column>
</DataTable>
</div>
</div>
</div>
</template>
<script setup>
import ReportService from '@/service/ReportService.js';
import { useToast } from 'primevue/usetoast';
import { computed, onMounted, reactive, ref } from 'vue';
const toast = useToast();
// Реактивные данные
const loading = ref(false);
const reports = ref([]);
const totalElements = ref(0);
const pageSize = ref(20);
const currentPage = ref(0);
const downloadingReports = ref([]);
// Данные формы
const formData = reactive({
reportTitle: '',
authorName: '',
companyName: '',
startDate: null,
endDate: null,
format: 'PDF'
});
// Ошибки валидации
const errors = reactive({
reportTitle: null,
authorName: null,
companyName: null,
startDate: null,
endDate: null
});
// Опции форматов
const formatOptions = [
{ label: 'PDF', value: 'PDF' },
{ label: 'DOCX', value: 'DOCX' }
];
// Вычисляемые свойства
const totalReports = computed(() => totalElements.value);
const thisMonthReports = computed(() => {
const now = new Date();
const thisMonth = now.getMonth();
const thisYear = now.getFullYear();
return reports.value.filter((report) => {
const reportDate = new Date(report.createdAt);
return reportDate.getMonth() === thisMonth && reportDate.getFullYear() === thisYear;
}).length;
});
// Методы
const validateForm = () => {
// Очистка предыдущих ошибок
Object.keys(errors).forEach((key) => (errors[key] = null));
let isValid = true;
if (!formData.reportTitle.trim()) {
errors.reportTitle = 'Заголовок отчёта обязателен';
isValid = false;
}
if (!formData.authorName.trim()) {
errors.authorName = 'Имя автора обязательно';
isValid = false;
}
if (!formData.companyName.trim()) {
errors.companyName = 'Название компании обязательно';
isValid = false;
}
if (!formData.startDate) {
errors.startDate = 'Дата начала обязательна';
isValid = false;
}
if (!formData.endDate) {
errors.endDate = 'Дата окончания обязательна';
isValid = false;
}
if (formData.startDate && formData.endDate && formData.startDate >= formData.endDate) {
errors.endDate = 'Дата окончания должна быть позже даты начала';
isValid = false;
}
return isValid;
};
const handleGenerate = async () => {
if (!validateForm()) {
toast.add({
severity: 'error',
summary: 'Ошибка валидации',
detail: 'Пожалуйста, исправьте ошибки в форме',
life: 3000
});
return;
}
loading.value = true;
try {
// Подготовка данных для API
const reportData = {
reportTitle: formData.reportTitle.trim(),
authorName: formData.authorName.trim(),
companyName: formData.companyName.trim(),
startDate: formData.startDate.toISOString(),
endDate: formData.endDate.toISOString(),
format: formData.format
};
const blob = await ReportService.generateReport(reportData);
// Создание имени файла
const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, '-');
const filename = `report_${timestamp}.${formData.format.toLowerCase()}`;
// Скачивание файла
ReportService.downloadFile(blob, filename);
toast.add({
severity: 'success',
summary: 'Успешно',
detail: 'Отчёт сгенерирован и скачан',
life: 3000
});
// Перезагрузка списка отчётов
await loadReports();
} catch (error) {
console.error('Ошибка генерации отчёта:', error);
toast.add({
severity: 'error',
summary: 'Ошибка',
detail: ReportService.handleError(error),
life: 5000
});
} finally {
loading.value = false;
}
};
const loadReports = async (page = 0) => {
loading.value = true;
try {
const data = await ReportService.getReportHistory(page, pageSize.value, 'createdAt,desc');
reports.value = data.content || [];
totalElements.value = data.totalElements || 0;
currentPage.value = page;
} catch (error) {
console.error('Ошибка загрузки истории:', error);
toast.add({
severity: 'error',
summary: 'Ошибка',
detail: ReportService.handleError(error),
life: 5000
});
} finally {
loading.value = false;
}
};
const onPageChange = (event) => {
loadReports(event.page);
};
const handleDownload = async (reportId) => {
downloadingReports.value.push(reportId);
try {
const blob = await ReportService.downloadReport(reportId);
// Получение имени файла из истории
const report = reports.value.find((r) => r.id === reportId);
const filename = report ? report.filename : `report_${reportId}.pdf`;
ReportService.downloadFile(blob, filename);
toast.add({
severity: 'success',
summary: 'Успешно',
detail: 'Отчёт скачан',
life: 3000
});
} catch (error) {
console.error('Ошибка скачивания:', error);
toast.add({
severity: 'error',
summary: 'Ошибка',
detail: ReportService.handleError(error),
life: 5000
});
} finally {
downloadingReports.value = downloadingReports.value.filter((id) => id !== reportId);
}
};
const formatDate = (dateString) => {
return new Date(dateString).toLocaleDateString('ru-RU');
};
const formatDateTime = (dateString) => {
return new Date(dateString).toLocaleString('ru-RU');
};
const formatFileSize = (bytes) => {
return ReportService.formatFileSize(bytes);
};
// Инициализация
onMounted(() => {
loadReports();
});
</script>
<style scoped>
.p-fluid .field {
margin-bottom: 1rem;
}
.card {
margin-bottom: 1rem;
}
.text-600 {
color: #6b7280;
}
.font-semibold {
font-weight: 600;
}
</style>
@@ -0,0 +1,464 @@
# Руководство по API для фронтенда: Генерация и история отчётов
## Базовый URL
```
/api/parser/report
```
## 1. Генерация отчёта
### POST /api/parser/report/generate
Создаёт новый отчёт и возвращает файл для скачивания.
**Заголовки:**
```
Content-Type: application/json
```
**Тело запроса:**
```json
{
"reportTitle": "Еженедельный анализ новостного фона",
"authorName": "Имя Аналитика",
"companyName": "Название Компании Клиента",
"startDate": "2025-09-10T00:00:00",
"endDate": "2025-09-17T23:59:59",
"format": "PDF"
}
```
**Параметры:**
- `reportTitle` (string, обязательный) - заголовок отчёта
- `authorName` (string, обязательный) - имя автора
- `companyName` (string, обязательный) - название компании
- `startDate` (string, обязательный) - дата начала периода в формате ISO 8601
- `endDate` (string, обязательный) - дата окончания периода в формате ISO 8601
- `format` (string, обязательный) - формат файла: "PDF" или "DOCX"
**Успешный ответ (200 OK):**
```
Content-Type: application/pdf (или application/vnd.openxmlformats-officedocument.wordprocessingml.document)
Content-Disposition: attachment; filename="report_2025-09-17-1234567890.pdf"
[бинарные данные файла]
```
**Пример на JavaScript:**
```javascript
async function generateReport(reportData) {
try {
const response = await fetch('/api/parser/report/generate', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(reportData)
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
// Получаем имя файла из заголовка
const contentDisposition = response.headers.get('Content-Disposition');
const filename = contentDisposition ? contentDisposition.split('filename=')[1].replace(/"/g, '') : 'report.pdf';
// Создаём blob и скачиваем файл
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
} catch (error) {
console.error('Ошибка при генерации отчёта:', error);
}
}
// Использование
const reportData = {
reportTitle: 'Еженедельный анализ новостного фона',
authorName: 'Иван Петров',
companyName: 'ООО Рога и Копыта',
startDate: '2025-01-01T00:00:00',
endDate: '2025-01-07T23:59:59',
format: 'PDF'
};
generateReport(reportData);
```
## 2. Получение истории отчётов
### GET /api/parser/report/history
Возвращает пагинированный список всех сгенерированных отчётов.
**Параметры запроса:**
- `page` (int, опциональный, по умолчанию: 0) - номер страницы (начиная с 0)
- `size` (int, опциональный, по умолчанию: 20) - количество элементов на странице
- `sort` (string, опциональный, по умолчанию: "createdAt,desc") - поле и направление сортировки
**Пример запроса:**
```
GET /api/parser/report/history?page=0&size=10&sort=createdAt,desc
```
**Успешный ответ (200 OK):**
```json
{
"content": [
{
"id": "507f1f77bcf86cd799439011",
"reportTitle": "Еженедельный анализ новостного фона",
"authorName": "Имя Аналитика",
"companyName": "Название Компании Клиента",
"startDate": "2025-09-10T00:00:00",
"endDate": "2025-09-17T23:59:59",
"format": "PDF",
"filename": "report_2025-09-17-1234567890.pdf",
"fileSize": 1234567,
"createdAt": "2025-09-17T21:11:58"
}
],
"pageable": {
"sort": {
"sorted": true,
"unsorted": false,
"empty": false
},
"pageNumber": 0,
"pageSize": 20,
"offset": 0,
"paged": true,
"unpaged": false
},
"totalElements": 1,
"totalPages": 1,
"last": true,
"first": true,
"numberOfElements": 1,
"size": 20,
"number": 0,
"sort": {
"sorted": true,
"unsorted": false,
"empty": false
},
"empty": false
}
```
**Пример на JavaScript:**
```javascript
async function getReportHistory(page = 0, size = 20, sort = 'createdAt,desc') {
try {
const params = new URLSearchParams({
page: page.toString(),
size: size.toString(),
sort: sort
});
const response = await fetch(`/api/parser/report/history?${params}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
return data;
} catch (error) {
console.error('Ошибка при получении истории отчётов:', error);
throw error;
}
}
// Использование
getReportHistory(0, 10, 'createdAt,desc')
.then((data) => {
console.log('Отчёты:', data.content);
console.log('Всего элементов:', data.totalElements);
console.log('Всего страниц:', data.totalPages);
})
.catch((error) => {
console.error('Ошибка:', error);
});
```
## 3. Скачивание отчёта из истории
### GET /api/parser/report/history/{id}
Скачивает конкретный отчёт по его ID.
**Параметры:**
- `id` (string, обязательный) - ID отчёта из истории
**Успешный ответ (200 OK):**
```
Content-Type: application/pdf (или application/vnd.openxmlformats-officedocument.wordprocessingml.document)
Content-Disposition: attachment; filename="report_2025-09-17-1234567890.pdf"
[бинарные данные файла]
```
**Ошибки:**
- `404 Not Found` - отчёт с указанным ID не найден
- `500 Internal Server Error` - ошибка при чтении файла
**Пример на JavaScript:**
```javascript
async function downloadReport(reportId) {
try {
const response = await fetch(`/api/parser/report/history/${reportId}`);
if (response.status === 404) {
throw new Error('Отчёт не найден');
}
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
// Получаем имя файла из заголовка
const contentDisposition = response.headers.get('Content-Disposition');
const filename = contentDisposition ? contentDisposition.split('filename=')[1].replace(/"/g, '') : 'report.pdf';
// Создаём blob и скачиваем файл
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
} catch (error) {
console.error('Ошибка при скачивании отчёта:', error);
throw error;
}
}
// Использование
downloadReport('507f1f77bcf86cd799439011')
.then(() => console.log('Отчёт скачан'))
.catch((error) => console.error('Ошибка:', error));
```
## 4. Полный пример React компонента
```jsx
import React, { useState, useEffect } from 'react';
const ReportManager = () => {
const [reports, setReports] = useState([]);
const [loading, setLoading] = useState(false);
const [currentPage, setCurrentPage] = useState(0);
const [totalPages, setTotalPages] = useState(0);
const [formData, setFormData] = useState({
reportTitle: '',
authorName: '',
companyName: '',
startDate: '',
endDate: '',
format: 'PDF'
});
// Загрузка истории отчётов
const loadReports = async (page = 0) => {
setLoading(true);
try {
const data = await getReportHistory(page, 10);
setReports(data.content);
setTotalPages(data.totalPages);
setCurrentPage(page);
} catch (error) {
console.error('Ошибка загрузки истории:', error);
} finally {
setLoading(false);
}
};
// Генерация нового отчёта
const handleGenerate = async (e) => {
e.preventDefault();
setLoading(true);
try {
await generateReport(formData);
// Перезагружаем историю после генерации
await loadReports(currentPage);
} catch (error) {
console.error('Ошибка генерации:', error);
} finally {
setLoading(false);
}
};
// Скачивание отчёта
const handleDownload = async (reportId) => {
try {
await downloadReport(reportId);
} catch (error) {
console.error('Ошибка скачивания:', error);
}
};
useEffect(() => {
loadReports();
}, []);
return (
<div className="report-manager">
<h2>Генерация отчётов</h2>
{/* Форма генерации */}
<form onSubmit={handleGenerate} className="report-form">
<div>
<label>Заголовок отчёта:</label>
<input type="text" value={formData.reportTitle} onChange={(e) => setFormData({ ...formData, reportTitle: e.target.value })} required />
</div>
<div>
<label>Автор:</label>
<input type="text" value={formData.authorName} onChange={(e) => setFormData({ ...formData, authorName: e.target.value })} required />
</div>
<div>
<label>Компания:</label>
<input type="text" value={formData.companyName} onChange={(e) => setFormData({ ...formData, companyName: e.target.value })} required />
</div>
<div>
<label>Дата начала:</label>
<input type="datetime-local" value={formData.startDate} onChange={(e) => setFormData({ ...formData, startDate: e.target.value })} required />
</div>
<div>
<label>Дата окончания:</label>
<input type="datetime-local" value={formData.endDate} onChange={(e) => setFormData({ ...formData, endDate: e.target.value })} required />
</div>
<div>
<label>Формат:</label>
<select value={formData.format} onChange={(e) => setFormData({ ...formData, format: e.target.value })}>
<option value="PDF">PDF</option>
<option value="DOCX">DOCX</option>
</select>
</div>
<button type="submit" disabled={loading}>
{loading ? 'Генерация...' : 'Сгенерировать отчёт'}
</button>
</form>
{/* История отчётов */}
<h2>История отчётов</h2>
{loading ? (
<div>Загрузка...</div>
) : (
<>
<div className="reports-list">
{reports.map((report) => (
<div key={report.id} className="report-item">
<h3>{report.reportTitle}</h3>
<p>Автор: {report.authorName}</p>
<p>Компания: {report.companyName}</p>
<p>
Период: {new Date(report.startDate).toLocaleDateString()} - {new Date(report.endDate).toLocaleDateString()}
</p>
<p>Формат: {report.format}</p>
<p>Размер: {(report.fileSize / 1024).toFixed(1)} KB</p>
<p>Создан: {new Date(report.createdAt).toLocaleString()}</p>
<button onClick={() => handleDownload(report.id)}>Скачать</button>
</div>
))}
</div>
{/* Пагинация */}
<div className="pagination">
<button onClick={() => loadReports(currentPage - 1)} disabled={currentPage === 0}>
Предыдущая
</button>
<span>
Страница {currentPage + 1} из {totalPages}
</span>
<button onClick={() => loadReports(currentPage + 1)} disabled={currentPage >= totalPages - 1}>
Следующая
</button>
</div>
</>
)}
</div>
);
};
export default ReportManager;
```
## 5. Обработка ошибок
### Типичные ошибки и их обработка:
1. **400 Bad Request** - неверные данные в запросе
2. **404 Not Found** - отчёт не найден (при скачивании)
3. **500 Internal Server Error** - внутренняя ошибка сервера
```javascript
async function handleApiCall(apiFunction, ...args) {
try {
return await apiFunction(...args);
} catch (error) {
if (error.message.includes('404')) {
alert('Отчёт не найден');
} else if (error.message.includes('400')) {
alert('Неверные данные запроса');
} else {
alert('Произошла ошибка: ' + error.message);
}
throw error;
}
}
```
## 6. Форматы дат
Все даты должны передаваться в формате ISO 8601:
- `2025-01-01T00:00:00` - 1 января 2025, 00:00:00
- `2025-01-01T23:59:59` - 1 января 2025, 23:59:59
Для HTML input[type="datetime-local"] используйте формат:
- `2025-01-01T00:00` (без секунд и часового пояса)
## 7. Размеры файлов
Размеры файлов возвращаются в байтах. Для отображения пользователю конвертируйте:
```javascript
function formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
```