.
This commit is contained in:
@@ -0,0 +1,198 @@
|
||||
{
|
||||
"report_title": "Маркетинговый анализ для интернет-продвижения",
|
||||
"sections": {
|
||||
"market_overview": {
|
||||
"title": "Обзор рынка",
|
||||
"metrics": {
|
||||
"online_demand_index": "76/100",
|
||||
"online_potential": "Высокий",
|
||||
"demand_growth_rate_yoy": "+27%",
|
||||
"seasonality_coefficient_spring": "1.34x",
|
||||
"seasonality_range": "1.29 - 1.38"
|
||||
},
|
||||
"monthly_online_index": {
|
||||
"march": 1.32,
|
||||
"april": 1.34,
|
||||
"may": 1.38
|
||||
},
|
||||
"geography": "Казахстан (анализ по регионам представлен на тепловой карте)",
|
||||
"conclusion": "Онлайн-спрос показывает устойчивый рост в течение года, весной достигая пика. Интернет-продвижение даст максимальный эффект."
|
||||
},
|
||||
"target_audience": {
|
||||
"title": "Целевая аудитория",
|
||||
"metrics": {
|
||||
"total_audience_size": "1.8 млн человек",
|
||||
"leading_segment_share": "29%",
|
||||
"above_average_income_share": "35%"
|
||||
},
|
||||
"segments": [
|
||||
{
|
||||
"name": "Женщины, 25–34 года",
|
||||
"share": "29%"
|
||||
},
|
||||
{
|
||||
"name": "Мужчины, 25–44 года",
|
||||
"share": "24%"
|
||||
},
|
||||
{
|
||||
"name": "Доход выше среднего",
|
||||
"share": "35%"
|
||||
}
|
||||
],
|
||||
"age_structure": {
|
||||
"18-24": "10%",
|
||||
"25-34": "29%",
|
||||
"35-44": "Высокая доля (визуально на графике)",
|
||||
"45-54": "15-24%",
|
||||
"55_plus": "Низкая доля"
|
||||
},
|
||||
"conclusion": "Основную ценность представляют мужчины и женщины 25–44 лет с доходом выше среднего (до 35% аудитории)."
|
||||
},
|
||||
"competitive_environment": {
|
||||
"title": "Конкурентная среда",
|
||||
"metrics": {
|
||||
"market_share_traffic": "16%",
|
||||
"visibility_rank": "#3",
|
||||
"competitor_share_in_context_ads": "27%",
|
||||
"benchmark_astana": "13-25%"
|
||||
},
|
||||
"digital_visibility_benchmarks": {
|
||||
"competitor_a": 1050,
|
||||
"your_business": 600,
|
||||
"lead": 300,
|
||||
"payment": 450,
|
||||
"others": 680
|
||||
},
|
||||
"competitor_strengths": {
|
||||
"your_business": ["Цена", "Оффер"],
|
||||
"competitor_a": ["Отзывы", "Рекламный бюджет"],
|
||||
"competitor_b": ["Контент", "Соцсети", "Экспириенс"],
|
||||
"competitor_c": ["Контент", "Сайт"]
|
||||
},
|
||||
"conclusion": "Ваш бизнес занимает 3-е место по видимости. Основные преимущества конкурентов — соцдоказательства и контент. Ценовая борьба не является оптимальной."
|
||||
},
|
||||
"demand_and_behavior": {
|
||||
"title": "Спрос и поведение",
|
||||
"metrics": {
|
||||
"search_volume_growth": "+62%",
|
||||
"interactions_before_purchase": "3-4",
|
||||
"repeat_refusal_probability": "54%"
|
||||
},
|
||||
"search_query_types": {
|
||||
"direct_interest_catalog": "52%",
|
||||
"question_based_articles": "28%",
|
||||
"competitors_reviews": "13%",
|
||||
"problem_solving_hot_promo": "7%"
|
||||
},
|
||||
"decision_factors": {
|
||||
"promo_discount": "43%",
|
||||
"review_recommendation": "36%",
|
||||
"warranty_quality": "33%",
|
||||
"convenience_service": "25%",
|
||||
"availability": "21%",
|
||||
"price": "19%"
|
||||
},
|
||||
"reasons_for_refusal": {
|
||||
"high_price": "37%",
|
||||
"no_reviews_social_proof": "34%",
|
||||
"choice_difficulty_unclear_offer": "29%"
|
||||
},
|
||||
"conclusion": "Основной трафик приходит из поиска. Для отклика ключевую роль играют акции и отзывы."
|
||||
},
|
||||
"customer_journey": {
|
||||
"title": "Путь клиента",
|
||||
"funnel_metrics": {
|
||||
"ad_traffic": "Реклама",
|
||||
"site_visitors": "Сайт / Лендинг",
|
||||
"leads": 5600,
|
||||
"bookings": 1200,
|
||||
"payments": 680
|
||||
},
|
||||
"conversion_rates": {
|
||||
"lead_to_booking": "21%",
|
||||
"booking_to_payment": "57%",
|
||||
"lead_to_payment_total": "12%"
|
||||
},
|
||||
"funnel_bottlenecks": {
|
||||
"lead_to_booking": "Медленная скорость ответа",
|
||||
"booking_to_payment": "Недостаточно касаний / напоминаний"
|
||||
},
|
||||
"conclusion": "Основные потери происходят на этапе лид -> запись (конверсия 21%) из-за медленных ответов менеджеров."
|
||||
},
|
||||
"acquisition_channels": {
|
||||
"title": "Каналы привлечения",
|
||||
"metrics": {
|
||||
"search_traffic_share": "48%",
|
||||
"average_lead_cost": "37 P",
|
||||
"growth_potential": "+21%"
|
||||
},
|
||||
"channel_shares": {
|
||||
"seo": "48%",
|
||||
"yandex_direct": "31%",
|
||||
"retargeting": "13%",
|
||||
"vkontakte": "6%"
|
||||
},
|
||||
"channel_efficiency": {
|
||||
"seo": { "cost": "25 P", "status": "Эффективен" },
|
||||
"yandex": { "cost": "45 P", "status": "Эффективен" },
|
||||
"retargeting": { "cost": "40 P", "status": "Удовлетворительно" },
|
||||
"vkontakte": { "cost": "57 P", "status": "Завышено" },
|
||||
"youtube": { "cost": "63 P", "status": "Завышено" },
|
||||
"instagram": { "cost": "77 P", "status": "Завышено" }
|
||||
},
|
||||
"conclusion": "Основной источник лидов — SEO. VK, Youtube и Instagram требуют оптимизации из-за завышенной стоимости лида."
|
||||
},
|
||||
"marketing_economics": {
|
||||
"title": "Экономика маркетинга",
|
||||
"metrics": {
|
||||
"customer_acquisition_cost_cac": "2900 P",
|
||||
"repeat_sales_conversion": "32%",
|
||||
"roi": "3.5x"
|
||||
},
|
||||
"financial_performance": {
|
||||
"marketing_expenses": { "month": "1.97 млн", "year": "23.6 млн" },
|
||||
"clients": { "month": 680, "year": 8160 },
|
||||
"cac": { "month": "2900 P", "year": "2900 P" },
|
||||
"revenue_from_client": { "month": "10.200 P", "year": "23.040 P" },
|
||||
"profit_from_client": { "month": "7.300 P", "year": "16.720 P" }
|
||||
},
|
||||
"conclusion": "Экономика digital-маркетинга эффективна: возврат инвестиций 3.5х. Точки роста — повторные продажи и удержание."
|
||||
},
|
||||
"bottlenecks": {
|
||||
"title": "Проблемы и узкие места",
|
||||
"statistics": {
|
||||
"landing_page_issues": "51%",
|
||||
"slow_manager_responses": "60%",
|
||||
"repeat_interaction_refusal": "36%"
|
||||
},
|
||||
"details": {
|
||||
"landing": ["Низкая конверсия (2%)", "Проблемы с формой записи", "Уход посетителей"],
|
||||
"lead_management": ["Медленные ответы (30-40+ мин)", "Недостаток напоминаний", "Пропущенные лиды"],
|
||||
"retention": ["Недостаток повторных предложений", "Нет прогрева", "Слабая работа с CRM"]
|
||||
},
|
||||
"conclusion": "Теряются десятки процентов лидов из-за медленных ответов менеджеров и слабой ретаргетинговой работы."
|
||||
},
|
||||
"recommendations": {
|
||||
"title": "Рекомендации (итоги)",
|
||||
"core_pillars": [
|
||||
{
|
||||
"strategy": "Фокус на эффективных каналах",
|
||||
"action": "Перераспределение бюджета на каналы с устойчивым спросом (Яндекс/SEO)"
|
||||
},
|
||||
{
|
||||
"strategy": "Увеличение повторных касаний",
|
||||
"action": "Усиление удерживающих кампаний и серий касаний с клиентами"
|
||||
},
|
||||
{
|
||||
"strategy": "Приоритет высокоценных аудиторий",
|
||||
"action": "Смещение акцента на платёжеспособные сегменты и B2B"
|
||||
},
|
||||
{
|
||||
"strategy": "Рост эффективности трафика",
|
||||
"action": "Снижение зависимости от дорогих источников и оптимизация каналов"
|
||||
}
|
||||
],
|
||||
"conclusion": "Рекомендации фокусируют усилия на эффективности интернет-продвижения и служат базой для следующих шагов."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
# Изменения в API: Интеграция v2 анализа
|
||||
|
||||
## Обзор изменений
|
||||
|
||||
Добавлена возможность опционального создания и сохранения v2 анализа вместе с обычным анализом. Все v2 анализы сохраняются в MongoDB и доступны через новые эндпойнты.
|
||||
|
||||
---
|
||||
|
||||
## 1. POST `/api/marketing/analysis/start` - Обновлен
|
||||
|
||||
### Новый query параметр:
|
||||
|
||||
- **`generateV2`** (boolean, опционально, по умолчанию `false`)
|
||||
- Если `true`, вместе с обычным анализом создается и сохраняется v2 анализ
|
||||
- Оба анализа автоматически связываются между собой
|
||||
|
||||
### Изменения в ответе:
|
||||
|
||||
**Если `generateV2=false` (по умолчанию):**
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Анализ запущен успешно",
|
||||
"data": {
|
||||
"analysisId": "string",
|
||||
"status": "processing",
|
||||
"estimatedCompletionTime": "2024-01-01T12:00:00",
|
||||
"message": "Комплексный анализ (...) запущен успешно..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Если `generateV2=true`:**
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Анализ запущен успешно",
|
||||
"data": {
|
||||
"analysisId": "string",
|
||||
"status": "processing",
|
||||
"estimatedCompletionTime": "2024-01-01T12:00:00",
|
||||
"message": "Комплексный анализ (...) запущен успешно...",
|
||||
"v2AnalysisId": "string" // ← НОВОЕ ПОЛЕ
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Пример использования:
|
||||
|
||||
```javascript
|
||||
// Создание обычного анализа
|
||||
POST /api/marketing/analysis/start
|
||||
Body: { ... }
|
||||
|
||||
// Создание обычного анализа + v2 анализа
|
||||
POST /api/marketing/analysis/start?generateV2=true
|
||||
Body: { ... }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. GET `/api/marketing/analysis/my` - Обновлен
|
||||
|
||||
### Новое поле в ответе:
|
||||
|
||||
Каждый элемент в списке теперь содержит опциональное поле `v2AnalysisId`:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": [
|
||||
{
|
||||
"analysisId": "string",
|
||||
"businessNiche": "string",
|
||||
"product": "string",
|
||||
// ... другие поля ...
|
||||
"v2AnalysisId": "string" // ← НОВОЕ ПОЛЕ (может быть null)
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Примечание:** Поле `v2AnalysisId` будет присутствовать только если для данного анализа был создан связанный v2 анализ.
|
||||
|
||||
---
|
||||
|
||||
## 3. GET `/api/marketing/analysis/{id}` - Обновлен
|
||||
|
||||
### Новое поле в ответе:
|
||||
|
||||
Эндпойнт теперь возвращает опциональное поле `v2AnalysisId`:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"analysisId": "string",
|
||||
"status": "string",
|
||||
"createdAt": "2024-01-01T12:00:00",
|
||||
"completedAt": "2024-01-01T12:00:00",
|
||||
"v2AnalysisId": "string", // ← НОВОЕ ПОЛЕ (может быть null)
|
||||
"report": { ... }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Примечание:** Поле `v2AnalysisId` будет присутствовать только если для данного анализа был создан связанный v2 анализ.
|
||||
|
||||
---
|
||||
|
||||
## 4. GET `/api/marketing/analysis/v2/{id}` - Новый эндпойнт
|
||||
|
||||
### Описание:
|
||||
|
||||
Получение сохраненного v2 анализа по ID.
|
||||
|
||||
### Параметры:
|
||||
|
||||
- **Path:** `id` (string) - ID v2 анализа
|
||||
- **Header:** `Authorization` (JWT токен)
|
||||
|
||||
### Ответ:
|
||||
|
||||
**Успешный ответ (200):**
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"reportTitle": "string",
|
||||
"sections": {
|
||||
"marketOverview": { ... },
|
||||
"targetAudience": { ... },
|
||||
"competitiveEnvironment": { ... },
|
||||
// ... другие секции ...
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Ошибки:**
|
||||
|
||||
- `401` - Не авторизован
|
||||
- `404` - Анализ не найден
|
||||
- `403` - Нет доступа к этому анализу
|
||||
|
||||
### Пример использования:
|
||||
|
||||
```javascript
|
||||
GET /api/marketing/analysis/v2/507f1f77bcf86cd799439011
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. POST `/api/marketing/analysis/start/v2` - Без изменений
|
||||
|
||||
Эндпойнт работает как прежде - создает только v2 анализ без связи с обычным анализом.
|
||||
|
||||
---
|
||||
|
||||
## Рекомендации для фронтенда
|
||||
|
||||
1. **При создании анализа:**
|
||||
|
||||
- Используйте `generateV2=true` если нужен v2 анализ сразу
|
||||
- Сохраняйте `v2AnalysisId` из ответа для последующего доступа
|
||||
|
||||
2. **При отображении списка анализов:**
|
||||
|
||||
- Проверяйте наличие поля `v2AnalysisId`
|
||||
- Если поле присутствует, можно показать кнопку/ссылку для просмотра v2 анализа
|
||||
|
||||
3. **При получении конкретного анализа:**
|
||||
|
||||
- Поле `v2AnalysisId` доступно в ответе `GET /api/marketing/analysis/{id}`
|
||||
- Используйте это поле для навигации к v2 анализу
|
||||
|
||||
4. **При получении v2 анализа:**
|
||||
- Используйте `GET /api/marketing/analysis/v2/{id}` для получения полных данных
|
||||
- Обрабатывайте случаи, когда v2 анализ может быть не найден (404)
|
||||
|
||||
---
|
||||
|
||||
## Обратная совместимость
|
||||
|
||||
✅ Все изменения обратно совместимы:
|
||||
|
||||
- Существующие запросы без `generateV2` работают как прежде
|
||||
- Поле `v2AnalysisId` опционально и не ломает существующий код
|
||||
- Новые эндпойнты не влияют на старую функциональность
|
||||
@@ -0,0 +1,22 @@
|
||||
<template>
|
||||
<div class="rounded-2xl bg-slate-100/70 border border-slate-200/60 px-5 py-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="h-9 w-9 rounded-full bg-green-100 text-green-700 flex items-center justify-center shrink-0 border border-green-200">
|
||||
<i class="pi pi-check"></i>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="text-[15px] font-semibold text-slate-800">Краткий вывод</div>
|
||||
<div class="mt-1 text-[14px] leading-snug text-slate-600">
|
||||
{{ text }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
text: { type: String, required: true }
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
<template>
|
||||
<div class="rounded-2xl bg-white shadow-sm border border-slate-200/60 p-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="text-[15px] font-semibold text-slate-800">{{ title }}</div>
|
||||
<div v-if="subtitle" class="text-[12px] text-slate-500">{{ subtitle }}</div>
|
||||
</div>
|
||||
|
||||
<div class="relative w-full">
|
||||
<svg :viewBox="`0 0 ${viewW} ${viewH}`" class="w-full h-auto">
|
||||
<g v-for="(s, idx) in normalizedSteps" :key="s.key">
|
||||
<polygon :points="polygonPoints(idx)" :fill="s.color" opacity="0.95"></polygon>
|
||||
<polygon :points="polygonPoints(idx)" fill="none" stroke="rgba(255,255,255,0.65)" stroke-width="2"></polygon>
|
||||
|
||||
<text :x="viewW / 2" :y="stageTextY(idx) - 6" text-anchor="middle" fill="rgba(15,23,42,0.85)" font-size="14" font-weight="600">
|
||||
{{ s.label }}
|
||||
</text>
|
||||
<text :x="viewW / 2" :y="stageTextY(idx) + 14" text-anchor="middle" fill="rgba(15,23,42,0.75)" font-size="13">
|
||||
{{ s.value }}
|
||||
</text>
|
||||
|
||||
<!-- conversion badge between stages -->
|
||||
<g v-if="s.rateToNext && idx < normalizedSteps.length - 1">
|
||||
<rect :x="viewW / 2 - 34" :y="badgeY(idx)" rx="10" ry="10" width="68" height="22" fill="white" stroke="rgba(15,23,42,0.12)"></rect>
|
||||
<text :x="viewW / 2" :y="badgeY(idx) + 15" text-anchor="middle" fill="#52C41A" font-size="12" font-weight="700">
|
||||
{{ s.rateToNext }}
|
||||
</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
title: { type: String, default: 'Потери на воронке' },
|
||||
subtitle: { type: String, default: '' },
|
||||
steps: {
|
||||
type: Array,
|
||||
required: true
|
||||
// [{ key, label, value, color, rateToNext }]
|
||||
}
|
||||
});
|
||||
|
||||
const viewW = 1000;
|
||||
const stageH = 92;
|
||||
const gap = 26;
|
||||
|
||||
const normalizedSteps = computed(() => {
|
||||
const defaults = ['#7CB9FF', '#52C41A', '#5B89FF', '#FF8E4F', '#FF6B6B'];
|
||||
return (props.steps || []).map((s, i) => ({
|
||||
key: s.key ?? String(i),
|
||||
label: s.label ?? '—',
|
||||
value: s.value ?? '—',
|
||||
rateToNext: s.rateToNext ?? '',
|
||||
color: s.color ?? defaults[i % defaults.length]
|
||||
}));
|
||||
});
|
||||
|
||||
const viewH = computed(() => normalizedSteps.value.length * stageH + (normalizedSteps.value.length - 1) * gap);
|
||||
|
||||
function widthAt(index) {
|
||||
const n = Math.max(1, normalizedSteps.value.length);
|
||||
const top = 900;
|
||||
const bottom = 420;
|
||||
if (n === 1) return top;
|
||||
const t = index / (n - 1);
|
||||
return top + (bottom - top) * t;
|
||||
}
|
||||
|
||||
function polygonPoints(index) {
|
||||
const y = index * (stageH + gap);
|
||||
const topW = widthAt(index);
|
||||
const bottomW = widthAt(index + 1);
|
||||
const cx = viewW / 2;
|
||||
|
||||
const x1 = cx - topW / 2;
|
||||
const x2 = cx + topW / 2;
|
||||
const x3 = cx + bottomW / 2;
|
||||
const x4 = cx - bottomW / 2;
|
||||
|
||||
const y1 = y;
|
||||
const y2 = y + stageH;
|
||||
|
||||
return `${x1},${y1} ${x2},${y1} ${x3},${y2} ${x4},${y2}`;
|
||||
}
|
||||
|
||||
function stageTextY(index) {
|
||||
const y = index * (stageH + gap);
|
||||
return y + stageH / 2;
|
||||
}
|
||||
|
||||
function badgeY(index) {
|
||||
const y = index * (stageH + gap);
|
||||
return y + stageH + (gap - 22) / 2;
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
<template>
|
||||
<div class="relative overflow-hidden rounded-2xl bg-white shadow-sm border border-slate-200/60">
|
||||
<!-- subtle top tint like PPT -->
|
||||
<div class="h-10 w-full" :style="{ background: headerBg }"></div>
|
||||
|
||||
<div class="-mt-7 px-5 pb-4 pt-0">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="h-10 w-10 rounded-xl flex items-center justify-center shadow-sm border border-white/50" :style="{ background: iconBg, color: accentColor }">
|
||||
<i class="pi" :class="icon"></i>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="text-[13px] font-semibold text-slate-800 truncate">{{ title }}</div>
|
||||
<div v-if="subtitle" class="text-[12px] text-slate-500 truncate">{{ subtitle }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="trendText" class="shrink-0">
|
||||
<span class="inline-flex items-center rounded-full px-2.5 py-1 text-[12px] font-semibold border border-slate-200/70 bg-white/70 text-slate-700">
|
||||
{{ trendText }}
|
||||
</span>
|
||||
</div>
|
||||
</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">
|
||||
{{ value }}
|
||||
</div>
|
||||
<div v-if="$slots.right" class="shrink-0">
|
||||
<slot name="right"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
title: { type: String, required: true },
|
||||
value: { type: [String, Number], required: true },
|
||||
subtitle: { type: String, default: '' },
|
||||
icon: { type: String, default: 'pi-chart-line' },
|
||||
accentColor: { type: String, default: '#5B89FF' },
|
||||
trendText: { type: String, default: '' }
|
||||
});
|
||||
|
||||
function hexToRgba(hex, alpha) {
|
||||
const h = String(hex || '')
|
||||
.replace('#', '')
|
||||
.trim();
|
||||
const full =
|
||||
h.length === 3
|
||||
? h
|
||||
.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);
|
||||
const g = parseInt(full.slice(2, 4), 16);
|
||||
const b = parseInt(full.slice(4, 6), 16);
|
||||
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
||||
}
|
||||
|
||||
const headerBg = computed(() => `linear-gradient(90deg, ${hexToRgba(props.accentColor, 0.2)}, ${hexToRgba(props.accentColor, 0.06)})`);
|
||||
const iconBg = computed(() => hexToRgba(props.accentColor, 0.16));
|
||||
</script>
|
||||
@@ -52,6 +52,8 @@ export const API_CONFIG = {
|
||||
|
||||
// MarketingAnalysisController - Маркетинговый анализ
|
||||
MARKETING_ANALYSIS_START: '/api/marketing/analysis/start',
|
||||
MARKETING_ANALYSIS_START_V2: '/api/marketing/analysis/start/v2',
|
||||
MARKETING_ANALYSIS_V2_GET: '/api/marketing/analysis/v2',
|
||||
MARKETING_ANALYSIS_GET: '/api/marketing/analysis',
|
||||
MARKETING_ANALYSIS_DOWNLOAD: '/api/marketing/analysis',
|
||||
MARKETING_ANALYSIS_MY: '/api/marketing/analysis/my',
|
||||
|
||||
@@ -235,6 +235,12 @@ const router = createRouter({
|
||||
component: () => import('@/views/pages/marketing/MarketingAnalysis.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/marketing-analysis/dashboard',
|
||||
name: 'marketing-analysis-dashboard',
|
||||
redirect: { name: 'marketing-analysis' },
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/marketing-analysis/promotion',
|
||||
name: 'marketing-promotion',
|
||||
|
||||
@@ -22,8 +22,9 @@ class MarketingService {
|
||||
* @param {string} [data.weakSide] - Слабая сторона бизнеса (опционально)
|
||||
* @returns {Promise<Object|Array>} Объект с analysisId и статусом (или массив объектов при множественном выборе типов анализа)
|
||||
*/
|
||||
async startAnalysis(data) {
|
||||
async startAnalysis(data, options = {}) {
|
||||
try {
|
||||
const generateV2 = options?.generateV2 === true;
|
||||
const requestBody = {
|
||||
businessNiche: data.businessNiche,
|
||||
product: data.product,
|
||||
@@ -42,7 +43,8 @@ class MarketingService {
|
||||
requestBody.weakSide = data.weakSide;
|
||||
}
|
||||
|
||||
const response = await AuthService.authFetch(`${API_BASE_URL}${API_CONFIG.ENDPOINTS.MARKETING_ANALYSIS_START}`, {
|
||||
const url = `${API_BASE_URL}${API_CONFIG.ENDPOINTS.MARKETING_ANALYSIS_START}${generateV2 ? '?generateV2=true' : ''}`;
|
||||
const response = await AuthService.authFetch(url, {
|
||||
method: 'POST',
|
||||
...DEFAULT_REQUEST_CONFIG,
|
||||
body: JSON.stringify(requestBody)
|
||||
@@ -66,6 +68,78 @@ class MarketingService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Получение сохраненного v2 анализа по ID
|
||||
* GET /api/marketing/analysis/v2/{id}
|
||||
* @param {string} v2AnalysisId
|
||||
* @returns {Promise<Object>} MarketingAnalysisResponseV2 data payload
|
||||
*/
|
||||
async getAnalysisV2ById(v2AnalysisId) {
|
||||
try {
|
||||
const response = await AuthService.authFetch(`${API_BASE_URL}${API_CONFIG.ENDPOINTS.MARKETING_ANALYSIS_V2_GET}/${v2AnalysisId}`);
|
||||
|
||||
const result = await response.json().catch(() => null);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(result?.message || result?.error?.message || 'Ошибка при получении v2 анализа');
|
||||
}
|
||||
|
||||
if (!result || !result.success) {
|
||||
throw new Error(result?.message || 'Ошибка при получении v2 анализа');
|
||||
}
|
||||
|
||||
return result.data || null;
|
||||
} catch (error) {
|
||||
console.error('Ошибка при получении v2 анализа:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Запуск маркетингового анализа v2 (синхронный ответ с полным отчётом)
|
||||
* POST /api/marketing/analysis/start/v2
|
||||
* @param {Object} data - MarketingAnalysisRequest
|
||||
* @returns {Promise<Object>} MarketingAnalysisResponseV2 (сырой JSON без обёртки {success,data})
|
||||
*/
|
||||
async fetchAnalysisV2(data) {
|
||||
try {
|
||||
const requestBody = {
|
||||
businessNiche: data.businessNiche,
|
||||
product: data.product,
|
||||
targetAudience: data.targetAudience,
|
||||
region: data.region,
|
||||
goal: data.goal,
|
||||
detailLevel: data.detailLevel,
|
||||
analysisType: data.analysisType
|
||||
};
|
||||
|
||||
if (data.strongSide) requestBody.strongSide = data.strongSide;
|
||||
if (data.weakSide) requestBody.weakSide = data.weakSide;
|
||||
|
||||
const response = await AuthService.authFetch(`${API_BASE_URL}${API_CONFIG.ENDPOINTS.MARKETING_ANALYSIS_START_V2}`, {
|
||||
method: 'POST',
|
||||
...DEFAULT_REQUEST_CONFIG,
|
||||
body: JSON.stringify(requestBody)
|
||||
});
|
||||
|
||||
const result = await response.json().catch(() => null);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(result?.message || result?.error?.message || `HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
// v2: ожидаем сырой отчёт, без result.success/result.data
|
||||
if (!result) {
|
||||
throw new Error('Пустой ответ от сервера');
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Ошибка при запуске маркетингового анализа v2:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Получение результата анализа
|
||||
* GET /api/marketing/analysis/{analysisId}
|
||||
@@ -598,6 +672,48 @@ class MarketingService {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Регенерация изображения для поста
|
||||
* POST /api/marketing/analysis/strategy/{strategyId}/post/{postIndex}/regenerate-image
|
||||
* @param {string} strategyId - ID стратегии
|
||||
* @param {number} postIndex - Индекс поста в календаре (0-based)
|
||||
* @returns {Promise<Object>} Объект с информацией о регенерации изображения
|
||||
*/
|
||||
async regenerateImage(strategyId, postIndex) {
|
||||
try {
|
||||
const response = await AuthService.authFetch(`${API_BASE_URL}/api/marketing/analysis/strategy/${strategyId}/post/${postIndex}/regenerate-image`, {
|
||||
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();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -136,8 +136,17 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Статус обработки -->
|
||||
<div v-else-if="status === 'processing' || status === 'queued'" class="card">
|
||||
<!-- Единая страница: v2 dashboard сверху + старый анализ ниже -->
|
||||
<div v-else class="space-y-4">
|
||||
<!-- v2 Dashboard (показываем первым) -->
|
||||
<div v-if="v2Loading || v2ReportNormalized" class="card">
|
||||
<div class="card-content">
|
||||
<MarketingAnalysisPptDashboard embedded :report="v2ReportNormalized" :loading="v2Loading" :error="v2Error" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Статус обработки (старый анализ) -->
|
||||
<div v-if="status === 'processing' || status === 'queued'" class="card">
|
||||
<div class="card-header mb-4">
|
||||
<h2 class="text-2xl font-bold text-surface-900 dark:text-surface-0">Анализ выполняется</h2>
|
||||
</div>
|
||||
@@ -757,11 +766,13 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import SegmentsChannelMatrixHeatmap from '@/components/marketing/SegmentsChannelMatrixHeatmap.vue';
|
||||
import MarketingService from '@/service/MarketingService';
|
||||
import MarketingAnalysisPptDashboard from '@/views/pages/MarketingAnalysis.vue';
|
||||
import { marked } from 'marked';
|
||||
import markedKatex from 'marked-katex-extension';
|
||||
import Button from 'primevue/button';
|
||||
@@ -855,6 +866,56 @@ const maxPollingAttempts = 60; // 10 minutes with 10 second intervals
|
||||
const pollingIntervalMs = 10000; // 10 seconds
|
||||
const downloadingPdf = ref(false);
|
||||
|
||||
// v2 dashboard state (loaded via start?generateV2=true + GET /analysis/v2/{id})
|
||||
const v2AnalysisId = ref(null);
|
||||
const v2Report = ref(null); // raw v2 payload from GET /api/marketing/analysis/v2/{id}
|
||||
const v2Loading = ref(false);
|
||||
const v2Error = ref(null);
|
||||
|
||||
const v2ReportNormalized = computed(() => {
|
||||
const root = v2Report.value;
|
||||
if (!root) return null;
|
||||
|
||||
const title = root.reportTitle || root.report_title || '';
|
||||
const s = root.sections || {};
|
||||
const pick = (camel, snake) => s[camel] || s[snake] || null;
|
||||
|
||||
return {
|
||||
report_title: title,
|
||||
sections: {
|
||||
market_overview: pick('marketOverview', 'market_overview'),
|
||||
target_audience: pick('targetAudience', 'target_audience'),
|
||||
competitive_environment: pick('competitiveEnvironment', 'competitive_environment'),
|
||||
demand_and_behavior: pick('demandAndBehavior', 'demand_and_behavior'),
|
||||
customer_journey: pick('customerJourney', 'customer_journey'),
|
||||
acquisition_channels: pick('acquisitionChannels', 'acquisition_channels'),
|
||||
marketing_economics: pick('marketingEconomics', 'marketing_economics'),
|
||||
bottlenecks: pick('bottlenecks', 'bottlenecks'),
|
||||
recommendations: pick('recommendations', 'recommendations')
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
const refreshV2 = async () => {
|
||||
if (!v2AnalysisId.value) return;
|
||||
v2Loading.value = true;
|
||||
v2Error.value = null;
|
||||
try {
|
||||
const data = await MarketingService.getAnalysisV2ById(v2AnalysisId.value);
|
||||
v2Report.value = data;
|
||||
} catch (e) {
|
||||
v2Error.value = e?.message || 'Не удалось загрузить v2 отчёт';
|
||||
toast.add({
|
||||
severity: 'warn',
|
||||
summary: 'v2 недоступен',
|
||||
detail: v2Error.value,
|
||||
life: 4000
|
||||
});
|
||||
} finally {
|
||||
v2Loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Validation
|
||||
const validateForm = () => {
|
||||
errors.value = {};
|
||||
@@ -1018,6 +1079,9 @@ const handleSubmit = async () => {
|
||||
}
|
||||
|
||||
submitting.value = true;
|
||||
v2Report.value = null;
|
||||
v2AnalysisId.value = null;
|
||||
v2Error.value = null;
|
||||
|
||||
try {
|
||||
// Формируем объект targetAudience (удаляем пустые массивы)
|
||||
@@ -1050,7 +1114,7 @@ const handleSubmit = async () => {
|
||||
requestData.weakSide = formData.value.weakSide.trim();
|
||||
}
|
||||
|
||||
const result = await MarketingService.startAnalysis(requestData);
|
||||
const result = await MarketingService.startAnalysis(requestData, { generateV2: true });
|
||||
|
||||
// Обработка ответа: может быть массив или один объект
|
||||
if (Array.isArray(result)) {
|
||||
@@ -1059,6 +1123,8 @@ const handleSubmit = async () => {
|
||||
analysisId.value = firstAnalysis.analysisId;
|
||||
status.value = firstAnalysis.status;
|
||||
estimatedCompletionTime.value = firstAnalysis.estimatedCompletion;
|
||||
v2AnalysisId.value = firstAnalysis.v2AnalysisId || null;
|
||||
router.replace({ query: { ...route.query, analysisId: analysisId.value, v2AnalysisId: v2AnalysisId.value || undefined } });
|
||||
|
||||
// Сохраняем типы анализов
|
||||
currentAnalysisType.value = formData.value.analysisType.length === 1 ? formData.value.analysisType[0] : null;
|
||||
@@ -1072,12 +1138,19 @@ const handleSubmit = async () => {
|
||||
|
||||
// Запускаем polling для первого анализа (можно расширить для всех)
|
||||
startPolling(firstAnalysis.analysisId);
|
||||
|
||||
// v2: загрузка сохраненного отчёта (если есть)
|
||||
if (v2AnalysisId.value) {
|
||||
await refreshV2();
|
||||
}
|
||||
} 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;
|
||||
v2AnalysisId.value = result.v2AnalysisId || null;
|
||||
router.replace({ query: { ...route.query, analysisId: analysisId.value, v2AnalysisId: v2AnalysisId.value || undefined } });
|
||||
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
@@ -1088,6 +1161,11 @@ const handleSubmit = async () => {
|
||||
|
||||
// Start polling
|
||||
startPolling(result.analysisId);
|
||||
|
||||
// v2: загрузка сохраненного отчёта (если есть)
|
||||
if (v2AnalysisId.value) {
|
||||
await refreshV2();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка при запуске анализа:', error);
|
||||
@@ -1138,6 +1216,10 @@ const resetAnalysis = () => {
|
||||
currentAnalysisType.value = null;
|
||||
estimatedCompletionTime.value = null;
|
||||
pollingAttempts.value = 0;
|
||||
v2AnalysisId.value = null;
|
||||
v2Report.value = null;
|
||||
v2Loading.value = false;
|
||||
v2Error.value = null;
|
||||
formData.value = {
|
||||
businessNiche: '',
|
||||
product: '',
|
||||
@@ -3175,6 +3257,7 @@ const goToStrategy = () => {
|
||||
// Load analysis from query params
|
||||
onMounted(async () => {
|
||||
const queryAnalysisId = route.query.analysisId;
|
||||
const queryV2AnalysisId = route.query.v2AnalysisId;
|
||||
if (queryAnalysisId) {
|
||||
// Try to load existing analysis
|
||||
try {
|
||||
@@ -3193,6 +3276,26 @@ onMounted(async () => {
|
||||
estimatedCompletionTime.value = result.estimatedCompletionTime;
|
||||
startPolling(queryAnalysisId);
|
||||
}
|
||||
|
||||
// v2 restore:
|
||||
// 1) from query param
|
||||
// 2) fallback to /analysis/my lookup (docs say v2AnalysisId is present there)
|
||||
if (queryV2AnalysisId) {
|
||||
v2AnalysisId.value = String(queryV2AnalysisId);
|
||||
await refreshV2();
|
||||
} else {
|
||||
try {
|
||||
const list = await MarketingService.getMyAnalyses();
|
||||
const found = Array.isArray(list) ? list.find((a) => a?.analysisId === queryAnalysisId) : null;
|
||||
if (found?.v2AnalysisId) {
|
||||
v2AnalysisId.value = found.v2AnalysisId;
|
||||
router.replace({ query: { ...route.query, v2AnalysisId: v2AnalysisId.value } });
|
||||
await refreshV2();
|
||||
}
|
||||
} catch (_) {
|
||||
// ignore v2 restore errors; old analysis page must keep working
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка при загрузке анализа:', error);
|
||||
toast.add({
|
||||
@@ -3212,6 +3315,10 @@ onBeforeUnmount(() => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.v2-glow :deep(canvas) {
|
||||
filter: drop-shadow(0 0 14px rgba(59, 130, 246, 0.22));
|
||||
}
|
||||
|
||||
.marketing-analysis-page {
|
||||
font-family: var(--font-family);
|
||||
}
|
||||
|
||||
@@ -51,6 +51,13 @@ const menuItems = [
|
||||
iconColor: 'text-primary-500',
|
||||
route: '/marketing-analysis/analysis'
|
||||
},
|
||||
{
|
||||
id: 'analysis-dashboard',
|
||||
label: 'Dashboard',
|
||||
icon: 'pi pi-chart-line',
|
||||
iconColor: 'text-primary-500',
|
||||
route: '/marketing-analysis/dashboard'
|
||||
},
|
||||
{
|
||||
id: 'analyses-list',
|
||||
label: 'Мои анализы',
|
||||
|
||||
@@ -162,16 +162,46 @@
|
||||
<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">
|
||||
<div v-if="hasImage(post) && !hasImageError(`${date}-${idx}`)" class="mb-3" style="position: relative">
|
||||
<img :src="getImageUrl(post)" :alt="post.theme" class="post-image" loading="lazy" @error="handleImageError(`${date}-${idx}`)" />
|
||||
<Button
|
||||
icon="pi pi-refresh"
|
||||
size="small"
|
||||
severity="secondary"
|
||||
class="regenerate-image-btn"
|
||||
:loading="isImageRegenerating(getPostIndex(post))"
|
||||
:disabled="isImageRegenerating(getPostIndex(post))"
|
||||
v-tooltip.top="'Регенерировать изображение'"
|
||||
@click="handleRegenerateImage(post, getPostIndex(post))"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="hasImage(post) && hasImageError(`${date}-${idx}`)" class="mb-3 no-image-placeholder">
|
||||
<div v-else-if="hasImage(post) && hasImageError(`${date}-${idx}`)" class="mb-3 no-image-placeholder" style="position: relative">
|
||||
<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>
|
||||
<Button
|
||||
icon="pi pi-refresh"
|
||||
size="small"
|
||||
severity="secondary"
|
||||
class="regenerate-image-btn"
|
||||
:loading="isImageRegenerating(getPostIndex(post))"
|
||||
:disabled="isImageRegenerating(getPostIndex(post))"
|
||||
v-tooltip.top="'Регенерировать изображение'"
|
||||
@click="handleRegenerateImage(post, getPostIndex(post))"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="mb-3 no-image-placeholder">
|
||||
<div v-else class="mb-3 no-image-placeholder" style="position: relative">
|
||||
<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>
|
||||
<Button
|
||||
icon="pi pi-refresh"
|
||||
size="small"
|
||||
severity="secondary"
|
||||
class="regenerate-image-btn"
|
||||
:loading="isImageRegenerating(getPostIndex(post))"
|
||||
:disabled="isImageRegenerating(getPostIndex(post))"
|
||||
v-tooltip.top="'Регенерировать изображение'"
|
||||
@click="handleRegenerateImage(post, getPostIndex(post))"
|
||||
/>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<p class="text-sm font-medium text-surface-600 dark:text-surface-400 mb-1">Текст поста:</p>
|
||||
@@ -222,16 +252,46 @@
|
||||
<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">
|
||||
<div v-if="hasImage(post) && !hasImageError(idx)" class="mb-3" style="position: relative">
|
||||
<img :src="getImageUrl(post)" :alt="post.theme" class="post-image" loading="lazy" @error="handleImageError(idx)" />
|
||||
<Button
|
||||
icon="pi pi-refresh"
|
||||
size="small"
|
||||
severity="secondary"
|
||||
class="regenerate-image-btn"
|
||||
:loading="isImageRegenerating(idx)"
|
||||
:disabled="isImageRegenerating(idx)"
|
||||
v-tooltip.top="'Регенерировать изображение'"
|
||||
@click="handleRegenerateImage(post, idx)"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="hasImage(post) && hasImageError(idx)" class="mb-3 no-image-placeholder">
|
||||
<div v-else-if="hasImage(post) && hasImageError(idx)" class="mb-3 no-image-placeholder" style="position: relative">
|
||||
<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>
|
||||
<Button
|
||||
icon="pi pi-refresh"
|
||||
size="small"
|
||||
severity="secondary"
|
||||
class="regenerate-image-btn"
|
||||
:loading="isImageRegenerating(idx)"
|
||||
:disabled="isImageRegenerating(idx)"
|
||||
v-tooltip.top="'Регенерировать изображение'"
|
||||
@click="handleRegenerateImage(post, idx)"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="mb-3 no-image-placeholder">
|
||||
<div v-else class="mb-3 no-image-placeholder" style="position: relative">
|
||||
<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>
|
||||
<Button
|
||||
icon="pi pi-refresh"
|
||||
size="small"
|
||||
severity="secondary"
|
||||
class="regenerate-image-btn"
|
||||
:loading="isImageRegenerating(idx)"
|
||||
:disabled="isImageRegenerating(idx)"
|
||||
v-tooltip.top="'Регенерировать изображение'"
|
||||
@click="handleRegenerateImage(post, idx)"
|
||||
/>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<p class="text-sm font-medium text-surface-600 dark:text-surface-400 mb-1">Текст поста:</p>
|
||||
@@ -329,6 +389,7 @@ 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
|
||||
const regeneratingImages = ref(new Set()); // Track which images are being regenerated (by post index)
|
||||
|
||||
// Computed
|
||||
const postsByDate = computed(() => {
|
||||
@@ -633,6 +694,117 @@ const isTaskExecuting = (taskId) => {
|
||||
return taskId ? executingTasks.value.has(taskId) : false;
|
||||
};
|
||||
|
||||
// Get post index in postCalendar array
|
||||
const getPostIndex = (post) => {
|
||||
if (!strategyData.value?.strategy?.postCalendar) return -1;
|
||||
return strategyData.value.strategy.postCalendar.findIndex((p) => p === post);
|
||||
};
|
||||
|
||||
// Check if image is regenerating
|
||||
const isImageRegenerating = (postIndex) => {
|
||||
return postIndex >= 0 && regeneratingImages.value.has(postIndex);
|
||||
};
|
||||
|
||||
// Handle regenerate image
|
||||
const handleRegenerateImage = async (post, postIndex) => {
|
||||
if (!strategyId.value || postIndex < 0) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Ошибка',
|
||||
detail: 'Не удалось определить параметры для регенерации изображения',
|
||||
life: 3000
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Add to regenerating set
|
||||
regeneratingImages.value.add(postIndex);
|
||||
|
||||
try {
|
||||
const result = await MarketingService.regenerateImage(strategyId.value, postIndex);
|
||||
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Изображение регенерируется',
|
||||
detail: 'Изображение успешно отправлено на регенерацию. Оно будет обновлено через несколько секунд.',
|
||||
life: 5000
|
||||
});
|
||||
|
||||
// Reload strategy data after a short delay to get updated image
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
const updatedStrategy = await MarketingService.getStrategy(strategyId.value);
|
||||
if (updatedStrategy.status === 'completed' && updatedStrategy.strategy) {
|
||||
// Update the post in strategyData
|
||||
if (updatedStrategy.strategy.postCalendar && updatedStrategy.strategy.postCalendar[postIndex]) {
|
||||
const updatedPost = updatedStrategy.strategy.postCalendar[postIndex];
|
||||
|
||||
// Update the post in current strategyData
|
||||
if (strategyData.value?.strategy?.postCalendar) {
|
||||
strategyData.value.strategy.postCalendar[postIndex] = updatedPost;
|
||||
}
|
||||
|
||||
// Clear image error for this post
|
||||
imageErrors.value.delete(postIndex);
|
||||
imageErrors.value.delete(`${post.publishDate}-${postIndex}`);
|
||||
|
||||
// Reload image if it has a filename
|
||||
if (updatedPost.imageFilename) {
|
||||
// Remove old blob URL if exists
|
||||
if (imageBlobUrls.value[updatedPost.imageFilename]) {
|
||||
URL.revokeObjectURL(imageBlobUrls.value[updatedPost.imageFilename]);
|
||||
}
|
||||
|
||||
// Load new image
|
||||
const blobUrl = await MarketingService.loadImageAsBlobUrl(updatedPost.imageFilename);
|
||||
if (blobUrl) {
|
||||
imageBlobUrls.value[updatedPost.imageFilename] = blobUrl;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка при обновлении стратегии после регенерации:', error);
|
||||
}
|
||||
}, 3000);
|
||||
} catch (error) {
|
||||
console.error('Ошибка при регенерации изображения:', error);
|
||||
|
||||
// Handle specific error types
|
||||
let errorMessage = error.message || 'Не удалось регенерировать изображение. Попробуйте позже.';
|
||||
let errorSummary = 'Ошибка';
|
||||
let errorLife = 5000;
|
||||
|
||||
if (error.code === 'RATE_LIMIT_EXCEEDED' || error.status === 429) {
|
||||
errorSummary = 'Превышен лимит запросов';
|
||||
errorMessage = error.message || 'Превышен лимит запросов к сервису генерации изображений. Пожалуйста, подождите несколько минут и попробуйте снова.';
|
||||
errorLife = 7000;
|
||||
} else if (error.code === 'UNAUTHORIZED' || error.status === 401) {
|
||||
errorSummary = 'Ошибка авторизации';
|
||||
errorMessage = 'Необходимо войти в систему';
|
||||
} else if (error.code === 'FORBIDDEN' || error.status === 403) {
|
||||
errorSummary = 'Доступ запрещен';
|
||||
errorMessage = 'У вас нет доступа к этой операции';
|
||||
} else if (error.code === 'NOT_FOUND' || error.status === 404) {
|
||||
errorSummary = 'Не найдено';
|
||||
errorMessage = 'Стратегия или пост не найдены';
|
||||
} else if (error.code === 'VALIDATION_ERROR' || error.status === 400) {
|
||||
errorSummary = 'Ошибка валидации';
|
||||
errorMessage = error.message || 'Проверьте правильность данных';
|
||||
}
|
||||
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: errorSummary,
|
||||
detail: errorMessage,
|
||||
life: errorLife
|
||||
});
|
||||
} finally {
|
||||
// Remove from regenerating set
|
||||
regeneratingImages.value.delete(postIndex);
|
||||
}
|
||||
};
|
||||
|
||||
// Export to CSV
|
||||
const exportToCsv = () => {
|
||||
if (!strategyData.value?.strategy?.postCalendar) return;
|
||||
@@ -920,4 +1092,12 @@ onBeforeUnmount(() => {
|
||||
background: var(--surface-800);
|
||||
border-color: var(--surface-700);
|
||||
}
|
||||
|
||||
.regenerate-image-btn {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
z-index: 10;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user