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
+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>