init
This commit is contained in:
@@ -0,0 +1,246 @@
|
|||||||
|
# SMM Система Konturai
|
||||||
|
|
||||||
|
Полная реализация системы управления социальными сетями для фронтенда Vue.js приложения.
|
||||||
|
|
||||||
|
## 🚀 Реализованные компоненты
|
||||||
|
|
||||||
|
### 1. Сервисы (Services)
|
||||||
|
|
||||||
|
#### CampaignService
|
||||||
|
|
||||||
|
- **Путь**: `src/service/CampaignService.js`
|
||||||
|
- **Функции**: CRUD операции для кампаний
|
||||||
|
- **API эндпоинты**:
|
||||||
|
- `GET /api/smm/campaigns` - получить все кампании
|
||||||
|
- `GET /api/smm/campaigns/{id}` - получить кампанию по ID
|
||||||
|
- `POST /api/smm/campaigns` - создать кампанию
|
||||||
|
- `PUT /api/smm/campaigns/{id}` - обновить кампанию
|
||||||
|
- `DELETE /api/smm/campaigns/{id}` - удалить кампанию
|
||||||
|
|
||||||
|
#### ChannelService
|
||||||
|
|
||||||
|
- **Путь**: `src/service/ChannelService.js`
|
||||||
|
- **Функции**: CRUD операции для каналов
|
||||||
|
- **API эндпоинты**:
|
||||||
|
- `GET /api/smm/channels` - получить все каналы
|
||||||
|
- `GET /api/smm/channels/{id}` - получить канал по ID
|
||||||
|
- `POST /api/smm/channels` - создать канал
|
||||||
|
- `PUT /api/smm/channels/{id}` - обновить канал
|
||||||
|
- `DELETE /api/smm/channels/{id}` - удалить канал
|
||||||
|
|
||||||
|
#### ContentService
|
||||||
|
|
||||||
|
- **Путь**: `src/service/ContentService.js`
|
||||||
|
- **Функции**: CRUD операции для контента, одобрение, получение сообщений
|
||||||
|
- **API эндпоинты**:
|
||||||
|
- `GET /api/smm/content` - получить весь контент
|
||||||
|
- `GET /api/smm/content/{id}` - получить контент по ID
|
||||||
|
- `POST /api/smm/content` - создать контент
|
||||||
|
- `PUT /api/smm/content/{id}` - обновить контент
|
||||||
|
- `DELETE /api/smm/content/{id}` - удалить контент
|
||||||
|
- `POST /api/smm/content/{id}/approve` - одобрить контент
|
||||||
|
- `GET /api/smm/content/{id}/messages` - получить сообщения контента
|
||||||
|
|
||||||
|
#### PublishingService
|
||||||
|
|
||||||
|
- **Путь**: `src/service/PublishingService.js`
|
||||||
|
- **Функции**: Публикация контента, проверка статуса, массовая публикация
|
||||||
|
- **API эндпоинты**:
|
||||||
|
- `POST /api/smm/publishing/post/{contentId}` - опубликовать контент
|
||||||
|
|
||||||
|
#### AnalyticsService
|
||||||
|
|
||||||
|
- **Путь**: `src/service/AnalyticsService.js`
|
||||||
|
- **Функции**: Сбор аналитики, получение статистики
|
||||||
|
- **API эндпоинты**:
|
||||||
|
- `POST /api/smm/analytics/collect-now` - запустить сбор аналитики
|
||||||
|
|
||||||
|
### 2. Типы данных
|
||||||
|
|
||||||
|
#### SMM Types
|
||||||
|
|
||||||
|
- **Путь**: `src/types/smm.js`
|
||||||
|
- **Содержит**:
|
||||||
|
- `CampaignStatus` - статусы кампаний (PLANNED, ACTIVE, COMPLETED)
|
||||||
|
- `ChannelType` - типы каналов (TELEGRAM, VK, INSTAGRAM)
|
||||||
|
- `ContentStatus` - статусы контента (DRAFT, PENDING_APPROVAL, APPROVED, PUBLISHED, FAILED)
|
||||||
|
- DTO классы для всех сущностей
|
||||||
|
|
||||||
|
### 3. Vue компоненты
|
||||||
|
|
||||||
|
#### CampaignList
|
||||||
|
|
||||||
|
- **Путь**: `src/views/smm/CampaignList.vue`
|
||||||
|
- **Функции**: Управление кампаниями с полным CRUD
|
||||||
|
- **Особенности**:
|
||||||
|
- Таблица с фильтрацией и сортировкой
|
||||||
|
- Диалоги создания/редактирования
|
||||||
|
- Валидация форм
|
||||||
|
- Подтверждение удаления
|
||||||
|
|
||||||
|
#### ChannelList
|
||||||
|
|
||||||
|
- **Путь**: `src/views/smm/ChannelList.vue`
|
||||||
|
- **Функции**: Управление каналами
|
||||||
|
- **Особенности**:
|
||||||
|
- Поддержка разных типов каналов
|
||||||
|
- Управление активностью каналов
|
||||||
|
- Валидация API ключей
|
||||||
|
|
||||||
|
#### ContentList
|
||||||
|
|
||||||
|
- **Путь**: `src/views/smm/ContentList.vue`
|
||||||
|
- **Функции**: Управление контентом
|
||||||
|
- **Особенности**:
|
||||||
|
- Создание и редактирование контента
|
||||||
|
- Одобрение контента
|
||||||
|
- Публикация контента
|
||||||
|
- Просмотр детальной информации
|
||||||
|
|
||||||
|
#### AnalyticsDashboard
|
||||||
|
|
||||||
|
- **Путь**: `src/views/smm/AnalyticsDashboard.vue`
|
||||||
|
- **Функции**: Аналитика и статистика
|
||||||
|
- **Особенности**:
|
||||||
|
- Статистические карточки
|
||||||
|
- Детальная статистика по всем модулям
|
||||||
|
- Ручной запуск сбора аналитики
|
||||||
|
|
||||||
|
#### SmmDashboard
|
||||||
|
|
||||||
|
- **Путь**: `src/views/smm/SmmDashboard.vue`
|
||||||
|
- **Функции**: Главная панель SMM системы
|
||||||
|
- **Особенности**:
|
||||||
|
- Обзор статистики
|
||||||
|
- Быстрые действия
|
||||||
|
- Последние кампании и контент
|
||||||
|
|
||||||
|
### 4. Маршрутизация
|
||||||
|
|
||||||
|
#### Добавленные маршруты
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
path: '/smm',
|
||||||
|
name: 'smm-dashboard',
|
||||||
|
component: () => import('@/views/smm/SmmDashboard.vue'),
|
||||||
|
meta: { requiresAuth: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/smm/campaigns',
|
||||||
|
name: 'smm-campaigns',
|
||||||
|
component: () => import('@/views/smm/CampaignList.vue'),
|
||||||
|
meta: { requiresAuth: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/smm/channels',
|
||||||
|
name: 'smm-channels',
|
||||||
|
component: () => import('@/views/smm/ChannelList.vue'),
|
||||||
|
meta: { requiresAuth: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/smm/content',
|
||||||
|
name: 'smm-content',
|
||||||
|
component: () => import('@/views/smm/ContentList.vue'),
|
||||||
|
meta: { requiresAuth: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/smm/analytics',
|
||||||
|
name: 'smm-analytics',
|
||||||
|
component: () => import('@/views/smm/AnalyticsDashboard.vue'),
|
||||||
|
meta: { requiresAuth: true }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Навигация
|
||||||
|
|
||||||
|
#### Обновленное меню
|
||||||
|
|
||||||
|
Добавлен новый раздел "SMM Система" с пунктами:
|
||||||
|
|
||||||
|
- SMM Панель (`/smm`)
|
||||||
|
- Кампании (`/smm/campaigns`)
|
||||||
|
- Каналы (`/smm/channels`)
|
||||||
|
- Контент (`/smm/content`)
|
||||||
|
- Аналитика (`/smm/analytics`)
|
||||||
|
|
||||||
|
## 🔧 Технические особенности
|
||||||
|
|
||||||
|
### Аутентификация
|
||||||
|
|
||||||
|
- Все сервисы используют JWT токены из localStorage
|
||||||
|
- Автоматическая проверка авторизации
|
||||||
|
- Обработка ошибок аутентификации
|
||||||
|
|
||||||
|
### Валидация
|
||||||
|
|
||||||
|
- Клиентская валидация всех форм
|
||||||
|
- Проверка обязательных полей
|
||||||
|
- Валидация форматов данных
|
||||||
|
|
||||||
|
### Обработка ошибок
|
||||||
|
|
||||||
|
- Централизованная обработка ошибок
|
||||||
|
- Toast уведомления для пользователя
|
||||||
|
- Логирование ошибок в консоль
|
||||||
|
|
||||||
|
### UI/UX
|
||||||
|
|
||||||
|
- Современный дизайн с PrimeVue компонентами
|
||||||
|
- Адаптивная верстка
|
||||||
|
- Интуитивная навигация
|
||||||
|
- Подтверждения действий
|
||||||
|
|
||||||
|
## 📋 Использование
|
||||||
|
|
||||||
|
### 1. Управление кампаниями
|
||||||
|
|
||||||
|
1. Перейдите в "SMM Система" → "Кампании"
|
||||||
|
2. Нажмите "Создать кампанию" для добавления новой кампании
|
||||||
|
3. Заполните форму с обязательными полями
|
||||||
|
4. Используйте фильтры и поиск для навигации по кампаниям
|
||||||
|
|
||||||
|
### 2. Управление каналами
|
||||||
|
|
||||||
|
1. Перейдите в "SMM Система" → "Каналы"
|
||||||
|
2. Добавьте каналы для Telegram, VK или Instagram
|
||||||
|
3. Укажите API ключи для каждого канала
|
||||||
|
4. Управляйте активностью каналов
|
||||||
|
|
||||||
|
### 3. Управление контентом
|
||||||
|
|
||||||
|
1. Перейдите в "SMM Система" → "Контент"
|
||||||
|
2. Создайте контент, привязав к кампании
|
||||||
|
3. Настройте время публикации и приоритет
|
||||||
|
4. Одобрите контент для публикации
|
||||||
|
5. Опубликуйте контент в каналы
|
||||||
|
|
||||||
|
### 4. Аналитика
|
||||||
|
|
||||||
|
1. Перейдите в "SMM Система" → "Аналитика"
|
||||||
|
2. Просматривайте статистику по всем модулям
|
||||||
|
3. Запускайте сбор аналитики вручную
|
||||||
|
4. Отслеживайте эффективность публикаций
|
||||||
|
|
||||||
|
## 🚀 Готовность к использованию
|
||||||
|
|
||||||
|
Система полностью готова к использованию и включает:
|
||||||
|
|
||||||
|
- ✅ Все API эндпоинты из документации
|
||||||
|
- ✅ Полный CRUD для всех сущностей
|
||||||
|
- ✅ Современный UI с PrimeVue
|
||||||
|
- ✅ Адаптивный дизайн
|
||||||
|
- ✅ Обработка ошибок
|
||||||
|
- ✅ Валидация данных
|
||||||
|
- ✅ Навигация и маршрутизация
|
||||||
|
|
||||||
|
## 🔗 Интеграция с бэкендом
|
||||||
|
|
||||||
|
Система готова к интеграции с бэкендом по адресу:
|
||||||
|
|
||||||
|
- **Базовый URL**: `https://api.konturai.kz/api/smm`
|
||||||
|
- **Аутентификация**: Bearer JWT токены
|
||||||
|
- **Формат данных**: JSON
|
||||||
|
- **Кодировка**: UTF-8
|
||||||
|
|
||||||
|
Все сервисы автоматически добавляют заголовки аутентификации и обрабатывают ответы API.
|
||||||
+23
-1
@@ -1,6 +1,7 @@
|
|||||||
// API Configuration
|
// API Configuration
|
||||||
export const API_CONFIG = {
|
export const API_CONFIG = {
|
||||||
BASE_URL: 'https://api.konturai.kz',
|
BASE_URL: 'https://api.konturai.kz',
|
||||||
|
SMM_BASE_URL: 'https://api.konturai.kz/api/smm',
|
||||||
ENDPOINTS: {
|
ENDPOINTS: {
|
||||||
// MarketItemController - Получение данных
|
// MarketItemController - Получение данных
|
||||||
NEWS_ITEMS: '/api/parser/items',
|
NEWS_ITEMS: '/api/parser/items',
|
||||||
@@ -26,7 +27,28 @@ export const API_CONFIG = {
|
|||||||
// ReportController - Генерация и управление отчётами
|
// ReportController - Генерация и управление отчётами
|
||||||
REPORT_GENERATE: '/api/parser/report/generate',
|
REPORT_GENERATE: '/api/parser/report/generate',
|
||||||
REPORT_HISTORY: '/api/parser/report/history',
|
REPORT_HISTORY: '/api/parser/report/history',
|
||||||
REPORT_DOWNLOAD: '/api/parser/report/history'
|
REPORT_DOWNLOAD: '/api/parser/report/history',
|
||||||
|
|
||||||
|
// SMM System Endpoints
|
||||||
|
// CampaignController - Управление кампаниями
|
||||||
|
CAMPAIGNS: '/campaigns',
|
||||||
|
CAMPAIGN_BY_ID: '/campaigns',
|
||||||
|
|
||||||
|
// ChannelController - Управление каналами
|
||||||
|
CHANNELS: '/channels',
|
||||||
|
CHANNEL_BY_ID: '/channels',
|
||||||
|
|
||||||
|
// ContentController - Управление контентом
|
||||||
|
CONTENT: '/content',
|
||||||
|
CONTENT_BY_ID: '/content',
|
||||||
|
CONTENT_APPROVE: '/content',
|
||||||
|
CONTENT_MESSAGES: '/content',
|
||||||
|
|
||||||
|
// PublishingController - Публикация контента
|
||||||
|
PUBLISHING_POST: '/publishing/post',
|
||||||
|
|
||||||
|
// AnalyticsController - Аналитика
|
||||||
|
ANALYTICS_COLLECT: '/analytics/collect-now'
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,16 @@ const model = ref([
|
|||||||
{ label: 'История отчётов', icon: 'pi pi-history', to: '/reports/history' }
|
{ label: 'История отчётов', icon: 'pi pi-history', to: '/reports/history' }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: 'SMM Система',
|
||||||
|
items: [
|
||||||
|
{ label: 'SMM Панель', icon: 'pi pi-fw pi-home', to: '/smm' },
|
||||||
|
{ label: 'Кампании', icon: 'pi pi-fw pi-megaphone', to: '/smm/campaigns' },
|
||||||
|
{ label: 'Каналы', icon: 'pi pi-fw pi-broadcast-tower', to: '/smm/channels' },
|
||||||
|
{ label: 'Контент', icon: 'pi pi-fw pi-file-edit', to: '/smm/content' },
|
||||||
|
{ label: 'Аналитика', icon: 'pi pi-fw pi-chart-line', to: '/smm/analytics' }
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: 'Admin',
|
label: 'Admin',
|
||||||
items: [
|
items: [
|
||||||
|
|||||||
@@ -167,6 +167,36 @@ const router = createRouter({
|
|||||||
name: 'reports-history',
|
name: 'reports-history',
|
||||||
component: () => import('@/views/reports/ReportHistory.vue'),
|
component: () => import('@/views/reports/ReportHistory.vue'),
|
||||||
meta: { requiresAuth: true }
|
meta: { requiresAuth: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/smm/campaigns',
|
||||||
|
name: 'smm-campaigns',
|
||||||
|
component: () => import('@/views/smm/CampaignList.vue'),
|
||||||
|
meta: { requiresAuth: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/smm/channels',
|
||||||
|
name: 'smm-channels',
|
||||||
|
component: () => import('@/views/smm/ChannelList.vue'),
|
||||||
|
meta: { requiresAuth: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/smm/content',
|
||||||
|
name: 'smm-content',
|
||||||
|
component: () => import('@/views/smm/ContentList.vue'),
|
||||||
|
meta: { requiresAuth: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/smm/analytics',
|
||||||
|
name: 'smm-analytics',
|
||||||
|
component: () => import('@/views/smm/AnalyticsDashboard.vue'),
|
||||||
|
meta: { requiresAuth: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/smm',
|
||||||
|
name: 'smm-dashboard',
|
||||||
|
component: () => import('@/views/smm/SmmDashboard.vue'),
|
||||||
|
meta: { requiresAuth: true }
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,225 @@
|
|||||||
|
import { API_CONFIG, DEFAULT_REQUEST_CONFIG } from '@/config/api.js';
|
||||||
|
import AuthService from '@/service/AuthService.js';
|
||||||
|
import { AnalyticsResultDto } from '@/types/smm.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AnalyticsService - Аналитика
|
||||||
|
* Базовый путь: /api/smm/analytics
|
||||||
|
*/
|
||||||
|
class AnalyticsService {
|
||||||
|
constructor() {
|
||||||
|
this.baseUrl = API_CONFIG.SMM_BASE_URL;
|
||||||
|
this.endpoints = {
|
||||||
|
collectNow: API_CONFIG.ENDPOINTS.ANALYTICS_COLLECT
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Запустить сбор аналитики
|
||||||
|
* POST /api/smm/analytics/collect-now
|
||||||
|
* @returns {Promise<AnalyticsResultDto>}
|
||||||
|
*/
|
||||||
|
async collectAnalytics() {
|
||||||
|
try {
|
||||||
|
const response = await AuthService.authFetch(`${this.baseUrl}${this.endpoints.collectNow}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
...DEFAULT_REQUEST_CONFIG.headers
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json();
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}, message: ${errorData.message || 'Unknown error'}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return new AnalyticsResultDto(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error collecting analytics:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получить статистику кампаний
|
||||||
|
* @returns {Promise<Object>} - Статистика кампаний
|
||||||
|
*/
|
||||||
|
async getCampaignStats() {
|
||||||
|
try {
|
||||||
|
// Импортируем CampaignService для получения данных
|
||||||
|
const { default: CampaignService } = await import('./CampaignService.js');
|
||||||
|
const campaigns = await CampaignService.getAllCampaigns();
|
||||||
|
|
||||||
|
const stats = {
|
||||||
|
total: campaigns.length,
|
||||||
|
byStatus: {},
|
||||||
|
totalBudget: 0,
|
||||||
|
activeCampaigns: 0,
|
||||||
|
completedCampaigns: 0,
|
||||||
|
plannedCampaigns: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
campaigns.forEach((campaign) => {
|
||||||
|
// Подсчет по статусам
|
||||||
|
stats.byStatus[campaign.status] = (stats.byStatus[campaign.status] || 0) + 1;
|
||||||
|
|
||||||
|
// Подсчет бюджета
|
||||||
|
if (campaign.budget) {
|
||||||
|
stats.totalBudget += campaign.budget;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Подсчет активных кампаний
|
||||||
|
if (campaign.status === 'ACTIVE') {
|
||||||
|
stats.activeCampaigns++;
|
||||||
|
} else if (campaign.status === 'COMPLETED') {
|
||||||
|
stats.completedCampaigns++;
|
||||||
|
} else if (campaign.status === 'PLANNED') {
|
||||||
|
stats.plannedCampaigns++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return stats;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting campaign stats:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получить статистику каналов
|
||||||
|
* @returns {Promise<Object>} - Статистика каналов
|
||||||
|
*/
|
||||||
|
async getChannelStats() {
|
||||||
|
try {
|
||||||
|
// Импортируем ChannelService для получения данных
|
||||||
|
const { default: ChannelService } = await import('./ChannelService.js');
|
||||||
|
const channels = await ChannelService.getAllChannels();
|
||||||
|
|
||||||
|
const stats = {
|
||||||
|
total: channels.length,
|
||||||
|
byType: {},
|
||||||
|
activeChannels: 0,
|
||||||
|
inactiveChannels: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
channels.forEach((channel) => {
|
||||||
|
// Подсчет по типам
|
||||||
|
stats.byType[channel.type] = (stats.byType[channel.type] || 0) + 1;
|
||||||
|
|
||||||
|
// Подсчет активных/неактивных
|
||||||
|
if (channel.isActive) {
|
||||||
|
stats.activeChannels++;
|
||||||
|
} else {
|
||||||
|
stats.inactiveChannels++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return stats;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting channel stats:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получить статистику контента
|
||||||
|
* @returns {Promise<Object>} - Статистика контента
|
||||||
|
*/
|
||||||
|
async getContentStats() {
|
||||||
|
try {
|
||||||
|
// Импортируем ContentService для получения данных
|
||||||
|
const { default: ContentService } = await import('./ContentService.js');
|
||||||
|
const content = await ContentService.getAllContent();
|
||||||
|
|
||||||
|
const stats = {
|
||||||
|
total: content.length,
|
||||||
|
byStatus: {},
|
||||||
|
byLocale: {},
|
||||||
|
pendingApproval: 0,
|
||||||
|
approved: 0,
|
||||||
|
published: 0,
|
||||||
|
failed: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
content.forEach((item) => {
|
||||||
|
// Подсчет по статусам
|
||||||
|
stats.byStatus[item.status] = (stats.byStatus[item.status] || 0) + 1;
|
||||||
|
|
||||||
|
// Подсчет по локалям
|
||||||
|
stats.byLocale[item.locale] = (stats.byLocale[item.locale] || 0) + 1;
|
||||||
|
|
||||||
|
// Подсчет специальных статусов
|
||||||
|
if (item.status === 'PENDING_APPROVAL') {
|
||||||
|
stats.pendingApproval++;
|
||||||
|
} else if (item.status === 'APPROVED') {
|
||||||
|
stats.approved++;
|
||||||
|
} else if (item.status === 'PUBLISHED') {
|
||||||
|
stats.published++;
|
||||||
|
} else if (item.status === 'FAILED') {
|
||||||
|
stats.failed++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return stats;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting content stats:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получить общую статистику системы
|
||||||
|
* @returns {Promise<Object>} - Общая статистика
|
||||||
|
*/
|
||||||
|
async getSystemStats() {
|
||||||
|
try {
|
||||||
|
const [campaignStats, channelStats, contentStats] = await Promise.all([this.getCampaignStats(), this.getChannelStats(), this.getContentStats()]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
campaigns: campaignStats,
|
||||||
|
channels: channelStats,
|
||||||
|
content: contentStats,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting system stats:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получить статистику публикаций
|
||||||
|
* @returns {Promise<Object>} - Статистика публикаций
|
||||||
|
*/
|
||||||
|
async getPublishingStats() {
|
||||||
|
try {
|
||||||
|
// Импортируем ContentService для получения данных
|
||||||
|
const { default: ContentService } = await import('./ContentService.js');
|
||||||
|
const content = await ContentService.getAllContent();
|
||||||
|
|
||||||
|
const publishedContent = content.filter((item) => item.status === 'PUBLISHED');
|
||||||
|
const stats = {
|
||||||
|
totalPublished: publishedContent.length,
|
||||||
|
totalContent: content.length,
|
||||||
|
publishingRate: content.length > 0 ? (publishedContent.length / content.length) * 100 : 0,
|
||||||
|
byCampaign: {},
|
||||||
|
byLocale: {}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Группировка по кампаниям
|
||||||
|
publishedContent.forEach((item) => {
|
||||||
|
stats.byCampaign[item.campaignId] = (stats.byCampaign[item.campaignId] || 0) + 1;
|
||||||
|
stats.byLocale[item.locale] = (stats.byLocale[item.locale] || 0) + 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
return stats;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting publishing stats:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Экспорт singleton instance
|
||||||
|
export default new AnalyticsService();
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
import { API_CONFIG, DEFAULT_REQUEST_CONFIG } from '@/config/api.js';
|
||||||
|
import AuthService from '@/service/AuthService.js';
|
||||||
|
import { CampaignDto } from '@/types/smm.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CampaignService - Управление кампаниями
|
||||||
|
* Базовый путь: /api/smm/campaigns
|
||||||
|
*/
|
||||||
|
class CampaignService {
|
||||||
|
constructor() {
|
||||||
|
this.baseUrl = API_CONFIG.SMM_BASE_URL;
|
||||||
|
this.endpoints = {
|
||||||
|
campaigns: API_CONFIG.ENDPOINTS.CAMPAIGNS,
|
||||||
|
campaignById: API_CONFIG.ENDPOINTS.CAMPAIGN_BY_ID
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получить все кампании
|
||||||
|
* GET /api/smm/campaigns
|
||||||
|
* @returns {Promise<CampaignDto[]>}
|
||||||
|
*/
|
||||||
|
async getAllCampaigns() {
|
||||||
|
try {
|
||||||
|
const response = await AuthService.authFetch(`${this.baseUrl}${this.endpoints.campaigns}`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
...DEFAULT_REQUEST_CONFIG.headers
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return data.map((campaign) => new CampaignDto(campaign));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching campaigns:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получить кампанию по ID
|
||||||
|
* GET /api/smm/campaigns/{id}
|
||||||
|
* @param {string} id - UUID кампании
|
||||||
|
* @returns {Promise<CampaignDto>}
|
||||||
|
*/
|
||||||
|
async getCampaignById(id) {
|
||||||
|
try {
|
||||||
|
const response = await AuthService.authFetch(`${this.baseUrl}${this.endpoints.campaignById}/${id}`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
...DEFAULT_REQUEST_CONFIG.headers
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return new CampaignDto(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error fetching campaign ${id}:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Создать кампанию
|
||||||
|
* POST /api/smm/campaigns
|
||||||
|
* @param {Object} campaignData - Данные кампании
|
||||||
|
* @param {string} campaignData.name - Название кампании
|
||||||
|
* @param {string} campaignData.goal - Цель кампании
|
||||||
|
* @param {number} campaignData.budget - Бюджет кампании
|
||||||
|
* @param {string} campaignData.startAt - Дата начала (ISO 8601)
|
||||||
|
* @param {string} campaignData.endAt - Дата окончания (ISO 8601)
|
||||||
|
* @param {string} campaignData.status - Статус кампании
|
||||||
|
* @returns {Promise<CampaignDto>}
|
||||||
|
*/
|
||||||
|
async createCampaign(campaignData) {
|
||||||
|
try {
|
||||||
|
// Валидация
|
||||||
|
this.validateCampaignData(campaignData);
|
||||||
|
|
||||||
|
const response = await AuthService.authFetch(`${this.baseUrl}${this.endpoints.campaigns}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
...DEFAULT_REQUEST_CONFIG.headers
|
||||||
|
},
|
||||||
|
body: JSON.stringify(campaignData)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json();
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}, message: ${errorData.message || 'Unknown error'}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return new CampaignDto(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating campaign:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обновить кампанию
|
||||||
|
* PUT /api/smm/campaigns/{id}
|
||||||
|
* @param {string} id - UUID кампании
|
||||||
|
* @param {Object} campaignData - Данные кампании
|
||||||
|
* @returns {Promise<CampaignDto>}
|
||||||
|
*/
|
||||||
|
async updateCampaign(id, campaignData) {
|
||||||
|
try {
|
||||||
|
// Валидация
|
||||||
|
this.validateCampaignData(campaignData);
|
||||||
|
|
||||||
|
const response = await AuthService.authFetch(`${this.baseUrl}${this.endpoints.campaignById}/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
...DEFAULT_REQUEST_CONFIG.headers
|
||||||
|
},
|
||||||
|
body: JSON.stringify(campaignData)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json();
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}, message: ${errorData.message || 'Unknown error'}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return new CampaignDto(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error updating campaign ${id}:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Удалить кампанию
|
||||||
|
* DELETE /api/smm/campaigns/{id}
|
||||||
|
* @param {string} id - UUID кампании
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async deleteCampaign(id) {
|
||||||
|
try {
|
||||||
|
const response = await AuthService.authFetch(`${this.baseUrl}${this.endpoints.campaignById}/${id}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error deleting campaign ${id}:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Валидация данных кампании
|
||||||
|
* @param {Object} campaignData - Данные кампании
|
||||||
|
* @throws {Error} Если валидация не прошла
|
||||||
|
*/
|
||||||
|
validateCampaignData(campaignData) {
|
||||||
|
if (!campaignData.name || campaignData.name.trim() === '') {
|
||||||
|
throw new Error('Название кампании обязательно');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (campaignData.budget !== undefined && campaignData.budget < 0) {
|
||||||
|
throw new Error('Бюджет должен быть >= 0');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (campaignData.startAt) {
|
||||||
|
const startDate = new Date(campaignData.startAt);
|
||||||
|
if (isNaN(startDate.getTime())) {
|
||||||
|
throw new Error('Неверный формат даты начала');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (campaignData.endAt) {
|
||||||
|
const endDate = new Date(campaignData.endAt);
|
||||||
|
if (isNaN(endDate.getTime())) {
|
||||||
|
throw new Error('Неверный формат даты окончания');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (campaignData.startAt && campaignData.endAt) {
|
||||||
|
const startDate = new Date(campaignData.startAt);
|
||||||
|
const endDate = new Date(campaignData.endAt);
|
||||||
|
if (endDate <= startDate) {
|
||||||
|
throw new Error('Дата окончания должна быть позже даты начала');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Экспорт singleton instance
|
||||||
|
export default new CampaignService();
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
import { API_CONFIG, DEFAULT_REQUEST_CONFIG } from '@/config/api.js';
|
||||||
|
import AuthService from '@/service/AuthService.js';
|
||||||
|
import { ChannelDto, ChannelType } from '@/types/smm.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ChannelService - Управление каналами
|
||||||
|
* Базовый путь: /api/smm/channels
|
||||||
|
*/
|
||||||
|
class ChannelService {
|
||||||
|
constructor() {
|
||||||
|
this.baseUrl = API_CONFIG.SMM_BASE_URL;
|
||||||
|
this.endpoints = {
|
||||||
|
channels: API_CONFIG.ENDPOINTS.CHANNELS,
|
||||||
|
channelById: API_CONFIG.ENDPOINTS.CHANNEL_BY_ID
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получить все каналы
|
||||||
|
* GET /api/smm/channels
|
||||||
|
* @returns {Promise<ChannelDto[]>}
|
||||||
|
*/
|
||||||
|
async getAllChannels() {
|
||||||
|
try {
|
||||||
|
const response = await AuthService.authFetch(`${this.baseUrl}${this.endpoints.channels}`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
...DEFAULT_REQUEST_CONFIG.headers
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return data.map((channel) => new ChannelDto(channel));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching channels:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получить канал по ID
|
||||||
|
* GET /api/smm/channels/{id}
|
||||||
|
* @param {string} id - UUID канала
|
||||||
|
* @returns {Promise<ChannelDto>}
|
||||||
|
*/
|
||||||
|
async getChannelById(id) {
|
||||||
|
try {
|
||||||
|
const response = await AuthService.authFetch(`${this.baseUrl}${this.endpoints.channelById}/${id}`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
...DEFAULT_REQUEST_CONFIG.headers
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return new ChannelDto(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error fetching channel ${id}:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Создать канал
|
||||||
|
* POST /api/smm/channels
|
||||||
|
* @param {Object} channelData - Данные канала
|
||||||
|
* @param {string} channelData.name - Название канала
|
||||||
|
* @param {string} channelData.type - Тип канала (TELEGRAM, VK, INSTAGRAM)
|
||||||
|
* @param {string} channelData.apiKeyRef - Ссылка на API ключ
|
||||||
|
* @param {boolean} channelData.isActive - Активен ли канал
|
||||||
|
* @returns {Promise<ChannelDto>}
|
||||||
|
*/
|
||||||
|
async createChannel(channelData) {
|
||||||
|
try {
|
||||||
|
// Валидация
|
||||||
|
this.validateChannelData(channelData);
|
||||||
|
|
||||||
|
const response = await AuthService.authFetch(`${this.baseUrl}${this.endpoints.channels}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
...DEFAULT_REQUEST_CONFIG.headers
|
||||||
|
},
|
||||||
|
body: JSON.stringify(channelData)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json();
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}, message: ${errorData.message || 'Unknown error'}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return new ChannelDto(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating channel:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обновить канал
|
||||||
|
* PUT /api/smm/channels/{id}
|
||||||
|
* @param {string} id - UUID канала
|
||||||
|
* @param {Object} channelData - Данные канала
|
||||||
|
* @returns {Promise<ChannelDto>}
|
||||||
|
*/
|
||||||
|
async updateChannel(id, channelData) {
|
||||||
|
try {
|
||||||
|
// Валидация
|
||||||
|
this.validateChannelData(channelData);
|
||||||
|
|
||||||
|
const response = await AuthService.authFetch(`${this.baseUrl}${this.endpoints.channelById}/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
...DEFAULT_REQUEST_CONFIG.headers
|
||||||
|
},
|
||||||
|
body: JSON.stringify(channelData)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json();
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}, message: ${errorData.message || 'Unknown error'}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return new ChannelDto(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error updating channel ${id}:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Удалить канал
|
||||||
|
* DELETE /api/smm/channels/{id}
|
||||||
|
* @param {string} id - UUID канала
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async deleteChannel(id) {
|
||||||
|
try {
|
||||||
|
const response = await AuthService.authFetch(`${this.baseUrl}${this.endpoints.channelById}/${id}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error deleting channel ${id}:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получить каналы по типу
|
||||||
|
* @param {string} type - Тип канала
|
||||||
|
* @returns {Promise<ChannelDto[]>}
|
||||||
|
*/
|
||||||
|
async getChannelsByType(type) {
|
||||||
|
try {
|
||||||
|
const allChannels = await this.getAllChannels();
|
||||||
|
return allChannels.filter((channel) => channel.type === type);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error fetching channels by type ${type}:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получить активные каналы
|
||||||
|
* @returns {Promise<ChannelDto[]>}
|
||||||
|
*/
|
||||||
|
async getActiveChannels() {
|
||||||
|
try {
|
||||||
|
const allChannels = await this.getAllChannels();
|
||||||
|
return allChannels.filter((channel) => channel.isActive);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching active channels:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Валидация данных канала
|
||||||
|
* @param {Object} channelData - Данные канала
|
||||||
|
* @throws {Error} Если валидация не прошла
|
||||||
|
*/
|
||||||
|
validateChannelData(channelData) {
|
||||||
|
if (!channelData.name || channelData.name.trim() === '') {
|
||||||
|
throw new Error('Название канала обязательно');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!channelData.type || !Object.values(ChannelType).includes(channelData.type)) {
|
||||||
|
throw new Error('Тип канала обязателен и должен быть одним из: TELEGRAM, VK, INSTAGRAM');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!channelData.apiKeyRef || channelData.apiKeyRef.trim() === '') {
|
||||||
|
throw new Error('Ссылка на API ключ обязательна');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof channelData.isActive !== 'boolean') {
|
||||||
|
throw new Error('Поле isActive должно быть булевым значением');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Экспорт singleton instance
|
||||||
|
export default new ChannelService();
|
||||||
@@ -0,0 +1,304 @@
|
|||||||
|
import { API_CONFIG, DEFAULT_REQUEST_CONFIG } from '@/config/api.js';
|
||||||
|
import AuthService from '@/service/AuthService.js';
|
||||||
|
import { ContentQueueDto, ContentStatus, MessageDto } from '@/types/smm.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ContentService - Управление контентом
|
||||||
|
* Базовый путь: /api/smm/content
|
||||||
|
*/
|
||||||
|
class ContentService {
|
||||||
|
constructor() {
|
||||||
|
this.baseUrl = API_CONFIG.SMM_BASE_URL;
|
||||||
|
this.endpoints = {
|
||||||
|
content: API_CONFIG.ENDPOINTS.CONTENT,
|
||||||
|
contentById: API_CONFIG.ENDPOINTS.CONTENT_BY_ID,
|
||||||
|
contentApprove: API_CONFIG.ENDPOINTS.CONTENT_APPROVE,
|
||||||
|
contentMessages: API_CONFIG.ENDPOINTS.CONTENT_MESSAGES
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получить весь контент
|
||||||
|
* GET /api/smm/content
|
||||||
|
* @returns {Promise<ContentQueueDto[]>}
|
||||||
|
*/
|
||||||
|
async getAllContent() {
|
||||||
|
try {
|
||||||
|
const response = await AuthService.authFetch(`${this.baseUrl}${this.endpoints.content}`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
...DEFAULT_REQUEST_CONFIG.headers
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return data.map((content) => new ContentQueueDto(content));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching content:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получить контент по ID
|
||||||
|
* GET /api/smm/content/{id}
|
||||||
|
* @param {string} id - UUID контента
|
||||||
|
* @returns {Promise<ContentQueueDto>}
|
||||||
|
*/
|
||||||
|
async getContentById(id) {
|
||||||
|
try {
|
||||||
|
const response = await AuthService.authFetch(`${this.baseUrl}${this.endpoints.contentById}/${id}`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
...DEFAULT_REQUEST_CONFIG.headers
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return new ContentQueueDto(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error fetching content ${id}:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Создать контент
|
||||||
|
* POST /api/smm/content
|
||||||
|
* @param {Object} contentData - Данные контента
|
||||||
|
* @param {string} contentData.campaignId - ID кампании
|
||||||
|
* @param {string} contentData.locale - Локаль
|
||||||
|
* @param {string} contentData.topic - Тема
|
||||||
|
* @param {string} contentData.postDraft - Черновик поста
|
||||||
|
* @param {string} contentData.assetsRefs - Ссылки на ресурсы
|
||||||
|
* @param {string} contentData.scheduledAt - Время публикации (ISO 8601)
|
||||||
|
* @param {number} contentData.priority - Приоритет
|
||||||
|
* @returns {Promise<ContentQueueDto>}
|
||||||
|
*/
|
||||||
|
async createContent(contentData) {
|
||||||
|
try {
|
||||||
|
// Валидация
|
||||||
|
this.validateContentData(contentData);
|
||||||
|
|
||||||
|
const response = await AuthService.authFetch(`${this.baseUrl}${this.endpoints.content}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
...DEFAULT_REQUEST_CONFIG.headers
|
||||||
|
},
|
||||||
|
body: JSON.stringify(contentData)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json();
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}, message: ${errorData.message || 'Unknown error'}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return new ContentQueueDto(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating content:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обновить контент
|
||||||
|
* PUT /api/smm/content/{id}
|
||||||
|
* @param {string} id - UUID контента
|
||||||
|
* @param {Object} contentData - Данные контента
|
||||||
|
* @returns {Promise<ContentQueueDto>}
|
||||||
|
*/
|
||||||
|
async updateContent(id, contentData) {
|
||||||
|
try {
|
||||||
|
// Валидация
|
||||||
|
this.validateContentData(contentData);
|
||||||
|
|
||||||
|
const response = await AuthService.authFetch(`${this.baseUrl}${this.endpoints.contentById}/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
...DEFAULT_REQUEST_CONFIG.headers
|
||||||
|
},
|
||||||
|
body: JSON.stringify(contentData)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json();
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}, message: ${errorData.message || 'Unknown error'}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return new ContentQueueDto(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error updating content ${id}:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Удалить контент
|
||||||
|
* DELETE /api/smm/content/{id}
|
||||||
|
* @param {string} id - UUID контента
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async deleteContent(id) {
|
||||||
|
try {
|
||||||
|
const response = await AuthService.authFetch(`${this.baseUrl}${this.endpoints.contentById}/${id}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error deleting content ${id}:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Одобрить контент
|
||||||
|
* POST /api/smm/content/{id}/approve
|
||||||
|
* @param {string} id - UUID контента
|
||||||
|
* @returns {Promise<ContentQueueDto>}
|
||||||
|
*/
|
||||||
|
async approveContent(id) {
|
||||||
|
try {
|
||||||
|
const response = await AuthService.authFetch(`${this.baseUrl}${this.endpoints.contentApprove}/${id}/approve`, {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json();
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}, message: ${errorData.message || 'Unknown error'}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return new ContentQueueDto(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error approving content ${id}:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получить сообщения контента
|
||||||
|
* GET /api/smm/content/{id}/messages
|
||||||
|
* @param {string} id - UUID контента
|
||||||
|
* @returns {Promise<MessageDto[]>}
|
||||||
|
*/
|
||||||
|
async getContentMessages(id) {
|
||||||
|
try {
|
||||||
|
const response = await AuthService.authFetch(`${this.baseUrl}${this.endpoints.contentMessages}/${id}/messages`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
...DEFAULT_REQUEST_CONFIG.headers
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return data.map((message) => new MessageDto(message));
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error fetching content messages ${id}:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получить контент по кампании
|
||||||
|
* @param {string} campaignId - UUID кампании
|
||||||
|
* @returns {Promise<ContentQueueDto[]>}
|
||||||
|
*/
|
||||||
|
async getContentByCampaign(campaignId) {
|
||||||
|
try {
|
||||||
|
const allContent = await this.getAllContent();
|
||||||
|
return allContent.filter((content) => content.campaignId === campaignId);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error fetching content by campaign ${campaignId}:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получить контент по статусу
|
||||||
|
* @param {string} status - Статус контента
|
||||||
|
* @returns {Promise<ContentQueueDto[]>}
|
||||||
|
*/
|
||||||
|
async getContentByStatus(status) {
|
||||||
|
try {
|
||||||
|
const allContent = await this.getAllContent();
|
||||||
|
return allContent.filter((content) => content.status === status);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error fetching content by status ${status}:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получить контент, ожидающий одобрения
|
||||||
|
* @returns {Promise<ContentQueueDto[]>}
|
||||||
|
*/
|
||||||
|
async getPendingApprovalContent() {
|
||||||
|
return this.getContentByStatus(ContentStatus.PENDING_APPROVAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получить одобренный контент
|
||||||
|
* @returns {Promise<ContentQueueDto[]>}
|
||||||
|
*/
|
||||||
|
async getApprovedContent() {
|
||||||
|
return this.getContentByStatus(ContentStatus.APPROVED);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получить опубликованный контент
|
||||||
|
* @returns {Promise<ContentQueueDto[]>}
|
||||||
|
*/
|
||||||
|
async getPublishedContent() {
|
||||||
|
return this.getContentByStatus(ContentStatus.PUBLISHED);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Валидация данных контента
|
||||||
|
* @param {Object} contentData - Данные контента
|
||||||
|
* @throws {Error} Если валидация не прошла
|
||||||
|
*/
|
||||||
|
validateContentData(contentData) {
|
||||||
|
if (!contentData.campaignId || contentData.campaignId.trim() === '') {
|
||||||
|
throw new Error('ID кампании обязателен');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!contentData.locale || contentData.locale.trim() === '') {
|
||||||
|
throw new Error('Локаль обязательна');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!contentData.topic || contentData.topic.trim() === '') {
|
||||||
|
throw new Error('Тема обязательна');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contentData.scheduledAt) {
|
||||||
|
const scheduledDate = new Date(contentData.scheduledAt);
|
||||||
|
if (isNaN(scheduledDate.getTime())) {
|
||||||
|
throw new Error('Неверный формат времени публикации');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contentData.priority !== undefined && (contentData.priority < 1 || contentData.priority > 10)) {
|
||||||
|
throw new Error('Приоритет должен быть от 1 до 10');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Экспорт singleton instance
|
||||||
|
export default new ContentService();
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { API_CONFIG, DEFAULT_REQUEST_CONFIG } from '@/config/api.js';
|
||||||
|
import AuthService from '@/service/AuthService.js';
|
||||||
|
import { PublishingResultDto } from '@/types/smm.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PublishingService - Публикация контента
|
||||||
|
* Базовый путь: /api/smm/publishing
|
||||||
|
*/
|
||||||
|
class PublishingService {
|
||||||
|
constructor() {
|
||||||
|
this.baseUrl = API_CONFIG.SMM_BASE_URL;
|
||||||
|
this.endpoints = {
|
||||||
|
publishPost: API_CONFIG.ENDPOINTS.PUBLISHING_POST
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Опубликовать контент
|
||||||
|
* POST /api/smm/publishing/post/{contentId}
|
||||||
|
* @param {string} contentId - UUID контента для публикации
|
||||||
|
* @returns {Promise<PublishingResultDto>}
|
||||||
|
*/
|
||||||
|
async publishContent(contentId) {
|
||||||
|
try {
|
||||||
|
if (!contentId || contentId.trim() === '') {
|
||||||
|
throw new Error('ID контента обязателен');
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await AuthService.authFetch(`${this.baseUrl}${this.endpoints.publishPost}/${contentId}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
...DEFAULT_REQUEST_CONFIG.headers
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json();
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}, message: ${errorData.message || 'Unknown error'}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return new PublishingResultDto(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error publishing content ${contentId}:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверить статус публикации контента
|
||||||
|
* @param {string} contentId - UUID контента
|
||||||
|
* @returns {Promise<boolean>} - true если контент опубликован
|
||||||
|
*/
|
||||||
|
async isContentPublished(contentId) {
|
||||||
|
try {
|
||||||
|
// Импортируем ContentService для проверки статуса
|
||||||
|
const { default: ContentService } = await import('./ContentService.js');
|
||||||
|
const content = await ContentService.getContentById(contentId);
|
||||||
|
return content.status === 'PUBLISHED';
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error checking publication status for content ${contentId}:`, error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получить URL опубликованного контента
|
||||||
|
* @param {string} contentId - UUID контента
|
||||||
|
* @returns {Promise<string|null>} - URL публикации или null
|
||||||
|
*/
|
||||||
|
async getPublishedContentUrl(contentId) {
|
||||||
|
try {
|
||||||
|
// Импортируем ContentService для получения сообщений
|
||||||
|
const { default: ContentService } = await import('./ContentService.js');
|
||||||
|
const messages = await ContentService.getContentMessages(contentId);
|
||||||
|
|
||||||
|
if (messages && messages.length > 0) {
|
||||||
|
// Возвращаем URL первого сообщения
|
||||||
|
return messages[0].url;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error getting published content URL for ${contentId}:`, error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получить статистику публикаций
|
||||||
|
* @param {string} contentId - UUID контента
|
||||||
|
* @returns {Promise<Object>} - Статистика публикаций
|
||||||
|
*/
|
||||||
|
async getPublishingStats(contentId) {
|
||||||
|
try {
|
||||||
|
// Импортируем ContentService для получения сообщений
|
||||||
|
const { default: ContentService } = await import('./ContentService.js');
|
||||||
|
const messages = await ContentService.getContentMessages(contentId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalMessages: messages.length,
|
||||||
|
publishedChannels: messages.map((msg) => msg.channelId),
|
||||||
|
urls: messages.map((msg) => msg.url),
|
||||||
|
lastPublished: messages.length > 0 ? new Date(Math.max(...messages.map((msg) => new Date(msg.postedAt).getTime()))) : null
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error getting publishing stats for content ${contentId}:`, error);
|
||||||
|
return {
|
||||||
|
totalMessages: 0,
|
||||||
|
publishedChannels: [],
|
||||||
|
urls: [],
|
||||||
|
lastPublished: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Массовая публикация контента
|
||||||
|
* @param {string[]} contentIds - Массив UUID контента
|
||||||
|
* @returns {Promise<PublishingResultDto[]>} - Результаты публикации
|
||||||
|
*/
|
||||||
|
async publishMultipleContent(contentIds) {
|
||||||
|
try {
|
||||||
|
if (!Array.isArray(contentIds) || contentIds.length === 0) {
|
||||||
|
throw new Error('Массив ID контента не может быть пустым');
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
|
||||||
|
for (const contentId of contentIds) {
|
||||||
|
try {
|
||||||
|
const result = await this.publishContent(contentId);
|
||||||
|
results.push(result);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error publishing content ${contentId}:`, error);
|
||||||
|
results.push({
|
||||||
|
success: false,
|
||||||
|
message: `Ошибка публикации контента ${contentId}: ${error.message}`,
|
||||||
|
data: null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in mass publishing:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Экспорт singleton instance
|
||||||
|
export default new PublishingService();
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
// SMM System Types and Enums
|
||||||
|
|
||||||
|
// Campaign Status Enum
|
||||||
|
export const CampaignStatus = {
|
||||||
|
PLANNED: 'PLANNED',
|
||||||
|
ACTIVE: 'ACTIVE',
|
||||||
|
COMPLETED: 'COMPLETED'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Channel Type Enum
|
||||||
|
export const ChannelType = {
|
||||||
|
TELEGRAM: 'TELEGRAM',
|
||||||
|
VK: 'VK',
|
||||||
|
INSTAGRAM: 'INSTAGRAM'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Content Status Enum
|
||||||
|
export const ContentStatus = {
|
||||||
|
DRAFT: 'DRAFT',
|
||||||
|
PENDING_APPROVAL: 'PENDING_APPROVAL',
|
||||||
|
APPROVED: 'APPROVED',
|
||||||
|
PUBLISHED: 'PUBLISHED',
|
||||||
|
FAILED: 'FAILED'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Campaign DTO
|
||||||
|
export class CampaignDto {
|
||||||
|
constructor(data = {}) {
|
||||||
|
this.id = data.id;
|
||||||
|
this.name = data.name;
|
||||||
|
this.goal = data.goal;
|
||||||
|
this.budget = data.budget;
|
||||||
|
this.startAt = data.startAt;
|
||||||
|
this.endAt = data.endAt;
|
||||||
|
this.status = data.status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Channel DTO
|
||||||
|
export class ChannelDto {
|
||||||
|
constructor(data = {}) {
|
||||||
|
this.id = data.id;
|
||||||
|
this.name = data.name;
|
||||||
|
this.type = data.type;
|
||||||
|
this.apiKeyRef = data.apiKeyRef;
|
||||||
|
this.isActive = data.isActive;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Content Queue DTO
|
||||||
|
export class ContentQueueDto {
|
||||||
|
constructor(data = {}) {
|
||||||
|
this.id = data.id;
|
||||||
|
this.campaignId = data.campaignId;
|
||||||
|
this.locale = data.locale;
|
||||||
|
this.topic = data.topic;
|
||||||
|
this.postDraft = data.postDraft;
|
||||||
|
this.assetsRefs = data.assetsRefs;
|
||||||
|
this.scheduledAt = data.scheduledAt;
|
||||||
|
this.priority = data.priority;
|
||||||
|
this.status = data.status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Message DTO
|
||||||
|
export class MessageDto {
|
||||||
|
constructor(data = {}) {
|
||||||
|
this.id = data.id;
|
||||||
|
this.channelId = data.channelId;
|
||||||
|
this.contentId = data.contentId;
|
||||||
|
this.externalId = data.externalId;
|
||||||
|
this.url = data.url;
|
||||||
|
this.postedAt = data.postedAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Publishing Result DTO
|
||||||
|
export class PublishingResultDto {
|
||||||
|
constructor(data = {}) {
|
||||||
|
this.success = data.success;
|
||||||
|
this.message = data.message;
|
||||||
|
this.data = data.data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Analytics Result DTO
|
||||||
|
export class AnalyticsResultDto {
|
||||||
|
constructor(data = {}) {
|
||||||
|
this.success = data.success;
|
||||||
|
this.message = data.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error Response DTO
|
||||||
|
export class ErrorResponseDto {
|
||||||
|
constructor(data = {}) {
|
||||||
|
this.timestamp = data.timestamp;
|
||||||
|
this.status = data.status;
|
||||||
|
this.error = data.error;
|
||||||
|
this.message = data.message;
|
||||||
|
this.path = data.path;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,404 @@
|
|||||||
|
<template>
|
||||||
|
<div class="analytics-dashboard">
|
||||||
|
<!-- Заголовок с кнопкой сбора аналитики -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5>Аналитика SMM системы</h5>
|
||||||
|
<Button icon="pi pi-refresh" label="Собрать аналитику" @click="collectAnalytics" :loading="collecting" class="p-button-info" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Статистические карточки -->
|
||||||
|
<div class="grid">
|
||||||
|
<div class="col-12 md:col-6 lg:col-3">
|
||||||
|
<div class="card stat-card">
|
||||||
|
<div class="stat-content">
|
||||||
|
<div class="stat-icon">
|
||||||
|
<i class="pi pi-chart-line text-blue-500"></i>
|
||||||
|
</div>
|
||||||
|
<div class="stat-info">
|
||||||
|
<h6>Кампании</h6>
|
||||||
|
<h3>{{ systemStats.campaigns?.total || 0 }}</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 md:col-6 lg:col-3">
|
||||||
|
<div class="card stat-card">
|
||||||
|
<div class="stat-content">
|
||||||
|
<div class="stat-icon">
|
||||||
|
<i class="pi pi-broadcast-tower text-green-500"></i>
|
||||||
|
</div>
|
||||||
|
<div class="stat-info">
|
||||||
|
<h6>Каналы</h6>
|
||||||
|
<h3>{{ systemStats.channels?.total || 0 }}</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 md:col-6 lg:col-3">
|
||||||
|
<div class="card stat-card">
|
||||||
|
<div class="stat-content">
|
||||||
|
<div class="stat-icon">
|
||||||
|
<i class="pi pi-file text-orange-500"></i>
|
||||||
|
</div>
|
||||||
|
<div class="stat-info">
|
||||||
|
<h6>Контент</h6>
|
||||||
|
<h3>{{ systemStats.content?.total || 0 }}</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 md:col-6 lg:col-3">
|
||||||
|
<div class="card stat-card">
|
||||||
|
<div class="stat-content">
|
||||||
|
<div class="stat-icon">
|
||||||
|
<i class="pi pi-send text-purple-500"></i>
|
||||||
|
</div>
|
||||||
|
<div class="stat-info">
|
||||||
|
<h6>Опубликовано</h6>
|
||||||
|
<h3>{{ publishingStats.totalPublished || 0 }}</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Детальная статистика -->
|
||||||
|
<div class="grid">
|
||||||
|
<!-- Статистика кампаний -->
|
||||||
|
<div class="col-12 lg:col-6">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h6>Статистика кампаний</h6>
|
||||||
|
</div>
|
||||||
|
<div class="card-content">
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-label">Активные:</span>
|
||||||
|
<span class="stat-value text-green-600">{{ systemStats.campaigns?.activeCampaigns || 0 }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-label">Завершенные:</span>
|
||||||
|
<span class="stat-value text-blue-600">{{ systemStats.campaigns?.completedCampaigns || 0 }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-label">Запланированные:</span>
|
||||||
|
<span class="stat-value text-orange-600">{{ systemStats.campaigns?.plannedCampaigns || 0 }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-label">Общий бюджет:</span>
|
||||||
|
<span class="stat-value text-purple-600">{{ formatCurrency(systemStats.campaigns?.totalBudget || 0) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Статистика каналов -->
|
||||||
|
<div class="col-12 lg:col-6">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h6>Статистика каналов</h6>
|
||||||
|
</div>
|
||||||
|
<div class="card-content">
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-label">Активные:</span>
|
||||||
|
<span class="stat-value text-green-600">{{ systemStats.channels?.activeChannels || 0 }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-label">Неактивные:</span>
|
||||||
|
<span class="stat-value text-red-600">{{ systemStats.channels?.inactiveChannels || 0 }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item" v-for="(count, type) in systemStats.channels?.byType" :key="type">
|
||||||
|
<span class="stat-label">{{ getChannelTypeLabel(type) }}:</span>
|
||||||
|
<span class="stat-value">{{ count }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Статистика контента -->
|
||||||
|
<div class="col-12 lg:col-6">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h6>Статистика контента</h6>
|
||||||
|
</div>
|
||||||
|
<div class="card-content">
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-label">Ожидает одобрения:</span>
|
||||||
|
<span class="stat-value text-orange-600">{{ systemStats.content?.pendingApproval || 0 }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-label">Одобрен:</span>
|
||||||
|
<span class="stat-value text-blue-600">{{ systemStats.content?.approved || 0 }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-label">Опубликован:</span>
|
||||||
|
<span class="stat-value text-green-600">{{ systemStats.content?.published || 0 }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-label">Ошибки:</span>
|
||||||
|
<span class="stat-value text-red-600">{{ systemStats.content?.failed || 0 }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Статистика публикаций -->
|
||||||
|
<div class="col-12 lg:col-6">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h6>Статистика публикаций</h6>
|
||||||
|
</div>
|
||||||
|
<div class="card-content">
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-label">Опубликовано:</span>
|
||||||
|
<span class="stat-value text-green-600">{{ publishingStats.totalPublished || 0 }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-label">Всего контента:</span>
|
||||||
|
<span class="stat-value">{{ publishingStats.totalContent || 0 }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-label">Процент публикации:</span>
|
||||||
|
<span class="stat-value text-blue-600">{{ publishingStats.publishingRate?.toFixed(1) || 0 }}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Время последнего обновления -->
|
||||||
|
<div class="card mt-4">
|
||||||
|
<div class="card-content text-center">
|
||||||
|
<small class="text-600"> Последнее обновление: {{ formatDateTime(systemStats.timestamp) }} </small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import AnalyticsService from '@/service/AnalyticsService.js';
|
||||||
|
import { ChannelType } from '@/types/smm.js';
|
||||||
|
import { useToast } from 'primevue/usetoast';
|
||||||
|
import { onMounted, ref } from 'vue';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'AnalyticsDashboard',
|
||||||
|
setup() {
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
|
// Reactive data
|
||||||
|
const systemStats = ref({});
|
||||||
|
const publishingStats = ref({});
|
||||||
|
const collecting = ref(false);
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
const loadAnalytics = async () => {
|
||||||
|
try {
|
||||||
|
const [systemStatsData, publishingStatsData] = await Promise.all([AnalyticsService.getSystemStats(), AnalyticsService.getPublishingStats()]);
|
||||||
|
|
||||||
|
systemStats.value = systemStatsData;
|
||||||
|
publishingStats.value = publishingStatsData;
|
||||||
|
} catch (error) {
|
||||||
|
toast.add({
|
||||||
|
severity: 'error',
|
||||||
|
summary: 'Ошибка',
|
||||||
|
detail: 'Не удалось загрузить аналитику: ' + error.message,
|
||||||
|
life: 3000
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const collectAnalytics = async () => {
|
||||||
|
collecting.value = true;
|
||||||
|
try {
|
||||||
|
await AnalyticsService.collectAnalytics();
|
||||||
|
toast.add({
|
||||||
|
severity: 'success',
|
||||||
|
summary: 'Успех',
|
||||||
|
detail: 'Сбор аналитики запущен',
|
||||||
|
life: 3000
|
||||||
|
});
|
||||||
|
// Перезагружаем данные после сбора
|
||||||
|
setTimeout(() => {
|
||||||
|
loadAnalytics();
|
||||||
|
}, 2000);
|
||||||
|
} catch (error) {
|
||||||
|
toast.add({
|
||||||
|
severity: 'error',
|
||||||
|
summary: 'Ошибка',
|
||||||
|
detail: 'Не удалось запустить сбор аналитики: ' + error.message,
|
||||||
|
life: 3000
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
collecting.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatCurrency = (value) => {
|
||||||
|
return new Intl.NumberFormat('ru-RU', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'KZT'
|
||||||
|
}).format(value || 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDateTime = (dateString) => {
|
||||||
|
if (!dateString) return 'Не определено';
|
||||||
|
return new Date(dateString).toLocaleString('ru-RU', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getChannelTypeLabel = (type) => {
|
||||||
|
const labels = {
|
||||||
|
[ChannelType.TELEGRAM]: 'Telegram',
|
||||||
|
[ChannelType.VK]: 'VK',
|
||||||
|
[ChannelType.INSTAGRAM]: 'Instagram'
|
||||||
|
};
|
||||||
|
return labels[type] || type;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Lifecycle
|
||||||
|
onMounted(() => {
|
||||||
|
loadAnalytics();
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
systemStats,
|
||||||
|
publishingStats,
|
||||||
|
collecting,
|
||||||
|
loadAnalytics,
|
||||||
|
collectAnalytics,
|
||||||
|
formatCurrency,
|
||||||
|
formatDateTime,
|
||||||
|
getChannelTypeLabel
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.analytics-dashboard {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
background: var(--surface-card);
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow:
|
||||||
|
0 1px 3px rgba(0, 0, 0, 0.12),
|
||||||
|
0 1px 2px rgba(0, 0, 0, 0.24);
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-icon {
|
||||||
|
font-size: 2rem;
|
||||||
|
margin-right: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-info h6 {
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
color: var(--text-color-secondary);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-info h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--surface-card);
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow:
|
||||||
|
0 1px 3px rgba(0, 0, 0, 0.12),
|
||||||
|
0 1px 2px rgba(0, 0, 0, 0.24);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-bottom: 1px solid var(--surface-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header h6 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-content {
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.75rem 0;
|
||||||
|
border-bottom: 1px solid var(--surface-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-600 {
|
||||||
|
color: var(--text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-center {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mb-4 {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mt-4 {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,399 @@
|
|||||||
|
<template>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5>Управление кампаниями</h5>
|
||||||
|
<Button icon="pi pi-plus" label="Создать кампанию" @click="showCreateDialog = true" class="p-button-success" />
|
||||||
|
</div>
|
||||||
|
<div class="card-content">
|
||||||
|
<DataTable
|
||||||
|
:value="campaigns"
|
||||||
|
:loading="loading"
|
||||||
|
paginator
|
||||||
|
:rows="10"
|
||||||
|
:rowsPerPageOptions="[5, 10, 20]"
|
||||||
|
sortMode="multiple"
|
||||||
|
removableSort
|
||||||
|
filterDisplay="row"
|
||||||
|
:globalFilterFields="['name', 'goal', 'status']"
|
||||||
|
v-model:filters="filters"
|
||||||
|
:filters="filters"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<div class="flex justify-content-between">
|
||||||
|
<span class="p-input-icon-left">
|
||||||
|
<i class="pi pi-search" />
|
||||||
|
<InputText v-model="filters['global'].value" placeholder="Поиск кампаний..." />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<Column field="name" header="Название" :sortable="true">
|
||||||
|
<template #body="slotProps">
|
||||||
|
<span class="font-semibold">{{ slotProps.data.name }}</span>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<Column field="goal" header="Цель" :sortable="true">
|
||||||
|
<template #body="slotProps">
|
||||||
|
<span>{{ slotProps.data.goal }}</span>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<Column field="budget" header="Бюджет" :sortable="true">
|
||||||
|
<template #body="slotProps">
|
||||||
|
<span class="font-semibold text-green-600">
|
||||||
|
{{ formatCurrency(slotProps.data.budget) }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<Column field="startAt" header="Начало" :sortable="true">
|
||||||
|
<template #body="slotProps">
|
||||||
|
<span>{{ formatDate(slotProps.data.startAt) }}</span>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<Column field="endAt" header="Окончание" :sortable="true">
|
||||||
|
<template #body="slotProps">
|
||||||
|
<span>{{ formatDate(slotProps.data.endAt) }}</span>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<Column field="status" header="Статус" :sortable="true">
|
||||||
|
<template #body="slotProps">
|
||||||
|
<Tag :value="getStatusLabel(slotProps.data.status)" :severity="getStatusSeverity(slotProps.data.status)" />
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<Column header="Действия" :exportable="false">
|
||||||
|
<template #body="slotProps">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Button icon="pi pi-pencil" class="p-button-rounded p-button-text p-button-sm" @click="editCampaign(slotProps.data)" v-tooltip.top="'Редактировать'" />
|
||||||
|
<Button icon="pi pi-trash" class="p-button-rounded p-button-text p-button-danger p-button-sm" @click="confirmDelete(slotProps.data)" v-tooltip.top="'Удалить'" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
</DataTable>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Диалог создания/редактирования кампании -->
|
||||||
|
<Dialog v-model:visible="showCreateDialog" :header="editingCampaign ? 'Редактировать кампанию' : 'Создать кампанию'" :modal="true" :style="{ width: '50vw' }" :closable="false">
|
||||||
|
<div class="p-fluid">
|
||||||
|
<div class="field">
|
||||||
|
<label for="name">Название кампании *</label>
|
||||||
|
<InputText id="name" v-model="campaignForm.name" :class="{ 'p-invalid': !campaignForm.name }" />
|
||||||
|
<small v-if="!campaignForm.name" class="p-error">Название обязательно</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="goal">Цель кампании</label>
|
||||||
|
<Textarea id="goal" v-model="campaignForm.goal" rows="3" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="budget">Бюджет</label>
|
||||||
|
<InputNumber id="budget" v-model="campaignForm.budget" mode="currency" currency="KZT" locale="ru-RU" :min="0" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid">
|
||||||
|
<div class="col-6">
|
||||||
|
<div class="field">
|
||||||
|
<label for="startAt">Дата начала</label>
|
||||||
|
<Calendar id="startAt" v-model="campaignForm.startAt" dateFormat="yy-mm-dd" showTime hourFormat="24" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
<div class="field">
|
||||||
|
<label for="endAt">Дата окончания</label>
|
||||||
|
<Calendar id="endAt" v-model="campaignForm.endAt" dateFormat="yy-mm-dd" showTime hourFormat="24" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="status">Статус</label>
|
||||||
|
<Dropdown id="status" v-model="campaignForm.status" :options="statusOptions" optionLabel="label" optionValue="value" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<Button label="Отмена" icon="pi pi-times" @click="cancelEdit" class="p-button-text" />
|
||||||
|
<Button :label="editingCampaign ? 'Обновить' : 'Создать'" icon="pi pi-check" @click="saveCampaign" :loading="saving" />
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<!-- Диалог подтверждения удаления -->
|
||||||
|
<Dialog v-model:visible="showDeleteDialog" header="Подтверждение удаления" :modal="true" :style="{ width: '25vw' }">
|
||||||
|
<p>Вы уверены, что хотите удалить кампанию "{{ campaignToDelete?.name }}"?</p>
|
||||||
|
<template #footer>
|
||||||
|
<Button label="Отмена" icon="pi pi-times" @click="showDeleteDialog = false" class="p-button-text" />
|
||||||
|
<Button label="Удалить" icon="pi pi-trash" @click="deleteCampaign" class="p-button-danger" :loading="deleting" />
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import CampaignService from '@/service/CampaignService.js';
|
||||||
|
import { CampaignStatus } from '@/types/smm.js';
|
||||||
|
import { useToast } from 'primevue/usetoast';
|
||||||
|
import { onMounted, reactive, ref } from 'vue';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'CampaignList',
|
||||||
|
setup() {
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
|
// Reactive data
|
||||||
|
const campaigns = ref([]);
|
||||||
|
const loading = ref(false);
|
||||||
|
const saving = ref(false);
|
||||||
|
const deleting = ref(false);
|
||||||
|
const showCreateDialog = ref(false);
|
||||||
|
const showDeleteDialog = ref(false);
|
||||||
|
const editingCampaign = ref(null);
|
||||||
|
const campaignToDelete = ref(null);
|
||||||
|
|
||||||
|
const filters = ref({
|
||||||
|
global: { value: null }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Campaign form
|
||||||
|
const campaignForm = reactive({
|
||||||
|
name: '',
|
||||||
|
goal: '',
|
||||||
|
budget: 0,
|
||||||
|
startAt: null,
|
||||||
|
endAt: null,
|
||||||
|
status: CampaignStatus.PLANNED
|
||||||
|
});
|
||||||
|
|
||||||
|
// Status options
|
||||||
|
const statusOptions = [
|
||||||
|
{ label: 'Запланирована', value: CampaignStatus.PLANNED },
|
||||||
|
{ label: 'Активна', value: CampaignStatus.ACTIVE },
|
||||||
|
{ label: 'Завершена', value: CampaignStatus.COMPLETED }
|
||||||
|
];
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
const loadCampaigns = async () => {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
campaigns.value = await CampaignService.getAllCampaigns();
|
||||||
|
} catch (error) {
|
||||||
|
toast.add({
|
||||||
|
severity: 'error',
|
||||||
|
summary: 'Ошибка',
|
||||||
|
detail: 'Не удалось загрузить кампании: ' + error.message,
|
||||||
|
life: 3000
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const editCampaign = (campaign) => {
|
||||||
|
editingCampaign.value = campaign;
|
||||||
|
Object.assign(campaignForm, {
|
||||||
|
name: campaign.name,
|
||||||
|
goal: campaign.goal,
|
||||||
|
budget: campaign.budget,
|
||||||
|
startAt: campaign.startAt ? new Date(campaign.startAt) : null,
|
||||||
|
endAt: campaign.endAt ? new Date(campaign.endAt) : null,
|
||||||
|
status: campaign.status
|
||||||
|
});
|
||||||
|
showCreateDialog.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveCampaign = async () => {
|
||||||
|
if (!campaignForm.name) {
|
||||||
|
toast.add({
|
||||||
|
severity: 'error',
|
||||||
|
summary: 'Ошибка валидации',
|
||||||
|
detail: 'Название кампании обязательно',
|
||||||
|
life: 3000
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
saving.value = true;
|
||||||
|
try {
|
||||||
|
const data = {
|
||||||
|
...campaignForm,
|
||||||
|
startAt: campaignForm.startAt ? campaignForm.startAt.toISOString() : null,
|
||||||
|
endAt: campaignForm.endAt ? campaignForm.endAt.toISOString() : null
|
||||||
|
};
|
||||||
|
|
||||||
|
if (editingCampaign.value) {
|
||||||
|
await CampaignService.updateCampaign(editingCampaign.value.id, data);
|
||||||
|
toast.add({
|
||||||
|
severity: 'success',
|
||||||
|
summary: 'Успех',
|
||||||
|
detail: 'Кампания обновлена',
|
||||||
|
life: 3000
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await CampaignService.createCampaign(data);
|
||||||
|
toast.add({
|
||||||
|
severity: 'success',
|
||||||
|
summary: 'Успех',
|
||||||
|
detail: 'Кампания создана',
|
||||||
|
life: 3000
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadCampaigns();
|
||||||
|
cancelEdit();
|
||||||
|
} catch (error) {
|
||||||
|
toast.add({
|
||||||
|
severity: 'error',
|
||||||
|
summary: 'Ошибка',
|
||||||
|
detail: 'Не удалось сохранить кампанию: ' + error.message,
|
||||||
|
life: 3000
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelEdit = () => {
|
||||||
|
editingCampaign.value = null;
|
||||||
|
showCreateDialog.value = false;
|
||||||
|
Object.assign(campaignForm, {
|
||||||
|
name: '',
|
||||||
|
goal: '',
|
||||||
|
budget: 0,
|
||||||
|
startAt: null,
|
||||||
|
endAt: null,
|
||||||
|
status: CampaignStatus.PLANNED
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDelete = (campaign) => {
|
||||||
|
campaignToDelete.value = campaign;
|
||||||
|
showDeleteDialog.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteCampaign = async () => {
|
||||||
|
deleting.value = true;
|
||||||
|
try {
|
||||||
|
await CampaignService.deleteCampaign(campaignToDelete.value.id);
|
||||||
|
toast.add({
|
||||||
|
severity: 'success',
|
||||||
|
summary: 'Успех',
|
||||||
|
detail: 'Кампания удалена',
|
||||||
|
life: 3000
|
||||||
|
});
|
||||||
|
await loadCampaigns();
|
||||||
|
showDeleteDialog.value = false;
|
||||||
|
campaignToDelete.value = null;
|
||||||
|
} catch (error) {
|
||||||
|
toast.add({
|
||||||
|
severity: 'error',
|
||||||
|
summary: 'Ошибка',
|
||||||
|
detail: 'Не удалось удалить кампанию: ' + error.message,
|
||||||
|
life: 3000
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
deleting.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatCurrency = (value) => {
|
||||||
|
return new Intl.NumberFormat('ru-RU', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'KZT'
|
||||||
|
}).format(value || 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateString) => {
|
||||||
|
if (!dateString) return '-';
|
||||||
|
return new Date(dateString).toLocaleDateString('ru-RU', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusLabel = (status) => {
|
||||||
|
const labels = {
|
||||||
|
[CampaignStatus.PLANNED]: 'Запланирована',
|
||||||
|
[CampaignStatus.ACTIVE]: 'Активна',
|
||||||
|
[CampaignStatus.COMPLETED]: 'Завершена'
|
||||||
|
};
|
||||||
|
return labels[status] || status;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusSeverity = (status) => {
|
||||||
|
const severities = {
|
||||||
|
[CampaignStatus.PLANNED]: 'info',
|
||||||
|
[CampaignStatus.ACTIVE]: 'success',
|
||||||
|
[CampaignStatus.COMPLETED]: 'secondary'
|
||||||
|
};
|
||||||
|
return severities[status] || 'info';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Lifecycle
|
||||||
|
onMounted(() => {
|
||||||
|
loadCampaigns();
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
campaigns,
|
||||||
|
loading,
|
||||||
|
saving,
|
||||||
|
deleting,
|
||||||
|
showCreateDialog,
|
||||||
|
showDeleteDialog,
|
||||||
|
editingCampaign,
|
||||||
|
campaignToDelete,
|
||||||
|
filters,
|
||||||
|
campaignForm,
|
||||||
|
statusOptions,
|
||||||
|
loadCampaigns,
|
||||||
|
editCampaign,
|
||||||
|
saveCampaign,
|
||||||
|
cancelEdit,
|
||||||
|
confirmDelete,
|
||||||
|
deleteCampaign,
|
||||||
|
formatCurrency,
|
||||||
|
formatDate,
|
||||||
|
getStatusLabel,
|
||||||
|
getStatusSeverity
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.card {
|
||||||
|
background: var(--surface-card);
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow:
|
||||||
|
0 1px 3px rgba(0, 0, 0, 0.12),
|
||||||
|
0 1px 2px rgba(0, 0, 0, 0.24);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-bottom: 1px solid var(--surface-border);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-content {
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.p-error {
|
||||||
|
color: var(--red-500);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,347 @@
|
|||||||
|
<template>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5>Управление каналами</h5>
|
||||||
|
<Button icon="pi pi-plus" label="Создать канал" @click="showCreateDialog = true" class="p-button-success" />
|
||||||
|
</div>
|
||||||
|
<div class="card-content">
|
||||||
|
<DataTable
|
||||||
|
:value="channels"
|
||||||
|
:loading="loading"
|
||||||
|
paginator
|
||||||
|
:rows="10"
|
||||||
|
:rowsPerPageOptions="[5, 10, 20]"
|
||||||
|
sortMode="multiple"
|
||||||
|
removableSort
|
||||||
|
filterDisplay="row"
|
||||||
|
:globalFilterFields="['name', 'type']"
|
||||||
|
v-model:filters="filters"
|
||||||
|
:filters="filters"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<div class="flex justify-content-between">
|
||||||
|
<span class="p-input-icon-left">
|
||||||
|
<i class="pi pi-search" />
|
||||||
|
<InputText v-model="filters['global'].value" placeholder="Поиск каналов..." />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<Column field="name" header="Название" :sortable="true">
|
||||||
|
<template #body="slotProps">
|
||||||
|
<span class="font-semibold">{{ slotProps.data.name }}</span>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<Column field="type" header="Тип" :sortable="true">
|
||||||
|
<template #body="slotProps">
|
||||||
|
<Tag :value="getTypeLabel(slotProps.data.type)" :severity="getTypeSeverity(slotProps.data.type)" />
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<Column field="apiKeyRef" header="API Ключ" :sortable="true">
|
||||||
|
<template #body="slotProps">
|
||||||
|
<span class="font-mono text-sm">{{ slotProps.data.apiKeyRef }}</span>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<Column field="isActive" header="Статус" :sortable="true">
|
||||||
|
<template #body="slotProps">
|
||||||
|
<Tag :value="slotProps.data.isActive ? 'Активен' : 'Неактивен'" :severity="slotProps.data.isActive ? 'success' : 'danger'" />
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<Column header="Действия" :exportable="false">
|
||||||
|
<template #body="slotProps">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Button icon="pi pi-pencil" class="p-button-rounded p-button-text p-button-sm" @click="editChannel(slotProps.data)" v-tooltip.top="'Редактировать'" />
|
||||||
|
<Button icon="pi pi-trash" class="p-button-rounded p-button-text p-button-danger p-button-sm" @click="confirmDelete(slotProps.data)" v-tooltip.top="'Удалить'" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
</DataTable>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Диалог создания/редактирования канала -->
|
||||||
|
<Dialog v-model:visible="showCreateDialog" :header="editingChannel ? 'Редактировать канал' : 'Создать канал'" :modal="true" :style="{ width: '50vw' }" :closable="false">
|
||||||
|
<div class="p-fluid">
|
||||||
|
<div class="field">
|
||||||
|
<label for="name">Название канала *</label>
|
||||||
|
<InputText id="name" v-model="channelForm.name" :class="{ 'p-invalid': !channelForm.name }" />
|
||||||
|
<small v-if="!channelForm.name" class="p-error">Название обязательно</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="type">Тип канала *</label>
|
||||||
|
<Dropdown id="type" v-model="channelForm.type" :options="typeOptions" optionLabel="label" optionValue="value" :class="{ 'p-invalid': !channelForm.type }" />
|
||||||
|
<small v-if="!channelForm.type" class="p-error">Тип канала обязателен</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="apiKeyRef">API Ключ *</label>
|
||||||
|
<InputText id="apiKeyRef" v-model="channelForm.apiKeyRef" :class="{ 'p-invalid': !channelForm.apiKeyRef }" />
|
||||||
|
<small v-if="!channelForm.apiKeyRef" class="p-error">API ключ обязателен</small>
|
||||||
|
<small class="text-600">Ссылка на API ключ в системе</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<div class="flex align-items-center">
|
||||||
|
<Checkbox id="isActive" v-model="channelForm.isActive" :binary="true" />
|
||||||
|
<label for="isActive" class="ml-2">Канал активен</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<Button label="Отмена" icon="pi pi-times" @click="cancelEdit" class="p-button-text" />
|
||||||
|
<Button :label="editingChannel ? 'Обновить' : 'Создать'" icon="pi pi-check" @click="saveChannel" :loading="saving" />
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<!-- Диалог подтверждения удаления -->
|
||||||
|
<Dialog v-model:visible="showDeleteDialog" header="Подтверждение удаления" :modal="true" :style="{ width: '25vw' }">
|
||||||
|
<p>Вы уверены, что хотите удалить канал "{{ channelToDelete?.name }}"?</p>
|
||||||
|
<template #footer>
|
||||||
|
<Button label="Отмена" icon="pi pi-times" @click="showDeleteDialog = false" class="p-button-text" />
|
||||||
|
<Button label="Удалить" icon="pi pi-trash" @click="deleteChannel" class="p-button-danger" :loading="deleting" />
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import ChannelService from '@/service/ChannelService.js';
|
||||||
|
import { ChannelType } from '@/types/smm.js';
|
||||||
|
import { useToast } from 'primevue/usetoast';
|
||||||
|
import { onMounted, reactive, ref } from 'vue';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'ChannelList',
|
||||||
|
setup() {
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
|
// Reactive data
|
||||||
|
const channels = ref([]);
|
||||||
|
const loading = ref(false);
|
||||||
|
const saving = ref(false);
|
||||||
|
const deleting = ref(false);
|
||||||
|
const showCreateDialog = ref(false);
|
||||||
|
const showDeleteDialog = ref(false);
|
||||||
|
const editingChannel = ref(null);
|
||||||
|
const channelToDelete = ref(null);
|
||||||
|
|
||||||
|
const filters = ref({
|
||||||
|
global: { value: null }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Channel form
|
||||||
|
const channelForm = reactive({
|
||||||
|
name: '',
|
||||||
|
type: '',
|
||||||
|
apiKeyRef: '',
|
||||||
|
isActive: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// Type options
|
||||||
|
const typeOptions = [
|
||||||
|
{ label: 'Telegram', value: ChannelType.TELEGRAM },
|
||||||
|
{ label: 'VK', value: ChannelType.VK },
|
||||||
|
{ label: 'Instagram', value: ChannelType.INSTAGRAM }
|
||||||
|
];
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
const loadChannels = async () => {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
channels.value = await ChannelService.getAllChannels();
|
||||||
|
} catch (error) {
|
||||||
|
toast.add({
|
||||||
|
severity: 'error',
|
||||||
|
summary: 'Ошибка',
|
||||||
|
detail: 'Не удалось загрузить каналы: ' + error.message,
|
||||||
|
life: 3000
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const editChannel = (channel) => {
|
||||||
|
editingChannel.value = channel;
|
||||||
|
Object.assign(channelForm, {
|
||||||
|
name: channel.name,
|
||||||
|
type: channel.type,
|
||||||
|
apiKeyRef: channel.apiKeyRef,
|
||||||
|
isActive: channel.isActive
|
||||||
|
});
|
||||||
|
showCreateDialog.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveChannel = async () => {
|
||||||
|
if (!channelForm.name || !channelForm.type || !channelForm.apiKeyRef) {
|
||||||
|
toast.add({
|
||||||
|
severity: 'error',
|
||||||
|
summary: 'Ошибка валидации',
|
||||||
|
detail: 'Все обязательные поля должны быть заполнены',
|
||||||
|
life: 3000
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
saving.value = true;
|
||||||
|
try {
|
||||||
|
if (editingChannel.value) {
|
||||||
|
await ChannelService.updateChannel(editingChannel.value.id, channelForm);
|
||||||
|
toast.add({
|
||||||
|
severity: 'success',
|
||||||
|
summary: 'Успех',
|
||||||
|
detail: 'Канал обновлен',
|
||||||
|
life: 3000
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await ChannelService.createChannel(channelForm);
|
||||||
|
toast.add({
|
||||||
|
severity: 'success',
|
||||||
|
summary: 'Успех',
|
||||||
|
detail: 'Канал создан',
|
||||||
|
life: 3000
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadChannels();
|
||||||
|
cancelEdit();
|
||||||
|
} catch (error) {
|
||||||
|
toast.add({
|
||||||
|
severity: 'error',
|
||||||
|
summary: 'Ошибка',
|
||||||
|
detail: 'Не удалось сохранить канал: ' + error.message,
|
||||||
|
life: 3000
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelEdit = () => {
|
||||||
|
editingChannel.value = null;
|
||||||
|
showCreateDialog.value = false;
|
||||||
|
Object.assign(channelForm, {
|
||||||
|
name: '',
|
||||||
|
type: '',
|
||||||
|
apiKeyRef: '',
|
||||||
|
isActive: true
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDelete = (channel) => {
|
||||||
|
channelToDelete.value = channel;
|
||||||
|
showDeleteDialog.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteChannel = async () => {
|
||||||
|
deleting.value = true;
|
||||||
|
try {
|
||||||
|
await ChannelService.deleteChannel(channelToDelete.value.id);
|
||||||
|
toast.add({
|
||||||
|
severity: 'success',
|
||||||
|
summary: 'Успех',
|
||||||
|
detail: 'Канал удален',
|
||||||
|
life: 3000
|
||||||
|
});
|
||||||
|
await loadChannels();
|
||||||
|
showDeleteDialog.value = false;
|
||||||
|
channelToDelete.value = null;
|
||||||
|
} catch (error) {
|
||||||
|
toast.add({
|
||||||
|
severity: 'error',
|
||||||
|
summary: 'Ошибка',
|
||||||
|
detail: 'Не удалось удалить канал: ' + error.message,
|
||||||
|
life: 3000
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
deleting.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTypeLabel = (type) => {
|
||||||
|
const labels = {
|
||||||
|
[ChannelType.TELEGRAM]: 'Telegram',
|
||||||
|
[ChannelType.VK]: 'VK',
|
||||||
|
[ChannelType.INSTAGRAM]: 'Instagram'
|
||||||
|
};
|
||||||
|
return labels[type] || type;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTypeSeverity = (type) => {
|
||||||
|
const severities = {
|
||||||
|
[ChannelType.TELEGRAM]: 'info',
|
||||||
|
[ChannelType.VK]: 'warning',
|
||||||
|
[ChannelType.INSTAGRAM]: 'success'
|
||||||
|
};
|
||||||
|
return severities[type] || 'info';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Lifecycle
|
||||||
|
onMounted(() => {
|
||||||
|
loadChannels();
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
channels,
|
||||||
|
loading,
|
||||||
|
saving,
|
||||||
|
deleting,
|
||||||
|
showCreateDialog,
|
||||||
|
showDeleteDialog,
|
||||||
|
editingChannel,
|
||||||
|
channelToDelete,
|
||||||
|
filters,
|
||||||
|
channelForm,
|
||||||
|
typeOptions,
|
||||||
|
loadChannels,
|
||||||
|
editChannel,
|
||||||
|
saveChannel,
|
||||||
|
cancelEdit,
|
||||||
|
confirmDelete,
|
||||||
|
deleteChannel,
|
||||||
|
getTypeLabel,
|
||||||
|
getTypeSeverity
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.card {
|
||||||
|
background: var(--surface-card);
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow:
|
||||||
|
0 1px 3px rgba(0, 0, 0, 0.12),
|
||||||
|
0 1px 2px rgba(0, 0, 0, 0.24);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-bottom: 1px solid var(--surface-border);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-content {
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.p-error {
|
||||||
|
color: var(--red-500);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-600 {
|
||||||
|
color: var(--text-color-secondary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,552 @@
|
|||||||
|
<template>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5>Управление контентом</h5>
|
||||||
|
<Button icon="pi pi-plus" label="Создать контент" @click="showCreateDialog = true" class="p-button-success" />
|
||||||
|
</div>
|
||||||
|
<div class="card-content">
|
||||||
|
<DataTable
|
||||||
|
:value="content"
|
||||||
|
:loading="loading"
|
||||||
|
paginator
|
||||||
|
:rows="10"
|
||||||
|
:rowsPerPageOptions="[5, 10, 20]"
|
||||||
|
sortMode="multiple"
|
||||||
|
removableSort
|
||||||
|
filterDisplay="row"
|
||||||
|
:globalFilterFields="['topic', 'locale', 'status']"
|
||||||
|
v-model:filters="filters"
|
||||||
|
:filters="filters"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<div class="flex justify-content-between">
|
||||||
|
<span class="p-input-icon-left">
|
||||||
|
<i class="pi pi-search" />
|
||||||
|
<InputText v-model="filters['global'].value" placeholder="Поиск контента..." />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<Column field="topic" header="Тема" :sortable="true">
|
||||||
|
<template #body="slotProps">
|
||||||
|
<span class="font-semibold">{{ slotProps.data.topic }}</span>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<Column field="locale" header="Локаль" :sortable="true">
|
||||||
|
<template #body="slotProps">
|
||||||
|
<Tag :value="slotProps.data.locale" severity="info" />
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<Column field="campaignId" header="Кампания" :sortable="true">
|
||||||
|
<template #body="slotProps">
|
||||||
|
<span>{{ getCampaignName(slotProps.data.campaignId) }}</span>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<Column field="postDraft" header="Черновик" :sortable="true">
|
||||||
|
<template #body="slotProps">
|
||||||
|
<span class="text-sm">{{ truncateText(slotProps.data.postDraft, 50) }}</span>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<Column field="scheduledAt" header="Запланировано" :sortable="true">
|
||||||
|
<template #body="slotProps">
|
||||||
|
<span>{{ formatDate(slotProps.data.scheduledAt) }}</span>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<Column field="priority" header="Приоритет" :sortable="true">
|
||||||
|
<template #body="slotProps">
|
||||||
|
<Tag :value="slotProps.data.priority" :severity="getPrioritySeverity(slotProps.data.priority)" />
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<Column field="status" header="Статус" :sortable="true">
|
||||||
|
<template #body="slotProps">
|
||||||
|
<Tag :value="getStatusLabel(slotProps.data.status)" :severity="getStatusSeverity(slotProps.data.status)" />
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<Column header="Действия" :exportable="false">
|
||||||
|
<template #body="slotProps">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Button icon="pi pi-eye" class="p-button-rounded p-button-text p-button-sm" @click="viewContent(slotProps.data)" v-tooltip.top="'Просмотр'" />
|
||||||
|
<Button icon="pi pi-pencil" class="p-button-rounded p-button-text p-button-sm" @click="editContent(slotProps.data)" v-tooltip.top="'Редактировать'" />
|
||||||
|
<Button v-if="slotProps.data.status === 'PENDING_APPROVAL'" icon="pi pi-check" class="p-button-rounded p-button-text p-button-success p-button-sm" @click="approveContent(slotProps.data)" v-tooltip.top="'Одобрить'" />
|
||||||
|
<Button v-if="slotProps.data.status === 'APPROVED'" icon="pi pi-send" class="p-button-rounded p-button-text p-button-info p-button-sm" @click="publishContent(slotProps.data)" v-tooltip.top="'Опубликовать'" />
|
||||||
|
<Button icon="pi pi-trash" class="p-button-rounded p-button-text p-button-danger p-button-sm" @click="confirmDelete(slotProps.data)" v-tooltip.top="'Удалить'" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
</DataTable>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Диалог создания/редактирования контента -->
|
||||||
|
<Dialog v-model:visible="showCreateDialog" :header="editingContent ? 'Редактировать контент' : 'Создать контент'" :modal="true" :style="{ width: '70vw' }" :closable="false">
|
||||||
|
<div class="p-fluid">
|
||||||
|
<div class="grid">
|
||||||
|
<div class="col-6">
|
||||||
|
<div class="field">
|
||||||
|
<label for="campaignId">Кампания *</label>
|
||||||
|
<Dropdown id="campaignId" v-model="contentForm.campaignId" :options="campaigns" optionLabel="name" optionValue="id" :class="{ 'p-invalid': !contentForm.campaignId }" />
|
||||||
|
<small v-if="!contentForm.campaignId" class="p-error">Кампания обязательна</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
<div class="field">
|
||||||
|
<label for="locale">Локаль *</label>
|
||||||
|
<InputText id="locale" v-model="contentForm.locale" :class="{ 'p-invalid': !contentForm.locale }" />
|
||||||
|
<small v-if="!contentForm.locale" class="p-error">Локаль обязательна</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="topic">Тема *</label>
|
||||||
|
<InputText id="topic" v-model="contentForm.topic" :class="{ 'p-invalid': !contentForm.topic }" />
|
||||||
|
<small v-if="!contentForm.topic" class="p-error">Тема обязательна</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="postDraft">Черновик поста</label>
|
||||||
|
<Textarea id="postDraft" v-model="contentForm.postDraft" rows="4" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="assetsRefs">Ссылки на ресурсы</label>
|
||||||
|
<InputText id="assetsRefs" v-model="contentForm.assetsRefs" />
|
||||||
|
<small class="text-600">Разделите ссылки запятыми</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid">
|
||||||
|
<div class="col-6">
|
||||||
|
<div class="field">
|
||||||
|
<label for="scheduledAt">Время публикации</label>
|
||||||
|
<Calendar id="scheduledAt" v-model="contentForm.scheduledAt" dateFormat="yy-mm-dd" showTime hourFormat="24" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
<div class="field">
|
||||||
|
<label for="priority">Приоритет</label>
|
||||||
|
<InputNumber id="priority" v-model="contentForm.priority" :min="1" :max="10" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<Button label="Отмена" icon="pi pi-times" @click="cancelEdit" class="p-button-text" />
|
||||||
|
<Button :label="editingContent ? 'Обновить' : 'Создать'" icon="pi pi-check" @click="saveContent" :loading="saving" />
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<!-- Диалог просмотра контента -->
|
||||||
|
<Dialog v-model:visible="showViewDialog" header="Просмотр контента" :modal="true" :style="{ width: '60vw' }">
|
||||||
|
<div v-if="viewingContent" class="content-view">
|
||||||
|
<div class="field">
|
||||||
|
<label>Тема:</label>
|
||||||
|
<p class="font-semibold">{{ viewingContent.topic }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Локаль:</label>
|
||||||
|
<p>{{ viewingContent.locale }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Кампания:</label>
|
||||||
|
<p>{{ getCampaignName(viewingContent.campaignId) }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Черновик поста:</label>
|
||||||
|
<p class="whitespace-pre-wrap">{{ viewingContent.postDraft }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Ссылки на ресурсы:</label>
|
||||||
|
<p>{{ viewingContent.assetsRefs }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Время публикации:</label>
|
||||||
|
<p>{{ formatDate(viewingContent.scheduledAt) }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Приоритет:</label>
|
||||||
|
<p>{{ viewingContent.priority }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Статус:</label>
|
||||||
|
<Tag :value="getStatusLabel(viewingContent.status)" :severity="getStatusSeverity(viewingContent.status)" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<!-- Диалог подтверждения удаления -->
|
||||||
|
<Dialog v-model:visible="showDeleteDialog" header="Подтверждение удаления" :modal="true" :style="{ width: '25vw' }">
|
||||||
|
<p>Вы уверены, что хотите удалить контент "{{ contentToDelete?.topic }}"?</p>
|
||||||
|
<template #footer>
|
||||||
|
<Button label="Отмена" icon="pi pi-times" @click="showDeleteDialog = false" class="p-button-text" />
|
||||||
|
<Button label="Удалить" icon="pi pi-trash" @click="deleteContent" class="p-button-danger" :loading="deleting" />
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import CampaignService from '@/service/CampaignService.js';
|
||||||
|
import ContentService from '@/service/ContentService.js';
|
||||||
|
import PublishingService from '@/service/PublishingService.js';
|
||||||
|
import { ContentStatus } from '@/types/smm.js';
|
||||||
|
import { useToast } from 'primevue/usetoast';
|
||||||
|
import { onMounted, reactive, ref } from 'vue';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'ContentList',
|
||||||
|
setup() {
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
|
// Reactive data
|
||||||
|
const content = ref([]);
|
||||||
|
const campaigns = ref([]);
|
||||||
|
const loading = ref(false);
|
||||||
|
const saving = ref(false);
|
||||||
|
const deleting = ref(false);
|
||||||
|
const showCreateDialog = ref(false);
|
||||||
|
const showViewDialog = ref(false);
|
||||||
|
const showDeleteDialog = ref(false);
|
||||||
|
const editingContent = ref(null);
|
||||||
|
const viewingContent = ref(null);
|
||||||
|
const contentToDelete = ref(null);
|
||||||
|
|
||||||
|
const filters = ref({
|
||||||
|
global: { value: null }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Content form
|
||||||
|
const contentForm = reactive({
|
||||||
|
campaignId: '',
|
||||||
|
locale: '',
|
||||||
|
topic: '',
|
||||||
|
postDraft: '',
|
||||||
|
assetsRefs: '',
|
||||||
|
scheduledAt: null,
|
||||||
|
priority: 1
|
||||||
|
});
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
const loadContent = async () => {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
content.value = await ContentService.getAllContent();
|
||||||
|
} catch (error) {
|
||||||
|
toast.add({
|
||||||
|
severity: 'error',
|
||||||
|
summary: 'Ошибка',
|
||||||
|
detail: 'Не удалось загрузить контент: ' + error.message,
|
||||||
|
life: 3000
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadCampaigns = async () => {
|
||||||
|
try {
|
||||||
|
campaigns.value = await CampaignService.getAllCampaigns();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading campaigns:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const viewContent = (contentItem) => {
|
||||||
|
viewingContent.value = contentItem;
|
||||||
|
showViewDialog.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const editContent = (contentItem) => {
|
||||||
|
editingContent.value = contentItem;
|
||||||
|
Object.assign(contentForm, {
|
||||||
|
campaignId: contentItem.campaignId,
|
||||||
|
locale: contentItem.locale,
|
||||||
|
topic: contentItem.topic,
|
||||||
|
postDraft: contentItem.postDraft,
|
||||||
|
assetsRefs: contentItem.assetsRefs,
|
||||||
|
scheduledAt: contentItem.scheduledAt ? new Date(contentItem.scheduledAt) : null,
|
||||||
|
priority: contentItem.priority
|
||||||
|
});
|
||||||
|
showCreateDialog.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveContent = async () => {
|
||||||
|
if (!contentForm.campaignId || !contentForm.locale || !contentForm.topic) {
|
||||||
|
toast.add({
|
||||||
|
severity: 'error',
|
||||||
|
summary: 'Ошибка валидации',
|
||||||
|
detail: 'Все обязательные поля должны быть заполнены',
|
||||||
|
life: 3000
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
saving.value = true;
|
||||||
|
try {
|
||||||
|
const data = {
|
||||||
|
...contentForm,
|
||||||
|
scheduledAt: contentForm.scheduledAt ? contentForm.scheduledAt.toISOString() : null
|
||||||
|
};
|
||||||
|
|
||||||
|
if (editingContent.value) {
|
||||||
|
await ContentService.updateContent(editingContent.value.id, data);
|
||||||
|
toast.add({
|
||||||
|
severity: 'success',
|
||||||
|
summary: 'Успех',
|
||||||
|
detail: 'Контент обновлен',
|
||||||
|
life: 3000
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await ContentService.createContent(data);
|
||||||
|
toast.add({
|
||||||
|
severity: 'success',
|
||||||
|
summary: 'Успех',
|
||||||
|
detail: 'Контент создан',
|
||||||
|
life: 3000
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadContent();
|
||||||
|
cancelEdit();
|
||||||
|
} catch (error) {
|
||||||
|
toast.add({
|
||||||
|
severity: 'error',
|
||||||
|
summary: 'Ошибка',
|
||||||
|
detail: 'Не удалось сохранить контент: ' + error.message,
|
||||||
|
life: 3000
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const approveContent = async (contentItem) => {
|
||||||
|
try {
|
||||||
|
await ContentService.approveContent(contentItem.id);
|
||||||
|
toast.add({
|
||||||
|
severity: 'success',
|
||||||
|
summary: 'Успех',
|
||||||
|
detail: 'Контент одобрен',
|
||||||
|
life: 3000
|
||||||
|
});
|
||||||
|
await loadContent();
|
||||||
|
} catch (error) {
|
||||||
|
toast.add({
|
||||||
|
severity: 'error',
|
||||||
|
summary: 'Ошибка',
|
||||||
|
detail: 'Не удалось одобрить контент: ' + error.message,
|
||||||
|
life: 3000
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const publishContent = async (contentItem) => {
|
||||||
|
try {
|
||||||
|
await PublishingService.publishContent(contentItem.id);
|
||||||
|
toast.add({
|
||||||
|
severity: 'success',
|
||||||
|
summary: 'Успех',
|
||||||
|
detail: 'Контент опубликован',
|
||||||
|
life: 3000
|
||||||
|
});
|
||||||
|
await loadContent();
|
||||||
|
} catch (error) {
|
||||||
|
toast.add({
|
||||||
|
severity: 'error',
|
||||||
|
summary: 'Ошибка',
|
||||||
|
detail: 'Не удалось опубликовать контент: ' + error.message,
|
||||||
|
life: 3000
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelEdit = () => {
|
||||||
|
editingContent.value = null;
|
||||||
|
showCreateDialog.value = false;
|
||||||
|
Object.assign(contentForm, {
|
||||||
|
campaignId: '',
|
||||||
|
locale: '',
|
||||||
|
topic: '',
|
||||||
|
postDraft: '',
|
||||||
|
assetsRefs: '',
|
||||||
|
scheduledAt: null,
|
||||||
|
priority: 1
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDelete = (contentItem) => {
|
||||||
|
contentToDelete.value = contentItem;
|
||||||
|
showDeleteDialog.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteContent = async () => {
|
||||||
|
deleting.value = true;
|
||||||
|
try {
|
||||||
|
await ContentService.deleteContent(contentToDelete.value.id);
|
||||||
|
toast.add({
|
||||||
|
severity: 'success',
|
||||||
|
summary: 'Успех',
|
||||||
|
detail: 'Контент удален',
|
||||||
|
life: 3000
|
||||||
|
});
|
||||||
|
await loadContent();
|
||||||
|
showDeleteDialog.value = false;
|
||||||
|
contentToDelete.value = null;
|
||||||
|
} catch (error) {
|
||||||
|
toast.add({
|
||||||
|
severity: 'error',
|
||||||
|
summary: 'Ошибка',
|
||||||
|
detail: 'Не удалось удалить контент: ' + error.message,
|
||||||
|
life: 3000
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
deleting.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCampaignName = (campaignId) => {
|
||||||
|
const campaign = campaigns.value.find((c) => c.id === campaignId);
|
||||||
|
return campaign ? campaign.name : campaignId;
|
||||||
|
};
|
||||||
|
|
||||||
|
const truncateText = (text, length) => {
|
||||||
|
if (!text) return '';
|
||||||
|
return text.length > length ? text.substring(0, length) + '...' : text;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateString) => {
|
||||||
|
if (!dateString) return '-';
|
||||||
|
return new Date(dateString).toLocaleDateString('ru-RU', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusLabel = (status) => {
|
||||||
|
const labels = {
|
||||||
|
[ContentStatus.DRAFT]: 'Черновик',
|
||||||
|
[ContentStatus.PENDING_APPROVAL]: 'Ожидает одобрения',
|
||||||
|
[ContentStatus.APPROVED]: 'Одобрен',
|
||||||
|
[ContentStatus.PUBLISHED]: 'Опубликован',
|
||||||
|
[ContentStatus.FAILED]: 'Ошибка'
|
||||||
|
};
|
||||||
|
return labels[status] || status;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusSeverity = (status) => {
|
||||||
|
const severities = {
|
||||||
|
[ContentStatus.DRAFT]: 'info',
|
||||||
|
[ContentStatus.PENDING_APPROVAL]: 'warning',
|
||||||
|
[ContentStatus.APPROVED]: 'success',
|
||||||
|
[ContentStatus.PUBLISHED]: 'success',
|
||||||
|
[ContentStatus.FAILED]: 'danger'
|
||||||
|
};
|
||||||
|
return severities[status] || 'info';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPrioritySeverity = (priority) => {
|
||||||
|
if (priority >= 8) return 'danger';
|
||||||
|
if (priority >= 6) return 'warning';
|
||||||
|
if (priority >= 4) return 'info';
|
||||||
|
return 'success';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Lifecycle
|
||||||
|
onMounted(() => {
|
||||||
|
loadContent();
|
||||||
|
loadCampaigns();
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
content,
|
||||||
|
campaigns,
|
||||||
|
loading,
|
||||||
|
saving,
|
||||||
|
deleting,
|
||||||
|
showCreateDialog,
|
||||||
|
showViewDialog,
|
||||||
|
showDeleteDialog,
|
||||||
|
editingContent,
|
||||||
|
viewingContent,
|
||||||
|
contentToDelete,
|
||||||
|
filters,
|
||||||
|
contentForm,
|
||||||
|
loadContent,
|
||||||
|
loadCampaigns,
|
||||||
|
viewContent,
|
||||||
|
editContent,
|
||||||
|
saveContent,
|
||||||
|
approveContent,
|
||||||
|
publishContent,
|
||||||
|
cancelEdit,
|
||||||
|
confirmDelete,
|
||||||
|
deleteContent,
|
||||||
|
getCampaignName,
|
||||||
|
truncateText,
|
||||||
|
formatDate,
|
||||||
|
getStatusLabel,
|
||||||
|
getStatusSeverity,
|
||||||
|
getPrioritySeverity
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.card {
|
||||||
|
background: var(--surface-card);
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow:
|
||||||
|
0 1px 3px rgba(0, 0, 0, 0.12),
|
||||||
|
0 1px 2px rgba(0, 0, 0, 0.24);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-bottom: 1px solid var(--surface-border);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-content {
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.p-error {
|
||||||
|
color: var(--red-500);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-600 {
|
||||||
|
color: var(--text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-view .field {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-view label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-color-secondary);
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.whitespace-pre-wrap {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,428 @@
|
|||||||
|
<template>
|
||||||
|
<div class="smm-dashboard">
|
||||||
|
<div class="grid">
|
||||||
|
<!-- Заголовок -->
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h4>SMM Система Konturai</h4>
|
||||||
|
<p class="text-600">Управление социальными сетями и контентом</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Быстрые действия -->
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5>Быстрые действия</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-content">
|
||||||
|
<div class="grid">
|
||||||
|
<div class="col-12 md:col-3">
|
||||||
|
<Button label="Создать кампанию" icon="pi pi-plus" class="w-full p-button-success" @click="$router.push('/smm/campaigns')" />
|
||||||
|
</div>
|
||||||
|
<div class="col-12 md:col-3">
|
||||||
|
<Button label="Добавить канал" icon="pi pi-plus" class="w-full p-button-info" @click="$router.push('/smm/channels')" />
|
||||||
|
</div>
|
||||||
|
<div class="col-12 md:col-3">
|
||||||
|
<Button label="Создать контент" icon="pi pi-plus" class="w-full p-button-warning" @click="$router.push('/smm/content')" />
|
||||||
|
</div>
|
||||||
|
<div class="col-12 md:col-3">
|
||||||
|
<Button label="Просмотр аналитики" icon="pi pi-chart-line" class="w-full p-button-secondary" @click="$router.push('/smm/analytics')" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Статистика -->
|
||||||
|
<div class="col-12 md:col-6 lg:col-3">
|
||||||
|
<div class="card stat-card">
|
||||||
|
<div class="stat-content">
|
||||||
|
<div class="stat-icon">
|
||||||
|
<i class="pi pi-megaphone text-blue-500"></i>
|
||||||
|
</div>
|
||||||
|
<div class="stat-info">
|
||||||
|
<h6>Кампании</h6>
|
||||||
|
<h3>{{ stats.campaigns || 0 }}</h3>
|
||||||
|
<small class="text-600">{{ stats.activeCampaigns || 0 }} активных</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 md:col-6 lg:col-3">
|
||||||
|
<div class="card stat-card">
|
||||||
|
<div class="stat-content">
|
||||||
|
<div class="stat-icon">
|
||||||
|
<i class="pi pi-broadcast-tower text-green-500"></i>
|
||||||
|
</div>
|
||||||
|
<div class="stat-info">
|
||||||
|
<h6>Каналы</h6>
|
||||||
|
<h3>{{ stats.channels || 0 }}</h3>
|
||||||
|
<small class="text-600">{{ stats.activeChannels || 0 }} активных</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 md:col-6 lg:col-3">
|
||||||
|
<div class="card stat-card">
|
||||||
|
<div class="stat-content">
|
||||||
|
<div class="stat-icon">
|
||||||
|
<i class="pi pi-file-edit text-orange-500"></i>
|
||||||
|
</div>
|
||||||
|
<div class="stat-info">
|
||||||
|
<h6>Контент</h6>
|
||||||
|
<h3>{{ stats.content || 0 }}</h3>
|
||||||
|
<small class="text-600">{{ stats.pendingApproval || 0 }} ожидает одобрения</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 md:col-6 lg:col-3">
|
||||||
|
<div class="card stat-card">
|
||||||
|
<div class="stat-content">
|
||||||
|
<div class="stat-icon">
|
||||||
|
<i class="pi pi-send text-purple-500"></i>
|
||||||
|
</div>
|
||||||
|
<div class="stat-info">
|
||||||
|
<h6>Опубликовано</h6>
|
||||||
|
<h3>{{ stats.published || 0 }}</h3>
|
||||||
|
<small class="text-600">{{ stats.publishingRate?.toFixed(1) || 0 }}% успешность</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Последние кампании -->
|
||||||
|
<div class="col-12 lg:col-6">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h6>Последние кампании</h6>
|
||||||
|
<Button label="Все кампании" icon="pi pi-arrow-right" class="p-button-text p-button-sm" @click="$router.push('/smm/campaigns')" />
|
||||||
|
</div>
|
||||||
|
<div class="card-content">
|
||||||
|
<div v-if="loading" class="text-center">
|
||||||
|
<ProgressSpinner />
|
||||||
|
</div>
|
||||||
|
<div v-else-if="recentCampaigns.length === 0" class="text-center text-600">
|
||||||
|
<i class="pi pi-info-circle"></i>
|
||||||
|
<p>Нет кампаний</p>
|
||||||
|
</div>
|
||||||
|
<div v-else class="campaign-list">
|
||||||
|
<div v-for="campaign in recentCampaigns" :key="campaign.id" class="campaign-item">
|
||||||
|
<div class="campaign-info">
|
||||||
|
<h6>{{ campaign.name }}</h6>
|
||||||
|
<p class="text-600">{{ campaign.goal }}</p>
|
||||||
|
<small>{{ formatDate(campaign.startAt) }} - {{ formatDate(campaign.endAt) }}</small>
|
||||||
|
</div>
|
||||||
|
<Tag :value="getStatusLabel(campaign.status)" :severity="getStatusSeverity(campaign.status)" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Последний контент -->
|
||||||
|
<div class="col-12 lg:col-6">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h6>Последний контент</h6>
|
||||||
|
<Button label="Весь контент" icon="pi pi-arrow-right" class="p-button-text p-button-sm" @click="$router.push('/smm/content')" />
|
||||||
|
</div>
|
||||||
|
<div class="card-content">
|
||||||
|
<div v-if="loading" class="text-center">
|
||||||
|
<ProgressSpinner />
|
||||||
|
</div>
|
||||||
|
<div v-else-if="recentContent.length === 0" class="text-center text-600">
|
||||||
|
<i class="pi pi-info-circle"></i>
|
||||||
|
<p>Нет контента</p>
|
||||||
|
</div>
|
||||||
|
<div v-else class="content-list">
|
||||||
|
<div v-for="content in recentContent" :key="content.id" class="content-item">
|
||||||
|
<div class="content-info">
|
||||||
|
<h6>{{ content.topic }}</h6>
|
||||||
|
<p class="text-600">{{ truncateText(content.postDraft, 50) }}</p>
|
||||||
|
<small>{{ formatDate(content.scheduledAt) }}</small>
|
||||||
|
</div>
|
||||||
|
<Tag :value="getContentStatusLabel(content.status)" :severity="getContentStatusSeverity(content.status)" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import AnalyticsService from '@/service/AnalyticsService.js';
|
||||||
|
import CampaignService from '@/service/CampaignService.js';
|
||||||
|
import ContentService from '@/service/ContentService.js';
|
||||||
|
import { CampaignStatus, ContentStatus } from '@/types/smm.js';
|
||||||
|
import { useToast } from 'primevue/usetoast';
|
||||||
|
import { onMounted, ref } from 'vue';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'SmmDashboard',
|
||||||
|
setup() {
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
|
// Reactive data
|
||||||
|
const stats = ref({});
|
||||||
|
const recentCampaigns = ref([]);
|
||||||
|
const recentContent = ref([]);
|
||||||
|
const loading = ref(false);
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
const loadDashboardData = async () => {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const [systemStats, publishingStats, campaigns, content] = await Promise.all([AnalyticsService.getSystemStats(), AnalyticsService.getPublishingStats(), CampaignService.getAllCampaigns(), ContentService.getAllContent()]);
|
||||||
|
|
||||||
|
stats.value = {
|
||||||
|
campaigns: systemStats.campaigns?.total || 0,
|
||||||
|
activeCampaigns: systemStats.campaigns?.activeCampaigns || 0,
|
||||||
|
channels: systemStats.channels?.total || 0,
|
||||||
|
activeChannels: systemStats.channels?.activeChannels || 0,
|
||||||
|
content: systemStats.content?.total || 0,
|
||||||
|
pendingApproval: systemStats.content?.pendingApproval || 0,
|
||||||
|
published: publishingStats.totalPublished || 0,
|
||||||
|
publishingRate: publishingStats.publishingRate || 0
|
||||||
|
};
|
||||||
|
|
||||||
|
// Получаем последние 5 кампаний
|
||||||
|
recentCampaigns.value = campaigns.sort((a, b) => new Date(b.startAt) - new Date(a.startAt)).slice(0, 5);
|
||||||
|
|
||||||
|
// Получаем последние 5 контентов
|
||||||
|
recentContent.value = content.sort((a, b) => new Date(b.scheduledAt || b.createdAt) - new Date(a.scheduledAt || a.createdAt)).slice(0, 5);
|
||||||
|
} catch (error) {
|
||||||
|
toast.add({
|
||||||
|
severity: 'error',
|
||||||
|
summary: 'Ошибка',
|
||||||
|
detail: 'Не удалось загрузить данные: ' + error.message,
|
||||||
|
life: 3000
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateString) => {
|
||||||
|
if (!dateString) return '-';
|
||||||
|
return new Date(dateString).toLocaleDateString('ru-RU', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const truncateText = (text, length) => {
|
||||||
|
if (!text) return '';
|
||||||
|
return text.length > length ? text.substring(0, length) + '...' : text;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusLabel = (status) => {
|
||||||
|
const labels = {
|
||||||
|
[CampaignStatus.PLANNED]: 'Запланирована',
|
||||||
|
[CampaignStatus.ACTIVE]: 'Активна',
|
||||||
|
[CampaignStatus.COMPLETED]: 'Завершена'
|
||||||
|
};
|
||||||
|
return labels[status] || status;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusSeverity = (status) => {
|
||||||
|
const severities = {
|
||||||
|
[CampaignStatus.PLANNED]: 'info',
|
||||||
|
[CampaignStatus.ACTIVE]: 'success',
|
||||||
|
[CampaignStatus.COMPLETED]: 'secondary'
|
||||||
|
};
|
||||||
|
return severities[status] || 'info';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getContentStatusLabel = (status) => {
|
||||||
|
const labels = {
|
||||||
|
[ContentStatus.DRAFT]: 'Черновик',
|
||||||
|
[ContentStatus.PENDING_APPROVAL]: 'Ожидает',
|
||||||
|
[ContentStatus.APPROVED]: 'Одобрен',
|
||||||
|
[ContentStatus.PUBLISHED]: 'Опубликован',
|
||||||
|
[ContentStatus.FAILED]: 'Ошибка'
|
||||||
|
};
|
||||||
|
return labels[status] || status;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getContentStatusSeverity = (status) => {
|
||||||
|
const severities = {
|
||||||
|
[ContentStatus.DRAFT]: 'info',
|
||||||
|
[ContentStatus.PENDING_APPROVAL]: 'warning',
|
||||||
|
[ContentStatus.APPROVED]: 'success',
|
||||||
|
[ContentStatus.PUBLISHED]: 'success',
|
||||||
|
[ContentStatus.FAILED]: 'danger'
|
||||||
|
};
|
||||||
|
return severities[status] || 'info';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Lifecycle
|
||||||
|
onMounted(() => {
|
||||||
|
loadDashboardData();
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
stats,
|
||||||
|
recentCampaigns,
|
||||||
|
recentContent,
|
||||||
|
loading,
|
||||||
|
formatDate,
|
||||||
|
truncateText,
|
||||||
|
getStatusLabel,
|
||||||
|
getStatusSeverity,
|
||||||
|
getContentStatusLabel,
|
||||||
|
getContentStatusSeverity
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.smm-dashboard {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--surface-card);
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow:
|
||||||
|
0 1px 3px rgba(0, 0, 0, 0.12),
|
||||||
|
0 1px 2px rgba(0, 0, 0, 0.24);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-bottom: 1px solid var(--surface-border);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header h4 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header h5 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header h6 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-content {
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
background: var(--surface-card);
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow:
|
||||||
|
0 1px 3px rgba(0, 0, 0, 0.12),
|
||||||
|
0 1px 2px rgba(0, 0, 0, 0.24);
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-icon {
|
||||||
|
font-size: 2rem;
|
||||||
|
margin-right: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-info h6 {
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
color: var(--text-color-secondary);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-info h3 {
|
||||||
|
margin: 0 0 0.25rem 0;
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.campaign-list,
|
||||||
|
.content-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.campaign-item,
|
||||||
|
.content-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1rem;
|
||||||
|
border: 1px solid var(--surface-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--surface-50);
|
||||||
|
}
|
||||||
|
|
||||||
|
.campaign-info,
|
||||||
|
.content-info {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.campaign-info h6,
|
||||||
|
.content-info h6 {
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.campaign-info p,
|
||||||
|
.content-info p {
|
||||||
|
margin: 0 0 0.25rem 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.campaign-info small,
|
||||||
|
.content-info small {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-600 {
|
||||||
|
color: var(--text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-center {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.w-full {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
### **Техническое задание №1: Основа проекта, модели данных и репозитории**
|
||||||
|
|
||||||
|
**Цель:** Создать фундамент backend-приложения для модуля SMM-автоматизации. На этом этапе необходимо инициализировать проект, определить структуру базы данных с помощью JPA-сущностей и создать репозитории для доступа к данным.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1\. Инициализация проекта
|
||||||
|
|
||||||
|
- **Задача:** Создать новый проект на базе **Spring Boot**.
|
||||||
|
- **Технологический стек:**
|
||||||
|
- **Фреймворк:** Spring Boot 3.x
|
||||||
|
- **Язык:** Java 17 (или выше)
|
||||||
|
- **Сборщик:** Maven
|
||||||
|
- **Артефакт:** `smm-automation-service`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2\. Зависимости
|
||||||
|
|
||||||
|
В `pom.xml` необходимо добавить следующие ключевые зависимости:
|
||||||
|
|
||||||
|
- **Spring Web:** для создания REST-контроллеров.
|
||||||
|
- **Spring Data JPA:** для работы с базой данных через сущности.
|
||||||
|
- **PostgreSQL Driver:** для подключения к базе данных PostgreSQL.
|
||||||
|
- **Lombok:** для сокращения шаблонного кода в моделях (геттеры, сеттеры, конструкторы).
|
||||||
|
- **Hibernate Types:** для поддержки типа данных JSON в PostgreSQL.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3\. Модели данных (JPA Entities)
|
||||||
|
|
||||||
|
Необходимо создать Java-классы, описывающие таблицы в базе данных, со всеми указанными полями, типами и связями.
|
||||||
|
|
||||||
|
### `Channel.java`
|
||||||
|
|
||||||
|
Канал для публикации (Telegram, VK и т.д.).
|
||||||
|
|
||||||
|
```java
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "channels")
|
||||||
|
public class Channel {
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.AUTO)
|
||||||
|
private UUID id;
|
||||||
|
|
||||||
|
private String name; // e.g., "Telegram Official"
|
||||||
|
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
private ChannelType type; // ENUM: TELEGRAM, VK, INSTAGRAM
|
||||||
|
|
||||||
|
private String apiKeyRef; // Ссылка на secret в Vault или аналоге
|
||||||
|
|
||||||
|
private boolean isActive;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Необходимо также создать Enum ChannelType
|
||||||
|
public enum ChannelType {
|
||||||
|
TELEGRAM, VK, INSTAGRAM
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `Campaign.java`
|
||||||
|
|
||||||
|
Маркетинговая кампания.
|
||||||
|
|
||||||
|
```java
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.ZonedDateTime;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "campaigns")
|
||||||
|
public class Campaign {
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.AUTO)
|
||||||
|
private UUID id;
|
||||||
|
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
private String goal;
|
||||||
|
|
||||||
|
private BigDecimal budget;
|
||||||
|
|
||||||
|
private ZonedDateTime startAt;
|
||||||
|
|
||||||
|
private ZonedDateTime endAt;
|
||||||
|
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
private CampaignStatus status; // ENUM: PLANNED, ACTIVE, COMPLETED
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enum CampaignStatus
|
||||||
|
public enum CampaignStatus {
|
||||||
|
PLANNED, ACTIVE, COMPLETED
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `ContentQueue.java`
|
||||||
|
|
||||||
|
Очередь контента для публикации.
|
||||||
|
|
||||||
|
```java
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import org.hibernate.annotations.JdbcTypeCode;
|
||||||
|
import org.hibernate.type.SqlTypes;
|
||||||
|
import java.time.ZonedDateTime;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "content_queue")
|
||||||
|
public class ContentQueue {
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.AUTO)
|
||||||
|
private UUID id;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "campaign_id")
|
||||||
|
private Campaign campaign;
|
||||||
|
|
||||||
|
private String locale; // e.g., "ru", "kk"
|
||||||
|
|
||||||
|
private String topic;
|
||||||
|
|
||||||
|
@JdbcTypeCode(SqlTypes.JSON)
|
||||||
|
@Column(columnDefinition = "jsonb")
|
||||||
|
private String postDraft; // JSON: { "title": "...", "body": "...", "hashtags": [...] }
|
||||||
|
|
||||||
|
@JdbcTypeCode(SqlTypes.JSON)
|
||||||
|
@Column(columnDefinition = "jsonb")
|
||||||
|
private String assetsRefs; // JSON: ["path/to/image1.jpg"]
|
||||||
|
|
||||||
|
private ZonedDateTime scheduledAt;
|
||||||
|
|
||||||
|
private int priority;
|
||||||
|
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
private ContentStatus status; // ENUM: DRAFT, PENDING_APPROVAL, APPROVED, PUBLISHED, FAILED
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enum ContentStatus
|
||||||
|
public enum ContentStatus {
|
||||||
|
DRAFT, PENDING_APPROVAL, APPROVED, PUBLISHED, FAILED
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `Message.java`
|
||||||
|
|
||||||
|
Факт публикации сообщения.
|
||||||
|
|
||||||
|
```java
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import java.time.ZonedDateTime;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "messages")
|
||||||
|
public class Message {
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.AUTO)
|
||||||
|
private UUID id;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "channel_id")
|
||||||
|
private Channel channel;
|
||||||
|
|
||||||
|
@OneToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "content_id")
|
||||||
|
private ContentQueue content;
|
||||||
|
|
||||||
|
private String externalId; // ID поста в соцсети
|
||||||
|
|
||||||
|
private String url;
|
||||||
|
|
||||||
|
private ZonedDateTime postedAt;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `KpiSnapshot.java`
|
||||||
|
|
||||||
|
Срезы по ключевым метрикам.
|
||||||
|
|
||||||
|
```java
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "kpi_snapshots")
|
||||||
|
public class KpiSnapshot {
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.AUTO)
|
||||||
|
private UUID id;
|
||||||
|
|
||||||
|
private LocalDate date;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "channel_id")
|
||||||
|
private Channel channel;
|
||||||
|
|
||||||
|
private String metric; // e.g., "CTR", "ER", "LEADS"
|
||||||
|
|
||||||
|
private BigDecimal value;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4\. Репозитории (Spring Data JPA)
|
||||||
|
|
||||||
|
Для каждой созданной сущности необходимо создать соответствующий интерфейс-репозиторий для взаимодействия с базой данных.
|
||||||
|
|
||||||
|
- `ChannelRepository.java`
|
||||||
|
- `CampaignRepository.java`
|
||||||
|
- `ContentQueueRepository.java`
|
||||||
|
- `MessageRepository.java`
|
||||||
|
- `KpiSnapshotRepository.java`
|
||||||
|
|
||||||
|
**Пример для `CampaignRepository`:**
|
||||||
|
|
||||||
|
```java
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface CampaignRepository extends JpaRepository<Campaign, UUID> {
|
||||||
|
// Здесь в будущем можно будет добавлять кастомные методы для запросов
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5\. Конфигурация базы данных
|
||||||
|
|
||||||
|
В файле `application.properties` или `application.yml` необходимо указать настройки для подключения к локальной или dev-базе данных PostgreSQL.
|
||||||
|
|
||||||
|
**Пример для `application.yml`:**
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
spring:
|
||||||
|
datasource:
|
||||||
|
url: jdbc:postgresql://localhost:5432/smm_automation_db
|
||||||
|
username: your_username
|
||||||
|
password: your_password
|
||||||
|
jpa:
|
||||||
|
hibernate:
|
||||||
|
ddl-auto: update # На время разработки, для prod использовать 'validate'
|
||||||
|
properties:
|
||||||
|
hibernate:
|
||||||
|
dialect: org.hibernate.dialect.PostgreSQLDialect
|
||||||
|
format_sql: true
|
||||||
|
show-sql: true
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Критерии выполнения
|
||||||
|
|
||||||
|
- Проект Spring Boot успешно создан и запускается без ошибок.
|
||||||
|
- Все 5 JPA-сущностей и 3 Enum'а созданы в проекте.
|
||||||
|
- Для каждой сущности создан соответствующий Spring Data JPA репозиторий.
|
||||||
|
- Приложение успешно подключается к базе данных PostgreSQL.
|
||||||
|
- При запуске приложения Hibernate автоматически создает или обновляет таблицы в базе данных в соответствии с описанными моделями.
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
### **Техническое задание №2: Базовое управление кампаниями и каналами (CRUD)**
|
||||||
|
|
||||||
|
**Цель:** Реализовать полную бизнес-логику и API для управления основными сущностями системы: **маркетинговыми кампаниями (`Campaign`)** и **каналами публикации (`Channel`)**. На этом этапе будет создана базовая административная функциональность, позволяющая создавать, просматривать, редактировать и удалять эти сущности через REST API.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1\. Data Transfer Objects (DTOs)
|
||||||
|
|
||||||
|
Для обеспечения чистоты и безопасности API необходимо создать DTO для всех входящих и исходящих данных.
|
||||||
|
|
||||||
|
### `CampaignDto.java`
|
||||||
|
|
||||||
|
Используется для возврата информации о кампании.
|
||||||
|
|
||||||
|
```java
|
||||||
|
public class CampaignDto {
|
||||||
|
private UUID id;
|
||||||
|
private String name;
|
||||||
|
private String goal;
|
||||||
|
private BigDecimal budget;
|
||||||
|
private ZonedDateTime startAt;
|
||||||
|
private ZonedDateTime endAt;
|
||||||
|
private CampaignStatus status;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `CreateCampaignRequest.java`
|
||||||
|
|
||||||
|
Используется для создания новой кампании.
|
||||||
|
|
||||||
|
```java
|
||||||
|
public class CreateCampaignRequest {
|
||||||
|
@NotBlank
|
||||||
|
private String name;
|
||||||
|
private String goal;
|
||||||
|
@PositiveOrZero
|
||||||
|
private BigDecimal budget;
|
||||||
|
@FutureOrPresent
|
||||||
|
private ZonedDateTime startAt;
|
||||||
|
@Future
|
||||||
|
private ZonedDateTime endAt;
|
||||||
|
private CampaignStatus status = CampaignStatus.PLANNED;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `ChannelDto.java`
|
||||||
|
|
||||||
|
Используется для возврата информации о канале.
|
||||||
|
|
||||||
|
```java
|
||||||
|
public class ChannelDto {
|
||||||
|
private UUID id;
|
||||||
|
private String name;
|
||||||
|
private ChannelType type;
|
||||||
|
private boolean isActive;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `CreateChannelRequest.java`
|
||||||
|
|
||||||
|
Используется для создания нового канала.
|
||||||
|
|
||||||
|
```java
|
||||||
|
public class CreateChannelRequest {
|
||||||
|
@NotBlank
|
||||||
|
private String name;
|
||||||
|
@NotNull
|
||||||
|
private ChannelType type;
|
||||||
|
@NotBlank
|
||||||
|
private String apiKeyRef;
|
||||||
|
private boolean isActive = true;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2\. Сервисный слой (Business Logic)
|
||||||
|
|
||||||
|
### `CampaignService.java`
|
||||||
|
|
||||||
|
Сервис для управления кампаниями.
|
||||||
|
|
||||||
|
- **Методы:**
|
||||||
|
- `List<CampaignDto> getAllCampaigns()`: Возвращает список всех кампаний.
|
||||||
|
- `CampaignDto getCampaignById(UUID id)`: Находит кампанию по ID. В случае отсутствия выбрасывает исключение `ResourceNotFoundException`.
|
||||||
|
- `CampaignDto createCampaign(CreateCampaignRequest request)`: Создает новую кампанию на основе DTO, сохраняет в БД и возвращает `CampaignDto`.
|
||||||
|
- `CampaignDto updateCampaign(UUID id, CreateCampaignRequest request)`: Обновляет существующую кампанию.
|
||||||
|
- `void deleteCampaign(UUID id)`: Удаляет кампанию по ID.
|
||||||
|
|
||||||
|
### `ChannelService.java`
|
||||||
|
|
||||||
|
Сервис для управления каналами.
|
||||||
|
|
||||||
|
- **Методы:**
|
||||||
|
- `List<ChannelDto> getAllChannels()`: Возвращает список всех каналов.
|
||||||
|
- `ChannelDto getChannelById(UUID id)`: Находит канал по ID. В случае отсутствия выбрасывает исключение `ResourceNotFoundException`.
|
||||||
|
- `ChannelDto createChannel(CreateChannelRequest request)`: Создает новый канал.
|
||||||
|
- `ChannelDto updateChannel(UUID id, CreateChannelRequest request)`: Обновляет существующий канал.
|
||||||
|
- `void deleteChannel(UUID id)`: Удаляет канал по ID.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3\. API Endpoints (REST Controllers)
|
||||||
|
|
||||||
|
### `CampaignController.java`
|
||||||
|
|
||||||
|
Контроллер для управления кампаниями.
|
||||||
|
|
||||||
|
- **`GET /api/smm/campaigns`**
|
||||||
|
- **Описание:** Получить список всех кампаний.
|
||||||
|
- **Ответ (200 OK):** `List<CampaignDto>`
|
||||||
|
- **`GET /api/smm/campaigns/{id}`**
|
||||||
|
- **Описание:** Получить кампанию по ID.
|
||||||
|
- **Ответ (200 OK):** `CampaignDto`
|
||||||
|
- **Ответ (404 Not Found):** Если кампания не найдена.
|
||||||
|
- **`POST /api/smm/campaigns`**
|
||||||
|
- **Описание:** Создать новую кампанию.
|
||||||
|
- **Тело запроса:** `CreateCampaignRequest`
|
||||||
|
- **Ответ (201 Created):** `CampaignDto`
|
||||||
|
- **`PUT /api/smm/campaigns/{id}`**
|
||||||
|
- **Описание:** Обновить существующую кампанию.
|
||||||
|
- **Тело запроса:** `CreateCampaignRequest`
|
||||||
|
- **Ответ (200 OK):** `CampaignDto`
|
||||||
|
- **`DELETE /api/smm/campaigns/{id}`**
|
||||||
|
- **Описание:** Удалить кампанию.
|
||||||
|
- **Ответ (204 No Content):** Успешное удаление.
|
||||||
|
|
||||||
|
### `ChannelController.java`
|
||||||
|
|
||||||
|
Контроллер для управления каналами.
|
||||||
|
|
||||||
|
- **`GET /api/smm/channels`**
|
||||||
|
- **Описание:** Получить список всех каналов.
|
||||||
|
- **Ответ (200 OK):** `List<ChannelDto>`
|
||||||
|
- **`POST /api/smm/channels`**
|
||||||
|
- **Описание:** Создать новый канал.
|
||||||
|
- **Тело запроса:** `CreateChannelRequest`
|
||||||
|
- **Ответ (201 Created):** `ChannelDto`
|
||||||
|
- **Прочие эндпоинты (`GET /id`, `PUT /id`, `DELETE /id`)** реализуются по аналогии с `CampaignController`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4\. Обработка ошибок
|
||||||
|
|
||||||
|
- Необходимо создать глобальный обработчик исключений (`@ControllerAdvice`) для перехвата `ResourceNotFoundException` и возврата корректного HTTP-статуса `404 Not Found` с информативным сообщением в теле ответа.
|
||||||
|
- Валидация DTO (`@NotBlank`, `@NotNull` и т.д.) должна быть включена с помощью аннотации `@Valid` в методах контроллера. При ошибке валидации Spring автоматически вернет статус `400 Bad Request`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Критерии выполнения
|
||||||
|
|
||||||
|
- Созданы все указанные DTO с аннотациями для валидации.
|
||||||
|
- Реализованы `CampaignService` и `ChannelService` со всей CRUD-логикой.
|
||||||
|
- Реализованы `CampaignController` и `ChannelController` со всеми указанными REST-эндпоинтами.
|
||||||
|
- Все эндпоинты корректно работают и возвращают ожидаемые HTTP-статусы и тела ответов.
|
||||||
|
- Реализована обработка ошибок для случаев, когда сущность не найдена.
|
||||||
|
- Код покрыт базовыми юнит-тестами для сервисного слоя.
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
### **Техническое задание: Модуль SMM-автоматизации**
|
||||||
|
|
||||||
|
**Версия 2.0**
|
||||||
|
|
||||||
|
Это ТЗ описывает разработку backend и frontend компонентов для модуля AI-продвижения в соцсетях. Оркестрация через n8n на данном этапе **не реализуется**, но архитектура закладывает основу для будущей интеграции. Управление процессами временно осуществляется через UI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Backend (Spring Boot)
|
||||||
|
|
||||||
|
Backend будет содержать всю бизнес-логику, модели данных и API для взаимодействия с frontend и внешними сервисами (соцсети, LLM).
|
||||||
|
|
||||||
|
### 1\. Модели данных (JPA Entities)
|
||||||
|
|
||||||
|
На основе схемы БД из первоначального ТЗ, создаем следующие JPA-сущности:
|
||||||
|
|
||||||
|
- **`Channel`**: Каналы для постинга.
|
||||||
|
```java
|
||||||
|
@Entity
|
||||||
|
public class Channel {
|
||||||
|
@Id @GeneratedValue
|
||||||
|
private UUID id;
|
||||||
|
private String name; // e.g., "Telegram Official"
|
||||||
|
private String type; // ENUM: TELEGRAM, VK, INSTAGRAM, etc.
|
||||||
|
private String apiKeyRef; // Ссылка на secret в Vault или аналоге
|
||||||
|
private boolean isActive;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- **`Campaign`**: Маркетинговые кампании.
|
||||||
|
```java
|
||||||
|
@Entity
|
||||||
|
public class Campaign {
|
||||||
|
@Id @GeneratedValue
|
||||||
|
private UUID id;
|
||||||
|
private String name;
|
||||||
|
private String goal;
|
||||||
|
private BigDecimal budget;
|
||||||
|
private ZonedDateTime startAt;
|
||||||
|
private ZonedDateTime endAt;
|
||||||
|
private String status; // ENUM: PLANNED, ACTIVE, COMPLETED
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- **`ContentQueue`**: Очередь контента для публикации.
|
||||||
|
```java
|
||||||
|
@Entity
|
||||||
|
public class ContentQueue {
|
||||||
|
@Id @GeneratedValue
|
||||||
|
private UUID id;
|
||||||
|
@ManyToOne
|
||||||
|
private Campaign campaign;
|
||||||
|
private String locale; // ENUM: "ru", "kk"
|
||||||
|
private String topic;
|
||||||
|
@JdbcTypeCode(SqlTypes.JSON)
|
||||||
|
private String postDraft; // JSON: { "title": "...", "body": "...", "hashtags": [...] }
|
||||||
|
@JdbcTypeCode(SqlTypes.JSON)
|
||||||
|
private String assetsRefs; // JSON: ["path/to/image1.jpg"]
|
||||||
|
private ZonedDateTime scheduledAt;
|
||||||
|
private int priority;
|
||||||
|
private String status; // ENUM: DRAFT, PENDING_APPROVAL, APPROVED, PUBLISHED, FAILED
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- **`Message`**: Опубликованные сообщения.
|
||||||
|
```java
|
||||||
|
@Entity
|
||||||
|
public class Message {
|
||||||
|
@Id @GeneratedValue
|
||||||
|
private UUID id;
|
||||||
|
@ManyToOne
|
||||||
|
private Channel channel;
|
||||||
|
@OneToOne
|
||||||
|
private ContentQueue content;
|
||||||
|
private String externalId; // ID поста в соцсети
|
||||||
|
private String url;
|
||||||
|
private ZonedDateTime postedAt;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- **`KpiSnapshot`**: Срезы по ключевым метрикам для аналитики.
|
||||||
|
```java
|
||||||
|
@Entity
|
||||||
|
public class KpiSnapshot {
|
||||||
|
@Id @GeneratedValue
|
||||||
|
private UUID id;
|
||||||
|
private LocalDate date;
|
||||||
|
@ManyToOne
|
||||||
|
private Channel channel;
|
||||||
|
private String metric; // e.g., "CTR", "ER", "LEADS"
|
||||||
|
private BigDecimal value;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2\. API Endpoints (REST Controllers)
|
||||||
|
|
||||||
|
Создаем REST контроллеры для управления сущностями.
|
||||||
|
|
||||||
|
#### `CampaignController`
|
||||||
|
|
||||||
|
- `GET /api/smm/campaigns` — получить список всех кампаний.
|
||||||
|
- `GET /api/smm/campaigns/{id}` — получить детали одной кампании.
|
||||||
|
- `POST /api/smm/campaigns` — создать новую кампанию.
|
||||||
|
- **Body**: `CampaignDto { name, goal, budget, startAt, endAt }`
|
||||||
|
- `PUT /api/smm/campaigns/{id}` — обновить кампанию.
|
||||||
|
|
||||||
|
#### `ContentController`
|
||||||
|
|
||||||
|
- `GET /api/smm/content` — получить очередь контента с фильтрами (по дате, статусу, кампании).
|
||||||
|
- `GET /api/smm/content/{id}` — получить конкретный элемент из очереди.
|
||||||
|
- `POST /api/smm/content/generate` — **(Ключевой метод)** запустить генерацию контента.
|
||||||
|
- **Body**: `GenerateRequestDto { topic, locale, channelType }`
|
||||||
|
- **Response**: `ContentQueue` в статусе `DRAFT`.
|
||||||
|
- `PUT /api/smm/content/{id}` — обновить пост вручную (текст, картинки, время публикации).
|
||||||
|
- `POST /api/smm/content/{id}/approve` — изменить статус на `APPROVED`.
|
||||||
|
- `DELETE /api/smm/content/{id}` — удалить черновик.
|
||||||
|
|
||||||
|
#### `PublishingController`
|
||||||
|
|
||||||
|
- `POST /api/smm/publishing/post/{contentId}` — принудительно опубликовать пост (для ручного управления).
|
||||||
|
|
||||||
|
#### `ChannelController`
|
||||||
|
|
||||||
|
- `GET /api/smm/channels` — получить список подключенных каналов.
|
||||||
|
- `POST /api/smm/channels` — подключить новый канал (сохранить токен).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3\. Сервисный слой (Business Logic)
|
||||||
|
|
||||||
|
Логика "AI-агентов" реализуется в сервисах.
|
||||||
|
|
||||||
|
- **`CampaignService`**: CRUD-операции для кампаний.
|
||||||
|
- **`ContentGenerationService`**:
|
||||||
|
- Интегрируется с API LLM (`Writer`, `Researcher`).
|
||||||
|
- Интегрируется с API для генерации изображений (`Designer`).
|
||||||
|
- Выполняет проверку на стоп-слова (`Compliance`).
|
||||||
|
- Создает запись в `ContentQueue` со статусом `DRAFT`.
|
||||||
|
- **`PublishingService`**:
|
||||||
|
- Содержит логику для постинга в каждый конкретный канал (Telegram, VK и т.д.) через их API.
|
||||||
|
- Запускается по расписанию (`@Scheduled` в Spring) для постов со статусом `APPROVED` и подошедшим `scheduledAt`.
|
||||||
|
- После успешной публикации создает запись в `Message` и обновляет статус в `ContentQueue`.
|
||||||
|
- **`AnalyticsService`**:
|
||||||
|
- По расписанию собирает статистику по опубликованным постам (`Message`).
|
||||||
|
- Сохраняет данные в `KpiSnapshot`.
|
||||||
|
|
||||||
|
### 4\. Placeholder для n8n
|
||||||
|
|
||||||
|
Для будущей интеграции создаем специальный контроллер.
|
||||||
|
|
||||||
|
#### `WorkflowTriggerController`
|
||||||
|
|
||||||
|
- `POST /api/smm/workflows/start-weekly-planning` — этот эндпоинт будет вызывать n8n, чтобы запустить цепочку. Сейчас он не используется, но должен быть заложен в архитектуру. Пока что эту логику будет инициировать пользователь через UI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Frontend (Vue.js)
|
||||||
|
|
||||||
|
Frontend-модуль предоставляет пользовательский интерфейс для управления всем SMM-циклом вручную.
|
||||||
|
|
||||||
|
### 1\. Компоненты и страницы (Views & Components)
|
||||||
|
|
||||||
|
#### **Страница "Кампании" (`/smm/campaigns`)**
|
||||||
|
|
||||||
|
- **Вид**: Таблица со списком всех кампаний (`id`, `name`, `status`, `period`).
|
||||||
|
- **Функционал**:
|
||||||
|
- Кнопка "Создать кампанию", открывающая модальное окно с формой.
|
||||||
|
- Просмотр и редактирование существующих кампаний.
|
||||||
|
|
||||||
|
#### **Страница "Контент-план" (`/smm/content-plan`)**
|
||||||
|
|
||||||
|
- **Вид**: Основной рабочий экран. Реализовать в виде календаря или Канбан-доски (колонки: "Черновик", "На согласовании", "Запланировано", "Опубликовано").
|
||||||
|
- **Функционал**:
|
||||||
|
- Отображение карточек постов из `ContentQueue`. На карточке: тема, канал, дата.
|
||||||
|
- Кнопка "Сгенерировать идею/пост", которая вызывает `POST /api/smm/content/generate`.
|
||||||
|
- Drag-n-drop карточек для изменения даты публикации (`scheduledAt`).
|
||||||
|
- Клик на карточку открывает модальное окно редактора поста.
|
||||||
|
|
||||||
|
#### **Компонент "Редактор поста"**
|
||||||
|
|
||||||
|
- **Вид**: Модальное окно или отдельная страница.
|
||||||
|
- **Поля**:
|
||||||
|
- Редактируемый текст поста для RU и KZ версий (вкладки или два поля).
|
||||||
|
- Превью сгенерированного изображения. Возможность загрузить свое.
|
||||||
|
- Выбор даты и времени публикации.
|
||||||
|
- Статус поста.
|
||||||
|
- **Кнопки**:
|
||||||
|
- "Сохранить" (`PUT /api/smm/content/{id}`).
|
||||||
|
- "Перегенерировать" (повторный вызов генерации для этого поста).
|
||||||
|
- "Согласовать" (`POST /api/smm/content/{id}/approve`).
|
||||||
|
- "Опубликовать сейчас" (`POST /api/smm/publishing/post/{contentId}`).
|
||||||
|
- "Удалить".
|
||||||
|
|
||||||
|
#### **Страница "Аналитика" (`/smm/analytics`)**
|
||||||
|
|
||||||
|
- **Вид**: Дашборд с графиками и ключевыми метриками (CTR, ER, Лиды) из `KpiSnapshot`.
|
||||||
|
- **Функционал**: Фильтры по дате, кампании, каналу.
|
||||||
|
|
||||||
|
#### **Страница "Настройки" (`/smm/settings`)**
|
||||||
|
|
||||||
|
- **Вид**: Управление подключенными каналами (`Channel`).
|
||||||
|
- **Функционал**: Добавление новых аккаунтов соцсетей (ввод токенов API), проверка статуса подключения.
|
||||||
|
|
||||||
|
### 2\. Взаимодействие с Backend
|
||||||
|
|
||||||
|
- Использовать `axios` или аналогичный HTTP-клиент для всех запросов к API.
|
||||||
|
- Для управления состоянием (списки кампаний, контента) использовать `Pinia`.
|
||||||
|
- Обеспечить обработку ошибок API и отображение уведомлений для пользователя.
|
||||||
|
|
||||||
|
Этот подход позволит вам разработать и протестировать всю основную функциональность модуля независимо от n8n, а когда придет время, вы просто настроите n8n на вызов уже готовых и отлаженных API-эндпоинтов.
|
||||||
+551
@@ -0,0 +1,551 @@
|
|||||||
|
# API Документация для Фронтенда
|
||||||
|
|
||||||
|
Данная документация описывает все доступные API эндпоинты для фронтенда системы управления социальными сетями Konturai.
|
||||||
|
|
||||||
|
## Базовый URL
|
||||||
|
|
||||||
|
```
|
||||||
|
https://api.konturai.kz/api/smm
|
||||||
|
```
|
||||||
|
|
||||||
|
## Аутентификация
|
||||||
|
|
||||||
|
Все запросы требуют аутентификации. Используйте Bearer токен в заголовке Authorization:
|
||||||
|
|
||||||
|
```
|
||||||
|
Authorization: Bearer <your-jwt-token>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. CampaignController - Управление кампаниями
|
||||||
|
|
||||||
|
**Базовый путь:** `/api/smm/campaigns`
|
||||||
|
|
||||||
|
### 1.1 Получить все кампании
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/smm/campaigns
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ответ:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"name": "Новогодняя кампания",
|
||||||
|
"goal": "Увеличение продаж на 20%",
|
||||||
|
"budget": 100000.0,
|
||||||
|
"startAt": "2024-01-01T00:00:00Z",
|
||||||
|
"endAt": "2024-01-31T23:59:59Z",
|
||||||
|
"status": "ACTIVE"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.2 Получить кампанию по ID
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/smm/campaigns/{id}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Параметры:**
|
||||||
|
|
||||||
|
- `id` (UUID) - ID кампании
|
||||||
|
|
||||||
|
**Ответ:** Объект CampaignDto
|
||||||
|
|
||||||
|
### 1.3 Создать кампанию
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/smm/campaigns
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
**Тело запроса:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Новогодняя кампания",
|
||||||
|
"goal": "Увеличение продаж на 20%",
|
||||||
|
"budget": 100000.0,
|
||||||
|
"startAt": "2024-01-01T00:00:00Z",
|
||||||
|
"endAt": "2024-01-31T23:59:59Z",
|
||||||
|
"status": "PLANNED"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Валидация:**
|
||||||
|
|
||||||
|
- `name` - обязательное поле, не пустое
|
||||||
|
- `budget` - должно быть >= 0
|
||||||
|
- `startAt` - дата должна быть в будущем или настоящем
|
||||||
|
- `endAt` - дата должна быть в будущем
|
||||||
|
|
||||||
|
**Ответ:** 201 Created, объект CampaignDto
|
||||||
|
|
||||||
|
### 1.4 Обновить кампанию
|
||||||
|
|
||||||
|
```http
|
||||||
|
PUT /api/smm/campaigns/{id}
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
**Параметры:**
|
||||||
|
|
||||||
|
- `id` (UUID) - ID кампании
|
||||||
|
|
||||||
|
**Тело запроса:** То же, что и при создании
|
||||||
|
|
||||||
|
**Ответ:** Объект CampaignDto
|
||||||
|
|
||||||
|
### 1.5 Удалить кампанию
|
||||||
|
|
||||||
|
```http
|
||||||
|
DELETE /api/smm/campaigns/{id}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Параметры:**
|
||||||
|
|
||||||
|
- `id` (UUID) - ID кампании
|
||||||
|
|
||||||
|
**Ответ:** 204 No Content
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. ChannelController - Управление каналами
|
||||||
|
|
||||||
|
**Базовый путь:** `/api/smm/channels`
|
||||||
|
|
||||||
|
### 2.1 Получить все каналы
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/smm/channels
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ответ:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "550e8400-e29b-41d4-a716-446655440001",
|
||||||
|
"name": "Основной Telegram канал",
|
||||||
|
"type": "TELEGRAM",
|
||||||
|
"isActive": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 Получить канал по ID
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/smm/channels/{id}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Параметры:**
|
||||||
|
|
||||||
|
- `id` (UUID) - ID канала
|
||||||
|
|
||||||
|
**Ответ:** Объект ChannelDto
|
||||||
|
|
||||||
|
### 2.3 Создать канал
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/smm/channels
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
**Тело запроса:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Основной Telegram канал",
|
||||||
|
"type": "TELEGRAM",
|
||||||
|
"apiKeyRef": "telegram_bot_token",
|
||||||
|
"isActive": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Валидация:**
|
||||||
|
|
||||||
|
- `name` - обязательное поле, не пустое
|
||||||
|
- `type` - обязательное поле, один из: TELEGRAM, VK, INSTAGRAM
|
||||||
|
- `apiKeyRef` - обязательное поле, не пустое
|
||||||
|
|
||||||
|
**Ответ:** 201 Created, объект ChannelDto
|
||||||
|
|
||||||
|
### 2.4 Обновить канал
|
||||||
|
|
||||||
|
```http
|
||||||
|
PUT /api/smm/channels/{id}
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
**Параметры:**
|
||||||
|
|
||||||
|
- `id` (UUID) - ID канала
|
||||||
|
|
||||||
|
**Тело запроса:** То же, что и при создании
|
||||||
|
|
||||||
|
**Ответ:** Объект ChannelDto
|
||||||
|
|
||||||
|
### 2.5 Удалить канал
|
||||||
|
|
||||||
|
```http
|
||||||
|
DELETE /api/smm/channels/{id}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Параметры:**
|
||||||
|
|
||||||
|
- `id` (UUID) - ID канала
|
||||||
|
|
||||||
|
**Ответ:** 204 No Content
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. ContentController - Управление контентом
|
||||||
|
|
||||||
|
**Базовый путь:** `/api/smm/content`
|
||||||
|
|
||||||
|
### 3.1 Получить весь контент
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/smm/content
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ответ:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "550e8400-e29b-41d4-a716-446655440002",
|
||||||
|
"campaignId": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"locale": "ru",
|
||||||
|
"topic": "Новогодние скидки",
|
||||||
|
"postDraft": "Специальное предложение на новогодние праздники!",
|
||||||
|
"assetsRefs": "image1.jpg,image2.jpg",
|
||||||
|
"scheduledAt": "2024-01-15T10:00:00Z",
|
||||||
|
"priority": 1,
|
||||||
|
"status": "PENDING_APPROVAL"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 Получить контент по ID
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/smm/content/{id}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Параметры:**
|
||||||
|
|
||||||
|
- `id` (UUID) - ID контента
|
||||||
|
|
||||||
|
**Ответ:** Объект ContentQueueDto
|
||||||
|
|
||||||
|
### 3.3 Создать контент
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/smm/content
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
**Тело запроса:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"campaignId": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"locale": "ru",
|
||||||
|
"topic": "Новогодние скидки",
|
||||||
|
"postDraft": "Специальное предложение на новогодние праздники!",
|
||||||
|
"assetsRefs": "image1.jpg,image2.jpg",
|
||||||
|
"scheduledAt": "2024-01-15T10:00:00Z",
|
||||||
|
"priority": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Валидация:**
|
||||||
|
|
||||||
|
- `campaignId` - обязательное поле, должен существовать
|
||||||
|
- `locale` - обязательное поле, не пустое
|
||||||
|
- `topic` - обязательное поле, не пустое
|
||||||
|
|
||||||
|
**Ответ:** 201 Created, объект ContentQueueDto
|
||||||
|
|
||||||
|
### 3.4 Обновить контент
|
||||||
|
|
||||||
|
```http
|
||||||
|
PUT /api/smm/content/{id}
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
**Параметры:**
|
||||||
|
|
||||||
|
- `id` (UUID) - ID контента
|
||||||
|
|
||||||
|
**Тело запроса:** То же, что и при создании
|
||||||
|
|
||||||
|
**Ответ:** Объект ContentQueueDto
|
||||||
|
|
||||||
|
### 3.5 Удалить контент
|
||||||
|
|
||||||
|
```http
|
||||||
|
DELETE /api/smm/content/{id}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Параметры:**
|
||||||
|
|
||||||
|
- `id` (UUID) - ID контента
|
||||||
|
|
||||||
|
**Ответ:** 204 No Content
|
||||||
|
|
||||||
|
### 3.6 Одобрить контент
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/smm/content/{id}/approve
|
||||||
|
```
|
||||||
|
|
||||||
|
**Параметры:**
|
||||||
|
|
||||||
|
- `id` (UUID) - ID контента
|
||||||
|
|
||||||
|
**Ответ:** Объект ContentQueueDto со статусом APPROVED
|
||||||
|
|
||||||
|
### 3.7 Получить сообщения контента
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/smm/content/{id}/messages
|
||||||
|
```
|
||||||
|
|
||||||
|
**Параметры:**
|
||||||
|
|
||||||
|
- `id` (UUID) - ID контента
|
||||||
|
|
||||||
|
**Ответ:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "550e8400-e29b-41d4-a716-446655440003",
|
||||||
|
"channelId": "550e8400-e29b-41d4-a716-446655440001",
|
||||||
|
"contentId": "550e8400-e29b-41d4-a716-446655440002",
|
||||||
|
"externalId": "12345",
|
||||||
|
"url": "https://t.me/channel/12345",
|
||||||
|
"postedAt": "2024-01-15T10:00:00Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. PublishingController - Публикация контента
|
||||||
|
|
||||||
|
**Базовый путь:** `/api/smm/publishing`
|
||||||
|
|
||||||
|
### 4.1 Опубликовать контент
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/smm/publishing/post/{contentId}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Параметры:**
|
||||||
|
|
||||||
|
- `contentId` (UUID) - ID контента для публикации
|
||||||
|
|
||||||
|
**Ответ:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": "Пост успешно опубликован",
|
||||||
|
"data": {
|
||||||
|
"messageId": "550e8400-e29b-41d4-a716-446655440003",
|
||||||
|
"externalUrl": "https://t.me/channel/12345"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Примечание:** Контент должен быть в статусе APPROVED для публикации.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. AnalyticsController - Аналитика
|
||||||
|
|
||||||
|
**Базовый путь:** `/api/smm/analytics`
|
||||||
|
|
||||||
|
### 5.1 Запустить сбор аналитики
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/smm/analytics/collect-now
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ответ:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": "Сбор аналитики запущен."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Примечание:** Этот эндпоинт предназначен для администраторов и отладки. Обычно аналитика собирается автоматически по расписанию.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Типы данных
|
||||||
|
|
||||||
|
### CampaignStatus
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
enum CampaignStatus {
|
||||||
|
PLANNED = 'PLANNED',
|
||||||
|
ACTIVE = 'ACTIVE',
|
||||||
|
COMPLETED = 'COMPLETED'
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### ChannelType
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
enum ChannelType {
|
||||||
|
TELEGRAM = 'TELEGRAM',
|
||||||
|
VK = 'VK',
|
||||||
|
INSTAGRAM = 'INSTAGRAM'
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### ContentStatus
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
enum ContentStatus {
|
||||||
|
DRAFT = 'DRAFT',
|
||||||
|
PENDING_APPROVAL = 'PENDING_APPROVAL',
|
||||||
|
APPROVED = 'APPROVED',
|
||||||
|
PUBLISHED = 'PUBLISHED',
|
||||||
|
FAILED = 'FAILED'
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Обработка ошибок
|
||||||
|
|
||||||
|
### Стандартные HTTP коды ответов:
|
||||||
|
|
||||||
|
- `200 OK` - Успешный запрос
|
||||||
|
- `201 Created` - Ресурс создан
|
||||||
|
- `204 No Content` - Успешное удаление
|
||||||
|
- `400 Bad Request` - Ошибка валидации
|
||||||
|
- `401 Unauthorized` - Не авторизован
|
||||||
|
- `403 Forbidden` - Нет доступа
|
||||||
|
- `404 Not Found` - Ресурс не найден
|
||||||
|
- `500 Internal Server Error` - Внутренняя ошибка сервера
|
||||||
|
|
||||||
|
### Формат ошибки:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"timestamp": "2024-01-15T10:00:00Z",
|
||||||
|
"status": 400,
|
||||||
|
"error": "Bad Request",
|
||||||
|
"message": "Validation failed",
|
||||||
|
"path": "/api/smm/campaigns"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Примеры использования
|
||||||
|
|
||||||
|
### Создание полного workflow:
|
||||||
|
|
||||||
|
1. **Создать кампанию:**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const campaign = await fetch('/api/smm/campaigns', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: 'Bearer ' + token
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: 'Новогодняя кампания',
|
||||||
|
goal: 'Увеличение продаж',
|
||||||
|
budget: 100000,
|
||||||
|
startAt: '2024-01-01T00:00:00Z',
|
||||||
|
endAt: '2024-01-31T23:59:59Z',
|
||||||
|
status: 'PLANNED'
|
||||||
|
})
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Создать канал:**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const channel = await fetch('/api/smm/channels', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: 'Bearer ' + token
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: 'Основной канал',
|
||||||
|
type: 'TELEGRAM',
|
||||||
|
apiKeyRef: 'telegram_bot_token',
|
||||||
|
isActive: true
|
||||||
|
})
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Создать контент:**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const content = await fetch('/api/smm/content', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: 'Bearer ' + token
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
campaignId: campaign.id,
|
||||||
|
locale: 'ru',
|
||||||
|
topic: 'Новогодние скидки',
|
||||||
|
postDraft: 'Специальное предложение!',
|
||||||
|
scheduledAt: '2024-01-15T10:00:00Z',
|
||||||
|
priority: 1
|
||||||
|
})
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Одобрить контент:**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
await fetch(`/api/smm/content/${content.id}/approve`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: 'Bearer ' + token
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Опубликовать контент:**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const publishResult = await fetch(`/api/smm/publishing/post/${content.id}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: 'Bearer ' + token
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Примечания
|
||||||
|
|
||||||
|
- Все даты передаются в формате ISO 8601 с UTC временной зоной
|
||||||
|
- UUID используются для всех идентификаторов
|
||||||
|
- Все запросы требуют аутентификации через JWT токен
|
||||||
|
- Система автоматически публикует контент по расписанию (если `scheduledAt` указан)
|
||||||
|
- Аналитика собирается автоматически каждый час
|
||||||
Reference in New Issue
Block a user