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