generation report
This commit is contained in:
@@ -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
@@ -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
@@ -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: [
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
@@ -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>
|
||||
@@ -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> </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>
|
||||
@@ -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];
|
||||
}
|
||||
```
|
||||
Reference in New Issue
Block a user