This commit is contained in:
root
2025-12-05 20:00:11 +05:00
parent 0b074d6281
commit 855a0d595e
9 changed files with 2400 additions and 110 deletions
@@ -0,0 +1,548 @@
# Документация для фронтенда: Генерация изображений для постов
## Обзор изменений
В систему добавлена автоматическая генерация изображений для постов в социальных сетях. Теперь при создании маркетинговой стратегии для каждого поста автоматически генерируется уникальное изображение с помощью OpenAI DALL-E API. Изображения сохраняются в MinIO и прикрепляются к постам при публикации в Facebook.
---
## Новые поля в API
### 1. PostCalendarItem (Календарь постов)
В объекте `PostCalendarItem` добавлены два новых поля для работы с изображениями:
| Поле | Тип | Описание | Обязательное |
| --------------- | ------ | ------------------------------------------------------------ | ------------ |
| `imageUrl` | string | Имя файла изображения (используется для получения через API) | Нет |
| `imageFilename` | string | Имя файла изображения | Нет |
**Важно:**
- Поля могут быть `null`, если генерация изображения не удалась
- В этом случае пост публикуется без изображения
- Оба поля содержат одинаковое значение (имя файла)
- Для получения изображения используйте API эндпоинт `/api/marketing/analysis/images/{imageFilename}`
### 2. PostingTask (Задачи публикации)
В объекте `PostingTask` также добавлены поля для изображений:
| Поле | Тип | Описание | Обязательное |
| --------------- | ------ | ------------------------------------------------------------ | ------------ |
| `imageUrl` | string | Имя файла изображения (используется для получения через API) | Нет |
| `imageFilename` | string | Имя файла изображения | Нет |
---
## Изменения в API эндпоинтах
### GET `/api/marketing/strategy/{strategyId}`
Ответ теперь включает поля изображений в каждом элементе календаря постов.
#### Пример ответа
```json
{
"success": true,
"data": {
"strategyId": "507f1f77bcf86cd799439011",
"analysisId": "507f1f77bcf86cd799439012",
"status": "completed",
"strategy": {
"postCalendar": [
{
"publishDate": "2024-01-15T10:00:00",
"platform": "Facebook",
"contentType": "пост",
"theme": "Презентация нового продукта",
"postText": "Мы рады представить наш новый продукт...",
"hashtags": ["#новинка", "#продукт", "#маркетинг"],
"publishTime": "10:00",
"imageUrl": "post_image_1705312800000_1234567890.png",
"imageFilename": "post_image_1705312800000_1234567890.png"
}
]
}
}
}
```
### GET `/api/marketing/analysis/{analysisId}/strategy`
Аналогично, ответ включает поля изображений в календаре постов.
---
## Получение изображений
### Через бэкенд API
Все изображения должны получаться через бэкенд API. Прямой доступ к MinIO с фронтенда не предусмотрен.
#### Эндпоинт для получения изображения
**GET** `/api/marketing/analysis/images/{imageFilename}`
Возвращает изображение поста в формате PNG.
#### Параметры пути
| Параметр | Тип | Описание |
| --------------- | ------ | --------------------- |
| `imageFilename` | string | Имя файла изображения |
#### Заголовки запроса
| Заголовок | Тип | Обязательный | Описание |
| --------------- | ------ | ------------ | ------------------------------------ |
| `Authorization` | string | Да | JWT токен в формате `Bearer {token}` |
#### Пример запроса
```javascript
const imageUrl = `/api/marketing/analysis/images/${post.imageFilename}`;
fetch(imageUrl, {
headers: {
Authorization: `Bearer ${token}`
}
})
.then((response) => {
if (response.ok) {
return response.blob();
}
throw new Error('Failed to load image');
})
.then((blob) => {
const imageObjectUrl = URL.createObjectURL(blob);
// Используйте imageObjectUrl для отображения
});
```
#### Пример ответа
- **Успешный ответ (200 OK):**
- Content-Type: `image/png`
- Body: бинарные данные изображения PNG
- **Ошибка 401 Unauthorized:**
- Токен отсутствует или невалиден
- **Ошибка 404 Not Found:**
- Изображение не найдено на сервере
- **Ошибка 500 Internal Server Error:**
- Внутренняя ошибка сервера при загрузке изображения
#### Кэширование
Сервер возвращает заголовок `Cache-Control: public, max-age=3600`, что позволяет браузеру кэшировать изображения на 1 час.
---
## Рекомендации по отображению
### 1. Проверка наличия изображения
Всегда проверяйте наличие изображения перед отображением:
```javascript
// Пример на JavaScript/TypeScript
const PostCard = ({ post, apiBaseUrl, authToken }) => {
const hasImage = post.imageUrl && post.imageFilename;
const imageUrl = hasImage ? `${apiBaseUrl}/api/marketing/analysis/images/${post.imageFilename}` : null;
return (
<div className="post-card">
<h3>{post.theme}</h3>
<p>{post.postText}</p>
{imageUrl ? (
<img
src={imageUrl}
alt={post.theme}
onError={(e) => {
// Fallback если изображение не загрузилось
e.target.style.display = 'none';
}}
// Если требуется авторизация, используйте fetch с заголовками
/>
) : (
<div className="no-image-placeholder">Изображение не сгенерировано</div>
)}
<div className="hashtags">
{post.hashtags.map((tag) => (
<span key={tag}>#{tag}</span>
))}
</div>
</div>
);
};
```
### 2. Обработка ошибок загрузки
Всегда предусматривайте fallback для случаев, когда:
- Изображение не сгенерировано (`imageUrl` = `null`)
- Изображение не найдено на сервере
- Ошибка при загрузке изображения
```javascript
const [imageError, setImageError] = useState(false);
const getImageUrl = (imageFilename) => {
if (!imageFilename) return null;
return `${API_BASE_URL}/api/marketing/analysis/images/${imageFilename}`;
};
const handleImageError = () => {
setImageError(true);
};
return (
<>
{post.imageUrl && !imageError ? (
<img src={getImageUrl(post.imageFilename)} alt={post.theme} onError={handleImageError} />
) : (
<div className="image-placeholder">
<Icon name="image" />
<span>Изображение недоступно</span>
</div>
)}
</>
);
```
### 3. Оптимизация загрузки
Рекомендуется использовать lazy loading для изображений:
```javascript
<img src={post.imageUrl ? `${API_BASE_URL}/api/marketing/analysis/images/${post.imageFilename}` : null} alt={post.theme} loading="lazy" className="post-image" />
```
### 4. Размеры изображений
Изображения генерируются в размере **1024x1024 пикселей** (квадратные). При отображении учитывайте это при настройке CSS:
```css
.post-image {
width: 100%;
max-width: 512px;
height: auto;
border-radius: 8px;
object-fit: cover;
}
```
---
## Примеры использования
### React компонент для отображения поста
```typescript
import React, { useState } from 'react';
interface PostCalendarItem {
publishDate: string;
platform: string;
contentType: string;
theme: string;
postText: string;
hashtags: string[];
publishTime: string;
imageUrl?: string | null;
imageFilename?: string | null;
}
interface PostCardProps {
post: PostCalendarItem;
apiBaseUrl: string;
}
const PostCard: React.FC<PostCardProps> = ({ post, apiBaseUrl }) => {
const [imageError, setImageError] = useState(false);
const getImageUrl = (): string | null => {
if (!post.imageFilename) return null;
return `${apiBaseUrl}/api/marketing/analysis/images/${post.imageFilename}`;
};
const imageUrl = getImageUrl();
return (
<div className='post-card'>
<div className='post-header'>
<span className='platform-badge'>{post.platform}</span>
<span className='content-type'>{post.contentType}</span>
<span className='publish-time'>{post.publishTime}</span>
</div>
<h3 className='post-theme'>{post.theme}</h3>
{imageUrl && !imageError ? (
<div className='post-image-container'>
<img
src={imageUrl}
alt={post.theme}
className='post-image'
loading='lazy'
onError={() => setImageError(true)}
/>
</div>
) : (
<div className='no-image-placeholder'>
<svg width='64' height='64' viewBox='0 0 24 24' fill='none'>
<path
d='M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z'
fill='currentColor'
/>
</svg>
<p>Изображение не сгенерировано</p>
</div>
)}
<p className='post-text'>{post.postText}</p>
<div className='post-hashtags'>
{post.hashtags.map((tag, index) => (
<span key={index} className='hashtag'>
{tag.startsWith('#') ? tag : `#${tag}`}
</span>
))}
</div>
<div className='post-footer'>
<span className='publish-date'>
{new Date(post.publishDate).toLocaleDateString('ru-RU')}
</span>
</div>
</div>
);
};
export default PostCard;
```
### Vue компонент
```vue
<template>
<div class="post-card">
<div class="post-header">
<span class="platform-badge">{{ post.platform }}</span>
<span class="content-type">{{ post.contentType }}</span>
<span class="publish-time">{{ post.publishTime }}</span>
</div>
<h3 class="post-theme">{{ post.theme }}</h3>
<div v-if="imageUrl && !imageError" class="post-image-container">
<img :src="imageUrl" :alt="post.theme" class="post-image" loading="lazy" @error="imageError = true" />
</div>
<div v-else class="no-image-placeholder">
<Icon name="image" />
<p>Изображение не сгенерировано</p>
</div>
<p class="post-text">{{ post.postText }}</p>
<div class="post-hashtags">
<span v-for="(tag, index) in post.hashtags" :key="index" class="hashtag">
{{ tag.startsWith('#') ? tag : `#${tag}` }}
</span>
</div>
<div class="post-footer">
<span class="publish-date">
{{ formatDate(post.publishDate) }}
</span>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
interface PostCalendarItem {
publishDate: string;
platform: string;
contentType: string;
theme: string;
postText: string;
hashtags: string[];
publishTime: string;
imageUrl?: string | null;
imageFilename?: string | null;
}
const props = defineProps<{
post: PostCalendarItem;
apiBaseUrl: string;
}>();
const imageError = ref(false);
const imageUrl = computed(() => {
if (!props.post.imageFilename) return null;
return `${props.apiBaseUrl}/api/marketing/analysis/images/${props.post.imageFilename}`;
});
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('ru-RU');
};
</script>
```
---
## Важные замечания
### 1. Генерация изображений
- Изображения генерируются **автоматически** при создании стратегии
- Процесс генерации может занять время (обычно 10-30 секунд на изображение)
- Если генерация не удалась, пост все равно будет создан, но без изображения
### 2. Хранение изображений
- Все изображения хранятся на бэкенде
- Формат изображений: **PNG**
- Размер изображений: **1024x1024 пикселей**
- Имя файла уникально для каждого поста
- Доступ к изображениям только через API эндпоинт
### 3. Публикация постов
- При публикации поста в Facebook изображение автоматически прикрепляется
- Если изображение отсутствует, пост публикуется только с текстом
- Это не влияет на успешность публикации
### 4. Обратная совместимость
- Старые стратегии, созданные до добавления этой функции, не будут иметь изображений
- Поля `imageUrl` и `imageFilename` будут `null` для таких постов
- Фронтенд должен корректно обрабатывать `null` значения
---
## Конфигурация
Для работы с изображениями вам понадобится базовый URL вашего API:
```typescript
// config.ts
export const API_CONFIG = {
baseUrl: 'http://your-backend-url' // URL вашего бэкенда
// Например: 'http://localhost:8080' или 'https://api.example.com'
};
```
---
## Миграция существующего кода
Если у вас уже есть компоненты для отображения постов, обновите их следующим образом:
1. **Добавьте проверку наличия изображения:**
```typescript
const hasImage = post.imageUrl && post.imageFilename;
```
2. **Добавьте отображение изображения:**
```jsx
{
hasImage && <img src={`${API_BASE_URL}/api/marketing/analysis/images/${post.imageFilename}`} alt={post.theme} />;
}
```
3. **Обновите типы/интерфейсы:**
```typescript
interface PostCalendarItem {
// ... существующие поля
imageUrl?: string | null;
imageFilename?: string | null;
}
```
---
## Поддержка
При возникновении проблем:
1. Проверьте, что `imageUrl` и `imageFilename` не `null`
2. Убедитесь, что используете правильный API эндпоинт: `/api/marketing/analysis/images/{imageFilename}`
3. Проверьте, что JWT токен валиден и передается в заголовке `Authorization`
4. Проверьте консоль браузера на наличие ошибок сети или авторизации
5. Убедитесь, что используете правильный базовый URL API
---
## Пример полного ответа API
```json
{
"success": true,
"data": {
"strategyId": "507f1f77bcf86cd799439011",
"analysisId": "507f1f77bcf86cd799439012",
"status": "completed",
"createdAt": "2024-01-15T10:00:00",
"completedAt": "2024-01-15T10:05:00",
"durationWeeks": 4,
"priorityPlatforms": ["Facebook", "Instagram"],
"strategy": {
"weeklyPlans": [
{
"weekNumber": 1,
"mainThemes": ["Презентация продукта", "Преимущества"],
"contentRecommendations": "Создавайте контент...",
"priorityPlatforms": ["Facebook"]
}
],
"postCalendar": [
{
"publishDate": "2024-01-16T10:00:00",
"platform": "Facebook",
"contentType": "пост",
"theme": "Презентация нового продукта",
"postText": "Мы рады представить наш новый продукт, который поможет вам...",
"hashtags": ["#новинка", "#продукт", "#маркетинг"],
"publishTime": "10:00",
"imageUrl": "post_image_1705312800000_1234567890.png",
"imageFilename": "post_image_1705312800000_1234567890.png"
},
{
"publishDate": "2024-01-18T14:00:00",
"platform": "Instagram",
"contentType": "сторис",
"theme": "Преимущества продукта",
"postText": "Узнайте о главных преимуществах нашего продукта...",
"hashtags": ["#преимущества", "#качество"],
"publishTime": "14:00",
"imageUrl": null,
"imageFilename": null
}
]
}
}
}
```
Обратите внимание, что второй пост не имеет изображения (`imageUrl` и `imageFilename` равны `null`). Это нормальная ситуация, если генерация изображения не удалась.
---
**Дата обновления:** 2024-01-15
**Версия API:** 1.0
File diff suppressed because it is too large Load Diff
+286
View File
@@ -0,0 +1,286 @@
# API Документация: Управление задачами публикации (Frontend/AI Agent)
## Базовый URL
```
https://api.konturai.kz
```
## Обзор
API для управления задачами публикации постов в социальных сетях. Позволяет запускать задачи публикации вручную, не дожидаясь времени выполнения через шедулер.
**Важно**:
- Для выполнения задачи требуются настроенные credentials для соответствующей платформы
- Задачи создаются автоматически при запуске стратегии через `/api/marketing/analysis/strategy/{strategyId}/start`
- Каждая задача связана с элементом календаря постов в стратегии
---
## Изменения в существующих эндпоинтах
### Обновление: Получение стратегии по ID анализа
**GET** `/api/marketing/analysis/{analysisId}/strategy`
Теперь каждый элемент в `postCalendar` содержит поле `taskId`, если задача была создана для этого элемента.
#### Пример ответа (обновленный формат)
```json
{
"success": true,
"data": {
"strategyId": "507f1f77bcf86cd799439012",
"analysisId": "507f1f77bcf86cd799439011",
"status": "completed",
"strategy": {
"postCalendar": [
{
"publishDate": "2025-01-25T10:00:00",
"platform": "Facebook",
"contentType": "пост",
"theme": "Презентация продукта",
"postText": "Добро пожаловать в наш новый продукт!",
"hashtags": ["#маркетинг", "#бизнес"],
"publishTime": "10:00",
"imageUrl": "post_image_1234567890.png",
"imageFilename": "post_image_1234567890.png",
"taskId": "507f1f77bcf86cd799439013"
}
]
}
}
}
```
**Новое поле:**
- `taskId` (string, опциональное) - ID задачи публикации, если задача была создана. Может быть `null`, если стратегия еще не была запущена.
---
## Новые эндпоинты
### 1. Ручной запуск задачи публикации
**POST** `/api/marketing/analysis/tasks/{taskId}/execute`
Запускает задачу публикации немедленно, не дожидаясь времени публикации через шедулер. Позволяет выполнить задачу вручную или повторить выполнение неудачной задачи.
#### Параметры запроса
| Параметр | Тип | Расположение | Обязательный | Описание |
| -------- | ------ | ------------ | ------------ | -------------------- |
| `taskId` | string | Path | ✅ | ID задачи публикации |
#### Заголовки запроса
```
Authorization: Bearer <your-jwt-token>
Content-Type: application/json
```
#### Пример запроса
```http
POST /api/marketing/analysis/tasks/507f1f77bcf86cd799439013/execute
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
Content-Type: application/json
```
#### Пример успешного ответа (200 OK)
```json
{
"success": true,
"message": "Задача успешно запущена",
"data": {
"taskId": "507f1f77bcf86cd799439013",
"status": "processing",
"platform": "Facebook",
"publishDate": "2025-01-25T10:00:00"
}
}
```
#### Описание полей ответа
| Поле | Тип | Описание |
| ------------- | ------ | ----------------------------------------------------- |
| `taskId` | string | ID задачи публикации |
| `status` | string | Статус задачи: `processing`, `completed` или `failed` |
| `platform` | string | Платформа для публикации (например, `Facebook`) |
| `publishDate` | string | Дата и время публикации в формате ISO 8601 |
#### Ошибки
**401 Unauthorized** - Требуется аутентификация
```json
{
"success": false,
"message": "Не авторизован",
"error": {
"code": "UNAUTHORIZED",
"message": "Требуется аутентификация. Пожалуйста, предоставьте валидный JWT токен."
}
}
```
**403 Forbidden** - Пользователь не является владельцем задачи
```json
{
"success": false,
"message": "Доступ запрещен",
"error": {
"code": "FORBIDDEN",
"message": "У вас нет доступа к этой задаче"
}
}
```
**404 Not Found** - Задача не найдена
```json
{
"success": false,
"message": "Задача не найдена",
"error": {
"code": "NOT_FOUND",
"message": "Задача с указанным ID не найдена"
}
}
```
**400 Bad Request** - Задача не может быть запущена
```json
{
"success": false,
"message": "Задача не может быть запущена",
"error": {
"code": "INVALID_STATUS",
"message": "Task cannot be executed manually. Current status: completed. Only tasks with status 'pending' or 'failed' can be executed manually."
}
}
```
#### Правила выполнения
1. **Статусы задач:**
- `pending` - задача ожидает выполнения (может быть запущена вручную)
- `failed` - задача завершилась с ошибкой (может быть запущена повторно вручную)
- `processing` - задача выполняется (не может быть запущена повторно)
- `completed` - задача успешно выполнена (не может быть запущена повторно)
2. **Повторное выполнение:**
- Задачи со статусом `failed` автоматически сбрасываются на `pending` перед повторным выполнением
- Ошибка из предыдущего выполнения очищается
3. **Асинхронное выполнение:**
- Задача запускается асинхронно
- Ответ возвращается сразу после начала выполнения
- Для проверки статуса задачи используйте соответствующие эндпоинты (если доступны)
#### Примеры использования
**Пример 1: Запуск задачи, которая еще не была выполнена**
```javascript
// Получаем стратегию
const strategyResponse = await fetch(
`/api/marketing/analysis/${analysisId}/strategy`,
{
headers: {
Authorization: `Bearer ${token}`,
},
}
);
const strategy = await strategyResponse.json();
const taskId = strategy.data.strategy.postCalendar[0].taskId;
// Запускаем задачу вручную
const executeResponse = await fetch(
`/api/marketing/analysis/tasks/${taskId}/execute`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
}
);
const result = await executeResponse.json();
console.log('Задача запущена:', result.data);
```
**Пример 2: Повторное выполнение неудачной задачи**
```javascript
// Если задача завершилась с ошибкой (status: "failed")
// можно повторить её выполнение
const retryResponse = await fetch(
`/api/marketing/analysis/tasks/${failedTaskId}/execute`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
}
);
const retryResult = await retryResponse.json();
if (retryResult.success) {
console.log('Повторная попытка запущена:', retryResult.data);
}
```
---
## Интеграция с существующим workflow
### Типичный сценарий использования
1. **Создание анализа**`POST /api/marketing/analysis/start`
2. **Генерация стратегии**`POST /api/marketing/analysis/strategy/generate`
3. **Получение стратегии**`GET /api/marketing/analysis/{analysisId}/strategy`
- Теперь содержит `taskId` для каждого элемента календаря
4. **Запуск стратегии**`POST /api/marketing/analysis/strategy/{strategyId}/start`
- Создает задачи публикации для всех элементов календаря
5. **Ручной запуск задачи** (опционально) → `POST /api/marketing/analysis/tasks/{taskId}/execute`
- Запускает задачу немедленно, не дожидаясь времени публикации
### Когда использовать ручной запуск
- **Тестирование**: Проверить публикацию поста перед запланированным временем
- **Повторная попытка**: Повторить выполнение задачи, которая завершилась с ошибкой
- **Срочная публикация**: Опубликовать пост раньше запланированного времени
- **Отладка**: Проверить работу системы публикации
---
## Примечания
1. **Аутентификация**: Все эндпоинты требуют валидный JWT токен в заголовке `Authorization`
2. **Права доступа**: Пользователь может запускать только свои собственные задачи
3. **Статусы задач**: Проверяйте статус задачи перед попыткой ручного запуска
4. **Асинхронность**: Выполнение задачи происходит асинхронно, ответ возвращается сразу
5. **Ошибки выполнения**: Если задача завершится с ошибкой, её можно запустить повторно
---
## Версия API
- **Версия документа**: 1.0
- **Дата обновления**: 2025-01-20
- **Изменения**:
- Добавлен эндпоинт для ручного запуска задач публикации
- Добавлено поле `taskId` в элементы календаря постов при получении стратегии
+2
View File
@@ -64,6 +64,8 @@ export const API_CONFIG = {
MARKETING_STRATEGY_MY: '/api/marketing/analysis/strategy/my',
MARKETING_STRATEGY_HISTORY: '/api/marketing/analysis/strategy',
MARKETING_STRATEGY_START: '/api/marketing/analysis/strategy',
MARKETING_IMAGE_GET: '/api/marketing/analysis/images',
MARKETING_TASK_EXECUTE: '/api/marketing/analysis/tasks',
// SocialMediaCredentialsController - Управление credentials социальных сетей
SOCIAL_MEDIA_CREDENTIALS: '/api/social-media/credentials',
+93 -11
View File
@@ -10,14 +10,17 @@ class MarketingService {
* @param {Object} data - Данные для анализа
* @param {string} data.businessNiche - Ниша бизнеса
* @param {string} data.product - Название продукта или услуги
* @param {string} data.targetAudience - Целевая аудитория (детальное описание)
* @param {string} data.region - Регион (город Казахстана)
* @param {Object} data.targetAudience - Целевая аудитория (объект с полями genders, ageRanges, types)
* @param {string[]} data.targetAudience.genders - Массив гендеров ('Женщины', 'Мужчины')
* @param {string[]} data.targetAudience.ageRanges - Массив возрастных диапазонов ('20-40', '25-45', '18-25', '40-60', '60+')
* @param {string[]} data.targetAudience.types - Массив типов ('Семьи', 'Молодёжь', 'Все подряд')
* @param {string[]} data.region - Массив регионов (города Казахстана)
* @param {string} data.goal - Цель на 6-12 месяцев
* @param {string} data.detailLevel - Уровень детализации анализа (КРАТКО, СТАНДАРТНО, ПОДРОБНО)
* @param {string} data.analysisType - Тип анализа (РЫНОК, КОНКУРЕНТЫ, ЦА, КАНАЛЫ, SWOT)
* @param {string[]} data.analysisType - Массив типов анализа (РЫНОК, КОНКУРЕНТЫ, ЦА, КАНАЛЫ, SWOT)
* @param {string} [data.strongSide] - Сильная сторона бизнеса (опционально)
* @param {string} [data.weakSide] - Слабая сторона бизнеса (опционально)
* @returns {Promise<Object>} Объект с analysisId и статусом
* @returns {Promise<Object|Array>} Объект с analysisId и статусом (или массив объектов при множественном выборе типов анализа)
*/
async startAnalysis(data) {
try {
@@ -55,6 +58,7 @@ class MarketingService {
throw new Error(result?.message || 'Ошибка при запуске анализа');
}
// Возвращаем data, который может быть объектом или массивом
return result.data || null;
} catch (error) {
console.error('Ошибка при запуске маркетингового анализа:', error);
@@ -305,7 +309,7 @@ class MarketingService {
throw new Error(result.message || 'Ошибка при получении списка стратегий');
}
return result.data;
return result.data || [];
} catch (error) {
console.error('Ошибка при получении списка стратегий:', error);
throw error;
@@ -357,18 +361,18 @@ class MarketingService {
if (!response.ok) {
const error = {
message: result.message || result.error?.message || 'Ошибка при запуске стратегии',
code: result.error?.code,
details: result.error?.details
message: result.data?.message || result.message || result.error?.message || 'Ошибка при запуске стратегии',
code: result.data?.code || result.error?.code,
details: result.data?.details || result.error?.details
};
throw error;
}
if (!result.success) {
const error = {
message: result.message || 'Ошибка при запуске стратегии',
code: result.error?.code,
details: result.error?.details
message: result.data?.message || result.message || 'Ошибка при запуске стратегии',
code: result.data?.code || result.error?.code,
details: result.data?.details || result.error?.details
};
throw error;
}
@@ -478,6 +482,43 @@ class MarketingService {
}
}
/**
* Получение URL изображения поста
* GET /api/marketing/analysis/images/{imageFilename}
* @param {string} imageFilename - Имя файла изображения
* @returns {string} URL изображения через бэкенд API
*/
getImageUrl(imageFilename) {
if (!imageFilename) return null;
return `${API_BASE_URL}${API_CONFIG.ENDPOINTS.MARKETING_IMAGE_GET}/${imageFilename}`;
}
/**
* Загрузка изображения поста с авторизацией
* GET /api/marketing/analysis/images/{imageFilename}
* @param {string} imageFilename - Имя файла изображения
* @returns {Promise<string>} Promise с blob URL изображения
*/
async loadImageAsBlobUrl(imageFilename) {
if (!imageFilename) return null;
try {
const imageUrl = this.getImageUrl(imageFilename);
const response = await AuthService.authFetch(imageUrl, {
method: 'GET'
});
if (!response.ok) {
throw new Error('Failed to load image');
}
const blob = await response.blob();
return URL.createObjectURL(blob);
} catch (error) {
console.error('Ошибка при загрузке изображения:', error);
return null;
}
}
/**
* Удаление credentials для платформы
* DELETE /api/social-media/credentials/{platform}
@@ -516,6 +557,47 @@ class MarketingService {
throw error;
}
}
/**
* Ручной запуск задачи публикации
* POST /api/marketing/analysis/tasks/{taskId}/execute
* @param {string} taskId - ID задачи публикации
* @returns {Promise<Object>} Объект с информацией о запуске задачи (taskId, status, platform, publishDate)
*/
async executeTask(taskId) {
try {
const response = await AuthService.authFetch(`${API_BASE_URL}${API_CONFIG.ENDPOINTS.MARKETING_TASK_EXECUTE}/${taskId}/execute`, {
method: 'POST',
...DEFAULT_REQUEST_CONFIG,
body: JSON.stringify({})
});
const result = await response.json();
if (!response.ok) {
const error = {
message: result.message || result.error?.message || 'Ошибка при запуске задачи',
code: result.error?.code,
details: result.error?.details
};
throw error;
}
if (!result.success) {
const error = {
message: result.message || 'Ошибка при запуске задачи',
code: result.error?.code,
details: result.error?.details
};
throw error;
}
return result.data;
} catch (error) {
console.error('Ошибка при запуске задачи публикации:', error);
throw error;
}
}
}
export default new MarketingService();
@@ -57,7 +57,9 @@
<Column field="analysisType" header="Тип анализа" :sortable="true" style="min-width: 180px">
<template #body="slotProps">
<Tag v-if="slotProps.data.analysisType" :value="getAnalysisTypeLabel(slotProps.data.analysisType)" severity="secondary" />
<div v-if="slotProps.data.analysisType" class="flex flex-wrap gap-1">
<Tag v-for="(type, index) in Array.isArray(slotProps.data.analysisType) ? slotProps.data.analysisType : [slotProps.data.analysisType]" :key="index" :value="getAnalysisTypeLabel(type)" severity="secondary" />
</div>
<span v-else class="text-surface-400"></span>
</template>
</Column>
+169 -47
View File
@@ -22,15 +22,60 @@
</div>
<div class="field">
<label for="targetAudience" class="block text-sm font-medium mb-2 text-surface-700 dark:text-surface-300"> Целевая аудитория * </label>
<Textarea id="targetAudience" v-model="formData.targetAudience" placeholder="Опишите вашу целевую аудиторию детально" rows="3" class="w-full" :class="{ 'p-invalid': errors.targetAudience }" />
<label class="block text-sm font-medium mb-2 text-surface-700 dark:text-surface-300"> Целевая аудитория * </label>
<!-- Гендер -->
<div class="mb-3">
<label class="block text-sm font-medium mb-2 text-surface-600 dark:text-surface-400">Гендер:</label>
<div class="flex gap-4 flex-wrap">
<div v-for="gender in VALID_GENDERS" :key="gender" class="flex align-items-center">
<Checkbox :inputId="`gender-${gender}`" :value="gender" v-model="formData.targetAudience.genders" />
<label :for="`gender-${gender}`" class="ml-2 cursor-pointer">{{ gender }}</label>
</div>
</div>
</div>
<!-- Возраст -->
<div class="mb-3">
<label class="block text-sm font-medium mb-2 text-surface-600 dark:text-surface-400">Возраст:</label>
<div class="flex gap-4 flex-wrap">
<div v-for="age in VALID_AGE_RANGES" :key="age" class="flex align-items-center">
<Checkbox :inputId="`age-${age}`" :value="age" v-model="formData.targetAudience.ageRanges" />
<label :for="`age-${age}`" class="ml-2 cursor-pointer">{{ age }}</label>
</div>
</div>
</div>
<!-- Тип -->
<div class="mb-3">
<label class="block text-sm font-medium mb-2 text-surface-600 dark:text-surface-400">Тип:</label>
<div class="flex gap-4 flex-wrap">
<div v-for="type in VALID_AUDIENCE_TYPES" :key="type" class="flex align-items-center">
<Checkbox :inputId="`type-${type}`" :value="type" v-model="formData.targetAudience.types" />
<label :for="`type-${type}`" class="ml-2 cursor-pointer">{{ type }}</label>
</div>
</div>
</div>
<!-- Отображение выбранных значений -->
<div v-if="formData.targetAudience.genders.length > 0 || formData.targetAudience.ageRanges.length > 0 || formData.targetAudience.types.length > 0" class="mt-2 p-2 border-round bg-surface-100 dark:bg-surface-800">
<small class="text-surface-600 dark:text-surface-400">
<strong>Выбрано:</strong>
<span v-if="formData.targetAudience.genders.length > 0"> Гендер: {{ formData.targetAudience.genders.join(', ') }}</span>
<span v-if="formData.targetAudience.ageRanges.length > 0"> Возраст: {{ formData.targetAudience.ageRanges.join(', ') }}</span>
<span v-if="formData.targetAudience.types.length > 0"> Тип: {{ formData.targetAudience.types.join(', ') }}</span>
</small>
</div>
<small v-if="errors.targetAudience" class="p-error">{{ errors.targetAudience }}</small>
<small class="text-surface-500 dark:text-surface-400 mt-1 block">Выберите хотя бы одну опцию для целевой аудитории</small>
</div>
<div class="field">
<label for="region" class="block text-sm font-medium mb-2 text-surface-700 dark:text-surface-300"> Регион (город Казахстана) * </label>
<Dropdown id="region" v-model="formData.region" :options="VALID_REGIONS" placeholder="Выберите город" class="w-full" :class="{ 'p-invalid': errors.region }" />
<MultiSelect id="region" v-model="formData.region" :options="VALID_REGIONS" placeholder="Выберите регионы" class="w-full" display="chip" :class="{ 'p-invalid': errors.region }" />
<small v-if="errors.region" class="p-error">{{ errors.region }}</small>
<small class="text-surface-500 dark:text-surface-400 mt-1 block">Выберите один или несколько регионов</small>
</div>
<div class="field">
@@ -67,18 +112,21 @@
<div class="field">
<label for="analysisType" class="block text-sm font-medium mb-2 text-surface-700 dark:text-surface-300"> Тип анализа * </label>
<Dropdown
id="analysisType"
v-model="formData.analysisType"
:options="analysisTypeOptions"
optionLabel="label"
optionValue="value"
placeholder="Выберите тип анализа"
class="w-full"
:class="{ 'p-invalid': errors.analysisType }"
/>
<MultiSelect id="analysisType" v-model="formData.analysisType" :options="VALID_ANALYSIS_TYPES" placeholder="Выберите типы анализа" class="w-full" display="chip" :class="{ 'p-invalid': errors.analysisType }">
<template #value="slotProps">
<div v-if="slotProps.value && slotProps.value.length > 0" class="flex flex-wrap gap-2">
<Tag v-for="type in slotProps.value" :key="type" :value="getAnalysisTypeLabel(type)" severity="info" />
</div>
<span v-else class="text-surface-400">{{ slotProps.placeholder }}</span>
</template>
<template #option="slotProps">
<div class="flex align-items-center">
<span>{{ getAnalysisTypeLabel(slotProps.option) }}</span>
</div>
</template>
</MultiSelect>
<small v-if="errors.analysisType" class="p-error">{{ errors.analysisType }}</small>
<small class="text-surface-500 dark:text-surface-400 mt-1 block">Выберите тип анализа, который будет сгенерирован</small>
<small class="text-surface-500 dark:text-surface-400 mt-1 block">Выберите один или несколько типов анализа. При выборе нескольких типов будут созданы отдельные анализы для каждого типа.</small>
</div>
<Button type="submit" label="Начать анализ" icon="pi pi-send" class="w-full p-button-primary" :loading="submitting" />
@@ -226,8 +274,9 @@
import MarketingService from '@/service/MarketingService';
import { marked } from 'marked';
import Button from 'primevue/button';
import Dropdown from 'primevue/dropdown';
import Checkbox from 'primevue/checkbox';
import InputText from 'primevue/inputtext';
import MultiSelect from 'primevue/multiselect';
import ProgressSpinner from 'primevue/progressspinner';
import RadioButton from 'primevue/radiobutton';
import Tag from 'primevue/tag';
@@ -265,19 +314,28 @@ const VALID_REGIONS = [
'Рудный'
];
const VALID_GENDERS = ['Женщины', 'Мужчины'];
const VALID_AGE_RANGES = ['20-40', '25-45', '18-25', '40-60', '60+'];
const VALID_AUDIENCE_TYPES = ['Семьи', 'Молодёжь', 'Все подряд'];
const VALID_ANALYSIS_TYPES = ['РЫНОК', 'КОНКУРЕНТЫ', 'ЦА', 'КАНАЛЫ', 'SWOT'];
const DETAIL_LEVELS = ['КРАТКО', 'СТАНДАРТНО', 'ПОДРОБНО'];
// Form data
const formData = ref({
businessNiche: '',
product: '',
targetAudience: '',
region: null,
targetAudience: {
genders: [],
ageRanges: [],
types: []
},
region: [],
goal: '',
detailLevel: 'СТАНДАРТНО',
strongSide: '',
weakSide: '',
analysisType: null
analysisType: []
});
const analysisTypeOptions = [
@@ -322,17 +380,42 @@ const validateForm = () => {
}
// TargetAudience validation
if (!formData.value.targetAudience || formData.value.targetAudience.trim().length < 3) {
errors.value.targetAudience = 'Целевая аудитория должна содержать минимум 3 символа';
} else if (formData.value.targetAudience.trim().length > 300) {
errors.value.targetAudience = 'Целевая аудитория не должна превышать 300 символов';
const hasGenders = formData.value.targetAudience.genders && formData.value.targetAudience.genders.length > 0;
const hasAgeRanges = formData.value.targetAudience.ageRanges && formData.value.targetAudience.ageRanges.length > 0;
const hasTypes = formData.value.targetAudience.types && formData.value.targetAudience.types.length > 0;
if (!hasGenders && !hasAgeRanges && !hasTypes) {
errors.value.targetAudience = 'Выберите хотя бы одну опцию для целевой аудитории (гендер, возраст или тип)';
} else {
// Валидация значений
if (formData.value.targetAudience.genders) {
const invalidGenders = formData.value.targetAudience.genders.filter((g) => !VALID_GENDERS.includes(g));
if (invalidGenders.length > 0) {
errors.value.targetAudience = `Недопустимые значения гендера: ${invalidGenders.join(', ')}`;
}
}
if (formData.value.targetAudience.ageRanges) {
const invalidAges = formData.value.targetAudience.ageRanges.filter((a) => !VALID_AGE_RANGES.includes(a));
if (invalidAges.length > 0) {
errors.value.targetAudience = `Недопустимые значения возраста: ${invalidAges.join(', ')}`;
}
}
if (formData.value.targetAudience.types) {
const invalidTypes = formData.value.targetAudience.types.filter((t) => !VALID_AUDIENCE_TYPES.includes(t));
if (invalidTypes.length > 0) {
errors.value.targetAudience = `Недопустимые значения типа: ${invalidTypes.join(', ')}`;
}
}
}
// Region validation
if (!formData.value.region) {
errors.value.region = 'Пожалуйста, выберите регион';
} else if (!VALID_REGIONS.includes(formData.value.region)) {
errors.value.region = 'Выбран недопустимый регион';
if (!formData.value.region || formData.value.region.length === 0) {
errors.value.region = 'Выберите хотя бы один регион';
} else {
const invalidRegions = formData.value.region.filter((r) => !VALID_REGIONS.includes(r));
if (invalidRegions.length > 0) {
errors.value.region = `Недопустимые регионы: ${invalidRegions.join(', ')}`;
}
}
// Goal validation
@@ -358,12 +441,12 @@ const validateForm = () => {
}
// AnalysisType validation
if (!formData.value.analysisType) {
errors.value.analysisType = 'Пожалуйста, выберите тип анализа';
if (!formData.value.analysisType || formData.value.analysisType.length === 0) {
errors.value.analysisType = 'Выберите хотя бы один тип анализа';
} else {
const validTypes = ['РЫНОК', 'КОНКУРЕНТЫ', 'ЦА', 'КАНАЛЫ', 'SWOT'];
if (!validTypes.includes(formData.value.analysisType)) {
errors.value.analysisType = 'Выбран недопустимый тип анализа';
const invalidTypes = formData.value.analysisType.filter((t) => !VALID_ANALYSIS_TYPES.includes(t));
if (invalidTypes.length > 0) {
errors.value.analysisType = `Недопустимые типы анализа: ${invalidTypes.join(', ')}`;
}
}
@@ -443,10 +526,22 @@ const handleSubmit = async () => {
submitting.value = true;
try {
// Формируем объект targetAudience (удаляем пустые массивы)
const targetAudience = {};
if (formData.value.targetAudience.genders && formData.value.targetAudience.genders.length > 0) {
targetAudience.genders = formData.value.targetAudience.genders;
}
if (formData.value.targetAudience.ageRanges && formData.value.targetAudience.ageRanges.length > 0) {
targetAudience.ageRanges = formData.value.targetAudience.ageRanges;
}
if (formData.value.targetAudience.types && formData.value.targetAudience.types.length > 0) {
targetAudience.types = formData.value.targetAudience.types;
}
const requestData = {
businessNiche: formData.value.businessNiche.trim(),
product: formData.value.product.trim(),
targetAudience: formData.value.targetAudience.trim(),
targetAudience: targetAudience,
region: formData.value.region,
goal: formData.value.goal.trim(),
detailLevel: formData.value.detailLevel,
@@ -463,20 +558,43 @@ const handleSubmit = async () => {
const result = await MarketingService.startAnalysis(requestData);
analysisId.value = result.analysisId;
status.value = result.status;
currentAnalysisType.value = formData.value.analysisType;
estimatedCompletionTime.value = result.estimatedCompletionTime;
// Обработка ответа: может быть массив или один объект
if (Array.isArray(result)) {
// Множественные анализы
const firstAnalysis = result[0];
analysisId.value = firstAnalysis.analysisId;
status.value = firstAnalysis.status;
estimatedCompletionTime.value = firstAnalysis.estimatedCompletion;
toast.add({
severity: 'success',
summary: 'Анализ запущен',
detail: result.message || 'Анализ успешно запущен. Результаты будут готовы в течение 5-10 минут.',
life: 5000
});
// Сохраняем типы анализов
currentAnalysisType.value = formData.value.analysisType.length === 1 ? formData.value.analysisType[0] : null;
// Start polling
startPolling(result.analysisId);
toast.add({
severity: 'success',
summary: 'Анализы запущены',
detail: `Создано ${result.length} анализов. Они будут обработаны параллельно.`,
life: 5000
});
// Запускаем polling для первого анализа (можно расширить для всех)
startPolling(firstAnalysis.analysisId);
} else {
// Один анализ
analysisId.value = result.analysisId;
status.value = result.status;
currentAnalysisType.value = formData.value.analysisType.length === 1 ? formData.value.analysisType[0] : null;
estimatedCompletionTime.value = result.estimatedCompletion;
toast.add({
severity: 'success',
summary: 'Анализ запущен',
detail: result.message || 'Анализ успешно запущен. Результаты будут готовы в течение 5-10 минут.',
life: 5000
});
// Start polling
startPolling(result.analysisId);
}
} catch (error) {
console.error('Ошибка при запуске анализа:', error);
toast.add({
@@ -529,13 +647,17 @@ const resetAnalysis = () => {
formData.value = {
businessNiche: '',
product: '',
targetAudience: '',
region: null,
targetAudience: {
genders: [],
ageRanges: [],
types: []
},
region: [],
goal: '',
detailLevel: 'СТАНДАРТНО',
strongSide: '',
weakSide: '',
analysisType: null
analysisType: []
};
errors.value = {};
};
+234 -36
View File
@@ -78,14 +78,7 @@
<p class="text-sm text-surface-500 dark:text-surface-400 mt-1">Длительность: {{ strategyData.durationWeeks }} {{ pluralize(strategyData.durationWeeks, 'неделя', 'недели', 'недель') }}</p>
</div>
<div class="flex gap-2">
<Button
label="Запустить стратегию"
icon="pi pi-play"
severity="success"
@click="handleStartStrategy"
:loading="startingStrategy"
:disabled="startingStrategy"
/>
<Button label="Запустить стратегию" icon="pi pi-play" severity="success" @click="handleStartStrategy" :loading="startingStrategy" :disabled="startingStrategy" />
<Button label="Создать новую стратегию" icon="pi pi-plus" severity="secondary" @click="resetStrategy" />
</div>
</div>
@@ -160,6 +153,7 @@
<div class="flex gap-2 flex-wrap">
<Tag :value="post.platform" severity="info" />
<Tag :value="post.contentType" severity="secondary" />
<Tag v-if="post.taskId" :value="`Задача: ${post.taskId.substring(0, 8)}...`" severity="success" class="text-xs" />
</div>
<span class="text-sm text-surface-500 dark:text-surface-400">{{ post.publishTime }}</span>
</div>
@@ -167,6 +161,18 @@
<p class="text-sm font-medium text-surface-600 dark:text-surface-400 mb-1">Тема:</p>
<p class="text-sm font-semibold text-surface-900 dark:text-surface-0">{{ post.theme }}</p>
</div>
<!-- Изображение поста -->
<div v-if="hasImage(post) && !hasImageError(`${date}-${idx}`)" class="mb-3">
<img :src="getImageUrl(post)" :alt="post.theme" class="post-image" loading="lazy" @error="handleImageError(`${date}-${idx}`)" />
</div>
<div v-else-if="hasImage(post) && hasImageError(`${date}-${idx}`)" class="mb-3 no-image-placeholder">
<i class="pi pi-image text-4xl text-surface-400 dark:text-surface-600"></i>
<p class="text-xs text-surface-500 dark:text-surface-400 mt-2">Изображение недоступно</p>
</div>
<div v-else class="mb-3 no-image-placeholder">
<i class="pi pi-image text-4xl text-surface-400 dark:text-surface-600"></i>
<p class="text-xs text-surface-500 dark:text-surface-400 mt-2">Изображение не сгенерировано</p>
</div>
<div class="mb-3">
<p class="text-sm font-medium text-surface-600 dark:text-surface-400 mb-1">Текст поста:</p>
<p class="text-sm text-surface-700 dark:text-surface-300 whitespace-pre-line line-height-3">{{ post.postText }}</p>
@@ -181,6 +187,17 @@
</div>
<div class="flex gap-2">
<Button label="Копировать текст" icon="pi pi-copy" size="small" severity="secondary" class="flex-1" @click="handleCopyPost(post)" />
<Button
v-if="post.taskId"
label="Запустить"
icon="pi pi-play"
size="small"
severity="success"
class="flex-1"
:loading="isTaskExecuting(post.taskId)"
:disabled="isTaskExecuting(post.taskId)"
@click="handleExecuteTask(post.taskId, post)"
/>
</div>
</div>
</div>
@@ -196,6 +213,7 @@
<div class="flex gap-2 flex-wrap">
<Tag :value="post.platform" severity="info" />
<Tag :value="post.contentType" severity="secondary" />
<Tag v-if="post.taskId" :value="`Задача: ${post.taskId.substring(0, 8)}...`" severity="success" class="text-xs" />
</div>
<span class="text-sm text-surface-500 dark:text-surface-400">{{ post.publishTime }}</span>
</div>
@@ -203,6 +221,18 @@
<p class="text-sm font-medium text-surface-600 dark:text-surface-400 mb-1">Тема:</p>
<p class="text-sm font-semibold text-surface-900 dark:text-surface-0">{{ post.theme }}</p>
</div>
<!-- Изображение поста -->
<div v-if="hasImage(post) && !hasImageError(idx)" class="mb-3">
<img :src="getImageUrl(post)" :alt="post.theme" class="post-image" loading="lazy" @error="handleImageError(idx)" />
</div>
<div v-else-if="hasImage(post) && hasImageError(idx)" class="mb-3 no-image-placeholder">
<i class="pi pi-image text-4xl text-surface-400 dark:text-surface-600"></i>
<p class="text-xs text-surface-500 dark:text-surface-400 mt-2">Изображение недоступно</p>
</div>
<div v-else class="mb-3 no-image-placeholder">
<i class="pi pi-image text-4xl text-surface-400 dark:text-surface-600"></i>
<p class="text-xs text-surface-500 dark:text-surface-400 mt-2">Изображение не сгенерировано</p>
</div>
<div class="mb-3">
<p class="text-sm font-medium text-surface-600 dark:text-surface-400 mb-1">Текст поста:</p>
<p class="text-sm text-surface-700 dark:text-surface-300 whitespace-pre-line line-height-3">{{ post.postText }}</p>
@@ -217,6 +247,17 @@
</div>
<div class="flex gap-2">
<Button label="Копировать текст" icon="pi pi-copy" size="small" severity="secondary" class="flex-1" @click="handleCopyPost(post)" />
<Button
v-if="post.taskId"
label="Запустить"
icon="pi pi-play"
size="small"
severity="success"
class="flex-1"
:loading="isTaskExecuting(post.taskId)"
:disabled="isTaskExecuting(post.taskId)"
@click="handleExecuteTask(post.taskId, post)"
/>
</div>
</div>
</div>
@@ -227,37 +268,17 @@
</div>
<!-- Диалог для настройки credentials -->
<Dialog
v-model:visible="credentialsDialogVisible"
:header="'Настройка credentials'"
:style="{ width: '600px' }"
:modal="true"
>
<Dialog v-model:visible="credentialsDialogVisible" :header="'Настройка credentials'" :style="{ width: '600px' }" :modal="true">
<div class="credentials-dialog-content">
<p class="text-surface-700 dark:text-surface-300 mb-4">
Для запуска стратегии необходимо настроить credentials для следующих платформ:
</p>
<p class="text-surface-700 dark:text-surface-300 mb-4">Для запуска стратегии необходимо настроить credentials для следующих платформ:</p>
<div class="mb-4">
<Tag
v-for="platform in missingPlatforms"
:key="platform"
:value="platform"
severity="warning"
class="mr-2 mb-2"
/>
<Tag v-for="platform in missingPlatforms" :key="platform" :value="platform" severity="warning" class="mr-2 mb-2" />
</div>
<p class="text-surface-600 dark:text-surface-400 text-sm">
Перейдите на страницу управления credentials для настройки необходимых платформ.
</p>
<p class="text-surface-600 dark:text-surface-400 text-sm">Перейдите на страницу управления credentials для настройки необходимых платформ.</p>
</div>
<template #footer>
<Button label="Отмена" icon="pi pi-times" @click="credentialsDialogVisible = false" severity="secondary" />
<Button
label="Настроить credentials"
icon="pi pi-cog"
@click="goToCredentials"
severity="info"
/>
<Button label="Настроить credentials" icon="pi pi-cog" @click="goToCredentials" severity="info" />
</template>
</Dialog>
</div>
@@ -305,6 +326,9 @@ const groupByDate = ref(true);
const startingStrategy = ref(false);
const credentialsDialogVisible = ref(false);
const missingPlatforms = ref([]);
const imageErrors = ref(new Set()); // Track image load errors by post index
const imageBlobUrls = ref({}); // Cache blob URLs for images with auth (reactive object)
const executingTasks = ref(new Set()); // Track which tasks are currently being executed
// Computed
const postsByDate = computed(() => {
@@ -320,6 +344,57 @@ const postsByDate = computed(() => {
}, {});
});
// Get image URL for a post (with auth via blob URL)
const getImageUrl = (post) => {
if (!post?.imageFilename) return null;
return imageBlobUrls.value[post.imageFilename] || null;
};
// Preload all images when strategy data is loaded
const preloadImages = async () => {
if (!strategyData.value?.strategy?.postCalendar) return;
const imagePromises = strategyData.value.strategy.postCalendar
.filter((post) => post?.imageFilename)
.map(async (post) => {
if (!imageBlobUrls.value[post.imageFilename]) {
try {
const blobUrl = await MarketingService.loadImageAsBlobUrl(post.imageFilename);
if (blobUrl) {
imageBlobUrls.value[post.imageFilename] = blobUrl;
}
} catch (error) {
console.error(`Ошибка при предзагрузке изображения ${post.imageFilename}:`, error);
}
}
});
await Promise.all(imagePromises);
};
// Cleanup blob URLs
const cleanupBlobUrls = () => {
Object.values(imageBlobUrls.value).forEach((blobUrl) => {
URL.revokeObjectURL(blobUrl);
});
imageBlobUrls.value = {};
};
// Check if post has image
const hasImage = (post) => {
return post?.imageUrl && post?.imageFilename;
};
// Check if image has error
const hasImageError = (postIndex) => {
return imageErrors.value.has(postIndex);
};
// Handle image error
const handleImageError = (postIndex) => {
imageErrors.value.add(postIndex);
};
// Validation
const validateForm = () => {
errors.value = {};
@@ -359,6 +434,9 @@ const startPolling = (id) => {
if (result.status === 'completed' && result.strategy) {
stopPolling();
strategyData.value = result;
imageErrors.value.clear(); // Reset image errors when new strategy is loaded
cleanupBlobUrls(); // Cleanup old blob URLs
await preloadImages(); // Preload all images with auth
toast.add({
severity: 'success',
summary: 'Стратегия готова',
@@ -446,6 +524,9 @@ const resetStrategy = () => {
status.value = null;
strategyData.value = null;
pollingAttempts.value = 0;
imageErrors.value.clear(); // Clear image errors when resetting
cleanupBlobUrls(); // Cleanup blob URLs when resetting
executingTasks.value.clear(); // Clear executing tasks when resetting
formData.value = {
analysisId: '',
durationWeeks: 4,
@@ -477,6 +558,81 @@ const handleCopyPost = (post) => {
});
};
// Handle execute task
const handleExecuteTask = async (taskId, post) => {
if (!taskId) {
toast.add({
severity: 'warn',
summary: 'Ошибка',
detail: 'ID задачи не найден',
life: 3000
});
return;
}
// Add task to executing set
executingTasks.value.add(taskId);
try {
const result = await MarketingService.executeTask(taskId);
toast.add({
severity: 'success',
summary: 'Задача запущена',
detail: `Задача публикации успешно запущена. Платформа: ${result.platform || post?.platform || 'N/A'}`,
life: 5000
});
} catch (error) {
console.error('Ошибка при запуске задачи:', error);
// Handle specific error codes
if (error.code === 'UNAUTHORIZED') {
toast.add({
severity: 'error',
summary: 'Ошибка авторизации',
detail: 'Необходимо войти в систему',
life: 5000
});
} else if (error.code === 'FORBIDDEN') {
toast.add({
severity: 'error',
summary: 'Доступ запрещен',
detail: 'У вас нет доступа к этой задаче',
life: 5000
});
} else if (error.code === 'NOT_FOUND') {
toast.add({
severity: 'error',
summary: 'Задача не найдена',
detail: 'Задача с указанным ID не найдена',
life: 5000
});
} else if (error.code === 'INVALID_STATUS') {
toast.add({
severity: 'warn',
summary: 'Задача не может быть запущена',
detail: error.message || 'Задача не может быть выполнена в текущем статусе. Только задачи со статусом "pending" или "failed" могут быть запущены вручную.',
life: 5000
});
} else {
toast.add({
severity: 'error',
summary: 'Ошибка',
detail: error.message || 'Не удалось запустить задачу. Попробуйте позже.',
life: 5000
});
}
} finally {
// Remove task from executing set
executingTasks.value.delete(taskId);
}
};
// Check if task is executing
const isTaskExecuting = (taskId) => {
return taskId ? executingTasks.value.has(taskId) : false;
};
// Export to CSV
const exportToCsv = () => {
if (!strategyData.value?.strategy?.postCalendar) return;
@@ -528,7 +684,7 @@ const handleStartStrategy = async () => {
try {
const result = await MarketingService.startStrategy(strategyId.value);
toast.add({
severity: 'success',
summary: 'Стратегия запущена',
@@ -537,16 +693,24 @@ const handleStartStrategy = async () => {
});
} catch (error) {
console.error('Ошибка при запуске стратегии:', error);
// Handle specific error codes
if (error.code === 'MISSING_CREDENTIALS') {
// Show error message in toast
toast.add({
severity: 'error',
summary: 'Ошибка',
detail: error.message || 'Не удалось запустить стратегию',
life: 5000
});
// Extract platform from error message (format: "Credentials not found for platform: facebook")
const platformMatch = error.message?.match(/platform:\s*(\w+)/i) || error.message?.match(/for platform\s+(\w+)/i);
if (platformMatch) {
missingPlatforms.value = [platformMatch[1].toLowerCase()];
} else {
// Try to get platforms from strategy data
const platforms = strategyData.value?.priorityPlatforms || strategyData.value?.strategy?.postCalendar?.map(p => p.platform).filter((v, i, a) => a.indexOf(v) === i) || [];
const platforms = strategyData.value?.priorityPlatforms || strategyData.value?.strategy?.postCalendar?.map((p) => p.platform).filter((v, i, a) => a.indexOf(v) === i) || [];
missingPlatforms.value = platforms.length > 0 ? platforms : ['facebook']; // Default to facebook if unknown
}
credentialsDialogVisible.value = true;
@@ -638,6 +802,8 @@ onMounted(async () => {
status.value = result.status;
if (result.status === 'completed' && result.strategy) {
strategyData.value = result;
cleanupBlobUrls(); // Cleanup old blob URLs
await preloadImages(); // Preload all images with auth
} else if (result.status === 'processing' || result.status === 'queued') {
startPolling(result.strategyId);
}
@@ -653,6 +819,8 @@ onMounted(async () => {
status.value = result.status;
if (result.status === 'completed' && result.strategy) {
strategyData.value = result;
cleanupBlobUrls(); // Cleanup old blob URLs
await preloadImages(); // Preload all images with auth
} else if (result.status === 'processing' || result.status === 'queued') {
startPolling(result.strategyId);
}
@@ -666,6 +834,8 @@ onMounted(async () => {
// Cleanup on unmount
onBeforeUnmount(() => {
stopPolling();
cleanupBlobUrls(); // Cleanup blob URLs on unmount
executingTasks.value.clear(); // Clear executing tasks on unmount
});
</script>
@@ -722,4 +892,32 @@ onBeforeUnmount(() => {
padding-bottom: 0;
margin-bottom: 0;
}
.post-image {
width: 100%;
max-width: 512px;
height: auto;
border-radius: 8px;
object-fit: cover;
display: block;
margin: 0 auto;
}
.no-image-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem;
background: var(--surface-50);
border: 1px dashed var(--surface-300);
border-radius: 8px;
min-height: 200px;
text-align: center;
}
.dark .no-image-placeholder {
background: var(--surface-800);
border-color: var(--surface-700);
}
</style>
@@ -18,8 +18,7 @@
:rowsPerPageOptions="[5, 10, 20, 50]"
sortMode="multiple"
removableSort
:sortField="'createdAt'"
:sortOrder="-1"
v-model:multiSortMeta="multiSortMeta"
filterDisplay="row"
:globalFilterFields="['analysisId', 'status']"
v-model:filters="filters"
@@ -36,18 +35,21 @@
</div>
</template>
<Column field="analysisId" header="ID анализа" :sortable="true" style="min-width: 200px">
<Column field="analysisId" header="ID анализа" :sortable="true" style="min-width: 200px" :showFilterMenu="false">
<template #body="slotProps">
<span class="font-mono text-sm">{{ slotProps.data.analysisId }}</span>
</template>
<template #filter>
<span></span>
</template>
</Column>
<Column field="status" header="Статус" :sortable="true" style="min-width: 120px">
<Column field="status" header="Статус" :sortable="true" style="min-width: 120px" :showFilterMenu="false">
<template #body="slotProps">
<Tag :value="getStatusLabel(slotProps.data.status)" :severity="getStatusSeverity(slotProps.data.status)" />
</template>
<template #filter="{ filterModel, filterCallback }">
<Dropdown v-model="filterModel.value" :options="statusOptions" placeholder="Все статусы" class="p-column-filter" @change="filterCallback()">
<Dropdown v-if="filterModel" v-model="filterModel.value" :options="statusOptions" optionLabel="label" optionValue="value" placeholder="Все статусы" class="p-column-filter" @change="filterCallback()">
<template #option="slotProps">
<Tag :value="slotProps.option.label" :severity="slotProps.option.severity" />
</template>
@@ -55,41 +57,56 @@
</template>
</Column>
<Column field="durationWeeks" header="Длительность" :sortable="true" style="min-width: 120px">
<Column field="durationWeeks" header="Длительность" :sortable="true" style="min-width: 120px" :showFilterMenu="false">
<template #body="slotProps">
<span>{{ slotProps.data.durationWeeks }} {{ pluralize(slotProps.data.durationWeeks, 'неделя', 'недели', 'недель') }}</span>
</template>
<template #filter>
<span></span>
</template>
</Column>
<Column field="priorityPlatforms" header="Платформы" style="min-width: 200px">
<Column field="priorityPlatforms" header="Платформы" style="min-width: 200px" :showFilterMenu="false">
<template #body="slotProps">
<div v-if="slotProps.data.priorityPlatforms && slotProps.data.priorityPlatforms.length > 0" class="flex flex-wrap gap-1">
<Tag v-for="platform in slotProps.data.priorityPlatforms" :key="platform" :value="platform" severity="secondary" />
</div>
<span v-else class="text-surface-400"></span>
</template>
</Column>
<Column field="createdAt" header="Создана" :sortable="true" style="min-width: 180px">
<template #body="slotProps">
<span>{{ formatDateTime(slotProps.data.createdAt) }}</span>
<template #filter>
<span></span>
</template>
</Column>
<Column field="completedAt" header="Завершена" :sortable="true" style="min-width: 180px">
<Column field="createdAt" header="Создана" :sortable="true" style="min-width: 180px" :showFilterMenu="false">
<template #body="slotProps">
<span>{{ formatDateTime(slotProps.data.createdAt) }}</span>
</template>
<template #filter>
<span></span>
</template>
</Column>
<Column field="completedAt" header="Завершена" :sortable="true" style="min-width: 180px" :showFilterMenu="false">
<template #body="slotProps">
<span v-if="slotProps.data.completedAt">{{ formatDateTime(slotProps.data.completedAt) }}</span>
<span v-else class="text-surface-400"></span>
</template>
<template #filter>
<span></span>
</template>
</Column>
<Column header="Действия" style="min-width: 200px">
<Column header="Действия" style="min-width: 200px" :showFilterMenu="false" :exportable="false">
<template #body="slotProps">
<div class="flex gap-2 flex-wrap">
<Button icon="pi pi-eye" severity="info" size="small" v-tooltip.top="'Просмотр деталей'" @click="viewStrategy(slotProps.data.strategyId)" :disabled="slotProps.data.status !== 'completed'" />
<Button icon="pi pi-history" severity="secondary" size="small" v-tooltip.top="'История статусов'" @click="viewHistory(slotProps.data.strategyId)" />
</div>
</template>
<template #filter>
<span></span>
</template>
</Column>
</DataTable>
</div>
@@ -161,6 +178,9 @@ const filters = ref({
status: { value: null, matchMode: FilterMatchMode.EQUALS }
});
// Multi-sort meta (for multiple column sorting)
const multiSortMeta = ref([{ field: 'createdAt', order: -1 }]);
// Status options for filter
const statusOptions = [
{ label: 'В очереди', value: 'queued', severity: 'info' },
@@ -174,9 +194,11 @@ const loadStrategies = async () => {
loading.value = true;
try {
const data = await MarketingService.getMyStrategies();
strategies.value = data || [];
// Ensure data is always an array
strategies.value = Array.isArray(data) ? data : [];
} catch (error) {
console.error('Ошибка при загрузке списка стратегий:', error);
strategies.value = []; // Reset to empty array on error
toast.add({
severity: 'error',
summary: 'Ошибка',