news
This commit is contained in:
@@ -0,0 +1,220 @@
|
||||
<template>
|
||||
<Card class="mb-4">
|
||||
<template #title>
|
||||
<div class="flex align-items-center">
|
||||
<i class="pi pi-filter mr-2"></i>
|
||||
Фильтры
|
||||
</div>
|
||||
</template>
|
||||
<template #content>
|
||||
<div class="grid">
|
||||
<!-- Фильтр по источнику -->
|
||||
<div class="col-12 md:col-4">
|
||||
<div class="field">
|
||||
<label for="sourceFilter" class="font-medium block mb-2">Источник</label>
|
||||
<Dropdown id="sourceFilter" v-model="localFilters.sourceName" :options="sourceOptions" optionLabel="label" optionValue="value" placeholder="Все источники" :loading="loading" showClear class="w-full" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Фильтр по дате начала -->
|
||||
<div class="col-12 md:col-4">
|
||||
<div class="field">
|
||||
<label for="startDate" class="font-medium block mb-2">Дата от</label>
|
||||
<Calendar id="startDate" v-model="localFilters.startDate" placeholder="Выберите дату" dateFormat="yy-mm-dd" showIcon class="w-full" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Фильтр по дате окончания -->
|
||||
<div class="col-12 md:col-4">
|
||||
<div class="field">
|
||||
<label for="endDate" class="font-medium block mb-2">Дата до</label>
|
||||
<Calendar id="endDate" v-model="localFilters.endDate" placeholder="Выберите дату" dateFormat="yy-mm-dd" showIcon class="w-full" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Сортировка -->
|
||||
<div class="col-12 md:col-6">
|
||||
<div class="field">
|
||||
<label for="sortField" class="font-medium block mb-2">Сортировка по</label>
|
||||
<Dropdown id="sortField" v-model="sortField" :options="sortFieldOptions" optionLabel="label" optionValue="value" placeholder="Выберите поле" class="w-full" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 md:col-6">
|
||||
<div class="field">
|
||||
<label for="sortOrder" class="font-medium block mb-2">Порядок</label>
|
||||
<Dropdown id="sortOrder" v-model="sortOrder" :options="sortOrderOptions" optionLabel="label" optionValue="value" placeholder="Выберите порядок" class="w-full" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Размер страницы -->
|
||||
<div class="col-12 md:col-6">
|
||||
<div class="field">
|
||||
<label for="pageSize" class="font-medium block mb-2">Новостей на странице</label>
|
||||
<Dropdown id="pageSize" v-model="localFilters.size" :options="pageSizeOptions" optionLabel="label" optionValue="value" placeholder="Выберите размер" class="w-full" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Быстрые фильтры -->
|
||||
<div class="col-12 md:col-6">
|
||||
<div class="field">
|
||||
<label class="font-medium block mb-2">Быстрые фильтры</label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button label="Сегодня" size="small" severity="secondary" @click="setTodayFilter" />
|
||||
<Button label="Неделя" size="small" severity="secondary" @click="setWeekFilter" />
|
||||
<Button label="Месяц" size="small" severity="secondary" @click="setMonthFilter" />
|
||||
<Button label="Очистить" size="small" severity="danger" @click="clearFilters" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Кнопки действий -->
|
||||
<div class="flex justify-content-end gap-2 mt-3">
|
||||
<Button label="Сбросить" icon="pi pi-times" severity="secondary" @click="resetFilters" />
|
||||
<Button label="Применить" icon="pi pi-check" @click="applyFilters" :loading="loading" />
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import Button from 'primevue/button';
|
||||
import Calendar from 'primevue/calendar';
|
||||
import Card from 'primevue/card';
|
||||
import Dropdown from 'primevue/dropdown';
|
||||
import { computed, reactive, ref, watch } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
filters: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
sources: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:filters', 'filter-change']);
|
||||
|
||||
// Локальные фильтры
|
||||
const localFilters = reactive({
|
||||
sourceName: null,
|
||||
startDate: null,
|
||||
endDate: null,
|
||||
size: 20
|
||||
});
|
||||
|
||||
// Сортировка
|
||||
const sortField = ref('publishedAt');
|
||||
const sortOrder = ref('desc');
|
||||
|
||||
// Опции для выпадающих списков
|
||||
const sourceOptions = computed(() => [
|
||||
{ label: 'Все источники', value: null },
|
||||
...props.sources.map((source) => ({
|
||||
label: source,
|
||||
value: source
|
||||
}))
|
||||
]);
|
||||
|
||||
const sortFieldOptions = [
|
||||
{ label: 'Дата публикации', value: 'publishedAt' },
|
||||
{ label: 'Дата добавления', value: 'addedAt' },
|
||||
{ label: 'Заголовок', value: 'title' },
|
||||
{ label: 'Источник', value: 'sourceName' }
|
||||
];
|
||||
|
||||
const sortOrderOptions = [
|
||||
{ label: 'По убыванию', value: 'desc' },
|
||||
{ label: 'По возрастанию', value: 'asc' }
|
||||
];
|
||||
|
||||
const pageSizeOptions = [
|
||||
{ label: '10 новостей', value: 10 },
|
||||
{ label: '20 новостей', value: 20 },
|
||||
{ label: '50 новостей', value: 50 },
|
||||
{ label: '100 новостей', value: 100 }
|
||||
];
|
||||
|
||||
// Методы
|
||||
const applyFilters = () => {
|
||||
const newFilters = {
|
||||
...localFilters,
|
||||
sort: `${sortField.value},${sortOrder.value}`,
|
||||
page: 0
|
||||
};
|
||||
|
||||
emit('update:filters', newFilters);
|
||||
emit('filter-change');
|
||||
};
|
||||
|
||||
const resetFilters = () => {
|
||||
localFilters.sourceName = null;
|
||||
localFilters.startDate = null;
|
||||
localFilters.endDate = null;
|
||||
localFilters.size = 20;
|
||||
sortField.value = 'publishedAt';
|
||||
sortOrder.value = 'desc';
|
||||
|
||||
applyFilters();
|
||||
};
|
||||
|
||||
const clearFilters = () => {
|
||||
resetFilters();
|
||||
};
|
||||
|
||||
const setTodayFilter = () => {
|
||||
const today = new Date();
|
||||
const startOfDay = new Date(today.getFullYear(), today.getMonth(), today.getDate());
|
||||
const endOfDay = new Date(today.getFullYear(), today.getMonth(), today.getDate(), 23, 59, 59);
|
||||
|
||||
localFilters.startDate = startOfDay;
|
||||
localFilters.endDate = endOfDay;
|
||||
applyFilters();
|
||||
};
|
||||
|
||||
const setWeekFilter = () => {
|
||||
const today = new Date();
|
||||
const weekAgo = new Date(today.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||
|
||||
localFilters.startDate = weekAgo;
|
||||
localFilters.endDate = today;
|
||||
applyFilters();
|
||||
};
|
||||
|
||||
const setMonthFilter = () => {
|
||||
const today = new Date();
|
||||
const monthAgo = new Date(today.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||
|
||||
localFilters.startDate = monthAgo;
|
||||
localFilters.endDate = today;
|
||||
applyFilters();
|
||||
};
|
||||
|
||||
// Инициализация из props
|
||||
const initializeFromProps = () => {
|
||||
localFilters.sourceName = props.filters.sourceName;
|
||||
localFilters.startDate = props.filters.startDate ? new Date(props.filters.startDate) : null;
|
||||
localFilters.endDate = props.filters.endDate ? new Date(props.filters.endDate) : null;
|
||||
localFilters.size = props.filters.size || 20;
|
||||
|
||||
const [field, order] = (props.filters.sort || 'publishedAt,desc').split(',');
|
||||
sortField.value = field;
|
||||
sortOrder.value = order;
|
||||
};
|
||||
|
||||
// Отслеживание изменений props
|
||||
watch(() => props.filters, initializeFromProps, { immediate: true, deep: true });
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.field {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,36 @@
|
||||
// API Configuration
|
||||
export const API_CONFIG = {
|
||||
BASE_URL: 'https://api.konturai.kz',
|
||||
ENDPOINTS: {
|
||||
// MarketItemController - Получение данных
|
||||
NEWS_ITEMS: '/api/parser/items',
|
||||
NEWS_ITEM_BY_ID: '/api/parser/items',
|
||||
STATS: '/api/parser/items/stats',
|
||||
SOURCES: '/api/parser/items/sources',
|
||||
NEWS_BY_SOURCE: '/api/parser/items/source',
|
||||
|
||||
// ParserAdminController - Управление парсерами
|
||||
PARSERS: '/api/parser/admin/parsers',
|
||||
PARSERS_INFO: '/api/parser/admin/parsers/info',
|
||||
PARSER_EXISTS: '/api/parser/admin/parsers/exists',
|
||||
PARSE_ALL: '/api/parser/admin/parsers/parse/all',
|
||||
PARSE_SOURCE: '/api/parser/admin/parsers/parse',
|
||||
|
||||
// HealthCheckController - Мониторинг системы
|
||||
HEALTH: '/api/parser/health',
|
||||
MONGO_HEALTH: '/api/parser/health/mongodb',
|
||||
SCHEDULER_HEALTH: '/api/parser/health/scheduler',
|
||||
SYSTEM_HEALTH: '/api/parser/health/system',
|
||||
PARSERS_HEALTH: '/api/parser/health/parsers'
|
||||
}
|
||||
};
|
||||
|
||||
// Default request configuration
|
||||
export const DEFAULT_REQUEST_CONFIG = {
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
};
|
||||
|
||||
// Request timeout (in milliseconds)
|
||||
export const REQUEST_TIMEOUT = 30000;
|
||||
+11
-1
@@ -11,9 +11,19 @@ const model = ref([
|
||||
{ label: 'New Dashboard', icon: 'pi pi-fw pi-home', to: '/dashboard-new' }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Новости',
|
||||
items: [
|
||||
{ label: 'Лента новостей', icon: 'pi pi-align-left', to: '/news' },
|
||||
{ label: 'Статистика', icon: 'pi pi-fw pi-chart-bar', to: '/news/dashboard' }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Admin',
|
||||
items: [{ label: 'Users', icon: 'pi pi-fw pi-users', to: '/admin/users' }]
|
||||
items: [
|
||||
{ label: 'Users', icon: 'pi pi-fw pi-users', to: '/admin/users' },
|
||||
{ label: 'Управление парсером', icon: 'pi pi-fw pi-cog', to: '/admin/news' }
|
||||
]
|
||||
}
|
||||
// {
|
||||
// label: 'UI Components',
|
||||
|
||||
@@ -131,6 +131,24 @@ const router = createRouter({
|
||||
name: 'documentation',
|
||||
component: () => import('@/views/pages/Documentation.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/news',
|
||||
name: 'news',
|
||||
component: () => import('@/views/news/NewsList.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/news/dashboard',
|
||||
name: 'news-dashboard',
|
||||
component: () => import('@/views/news/NewsDashboard.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/news/:id',
|
||||
name: 'news-detail',
|
||||
component: () => import('@/views/news/NewsDetail.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -143,6 +161,12 @@ const router = createRouter({
|
||||
name: 'admin-users',
|
||||
component: () => import('@/views/pages/AdminUsers.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'news',
|
||||
name: 'admin-news',
|
||||
component: () => import('@/views/news/NewsAdmin.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Lightweight auth service to manage tokens and API calls
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || 'https://api.konturai.kz';
|
||||
// Use proxy in development, direct API in production
|
||||
const API_URL = import.meta.env.VITE_API_URL || (import.meta.env.DEV ? '' : 'https://api.konturai.kz');
|
||||
|
||||
const TOKEN_STORAGE_KEY = 'auth.accessToken';
|
||||
const REFRESH_STORAGE_KEY = 'auth.refreshToken';
|
||||
@@ -65,6 +66,15 @@ async function logout() {
|
||||
clearTokens();
|
||||
}
|
||||
|
||||
async function readErrorMessage(response) {
|
||||
try {
|
||||
const data = await response.json();
|
||||
return data.message || `HTTP ${response.status}: ${response.statusText}`;
|
||||
} catch (_) {
|
||||
return `HTTP ${response.status}: ${response.statusText}`;
|
||||
}
|
||||
}
|
||||
|
||||
async function authFetch(input, init = {}) {
|
||||
const headers = new Headers(init.headers || {});
|
||||
const token = getAccessToken();
|
||||
@@ -91,6 +101,7 @@ export default {
|
||||
refreshToken,
|
||||
logout,
|
||||
authFetch,
|
||||
readErrorMessage,
|
||||
getAccessToken,
|
||||
getRefreshToken,
|
||||
setTokens,
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
import { API_CONFIG, DEFAULT_REQUEST_CONFIG } from '@/config/api';
|
||||
import AuthService from './AuthService';
|
||||
|
||||
const API_BASE_URL = API_CONFIG.BASE_URL;
|
||||
|
||||
class NewsService {
|
||||
// ===== MarketItemController - Получение данных =====
|
||||
|
||||
async getNewsItems(params = {}) {
|
||||
const { page = 0, size = 20, sort = 'publishedAt,desc', sourceName = null, startDate = null, endDate = null } = params;
|
||||
|
||||
const queryParams = new URLSearchParams({
|
||||
page: page.toString(),
|
||||
size: size.toString(),
|
||||
sort
|
||||
});
|
||||
|
||||
if (sourceName) {
|
||||
queryParams.append('sourceName', sourceName);
|
||||
}
|
||||
if (startDate) {
|
||||
queryParams.append('startDate', startDate);
|
||||
}
|
||||
if (endDate) {
|
||||
queryParams.append('endDate', endDate);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await AuthService.authFetch(`${API_BASE_URL}${API_CONFIG.ENDPOINTS.NEWS_ITEMS}?${queryParams}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || 'Ошибка при загрузке новостей');
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('Ошибка при загрузке новостей:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getNewsItemById(id) {
|
||||
try {
|
||||
const response = await AuthService.authFetch(`${API_BASE_URL}${API_CONFIG.ENDPOINTS.NEWS_ITEM_BY_ID}/${id}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || 'Ошибка при загрузке новости');
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('Ошибка при загрузке новости:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getNewsBySource(sourceName, params = {}) {
|
||||
const { page = 0, size = 20, sort = 'publishedAt,desc' } = params;
|
||||
|
||||
const queryParams = new URLSearchParams({
|
||||
page: page.toString(),
|
||||
size: size.toString(),
|
||||
sort
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await AuthService.authFetch(`${API_BASE_URL}${API_CONFIG.ENDPOINTS.NEWS_BY_SOURCE}/${encodeURIComponent(sourceName)}?${queryParams}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || 'Ошибка при загрузке новостей источника');
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('Ошибка при загрузке новостей источника:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getStats() {
|
||||
try {
|
||||
const response = await AuthService.authFetch(`${API_BASE_URL}${API_CONFIG.ENDPOINTS.STATS}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || 'Ошибка при загрузке статистики');
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('Ошибка при загрузке статистики:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getSources() {
|
||||
try {
|
||||
const response = await AuthService.authFetch(`${API_BASE_URL}${API_CONFIG.ENDPOINTS.SOURCES}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || 'Ошибка при загрузке списка источников');
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('Ошибка при загрузке списка источников:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== ParserAdminController - Управление парсерами =====
|
||||
|
||||
async getParsers() {
|
||||
try {
|
||||
const response = await AuthService.authFetch(`${API_BASE_URL}${API_CONFIG.ENDPOINTS.PARSERS}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || 'Ошибка при получении списка парсеров');
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('Ошибка при получении списка парсеров:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getParsersInfo() {
|
||||
try {
|
||||
const response = await AuthService.authFetch(`${API_BASE_URL}${API_CONFIG.ENDPOINTS.PARSERS_INFO}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || 'Ошибка при получении информации о парсерах');
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('Ошибка при получении информации о парсерах:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async checkParserExists(sourceName) {
|
||||
try {
|
||||
const response = await AuthService.authFetch(`${API_BASE_URL}${API_CONFIG.ENDPOINTS.PARSER_EXISTS}/${sourceName}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || 'Ошибка при проверке существования парсера');
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('Ошибка при проверке существования парсера:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== HealthCheckController - Мониторинг системы =====
|
||||
|
||||
async getHealth() {
|
||||
try {
|
||||
const response = await AuthService.authFetch(`${API_BASE_URL}${API_CONFIG.ENDPOINTS.HEALTH}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || 'Ошибка при проверке состояния сервиса');
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('Ошибка при проверке состояния сервиса:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getMongoHealth() {
|
||||
try {
|
||||
const response = await AuthService.authFetch(`${API_BASE_URL}${API_CONFIG.ENDPOINTS.MONGO_HEALTH}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || 'Ошибка при проверке состояния MongoDB');
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('Ошибка при проверке состояния MongoDB:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getSchedulerHealth() {
|
||||
try {
|
||||
const response = await AuthService.authFetch(`${API_BASE_URL}${API_CONFIG.ENDPOINTS.SCHEDULER_HEALTH}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || 'Ошибка при проверке состояния планировщика');
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('Ошибка при проверке состояния планировщика:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getSystemHealth() {
|
||||
try {
|
||||
const response = await AuthService.authFetch(`${API_BASE_URL}${API_CONFIG.ENDPOINTS.SYSTEM_HEALTH}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || 'Ошибка при получении информации о системе');
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('Ошибка при получении информации о системе:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getParsersHealth() {
|
||||
try {
|
||||
const response = await AuthService.authFetch(`${API_BASE_URL}${API_CONFIG.ENDPOINTS.PARSERS_HEALTH}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || 'Ошибка при проверке состояния парсеров');
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('Ошибка при проверке состояния парсеров:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Административные методы парсинга =====
|
||||
|
||||
async parseAll() {
|
||||
try {
|
||||
const response = await AuthService.authFetch(`${API_BASE_URL}${API_CONFIG.ENDPOINTS.PARSE_ALL}`, {
|
||||
method: 'POST',
|
||||
...DEFAULT_REQUEST_CONFIG
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || 'Ошибка при запуске парсинга всех источников');
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('Ошибка при запуске парсинга всех источников:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async parseSource(source) {
|
||||
try {
|
||||
const response = await AuthService.authFetch(`${API_BASE_URL}${API_CONFIG.ENDPOINTS.PARSE_SOURCE}/${source}`, {
|
||||
method: 'POST',
|
||||
...DEFAULT_REQUEST_CONFIG
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || `Ошибка при запуске парсинга источника ${source}`);
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error(`Ошибка при запуске парсинга источника ${source}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Вспомогательные методы =====
|
||||
|
||||
// Методы для форматирования дат
|
||||
formatDate(dateString) {
|
||||
if (!dateString) return '';
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('ru-RU', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
formatDateShort(dateString) {
|
||||
if (!dateString) return '';
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('ru-RU', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
// Метод для получения списка источников из статистики (fallback)
|
||||
async getSourcesFromStats() {
|
||||
try {
|
||||
const stats = await this.getStats();
|
||||
return Object.keys(stats.data.sourceStatistics || {});
|
||||
} catch (error) {
|
||||
console.error('Ошибка при получении списка источников из статистики:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new NewsService();
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user