fix
This commit is contained in:
-548
@@ -1,548 +0,0 @@
|
||||
# Документация для фронтенда: Генерация изображений для постов
|
||||
|
||||
## Обзор изменений
|
||||
|
||||
В систему добавлена автоматическая генерация изображений для постов в социальных сетях. Теперь при создании маркетинговой стратегии для каждого поста автоматически генерируется уникальное изображение с помощью 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
|
||||
@@ -28,7 +28,7 @@
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex items-end justify-between gap-3">
|
||||
<div class="text-[38px] leading-none font-semibold tracking-tight text-slate-900">
|
||||
<div class="text-[30px] leading-none font-semibold tracking-tight text-slate-900">
|
||||
{{ value }}
|
||||
</div>
|
||||
<div v-if="$slots.right" class="shrink-0">
|
||||
@@ -59,9 +59,9 @@ function hexToRgba(hex, alpha) {
|
||||
const full =
|
||||
h.length === 3
|
||||
? h
|
||||
.split('')
|
||||
.map((c) => c + c)
|
||||
.join('')
|
||||
.split('')
|
||||
.map((c) => c + c)
|
||||
.join('')
|
||||
: h;
|
||||
if (full.length !== 6) return `rgba(91, 137, 255, ${alpha})`;
|
||||
const r = parseInt(full.slice(0, 2), 16);
|
||||
|
||||
@@ -140,7 +140,18 @@ class MarketingService {
|
||||
|
||||
// v2: извлекаем data из обёртки {success, message, data}, если она есть
|
||||
// для обратной совместимости поддерживаем оба формата
|
||||
return result.data || result;
|
||||
let extracted = result.data || result;
|
||||
|
||||
// Убеждаемся, что sources сохраняются, если они есть в исходных данных
|
||||
if (extracted && !extracted.sources) {
|
||||
if (result.data?.sources) {
|
||||
extracted.sources = result.data.sources;
|
||||
} else if (result.sources) {
|
||||
extracted.sources = result.sources;
|
||||
}
|
||||
}
|
||||
|
||||
return extracted;
|
||||
} catch (error) {
|
||||
console.error('Ошибка при запуске маркетингового анализа v2:', error);
|
||||
throw error;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -110,6 +110,13 @@
|
||||
<small v-if="errors.weakSide" class="p-error">{{ errors.weakSide }}</small>
|
||||
</div>
|
||||
|
||||
<!-- <div class="field">
|
||||
<label for="marketingBudget" class="block text-sm font-medium mb-2 text-surface-700 dark:text-surface-300"> Бюджет на маркетинг </label>
|
||||
<InputNumber id="marketingBudget" v-model="formData.marketingBudget" :min="0" placeholder="Введите бюджет в тенге" class="w-full" :class="{ 'p-invalid': errors.marketingBudget }" />
|
||||
<small v-if="errors.marketingBudget" class="p-error">{{ errors.marketingBudget }}</small>
|
||||
<small class="text-surface-500 dark:text-surface-400 mt-1 block">Укажите ваш бюджет на маркетинг (опционально)</small>
|
||||
</div> -->
|
||||
|
||||
<div class="field">
|
||||
<label for="analysisType" class="block text-sm font-medium mb-2 text-surface-700 dark:text-surface-300"> Тип анализа * </label>
|
||||
<MultiSelect id="analysisType" v-model="formData.analysisType" :options="VALID_ANALYSIS_TYPES" placeholder="Выберите типы анализа" class="w-full" display="chip" :class="{ 'p-invalid': errors.analysisType }">
|
||||
@@ -441,6 +448,171 @@
|
||||
<HeatmapTable :matrix="segment.matrix" :title="segment.title || 'Матрица каналов по сегментам'" />
|
||||
</div>
|
||||
|
||||
<!-- New Competitive Environment Component -->
|
||||
<div v-else-if="segment.type === 'competitiveEnvironment'">
|
||||
<div class="mb-6">
|
||||
<h3 class="text-2xl font-semibold text-surface-900 dark:text-surface-0 mb-4">
|
||||
<i class="pi pi-chart-line mr-2 text-primary-500"></i>{{ segment.title || 'Конкурентная среда' }}
|
||||
</h3>
|
||||
|
||||
<!-- Metrics KPI Cards -->
|
||||
<div v-if="segment.metrics" class="grid mb-6">
|
||||
<div class="col-12 md:col-3 mb-3">
|
||||
<div class="p-4 border-round bg-surface-0 dark:bg-surface-800 border-1 border-surface-200 dark:border-surface-700 h-full">
|
||||
<div class="flex align-items-center mb-2">
|
||||
<i class="pi pi-sitemap text-primary-500 mr-2 text-xl"></i>
|
||||
<span class="text-sm font-medium text-surface-600 dark:text-surface-400">Доля лидера</span>
|
||||
</div>
|
||||
<div class="text-2xl font-bold text-surface-900 dark:text-surface-0">
|
||||
{{ segment.metrics.leader_market_share || '—' }}
|
||||
</div>
|
||||
<div v-if="segment.metrics.market_leader_name" class="text-xs text-surface-500 dark:text-surface-400 mt-1">
|
||||
{{ segment.metrics.market_leader_name }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 md:col-3 mb-3">
|
||||
<div class="p-4 border-round bg-surface-0 dark:bg-surface-800 border-1 border-surface-200 dark:border-surface-700 h-full">
|
||||
<div class="flex align-items-center mb-2">
|
||||
<i class="pi pi-chart-bar text-green-500 mr-2 text-xl"></i>
|
||||
<span class="text-sm font-medium text-surface-600 dark:text-surface-400">Уровень конкуренции</span>
|
||||
</div>
|
||||
<div class="text-2xl font-bold text-surface-900 dark:text-surface-0">
|
||||
{{ segment.metrics.competition_level || '—' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 md:col-3 mb-3">
|
||||
<div class="p-4 border-round bg-surface-0 dark:bg-surface-800 border-1 border-surface-200 dark:border-surface-700 h-full">
|
||||
<div class="flex align-items-center mb-2">
|
||||
<i class="pi pi-wallet text-orange-500 mr-2 text-xl"></i>
|
||||
<span class="text-sm font-medium text-surface-600 dark:text-surface-400">Давление рекламы</span>
|
||||
</div>
|
||||
<div class="text-2xl font-bold text-surface-900 dark:text-surface-0">
|
||||
{{ segment.metrics.ad_pressure || '—' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Visibility Chart -->
|
||||
<div v-if="segment.visibilityChart" class="mb-6">
|
||||
<div class="flex align-items-center mb-3">
|
||||
<i class="pi pi-chart-bar text-primary-500 mr-2"></i>
|
||||
<h4 class="text-lg font-semibold text-surface-700 dark:text-surface-300 m-0">{{ segment.visibilityChart.title }}</h4>
|
||||
</div>
|
||||
<div class="p-4 border-round bg-surface-0 dark:bg-surface-800 border-1 border-surface-200 dark:border-surface-700">
|
||||
<div class="chart-container" :style="segment.visibilityChart.chartHeight">
|
||||
<Chart
|
||||
v-if="hasRenderableChartData(segment.visibilityChart.data)"
|
||||
:type="segment.visibilityChart.chartType"
|
||||
:data="segment.visibilityChart.data"
|
||||
:options="segment.visibilityChart.options"
|
||||
@chart:click="onChartClick"
|
||||
@chart:hover="onChartHover"
|
||||
ref="visibilityChartRef"
|
||||
/>
|
||||
<div v-else class="p-3 border-round text-center text-surface-500 dark:text-surface-400">
|
||||
Нет данных для отображения
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Competitors Table -->
|
||||
<div v-if="segment.competitors && segment.competitors.length > 0" class="mb-6">
|
||||
<div class="flex align-items-center mb-3">
|
||||
<i class="pi pi-table text-primary-500 mr-2"></i>
|
||||
<h4 class="text-lg font-semibold text-surface-700 dark:text-surface-300 m-0">Таблица конкурентов</h4>
|
||||
</div>
|
||||
<div class="overflow-x-auto border-round" style="border: 1px solid var(--surface-border)">
|
||||
<table class="competitive-environment-table w-full border-collapse">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="competitive-env-th">Название</th>
|
||||
<th class="competitive-env-th">Сайт</th>
|
||||
<th class="competitive-env-th text-center">Видимость</th>
|
||||
<th class="competitive-env-th">Ключевые преимущества</th>
|
||||
<th class="competitive-env-th text-center">Ценовой сегмент</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="(competitor, idx) in segment.competitors"
|
||||
:key="competitor.name || idx"
|
||||
:class="['competitive-env-tr', { 'competitive-env-tr-highlighted': highlightedRowIndex === idx }]"
|
||||
@mouseenter="highlightRow(idx)"
|
||||
@mouseleave="highlightRow(null)"
|
||||
@click="handleRowClick(competitor)"
|
||||
>
|
||||
<td class="competitive-env-td font-semibold">
|
||||
<i class="pi pi-building text-primary-500 mr-2"></i>{{ competitor.name }}
|
||||
</td>
|
||||
<td class="competitive-env-td">
|
||||
<a
|
||||
v-if="competitor.website"
|
||||
:href="getFullUrl(competitor.website)"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-primary-500 hover:text-primary-700 dark:hover:text-primary-400 flex align-items-center"
|
||||
@click.stop
|
||||
>
|
||||
<i class="pi pi-external-link mr-1"></i>
|
||||
<span class="text-sm">{{ formatWebsite(competitor.website) }}</span>
|
||||
</a>
|
||||
<span v-else class="text-surface-400">—</span>
|
||||
</td>
|
||||
<td class="competitive-env-td text-center">
|
||||
<div class="flex align-items-center justify-content-center">
|
||||
<span
|
||||
class="competitive-env-score-badge"
|
||||
:class="getScoreClass(competitor.visibility_score)"
|
||||
>
|
||||
{{ competitor.visibility_score || 0 }}%
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="competitive-env-td">
|
||||
<div v-if="competitor.key_strengths && competitor.key_strengths.length > 0" class="flex flex-wrap gap-1">
|
||||
<Tag
|
||||
v-for="(strength, sIdx) in competitor.key_strengths"
|
||||
:key="sIdx"
|
||||
:value="strength"
|
||||
severity="success"
|
||||
class="text-xs"
|
||||
/>
|
||||
</div>
|
||||
<span v-else class="text-surface-400">—</span>
|
||||
</td>
|
||||
<td class="competitive-env-td text-center">
|
||||
<Tag
|
||||
v-if="competitor.price_segment"
|
||||
:value="competitor.price_segment"
|
||||
:severity="getPriceSegmentSeverity(competitor.price_segment)"
|
||||
/>
|
||||
<span v-else class="text-surface-400">—</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Conclusion -->
|
||||
<div v-if="segment.conclusion" class="mt-6">
|
||||
<div class="p-4 border-round bg-primary-50 dark:bg-primary-900 border-1 border-primary-200 dark:border-primary-700">
|
||||
<div class="flex align-items-start">
|
||||
<i class="pi pi-info-circle text-primary-500 mr-2 mt-1 flex-shrink-0"></i>
|
||||
<div>
|
||||
<h4 class="text-lg font-semibold text-primary-700 dark:text-primary-300 mb-2">Заключение</h4>
|
||||
<p class="text-surface-700 dark:text-surface-300 m-0">{{ segment.conclusion }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="segment.type === 'competitors'">
|
||||
<div class="mb-4">
|
||||
<h3 class="text-xl font-semibold text-surface-900 dark:text-surface-0"><i class="pi pi-chart-line mr-2"></i>Анализ конкурентов</h3>
|
||||
@@ -775,14 +947,14 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Кнопки действий (всегда видны) -->
|
||||
<div class="card">
|
||||
<div class="card-content text-center">
|
||||
<div class="flex gap-3 justify-content-center flex-wrap">
|
||||
<Button label="Создать стратегию на основе анализа" icon="pi pi-magic" severity="success" @click="goToStrategy" />
|
||||
<Button label="Создать новый анализ" icon="pi pi-plus" severity="secondary" @click="resetAnalysis" />
|
||||
</div>
|
||||
<!-- Кнопки действий (всегда видны, когда есть анализ) -->
|
||||
<div v-if="(status === 'completed' && report) || v2ReportNormalized" class="card">
|
||||
<div class="card-content text-center">
|
||||
<div class="flex gap-3 justify-content-center flex-wrap">
|
||||
<Button label="Создать стратегию на основе анализа" icon="pi pi-magic" severity="success" @click="goToStrategy" />
|
||||
<Button label="Создать новый анализ" icon="pi pi-plus" severity="secondary" @click="resetAnalysis" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -862,6 +1034,7 @@ const formData = ref({
|
||||
detailLevel: 'СТАНДАРТНО',
|
||||
strongSide: '',
|
||||
weakSide: '',
|
||||
marketingBudget: null,
|
||||
analysisType: []
|
||||
});
|
||||
|
||||
@@ -928,7 +1101,8 @@ const v2ReportNormalized = computed(() => {
|
||||
marketing_economics: pick('marketingEconomics', 'marketing_economics'),
|
||||
bottlenecks: pick('bottlenecks', 'bottlenecks'),
|
||||
recommendations: pick('recommendations', 'recommendations')
|
||||
}
|
||||
},
|
||||
sources: root.sources || []
|
||||
};
|
||||
});
|
||||
|
||||
@@ -957,16 +1131,16 @@ const drawBarValues = (chart) => {
|
||||
// Горизонтальный: цифра справа темная
|
||||
ctx.fillStyle = document.documentElement.classList.contains('dark') ? '#ffffff' : '#1f2937';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText(label, bar.x + 8, bar.y);
|
||||
ctx.fillText(label, bar.x + 8, bar.y);
|
||||
} else {
|
||||
// === ВЕРТИКАЛЬНЫЙ (КАК НА ФОТО) ===
|
||||
const barHeight = Math.abs(bar.base - bar.y);
|
||||
|
||||
|
||||
// Если столбец достаточно высокий (>25px), рисуем цифру ВНУТРИ белым цветом
|
||||
if (barHeight > 25) {
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillStyle = '#ffffff';
|
||||
// bar.y - это верхушка столбца. Добавляем 15px, чтобы спустить цифру чуть ниже верха
|
||||
ctx.fillText(label, bar.x, bar.y + 15);
|
||||
ctx.fillText(label, bar.x, bar.y + 15);
|
||||
} else {
|
||||
// Если столбец мелкий, рисуем цифру СВЕРХУ темным цветом
|
||||
ctx.fillStyle = document.documentElement.classList.contains('dark') ? '#ffffff' : '#1f2937';
|
||||
@@ -991,7 +1165,7 @@ const photoStyleBarOptions = {
|
||||
},
|
||||
plugins: {
|
||||
legend: { display: false }, // Легенда не нужна
|
||||
tooltip: {
|
||||
tooltip: {
|
||||
enabled: true,
|
||||
callbacks: { label: (ctx) => `${ctx.formattedValue}%` }
|
||||
}
|
||||
@@ -1003,7 +1177,7 @@ const photoStyleBarOptions = {
|
||||
grid: { display: true, drawBorder: false, color: 'rgba(0,0,0,0.05)' },
|
||||
ticks: { font: { size: 10 } }
|
||||
},
|
||||
x: {
|
||||
x: {
|
||||
grid: { display: false }, // Вертикальные линии убираем
|
||||
ticks: {
|
||||
font: { size: 12, weight: '600' },
|
||||
@@ -1025,8 +1199,8 @@ const horizontalBarChartOptions = {
|
||||
},
|
||||
plugins: { legend: { display: false } },
|
||||
scales: {
|
||||
x: { display: false, beginAtZero: true },
|
||||
y: {
|
||||
x: { display: false, beginAtZero: true },
|
||||
y: {
|
||||
grid: { display: false },
|
||||
ticks: { font: { size: 12, weight: '500' }, crossAlign: 'far' }
|
||||
}
|
||||
@@ -1058,8 +1232,8 @@ const conversionFunnelChartOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: { legend: { display: true, position: 'bottom' } },
|
||||
scales: {
|
||||
y: { beginAtZero: true, title: { display: true, text: 'Пользователи' } }
|
||||
scales: {
|
||||
y: { beginAtZero: true, title: { display: true, text: 'Пользователи' } }
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1313,6 +1487,9 @@ const handleSubmit = async () => {
|
||||
if (formData.value.weakSide && formData.value.weakSide.trim()) {
|
||||
requestData.weakSide = formData.value.weakSide.trim();
|
||||
}
|
||||
if (formData.value.marketingBudget !== null && formData.value.marketingBudget !== undefined) {
|
||||
requestData.marketingExpenses = formData.value.marketingBudget;
|
||||
}
|
||||
|
||||
const result = await MarketingService.startAnalysis(requestData, { generateV2: true });
|
||||
|
||||
@@ -1946,6 +2123,104 @@ const buildCompetitorsBarChartData = (competitors, metricType = 'reach') => {
|
||||
};
|
||||
};
|
||||
|
||||
// Build competitors visibility score chart data (horizontal bar chart)
|
||||
const buildCompetitorsVisibilityChartData = (competitors) => {
|
||||
if (!Array.isArray(competitors) || competitors.length === 0) return null;
|
||||
|
||||
// Sort by visibility_score descending for better visualization
|
||||
const sortedCompetitors = [...competitors].sort((a, b) => (b.visibility_score || 0) - (a.visibility_score || 0));
|
||||
|
||||
return {
|
||||
labels: sortedCompetitors.map((comp) => comp.name),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Видимость (score)',
|
||||
data: sortedCompetitors.map((comp) => comp.visibility_score || 0),
|
||||
backgroundColor: sortedCompetitors.map((comp) => {
|
||||
const score = comp.visibility_score || 0;
|
||||
// Color coding: high (green), medium (yellow), low (red)
|
||||
if (score >= 70) return '#10B981'; // green-500
|
||||
if (score >= 40) return '#F59E0B'; // amber-500
|
||||
return '#EF4444'; // red-500
|
||||
}),
|
||||
borderColor: '#ffffff',
|
||||
borderWidth: 2,
|
||||
borderRadius: 4
|
||||
}
|
||||
]
|
||||
};
|
||||
};
|
||||
|
||||
// Process competitive environment data into segment format
|
||||
const processCompetitiveEnvironmentData = (competitiveEnv) => {
|
||||
if (!competitiveEnv || typeof competitiveEnv !== 'object') return null;
|
||||
|
||||
const competitors = competitiveEnv.competitors || [];
|
||||
if (!Array.isArray(competitors) || competitors.length === 0) return null;
|
||||
|
||||
// Build visibility chart data
|
||||
const visibilityChartData = buildCompetitorsVisibilityChartData(competitors);
|
||||
|
||||
return {
|
||||
type: 'competitiveEnvironment',
|
||||
title: competitiveEnv.title || 'Конкурентная среда',
|
||||
metrics: competitiveEnv.metrics || {},
|
||||
competitors: competitors,
|
||||
visibilityChart: visibilityChartData
|
||||
? {
|
||||
chartType: 'bar',
|
||||
data: visibilityChartData,
|
||||
options: {
|
||||
indexAxis: 'y',
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
layout: { padding: { right: 40 } },
|
||||
animation: {
|
||||
duration: 500,
|
||||
onComplete: (animation) => drawBarValues(animation.chart)
|
||||
},
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: (context) => `Видимость: ${context.parsed.x}%`
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
display: true,
|
||||
beginAtZero: true,
|
||||
max: 100,
|
||||
ticks: {
|
||||
callback: function(value) {
|
||||
return value + '%';
|
||||
},
|
||||
font: { size: 12 }
|
||||
},
|
||||
grid: {
|
||||
display: true,
|
||||
drawBorder: false,
|
||||
color: 'rgba(0,0,0,0.05)'
|
||||
}
|
||||
},
|
||||
y: {
|
||||
grid: { display: false },
|
||||
ticks: {
|
||||
font: { size: 12, weight: '500' },
|
||||
crossAlign: 'far'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
title: 'Видимость конкурентов',
|
||||
chartHeight: 'height: 400px'
|
||||
}
|
||||
: null,
|
||||
conclusion: competitiveEnv.conclusion || ''
|
||||
};
|
||||
};
|
||||
|
||||
// Build competitors heatmap data
|
||||
const buildCompetitorsHeatmapData = (competitors) => {
|
||||
if (!Array.isArray(competitors) || competitors.length === 0) return null;
|
||||
@@ -1996,12 +2271,12 @@ const buildChannelsChartData = (acquisitionChannels) => {
|
||||
if (!shares || typeof shares !== 'object') return null;
|
||||
|
||||
// Превращаем ключи "instagram_target" в "Instagram Target"
|
||||
const labels = Object.keys(shares).map(key =>
|
||||
const labels = Object.keys(shares).map(key =>
|
||||
key.split('_').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ')
|
||||
);
|
||||
|
||||
// Чистим значения от знака % для графика
|
||||
const values = Object.values(shares).map(val =>
|
||||
const values = Object.values(shares).map(val =>
|
||||
(typeof val === 'string') ? parseFloat(val.replace('%', '')) : val
|
||||
);
|
||||
|
||||
@@ -2149,16 +2424,16 @@ const mapChartsDataToSegments = (chartsData) => {
|
||||
segments.push({
|
||||
type: 'chart',
|
||||
key: 'ageStructure',
|
||||
title: 'АРЫСТАНБЕК',
|
||||
title: 'Возрастная структура',
|
||||
chartType: 'bar',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [{
|
||||
data: values,
|
||||
// !!! ЦВЕТ КАК НА ФОТО (СИНИЙ) !!!
|
||||
backgroundColor: '#60a5fa',
|
||||
// !!! ЦВЕТ КАК НА ФОТО (СИНИЙ) !!!
|
||||
backgroundColor: '#60a5fa',
|
||||
borderRadius: 8, // Скругление
|
||||
barPercentage: 0.65,
|
||||
barPercentage: 0.65,
|
||||
unit: '%'
|
||||
}]
|
||||
},
|
||||
@@ -2177,7 +2452,7 @@ const mapChartsDataToSegments = (chartsData) => {
|
||||
values = parsed.values;
|
||||
} else {
|
||||
labels = ['Низкий', 'Средний', 'Высокий'];
|
||||
values = [20, 50, 30];
|
||||
values = [20, 50, 30];
|
||||
}
|
||||
|
||||
segments.push({
|
||||
@@ -2291,7 +2566,7 @@ const mapJsonBlockToSegment = (identifier, data, rawText) => {
|
||||
title: 'Распределение по возрасту',
|
||||
chartType: 'bar',
|
||||
data: chartData,
|
||||
options: competitorsBarChartOptions,
|
||||
options: horizontalBarChartOptions,
|
||||
chartHeight: 'height: 350px'
|
||||
};
|
||||
}
|
||||
@@ -2377,7 +2652,23 @@ const mapJsonBlockToSegment = (identifier, data, rawText) => {
|
||||
gendersChartData
|
||||
};
|
||||
}
|
||||
case 'competitiveenvironment':
|
||||
case 'competitive_environment': {
|
||||
// New structure: { title, metrics, competitors, conclusion }
|
||||
const processed = processCompetitiveEnvironmentData(data);
|
||||
if (processed) return processed;
|
||||
// Fallback to old structure if processing fails
|
||||
return null;
|
||||
}
|
||||
case 'competitors': {
|
||||
// Check if it's new structure (has metrics, competitors array with visibility_score)
|
||||
if (data && typeof data === 'object' && !Array.isArray(data)) {
|
||||
if (data.metrics && Array.isArray(data.competitors)) {
|
||||
const processed = processCompetitiveEnvironmentData(data);
|
||||
if (processed) return processed;
|
||||
}
|
||||
}
|
||||
// Old structure: array of competitors
|
||||
if (!Array.isArray(data) || data.length === 0) return null;
|
||||
const reachChartData = buildCompetitorsBarChartData(data, 'reach');
|
||||
const followersChartData = buildCompetitorsBarChartData(data, 'followers');
|
||||
@@ -2390,7 +2681,7 @@ const mapJsonBlockToSegment = (identifier, data, rawText) => {
|
||||
? {
|
||||
chartType: 'bar',
|
||||
data: reachChartData,
|
||||
options: competitorsBarChartOptions,
|
||||
options: horizontalBarChartOptions,
|
||||
title: 'Охват конкурентов'
|
||||
}
|
||||
: null,
|
||||
@@ -2398,7 +2689,7 @@ const mapJsonBlockToSegment = (identifier, data, rawText) => {
|
||||
? {
|
||||
chartType: 'bar',
|
||||
data: followersChartData,
|
||||
options: competitorsBarChartOptions,
|
||||
options: horizontalBarChartOptions,
|
||||
title: 'Подписчики конкурентов'
|
||||
}
|
||||
: null
|
||||
@@ -2455,9 +2746,28 @@ const getChartSegmentByPlaceholder = (placeholder, chartsData) => {
|
||||
return null;
|
||||
|
||||
case 'CHART_COMPETITORS':
|
||||
case 'CHART_COMPETITIVE_ENVIRONMENT': {
|
||||
// Check for new competitive environment structure first
|
||||
if (chartsData.competitiveEnvironment || chartsData.competitive_environment) {
|
||||
const competitiveEnv = chartsData.competitiveEnvironment || chartsData.competitive_environment;
|
||||
const processed = processCompetitiveEnvironmentData(competitiveEnv);
|
||||
if (processed) return processed;
|
||||
}
|
||||
// Fallback to old competitors structure
|
||||
const competitorsArray = Array.isArray(chartsData.competitors) ? chartsData.competitors : chartsData.competitors?.competitors || [];
|
||||
|
||||
if (competitorsArray.length > 0) {
|
||||
// Check if it's new structure (has visibility_score)
|
||||
if (competitorsArray[0]?.visibility_score !== undefined) {
|
||||
const processed = processCompetitiveEnvironmentData({
|
||||
competitors: competitorsArray,
|
||||
metrics: chartsData.metrics || {},
|
||||
title: chartsData.title || 'Конкурентная среда',
|
||||
conclusion: chartsData.conclusion || ''
|
||||
});
|
||||
if (processed) return processed;
|
||||
}
|
||||
// Old structure
|
||||
const reachChartData = buildCompetitorsBarChartData(competitorsArray, 'reach');
|
||||
const followersChartData = buildCompetitorsBarChartData(competitorsArray, 'followers');
|
||||
|
||||
@@ -2469,7 +2779,7 @@ const getChartSegmentByPlaceholder = (placeholder, chartsData) => {
|
||||
? {
|
||||
chartType: 'bar',
|
||||
data: reachChartData,
|
||||
options: competitorsBarChartOptions,
|
||||
options: horizontalBarChartOptions,
|
||||
title: 'Охват конкурентов'
|
||||
}
|
||||
: null,
|
||||
@@ -2477,7 +2787,7 @@ const getChartSegmentByPlaceholder = (placeholder, chartsData) => {
|
||||
? {
|
||||
chartType: 'bar',
|
||||
data: followersChartData,
|
||||
options: competitorsBarChartOptions,
|
||||
options: horizontalBarChartOptions,
|
||||
title: 'Подписчики конкурентов'
|
||||
}
|
||||
: null
|
||||
@@ -2509,6 +2819,7 @@ const getChartSegmentByPlaceholder = (placeholder, chartsData) => {
|
||||
return segment;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
case 'CHART_SWOT':
|
||||
// Check for swotCompetitors first
|
||||
@@ -2590,7 +2901,7 @@ const getChartSegmentByIdentifier = (identifier, chartsData) => {
|
||||
title: 'Распределение по возрасту',
|
||||
chartType: 'bar',
|
||||
data: chartData,
|
||||
options: competitorsBarChartOptions,
|
||||
options: horizontalBarChartOptions,
|
||||
chartHeight: 'height: 350px'
|
||||
};
|
||||
}
|
||||
@@ -2622,9 +2933,29 @@ const getChartSegmentByIdentifier = (identifier, chartsData) => {
|
||||
}
|
||||
return null;
|
||||
|
||||
case 'competitors':
|
||||
case 'competitiveenvironment':
|
||||
case 'competitive_environment': {
|
||||
const competitiveEnv = chartsData.competitiveEnvironment || chartsData.competitive_environment;
|
||||
if (competitiveEnv) {
|
||||
const processed = processCompetitiveEnvironmentData(competitiveEnv);
|
||||
if (processed) return processed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
case 'competitors': {
|
||||
const competitorsArray = Array.isArray(chartsData.competitors) ? chartsData.competitors : chartsData.competitors?.competitors || [];
|
||||
if (competitorsArray.length > 0) {
|
||||
// Check if it's new structure (has visibility_score)
|
||||
if (competitorsArray[0]?.visibility_score !== undefined) {
|
||||
const processed = processCompetitiveEnvironmentData({
|
||||
competitors: competitorsArray,
|
||||
metrics: chartsData.metrics || {},
|
||||
title: chartsData.title || 'Конкурентная среда',
|
||||
conclusion: chartsData.conclusion || ''
|
||||
});
|
||||
if (processed) return processed;
|
||||
}
|
||||
// Old structure
|
||||
const reachChartData = buildCompetitorsBarChartData(competitorsArray, 'reach');
|
||||
const followersChartData = buildCompetitorsBarChartData(competitorsArray, 'followers');
|
||||
|
||||
@@ -2636,7 +2967,7 @@ const getChartSegmentByIdentifier = (identifier, chartsData) => {
|
||||
? {
|
||||
chartType: 'bar',
|
||||
data: reachChartData,
|
||||
options: competitorsBarChartOptions,
|
||||
options: horizontalBarChartOptions,
|
||||
title: 'Охват конкурентов'
|
||||
}
|
||||
: null,
|
||||
@@ -2644,7 +2975,7 @@ const getChartSegmentByIdentifier = (identifier, chartsData) => {
|
||||
? {
|
||||
chartType: 'bar',
|
||||
data: followersChartData,
|
||||
options: competitorsBarChartOptions,
|
||||
options: horizontalBarChartOptions,
|
||||
title: 'Подписчики конкурентов'
|
||||
}
|
||||
: null
|
||||
@@ -2676,6 +3007,7 @@ const getChartSegmentByIdentifier = (identifier, chartsData) => {
|
||||
return segment;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
case 'swot':
|
||||
// Check for swotCompetitors first
|
||||
@@ -2824,9 +3156,27 @@ const tryParseJsonSegment = (text) => {
|
||||
try {
|
||||
const parsedJson = JSON.parse(trimmed);
|
||||
|
||||
// Check if it's new competitive environment structure
|
||||
if (parsedJson && typeof parsedJson === 'object' && !Array.isArray(parsedJson)) {
|
||||
if (parsedJson.competitors && Array.isArray(parsedJson.competitors) && parsedJson.metrics) {
|
||||
const processed = processCompetitiveEnvironmentData(parsedJson);
|
||||
if (processed) return processed;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if it's an array of competitors
|
||||
if (Array.isArray(parsedJson) && parsedJson.length > 0) {
|
||||
// Check if it looks like competitors data
|
||||
// Check if it's new structure (has visibility_score)
|
||||
if (parsedJson[0]?.visibility_score !== undefined) {
|
||||
const processed = processCompetitiveEnvironmentData({
|
||||
competitors: parsedJson,
|
||||
metrics: {},
|
||||
title: 'Конкурентная среда',
|
||||
conclusion: ''
|
||||
});
|
||||
if (processed) return processed;
|
||||
}
|
||||
// Check if it looks like old competitors data
|
||||
if (parsedJson[0]?.name && (parsedJson[0]?.channelsHeatmap || parsedJson[0]?.reach || parsedJson[0]?.followers)) {
|
||||
return {
|
||||
type: 'competitors',
|
||||
@@ -2843,7 +3193,7 @@ const tryParseJsonSegment = (text) => {
|
||||
const chartType = parsedJson.chartType || 'bar';
|
||||
|
||||
// Use appropriate options based on chart type
|
||||
const options = chartType === 'pie' || chartType === 'doughnut' ? pieChartOptions : chartType === 'line' ? seasonalityChartOptions : competitorsBarChartOptions;
|
||||
const options = chartType === 'pie' || chartType === 'doughnut' ? pieChartOptions : chartType === 'line' ? seasonalityChartOptions : horizontalBarChartOptions;
|
||||
|
||||
// Normalize chart data (colors/borders, safety)
|
||||
const chartData = normalizeChartJsData(parsedJson, chartType);
|
||||
@@ -2989,7 +3339,7 @@ const chartsDataSegments = computed(() => {
|
||||
? {
|
||||
chartType: 'bar',
|
||||
data: reachChartData,
|
||||
options: competitorsBarChartOptions,
|
||||
options: horizontalBarChartOptions,
|
||||
title: 'Охват конкурентов'
|
||||
}
|
||||
: null,
|
||||
@@ -2997,7 +3347,7 @@ const chartsDataSegments = computed(() => {
|
||||
? {
|
||||
chartType: 'bar',
|
||||
data: followersChartData,
|
||||
options: competitorsBarChartOptions,
|
||||
options: horizontalBarChartOptions,
|
||||
title: 'Подписчики конкурентов'
|
||||
}
|
||||
: null
|
||||
@@ -3046,13 +3396,24 @@ const chartsDataSegments = computed(() => {
|
||||
const parsedContent = computed(() => {
|
||||
let segments = [];
|
||||
|
||||
// Check for v2ReportNormalized competitive_environment first
|
||||
if (v2ReportNormalized.value?.sections?.competitive_environment) {
|
||||
const competitiveEnv = v2ReportNormalized.value.sections.competitive_environment;
|
||||
const processed = processCompetitiveEnvironmentData(competitiveEnv);
|
||||
if (processed) {
|
||||
segments.push(processed);
|
||||
}
|
||||
}
|
||||
|
||||
// If we have both fullAnalysis and chartsData, parse fullAnalysis and inject charts
|
||||
if (report.value?.fullAnalysis && report.value?.chartsData) {
|
||||
segments = parseFullAnalysisWithCharts(report.value.fullAnalysis, report.value.chartsData);
|
||||
const parsedSegments = parseFullAnalysisWithCharts(report.value.fullAnalysis, report.value.chartsData);
|
||||
segments = [...segments, ...parsedSegments];
|
||||
}
|
||||
// If only chartsData exists, use it
|
||||
else if (report.value?.chartsData) {
|
||||
segments = chartsDataSegments.value;
|
||||
const chartSegments = chartsDataSegments.value;
|
||||
segments = [...segments, ...chartSegments];
|
||||
}
|
||||
// Otherwise, fall back to parsing fullAnalysis with JSON blocks (for old data)
|
||||
else if (report.value?.fullAnalysis) {
|
||||
@@ -3116,8 +3477,32 @@ const parsedContent = computed(() => {
|
||||
return segments.filter((segment) => {
|
||||
// If segment doesn't have a type, try to process it
|
||||
if (!segment || !segment.type) {
|
||||
// Check if it's new competitive environment structure
|
||||
if (segment && typeof segment === 'object' && !Array.isArray(segment)) {
|
||||
if (segment.competitors && Array.isArray(segment.competitors) && segment.metrics) {
|
||||
const processed = processCompetitiveEnvironmentData(segment);
|
||||
if (processed) {
|
||||
segments[segments.indexOf(segment)] = processed;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
// If it's an array (like competitors), process it
|
||||
if (Array.isArray(segment) && segment.length > 0 && segment[0]?.name) {
|
||||
// Check if it's new structure (has visibility_score)
|
||||
if (segment[0]?.visibility_score !== undefined) {
|
||||
const processed = processCompetitiveEnvironmentData({
|
||||
competitors: segment,
|
||||
metrics: {},
|
||||
title: 'Конкурентная среда',
|
||||
conclusion: ''
|
||||
});
|
||||
if (processed) {
|
||||
segments[segments.indexOf(segment)] = processed;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// Old structure
|
||||
const processed = {
|
||||
type: 'competitors',
|
||||
competitors: segment,
|
||||
@@ -3329,6 +3714,102 @@ const goToStrategy = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Competitive Environment: Chart and Table Synchronization
|
||||
const highlightedRowIndex = ref(null);
|
||||
const visibilityChartRef = ref(null);
|
||||
|
||||
const highlightRow = (index) => {
|
||||
highlightedRowIndex.value = index;
|
||||
// Highlight corresponding bar in chart if chart is available
|
||||
if (visibilityChartRef.value && visibilityChartRef.value.chart && index !== null) {
|
||||
const chart = visibilityChartRef.value.chart;
|
||||
const meta = chart.getDatasetMeta(0);
|
||||
if (meta && meta.data[index]) {
|
||||
// Reset all bars
|
||||
meta.data.forEach((bar) => {
|
||||
bar._model.backgroundColor = bar._view.backgroundColor;
|
||||
});
|
||||
// Highlight the hovered bar
|
||||
const hoveredBar = meta.data[index];
|
||||
if (hoveredBar) {
|
||||
const originalColor = hoveredBar._view.backgroundColor;
|
||||
hoveredBar._model.backgroundColor = originalColor + 'CC'; // Add transparency
|
||||
chart.update('none');
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onChartHover = (event, elements) => {
|
||||
if (elements && elements.length > 0) {
|
||||
const elementIndex = elements[0].index;
|
||||
highlightedRowIndex.value = elementIndex;
|
||||
}
|
||||
};
|
||||
|
||||
const onChartClick = (event, elements) => {
|
||||
if (elements && elements.length > 0) {
|
||||
const elementIndex = elements[0].index;
|
||||
// Could trigger some action on click
|
||||
}
|
||||
};
|
||||
|
||||
const handleRowClick = (competitor) => {
|
||||
if (competitor.website) {
|
||||
let url = competitor.website.trim();
|
||||
// If URL doesn't start with http:// or https://, add https://
|
||||
if (!url.match(/^https?:\/\//i)) {
|
||||
url = 'https://' + url;
|
||||
}
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
};
|
||||
|
||||
const getFullUrl = (url) => {
|
||||
if (!url) return '';
|
||||
const trimmed = url.trim();
|
||||
// If URL doesn't start with http:// or https://, add https://
|
||||
if (!trimmed.match(/^https?:\/\//i)) {
|
||||
return 'https://' + trimmed;
|
||||
}
|
||||
return trimmed;
|
||||
};
|
||||
|
||||
const formatWebsite = (url) => {
|
||||
if (!url) return '';
|
||||
const trimmed = url.trim();
|
||||
try {
|
||||
// If it's already a full URL, parse it
|
||||
if (trimmed.match(/^https?:\/\//i)) {
|
||||
const urlObj = new URL(trimmed);
|
||||
return urlObj.hostname.replace('www.', '');
|
||||
}
|
||||
// Otherwise, just return the domain part (before first /)
|
||||
const domain = trimmed.split('/')[0];
|
||||
return domain.replace('www.', '');
|
||||
} catch {
|
||||
// If parsing fails, return as is (might be just domain name)
|
||||
return trimmed.split('/')[0].replace('www.', '');
|
||||
}
|
||||
};
|
||||
|
||||
const getScoreClass = (score) => {
|
||||
if (score >= 70) return 'competitive-env-score-high';
|
||||
if (score >= 40) return 'competitive-env-score-medium';
|
||||
return 'competitive-env-score-low';
|
||||
};
|
||||
|
||||
const getPriceSegmentSeverity = (segment) => {
|
||||
const segmentLower = (segment || '').toLowerCase();
|
||||
if (segmentLower.includes('premium') || segmentLower.includes('высок') || segmentLower.includes('премиум')) {
|
||||
return 'danger';
|
||||
}
|
||||
if (segmentLower.includes('low') || segmentLower.includes('низк')) {
|
||||
return 'success';
|
||||
}
|
||||
return 'warning';
|
||||
};
|
||||
|
||||
// Load analysis from query params
|
||||
onMounted(async () => {
|
||||
const queryAnalysisId = route.query.analysisId;
|
||||
@@ -3729,4 +4210,81 @@ onBeforeUnmount(() => {
|
||||
.comparison-table-tr:hover {
|
||||
background-color: var(--surface-50);
|
||||
}
|
||||
|
||||
/* Competitive Environment Table Styles */
|
||||
.competitive-environment-table {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.competitive-env-th {
|
||||
padding: 1rem;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
background-color: var(--surface-50);
|
||||
border-bottom: 2px solid var(--surface-border);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.competitive-env-td {
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid var(--surface-border);
|
||||
color: var(--text-color);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.competitive-env-tr {
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.competitive-env-tr:hover {
|
||||
background-color: var(--surface-hover);
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
.competitive-env-tr-highlighted {
|
||||
background-color: var(--primary-50);
|
||||
border-left: 3px solid var(--primary-500);
|
||||
}
|
||||
|
||||
.competitive-env-score-badge {
|
||||
display: inline-block;
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
min-width: 3rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.competitive-env-score-high {
|
||||
background-color: rgba(16, 185, 129, 0.1);
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.competitive-env-score-medium {
|
||||
background-color: rgba(245, 158, 11, 0.1);
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.competitive-env-score-low {
|
||||
background-color: rgba(239, 68, 68, 0.1);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.dark .competitive-env-score-high {
|
||||
background-color: rgba(16, 185, 129, 0.2);
|
||||
color: #34d399;
|
||||
}
|
||||
|
||||
.dark .competitive-env-score-medium {
|
||||
background-color: rgba(245, 158, 11, 0.2);
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.dark .competitive-env-score-low {
|
||||
background-color: rgba(239, 68, 68, 0.2);
|
||||
color: #f87171;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -217,15 +217,14 @@
|
||||
<div class="flex gap-1 mt-auto">
|
||||
<Button label="Копировать" icon="pi pi-copy" size="small" severity="secondary" class="flex-1 text-xs" @click="handleCopyPost(post)" />
|
||||
<Button
|
||||
v-if="post.taskId"
|
||||
label="Запустить"
|
||||
icon="pi pi-play"
|
||||
label="Опубликовать"
|
||||
icon="pi pi-send"
|
||||
size="small"
|
||||
severity="success"
|
||||
class="flex-1 text-xs"
|
||||
:loading="isTaskExecuting(post.taskId)"
|
||||
:disabled="isTaskExecuting(post.taskId)"
|
||||
@click="handleExecuteTask(post.taskId, post)"
|
||||
:loading="post.taskId ? isTaskExecuting(post.taskId) : false"
|
||||
:disabled="!post.taskId || isTaskExecuting(post.taskId)"
|
||||
@click="handlePublishPost(post)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -308,14 +307,14 @@
|
||||
<Button label="Копировать" icon="pi pi-copy" size="small" severity="secondary" class="flex-1 text-xs" @click="handleCopyPost(post)" />
|
||||
<Button
|
||||
v-if="post.taskId"
|
||||
label="Запустить"
|
||||
icon="pi pi-play"
|
||||
label="Опубликовать"
|
||||
icon="pi pi-send"
|
||||
size="small"
|
||||
severity="success"
|
||||
class="flex-1 text-xs"
|
||||
:loading="isTaskExecuting(post.taskId)"
|
||||
:disabled="isTaskExecuting(post.taskId)"
|
||||
@click="handleExecuteTask(post.taskId, post)"
|
||||
@click="handlePublishPost(post)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -688,6 +687,21 @@ const handleExecuteTask = async (taskId, post) => {
|
||||
}
|
||||
};
|
||||
|
||||
// Handle publish post
|
||||
const handlePublishPost = async (post) => {
|
||||
if (!post.taskId) {
|
||||
toast.add({
|
||||
severity: 'warn',
|
||||
summary: 'Ошибка',
|
||||
detail: 'Задача публикации не создана. Сначала запустите стратегию.',
|
||||
life: 3000
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await handleExecuteTask(post.taskId, post);
|
||||
};
|
||||
|
||||
// Check if task is executing
|
||||
const isTaskExecuting = (taskId) => {
|
||||
return taskId ? executingTasks.value.has(taskId) : false;
|
||||
|
||||
Reference in New Issue
Block a user