.
This commit is contained in:
+548
@@ -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
@@ -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` в элементы календаря постов при получении стратегии
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 = {};
|
||||
};
|
||||
|
||||
@@ -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: 'Ошибка',
|
||||
|
||||
Reference in New Issue
Block a user