This commit is contained in:
root
2026-01-03 13:26:31 +05:00
parent e186e36a74
commit 606f5af28d
12 changed files with 2768 additions and 517 deletions
+198
View File
@@ -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": "Женщины, 2534 года",
"share": "29%"
},
{
"name": "Мужчины, 2544 года",
"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": "Рекомендации фокусируют усилия на эффективности интернет-продвижения и служат базой для следующих шагов."
}
}
}
+193
View File
@@ -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>
+100
View File
@@ -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>
+71
View File
@@ -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>
+2
View File
@@ -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',
+6
View File
@@ -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',
+118 -2
View File
@@ -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
File diff suppressed because it is too large Load Diff
@@ -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>