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
@@ -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>