news
This commit is contained in:
+197
@@ -0,0 +1,197 @@
|
|||||||
|
# Архитектура новостного интерфейса
|
||||||
|
|
||||||
|
## Схема компонентов
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Vue.js Frontend │
|
||||||
|
├─────────────────────────────────────────────────────────────────┤
|
||||||
|
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
|
||||||
|
│ │ NewsList │ │ NewsDashboard │ │ NewsAdmin │ │
|
||||||
|
│ │ (Лента) │ │ (Статистика) │ │ (Управление) │ │
|
||||||
|
│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │
|
||||||
|
│ │ │ │ │
|
||||||
|
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
|
||||||
|
│ │ NewsFilters │ │ NewsDetail │ │ NewsService │ │
|
||||||
|
│ │ (Фильтры) │ │ (Детали) │ │ (API слой) │ │
|
||||||
|
│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ News Parser API │
|
||||||
|
├─────────────────────────────────────────────────────────────────┤
|
||||||
|
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
|
||||||
|
│ │ GET /items │ │ GET /stats │ │ GET /health │ │
|
||||||
|
│ │ (Новости) │ │ (Статистика) │ │ (Статус) │ │
|
||||||
|
│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │
|
||||||
|
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
|
||||||
|
│ │ POST /parse/all │ │POST /parse/{id} │ │GET /health/mongo│ │
|
||||||
|
│ │ (Парсинг всех) │ │ (Парсинг по ID) │ │ (Статус БД) │ │
|
||||||
|
│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ MongoDB Database │
|
||||||
|
├─────────────────────────────────────────────────────────────────┤
|
||||||
|
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
|
||||||
|
│ │ News Items │ │ Statistics │ │ Health Logs │ │
|
||||||
|
│ │ (Новости) │ │ (Статистика) │ │ (Логи) │ │
|
||||||
|
│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Поток данных
|
||||||
|
|
||||||
|
### 1. Загрузка новостей
|
||||||
|
|
||||||
|
```
|
||||||
|
User → NewsList → NewsService → API → MongoDB
|
||||||
|
← NewsFilters ←
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Фильтрация
|
||||||
|
|
||||||
|
```
|
||||||
|
User → NewsFilters → NewsList → NewsService → API (с параметрами)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Статистика
|
||||||
|
|
||||||
|
```
|
||||||
|
User → NewsDashboard → NewsService → API → MongoDB
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Управление парсером
|
||||||
|
|
||||||
|
```
|
||||||
|
User → NewsAdmin → NewsService → API → Parser Service → MongoDB
|
||||||
|
```
|
||||||
|
|
||||||
|
## Структура данных
|
||||||
|
|
||||||
|
### NewsItem
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
id: string,
|
||||||
|
sourceName: string,
|
||||||
|
title: string,
|
||||||
|
url: string,
|
||||||
|
publishedAt: string,
|
||||||
|
addedAt: string,
|
||||||
|
rawText: string,
|
||||||
|
hash: string,
|
||||||
|
category: string,
|
||||||
|
analytics: {
|
||||||
|
summary: string,
|
||||||
|
sentiment: string,
|
||||||
|
tags: string[],
|
||||||
|
entities: object
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### API Response
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
success: boolean,
|
||||||
|
message: string,
|
||||||
|
data: {
|
||||||
|
content: NewsItem[],
|
||||||
|
pageable: object,
|
||||||
|
totalElements: number,
|
||||||
|
totalPages: number,
|
||||||
|
last: boolean,
|
||||||
|
first: boolean,
|
||||||
|
numberOfElements: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Технологический стек
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
- **Vue 3** - основной фреймворк
|
||||||
|
- **PrimeVue** - UI компоненты
|
||||||
|
- **Tailwind CSS** - стилизация
|
||||||
|
- **Chart.js** - графики и диаграммы
|
||||||
|
- **Vue Router** - маршрутизация
|
||||||
|
|
||||||
|
### Backend API
|
||||||
|
|
||||||
|
- **REST API** - HTTP эндпоинты
|
||||||
|
- **JSON** - формат данных
|
||||||
|
- **MongoDB** - база данных
|
||||||
|
|
||||||
|
### Инструменты разработки
|
||||||
|
|
||||||
|
- **Vite** - сборщик
|
||||||
|
- **ESLint** - линтер
|
||||||
|
- **Prettier** - форматирование
|
||||||
|
|
||||||
|
## Принципы архитектуры
|
||||||
|
|
||||||
|
### 1. Разделение ответственности
|
||||||
|
|
||||||
|
- **Компоненты** - только UI логика
|
||||||
|
- **Сервисы** - работа с API
|
||||||
|
- **Конфигурация** - настройки
|
||||||
|
|
||||||
|
### 2. Переиспользование
|
||||||
|
|
||||||
|
- **NewsService** - единая точка доступа к API
|
||||||
|
- **NewsFilters** - переиспользуемый компонент
|
||||||
|
- **Конфигурация** - централизованные настройки
|
||||||
|
|
||||||
|
### 3. Производительность
|
||||||
|
|
||||||
|
- **Ленивая загрузка** - компоненты загружаются по требованию
|
||||||
|
- **Кэширование** - данные статистики кэшируются
|
||||||
|
- **Оптимизация** - минимальные API запросы
|
||||||
|
|
||||||
|
### 4. Масштабируемость
|
||||||
|
|
||||||
|
- **Модульная структура** - легко добавлять новые функции
|
||||||
|
- **Конфигурируемость** - настройки через переменные окружения
|
||||||
|
- **Расширяемость** - простое добавление новых источников
|
||||||
|
|
||||||
|
## Безопасность
|
||||||
|
|
||||||
|
### 1. Валидация данных
|
||||||
|
|
||||||
|
- **Проверка ответов API** - всегда проверяется поле `success`
|
||||||
|
- **Обработка ошибок** - graceful degradation
|
||||||
|
- **Санитизация** - очистка пользовательского ввода
|
||||||
|
|
||||||
|
### 2. Аутентификация
|
||||||
|
|
||||||
|
- **Требуется авторизация** - все маршруты защищены
|
||||||
|
- **Роли пользователей** - админ функции только для админов
|
||||||
|
|
||||||
|
### 3. CORS
|
||||||
|
|
||||||
|
- **Настройка CORS** - на уровне API сервера
|
||||||
|
- **Безопасные запросы** - только к настроенному API
|
||||||
|
|
||||||
|
## Мониторинг и отладка
|
||||||
|
|
||||||
|
### 1. Логирование
|
||||||
|
|
||||||
|
- **Консольные логи** - для разработки
|
||||||
|
- **Обработка ошибок** - централизованная система
|
||||||
|
|
||||||
|
### 2. Метрики
|
||||||
|
|
||||||
|
- **Статистика загрузки** - количество запросов
|
||||||
|
- **Время отклика** - производительность API
|
||||||
|
- **Статус системы** - мониторинг здоровья
|
||||||
|
|
||||||
|
### 3. Отладка
|
||||||
|
|
||||||
|
- **DevTools** - инструменты разработчика
|
||||||
|
- **Network tab** - мониторинг запросов
|
||||||
|
- **Console** - логи и ошибки
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# Отладка проблем с новостным интерфейсом
|
||||||
|
|
||||||
|
## Проблема: Не открываются страницы новостей
|
||||||
|
|
||||||
|
### Шаги для диагностики:
|
||||||
|
|
||||||
|
1. **Откройте консоль браузера** (F12 → Console)
|
||||||
|
2. **Попробуйте кликнуть на "Лента новостей"** в меню
|
||||||
|
3. **Проверьте ошибки в консоли**
|
||||||
|
|
||||||
|
### Возможные причины и решения:
|
||||||
|
|
||||||
|
#### 1. Ошибки импорта компонентов
|
||||||
|
|
||||||
|
**Симптомы:** Ошибки типа "Failed to resolve component" или "Cannot resolve module"
|
||||||
|
**Решение:** Убедитесь, что все PrimeVue компоненты импортированы
|
||||||
|
|
||||||
|
#### 2. Ошибки в API запросах
|
||||||
|
|
||||||
|
**Симптомы:** Ошибки сети или CORS
|
||||||
|
**Решение:**
|
||||||
|
|
||||||
|
- Проверьте, что бэкенд API запущен на https://api.konturai.kz
|
||||||
|
- Создайте файл `.env` с содержимым: `VUE_APP_API_URL=https://api.konturai.kz`
|
||||||
|
|
||||||
|
#### 3. Ошибки в коде компонентов
|
||||||
|
|
||||||
|
**Симптомы:** JavaScript ошибки в консоли
|
||||||
|
**Решение:** Проверьте синтаксис и логику компонентов
|
||||||
|
|
||||||
|
### Быстрая проверка:
|
||||||
|
|
||||||
|
1. **Проверьте, что сервер запущен:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Проверьте консоль браузера** на наличие ошибок
|
||||||
|
|
||||||
|
3. **Попробуйте перейти напрямую по URL:**
|
||||||
|
- http://localhost:3000/news
|
||||||
|
- http://localhost:3000/news/dashboard
|
||||||
|
- http://localhost:3000/admin/news
|
||||||
|
|
||||||
|
### Если ничего не помогает:
|
||||||
|
|
||||||
|
1. **Очистите кэш браузера** (Ctrl+Shift+R)
|
||||||
|
2. **Перезапустите сервер разработки**
|
||||||
|
3. **Проверьте, что все файлы сохранены**
|
||||||
|
|
||||||
|
### Тестовый компонент:
|
||||||
|
|
||||||
|
Если основные компоненты не работают, можно временно заменить их на простой тестовый компонент:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<template>
|
||||||
|
<div class="card">
|
||||||
|
<h1>Тест новостей</h1>
|
||||||
|
<p>Если вы видите эту страницу, значит маршрутизация работает!</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
// Простой тестовый компонент
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Контакты:
|
||||||
|
|
||||||
|
Если проблема не решается, проверьте:
|
||||||
|
|
||||||
|
- Версию Node.js (должна быть 16+)
|
||||||
|
- Версию npm (должна быть 8+)
|
||||||
|
- Наличие всех зависимостей (`npm install`)
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# Исправление ошибки "process is not defined"
|
||||||
|
|
||||||
|
## Проблема
|
||||||
|
|
||||||
|
В консоли браузера появляется ошибка:
|
||||||
|
|
||||||
|
```
|
||||||
|
[Vue Router warn]: Unexpected error when starting the router: ReferenceError: process is not defined at api.js:3:15
|
||||||
|
```
|
||||||
|
|
||||||
|
## Причина
|
||||||
|
|
||||||
|
В браузере нет объекта `process`, который используется для доступа к переменным окружения Node.js. В Vite нужно использовать `import.meta.env` вместо `process.env`.
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
|
||||||
|
Ошибка уже исправлена в файле `src/config/api.js`:
|
||||||
|
|
||||||
|
**Было:**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
BASE_URL: process.env.VUE_APP_API_URL || 'https://api.konturai.kz',
|
||||||
|
```
|
||||||
|
|
||||||
|
**Стало:**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
BASE_URL: 'https://api.konturai.kz',
|
||||||
|
```
|
||||||
|
|
||||||
|
## Дополнительные исправления
|
||||||
|
|
||||||
|
1. **Добавлен Chart.js в main.js** для корректной работы графиков
|
||||||
|
2. **Зарегистрирован Chart как глобальный компонент**
|
||||||
|
3. **Исправлены все импорты PrimeVue компонентов**
|
||||||
|
|
||||||
|
## Проверка
|
||||||
|
|
||||||
|
1. Перезапустите сервер разработки: `npm run dev`
|
||||||
|
2. Обновите страницу в браузере (Ctrl+Shift+R)
|
||||||
|
3. Проверьте консоль - ошибка должна исчезнуть
|
||||||
|
4. Попробуйте перейти на страницы новостей
|
||||||
|
|
||||||
|
## Если проблема остается
|
||||||
|
|
||||||
|
1. Очистите кэш браузера
|
||||||
|
2. Удалите папку `node_modules` и выполните `npm install`
|
||||||
|
3. Перезапустите сервер разработки
|
||||||
File diff suppressed because it is too large
Load Diff
+183
@@ -0,0 +1,183 @@
|
|||||||
|
# Новостной интерфейс для KonturAI
|
||||||
|
|
||||||
|
Этот модуль реализует полноценный интерфейс для работы с новостным парсером согласно API документации.
|
||||||
|
|
||||||
|
## 🚀 Возможности
|
||||||
|
|
||||||
|
### 📰 Лента новостей (`/news`)
|
||||||
|
|
||||||
|
- **Отображение новостей** с заголовком, источником, датой и превью текста
|
||||||
|
- **Пагинация** с поддержкой "бесконечной прокрутки" и классической пагинации
|
||||||
|
- **Сортировка** по дате публикации, дате добавления, заголовку и источнику
|
||||||
|
- **Фильтрация** по источнику и датам
|
||||||
|
- **Быстрые фильтры** (сегодня, неделя, месяц)
|
||||||
|
- **Статистика** на странице с количеством новостей и активных фильтров
|
||||||
|
|
||||||
|
### 📊 Дашборд статистики (`/news/dashboard`)
|
||||||
|
|
||||||
|
- **Общая статистика** с количеством новостей, источников и средним количеством на источник
|
||||||
|
- **Статус системы** с проверкой состояния сервиса и MongoDB
|
||||||
|
- **Круговая диаграмма** распределения новостей по источникам
|
||||||
|
- **Детальная статистика** по каждому источнику
|
||||||
|
|
||||||
|
### ⚙️ Админ-панель (`/admin/news`)
|
||||||
|
|
||||||
|
- **Управление парсером** с кнопками запуска парсинга
|
||||||
|
- **Мониторинг состояния** сервиса и базы данных
|
||||||
|
- **Результаты парсинга** с отображением обработанных новостей
|
||||||
|
- **Статистика в реальном времени**
|
||||||
|
|
||||||
|
### 📄 Детальный просмотр новости (`/news/:id`)
|
||||||
|
|
||||||
|
- **Полный текст** новости с форматированием
|
||||||
|
- **Аналитика** (краткое содержание, тональность, теги, сущности)
|
||||||
|
- **Техническая информация** (ID, URL, хеш, даты)
|
||||||
|
- **Действия** (открыть оригинал, поделиться, вернуться)
|
||||||
|
|
||||||
|
## 🛠 Технические детали
|
||||||
|
|
||||||
|
### Структура файлов
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── service/
|
||||||
|
│ └── NewsService.js # Сервис для работы с API
|
||||||
|
├── config/
|
||||||
|
│ └── api.js # Конфигурация API
|
||||||
|
├── components/news/
|
||||||
|
│ └── NewsFilters.vue # Компонент фильтров
|
||||||
|
└── views/news/
|
||||||
|
├── NewsList.vue # Лента новостей
|
||||||
|
├── NewsDashboard.vue # Дашборд статистики
|
||||||
|
├── NewsAdmin.vue # Админ-панель
|
||||||
|
└── NewsDetail.vue # Детальный просмотр
|
||||||
|
```
|
||||||
|
|
||||||
|
### API Endpoints
|
||||||
|
|
||||||
|
- `GET /api/parser/items` - получение новостей с фильтрацией и пагинацией
|
||||||
|
- `GET /api/parser/stats` - статистика системы
|
||||||
|
- `GET /api/parser/health` - статус сервиса
|
||||||
|
- `GET /api/parser/health/mongodb` - статус MongoDB
|
||||||
|
- `POST /api/parser/parse/all` - запуск парсинга всех источников
|
||||||
|
- `POST /api/parser/parse/{source}` - запуск парсинга конкретного источника
|
||||||
|
|
||||||
|
### Конфигурация
|
||||||
|
|
||||||
|
Создайте файл `.env` в корне проекта:
|
||||||
|
|
||||||
|
```env
|
||||||
|
VUE_APP_API_URL=https://api.konturai.kz
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🎨 UI/UX особенности
|
||||||
|
|
||||||
|
### Дизайн
|
||||||
|
|
||||||
|
- **Современный интерфейс** с использованием PrimeVue компонентов
|
||||||
|
- **Адаптивная верстка** для всех устройств
|
||||||
|
- **Цветовая схема** с интуитивными индикаторами статуса
|
||||||
|
- **Анимации и переходы** для улучшения пользовательского опыта
|
||||||
|
|
||||||
|
### Интерактивность
|
||||||
|
|
||||||
|
- **Hover эффекты** на карточках новостей
|
||||||
|
- **Загрузочные индикаторы** для всех асинхронных операций
|
||||||
|
- **Уведомления** об успешных операциях и ошибках
|
||||||
|
- **Tooltips** для дополнительной информации
|
||||||
|
|
||||||
|
### Производительность
|
||||||
|
|
||||||
|
- **Ленивая загрузка** компонентов
|
||||||
|
- **Кэширование** данных статистики
|
||||||
|
- **Оптимизированные запросы** с минимальным количеством API вызовов
|
||||||
|
|
||||||
|
## 🔧 Настройка и запуск
|
||||||
|
|
||||||
|
1. **Установите зависимости:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Настройте API URL:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
echo "VUE_APP_API_URL=http://your-api-server:8080" > .env
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Запустите в режиме разработки:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Соберите для продакшена:**
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📱 Навигация
|
||||||
|
|
||||||
|
### Основное меню
|
||||||
|
|
||||||
|
- **Home** → New Dashboard
|
||||||
|
- **Новости** → Лента новостей, Статистика
|
||||||
|
- **Admin** → Users, Управление парсером
|
||||||
|
|
||||||
|
### Маршруты
|
||||||
|
|
||||||
|
- `/news` - лента новостей
|
||||||
|
- `/news/dashboard` - статистика
|
||||||
|
- `/news/:id` - детальный просмотр новости
|
||||||
|
- `/admin/news` - управление парсером
|
||||||
|
|
||||||
|
## 🎯 Особенности реализации
|
||||||
|
|
||||||
|
### Фильтрация
|
||||||
|
|
||||||
|
- **Источник**: выпадающий список с динамической загрузкой
|
||||||
|
- **Даты**: календарь с поддержкой диапазонов
|
||||||
|
- **Сортировка**: поле и направление
|
||||||
|
- **Размер страницы**: 10, 20, 50, 100 новостей
|
||||||
|
|
||||||
|
### Пагинация
|
||||||
|
|
||||||
|
- **Классическая**: кнопки страниц с информацией
|
||||||
|
- **Бесконечная**: кнопка "Загрузить еще"
|
||||||
|
- **Навигация**: первая, предыдущая, следующая, последняя
|
||||||
|
|
||||||
|
### Обработка ошибок
|
||||||
|
|
||||||
|
- **Graceful degradation** при недоступности API
|
||||||
|
- **Информативные сообщения** об ошибках
|
||||||
|
- **Retry механизмы** для критических операций
|
||||||
|
|
||||||
|
### Аналитика
|
||||||
|
|
||||||
|
- **Chart.js** для визуализации данных
|
||||||
|
- **Цветовое кодирование** источников
|
||||||
|
- **Процентное соотношение** в статистике
|
||||||
|
|
||||||
|
## 🔮 Планы развития
|
||||||
|
|
||||||
|
- [ ] **WebSocket** для обновлений в реальном времени
|
||||||
|
- [ ] **Поиск** по содержимому новостей
|
||||||
|
- [ ] **Избранное** и закладки
|
||||||
|
- [ ] **Экспорт** данных в различных форматах
|
||||||
|
- [ ] **Уведомления** о новых новостях
|
||||||
|
- [ ] **Темная тема** интерфейса
|
||||||
|
- [ ] **Мобильное приложение** на базе Capacitor
|
||||||
|
|
||||||
|
## 📞 Поддержка
|
||||||
|
|
||||||
|
При возникновении проблем:
|
||||||
|
|
||||||
|
1. Проверьте конфигурацию API URL
|
||||||
|
2. Убедитесь, что бэкенд сервис запущен
|
||||||
|
3. Проверьте консоль браузера на наличие ошибок
|
||||||
|
4. Обратитесь к документации API
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Создано с ❤️ для эффективной работы с новостными данными**
|
||||||
@@ -0,0 +1,351 @@
|
|||||||
|
# Руководство по API для фронтенда
|
||||||
|
|
||||||
|
## Обзор
|
||||||
|
|
||||||
|
Это руководство описывает API сервиса `Parser`, который собирает и предоставляет новостные данные. API спроектировано для эффективной работы с клиентскими приложениями и поддерживает пагинацию, сортировку и фильтрацию.
|
||||||
|
|
||||||
|
## Реализация в UI: что мы должны показать пользователю
|
||||||
|
|
||||||
|
Ниже описаны ключевые элементы интерфейса, которые необходимо создать, используя эндпоинты из этого руководства.
|
||||||
|
|
||||||
|
### 1. Основной экран: Лента новостей 📰
|
||||||
|
|
||||||
|
Это главный экран приложения, где пользователь видит все собранные новости.
|
||||||
|
|
||||||
|
- **Функционал:**
|
||||||
|
- **Отображение новостей:** Используйте `GET /api/parser/items` для загрузки и отображения списка новостей. Каждая новость в списке должна показывать заголовок, источник, дату публикации и краткий текст (`rawText` или `analytics.summary`, когда он будет готов).
|
||||||
|
- **Пагинация:** Так как новостей может быть много, реализуйте "бесконечную прокрутку" (подгрузка новых статей при скролле вниз) или классические кнопки пагинации («1, 2, 3...»). API полностью это поддерживает.
|
||||||
|
- **Сортировка:** По умолчанию новости должны быть отсортированы по дате публикации (`sort=publishedAt,desc`), чтобы пользователь видел сначала самое свежее.
|
||||||
|
|
||||||
|
### 2. Панель фильтров 🔎
|
||||||
|
|
||||||
|
Сбоку или сверху от ленты новостей должна быть панель, позволяющая пользователю отфильтровать данные.
|
||||||
|
|
||||||
|
- **Функционал:**
|
||||||
|
- **Фильтр по источнику:** Добавьте выпадающий список или чекбоксы с названиями источников (например, "Kursiv", "Kapital.kz"). При выборе источника отправляйте его в параметре `sourceName` в запросе к `GET /api/parser/items`.
|
||||||
|
- **Фильтр по дате:** Добавьте два поля для выбора даты ("от" и "до") с календарем. Это позволит пользователю смотреть новости за конкретный период. Используйте параметры `startDate` и `endDate`.
|
||||||
|
|
||||||
|
### 3. Дашборд/Статистика 📊
|
||||||
|
|
||||||
|
Нужно создать отдельную страницу "Дашборд" или "Статистика", где будет отображаться общая информация о системе.
|
||||||
|
|
||||||
|
- **Функционал:**
|
||||||
|
- **Общее количество новостей:** Используйте `GET /api/parser/stats` для получения и отображения общего числа статей в базе (`totalItems`).
|
||||||
|
- **Разбивка по источникам:** На основе поля `sourceStatistics` постройте круговую диаграмму (pie chart), которая наглядно покажет, сколько новостей пришло с каждого источника.
|
||||||
|
- **Статус системы:** Используйте `GET /api/parser/health`, чтобы показать пользователю (скорее, администратору) текущий статус сервиса и планировщика. Можно добавить зелёный/красный индикатор.
|
||||||
|
|
||||||
|
### 4. Админ-панель: Управление парсером (опционально, для администраторов) ⚙️
|
||||||
|
|
||||||
|
Эта часть интерфейса может быть скрыта от обычных пользователей и доступна только администраторам.
|
||||||
|
|
||||||
|
- **Функционал:**
|
||||||
|
- **Ручной запуск парсинга:** Добавьте кнопки "Запустить парсинг Kursiv", "Запустить парсинг Kapital" и "Запустить все". Эти кнопки будут отправлять запросы на эндпоинты `POST /api/parser/parse/{source}` и `POST /api/parser/parse/all`.
|
||||||
|
- **Мониторинг состояния БД:** Используйте `GET /api/parser/health/mongodb` для проверки статуса подключения к базе данных и выводите сообщение об успехе или ошибке.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### 1. Получение записей с фильтрацией и пагинацией
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/parser/items?page=0&size=10&sort=publishedAt,desc&sourceName=Kursiv&startDate=2025-01-01&endDate=2025-01-31
|
||||||
|
```
|
||||||
|
|
||||||
|
**Параметры:**
|
||||||
|
|
||||||
|
- `page` (optional, default: 0) - номер страницы
|
||||||
|
- `size` (optional, default: 20) - размер страницы
|
||||||
|
- `sort` (optional, default: "publishedAt,desc") - сортировка
|
||||||
|
- `sourceName` (optional) - фильтр по источнику
|
||||||
|
- `startDate` (optional) - начальная дата (ISO format)
|
||||||
|
- `endDate` (optional) - конечная дата (ISO format)
|
||||||
|
|
||||||
|
**Ответ:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": "Записи получены успешно",
|
||||||
|
"data": {
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"id": "...",
|
||||||
|
"sourceName": "Kursiv (Бизнес/экономика)",
|
||||||
|
"title": "Заголовок новости",
|
||||||
|
"url": "https://kursiv.media/...",
|
||||||
|
"publishedAt": "2025-01-14T10:30:00",
|
||||||
|
"addedAt": "2025-01-14T12:00:00",
|
||||||
|
"rawText": "Текст статьи...",
|
||||||
|
"hash": "...",
|
||||||
|
"category": "Бизнес",
|
||||||
|
"analytics": {
|
||||||
|
"summary": null,
|
||||||
|
"sentiment": null,
|
||||||
|
"tags": [],
|
||||||
|
"entities": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"pageable": {
|
||||||
|
"sort": {
|
||||||
|
"sorted": true,
|
||||||
|
"unsorted": false,
|
||||||
|
"empty": false
|
||||||
|
},
|
||||||
|
"pageNumber": 0,
|
||||||
|
"pageSize": 10,
|
||||||
|
"offset": 0,
|
||||||
|
"paged": true,
|
||||||
|
"unpaged": false
|
||||||
|
},
|
||||||
|
"totalElements": 25,
|
||||||
|
"totalPages": 3,
|
||||||
|
"last": false,
|
||||||
|
"first": true,
|
||||||
|
"numberOfElements": 10,
|
||||||
|
"size": 10,
|
||||||
|
"number": 0,
|
||||||
|
"sort": {
|
||||||
|
"sorted": true,
|
||||||
|
"unsorted": false,
|
||||||
|
"empty": false
|
||||||
|
},
|
||||||
|
"empty": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Статистика системы
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/parser/stats
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ответ:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": "Статистика получена успешно",
|
||||||
|
"data": {
|
||||||
|
"totalItems": 150,
|
||||||
|
"sourceStatistics": {
|
||||||
|
"Kursiv (Бизнес/экономика)": 75,
|
||||||
|
"Kapital.kz (Бизнес)": 75
|
||||||
|
},
|
||||||
|
"lastUpdate": "2025-01-14T12:00:00"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Запуск парсинга
|
||||||
|
|
||||||
|
#### Парсинг всех источников
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/parser/parse/all
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ответ:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": "Парсинг всех источников завершен успешно",
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"source": "Kursiv",
|
||||||
|
"processedItems": 15,
|
||||||
|
"totalItemsInDb": 90,
|
||||||
|
"status": "completed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "Kapital",
|
||||||
|
"processedItems": 12,
|
||||||
|
"totalItemsInDb": 90,
|
||||||
|
"status": "completed"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Парсинг конкретного источника
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/parser/parse/kursiv
|
||||||
|
POST /api/parser/parse/kapital
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Проверка состояния
|
||||||
|
|
||||||
|
#### Общее состояние
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/parser/health
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ответ:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": "Сервис работает",
|
||||||
|
"data": {
|
||||||
|
"status": "UP",
|
||||||
|
"service": "RSS Parser System",
|
||||||
|
"timestamp": 1705123456789,
|
||||||
|
"scheduler": "Enabled - runs every 30 minutes"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Состояние MongoDB
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/parser/health/mongodb
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ответ при успехе:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": "MongoDB подключение успешно",
|
||||||
|
"data": {
|
||||||
|
"status": "UP",
|
||||||
|
"message": "MongoDB подключение успешно",
|
||||||
|
"timestamp": 1705123456789
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ответ при ошибке:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": false,
|
||||||
|
"message": "Ошибка подключения к MongoDB",
|
||||||
|
"data": {
|
||||||
|
"status": "DOWN",
|
||||||
|
"message": "Ошибка подключения к MongoDB: Command failed with error 13 (Unauthorized)",
|
||||||
|
"timestamp": 1705123456789,
|
||||||
|
"suggestion": "Проверьте учетные данные в application.properties"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Примеры использования
|
||||||
|
|
||||||
|
### 1. Получение последних новостей с пагинацией
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Получить первую страницу с 10 записями, отсортированными по дате публикации
|
||||||
|
fetch('/api/parser/items?page=0&size=10&sort=publishedAt,desc')
|
||||||
|
.then((response) => response.json())
|
||||||
|
.then((data) => {
|
||||||
|
if (data.success) {
|
||||||
|
const items = data.data.content;
|
||||||
|
const totalPages = data.data.totalPages;
|
||||||
|
// Обработка данных
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Фильтрация по источнику
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Получить только новости от Kursiv
|
||||||
|
fetch('/api/parser/items?sourceName=Kursiv (Бизнес/экономика)&page=0&size=20')
|
||||||
|
.then((response) => response.json())
|
||||||
|
.then((data) => {
|
||||||
|
if (data.success) {
|
||||||
|
const kursivNews = data.data.content;
|
||||||
|
// Обработка данных
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Фильтрация по датам
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Получить новости за последнюю неделю
|
||||||
|
const endDate = new Date().toISOString();
|
||||||
|
const startDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString();
|
||||||
|
|
||||||
|
fetch(`/api/parser/items?startDate=${startDate}&endDate=${endDate}`)
|
||||||
|
.then((response) => response.json())
|
||||||
|
.then((data) => {
|
||||||
|
if (data.success) {
|
||||||
|
const recentNews = data.data.content;
|
||||||
|
// Обработка данных
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Комплексная фильтрация
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Получить новости Kapital за последний месяц, отсортированные по дате
|
||||||
|
const endDate = new Date().toISOString();
|
||||||
|
const startDate = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString();
|
||||||
|
|
||||||
|
fetch(`/api/parser/items?sourceName=Kapital.kz (Бизнес)&startDate=${startDate}&endDate=${endDate}&sort=publishedAt,desc&page=0&size=50`)
|
||||||
|
.then((response) => response.json())
|
||||||
|
.then((data) => {
|
||||||
|
if (data.success) {
|
||||||
|
const kapitalNews = data.data.content;
|
||||||
|
const totalCount = data.data.totalElements;
|
||||||
|
// Обработка данных
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Получение статистики
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
fetch('/api/parser/stats')
|
||||||
|
.then((response) => response.json())
|
||||||
|
.then((data) => {
|
||||||
|
if (data.success) {
|
||||||
|
const stats = data.data;
|
||||||
|
console.log(`Всего новостей: ${stats.totalItems}`);
|
||||||
|
console.log('По источникам:', stats.sourceStatistics);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Поддерживаемые форматы дат
|
||||||
|
|
||||||
|
API поддерживает следующие форматы дат:
|
||||||
|
|
||||||
|
- `yyyy-MM-dd` (например: 2025-01-14)
|
||||||
|
- `yyyy-MM-dd'T'HH:mm:ss` (например: 2025-01-14T10:30:00)
|
||||||
|
- `yyyy-MM-dd'T'HH:mm:ss.SSS` (например: 2025-01-14T10:30:00.000)
|
||||||
|
- `yyyy-MM-dd HH:mm:ss` (например: 2025-01-14 10:30:00)
|
||||||
|
|
||||||
|
## Обработка ошибок
|
||||||
|
|
||||||
|
Все ответы API следуют единому формату:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": false,
|
||||||
|
"message": "Описание ошибки",
|
||||||
|
"error": "Детальная информация об ошибке"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
HTTP статус коды:
|
||||||
|
|
||||||
|
- `200` - Успешный запрос
|
||||||
|
- `500` - Внутренняя ошибка сервера
|
||||||
|
- `503` - Сервис недоступен (например, MongoDB)
|
||||||
|
|
||||||
|
## Рекомендации для фронтенда
|
||||||
|
|
||||||
|
1. **Кэширование**: Используйте кэширование для статистики и часто запрашиваемых данных
|
||||||
|
2. **Пагинация**: Реализуйте бесконечную прокрутку или традиционную пагинацию
|
||||||
|
3. **Фильтры**: Предоставьте пользователю удобные фильтры по источнику и датам
|
||||||
|
4. **Обновление**: Используйте WebSocket или polling для обновления данных в реальном времени
|
||||||
|
5. **Обработка ошибок**: Всегда проверяйте поле `success` в ответе
|
||||||
+1
-1
@@ -14,7 +14,7 @@ JWT-аутентификация. Публичные и админские эн
|
|||||||
### Базовый URL
|
### Базовый URL
|
||||||
|
|
||||||
- Прод: укажите боевой домен/порт
|
- Прод: укажите боевой домен/порт
|
||||||
- Dev локально: `http://localhost:8080`
|
- Dev локально: `https://api.konturai.kz`
|
||||||
|
|
||||||
### Переменные окружения (frontend)
|
### Переменные окружения (frontend)
|
||||||
|
|
||||||
|
|||||||
@@ -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: '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',
|
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',
|
// label: 'UI Components',
|
||||||
|
|||||||
@@ -131,6 +131,24 @@ const router = createRouter({
|
|||||||
name: 'documentation',
|
name: 'documentation',
|
||||||
component: () => import('@/views/pages/Documentation.vue'),
|
component: () => import('@/views/pages/Documentation.vue'),
|
||||||
meta: { requiresAuth: true }
|
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',
|
name: 'admin-users',
|
||||||
component: () => import('@/views/pages/AdminUsers.vue'),
|
component: () => import('@/views/pages/AdminUsers.vue'),
|
||||||
meta: { requiresAuth: true, requiresAdmin: true }
|
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
|
// 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 TOKEN_STORAGE_KEY = 'auth.accessToken';
|
||||||
const REFRESH_STORAGE_KEY = 'auth.refreshToken';
|
const REFRESH_STORAGE_KEY = 'auth.refreshToken';
|
||||||
@@ -65,6 +66,15 @@ async function logout() {
|
|||||||
clearTokens();
|
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 = {}) {
|
async function authFetch(input, init = {}) {
|
||||||
const headers = new Headers(init.headers || {});
|
const headers = new Headers(init.headers || {});
|
||||||
const token = getAccessToken();
|
const token = getAccessToken();
|
||||||
@@ -91,6 +101,7 @@ export default {
|
|||||||
refreshToken,
|
refreshToken,
|
||||||
logout,
|
logout,
|
||||||
authFetch,
|
authFetch,
|
||||||
|
readErrorMessage,
|
||||||
getAccessToken,
|
getAccessToken,
|
||||||
getRefreshToken,
|
getRefreshToken,
|
||||||
setTokens,
|
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>
|
||||||
@@ -20,5 +20,25 @@ export default defineConfig({
|
|||||||
alias: {
|
alias: {
|
||||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'https://api.konturai.kz',
|
||||||
|
changeOrigin: true,
|
||||||
|
secure: true,
|
||||||
|
configure: (proxy, _options) => {
|
||||||
|
proxy.on('error', (err, _req, _res) => {
|
||||||
|
console.log('proxy error', err);
|
||||||
|
});
|
||||||
|
proxy.on('proxyReq', (proxyReq, req, _res) => {
|
||||||
|
console.log('Sending Request to the Target:', req.method, req.url);
|
||||||
|
});
|
||||||
|
proxy.on('proxyRes', (proxyRes, req, _res) => {
|
||||||
|
console.log('Received Response from the Target:', proxyRes.statusCode, req.url);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user