This commit is contained in:
root
2025-09-15 09:31:51 +05:00
parent 32e9ce8267
commit 13b613e191
18 changed files with 4048 additions and 3 deletions
+498
View File
@@ -0,0 +1,498 @@
<template>
<div class="card">
<Toast />
<div class="flex justify-content-between align-items-center mb-4">
<h1 class="text-2xl font-semibold m-0">Управление парсером</h1>
<Button icon="pi pi-refresh" label="Обновить статус" @click="loadStatus" :loading="loading" severity="secondary" />
</div>
<!-- Статус системы -->
<div class="grid mb-4">
<div class="col-12 md:col-6">
<Card>
<template #title>
<div class="flex align-items-center">
<i class="pi pi-cog mr-2"></i>
Статус сервиса
</div>
</template>
<template #content>
<div v-if="healthLoading" class="text-center py-3">
<ProgressSpinner size="30" />
</div>
<div v-else-if="health" class="space-y-3">
<div class="flex justify-content-between align-items-center">
<span class="font-medium">Статус:</span>
<Tag :value="health.status" :severity="health.status === 'UP' ? 'success' : 'danger'" />
</div>
<div class="flex justify-content-between align-items-center">
<span class="font-medium">Сервис:</span>
<span>{{ health.service }}</span>
</div>
<div class="flex justify-content-between align-items-center">
<span class="font-medium">Планировщик:</span>
<span>{{ health.scheduler }}</span>
</div>
<div class="flex justify-content-between align-items-center">
<span class="font-medium">Время:</span>
<span>{{ formatDate(health.timestamp) }}</span>
</div>
</div>
<div v-else class="text-center py-3 text-color-secondary">
<i class="pi pi-exclamation-triangle text-2xl mb-2"></i>
<p>Не удалось загрузить статус сервиса</p>
</div>
</template>
</Card>
</div>
<div class="col-12 md:col-6">
<Card>
<template #title>
<div class="flex align-items-center">
<i class="pi pi-database mr-2"></i>
Статус базы данных
</div>
</template>
<template #content>
<div v-if="mongoHealthLoading" class="text-center py-3">
<ProgressSpinner size="30" />
</div>
<div v-else-if="mongoHealth" class="space-y-3">
<div class="flex justify-content-between align-items-center">
<span class="font-medium">Статус:</span>
<Tag :value="mongoHealth.status" :severity="mongoHealth.status === 'UP' ? 'success' : 'danger'" />
</div>
<div class="flex justify-content-between align-items-center">
<span class="font-medium">Сообщение:</span>
<span class="text-sm">{{ mongoHealth.message }}</span>
</div>
<div class="flex justify-content-between align-items-center">
<span class="font-medium">Время:</span>
<span>{{ formatDate(mongoHealth.timestamp) }}</span>
</div>
<div v-if="mongoHealth.suggestion" class="mt-3 p-2 bg-orange-50 border-orange-200 rounded">
<div class="text-sm text-orange-800">
<i class="pi pi-info-circle mr-1"></i>
{{ mongoHealth.suggestion }}
</div>
</div>
</div>
<div v-else class="text-center py-3 text-color-secondary">
<i class="pi pi-exclamation-triangle text-2xl mb-2"></i>
<p>Не удалось загрузить статус базы данных</p>
</div>
</template>
</Card>
</div>
</div>
<!-- Управление парсингом -->
<Card class="mb-4">
<template #title>
<div class="flex align-items-center">
<i class="pi pi-play mr-2"></i>
Запуск парсинга
</div>
</template>
<template #content>
<div class="grid">
<!-- Кнопка для всех источников -->
<div class="col-12 md:col-4">
<div class="text-center p-4 border-round bg-blue-50">
<i class="pi pi-globe text-blue-500 text-4xl mb-3"></i>
<h3 class="text-lg font-semibold mb-2">Все источники</h3>
<p class="text-sm text-color-secondary mb-3">Запустить парсинг всех доступных источников</p>
<Button label="Запустить все" icon="pi pi-play" @click="parseAll" :loading="parsingAll" severity="info" class="w-full" />
</div>
</div>
<!-- Динамические кнопки для каждого источника -->
<div v-if="sourcesLoading" class="col-12">
<div class="text-center py-4">
<ProgressSpinner size="30" />
<p class="mt-2 text-color-secondary">Загрузка источников...</p>
</div>
</div>
<div v-else-if="sources.length === 0" class="col-12">
<div class="text-center py-4">
<i class="pi pi-exclamation-triangle text-orange-500 text-4xl mb-3"></i>
<p class="text-color-secondary">Источники не найдены</p>
</div>
</div>
<div v-else v-for="(source, index) in sources" :key="source" class="col-12 md:col-4">
<div class="text-center p-4 border-round" :class="getSourceCardClass(index)">
<i :class="getSourceIcon(index)" class="text-4xl mb-3"></i>
<h3 class="text-lg font-semibold mb-2">{{ source }}</h3>
<p class="text-sm text-color-secondary mb-3">Запустить парсинг только {{ source }}</p>
<Button :label="`Запустить ${source}`" icon="pi pi-play" @click="parseSource(getSourceKey(source))" :loading="parsingSources[getSourceKey(source)]" :severity="getSourceSeverity(index)" class="w-full" />
</div>
</div>
</div>
</template>
</Card>
<!-- Результаты последнего парсинга -->
<Card v-if="lastParseResult" class="mb-4">
<template #title>
<div class="flex align-items-center">
<i class="pi pi-check-circle mr-2"></i>
Результаты последнего парсинга
</div>
</template>
<template #content>
<div class="grid">
<div v-for="result in lastParseResult" :key="result.source" class="col-12 md:col-6">
<div class="p-3 border-round" :class="getResultCardClass(result.status)">
<div class="flex justify-content-between align-items-center mb-2">
<h4 class="text-lg font-semibold m-0">{{ result.source }}</h4>
<Tag :value="result.status" :severity="getStatusSeverity(result.status)" />
</div>
<div class="space-y-2">
<div class="flex justify-content-between">
<span class="text-sm text-color-secondary">Обработано:</span>
<span class="font-semibold">{{ result.processedItems }}</span>
</div>
<div class="flex justify-content-between">
<span class="text-sm text-color-secondary">Всего в БД:</span>
<span class="font-semibold">{{ result.totalItemsInDb }}</span>
</div>
</div>
</div>
</div>
</div>
</template>
</Card>
<!-- Статистика -->
<Card class="mb-4">
<template #title>
<div class="flex align-items-center">
<i class="pi pi-chart-bar mr-2"></i>
Текущая статистика
</div>
</template>
<template #content>
<div v-if="statsLoading" class="text-center py-3">
<ProgressSpinner size="30" />
</div>
<div v-else-if="stats" class="grid">
<div class="col-12 md:col-3">
<div class="text-center p-3 border-round bg-blue-50">
<div class="text-2xl font-bold text-blue-900">{{ stats.totalItems || 0 }}</div>
<div class="text-sm text-blue-600">Всего новостей</div>
</div>
</div>
<div class="col-12 md:col-3">
<div class="text-center p-3 border-round bg-green-50">
<div class="text-2xl font-bold text-green-900">{{ sourceCount }}</div>
<div class="text-sm text-green-600">Источников</div>
</div>
</div>
<div class="col-12 md:col-3">
<div class="text-center p-3 border-round bg-orange-50">
<div class="text-2xl font-bold text-orange-900">{{ averagePerSource }}</div>
<div class="text-sm text-orange-600">Среднее на источник</div>
</div>
</div>
<div class="col-12 md:col-3">
<div class="text-center p-3 border-round bg-purple-50">
<div class="text-2xl font-bold text-purple-900">{{ formatDate(stats.lastUpdate) }}</div>
<div class="text-sm text-purple-600">Последнее обновление</div>
</div>
</div>
</div>
<div v-else class="text-center py-3 text-color-secondary">
<p>Не удалось загрузить статистику</p>
</div>
</template>
</Card>
<!-- Действия -->
<div class="flex justify-content-center gap-2">
<Button label="Перейти к новостям" icon="pi pi-arrow-right" @click="goToNews" severity="info" />
<Button label="Статистика" icon="pi pi-chart-bar" @click="goToDashboard" severity="secondary" />
</div>
</div>
</template>
<script setup>
import NewsService from '@/service/NewsService';
import Button from 'primevue/button';
import Card from 'primevue/card';
import ProgressSpinner from 'primevue/progressspinner';
import Tag from 'primevue/tag';
import { useToast } from 'primevue/usetoast';
import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
const router = useRouter();
const toast = useToast();
// Реактивные данные
const health = ref(null);
const mongoHealth = ref(null);
const stats = ref(null);
const sources = ref([]);
const groupedSources = ref({});
const lastParseResult = ref(null);
const loading = ref(false);
const healthLoading = ref(false);
const mongoHealthLoading = ref(false);
const statsLoading = ref(false);
const sourcesLoading = ref(false);
const parsingAll = ref(false);
const parsingSources = ref({});
// Вычисляемые свойства
const sourceCount = computed(() => {
return stats.value?.sourceStatistics ? Object.keys(stats.value.sourceStatistics).length : 0;
});
const averagePerSource = computed(() => {
if (!stats.value?.sourceStatistics || sourceCount.value === 0) return 0;
const total = Object.values(stats.value.sourceStatistics).reduce((sum, count) => sum + count, 0);
return Math.round(total / sourceCount.value);
});
// Методы
const loadStatus = async () => {
loading.value = true;
try {
await Promise.all([loadHealth(), loadMongoHealth(), loadStats(), loadSources()]);
} finally {
loading.value = false;
}
};
const loadHealth = async () => {
healthLoading.value = true;
try {
const response = await NewsService.getHealth();
if (response.success) {
health.value = response.data;
}
} catch (error) {
console.error('Ошибка при загрузке статуса сервиса:', error);
} finally {
healthLoading.value = false;
}
};
const loadMongoHealth = async () => {
mongoHealthLoading.value = true;
try {
const response = await NewsService.getMongoHealth();
if (response.success) {
mongoHealth.value = response.data;
}
} catch (error) {
console.error('Ошибка при загрузке статуса MongoDB:', error);
} finally {
mongoHealthLoading.value = false;
}
};
const loadStats = async () => {
statsLoading.value = true;
try {
const response = await NewsService.getStats();
if (response.success) {
stats.value = response.data;
}
} catch (error) {
console.error('Ошибка при загрузке статистики:', error);
} finally {
statsLoading.value = false;
}
};
const loadSources = async () => {
sourcesLoading.value = true;
try {
const response = await NewsService.getSources();
if (response.success) {
// Группируем источники по source_name
const groupedSourcesData = response.data.reduce((groups, source) => {
const sourceName = source.source_name || source; // Поддержка как объектов, так и строк
if (!groups[sourceName]) {
groups[sourceName] = [];
}
groups[sourceName].push(source);
return groups;
}, {});
console.log('Загруженные источники:', response.data);
console.log('Сгруппированные источники:', groupedSourcesData);
// Сохраняем сгруппированные данные
groupedSources.value = groupedSourcesData;
// Преобразуем в массив для отображения
sources.value = Object.keys(groupedSourcesData);
}
} catch (error) {
console.error('Ошибка при загрузке источников:', error);
toast.add({
severity: 'error',
summary: 'Ошибка',
detail: 'Не удалось загрузить список источников',
life: 3000
});
} finally {
sourcesLoading.value = false;
}
};
const parseAll = async () => {
parsingAll.value = true;
try {
const response = await NewsService.parseAll();
if (response.success) {
lastParseResult.value = response.data;
toast.add({
severity: 'success',
summary: 'Успешно',
detail: 'Парсинг всех источников завершен успешно',
life: 3000
});
// Обновить статистику
await loadStats();
}
} catch (error) {
console.error('Ошибка при запуске парсинга всех источников:', error);
toast.add({
severity: 'error',
summary: 'Ошибка',
detail: error.message || 'Ошибка при запуске парсинга всех источников',
life: 5000
});
} finally {
parsingAll.value = false;
}
};
const parseSource = async (source) => {
parsingSources.value[source] = true;
try {
const response = await NewsService.parseSource(source);
if (response.success) {
toast.add({
severity: 'success',
summary: 'Успешно',
detail: `Парсинг источника ${source} завершен успешно`,
life: 3000
});
// Обновить статистику
await loadStats();
}
} catch (error) {
console.error(`Ошибка при запуске парсинга источника ${source}:`, error);
toast.add({
severity: 'error',
summary: 'Ошибка',
detail: error.message || `Ошибка при запуске парсинга источника ${source}`,
life: 5000
});
} finally {
parsingSources.value[source] = false;
}
};
const getResultCardClass = (status) => {
switch (status) {
case 'completed':
return 'bg-green-50 border-green-200';
case 'failed':
return 'bg-red-50 border-red-200';
case 'in_progress':
return 'bg-yellow-50 border-yellow-200';
default:
return 'bg-gray-50 border-gray-200';
}
};
const getStatusSeverity = (status) => {
switch (status) {
case 'completed':
return 'success';
case 'failed':
return 'danger';
case 'in_progress':
return 'warning';
default:
return 'info';
}
};
// Вспомогательные методы для источников
const getSourceKey = (sourceName) => {
// Маппинг названий источников на ключи API
const sourceMap = {
'Kursiv Media': 'kursiv',
'Kapital.kz': 'kapital',
'LSM.kz': 'lsm',
РБК: 'rbc',
Ведомости: 'vedomosti'
};
// Сначала проверяем точное совпадение
if (sourceMap[sourceName]) {
return sourceMap[sourceName];
}
// Затем проверяем частичные совпадения (регистронезависимо)
const lowerSourceName = sourceName.toLowerCase();
for (const [key, value] of Object.entries(sourceMap)) {
if (key.toLowerCase().includes(lowerSourceName) || lowerSourceName.includes(key.toLowerCase())) {
return value;
}
}
// Если ничего не найдено, создаем ключ из названия
return sourceName.toLowerCase().replace(/[^a-z0-9]/g, '');
};
const getSourceCardClass = (index) => {
const classes = ['bg-green-50', 'bg-orange-50', 'bg-purple-50', 'bg-red-50', 'bg-cyan-50'];
return classes[index % classes.length];
};
const getSourceIcon = (index) => {
const icons = ['pi pi-building text-green-500', 'pi pi-briefcase text-orange-500', 'pi pi-chart-line text-purple-500', 'pi pi-newspaper text-red-500', 'pi pi-globe text-cyan-500'];
return icons[index % icons.length];
};
const getSourceSeverity = (index) => {
const severities = ['success', 'warning', 'help', 'danger', 'info'];
return severities[index % severities.length];
};
const formatDate = (timestamp) => {
if (!timestamp) return 'Неизвестно';
return NewsService.formatDate(new Date(timestamp).toISOString());
};
const goToNews = () => {
router.push('/news');
};
const goToDashboard = () => {
router.push('/news/dashboard');
};
// Жизненный цикл
onMounted(() => {
loadStatus();
});
</script>
<style scoped>
.space-y-2 > * + * {
margin-top: 0.5rem;
}
.space-y-3 > * + * {
margin-top: 0.75rem;
}
</style>
+366
View File
@@ -0,0 +1,366 @@
<template>
<div class="card">
<div class="flex justify-content-between align-items-center mb-4">
<h1 class="text-2xl font-semibold m-0">Статистика новостей</h1>
<Button icon="pi pi-refresh" label="Обновить" @click="loadData" :loading="loading" severity="secondary" />
</div>
<!-- Общая статистика -->
<div class="grid mb-4">
<div class="col-12 md:col-3">
<div class="card bg-blue-50 border-blue-200">
<div class="flex align-items-center">
<i class="pi pi-align-left text-blue-500 text-3xl mr-3"></i>
<div>
<div class="text-sm text-blue-600">Всего новостей</div>
<div class="text-2xl font-bold text-blue-900">{{ stats.totalItems || 0 }}</div>
</div>
</div>
</div>
</div>
<div class="col-12 md:col-3">
<div class="card bg-green-50 border-green-200">
<div class="flex align-items-center">
<i class="pi pi-calendar text-green-500 text-3xl mr-3"></i>
<div>
<div class="text-sm text-green-600">Последнее обновление</div>
<div class="text-sm font-semibold text-green-900">{{ formatDate(stats.lastUpdate) }}</div>
</div>
</div>
</div>
</div>
<div class="col-12 md:col-3">
<div class="card bg-orange-50 border-orange-200">
<div class="flex align-items-center">
<i class="pi pi-database text-orange-500 text-3xl mr-3"></i>
<div>
<div class="text-sm text-orange-600">Источников</div>
<div class="text-2xl font-bold text-orange-900">{{ sourceCount }}</div>
</div>
</div>
</div>
</div>
<div class="col-12 md:col-3">
<div class="card bg-purple-50 border-purple-200">
<div class="flex align-items-center">
<i class="pi pi-chart-line text-purple-500 text-3xl mr-3"></i>
<div>
<div class="text-sm text-purple-600">Среднее на источник</div>
<div class="text-2xl font-bold text-purple-900">{{ averagePerSource }}</div>
</div>
</div>
</div>
</div>
</div>
<!-- Статус системы -->
<div class="grid mb-4">
<div class="col-12 md:col-6">
<Card>
<template #title>
<div class="flex align-items-center">
<i class="pi pi-cog mr-2"></i>
Статус сервиса
</div>
</template>
<template #content>
<div v-if="healthLoading" class="text-center py-3">
<ProgressSpinner size="30" />
</div>
<div v-else-if="health" class="space-y-3">
<div class="flex justify-content-between align-items-center">
<span class="font-medium">Сервис:</span>
<Tag :value="health.status" :severity="health.status === 'UP' ? 'success' : 'danger'" />
</div>
<div class="flex justify-content-between align-items-center">
<span class="font-medium">Название:</span>
<span>{{ health.service }}</span>
</div>
<div class="flex justify-content-between align-items-center">
<span class="font-medium">Планировщик:</span>
<span>{{ health.scheduler }}</span>
</div>
<div class="flex justify-content-between align-items-center">
<span class="font-medium">Время:</span>
<span>{{ formatDate(health.timestamp) }}</span>
</div>
</div>
<div v-else class="text-center py-3 text-color-secondary">
<i class="pi pi-exclamation-triangle text-2xl mb-2"></i>
<p>Не удалось загрузить статус сервиса</p>
</div>
</template>
</Card>
</div>
<div class="col-12 md:col-6">
<Card>
<template #title>
<div class="flex align-items-center">
<i class="pi pi-database mr-2"></i>
Статус базы данных
</div>
</template>
<template #content>
<div v-if="mongoHealthLoading" class="text-center py-3">
<ProgressSpinner size="30" />
</div>
<div v-else-if="mongoHealth" class="space-y-3">
<div class="flex justify-content-between align-items-center">
<span class="font-medium">Статус:</span>
<Tag :value="mongoHealth.status" :severity="mongoHealth.status === 'UP' ? 'success' : 'danger'" />
</div>
<div class="flex justify-content-between align-items-center">
<span class="font-medium">Сообщение:</span>
<span class="text-sm">{{ mongoHealth.message }}</span>
</div>
<div class="flex justify-content-between align-items-center">
<span class="font-medium">Время:</span>
<span>{{ formatDate(mongoHealth.timestamp) }}</span>
</div>
<div v-if="mongoHealth.suggestion" class="mt-3 p-2 bg-orange-50 border-orange-200 rounded">
<div class="text-sm text-orange-800">
<i class="pi pi-info-circle mr-1"></i>
{{ mongoHealth.suggestion }}
</div>
</div>
</div>
<div v-else class="text-center py-3 text-color-secondary">
<i class="pi pi-exclamation-triangle text-2xl mb-2"></i>
<p>Не удалось загрузить статус базы данных</p>
</div>
</template>
</Card>
</div>
</div>
<!-- Статистика по источникам -->
<div class="grid">
<div class="col-12 lg:col-8">
<Card>
<template #title>
<div class="flex align-items-center">
<i class="pi pi-chart-pie mr-2"></i>
Распределение по источникам
</div>
</template>
<template #content>
<div v-if="loading" class="text-center py-6">
<ProgressSpinner />
<p class="mt-3 text-color-secondary">Загрузка статистики...</p>
</div>
<div v-else-if="!stats.sourceStatistics || Object.keys(stats.sourceStatistics).length === 0" class="text-center py-6">
<i class="pi pi-inbox text-4xl text-color-secondary mb-3"></i>
<p class="text-color-secondary">Нет данных по источникам</p>
</div>
<div v-else>
<Chart type="pie" :data="chartData" :options="chartOptions" class="w-full" />
</div>
</template>
</Card>
</div>
<div class="col-12 lg:col-4">
<Card>
<template #title>
<div class="flex align-items-center">
<i class="pi pi-list mr-2"></i>
Детальная статистика
</div>
</template>
<template #content>
<div v-if="loading" class="text-center py-3">
<ProgressSpinner size="30" />
</div>
<div v-else-if="!stats.sourceStatistics" class="text-center py-3 text-color-secondary">
<p>Нет данных</p>
</div>
<div v-else class="space-y-3">
<div v-for="(count, source) in stats.sourceStatistics" :key="source" class="flex justify-content-between align-items-center p-2 border-round bg-gray-50">
<div class="flex align-items-center">
<div class="w-3rem h-3rem border-round flex align-items-center justify-content-center mr-3" :style="{ backgroundColor: getSourceColor(source) }">
<i class="pi pi-align-left text-white"></i>
</div>
<div>
<div class="font-medium text-sm">{{ source }}</div>
<div class="text-xs text-color-secondary">{{ count }} новостей</div>
</div>
</div>
<div class="text-right">
<div class="font-bold text-lg">{{ count }}</div>
<div class="text-xs text-color-secondary">{{ getPercentage(count) }}%</div>
</div>
</div>
</div>
</template>
</Card>
</div>
</div>
<!-- Действия -->
<div class="flex justify-content-center mt-4">
<Button label="Перейти к новостям" icon="pi pi-arrow-right" @click="goToNews" severity="info" />
</div>
</div>
</template>
<script setup>
import NewsService from '@/service/NewsService';
import Button from 'primevue/button';
import Card from 'primevue/card';
import Chart from 'primevue/chart';
import ProgressSpinner from 'primevue/progressspinner';
import Tag from 'primevue/tag';
import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
const router = useRouter();
// Реактивные данные
const stats = ref({});
const health = ref(null);
const mongoHealth = ref(null);
const loading = ref(false);
const healthLoading = ref(false);
const mongoHealthLoading = ref(false);
// Цвета для источников
const sourceColors = ['#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#06B6D4', '#84CC16', '#F97316', '#EC4899', '#6366F1'];
// Вычисляемые свойства
const sourceCount = computed(() => {
return stats.value.sourceStatistics ? Object.keys(stats.value.sourceStatistics).length : 0;
});
const averagePerSource = computed(() => {
if (!stats.value.sourceStatistics || sourceCount.value === 0) return 0;
const total = Object.values(stats.value.sourceStatistics).reduce((sum, count) => sum + count, 0);
return Math.round(total / sourceCount.value);
});
const chartData = computed(() => {
if (!stats.value.sourceStatistics) return { labels: [], datasets: [] };
const sources = Object.keys(stats.value.sourceStatistics);
const counts = Object.values(stats.value.sourceStatistics);
return {
labels: sources,
datasets: [
{
data: counts,
backgroundColor: sources.map((_, index) => getSourceColor(index)),
borderWidth: 2,
borderColor: '#ffffff'
}
]
};
});
const chartOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'bottom',
labels: {
padding: 20,
usePointStyle: true
}
},
tooltip: {
callbacks: {
label: function (context) {
const label = context.label || '';
const value = context.parsed;
const total = context.dataset.data.reduce((a, b) => a + b, 0);
const percentage = ((value / total) * 100).toFixed(1);
return `${label}: ${value} новостей (${percentage}%)`;
}
}
}
}
};
// Методы
const loadData = async () => {
loading.value = true;
try {
await Promise.all([loadStats(), loadHealth(), loadMongoHealth()]);
} finally {
loading.value = false;
}
};
const loadStats = async () => {
try {
const response = await NewsService.getStats();
if (response.success) {
stats.value = response.data;
}
} catch (error) {
console.error('Ошибка при загрузке статистики:', error);
}
};
const loadHealth = async () => {
healthLoading.value = true;
try {
const response = await NewsService.getHealth();
if (response.success) {
health.value = response.data;
}
} catch (error) {
console.error('Ошибка при загрузке статуса сервиса:', error);
} finally {
healthLoading.value = false;
}
};
const loadMongoHealth = async () => {
mongoHealthLoading.value = true;
try {
const response = await NewsService.getMongoHealth();
if (response.success) {
mongoHealth.value = response.data;
}
} catch (error) {
console.error('Ошибка при загрузке статуса MongoDB:', error);
} finally {
mongoHealthLoading.value = false;
}
};
const getSourceColor = (source) => {
if (typeof source === 'number') {
return sourceColors[source % sourceColors.length];
}
const sources = Object.keys(stats.value.sourceStatistics || {});
const index = sources.indexOf(source);
return sourceColors[index % sourceColors.length];
};
const getPercentage = (count) => {
if (!stats.value.totalItems || stats.value.totalItems === 0) return 0;
return ((count / stats.value.totalItems) * 100).toFixed(1);
};
const formatDate = (timestamp) => {
if (!timestamp) return 'Неизвестно';
return NewsService.formatDate(new Date(timestamp).toISOString());
};
const goToNews = () => {
router.push('/news');
};
// Жизненный цикл
onMounted(() => {
loadData();
});
</script>
<style scoped>
.space-y-3 > * + * {
margin-top: 0.75rem;
}
</style>
+327
View File
@@ -0,0 +1,327 @@
<template>
<div class="card">
<!-- Загрузка -->
<div v-if="loading" class="text-center py-6">
<ProgressSpinner />
<p class="mt-3 text-color-secondary">Загрузка новости...</p>
</div>
<!-- Ошибка -->
<div v-else-if="error" class="text-center py-6">
<i class="pi pi-exclamation-triangle text-6xl text-red-500 mb-3"></i>
<h3 class="text-xl font-semibold mb-2">Ошибка загрузки</h3>
<p class="text-color-secondary mb-4">{{ error }}</p>
<Button label="Попробовать снова" icon="pi pi-refresh" @click="loadNews" severity="secondary" />
</div>
<!-- Новость не найдена -->
<div v-else-if="!news" class="text-center py-6">
<i class="pi pi-inbox text-6xl text-color-secondary mb-3"></i>
<h3 class="text-xl font-semibold mb-2">Новость не найдена</h3>
<p class="text-color-secondary mb-4">Запрашиваемая новость не существует или была удалена</p>
<Button label="Вернуться к новостям" icon="pi pi-arrow-left" @click="goBack" severity="info" />
</div>
<!-- Контент новости -->
<div v-else>
<!-- Заголовок и метаданные -->
<div class="mb-4">
<div class="flex justify-content-between align-items-start mb-3">
<Button icon="pi pi-arrow-left" label="Назад к новостям" @click="goBack" severity="secondary" text />
<div class="flex gap-2">
<Button icon="pi pi-external-link" label="Оригинал" @click="openOriginalUrl" severity="info" size="small" />
<Button icon="pi pi-share" @click="shareNews" severity="secondary" size="small" v-tooltip.top="'Поделиться'" />
</div>
</div>
<div class="flex flex-wrap align-items-center gap-2 mb-3">
<Tag :value="news.sourceName" severity="info" class="text-sm" />
<Tag v-if="news.category" :value="news.category" severity="success" class="text-sm" />
<div class="flex align-items-center text-sm text-color-secondary">
<i class="pi pi-calendar mr-1"></i>
{{ formatDate(news.publishedAt) }}
</div>
<div class="flex align-items-center text-sm text-color-secondary">
<i class="pi pi-clock mr-1"></i>
Добавлено: {{ formatDateShort(news.addedAt) }}
</div>
</div>
<h1 class="text-3xl font-bold line-height-3 mb-4">{{ news.title }}</h1>
</div>
<!-- Аналитика (если доступна) -->
<div v-if="hasAnalytics" class="mb-4">
<Card>
<template #title>
<div class="flex align-items-center">
<i class="pi pi-chart-line mr-2"></i>
Аналитика
</div>
</template>
<template #content>
<div class="grid">
<div v-if="news.analytics.summary" class="col-12">
<h4 class="font-semibold mb-2">Краткое содержание:</h4>
<p class="text-color-secondary line-height-3">{{ news.analytics.summary }}</p>
</div>
<div v-if="news.analytics.sentiment" class="col-12 md:col-6">
<h4 class="font-semibold mb-2">Тональность:</h4>
<Tag :value="news.analytics.sentiment" :severity="getSentimentSeverity(news.analytics.sentiment)" />
</div>
<div v-if="news.analytics.tags && news.analytics.tags.length > 0" class="col-12 md:col-6">
<h4 class="font-semibold mb-2">Теги:</h4>
<div class="flex flex-wrap gap-1">
<Tag v-for="tag in news.analytics.tags" :key="tag" :value="tag" severity="secondary" class="text-xs" />
</div>
</div>
<div v-if="news.analytics.entities && Object.keys(news.analytics.entities).length > 0" class="col-12">
<h4 class="font-semibold mb-2">Сущности:</h4>
<div class="flex flex-wrap gap-2">
<div v-for="(count, entity) in news.analytics.entities" :key="entity" class="p-2 border-round bg-gray-50 text-sm">
<span class="font-medium">{{ entity }}</span>
<span class="text-color-secondary ml-1">({{ count }})</span>
</div>
</div>
</div>
</div>
</template>
</Card>
</div>
<!-- Основной текст -->
<Card>
<template #title>
<div class="flex align-items-center">
<i class="pi pi-file-text mr-2"></i>
Содержание
</div>
</template>
<template #content>
<div class="prose max-w-none">
<div v-if="news.rawText" class="whitespace-pre-wrap line-height-3">
{{ news.rawText }}
</div>
<div v-else class="text-center py-4 text-color-secondary">
<i class="pi pi-file-o text-4xl mb-2"></i>
<p>Текст новости недоступен</p>
</div>
</div>
</template>
</Card>
<!-- Техническая информация -->
<Card class="mt-4">
<template #title>
<div class="flex align-items-center">
<i class="pi pi-info-circle mr-2"></i>
Техническая информация
</div>
</template>
<template #content>
<div class="grid">
<div class="col-12 md:col-6">
<div class="space-y-2">
<div class="flex justify-content-between">
<span class="font-medium text-sm">ID:</span>
<span class="text-sm font-mono">{{ news.id }}</span>
</div>
<div class="flex justify-content-between">
<span class="font-medium text-sm">URL:</span>
<a :href="news.url" target="_blank" class="text-sm text-blue-600 hover:text-blue-800">
{{ truncateUrl(news.url) }}
</a>
</div>
<div class="flex justify-content-between">
<span class="font-medium text-sm">Хеш:</span>
<span class="text-sm font-mono">{{ truncateHash(news.hash) }}</span>
</div>
</div>
</div>
<div class="col-12 md:col-6">
<div class="space-y-2">
<div class="flex justify-content-between">
<span class="font-medium text-sm">Дата публикации:</span>
<span class="text-sm">{{ formatDate(news.publishedAt) }}</span>
</div>
<div class="flex justify-content-between">
<span class="font-medium text-sm">Дата добавления:</span>
<span class="text-sm">{{ formatDate(news.addedAt) }}</span>
</div>
<div class="flex justify-content-between">
<span class="font-medium text-sm">Источник:</span>
<span class="text-sm">{{ news.sourceName }}</span>
</div>
</div>
</div>
</div>
</template>
</Card>
<!-- Действия -->
<div class="flex justify-content-center gap-2 mt-4">
<Button label="Вернуться к новостям" icon="pi pi-arrow-left" @click="goBack" severity="secondary" />
<Button label="Открыть оригинал" icon="pi pi-external-link" @click="openOriginalUrl" severity="info" />
<Button label="Поделиться" icon="pi pi-share" @click="shareNews" severity="success" />
</div>
</div>
</div>
</template>
<script setup>
import NewsService from '@/service/NewsService';
import Button from 'primevue/button';
import Card from 'primevue/card';
import ProgressSpinner from 'primevue/progressspinner';
import Tag from 'primevue/tag';
import { computed, onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
const route = useRoute();
const router = useRouter();
// Реактивные данные
const news = ref(null);
const loading = ref(false);
const error = ref(null);
// Вычисляемые свойства
const hasAnalytics = computed(() => {
return (
news.value?.analytics &&
(news.value.analytics.summary || news.value.analytics.sentiment || (news.value.analytics.tags && news.value.analytics.tags.length > 0) || (news.value.analytics.entities && Object.keys(news.value.analytics.entities).length > 0))
);
});
// Методы
const loadNews = async () => {
const newsId = route.params.id;
if (!newsId) {
error.value = 'ID новости не указан';
return;
}
loading.value = true;
error.value = null;
try {
// Поскольку у нас нет отдельного API для получения одной новости,
// мы будем использовать общий API с фильтром по ID
// В реальном приложении здесь должен быть отдельный эндпоинт
const response = await NewsService.getNewsItems({
page: 0,
size: 1
// Здесь можно добавить фильтр по ID, если API это поддерживает
});
if (response.success && response.data.content.length > 0) {
// Найдем новость по ID (временное решение)
const foundNews = response.data.content.find((item) => item.id === newsId);
if (foundNews) {
news.value = foundNews;
} else {
error.value = 'Новость не найдена';
}
} else {
error.value = 'Не удалось загрузить новость';
}
} catch (err) {
console.error('Ошибка при загрузке новости:', err);
error.value = 'Ошибка при загрузке новости';
} finally {
loading.value = false;
}
};
const goBack = () => {
router.push('/news');
};
const openOriginalUrl = () => {
if (news.value?.url) {
window.open(news.value.url, '_blank');
}
};
const shareNews = async () => {
if (navigator.share && news.value) {
try {
await navigator.share({
title: news.value.title,
text: news.value.rawText?.substring(0, 200) + '...',
url: news.value.url
});
} catch (err) {
console.log('Поделиться отменено или не поддерживается');
}
} else {
// Fallback - копирование URL в буфер обмена
if (news.value?.url) {
try {
await navigator.clipboard.writeText(news.value.url);
// Здесь можно показать уведомление об успешном копировании
console.log('URL скопирован в буфер обмена');
} catch (err) {
console.error('Ошибка при копировании URL:', err);
}
}
}
};
const getSentimentSeverity = (sentiment) => {
switch (sentiment?.toLowerCase()) {
case 'positive':
case 'позитивный':
return 'success';
case 'negative':
case 'негативный':
return 'danger';
case 'neutral':
case 'нейтральный':
return 'info';
default:
return 'secondary';
}
};
const formatDate = (dateString) => {
return NewsService.formatDate(dateString);
};
const formatDateShort = (dateString) => {
return NewsService.formatDateShort(dateString);
};
const truncateUrl = (url) => {
if (!url) return '';
return url.length > 50 ? url.substring(0, 50) + '...' : url;
};
const truncateHash = (hash) => {
if (!hash) return '';
return hash.length > 20 ? hash.substring(0, 20) + '...' : hash;
};
// Жизненный цикл
onMounted(() => {
loadNews();
});
</script>
<style scoped>
.prose {
max-width: none;
}
.space-y-2 > * + * {
margin-top: 0.5rem;
}
.whitespace-pre-wrap {
white-space: pre-wrap;
}
.line-height-3 {
line-height: 1.75;
}
</style>
+310
View File
@@ -0,0 +1,310 @@
<template>
<div class="card">
<div class="flex justify-content-between align-items-center mb-4">
<h1 class="text-2xl font-semibold m-0">Лента новостей</h1>
<Button icon="pi pi-refresh" label="Обновить" @click="loadNews" :loading="loading" severity="secondary" />
</div>
<!-- Фильтры -->
<NewsFilters v-model:filters="filters" @filter-change="onFilterChange" :sources="sources" :loading="loading" />
<!-- Статистика -->
<div class="grid mb-4">
<div class="col-12 md:col-3">
<div class="card bg-blue-50 border-blue-200">
<div class="flex align-items-center">
<i class="pi pi-align-left text-blue-500 text-2xl mr-3"></i>
<div>
<div class="text-sm text-blue-600">Всего новостей</div>
<div class="text-xl font-semibold text-blue-900">{{ totalElements || 0 }}</div>
</div>
</div>
</div>
</div>
<div class="col-12 md:col-3">
<div class="card bg-green-50 border-green-200">
<div class="flex align-items-center">
<i class="pi pi-calendar text-green-500 text-2xl mr-3"></i>
<div>
<div class="text-sm text-green-600">На странице</div>
<div class="text-xl font-semibold text-green-900">{{ numberOfElements || 0 }}</div>
</div>
</div>
</div>
</div>
<div class="col-12 md:col-3">
<div class="card bg-orange-50 border-orange-200">
<div class="flex align-items-center">
<i class="pi pi-clock text-orange-500 text-2xl mr-3"></i>
<div>
<div class="text-sm text-orange-600">Последнее обновление</div>
<div class="text-sm font-semibold text-orange-900">{{ lastUpdateTime }}</div>
</div>
</div>
</div>
</div>
<div class="col-12 md:col-3">
<div class="card bg-purple-50 border-purple-200">
<div class="flex align-items-center">
<i class="pi pi-filter text-purple-500 text-2xl mr-3"></i>
<div>
<div class="text-sm text-purple-600">Активные фильтры</div>
<div class="text-xl font-semibold text-purple-900">{{ activeFiltersCount }}</div>
</div>
</div>
</div>
</div>
</div>
<!-- Список новостей -->
<div v-if="loading && news.length === 0" class="text-center py-6">
<ProgressSpinner />
<p class="mt-3 text-color-secondary">Загрузка новостей...</p>
</div>
<div v-else-if="news.length === 0" class="text-center py-6">
<i class="pi pi-inbox text-6xl text-color-secondary mb-3"></i>
<h3 class="text-xl font-semibold mb-2">Новости не найдены</h3>
<p class="text-color-secondary">Попробуйте изменить фильтры или обновить страницу</p>
</div>
<div v-else class="grid">
<div v-for="item in news" :key="item.id" class="col-12">
<Card class="mb-3 hover:shadow-2 transition-all transition-duration-200">
<template #header>
<div class="p-3 pb-0">
<div class="flex justify-content-between align-items-start mb-2">
<Tag :value="item.sourceName" severity="info" class="mr-2" />
<div class="flex align-items-center text-sm text-color-secondary">
<i class="pi pi-calendar mr-1"></i>
{{ formatDate(item.publishedAt) }}
</div>
</div>
<h3 class="text-xl font-semibold mb-2 line-height-3">
{{ item.title }}
</h3>
</div>
</template>
<template #content>
<div class="p-3 pt-0">
<p class="text-color-secondary line-height-3 mb-3">
{{ getPreviewText(item.rawText) }}
</p>
<div class="flex justify-content-between align-items-center">
<div class="flex align-items-center">
<Tag v-if="item.category" :value="item.category" severity="success" class="mr-2" />
<span class="text-sm text-color-secondary">
<i class="pi pi-clock mr-1"></i>
Добавлено: {{ formatDateShort(item.addedAt) }}
</span>
</div>
<div class="flex gap-2">
<Button icon="pi pi-external-link" label="Читать" size="small" @click="openNewsDetail(item.id)" severity="secondary" />
<Button icon="pi pi-external-link" size="small" @click="openOriginalUrl(item.url)" severity="info" v-tooltip.top="'Открыть оригинал'" />
</div>
</div>
</div>
</template>
</Card>
</div>
</div>
<!-- Пагинация -->
<div v-if="totalPages > 1" class="flex justify-content-center mt-4">
<Paginator
v-model:first="first"
:rows="filters.size"
:totalRecords="totalElements"
:rowsPerPageOptions="[10, 20, 50, 100]"
@page="onPageChange"
template="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink RowsPerPageDropdown"
/>
</div>
<!-- Кнопка "Загрузить еще" для бесконечной прокрутки -->
<div v-if="!last && news.length > 0" class="text-center mt-4">
<Button label="Загрузить еще" icon="pi pi-plus" @click="loadMore" :loading="loadingMore" severity="secondary" />
</div>
</div>
</template>
<script setup>
import NewsFilters from '@/components/news/NewsFilters.vue';
import NewsService from '@/service/NewsService';
import Button from 'primevue/button';
import Card from 'primevue/card';
import Paginator from 'primevue/paginator';
import ProgressSpinner from 'primevue/progressspinner';
import Tag from 'primevue/tag';
import { computed, onMounted, reactive, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
const router = useRouter();
// Реактивные данные
const news = ref([]);
const loading = ref(false);
const loadingMore = ref(false);
const sources = ref([]);
const first = ref(0);
// Данные пагинации
const totalElements = ref(0);
const totalPages = ref(0);
const numberOfElements = ref(0);
const last = ref(false);
// Фильтры
const filters = reactive({
page: 0,
size: 20,
sort: 'publishedAt,desc',
sourceName: null,
startDate: null,
endDate: null
});
// Вычисляемые свойства
const lastUpdateTime = computed(() => {
if (news.value.length === 0) return 'Неизвестно';
const latest = news.value.reduce((latest, item) => {
return new Date(item.addedAt) > new Date(latest.addedAt) ? item : latest;
}, news.value[0]);
return NewsService.formatDateShort(latest.addedAt);
});
const activeFiltersCount = computed(() => {
let count = 0;
if (filters.sourceName) count++;
if (filters.startDate) count++;
if (filters.endDate) count++;
return count;
});
// Методы
const loadNews = async (reset = true) => {
try {
loading.value = true;
if (reset) {
first.value = 0;
filters.page = 0;
news.value = [];
}
const response = await NewsService.getNewsItems(filters);
if (response.success) {
const data = response.data;
if (reset) {
news.value = data.content;
} else {
news.value.push(...data.content);
}
totalElements.value = data.totalElements;
totalPages.value = data.totalPages;
numberOfElements.value = data.numberOfElements;
last.value = data.last;
}
} catch (error) {
console.error('Ошибка при загрузке новостей:', error);
// Здесь можно добавить уведомление об ошибке
} finally {
loading.value = false;
loadingMore.value = false;
}
};
const loadMore = async () => {
if (loadingMore.value || last.value) return;
loadingMore.value = true;
filters.page += 1;
await loadNews(false);
};
const onFilterChange = () => {
loadNews(true);
};
const onPageChange = (event) => {
first.value = event.first;
filters.page = event.page;
filters.size = event.rows;
loadNews(true);
};
const openNewsDetail = (id) => {
router.push(`/news/${id}`);
};
const openOriginalUrl = (url) => {
window.open(url, '_blank');
};
const getPreviewText = (text) => {
if (!text) return 'Текст недоступен';
return text.length > 200 ? text.substring(0, 200) + '...' : text;
};
const formatDate = (dateString) => {
return NewsService.formatDate(dateString);
};
const formatDateShort = (dateString) => {
return NewsService.formatDateShort(dateString);
};
const loadSources = async () => {
try {
sources.value = await NewsService.getSources();
} catch (error) {
console.error('Ошибка при загрузке источников:', error);
}
};
// Жизненный цикл
onMounted(async () => {
await loadSources();
await loadNews();
});
// Отслеживание изменений фильтров
watch(
() => filters.sourceName,
() => {
filters.page = 0;
first.value = 0;
}
);
watch(
() => filters.startDate,
() => {
filters.page = 0;
first.value = 0;
}
);
watch(
() => filters.endDate,
() => {
filters.page = 0;
first.value = 0;
}
);
</script>
<style scoped>
.hover\:shadow-2:hover {
box-shadow:
0 4px 6px -1px rgba(0, 0, 0, 0.1),
0 2px 4px -1px rgba(0, 0, 0, 0.06);
}
.transition-all {
transition: all 0.2s ease-in-out;
}
</style>